From 5f8718e7b2755b9b9c21563af001b2fbf0322478 Mon Sep 17 00:00:00 2001 From: Valentin Date: Fri, 2 Jan 2026 20:17:40 +0100 Subject: [PATCH] first commit :D --- .dependencies | 34 + .editorconfig | 17 + .github/FUNDING.yml | 8 + .github/workflows/build.yaml | 81 + .github/workflows/tests.yaml | 68 + .github/workflows/trigger-skeletons.yml | 48 + .gitignore | 50 + .htaccess | 78 + .phan/config.php | 44 + .phan/internal_stubs/Redis.phan_php | 5153 +++++++++++++ .phan/internal_stubs/memcache.phan_php | 460 ++ .phan/internal_stubs/memcached.phan_php | 1308 ++++ .travis.yml | 96 + CHANGELOG.md | 4214 +++++++++++ CODE_OF_CONDUCT.md | 133 + CONTRIBUTING.md | 138 + LICENSE.txt | 21 + README.md | 156 + SECURITY.md | 38 + assets/.gitkeep | 1 + backup/.gitkeep | 1 + bin/composer.phar | Bin 0 -> 2977479 bytes bin/gpm | 46 + bin/grav | 42 + bin/plugin | 43 + cache/.gitkeep | 1 + codeception.yml | 16 + composer.json | 127 + composer.lock | 6549 +++++++++++++++++ images/.gitkeep | 1 + index.php | 51 + logs/.gitkeep | 1 + now.json | 4 + robots.txt | 21 + system/assets/debugger/clockwork.css | 61 + system/assets/debugger/clockwork.js | 37 + system/assets/debugger/phpdebugbar.css | 67 + system/assets/grav.png | Bin 0 -> 1612 bytes system/assets/jquery/jquery-2.1.4.min.js | 4 + system/assets/jquery/jquery-2.x.min.js | 4 + system/assets/jquery/jquery-3.x.min.js | 2 + system/assets/responsive-overlays/1x.png | Bin 0 -> 3238 bytes system/assets/responsive-overlays/2x.png | Bin 0 -> 7593 bytes system/assets/responsive-overlays/3x.png | Bin 0 -> 13002 bytes system/assets/responsive-overlays/4x.png | Bin 0 -> 15545 bytes system/assets/responsive-overlays/unknown.png | Bin 0 -> 5241 bytes system/assets/whoops.css | 19 + system/blueprints/config/backups.yaml | 125 + system/blueprints/config/media.yaml | 5 + system/blueprints/config/scheduler.yaml | 792 ++ system/blueprints/config/security.yaml | 119 + system/blueprints/config/site.yaml | 124 + system/blueprints/config/streams.yaml | 8 + system/blueprints/config/system.yaml | 1917 +++++ system/blueprints/flex/accounts.yaml | 8 + system/blueprints/flex/configure/compat.yaml | 17 + system/blueprints/flex/pages.yaml | 212 + system/blueprints/flex/shared/configure.yaml | 70 + system/blueprints/flex/user-accounts.yaml | 155 + system/blueprints/flex/user-groups.yaml | 124 + system/blueprints/pages/default.yaml | 381 + system/blueprints/pages/external.yaml | 52 + system/blueprints/pages/modular.yaml | 36 + .../blueprints/pages/partials/security.yaml | 67 + system/blueprints/pages/root.yaml | 16 + system/blueprints/user/account.yaml | 157 + system/blueprints/user/account_new.yaml | 18 + system/blueprints/user/group.yaml | 55 + system/blueprints/user/group_new.yaml | 23 + system/config/backups.yaml | 15 + system/config/media.yaml | 223 + system/config/mime.yaml | 1986 +++++ system/config/permissions.yaml | 53 + system/config/scheduler.yaml | 68 + system/config/security.yaml | 47 + system/config/site.yaml | 35 + system/config/system.yaml | 235 + system/defines.php | 104 + system/images/media/thumb-3dm.png | Bin 0 -> 3013 bytes system/images/media/thumb-3ds.png | Bin 0 -> 3116 bytes system/images/media/thumb-3g2.png | Bin 0 -> 3006 bytes system/images/media/thumb-3gp.png | Bin 0 -> 2853 bytes system/images/media/thumb-7z.png | Bin 0 -> 1648 bytes system/images/media/thumb-aac.png | Bin 0 -> 3218 bytes system/images/media/thumb-ai.png | Bin 0 -> 1595 bytes system/images/media/thumb-aif.png | Bin 0 -> 1691 bytes system/images/media/thumb-apk.png | Bin 0 -> 2557 bytes system/images/media/thumb-app.png | Bin 0 -> 2360 bytes system/images/media/thumb-asf.png | Bin 0 -> 2635 bytes system/images/media/thumb-asp.png | Bin 0 -> 2913 bytes system/images/media/thumb-aspx.png | Bin 0 -> 3726 bytes system/images/media/thumb-asx.png | Bin 0 -> 3287 bytes system/images/media/thumb-avi.png | Bin 0 -> 2448 bytes system/images/media/thumb-bak.png | Bin 0 -> 2814 bytes system/images/media/thumb-bat.png | Bin 0 -> 2182 bytes system/images/media/thumb-bin.png | Bin 0 -> 1841 bytes system/images/media/thumb-bmp.png | Bin 0 -> 2573 bytes system/images/media/thumb-cab.png | Bin 0 -> 3056 bytes system/images/media/thumb-cad.png | Bin 0 -> 3021 bytes system/images/media/thumb-cdr.png | Bin 0 -> 2713 bytes system/images/media/thumb-cer.png | Bin 0 -> 2367 bytes system/images/media/thumb-cfg.png | Bin 0 -> 2764 bytes system/images/media/thumb-cfm.png | Bin 0 -> 2589 bytes system/images/media/thumb-cgi.png | Bin 0 -> 2626 bytes system/images/media/thumb-com.png | Bin 0 -> 3292 bytes system/images/media/thumb-cpl.png | Bin 0 -> 2174 bytes system/images/media/thumb-cpp.png | Bin 0 -> 2425 bytes system/images/media/thumb-crx.png | Bin 0 -> 2965 bytes system/images/media/thumb-csr.png | Bin 0 -> 3158 bytes system/images/media/thumb-css.png | Bin 0 -> 3235 bytes system/images/media/thumb-csv.png | Bin 0 -> 3317 bytes system/images/media/thumb-cue.png | Bin 0 -> 2271 bytes system/images/media/thumb-cur.png | Bin 0 -> 2716 bytes system/images/media/thumb-dat.png | Bin 0 -> 2188 bytes system/images/media/thumb-db.png | Bin 0 -> 1964 bytes system/images/media/thumb-dbf.png | Bin 0 -> 1947 bytes system/images/media/thumb-dds.png | Bin 0 -> 2815 bytes system/images/media/thumb-dem.png | Bin 0 -> 2215 bytes system/images/media/thumb-dll.png | Bin 0 -> 1352 bytes system/images/media/thumb-dmg.png | Bin 0 -> 3064 bytes system/images/media/thumb-dmp.png | Bin 0 -> 2527 bytes system/images/media/thumb-doc.png | Bin 0 -> 3051 bytes system/images/media/thumb-docx.png | Bin 0 -> 3865 bytes system/images/media/thumb-drv.png | Bin 0 -> 2728 bytes system/images/media/thumb-dtd.png | Bin 0 -> 1949 bytes system/images/media/thumb-dwg.png | Bin 0 -> 3530 bytes system/images/media/thumb-dxf.png | Bin 0 -> 2392 bytes system/images/media/thumb-elf.png | Bin 0 -> 663 bytes system/images/media/thumb-eot.png | Bin 0 -> 2002 bytes system/images/media/thumb-eps.png | Bin 0 -> 2276 bytes system/images/media/thumb-exe.png | Bin 0 -> 1813 bytes system/images/media/thumb-fla.png | Bin 0 -> 1724 bytes system/images/media/thumb-flv.png | Bin 0 -> 1720 bytes system/images/media/thumb-fnt.png | Bin 0 -> 1254 bytes system/images/media/thumb-fon.png | Bin 0 -> 2402 bytes system/images/media/thumb-gam.png | Bin 0 -> 3203 bytes system/images/media/thumb-gbr.png | Bin 0 -> 2849 bytes system/images/media/thumb-ged.png | Bin 0 -> 2269 bytes system/images/media/thumb-gif.png | Bin 0 -> 1751 bytes system/images/media/thumb-gpx.png | Bin 0 -> 2972 bytes system/images/media/thumb-gz.png | Bin 0 -> 2134 bytes system/images/media/thumb-gzip.png | Bin 0 -> 2451 bytes system/images/media/thumb-hqz.png | Bin 0 -> 2604 bytes system/images/media/thumb-html.png | Bin 0 -> 1740 bytes system/images/media/thumb-icns.png | Bin 0 -> 3212 bytes system/images/media/thumb-ico.png | Bin 0 -> 2769 bytes system/images/media/thumb-ics.png | Bin 0 -> 2787 bytes system/images/media/thumb-iff.png | Bin 0 -> 601 bytes system/images/media/thumb-indd.png | Bin 0 -> 2475 bytes system/images/media/thumb-iso.png | Bin 0 -> 2864 bytes system/images/media/thumb-jar.png | Bin 0 -> 2583 bytes system/images/media/thumb-jpg.png | Bin 0 -> 2435 bytes system/images/media/thumb-js.png | Bin 0 -> 2046 bytes system/images/media/thumb-json.png | Bin 0 -> 7818 bytes system/images/media/thumb-jsp.png | Bin 0 -> 2498 bytes system/images/media/thumb-key.png | Bin 0 -> 2130 bytes system/images/media/thumb-kml.png | Bin 0 -> 2346 bytes system/images/media/thumb-kmz.png | Bin 0 -> 2701 bytes system/images/media/thumb-lnk.png | Bin 0 -> 1971 bytes system/images/media/thumb-log.png | Bin 0 -> 2762 bytes system/images/media/thumb-lua.png | Bin 0 -> 2117 bytes system/images/media/thumb-m3u.png | Bin 0 -> 2909 bytes system/images/media/thumb-m4a.png | Bin 0 -> 2754 bytes system/images/media/thumb-m4v.png | Bin 0 -> 2738 bytes system/images/media/thumb-max.png | Bin 0 -> 3213 bytes system/images/media/thumb-mdb.png | Bin 0 -> 2691 bytes system/images/media/thumb-mdf.png | Bin 0 -> 2243 bytes system/images/media/thumb-mid.png | Bin 0 -> 2199 bytes system/images/media/thumb-mim.png | Bin 0 -> 2284 bytes system/images/media/thumb-mov.png | Bin 0 -> 3221 bytes system/images/media/thumb-mp3.png | Bin 0 -> 2801 bytes system/images/media/thumb-mp4.png | Bin 0 -> 2221 bytes system/images/media/thumb-mpa.png | Bin 0 -> 2734 bytes system/images/media/thumb-mpe.png | Bin 0 -> 1971 bytes system/images/media/thumb-mpg.png | Bin 0 -> 2811 bytes system/images/media/thumb-msg.png | Bin 0 -> 3319 bytes system/images/media/thumb-msi.png | Bin 0 -> 2594 bytes system/images/media/thumb-nes.png | Bin 0 -> 2320 bytes system/images/media/thumb-obj.png | Bin 0 -> 2716 bytes system/images/media/thumb-odb.png | Bin 0 -> 2912 bytes system/images/media/thumb-odc.png | Bin 0 -> 3239 bytes system/images/media/thumb-odf.png | Bin 0 -> 2496 bytes system/images/media/thumb-odg.png | Bin 0 -> 3069 bytes system/images/media/thumb-odi.png | Bin 0 -> 2453 bytes system/images/media/thumb-odp.png | Bin 0 -> 2871 bytes system/images/media/thumb-ods.png | Bin 0 -> 3257 bytes system/images/media/thumb-odt.png | Bin 0 -> 2414 bytes system/images/media/thumb-odx.png | Bin 0 -> 3133 bytes system/images/media/thumb-ogg.png | Bin 0 -> 3577 bytes system/images/media/thumb-pct.png | Bin 0 -> 2248 bytes system/images/media/thumb-pdb.png | Bin 0 -> 2354 bytes system/images/media/thumb-pdf.png | Bin 0 -> 1823 bytes system/images/media/thumb-pif.png | Bin 0 -> 1103 bytes system/images/media/thumb-pkg.png | Bin 0 -> 2660 bytes system/images/media/thumb-pl.png | Bin 0 -> 1066 bytes system/images/media/thumb-png.png | Bin 0 -> 2530 bytes system/images/media/thumb-pps.png | Bin 0 -> 2497 bytes system/images/media/thumb-ppt.png | Bin 0 -> 1573 bytes system/images/media/thumb-pptx.png | Bin 0 -> 2560 bytes system/images/media/thumb-ps.png | Bin 0 -> 2285 bytes system/images/media/thumb-psd.png | Bin 0 -> 2613 bytes system/images/media/thumb-pub.png | Bin 0 -> 2137 bytes system/images/media/thumb-py.png | Bin 0 -> 1776 bytes system/images/media/thumb-ra.png | Bin 0 -> 2223 bytes system/images/media/thumb-rar.png | Bin 0 -> 2695 bytes system/images/media/thumb-raw.png | Bin 0 -> 3433 bytes system/images/media/thumb-rm.png | Bin 0 -> 2181 bytes system/images/media/thumb-rom.png | Bin 0 -> 3088 bytes system/images/media/thumb-rpm.png | Bin 0 -> 2566 bytes system/images/media/thumb-rss.png | Bin 0 -> 3091 bytes system/images/media/thumb-rtf.png | Bin 0 -> 1364 bytes system/images/media/thumb-sav.png | Bin 0 -> 3204 bytes system/images/media/thumb-sdf.png | Bin 0 -> 2444 bytes system/images/media/thumb-sql.png | Bin 0 -> 2929 bytes system/images/media/thumb-srt.png | Bin 0 -> 2366 bytes system/images/media/thumb-svg.png | Bin 0 -> 3359 bytes system/images/media/thumb-swf.png | Bin 0 -> 3181 bytes system/images/media/thumb-sys.png | Bin 0 -> 3128 bytes system/images/media/thumb-tar.png | Bin 0 -> 2165 bytes system/images/media/thumb-tex.png | Bin 0 -> 1716 bytes system/images/media/thumb-tga.png | Bin 0 -> 2655 bytes system/images/media/thumb-thm.png | Bin 0 -> 1666 bytes system/images/media/thumb-tiff.png | Bin 0 -> 690 bytes system/images/media/thumb-tmp.png | Bin 0 -> 2040 bytes system/images/media/thumb-ttf.png | Bin 0 -> 615 bytes system/images/media/thumb-txt.png | Bin 0 -> 1853 bytes system/images/media/thumb-uue.png | Bin 0 -> 1632 bytes system/images/media/thumb-vb.png | Bin 0 -> 2074 bytes system/images/media/thumb-vcd.png | Bin 0 -> 3040 bytes system/images/media/thumb-vcf.png | Bin 0 -> 2581 bytes system/images/media/thumb-wav.png | Bin 0 -> 3601 bytes system/images/media/thumb-webm.png | Bin 0 -> 3843 bytes system/images/media/thumb-wma.png | Bin 0 -> 3552 bytes system/images/media/thumb-wmv.png | Bin 0 -> 3789 bytes system/images/media/thumb-woff.png | Bin 0 -> 3421 bytes system/images/media/thumb-woff2.png | Bin 0 -> 3927 bytes system/images/media/thumb-wpd.png | Bin 0 -> 3127 bytes system/images/media/thumb-wps.png | Bin 0 -> 3368 bytes system/images/media/thumb-wsf.png | Bin 0 -> 3028 bytes system/images/media/thumb-xls.png | Bin 0 -> 2562 bytes system/images/media/thumb-xlsx.png | Bin 0 -> 3481 bytes system/images/media/thumb-xml.png | Bin 0 -> 2557 bytes system/images/media/thumb-yuv.png | Bin 0 -> 2741 bytes system/images/media/thumb-zip.png | Bin 0 -> 1628 bytes system/images/media/thumb.png | Bin 0 -> 1200 bytes system/images/watermark.png | Bin 0 -> 95789 bytes system/install.php | 15 + system/languages/ar.yaml | 93 + system/languages/bg.yaml | 72 + system/languages/ca.yaml | 87 + system/languages/cs.yaml | 147 + system/languages/da.yaml | 90 + system/languages/de.yaml | 147 + system/languages/el.yaml | 144 + system/languages/en.yaml | 121 + system/languages/eo.yaml | 40 + system/languages/es.yaml | 107 + system/languages/et.yaml | 108 + system/languages/eu.yaml | 62 + system/languages/fa.yaml | 62 + system/languages/fi.yaml | 134 + system/languages/fr.yaml | 147 + system/languages/gl.yaml | 147 + system/languages/he.yaml | 99 + system/languages/hr.yaml | 104 + system/languages/hu.yaml | 97 + system/languages/id.yaml | 147 + system/languages/is.yaml | 80 + system/languages/it.yaml | 147 + system/languages/ja.yaml | 81 + system/languages/ko.yaml | 90 + system/languages/lt.yaml | 78 + system/languages/lv.yaml | 84 + system/languages/mn.yaml | 147 + system/languages/my.yaml | 147 + system/languages/nb.yaml | 4 + system/languages/nl.yaml | 147 + system/languages/no.yaml | 82 + system/languages/pl.yaml | 100 + system/languages/pt.yaml | 147 + system/languages/ro.yaml | 96 + system/languages/ru.yaml | 114 + system/languages/si.yaml | 120 + system/languages/sk.yaml | 144 + system/languages/sl.yaml | 85 + system/languages/sr.yaml | 147 + system/languages/sv.yaml | 100 + system/languages/sw.yaml | 147 + system/languages/th.yaml | 147 + system/languages/tr.yaml | 100 + system/languages/uk.yaml | 63 + system/languages/vi.yaml | 63 + system/languages/zh-cn.yaml | 146 + system/languages/zh-tw.yaml | 79 + system/languages/zh.yaml | 146 + system/pages/notfound.md | 6 + system/router.php | 55 + system/src/DOMLettersIterator.php | 165 + system/src/DOMWordsIterator.php | 158 + system/src/Grav/Common/Assets.php | 595 ++ system/src/Grav/Common/Assets/BaseAsset.php | 283 + system/src/Grav/Common/Assets/BlockAssets.php | 207 + system/src/Grav/Common/Assets/Css.php | 52 + system/src/Grav/Common/Assets/InlineCss.php | 44 + system/src/Grav/Common/Assets/InlineJs.php | 44 + .../src/Grav/Common/Assets/InlineJsModule.php | 46 + system/src/Grav/Common/Assets/Js.php | 48 + system/src/Grav/Common/Assets/JsModule.php | 49 + system/src/Grav/Common/Assets/Link.php | 43 + system/src/Grav/Common/Assets/Pipeline.php | 347 + .../Common/Assets/Traits/AssetUtilsTrait.php | 215 + .../Assets/Traits/LegacyAssetsTrait.php | 137 + .../Assets/Traits/TestingAssetsTrait.php | 350 + system/src/Grav/Common/Backup/Backups.php | 325 + system/src/Grav/Common/Browser.php | 153 + system/src/Grav/Common/Cache.php | 743 ++ system/src/Grav/Common/Composer.php | 67 + .../src/Grav/Common/Config/CompiledBase.php | 269 + .../Grav/Common/Config/CompiledBlueprints.php | 131 + .../src/Grav/Common/Config/CompiledConfig.php | 114 + .../Grav/Common/Config/CompiledLanguages.php | 83 + system/src/Grav/Common/Config/Config.php | 156 + .../Grav/Common/Config/ConfigFileFinder.php | 273 + system/src/Grav/Common/Config/Languages.php | 107 + system/src/Grav/Common/Config/Setup.php | 423 ++ system/src/Grav/Common/Data/Blueprint.php | 594 ++ .../src/Grav/Common/Data/BlueprintSchema.php | 461 ++ system/src/Grav/Common/Data/Blueprints.php | 121 + system/src/Grav/Common/Data/Data.php | 343 + system/src/Grav/Common/Data/DataInterface.php | 84 + system/src/Grav/Common/Data/Validation.php | 1238 ++++ .../Grav/Common/Data/ValidationException.php | 67 + system/src/Grav/Common/Debugger.php | 1148 +++ system/src/Grav/Common/Errors/BareHandler.php | 33 + system/src/Grav/Common/Errors/Errors.php | 85 + .../Grav/Common/Errors/Resources/error.css | 52 + .../Common/Errors/Resources/layout.html.php | 30 + .../Grav/Common/Errors/SimplePageHandler.php | 122 + .../src/Grav/Common/Errors/SystemFacade.php | 67 + system/src/Grav/Common/File/CompiledFile.php | 195 + .../src/Grav/Common/File/CompiledJsonFile.php | 33 + .../Grav/Common/File/CompiledMarkdownFile.php | 21 + .../src/Grav/Common/File/CompiledYamlFile.php | 21 + .../src/Grav/Common/Filesystem/Archiver.php | 108 + system/src/Grav/Common/Filesystem/Folder.php | 548 ++ .../RecursiveDirectoryFilterIterator.php | 119 + .../RecursiveFolderFilterIterator.php | 55 + .../Grav/Common/Filesystem/ZipArchiver.php | 166 + .../src/Grav/Common/Flex/FlexCollection.php | 28 + system/src/Grav/Common/Flex/FlexIndex.php | 29 + system/src/Grav/Common/Flex/FlexObject.php | 74 + .../Flex/Traits/FlexCollectionTrait.php | 51 + .../Common/Flex/Traits/FlexCommonTrait.php | 54 + .../Grav/Common/Flex/Traits/FlexGravTrait.php | 74 + .../Common/Flex/Traits/FlexIndexTrait.php | 20 + .../Common/Flex/Traits/FlexObjectTrait.php | 62 + .../Flex/Types/Generic/GenericCollection.php | 24 + .../Flex/Types/Generic/GenericIndex.php | 24 + .../Flex/Types/Generic/GenericObject.php | 22 + .../Flex/Types/Pages/PageCollection.php | 839 +++ .../Common/Flex/Types/Pages/PageIndex.php | 1198 +++ .../Common/Flex/Types/Pages/PageObject.php | 744 ++ .../Flex/Types/Pages/Storage/PageStorage.php | 700 ++ .../Types/Pages/Traits/PageContentTrait.php | 75 + .../Types/Pages/Traits/PageLegacyTrait.php | 236 + .../Types/Pages/Traits/PageRoutableTrait.php | 122 + .../Types/Pages/Traits/PageTranslateTrait.php | 108 + .../Types/UserGroups/UserGroupCollection.php | 56 + .../Flex/Types/UserGroups/UserGroupIndex.php | 24 + .../Flex/Types/UserGroups/UserGroupObject.php | 134 + .../Types/Users/Storage/UserFileStorage.php | 47 + .../Types/Users/Storage/UserFolderStorage.php | 37 + .../Users/Traits/UserObjectLegacyTrait.php | 94 + .../Flex/Types/Users/UserCollection.php | 135 + .../Common/Flex/Types/Users/UserIndex.php | 206 + .../Common/Flex/Types/Users/UserObject.php | 1059 +++ system/src/Grav/Common/Form/FormFlash.php | 107 + .../Grav/Common/GPM/AbstractCollection.php | 41 + .../GPM/Common/AbstractPackageCollection.php | 50 + .../Common/GPM/Common/CachedCollection.php | 43 + system/src/Grav/Common/GPM/Common/Package.php | 99 + system/src/Grav/Common/GPM/GPM.php | 1270 ++++ system/src/Grav/Common/GPM/Installer.php | 544 ++ system/src/Grav/Common/GPM/Licenses.php | 116 + .../GPM/Local/AbstractPackageCollection.php | 34 + system/src/Grav/Common/GPM/Local/Package.php | 51 + system/src/Grav/Common/GPM/Local/Packages.php | 29 + system/src/Grav/Common/GPM/Local/Plugins.php | 33 + system/src/Grav/Common/GPM/Local/Themes.php | 33 + .../GPM/Remote/AbstractPackageCollection.php | 81 + .../src/Grav/Common/GPM/Remote/GravCore.php | 151 + system/src/Grav/Common/GPM/Remote/Package.php | 66 + .../src/Grav/Common/GPM/Remote/Packages.php | 34 + system/src/Grav/Common/GPM/Remote/Plugins.php | 32 + system/src/Grav/Common/GPM/Remote/Themes.php | 32 + system/src/Grav/Common/GPM/Response.php | 3 + system/src/Grav/Common/GPM/Upgrader.php | 138 + system/src/Grav/Common/Getters.php | 170 + system/src/Grav/Common/Grav.php | 829 +++ system/src/Grav/Common/GravTrait.php | 34 + system/src/Grav/Common/HTTP/Client.php | 130 + system/src/Grav/Common/HTTP/Response.php | 96 + system/src/Grav/Common/Helpers/Base32.php | 141 + system/src/Grav/Common/Helpers/Excerpts.php | 196 + system/src/Grav/Common/Helpers/Exif.php | 48 + system/src/Grav/Common/Helpers/LogViewer.php | 167 + system/src/Grav/Common/Helpers/Truncator.php | 344 + system/src/Grav/Common/Helpers/YamlLinter.php | 122 + system/src/Grav/Common/Inflector.php | 363 + system/src/Grav/Common/Iterator.php | 264 + system/src/Grav/Common/Language/Language.php | 663 ++ .../Grav/Common/Language/LanguageCodes.php | 246 + system/src/Grav/Common/Markdown/Parsedown.php | 43 + .../Grav/Common/Markdown/ParsedownExtra.php | 46 + .../Common/Markdown/ParsedownGravTrait.php | 319 + .../Media/Interfaces/AudioMediaInterface.php | 25 + .../Interfaces/ImageManipulateInterface.php | 120 + .../Media/Interfaces/ImageMediaInterface.php | 17 + .../Interfaces/MediaCollectionInterface.php | 115 + .../Media/Interfaces/MediaFileInterface.php | 53 + .../Media/Interfaces/MediaInterface.php | 17 + .../Media/Interfaces/MediaLinkInterface.php | 17 + .../Media/Interfaces/MediaObjectInterface.php | 227 + .../Media/Interfaces/MediaPlayerInterface.php | 56 + .../Media/Interfaces/MediaUploadInterface.php | 73 + .../Media/Interfaces/VideoMediaInterface.php | 32 + .../Common/Media/Traits/AudioMediaTrait.php | 53 + .../Media/Traits/ImageDecodingTrait.php | 40 + .../Media/Traits/ImageFetchPriorityTrait.php | 40 + .../Common/Media/Traits/ImageLoadingTrait.php | 37 + .../Common/Media/Traits/ImageMediaTrait.php | 428 ++ .../Common/Media/Traits/MediaFileTrait.php | 139 + .../Common/Media/Traits/MediaObjectTrait.php | 630 ++ .../Common/Media/Traits/MediaPlayerTrait.php | 113 + .../Grav/Common/Media/Traits/MediaTrait.php | 153 + .../Common/Media/Traits/MediaUploadTrait.php | 680 ++ .../Common/Media/Traits/StaticResizeTrait.php | 40 + .../Media/Traits/ThumbnailMediaTrait.php | 149 + .../Common/Media/Traits/VideoMediaTrait.php | 68 + system/src/Grav/Common/Page/Collection.php | 710 ++ system/src/Grav/Common/Page/Header.php | 38 + .../Interfaces/PageCollectionInterface.php | 310 + .../Page/Interfaces/PageContentInterface.php | 267 + .../Page/Interfaces/PageFormInterface.php | 33 + .../Common/Page/Interfaces/PageInterface.php | 25 + .../Page/Interfaces/PageLegacyInterface.php | 475 ++ .../Page/Interfaces/PageRoutableInterface.php | 180 + .../Interfaces/PageTranslateInterface.php | 38 + .../Page/Interfaces/PagesSourceInterface.php | 56 + .../Grav/Common/Page/Markdown/Excerpts.php | 343 + system/src/Grav/Common/Page/Media.php | 286 + .../Grav/Common/Page/Medium/AbstractMedia.php | 344 + .../Grav/Common/Page/Medium/AudioMedium.php | 36 + .../Grav/Common/Page/Medium/GlobalMedia.php | 150 + .../src/Grav/Common/Page/Medium/ImageFile.php | 235 + .../Grav/Common/Page/Medium/ImageMedium.php | 499 ++ system/src/Grav/Common/Page/Medium/Link.php | 102 + system/src/Grav/Common/Page/Medium/Medium.php | 140 + .../Grav/Common/Page/Medium/MediumFactory.php | 220 + .../Common/Page/Medium/ParsedownHtmlTrait.php | 44 + .../Page/Medium/RenderableInterface.php | 41 + .../Common/Page/Medium/StaticImageMedium.php | 48 + .../Common/Page/Medium/StaticResizeTrait.php | 24 + .../Page/Medium/ThumbnailImageMedium.php | 21 + .../Common/Page/Medium/VectorImageMedium.php | 68 + .../Grav/Common/Page/Medium/VideoMedium.php | 36 + system/src/Grav/Common/Page/Page.php | 2935 ++++++++ system/src/Grav/Common/Page/Pages.php | 2258 ++++++ .../Grav/Common/Page/Traits/PageFormTrait.php | 126 + system/src/Grav/Common/Page/Types.php | 179 + system/src/Grav/Common/Plugin.php | 472 ++ system/src/Grav/Common/Plugins.php | 330 + .../Common/Processors/AssetsProcessor.php | 41 + .../Common/Processors/BackupsProcessor.php | 41 + .../Processors/DebuggerAssetsProcessor.php | 40 + .../Processors/Events/RequestHandlerEvent.php | 82 + .../Common/Processors/InitializeProcessor.php | 461 ++ .../Grav/Common/Processors/PagesProcessor.php | 115 + .../Common/Processors/PluginsProcessor.php | 41 + .../Grav/Common/Processors/ProcessorBase.php | 70 + .../Common/Processors/ProcessorInterface.php | 20 + .../Common/Processors/RenderProcessor.php | 71 + .../Common/Processors/RequestProcessor.php | 66 + .../Common/Processors/SchedulerProcessor.php | 42 + .../Grav/Common/Processors/TasksProcessor.php | 71 + .../Common/Processors/ThemesProcessor.php | 40 + .../Grav/Common/Processors/TwigProcessor.php | 40 + system/src/Grav/Common/Scheduler/Cron.php | 577 ++ .../Grav/Common/Scheduler/IntervalTrait.php | 404 + system/src/Grav/Common/Scheduler/Job.php | 1073 +++ .../src/Grav/Common/Scheduler/JobHistory.php | 462 ++ system/src/Grav/Common/Scheduler/JobQueue.php | 588 ++ .../src/Grav/Common/Scheduler/Scheduler.php | 1108 +++ .../Common/Scheduler/SchedulerController.php | 270 + system/src/Grav/Common/Security.php | 287 + .../Service/AccountsServiceProvider.php | 157 + .../Common/Service/AssetsServiceProvider.php | 32 + .../Common/Service/BackupsServiceProvider.php | 35 + .../Common/Service/ConfigServiceProvider.php | 206 + .../Common/Service/ErrorServiceProvider.php | 30 + .../Service/FilesystemServiceProvider.php | 32 + .../Common/Service/FlexServiceProvider.php | 121 + .../Service/InflectorServiceProvider.php | 32 + .../Common/Service/LoggerServiceProvider.php | 42 + .../Common/Service/OutputServiceProvider.php | 39 + .../Common/Service/PagesServiceProvider.php | 140 + .../Common/Service/RequestServiceProvider.php | 103 + .../Service/SchedulerServiceProvider.php | 64 + .../Common/Service/SessionServiceProvider.php | 134 + .../Common/Service/StreamsServiceProvider.php | 56 + .../Common/Service/TaskServiceProvider.php | 55 + system/src/Grav/Common/Session.php | 202 + system/src/Grav/Common/Taxonomy.php | 181 + system/src/Grav/Common/Theme.php | 87 + system/src/Grav/Common/Themes.php | 417 ++ .../Common/Twig/Exception/TwigException.php | 21 + .../Twig/Extension/FilesystemExtension.php | 387 + .../Common/Twig/Extension/GravExtension.php | 1756 +++++ .../Grav/Common/Twig/Node/TwigNodeCache.php | 93 + .../Grav/Common/Twig/Node/TwigNodeLink.php | 114 + .../Common/Twig/Node/TwigNodeMarkdown.php | 52 + .../Grav/Common/Twig/Node/TwigNodeRender.php | 84 + .../Grav/Common/Twig/Node/TwigNodeScript.php | 142 + .../Grav/Common/Twig/Node/TwigNodeStyle.php | 133 + .../Grav/Common/Twig/Node/TwigNodeSwitch.php | 88 + .../Grav/Common/Twig/Node/TwigNodeThrow.php | 52 + .../Common/Twig/Node/TwigNodeTryCatch.php | 67 + .../Twig/TokenParser/TwigTokenParserCache.php | 74 + .../Twig/TokenParser/TwigTokenParserLink.php | 109 + .../TokenParser/TwigTokenParserMarkdown.php | 59 + .../TokenParser/TwigTokenParserRender.php | 74 + .../TokenParser/TwigTokenParserScript.php | 132 + .../Twig/TokenParser/TwigTokenParserStyle.php | 119 + .../TokenParser/TwigTokenParserSwitch.php | 132 + .../Twig/TokenParser/TwigTokenParserThrow.php | 55 + .../TokenParser/TwigTokenParserTryCatch.php | 81 + system/src/Grav/Common/Twig/Twig.php | 578 ++ .../Common/Twig/TwigClockworkDataSource.php | 58 + .../Grav/Common/Twig/TwigClockworkDumper.php | 72 + .../src/Grav/Common/Twig/TwigEnvironment.php | 60 + system/src/Grav/Common/Twig/TwigExtension.php | 21 + .../Grav/Common/Twig/WriteCacheFileTrait.php | 56 + system/src/Grav/Common/Uri.php | 1527 ++++ system/src/Grav/Common/User/Access.php | 52 + .../src/Grav/Common/User/Authentication.php | 61 + system/src/Grav/Common/User/DataUser/User.php | 329 + .../Common/User/DataUser/UserCollection.php | 163 + system/src/Grav/Common/User/Group.php | 172 + .../User/Interfaces/AuthorizeInterface.php | 26 + .../Interfaces/UserCollectionInterface.php | 40 + .../User/Interfaces/UserGroupInterface.php | 18 + .../Common/User/Interfaces/UserInterface.php | 189 + .../src/Grav/Common/User/Traits/UserTrait.php | 233 + system/src/Grav/Common/User/User.php | 144 + system/src/Grav/Common/Utils.php | 2227 ++++++ system/src/Grav/Common/Yaml.php | 65 + .../Grav/Console/Application/Application.php | 138 + .../CommandLoader/PluginCommandLoader.php | 103 + .../Console/Application/GpmApplication.php | 42 + .../Console/Application/GravApplication.php | 52 + .../Console/Application/PluginApplication.php | 116 + system/src/Grav/Console/Cli/BackupCommand.php | 138 + system/src/Grav/Console/Cli/CleanCommand.php | 411 ++ .../Grav/Console/Cli/ClearCacheCommand.php | 104 + .../src/Grav/Console/Cli/ComposerCommand.php | 64 + .../src/Grav/Console/Cli/InstallCommand.php | 302 + .../src/Grav/Console/Cli/LogViewerCommand.php | 96 + .../Grav/Console/Cli/NewProjectCommand.php | 75 + .../Cli/PageSystemValidatorCommand.php | 299 + .../src/Grav/Console/Cli/SandboxCommand.php | 347 + .../src/Grav/Console/Cli/SchedulerCommand.php | 276 + .../src/Grav/Console/Cli/SecurityCommand.php | 102 + system/src/Grav/Console/Cli/ServerCommand.php | 154 + .../Grav/Console/Cli/YamlLinterCommand.php | 124 + system/src/Grav/Console/ConsoleCommand.php | 46 + system/src/Grav/Console/ConsoleTrait.php | 338 + .../Grav/Console/Gpm/DirectInstallCommand.php | 321 + system/src/Grav/Console/Gpm/IndexCommand.php | 335 + system/src/Grav/Console/Gpm/InfoCommand.php | 191 + .../src/Grav/Console/Gpm/InstallCommand.php | 726 ++ .../Grav/Console/Gpm/SelfupgradeCommand.php | 344 + .../src/Grav/Console/Gpm/UninstallCommand.php | 312 + system/src/Grav/Console/Gpm/UpdateCommand.php | 289 + .../src/Grav/Console/Gpm/VersionCommand.php | 125 + system/src/Grav/Console/GpmCommand.php | 68 + system/src/Grav/Console/GravCommand.php | 52 + .../Grav/Console/Plugin/PluginListCommand.php | 69 + .../Grav/Console/TerminalObjects/Table.php | 38 + .../Grav/Events/BeforeSessionStartEvent.php | 36 + system/src/Grav/Events/FlexRegisterEvent.php | 45 + system/src/Grav/Events/PageEvent.php | 18 + .../Grav/Events/PermissionsRegisterEvent.php | 45 + system/src/Grav/Events/PluginsLoadedEvent.php | 53 + system/src/Grav/Events/SessionStartEvent.php | 36 + system/src/Grav/Events/TypesEvent.php | 18 + system/src/Grav/Framework/Acl/Access.php | 231 + system/src/Grav/Framework/Acl/Action.php | 204 + system/src/Grav/Framework/Acl/Permissions.php | 249 + .../Grav/Framework/Acl/PermissionsReader.php | 186 + .../Framework/Acl/RecursiveActionIterator.php | 64 + .../Grav/Framework/Cache/AbstractCache.php | 32 + .../Framework/Cache/Adapter/ChainCache.php | 210 + .../Framework/Cache/Adapter/DoctrineCache.php | 118 + .../Framework/Cache/Adapter/FileCache.php | 266 + .../Framework/Cache/Adapter/MemoryCache.php | 83 + .../Framework/Cache/Adapter/SessionCache.php | 107 + .../Grav/Framework/Cache/CacheInterface.php | 71 + .../src/Grav/Framework/Cache/CacheTrait.php | 373 + .../Cache/Exception/CacheException.php | 21 + .../Exception/InvalidArgumentException.php | 20 + .../Collection/AbstractFileCollection.php | 238 + .../Collection/AbstractIndexCollection.php | 574 ++ .../Collection/AbstractLazyCollection.php | 97 + .../Framework/Collection/ArrayCollection.php | 117 + .../Collection/CollectionInterface.php | 69 + .../Framework/Collection/FileCollection.php | 97 + .../Collection/FileCollectionInterface.php | 33 + .../Grav/Framework/Compat/Serializable.php | 47 + .../Framework/ContentBlock/ContentBlock.php | 303 + .../ContentBlock/ContentBlockInterface.php | 90 + .../Grav/Framework/ContentBlock/HtmlBlock.php | 502 ++ .../ContentBlock/HtmlBlockInterface.php | 130 + .../Contracts/Media/MediaObjectInterface.php | 52 + .../Contracts/Object/IdentifierInterface.php | 27 + .../RelationshipIdentifierInterface.php | 28 + .../Relationships/RelationshipInterface.php | 81 + .../Relationships/RelationshipsInterface.php | 53 + .../ToManyRelationshipInterface.php | 55 + .../ToOneRelationshipInterface.php | 37 + .../Traits/ControllerResponseTrait.php | 307 + system/src/Grav/Framework/DI/Container.php | 35 + .../src/Grav/Framework/File/AbstractFile.php | 444 ++ system/src/Grav/Framework/File/CsvFile.php | 40 + system/src/Grav/Framework/File/DataFile.php | 78 + system/src/Grav/Framework/File/File.php | 35 + .../File/Formatter/AbstractFormatter.php | 117 + .../Framework/File/Formatter/CsvFormatter.php | 170 + .../File/Formatter/FormatterInterface.php | 12 + .../Framework/File/Formatter/IniFormatter.php | 68 + .../File/Formatter/JsonFormatter.php | 170 + .../File/Formatter/MarkdownFormatter.php | 161 + .../File/Formatter/SerializeFormatter.php | 98 + .../File/Formatter/YamlFormatter.php | 129 + system/src/Grav/Framework/File/IniFile.php | 40 + .../Interfaces/FileFormatterInterface.php | 72 + .../File/Interfaces/FileInterface.php | 180 + system/src/Grav/Framework/File/JsonFile.php | 31 + .../src/Grav/Framework/File/MarkdownFile.php | 40 + system/src/Grav/Framework/File/YamlFile.php | 40 + .../Grav/Framework/Filesystem/Filesystem.php | 356 + .../Interfaces/FilesystemInterface.php | 84 + system/src/Grav/Framework/Flex/Flex.php | 334 + .../Grav/Framework/Flex/FlexCollection.php | 733 ++ .../src/Grav/Framework/Flex/FlexDirectory.php | 1187 +++ .../Grav/Framework/Flex/FlexDirectoryForm.php | 509 ++ system/src/Grav/Framework/Flex/FlexForm.php | 610 ++ .../src/Grav/Framework/Flex/FlexFormFlash.php | 130 + .../Grav/Framework/Flex/FlexIdentifier.php | 75 + system/src/Grav/Framework/Flex/FlexIndex.php | 930 +++ system/src/Grav/Framework/Flex/FlexObject.php | 1288 ++++ .../Interfaces/FlexAuthorizeInterface.php | 33 + .../Interfaces/FlexCollectionInterface.php | 144 + .../Flex/Interfaces/FlexCommonInterface.php | 79 + .../Interfaces/FlexDirectoryFormInterface.php | 27 + .../Interfaces/FlexDirectoryInterface.php | 228 + .../Flex/Interfaces/FlexFormInterface.php | 51 + .../Flex/Interfaces/FlexIndexInterface.php | 64 + .../Flex/Interfaces/FlexInterface.php | 100 + .../Interfaces/FlexObjectFormInterface.php | 27 + .../Flex/Interfaces/FlexObjectInterface.php | 211 + .../Flex/Interfaces/FlexStorageInterface.php | 138 + .../Interfaces/FlexTranslateInterface.php | 51 + .../Flex/Pages/FlexPageCollection.php | 211 + .../Framework/Flex/Pages/FlexPageIndex.php | 48 + .../Framework/Flex/Pages/FlexPageObject.php | 496 ++ .../Flex/Pages/Traits/PageAuthorsTrait.php | 249 + .../Flex/Pages/Traits/PageContentTrait.php | 842 +++ .../Flex/Pages/Traits/PageLegacyTrait.php | 1124 +++ .../Flex/Pages/Traits/PageRoutableTrait.php | 550 ++ .../Flex/Pages/Traits/PageTranslateTrait.php | 291 + .../Storage/AbstractFilesystemStorage.php | 232 + .../Framework/Flex/Storage/FileStorage.php | 160 + .../Framework/Flex/Storage/FolderStorage.php | 708 ++ .../Framework/Flex/Storage/SimpleStorage.php | 507 ++ .../Flex/Traits/FlexAuthorizeTrait.php | 126 + .../Framework/Flex/Traits/FlexMediaTrait.php | 576 ++ .../Flex/Traits/FlexRelatedDirectoryTrait.php | 59 + .../Flex/Traits/FlexRelationshipsTrait.php | 61 + system/src/Grav/Framework/Form/FormFlash.php | 586 ++ .../src/Grav/Framework/Form/FormFlashFile.php | 266 + .../Form/Interfaces/FormFactoryInterface.php | 42 + .../Form/Interfaces/FormFlashInterface.php | 181 + .../Form/Interfaces/FormInterface.php | 187 + .../Grav/Framework/Form/Traits/FormTrait.php | 897 +++ .../Framework/Interfaces/RenderInterface.php | 38 + .../Logger/Processors/UserProcessor.php | 34 + .../Interfaces/MediaCollectionInterface.php | 23 + .../Media/Interfaces/MediaInterface.php | 37 + .../Interfaces/MediaManipulationInterface.php | 33 + .../Media/Interfaces/MediaObjectInterface.php | 47 + .../Grav/Framework/Media/MediaIdentifier.php | 150 + .../src/Grav/Framework/Media/MediaObject.php | 215 + .../Framework/Media/UploadedMediaObject.php | 172 + system/src/Grav/Framework/Mime/MimeTypes.php | 107 + .../Object/Access/ArrayAccessTrait.php | 66 + .../Object/Access/NestedArrayAccessTrait.php | 66 + .../Access/NestedPropertyCollectionTrait.php | 120 + .../Object/Access/NestedPropertyTrait.php | 180 + .../Object/Access/OverloadedPropertyTrait.php | 66 + .../src/Grav/Framework/Object/ArrayObject.php | 31 + .../Object/Base/ObjectCollectionTrait.php | 377 + .../Framework/Object/Base/ObjectTrait.php | 202 + .../Collection/ObjectExpressionVisitor.php | 240 + .../Object/Identifiers/Identifier.php | 66 + .../NestedObjectCollectionInterface.php | 64 + .../Interfaces/NestedObjectInterface.php | 60 + .../Interfaces/ObjectCollectionInterface.php | 126 + .../Object/Interfaces/ObjectInterface.php | 63 + .../src/Grav/Framework/Object/LazyObject.php | 34 + .../Framework/Object/ObjectCollection.php | 131 + .../src/Grav/Framework/Object/ObjectIndex.php | 281 + .../Object/Property/ArrayPropertyTrait.php | 115 + .../Object/Property/LazyPropertyTrait.php | 114 + .../Object/Property/MixedPropertyTrait.php | 121 + .../Object/Property/ObjectPropertyTrait.php | 213 + .../Grav/Framework/Object/PropertyObject.php | 32 + .../Pagination/AbstractPagination.php | 429 ++ .../Pagination/AbstractPaginationPage.php | 78 + .../Interfaces/PaginationInterface.php | 104 + .../Interfaces/PaginationPageInterface.php | 47 + .../Grav/Framework/Pagination/Pagination.php | 32 + .../Framework/Pagination/PaginationPage.php | 26 + .../src/Grav/Framework/Psr7/AbstractUri.php | 412 ++ system/src/Grav/Framework/Psr7/Request.php | 34 + system/src/Grav/Framework/Psr7/Response.php | 265 + .../src/Grav/Framework/Psr7/ServerRequest.php | 364 + system/src/Grav/Framework/Psr7/Stream.php | 43 + .../Psr7/Traits/MessageDecoratorTrait.php | 140 + .../Psr7/Traits/RequestDecoratorTrait.php | 112 + .../Psr7/Traits/ResponseDecoratorTrait.php | 82 + .../Traits/ServerRequestDecoratorTrait.php | 176 + .../Psr7/Traits/StreamDecoratorTrait.php | 153 + .../Traits/UploadedFileDecoratorTrait.php | 73 + .../Psr7/Traits/UriDecorationTrait.php | 188 + .../src/Grav/Framework/Psr7/UploadedFile.php | 70 + system/src/Grav/Framework/Psr7/Uri.php | 135 + .../Framework/Relationships/Relationships.php | 217 + .../Relationships/ToManyRelationship.php | 259 + .../Relationships/ToOneRelationship.php | 207 + .../Traits/RelationshipTrait.php | 128 + .../Exception/InvalidArgumentException.php | 49 + .../Exception/NotFoundException.php | 37 + .../Exception/NotHandledException.php | 20 + .../Exception/PageExpiredException.php | 32 + .../Exception/RequestException.php | 102 + .../RequestHandler/Middlewares/Exceptions.php | 78 + .../Middlewares/MultipartRequestSupport.php | 123 + .../RequestHandler/RequestHandler.php | 80 + .../Traits/RequestHandlerTrait.php | 64 + system/src/Grav/Framework/Route/Route.php | 452 ++ .../src/Grav/Framework/Route/RouteFactory.php | 236 + .../Session/Exceptions/SessionException.php | 20 + .../src/Grav/Framework/Session/Messages.php | 134 + system/src/Grav/Framework/Session/Session.php | 562 ++ .../Framework/Session/SessionInterface.php | 159 + system/src/Grav/Framework/Uri/Uri.php | 216 + system/src/Grav/Framework/Uri/UriFactory.php | 171 + .../src/Grav/Framework/Uri/UriPartsFilter.php | 145 + system/src/Grav/Installer/Install.php | 400 + .../src/Grav/Installer/InstallException.php | 29 + system/src/Grav/Installer/VersionUpdate.php | 83 + system/src/Grav/Installer/VersionUpdater.php | 133 + system/src/Grav/Installer/Versions.php | 329 + system/src/Grav/Installer/YamlUpdater.php | 431 ++ .../Installer/updates/1.7.0_2020-11-20_1.php | 24 + .../DeferredExtension/DeferredBlockNode.php | 43 + .../DeferredExtension/DeferredDeclareNode.php | 27 + .../DeferredExtension/DeferredExtension.php | 72 + .../DeferredInitializeNode.php | 27 + .../Twig/DeferredExtension/DeferredNode.php | 27 + .../DeferredExtension/DeferredNodeVisitor.php | 50 + .../DeferredNodeVisitorCompat.php | 67 + .../DeferredExtension/DeferredResolveNode.php | 27 + .../DeferredExtension/DeferredTokenParser.php | 77 + system/templates/default.html.twig | 4 + system/templates/external.html.twig | 1 + system/templates/flex/404.html.twig | 4 + .../flex/_default/collection/debug.html.twig | 5 + .../flex/_default/object/debug.html.twig | 4 + system/templates/modular/default.html.twig | 4 + system/templates/partials/messages.html.twig | 14 + system/templates/partials/metadata.html.twig | 3 + tests/_bootstrap.php | 35 + tests/_support/AcceptanceTester.php | 26 + tests/_support/FunctionalTester.php | 26 + tests/_support/Helper/Acceptance.php | 10 + tests/_support/Helper/Functional.php | 10 + tests/_support/Helper/Unit.php | 90 + tests/_support/UnitTester.php | 26 + tests/acceptance.suite.yml | 12 + tests/acceptance/_bootstrap.php | 2 + .../01.item1-1/01.item1-1-1/default.md | 10 + .../01.item1-1/02.item1-1-2/default.md | 10 + .../01.item1-1/03.item1-1-3/default.md | 10 + .../user/pages/01.item1/01.item1-1/default.md | 10 + .../02.item1-2/01.item1-2-1/default.md | 10 + .../02.item1-2/02.item1-2-2/default.md | 10 + .../02.item1-2/03.item1-2-3/default.md | 10 + .../user/pages/01.item1/02.item1-2/default.md | 10 + .../03.item1-3/01.item1-3-1/default.md | 10 + .../03.item1-3/02.item1-3-2/default.md | 10 + .../03.item1-3/03.item1-3-3/default.md | 10 + .../user/pages/01.item1/03.item1-3/default.md | 10 + .../user/pages/01.item1/default.md | 10 + .../user/pages/01.item1/existing-file.zip | Bin 0 -> 451 bytes .../user/pages/01.item1/home-cache-image.jpg | Bin 0 -> 156699 bytes .../user/pages/01.item1/home-sample-image.jpg | Bin 0 -> 156699 bytes .../01.item2-1/01.item2-1-1/default.md | 10 + .../01.item2-1/02.item2-1-2/default.md | 10 + .../01.item2-1/03.item2-1-3/default.md | 10 + .../user/pages/02.item2/01.item2-1/default.md | 10 + .../02.item2-2/01.item2-2-1/default.md | 10 + .../02.item2-2/02.item2-2-2/default.md | 10 + .../02.item2-2/03.item2-2-3/default.md | 10 + .../pages/02.item2/02.item2-2/cache-image.jpg | Bin 0 -> 156699 bytes .../user/pages/02.item2/02.item2-2/default.md | 10 + .../02.item2/02.item2-2/existing-file.zip | Bin 0 -> 451 bytes .../02.item2/02.item2-2/sample-image.jpg | Bin 0 -> 156699 bytes .../03.item2-3/01.item2-3-1/default.md | 10 + .../03.item2-3/02.item2-3-2/default.md | 10 + .../03.item2-3/03.item2-3-3/default.md | 10 + .../user/pages/02.item2/03.item2-3/default.md | 10 + .../user/pages/02.item2/default.md | 10 + .../01.item3-1/01.item3-1-1/default.md | 14 + .../01.item3-1/02.item3-1-2/default.md | 10 + .../01.item3-1/03.item3-1-3/default.md | 10 + .../user/pages/03.item3/01.item3-1/default.md | 10 + .../02.item3-2/01.item3-2-1/default.md | 10 + .../02.item3-2/02.item3-2-2/default.md | 10 + .../02.item3-2/03.item3-2-3/default.md | 10 + .../user/pages/03.item3/02.item3-2/default.md | 10 + .../03.item3-3/01.item3-3-1/default.md | 10 + .../03.item3-3/02.item3-3-2/default.md | 10 + .../03.item3-3/03.item3-3-3/default.md | 10 + .../user/pages/03.item3/03.item3-3/default.md | 10 + .../user/pages/03.item3/default.md | 10 + .../simple-site/user/pages/01.home/default.md | 10 + .../simple-site/user/pages/02.blog/blog.md | 10 + .../user/pages/02.blog/post-one/item.md | 10 + .../user/pages/02.blog/post-two/item.md | 10 + .../user/pages/03.about/default.md | 10 + .../pages/04.page-translated/default.en.md | 0 .../pages/04.page-translated/default.fr.md | 5 + .../05.translatedlong/part2/default.en.md | 0 .../05.translatedlong/part2/default.fr.md | 5 + .../user/pages/01.simple-page/default.en.md | 0 .../user/pages/01.simple-page/default.fr.md | 5 + .../single-pages/01.simple-page/default.md | 5 + tests/functional.suite.yml | 11 + .../Grav/Console/DirectInstallCommandTest.php | 38 + tests/functional/_bootstrap.php | 2 + .../UniformResourceLocatorExtension.php | 51 + tests/phpstan/extension.neon | 5 + tests/phpstan/phpstan-bootstrap.php | 7 + tests/phpstan/phpstan.neon | 175 + tests/phpstan/plugins-bootstrap.php | 64 + tests/phpstan/plugins.neon | 70 + tests/unit.suite.yml | 9 + tests/unit/Grav/Common/AssetsTest.php | 847 +++ tests/unit/Grav/Common/BrowserTest.php | 51 + tests/unit/Grav/Common/ComposerTest.php | 31 + tests/unit/Grav/Common/Data/BlueprintTest.php | 72 + tests/unit/Grav/Common/GPM/GPMTest.php | 329 + .../unit/Grav/Common/Helpers/ExcerptsTest.php | 120 + tests/unit/Grav/Common/InflectorTest.php | 147 + .../Common/Language/LanguageCodesTest.php | 27 + .../Grav/Common/Markdown/ParsedownTest.php | 1260 ++++ tests/unit/Grav/Common/Page/PagesTest.php | 299 + .../Twig/Extensions/GravExtensionTest.php | 202 + tests/unit/Grav/Common/UriTest.php | 1178 +++ tests/unit/Grav/Common/UtilsTest.php | 572 ++ .../Grav/Console/Gpm/InstallCommandTest.php | 28 + .../File/Formatter/CsvFormatterTest.php | 48 + .../Framework/Filesystem/FilesystemTest.php | 338 + tests/unit/_bootstrap.php | 3 + tests/unit/data/blueprints/strict.yaml | 15 + tmp/.gitkeep | 1 + .../index.yaml | 31 + .../index.yaml | 31 + user/accounts/.gitkeep | 1 + user/config/media.yaml | 0 user/config/site.yaml | 7 + user/config/system.yaml | 45 + user/config/themes/quark.yaml | 14 + user/data/.gitkeep | 1 + user/pages/01.home/02.diag/P3.png | Bin 0 -> 396931 bytes user/pages/01.home/02.diag/Ptest.png | Bin 0 -> 51400 bytes user/pages/01.home/02.diag/default.md | 17 + user/pages/01.home/03.boule/boule.md | 5 + user/pages/01.home/debut.md | 18 + user/plugins/.gitkeep | 1 + user/themes/.gitkeep | 1 + webserver-configs/Caddyfile | 31 + webserver-configs/Caddyfile-0.8.x | 33 + webserver-configs/htaccess.txt | 78 + webserver-configs/lighttpd.conf | 48 + webserver-configs/nginx.conf | 44 + webserver-configs/web.config | 42 + 908 files changed, 137535 insertions(+) create mode 100644 .dependencies create mode 100644 .editorconfig create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/build.yaml create mode 100644 .github/workflows/tests.yaml create mode 100644 .github/workflows/trigger-skeletons.yml create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 .phan/config.php create mode 100644 .phan/internal_stubs/Redis.phan_php create mode 100644 .phan/internal_stubs/memcache.phan_php create mode 100644 .phan/internal_stubs/memcached.phan_php create mode 100644 .travis.yml create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 assets/.gitkeep create mode 100644 backup/.gitkeep create mode 100755 bin/composer.phar create mode 100755 bin/gpm create mode 100755 bin/grav create mode 100755 bin/plugin create mode 100644 cache/.gitkeep create mode 100644 codeception.yml create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 images/.gitkeep create mode 100644 index.php create mode 100644 logs/.gitkeep create mode 100644 now.json create mode 100644 robots.txt create mode 100644 system/assets/debugger/clockwork.css create mode 100644 system/assets/debugger/clockwork.js create mode 100644 system/assets/debugger/phpdebugbar.css create mode 100644 system/assets/grav.png create mode 100644 system/assets/jquery/jquery-2.1.4.min.js create mode 100644 system/assets/jquery/jquery-2.x.min.js create mode 100644 system/assets/jquery/jquery-3.x.min.js create mode 100644 system/assets/responsive-overlays/1x.png create mode 100644 system/assets/responsive-overlays/2x.png create mode 100644 system/assets/responsive-overlays/3x.png create mode 100644 system/assets/responsive-overlays/4x.png create mode 100644 system/assets/responsive-overlays/unknown.png create mode 100644 system/assets/whoops.css create mode 100644 system/blueprints/config/backups.yaml create mode 100644 system/blueprints/config/media.yaml create mode 100644 system/blueprints/config/scheduler.yaml create mode 100644 system/blueprints/config/security.yaml create mode 100644 system/blueprints/config/site.yaml create mode 100644 system/blueprints/config/streams.yaml create mode 100644 system/blueprints/config/system.yaml create mode 100644 system/blueprints/flex/accounts.yaml create mode 100644 system/blueprints/flex/configure/compat.yaml create mode 100644 system/blueprints/flex/pages.yaml create mode 100644 system/blueprints/flex/shared/configure.yaml create mode 100644 system/blueprints/flex/user-accounts.yaml create mode 100644 system/blueprints/flex/user-groups.yaml create mode 100644 system/blueprints/pages/default.yaml create mode 100644 system/blueprints/pages/external.yaml create mode 100644 system/blueprints/pages/modular.yaml create mode 100644 system/blueprints/pages/partials/security.yaml create mode 100644 system/blueprints/pages/root.yaml create mode 100644 system/blueprints/user/account.yaml create mode 100644 system/blueprints/user/account_new.yaml create mode 100644 system/blueprints/user/group.yaml create mode 100644 system/blueprints/user/group_new.yaml create mode 100644 system/config/backups.yaml create mode 100644 system/config/media.yaml create mode 100644 system/config/mime.yaml create mode 100644 system/config/permissions.yaml create mode 100644 system/config/scheduler.yaml create mode 100644 system/config/security.yaml create mode 100644 system/config/site.yaml create mode 100644 system/config/system.yaml create mode 100644 system/defines.php create mode 100644 system/images/media/thumb-3dm.png create mode 100644 system/images/media/thumb-3ds.png create mode 100644 system/images/media/thumb-3g2.png create mode 100644 system/images/media/thumb-3gp.png create mode 100644 system/images/media/thumb-7z.png create mode 100644 system/images/media/thumb-aac.png create mode 100644 system/images/media/thumb-ai.png create mode 100644 system/images/media/thumb-aif.png create mode 100644 system/images/media/thumb-apk.png create mode 100644 system/images/media/thumb-app.png create mode 100644 system/images/media/thumb-asf.png create mode 100644 system/images/media/thumb-asp.png create mode 100644 system/images/media/thumb-aspx.png create mode 100644 system/images/media/thumb-asx.png create mode 100644 system/images/media/thumb-avi.png create mode 100644 system/images/media/thumb-bak.png create mode 100644 system/images/media/thumb-bat.png create mode 100644 system/images/media/thumb-bin.png create mode 100644 system/images/media/thumb-bmp.png create mode 100644 system/images/media/thumb-cab.png create mode 100644 system/images/media/thumb-cad.png create mode 100644 system/images/media/thumb-cdr.png create mode 100644 system/images/media/thumb-cer.png create mode 100644 system/images/media/thumb-cfg.png create mode 100644 system/images/media/thumb-cfm.png create mode 100644 system/images/media/thumb-cgi.png create mode 100644 system/images/media/thumb-com.png create mode 100644 system/images/media/thumb-cpl.png create mode 100644 system/images/media/thumb-cpp.png create mode 100644 system/images/media/thumb-crx.png create mode 100644 system/images/media/thumb-csr.png create mode 100644 system/images/media/thumb-css.png create mode 100644 system/images/media/thumb-csv.png create mode 100644 system/images/media/thumb-cue.png create mode 100644 system/images/media/thumb-cur.png create mode 100644 system/images/media/thumb-dat.png create mode 100644 system/images/media/thumb-db.png create mode 100644 system/images/media/thumb-dbf.png create mode 100644 system/images/media/thumb-dds.png create mode 100644 system/images/media/thumb-dem.png create mode 100644 system/images/media/thumb-dll.png create mode 100644 system/images/media/thumb-dmg.png create mode 100644 system/images/media/thumb-dmp.png create mode 100644 system/images/media/thumb-doc.png create mode 100644 system/images/media/thumb-docx.png create mode 100644 system/images/media/thumb-drv.png create mode 100644 system/images/media/thumb-dtd.png create mode 100644 system/images/media/thumb-dwg.png create mode 100644 system/images/media/thumb-dxf.png create mode 100644 system/images/media/thumb-elf.png create mode 100644 system/images/media/thumb-eot.png create mode 100644 system/images/media/thumb-eps.png create mode 100644 system/images/media/thumb-exe.png create mode 100644 system/images/media/thumb-fla.png create mode 100644 system/images/media/thumb-flv.png create mode 100644 system/images/media/thumb-fnt.png create mode 100644 system/images/media/thumb-fon.png create mode 100644 system/images/media/thumb-gam.png create mode 100644 system/images/media/thumb-gbr.png create mode 100644 system/images/media/thumb-ged.png create mode 100644 system/images/media/thumb-gif.png create mode 100644 system/images/media/thumb-gpx.png create mode 100644 system/images/media/thumb-gz.png create mode 100644 system/images/media/thumb-gzip.png create mode 100644 system/images/media/thumb-hqz.png create mode 100644 system/images/media/thumb-html.png create mode 100644 system/images/media/thumb-icns.png create mode 100644 system/images/media/thumb-ico.png create mode 100644 system/images/media/thumb-ics.png create mode 100644 system/images/media/thumb-iff.png create mode 100644 system/images/media/thumb-indd.png create mode 100644 system/images/media/thumb-iso.png create mode 100644 system/images/media/thumb-jar.png create mode 100644 system/images/media/thumb-jpg.png create mode 100644 system/images/media/thumb-js.png create mode 100644 system/images/media/thumb-json.png create mode 100644 system/images/media/thumb-jsp.png create mode 100644 system/images/media/thumb-key.png create mode 100644 system/images/media/thumb-kml.png create mode 100644 system/images/media/thumb-kmz.png create mode 100644 system/images/media/thumb-lnk.png create mode 100644 system/images/media/thumb-log.png create mode 100644 system/images/media/thumb-lua.png create mode 100644 system/images/media/thumb-m3u.png create mode 100644 system/images/media/thumb-m4a.png create mode 100644 system/images/media/thumb-m4v.png create mode 100644 system/images/media/thumb-max.png create mode 100644 system/images/media/thumb-mdb.png create mode 100644 system/images/media/thumb-mdf.png create mode 100644 system/images/media/thumb-mid.png create mode 100644 system/images/media/thumb-mim.png create mode 100644 system/images/media/thumb-mov.png create mode 100644 system/images/media/thumb-mp3.png create mode 100644 system/images/media/thumb-mp4.png create mode 100644 system/images/media/thumb-mpa.png create mode 100644 system/images/media/thumb-mpe.png create mode 100644 system/images/media/thumb-mpg.png create mode 100644 system/images/media/thumb-msg.png create mode 100644 system/images/media/thumb-msi.png create mode 100644 system/images/media/thumb-nes.png create mode 100644 system/images/media/thumb-obj.png create mode 100644 system/images/media/thumb-odb.png create mode 100644 system/images/media/thumb-odc.png create mode 100644 system/images/media/thumb-odf.png create mode 100644 system/images/media/thumb-odg.png create mode 100644 system/images/media/thumb-odi.png create mode 100644 system/images/media/thumb-odp.png create mode 100644 system/images/media/thumb-ods.png create mode 100644 system/images/media/thumb-odt.png create mode 100644 system/images/media/thumb-odx.png create mode 100644 system/images/media/thumb-ogg.png create mode 100644 system/images/media/thumb-pct.png create mode 100644 system/images/media/thumb-pdb.png create mode 100644 system/images/media/thumb-pdf.png create mode 100644 system/images/media/thumb-pif.png create mode 100644 system/images/media/thumb-pkg.png create mode 100644 system/images/media/thumb-pl.png create mode 100644 system/images/media/thumb-png.png create mode 100644 system/images/media/thumb-pps.png create mode 100644 system/images/media/thumb-ppt.png create mode 100644 system/images/media/thumb-pptx.png create mode 100644 system/images/media/thumb-ps.png create mode 100644 system/images/media/thumb-psd.png create mode 100644 system/images/media/thumb-pub.png create mode 100644 system/images/media/thumb-py.png create mode 100644 system/images/media/thumb-ra.png create mode 100644 system/images/media/thumb-rar.png create mode 100644 system/images/media/thumb-raw.png create mode 100644 system/images/media/thumb-rm.png create mode 100644 system/images/media/thumb-rom.png create mode 100644 system/images/media/thumb-rpm.png create mode 100644 system/images/media/thumb-rss.png create mode 100644 system/images/media/thumb-rtf.png create mode 100644 system/images/media/thumb-sav.png create mode 100644 system/images/media/thumb-sdf.png create mode 100644 system/images/media/thumb-sql.png create mode 100644 system/images/media/thumb-srt.png create mode 100644 system/images/media/thumb-svg.png create mode 100644 system/images/media/thumb-swf.png create mode 100644 system/images/media/thumb-sys.png create mode 100644 system/images/media/thumb-tar.png create mode 100644 system/images/media/thumb-tex.png create mode 100644 system/images/media/thumb-tga.png create mode 100644 system/images/media/thumb-thm.png create mode 100644 system/images/media/thumb-tiff.png create mode 100644 system/images/media/thumb-tmp.png create mode 100644 system/images/media/thumb-ttf.png create mode 100644 system/images/media/thumb-txt.png create mode 100644 system/images/media/thumb-uue.png create mode 100644 system/images/media/thumb-vb.png create mode 100644 system/images/media/thumb-vcd.png create mode 100644 system/images/media/thumb-vcf.png create mode 100644 system/images/media/thumb-wav.png create mode 100644 system/images/media/thumb-webm.png create mode 100644 system/images/media/thumb-wma.png create mode 100644 system/images/media/thumb-wmv.png create mode 100644 system/images/media/thumb-woff.png create mode 100644 system/images/media/thumb-woff2.png create mode 100644 system/images/media/thumb-wpd.png create mode 100644 system/images/media/thumb-wps.png create mode 100644 system/images/media/thumb-wsf.png create mode 100644 system/images/media/thumb-xls.png create mode 100644 system/images/media/thumb-xlsx.png create mode 100644 system/images/media/thumb-xml.png create mode 100644 system/images/media/thumb-yuv.png create mode 100644 system/images/media/thumb-zip.png create mode 100644 system/images/media/thumb.png create mode 100644 system/images/watermark.png create mode 100644 system/install.php create mode 100644 system/languages/ar.yaml create mode 100644 system/languages/bg.yaml create mode 100644 system/languages/ca.yaml create mode 100644 system/languages/cs.yaml create mode 100644 system/languages/da.yaml create mode 100644 system/languages/de.yaml create mode 100644 system/languages/el.yaml create mode 100644 system/languages/en.yaml create mode 100644 system/languages/eo.yaml create mode 100644 system/languages/es.yaml create mode 100644 system/languages/et.yaml create mode 100644 system/languages/eu.yaml create mode 100644 system/languages/fa.yaml create mode 100644 system/languages/fi.yaml create mode 100644 system/languages/fr.yaml create mode 100644 system/languages/gl.yaml create mode 100644 system/languages/he.yaml create mode 100644 system/languages/hr.yaml create mode 100644 system/languages/hu.yaml create mode 100644 system/languages/id.yaml create mode 100644 system/languages/is.yaml create mode 100644 system/languages/it.yaml create mode 100644 system/languages/ja.yaml create mode 100644 system/languages/ko.yaml create mode 100644 system/languages/lt.yaml create mode 100644 system/languages/lv.yaml create mode 100644 system/languages/mn.yaml create mode 100644 system/languages/my.yaml create mode 100644 system/languages/nb.yaml create mode 100644 system/languages/nl.yaml create mode 100644 system/languages/no.yaml create mode 100644 system/languages/pl.yaml create mode 100644 system/languages/pt.yaml create mode 100644 system/languages/ro.yaml create mode 100644 system/languages/ru.yaml create mode 100644 system/languages/si.yaml create mode 100644 system/languages/sk.yaml create mode 100644 system/languages/sl.yaml create mode 100644 system/languages/sr.yaml create mode 100644 system/languages/sv.yaml create mode 100644 system/languages/sw.yaml create mode 100644 system/languages/th.yaml create mode 100644 system/languages/tr.yaml create mode 100644 system/languages/uk.yaml create mode 100644 system/languages/vi.yaml create mode 100644 system/languages/zh-cn.yaml create mode 100644 system/languages/zh-tw.yaml create mode 100644 system/languages/zh.yaml create mode 100644 system/pages/notfound.md create mode 100644 system/router.php create mode 100644 system/src/DOMLettersIterator.php create mode 100644 system/src/DOMWordsIterator.php create mode 100644 system/src/Grav/Common/Assets.php create mode 100644 system/src/Grav/Common/Assets/BaseAsset.php create mode 100644 system/src/Grav/Common/Assets/BlockAssets.php create mode 100644 system/src/Grav/Common/Assets/Css.php create mode 100644 system/src/Grav/Common/Assets/InlineCss.php create mode 100644 system/src/Grav/Common/Assets/InlineJs.php create mode 100644 system/src/Grav/Common/Assets/InlineJsModule.php create mode 100644 system/src/Grav/Common/Assets/Js.php create mode 100644 system/src/Grav/Common/Assets/JsModule.php create mode 100644 system/src/Grav/Common/Assets/Link.php create mode 100644 system/src/Grav/Common/Assets/Pipeline.php create mode 100644 system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php create mode 100644 system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php create mode 100644 system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php create mode 100644 system/src/Grav/Common/Backup/Backups.php create mode 100644 system/src/Grav/Common/Browser.php create mode 100644 system/src/Grav/Common/Cache.php create mode 100644 system/src/Grav/Common/Composer.php create mode 100644 system/src/Grav/Common/Config/CompiledBase.php create mode 100644 system/src/Grav/Common/Config/CompiledBlueprints.php create mode 100644 system/src/Grav/Common/Config/CompiledConfig.php create mode 100644 system/src/Grav/Common/Config/CompiledLanguages.php create mode 100644 system/src/Grav/Common/Config/Config.php create mode 100644 system/src/Grav/Common/Config/ConfigFileFinder.php create mode 100644 system/src/Grav/Common/Config/Languages.php create mode 100644 system/src/Grav/Common/Config/Setup.php create mode 100644 system/src/Grav/Common/Data/Blueprint.php create mode 100644 system/src/Grav/Common/Data/BlueprintSchema.php create mode 100644 system/src/Grav/Common/Data/Blueprints.php create mode 100644 system/src/Grav/Common/Data/Data.php create mode 100644 system/src/Grav/Common/Data/DataInterface.php create mode 100644 system/src/Grav/Common/Data/Validation.php create mode 100644 system/src/Grav/Common/Data/ValidationException.php create mode 100644 system/src/Grav/Common/Debugger.php create mode 100644 system/src/Grav/Common/Errors/BareHandler.php create mode 100644 system/src/Grav/Common/Errors/Errors.php create mode 100644 system/src/Grav/Common/Errors/Resources/error.css create mode 100644 system/src/Grav/Common/Errors/Resources/layout.html.php create mode 100644 system/src/Grav/Common/Errors/SimplePageHandler.php create mode 100644 system/src/Grav/Common/Errors/SystemFacade.php create mode 100644 system/src/Grav/Common/File/CompiledFile.php create mode 100644 system/src/Grav/Common/File/CompiledJsonFile.php create mode 100644 system/src/Grav/Common/File/CompiledMarkdownFile.php create mode 100644 system/src/Grav/Common/File/CompiledYamlFile.php create mode 100644 system/src/Grav/Common/Filesystem/Archiver.php create mode 100644 system/src/Grav/Common/Filesystem/Folder.php create mode 100644 system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php create mode 100644 system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php create mode 100644 system/src/Grav/Common/Filesystem/ZipArchiver.php create mode 100644 system/src/Grav/Common/Flex/FlexCollection.php create mode 100644 system/src/Grav/Common/Flex/FlexIndex.php create mode 100644 system/src/Grav/Common/Flex/FlexObject.php create mode 100644 system/src/Grav/Common/Flex/Traits/FlexCollectionTrait.php create mode 100644 system/src/Grav/Common/Flex/Traits/FlexCommonTrait.php create mode 100644 system/src/Grav/Common/Flex/Traits/FlexGravTrait.php create mode 100644 system/src/Grav/Common/Flex/Traits/FlexIndexTrait.php create mode 100644 system/src/Grav/Common/Flex/Traits/FlexObjectTrait.php create mode 100644 system/src/Grav/Common/Flex/Types/Generic/GenericCollection.php create mode 100644 system/src/Grav/Common/Flex/Types/Generic/GenericIndex.php create mode 100644 system/src/Grav/Common/Flex/Types/Generic/GenericObject.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/PageCollection.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/PageIndex.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/PageObject.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/Storage/PageStorage.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/Traits/PageContentTrait.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/Traits/PageLegacyTrait.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/Traits/PageRoutableTrait.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/Traits/PageTranslateTrait.php create mode 100644 system/src/Grav/Common/Flex/Types/UserGroups/UserGroupCollection.php create mode 100644 system/src/Grav/Common/Flex/Types/UserGroups/UserGroupIndex.php create mode 100644 system/src/Grav/Common/Flex/Types/UserGroups/UserGroupObject.php create mode 100644 system/src/Grav/Common/Flex/Types/Users/Storage/UserFileStorage.php create mode 100644 system/src/Grav/Common/Flex/Types/Users/Storage/UserFolderStorage.php create mode 100644 system/src/Grav/Common/Flex/Types/Users/Traits/UserObjectLegacyTrait.php create mode 100644 system/src/Grav/Common/Flex/Types/Users/UserCollection.php create mode 100644 system/src/Grav/Common/Flex/Types/Users/UserIndex.php create mode 100644 system/src/Grav/Common/Flex/Types/Users/UserObject.php create mode 100644 system/src/Grav/Common/Form/FormFlash.php create mode 100644 system/src/Grav/Common/GPM/AbstractCollection.php create mode 100644 system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php create mode 100644 system/src/Grav/Common/GPM/Common/CachedCollection.php create mode 100644 system/src/Grav/Common/GPM/Common/Package.php create mode 100644 system/src/Grav/Common/GPM/GPM.php create mode 100644 system/src/Grav/Common/GPM/Installer.php create mode 100644 system/src/Grav/Common/GPM/Licenses.php create mode 100644 system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php create mode 100644 system/src/Grav/Common/GPM/Local/Package.php create mode 100644 system/src/Grav/Common/GPM/Local/Packages.php create mode 100644 system/src/Grav/Common/GPM/Local/Plugins.php create mode 100644 system/src/Grav/Common/GPM/Local/Themes.php create mode 100644 system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php create mode 100644 system/src/Grav/Common/GPM/Remote/GravCore.php create mode 100644 system/src/Grav/Common/GPM/Remote/Package.php create mode 100644 system/src/Grav/Common/GPM/Remote/Packages.php create mode 100644 system/src/Grav/Common/GPM/Remote/Plugins.php create mode 100644 system/src/Grav/Common/GPM/Remote/Themes.php create mode 100644 system/src/Grav/Common/GPM/Response.php create mode 100644 system/src/Grav/Common/GPM/Upgrader.php create mode 100644 system/src/Grav/Common/Getters.php create mode 100644 system/src/Grav/Common/Grav.php create mode 100644 system/src/Grav/Common/GravTrait.php create mode 100644 system/src/Grav/Common/HTTP/Client.php create mode 100644 system/src/Grav/Common/HTTP/Response.php create mode 100644 system/src/Grav/Common/Helpers/Base32.php create mode 100644 system/src/Grav/Common/Helpers/Excerpts.php create mode 100644 system/src/Grav/Common/Helpers/Exif.php create mode 100644 system/src/Grav/Common/Helpers/LogViewer.php create mode 100644 system/src/Grav/Common/Helpers/Truncator.php create mode 100644 system/src/Grav/Common/Helpers/YamlLinter.php create mode 100644 system/src/Grav/Common/Inflector.php create mode 100644 system/src/Grav/Common/Iterator.php create mode 100644 system/src/Grav/Common/Language/Language.php create mode 100644 system/src/Grav/Common/Language/LanguageCodes.php create mode 100644 system/src/Grav/Common/Markdown/Parsedown.php create mode 100644 system/src/Grav/Common/Markdown/ParsedownExtra.php create mode 100644 system/src/Grav/Common/Markdown/ParsedownGravTrait.php create mode 100644 system/src/Grav/Common/Media/Interfaces/AudioMediaInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/ImageManipulateInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/ImageMediaInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/MediaCollectionInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/MediaFileInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/MediaInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/MediaLinkInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/MediaObjectInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/MediaPlayerInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/MediaUploadInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/VideoMediaInterface.php create mode 100644 system/src/Grav/Common/Media/Traits/AudioMediaTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/ImageDecodingTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/ImageFetchPriorityTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/ImageLoadingTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/ImageMediaTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/MediaFileTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/MediaObjectTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/MediaPlayerTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/MediaTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/MediaUploadTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/StaticResizeTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/ThumbnailMediaTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/VideoMediaTrait.php create mode 100644 system/src/Grav/Common/Page/Collection.php create mode 100644 system/src/Grav/Common/Page/Header.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PageCollectionInterface.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PageContentInterface.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PageFormInterface.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PageInterface.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PageLegacyInterface.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PageTranslateInterface.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PagesSourceInterface.php create mode 100644 system/src/Grav/Common/Page/Markdown/Excerpts.php create mode 100644 system/src/Grav/Common/Page/Media.php create mode 100644 system/src/Grav/Common/Page/Medium/AbstractMedia.php create mode 100644 system/src/Grav/Common/Page/Medium/AudioMedium.php create mode 100644 system/src/Grav/Common/Page/Medium/GlobalMedia.php create mode 100644 system/src/Grav/Common/Page/Medium/ImageFile.php create mode 100644 system/src/Grav/Common/Page/Medium/ImageMedium.php create mode 100644 system/src/Grav/Common/Page/Medium/Link.php create mode 100644 system/src/Grav/Common/Page/Medium/Medium.php create mode 100644 system/src/Grav/Common/Page/Medium/MediumFactory.php create mode 100644 system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php create mode 100644 system/src/Grav/Common/Page/Medium/RenderableInterface.php create mode 100644 system/src/Grav/Common/Page/Medium/StaticImageMedium.php create mode 100644 system/src/Grav/Common/Page/Medium/StaticResizeTrait.php create mode 100644 system/src/Grav/Common/Page/Medium/ThumbnailImageMedium.php create mode 100644 system/src/Grav/Common/Page/Medium/VectorImageMedium.php create mode 100644 system/src/Grav/Common/Page/Medium/VideoMedium.php create mode 100644 system/src/Grav/Common/Page/Page.php create mode 100644 system/src/Grav/Common/Page/Pages.php create mode 100644 system/src/Grav/Common/Page/Traits/PageFormTrait.php create mode 100644 system/src/Grav/Common/Page/Types.php create mode 100644 system/src/Grav/Common/Plugin.php create mode 100644 system/src/Grav/Common/Plugins.php create mode 100644 system/src/Grav/Common/Processors/AssetsProcessor.php create mode 100644 system/src/Grav/Common/Processors/BackupsProcessor.php create mode 100644 system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php create mode 100644 system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php create mode 100644 system/src/Grav/Common/Processors/InitializeProcessor.php create mode 100644 system/src/Grav/Common/Processors/PagesProcessor.php create mode 100644 system/src/Grav/Common/Processors/PluginsProcessor.php create mode 100644 system/src/Grav/Common/Processors/ProcessorBase.php create mode 100644 system/src/Grav/Common/Processors/ProcessorInterface.php create mode 100644 system/src/Grav/Common/Processors/RenderProcessor.php create mode 100644 system/src/Grav/Common/Processors/RequestProcessor.php create mode 100644 system/src/Grav/Common/Processors/SchedulerProcessor.php create mode 100644 system/src/Grav/Common/Processors/TasksProcessor.php create mode 100644 system/src/Grav/Common/Processors/ThemesProcessor.php create mode 100644 system/src/Grav/Common/Processors/TwigProcessor.php create mode 100644 system/src/Grav/Common/Scheduler/Cron.php create mode 100644 system/src/Grav/Common/Scheduler/IntervalTrait.php create mode 100644 system/src/Grav/Common/Scheduler/Job.php create mode 100644 system/src/Grav/Common/Scheduler/JobHistory.php create mode 100644 system/src/Grav/Common/Scheduler/JobQueue.php create mode 100644 system/src/Grav/Common/Scheduler/Scheduler.php create mode 100644 system/src/Grav/Common/Scheduler/SchedulerController.php create mode 100644 system/src/Grav/Common/Security.php create mode 100644 system/src/Grav/Common/Service/AccountsServiceProvider.php create mode 100644 system/src/Grav/Common/Service/AssetsServiceProvider.php create mode 100644 system/src/Grav/Common/Service/BackupsServiceProvider.php create mode 100644 system/src/Grav/Common/Service/ConfigServiceProvider.php create mode 100644 system/src/Grav/Common/Service/ErrorServiceProvider.php create mode 100644 system/src/Grav/Common/Service/FilesystemServiceProvider.php create mode 100644 system/src/Grav/Common/Service/FlexServiceProvider.php create mode 100644 system/src/Grav/Common/Service/InflectorServiceProvider.php create mode 100644 system/src/Grav/Common/Service/LoggerServiceProvider.php create mode 100644 system/src/Grav/Common/Service/OutputServiceProvider.php create mode 100644 system/src/Grav/Common/Service/PagesServiceProvider.php create mode 100644 system/src/Grav/Common/Service/RequestServiceProvider.php create mode 100644 system/src/Grav/Common/Service/SchedulerServiceProvider.php create mode 100644 system/src/Grav/Common/Service/SessionServiceProvider.php create mode 100644 system/src/Grav/Common/Service/StreamsServiceProvider.php create mode 100644 system/src/Grav/Common/Service/TaskServiceProvider.php create mode 100644 system/src/Grav/Common/Session.php create mode 100644 system/src/Grav/Common/Taxonomy.php create mode 100644 system/src/Grav/Common/Theme.php create mode 100644 system/src/Grav/Common/Themes.php create mode 100644 system/src/Grav/Common/Twig/Exception/TwigException.php create mode 100644 system/src/Grav/Common/Twig/Extension/FilesystemExtension.php create mode 100644 system/src/Grav/Common/Twig/Extension/GravExtension.php create mode 100644 system/src/Grav/Common/Twig/Node/TwigNodeCache.php create mode 100644 system/src/Grav/Common/Twig/Node/TwigNodeLink.php create mode 100644 system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php create mode 100644 system/src/Grav/Common/Twig/Node/TwigNodeRender.php create mode 100644 system/src/Grav/Common/Twig/Node/TwigNodeScript.php create mode 100644 system/src/Grav/Common/Twig/Node/TwigNodeStyle.php create mode 100644 system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php create mode 100644 system/src/Grav/Common/Twig/Node/TwigNodeThrow.php create mode 100644 system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php create mode 100644 system/src/Grav/Common/Twig/TokenParser/TwigTokenParserCache.php create mode 100644 system/src/Grav/Common/Twig/TokenParser/TwigTokenParserLink.php create mode 100644 system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php create mode 100644 system/src/Grav/Common/Twig/TokenParser/TwigTokenParserRender.php create mode 100644 system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php create mode 100644 system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php create mode 100644 system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php create mode 100644 system/src/Grav/Common/Twig/TokenParser/TwigTokenParserThrow.php create mode 100644 system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php create mode 100644 system/src/Grav/Common/Twig/Twig.php create mode 100644 system/src/Grav/Common/Twig/TwigClockworkDataSource.php create mode 100644 system/src/Grav/Common/Twig/TwigClockworkDumper.php create mode 100644 system/src/Grav/Common/Twig/TwigEnvironment.php create mode 100644 system/src/Grav/Common/Twig/TwigExtension.php create mode 100644 system/src/Grav/Common/Twig/WriteCacheFileTrait.php create mode 100644 system/src/Grav/Common/Uri.php create mode 100644 system/src/Grav/Common/User/Access.php create mode 100644 system/src/Grav/Common/User/Authentication.php create mode 100644 system/src/Grav/Common/User/DataUser/User.php create mode 100644 system/src/Grav/Common/User/DataUser/UserCollection.php create mode 100644 system/src/Grav/Common/User/Group.php create mode 100644 system/src/Grav/Common/User/Interfaces/AuthorizeInterface.php create mode 100644 system/src/Grav/Common/User/Interfaces/UserCollectionInterface.php create mode 100644 system/src/Grav/Common/User/Interfaces/UserGroupInterface.php create mode 100644 system/src/Grav/Common/User/Interfaces/UserInterface.php create mode 100644 system/src/Grav/Common/User/Traits/UserTrait.php create mode 100644 system/src/Grav/Common/User/User.php create mode 100644 system/src/Grav/Common/Utils.php create mode 100644 system/src/Grav/Common/Yaml.php create mode 100644 system/src/Grav/Console/Application/Application.php create mode 100644 system/src/Grav/Console/Application/CommandLoader/PluginCommandLoader.php create mode 100644 system/src/Grav/Console/Application/GpmApplication.php create mode 100644 system/src/Grav/Console/Application/GravApplication.php create mode 100644 system/src/Grav/Console/Application/PluginApplication.php create mode 100644 system/src/Grav/Console/Cli/BackupCommand.php create mode 100644 system/src/Grav/Console/Cli/CleanCommand.php create mode 100644 system/src/Grav/Console/Cli/ClearCacheCommand.php create mode 100644 system/src/Grav/Console/Cli/ComposerCommand.php create mode 100644 system/src/Grav/Console/Cli/InstallCommand.php create mode 100644 system/src/Grav/Console/Cli/LogViewerCommand.php create mode 100644 system/src/Grav/Console/Cli/NewProjectCommand.php create mode 100644 system/src/Grav/Console/Cli/PageSystemValidatorCommand.php create mode 100644 system/src/Grav/Console/Cli/SandboxCommand.php create mode 100644 system/src/Grav/Console/Cli/SchedulerCommand.php create mode 100644 system/src/Grav/Console/Cli/SecurityCommand.php create mode 100644 system/src/Grav/Console/Cli/ServerCommand.php create mode 100644 system/src/Grav/Console/Cli/YamlLinterCommand.php create mode 100644 system/src/Grav/Console/ConsoleCommand.php create mode 100644 system/src/Grav/Console/ConsoleTrait.php create mode 100644 system/src/Grav/Console/Gpm/DirectInstallCommand.php create mode 100644 system/src/Grav/Console/Gpm/IndexCommand.php create mode 100644 system/src/Grav/Console/Gpm/InfoCommand.php create mode 100644 system/src/Grav/Console/Gpm/InstallCommand.php create mode 100644 system/src/Grav/Console/Gpm/SelfupgradeCommand.php create mode 100644 system/src/Grav/Console/Gpm/UninstallCommand.php create mode 100644 system/src/Grav/Console/Gpm/UpdateCommand.php create mode 100644 system/src/Grav/Console/Gpm/VersionCommand.php create mode 100644 system/src/Grav/Console/GpmCommand.php create mode 100644 system/src/Grav/Console/GravCommand.php create mode 100644 system/src/Grav/Console/Plugin/PluginListCommand.php create mode 100644 system/src/Grav/Console/TerminalObjects/Table.php create mode 100644 system/src/Grav/Events/BeforeSessionStartEvent.php create mode 100644 system/src/Grav/Events/FlexRegisterEvent.php create mode 100644 system/src/Grav/Events/PageEvent.php create mode 100644 system/src/Grav/Events/PermissionsRegisterEvent.php create mode 100644 system/src/Grav/Events/PluginsLoadedEvent.php create mode 100644 system/src/Grav/Events/SessionStartEvent.php create mode 100644 system/src/Grav/Events/TypesEvent.php create mode 100644 system/src/Grav/Framework/Acl/Access.php create mode 100644 system/src/Grav/Framework/Acl/Action.php create mode 100644 system/src/Grav/Framework/Acl/Permissions.php create mode 100644 system/src/Grav/Framework/Acl/PermissionsReader.php create mode 100644 system/src/Grav/Framework/Acl/RecursiveActionIterator.php create mode 100644 system/src/Grav/Framework/Cache/AbstractCache.php create mode 100644 system/src/Grav/Framework/Cache/Adapter/ChainCache.php create mode 100644 system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php create mode 100644 system/src/Grav/Framework/Cache/Adapter/FileCache.php create mode 100644 system/src/Grav/Framework/Cache/Adapter/MemoryCache.php create mode 100644 system/src/Grav/Framework/Cache/Adapter/SessionCache.php create mode 100644 system/src/Grav/Framework/Cache/CacheInterface.php create mode 100644 system/src/Grav/Framework/Cache/CacheTrait.php create mode 100644 system/src/Grav/Framework/Cache/Exception/CacheException.php create mode 100644 system/src/Grav/Framework/Cache/Exception/InvalidArgumentException.php create mode 100644 system/src/Grav/Framework/Collection/AbstractFileCollection.php create mode 100644 system/src/Grav/Framework/Collection/AbstractIndexCollection.php create mode 100644 system/src/Grav/Framework/Collection/AbstractLazyCollection.php create mode 100644 system/src/Grav/Framework/Collection/ArrayCollection.php create mode 100644 system/src/Grav/Framework/Collection/CollectionInterface.php create mode 100644 system/src/Grav/Framework/Collection/FileCollection.php create mode 100644 system/src/Grav/Framework/Collection/FileCollectionInterface.php create mode 100644 system/src/Grav/Framework/Compat/Serializable.php create mode 100644 system/src/Grav/Framework/ContentBlock/ContentBlock.php create mode 100644 system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php create mode 100644 system/src/Grav/Framework/ContentBlock/HtmlBlock.php create mode 100644 system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php create mode 100644 system/src/Grav/Framework/Contracts/Media/MediaObjectInterface.php create mode 100644 system/src/Grav/Framework/Contracts/Object/IdentifierInterface.php create mode 100644 system/src/Grav/Framework/Contracts/Relationships/RelationshipIdentifierInterface.php create mode 100644 system/src/Grav/Framework/Contracts/Relationships/RelationshipInterface.php create mode 100644 system/src/Grav/Framework/Contracts/Relationships/RelationshipsInterface.php create mode 100644 system/src/Grav/Framework/Contracts/Relationships/ToManyRelationshipInterface.php create mode 100644 system/src/Grav/Framework/Contracts/Relationships/ToOneRelationshipInterface.php create mode 100644 system/src/Grav/Framework/Controller/Traits/ControllerResponseTrait.php create mode 100644 system/src/Grav/Framework/DI/Container.php create mode 100644 system/src/Grav/Framework/File/AbstractFile.php create mode 100644 system/src/Grav/Framework/File/CsvFile.php create mode 100644 system/src/Grav/Framework/File/DataFile.php create mode 100644 system/src/Grav/Framework/File/File.php create mode 100644 system/src/Grav/Framework/File/Formatter/AbstractFormatter.php create mode 100644 system/src/Grav/Framework/File/Formatter/CsvFormatter.php create mode 100644 system/src/Grav/Framework/File/Formatter/FormatterInterface.php create mode 100644 system/src/Grav/Framework/File/Formatter/IniFormatter.php create mode 100644 system/src/Grav/Framework/File/Formatter/JsonFormatter.php create mode 100644 system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php create mode 100644 system/src/Grav/Framework/File/Formatter/SerializeFormatter.php create mode 100644 system/src/Grav/Framework/File/Formatter/YamlFormatter.php create mode 100644 system/src/Grav/Framework/File/IniFile.php create mode 100644 system/src/Grav/Framework/File/Interfaces/FileFormatterInterface.php create mode 100644 system/src/Grav/Framework/File/Interfaces/FileInterface.php create mode 100644 system/src/Grav/Framework/File/JsonFile.php create mode 100644 system/src/Grav/Framework/File/MarkdownFile.php create mode 100644 system/src/Grav/Framework/File/YamlFile.php create mode 100644 system/src/Grav/Framework/Filesystem/Filesystem.php create mode 100644 system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php create mode 100644 system/src/Grav/Framework/Flex/Flex.php create mode 100644 system/src/Grav/Framework/Flex/FlexCollection.php create mode 100644 system/src/Grav/Framework/Flex/FlexDirectory.php create mode 100644 system/src/Grav/Framework/Flex/FlexDirectoryForm.php create mode 100644 system/src/Grav/Framework/Flex/FlexForm.php create mode 100644 system/src/Grav/Framework/Flex/FlexFormFlash.php create mode 100644 system/src/Grav/Framework/Flex/FlexIdentifier.php create mode 100644 system/src/Grav/Framework/Flex/FlexIndex.php create mode 100644 system/src/Grav/Framework/Flex/FlexObject.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexCollectionInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexCommonInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexDirectoryFormInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexDirectoryInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexFormInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexIndexInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexObjectFormInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexObjectInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexStorageInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexTranslateInterface.php create mode 100644 system/src/Grav/Framework/Flex/Pages/FlexPageCollection.php create mode 100644 system/src/Grav/Framework/Flex/Pages/FlexPageIndex.php create mode 100644 system/src/Grav/Framework/Flex/Pages/FlexPageObject.php create mode 100644 system/src/Grav/Framework/Flex/Pages/Traits/PageAuthorsTrait.php create mode 100644 system/src/Grav/Framework/Flex/Pages/Traits/PageContentTrait.php create mode 100644 system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php create mode 100644 system/src/Grav/Framework/Flex/Pages/Traits/PageRoutableTrait.php create mode 100644 system/src/Grav/Framework/Flex/Pages/Traits/PageTranslateTrait.php create mode 100644 system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php create mode 100644 system/src/Grav/Framework/Flex/Storage/FileStorage.php create mode 100644 system/src/Grav/Framework/Flex/Storage/FolderStorage.php create mode 100644 system/src/Grav/Framework/Flex/Storage/SimpleStorage.php create mode 100644 system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php create mode 100644 system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php create mode 100644 system/src/Grav/Framework/Flex/Traits/FlexRelatedDirectoryTrait.php create mode 100644 system/src/Grav/Framework/Flex/Traits/FlexRelationshipsTrait.php create mode 100644 system/src/Grav/Framework/Form/FormFlash.php create mode 100644 system/src/Grav/Framework/Form/FormFlashFile.php create mode 100644 system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php create mode 100644 system/src/Grav/Framework/Form/Interfaces/FormFlashInterface.php create mode 100644 system/src/Grav/Framework/Form/Interfaces/FormInterface.php create mode 100644 system/src/Grav/Framework/Form/Traits/FormTrait.php create mode 100644 system/src/Grav/Framework/Interfaces/RenderInterface.php create mode 100644 system/src/Grav/Framework/Logger/Processors/UserProcessor.php create mode 100644 system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php create mode 100644 system/src/Grav/Framework/Media/Interfaces/MediaInterface.php create mode 100644 system/src/Grav/Framework/Media/Interfaces/MediaManipulationInterface.php create mode 100644 system/src/Grav/Framework/Media/Interfaces/MediaObjectInterface.php create mode 100644 system/src/Grav/Framework/Media/MediaIdentifier.php create mode 100644 system/src/Grav/Framework/Media/MediaObject.php create mode 100644 system/src/Grav/Framework/Media/UploadedMediaObject.php create mode 100644 system/src/Grav/Framework/Mime/MimeTypes.php create mode 100644 system/src/Grav/Framework/Object/Access/ArrayAccessTrait.php create mode 100644 system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php create mode 100644 system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php create mode 100644 system/src/Grav/Framework/Object/Access/NestedPropertyTrait.php create mode 100644 system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php create mode 100644 system/src/Grav/Framework/Object/ArrayObject.php create mode 100644 system/src/Grav/Framework/Object/Base/ObjectCollectionTrait.php create mode 100644 system/src/Grav/Framework/Object/Base/ObjectTrait.php create mode 100644 system/src/Grav/Framework/Object/Collection/ObjectExpressionVisitor.php create mode 100644 system/src/Grav/Framework/Object/Identifiers/Identifier.php create mode 100644 system/src/Grav/Framework/Object/Interfaces/NestedObjectCollectionInterface.php create mode 100644 system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php create mode 100644 system/src/Grav/Framework/Object/Interfaces/ObjectCollectionInterface.php create mode 100644 system/src/Grav/Framework/Object/Interfaces/ObjectInterface.php create mode 100644 system/src/Grav/Framework/Object/LazyObject.php create mode 100644 system/src/Grav/Framework/Object/ObjectCollection.php create mode 100644 system/src/Grav/Framework/Object/ObjectIndex.php create mode 100644 system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php create mode 100644 system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php create mode 100644 system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php create mode 100644 system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php create mode 100644 system/src/Grav/Framework/Object/PropertyObject.php create mode 100644 system/src/Grav/Framework/Pagination/AbstractPagination.php create mode 100644 system/src/Grav/Framework/Pagination/AbstractPaginationPage.php create mode 100644 system/src/Grav/Framework/Pagination/Interfaces/PaginationInterface.php create mode 100644 system/src/Grav/Framework/Pagination/Interfaces/PaginationPageInterface.php create mode 100644 system/src/Grav/Framework/Pagination/Pagination.php create mode 100644 system/src/Grav/Framework/Pagination/PaginationPage.php create mode 100644 system/src/Grav/Framework/Psr7/AbstractUri.php create mode 100644 system/src/Grav/Framework/Psr7/Request.php create mode 100644 system/src/Grav/Framework/Psr7/Response.php create mode 100644 system/src/Grav/Framework/Psr7/ServerRequest.php create mode 100644 system/src/Grav/Framework/Psr7/Stream.php create mode 100644 system/src/Grav/Framework/Psr7/Traits/MessageDecoratorTrait.php create mode 100644 system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php create mode 100644 system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php create mode 100644 system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php create mode 100644 system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php create mode 100644 system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php create mode 100644 system/src/Grav/Framework/Psr7/Traits/UriDecorationTrait.php create mode 100644 system/src/Grav/Framework/Psr7/UploadedFile.php create mode 100644 system/src/Grav/Framework/Psr7/Uri.php create mode 100644 system/src/Grav/Framework/Relationships/Relationships.php create mode 100644 system/src/Grav/Framework/Relationships/ToManyRelationship.php create mode 100644 system/src/Grav/Framework/Relationships/ToOneRelationship.php create mode 100644 system/src/Grav/Framework/Relationships/Traits/RelationshipTrait.php create mode 100644 system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php create mode 100644 system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php create mode 100644 system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php create mode 100644 system/src/Grav/Framework/RequestHandler/Exception/PageExpiredException.php create mode 100644 system/src/Grav/Framework/RequestHandler/Exception/RequestException.php create mode 100644 system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php create mode 100644 system/src/Grav/Framework/RequestHandler/Middlewares/MultipartRequestSupport.php create mode 100644 system/src/Grav/Framework/RequestHandler/RequestHandler.php create mode 100644 system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php create mode 100644 system/src/Grav/Framework/Route/Route.php create mode 100644 system/src/Grav/Framework/Route/RouteFactory.php create mode 100644 system/src/Grav/Framework/Session/Exceptions/SessionException.php create mode 100644 system/src/Grav/Framework/Session/Messages.php create mode 100644 system/src/Grav/Framework/Session/Session.php create mode 100644 system/src/Grav/Framework/Session/SessionInterface.php create mode 100644 system/src/Grav/Framework/Uri/Uri.php create mode 100644 system/src/Grav/Framework/Uri/UriFactory.php create mode 100644 system/src/Grav/Framework/Uri/UriPartsFilter.php create mode 100644 system/src/Grav/Installer/Install.php create mode 100644 system/src/Grav/Installer/InstallException.php create mode 100644 system/src/Grav/Installer/VersionUpdate.php create mode 100644 system/src/Grav/Installer/VersionUpdater.php create mode 100644 system/src/Grav/Installer/Versions.php create mode 100644 system/src/Grav/Installer/YamlUpdater.php create mode 100644 system/src/Grav/Installer/updates/1.7.0_2020-11-20_1.php create mode 100755 system/src/Twig/DeferredExtension/DeferredBlockNode.php create mode 100644 system/src/Twig/DeferredExtension/DeferredDeclareNode.php create mode 100644 system/src/Twig/DeferredExtension/DeferredExtension.php create mode 100644 system/src/Twig/DeferredExtension/DeferredInitializeNode.php create mode 100755 system/src/Twig/DeferredExtension/DeferredNode.php create mode 100644 system/src/Twig/DeferredExtension/DeferredNodeVisitor.php create mode 100644 system/src/Twig/DeferredExtension/DeferredNodeVisitorCompat.php create mode 100644 system/src/Twig/DeferredExtension/DeferredResolveNode.php create mode 100644 system/src/Twig/DeferredExtension/DeferredTokenParser.php create mode 100644 system/templates/default.html.twig create mode 100644 system/templates/external.html.twig create mode 100644 system/templates/flex/404.html.twig create mode 100644 system/templates/flex/_default/collection/debug.html.twig create mode 100644 system/templates/flex/_default/object/debug.html.twig create mode 100644 system/templates/modular/default.html.twig create mode 100644 system/templates/partials/messages.html.twig create mode 100644 system/templates/partials/metadata.html.twig create mode 100644 tests/_bootstrap.php create mode 100644 tests/_support/AcceptanceTester.php create mode 100644 tests/_support/FunctionalTester.php create mode 100644 tests/_support/Helper/Acceptance.php create mode 100644 tests/_support/Helper/Functional.php create mode 100644 tests/_support/Helper/Unit.php create mode 100644 tests/_support/UnitTester.php create mode 100644 tests/acceptance.suite.yml create mode 100644 tests/acceptance/_bootstrap.php create mode 100644 tests/fake/nested-site/user/pages/01.item1/01.item1-1/01.item1-1-1/default.md create mode 100644 tests/fake/nested-site/user/pages/01.item1/01.item1-1/02.item1-1-2/default.md create mode 100644 tests/fake/nested-site/user/pages/01.item1/01.item1-1/03.item1-1-3/default.md create mode 100644 tests/fake/nested-site/user/pages/01.item1/01.item1-1/default.md create mode 100644 tests/fake/nested-site/user/pages/01.item1/02.item1-2/01.item1-2-1/default.md create mode 100644 tests/fake/nested-site/user/pages/01.item1/02.item1-2/02.item1-2-2/default.md create mode 100644 tests/fake/nested-site/user/pages/01.item1/02.item1-2/03.item1-2-3/default.md create mode 100644 tests/fake/nested-site/user/pages/01.item1/02.item1-2/default.md create mode 100644 tests/fake/nested-site/user/pages/01.item1/03.item1-3/01.item1-3-1/default.md create mode 100644 tests/fake/nested-site/user/pages/01.item1/03.item1-3/02.item1-3-2/default.md create mode 100644 tests/fake/nested-site/user/pages/01.item1/03.item1-3/03.item1-3-3/default.md create mode 100644 tests/fake/nested-site/user/pages/01.item1/03.item1-3/default.md create mode 100644 tests/fake/nested-site/user/pages/01.item1/default.md create mode 100644 tests/fake/nested-site/user/pages/01.item1/existing-file.zip create mode 100644 tests/fake/nested-site/user/pages/01.item1/home-cache-image.jpg create mode 100644 tests/fake/nested-site/user/pages/01.item1/home-sample-image.jpg create mode 100644 tests/fake/nested-site/user/pages/02.item2/01.item2-1/01.item2-1-1/default.md create mode 100644 tests/fake/nested-site/user/pages/02.item2/01.item2-1/02.item2-1-2/default.md create mode 100644 tests/fake/nested-site/user/pages/02.item2/01.item2-1/03.item2-1-3/default.md create mode 100644 tests/fake/nested-site/user/pages/02.item2/01.item2-1/default.md create mode 100644 tests/fake/nested-site/user/pages/02.item2/02.item2-2/01.item2-2-1/default.md create mode 100644 tests/fake/nested-site/user/pages/02.item2/02.item2-2/02.item2-2-2/default.md create mode 100644 tests/fake/nested-site/user/pages/02.item2/02.item2-2/03.item2-2-3/default.md create mode 100644 tests/fake/nested-site/user/pages/02.item2/02.item2-2/cache-image.jpg create mode 100644 tests/fake/nested-site/user/pages/02.item2/02.item2-2/default.md create mode 100644 tests/fake/nested-site/user/pages/02.item2/02.item2-2/existing-file.zip create mode 100644 tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg create mode 100644 tests/fake/nested-site/user/pages/02.item2/03.item2-3/01.item2-3-1/default.md create mode 100644 tests/fake/nested-site/user/pages/02.item2/03.item2-3/02.item2-3-2/default.md create mode 100644 tests/fake/nested-site/user/pages/02.item2/03.item2-3/03.item2-3-3/default.md create mode 100644 tests/fake/nested-site/user/pages/02.item2/03.item2-3/default.md create mode 100644 tests/fake/nested-site/user/pages/02.item2/default.md create mode 100644 tests/fake/nested-site/user/pages/03.item3/01.item3-1/01.item3-1-1/default.md create mode 100644 tests/fake/nested-site/user/pages/03.item3/01.item3-1/02.item3-1-2/default.md create mode 100644 tests/fake/nested-site/user/pages/03.item3/01.item3-1/03.item3-1-3/default.md create mode 100644 tests/fake/nested-site/user/pages/03.item3/01.item3-1/default.md create mode 100644 tests/fake/nested-site/user/pages/03.item3/02.item3-2/01.item3-2-1/default.md create mode 100644 tests/fake/nested-site/user/pages/03.item3/02.item3-2/02.item3-2-2/default.md create mode 100644 tests/fake/nested-site/user/pages/03.item3/02.item3-2/03.item3-2-3/default.md create mode 100644 tests/fake/nested-site/user/pages/03.item3/02.item3-2/default.md create mode 100644 tests/fake/nested-site/user/pages/03.item3/03.item3-3/01.item3-3-1/default.md create mode 100644 tests/fake/nested-site/user/pages/03.item3/03.item3-3/02.item3-3-2/default.md create mode 100644 tests/fake/nested-site/user/pages/03.item3/03.item3-3/03.item3-3-3/default.md create mode 100644 tests/fake/nested-site/user/pages/03.item3/03.item3-3/default.md create mode 100644 tests/fake/nested-site/user/pages/03.item3/default.md create mode 100644 tests/fake/simple-site/user/pages/01.home/default.md create mode 100644 tests/fake/simple-site/user/pages/02.blog/blog.md create mode 100644 tests/fake/simple-site/user/pages/02.blog/post-one/item.md create mode 100644 tests/fake/simple-site/user/pages/02.blog/post-two/item.md create mode 100644 tests/fake/simple-site/user/pages/03.about/default.md create mode 100644 tests/fake/simple-site/user/pages/04.page-translated/default.en.md create mode 100644 tests/fake/simple-site/user/pages/04.page-translated/default.fr.md create mode 100644 tests/fake/simple-site/user/pages/05.translatedlong/part2/default.en.md create mode 100644 tests/fake/simple-site/user/pages/05.translatedlong/part2/default.fr.md create mode 100644 tests/fake/single-page-translated/user/pages/01.simple-page/default.en.md create mode 100644 tests/fake/single-page-translated/user/pages/01.simple-page/default.fr.md create mode 100644 tests/fake/single-pages/01.simple-page/default.md create mode 100644 tests/functional.suite.yml create mode 100644 tests/functional/Grav/Console/DirectInstallCommandTest.php create mode 100644 tests/functional/_bootstrap.php create mode 100644 tests/phpstan/classes/Toolbox/UniformResourceLocatorExtension.php create mode 100644 tests/phpstan/extension.neon create mode 100644 tests/phpstan/phpstan-bootstrap.php create mode 100644 tests/phpstan/phpstan.neon create mode 100644 tests/phpstan/plugins-bootstrap.php create mode 100644 tests/phpstan/plugins.neon create mode 100644 tests/unit.suite.yml create mode 100644 tests/unit/Grav/Common/AssetsTest.php create mode 100644 tests/unit/Grav/Common/BrowserTest.php create mode 100644 tests/unit/Grav/Common/ComposerTest.php create mode 100644 tests/unit/Grav/Common/Data/BlueprintTest.php create mode 100644 tests/unit/Grav/Common/GPM/GPMTest.php create mode 100644 tests/unit/Grav/Common/Helpers/ExcerptsTest.php create mode 100644 tests/unit/Grav/Common/InflectorTest.php create mode 100644 tests/unit/Grav/Common/Language/LanguageCodesTest.php create mode 100644 tests/unit/Grav/Common/Markdown/ParsedownTest.php create mode 100644 tests/unit/Grav/Common/Page/PagesTest.php create mode 100644 tests/unit/Grav/Common/Twig/Extensions/GravExtensionTest.php create mode 100644 tests/unit/Grav/Common/UriTest.php create mode 100644 tests/unit/Grav/Common/UtilsTest.php create mode 100644 tests/unit/Grav/Console/Gpm/InstallCommandTest.php create mode 100644 tests/unit/Grav/Framework/File/Formatter/CsvFormatterTest.php create mode 100644 tests/unit/Grav/Framework/Filesystem/FilesystemTest.php create mode 100644 tests/unit/_bootstrap.php create mode 100644 tests/unit/data/blueprints/strict.yaml create mode 100644 tmp/.gitkeep create mode 100644 tmp/forms/12506087c7af80c03c9739f878d53021/787b04d841521ef3800fd09c27ff60da/index.yaml create mode 100644 tmp/forms/5238a8ecca1f4e5830940b1418e0e0a9/858a5d9bb039a219b7a49584c3f89606/index.yaml create mode 100644 user/accounts/.gitkeep create mode 100644 user/config/media.yaml create mode 100644 user/config/site.yaml create mode 100644 user/config/system.yaml create mode 100644 user/config/themes/quark.yaml create mode 100644 user/data/.gitkeep create mode 100644 user/pages/01.home/02.diag/P3.png create mode 100644 user/pages/01.home/02.diag/Ptest.png create mode 100644 user/pages/01.home/02.diag/default.md create mode 100644 user/pages/01.home/03.boule/boule.md create mode 100644 user/pages/01.home/debut.md create mode 100644 user/plugins/.gitkeep create mode 100644 user/themes/.gitkeep create mode 100644 webserver-configs/Caddyfile create mode 100644 webserver-configs/Caddyfile-0.8.x create mode 100644 webserver-configs/htaccess.txt create mode 100644 webserver-configs/lighttpd.conf create mode 100644 webserver-configs/nginx.conf create mode 100644 webserver-configs/web.config diff --git a/.dependencies b/.dependencies new file mode 100644 index 0000000..86f4a79 --- /dev/null +++ b/.dependencies @@ -0,0 +1,34 @@ +git: + problems: + url: https://github.com/getgrav/grav-plugin-problems + path: user/plugins/problems + branch: master + error: + url: https://github.com/getgrav/grav-plugin-error + path: user/plugins/error + branch: master + markdown-notices: + url: https://github.com/getgrav/grav-plugin-markdown-notices + path: user/plugins/markdown-notices + branch: master + quark: + url: https://github.com/getgrav/grav-theme-quark + path: user/themes/quark + branch: master +links: + problems: + src: grav-plugin-problems + path: user/plugins/problems + scm: github + error: + src: grav-plugin-error + path: user/plugins/error + scm: github + markdown-notices: + src: grav-plugin-markdown-notices + path: user/plugins/markdown-notices + scm: github + quark: + src: grav-theme-quark + path: user/themes/quark + scm: github diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bb34874 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +# 2 space indentation +[*.{yaml,yml,vue,js,css}] +indent_size = 2 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e84f52b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,8 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: grav +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +custom: # Replace with a single custom sponsorship URL diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..6105124 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,81 @@ +name: Release Builds + +on: + release: + types: [published] + +permissions: {} + +jobs: + build: + permissions: + contents: write # for release creation (svenstaro/upload-release-action) + + if: "!github.event.release.prerelease" + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + with: + ref: ${{ github.ref }} + + - name: Extract Tag + run: echo "PACKAGE_VERSION=${{ github.ref }}" >> $GITHUB_ENV + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 7.3 + extensions: opcache, gd + tools: composer:v2 + coverage: none + env: + COMPOSER_TOKEN: ${{ secrets.GLOBAL_TOKEN }} + + - name: Install Dependencies + run: | + sudo apt-get -y update -qq < /dev/null > /dev/null + sudo apt-get -y install -qq git zip < /dev/null > /dev/null + + - name: Retrieval of Builder Scripts + run: | + # Real Grav URL + curl --silent -H "Authorization: token ${{ secrets.GLOBAL_TOKEN }}" -H "Accept: application/vnd.github.v3.raw" ${{ secrets.BUILD_SCRIPT_URL }} --output build-grav.sh + + # Development Local URL + # curl ${{ secrets.BUILD_SCRIPT_URL }} --output build-grav.sh + + - name: Grav Builder + run: | + bash ./build-grav.sh + + - name: Upload packages to release + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ env.PACKAGE_VERSION }} + file: ./grav-dist/*.zip + overwrite: true + file_glob: true + + slack: + permissions: + actions: read # to list jobs for workflow run (technote-space/workflow-conclusion-action) + + name: Slack + needs: build + runs-on: ubuntu-latest + if: always() + steps: + - uses: technote-space/workflow-conclusion-action@v2 + - uses: 8398a7/action-slack@v3 + with: + status: failure + fields: repo,message,author,action + icon_emoji: ':octocat:' + author_name: 'Github Action Build' + text: '🚚 Automated Build Failure' + env: + GITHUB_TOKEN: ${{ secrets.GLOBAL_TOKEN }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + if: env.WORKFLOW_CONCLUSION == 'failure' diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..7b705eb --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,68 @@ +name: PHP Tests + +on: + push: + branches: [ develop ] + pull_request: + branches: [ develop ] + +permissions: + contents: read # to fetch code (actions/checkout) + +jobs: + unit-tests: + strategy: + matrix: + php: ['8.3', '8.2', '8.1', '8.0', '7.4', '7.3'] + os: [ubuntu-latest] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: opcache, gd + tools: composer:v2 + coverage: none + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run test suite + run: vendor/bin/codecept run + +# slack: +# name: Slack +# needs: unit-tests +# runs-on: ubuntu-latest +# if: always() +# steps: +# - uses: technote-space/workflow-conclusion-action@v2 +# - uses: 8398a7/action-slack@v3 +# with: +# status: failure +# fields: repo,message,author,action +# icon_emoji: ':octocat:' +# author_name: 'Github Action Tests' +# text: '💥 Automated Test Failure' +# env: +# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +# SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} +# if: env.WORKFLOW_CONCLUSION == 'failure' diff --git a/.github/workflows/trigger-skeletons.yml b/.github/workflows/trigger-skeletons.yml new file mode 100644 index 0000000..b42b963 --- /dev/null +++ b/.github/workflows/trigger-skeletons.yml @@ -0,0 +1,48 @@ +name: Trigger Skeletons Build + +on: + workflow_dispatch: + inputs: + version: + description: 'Which Grav release to use' + required: true + default: 'latest' + admin: + description: 'Create also a package with Admin' + required: true + default: true + +permissions: + contents: read # to fetch code (actions/checkout) + +jobs: + build: + runs-on: ubuntu-latest + env: + WORKFLOW: "build-skeleton.yml" + AUTH: ":${{secrets.GLOBAL_TOKEN}}" + steps: + - uses: actions/checkout@v2 + - name: Make it rain ☔️ + run: | + SKELETONS=`curl -s "${{secrets.SKELETONS_JSON_LIST}}"` + echo "$SKELETONS" | jq -cr '.[]' | while read SKELETON; do + KEY=$(echo "$SKELETON" | jq -cr 'keys[0]') + VERSION=$(echo "$SKELETON" | jq -cr '.[]') + URL="https://api.github.com/repos/${KEY}/actions/workflows/${WORKFLOW}/dispatches" + + curl -X POST \ + -u "${AUTH}" \ + -H "Accept: application/vnd.github.everest-preview+json" \ + -H "Content-Type: application/json" \ + -sS \ + ${URL} \ + --data '{ "ref": "develop", + "inputs": { + "tag": "'"$VERSION"'", + "version": "'"$INPUT_VERSION"'", + "admin": "'"$INPUT_ADMIN"'" + } + }' > /dev/null + echo "Dispatched Worfklow for ${KEY}@$VERSION" + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2a78f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Composer +.composer +vendor/* +!*/vendor/* + +# Sass +.sass-cache + +# Grav Specific +backup/* +!backup/.* +cache/* +!cache/.* +assets/* +!assets/.* +logs/* +!logs/.* +images/* +!images/.* +user/accounts/* +!user/accounts/.* +user/data/* +!user/data/.* +user/plugins/* +!user/plugins/.* +user/themes/* +!user/themes/.* +user/**/config/security.yaml + +# Environments +.env +.gravenv + +# OS Generated +.DS_Store* +ehthumbs.db +Icon? +Thumbs.db +*.swp + +# phpstorm +.idea/* + +# testing stuff +tests/_output/* +tests/_support/_generated/* +tests/cache/* +tests/error.log +system/templates/testing/* +/user/config/versions.yaml diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..098c582 --- /dev/null +++ b/.htaccess @@ -0,0 +1,78 @@ + + +RewriteEngine On + +## Begin RewriteBase +# If you are getting 500 or 404 errors on subpages, you may have to uncomment the RewriteBase entry +# You should change the '/' to your appropriate subfolder. For example if you have +# your Grav install at the root of your site '/' should work, else it might be something +# along the lines of: RewriteBase / +## + +# RewriteBase / + +## End - RewriteBase + +## Begin - X-Forwarded-Proto +# In some hosted or load balanced environments, SSL negotiation happens upstream. +# In order for Grav to recognize the connection as secure, you need to uncomment +# the following lines. +# +# RewriteCond %{HTTP:X-Forwarded-Proto} https +# RewriteRule .* - [E=HTTPS:on] +# +## End - X-Forwarded-Proto + +## Begin - Exploits +# If you experience problems on your site block out the operations listed below +# This attempts to block the most common type of exploit `attempts` to Grav +# +# Block out any script trying to use twig tags in URL. +RewriteCond %{REQUEST_URI} ({{|}}|{%|%}) [OR] +RewriteCond %{QUERY_STRING} ({{|}}|{%25|%25}) [OR] +# Block out any script trying to base64_encode data within the URL. +RewriteCond %{QUERY_STRING} base64_encode[^(]*\([^)]*\) [OR] +# Block out any script that includes a + markdown: false + + status_enhanced: + type: display + label: + content: | + + + modern_health: + type: display + label: Health Status + content: | +
+
Checking health...
+
+ + markdown: false + + trigger_methods: + type: display + label: Active Triggers + content: | +
+
Checking triggers...
+
+ + markdown: false + + jobs_tab: + type: tab + title: PLUGIN_ADMIN.SCHEDULER_JOBS + + fields: + jobs_title: + type: section + title: PLUGIN_ADMIN.SCHEDULER_JOBS + underline: true + + custom_jobs: + type: list + style: vertical + label: + classes: cron-job-list compact + key: id + fields: + .id: + type: key + label: ID + placeholder: 'process-name' + validate: + required: true + pattern: '[a-zа-я0-9_\-]+' + max: 20 + message: 'ID must be lowercase with dashes/underscores only and less than 20 characters' + .command: + type: text + label: PLUGIN_ADMIN.COMMAND + placeholder: 'ls' + validate: + required: true + .args: + type: text + label: PLUGIN_ADMIN.EXTRA_ARGUMENTS + placeholder: '-lah' + .at: + type: text + wrapper_classes: cron-selector + label: PLUGIN_ADMIN.SCHEDULER_RUNAT + help: PLUGIN_ADMIN.SCHEDULER_RUNAT_HELP + placeholder: '* * * * *' + validate: + required: true + .output: + type: text + label: PLUGIN_ADMIN.SCHEDULER_OUTPUT + help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_HELP + placeholder: 'logs/ls-cron.out' + .output_mode: + type: select + label: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE + help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE_HELP + default: append + options: + append: Append + overwrite: Overwrite + .email: + type: text + label: PLUGIN_ADMIN.SCHEDULER_EMAIL + help: PLUGIN_ADMIN.SCHEDULER_EMAIL_HELP + placeholder: 'notifications@yoursite.com' + + modern_tab: + type: tab + title: Advanced Features + + fields: + workers_section: + type: section + title: Worker Configuration + underline: true + + fields: + modern.workers: + type: number + label: Concurrent Workers + help: Number of jobs that can run simultaneously (1 = sequential) + default: 4 + size: x-small + append: workers + validate: + type: int + min: 1 + max: 10 + + retry_section: + type: section + title: Retry Configuration + underline: true + + fields: + modern.retry.enabled: + type: toggle + label: Enable Job Retry + help: Automatically retry failed jobs + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + modern.retry.max_attempts: + type: number + label: Maximum Retry Attempts + help: Maximum number of times to retry a failed job + default: 3 + size: x-small + append: retries + validate: + type: int + min: 1 + max: 10 + + modern.retry.backoff: + type: select + label: Retry Backoff Strategy + help: How to calculate delay between retries + default: exponential + options: + linear: Linear (fixed delay) + exponential: Exponential (increasing delay) + + queue_section: + type: section + title: Queue Configuration + underline: true + + fields: + modern.queue.path: + type: text + label: Queue Storage Path + help: Where to store queued jobs + default: 'user-data://scheduler/queue' + placeholder: 'user-data://scheduler/queue' + + modern.queue.max_size: + type: number + label: Maximum Queue Size + help: Maximum number of jobs that can be queued + default: 1000 + size: x-small + append: jobs + validate: + type: int + min: 100 + max: 10000 + + history_section: + type: section + title: Job History + underline: true + + fields: + modern.history.enabled: + type: toggle + label: Enable Job History + help: Track execution history for all jobs + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + modern.history.retention_days: + type: number + label: History Retention (days) + help: How long to keep job history + default: 30 + size: x-small + append: days + validate: + type: int + min: 1 + max: 365 + + webhook_section: + type: section + title: Webhook Configuration + underline: true + + fields: + webhook_plugin_status: + type: webhook-status + label: + modern.webhook.enabled: + type: toggle + label: Enable Webhook Triggers + help: Allow triggering scheduler via HTTP webhook + highlight: 0 + default: 0 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + modern.webhook.token: + type: text + label: Webhook Security Token + help: Secret token for authenticating webhook requests. Keep this secret! + placeholder: 'Click Generate to create a secure token' + autocomplete: 'off' + + webhook_token_generate: + type: display + label: + content: | +
+ +
+ + markdown: false + + modern.webhook.path: + type: text + label: Webhook Path + help: URL path for webhook endpoint + default: '/scheduler/webhook' + placeholder: '/scheduler/webhook' + + health_section: + type: section + title: Health Check Configuration + underline: true + + fields: + modern.health.enabled: + type: toggle + label: Enable Health Check + help: Provide health status endpoint for monitoring + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + modern.health.path: + type: text + label: Health Check Path + help: URL path for health check endpoint + default: '/scheduler/health' + placeholder: '/scheduler/health' + + webhook_usage: + type: section + title: Usage Examples + underline: true + + fields: + webhook_examples: + type: display + label: + content: | + +
+ + +
+

How to use webhooks:

+ +
+ +
+ +
Copy
+
+
+ +
+ +
+ +
Copy
+
+
+ +
+ +
+ +
Copy
+
+
+ +
+

GitHub Actions example:

+
- name: Trigger Scheduler
+                                                  run: |
+                                                    curl -X POST ${{ secrets.SITE_URL }}/scheduler/webhook \
+                                                      -H "Authorization: Bearer ${{ secrets.WEBHOOK_TOKEN }}"
+
+
+
+ markdown: false + + + + diff --git a/system/blueprints/config/security.yaml b/system/blueprints/config/security.yaml new file mode 100644 index 0000000..4e93e9b --- /dev/null +++ b/system/blueprints/config/security.yaml @@ -0,0 +1,119 @@ +title: PLUGIN_ADMIN.SECURITY + +form: + validation: loose + fields: + + xss_section: + type: section + title: PLUGIN_ADMIN.XSS_SECURITY + underline: true + + xss_whitelist: + type: selectize + size: large + label: PLUGIN_ADMIN.XSS_WHITELIST_PERMISSIONS + help: PLUGIN_ADMIN.XSS_WHITELIST_PERMISSIONS_HELP + placeholder: 'admin.super' + classes: fancy + validate: + type: commalist + + xss_enabled.on_events: + type: toggle + label: PLUGIN_ADMIN.XSS_ON_EVENTS + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + default: true + validate: + type: bool + + xss_enabled.invalid_protocols: + type: toggle + label: PLUGIN_ADMIN.XSS_INVALID_PROTOCOLS + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + default: true + validate: + type: bool + + xss_invalid_protocols: + type: selectize + size: large + label: PLUGIN_ADMIN.XSS_INVALID_PROTOCOLS_LIST + classes: fancy + validate: + type: commalist + + xss_enabled.moz_binding: + type: toggle + label: PLUGIN_ADMIN.XSS_MOZ_BINDINGS + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + default: true + validate: + type: bool + + xss_enabled.html_inline_styles: + type: toggle + label: PLUGIN_ADMIN.XSS_HTML_INLINE_STYLES + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + default: true + validate: + type: bool + + xss_enabled.dangerous_tags: + type: toggle + label: PLUGIN_ADMIN.XSS_DANGEROUS_TAGS + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + default: true + validate: + type: bool + + xss_dangerous_tags: + type: selectize + size: large + label: PLUGIN_ADMIN.XSS_DANGEROUS_TAGS_LIST + classes: fancy + validate: + type: commalist + + uploads_section: + type: section + title: PLUGIN_ADMIN.UPLOADS_SECURITY + underline: true + + + uploads_dangerous_extensions: + type: selectize + size: large + label: PLUGIN_ADMIN.UPLOADS_DANGEROUS_EXTENSIONS + help: PLUGIN_ADMIN.UPLOADS_DANGEROUS_EXTENSIONS_HELP + classes: fancy + validate: + type: commalist + + + sanitize_svg: + type: toggle + label: PLUGIN_ADMIN.SANITIZE_SVG + help: PLUGIN_ADMIN.SANITIZE_SVG_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + default: true + validate: + type: bool diff --git a/system/blueprints/config/site.yaml b/system/blueprints/config/site.yaml new file mode 100644 index 0000000..6603823 --- /dev/null +++ b/system/blueprints/config/site.yaml @@ -0,0 +1,124 @@ +title: PLUGIN_ADMIN.SITE +form: + validation: loose + fields: + + content: + type: section + title: PLUGIN_ADMIN.DEFAULTS + underline: true + + fields: + title: + type: text + label: PLUGIN_ADMIN.SITE_TITLE + size: large + placeholder: PLUGIN_ADMIN.SITE_TITLE_PLACEHOLDER + help: PLUGIN_ADMIN.SITE_TITLE_HELP + + default_lang: + type: text + label: PLUGIN_ADMIN.SITE_DEFAULT_LANG + size: x-small + placeholder: PLUGIN_ADMIN.SITE_DEFAULT_LANG_PLACEHOLDER + help: PLUGIN_ADMIN.SITE_DEFAULT_LANG_HELP + + author.name: + type: text + size: large + label: PLUGIN_ADMIN.DEFAULT_AUTHOR + help: PLUGIN_ADMIN.DEFAULT_AUTHOR_HELP + + author.email: + type: text + size: large + label: PLUGIN_ADMIN.DEFAULT_EMAIL + help: PLUGIN_ADMIN.DEFAULT_EMAIL_HELP + validate: + type: email + + taxonomies: + type: selectize + size: large + label: PLUGIN_ADMIN.TAXONOMY_TYPES + classes: fancy + help: PLUGIN_ADMIN.TAXONOMY_TYPES_HELP + validate: + type: commalist + + summary: + type: section + title: PLUGIN_ADMIN.PAGE_SUMMARY + underline: true + + fields: + summary.enabled: + type: toggle + label: PLUGIN_ADMIN.ENABLED + highlight: 1 + help: PLUGIN_ADMIN.ENABLED_HELP + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + summary.size: + type: text + size: small + append: PLUGIN_ADMIN.CHARACTERS + label: PLUGIN_ADMIN.SUMMARY_SIZE + help: PLUGIN_ADMIN.SUMMARY_SIZE_HELP + validate: + type: int + min: 0 + max: 65536 + + summary.format: + type: toggle + label: PLUGIN_ADMIN.FORMAT + classes: fancy + help: PLUGIN_ADMIN.FORMAT_HELP + highlight: short + options: + 'short': PLUGIN_ADMIN.SHORT + 'long': PLUGIN_ADMIN.LONG + + summary.delimiter: + type: text + size: x-small + label: PLUGIN_ADMIN.DELIMITER + help: PLUGIN_ADMIN.DELIMITER_HELP + + metadata: + type: section + title: PLUGIN_ADMIN.METADATA + underline: true + + fields: + metadata: + type: array + label: PLUGIN_ADMIN.METADATA + help: PLUGIN_ADMIN.METADATA_HELP + placeholder_key: PLUGIN_ADMIN.METADATA_KEY + placeholder_value: PLUGIN_ADMIN.METADATA_VALUE + + routes: + type: section + title: PLUGIN_ADMIN.REDIRECTS_AND_ROUTES + underline: true + + fields: + redirects: + type: array + label: PLUGIN_ADMIN.CUSTOM_REDIRECTS + help: PLUGIN_ADMIN.CUSTOM_REDIRECTS_HELP + placeholder_key: PLUGIN_ADMIN.CUSTOM_REDIRECTS_PLACEHOLDER_KEY + placeholder_value: PLUGIN_ADMIN.CUSTOM_REDIRECTS_PLACEHOLDER_VALUE + + routes: + type: array + label: PLUGIN_ADMIN.CUSTOM_ROUTES + help: PLUGIN_ADMIN.CUSTOM_ROUTES_HELP + placeholder_key: PLUGIN_ADMIN.CUSTOM_ROUTES_PLACEHOLDER_KEY + placeholder_value: PLUGIN_ADMIN.CUSTOM_ROUTES_PLACEHOLDER_VALUE diff --git a/system/blueprints/config/streams.yaml b/system/blueprints/config/streams.yaml new file mode 100644 index 0000000..c73b80b --- /dev/null +++ b/system/blueprints/config/streams.yaml @@ -0,0 +1,8 @@ +title: PLUGIN_ADMIN.FILE_STREAMS + +form: + validation: loose + hidden: true + fields: + schemes.xxx: + type: array diff --git a/system/blueprints/config/system.yaml b/system/blueprints/config/system.yaml new file mode 100644 index 0000000..13b04d2 --- /dev/null +++ b/system/blueprints/config/system.yaml @@ -0,0 +1,1917 @@ +title: PLUGIN_ADMIN.SYSTEM + +form: + validation: loose + fields: + + system_tabs: + type: tabs + classes: side-tabs + + fields: + content: + type: tab + title: PLUGIN_ADMIN.CONTENT + + fields: + content_section: + type: section + title: PLUGIN_ADMIN.CONTENT + underline: true + + home.alias: + type: pages + size: large + classes: fancy + label: PLUGIN_ADMIN.HOME_PAGE + show_all: false + show_modular: false + show_root: false + show_slug: true + help: PLUGIN_ADMIN.HOME_PAGE_HELP + + home.hide_in_urls: + type: toggle + label: PLUGIN_ADMIN.HIDE_HOME_IN_URLS + help: PLUGIN_ADMIN.HIDE_HOME_IN_URLS_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + pages.theme: + type: themeselect + classes: fancy + selectize: true + size: medium + label: PLUGIN_ADMIN.DEFAULT_THEME + help: PLUGIN_ADMIN.DEFAULT_THEME_HELP + + pages.process: + type: checkboxes + label: PLUGIN_ADMIN.PROCESS + help: PLUGIN_ADMIN.PROCESS_HELP + default: [markdown: true, twig: true] + options: + markdown: Markdown + twig: Twig + use: keys + + pages.types: + type: array + label: PLUGIN_ADMIN.PAGE_TYPES + help: PLUGIN_ADMIN.PAGE_TYPES_HELP + size: small + default: ['html','htm','json','xml','txt','rss','atom'] + value_only: true + + timezone: + type: select + label: PLUGIN_ADMIN.TIMEZONE + size: medium + classes: fancy + help: PLUGIN_ADMIN.TIMEZONE_HELP + data-options@: '\Grav\Common\Utils::timezones' + default: '' + options: + '': 'Default (Server Timezone)' + + pages.dateformat.default: + type: select + size: medium + selectize: + create: true + label: PLUGIN_ADMIN.DEFAULT_DATE_FORMAT + help: PLUGIN_ADMIN.DEFAULT_DATE_FORMAT_HELP + placeholder: PLUGIN_ADMIN.DEFAULT_DATE_FORMAT_PLACEHOLDER + data-options@: '\Grav\Common\Utils::dateFormats' + validate: + type: string + + pages.dateformat.short: + type: dateformat + size: medium + classes: fancy + label: PLUGIN_ADMIN.SHORT_DATE_FORMAT + help: PLUGIN_ADMIN.SHORT_DATE_FORMAT_HELP + default: "jS M Y" + options: + "F jS \\a\\t g:ia": Date1 + "l jS \\of F g:i A": Date2 + "D, d M Y G:i:s": Date3 + "d-m-y G:i": Date4 + "jS M Y": Date5 + "Y-m-d G:i": Date6 + + pages.dateformat.long: + type: dateformat + size: medium + classes: fancy + label: PLUGIN_ADMIN.LONG_DATE_FORMAT + help: PLUGIN_ADMIN.LONG_DATE_FORMAT_HELP + options: + "F jS \\a\\t g:ia": Date1 + "l jS \\of F g:i A": Date2 + "D, d M Y G:i:s": Date3 + "d-m-y G:i": Date4 + "jS M Y": Date5 + "Y-m-d G:i:s": Date6 + + pages.order.by: + type: select + size: large + classes: fancy + label: PLUGIN_ADMIN.DEFAULT_ORDERING + help: PLUGIN_ADMIN.DEFAULT_ORDERING_HELP + options: + default: PLUGIN_ADMIN.DEFAULT_ORDERING_DEFAULT + folder: PLUGIN_ADMIN.DEFAULT_ORDERING_FOLDER + title: PLUGIN_ADMIN.DEFAULT_ORDERING_TITLE + date: PLUGIN_ADMIN.DEFAULT_ORDERING_DATE + + pages.order.dir: + type: toggle + label: PLUGIN_ADMIN.DEFAULT_ORDER_DIRECTION + highlight: asc + default: desc + help: PLUGIN_ADMIN.DEFAULT_ORDER_DIRECTION_HELP + options: + asc: PLUGIN_ADMIN.ASCENDING + desc: PLUGIN_ADMIN.DESCENDING + + pages.list.count: + type: text + size: x-small + append: PLUGIN_ADMIN.PAGES + label: PLUGIN_ADMIN.DEFAULT_PAGE_COUNT + help: PLUGIN_ADMIN.DEFAULT_PAGE_COUNT_HELP + validate: + type: number + min: 1 + + pages.publish_dates: + type: toggle + label: PLUGIN_ADMIN.DATE_BASED_PUBLISHING + help: PLUGIN_ADMIN.DATE_BASED_PUBLISHING_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + pages.events: + type: checkboxes + label: PLUGIN_ADMIN.EVENTS + help: PLUGIN_ADMIN.EVENTS_HELP + default: [page: true, twig: true] + options: + page: Page Events + twig: Twig Events + use: keys + + pages.append_url_extension: + type: text + size: x-small + placeholder: "e.g. .html" + label: PLUGIN_ADMIN.APPEND_URL_EXT + help: PLUGIN_ADMIN.APPEND_URL_EXT_HELP + + pages.redirect_default_code: + type: select + size: medium + classes: fancy + label: PLUGIN_ADMIN.REDIRECT_DEFAULT_CODE + help: PLUGIN_ADMIN.REDIRECT_DEFAULT_CODE_HELP + default: 302 + options: + 301: PLUGIN_ADMIN.REDIRECT_OPTION_301 + 302: PLUGIN_ADMIN.REDIRECT_OPTION_302 + 303: PLUGIN_ADMIN.REDIRECT_OPTION_303 + + pages.redirect_default_route: + type: select + size: medium + classes: fancy + label: PLUGIN_ADMIN.REDIRECT_DEFAULT_ROUTE + help: PLUGIN_ADMIN.REDIRECT_DEFAULT_ROUTE_HELP + default: 0 + options: + 0: PLUGIN_ADMIN.REDIRECT_OPTION_NO_REDIRECT + 1: PLUGIN_ADMIN.REDIRECT_OPTION_DEFAULT_REDIRECT + 301: PLUGIN_ADMIN.REDIRECT_OPTION_301 + 302: PLUGIN_ADMIN.REDIRECT_OPTION_302 + validate: + type: int + + pages.redirect_trailing_slash: + type: select + size: medium + classes: fancy + label: PLUGIN_ADMIN.REDIRECT_TRAILING_SLASH + help: PLUGIN_ADMIN.REDIRECT_TRAILING_SLASH_HELP + default: 1 + options: + 0: PLUGIN_ADMIN.REDIRECT_OPTION_NO_REDIRECT + 1: PLUGIN_ADMIN.REDIRECT_OPTION_DEFAULT_REDIRECT + 301: PLUGIN_ADMIN.REDIRECT_OPTION_301 + 302: PLUGIN_ADMIN.REDIRECT_OPTION_302 + validate: + type: int + + pages.ignore_hidden: + type: toggle + label: PLUGIN_ADMIN.IGNORE_HIDDEN + help: PLUGIN_ADMIN.IGNORE_HIDDEN_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + pages.ignore_files: + type: selectize + size: large + label: PLUGIN_ADMIN.IGNORE_FILES + help: PLUGIN_ADMIN.IGNORE_FILES_HELP + classes: fancy + validate: + type: commalist + + pages.ignore_folders: + type: selectize + size: large + label: PLUGIN_ADMIN.IGNORE_FOLDERS + help: PLUGIN_ADMIN.IGNORE_FOLDERS_HELP + classes: fancy + validate: + type: commalist + + pages.hide_empty_folders: + type: toggle + label: PLUGIN_ADMIN.HIDE_EMPTY_FOLDERS + help: PLUGIN_ADMIN.HIDE_EMPTY_FOLDERS_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + pages.url_taxonomy_filters: + type: toggle + label: PLUGIN_ADMIN.ALLOW_URL_TAXONOMY_FILTERS + help: PLUGIN_ADMIN.ALLOW_URL_TAXONOMY_FILTERS_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + pages.twig_first: + type: toggle + label: PLUGIN_ADMIN.TWIG_FIRST + help: PLUGIN_ADMIN.TWIG_FIRST_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + pages.never_cache_twig: + type: toggle + label: PLUGIN_ADMIN.NEVER_CACHE_TWIG + help: PLUGIN_ADMIN.NEVER_CACHE_TWIG_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + pages.frontmatter.process_twig: + type: toggle + label: PLUGIN_ADMIN.FRONTMATTER_PROCESS_TWIG + help: PLUGIN_ADMIN.FRONTMATTER_PROCESS_TWIG_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + pages.frontmatter.ignore_fields: + type: selectize + size: large + placeholder: "e.g. forms" + label: PLUGIN_ADMIN.FRONTMATTER_IGNORE_FIELDS + help: PLUGIN_ADMIN.FRONTMATTER_IGNORE_FIELDS_HELP + classes: fancy + validate: + type: commalist + + languages: + type: tab + title: PLUGIN_ADMIN.LANGUAGES + + fields: + languages-section: + type: section + title: PLUGIN_ADMIN.LANGUAGES + underline: true + + languages.supported: + type: selectize + size: large + placeholder: "e.g. en, fr" + label: PLUGIN_ADMIN.SUPPORTED + help: PLUGIN_ADMIN.SUPPORTED_HELP + classes: fancy + validate: + type: commalist + + languages.default_lang: + type: text + size: x-small + label: PLUGIN_ADMIN.DEFAULT_LANG + help: PLUGIN_ADMIN.DEFAULT_LANG_HELP + + languages.include_default_lang: + type: toggle + label: PLUGIN_ADMIN.INCLUDE_DEFAULT_LANG + help: PLUGIN_ADMIN.INCLUDE_DEFAULT_LANG_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + languages.include_default_lang_file_extension: + type: toggle + label: PLUGIN_ADMIN.INCLUDE_DEFAULT_LANG_FILE_EXTENSION + help: PLUGIN_ADMIN.INCLUDE_DEFAULT_LANG_HELP_FILE_EXTENSION + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + languages.content_fallback: + type: list + label: PLUGIN_ADMIN.CONTENT_LANGUAGE_FALLBACKS + help: PLUGIN_ADMIN.CONTENT_LANGUAGE_FALLBACKS_HELP + fields: + key: + type: key + label: PLUGIN_ADMIN.LANGUAGE + help: PLUGIN_ADMIN.CONTENT_FALLBACK_LANGUAGE_HELP + placeholder: fr-ca + value: + type: selectize + size: large + placeholder: "fr, en" + label: PLUGIN_ADMIN.CONTENT_LANGUAGE_FALLBACK + help: PLUGIN_ADMIN.CONTENT_LANGUAGE_FALLBACK_HELP + classes: fancy +# TODO: does not work. +# validate: +# type: commalist + + languages.pages_fallback_only: + type: toggle + label: PLUGIN_ADMIN.PAGES_FALLBACK_ONLY + help: PLUGIN_ADMIN.PAGES_FALLBACK_ONLY_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + languages.translations: + type: toggle + label: PLUGIN_ADMIN.LANGUAGE_TRANSLATIONS + help: PLUGIN_ADMIN.LANGUAGE_TRANSLATIONS_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + languages.translations_fallback: + type: toggle + label: PLUGIN_ADMIN.TRANSLATIONS_FALLBACK + help: PLUGIN_ADMIN.TRANSLATIONS_FALLBACK_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + languages.session_store_active: + type: toggle + label: PLUGIN_ADMIN.ACTIVE_LANGUAGE_IN_SESSION + help: PLUGIN_ADMIN.ACTIVE_LANGUAGE_IN_SESSION_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + languages.http_accept_language: + type: toggle + label: PLUGIN_ADMIN.HTTP_ACCEPT_LANGUAGE + help: PLUGIN_ADMIN.HTTP_ACCEPT_LANGUAGE_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + languages.override_locale: + type: toggle + label: PLUGIN_ADMIN.OVERRIDE_LOCALE + help: PLUGIN_ADMIN.OVERRIDE_LOCALE_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + languages.debug: + type: toggle + label: PLUGIN_ADMIN.LANGUAGE_DEBUG + help: PLUGIN_ADMIN.LANGUAGE_DEBUG_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + http_headers: + type: tab + title: PLUGIN_ADMIN.HTTP_HEADERS + + fields: + http_headers_section: + type: section + title: PLUGIN_ADMIN.HTTP_HEADERS + underline: true + + pages.expires: + type: text + size: x-small + append: GRAV.NICETIME.SECOND_PLURAL + label: PLUGIN_ADMIN.EXPIRES + help: PLUGIN_ADMIN.EXPIRES_HELP + validate: + type: number + min: 1 + pages.cache_control: + type: text + size: medium + label: PLUGIN_ADMIN.CACHE_CONTROL + help: PLUGIN_ADMIN.CACHE_CONTROL_HELP + placeholder: 'e.g. public, max-age=31536000' + pages.last_modified: + type: toggle + label: PLUGIN_ADMIN.LAST_MODIFIED + help: PLUGIN_ADMIN.LAST_MODIFIED_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + pages.etag: + type: toggle + label: PLUGIN_ADMIN.ETAG + help: PLUGIN_ADMIN.ETAG_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + pages.vary_accept_encoding: + type: toggle + label: PLUGIN_ADMIN.VARY_ACCEPT_ENCODING + help: PLUGIN_ADMIN.VARY_ACCEPT_ENCODING_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + markdown: + type: tab + title: PLUGIN_ADMIN.MARKDOWN + + fields: + markdow_section: + type: section + title: PLUGIN_ADMIN.MARKDOWN + underline: true + + pages.markdown.extra: + type: toggle + label: Markdown extra + help: PLUGIN_ADMIN.MARKDOWN_EXTRA_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + pages.markdown.auto_line_breaks: + type: toggle + label: PLUGIN_ADMIN.AUTO_LINE_BREAKS + help: PLUGIN_ADMIN.AUTO_LINE_BREAKS_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + pages.markdown.auto_url_links: + type: toggle + label: PLUGIN_ADMIN.AUTO_URL_LINKS + help: PLUGIN_ADMIN.AUTO_URL_LINKS_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + pages.markdown.escape_markup: + type: toggle + label: PLUGIN_ADMIN.ESCAPE_MARKUP + help: PLUGIN_ADMIN.ESCAPE_MARKUP_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + pages.markdown.valid_link_attributes: + type: selectize + size: large + label: PLUGIN_ADMIN.VALID_LINK_ATTRIBUTES + help: PLUGIN_ADMIN.VALID_LINK_ATTRIBUTES_HELP + placeholder: "rel, target, id, class, classes" + classes: fancy + validate: + type: commalist + + caching: + type: tab + title: PLUGIN_ADMIN.CACHING + + fields: + caching_section: + type: section + title: PLUGIN_ADMIN.CACHING + underline: true + + cache.enabled: + type: toggle + label: PLUGIN_ADMIN.CACHING + help: PLUGIN_ADMIN.CACHING_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + cache.check.method: + type: select + size: medium + classes: fancy + label: PLUGIN_ADMIN.CACHE_CHECK_METHOD + help: PLUGIN_ADMIN.CACHE_CHECK_METHOD_HELP + options: + file: Markdown + Yaml file timestamps + folder: Folder timestamps + hash: All files timestamps + none: No timestamp checking + + cache.driver: + type: select + size: small + classes: fancy + label: PLUGIN_ADMIN.CACHE_DRIVER + help: PLUGIN_ADMIN.CACHE_DRIVER_HELP + options: + auto: Auto detect + file: File + apc: APC + apcu: APCu + memcache: Memcache + memcached: Memcached + wincache: WinCache + redis: Redis + + cache.prefix: + type: text + size: x-small + label: PLUGIN_ADMIN.CACHE_PREFIX + help: PLUGIN_ADMIN.CACHE_PREFIX_HELP + placeholder: PLUGIN_ADMIN.CACHE_PREFIX_PLACEHOLDER + + cache.purge_max_age_days: + type: text + size: x-small + append: GRAV.NICETIME.DAY_PLURAL + label: PLUGIN_ADMIN.CACHE_PURGE_AGE + help: PLUGIN_ADMIN.CACHE_PURGE_AGE_HELP + validate: + type: number + min: 1 + max: 365 + step: 1 + default: 30 + + cache.purge_at: + type: cron + label: PLUGIN_ADMIN.CACHE_PURGE_JOB + help: PLUGIN_ADMIN.CACHE_PURGE_JOB_HELP + default: '* 4 * * *' + + cache.clear_at: + type: cron + label: PLUGIN_ADMIN.CACHE_CLEAR_JOB + help: PLUGIN_ADMIN.CACHE_CLEAR_JOB_HELP + default: '* 3 * * *' + + cache.clear_job_type: + type: select + size: medium + label: PLUGIN_ADMIN.CACHE_JOB_TYPE + help: PLUGIN_ADMIN.CACHE_JOB_TYPE_HELP + options: + standard: Standard Cache Folders + all: All Cache Folders + + cache.clear_images_by_default: + type: toggle + label: PLUGIN_ADMIN.CLEAR_IMAGES_BY_DEFAULT + help: PLUGIN_ADMIN.CLEAR_IMAGES_BY_DEFAULT_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + cache.cli_compatibility: + type: toggle + label: PLUGIN_ADMIN.CLI_COMPATIBILITY + help: PLUGIN_ADMIN.CLI_COMPATIBILITY_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + cache.lifetime: + type: text + size: small + append: GRAV.NICETIME.SECOND_PLURAL + label: PLUGIN_ADMIN.LIFETIME + help: PLUGIN_ADMIN.LIFETIME_HELP + validate: + type: number + + cache.gzip: + type: toggle + label: PLUGIN_ADMIN.GZIP_COMPRESSION + help: PLUGIN_ADMIN.GZIP_COMPRESSION_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + cache.allow_webserver_gzip: + type: toggle + label: PLUGIN_ADMIN.ALLOW_WEBSERVER_GZIP + help: PLUGIN_ADMIN.ALLOW_WEBSERVER_GZIP_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + cache.memcache.server: + type: text + size: medium + label: PLUGIN_ADMIN.MEMCACHE_SERVER + help: PLUGIN_ADMIN.MEMCACHE_SERVER_HELP + placeholder: "localhost" + + cache.memcache.port: + type: text + size: small + label: PLUGIN_ADMIN.MEMCACHE_PORT + help: PLUGIN_ADMIN.MEMCACHE_PORT_HELP + placeholder: "11211" + + cache.memcached.server: + type: text + size: medium + label: PLUGIN_ADMIN.MEMCACHED_SERVER + help: PLUGIN_ADMIN.MEMCACHED_SERVER_HELP + placeholder: "localhost" + + cache.memcached.port: + type: text + size: small + label: PLUGIN_ADMIN.MEMCACHED_PORT + help: PLUGIN_ADMIN.MEMCACHED_PORT_HELP + placeholder: "11211" + + cache.redis.socket: + type: text + size: medium + label: PLUGIN_ADMIN.REDIS_SOCKET + help: PLUGIN_ADMIN.REDIS_SOCKET_HELP + placeholder: "/var/run/redis/redis.sock" + + cache.redis.server: + type: text + size: medium + label: PLUGIN_ADMIN.REDIS_SERVER + help: PLUGIN_ADMIN.REDIS_SERVER_HELP + placeholder: "localhost" + + cache.redis.port: + type: text + size: small + label: PLUGIN_ADMIN.REDIS_PORT + help: PLUGIN_ADMIN.REDIS_PORT_HELP + placeholder: "6379" + + cache.redis.password: + type: text + size: small + label: PLUGIN_ADMIN.REDIS_PASSWORD + + cache.redis.database: + type: text + size: medium + label: PLUGIN_ADMIN.REDIS_DATABASE + help: PLUGIN_ADMIN.REDIS_DATABASE_HELP + placeholder: "0" + validate: + type: number + min: 0 + + flex_caching: + type: section + title: PLUGIN_ADMIN.FLEX_CACHING + + flex.cache.index.enabled: + type: toggle + label: PLUGIN_ADMIN.FLEX_INDEX_CACHE_ENABLED + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + flex.cache.index.lifetime: + type: text + label: PLUGIN_ADMIN.FLEX_INDEX_CACHE_LIFETIME + default: 60 + validate: + type: int + + flex.cache.object.enabled: + type: toggle + label: PLUGIN_ADMIN.FLEX_OBJECT_CACHE_ENABLED + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + flex.cache.object.lifetime: + type: text + label: PLUGIN_ADMIN.FLEX_OBJECT_CACHE_LIFETIME + default: 600 + validate: + type: int + + flex.cache.render.enabled: + type: toggle + label: PLUGIN_ADMIN.FLEX_RENDER_CACHE_ENABLED + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + flex.cache.render.lifetime: + type: text + label: PLUGIN_ADMIN.FLEX_RENDER_CACHE_LIFETIME + default: 600 + validate: + type: int + + twig: + type: tab + title: PLUGIN_ADMIN.TWIG_TEMPLATING + + fields: + twig_section: + type: section + title: PLUGIN_ADMIN.TWIG_TEMPLATING + underline: true + + twig.cache: + type: toggle + label: PLUGIN_ADMIN.TWIG_CACHING + help: PLUGIN_ADMIN.TWIG_CACHING_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + twig.debug: + type: toggle + label: PLUGIN_ADMIN.TWIG_DEBUG + help: PLUGIN_ADMIN.TWIG_DEBUG_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + twig.auto_reload: + type: toggle + label: PLUGIN_ADMIN.DETECT_CHANGES + help: PLUGIN_ADMIN.DETECT_CHANGES_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + twig.autoescape: + type: toggle + label: PLUGIN_ADMIN.AUTOESCAPE_VARIABLES + help: PLUGIN_ADMIN.AUTOESCAPE_VARIABLES_HELP + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + twig.umask_fix: + type: toggle + label: PLUGIN_ADMIN.TWIG_UMASK_FIX + help: PLUGIN_ADMIN.TWIG_UMASK_FIX_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + assets: + type: tab + title: PLUGIN_ADMIN.ASSETS + + fields: + general_config_section: + type: section + title: PLUGIN_ADMIN.GENERAL_CONFIG + underline: true + + assets.enable_asset_timestamp: + type: toggle + label: PLUGIN_ADMIN.ENABLED_TIMESTAMPS_ON_ASSETS + help: PLUGIN_ADMIN.ENABLED_TIMESTAMPS_ON_ASSETS_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + assets.enable_asset_sri: + type: toggle + label: PLUGIN_ADMIN.ENABLED_SRI_ON_ASSETS + help: PLUGIN_ADMIN.ENABLED_SRI_ON_ASSETS_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + assets.collections: + type: multilevel + label: PLUGIN_ADMIN.COLLECTIONS + placeholder_key: collection_name + placeholder_value: collection_path + validate: + type: array + + + css_assets_section: + type: section + title: PLUGIN_ADMIN.CSS_ASSETS + underline: true + + assets.css_pipeline: + type: toggle + label: PLUGIN_ADMIN.CSS_PIPELINE + help: PLUGIN_ADMIN.CSS_PIPELINE_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + assets.css_pipeline_include_externals: + type: toggle + label: PLUGIN_ADMIN.CSS_PIPELINE_INCLUDE_EXTERNALS + help: PLUGIN_ADMIN.CSS_PIPELINE_INCLUDE_EXTERNALS_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + assets.css_pipeline_before_excludes: + type: toggle + label: PLUGIN_ADMIN.CSS_PIPELINE_BEFORE_EXCLUDES + help: PLUGIN_ADMIN.CSS_PIPELINE_BEFORE_EXCLUDES_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + assets.css_minify: + type: toggle + label: PLUGIN_ADMIN.CSS_MINIFY + help: PLUGIN_ADMIN.CSS_MINIFY_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + assets.css_minify_windows: + type: toggle + label: PLUGIN_ADMIN.CSS_MINIFY_WINDOWS_OVERRIDE + help: PLUGIN_ADMIN.CSS_MINIFY_WINDOWS_OVERRIDE_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + assets.css_rewrite: + type: toggle + label: PLUGIN_ADMIN.CSS_REWRITE + help: PLUGIN_ADMIN.CSS_REWRITE_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + js_assets_section: + type: section + title: PLUGIN_ADMIN.JS_ASSETS + underline: true + + assets.js_pipeline: + type: toggle + label: PLUGIN_ADMIN.JAVASCRIPT_PIPELINE + help: PLUGIN_ADMIN.JAVASCRIPT_PIPELINE_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + assets.js_pipeline_include_externals: + type: toggle + label: PLUGIN_ADMIN.JAVASCRIPT_PIPELINE_INCLUDE_EXTERNALS + help: PLUGIN_ADMIN.JAVASCRIPT_PIPELINE_INCLUDE_EXTERNALS_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + assets.js_pipeline_before_excludes: + type: toggle + label: PLUGIN_ADMIN.JAVASCRIPT_PIPELINE_BEFORE_EXCLUDES + help: PLUGIN_ADMIN.JAVASCRIPT_PIPELINE_BEFORE_EXCLUDES_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + assets.js_minify: + type: toggle + label: PLUGIN_ADMIN.JAVASCRIPT_MINIFY + help: PLUGIN_ADMIN.JAVASCRIPT_MINIFY_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + js_module_assets_section: + type: section + title: PLUGIN_ADMIN.JS_MODULE_ASSETS + underline: true + + assets.js_module_pipeline: + type: toggle + label: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE + help: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + assets.js_module_pipeline_include_externals: + type: toggle + label: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE_INCLUDE_EXTERNALS + help: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE_INCLUDE_EXTERNALS_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + assets.js_module_pipeline_before_excludes: + type: toggle + label: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE_BEFORE_EXCLUDES + help: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE_BEFORE_EXCLUDES_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + + + errors: + type: tab + title: PLUGIN_ADMIN.ERROR_HANDLER + + fields: + errors_section: + type: section + title: PLUGIN_ADMIN.ERROR_HANDLER + underline: true + + errors.display: + type: select + label: PLUGIN_ADMIN.DISPLAY_ERRORS + help: PLUGIN_ADMIN.DISPLAY_ERRORS_HELP + size: medium + highlight: 1 + options: + -1: PLUGIN_ADMIN.ERROR_SYSTEM + 0: PLUGIN_ADMIN.ERROR_SIMPLE + 1: PLUGIN_ADMIN.ERROR_FULL_BACKTRACE + validate: + type: int + + + errors.log: + type: toggle + label: PLUGIN_ADMIN.LOG_ERRORS + help: PLUGIN_ADMIN.LOG_ERRORS_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + log.handler: + type: select + size: small + label: PLUGIN_ADMIN.LOG_HANDLER + help: PLUGIN_ADMIN.LOG_HANDLER_HELP + default: 'file' + options: + 'file': 'File' + 'syslog': 'Syslog' + + log.syslog.facility: + type: select + size: small + label: PLUGIN_ADMIN.SYSLOG_FACILITY + help: PLUGIN_ADMIN.SYSLOG_FACILITY_HELP + default: local6 + options: + auth: auth + authpriv: authpriv + cron: cron + daemon: daemon + kern: kern + lpr: lpr + mail: mail + news: news + syslog: syslog + user: user + uucp: uucp + local0: local0 + local1: local1 + local2: local2 + local3: local3 + local4: local4 + local5: local5 + local6: local6 + local7: local7 + + log.syslog.tag: + type: text + size: small + label: PLUGIN_ADMIN.SYSLOG_TAG + help: PLUGIN_ADMIN.SYSLOG_TAG_HELP + placeholder: "grav" + + debugger: + type: tab + title: PLUGIN_ADMIN.DEBUGGER + + fields: + debugger_section: + type: section + title: PLUGIN_ADMIN.DEBUGGER + underline: true + + debugger.enabled: + type: toggle + label: PLUGIN_ADMIN.DEBUGGER + help: PLUGIN_ADMIN.DEBUGGER_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + debugger.provider: + type: select + label: PLUGIN_ADMIN.DEBUGGER_PROVIDER + help: PLUGIN_ADMIN.DEBUGGER_PROVIDER_HELP + size: medium + default: debugbar + options: + debugbar: PLUGIN_ADMIN.DEBUGGER_DEBUGBAR + clockwork: PLUGIN_ADMIN.DEBUGGER_CLOCKWORK + + debugger.censored: + type: toggle + label: PLUGIN_ADMIN.DEBUGGER_CENSORED + help: PLUGIN_ADMIN.DEBUGGER_CENSORED_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + debugger.shutdown.close_connection: + type: toggle + label: PLUGIN_ADMIN.SHUTDOWN_CLOSE_CONNECTION + help: PLUGIN_ADMIN.SHUTDOWN_CLOSE_CONNECTION_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + media: + type: tab + title: PLUGIN_ADMIN.MEDIA + + fields: + media_section: + type: section + title: PLUGIN_ADMIN.MEDIA + underline: true + + images.adapter: + type: select + size: small + label: PLUGIN_ADMIN.IMAGE_ADAPTER + help: PLUGIN_ADMIN.IMAGE_ADAPTER_HELP + highlight: gd + options: + gd: GD (PHP built-in) + imagick: Imagick + + images.default_image_quality: + type: range + append: '%' + label: PLUGIN_ADMIN.DEFAULT_IMAGE_QUALITY + help: PLUGIN_ADMIN.DEFAULT_IMAGE_QUALITY_HELP + validate: + min: 1 + max: 100 + + images.cache_all: + type: toggle + label: PLUGIN_ADMIN.CACHE_ALL + help: PLUGIN_ADMIN.CACHE_ALL_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + images.cache_perms: + type: select + size: small + label: PLUGIN_ADMIN.CACHE_PERMS + help: PLUGIN_ADMIN.CACHE_PERMS_HELP + highlight: '0755' + options: + '0755': '0755' + '0775': '0775' + + images.debug: + type: toggle + label: PLUGIN_ADMIN.IMAGES_DEBUG + help: PLUGIN_ADMIN.IMAGES_DEBUG_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + images.auto_fix_orientation: + type: toggle + label: PLUGIN_ADMIN.IMAGES_AUTO_FIX_ORIENTATION + help: PLUGIN_ADMIN.IMAGES_AUTO_FIX_ORIENTATION_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + images.defaults.loading: + type: select + size: small + label: PLUGIN_ADMIN.IMAGES_LOADING + help: PLUGIN_ADMIN.IMAGES_LOADING_HELP + highlight: auto + options: + auto: Auto + lazy: Lazy + eager: Eager + + images.defaults.decoding: + type: select + size: small + label: PLUGIN_ADMIN.IMAGES_DECODING + help: PLUGIN_ADMIN.IMAGES_DECODING_HELP + highlight: auto + options: + auto: Auto + sync: Sync + async: Async + + images.defaults.fetchpriority: + type: select + size: small + label: PLUGIN_ADMIN.IMAGES_FETCHPRIORITY + help: PLUGIN_ADMIN.IMAGES_FETCHPRIORITY_HELP + highlight: auto + options: + auto: Auto + high: High + low: Low + + images.seofriendly: + type: toggle + label: PLUGIN_ADMIN.IMAGES_SEOFRIENDLY + help: PLUGIN_ADMIN.IMAGES_SEOFRIENDLY_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + media.enable_media_timestamp: + type: toggle + label: PLUGIN_ADMIN.ENABLE_MEDIA_TIMESTAMP + help: PLUGIN_ADMIN.ENABLE_MEDIA_TIMESTAMP_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + media.auto_metadata_exif: + type: toggle + label: PLUGIN_ADMIN.ENABLE_AUTO_METADATA + help: PLUGIN_ADMIN.ENABLE_AUTO_METADATA_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + media.allowed_fallback_types: + type: selectize + size: large + label: PLUGIN_ADMIN.FALLBACK_TYPES + help: PLUGIN_ADMIN.FALLBACK_TYPES_HELP + classes: fancy + validate: + type: commalist + + media.unsupported_inline_types: + type: selectize + size: large + label: PLUGIN_ADMIN.INLINE_TYPES + help: PLUGIN_ADMIN.INLINE_TYPES_HELP + classes: fancy + validate: + type: commalist + + section_images_cls: + type: section + title: PLUGIN_ADMIN.IMAGES_CLS_TITLE + underline: true + + images.cls.auto_sizes: + type: toggle + label: PLUGIN_ADMIN.IMAGES_CLS_AUTO_SIZES + help: PLUGIN_ADMIN.IMAGES_CLS_AUTO_SIZES_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + images.cls.aspect_ratio: + type: toggle + label: PLUGIN_ADMIN.IMAGES_CLS_ASPECT_RATIO + help: PLUGIN_ADMIN.IMAGES_CLS_ASPECT_RATIO_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + images.cls.retina_scale: + type: select + label: PLUGIN_ADMIN.IMAGES_CLS_RETINA_SCALE + help: PLUGIN_ADMIN.IMAGES_CLS_RETINA_SCALE_HELP + size: small + highlight: 1 + options: + 1: 1X + 2: 2X + 3: 3X + 4: 4X + + session: + type: tab + title: PLUGIN_ADMIN.SESSION + + fields: + session_section: + type: section + title: PLUGIN_ADMIN.SESSION + underline: true + + session.enabled: + type: hidden + label: PLUGIN_ADMIN.ENABLED + help: PLUGIN_ADMIN.SESSION_ENABLED_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + default: true + validate: + type: bool + + session.initialize: + type: toggle + label: PLUGIN_ADMIN.SESSION_INITIALIZE + help: PLUGIN_ADMIN.SESSION_INITIALIZE_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + default: true + validate: + type: bool + + session.timeout: + type: text + size: small + append: GRAV.NICETIME.SECOND_PLURAL + label: PLUGIN_ADMIN.TIMEOUT + help: PLUGIN_ADMIN.TIMEOUT_HELP + validate: + type: number + min: 0 + + session.name: + type: text + size: small + label: PLUGIN_ADMIN.NAME + help: PLUGIN_ADMIN.SESSION_NAME_HELP + + session.uniqueness: + type: select + size: medium + label: PLUGIN_ADMIN.SESSION_UNIQUENESS + help: PLUGIN_ADMIN.SESSION_UNIQUENESS_HELP + highlight: path + default: path + options: + path: Grav's root file path + salt: Grav's random security salt + + session.secure: + type: toggle + label: PLUGIN_ADMIN.SESSION_SECURE + help: PLUGIN_ADMIN.SESSION_SECURE_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + default: false + validate: + type: bool + + session.secure_https: + type: toggle + label: PLUGIN_ADMIN.SESSION_SECURE_HTTPS + help: PLUGIN_ADMIN.SESSION_SECURE_HTTPS_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + default: true + validate: + type: bool + + session.httponly: + type: toggle + label: PLUGIN_ADMIN.SESSION_HTTPONLY + help: PLUGIN_ADMIN.SESSION_HTTPONLY_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + default: true + validate: + type: bool + + session.domain: + type: text + size: small + label: PLUGIN_ADMIN.SESSION_DOMAIN + help: PLUGIN_ADMIN.SESSION_DOMAIN_HELP + + session.path: + type: text + size: small + label: PLUGIN_ADMIN.SESSION_PATH + help: PLUGIN_ADMIN.SESSION_PATH_HELP + + session.samesite: + type: text + size: small + label: PLUGIN_ADMIN.SESSION_SAMESITE + help: PLUGIN_ADMIN.SESSION_SAMESITE_HELP + + session.split: + type: toggle + label: PLUGIN_ADMIN.SESSION_SPLIT + help: PLUGIN_ADMIN.SESSION_SPLIT_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + default: true + validate: + type: bool + + advanced: + type: tab + title: PLUGIN_ADMIN.ADVANCED + + fields: + advanced_section: + type: section + title: PLUGIN_ADMIN.ADVANCED + underline: true + + gpm_section: + type: section + title: PLUGIN_ADMIN.GPM_SECTION + + gpm.releases: + type: toggle + label: PLUGIN_ADMIN.GPM_RELEASES + highlight: stable + help: PLUGIN_ADMIN.GPM_RELEASES_HELP + options: + stable: PLUGIN_ADMIN.STABLE + testing: PLUGIN_ADMIN.TESTING + + gpm.official_gpm_only: + type: toggle + label: PLUGIN_ADMIN.GPM_OFFICIAL_ONLY + highlight: 1 + help: PLUGIN_ADMIN.GPM_OFFICIAL_ONLY_HELP + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + default: true + validate: + type: bool + + http_section: + type: section + title: PLUGIN_ADMIN.HTTP_SECTION + + http.method: + type: toggle + label: PLUGIN_ADMIN.GPM_METHOD + highlight: auto + help: PLUGIN_ADMIN.GPM_METHOD_HELP + options: + auto: PLUGIN_ADMIN.AUTO + fopen: PLUGIN_ADMIN.FOPEN + curl: PLUGIN_ADMIN.CURL + + http.enable_proxy: + type: toggle + label: PLUGIN_ADMIN.SSL_ENABLE_PROXY + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + default: false + validate: + type: bool + + http.proxy_url: + type: text + size: medium + placeholder: "e.g. 127.0.0.1:3128" + label: PLUGIN_ADMIN.PROXY_URL + help: PLUGIN_ADMIN.PROXY_URL_HELP + + http.proxy_cert_path: + type: text + size: medium + placeholder: "e.g. /Users/bob/certs/" + label: PLUGIN_ADMIN.PROXY_CERT + help: PLUGIN_ADMIN.PROXY_CERT_HELP + + http.verify_peer: + type: toggle + label: PLUGIN_ADMIN.SSL_VERIFY_PEER + highlight: 1 + help: PLUGIN_ADMIN.SSL_VERIFY_PEER_HELP + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + http.verify_host: + type: toggle + label: PLUGIN_ADMIN.SSL_VERIFY_HOST + highlight: 1 + help: PLUGIN_ADMIN.SSL_VERIFY_HOST_HELP + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + http.concurrent_connections: + type: number + size: x-small + label: PLUGIN_ADMIN.HTTP_CONNECTIONS + help: PLUGIN_ADMIN.HTTP_CONNECTIONS_HELP + validate: + min: 1 + max: 20 + + misc_section: + type: section + title: PLUGIN_ADMIN.MISC_SECTION + + reverse_proxy_setup: + type: toggle + label: PLUGIN_ADMIN.REVERSE_PROXY + highlight: 0 + help: PLUGIN_ADMIN.REVERSE_PROXY_HELP + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + username_regex: + type: text + size: large + label: PLUGIN_ADMIN.USERNAME_REGEX + help: PLUGIN_ADMIN.USERNAME_REGEX_HELP + + pwd_regex: + type: text + size: large + label: PLUGIN_ADMIN.PWD_REGEX + help: PLUGIN_ADMIN.PWD_REGEX_HELP + + intl_enabled: + type: toggle + label: PLUGIN_ADMIN.INTL_ENABLED + highlight: 1 + help: PLUGIN_ADMIN.INTL_ENABLED_HELP + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + wrapped_site: + type: toggle + label: PLUGIN_ADMIN.WRAPPED_SITE + highlight: 0 + help: PLUGIN_ADMIN.WRAPPED_SITE_HELP + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + absolute_urls: + type: toggle + label: PLUGIN_ADMIN.ABSOLUTE_URLS + highlight: 0 + help: PLUGIN_ADMIN.ABSOLUTE_URLS_HELP + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + param_sep: + type: select + size: medium + label: PLUGIN_ADMIN.PARAMETER_SEPARATOR + classes: fancy + help: PLUGIN_ADMIN.PARAMETER_SEPARATOR_HELP + default: '' + options: + ':': ': (default)' + ';': '; (for Apache running on Windows)' + + force_ssl: + type: toggle + label: PLUGIN_ADMIN.FORCE_SSL + highlight: 0 + help: PLUGIN_ADMIN.FORCE_SSL_HELP + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + force_lowercase_urls: + type: toggle + label: PLUGIN_ADMIN.FORCE_LOWERCASE_URLS + highlight: 1 + default: 1 + help: PLUGIN_ADMIN.FORCE_LOWERCASE_URLS_HELP + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + custom_base_url: + type: text + size: medium + placeholder: "e.g. http://yoursite.com/yourpath" + label: PLUGIN_ADMIN.CUSTOM_BASE_URL + help: PLUGIN_ADMIN.CUSTOM_BASE_URL_HELP + + http_x_forwarded.protocol: + type: toggle + label: HTTP_X_FORWARDED_PROTO Enabled + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + http_x_forwarded.host: + type: toggle + label: HTTP_X_FORWARDED_HOST Enabled + highlight: 0 + default: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + http_x_forwarded.port: + type: toggle + label: HTTP_X_FORWARDED_PORT Enabled + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + http_x_forwarded.ip: + type: toggle + label: HTTP_X_FORWARDED IP Enabled + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + + strict_mode.blueprint_compat: + type: toggle + label: PLUGIN_ADMIN.STRICT_BLUEPRINT_COMPAT + highlight: 0 + default: 0 + help: PLUGIN_ADMIN.STRICT_BLUEPRINT_COMPAT_HELP + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + strict_mode.yaml_compat: + type: toggle + label: PLUGIN_ADMIN.STRICT_YAML_COMPAT + highlight: 0 + default: 0 + help: PLUGIN_ADMIN.STRICT_YAML_COMPAT_HELP + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + strict_mode.twig_compat: + type: toggle + label: PLUGIN_ADMIN.STRICT_TWIG_COMPAT + highlight: 0 + default: 0 + help: PLUGIN_ADMIN.STRICT_TWIG_COMPAT_HELP + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + + accounts: + type: tab + title: PLUGIN_ADMIN.ACCOUNTS + + fields: + flex_accounts: + type: section + title: User Accounts + + accounts.type: + type: select + label: PLUGIN_ADMIN.ACCOUNTS_TYPE + highlight: stable + help: PLUGIN_ADMIN.ACCOUNTS_TYPE_HELP + options: + regular: PLUGIN_ADMIN.REGULAR + flex: PLUGIN_ADMIN.FLEX + + accounts.storage: + type: select + label: PLUGIN_ADMIN.ACCOUNTS_STORAGE + highlight: stable + help: PLUGIN_ADMIN.ACCOUNTS_STORAGE_HELP + options: + file: PLUGIN_ADMIN.FILE + folder: PLUGIN_ADMIN.FOLDER + + accounts.avatar: + type: select + label: PLUGIN_ADMIN.AVATAR + default: gravatar + help: PLUGIN_ADMIN.AVATAR_HELP + options: + multiavatar: Multiavatar [local] + gravatar: Gravatar [external] + +# experimental: +# type: tab +# title: PLUGIN_ADMIN.EXPERIMENTAL +# +# fields: +# experimental_section: +# type: section +# title: PLUGIN_ADMIN.EXPERIMENTAL +# underline: true +# +# flex_pages: +# type: section +# title: Flex Pages +# +# pages.type: +# type: select +# label: PLUGIN_ADMIN.PAGES_TYPE +# highlight: regular +# help: PLUGIN_ADMIN.PAGES_TYPE_HELP +# options: +# regular: PLUGIN_ADMIN.REGULAR +# flex: PLUGIN_ADMIN.FLEX +# +# pages.type: +# type: hidden + + + diff --git a/system/blueprints/flex/accounts.yaml b/system/blueprints/flex/accounts.yaml new file mode 100644 index 0000000..52eff69 --- /dev/null +++ b/system/blueprints/flex/accounts.yaml @@ -0,0 +1,8 @@ +title: Flex User Accounts +description: Manage your User Accounts in Flex. +type: flex-objects + +# Deprecated in Grav 1.7.0-rc.4: file was renamed to user-accounts.yaml +extends@: + type: user-accounts + context: blueprints://flex diff --git a/system/blueprints/flex/configure/compat.yaml b/system/blueprints/flex/configure/compat.yaml new file mode 100644 index 0000000..08497d4 --- /dev/null +++ b/system/blueprints/flex/configure/compat.yaml @@ -0,0 +1,17 @@ +form: + compatibility: + type: tab + title: Compatibility + fields: + object.compat.events: + type: toggle + toggleable: true + label: Admin event compatibility + help: Enables onAdminSave and onAdminAfterSave events for plugins + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool diff --git a/system/blueprints/flex/pages.yaml b/system/blueprints/flex/pages.yaml new file mode 100644 index 0000000..89dab6a --- /dev/null +++ b/system/blueprints/flex/pages.yaml @@ -0,0 +1,212 @@ +title: Pages +description: Manage your Grav Pages in Flex. +type: flex-objects + +# Extends a page (blueprint gets overridden inside the object) +extends@: + type: default + context: blueprints://pages + +# +# HIGHLY SPECIALIZED FLEX TYPE, AVOID USING PAGES AS BASE FOR YOUR OWN TYPE. +# + +# Flex configuration +config: + # Administration Configuration (needs Flex Objects plugin) + admin: + # Admin router + router: + path: '/pages' + + # Permissions + permissions: + # Primary permissions + admin.pages: + type: crudl + label: Pages + admin.configuration.pages: + type: default + label: Pages Configuration + + # Admin menu + menu: + list: + route: '/pages' + title: PLUGIN_ADMIN.PAGES + icon: fa-file-text + authorize: ['admin.pages.list', 'admin.super'] + priority: 5 + + # Admin template type (folder) + template: pages + + # Allowed admin actions + actions: + list: true + create: true + read: true + update: true + delete: true + + # List view + list: + # Fields shown in the list view + fields: + published: + width: 8 + alias: header.published + visible: + width: 8 + field: + label: Visible + type: toggle + menu: + link: edit + alias: header.menu + full_route: + field: + label: Route + type: text + link: edit + sort: + field: key + name: + width: 8 + field: + label: Type + type: text + translations: + width: 8 + field: + label: Translations + type: text +# updated_date: +# alias: header.update_date + + # Extra options + options: + # Default number of records for pagination + per_page: 20 + # Default ordering + order: + by: key + dir: asc + + # TODO: not used yet + buttons: + back: + icon: reply + title: PLUGIN_ADMIN.BACK + add: + icon: plus + label: PLUGIN_ADMIN.ADD + + edit: + title: + template: "{% if object.root %}Root ( <root> ){% else %}{{ (form.value('header.title') ?? form.value('folder'))|e }} ( {{ (object.getRoute().toString(false) ?: '/')|e }} ){% endif %}" + + # TODO: not used yet + buttons: + back: + icon: reply + title: PLUGIN_ADMIN.BACK + preview: + icon: eye + title: PLUGIN_ADMIN.PREVIEW + add: + icon: plus + label: PLUGIN_ADMIN.ADD + copy: + icon: copy + label: PLUGIN_ADMIN.COPY + move: + icon: arrows + label: PLUGIN_ADMIN.MOVE + delete: + icon: close + label: PLUGIN_ADMIN.DELETE + save: + icon: check + label: PLUGIN_ADMIN.SAVE + + # Preview View + preview: + enabled: true + + # Configure view + configure: + authorize: 'admin.configuration.pages' + + # Site Configuration + site: + # Hide from flex types + hidden: true + templates: + collection: + # Lookup for the template layout files for collections of objects + paths: + - 'flex/{TYPE}/collection/{LAYOUT}{EXT}' + object: + # Lookup for the template layout files for objects + paths: + - 'flex/{TYPE}/object/{LAYOUT}{EXT}' + defaults: + # Default template {TYPE}; overridden by filename of this blueprint if template folder exists + type: pages + # Default template {LAYOUT}; can be overridden in render calls (usually Twig in templates) + layout: default + + # Default filters for frontend. + filter: + - withPublished + + # Data Configuration + data: + object: 'Grav\Common\Flex\Types\Pages\PageObject' + collection: 'Grav\Common\Flex\Types\Pages\PageCollection' + index: 'Grav\Common\Flex\Types\Pages\PageIndex' + storage: + class: 'Grav\Common\Flex\Types\Pages\Storage\PageStorage' + options: + formatter: + class: 'Grav\Framework\File\Formatter\MarkdownFormatter' + folder: 'page://' + # Keep index file in filesystem to speed up lookups + indexed: true + # Set default ordering of the pages + ordering: + storage_key: ASC + search: + # Search options + options: + contains: 1 + # Fields to be searched + fields: + - key + - slug + - menu + - title + +blueprints: + configure: + fields: + import@: + type: configure/compat + context: blueprints://flex + +# Regular form definition +form: + fields: + lang: + type: hidden + value: '' + + tabs: + fields: + security: + type: tab + title: PLUGIN_ADMIN.SECURITY + import@: + type: partials/security + context: blueprints://pages diff --git a/system/blueprints/flex/shared/configure.yaml b/system/blueprints/flex/shared/configure.yaml new file mode 100644 index 0000000..e3e5dcb --- /dev/null +++ b/system/blueprints/flex/shared/configure.yaml @@ -0,0 +1,70 @@ +form: + validation: loose + + fields: + tabs: + type: tabs + fields: + cache: + type: tab + title: Caching + fields: + object.cache.index.enabled: + type: toggle + toggleable: true + label: PLUGIN_ADMIN.FLEX_INDEX_CACHE_ENABLED + highlight: 1 + config-default@: system.flex.cache.index.enabled + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + object.cache.index.lifetime: + type: text + toggleable: true + label: PLUGIN_ADMIN.FLEX_INDEX_CACHE_LIFETIME + config-default@: system.flex.cache.index.lifetime + validate: + type: int + + object.cache.object.enabled: + type: toggle + toggleable: true + label: PLUGIN_ADMIN.FLEX_OBJECT_CACHE_ENABLED + highlight: 1 + config-default@: system.flex.cache.object.enabled + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + object.cache.object.lifetime: + type: text + toggleable: true + label: PLUGIN_ADMIN.FLEX_OBJECT_CACHE_LIFETIME + config-default@: system.flex.cache.object.lifetime + validate: + type: int + + object.cache.render.enabled: + type: toggle + toggleable: true + label: PLUGIN_ADMIN.FLEX_RENDER_CACHE_ENABLED + highlight: 1 + config-default@: system.flex.cache.render.enabled + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + object.cache.render.lifetime: + type: text + toggleable: true + label: PLUGIN_ADMIN.FLEX_RENDER_CACHE_LIFETIME + config-default@: system.flex.cache.render.lifetime + validate: + type: int diff --git a/system/blueprints/flex/user-accounts.yaml b/system/blueprints/flex/user-accounts.yaml new file mode 100644 index 0000000..74baeb6 --- /dev/null +++ b/system/blueprints/flex/user-accounts.yaml @@ -0,0 +1,155 @@ +title: User Accounts +description: Manage your User Accounts in Flex. +type: flex-objects + +# Extends user account +extends@: + type: account + context: blueprints://user + +# +# HIGHLY SPECIALIZED FLEX TYPE, AVOID USING USER ACCOUNTS AS BASE FOR YOUR OWN TYPE. +# + +# Flex configuration +config: + # Administration Configuration (needs Flex Objects plugin) + admin: + # Admin router + router: + path: '/accounts/users' + actions: + configure: + path: '/accounts/configure' + redirects: + '/user': '/accounts/users' + '/accounts': '/accounts/users' + + # Permissions + permissions: + # Primary permissions + admin.users: + type: crudl + label: User Accounts + admin.configuration.users: + type: default + label: Accounts Configuration + + # Admin menu + menu: + base: + location: '/accounts' + route: '/accounts/users' + index: 0 + title: PLUGIN_ADMIN.ACCOUNTS + icon: fa-users + authorize: ['admin.users.list', 'admin.super'] + priority: 6 + + # Admin template type (folder) + template: user-accounts + + # List view + list: + # Fields shown in the list view + fields: + username: + link: edit + search: true + field: + label: PLUGIN_ADMIN.USERNAME + email: + search: true + fullname: + search: true + # Extra options + options: + per_page: 20 + order: + by: username + dir: asc + + # Edit view + edit: + title: + template: "{{ form.value('fullname') ?? form.value('username') }} <{{ form.value('email') }}>" + + # Configure view + configure: + hidden: true + authorize: 'admin.configuration.users' + form: 'accounts' + title: + template: "{{ 'PLUGIN_ADMIN.ACCOUNTS'|tu }} {{ 'PLUGIN_ADMIN.CONFIGURATION'|tu }}" + + # Site Configuration + site: + # Hide from flex types + hidden: true + templates: + collection: + # Lookup for the template layout files for collections of objects + paths: + - 'flex/{TYPE}/collection/{LAYOUT}{EXT}' + object: + # Lookup for the template layout files for objects + paths: + - 'flex/{TYPE}/object/{LAYOUT}{EXT}' + defaults: + # Default template {TYPE}; overridden by filename of this blueprint if template folder exists + type: user-accounts + # Default template {LAYOUT}; can be overridden in render calls (usually Twig in templates) + layout: default + + # Data Configuration + data: + object: 'Grav\Common\Flex\Types\Users\UserObject' + collection: 'Grav\Common\Flex\Types\Users\UserCollection' + index: 'Grav\Common\Flex\Types\Users\UserIndex' + storage: + class: 'Grav\Common\Flex\Types\Users\Storage\UserFileStorage' + options: + formatter: + class: 'Grav\Framework\File\Formatter\YamlFormatter' + folder: 'account://' + pattern: '{FOLDER}/{KEY}{EXT}' + indexed: true + key: username + case_sensitive: false + search: + options: + contains: 1 + fields: + - key + - email + - username + - fullname + + relationships: + media: + type: media + cardinality: to-many + avatar: + type: media + cardinality: to-one +# roles: +# type: user-groups +# cardinality: to-many + +blueprints: + configure: + fields: + import@: + type: configure/compat + context: blueprints://flex + +# Regular form definition +form: + fields: + username: + flex-disabled@: exists + disabled: false + flex-readonly@: exists + readonly: false + validate: + required: true diff --git a/system/blueprints/flex/user-groups.yaml b/system/blueprints/flex/user-groups.yaml new file mode 100644 index 0000000..a5d348b --- /dev/null +++ b/system/blueprints/flex/user-groups.yaml @@ -0,0 +1,124 @@ +title: User Groups +description: Manage your User Groups in Flex. +type: flex-objects + +# Extends user group +extends@: + type: group + context: blueprints://user + +# Flex configuration +config: + # Administration Configuration (needs Flex Objects plugin) + admin: + # Admin router + router: + path: '/accounts/groups' + actions: + configure: + path: '/accounts/configure' + redirects: + '/groups': '/accounts/groups' + '/accounts': '/accounts/groups' + + # Permissions + permissions: + # Primary permissions + admin.users: + type: crudl + label: User Accounts + admin.configuration.users: + type: default + label: Accounts Configuration + + # Admin menu + menu: + base: + location: '/accounts' + route: '/accounts/groups' + index: 1 + title: PLUGIN_ADMIN.ACCOUNTS + icon: fa-users + authorize: ['admin.users.list', 'admin.super'] + priority: 6 + + # Admin template type (folder) + template: user-groups + + # List view + list: + # Fields shown in the list view + fields: + groupname: + link: edit + search: true + readableName: + search: true + description: + search: true + # Extra options + options: + per_page: 20 + order: + by: groupname + dir: asc + + # Edit view + edit: + title: + template: "{{ form.value('readableName') ?? form.value('groupname') }}" + + # Configure view + configure: + hidden: true + authorize: 'admin.configuration.users' + form: 'accounts' + title: + template: "{{ 'PLUGIN_ADMIN.ACCOUNTS'|tu }} {{ 'PLUGIN_ADMIN.CONFIGURATION'|tu }}" + + # Site Configuration + site: + # Hide from flex types + hidden: true + templates: + collection: + # Lookup for the template layout files for collections of objects + paths: + - 'flex/{TYPE}/collection/{LAYOUT}{EXT}' + object: + # Lookup for the template layout files for objects + paths: + - 'flex/{TYPE}/object/{LAYOUT}{EXT}' + defaults: + # Default template {TYPE}; overridden by filename of this blueprint if template folder exists + type: user-groups + # Default template {LAYOUT}; can be overridden in render calls (usually Twig in templates) + layout: default + + # Data Configuration + data: + object: 'Grav\Common\Flex\Types\UserGroups\UserGroupObject' + collection: 'Grav\Common\Flex\Types\UserGroups\UserGroupCollection' + index: 'Grav\Common\Flex\Types\UserGroups\UserGroupIndex' + storage: + class: 'Grav\Framework\Flex\Storage\SimpleStorage' + options: + formatter: + class: 'Grav\Framework\File\Formatter\YamlFormatter' + folder: 'user://config/groups.yaml' + key: groupname + search: + options: + contains: 1 + fields: + - key + - groupname + - readableName + - description + +blueprints: + configure: + fields: + import@: + type: configure/compat + context: blueprints://flex diff --git a/system/blueprints/pages/default.yaml b/system/blueprints/pages/default.yaml new file mode 100644 index 0000000..a573a83 --- /dev/null +++ b/system/blueprints/pages/default.yaml @@ -0,0 +1,381 @@ +title: PLUGIN_ADMIN.DEFAULT + +rules: + slug: + pattern: '[a-zA-Zа-яA-Я0-9_\-]+' + min: 1 + max: 200 + +form: + validation: loose + + fields: + + tabs: + type: tabs + active: 1 + + fields: + content: + type: tab + title: PLUGIN_ADMIN.CONTENT + + fields: + xss_check: + type: xss + + header.title: + type: text + autofocus: true + style: vertical + label: PLUGIN_ADMIN.TITLE + + content: + type: markdown + validate: + type: textarea + + header.media_order: + type: pagemedia + label: PLUGIN_ADMIN.PAGE_MEDIA + + options: + type: tab + title: PLUGIN_ADMIN.OPTIONS + + fields: + + publishing: + type: section + title: PLUGIN_ADMIN.PUBLISHING + underline: true + + fields: + header.published: + type: toggle + toggleable: true + label: PLUGIN_ADMIN.PUBLISHED + help: PLUGIN_ADMIN.PUBLISHED_HELP + highlight: 1 + size: medium + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + header.date: + type: datetime + label: PLUGIN_ADMIN.DATE + toggleable: true + help: PLUGIN_ADMIN.DATE_HELP + + header.publish_date: + type: datetime + label: PLUGIN_ADMIN.PUBLISHED_DATE + toggleable: true + help: PLUGIN_ADMIN.PUBLISHED_DATE_HELP + + header.unpublish_date: + type: datetime + label: PLUGIN_ADMIN.UNPUBLISHED_DATE + toggleable: true + help: PLUGIN_ADMIN.UNPUBLISHED_DATE_HELP + + header.metadata: + toggleable: true + type: array + label: PLUGIN_ADMIN.METADATA + help: PLUGIN_ADMIN.METADATA_HELP + placeholder_key: PLUGIN_ADMIN.METADATA_KEY + placeholder_value: PLUGIN_ADMIN.METADATA_VALUE + + taxonomies: + type: section + title: PLUGIN_ADMIN.TAXONOMIES + underline: true + + fields: + header.taxonomy: + type: taxonomy + label: PLUGIN_ADMIN.TAXONOMY + multiple: true + validate: + type: array + + advanced: + type: tab + title: PLUGIN_ADMIN.ADVANCED + + fields: + columns: + type: columns + fields: + column1: + type: column + fields: + + settings: + type: section + title: PLUGIN_ADMIN.SETTINGS + underline: true + + folder: + type: folder-slug + label: PLUGIN_ADMIN.FOLDER_NAME + validate: + rule: slug + + route: + type: parents + label: PLUGIN_ADMIN.PARENT + classes: fancy + + name: + type: select + classes: fancy + label: PLUGIN_ADMIN.PAGE_FILE + help: PLUGIN_ADMIN.PAGE_FILE_HELP + default: default + data-options@: '\Grav\Common\Page\Pages::pageTypes' + + header.body_classes: + type: text + label: PLUGIN_ADMIN.BODY_CLASSES + + + column2: + type: column + + fields: + order_title: + type: section + title: PLUGIN_ADMIN.ORDERING + underline: true + + ordering: + type: toggle + label: PLUGIN_ADMIN.FOLDER_NUMERIC_PREFIX + help: PLUGIN_ADMIN.FOLDER_NUMERIC_PREFIX_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + order: + type: order + label: PLUGIN_ADMIN.SORTABLE_PAGES + sitemap: + + overrides: + type: section + title: PLUGIN_ADMIN.OVERRIDES + underline: true + + fields: + + header.dateformat: + toggleable: true + type: select + size: medium + selectize: + create: true + label: PLUGIN_ADMIN.DEFAULT_DATE_FORMAT + help: PLUGIN_ADMIN.DEFAULT_DATE_FORMAT_HELP + placeholder: PLUGIN_ADMIN.DEFAULT_DATE_FORMAT_PLACEHOLDER + data-options@: '\Grav\Common\Utils::dateFormats' + validate: + type: string + + header.menu: + type: text + label: PLUGIN_ADMIN.MENU + toggleable: true + help: PLUGIN_ADMIN.MENU_HELP + + header.slug: + type: text + label: PLUGIN_ADMIN.SLUG + toggleable: true + help: PLUGIN_ADMIN.SLUG_HELP + validate: + message: PLUGIN_ADMIN.SLUG_VALIDATE_MESSAGE + rule: slug + + header.redirect: + type: text + label: PLUGIN_ADMIN.REDIRECT + toggleable: true + help: PLUGIN_ADMIN.REDIRECT_HELP + + header.process: + type: checkboxes + label: PLUGIN_ADMIN.PROCESS + toggleable: true + config-default@: system.pages.process + default: + markdown: true + twig: false + options: + markdown: Markdown + twig: Twig + use: keys + + header.twig_first: + type: toggle + toggleable: true + label: PLUGIN_ADMIN.TWIG_FIRST + help: PLUGIN_ADMIN.TWIG_FIRST_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + header.never_cache_twig: + type: toggle + toggleable: true + label: PLUGIN_ADMIN.NEVER_CACHE_TWIG + help: PLUGIN_ADMIN.NEVER_CACHE_TWIG_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + header.child_type: + type: select + toggleable: true + label: PLUGIN_ADMIN.DEFAULT_CHILD_TYPE + default: default + placeholder: PLUGIN_ADMIN.USE_GLOBAL + data-options@: '\Grav\Common\Page\Pages::types' + + header.routable: + type: toggle + toggleable: true + label: PLUGIN_ADMIN.ROUTABLE + help: PLUGIN_ADMIN.ROUTABLE_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + header.cache_enable: + type: toggle + toggleable: true + label: PLUGIN_ADMIN.CACHING + highlight: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + header.visible: + type: toggle + toggleable: true + label: PLUGIN_ADMIN.VISIBLE + help: PLUGIN_ADMIN.VISIBLE_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + header.debugger: + type: toggle + toggleable: true + label: PLUGIN_ADMIN.DEBUGGER + help: PLUGIN_ADMIN.DEBUGGER_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + header.template: + type: text + toggleable: true + label: PLUGIN_ADMIN.DISPLAY_TEMPLATE + + header.append_url_extension: + type: text + label: PLUGIN_ADMIN.APPEND_URL_EXT + toggleable: true + help: PLUGIN_ADMIN.APPEND_URL_EXT_HELP + + routes_only: + type: section + title: PLUGIN_ADMIN.ROUTE_OVERRIDES + underline: true + + fields: + + header.redirect_default_route: + type: toggle + toggleable: true + label: PLUGIN_ADMIN.REDIRECT_DEFAULT_ROUTE + help: PLUGIN_ADMIN.REDIRECT_DEFAULT_ROUTE_HELP + config-highlight@: system.pages.redirect_default_route + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + header.routes.default: + type: text + toggleable: true + label: PLUGIN_ADMIN.ROUTE_DEFAULT + + header.routes.canonical: + type: text + toggleable: true + label: PLUGIN_ADMIN.ROUTE_CANONICAL + + header.routes.aliases: + type: array + toggleable: true + value_only: true + size: large + label: PLUGIN_ADMIN.ROUTE_ALIASES + + + admin_only: + type: section + title: PLUGIN_ADMIN.ADMIN_SPECIFIC_OVERRIDES + underline: true + + fields: + + header.admin.children_display_order: + type: select + label: PLUGIN_ADMIN.ADMIN_CHILDREN_DISPLAY_ORDER + help: PLUGIN_ADMIN.ADMIN_CHILDREN_DISPLAY_ORDER_HELP + toggleable: true + classes: fancy + default: 'collection' + options: + 'default': 'Ordered by Folder name (default)' + 'collection': 'Ordered by Collection definition' + + + header.order_by: + type: hidden + + header.order_manual: + type: hidden + validate: + type: commalist + + blueprint: + type: blueprint diff --git a/system/blueprints/pages/external.yaml b/system/blueprints/pages/external.yaml new file mode 100644 index 0000000..d3bb57a --- /dev/null +++ b/system/blueprints/pages/external.yaml @@ -0,0 +1,52 @@ +title: PLUGIN_ADMIN.EXTERNAL +extends@: + type: default + context: blueprints://pages + +form: + validation: loose + fields: + + tabs: + type: tabs + active: 1 + + fields: + + content: + fields: + + header.title: + type: text + autofocus: true + style: horizontal + label: PLUGIN_ADMIN.TITLE + + content: + unset@: true + + header.media_order: + unset@: true + + header.external_url: + type: text + label: PLUGIN_ADMIN.EXTERNAL_URL + placeholder: https://getgrav.org + validate: + required: true + + options: + fields: + + publishing: + fields: + + header.date: + unset@: true + + header.metadata: + unset@: true + + taxonomies: + unset@: true + diff --git a/system/blueprints/pages/modular.yaml b/system/blueprints/pages/modular.yaml new file mode 100644 index 0000000..5461b23 --- /dev/null +++ b/system/blueprints/pages/modular.yaml @@ -0,0 +1,36 @@ +title: PLUGIN_ADMIN.MODULE +extends@: default + +form: + fields: + tabs: + type: tabs + active: 1 + + fields: + content: + fields: + + modular_title: + type: spacer + title: PLUGIN_ADMIN.MODULE_SETUP + + header.content.items: + type: text + label: PLUGIN_ADMIN.ITEMS + default: '@self.modular' + size: medium + + header.content.order.by: + type: text + label: PLUGIN_ADMIN.ORDER_BY + placeholder: date + help: + size: small + + header.content.order.dir: + type: text + label: PLUGIN_ADMIN.ORDER + help: '"desc" or "asc" are valid values' + placeholder: desc + size: small diff --git a/system/blueprints/pages/partials/security.yaml b/system/blueprints/pages/partials/security.yaml new file mode 100644 index 0000000..26d8c7d --- /dev/null +++ b/system/blueprints/pages/partials/security.yaml @@ -0,0 +1,67 @@ +form: + fields: + _site: + type: section + title: PLUGIN_ADMIN.PAGE_ACCESS + underline: true + + fields: + + header.login.visibility_requires_access: + type: toggle + toggleable: true + label: PLUGIN_ADMIN.PAGE_VISIBILITY_REQUIRES_ACCESS + help: PLUGIN_ADMIN.PAGE_VISIBILITY_REQUIRES_ACCESS_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + + header.access: + type: acl_picker + label: PLUGIN_ADMIN.PAGE_ACCESS + help: PLUGIN_ADMIN.PAGE_ACCESS_HELP + ignore_empty: true + data_type: access + validate: + type: array + value_type: bool + + _admin: + security@: {or: [admin.super, admin.configuration.pages]} + type: section + title: PLUGIN_ADMIN.PAGE PERMISSIONS + underline: true + + fields: + + header.permissions.inherit: + type: toggle + toggleable: true + label: PLUGIN_ADMIN.PAGE_INHERIT_PERMISSIONS + help: PLUGIN_ADMIN.PAGE_INHERIT_PERMISSIONS_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + header.permissions.authors: + type: array + toggleable: true + value_only: true + placeholder_value: PLUGIN_ADMIN.USERNAME + label: PLUGIN_ADMIN.PAGE_AUTHORS + help: PLUGIN_ADMIN.PAGE_AUTHORS_HELP + + header.permissions.groups: + ignore@: true + type: acl_picker + label: PLUGIN_ADMIN.PAGE_GROUPS + help: PLUGIN_ADMIN.PAGE_GROUPS_HELP + ignore_empty: true + data_type: permissions diff --git a/system/blueprints/pages/root.yaml b/system/blueprints/pages/root.yaml new file mode 100644 index 0000000..5abc516 --- /dev/null +++ b/system/blueprints/pages/root.yaml @@ -0,0 +1,16 @@ +title: PLUGIN_ADMIN.ROOT + +rules: + slug: + pattern: '[a-zA-Zа-яA-Я0-9_\-]+' + min: 1 + max: 200 + +form: + validation: loose + + fields: + + tabs: + type: tabs + active: 1 diff --git a/system/blueprints/user/account.yaml b/system/blueprints/user/account.yaml new file mode 100644 index 0000000..ef5f25b --- /dev/null +++ b/system/blueprints/user/account.yaml @@ -0,0 +1,157 @@ +title: Account +form: + validation: loose + + fields: + + info: + type: userinfo + size: large + + avatar: + type: file + size: large + destination: 'account://avatars' + multiple: false + random_name: true + + multiavatar_only: + type: conditional + condition: config.system.accounts.avatar == 'multiavatar' + fields: + avatar_hash: + type: text + label: '' + placeholder: 'e.g. dceaadcfda491f4e45' + description: PLUGIN_ADMIN.AVATAR_HASH + size: large + + content: + type: section + title: PLUGIN_ADMIN.ACCOUNT + underline: true + + username: + type: text + size: large + label: PLUGIN_ADMIN.USERNAME + disabled: true + readonly: true + + email: + type: email + size: large + label: PLUGIN_ADMIN.EMAIL + validate: + type: email + message: PLUGIN_ADMIN.EMAIL_VALIDATION_MESSAGE + required: true + + password: + type: password + size: large + label: PLUGIN_ADMIN.PASSWORD + autocomplete: new-password + validate: + required: false + message: PLUGIN_ADMIN.PASSWORD_VALIDATION_MESSAGE + config-pattern@: system.pwd_regex + + fullname: + type: text + size: large + label: PLUGIN_ADMIN.FULL_NAME + validate: + required: true + + title: + type: text + size: large + label: PLUGIN_ADMIN.TITLE + + language: + type: select + label: PLUGIN_ADMIN.LANGUAGE + size: medium + classes: fancy + data-options@: '\Grav\Plugin\Admin\Admin::adminLanguages' + default: 'en' + help: PLUGIN_ADMIN.LANGUAGE_HELP + + content_editor: + type: select + label: PLUGIN_ADMIN.CONTENT_EDITOR + size: medium + classes: fancy + data-options@: 'Grav\Plugin\Admin\Admin::contentEditor' + default: 'default' + help: PLUGIN_ADMIN.CONTENT_EDITOR_HELP + + twofa_check: + type: conditional + condition: config.plugins.admin.twofa_enabled + + fields: + + twofa: + title: PLUGIN_ADMIN.2FA_TITLE + type: section + underline: true + + twofa_enabled: + type: toggle + label: PLUGIN_ADMIN.2FA_ENABLED + classes: twofa-toggle + highlight: 1 + default: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + + twofa_secret: + type: 2fa_secret + outerclasses: 'twofa-secret' + markdown: true + label: PLUGIN_ADMIN.2FA_SECRET + sublabel: PLUGIN_ADMIN.2FA_SECRET_HELP + + yubikey_id: + type: text + label: PLUGIN_ADMIN.YUBIKEY_ID + description: PLUGIN_ADMIN.YUBIKEY_HELP + size: small + maxlength: 12 + + + + security: + security@: admin.super + title: PLUGIN_ADMIN.ACCESS_LEVELS + type: section + underline: true + + fields: + groups: + security@: admin.super + type: select + multiple: true + size: large + label: PLUGIN_ADMIN.GROUPS + data-options@: '\Grav\Common\User\Group::groupNames' + classes: fancy + help: PLUGIN_ADMIN.GROUPS_HELP + validate: + type: commalist + + access: + security@: admin.super + type: permissions + check_authorize: true + label: PLUGIN_ADMIN.PERMISSIONS + ignore_empty: true + validate: + type: array + value_type: bool diff --git a/system/blueprints/user/account_new.yaml b/system/blueprints/user/account_new.yaml new file mode 100644 index 0000000..1b22f93 --- /dev/null +++ b/system/blueprints/user/account_new.yaml @@ -0,0 +1,18 @@ +title: PLUGIN_ADMIN.ADD_ACCOUNT + +form: + validation: loose + fields: + + content: + type: section + title: PLUGIN_ADMIN.ADD_ACCOUNT + + username: + type: text + label: PLUGIN_ADMIN.USERNAME + help: PLUGIN_ADMIN.USERNAME_HELP + unset-disabled@: true + unset-readonly@: true + validate: + required: true diff --git a/system/blueprints/user/group.yaml b/system/blueprints/user/group.yaml new file mode 100644 index 0000000..61f0227 --- /dev/null +++ b/system/blueprints/user/group.yaml @@ -0,0 +1,55 @@ +title: Group +rules: + slug: + pattern: '[a-zA-Zа-яA-Я0-9_\-]+' + min: 1 + max: 200 + +form: + validation: loose + + fields: + groupname: + type: text + size: large + label: PLUGIN_ADMIN.GROUP_NAME + flex-disabled@: exists + flex-readonly@: exists + validate: + required: true + rule: slug + + readableName: + type: text + size: large + label: PLUGIN_ADMIN.DISPLAY_NAME + + description: + type: text + size: large + label: PLUGIN_ADMIN.DESCRIPTION + + icon: + type: text + size: small + label: PLUGIN_ADMIN.ICON + + enabled: + type: toggle + label: PLUGIN_ADMIN.ENABLED + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + access: + type: permissions + check_authorize: false + label: PLUGIN_ADMIN.PERMISSIONS + ignore_empty: true + validate: + type: array + value_type: bool diff --git a/system/blueprints/user/group_new.yaml b/system/blueprints/user/group_new.yaml new file mode 100644 index 0000000..baa37c3 --- /dev/null +++ b/system/blueprints/user/group_new.yaml @@ -0,0 +1,23 @@ +title: PLUGIN_ADMIN_PRO.ADD_GROUP + +rules: + slug: + pattern: '[a-zA-Zа-яA-Я0-9_\-]+' + min: 1 + max: 200 + +form: + validation: loose + fields: + + content: + type: section + title: PLUGIN_ADMIN_PRO.ADD_GROUP + + groupname: + type: text + label: PLUGIN_ADMIN_PRO.GROUP_NAME + help: PLUGIN_ADMIN_PRO.GROUP_NAME_HELP + validate: + required: true + rule: slug diff --git a/system/config/backups.yaml b/system/config/backups.yaml new file mode 100644 index 0000000..0d3c41e --- /dev/null +++ b/system/config/backups.yaml @@ -0,0 +1,15 @@ +purge: + trigger: space + max_backups_count: 25 + max_backups_space: 5 + max_backups_time: 365 + +profiles: + - + name: 'Default Site Backup' + root: '/' + schedule: false + schedule_at: '0 3 * * *' + exclude_paths: "/backup\r\n/cache\r\n/images\r\n/logs\r\n/tmp" + exclude_files: ".DS_Store\r\n.git\r\n.svn\r\n.hg\r\n.idea\r\n.vscode\r\nnode_modules" + diff --git a/system/config/media.yaml b/system/config/media.yaml new file mode 100644 index 0000000..e231b33 --- /dev/null +++ b/system/config/media.yaml @@ -0,0 +1,223 @@ +types: + defaults: + type: file + thumb: media/thumb.png + mime: application/octet-stream + image: + filters: + default: + - enableProgressive + + jpg: + type: image + thumb: media/thumb-jpg.png + mime: image/jpeg + jpe: + type: image + thumb: media/thumb-jpg.png + mime: image/jpeg + jpeg: + type: image + thumb: media/thumb-jpg.png + mime: image/jpeg + png: + type: image + thumb: media/thumb-png.png + mime: image/png + webp: + type: image + thumb: media/thumb-webp.png + mime: image/webp + avif: + type: image + thumb: media/thumb.png + mime: image/avif + gif: + type: animated + thumb: media/thumb-gif.png + mime: image/gif + svg: + type: vector + thumb: media/thumb-svg.png + mime: image/svg+xml + mp4: + type: video + thumb: media/thumb-mp4.png + mime: video/mp4 + mov: + type: video + thumb: media/thumb-mov.png + mime: video/quicktime + m4v: + type: video + thumb: media/thumb-m4v.png + mime: video/x-m4v + swf: + type: video + thumb: media/thumb-swf.png + mime: video/x-flv + flv: + type: video + thumb: media/thumb-flv.png + mime: video/x-flv + webm: + type: video + thumb: media/thumb-webm.png + mime: video/webm + ogv: + type: video + thumb: media/thumb-ogg.png + mime: video/ogg + mp3: + type: audio + thumb: media/thumb-mp3.png + mime: audio/mp3 + ogg: + type: audio + thumb: media/thumb-ogg.png + mime: audio/ogg + wma: + type: audio + thumb: media/thumb-wma.png + mime: audio/wma + m4a: + type: audio + thumb: media/thumb-m4a.png + mime: audio/m4a + wav: + type: audio + thumb: media/thumb-wav.png + mime: audio/wav + aiff: + type: audio + thumb: media/thumb-aif.png + mime: audio/aiff + aif: + type: audio + thumb: media/thumb-aif.png + mime: audio/aiff + txt: + type: file + thumb: media/thumb-txt.png + mime: text/plain + xml: + type: file + thumb: media/thumb-xml.png + mime: application/xml + doc: + type: file + thumb: media/thumb-doc.png + mime: application/msword + docx: + type: file + thumb: media/thumb-docx.png + mime: application/vnd.openxmlformats-officedocument.wordprocessingml.document + xls: + type: file + thumb: media/thumb-xls.png + mime: application/vnd.ms-excel + xlsx: + type: file + thumb: media/thumb-xlsx.png + mime: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + ppt: + type: file + thumb: media/thumb-ppt.png + mime: application/vnd.ms-powerpoint + pptx: + type: file + thumb: media/thumb-pptx.png + mime: application/vnd.openxmlformats-officedocument.presentationml.presentation + pps: + type: file + thumb: media/thumb-pps.png + mime: application/vnd.ms-powerpoint + rtf: + type: file + thumb: media/thumb-rtf.png + mime: application/rtf + bmp: + type: file + thumb: media/thumb-bmp.png + mime: image/bmp + tiff: + type: file + thumb: media/thumb-tiff.png + mime: image/tiff + mpeg: + type: file + thumb: media/thumb-mpg.png + mime: video/mpeg + mpg: + type: file + thumb: media/thumb-mpg.png + mime: video/mpeg + mpe: + type: file + thumb: media/thumb-mpe.png + mime: video/mpeg + avi: + type: file + thumb: media/thumb-avi.png + mime: video/msvideo + wmv: + type: file + thumb: media/thumb-wmv.png + mime: video/x-ms-wmv + html: + type: file + thumb: media/thumb-html.png + mime: text/html + htm: + type: file + thumb: media/thumb-html.png + mime: text/html + ics: + type: iCal + thumb: media/thumb-ics.png + mime: text/calendar + pdf: + type: file + thumb: media/thumb-pdf.png + mime: application/pdf + ai: + type: file + thumb: media/thumb-ai.png + mime: image/ai + psd: + type: file + thumb: media/thumb-psd.png + mime: image/psd + zip: + type: file + thumb: media/thumb-zip.png + mime: application/zip + 7z: + type: file + thumb: media/thumb-7z.png + mime: application/x-7z-compressed + gz: + type: file + thumb: media/thumb-gz.png + mime: application/x-gzip + tar: + type: file + thumb: media/thumb-tar.png + mime: application/x-tar + css: + type: file + thumb: media/thumb-css.png + mime: text/css + js: + type: file + thumb: media/thumb-js.png + mime: text/javascript + json: + type: file + thumb: media/thumb-json.png + mime: application/json + vcf: + type: file + thumb: media/thumb-vcf.png + mime: text/x-vcard + diff --git a/system/config/mime.yaml b/system/config/mime.yaml new file mode 100644 index 0000000..3143c67 --- /dev/null +++ b/system/config/mime.yaml @@ -0,0 +1,1986 @@ +types: + '123': + - application/vnd.lotus-1-2-3 + wof: + - application/font-woff + php: + - application/php + - application/x-httpd-php + - application/x-httpd-php-source + - application/x-php + - text/php + - text/x-php + otf: + - application/x-font-otf + - font/otf + ttf: + - application/x-font-ttf + - font/ttf + ttc: + - application/x-font-ttf + - font/collection + zip: + - application/x-gzip + - application/zip + - application/x-zip-compressed + amr: + - audio/amr + mp3: + - audio/mpeg + mpga: + - audio/mpeg + mp2: + - audio/mpeg + mp2a: + - audio/mpeg + m2a: + - audio/mpeg + m3a: + - audio/mpeg + jpg: + - image/jpeg + jpeg: + - image/jpeg + jpe: + - image/jpeg + bmp: + - image/x-ms-bmp + - image/bmp + ez: + - application/andrew-inset + aw: + - application/applixware + atom: + - application/atom+xml + atomcat: + - application/atomcat+xml + atomsvc: + - application/atomsvc+xml + ccxml: + - application/ccxml+xml + cdmia: + - application/cdmi-capability + cdmic: + - application/cdmi-container + cdmid: + - application/cdmi-domain + cdmio: + - application/cdmi-object + cdmiq: + - application/cdmi-queue + cu: + - application/cu-seeme + davmount: + - application/davmount+xml + dbk: + - application/docbook+xml + dssc: + - application/dssc+der + xdssc: + - application/dssc+xml + ecma: + - application/ecmascript + emma: + - application/emma+xml + epub: + - application/epub+zip + exi: + - application/exi + pfr: + - application/font-tdpfr + gml: + - application/gml+xml + gpx: + - application/gpx+xml + gxf: + - application/gxf + stk: + - application/hyperstudio + ink: + - application/inkml+xml + inkml: + - application/inkml+xml + ipfix: + - application/ipfix + jar: + - application/java-archive + ser: + - application/java-serialized-object + class: + - application/java-vm + js: + - application/javascript + json: + - application/json + jsonml: + - application/jsonml+json + lostxml: + - application/lost+xml + hqx: + - application/mac-binhex40 + cpt: + - application/mac-compactpro + mads: + - application/mads+xml + mrc: + - application/marc + mrcx: + - application/marcxml+xml + ma: + - application/mathematica + nb: + - application/mathematica + mb: + - application/mathematica + mathml: + - application/mathml+xml + mbox: + - application/mbox + mscml: + - application/mediaservercontrol+xml + metalink: + - application/metalink+xml + meta4: + - application/metalink4+xml + mets: + - application/mets+xml + mods: + - application/mods+xml + m21: + - application/mp21 + mp21: + - application/mp21 + mp4s: + - application/mp4 + doc: + - application/msword + dot: + - application/msword + mxf: + - application/mxf + bin: + - application/octet-stream + dms: + - application/octet-stream + lrf: + - application/octet-stream + mar: + - application/octet-stream + so: + - application/octet-stream + dist: + - application/octet-stream + distz: + - application/octet-stream + pkg: + - application/octet-stream + bpk: + - application/octet-stream + dump: + - application/octet-stream + elc: + - application/octet-stream + deploy: + - application/octet-stream + oda: + - application/oda + opf: + - application/oebps-package+xml + ogx: + - application/ogg + omdoc: + - application/omdoc+xml + onetoc: + - application/onenote + onetoc2: + - application/onenote + onetmp: + - application/onenote + onepkg: + - application/onenote + oxps: + - application/oxps + xer: + - application/patch-ops-error+xml + pdf: + - application/pdf + pgp: + - application/pgp-encrypted + asc: + - application/pgp-signature + sig: + - application/pgp-signature + prf: + - application/pics-rules + p10: + - application/pkcs10 + p7m: + - application/pkcs7-mime + p7c: + - application/pkcs7-mime + p7s: + - application/pkcs7-signature + p8: + - application/pkcs8 + ac: + - application/pkix-attr-cert + cer: + - application/pkix-cert + crl: + - application/pkix-crl + pkipath: + - application/pkix-pkipath + pki: + - application/pkixcmp + pls: + - application/pls+xml + ai: + - application/postscript + eps: + - application/postscript + ps: + - application/postscript + cww: + - application/prs.cww + pskcxml: + - application/pskc+xml + rdf: + - application/rdf+xml + rif: + - application/reginfo+xml + rnc: + - application/relax-ng-compact-syntax + rl: + - application/resource-lists+xml + rld: + - application/resource-lists-diff+xml + rs: + - application/rls-services+xml + gbr: + - application/rpki-ghostbusters + mft: + - application/rpki-manifest + roa: + - application/rpki-roa + rsd: + - application/rsd+xml + rss: + - application/rss+xml + rtf: + - application/rtf + sbml: + - application/sbml+xml + scq: + - application/scvp-cv-request + scs: + - application/scvp-cv-response + spq: + - application/scvp-vp-request + spp: + - application/scvp-vp-response + sdp: + - application/sdp + setpay: + - application/set-payment-initiation + setreg: + - application/set-registration-initiation + shf: + - application/shf+xml + smi: + - application/smil+xml + smil: + - application/smil+xml + rq: + - application/sparql-query + srx: + - application/sparql-results+xml + gram: + - application/srgs + grxml: + - application/srgs+xml + sru: + - application/sru+xml + ssdl: + - application/ssdl+xml + ssml: + - application/ssml+xml + tei: + - application/tei+xml + teicorpus: + - application/tei+xml + tfi: + - application/thraud+xml + tsd: + - application/timestamped-data + plb: + - application/vnd.3gpp.pic-bw-large + psb: + - application/vnd.3gpp.pic-bw-small + pvb: + - application/vnd.3gpp.pic-bw-var + tcap: + - application/vnd.3gpp2.tcap + pwn: + - application/vnd.3m.post-it-notes + aso: + - application/vnd.accpac.simply.aso + imp: + - application/vnd.accpac.simply.imp + acu: + - application/vnd.acucobol + atc: + - application/vnd.acucorp + acutc: + - application/vnd.acucorp + air: + - application/vnd.adobe.air-application-installer-package+zip + fcdt: + - application/vnd.adobe.formscentral.fcdt + fxp: + - application/vnd.adobe.fxp + fxpl: + - application/vnd.adobe.fxp + xdp: + - application/vnd.adobe.xdp+xml + xfdf: + - application/vnd.adobe.xfdf + ahead: + - application/vnd.ahead.space + azf: + - application/vnd.airzip.filesecure.azf + azs: + - application/vnd.airzip.filesecure.azs + azw: + - application/vnd.amazon.ebook + acc: + - application/vnd.americandynamics.acc + ami: + - application/vnd.amiga.ami + apk: + - application/vnd.android.package-archive + cii: + - application/vnd.anser-web-certificate-issue-initiation + fti: + - application/vnd.anser-web-funds-transfer-initiation + atx: + - application/vnd.antix.game-component + mpkg: + - application/vnd.apple.installer+xml + m3u8: + - application/vnd.apple.mpegurl + swi: + - application/vnd.aristanetworks.swi + iota: + - application/vnd.astraea-software.iota + aep: + - application/vnd.audiograph + mpm: + - application/vnd.blueice.multipass + bmi: + - application/vnd.bmi + rep: + - application/vnd.businessobjects + cdxml: + - application/vnd.chemdraw+xml + mmd: + - application/vnd.chipnuts.karaoke-mmd + cdy: + - application/vnd.cinderella + cla: + - application/vnd.claymore + rp9: + - application/vnd.cloanto.rp9 + c4g: + - application/vnd.clonk.c4group + c4d: + - application/vnd.clonk.c4group + c4f: + - application/vnd.clonk.c4group + c4p: + - application/vnd.clonk.c4group + c4u: + - application/vnd.clonk.c4group + c11amc: + - application/vnd.cluetrust.cartomobile-config + c11amz: + - application/vnd.cluetrust.cartomobile-config-pkg + csp: + - application/vnd.commonspace + cdbcmsg: + - application/vnd.contact.cmsg + cmc: + - application/vnd.cosmocaller + clkx: + - application/vnd.crick.clicker + clkk: + - application/vnd.crick.clicker.keyboard + clkp: + - application/vnd.crick.clicker.palette + clkt: + - application/vnd.crick.clicker.template + clkw: + - application/vnd.crick.clicker.wordbank + wbs: + - application/vnd.criticaltools.wbs+xml + pml: + - application/vnd.ctc-posml + ppd: + - application/vnd.cups-ppd + car: + - application/vnd.curl.car + pcurl: + - application/vnd.curl.pcurl + dart: + - application/vnd.dart + rdz: + - application/vnd.data-vision.rdz + uvf: + - application/vnd.dece.data + uvvf: + - application/vnd.dece.data + uvd: + - application/vnd.dece.data + uvvd: + - application/vnd.dece.data + uvt: + - application/vnd.dece.ttml+xml + uvvt: + - application/vnd.dece.ttml+xml + uvx: + - application/vnd.dece.unspecified + uvvx: + - application/vnd.dece.unspecified + uvz: + - application/vnd.dece.zip + uvvz: + - application/vnd.dece.zip + fe_launch: + - application/vnd.denovo.fcselayout-link + dna: + - application/vnd.dna + mlp: + - application/vnd.dolby.mlp + dpg: + - application/vnd.dpgraph + dfac: + - application/vnd.dreamfactory + kpxx: + - application/vnd.ds-keypoint + ait: + - application/vnd.dvb.ait + svc: + - application/vnd.dvb.service + geo: + - application/vnd.dynageo + mag: + - application/vnd.ecowin.chart + nml: + - application/vnd.enliven + esf: + - application/vnd.epson.esf + msf: + - application/vnd.epson.msf + qam: + - application/vnd.epson.quickanime + slt: + - application/vnd.epson.salt + ssf: + - application/vnd.epson.ssf + es3: + - application/vnd.eszigno3+xml + et3: + - application/vnd.eszigno3+xml + ez2: + - application/vnd.ezpix-album + ez3: + - application/vnd.ezpix-package + fdf: + - application/vnd.fdf + mseed: + - application/vnd.fdsn.mseed + seed: + - application/vnd.fdsn.seed + dataless: + - application/vnd.fdsn.seed + gph: + - application/vnd.flographit + ftc: + - application/vnd.fluxtime.clip + fm: + - application/vnd.framemaker + frame: + - application/vnd.framemaker + maker: + - application/vnd.framemaker + book: + - application/vnd.framemaker + fnc: + - application/vnd.frogans.fnc + ltf: + - application/vnd.frogans.ltf + fsc: + - application/vnd.fsc.weblaunch + oas: + - application/vnd.fujitsu.oasys + oa2: + - application/vnd.fujitsu.oasys2 + oa3: + - application/vnd.fujitsu.oasys3 + fg5: + - application/vnd.fujitsu.oasysgp + bh2: + - application/vnd.fujitsu.oasysprs + ddd: + - application/vnd.fujixerox.ddd + xdw: + - application/vnd.fujixerox.docuworks + xbd: + - application/vnd.fujixerox.docuworks.binder + fzs: + - application/vnd.fuzzysheet + txd: + - application/vnd.genomatix.tuxedo + ggb: + - application/vnd.geogebra.file + ggt: + - application/vnd.geogebra.tool + gex: + - application/vnd.geometry-explorer + gre: + - application/vnd.geometry-explorer + gxt: + - application/vnd.geonext + g2w: + - application/vnd.geoplan + g3w: + - application/vnd.geospace + gmx: + - application/vnd.gmx + kml: + - application/vnd.google-earth.kml+xml + kmz: + - application/vnd.google-earth.kmz + gqf: + - application/vnd.grafeq + gqs: + - application/vnd.grafeq + gac: + - application/vnd.groove-account + ghf: + - application/vnd.groove-help + gim: + - application/vnd.groove-identity-message + grv: + - application/vnd.groove-injector + gtm: + - application/vnd.groove-tool-message + tpl: + - application/vnd.groove-tool-template + vcg: + - application/vnd.groove-vcard + hal: + - application/vnd.hal+xml + zmm: + - application/vnd.handheld-entertainment+xml + hbci: + - application/vnd.hbci + les: + - application/vnd.hhe.lesson-player + hpgl: + - application/vnd.hp-hpgl + hpid: + - application/vnd.hp-hpid + hps: + - application/vnd.hp-hps + jlt: + - application/vnd.hp-jlyt + pcl: + - application/vnd.hp-pcl + pclxl: + - application/vnd.hp-pclxl + sfd-hdstx: + - application/vnd.hydrostatix.sof-data + mpy: + - application/vnd.ibm.minipay + afp: + - application/vnd.ibm.modcap + listafp: + - application/vnd.ibm.modcap + list3820: + - application/vnd.ibm.modcap + irm: + - application/vnd.ibm.rights-management + sc: + - application/vnd.ibm.secure-container + icc: + - application/vnd.iccprofile + icm: + - application/vnd.iccprofile + igl: + - application/vnd.igloader + ivp: + - application/vnd.immervision-ivp + ivu: + - application/vnd.immervision-ivu + igm: + - application/vnd.insors.igm + xpw: + - application/vnd.intercon.formnet + xpx: + - application/vnd.intercon.formnet + i2g: + - application/vnd.intergeo + qbo: + - application/vnd.intu.qbo + qfx: + - application/vnd.intu.qfx + rcprofile: + - application/vnd.ipunplugged.rcprofile + irp: + - application/vnd.irepository.package+xml + xpr: + - application/vnd.is-xpr + fcs: + - application/vnd.isac.fcs + jam: + - application/vnd.jam + rms: + - application/vnd.jcp.javame.midlet-rms + jisp: + - application/vnd.jisp + joda: + - application/vnd.joost.joda-archive + ktz: + - application/vnd.kahootz + ktr: + - application/vnd.kahootz + karbon: + - application/vnd.kde.karbon + chrt: + - application/vnd.kde.kchart + kfo: + - application/vnd.kde.kformula + flw: + - application/vnd.kde.kivio + kon: + - application/vnd.kde.kontour + kpr: + - application/vnd.kde.kpresenter + kpt: + - application/vnd.kde.kpresenter + ksp: + - application/vnd.kde.kspread + kwd: + - application/vnd.kde.kword + kwt: + - application/vnd.kde.kword + htke: + - application/vnd.kenameaapp + kia: + - application/vnd.kidspiration + kne: + - application/vnd.kinar + knp: + - application/vnd.kinar + skp: + - application/vnd.koan + skd: + - application/vnd.koan + skt: + - application/vnd.koan + skm: + - application/vnd.koan + sse: + - application/vnd.kodak-descriptor + lasxml: + - application/vnd.las.las+xml + lbd: + - application/vnd.llamagraphics.life-balance.desktop + lbe: + - application/vnd.llamagraphics.life-balance.exchange+xml + apr: + - application/vnd.lotus-approach + pre: + - application/vnd.lotus-freelance + nsf: + - application/vnd.lotus-notes + org: + - application/vnd.lotus-organizer + scm: + - application/vnd.lotus-screencam + lwp: + - application/vnd.lotus-wordpro + portpkg: + - application/vnd.macports.portpkg + mcd: + - application/vnd.mcd + mc1: + - application/vnd.medcalcdata + cdkey: + - application/vnd.mediastation.cdkey + mwf: + - application/vnd.mfer + mfm: + - application/vnd.mfmp + flo: + - application/vnd.micrografx.flo + igx: + - application/vnd.micrografx.igx + mif: + - application/vnd.mif + daf: + - application/vnd.mobius.daf + dis: + - application/vnd.mobius.dis + mbk: + - application/vnd.mobius.mbk + mqy: + - application/vnd.mobius.mqy + msl: + - application/vnd.mobius.msl + plc: + - application/vnd.mobius.plc + txf: + - application/vnd.mobius.txf + mpn: + - application/vnd.mophun.application + mpc: + - application/vnd.mophun.certificate + xul: + - application/vnd.mozilla.xul+xml + cil: + - application/vnd.ms-artgalry + cab: + - application/vnd.ms-cab-compressed + xls: + - application/vnd.ms-excel + xlm: + - application/vnd.ms-excel + xla: + - application/vnd.ms-excel + xlc: + - application/vnd.ms-excel + xlt: + - application/vnd.ms-excel + xlw: + - application/vnd.ms-excel + xlam: + - application/vnd.ms-excel.addin.macroenabled.12 + xlsb: + - application/vnd.ms-excel.sheet.binary.macroenabled.12 + xlsm: + - application/vnd.ms-excel.sheet.macroenabled.12 + xltm: + - application/vnd.ms-excel.template.macroenabled.12 + eot: + - application/vnd.ms-fontobject + chm: + - application/vnd.ms-htmlhelp + ims: + - application/vnd.ms-ims + lrm: + - application/vnd.ms-lrm + thmx: + - application/vnd.ms-officetheme + cat: + - application/vnd.ms-pki.seccat + stl: + - application/vnd.ms-pki.stl + ppt: + - application/vnd.ms-powerpoint + pps: + - application/vnd.ms-powerpoint + pot: + - application/vnd.ms-powerpoint + ppam: + - application/vnd.ms-powerpoint.addin.macroenabled.12 + pptm: + - application/vnd.ms-powerpoint.presentation.macroenabled.12 + sldm: + - application/vnd.ms-powerpoint.slide.macroenabled.12 + ppsm: + - application/vnd.ms-powerpoint.slideshow.macroenabled.12 + potm: + - application/vnd.ms-powerpoint.template.macroenabled.12 + mpp: + - application/vnd.ms-project + mpt: + - application/vnd.ms-project + docm: + - application/vnd.ms-word.document.macroenabled.12 + dotm: + - application/vnd.ms-word.template.macroenabled.12 + wps: + - application/vnd.ms-works + wks: + - application/vnd.ms-works + wcm: + - application/vnd.ms-works + wdb: + - application/vnd.ms-works + wpl: + - application/vnd.ms-wpl + xps: + - application/vnd.ms-xpsdocument + mseq: + - application/vnd.mseq + mus: + - application/vnd.musician + msty: + - application/vnd.muvee.style + taglet: + - application/vnd.mynfc + nlu: + - application/vnd.neurolanguage.nlu + ntf: + - application/vnd.nitf + nitf: + - application/vnd.nitf + nnd: + - application/vnd.noblenet-directory + nns: + - application/vnd.noblenet-sealer + nnw: + - application/vnd.noblenet-web + ngdat: + - application/vnd.nokia.n-gage.data + n-gage: + - application/vnd.nokia.n-gage.symbian.install + rpst: + - application/vnd.nokia.radio-preset + rpss: + - application/vnd.nokia.radio-presets + edm: + - application/vnd.novadigm.edm + edx: + - application/vnd.novadigm.edx + ext: + - application/vnd.novadigm.ext + odc: + - application/vnd.oasis.opendocument.chart + otc: + - application/vnd.oasis.opendocument.chart-template + odb: + - application/vnd.oasis.opendocument.database + odf: + - application/vnd.oasis.opendocument.formula + odft: + - application/vnd.oasis.opendocument.formula-template + odg: + - application/vnd.oasis.opendocument.graphics + otg: + - application/vnd.oasis.opendocument.graphics-template + odi: + - application/vnd.oasis.opendocument.image + oti: + - application/vnd.oasis.opendocument.image-template + odp: + - application/vnd.oasis.opendocument.presentation + otp: + - application/vnd.oasis.opendocument.presentation-template + ods: + - application/vnd.oasis.opendocument.spreadsheet + ots: + - application/vnd.oasis.opendocument.spreadsheet-template + odt: + - application/vnd.oasis.opendocument.text + odm: + - application/vnd.oasis.opendocument.text-master + ott: + - application/vnd.oasis.opendocument.text-template + oth: + - application/vnd.oasis.opendocument.text-web + xo: + - application/vnd.olpc-sugar + dd2: + - application/vnd.oma.dd2+xml + oxt: + - application/vnd.openofficeorg.extension + pptx: + - application/vnd.openxmlformats-officedocument.presentationml.presentation + sldx: + - application/vnd.openxmlformats-officedocument.presentationml.slide + ppsx: + - application/vnd.openxmlformats-officedocument.presentationml.slideshow + potx: + - application/vnd.openxmlformats-officedocument.presentationml.template + xlsx: + - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + xltx: + - application/vnd.openxmlformats-officedocument.spreadsheetml.template + docx: + - application/vnd.openxmlformats-officedocument.wordprocessingml.document + dotx: + - application/vnd.openxmlformats-officedocument.wordprocessingml.template + mgp: + - application/vnd.osgeo.mapguide.package + dp: + - application/vnd.osgi.dp + esa: + - application/vnd.osgi.subsystem + pdb: + - application/vnd.palm + pqa: + - application/vnd.palm + oprc: + - application/vnd.palm + paw: + - application/vnd.pawaafile + str: + - application/vnd.pg.format + ei6: + - application/vnd.pg.osasli + efif: + - application/vnd.picsel + wg: + - application/vnd.pmi.widget + plf: + - application/vnd.pocketlearn + pbd: + - application/vnd.powerbuilder6 + box: + - application/vnd.previewsystems.box + mgz: + - application/vnd.proteus.magazine + qps: + - application/vnd.publishare-delta-tree + ptid: + - application/vnd.pvi.ptid1 + qxd: + - application/vnd.quark.quarkxpress + qxt: + - application/vnd.quark.quarkxpress + qwd: + - application/vnd.quark.quarkxpress + qwt: + - application/vnd.quark.quarkxpress + qxl: + - application/vnd.quark.quarkxpress + qxb: + - application/vnd.quark.quarkxpress + bed: + - application/vnd.realvnc.bed + mxl: + - application/vnd.recordare.musicxml + musicxml: + - application/vnd.recordare.musicxml+xml + cryptonote: + - application/vnd.rig.cryptonote + cod: + - application/vnd.rim.cod + rm: + - application/vnd.rn-realmedia + rmvb: + - application/vnd.rn-realmedia-vbr + link66: + - application/vnd.route66.link66+xml + st: + - application/vnd.sailingtracker.track + see: + - application/vnd.seemail + sema: + - application/vnd.sema + semd: + - application/vnd.semd + semf: + - application/vnd.semf + ifm: + - application/vnd.shana.informed.formdata + itp: + - application/vnd.shana.informed.formtemplate + iif: + - application/vnd.shana.informed.interchange + ipk: + - application/vnd.shana.informed.package + twd: + - application/vnd.simtech-mindmapper + twds: + - application/vnd.simtech-mindmapper + mmf: + - application/vnd.smaf + teacher: + - application/vnd.smart.teacher + sdkm: + - application/vnd.solent.sdkm+xml + sdkd: + - application/vnd.solent.sdkm+xml + dxp: + - application/vnd.spotfire.dxp + sfs: + - application/vnd.spotfire.sfs + sdc: + - application/vnd.stardivision.calc + sda: + - application/vnd.stardivision.draw + sdd: + - application/vnd.stardivision.impress + smf: + - application/vnd.stardivision.math + sdw: + - application/vnd.stardivision.writer + vor: + - application/vnd.stardivision.writer + sgl: + - application/vnd.stardivision.writer-global + smzip: + - application/vnd.stepmania.package + sm: + - application/vnd.stepmania.stepchart + sxc: + - application/vnd.sun.xml.calc + stc: + - application/vnd.sun.xml.calc.template + sxd: + - application/vnd.sun.xml.draw + std: + - application/vnd.sun.xml.draw.template + sxi: + - application/vnd.sun.xml.impress + sti: + - application/vnd.sun.xml.impress.template + sxm: + - application/vnd.sun.xml.math + sxw: + - application/vnd.sun.xml.writer + sxg: + - application/vnd.sun.xml.writer.global + stw: + - application/vnd.sun.xml.writer.template + sus: + - application/vnd.sus-calendar + susp: + - application/vnd.sus-calendar + svd: + - application/vnd.svd + sis: + - application/vnd.symbian.install + sisx: + - application/vnd.symbian.install + xsm: + - application/vnd.syncml+xml + bdm: + - application/vnd.syncml.dm+wbxml + xdm: + - application/vnd.syncml.dm+xml + tao: + - application/vnd.tao.intent-module-archive + pcap: + - application/vnd.tcpdump.pcap + cap: + - application/vnd.tcpdump.pcap + dmp: + - application/vnd.tcpdump.pcap + tmo: + - application/vnd.tmobile-livetv + tpt: + - application/vnd.trid.tpt + mxs: + - application/vnd.triscape.mxs + tra: + - application/vnd.trueapp + ufd: + - application/vnd.ufdl + ufdl: + - application/vnd.ufdl + utz: + - application/vnd.uiq.theme + umj: + - application/vnd.umajin + unityweb: + - application/vnd.unity + uoml: + - application/vnd.uoml+xml + vcx: + - application/vnd.vcx + vsd: + - application/vnd.visio + vst: + - application/vnd.visio + vss: + - application/vnd.visio + vsw: + - application/vnd.visio + vis: + - application/vnd.visionary + vsf: + - application/vnd.vsf + wbxml: + - application/vnd.wap.wbxml + wmlc: + - application/vnd.wap.wmlc + wmlsc: + - application/vnd.wap.wmlscriptc + wtb: + - application/vnd.webturbo + nbp: + - application/vnd.wolfram.player + wpd: + - application/vnd.wordperfect + wqd: + - application/vnd.wqd + stf: + - application/vnd.wt.stf + xar: + - application/vnd.xara + xfdl: + - application/vnd.xfdl + hvd: + - application/vnd.yamaha.hv-dic + hvs: + - application/vnd.yamaha.hv-script + hvp: + - application/vnd.yamaha.hv-voice + osf: + - application/vnd.yamaha.openscoreformat + osfpvg: + - application/vnd.yamaha.openscoreformat.osfpvg+xml + saf: + - application/vnd.yamaha.smaf-audio + spf: + - application/vnd.yamaha.smaf-phrase + cmp: + - application/vnd.yellowriver-custom-menu + zir: + - application/vnd.zul + zirz: + - application/vnd.zul + zaz: + - application/vnd.zzazz.deck+xml + vxml: + - application/voicexml+xml + wgt: + - application/widget + hlp: + - application/winhlp + wsdl: + - application/wsdl+xml + wspolicy: + - application/wspolicy+xml + 7z: + - application/x-7z-compressed + abw: + - application/x-abiword + ace: + - application/x-ace-compressed + dmg: + - application/x-apple-diskimage + aab: + - application/x-authorware-bin + x32: + - application/x-authorware-bin + u32: + - application/x-authorware-bin + vox: + - application/x-authorware-bin + aam: + - application/x-authorware-map + aas: + - application/x-authorware-seg + bcpio: + - application/x-bcpio + torrent: + - application/x-bittorrent + blb: + - application/x-blorb + blorb: + - application/x-blorb + bz: + - application/x-bzip + bz2: + - application/x-bzip2 + boz: + - application/x-bzip2 + cbr: + - application/x-cbr + cba: + - application/x-cbr + cbt: + - application/x-cbr + cbz: + - application/x-cbr + cb7: + - application/x-cbr + vcd: + - application/x-cdlink + cfs: + - application/x-cfs-compressed + chat: + - application/x-chat + pgn: + - application/x-chess-pgn + nsc: + - application/x-conference + cpio: + - application/x-cpio + csh: + - application/x-csh + deb: + - application/x-debian-package + udeb: + - application/x-debian-package + dgc: + - application/x-dgc-compressed + dir: + - application/x-director + dcr: + - application/x-director + dxr: + - application/x-director + cst: + - application/x-director + cct: + - application/x-director + cxt: + - application/x-director + w3d: + - application/x-director + fgd: + - application/x-director + swa: + - application/x-director + wad: + - application/x-doom + ncx: + - application/x-dtbncx+xml + dtb: + - application/x-dtbook+xml + res: + - application/x-dtbresource+xml + dvi: + - application/x-dvi + evy: + - application/x-envoy + eva: + - application/x-eva + bdf: + - application/x-font-bdf + gsf: + - application/x-font-ghostscript + psf: + - application/x-font-linux-psf + pcf: + - application/x-font-pcf + snf: + - application/x-font-snf + pfa: + - application/x-font-type1 + pfb: + - application/x-font-type1 + pfm: + - application/x-font-type1 + afm: + - application/x-font-type1 + arc: + - application/x-freearc + spl: + - application/x-futuresplash + gca: + - application/x-gca-compressed + ulx: + - application/x-glulx + gnumeric: + - application/x-gnumeric + gramps: + - application/x-gramps-xml + gtar: + - application/x-gtar + hdf: + - application/x-hdf + install: + - application/x-install-instructions + iso: + - application/x-iso9660-image + jnlp: + - application/x-java-jnlp-file + latex: + - application/x-latex + lzh: + - application/x-lzh-compressed + lha: + - application/x-lzh-compressed + mie: + - application/x-mie + prc: + - application/x-mobipocket-ebook + mobi: + - application/x-mobipocket-ebook + application: + - application/x-ms-application + lnk: + - application/x-ms-shortcut + wmd: + - application/x-ms-wmd + wmz: + - application/x-ms-wmz + - application/x-msmetafile + xbap: + - application/x-ms-xbap + mdb: + - application/x-msaccess + obd: + - application/x-msbinder + crd: + - application/x-mscardfile + clp: + - application/x-msclip + exe: + - application/x-msdownload + dll: + - application/x-msdownload + com: + - application/x-msdownload + bat: + - application/x-msdownload + msi: + - application/x-msdownload + mvb: + - application/x-msmediaview + m13: + - application/x-msmediaview + m14: + - application/x-msmediaview + wmf: + - application/x-msmetafile + emf: + - application/x-msmetafile + emz: + - application/x-msmetafile + mny: + - application/x-msmoney + pub: + - application/x-mspublisher + scd: + - application/x-msschedule + trm: + - application/x-msterminal + wri: + - application/x-mswrite + nc: + - application/x-netcdf + cdf: + - application/x-netcdf + nzb: + - application/x-nzb + p12: + - application/x-pkcs12 + pfx: + - application/x-pkcs12 + p7b: + - application/x-pkcs7-certificates + spc: + - application/x-pkcs7-certificates + p7r: + - application/x-pkcs7-certreqresp + rar: + - application/x-rar-compressed + ris: + - application/x-research-info-systems + sh: + - application/x-sh + shar: + - application/x-shar + swf: + - application/x-shockwave-flash + xap: + - application/x-silverlight-app + sql: + - application/x-sql + sit: + - application/x-stuffit + sitx: + - application/x-stuffitx + srt: + - application/x-subrip + sv4cpio: + - application/x-sv4cpio + sv4crc: + - application/x-sv4crc + t3: + - application/x-t3vm-image + gam: + - application/x-tads + tar: + - application/x-tar + tcl: + - application/x-tcl + tex: + - application/x-tex + tfm: + - application/x-tex-tfm + texinfo: + - application/x-texinfo + texi: + - application/x-texinfo + obj: + - application/x-tgif + ustar: + - application/x-ustar + src: + - application/x-wais-source + der: + - application/x-x509-ca-cert + crt: + - application/x-x509-ca-cert + fig: + - application/x-xfig + xlf: + - application/x-xliff+xml + xpi: + - application/x-xpinstall + xz: + - application/x-xz + z1: + - application/x-zmachine + z2: + - application/x-zmachine + z3: + - application/x-zmachine + z4: + - application/x-zmachine + z5: + - application/x-zmachine + z6: + - application/x-zmachine + z7: + - application/x-zmachine + z8: + - application/x-zmachine + xaml: + - application/xaml+xml + xdf: + - application/xcap-diff+xml + xenc: + - application/xenc+xml + xhtml: + - application/xhtml+xml + xht: + - application/xhtml+xml + xml: + - application/xml + xsl: + - application/xml + dtd: + - application/xml-dtd + xop: + - application/xop+xml + xpl: + - application/xproc+xml + xslt: + - application/xslt+xml + xspf: + - application/xspf+xml + mxml: + - application/xv+xml + xhvml: + - application/xv+xml + xvml: + - application/xv+xml + xvm: + - application/xv+xml + yang: + - application/yang + yin: + - application/yin+xml + adp: + - audio/adpcm + au: + - audio/basic + snd: + - audio/basic + mid: + - audio/midi + midi: + - audio/midi + kar: + - audio/midi + rmi: + - audio/midi + m4a: + - audio/mp4 + mp4a: + - audio/mp4 + oga: + - audio/ogg + ogg: + - audio/ogg + spx: + - audio/ogg + s3m: + - audio/s3m + sil: + - audio/silk + uva: + - audio/vnd.dece.audio + uvva: + - audio/vnd.dece.audio + eol: + - audio/vnd.digital-winds + dra: + - audio/vnd.dra + dts: + - audio/vnd.dts + dtshd: + - audio/vnd.dts.hd + lvp: + - audio/vnd.lucent.voice + pya: + - audio/vnd.ms-playready.media.pya + ecelp4800: + - audio/vnd.nuera.ecelp4800 + ecelp7470: + - audio/vnd.nuera.ecelp7470 + ecelp9600: + - audio/vnd.nuera.ecelp9600 + rip: + - audio/vnd.rip + weba: + - audio/webm + aac: + - audio/x-aac + aif: + - audio/x-aiff + aiff: + - audio/x-aiff + aifc: + - audio/x-aiff + caf: + - audio/x-caf + flac: + - audio/x-flac + mka: + - audio/x-matroska + m3u: + - audio/x-mpegurl + wax: + - audio/x-ms-wax + wma: + - audio/x-ms-wma + ram: + - audio/x-pn-realaudio + ra: + - audio/x-pn-realaudio + rmp: + - audio/x-pn-realaudio-plugin + wav: + - audio/x-wav + xm: + - audio/xm + cdx: + - chemical/x-cdx + cif: + - chemical/x-cif + cmdf: + - chemical/x-cmdf + cml: + - chemical/x-cml + csml: + - chemical/x-csml + xyz: + - chemical/x-xyz + woff: + - font/woff + woff2: + - font/woff2 + cgm: + - image/cgm + g3: + - image/g3fax + gif: + - image/gif + ief: + - image/ief + ktx: + - image/ktx + png: + - image/png + btif: + - image/prs.btif + sgi: + - image/sgi + svg: + - image/svg+xml + svgz: + - image/svg+xml + tiff: + - image/tiff + tif: + - image/tiff + psd: + - image/vnd.adobe.photoshop + uvi: + - image/vnd.dece.graphic + uvvi: + - image/vnd.dece.graphic + uvg: + - image/vnd.dece.graphic + uvvg: + - image/vnd.dece.graphic + djvu: + - image/vnd.djvu + djv: + - image/vnd.djvu + sub: + - image/vnd.dvb.subtitle + - text/vnd.dvb.subtitle + dwg: + - image/vnd.dwg + dxf: + - image/vnd.dxf + fbs: + - image/vnd.fastbidsheet + fpx: + - image/vnd.fpx + fst: + - image/vnd.fst + mmr: + - image/vnd.fujixerox.edmics-mmr + rlc: + - image/vnd.fujixerox.edmics-rlc + mdi: + - image/vnd.ms-modi + wdp: + - image/vnd.ms-photo + npx: + - image/vnd.net-fpx + wbmp: + - image/vnd.wap.wbmp + xif: + - image/vnd.xiff + webp: + - image/webp + 3ds: + - image/x-3ds + ras: + - image/x-cmu-raster + cmx: + - image/x-cmx + fh: + - image/x-freehand + fhc: + - image/x-freehand + fh4: + - image/x-freehand + fh5: + - image/x-freehand + fh7: + - image/x-freehand + ico: + - image/x-icon + sid: + - image/x-mrsid-image + pcx: + - image/x-pcx + pic: + - image/x-pict + pct: + - image/x-pict + pnm: + - image/x-portable-anymap + pbm: + - image/x-portable-bitmap + pgm: + - image/x-portable-graymap + ppm: + - image/x-portable-pixmap + rgb: + - image/x-rgb + tga: + - image/x-tga + xbm: + - image/x-xbitmap + xpm: + - image/x-xpixmap + xwd: + - image/x-xwindowdump + eml: + - message/rfc822 + mime: + - message/rfc822 + igs: + - model/iges + iges: + - model/iges + msh: + - model/mesh + mesh: + - model/mesh + silo: + - model/mesh + dae: + - model/vnd.collada+xml + dwf: + - model/vnd.dwf + gdl: + - model/vnd.gdl + gtw: + - model/vnd.gtw + mts: + - model/vnd.mts + vtu: + - model/vnd.vtu + wrl: + - model/vrml + vrml: + - model/vrml + x3db: + - model/x3d+binary + x3dbz: + - model/x3d+binary + x3dv: + - model/x3d+vrml + x3dvz: + - model/x3d+vrml + x3d: + - model/x3d+xml + x3dz: + - model/x3d+xml + appcache: + - text/cache-manifest + ics: + - text/calendar + ifb: + - text/calendar + css: + - text/css + csv: + - text/csv + html: + - text/html + htm: + - text/html + n3: + - text/n3 + txt: + - text/plain + text: + - text/plain + conf: + - text/plain + def: + - text/plain + list: + - text/plain + log: + - text/plain + in: + - text/plain + dsc: + - text/prs.lines.tag + rtx: + - text/richtext + sgml: + - text/sgml + sgm: + - text/sgml + tsv: + - text/tab-separated-values + t: + - text/troff + tr: + - text/troff + roff: + - text/troff + man: + - text/troff + me: + - text/troff + ms: + - text/troff + ttl: + - text/turtle + uri: + - text/uri-list + uris: + - text/uri-list + urls: + - text/uri-list + vcard: + - text/vcard + curl: + - text/vnd.curl + dcurl: + - text/vnd.curl.dcurl + mcurl: + - text/vnd.curl.mcurl + scurl: + - text/vnd.curl.scurl + fly: + - text/vnd.fly + flx: + - text/vnd.fmi.flexstor + gv: + - text/vnd.graphviz + 3dml: + - text/vnd.in3d.3dml + spot: + - text/vnd.in3d.spot + jad: + - text/vnd.sun.j2me.app-descriptor + wml: + - text/vnd.wap.wml + wmls: + - text/vnd.wap.wmlscript + s: + - text/x-asm + asm: + - text/x-asm + c: + - text/x-c + cc: + - text/x-c + cxx: + - text/x-c + cpp: + - text/x-c + h: + - text/x-c + hh: + - text/x-c + dic: + - text/x-c + f: + - text/x-fortran + for: + - text/x-fortran + f77: + - text/x-fortran + f90: + - text/x-fortran + java: + - text/x-java-source + nfo: + - text/x-nfo + opml: + - text/x-opml + p: + - text/x-pascal + pas: + - text/x-pascal + etx: + - text/x-setext + sfv: + - text/x-sfv + uu: + - text/x-uuencode + vcs: + - text/x-vcalendar + vcf: + - text/x-vcard + 3gp: + - video/3gpp + 3g2: + - video/3gpp2 + h261: + - video/h261 + h263: + - video/h263 + h264: + - video/h264 + jpgv: + - video/jpeg + jpm: + - video/jpm + jpgm: + - video/jpm + mj2: + - video/mj2 + mjp2: + - video/mj2 + mp4: + - video/mp4 + mp4v: + - video/mp4 + mpg4: + - video/mp4 + mpeg: + - video/mpeg + mpg: + - video/mpeg + mpe: + - video/mpeg + m1v: + - video/mpeg + m2v: + - video/mpeg + ogv: + - video/ogg + qt: + - video/quicktime + mov: + - video/quicktime + uvh: + - video/vnd.dece.hd + uvvh: + - video/vnd.dece.hd + uvm: + - video/vnd.dece.mobile + uvvm: + - video/vnd.dece.mobile + uvp: + - video/vnd.dece.pd + uvvp: + - video/vnd.dece.pd + uvs: + - video/vnd.dece.sd + uvvs: + - video/vnd.dece.sd + uvv: + - video/vnd.dece.video + uvvv: + - video/vnd.dece.video + dvb: + - video/vnd.dvb.file + fvt: + - video/vnd.fvt + mxu: + - video/vnd.mpegurl + m4u: + - video/vnd.mpegurl + pyv: + - video/vnd.ms-playready.media.pyv + uvu: + - video/vnd.uvvu.mp4 + uvvu: + - video/vnd.uvvu.mp4 + viv: + - video/vnd.vivo + webm: + - video/webm + f4v: + - video/x-f4v + fli: + - video/x-fli + flv: + - video/x-flv + m4v: + - video/x-m4v + mkv: + - video/x-matroska + mk3d: + - video/x-matroska + mks: + - video/x-matroska + mng: + - video/x-mng + asf: + - video/x-ms-asf + asx: + - video/x-ms-asf + vob: + - video/x-ms-vob + wm: + - video/x-ms-wm + wmv: + - video/x-ms-wmv + wmx: + - video/x-ms-wmx + wvx: + - video/x-ms-wvx + avi: + - video/x-msvideo + movie: + - video/x-sgi-movie + smv: + - video/x-smv + ice: + - x-conference/x-cooltalk diff --git a/system/config/permissions.yaml b/system/config/permissions.yaml new file mode 100644 index 0000000..a65b223 --- /dev/null +++ b/system/config/permissions.yaml @@ -0,0 +1,53 @@ +actions: + site: + type: access + label: Site + admin: + type: access + label: Admin + admin.pages: + type: access + label: Pages + admin.users: + type: access + label: User Accounts + +types: + default: + type: access + + crud: + type: compact + letters: + c: + action: create + label: PLUGIN_ADMIN.CREATE + r: + action: read + label: PLUGIN_ADMIN.READ + u: + action: update + label: PLUGIN_ADMIN.UPDATE + d: + action: delete + label: PLUGIN_ADMIN.DELETE + + crudp: + type: crud + letters: + p: + action: publish + label: PLUGIN_ADMIN.PUBLISH + + crudl: + type: crud + letters: + l: + action: list + label: PLUGIN_ADMIN.LIST + + crudpl: + type: crud + use: + - crudp + - crudl diff --git a/system/config/scheduler.yaml b/system/config/scheduler.yaml new file mode 100644 index 0000000..8868532 --- /dev/null +++ b/system/config/scheduler.yaml @@ -0,0 +1,68 @@ +# Grav Scheduler Configuration + +# Default scheduler settings (backward compatible) +defaults: + output: true + output_type: file + email: null + +# Status of individual jobs (enabled/disabled) +status: {} + +# Custom scheduled jobs +custom_jobs: {} + +# Modern scheduler features (disabled by default for backward compatibility) +modern: + # Enable modern scheduler features + enabled: false + + # Number of concurrent workers (1 = sequential execution like legacy) + workers: 1 + + # Job retry configuration + retry: + enabled: true + max_attempts: 3 + backoff: exponential # 'linear' or 'exponential' + + # Job queue configuration + queue: + path: user-data://scheduler/queue + max_size: 1000 + + # Webhook trigger configuration + webhook: + enabled: false + token: null # Set a secure token to enable webhook triggers + path: /scheduler/webhook + + # Health check endpoint + health: + enabled: true + path: /scheduler/health + + # Job execution history + history: + enabled: true + retention_days: 30 + path: user-data://scheduler/history + + # Performance settings + performance: + job_timeout: 300 # Default timeout in seconds + lock_timeout: 10 # Lock acquisition timeout in seconds + + # Monitoring and alerts + monitoring: + enabled: false + alert_on_failure: true + alert_email: null + webhook_url: null + + # Trigger detection methods + triggers: + check_cron: true + check_systemd: true + check_webhook: true + check_external: true \ No newline at end of file diff --git a/system/config/security.yaml b/system/config/security.yaml new file mode 100644 index 0000000..43d3132 --- /dev/null +++ b/system/config/security.yaml @@ -0,0 +1,47 @@ +xss_whitelist: [admin.super] # Whitelist of user access that should 'skip' XSS checking +xss_enabled: + on_events: true + invalid_protocols: true + moz_binding: true + html_inline_styles: true + dangerous_tags: true +xss_invalid_protocols: + - javascript + - livescript + - vbscript + - mocha + - feed + - data +xss_dangerous_tags: + - applet + - meta + - xml + - blink + - link + - style + - script + - embed + - object + - iframe + - frame + - frameset + - ilayer + - layer + - bgsound + - title + - base +uploads_dangerous_extensions: + - php + - php2 + - php3 + - php4 + - php5 + - phar + - phtml + - html + - htm + - shtml + - shtm + - js + - exe +sanitize_svg: true diff --git a/system/config/site.yaml b/system/config/site.yaml new file mode 100644 index 0000000..f46cca4 --- /dev/null +++ b/system/config/site.yaml @@ -0,0 +1,35 @@ +title: Grav # Name of the site +default_lang: en # Default language for site (potentially used by theme) + +author: + name: John Appleseed # Default author name + email: 'john@example.com' # Default author email + +taxonomies: [category,tag] # Arbitrary list of taxonomy types + +metadata: + description: 'My Grav Site' # Site description + +summary: + enabled: true # enable or disable summary of page + format: short # long = summary delimiter will be ignored; short = use the first occurrence of delimiter or size + size: 300 # Maximum length of summary (characters) + delimiter: === # The summary delimiter + +redirects: +# '/redirect-test': '/' # Redirect test goes to home page +# '/old/(.*)': '/new/$1' # Would redirect /old/my-page to /new/my-page + +routes: +# '/something/else': '/blog/sample-3' # Alias for /blog/sample-3 +# '/new/(.*)': '/blog/$1' # Regex any /new/my-page URL to /blog/my-page Route + +blog: + route: '/blog' # Custom value added (accessible via site.blog.route) + +#menu: # Menu Example +# - text: Source +# icon: github +# url: https://github.com/getgrav/grav +# - icon: twitter +# url: http://twitter.com/getgrav diff --git a/system/config/system.yaml b/system/config/system.yaml new file mode 100644 index 0000000..984f467 --- /dev/null +++ b/system/config/system.yaml @@ -0,0 +1,235 @@ +absolute_urls: false # Absolute or relative URLs for `base_url` +timezone: '' # Valid values: http://php.net/manual/en/timezones.php +default_locale: # Default locale (defaults to system) +param_sep: ':' # Parameter separator, use ';' for Apache on windows +wrapped_site: false # For themes/plugins to know if Grav is wrapped by another platform +reverse_proxy_setup: false # Running in a reverse proxy scenario with different webserver ports than proxy +force_ssl: false # If enabled, Grav forces to be accessed via HTTPS (NOTE: Not an ideal solution) +force_lowercase_urls: true # If you want to support mixed cased URLs set this to false +custom_base_url: '' # Set the base_url manually, e.g. http://yoursite.com/yourpath +username_regex: '^[a-z0-9_-]{3,16}$' # Only lowercase chars, digits, dashes, underscores. 3 - 16 chars +pwd_regex: '(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}' # At least one number, one uppercase and lowercase letter, and be at least 8+ chars +intl_enabled: true # Special logic for PHP International Extension (mod_intl) +http_x_forwarded: # Configuration options for the various HTTP_X_FORWARD headers + protocol: true + host: false + port: true + ip: true + +languages: + supported: [] # List of languages supported. eg: [en, fr, de] + default_lang: # Default is the first supported language. Must be one of the supported languages + include_default_lang: true # Include the default lang prefix in all URLs + include_default_lang_file_extension: true # If true, include language code for the default language in file extension: default.en.md + translations: true # If false, translation keys are used instead of translated strings + translations_fallback: true # Fallback through supported translations if active lang doesn't exist + session_store_active: false # Store active language in session + http_accept_language: false # Attempt to set the language based on http_accept_language header in the browser + override_locale: false # Override the default or system locale with language specific one + content_fallback: {} # Custom language fallbacks. eg: {fr: ['fr', 'en']} + pages_fallback_only: false # DEPRECATED: Use `content_fallback` instead + debug: false # Debug language detection + +home: + alias: '/home' # Default path for home, ie / + hide_in_urls: false # Hide the home route in URLs + +pages: + type: regular # EXPERIMENTAL: Page type: regular or flex + dirs: ['page://'] # Advanced functionality, allows for multiple page paths + theme: quark # Default theme (defaults to "quark" theme) + order: + by: default # Order pages by "default", "alpha" or "date" + dir: asc # Default ordering direction, "asc" or "desc" + list: + count: 20 # Default item count per page + dateformat: + default: # The default date format Grav expects in the `date: ` field + short: 'jS M Y' # Short date format + long: 'F jS \a\t g:ia' # Long date format + publish_dates: true # automatically publish/unpublish based on dates + process: + markdown: true # Process Markdown + twig: false # Process Twig + twig_first: false # Process Twig before markdown when processing both on a page + never_cache_twig: false # Only cache content, never cache twig processed in content (incompatible with `twig_first: true`) + events: + page: true # Enable page level events + twig: true # Enable Twig level events + markdown: + extra: false # Enable support for Markdown Extra support (GFM by default) + auto_line_breaks: false # Enable automatic line breaks + auto_url_links: false # Enable automatic HTML links + escape_markup: false # Escape markup tags into entities + special_chars: # List of special characters to automatically convert to entities + '>': 'gt' + '<': 'lt' + valid_link_attributes: # Valid attributes to pass through via markdown links + - rel + - target + - id + - class + - classes + types: [html,htm,xml,txt,json,rss,atom] # list of valid page types + append_url_extension: '' # Append page's extension in Page urls (e.g. '.html' results in /path/page.html) + expires: 604800 # Page expires time in seconds (604800 seconds = 7 days) + cache_control: # Can be blank for no setting, or a valid `cache-control` text value + last_modified: false # Set the last modified date header based on file modification timestamp + etag: true # Set the etag header tag + vary_accept_encoding: false # Add `Vary: Accept-Encoding` header + redirect_default_code: 302 # Default code to use for redirects: 301|302|303 + redirect_trailing_slash: 1 # Always redirect trailing slash with redirect code 0|1|301|302 (0: no redirect, 1: use default code) + redirect_default_route: 0 # Always redirect to page's default route using code 0|1|301|302, also removes .htm and .html extensions + ignore_files: [.DS_Store] # Files to ignore in Pages + ignore_folders: [.git, .idea] # Folders to ignore in Pages + ignore_hidden: true # Ignore all Hidden files and folders + hide_empty_folders: false # If folder has no .md file, should it be hidden + url_taxonomy_filters: true # Enable auto-magic URL-based taxonomy filters for page collections + frontmatter: + process_twig: false # Should the frontmatter be processed to replace Twig variables? + ignore_fields: ['form','forms'] # Fields that might contain Twig variables and should not be processed + +cache: + enabled: true # Set to true to enable caching + check: + method: file # Method to check for updates in pages: file|folder|hash|none + driver: auto # One of: auto|file|apcu|memcache|wincache + prefix: 'g' # Cache prefix string (prevents cache conflicts) + purge_at: '0 4 * * *' # How often to purge old file cache (using new scheduler) + clear_at: '0 3 * * *' # How often to clear cache (using new scheduler) + clear_job_type: 'standard' # Type to clear when processing the scheduled clear job `standard`|`all` + clear_images_by_default: false # By default grav does not include processed images in cache clear, this can be enabled + cli_compatibility: false # Ensures only non-volatile drivers are used (file, redis, memcache, etc.) + lifetime: 604800 # Lifetime of cached data in seconds (0 = infinite) + purge_max_age_days: 30 # Maximum age of cache items in days before they are purged + gzip: false # GZip compress the page output + allow_webserver_gzip: false # If true, `content-encoding: identity` but connection isn't closed before `onShutDown()` event + redis: + socket: false # Path to redis unix socket (e.g. /var/run/redis/redis.sock), false = use server and port to connect + password: # Optional password + database: # Optional database ID + +twig: + cache: true # Set to true to enable Twig caching + debug: true # Enable Twig debug + auto_reload: true # Refresh cache on changes + autoescape: true # Autoescape Twig vars (DEPRECATED, always enabled in strict mode) + undefined_functions: true # Allow undefined functions + undefined_filters: true # Allow undefined filters + safe_functions: [] # List of PHP functions which are allowed to be used as Twig functions + safe_filters: [] # List of PHP functions which are allowed to be used as Twig filters + umask_fix: false # By default Twig creates cached files as 755, fix switches this to 775 + +assets: # Configuration for Assets Manager (JS, CSS) + css_pipeline: false # The CSS pipeline is the unification of multiple CSS resources into one file + css_pipeline_include_externals: true # Include external URLs in the pipeline by default + css_pipeline_before_excludes: true # Render the pipeline before any excluded files + css_minify: true # Minify the CSS during pipelining + css_minify_windows: false # Minify Override for Windows platforms. False by default due to ThreadStackSize + css_rewrite: true # Rewrite any CSS relative URLs during pipelining + js_pipeline: false # The JS pipeline is the unification of multiple JS resources into one file + js_pipeline_include_externals: true # Include external URLs in the pipeline by default + js_pipeline_before_excludes: true # Render the pipeline before any excluded files + js_module_pipeline: false # The JS Module pipeline is the unification of multiple JS Module resources into one file + js_module_pipeline_include_externals: true # Include external URLs in the pipeline by default + js_module_pipeline_before_excludes: true # Render the pipeline before any excluded files + js_minify: true # Minify the JS during pipelining + enable_asset_timestamp: false # Enable asset timestamps + enable_asset_sri: false # Enable asset SRI + collections: + jquery: system://assets/jquery/jquery-3.x.min.js + +errors: + display: 0 # Display either (1) Full backtrace | (0) Simple Error | (-1) System Error + log: true # Log errors to /logs folder + +log: + handler: file # Log handler. Currently supported: file | syslog + syslog: + facility: local6 # Syslog facilities output + tag: grav # Syslog tag. Default: "grav". + +debugger: + enabled: false # Enable Grav debugger and following settings + provider: clockwork # Debugger provider: debugbar | clockwork + censored: false # Censor potentially sensitive information (POST parameters, cookies, files, configuration and most array/object data in log messages) + shutdown: + close_connection: true # Close the connection before calling onShutdown(). false for debugging + +images: + adapter: gd # Image adapter to use: gd | imagick + default_image_quality: 85 # Default image quality to use when resampling images (85%) + cache_all: false # Cache all image by default + cache_perms: '0755' # MUST BE IN QUOTES!! Default cache folder perms. Usually '0755' or '0775' + debug: false # Show an overlay over images indicating the pixel depth of the image when working with retina for example + auto_fix_orientation: true # Automatically fix the image orientation based on the Exif data + seofriendly: false # SEO-friendly processed image names + cls: # Cumulative Layout Shift: See https://web.dev/optimize-cls/ + auto_sizes: false # Automatically add height/width to image + aspect_ratio: false # Reserve space with aspect ratio style + retina_scale: 1 # scale to adjust auto-sizes for better handling of HiDPI resolutions + defaults: + loading: auto # Let browser pick [auto|lazy|eager] + decoding: auto # Let browser pick [auto|sync|async] + fetchpriority: auto # Let browser pick [auto|high|low] + watermark: + image: 'system://images/watermark.png' # Path to a watermark image + position_y: 'center' # top|center|bottom + position_x: 'center' # left|center|right + scale: 33 # percentage of watermark scale + watermark_all: false # automatically watermark all images + +media: + enable_media_timestamp: false # Enable media timestamps + unsupported_inline_types: [] # Array of supported media types to try to display inline + allowed_fallback_types: [] # Array of allowed media types of files found if accessed via Page route + auto_metadata_exif: false # Automatically create metadata files from Exif data where possible + +session: + enabled: true # Enable Session support + initialize: true # Initialize session from Grav (if false, plugin needs to start the session) + timeout: 1800 # Timeout in seconds + name: grav-site # Name prefix of the session cookie. Use alphanumeric, dashes or underscores only. Do not use dots in the session name + uniqueness: path # Should sessions be `path` based or `security.salt` based + secure: false # Set session secure. If true, indicates that communication for this cookie must be over an encrypted transmission. Enable this only on sites that run exclusively on HTTPS + secure_https: true # Set session secure on HTTPS but not on HTTP. Has no effect if you have `session.secure: true`. Set to false if your site jumps between HTTP and HTTPS. + httponly: true # Set session HTTP only. If true, indicates that cookies should be used only over HTTP, and JavaScript modification is not allowed. + samesite: Lax # Set session SameSite. Possible values are Lax, Strict and None. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite + split: true # Sessions should be independent between site and plugins (such as admin) + domain: # Domain used by sessions. + path: # Path used by sessions. + +gpm: + releases: stable # Set to either 'stable' or 'testing' + official_gpm_only: true # By default GPM direct-install will only allow URLs via the official GPM proxy to ensure security + +http: + method: auto # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL + enable_proxy: true # Enable proxy server configuration + proxy_url: # Configure a manual proxy URL for GPM (eg 127.0.0.1:3128) + proxy_cert_path: # Local path to proxy certificate folder containing pem files + concurrent_connections: 5 # Concurrent HTTP connections when multiplexing + verify_peer: true # Enable/Disable SSL verification of peer certificates + verify_host: true # Enable/Disable SSL verification of host certificates + +accounts: + type: regular # EXPERIMENTAL: Account type: regular or flex + storage: file # EXPERIMENTAL: Flex storage type: file or folder + avatar: gravatar # Avatar generator [multiavatar|gravatar] + +flex: + cache: + index: + enabled: true # Set to true to enable Flex index caching. Is used to cache timestamps in files + lifetime: 60 # Lifetime of cached index in seconds (0 = infinite) + object: + enabled: true # Set to true to enable Flex object caching. Is used to cache object data + lifetime: 600 # Lifetime of cached objects in seconds (0 = infinite) + render: + enabled: true # Set to true to enable Flex render caching. Is used to cache rendered output + lifetime: 600 # Lifetime of cached HTML in seconds (0 = infinite) + +strict_mode: + yaml_compat: false # Set to true to enable YAML backwards compatibility + twig_compat: false # Set to true to enable deprecated Twig settings (autoescape: false) + blueprint_compat: false # Set to true to enable backward compatible strict support for blueprints diff --git a/system/defines.php b/system/defines.php new file mode 100644 index 0000000..7c1e890 --- /dev/null +++ b/system/defines.php @@ -0,0 +1,104 @@ +H`OJ@+rq56}IxXG~<)ZCfWHAt7UCYIIIQ z;uqFGv-Y>2PJwF;`IA<8f|j6!#OF9^+;#O&{Y&6ElamtJjoT+BB!0bc*5bUeNg)K| z;!nL8vjG_r5_J=}Uo|Wiiy3vugGK}=#gohA>kgD4#b!lNau+H@$OV{0a4~P55FsQ= zh;2)u%2C|B2qH-_DHe|}k<%-rWihmblBR`F@er2J$J%EIIv44gBdm%c!6KQ#!z_xS z(?yWmH`wz#>{|`vgm?}j(|Rz6GPrgO_o#qVyU`U){Jaf4UkW9(qh-VRn@;p|KX&CC z{IM5XM#-irqI?8DQw(MDuw{%qR|3sKBk$gLkpVh4J?5*`u})+JC*9~RSyylO}9)+4zCSk);0z8eewfs_p4eGA0fPLxrP z^e+%&OJw~7-Zo8q8^LeZzy*U?-#k$|jK6G0AGe^b(?s_ik<*U_)gtZ{@Q8@4pTv_o z(L41>*DUe86%DL`^Z3}Xh{)n$VH_mk1NEeYgp{rT|D^M{X*L7+P$qc^dk>9T1%Oie78)+Bz`F900HUYA}2 z_Wf3Q>^-Z&q*qU`g)6B1&|=a(X%7L-k~h;{i8uhoa!QX$Ym6xwy*P|WwnPRU@4+6c zq>@(0d4bj;vEb4jc@o@~*p#n^fMc+CQ$o?2miOKR;D*Aa8HGd`$PgJ6j0-L?CQ|a) z;L@>JPt01xz&kH|!S*HjQeBqOcIChmLZe1-Y5zPR6YF_YUkEmCiCLa9h`?SRtyP*X z?V|{WtP5lg+12mg8+0R|&Ie+qvYLVu&qS~y?{{qbtQ*?zUex%i52RnCpY3in2rl&a z(x48lPk!ab=I>JDpo5OyJ~8M+2Z4&9*w8$ql9$zUgtKM(%dOo)a80$NB^8~eTq1!`qNW>sntq_5&ngL=q8dU$ zY^o3(kXL0sU!8PwCM!&G?-3Kv+R!yEl7`T7w628>s|uLbrYOGLdTG!?0c>|%47HCr z$TZlq=s%IIEbB}-Id5VZY`K*7V!!mm5PUTvX@7ku~ z1&KNnaaeV1`CBDWK?z*ZW#Gdbg(KM56M2;lQ;Uy_t|(>c6*fX1im9EtxK1a*9C zr+f(O+VHr|uusxj=OD}Xnx=K%i{^&dx?u5NfY?v6Xg{4@S!N%p;|*%W8FcPFGNMZ5 z0o-o+$skEwG~LY>M)?JTk{24AoEKG>G7|5OBY-HX*2lXK5oT))>`PVdVw zvDPvt!}_=Tim_v&)dL^ zGglR>;=^)x7_!RmOPk0zwYvVt%4~FFWo3X@?aP@cCC@*yIC4faffJA4nXh!KslL}{ zB6VfDuYepSU*lU_qdxLuq%yrvJLOVLEIe7`>9dPRM^-sZlVj_{S>a!|@4hBWcBh=9 z7{#&&P`PafIckuD%nBef`tBqG%;-^!dgYA=2F7(VcEkYvHF|-n2PKQB8a`sGZspfq zKO%RyN%|!RZPc5+9H{_4lr1Y(4PoMaGOrp%^RY1z@D^=4ZBfCu}IE^1|nw`LxPRAas+f4Z#9?pE>sOYfMF5GMeKx)Fg zZD)^z&Y8ih5Q|;<%=p(~Z?#8MTABj?j9@T};@*AP_!?{23(sd6@qM=Q z54H+Ezi3b|{-(*<6CSti*$fa65I8=wu&E}YC2e^15B?3GmPWgBz%$duH!VcXz|=Ky zLj#4CqZ=?r6LNJy4V4V8Ut`))X75#*0PP{L{fLO?x*?&W@fsFR8v|0=07U)eW?kuXD&!PD?MX6KM03FSAU9i2i*(f#t z&?@UUh|gKAwelx8Ut(-a0m+8&J=UMrw(8gTGBnid_j2z0$R9M#2=IP9Ij*N@V0lc_T|LXYDtq&Ez_Q4+Y1?gPoBRp%Way_mqse8X8YCH@qvOKl$6D znt#-dIWOy;Rn`G>?vb>pJwa4aT}{^=ejLH5hv!N${}jfd2Ua7sZCi}gUyYzUt(`>a z9$`&Dq76IqA+37?5U+PeQcv5m-cde(%Fns2IY4hM{#sjZps&A}QkI~6NS{eN>^G{? zrr^#QUXJ=7S|T*9 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-3ds.png b/system/images/media/thumb-3ds.png new file mode 100644 index 0000000000000000000000000000000000000000..3161c699d0087f20f5598e790fddede47b5d7cc7 GIT binary patch literal 3116 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 Jxd9xM_&*EafxrL& literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-aac.png b/system/images/media/thumb-aac.png new file mode 100644 index 0000000000000000000000000000000000000000..62b035c68a6486397aa20b9f93ec70c0405a0004 GIT binary patch literal 3218 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 Zo|rJcCd*~<7XDww*x`zn*{&> literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-ai.png b/system/images/media/thumb-ai.png new file mode 100644 index 0000000000000000000000000000000000000000..21a647b7ecfb4f555fcf1a6808714bfe7409c359 GIT binary patch literal 1595 zcmbtT`8(8k9G_a+OwvCdiGD)_w&4;*YP~BpWd%`pqZ)Rdiqv6jYeB<#L|aov{k$x zyY{CQr@*d`TmdO(rU{cq%L-kG+exqJRi2RH30g+0%J>TZWM*t(07sujGX3CaV+uo% zqNhlnKO6^96on6eK7!z{gI|&vk0;f=z!ZA4Z3`LP>MKnc{SZ5KOcpjv2;P`V` zM*%+6gyeF`#VM>kkC5~d;{*6WJxIL_$C#p3@#OLxl6{vV3HXHtlJkK0K7!=^gSF*? zL^d(j567P+=O@7{8>}@8$+$(~C?V>^zqBHSPl(B3v^pN}9nrcZ5?%mrT*>8GIKc|< ztp;fxcvl%d)J!hUprv8>XCW~qf@96_-YUGW25WgwObn58HsXD?;GHKS z>4OvgM)Dt#Gh*=W23i@5mOjHiW+U%?@%}m>xP}VDk?i{**@0Y|MJr;^+9a&A5UqU$ zURoph0a$A$QWyx{UdNi#2yqYIQ;8G@BgG+vxErK7hZF>&wJ*uVX}r6foc@N^Bw{TY;FT?sbr?43M*9AjJtEXdstnu!d9; zp2tVpK$0DJeFbeu1?e{d{}Ngrg_cL6^?dk+Iq|ifoEas)ea6~ykoP_y*&ZZaY(~V2 zE4P<6(q~$D_e~8*EEO!3D(oC9T{3B#Dju!2pE;7R#QyYVTnG>O=?Y;RvUiughj+lX zi+bj!tv(6vev;`Z_?BGrhgMg%@=9&~#~cs7d1K8j;~auDaMyp8qHA-K!i{5xmq;HD zc1hJL6EnYhrG^{fx<}bj$nf$_(N*5HZ6p6=zuFRO1070SM#3MeY~9HJLm86ylM=MN zbz+KTps4C#CuXs(3>1DxAoy8I(AXqqF4LjV5-6lzT2LEQ$~Y}0__N#DhdDjDiND63 ze<4%`dYFtbL+FsuWKdqM#J8o!@(?C17$CKKbtKizx+@gb?)6L_8Qssdm_m*3lm2!i zyO|m{eU!e3y15+-@ZZd}NRvDX^t4`ZX7|H(9reXhf=@bj8RevKpOO}F^fy*YnXISv zVV|yUI+Oxe8G1X+Cg$~5y;0VsL$5woTSeJih*O@4@w4R7h4OWMd&`u{+pN`DUN*Ak zN7{7Ciq+(>myG8{8pcagr_(I8);23k3!0ia^A1#o?Uu->f_2cdl4>&vRWP*W^`JV- z;O6Ls-Q(xC$=%jq?Vnyhe24#_^kZb59aCdYKYF}T#?U5Iw0$#w(~f|cGVKu-Hh=wc z4&$u%v8TCOhgFrdn&1R^{_59NtbnSlVNt33FZ|msqTOkC=Ei!#4o(Rpg{|W5^>M&< zo~;7bFi*jb(PPse+8-R4Jayj*MEK4I78{VcJCKF>ZlHBwWauOG+W;1SOl)$8#MU7S#4 zp331UGCR__5h;^Bu3T9eC{p6wxpPTw9Tb+5Ll0|R3x$yJb2jZoJ_#Y_!x}8T?LpN8 zi6vQTL#(LjjeG?^k^hhza!924bUc@Fso^%9>k^c%Tbur*iw<$U4I6>Ex!y^+Z&RE3 z+B@ftw%2{Jh&%S!^WHh$S1-Qm8I#e*+io3jAj7{uKK97|=eJDn?U^oPu-fx$ZLeCj z=`1bFb7q~T)%$cgVP{No?)>hVe^ixKXzmgM9KNF$rYaEAZE zy`sdTQb$vDg5c!u=Wv9;G?D%oX0 r?&Co8bt0tjfNSpmc)S0il*_$UoA*Bd)ge>;_z%a(z*Ilu#6|AE*$-L` literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-aif.png b/system/images/media/thumb-aif.png new file mode 100644 index 0000000000000000000000000000000000000000..c63b446d620981dd1b535ea727eeff57f69225a5 GIT binary patch literal 1691 zcmbtT`9IqS0L|8Q>!Q^iw5WE=W-Harr!DK$N`3JWL`&xAp_JZ^wJX|a>DE?e)sB>z zwJ0TIq^=NDq}En$5`rLyBaK8tB8S|E>Gmh=eLnB=KJR@#@3;5vMjZ~_1a<*~K%h-w z&|o+S^cLz3H>_Jrx|7MaHF@WJU}PW&)Lv|7NnXF^-^zrC0-zSAs}ux!`<*CQOvtwb zl|PO5R!!5)R;$5kJ~7yIn?qP#UA-~ZGrJ-WBv((U7A}u;SZvmEKG|fo_zhI~4pdrg zHoe7Uv|1hsC~~7#WzvUJ>l)^m(X{$8rP#J=gVP#1=EtIF^-pFP{-no293(xi5Kxnbu+y3) zfoAF2XcuyV2&2}uzGf#6J%v(g&$C+FM5DtB5mWK{`dIgIM&pXvP|hc1v+>lWS%PFT zf?78u7ur@=%{J?$5!_j3OBs(;JHuEu>Ue6|y~%!wPK6ll9*|8xU*KxZ#uc+MZ~U3Y zZ0Hhm81lJx(I{e=*=r8xo!*^2{+0$G7wU5F&*N`}#wI|6n`AbD;Z|mi@4I-;L|c>^z=6Pt3@Zq+dOM4%7Jl+Rpw5 zK26%TGo%As+&v)mN!HAwO}n?OLPJX~9MP&Wg)8}OzP^pDXTnb|hV4Q##JkWB?2Dk% zb4YLY;}1PDqM_^Xl`aq9l60gu6Mr`4KrM=FY=Xg9yU@x0m9bxA_B*iM_tC{!1i)pU zgY!MPClvBqI~IsyII^8C#cxQ(0xl^b@yMFv=Di*wqRPjW9jMGD1qg1zz|K2`CVq4~ z3WD_h8)^3JCj``?ga7Q^^I@e z+|;V~K%d%1i0JV1>6YpZvFIcSiR+B>GfvQ>j^@NbNC&py%yk}lGW`!ohtwd;AWx;& zmpDoEU=OzV13}KKw5G}UWvmeu_)n^J#FY) zqUrKcVmJgj#fx*{+vJ#XFL2I|o>TXc8zURj8$JpriITqS;7>SnD8=ol)Z=AQso5Ja zk{Rv8l!!k;NLe-B{ac{>q;(ITd7_i3xT?(nF2=D45w|=H^gy-WS zk6K=oG}rIxA~?YFSwY@*;oZ#T!za7oP@CyEHaCy(<&sb9_fXrrP1fq8J2DCJ5Tp5k zo8|;R{qD7WEpbqVe<2MPnL&w=)1K^cU~?0Wm4C)W?9SIkT)&Y57l+?1y=Qz#_&on= zN-8~buJ81ftml9_JFF_e8Ib@n_SQ3jtDbK0_xu)jqcd6o^-3hXbq7);MF^0&H7M7w z!e#p30d?jT2V_DsiofysH-$h^m#3RoMCphV2G!T+-vlH^Ll1YcTw);^L@>WU@%oBRL4Nc(}l^zm^9ljW5&{0FpYC0 zY^WN$zLD2bss+0Y5Du|D3)5^1)ivZW863+cl1VigE}+ zeII$o=$w|)DGYG!aNmK;K&Z$)PWxsY_nYVK|Hn=Ltt$IK^$Td?$MRP4_VwPhfw`^*uSSq?m zB8=SP)W$aCIvX>DG55>FVw=y`d7kgTaNf`Jc|OnQ{ln{d|L}gEPojgJwc0wJbqEAP z%?54Zh(IWi|CzOltIkL7`jyqCt)``tGB`pN9z-af@Zxj1mY(R2U}-L zpkrWJ40RV_rVE`8NPjh6*(|GK{5#|GLZd%rCDH{ z2l+A#lmhQDOf&=YD#)eKa19zlVYUYvZ-8V1SpFbg0CornUPJdY=*$5-#~xt$y&ldZ7TBR$(# z$--I5XlGKA%S4%fFP`H%QT875(RHd1{}FujX|sf^?co*Fh|-8-GySR+$HiEaXVfbS ztKIwebljIBLYMZHn2K(BIQ5E_mh6t;lcm&$%U|}KS!+yB$3?cR+<(fsGshFD;wqz# zc{H2VFKD@x`mbMk*Zr445w-#I`<@Lt!ib0Y@wdpxfB>51p^R2~LmLzwp;I z99f*hA2eXzJA6mb?pnRq2^EOv3lGFH-dt_;H_ow+dRJ4@omPY_>rF!&u|^a5#Mfa@ zuBVpvd2h38xTt}zLjme%JgsiB-GbdY_G46dbBW`7mj5O@CfRlRTM0U72ho z&b7&pJj~ykPRR^)`EhS(2lYuCa#1B3Rnw{YL2Tj_xtSC$APyy)=xncL+LP5HNw`bF znSr6*cS?+PEc7V%m9eeI2sAELdhW_^9<%2fP)YS_X4N-(rt5Xy48K2hgq3}Cp+vDq zt+Mdk0Iol0PbV^mrd_#Z^VP;Gia)tsw%rKZhNIs!Dmf-c+6NjqXg+d#iXoqg8{g7i z%YAOZjp8BC#1PNTg7Ks2uRThnPF)^0DFtQ=Z%ZyIHgXI>C^ zpzv;~TOiRZ%U*Ua_%RiM@*fWR67E~2a@N|8pIoIj>CLW+Ry*nRDB-QCqEf~0e)ygR zE_>J1AxvMU%3`ioi@vxgeh29RxHCI86uWh^41-Z(z2LyI^h_<`hpwL{R-T;S_p#+g zSS;1`SpcnE`MR^OC%K=Y@{7UjF6(QzDX!#20;#m;PF)RA)r*(uQxbL2tU91_x5TJ} zpqyALDc~Q(qF&U*=7aEe zWj^Md6)|Np14G{Er?BJkUalPN!yxToPZb+(LX8;7*05U+?qUTMO*htUbT?42yNqP{ zTtAN9pKw9L^o_MkR?p5dBjG(>_Q*HXB4$jfh?KeMCF6?hvq`eh*M0K=be#C-haMX8 zPYI4rk&JcK@s;81wv{*pL!{?O}_Z9LYxVUNKhT;}GW}ZfD?zYLvm;KizwPk<4 zN9ud4yoHA6F_>QL!Utwp{3;KjF&2Z-dKl9B%hX6Eb0Rp(&#@x<#%?!y{ZQmI4i&Y2u5-1BsC z((zhe_4**W+Phie;YnKdVTXx4l1sjG{Hu@+2NRYZZ(6MJe@}TgD))RP?h@WEM{zz) zbV!xcbaNMS&VQEo-FW6|RdsKJDY_?aRGpJCBpxX*DQ$2|l^Rd9&B!og}-$h)g|S$b4{*8Qc5|E}<}>W1bvu8Zz>Pj2ub z1X)L}2Hb6DDV`~Fv(o#1SD!Ip=9F{5;dF&r=^sl*98ph&_nNnz;TXj>?cB~8Y09hW zR&11a0*XWTT*VpIpAtF#uuYb+=G`)fLI?G0qtPWYnR=FqD8)rBV?q4uzxUJrPnMa= Y)h^B17HsR_YyV@}SlU?>oVZB*A6*SvQ~&?~ literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-app.png b/system/images/media/thumb-app.png new file mode 100644 index 0000000000000000000000000000000000000000..2d339250ab59551d4c496b622f3b840ad3c500c3 GIT binary patch literal 2360 zcmbtV`#01HAD-RUtk7(mEq0~J+3M}>m1fgcgtcQ;TDQ`L5Vg5fy0F^FZ8GDs(?t`d z#EdW!4HJbC)5Ii0WelQW!Z7ZaVP^Q=zw@^5zp$Tkp65BwbI$YIbIyl(z-u4L*ut1V zAdvREx%v3*Sy0jMGtkygRY4fjFSSCKVtr8k}<>(Sm< z@T(kbY7iE4aP2HMEW<{75XmD{DTQ8Sz)ftZG6|J8p+oQW2#83ZK~EFldKTPp8!S47 zbUeqD9gu*D%?`uO1yB_YEczXKaT9!e4ys9qYX5>?-hud6u<<^)DGwIi!=?wJnlz9b z3JI>GaxwCu9>_fimS4u_M!|w0J+uT1vyj#jq^%6%$Dtoz;q&8QaTr(-jD8S?mU)W^ zgs+PCyHXFw_RT7Q{8gi0yi%@YMBg9TJ)04s8{iuI6f;&)4^j(HFm0C1;=50ulj#QH z)ua5kD}S~asAGR(v*qh-wT_}n;H4sbfTHla0u(O$JkNh{4}VNto7}Qy`C#9{UYnRL z7Ud4K00tCozUb>Hsj|DY4^wsx@7=v(OMld?UmxndgQG7QZ!!G(XiW>QiVMuzB?*=C zkfYbBTLV5hR6n3*d;2qGE_4q;A}zz}+px8zCvJV~u&G?!T}XeuAh~|!Gxz8716lGc zU!`9Zb$C)eW^Br*3C>c@Jc3ekHeEL{jlnkhvIN?Kvx|NokDXdsLHW_mQoqxeWrxQX z(-nRZMm4J}HgMP!w}x65rxzl>Xd~E|WGkcuvvO9)g{vP64Z4g;rq05jd|9!0S)0KX z-jcDx!T&UC$g?!nYJq)ZvNATV&3Gnaw`8bVuT?|{OKQ(OUg))%-cQnWuhtq~Ud%uX zdr&bN7p;lY@MhTe9#9M-^M?J?E<>GJ8_Rb8X*kfL$Jie>wV;Vy7}$XFzM% z9L`dwiOoVtRhyzVy)>p~5YrV=+rW_roCIp2y|VgvkCn_x$-GF6%ggyb&}2!cShJ-O zvuEUeopxSgsT!SMawpOi=9UW;|AAxXo37{ri`@T`B@UtxNsrAp~rjF zL^FB^xEjXLeI*XNbWgaC7HcD1?2IrJSNrE$OL%vlal;rl48<3$F&gV`wU<_W=zY-@ z`n%kf%N=reNJx$LClaD38426%1`gc|UcPSP=Z>uPlG4bJ8XlEwlz!ZIiL6bd=XYqg ziu%kenkWG){uoPsK0fuHc+AmqS9IDza?Drh@$kq!eFvBydWiZW*h|4vWZSp_*Oq%j z?#jubNHq?QCeL?I$jumAl76VcMf=J;?+F z@GpIHQ}e=Nts#l=Q|OgD7TnFn&Mk0Mq#&37v8%GwdbG}mWA!U#%*1vC2wh>j5o-ZY z#3p|_?#hzxf#~Nb@wYscM|6<2=yIaKY%S5?`^k3mkbT~AhT|$rb)+zWEQA<5=+qr+ z$<&@=(Ro{EyP=CI!3hqI>w_|PNu160*3sVu2f*a<=^z7a@p`$}bLHVzoE66#e#{bm`XwLH%8;Tz-9g(Co#*Ko-e5 zY|#IvrL2hky?1m*H_6s7>_Ic&EVJFTjTZ7d{)Rs{$^7p~Y|r%7Kjjxi zypKfqlXb*)( zmr*v7b7#!CkxXedr_4F-XxmaC^;#z_Kyb3AaeXHx)ua|-SrOxR1u{32(l*r`r)dhv z)N2|qVT;#}<^R6iVC2K2rLGz3p`7_i`SNwK#F@DH@UWB8Pi%C(>3r|PRjbtB6l{Na z?A~EOCpG7~F$X&?55?RpnekMlI8)q`YdlE_#mvppv_p?@04Q)xR9??<{^jM)JlfVT i|G_W#e|1RzmPT+YviJU!m{CxSr)4ncX`fhVF6q)9tOJSoyUA}x_B#ekI1i&8?BUW1g- zLB!CJCLk?vR6|Mc4W2jeH{8zb?CgHnPdl@)$A20M3S1EY004qUkMy1Z0H-qkA>Zkr zNr&_B@lWCP)G^Zm0GiX!ah%Wm^rs+C3?BjzQ&;x^03OI=)2I4aYb)03Dz&x-=U`7t zOCx1xov_&_CnucUT~bO4_2WlkZ0yPLF%kOagv&kQaIm&El=3pHz1=a3g|)F}|NS?Y zM8ZC|<>K+jG#aI{0_W_6b8|grGLIPyYJDBf+v}K0<^1@;!C>g0KXZ0=xC8>u!yWJQ zg7y77Avg%<>Vo%u$vrwEM7$=(#;`XwxI_YDe4MktPl%3Ue)~rGP|C&O7$_8Tc9xWx z$t9CHd%O4m|6@9xl$t`$$ze`SVeOt%5eQ06^)ZD)@9V=kIWp(w2qD3Y;URKvE)9t! z7ZnoYD0PfQgSjWJ&iR#Pki%+wX%Y9ccV5T=si7* z(NR1E%*A4Hjt=-2-Z*b>O4$cOSSY!;h!7RY7#m}6ZL&8vX{{}^)>h`^Bx7WR5E?>^ zdrNC;!~6Nt2R;#DiPWYhLS!U+eVw(m#3d4Got-!@PfBSCIX{oy+e@jgBIo6C_V(~# zFtwop9~gjt=|iolIcBq|jg5pi(PTKBQdY*Co~92Buof3t3k#I0O3u*{rK*ZtSV(%8 z&HVZm{|bWh^q{pPnbT9m#028oSb9%4eQ=Q8-%o3A$NT#c6XJ<57;|!h*4D~eULwZF z6M}<@F;F}>kTEvOIXob|4rk8IlHb2$Z*7qa3rHCmI2UKUpC4mrh@78KYiXer7t^}B z7+(hIeZADWIznUwDJzq6c*sVtQA&!5Nib4cDk(XM*4e?FnW1%b(EIzS_4Ta91wvRD zdwZMO*gz>Qr8PIRR+eR(uVw-OXM>IObe@K;V9~qL^VVX+#C;yYxH+N9r;5Icmu`yv zdh3~8l32)3*f<4>hdg&fqtTHk74}Pi=0hO<;NJ;|Pk2pvx|^!Os2xTjLuE|#|3@8=9%TA=|0E-JBxe~)39&nc0SI{ zthdK)?mf77FT6f}U0n?MD#m-2!lO~s25il@M=w5XGLS#{Edk^*H@py=eaD@9mW>26 z+GKby#!exm3!7`;b5mvcNG|`4kMAO^gtIt*ROpH}b}vV2LV2l~Rs0Zc``+|wJ7nM$ z4?iZQioYo0g~1a2ccwIyw?)I~dz|q!ydQSzO83VUP>fe4cF?yeixdUD*If*XshJCw z(H<&yt45UB>l?IusEp-9t>4v^q~)791PM-s1`aJGn9zDP0zyLQ-BY58eY5d*3Xv+c z-C}9BaIFt3$iKP`f$aysNUUVI6*?q;C{F1x}#eGkeV@OB)vt7f?OWt=~XS^P!9n1~H4Mjoj=XEZxlcP}PGoT)5_F)A}QxW>S%t zS;=E4gD&A~1}hiaA0v0y^P$F#<=%u7@%bxom*r`-FLm3fn(I8x7CGgGooL_>@5?^* z??f#nDTfX-^0wwY&4J_UXMZ)%y4Jo_qgg+})2{@TNWENS#8+$i2`TC%M#whrbclP_&GSF!` z&p-FNKZAzC{EeXv;@2Xrw-4=6hjA+_GO>-Ur z(@m%~%PxYnQFdn;9L`PazfZNzvN!LcD2Pbzdv9f@r`kTf6#|c!Se$8{LDYJ0^PvWL ziJu`Y<>Gxx1*CTGOS)0FS`J?bLK%m$T8vnX{<6k1&(h;;l9fVzzmTkmb{MH{y5Hr) zTAcCvHnPk{+tw{Bq68E%s#qTd#=puq8doa`dM||D3@Ke|(uVTpn?FIQ7>$XUoDdxC z$7+MTi;H~CfyLqD(|r~;%H~K-d#upQs?)${ba$jtSngJfa1&hLQ< zpvkFU&hJzYIDUp8dalY}Oq|WEcmqW_3UZ_?kq3<>ye?|r`47BYv9M=@F53leO7z?E z)fW?ubp8fOJHj=e-Olri%1s@oe=G!@_q+GmEJ;lGRZ1)BW?0okml5!nK36`s=!(wz zh{%Ew_EE0xbJ4{0vPlV^>8<+^Xd829}RnvAMIW= zPykD9U7>z&aB0$9(0?$QE(6T)8b9Nwy>x8}o%HhUn(!xO_^OVd7OVUA1zQuKEq+4D zIOz#CUA8+x(m@-syccp*sO~ynu40US-!2PTn;uk_6={S6e()r}Zv^es%@BmX6kBg0 zR?dHEb*+BOr)Q#7jmqEE2(7vrp+RER1nY#r6%zRL4wLTl)sm#SFw*%?!wLz)=kg4X zm3YhOtw%%h_*tp6u}zy)6Yyy%`@M;RF#ZaQIbLB?e!+k_RZFJxjSPbg zrD-REr1p@i)LPta8-K&Oc|(Bcqw=$9L)aG|!u93hvC_*JXs^!^MHJ1a%_7)bZnQJpS1ya{Ve&5wdG~p_VE1F-n zuU0lMaptI`7Y0A@!&n&uGb0VuKY#MNtDHRf_0h0YTadtDzByBFtRq1lB!79yXm^~m*3%UZ)Plo>{DBkl9GO5ckkidjBoF^ zwzlSHrz+r?HD&qr6|k(tu*>UzU>O;4p_%a?x;vUO<3b_vz`XCD zB@&5PEEaAE;8}6-?D*xy`Npb}eDIf{KK#VU(B$Y(%201k(xsG@W>Ykfn& zrwsNC_IBo`go{MN#rfHW%Hp4-u5x(h!rU~U$8B$_T4$`xOpR@BigQyUst~!u{aw^? zGO{prpu1i2i;6^b6|jn8$oksqIAw5|PRmUW?`+0E5`e4Abg@_@5bzfl=6GB-23auF zM_5@}$WHpy(}~-XNcy|mIcz2(FS(`+%3-rAe`KRevY}~T(Ir_;HHhlc{CYIJ6IU}j zOe#o^8Xh42$V*;ZT_O*37iGo}+ZtHRwGLd(rdW)vDIMtP82Q=PT#Ia}L(NT3h=f8I z`A`)&;$0;OYYe9N+AEBiRk<)>#o}C)YP7JFq z%^Mr;uPZOA{E_pML@3TqU@R}rOpO+#eeG(kXEWETN^==2OJn4LF>*h1eKjW;R9jv+ zJvmaC5skx?_2QeMsgV-NCZV++S(wIHS;U|U+8Qe{Na)PuD4jM_lAB1MpIKR)FV0Dr z92=e*CpT3iNW`{sSO$;7ZmF-BpQ9p+(w6A7qRiOVh6*l+U6>Km)z+}Kx-1lK&}Jt1 zJPxiF`6D-}7FAf1lZbDrC3Uq9_Y)W9sBMkt<~kIiwT?PL=_53k6{L<+2AIDhTbOHY zK=%{d@XeT!p!o=v|yD`S3And29D&F^Ehwj4a$cj(IjtK9X z9T6?hE#;cNOx<~-sU)W)r1)6*(Ri+0K)z=s2gE;eZtrzX?{R5%Fa%iB0&+DU>8ZfTZaLPd)}#h6{S`iGCcJBvPR zfAiS+Se2;T14RJS<(F?S=XL8d27=p#_3XNSNVm@hC{_O0a*rrZy zL2o-_bp@fledpYF%~EPp@NN$jt~?I5xX_p+@)Nc;En{=!oStd2>)YXuwb+49&!lNl;9@6*V@-^sLs zU}SdTjb*&WC6S0E+QI9;6D&9azEz~Q4&iTL!0X6;-{U^U9Ezed?wcjOJStCfSpZC3 z33ze>Vm)|eEdSetsY1JBX;$0~-EjL;3#q0j+d%=}up}VbUcya_XNUHZqE({==FK5S zd=X-!AEVlF+iWarSCuON!xJ*+9u43yp`&=*N0wc+(Lc{V&T>ZNr|JR`w}}q$M`^8b8YeaVRWNaBU)}?7 zE5b0PoH+F;KP8oEfco9pSGd|TuLcu^TO`c2Vr5kH2C@c3$nAI&q>*UJipKej>jpNA zRGn;m!yqJ=;?9&Yf6NVtmt(oUl(PFX!$)D{;Y!b`e_c82Qu^tWG7maqYreG zZz%tPprz;VgIzH7GaS*{v!jFu=*i(#MUU78)J(0Do&b%c-j{$S(9B>&{hx`gqtMZy z-Pj7#Xxk6=!Qe|)6di-HS`(U^q`lY>sF6dz^B3d4qJe;whvYYghmv?su|qJe4J( zKK5Jnait6HJ8x{J&~FOMhi5;_58r{x+P~%7`Oh0ndh^hwB=^d6F;f{5e^GDW3+rm} zDmj)mA1UiL-@Cdk^F-qobEpKji9U225^of-jyb;o3*P|kBW(d{ViX%s&4V`;Y3Hua zY5x;E{uigfV*&v42vhH-8T2TZpoY8u`3@22PCu`(zKLs8)k!=i_6*bT%PYR$NY?0P zVe%vofn*QgR-RWWz)~%J&(ay&Hyw{8Mt*huN2YQ5fqbZP1vd>AVJ1o6>T@CY0HVn~!Z z1gD-(Q`ld93x@1&no_L~x?!nt@QmRZLRYOky#73lfc|jUGwRi>%!%+fTIGr!?nOs@ z-w9)(z&P9?UqN@S1=Wmx5cf>uPQvT+9H)xw%h4}rgH@Mdi~_O}p~q*C0;Ft*j{g7; z8GhwPtD18g^!8A#($}Eq><7X4#N=X7!2Z5a$M^7M?~iG8FmN*kaL?5A*uD4Yf=WZT z_5Ih9wQTJStTj&Onkdrz%;<5Q_uas{LxJglec0aT8{!!uqp9Hu^<;S;Q%hQ!CB55p zbyYV@>4O^6)NkKXu)kmYZjOJ)^sWOUg>f-7S@KO;Xz;}p!_D@bhDmVs`U#jS zbun<1eM}a5Rl5MN=@{HVwL|IzsZxtEuWFM4+z7Pa(Zy1=lo_;-<9Kktm%(KA1^o#x z8;x)_xUzS|?n&#S4z5As8Mw4){aT=R#QN&f-=h9}=AieyNpuQ$7hh~ub|5hGY+(Pvv;V@S=*-TK5Ft-Y9C#KChf}NJ?0Po7L|&I(jcE;JBjWG$8){W#)5zm z*MwEC$G-GZ(Q34)YzYOQ;Mi!CP;xdZXEED}R zQJLQ{f3My1OEE4*6FsBb&HEZgsa0?zrKqKJrLA^}g=JueRu_EzahCHiNg&s50wh8@ zpzI=0$4m7?YWQzfI$nGW4aRr6Mm&1ap0Rtv%ugJEz=Ygf@2nPm9aJ*MUvsc+SwO~~ zKwFWHELlIz@v_|B)<$xL)g4u&UcWR2^?Ik_%4!%d(U~!bMF0cpAYx>a5EeLmWTE$?iUggI%I8Sepg6HIQ#GH z+w(_heFPQ!S)zWX_NGEYjadh_ABp|p!eMtU&4lWPk1q-diNrbB-Luf^ZLc8WcLW0c z-u7JV1Zri;tiNM-clRY46+1SxvAu23_gk>L`y4e8G7KMMtnhbs4F4(uqrTr*7V}nr zN8aC1L?}ZjLve|i|cEfJKNg=fi0w~gn)gB0Gsr8YzcOjH`eLv zYp>8FtrXJQ_7;o3c^lkGV6$RI2Pr(RFA~b#;=2#@g$@r)tuoQfmC02Go6ld}+_V69 z;#jMgRR&{oV{2#U=^(Uh1{a1xaQJ*OkLxke-$t2d^EZJg1OVFILMGWky4Sb2{D)wm z=_%vB--FBa7o&s!8Amz8dOZjFp>$dUiD=&6*+nH=gS#xjT|>)D!NYKG1o$4T7raQN zuJL|RNMB~~-Bd~!l`_xej4+npO`#LUhKr`B;7g0Ob2DBD@XE$|;1Jvm-q%N4csn`1 zzPs4Om$pzogU7R0XF2T0NXT7SPY!m%9@2dW+VcsANg5xnBN3C)sNW0Z zapp?(9N{woi(IDvn4j%iTH9arzBrtY&T|d;~Ga7|X@e*z75E`>Fgncv+Vcbn8D%V&5^zC7@K5;3 z#`#%1dv$`zNSzpICeI<37OUn6DH9`cV?!UX6As{VJ0YP14c6wS_o8vjg!NcCh++@x z7H~kqx`%6~|4K$gLNqx?=v26<8Bc&hDzitnmhEPDO=M1Nk;r576? z@@1_-A0wT(``)+0&?n;is;V%#DSFU@VeR)P5`mr2oaR=$?J5e`-5l4*06%%2>i#*m zA|dQXi2uGN!tVx;DkBuL&(WTl*DV;b3&j*-H7RoQm2$6h+B6uVWUZKd;s#P@rB~LY zQBwbKph<`VkXvy6oH4>Y>)>?|9v)?ZkVy-uab#;+ZVu#bLOMqgTyG`PKKK>I%#4o8 zP0fK*{n*^}aKG{C3>atTJ?(_yWSwhV4((GYX+Qi*6xKqU({ymNL%%YyDI->=;`c2b zcwDHSft>dEaDu=lJU&iX;@hA@7%E$W9g$T90=}cyOG3{4zER zPOM{rRp&k``~c;wL-`cMk%)xGX{@8IiDG-WzH?LM(j`c{Vt4^?uQ+7^`SCWwBL*N= z4o(y=>o*^LrFZ#=I$doo*E3ks$~FHckCeca?zwn&AW*ZnCjHuxQB!(Yi4zN6ttCeG zbaE~K{7o?{OWRWk3yjq?F~OnepVV28c|iy=_N&rvUl%ZNwzIo!CPd3eq3RsI&ejR% zYJc6p&BUx!!ViK?@8-m?QGTW{yX0e9G3Q~mNrRf=K82@{*R-(?!DaJh`LfqfmNu4Y zcexNc{9bj{pN82%|Bbk$Omx1;Gpl2lU;j5~{jQ3@KY`v{)A3nYqNw%ff$Oc2CB3Z) zUZ)a_m@WopC^bK#?B_Wn!{TcnO#8{baFWKF1u@+A@Gwfg_$1ex>^RP$uueembvG5x%lF#69g97QAChXE$Ng{bGe$H^CK)cd z8-R7!YL(aD(~69kvT>fl$WZC*^)EWg0l_Tzod^x(upG+`Zt@yY*{RN-W7R$1A8*@o zPXA;fI=o!~=-smr?Zb^^>~$aZhKAgS= zW_-&Ff4)F!nKB|SrM6DQyiC^!rcW3KO_}zV$|k1hX=@3R%022-359W~u`DZR@r4%_ zYjAXgZlVt32~FyLmkkF=kKV)1rr1=yVZOc{+KEfW%C)VSHvfpi-i;@EQ@$A>kAjb8 zW5hmQ!iGnMADBS?vczJ_yr$*x3164JNMCxjuhb>^Ov$Sm)S~9yE3~inuYnvUSL398 zz0YI?E6LQ_sbrlw8PSqgQdM}!bb8js1Z`INAZX=mw?ageUa$qzO@(za6nZ?{8Dj*A zbZU7N;_ObJ#y5dsIwv+^v|p8i23kqu9Qhy4Ye@)ifjVydm;f0=x595@SlF? z*A+xG7nLXI^?}{`PB*YH+Y=Q-js?i!d+wa%HBo?Aw*(QT*=a$E0vJ}EA?>@;=I-z8 zCw|C^Ive+oMV{4*uPwSWqrNXRxZrurzwp}Cg zb#?*@Q18rR33%QKEQD^4bd3xE@-77*k;$flH=U=IOH1f8$zy^>@EH(rF*34otuy8` zv5GZsz8B2t1%;gGpNcQ4{wX--WYXsu;zzvT0_qMj7?IbW!-(z$J3Tsa4V9%DTX7ci zpG<((;re8q@MV*DscRtL0!FcBPs8d-Vc{4nS$=xDlwnO+&6teyu-}4ZkI;@8sD!d~ zDwHOk{nA1*G z_vL&jwprR#&4hhvv31PAMOzg-S5>FrD*`ZuXMw7L<^Yn&=}R@mdFbASxI8tD2n*|t z2-~sz$6q_X1mG&Rrb#rpdMQF|Aeh|TQdyuKewf(-Q_Al9XGvB(>p7Lul0Uy^Q0#S! zfSyTI(P{?Y(22WhzKbJ%N52yDc~{er8vFjivu|TE_TTzVzuMnj08aw}8QbA@AmRHL z#bY{$aEd7R6~_U7^3RQI=hDtQ#sdu>sm-J$VaKjcId7Bw_9x1%RAQd0C)y21r!&*g z7Mn8Z(OTWS;nYJrB9Qr#(!XbxGxDe#F^seyA2>xknEe4ME*;_=k&xjJ$Yz2usWL~i zQ<^lltqX$+{7Nl=2@|Eg*H_BVd%b#l{9OJGM5t~PInh@7jtKzSc~n8>g_838`>V=? zej_i}H>zEAx~im?1piQ1{%gw=YM}IU(LX`PKMXoo7g;(cgL*|H#&-RqP_|FIjx*Y! z#{TGbT=<=wi%aLVNp~LNPT|?t|m9_{!E>#5sA+iy5{S-?#0WxKCqE!|Uqcw~?sukhOJd`Rosi`;AM| zKwq9(d)FzP*jkrCl{;Xt{aXK)APL_Y!0${>_hrC2Lc$xJuj00eI{CmC4@UbU#P{Y( zl{8nb<$Hazz4ia8x7*!@x~hMzrMbJ!SN=5h{{bXYD0vfQ$dbSDms?u^% zI}wp1AOFkWkNt8Gp8bMf=eVzlwTXxbG~?8Pr|2(zB*f0*Z;@KenKcoSqu>Wt4(9hE z{n5yY-TecBKxkJtXa(u#9~=sW!Vmq#gkHQrC_EGhs_}ELIwx#v28zdLe&q({8P+xZ zHZ=pT4d`bG42i+!?d>nJc{}?D?A^U7`sO3(P+;rW@ZvfUiuP<8c7YCYclVGpt6Thi z$~p(!jM+atjOv^~keA5o>}QD4p#{d~4nKE<(nMO^KNO_)6CLXZ=Qp<=H=qv=1rKWn zSlj$*CTE$m9ovO#p)9X$?S!>s3&*DCSX(vt`HaCSKg4Kc$Ao_~hOxENIJMY6M^Ek} zwo;d!>xWo7eEYh=HtO>D@%7fYg&hFm3Ui{0}F^9TACT}-wU}~MW8{UC^3Linu z(f1AxgWJYmw2XDr*5KqNE`R^i5UBz;yRgY~svjcKHxp5Wf4V0#2ByjYS{$sc{3&kiOU(GxHk@O z?;T{J$+%V4$l`iP+ct~ z?jvB9nejdNv1MifX8JvfP&qk=UDce zVQR6By29Y?`oKr?N2vt*=HLROjnqel`~A4 zTw^h}ck2lA9|uXzl;su9b|r3hmAn06pc^S7B6jMonTbOrnK`}UC2OwS#ZELDY8;Zb zQZ1FTv{X6w<$+YxiDU1is3T-N&xq~XpY8x5E>phe3bG(~ zI&FuZ%UV%tyV7X8ykpd8_%zW){MV2F?+{4J**hS|f~;RWdv@U!vc4#YBVV=Xyr*LE z@T>-FB-!2~vg$2cjy8xZbNi~k4nJ*VQp#l57~OeVA#i;+ilZ8^9-< z=#d<~uq3so0g@3%ISr8VgFt?j?CD5Zd%$jR`vn}t#pnO_vGHn4za;`8-3@u}CqGvc zzbF8@@+F6j7Q8<(-cLj%hb}0!UdC(0=>JvR(P@M^bu|jvo_fw(?652fWggG_XnO0T z?V$|m&z!X>cfgl9HQK}6A7;)`GmUEai;44@ydpWUs)kA4- zXDh1dxpb)4+&GVUdEO4={59nSwD6n4)mLtyxcT)C#y~*9S4+p3)0ad;SVIdN^_6*0f}@(ZKxWN5_A>Y6|u!xQf#P zmnjkDbbLH+iEpr+9r`+(!sV6fZyV=-Z@@e~c+w1f7B?=}v98*8()ts(c+Ve@tT@(n zabQ04=Sa6KwYq8HUc#9Xu^@YZ%gprfj-2#mwqkL2)D-M&(q3r@eI~PoZmq9%jo6W7 zwN4vl0PX@Uk_!WMl%H5umJGz$U*Cf1Iz#K0!Ll~1?hC;v*$gS70>lM2!5my&eem2) zQGyu(LbT3#_(x=0r!`oO_?|Z0IYxr4<%~INxzMmnl)AcZ8CZT^N$k6jAg|k;f>aAD zHxksAI4oZ=OpN|mDYf7Vw7{fo{iT_g5;f8hIa$5)55}au8jUU8Hd)E8Sp!7IMB4bQ zN@ns|fw)fH3Pa-RH%_tFwAD{LY5$ z)t+=G2R6LG6<;d!YS!icwKK1}oCIu5f9Y85lYEs+Fw0reJQki1X$N#DKe>G2#4%1u z(X$J{Ge*MGsV}r8VMqPUOap%gE%|}qT1p=V5H00+JiSB1GZLKlN{3nu$?NaRXc!p_lK>>p5R|uJq8Qwp;O*IUZk{yxnW4d56qz3Ga3ifi3u28WuLo35xf2v!Pg&VR8jrF(Wnc4+)?Nr(M ze;67eM}|x4ysa%t&@XKDb?EYrT{_{UEz@If)%0}k#pAJl5<%K3X>_0>v~$rH794xl z{ezYr)G*1xPqD=)7_tc=XWz zyEDjmOkM^~rT6E*cdq_KhEY1sB1RIMC;gZXZMe4R-y89*d}H*Vq(M0tB7tlv;na3> z1Eub7UTW}T3xEcu6mG(i%n9D^R|W^`ndBFgaGYL$5hjJY7|0N zOAjJ@I8^Ijh80%g;ClBoyax60t6=r0+q&UTb+4pL-G5Tf2)!MTcQ~EO3k`ljAg!`D z(QV-m?eeqw;5Nx&+DB>OF3f!W#jKVoKR4E$+scq2IncV~ML9byrd)*t;Sw|4C0JTT zPNBw~+baFBwx}$5+t4(n{c%vLWtiocTa{d;-h>T(U`VU9BtT&98xGy>=xNstD9Gc~ z!GF_!6(%OdTxgi2Z>14L16_}s&ABfpiLeUwtv-}Kw1&+FaSZ!G1HTThln7@=;A z|C6gpG0zW1ieOLJ1{k#Xt&fMXSL z4A8~9AZfQuet%{?759X(R3^b;!!*aJ9$8M+Zm((kTJgAMESQH(d2V+O?%0m}IrMaT zqduOtUnB?~u;-N_fLi=98tmUL!3FbV>_$5^8*#1Y@oW^#=?~hi&Y^4W#<6yl0*P-K zXI~FgFN^B(*)>QT8u3=4B%)vlfNHwi?k~EvRxvixa{jYw;c=R(Ud%0MQ;bQg^`W5! zd4EghJ5nW!8Q&qAq?dl(!y)CBcU-$ZHiDFvzDx~0)<#+wDYgTsGFNGQz zH%gRB7(CuSWsL@$;|Jgb|qsFXuu813w*$>(|Q&{*swPjY^b1xz2tV61BdQmg}5% tkL<%Dw=7Iws5<_?TT%bt+$F>dMKY&*OdU?DhWyvMYi?s!`?ts2{{R+O-yi@0 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-avi.png b/system/images/media/thumb-avi.png new file mode 100644 index 0000000000000000000000000000000000000000..2a2490cd1af885adc7d651043189a448a58da5f9 GIT binary patch literal 2448 zcmbtWiBr>87o`-mm9|t7v`QiUtWv8&TSP?`BS?kHVi8#d1Q&`3$RayZvESH8AA%SYJktAtt~sC-^jO=FBrpMRF!ON(-Hl~ zWwZt_n(tp`Osy_TlnSDFVS8u$I<8))#uQW ziRA96iS{IN&#X)|BACGlIh8Zmo<%A~IExj|+M+6Xiq++f_0|OvY>6h^ltoUo3pS(= zNnOl!!A-*ZHZGYX6G6G;cXNbi^nn?vFqb}nUg6v&wxU;NGO6$y&Y0g=WB&A@7p|VN zx^!>6t$BWezp=il-WnHk%h{uHwK53boIvjGUZnob9Pu4%Xq+Ek*_1ku);y*4Pl&nM zwEj>+3s){#S8c?TI^D5#MU0`8sUDngjw@fiLu|c*sddHFE-Kdi#~S|_|L}H}(7QyR zl<+*U?;+_>48w&Elum_*>hWG8QTj-xPpvN}KBK z;!)=1;<{Nv!SvvmXpXwNEZCIBOm>toM@m_!e`YZ7rRg|QX9;uoBcJ&f6>*!`Qp^}$ zRVm)h5$9y$#<}qk0eeWm9$n!?PqtTc#u)2@1-T@I@E*QIf5XA0Q+gj!davQ$X?_1E zO-pMR&*joZuOxiQpmr~6-Gd$hpgs{)_D z)i$NBL5y>AvO1O0ARyzLvyU7WBHq99+hu8ri$EG*!40CnSvpGDv4zTIu$bpW8tnV< zVCAn)lN9LfkcGVF&^6s`UDD`oGI*x(hrVN_w)U)_=2nF`nGT6i8*akuOqDkIl$4Jh zzKgJJ)Rci$u%Gqn5U?5{!#DQMaGnpEbcd`<8lJOC2I&`YAI61Zp-+z&UayMQAz@G5 z$*Pz3#g!CGBf?k}UPJ?DQ=jX)u_-}k+s#XFT(eooZYuQE=5ur5P1;JXZ&32-(zNOX zTt0MlDGQvk8RN&#PUac9VC7bp_9av4PM~zw)o(F13V3cb4ZjKZ?GEn)rzBOMFN|eS z5sDh$_S(BR4!V8P0c6JtHgHb%Sutn|54%o;S-oC1%9Q=2S?dEMt2oc`=Z=)Ju37V1 zpv!sRX!Gah+Np-g4@r}hKuBJ`5l|4Sz`sfbr=X$K4nxZsWVgb)AM7uJSV0{l94yQt z$8g!lP^IRLuht=qBc))5#yWmklsJ^J{o{uV`vdygLva)+S0A8|bU*Y76)C^_2Yq8* z)r)OL@s#;H?|oyd!720#mY&$;v$C%7Fq=HLd^^nz@3ju!wwsOM6kpRrvZxe<^8Shw zc>9ZWyzlCk1Bm@)WI4SmF!lL-{sa^*4J@6qGip?t$@BnTXKfrM%6s(q;O;R9cYjQ{ z)a95lREKmob$Ohq&gp22tcp$lB@OhI#6pL}-aT|YZEzx$xtLw*hWk)c?D3SEe|o0j z{$6K&(^lBernwxv-AhsY3|J`8A6m|ec|a-x(E2_+lVcGt!EE^Nho{^+Xq^A#!vSr+ z_}N6I)q*K9(5SmL9mEvf6bjJKZe?5KJki&y%g(B{;TUuJ&Ovm~aKj+s(3Wx6gB7f# zk$uj!ktIzR1kJVUHqOb`0FI~t#FBfIXrng>*AKcEtZ=kr-*>p}Rq?3@|!LOkf|_QZcZZ+*{;ALjYYX zL*LmvKeD1q)K@KjQVn1DSOy4yc~X6Lx7im*tAcqJ#1fR(ryd2-10B4aqeZ(sMj>Lz zIfW;}F!==2+Rs(~dW~>kl#p>!hZHCzjD-mM=sldV9B|)Oz*xETNXRDHk)PE5>fT@~C&=#W==*0Z4mc;qJR{@H+^--^Y!U+! zCK2YrpRD_{;v2qp4jW#<1$Bp68WZ&IVN;7MwfRv&1Gc!#`|OLr48;T0F{l8bc7Aut zE>Gku&EbkldxJ-3qhL)y>$tnr1UI~)b7GwPcSVTdfbynhz{-uM(-n<6>(1xh zRQaK+YzI(fr1eVx43nJvm6aCSR zWBH*?RKSTpBBm7IIcNw;iaiff-9S=Z(lqJLDT%h(p_XkJx9&=c2~?+;IjxF2CfEOZ zcaVz(eb literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-bak.png b/system/images/media/thumb-bak.png new file mode 100644 index 0000000000000000000000000000000000000000..86ea01c24fafe652e246da8782ed0a1339da3dba GIT binary patch literal 2814 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 SPHVvehr39$G%7N@8Twx}!q#N~ literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-bat.png b/system/images/media/thumb-bat.png new file mode 100644 index 0000000000000000000000000000000000000000..1722dd037e7e71d714e98664a8276af91e2fb7cb GIT binary patch literal 2182 zcmbtU`#%#38`rXkBfaVJBHAgJD0NCAPOPhyNDIAMDIt>Sb|OV<)tlqKRIbGd4BqSo(N|rdkr-mH6&ic??kL@w(Ucd+({l?v#aMax{|e9Gk*kk7*u zADV9iLv9fi3upapB7l;`! zTn7|S7$^fe0R;6x^#j^!_8S;&fzj{K_6#`LFjxW9Yrsr|MLG0lK}R%DePMzFozH=p4BQ;( zdI2&iwA_JlHZUFmg3R|H#&_|kjuRuDQ6^j zbCGIX9hcy6ldKoakFAes#rds3tTvpEKY4<<9*+1Nxai;Uj5kKqtjULZQV&(-;7 zM2%OaVQArug>KxUonPC#8~JF#g;N5xlD5Z>%|nQsP65>&e?|gNfFY7OA#(CD53ORN zhr1u_2eF_A{ZY-}pc{LZbN}>+O7U6LD4Z_m6CIi{qXR&n@&FO6>DHpX`-9BDS;m zD<@%bLQUX9bkU+kWpotRTw#O_wEAzQJYQrzYIuR*2&Cm6&X0tUKyRwa4}krMA-t!`}V6biCJ)WXkyU$%yf7 zHGCY?RHH@LE+%nnFA_U^;$Ui%JD!_pvn6!vMxhqw9r633b4sAAu&UMftXGG|L~se( zbd5nTd+_@8JCbz?;)Si{7uRDb#L}uHD?MwDUQcawChQ{7S0?L32VL^3TsskP-YB{F zBwZ`~FrVCkFxiu6Q4=(q@w}YzOIn-*_eG1mmoKCsOw22-eCy{X^X8Jqe#s|b&dVC> z#YpU4(s?}PpnC`R>nbYWD@`+t40#%zJV{CRKvd#ThATr14!nJ^+fpKwMoeWQOg7L8?Oz}@XJlq!92_c9 z8EZt6ZZV>kJnRVV^R9VZXL_2DP~wC^aaC@QT%OnTsJpg==G2-(^Im!M7ZZ~HN^M+e zp2#rr$jn*Vms^TA@@ngZyech>3Nhd$&#ZWKx{+#+d%Z%x?)Qf`YEI>oxqt!2M z!YI(zu61e=3F*;>|C>hJBF@|rLpCOyt0_tK%23xr>Zhx zVjLM|Z1-Z`zSIpEIr^$j-y18|Qu*1ni5Gog1BQ)uOS=|ofIl4&8c@aM#rrVTHPy0Q zP%rqsk@VGkye#Mv_NOaxTAxNME@{#l)!G!fkx}+?XTQ#R%njm#Kl4FUN})V{+r8o^ zTzvCn`nG@3nD(BxbO}1F5~Gnb0%%C9lU@!cIDNEqx*8beXygA6p7Z}#L#d0JYJ;04 UhuGRL|8`Csb2?gZ`10ew0Qo?wbpQYW literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-bin.png b/system/images/media/thumb-bin.png new file mode 100644 index 0000000000000000000000000000000000000000..b23feb0205107d309a8c87efb055e5cc9954c952 GIT binary patch literal 1841 zcmbtU`BPJe7LH>8!`_If86R`0lmdcPtSgT)mx8${L>uv`$Rbn(N)ZJSkjlmN_sx92ob$~|Kg#18>2vfM z42F>hVqFyU*A)_leiN{=j3QO#(Vnd5mKm%GgLH_sxEgL2#Rn+t<-SUCV?!iZwD2beu zPLmIZh@cnfpZ$2(JT;)CR&cs}nE00%=~A{0vqIY}oMg=+ek*XRe zWV2-VJXJA5B(z~^omkI2RrHQ{I!>IZN8~EHYmOSJ&U>QGgxtdo^o|=+j)X*-aT!-9 z{=LbZ9W)YAxn_8EEFscz)!+5%(X}FEEMc+!jtfU+dFb47Zj#e_TmJp&VWsS5S$y!L zs=cw6+Bp3kD0b{Cl1PNnX*rWnV)OT1ZL%5PX~BV&j`UPx`iSuQw$wiL`_zyVorC;c z9S`GBbVf0VDu!c{Z+CS`FA5Ch73F$7E4?s{E9?uN-{&lk3W6t{K}X%1$DUs4V1|Q0 zVaWe>k!PrC+X6=E+Gfm#ff>gI3M2m0LY=T=9xMj0m|N@E%4B9%;c!rt4;)ks#0$$7 zc?PN`C&5`G{&f!ne&RMMBrL0;^1e%4ZbQB{e*7n5t!w)Sb8{ekI+C*^y8oEplzQYA zM--e792bsMmXNXK&by)I-vJib_y8+Z-^=BQuw5WqaJ#?MW>2B6?Ve})S*BL@>1j83 z#(eQM=n&ktTq&Ogi|jX8SxRN0y@MsQV1~ce;hAyJ%W$*6>@Q}C#aaJEu8b`TDFuEL z-b+6?n)?;B6NKBrNj;l8rMmAn)%(;OhBLtJZo;Dfl!DvKGyTtpZV>TdKR>mF(H>Jp zHkv04$^woHeB*c1I$`A&qL}YVS^F0t$M^|(R{ZdA`|v>yi(TP+-B@f5QNU|!H*3Vg z!R3TFBm!cwlhe7e`ux4pO4%cWFmOB4l;p(pSl6wNj)e@_$=B0d>>xET-EXwpT?jTx z;!F?_w9~^%c5&g2MQ*Le23Z$O_9x^!?T7Z61}xP8ti9?7!Tc!rF1J9qlF7<471@}_ zTIsS9ataCpSXpKww07%G=;Zo_@xHGiK0KE>uF-c)NVya@YzzLxj1Nv+xRL!KKVyMs z$o}~2qq~;UU-N<;Hie$X^jk?h(H4O#@mc)_WHU#1EZ+VXpW(P{Y&`v(2)qj06Y1?u+`d_kk`=5Aqz*+|cX znH-6Nm(E?9!$U!ie$<1fN%cD0~3cqJLajnDzSgK zYY{M&V$R(B9W*WZZEyL2>Sxfr+)Q*Cjy!)YB5!~oY0K@l{LgLK|965dd(qrESANxH?V%v^K@rhl+Zfcpt%2}e}; zjlzU&QEdYi0?MA^)Re6dM&vO!pSs!n#&D;5Hehg?8MXsjY0U{S*{`Mf9ean&syBg#7W3X_{GF%r*?Uz}>cWtJ V#YDC~J8}E7;Ni}5t8hId_&>uUyLtcs literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-bmp.png b/system/images/media/thumb-bmp.png new file mode 100644 index 0000000000000000000000000000000000000000..558496ed6a48908c387bceea05a910e4e37c2c12 GIT binary patch literal 2573 zcmcIm`8V5%7foA4?HyD79+XdwnxUAfnOdrtj|M5#)>5jqOjT=WF=f;?+G(PstB(_LMf-&cO|Al$yyz}n8=brb|J?Fjq=gxR&s_Cmi zAP~({aF{OyvJ3rhtM1uRFqiRLJ3}cF^adf2`bX-D%gQ@^SAwsHE2M^OD1bnApFVfm z-_4=3d1z7ig3VBDZT-@VBMawGcXw=WZ>J5CkTl8(Yzumn_GV&?xxDn8#TXO`Yo{l+ zwzoI8wvKl;uPYQ=+gtLjO*dTInq2;KS8EH8>x^wFWsTC8L;|T~Vs*s>*Dl}O^y+S3 z+*o(VwVlCtG;k*8q>^|_@95GZZBf|2Ft;L;iRCi<+^lzZ2Ww?1mP{0FtTUDuQ>guQ z(-X_G4cX?V3$|rrQ&Bm|`i|BSU`MZ`t`$;yet8HOj>e|)H5wAXF(yOP& zul3`J^Mc@>u9s}a`ziJ*Tzl3qwP}XKldQd+U|k?$2Sp13#Lj3E!Lz&lCZ$KTzMeTe zkTlTukvo-2?SIA^J=xVNQOKnVMdQqL_Q+sJPgfI<^EYR*e0+?(vfRSsg!bYBiCCYJhrk_JT`(FruyK&Op8~?mY4XF zwE@w>D>n1P)cE-7a@7?3k3mulnfRGIeRqg*pH96uJn+}Z(CtBT9)niE819`H%&du< zc^vMVcv8I5yC9G%%vV7}<}{XAIYKCZCRt_!BzQ z2T51@x;tlig-m)opWDHoY2$NmkcbJC-X~1@?*qNViz13}{?X_VVQv;dqhM!wUuJnz zJ`ULs2sG>z4D?TC3R%*$uiXs)T&`8J(ck?vSk2t@&^;YMNmW&4x6g4D9O_g2ld`#m z+qqpn?`+?P=#Izk};EruX zm5d&2nts477|*^~am2pNVk3v`wAS7ntPx+}c`Rk4ky_wYG4Vgl{jJ>V?2^`re4lv@ z)f^(GF;bJz9u&q8L3b47v{T@+y<%mL~Y`k7QZ{%yt zP|*9ch)TJWrtilA3)IE^n4uB*cc@f61;Dq!Z=f$9l?Mk527<-v@m9o9>`QOQ$k1WK z4WB7VNODs_%HQ0^4}#4odX@1T$h^32?2I=GYw0Q{?hmK~?B@h!Y9JSN#M zDLSjLK|5U0oi6Y|>&nyqRi;)CcenR-A%dOVt)hIEOe zL9gfkuo|Ltw0%A7*(zvMMbmDyHO%7z_#2|v^;i1l4l2DHaB(w0MZGu| zUHX`QI104Bu5PAG(9?f(M*_x5tcm$hw(1 z(sf(eyxsp>0a)&P$;NqU-!XgL3%<|O(P_1I?xYn9xUjp@I|I2eP`I!iS1DQFHVIM* zlKRY!V+zyub6KsstAL_!IuG}48!*Gq?2*{OZ*S*5pl))AvUn}A_*G68R-Q&VN4j1_4Awg_HP?6&;%NWg&8}?RKL_a20 zJy5Rz+JQURW3T1g09UBSyM;`J@4pjlZ9~+HQCrA7^6>dyYle9&lBK9l{u)*;_~0B% z`TQ7=U42#^NG{Yj2#%*lp)>u?PY3E09W|2#BW3|+lGd?H<>6H zo%3%g7tyxDp)YYJ9V&N1g%2A!OU4(mkUkJ z6OkSI{BzW2p5gFi@)N1U{MfzUebGUE$@l()*}Bjl&XRnM}U?*j~| zAiHG{Fhgy{jb-`5Q2&8j- zSTuUn>&*j~!zd)c_wN@xtOw2BhIO1lheD6m?oK)61nu9}XuRjD&%wibAeUDO?Ae=l6MYlvA(IqcPtEq@Pm_H^$MO>(xwZ?*%$}>qx<311qGVYISp@ zGFS>VPlsC~Ej2d%s-PJF8JVp!1`Nj5IF{_-->)qoOKs=}{oHg2-~%KFgPB^7pDP{Rj$FBku-EhrZi1!i!5m)&dJ}bUm(kO^jNg*?lz?*|s~~av5RGcS zzc-}1bItOQC|fIs0^kh7(#S@@zIU?(zPG_y^i>oB#<=bMfZ_6?=(FQZ-5KV3ACI*E vt^ualu4b4%Y~A?IMuhqgPt^Zc@BCX3)6568_O9IMf2C7yXJ9q1zaahrHCG=U literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-cab.png b/system/images/media/thumb-cab.png new file mode 100644 index 0000000000000000000000000000000000000000..39d450fa59a84209a9f6ceba3c0e182113dc69f2 GIT binary patch literal 3056 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 IyH8&I56j$9wEzGB literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-cad.png b/system/images/media/thumb-cad.png new file mode 100644 index 0000000000000000000000000000000000000000..b0a59863a8d499891d6069209387aee5c7494206 GIT binary patch literal 3021 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-cdr.png b/system/images/media/thumb-cdr.png new file mode 100644 index 0000000000000000000000000000000000000000..1c106f1b20e156c3301b1d1e5e140daed5411448 GIT binary patch literal 2713 zcmcIl`8yPdAJ!UjtRrWKm@TrEDB7Y}J1wa!GKR8P9jtVHliXK3lOwW5?z>Qul_->R zg)weJju~^919RVFPGjx!eE)_0JkR@lp7;H{&->HodETd&y*+fa_G+o9sOVh0;09Jv z*#Z9-8mik$=XHc~djTRqULX~f);w*+b+v82BO2^+PUYQ{8BImyn;n;bfw&JZMUP+- zhnF6GB~?h}f<;R03?YAOYik6ZFi)xyh*!zXfeCB|k=~_LZsKX}*!A}k8Fy<-xkCS} z+)^qwC33|EVku@E3qzn|6`N8by;~?=Lt>KT3h~(TA5_jbZ)1hYpXYCoH=XV5 z3A5I;A!VXg3Xz!PrPWu>$K)?e;d_lkcfU^%NIWFRV5$qnSBsDe05t~B19~T?z`|o4^cN71M zfA9M^*;^v4$l{)BJD)pcG49YyzWT@iWi#+ZlQZBv)P7U?6&4v*YJwV?~rv-@s z^OBxVQRnp|(awV3CiP~^957b}vwe>Un~lIS8LB+ju=_aqMvh{wuyL>;7;}xiJ z;5SP%N>OB2c1Y)f+h*3Ign4oWcPH|%=pYL--&1DQmk*cen}&@G({!HoM!mli`v|U@ zNrF+_Q&*aG`Y)GxxPTewDmsNBPShF2%!_pr6#2A2U9IMB-VrcE->q@_(D))v82QEW z5ovLM%(bm_vyBQbc+@f9hHAS{0N-$aNn)!9Xl*Rzm0+_|X-hqabhDfOg1tE#0{SH& z2dGL4H<8VfUw?AGYV;x8-wI(!sRuzA>VaYDe(=trrjrtbFt@u`gRm=c!q#D35G1GP zCoIb-EIr;h^bpZ=IC7zUscg0_YzuxCvVEE8 zpO2rd3!XS}Z^~WYY5{z*3(z>TQ&*M{;MnbK4T{Yc`Mc@-}TUk@NV$4=EL*frY@YhFmIKD)eVJa9o3$THnbFVM-|%#!j(lgR;x}M z21ZZre4%$D%fRoM-LhI_)d5|1EpfbI7=+NK7j5J=0XBC~r&Yd5YH3W0N1HkAJ4N-< zv+8Je^#HN=M0^M~aY`onzQ(9BGHe~5$&X9f4dSG<#eKl^5)nO74W=Q?ra-?ZG$cwZv+*ojvX&v+lX! zX!&EhccNj~O$BQ&uLi-o?-6MFji7e$3CO!+1nVq}pZ?vL9u#{d_E7cB4^X7K~EIb8WIPCq{n168oe& zl%`*AdpqhJA1J%g&FWM04LWA-d4>Vs_Alp;TNdm@MeL?~_8tXyd`ElNvo8uYbm{`J zkQ&}J3d@rFcP5&;wO4yd63j)8F5^JK^ggBGD6CML^j1me{t4RIya(lq2>`t-w^+${ z&N_4L*Y~Xa>N=V-IGuJ_W&+AqzrogS)2Q7#mjvH%Hy*GW{^B^J1}f;UUh zmKpTNdO!x?g+qvI9Z)a?t6{uRf@)#>aw8bt4x*SYTL_1-!H+9=+brr<4^&Jb{U5Pe zH9oDxmbCb+2EY9Q{Br>QjSn?RP+BKcHjccXLVoXuKg^=nT0oyB@Kzg`B7m4j?-CZvg}fTU$aW}82)E9lVilIr2ahYTMJ+z9!k!PnR4(*(9O2BPBXTU14VH}| zH`~CHF(jfLyxsyP_rjD$Fr^pH9e|@akarVUJ&BZzBJU=V(lG=;bmmNafIu)Ta&kE3 z9yKBz2dG;*K3_9c=&=3%9)~@v44ueC1HHtF?BHWNvNx`Jvfox_C1Xe?Oo7x1s+@&} z-$~tPl3UAQ=i0}#m6ereGFE*$(f^(1sFmja$-r#5wU2(L>x=9oLn9Su zlWlra%QmiTIbENbb8O(k;_sA`-Zd;wMq^0^oZ%gi>C$WyGv5?<=0$<}n-gC6+oFWJ zxVcWj^Vdoye^{M-%pgZM2m2TZzcr_()tAUX!ZAx zj`DLGOJf3Do=<|GR9lVfug4kPuS<^DpA>!)UC&+ZlTR$)l0Gq7W44<-U3VgJUf|&v za6GnFN#7#7?er~!(IXP=tO>diOH3HHG~MX$nv?qOSk6Mf$|7vi2=U_6Ov|jDj zE%7w!dI$Od(&d9#XREm%k=gd9pXawQXPPvAM^b5?X&<8`>C#%Xh4J6jm3b5OOH!V` zwAPGyi>>TkjxbGgpt0JCWY2>a-J}IuSsevj(K*XqoPzVuE0z~Dc|D4l)aVV{tHM15 zJ80Bp)%zQMjtQi!C1h=tLFzRM{BuXR#pnAOQESAdjg`WN54*D^6*r^Tq+V-EI@xBq z&QL`j<3zLeE`Kp;CaRj>O$JQzdO}0)(m!l&cxaopzX6Q5QQM_biNo?dlhKSTUtmhD zeDgKu?+>nbrsdLz!;H6K|6-A3$$C&w!bo;&Ux(QyPQ_c*cJ8b)dtn{uzM4=zWO!S^S6>O zNIf@m6snPct?e%%UwsqCA_=DLWOW>2aaaF)Z_qS;*p%}mgQKVqiBmkyv-6*H3b|e$ zEHt#rHzNmkRpgSblRvr-wLg_uFwcf>=9yEe?+%>_Gnr{E-J<4PSxbqIXfUWaEZQ5^ z(e`sSBbl^qMf+8jW2}*4x5S(oxke{Uzd;;)XvMlShCa|dBOhU=3o3ui-^LNo9bnK} zcWe38d-S7i>t5L3qQ_Qn*;%GeVczn4f)#?dWFND&*nFK~7)8ioi8j8sIno;!tT#Ng zWn>JOoMk6&Xh^yG<%vkw=eG}i<4r4(Ka40I^3m{Cy4D$1oD33EMN1~DXKMm#SF2Lw zhmvX{$e~iI4bO}EXBfn*@Eg1=+tKK;qh!}J$(&1vdeBdrt&3$BpSiMW8($69fvq-tt!t&c!y)Gj0Aro|e zc|t<+o+!ST4^laqnHTWNHlTJ*n9V`-^j-SFM>RfKm(+_Tdv&Dmz6-XE;CY#blnq^$ z>45=)^x**ps-GCo9&vltb6~_HpQRZpTn=>yJO+LxwK}I{yKV4y7UX8C`DqqZB}Mt* zLWu2`%+M*8HJc=^ch?Pd2g*&jrjwM93Ordd17 z)vDS3Uk_2Y(u0f!_4e*0SF2+aM~&w_yHBW_M$PSLAj}XC=Ko{{2?#UWKYbi z+ZNppxz?4tZqAFfzm#60s0!(&?FOb+CNzs~Qbp#nv};`H5O{@Nyw>uUy=G*StIrA>gY$G29R{S$c8~I*#Vf{;n zhN`5{sGLyZ@ZE$etETd%vA(sBdr8dKx*wzD9HYqIkLht+Sx!`sm$~FY#b1ksi2T9RU7fo#e26c*WaKp2 zCNO=Uq$GW?x+b0_o)oihYU?Zh!1Je#Xhj-p%)WR$``iW-7X0v+Y}>f-;V1XPJ*rp{ jd6s`aGyfMmD7f)IFJ7$Pw@H!z_adjWt`4QAuipJHgp@tV literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-cfg.png b/system/images/media/thumb-cfg.png new file mode 100644 index 0000000000000000000000000000000000000000..187e892aa1471406a5ab07b74d7801e324fb59e2 GIT binary patch literal 2764 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 TvHYIB{VxLc^z}fVxeogu4(QAu literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-cpp.png b/system/images/media/thumb-cpp.png new file mode 100644 index 0000000000000000000000000000000000000000..059668adc7c5b6e6a0c43060a9348413e5fb71b2 GIT binary patch literal 2425 zcmcIl`8U*y8=h{Ykan#!b(3mJMcPculro~Uh?Fc@iXn|mWJ%3PL$_;^rA2g0n@ADE zj4?9AjIHdAbugB(&lvOB=j)#D-*De^-t(U4InQ~1d7pEhnA4}M=4-Fg#^G@Dt;v>W zakyEme|PTe8HVlt1DiP-S4k&HINaAb9mHL82G6>A*6IlEbM2ZT9PW=@r)}+zSrw>a zYr)15^rIMNRDt)a!8;;QtyT{zv0gdWHHmtZ01&E1F*T-EfA2%c1u6_vgNWLR2b|)l zcs+<{5Sauq7*!W^!*(2%_jf=bMF2!SJdLHdLgi9Ks=(@G2)`FWQ1t|WO~dNW3G`wy zAP2EN1;&xU!U4p(Q02-8ih2-7p6XTwnAHy36sjVG;5n}9StB&6!cv=|kV-JQ0s7Vj zdwvJ{r?7!348hc)mEe^UU`&Ob<*I&H!SItnv2+FJh zf6CC-Q8cLm@(}=WKcNp|II$j*OrrEE(5(ch=tn-a!S|}b`eC$k0Abef3UIhNz1Ef_ z`w;PH`+#dB8FyDQYr&f}UZl{(jRu?P>tnUGx3q?h^HP-~yq6uhF5^6vyw2eL+Mtau z{G$D)Yb6oIrVM@!H{R2fdt@e=|KV)UOPW4uWtW`jpieU~**4e_%uYQjU+Wujn%xlp zC?+6Pw*RV(@L9i>FTn!xt)3s+vdfZ^;v8fo)c)jsgsu3t$YF!9=}E45RZ4%{)DFI) zHKy?BHmlCbpkxozcem=h8N@`>oZx9ZgLp2U>$gccxOS~1WT}1&tSuk6I6(Uxho7xb z*{<5MPdpH^*N~kL=w5NM-jfE*HQm9_r;?`z$&*2Q1IOU`rV|ea;?wX+<~`<$o7JmF z!5c*q3uYpEKO5g1OMR!h@WrY3MkAzm#VDeez)Z{)ju^h_zh>~a&N@CR^CehJzi>bI zMhdG_b||WJ zM<2UK2A|r@G9hs7+LqhbJ8(2tUg!E=*ma=hmdo`Z-S}6v0mNU)nYDJ=re@@273CwMhN&I5p+VJPtkg|d48L_GfP9d-66`DN{xxH zfJmHlcbjjFHGy+|WA(hO!+!EBDXhQ;H}AIKwbI=wk8}cU4(hcr^m5vVwg^2~e~MP6 z`y9BK$Q@54X%%iTN>z_2#YjlX%(hqjp*x#k}^13D^{;FD;fsE*E8{|#zz?*N;+{%j_#rN z4?W~M;btZHZB$ymarq~6dUYGb9sMeG>7|Do^GmYsY53Fxh|!x)PdgTGKj<1buRipn zk4L0;Uc{ZZw+T+>a)R@UJbY^S;F_{0TK$?Ina%!{ZuEpGM04fNx+1*DxaAJhic~oI>Q|mlYRC%vs%uIDbk6#@El@7%PTQ&bn#h^?VRM;b&vx+G_1McSb}* zksjUOa%I_uw@xp!S9)HmtTdEuWVNr*EL5aUEc2|kbpP-+Nie$z&qxp6rG1DLA87SV zeAG6<)t6Q_&)7BMi~vthPri@Jado74Z`&pPvNJCJj80nM>+39Gn(^M0zj&HMh#Mt! zhd6{CHk%vn<(Tim5`{Po{q;A@<_Cdg{KcLR{4vPMp9+?Sh`bzJqa=<7>`P%Cece0B z^cAlYo{;5%DhW+K(V-EO?-M$_NVJ6SqY=Xq$nSj^ytTUMSfRnf8TdjJ`Pi;~4k0ah zvk@5=nVV33nob$a`vd2HfNwV78V5K7nRjg1fvar}Q?THZvGZ#1D>*2(?gIrQp{ zK9wVxOW7ySYs7rc^>NpCpm>{K);oYsVm&p&>1=tS4XZmd_`+yved2|4XAGyUWO*w{ zuRp#$8(e2~W*Ldz_#~^Px91Ltby!OAKTFODurMYk425s=5y#Q-_o@g=MBST@C+jv| z4Q&s)b9v#0-O=L{IS&*}JUKI}cs!Q%XV_zbJzc}&`}C3IHCn2DW3lVWZpoMYDF)9I zZ2hRI$5}Y9vbPy~Vt>;d!<+|FVxC+ZFE%i#%YID{-0d_?QAaD^@G+_6BfT?Cd{D~# g4;$nE)<7hvHD~FGPhlf8^}meuu~U|xkGRqQ2c3rsEdT%j literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-crx.png b/system/images/media/thumb-crx.png new file mode 100644 index 0000000000000000000000000000000000000000..d90a0e7300d5fd3904ad7ea624c4644b3abfd6ac GIT binary patch literal 2965 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-csv.png b/system/images/media/thumb-csv.png new file mode 100644 index 0000000000000000000000000000000000000000..730edc7b1781c6a02e84276c3d5538dcd876c840 GIT binary patch literal 3317 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-cue.png b/system/images/media/thumb-cue.png new file mode 100644 index 0000000000000000000000000000000000000000..98cdf358422e7859e671068a5432a8d271d57d89 GIT binary patch literal 2271 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-dat.png b/system/images/media/thumb-dat.png new file mode 100644 index 0000000000000000000000000000000000000000..dc4c4a3cafa9a3c5a4b77a45fdd7265ad89b2c9a GIT binary patch literal 2188 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 O=y2$Ks4k!n~x!F`LgrT;h~7SVGo&2 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 I-s}nV|K>Tf6951J literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-dll.png b/system/images/media/thumb-dll.png new file mode 100644 index 0000000000000000000000000000000000000000..469cd5a6e6877835b83c2cfc3bd5424d644d738a GIT binary patch literal 1352 zcmbu7i%(N`6vyjmCsL}e7BR?Xr(N((HXSfy$Vy|gLCC+B=W$v40AJHOwhy_pnT zL`(zK=G*aZE;Q+Bs{M%^?dp_}cZ1((X{f!sce-Ztj-FZcs4mGRBIsZVF& zNpw#q=j!DUXFybJcFfwG@-cahFW}@!7#j0{OxGF+dMpl!#@s)ys2f-OF`>Mn(7)u% zE|1B(d3{h^Iv_eTt!fMey&WEXEs2)x@aVLbAvgrP zy(X{UK5yz(8~+W3VB^BH*)ANGl+PKuw3a~_4o)a<&Zz$~*{4;;?os&_wegL^qYVaq zZl76c=v3(2#o8xI{mXGht+XT#f)q3lo-*m6Xl;0Ycn>6Oqp5#TlO0DFV6|38> z;Hus6`J9n@+BhY@3^vk~19U=ucAFPELf%YJ=Q81(c%Y6>=v!d=F)S*0H_2Z`tc#!$ z9{JI%EN~7!x2OtD?=?}$6?a}>sVrjJf#yX1zX6mYhjUWSh$J=ug2tw(^2;x+Cg zf6r*bd5uRiKq0a6%kI`L0x5yZrh+B(-gj9}bx$Z9zn0W6%#4n}#~%G9IZ#e~L*B3H zIe=cayk*C_(hq^3K}awXM@KJ(Mwv_uLP_gn8vzcz5Q~phrzQvfL=0VgW*MH8#P!lV z*B1SWyBqPyt$-fhb|4iz1Wg;H$ZgVc z4;O25@uaQX-Un%wdC3?k2b68taO#7vS*PM4rC0I0tMe_V{awQ4ybLPvDzU z$o+bYr+i2M E5AMOl?*IS* literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-dmg.png b/system/images/media/thumb-dmg.png new file mode 100644 index 0000000000000000000000000000000000000000..ea35c4e4f88dd07d29e0fc29865ec6311965845d GIT binary patch literal 3064 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 H4`TiY$1Ong literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-dmp.png b/system/images/media/thumb-dmp.png new file mode 100644 index 0000000000000000000000000000000000000000..9887e631e82ce8f4454f4a0a7ce430b130574b36 GIT binary patch literal 2527 zcmcJQ_g9k#7sqLhKv{+tEQ+8J5VQ)23R=M(QC`4MT z3_&4`fD+jwf(R&K$dJ8PWXK3R&&rdRp7Z_-z2|(-J@<3&_xsa5=f+;LGMAT8m64E; zkf&0}wh|KGF#nPCwk=Zb`Vrr11P{OxkdP=$ki}fLZ_VEX*qWc0C}>lglaTn<;1bQ= zO!({~HrgVZ9TLrtBeh9r%PX|KP%IY1l@H<4D6H?Dh~I-WvXJ`cXiL6Wgo$yH7!eAe z(S#|sIJ_Z7ATbIRiZ zlQ8YFXl4NYTn=Tr;%f`yEyK#3XmJV`Op7rDseLB;(u<9^!G&RBcvJZNXDG)@m}(D~ zMI+UJLfM{3&0jD#L5xCT4251?!}@APlbr~ciMGBLCRyPd0=VJ=C5!`1UB>$%J&z|kHLj>l$Qd}A5y<_c46;Nm}U zXdNz(MLUY2+*_iF4t(hglOjZ+m^a2l4o({L{n3Sw^AKv)(K2UXHGw<#Z z%H_l&!xfOH8+~?PYq=`9c`D$VtcJVuz9<6Ny-{B@g%6Ov11%}&JqdH20jcBM}M zRW*E{keY2LO@1MU9P^CR)Rk1+QzDqid$>cRf$Z{lzfFXibpLRO*KtO!LkxZ59MRz5 zWs^Jk8j?xsegQYLM@T+VU(c30DiBu2tdjQ{Fs{vx_3Lth^{a<_^azTM$jIX|22cPS zITSb-&`c=4_l7NWz$0DlrM=OTUbgWoPb}zm`PKUEEfyw>P3_epC7|KwdL21KN&-Dv zsYvhaTh3A;k<1-1Y{UMY+!?Pb;Z z?GXfg(X=dlMB?O54XS;oF&#Y`(hYf$-T8l1JE3zDpikrHwFO9w!3@Sut;DuS zV<0lnj@7Tsr@MDHh%Xc@>x~S^1Kv@lfMhgXLqWR8rn2eObW?3&{pexHZc*w-?Q1f3 zuBc!4kkLRwR18ga8;^4R9>3YkZ(-|gr{?;;45{12y4;aEu>YcAJty!CgmTQFVCPOl|B?o6BdCLCaEq(cFJ5@Ee z-kpo5sX0Qm*II#0EG+LH=lWZej@CUy=sNx&&9+fbu?b|vH8=jP@I|}HNYR*L{TQAk zgizH{yH%2I^P~wblltibQ#ry@r~1yhRZ_F#li?oaQ&K6ULz25Qw|m7B`6C@clO8#q z1gb9YrER_DtSfLkT9QqogaW7k_B21CV?m^U?6ylF0H0-S9XaV(A6e$uad>T>K;6$f zC6XtnV9Kg2LoJ>K$Wmf$=#DAUcj+>I8d`~+0!eHscIAAy{dM|u7g0;+D31oRR9XDG zM}_z{r+!sKimCG@rXeBrdpAlrCpFI}Q%4Gy72A;IGs0_xXXj0cc1*^FxLLq^Td#{H zF&|hx*&uCu{6l%z+%d3UF0oJV7lNYi#kGa2AZxdqMXhN;Hfga&cR`Esfn)~Q`Op9( zm9Ky11S&N+GYeJ7t)$8LvRE^a)C=C#+gTXV+F8~aM=AsPzVdLw{)d1?_gK>KHFKgd z5Y6$F9(riNIQ>#MM-B#ADq7`ZP0OF2MX1?2tCN}Q5wyLhy}4e;r%B%l1nkx^8uKp5 zZ>uS}?G#wVww@+UUT%*IA^>t$ZYf9f;~}hNw|l7r(xRTGLf@gQ@6hXj?|A0D!6~Nx fi*M=w(YOGYJn*03wm#g6{|};?S&<9Q-vIvya+(&6 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-doc.png b/system/images/media/thumb-doc.png new file mode 100644 index 0000000000000000000000000000000000000000..7cb1b757960c5a9b70cc9b4b71d4f9f5cc8f522b GIT binary patch literal 3051 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-dwg.png b/system/images/media/thumb-dwg.png new file mode 100644 index 0000000000000000000000000000000000000000..3de942b13ca7e29c4ca2fb2c82ac9abaf6101a6d GIT binary patch literal 3530 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-elf.png b/system/images/media/thumb-elf.png new file mode 100644 index 0000000000000000000000000000000000000000..d935be350ccdc7b0a613670d6fd4da61bdac727f GIT binary patch literal 663 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKL9LzwG?TN?!0V$SrM_)$E)e-c@Ne8%D+ zcPEB*=VV?2Iqd;HA+FwQj#cfuwB+Lbw_ktEJ$JYB=(R-`?*0G&zvtNX*27mHefaX> z`>&@Tzn*>i_V?d^H(!7J^7HrWFWb=~T+rGwZKCuHAom#+lotdoI5J_Ven?4-ejdX*zf%b=#SL|NqZEd#CQe z3=9dqU~ZAPZWEXbD>je(ZB!u&-YHe(sD0O z$9KVVo$AleqHT7oaRR+T0XSf9E9YJvxAuLvzWmhleoFa$(ZWmYJItci@^|UCPd)!l zDL<`=L#aangZQNGaf@^IHTe{|T{m;HCv4tP8tZt8#gRqOMF5Eqfbb?rHQ$-bqQjbL z;&I_?SW^wJ_a5#`6%&HO(Tz0`fAZNRLD56HPH*4b=@mjtJ}T7daiQ7Y1ar-%^NxAD zME9)Wp2WI*ebW)vl~0OdCv4zGvY$l|=A@ff4hQbJ`Tmz#*{uIOpC3KnC_q85L9|*w Ym;QcYwfzPiH(ro{r>mdKI;Vst0J04F-T(jq literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-eot.png b/system/images/media/thumb-eot.png new file mode 100644 index 0000000000000000000000000000000000000000..a56281a15a9eb3556d8d5dc932721345c30302a1 GIT binary patch literal 2002 zcmbu9_g52U7su)QvT5XH6s%B*n(9F*`09(JKy3;ZkRbw!2#8c96%!Cd3YAo;?q)7w7X^?mqFAoH(L;)E z!}ZQC>vVeVx^!EuX`?U7RBDw*TQEd9*D~Hd!wYK}4@Y8Wg%Xi`TcOq{HCmx;J9~gU zz90y0!d&Yj;1&fMt?m~zb!dLIev(~+hne8l7%t zZDUy?|Dls`z7{am-&wXd%s*4TZ9FjLr~jHqjQI5Lq{SCp#HS0@*fc zSrpZZV+uD*#^_s0b!hYWym;$9m0O0HIomv5ft^Y1oy2h0Vn5)=xNCTxFur}_9hI9q zNFJD5{S8A$&-2w)oVg+Cm+Hb*T;CNa2u{L@benup}CqTUBdyl-2bN)YRbI>h*5o zg|{h7>zjN&Iro^zDjKFOY)C(@h~Lw=RQ@`0dZCNCbfFbDC)&a+tj&qH>WD1pFzsR+ z?hnE&XI(0iZC7F$nf)K(IK~7|*c{{7V`OAbX}R`MJBPS?VD9$UkKg^) z)|SO)H=nT2^aw}vJnBQCa@Fy2?(4&lYoz;(*hh+f_7;&>>oS>|1UUk-FwECKc;4h= zlUeKS)lQj(CzyvbV<`g^|7V#2FGB=zsPcC)u^TI^=zHJ#s^1>8bCHVc8ujDAe#=g$ zufn^8));3_Zc7g9?DaTcPHqP4cGr6LrvCzDJMfSIn^(R} zM{}|d_)^{C{bIHQ!U^i;W$)v&?FnAnjaY2lg(<#ahaiDVos#R)G_kNg(_T$rb4=Fv zupHCqi(3x6VoVz2VYJVxiA-vG$ME&sgv4{O9Fyn>u`rK)&%$w5+}|gdDW<1YM;{J_ zy#?-5`CZg=Oz-b-%lBnwyLVQ^{kH2&#d4Y=z7R$D#=Rlxc#ZnxC6~Lv{FJ$&%!V}o z2D9WJ2jLV9_szT1F8`_RN~KDhP!wDrAxVmK>uym<(&GxjYc>d1Z&Ju-4h7O1t?8tn zz!K|l=iScBs4DjykjJN`!3VIu#tcVi-d)N;Bzt;iUa7qa&I>!m$^Vg5m;Bz^Sk`3; z&xF+%`R2V^TdPGso|e3T@|+V>(;XsBjYr z0SH5p^LqmZ!0w3Rc!KkZozBbgCOA(lUYedO&t+{&N5Nppr(~PF-fs@(CY}}cz5vFV z-*)%$D#(>%t2o}_ureUA0+P+a@B`6+KW$K6wojU@;5MMyhYEejdZG z{n7x5iNrU10wrg2k}3_pll28lcL=&WuOThw246Nm>ZKTOA;w?+Dxe(v^($m5%P|(r z*l&QA*YUN^?Sq`IzK76AYw`^2no7&?t#p~PzEtz9_FzDHynELhh_zo?u$9=`)zVO` zozEaWsMMkzM+agFN3n3JaPvVW$7&$~%KMzx%-PcYAjdwHj_x*e1pcH^UYvXl5Og-! z7DNZyQAX%T%t9UZ7UFCXL13tLW%Si{)63;ra~lK7Nu`6)if5t-vY^l&G!Nu1y$%^$ zn^4=o^tf(8ZUCYi6B2IHXk#IKS~0{Do&l>ZE_#y`skM)G;^pqM0j&VJ>(TeB&g+Ib zHJj+K{Jo5EK3FOT)i}Ib8gvR!Kbqxf15yBSvJHW<`pkN&V7Th2aicHn+?S5QmoJ5- z#--pE^%wR-se1y#Rfwje4VhjyQZ440mP9+_67wT(A-_@^&~_1LO3UhdPIXque1cpC zD`nkQP%5W{>X3B2I{HYOpiDU-?*IBGvGn6UyxS_vE#N8ik;ZkU(CikzI=kqQPu8uR zy#f7DH$hXrU2XSMOR@}zn{n+-ec@_@Ctgc`%2#Yb#lXOE`pA1C z;K_gV85VGV7${pNU9i;SNQXk&$3f6Um9C8`Yv~*7`rU5i0PL8)<6YG6r=ntVY7W`% z#*};Pua85ir-w*mnRE405-Rr#n}2ZS{%-|F>HpO+;uN{XC^7q62s|C?*Ye#J=wCVu BH>v;t literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-eps.png b/system/images/media/thumb-eps.png new file mode 100644 index 0000000000000000000000000000000000000000..487533dc199149bdd0135c298cdb94e8fcc2f745 GIT binary patch literal 2276 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 M9Xalrd)S}#e~OV literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-fla.png b/system/images/media/thumb-fla.png new file mode 100644 index 0000000000000000000000000000000000000000..ece67ffa32cbd35de8463eaa022b443f417b0a5e GIT binary patch literal 1724 zcmbu8i&N7F631~Q$kQUecv$?6)mp9!77>9#Fo+sOKqGAg0p&<-MK};n0Rgc|uZRl% zj8qRLC@o$Kh!yH7F(i6 z(Vos@J)Mn`F}->D?(pFFyz!$-!JpHbtk!5UK7XKhNT-RT64i?fN62`BT6vM#Q_f=x z=5+V?T;;;N#L$YUOdRcO(>dW zV%F^RHw@BCv5>8qsTT=TnB-JukJWBZqLa?ix_^<3%8UkNAN9?+bb4vAdvfB(Ar4-p ztQz6nyY!5*;Z^&UYxZ2rvg^Z}4_<=X@t7PmxpUctACNs#tNkzB0Y{txF z(I88;xNw%%y=1i%a{7#B^Q}Qv8nfr^xb){yK6z60S}u7c7~*JWv-;_9dN)QmG`GB5 zClX}$GyWKtCepf3QHW7wJZVyOgVXnIA8k}WH>}fYO(uic+$0k(S*??c3zhuAnWd!! zT37AZ$Z-mxOFeO#O8hV}PMe-=l}jvko5ZMpJ|-B@%#7*h^4Lu3w7QJjk5efw_fj5h z6U?kyq3~2JG^uD*BT^%_AiTTf!{BRQVBMxIv9Ismq51#XMxppuoy!b)CMnTT=OrbH z_8~`?y`AgvGqRexxkzcppG)Z*y9(|IYlWxRtn?Q`_RGFnELT9$hKcgOl2Z5`d^|54 zcK*;Se);F=z7;|EySj!OCsJDo2oR3WhH3Q#;FVMHZX8{mhN|&yEYbfMU%#!90z}7) zlqt~D?gj-V-s!0`JOylhtZ6?Fa#5@o*-xubHCvUMt&cTa`vYJP1b4%bbJGnD2g@Gk z7Q{UTUO5(9VO3@S@I+EXalTDRKjqQZIh}Lx-)WFU))$Q46lm1)Y0wMRdCpR(XhGF@ zA`>-0i(^0(gA0*=BDfs3H8}_-hlc*^klnPu6o&xG=txh#clF*%qhmk>gZw@(@PdzP z7pXbX6`luvno+s==U(Q4JUEC#M{au(mfmFbcdGA517pzc60(uaqtsFV~{_Xd4G-``CvgCpcZOlwVRFM3kuI}v-`{qQGmpflt zngA*U*OqCuQl$^71Ml^veVbX$NWE*Ra6yhO4cX zC&NMd=K?q3A`a?3&PwkYZrf%M8 zEIm?O`gNG-{UxvtUFxHjjA6i{-?J)!@C~>?@n%mfO6jL0yc=kI=Zv`q;(1QtdtmiY zKJiH?1GT!yJ-FrBs12p`M#h8Z&U-+%t zqolALgvNtSMcDVjnt>{8S8l|0&DD!o53dgg-V=7gHbS-cGs_lk-u-Z(Ix1rij0O#* zk9p11-_fzPkAHwPZBmYe?EEf2vaK77Li#I{c05ZizfU=NShH#O4A5Vo_%!g^bnf8h z?L}EI@BzB6`ZJ4f_Tcji4VTua>a*v*nzsE!`%6IR6wZ5Oxc*;C&dAAXSIkwg{@sg? zuBbN3I%u#5rl>IMm?GZma9-<{-F<*Q^I-Fr{7^|_P}>RKN;Ko*pD5w>NPT~Dz>O83 pE+QD0Ag(;L5%Cv({QsjSoym>LkwptI)S)%y)bWJaH!+t{{{w8thZO(- literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-flv.png b/system/images/media/thumb-flv.png new file mode 100644 index 0000000000000000000000000000000000000000..1dafcd41b63bb5b0177e7bc81578f2ccb56b42f5 GIT binary patch literal 1720 zcmbtTi&N6+8n#*8EQeTkEAx0`I=jd1vd2BPZkcIA){0x}>6oHr$L7bO`MXf`(u z219CODE7{+RMgL#YPHF24>~&WRlShmC9hyzZn(lbvjl# zS+CdkyzEe}t`ufHpcmp>>R7@--e6xhy$DZ8PsvG%Z+*h9t14$wOMa`MbhWn%2Dq7t zcgM#h1HG?SmzUO*N-7~|sJ}-d5@y7q7G@PMTAq#$3(}$ykLhKT<8RuZHq?*{Q;?x6 zON+H-k4kcIf`NV#4m&F3tJc=~x?Wb55SxChec9SPJ~rC>#|tK{w7f7sJr=2WKRq`i z&qm+N!z9itX4D(1?BqBRf3P?^Lnaydu)4A^H_KsGO-;xeYAco&=5tdMrYFXq)K)%k zX`GNsmKPVKBchkjnz~=Kn@lElB~>gKdcvq`sHrH;&1_`Vj0pJ*O0nqmATB=YaXFb? zMaxNz9}&E+E-e%ea`UiBzoKI|RO`)k%(pTrjhJUN8sAS%dVKzMKJeuEzUQF<$>Xb& z^9ccghyHy3$w8`RwPuQDDZZNn^J#({-JHTGJ2f9F>wD=W4gUiu<1Hp>{fv_`MHzui zJAr(}uVCy4{>tAt>7s9A<(BHCnCqXpIIZdqr1^rMwy*0pn2VL%Yov?+!VixM3T(JT ze-1+4_J3@s2-wz$+nc<`g?0VgTOJc3`;xGH)9moth)W__21= zpTbC|amTOQAkiD;*u>Y$-~#g_(q5S+b)O?4la>c*HShS|vp>mm?mn~Qg$3IgW?GbI zIwlxq-aPYyF75G;$?KtE@SV4~gHRUHNg!A4DVnOLZeyivHG2zzyL1RGI7#@L_Zk(K z8Y^X^#RRu2r02Neuj2Hr9Pj`3S2yNES}kObkAwVHT~!-CaSO+wOIEJ8uiJ-;5D|?I z^&JpIpnX^K5HBQ7@ylUY0kpJ#t-cgQ(MBNbWzH}p`orKh^W zd4Ik-_0iXwF$OvIYf4DCZsXL)GKl)q4)@OUXM(p5pMawq=5WOxau8R%zSeH^wr0#h zFmllHLAa@^`3C8EW&_1R8EgmFFz5f{4Zz}oHNx(0IjdcZux4yRW@a3+cQ?|h2W+|y zC_-DDgWfz0gK{dJVr6cT(KQbB7}AgA&|p%t`&4t>u(+u`G1Quo0dbapBdoY_HF^79 zY=fo0H_3Qx9Qi4nz;d`#0r|=NNLw$)Z{KkSm^O9I9Q7ryKUt{A_=P;vU4Z-Ui!U8m z)CW}wTNgaw9?Hc?>hk)Ku9l)Is!|)GvNxQS`~q>(A{^%yWYGO zXpZ_f%JEcc8fOmT`DE>8cHA-jERzw6P@8fodSNpM@tQx8r>Q{@que z*Y*IhLCGP+`?!^++goioMNfc3h$8X`;Fqki?V~Mhr<1s;=Lo;=Fk6oQt8Inoz76aO zl0>@ja;ulG!0SafNosE?I8Ss7$XUmAqI|(Ybcw1M(P1I|USqzexw7AOnV45kZEobsEM9 z)y+&`y6M^>PP-uBu%&>2<%v+GSWrM1tH8>S0tI~czMp%Ye}X5ux!-%vJ@+J^?=9FB z72(c*m(SsF+{L1>7!GHF%DEOTWHWyy7-@FnrXZ0Bhf`U+m`vcY=LPbZh)|B!-Fi$^i#?s7W*}Ys1Rt2DQkwxrFtYm~vjq$}s4OFl z)nonB6d)P>5P7W!M@PvY&A7IoIBoLICZrfSGL0`Kqub^xBCdo1Ql0@ zB{qWeW-w3CS*`HyAhFK?T87C;9adn1(|{hDp%NOw=p22kk5E6uGg^RooJwm3M<0O0 zP2hAJJc&ZN5p+*dts`VE3RO?=>{fWT9XcSbc}8^D$V+Ct&;)D$Cffcbs|Sh92Vfqj zf41P~J7DJ+HU5h3o1*sAVL2$Qw~|%|bpV_3;cynZi^CAf$==E5aq^|e(ledgT?-qx zFY`1Fxd8qb>P0ar!FX1+1BG5KCUzk1D$c8fKz`r3d82 z_PkVU>v3bP_ZTQwey?>&ImQ?(A(`J1v}R;SD|z{D+-FbyM4re^rJ{vz?(r7g5Lgx= ziM-Sxr;IBUR4OELZo#so?mQ%vS24o(SSK3Ia7sUauG=zopbt9j60nu_-)wQU=`6=p z#*o%ONn6=mNLnvlF7PZJ*m6WQcDM_1+2kV)9Ai$A!Tre1E9|g4UsedNDI|rwf@NyC z&M9Y;`5tRUH6f~fNb;)XLWycjC(tNwJuJ@oP&6d?qd~S|gO{f|T_u+0=2KDp7=Z{c zHP~~6Z1I=9GG);xT;CPKNHjWkEqa7&%c$twy+Sz5$kK!S(2d-RPc^}4y*9l%Le+$r zFXh3-2;VhzF1c!T8INuIXrlex2A;2%u%qK#mz^K81ts@GPwiJon~PZPSlQ<+)g)OB zkGQ^GFAt<`KE0Zqx~)Mo#e=^IVh^UR&s-ylke(Q_9z*y+hbFKs+h%G6-zwEBVT!$o z73%{_;_>QG)g8qA4cVTpSo+qT#2qIlGxP$PQe75XRk(_MSa?NC1iX(9zWuSRfCZwy zz+$?1)y-oas>xEE0{w6eR28kZY-Y^O|@mMNvT%ynvwGL_h)oQBV*B{^PKlN=Q-y+=Pmy3+?lP` zyRFU4%(g?{}He;Z^{lmbIPoR=(J#F z_OFodLc#;T8SMF;F%6q0Rn0PDM~C!AV=;|vGMSk2<$TJ7&S0Rg$WCH=UvXv(M&oe| z>c1G&P4f6DZ11;&J@R#J7HM=ry{6LX?oLk{O(s7KYI<3^ZZN!^W0y0j=Z6ObDrGPZ z9XQmdGa61{P{K8pQn$XW)gbwEoK?BeWYp;O%{#&W5(2F0(L|qCQ26v#PfNRV`EDa#k#hPIXxp^IXB#YVR&F(rF=R=E2I!pCh*at zLq8C(;x$$L7>*)a3c>Z?B9GsrPEwa;2o7s>Y2lYC;vX}#n9-p|9-F0D!7hjt>+3SD z<_&kYd!B!dNMI}EzfsA3B0(E}jx1ZMpPPL)LwmxcMvM$*5DDEv{+v?rl0Eaq9r0Z= zv&|il08sdi8R@L3GMu_;fal1b4^4EmK?O&RQl9QR+hD)xf(z7o-~HH4h@t-kPhYgF zy%6X9RLRL-?lZ&@;;IkaAkJpV(iMRmHB0uQJ+E5F`m;MN{pqHS1ODeV@Vb7Pa0ixs zF0Dp0am2YOg*1^}lr!X>BpyIqJshrYe(T}fefaS>qbMeCB8;);Fc00()(CxR!GVEX zg%cqjI)Bpf8&eUuu)3mKvGo$SoB)1*q}ST*L3}Z^9I${EW=FJN?d1URXQFq;_kvv0 znk&6iu4lmu9s>C`6uJCvadTKq$W}^^KU{9EUBz^~eVA_t6O8o$cICq_KM@fNPk4ci zUZCOK2wgag&i zvQ}I}9d4C(=1m-8IO~^6Tk=dQK#eI%_ss8e0O8uwb{EotW6y3Su)fvfPU_^Eq%>4X zvk?K*#zBu+BV#jrH^&%K$RB)n;`pw0fIn|`0?$GVjz-^cmU!&d4BC$D*FAv>NR|{J znBGa28l@XSSOAEvBDF)&VhGhTxK1S}0t?G_#-DBl61LL=JX+r*ukgup zC<;{IVGgOHZ*T6g4KJ15_HnwM<8ug)xn@-+#$|BCbrPOmDNXs+hG8ev<-rLGw_z8j z&ueC1@Rl;t4i^IY=RRqDWkz1k=Rrwu8gg#L@g4voulU(Hy*?eote+zwfmMmVuo{wO z*~eO5N1y7;R|b_u5G+0zR%4NG4ta-`clec8+dYD%`a0-;B8P*LtL}4tS)-osMl1bY z{#_9cb|1cN z#hn+mAqF{c$FEsAGhQGOk)x%WE{o#FF8!Z@&%$@`K6zi~x$q!^crpSmTLCkb%a);g z;5_sY%R) z6TgD0bqOEZy!L@3;M{w*SDqpf;-6`)jj;Ib7~XrU&>e!uyifA#Jd=WbM6%~V^63Hc zc4)Xc^KxNs?exP0uFpX$g-`+gG8SrY!Hm%qhDv`+Xl*D1GHtC7Tkk~p;9Emmuq95m zqL7BIN9_tTGYQ&YFR(~p?Qs5<)0V`(DkNfo_u_0P2x%nG{oe2U;i4pu*QBxvYblLR zACbTUbTzd_+Y#djf^6A!dx0{iAj_kE1rdhYV7Q(CnB$dlmGk>RdRL+q2x)&Eie1Tk|L|iu4!I4Q#72}OK35Pdu`Xmk4IosSEequ@_$0FaEHw6CO2hj-^G z-gdSAJJ;K$Y>%A_?&%sUxuYoU>-^YU);r(!d!Ss>HkSndMrn+nbHUZnjBT&gOun>H zE{d80G9v+yOoX`9Z!uEoCnp-t-Rd{8bq)?&7@DkApgbZMYF~L-!Qos&UQJWt_ zIrN6q0uZtain!$6+A^Z9@vM5`Bq)9ZfKXKoWrLHWg}czzqPVqu8)6h{mG9s{zoBWl zzMpAw0n7YnPJhS4&8}x!RXMM0%n! qMZ%hBt)4eUv;5~z;s2W}Ci#(1k>PEXhj!)u%|HUr1+<)sh5ip8P$X{v literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-gam.png b/system/images/media/thumb-gam.png new file mode 100644 index 0000000000000000000000000000000000000000..56ce110091d467f1a36262c8dbdcdacd38fa7438 GIT binary patch literal 3203 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 h5B=|o&;ON$t(f#G&@SlYq6zHw literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-gbr.png b/system/images/media/thumb-gbr.png new file mode 100644 index 0000000000000000000000000000000000000000..631deb51a1e6a61bdbd76f6032132b83c1f27637 GIT binary patch literal 2849 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-gif.png b/system/images/media/thumb-gif.png new file mode 100644 index 0000000000000000000000000000000000000000..7fc2b73d1b340f1003b8991d471a309116fa2755 GIT binary patch literal 1751 zcmbtT2~$&t8jVld*M`Bi3Q~PIG$S+aQmue(-AEA<5Y&hWl!R3gkfm%1P!L5_7D)&d z6$v~cfwBaU00AVTrU{nS5D5@8vRJl|Eu&Br|Jq7+&rmCzo zh(xlVgCRa&78Iy@^iX~|N*)%f9~zQ}hbas0svbRpxUWUAJFU9 zU|E?WIa%AyRx{`(r4q=<(2tDBV`7Ype`z~dx@XU1AwL7ebmg7f@~hFhSFg0Jc9Tp7 zWM{(*3ySz_n#M-u%^Qk@cpxX+@b;}?dRiV4ZkU)*Bqf5SrSR;mwx?TN$AI6zQ_(7w zxAVc05{S<;uCA&oD&YBfXmHRlIjL!FRnzGZkEed}Uywoua<9vyufX$j`r%<9E7K^I zYMPo1q6w1{Fadx`q0n}8C{j~Ea)~lGSHo;pBqV5>8ujDjKxU?XcnFqAATC#V`?jvH zPd_#a4GaJzqPpgZJTd}EPY0=`+RjcOJx%xGg<)z+)6!zr=~PuT{kXs&p4Kp#aspmm zQ=`nkrLL>fG&d^?3zT_z%DZ=3lY5)igOZB%aQ7#PW$%`V!T^U)XPx3PpIJG0qA}mx z*zX$Ewx@QDqpLfmlVmA4I*DoDOjWsSZ`WT(2dJ49OZvuY<6JiRfIj&7#f$4H`v(J9 zQtDvoB?%`nN&BZT^w{~6F<~^1_70+Sp{Mt5jGJ?}c+2(ifzGe+Tr!h+;OxCkX3fHr zXHsNvdIa`ehDNZW zc7M+9eyUht-@eqlIi0+6V)V?2HB6G={U^)&vOWmx50B#GD;L7wULe{&4CXi+`4R@j z!y(QZhMEEIOJ3iy*UgT8{qbE=$Lrbgje?h24H7$F&US{Jf(faqOU28}&PePF=GJJolazOaXX9AX@hW; z!nfNe+>}CE+0FzUhS6yicZW?jGi-66d*w~MXCVI+)m+8xYqX^@eGcu}>$Gvqt4#bm zrT7!;qLlGH^t6cLe-9)(N^iy)22+s*E|>S)w;v@v=Xj&0Gket0@bu@04(_J66AG^= z18$Vobpx@sxS%1Q>o(^x6V6u;#Vr`+z?>CEUK@&iNm$EF`?aJHj9jTg`*!_~`Gw!fT574+`vZgf1D zhAu;I?mP)4&h2FQeeI8Fv@ex07OO8{+E0+PYmSc*>@~N1H_0Dw<&hozF|SS_U6ZaB zzu(fdnv14(eM2hh+u?TXV-{wfU}Fybv9=dl8_YmrC9wNzCVA@)+Q-M4$i-VTm1{vE zQOUl3sF;U#eB(9Z%br!&xS)oIkJ&yyCAj-Y{D&4RdA#3#eNe6Lt`>QERuIQn;>Q&e z`;hTg9haB$`YoumkKBtF4)oVIu%^YO$qvBDla%UXhWF|lO)14wMpWZVuxeH$E*efXM5 z=dE!e+x)n4Z}sj@ZYU6_p8E8w2l29=9oA>x_7i7Tie?CbkRSQfKPdVCrMYXyO}9ky Ui2D8?!v7|)r~N!y&|x?K2bQ$5@c;k- literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-gpx.png b/system/images/media/thumb-gpx.png new file mode 100644 index 0000000000000000000000000000000000000000..d54b9156aa7417fe0f692ef58ce4b9b0994b0e54 GIT binary patch literal 2972 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 ZT%Kj)lJoX*QL#TTT}=axYIV!c{{uox7zzLY literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-gz.png b/system/images/media/thumb-gz.png new file mode 100644 index 0000000000000000000000000000000000000000..83b10535a7bf2872127d5d117183fea9700d30b5 GIT binary patch literal 2134 zcmbtU`BxGM9;a!m(b9IO9IbJ4oodQVvs%~6h{?(IwzM`=(=;`&Jf7rjR<37iptd?? zWQ~{^9;D)}ln0;)C^rfSigF2fA_&Mh`!CGrz0dm`?|pvzzHiYDZxrL1j%~=1>qibr-ebl&D6NaZ)tEcer zf<^7tmO`nn!cRBRW+@^CZcZB8MSSvwdgt?K_w)jS%AXL*lX@pKI^E3{Ts49HeuA6a zH<>d?`@$97YQd%UPX@M*5f@~=Y~lR!dSVahGh+@rD}~^gg~RmqO^t9x`Ml$m)2xT6_PO* z+=lOEFJSl*%&eq=%D>l!fA*DHN1iFgGN%_;UXD#=qRCwODq&vM$`r_z>USh=Oc#+U zS$fz-9Og?tQg~ly#KjoKZRF_Fuhi&H;<#Wjf0$l^VHS-r6dUTI5&EW9Hz|}OX#75o zuzZxI(QSRA^2MtgGxF8{4$`P%MN}uDoh8WmN=32eEAi6|$x;|| z6Rj#=jZc$BiWd6Z;EcEo$7-PP!cpUo`X*kFvp|cmLN% zGCy`}A+TGYFo1NS*+4?bF|<)l%1L;$Hyc{Pdk#M63$m~vK$ZlhH%)v`0ILo2(j>R@ zsK2bHJKr=EC2^3G@Lr>ARJplhl@+{NV;0~KjoWsCF#qWCUwRLcOM|UU znYg-w!)+k{TBsZxO`lY&_at0CCt&Q4B;8f6kxS;veu(wKu?9&KQ-8$(qdCjMrq>Cq zLnZ7=ZMnK1TtbM_9ppz|JbGDKUGw%3Fxw?VL?5ecJ-#@{+8)_O-M;h@Y#Lc&u75ww4{X&3thBIH62Ui5A1oO{K! znz)ElA4&Iz8V;PUH@%B_i-aKZ3$IKMhu>(kEi@u|8AHx=6pBMF7(Ko3*@vxBsK-BE z0@|l*hT5Hq?>tGGz#`Mj&(s1H1eVr>MPiO)Q7i0)v& zwYh%~5x6tb9r$Ciaf(|U`ms-1MIxoz3F2Q-A`S6EXHgsYhOv#~wcP8NaN{I=&HE^txE4d~>DG?rzVlO_7lKD$~q2 z&ayMt@s3}O?b79jVoRC5HC#8DxJP=d%>{#x2TiP(raLGft$W7ctpsmrp%y}e9wf~qST<09 zHrREw1PSqKN@`AVh>hZAWfD)t#$oUl|1Rv7l)6SI5w%a#ENj9qn>hH7nV6Mjc=CGQ zsN*J`K3q0gI~zX$wJz^@-kz2Es3$C>5$bVozy9ZpqONmdjZ=J~H^^*S_V+i-4@E}b zHBZQ?Fn^fKeM+aZ;UQf-6FWTl-By9dzwSuco?V+G#{25?9xuK*HS;{k&Q{l$#1bYp zgFwEnEl5a>;y{8)4kM<~=KKY?i-0=n0pnZ8$W7wyZ4H{U6@;wu#HA8kcjM^MArSOp zYZI~KA7gmGyqkdz*Su|C-1%F!sv;Z#ys)pPEN-eigEfB~d+BIoW}rh}jf_b{00Li~ zMqnaxb?RItV~ZIro7w0mEK?L<51QnJ?lh#T2qEU5WwhVr{F(`b*E_`^)Ps`QTdt{x zsJ}t=l|H7XM5MFjx*bXsAZ0fhHWc$mf^#p{B4R6@Bw=1**-t?!=1&ZqEjfsO%QI*K zy8orZHjhfEsKSzeQ1TTHBDt@t?h(7YPrINk20iGohBrrcU}kF6*Hdh_H0jkZyz{8( z1=7aSg7Sp4C}yfwA05aLUVj36_J&;_5bPO1-5@pfiRZ%(6dX~7PBF8C9d`eTNBRFL Zb^46v(w?8mbc)#@+0EJeTC-C~&hIt_seJ$d literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-gzip.png b/system/images/media/thumb-gzip.png new file mode 100644 index 0000000000000000000000000000000000000000..bb7f0c66c49bd7926207c091cb93258fb88eaa79 GIT binary patch literal 2451 zcmcJR`8yN}7soBrjZ{~ZmMcvX>5^NN+vc5W%AgR+ZmiitQg*qc8xxhWE6ZfbmNl}E z7R%U%jIlIXvyL%j#)KK;y!s>V=XuU^&iOv)dA{d|^TT;+sIR?a+ktIDLP9%qFlZwo zAz{L=5EI>0s_a@eHWR}6(v3?(LS@ks>vmfk$~4P&Js;KPOh z1UzW8gpGAz(O`7}$gZ$F2`x^*XoD6-~ff4|eVBm}cdkoq0e2c^TcAGyW*VUx3xYM^bD=8~n(ctU0)uHli-yr+SY-i= z3M)*Q{si4PXto7<28kN7`|6R zhYw8F!&C!|mqW8XbpHeMUtsDZOw_>YBJ@N6cLu0Y(CH6dAu#X?I{l!{0~Q9L-5X|` z;pZ&ObpZ7#wBG}2B#f3o4<2|N7<&hc1F+B!L%A^h88{Qb`3|k_z?}tV7p$?N_Zje( zVU`T^Oqi&J!4&8WfRVS*<^^4k;Ae*;n|*$a&0O1Y*}sNeOw|Mbb*_KFfL1o0atS zq^G;&&zEb~`7Ky`o*=G7j|?riPn-KXe>h*YO?dOl{yX;N1Q`tbWqH)}af1$(oULT~ zsvcou+p&G4jPioe`1lfmK5zcsZiCQ^$dEB&4eNy9n*IrY8T^FCixF$$RC%1WM)8_O zrkX6H7i*BHg?U-SGJo-{&Fw&&pjKXP;@D_x7GouXNOnPb6;)r5bM{^(j@+B&@B}Ai zTSxm<@VOTr4Kjx&<^$TwWbtZmk9jI0+paFuTHQOID7v)PPMtUs{c!K_&(++>-P@2L!jKAs4L7RcKqgWQj*syxBeVW z&g&Tq72HkPIT>fGG~r$}6Bb;dQYpH`Qa>BbbUky?&6NLv_WZN+5| ziz^Wh9;#Q@DE%wTHja|x{L)I#gk0zI@%~r2>#D}DD$F&FlICnbD{_nEQ&Cd!`}vQp z-i*cF`Ju``z#SZ-T8te5AR3M zJgL~5atcLx?S~UPiRB&1*E~b83V0nhbo-4i#g~6&ux|ozI!DSZdMoS5=eq9)?Al*ZqaC17 z|2x{uC)DWBdXh#uovl&_K>zxyX2lI(R^@4>G+6B)@u`Tj_|Jc0R zhl#DAb~0INCLI4%*?iP=E%76{P|Ee?OLEE~BTinGnpBV^=9VuZ0;?F~Rw_N>DR_PE zQ{d8bydmE`$w4H15c*^LAQ%N2&9W5!oNmBBfVE zaxt+~p{g2v**zwD{%&HIQjiq>&yZ`=H;-FClpsks(xzwrc-7}_vz2P%;)rBsdp_`& zX%&6HR8>n&BIB|J@|_A7(*mb&AwwMtqzi3!kx;tZDOWJk8CT@CQ2lRs@J-NagjbHn zqORWcH9M3D4^`caBb_%5s6IUQkN z{vfD{k<2|onAfuVi*b0Z#0GIHr_wSIgH|RKRvIhL9Imve^J#vJ>e_TpWy+t!O$G;O zCwmZ=XNuE97AfU}Q@dNUDZlH+qBTU!AIxP z*IZ5MR;VzItVZ4_k4&X2&5A0&Kbx$U7$}Pg!#hx~X6H6$2xLN#N8RL1t#2iWIku-u znpRY~iRu_~s>c@&UPPQ~Rx8r;>LX%*x;vZa_GyYwrOF`KT2 zkLDIOu02PM#0+$=wuEUEQhMX{8w@#rYbuK=Z10E{8UEL!Xx#;ulX%)P&DJUWT^=5F zr!%+@SG;!9qrbt#*lNg&Gwyx~Ii7Dax`$eC9;n{jFjMR1wT9=J>&5<|jIeh|7`8RV z(zUpam&Ma!8?Tt XW32+(KlmIL|K+4}RUch^**5I|P00{C literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-hqz.png b/system/images/media/thumb-hqz.png new file mode 100644 index 0000000000000000000000000000000000000000..2589c16e210052fff522e07461eb577677f33ae4 GIT binary patch literal 2604 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 iO9}ty!Ts>I3go|d|C|`i5uJ!L-k z1O*=Vlbs@CRgW}6fmyFtak&aGA7!!3X0zhfEkyxSJ3MTVNZ^Ec<&!7+>1l0WpR)Qf zR#{=vYIWmd&@UHMO-`WMP^M))e)TGlv=ORril@W&F_+O#$ zGcc73U5M6AOdwfTwId@Y3^SrA9D7j#f+medClYCdLYVrSVR;EoPJ)wY@NbFG*$9kX zsh^!y*4HcY^N_1oFUxB4(i2XS?Foq+qY2kd6QlbQKB@xy--9rT3n>8tu@N!(0PjdG96>H6?wOHqoXFZ z8m3Xz?d|#xACTN!6_=x*npC&9$JF)=2k61`if zn;6%>e{Wb?Qr6WOWit5JIPB3QWo?bR?WH_DUDMfVSX`86WEke8@=Gc3#TevTjA@Io0DYbI+}VE z!?AvNiQTG-|IVP=X*>LxC(v=;n9=;rzWRO^u9ejhK7gPS_$#LzJ( ziu8GOtO9Qb_i!_QQ0I4dRaSL=WksrkHXA$jzNsClexMWJy3eFaY_-7BA2#tR<=Cfe zU$FbZT-*kBAeg^I$J|#1)Z7T<;ecM8UBo-TQ4?W9I^&}x7LyC6^&Xqb?1Vf9(J_#OHHMtI+u$<|ohP$f(SPc$6vf5IdcP$8R};MaO3$2k&D={< n=8Pm=7l+~g&Bw3KYRtOzC9Oea&EHn+T=fBdM|>L&on`zVFA<`m literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-icns.png b/system/images/media/thumb-icns.png new file mode 100644 index 0000000000000000000000000000000000000000..4d7ac392ee88eab62ded82308add3a0699d6650c GIT binary patch literal 3212 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 VS9*H*x43_Sp}w(RnXcXQ{{gDb!N33j literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-ics.png b/system/images/media/thumb-ics.png new file mode 100644 index 0000000000000000000000000000000000000000..2ddc380f32bda47b1565a040e844b9d71bc6da2a GIT binary patch literal 2787 zcmcJQ_ct317su5qooLb0k(WGA`?RG`YgB8zv=miUN-0HcqLkXJX^b{@)ZQzx#f;c9 zB*Y#;h>-|l2bGp2Ue7u2zwq31?z!jt`Qe^>zUQ8M!(YGBJby;)3x7`Kde|q0+d)lNjf0~q_AJwi=R}|KFwlI;fgHR>ZbntZ(-M;+LXVG+*H%|@5@Ub=`W5c);p8tk{dwRw1WI59l9wXt5Bo4LEQv$VMI#l>!AiQ@0{ArTtP zWFE2E>hEX?Pfhsl>%PCYH$5@lkH!4l-#_^I^Sh5*9kOJw7mF${ z#&)#!VLBl0pstq2PfP)h^W2r5v75fv0(!#R*Eb{zZbuj`K5;#Yk9_;NI!1avu z_btwmC=2sM!bF&tE55s{sira~5r#zMrbK-!&CQ^#EYD6)<|f6(eDy2I$>?rvYO6;f z(v!N|n#=OCkcgb|;X!;)*X$Ib4P8rFnCt8AEXYWnpCR^QIyxHBXk=+&W(s+ploTE^ zJu!9@RA|V-ahj>Eu4d><+$1q93C}sO^xKX*g0}p(0o59AIb;sC?(_AGxq$5vQyRtlRK+IRM-j zPnq3!10#&hlqbU)3_r9@Mkz44M?ndj$W5-vNHr#5`G&CURhR~#0OEPoIzYTxb^dk7 zyO1-SmV(fC08-QRpQbk^P?u(~MZtd>^I-rm++1C;@x%aglu#Rdj+1^4s6vuA&Rc7D z{b+5`0pO(b0o|dSSj9n|qqS={k9^Et0FV%1tIH8Zi8i~3I?h|CIs&xOlods6aSCio!;Wfx< zRQ%-8;A=9Q-&tHEKf950dt1tIP^Z{qDBNk7>U9g zFy3xH^{Y6y-^rFp#RkzATLf-TlqN8u}|Yn=MLy9EiF`nOz^@d(|Ugajx> z`lj=NrNmsHx)4+X_OMt-T&@El;S^}+tP7b6>)R-sV+FV~vbgj3mbhrfV0?L=Ke-Me z#{&-M2^epR^`Jevq=mAxez*4pDTfk~A0(V_2`yzHLAS5G0t~7?(Xr9(-I6nkFmour zzvjKr&7x1r!GK`Nm)QhS%$h$hL_72Gh8oWkK#>z7(FfnJ5#mfcwNlz$}$VsI$2DM!z_y6 zT&ir<%}uXb$=<)KXmba$$~FB*QfhgsZsDEru#H^-UVBvNp;SaE=evUQcMp}qAG#;0T+->GQ45`1J|L&sn0E; zVu%U|g*P-q@aLqrdrxe&g`0eqGn87^(_#3d2c&@l4uJgbAMF2plx^+!B#69KQCA@V zm4uzEvh{?D4<6co#hbwy{1`zTZ`IP4j3blOeYy$s94*AJenCBrQ*5UUsC&!=9 z9`-6NNRhuM9+J>nA*o#amTZzLX!@Xv1 z2}aF^BJp%tTnM69`B=~LSd$r8#ZJE*x*JFL5W~IjbM1U$F1LHuP)_u$)7H^bR({M` za#9s{o-b_=zDU;YS~~T!EA4KG@|z=FUTEed~KlVNO>gj3=;#zpwWNs&Q3C7Kc9=cX_jwZO#@SlEQ;t=b5~A6gjyq z;rr+t{)eglYqljOuX|lY`$~ zC`7n(3k8>P*+vj*1$fJZJEBFou`YB_>BLip!XeVq=^eNAHK-jGoQ|Y*6vR9-HC}ig z!5-Qm$`S;8ZN0c4D^Gv9i~0{G>KRE^TuynEBD;DCyZ%kzgZKS$)V<(3u+3t<2mgOB zyUZoPlsqt;$HyOB9AkGPT4-$-q|?mP{%#03Mr0}!{Ch9-|D9){uaV*er9 M8n4umPd^0z4{2JAvH$=8 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-iff.png b/system/images/media/thumb-iff.png new file mode 100644 index 0000000000000000000000000000000000000000..09a2cecef34f29850b2f38804cc52624b64f30f9 GIT binary patch literal 601 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 HypQ}J-ByGi literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-jpg.png b/system/images/media/thumb-jpg.png new file mode 100644 index 0000000000000000000000000000000000000000..b2649a89aa8cd3d53310fac13aec6e1572d68520 GIT binary patch literal 2435 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-json.png b/system/images/media/thumb-json.png new file mode 100644 index 0000000000000000000000000000000000000000..bf8c903ca3cc8d3d0db0062db0b7fbacc0ba6870 GIT binary patch literal 7818 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 h|E7A1D_5}z{XfJU+|mF5 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-jsp.png b/system/images/media/thumb-jsp.png new file mode 100644 index 0000000000000000000000000000000000000000..c45ac8ecfbc98f7e0f4be032cc3486b6c8a964f8 GIT binary patch literal 2498 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 M9(Ay09=SySA2)q!HUIzs literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-key.png b/system/images/media/thumb-key.png new file mode 100644 index 0000000000000000000000000000000000000000..f1826a3870d18c7b54260f203b3b6b83841a1f72 GIT binary patch literal 2130 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 F{|C+pi`W1F literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-lnk.png b/system/images/media/thumb-lnk.png new file mode 100644 index 0000000000000000000000000000000000000000..2113331acd46d0c925b6a4f8f0b0d8d58fcc0393 GIT binary patch literal 1971 zcmbtUi#t^5AD@09BO)nji?h4RQ+k@s?zXi{?b)P}nrgB(`*jB&ryjI)tZ}TpB|Ow(DDOMfCR!{nQIk3+8VqnlzQ|RLd~nK3k1Udj7}YQ zKEgfw8`sTMtxzDB{IG~{#P_0nVnVG}Pi;MS{}%YNb-GQVGk-xE|-Y=iF5i z3F7U=bv>)(^Hm}d?^?1FMG;RAl~l@gcT+4ZD3_PzA3h)#&Ldu)DxnbZ@fOzBD7jqZ zybnqbk_`=^mjltj0LA>gN+3`oh)OP3EHCp?t|>7Li;h;}I4>zlHa3QaFtE5-EGbdk z_DKBrvEW{bY-AXrT~N%-@K{VKha-8xR>@>2BNz<{k$?QiOZ^=U3l%jqDCXvP*HaXq zXJzl+BL0`K=qP?+0WU38`ueqEX;Je0x#YzQeqJ8p=ZmM`M1!ti3GuwtR7rO??`jf$ z>lU7oA*?8ubUl-Qno{z3XfR#;^r>QLN!-zahKHe{Ay`zTsHH{J(kvSu<|VTPaH+Jv zU;OMD784`?^hw(PQuKEdKRa98*(oR~5w}0aZ=?yz{zNX)_yq<0>^riN5nf7)^vxUT zAP2v5TQ)i>tgS@@{N>ZrviI+WI zer~R?vQqq{6N`_N4h%@TxyxS6yDcTAZvgL~(~@_5MLrFez5V%*cJX8^`tPQ`KYcdRt7W_+IT?mcBf->505dI1FDanrUzo(;7AMo!Qbwj zSP1K#?8}I3ibhvGh(x@`TwHxS+pGU#oH^Zexg3Z!vs7i|hBO_v;QuL!Y2q1)zgrO-_>(q8tL^*#c~T%g-Dnz`Gq{SoFv zUjrI5&wX!w6zVurx%BD~NjJ+Fjv^g~cpFO1i?zT(reL`s3~~*;Z@9LS1*C#$b34AE z1v7@Gp8@0$=+oU7+Gec}9(_ET&jP}kjeD#U&BykZ1Ag|AZb5p?A-%zdnm+~n=G(;j zgNqD%BiI)T4qKR0O)P*Nm7@Jo?nZC~G<9wEUisA_AlKfGqTv`CC+sGL9Xk(QsQ3YP zn_B}?naQYA?bgYBlbP%DDb9>cz#n|IZCAS>gbIzAr?^r!S?(kYmpiSuv(4!->BKA( zS|4}z#%z2Z)1+80%fzy3PjT=b_C|OXg&KjWYTV-MOqw3GKhK&>g<7qhYJ;}*o51@i z5DZME;{hi>>ro4~Yf`5=f)W(OR%XAKr3N+4I#w98GED*b`l!e0}&?`408c z>~Pj}G~DVs*63dq1Q{ewI<*30-~6f}Pd&x64vp&Lm=`M|H1}vJe3F=DYuUN^ExE7HV z!6|Vm1#|k@8)%+!+dm)Im@|1)lPImOF&hfl$7ZKSGoNQ}LmF6a13JXwW?U<8V{v1V z*?Kqu3Z@qUQ%aXy{ebVc(VpVA;I5NWq>bfvVhu6VPtT^VYCa9D?qMbOYU<^?d+aS6 zj)RUIj%FsCrUh*t3OGoP`t}TTwVFfvHohDXvToWHYD9c<(ErNYIi^0P+MoNuqWD;? z=Mb_v@TK+5oP91i^q-tM(` Cg7r!O literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-log.png b/system/images/media/thumb-log.png new file mode 100644 index 0000000000000000000000000000000000000000..e36eff13b05c1a08431629169d2dd757dfd92d43 GIT binary patch literal 2762 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-m3u.png b/system/images/media/thumb-m3u.png new file mode 100644 index 0000000000000000000000000000000000000000..a7ed6c326acfb43248cda126d118a18a23b05592 GIT binary patch literal 2909 zcmcJQ`8U*y8^_&3C}k-viqGxp>dH;!3K92HgcKqQ5j2ULD}X;*|Ha7 zA2P;1V;M8UjC~k0W{erLZ@%60{TuFc&ig#)dHwJ{&-;hxJjqv_>{L|_sL095sXEx( zT$PjiDf3?`ZT&&ucTln)P2LZ@49dyXWo(z+QTQ?c6nfR}oLn{fAXQFoOW>7DuC|Vq zsLNF-ulfPhJncpI#1NS-5KA^UH@{%#Lt97EP7(ThCn2gNP8Dnn~BS}zYe4@52A|0nK5oH`Qr4b#PdHcg zGI>Iuh9Td^A+b~jMdCh<&L;QZ_+m*RdirWj-&X>eu*&j>qZik?A4g`|rYYkq%&dW_ zc_t@sn6%Cp1~y}s*}RgmxoX_PlhzTIKos9O4#Q0M%+ru_wDxI=TW!Bfb?-V~w8|Bf zj?a~k&(+`-RyKGWLUDR8A*6LAf0#70w$VAe!WM`cr{cVz5 zHLF{FEL% zY#}Yb?wcH1V(>(g z@3Yhi>Uzh_N_g9-KYaKBeAxTzVB+_QX$IS`aX7Ml6i-`6&eMuVXDcTbl6&xfcVSae z`0sO622YUCiH+_UYnWUbSzas0&S&;ba#Ow~%E>9EJJ^7(QB#a*{`uW@y3bfY%{D1I z%TsOtOo9Gks{p$AFzfglns*(GP+G;KdbtlSF@v8n+uPguGTCM3s$-@Qji#fYFr6bY z6w~{DYw2-AA#iw%%fFZKAC`u>U~68{imfvdSNY0&yz!ybZkTU_IT=)E;X~VD&c*N7 zB%?Z)vsY}B9t8wFX11B+w2BnQ=tBh}MJW2B!dKINqOvawvuXE3nocjWV0PT*y z<{I}hD-YY(1I4MuI67z7)z)V0X?Shj2fzU$f2|02iKqHTRB8G6Xq^8sP(xmL+NVEB zp%A3`DXN#`s3EQGg_?JZcC8<1Xr-osuk;mL#vY#)Q_PtbT;Sd>y-jhLz zQ7HG`jWlSoQJV!mt5)xUD|&+^3Ob%K$_xATqL2+fXWW9$C;h|%xM zK7|0f93WAOzlG7$OSiy)gcG+H-RSSL^2Bh}n%xJqy&ct&M)r?u;<{3;zyqE=CG~so zk>YuyG+*~0w)ZN+<5>@2Kyb?MXBW&I3S+Z)0BD&=_R5g+R*8j>N`hBnJZF%%*h@E4 zfOrtn(sJkOVzI-ex7*3)xIm*UL3p-DF#^YFp<}t|~+=YF1 znd72$y}<&f5q(!py~Fm`tk57~wz`79wcE0#-5a10*wotE%4yEnFSZWJY{|Zi@=w#gCZDW)YQ4a! z))N+UVfM*yE}1TxvH;(eD#~6)3UCs{qB=w7iam5Ug93_x#j2z&Yj6FGG>`|#75kd~ zqbdlS#jaCeqx6fXx}KFi`V8xiVBIz3#%JqkfMUAidHOqbq?S#f)_K^jVfutK_4fd0 z)4s_wwrU6fll)P7-k3rw-c|MuRXK_Buv!|)I|9Z+B#K1puvphb*Zjm=K%hP}$6+Mm~_aw#!t{fX|?NL9(sA}zs zZFR~d0PK2nApZFNs4KdYc^A|^i!8|KHW*L`F6YKndLr_9X%~+{bI1Lxn_mEEph1Xg z?F{C?m=}$q74Kp;kRwsA4%VbJDKBoR;-9jF<{s!<)Ej>U`<7v5&m?p{T`LMHq|HF{ z%nVAa`#1pm-1jfG8;>YOX=c!;lQM5|8)*=c)}*kQSnX_l);`Zn8ZoXQEj2j_=Au;d zycw+g>vzr;9*p*n7>K&|)oMJk&;5GAcqQd83kbBEUs}a)xloyJ`G>@f-rRHkzIMW9 z!h0525_R+QoKn4o%*Q)=m3T0roEc_K&q&+GjY4hvt&pNp|MT&_8qR2B72m)pw?o#| zF46n-##}rBg~*Y(qPB&28<_AuGy#rF)UeaL2sQWfgGpH%gWJgtD6rPvH$KxEbsMp! z^yE+7>Clmgnn-BYwwEvSq{rpmDjsCy3&OSJsS!6to97;7-gWUS-uVWTl;N#weVF-W z^`Au-n6Zwlx0bvM#w#Wa+pAcv*bDQ6!hkUObHjtj`#d;O7G z!*uxJQGNxkAJNy1pRYc_3@YVbhK2wEg7DA_+Z?15T4e1Epk{z*`Dli6iDJ5&^Rh!k zbrom_q-qyiMGTPu+AYV7=&d>+o1B})zU76k?u{FJ^0-dQ@z__V5)(e4}RR?l|-;I_BaMa{c)&0}BWNeW__q(lt!8 z$2T%vTa7=NudBx`8OR2b&LhNDOMCCR{mq;)JR!3wvv|w9;hk>+Vna4^5VOY6r~Q$U z9U4zB6W>?%@;tN7g1CK%SoZ-_D4SGelyrMyr{#xFy@l5eQXxa9Wm^y*+Y)!{@+S+7 z12=D{`9+BNM-$w9uc>WVymgc$mQ|$wLjUN>@SW9xy(^=u9L`9Bxhq4;@~@ek+-z|^ zW8EW&OTur5z+Ar=ZhzyokE`rWa@ZLmukrn!Gpb=o#j!!)YU8U(d z_!&#tbLS4+&{S{Wu zd!K3?#horZVTwy`K!lLGFy)YGhSo;zDMwbbBnnN7%6UV{ZLxLL5d0H7#5l`6I zyP-8h<^`O%@Q1;J(_+4fENbkdf^Kj{TQn=wYqH=!{OA9-eks{z$uCyjt94oHU*KTt LWK(_aFUbD@7N!=3 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-m4a.png b/system/images/media/thumb-m4a.png new file mode 100644 index 0000000000000000000000000000000000000000..224f058fdb603487014d5e8e2e4b814b4489af73 GIT binary patch literal 2754 zcmcJQi8mCA7svHOWy?}{Ma+}(T7~qp#hWBNX=Ew85=~^u#8^kn)Uz`vLzt0c-VK#l-#-=y1c?is*WWzr4&H z9p!!fN-ruBlF796^xfTET5b-jxtZ10N(u-dd3)`OL{tPqNT(CsTm@@uy8^+kP$=SZ ziSBMh4|gGnw9Drcy*x!MmXJmx`P~=sc%+A5N_4b{!Qf6#l6<`R-@lQ3?~y}-IRgVE ze?R`upS;CI&hW61NF;iCkna1ka5(PFjEK({aX93lNBkc@sL4sRtW4_jXCgM641ut_ z-;pE21;2g?DHQ(73N0;_`~)J{-ey$1r6IHEd3oINaq^=;(gT0Sn^H0)jGB}5PWOsEjN=iuJ2c)|`%<3vuTN^DigPM}eC@*8y)bLkV*}c7_fQOt9{k+8ma@b=b zmCC{61q1@KwwC+p6D8p(XK;|ew8UGOry-G~dw1yt`P``~&d3M_3T1V4FiJ2C>>KLe z&)MDG?Dy|k?d|NIZuYx(l$aR7#s+s{f|{B_fyD`_6n0l91B+$W*RdKKSsgg)GX!sT zmOD1eC@m$2hEO7*{BKLNoNQ)Q6(u@~)zn0VCkocrXt}v`bTO-?MXn`kOaD&-wU~_9B;sYv(O2Fe@tsD`Bd)wGXC)sAijAope8-(P;H$whPWWIQ)Atqqcf0rIYQ-^tPisbPJ}?jz6c zF$4xUE3as&apyGR(l_i9d`$PIj}H-y)Kj#qvr`+GxBPg(SiPwrNg(TJFks?)%iJG1 zKB8w6@{CTZ^+QxVpHq}^=>)LTpUCN-V1UY|CIHJG*6FsY*q}0qoW5DO=`!!Ab|~L6 zNTcuqUU|e2UokRC^jrrkL{$5~S3Bc^lp8U|f6J4*vI<6?Julv4ql^u1ibW>o$=Op4 zY{-P8w&3KC5Im3Blkt-YmLNuqM@Ciy|eU+RSGjax6*6Bp5E zK=7jT7TXUEP^32#Fg;u3`Nh7eSAZ~uj%7FIa%Gu-j2lM*bW=3R|5fn@fYuI66gXWN zzm&VbQiWeddzyMPVVr)_24RGYo^*}EnA4vF%<(1C++oLQfP9K!q={ZYasDM}vJMcQ zvAV%0^s8>t*9Q+&ZefpiTsD%(uJ`>u+*}~?U4;0+@uLLLBD;m#K?UYLOtVnXmT%J1 zq=hw^Nxu4FhddQ#7+D2d&;te`+ebdlh@Js2D4zWu6V%@mZilZ(`7UKV2Hv|xR$U5t#Xe8O;Py@BTH}uMnJ@~>MdAB})R>abjJsTsQb&%Ey zO({G1SX$rP^M~`JAf58pm@hBMe{*`V5$m%LWUD+D_2qak9iiDY|Uz42re)~-3{yHE5jQ{v)Vd(Gz-Aqbr!kCwJ ziKOSMbQGHeefIf7gPrEZ4pm{xI83U^{zmbA&lhCWC7Y|=LApA( zqYHI_>+5R~N?{Y$^?)uwvw9MVp`9FuY^Z}~ihgD-NV=_?w1CEQ0ljI%6Sbe0Pq{bC zKr-m_b!Z8SLa!?}PyuQ{K-z8&~Gf|;{RCQaMszuV18KldcH zu?oy89bh_tx+RwkXaz>gofGPd1Ursmharlz6duW~IOTI4DZ zf)n~N@NR=-nWomt7bT4nfF2vQB&1*7CjL|YcxaHmM!wf3J%ONy^Pm|*r67`=noqy2e7nl=D%~h zci=aYcNgK%q3=&$>i|ioNqQaUngMJ#P-H66aS-Ght)TsfNE)*Od;nyb#Hrxk8z~o% zZILckz;!4J)?uOS3Yg}9oTZJAr#oKX-5P_dBRzQfF<1D6Y28>Q>@VNt?LGN^ z{Q2Ny?+K?N8IN^a|QW*N3 zi(|%1=xKbu*n6;793dMK=lKaobQf;gA}xJM;sug=EGem;b;tPnUZ$zD-FcfQKBM9# z$JGaLCd|a`?L+*K`W#uUp7Jg382q=aXchEg)7PMP;0WE90PD_84MT8ypg~rFL3>&w zLMf>Bwx$CP@fKK9@io%co1+7{G4U_{&HtCC^zu{}yrDr~`(7XV2iaQPyjFMhF8qIX CC6qn@ literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-m4v.png b/system/images/media/thumb-m4v.png new file mode 100644 index 0000000000000000000000000000000000000000..fa45046c78440f8e69eb789ce25a50019f768a4f GIT binary patch literal 2738 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 F{{rDTe^dYf literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-max.png b/system/images/media/thumb-max.png new file mode 100644 index 0000000000000000000000000000000000000000..d08aa823454016951b4a12d1fefad4dec860ee23 GIT binary patch literal 3213 zcmcIm`8OMg6Gy1hRl2&{Lsz3!J+!t(x2=|jQdL)JZHn4%EmilTs>BuTu64&1w-j*( z5qAioj)cS+L`b6I4&sO)BJ#EG`#0>oH*em2X5M_>o6r0(QO_)l7sA0FD8abIJW;r>M$4gvof-Z4aK>o>ZF1hz z&;2U&RerBJ+8?fgwG(mNz)KEge?GNOfvTS3XPzs$t^ym-pzy$rF_udct;&$;6O7bC zwSJJs#%;cYm@+&pBU=P=so)1@Me2GdpWcO5w7`s%HrBbo#TD+>7;q*1FqZZ++? z{(IZQ6S`HpPAoZOT@TICLIzr%{(1!*+1J$7XqFIK^f0CA;&D~m1-F|ug`Yw5w)W(0 z|CM~)iOR6%vsG$Y=>Y8A-8fm9*)#4jy;iNc4!OHi>!zdpWwQ}zY>v{d-d~cMy`ghS zX6wB2u3`|egJV*G45gn@9KwECczcv6Wo-^M*b(@xiOVXif83~24w-TkRwbmVE zWt{R+aUzG2Ps0J$kLdnv`UOP@FjYE&iG+bTAL=(OrG61wq~djb*_Ld_cckb=fXRV} zI+}e$1t!Sx3=#rFX-x-xetv5BUlAXh&Gw-yLK)7KJp+Wq)$ zbAwIGlT|lTc8@bSv-+r0Q2kyeau)>uCk8g;#x7JZ4h#BzrGt!km9#&cVhrqQHRC;J zDc-vd1KWuC%DS0ah;+QHbM~%ixm1^4V}kSsu&_`N*+x=o_$Atl-jB1=ZoX9fX$^(i zpMS*83Rn)0x&yo188>#yf8z$Lh4I5l;xKt`KAA=QD^T}U!GYoNGQ@eU6VkrWbKk%y zmeEr3>sVRYS!Js&G{x9U!FYbbVc2HHGFa!BmGp&&KL-ekKo(L`n$LqDRp|OXFR<6R zhSfsH93up6mU!N@k{+(7a-;5>1XEY!Wu0=IH#!UQ?pz+&%e@SfQR9HfVAg^OXU(+6 z{26f4uutw00ZwvFI6ue32&=RbyiYpH8}`rZtyP9RxB_c1?1`8zMQt;2$DTPqLS0ON zuVsz{Y=@R#-RHgEBgb9-B#~Iqci$y}c|E>0ZHg2Z59-cZ1Xy|h8f2ZX9>OwF{QgN! zHg=Ch>p=9vjeQ}ui?#DU?ha=pt!Hp+H7`TXN=jb19EpCobMV6~gI3CCcvRh}7;m0P z(FgidW4cHS>aTyiv)1%Uf}dK_7%;ngr)y(XJh{#R4hsFfS4F|^)j8X{)`8j~yHc`)%(aKdSCS<)`wpdj zOg_>I;UM0G1toch+B^eNa;U}&f@&QRH1S^4zw(YLMm zc`0ky4kRHudD(eJ?jGP*1W(7w$01Pi!l|>$N;=!zt-KjERfXHmyo|f|fJ*0gb~djO zQiH&OlGS6??EScv8p}W^ZrXu;01Y1hQno1W{%+9iN(_j+%xclm%9}`Tt+4VokXTC7 ziclY2VnnT`R!r>CYg+uBPy7>H&ZSU{^)L^h8Hz-8*2;AuZgc9pDyT`TdolHYk2V(?VhezB0;lHYndM?xuMs#0gB%fQ%!t+Q-9MI zz42qJt^!|SH24J5EN;}5KpxsGktmQOD!Y02NS`1w$L1hLCw?2M{6&@cKp zk`{C|pSs;j)wt}FZy(4yiu}qqBk&!9gP|VE_V0l`zNS0zmuSW^Y2@fYPxwUzE`&Py zVeqr$uuZVCixy6+#BFkYuF27FwX)GR=Pba>%5Xj#s^fcd4CiBFQSQU_~^)VkwhT|MOu@4veTxHWbr2L4C|8jSR+`?Zm16WDWB6rLL7 z6mXL(u6=b6G&U(=V%ggS2-FX?@n5@DW9ATjG8h1VnYH<$d?2b3aE1t}q@N+$@iPQi z!6*+L**=0b9x?s-qwT9jrw=z9FKmOza1Ukh&S%Md_+vB|hikf%HF0zIgK)y_PWJP} z<94|qz5XMc*)lSQ@we;DbJM^G#RYdQ0Q|S&@x~kd`XohSYc@>|40OvW2_V^GlkWP{ zN?q3MHIJc`>X+r#aO0?gC=Hw zbUC(f|NKC|rE~pKK!S4BWBZ~Auvx6n;HFfy?ZC5<&yt&#gwZ;Onda)?j|-i}TdHV( z2$YPl+Vr7A)8$8<4IG{TrDN<1D(fktfedQnW0C30KfECZ(!)AeIiQi;9=6G42r%Gd zCC_7|dU=*a0l=A4NG9e~*z>o3`Tb=j0N1h~T-J`u%<_>$!srO03rrI?l}o)!({(=| zqocroC|uMMhiaF(;`bSGr%Klv%pqIhJf(mc+K`;x0cakpEarbC|7|gw3;A;DPa#ltEj>jX4_$X3Zo5F@}rO&JI#W(QJNYrWeLB52-ZJ zu29aMC`puFIP(6@Cy#0fc0Vwq!RJj?Q&W1-fB5tKe{`FOA`)A@QCn9qMe6w%GBL6+ JtTgyN;(xACn*sm; literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-mdb.png b/system/images/media/thumb-mdb.png new file mode 100644 index 0000000000000000000000000000000000000000..3646fd24338590ace546f06373615fa6bf432d0d GIT binary patch literal 2691 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 m!pQr$UYq|(i~j#t>!sE`l{Pg{o3xr`^3|M% zFG37c5)u?tUa5&-imwUb179dA@)l4KnEegA=iGD8=X378=iFz;sgS@`D>khFfk3N* zgN~dAftCSZbou|57|LaqX~|gJfSiPYK-Gw^7cW~b;brls0}q4n+%3}}&{sQ7oj4PK z%zW6@*4$8yHJMD+Zwtv)Xst$VHkULapUix8<*m^_2QfGy0F zOixaX2@A4doo!8W+3dU0+;$d&-_wqJol}ziXkMqCo*e(wLa%(CgUNq7BNpM%FHtYk zjmAa&!h*2Bk5*H`X{KUcWt8Onp;F4Vnz_YA0|j5YFt0Nim%J8v-Jh0h1qzT!XPRlm zg@t)`Q$4$}4qui(__?<*D+TlFFP&CHC*wZSYu=V1W@p4w$#f+un^;jar&jiI*`vb# z?)DbVoO(>y&!kjQNpDd(>5UEV1$~_?CbLr|1Su7TfOpFgV))RUlYWbiYovZS@KZh+rYUyY^L;_BX)O-+oA z4hgvI4{r*d*WgMN^4TUTQ8G0l7L7I#%gc+i`un;FSR}ujR~+p_+on3=i<}WqEIl5nN8=@F0KDVCd^^dyhpnQLCpW$AtqtwMrqC zOm((3m*zi(d)7LDKvq|Rk3h~O^-G6z6uXvXACzdkcarVT&;DPrZT(Nrt^@>}irMIw4f z*)ZG?urXTSiDVa(Zid6y=!9W((lB{;dYv&I&nYI!%zN0bCs}Z#co6ATer|&k(RqmC zETrs#>PtKW^otFDW4cW#2n~sNzQk5J9xTqp3kG~dDP^cy-6Cl`>*ms)RAizKjbMrY4 z+{#DPGS=G=qr+p{D4yvjs5;&~9)BC8Kz!d_`4DoQwmW{luj4f&^%e5Iu_C?wAhbwR zdjcTkWrZaF@+~{lb}=gR3o-d&YNvNNaPF*zKq!~(b`s5HyOxt(;%v^Y7YU*yVMd9h z@cD6zWT_@8x^t)0RoqI|$#A>aH2WOpS7HsGpnh&~j<#1oE7w^H_Yuh8w9o@`S$L!; z?dR`;^dvK>%Jpn)P--s0jolEX#xgXTq#f&=i#Eo&9efp1zaxc&YXFbNadsP~6H@4& zDL~~ZAUsEk!V=={(B$rD26)=RXg?&8jAfjLn5tcbzCR%Et5|b^efspW#5vRhC#rky zinYFZD`DGxwf&-DT9}=cOIT=IiyaM(}7NhfS>acYofR7;|M|v!=2RwR~<0HwM)jzx}05eZK@!bm*c?Fs>zlSdsbNs$UvL77-WE3mA0!V=u z*CBk*+yUmY7~3ff2IVfmdrBm8x76)≷>txokgq8;OD6;o$5LzCV8C5a#LwE5T5v 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 KHci)lfA&A*jI3z@ literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-mp3.png b/system/images/media/thumb-mp3.png new file mode 100644 index 0000000000000000000000000000000000000000..f72ec75f223a17d44c11d5a1999f04310df631af GIT binary patch literal 2801 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-mpa.png b/system/images/media/thumb-mpa.png new file mode 100644 index 0000000000000000000000000000000000000000..ff85a9df59937478438e98149d33a737fe2f34bc GIT binary patch literal 2734 zcmcIl_ct2~6i#W;DXmRQ&}vmty3|Ouv_h-XsMe@Qi)X8fQTn{L#7?uyc0uj{tg0f4HsgWK-=MD?DR53Ozh$_I|q0RS=k`*-Ya-Y!Kht@GGC z{=*7%>*PXM>u?ikUMLj4@4_V`$8q$PsP>Va-QAM0nV4UrcS@1FLLq;9C!>$}ZD7*2 z4B1Xu3~CYtHmpN9TrF8^aM;aM%_emTm!q2Ir0aCl*PYkP;eu?g$OPc3s6 zIlQXzIoj%mU*o{0V0(6Dy?TP$J;UguGV@2K@bne88qC*z@)(`{V{p>54m&iz9Nj)z zPozSr>b5$hgUN}&BBltIo#}ia?lT7 zQvg7cXKi`iE}Y1kTzDgU^Ehht(siu_^{jV8$36m$)kDwkw~P$bfZ05-yq}(Xai2*m z#7ed*sg*}rn#%vIK-qoa#kF~%7S4&dhDm3ty1hi0JU=WZ((wNXw5>}*2lCLjfo$Xc zBMpj=j`_}gae7+=?uqi8I{5wsB*D;0hIHEAlQF7GrasAdfUBL??D{be5`C<07InEBNQPlz|*vi-o3ezifP>J zDprhWYnQ*-SD6EOEuBG?iRV&j)}Wd5%XyfVwUk#AmpMhg#Z^raD=E!KY&rl#GP(R7+Id@P&GE2aF zC4-oVuvhPvZY32bf*M`HiKXl6@cN0OrrG=%;1 z0qRJZQA6Q-kqg~&d6U;cqt6jmH|#4CzA9A6_O?%{v`SH5t#SsdSB?3y3m~|hjnK8Z z_?F(TGBm%m!Ox5mZypA#DmEOhZ@cWwi%T-El@y+;_d=SO9E^u`-iwy9Or6hcctW~c z9t?!Mx#|HfMurIgc4ePSmuc1&e=X$+Khvm7b|BBUDH%yoPy4%?UA#34EMG(v*Q~>L zK0d09txX&*)}9GYjV6vqXNec#zgx2JI@;PHM~$=-udS-XpMCP*pRU+EAs1=}S6M#= z&hd?`;fHO4ADD7KOUJf=Y;NUc$EfK|QE=u@PTw7g|6TqcM={n>Ip{?fT{ql#SlD|N zw4%Ygi^h|o-@%7IyMc;4{N+pr0}PYrq;0M+0#xiiT|oDP_a0*&$+FwMQg&8CPhR5c zzK%EGNXektC{+3vcIdn6uSAe=8$R+T6xGmYUA1}>&2myHdqC`ZR5 zL_-aXXRlmxHq;I}j?VfmXJfZ9fjcejWHKCY4pw+i(nv#V9+w>1E;A~)a+4O!N~XR1 zXiT>Ng*;toCwXWoAs-#zxAwS>Tr%qFi%?k(tzc3}@#b+1H9l?y-h+zmGFMg3lg?Gc z$!oe4L|EekdFt?Bo2UbSc`GxPLO@VILKHaCCOZVN%Pir6fDu19UO+#C{#q|Jh{cys zA=)*`xL8`*ZFa>zA{FY*764&nh*6AhlTTpWTW#mJGELylWRY;4e$)Kr`$oyJ=9CP& z(^=fddkq8(cFfm$2W2~^SYL!ElC3%<_&|u2{1h-@ZwM&O-C9lTi+gKr0OTUPw46)<5FI*aCEZRC`I|QXa%^Ttk9-Y{{X1j-6O|n&}O? zo$fQ9-cv%9!KkS2)yQ(R5@x?T0sG6yA!_caZ$*GH%>v|GXIksHVss>LzS`Lss@>K^ zX5<$un@L%|iQm|=6d$HA&Y!p7WYD-~%kPz8+yQD|QG!IZDay;NiD}&ODL+V@Aa8P% zdKvytQwV~HKOXo|Co3F?&Y6HZH6FG^XnDr;(;WO|@N5x*xMU6XFQ^Lava$WQCRAB9 zsytmX_T3eU*GEq(!JM-TGGD9;3r7})Y!0Zy@gtRQKj?GsKlhw(Zdf$YbH<%1wyD`H xAfAh|^9)|@^2{3fQ)%#j{+|HAAHst6J-)he8u{7|H~#z7);I53mfv{x=fCPzeKr69 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-mpe.png b/system/images/media/thumb-mpe.png new file mode 100644 index 0000000000000000000000000000000000000000..a66180e6db9c5256cd58582ff80137b2d4f47cbb GIT binary patch literal 1971 zcmbtU`B#$%7EQ-|>;a9qpa`E8L8*#TQ5Zq{IjBJ^wv0~4t>_sPsY4MI%BIu=K@>1z z!4?pd#v-C9ZXp;zL_)&8M9998#q4`R7LulaL*F^?y!-As_q|`OZ@fv~M@8Z8T8o!qjiRKD?)& zc5&We8)ROki<$@Mc{aP*U=}-_&X0n7Gs31}b_q-JoFjd0oReB?`cY1~+Q4FppU}lE zd|Br(>smkU5{aJUaM`$T%O?lLZbgP ztLULHi-uUm6vkyTqrhZQkZ9Rb&FClfu){GgRQAo!TP14BB)>+kon(kxRC>BnPgCeV z+UG4o<@*6fzQv}SRlIXL9eT5nD}BR~JhR#jQ~Y0fGJ?q>*P3`DRsYA?2CATLieD>M zk?EpGle}uVc1oe6QU$eqIZ>sj$+Y8Ui_&aWjdE^I&pyza_%lM>APX(iPH?4orG8p( z7Fq4a8R73V(L=R?NfY8MHmy+6t1)uMxm6ki`;&Tz{pkgTd4==o)g=F}%D@mQ`x)Za z5q6nGJt&ZO2^2j#lVFTfG0ZNt+FXKmgIO{yd@#<%PVnxGux|(z?+tSj#|>uml`)P8$@ zEb?vcMC;M>V)&W@ruW98=H6Sh8P2Pk)r)XZqqnia8eaR$rv^@GG&j;}z)qb=&_MKm_LN4aZ(KQNheu%lHXs z1w8NfQwb7b73PDMl85$pFH03iNGlKcA%o*Kp)#>@%YmT4Vo*zsLHoN&$-A=hvNoeX zOf@dUtwY)Ob8d8L@59TA{BfJfB;t}GxAmylz1Sx3rjFCz5by*{_XgH`Ajby3e;(U? z7nFx`GTSmn+|`FD9ih7D(7cu2FZvB-pKjavtwsFbbr6G?`Cb0K3f_tEx)RBx0TTuS@Jeza8lKfNh zrwjMBK#9$yV{q5*R;3hN(7(JeXVk1~D+VvV zZ;t>H+{^xO4}Oy4);D{{<_KSd%81?uVLL%Souv{g&~2K?>qvAUVt|9hZ9?UmG^nhc z-7;fvE_R~j)}tTtpf?rir{=H-Fr*66(VSXE2Cn zRe76VWk4t*mfX`wiV z71vb;z9p3gsSC{Qo@~H*FCM#@+KJaN!(D@$ z*p#Prn?f?um*LQ02DcyY4=3D$n)Xy2`ln~sE{s78#6+OhLZm2%qZ2fLiQ4-44%9Pn z2|&NSDy804f9qM_I#6)ih01cy02T;4h(0mw75TUpEZxh!X8czynYh*%_kHDK&#do4 z5n6CUWR5?dX2@4=5ZlNw2XX z!l1_H?HOywn9Qac`+OsES?anUsk=SCl0=3WfXWF6hwx1hh{#gZvdc zyEsgxINrdSS-gQAq>%fn@vrFvuTxX>JO8fb!C;T5+-}7V&BB8}SN-Lk340Rb+jgHq F{|WUL69xbP literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-mpg.png b/system/images/media/thumb-mpg.png new file mode 100644 index 0000000000000000000000000000000000000000..19ba9a019a85db732e4658f1513c3b8a02995b72 GIT binary patch literal 2811 zcmcIl`#01H9~Lq;D=J;7Ce?1Kv`Sj7%VY_Q28kjeTDOoS$s0R`CW_I;y|Ga)5xM0u z?&C6Ulg51pBll*^Xbk3ipKZ_kH|%rHb3W&J&Uv1nKIc4-ZO@x4Z`id#Mn*>2(!$hU zMrIx5Unf4b8JW^VCB$R>cfBse-rPi{n5pqqMn=}s*4n|$yjsFp zLb)RJ^)Qsu4?XM#yA}|sR2s(w3pw!EH*{bTefkNssFq+-X#pF~9E1Qwiegd-m4b*= zD8VdiBnT!Q6{6>BB~JAc43nl0K%Mi5RgFXfORqElYq0ckJrLLm++%=C05&7W_-okp zX281zsQm&@FJs>TtaKa}ftVP=8mEz2G3M0_5NjpTJz!)P_>l|S(1A|MgfixlOZC9QD&|iI{OAC04MR|=L!HE>0SIaZ zmO;#;3HYN%^6WG8iHE!$fv+|KFIZ4m2UtDn!5h2)r7E-gDr^HEb2a?lM5$D%LWCOf91; z5ca4S{IZOay1{Erz}yN}G6ws!0DpCX6WN3cqAQP18uv zS7iJfn$!n1Od~hwKsW;&TS9}{fX{psMy1a`L&an8XCC^$PB5Sq_Sz$!UDEF+_6YiWAc;VxS+z8axsuF)Y{C%YvtcxoRdT0Z4(lDw>1SlEWP z!$Pw-HRpWK_0IV;TDO~{Vv3jdojXeji@S;5+!F2y_|;3;U`*H}++*_nXa7Sep6fgk zPrbi7)4ZLhuxAupwJJEcZO;^os=&{=?HtI{ZMtkX-ai~4QMIk3zAz<1L+e=_>kB<{ zSI}krVHQ3jIw(sA%subReCDw8BD3;hj$08q)Hv$5lczY<>E*{F7FA3W#DWll!X}H1 zuBX?DZ68uxy36Z_lThX%zFZoQlV?y(5Efb;eo~g`sW5MT*&MvmEthm0zU?bi_rco_ z1xh&dq8I54h1IFo!tuUFuGHjpIL+DJSxSYSrSGR6={Vv}^yPI&Ps%0rryJ@GIjHls zSXMVSnnq5KdVcAXm)E4F9e49K*bZws7v9)7bMVt1zL%zK(iUsZnbW$|iw2`!!FVTw zh%qZ`{PIpo?yit27qcvr2)QJQ#3r6PNFj?zQWs1oY`;;oPhTQLeN9 z4i!WF2{vKoYRIF@iJL9LB#75T7|uUikUGDI`l<24QcuThd-E- zTaH1-&o$;pio^f*R|={v_Ad-uI8|O&q&%bViB2}jZpwZ~`xs{z>2@jH(3<~iPRd(n zb@F1+JL&!=LaU=-%wQx~r^?ybt>k2hXR2cuV}h{DdcXHhFMPJOy7_JG&H<-Jmgm-( z_{x^ZAq9I+;LLd?J&K6OI3DED@$7bh;D|w@`;2Pka&#q$aV`uTaF^TEKTw-czE;BS z%97|iB|2IW+3E~Kr&Y!4;EQ5hHX5HXJW(8_5+Kr#P8-!BI!KpfeGU1DKiS{U=kW + + diff --git a/system/src/Grav/Common/Errors/SimplePageHandler.php b/system/src/Grav/Common/Errors/SimplePageHandler.php new file mode 100644 index 0000000..e954717 --- /dev/null +++ b/system/src/Grav/Common/Errors/SimplePageHandler.php @@ -0,0 +1,122 @@ +searchPaths[] = __DIR__ . '/Resources'; + } + + /** + * @return int + */ + public function handle() + { + $inspector = $this->getInspector(); + + $helper = new TemplateHelper(); + $templateFile = $this->getResource('layout.html.php'); + $cssFile = $this->getResource('error.css'); + + $code = $inspector->getException()->getCode(); + if (($code >= 400) && ($code < 600)) { + $this->getRun()->sendHttpCode($code); + } + $message = $inspector->getException()->getMessage(); + + if ($inspector->getException() instanceof ErrorException) { + $code = Misc::translateErrorCode($code); + } + + $vars = array( + 'stylesheet' => file_get_contents($cssFile), + 'code' => $code, + 'message' => htmlspecialchars(strip_tags(rawurldecode($message)), ENT_QUOTES, 'UTF-8'), + ); + + $helper->setVariables($vars); + $helper->render($templateFile); + + return Handler::QUIT; + } + + /** + * @param string $resource + * @return string + * @throws RuntimeException + */ + 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: " . implode(', ', $this->searchPaths). ')' + ); + } + + /** + * @param string $path + * @return void + */ + public function addResourcePath($path) + { + if (!is_dir($path)) { + throw new InvalidArgumentException( + "'{$path}' is not a valid directory" + ); + } + + array_unshift($this->searchPaths, $path); + } + + /** + * @return array + */ + public function getResourcePaths() + { + return $this->searchPaths; + } +} diff --git a/system/src/Grav/Common/Errors/SystemFacade.php b/system/src/Grav/Common/Errors/SystemFacade.php new file mode 100644 index 0000000..4a775cd --- /dev/null +++ b/system/src/Grav/Common/Errors/SystemFacade.php @@ -0,0 +1,67 @@ +whoopsShutdownHandler = $function; + register_shutdown_function([$this, 'handleShutdown']); + } + + /** + * Special case to deal with Fatal errors and the like. + * + * @return void + */ + public function handleShutdown() + { + $error = $this->getLastError(); + + // Ignore core warnings and errors. + if ($error && !($error['type'] & (E_CORE_WARNING | E_CORE_ERROR))) { + $handler = $this->whoopsShutdownHandler; + $handler(); + } + } + + + /** + * @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'); + + // Work around PHP bug #8218 (8.0.17 & 8.1.4). + header_remove('Content-Encoding'); + } + + return http_response_code($httpCode); + } +} diff --git a/system/src/Grav/Common/File/CompiledFile.php b/system/src/Grav/Common/File/CompiledFile.php new file mode 100644 index 0000000..152bc0e --- /dev/null +++ b/system/src/Grav/Common/File/CompiledFile.php @@ -0,0 +1,195 @@ +filename; + // If nothing has been loaded, attempt to get pre-compiled version of the file first. + if ($var === null && $this->raw === null && $this->content === null) { + $key = md5($filename); + $file = PhpFile::instance(CACHE_DIR . "compiled/files/{$key}{$this->extension}.php"); + + $modified = $this->modified(); + if (!$modified) { + try { + return $this->decode($this->raw()); + } catch (Throwable $e) { + // If the compiled file is broken, we can safely ignore the error and continue. + } + } + + $class = get_class($this); + + $size = filesize($filename); + $cache = $file->exists() ? $file->content() : null; + + // Load real file if cache isn't up to date (or is invalid). + if (!isset($cache['@class']) + || $cache['@class'] !== $class + || $cache['modified'] !== $modified + || ($cache['size'] ?? null) !== $size + || $cache['filename'] !== $filename + ) { + // Attempt to lock the file for writing. + try { + $locked = $file->lock(false); + } catch (Exception $e) { + $locked = false; + + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addMessage(sprintf('%s(): Cannot obtain a lock for compiling cache file for %s: %s', __METHOD__, $this->filename, $e->getMessage()), 'warning'); + } + + // Decode RAW file into compiled array. + $data = (array)$this->decode($this->raw()); + $cache = [ + '@class' => $class, + 'filename' => $filename, + 'modified' => $modified, + 'size' => $size, + 'data' => $data + ]; + + // If compiled file wasn't already locked by another process, save it. + if ($locked) { + $file->save($cache); + $file->unlock(); + + // Compile cached file into bytecode cache + if (function_exists('opcache_invalidate') && filter_var(ini_get('opcache.enable'), \FILTER_VALIDATE_BOOLEAN)) { + $lockName = $file->filename(); + + // Silence error if function exists, but is restricted. + @opcache_invalidate($lockName, true); + @opcache_compile_file($lockName); + } + } + } + $file->free(); + + $this->content = $cache['data']; + } + } catch (Exception $e) { + throw new RuntimeException(sprintf('Failed to read %s: %s', Utils::basename($filename), $e->getMessage()), 500, $e); + } + + return parent::content($var); + } + + /** + * Save file. + * + * @param mixed $data Optional data to be saved, usually array. + * @return void + * @throws RuntimeException + */ + public function save($data = null) + { + // Make sure that the cache file is always up to date! + $key = md5($this->filename); + $file = PhpFile::instance(CACHE_DIR . "compiled/files/{$key}{$this->extension}.php"); + try { + $locked = $file->lock(); + } catch (Exception $e) { + $locked = false; + + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addMessage(sprintf('%s(): Cannot obtain a lock for compiling cache file for %s: %s', __METHOD__, $this->filename, $e->getMessage()), 'warning'); + } + + parent::save($data); + + if ($locked) { + $modified = $this->modified(); + $filename = $this->filename; + $class = get_class($this); + $size = filesize($filename); + + // windows doesn't play nicely with this as it can't read when locked + if (!Utils::isWindows()) { + // Reload data from the filesystem. This ensures that we always cache the correct data (see issue #2282). + $this->raw = $this->content = null; + $data = (array)$this->decode($this->raw()); + } + + // Decode data into compiled array. + $cache = [ + '@class' => $class, + 'filename' => $filename, + 'modified' => $modified, + 'size' => $size, + 'data' => $data + ]; + + $file->save($cache); + $file->unlock(); + + // Compile cached file into bytecode cache + if (function_exists('opcache_invalidate') && filter_var(ini_get('opcache.enable'), \FILTER_VALIDATE_BOOLEAN)) { + $lockName = $file->filename(); + // Silence error if function exists, but is restricted. + @opcache_invalidate($lockName, true); + @opcache_compile_file($lockName); + } + } + } + + /** + * Serialize file. + * + * @return array + */ + public function __sleep() + { + return [ + 'filename', + 'extension', + 'raw', + 'content', + 'settings' + ]; + } + + /** + * Unserialize file. + */ + public function __wakeup() + { + if (!isset(static::$instances[$this->filename])) { + static::$instances[$this->filename] = $this; + } + } +} diff --git a/system/src/Grav/Common/File/CompiledJsonFile.php b/system/src/Grav/Common/File/CompiledJsonFile.php new file mode 100644 index 0000000..22b9edb --- /dev/null +++ b/system/src/Grav/Common/File/CompiledJsonFile.php @@ -0,0 +1,33 @@ + ['.DS_Store'], + 'exclude_paths' => [] + ]; + + /** @var string */ + protected $archive_file; + + /** + * @param string $compression + * @return ZipArchiver + */ + public static function create($compression) + { + if ($compression === 'zip') { + return new ZipArchiver(); + } + + return new ZipArchiver(); + } + + /** + * @param string $archive_file + * @return $this + */ + public function setArchive($archive_file) + { + $this->archive_file = $archive_file; + + return $this; + } + + /** + * @param array $options + * @return $this + */ + public function setOptions($options) + { + // Set infinite PHP execution time if possible. + if (Utils::functionExists('set_time_limit')) { + @set_time_limit(0); + } + + $this->options = $options + $this->options; + + return $this; + } + + /** + * @param string $folder + * @param callable|null $status + * @return $this + */ + abstract public function compress($folder, callable $status = null); + + /** + * @param string $destination + * @param callable|null $status + * @return $this + */ + abstract public function extract($destination, callable $status = null); + + /** + * @param array $folders + * @param callable|null $status + * @return $this + */ + abstract public function addEmptyFolders($folders, callable $status = null); + + /** + * @param string $rootPath + * @return RecursiveIteratorIterator + */ + protected function getArchiveFiles($rootPath) + { + $exclude_paths = $this->options['exclude_paths']; + $exclude_files = $this->options['exclude_files']; + $dirItr = new RecursiveDirectoryIterator($rootPath, RecursiveDirectoryIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS | FilesystemIterator::UNIX_PATHS); + $filterItr = new RecursiveDirectoryFilterIterator($dirItr, $rootPath, $exclude_paths, $exclude_files); + $files = new RecursiveIteratorIterator($filterItr, RecursiveIteratorIterator::SELF_FIRST); + + return $files; + } +} diff --git a/system/src/Grav/Common/Filesystem/Folder.php b/system/src/Grav/Common/Filesystem/Folder.php new file mode 100644 index 0000000..019342f --- /dev/null +++ b/system/src/Grav/Common/Filesystem/Folder.php @@ -0,0 +1,548 @@ +isStream($path)) { + $directory = $locator->getRecursiveIterator($path, $flags); + } else { + $directory = new RecursiveDirectoryIterator($path, $flags); + } + $filter = new RecursiveFolderFilterIterator($directory); + $iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST); + + foreach ($iterator as $dir) { + $dir_modified = $dir->getMTime(); + if ($dir_modified > $last_modified) { + $last_modified = $dir_modified; + } + } + } + + return $last_modified; + } + + /** + * Recursively find the last modified time under given path by file. + * + * @param array $paths + * @param string $extensions which files to search for specifically + * @return int + */ + public static function lastModifiedFile(array $paths, $extensions = 'md|yaml'): int + { + $last_modified = 0; + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $flags = RecursiveDirectoryIterator::SKIP_DOTS; + + foreach($paths as $path) { + if (!file_exists($path)) { + return 0; + } + if ($locator->isStream($path)) { + $directory = $locator->getRecursiveIterator($path, $flags); + } else { + $directory = new RecursiveDirectoryIterator($path, $flags); + } + $recursive = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST); + $iterator = new RegexIterator($recursive, '/^.+\.'.$extensions.'$/i'); + + /** @var RecursiveDirectoryIterator $file */ + foreach ($iterator as $file) { + try { + $file_modified = $file->getMTime(); + if ($file_modified > $last_modified) { + $last_modified = $file_modified; + } + } catch (Exception $e) { + Grav::instance()['log']->error('Could not process file: ' . $e->getMessage()); + } + } + } + + return $last_modified; + } + + /** + * Recursively md5 hash all files in a path + * + * @param array $paths + * @return string + */ + public static function hashAllFiles(array $paths): string + { + $files = []; + + foreach ($paths as $path) { + if (file_exists($path)) { + $flags = RecursiveDirectoryIterator::SKIP_DOTS; + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if ($locator->isStream($path)) { + $directory = $locator->getRecursiveIterator($path, $flags); + } else { + $directory = new RecursiveDirectoryIterator($path, $flags); + } + + $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST); + + foreach ($iterator as $file) { + $files[] = $file->getPathname() . '?'. $file->getMTime(); + } + } + } + + return md5(serialize($files)); + } + + /** + * Get relative path between target and base path. If path isn't relative, return full path. + * + * @param string $path + * @param string $base + * @return string + */ + public static function getRelativePath($path, $base = GRAV_ROOT) + { + if ($base) { + $base = preg_replace('![\\\/]+!', '/', $base); + $path = preg_replace('![\\\/]+!', '/', $path); + if (strpos($path, $base) === 0) { + $path = ltrim(substr($path, strlen($base)), '/'); + } + } + + return $path; + } + + /** + * Get relative path between target and base path. If path isn't relative, return full path. + * + * @param string $path + * @param string $base + * @return string + */ + public static function getRelativePathDotDot($path, $base) + { + // Normalize paths. + $base = preg_replace('![\\\/]+!', '/', $base); + $path = preg_replace('![\\\/]+!', '/', $path); + + if ($path === $base) { + return ''; + } + + $baseParts = explode('/', ltrim($base, '/')); + $pathParts = explode('/', ltrim($path, '/')); + + array_pop($baseParts); + $lastPart = array_pop($pathParts); + foreach ($baseParts as $i => $directory) { + if (isset($pathParts[$i]) && $pathParts[$i] === $directory) { + unset($baseParts[$i], $pathParts[$i]); + } else { + break; + } + } + $pathParts[] = $lastPart; + $path = str_repeat('../', count($baseParts)) . implode('/', $pathParts); + + return '' === $path + || strpos($path, '/') === 0 + || false !== ($colonPos = strpos($path, ':')) && ($colonPos < ($slashPos = strpos($path, '/')) || false === $slashPos) + ? "./$path" : $path; + } + + /** + * Shift first directory out of the path. + * + * @param string $path + * @return string|null + */ + public static function shift(&$path) + { + $parts = explode('/', trim($path, '/'), 2); + $result = array_shift($parts); + $path = array_shift($parts); + + return $result ?: null; + } + + /** + * Return recursive list of all files and directories under given path. + * + * @param string $path + * @param array $params + * @return array + * @throws RuntimeException + */ + public static function all($path, array $params = []) + { + if (!$path) { + throw new RuntimeException("Path doesn't exist."); + } + if (!file_exists($path)) { + return []; + } + + $compare = isset($params['compare']) ? 'get' . $params['compare'] : null; + $pattern = $params['pattern'] ?? null; + $filters = $params['filters'] ?? null; + $recursive = $params['recursive'] ?? true; + $levels = $params['levels'] ?? -1; + $key = isset($params['key']) ? 'get' . $params['key'] : null; + $value = 'get' . ($params['value'] ?? ($recursive ? 'SubPathname' : 'Filename')); + $folders = $params['folders'] ?? true; + $files = $params['files'] ?? true; + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if ($recursive) { + $flags = RecursiveDirectoryIterator::SKIP_DOTS + FilesystemIterator::UNIX_PATHS + + FilesystemIterator::CURRENT_AS_SELF + FilesystemIterator::FOLLOW_SYMLINKS; + if ($locator->isStream($path)) { + $directory = $locator->getRecursiveIterator($path, $flags); + } else { + $directory = new RecursiveDirectoryIterator($path, $flags); + } + $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST); + $iterator->setMaxDepth(max($levels, -1)); + } else { + if ($locator->isStream($path)) { + $iterator = $locator->getIterator($path); + } else { + $iterator = new FilesystemIterator($path); + } + } + + $results = []; + + /** @var RecursiveDirectoryIterator $file */ + foreach ($iterator as $file) { + // Ignore hidden files. + if (strpos($file->getFilename(), '.') === 0 && $file->isFile()) { + continue; + } + if (!$folders && $file->isDir()) { + continue; + } + if (!$files && $file->isFile()) { + continue; + } + if ($compare && $pattern && !preg_match($pattern, $file->{$compare}())) { + continue; + } + $fileKey = $key ? $file->{$key}() : null; + $filePath = $file->{$value}(); + if ($filters) { + if (isset($filters['key'])) { + $pre = !empty($filters['pre-key']) ? $filters['pre-key'] : ''; + $fileKey = $pre . preg_replace($filters['key'], '', $fileKey); + } + if (isset($filters['value'])) { + $filter = $filters['value']; + if (is_callable($filter)) { + $filePath = $filter($file); + } else { + $filePath = preg_replace($filter, '', $filePath); + } + } + } + + if ($fileKey !== null) { + $results[$fileKey] = $filePath; + } else { + $results[] = $filePath; + } + } + + return $results; + } + + /** + * Recursively copy directory in filesystem. + * + * @param string $source + * @param string $target + * @param string|null $ignore Ignore files matching pattern (regular expression). + * @return void + * @throws RuntimeException + */ + public static function copy($source, $target, $ignore = null) + { + $source = rtrim($source, '\\/'); + $target = rtrim($target, '\\/'); + + if (!is_dir($source)) { + throw new RuntimeException('Cannot copy non-existing folder.'); + } + + // Make sure that path to the target exists before copying. + self::create($target); + + $success = true; + + // Go through all sub-directories and copy everything. + $files = self::all($source); + foreach ($files as $file) { + if ($ignore && preg_match($ignore, $file)) { + continue; + } + $src = $source .'/'. $file; + $dst = $target .'/'. $file; + + if (is_dir($src)) { + // Create current directory (if it doesn't exist). + if (!is_dir($dst)) { + $success &= @mkdir($dst, 0777, true); + } + } else { + // Or copy current file. + $success &= @copy($src, $dst); + } + } + + if (!$success) { + $error = error_get_last(); + throw new RuntimeException($error['message'] ?? 'Unknown error'); + } + + // Make sure that the change will be detected when caching. + @touch(dirname($target)); + } + + /** + * Move directory in filesystem. + * + * @param string $source + * @param string $target + * @return void + * @throws RuntimeException + */ + public static function move($source, $target) + { + if (!file_exists($source) || !is_dir($source)) { + // Rename fails if source folder does not exist. + throw new RuntimeException('Cannot move non-existing folder.'); + } + + // Don't do anything if the source is the same as the new target + if ($source === $target) { + return; + } + + if (strpos($target, $source . '/') === 0) { + throw new RuntimeException('Cannot move folder to itself'); + } + + if (file_exists($target)) { + // Rename fails if target folder exists. + throw new RuntimeException('Cannot move files to existing folder/file.'); + } + + // Make sure that path to the target exists before moving. + self::create(dirname($target)); + + // Silence warnings (chmod failed etc). + @rename($source, $target); + + // Rename function can fail while still succeeding, so let's check if the folder exists. + if (is_dir($source)) { + // Rename doesn't support moving folders across filesystems. Use copy instead. + self::copy($source, $target); + self::delete($source); + } + + // Make sure that the change will be detected when caching. + @touch(dirname($source)); + @touch(dirname($target)); + @touch($target); + } + + /** + * Recursively delete directory from filesystem. + * + * @param string $target + * @param bool $include_target + * @return bool + * @throws RuntimeException + */ + public static function delete($target, $include_target = true) + { + if (!is_dir($target)) { + return false; + } + + $success = self::doDelete($target, $include_target); + + if (!$success) { + $error = error_get_last(); + + throw new RuntimeException($error['message'] ?? 'Unknown error'); + } + + // Make sure that the change will be detected when caching. + if ($include_target) { + @touch(dirname($target)); + } else { + @touch($target); + } + + return $success; + } + + /** + * @param string $folder + * @return void + * @throws RuntimeException + */ + public static function mkdir($folder) + { + self::create($folder); + } + + /** + * @param string $folder + * @return void + * @throws RuntimeException + */ + public static function create($folder) + { + // Silence error for open_basedir; should fail in mkdir instead. + if (@is_dir($folder)) { + return; + } + + $success = @mkdir($folder, 0777, true); + + if (!$success) { + // Take yet another look, make sure that the folder doesn't exist. + clearstatcache(true, $folder); + if (!@is_dir($folder)) { + throw new RuntimeException(sprintf('Unable to create directory: %s', $folder)); + } + } + } + + /** + * Recursive copy of one directory to another + * + * @param string $src + * @param string $dest + * @return bool + * @throws RuntimeException + */ + public static function rcopy($src, $dest) + { + + // If the src is not a directory do a simple file copy + if (!is_dir($src)) { + copy($src, $dest); + return true; + } + + // If the destination directory does not exist create it + if (!is_dir($dest)) { + static::create($dest); + } + + // Open the source directory to read in files + $i = new DirectoryIterator($src); + foreach ($i as $f) { + if ($f->isFile()) { + copy($f->getRealPath(), "{$dest}/" . $f->getFilename()); + } else { + if (!$f->isDot() && $f->isDir()) { + static::rcopy($f->getRealPath(), "{$dest}/{$f}"); + } + } + } + return true; + } + + /** + * Does a directory contain children + * + * @param string $directory + * @return int|false + */ + public static function countChildren($directory) + { + if (!is_dir($directory)) { + return false; + } + $directories = glob($directory . '/*', GLOB_ONLYDIR); + + return $directories ? count($directories) : false; + } + + /** + * @param string $folder + * @param bool $include_target + * @return bool + * @internal + */ + protected static function doDelete($folder, $include_target = true) + { + // Special case for symbolic links. + if ($include_target && is_link($folder)) { + return @unlink($folder); + } + + // Go through all items in filesystem and recursively remove everything. + $files = scandir($folder, SCANDIR_SORT_NONE); + $files = $files ? array_diff($files, ['.', '..']) : []; + foreach ($files as $file) { + $path = "{$folder}/{$file}"; + is_dir($path) ? self::doDelete($path) : @unlink($path); + } + + return $include_target ? @rmdir($folder) : true; + } +} diff --git a/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php b/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php new file mode 100644 index 0000000..ad8e0cd --- /dev/null +++ b/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php @@ -0,0 +1,119 @@ +current(); + $filename = $file->getFilename(); + $relative_filename = str_replace($this::$root . '/', '', $file->getPathname()); + + if ($file->isDir()) { + // Check if the directory path is in the ignore list + if (in_array($relative_filename, $this::$ignore_folders, true)) { + return false; + } + // Check if any parent directory is in the ignore list + foreach ($this::$ignore_folders as $ignore_folder) { + $ignore_folder = trim($ignore_folder, '/'); + if (strpos($relative_filename, $ignore_folder . '/') === 0 || $relative_filename === $ignore_folder) { + return false; + } + } + if (!$this->matchesPattern($filename, $this::$ignore_files)) { + return true; + } + } elseif ($file->isFile() && !$this->matchesPattern($filename, $this::$ignore_files)) { + return true; + } + return false; + } + + /** + * Check if filename matches any pattern in the list + * + * @param string $filename + * @param array $patterns + * @return bool + */ + protected function matchesPattern($filename, $patterns) + { + foreach ($patterns as $pattern) { + // Check for exact match + if ($filename === $pattern) { + return true; + } + // Check for extension patterns like .pdf + if (strpos($pattern, '.') === 0 && substr($filename, -strlen($pattern)) === $pattern) { + return true; + } + // Check for wildcard patterns + if (strpos($pattern, '*') !== false) { + $regex = '/^' . str_replace('\\*', '.*', preg_quote($pattern, '/')) . '$/'; + if (preg_match($regex, $filename)) { + return true; + } + } + } + return false; + } + + /** + * @return RecursiveDirectoryFilterIterator|RecursiveFilterIterator + */ + public function getChildren() :RecursiveFilterIterator + { + /** @var RecursiveDirectoryFilterIterator $iterator */ + $iterator = $this->getInnerIterator(); + + return new self($iterator->getChildren(), $this::$root, $this::$ignore_folders, $this::$ignore_files); + } +} diff --git a/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php b/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php new file mode 100644 index 0000000..b43cdc1 --- /dev/null +++ b/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php @@ -0,0 +1,55 @@ +get('system.pages.ignore_folders'); + } + + $this::$ignore_folders = $ignore_folders; + } + + /** + * Check whether the current element of the iterator is acceptable + * + * @return bool true if the current element is acceptable, otherwise false. + */ + public function accept() :bool + { + /** @var SplFileInfo $current */ + $current = $this->current(); + + return $current->isDir() && !in_array($current->getFilename(), $this::$ignore_folders, true); + } +} diff --git a/system/src/Grav/Common/Filesystem/ZipArchiver.php b/system/src/Grav/Common/Filesystem/ZipArchiver.php new file mode 100644 index 0000000..770e84a --- /dev/null +++ b/system/src/Grav/Common/Filesystem/ZipArchiver.php @@ -0,0 +1,166 @@ +open($this->archive_file); + + if ($archive === true) { + Folder::create($destination); + + if (!$zip->extractTo($destination)) { + throw new RuntimeException('ZipArchiver: ZIP failed to extract ' . $this->archive_file . ' to ' . $destination); + } + + $zip->close(); + + return $this; + } + + throw new RuntimeException('ZipArchiver: Failed to open ' . $this->archive_file); + } + + /** + * @param string $source + * @param callable|null $status + * @return $this + */ + public function compress($source, callable $status = null) + { + if (!extension_loaded('zip')) { + throw new InvalidArgumentException('ZipArchiver: Zip PHP module not installed...'); + } + + // Get real path for our folder + $rootPath = realpath($source); + if (!$rootPath) { + throw new InvalidArgumentException('ZipArchiver: ' . $source . ' cannot be found...'); + } + + $zip = new ZipArchive(); + $result = $zip->open($this->archive_file, ZipArchive::CREATE); + if ($result !== true) { + $error = 'unknown error'; + if ($result === ZipArchive::ER_NOENT) { + $error = 'file does not exist'; + } elseif ($result === ZipArchive::ER_EXISTS) { + $error = 'file already exists'; + } elseif ($result === ZipArchive::ER_OPEN) { + $error = 'cannot open file'; + } elseif ($result === ZipArchive::ER_READ) { + $error = 'read error'; + } elseif ($result === ZipArchive::ER_SEEK) { + $error = 'seek error'; + } + throw new InvalidArgumentException('ZipArchiver: ' . $this->archive_file . ' cannot be created: ' . $error); + } + + $files = $this->getArchiveFiles($rootPath); + + $status && $status([ + 'type' => 'count', + 'steps' => iterator_count($files), + ]); + + foreach ($files as $file) { + $filePath = $file->getPathname(); + $relativePath = ltrim(substr($filePath, strlen($rootPath)), '/'); + + if ($file->isDir()) { + $zip->addEmptyDir($relativePath); + } else { + $zip->addFile($filePath, $relativePath); + } + + $status && $status([ + 'type' => 'progress', + ]); + } + + $status && $status([ + 'type' => 'message', + 'message' => 'Compressing...' + ]); + + $zip->close(); + + return $this; + } + + /** + * @param array $folders + * @param callable|null $status + * @return $this + */ + public function addEmptyFolders($folders, callable $status = null) + { + if (!extension_loaded('zip')) { + throw new InvalidArgumentException('ZipArchiver: Zip PHP module not installed...'); + } + + $zip = new ZipArchive(); + $result = $zip->open($this->archive_file); + if ($result !== true) { + $error = 'unknown error'; + if ($result === ZipArchive::ER_NOENT) { + $error = 'file does not exist'; + } elseif ($result === ZipArchive::ER_EXISTS) { + $error = 'file already exists'; + } elseif ($result === ZipArchive::ER_OPEN) { + $error = 'cannot open file'; + } elseif ($result === ZipArchive::ER_READ) { + $error = 'read error'; + } elseif ($result === ZipArchive::ER_SEEK) { + $error = 'seek error'; + } + throw new InvalidArgumentException('ZipArchiver: ' . $this->archive_file . ' cannot be opened: ' . $error); + } + + $status && $status([ + 'type' => 'message', + 'message' => 'Adding empty folders...' + ]); + + foreach ($folders as $folder) { + if ($zip->addEmptyDir($folder) === false) { + $status && $status([ + 'type' => 'message', + 'message' => 'Warning: Could not add empty directory: ' . $folder + ]); + } + $status && $status([ + 'type' => 'progress', + ]); + } + + $zip->close(); + + return $this; + } +} diff --git a/system/src/Grav/Common/Flex/FlexCollection.php b/system/src/Grav/Common/Flex/FlexCollection.php new file mode 100644 index 0000000..a8b9122 --- /dev/null +++ b/system/src/Grav/Common/Flex/FlexCollection.php @@ -0,0 +1,28 @@ + + */ +abstract class FlexCollection extends \Grav\Framework\Flex\FlexCollection +{ + use FlexGravTrait; + use FlexCollectionTrait; +} diff --git a/system/src/Grav/Common/Flex/FlexIndex.php b/system/src/Grav/Common/Flex/FlexIndex.php new file mode 100644 index 0000000..045934a --- /dev/null +++ b/system/src/Grav/Common/Flex/FlexIndex.php @@ -0,0 +1,29 @@ + + */ +abstract class FlexIndex extends \Grav\Framework\Flex\FlexIndex +{ + use FlexGravTrait; + use FlexIndexTrait; +} diff --git a/system/src/Grav/Common/Flex/FlexObject.php b/system/src/Grav/Common/Flex/FlexObject.php new file mode 100644 index 0000000..ae87b0c --- /dev/null +++ b/system/src/Grav/Common/Flex/FlexObject.php @@ -0,0 +1,74 @@ +getNestedProperty($name, null, $separator); + + // Handle media order field. + if (null === $value && $name === 'media_order') { + return implode(',', $this->getMediaOrder()); + } + + // Handle media fields. + $settings = $this->getFieldSettings($name); + if (($settings['media_field'] ?? false) === true) { + return $this->parseFileProperty($value, $settings); + } + + return $value ?? $default; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::prepareStorage() + */ + public function prepareStorage(): array + { + // Remove extra content from media fields. + $fields = $this->getMediaFields(); + foreach ($fields as $field) { + $data = $this->getNestedProperty($field); + if (is_array($data)) { + foreach ($data as $name => &$image) { + unset($image['image_url'], $image['thumb_url']); + } + unset($image); + $this->setNestedProperty($field, $data); + } + } + + return parent::prepareStorage(); + } +} diff --git a/system/src/Grav/Common/Flex/Traits/FlexCollectionTrait.php b/system/src/Grav/Common/Flex/Traits/FlexCollectionTrait.php new file mode 100644 index 0000000..76049d9 --- /dev/null +++ b/system/src/Grav/Common/Flex/Traits/FlexCollectionTrait.php @@ -0,0 +1,51 @@ + 'flex', + 'directory' => $this->getFlexDirectory(), + 'collection' => $this + ]); + } + if (strpos($name, 'onFlexCollection') !== 0 && strpos($name, 'on') === 0) { + $name = 'onFlexCollection' . substr($name, 2); + } + + $container = $this->getContainer(); + if ($event instanceof Event) { + $container->fireEvent($name, $event); + } else { + $container->dispatchEvent($event); + } + + return $this; + } +} diff --git a/system/src/Grav/Common/Flex/Traits/FlexCommonTrait.php b/system/src/Grav/Common/Flex/Traits/FlexCommonTrait.php new file mode 100644 index 0000000..1ab9e0c --- /dev/null +++ b/system/src/Grav/Common/Flex/Traits/FlexCommonTrait.php @@ -0,0 +1,54 @@ +getContainer(); + + /** @var Twig $twig */ + $twig = $container['twig']; + + try { + return $twig->twig()->resolveTemplate($this->getTemplatePaths($layout)); + } catch (LoaderError $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + return $twig->twig()->resolveTemplate(['flex/404.html.twig']); + } + } + + abstract protected function getTemplatePaths(string $layout): array; + abstract protected function getContainer(): Grav; +} diff --git a/system/src/Grav/Common/Flex/Traits/FlexGravTrait.php b/system/src/Grav/Common/Flex/Traits/FlexGravTrait.php new file mode 100644 index 0000000..05f376c --- /dev/null +++ b/system/src/Grav/Common/Flex/Traits/FlexGravTrait.php @@ -0,0 +1,74 @@ +getContainer(); + + /** @var Flex $flex */ + $flex = $container['flex']; + + return $flex; + } + + /** + * @return UserInterface|null + */ + protected function getActiveUser(): ?UserInterface + { + $container = $this->getContainer(); + + /** @var UserInterface|null $user */ + $user = $container['user'] ?? null; + + return $user; + } + + /** + * @return bool + */ + protected function isAdminSite(): bool + { + $container = $this->getContainer(); + + return isset($container['admin']); + } + + /** + * @return string + */ + protected function getAuthorizeScope(): string + { + return $this->isAdminSite() ? 'admin' : 'site'; + } +} diff --git a/system/src/Grav/Common/Flex/Traits/FlexIndexTrait.php b/system/src/Grav/Common/Flex/Traits/FlexIndexTrait.php new file mode 100644 index 0000000..a362971 --- /dev/null +++ b/system/src/Grav/Common/Flex/Traits/FlexIndexTrait.php @@ -0,0 +1,20 @@ + 'onFlexObjectRender', + 'onBeforeSave' => 'onFlexObjectBeforeSave', + 'onAfterSave' => 'onFlexObjectAfterSave', + 'onBeforeDelete' => 'onFlexObjectBeforeDelete', + 'onAfterDelete' => 'onFlexObjectAfterDelete' + ]; + + if (null === $event) { + $event = new Event([ + 'type' => 'flex', + 'directory' => $this->getFlexDirectory(), + 'object' => $this + ]); + } + + if (isset($events['name'])) { + $name = $events['name']; + } elseif (strpos($name, 'onFlexObject') !== 0 && strpos($name, 'on') === 0) { + $name = 'onFlexObject' . substr($name, 2); + } + + $container = $this->getContainer(); + if ($event instanceof Event) { + $container->fireEvent($name, $event); + } else { + $container->dispatchEvent($event); + } + + return $this; + } +} diff --git a/system/src/Grav/Common/Flex/Types/Generic/GenericCollection.php b/system/src/Grav/Common/Flex/Types/Generic/GenericCollection.php new file mode 100644 index 0000000..ee10f6d --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Generic/GenericCollection.php @@ -0,0 +1,24 @@ + + */ +class GenericCollection extends FlexCollection +{ +} diff --git a/system/src/Grav/Common/Flex/Types/Generic/GenericIndex.php b/system/src/Grav/Common/Flex/Types/Generic/GenericIndex.php new file mode 100644 index 0000000..c774b0f --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Generic/GenericIndex.php @@ -0,0 +1,24 @@ + + */ +class GenericIndex extends FlexIndex +{ +} diff --git a/system/src/Grav/Common/Flex/Types/Generic/GenericObject.php b/system/src/Grav/Common/Flex/Types/Generic/GenericObject.php new file mode 100644 index 0000000..8a74326 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Generic/GenericObject.php @@ -0,0 +1,22 @@ + + * @implements PageCollectionInterface + * + * Incompatibilities with Grav\Common\Page\Collection: + * $page = $collection->key() will not work at all + * $clone = clone $collection does not clone objects inside the collection, does it matter? + * $string = (string)$collection returns collection id instead of comma separated list + * $collection->add() incompatible method signature + * $collection->remove() incompatible method signature + * $collection->filter() incompatible method signature (takes closure instead of callable) + * $collection->prev() does not rewind the internal pointer + * AND most methods are immutable; they do not update the current collection, but return updated one + * + * @method PageIndex getIndex() + */ +class PageCollection extends FlexPageCollection implements PageCollectionInterface +{ + use FlexGravTrait; + use FlexCollectionTrait; + + /** @var array|null */ + protected $_params; + + /** + * @return array + */ + public static function getCachedMethods(): array + { + return [ + // Collection specific methods + 'getRoot' => false, + 'getParams' => false, + 'setParams' => false, + 'params' => false, + 'addPage' => false, + 'merge' => false, + 'intersect' => false, + 'prev' => false, + 'nth' => false, + 'random' => false, + 'append' => false, + 'batch' => false, + 'order' => false, + + // Collection filtering + 'dateRange' => true, + 'visible' => true, + 'nonVisible' => true, + 'pages' => true, + 'modules' => true, + 'modular' => true, + 'nonModular' => true, + 'published' => true, + 'nonPublished' => true, + 'routable' => true, + 'nonRoutable' => true, + 'ofType' => true, + 'ofOneOfTheseTypes' => true, + 'ofOneOfTheseAccessLevels' => true, + 'withOrdered' => true, + 'withModules' => true, + 'withPages' => true, + 'withTranslation' => true, + 'filterBy' => true, + + 'toExtendedArray' => false, + 'getLevelListing' => false, + ] + parent::getCachedMethods(); + } + + /** + * @return PageInterface + */ + public function getRoot() + { + return $this->getIndex()->getRoot(); + } + + /** + * Get the collection params + * + * @return array + */ + public function getParams(): array + { + return $this->_params ?? []; + } + + /** + * Set parameters to the Collection + * + * @param array $params + * @return $this + */ + public function setParams(array $params) + { + $this->_params = $this->_params ? array_merge($this->_params, $params) : $params; + + return $this; + } + + /** + * Get the collection params + * + * @return array + */ + public function params(): array + { + return $this->getParams(); + } + + /** + * Add a single page to a collection + * + * @param PageInterface $page + * @return $this + */ + public function addPage(PageInterface $page) + { + if (!$page instanceof PageObject) { + throw new InvalidArgumentException('$page is not a flex page.'); + } + + // FIXME: support other keys. + $this->set($page->getKey(), $page); + + return $this; + } + + /** + * + * Merge another collection with the current collection + * + * @param PageCollectionInterface $collection + * @return static + * @phpstan-return static + */ + public function merge(PageCollectionInterface $collection) + { + throw new RuntimeException(__METHOD__ . '(): Not Implemented'); + } + + /** + * Intersect another collection with the current collection + * + * @param PageCollectionInterface $collection + * @return static + * @phpstan-return static + */ + public function intersect(PageCollectionInterface $collection) + { + throw new RuntimeException(__METHOD__ . '(): Not Implemented'); + } + + /** + * Set current page. + */ + public function setCurrent(string $path): void + { + throw new RuntimeException(__METHOD__ . '(): Not Implemented'); + } + + /** + * Return previous item. + * + * @return PageInterface|false + * @phpstan-return T|false + */ + public function prev() + { + // FIXME: this method does not rewind the internal pointer! + $key = (string)$this->key(); + $prev = $this->prevSibling($key); + + return $prev !== $this->current() ? $prev : false; + } + + /** + * Return nth item. + * @param int $key + * @return PageInterface|bool + * @phpstan-return T|false + */ + public function nth($key) + { + return $this->slice($key, 1)[0] ?? false; + } + + /** + * Pick one or more random entries. + * + * @param int $num Specifies how many entries should be picked. + * @return static + * @phpstan-return static + */ + public function random($num = 1) + { + return $this->createFrom($this->shuffle()->slice(0, $num)); + } + + /** + * Append new elements to the list. + * + * @param array $items Items to be appended. Existing keys will be overridden with the new values. + * @return static + * @phpstan-return static + */ + public function append($items) + { + throw new RuntimeException(__METHOD__ . '(): Not Implemented'); + } + + /** + * Split collection into array of smaller collections. + * + * @param int $size + * @return static[] + * @phpstan-return static[] + */ + public function batch($size): array + { + $chunks = $this->chunk($size); + + $list = []; + foreach ($chunks as $chunk) { + $list[] = $this->createFrom($chunk); + } + + return $list; + } + + /** + * Reorder collection. + * + * @param string $by + * @param string $dir + * @param array|null $manual + * @param int|null $sort_flags + * @return static + * @phpstan-return static + */ + public function order($by, $dir = 'asc', $manual = null, $sort_flags = null) + { + if (!$this->count()) { + return $this; + } + + if ($by === 'random') { + return $this->shuffle(); + } + + $keys = $this->buildSort($by, $dir, $manual, $sort_flags); + + return $this->createFrom(array_replace(array_flip($keys), $this->toArray()) ?? []); + } + + /** + * @param string $order_by + * @param string $order_dir + * @param array|null $manual + * @param int|null $sort_flags + * @return array + */ + protected function buildSort($order_by = 'default', $order_dir = 'asc', $manual = null, $sort_flags = null): array + { + // do this header query work only once + $header_query = null; + $header_default = null; + if (strpos($order_by, 'header.') === 0) { + $query = explode('|', str_replace('header.', '', $order_by), 2); + $header_query = array_shift($query) ?? ''; + $header_default = array_shift($query); + } + + $list = []; + foreach ($this as $key => $child) { + switch ($order_by) { + case 'title': + $list[$key] = $child->title(); + break; + case 'date': + $list[$key] = $child->date(); + $sort_flags = SORT_REGULAR; + break; + case 'modified': + $list[$key] = $child->modified(); + $sort_flags = SORT_REGULAR; + break; + case 'publish_date': + $list[$key] = $child->publishDate(); + $sort_flags = SORT_REGULAR; + break; + case 'unpublish_date': + $list[$key] = $child->unpublishDate(); + $sort_flags = SORT_REGULAR; + break; + case 'slug': + $list[$key] = $child->slug(); + break; + case 'basename': + $list[$key] = Utils::basename($key); + break; + case 'folder': + $list[$key] = $child->folder(); + break; + case 'manual': + case 'default': + default: + if (is_string($header_query)) { + /** @var Header $child_header */ + $child_header = $child->header(); + $header_value = $child_header->get($header_query); + if (is_array($header_value)) { + $list[$key] = implode(',', $header_value); + } elseif ($header_value) { + $list[$key] = $header_value; + } else { + $list[$key] = $header_default ?: $key; + } + $sort_flags = $sort_flags ?: SORT_REGULAR; + break; + } + $list[$key] = $key; + $sort_flags = $sort_flags ?: SORT_REGULAR; + } + } + + if (null === $sort_flags) { + $sort_flags = SORT_NATURAL | SORT_FLAG_CASE; + } + + // else just sort the list according to specified key + if (extension_loaded('intl') && Grav::instance()['config']->get('system.intl_enabled')) { + $locale = setlocale(LC_COLLATE, '0'); //`setlocale` with a '0' param returns the current locale set + $col = Collator::create($locale); + if ($col) { + $col->setAttribute(Collator::NUMERIC_COLLATION, Collator::ON); + if (($sort_flags & SORT_NATURAL) === SORT_NATURAL) { + $list = preg_replace_callback('~([0-9]+)\.~', static function ($number) { + return sprintf('%032d.', $number[0]); + }, $list); + if (!is_array($list)) { + throw new RuntimeException('Internal Error'); + } + + $list_vals = array_values($list); + if (is_numeric(array_shift($list_vals))) { + $sort_flags = Collator::SORT_REGULAR; + } else { + $sort_flags = Collator::SORT_STRING; + } + } + + $col->asort($list, $sort_flags); + } else { + asort($list, $sort_flags); + } + } else { + asort($list, $sort_flags); + } + + // Move manually ordered items into the beginning of the list. Order of the unlisted items does not change. + if (is_array($manual) && !empty($manual)) { + $i = count($manual); + $new_list = []; + foreach ($list as $key => $dummy) { + $child = $this[$key] ?? null; + $order = $child ? array_search($child->slug, $manual, true) : false; + if ($order === false) { + $order = $i++; + } + $new_list[$key] = (int)$order; + } + + $list = $new_list; + + // Apply manual ordering to the list. + asort($list, SORT_NUMERIC); + } + + if ($order_dir !== 'asc') { + $list = array_reverse($list); + } + + return array_keys($list); + } + + /** + * Mimicks Pages class. + * + * @return $this + * @deprecated 1.7 Not needed anymore in Flex Pages (does nothing). + */ + public function all() + { + return $this; + } + + /** + * Returns the items between a set of date ranges of either the page date field (default) or + * an arbitrary datetime page field where start date and end date are optional + * Dates must be passed in as text that strtotime() can process + * http://php.net/manual/en/function.strtotime.php + * + * @param string|null $startDate + * @param string|null $endDate + * @param string|null $field + * @return static + * @phpstan-return static + * @throws Exception + */ + public function dateRange($startDate = null, $endDate = null, $field = null) + { + $start = $startDate ? Utils::date2timestamp($startDate) : null; + $end = $endDate ? Utils::date2timestamp($endDate) : null; + + $entries = []; + foreach ($this as $key => $object) { + if (!$object) { + continue; + } + + $date = $field ? strtotime($object->getNestedProperty($field)) : $object->date(); + + if ((!$start || $date >= $start) && (!$end || $date <= $end)) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only visible pages + * + * @return static The collection with only visible pages + * @phpstan-return static + */ + public function visible() + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && $object->visible()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only non-visible pages + * + * @return static The collection with only non-visible pages + * @phpstan-return static + */ + public function nonVisible() + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && !$object->visible()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only pages + * + * @return static The collection with only pages + * @phpstan-return static + */ + public function pages() + { + $entries = []; + /** + * @var int|string $key + * @var PageInterface|null $object + */ + foreach ($this as $key => $object) { + if ($object && !$object->isModule()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only modules + * + * @return static The collection with only modules + * @phpstan-return static + */ + public function modules() + { + $entries = []; + /** + * @var int|string $key + * @var PageInterface|null $object + */ + foreach ($this as $key => $object) { + if ($object && $object->isModule()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Alias of modules() + * + * @return static + * @phpstan-return static + */ + public function modular() + { + return $this->modules(); + } + + /** + * Alias of pages() + * + * @return static + * @phpstan-return static + */ + public function nonModular() + { + return $this->pages(); + } + + /** + * Creates new collection with only published pages + * + * @return static The collection with only published pages + * @phpstan-return static + */ + public function published() + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && $object->published()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only non-published pages + * + * @return static The collection with only non-published pages + * @phpstan-return static + */ + public function nonPublished() + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && !$object->published()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only routable pages + * + * @return static The collection with only routable pages + * @phpstan-return static + */ + public function routable() + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && $object->routable()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only non-routable pages + * + * @return static The collection with only non-routable pages + * @phpstan-return static + */ + public function nonRoutable() + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && !$object->routable()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only pages of the specified type + * + * @param string $type + * @return static The collection + * @phpstan-return static + */ + public function ofType($type) + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && $object->template() === $type) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only pages of one of the specified types + * + * @param string[] $types + * @return static The collection + * @phpstan-return static + */ + public function ofOneOfTheseTypes($types) + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && in_array($object->template(), $types, true)) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only pages of one of the specified access levels + * + * @param array $accessLevels + * @return static The collection + * @phpstan-return static + */ + public function ofOneOfTheseAccessLevels($accessLevels) + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && isset($object->header()->access)) { + if (is_array($object->header()->access)) { + //Multiple values for access + $valid = false; + + foreach ($object->header()->access as $index => $accessLevel) { + if (is_array($accessLevel)) { + foreach ($accessLevel as $innerIndex => $innerAccessLevel) { + if (in_array($innerAccessLevel, $accessLevels)) { + $valid = true; + } + } + } else { + if (in_array($index, $accessLevels)) { + $valid = true; + } + } + } + if ($valid) { + $entries[$key] = $object; + } + } else { + //Single value for access + if (in_array($object->header()->access, $accessLevels)) { + $entries[$key] = $object; + } + } + } + } + + return $this->createFrom($entries); + } + + /** + * @param bool $bool + * @return static + * @phpstan-return static + */ + public function withOrdered(bool $bool = true) + { + $list = array_keys(array_filter($this->call('isOrdered', [$bool]))); + + return $this->select($list); + } + + /** + * @param bool $bool + * @return static + * @phpstan-return static + */ + public function withModules(bool $bool = true) + { + $list = array_keys(array_filter($this->call('isModule', [$bool]))); + + return $this->select($list); + } + + /** + * @param bool $bool + * @return static + * @phpstan-return static + */ + public function withPages(bool $bool = true) + { + $list = array_keys(array_filter($this->call('isPage', [$bool]))); + + return $this->select($list); + } + + /** + * @param bool $bool + * @param string|null $languageCode + * @param bool|null $fallback + * @return static + * @phpstan-return static + */ + public function withTranslation(bool $bool = true, string $languageCode = null, bool $fallback = null) + { + $list = array_keys(array_filter($this->call('hasTranslation', [$languageCode, $fallback]))); + + return $bool ? $this->select($list) : $this->unselect($list); + } + + /** + * @param string|null $languageCode + * @param bool|null $fallback + * @return PageIndex + */ + public function withTranslated(string $languageCode = null, bool $fallback = null) + { + return $this->getIndex()->withTranslated($languageCode, $fallback); + } + + /** + * Filter pages by given filters. + * + * - search: string + * - page_type: string|string[] + * - modular: bool + * - visible: bool + * - routable: bool + * - published: bool + * - page: bool + * - translated: bool + * + * @param array $filters + * @param bool $recursive + * @return static + * @phpstan-return static + */ + public function filterBy(array $filters, bool $recursive = false) + { + $list = array_keys(array_filter($this->call('filterBy', [$filters, $recursive]))); + + return $this->select($list); + } + + /** + * Get the extended version of this Collection with each page keyed by route + * + * @return array + * @throws Exception + */ + public function toExtendedArray(): array + { + $entries = []; + foreach ($this as $key => $object) { + if ($object) { + $entries[$object->route()] = $object->toArray(); + } + } + + return $entries; + } + + /** + * @param array $options + * @return array + */ + public function getLevelListing(array $options): array + { + /** @var PageIndex $index */ + $index = $this->getIndex(); + + return method_exists($index, 'getLevelListing') ? $index->getLevelListing($options) : []; + } +} diff --git a/system/src/Grav/Common/Flex/Types/Pages/PageIndex.php b/system/src/Grav/Common/Flex/Types/Pages/PageIndex.php new file mode 100644 index 0000000..ac8e899 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Pages/PageIndex.php @@ -0,0 +1,1198 @@ + + * @implements PageCollectionInterface + * + * @method PageIndex withModules(bool $bool = true) + * @method PageIndex withPages(bool $bool = true) + * @method PageIndex withTranslation(bool $bool = true, string $languageCode = null, bool $fallback = null) + */ +class PageIndex extends FlexPageIndex implements PageCollectionInterface +{ + use FlexGravTrait; + use FlexIndexTrait; + + public const VERSION = parent::VERSION . '.5'; + public const ORDER_LIST_REGEX = '/(\/\d+)\.[^\/]+/u'; + public const PAGE_ROUTE_REGEX = '/\/\d+\./u'; + + /** @var PageObject|array */ + protected $_root; + /** @var array|null */ + protected $_params; + + /** + * @param array $entries + * @param FlexDirectory|null $directory + */ + public function __construct(array $entries = [], FlexDirectory $directory = null) + { + // Remove root if it's taken. + if (isset($entries[''])) { + $this->_root = $entries['']; + unset($entries['']); + } + + parent::__construct($entries, $directory); + } + + /** + * @param FlexStorageInterface $storage + * @return array + */ + public static function loadEntriesFromStorage(FlexStorageInterface $storage): array + { + // Load saved index. + $index = static::loadIndex($storage); + + $version = $index['version'] ?? 0; + $force = static::VERSION !== $version; + + // TODO: Following check flex index to be out of sync after some saves, disabled until better solution is found. + //$timestamp = $index['timestamp'] ?? 0; + //if (!$force && $timestamp && $timestamp > time() - 1) { + // return $index['index']; + //} + + // Load up to date index. + $entries = parent::loadEntriesFromStorage($storage); + + return static::updateIndexFile($storage, $index['index'], $entries, ['include_missing' => true, 'force_update' => $force]); + } + + /** + * @param string $key + * @return PageObject|null + * @phpstan-return T|null + */ + public function get($key) + { + if (mb_strpos($key, '|') !== false) { + [$key, $params] = explode('|', $key, 2); + } + + $element = parent::get($key); + if (null === $element) { + return null; + } + + if (isset($params)) { + $element = $element->getTranslation(ltrim($params, '.')); + } + + \assert(null === $element || $element instanceof PageObject); + + return $element; + } + + /** + * @return PageInterface + */ + public function getRoot() + { + $root = $this->_root; + if (is_array($root)) { + $directory = $this->getFlexDirectory(); + $storage = $directory->getStorage(); + + $defaults = [ + 'header' => [ + 'routable' => false, + 'permissions' => [ + 'inherit' => false + ] + ] + ]; + + $row = $storage->readRows(['' => null])[''] ?? null; + if (null !== $row) { + if (isset($row['__ERROR'])) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $message = sprintf('Flex Pages: root page is broken in storage: %s', $row['__ERROR']); + + $debugger->addException(new RuntimeException($message)); + $debugger->addMessage($message, 'error'); + + $row = ['__META' => $root]; + } + + } else { + $row = ['__META' => $root]; + } + + $row = array_merge_recursive($defaults, $row); + + /** @var PageObject $root */ + $root = $this->getFlexDirectory()->createObject($row, '/', false); + $root->name('root.md'); + $root->root(true); + + $this->_root = $root; + } + + return $root; + } + + /** + * @param string|null $languageCode + * @param bool|null $fallback + * @return static + * @phpstan-return static + */ + public function withTranslated(string $languageCode = null, bool $fallback = null) + { + if (null === $languageCode) { + return $this; + } + + $entries = $this->translateEntries($this->getEntries(), $languageCode, $fallback); + $params = ['language' => $languageCode, 'language_fallback' => $fallback] + $this->getParams(); + + return $this->createFrom($entries)->setParams($params); + } + + /** + * @return string|null + */ + public function getLanguage(): ?string + { + return $this->_params['language'] ?? null; + } + + /** + * Get the collection params + * + * @return array + */ + public function getParams(): array + { + return $this->_params ?? []; + } + + /** + * Get the collection param + * + * @param string $name + * @return mixed + */ + public function getParam(string $name) + { + return $this->_params[$name] ?? null; + } + + /** + * Set parameters to the Collection + * + * @param array $params + * @return $this + */ + public function setParams(array $params) + { + $this->_params = $this->_params ? array_merge($this->_params, $params) : $params; + + return $this; + } + + /** + * Set a parameter to the Collection + * + * @param string $name + * @param mixed $value + * @return $this + */ + public function setParam(string $name, $value) + { + $this->_params[$name] = $value; + + return $this; + } + + /** + * Get the collection params + * + * @return array + */ + public function params(): array + { + return $this->getParams(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getCacheKey() + */ + public function getCacheKey(): string + { + return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1(json_encode($this->getKeys()) . $this->getKeyField() . $this->getLanguage()); + } + + /** + * Filter pages by given filters. + * + * - search: string + * - page_type: string|string[] + * - modular: bool + * - visible: bool + * - routable: bool + * - published: bool + * - page: bool + * - translated: bool + * + * @param array $filters + * @param bool $recursive + * @return static + * @phpstan-return static + */ + public function filterBy(array $filters, bool $recursive = false) + { + if (!$filters) { + return $this; + } + + if ($recursive) { + return $this->__call('filterBy', [$filters, true]); + } + + $list = []; + $index = $this; + foreach ($filters as $key => $value) { + switch ($key) { + case 'search': + $index = $index->search((string)$value); + break; + case 'page_type': + if (!is_array($value)) { + $value = is_string($value) && $value !== '' ? explode(',', $value) : []; + } + $index = $index->ofOneOfTheseTypes($value); + break; + case 'routable': + $index = $index->withRoutable((bool)$value); + break; + case 'published': + $index = $index->withPublished((bool)$value); + break; + case 'visible': + $index = $index->withVisible((bool)$value); + break; + case 'module': + $index = $index->withModules((bool)$value); + break; + case 'page': + $index = $index->withPages((bool)$value); + break; + case 'folder': + $index = $index->withPages(!$value); + break; + case 'translated': + $index = $index->withTranslation((bool)$value); + break; + default: + $list[$key] = $value; + } + } + + return $list ? $index->filterByParent($list) : $index; + } + + /** + * @param array $filters + * @return static + * @phpstan-return static + */ + protected function filterByParent(array $filters) + { + /** @var static $index */ + $index = parent::filterBy($filters); + + return $index; + } + + /** + * @param array $options + * @return array + */ + public function getLevelListing(array $options): array + { + // Undocumented B/C + $order = $options['order'] ?? 'asc'; + if ($order === SORT_ASC) { + $options['order'] = 'asc'; + } elseif ($order === SORT_DESC) { + $options['order'] = 'desc'; + } + + $options += [ + 'field' => null, + 'route' => null, + 'leaf_route' => null, + 'sortby' => null, + 'order' => 'asc', + 'lang' => null, + 'filters' => [], + ]; + + $options['filters'] += [ + 'type' => ['root', 'dir'], + ]; + + $key = 'page.idx.lev.' . sha1(json_encode($options, JSON_THROW_ON_ERROR) . $this->getCacheKey()); + $checksum = $this->getCacheChecksum(); + + $cache = $this->getCache('object'); + + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + + $result = null; + try { + $cached = $cache->get($key); + $test = $cached[0] ?? null; + $result = $test === $checksum ? ($cached[1] ?? null) : null; + } catch (\Psr\SimpleCache\InvalidArgumentException $e) { + $debugger->addException($e); + } + + try { + if (null === $result) { + $result = $this->getLevelListingRecurse($options); + $cache->set($key, [$checksum, $result]); + } + } catch (\Psr\SimpleCache\InvalidArgumentException $e) { + $debugger->addException($e); + } + + return $result; + } + + /** + * @param array $entries + * @param string|null $keyField + * @return static + * @phpstan-return static + */ + protected function createFrom(array $entries, string $keyField = null) + { + /** @var static $index */ + $index = parent::createFrom($entries, $keyField); + $index->_root = $this->getRoot(); + + return $index; + } + + /** + * @param array $entries + * @param string $lang + * @param bool|null $fallback + * @return array + */ + protected function translateEntries(array $entries, string $lang, bool $fallback = null): array + { + $languages = $this->getFallbackLanguages($lang, $fallback); + foreach ($entries as $key => &$entry) { + // Find out which version of the page we should load. + $translations = $this->getLanguageTemplates((string)$key); + if (!$translations) { + // No translations found, is this a folder? + continue; + } + + // Find a translation. + $template = null; + foreach ($languages as $code) { + if (isset($translations[$code])) { + $template = $translations[$code]; + break; + } + } + + // We couldn't find a translation, remove entry from the list. + if (!isset($code, $template)) { + unset($entries['key']); + continue; + } + + // Get the main key without template and language. + [$main_key,] = explode('|', $entry['storage_key'] . '|', 2); + + // Update storage key and language. + $entry['storage_key'] = $main_key . '|' . $template . '.' . $code; + $entry['lang'] = $code; + } + unset($entry); + + return $entries; + } + + /** + * @return array + */ + protected function getLanguageTemplates(string $key): array + { + $meta = $this->getMetaData($key); + $template = $meta['template'] ?? 'folder'; + $translations = $meta['markdown'] ?? []; + $list = []; + foreach ($translations as $code => $search) { + if (isset($search[$template])) { + // Use main template if possible. + $list[$code] = $template; + } elseif (!empty($search)) { + // Fall back to first matching template. + $list[$code] = key($search); + } + } + + return $list; + } + + /** + * @param string|null $languageCode + * @param bool|null $fallback + * @return array + */ + protected function getFallbackLanguages(string $languageCode = null, bool $fallback = null): array + { + $fallback = $fallback ?? true; + if (!$fallback && null !== $languageCode) { + return [$languageCode]; + } + + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + $languageCode = $languageCode ?? ''; + if ($languageCode === '' && $fallback) { + return $language->getFallbackLanguages(null, true); + } + + return $fallback ? $language->getFallbackLanguages($languageCode, true) : [$languageCode]; + } + + /** + * @param array $options + * @return array + */ + protected function getLevelListingRecurse(array $options): array + { + $filters = $options['filters'] ?? []; + $field = $options['field']; + $route = $options['route']; + $leaf_route = $options['leaf_route']; + $sortby = $options['sortby']; + $order = $options['order']; + $language = $options['lang']; + + $status = 'error'; + $response = []; + $extra = null; + + // Handle leaf_route + $leaf = null; + if ($leaf_route && $route !== $leaf_route) { + $nodes = explode('/', $leaf_route); + $sub_route = '/' . implode('/', array_slice($nodes, 1, $options['level']++)); + $options['route'] = $sub_route; + + [$status,,$leaf,$extra] = $this->getLevelListingRecurse($options); + } + + // Handle no route, assume page tree root + if (!$route) { + $page = $this->getRoot(); + } else { + $page = $this->get(trim($route, '/')); + } + $path = $page ? $page->path() : null; + + if ($field) { + // Get forced filters from the field. + $blueprint = $page ? $page->getBlueprint() : $this->getFlexDirectory()->getBlueprint(); + $settings = $blueprint->schema()->getProperty($field); + $filters = array_merge([], $filters, $settings['filters'] ?? []); + } + + // Clean up filter. + $filter_type = (array)($filters['type'] ?? []); + unset($filters['type']); + $filters = array_filter($filters, static function($val) { return $val !== null && $val !== ''; }); + + if ($page) { + $status = 'success'; + $msg = 'PLUGIN_ADMIN.PAGE_ROUTE_FOUND'; + + if ($page->root() && (!$filter_type || in_array('root', $filter_type, true))) { + if ($field) { + $response[] = [ + 'name' => '', + 'value' => '/', + 'item-key' => '', + 'filename' => '.', + 'extension' => '', + 'type' => 'root', + 'modified' => $page->modified(), + 'size' => 0, + 'symlink' => false, + 'has-children' => false + ]; + } else { + $response[] = [ + 'item-key' => '-root-', + 'icon' => 'root', + 'title' => 'Root', // FIXME + 'route' => [ + 'display' => '<root>', // FIXME + 'raw' => '_root', + ], + 'modified' => $page->modified(), + 'extras' => [ + 'template' => $page->template(), + //'lang' => null, + //'translated' => null, + 'langs' => [], + 'published' => false, + 'visible' => false, + 'routable' => false, + 'tags' => ['root', 'non-routable'], + 'actions' => ['edit'], // FIXME + ] + ]; + } + } + + /** @var PageCollection|PageIndex $children */ + $children = $page->children(); + /** @var PageIndex $children */ + $children = $children->getIndex(); + $selectedChildren = $children->filterBy($filters + ['language' => $language], true); + + /** @var Header $header */ + $header = $page->header(); + + if (!$field && $header->get('admin.children_display_order', 'collection') === 'collection' && ($orderby = $header->get('content.order.by'))) { + // Use custom sorting by page header. + $sortby = $orderby; + $order = $header->get('content.order.dir', $order); + $custom = $header->get('content.order.custom'); + } + + if ($sortby) { + // Sort children. + $selectedChildren = $selectedChildren->order($sortby, $order, $custom ?? null); + } + + /** @var UserInterface|null $user */ + $user = Grav::instance()['user'] ?? null; + + /** @var PageObject $child */ + foreach ($selectedChildren as $child) { + $selected = $child->path() === $extra; + $includeChildren = is_array($leaf) && !empty($leaf) && $selected; + if ($field) { + $child_count = count($child->children()); + $payload = [ + 'name' => $child->menu(), + 'value' => $child->rawRoute(), + 'item-key' => Utils::basename($child->rawRoute() ?? ''), + 'filename' => $child->folder(), + 'extension' => $child->extension(), + 'type' => 'dir', + 'modified' => $child->modified(), + 'size' => $child_count, + 'symlink' => false, + 'has-children' => $child_count > 0 + ]; + } else { + $lang = $child->findTranslation($language) ?? 'n/a'; + /** @var PageObject $child */ + $child = $child->getTranslation($language) ?? $child; + + // TODO: all these features are independent from each other, we cannot just have one icon/color to catch all. + // TODO: maybe icon by home/modular/page/folder (or even from blueprints) and color by visibility etc.. + if ($child->home()) { + $icon = 'home'; + } elseif ($child->isModule()) { + $icon = 'modular'; + } elseif ($child->visible()) { + $icon = 'visible'; + } elseif ($child->isPage()) { + $icon = 'page'; + } else { + // TODO: add support + $icon = 'folder'; + } + $tags = [ + $child->published() ? 'published' : 'non-published', + $child->visible() ? 'visible' : 'non-visible', + $child->routable() ? 'routable' : 'non-routable' + ]; + $extras = [ + 'template' => $child->template(), + 'lang' => $lang ?: null, + 'translated' => $lang ? $child->hasTranslation($language, false) : null, + 'langs' => $child->getAllLanguages(true) ?: null, + 'published' => $child->published(), + 'published_date' => $this->jsDate($child->publishDate()), + 'unpublished_date' => $this->jsDate($child->unpublishDate()), + 'visible' => $child->visible(), + 'routable' => $child->routable(), + 'tags' => $tags, + 'actions' => $this->getListingActions($child, $user), + ]; + $extras = array_filter($extras, static function ($v) { + return $v !== null; + }); + + /** @var PageIndex $tmp */ + $tmp = $child->children()->getIndex(); + $child_count = $tmp->count(); + $count = $filters ? $tmp->filterBy($filters, true)->count() : null; + $route = $child->getRoute(); + $route = $route ? ($route->toString(false) ?: '/') : ''; + $payload = [ + 'item-key' => htmlspecialchars(Utils::basename($child->rawRoute() ?? $child->getKey())), + 'icon' => $icon, + 'title' => htmlspecialchars($child->menu()), + 'route' => [ + 'display' => htmlspecialchars($route) ?: null, + 'raw' => htmlspecialchars($child->rawRoute()), + ], + 'modified' => $this->jsDate($child->modified()), + 'child_count' => $child_count ?: null, + 'count' => $count ?? null, + 'filters_hit' => $filters ? ($child->filterBy($filters, false) ?: null) : null, + 'extras' => $extras + ]; + $payload = array_filter($payload, static function ($v) { + return $v !== null; + }); + } + + // Add children if any + if ($includeChildren) { + $payload['children'] = array_values($leaf); + } + + $response[] = $payload; + } + } else { + $msg = 'PLUGIN_ADMIN.PAGE_ROUTE_NOT_FOUND'; + } + + if ($field) { + $temp_array = []; + foreach ($response as $index => $item) { + $temp_array[$item['type']][$index] = $item; + } + + $sorted = Utils::sortArrayByArray($temp_array, $filter_type); + $response = Utils::arrayFlatten($sorted); + } + + return [$status, $msg, $response, $path]; + } + + /** + * @param PageObject $object + * @param UserInterface $user + * @return array + */ + protected function getListingActions(PageObject $object, UserInterface $user): array + { + $actions = []; + if ($object->isAuthorized('read', null, $user)) { + $actions[] = 'preview'; + $actions[] = 'edit'; + } + if ($object->isAuthorized('update', null, $user)) { + $actions[] = 'copy'; + $actions[] = 'move'; + } + if ($object->isAuthorized('delete', null, $user)) { + $actions[] = 'delete'; + } + + return $actions; + } + + /** + * @param FlexStorageInterface $storage + * @return CompiledJsonFile|CompiledYamlFile|null + */ + protected static function getIndexFile(FlexStorageInterface $storage) + { + if (!method_exists($storage, 'isIndexed') || !$storage->isIndexed()) { + return null; + } + + // Load saved index file. + $grav = Grav::instance(); + $locator = $grav['locator']; + + $filename = $locator->findResource('user-data://flex/indexes/pages.json', true, true); + + return CompiledJsonFile::instance($filename); + } + + /** + * @param int|null $timestamp + * @return string|null + */ + private function jsDate(int $timestamp = null): ?string + { + if (!$timestamp) { + return null; + } + + $config = Grav::instance()['config']; + $dateFormat = $config->get('system.pages.dateformat.long'); + + return date($dateFormat, $timestamp) ?: null; + } + + /** + * Add a single page to a collection + * + * @param PageInterface $page + * @return PageCollection + * @phpstan-return C + */ + public function addPage(PageInterface $page) + { + return $this->getCollection()->addPage($page); + } + + /** + * + * Create a copy of this collection + * + * @return static + * @phpstan-return static + */ + public function copy() + { + return clone $this; + } + + /** + * + * Merge another collection with the current collection + * + * @param PageCollectionInterface $collection + * @return PageCollection + * @phpstan-return C + */ + public function merge(PageCollectionInterface $collection) + { + return $this->getCollection()->merge($collection); + } + + + /** + * Intersect another collection with the current collection + * + * @param PageCollectionInterface $collection + * @return PageCollection + * @phpstan-return C + */ + public function intersect(PageCollectionInterface $collection) + { + return $this->getCollection()->intersect($collection); + } + + /** + * Split collection into array of smaller collections. + * + * @param int $size + * @return PageCollection[] + * @phpstan-return C[] + */ + public function batch($size) + { + return $this->getCollection()->batch($size); + } + + /** + * Remove item from the list. + * + * @param string $key + * @return PageObject|null + * @phpstan-return T|null + * @throws InvalidArgumentException + */ + public function remove($key) + { + return $this->getCollection()->remove($key); + } + + /** + * Reorder collection. + * + * @param string $by + * @param string $dir + * @param array $manual + * @param string $sort_flags + * @return static + * @phpstan-return static + */ + public function order($by, $dir = 'asc', $manual = null, $sort_flags = null) + { + /** @var PageCollectionInterface $collection */ + $collection = $this->__call('order', [$by, $dir, $manual, $sort_flags]); + + return $collection; + } + + /** + * Check to see if this item is the first in the collection. + * + * @param string $path + * @return bool True if item is first. + */ + public function isFirst($path): bool + { + /** @var bool $result */ + $result = $this->__call('isFirst', [$path]); + + return $result; + + } + + /** + * Check to see if this item is the last in the collection. + * + * @param string $path + * @return bool True if item is last. + */ + public function isLast($path): bool + { + /** @var bool $result */ + $result = $this->__call('isLast', [$path]); + + return $result; + } + + /** + * Gets the previous sibling based on current position. + * + * @param string $path + * @return PageObject|null The previous item. + * @phpstan-return T|null + */ + public function prevSibling($path) + { + /** @var PageObject|null $result */ + $result = $this->__call('prevSibling', [$path]); + + return $result; + } + + /** + * Gets the next sibling based on current position. + * + * @param string $path + * @return PageObject|null The next item. + * @phpstan-return T|null + */ + public function nextSibling($path) + { + /** @var PageObject|null $result */ + $result = $this->__call('nextSibling', [$path]); + + return $result; + } + + /** + * Returns the adjacent sibling based on a direction. + * + * @param string $path + * @param int $direction either -1 or +1 + * @return PageObject|false The sibling item. + * @phpstan-return T|false + */ + public function adjacentSibling($path, $direction = 1) + { + /** @var PageObject|false $result */ + $result = $this->__call('adjacentSibling', [$path, $direction]); + + return $result; + } + + /** + * Returns the item in the current position. + * + * @param string $path the path the item + * @return int|null The index of the current page, null if not found. + */ + public function currentPosition($path): ?int + { + /** @var int|null $result */ + $result = $this->__call('currentPosition', [$path]); + + return $result; + } + + /** + * Returns the items between a set of date ranges of either the page date field (default) or + * an arbitrary datetime page field where start date and end date are optional + * Dates must be passed in as text that strtotime() can process + * http://php.net/manual/en/function.strtotime.php + * + * @param string|null $startDate + * @param string|null $endDate + * @param string|null $field + * @return static + * @phpstan-return static + * @throws Exception + */ + public function dateRange($startDate = null, $endDate = null, $field = null) + { + $collection = $this->__call('dateRange', [$startDate, $endDate, $field]); + + return $collection; + } + + /** + * Mimicks Pages class. + * + * @return $this + * @deprecated 1.7 Not needed anymore in Flex Pages (does nothing). + */ + public function all() + { + return $this; + } + + /** + * Creates new collection with only visible pages + * + * @return static The collection with only visible pages + * @phpstan-return static + */ + public function visible() + { + $collection = $this->__call('visible', []); + + return $collection; + } + + /** + * Creates new collection with only non-visible pages + * + * @return static The collection with only non-visible pages + * @phpstan-return static + */ + public function nonVisible() + { + $collection = $this->__call('nonVisible', []); + + return $collection; + } + + /** + * Creates new collection with only non-modular pages + * + * @return static The collection with only non-modular pages + * @phpstan-return static + */ + public function pages() + { + $collection = $this->__call('pages', []); + + return $collection; + } + + /** + * Creates new collection with only modular pages + * + * @return static The collection with only modular pages + * @phpstan-return static + */ + public function modules() + { + $collection = $this->__call('modules', []); + + return $collection; + } + + /** + * Creates new collection with only modular pages + * + * @return static The collection with only modular pages + * @phpstan-return static + */ + public function modular() + { + return $this->modules(); + } + + /** + * Creates new collection with only non-modular pages + * + * @return static The collection with only non-modular pages + * @phpstan-return static + */ + public function nonModular() + { + return $this->pages(); + } + + /** + * Creates new collection with only published pages + * + * @return static The collection with only published pages + * @phpstan-return static + */ + public function published() + { + $collection = $this->__call('published', []); + + return $collection; + } + + /** + * Creates new collection with only non-published pages + * + * @return static The collection with only non-published pages + * @phpstan-return static + */ + public function nonPublished() + { + $collection = $this->__call('nonPublished', []); + + return $collection; + } + + /** + * Creates new collection with only routable pages + * + * @return static The collection with only routable pages + * @phpstan-return static + */ + public function routable() + { + $collection = $this->__call('routable', []); + + return $collection; + } + + /** + * Creates new collection with only non-routable pages + * + * @return static The collection with only non-routable pages + * @phpstan-return static + */ + public function nonRoutable() + { + $collection = $this->__call('nonRoutable', []); + + return $collection; + } + + /** + * Creates new collection with only pages of the specified type + * + * @param string $type + * @return static The collection + * @phpstan-return static + */ + public function ofType($type) + { + $collection = $this->__call('ofType', [$type]); + + return $collection; + } + + /** + * Creates new collection with only pages of one of the specified types + * + * @param string[] $types + * @return static The collection + * @phpstan-return static + */ + public function ofOneOfTheseTypes($types) + { + $collection = $this->__call('ofOneOfTheseTypes', [$types]); + + return $collection; + } + + /** + * Creates new collection with only pages of one of the specified access levels + * + * @param array $accessLevels + * @return static The collection + * @phpstan-return static + */ + public function ofOneOfTheseAccessLevels($accessLevels) + { + $collection = $this->__call('ofOneOfTheseAccessLevels', [$accessLevels]); + + return $collection; + } + + /** + * Converts collection into an array. + * + * @return array + */ + public function toArray() + { + return $this->getCollection()->toArray(); + } + + /** + * Get the extended version of this Collection with each page keyed by route + * + * @return array + * @throws Exception + */ + public function toExtendedArray() + { + return $this->getCollection()->toExtendedArray(); + } + +} diff --git a/system/src/Grav/Common/Flex/Types/Pages/PageObject.php b/system/src/Grav/Common/Flex/Types/Pages/PageObject.php new file mode 100644 index 0000000..6ce2c21 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Pages/PageObject.php @@ -0,0 +1,744 @@ + true, + 'full_order' => true, + 'filterBy' => true, + 'translated' => false, + ] + parent::getCachedMethods(); + } + + /** + * @return void + */ + public function initialize(): void + { + if (!$this->_initialized) { + Grav::instance()->fireEvent('onPageProcessed', new Event(['page' => $this])); + $this->_initialized = true; + } + } + + /** + * @param string|array $query + * @return Route|null + */ + public function getRoute($query = []): ?Route + { + $path = $this->route(); + if (null === $path) { + return null; + } + + $route = RouteFactory::createFromString($path); + if ($lang = $route->getLanguage()) { + $grav = Grav::instance(); + if (!$grav['config']->get('system.languages.include_default_lang')) { + /** @var Language $language */ + $language = $grav['language']; + if ($lang === $language->getDefault()) { + $route = $route->withLanguage(''); + } + } + } + if (is_array($query)) { + foreach ($query as $key => $value) { + $route = $route->withQueryParam($key, $value); + } + } else { + $route = $route->withAddedPath($query); + } + + return $route; + } + + /** + * @inheritdoc PageInterface + */ + public function getFormValue(string $name, $default = null, string $separator = null) + { + $test = new stdClass(); + + $value = $this->pageContentValue($name, $test); + if ($value !== $test) { + return $value; + } + + switch ($name) { + case 'name': + // TODO: this should not be template! + return $this->getProperty('template'); + case 'route': + $filesystem = Filesystem::getInstance(false); + $key = $filesystem->dirname($this->hasKey() ? '/' . $this->getKey() : '/'); + return $key !== '/' ? $key : null; + case 'full_route': + return $this->hasKey() ? '/' . $this->getKey() : ''; + case 'full_order': + return $this->full_order(); + case 'lang': + return $this->getLanguage() ?? ''; + case 'translations': + return $this->getLanguages(); + } + + return parent::getFormValue($name, $default, $separator); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getCacheKey() + */ + public function getCacheKey(): string + { + $cacheKey = parent::getCacheKey(); + if ($cacheKey) { + /** @var Language $language */ + $language = Grav::instance()['language']; + $cacheKey .= '_' . $language->getActive(); + } + + return $cacheKey; + } + + /** + * @param array $variables + * @return array + */ + protected function onBeforeSave(array $variables) + { + $reorder = $variables[0] ?? true; + + $meta = $this->getMetaData(); + if (($meta['copy'] ?? false) === true) { + $this->folder = $this->getKey(); + } + + // Figure out storage path to the new route. + $parentKey = $this->getProperty('parent_key'); + if ($parentKey !== '') { + $parentRoute = $this->getProperty('route'); + + // Root page cannot be moved. + if ($this->root()) { + throw new RuntimeException(sprintf('Root page cannot be moved to %s', $parentRoute)); + } + + // Make sure page isn't being moved under itself. + $key = $this->getStorageKey(); + + /** @var PageObject|null $parent */ + $parent = $parentKey !== false ? $this->getFlexDirectory()->getObject($parentKey, 'storage_key') : null; + if (!$parent) { + // Page cannot be moved to non-existing location. + throw new RuntimeException(sprintf('Page /%s cannot be moved to non-existing path %s', $key, $parentRoute)); + } + + // TODO: make sure that the page doesn't exist yet if moved/copied. + } + + if ($reorder === true && !$this->root()) { + $reorder = $this->_reorder; + } + + // Force automatic reorder if item is supposed to be added to the last. + if (!is_array($reorder) && (int)$this->order() >= 999999) { + $reorder = []; + } + + // Reorder siblings. + $siblings = is_array($reorder) ? ($this->reorderSiblings($reorder) ?? []) : []; + + $data = $this->prepareStorage(); + unset($data['header']); + + foreach ($siblings as $sibling) { + $data = $sibling->prepareStorage(); + unset($data['header']); + } + + return ['reorder' => $reorder, 'siblings' => $siblings]; + } + + /** + * @param array $variables + * @return array + */ + protected function onSave(array $variables): array + { + /** @var PageCollection $siblings */ + $siblings = $variables['siblings']; + /** @var PageObject $sibling */ + foreach ($siblings as $sibling) { + $sibling->save(false); + } + + return $variables; + } + + /** + * @param array $variables + */ + protected function onAfterSave(array $variables): void + { + $this->getFlexDirectory()->reloadIndex(); + } + + /** + * @param UserInterface|null $user + */ + public function check(UserInterface $user = null): void + { + parent::check($user); + + if ($user && $this->isMoved()) { + $parentKey = $this->getProperty('parent_key'); + + /** @var PageObject|null $parent */ + $parent = $this->getFlexDirectory()->getObject($parentKey, 'storage_key'); + if (!$parent || !$parent->isAuthorized('create', null, $user)) { + throw new \RuntimeException('Forbidden', 403); + } + } + } + + /** + * @param array|bool $reorder + * @return static + */ + public function save($reorder = true) + { + $variables = $this->onBeforeSave(func_get_args()); + + // Backwards compatibility with older plugins. + $fireEvents = $reorder && $this->isAdminSite() && $this->getFlexDirectory()->getConfig('object.compat.events', true); + $grav = $this->getContainer(); + if ($fireEvents) { + $self = $this; + $grav->fireEvent('onAdminSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => &$self])); + if ($self !== $this) { + throw new RuntimeException('Switching Flex Page object during onAdminSave event is not supported! Please update plugin.'); + } + } + + /** @var static $instance */ + $instance = parent::save(); + $variables = $this->onSave($variables); + + $this->onAfterSave($variables); + + // Backwards compatibility with older plugins. + if ($fireEvents) { + $grav->fireEvent('onAdminAfterSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => $this])); + } + + // Reset original after save events have all been called. + $this->_originalObject = null; + + return $instance; + } + + /** + * @return static + */ + public function delete() + { + $result = parent::delete(); + + // Backwards compatibility with older plugins. + $fireEvents = $this->isAdminSite() && $this->getFlexDirectory()->getConfig('object.compat.events', true); + if ($fireEvents) { + $this->getContainer()->fireEvent('onAdminAfterDelete', new Event(['object' => $this])); + } + + return $result; + } + + /** + * Prepare move page to new location. Moves also everything that's under the current page. + * + * You need to call $this->save() in order to perform the move. + * + * @param PageInterface $parent New parent page. + * @return $this + */ + public function move(PageInterface $parent) + { + if (!$parent instanceof FlexObjectInterface) { + throw new RuntimeException('Failed: Parent is not Flex Object'); + } + + $this->_reorder = []; + $this->setProperty('parent_key', $parent->getStorageKey()); + $this->storeOriginal(); + + return $this; + } + + /** + * @param UserInterface $user + * @param string $action + * @param string $scope + * @param bool $isMe + * @return bool|null + */ + protected function isAuthorizedOverride(UserInterface $user, string $action, string $scope, bool $isMe): ?bool + { + // Special case: creating a new page means checking parent for its permissions. + if ($action === 'create' && !$this->exists()) { + $parent = $this->parent(); + if ($parent && method_exists($parent, 'isAuthorized')) { + return $parent->isAuthorized($action, $scope, $user); + } + + return false; + } + + return parent::isAuthorizedOverride($user, $action, $scope, $isMe); + } + + /** + * @return bool + */ + protected function isMoved(): bool + { + $storageKey = $this->getMasterKey(); + $filesystem = Filesystem::getInstance(false); + $oldParentKey = ltrim($filesystem->dirname("/{$storageKey}"), '/'); + $newParentKey = $this->getProperty('parent_key'); + + return $this->exists() && $oldParentKey !== $newParentKey; + } + + /** + * @param array $ordering + * @return PageCollection|null + * @phpstan-return ObjectCollection|null + */ + protected function reorderSiblings(array $ordering) + { + $storageKey = $this->getMasterKey(); + $isMoved = $this->isMoved(); + $order = !$isMoved ? $this->order() : false; + if ($order !== false) { + $order = (int)$order; + } + + $parent = $this->parent(); + if (!$parent) { + throw new RuntimeException('Cannot reorder a page which has no parent'); + } + + /** @var PageCollection $siblings */ + $siblings = $parent->children(); + $siblings = $siblings->getCollection()->withOrdered(); + + // Handle special case where ordering isn't given. + if ($ordering === []) { + if ($order >= 999999) { + // Set ordering to point to be the last item, ignoring the object itself. + $order = 0; + foreach ($siblings as $sibling) { + if ($sibling->getKey() !== $this->getKey()) { + $order = max($order, (int)$sibling->order()); + } + } + $this->order($order + 1); + } + + // Do not change sibling ordering. + return null; + } + + $siblings = $siblings->orderBy(['order' => 'ASC']); + + if ($storageKey !== null) { + if ($order !== false) { + // Add current page back to the list if it's ordered. + $siblings->set($storageKey, $this); + } else { + // Remove old copy of the current page from the siblings list. + $siblings->remove($storageKey); + } + } + + // Add missing siblings into the end of the list, keeping the previous ordering between them. + foreach ($siblings as $sibling) { + $folder = (string)$sibling->getProperty('folder'); + $basename = preg_replace('|^\d+\.|', '', $folder); + if (!in_array($basename, $ordering, true)) { + $ordering[] = $basename; + } + } + + // Reorder. + $ordering = array_flip(array_values($ordering)); + $count = count($ordering); + foreach ($siblings as $sibling) { + $folder = (string)$sibling->getProperty('folder'); + $basename = preg_replace('|^\d+\.|', '', $folder); + $newOrder = $ordering[$basename] ?? null; + $newOrder = null !== $newOrder ? $newOrder + 1 : (int)$sibling->order() + $count; + $sibling->order($newOrder); + } + + $siblings = $siblings->orderBy(['order' => 'ASC']); + $siblings->removeElement($this); + + // If menu item was moved, just make it to be the last in order. + if ($isMoved && $this->order() !== false) { + $parentKey = $this->getProperty('parent_key'); + if ($parentKey === '') { + /** @var PageIndex $index */ + $index = $this->getFlexDirectory()->getIndex(); + $newParent = $index->getRoot(); + } else { + $newParent = $this->getFlexDirectory()->getObject($parentKey, 'storage_key'); + if (!$newParent instanceof PageInterface) { + throw new RuntimeException("New parent page '{$parentKey}' not found."); + } + } + /** @var PageCollection $newSiblings */ + $newSiblings = $newParent->children(); + $newSiblings = $newSiblings->getCollection()->withOrdered(); + $order = 0; + foreach ($newSiblings as $sibling) { + $order = max($order, (int)$sibling->order()); + } + $this->order($order + 1); + } + + return $siblings; + } + + /** + * @return string + */ + public function full_order(): string + { + $route = $this->path() . '/' . $this->folder(); + + return preg_replace(PageIndex::ORDER_LIST_REGEX, '\\1', $route) ?? $route; + } + + /** + * @param string $name + * @return Blueprint + */ + protected function doGetBlueprint(string $name = ''): Blueprint + { + try { + // Make sure that pages has been initialized. + Pages::getTypes(); + + // TODO: We need to move raw blueprint logic to Grav itself to remove admin dependency here. + if ($name === 'raw') { + // Admin RAW mode. + if ($this->isAdminSite()) { + /** @var Admin $admin */ + $admin = Grav::instance()['admin']; + + $template = $this->isModule() ? 'modular_raw' : ($this->root() ? 'root_raw' : 'raw'); + + return $admin->blueprints("admin/pages/{$template}"); + } + } + + $template = $this->getProperty('template') . ($name ? '.' . $name : ''); + + $blueprint = $this->getFlexDirectory()->getBlueprint($template, 'blueprints://pages'); + } catch (RuntimeException $e) { + $template = 'default' . ($name ? '.' . $name : ''); + + $blueprint = $this->getFlexDirectory()->getBlueprint($template, 'blueprints://pages'); + } + + $isNew = $blueprint->get('initialized', false) === false; + if ($isNew === true && $name === '') { + // Support onBlueprintCreated event just like in Pages::blueprints($template) + $blueprint->set('initialized', true); + $blueprint->setFilename($template); + + Grav::instance()->fireEvent('onBlueprintCreated', new Event(['blueprint' => $blueprint, 'type' => $template])); + } + + return $blueprint; + } + + /** + * @param array $options + * @return array + */ + public function getLevelListing(array $options): array + { + $index = $this->getFlexDirectory()->getIndex(); + if (!is_callable([$index, 'getLevelListing'])) { + return []; + } + + // Deal with relative paths. + $initial = $options['initial'] ?? null; + $var = $initial ? 'leaf_route' : 'route'; + $route = $options[$var] ?? ''; + if ($route !== '' && !str_starts_with($route, '/')) { + $filesystem = Filesystem::getInstance(); + + $route = "/{$this->getKey()}/{$route}"; + $route = $filesystem->normalize($route); + + $options[$var] = $route; + } + + [$status, $message, $response,] = $index->getLevelListing($options); + + return [$status, $message, $response, $options[$var] ?? null]; + } + + /** + * Filter page (true/false) by given filters. + * + * - search: string + * - extension: string + * - module: bool + * - visible: bool + * - routable: bool + * - published: bool + * - page: bool + * - translated: bool + * + * @param array $filters + * @param bool $recursive + * @return bool + */ + public function filterBy(array $filters, bool $recursive = false): bool + { + $language = $filters['language'] ?? null; + if (null !== $language) { + /** @var PageObject $test */ + $test = $this->getTranslation($language) ?? $this; + } else { + $test = $this; + } + + foreach ($filters as $key => $value) { + switch ($key) { + case 'search': + $matches = $test->search((string)$value) > 0.0; + break; + case 'page_type': + $types = $value ? explode(',', $value) : []; + $matches = in_array($test->template(), $types, true); + break; + case 'extension': + $matches = Utils::contains((string)$value, $test->extension()); + break; + case 'routable': + $matches = $test->isRoutable() === (bool)$value; + break; + case 'published': + $matches = $test->isPublished() === (bool)$value; + break; + case 'visible': + $matches = $test->isVisible() === (bool)$value; + break; + case 'module': + $matches = $test->isModule() === (bool)$value; + break; + case 'page': + $matches = $test->isPage() === (bool)$value; + break; + case 'folder': + $matches = $test->isPage() === !$value; + break; + case 'translated': + $matches = $test->hasTranslation() === (bool)$value; + break; + default: + $matches = true; + break; + } + + // If current filter does not match, we still may have match as a parent. + if ($matches === false) { + if (!$recursive) { + return false; + } + + /** @var PageIndex $index */ + $index = $this->children()->getIndex(); + + return $index->filterBy($filters, true)->count() > 0; + } + } + + return true; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::exists() + */ + public function exists(): bool + { + return $this->root ?: parent::exists(); + } + + /** + * @return array + */ + public function __debugInfo(): array + { + $list = parent::__debugInfo(); + + return $list + [ + '_content_meta:private' => $this->getContentMeta(), + '_content:private' => $this->getRawContent() + ]; + } + + /** + * @param array $elements + * @param bool $extended + */ + protected function filterElements(array &$elements, bool $extended = false): void + { + // Change parent page if needed. + if (array_key_exists('route', $elements) && isset($elements['folder'], $elements['name'])) { + $elements['template'] = $elements['name']; + + // Figure out storage path to the new route. + $parentKey = trim($elements['route'] ?? '', '/'); + if ($parentKey !== '') { + /** @var PageObject|null $parent */ + $parent = $this->getFlexDirectory()->getObject($parentKey); + $parentKey = $parent ? $parent->getStorageKey() : $parentKey; + } + + $elements['parent_key'] = $parentKey; + } + + // Deal with ordering=bool and order=page1,page2,page3. + if ($this->root()) { + // Root page doesn't have ordering. + unset($elements['ordering'], $elements['order']); + } elseif (array_key_exists('ordering', $elements) && array_key_exists('order', $elements)) { + // Store ordering. + $ordering = $elements['order'] ?? null; + $this->_reorder = !empty($ordering) ? explode(',', $ordering) : []; + + $order = false; + if ((bool)($elements['ordering'] ?? false)) { + $order = $this->order(); + if ($order === false) { + $order = 999999; + } + } + + $elements['order'] = $order; + } + + parent::filterElements($elements, true); + } + + /** + * @return array + */ + public function prepareStorage(): array + { + $meta = $this->getMetaData(); + $oldLang = $meta['lang'] ?? ''; + $newLang = $this->getProperty('lang') ?? ''; + + // Always clone the page to the new language. + if ($oldLang !== $newLang) { + $meta['clone'] = true; + } + + // Make sure that certain elements are always sent to the storage layer. + $elements = [ + '__META' => $meta, + 'storage_key' => $this->getStorageKey(), + 'parent_key' => $this->getProperty('parent_key'), + 'order' => $this->getProperty('order'), + 'folder' => preg_replace('|^\d+\.|', '', $this->getProperty('folder') ?? ''), + 'template' => preg_replace('|modular/|', '', $this->getProperty('template') ?? ''), + 'lang' => $newLang + ] + parent::prepareStorage(); + + return $elements; + } +} diff --git a/system/src/Grav/Common/Flex/Types/Pages/Storage/PageStorage.php b/system/src/Grav/Common/Flex/Types/Pages/Storage/PageStorage.php new file mode 100644 index 0000000..72349dc --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Pages/Storage/PageStorage.php @@ -0,0 +1,700 @@ +flags = FilesystemIterator::KEY_AS_FILENAME | FilesystemIterator::CURRENT_AS_FILEINFO + | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS; + + $grav = Grav::instance(); + + $config = $grav['config']; + $this->ignore_hidden = (bool)$config->get('system.pages.ignore_hidden'); + $this->ignore_files = (array)$config->get('system.pages.ignore_files'); + $this->ignore_folders = (array)$config->get('system.pages.ignore_folders'); + $this->include_default_lang_file_extension = (bool)$config->get('system.languages.include_default_lang_file_extension', true); + $this->recurse = (bool)($options['recurse'] ?? true); + $this->regex = '/(\.([\w\d_-]+))?\.md$/D'; + } + + /** + * @param string $key + * @param bool $variations + * @return array + */ + public function parseKey(string $key, bool $variations = true): array + { + if (mb_strpos($key, '|') !== false) { + [$key, $params] = explode('|', $key, 2); + } else { + $params = ''; + } + $key = ltrim($key, '/'); + + $keys = parent::parseKey($key, false) + ['params' => $params]; + + if ($variations) { + $keys += $this->parseParams($key, $params); + } + + return $keys; + } + + /** + * @param string $key + * @return string + */ + public function readFrontmatter(string $key): string + { + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + try { + if ($file instanceof MarkdownFile) { + $frontmatter = $file->frontmatter(); + } else { + $frontmatter = $file->raw(); + } + } catch (RuntimeException $e) { + $frontmatter = 'ERROR: ' . $e->getMessage(); + } finally { + $file->free(); + unset($file); + } + + return $frontmatter; + } + + /** + * @param string $key + * @return string + */ + public function readRaw(string $key): string + { + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + try { + $raw = $file->raw(); + } catch (RuntimeException $e) { + $raw = 'ERROR: ' . $e->getMessage(); + } finally { + $file->free(); + unset($file); + } + + return $raw; + } + + /** + * @param array $keys + * @param bool $includeParams + * @return string + */ + public function buildStorageKey(array $keys, bool $includeParams = true): string + { + $key = $keys['key'] ?? null; + if (null === $key) { + $key = $keys['parent_key'] ?? ''; + if ($key !== '') { + $key .= '/'; + } + $order = $keys['order'] ?? null; + $folder = $keys['folder'] ?? 'undefined'; + $key .= is_numeric($order) ? sprintf('%02d.%s', $order, $folder) : $folder; + } + + $params = $includeParams ? $this->buildStorageKeyParams($keys) : ''; + + return $params ? "{$key}|{$params}" : $key; + } + + /** + * @param array $keys + * @return string + */ + public function buildStorageKeyParams(array $keys): string + { + $params = $keys['template'] ?? ''; + $language = $keys['lang'] ?? ''; + if ($language) { + $params .= '.' . $language; + } + + return $params; + } + + /** + * @param array $keys + * @return string + */ + public function buildFolder(array $keys): string + { + return $this->dataFolder . '/' . $this->buildStorageKey($keys, false); + } + + /** + * @param array $keys + * @return string + */ + public function buildFilename(array $keys): string + { + $file = $this->buildStorageKeyParams($keys); + + // Template is optional; if it is missing, we need to have to load the object metadata. + if ($file && $file[0] === '.') { + $meta = $this->getObjectMeta($this->buildStorageKey($keys, false)); + $file = ($meta['template'] ?? 'folder') . $file; + } + + return $file . $this->dataExt; + } + + /** + * @param array $keys + * @return string + */ + public function buildFilepath(array $keys): string + { + $folder = $this->buildFolder($keys); + $filename = $this->buildFilename($keys); + + return rtrim($folder, '/') !== $folder ? $folder . $filename : $folder . '/' . $filename; + } + + /** + * @param array $row + * @param bool $setDefaultLang + * @return array + */ + public function extractKeysFromRow(array $row, bool $setDefaultLang = true): array + { + $meta = $row['__META'] ?? null; + $storageKey = $row['storage_key'] ?? $meta['storage_key'] ?? ''; + $keyMeta = $storageKey !== '' ? $this->extractKeysFromStorageKey($storageKey) : null; + $parentKey = $row['parent_key'] ?? $meta['parent_key'] ?? $keyMeta['parent_key'] ?? ''; + $order = $row['order'] ?? $meta['order'] ?? $keyMeta['order'] ?? null; + $folder = $row['folder'] ?? $meta['folder'] ?? $keyMeta['folder'] ?? ''; + $template = $row['template'] ?? $meta['template'] ?? $keyMeta['template'] ?? ''; + $lang = $row['lang'] ?? $meta['lang'] ?? $keyMeta['lang'] ?? ''; + + // Handle default language, if it should be saved without language extension. + if ($setDefaultLang && empty($meta['markdown'][$lang])) { + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + $default = $language->getDefault(); + // Make sure that the default language file doesn't exist before overriding it. + if (empty($meta['markdown'][$default])) { + if ($this->include_default_lang_file_extension) { + if ($lang === '') { + $lang = $language->getDefault(); + } + } elseif ($lang === $language->getDefault()) { + $lang = ''; + } + } + } + + $keys = [ + 'key' => null, + 'params' => null, + 'parent_key' => $parentKey, + 'order' => is_numeric($order) ? (int)$order : null, + 'folder' => $folder, + 'template' => $template, + 'lang' => $lang + ]; + + $keys['key'] = $this->buildStorageKey($keys, false); + $keys['params'] = $this->buildStorageKeyParams($keys); + + return $keys; + } + + /** + * @param string $key + * @return array + */ + public function extractKeysFromStorageKey(string $key): array + { + if (mb_strpos($key, '|') !== false) { + [$key, $params] = explode('|', $key, 2); + [$template, $language] = mb_strpos($params, '.') !== false ? explode('.', $params, 2) : [$params, '']; + } else { + $params = $template = $language = ''; + } + $objectKey = Utils::basename($key); + if (preg_match('|^(\d+)\.(.+)$|', $objectKey, $matches)) { + [, $order, $folder] = $matches; + } else { + [$order, $folder] = ['', $objectKey]; + } + + $filesystem = Filesystem::getInstance(false); + + $parentKey = ltrim($filesystem->dirname('/' . $key), '/'); + + return [ + 'key' => $key, + 'params' => $params, + 'parent_key' => $parentKey, + 'order' => is_numeric($order) ? (int)$order : null, + 'folder' => $folder, + 'template' => $template, + 'lang' => $language + ]; + } + + /** + * @param string $key + * @param string $params + * @return array + */ + protected function parseParams(string $key, string $params): array + { + if (mb_strpos($params, '.') !== false) { + [$template, $language] = explode('.', $params, 2); + } else { + $template = $params; + $language = ''; + } + + if ($template === '') { + $meta = $this->getObjectMeta($key); + $template = $meta['template'] ?? 'folder'; + } + + return [ + 'file' => $template . ($language ? '.' . $language : ''), + 'template' => $template, + 'lang' => $language + ]; + } + + /** + * Prepares the row for saving and returns the storage key for the record. + * + * @param array $row + */ + protected function prepareRow(array &$row): void + { + // Remove keys used in the filesystem. + unset($row['parent_key'], $row['order'], $row['folder'], $row['template'], $row['lang']); + } + + /** + * @param string $key + * @return array + */ + protected function loadRow(string $key): ?array + { + $data = parent::loadRow($key); + + // Special case for root page. + if ($key === '' && null !== $data) { + $data['root'] = true; + } + + return $data; + } + + /** + * Page storage supports moving and copying the pages and their languages. + * + * $row['__META']['copy'] = true Use this if you want to copy the whole folder, otherwise it will be moved + * $row['__META']['clone'] = true Use this if you want to clone the file, otherwise it will be renamed + * + * @param string $key + * @param array $row + * @return array + */ + protected function saveRow(string $key, array $row): array + { + // Initialize all key-related variables. + $newKeys = $this->extractKeysFromRow($row); + $newKey = $this->buildStorageKey($newKeys); + $newFolder = $this->buildFolder($newKeys); + $newFilename = $this->buildFilename($newKeys); + $newFilepath = rtrim($newFolder, '/') !== $newFolder ? $newFolder . $newFilename : $newFolder . '/' . $newFilename; + + try { + if ($key === '' && empty($row['root'])) { + throw new RuntimeException('Page has no path'); + } + + $grav = Grav::instance(); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addMessage("Save page: {$newKey}", 'debug'); + + // Check if the row already exists. + $oldKey = $row['__META']['storage_key'] ?? null; + if (is_string($oldKey)) { + // Initialize all old key-related variables. + $oldKeys = $this->extractKeysFromRow(['__META' => $row['__META']], false); + $oldFolder = $this->buildFolder($oldKeys); + $oldFilename = $this->buildFilename($oldKeys); + + // Check if folder has changed. + if ($oldFolder !== $newFolder && file_exists($oldFolder)) { + $isCopy = $row['__META']['copy'] ?? false; + if ($isCopy) { + if (strpos($newFolder, $oldFolder . '/') === 0) { + throw new RuntimeException(sprintf('Page /%s cannot be copied to itself', $oldKey)); + } + + $this->copyRow($oldKey, $newKey); + $debugger->addMessage("Page copied: {$oldFolder} => {$newFolder}", 'debug'); + } else { + if (strpos($newFolder, $oldFolder . '/') === 0) { + throw new RuntimeException(sprintf('Page /%s cannot be moved to itself', $oldKey)); + } + + $this->renameRow($oldKey, $newKey); + $debugger->addMessage("Page moved: {$oldFolder} => {$newFolder}", 'debug'); + } + } + + // Check if filename has changed. + if ($oldFilename !== $newFilename) { + // Get instance of the old file (we have already copied/moved it). + $oldFilepath = "{$newFolder}/{$oldFilename}"; + $file = $this->getFile($oldFilepath); + + // Rename the file if we aren't supposed to clone it. + $isClone = $row['__META']['clone'] ?? false; + if (!$isClone && $file->exists()) { + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $toPath = $locator->isStream($newFilepath) ? $locator->findResource($newFilepath, true, true) : GRAV_ROOT . "/{$newFilepath}"; + $success = $file->rename($toPath); + if (!$success) { + throw new RuntimeException("Changing page template failed: {$oldFilepath} => {$newFilepath}"); + } + $debugger->addMessage("Page template changed: {$oldFilename} => {$newFilename}", 'debug'); + } else { + $file = null; + $debugger->addMessage("Page template created: {$newFilename}", 'debug'); + } + } + } + + // Clean up the data to be saved. + $this->prepareRow($row); + unset($row['__META'], $row['__ERROR']); + + if (!isset($file)) { + $file = $this->getFile($newFilepath); + } + + // Compare existing file content to the new one and save the file only if content has been changed. + $file->free(); + $oldRaw = $file->raw(); + $file->content($row); + $newRaw = $file->raw(); + if ($oldRaw !== $newRaw) { + $file->save($row); + $debugger->addMessage("Page content saved: {$newFilepath}", 'debug'); + } else { + $debugger->addMessage('Page content has not been changed, do not update the file', 'debug'); + } + } catch (RuntimeException $e) { + $name = isset($file) ? $file->filename() : $newKey; + + throw new RuntimeException(sprintf('Flex saveRow(%s): %s', $name, $e->getMessage())); + } finally { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $locator->clearCache(); + + if (isset($file)) { + $file->free(); + unset($file); + } + } + + $row['__META'] = $this->getObjectMeta($newKey, true); + + return $row; + } + + /** + * Check if page folder should be deleted. + * + * Deleting page can be done either by deleting everything or just a single language. + * If key contains the language, delete only it, unless it is the last language. + * + * @param string $key + * @return bool + */ + protected function canDeleteFolder(string $key): bool + { + // Return true if there's no language in the key. + $keys = $this->extractKeysFromStorageKey($key); + if (!$keys['lang']) { + return true; + } + + // Get the main key and reload meta. + $key = $this->buildStorageKey($keys); + $meta = $this->getObjectMeta($key, true); + + // Return true if there aren't any markdown files left. + return empty($meta['markdown'] ?? []); + } + + /** + * Get key from the filesystem path. + * + * @param string $path + * @return string + */ + protected function getKeyFromPath(string $path): string + { + if ($this->base_path) { + $path = $this->base_path . '/' . $path; + } + + return $path; + } + + /** + * Returns list of all stored keys in [key => timestamp] pairs. + * + * @return array + */ + protected function buildIndex(): array + { + $this->clearCache(); + + return $this->getIndexMeta(); + } + + /** + * @param string $key + * @param bool $reload + * @return array + */ + protected function getObjectMeta(string $key, bool $reload = false): array + { + $keys = $this->extractKeysFromStorageKey($key); + $key = $keys['key']; + + if ($reload || !isset($this->meta[$key])) { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if (mb_strpos($key, '@@') === false) { + $path = $this->getStoragePath($key); + if (is_string($path)) { + $path = $locator->isStream($path) ? $locator->findResource($path) : GRAV_ROOT . "/{$path}"; + } else { + $path = null; + } + } else { + $path = null; + } + + $modified = 0; + $markdown = []; + $children = []; + + if (is_string($path) && is_dir($path)) { + $modified = filemtime($path); + $iterator = new FilesystemIterator($path, $this->flags); + + /** @var SplFileInfo $info */ + foreach ($iterator as $k => $info) { + // Ignore all hidden files if set. + if ($k === '' || ($this->ignore_hidden && $k[0] === '.')) { + continue; + } + + if ($info->isDir()) { + // Ignore all folders in ignore list. + if ($this->ignore_folders && in_array($k, $this->ignore_folders, true)) { + continue; + } + + $children[$k] = false; + } else { + // Ignore all files in ignore list. + if ($this->ignore_files && in_array($k, $this->ignore_files, true)) { + continue; + } + + $timestamp = $info->getMTime(); + + // Page is the one that matches to $page_extensions list with the lowest index number. + if (preg_match($this->regex, $k, $matches)) { + $mark = $matches[2] ?? ''; + $ext = $matches[1] ?? ''; + $ext .= $this->dataExt; + $markdown[$mark][Utils::basename($k, $ext)] = $timestamp; + } + + $modified = max($modified, $timestamp); + } + } + } + + $rawRoute = trim(preg_replace(PageIndex::PAGE_ROUTE_REGEX, '/', "/{$key}") ?? '', '/'); + $route = PageIndex::normalizeRoute($rawRoute); + + ksort($markdown, SORT_NATURAL | SORT_FLAG_CASE); + ksort($children, SORT_NATURAL | SORT_FLAG_CASE); + + $file = array_key_first($markdown[''] ?? (reset($markdown) ?: [])); + + $meta = [ + 'key' => $route, + 'storage_key' => $key, + 'template' => $file, + 'storage_timestamp' => $modified, + ]; + if ($markdown) { + $meta['markdown'] = $markdown; + } + if ($children) { + $meta['children'] = $children; + } + $meta['checksum'] = md5(json_encode($meta) ?: ''); + + // Cache meta as copy. + $this->meta[$key] = $meta; + } else { + $meta = $this->meta[$key]; + } + + $params = $keys['params']; + if ($params) { + $language = $keys['lang']; + $template = $keys['template'] ?: array_key_first($meta['markdown'][$language]) ?? $meta['template']; + $meta['exists'] = ($template && !empty($meta['children'])) || isset($meta['markdown'][$language][$template]); + $meta['storage_key'] .= '|' . $params; + $meta['template'] = $template; + $meta['lang'] = $language; + } + + return $meta; + } + + /** + * @return array + */ + protected function getIndexMeta(): array + { + $queue = ['']; + $list = []; + do { + $current = array_pop($queue); + if ($current === null) { + break; + } + + $meta = $this->getObjectMeta($current); + $storage_key = $meta['storage_key']; + + if (!empty($meta['children'])) { + $prefix = $storage_key . ($storage_key !== '' ? '/' : ''); + + foreach ($meta['children'] as $child => $value) { + $queue[] = $prefix . $child; + } + } + + $list[$storage_key] = $meta; + } while ($queue); + + ksort($list, SORT_NATURAL | SORT_FLAG_CASE); + + // Update parent timestamps. + foreach (array_reverse($list) as $storage_key => $meta) { + if ($storage_key !== '') { + $filesystem = Filesystem::getInstance(false); + + $storage_key = (string)$storage_key; + $parentKey = $filesystem->dirname($storage_key); + if ($parentKey === '.') { + $parentKey = ''; + } + + /** @phpstan-var array{'storage_key': string, 'storage_timestamp': int, 'children': array} $parent */ + $parent = &$list[$parentKey]; + $basename = Utils::basename($storage_key); + + if (isset($parent['children'][$basename])) { + $timestamp = $meta['storage_timestamp']; + $parent['children'][$basename] = $timestamp; + if ($basename && $basename[0] === '_') { + $parent['storage_timestamp'] = max($parent['storage_timestamp'], $timestamp); + } + } + } + } + + return $list; + } + + /** + * @return string + */ + protected function getNewKey(): string + { + throw new RuntimeException('Generating random key is disabled for pages'); + } +} diff --git a/system/src/Grav/Common/Flex/Types/Pages/Traits/PageContentTrait.php b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageContentTrait.php new file mode 100644 index 0000000..95d14f9 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageContentTrait.php @@ -0,0 +1,75 @@ +getProperty($property) : null; + if (null === $value) { + $value = $this->language() . ($var ?? ($this->modified() . md5($this->filePath() ?? $this->getKey()))); + + $this->setProperty($property, $value); + if ($this->doHasProperty($property)) { + $value = $this->getProperty($property); + } + } + + return $value; + } + + + /** + * @inheritdoc + */ + public function date($var = null): int + { + return $this->loadHeaderProperty( + 'date', + $var, + function ($value) { + $value = $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : false; + + if (!$value) { + // Get the specific translation updated date. + $meta = $this->getMetaData(); + $language = $meta['lang'] ?? ''; + $template = $this->getProperty('template'); + $value = $meta['markdown'][$language][$template] ?? 0; + } + + return $value ?: $this->modified(); + } + ); + } + + /** + * @inheritdoc + * @param bool $bool + */ + public function isPage(bool $bool = true): bool + { + $meta = $this->getMetaData(); + + return empty($meta['markdown']) !== $bool; + } +} diff --git a/system/src/Grav/Common/Flex/Types/Pages/Traits/PageLegacyTrait.php b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageLegacyTrait.php new file mode 100644 index 0000000..cd0f887 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageLegacyTrait.php @@ -0,0 +1,236 @@ +path() ?? ''; + + return $pages->children($path); + } + + /** + * Check to see if this item is the first in an array of sub-pages. + * + * @return bool True if item is first. + */ + public function isFirst(): bool + { + if (Utils::isAdminPlugin()) { + return parent::isFirst(); + } + + $path = $this->path(); + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if (null !== $path && $collection instanceof PageCollectionInterface) { + return $collection->isFirst($path); + } + + return true; + } + + /** + * Check to see if this item is the last in an array of sub-pages. + * + * @return bool True if item is last + */ + public function isLast(): bool + { + if (Utils::isAdminPlugin()) { + return parent::isLast(); + } + + $path = $this->path(); + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if (null !== $path && $collection instanceof PageCollectionInterface) { + return $collection->isLast($path); + } + + return true; + } + + /** + * Returns the adjacent sibling based on a direction. + * + * @param int $direction either -1 or +1 + * @return PageInterface|false the sibling page + */ + public function adjacentSibling($direction = 1) + { + if (Utils::isAdminPlugin()) { + return parent::adjacentSibling($direction); + } + + $path = $this->path(); + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if (null !== $path && $collection instanceof PageCollectionInterface) { + $child = $collection->adjacentSibling($path, $direction); + if ($child instanceof PageInterface) { + return $child; + } + } + + return false; + } + + /** + * Helper method to return an ancestor page. + * + * @param string|null $lookup Name of the parent folder + * @return PageInterface|null page you were looking for if it exists + */ + public function ancestor($lookup = null) + { + if (Utils::isAdminPlugin()) { + return parent::ancestor($lookup); + } + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->ancestor($this->getProperty('parent_route'), $lookup); + } + + /** + * Method that contains shared logic for inherited() and inheritedField() + * + * @param string $field Name of the parent folder + * @return array + */ + protected function getInheritedParams($field): array + { + if (Utils::isAdminPlugin()) { + return parent::getInheritedParams($field); + } + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + $inherited = $pages->inherited($this->getProperty('parent_route'), $field); + $inheritedParams = $inherited ? (array)$inherited->value('header.' . $field) : []; + $currentParams = (array)$this->getFormValue('header.' . $field); + if ($inheritedParams && is_array($inheritedParams)) { + $currentParams = array_replace_recursive($inheritedParams, $currentParams); + } + + return [$inherited, $currentParams]; + } + + /** + * Helper method to return a page. + * + * @param string $url the url of the page + * @param bool $all + * @return PageInterface|null page you were looking for if it exists + */ + public function find($url, $all = false) + { + if (Utils::isAdminPlugin()) { + return parent::find($url, $all); + } + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->find($url, $all); + } + + /** + * Get a collection of pages in the current context. + * + * @param string|array $params + * @param bool $pagination + * @return PageCollectionInterface|Collection + * @throws InvalidArgumentException + */ + public function collection($params = 'content', $pagination = true) + { + if (Utils::isAdminPlugin()) { + return parent::collection($params, $pagination); + } + + if (is_string($params)) { + // Look into a page header field. + $params = (array)$this->getFormValue('header.' . $params); + } elseif (!is_array($params)) { + throw new InvalidArgumentException('Argument should be either header variable name or array of parameters'); + } + + $context = [ + 'pagination' => $pagination, + 'self' => $this + ]; + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->getCollection($params, $context); + } + + /** + * @param string|array $value + * @param bool $only_published + * @return PageCollectionInterface|Collection + */ + public function evaluate($value, $only_published = true) + { + if (Utils::isAdminPlugin()) { + return parent::collection($value, $only_published); + } + + $params = [ + 'items' => $value, + 'published' => $only_published + ]; + $context = [ + 'event' => false, + 'pagination' => false, + 'url_taxonomy_filters' => false, + 'self' => $this + ]; + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->getCollection($params, $context); + } +} diff --git a/system/src/Grav/Common/Flex/Types/Pages/Traits/PageRoutableTrait.php b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageRoutableTrait.php new file mode 100644 index 0000000..f3defb5 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageRoutableTrait.php @@ -0,0 +1,122 @@ +root()) { + return null; + } + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + $filesystem = Filesystem::getInstance(false); + + // FIXME: this does not work, needs to use $pages->get() with cached parent id! + $key = $this->getKey(); + $parent_route = $filesystem->dirname('/' . $key); + + return $parent_route !== '/' ? $pages->find($parent_route) : $pages->root(); + } + + /** + * Returns the item in the current position. + * + * @return int|null the index of the current page. + */ + public function currentPosition(): ?int + { + $path = $this->path(); + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if (null !== $path && $collection instanceof PageCollectionInterface) { + return $collection->currentPosition($path); + } + + return 1; + } + + /** + * Returns whether or not this page is the currently active page requested via the URL. + * + * @return bool True if it is active + */ + public function active(): bool + { + $grav = Grav::instance(); + $uri_path = rtrim(urldecode($grav['uri']->path()), '/') ?: '/'; + $routes = $grav['pages']->routes(); + + return isset($routes[$uri_path]) && $routes[$uri_path] === $this->path(); + } + + /** + * Returns whether or not this URI's URL contains the URL of the active page. + * Or in other words, is this page's URL in the current URL + * + * @return bool True if active child exists + */ + public function activeChild(): bool + { + $grav = Grav::instance(); + /** @var Uri $uri */ + $uri = $grav['uri']; + /** @var Pages $pages */ + $pages = $grav['pages']; + $uri_path = rtrim(urldecode($uri->path()), '/'); + $routes = $pages->routes(); + + if (isset($routes[$uri_path])) { + $page = $pages->find($uri->route()); + /** @var PageInterface|null $child_page */ + $child_page = $page ? $page->parent() : null; + while ($child_page && !$child_page->root()) { + if ($this->path() === $child_page->path()) { + return true; + } + $child_page = $child_page->parent(); + } + } + + return false; + } +} diff --git a/system/src/Grav/Common/Flex/Types/Pages/Traits/PageTranslateTrait.php b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageTranslateTrait.php new file mode 100644 index 0000000..12cf6d5 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageTranslateTrait.php @@ -0,0 +1,108 @@ +getLanguageTemplates(); + if (!$translated) { + return $translated; + } + + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + $languages = $language->getLanguages(); + $languages[] = ''; + $defaultCode = $language->getDefault(); + + if (isset($translated[$defaultCode])) { + unset($translated['']); + } + + foreach ($translated as $key => &$template) { + $template .= $key !== '' ? ".{$key}.md" : '.md'; + } + unset($template); + + $translated = array_intersect_key($translated, array_flip($languages)); + + $folder = $this->getStorageFolder(); + if (!$folder) { + return []; + } + $folder = $locator->isStream($folder) ? $locator->getResource($folder) : GRAV_ROOT . "/{$folder}"; + + $list = array_fill_keys($languages, null); + foreach ($translated as $languageCode => $languageFile) { + $languageExtension = $languageCode ? ".{$languageCode}.md" : '.md'; + $path = "{$folder}/{$languageFile}"; + + // FIXME: use flex, also rawRoute() does not fully work? + $aPage = new Page(); + $aPage->init(new SplFileInfo($path), $languageExtension); + if ($onlyPublished && !$aPage->published()) { + continue; + } + + $header = $aPage->header(); + // @phpstan-ignore-next-line + $routes = $header->routes ?? []; + $route = $routes['default'] ?? $aPage->rawRoute(); + if (!$route) { + $route = $aPage->route(); + } + + $list[$languageCode ?: $defaultCode] = $route ?? ''; + } + + $list = array_filter($list, static function ($var) { + return null !== $var; + }); + + // Hack to get the same result as with old pages. + foreach ($list as &$path) { + if ($path === '') { + $path = null; + } + } + + return $list; + } +} diff --git a/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupCollection.php b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupCollection.php new file mode 100644 index 0000000..3be0e13 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupCollection.php @@ -0,0 +1,56 @@ + + */ +class UserGroupCollection extends FlexCollection +{ + /** + * @return array + */ + public static function getCachedMethods(): array + { + return [ + 'authorize' => false, + ] + parent::getCachedMethods(); + } + + /** + * Checks user authorization to the action. + * + * @param string $action + * @param string|null $scope + * @return bool|null + */ + public function authorize(string $action, string $scope = null): ?bool + { + $authorized = null; + /** @var UserGroupObject $object */ + foreach ($this as $object) { + $auth = $object->authorize($action, $scope); + if ($auth === true) { + $authorized = true; + } elseif ($auth === false) { + return false; + } + } + + return $authorized; + } +} diff --git a/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupIndex.php b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupIndex.php new file mode 100644 index 0000000..ac27eff --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupIndex.php @@ -0,0 +1,24 @@ + + */ +class UserGroupIndex extends FlexIndex +{ +} diff --git a/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupObject.php b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupObject.php new file mode 100644 index 0000000..7a95a7d --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupObject.php @@ -0,0 +1,134 @@ + false, + ] + parent::getCachedMethods(); + } + + /** + * @return string + */ + public function getTitle(): string + { + return $this->getProperty('readableName'); + } + + /** + * Checks user authorization to the action. + * + * @param string $action + * @param string|null $scope + * @return bool|null + */ + public function authorize(string $action, string $scope = null): ?bool + { + if ($scope === 'test') { + $scope = null; + } elseif (!$this->getProperty('enabled', true)) { + return null; + } + + $access = $this->getAccess(); + + $authorized = $access->authorize($action, $scope); + if (is_bool($authorized)) { + return $authorized; + } + + return $access->authorize('admin.super') ? true : null; + } + + public static function groupNames(): array + { + $groups = []; + $user_groups = Grav::instance()['user_groups'] ?? []; + + foreach ($user_groups as $key => $group) { + $groups[$key] = $group->readableName; + } + + return $groups; + } + + /** + * @return Access + */ + protected function getAccess(): Access + { + if (null === $this->_access) { + $this->getProperty('access'); + } + + return $this->_access; + } + + /** + * @param mixed $value + * @return array + */ + protected function offsetLoad_access($value): array + { + if (!$value instanceof Access) { + $value = new Access($value); + } + + $this->_access = $value; + + return $value->jsonSerialize(); + } + + /** + * @param mixed $value + * @return array + */ + protected function offsetPrepare_access($value): array + { + return $this->offsetLoad_access($value); + } + + /** + * @param array|null $value + * @return array|null + */ + protected function offsetSerialize_access(?array $value): ?array + { + return $value; + } +} diff --git a/system/src/Grav/Common/Flex/Types/Users/Storage/UserFileStorage.php b/system/src/Grav/Common/Flex/Types/Users/Storage/UserFileStorage.php new file mode 100644 index 0000000..79c5112 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Users/Storage/UserFileStorage.php @@ -0,0 +1,47 @@ +update($data)` instead (same but with data validation & filtering, file upload support). + */ + public function merge(array $data) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->update($data) method instead', E_USER_DEPRECATED); + + $this->setElements($this->getBlueprint()->mergeData($this->toArray(), $data)); + + return $this; + } + + /** + * Return media object for the User's avatar. + * + * @return ImageMedium|StaticImageMedium|null + * @deprecated 1.6 Use ->getAvatarImage() method instead. + */ + public function getAvatarMedia() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getAvatarImage() method instead', E_USER_DEPRECATED); + + return $this->getAvatarImage(); + } + + /** + * Return the User's avatar URL + * + * @return string + * @deprecated 1.6 Use ->getAvatarUrl() method instead. + */ + public function avatarUrl() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getAvatarUrl() method instead', E_USER_DEPRECATED); + + return $this->getAvatarUrl(); + } + + /** + * Checks user authorization to the action. + * Ensures backwards compatibility + * + * @param string $action + * @return bool + * @deprecated 1.5 Use ->authorize() method instead. + */ + public function authorise($action) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->authorize() method instead', E_USER_DEPRECATED); + + return $this->authorize($action) ?? false; + } + + /** + * Implements Countable interface. + * + * @return int + * @deprecated 1.6 Method makes no sense for user account. + */ + #[\ReturnTypeWillChange] + public function count() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + + return count($this->jsonSerialize()); + } +} diff --git a/system/src/Grav/Common/Flex/Types/Users/UserCollection.php b/system/src/Grav/Common/Flex/Types/Users/UserCollection.php new file mode 100644 index 0000000..98ecb4c --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Users/UserCollection.php @@ -0,0 +1,135 @@ + + */ +class UserCollection extends FlexCollection implements UserCollectionInterface +{ + /** + * @return array + */ + public static function getCachedMethods(): array + { + return [ + 'authorize' => 'session', + ] + parent::getCachedMethods(); + } + + /** + * Load user account. + * + * Always creates user object. To check if user exists, use $this->exists(). + * + * @param string $username + * @return UserObject + */ + public function load($username): UserInterface + { + $username = (string)$username; + + if ($username !== '') { + $key = $this->filterUsername($username); + $user = $this->get($key); + if ($user) { + return $user; + } + } else { + $key = ''; + } + + $directory = $this->getFlexDirectory(); + + /** @var UserObject $object */ + $object = $directory->createObject( + [ + 'username' => $username, + 'state' => 'enabled' + ], + $key + ); + + return $object; + } + + /** + * Find a user by username, email, etc + * + * @param string $query the query to search for + * @param string|string[] $fields the fields to search + * @return UserObject + */ + public function find($query, $fields = ['username', 'email']): UserInterface + { + if (is_string($query) && $query !== '') { + foreach ((array)$fields as $field) { + if ($field === 'key') { + $user = $this->get($query); + } elseif ($field === 'storage_key') { + $user = $this->withKeyField('storage_key')->get($query); + } elseif ($field === 'flex_key') { + $user = $this->withKeyField('flex_key')->get($query); + } elseif ($field === 'username') { + $user = $this->get($this->filterUsername($query)); + } else { + $user = parent::find($query, $field); + } + if ($user instanceof UserObject) { + return $user; + } + } + } + + return $this->load(''); + } + + /** + * Delete user account. + * + * @param string $username + * @return bool True if user account was found and was deleted. + */ + public function delete($username): bool + { + $user = $this->load($username); + + $exists = $user->exists(); + if ($exists) { + $user->delete(); + } + + return $exists; + } + + /** + * @param string $key + * @return string + */ + protected function filterUsername(string $key): string + { + $storage = $this->getFlexDirectory()->getStorage(); + if (method_exists($storage, 'normalizeKey')) { + return $storage->normalizeKey($key); + } + + return mb_strtolower($key); + } +} diff --git a/system/src/Grav/Common/Flex/Types/Users/UserIndex.php b/system/src/Grav/Common/Flex/Types/Users/UserIndex.php new file mode 100644 index 0000000..53467b5 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Users/UserIndex.php @@ -0,0 +1,206 @@ + + */ +class UserIndex extends FlexIndex implements UserCollectionInterface +{ + public const VERSION = parent::VERSION . '.2'; + + /** + * @param FlexStorageInterface $storage + * @return array + */ + public static function loadEntriesFromStorage(FlexStorageInterface $storage): array + { + // Load saved index. + $index = static::loadIndex($storage); + + $version = $index['version'] ?? 0; + $force = static::VERSION !== $version; + + // TODO: Following check flex index to be out of sync after some saves, disabled until better solution is found. + //$timestamp = $index['timestamp'] ?? 0; + //if (!$force && $timestamp && $timestamp > time() - 1) { + // return $index['index']; + //} + + // Load up-to-date index. + $entries = parent::loadEntriesFromStorage($storage); + + return static::updateIndexFile($storage, $index['index'], $entries, ['force_update' => $force]); + } + + /** + * @param array $meta + * @param array $data + * @param FlexStorageInterface $storage + * @return void + */ + public static function updateObjectMeta(array &$meta, array $data, FlexStorageInterface $storage): void + { + // Username can also be number and stored as such. + $key = (string)($data['username'] ?? $meta['key'] ?? $meta['storage_key']); + $meta['key'] = static::filterUsername($key, $storage); + $meta['email'] = isset($data['email']) ? mb_strtolower($data['email']) : null; + } + + /** + * Load user account. + * + * Always creates user object. To check if user exists, use $this->exists(). + * + * @param string $username + * @return UserObject + */ + public function load($username): UserInterface + { + $username = (string)$username; + + if ($username !== '') { + $key = static::filterUsername($username, $this->getFlexDirectory()->getStorage()); + $user = $this->get($key); + if ($user) { + return $user; + } + } else { + $key = ''; + } + + $directory = $this->getFlexDirectory(); + + /** @var UserObject $object */ + $object = $directory->createObject( + [ + 'username' => $username, + 'state' => 'enabled' + ], + $key + ); + + return $object; + } + + /** + * Delete user account. + * + * @param string $username + * @return bool True if user account was found and was deleted. + */ + public function delete($username): bool + { + $user = $this->load($username); + + $exists = $user->exists(); + if ($exists) { + $user->delete(); + } + + return $exists; + } + + /** + * Find a user by username, email, etc + * + * @param string $query the query to search for + * @param array $fields the fields to search + * @return UserObject + */ + public function find($query, $fields = ['username', 'email']): UserInterface + { + if (is_string($query) && $query !== '') { + foreach ((array)$fields as $field) { + if ($field === 'key') { + $user = $this->get($query); + } elseif ($field === 'storage_key') { + $user = $this->withKeyField('storage_key')->get($query); + } elseif ($field === 'flex_key') { + $user = $this->withKeyField('flex_key')->get($query); + } elseif ($field === 'email') { + $email = mb_strtolower($query); + $user = $this->withKeyField('email')->get($email); + } elseif ($field === 'username') { + $username = static::filterUsername($query, $this->getFlexDirectory()->getStorage()); + $user = $this->get($username); + } else { + $user = $this->__call('find', [$query, $field]); + } + if ($user) { + return $user; + } + } + } + + return $this->load(''); + } + + /** + * @param string $key + * @param FlexStorageInterface $storage + * @return string + */ + protected static function filterUsername(string $key, FlexStorageInterface $storage): string + { + return method_exists($storage, 'normalizeKey') ? $storage->normalizeKey($key) : $key; + } + + /** + * @param FlexStorageInterface $storage + * @return CompiledYamlFile|null + */ + protected static function getIndexFile(FlexStorageInterface $storage) + { + // Load saved index file. + $grav = Grav::instance(); + $locator = $grav['locator']; + $filename = $locator->findResource('user-data://flex/indexes/accounts.yaml', true, true); + + return CompiledYamlFile::instance($filename); + } + + /** + * @param array $entries + * @param array $added + * @param array $updated + * @param array $removed + */ + protected static function onChanges(array $entries, array $added, array $updated, array $removed): void + { + $message = sprintf('Flex: User index updated, %d objects (%d added, %d updated, %d removed).', count($entries), count($added), count($updated), count($removed)); + + $grav = Grav::instance(); + + /** @var Logger $logger */ + $logger = $grav['log']; + $logger->addDebug($message); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addMessage($message, 'debug'); + } +} diff --git a/system/src/Grav/Common/Flex/Types/Users/UserObject.php b/system/src/Grav/Common/Flex/Types/Users/UserObject.php new file mode 100644 index 0000000..5e43b59 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Users/UserObject.php @@ -0,0 +1,1059 @@ + 'session', + 'load' => false, + 'find' => false, + 'remove' => false, + 'get' => true, + 'set' => false, + 'undef' => false, + 'def' => false, + ] + parent::getCachedMethods(); + } + + /** + * UserObject constructor. + * @param array $elements + * @param string $key + * @param FlexDirectory $directory + * @param bool $validate + */ + public function __construct(array $elements, $key, FlexDirectory $directory, bool $validate = false) + { + // User can only be authenticated via login. + unset($elements['authenticated'], $elements['authorized']); + + // Define username if it's not set. + if (!isset($elements['username'])) { + $storageKey = $elements['__META']['storage_key'] ?? null; + $storage = $directory->getStorage(); + if (null !== $storageKey && method_exists($storage, 'normalizeKey') && $key === $storage->normalizeKey($storageKey)) { + $elements['username'] = $storageKey; + } else { + $elements['username'] = $key; + } + } + + // Define state if it isn't set. + if (!isset($elements['state'])) { + $elements['state'] = 'enabled'; + } + + parent::__construct($elements, $key, $directory, $validate); + } + + public function __clone() + { + $this->_access = null; + $this->_groups = null; + + parent::__clone(); + } + + /** + * @return void + */ + public function onPrepareRegistration(): void + { + if (!$this->getProperty('access')) { + /** @var Config $config */ + $config = Grav::instance()['config']; + + $groups = $config->get('plugins.login.user_registration.groups', ''); + $access = $config->get('plugins.login.user_registration.access', ['site' => ['login' => true]]); + + $this->setProperty('groups', $groups); + $this->setProperty('access', $access); + } + } + + /** + * Helper to get content editor will fall back if not set + * + * @return string + */ + public function getContentEditor(): string + { + return $this->getProperty('content_editor', 'default'); + } + + /** + * Get value by using dot notation for nested arrays/objects. + * + * @example $value = $this->get('this.is.my.nested.variable'); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @param string|null $separator Separator, defaults to '.' + * @return mixed Value. + */ + public function get($name, $default = null, $separator = null) + { + return $this->getNestedProperty($name, $default, $separator); + } + + /** + * Set value by using dot notation for nested arrays/objects. + * + * @example $data->set('this.is.my.nested.variable', $value); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function set($name, $value, $separator = null) + { + $this->setNestedProperty($name, $value, $separator); + + return $this; + } + + /** + * Unset value by using dot notation for nested arrays/objects. + * + * @example $data->undef('this.is.my.nested.variable'); + * + * @param string $name Dot separated path to the requested value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function undef($name, $separator = null) + { + $this->unsetNestedProperty($name, $separator); + + return $this; + } + + /** + * Set default value by using dot notation for nested arrays/objects. + * + * @example $data->def('this.is.my.nested.variable', 'default'); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function def($name, $default = null, $separator = null) + { + $this->defNestedProperty($name, $default, $separator); + + return $this; + } + + /** + * @param UserInterface|null $user + * @return bool + */ + public function isMyself(?UserInterface $user = null): bool + { + if (null === $user) { + $user = $this->getActiveUser(); + if ($user && !$user->authenticated) { + $user = null; + } + } + + return $user && $this->username === $user->username; + } + + /** + * Checks user authorization to the action. + * + * @param string $action + * @param string|null $scope + * @return bool|null + */ + public function authorize(string $action, string $scope = null): ?bool + { + if ($scope === 'test') { + // Special scope to test user permissions. + $scope = null; + } else { + // User needs to be enabled. + if ($this->getProperty('state') !== 'enabled') { + return false; + } + + // User needs to be logged in. + if (!$this->getProperty('authenticated')) { + return false; + } + + if (strpos($action, 'login') === false && !$this->getProperty('authorized')) { + // User needs to be authorized (2FA). + return false; + } + + // Workaround bug in Login::isUserAuthorizedForPage() <= Login v3.0.4 + if ((string)(int)$action === $action) { + return false; + } + } + + // Check custom application access. + $authorizeCallable = static::$authorizeCallable; + if ($authorizeCallable instanceof Closure) { + $callable = $authorizeCallable->bindTo($this, $this); + $authorized = $callable($action, $scope); + if (is_bool($authorized)) { + return $authorized; + } + } + + // Check user access. + $access = $this->getAccess(); + $authorized = $access->authorize($action, $scope); + if (is_bool($authorized)) { + return $authorized; + } + + // Check group access. + $authorized = $this->getGroups()->authorize($action, $scope); + if (is_bool($authorized)) { + return $authorized; + } + + // If any specific rule isn't hit, check if user is a superuser. + return $access->authorize('admin.super') === true; + } + + /** + * @param string $property + * @param mixed $default + * @return mixed + */ + public function getProperty($property, $default = null) + { + $value = parent::getProperty($property, $default); + + if ($property === 'avatar') { + $settings = $this->getMediaFieldSettings($property); + $value = $this->parseFileProperty($value, $settings); + } + + return $value; + } + + /** + * @return UserGroupIndex + */ + public function getRoles(): UserGroupIndex + { + return $this->getGroups(); + } + + /** + * Convert object into an array. + * + * @return array + */ + public function toArray() + { + $array = $this->jsonSerialize(); + + $settings = $this->getMediaFieldSettings('avatar'); + $array['avatar'] = $this->parseFileProperty($array['avatar'] ?? null, $settings); + + return $array; + } + + /** + * Convert object into YAML string. + * + * @param int $inline The level where you switch to inline YAML. + * @param int $indent The amount of spaces to use for indentation of nested nodes. + * @return string A YAML string representing the object. + */ + public function toYaml($inline = 5, $indent = 2) + { + $yaml = new YamlFormatter(['inline' => $inline, 'indent' => $indent]); + + return $yaml->encode($this->toArray()); + } + + /** + * Convert object into JSON string. + * + * @return string + */ + public function toJson() + { + $json = new JsonFormatter(); + + return $json->encode($this->toArray()); + } + + /** + * Join nested values together by using blueprints. + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value Value to be joined. + * @param string|null $separator Separator, defaults to '.' + * @return $this + * @throws RuntimeException + */ + public function join($name, $value, $separator = null) + { + $separator = $separator ?? '.'; + $old = $this->get($name, null, $separator); + if ($old !== null) { + if (!is_array($old)) { + throw new RuntimeException('Value ' . $old); + } + + if (is_object($value)) { + $value = (array) $value; + } elseif (!is_array($value)) { + throw new RuntimeException('Value ' . $value); + } + + $value = $this->getBlueprint()->mergeData($old, $value, $name, $separator); + } + + $this->set($name, $value, $separator); + + return $this; + } + + /** + * Get nested structure containing default values defined in the blueprints. + * + * Fields without default value are ignored in the list. + + * @return array + */ + public function getDefaults() + { + return $this->getBlueprint()->getDefaults(); + } + + /** + * Set default values by using blueprints. + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value Value to be joined. + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function joinDefaults($name, $value, $separator = null) + { + if (is_object($value)) { + $value = (array) $value; + } + + $old = $this->get($name, null, $separator); + if ($old !== null) { + $value = $this->getBlueprint()->mergeData($value, $old, $name, $separator ?? '.'); + } + + $this->setNestedProperty($name, $value, $separator); + + return $this; + } + + /** + * Get value from the configuration and join it with given data. + * + * @param string $name Dot separated path to the requested value. + * @param array|object $value Value to be joined. + * @param string $separator Separator, defaults to '.' + * @return array + * @throws RuntimeException + */ + public function getJoined($name, $value, $separator = null) + { + if (is_object($value)) { + $value = (array) $value; + } elseif (!is_array($value)) { + throw new RuntimeException('Value ' . $value); + } + + $old = $this->get($name, null, $separator); + + if ($old === null) { + // No value set; no need to join data. + return $value; + } + + if (!is_array($old)) { + throw new RuntimeException('Value ' . $old); + } + + // Return joined data. + return $this->getBlueprint()->mergeData($old, $value, $name, $separator ?? '.'); + } + + /** + * Set default values to the configuration if variables were not set. + * + * @param array $data + * @return $this + */ + public function setDefaults(array $data) + { + $this->setElements($this->getBlueprint()->mergeData($data, $this->toArray())); + + return $this; + } + + /** + * Validate by blueprints. + * + * @return $this + * @throws \Exception + */ + public function validate() + { + $this->getBlueprint()->validate($this->toArray()); + + return $this; + } + + /** + * Filter all items by using blueprints. + * @return $this + */ + public function filter() + { + $this->setElements($this->getBlueprint()->filter($this->toArray())); + + return $this; + } + + /** + * Get extra items which haven't been defined in blueprints. + * + * @return array + */ + public function extra() + { + return $this->getBlueprint()->extra($this->toArray()); + } + + /** + * Return unmodified data as raw string. + * + * NOTE: This function only returns data which has been saved to the storage. + * + * @return string + */ + public function raw() + { + $file = $this->file(); + + return $file ? $file->raw() : ''; + } + + /** + * Set or get the data storage. + * + * @param FileInterface|null $storage Optionally enter a new storage. + * @return FileInterface|null + */ + public function file(FileInterface $storage = null) + { + if (null !== $storage) { + $this->_storage = $storage; + } + + return $this->_storage; + } + + /** + * @return bool + */ + public function isValid(): bool + { + return $this->getProperty('state') !== null; + } + + /** + * Save user + * + * @return static + */ + public function save() + { + // TODO: We may want to handle this in the storage layer in the future. + $key = $this->getStorageKey(); + if (!$key || strpos($key, '@@')) { + $storage = $this->getFlexDirectory()->getStorage(); + if ($storage instanceof FileStorage) { + $this->setStorageKey($this->getKey()); + } + } + + $password = $this->getProperty('password') ?? $this->getProperty('password1'); + if (null !== $password && '' !== $password) { + $password2 = $this->getProperty('password2'); + if (!\is_string($password) || ($password2 && $password !== $password2)) { + throw new \RuntimeException('Passwords did not match.'); + } + + $this->setProperty('hashed_password', Authentication::create($password)); + } + $this->unsetProperty('password'); + $this->unsetProperty('password1'); + $this->unsetProperty('password2'); + + // Backwards compatibility with older plugins. + $fireEvents = $this->isAdminSite() && $this->getFlexDirectory()->getConfig('object.compat.events', true); + $grav = $this->getContainer(); + if ($fireEvents) { + $self = $this; + $grav->fireEvent('onAdminSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => &$self])); + if ($self !== $this) { + throw new RuntimeException('Switching Flex User object during onAdminSave event is not supported! Please update plugin.'); + } + } + + $instance = parent::save(); + + // Backwards compatibility with older plugins. + if ($fireEvents) { + $grav->fireEvent('onAdminAfterSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => $this])); + } + + return $instance; + } + + /** + * @return array + */ + public function prepareStorage(): array + { + $elements = parent::prepareStorage(); + + // Do not save authorization information. + unset($elements['authenticated'], $elements['authorized']); + + return $elements; + } + + /** + * @return MediaCollectionInterface + */ + public function getMedia() + { + /** @var Media $media */ + $media = $this->getFlexMedia(); + + // Deal with shared avatar folder. + $path = $this->getAvatarFile(); + if ($path && !$media[$path] && is_file($path)) { + $medium = MediumFactory::fromFile($path); + if ($medium) { + $media->add($path, $medium); + $name = Utils::basename($path); + if ($name !== $path) { + $media->add($name, $medium); + } + } + } + + return $media; + } + + /** + * @return string|null + */ + public function getMediaFolder(): ?string + { + $folder = $this->getFlexMediaFolder(); + + // Check for shared media + if (!$folder && !$this->getFlexDirectory()->getMediaFolder()) { + $this->_loadMedia = false; + $folder = $this->getBlueprint()->fields()['avatar']['destination'] ?? 'account://avatars'; + } + + return $folder; + } + + /** + * @param string $name + * @return array|object|null + * @internal + */ + public function initRelationship(string $name) + { + switch ($name) { + case 'media': + $list = []; + foreach ($this->getMedia()->all() as $filename => $object) { + $list[] = $this->buildMediaObject(null, $filename, $object); + } + + return $list; + case 'avatar': + return $this->buildMediaObject('avatar', basename($this->getAvatarUrl()), $this->getAvatarImage()); + } + + throw new \InvalidArgumentException(sprintf('%s: Relationship %s does not exist', $this->getFlexType(), $name)); + } + + /** + * @return bool Return true if relationships were updated. + */ + protected function updateRelationships(): bool + { + $modified = $this->getRelationships()->getModified(); + if ($modified) { + foreach ($modified as $relationship) { + $name = $relationship->getName(); + switch ($name) { + case 'avatar': + \assert($relationship instanceof ToOneRelationshipInterface); + $this->updateAvatarRelationship($relationship); + break; + default: + throw new \InvalidArgumentException(sprintf('%s: Relationship %s cannot be modified', $this->getFlexType(), $name), 400); + } + } + + $this->resetRelationships(); + + return true; + } + + return false; + } + + /** + * @param ToOneRelationshipInterface $relationship + */ + protected function updateAvatarRelationship(ToOneRelationshipInterface $relationship): void + { + $files = []; + $avatar = $this->getAvatarImage(); + if ($avatar) { + $files['avatar'][$avatar->filename] = null; + } + + $identifier = $relationship->getIdentifier(); + if ($identifier) { + \assert($identifier instanceof MediaIdentifier); + $object = $identifier->getObject(); + if ($object instanceof UploadedMediaObject) { + $uploadedFile = $object->getUploadedFile(); + if ($uploadedFile) { + $files['avatar'][$uploadedFile->getClientFilename()] = $uploadedFile; + } + } + } + + $this->update([], $files); + } + + /** + * @param string $name + * @return Blueprint + */ + protected function doGetBlueprint(string $name = ''): Blueprint + { + $blueprint = $this->getFlexDirectory()->getBlueprint($name ? '.' . $name : $name); + + // HACK: With folder storage we need to ignore the avatar destination. + if ($this->getFlexDirectory()->getMediaFolder()) { + $field = $blueprint->get('form/fields/avatar'); + if ($field) { + unset($field['destination']); + $blueprint->set('form/fields/avatar', $field); + } + } + + return $blueprint; + } + + /** + * @param UserInterface $user + * @param string $action + * @param string $scope + * @param bool $isMe + * @return bool|null + */ + protected function isAuthorizedOverride(UserInterface $user, string $action, string $scope, bool $isMe = false): ?bool + { + // Check custom application access. + $isAuthorizedCallable = static::$isAuthorizedCallable; + if ($isAuthorizedCallable instanceof Closure) { + $callable = $isAuthorizedCallable->bindTo($this, $this); + $authorized = $callable($user, $action, $scope, $isMe); + if (is_bool($authorized)) { + return $authorized; + } + } + + if ($user instanceof self && $user->getStorageKey() === $this->getStorageKey()) { + // User cannot delete his own account, otherwise he has full access. + return $action !== 'delete'; + } + + return parent::isAuthorizedOverride($user, $action, $scope, $isMe); + } + + /** + * @return string|null + */ + protected function getAvatarFile(): ?string + { + $avatars = $this->getElement('avatar'); + if (is_array($avatars) && $avatars) { + $avatar = array_shift($avatars); + + return $avatar['path'] ?? null; + } + + return null; + } + + /** + * Gets the associated media collection (original images). + * + * @return MediaCollectionInterface Representation of associated media. + */ + protected function getOriginalMedia() + { + $folder = $this->getMediaFolder(); + if ($folder) { + $folder .= '/original'; + } + + return (new Media($folder ?? '', $this->getMediaOrder()))->setTimestamps(); + } + + /** + * @param array $files + * @return void + */ + protected function setUpdatedMedia(array $files): void + { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + $media = $this->getMedia(); + if (!$media instanceof MediaUploadInterface) { + return; + } + + $filesystem = Filesystem::getInstance(false); + + $list = []; + $list_original = []; + foreach ($files as $field => $group) { + // Ignore files without a field. + if ($field === '') { + continue; + } + $field = (string)$field; + + // Load settings for the field. + $settings = $this->getMediaFieldSettings($field); + foreach ($group as $filename => $file) { + if ($file) { + // File upload. + $filename = $file->getClientFilename(); + + /** @var FormFlashFile $file */ + $data = $file->jsonSerialize(); + unset($data['tmp_name'], $data['path']); + } else { + // File delete. + $data = null; + } + + if ($file) { + // Check file upload against media limits (except for max size). + $filename = $media->checkUploadedFile($file, $filename, ['filesize' => 0] + $settings); + } + + $self = $settings['self']; + if ($this->_loadMedia && $self) { + $filepath = $filename; + } else { + $filepath = "{$settings['destination']}/{$filename}"; + + // For backwards compatibility we are always using relative path from the installation root. + if ($locator->isStream($filepath)) { + $filepath = $locator->findResource($filepath, false, true); + } + } + + // Special handling for original images. + if (strpos($field, '/original')) { + if ($this->_loadMedia && $self) { + $list_original[$filename] = [$file, $settings]; + } + continue; + } + + // Calculate path without the retina scaling factor. + $realpath = $filesystem->pathname($filepath) . str_replace(['@3x', '@2x'], '', Utils::basename($filepath)); + + $list[$filename] = [$file, $settings]; + + $path = str_replace('.', "\n", $field); + if (null !== $data) { + $data['name'] = $filename; + $data['path'] = $filepath; + + $this->setNestedProperty("{$path}\n{$realpath}", $data, "\n"); + } else { + $this->unsetNestedProperty("{$path}\n{$realpath}", "\n"); + } + } + } + + $this->clearMediaCache(); + + $this->_uploads = $list; + $this->_uploads_original = $list_original; + } + + protected function saveUpdatedMedia(): void + { + $media = $this->getMedia(); + if (!$media instanceof MediaUploadInterface) { + throw new RuntimeException('Internal error UO101'); + } + + // Upload/delete original sized images. + /** + * @var string $filename + * @var UploadedFileInterface|array|null $file + */ + foreach ($this->_uploads_original ?? [] as $filename => $file) { + $filename = 'original/' . $filename; + if (is_array($file)) { + [$file, $settings] = $file; + } else { + $settings = null; + } + if ($file instanceof UploadedFileInterface) { + $media->copyUploadedFile($file, $filename, $settings); + } else { + $media->deleteFile($filename, $settings); + } + } + + // Upload/delete altered files. + /** + * @var string $filename + * @var UploadedFileInterface|array|null $file + */ + foreach ($this->getUpdatedMedia() as $filename => $file) { + if (is_array($file)) { + [$file, $settings] = $file; + } else { + $settings = null; + } + if ($file instanceof UploadedFileInterface) { + $media->copyUploadedFile($file, $filename, $settings); + } else { + $media->deleteFile($filename, $settings); + } + } + + $this->setUpdatedMedia([]); + $this->clearMediaCache(); + } + + /** + * @return array + */ + protected function doSerialize(): array + { + return [ + 'type' => $this->getFlexType(), + 'key' => $this->getKey(), + 'elements' => $this->jsonSerialize(), + 'storage' => $this->getMetaData() + ]; + } + + /** + * @return UserGroupIndex + */ + protected function getUserGroups() + { + $grav = Grav::instance(); + + /** @var Flex $flex */ + $flex = $grav['flex']; + + /** @var UserGroupCollection|null $groups */ + $groups = $flex->getDirectory('user-groups'); + if ($groups) { + /** @var UserGroupIndex $index */ + $index = $groups->getIndex(); + + return $index; + } + + return $grav['user_groups']; + } + + /** + * @return UserGroupIndex + */ + protected function getGroups() + { + if (null === $this->_groups) { + /** @var UserGroupIndex $groups */ + $groups = $this->getUserGroups()->select((array)$this->getProperty('groups')); + $this->_groups = $groups; + } + + return $this->_groups; + } + + /** + * @return Access + */ + protected function getAccess(): Access + { + if (null === $this->_access) { + $this->_access = new Access($this->getProperty('access')); + } + + return $this->_access; + } + + /** + * @param mixed $value + * @return array + */ + protected function offsetLoad_access($value): array + { + if (!$value instanceof Access) { + $value = new Access($value); + } + + return $value->jsonSerialize(); + } + + /** + * @param mixed $value + * @return array + */ + protected function offsetPrepare_access($value): array + { + return $this->offsetLoad_access($value); + } + + /** + * @param array|null $value + * @return array|null + */ + protected function offsetSerialize_access(?array $value): ?array + { + return $value; + } +} diff --git a/system/src/Grav/Common/Form/FormFlash.php b/system/src/Grav/Common/Form/FormFlash.php new file mode 100644 index 0000000..4f624ea --- /dev/null +++ b/system/src/Grav/Common/Form/FormFlash.php @@ -0,0 +1,107 @@ +files as $field => $files) { + if (strpos($field, '/')) { + continue; + } + foreach ($files as $file) { + if (is_array($file)) { + $file['tmp_name'] = $this->getTmpDir() . '/' . $file['tmp_name']; + $fields[$field][$file['path'] ?? $file['name']] = $file; + } + } + } + + return $fields; + } + + /** + * @param string $field + * @param string $filename + * @param array $upload + * @return bool + * @deprecated 1.6 For backwards compatibility only, do not use + */ + public function uploadFile(string $field, string $filename, array $upload): bool + { + if (!$this->uniqueId) { + return false; + } + + $tmp_dir = $this->getTmpDir(); + Folder::create($tmp_dir); + + $tmp_file = $upload['file']['tmp_name']; + $basename = Utils::basename($tmp_file); + + if (!move_uploaded_file($tmp_file, $tmp_dir . '/' . $basename)) { + return false; + } + + $upload['file']['tmp_name'] = $basename; + $upload['file']['name'] = $filename; + + $this->addFileInternal($field, $filename, $upload['file']); + + return true; + } + + /** + * @param string $field + * @param string $filename + * @param array $upload + * @param array $crop + * @return bool + * @deprecated 1.6 For backwards compatibility only, do not use + */ + public function cropFile(string $field, string $filename, array $upload, array $crop): bool + { + if (!$this->uniqueId) { + return false; + } + + $tmp_dir = $this->getTmpDir(); + Folder::create($tmp_dir); + + $tmp_file = $upload['file']['tmp_name']; + $basename = Utils::basename($tmp_file); + + if (!move_uploaded_file($tmp_file, $tmp_dir . '/' . $basename)) { + return false; + } + + $upload['file']['tmp_name'] = $basename; + $upload['file']['name'] = $filename; + + $this->addFileInternal($field, $filename, $upload['file'], $crop); + + return true; + } +} diff --git a/system/src/Grav/Common/GPM/AbstractCollection.php b/system/src/Grav/Common/GPM/AbstractCollection.php new file mode 100644 index 0000000..a4d11f4 --- /dev/null +++ b/system/src/Grav/Common/GPM/AbstractCollection.php @@ -0,0 +1,41 @@ +toArray(), JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + public function toArray() + { + $items = []; + + foreach ($this->items as $name => $package) { + $items[$name] = $package->toArray(); + } + + return $items; + } +} diff --git a/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php b/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php new file mode 100644 index 0000000..121a48a --- /dev/null +++ b/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php @@ -0,0 +1,50 @@ +items as $name => $package) { + $items[$name] = $package->toArray(); + } + + return json_encode($items, JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + public function toArray() + { + $items = []; + + foreach ($this->items as $name => $package) { + $items[$name] = $package->toArray(); + } + + return $items; + } +} diff --git a/system/src/Grav/Common/GPM/Common/CachedCollection.php b/system/src/Grav/Common/GPM/Common/CachedCollection.php new file mode 100644 index 0000000..7613c76 --- /dev/null +++ b/system/src/Grav/Common/GPM/Common/CachedCollection.php @@ -0,0 +1,43 @@ + $item) { + $this->append([$name => $item]); + } + } +} diff --git a/system/src/Grav/Common/GPM/Common/Package.php b/system/src/Grav/Common/GPM/Common/Package.php new file mode 100644 index 0000000..07ecc5b --- /dev/null +++ b/system/src/Grav/Common/GPM/Common/Package.php @@ -0,0 +1,99 @@ +data = $package; + + if ($type) { + $this->data->set('package_type', $type); + } + } + + /** + * @return Data + */ + public function getData() + { + return $this->data; + } + + /** + * @param string $key + * @return mixed + */ + #[\ReturnTypeWillChange] + public function __get($key) + { + return $this->data->get($key); + } + + /** + * @param string $key + * @param mixed $value + * @return void + */ + #[\ReturnTypeWillChange] + public function __set($key, $value) + { + $this->data->set($key, $value); + } + + /** + * @param string $key + * @return bool + */ + #[\ReturnTypeWillChange] + public function __isset($key) + { + return isset($this->data->{$key}); + } + + /** + * @return string + */ + #[\ReturnTypeWillChange] + public function __toString() + { + return $this->toJson(); + } + + /** + * @return string + */ + public function toJson() + { + return $this->data->toJson(); + } + + /** + * @return array + */ + public function toArray() + { + return $this->data->toArray(); + } +} diff --git a/system/src/Grav/Common/GPM/GPM.php b/system/src/Grav/Common/GPM/GPM.php new file mode 100644 index 0000000..d4690c2 --- /dev/null +++ b/system/src/Grav/Common/GPM/GPM.php @@ -0,0 +1,1270 @@ + 'user/plugins/%name%', + 'themes' => 'user/themes/%name%', + 'skeletons' => 'user/' + ]; + + /** + * Creates a new GPM instance with Local and Remote packages available + * + * @param bool $refresh Applies to Remote Packages only and forces a refetch of data + * @param callable|null $callback Either a function or callback in array notation + */ + public function __construct($refresh = false, $callback = null) + { + parent::__construct(); + + Folder::create(CACHE_DIR . '/gpm'); + + $this->cache = []; + $this->installed = new Local\Packages(); + $this->refresh = $refresh; + $this->callback = $callback; + } + + /** + * Magic getter method + * + * @param string $offset Asset name value + * @return mixed Asset value + */ + #[\ReturnTypeWillChange] + public function __get($offset) + { + switch ($offset) { + case 'grav': + return $this->getGrav(); + } + + return parent::__get($offset); + } + + /** + * Magic method to determine if the attribute is set + * + * @param string $offset Asset name value + * @return bool True if the value is set + */ + #[\ReturnTypeWillChange] + public function __isset($offset) + { + switch ($offset) { + case 'grav': + return $this->getGrav() !== null; + } + + return parent::__isset($offset); + } + + /** + * Return the locally installed packages + * + * @return Local\Packages + */ + public function getInstalled() + { + return $this->installed; + } + + /** + * Returns the Locally installable packages + * + * @param array $list_type_installed + * @return array The installed packages + */ + public function getInstallable($list_type_installed = ['plugins' => true, 'themes' => true]) + { + $items = ['total' => 0]; + foreach ($list_type_installed as $type => $type_installed) { + if ($type_installed === false) { + continue; + } + $methodInstallableType = 'getInstalled' . ucfirst($type); + $to_install = $this->$methodInstallableType(); + $items[$type] = $to_install; + $items['total'] += count($to_install); + } + + return $items; + } + + /** + * Returns the amount of locally installed packages + * + * @return int Amount of installed packages + */ + public function countInstalled() + { + $installed = $this->getInstalled(); + + return count($installed['plugins']) + count($installed['themes']); + } + + /** + * Return the instance of a specific Package + * + * @param string $slug The slug of the Package + * @return Local\Package|null The instance of the Package + */ + public function getInstalledPackage($slug) + { + return $this->getInstalledPlugin($slug) ?? $this->getInstalledTheme($slug); + } + + /** + * Return the instance of a specific Plugin + * + * @param string $slug The slug of the Plugin + * @return Local\Package|null The instance of the Plugin + */ + public function getInstalledPlugin($slug) + { + return $this->installed['plugins'][$slug] ?? null; + } + + /** + * Returns the Locally installed plugins + * @return Iterator The installed plugins + */ + public function getInstalledPlugins() + { + return $this->installed['plugins']; + } + + + /** + * Returns the plugin's enabled state + * + * @param string $slug + * @return bool True if the Plugin is Enabled. False if manually set to enable:false. Null otherwise. + */ + public function isPluginEnabled($slug): bool + { + $grav = Grav::instance(); + + return ($grav['config']['plugins'][$slug]['enabled'] ?? false) === true; + } + + /** + * Checks if a Plugin is installed + * + * @param string $slug The slug of the Plugin + * @return bool True if the Plugin has been installed. False otherwise + */ + public function isPluginInstalled($slug): bool + { + return isset($this->installed['plugins'][$slug]); + } + + /** + * @param string $slug + * @return bool + */ + public function isPluginInstalledAsSymlink($slug) + { + $plugin = $this->getInstalledPlugin($slug); + + return (bool)($plugin->symlink ?? false); + } + + /** + * Return the instance of a specific Theme + * + * @param string $slug The slug of the Theme + * @return Local\Package|null The instance of the Theme + */ + public function getInstalledTheme($slug) + { + return $this->installed['themes'][$slug] ?? null; + } + + /** + * Returns the Locally installed themes + * + * @return Iterator The installed themes + */ + public function getInstalledThemes() + { + return $this->installed['themes']; + } + + /** + * Checks if a Theme is enabled + * + * @param string $slug The slug of the Theme + * @return bool True if the Theme has been set to the default theme. False if installed, but not enabled. Null otherwise. + */ + public function isThemeEnabled($slug): bool + { + $grav = Grav::instance(); + + $current_theme = $grav['config']['system']['pages']['theme'] ?? null; + + return $current_theme === $slug; + } + + /** + * Checks if a Theme is installed + * + * @param string $slug The slug of the Theme + * @return bool True if the Theme has been installed. False otherwise + */ + public function isThemeInstalled($slug): bool + { + return isset($this->installed['themes'][$slug]); + } + + /** + * Returns the amount of updates available + * + * @return int Amount of available updates + */ + public function countUpdates() + { + return count($this->getUpdatablePlugins()) + count($this->getUpdatableThemes()); + } + + /** + * Returns an array of Plugins and Themes that can be updated. + * Plugins and Themes are extended with the `available` property that relies to the remote version + * + * @param array $list_type_update specifies what type of package to update + * @return array Array of updatable Plugins and Themes. + * Format: ['total' => int, 'plugins' => array, 'themes' => array] + */ + public function getUpdatable($list_type_update = ['plugins' => true, 'themes' => true]) + { + $items = ['total' => 0]; + foreach ($list_type_update as $type => $type_updatable) { + if ($type_updatable === false) { + continue; + } + $methodUpdatableType = 'getUpdatable' . ucfirst($type); + $to_update = $this->$methodUpdatableType(); + $items[$type] = $to_update; + $items['total'] += count($to_update); + } + + return $items; + } + + /** + * Returns an array of Plugins that can be updated. + * The Plugins are extended with the `available` property that relies to the remote version + * + * @return array Array of updatable Plugins + */ + public function getUpdatablePlugins() + { + $items = []; + + $repository = $this->getRepository(); + if (null === $repository) { + return $items; + } + + $plugins = $repository['plugins']; + + // local cache to speed things up + if (isset($this->cache[__METHOD__])) { + return $this->cache[__METHOD__]; + } + + foreach ($this->installed['plugins'] as $slug => $plugin) { + if (!isset($plugins[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) { + continue; + } + + $local_version = $plugin->version ?? 'Unknown'; + $remote_version = $plugins[$slug]->version; + + if (version_compare($local_version, $remote_version) < 0) { + $plugins[$slug]->available = $remote_version; + $plugins[$slug]->version = $local_version; + $plugins[$slug]->type = $plugins[$slug]->release_type; + $items[$slug] = $plugins[$slug]; + } + } + + $this->cache[__METHOD__] = $items; + + return $items; + } + + /** + * Get the latest release of a package from the GPM + * + * @param string $package_name + * @return string|null + */ + public function getLatestVersionOfPackage($package_name) + { + $repository = $this->getRepository(); + if (null === $repository) { + return null; + } + + $plugins = $repository['plugins']; + if (isset($plugins[$package_name])) { + return $plugins[$package_name]->available ?: $plugins[$package_name]->version; + } + + //Not a plugin, it's a theme? + $themes = $repository['themes']; + if (isset($themes[$package_name])) { + return $themes[$package_name]->available ?: $themes[$package_name]->version; + } + + return null; + } + + /** + * Check if a Plugin or Theme is updatable + * + * @param string $slug The slug of the package + * @return bool True if updatable. False otherwise or if not found + */ + public function isUpdatable($slug) + { + return $this->isPluginUpdatable($slug) || $this->isThemeUpdatable($slug); + } + + /** + * Checks if a Plugin is updatable + * + * @param string $plugin The slug of the Plugin + * @return bool True if the Plugin is updatable. False otherwise + */ + public function isPluginUpdatable($plugin) + { + return array_key_exists($plugin, (array)$this->getUpdatablePlugins()); + } + + /** + * Returns an array of Themes that can be updated. + * The Themes are extended with the `available` property that relies to the remote version + * + * @return array Array of updatable Themes + */ + public function getUpdatableThemes() + { + $items = []; + + $repository = $this->getRepository(); + if (null === $repository) { + return $items; + } + + $themes = $repository['themes']; + + // local cache to speed things up + if (isset($this->cache[__METHOD__])) { + return $this->cache[__METHOD__]; + } + + foreach ($this->installed['themes'] as $slug => $plugin) { + if (!isset($themes[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) { + continue; + } + + $local_version = $plugin->version ?? 'Unknown'; + $remote_version = $themes[$slug]->version; + + if (version_compare($local_version, $remote_version) < 0) { + $themes[$slug]->available = $remote_version; + $themes[$slug]->version = $local_version; + $themes[$slug]->type = $themes[$slug]->release_type; + $items[$slug] = $themes[$slug]; + } + } + + $this->cache[__METHOD__] = $items; + + return $items; + } + + /** + * Checks if a Theme is Updatable + * + * @param string $theme The slug of the Theme + * @return bool True if the Theme is updatable. False otherwise + */ + public function isThemeUpdatable($theme) + { + return array_key_exists($theme, (array)$this->getUpdatableThemes()); + } + + /** + * Get the release type of a package (stable / testing) + * + * @param string $package_name + * @return string|null + */ + public function getReleaseType($package_name) + { + $repository = $this->getRepository(); + if (null === $repository) { + return null; + } + + $plugins = $repository['plugins']; + if (isset($plugins[$package_name])) { + return $plugins[$package_name]->release_type; + } + + //Not a plugin, it's a theme? + $themes = $repository['themes']; + if (isset($themes[$package_name])) { + return $themes[$package_name]->release_type; + } + + return null; + } + + /** + * Returns true if the package latest release is stable + * + * @param string $package_name + * @return bool + */ + public function isStableRelease($package_name) + { + return $this->getReleaseType($package_name) === 'stable'; + } + + /** + * Returns true if the package latest release is testing + * + * @param string $package_name + * @return bool + */ + public function isTestingRelease($package_name) + { + $package = $this->getInstalledPackage($package_name); + $testing = $package->testing ?? false; + + return $this->getReleaseType($package_name) === 'testing' || $testing; + } + + /** + * Returns a Plugin from the repository + * + * @param string $slug The slug of the Plugin + * @return Remote\Package|null Package if found, NULL if not + */ + public function getRepositoryPlugin($slug) + { + $packages = $this->getRepositoryPlugins(); + + return $packages ? ($packages[$slug] ?? null) : null; + } + + /** + * Returns the list of Plugins available in the repository + * + * @return Iterator|null The Plugins remotely available + */ + public function getRepositoryPlugins() + { + return $this->getRepository()['plugins'] ?? null; + } + + /** + * Returns a Theme from the repository + * + * @param string $slug The slug of the Theme + * @return Remote\Package|null Package if found, NULL if not + */ + public function getRepositoryTheme($slug) + { + $packages = $this->getRepositoryThemes(); + + return $packages ? ($packages[$slug] ?? null) : null; + } + + /** + * Returns the list of Themes available in the repository + * + * @return Iterator|null The Themes remotely available + */ + public function getRepositoryThemes() + { + return $this->getRepository()['themes'] ?? null; + } + + /** + * Returns the list of Plugins and Themes available in the repository + * + * @return Remote\Packages|null Available Plugins and Themes + * Format: ['plugins' => array, 'themes' => array] + */ + public function getRepository() + { + if (null === $this->repository) { + try { + $this->repository = new Remote\Packages($this->refresh, $this->callback); + } catch (Exception $e) {} + } + + return $this->repository; + } + + /** + * Returns Grav version available in the repository + * + * @return Remote\GravCore|null + */ + public function getGrav() + { + if (null === $this->grav) { + try { + $this->grav = new Remote\GravCore($this->refresh, $this->callback); + } catch (Exception $e) {} + } + + return $this->grav; + } + + /** + * Searches for a Package in the repository + * + * @param string $search Can be either the slug or the name + * @param bool $ignore_exception True if should not fire an exception (for use in Twig) + * @return Remote\Package|false Package if found, FALSE if not + */ + public function findPackage($search, $ignore_exception = false) + { + $search = strtolower($search); + + $found = $this->getRepositoryPlugin($search) ?? $this->getRepositoryTheme($search); + if ($found) { + return $found; + } + + $themes = $this->getRepositoryThemes(); + $plugins = $this->getRepositoryPlugins(); + + if (null === $themes || null === $plugins) { + if (!is_writable(GRAV_ROOT . '/cache/gpm')) { + throw new RuntimeException('The cache/gpm folder is not writable. Please check the folder permissions.'); + } + + if ($ignore_exception) { + return false; + } + + throw new RuntimeException('GPM not reachable. Please check your internet connection or check the Grav site is reachable'); + } + + foreach ($themes as $slug => $theme) { + if ($search === $slug || $search === $theme->name) { + return $theme; + } + } + + foreach ($plugins as $slug => $plugin) { + if ($search === $slug || $search === $plugin->name) { + return $plugin; + } + } + + return false; + } + + /** + * Download the zip package via the URL + * + * @param string $package_file + * @param string $tmp + * @return string|null + */ + public static function downloadPackage($package_file, $tmp) + { + $package = parse_url($package_file); + if (!is_array($package)) { + throw new \RuntimeException("Malformed GPM URL: {$package_file}"); + } + + $filename = Utils::basename($package['path'] ?? ''); + + if (Grav::instance()['config']->get('system.gpm.official_gpm_only') && ($package['host'] ?? null) !== 'getgrav.org') { + throw new RuntimeException('Only official GPM URLs are allowed. You can modify this behavior in the System configuration.'); + } + + $output = Response::get($package_file, []); + + if ($output) { + Folder::create($tmp); + file_put_contents($tmp . DS . $filename, $output); + return $tmp . DS . $filename; + } + + return null; + } + + /** + * Copy the local zip package to tmp + * + * @param string $package_file + * @param string $tmp + * @return string|null + */ + public static function copyPackage($package_file, $tmp) + { + $package_file = realpath($package_file); + + if ($package_file && file_exists($package_file)) { + $filename = Utils::basename($package_file); + Folder::create($tmp); + copy($package_file, $tmp . DS . $filename); + return $tmp . DS . $filename; + } + + return null; + } + + /** + * Try to guess the package type from the source files + * + * @param string $source + * @return string|false + */ + public static function getPackageType($source) + { + $plugin_regex = '/^class\\s{1,}[a-zA-Z0-9]{1,}\\s{1,}extends.+Plugin/m'; + $theme_regex = '/^class\\s{1,}[a-zA-Z0-9]{1,}\\s{1,}extends.+Theme/m'; + + if (file_exists($source . 'system/defines.php') && + file_exists($source . 'system/config/system.yaml') + ) { + return 'grav'; + } + + // must have a blueprint + if (!file_exists($source . 'blueprints.yaml')) { + return false; + } + + // either theme or plugin + $name = Utils::basename($source); + if (Utils::contains($name, 'theme')) { + return 'theme'; + } + if (Utils::contains($name, 'plugin')) { + return 'plugin'; + } + + $glob = glob($source . '*.php') ?: []; + foreach ($glob as $filename) { + $contents = file_get_contents($filename); + if (!$contents) { + continue; + } + if (preg_match($theme_regex, $contents)) { + return 'theme'; + } + if (preg_match($plugin_regex, $contents)) { + return 'plugin'; + } + } + + // Assume it's a theme + return 'theme'; + } + + /** + * Try to guess the package name from the source files + * + * @param string $source + * @return string|false + */ + public static function getPackageName($source) + { + $ignore_yaml_files = ['blueprints', 'languages']; + + $glob = glob($source . '*.yaml') ?: []; + foreach ($glob as $filename) { + $name = strtolower(Utils::basename($filename, '.yaml')); + if (in_array($name, $ignore_yaml_files)) { + continue; + } + + return $name; + } + + return false; + } + + /** + * Find/Parse the blueprint file + * + * @param string $source + * @return array|false + */ + public static function getBlueprints($source) + { + $blueprint_file = $source . 'blueprints.yaml'; + if (!file_exists($blueprint_file)) { + return false; + } + + $file = YamlFile::instance($blueprint_file); + $blueprint = (array)$file->content(); + $file->free(); + + return $blueprint; + } + + /** + * Get the install path for a name and a particular type of package + * + * @param string $type + * @param string $name + * @return string + */ + public static function getInstallPath($type, $name) + { + $locator = Grav::instance()['locator']; + + if ($type === 'theme') { + $install_path = $locator->findResource('themes://', false) . DS . $name; + } else { + $install_path = $locator->findResource('plugins://', false) . DS . $name; + } + + return $install_path; + } + + /** + * Searches for a list of Packages in the repository + * + * @param array $searches An array of either slugs or names + * @return array Array of found Packages + * Format: ['total' => int, 'not_found' => array, ] + */ + public function findPackages($searches = []) + { + $packages = ['total' => 0, 'not_found' => []]; + $inflector = new Inflector(); + + foreach ($searches as $search) { + $repository = ''; + // if this is an object, get the search data from the key + if (is_object($search)) { + $search = (array)$search; + $key = key($search); + $repository = $search[$key]; + $search = $key; + } + + $found = $this->findPackage($search); + if ($found) { + // set override repository if provided + if ($repository) { + $found->override_repository = $repository; + } + if (!isset($packages[$found->package_type])) { + $packages[$found->package_type] = []; + } + + $packages[$found->package_type][$found->slug] = $found; + $packages['total']++; + } else { + // make a best guess at the type based on the repo URL + if (Utils::contains($repository, '-theme')) { + $type = 'themes'; + } else { + $type = 'plugins'; + } + + $not_found = new stdClass(); + $not_found->name = $inflector::camelize($search); + $not_found->slug = $search; + $not_found->package_type = $type; + $not_found->install_path = str_replace('%name%', $search, $this->install_paths[$type]); + $not_found->override_repository = $repository; + $packages['not_found'][$search] = $not_found; + } + } + + return $packages; + } + + /** + * Return the list of packages that have the passed one as dependency + * + * @param string $slug The slug name of the package + * @return array + */ + public function getPackagesThatDependOnPackage($slug) + { + $plugins = $this->getInstalledPlugins(); + $themes = $this->getInstalledThemes(); + $packages = array_merge($plugins->toArray(), $themes->toArray()); + + $list = []; + foreach ($packages as $package_name => $package) { + $dependencies = $package['dependencies'] ?? []; + foreach ($dependencies as $dependency) { + if (is_array($dependency) && isset($dependency['name'])) { + $dependency = $dependency['name']; + } + + if ($dependency === $slug) { + $list[] = $package_name; + } + } + } + + return $list; + } + + + /** + * Get the required version of a dependency of a package + * + * @param string $package_slug + * @param string $dependency_slug + * @return mixed|null + */ + public function getVersionOfDependencyRequiredByPackage($package_slug, $dependency_slug) + { + $dependencies = $this->getInstalledPackage($package_slug)->dependencies ?? []; + foreach ($dependencies as $dependency) { + if (isset($dependency[$dependency_slug])) { + return $dependency[$dependency_slug]; + } + } + + return null; + } + + /** + * Check the package identified by $slug can be updated to the version passed as argument. + * Thrown an exception if it cannot be updated because another package installed requires it to be at an older version. + * + * @param string $slug + * @param string $version_with_operator + * @param array $ignore_packages_list + * @return bool + * @throws RuntimeException + */ + public function checkNoOtherPackageNeedsThisDependencyInALowerVersion($slug, $version_with_operator, $ignore_packages_list) + { + // check if any of the currently installed package need this in a lower version than the one we need. In case, abort and tell which package + $dependent_packages = $this->getPackagesThatDependOnPackage($slug); + $version = $this->calculateVersionNumberFromDependencyVersion($version_with_operator); + + if (count($dependent_packages)) { + foreach ($dependent_packages as $dependent_package) { + $other_dependency_version_with_operator = $this->getVersionOfDependencyRequiredByPackage($dependent_package, $slug); + $other_dependency_version = $this->calculateVersionNumberFromDependencyVersion($other_dependency_version_with_operator); + + // check version is compatible with the one needed by the current package + if ($this->versionFormatIsNextSignificantRelease($other_dependency_version_with_operator)) { + $compatible = $this->checkNextSignificantReleasesAreCompatible($version, $other_dependency_version); + if (!$compatible && !in_array($dependent_package, $ignore_packages_list, true)) { + throw new RuntimeException( + "Package $slug is required in an older version by package $dependent_package. This package needs a newer version, and because of this it cannot be installed. The $dependent_package package must be updated to use a newer release of $slug.", + 2 + ); + } + } + } + } + + return true; + } + + /** + * Check the passed packages list can be updated + * + * @param array $packages_names_list + * @return void + * @throws Exception + */ + public function checkPackagesCanBeInstalled($packages_names_list) + { + foreach ($packages_names_list as $package_name) { + $latest = $this->getLatestVersionOfPackage($package_name); + $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion($package_name, $latest, $packages_names_list); + } + } + + /** + * Fetch the dependencies, check the installed packages and return an array with + * the list of packages with associated an information on what to do: install, update or ignore. + * + * `ignore` means the package is already installed and can be safely left as-is. + * `install` means the package is not installed and must be installed. + * `update` means the package is already installed and must be updated as a dependency needs a higher version. + * + * @param array $packages + * @return array + * @throws RuntimeException + */ + public function getDependencies($packages) + { + $dependencies = $this->calculateMergedDependenciesOfPackages($packages); + foreach ($dependencies as $dependency_slug => $dependencyVersionWithOperator) { + $dependency_slug = (string)$dependency_slug; + if (in_array($dependency_slug, $packages, true)) { + unset($dependencies[$dependency_slug]); + continue; + } + + // Check PHP version + if ($dependency_slug === 'php') { + $testVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator); + if (version_compare($testVersion, PHP_VERSION) === 1) { + //Needs a Grav update first + throw new RuntimeException("One of the packages require PHP {$dependencies['php']}. Please update PHP to resolve this"); + } + + unset($dependencies[$dependency_slug]); + continue; + } + + //First, check for Grav dependency. If a dependency requires Grav > the current version, abort and tell. + if ($dependency_slug === 'grav') { + $testVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator); + if (version_compare($testVersion, GRAV_VERSION) === 1) { + //Needs a Grav update first + throw new RuntimeException("One of the packages require Grav {$dependencies['grav']}. Please update Grav to the latest release."); + } + + unset($dependencies[$dependency_slug]); + continue; + } + + if ($this->isPluginInstalled($dependency_slug)) { + if ($this->isPluginInstalledAsSymlink($dependency_slug)) { + unset($dependencies[$dependency_slug]); + continue; + } + + $dependencyVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator); + + // get currently installed version + $locator = Grav::instance()['locator']; + $blueprints_path = $locator->findResource('plugins://' . $dependency_slug . DS . 'blueprints.yaml'); + $file = YamlFile::instance($blueprints_path); + $package_yaml = $file->content(); + $file->free(); + $currentlyInstalledVersion = $package_yaml['version']; + + // if requirement is next significant release, check is compatible with currently installed version, might not be + if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator) + && $this->firstVersionIsLower($dependencyVersion, $currentlyInstalledVersion)) { + $compatible = $this->checkNextSignificantReleasesAreCompatible($dependencyVersion, $currentlyInstalledVersion); + + if (!$compatible) { + throw new RuntimeException( + 'Dependency ' . $dependency_slug . ' is required in an older version than the one installed. This package must be updated. Please get in touch with its developer.', + 2 + ); + } + } + + //if I already have the latest release, remove the dependency + $latestRelease = $this->getLatestVersionOfPackage($dependency_slug); + + if ($this->firstVersionIsLower($latestRelease, $dependencyVersion)) { + //throw an exception if a required version cannot be found in the GPM yet + throw new RuntimeException( + 'Dependency ' . $package_yaml['name'] . ' is required in version ' . $dependencyVersion . ' which is higher than the latest release, ' . $latestRelease . '. Try running `bin/gpm -f index` to force a refresh of the GPM cache', + 1 + ); + } + + if ($this->firstVersionIsLower($currentlyInstalledVersion, $dependencyVersion)) { + $dependencies[$dependency_slug] = 'update'; + } elseif ($currentlyInstalledVersion === $latestRelease) { + unset($dependencies[$dependency_slug]); + } else { + // an update is not strictly required mark as 'ignore' + $dependencies[$dependency_slug] = 'ignore'; + } + } else { + $dependencyVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator); + + // if requirement is next significant release, check is compatible with latest available version, might not be + if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator)) { + $latestVersionOfPackage = $this->getLatestVersionOfPackage($dependency_slug); + if ($this->firstVersionIsLower($dependencyVersion, $latestVersionOfPackage)) { + $compatible = $this->checkNextSignificantReleasesAreCompatible( + $dependencyVersion, + $latestVersionOfPackage + ); + + if (!$compatible) { + throw new RuntimeException( + 'Dependency ' . $dependency_slug . ' is required in an older version than the latest release available, and it cannot be installed. This package must be updated. Please get in touch with its developer.', + 2 + ); + } + } + } + + $dependencies[$dependency_slug] = 'install'; + } + } + + $dependencies_slugs = array_keys($dependencies); + $this->checkNoOtherPackageNeedsTheseDependenciesInALowerVersion(array_merge($packages, $dependencies_slugs)); + + return $dependencies; + } + + /** + * @param array $dependencies_slugs + * @return void + */ + public function checkNoOtherPackageNeedsTheseDependenciesInALowerVersion($dependencies_slugs) + { + foreach ($dependencies_slugs as $dependency_slug) { + $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion( + $dependency_slug, + $this->getLatestVersionOfPackage($dependency_slug), + $dependencies_slugs + ); + } + } + + /** + * @param string $firstVersion + * @param string $secondVersion + * @return bool + */ + private function firstVersionIsLower($firstVersion, $secondVersion) + { + return version_compare($firstVersion, $secondVersion) === -1; + } + + /** + * Calculates and merges the dependencies of a package + * + * @param string $packageName The package information + * @param array $dependencies The dependencies array + * @return array + */ + private function calculateMergedDependenciesOfPackage($packageName, $dependencies) + { + $packageData = $this->findPackage($packageName); + + if (empty($packageData->dependencies)) { + return $dependencies; + } + + foreach ($packageData->dependencies as $dependency) { + $dependencyName = $dependency['name'] ?? null; + if (!$dependencyName) { + continue; + } + + $dependencyVersion = $dependency['version'] ?? '*'; + + if (!isset($dependencies[$dependencyName])) { + // Dependency added for the first time + $dependencies[$dependencyName] = $dependencyVersion; + + //Factor in the package dependencies too + $dependencies = $this->calculateMergedDependenciesOfPackage($dependencyName, $dependencies); + } elseif ($dependencyVersion !== '*') { + // Dependency already added by another package + // If this package requires a version higher than the currently stored one, store this requirement instead + $currentDependencyVersion = $dependencies[$dependencyName]; + $currently_stored_version_number = $this->calculateVersionNumberFromDependencyVersion($currentDependencyVersion); + + $currently_stored_version_is_in_next_significant_release_format = false; + if ($this->versionFormatIsNextSignificantRelease($currentDependencyVersion)) { + $currently_stored_version_is_in_next_significant_release_format = true; + } + + if (!$currently_stored_version_number) { + $currently_stored_version_number = '*'; + } + + $current_package_version_number = $this->calculateVersionNumberFromDependencyVersion($dependencyVersion); + if (!$current_package_version_number) { + throw new RuntimeException("Bad format for version of dependency {$dependencyName} for package {$packageName}", 1); + } + + $current_package_version_is_in_next_significant_release_format = false; + if ($this->versionFormatIsNextSignificantRelease($dependencyVersion)) { + $current_package_version_is_in_next_significant_release_format = true; + } + + //If I had stored '*', change right away with the more specific version required + if ($currently_stored_version_number === '*') { + $dependencies[$dependencyName] = $dependencyVersion; + } elseif (!$currently_stored_version_is_in_next_significant_release_format && !$current_package_version_is_in_next_significant_release_format) { + //Comparing versions equals or higher, a simple version_compare is enough + if (version_compare($currently_stored_version_number, $current_package_version_number) === -1) { + //Current package version is higher + $dependencies[$dependencyName] = $dependencyVersion; + } + } else { + $compatible = $this->checkNextSignificantReleasesAreCompatible($currently_stored_version_number, $current_package_version_number); + if (!$compatible) { + throw new RuntimeException("Dependency {$dependencyName} is required in two incompatible versions", 2); + } + } + } + } + + return $dependencies; + } + + /** + * Calculates and merges the dependencies of the passed packages + * + * @param array $packages + * @return array + */ + public function calculateMergedDependenciesOfPackages($packages) + { + $dependencies = []; + + foreach ($packages as $package) { + $dependencies = $this->calculateMergedDependenciesOfPackage($package, $dependencies); + } + + return $dependencies; + } + + /** + * Returns the actual version from a dependency version string. + * Examples: + * $versionInformation == '~2.0' => returns '2.0' + * $versionInformation == '>=2.0.2' => returns '2.0.2' + * $versionInformation == '2.0.2' => returns '2.0.2' + * $versionInformation == '*' => returns null + * $versionInformation == '' => returns null + * + * @param string $version + * @return string|null + */ + public function calculateVersionNumberFromDependencyVersion($version) + { + if ($version === '*') { + return null; + } + if ($version === '') { + return null; + } + if ($this->versionFormatIsNextSignificantRelease($version)) { + return trim(substr($version, 1)); + } + if ($this->versionFormatIsEqualOrHigher($version)) { + return trim(substr($version, 2)); + } + + return $version; + } + + /** + * Check if the passed version information contains next significant release (tilde) operator + * + * Example: returns true for $version: '~2.0' + * + * @param string $version + * @return bool + */ + public function versionFormatIsNextSignificantRelease($version): bool + { + return strpos($version, '~') === 0; + } + + /** + * Check if the passed version information contains equal or higher operator + * + * Example: returns true for $version: '>=2.0' + * + * @param string $version + * @return bool + */ + public function versionFormatIsEqualOrHigher($version): bool + { + return strpos($version, '>=') === 0; + } + + /** + * Check if two releases are compatible by next significant release + * + * ~1.2 is equivalent to >=1.2 <2.0.0 + * ~1.2.3 is equivalent to >=1.2.3 <1.3.0 + * + * In short, allows the last digit specified to go up + * + * @param string $version1 the version string (e.g. '2.0.0' or '1.0') + * @param string $version2 the version string (e.g. '2.0.0' or '1.0') + * @return bool + */ + public function checkNextSignificantReleasesAreCompatible($version1, $version2): bool + { + $version1array = explode('.', $version1); + $version2array = explode('.', $version2); + + if (count($version1array) > count($version2array)) { + [$version1array, $version2array] = [$version2array, $version1array]; + } + + $i = 0; + while ($i < count($version1array) - 1) { + if ($version1array[$i] !== $version2array[$i]) { + return false; + } + $i++; + } + + return true; + } +} diff --git a/system/src/Grav/Common/GPM/Installer.php b/system/src/Grav/Common/GPM/Installer.php new file mode 100644 index 0000000..84d6562 --- /dev/null +++ b/system/src/Grav/Common/GPM/Installer.php @@ -0,0 +1,544 @@ + true, + 'ignore_symlinks' => true, + 'sophisticated' => false, + 'theme' => false, + 'install_path' => '', + 'ignores' => [], + 'exclude_checks' => [self::EXISTS, self::NOT_FOUND, self::IS_LINK] + ]; + + /** + * Installs a given package to a given destination. + * + * @param string $zip the local path to ZIP package + * @param string $destination The local path to the Grav Instance + * @param array $options Options to use for installing. ie, ['install_path' => 'user/themes/antimatter'] + * @param string|null $extracted The local path to the extacted ZIP package + * @param bool $keepExtracted True if you want to keep the original files + * @return bool True if everything went fine, False otherwise. + */ + public static function install($zip, $destination, $options = [], $extracted = null, $keepExtracted = false) + { + $destination = rtrim($destination, DS); + $options = array_merge(self::$options, $options); + $install_path = rtrim($destination . DS . ltrim($options['install_path'], DS), DS); + + if (!self::isGravInstance($destination) || !self::isValidDestination( + $install_path, + $options['exclude_checks'] + ) + ) { + return false; + } + + if ((self::lastErrorCode() === self::IS_LINK && $options['ignore_symlinks']) || + (self::lastErrorCode() === self::EXISTS && !$options['overwrite']) + ) { + return false; + } + + // Create a tmp location + $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true); + $tmp = $tmp_dir . '/Grav-' . uniqid('', false); + + if (!$extracted) { + $extracted = self::unZip($zip, $tmp); + if (!$extracted) { + Folder::delete($tmp); + return false; + } + } + + if (!file_exists($extracted)) { + self::$error = self::INVALID_SOURCE; + return false; + } + + $is_install = true; + $installer = self::loadInstaller($extracted, $is_install); + + if (isset($options['is_update']) && $options['is_update'] === true) { + $method = 'preUpdate'; + } else { + $method = 'preInstall'; + } + + if ($installer && method_exists($installer, $method)) { + $method_result = $installer::$method(); + if ($method_result !== true) { + self::$error = 'An error occurred'; + if (is_string($method_result)) { + self::$error = $method_result; + } + + return false; + } + } + + if (!$options['sophisticated']) { + $isTheme = $options['theme'] ?? false; + // Make sure that themes are always being copied, even if option was not set! + $isTheme = $isTheme || preg_match('|/themes/[^/]+|ui', $install_path); + if ($isTheme) { + self::copyInstall($extracted, $install_path); + } else { + self::moveInstall($extracted, $install_path); + } + } else { + self::sophisticatedInstall($extracted, $install_path, $options['ignores'], $keepExtracted); + } + + Folder::delete($tmp); + + if (isset($options['is_update']) && $options['is_update'] === true) { + $method = 'postUpdate'; + } else { + $method = 'postInstall'; + } + + self::$message = ''; + if ($installer && method_exists($installer, $method)) { + self::$message = $installer::$method(); + } + + self::$error = self::OK; + + return true; + } + + /** + * Unzip a file to somewhere + * + * @param string $zip_file + * @param string $destination + * @return string|false + */ + public static function unZip($zip_file, $destination) + { + $zip = new ZipArchive(); + $archive = $zip->open($zip_file); + + if ($archive === true) { + Folder::create($destination); + + $unzip = $zip->extractTo($destination); + + + if (!$unzip) { + self::$error = self::ZIP_EXTRACT_ERROR; + Folder::delete($destination); + $zip->close(); + return false; + } + + $package_folder_name = $zip->getNameIndex(0); + if ($package_folder_name === false) { + throw new \RuntimeException('Bad package file: ' . Utils::basename($zip_file)); + } + $package_folder_name = preg_replace('#\./$#', '', $package_folder_name); + $zip->close(); + + return $destination . '/' . $package_folder_name; + } + + self::$error = self::ZIP_EXTRACT_ERROR; + self::$error_zip = $archive; + + return false; + } + + /** + * Instantiates and returns the package installer class + * + * @param string $installer_file_folder The folder path that contains install.php + * @param bool $is_install True if install, false if removal + * @return string|null + */ + private static function loadInstaller($installer_file_folder, $is_install) + { + $installer_file_folder = rtrim($installer_file_folder, DS); + + $install_file = $installer_file_folder . DS . 'install.php'; + + if (!file_exists($install_file)) { + return null; + } + + require_once $install_file; + + if ($is_install) { + $slug = ''; + if (($pos = strpos($installer_file_folder, 'grav-plugin-')) !== false) { + $slug = substr($installer_file_folder, $pos + strlen('grav-plugin-')); + } elseif (($pos = strpos($installer_file_folder, 'grav-theme-')) !== false) { + $slug = substr($installer_file_folder, $pos + strlen('grav-theme-')); + } + } else { + $path_elements = explode('/', $installer_file_folder); + $slug = end($path_elements); + } + + if (!$slug) { + return null; + } + + $class_name = ucfirst($slug) . 'Install'; + + if (class_exists($class_name)) { + return $class_name; + } + + $class_name_alphanumeric = preg_replace('/[^a-zA-Z0-9]+/', '', $class_name) ?? $class_name; + + if (class_exists($class_name_alphanumeric)) { + return $class_name_alphanumeric; + } + + return null; + } + + /** + * @param string $source_path + * @param string $install_path + * @return bool + */ + public static function moveInstall($source_path, $install_path) + { + if (file_exists($install_path)) { + Folder::delete($install_path); + } + + Folder::move($source_path, $install_path); + + return true; + } + + /** + * @param string $source_path + * @param string $install_path + * @return bool + */ + public static function copyInstall($source_path, $install_path) + { + if (empty($source_path)) { + throw new RuntimeException("Directory $source_path is missing"); + } + + Folder::rcopy($source_path, $install_path); + + return true; + } + + /** + * @param string $source_path + * @param string $install_path + * @param array $ignores + * @param bool $keep_source + * @return bool + */ + public static function sophisticatedInstall($source_path, $install_path, $ignores = [], $keep_source = false) + { + foreach (new DirectoryIterator($source_path) as $file) { + if ($file->isLink() || $file->isDot() || in_array($file->getFilename(), $ignores, true)) { + continue; + } + + $path = $install_path . DS . $file->getFilename(); + + if ($file->isDir()) { + Folder::delete($path); + if ($keep_source) { + Folder::copy($file->getPathname(), $path); + } else { + Folder::move($file->getPathname(), $path); + } + + if ($file->getFilename() === 'bin') { + $glob = glob($path . DS . '*') ?: []; + foreach ($glob as $bin_file) { + @chmod($bin_file, 0755); + } + } + } else { + @unlink($path); + @copy($file->getPathname(), $path); + } + } + + return true; + } + + /** + * Uninstalls one or more given package + * + * @param string $path The slug of the package(s) + * @param array $options Options to use for uninstalling + * @return bool True if everything went fine, False otherwise. + */ + public static function uninstall($path, $options = []) + { + $options = array_merge(self::$options, $options); + if (!self::isValidDestination($path, $options['exclude_checks']) + ) { + return false; + } + + $installer_file_folder = $path; + $is_install = false; + $installer = self::loadInstaller($installer_file_folder, $is_install); + + if ($installer && method_exists($installer, 'preUninstall')) { + $method_result = $installer::preUninstall(); + if ($method_result !== true) { + self::$error = 'An error occurred'; + if (is_string($method_result)) { + self::$error = $method_result; + } + + return false; + } + } + + $result = Folder::delete($path); + + self::$message = ''; + if ($result && $installer && method_exists($installer, 'postUninstall')) { + self::$message = $installer::postUninstall(); + } + + return $result; + } + + /** + * Runs a set of checks on the destination and sets the Error if any + * + * @param string $destination The directory to run validations at + * @param array $exclude An array of constants to exclude from the validation + * @return bool True if validation passed. False otherwise + */ + public static function isValidDestination($destination, $exclude = []) + { + self::$error = 0; + self::$target = $destination; + + if (is_link($destination)) { + self::$error = self::IS_LINK; + } elseif (file_exists($destination)) { + self::$error = self::EXISTS; + } elseif (!file_exists($destination)) { + self::$error = self::NOT_FOUND; + } elseif (!is_dir($destination)) { + self::$error = self::NOT_DIRECTORY; + } + + if (count($exclude) && in_array(self::$error, $exclude, true)) { + return true; + } + + return !self::$error; + } + + /** + * Validates if the given path is a Grav Instance + * + * @param string $target The local path to the Grav Instance + * @return bool True if is a Grav Instance. False otherwise + */ + public static function isGravInstance($target) + { + self::$error = 0; + self::$target = $target; + + if (!file_exists($target . DS . 'index.php') || + !file_exists($target . DS . 'bin') || + !file_exists($target . DS . 'user') || + !file_exists($target . DS . 'system' . DS . 'config' . DS . 'system.yaml') + ) { + self::$error = self::NOT_GRAV_ROOT; + } + + return !self::$error; + } + + /** + * Returns the last message added by the installer + * + * @return string The message + */ + public static function getMessage() + { + return self::$message; + } + + /** + * Returns the last error occurred in a string message format + * + * @return string The message of the last error + */ + public static function lastErrorMsg() + { + if (is_string(self::$error)) { + return self::$error; + } + + switch (self::$error) { + case 0: + $msg = 'No Error'; + break; + + case self::EXISTS: + $msg = 'The target path "' . self::$target . '" already exists'; + break; + + case self::IS_LINK: + $msg = 'The target path "' . self::$target . '" is a symbolic link'; + break; + + case self::NOT_FOUND: + $msg = 'The target path "' . self::$target . '" does not appear to exist'; + break; + + case self::NOT_DIRECTORY: + $msg = 'The target path "' . self::$target . '" does not appear to be a folder'; + break; + + case self::NOT_GRAV_ROOT: + $msg = 'The target path "' . self::$target . '" does not appear to be a Grav instance'; + break; + + case self::ZIP_OPEN_ERROR: + $msg = 'Unable to open the package file'; + break; + + case self::ZIP_EXTRACT_ERROR: + $msg = 'Unable to extract the package. '; + if (self::$error_zip) { + switch (self::$error_zip) { + case ZipArchive::ER_EXISTS: + $msg .= 'File already exists.'; + break; + + case ZipArchive::ER_INCONS: + $msg .= 'Zip archive inconsistent.'; + break; + + case ZipArchive::ER_MEMORY: + $msg .= 'Memory allocation failure.'; + break; + + case ZipArchive::ER_NOENT: + $msg .= 'No such file.'; + break; + + case ZipArchive::ER_NOZIP: + $msg .= 'Not a zip archive.'; + break; + + case ZipArchive::ER_OPEN: + $msg .= "Can't open file."; + break; + + case ZipArchive::ER_READ: + $msg .= 'Read error.'; + break; + + case ZipArchive::ER_SEEK: + $msg .= 'Seek error.'; + break; + } + } + break; + + case self::INVALID_SOURCE: + $msg = 'Invalid source file'; + break; + + default: + $msg = 'Unknown Error'; + break; + } + + return $msg; + } + + /** + * Returns the last error code of the occurred error + * + * @return int|string The code of the last error + */ + public static function lastErrorCode() + { + return self::$error; + } + + /** + * Allows to manually set an error + * + * @param int|string $error the Error code + * @return void + */ + public static function setError($error) + { + self::$error = $error; + } +} diff --git a/system/src/Grav/Common/GPM/Licenses.php b/system/src/Grav/Common/GPM/Licenses.php new file mode 100644 index 0000000..37ec69d --- /dev/null +++ b/system/src/Grav/Common/GPM/Licenses.php @@ -0,0 +1,116 @@ +content(); + $slug = strtolower($slug); + + if ($license && !self::validate($license)) { + return false; + } + + if (!is_string($license)) { + if (isset($data['licenses'][$slug])) { + unset($data['licenses'][$slug]); + } else { + return false; + } + } else { + $data['licenses'][$slug] = $license; + } + + $licenses->save($data); + $licenses->free(); + + return true; + } + + /** + * Returns the license for a Premium package + * + * @param string|null $slug + * @return string[]|string + */ + public static function get($slug = null) + { + $licenses = self::getLicenseFile(); + $data = (array)$licenses->content(); + $licenses->free(); + + if (null === $slug) { + return $data['licenses'] ?? []; + } + + $slug = strtolower($slug); + + return $data['licenses'][$slug] ?? ''; + } + + + /** + * Validates the License format + * + * @param string|null $license + * @return bool + */ + public static function validate($license = null) + { + if (!is_string($license)) { + return false; + } + + return (bool)preg_match('#' . self::$regex. '#', $license); + } + + /** + * Get the License File object + * + * @return FileInterface + */ + public static function getLicenseFile() + { + if (!isset(self::$file)) { + $path = Grav::instance()['locator']->findResource('user-data://') . '/licenses.yaml'; + if (!file_exists($path)) { + touch($path); + } + self::$file = CompiledYamlFile::instance($path); + } + + return self::$file; + } +} diff --git a/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php b/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php new file mode 100644 index 0000000..bcc5697 --- /dev/null +++ b/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php @@ -0,0 +1,34 @@ + $data) { + $data->set('slug', $name); + $this->items[$name] = new Package($data, $this->type); + } + } +} diff --git a/system/src/Grav/Common/GPM/Local/Package.php b/system/src/Grav/Common/GPM/Local/Package.php new file mode 100644 index 0000000..0caf2ad --- /dev/null +++ b/system/src/Grav/Common/GPM/Local/Package.php @@ -0,0 +1,51 @@ +blueprints()->toArray()); + parent::__construct($data, $package_type); + + $this->settings = $package->toArray(); + + $html_description = Parsedown::instance()->line($this->__get('description')); + $this->data->set('slug', $package->__get('slug')); + $this->data->set('description_html', $html_description); + $this->data->set('description_plain', strip_tags($html_description)); + $this->data->set('symlink', is_link(USER_DIR . $package_type . DS . $this->__get('slug'))); + } + + /** + * @return bool + */ + public function isEnabled() + { + return (bool)$this->settings['enabled']; + } +} diff --git a/system/src/Grav/Common/GPM/Local/Packages.php b/system/src/Grav/Common/GPM/Local/Packages.php new file mode 100644 index 0000000..9f73e1e --- /dev/null +++ b/system/src/Grav/Common/GPM/Local/Packages.php @@ -0,0 +1,29 @@ + new Plugins(), + 'themes' => new Themes() + ]; + + parent::__construct($items); + } +} diff --git a/system/src/Grav/Common/GPM/Local/Plugins.php b/system/src/Grav/Common/GPM/Local/Plugins.php new file mode 100644 index 0000000..dabbe47 --- /dev/null +++ b/system/src/Grav/Common/GPM/Local/Plugins.php @@ -0,0 +1,33 @@ +all()); + } +} diff --git a/system/src/Grav/Common/GPM/Local/Themes.php b/system/src/Grav/Common/GPM/Local/Themes.php new file mode 100644 index 0000000..31ec3a4 --- /dev/null +++ b/system/src/Grav/Common/GPM/Local/Themes.php @@ -0,0 +1,33 @@ +all()); + } +} diff --git a/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php b/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php new file mode 100644 index 0000000..2fbf82a --- /dev/null +++ b/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php @@ -0,0 +1,81 @@ +get('system.gpm.releases', 'stable'); + $cache_dir = Grav::instance()['locator']->findResource('cache://gpm', true, true); + $this->cache = new FilesystemCache($cache_dir); + + $this->repository = $repository . '?v=' . GRAV_VERSION . '&' . $channel . '=1'; + $this->raw = $this->cache->fetch(md5($this->repository)); + + $this->fetch($refresh, $callback); + foreach (json_decode($this->raw, true) as $slug => $data) { + // Temporarily fix for using multi-sites + if (isset($data['install_path'])) { + $path = preg_replace('~^user/~i', 'user://', $data['install_path']); + $data['install_path'] = Grav::instance()['locator']->findResource($path, false, true); + } + $this->items[$slug] = new Package($data, $this->type); + } + } + + /** + * @param bool $refresh + * @param callable|null $callback + * @return string + */ + public function fetch($refresh = false, $callback = null) + { + if (!$this->raw || $refresh) { + $response = Response::get($this->repository, [], $callback); + $this->raw = $response; + $this->cache->save(md5($this->repository), $this->raw, $this->lifetime); + } + + return $this->raw; + } +} diff --git a/system/src/Grav/Common/GPM/Remote/GravCore.php b/system/src/Grav/Common/GPM/Remote/GravCore.php new file mode 100644 index 0000000..4700e7d --- /dev/null +++ b/system/src/Grav/Common/GPM/Remote/GravCore.php @@ -0,0 +1,151 @@ +get('system.gpm.releases', 'stable'); + $cache_dir = Grav::instance()['locator']->findResource('cache://gpm', true, true); + $this->cache = new FilesystemCache($cache_dir); + $this->repository .= '?v=' . GRAV_VERSION . '&' . $channel . '=1'; + $this->raw = $this->cache->fetch(md5($this->repository)); + + $this->fetch($refresh, $callback); + + $this->data = json_decode($this->raw, true); + $this->version = $this->data['version'] ?? '-'; + $this->date = $this->data['date'] ?? '-'; + $this->min_php = $this->data['min_php'] ?? null; + + if (isset($this->data['assets'])) { + foreach ((array)$this->data['assets'] as $slug => $data) { + $this->items[$slug] = new Package($data); + } + } + } + + /** + * Returns the list of assets associated to the latest version of Grav + * + * @return array list of assets + */ + public function getAssets() + { + return $this->data['assets']; + } + + /** + * Returns the changelog list for each version of Grav + * + * @param string|null $diff the version number to start the diff from + * @return array changelog list for each version + */ + public function getChangelog($diff = null) + { + if (!$diff) { + return $this->data['changelog']; + } + + $diffLog = []; + foreach ((array)$this->data['changelog'] as $version => $changelog) { + preg_match("/[\w\-\.]+/", $version, $cleanVersion); + + if (!$cleanVersion || version_compare($diff, $cleanVersion[0], '>=')) { + continue; + } + + $diffLog[$version] = $changelog; + } + + return $diffLog; + } + + /** + * Return the release date of the latest Grav + * + * @return string + */ + public function getDate() + { + return $this->date; + } + + /** + * Determine if this version of Grav is eligible to be updated + * + * @return mixed + */ + public function isUpdatable() + { + return version_compare(GRAV_VERSION, $this->getVersion(), '<'); + } + + /** + * Returns the latest version of Grav available remotely + * + * @return string + */ + public function getVersion() + { + return $this->version; + } + + /** + * Returns the minimum PHP version + * + * @return string + */ + public function getMinPHPVersion() + { + // If non min set, assume current PHP version + if (null === $this->min_php) { + $this->min_php = PHP_VERSION; + } + + return $this->min_php; + } + + /** + * Is this installation symlinked? + * + * @return bool + */ + public function isSymlink() + { + return is_link(GRAV_ROOT . DS . 'index.php'); + } +} diff --git a/system/src/Grav/Common/GPM/Remote/Package.php b/system/src/Grav/Common/GPM/Remote/Package.php new file mode 100644 index 0000000..41db50e --- /dev/null +++ b/system/src/Grav/Common/GPM/Remote/Package.php @@ -0,0 +1,66 @@ +data->toArray(); + } + + /** + * Returns the changelog list for each version of a package + * + * @param string|null $diff the version number to start the diff from + * @return array changelog list for each version + */ + public function getChangelog($diff = null) + { + if (!$diff) { + return $this->data['changelog']; + } + + $diffLog = []; + foreach ((array)$this->data['changelog'] as $version => $changelog) { + preg_match("/[\w\-.]+/", $version, $cleanVersion); + + if (!$cleanVersion || version_compare($diff, $cleanVersion[0], '>=')) { + continue; + } + + $diffLog[$version] = $changelog; + } + + return $diffLog; + } +} diff --git a/system/src/Grav/Common/GPM/Remote/Packages.php b/system/src/Grav/Common/GPM/Remote/Packages.php new file mode 100644 index 0000000..714bad2 --- /dev/null +++ b/system/src/Grav/Common/GPM/Remote/Packages.php @@ -0,0 +1,34 @@ + new Plugins($refresh, $callback), + 'themes' => new Themes($refresh, $callback) + ]; + + parent::__construct($items); + } +} diff --git a/system/src/Grav/Common/GPM/Remote/Plugins.php b/system/src/Grav/Common/GPM/Remote/Plugins.php new file mode 100644 index 0000000..3495566 --- /dev/null +++ b/system/src/Grav/Common/GPM/Remote/Plugins.php @@ -0,0 +1,32 @@ +repository, $refresh, $callback); + } +} diff --git a/system/src/Grav/Common/GPM/Remote/Themes.php b/system/src/Grav/Common/GPM/Remote/Themes.php new file mode 100644 index 0000000..09e3f8a --- /dev/null +++ b/system/src/Grav/Common/GPM/Remote/Themes.php @@ -0,0 +1,32 @@ +repository, $refresh, $callback); + } +} diff --git a/system/src/Grav/Common/GPM/Response.php b/system/src/Grav/Common/GPM/Response.php new file mode 100644 index 0000000..98654b6 --- /dev/null +++ b/system/src/Grav/Common/GPM/Response.php @@ -0,0 +1,3 @@ +remote = new Remote\GravCore($refresh, $callback); + } + + /** + * Returns the release date of the latest version of Grav + * + * @return string + */ + public function getReleaseDate() + { + return $this->remote->getDate(); + } + + /** + * Returns the version of the installed Grav + * + * @return string + */ + public function getLocalVersion() + { + return GRAV_VERSION; + } + + /** + * Returns the version of the remotely available Grav + * + * @return string + */ + public function getRemoteVersion() + { + return $this->remote->getVersion(); + } + + /** + * Returns an array of assets available to download remotely + * + * @return array + */ + public function getAssets() + { + return $this->remote->getAssets(); + } + + /** + * Returns the changelog list for each version of Grav + * + * @param string|null $diff the version number to start the diff from + * @return array return the changelog list for each version + */ + public function getChangelog($diff = null) + { + return $this->remote->getChangelog($diff); + } + + /** + * Make sure this meets minimum PHP requirements + * + * @return bool + */ + public function meetsRequirements() + { + if (version_compare(PHP_VERSION, $this->minPHPVersion(), '<')) { + return false; + } + + return true; + } + + /** + * Get minimum PHP version from remote + * + * @return string + */ + public function minPHPVersion() + { + if (null === $this->min_php) { + $this->min_php = $this->remote->getMinPHPVersion(); + } + + return $this->min_php; + } + + /** + * Checks if the currently installed Grav is upgradable to a newer version + * + * @return bool True if it's upgradable, False otherwise. + */ + public function isUpgradable() + { + return version_compare($this->getLocalVersion(), $this->getRemoteVersion(), '<'); + } + + /** + * Checks if Grav is currently symbolically linked + * + * @return bool True if Grav is symlinked, False otherwise. + */ + public function isSymlink() + { + return $this->remote->isSymlink(); + } +} diff --git a/system/src/Grav/Common/Getters.php b/system/src/Grav/Common/Getters.php new file mode 100644 index 0000000..d16dba5 --- /dev/null +++ b/system/src/Grav/Common/Getters.php @@ -0,0 +1,170 @@ +offsetSet($offset, $value); + } + + /** + * Magic getter method + * + * @param int|string $offset Medium name value + * @return mixed Medium value + */ + #[\ReturnTypeWillChange] + public function __get($offset) + { + return $this->offsetGet($offset); + } + + /** + * Magic method to determine if the attribute is set + * + * @param int|string $offset Medium name value + * @return boolean True if the value is set + */ + #[\ReturnTypeWillChange] + public function __isset($offset) + { + return $this->offsetExists($offset); + } + + /** + * Magic method to unset the attribute + * + * @param int|string $offset The name value to unset + */ + #[\ReturnTypeWillChange] + public function __unset($offset) + { + $this->offsetUnset($offset); + } + + /** + * @param int|string $offset + * @return bool + */ + #[\ReturnTypeWillChange] + public function offsetExists($offset) + { + if ($this->gettersVariable) { + $var = $this->gettersVariable; + + return isset($this->{$var}[$offset]); + } + + return isset($this->{$offset}); + } + + /** + * @param int|string $offset + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + if ($this->gettersVariable) { + $var = $this->gettersVariable; + + return $this->{$var}[$offset] ?? null; + } + + return $this->{$offset} ?? null; + } + + /** + * @param int|string $offset + * @param mixed $value + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + if ($this->gettersVariable) { + $var = $this->gettersVariable; + $this->{$var}[$offset] = $value; + } else { + $this->{$offset} = $value; + } + } + + /** + * @param int|string $offset + */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset) + { + if ($this->gettersVariable) { + $var = $this->gettersVariable; + unset($this->{$var}[$offset]); + } else { + unset($this->{$offset}); + } + } + + /** + * @return int + */ + #[\ReturnTypeWillChange] + public function count() + { + if ($this->gettersVariable) { + $var = $this->gettersVariable; + return count($this->{$var}); + } + + return count($this->toArray()); + } + + /** + * Returns an associative array of object properties. + * + * @return array + */ + public function toArray() + { + if ($this->gettersVariable) { + $var = $this->gettersVariable; + + return $this->{$var}; + } + + $properties = (array)$this; + $list = []; + foreach ($properties as $property => $value) { + if ($property[0] !== "\0") { + $list[$property] = $value; + } + } + + return $list; + } +} diff --git a/system/src/Grav/Common/Grav.php b/system/src/Grav/Common/Grav.php new file mode 100644 index 0000000..892f1d0 --- /dev/null +++ b/system/src/Grav/Common/Grav.php @@ -0,0 +1,829 @@ + Browser::class, + 'cache' => Cache::class, + 'events' => EventDispatcher::class, + 'exif' => Exif::class, + 'plugins' => Plugins::class, + 'scheduler' => Scheduler::class, + 'taxonomy' => Taxonomy::class, + 'themes' => Themes::class, + 'twig' => Twig::class, + 'uri' => Uri::class, + ]; + + /** + * @var array All middleware processors that are processed in $this->process() + */ + protected $middleware = [ + 'multipartRequestSupport', + 'initializeProcessor', + 'pluginsProcessor', + 'themesProcessor', + 'requestProcessor', + 'tasksProcessor', + 'backupsProcessor', + 'schedulerProcessor', + 'assetsProcessor', + 'twigProcessor', + 'pagesProcessor', + 'debuggerAssetsProcessor', + 'renderProcessor', + ]; + + /** @var array */ + protected $initialized = []; + + /** + * Reset the Grav instance. + * + * @return void + */ + public static function resetInstance(): void + { + if (self::$instance) { + // @phpstan-ignore-next-line + self::$instance = null; + } + } + + /** + * Return the Grav instance. Create it if it's not already instanced + * + * @param array $values + * @return Grav + */ + public static function instance(array $values = []) + { + if (null === self::$instance) { + self::$instance = static::load($values); + + /** @var ClassLoader|null $loader */ + $loader = self::$instance['loader'] ?? null; + if ($loader) { + // Load fix for Deferred Twig Extension + $loader->addPsr4('Phive\\Twig\\Extensions\\Deferred\\', LIB_DIR . 'Phive/Twig/Extensions/Deferred/', true); + } + } elseif ($values) { + $instance = self::$instance; + foreach ($values as $key => $value) { + $instance->offsetSet($key, $value); + } + } + + return self::$instance; + } + + /** + * Get Grav version. + * + * @return string + */ + public function getVersion(): string + { + return GRAV_VERSION; + } + + /** + * @return bool + */ + public function isSetup(): bool + { + return isset($this->initialized['setup']); + } + + /** + * Setup Grav instance using specific environment. + * + * @param string|null $environment + * @return $this + */ + public function setup(string $environment = null) + { + if (isset($this->initialized['setup'])) { + return $this; + } + + $this->initialized['setup'] = true; + + // Force environment if passed to the method. + if ($environment) { + Setup::$environment = $environment; + } + + // Initialize setup and streams. + $this['setup']; + $this['streams']; + + return $this; + } + + /** + * Initialize CLI environment. + * + * Call after `$grav->setup($environment)` + * + * - Load configuration + * - Initialize logger + * - Disable debugger + * - Set timezone, locale + * - Load plugins (call PluginsLoadedEvent) + * - Set Pages and Users type to be used in the site + * + * This method WILL NOT initialize assets, twig or pages. + * + * @return $this + */ + public function initializeCli() + { + InitializeProcessor::initializeCli($this); + + return $this; + } + + /** + * Process a request + * + * @return void + */ + public function process(): void + { + if (isset($this->initialized['process'])) { + return; + } + + // Initialize Grav if needed. + $this->setup(); + + $this->initialized['process'] = true; + + $container = new Container( + [ + 'multipartRequestSupport' => function () { + return new MultipartRequestSupport(); + }, + 'initializeProcessor' => function () { + return new InitializeProcessor($this); + }, + 'backupsProcessor' => function () { + return new BackupsProcessor($this); + }, + 'pluginsProcessor' => function () { + return new PluginsProcessor($this); + }, + 'themesProcessor' => function () { + return new ThemesProcessor($this); + }, + 'schedulerProcessor' => function () { + return new SchedulerProcessor($this); + }, + 'requestProcessor' => function () { + return new RequestProcessor($this); + }, + 'tasksProcessor' => function () { + return new TasksProcessor($this); + }, + 'assetsProcessor' => function () { + return new AssetsProcessor($this); + }, + 'twigProcessor' => function () { + return new TwigProcessor($this); + }, + 'pagesProcessor' => function () { + return new PagesProcessor($this); + }, + 'debuggerAssetsProcessor' => function () { + return new DebuggerAssetsProcessor($this); + }, + 'renderProcessor' => function () { + return new RenderProcessor($this); + }, + ] + ); + + $default = static function () { + return new Response(404, ['Expires' => 0, 'Cache-Control' => 'no-store, max-age=0'], 'Not Found'); + }; + + $collection = new RequestHandler($this->middleware, $default, $container); + + $response = $collection->handle($this['request']); + $body = $response->getBody(); + + /** @var Messages $messages */ + $messages = $this['messages']; + + // Prevent caching if session messages were displayed in the page. + $noCache = $messages->isCleared(); + if ($noCache) { + $response = $response->withHeader('Cache-Control', 'no-store, max-age=0'); + } + + // Handle ETag and If-None-Match headers. + if ($response->getHeaderLine('ETag') === '1') { + $etag = md5($body); + $response = $response->withHeader('ETag', '"' . $etag . '"'); + + $search = trim($this['request']->getHeaderLine('If-None-Match'), '"'); + if ($noCache === false && $search === $etag) { + $response = $response->withStatus(304); + $body = ''; + } + } + + // Echo page content. + $this->header($response); + echo $body; + + $this['debugger']->render(); + + // Response object can turn off all shutdown processing. This can be used for example to speed up AJAX responses. + // Note that using this feature will also turn off response compression. + if ($response->getHeaderLine('Grav-Internal-SkipShutdown') !== '1') { + register_shutdown_function([$this, 'shutdown']); + } + } + + /** + * Clean any output buffers. Useful when exiting from the application. + * + * Please use $grav->close() and $grav->redirect() instead of calling this one! + * + * @return void + */ + public function cleanOutputBuffers(): void + { + // Make sure nothing extra gets written to the response. + while (ob_get_level()) { + ob_end_clean(); + } + // Work around PHP bug #8218 (8.0.17 & 8.1.4). + header_remove('Content-Encoding'); + } + + /** + * Terminates Grav request with a response. + * + * Please use this method instead of calling `die();` or `exit();`. Note that you need to create a response object. + * + * @param ResponseInterface $response + * @return never-return + */ + public function close(ResponseInterface $response): void + { + $this->cleanOutputBuffers(); + + // Close the session. + if (isset($this['session'])) { + $this['session']->close(); + } + + /** @var ServerRequestInterface $request */ + $request = $this['request']; + + /** @var Debugger $debugger */ + $debugger = $this['debugger']; + $response = $debugger->logRequest($request, $response); + + $body = $response->getBody(); + + /** @var Messages $messages */ + $messages = $this['messages']; + + // Prevent caching if session messages were displayed in the page. + $noCache = $messages->isCleared(); + if ($noCache) { + $response = $response->withHeader('Cache-Control', 'no-store, max-age=0'); + } + + // Handle ETag and If-None-Match headers. + if ($response->getHeaderLine('ETag') === '1') { + $etag = md5($body); + $response = $response->withHeader('ETag', '"' . $etag . '"'); + + $search = trim($this['request']->getHeaderLine('If-None-Match'), '"'); + if ($noCache === false && $search === $etag) { + $response = $response->withStatus(304); + $body = ''; + } + } + + // Echo page content. + $this->header($response); + echo $body; + exit(); + } + + /** + * @param ResponseInterface $response + * @return never-return + * @deprecated 1.7 Use $grav->close() instead. + */ + public function exit(ResponseInterface $response): void + { + $this->close($response); + } + + /** + * Terminates Grav request and redirects browser to another location. + * + * Please use this method instead of calling `header("Location: {$url}", true, 302); exit();`. + * + * @param Route|string $route Internal route. + * @param int|null $code Redirection code (30x) + * @return never-return + */ + public function redirect($route, $code = null): void + { + $response = $this->getRedirectResponse($route, $code); + + $this->close($response); + } + + /** + * Returns redirect response object from Grav. + * + * @param Route|string $route Internal route. + * @param int|null $code Redirection code (30x) + * @return ResponseInterface + */ + public function getRedirectResponse($route, $code = null): ResponseInterface + { + /** @var Uri $uri */ + $uri = $this['uri']; + + if (is_string($route)) { + // Clean route for redirect + $route = preg_replace("#^\/[\\\/]+\/#", '/', $route); + + if (null === $code) { + // Check for redirect code in the route: e.g. /new/[301], /new[301]/route or /new[301].html + $regex = '/.*(\[(30[1-7])\])(.\w+|\/.*?)?$/'; + preg_match($regex, $route, $matches); + if ($matches) { + $route = str_replace($matches[1], '', $matches[0]); + $code = $matches[2]; + } + } + + if ($uri::isExternal($route)) { + $url = $route; + } else { + $url = rtrim($uri->rootUrl(), '/') . '/'; + + if ($this['config']->get('system.pages.redirect_trailing_slash', true)) { + $url .= trim($route, '/'); // Remove trailing slash + } else { + $url .= ltrim($route, '/'); // Support trailing slash default routes + } + } + } elseif ($route instanceof Route) { + $url = $route->toString(true); + } else { + throw new InvalidArgumentException('Bad $route'); + } + + if ($code < 300 || $code > 399) { + $code = null; + } + + if ($code === null) { + $code = $this['config']->get('system.pages.redirect_default_code', 302); + } + + if ($uri->extension() === 'json') { + return new Response(200, ['Content-Type' => 'application/json'], json_encode(['code' => $code, 'redirect' => $url], JSON_THROW_ON_ERROR)); + } + + return new Response($code, ['Location' => $url]); + } + + /** + * Redirect browser to another location taking language into account (preferred) + * + * @param string $route Internal route. + * @param int $code Redirection code (30x) + * @return void + */ + public function redirectLangSafe($route, $code = null): void + { + if (!$this['uri']->isExternal($route)) { + $this->redirect($this['pages']->route($route), $code); + } else { + $this->redirect($route, $code); + } + } + + /** + * Set response header. + * + * @param ResponseInterface|null $response + * @return void + */ + public function header(ResponseInterface $response = null): void + { + if (null === $response) { + /** @var PageInterface $page */ + $page = $this['page']; + $response = new Response($page->httpResponseCode(), $page->httpHeaders(), ''); + } + + header("HTTP/{$response->getProtocolVersion()} {$response->getStatusCode()} {$response->getReasonPhrase()}"); + foreach ($response->getHeaders() as $key => $values) { + // Skip internal Grav headers. + if (strpos($key, 'Grav-Internal-') === 0) { + continue; + } + foreach ($values as $i => $value) { + header($key . ': ' . $value, $i === 0); + } + } + } + + /** + * Set the system locale based on the language and configuration + * + * @return void + */ + public function setLocale(): void + { + // Initialize Locale if set and configured. + if ($this['language']->enabled() && $this['config']->get('system.languages.override_locale')) { + $language = $this['language']->getLanguage(); + setlocale(LC_ALL, strlen($language) < 3 ? ($language . '_' . strtoupper($language)) : $language); + } elseif ($this['config']->get('system.default_locale')) { + setlocale(LC_ALL, $this['config']->get('system.default_locale')); + } + } + + /** + * @param object $event + * @return object + */ + public function dispatchEvent($event) + { + /** @var EventDispatcherInterface $events */ + $events = $this['events']; + $eventName = get_class($event); + + $timestamp = microtime(true); + $event = $events->dispatch($event); + + /** @var Debugger $debugger */ + $debugger = $this['debugger']; + $debugger->addEvent($eventName, $event, $events, $timestamp); + + return $event; + } + + /** + * Fires an event with optional parameters. + * + * @param string $eventName + * @param Event|null $event + * @return Event + */ + public function fireEvent($eventName, Event $event = null) + { + /** @var EventDispatcherInterface $events */ + $events = $this['events']; + if (null === $event) { + $event = new Event(); + } + + $timestamp = microtime(true); + $events->dispatch($event, $eventName); + + /** @var Debugger $debugger */ + $debugger = $this['debugger']; + $debugger->addEvent($eventName, $event, $events, $timestamp); + + return $event; + } + + /** + * Set the final content length for the page and flush the buffer + * + * @return void + */ + public function shutdown(): void + { + // Prevent user abort allowing onShutdown event to run without interruptions. + if (function_exists('ignore_user_abort')) { + @ignore_user_abort(true); + } + + // Close the session allowing new requests to be handled. + if (isset($this['session'])) { + $this['session']->close(); + } + + /** @var Config $config */ + $config = $this['config']; + if ($config->get('system.debugger.shutdown.close_connection', true)) { + // Flush the response and close the connection to allow time consuming tasks to be performed without leaving + // the connection to the client open. This will make page loads to feel much faster. + + // FastCGI allows us to flush all response data to the client and finish the request. + $success = function_exists('fastcgi_finish_request') ? @fastcgi_finish_request() : false; + if (!$success) { + // Unfortunately without FastCGI there is no way to force close the connection. + // We need to ask browser to close the connection for us. + + if ($config->get('system.cache.gzip')) { + // Flush gzhandler buffer if gzip setting was enabled to get the size of the compressed output. + ob_end_flush(); + } elseif ($config->get('system.cache.allow_webserver_gzip')) { + // Let web server to do the hard work. + header('Content-Encoding: identity'); + } elseif (function_exists('apache_setenv')) { + // Without gzip we have no other choice than to prevent server from compressing the output. + // This action turns off mod_deflate which would prevent us from closing the connection. + @apache_setenv('no-gzip', '1'); + } else { + // Fall back to unknown content encoding, it prevents most servers from deflating the content. + header('Content-Encoding: none'); + } + + // Get length and close the connection. + header('Content-Length: ' . ob_get_length()); + header('Connection: close'); + + ob_end_flush(); + @ob_flush(); + flush(); + } + } + + // Run any time consuming tasks. + $this->fireEvent('onShutdown'); + } + + /** + * Magic Catch All Function + * + * Used to call closures. + * + * Source: http://stackoverflow.com/questions/419804/closures-as-class-members + * + * @param string $method + * @param array $args + * @return mixed|null + */ + #[\ReturnTypeWillChange] + public function __call($method, $args) + { + $closure = $this->{$method} ?? null; + + return is_callable($closure) ? $closure(...$args) : null; + } + + /** + * Measure how long it takes to do an action. + * + * @param string $timerId + * @param string $timerTitle + * @param callable $callback + * @return mixed Returns value returned by the callable. + */ + public function measureTime(string $timerId, string $timerTitle, callable $callback) + { + $debugger = $this['debugger']; + $debugger->startTimer($timerId, $timerTitle); + $result = $callback(); + $debugger->stopTimer($timerId); + + return $result; + } + + /** + * Initialize and return a Grav instance + * + * @param array $values + * @return static + */ + protected static function load(array $values) + { + $container = new static($values); + + $container['debugger'] = new Debugger(); + $container['grav'] = function (Container $container) { + user_error('Calling $grav[\'grav\'] or {{ grav.grav }} is deprecated since Grav 1.6, just use $grav or {{ grav }}', E_USER_DEPRECATED); + + return $container; + }; + + $container->registerServices(); + + return $container; + } + + /** + * Register all services + * Services are defined in the diMap. They can either only the class + * of a Service Provider or a pair of serviceKey => serviceClass that + * gets directly mapped into the container. + * + * @return void + */ + protected function registerServices(): void + { + foreach (self::$diMap as $serviceKey => $serviceClass) { + if (is_int($serviceKey)) { + $this->register(new $serviceClass); + } else { + $this[$serviceKey] = function ($c) use ($serviceClass) { + return new $serviceClass($c); + }; + } + } + } + + /** + * This attempts to find media, other files, and download them + * + * @param string $path + * @return PageInterface|false + */ + public function fallbackUrl($path) + { + $path_parts = Utils::pathinfo($path); + if (!is_array($path_parts)) { + return false; + } + + /** @var Uri $uri */ + $uri = $this['uri']; + + /** @var Config $config */ + $config = $this['config']; + + /** @var Pages $pages */ + $pages = $this['pages']; + $page = $pages->find($path_parts['dirname'], true); + + $uri_extension = strtolower($uri->extension() ?? ''); + $fallback_types = $config->get('system.media.allowed_fallback_types'); + $supported_types = $config->get('media.types'); + + $parsed_url = parse_url(rawurldecode($uri->basename())); + $media_file = $parsed_url['path']; + + $event = new Event([ + 'uri' => $uri, + 'page' => &$page, + 'filename' => &$media_file, + 'extension' => $uri_extension, + 'allowed_fallback_types' => &$fallback_types, + 'media_types' => &$supported_types + ]); + + $this->fireEvent('onPageFallBackUrl', $event); + + // Check whitelist first, then ensure extension is a valid media type + if (!empty($fallback_types) && !in_array($uri_extension, $fallback_types, true)) { + return false; + } + if (!array_key_exists($uri_extension, $supported_types)) { + return false; + } + + if ($page) { + $media = $page->media()->all(); + + // if this is a media object, try actions first + if (isset($media[$media_file])) { + /** @var Medium $medium */ + $medium = $media[$media_file]; + foreach ($uri->query(null, true) as $action => $params) { + if (in_array($action, ImageMedium::$magic_actions, true)) { + call_user_func_array([&$medium, $action], explode(',', $params)); + } + } + Utils::download($medium->path(), false); + } + + // unsupported media type, try to download it... + if ($uri_extension) { + $extension = $uri_extension; + } elseif (isset($path_parts['extension'])) { + $extension = $path_parts['extension']; + } else { + $extension = null; + } + + if ($extension) { + $download = true; + if (in_array(ltrim($extension, '.'), $config->get('system.media.unsupported_inline_types', []), true)) { + $download = false; + } + Utils::download($page->path() . DIRECTORY_SEPARATOR . $uri->basename(), $download); + } + } + + // Nothing found + return false; + } +} diff --git a/system/src/Grav/Common/GravTrait.php b/system/src/Grav/Common/GravTrait.php new file mode 100644 index 0000000..0472e61 --- /dev/null +++ b/system/src/Grav/Common/GravTrait.php @@ -0,0 +1,34 @@ + 'Grav CMS' + ]; + + public static function getClient(array $overrides = [], int $connections = 6, callable $callback = null): HttpClientInterface + { + $config = Grav::instance()['config']; + $options = static::getOptions(); + + // Use callback if provided + if ($callback) { + self::$callback = $callback; + $options->setOnProgress([Client::class, 'progress']); + } + + $settings = array_merge($options->toArray(), $overrides); + $preferred_method = $config->get('system.http.method'); + // Try old GPM setting if value is the same as system default + if ($preferred_method === 'auto') { + $preferred_method = $config->get('system.gpm.method', 'auto'); + } + + switch ($preferred_method) { + case 'curl': + $client = new CurlHttpClient($settings, $connections); + break; + case 'fopen': + case 'native': + $client = new NativeHttpClient($settings, $connections); + break; + default: + $client = HttpClient::create($settings, $connections); + } + + return $client; + } + + /** + * Get HTTP Options + * + * @return HttpOptions + */ + public static function getOptions(): HttpOptions + { + $config = Grav::instance()['config']; + $referer = defined('GRAV_CLI') ? 'grav_cli' : Grav::instance()['uri']->rootUrl(true); + + $options = new HttpOptions(); + + // Set default Headers + $options->setHeaders(array_merge([ 'Referer' => $referer ], self::$headers)); + + // Disable verify Peer if required + $verify_peer = $config->get('system.http.verify_peer'); + // Try old GPM setting if value is default + if ($verify_peer === true) { + $verify_peer = $config->get('system.gpm.verify_peer', null) ?? $verify_peer; + } + $options->verifyPeer($verify_peer); + + // Set verify Host + $verify_host = $config->get('system.http.verify_host', true); + $options->verifyHost($verify_host); + + // New setting and must be enabled for Proxy to work + if ($config->get('system.http.enable_proxy', true)) { + // Set proxy url if provided + $proxy_url = $config->get('system.http.proxy_url', $config->get('system.gpm.proxy_url', null)); + if ($proxy_url !== null) { + $options->setProxy($proxy_url); + } + + // Certificate + $proxy_cert = $config->get('system.http.proxy_cert_path', null); + if ($proxy_cert !== null) { + $options->setCaPath($proxy_cert); + } + } + + return $options; + } + + /** + * Progress normalized for cURL and Fopen + * Accepts a variable length of arguments passed in by stream method + * + * @return void + */ + public static function progress(int $bytes_transferred, int $filesize, array $info) + { + + if ($bytes_transferred > 0) { + $percent = $filesize <= 0 ? 0 : (int)(($bytes_transferred * 100) / $filesize); + + $progress = [ + 'code' => $info['http_code'], + 'filesize' => $filesize, + 'transferred' => $bytes_transferred, + 'percent' => $percent < 100 ? $percent : 100 + ]; + + if (self::$callback !== null) { + call_user_func(self::$callback, $progress); + } + } + } +} diff --git a/system/src/Grav/Common/HTTP/Response.php b/system/src/Grav/Common/HTTP/Response.php new file mode 100644 index 0000000..42c676e --- /dev/null +++ b/system/src/Grav/Common/HTTP/Response.php @@ -0,0 +1,96 @@ +getContent(); + } + + + /** + * Makes a request to the URL by using the preferred method + * + * @param string $method method to call such as GET, PUT, etc + * @param string $uri URL to call + * @param array $overrides An array of parameters for both `curl` and `fopen` + * @param callable|null $callback Either a function or callback in array notation + * @return ResponseInterface + * @throws TransportExceptionInterface + */ + public static function request(string $method, string $uri, array $overrides = [], callable $callback = null): ResponseInterface + { + if (empty($method)) { + throw new TransportException('missing method (GET, PUT, etc.)'); + } + + if (empty($uri)) { + throw new TransportException('missing URI'); + } + + // check if this function is available, if so use it to stop any timeouts + try { + if (Utils::functionExists('set_time_limit')) { + @set_time_limit(0); + } + } catch (Exception $e) {} + + $client = Client::getClient($overrides, 6, $callback); + + return $client->request($method, $uri); + } + + + /** + * Is this a remote file or not + * + * @param string $file + * @return bool + */ + public static function isRemote($file): bool + { + return (bool) filter_var($file, FILTER_VALIDATE_URL); + } + + +} diff --git a/system/src/Grav/Common/Helpers/Base32.php b/system/src/Grav/Common/Helpers/Base32.php new file mode 100644 index 0000000..32b0fec --- /dev/null +++ b/system/src/Grav/Common/Helpers/Base32.php @@ -0,0 +1,141 @@ +', '?' + 0xFF,0x00,0x01,0x02,0x03,0x04,0x05,0x06, // '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G' + 0x07,0x08,0x09,0x0A,0x0B,0x0C,0x0D,0x0E, // 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O' + 0x0F,0x10,0x11,0x12,0x13,0x14,0x15,0x16, // 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W' + 0x17,0x18,0x19,0xFF,0xFF,0xFF,0xFF,0xFF, // 'X', 'Y', 'Z', '[', '\', ']', '^', '_' + 0xFF,0x00,0x01,0x02,0x03,0x04,0x05,0x06, // '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g' + 0x07,0x08,0x09,0x0A,0x0B,0x0C,0x0D,0x0E, // 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o' + 0x0F,0x10,0x11,0x12,0x13,0x14,0x15,0x16, // 'p', 'q', 'r', 's', 't', 'u', 'v', 'w' + 0x17,0x18,0x19,0xFF,0xFF,0xFF,0xFF,0xFF // 'x', 'y', 'z', '{', '|', '}', '~', 'DEL' + ]; + + /** + * Encode in Base32 + * + * @param string $bytes + * @return string + */ + public static function encode($bytes) + { + $i = 0; + $index = 0; + $base32 = ''; + $bytesLen = strlen($bytes); + + while ($i < $bytesLen) { + $currByte = ord($bytes[$i]); + + /* Is the current digit going to span a byte boundary? */ + if ($index > 3) { + if (($i + 1) < $bytesLen) { + $nextByte = ord($bytes[$i+1]); + } else { + $nextByte = 0; + } + + $digit = $currByte & (0xFF >> $index); + $index = ($index + 5) % 8; + $digit <<= $index; + $digit |= $nextByte >> (8 - $index); + $i++; + } else { + $digit = ($currByte >> (8 - ($index + 5))) & 0x1F; + $index = ($index + 5) % 8; + if ($index === 0) { + $i++; + } + } + + $base32 .= self::$base32Chars[$digit]; + } + return $base32; + } + + /** + * Decode in Base32 + * + * @param string $base32 + * @return string + */ + public static function decode($base32) + { + $bytes = []; + $base32Len = strlen($base32); + $base32LookupLen = count(self::$base32Lookup); + + for ($i = $base32Len * 5 / 8 - 1; $i >= 0; --$i) { + $bytes[] = 0; + } + + for ($i = 0, $index = 0, $offset = 0; $i < $base32Len; $i++) { + $lookup = ord($base32[$i]) - ord('0'); + + /* Skip chars outside the lookup table */ + if ($lookup < 0 || $lookup >= $base32LookupLen) { + continue; + } + + $digit = self::$base32Lookup[$lookup]; + + /* If this digit is not in the table, ignore it */ + if ($digit === 0xFF) { + continue; + } + + if ($index <= 3) { + $index = ($index + 5) % 8; + if ($index === 0) { + $bytes[$offset] |= $digit; + $offset++; + if ($offset >= count($bytes)) { + break; + } + } else { + $bytes[$offset] |= $digit << (8 - $index); + } + } else { + $index = ($index + 5) % 8; + $bytes[$offset] |= ($digit >> $index); + $offset++; + if ($offset >= count($bytes)) { + break; + } + $bytes[$offset] |= $digit << (8 - $index); + } + } + + $bites = ''; + foreach ($bytes as $byte) { + $bites .= chr($byte); + } + + return $bites; + } +} diff --git a/system/src/Grav/Common/Helpers/Excerpts.php b/system/src/Grav/Common/Helpers/Excerpts.php new file mode 100644 index 0000000..cdfb33b --- /dev/null +++ b/system/src/Grav/Common/Helpers/Excerpts.php @@ -0,0 +1,196 @@ +` + * @param PageInterface|null $page Page, defaults to the current page object + * @return string Returns final HTML string + */ + public static function processImageHtml($html, PageInterface $page = null) + { + $excerpt = static::getExcerptFromHtml($html, 'img'); + if (null === $excerpt) { + return ''; + } + + $original_src = $excerpt['element']['attributes']['src']; + $excerpt['element']['attributes']['href'] = $original_src; + + $excerpt = static::processLinkExcerpt($excerpt, $page, 'image'); + + $excerpt['element']['attributes']['src'] = $excerpt['element']['attributes']['href']; + unset($excerpt['element']['attributes']['href']); + + $excerpt = static::processImageExcerpt($excerpt, $page); + + $excerpt['element']['attributes']['data-src'] = $original_src; + + $html = static::getHtmlFromExcerpt($excerpt); + + return $html; + } + + /** + * Process Grav page link URL from HTML tag + * + * @param string $html HTML tag e.g. `Page Link` + * @param PageInterface|null $page Page, defaults to the current page object + * @return string Returns final HTML string + */ + public static function processLinkHtml($html, PageInterface $page = null) + { + $excerpt = static::getExcerptFromHtml($html, 'a'); + if (null === $excerpt) { + return ''; + } + + $original_href = $excerpt['element']['attributes']['href']; + $excerpt = static::processLinkExcerpt($excerpt, $page, 'link'); + $excerpt['element']['attributes']['data-href'] = $original_href; + + $html = static::getHtmlFromExcerpt($excerpt); + + return $html; + } + + /** + * Get an Excerpt array from a chunk of HTML + * + * @param string $html Chunk of HTML + * @param string $tag A tag, for example `img` + * @return array|null returns nested array excerpt + */ + public static function getExcerptFromHtml($html, $tag) + { + $doc = new DOMDocument('1.0', 'UTF-8'); + $internalErrors = libxml_use_internal_errors(true); + $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); + libxml_use_internal_errors($internalErrors); + + $elements = $doc->getElementsByTagName($tag); + $excerpt = null; + $inner = []; + + foreach ($elements as $element) { + $attributes = []; + foreach ($element->attributes as $name => $value) { + $attributes[$name] = $value->value; + } + $excerpt = [ + 'element' => [ + 'name' => $element->tagName, + 'attributes' => $attributes + ] + ]; + + foreach ($element->childNodes as $node) { + $inner[] = $doc->saveHTML($node); + } + + $excerpt = array_merge_recursive($excerpt, ['element' => ['text' => implode('', $inner)]]); + + + } + + return $excerpt; + } + + /** + * Rebuild HTML tag from an excerpt array + * + * @param array $excerpt + * @return string + */ + public static function getHtmlFromExcerpt($excerpt) + { + $element = $excerpt['element']; + $html = '<'.$element['name']; + + if (isset($element['attributes'])) { + foreach ($element['attributes'] as $name => $value) { + if ($value === null) { + continue; + } + $html .= ' '.$name.'="'.$value.'"'; + } + } + + if (isset($element['text'])) { + $html .= '>'; + $html .= is_array($element['text']) ? static::getHtmlFromExcerpt(['element' => $element['text']]) : $element['text']; + $html .= ''; + } else { + $html .= ' />'; + } + + return $html; + } + + /** + * Process a Link excerpt + * + * @param array $excerpt + * @param PageInterface|null $page Page, defaults to the current page object + * @param string $type + * @return mixed + */ + public static function processLinkExcerpt($excerpt, PageInterface $page = null, $type = 'link') + { + $excerpts = new ExcerptsObject($page); + + return $excerpts->processLinkExcerpt($excerpt, $type); + } + + /** + * Process an image excerpt + * + * @param array $excerpt + * @param PageInterface|null $page Page, defaults to the current page object + * @return array + */ + public static function processImageExcerpt(array $excerpt, PageInterface $page = null) + { + $excerpts = new ExcerptsObject($page); + + return $excerpts->processImageExcerpt($excerpt); + } + + /** + * Process media actions + * + * @param Medium $medium + * @param string|array $url + * @param PageInterface|null $page Page, defaults to the current page object + * @return Medium|Link + */ + public static function processMediaActions($medium, $url, PageInterface $page = null) + { + $excerpts = new ExcerptsObject($page); + + return $excerpts->processMediaActions($medium, $url); + } +} diff --git a/system/src/Grav/Common/Helpers/Exif.php b/system/src/Grav/Common/Helpers/Exif.php new file mode 100644 index 0000000..a458dcc --- /dev/null +++ b/system/src/Grav/Common/Helpers/Exif.php @@ -0,0 +1,48 @@ +get('system.media.auto_metadata_exif')) { + if (function_exists('exif_read_data') && class_exists(Reader::class)) { + $this->reader = Reader::factory(Reader::TYPE_NATIVE); + } else { + throw new RuntimeException('Please enable the Exif extension for PHP or disable Exif support in Grav system configuration'); + } + } + } + + /** + * @return Reader + */ + public function getReader() + { + return $this->reader; + } +} diff --git a/system/src/Grav/Common/Helpers/LogViewer.php b/system/src/Grav/Common/Helpers/LogViewer.php new file mode 100644 index 0000000..d657faa --- /dev/null +++ b/system/src/Grav/Common/Helpers/LogViewer.php @@ -0,0 +1,167 @@ +.*?)\] (?P\w+)\.(?P\w+): (?P.*[^ ]+) (?P[^ ]+) (?P[^ ]+)/'; + + /** + * Get the objects of a tailed file + * + * @param string $filepath + * @param int $lines + * @param bool $desc + * @return array + */ + public function objectTail($filepath, $lines = 1, $desc = true) + { + $data = $this->tail($filepath, $lines); + $tailed_log = $data ? explode(PHP_EOL, $data) : []; + $line_objects = []; + + foreach ($tailed_log as $line) { + $line_objects[] = $this->parse($line); + } + + return $desc ? $line_objects : array_reverse($line_objects); + } + + /** + * Optimized way to get just the last few entries of a log file + * + * @param string $filepath + * @param int $lines + * @return string|false + */ + public function tail($filepath, $lines = 1) + { + $f = $filepath ? @fopen($filepath, 'rb') : false; + if ($f === false) { + return false; + } + + $buffer = ($lines < 2 ? 64 : ($lines < 10 ? 512 : 4096)); + + fseek($f, -1, SEEK_END); + if (fread($f, 1) !== "\n") { + --$lines; + } + + // Start reading + $output = ''; + // While we would like more + while (ftell($f) > 0 && $lines >= 0) { + // Figure out how far back we should jump + $seek = min(ftell($f), $buffer); + // Do the jump (backwards, relative to where we are) + fseek($f, -$seek, SEEK_CUR); + // Read a chunk and prepend it to our output + $chunk = fread($f, $seek); + if ($chunk === false) { + throw new \RuntimeException('Cannot read file'); + } + $output = $chunk . $output; + // Jump back to where we started reading + fseek($f, -mb_strlen($chunk, '8bit'), SEEK_CUR); + // Decrease our line counter + $lines -= substr_count($chunk, "\n"); + } + // While we have too many lines + // (Because of buffer size we might have read too many) + while ($lines++ < 0) { + // Find first newline and remove all text before that + $output = substr($output, strpos($output, "\n") + 1); + } + // Close file and return + fclose($f); + + return trim($output); + } + + /** + * Helper class to get level color + * + * @param string $level + * @return string + */ + public static function levelColor($level) + { + $colors = [ + 'DEBUG' => 'green', + 'INFO' => 'cyan', + 'NOTICE' => 'yellow', + 'WARNING' => 'yellow', + 'ERROR' => 'red', + 'CRITICAL' => 'red', + 'ALERT' => 'red', + 'EMERGENCY' => 'magenta' + ]; + return $colors[$level] ?? 'white'; + } + + /** + * Parse a monolog row into array bits + * + * @param string $line + * @return array + */ + public function parse($line) + { + if (!is_string($line) || $line === '') { + return []; + } + + preg_match($this->pattern, $line, $data); + if (!isset($data['date'])) { + return []; + } + + preg_match('/(.*)- Trace:(.*)/', $data['message'], $matches); + if (is_array($matches) && isset($matches[1])) { + $data['message'] = trim($matches[1]); + $data['trace'] = trim($matches[2]); + } + + return [ + 'date' => DateTime::createFromFormat('Y-m-d H:i:s', $data['date']), + 'logger' => $data['logger'], + 'level' => $data['level'], + 'message' => $data['message'], + 'trace' => isset($data['trace']) ? self::parseTrace($data['trace']) : null, + 'context' => json_decode($data['context'], true), + 'extra' => json_decode($data['extra'], true) + ]; + } + + /** + * Parse text of trace into an array of lines + * + * @param string $trace + * @param int $rows + * @return array + */ + public static function parseTrace($trace, $rows = 10) + { + $lines = array_filter(preg_split('/#\d*/m', $trace)); + + return array_slice($lines, 0, $rows); + } +} diff --git a/system/src/Grav/Common/Helpers/Truncator.php b/system/src/Grav/Common/Helpers/Truncator.php new file mode 100644 index 0000000..0ccd034 --- /dev/null +++ b/system/src/Grav/Common/Helpers/Truncator.php @@ -0,0 +1,344 @@ +getElementsByTagName('div')->item(0); + $container = $container->parentNode->removeChild($container); + + // Iterate over words. + $words = new DOMWordsIterator($container); + $truncated = false; + foreach ($words as $word) { + // If we have exceeded the limit, we delete the remainder of the content. + if ($words->key() >= $limit) { + // Grab current position. + $currentWordPosition = $words->currentWordPosition(); + $curNode = $currentWordPosition[0]; + $offset = $currentWordPosition[1]; + $words = $currentWordPosition[2]; + + $curNode->nodeValue = substr( + $curNode->nodeValue, + 0, + $words[$offset][1] + strlen($words[$offset][0]) + ); + + self::removeProceedingNodes($curNode, $container); + + if (!empty($ellipsis)) { + self::insertEllipsis($curNode, $ellipsis); + } + + $truncated = true; + + break; + } + } + + // Return original HTML if not truncated. + if ($truncated) { + $html = self::getCleanedHtml($doc, $container); + } + + return $html; + } + + /** + * Safely truncates HTML by a given number of letters. + * + * @param string $html Input HTML. + * @param int $limit Limit to how many letters we preserve. + * @param string $ellipsis String to use as ellipsis (if any). + * @return string Safe truncated HTML. + */ + public static function truncateLetters($html, $limit = 0, $ellipsis = '') + { + if ($limit <= 0) { + return $html; + } + + $doc = self::htmlToDomDocument($html); + $container = $doc->getElementsByTagName('div')->item(0); + $container = $container->parentNode->removeChild($container); + + // Iterate over letters. + $letters = new DOMLettersIterator($container); + $truncated = false; + foreach ($letters as $letter) { + // If we have exceeded the limit, we want to delete the remainder of this document. + if ($letters->key() >= $limit) { + $currentText = $letters->currentTextPosition(); + $currentText[0]->nodeValue = mb_substr($currentText[0]->nodeValue, 0, $currentText[1] + 1); + self::removeProceedingNodes($currentText[0], $container); + + if (!empty($ellipsis)) { + self::insertEllipsis($currentText[0], $ellipsis); + } + + $truncated = true; + + break; + } + } + + // Return original HTML if not truncated. + if ($truncated) { + $html = self::getCleanedHtml($doc, $container); + } + + return $html; + } + + /** + * Builds a DOMDocument object from a string containing HTML. + * + * @param string $html HTML to load + * @return DOMDocument Returns a DOMDocument object. + */ + public static function htmlToDomDocument($html) + { + if (!$html) { + $html = ''; + } + + // Transform multibyte entities which otherwise display incorrectly. + $html = mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, ~0], 'UTF-8'); + + // Internal errors enabled as HTML5 not fully supported. + libxml_use_internal_errors(true); + + // Instantiate new DOMDocument object, and then load in UTF-8 HTML. + $dom = new DOMDocument(); + $dom->encoding = 'UTF-8'; + $dom->loadHTML("
$html
"); + + return $dom; + } + + /** + * Removes all nodes after the current node. + * + * @param DOMNode|DOMElement $domNode + * @param DOMNode|DOMElement $topNode + * @return void + */ + private static function removeProceedingNodes($domNode, $topNode) + { + /** @var DOMNode|null $nextNode */ + $nextNode = $domNode->nextSibling; + + if ($nextNode !== null) { + self::removeProceedingNodes($nextNode, $topNode); + $domNode->parentNode->removeChild($nextNode); + } else { + //scan upwards till we find a sibling + $curNode = $domNode->parentNode; + while ($curNode !== $topNode) { + if ($curNode->nextSibling !== null) { + $curNode = $curNode->nextSibling; + self::removeProceedingNodes($curNode, $topNode); + $curNode->parentNode->removeChild($curNode); + break; + } + $curNode = $curNode->parentNode; + } + } + } + + /** + * Clean extra code + * + * @param DOMDocument $doc + * @param DOMNode $container + * @return string + */ + private static function getCleanedHTML(DOMDocument $doc, DOMNode $container) + { + while ($doc->firstChild) { + $doc->removeChild($doc->firstChild); + } + + while ($container->firstChild) { + $doc->appendChild($container->firstChild); + } + + return trim($doc->saveHTML()); + } + + /** + * Inserts an ellipsis + * + * @param DOMNode|DOMElement $domNode Element to insert after. + * @param string $ellipsis Text used to suffix our document. + * @return void + */ + private static function insertEllipsis($domNode, $ellipsis) + { + $avoid = array('a', 'strong', 'em', 'h1', 'h2', 'h3', 'h4', 'h5'); //html tags to avoid appending the ellipsis to + + if ($domNode->parentNode->parentNode !== null && in_array($domNode->parentNode->nodeName, $avoid, true)) { + // Append as text node to parent instead + $textNode = new DOMText($ellipsis); + + /** @var DOMNode|null $nextSibling */ + $nextSibling = $domNode->parentNode->parentNode->nextSibling; + if ($nextSibling) { + $domNode->parentNode->parentNode->insertBefore($textNode, $domNode->parentNode->parentNode->nextSibling); + } else { + $domNode->parentNode->parentNode->appendChild($textNode); + } + } else { + // Append to current node + $domNode->nodeValue = rtrim($domNode->nodeValue) . $ellipsis; + } + } + + /** + * @param string $text + * @param int $length + * @param string $ending + * @param bool $exact + * @param bool $considerHtml + * @return string + */ + public function truncate( + $text, + $length = 100, + $ending = '...', + $exact = false, + $considerHtml = true + ) { + if ($considerHtml) { + // if the plain text is shorter than the maximum length, return the whole text + if (strlen(preg_replace('/<.*?>/', '', $text)) <= $length) { + return $text; + } + + // splits all html-tags to scanable lines + preg_match_all('/(<.+?>)?([^<>]*)/s', $text, $lines, PREG_SET_ORDER); + $total_length = strlen($ending); + $truncate = ''; + $open_tags = []; + + foreach ($lines as $line_matchings) { + // if there is any html-tag in this line, handle it and add it (uncounted) to the output + if (!empty($line_matchings[1])) { + // if it's an "empty element" with or without xhtml-conform closing slash + if (preg_match('/^<(\s*.+?\/\s*|\s*(img|br|input|hr|area|base|basefont|col|frame|isindex|link|meta|param)(\s.+?)?)>$/is', $line_matchings[1])) { + // do nothing + // if tag is a closing tag + } elseif (preg_match('/^<\s*\/([^\s]+?)\s*>$/s', $line_matchings[1], $tag_matchings)) { + // delete tag from $open_tags list + $pos = array_search($tag_matchings[1], $open_tags); + if ($pos !== false) { + unset($open_tags[$pos]); + } + // if tag is an opening tag + } elseif (preg_match('/^<\s*([^\s>!]+).*?>$/s', $line_matchings[1], $tag_matchings)) { + // add tag to the beginning of $open_tags list + array_unshift($open_tags, strtolower($tag_matchings[1])); + } + // add html-tag to $truncate'd text + $truncate .= $line_matchings[1]; + } + // calculate the length of the plain text part of the line; handle entities as one character + $content_length = strlen(preg_replace('/&[0-9a-z]{2,8};|&#[0-9]{1,7};|[0-9a-f]{1,6};/i', ' ', $line_matchings[2])); + if ($total_length+$content_length> $length) { + // the number of characters which are left + $left = $length - $total_length; + $entities_length = 0; + // search for html entities + if (preg_match_all('/&[0-9a-z]{2,8};|&#[0-9]{1,7};|[0-9a-f]{1,6};/i', $line_matchings[2], $entities, PREG_OFFSET_CAPTURE)) { + // calculate the real length of all entities in the legal range + foreach ($entities[0] as $entity) { + if ($entity[1]+1-$entities_length <= $left) { + $left--; + $entities_length += strlen($entity[0]); + } else { + // no more characters left + break; + } + } + } + $truncate .= substr($line_matchings[2], 0, $left+$entities_length); + // maximum lenght is reached, so get off the loop + break; + } else { + $truncate .= $line_matchings[2]; + $total_length += $content_length; + } + // if the maximum length is reached, get off the loop + if ($total_length>= $length) { + break; + } + } + } else { + if (strlen($text) <= $length) { + return $text; + } + + $truncate = substr($text, 0, $length - strlen($ending)); + } + // if the words shouldn't be cut in the middle... + if (!$exact) { + // ...search the last occurance of a space... + $spacepos = strrpos($truncate, ' '); + if (false !== $spacepos) { + // ...and cut the text in this position + $truncate = substr($truncate, 0, $spacepos); + } + } + // add the defined ending to the text + $truncate .= $ending; + if (isset($open_tags)) { + // close all unclosed html-tags + foreach ($open_tags as $tag) { + $truncate .= ''; + } + } + + return $truncate; + } +} diff --git a/system/src/Grav/Common/Helpers/YamlLinter.php b/system/src/Grav/Common/Helpers/YamlLinter.php new file mode 100644 index 0000000..296c681 --- /dev/null +++ b/system/src/Grav/Common/Helpers/YamlLinter.php @@ -0,0 +1,122 @@ +get('system.pages.theme'); + $theme_path = 'themes://' . $current_theme . '/blueprints'; + + $locator->addPath('blueprints', '', [$theme_path]); + return static::recurseFolder('blueprints://'); + } + + /** + * @param string $path + * @param string $extensions + * @return array + */ + public static function recurseFolder($path, $extensions = '(md|yaml)') + { + $lint_errors = []; + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $flags = RecursiveDirectoryIterator::SKIP_DOTS | RecursiveDirectoryIterator::FOLLOW_SYMLINKS; + if ($locator->isStream($path)) { + $directory = $locator->getRecursiveIterator($path, $flags); + } else { + $directory = new RecursiveDirectoryIterator($path, $flags); + } + $recursive = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST); + $iterator = new RegexIterator($recursive, '/^.+\.'.$extensions.'$/ui'); + + /** @var RecursiveDirectoryIterator $file */ + foreach ($iterator as $filepath => $file) { + try { + Yaml::parse(static::extractYaml($filepath)); + } catch (Exception $e) { + $lint_errors[str_replace(GRAV_ROOT, '', $filepath)] = $e->getMessage(); + } + } + + return $lint_errors; + } + + /** + * @param string $path + * @return string + */ + protected static function extractYaml($path) + { + $extension = Utils::pathinfo($path, PATHINFO_EXTENSION); + if ($extension === 'md') { + $file = MarkdownFile::instance($path); + $contents = $file->frontmatter(); + $file->free(); + } else { + $contents = file_get_contents($path); + } + return $contents; + } +} diff --git a/system/src/Grav/Common/Inflector.php b/system/src/Grav/Common/Inflector.php new file mode 100644 index 0000000..e8f57bc --- /dev/null +++ b/system/src/Grav/Common/Inflector.php @@ -0,0 +1,363 @@ +isDebug()) { + static::$plural = $language->translate('GRAV.INFLECTOR_PLURALS', null, true); + static::$singular = $language->translate('GRAV.INFLECTOR_SINGULAR', null, true); + static::$uncountable = $language->translate('GRAV.INFLECTOR_UNCOUNTABLE', null, true); + static::$irregular = $language->translate('GRAV.INFLECTOR_IRREGULAR', null, true); + static::$ordinals = $language->translate('GRAV.INFLECTOR_ORDINALS', null, true); + } + } + } + + /** + * Pluralizes English nouns. + * + * @param string $word English noun to pluralize + * @param int $count The count + * @return string|false Plural noun + */ + public static function pluralize($word, $count = 2) + { + static::init(); + + if ((int)$count === 1) { + return $word; + } + + $lowercased_word = strtolower($word); + + if (is_array(static::$uncountable)) { + foreach (static::$uncountable as $_uncountable) { + if (substr($lowercased_word, -1 * strlen($_uncountable)) === $_uncountable) { + return $word; + } + } + } + + if (is_array(static::$irregular)) { + foreach (static::$irregular as $_plural => $_singular) { + if (preg_match('/(' . $_plural . ')$/i', $word, $arr)) { + return preg_replace('/(' . $_plural . ')$/i', substr($arr[0], 0, 1) . substr($_singular, 1), $word); + } + } + } + + if (is_array(static::$plural)) { + foreach (static::$plural as $rule => $replacement) { + if (preg_match($rule, $word)) { + return preg_replace($rule, $replacement, $word); + } + } + } + + return false; + } + + /** + * Singularizes English nouns. + * + * @param string $word English noun to singularize + * @param int $count + * + * @return string Singular noun. + */ + public static function singularize($word, $count = 1) + { + static::init(); + + if ((int)$count !== 1) { + return $word; + } + + $lowercased_word = strtolower($word); + + if (is_array(static::$uncountable)) { + foreach (static::$uncountable as $_uncountable) { + if (substr($lowercased_word, -1 * strlen($_uncountable)) === $_uncountable) { + return $word; + } + } + } + + if (is_array(static::$irregular)) { + foreach (static::$irregular as $_plural => $_singular) { + if (preg_match('/(' . $_singular . ')$/i', $word, $arr)) { + return preg_replace('/(' . $_singular . ')$/i', substr($arr[0], 0, 1) . substr($_plural, 1), $word); + } + } + } + + if (is_array(static::$singular)) { + foreach (static::$singular as $rule => $replacement) { + if (preg_match($rule, $word)) { + return preg_replace($rule, $replacement, $word); + } + } + } + + return $word; + } + + /** + * Converts an underscored or CamelCase word into a English + * sentence. + * + * The titleize public function converts text like "WelcomePage", + * "welcome_page" or "welcome page" to this "Welcome + * Page". + * If second parameter is set to 'first' it will only + * capitalize the first character of the title. + * + * @param string $word Word to format as tile + * @param string $uppercase If set to 'first' it will only uppercase the + * first character. Otherwise it will uppercase all + * the words in the title. + * + * @return string Text formatted as title + */ + public static function titleize($word, $uppercase = '') + { + $humanize_underscorize = static::humanize(static::underscorize($word)); + + if ($uppercase === 'first') { + $firstLetter = mb_strtoupper(mb_substr($humanize_underscorize, 0, 1, "UTF-8"), "UTF-8"); + return $firstLetter . mb_substr($humanize_underscorize, 1, mb_strlen($humanize_underscorize, "UTF-8"), "UTF-8"); + } else { + return mb_convert_case($humanize_underscorize, MB_CASE_TITLE, 'UTF-8'); + } + + } + + /** + * Returns given word as CamelCased + * + * Converts a word like "send_email" to "SendEmail". It + * will remove non alphanumeric character from the word, so + * "who's online" will be converted to "WhoSOnline" + * + * @see variablize + * + * @param string $word Word to convert to camel case + * @return string UpperCamelCasedWord + */ + public static function camelize($word) + { + return str_replace(' ', '', ucwords(preg_replace('/[^\p{L}^0-9]+/', ' ', $word))); + } + + /** + * Converts a word "into_it_s_underscored_version" + * + * Convert any "CamelCased" or "ordinary Word" into an + * "underscored_word". + * + * This can be really useful for creating friendly URLs. + * + * @param string $word Word to underscore + * @return string Underscored word + */ + public static function underscorize($word) + { + $regex1 = preg_replace('/([A-Z]+)([A-Z][a-z])/', '\1_\2', $word); + $regex2 = preg_replace('/([a-zd])([A-Z])/', '\1_\2', $regex1); + $regex3 = preg_replace('/[^\p{L}^0-9]+/u', '_', $regex2); + + return strtolower($regex3); + } + + /** + * Converts a word "into-it-s-hyphenated-version" + * + * Convert any "CamelCased" or "ordinary Word" into an + * "hyphenated-word". + * + * This can be really useful for creating friendly URLs. + * + * @param string $word Word to hyphenate + * @return string hyphenized word + */ + public static function hyphenize($word) + { + $regex1 = preg_replace('/([A-Z]+)([A-Z][a-z])/', '\1-\2', $word); + $regex2 = preg_replace('/([a-z])([A-Z])/', '\1-\2', $regex1); + $regex3 = preg_replace('/([0-9])([A-Z])/', '\1-\2', $regex2); + $regex4 = preg_replace('/[^\p{L}^0-9]+/', '-', $regex3); + + $regex4 = trim($regex4, '-'); + + return strtolower($regex4); + } + + /** + * Returns a human-readable string from $word + * + * Returns a human-readable string from $word, by replacing + * underscores with a space, and by upper-casing the initial + * character by default. + * + * If you need to uppercase all the words you just have to + * pass 'all' as a second parameter. + * + * @param string $word String to "humanize" + * @param string $uppercase If set to 'all' it will uppercase all the words + * instead of just the first one. + * + * @return string Human-readable word + */ + public static function humanize($word, $uppercase = '') + { + $uppercase = $uppercase === 'all' ? 'ucwords' : 'ucfirst'; + + return $uppercase(str_replace('_', ' ', preg_replace('/_id$/', '', $word))); + } + + /** + * Same as camelize but first char is underscored + * + * Converts a word like "send_email" to "sendEmail". It + * will remove non alphanumeric character from the word, so + * "who's online" will be converted to "whoSOnline" + * + * @see camelize + * + * @param string $word Word to lowerCamelCase + * @return string Returns a lowerCamelCasedWord + */ + public static function variablize($word) + { + $word = static::camelize($word); + + return strtolower($word[0]) . substr($word, 1); + } + + /** + * Converts a class name to its table name according to rails + * naming conventions. + * + * Converts "Person" to "people" + * + * @see classify + * + * @param string $class_name Class name for getting related table_name. + * @return string plural_table_name + */ + public static function tableize($class_name) + { + return static::pluralize(static::underscorize($class_name)); + } + + /** + * Converts a table name to its class name according to rails + * naming conventions. + * + * Converts "people" to "Person" + * + * @see tableize + * + * @param string $table_name Table name for getting related ClassName. + * @return string SingularClassName + */ + public static function classify($table_name) + { + return static::camelize(static::singularize($table_name)); + } + + /** + * Converts number to its ordinal English form. + * + * This method converts 13 to 13th, 2 to 2nd ... + * + * @param int $number Number to get its ordinal value + * @return string Ordinal representation of given string. + */ + public static function ordinalize($number) + { + static::init(); + + if (!is_array(static::$ordinals)) { + return (string)$number; + } + + if (in_array($number % 100, range(11, 13), true)) { + return $number . static::$ordinals['default']; + } + + switch ($number % 10) { + case 1: + return $number . static::$ordinals['first']; + case 2: + return $number . static::$ordinals['second']; + case 3: + return $number . static::$ordinals['third']; + default: + return $number . static::$ordinals['default']; + } + } + + /** + * Converts a number of days to a number of months + * + * @param int $days + * @return int + */ + public static function monthize($days) + { + $now = new DateTime(); + $end = new DateTime(); + + $duration = new DateInterval("P{$days}D"); + + $diff = $end->add($duration)->diff($now); + + // handle years + if ($diff->y > 0) { + $diff->m += 12 * $diff->y; + } + + return $diff->m; + } +} diff --git a/system/src/Grav/Common/Iterator.php b/system/src/Grav/Common/Iterator.php new file mode 100644 index 0000000..e5c1351 --- /dev/null +++ b/system/src/Grav/Common/Iterator.php @@ -0,0 +1,264 @@ +items[$key] ?? null; + } + + /** + * Clone the iterator. + */ + #[\ReturnTypeWillChange] + public function __clone() + { + foreach ($this as $key => $value) { + if (is_object($value)) { + $this->{$key} = clone $this->{$key}; + } + } + } + + /** + * Convents iterator to a comma separated list. + * + * @return string + */ + #[\ReturnTypeWillChange] + public function __toString() + { + return implode(',', $this->items); + } + + /** + * Remove item from the list. + * + * @param string $key + * @return void + */ + public function remove($key) + { + $this->offsetUnset($key); + } + + /** + * Return previous item. + * + * @return mixed + */ + public function prev() + { + return prev($this->items); + } + + /** + * Return nth item. + * + * @param int $key + * @return mixed|bool + */ + public function nth($key) + { + $items = array_keys($this->items); + + return isset($items[$key]) ? $this->offsetGet($items[$key]) : false; + } + + /** + * Get the first item + * + * @return mixed + */ + public function first() + { + $items = array_keys($this->items); + + return $this->offsetGet(array_shift($items)); + } + + /** + * Get the last item + * + * @return mixed + */ + public function last() + { + $items = array_keys($this->items); + + return $this->offsetGet(array_pop($items)); + } + + /** + * Reverse the Iterator + * + * @return $this + */ + public function reverse() + { + $this->items = array_reverse($this->items); + + return $this; + } + + /** + * @param mixed $needle Searched value. + * + * @return string|int|false Key if found, otherwise false. + */ + public function indexOf($needle) + { + foreach (array_values($this->items) as $key => $value) { + if ($value === $needle) { + return $key; + } + } + + return false; + } + + /** + * Shuffle items. + * + * @return $this + */ + public function shuffle() + { + $keys = array_keys($this->items); + shuffle($keys); + + $new = []; + foreach ($keys as $key) { + $new[$key] = $this->items[$key]; + } + + $this->items = $new; + + return $this; + } + + /** + * Slice the list. + * + * @param int $offset + * @param int|null $length + * @return $this + */ + public function slice($offset, $length = null) + { + $this->items = array_slice($this->items, $offset, $length); + + return $this; + } + + /** + * Pick one or more random entries. + * + * @param int $num Specifies how many entries should be picked. + * @return $this + */ + public function random($num = 1) + { + $count = count($this->items); + if ($num > $count) { + $num = $count; + } + + $this->items = array_intersect_key($this->items, array_flip((array)array_rand($this->items, $num))); + + return $this; + } + + /** + * Append new elements to the list. + * + * @param array|Iterator $items Items to be appended. Existing keys will be overridden with the new values. + * @return $this + */ + public function append($items) + { + if ($items instanceof static) { + $items = $items->toArray(); + } + $this->items = array_merge($this->items, (array)$items); + + return $this; + } + + /** + * Filter elements from the list + * + * @param callable|null $callback A function the receives ($value, $key) and must return a boolean to indicate + * filter status + * + * @return $this + */ + public function filter(callable $callback = null) + { + foreach ($this->items as $key => $value) { + if ((!$callback && !(bool)$value) || ($callback && !$callback($value, $key))) { + unset($this->items[$key]); + } + } + + return $this; + } + + + /** + * Sorts elements from the list and returns a copy of the list in the proper order + * + * @param callable|null $callback + * @param bool $desc + * @return $this|array + * + */ + public function sort(callable $callback = null, $desc = false) + { + if (!$callback || !is_callable($callback)) { + return $this; + } + + $items = $this->items; + uasort($items, $callback); + + return !$desc ? $items : array_reverse($items, true); + } +} diff --git a/system/src/Grav/Common/Language/Language.php b/system/src/Grav/Common/Language/Language.php new file mode 100644 index 0000000..66a5bab --- /dev/null +++ b/system/src/Grav/Common/Language/Language.php @@ -0,0 +1,663 @@ +grav = $grav; + $this->config = $grav['config']; + + $languages = $this->config->get('system.languages.supported', []); + foreach ($languages as &$language) { + $language = (string)$language; + } + unset($language); + + $this->languages = $languages; + + $this->init(); + } + + /** + * Initialize the default and enabled languages + * + * @return void + */ + public function init() + { + $default = $this->config->get('system.languages.default_lang'); + if (null !== $default) { + $default = (string)$default; + } + + // Note that reset returns false on empty languages. + $this->default = $default ?? reset($this->languages); + + $this->resetFallbackPageExtensions(); + + if (empty($this->languages)) { + // If no languages are set, turn of multi-language support. + $this->enabled = false; + } elseif ($default && !in_array($default, $this->languages, true)) { + // If default language isn't in the language list, we need to add it. + array_unshift($this->languages, $default); + } + } + + /** + * Ensure that languages are enabled + * + * @return bool + */ + public function enabled() + { + return $this->enabled; + } + + /** + * Returns true if language debugging is turned on. + * + * @return bool + */ + public function isDebug(): bool + { + return !$this->config->get('system.languages.translations', true); + } + + /** + * Gets the array of supported languages + * + * @return array + */ + public function getLanguages() + { + return $this->languages; + } + + /** + * Sets the current supported languages manually + * + * @param array $langs + * @return void + */ + public function setLanguages($langs) + { + $this->languages = $langs; + + $this->init(); + } + + /** + * Gets a pipe-separated string of available languages + * + * @param string|null $delimiter Delimiter to be quoted. + * @return string + */ + public function getAvailable($delimiter = null) + { + $languagesArray = $this->languages; //Make local copy + + $languagesArray = array_map(static function ($value) use ($delimiter) { + return preg_quote($value, $delimiter); + }, $languagesArray); + + sort($languagesArray); + + return implode('|', array_reverse($languagesArray)); + } + + /** + * Gets language, active if set, else default + * + * @return string|false + */ + public function getLanguage() + { + return $this->active ?: $this->default; + } + + /** + * Gets current default language + * + * @return string|false + */ + public function getDefault() + { + return $this->default; + } + + /** + * Sets default language manually + * + * @param string $lang + * @return string|bool + */ + public function setDefault($lang) + { + $lang = (string)$lang; + if ($this->validate($lang)) { + $this->default = $lang; + + return $lang; + } + + return false; + } + + /** + * Gets current active language + * + * @return string|false + */ + public function getActive() + { + return $this->active; + } + + /** + * Sets active language manually + * + * @param string|false $lang + * @return string|false + */ + public function setActive($lang) + { + $lang = (string)$lang; + if ($this->validate($lang)) { + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addMessage('Active language set to ' . $lang, 'debug'); + + $this->active = $lang; + + return $lang; + } + + return false; + } + + /** + * Sets the active language based on the first part of the URL + * + * @param string $uri + * @return string + */ + public function setActiveFromUri($uri) + { + $regex = '/(^\/(' . $this->getAvailable() . '))(?:\/|\?|$)/i'; + + // if languages set + if ($this->enabled()) { + // Try setting language from prefix of URL (/en/blah/blah). + if (preg_match($regex, $uri, $matches)) { + $this->lang_in_url = true; + $this->setActive($matches[2]); + $uri = preg_replace("/\\" . $matches[1] . '/', '', $uri, 1); + + // Store in session if language is different. + if (isset($this->grav['session']) && $this->grav['session']->isStarted() + && $this->config->get('system.languages.session_store_active', true) + && $this->grav['session']->active_language != $this->active + ) { + $this->grav['session']->active_language = $this->active; + } + } else { + // Try getting language from the session, else no active. + if (isset($this->grav['session']) && $this->grav['session']->isStarted() && + $this->config->get('system.languages.session_store_active', true)) { + $this->setActive($this->grav['session']->active_language ?: null); + } + // if still null, try from http_accept_language header + if ($this->active === null && + $this->config->get('system.languages.http_accept_language') && + $accept = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? false) { + $negotiator = new LanguageNegotiator(); + $best_language = $negotiator->getBest($accept, $this->languages); + + if ($best_language instanceof AcceptLanguage) { + $this->setActive($best_language->getType()); + } else { + $this->setActive($this->getDefault()); + } + } + } + } + + return $uri; + } + + /** + * Get a URL prefix based on configuration + * + * @param string|null $lang + * @return string + */ + public function getLanguageURLPrefix($lang = null) + { + if (!$this->enabled()) { + return ''; + } + + // if active lang is not passed in, use current active + if (!$lang) { + $lang = $this->getLanguage(); + } + + return $this->isIncludeDefaultLanguage($lang) ? '/' . $lang : ''; + } + + /** + * Test to see if language is default and language should be included in the URL + * + * @param string|null $lang + * @return bool + */ + public function isIncludeDefaultLanguage($lang = null) + { + if (!$this->enabled()) { + return false; + } + + // if active lang is not passed in, use current active + if (!$lang) { + $lang = $this->getLanguage(); + } + + return !($this->default === $lang && $this->config->get('system.languages.include_default_lang') === false); + } + + /** + * Simple getter to tell if a language was found in the URL + * + * @return bool + */ + public function isLanguageInUrl() + { + return (bool) $this->lang_in_url; + } + + /** + * Get full list of used language page extensions: [''=>'.md', 'en'=>'.en.md', ...] + * + * @param string|null $fileExtension + * @return array + */ + public function getPageExtensions($fileExtension = null) + { + $fileExtension = $fileExtension ?: CONTENT_EXT; + + if (!isset($this->fallback_extensions[$fileExtension])) { + $extensions[''] = $fileExtension; + foreach ($this->languages as $code) { + $extensions[$code] = ".{$code}{$fileExtension}"; + } + + $this->fallback_extensions[$fileExtension] = $extensions; + } + + return $this->fallback_extensions[$fileExtension]; + } + + /** + * Gets an array of valid extensions with active first, then fallback extensions + * + * @param string|null $fileExtension + * @param string|null $languageCode + * @param bool $assoc Return values in ['en' => '.en.md', ...] format. + * @return array Key is the language code, value is the file extension to be used. + */ + public function getFallbackPageExtensions(string $fileExtension = null, string $languageCode = null, bool $assoc = false) + { + $fileExtension = $fileExtension ?: CONTENT_EXT; + $key = $fileExtension . '-' . ($languageCode ?? 'default') . '-' . (int)$assoc; + + if (!isset($this->fallback_extensions[$key])) { + $all = $this->getPageExtensions($fileExtension); + $list = []; + $fallback = $this->getFallbackLanguages($languageCode, true); + foreach ($fallback as $code) { + $ext = $all[$code] ?? null; + if (null !== $ext) { + $list[$code] = $ext; + } + } + if (!$assoc) { + $list = array_values($list); + } + + $this->fallback_extensions[$key] = $list; + } + + return $this->fallback_extensions[$key]; + } + + /** + * Resets the fallback_languages value. + * + * Useful to re-initialize the pages and change site language at runtime, example: + * + * ``` + * $this->grav['language']->setActive('it'); + * $this->grav['language']->resetFallbackPageExtensions(); + * $this->grav['pages']->init(); + * ``` + * + * @return void + */ + public function resetFallbackPageExtensions() + { + $this->fallback_languages = []; + $this->fallback_extensions = []; + $this->page_extensions = []; + } + + /** + * Gets an array of languages with active first, then fallback languages. + * + * + * @param string|null $languageCode + * @param bool $includeDefault If true, list contains '', which can be used for default + * @return array + */ + public function getFallbackLanguages(string $languageCode = null, bool $includeDefault = false) + { + // Handle default. + if ($languageCode === '' || !$this->enabled()) { + return ['']; + } + + $default = $this->getDefault() ?? 'en'; + $active = $languageCode ?? $this->getActive() ?? $default; + $key = $active . '-' . (int)$includeDefault; + + if (!isset($this->fallback_languages[$key])) { + $fallback = $this->config->get('system.languages.content_fallback.' . $active); + $fallback_languages = []; + + if (null === $fallback && $this->config->get('system.languages.pages_fallback_only', false)) { + user_error('Configuration option `system.languages.pages_fallback_only` is deprecated since Grav 1.7, use `system.languages.content_fallback` instead', E_USER_DEPRECATED); + + // Special fallback list returns itself and all the previous items in reverse order: + // active: 'v2', languages: ['v1', 'v2', 'v3', 'v4'] => ['v2', 'v1', ''] + if ($includeDefault) { + $fallback_languages[''] = ''; + } + foreach ($this->languages as $code) { + $fallback_languages[$code] = $code; + if ($code === $active) { + break; + } + } + $fallback_languages = array_reverse($fallback_languages); + } else { + if (null === $fallback) { + $fallback = [$default]; + } elseif (!is_array($fallback)) { + $fallback = is_string($fallback) && $fallback !== '' ? explode(',', $fallback) : []; + } + array_unshift($fallback, $active); + $fallback = array_unique($fallback); + + foreach ($fallback as $code) { + // Default fallback list has active language followed by default language and extensionless file: + // active: 'fi', default: 'en', languages: ['sv', 'en', 'de', 'fi'] => ['fi', 'en', ''] + $fallback_languages[$code] = $code; + if ($includeDefault && $code === $default) { + $fallback_languages[''] = ''; + } + } + } + + $fallback_languages = array_values($fallback_languages); + + $this->fallback_languages[$key] = $fallback_languages; + } + + return $this->fallback_languages[$key]; + } + + /** + * Ensures the language is valid and supported + * + * @param string $lang + * @return bool + */ + public function validate($lang) + { + return in_array($lang, $this->languages, true); + } + + /** + * Translate a key and possibly arguments into a string using current lang and fallbacks + * + * @param string|array $args The first argument is the lookup key value + * Other arguments can be passed and replaced in the translation with sprintf syntax + * @param array|null $languages + * @param bool $array_support + * @param bool $html_out + * @return string|string[] + */ + public function translate($args, array $languages = null, $array_support = false, $html_out = false) + { + if (is_array($args)) { + $lookup = array_shift($args); + } else { + $lookup = $args; + $args = []; + } + + if (!$this->isDebug()) { + if ($lookup && $this->enabled() && empty($languages)) { + $languages = $this->getTranslatedLanguages(); + } + + $languages = $languages ?: ['en']; + + foreach ((array)$languages as $lang) { + $translation = $this->getTranslation($lang, $lookup, $array_support); + + if ($translation) { + if (is_string($translation) && count($args) >= 1) { + return vsprintf($translation, $args); + } + + return $translation; + } + } + } elseif ($array_support) { + return [$lookup]; + } + + if ($html_out) { + return '' . $lookup . ''; + } + + return $lookup; + } + + /** + * Translate Array + * + * @param string $key + * @param string $index + * @param array|null $languages + * @param bool $html_out + * @return string + */ + public function translateArray($key, $index, $languages = null, $html_out = false) + { + if ($this->isDebug()) { + return $key . '[' . $index . ']'; + } + + if ($key && empty($languages) && $this->enabled()) { + $languages = $this->getTranslatedLanguages(); + } + + $languages = $languages ?: ['en']; + + foreach ((array)$languages as $lang) { + $translation_array = (array)Grav::instance()['languages']->get($lang . '.' . $key, null); + if ($translation_array && array_key_exists($index, $translation_array)) { + return $translation_array[$index]; + } + } + + if ($html_out) { + return '' . $key . '[' . $index . ']'; + } + + return $key . '[' . $index . ']'; + } + + /** + * Lookup the translation text for a given lang and key + * + * @param string $lang lang code + * @param string $key key to lookup with + * @param bool $array_support + * @return string|string[] + */ + public function getTranslation($lang, $key, $array_support = false) + { + if ($this->isDebug()) { + return $key; + } + + $translation = Grav::instance()['languages']->get($lang . '.' . $key, null); + if (!$array_support && is_array($translation)) { + return (string)array_shift($translation); + } + + return $translation; + } + + /** + * Get the browser accepted languages + * + * @param array $accept_langs + * @return array + * @deprecated 1.6 No longer used - using content negotiation. + */ + public function getBrowserLanguages($accept_langs = []) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, no longer used', E_USER_DEPRECATED); + + if (empty($this->http_accept_language)) { + if (empty($accept_langs) && isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { + $accept_langs = $_SERVER['HTTP_ACCEPT_LANGUAGE']; + } else { + return $accept_langs; + } + + $langs = []; + + foreach (explode(',', $accept_langs) as $k => $pref) { + // split $pref again by ';q=' + // and decorate the language entries by inverted position + if (false !== ($i = strpos($pref, ';q='))) { + $langs[substr($pref, 0, $i)] = [(float)substr($pref, $i + 3), -$k]; + } else { + $langs[$pref] = [1, -$k]; + } + } + arsort($langs); + + // no need to undecorate, because we're only interested in the keys + $this->http_accept_language = array_keys($langs); + } + return $this->http_accept_language; + } + + /** + * Accessible wrapper to LanguageCodes + * + * @param string $code + * @param string $type + * @return string|false + */ + public function getLanguageCode($code, $type = 'name') + { + return LanguageCodes::get($code, $type); + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function __debugInfo() + { + $vars = get_object_vars($this); + unset($vars['grav'], $vars['config']); + + return $vars; + } + + /** + * @return array + */ + protected function getTranslatedLanguages(): array + { + if ($this->config->get('system.languages.translations_fallback', true)) { + $languages = $this->getFallbackLanguages(); + } else { + $languages = [$this->getLanguage()]; + } + + $languages[] = 'en'; + + return array_values(array_unique($languages)); + } +} diff --git a/system/src/Grav/Common/Language/LanguageCodes.php b/system/src/Grav/Common/Language/LanguageCodes.php new file mode 100644 index 0000000..fc82cd1 --- /dev/null +++ b/system/src/Grav/Common/Language/LanguageCodes.php @@ -0,0 +1,246 @@ + [ 'name' => 'Afrikaans', 'nativeName' => 'Afrikaans' ], + 'ak' => [ 'name' => 'Akan', 'nativeName' => 'Akan' ], // unverified native name + 'ast' => [ 'name' => 'Asturian', 'nativeName' => 'Asturianu' ], + 'ar' => [ 'name' => 'Arabic', 'nativeName' => 'عربي', 'orientation' => 'rtl'], + 'as' => [ 'name' => 'Assamese', 'nativeName' => 'অসমীয়া' ], + 'be' => [ 'name' => 'Belarusian', 'nativeName' => 'Беларуская' ], + 'bg' => [ 'name' => 'Bulgarian', 'nativeName' => 'Български' ], + 'bn' => [ 'name' => 'Bengali', 'nativeName' => 'বাংলা' ], + 'bn-BD' => [ 'name' => 'Bengali (Bangladesh)', 'nativeName' => 'বাংলা (বাংলাদেশ)' ], + 'bn-IN' => [ 'name' => 'Bengali (India)', 'nativeName' => 'বাংলা (ভারত)' ], + 'br' => [ 'name' => 'Breton', 'nativeName' => 'Brezhoneg' ], + 'bs' => [ 'name' => 'Bosnian', 'nativeName' => 'Bosanski' ], + 'ca' => [ 'name' => 'Catalan', 'nativeName' => 'Català' ], + 'ca-valencia'=> [ 'name' => 'Catalan (Valencian)', 'nativeName' => 'Català (valencià)' ], // not iso-639-1. a=l10n-drivers + 'cs' => [ 'name' => 'Czech', 'nativeName' => 'Čeština' ], + 'cy' => [ 'name' => 'Welsh', 'nativeName' => 'Cymraeg' ], + 'da' => [ 'name' => 'Danish', 'nativeName' => 'Dansk' ], + 'de' => [ 'name' => 'German', 'nativeName' => 'Deutsch' ], + 'de-AT' => [ 'name' => 'German (Austria)', 'nativeName' => 'Deutsch (Österreich)' ], + 'de-CH' => [ 'name' => 'German (Switzerland)', 'nativeName' => 'Deutsch (Schweiz)' ], + 'de-DE' => [ 'name' => 'German (Germany)', 'nativeName' => 'Deutsch (Deutschland)' ], + 'dsb' => [ 'name' => 'Lower Sorbian', 'nativeName' => 'Dolnoserbšćina' ], // iso-639-2 + 'el' => [ 'name' => 'Greek', 'nativeName' => 'Ελληνικά' ], + 'en' => [ 'name' => 'English', 'nativeName' => 'English' ], + 'en-AU' => [ 'name' => 'English (Australian)', 'nativeName' => 'English (Australian)' ], + 'en-CA' => [ 'name' => 'English (Canadian)', 'nativeName' => 'English (Canadian)' ], + 'en-GB' => [ 'name' => 'English (British)', 'nativeName' => 'English (British)' ], + 'en-NZ' => [ 'name' => 'English (New Zealand)', 'nativeName' => 'English (New Zealand)' ], + 'en-US' => [ 'name' => 'English (US)', 'nativeName' => 'English (US)' ], + 'en-ZA' => [ 'name' => 'English (South African)', 'nativeName' => 'English (South African)' ], + 'eo' => [ 'name' => 'Esperanto', 'nativeName' => 'Esperanto' ], + 'es' => [ 'name' => 'Spanish', 'nativeName' => 'Español' ], + 'es-AR' => [ 'name' => 'Spanish (Argentina)', 'nativeName' => 'Español (de Argentina)' ], + 'es-CL' => [ 'name' => 'Spanish (Chile)', 'nativeName' => 'Español (de Chile)' ], + 'es-ES' => [ 'name' => 'Spanish (Spain)', 'nativeName' => 'Español (de España)' ], + 'es-MX' => [ 'name' => 'Spanish (Mexico)', 'nativeName' => 'Español (de México)' ], + 'et' => [ 'name' => 'Estonian', 'nativeName' => 'Eesti keel' ], + 'eu' => [ 'name' => 'Basque', 'nativeName' => 'Euskara' ], + 'fa' => [ 'name' => 'Persian', 'nativeName' => 'فارسی' , 'orientation' => 'rtl' ], + 'fi' => [ 'name' => 'Finnish', 'nativeName' => 'Suomi' ], + 'fj-FJ' => [ 'name' => 'Fijian', 'nativeName' => 'Vosa vaka-Viti' ], + 'fr' => [ 'name' => 'French', 'nativeName' => 'Français' ], + 'fr-CA' => [ 'name' => 'French (Canada)', 'nativeName' => 'Français (Canada)' ], + 'fr-FR' => [ 'name' => 'French (France)', 'nativeName' => 'Français (France)' ], + 'fur' => [ 'name' => 'Friulian', 'nativeName' => 'Furlan' ], + 'fur-IT' => [ 'name' => 'Friulian', 'nativeName' => 'Furlan' ], + 'fy' => [ 'name' => 'Frisian', 'nativeName' => 'Frysk' ], + 'fy-NL' => [ 'name' => 'Frisian', 'nativeName' => 'Frysk' ], + 'ga' => [ 'name' => 'Irish', 'nativeName' => 'Gaeilge' ], + 'ga-IE' => [ 'name' => 'Irish (Ireland)', 'nativeName' => 'Gaeilge (Éire)' ], + 'gd' => [ 'name' => 'Gaelic (Scotland)', 'nativeName' => 'Gàidhlig' ], + 'gl' => [ 'name' => 'Galician', 'nativeName' => 'Galego' ], + 'gu' => [ 'name' => 'Gujarati', 'nativeName' => 'ગુજરાતી' ], + 'gu-IN' => [ 'name' => 'Gujarati', 'nativeName' => 'ગુજરાતી' ], + 'he' => [ 'name' => 'Hebrew', 'nativeName' => 'עברית', 'orientation' => 'rtl' ], + 'hi' => [ 'name' => 'Hindi', 'nativeName' => 'हिन्दी' ], + 'hi-IN' => [ 'name' => 'Hindi (India)', 'nativeName' => 'हिन्दी (भारत)' ], + 'hr' => [ 'name' => 'Croatian', 'nativeName' => 'Hrvatski' ], + 'hsb' => [ 'name' => 'Upper Sorbian', 'nativeName' => 'Hornjoserbsce' ], + 'hu' => [ 'name' => 'Hungarian', 'nativeName' => 'Magyar' ], + 'hy' => [ 'name' => 'Armenian', 'nativeName' => 'Հայերեն' ], + 'hy-AM' => [ 'name' => 'Armenian', 'nativeName' => 'Հայերեն' ], + 'id' => [ 'name' => 'Indonesian', 'nativeName' => 'Bahasa Indonesia' ], + 'is' => [ 'name' => 'Icelandic', 'nativeName' => 'íslenska' ], + 'it' => [ 'name' => 'Italian', 'nativeName' => 'Italiano' ], + 'ja' => [ 'name' => 'Japanese', 'nativeName' => '日本語' ], + 'ja-JP' => [ 'name' => 'Japanese', 'nativeName' => '日本語' ], // not iso-639-1 + 'ka' => [ 'name' => 'Georgian', 'nativeName' => 'ქართული' ], + 'kk' => [ 'name' => 'Kazakh', 'nativeName' => 'Қазақ' ], + 'km' => [ 'name' => 'Khmer', 'nativeName' => 'Khmer' ], + 'kn' => [ 'name' => 'Kannada', 'nativeName' => 'ಕನ್ನಡ' ], + 'ko' => [ 'name' => 'Korean', 'nativeName' => '한국어' ], + 'ku' => [ 'name' => 'Kurdish', 'nativeName' => 'Kurdî' ], + 'la' => [ 'name' => 'Latin', 'nativeName' => 'Latina' ], + 'lb' => [ 'name' => 'Luxembourgish', 'nativeName' => 'Lëtzebuergesch' ], + 'lg' => [ 'name' => 'Luganda', 'nativeName' => 'Luganda' ], + 'lo' => [ 'name' => 'Lao', 'nativeName' => 'Lao' ], + 'lt' => [ 'name' => 'Lithuanian', 'nativeName' => 'Lietuvių' ], + 'lv' => [ 'name' => 'Latvian', 'nativeName' => 'Latviešu' ], + 'mai' => [ 'name' => 'Maithili', 'nativeName' => 'मैथिली মৈথিলী' ], + 'mg' => [ 'name' => 'Malagasy', 'nativeName' => 'Malagasy' ], + 'mi' => [ 'name' => 'Maori (Aotearoa)', 'nativeName' => 'Māori (Aotearoa)' ], + 'mk' => [ 'name' => 'Macedonian', 'nativeName' => 'Македонски' ], + 'ml' => [ 'name' => 'Malayalam', 'nativeName' => 'മലയാളം' ], + 'mn' => [ 'name' => 'Mongolian', 'nativeName' => 'Монгол' ], + 'mr' => [ 'name' => 'Marathi', 'nativeName' => 'मराठी' ], + 'my' => [ 'name' => 'Myanmar (Burmese)', 'nativeName' => 'ဗမာी' ], + 'no' => [ 'name' => 'Norwegian', 'nativeName' => 'Norsk' ], + 'nb' => [ 'name' => 'Norwegian', 'nativeName' => 'Norsk' ], + 'nb-NO' => [ 'name' => 'Norwegian (Bokmål)', 'nativeName' => 'Norsk bokmål' ], + 'ne-NP' => [ 'name' => 'Nepali', 'nativeName' => 'नेपाली' ], + 'nn-NO' => [ 'name' => 'Norwegian (Nynorsk)', 'nativeName' => 'Norsk nynorsk' ], + 'nl' => [ 'name' => 'Dutch', 'nativeName' => 'Nederlands' ], + 'nr' => [ 'name' => 'Ndebele, South', 'nativeName' => 'IsiNdebele' ], + 'nso' => [ 'name' => 'Northern Sotho', 'nativeName' => 'Sepedi' ], + 'oc' => [ 'name' => 'Occitan (Lengadocian)', 'nativeName' => 'Occitan (lengadocian)' ], + 'or' => [ 'name' => 'Oriya', 'nativeName' => 'ଓଡ଼ିଆ' ], + 'pa' => [ 'name' => 'Punjabi', 'nativeName' => 'ਪੰਜਾਬੀ' ], + 'pa-IN' => [ 'name' => 'Punjabi', 'nativeName' => 'ਪੰਜਾਬੀ' ], + 'pl' => [ 'name' => 'Polish', 'nativeName' => 'Polski' ], + 'pt' => [ 'name' => 'Portuguese', 'nativeName' => 'Português' ], + 'pt-BR' => [ 'name' => 'Portuguese (Brazilian)', 'nativeName' => 'Português (do Brasil)' ], + 'pt-PT' => [ 'name' => 'Portuguese (Portugal)', 'nativeName' => 'Português (Europeu)' ], + 'ro' => [ 'name' => 'Romanian', 'nativeName' => 'Română' ], + 'rm' => [ 'name' => 'Romansh', 'nativeName' => 'Rumantsch' ], + 'ru' => [ 'name' => 'Russian', 'nativeName' => 'Русский' ], + 'rw' => [ 'name' => 'Kinyarwanda', 'nativeName' => 'Ikinyarwanda' ], + 'si' => [ 'name' => 'Sinhala', 'nativeName' => 'සිංහල' ], + 'sk' => [ 'name' => 'Slovak', 'nativeName' => 'Slovenčina' ], + 'sl' => [ 'name' => 'Slovenian', 'nativeName' => 'Slovensko' ], + 'son' => [ 'name' => 'Songhai', 'nativeName' => 'Soŋay' ], + 'sq' => [ 'name' => 'Albanian', 'nativeName' => 'Shqip' ], + 'sr' => [ 'name' => 'Serbian', 'nativeName' => 'Српски' ], + 'sr-Latn' => [ 'name' => 'Serbian', 'nativeName' => 'Srpski' ], // follows RFC 4646 + 'ss' => [ 'name' => 'Siswati', 'nativeName' => 'siSwati' ], + 'st' => [ 'name' => 'Southern Sotho', 'nativeName' => 'Sesotho' ], + 'sv' => [ 'name' => 'Swedish', 'nativeName' => 'Svenska' ], + 'sv-SE' => [ 'name' => 'Swedish', 'nativeName' => 'Svenska' ], + 'sw' => [ 'name' => 'Swahili', 'nativeName' => 'Swahili' ], + 'ta' => [ 'name' => 'Tamil', 'nativeName' => 'தமிழ்' ], + 'ta-IN' => [ 'name' => 'Tamil (India)', 'nativeName' => 'தமிழ் (இந்தியா)' ], + 'ta-LK' => [ 'name' => 'Tamil (Sri Lanka)', 'nativeName' => 'தமிழ் (இலங்கை)' ], + 'te' => [ 'name' => 'Telugu', 'nativeName' => 'తెలుగు' ], + 'th' => [ 'name' => 'Thai', 'nativeName' => 'ไทย' ], + 'tlh' => [ 'name' => 'Klingon', 'nativeName' => 'Klingon' ], + 'tn' => [ 'name' => 'Tswana', 'nativeName' => 'Setswana' ], + 'tr' => [ 'name' => 'Turkish', 'nativeName' => 'Türkçe' ], + 'ts' => [ 'name' => 'Tsonga', 'nativeName' => 'Xitsonga' ], + 'tt' => [ 'name' => 'Tatar', 'nativeName' => 'Tatarça' ], + 'tt-RU' => [ 'name' => 'Tatar', 'nativeName' => 'Tatarça' ], + 'uk' => [ 'name' => 'Ukrainian', 'nativeName' => 'Українська' ], + 'ur' => [ 'name' => 'Urdu', 'nativeName' => 'اُردو', 'orientation' => 'rtl' ], + 've' => [ 'name' => 'Venda', 'nativeName' => 'Tshivenḓa' ], + 'vi' => [ 'name' => 'Vietnamese', 'nativeName' => 'Tiếng Việt' ], + 'wo' => [ 'name' => 'Wolof', 'nativeName' => 'Wolof' ], + 'xh' => [ 'name' => 'Xhosa', 'nativeName' => 'isiXhosa' ], + 'yi' => [ 'name' => 'Yiddish', 'nativeName' => 'ייִדיש', 'orientation' => 'rtl' ], + 'ydd' => [ 'name' => 'Yiddish', 'nativeName' => 'ייִדיש', 'orientation' => 'rtl' ], + 'zh' => [ 'name' => 'Chinese (Simplified)', 'nativeName' => '中文 (简体)' ], + 'zh-CN' => [ 'name' => 'Chinese (Simplified)', 'nativeName' => '中文 (简体)' ], + 'zh-TW' => [ 'name' => 'Chinese (Traditional)', 'nativeName' => '正體中文 (繁體)' ], + 'zu' => [ 'name' => 'Zulu', 'nativeName' => 'isiZulu' ] + ]; + + /** + * @param string $code + * @return string|false + */ + public static function getName($code) + { + return static::get($code, 'name'); + } + + /** + * @param string $code + * @return string|false + */ + public static function getNativeName($code) + { + if (isset(static::$codes[$code])) { + return static::get($code, 'nativeName'); + } + + if (preg_match('/[a-zA-Z]{2}-[a-zA-Z]{2}/', $code)) { + return static::get(substr($code, 0, 2), 'nativeName') . ' (' . substr($code, -2) . ')'; + } + + return $code; + } + + /** + * @param string $code + * @return string + */ + public static function getOrientation($code) + { + return static::$codes[$code]['orientation'] ?? 'ltr'; + } + + /** + * @param string $code + * @return bool + */ + public static function isRtl($code) + { + return static::getOrientation($code) === 'rtl'; + } + + /** + * @param array $keys + * @return array + */ + public static function getNames(array $keys) + { + $results = []; + foreach ($keys as $key) { + if (isset(static::$codes[$key])) { + $results[$key] = static::$codes[$key]; + } + } + return $results; + } + + /** + * @param string $code + * @param string $type + * @return string|false + */ + public static function get($code, $type) + { + return static::$codes[$code][$type] ?? false; + } + + /** + * @param bool $native + * @return array + */ + public static function getList($native = true) + { + $list = []; + foreach (static::$codes as $key => $names) { + $list[$key] = $native ? $names['nativeName'] : $names['name']; + } + + return $list; + } +} diff --git a/system/src/Grav/Common/Markdown/Parsedown.php b/system/src/Grav/Common/Markdown/Parsedown.php new file mode 100644 index 0000000..164f264 --- /dev/null +++ b/system/src/Grav/Common/Markdown/Parsedown.php @@ -0,0 +1,43 @@ + $defaults]; + } + $excerpts = new Excerpts($excerpts, $defaults); + user_error(__CLASS__ . '::' . __FUNCTION__ . '($page, $defaults) is deprecated since Grav 1.6.10, use new ' . __CLASS__ . '(new ' . Excerpts::class . '($page, [\'markdown\' => $defaults])) instead.', E_USER_DEPRECATED); + } + + $this->init($excerpts, $defaults); + } +} diff --git a/system/src/Grav/Common/Markdown/ParsedownExtra.php b/system/src/Grav/Common/Markdown/ParsedownExtra.php new file mode 100644 index 0000000..d9259e8 --- /dev/null +++ b/system/src/Grav/Common/Markdown/ParsedownExtra.php @@ -0,0 +1,46 @@ + $defaults]; + } + $excerpts = new Excerpts($excerpts, $defaults); + user_error(__CLASS__ . '::' . __FUNCTION__ . '($page, $defaults) is deprecated since Grav 1.6.10, use new ' . __CLASS__ . '(new ' . Excerpts::class . '($page, [\'markdown\' => $defaults])) instead.', E_USER_DEPRECATED); + } + + parent::__construct(); + + $this->init($excerpts, $defaults); + } +} diff --git a/system/src/Grav/Common/Markdown/ParsedownGravTrait.php b/system/src/Grav/Common/Markdown/ParsedownGravTrait.php new file mode 100644 index 0000000..7804164 --- /dev/null +++ b/system/src/Grav/Common/Markdown/ParsedownGravTrait.php @@ -0,0 +1,319 @@ + $defaults]; + } + $this->excerpts = new Excerpts($excerpts, $defaults); + user_error(__CLASS__ . '::' . __FUNCTION__ . '($page, $defaults) is deprecated since Grav 1.6.10, use ->init(new ' . Excerpts::class . '($page, [\'markdown\' => $defaults])) instead.', E_USER_DEPRECATED); + } else { + $this->excerpts = $excerpts; + } + + $this->BlockTypes['{'][] = 'TwigTag'; + $this->special_chars = ['>' => 'gt', '<' => 'lt', '"' => 'quot']; + + $defaults = $this->excerpts->getConfig(); + + if (isset($defaults['markdown']['auto_line_breaks'])) { + $this->setBreaksEnabled($defaults['markdown']['auto_line_breaks']); + } + if (isset($defaults['markdown']['auto_url_links'])) { + $this->setUrlsLinked($defaults['markdown']['auto_url_links']); + } + if (isset($defaults['markdown']['escape_markup'])) { + $this->setMarkupEscaped($defaults['markdown']['escape_markup']); + } + if (isset($defaults['markdown']['special_chars'])) { + $this->setSpecialChars($defaults['markdown']['special_chars']); + } + + $this->excerpts->fireInitializedEvent($this); + } + + /** + * @return Excerpts + */ + public function getExcerpts() + { + return $this->excerpts; + } + + /** + * Be able to define a new Block type or override an existing one + * + * @param string $type + * @param string $tag + * @param bool $continuable + * @param bool $completable + * @param int|null $index + * @return void + */ + public function addBlockType($type, $tag, $continuable = false, $completable = false, $index = null) + { + $block = &$this->unmarkedBlockTypes; + if ($type) { + if (!isset($this->BlockTypes[$type])) { + $this->BlockTypes[$type] = []; + } + $block = &$this->BlockTypes[$type]; + } + + if (null === $index) { + $block[] = $tag; + } else { + array_splice($block, $index, 0, [$tag]); + } + + if ($continuable) { + $this->continuable_blocks[] = $tag; + } + if ($completable) { + $this->completable_blocks[] = $tag; + } + } + + /** + * Be able to define a new Inline type or override an existing one + * + * @param string $type + * @param string $tag + * @param int|null $index + * @return void + */ + public function addInlineType($type, $tag, $index = null) + { + if (null === $index || !isset($this->InlineTypes[$type])) { + $this->InlineTypes[$type] [] = $tag; + } else { + array_splice($this->InlineTypes[$type], $index, 0, [$tag]); + } + + if (strpos($this->inlineMarkerList, $type) === false) { + $this->inlineMarkerList .= $type; + } + } + + /** + * Overrides the default behavior to allow for plugin-provided blocks to be continuable + * + * @param string $Type + * @return bool + */ + protected function isBlockContinuable($Type) + { + $continuable = in_array($Type, $this->continuable_blocks, true) + || method_exists($this, 'block' . $Type . 'Continue'); + + return $continuable; + } + + /** + * Overrides the default behavior to allow for plugin-provided blocks to be completable + * + * @param string $Type + * @return bool + */ + protected function isBlockCompletable($Type) + { + $completable = in_array($Type, $this->completable_blocks, true) + || method_exists($this, 'block' . $Type . 'Complete'); + + return $completable; + } + + + /** + * Make the element function publicly accessible, Medium uses this to render from Twig + * + * @param array $Element + * @return string markup + */ + public function elementToHtml(array $Element) + { + return $this->element($Element); + } + + /** + * Setter for special chars + * + * @param array $special_chars + * @return $this + */ + public function setSpecialChars($special_chars) + { + $this->special_chars = $special_chars; + + return $this; + } + + /** + * Ensure Twig tags are treated as block level items with no

tags + * + * @param array $line + * @return array|null + */ + protected function blockTwigTag($line) + { + if (preg_match('/(?:{{|{%|{#)(.*)(?:}}|%}|#})/', $line['body'], $matches)) { + return ['markup' => $line['body']]; + } + + return null; + } + + /** + * @param array $excerpt + * @return array|null + */ + protected function inlineSpecialCharacter($excerpt) + { + if ($excerpt['text'][0] === '&' && !preg_match('/^&#?\w+;/', $excerpt['text'])) { + return [ + 'markup' => '&', + 'extent' => 1, + ]; + } + + if (isset($this->special_chars[$excerpt['text'][0]])) { + return [ + 'markup' => '&' . $this->special_chars[$excerpt['text'][0]] . ';', + 'extent' => 1, + ]; + } + + return null; + } + + /** + * @param array $excerpt + * @return array + */ + protected function inlineImage($excerpt) + { + if (preg_match($this->twig_link_regex, $excerpt['text'], $matches)) { + $excerpt['text'] = str_replace($matches[1], '/', $excerpt['text']); + $excerpt = parent::inlineImage($excerpt); + $excerpt['element']['attributes']['src'] = $matches[1]; + $excerpt['extent'] = $excerpt['extent'] + strlen($matches[1]) - 1; + + return $excerpt; + } + + $excerpt['type'] = 'image'; + $excerpt = parent::inlineImage($excerpt); + + // if this is an image process it + if (isset($excerpt['element']['attributes']['src'])) { + $excerpt = $this->excerpts->processImageExcerpt($excerpt); + } + + return $excerpt; + } + + /** + * @param array $excerpt + * @return array + */ + protected function inlineLink($excerpt) + { + $type = $excerpt['type'] ?? 'link'; + + // do some trickery to get around Parsedown requirement for valid URL if its Twig in there + if (preg_match($this->twig_link_regex, $excerpt['text'], $matches)) { + $excerpt['text'] = str_replace($matches[1], '/', $excerpt['text']); + $excerpt = parent::inlineLink($excerpt); + $excerpt['element']['attributes']['href'] = $matches[1]; + $excerpt['extent'] = $excerpt['extent'] + strlen($matches[1]) - 1; + + return $excerpt; + } + + $excerpt = parent::inlineLink($excerpt); + + // if this is a link + if (isset($excerpt['element']['attributes']['href'])) { + $excerpt = $this->excerpts->processLinkExcerpt($excerpt, $type); + } + + return $excerpt; + } + + /** + * For extending this class via plugins + * + * @param string $method + * @param array $args + * @return mixed|null + */ + #[\ReturnTypeWillChange] + public function __call($method, $args) + { + + if (isset($this->plugins[$method]) === true) { + $func = $this->plugins[$method]; + + return call_user_func_array($func, $args); + } elseif (isset($this->{$method}) === true) { + $func = $this->{$method}; + + return call_user_func_array($func, $args); + } + + return null; + } + + public function __set($name, $value) + { + if (is_callable($value)) { + $this->plugins[$name] = $value; + } + + } + + +} diff --git a/system/src/Grav/Common/Media/Interfaces/AudioMediaInterface.php b/system/src/Grav/Common/Media/Interfaces/AudioMediaInterface.php new file mode 100644 index 0000000..c666449 --- /dev/null +++ b/system/src/Grav/Common/Media/Interfaces/AudioMediaInterface.php @@ -0,0 +1,25 @@ +set('this.is.my.nested.variable', $value); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function set($name, $value, $separator = null); +} diff --git a/system/src/Grav/Common/Media/Interfaces/MediaPlayerInterface.php b/system/src/Grav/Common/Media/Interfaces/MediaPlayerInterface.php new file mode 100644 index 0000000..42ba950 --- /dev/null +++ b/system/src/Grav/Common/Media/Interfaces/MediaPlayerInterface.php @@ -0,0 +1,56 @@ + 'user://pages/media']; // Settings from the form field. + * $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings); + * $media->copyUploadedFile($uploadedFile, $filename); + + * @param UploadedFileInterface $uploadedFile + * @param string|null $filename + * @param array|null $settings + * @return string + * @throws RuntimeException + */ + public function checkUploadedFile(UploadedFileInterface $uploadedFile, string $filename = null, array $settings = null): string; + + /** + * Copy uploaded file to the media collection. + * + * WARNING: Always check uploaded file before copying it! + * + * @example + * $filename = null; // Override filename if needed (ignored if randomizing filenames). + * $settings = ['destination' => 'user://pages/media']; // Settings from the form field. + * $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings); + * $media->copyUploadedFile($uploadedFile, $filename); + * + * @param UploadedFileInterface $uploadedFile + * @param string $filename + * @param array|null $settings + * @return void + * @throws RuntimeException + */ + public function copyUploadedFile(UploadedFileInterface $uploadedFile, string $filename, array $settings = null): void; + + /** + * Delete real file from the media collection. + * + * @param string $filename + * @param array|null $settings + * @return void + */ + public function deleteFile(string $filename, array $settings = null): void; + + /** + * Rename file inside the media collection. + * + * @param string $from + * @param string $to + * @param array|null $settings + */ + public function renameFile(string $from, string $to, array $settings = null): void; +} diff --git a/system/src/Grav/Common/Media/Interfaces/VideoMediaInterface.php b/system/src/Grav/Common/Media/Interfaces/VideoMediaInterface.php new file mode 100644 index 0000000..b82206c --- /dev/null +++ b/system/src/Grav/Common/Media/Interfaces/VideoMediaInterface.php @@ -0,0 +1,32 @@ +attributes['controlsList'] = $controlsList; + + return $this; + } + + /** + * Parsedown element for source display mode + * + * @param array $attributes + * @param bool $reset + * @return array + */ + protected function sourceParsedownElement(array $attributes, $reset = true) + { + $location = $this->url($reset); + + return [ + 'name' => 'audio', + 'rawHtml' => 'Your browser does not support the audio tag.', + 'attributes' => $attributes + ]; + } +} diff --git a/system/src/Grav/Common/Media/Traits/ImageDecodingTrait.php b/system/src/Grav/Common/Media/Traits/ImageDecodingTrait.php new file mode 100644 index 0000000..7ea01e9 --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/ImageDecodingTrait.php @@ -0,0 +1,40 @@ +get('system.images.defaults.decoding', 'auto'); + } + + // Validate the provided value (similar to loading) + if ($value !== null && $value !== 'auto') { + $this->attributes['decoding'] = $value; + } + + return $this; + } + +} \ No newline at end of file diff --git a/system/src/Grav/Common/Media/Traits/ImageFetchPriorityTrait.php b/system/src/Grav/Common/Media/Traits/ImageFetchPriorityTrait.php new file mode 100644 index 0000000..af20a97 --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/ImageFetchPriorityTrait.php @@ -0,0 +1,40 @@ +get('system.images.defaults.fetchpriority', 'auto'); + } + + // Validate the provided value (similar to loading and decoding attributes) + if ($value !== null && $value !== 'auto') { + $this->attributes['fetchpriority'] = $value; + } + + return $this; + } + +} \ No newline at end of file diff --git a/system/src/Grav/Common/Media/Traits/ImageLoadingTrait.php b/system/src/Grav/Common/Media/Traits/ImageLoadingTrait.php new file mode 100644 index 0000000..423aba8 --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/ImageLoadingTrait.php @@ -0,0 +1,37 @@ +get('system.images.defaults.loading', 'auto'); + } + if ($value && $value !== 'auto') { + $this->attributes['loading'] = $value; + } + + return $this; + } +} diff --git a/system/src/Grav/Common/Media/Traits/ImageMediaTrait.php b/system/src/Grav/Common/Media/Traits/ImageMediaTrait.php new file mode 100644 index 0000000..59da4cc --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/ImageMediaTrait.php @@ -0,0 +1,428 @@ + [0, 1], + 'forceResize' => [0, 1], + 'cropResize' => [0, 1], + 'crop' => [0, 1, 2, 3], + 'zoomCrop' => [0, 1] + ]; + + /** @var string */ + protected $sizes = '100vw'; + + + /** + * Allows the ability to override the image's pretty name stored in cache + * + * @param string $name + */ + public function setImagePrettyName($name) + { + $this->set('prettyname', $name); + if ($this->image) { + $this->image->setPrettyName($name); + } + } + + /** + * @return string + */ + public function getImagePrettyName() + { + if ($this->get('prettyname')) { + return $this->get('prettyname'); + } + + $basename = $this->get('basename'); + if (preg_match('/[a-z0-9]{40}-(.*)/', $basename, $matches)) { + $basename = $matches[1]; + } + return $basename; + } + + /** + * Simply processes with no extra methods. Useful for triggering events. + * + * @return $this + */ + public function cache() + { + if (!$this->image) { + $this->image(); + } + + return $this; + } + + /** + * Generate alternative image widths, using either an array of integers, or + * a min width, a max width, and a step parameter to fill out the necessary + * widths. Existing image alternatives won't be overwritten. + * + * @param int|int[] $min_width + * @param int $max_width + * @param int $step + * @return $this + */ + public function derivatives($min_width, $max_width = 2500, $step = 200) + { + if (!empty($this->alternatives)) { + $max = max(array_keys($this->alternatives)); + $base = $this->alternatives[$max]; + } else { + $base = $this; + } + + $widths = []; + + if (func_num_args() === 1) { + foreach ((array) func_get_arg(0) as $width) { + if ($width < $base->get('width')) { + $widths[] = $width; + } + } + } else { + $max_width = min($max_width, $base->get('width')); + + for ($width = $min_width; $width < $max_width; $width += $step) { + $widths[] = $width; + } + } + + foreach ($widths as $width) { + // Only generate image alternatives that don't already exist + if (array_key_exists((int) $width, $this->alternatives)) { + continue; + } + + $derivative = MediumFactory::fromFile($base->get('filepath')); + + // It's possible that MediumFactory::fromFile returns null if the + // original image file no longer exists and this class instance was + // retrieved from the page cache + if (null !== $derivative) { + $index = 2; + $alt_widths = array_keys($this->alternatives); + sort($alt_widths); + + foreach ($alt_widths as $i => $key) { + if ($width > $key) { + $index += max($i, 1); + } + } + + $basename = preg_replace('/(@\d+x)?$/', "@{$width}w", $base->get('basename'), 1); + $derivative->setImagePrettyName($basename); + + $ratio = $base->get('width') / $width; + $height = $derivative->get('height') / $ratio; + + $derivative->resize($width, $height); + $derivative->set('width', $width); + $derivative->set('height', $height); + + $this->addAlternative($ratio, $derivative); + } + } + + return $this; + } + + /** + * Clear out the alternatives. + */ + public function clearAlternatives() + { + $this->alternatives = []; + } + + /** + * Sets or gets the quality of the image + * + * @param int|null $quality 0-100 quality + * @return int|$this + */ + public function quality($quality = null) + { + if ($quality) { + if (!$this->image) { + $this->image(); + } + + $this->quality = $quality; + + return $this; + } + + return $this->quality; + } + + /** + * Sets image output format. + * + * @param string $format + * @return $this + */ + public function format($format) + { + if (!$this->image) { + $this->image(); + } + + $this->format = $format; + + return $this; + } + + /** + * Set or get sizes parameter for srcset media action + * + * @param string|null $sizes + * @return string + */ + public function sizes($sizes = null) + { + if ($sizes) { + $this->sizes = $sizes; + + return $this; + } + + return empty($this->sizes) ? '100vw' : $this->sizes; + } + + /** + * Allows to set the width attribute from Markdown or Twig + * Examples: ![Example](myimg.png?width=200&height=400) + * ![Example](myimg.png?resize=100,200&width=100&height=200) + * ![Example](myimg.png?width=auto&height=auto) + * ![Example](myimg.png?width&height) + * {{ page.media['myimg.png'].width().height().html }} + * {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }} + * + * @param string|int $value A value or 'auto' or empty to use the width of the image + * @return $this + */ + public function width($value = 'auto') + { + if (!$value || $value === 'auto') { + $this->attributes['width'] = $this->get('width'); + } else { + $this->attributes['width'] = $value; + } + + return $this; + } + + /** + * Allows to set the height attribute from Markdown or Twig + * Examples: ![Example](myimg.png?width=200&height=400) + * ![Example](myimg.png?resize=100,200&width=100&height=200) + * ![Example](myimg.png?width=auto&height=auto) + * ![Example](myimg.png?width&height) + * {{ page.media['myimg.png'].width().height().html }} + * {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }} + * + * @param string|int $value A value or 'auto' or empty to use the height of the image + * @return $this + */ + public function height($value = 'auto') + { + if (!$value || $value === 'auto') { + $this->attributes['height'] = $this->get('height'); + } else { + $this->attributes['height'] = $value; + } + + return $this; + } + + /** + * Filter image by using user defined filter parameters. + * + * @param string $filter Filter to be used. + * @return $this + */ + public function filter($filter = 'image.filters.default') + { + $filters = (array) $this->get($filter, []); + foreach ($filters as $params) { + $params = (array) $params; + $method = array_shift($params); + $this->__call($method, $params); + } + + return $this; + } + + /** + * Return the image higher quality version + * + * @return ImageMediaInterface|$this the alternative version with higher quality + */ + public function higherQualityAlternative() + { + if ($this->alternatives) { + /** @var ImageMedium $max */ + $max = reset($this->alternatives); + /** @var ImageMedium $alternative */ + foreach ($this->alternatives as $alternative) { + if ($alternative->quality() > $max->quality()) { + $max = $alternative; + } + } + + return $max; + } + + return $this; + } + + /** + * Gets medium image, resets image manipulation operations. + * + * @return $this + */ + protected function image() + { + $locator = Grav::instance()['locator']; + + // Use existing cache folder or if it doesn't exist, create it. + $cacheDir = $locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true); + + // Make sure we free previous image. + unset($this->image); + + /** @var MediaCollectionInterface $media */ + $media = $this->get('media'); + if ($media && method_exists($media, 'getImageFileObject')) { + $this->image = $media->getImageFileObject($this); + } else { + $this->image = ImageFile::open($this->get('filepath')); + } + + $this->image + ->setCacheDir($cacheDir) + ->setActualCacheDir($cacheDir) + ->setPrettyName($this->getImagePrettyName()); + + // Fix orientation if enabled + $config = Grav::instance()['config']; + if ($config->get('system.images.auto_fix_orientation', false) && + extension_loaded('exif') && function_exists('exif_read_data')) { + $this->image->fixOrientation(); + } + + // Set CLS configuration + $this->auto_sizes = $config->get('system.images.cls.auto_sizes', false); + $this->aspect_ratio = $config->get('system.images.cls.aspect_ratio', false); + $this->retina_scale = $config->get('system.images.cls.retina_scale', 1); + + $this->watermark = $config->get('system.images.watermark.watermark_all', false); + + return $this; + } + + /** + * Save the image with cache. + * + * @return string + */ + protected function saveImage() + { + if (!$this->image) { + return parent::path(false); + } + + $this->filter(); + + if (isset($this->result)) { + return $this->result; + } + + if ($this->format === 'guess') { + $extension = strtolower($this->get('extension')); + $this->format($extension); + } + + if (!$this->debug_watermarked && $this->get('debug')) { + $ratio = $this->get('ratio'); + if (!$ratio) { + $ratio = 1; + } + + $locator = Grav::instance()['locator']; + $overlay = $locator->findResource("system://assets/responsive-overlays/{$ratio}x.png") ?: $locator->findResource('system://assets/responsive-overlays/unknown.png'); + $this->image->merge(ImageFile::open($overlay)); + } + + if ($this->watermark) { + $this->watermark(); + } + + return $this->image->cacheFile($this->format, $this->quality, false, [$this->get('width'), $this->get('height'), $this->get('modified')]); + } +} diff --git a/system/src/Grav/Common/Media/Traits/MediaFileTrait.php b/system/src/Grav/Common/Media/Traits/MediaFileTrait.php new file mode 100644 index 0000000..9994538 --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/MediaFileTrait.php @@ -0,0 +1,139 @@ +path(false); + + return file_exists($path); + } + + /** + * Get file modification time for the medium. + * + * @return int|null + */ + public function modified() + { + $path = $this->path(false); + if (!file_exists($path)) { + return null; + } + + return filemtime($path) ?: null; + } + + /** + * Get size of the medium. + * + * @return int + */ + public function size() + { + $path = $this->path(false); + if (!file_exists($path)) { + return 0; + } + + return filesize($path) ?: 0; + } + + /** + * Return PATH to file. + * + * @param bool $reset + * @return string path to file + */ + public function path($reset = true) + { + if ($reset) { + $this->reset(); + } + + return $this->get('url') ?? $this->get('filepath'); + } + + /** + * Return the relative path to file + * + * @param bool $reset + * @return string + */ + public function relativePath($reset = true) + { + if ($reset) { + $this->reset(); + } + + $path = $this->path(false); + $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $path) ?: $path; + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + if ($locator->isStream($output)) { + $output = (string)($locator->findResource($output, false) ?: $locator->findResource($output, false, true)); + } + + return $output; + } + + /** + * Return URL to file. + * + * @param bool $reset + * @return string + */ + public function url($reset = true) + { + $url = $this->get('url'); + if ($url) { + return $url; + } + + $path = $this->relativePath($reset); + + return trim($this->getGrav()['base_url'] . '/' . $this->urlQuerystring($path), '\\'); + } + + /** + * Get the URL with full querystring + * + * @param string $url + * @return string + */ + abstract public function urlQuerystring($url); + + /** + * Reset medium. + * + * @return $this + */ + abstract public function reset(); + + /** + * @return Grav + */ + abstract protected function getGrav(): Grav; +} diff --git a/system/src/Grav/Common/Media/Traits/MediaObjectTrait.php b/system/src/Grav/Common/Media/Traits/MediaObjectTrait.php new file mode 100644 index 0000000..eb45618 --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/MediaObjectTrait.php @@ -0,0 +1,630 @@ +getItems()); + } + + /** + * Set querystring to file modification timestamp (or value provided as a parameter). + * + * @param string|int|null $timestamp + * @return $this + */ + public function setTimestamp($timestamp = null) + { + if (null !== $timestamp) { + $this->timestamp = (string)($timestamp); + } elseif ($this instanceof MediaFileInterface) { + $this->timestamp = (string)$this->modified(); + } else { + $this->timestamp = ''; + } + + return $this; + } + + /** + * Returns an array containing just the metadata + * + * @return array + */ + public function metadata() + { + return $this->metadata; + } + + /** + * Add meta file for the medium. + * + * @param string $filepath + */ + abstract public function addMetaFile($filepath); + + /** + * Add alternative Medium to this Medium. + * + * @param int|float $ratio + * @param MediaObjectInterface $alternative + */ + public function addAlternative($ratio, MediaObjectInterface $alternative) + { + if (!is_numeric($ratio) || $ratio === 0) { + return; + } + + $alternative->set('ratio', $ratio); + $width = $alternative->get('width', 0); + + $this->alternatives[$width] = $alternative; + } + + /** + * @param bool $withDerived + * @return array + */ + public function getAlternatives(bool $withDerived = true): array + { + $alternatives = []; + foreach ($this->alternatives + [$this->get('width', 0) => $this] as $size => $alternative) { + if ($withDerived || $alternative->filename === Utils::basename($alternative->filepath)) { + $alternatives[$size] = $alternative; + } + } + + ksort($alternatives, SORT_NUMERIC); + + return $alternatives; + } + + /** + * Return string representation of the object (html). + * + * @return string + */ + #[\ReturnTypeWillChange] + abstract public function __toString(); + + /** + * Get/set querystring for the file's url + * + * @param string|null $querystring + * @param bool $withQuestionmark + * @return string + */ + public function querystring($querystring = null, $withQuestionmark = true) + { + if (null !== $querystring) { + $this->medium_querystring[] = ltrim($querystring, '?&'); + foreach ($this->alternatives as $alt) { + $alt->querystring($querystring, $withQuestionmark); + } + } + + if (empty($this->medium_querystring)) { + return ''; + } + + // join the strings + $querystring = implode('&', $this->medium_querystring); + // explode all strings + $query_parts = explode('&', $querystring); + // Join them again now ensure the elements are unique + $querystring = implode('&', array_unique($query_parts)); + + return $withQuestionmark ? ('?' . $querystring) : $querystring; + } + + /** + * Get the URL with full querystring + * + * @param string $url + * @return string + */ + public function urlQuerystring($url) + { + $querystring = $this->querystring(); + if (isset($this->timestamp) && !Utils::contains($querystring, $this->timestamp)) { + $querystring = empty($querystring) ? ('?' . $this->timestamp) : ($querystring . '&' . $this->timestamp); + } + + return ltrim($url . $querystring . $this->urlHash(), '/'); + } + + /** + * Get/set hash for the file's url + * + * @param string|null $hash + * @param bool $withHash + * @return string + */ + public function urlHash($hash = null, $withHash = true) + { + if ($hash) { + $this->set('urlHash', ltrim($hash, '#')); + } + + $hash = $this->get('urlHash', ''); + + return $withHash && !empty($hash) ? '#' . $hash : $hash; + } + + /** + * Get an element (is array) that can be rendered by the Parsedown engine + * + * @param string|null $title + * @param string|null $alt + * @param string|null $class + * @param string|null $id + * @param bool $reset + * @return array + */ + public function parsedownElement($title = null, $alt = null, $class = null, $id = null, $reset = true) + { + $attributes = $this->attributes; + $items = $this->getItems(); + + $style = ''; + foreach ($this->styleAttributes as $key => $value) { + if (is_numeric($key)) { // Special case for inline style attributes, refer to style() method + $style .= $value; + } else { + $style .= $key . ': ' . $value . ';'; + } + } + if ($style) { + $attributes['style'] = $style; + } + + if (empty($attributes['title'])) { + if (!empty($title)) { + $attributes['title'] = $title; + } elseif (!empty($items['title'])) { + $attributes['title'] = $items['title']; + } + } + + if (empty($attributes['alt'])) { + if (!empty($alt)) { + $attributes['alt'] = $alt; + } elseif (!empty($items['alt'])) { + $attributes['alt'] = $items['alt']; + } elseif (!empty($items['alt_text'])) { + $attributes['alt'] = $items['alt_text']; + } else { + $attributes['alt'] = ''; + } + } + + if (empty($attributes['class'])) { + if (!empty($class)) { + $attributes['class'] = $class; + } elseif (!empty($items['class'])) { + $attributes['class'] = $items['class']; + } + } + + if (empty($attributes['id'])) { + if (!empty($id)) { + $attributes['id'] = $id; + } elseif (!empty($items['id'])) { + $attributes['id'] = $items['id']; + } + } + + switch ($this->mode) { + case 'text': + $element = $this->textParsedownElement($attributes, false); + break; + case 'thumbnail': + $thumbnail = $this->getThumbnail(); + $element = $thumbnail ? $thumbnail->sourceParsedownElement($attributes, false) : []; + break; + case 'source': + $element = $this->sourceParsedownElement($attributes, false); + break; + default: + $element = []; + } + + if ($reset) { + $this->reset(); + } + + $this->display('source'); + + return $element; + } + + /** + * Reset medium. + * + * @return $this + */ + public function reset() + { + $this->attributes = []; + + return $this; + } + + /** + * Add custom attribute to medium. + * + * @param string $attribute + * @param string $value + * @return $this + */ + public function attribute($attribute = null, $value = '') + { + if (!empty($attribute)) { + $this->attributes[$attribute] = $value; + } + return $this; + } + + /** + * Switch display mode. + * + * @param string $mode + * + * @return MediaObjectInterface|null + */ + public function display($mode = 'source') + { + if ($this->mode === $mode) { + return $this; + } + + $this->mode = $mode; + if ($mode === 'thumbnail') { + $thumbnail = $this->getThumbnail(); + + return $thumbnail ? $thumbnail->reset() : null; + } + + return $this->reset(); + } + + /** + * Helper method to determine if this media item has a thumbnail or not + * + * @param string $type; + * @return bool + */ + public function thumbnailExists($type = 'page') + { + $thumbs = $this->get('thumbnails'); + + return isset($thumbs[$type]); + } + + /** + * Switch thumbnail. + * + * @param string $type + * @return $this + */ + public function thumbnail($type = 'auto') + { + if ($type !== 'auto' && !in_array($type, $this->thumbnailTypes, true)) { + return $this; + } + + if ($this->thumbnailType !== $type) { + $this->_thumbnail = null; + } + + $this->thumbnailType = $type; + + return $this; + } + + /** + * Return URL to file. + * + * @param bool $reset + * @return string + */ + abstract public function url($reset = true); + + /** + * Turn the current Medium into a Link + * + * @param bool $reset + * @param array $attributes + * @return MediaLinkInterface + */ + public function link($reset = true, array $attributes = []) + { + if ($this->mode !== 'source') { + $this->display('source'); + } + + foreach ($this->attributes as $key => $value) { + empty($attributes['data-' . $key]) && $attributes['data-' . $key] = $value; + } + + empty($attributes['href']) && $attributes['href'] = $this->url(); + + return $this->createLink($attributes); + } + + /** + * Turn the current Medium into a Link with lightbox enabled + * + * @param int|null $width + * @param int|null $height + * @param bool $reset + * @return MediaLinkInterface + */ + public function lightbox($width = null, $height = null, $reset = true) + { + $attributes = ['rel' => 'lightbox']; + + if ($width && $height) { + $attributes['data-width'] = $width; + $attributes['data-height'] = $height; + } + + return $this->link($reset, $attributes); + } + + /** + * Add a class to the element from Markdown or Twig + * Example: ![Example](myimg.png?classes=float-left) or ![Example](myimg.png?classes=myclass1,myclass2) + * + * @return $this + */ + public function classes() + { + $classes = func_get_args(); + if (!empty($classes)) { + $this->attributes['class'] = implode(',', $classes); + } + + return $this; + } + + /** + * Add an id to the element from Markdown or Twig + * Example: ![Example](myimg.png?id=primary-img) + * + * @param string $id + * @return $this + */ + public function id($id) + { + if (is_string($id)) { + $this->attributes['id'] = trim($id); + } + + return $this; + } + + /** + * Allows to add an inline style attribute from Markdown or Twig + * Example: ![Example](myimg.png?style=float:left) + * + * @param string $style + * @return $this + */ + public function style($style) + { + $this->styleAttributes[] = rtrim($style, ';') . ';'; + + return $this; + } + + /** + * Allow any action to be called on this medium from twig or markdown + * + * @param string $method + * @param array $args + * @return $this + */ + #[\ReturnTypeWillChange] + public function __call($method, $args) + { + $count = count($args); + if ($count > 1 || ($count === 1 && !empty($args[0]))) { + $method .= '=' . implode(',', array_map(static function ($a) { + if (is_array($a)) { + $a = '[' . implode(',', $a) . ']'; + } + + return rawurlencode($a); + }, $args)); + } + + if (!empty($method)) { + $this->querystring($this->querystring(null, false) . '&' . $method); + } + + return $this; + } + + /** + * Parsedown element for source display mode + * + * @param array $attributes + * @param bool $reset + * @return array + */ + protected function sourceParsedownElement(array $attributes, $reset = true) + { + return $this->textParsedownElement($attributes, $reset); + } + + /** + * Parsedown element for text display mode + * + * @param array $attributes + * @param bool $reset + * @return array + */ + protected function textParsedownElement(array $attributes, $reset = true) + { + if ($reset) { + $this->reset(); + } + + $text = $attributes['title'] ?? ''; + if ($text === '') { + $text = $attributes['alt'] ?? ''; + if ($text === '') { + $text = $this->get('filename'); + } + } + + return [ + 'name' => 'p', + 'attributes' => $attributes, + 'text' => $text + ]; + } + + /** + * Get the thumbnail Medium object + * + * @return ThumbnailImageMedium|null + */ + protected function getThumbnail() + { + if (null === $this->_thumbnail) { + $types = $this->thumbnailTypes; + + if ($this->thumbnailType !== 'auto') { + array_unshift($types, $this->thumbnailType); + } + + foreach ($types as $type) { + $thumb = $this->get("thumbnails.{$type}", false); + if ($thumb) { + $image = $thumb instanceof ThumbnailImageMedium ? $thumb : $this->createThumbnail($thumb); + if($image) { + $image->parent = $this; + $this->_thumbnail = $image; + } + break; + } + } + } + + return $this->_thumbnail; + } + + /** + * Get value by using dot notation for nested arrays/objects. + * + * @example $value = $this->get('this.is.my.nested.variable'); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @param string|null $separator Separator, defaults to '.' + * @return mixed Value. + */ + abstract public function get($name, $default = null, $separator = null); + + /** + * Set value by using dot notation for nested arrays/objects. + * + * @example $data->set('this.is.my.nested.variable', $value); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + abstract public function set($name, $value, $separator = null); + + /** + * @param string $thumb + */ + abstract protected function createThumbnail($thumb); + + /** + * @param array $attributes + * @return MediaLinkInterface + */ + abstract protected function createLink(array $attributes); + + /** + * @return array + */ + abstract protected function getItems(): array; +} diff --git a/system/src/Grav/Common/Media/Traits/MediaPlayerTrait.php b/system/src/Grav/Common/Media/Traits/MediaPlayerTrait.php new file mode 100644 index 0000000..101c6a6 --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/MediaPlayerTrait.php @@ -0,0 +1,113 @@ +attributes['controls'] = 'controls'; + } else { + unset($this->attributes['controls']); + } + + return $this; + } + + /** + * Allows to set the loop attribute + * + * @param bool $status + * @return $this + */ + public function loop($status = false) + { + if ($status) { + $this->attributes['loop'] = 'loop'; + } else { + unset($this->attributes['loop']); + } + + return $this; + } + + /** + * Allows to set the autoplay attribute + * + * @param bool $status + * @return $this + */ + public function autoplay($status = false) + { + if ($status) { + $this->attributes['autoplay'] = 'autoplay'; + } else { + unset($this->attributes['autoplay']); + } + + return $this; + } + + /** + * Allows to set the muted attribute + * + * @param bool $status + * @return $this + */ + public function muted($status = false) + { + if ($status) { + $this->attributes['muted'] = 'muted'; + } else { + unset($this->attributes['muted']); + } + + return $this; + } + + /** + * Allows to set the preload behaviour + * + * @param string|null $preload + * @return $this + */ + public function preload($preload = null) + { + $validPreloadAttrs = ['auto', 'metadata', 'none']; + + if (null === $preload) { + unset($this->attributes['preload']); + } elseif (in_array($preload, $validPreloadAttrs, true)) { + $this->attributes['preload'] = $preload; + } + + return $this; + } + + /** + * Reset player. + */ + public function resetPlayer() + { + $this->attributes['controls'] = 'controls'; + } +} diff --git a/system/src/Grav/Common/Media/Traits/MediaTrait.php b/system/src/Grav/Common/Media/Traits/MediaTrait.php new file mode 100644 index 0000000..09caeaa --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/MediaTrait.php @@ -0,0 +1,153 @@ +getMediaFolder(); + if (!$folder) { + return null; + } + + if (strpos($folder, '://')) { + return $folder; + } + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $user = $locator->findResource('user://'); + if (strpos($folder, $user) === 0) { + return 'user://' . substr($folder, strlen($user)+1); + } + + return null; + } + + /** + * Gets the associated media collection. + * + * @return MediaCollectionInterface|Media Representation of associated media. + */ + public function getMedia() + { + $media = $this->media; + if (null === $media) { + $cache = $this->getMediaCache(); + $cacheKey = md5('media' . $this->getCacheKey()); + + // Use cached media if possible. + $media = $cache->get($cacheKey); + if (!$media instanceof MediaCollectionInterface) { + $media = new Media($this->getMediaFolder(), $this->getMediaOrder(), $this->_loadMedia); + $cache->set($cacheKey, $media); + } + + $this->media = $media; + } + + return $media; + } + + /** + * Sets the associated media collection. + * + * @param MediaCollectionInterface|Media $media Representation of associated media. + * @return $this + */ + protected function setMedia(MediaCollectionInterface $media) + { + $cache = $this->getMediaCache(); + $cacheKey = md5('media' . $this->getCacheKey()); + $cache->set($cacheKey, $media); + + $this->media = $media; + + return $this; + } + + /** + * @return void + */ + protected function freeMedia() + { + $this->media = null; + } + + /** + * Clear media cache. + * + * @return void + */ + protected function clearMediaCache() + { + $cache = $this->getMediaCache(); + $cacheKey = md5('media' . $this->getCacheKey()); + $cache->delete($cacheKey); + + $this->freeMedia(); + } + + /** + * @return CacheInterface + */ + protected function getMediaCache() + { + /** @var Cache $cache */ + $cache = Grav::instance()['cache']; + + return $cache->getSimpleCache(); + } + + /** + * @return string + */ + abstract protected function getCacheKey(): string; +} diff --git a/system/src/Grav/Common/Media/Traits/MediaUploadTrait.php b/system/src/Grav/Common/Media/Traits/MediaUploadTrait.php new file mode 100644 index 0000000..75c00e0 --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/MediaUploadTrait.php @@ -0,0 +1,680 @@ + true, // Whether path is in the media collection path itself. + 'avoid_overwriting' => false, // Do not override existing files (adds datetime postfix if conflict). + 'random_name' => false, // True if name needs to be randomized. + 'accept' => ['image/*'], // Accepted mime types or file extensions. + 'limit' => 10, // Maximum number of files. + 'filesize' => null, // Maximum filesize in MB. + 'destination' => null // Destination path, if empty, exception is thrown. + ]; + + /** + * Create Medium from an uploaded file. + * + * @param UploadedFileInterface $uploadedFile + * @param array $params + * @return Medium|null + */ + public function createFromUploadedFile(UploadedFileInterface $uploadedFile, array $params = []) + { + return MediumFactory::fromUploadedFile($uploadedFile, $params); + } + + /** + * Checks that uploaded file meets the requirements. Returns new filename. + * + * @example + * $filename = null; // Override filename if needed (ignored if randomizing filenames). + * $settings = ['destination' => 'user://pages/media']; // Settings from the form field. + * $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings); + * $media->copyUploadedFile($uploadedFile, $filename); + * + * @param UploadedFileInterface $uploadedFile + * @param string|null $filename + * @param array|null $settings + * @return string + * @throws RuntimeException + */ + public function checkUploadedFile(UploadedFileInterface $uploadedFile, string $filename = null, array $settings = null): string + { + // Check if there is an upload error. + switch ($uploadedFile->getError()) { + case UPLOAD_ERR_OK: + break; + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_FILESIZE_LIMIT'), 400); + case UPLOAD_ERR_PARTIAL: + case UPLOAD_ERR_NO_FILE: + if (!$uploadedFile instanceof FormFlashFile) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.NO_FILES_SENT'), 400); + } + break; + case UPLOAD_ERR_NO_TMP_DIR: + throw new RuntimeException($this->translate('PLUGIN_ADMIN.UPLOAD_ERR_NO_TMP_DIR'), 400); + case UPLOAD_ERR_CANT_WRITE: + case UPLOAD_ERR_EXTENSION: + default: + throw new RuntimeException($this->translate('PLUGIN_ADMIN.UNKNOWN_ERRORS'), 400); + } + + $metadata = [ + 'filename' => $uploadedFile->getClientFilename(), + 'mime' => $uploadedFile->getClientMediaType(), + 'size' => $uploadedFile->getSize(), + ]; + + if ($uploadedFile instanceof FormFlashFile) { + $uploadedFile->checkXss(); + } + + return $this->checkFileMetadata($metadata, $filename, $settings); + } + + /** + * Checks that file metadata meets the requirements. Returns new filename. + * + * @param array $metadata + * @param array|null $settings + * @return string + * @throws RuntimeException + */ + public function checkFileMetadata(array $metadata, string $filename = null, array $settings = null): string + { + // Add the defaults to the settings. + $settings = $this->getUploadSettings($settings); + + // Destination is always needed (but it can be set in defaults). + $self = $settings['self'] ?? false; + if (!isset($settings['destination']) && $self === false) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.DESTINATION_NOT_SPECIFIED'), 400); + } + + if (null === $filename) { + // If no filename is given, use the filename from the uploaded file (path is not allowed). + $folder = ''; + $filename = $metadata['filename'] ?? ''; + } else { + // If caller sets the filename, we will accept any custom path. + $folder = dirname($filename); + if ($folder === '.') { + $folder = ''; + } + $filename = Utils::basename($filename); + } + $extension = Utils::pathinfo($filename, PATHINFO_EXTENSION); + + // Decide which filename to use. + if ($settings['random_name']) { + // Generate random filename if asked for. + $filename = mb_strtolower(Utils::generateRandomString(15) . '.' . $extension); + } + + // Handle conflicting filename if needed. + if ($settings['avoid_overwriting']) { + $destination = $settings['destination']; + if ($destination && $this->fileExists($filename, $destination)) { + $filename = date('YmdHis') . '-' . $filename; + } + } + $filepath = $folder . $filename; + + // Check if the filename is allowed. + if (!Utils::checkFilename($filepath)) { + throw new RuntimeException( + sprintf($this->translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_UPLOAD'), $filepath, $this->translate('PLUGIN_ADMIN.BAD_FILENAME')) + ); + } + + // Check if the file extension is allowed. + $extension = mb_strtolower($extension); + if (!$extension || !$this->getConfig()->get("media.types.{$extension}")) { + // Not a supported type. + throw new RuntimeException($this->translate('PLUGIN_ADMIN.UNSUPPORTED_FILE_TYPE') . ': ' . $extension, 400); + } + + // Calculate maximum file size (from MB). + $filesize = $settings['filesize']; + if ($filesize) { + $max_filesize = $filesize * 1048576; + if ($metadata['size'] > $max_filesize) { + // TODO: use own language string + throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400); + } + } elseif (null === $filesize) { + // Check size against the Grav upload limit. + $grav_limit = Utils::getUploadLimit(); + if ($grav_limit > 0 && $metadata['size'] > $grav_limit) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400); + } + } + + $grav = Grav::instance(); + /** @var MimeTypes $mimeChecker */ + $mimeChecker = $grav['mime']; + + // Handle Accepted file types. Accept can only be mime types (image/png | image/*) or file extensions (.pdf | .jpg) + // Do not trust mime type sent by the browser. + $mime = $metadata['mime'] ?? $mimeChecker->getMimeType($extension); + $validExtensions = $mimeChecker->getExtensions($mime); + if (!in_array($extension, $validExtensions, true)) { + throw new RuntimeException('The mime type does not match to file extension', 400); + } + + $accepted = false; + $errors = []; + foreach ((array)$settings['accept'] as $type) { + // Force acceptance of any file when star notation + if ($type === '*') { + $accepted = true; + break; + } + + $isMime = strstr($type, '/'); + $find = str_replace(['.', '*', '+'], ['\.', '.*', '\+'], $type); + + if ($isMime) { + $match = preg_match('#' . $find . '$#', $mime); + if (!$match) { + // TODO: translate + $errors[] = 'The MIME type "' . $mime . '" for the file "' . $filepath . '" is not an accepted.'; + } else { + $accepted = true; + break; + } + } else { + $match = preg_match('#' . $find . '$#', $filename); + if (!$match) { + // TODO: translate + $errors[] = 'The File Extension for the file "' . $filepath . '" is not an accepted.'; + } else { + $accepted = true; + break; + } + } + } + if (!$accepted) { + throw new RuntimeException(implode('
', $errors), 400); + } + + return $filepath; + } + + /** + * Copy uploaded file to the media collection. + * + * WARNING: Always check uploaded file before copying it! + * + * @example + * $settings = ['destination' => 'user://pages/media']; // Settings from the form field. + * $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings); + * $media->copyUploadedFile($uploadedFile, $filename, $settings); + * + * @param UploadedFileInterface $uploadedFile + * @param string $filename + * @param array|null $settings + * @return void + * @throws RuntimeException + */ + public function copyUploadedFile(UploadedFileInterface $uploadedFile, string $filename, array $settings = null): void + { + // Add the defaults to the settings. + $settings = $this->getUploadSettings($settings); + + $path = $settings['destination'] ?? $this->getPath(); + if (!$path || !$filename) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE'), 400); + } + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + try { + // Clear locator cache to make sure we have up to date information from the filesystem. + $locator->clearCache(); + $this->clearCache(); + + $filesystem = Filesystem::getInstance(false); + + // Calculate path without the retina scaling factor. + $basename = $filesystem->basename($filename); + $pathname = $filesystem->pathname($filename); + + // Get name for the uploaded file. + [$base, $ext,,] = $this->getFileParts($basename); + $name = "{$pathname}{$base}.{$ext}"; + + // Upload file. + if ($uploadedFile instanceof FormFlashFile) { + // FormFlashFile needs some additional logic. + if ($uploadedFile->getError() === \UPLOAD_ERR_OK) { + // Move uploaded file. + $this->doMoveUploadedFile($uploadedFile, $filename, $path); + } elseif (strpos($filename, 'original/') === 0 && !$this->fileExists($filename, $path) && $this->fileExists($basename, $path)) { + // Original image support: override original image if it's the same as the uploaded image. + $this->doCopy($basename, $filename, $path); + } + + // FormFlashFile may also contain metadata. + $metadata = $uploadedFile->getMetaData(); + if ($metadata) { + // TODO: This overrides metadata if used with multiple retina image sizes. + $this->doSaveMetadata(['upload' => $metadata], $name, $path); + } + } else { + // Not a FormFlashFile. + $this->doMoveUploadedFile($uploadedFile, $filename, $path); + } + + // Post-processing: Special content sanitization for SVG. + $mime = Utils::getMimeByFilename($filename); + if (Utils::contains($mime, 'svg', false)) { + $this->doSanitizeSvg($filename, $path); + } + + // Add the new file into the media. + // TODO: This overrides existing media sizes if used with multiple retina image sizes. + $this->doAddUploadedMedium($name, $filename, $path); + + } catch (Exception $e) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE') . $e->getMessage(), 400); + } finally { + // Finally clear media cache. + $locator->clearCache(); + $this->clearCache(); + } + } + + /** + * Delete real file from the media collection. + * + * @param string $filename + * @param array|null $settings + * @return void + * @throws RuntimeException + */ + public function deleteFile(string $filename, array $settings = null): void + { + // Add the defaults to the settings. + $settings = $this->getUploadSettings($settings); + $filesystem = Filesystem::getInstance(false); + + // First check for allowed filename. + $basename = $filesystem->basename($filename); + if (!Utils::checkFilename($basename)) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ": {$this->translate('PLUGIN_ADMIN.BAD_FILENAME')}: " . $filename, 400); + } + + $path = $settings['destination'] ?? $this->getPath(); + if (!$path) { + return; + } + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + $locator->clearCache(); + + $pathname = $filesystem->pathname($filename); + + // Get base name of the file. + [$base, $ext,,] = $this->getFileParts($basename); + $name = "{$pathname}{$base}.{$ext}"; + + // Remove file and all all the associated metadata. + $this->doRemove($name, $path); + + // Finally clear media cache. + $locator->clearCache(); + $this->clearCache(); + } + + /** + * Rename file inside the media collection. + * + * @param string $from + * @param string $to + * @param array|null $settings + */ + public function renameFile(string $from, string $to, array $settings = null): void + { + // Add the defaults to the settings. + $settings = $this->getUploadSettings($settings); + $filesystem = Filesystem::getInstance(false); + + $path = $settings['destination'] ?? $this->getPath(); + if (!$path) { + // TODO: translate error message + throw new RuntimeException('Failed to rename file: Bad destination', 400); + } + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + $locator->clearCache(); + + // Get base name of the file. + $pathname = $filesystem->pathname($from); + + // Remove @2x, @3x and .meta.yaml + [$base, $ext,,] = $this->getFileParts($filesystem->basename($from)); + $from = "{$pathname}{$base}.{$ext}"; + + [$base, $ext,,] = $this->getFileParts($filesystem->basename($to)); + $to = "{$pathname}{$base}.{$ext}"; + + $this->doRename($from, $to, $path); + + // Finally clear media cache. + $locator->clearCache(); + $this->clearCache(); + } + + /** + * Internal logic to move uploaded file. + * + * @param UploadedFileInterface $uploadedFile + * @param string $filename + * @param string $path + */ + protected function doMoveUploadedFile(UploadedFileInterface $uploadedFile, string $filename, string $path): void + { + $filepath = sprintf('%s/%s', $path, $filename); + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + // Do not use streams internally. + if ($locator->isStream($filepath)) { + $filepath = (string)$locator->findResource($filepath, true, true); + } + + Folder::create(dirname($filepath)); + + $uploadedFile->moveTo($filepath); + } + + /** + * Get upload settings. + * + * @param array|null $settings Form field specific settings (override). + * @return array + */ + public function getUploadSettings(?array $settings = null): array + { + return null !== $settings ? $settings + $this->_upload_defaults : $this->_upload_defaults; + } + + /** + * Internal logic to copy file. + * + * @param string $src + * @param string $dst + * @param string $path + */ + protected function doCopy(string $src, string $dst, string $path): void + { + $src = sprintf('%s/%s', $path, $src); + $dst = sprintf('%s/%s', $path, $dst); + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + // Do not use streams internally. + if ($locator->isStream($dst)) { + $dst = (string)$locator->findResource($dst, true, true); + } + + Folder::create(dirname($dst)); + + copy($src, $dst); + } + + /** + * Internal logic to rename file. + * + * @param string $from + * @param string $to + * @param string $path + */ + protected function doRename(string $from, string $to, string $path): void + { + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + $fromPath = $path . '/' . $from; + if ($locator->isStream($fromPath)) { + $fromPath = $locator->findResource($fromPath, true, true); + } + + if (!is_file($fromPath)) { + return; + } + + $mediaPath = dirname($fromPath); + $toPath = $mediaPath . '/' . $to; + if ($locator->isStream($toPath)) { + $toPath = $locator->findResource($toPath, true, true); + } + + if (is_file($toPath)) { + // TODO: translate error message + throw new RuntimeException(sprintf('File could not be renamed: %s already exists (%s)', $to, $mediaPath), 500); + } + + $result = rename($fromPath, $toPath); + if (!$result) { + // TODO: translate error message + throw new RuntimeException(sprintf('File could not be renamed: %s -> %s (%s)', $from, $to, $mediaPath), 500); + } + + // TODO: Add missing logic to handle retina files. + if (is_file($fromPath . '.meta.yaml')) { + $result = rename($fromPath . '.meta.yaml', $toPath . '.meta.yaml'); + if (!$result) { + // TODO: translate error message + throw new RuntimeException(sprintf('Meta could not be renamed: %s -> %s (%s)', $from, $to, $mediaPath), 500); + } + } + } + + /** + * Internal logic to remove file. + * + * @param string $filename + * @param string $path + */ + protected function doRemove(string $filename, string $path): void + { + $filesystem = Filesystem::getInstance(false); + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + // If path doesn't exist, there's nothing to do. + $pathname = $filesystem->pathname($filename); + if (!$this->fileExists($pathname, $path)) { + return; + } + + $folder = $locator->isStream($path) ? (string)$locator->findResource($path, true, true) : $path; + + // Remove requested media file. + if ($this->fileExists($filename, $path)) { + $result = unlink("{$folder}/{$filename}"); + if (!$result) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500); + } + } + + // Remove associated metadata. + $this->doRemoveMetadata($filename, $path); + + // Remove associated 2x, 3x and their .meta.yaml files. + $targetPath = rtrim(sprintf('%s/%s', $folder, $pathname), '/'); + $dir = scandir($targetPath, SCANDIR_SORT_NONE); + if (false === $dir) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500); + } + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + $basename = $filesystem->basename($filename); + $fileParts = (array)$filesystem->pathinfo($filename); + + foreach ($dir as $file) { + $preg_name = preg_quote($fileParts['filename'], '`'); + $preg_ext = preg_quote($fileParts['extension'] ?? '.', '`'); + $preg_filename = preg_quote($basename, '`'); + + if (preg_match("`({$preg_name}@\d+x\.{$preg_ext}(?:\.meta\.yaml)?$|{$preg_filename}\.meta\.yaml)$`", $file)) { + $testPath = $targetPath . '/' . $file; + if ($locator->isStream($testPath)) { + $testPath = (string)$locator->findResource($testPath, true, true); + $locator->clearCache($testPath); + } + + if (is_file($testPath)) { + $result = unlink($testPath); + if (!$result) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500); + } + } + } + } + + $this->hide($filename); + } + + /** + * @param array $metadata + * @param string $filename + * @param string $path + */ + protected function doSaveMetadata(array $metadata, string $filename, string $path): void + { + $filepath = sprintf('%s/%s', $path, $filename); + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + // Do not use streams internally. + if ($locator->isStream($filepath)) { + $filepath = (string)$locator->findResource($filepath, true, true); + } + + $file = YamlFile::instance($filepath . '.meta.yaml'); + $file->save($metadata); + } + + /** + * @param string $filename + * @param string $path + */ + protected function doRemoveMetadata(string $filename, string $path): void + { + $filepath = sprintf('%s/%s', $path, $filename); + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + // Do not use streams internally. + if ($locator->isStream($filepath)) { + $filepath = (string)$locator->findResource($filepath, true); + if (!$filepath) { + return; + } + } + + $file = YamlFile::instance($filepath . '.meta.yaml'); + if ($file->exists()) { + $file->delete(); + } + } + + /** + * @param string $filename + * @param string $path + */ + protected function doSanitizeSvg(string $filename, string $path): void + { + $filepath = sprintf('%s/%s', $path, $filename); + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + // Do not use streams internally. + if ($locator->isStream($filepath)) { + $filepath = (string)$locator->findResource($filepath, true, true); + } + + Security::sanitizeSVG($filepath); + } + + /** + * @param string $name + * @param string $filename + * @param string $path + */ + protected function doAddUploadedMedium(string $name, string $filename, string $path): void + { + $filepath = sprintf('%s/%s', $path, $filename); + $medium = $this->createFromFile($filepath); + $realpath = $path . '/' . $name; + $this->add($realpath, $medium); + } + + /** + * @param string $string + * @return string + */ + protected function translate(string $string): string + { + return $this->getLanguage()->translate($string); + } + + abstract protected function getPath(): ?string; + + abstract protected function getGrav(): Grav; + + abstract protected function getConfig(): Config; + + abstract protected function getLanguage(): Language; + + abstract protected function clearCache(): void; +} diff --git a/system/src/Grav/Common/Media/Traits/StaticResizeTrait.php b/system/src/Grav/Common/Media/Traits/StaticResizeTrait.php new file mode 100644 index 0000000..83fb205 --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/StaticResizeTrait.php @@ -0,0 +1,40 @@ +styleAttributes['width'] = $width . 'px'; + } else { + unset($this->styleAttributes['width']); + } + if ($height) { + $this->styleAttributes['height'] = $height . 'px'; + } else { + unset($this->styleAttributes['height']); + } + + return $this; + } +} diff --git a/system/src/Grav/Common/Media/Traits/ThumbnailMediaTrait.php b/system/src/Grav/Common/Media/Traits/ThumbnailMediaTrait.php new file mode 100644 index 0000000..d65d073 --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/ThumbnailMediaTrait.php @@ -0,0 +1,149 @@ +bubble('parsedownElement', [$title, $alt, $class, $id, $reset]); + } + + /** + * Return HTML markup from the medium. + * + * @param string|null $title + * @param string|null $alt + * @param string|null $class + * @param string|null $id + * @param bool $reset + * @return string + */ + public function html($title = null, $alt = null, $class = null, $id = null, $reset = true) + { + return $this->bubble('html', [$title, $alt, $class, $id, $reset]); + } + + /** + * Switch display mode. + * + * @param string $mode + * + * @return MediaLinkInterface|MediaObjectInterface|null + */ + public function display($mode = 'source') + { + return $this->bubble('display', [$mode], false); + } + + /** + * Switch thumbnail. + * + * @param string $type + * + * @return MediaLinkInterface|MediaObjectInterface + */ + public function thumbnail($type = 'auto') + { + $this->bubble('thumbnail', [$type], false); + + return $this->bubble('getThumbnail', [], false); + } + + /** + * Turn the current Medium into a Link + * + * @param bool $reset + * @param array $attributes + * @return MediaLinkInterface + */ + public function link($reset = true, array $attributes = []) + { + return $this->bubble('link', [$reset, $attributes], false); + } + + /** + * Turn the current Medium into a Link with lightbox enabled + * + * @param int|null $width + * @param int|null $height + * @param bool $reset + * @return MediaLinkInterface + */ + public function lightbox($width = null, $height = null, $reset = true) + { + return $this->bubble('lightbox', [$width, $height, $reset], false); + } + + /** + * Bubble a function call up to either the superclass function or the parent Medium instance + * + * @param string $method + * @param array $arguments + * @param bool $testLinked + * @return mixed + */ + protected function bubble($method, array $arguments = [], $testLinked = true) + { + if (!$testLinked || $this->linked) { + $parent = $this->parent; + if (null === $parent) { + return $this; + } + + $closure = [$parent, $method]; + + if (!is_callable($closure)) { + throw new BadMethodCallException(get_class($parent) . '::' . $method . '() not found.'); + } + + return $closure(...$arguments); + } + + return parent::{$method}(...$arguments); + } +} diff --git a/system/src/Grav/Common/Media/Traits/VideoMediaTrait.php b/system/src/Grav/Common/Media/Traits/VideoMediaTrait.php new file mode 100644 index 0000000..101f42a --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/VideoMediaTrait.php @@ -0,0 +1,68 @@ +attributes['poster'] = $urlImage; + + return $this; + } + + /** + * Allows to set the playsinline attribute + * + * @param bool $status + * @return $this + */ + public function playsinline($status = false) + { + if ($status) { + $this->attributes['playsinline'] = 'playsinline'; + } else { + unset($this->attributes['playsinline']); + } + + return $this; + } + + /** + * Parsedown element for source display mode + * + * @param array $attributes + * @param bool $reset + * @return array + */ + protected function sourceParsedownElement(array $attributes, $reset = true) + { + $location = $this->url($reset); + + return [ + 'name' => 'video', + 'rawHtml' => 'Your browser does not support the video tag.', + 'attributes' => $attributes + ]; + } +} diff --git a/system/src/Grav/Common/Page/Collection.php b/system/src/Grav/Common/Page/Collection.php new file mode 100644 index 0000000..73f52e6 --- /dev/null +++ b/system/src/Grav/Common/Page/Collection.php @@ -0,0 +1,710 @@ + + */ +class Collection extends Iterator implements PageCollectionInterface +{ + /** @var Pages */ + protected $pages; + /** @var array */ + protected $params; + + /** + * Collection constructor. + * + * @param array $items + * @param array $params + * @param Pages|null $pages + */ + public function __construct($items = [], array $params = [], Pages $pages = null) + { + parent::__construct($items); + + $this->params = $params; + $this->pages = $pages ?: Grav::instance()->offsetGet('pages'); + } + + /** + * Get the collection params + * + * @return array + */ + public function params() + { + return $this->params; + } + + /** + * Set parameters to the Collection + * + * @param array $params + * @return $this + */ + public function setParams(array $params) + { + $this->params = array_merge($this->params, $params); + + return $this; + } + + /** + * Add a single page to a collection + * + * @param PageInterface $page + * @return $this + */ + public function addPage(PageInterface $page) + { + $this->items[$page->path()] = ['slug' => $page->slug()]; + + return $this; + } + + /** + * Add a page with path and slug + * + * @param string $path + * @param string $slug + * @return $this + */ + public function add($path, $slug) + { + $this->items[$path] = ['slug' => $slug]; + + return $this; + } + + /** + * + * Create a copy of this collection + * + * @return static + */ + public function copy() + { + return new static($this->items, $this->params, $this->pages); + } + + /** + * + * Merge another collection with the current collection + * + * @param PageCollectionInterface $collection + * @return $this + */ + public function merge(PageCollectionInterface $collection) + { + foreach ($collection as $page) { + $this->addPage($page); + } + + return $this; + } + + /** + * Intersect another collection with the current collection + * + * @param PageCollectionInterface $collection + * @return $this + */ + public function intersect(PageCollectionInterface $collection) + { + $array1 = $this->items; + $array2 = $collection->toArray(); + + $this->items = array_uintersect($array1, $array2, function ($val1, $val2) { + return strcmp($val1['slug'], $val2['slug']); + }); + + return $this; + } + + /** + * Set current page. + */ + public function setCurrent(string $path): void + { + reset($this->items); + + while (($key = key($this->items)) !== null && $key !== $path) { + next($this->items); + } + } + + /** + * Returns current page. + * + * @return PageInterface + */ + #[\ReturnTypeWillChange] + public function current() + { + $current = parent::key(); + + return $this->pages->get($current); + } + + /** + * Returns current slug. + * + * @return mixed + */ + #[\ReturnTypeWillChange] + public function key() + { + $current = parent::current(); + + return $current['slug']; + } + + /** + * Returns the value at specified offset. + * + * @param string $offset + * @return PageInterface|null + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->pages->get($offset) ?: null; + } + + /** + * Split collection into array of smaller collections. + * + * @param int $size + * @return Collection[] + */ + public function batch($size) + { + $chunks = array_chunk($this->items, $size, true); + + $list = []; + foreach ($chunks as $chunk) { + $list[] = new static($chunk, $this->params, $this->pages); + } + + return $list; + } + + /** + * Remove item from the list. + * + * @param PageInterface|string|null $key + * @return $this + * @throws InvalidArgumentException + */ + public function remove($key = null) + { + if ($key instanceof PageInterface) { + $key = $key->path(); + } elseif (null === $key) { + $key = (string)key($this->items); + } + if (!is_string($key)) { + throw new InvalidArgumentException('Invalid argument $key.'); + } + + parent::remove($key); + + return $this; + } + + /** + * Reorder collection. + * + * @param string $by + * @param string $dir + * @param array|null $manual + * @param string|null $sort_flags + * @return $this + */ + public function order($by, $dir = 'asc', $manual = null, $sort_flags = null) + { + $this->items = $this->pages->sortCollection($this, $by, $dir, $manual, $sort_flags); + + return $this; + } + + /** + * Check to see if this item is the first in the collection. + * + * @param string $path + * @return bool True if item is first. + */ + public function isFirst($path): bool + { + return $this->items && $path === array_keys($this->items)[0]; + } + + /** + * Check to see if this item is the last in the collection. + * + * @param string $path + * @return bool True if item is last. + */ + public function isLast($path): bool + { + return $this->items && $path === array_keys($this->items)[count($this->items) - 1]; + } + + /** + * Gets the previous sibling based on current position. + * + * @param string $path + * + * @return PageInterface The previous item. + */ + public function prevSibling($path) + { + return $this->adjacentSibling($path, -1); + } + + /** + * Gets the next sibling based on current position. + * + * @param string $path + * + * @return PageInterface The next item. + */ + public function nextSibling($path) + { + return $this->adjacentSibling($path, 1); + } + + /** + * Returns the adjacent sibling based on a direction. + * + * @param string $path + * @param int $direction either -1 or +1 + * @return PageInterface|Collection The sibling item. + */ + public function adjacentSibling($path, $direction = 1) + { + $values = array_keys($this->items); + $keys = array_flip($values); + + if (array_key_exists($path, $keys)) { + $index = $keys[$path] - $direction; + + return isset($values[$index]) ? $this->offsetGet($values[$index]) : $this; + } + + return $this; + } + + /** + * Returns the item in the current position. + * + * @param string $path the path the item + * @return int|null The index of the current page, null if not found. + */ + public function currentPosition($path): ?int + { + $pos = array_search($path, array_keys($this->items), true); + + return $pos !== false ? $pos : null; + } + + /** + * Returns the items between a set of date ranges of either the page date field (default) or + * an arbitrary datetime page field where start date and end date are optional + * Dates must be passed in as text that strtotime() can process + * http://php.net/manual/en/function.strtotime.php + * + * @param string|null $startDate + * @param string|null $endDate + * @param string|null $field + * @return $this + * @throws Exception + */ + public function dateRange($startDate = null, $endDate = null, $field = null) + { + $start = $startDate ? Utils::date2timestamp($startDate) : null; + $end = $endDate ? Utils::date2timestamp($endDate) : null; + + $date_range = []; + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if (!$page) { + continue; + } + + $date = $field ? strtotime($page->value($field)) : $page->date(); + + if ((!$start || $date >= $start) && (!$end || $date <= $end)) { + $date_range[$path] = $slug; + } + } + + $this->items = $date_range; + + return $this; + } + + /** + * Creates new collection with only visible pages + * + * @return Collection The collection with only visible pages + */ + public function visible() + { + $visible = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && $page->visible()) { + $visible[$path] = $slug; + } + } + $this->items = $visible; + + return $this; + } + + /** + * Creates new collection with only non-visible pages + * + * @return Collection The collection with only non-visible pages + */ + public function nonVisible() + { + $visible = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && !$page->visible()) { + $visible[$path] = $slug; + } + } + $this->items = $visible; + + return $this; + } + + /** + * Creates new collection with only pages + * + * @return Collection The collection with only pages + */ + public function pages() + { + $modular = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && !$page->isModule()) { + $modular[$path] = $slug; + } + } + $this->items = $modular; + + return $this; + } + + /** + * Creates new collection with only modules + * + * @return Collection The collection with only modules + */ + public function modules() + { + $modular = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && $page->isModule()) { + $modular[$path] = $slug; + } + } + $this->items = $modular; + + return $this; + } + + /** + * Alias of pages() + * + * @return Collection The collection with only non-module pages + */ + public function nonModular() + { + $this->pages(); + + return $this; + } + + /** + * Alias of modules() + * + * @return Collection The collection with only modules + */ + public function modular() + { + $this->modules(); + + return $this; + } + + /** + * Creates new collection with only translated pages + * + * @return Collection The collection with only published pages + * @internal + */ + public function translated() + { + $published = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && $page->translated()) { + $published[$path] = $slug; + } + } + $this->items = $published; + + return $this; + } + + /** + * Creates new collection with only untranslated pages + * + * @return Collection The collection with only non-published pages + * @internal + */ + public function nonTranslated() + { + $published = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && !$page->translated()) { + $published[$path] = $slug; + } + } + $this->items = $published; + + return $this; + } + + /** + * Creates new collection with only published pages + * + * @return Collection The collection with only published pages + */ + public function published() + { + $published = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && $page->published()) { + $published[$path] = $slug; + } + } + $this->items = $published; + + return $this; + } + + /** + * Creates new collection with only non-published pages + * + * @return Collection The collection with only non-published pages + */ + public function nonPublished() + { + $published = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && !$page->published()) { + $published[$path] = $slug; + } + } + $this->items = $published; + + return $this; + } + + /** + * Creates new collection with only routable pages + * + * @return Collection The collection with only routable pages + */ + public function routable() + { + $routable = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + + if ($page !== null && $page->routable()) { + $routable[$path] = $slug; + } + } + + $this->items = $routable; + + return $this; + } + + /** + * Creates new collection with only non-routable pages + * + * @return Collection The collection with only non-routable pages + */ + public function nonRoutable() + { + $routable = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && !$page->routable()) { + $routable[$path] = $slug; + } + } + $this->items = $routable; + + return $this; + } + + /** + * Creates new collection with only pages of the specified type + * + * @param string $type + * @return Collection The collection + */ + public function ofType($type) + { + $items = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && $page->template() === $type) { + $items[$path] = $slug; + } + } + + $this->items = $items; + + return $this; + } + + /** + * Creates new collection with only pages of one of the specified types + * + * @param string[] $types + * @return Collection The collection + */ + public function ofOneOfTheseTypes($types) + { + $items = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && in_array($page->template(), $types, true)) { + $items[$path] = $slug; + } + } + + $this->items = $items; + + return $this; + } + + /** + * Creates new collection with only pages of one of the specified access levels + * + * @param array $accessLevels + * @return Collection The collection + */ + public function ofOneOfTheseAccessLevels($accessLevels) + { + $items = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + + if ($page !== null && isset($page->header()->access)) { + if (is_array($page->header()->access)) { + //Multiple values for access + $valid = false; + + foreach ($page->header()->access as $index => $accessLevel) { + if (is_array($accessLevel)) { + foreach ($accessLevel as $innerIndex => $innerAccessLevel) { + if (in_array($innerAccessLevel, $accessLevels, false)) { + $valid = true; + } + } + } else { + if (in_array($index, $accessLevels, false)) { + $valid = true; + } + } + } + if ($valid) { + $items[$path] = $slug; + } + } else { + //Single value for access + if (in_array($page->header()->access, $accessLevels, false)) { + $items[$path] = $slug; + } + } + } + } + + $this->items = $items; + + return $this; + } + + /** + * Get the extended version of this Collection with each page keyed by route + * + * @return array + * @throws Exception + */ + public function toExtendedArray() + { + $items = []; + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + + if ($page !== null) { + $items[$page->route()] = $page->toArray(); + } + } + return $items; + } +} diff --git a/system/src/Grav/Common/Page/Header.php b/system/src/Grav/Common/Page/Header.php new file mode 100644 index 0000000..3dc9df6 --- /dev/null +++ b/system/src/Grav/Common/Page/Header.php @@ -0,0 +1,38 @@ +toArray(); + } +} diff --git a/system/src/Grav/Common/Page/Interfaces/PageCollectionInterface.php b/system/src/Grav/Common/Page/Interfaces/PageCollectionInterface.php new file mode 100644 index 0000000..83e147d --- /dev/null +++ b/system/src/Grav/Common/Page/Interfaces/PageCollectionInterface.php @@ -0,0 +1,310 @@ + + * @extends ArrayAccess + */ +interface PageCollectionInterface extends Traversable, ArrayAccess, Countable, Serializable +{ + /** + * Get the collection params + * + * @return array + */ + public function params(); + + /** + * Set parameters to the Collection + * + * @param array $params + * @return $this + */ + public function setParams(array $params); + + /** + * Add a single page to a collection + * + * @param PageInterface $page + * @return $this + */ + public function addPage(PageInterface $page); + + /** + * Add a page with path and slug + * + * @param string $path + * @param string $slug + * @return $this + */ + //public function add($path, $slug); + + /** + * + * Create a copy of this collection + * + * @return static + */ + public function copy(); + + /** + * + * Merge another collection with the current collection + * + * @param PageCollectionInterface $collection + * @return PageCollectionInterface + * @phpstan-return PageCollectionInterface + */ + public function merge(PageCollectionInterface $collection); + + /** + * Intersect another collection with the current collection + * + * @param PageCollectionInterface $collection + * @return PageCollectionInterface + * @phpstan-return PageCollectionInterface + */ + public function intersect(PageCollectionInterface $collection); + + /** + * Split collection into array of smaller collections. + * + * @param int $size + * @return PageCollectionInterface[] + * @phpstan-return array> + */ + public function batch($size); + + /** + * Remove item from the list. + * + * @param PageInterface|string|null $key + * @return PageCollectionInterface + * @phpstan-return PageCollectionInterface + * @throws InvalidArgumentException + */ + //public function remove($key = null); + + /** + * Reorder collection. + * + * @param string $by + * @param string $dir + * @param array|null $manual + * @param string|null $sort_flags + * @return PageCollectionInterface + * @phpstan-return PageCollectionInterface + */ + public function order($by, $dir = 'asc', $manual = null, $sort_flags = null); + + /** + * Check to see if this item is the first in the collection. + * + * @param string $path + * @return bool True if item is first. + */ + public function isFirst($path): bool; + + /** + * Check to see if this item is the last in the collection. + * + * @param string $path + * @return bool True if item is last. + */ + public function isLast($path): bool; + + /** + * Gets the previous sibling based on current position. + * + * @param string $path + * @return PageInterface The previous item. + * @phpstan-return T + */ + public function prevSibling($path); + + /** + * Gets the next sibling based on current position. + * + * @param string $path + * @return PageInterface The next item. + * @phpstan-return T + */ + public function nextSibling($path); + + /** + * Returns the adjacent sibling based on a direction. + * + * @param string $path + * @param int $direction either -1 or +1 + * @return PageInterface|PageCollectionInterface|false The sibling item. + * @phpstan-return T|false + */ + public function adjacentSibling($path, $direction = 1); + + /** + * Returns the item in the current position. + * + * @param string $path the path the item + * @return int|null The index of the current page, null if not found. + */ + public function currentPosition($path): ?int; + + /** + * Returns the items between a set of date ranges of either the page date field (default) or + * an arbitrary datetime page field where start date and end date are optional + * Dates must be passed in as text that strtotime() can process + * http://php.net/manual/en/function.strtotime.php + * + * @param string|null $startDate + * @param string|null $endDate + * @param string|null $field + * @return PageCollectionInterface + * @phpstan-return PageCollectionInterface + * @throws Exception + */ + public function dateRange($startDate = null, $endDate = null, $field = null); + + /** + * Creates new collection with only visible pages + * + * @return PageCollectionInterface The collection with only visible pages + * @phpstan-return PageCollectionInterface + */ + public function visible(); + + /** + * Creates new collection with only non-visible pages + * + * @return PageCollectionInterface The collection with only non-visible pages + * @phpstan-return PageCollectionInterface + */ + public function nonVisible(); + + /** + * Creates new collection with only pages + * + * @return PageCollectionInterface The collection with only pages + * @phpstan-return PageCollectionInterface + */ + public function pages(); + + /** + * Creates new collection with only modules + * + * @return PageCollectionInterface The collection with only modules + * @phpstan-return PageCollectionInterface + */ + public function modules(); + + /** + * Creates new collection with only modules + * + * @return PageCollectionInterface The collection with only modules + * @phpstan-return PageCollectionInterface + * @deprecated 1.7 Use $this->modules() instead + */ + public function modular(); + + /** + * Creates new collection with only non-module pages + * + * @return PageCollectionInterface The collection with only non-module pages + * @phpstan-return PageCollectionInterface + * @deprecated 1.7 Use $this->pages() instead + */ + public function nonModular(); + + /** + * Creates new collection with only published pages + * + * @return PageCollectionInterface The collection with only published pages + * @phpstan-return PageCollectionInterface + */ + public function published(); + + /** + * Creates new collection with only non-published pages + * + * @return PageCollectionInterface The collection with only non-published pages + * @phpstan-return PageCollectionInterface + */ + public function nonPublished(); + + /** + * Creates new collection with only routable pages + * + * @return PageCollectionInterface The collection with only routable pages + * @phpstan-return PageCollectionInterface + */ + public function routable(); + + /** + * Creates new collection with only non-routable pages + * + * @return PageCollectionInterface The collection with only non-routable pages + * @phpstan-return PageCollectionInterface + */ + public function nonRoutable(); + + /** + * Creates new collection with only pages of the specified type + * + * @param string $type + * @return PageCollectionInterface The collection + * @phpstan-return PageCollectionInterface + */ + public function ofType($type); + + /** + * Creates new collection with only pages of one of the specified types + * + * @param string[] $types + * @return PageCollectionInterface The collection + * @phpstan-return PageCollectionInterface + */ + public function ofOneOfTheseTypes($types); + + /** + * Creates new collection with only pages of one of the specified access levels + * + * @param array $accessLevels + * @return PageCollectionInterface The collection + * @phpstan-return PageCollectionInterface + */ + public function ofOneOfTheseAccessLevels($accessLevels); + + /** + * Converts collection into an array. + * + * @return array + */ + public function toArray(); + + /** + * Get the extended version of this Collection with each page keyed by route + * + * @return array + * @throws Exception + */ + public function toExtendedArray(); +} diff --git a/system/src/Grav/Common/Page/Interfaces/PageContentInterface.php b/system/src/Grav/Common/Page/Interfaces/PageContentInterface.php new file mode 100644 index 0000000..c620941 --- /dev/null +++ b/system/src/Grav/Common/Page/Interfaces/PageContentInterface.php @@ -0,0 +1,267 @@ +true) for example + * + * @param array|null $var New array of name value pairs where the name is the process and value is true or false + * @return array Array of name value pairs where the name is the process and value is true or false + */ + public function process($var = null); + + /** + * Gets and Sets the slug for the Page. The slug is used in the URL routing. If not set it uses + * the parent folder from the path + * + * @param string|null $var New slug, e.g. 'my-blog' + * @return string The slug + */ + public function slug($var = null); + + /** + * Get/set order number of this page. + * + * @param int|null $var New order as a number + * @return string|bool Order in a form of '02.' or false if not set + */ + public function order($var = null); + + /** + * Gets and sets the identifier for this Page object. + * + * @param string|null $var New identifier + * @return string The identifier + */ + public function id($var = null); + + /** + * Gets and sets the modified timestamp. + * + * @param int|null $var New modified unix timestamp + * @return int Modified unix timestamp + */ + public function modified($var = null); + + /** + * Gets and sets the option to show the last_modified header for the page. + * + * @param bool|null $var New last_modified header value + * @return bool Show last_modified header + */ + public function lastModified($var = null); + + /** + * Get/set the folder. + * + * @param string|null $var New folder + * @return string|null The folder + */ + public function folder($var = null); + + /** + * Gets and sets the date for this Page object. This is typically passed in via the page headers + * + * @param string|null $var New string representation of a date + * @return int Unix timestamp representation of the date + */ + public function date($var = null); + + /** + * Gets and sets the date format for this Page object. This is typically passed in via the page headers + * using typical PHP date string structure - http://php.net/manual/en/function.date.php + * + * @param string|null $var New string representation of a date format + * @return string String representation of a date format + */ + public function dateformat($var = null); + + /** + * Gets and sets the taxonomy array which defines which taxonomies this page identifies itself with. + * + * @param array|null $var New array of taxonomies + * @return array An array of taxonomies + */ + public function taxonomy($var = null); + + /** + * Gets the configured state of the processing method. + * + * @param string $process The process name, eg "twig" or "markdown" + * @return bool Whether or not the processing method is enabled for this Page + */ + public function shouldProcess($process); + + /** + * Returns true if page is a module. + * + * @return bool + */ + public function isModule(): bool; + + /** + * Returns whether or not this Page object has a .md file associated with it or if its just a directory. + * + * @return bool True if its a page with a .md file associated + */ + public function isPage(); + + /** + * Returns whether or not this Page object is a directory or a page. + * + * @return bool True if its a directory + */ + public function isDir(); + + /** + * Returns whether the page exists in the filesystem. + * + * @return bool + */ + public function exists(); + + /** + * Returns the blueprint from the page. + * + * @param string $name Name of the Blueprint form. Used by flex only. + * @return Blueprint Returns a Blueprint. + */ + public function getBlueprint(string $name = ''); +} diff --git a/system/src/Grav/Common/Page/Interfaces/PageFormInterface.php b/system/src/Grav/Common/Page/Interfaces/PageFormInterface.php new file mode 100644 index 0000000..3c88ebf --- /dev/null +++ b/system/src/Grav/Common/Page/Interfaces/PageFormInterface.php @@ -0,0 +1,33 @@ + blueprint, ...], where blueprint follows the regular form blueprint format. + * + * @return array + */ + //public function getForms(): array; + + /** + * Add forms to this page. + * + * @param array $new + * @return $this + */ + public function addForms(array $new/*, $override = true*/); + + /** + * Alias of $this->getForms(); + * + * @return array + */ + public function forms();//: array; +} diff --git a/system/src/Grav/Common/Page/Interfaces/PageInterface.php b/system/src/Grav/Common/Page/Interfaces/PageInterface.php new file mode 100644 index 0000000..b48e5be --- /dev/null +++ b/system/src/Grav/Common/Page/Interfaces/PageInterface.php @@ -0,0 +1,25 @@ +save() in order to perform the move. + * + * @param PageInterface $parent New parent page. + * @return $this + */ + public function move(PageInterface $parent); + + /** + * Prepare a copy from the page. Copies also everything that's under the current page. + * + * Returns a new Page object for the copy. + * You need to call $this->save() in order to perform the move. + * + * @param PageInterface $parent New parent page. + * @return $this + */ + public function copy(PageInterface $parent); + + /** + * Get blueprints for the page. + * + * @return Blueprint + */ + public function blueprints(); + + /** + * Get the blueprint name for this page. Use the blueprint form field if set + * + * @return string + */ + public function blueprintName(); + + /** + * Validate page header. + * + * @throws Exception + */ + public function validate(); + + /** + * Filter page header from illegal contents. + */ + public function filter(); + + /** + * Get unknown header variables. + * + * @return array + */ + public function extra(); + + /** + * Convert page to an array. + * + * @return array + */ + public function toArray(); + + /** + * Convert page to YAML encoded string. + * + * @return string + */ + public function toYaml(); + + /** + * Convert page to JSON encoded string. + * + * @return string + */ + public function toJson(); + + /** + * Returns normalized list of name => form pairs. + * + * @return array + */ + public function forms(); + + /** + * @param array $new + */ + public function addForms(array $new); + + /** + * Gets and sets the name field. If no name field is set, it will return 'default.md'. + * + * @param string|null $var The name of this page. + * @return string The name of this page. + */ + public function name($var = null); + + /** + * Returns child page type. + * + * @return string + */ + public function childType(); + + /** + * Gets and sets the template field. This is used to find the correct Twig template file to render. + * If no field is set, it will return the name without the .md extension + * + * @param string|null $var the template name + * @return string the template name + */ + public function template($var = null); + + /** + * Allows a page to override the output render format, usually the extension provided + * in the URL. (e.g. `html`, `json`, `xml`, etc). + * + * @param string|null $var + * @return string + */ + public function templateFormat($var = null); + + /** + * Gets and sets the extension field. + * + * @param string|null $var + * @return string|null + */ + public function extension($var = null); + + /** + * Gets and sets the expires field. If not set will return the default + * + * @param int|null $var The new expires value. + * @return int The expires value + */ + public function expires($var = null); + + /** + * Gets and sets the cache-control property. If not set it will return the default value (null) + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control for more details on valid options + * + * @param string|null $var + * @return string|null + */ + public function cacheControl($var = null); + + /** + * @param bool|null $var + * @return bool + */ + public function ssl($var = null); + + /** + * Returns the state of the debugger override etting for this page + * + * @return bool + */ + public function debugger(); + + /** + * Function to merge page metadata tags and build an array of Metadata objects + * that can then be rendered in the page. + * + * @param array|null $var an Array of metadata values to set + * @return array an Array of metadata values for the page + */ + public function metadata($var = null); + + /** + * Gets and sets the option to show the etag header for the page. + * + * @param bool|null $var show etag header + * @return bool show etag header + */ + public function eTag($var = null): bool; + + /** + * Gets and sets the path to the .md file for this Page object. + * + * @param string|null $var the file path + * @return string|null the file path + */ + public function filePath($var = null); + + /** + * Gets the relative path to the .md file + * + * @return string The relative file path + */ + public function filePathClean(); + + /** + * Gets and sets the order by which any sub-pages should be sorted. + * + * @param string|null $var the order, either "asc" or "desc" + * @return string the order, either "asc" or "desc" + * @deprecated 1.6 + */ + public function orderDir($var = null); + + /** + * Gets and sets the order by which the sub-pages should be sorted. + * + * default - is the order based on the file system, ie 01.Home before 02.Advark + * title - is the order based on the title set in the pages + * date - is the order based on the date set in the pages + * folder - is the order based on the name of the folder with any numerics omitted + * + * @param string|null $var supported options include "default", "title", "date", and "folder" + * @return string supported options include "default", "title", "date", and "folder" + * @deprecated 1.6 + */ + public function orderBy($var = null); + + /** + * Gets the manual order set in the header. + * + * @param string|null $var supported options include "default", "title", "date", and "folder" + * @return array + * @deprecated 1.6 + */ + public function orderManual($var = null); + + /** + * Gets and sets the maxCount field which describes how many sub-pages should be displayed if the + * sub_pages header property is set for this page object. + * + * @param int|null $var the maximum number of sub-pages + * @return int the maximum number of sub-pages + * @deprecated 1.6 + */ + public function maxCount($var = null); + + /** + * Gets and sets the modular var that helps identify this page is a modular child + * + * @param bool|null $var true if modular_twig + * @return bool true if modular_twig + * @deprecated 1.7 Use ->isModule() or ->modularTwig() method instead. + */ + public function modular($var = null); + + /** + * Gets and sets the modular_twig var that helps identify this page as a modular child page that will need + * twig processing handled differently from a regular page. + * + * @param bool|null $var true if modular_twig + * @return bool true if modular_twig + */ + public function modularTwig($var = null); + + /** + * Returns children of this page. + * + * @return PageCollectionInterface|Collection + */ + public function children(); + + /** + * Check to see if this item is the first in an array of sub-pages. + * + * @return bool True if item is first. + */ + public function isFirst(); + + /** + * Check to see if this item is the last in an array of sub-pages. + * + * @return bool True if item is last + */ + public function isLast(); + + /** + * Gets the previous sibling based on current position. + * + * @return PageInterface the previous Page item + */ + public function prevSibling(); + + /** + * Gets the next sibling based on current position. + * + * @return PageInterface the next Page item + */ + public function nextSibling(); + + /** + * Returns the adjacent sibling based on a direction. + * + * @param int $direction either -1 or +1 + * @return PageInterface|false the sibling page + */ + public function adjacentSibling($direction = 1); + + /** + * Helper method to return an ancestor page. + * + * @param bool|null $lookup Name of the parent folder + * @return PageInterface page you were looking for if it exists + */ + public function ancestor($lookup = null); + + /** + * Helper method to return an ancestor page to inherit from. The current + * page object is returned. + * + * @param string $field Name of the parent folder + * @return PageInterface + */ + public function inherited($field); + + /** + * Helper method to return an ancestor field only to inherit from. The + * first occurrence of an ancestor field will be returned if at all. + * + * @param string $field Name of the parent folder + * @return array + */ + public function inheritedField($field); + + /** + * Helper method to return a page. + * + * @param string $url the url of the page + * @param bool $all + * @return PageInterface page you were looking for if it exists + */ + public function find($url, $all = false); + + /** + * Get a collection of pages in the current context. + * + * @param string|array $params + * @param bool $pagination + * @return Collection + * @throws InvalidArgumentException + */ + public function collection($params = 'content', $pagination = true); + + /** + * @param string|array $value + * @param bool $only_published + * @return PageCollectionInterface|Collection + */ + public function evaluate($value, $only_published = true); + + /** + * Returns whether or not the current folder exists + * + * @return bool + */ + public function folderExists(); + + /** + * Gets the Page Unmodified (original) version of the page. + * + * @return PageInterface The original version of the page. + */ + public function getOriginal(); + + /** + * Gets the action. + * + * @return string The Action string. + */ + public function getAction(); +} diff --git a/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php b/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php new file mode 100644 index 0000000..2900266 --- /dev/null +++ b/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php @@ -0,0 +1,180 @@ +page = $page ?? Grav::instance()['page'] ?? null; + + // Add defaults to the configuration. + if (null === $config || !isset($config['markdown'], $config['images'])) { + $c = Grav::instance()['config']; + $config = $config ?? []; + $config += [ + 'markdown' => $c->get('system.pages.markdown', []), + 'images' => $c->get('system.images', []) + ]; + } + + $this->config = $config; + } + + /** + * @return PageInterface|null + */ + public function getPage(): ?PageInterface + { + return $this->page; + } + + /** + * @return array + */ + public function getConfig(): array + { + return $this->config; + } + + /** + * @param object $markdown + * @return void + */ + public function fireInitializedEvent($markdown): void + { + $grav = Grav::instance(); + + $grav->fireEvent('onMarkdownInitialized', new Event(['markdown' => $markdown, 'page' => $this->page])); + } + + /** + * Process a Link excerpt + * + * @param array $excerpt + * @param string $type + * @return array + */ + public function processLinkExcerpt(array $excerpt, string $type = 'link'): array + { + $grav = Grav::instance(); + $url = htmlspecialchars_decode(rawurldecode($excerpt['element']['attributes']['href'])); + $url_parts = $this->parseUrl($url); + + // If there is a query, then parse it and build action calls. + if (isset($url_parts['query'])) { + $actions = array_reduce( + explode('&', $url_parts['query']), + static function ($carry, $item) { + $parts = explode('=', $item, 2); + $value = isset($parts[1]) ? rawurldecode($parts[1]) : true; + $carry[$parts[0]] = $value; + + return $carry; + }, + [] + ); + + // Valid attributes supported. + $valid_attributes = $grav['config']->get('system.pages.markdown.valid_link_attributes') ?? []; + + $skip = []; + // Unless told to not process, go through actions. + if (array_key_exists('noprocess', $actions)) { + $skip = is_bool($actions['noprocess']) ? $actions : explode(',', $actions['noprocess']); + unset($actions['noprocess']); + } + + // Loop through actions for the image and call them. + foreach ($actions as $attrib => $value) { + if (!in_array($attrib, $skip)) { + $key = $attrib; + + if (in_array($attrib, $valid_attributes, true)) { + // support both class and classes. + if ($attrib === 'classes') { + $attrib = 'class'; + } + $excerpt['element']['attributes'][$attrib] = str_replace(',', ' ', $value); + unset($actions[$key]); + } + } + } + + $url_parts['query'] = http_build_query($actions, '', '&', PHP_QUERY_RFC3986); + } + + // If no query elements left, unset query. + if (empty($url_parts['query'])) { + unset($url_parts['query']); + } + + // Set path to / if not set. + if (empty($url_parts['path'])) { + $url_parts['path'] = ''; + } + + // If scheme isn't http(s).. + if (!empty($url_parts['scheme']) && !in_array($url_parts['scheme'], ['http', 'https'])) { + // Handle custom streams. + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + if ($type === 'link' && $locator->isStream($url)) { + $path = $locator->findResource($url, false) ?: $locator->findResource($url, false, true); + $url_parts['path'] = $grav['base_url_relative'] . '/' . $path; + unset($url_parts['stream'], $url_parts['scheme']); + } + + $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts); + + return $excerpt; + } + + // Handle paths and such. + $url_parts = Uri::convertUrl($this->page, $url_parts, $type); + + // Build the URL from the component parts and set it on the element. + $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts); + + return $excerpt; + } + + /** + * Process an image excerpt + * + * @param array $excerpt + * @return array + */ + public function processImageExcerpt(array $excerpt): array + { + $url = htmlspecialchars_decode(urldecode($excerpt['element']['attributes']['src'])); + $url_parts = $this->parseUrl($url); + + $media = null; + $filename = null; + + if (!empty($url_parts['stream'])) { + $filename = $url_parts['scheme'] . '://' . ($url_parts['path'] ?? ''); + + $media = $this->page->getMedia(); + } else { + $grav = Grav::instance(); + /** @var Pages $pages */ + $pages = $grav['pages']; + + // File is also local if scheme is http(s) and host matches. + $local_file = isset($url_parts['path']) + && (empty($url_parts['scheme']) || in_array($url_parts['scheme'], ['http', 'https'], true)) + && (empty($url_parts['host']) || $url_parts['host'] === $grav['uri']->host()); + + if ($local_file) { + $filename = Utils::basename($url_parts['path']); + $folder = dirname($url_parts['path']); + + // Get the local path to page media if possible. + if ($this->page && $folder === $this->page->url(false, false, false)) { + // Get the media objects for this page. + $media = $this->page->getMedia(); + } else { + // see if this is an external page to this one + $base_url = rtrim($grav['base_url_relative'] . $pages->base(), '/'); + $page_route = '/' . ltrim(str_replace($base_url, '', $folder), '/'); + + $ext_page = $pages->find($page_route, true); + if ($ext_page) { + $media = $ext_page->getMedia(); + } else { + $grav->fireEvent('onMediaLocate', new Event(['route' => $page_route, 'media' => &$media])); + } + } + } + } + + // If there is a media file that matches the path referenced.. + if ($media && $filename && isset($media[$filename])) { + // Get the medium object. + /** @var Medium $medium */ + $medium = $media[$filename]; + + // Process operations + $medium = $this->processMediaActions($medium, $url_parts); + $element_excerpt = $excerpt['element']['attributes']; + + $alt = $element_excerpt['alt'] ?? ''; + $title = $element_excerpt['title'] ?? ''; + $class = $element_excerpt['class'] ?? ''; + $id = $element_excerpt['id'] ?? ''; + + $excerpt['element'] = $medium->parsedownElement($title, $alt, $class, $id, true); + } else { + // Not a current page media file, see if it needs converting to relative. + $excerpt['element']['attributes']['src'] = Uri::buildUrl($url_parts); + } + + return $excerpt; + } + + /** + * Process media actions + * + * @param Medium $medium + * @param string|array $url + * @return Medium|Link + */ + public function processMediaActions($medium, $url) + { + $url_parts = is_string($url) ? $this->parseUrl($url) : $url; + $actions = []; + + + // if there is a query, then parse it and build action calls + if (isset($url_parts['query'])) { + $actions = array_reduce( + explode('&', $url_parts['query']), + static function ($carry, $item) { + $parts = explode('=', $item, 2); + $value = $parts[1] ?? null; + $carry[] = ['method' => $parts[0], 'params' => $value]; + + return $carry; + }, + [] + ); + } + + $defaults = $this->config['images']['defaults'] ?? []; + if (count($defaults)) { + foreach ($defaults as $method => $params) { + if (array_search($method, array_column($actions, 'method')) === false) { + $actions[] = [ + 'method' => $method, + 'params' => $params, + ]; + } + } + } + + // loop through actions for the image and call them + foreach ($actions as $action) { + $matches = []; + + if (preg_match('/\[(.*)\]/', $action['params'], $matches)) { + $args = [explode(',', $matches[1])]; + } else { + $args = explode(',', $action['params']); + } + + $medium = call_user_func_array([$medium, $action['method']], $args); + } + + if (isset($url_parts['fragment'])) { + $medium->urlHash($url_parts['fragment']); + } + + return $medium; + } + + /** + * Variation of parse_url() which works also with local streams. + * + * @param string $url + * @return array + */ + protected function parseUrl(string $url) + { + $url_parts = Utils::multibyteParseUrl($url); + + if (isset($url_parts['scheme'])) { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + // Special handling for the streams. + if ($locator->schemeExists($url_parts['scheme'])) { + if (isset($url_parts['host'])) { + // Merge host and path into a path. + $url_parts['path'] = $url_parts['host'] . (isset($url_parts['path']) ? '/' . $url_parts['path'] : ''); + unset($url_parts['host']); + } + + $url_parts['stream'] = true; + } + } + + return $url_parts; + } +} diff --git a/system/src/Grav/Common/Page/Media.php b/system/src/Grav/Common/Page/Media.php new file mode 100644 index 0000000..12c399c --- /dev/null +++ b/system/src/Grav/Common/Page/Media.php @@ -0,0 +1,286 @@ +setPath($path); + $this->media_order = $media_order; + + $this->__wakeup(); + if ($load) { + $this->init(); + } + } + + /** + * Initialize static variables on unserialize. + */ + public function __wakeup() + { + if (null === static::$global) { + // Add fallback to global media. + static::$global = GlobalMedia::getInstance(); + } + } + + /** + * Return raw route to the page. + * + * @return string|null Route to the page or null if media isn't for a page. + */ + public function getRawRoute(): ?string + { + $path = $this->getPath(); + if ($path) { + /** @var Pages $pages */ + $pages = $this->getGrav()['pages']; + $page = $pages->get($path); + if ($page) { + return $page->rawRoute(); + } + } + + return null; + } + + /** + * Return page route. + * + * @return string|null Route to the page or null if media isn't for a page. + */ + public function getRoute(): ?string + { + $path = $this->getPath(); + if ($path) { + /** @var Pages $pages */ + $pages = $this->getGrav()['pages']; + $page = $pages->get($path); + if ($page) { + return $page->route(); + } + } + + return null; + } + + /** + * @param string $offset + * @return bool + */ + #[\ReturnTypeWillChange] + public function offsetExists($offset) + { + return parent::offsetExists($offset) ?: isset(static::$global[$offset]); + } + + /** + * @param string $offset + * @return MediaObjectInterface|null + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return parent::offsetGet($offset) ?: static::$global[$offset]; + } + + /** + * Initialize class. + * + * @return void + */ + protected function init() + { + $path = $this->getPath(); + + // Handle special cases where page doesn't exist in filesystem. + if (!$path || !is_dir($path)) { + return; + } + + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + /** @var Config $config */ + $config = $grav['config']; + + $exif_reader = isset($grav['exif']) ? $grav['exif']->getReader() : null; + $media_types = array_keys($config->get('media.types', [])); + + $iterator = new FilesystemIterator($path, FilesystemIterator::UNIX_PATHS | FilesystemIterator::SKIP_DOTS); + + $media = []; + + foreach ($iterator as $file => $info) { + // Ignore folders and Markdown files. + $filename = $info->getFilename(); + if (!$info->isFile() || $info->getExtension() === 'md' || $filename === 'frontmatter.yaml' || $filename === 'media.json' || strpos($filename, '.') === 0) { + continue; + } + + // Find out what type we're dealing with + [$basename, $ext, $type, $extra] = $this->getFileParts($filename); + + if (!in_array(strtolower($ext), $media_types, true)) { + continue; + } + + if ($type === 'alternative') { + $media["{$basename}.{$ext}"][$type][$extra] = ['file' => $file, 'size' => $info->getSize()]; + } else { + $media["{$basename}.{$ext}"][$type] = ['file' => $file, 'size' => $info->getSize()]; + } + } + + foreach ($media as $name => $types) { + // First prepare the alternatives in case there is no base medium + if (!empty($types['alternative'])) { + /** + * @var string|int $ratio + * @var array $alt + */ + foreach ($types['alternative'] as $ratio => &$alt) { + $alt['file'] = $this->createFromFile($alt['file']); + + if (empty($alt['file'])) { + unset($types['alternative'][$ratio]); + } else { + $alt['file']->set('size', $alt['size']); + } + } + unset($alt); + } + + $file_path = null; + + // Create the base medium + if (empty($types['base'])) { + if (!isset($types['alternative'])) { + continue; + } + + $max = max(array_keys($types['alternative'])); + $medium = $types['alternative'][$max]['file']; + $file_path = $medium->path(); + $medium = MediumFactory::scaledFromMedium($medium, $max, 1)['file']; + } else { + $medium = $this->createFromFile($types['base']['file']); + if ($medium) { + $medium->set('size', $types['base']['size']); + $file_path = $medium->path(); + } + } + + if (empty($medium)) { + continue; + } + + // metadata file + $meta_path = $file_path . '.meta.yaml'; + + if (file_exists($meta_path)) { + $types['meta']['file'] = $meta_path; + } elseif ($file_path && $exif_reader && $medium->get('mime') === 'image/jpeg' && empty($types['meta']) && $config->get('system.media.auto_metadata_exif')) { + $meta = $exif_reader->read($file_path); + + if ($meta) { + $meta_data = $meta->getData(); + $meta_trimmed = array_diff_key($meta_data, array_flip($this->standard_exif)); + if ($meta_trimmed) { + if ($locator->isStream($meta_path)) { + $file = File::instance($locator->findResource($meta_path, true, true)); + } else { + $file = File::instance($meta_path); + } + $file->save(Yaml::dump($meta_trimmed)); + $types['meta']['file'] = $meta_path; + } + } + } + + if (!empty($types['meta'])) { + $medium->addMetaFile($types['meta']['file']); + } + + if (!empty($types['thumb'])) { + // We will not turn it into medium yet because user might never request the thumbnail + // not wasting any resources on that, maybe we should do this for medium in general? + $medium->set('thumbnails.page', $types['thumb']['file']); + } + + // Build missing alternatives + if (!empty($types['alternative'])) { + $alternatives = $types['alternative']; + $max = max(array_keys($alternatives)); + + for ($i=$max; $i > 1; $i--) { + if (isset($alternatives[$i])) { + continue; + } + + $types['alternative'][$i] = MediumFactory::scaledFromMedium($alternatives[$max]['file'], $max, $i); + } + + foreach ($types['alternative'] as $altMedium) { + if ($altMedium['file'] != $medium) { + $altWidth = $altMedium['file']->get('width'); + $medWidth = $medium->get('width'); + if ($altWidth && $medWidth) { + $ratio = $altWidth / $medWidth; + $medium->addAlternative($ratio, $altMedium['file']); + } + } + } + } + + $this->add($name, $medium); + } + } + + /** + * @return string|null + * @deprecated 1.6 Use $this->getPath() instead. + */ + public function path(): ?string + { + return $this->getPath(); + } +} diff --git a/system/src/Grav/Common/Page/Medium/AbstractMedia.php b/system/src/Grav/Common/Page/Medium/AbstractMedia.php new file mode 100644 index 0000000..3f79e4c --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/AbstractMedia.php @@ -0,0 +1,344 @@ +path; + } + + /** + * @param string|null $path + * @return void + */ + public function setPath(?string $path): void + { + $this->path = $path; + } + + /** + * Get medium by filename. + * + * @param string $filename + * @return MediaObjectInterface|null + */ + public function get($filename) + { + return $this->offsetGet($filename); + } + + /** + * Call object as function to get medium by filename. + * + * @param string $filename + * @return mixed + */ + #[\ReturnTypeWillChange] + public function __invoke($filename) + { + return $this->offsetGet($filename); + } + + /** + * Set file modification timestamps (query params) for all the media files. + * + * @param string|int|null $timestamp + * @return $this + */ + public function setTimestamps($timestamp = null) + { + foreach ($this->items as $instance) { + $instance->setTimestamp($timestamp); + } + + return $this; + } + + /** + * Get a list of all media. + * + * @return MediaObjectInterface[] + */ + public function all() + { + $this->items = $this->orderMedia($this->items); + + return $this->items; + } + + /** + * Get a list of all image media. + * + * @return MediaObjectInterface[] + */ + public function images() + { + $this->images = $this->orderMedia($this->images); + + return $this->images; + } + + /** + * Get a list of all video media. + * + * @return MediaObjectInterface[] + */ + public function videos() + { + $this->videos = $this->orderMedia($this->videos); + + return $this->videos; + } + + /** + * Get a list of all audio media. + * + * @return MediaObjectInterface[] + */ + public function audios() + { + $this->audios = $this->orderMedia($this->audios); + + return $this->audios; + } + + /** + * Get a list of all file media. + * + * @return MediaObjectInterface[] + */ + public function files() + { + $this->files = $this->orderMedia($this->files); + + return $this->files; + } + + /** + * @param string $name + * @param MediaObjectInterface|null $file + * @return void + */ + public function add($name, $file) + { + if (null === $file) { + return; + } + + $this->offsetSet($name, $file); + + switch ($file->type) { + case 'image': + $this->images[$name] = $file; + break; + case 'video': + $this->videos[$name] = $file; + break; + case 'audio': + $this->audios[$name] = $file; + break; + default: + $this->files[$name] = $file; + } + } + + /** + * @param string $name + * @return void + */ + public function hide($name) + { + $this->offsetUnset($name); + + unset($this->images[$name], $this->videos[$name], $this->audios[$name], $this->files[$name]); + } + + /** + * Create Medium from a file. + * + * @param string $file + * @param array $params + * @return Medium|null + */ + public function createFromFile($file, array $params = []) + { + return MediumFactory::fromFile($file, $params); + } + + /** + * Create Medium from array of parameters + * + * @param array $items + * @param Blueprint|null $blueprint + * @return Medium|null + */ + public function createFromArray(array $items = [], Blueprint $blueprint = null) + { + return MediumFactory::fromArray($items, $blueprint); + } + + /** + * @param MediaObjectInterface $mediaObject + * @return ImageFile + */ + public function getImageFileObject(MediaObjectInterface $mediaObject): ImageFile + { + return ImageFile::open($mediaObject->get('filepath')); + } + + /** + * Order the media based on the page's media_order + * + * @param array $media + * @return array + */ + protected function orderMedia($media) + { + if (null === $this->media_order) { + $path = $this->getPath(); + if (null !== $path) { + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + $page = $pages->get($path); + if ($page && isset($page->header()->media_order)) { + $this->media_order = array_map('trim', explode(',', $page->header()->media_order)); + } + } + } + + if (!empty($this->media_order) && is_array($this->media_order)) { + $media = Utils::sortArrayByArray($media, $this->media_order); + } else { + ksort($media, SORT_NATURAL | SORT_FLAG_CASE); + } + + return $media; + } + + protected function fileExists(string $filename, string $destination): bool + { + return file_exists("{$destination}/{$filename}"); + } + + /** + * Get filename, extension and meta part. + * + * @param string $filename + * @return array + */ + protected function getFileParts($filename) + { + if (preg_match('/(.*)@(\d+)x\.(.*)$/', $filename, $matches)) { + $name = $matches[1]; + $extension = $matches[3]; + $extra = (int) $matches[2]; + $type = 'alternative'; + + if ($extra === 1) { + $type = 'base'; + $extra = null; + } + } else { + $fileParts = explode('.', $filename); + + $name = array_shift($fileParts); + $extension = null; + $extra = null; + $type = 'base'; + + while (($part = array_shift($fileParts)) !== null) { + if ($part !== 'meta' && $part !== 'thumb') { + if (null !== $extension) { + $name .= '.' . $extension; + } + $extension = $part; + } else { + $type = $part; + $extra = '.' . $part . '.' . implode('.', $fileParts); + break; + } + } + } + + return [$name, $extension, $type, $extra]; + } + + protected function getGrav(): Grav + { + return Grav::instance(); + } + + protected function getConfig(): Config + { + return $this->getGrav()['config']; + } + + protected function getLanguage(): Language + { + return $this->getGrav()['language']; + } + + protected function clearCache(): void + { + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + $locator->clearCache(); + } +} diff --git a/system/src/Grav/Common/Page/Medium/AudioMedium.php b/system/src/Grav/Common/Page/Medium/AudioMedium.php new file mode 100644 index 0000000..f565512 --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/AudioMedium.php @@ -0,0 +1,36 @@ +resetPlayer(); + + return $this; + } +} diff --git a/system/src/Grav/Common/Page/Medium/GlobalMedia.php b/system/src/Grav/Common/Page/Medium/GlobalMedia.php new file mode 100644 index 0000000..3f75ad1 --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/GlobalMedia.php @@ -0,0 +1,150 @@ +resolveStream($offset)); + } + + /** + * @param string $offset + * @return MediaObjectInterface|null + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return parent::offsetGet($offset) ?: $this->addMedium($offset); + } + + /** + * @param string $filename + * @return string|null + */ + protected function resolveStream($filename) + { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if (!$locator->isStream($filename)) { + return null; + } + + return $locator->findResource($filename) ?: null; + } + + /** + * @param string $stream + * @return MediaObjectInterface|null + */ + protected function addMedium($stream) + { + $filename = $this->resolveStream($stream); + if (!$filename) { + return null; + } + + $path = dirname($filename); + [$basename, $ext,, $extra] = $this->getFileParts(Utils::basename($filename)); + $medium = MediumFactory::fromFile($filename); + + if (null === $medium) { + return null; + } + + $medium->set('size', filesize($filename)); + $scale = (int) ($extra ?: 1); + + if ($scale !== 1) { + $altMedium = $medium; + + // Create scaled down regular sized image. + $medium = MediumFactory::scaledFromMedium($altMedium, $scale, 1)['file']; + + if (empty($medium)) { + return null; + } + + // Add original sized image as alternative. + $medium->addAlternative($scale, $altMedium['file']); + + // Locate or generate smaller retina images. + for ($i = $scale-1; $i > 1; $i--) { + $altFilename = "{$path}/{$basename}@{$i}x.{$ext}"; + + if (file_exists($altFilename)) { + $scaled = MediumFactory::fromFile($altFilename); + } else { + $scaled = MediumFactory::scaledFromMedium($altMedium, $scale, $i)['file']; + } + + if ($scaled) { + $medium->addAlternative($i, $scaled); + } + } + } + + $meta = "{$path}/{$basename}.{$ext}.yaml"; + if (file_exists($meta)) { + $medium->addMetaFile($meta); + } + $meta = "{$path}/{$basename}.{$ext}.meta.yaml"; + if (file_exists($meta)) { + $medium->addMetaFile($meta); + } + + $thumb = "{$path}/{$basename}.thumb.{$ext}"; + if (file_exists($thumb)) { + $medium->set('thumbnails.page', $thumb); + } + + $this->add($stream, $medium); + + return $medium; + } +} diff --git a/system/src/Grav/Common/Page/Medium/ImageFile.php b/system/src/Grav/Common/Page/Medium/ImageFile.php new file mode 100644 index 0000000..e5cf6b0 --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/ImageFile.php @@ -0,0 +1,235 @@ +get('system.images.adapter', 'gd'); + try { + $this->setAdapter($adapter); + } catch (Exception $e) { + $grav['log']->error( + 'Image adapter "' . $adapter . '" is not available. Falling back to GD adapter.' + ); + } + } + + /** + * Destruct also image object. + */ + #[\ReturnTypeWillChange] + public function __destruct() + { + $adapter = $this->adapter; + if ($adapter) { + $adapter->deinit(); + } + } + + /** + * Clear previously applied operations + * + * @return void + */ + public function clearOperations() + { + $this->operations = []; + } + + /** + * This is the same as the Gregwar Image class except this one fires a Grav Event on creation of new cached file + * + * @param string $type the image type + * @param int $quality the quality (for JPEG) + * @param bool $actual + * @param array $extras + * @return string + */ + public function cacheFile($type = 'jpg', $quality = 80, $actual = false, $extras = []) + { + if ($type === 'guess') { + $type = $this->guessType(); + } + + if (!$this->forceCache && !count($this->operations) && $type === $this->guessType()) { + return $this->getFilename($this->getFilePath()); + } + + // Computes the hash + $this->hash = $this->getHash($type, $quality, $extras); + + /** @var Config $config */ + $config = Grav::instance()['config']; + + // Seo friendly image names + $seofriendly = $config->get('system.images.seofriendly', false); + + if ($seofriendly) { + $mini_hash = substr($this->hash, 0, 4) . substr($this->hash, -4); + $cacheFile = "{$this->prettyName}-{$mini_hash}"; + } else { + $cacheFile = "{$this->hash}-{$this->prettyName}"; + } + + $cacheFile .= '.' . $type; + + // If the files does not exists, save it + $image = $this; + + // Target file should be younger than all the current image + // dependencies + $conditions = array( + 'younger-than' => $this->getDependencies() + ); + + // The generating function + $generate = function ($target) use ($image, $type, $quality) { + $result = $image->save($target, $type, $quality); + + if ($result !== $target) { + throw new GenerationError($result); + } + + Grav::instance()->fireEvent('onImageMediumSaved', new Event(['image' => $target])); + }; + + // Asking the cache for the cacheFile + try { + $perms = $config->get('system.images.cache_perms', '0755'); + $perms = octdec($perms); + $file = $this->getCacheSystem()->setDirectoryMode($perms)->getOrCreateFile($cacheFile, $conditions, $generate, $actual); + } catch (GenerationError $e) { + $file = $e->getNewFile(); + } + + // Nulling the resource + $adapter = $this->getAdapter(); + $adapter->setSource(new Source\File($file)); + $adapter->deinit(); + + if ($actual) { + return $file; + } + + return $this->getFilename($file); + } + + /** + * Gets the hash. + * + * @param string $type + * @param int $quality + * @param array $extras + * @return string + */ + public function getHash($type = 'guess', $quality = 80, $extras = []) + { + if (null === $this->hash) { + $this->generateHash($type, $quality, $extras); + } + + return $this->hash; + } + + /** + * Generates the hash. + * + * @param string $type + * @param int $quality + * @param array $extras + */ + public function generateHash($type = 'guess', $quality = 80, $extras = []) + { + $inputInfos = $this->source->getInfos(); + + $data = [ + $inputInfos, + $this->serializeOperations(), + $type, + $quality, + $extras + ]; + + $this->hash = sha1(serialize($data)); + } + + /** + * Read exif rotation from file and apply it. + */ + public function fixOrientation() + { + if (!extension_loaded('exif')) { + throw new RuntimeException('You need to EXIF PHP Extension to use this function'); + } + + if (!file_exists($this->source->getInfos()) || !in_array(exif_imagetype($this->source->getInfos()), [IMAGETYPE_JPEG, IMAGETYPE_TIFF_II, IMAGETYPE_TIFF_MM], true)) { + return $this; + } + + // resolve any streams + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $filepath = $this->source->getInfos(); + if ($locator->isStream($filepath)) { + $filepath = $locator->findResource($this->source->getInfos(), true, true); + } + + // Make sure file exists + if (!file_exists($filepath)) { + return $this; + } + + try { + $exif = @exif_read_data($filepath); + } catch (Exception $e) { + Grav::instance()['log']->error($filepath . ' - ' . $e->getMessage()); + return $this; + } + + if ($exif === false || !array_key_exists('Orientation', $exif)) { + return $this; + } + + return $this->applyExifOrientation($exif['Orientation']); + } +} diff --git a/system/src/Grav/Common/Page/Medium/ImageMedium.php b/system/src/Grav/Common/Page/Medium/ImageMedium.php new file mode 100644 index 0000000..7122b5f --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/ImageMedium.php @@ -0,0 +1,499 @@ +getGrav()['config']; + + $this->thumbnailTypes = ['page', 'media', 'default']; + $this->default_quality = $config->get('system.images.default_image_quality', 85); + $this->def('debug', $config->get('system.images.debug')); + + $path = $this->get('filepath'); + if (!$path || !file_exists($path) || !filesize($path)) { + return; + } + + $this->set('thumbnails.media', $path); + + if (!($this->offsetExists('width') && $this->offsetExists('height') && $this->offsetExists('mime'))) { + $image_info = getimagesize($path); + if ($image_info) { + $this->def('width', (int) $image_info[0]); + $this->def('height', (int) $image_info[1]); + $this->def('mime', $image_info['mime']); + } + } + + $this->reset(); + + if ($config->get('system.images.cache_all', false)) { + $this->cache(); + } + } + + /** + * @return array + */ + public function getMeta(): array + { + return [ + 'width' => $this->width, + 'height' => $this->height, + ] + parent::getMeta(); + } + + /** + * Also unset the image on destruct. + */ + #[\ReturnTypeWillChange] + public function __destruct() + { + unset($this->image); + } + + /** + * Also clone image. + */ + #[\ReturnTypeWillChange] + public function __clone() + { + if ($this->image) { + $this->image = clone $this->image; + } + + parent::__clone(); + } + + /** + * Reset image. + * + * @return $this + */ + public function reset() + { + parent::reset(); + + if ($this->image) { + $this->image(); + $this->medium_querystring = []; + $this->filter(); + $this->clearAlternatives(); + } + + $this->format = 'guess'; + $this->quality = $this->default_quality; + + $this->debug_watermarked = false; + + $config = $this->getGrav()['config']; + // Set CLS configuration + $this->auto_sizes = $config->get('system.images.cls.auto_sizes', false); + $this->aspect_ratio = $config->get('system.images.cls.aspect_ratio', false); + $this->retina_scale = $config->get('system.images.cls.retina_scale', 1); + + return $this; + } + + /** + * Add meta file for the medium. + * + * @param string $filepath + * @return $this + */ + public function addMetaFile($filepath) + { + parent::addMetaFile($filepath); + + // Apply filters in meta file + $this->reset(); + + return $this; + } + + /** + * Return PATH to image. + * + * @param bool $reset + * @return string path to image + */ + public function path($reset = true) + { + $output = $this->saveImage(); + + if ($reset) { + $this->reset(); + } + + return $output; + } + + /** + * Return URL to image. + * + * @param bool $reset + * @return string + */ + public function url($reset = true) + { + $grav = $this->getGrav(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $image_path = (string)($locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true)); + $saved_image_path = $this->saved_image_path = $this->saveImage(); + + $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $saved_image_path) ?: $saved_image_path; + + if ($locator->isStream($output)) { + $output = (string)($locator->findResource($output, false) ?: $locator->findResource($output, false, true)); + } + + if (Utils::startsWith($output, $image_path)) { + $image_dir = $locator->findResource('cache://images', false); + $output = '/' . $image_dir . preg_replace('|^' . preg_quote($image_path, '|') . '|', '', $output); + } + + if ($reset) { + $this->reset(); + } + + return trim($grav['base_url'] . '/' . $this->urlQuerystring($output), '\\'); + } + + /** + * Return srcset string for this Medium and its alternatives. + * + * @param bool $reset + * @return string + */ + public function srcset($reset = true) + { + if (empty($this->alternatives)) { + if ($reset) { + $this->reset(); + } + + return ''; + } + + $srcset = []; + foreach ($this->alternatives as $ratio => $medium) { + $srcset[] = $medium->url($reset) . ' ' . $medium->get('width') . 'w'; + } + $srcset[] = str_replace(' ', '%20', $this->url($reset)) . ' ' . $this->get('width') . 'w'; + + return implode(', ', $srcset); + } + + /** + * Parsedown element for source display mode + * + * @param array $attributes + * @param bool $reset + * @return array + */ + public function sourceParsedownElement(array $attributes, $reset = true) + { + empty($attributes['src']) && $attributes['src'] = $this->url(false); + + $srcset = $this->srcset($reset); + if ($srcset) { + empty($attributes['srcset']) && $attributes['srcset'] = $srcset; + $attributes['sizes'] = $this->sizes(); + } + + if ($this->saved_image_path && $this->auto_sizes) { + if (!array_key_exists('height', $this->attributes) && !array_key_exists('width', $this->attributes)) { + $info = getimagesize($this->saved_image_path); + $width = (int)$info[0]; + $height = (int)$info[1]; + + $scaling_factor = $this->retina_scale > 0 ? $this->retina_scale : 1; + $attributes['width'] = (int)($width / $scaling_factor); + $attributes['height'] = (int)($height / $scaling_factor); + + if ($this->aspect_ratio) { + $style = ($attributes['style'] ?? ' ') . "--aspect-ratio: $width/$height;"; + $attributes['style'] = trim($style); + } + } + } + + return ['name' => 'img', 'attributes' => $attributes]; + } + + /** + * Turn the current Medium into a Link + * + * @param bool $reset + * @param array $attributes + * @return MediaLinkInterface + */ + public function link($reset = true, array $attributes = []) + { + $attributes['href'] = $this->url(false); + $srcset = $this->srcset(false); + if ($srcset) { + $attributes['data-srcset'] = $srcset; + } + + return parent::link($reset, $attributes); + } + + /** + * Turn the current Medium into a Link with lightbox enabled + * + * @param int $width + * @param int $height + * @param bool $reset + * @return MediaLinkInterface + */ + public function lightbox($width = null, $height = null, $reset = true) + { + if ($this->mode !== 'source') { + $this->display('source'); + } + + if ($width && $height) { + $this->__call('cropResize', [(int) $width, (int) $height]); + } + + return parent::lightbox($width, $height, $reset); + } + + /** + * @param string $enabled + * @return $this + */ + public function autoSizes($enabled = 'true') + { + $this->auto_sizes = $enabled === 'true' ?: false; + + return $this; + } + + /** + * @param string $enabled + * @return $this + */ + public function aspectRatio($enabled = 'true') + { + $this->aspect_ratio = $enabled === 'true' ?: false; + + return $this; + } + + /** + * @param int $scale + * @return $this + */ + public function retinaScale($scale = 1) + { + $this->retina_scale = (int)$scale; + + return $this; + } + + /** + * @param string|null $image + * @param string|null $position + * @param int|float|null $scale + * @return $this + */ + public function watermark($image = null, $position = null, $scale = null) + { + $grav = $this->getGrav(); + + $locator = $grav['locator']; + $config = $grav['config']; + + $args = func_get_args(); + + $file = $args[0] ?? '1'; // using '1' because of markdown. doing ![](image.jpg?watermark) returns $args[0]='1'; + $file = $file === '1' ? $config->get('system.images.watermark.image') : $args[0]; + + $watermark = $locator->findResource($file); + $watermark = ImageFile::open($watermark); + + // Scaling operations + $scale = ($scale ?? $config->get('system.images.watermark.scale', 100)) / 100; + $wwidth = (int) ($this->get('width') * $scale); + $wheight = (int) ($this->get('height') * $scale); + $watermark->resize($wwidth, $wheight); + + // Position operations + $position = !empty($args[1]) ? explode('-', $args[1]) : ['center', 'center']; // todo change to config + $positionY = $position[0] ?? $config->get('system.images.watermark.position_y', 'center'); + $positionX = $position[1] ?? $config->get('system.images.watermark.position_x', 'center'); + + switch ($positionY) + { + case 'top': + $positionY = 0; + break; + + case 'bottom': + $positionY = (int)$this->get('height')-$wheight; + break; + + case 'center': + $positionY = ((int)$this->get('height')/2) - ($wheight/2); + break; + } + + switch ($positionX) + { + case 'left': + $positionX = 0; + break; + + case 'right': + $positionX = (int) ($this->get('width')-$wwidth); + break; + + case 'center': + $positionX = (int) (($this->get('width')/2) - ($wwidth/2)); + break; + } + + $this->__call('merge', [$watermark,$positionX, $positionY]); + + return $this; + } + + /** + * Handle this commonly used variant + * + * @return $this + */ + public function cropZoom() + { + $this->__call('zoomCrop', func_get_args()); + + return $this; + } + + /** + * Add a frame to image + * + * @return $this + */ + public function addFrame(int $border = 10, string $color = '0x000000') + { + if($border > 0 && preg_match('/^0x[a-f0-9]{6}$/i', $color)) { // $border must be an integer and bigger than 0; $color must be formatted as an HEX value (0x??????). + $image = ImageFile::open($this->path()); + } + else { + return $this; + } + + $dst_width = (int) ($image->width()+2*$border); + $dst_height = (int) ($image->height()+2*$border); + + $frame = ImageFile::create($dst_width, $dst_height); + + $frame->__call('fill', [$color]); + + $this->image = $frame; + + $this->__call('merge', [$image, $border, $border]); + + $this->saveImage(); + + return $this; + + } + + /** + * Forward the call to the image processing method. + * + * @param string $method + * @param mixed $args + * @return $this|mixed + */ + #[\ReturnTypeWillChange] + public function __call($method, $args) + { + if (!in_array($method, static::$magic_actions, true)) { + return parent::__call($method, $args); + } + + // Always initialize image. + if (!$this->image) { + $this->image(); + } + + try { + $this->image->{$method}(...$args); + + /** @var ImageMediaInterface $medium */ + foreach ($this->alternatives as $medium) { + $args_copy = $args; + + // regular image: resize 400x400 -> 200x200 + // --> @2x: resize 800x800->400x400 + if (isset(static::$magic_resize_actions[$method])) { + foreach (static::$magic_resize_actions[$method] as $param) { + if (isset($args_copy[$param])) { + $args_copy[$param] *= $medium->get('ratio'); + } + } + } + + // Do the same call for alternative media. + $medium->__call($method, $args_copy); + } + } catch (BadFunctionCallException $e) { + } + + return $this; + } +} diff --git a/system/src/Grav/Common/Page/Medium/Link.php b/system/src/Grav/Common/Page/Medium/Link.php new file mode 100644 index 0000000..cb83ace --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/Link.php @@ -0,0 +1,102 @@ +attributes = $attributes; + + $source = $medium->reset()->thumbnail('auto')->display('thumbnail'); + if (!$source instanceof MediaObjectInterface) { + throw new RuntimeException('Media has no thumbnail set'); + } + + $source->set('linked', true); + + $this->source = $source; + } + + /** + * Get an element (is array) that can be rendered by the Parsedown engine + * + * @param string|null $title + * @param string|null $alt + * @param string|null $class + * @param string|null $id + * @param bool $reset + * @return array + */ + public function parsedownElement($title = null, $alt = null, $class = null, $id = null, $reset = true) + { + $innerElement = $this->source->parsedownElement($title, $alt, $class, $id, $reset); + + return [ + 'name' => 'a', + 'attributes' => $this->attributes, + 'handler' => is_array($innerElement) ? 'element' : 'line', + 'text' => $innerElement + ]; + } + + /** + * Forward the call to the source element + * + * @param string $method + * @param mixed $args + * @return mixed + */ + #[\ReturnTypeWillChange] + public function __call($method, $args) + { + $object = $this->source; + $callable = [$object, $method]; + if (!is_callable($callable)) { + throw new BadMethodCallException(get_class($object) . '::' . $method . '() not found.'); + } + + $object = call_user_func_array($callable, $args); + if (!$object instanceof MediaLinkInterface) { + // Don't start nesting links, if user has multiple link calls in his + // actions, we will drop the previous links. + return $this; + } + + $this->source = $object; + + return $object; + } +} diff --git a/system/src/Grav/Common/Page/Medium/Medium.php b/system/src/Grav/Common/Page/Medium/Medium.php new file mode 100644 index 0000000..fe53c80 --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/Medium.php @@ -0,0 +1,140 @@ +get('system.media.enable_media_timestamp', true)) { + $this->timestamp = Grav::instance()['cache']->getKey(); + } + + $this->def('mime', 'application/octet-stream'); + + if (!$this->offsetExists('size')) { + $path = $this->get('filepath'); + $this->def('size', filesize($path)); + } + + $this->reset(); + } + + /** + * Clone medium. + */ + #[\ReturnTypeWillChange] + public function __clone() + { + // Allows future compatibility as parent::__clone() works. + } + + /** + * Add meta file for the medium. + * + * @param string $filepath + */ + public function addMetaFile($filepath) + { + $this->metadata = (array)CompiledYamlFile::instance($filepath)->content(); + $this->merge($this->metadata); + } + + /** + * @return array + */ + public function getMeta(): array + { + return [ + 'mime' => $this->mime, + 'size' => $this->size, + 'modified' => $this->modified, + ]; + } + + /** + * Return string representation of the object (html). + * + * @return string + */ + #[\ReturnTypeWillChange] + public function __toString() + { + return $this->html(); + } + + /** + * @param string $thumb + * @return Medium|null + */ + protected function createThumbnail($thumb) + { + return MediumFactory::fromFile($thumb, ['type' => 'thumbnail']); + } + + /** + * @param array $attributes + * @return MediaLinkInterface + */ + protected function createLink(array $attributes) + { + return new Link($attributes, $this); + } + + /** + * @return Grav + */ + protected function getGrav(): Grav + { + return Grav::instance(); + } + + /** + * @return array + */ + protected function getItems(): array + { + return $this->items; + } +} diff --git a/system/src/Grav/Common/Page/Medium/MediumFactory.php b/system/src/Grav/Common/Page/Medium/MediumFactory.php new file mode 100644 index 0000000..838946b --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/MediumFactory.php @@ -0,0 +1,220 @@ +get('media.types.' . strtolower($ext)) : null; + if (!is_array($media_params)) { + return null; + } + + // Remove empty 'image' attribute + if (isset($media_params['image']) && empty($media_params['image'])) { + unset($media_params['image']); + } + + $params += $media_params; + + // Add default settings for undefined variables. + $params += (array)$config->get('media.types.defaults'); + $params += [ + 'type' => 'file', + 'thumb' => 'media/thumb.png', + 'mime' => 'application/octet-stream', + 'filepath' => $file, + 'filename' => $filename, + 'basename' => $basename, + 'extension' => $ext, + 'path' => $path, + 'modified' => filemtime($file), + 'thumbnails' => [] + ]; + + $locator = Grav::instance()['locator']; + + $file = $locator->findResource("image://{$params['thumb']}"); + if ($file) { + $params['thumbnails']['default'] = $file; + } + + return static::fromArray($params); + } + + /** + * Create Medium from an uploaded file + * + * @param UploadedFileInterface $uploadedFile + * @param array $params + * @return Medium|null + */ + public static function fromUploadedFile(UploadedFileInterface $uploadedFile, array $params = []) + { + // For now support only FormFlashFiles, which exist over multiple requests. Also ignore errored and moved media. + if (!$uploadedFile instanceof FormFlashFile || $uploadedFile->getError() !== \UPLOAD_ERR_OK || $uploadedFile->isMoved()) { + return null; + } + + $clientName = $uploadedFile->getClientFilename(); + if (!$clientName) { + return null; + } + + $parts = Utils::pathinfo($clientName); + $filename = $parts['basename']; + $ext = $parts['extension'] ?? ''; + $basename = $parts['filename']; + $file = $uploadedFile->getTmpFile(); + $path = $file ? dirname($file) : ''; + + $config = Grav::instance()['config']; + + $media_params = $ext ? $config->get('media.types.' . strtolower($ext)) : null; + if (!is_array($media_params)) { + return null; + } + + $params += $media_params; + + // Add default settings for undefined variables. + $params += (array)$config->get('media.types.defaults'); + $params += [ + 'type' => 'file', + 'thumb' => 'media/thumb.png', + 'mime' => 'application/octet-stream', + 'filepath' => $file, + 'filename' => $filename, + 'basename' => $basename, + 'extension' => $ext, + 'path' => $path, + 'modified' => $file ? filemtime($file) : 0, + 'thumbnails' => [] + ]; + + $locator = Grav::instance()['locator']; + + $file = $locator->findResource("image://{$params['thumb']}"); + if ($file) { + $params['thumbnails']['default'] = $file; + } + + return static::fromArray($params); + } + + /** + * Create Medium from array of parameters + * + * @param array $items + * @param Blueprint|null $blueprint + * @return Medium + */ + public static function fromArray(array $items = [], Blueprint $blueprint = null) + { + $type = $items['type'] ?? null; + + switch ($type) { + case 'image': + return new ImageMedium($items, $blueprint); + case 'thumbnail': + return new ThumbnailImageMedium($items, $blueprint); + case 'vector': + return new VectorImageMedium($items, $blueprint); + case 'animated': + return new StaticImageMedium($items, $blueprint); + case 'video': + return new VideoMedium($items, $blueprint); + case 'audio': + return new AudioMedium($items, $blueprint); + default: + return new Medium($items, $blueprint); + } + } + + /** + * Create a new ImageMedium by scaling another ImageMedium object. + * + * @param ImageMediaInterface|MediaObjectInterface $medium + * @param int $from + * @param int $to + * @return ImageMediaInterface|MediaObjectInterface|array + */ + public static function scaledFromMedium($medium, $from, $to) + { + if (!$medium instanceof ImageMedium) { + return $medium; + } + + if ($to > $from) { + return $medium; + } + + $ratio = $to / $from; + $width = $medium->get('width') * $ratio; + $height = $medium->get('height') * $ratio; + + $prev_basename = $medium->get('basename'); + $basename = str_replace('@' . $from . 'x', $to !== 1 ? '@' . $to . 'x' : '', $prev_basename); + + $debug = $medium->get('debug'); + $medium->set('debug', false); + $medium->setImagePrettyName($basename); + + $file = $medium->resize($width, $height)->path(); + + $medium->set('debug', $debug); + $medium->setImagePrettyName($prev_basename); + + $size = filesize($file); + + $medium = self::fromFile($file); + if ($medium) { + $medium->set('basename', $basename); + $medium->set('filename', $basename . '.' . $medium->extension); + $medium->set('size', $size); + } + + return ['file' => $medium, 'size' => $size]; + } +} diff --git a/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php b/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php new file mode 100644 index 0000000..4b6e24c --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php @@ -0,0 +1,44 @@ +parsedownElement($title, $alt, $class, $id, $reset); + + if (!$this->parsedown) { + $this->parsedown = new Parsedown(new Excerpts()); + } + + return $this->parsedown->elementToHtml($element); + } +} diff --git a/system/src/Grav/Common/Page/Medium/RenderableInterface.php b/system/src/Grav/Common/Page/Medium/RenderableInterface.php new file mode 100644 index 0000000..6a71058 --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/RenderableInterface.php @@ -0,0 +1,41 @@ +url($reset); + } + + return ['name' => 'img', 'attributes' => $attributes]; + } + + /** + * @return $this + */ + public function higherQualityAlternative() + { + return $this; + } +} diff --git a/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php b/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php new file mode 100644 index 0000000..61ff6a1 --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php @@ -0,0 +1,24 @@ +get('width'); + $height = $this->get('height'); + if ($width && $height) { + return; + } + + // Make sure that getting image size is supported. + if ($this->mime !== 'image/svg+xml' || !\extension_loaded('simplexml')) { + return; + } + + // Make sure that the image exists. + $path = $this->get('filepath'); + if (!$path || !file_exists($path) || !filesize($path)) { + return; + } + + $xml = simplexml_load_string(file_get_contents($path)); + $attr = $xml ? $xml->attributes() : null; + if (!$attr instanceof \SimpleXMLElement) { + return; + } + + // Get the size from svg image. + if ($attr->width && $attr->height) { + $width = (string)$attr->width; + $height = (string)$attr->height; + } elseif ($attr->viewBox && \count($size = explode(' ', (string)$attr->viewBox)) === 4) { + [,$width,$height,] = $size; + } + + if ($width && $height) { + $this->def('width', (int)$width); + $this->def('height', (int)$height); + } + } +} diff --git a/system/src/Grav/Common/Page/Medium/VideoMedium.php b/system/src/Grav/Common/Page/Medium/VideoMedium.php new file mode 100644 index 0000000..f299eee --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/VideoMedium.php @@ -0,0 +1,36 @@ +resetPlayer(); + + return $this; + } +} diff --git a/system/src/Grav/Common/Page/Page.php b/system/src/Grav/Common/Page/Page.php new file mode 100644 index 0000000..7cf9bf3 --- /dev/null +++ b/system/src/Grav/Common/Page/Page.php @@ -0,0 +1,2935 @@ +taxonomy = []; + $this->process = $config->get('system.pages.process'); + $this->published = true; + } + + /** + * Initializes the page instance variables based on a file + * + * @param SplFileInfo $file The file information for the .md file that the page represents + * @param string|null $extension + * @return $this + */ + public function init(SplFileInfo $file, $extension = null) + { + $config = Grav::instance()['config']; + + $this->initialized = true; + + // some extension logic + if (empty($extension)) { + $this->extension('.' . $file->getExtension()); + } else { + $this->extension($extension); + } + + // extract page language from page extension + $language = trim(Utils::basename($this->extension(), 'md'), '.') ?: null; + $this->language($language); + + $this->hide_home_route = $config->get('system.home.hide_in_urls', false); + $this->home_route = $this->adjustRouteCase($config->get('system.home.alias')); + $this->filePath($file->getPathname()); + $this->modified($file->getMTime()); + $this->id($this->modified() . md5($this->filePath())); + $this->routable(true); + $this->header(); + $this->date(); + $this->metadata(); + $this->url(); + $this->visible(); + $this->modularTwig(strpos($this->slug(), '_') === 0); + $this->setPublishState(); + $this->published(); + $this->urlExtension(); + + return $this; + } + + #[\ReturnTypeWillChange] + public function __clone() + { + $this->initialized = false; + $this->header = $this->header ? clone $this->header : null; + } + + /** + * @return void + */ + public function initialize(): void + { + if (!$this->initialized) { + $this->initialized = true; + $this->route = null; + $this->raw_route = null; + $this->_forms = null; + } + } + + /** + * @return void + */ + protected function processFrontmatter() + { + // Quick check for twig output tags in frontmatter if enabled + $process_fields = (array)$this->header(); + if (Utils::contains(json_encode(array_values($process_fields)), '{{')) { + $ignored_fields = []; + foreach ((array)Grav::instance()['config']->get('system.pages.frontmatter.ignore_fields') as $field) { + if (isset($process_fields[$field])) { + $ignored_fields[$field] = $process_fields[$field]; + unset($process_fields[$field]); + } + } + $text_header = Grav::instance()['twig']->processString(json_encode($process_fields, JSON_UNESCAPED_UNICODE), ['page' => $this]); + $this->header((object)(json_decode($text_header, true) + $ignored_fields)); + } + } + + /** + * Return an array with the routes of other translated languages + * + * @param bool $onlyPublished only return published translations + * @return array the page translated languages + */ + public function translatedLanguages($onlyPublished = false) + { + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + + $languages = $language->getLanguages(); + $defaultCode = $language->getDefault(); + + $name = substr($this->name, 0, -strlen($this->extension())); + $translatedLanguages = []; + + foreach ($languages as $languageCode) { + $languageExtension = ".{$languageCode}.md"; + $path = $this->path . DS . $this->folder . DS . $name . $languageExtension; + $exists = file_exists($path); + + // Default language may be saved without language file location. + if (!$exists && $languageCode === $defaultCode) { + $languageExtension = '.md'; + $path = $this->path . DS . $this->folder . DS . $name . $languageExtension; + $exists = file_exists($path); + } + + if ($exists) { + $aPage = new Page(); + $aPage->init(new SplFileInfo($path), $languageExtension); + $aPage->route($this->route()); + $aPage->rawRoute($this->rawRoute()); + $route = $aPage->header()->routes['default'] ?? $aPage->rawRoute(); + if (!$route) { + $route = $aPage->route(); + } + + if ($onlyPublished && !$aPage->published()) { + continue; + } + + $translatedLanguages[$languageCode] = $route; + } + } + + return $translatedLanguages; + } + + /** + * Return an array listing untranslated languages available + * + * @param bool $includeUnpublished also list unpublished translations + * @return array the page untranslated languages + */ + public function untranslatedLanguages($includeUnpublished = false) + { + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + + $languages = $language->getLanguages(); + $translated = array_keys($this->translatedLanguages(!$includeUnpublished)); + + return array_values(array_diff($languages, $translated)); + } + + /** + * Gets and Sets the raw data + * + * @param string|null $var Raw content string + * @return string Raw content string + */ + public function raw($var = null) + { + $file = $this->file(); + + if ($var) { + // First update file object. + if ($file) { + $file->raw($var); + } + + // Reset header and content. + $this->modified = time(); + $this->id($this->modified() . md5($this->filePath())); + $this->header = null; + $this->content = null; + $this->summary = null; + } + + return $file ? $file->raw() : ''; + } + + /** + * Gets and Sets the page frontmatter + * + * @param string|null $var + * + * @return string + */ + public function frontmatter($var = null) + { + if ($var) { + $this->frontmatter = (string)$var; + + // Update also file object. + $file = $this->file(); + if ($file) { + $file->frontmatter((string)$var); + } + + // Force content re-processing. + $this->id(time() . md5($this->filePath())); + } + if (!$this->frontmatter) { + $this->header(); + } + + return $this->frontmatter; + } + + /** + * Gets and Sets the header based on the YAML configuration at the top of the .md file + * + * @param object|array|null $var a YAML object representing the configuration for the file + * @return \stdClass the current YAML configuration + */ + public function header($var = null) + { + if ($var) { + $this->header = (object)$var; + + // Update also file object. + $file = $this->file(); + if ($file) { + $file->header((array)$var); + } + + // Force content re-processing. + $this->id(time() . md5($this->filePath())); + } + if (!$this->header) { + $file = $this->file(); + if ($file) { + try { + $this->raw_content = $file->markdown(); + $this->frontmatter = $file->frontmatter(); + $this->header = (object)$file->header(); + + if (!Utils::isAdminPlugin()) { + // If there's a `frontmatter.yaml` file merge that in with the page header + // note page's own frontmatter has precedence and will overwrite any defaults + $frontmatter_filename = $this->path . '/' . $this->folder . '/frontmatter.yaml'; + if (file_exists($frontmatter_filename)) { + $frontmatter_file = CompiledYamlFile::instance($frontmatter_filename); + $frontmatter_data = $frontmatter_file->content(); + $this->header = (object)array_replace_recursive( + $frontmatter_data, + (array)$this->header + ); + $frontmatter_file->free(); + } + + // Process frontmatter with Twig if enabled + if (Grav::instance()['config']->get('system.pages.frontmatter.process_twig') === true) { + $this->processFrontmatter(); + } + } + } catch (Exception $e) { + $file->raw(Grav::instance()['language']->translate([ + 'GRAV.FRONTMATTER_ERROR_PAGE', + $this->slug(), + $file->filename(), + $e->getMessage(), + $file->raw() + ])); + $this->raw_content = $file->markdown(); + $this->frontmatter = $file->frontmatter(); + $this->header = (object)$file->header(); + } + $var = true; + } + } + + if ($var) { + if (isset($this->header->modified)) { + $this->modified($this->header->modified); + } + if (isset($this->header->slug)) { + $this->slug($this->header->slug); + } + if (isset($this->header->routes)) { + $this->routes = (array)$this->header->routes; + } + if (isset($this->header->title)) { + $this->title = trim($this->header->title); + } + if (isset($this->header->language)) { + $this->language = trim($this->header->language); + } + if (isset($this->header->template)) { + $this->template = trim($this->header->template); + } + if (isset($this->header->menu)) { + $this->menu = trim($this->header->menu); + } + if (isset($this->header->routable)) { + $this->routable = (bool)$this->header->routable; + } + if (isset($this->header->visible)) { + $this->visible = (bool)$this->header->visible; + } + if (isset($this->header->redirect)) { + $this->redirect = trim($this->header->redirect); + } + if (isset($this->header->external_url)) { + $this->external_url = trim($this->header->external_url); + } + if (isset($this->header->order_dir)) { + $this->order_dir = trim($this->header->order_dir); + } + if (isset($this->header->order_by)) { + $this->order_by = trim($this->header->order_by); + } + if (isset($this->header->order_manual)) { + $this->order_manual = (array)$this->header->order_manual; + } + if (isset($this->header->dateformat)) { + $this->dateformat($this->header->dateformat); + } + if (isset($this->header->date)) { + $this->date($this->header->date); + } + if (isset($this->header->markdown_extra)) { + $this->markdown_extra = (bool)$this->header->markdown_extra; + } + if (isset($this->header->taxonomy)) { + $this->taxonomy($this->header->taxonomy); + } + if (isset($this->header->max_count)) { + $this->max_count = (int)$this->header->max_count; + } + if (isset($this->header->process)) { + foreach ((array)$this->header->process as $process => $status) { + $this->process[$process] = (bool)$status; + } + } + if (isset($this->header->published)) { + $this->published = (bool)$this->header->published; + } + if (isset($this->header->publish_date)) { + $this->publishDate($this->header->publish_date); + } + if (isset($this->header->unpublish_date)) { + $this->unpublishDate($this->header->unpublish_date); + } + if (isset($this->header->expires)) { + $this->expires = (int)$this->header->expires; + } + if (isset($this->header->cache_control)) { + $this->cache_control = $this->header->cache_control; + } + if (isset($this->header->etag)) { + $this->etag = (bool)$this->header->etag; + } + if (isset($this->header->last_modified)) { + $this->last_modified = (bool)$this->header->last_modified; + } + if (isset($this->header->ssl)) { + $this->ssl = (bool)$this->header->ssl; + } + if (isset($this->header->template_format)) { + $this->template_format = $this->header->template_format; + } + if (isset($this->header->debugger)) { + $this->debugger = (bool)$this->header->debugger; + } + if (isset($this->header->append_url_extension)) { + $this->url_extension = $this->header->append_url_extension; + } + } + + return $this->header; + } + + /** + * Get page language + * + * @param string|null $var + * @return mixed + */ + public function language($var = null) + { + if ($var !== null) { + $this->language = $var; + } + + return $this->language; + } + + /** + * Modify a header value directly + * + * @param string $key + * @param mixed $value + */ + public function modifyHeader($key, $value) + { + $this->header->{$key} = $value; + } + + /** + * @return int + */ + public function httpResponseCode() + { + return (int)($this->header()->http_response_code ?? 200); + } + + /** + * @return array + */ + public function httpHeaders() + { + $headers = []; + + $grav = Grav::instance(); + $format = $this->templateFormat(); + $cache_control = $this->cacheControl(); + $expires = $this->expires(); + + // Set Content-Type header + $headers['Content-Type'] = Utils::getMimeByExtension($format, 'text/html'); + + // Calculate Expires Headers if set to > 0 + if ($expires > 0) { + $expires_date = gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT'; + if (!$cache_control) { + $headers['Cache-Control'] = 'max-age=' . $expires; + } + $headers['Expires'] = $expires_date; + } + + // Set Cache-Control header + if ($cache_control) { + $headers['Cache-Control'] = strtolower($cache_control); + } + + // Set Last-Modified header + if ($this->lastModified()) { + $last_modified = $this->modified(); + foreach ($this->children()->modular() as $cpage) { + $modular_mtime = $cpage->modified(); + if ($modular_mtime > $last_modified) { + $last_modified = $modular_mtime; + } + } + + $last_modified_date = gmdate('D, d M Y H:i:s', $last_modified) . ' GMT'; + $headers['Last-Modified'] = $last_modified_date; + } + + // Ask Grav to calculate ETag from the final content. + if ($this->eTag()) { + $headers['ETag'] = '1'; + } + + // Set Vary: Accept-Encoding header + if ($grav['config']->get('system.pages.vary_accept_encoding', false)) { + $headers['Vary'] = 'Accept-Encoding'; + } + + + // Added new Headers event + $headers_obj = (object) $headers; + Grav::instance()->fireEvent('onPageHeaders', new Event(['headers' => $headers_obj])); + + return (array)$headers_obj; + } + + /** + * Get the summary. + * + * @param int|null $size Max summary size. + * @param bool $textOnly Only count text size. + * @return string + */ + public function summary($size = null, $textOnly = false) + { + $config = (array)Grav::instance()['config']->get('site.summary'); + if (isset($this->header->summary)) { + $config = array_merge($config, $this->header->summary); + } + + // Return summary based on settings in site config file + if (!$config['enabled']) { + return $this->content(); + } + + // Set up variables to process summary from page or from custom summary + if ($this->summary === null) { + $content = $textOnly ? strip_tags($this->content()) : $this->content(); + $summary_size = $this->summary_size; + } else { + $content = $textOnly ? strip_tags($this->summary) : $this->summary; + $summary_size = mb_strwidth($content, 'utf-8'); + } + + // Return calculated summary based on summary divider's position + $format = $config['format']; + // Return entire page content on wrong/ unknown format + if (!in_array($format, ['short', 'long'])) { + return $content; + } + if (($format === 'short') && isset($summary_size)) { + // Slice the string + if (mb_strwidth($content, 'utf8') > $summary_size) { + return mb_substr($content, 0, $summary_size); + } + + return $content; + } + + // Get summary size from site config's file + if ($size === null) { + $size = $config['size']; + } + + // If the size is zero, return the entire page content + if ($size === 0) { + return $content; + // Return calculated summary based on defaults + } + if (!is_numeric($size) || ($size < 0)) { + $size = 300; + } + + // Only return string but not html, wrap whatever html tag you want when using + if ($textOnly) { + if (mb_strwidth($content, 'utf-8') <= $size) { + return $content; + } + + return mb_strimwidth($content, 0, $size, '…', 'UTF-8'); + } + + $summary = Utils::truncateHtml($content, $size); + + return html_entity_decode($summary, ENT_COMPAT | ENT_HTML401, 'UTF-8'); + } + + /** + * Sets the summary of the page + * + * @param string $summary Summary + */ + public function setSummary($summary) + { + $this->summary = $summary; + } + + /** + * Gets and Sets the content based on content portion of the .md file + * + * @param string|null $var Content + * @return string Content + */ + public function content($var = null) + { + if ($var !== null) { + $this->raw_content = $var; + + // Update file object. + $file = $this->file(); + if ($file) { + $file->markdown($var); + } + + // Force re-processing. + $this->id(time() . md5($this->filePath())); + $this->content = null; + } + // If no content, process it + if ($this->content === null) { + // Get media + $this->media(); + + /** @var Config $config */ + $config = Grav::instance()['config']; + + // Load cached content + /** @var Cache $cache */ + $cache = Grav::instance()['cache']; + $cache_id = md5('page' . $this->getCacheKey()); + $content_obj = $cache->fetch($cache_id); + + if (is_array($content_obj)) { + $this->content = $content_obj['content']; + $this->content_meta = $content_obj['content_meta']; + } else { + $this->content = $content_obj; + } + + + $process_markdown = $this->shouldProcess('markdown'); + $process_twig = $this->shouldProcess('twig') || $this->modularTwig(); + + $cache_enable = $this->header->cache_enable ?? $config->get( + 'system.cache.enabled', + true + ); + $twig_first = $this->header->twig_first ?? $config->get( + 'system.pages.twig_first', + false + ); + + // never cache twig means it's always run after content + $never_cache_twig = $this->header->never_cache_twig ?? $config->get( + 'system.pages.never_cache_twig', + true + ); + + // if no cached-content run everything + if ($never_cache_twig) { + if ($this->content === false || $cache_enable === false) { + $this->content = $this->raw_content; + Grav::instance()->fireEvent('onPageContentRaw', new Event(['page' => $this])); + + if ($process_markdown) { + $this->processMarkdown(); + } + + // Content Processed but not cached yet + Grav::instance()->fireEvent('onPageContentProcessed', new Event(['page' => $this])); + + if ($cache_enable) { + $this->cachePageContent(); + } + } + + if ($process_twig) { + $this->processTwig(); + } + } else { + if ($this->content === false || $cache_enable === false) { + $this->content = $this->raw_content; + Grav::instance()->fireEvent('onPageContentRaw', new Event(['page' => $this])); + + if ($twig_first) { + if ($process_twig) { + $this->processTwig(); + } + if ($process_markdown) { + $this->processMarkdown(); + } + + // Content Processed but not cached yet + Grav::instance()->fireEvent('onPageContentProcessed', new Event(['page' => $this])); + } else { + if ($process_markdown) { + $this->processMarkdown($process_twig); + } + + // Content Processed but not cached yet + Grav::instance()->fireEvent('onPageContentProcessed', new Event(['page' => $this])); + + if ($process_twig) { + $this->processTwig(); + } + } + + if ($cache_enable) { + $this->cachePageContent(); + } + } + } + + // Handle summary divider + $delimiter = $config->get('site.summary.delimiter', '==='); + $divider_pos = mb_strpos($this->content, "

{$delimiter}

"); + if ($divider_pos !== false) { + $this->summary_size = $divider_pos; + $this->content = str_replace("

{$delimiter}

", '', $this->content); + } + + // Fire event when Page::content() is called + Grav::instance()->fireEvent('onPageContent', new Event(['page' => $this])); + } + + return $this->content; + } + + /** + * Get the contentMeta array and initialize content first if it's not already + * + * @return mixed + */ + public function contentMeta() + { + if ($this->content === null) { + $this->content(); + } + + return $this->getContentMeta(); + } + + /** + * Add an entry to the page's contentMeta array + * + * @param string $name + * @param mixed $value + */ + public function addContentMeta($name, $value) + { + $this->content_meta[$name] = $value; + } + + /** + * Return the whole contentMeta array as it currently stands + * + * @param string|null $name + * + * @return mixed|null + */ + public function getContentMeta($name = null) + { + if ($name) { + return $this->content_meta[$name] ?? null; + } + + return $this->content_meta; + } + + /** + * Sets the whole content meta array in one shot + * + * @param array $content_meta + * + * @return array + */ + public function setContentMeta($content_meta) + { + return $this->content_meta = $content_meta; + } + + /** + * Process the Markdown content. Uses Parsedown or Parsedown Extra depending on configuration + * + * @param bool $keepTwig If true, content between twig tags will not be processed. + * @return void + */ + protected function processMarkdown(bool $keepTwig = false) + { + /** @var Config $config */ + $config = Grav::instance()['config']; + + $markdownDefaults = (array)$config->get('system.pages.markdown'); + if (isset($this->header()->markdown)) { + $markdownDefaults = array_merge($markdownDefaults, $this->header()->markdown); + } + + // pages.markdown_extra is deprecated, but still check it... + if (!isset($markdownDefaults['extra']) && (isset($this->markdown_extra) || $config->get('system.pages.markdown_extra') !== null)) { + user_error('Configuration option \'system.pages.markdown_extra\' is deprecated since Grav 1.5, use \'system.pages.markdown.extra\' instead', E_USER_DEPRECATED); + + $markdownDefaults['extra'] = $this->markdown_extra ?: $config->get('system.pages.markdown_extra'); + } + + $extra = $markdownDefaults['extra'] ?? false; + $defaults = [ + 'markdown' => $markdownDefaults, + 'images' => $config->get('system.images', []) + ]; + + $excerpts = new Excerpts($this, $defaults); + + // Initialize the preferred variant of Parsedown + if ($extra) { + $parsedown = new ParsedownExtra($excerpts); + } else { + $parsedown = new Parsedown($excerpts); + } + + $content = $this->content; + if ($keepTwig) { + $token = [ + '/' . Utils::generateRandomString(3), + Utils::generateRandomString(3) . '/' + ]; + // Base64 encode any twig. + $content = preg_replace_callback( + ['/({#.*?#})/mu', '/({{.*?}})/mu', '/({%.*?%})/mu'], + static function ($matches) use ($token) { return $token[0] . base64_encode($matches[1]) . $token[1]; }, + $content + ); + } + + $content = $parsedown->text($content); + + if ($keepTwig) { + // Base64 decode the encoded twig. + $content = preg_replace_callback( + ['`' . $token[0] . '([A-Za-z0-9+/]+={0,2})' . $token[1] . '`mu'], + static function ($matches) { return base64_decode($matches[1]); }, + $content + ); + } + + $this->content = $content; + } + + + /** + * Process the Twig page content. + * + * @return void + */ + private function processTwig() + { + /** @var Twig $twig */ + $twig = Grav::instance()['twig']; + $this->content = $twig->processPage($this, $this->content); + } + + /** + * Fires the onPageContentProcessed event, and caches the page content using a unique ID for the page + * + * @return void + */ + public function cachePageContent() + { + /** @var Cache $cache */ + $cache = Grav::instance()['cache']; + $cache_id = md5('page' . $this->getCacheKey()); + $cache->save($cache_id, ['content' => $this->content, 'content_meta' => $this->content_meta]); + } + + /** + * Needed by the onPageContentProcessed event to get the raw page content + * + * @return string the current page content + */ + public function getRawContent() + { + return $this->content; + } + + /** + * Needed by the onPageContentProcessed event to set the raw page content + * + * @param string|null $content + * @return void + */ + public function setRawContent($content) + { + $this->content = $content ?? ''; + } + + /** + * Get value from a page variable (used mostly for creating edit forms). + * + * @param string $name Variable name. + * @param mixed $default + * @return mixed + */ + public function value($name, $default = null) + { + if ($name === 'content') { + return $this->raw_content; + } + if ($name === 'route') { + $parent = $this->parent(); + + return $parent ? $parent->rawRoute() : ''; + } + if ($name === 'order') { + $order = $this->order(); + + return $order ? (int)$this->order() : ''; + } + if ($name === 'ordering') { + return (bool)$this->order(); + } + if ($name === 'folder') { + return preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder); + } + if ($name === 'slug') { + return $this->slug(); + } + if ($name === 'name') { + $name = $this->name(); + $language = $this->language() ? '.' . $this->language() : ''; + $pattern = '%(' . preg_quote($language, '%') . ')?\.md$%'; + $name = preg_replace($pattern, '', $name); + + if ($this->isModule()) { + return 'modular/' . $name; + } + + return $name; + } + if ($name === 'media') { + return $this->media()->all(); + } + if ($name === 'media.file') { + return $this->media()->files(); + } + if ($name === 'media.video') { + return $this->media()->videos(); + } + if ($name === 'media.image') { + return $this->media()->images(); + } + if ($name === 'media.audio') { + return $this->media()->audios(); + } + + $path = explode('.', $name); + $scope = array_shift($path); + + if ($name === 'frontmatter') { + return $this->frontmatter; + } + + if ($scope === 'header') { + $current = $this->header(); + foreach ($path as $field) { + if (is_object($current) && isset($current->{$field})) { + $current = $current->{$field}; + } elseif (is_array($current) && isset($current[$field])) { + $current = $current[$field]; + } else { + return $default; + } + } + + return $current; + } + + return $default; + } + + /** + * Gets and Sets the Page raw content + * + * @param string|null $var + * @return string + */ + public function rawMarkdown($var = null) + { + if ($var !== null) { + $this->raw_content = $var; + } + + return $this->raw_content; + } + + /** + * @return bool + * @internal + */ + public function translated(): bool + { + return $this->initialized; + } + + /** + * Get file object to the page. + * + * @return MarkdownFile|null + */ + public function file() + { + if ($this->name) { + return MarkdownFile::instance($this->filePath()); + } + + return null; + } + + /** + * Save page if there's a file assigned to it. + * + * @param bool|array $reorder Internal use. + */ + public function save($reorder = true) + { + // Perform move, copy [or reordering] if needed. + $this->doRelocation(); + + $file = $this->file(); + if ($file) { + $file->filename($this->filePath()); + $file->header((array)$this->header()); + $file->markdown($this->raw_content); + $file->save(); + } + + // Perform reorder if required + if ($reorder && is_array($reorder)) { + $this->doReorder($reorder); + } + + // We need to signal Flex Pages about the change. + /** @var Flex|null $flex */ + $flex = Grav::instance()['flex'] ?? null; + $directory = $flex ? $flex->getDirectory('pages') : null; + if (null !== $directory) { + $directory->clearCache(); + } + + $this->_original = null; + } + + /** + * Prepare move page to new location. Moves also everything that's under the current page. + * + * You need to call $this->save() in order to perform the move. + * + * @param PageInterface $parent New parent page. + * @return $this + */ + public function move(PageInterface $parent) + { + if (!$this->_original) { + $clone = clone $this; + $this->_original = $clone; + } + + $this->_action = 'move'; + + if ($this->route() === $parent->route()) { + throw new RuntimeException('Failed: Cannot set page parent to self'); + } + if (Utils::startsWith($parent->rawRoute(), $this->rawRoute())) { + throw new RuntimeException('Failed: Cannot set page parent to a child of current page'); + } + + $this->parent($parent); + $this->id(time() . md5($this->filePath())); + + if ($parent->path()) { + $this->path($parent->path() . '/' . $this->folder()); + } + + if ($parent->route()) { + $this->route($parent->route() . '/' . $this->slug()); + } else { + $this->route(Grav::instance()['pages']->root()->route() . '/' . $this->slug()); + } + + $this->raw_route = null; + + return $this; + } + + /** + * Prepare a copy from the page. Copies also everything that's under the current page. + * + * Returns a new Page object for the copy. + * You need to call $this->save() in order to perform the move. + * + * @param PageInterface $parent New parent page. + * @return $this + */ + public function copy(PageInterface $parent) + { + $this->move($parent); + $this->_action = 'copy'; + + return $this; + } + + /** + * Get blueprints for the page. + * + * @return Blueprint + */ + public function blueprints() + { + $grav = Grav::instance(); + + /** @var Pages $pages */ + $pages = $grav['pages']; + + $blueprint = $pages->blueprints($this->blueprintName()); + $fields = $blueprint->fields(); + $edit_mode = isset($grav['admin']) ? $grav['config']->get('plugins.admin.edit_mode') : null; + + // override if you only want 'normal' mode + if (empty($fields) && ($edit_mode === 'auto' || $edit_mode === 'normal')) { + $blueprint = $pages->blueprints('default'); + } + + // override if you only want 'expert' mode + if (!empty($fields) && $edit_mode === 'expert') { + $blueprint = $pages->blueprints(''); + } + + return $blueprint; + } + + /** + * Returns the blueprint from the page. + * + * @param string $name Not used. + * @return Blueprint Returns a Blueprint. + */ + public function getBlueprint(string $name = '') + { + return $this->blueprints(); + } + + /** + * Get the blueprint name for this page. Use the blueprint form field if set + * + * @return string + */ + public function blueprintName() + { + if (!isset($_POST['blueprint'])) { + return $this->template(); + } + + $post_value = $_POST['blueprint']; + $sanitized_value = htmlspecialchars(strip_tags($post_value), ENT_QUOTES, 'UTF-8'); + + return $sanitized_value ?: $this->template(); + } + + /** + * Validate page header. + * + * @return void + * @throws Exception + */ + public function validate() + { + $blueprints = $this->blueprints(); + $blueprints->validate($this->toArray()); + } + + /** + * Filter page header from illegal contents. + * + * @return void + */ + public function filter() + { + $blueprints = $this->blueprints(); + $values = $blueprints->filter($this->toArray()); + if ($values && isset($values['header'])) { + $this->header($values['header']); + } + } + + /** + * Get unknown header variables. + * + * @return array + */ + public function extra() + { + $blueprints = $this->blueprints(); + + return $blueprints->extra($this->toArray()['header'], 'header.'); + } + + /** + * Convert page to an array. + * + * @return array + */ + public function toArray() + { + return [ + 'header' => (array)$this->header(), + 'content' => (string)$this->value('content') + ]; + } + + /** + * Convert page to YAML encoded string. + * + * @return string + */ + public function toYaml() + { + return Yaml::dump($this->toArray(), 20); + } + + /** + * Convert page to JSON encoded string. + * + * @return string + */ + public function toJson() + { + return json_encode($this->toArray()); + } + + /** + * @return string + */ + public function getCacheKey(): string + { + return $this->id(); + } + + /** + * Gets and sets the associated media as found in the page folder. + * + * @param Media|null $var Representation of associated media. + * @return Media Representation of associated media. + */ + public function media($var = null) + { + if ($var) { + $this->setMedia($var); + } + + /** @var Media $media */ + $media = $this->getMedia(); + + return $media; + } + + /** + * Get filesystem path to the associated media. + * + * @return string|null + */ + public function getMediaFolder() + { + return $this->path(); + } + + /** + * Get display order for the associated media. + * + * @return array Empty array means default ordering. + */ + public function getMediaOrder() + { + $header = $this->header(); + + return isset($header->media_order) ? array_map('trim', explode(',', (string)$header->media_order)) : []; + } + + /** + * Gets and sets the name field. If no name field is set, it will return 'default.md'. + * + * @param string|null $var The name of this page. + * @return string The name of this page. + */ + public function name($var = null) + { + if ($var !== null) { + $this->name = $var; + } + + return $this->name ?: 'default.md'; + } + + /** + * Returns child page type. + * + * @return string + */ + public function childType() + { + return isset($this->header->child_type) ? (string)$this->header->child_type : ''; + } + + /** + * Gets and sets the template field. This is used to find the correct Twig template file to render. + * If no field is set, it will return the name without the .md extension + * + * @param string|null $var the template name + * @return string the template name + */ + public function template($var = null) + { + if ($var !== null) { + $this->template = $var; + } + if (empty($this->template)) { + $this->template = ($this->isModule() ? 'modular/' : '') . str_replace($this->extension(), '', $this->name()); + } + + return $this->template; + } + + /** + * Allows a page to override the output render format, usually the extension provided in the URL. + * (e.g. `html`, `json`, `xml`, etc). + * + * @param string|null $var + * @return string + */ + public function templateFormat($var = null) + { + if (null !== $var) { + $this->template_format = is_string($var) ? $var : null; + } + + if (!isset($this->template_format)) { + $this->template_format = ltrim($this->header->append_url_extension ?? Utils::getPageFormat(), '.'); + } + + return $this->template_format; + } + + /** + * Gets and sets the extension field. + * + * @param string|null $var + * @return string + */ + public function extension($var = null) + { + if ($var !== null) { + $this->extension = $var; + } + if (empty($this->extension)) { + $this->extension = '.' . Utils::pathinfo($this->name(), PATHINFO_EXTENSION); + } + + return $this->extension; + } + + /** + * Returns the page extension, got from the page `url_extension` config and falls back to the + * system config `system.pages.append_url_extension`. + * + * @return string The extension of this page. For example `.html` + */ + public function urlExtension() + { + if ($this->home()) { + return ''; + } + + // if not set in the page get the value from system config + if (null === $this->url_extension) { + $this->url_extension = Grav::instance()['config']->get('system.pages.append_url_extension', ''); + } + + return $this->url_extension; + } + + /** + * Gets and sets the expires field. If not set will return the default + * + * @param int|null $var The new expires value. + * @return int The expires value + */ + public function expires($var = null) + { + if ($var !== null) { + $this->expires = $var; + } + + return $this->expires ?? Grav::instance()['config']->get('system.pages.expires'); + } + + /** + * Gets and sets the cache-control property. If not set it will return the default value (null) + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control for more details on valid options + * + * @param string|null $var + * @return string|null + */ + public function cacheControl($var = null) + { + if ($var !== null) { + $this->cache_control = $var; + } + + return $this->cache_control ?? Grav::instance()['config']->get('system.pages.cache_control'); + } + + /** + * Gets and sets the title for this Page. If no title is set, it will use the slug() to get a name + * + * @param string|null $var the title of the Page + * @return string the title of the Page + */ + public function title($var = null) + { + if ($var !== null) { + $this->title = $var; + } + if (empty($this->title)) { + $this->title = ucfirst($this->slug()); + } + + return $this->title; + } + + /** + * Gets and sets the menu name for this Page. This is the text that can be used specifically for navigation. + * If no menu field is set, it will use the title() + * + * @param string|null $var the menu field for the page + * @return string the menu field for the page + */ + public function menu($var = null) + { + if ($var !== null) { + $this->menu = $var; + } + if (empty($this->menu)) { + $this->menu = $this->title(); + } + + return $this->menu; + } + + /** + * Gets and Sets whether or not this Page is visible for navigation + * + * @param bool|null $var true if the page is visible + * @return bool true if the page is visible + */ + public function visible($var = null) + { + if ($var !== null) { + $this->visible = (bool)$var; + } + + if ($this->visible === null) { + // Set item visibility in menu if folder is different from slug + // eg folder = 01.Home and slug = Home + if (preg_match(PAGE_ORDER_PREFIX_REGEX, $this->folder)) { + $this->visible = true; + } else { + $this->visible = false; + } + } + + return $this->visible; + } + + /** + * Gets and Sets whether or not this Page is considered published + * + * @param bool|null $var true if the page is published + * @return bool true if the page is published + */ + public function published($var = null) + { + if ($var !== null) { + $this->published = (bool)$var; + } + + // If not published, should not be visible in menus either + if ($this->published === false) { + $this->visible = false; + } + + return $this->published; + } + + /** + * Gets and Sets the Page publish date + * + * @param string|null $var string representation of a date + * @return int unix timestamp representation of the date + */ + public function publishDate($var = null) + { + if ($var !== null) { + $this->publish_date = Utils::date2timestamp($var, $this->dateformat); + } + + return $this->publish_date; + } + + /** + * Gets and Sets the Page unpublish date + * + * @param string|null $var string representation of a date + * @return int|null unix timestamp representation of the date + */ + public function unpublishDate($var = null) + { + if ($var !== null) { + $this->unpublish_date = Utils::date2timestamp($var, $this->dateformat); + } + + return $this->unpublish_date; + } + + /** + * Gets and Sets whether or not this Page is routable, ie you can reach it + * via a URL. + * The page must be *routable* and *published* + * + * @param bool|null $var true if the page is routable + * @return bool true if the page is routable + */ + public function routable($var = null) + { + if ($var !== null) { + $this->routable = (bool)$var; + } + + return $this->routable && $this->published(); + } + + /** + * @param bool|null $var + * @return bool + */ + public function ssl($var = null) + { + if ($var !== null) { + $this->ssl = (bool)$var; + } + + return $this->ssl; + } + + /** + * Gets and Sets the process setup for this Page. This is multi-dimensional array that consists of + * a simple array of arrays with the form array("markdown"=>true) for example + * + * @param array|null $var an Array of name value pairs where the name is the process and value is true or false + * @return array an Array of name value pairs where the name is the process and value is true or false + */ + public function process($var = null) + { + if ($var !== null) { + $this->process = (array)$var; + } + + return $this->process; + } + + /** + * Returns the state of the debugger override setting for this page + * + * @return bool + */ + public function debugger() + { + return !(isset($this->debugger) && $this->debugger === false); + } + + /** + * Function to merge page metadata tags and build an array of Metadata objects + * that can then be rendered in the page. + * + * @param array|null $var an Array of metadata values to set + * @return array an Array of metadata values for the page + */ + public function metadata($var = null) + { + if ($var !== null) { + $this->metadata = (array)$var; + } + + // if not metadata yet, process it. + if (null === $this->metadata) { + $header_tag_http_equivs = ['content-type', 'default-style', 'refresh', 'x-ua-compatible', 'content-security-policy']; + + $this->metadata = []; + + // Set the Generator tag + $metadata = [ + 'generator' => 'GravCMS' + ]; + + $config = Grav::instance()['config']; + + $escape = !$config->get('system.strict_mode.twig_compat', false) || $config->get('system.twig.autoescape', true); + + // Get initial metadata for the page + $metadata = array_merge($metadata, $config->get('site.metadata', [])); + + if (isset($this->header->metadata) && is_array($this->header->metadata)) { + // Merge any site.metadata settings in with page metadata + $metadata = array_merge($metadata, $this->header->metadata); + } + + // Build an array of meta objects.. + foreach ((array)$metadata as $key => $value) { + // Lowercase the key + $key = strtolower($key); + // If this is a property type metadata: "og", "twitter", "facebook" etc + // Backward compatibility for nested arrays in metas + if (is_array($value)) { + foreach ($value as $property => $prop_value) { + $prop_key = $key . ':' . $property; + $this->metadata[$prop_key] = [ + 'name' => $prop_key, + 'property' => $prop_key, + 'content' => $escape ? htmlspecialchars($prop_value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $prop_value + ]; + } + } else { + // If it this is a standard meta data type + if ($value) { + if (in_array($key, $header_tag_http_equivs, true)) { + $this->metadata[$key] = [ + 'http_equiv' => $key, + 'content' => $escape ? htmlspecialchars($value, ENT_COMPAT, 'UTF-8') : $value + ]; + } elseif ($key === 'charset') { + $this->metadata[$key] = ['charset' => $escape ? htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $value]; + } else { + // if it's a social metadata with separator, render as property + $separator = strpos($key, ':'); + $hasSeparator = $separator && $separator < strlen($key) - 1; + $entry = [ + 'content' => $escape ? htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $value + ]; + + if ($hasSeparator && !Utils::startsWith($key, ['twitter', 'flattr','fediverse'])) { + $entry['property'] = $key; + } else { + $entry['name'] = $key; + } + + $this->metadata[$key] = $entry; + } + } + } + } + } + + return $this->metadata; + } + + /** + * Reset the metadata and pull from header again + */ + public function resetMetadata() + { + $this->metadata = null; + } + + /** + * Gets and Sets the slug for the Page. The slug is used in the URL routing. If not set it uses + * the parent folder from the path + * + * @param string|null $var the slug, e.g. 'my-blog' + * @return string the slug + */ + public function slug($var = null) + { + if ($var !== null && $var !== '') { + $this->slug = $var; + } + + if (empty($this->slug)) { + $this->slug = $this->adjustRouteCase(preg_replace(PAGE_ORDER_PREFIX_REGEX, '', (string) $this->folder)) ?: null; + } + + return $this->slug; + } + + /** + * Get/set order number of this page. + * + * @param int|null $var + * @return string|bool + */ + public function order($var = null) + { + if ($var !== null) { + $order = $var ? sprintf('%02d.', (int)$var) : ''; + $this->folder($order . preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder)); + + return $order; + } + + preg_match(PAGE_ORDER_PREFIX_REGEX, $this->folder, $order); + + return $order[0] ?? false; + } + + /** + * Gets the URL for a page - alias of url(). + * + * @param bool $include_host + * @return string the permalink + */ + public function link($include_host = false) + { + return $this->url($include_host); + } + + /** + * Gets the URL with host information, aka Permalink. + * @return string The permalink. + */ + public function permalink() + { + return $this->url(true, false, true, true); + } + + /** + * Returns the canonical URL for a page + * + * @param bool $include_lang + * @return string + */ + public function canonical($include_lang = true) + { + return $this->url(true, true, $include_lang); + } + + /** + * Gets the url for the Page. + * + * @param bool $include_host Defaults false, but true would include http://yourhost.com + * @param bool $canonical True to return the canonical URL + * @param bool $include_base Include base url on multisite as well as language code + * @param bool $raw_route + * @return string The url. + */ + public function url($include_host = false, $canonical = false, $include_base = true, $raw_route = false) + { + // Override any URL when external_url is set + if (isset($this->external_url)) { + return $this->external_url; + } + + $grav = Grav::instance(); + + /** @var Pages $pages */ + $pages = $grav['pages']; + + /** @var Config $config */ + $config = $grav['config']; + + // get base route (multi-site base and language) + $route = $include_base ? $pages->baseRoute() : ''; + + // add full route if configured to do so + if (!$include_host && $config->get('system.absolute_urls', false)) { + $include_host = true; + } + + if ($canonical) { + $route .= $this->routeCanonical(); + } elseif ($raw_route) { + $route .= $this->rawRoute(); + } else { + $route .= $this->route(); + } + + /** @var Uri $uri */ + $uri = $grav['uri']; + $url = $uri->rootUrl($include_host) . '/' . trim($route, '/') . $this->urlExtension(); + + return Uri::filterPath($url); + } + + /** + * Gets the route for the page based on the route headers if available, else from + * the parents route and the current Page's slug. + * + * @param string|null $var Set new default route. + * @return string|null The route for the Page. + */ + public function route($var = null) + { + if ($var !== null) { + $this->route = $var; + } + + if (empty($this->route)) { + $baseRoute = null; + + // calculate route based on parent slugs + $parent = $this->parent(); + if (isset($parent)) { + if ($this->hide_home_route && $parent->route() === $this->home_route) { + $baseRoute = ''; + } else { + $baseRoute = (string)$parent->route(); + } + } + + $this->route = isset($baseRoute) ? $baseRoute . '/' . $this->slug() : null; + + if (!empty($this->routes) && isset($this->routes['default'])) { + $this->routes['aliases'][] = $this->route; + $this->route = $this->routes['default']; + + return $this->route; + } + } + + return $this->route; + } + + /** + * Helper method to clear the route out so it regenerates next time you use it + */ + public function unsetRouteSlug() + { + unset($this->route, $this->slug); + } + + /** + * Gets and Sets the page raw route + * + * @param string|null $var + * @return null|string + */ + public function rawRoute($var = null) + { + if ($var !== null) { + $this->raw_route = $var; + } + + if (empty($this->raw_route)) { + $parent = $this->parent(); + $baseRoute = $parent ? (string)$parent->rawRoute() : null; + + $slug = $this->adjustRouteCase(preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder)); + + $this->raw_route = isset($baseRoute) ? $baseRoute . '/' . $slug : null; + } + + return $this->raw_route; + } + + /** + * Gets the route aliases for the page based on page headers. + * + * @param array|null $var list of route aliases + * @return array The route aliases for the Page. + */ + public function routeAliases($var = null) + { + if ($var !== null) { + $this->routes['aliases'] = (array)$var; + } + + if (!empty($this->routes) && isset($this->routes['aliases'])) { + return $this->routes['aliases']; + } + + return []; + } + + /** + * Gets the canonical route for this page if its set. If provided it will use + * that value, else if it's `true` it will use the default route. + * + * @param string|null $var + * @return bool|string + */ + public function routeCanonical($var = null) + { + if ($var !== null) { + $this->routes['canonical'] = $var; + } + + if (!empty($this->routes) && isset($this->routes['canonical'])) { + return $this->routes['canonical']; + } + + return $this->route(); + } + + /** + * Gets and sets the identifier for this Page object. + * + * @param string|null $var the identifier + * @return string the identifier + */ + public function id($var = null) + { + if (null === $this->id) { + // We need to set unique id to avoid potential cache conflicts between pages. + $var = time() . md5($this->filePath()); + } + if ($var !== null) { + // store unique per language + $active_lang = Grav::instance()['language']->getLanguage() ?: ''; + $id = $active_lang . $var; + $this->id = $id; + } + + return $this->id; + } + + /** + * Gets and sets the modified timestamp. + * + * @param int|null $var modified unix timestamp + * @return int modified unix timestamp + */ + public function modified($var = null) + { + if ($var !== null) { + $this->modified = $var; + } + + return $this->modified; + } + + /** + * Gets the redirect set in the header. + * + * @param string|null $var redirect url + * @return string|null + */ + public function redirect($var = null) + { + if ($var !== null) { + $this->redirect = $var; + } + + return $this->redirect ?: null; + } + + /** + * Gets and sets the option to show the etag header for the page. + * + * @param bool|null $var show etag header + * @return bool show etag header + */ + public function eTag($var = null): bool + { + if ($var !== null) { + $this->etag = $var; + } + if (!isset($this->etag)) { + $this->etag = (bool)Grav::instance()['config']->get('system.pages.etag'); + } + + return $this->etag ?? false; + } + + /** + * Gets and sets the option to show the last_modified header for the page. + * + * @param bool|null $var show last_modified header + * @return bool show last_modified header + */ + public function lastModified($var = null) + { + if ($var !== null) { + $this->last_modified = $var; + } + if (!isset($this->last_modified)) { + $this->last_modified = (bool)Grav::instance()['config']->get('system.pages.last_modified'); + } + + return $this->last_modified; + } + + /** + * Gets and sets the path to the .md file for this Page object. + * + * @param string|null $var the file path + * @return string|null the file path + */ + public function filePath($var = null) + { + if ($var !== null) { + // Filename of the page. + $this->name = Utils::basename($var); + // Folder of the page. + $this->folder = Utils::basename(dirname($var)); + // Path to the page. + $this->path = dirname($var, 2); + } + + return rtrim($this->path . '/' . $this->folder . '/' . ($this->name() ?: ''), '/'); + } + + /** + * Gets the relative path to the .md file + * + * @return string The relative file path + */ + public function filePathClean() + { + return str_replace(GRAV_ROOT . DS, '', $this->filePath()); + } + + /** + * Returns the clean path to the page file + * + * @return string + */ + public function relativePagePath() + { + return str_replace('/' . $this->name(), '', $this->filePathClean()); + } + + /** + * Gets and sets the path to the folder where the .md for this Page object resides. + * This is equivalent to the filePath but without the filename. + * + * @param string|null $var the path + * @return string|null the path + */ + public function path($var = null) + { + if ($var !== null) { + // Folder of the page. + $this->folder = Utils::basename($var); + // Path to the page. + $this->path = dirname($var); + } + + return $this->path ? $this->path . '/' . $this->folder : null; + } + + /** + * Get/set the folder. + * + * @param string|null $var Optional path + * @return string|null + */ + public function folder($var = null) + { + if ($var !== null) { + $this->folder = $var; + } + + return $this->folder; + } + + /** + * Gets and sets the date for this Page object. This is typically passed in via the page headers + * + * @param string|null $var string representation of a date + * @return int unix timestamp representation of the date + */ + public function date($var = null) + { + if ($var !== null) { + $this->date = Utils::date2timestamp($var, $this->dateformat); + } + + if (!$this->date) { + $this->date = $this->modified; + } + + return $this->date; + } + + /** + * Gets and sets the date format for this Page object. This is typically passed in via the page headers + * using typical PHP date string structure - http://php.net/manual/en/function.date.php + * + * @param string|null $var string representation of a date format + * @return string string representation of a date format + */ + public function dateformat($var = null) + { + if ($var !== null) { + $this->dateformat = $var; + } + + return $this->dateformat; + } + + /** + * Gets and sets the order by which any sub-pages should be sorted. + * + * @param string|null $var the order, either "asc" or "desc" + * @return string the order, either "asc" or "desc" + * @deprecated 1.6 + */ + public function orderDir($var = null) + { + //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + + if ($var !== null) { + $this->order_dir = $var; + } + + if (empty($this->order_dir)) { + $this->order_dir = 'asc'; + } + + return $this->order_dir; + } + + /** + * Gets and sets the order by which the sub-pages should be sorted. + * + * default - is the order based on the file system, ie 01.Home before 02.Advark + * title - is the order based on the title set in the pages + * date - is the order based on the date set in the pages + * folder - is the order based on the name of the folder with any numerics omitted + * + * @param string|null $var supported options include "default", "title", "date", and "folder" + * @return string supported options include "default", "title", "date", and "folder" + * @deprecated 1.6 + */ + public function orderBy($var = null) + { + //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + + if ($var !== null) { + $this->order_by = $var; + } + + return $this->order_by; + } + + /** + * Gets the manual order set in the header. + * + * @param string|null $var supported options include "default", "title", "date", and "folder" + * @return array + * @deprecated 1.6 + */ + public function orderManual($var = null) + { + //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + + if ($var !== null) { + $this->order_manual = $var; + } + + return (array)$this->order_manual; + } + + /** + * Gets and sets the maxCount field which describes how many sub-pages should be displayed if the + * sub_pages header property is set for this page object. + * + * @param int|null $var the maximum number of sub-pages + * @return int the maximum number of sub-pages + * @deprecated 1.6 + */ + public function maxCount($var = null) + { + //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + + if ($var !== null) { + $this->max_count = (int)$var; + } + if (empty($this->max_count)) { + /** @var Config $config */ + $config = Grav::instance()['config']; + $this->max_count = (int)$config->get('system.pages.list.count'); + } + + return $this->max_count; + } + + /** + * Gets and sets the taxonomy array which defines which taxonomies this page identifies itself with. + * + * @param array|null $var an array of taxonomies + * @return array an array of taxonomies + */ + public function taxonomy($var = null) + { + if ($var !== null) { + // make sure first level are arrays + array_walk($var, static function (&$value) { + $value = (array) $value; + }); + // make sure all values are strings + array_walk_recursive($var, static function (&$value) { + $value = (string) $value; + }); + $this->taxonomy = $var; + } + + return $this->taxonomy; + } + + /** + * Gets and sets the modular var that helps identify this page is a modular child + * + * @param bool|null $var true if modular_twig + * @return bool true if modular_twig + * @deprecated 1.7 Use ->isModule() or ->modularTwig() method instead. + */ + public function modular($var = null) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use ->isModule() or ->modularTwig() method instead', E_USER_DEPRECATED); + + return $this->modularTwig($var); + } + + /** + * Gets and sets the modular_twig var that helps identify this page as a modular child page that will need + * twig processing handled differently from a regular page. + * + * @param bool|null $var true if modular_twig + * @return bool true if modular_twig + */ + public function modularTwig($var = null) + { + if ($var !== null) { + $this->modular_twig = (bool)$var; + if ($var) { + $this->visible(false); + // some routable logic + if (empty($this->header->routable)) { + $this->routable = false; + } + } + } + + return $this->modular_twig ?? false; + } + + /** + * Gets the configured state of the processing method. + * + * @param string $process the process, eg "twig" or "markdown" + * @return bool whether or not the processing method is enabled for this Page + */ + public function shouldProcess($process) + { + return (bool)($this->process[$process] ?? false); + } + + /** + * Gets and Sets the parent object for this page + * + * @param PageInterface|null $var the parent page object + * @return PageInterface|null the parent page object if it exists. + */ + public function parent(PageInterface $var = null) + { + if ($var) { + $this->parent = $var->path(); + + return $var; + } + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->get($this->parent); + } + + /** + * Gets the top parent object for this page. Can return page itself. + * + * @return PageInterface The top parent page object. + */ + public function topParent() + { + $topParent = $this; + + while (true) { + $theParent = $topParent->parent(); + if ($theParent !== null && $theParent->parent() !== null) { + $topParent = $theParent; + } else { + break; + } + } + + return $topParent; + } + + /** + * Returns children of this page. + * + * @return PageCollectionInterface|Collection + */ + public function children() + { + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->children($this->path()); + } + + + /** + * Check to see if this item is the first in an array of sub-pages. + * + * @return bool True if item is first. + */ + public function isFirst() + { + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if ($collection instanceof Collection) { + return $collection->isFirst($this->path()); + } + + return true; + } + + /** + * Check to see if this item is the last in an array of sub-pages. + * + * @return bool True if item is last + */ + public function isLast() + { + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if ($collection instanceof Collection) { + return $collection->isLast($this->path()); + } + + return true; + } + + /** + * Gets the previous sibling based on current position. + * + * @return PageInterface the previous Page item + */ + public function prevSibling() + { + return $this->adjacentSibling(-1); + } + + /** + * Gets the next sibling based on current position. + * + * @return PageInterface the next Page item + */ + public function nextSibling() + { + return $this->adjacentSibling(1); + } + + /** + * Returns the adjacent sibling based on a direction. + * + * @param int $direction either -1 or +1 + * @return PageInterface|false the sibling page + */ + public function adjacentSibling($direction = 1) + { + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if ($collection instanceof Collection) { + return $collection->adjacentSibling($this->path(), $direction); + } + + return false; + } + + /** + * Returns the item in the current position. + * + * @return int|null The index of the current page. + */ + public function currentPosition() + { + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if ($collection instanceof Collection) { + return $collection->currentPosition($this->path()); + } + + return 1; + } + + /** + * Returns whether or not this page is the currently active page requested via the URL. + * + * @return bool True if it is active + */ + public function active() + { + $uri_path = rtrim(urldecode(Grav::instance()['uri']->path()), '/') ?: '/'; + $routes = Grav::instance()['pages']->routes(); + + return isset($routes[$uri_path]) && $routes[$uri_path] === $this->path(); + } + + /** + * Returns whether or not this URI's URL contains the URL of the active page. + * Or in other words, is this page's URL in the current URL + * + * @return bool True if active child exists + */ + public function activeChild() + { + $grav = Grav::instance(); + /** @var Uri $uri */ + $uri = $grav['uri']; + /** @var Pages $pages */ + $pages = $grav['pages']; + $uri_path = rtrim(urldecode($uri->path()), '/'); + $routes = $pages->routes(); + + if (isset($routes[$uri_path])) { + $page = $pages->find($uri->route()); + /** @var PageInterface|null $child_page */ + $child_page = $page ? $page->parent() : null; + while ($child_page && !$child_page->root()) { + if ($this->path() === $child_page->path()) { + return true; + } + $child_page = $child_page->parent(); + } + } + + return false; + } + + /** + * Returns whether or not this page is the currently configured home page. + * + * @return bool True if it is the homepage + */ + public function home() + { + $home = Grav::instance()['config']->get('system.home.alias'); + + return $this->route() === $home || $this->rawRoute() === $home; + } + + /** + * Returns whether or not this page is the root node of the pages tree. + * + * @return bool True if it is the root + */ + public function root() + { + return !$this->parent && !$this->name && !$this->visible; + } + + /** + * Helper method to return an ancestor page. + * + * @param bool|null $lookup Name of the parent folder + * @return PageInterface page you were looking for if it exists + */ + public function ancestor($lookup = null) + { + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->ancestor($this->route, $lookup); + } + + /** + * Helper method to return an ancestor page to inherit from. The current + * page object is returned. + * + * @param string $field Name of the parent folder + * @return PageInterface + */ + public function inherited($field) + { + [$inherited, $currentParams] = $this->getInheritedParams($field); + + $this->modifyHeader($field, $currentParams); + + return $inherited; + } + + /** + * Helper method to return an ancestor field only to inherit from. The + * first occurrence of an ancestor field will be returned if at all. + * + * @param string $field Name of the parent folder + * + * @return array + */ + public function inheritedField($field) + { + [$inherited, $currentParams] = $this->getInheritedParams($field); + + return $currentParams; + } + + /** + * Method that contains shared logic for inherited() and inheritedField() + * + * @param string $field Name of the parent folder + * @return array + */ + protected function getInheritedParams($field) + { + $pages = Grav::instance()['pages']; + + /** @var Pages $pages */ + $inherited = $pages->inherited($this->route, $field); + $inheritedParams = $inherited ? (array)$inherited->value('header.' . $field) : []; + $currentParams = (array)$this->value('header.' . $field); + if ($inheritedParams && is_array($inheritedParams)) { + $currentParams = array_replace_recursive($inheritedParams, $currentParams); + } + + return [$inherited, $currentParams]; + } + + /** + * Helper method to return a page. + * + * @param string $url the url of the page + * @param bool $all + * + * @return PageInterface page you were looking for if it exists + */ + public function find($url, $all = false) + { + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->find($url, $all); + } + + /** + * Get a collection of pages in the current context. + * + * @param string|array $params + * @param bool $pagination + * + * @return PageCollectionInterface|Collection + * @throws InvalidArgumentException + */ + public function collection($params = 'content', $pagination = true) + { + if (is_string($params)) { + // Look into a page header field. + $params = (array)$this->value('header.' . $params); + } elseif (!is_array($params)) { + throw new InvalidArgumentException('Argument should be either header variable name or array of parameters'); + } + + $params['filter'] = ($params['filter'] ?? []) + ['translated' => true]; + $context = [ + 'pagination' => $pagination, + 'self' => $this + ]; + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->getCollection($params, $context); + } + + /** + * @param string|array $value + * @param bool $only_published + * @return PageCollectionInterface|Collection + */ + public function evaluate($value, $only_published = true) + { + $params = [ + 'items' => $value, + 'published' => $only_published + ]; + $context = [ + 'event' => false, + 'pagination' => false, + 'url_taxonomy_filters' => false, + 'self' => $this + ]; + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->getCollection($params, $context); + } + + /** + * Returns whether or not this Page object has a .md file associated with it or if its just a directory. + * + * @return bool True if its a page with a .md file associated + */ + public function isPage() + { + if ($this->name) { + return true; + } + + return false; + } + + /** + * Returns whether or not this Page object is a directory or a page. + * + * @return bool True if its a directory + */ + public function isDir() + { + return !$this->isPage(); + } + + /** + * @return bool + */ + public function isModule(): bool + { + return $this->modularTwig(); + } + + /** + * Returns whether the page exists in the filesystem. + * + * @return bool + */ + public function exists() + { + $file = $this->file(); + + return $file && $file->exists(); + } + + /** + * Returns whether or not the current folder exists + * + * @return bool + */ + public function folderExists() + { + return file_exists($this->path()); + } + + /** + * Cleans the path. + * + * @param string $path the path + * @return string the path + */ + protected function cleanPath($path) + { + $lastchunk = strrchr($path, DS); + if (strpos($lastchunk, ':') !== false) { + $path = str_replace($lastchunk, '', $path); + } + + return $path; + } + + /** + * Reorders all siblings according to a defined order + * + * @param array|null $new_order + */ + protected function doReorder($new_order) + { + if (!$this->_original) { + return; + } + + $pages = Grav::instance()['pages']; + $pages->init(); + + $this->_original->path($this->path()); + + $parent = $this->parent(); + $siblings = $parent ? $parent->children() : null; + + if ($siblings) { + $siblings->order('slug', 'asc', $new_order); + + $counter = 0; + + // Reorder all moved pages. + foreach ($siblings as $slug => $page) { + $order = (int)trim($page->order(), '.'); + $counter++; + + if ($order) { + if ($page->path() === $this->path() && $this->folderExists()) { + // Handle current page; we do want to change ordering number, but nothing else. + $this->order($counter); + $this->save(false); + } else { + // Handle all the other pages. + $page = $pages->get($page->path()); + if ($page && $page->folderExists() && !$page->_action) { + $page = $page->move($this->parent()); + $page->order($counter); + $page->save(false); + } + } + } + } + } + } + + /** + * Moves or copies the page in filesystem. + * + * @internal + * @return void + * @throws Exception + */ + protected function doRelocation() + { + if (!$this->_original) { + return; + } + + if (is_dir($this->_original->path())) { + if ($this->_action === 'move') { + Folder::move($this->_original->path(), $this->path()); + } elseif ($this->_action === 'copy') { + Folder::copy($this->_original->path(), $this->path()); + } + } + + if ($this->name() !== $this->_original->name()) { + $path = $this->path(); + if (is_file($path . '/' . $this->_original->name())) { + rename($path . '/' . $this->_original->name(), $path . '/' . $this->name()); + } + } + } + + /** + * @return void + */ + protected function setPublishState() + { + // Handle publishing dates if no explicit published option set + if (Grav::instance()['config']->get('system.pages.publish_dates') && !isset($this->header->published)) { + // unpublish if required, if not clear cache right before page should be unpublished + if ($this->unpublishDate()) { + if ($this->unpublishDate() < time()) { + $this->published(false); + } else { + $this->published(); + Grav::instance()['cache']->setLifeTime($this->unpublishDate()); + } + } + // publish if required, if not clear cache right before page is published + if ($this->publishDate() && $this->publishDate() > time()) { + $this->published(false); + Grav::instance()['cache']->setLifeTime($this->publishDate()); + } + } + } + + /** + * @param string $route + * @return string + */ + protected function adjustRouteCase($route) + { + $case_insensitive = Grav::instance()['config']->get('system.force_lowercase_urls'); + + return $case_insensitive ? mb_strtolower($route) : $route; + } + + /** + * Gets the Page Unmodified (original) version of the page. + * + * @return PageInterface The original version of the page. + */ + public function getOriginal() + { + return $this->_original; + } + + /** + * Gets the action. + * + * @return string|null The Action string. + */ + public function getAction() + { + return $this->_action; + } +} diff --git a/system/src/Grav/Common/Page/Pages.php b/system/src/Grav/Common/Page/Pages.php new file mode 100644 index 0000000..9dd8ee6 --- /dev/null +++ b/system/src/Grav/Common/Page/Pages.php @@ -0,0 +1,2258 @@ + */ + protected $instances = []; + /** @var array */ + protected $index = []; + /** @var array */ + protected $children; + /** @var string */ + protected $base = ''; + /** @var string[] */ + protected $baseRoute = []; + /** @var string[] */ + protected $routes = []; + /** @var array */ + protected $sort; + /** @var Blueprints */ + protected $blueprints; + /** @var bool */ + protected $enable_pages = true; + /** @var int */ + protected $last_modified; + /** @var string[] */ + protected $ignore_files; + /** @var string[] */ + protected $ignore_folders; + /** @var bool */ + protected $ignore_hidden; + /** @var string */ + protected $check_method; + /** @var string */ + protected $simple_pages_hash; + /** @var string */ + protected $pages_cache_id; + /** @var bool */ + protected $initialized = false; + /** @var string */ + protected $active_lang; + /** @var bool */ + protected $fire_events = false; + /** @var Types|null */ + protected static $types; + /** @var string|null */ + protected static $home_route; + + + /** + * Constructor + * + * @param Grav $grav + */ + public function __construct(Grav $grav) + { + $this->grav = $grav; + } + + /** + * @return FlexDirectory|null + */ + public function getDirectory(): ?FlexDirectory + { + return $this->directory; + } + + /** + * Method used in admin to disable frontend pages from being initialized. + */ + public function disablePages(): void + { + $this->enable_pages = false; + } + + /** + * Method used in admin to later load frontend pages. + */ + public function enablePages(): void + { + if (!$this->enable_pages) { + $this->enable_pages = true; + + $this->init(); + } + } + + /** + * Get or set base path for the pages. + * + * @param string|null $path + * @return string + */ + public function base($path = null) + { + if ($path !== null) { + $path = trim($path, '/'); + $this->base = $path ? '/' . $path : ''; + $this->baseRoute = []; + } + + return $this->base; + } + + /** + * + * Get base route for Grav pages. + * + * @param string|null $lang Optional language code for multilingual routes. + * @return string + */ + public function baseRoute($lang = null) + { + $key = $lang ?: $this->active_lang ?: 'default'; + + if (!isset($this->baseRoute[$key])) { + /** @var Language $language */ + $language = $this->grav['language']; + + $path_base = rtrim($this->base(), '/'); + $path_lang = $language->enabled() ? $language->getLanguageURLPrefix($lang) : ''; + + $this->baseRoute[$key] = $path_base . $path_lang; + } + + return $this->baseRoute[$key]; + } + + /** + * + * Get route for Grav site. + * + * @param string $route Optional route to the page. + * @param string|null $lang Optional language code for multilingual links. + * @return string + */ + public function route($route = '/', $lang = null) + { + if (!$route || $route === '/') { + return $this->baseRoute($lang) ?: '/'; + } + + return $this->baseRoute($lang) . $route; + } + + /** + * Get relative referrer route and language code. Returns null if the route isn't within the current base, language (if set) and route. + * + * @example `$langCode = null; $referrer = $pages->referrerRoute($langCode, '/admin');` returns relative referrer url within /admin and updates $langCode + * @example `$langCode = 'en'; $referrer = $pages->referrerRoute($langCode, '/admin');` returns relative referrer url within the /en/admin + * + * @param string|null $langCode Variable to store the language code. If already set, check only against that language. + * @param string $route Optional route within the site. + * @return string|null + * @since 1.7.23 + */ + public function referrerRoute(?string &$langCode, string $route = '/'): ?string + { + $referrer = $_SERVER['HTTP_REFERER'] ?? null; + + // Start by checking that referrer came from our site. + $root = $this->grav['base_url_absolute']; + if (!is_string($referrer) || !str_starts_with($referrer, $root)) { + return null; + } + + /** @var Language $language */ + $language = $this->grav['language']; + + // Get all language codes and append no language. + if (null === $langCode) { + $languages = $language->enabled() ? $language->getLanguages() : []; + $languages[] = ''; + } else { + $languages[] = $langCode; + } + + $path_base = rtrim($this->base(), '/'); + $path_route = rtrim($route, '/'); + + // Try to figure out the language code. + foreach ($languages as $code) { + $path_lang = $code ? "/{$code}" : ''; + + $base = $path_base . $path_lang . $path_route; + if ($referrer === $base || str_starts_with($referrer, "{$base}/")) { + if (null === $langCode) { + $langCode = $code; + } + + return substr($referrer, \strlen($base)); + } + } + + return null; + } + + /** + * + * Get base URL for Grav pages. + * + * @param string|null $lang Optional language code for multilingual links. + * @param bool|null $absolute If true, return absolute url, if false, return relative url. Otherwise return default. + * @return string + */ + public function baseUrl($lang = null, $absolute = null) + { + if ($absolute === null) { + $type = 'base_url'; + } elseif ($absolute) { + $type = 'base_url_absolute'; + } else { + $type = 'base_url_relative'; + } + + return $this->grav[$type] . $this->baseRoute($lang); + } + + /** + * + * Get home URL for Grav site. + * + * @param string|null $lang Optional language code for multilingual links. + * @param bool|null $absolute If true, return absolute url, if false, return relative url. Otherwise return default. + * @return string + */ + public function homeUrl($lang = null, $absolute = null) + { + return $this->baseUrl($lang, $absolute) ?: '/'; + } + + /** + * + * Get URL for Grav site. + * + * @param string $route Optional route to the page. + * @param string|null $lang Optional language code for multilingual links. + * @param bool|null $absolute If true, return absolute url, if false, return relative url. Otherwise return default. + * @return string + */ + public function url($route = '/', $lang = null, $absolute = null) + { + if (!$route || $route === '/') { + return $this->homeUrl($lang, $absolute); + } + + return $this->baseUrl($lang, $absolute) . Uri::filterPath($route); + } + + /** + * @param string $method + * @return void + */ + public function setCheckMethod($method): void + { + $this->check_method = strtolower($method); + } + + /** + * @return void + */ + public function register(): void + { + $config = $this->grav['config']; + $type = $config->get('system.pages.type'); + if ($type === 'flex') { + $this->initFlexPages(); + } + } + + /** + * Reset pages (used in search indexing etc). + * + * @return void + */ + public function reset(): void + { + $this->initialized = false; + + $this->init(); + } + + /** + * Class initialization. Must be called before using this class. + */ + public function init(): void + { + if ($this->initialized) { + return; + } + + $config = $this->grav['config']; + $this->ignore_files = (array)$config->get('system.pages.ignore_files'); + $this->ignore_folders = (array)$config->get('system.pages.ignore_folders'); + $this->ignore_hidden = (bool)$config->get('system.pages.ignore_hidden'); + $this->fire_events = (bool)$config->get('system.pages.events.page'); + + $this->instances = []; + $this->index = []; + $this->children = []; + $this->routes = []; + + if (!$this->check_method) { + $this->setCheckMethod($config->get('system.cache.check.method', 'file')); + } + + if ($this->enable_pages === false) { + $page = $this->buildRootPage(); + $this->instances[$page->path()] = $page; + + return; + } + + $this->buildPages(); + + $this->initialized = true; + } + + /** + * Get or set last modification time. + * + * @param int|null $modified + * @return int|null + */ + public function lastModified($modified = null) + { + if ($modified && $modified > $this->last_modified) { + $this->last_modified = $modified; + } + + return $this->last_modified; + } + + /** + * Returns a list of all pages. + * + * @return PageInterface[] + */ + public function instances() + { + $instances = []; + foreach ($this->index as $path => $instance) { + $page = $this->get($path); + if ($page) { + $instances[$path] = $page; + } + } + + return $instances; + } + + /** + * Returns a list of all routes. + * + * @return array + */ + public function routes() + { + return $this->routes; + } + + /** + * Adds a page and assigns a route to it. + * + * @param PageInterface $page Page to be added. + * @param string|null $route Optional route (uses route from the object if not set). + */ + public function addPage(PageInterface $page, $route = null): void + { + $path = $page->path() ?? ''; + if (!isset($this->index[$path])) { + $this->index[$path] = $page; + $this->instances[$path] = $page; + } + $route = $page->route($route); + $parent = $page->parent(); + if ($parent) { + $this->children[$parent->path() ?? ''][$path] = ['slug' => $page->slug()]; + } + $this->routes[$route] = $path; + + $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page])); + } + + /** + * Get a collection of pages in the given context. + * + * @param array $params + * @param array $context + * @return PageCollectionInterface|Collection + */ + public function getCollection(array $params = [], array $context = []) + { + if (!isset($params['items'])) { + return new Collection(); + } + + /** @var Config $config */ + $config = $this->grav['config']; + + $context += [ + 'event' => true, + 'pagination' => true, + 'url_taxonomy_filters' => $config->get('system.pages.url_taxonomy_filters'), + 'taxonomies' => (array)$config->get('site.taxonomies'), + 'pagination_page' => 1, + 'self' => null, + ]; + + // Include taxonomies from the URL if requested. + $process_taxonomy = $params['url_taxonomy_filters'] ?? $context['url_taxonomy_filters']; + if ($process_taxonomy) { + /** @var Uri $uri */ + $uri = $this->grav['uri']; + foreach ($context['taxonomies'] as $taxonomy) { + $param = $uri->param(rawurlencode($taxonomy)); + $items = is_string($param) ? explode(',', $param) : []; + foreach ($items as $item) { + $params['taxonomies'][$taxonomy][] = htmlspecialchars_decode(rawurldecode($item), ENT_QUOTES); + } + } + } + + $pagination = $params['pagination'] ?? $context['pagination']; + if ($pagination && !isset($params['page'], $params['start'])) { + /** @var Uri $uri */ + $uri = $this->grav['uri']; + $context['current_page'] = $uri->currentPage(); + } + + $collection = $this->evaluate($params['items'], $context['self']); + $collection->setParams($params); + + // Filter by taxonomies. + foreach ($params['taxonomies'] ?? [] as $taxonomy => $items) { + foreach ($collection as $page) { + // Don't include modules + if ($page->isModule()) { + continue; + } + + $test = $page->taxonomy()[$taxonomy] ?? []; + foreach ($items as $item) { + if (!$test || !in_array($item, $test, true)) { + $collection->remove($page->path()); + } + } + } + } + + $filters = $params['filter'] ?? []; + + // Assume published=true if not set. + if (!isset($filters['published']) && !isset($filters['non-published'])) { + $filters['published'] = true; + } + + // Remove any inclusive sets from filter. + $sets = ['published', 'visible', 'modular', 'routable']; + foreach ($sets as $type) { + $nonType = "non-{$type}"; + if (isset($filters[$type], $filters[$nonType]) && $filters[$type] === $filters[$nonType]) { + if (!$filters[$type]) { + // Both options are false, return empty collection as nothing can match the filters. + return new Collection(); + } + + // Both options are true, remove opposite filters as all pages will match the filters. + unset($filters[$type], $filters[$nonType]); + } + } + + // Filter the collection + foreach ($filters as $type => $filter) { + if (null === $filter) { + continue; + } + + // Convert non-type to type. + if (str_starts_with($type, 'non-')) { + $type = substr($type, 4); + $filter = !$filter; + } + + switch ($type) { + case 'translated': + if ($filter) { + $collection = $collection->translated(); + } else { + $collection = $collection->nonTranslated(); + } + break; + case 'published': + if ($filter) { + $collection = $collection->published(); + } else { + $collection = $collection->nonPublished(); + } + break; + case 'visible': + if ($filter) { + $collection = $collection->visible(); + } else { + $collection = $collection->nonVisible(); + } + break; + case 'page': + if ($filter) { + $collection = $collection->pages(); + } else { + $collection = $collection->modules(); + } + break; + case 'module': + case 'modular': + if ($filter) { + $collection = $collection->modules(); + } else { + $collection = $collection->pages(); + } + break; + case 'routable': + if ($filter) { + $collection = $collection->routable(); + } else { + $collection = $collection->nonRoutable(); + } + break; + case 'type': + $collection = $collection->ofType($filter); + break; + case 'types': + $collection = $collection->ofOneOfTheseTypes($filter); + break; + case 'access': + $collection = $collection->ofOneOfTheseAccessLevels($filter); + break; + } + } + + if (isset($params['dateRange'])) { + $start = $params['dateRange']['start'] ?? null; + $end = $params['dateRange']['end'] ?? null; + $field = $params['dateRange']['field'] ?? null; + $collection = $collection->dateRange($start, $end, $field); + } + + if (isset($params['order'])) { + $by = $params['order']['by'] ?? 'default'; + $dir = $params['order']['dir'] ?? 'asc'; + $custom = $params['order']['custom'] ?? null; + $sort_flags = $params['order']['sort_flags'] ?? null; + + if (is_array($sort_flags)) { + $sort_flags = array_map('constant', $sort_flags); //transform strings to constant value + $sort_flags = array_reduce($sort_flags, static function ($a, $b) { + return $a | $b; + }, 0); //merge constant values using bit or + } + + $collection = $collection->order($by, $dir, $custom, $sort_flags); + } + + // New Custom event to handle things like pagination. + if ($context['event']) { + $this->grav->fireEvent('onCollectionProcessed', new Event(['collection' => $collection, 'context' => $context])); + } + + if ($context['pagination']) { + // Slice and dice the collection if pagination is required + $params = $collection->params(); + + $limit = (int)($params['limit'] ?? 0); + $page = (int)($params['page'] ?? $context['current_page'] ?? 0); + $start = (int)($params['start'] ?? 0); + $start = $limit > 0 && $page > 0 ? ($page - 1) * $limit : max(0, $start); + + if ($start || ($limit && $collection->count() > $limit)) { + $collection->slice($start, $limit ?: null); + } + } + + return $collection; + } + + /** + * @param array|string $value + * @param PageInterface|null $self + * @return Collection + */ + protected function evaluate($value, PageInterface $self = null) + { + // Parse command. + if (is_string($value)) { + // Format: @command.param + $cmd = $value; + $params = []; + } elseif (is_array($value) && count($value) === 1 && !is_int(key($value))) { + // Format: @command.param: { attr1: value1, attr2: value2 } + $cmd = (string)key($value); + $params = (array)current($value); + } else { + $result = []; + foreach ((array)$value as $key => $val) { + if (is_int($key)) { + $result = $result + $this->evaluate($val, $self)->toArray(); + } else { + $result = $result + $this->evaluate([$key => $val], $self)->toArray(); + } + } + + return new Collection($result); + } + + $parts = explode('.', $cmd); + $scope = array_shift($parts); + $type = $parts[0] ?? null; + + /** @var PageInterface|null $page */ + $page = null; + switch ($scope) { + case 'self@': + case '@self': + $page = $self; + break; + + case 'page@': + case '@page': + $page = isset($params[0]) ? $this->find($params[0]) : null; + break; + + case 'root@': + case '@root': + $page = $this->root(); + break; + + case 'taxonomy@': + case '@taxonomy': + // Gets a collection of pages by using one of the following formats: + // @taxonomy.category: blog + // @taxonomy.category: [ blog, featured ] + // @taxonomy: { category: [ blog, featured ], level: 1 } + + /** @var Taxonomy $taxonomy_map */ + $taxonomy_map = Grav::instance()['taxonomy']; + + if (!empty($parts)) { + $params = [implode('.', $parts) => $params]; + } + + return $taxonomy_map->findTaxonomy($params); + } + + if (!$page) { + return new Collection(); + } + + // Handle '@page', '@page.modular: false', '@self' and '@self.modular: false'. + if (null === $type || (in_array($type, ['modular', 'modules']) && ($params[0] ?? null) === false)) { + $type = 'children'; + } + + switch ($type) { + case 'all': + $collection = $page->children(); + break; + case 'modules': + case 'modular': + $collection = $page->children()->modules(); + break; + case 'pages': + case 'children': + $collection = $page->children()->pages(); + break; + case 'page': + case 'self': + $collection = !$page->root() ? (new Collection())->addPage($page) : new Collection(); + break; + case 'parent': + $parent = $page->parent(); + $collection = new Collection(); + $collection = $parent ? $collection->addPage($parent) : $collection; + break; + case 'siblings': + $parent = $page->parent(); + if ($parent) { + /** @var Collection $collection */ + $collection = $parent->children(); + $collection = $collection->remove($page->path()); + } else { + $collection = new Collection(); + } + break; + case 'descendants': + $collection = $this->all($page)->remove($page->path())->pages(); + break; + default: + // Unknown type; return empty collection. + $collection = new Collection(); + break; + } + + return $collection; + } + + /** + * Sort sub-pages in a page. + * + * @param PageInterface $page + * @param string|null $order_by + * @param string|null $order_dir + * @return array + */ + public function sort(PageInterface $page, $order_by = null, $order_dir = null, $sort_flags = null) + { + if ($order_by === null) { + $order_by = $page->orderBy(); + } + if ($order_dir === null) { + $order_dir = $page->orderDir(); + } + + $path = $page->path(); + if (null === $path) { + return []; + } + + $children = $this->children[$path] ?? []; + + if (!$children) { + return $children; + } + + if (!isset($this->sort[$path][$order_by])) { + $this->buildSort($path, $children, $order_by, $page->orderManual(), $sort_flags); + } + + $sort = $this->sort[$path][$order_by]; + + if ($order_dir !== 'asc') { + $sort = array_reverse($sort); + } + + return $sort; + } + + /** + * @param Collection $collection + * @param string $orderBy + * @param string $orderDir + * @param array|null $orderManual + * @param int|null $sort_flags + * @return array + * @internal + */ + public function sortCollection(Collection $collection, $orderBy, $orderDir = 'asc', $orderManual = null, $sort_flags = null) + { + $items = $collection->toArray(); + if (!$items) { + return []; + } + + $lookup = md5(json_encode($items) . json_encode($orderManual) . $orderBy . $orderDir); + if (!isset($this->sort[$lookup][$orderBy])) { + $this->buildSort($lookup, $items, $orderBy, $orderManual, $sort_flags); + } + + $sort = $this->sort[$lookup][$orderBy]; + + if ($orderDir !== 'asc') { + $sort = array_reverse($sort); + } + + return $sort; + } + + /** + * Get a page instance. + * + * @param string $path The filesystem full path of the page + * @return PageInterface|null + * @throws RuntimeException + */ + public function get($path) + { + $path = (string)$path; + if ($path === '') { + return null; + } + + // Check for local instances first. + if (array_key_exists($path, $this->instances)) { + return $this->instances[$path]; + } + + $instance = $this->index[$path] ?? null; + if (is_string($instance)) { + if ($this->directory) { + /** @var Language $language */ + $language = $this->grav['language']; + $lang = $language->getActive(); + if ($lang) { + $languages = $language->getFallbackLanguages($lang, true); + $key = $instance; + $instance = null; + foreach ($languages as $code) { + $test = $code ? $key . ':' . $code : $key; + if (($instance = $this->directory->getObject($test, 'flex_key')) !== null) { + break; + } + } + } else { + $instance = $this->directory->getObject($instance, 'flex_key'); + } + } + + if ($instance instanceof PageInterface) { + if ($this->fire_events && method_exists($instance, 'initialize')) { + $instance->initialize(); + } + } else { + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addMessage(sprintf('Flex page %s is missing or broken!', $instance), 'debug'); + } + } + + if ($instance) { + $this->instances[$path] = $instance; + } + + return $instance; + } + + /** + * Get children of the path. + * + * @param string $path + * @return Collection + */ + public function children($path) + { + $children = $this->children[(string)$path] ?? []; + + return new Collection($children, [], $this); + } + + /** + * Get a page ancestor. + * + * @param string $route The relative URL of the page + * @param string|null $path The relative path of the ancestor folder + * @return PageInterface|null + */ + public function ancestor($route, $path = null) + { + if ($path !== null) { + $page = $this->find($route, true); + + if ($page && $page->path() === $path) { + return $page; + } + + $parent = $page ? $page->parent() : null; + if ($parent && !$parent->root()) { + return $this->ancestor($parent->route(), $path); + } + } + + return null; + } + + /** + * Get a page ancestor trait. + * + * @param string $route The relative route of the page + * @param string|null $field The field name of the ancestor to query for + * @return PageInterface|null + */ + public function inherited($route, $field = null) + { + if ($field !== null) { + $page = $this->find($route, true); + + $parent = $page ? $page->parent() : null; + if ($parent && $parent->value('header.' . $field) !== null) { + return $parent; + } + if ($parent && !$parent->root()) { + return $this->inherited($parent->route(), $field); + } + } + + return null; + } + + /** + * Find a page based on route. + * + * @param string $route The route of the page + * @param bool $all If true, return also non-routable pages, otherwise return null if page isn't routable + * @return PageInterface|null + */ + public function find($route, $all = false) + { + $route = urldecode((string)$route); + + // Fetch page if there's a defined route to it. + $path = $this->routes[$route] ?? null; + $page = null !== $path ? $this->get($path) : null; + + // Try without trailing slash + if (null === $page && Utils::endsWith($route, '/')) { + $path = $this->routes[rtrim($route, '/')] ?? null; + $page = null !== $path ? $this->get($path) : null; + } + + if (!$all && !isset($this->grav['admin'])) { + if (null === $page || !$page->routable()) { + // If the page cannot be accessed, look for the site wide routes and wildcards. + $page = $this->findSiteBasedRoute($route) ?? $page; + } + } + + return $page; + } + + /** + * Check site based routes. + * + * @param string $route + * @return PageInterface|null + */ + protected function findSiteBasedRoute($route) + { + /** @var Config $config */ + $config = $this->grav['config']; + + $site_routes = $config->get('site.routes'); + if (!is_array($site_routes)) { + return null; + } + + $page = null; + + // See if route matches one in the site configuration + $site_route = $site_routes[$route] ?? null; + if ($site_route) { + $page = $this->find($site_route); + } else { + // Use reverse order because of B/C (previously matched multiple and returned the last match). + foreach (array_reverse($site_routes, true) as $pattern => $replace) { + $pattern = '#^' . str_replace('/', '\/', ltrim($pattern, '^')) . '#'; + try { + $found = preg_replace($pattern, $replace, $route); + if ($found && $found !== $route) { + $page = $this->find($found); + if ($page) { + return $page; + } + } + } catch (ErrorException $e) { + $this->grav['log']->error('site.routes: ' . $pattern . '-> ' . $e->getMessage()); + } + } + } + + return $page; + } + + /** + * Dispatch URI to a page. + * + * @param string $route The relative URL of the page + * @param bool $all If true, return also non-routable pages, otherwise return null if page isn't routable + * @param bool $redirect If true, allow redirects + * @return PageInterface|null + * @throws Exception + */ + public function dispatch($route, $all = false, $redirect = true) + { + $page = $this->find($route, true); + + // If we want all pages or are in admin, return what we already have. + if ($all || isset($this->grav['admin'])) { + return $page; + } + + if ($page) { + $routable = $page->routable(); + if ($redirect) { + if ($page->redirect()) { + // Follow a redirect page. + $this->grav->redirectLangSafe($page->redirect()); + } + + if (!$routable) { + /** @var Collection $children */ + $children = $page->children()->visible()->routable()->published(); + $child = $children->first(); + if ($child !== null) { + // Redirect to the first visible child as current page isn't routable. + $this->grav->redirectLangSafe($child->route()); + } + } + } + + if ($routable) { + return $page; + } + } + + $route = urldecode((string)$route); + + // The page cannot be reached, look into site wide redirects, routes and wildcards. + $redirectedPage = $this->findSiteBasedRoute($route); + if ($redirectedPage) { + $page = $this->dispatch($redirectedPage->route(), false, $redirect); + } + + /** @var Config $config */ + $config = $this->grav['config']; + + /** @var Uri $uri */ + $uri = $this->grav['uri']; + /** @var \Grav\Framework\Uri\Uri $source_url */ + $source_url = $uri->uri(false); + + // Try Regex style redirects + $site_redirects = $config->get('site.redirects'); + if (is_array($site_redirects)) { + foreach ((array)$site_redirects as $pattern => $replace) { + $pattern = ltrim($pattern, '^'); + $pattern = '#^' . str_replace('/', '\/', $pattern) . '#'; + try { + /** @var string $found */ + $found = preg_replace($pattern, $replace, $source_url); + if ($found && $found !== $source_url) { + $this->grav->redirectLangSafe($found); + } + } catch (ErrorException $e) { + $this->grav['log']->error('site.redirects: ' . $pattern . '-> ' . $e->getMessage()); + } + } + } + + return $page; + } + + /** + * Get root page. + * + * @return PageInterface + * @throws RuntimeException + */ + public function root() + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + $path = $locator->findResource('page://'); + $root = is_string($path) ? $this->get(rtrim($path, '/')) : null; + if (null === $root) { + throw new RuntimeException('Internal error'); + } + + return $root; + } + + /** + * Get a blueprint for a page type. + * + * @param string $type + * @return Blueprint + */ + public function blueprints($type) + { + if ($this->blueprints === null) { + $this->blueprints = new Blueprints(self::getTypes()); + } + + try { + $blueprint = $this->blueprints->get($type); + } catch (RuntimeException $e) { + $blueprint = $this->blueprints->get('default'); + } + + if (empty($blueprint->initialized)) { + $blueprint->initialized = true; + $this->grav->fireEvent('onBlueprintCreated', new Event(['blueprint' => $blueprint, 'type' => $type])); + } + + return $blueprint; + } + + /** + * Get all pages + * + * @param PageInterface|null $current + * @return Collection + */ + public function all(PageInterface $current = null) + { + $all = new Collection(); + + /** @var PageInterface $current */ + $current = $current ?: $this->root(); + + if (!$current->root()) { + $all[$current->path()] = ['slug' => $current->slug()]; + } + + foreach ($current->children() as $next) { + $all->append($this->all($next)); + } + + return $all; + } + + /** + * Get available parents raw routes. + * + * @return array + */ + public static function parentsRawRoutes() + { + $rawRoutes = true; + + return self::getParents($rawRoutes); + } + + /** + * Get available parents routes + * + * @param bool $rawRoutes get the raw route or the normal route + * @return array + */ + private static function getParents($rawRoutes) + { + $grav = Grav::instance(); + + /** @var Pages $pages */ + $pages = $grav['pages']; + + $parents = $pages->getList(null, 0, $rawRoutes); + + if (isset($grav['admin'])) { + // Remove current route from parents + + /** @var Admin $admin */ + $admin = $grav['admin']; + + $page = $admin->getPage($admin->route); + $page_route = $page->route(); + if (isset($parents[$page_route])) { + unset($parents[$page_route]); + } + } + + return $parents; + } + + /** + * Get list of route/title of all pages. Title is in HTML. + * + * @param PageInterface|null $current + * @param int $level + * @param bool $rawRoutes + * @param bool $showAll + * @param bool $showFullpath + * @param bool $showSlug + * @param bool $showModular + * @param bool $limitLevels + * @return array + */ + public function getList(PageInterface $current = null, $level = 0, $rawRoutes = false, $showAll = true, $showFullpath = false, $showSlug = false, $showModular = false, $limitLevels = false) + { + if (!$current) { + if ($level) { + throw new RuntimeException('Internal error'); + } + + $current = $this->root(); + } + + $list = []; + + if (!$current->root()) { + if ($rawRoutes) { + $route = $current->rawRoute(); + } else { + $route = $current->route(); + } + + if ($showFullpath) { + $option = htmlspecialchars($current->route()); + } else { + $extra = $showSlug ? '(' . $current->slug() . ') ' : ''; + $option = str_repeat('—-', $level). '▸ ' . $extra . htmlspecialchars($current->title()); + } + + $list[$route] = $option; + } + + if ($limitLevels === false || ($level+1 < $limitLevels)) { + foreach ($current->children() as $next) { + if ($showAll || $next->routable() || ($next->isModule() && $showModular)) { + $list = array_merge($list, $this->getList($next, $level + 1, $rawRoutes, $showAll, $showFullpath, $showSlug, $showModular, $limitLevels)); + } + } + } + + return $list; + } + + /** + * Get available page types. + * + * @return Types + */ + public static function getTypes() + { + if (null === self::$types) { + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + // Prevent calls made before theme:// has been initialized (happens when upgrading old version of Admin plugin). + if (!$locator->isStream('theme://')) { + return new Types(); + } + + $scanBlueprintsAndTemplates = static function (Types $types) use ($grav) { + // Scan blueprints + $event = new TypesEvent(); + $event->types = $types; + $grav->fireEvent('onGetPageBlueprints', $event); + + $types->init(); + + // Try new location first. + $lookup = 'theme://blueprints/pages/'; + if (!is_dir($lookup)) { + $lookup = 'theme://blueprints/'; + } + $types->scanBlueprints($lookup); + + // Scan templates + $event = new TypesEvent(); + $event->types = $types; + $grav->fireEvent('onGetPageTemplates', $event); + + $types->scanTemplates('theme://templates/'); + }; + + if ($grav['config']->get('system.cache.enabled')) { + /** @var Cache $cache */ + $cache = $grav['cache']; + + // Use cached types if possible. + $types_cache_id = md5('types'); + $types = $cache->fetch($types_cache_id); + + if (!$types instanceof Types) { + $types = new Types(); + $scanBlueprintsAndTemplates($types); + $cache->save($types_cache_id, $types); + } + } else { + $types = new Types(); + $scanBlueprintsAndTemplates($types); + } + + // Register custom paths to the locator. + $locator = $grav['locator']; + foreach ($types as $type => $paths) { + foreach ($paths as $k => $path) { + if (strpos($path, 'blueprints://') === 0) { + unset($paths[$k]); + } + } + if ($paths) { + $locator->addPath('blueprints', "pages/$type.yaml", $paths); + } + } + + self::$types = $types; + } + + return self::$types; + } + + /** + * Get available page types. + * + * @return array + */ + public static function types() + { + $types = self::getTypes(); + + return $types->pageSelect(); + } + + /** + * Get available page types. + * + * @return array + */ + public static function modularTypes() + { + $types = self::getTypes(); + + return $types->modularSelect(); + } + + /** + * Get template types based on page type (standard or modular) + * + * @param string|null $type + * @return array + */ + public static function pageTypes($type = null) + { + if (null === $type && isset(Grav::instance()['admin'])) { + /** @var Admin $admin */ + $admin = Grav::instance()['admin']; + + /** @var PageInterface|null $page */ + $page = $admin->page(); + + $type = $page && $page->isModule() ? 'modular' : 'standard'; + } + + switch ($type) { + case 'standard': + return static::types(); + case 'modular': + return static::modularTypes(); + } + + return []; + } + + /** + * Get access levels of the site pages + * + * @return array + */ + public function accessLevels() + { + $accessLevels = []; + foreach ($this->all() as $page) { + if ($page instanceof PageInterface && isset($page->header()->access)) { + if (is_array($page->header()->access)) { + foreach ($page->header()->access as $index => $accessLevel) { + if (is_array($accessLevel)) { + foreach ($accessLevel as $innerIndex => $innerAccessLevel) { + $accessLevels[] = $innerIndex; + } + } else { + $accessLevels[] = $index; + } + } + } else { + $accessLevels[] = $page->header()->access; + } + } + } + + return array_unique($accessLevels); + } + + /** + * Get available parents routes + * + * @return array + */ + public static function parents() + { + $rawRoutes = false; + + return self::getParents($rawRoutes); + } + + /** + * Gets the home route + * + * @return string + */ + public static function getHomeRoute() + { + if (empty(self::$home_route)) { + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + /** @var Language $language */ + $language = $grav['language']; + + $home = $config->get('system.home.alias'); + + if ($language->enabled()) { + $home_aliases = $config->get('system.home.aliases'); + if ($home_aliases) { + $active = $language->getActive(); + $default = $language->getDefault(); + + try { + if ($active) { + $home = $home_aliases[$active]; + } else { + $home = $home_aliases[$default]; + } + } catch (ErrorException $e) { + $home = $home_aliases[$default]; + } + } + } + + self::$home_route = trim($home, '/'); + } + + return self::$home_route; + } + + /** + * Needed for testing where we change the home route via config + * + * @return string|null + */ + public static function resetHomeRoute() + { + self::$home_route = null; + + return self::getHomeRoute(); + } + + protected function initFlexPages(): void + { + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addMessage('Pages: Flex Directory'); + + /** @var Flex $flex */ + $flex = $this->grav['flex']; + $directory = $flex->getDirectory('pages'); + + /** @var EventDispatcher $dispatcher */ + $dispatcher = $this->grav['events']; + + // Stop /admin/pages from working, display error instead. + $dispatcher->addListener( + 'onAdminPage', + static function (Event $event) use ($directory) { + $grav = Grav::instance(); + $admin = $grav['admin']; + [$base,$location,] = $admin->getRouteDetails(); + if ($location !== 'pages' || isset($grav['flex_objects'])) { + return; + } + + /** @var PageInterface $page */ + $page = $event['page']; + $page->init(new SplFileInfo('plugin://admin/pages/admin/error.md')); + $page->routable(true); + $header = $page->header(); + $header->title = 'Please install missing plugin'; + $page->content("## Please install and enable **[Flex Objects]({$base}/plugins/flex-objects)** plugin. It is required to edit **Flex Pages**."); + + /** @var Header $header */ + $header = $page->header(); + $menu = $directory->getConfig('admin.menu.list'); + $header->access = $menu['authorize'] ?? ['admin.super']; + }, + 100000 + ); + + $this->directory = $directory; + } + + /** + * Builds pages. + * + * @internal + */ + protected function buildPages(): void + { + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->startTimer('build-pages', 'Init frontend routes'); + + if ($this->directory) { + $this->buildFlexPages($this->directory); + } else { + $this->buildRegularPages(); + } + $debugger->stopTimer('build-pages'); + } + + protected function buildFlexPages(FlexDirectory $directory): void + { + /** @var Config $config */ + $config = $this->grav['config']; + + // TODO: right now we are just emulating normal pages, it is inefficient and bad... but works! + /** @var PageCollection|PageIndex $collection */ + $collection = $directory->getIndex(null, 'storage_key'); + $cache = $directory->getCache('index'); + + /** @var Language $language */ + $language = $this->grav['language']; + + $this->pages_cache_id = 'pages-' . md5($collection->getCacheChecksum() . $language->getActive() . $config->checksum()); + + $cached = $cache->get($this->pages_cache_id); + + if ($cached && $this->getVersion() === $cached[0]) { + [, $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort] = $cached; + + /** @var Taxonomy $taxonomy */ + $taxonomy = $this->grav['taxonomy']; + $taxonomy->taxonomy($taxonomy_map); + + return; + } + + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addMessage('Page cache missed, rebuilding Flex Pages..'); + + $root = $collection->getRoot(); + $root_path = $root->path(); + $this->routes = []; + $this->instances = [$root_path => $root]; + $this->index = [$root_path => $root]; + $this->children = []; + $this->sort = []; + + if ($this->fire_events) { + $this->grav->fireEvent('onBuildPagesInitialized'); + } + + /** @var PageInterface $page */ + foreach ($collection as $page) { + $path = $page->path(); + if (null === $path) { + throw new RuntimeException('Internal error'); + } + + if ($page instanceof FlexTranslateInterface) { + $page = $page->hasTranslation() ? $page->getTranslation() : null; + } + + if (!$page instanceof FlexPageObject || $path === $root_path) { + continue; + } + + if ($this->fire_events) { + if (method_exists($page, 'initialize')) { + $page->initialize(); + } else { + // TODO: Deprecated, only used in 1.7 betas. + $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page])); + } + } + + $parent = dirname($path); + + $route = $page->rawRoute(); + + // Skip duplicated empty folders (git revert does not remove those). + // TODO: still not perfect, will only work if the page has been translated. + if (isset($this->routes[$route])) { + $oldPath = $this->routes[$route]; + if ($page->isPage()) { + unset($this->index[$oldPath], $this->children[dirname($oldPath)][$oldPath]); + } else { + continue; + } + } + + $this->routes[$route] = $path; + $this->instances[$path] = $page; + $this->index[$path] = $page->getFlexKey(); + // FIXME: ... better... + $this->children[$parent][$path] = ['slug' => $page->slug()]; + if (!isset($this->children[$path])) { + $this->children[$path] = []; + } + } + + foreach ($this->children as $path => $list) { + $page = $this->instances[$path] ?? null; + if (null === $page) { + continue; + } + // Call onFolderProcessed event. + if ($this->fire_events) { + $this->grav->fireEvent('onFolderProcessed', new Event(['page' => $page])); + } + // Sort the children. + $this->children[$path] = $this->sort($page); + } + + $this->routes = []; + $this->buildRoutes(); + + // cache if needed + if (null !== $cache) { + /** @var Taxonomy $taxonomy */ + $taxonomy = $this->grav['taxonomy']; + $taxonomy_map = $taxonomy->taxonomy(); + + // save pages, routes, taxonomy, and sort to cache + $cache->set($this->pages_cache_id, [$this->getVersion(), $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort]); + } + } + + /** + * @return Page + */ + protected function buildRootPage() + { + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $path = $locator->findResource('page://'); + if (!is_string($path)) { + throw new RuntimeException('Internal Error'); + } + + /** @var Config $config */ + $config = $grav['config']; + + $page = new Page(); + $page->path($path); + $page->orderDir($config->get('system.pages.order.dir')); + $page->orderBy($config->get('system.pages.order.by')); + $page->modified(0); + $page->routable(false); + $page->template('default'); + $page->extension('.md'); + + return $page; + } + + protected function buildRegularPages(): void + { + /** @var Config $config */ + $config = $this->grav['config']; + + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + /** @var Language $language */ + $language = $this->grav['language']; + + $pages_dirs = $this->getPagesPaths(); + + // Set active language + $this->active_lang = $language->getActive(); + + if ($config->get('system.cache.enabled')) { + /** @var Language $language */ + $language = $this->grav['language']; + + // how should we check for last modified? Default is by file + switch ($this->check_method) { + case 'none': + case 'off': + $hash = 0; + break; + case 'folder': + $hash = Folder::lastModifiedFolder($pages_dirs); + break; + case 'hash': + $hash = Folder::hashAllFiles($pages_dirs); + break; + default: + $hash = Folder::lastModifiedFile($pages_dirs); + } + + $this->simple_pages_hash = json_encode($pages_dirs) . $hash . $config->checksum(); + $this->pages_cache_id = md5($this->simple_pages_hash . $language->getActive()); + + /** @var Cache $cache */ + $cache = $this->grav['cache']; + $cached = $cache->fetch($this->pages_cache_id); + if ($cached && $this->getVersion() === $cached[0]) { + [, $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort] = $cached; + + /** @var Taxonomy $taxonomy */ + $taxonomy = $this->grav['taxonomy']; + $taxonomy->taxonomy($taxonomy_map); + + return; + } + + $this->grav['debugger']->addMessage('Page cache missed, rebuilding pages..'); + } else { + $this->grav['debugger']->addMessage('Page cache disabled, rebuilding pages..'); + } + + $this->resetPages($pages_dirs); + } + + protected function getPagesPaths(): array + { + $grav = Grav::instance(); + $locator = $grav['locator']; + $paths = []; + + $dirs = (array) $grav['config']->get('system.pages.dirs', ['page://']); + foreach ($dirs as $dir) { + $path = $locator->findResource($dir); + if (file_exists($path) && !in_array($path, $paths, true)) { + $paths[] = $path; + } + } + + return $paths; + } + + /** + * Accessible method to manually reset the pages cache + * + * @param array $pages_dirs + */ + public function resetPages(array $pages_dirs): void + { + $this->sort = []; + + foreach ($pages_dirs as $dir) { + $this->recurse($dir); + } + + $this->buildRoutes(); + + // cache if needed + if ($this->grav['config']->get('system.cache.enabled')) { + /** @var Cache $cache */ + $cache = $this->grav['cache']; + /** @var Taxonomy $taxonomy */ + $taxonomy = $this->grav['taxonomy']; + + // save pages, routes, taxonomy, and sort to cache + $cache->save($this->pages_cache_id, [$this->getVersion(), $this->index, $this->routes, $this->children, $taxonomy->taxonomy(), $this->sort]); + } + } + + /** + * Recursive function to load & build page relationships. + * + * @param string $directory + * @param PageInterface|null $parent + * @return PageInterface + * @throws RuntimeException + * @internal + */ + protected function recurse(string $directory, PageInterface $parent = null) + { + $directory = rtrim($directory, DS); + $page = new Page; + + /** @var Config $config */ + $config = $this->grav['config']; + + /** @var Language $language */ + $language = $this->grav['language']; + + // Stuff to do at root page + // Fire event for memory and time consuming plugins... + if ($parent === null && $this->fire_events) { + $this->grav->fireEvent('onBuildPagesInitialized'); + } + + $page->path($directory); + if ($parent) { + $page->parent($parent); + } + + $page->orderDir($config->get('system.pages.order.dir')); + $page->orderBy($config->get('system.pages.order.by')); + + // Add into instances + if (!isset($this->index[$page->path()])) { + $this->index[$page->path()] = $page; + $this->instances[$page->path()] = $page; + if ($parent && $page->path()) { + $this->children[$parent->path()][$page->path()] = ['slug' => $page->slug()]; + } + } elseif ($parent !== null) { + throw new RuntimeException('Fatal error when creating page instances.'); + } + + // Build regular expression for all the allowed page extensions. + $page_extensions = $language->getFallbackPageExtensions(); + $regex = '/^[^\.]*(' . implode('|', array_map( + static function ($str) { + return preg_quote($str, '/'); + }, + $page_extensions + )) . ')$/'; + + $folders = []; + $page_found = null; + $page_extension = '.md'; + $last_modified = 0; + + $iterator = new FilesystemIterator($directory); + foreach ($iterator as $file) { + $filename = $file->getFilename(); + + // Ignore all hidden files if set. + if ($this->ignore_hidden && $filename && strpos($filename, '.') === 0) { + continue; + } + + // Handle folders later. + if ($file->isDir()) { + // But ignore all folders in ignore list. + if (!in_array($filename, $this->ignore_folders, true)) { + $folders[] = $file; + } + continue; + } + + // Ignore all files in ignore list. + if (in_array($filename, $this->ignore_files, true)) { + continue; + } + + // Update last modified date to match the last updated file in the folder. + $modified = $file->getMTime(); + if ($modified > $last_modified) { + $last_modified = $modified; + } + + // Page is the one that matches to $page_extensions list with the lowest index number. + if (preg_match($regex, $filename, $matches, PREG_OFFSET_CAPTURE)) { + $ext = $matches[1][0]; + + if ($page_found === null || array_search($ext, $page_extensions, true) < array_search($page_extension, $page_extensions, true)) { + $page_found = $file; + $page_extension = $ext; + } + } + } + + $content_exists = false; + if ($parent && $page_found) { + $page->init($page_found, $page_extension); + + $content_exists = true; + + if ($this->fire_events) { + $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page])); + } + } + + // Now handle all the folders under the page. + /** @var FilesystemIterator $file */ + foreach ($folders as $file) { + $filename = $file->getFilename(); + + // if folder contains separator, continue + if (Utils::contains($file->getFilename(), $config->get('system.param_sep', ':'))) { + continue; + } + + if (!$page->path()) { + $page->path($file->getPath()); + } + + $path = $directory . DS . $filename; + $child = $this->recurse($path, $page); + + if (preg_match('/^(\d+\.)_/', $filename)) { + $child->routable(false); + $child->modularTwig(true); + } + + $this->children[$page->path()][$child->path()] = ['slug' => $child->slug()]; + + if ($this->fire_events) { + $this->grav->fireEvent('onFolderProcessed', new Event(['page' => $page])); + } + } + + if (!$content_exists) { + // Set routable to false if no page found + $page->routable(false); + + // Hide empty folders if option set + if ($config->get('system.pages.hide_empty_folders')) { + $page->visible(false); + } + } + + // Override the modified time if modular + if ($page->template() === 'modular') { + foreach ($page->collection() as $child) { + $modified = $child->modified(); + + if ($modified > $last_modified) { + $last_modified = $modified; + } + } + } + + // Override the modified and ID so that it takes the latest change into account + $page->modified($last_modified); + $page->id($last_modified . md5($page->filePath() ?? '')); + + // Sort based on Defaults or Page Overridden sort order + $this->children[$page->path()] = $this->sort($page); + + return $page; + } + + /** + * @internal + */ + protected function buildRoutes(): void + { + /** @var Taxonomy $taxonomy */ + $taxonomy = $this->grav['taxonomy']; + + // Get the home route + $home = self::resetHomeRoute(); + // Build routes and taxonomy map. + /** @var PageInterface|string $page */ + foreach ($this->index as $path => $page) { + if (is_string($page)) { + $page = $this->get($path); + } + + if (!$page || $page->root()) { + continue; + } + + // process taxonomy + $taxonomy->addTaxonomy($page); + + $page_path = $page->path(); + if (null === $page_path) { + throw new RuntimeException('Internal Error'); + } + + $route = $page->route(); + $raw_route = $page->rawRoute(); + + // add regular route + if ($route) { + if (isset($this->routes[$route]) && $this->routes[$route] !== $page_path) { + $this->grav['debugger']->addMessage("Route '{$route}' already exists: {$this->routes[$route]}, overwriting with {$page_path}"); + } + $this->routes[$route] = $page_path; + } + + // add raw route + if ($raw_route) { + if (isset($this->routes[$raw_route]) && $this->routes[$route] !== $page_path) { + $this->grav['debugger']->addMessage("Raw Route '{$raw_route}' already exists: {$this->routes[$raw_route]}, overwriting with {$page_path}"); + } + $this->routes[$raw_route] = $page_path; + } + + // add canonical route + $route_canonical = $page->routeCanonical(); + if ($route_canonical) { + if (isset($this->routes[$route_canonical]) && $this->routes[$route_canonical] !== $page_path) { + $this->grav['debugger']->addMessage("Canonical Route '{$route_canonical}' already exists: {$this->routes[$route_canonical]}, overwriting with {$page_path}"); + } + $this->routes[$route_canonical] = $page_path; + } + + // add aliases to routes list if they are provided + $route_aliases = $page->routeAliases(); + if ($route_aliases) { + foreach ($route_aliases as $alias) { + if (isset($this->routes[$alias]) && $this->routes[$alias] !== $page_path) { + $this->grav['debugger']->addMessage("Alias Route '{$alias}' already exists: {$this->routes[$alias]}, overwriting with {$page_path}"); + } + $this->routes[$alias] = $page_path; + } + } + } + + // Alias and set default route to home page. + $homeRoute = "/{$home}"; + if ($home && isset($this->routes[$homeRoute])) { + $home = $this->get($this->routes[$homeRoute]); + if ($home) { + $this->routes['/'] = $this->routes[$homeRoute]; + $home->route('/'); + } + } + } + + /** + * @param string $path + * @param array $pages + * @param string $order_by + * @param array|null $manual + * @param int|null $sort_flags + * @throws RuntimeException + * @internal + */ + protected function buildSort($path, array $pages, $order_by = 'default', $manual = null, $sort_flags = null): void + { + $list = []; + $header_query = null; + $header_default = null; + + // do this header query work only once + if (strpos($order_by, 'header.') === 0) { + $query = explode('|', str_replace('header.', '', $order_by), 2); + $header_query = array_shift($query) ?? ''; + $header_default = array_shift($query); + } + + foreach ($pages as $key => $info) { + $child = $this->get($key); + if (!$child) { + throw new RuntimeException("Page does not exist: {$key}"); + } + + switch ($order_by) { + case 'title': + $list[$key] = $child->title(); + break; + case 'date': + $list[$key] = $child->date(); + $sort_flags = SORT_REGULAR; + break; + case 'modified': + $list[$key] = $child->modified(); + $sort_flags = SORT_REGULAR; + break; + case 'publish_date': + $list[$key] = $child->publishDate(); + $sort_flags = SORT_REGULAR; + break; + case 'unpublish_date': + $list[$key] = $child->unpublishDate(); + $sort_flags = SORT_REGULAR; + break; + case 'slug': + $list[$key] = $child->slug(); + break; + case 'basename': + $list[$key] = Utils::basename($key); + break; + case 'folder': + $list[$key] = $child->folder(); + break; + case 'manual': + case 'default': + default: + if (is_string($header_query)) { + $child_header = $child->header(); + if (!$child_header instanceof Header) { + $child_header = new Header((array)$child_header); + } + $header_value = $child_header->get($header_query); + if (is_array($header_value)) { + $list[$key] = implode(',', $header_value); + } elseif ($header_value) { + $list[$key] = $header_value; + } else { + $list[$key] = $header_default ?: $key; + } + $sort_flags = $sort_flags ?: SORT_REGULAR; + break; + } + $list[$key] = $key; + $sort_flags = $sort_flags ?: SORT_REGULAR; + } + } + + if (!$sort_flags) { + $sort_flags = SORT_NATURAL | SORT_FLAG_CASE; + } + + // handle special case when order_by is random + if ($order_by === 'random') { + $list = $this->arrayShuffle($list); + } else { + // else just sort the list according to specified key + if (extension_loaded('intl') && $this->grav['config']->get('system.intl_enabled')) { + $locale = setlocale(LC_COLLATE, '0'); //`setlocale` with a '0' param returns the current locale set + $col = Collator::create($locale); + if ($col) { + $col->setAttribute(Collator::NUMERIC_COLLATION, Collator::ON); + if (($sort_flags & SORT_NATURAL) === SORT_NATURAL) { + $list = preg_replace_callback('~([0-9]+)\.~', static function ($number) { + return sprintf('%032d.', $number[0]); + }, $list); + if (!is_array($list)) { + throw new RuntimeException('Internal Error'); + } + + $list_vals = array_values($list); + if (is_numeric(array_shift($list_vals))) { + $sort_flags = Collator::SORT_REGULAR; + } else { + $sort_flags = Collator::SORT_STRING; + } + } + + $col->asort($list, $sort_flags); + } else { + asort($list, $sort_flags); + } + } else { + asort($list, $sort_flags); + } + } + + + // Move manually ordered items into the beginning of the list. Order of the unlisted items does not change. + if (is_array($manual) && !empty($manual)) { + $new_list = []; + $i = count($manual); + + foreach ($list as $key => $dummy) { + $info = $pages[$key]; + $order = array_search($info['slug'], $manual, true); + if ($order === false) { + $order = $i++; + } + $new_list[$key] = (int)$order; + } + + $list = $new_list; + + // Apply manual ordering to the list. + asort($list, SORT_NUMERIC); + } + + foreach ($list as $key => $sort) { + $info = $pages[$key]; + $this->sort[$path][$order_by][$key] = $info; + } + } + + /** + * Shuffles an associative array + * + * @param array $list + * @return array + */ + protected function arrayShuffle(array $list): array + { + $keys = array_keys($list); + shuffle($keys); + + $new = []; + foreach ($keys as $key) { + $new[$key] = $list[$key]; + } + + return $new; + } + + /** + * @return string + */ + protected function getVersion(): string + { + return $this->directory ? 'flex' : 'regular'; + } + + /** + * Get the Pages cache ID + * + * this is particularly useful to know if pages have changed and you want + * to sync another cache with pages cache - works best in `onPagesInitialized()` + * + * @return null|string + */ + public function getPagesCacheId(): ?string + { + return $this->pages_cache_id; + } + + /** + * Get the simple pages hash that is not md5 encoded, and isn't specific to language + * + * @return null|string + */ + public function getSimplePagesHash(): ?string + { + return $this->simple_pages_hash; + } +} diff --git a/system/src/Grav/Common/Page/Traits/PageFormTrait.php b/system/src/Grav/Common/Page/Traits/PageFormTrait.php new file mode 100644 index 0000000..b99e7b7 --- /dev/null +++ b/system/src/Grav/Common/Page/Traits/PageFormTrait.php @@ -0,0 +1,126 @@ + blueprint, ...], where blueprint follows the regular form blueprint format. + * + * @return array + */ + public function getForms(): array + { + if (null === $this->_forms) { + $header = $this->header(); + + // Call event to allow filling the page header form dynamically (e.g. use case: Comments plugin) + $grav = Grav::instance(); + $grav->fireEvent('onFormPageHeaderProcessed', new Event(['page' => $this, 'header' => $header])); + + $rules = $header->rules ?? null; + if (!is_array($rules)) { + $rules = []; + } + + $forms = []; + + // First grab page.header.form + $form = $this->normalizeForm($header->form ?? null, null, $rules); + if ($form) { + $forms[$form['name']] = $form; + } + + // Append page.header.forms (override singular form if it clashes) + $headerForms = $header->forms ?? null; + if (is_array($headerForms)) { + foreach ($headerForms as $name => $form) { + $form = $this->normalizeForm($form, $name, $rules); + if ($form) { + $forms[$form['name']] = $form; + } + } + } + + $this->_forms = $forms; + } + + return $this->_forms; + } + + /** + * Add forms to this page. + * + * @param array $new + * @param bool $override + * @return $this + */ + public function addForms(array $new, $override = true) + { + // Initialize forms. + $this->forms(); + + foreach ($new as $name => $form) { + $form = $this->normalizeForm($form, $name); + $name = $form['name'] ?? null; + if ($name && ($override || !isset($this->_forms[$name]))) { + $this->_forms[$name] = $form; + } + } + + return $this; + } + + /** + * Alias of $this->getForms(); + * + * @return array + */ + public function forms(): array + { + return $this->getForms(); + } + + /** + * @param array|null $form + * @param string|null $name + * @param array $rules + * @return array|null + */ + protected function normalizeForm($form, $name = null, array $rules = []): ?array + { + if (!is_array($form)) { + return null; + } + + // Ignore numeric indexes on name. + if (!$name || (string)(int)$name === (string)$name) { + $name = null; + } + + $name = $name ?? $form['name'] ?? $this->slug(); + + $formRules = $form['rules'] ?? null; + if (!is_array($formRules)) { + $formRules = []; + } + + return ['name' => $name, 'rules' => $rules + $formRules] + $form; + } + + abstract public function header($var = null); + abstract public function slug($var = null); +} diff --git a/system/src/Grav/Common/Page/Types.php b/system/src/Grav/Common/Page/Types.php new file mode 100644 index 0000000..02fe99a --- /dev/null +++ b/system/src/Grav/Common/Page/Types.php @@ -0,0 +1,179 @@ +items[$type])) { + $this->items[$type] = []; + } elseif (null === $blueprint) { + return; + } + + if (null === $blueprint) { + $blueprint = $this->systemBlueprints[$type] ?? $this->systemBlueprints['default'] ?? null; + } + + if ($blueprint) { + array_unshift($this->items[$type], $blueprint); + } + } + + /** + * @return void + */ + public function init() + { + if (empty($this->systemBlueprints)) { + // Register all blueprints from the blueprints stream. + $this->systemBlueprints = $this->findBlueprints('blueprints://pages'); + foreach ($this->systemBlueprints as $type => $blueprint) { + $this->register($type); + } + } + } + + /** + * @param string $uri + * @return void + */ + public function scanBlueprints($uri) + { + if (!is_string($uri)) { + throw new InvalidArgumentException('First parameter must be URI'); + } + + foreach ($this->findBlueprints($uri) as $type => $blueprint) { + $this->register($type, $blueprint); + } + } + + /** + * @param string $uri + * @return void + */ + public function scanTemplates($uri) + { + if (!is_string($uri)) { + throw new InvalidArgumentException('First parameter must be URI'); + } + + $options = [ + 'compare' => 'Filename', + 'pattern' => '|\.html\.twig$|', + 'filters' => [ + 'value' => '|\.html\.twig$|' + ], + 'value' => 'Filename', + 'recursive' => false + ]; + + foreach (Folder::all($uri, $options) as $type) { + $this->register($type); + } + + $modular_uri = rtrim($uri, '/') . '/modular'; + if (is_dir($modular_uri)) { + foreach (Folder::all($modular_uri, $options) as $type) { + $this->register('modular/' . $type); + } + } + } + + /** + * @return array + */ + public function pageSelect() + { + $list = []; + foreach ($this->items as $name => $file) { + if (strpos($name, '/')) { + continue; + } + $list[$name] = ucfirst(str_replace('_', ' ', $name)); + } + ksort($list); + + return $list; + } + + /** + * @return array + */ + public function modularSelect() + { + $list = []; + foreach ($this->items as $name => $file) { + if (strpos($name, 'modular/') !== 0) { + continue; + } + $list[$name] = ucfirst(trim(str_replace('_', ' ', Utils::basename($name)))); + } + ksort($list); + + return $list; + } + + /** + * @param string $uri + * @return array + */ + private function findBlueprints($uri) + { + $options = [ + 'compare' => 'Filename', + 'pattern' => '|\.yaml$|', + 'filters' => [ + 'key' => '|\.yaml$|' + ], + 'key' => 'SubPathName', + 'value' => 'PathName', + ]; + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if ($locator->isStream($uri)) { + $options['value'] = 'Url'; + } + + return Folder::all($uri, $options); + } +} diff --git a/system/src/Grav/Common/Plugin.php b/system/src/Grav/Common/Plugin.php new file mode 100644 index 0000000..c832be7 --- /dev/null +++ b/system/src/Grav/Common/Plugin.php @@ -0,0 +1,472 @@ +name = $name; + $this->grav = $grav; + + if ($config) { + $this->setConfig($config); + } + } + + /** + * @return ClassLoader|null + * @internal + */ + final public function getAutoloader(): ?ClassLoader + { + return $this->loader; + } + + /** + * @param ClassLoader|null $loader + * @internal + */ + final public function setAutoloader(?ClassLoader $loader): void + { + $this->loader = $loader; + } + + /** + * @param Config $config + * @return $this + */ + public function setConfig(Config $config) + { + $this->config = $config; + + return $this; + } + + /** + * Get configuration of the plugin. + * + * @return array + */ + public function config() + { + return $this->config["plugins.{$this->name}"] ?? []; + } + + /** + * Determine if plugin is running under the admin + * + * @return bool + */ + public function isAdmin() + { + return Utils::isAdminPlugin(); + } + + /** + * Determine if plugin is running under the CLI + * + * @return bool + */ + public function isCli() + { + return defined('GRAV_CLI'); + } + + /** + * Determine if this route is in Admin and active for the plugin + * + * @param string $plugin_route + * @return bool + */ + protected function isPluginActiveAdmin($plugin_route) + { + $active = false; + + /** @var Uri $uri */ + $uri = $this->grav['uri']; + /** @var Config $config */ + $config = $this->config ?? $this->grav['config']; + + if (strpos($uri->path(), $config->get('plugins.admin.route') . '/' . $plugin_route) === false) { + $active = false; + } elseif (isset($uri->paths()[1]) && $uri->paths()[1] === $plugin_route) { + $active = true; + } + + return $active; + } + + /** + * @param array $events + * @return void + */ + protected function enable(array $events) + { + /** @var EventDispatcher $dispatcher */ + $dispatcher = $this->grav['events']; + + foreach ($events as $eventName => $params) { + if (is_string($params)) { + $dispatcher->addListener($eventName, [$this, $params]); + } elseif (is_string($params[0])) { + $dispatcher->addListener($eventName, [$this, $params[0]], $this->getPriority($params, $eventName)); + } else { + foreach ($params as $listener) { + $dispatcher->addListener($eventName, [$this, $listener[0]], $this->getPriority($listener, $eventName)); + } + } + } + } + + /** + * @param array $params + * @param string $eventName + * @return int + */ + private function getPriority($params, $eventName) + { + $override = implode('.', ['priorities', $this->name, $eventName, $params[0]]); + + return $this->grav['config']->get($override) ?? $params[1] ?? 0; + } + + /** + * @param array $events + * @return void + */ + protected function disable(array $events) + { + /** @var EventDispatcher $dispatcher */ + $dispatcher = $this->grav['events']; + + foreach ($events as $eventName => $params) { + if (is_string($params)) { + $dispatcher->removeListener($eventName, [$this, $params]); + } elseif (is_string($params[0])) { + $dispatcher->removeListener($eventName, [$this, $params[0]]); + } else { + foreach ($params as $listener) { + $dispatcher->removeListener($eventName, [$this, $listener[0]]); + } + } + } + } + + /** + * Whether or not an offset exists. + * + * @param string $offset An offset to check for. + * @return bool Returns TRUE on success or FALSE on failure. + */ + #[\ReturnTypeWillChange] + public function offsetExists($offset) + { + if ($offset === 'title') { + $offset = 'name'; + } + + $blueprint = $this->getBlueprint(); + + return isset($blueprint[$offset]); + } + + /** + * Returns the value at specified offset. + * + * @param string $offset The offset to retrieve. + * @return mixed Can return all value types. + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + if ($offset === 'title') { + $offset = 'name'; + } + + $blueprint = $this->getBlueprint(); + + return $blueprint[$offset] ?? null; + } + + /** + * Assigns a value to the specified offset. + * + * @param string $offset The offset to assign the value to. + * @param mixed $value The value to set. + * @throws LogicException + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + throw new LogicException(__CLASS__ . ' blueprints cannot be modified.'); + } + + /** + * Unsets an offset. + * + * @param string $offset The offset to unset. + * @throws LogicException + */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset) + { + throw new LogicException(__CLASS__ . ' blueprints cannot be modified.'); + } + + /** + * @return array + */ + public function __debugInfo(): array + { + $array = (array)$this; + + unset($array["\0*\0grav"]); + $array["\0*\0config"] = $this->config(); + + return $array; + } + + /** + * This function will search a string for markdown links in a specific format. The link value can be + * optionally compared against via the $internal_regex and operated on by the callback $function + * provided. + * + * format: [plugin:myplugin_name](function_data) + * + * @param string $content The string to perform operations upon + * @param callable $function The anonymous callback function + * @param string $internal_regex Optional internal regex to extra data from + * @return string + */ + protected function parseLinks($content, $function, $internal_regex = '(.*)') + { + $regex = '/\[plugin:(?:' . preg_quote($this->name, '/') . ')\]\(' . $internal_regex . '\)/i'; + + $result = preg_replace_callback($regex, $function, $content); + \assert($result !== null); + + return $result; + } + + /** + * Merge global and page configurations. + * + * WARNING: This method modifies page header! + * + * @param PageInterface $page The page to merge the configurations with the + * plugin settings. + * @param mixed $deep false = shallow|true = recursive|merge = recursive+unique + * @param array $params Array of additional configuration options to + * merge with the plugin settings. + * @param string $type Is this 'plugins' or 'themes' + * @return Data + */ + protected function mergeConfig(PageInterface $page, $deep = false, $params = [], $type = 'plugins') + { + /** @var Config $config */ + $config = $this->config ?? $this->grav['config']; + + $class_name = $this->name; + $class_name_merged = $class_name . '.merged'; + $defaults = $config->get($type . '.' . $class_name, []); + $page_header = $page->header(); + $header = []; + + if (!isset($page_header->{$class_name_merged}) && isset($page_header->{$class_name})) { + // Get default plugin configurations and retrieve page header configuration + $config = $page_header->{$class_name}; + if (is_bool($config)) { + // Overwrite enabled option with boolean value in page header + $config = ['enabled' => $config]; + } + // Merge page header settings using deep or shallow merging technique + $header = $this->mergeArrays($deep, $defaults, $config); + + // Create new config object and set it on the page object so it's cached for next time + $page->modifyHeader($class_name_merged, new Data($header)); + } elseif (isset($page_header->{$class_name_merged})) { + $merged = $page_header->{$class_name_merged}; + $header = $merged->toArray(); + } + if (empty($header)) { + $header = $defaults; + } + // Merge additional parameter with configuration options + $header = $this->mergeArrays($deep, $header, $params); + + // Return configurations as a new data config class + return new Data($header); + } + + /** + * Merge arrays based on deepness + * + * @param string|bool $deep + * @param array $array1 + * @param array $array2 + * @return array + */ + private function mergeArrays($deep, $array1, $array2) + { + if ($deep === 'merge') { + return Utils::arrayMergeRecursiveUnique($array1, $array2); + } + if ($deep === true) { + return array_replace_recursive($array1, $array2); + } + + return array_merge($array1, $array2); + } + + /** + * Persists to disk the plugin parameters currently stored in the Grav Config object + * + * @param string $name The name of the plugin whose config it should store. + * @return bool + */ + public static function saveConfig($name) + { + if (!$name) { + return false; + } + + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + $filename = 'config://plugins/' . $name . '.yaml'; + $file = YamlFile::instance((string)$locator->findResource($filename, true, true)); + $content = $grav['config']->get('plugins.' . $name); + $file->save($content); + $file->free(); + unset($file); + + return true; + } + + public static function inheritedConfigOption(string $plugin, string $var, PageInterface $page = null, $default = null) + { + if (Utils::isAdminPlugin()) { + $page = Grav::instance()['admin']->page() ?? null; + } else { + $page = $page ?? Grav::instance()['page'] ?? null; + } + + // Try to find var in the page headers + if ($page instanceof PageInterface && $page->exists()) { + // Loop over pages and look for header vars + while ($page && !$page->root()) { + $header = new Data((array)$page->header()); + $value = $header->get("$plugin.$var"); + if (isset($value)) { + return $value; + } + $page = $page->parent(); + } + } + + return Grav::instance()['config']->get("plugins.$plugin.$var", $default); + } + + /** + * Simpler getter for the plugin blueprint + * + * @return Blueprint + */ + public function getBlueprint() + { + if (null === $this->blueprint) { + $this->loadBlueprint(); + \assert($this->blueprint instanceof Blueprint); + } + + return $this->blueprint; + } + + /** + * Load blueprints. + * + * @return void + */ + protected function loadBlueprint() + { + if (null === $this->blueprint) { + $grav = Grav::instance(); + /** @var Plugins $plugins */ + $plugins = $grav['plugins']; + $data = $plugins->get($this->name); + \assert($data !== null); + $this->blueprint = $data->blueprints(); + } + } +} diff --git a/system/src/Grav/Common/Plugins.php b/system/src/Grav/Common/Plugins.php new file mode 100644 index 0000000..3826bda --- /dev/null +++ b/system/src/Grav/Common/Plugins.php @@ -0,0 +1,330 @@ +getIterator('plugins://'); + + $plugins = []; + /** @var SplFileInfo $directory */ + foreach ($iterator as $directory) { + if (!$directory->isDir()) { + continue; + } + $plugins[] = $directory->getFilename(); + } + + sort($plugins, SORT_NATURAL | SORT_FLAG_CASE); + + foreach ($plugins as $plugin) { + $object = $this->loadPlugin($plugin); + if ($object) { + $this->add($object); + } + } + } + + /** + * @return $this + */ + public function setup() + { + $blueprints = []; + $formFields = []; + + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + /** @var Plugin $plugin */ + foreach ($this->items as $plugin) { + // Setup only enabled plugins. + if ($config["plugins.{$plugin->name}.enabled"] && $plugin instanceof Plugin) { + if (isset($plugin->features['blueprints'])) { + $blueprints["plugin://{$plugin->name}/blueprints"] = $plugin->features['blueprints']; + } + if (method_exists($plugin, 'getFormFieldTypes')) { + $formFields[get_class($plugin)] = $plugin->features['formfields'] ?? 0; + } + } + } + + if ($blueprints) { + // Order by priority. + arsort($blueprints, SORT_NUMERIC); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $locator->addPath('blueprints', '', array_keys($blueprints), ['system', 'blueprints']); + } + + if ($formFields) { + // Order by priority. + arsort($formFields, SORT_NUMERIC); + + $list = []; + foreach ($formFields as $className => $priority) { + $plugin = $this->items[$className]; + $list += $plugin->getFormFieldTypes(); + } + + $this->formFieldTypes = $list; + } + + return $this; + } + + /** + * Registers all plugins. + * + * @return Plugin[] array of Plugin objects + * @throws RuntimeException + */ + public function init() + { + if ($this->plugins_initialized) { + return $this->items; + } + + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + /** @var EventDispatcher $events */ + $events = $grav['events']; + + foreach ($this->items as $instance) { + // Register only enabled plugins. + if ($config["plugins.{$instance->name}.enabled"] && $instance instanceof Plugin) { + // Set plugin configuration. + $instance->setConfig($config); + // Register autoloader. + if (method_exists($instance, 'autoload')) { + $instance->setAutoloader($instance->autoload()); + } + // Register event listeners. + $events->addSubscriber($instance); + } + } + + // Plugins Loaded Event + $event = new PluginsLoadedEvent($grav, $this); + $grav->dispatchEvent($event); + + $this->plugins_initialized = true; + + return $this->items; + } + + /** + * Add a plugin + * + * @param Plugin $plugin + * @return void + */ + public function add($plugin) + { + if (is_object($plugin)) { + $this->items[get_class($plugin)] = $plugin; + } + } + + /** + * @return array + */ + public function __debugInfo(): array + { + $array = (array)$this; + + unset($array["\0Grav\Common\Iterator\0iteratorUnset"]); + + return $array; + } + + /** + * @return Plugin[] Index of all plugins by plugin name. + */ + public static function getPlugins(): array + { + /** @var Plugins $plugins */ + $plugins = Grav::instance()['plugins']; + + $list = []; + foreach ($plugins as $instance) { + $list[$instance->name] = $instance; + } + + return $list; + } + + /** + * @param string $name Plugin name + * @return Plugin|null Plugin object or null if plugin cannot be found. + */ + public static function getPlugin(string $name) + { + $list = static::getPlugins(); + + return $list[$name] ?? null; + } + + /** + * Return list of all plugin data with their blueprints. + * + * @return Data[] + */ + public static function all() + { + $grav = Grav::instance(); + + /** @var Plugins $plugins */ + $plugins = $grav['plugins']; + $list = []; + + foreach ($plugins as $instance) { + $name = $instance->name; + + try { + $result = self::get($name); + } catch (Exception $e) { + $exception = new RuntimeException(sprintf('Plugin %s: %s', $name, $e->getMessage()), $e->getCode(), $e); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addMessage("Plugin {$name} cannot be loaded, please check Exceptions tab", 'error'); + $debugger->addException($exception); + + continue; + } + + if ($result) { + $list[$name] = $result; + } + } + + return $list; + } + + /** + * Get a plugin by name + * + * @param string $name + * @return Data|null + */ + public static function get($name) + { + $blueprints = new Blueprints('plugins://'); + $blueprint = $blueprints->get("{$name}/blueprints"); + + // Load default configuration. + $file = CompiledYamlFile::instance("plugins://{$name}/{$name}" . YAML_EXT); + + // ensure this is a valid plugin + if (!$file->exists()) { + return null; + } + + $obj = new Data((array)$file->content(), $blueprint); + + // Override with user configuration. + $obj->merge(Grav::instance()['config']->get('plugins.' . $name) ?: []); + + // Save configuration always to user/config. + $file = CompiledYamlFile::instance("config://plugins/{$name}.yaml"); + $obj->file($file); + + return $obj; + } + + /** + * @param string $name + * @return Plugin|null + */ + protected function loadPlugin($name) + { + // NOTE: ALL THE LOCAL VARIABLES ARE USED INSIDE INCLUDED FILE, DO NOT REMOVE THEM! + $grav = Grav::instance(); + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $class = null; + + // Start by attempting to load the plugin_name.php file. + $file = $locator->findResource('plugins://' . $name . DS . $name . PLUGIN_EXT); + if (is_file($file)) { + // Local variables available in the file: $grav, $name, $file + $class = include_once $file; + if (!is_object($class) || !is_subclass_of($class, Plugin::class, true)) { + $class = null; + } + } + + // If the class hasn't been initialized yet, guess the class name and create a new instance. + if (null === $class) { + $className = Inflector::camelize($name); + $pluginClassFormat = [ + 'Grav\\Plugin\\' . ucfirst($name). 'Plugin', + 'Grav\\Plugin\\' . $className . 'Plugin', + 'Grav\\Plugin\\' . $className + ]; + + foreach ($pluginClassFormat as $pluginClass) { + if (is_subclass_of($pluginClass, Plugin::class, true)) { + $class = new $pluginClass($name, $grav); + break; + } + } + } + + // Log a warning if plugin cannot be found. + if (null === $class) { + $grav['log']->addWarning( + sprintf("Plugin '%s' enabled but not found! Try clearing cache with `bin/grav clearcache`", $name) + ); + } + + return $class; + } +} diff --git a/system/src/Grav/Common/Processors/AssetsProcessor.php b/system/src/Grav/Common/Processors/AssetsProcessor.php new file mode 100644 index 0000000..6ed8283 --- /dev/null +++ b/system/src/Grav/Common/Processors/AssetsProcessor.php @@ -0,0 +1,41 @@ +startTimer(); + $this->container['assets']->init(); + $this->container->fireEvent('onAssetsInitialized'); + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/BackupsProcessor.php b/system/src/Grav/Common/Processors/BackupsProcessor.php new file mode 100644 index 0000000..deb4da7 --- /dev/null +++ b/system/src/Grav/Common/Processors/BackupsProcessor.php @@ -0,0 +1,41 @@ +startTimer(); + $backups = $this->container['backups']; + $backups->init(); + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php b/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php new file mode 100644 index 0000000..530c7f2 --- /dev/null +++ b/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php @@ -0,0 +1,40 @@ +startTimer(); + $this->container['debugger']->addAssets(); + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php b/system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php new file mode 100644 index 0000000..e84db7e --- /dev/null +++ b/system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php @@ -0,0 +1,82 @@ +offsetGet('request'); + } + + /** + * @return Route + */ + public function getRoute(): Route + { + return $this->getRequest()->getAttribute('route'); + } + + /** + * @return RequestHandler + */ + public function getHandler(): RequestHandler + { + return $this->offsetGet('handler'); + } + + /** + * @return ResponseInterface|null + */ + public function getResponse(): ?ResponseInterface + { + return $this->offsetGet('response'); + } + + /** + * @param ResponseInterface $response + * @return $this + */ + public function setResponse(ResponseInterface $response): self + { + $this->offsetSet('response', $response); + $this->stopPropagation(); + + return $this; + } + + /** + * @param string $name + * @param MiddlewareInterface $middleware + * @return RequestHandlerEvent + */ + public function addMiddleware(string $name, MiddlewareInterface $middleware): self + { + /** @var RequestHandler $handler */ + $handler = $this['handler']; + $handler->addMiddleware($name, $middleware); + + return $this; + } +} diff --git a/system/src/Grav/Common/Processors/InitializeProcessor.php b/system/src/Grav/Common/Processors/InitializeProcessor.php new file mode 100644 index 0000000..43a88d0 --- /dev/null +++ b/system/src/Grav/Common/Processors/InitializeProcessor.php @@ -0,0 +1,461 @@ +processCli(); + } + } + + /** + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $this->startTimer('_init', 'Initialize'); + + // Load configuration. + $config = $this->initializeConfig(); + + // Initialize logger. + $this->initializeLogger($config); + + // Initialize error handlers. + $this->initializeErrors(); + + // Initialize debugger. + $debugger = $this->initializeDebugger(); + + // Debugger can return response right away. + $response = $this->handleDebuggerRequest($debugger, $request); + if ($response) { + $this->stopTimer('_init'); + + return $response; + } + + // Initialize output buffering. + $this->initializeOutputBuffering($config); + + // Set timezone, locale. + $this->initializeLocale($config); + + // Load plugins. + $this->initializePlugins(); + + // Load pages. + $this->initializePages($config); + + // Load accounts (decides class to be used). + // TODO: remove in 2.0. + $this->container['accounts']; + + // Initialize session (used by URI, see issue #3269). + $this->initializeSession($config); + + // Initialize URI (uses session, see issue #3269). + $this->initializeUri($config); + + // Grav may return redirect response right away. + $redirectCode = (int)$config->get('system.pages.redirect_trailing_slash', 1); + if ($redirectCode) { + $response = $this->handleRedirectRequest($request, $redirectCode > 300 ? $redirectCode : null); + if ($response) { + $this->stopTimer('_init'); + + return $response; + } + } + + $this->stopTimer('_init'); + + // Wrap call to next handler so that debugger can profile it. + /** @var Response $response */ + $response = $debugger->profile(static function () use ($handler, $request) { + return $handler->handle($request); + }); + + // Log both request and response and return the response. + return $debugger->logRequest($request, $response); + } + + public function processCli(): void + { + // Load configuration. + $config = $this->initializeConfig(); + + // Initialize logger. + $this->initializeLogger($config); + + // Disable debugger. + $this->container['debugger']->enabled(false); + + // Set timezone, locale. + $this->initializeLocale($config); + + // Load plugins. + $this->initializePlugins(); + + // Load pages. + $this->initializePages($config); + + // Initialize URI. + $this->initializeUri($config); + + // Load accounts (decides class to be used). + // TODO: remove in 2.0. + $this->container['accounts']; + } + + /** + * @return Config + */ + protected function initializeConfig(): Config + { + $this->startTimer('_init_config', 'Configuration'); + + // Initialize Configuration + $grav = $this->container; + + /** @var Config $config */ + $config = $grav['config']; + $config->init(); + $grav['plugins']->setup(); + + if (defined('GRAV_SCHEMA') && $config->get('versions') === null) { + $filename = USER_DIR . 'config/versions.yaml'; + if (!is_file($filename)) { + $versions = [ + 'core' => [ + 'grav' => [ + 'version' => GRAV_VERSION, + 'schema' => GRAV_SCHEMA + ] + ] + ]; + $config->set('versions', $versions); + + $file = new YamlFile($filename, new YamlFormatter(['inline' => 4])); + $file->save($versions); + } + } + + // Override configuration using the environment. + $prefix = 'GRAV_CONFIG'; + $env = getenv($prefix); + if ($env) { + $cPrefix = $prefix . '__'; + $aPrefix = $prefix . '_ALIAS__'; + $cLen = strlen($cPrefix); + $aLen = strlen($aPrefix); + + $keys = $aliases = []; + $env = $_ENV + $_SERVER; + foreach ($env as $key => $value) { + if (!str_starts_with($key, $prefix)) { + continue; + } + if (str_starts_with($key, $cPrefix)) { + $key = str_replace('__', '.', substr($key, $cLen)); + $keys[$key] = $value; + } elseif (str_starts_with($key, $aPrefix)) { + $key = substr($key, $aLen); + $aliases[$key] = $value; + } + } + $list = []; + foreach ($keys as $key => $value) { + foreach ($aliases as $alias => $real) { + $key = str_replace($alias, $real, $key); + } + $list[$key] = $value; + $config->set($key, $value); + } + } + + $this->stopTimer('_init_config'); + + return $config; + } + + /** + * @param Config $config + * @return Logger + */ + protected function initializeLogger(Config $config): Logger + { + $this->startTimer('_init_logger', 'Logger'); + + $grav = $this->container; + + // Initialize Logging + /** @var Logger $log */ + $log = $grav['log']; + + if ($config->get('system.log.handler', 'file') === 'syslog') { + $log->popHandler(); + + $facility = $config->get('system.log.syslog.facility', 'local6'); + $tag = $config->get('system.log.syslog.tag', 'grav'); + $logHandler = new SyslogHandler($tag, $facility); + $formatter = new LineFormatter("%channel%.%level_name%: %message% %extra%"); + $logHandler->setFormatter($formatter); + + $log->pushHandler($logHandler); + } + + $this->stopTimer('_init_logger'); + + return $log; + } + + /** + * @return Errors + */ + protected function initializeErrors(): Errors + { + $this->startTimer('_init_errors', 'Error Handlers Reset'); + + $grav = $this->container; + + // Initialize Error Handlers + /** @var Errors $errors */ + $errors = $grav['errors']; + $errors->resetHandlers(); + + $this->stopTimer('_init_errors'); + + return $errors; + } + + /** + * @return Debugger + */ + protected function initializeDebugger(): Debugger + { + $this->startTimer('_init_debugger', 'Init Debugger'); + + $grav = $this->container; + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->init(); + + $this->stopTimer('_init_debugger'); + + return $debugger; + } + + /** + * @param Debugger $debugger + * @param ServerRequestInterface $request + * @return ResponseInterface|null + */ + protected function handleDebuggerRequest(Debugger $debugger, ServerRequestInterface $request): ?ResponseInterface + { + // Clockwork integration. + $clockwork = $debugger->getClockwork(); + if ($clockwork) { + $server = $request->getServerParams(); +// $baseUri = str_replace('\\', '/', dirname(parse_url($server['SCRIPT_NAME'], PHP_URL_PATH))); +// if ($baseUri === '/') { +// $baseUri = ''; +// } + $requestTime = $server['REQUEST_TIME_FLOAT'] ?? GRAV_REQUEST_TIME; + + $request = $request->withAttribute('request_time', $requestTime); + + // Handle clockwork API calls. + $uri = $request->getUri(); + if (Utils::contains($uri->getPath(), '/__clockwork/')) { + return $debugger->debuggerRequest($request); + } + + $this->container['clockwork'] = $clockwork; + } + + return null; + } + + /** + * @param Config $config + */ + protected function initializeOutputBuffering(Config $config): void + { + $this->startTimer('_init_ob', 'Initialize Output Buffering'); + + // Use output buffering to prevent headers from being sent too early. + ob_start(); + if ($config->get('system.cache.gzip') && !@ob_start('ob_gzhandler')) { + // Enable zip/deflate with a fallback in case of if browser does not support compressing. + ob_start(); + } + + $this->stopTimer('_init_ob'); + } + + /** + * @param Config $config + */ + protected function initializeLocale(Config $config): void + { + $this->startTimer('_init_locale', 'Initialize Locale'); + + // Initialize the timezone. + $timezone = $config->get('system.timezone'); + if ($timezone) { + date_default_timezone_set($timezone); + } + + $grav = $this->container; + $grav->setLocale(); + + $this->stopTimer('_init_locale'); + } + + protected function initializePlugins(): Plugins + { + $this->startTimer('_init_plugins_load', 'Load Plugins'); + + $grav = $this->container; + + /** @var Plugins $plugins */ + $plugins = $grav['plugins']; + $plugins->init(); + + $this->stopTimer('_init_plugins_load'); + + return $plugins; + } + + protected function initializePages(Config $config): Pages + { + $this->startTimer('_init_pages_register', 'Load Pages'); + + $grav = $this->container; + + /** @var Pages $pages */ + $pages = $grav['pages']; + // Upgrading from older Grav versions won't work without checking if the method exists. + if (method_exists($pages, 'register')) { + $pages->register(); + } + + $this->stopTimer('_init_pages_register'); + + return $pages; + } + + + protected function initializeUri(Config $config): void + { + $this->startTimer('_init_uri', 'Initialize URI'); + + $grav = $this->container; + + /** @var Uri $uri */ + $uri = $grav['uri']; + $uri->init(); + + $this->stopTimer('_init_uri'); + } + + protected function handleRedirectRequest(RequestInterface $request, int $code = null): ?ResponseInterface + { + if (!in_array($request->getMethod(), ['GET', 'HEAD'])) { + return null; + } + + // Redirect pages with trailing slash if configured to do so. + $uri = $request->getUri(); + $path = $uri->getPath() ?: '/'; + $root = $this->container['uri']->rootUrl(); + + if ($path !== $root && $path !== $root . '/' && Utils::endsWith($path, '/')) { + // Use permanent redirect for SEO reasons. + return $this->container->getRedirectResponse((string)$uri->withPath(rtrim($path, '/')), $code); + } + + return null; + } + + /** + * @param Config $config + */ + protected function initializeSession(Config $config): void + { + // FIXME: Initialize session should happen later after plugins have been loaded. This is a workaround to fix session issues in AWS. + if (isset($this->container['session']) && $config->get('system.session.initialize', true)) { + $this->startTimer('_init_session', 'Start Session'); + + /** @var Session $session */ + $session = $this->container['session']; + + try { + $session->init(); + } catch (SessionException $e) { + $session->init(); + $message = 'Session corruption detected, restarting session...'; + $this->addMessage($message); + $this->container['messages']->add($message, 'error'); + } + + $this->stopTimer('_init_session'); + } + } +} diff --git a/system/src/Grav/Common/Processors/PagesProcessor.php b/system/src/Grav/Common/Processors/PagesProcessor.php new file mode 100644 index 0000000..fb8e896 --- /dev/null +++ b/system/src/Grav/Common/Processors/PagesProcessor.php @@ -0,0 +1,115 @@ +startTimer(); + + // Dump Cache state + $this->container['debugger']->addMessage($this->container['cache']->getCacheStatus()); + + $this->container['pages']->init(); + + $route = $this->container['route']; + + $this->container->fireEvent('onPagesInitialized', new Event( + [ + 'pages' => $this->container['pages'], + 'route' => $route, + 'request' => $request + ] + )); + $this->container->fireEvent('onPageInitialized', new Event( + [ + 'page' => $this->container['page'], + 'route' => $route, + 'request' => $request + ] + )); + + /** @var PageInterface $page */ + $page = $this->container['page']; + + if (!$page->routable()) { + $exception = new RequestException($request, 'Page Not Found', 404); + // If no page found, fire event + $event = new PageEvent([ + 'page' => $page, + 'code' => $exception->getCode(), + 'message' => $exception->getMessage(), + 'exception' => $exception, + 'route' => $route, + 'request' => $request + ]); + $event->page = null; + $event = $this->container->fireEvent('onPageNotFound', $event); + + if (isset($event->page)) { + unset($this->container['page']); + $this->container['page'] = $page = $event->page; + } else { + throw new RuntimeException('Page Not Found', 404); + } + + $this->addMessage("Routed to page {$page->rawRoute()} (type: {$page->template()}) [Not Found fallback]"); + } else { + $this->addMessage("Routed to page {$page->rawRoute()} (type: {$page->template()})"); + + $task = $this->container['task']; + $action = $this->container['action']; + + /** @var Forms $forms */ + $forms = $this->container['forms'] ?? null; + $form = $forms ? $forms->getActiveForm() : null; + + $options = ['page' => $page, 'form' => $form, 'request' => $request]; + if ($task) { + $event = new Event(['task' => $task] + $options); + $this->container->fireEvent('onPageTask', $event); + $this->container->fireEvent('onPageTask.' . $task, $event); + } elseif ($action) { + $event = new Event(['action' => $action] + $options); + $this->container->fireEvent('onPageAction', $event); + $this->container->fireEvent('onPageAction.' . $action, $event); + } + } + + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/PluginsProcessor.php b/system/src/Grav/Common/Processors/PluginsProcessor.php new file mode 100644 index 0000000..a6c2865 --- /dev/null +++ b/system/src/Grav/Common/Processors/PluginsProcessor.php @@ -0,0 +1,41 @@ +startTimer(); + $grav = $this->container; + $grav->fireEvent('onPluginsInitialized'); + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/ProcessorBase.php b/system/src/Grav/Common/Processors/ProcessorBase.php new file mode 100644 index 0000000..d4a0620 --- /dev/null +++ b/system/src/Grav/Common/Processors/ProcessorBase.php @@ -0,0 +1,70 @@ +container = $container; + } + + /** + * @param string|null $id + * @param string|null $title + */ + protected function startTimer($id = null, $title = null): void + { + /** @var Debugger $debugger */ + $debugger = $this->container['debugger']; + $debugger->startTimer($id ?? $this->id, $title ?? $this->title); + } + + /** + * @param string|null $id + */ + protected function stopTimer($id = null): void + { + /** @var Debugger $debugger */ + $debugger = $this->container['debugger']; + $debugger->stopTimer($id ?? $this->id); + } + + /** + * @param string $message + * @param string $label + * @param bool $isString + */ + protected function addMessage($message, $label = 'info', $isString = true): void + { + /** @var Debugger $debugger */ + $debugger = $this->container['debugger']; + $debugger->addMessage($message, $label, $isString); + } +} diff --git a/system/src/Grav/Common/Processors/ProcessorInterface.php b/system/src/Grav/Common/Processors/ProcessorInterface.php new file mode 100644 index 0000000..2d99e3f --- /dev/null +++ b/system/src/Grav/Common/Processors/ProcessorInterface.php @@ -0,0 +1,20 @@ +startTimer(); + + $container = $this->container; + $output = $container['output']; + + if ($output instanceof ResponseInterface) { + return $output; + } + + /** @var PageInterface $page */ + $page = $this->container['page']; + + // Use internal Grav output. + $container->output = $output; + + ob_start(); + + $event = new Event(['page' => $page, 'output' => &$container->output]); + $container->fireEvent('onOutputGenerated', $event); + + echo $container->output; + + $html = ob_get_clean(); + + // remove any output + $container->output = ''; + + $event = new Event(['page' => $page, 'output' => $html]); + $this->container->fireEvent('onOutputRendered', $event); + + $this->stopTimer(); + + return new Response($page->httpResponseCode(), $page->httpHeaders(), $html); + } +} diff --git a/system/src/Grav/Common/Processors/RequestProcessor.php b/system/src/Grav/Common/Processors/RequestProcessor.php new file mode 100644 index 0000000..a69ce3d --- /dev/null +++ b/system/src/Grav/Common/Processors/RequestProcessor.php @@ -0,0 +1,66 @@ +startTimer(); + + $header = $request->getHeaderLine('Content-Type'); + $type = trim(strstr($header, ';', true) ?: $header); + if ($type === 'application/json') { + $request = $request->withParsedBody(json_decode($request->getBody()->getContents(), true)); + } + + $uri = $request->getUri(); + $ext = mb_strtolower(Utils::pathinfo($uri->getPath(), PATHINFO_EXTENSION)); + + $request = $request + ->withAttribute('grav', $this->container) + ->withAttribute('time', $_SERVER['REQUEST_TIME_FLOAT'] ?? GRAV_REQUEST_TIME) + ->withAttribute('route', Uri::getCurrentRoute()->withExtension($ext)) + ->withAttribute('referrer', $this->container['uri']->referrer()); + + $event = new RequestHandlerEvent(['request' => $request, 'handler' => $handler]); + /** @var RequestHandlerEvent $event */ + $event = $this->container->fireEvent('onRequestHandlerInit', $event); + $response = $event->getResponse(); + $this->stopTimer(); + + if ($response) { + return $response; + } + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/SchedulerProcessor.php b/system/src/Grav/Common/Processors/SchedulerProcessor.php new file mode 100644 index 0000000..264f0b2 --- /dev/null +++ b/system/src/Grav/Common/Processors/SchedulerProcessor.php @@ -0,0 +1,42 @@ +startTimer(); + $scheduler = $this->container['scheduler']; + $this->container->fireEvent('onSchedulerInitialized', new Event(['scheduler' => $scheduler])); + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/TasksProcessor.php b/system/src/Grav/Common/Processors/TasksProcessor.php new file mode 100644 index 0000000..73540b8 --- /dev/null +++ b/system/src/Grav/Common/Processors/TasksProcessor.php @@ -0,0 +1,71 @@ +startTimer(); + + $task = $this->container['task']; + $action = $this->container['action']; + if ($task || $action) { + $attributes = $request->getAttribute('controller'); + + $controllerClass = $attributes['class'] ?? null; + if ($controllerClass) { + /** @var RequestHandlerInterface $controller */ + $controller = new $controllerClass($attributes['path'] ?? '', $attributes['params'] ?? []); + try { + $response = $controller->handle($request); + + if ($response->getStatusCode() === 418) { + $response = $handler->handle($request); + } + + $this->stopTimer(); + + return $response; + } catch (NotFoundException $e) { + // Task not found: Let it pass through. + } + } + + if ($task) { + $this->container->fireEvent('onTask.' . $task); + } elseif ($action) { + $this->container->fireEvent('onAction.' . $action); + } + } + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/ThemesProcessor.php b/system/src/Grav/Common/Processors/ThemesProcessor.php new file mode 100644 index 0000000..e2a68a6 --- /dev/null +++ b/system/src/Grav/Common/Processors/ThemesProcessor.php @@ -0,0 +1,40 @@ +startTimer(); + $this->container['themes']->init(); + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/TwigProcessor.php b/system/src/Grav/Common/Processors/TwigProcessor.php new file mode 100644 index 0000000..e4722b1 --- /dev/null +++ b/system/src/Grav/Common/Processors/TwigProcessor.php @@ -0,0 +1,40 @@ +startTimer(); + $this->container['twig']->init(); + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Scheduler/Cron.php b/system/src/Grav/Common/Scheduler/Cron.php new file mode 100644 index 0000000..1de43f0 --- /dev/null +++ b/system/src/Grav/Common/Scheduler/Cron.php @@ -0,0 +1,577 @@ + modified for Grav integration + * @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved. + * @license MIT License; see LICENSE file for details. + */ + +namespace Grav\Common\Scheduler; + +/* + * Usage examples : + * ---------------- + * + * $cron = new Cron('10-30/5 12 * * *'); + * + * var_dump($cron->getMinutes()); + * // array(5) { + * // [0]=> int(10) + * // [1]=> int(15) + * // [2]=> int(20) + * // [3]=> int(25) + * // [4]=> int(30) + * // } + * + * var_dump($cron->getText('fr')); + * // string(32) "Chaque jour à 12:10,15,20,25,30" + * + * var_dump($cron->getText('en')); + * // string(30) "Every day at 12:10,15,20,25,30" + * + * var_dump($cron->getType()); + * // string(3) "day" + * + * var_dump($cron->getCronHours()); + * // string(2) "12" + * + * var_dump($cron->matchExact(new \DateTime('2012-07-01 13:25:10'))); + * // bool(false) + * + * var_dump($cron->matchExact(new \DateTime('2012-07-01 12:15:20'))); + * // bool(true) + * + * var_dump($cron->matchWithMargin(new \DateTime('2012-07-01 12:32:50'), -3, 5)); + * // bool(true) + */ + +use DateInterval; +use DateTime; +use RuntimeException; +use function count; +use function in_array; +use function is_array; +use function is_string; + +class Cron +{ + public const TYPE_UNDEFINED = ''; + public const TYPE_MINUTE = 'minute'; + public const TYPE_HOUR = 'hour'; + public const TYPE_DAY = 'day'; + public const TYPE_WEEK = 'week'; + public const TYPE_MONTH = 'month'; + public const TYPE_YEAR = 'year'; + /** + * + * @var array + */ + protected $texts = [ + 'fr' => [ + 'empty' => '-tout-', + 'name_minute' => 'minute', + 'name_hour' => 'heure', + 'name_day' => 'jour', + 'name_week' => 'semaine', + 'name_month' => 'mois', + 'name_year' => 'année', + 'text_period' => 'Chaque %s', + 'text_mins' => 'à %s minutes', + 'text_time' => 'à %02s:%02s', + 'text_dow' => 'le %s', + 'text_month' => 'de %s', + 'text_dom' => 'le %s', + 'weekdays' => ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'], + 'months' => ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'], + ], + 'en' => [ + 'empty' => '-all-', + 'name_minute' => 'minute', + 'name_hour' => 'hour', + 'name_day' => 'day', + 'name_week' => 'week', + 'name_month' => 'month', + 'name_year' => 'year', + 'text_period' => 'Every %s', + 'text_mins' => 'at %s minutes past the hour', + 'text_time' => 'at %02s:%02s', + 'text_dow' => 'on %s', + 'text_month' => 'of %s', + 'text_dom' => 'on the %s', + 'weekdays' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + 'months' => ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'], + ], + ]; + + /** + * min hour dom month dow + * @var string + */ + protected $cron = ''; + /** + * + * @var array + */ + protected $minutes = []; + /** + * + * @var array + */ + protected $hours = []; + /** + * + * @var array + */ + protected $months = []; + /** + * 0-7 : sunday, monday, ... saturday, sunday + * @var array + */ + protected $dow = []; + /** + * + * @var array + */ + protected $dom = []; + + /** + * @param string|null $cron + */ + public function __construct($cron = null) + { + if (null !== $cron) { + $this->setCron($cron); + } + } + + /** + * @return string + */ + public function getCron() + { + return implode(' ', [ + $this->getCronMinutes(), + $this->getCronHours(), + $this->getCronDaysOfMonth(), + $this->getCronMonths(), + $this->getCronDaysOfWeek(), + ]); + } + + /** + * @param string $lang 'fr' or 'en' + * @return string + */ + public function getText($lang) + { + // check lang + if (!isset($this->texts[$lang])) { + return $this->getCron(); + } + + $texts = $this->texts[$lang]; + // check type + + $type = $this->getType(); + if ($type === self::TYPE_UNDEFINED) { + return $this->getCron(); + } + + // init + $elements = []; + $elements[] = sprintf($texts['text_period'], $texts['name_' . $type]); + + // hour + if ($type === self::TYPE_HOUR) { + $elements[] = sprintf($texts['text_mins'], $this->getCronMinutes()); + } + + // week + if ($type === self::TYPE_WEEK) { + $dow = $this->getCronDaysOfWeek(); + foreach ($texts['weekdays'] as $i => $wd) { + $dow = str_replace((string) ($i + 1), $wd, $dow); + } + $elements[] = sprintf($texts['text_dow'], $dow); + } + + // month + year + if (in_array($type, [self::TYPE_MONTH, self::TYPE_YEAR], true)) { + $elements[] = sprintf($texts['text_dom'], $this->getCronDaysOfMonth()); + } + + // year + if ($type === self::TYPE_YEAR) { + $months = $this->getCronMonths(); + for ($i = count($texts['months']) - 1; $i >= 0; $i--) { + $months = str_replace((string) ($i + 1), $texts['months'][$i], $months); + } + $elements[] = sprintf($texts['text_month'], $months); + } + + // day + week + month + year + if (in_array($type, [self::TYPE_DAY, self::TYPE_WEEK, self::TYPE_MONTH, self::TYPE_YEAR], true)) { + $elements[] = sprintf($texts['text_time'], $this->getCronHours(), $this->getCronMinutes()); + } + + return str_replace('*', $texts['empty'], implode(' ', $elements)); + } + + /** + * @return string + */ + public function getType() + { + $mask = preg_replace('/[^\* ]/', '-', $this->getCron()); + $mask = preg_replace('/-+/', '-', $mask); + $mask = preg_replace('/[^-\*]/', '', $mask); + + if ($mask === '*****') { + return self::TYPE_MINUTE; + } + + if ($mask === '-****') { + return self::TYPE_HOUR; + } + + if (substr($mask, -3) === '***') { + return self::TYPE_DAY; + } + + if (substr($mask, -3) === '-**') { + return self::TYPE_MONTH; + } + + if (substr($mask, -3) === '**-') { + return self::TYPE_WEEK; + } + + if (substr($mask, -2) === '-*') { + return self::TYPE_YEAR; + } + + return self::TYPE_UNDEFINED; + } + + /** + * @param string $cron + * @return $this + */ + public function setCron($cron) + { + // sanitize + $cron = trim($cron); + $cron = preg_replace('/\s+/', ' ', $cron); + // explode + $elements = explode(' ', $cron); + if (count($elements) !== 5) { + throw new RuntimeException('Bad number of elements'); + } + + $this->cron = $cron; + $this->setMinutes($elements[0]); + $this->setHours($elements[1]); + $this->setDaysOfMonth($elements[2]); + $this->setMonths($elements[3]); + $this->setDaysOfWeek($elements[4]); + + return $this; + } + + /** + * @return string + */ + public function getCronMinutes() + { + return $this->arrayToCron($this->minutes); + } + + /** + * @return string + */ + public function getCronHours() + { + return $this->arrayToCron($this->hours); + } + + /** + * @return string + */ + public function getCronDaysOfMonth() + { + return $this->arrayToCron($this->dom); + } + + /** + * @return string + */ + public function getCronMonths() + { + return $this->arrayToCron($this->months); + } + + /** + * @return string + */ + public function getCronDaysOfWeek() + { + return $this->arrayToCron($this->dow); + } + + /** + * @return array + */ + public function getMinutes() + { + return $this->minutes; + } + + /** + * @return array + */ + public function getHours() + { + return $this->hours; + } + + /** + * @return array + */ + public function getDaysOfMonth() + { + return $this->dom; + } + + /** + * @return array + */ + public function getMonths() + { + return $this->months; + } + + /** + * @return array + */ + public function getDaysOfWeek() + { + return $this->dow; + } + + /** + * @param string|string[] $minutes + * @return $this + */ + public function setMinutes($minutes) + { + $this->minutes = $this->cronToArray($minutes, 0, 59); + + return $this; + } + + /** + * @param string|string[] $hours + * @return $this + */ + public function setHours($hours) + { + $this->hours = $this->cronToArray($hours, 0, 23); + + return $this; + } + + /** + * @param string|string[] $months + * @return $this + */ + public function setMonths($months) + { + $this->months = $this->cronToArray($months, 1, 12); + + return $this; + } + + /** + * @param string|string[] $dow + * @return $this + */ + public function setDaysOfWeek($dow) + { + $this->dow = $this->cronToArray($dow, 0, 7); + + return $this; + } + + /** + * @param string|string[] $dom + * @return $this + */ + public function setDaysOfMonth($dom) + { + $this->dom = $this->cronToArray($dom, 1, 31); + + return $this; + } + + /** + * @param mixed $date + * @param int $min + * @param int $hour + * @param int $day + * @param int $month + * @param int $weekday + * @return DateTime + */ + protected function parseDate($date, &$min, &$hour, &$day, &$month, &$weekday) + { + if (is_numeric($date) && (int)$date == $date) { + $date = new DateTime('@' . $date); + } elseif (is_string($date)) { + $date = new DateTime('@' . strtotime($date)); + } + if ($date instanceof DateTime) { + $min = (int)$date->format('i'); + $hour = (int)$date->format('H'); + $day = (int)$date->format('d'); + $month = (int)$date->format('m'); + $weekday = (int)$date->format('w'); // 0-6 + } else { + throw new RuntimeException('Date format not supported'); + } + + return new DateTime($date->format('Y-m-d H:i:sP')); + } + + /** + * @param int|string|DateTime $date + */ + public function matchExact($date) + { + $date = $this->parseDate($date, $min, $hour, $day, $month, $weekday); + + return + (empty($this->minutes) || in_array($min, $this->minutes, true)) && + (empty($this->hours) || in_array($hour, $this->hours, true)) && + (empty($this->dom) || in_array($day, $this->dom, true)) && + (empty($this->months) || in_array($month, $this->months, true)) && + (empty($this->dow) || in_array($weekday, $this->dow, true) || ($weekday == 0 && in_array(7, $this->dow, true)) || ($weekday == 7 && in_array(0, $this->dow, true)) + ); + } + + /** + * @param int|string|DateTime $date + * @param int $minuteBefore + * @param int $minuteAfter + */ + public function matchWithMargin($date, $minuteBefore = 0, $minuteAfter = 0) + { + if ($minuteBefore > 0) { + throw new RuntimeException('MinuteBefore parameter cannot be positive !'); + } + if ($minuteAfter < 0) { + throw new RuntimeException('MinuteAfter parameter cannot be negative !'); + } + + $date = $this->parseDate($date, $min, $hour, $day, $month, $weekday); + $interval = new DateInterval('PT1M'); // 1 min + if ($minuteBefore !== 0) { + $date->sub(new DateInterval('PT' . abs($minuteBefore) . 'M')); + } + $n = $minuteAfter - $minuteBefore + 1; + for ($i = 0; $i < $n; $i++) { + if ($this->matchExact($date)) { + return true; + } + $date->add($interval); + } + + return false; + } + + /** + * @param array $array + * @return string + */ + protected function arrayToCron($array) + { + $n = count($array); + if (!is_array($array) || $n === 0) { + return '*'; + } + + $cron = [$array[0]]; + $s = $c = $array[0]; + for ($i = 1; $i < $n; $i++) { + if ($array[$i] == $c + 1) { + $c = $array[$i]; + $cron[count($cron) - 1] = $s . '-' . $c; + } else { + $s = $c = $array[$i]; + $cron[] = $c; + } + } + + return implode(',', $cron); + } + + /** + * + * @param array|string $string + * @param int $min + * @param int $max + * @return array + */ + protected function cronToArray($string, $min, $max) + { + $array = []; + if (is_array($string)) { + foreach ($string as $val) { + if (is_numeric($val) && (int)$val == $val && $val >= $min && $val <= $max) { + $array[] = (int)$val; + } + } + } elseif ($string !== '*') { + while ($string !== '') { + // test "*/n" expression + if (preg_match('/^\*\/([0-9]+),?/', $string, $m)) { + for ($i = max(0, $min); $i <= min(59, $max); $i += $m[1]) { + $array[] = (int)$i; + } + $string = substr($string, strlen($m[0])); + continue; + } + // test "a-b/n" expression + if (preg_match('/^([0-9]+)-([0-9]+)\/([0-9]+),?/', $string, $m)) { + for ($i = max($m[1], $min); $i <= min($m[2], $max); $i += $m[3]) { + $array[] = (int)$i; + } + $string = substr($string, strlen($m[0])); + continue; + } + // test "a-b" expression + if (preg_match('/^([0-9]+)-([0-9]+),?/', $string, $m)) { + for ($i = max($m[1], $min); $i <= min($m[2], $max); $i++) { + $array[] = (int)$i; + } + $string = substr($string, strlen($m[0])); + continue; + } + // test "c" expression + if (preg_match('/^([0-9]+),?/', $string, $m)) { + if ($m[1] >= $min && $m[1] <= $max) { + $array[] = (int)$m[1]; + } + $string = substr($string, strlen($m[0])); + continue; + } + + // something goes wrong in the expression + return []; + } + } + sort($array, SORT_NUMERIC); + + return $array; + } +} diff --git a/system/src/Grav/Common/Scheduler/IntervalTrait.php b/system/src/Grav/Common/Scheduler/IntervalTrait.php new file mode 100644 index 0000000..55b1d89 --- /dev/null +++ b/system/src/Grav/Common/Scheduler/IntervalTrait.php @@ -0,0 +1,404 @@ +at = $expression; + $this->executionTime = CronExpression::factory($expression); + + return $this; + } + + /** + * Set the execution time to every minute. + * + * @return self + */ + public function everyMinute() + { + return $this->at('* * * * *'); + } + + /** + * Set the execution time to every hour. + * + * @param int|string $minute + * @return self + */ + public function hourly($minute = 0) + { + $c = $this->validateCronSequence($minute); + + return $this->at("{$c['minute']} * * * *"); + } + + /** + * Set the execution time to once a day. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function daily($hour = 0, $minute = 0) + { + if (is_string($hour)) { + $parts = explode(':', $hour); + $hour = $parts[0]; + $minute = $parts[1] ?? '0'; + } + $c = $this->validateCronSequence($minute, $hour); + + return $this->at("{$c['minute']} {$c['hour']} * * *"); + } + + /** + * Set the execution time to once a week. + * + * @param int|string $weekday + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function weekly($weekday = 0, $hour = 0, $minute = 0) + { + if (is_string($hour)) { + $parts = explode(':', $hour); + $hour = $parts[0]; + $minute = $parts[1] ?? '0'; + } + $c = $this->validateCronSequence($minute, $hour, null, null, $weekday); + + return $this->at("{$c['minute']} {$c['hour']} * * {$c['weekday']}"); + } + + /** + * Set the execution time to once a month. + * + * @param int|string $month + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function monthly($month = '*', $day = 1, $hour = 0, $minute = 0) + { + if (is_string($hour)) { + $parts = explode(':', $hour); + $hour = $parts[0]; + $minute = $parts[1] ?? '0'; + } + $c = $this->validateCronSequence($minute, $hour, $day, $month); + + return $this->at("{$c['minute']} {$c['hour']} {$c['day']} {$c['month']} *"); + } + + /** + * Set the execution time to every Sunday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function sunday($hour = 0, $minute = 0) + { + return $this->weekly(0, $hour, $minute); + } + + /** + * Set the execution time to every Monday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function monday($hour = 0, $minute = 0) + { + return $this->weekly(1, $hour, $minute); + } + + /** + * Set the execution time to every Tuesday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function tuesday($hour = 0, $minute = 0) + { + return $this->weekly(2, $hour, $minute); + } + + /** + * Set the execution time to every Wednesday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function wednesday($hour = 0, $minute = 0) + { + return $this->weekly(3, $hour, $minute); + } + + /** + * Set the execution time to every Thursday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function thursday($hour = 0, $minute = 0) + { + return $this->weekly(4, $hour, $minute); + } + + /** + * Set the execution time to every Friday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function friday($hour = 0, $minute = 0) + { + return $this->weekly(5, $hour, $minute); + } + + /** + * Set the execution time to every Saturday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function saturday($hour = 0, $minute = 0) + { + return $this->weekly(6, $hour, $minute); + } + + /** + * Set the execution time to every January. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function january($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(1, $day, $hour, $minute); + } + + /** + * Set the execution time to every February. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function february($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(2, $day, $hour, $minute); + } + + /** + * Set the execution time to every March. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function march($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(3, $day, $hour, $minute); + } + + /** + * Set the execution time to every April. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function april($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(4, $day, $hour, $minute); + } + + /** + * Set the execution time to every May. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function may($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(5, $day, $hour, $minute); + } + + /** + * Set the execution time to every June. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function june($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(6, $day, $hour, $minute); + } + + /** + * Set the execution time to every July. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function july($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(7, $day, $hour, $minute); + } + + /** + * Set the execution time to every August. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function august($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(8, $day, $hour, $minute); + } + + /** + * Set the execution time to every September. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function september($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(9, $day, $hour, $minute); + } + + /** + * Set the execution time to every October. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function october($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(10, $day, $hour, $minute); + } + + /** + * Set the execution time to every November. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function november($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(11, $day, $hour, $minute); + } + + /** + * Set the execution time to every December. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function december($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(12, $day, $hour, $minute); + } + + /** + * Validate sequence of cron expression. + * + * @param int|string|null $minute + * @param int|string|null $hour + * @param int|string|null $day + * @param int|string|null $month + * @param int|string|null $weekday + * @return array + */ + private function validateCronSequence($minute = null, $hour = null, $day = null, $month = null, $weekday = null) + { + return [ + 'minute' => $this->validateCronRange($minute, 0, 59), + 'hour' => $this->validateCronRange($hour, 0, 23), + 'day' => $this->validateCronRange($day, 1, 31), + 'month' => $this->validateCronRange($month, 1, 12), + 'weekday' => $this->validateCronRange($weekday, 0, 6), + ]; + } + + /** + * Validate sequence of cron expression. + * + * @param int|string|null $value + * @param int $min + * @param int $max + * @return mixed + */ + private function validateCronRange($value, $min, $max) + { + if ($value === null || $value === '*') { + return '*'; + } + + if (! is_numeric($value) || + ! ($value >= $min && $value <= $max) + ) { + throw new InvalidArgumentException( + "Invalid value: it should be '*' or between {$min} and {$max}." + ); + } + + return $value; + } +} diff --git a/system/src/Grav/Common/Scheduler/Job.php b/system/src/Grav/Common/Scheduler/Job.php new file mode 100644 index 0000000..62cc299 --- /dev/null +++ b/system/src/Grav/Common/Scheduler/Job.php @@ -0,0 +1,1073 @@ +id = Grav::instance()['inflector']->hyphenize($id); + } else { + if (is_string($command)) { + $this->id = md5($command); + } else { + /* @var object $command */ + $this->id = spl_object_hash($command); + } + } + $this->creationTime = new DateTime('now'); + // initialize the directory path for lock files + $this->tempDir = sys_get_temp_dir(); + $this->command = $command; + $this->args = $args; + // Set enabled state + $status = Grav::instance()['config']->get('scheduler.status'); + $this->enabled = !(isset($status[$id]) && $status[$id] === 'disabled'); + } + + /** + * Get the command + * + * @return Closure|string + */ + public function getCommand() + { + return $this->command; + } + + /** + * Get the cron 'at' syntax for this job + * + * @return string + */ + public function getAt() + { + return $this->at; + } + + /** + * Get the status of this job + * + * @return bool + */ + public function getEnabled() + { + return $this->enabled; + } + + /** + * Get optional arguments + * + * @return string|null + */ + public function getArguments() + { + if (is_string($this->args)) { + return $this->args; + } + + return null; + } + + /** + * Get raw arguments (array or string) + * + * @return array|string + */ + public function getRawArguments() + { + return $this->args; + } + + /** + * @return CronExpression + */ + public function getCronExpression() + { + return CronExpression::factory($this->at); + } + + /** + * Get the status of the last run for this job + * + * @return bool + */ + public function isSuccessful() + { + return $this->successful; + } + + /** + * Get the Job id. + * + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * Check if the Job is due to run. + * It accepts as input a DateTime used to check if + * the job is due. Defaults to job creation time. + * It also default the execution time if not previously defined. + * + * @param DateTime|null $date + * @return bool + */ + public function isDue(DateTime $date = null) + { + // The execution time is being defaulted if not defined + if (!$this->executionTime) { + $this->at('* * * * *'); + } + + $date = $date ?? $this->creationTime; + + return $this->executionTime->isDue($date); + } + + /** + * Check if the Job is overlapping. + * + * @return bool + */ + public function isOverlapping() + { + return $this->lockFile && + file_exists($this->lockFile) && + call_user_func($this->whenOverlapping, filemtime($this->lockFile)) === false; + } + + /** + * Force the Job to run in foreground. + * + * @return $this + */ + public function inForeground() + { + $this->runInBackground = false; + + return $this; + } + + /** + * Sets/Gets an option backlink + * + * @param string|null $link + * @return string|null + */ + public function backlink($link = null) + { + if ($link) { + $this->backlink = $link; + } + return $this->backlink; + } + + + /** + * Check if the Job can run in background. + * + * @return bool + */ + public function runInBackground() + { + return !(is_callable($this->command) || $this->runInBackground === false); + } + + /** + * This will prevent the Job from overlapping. + * It prevents another instance of the same Job of + * being executed if the previous is still running. + * The job id is used as a filename for the lock file. + * + * @param string|null $tempDir The directory path for the lock files + * @param callable|null $whenOverlapping A callback to ignore job overlapping + * @return self + */ + public function onlyOne($tempDir = null, callable $whenOverlapping = null) + { + if ($tempDir === null || !is_dir($tempDir)) { + $tempDir = $this->tempDir; + } + $this->lockFile = implode('/', [ + trim($tempDir), + trim($this->id) . '.lock', + ]); + if ($whenOverlapping) { + $this->whenOverlapping = $whenOverlapping; + } else { + $this->whenOverlapping = static function () { + return false; + }; + } + + return $this; + } + + /** + * Configure the job. + * + * @param array $config + * @return self + */ + public function configure(array $config = []) + { + // Check if config has defined a tempDir + if (isset($config['tempDir']) && is_dir($config['tempDir'])) { + $this->tempDir = $config['tempDir']; + } + + return $this; + } + + /** + * Truth test to define if the job should run if due. + * + * @param callable $fn + * @return self + */ + public function when(callable $fn) + { + $this->truthTest = $fn(); + + return $this; + } + + /** + * Run the job. + * + * @return bool + */ + public function run() + { + // Check dependencies (modern feature) + if (!$this->checkDependencies()) { + $this->output = 'Dependencies not met'; + $this->successful = false; + return false; + } + + // If the truthTest failed, don't run + if ($this->truthTest !== true) { + return false; + } + + // If overlapping, don't run + if ($this->isOverlapping()) { + return false; + } + + // Write lock file if necessary + $this->createLockFile(); + + // Call before if required + if (is_callable($this->before)) { + call_user_func($this->before); + } + + // If command is callable... + if (is_callable($this->command)) { + $this->output = $this->exec(); + } else { + $args = is_string($this->args) ? explode(' ', $this->args) : $this->args; + $command = array_merge([$this->command], $args); + $process = new Process($command); + + // Apply timeout if set (modern feature) + if ($this->timeout > 0) { + $process->setTimeout($this->timeout); + } + + $this->process = $process; + + if ($this->runInBackground()) { + $process->start(); + } else { + $process->run(); + $this->finalize(); + } + } + + return true; + } + + /** + * Finish up processing the job + * + * @return void + */ + public function finalize() + { + $process = $this->process; + + if ($process) { + $process->wait(); + + if ($process->isSuccessful()) { + $this->successful = true; + $this->output = $process->getOutput(); + } else { + $this->successful = false; + $this->output = $process->getErrorOutput(); + } + + $this->postRun(); + + unset($this->process); + } + } + + /** + * Things to run after job has run + * + * @return void + */ + private function postRun() + { + if (count($this->outputTo) > 0) { + foreach ($this->outputTo as $file) { + $output_mode = $this->outputMode === 'append' ? FILE_APPEND | LOCK_EX : LOCK_EX; + $timestamp = (new DateTime('now'))->format('c'); + $output = $timestamp . "\n" . str_pad('', strlen($timestamp), '>') . "\n" . $this->output; + file_put_contents($file, $output, $output_mode); + } + } + + // Send output to email + $this->emailOutput(); + + // Call any callback defined + if (is_callable($this->after)) { + call_user_func($this->after, $this->output, $this->returnCode); + } + + $this->removeLockFile(); + } + + /** + * Create the job lock file. + * + * @param mixed $content + * @return void + */ + private function createLockFile($content = null) + { + if ($this->lockFile) { + if ($content === null || !is_string($content)) { + $content = $this->getId(); + } + file_put_contents($this->lockFile, $content); + } + } + + /** + * Remove the job lock file. + * + * @return void + */ + private function removeLockFile() + { + if ($this->lockFile && file_exists($this->lockFile)) { + unlink($this->lockFile); + } + } + + /** + * Execute a callable job. + * + * @return string + * @throws RuntimeException + */ + private function exec() + { + $return_data = ''; + ob_start(); + try { + $return_data = call_user_func_array($this->command, $this->args); + $this->successful = true; + } catch (RuntimeException $e) { + $return_data = $e->getMessage(); + $this->successful = false; + } + $this->output = ob_get_clean() . (is_string($return_data) ? $return_data : ''); + + $this->postRun(); + + return $this->output; + } + + /** + * Set the file/s where to write the output of the job. + * + * @param string|array $filename + * @param bool $append + * @return self + */ + public function output($filename, $append = false) + { + $this->outputTo = is_array($filename) ? $filename : [$filename]; + $this->outputMode = $append === false ? 'overwrite' : 'append'; + + return $this; + } + + /** + * Get the job output. + * + * @return mixed + */ + public function getOutput() + { + return $this->output; + } + + /** + * Set the emails where the output should be sent to. + * The Job should be set to write output to a file + * for this to work. + * + * @param string|array $email + * @return self + */ + public function email($email) + { + if (!is_string($email) && !is_array($email)) { + throw new InvalidArgumentException('The email can be only string or array'); + } + + $this->emailTo = is_array($email) ? $email : [$email]; + // Force the job to run in foreground + $this->inForeground(); + + return $this; + } + + /** + * Email the output of the job, if any. + * + * @return bool + */ + private function emailOutput() + { + if (!count($this->outputTo) || !count($this->emailTo)) { + return false; + } + + if (is_callable('Grav\Plugin\Email\Utils::sendEmail')) { + $subject ='Grav Scheduled Job [' . $this->getId() . ']'; + $content = "

Output from Job ID: {$this->getId()}

\n

Command: {$this->getCommand()}


\n".$this->getOutput()."\n
"; + $to = $this->emailTo; + + \Grav\Plugin\Email\Utils::sendEmail($subject, $content, $to); + } + + return true; + } + + /** + * Set function to be called before job execution + * Job object is injected as a parameter to callable function. + * + * @param callable $fn + * @return self + */ + public function before(callable $fn) + { + $this->before = $fn; + + return $this; + } + + /** + * Set a function to be called after job execution. + * By default this will force the job to run in foreground + * because the output is injected as a parameter of this + * function, but it could be avoided by passing true as a + * second parameter. The job will run in background if it + * meets all the other criteria. + * + * @param callable $fn + * @param bool $runInBackground + * @return self + */ + public function then(callable $fn, $runInBackground = false) + { + $this->after = $fn; + // Force the job to run in foreground + if ($runInBackground === false) { + $this->inForeground(); + } + + return $this; + } + + // Modern Job Methods + + /** + * Set maximum retry attempts + * + * @param int $attempts + * @return self + */ + public function maxAttempts(int $attempts): self + { + $this->maxAttempts = $attempts; + return $this; + } + + /** + * Get maximum retry attempts + * + * @return int + */ + public function getMaxAttempts(): int + { + return $this->maxAttempts; + } + + /** + * Set retry delay + * + * @param int $seconds + * @param string $strategy 'linear' or 'exponential' + * @return self + */ + public function retryDelay(int $seconds, string $strategy = 'exponential'): self + { + $this->retryDelay = $seconds; + $this->retryStrategy = $strategy; + return $this; + } + + /** + * Get current retry count + * + * @return int + */ + public function getRetryCount(): int + { + return $this->retryCount; + } + + /** + * Set job timeout + * + * @param int $seconds + * @return self + */ + public function timeout(int $seconds): self + { + $this->timeout = $seconds; + return $this; + } + + /** + * Set job priority + * + * @param string $priority 'high', 'normal', or 'low' + * @return self + */ + public function priority(string $priority): self + { + if (!in_array($priority, ['high', 'normal', 'low'])) { + throw new InvalidArgumentException('Priority must be high, normal, or low'); + } + $this->priority = $priority; + return $this; + } + + /** + * Get job priority + * + * @return string + */ + public function getPriority(): string + { + return $this->priority; + } + + /** + * Add job dependency + * + * @param string $jobId + * @return self + */ + public function dependsOn(string $jobId): self + { + $this->dependencies[] = $jobId; + return $this; + } + + /** + * Chain another job to run after this one + * + * @param Job $job + * @param bool $onlyOnSuccess Run only if current job succeeds + * @return self + */ + public function chain(Job $job, bool $onlyOnSuccess = true): self + { + $this->chainedJobs[] = [ + 'job' => $job, + 'onlyOnSuccess' => $onlyOnSuccess, + ]; + return $this; + } + + /** + * Add metadata to the job + * + * @param string $key + * @param mixed $value + * @return self + */ + public function withMetadata(string $key, $value): self + { + $this->metadata[$key] = $value; + return $this; + } + + /** + * Add tags to the job + * + * @param array $tags + * @return self + */ + public function withTags(array $tags): self + { + $this->tags = array_merge($this->tags, $tags); + return $this; + } + + /** + * Set success callback + * + * @param callable $callback + * @return self + */ + public function onSuccess(callable $callback): self + { + $this->onSuccess = $callback; + return $this; + } + + /** + * Set failure callback + * + * @param callable $callback + * @return self + */ + public function onFailure(callable $callback): self + { + $this->onFailure = $callback; + return $this; + } + + /** + * Set retry callback + * + * @param callable $callback + * @return self + */ + public function onRetry(callable $callback): self + { + $this->onRetry = $callback; + return $this; + } + + /** + * Run the job with retry support + * + * @return bool + */ + public function runWithRetry(): bool + { + $attempts = 0; + $lastException = null; + + while ($attempts < $this->maxAttempts) { + $attempts++; + $this->retryCount = $attempts - 1; + + try { + // Record execution start time + $this->executionStartTime = microtime(true); + + // Run the job + $result = $this->run(); + + // Record execution time + $this->executionDuration = microtime(true) - $this->executionStartTime; + + if ($result && $this->isSuccessful()) { + // Call success callback + if ($this->onSuccess) { + call_user_func($this->onSuccess, $this); + } + + // Run chained jobs + $this->runChainedJobs(true); + + return true; + } + + throw new RuntimeException('Job execution failed'); + + } catch (\Exception $e) { + $lastException = $e; + $this->output = $e->getMessage(); + $this->successful = false; + + if ($attempts < $this->maxAttempts) { + // Call retry callback + if ($this->onRetry) { + call_user_func($this->onRetry, $this, $attempts, $e); + } + + // Calculate delay before retry + $delay = $this->calculateRetryDelay($attempts); + if ($delay > 0) { + sleep($delay); + } + } else { + // Final failure + if ($this->onFailure) { + call_user_func($this->onFailure, $this, $e); + } + + // Run chained jobs that should run on failure + $this->runChainedJobs(false); + } + } + } + + return false; + } + + /** + * Get execution time in seconds + * + * @return float + */ + public function getExecutionTime(): float + { + return $this->executionDuration; + } + + /** + * Get job metadata + * + * @param string|null $key + * @return mixed + */ + public function getMetadata(string $key = null) + { + if ($key === null) { + return $this->metadata; + } + + return $this->metadata[$key] ?? null; + } + + /** + * Get job tags + * + * @return array + */ + public function getTags(): array + { + return $this->tags; + } + + /** + * Check if job has a specific tag + * + * @param string $tag + * @return bool + */ + public function hasTag(string $tag): bool + { + return in_array($tag, $this->tags); + } + + /** + * Set queue ID + * + * @param string $queueId + * @return self + */ + public function setQueueId(string $queueId): self + { + $this->queueId = $queueId; + return $this; + } + + /** + * Get queue ID + * + * @return string|null + */ + public function getQueueId(): ?string + { + return $this->queueId; + } + + /** + * Get process (for background jobs) + * + * @return Process|null + */ + public function getProcess(): ?Process + { + return $this->process; + } + + /** + * Calculate retry delay based on strategy + * + * @param int $attempt + * @return int + */ + protected function calculateRetryDelay(int $attempt): int + { + if ($this->retryStrategy === 'exponential') { + return min($this->retryDelay * pow(2, $attempt - 1), 3600); // Max 1 hour + } + + return $this->retryDelay; + } + + /** + * Check if dependencies are met + * + * @return bool + */ + protected function checkDependencies(): bool + { + if (empty($this->dependencies)) { + return true; + } + + // This would need to check against job history or status + // For now, we'll assume dependencies are met + // In a real implementation, this would check the Scheduler's job status + return true; + } + + /** + * Run chained jobs + * + * @param bool $success Whether the current job succeeded + * @return void + */ + protected function runChainedJobs(bool $success): void + { + foreach ($this->chainedJobs as $chainedJob) { + $shouldRun = !$chainedJob['onlyOnSuccess'] || $success; + + if ($shouldRun) { + $job = $chainedJob['job']; + if (method_exists($job, 'runWithRetry')) { + $job->runWithRetry(); + } else { + $job->run(); + } + } + } + } + + /** + * Convert job to array for serialization + * + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->getId(), + 'command' => is_string($this->command) ? $this->command : 'Closure', + 'at' => $this->getAt(), + 'enabled' => $this->getEnabled(), + 'priority' => $this->priority, + 'max_attempts' => $this->maxAttempts, + 'retry_count' => $this->retryCount, + 'retry_delay' => $this->retryDelay, + 'retry_strategy' => $this->retryStrategy, + 'timeout' => $this->timeout, + 'dependencies' => $this->dependencies, + 'metadata' => $this->metadata, + 'tags' => $this->tags, + 'execution_time' => $this->executionDuration, + 'successful' => $this->successful, + 'output' => $this->output, + ]; + } + + /** + * Create job from array + * + * @param array $data + * @return self + */ + public static function fromArray(array $data): self + { + $job = new self($data['command'] ?? '', [], $data['id'] ?? null); + + if (isset($data['at'])) { + $job->at($data['at']); + } + + if (isset($data['priority'])) { + $job->priority($data['priority']); + } + + if (isset($data['max_attempts'])) { + $job->maxAttempts($data['max_attempts']); + } + + if (isset($data['retry_delay']) && isset($data['retry_strategy'])) { + $job->retryDelay($data['retry_delay'], $data['retry_strategy']); + } + + if (isset($data['timeout'])) { + $job->timeout($data['timeout']); + } + + if (isset($data['dependencies'])) { + foreach ($data['dependencies'] as $dep) { + $job->dependsOn($dep); + } + } + + if (isset($data['metadata'])) { + foreach ($data['metadata'] as $key => $value) { + $job->withMetadata($key, $value); + } + } + + if (isset($data['tags'])) { + $job->withTags($data['tags']); + } + + return $job; + } +} diff --git a/system/src/Grav/Common/Scheduler/JobHistory.php b/system/src/Grav/Common/Scheduler/JobHistory.php new file mode 100644 index 0000000..8361d5c --- /dev/null +++ b/system/src/Grav/Common/Scheduler/JobHistory.php @@ -0,0 +1,462 @@ +historyPath = $historyPath; + $this->retentionDays = $retentionDays; + + // Ensure history directory exists + if (!is_dir($this->historyPath)) { + mkdir($this->historyPath, 0755, true); + } + } + + /** + * Log job execution + * + * @param Job $job + * @param array $metadata Additional metadata to store + * @return string Log entry ID + */ + public function logExecution(Job $job, array $metadata = []): string + { + $entryId = uniqid($job->getId() . '_', true); + $timestamp = new DateTime(); + + $entry = [ + 'id' => $entryId, + 'job_id' => $job->getId(), + 'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure', + 'arguments' => method_exists($job, 'getRawArguments') ? $job->getRawArguments() : $job->getArguments(), + 'executed_at' => $timestamp->format('c'), + 'timestamp' => $timestamp->getTimestamp(), + 'success' => $job->isSuccessful(), + 'output' => $this->captureOutput($job), + 'execution_time' => method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null, + 'retry_count' => method_exists($job, 'getRetryCount') ? $job->getRetryCount() : 0, + 'priority' => method_exists($job, 'getPriority') ? $job->getPriority() : 'normal', + 'tags' => method_exists($job, 'getTags') ? $job->getTags() : [], + 'metadata' => array_merge( + method_exists($job, 'getMetadata') ? $job->getMetadata() : [], + $metadata + ), + ]; + + // Store in daily file + $this->storeEntry($entry); + + // Also store in job-specific history + $this->storeJobHistory($job->getId(), $entry); + + return $entryId; + } + + /** + * Capture job output with length limit + * + * @param Job $job + * @return array + */ + protected function captureOutput(Job $job): array + { + $output = $job->getOutput(); + $truncated = false; + + if (strlen($output) > $this->maxOutputLength) { + $output = substr($output, 0, $this->maxOutputLength); + $truncated = true; + } + + return [ + 'content' => $output, + 'truncated' => $truncated, + 'length' => strlen($job->getOutput()), + ]; + } + + /** + * Store entry in daily log file + * + * @param array $entry + * @return void + */ + protected function storeEntry(array $entry): void + { + $date = date('Y-m-d'); + $filename = $this->historyPath . '/' . $date . '.json'; + + $jsonFile = JsonFile::instance($filename); + $entries = $jsonFile->content() ?: []; + $entries[] = $entry; + $jsonFile->save($entries); + } + + /** + * Store job-specific history + * + * @param string $jobId + * @param array $entry + * @return void + */ + protected function storeJobHistory(string $jobId, array $entry): void + { + $jobDir = $this->historyPath . '/jobs'; + if (!is_dir($jobDir)) { + mkdir($jobDir, 0755, true); + } + + $filename = $jobDir . '/' . $jobId . '.json'; + $jsonFile = JsonFile::instance($filename); + $history = $jsonFile->content() ?: []; + + // Keep only last 100 executions per job + $history[] = $entry; + if (count($history) > 100) { + $history = array_slice($history, -100); + } + + $jsonFile->save($history); + } + + /** + * Get job history + * + * @param string $jobId + * @param int $limit + * @return array + */ + public function getJobHistory(string $jobId, int $limit = 50): array + { + $filename = $this->historyPath . '/jobs/' . $jobId . '.json'; + if (!file_exists($filename)) { + return []; + } + + $jsonFile = JsonFile::instance($filename); + $history = $jsonFile->content() ?: []; + + // Return most recent first + $history = array_reverse($history); + + if ($limit > 0) { + $history = array_slice($history, 0, $limit); + } + + return $history; + } + + /** + * Get history for a date range + * + * @param DateTime $startDate + * @param DateTime $endDate + * @param string|null $jobId Filter by job ID + * @return array + */ + public function getHistoryRange(DateTime $startDate, DateTime $endDate, ?string $jobId = null): array + { + $history = []; + $current = clone $startDate; + + while ($current <= $endDate) { + $filename = $this->historyPath . '/' . $current->format('Y-m-d') . '.json'; + if (file_exists($filename)) { + $jsonFile = JsonFile::instance($filename); + $entries = $jsonFile->content() ?: []; + + foreach ($entries as $entry) { + if ($jobId === null || $entry['job_id'] === $jobId) { + $history[] = $entry; + } + } + } + + $current->modify('+1 day'); + } + + return $history; + } + + /** + * Get job statistics + * + * @param string $jobId + * @param int $days Number of days to analyze + * @return array + */ + public function getJobStatistics(string $jobId, int $days = 7): array + { + $startDate = new DateTime("-{$days} days"); + $endDate = new DateTime('now'); + + $history = $this->getHistoryRange($startDate, $endDate, $jobId); + + if (empty($history)) { + return [ + 'total_runs' => 0, + 'successful_runs' => 0, + 'failed_runs' => 0, + 'success_rate' => 0, + 'average_execution_time' => 0, + 'last_run' => null, + 'last_success' => null, + 'last_failure' => null, + ]; + } + + $totalRuns = count($history); + $successfulRuns = 0; + $executionTimes = []; + $lastRun = null; + $lastSuccess = null; + $lastFailure = null; + + foreach ($history as $entry) { + if ($entry['success']) { + $successfulRuns++; + if (!$lastSuccess || $entry['timestamp'] > $lastSuccess['timestamp']) { + $lastSuccess = $entry; + } + } else { + if (!$lastFailure || $entry['timestamp'] > $lastFailure['timestamp']) { + $lastFailure = $entry; + } + } + + if (!$lastRun || $entry['timestamp'] > $lastRun['timestamp']) { + $lastRun = $entry; + } + + if (isset($entry['execution_time']) && $entry['execution_time'] > 0) { + $executionTimes[] = $entry['execution_time']; + } + } + + return [ + 'total_runs' => $totalRuns, + 'successful_runs' => $successfulRuns, + 'failed_runs' => $totalRuns - $successfulRuns, + 'success_rate' => $totalRuns > 0 ? round(($successfulRuns / $totalRuns) * 100, 2) : 0, + 'average_execution_time' => !empty($executionTimes) ? round(array_sum($executionTimes) / count($executionTimes), 3) : 0, + 'last_run' => $lastRun, + 'last_success' => $lastSuccess, + 'last_failure' => $lastFailure, + ]; + } + + /** + * Get global statistics + * + * @param int $days + * @return array + */ + public function getGlobalStatistics(int $days = 7): array + { + $startDate = new DateTime("-{$days} days"); + $endDate = new DateTime('now'); + + $history = $this->getHistoryRange($startDate, $endDate); + + $jobStats = []; + foreach ($history as $entry) { + $jobId = $entry['job_id']; + if (!isset($jobStats[$jobId])) { + $jobStats[$jobId] = [ + 'runs' => 0, + 'success' => 0, + 'failed' => 0, + ]; + } + + $jobStats[$jobId]['runs']++; + if ($entry['success']) { + $jobStats[$jobId]['success']++; + } else { + $jobStats[$jobId]['failed']++; + } + } + + return [ + 'total_executions' => count($history), + 'unique_jobs' => count($jobStats), + 'job_statistics' => $jobStats, + 'period_days' => $days, + 'from_date' => $startDate->format('Y-m-d'), + 'to_date' => $endDate->format('Y-m-d'), + ]; + } + + /** + * Search history + * + * @param array $criteria + * @return array + */ + public function searchHistory(array $criteria): array + { + $results = []; + + // Determine date range + $startDate = isset($criteria['start_date']) ? new DateTime($criteria['start_date']) : new DateTime('-7 days'); + $endDate = isset($criteria['end_date']) ? new DateTime($criteria['end_date']) : new DateTime('now'); + + $history = $this->getHistoryRange($startDate, $endDate, $criteria['job_id'] ?? null); + + foreach ($history as $entry) { + $match = true; + + // Filter by success status + if (isset($criteria['success']) && $entry['success'] !== $criteria['success']) { + $match = false; + } + + // Filter by output content + if (isset($criteria['output_contains']) && + stripos($entry['output']['content'], $criteria['output_contains']) === false) { + $match = false; + } + + // Filter by tags + if (isset($criteria['tags']) && is_array($criteria['tags'])) { + $entryTags = $entry['tags'] ?? []; + if (empty(array_intersect($criteria['tags'], $entryTags))) { + $match = false; + } + } + + if ($match) { + $results[] = $entry; + } + } + + // Sort results + if (isset($criteria['sort_by'])) { + usort($results, function($a, $b) use ($criteria) { + $field = $criteria['sort_by']; + $order = $criteria['sort_order'] ?? 'desc'; + + $aVal = $a[$field] ?? 0; + $bVal = $b[$field] ?? 0; + + if ($order === 'asc') { + return $aVal <=> $bVal; + } else { + return $bVal <=> $aVal; + } + }); + } + + // Limit results + if (isset($criteria['limit'])) { + $results = array_slice($results, 0, $criteria['limit']); + } + + return $results; + } + + /** + * Clean old history files + * + * @return int Number of files deleted + */ + public function cleanOldHistory(): int + { + $deleted = 0; + $cutoffDate = new DateTime("-{$this->retentionDays} days"); + + $files = glob($this->historyPath . '/*.json'); + foreach ($files as $file) { + $filename = basename($file, '.json'); + // Check if filename is a date + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $filename)) { + $fileDate = new DateTime($filename); + if ($fileDate < $cutoffDate) { + unlink($file); + $deleted++; + } + } + } + + return $deleted; + } + + /** + * Export history to CSV + * + * @param array $history + * @param string $filename + * @return bool + */ + public function exportToCsv(array $history, string $filename): bool + { + $handle = fopen($filename, 'w'); + if (!$handle) { + return false; + } + + // Write headers + fputcsv($handle, [ + 'Job ID', + 'Executed At', + 'Success', + 'Execution Time', + 'Output Length', + 'Retry Count', + 'Priority', + 'Tags', + ]); + + // Write data + foreach ($history as $entry) { + fputcsv($handle, [ + $entry['job_id'], + $entry['executed_at'], + $entry['success'] ? 'Yes' : 'No', + $entry['execution_time'] ?? '', + $entry['output']['length'] ?? 0, + $entry['retry_count'] ?? 0, + $entry['priority'] ?? 'normal', + implode(', ', $entry['tags'] ?? []), + ]); + } + + fclose($handle); + return true; + } +} \ No newline at end of file diff --git a/system/src/Grav/Common/Scheduler/JobQueue.php b/system/src/Grav/Common/Scheduler/JobQueue.php new file mode 100644 index 0000000..bdf4596 --- /dev/null +++ b/system/src/Grav/Common/Scheduler/JobQueue.php @@ -0,0 +1,588 @@ +queuePath = $queuePath; + $this->lockFile = $queuePath . '/.lock'; + + // Create queue directories + $this->initializeDirectories(); + } + + /** + * Initialize queue directories + * + * @return void + */ + protected function initializeDirectories(): void + { + $dirs = [ + $this->queuePath . '/pending', + $this->queuePath . '/processing', + $this->queuePath . '/failed', + $this->queuePath . '/completed', + ]; + + foreach ($dirs as $dir) { + if (!file_exists($dir)) { + mkdir($dir, 0755, true); + } + } + } + + /** + * Push a job to the queue + * + * @param Job $job + * @param string $priority + * @return string Job queue ID + */ + public function push(Job $job, string $priority = self::PRIORITY_NORMAL): string + { + $queueId = $this->generateQueueId($job); + $timestamp = microtime(true); + + $queueItem = [ + 'id' => $queueId, + 'job_id' => $job->getId(), + 'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure', + 'arguments' => method_exists($job, 'getRawArguments') ? $job->getRawArguments() : $job->getArguments(), + 'priority' => $priority, + 'timestamp' => $timestamp, + 'attempts' => 0, + 'max_attempts' => method_exists($job, 'getMaxAttempts') ? $job->getMaxAttempts() : 1, + 'created_at' => date('c'), + 'scheduled_for' => null, + 'metadata' => [], + ]; + + // Always serialize the job to preserve its full state + $queueItem['serialized_job'] = base64_encode(serialize($job)); + + $this->writeQueueItem($queueItem, 'pending'); + + return $queueId; + } + + /** + * Push a job for delayed execution + * + * @param Job $job + * @param \DateTime $scheduledFor + * @param string $priority + * @return string + */ + public function pushDelayed(Job $job, \DateTime $scheduledFor, string $priority = self::PRIORITY_NORMAL): string + { + $queueId = $this->push($job, $priority); + + // Update the scheduled time + $item = $this->getQueueItem($queueId, 'pending'); + if ($item) { + $item['scheduled_for'] = $scheduledFor->format('c'); + $this->writeQueueItem($item, 'pending'); + } + + return $queueId; + } + + /** + * Pop the next job from the queue + * + * @return Job|null + */ + public function pop(): ?Job + { + if (!$this->lock()) { + return null; + } + + try { + // Get all pending items + $items = $this->getPendingItems(); + + if (empty($items)) { + $this->unlock(); + return null; + } + + // Sort by priority and timestamp + usort($items, function($a, $b) { + $priorityOrder = [ + self::PRIORITY_HIGH => 0, + self::PRIORITY_NORMAL => 1, + self::PRIORITY_LOW => 2, + ]; + + $aPriority = $priorityOrder[$a['priority']] ?? 1; + $bPriority = $priorityOrder[$b['priority']] ?? 1; + + if ($aPriority !== $bPriority) { + return $aPriority - $bPriority; + } + + return $a['timestamp'] <=> $b['timestamp']; + }); + + // Get the first item that's ready to run + $now = new \DateTime(); + foreach ($items as $item) { + if ($item['scheduled_for']) { + $scheduledTime = new \DateTime($item['scheduled_for']); + if ($scheduledTime > $now) { + continue; // Skip items not yet due + } + } + + // Move to processing + $this->moveQueueItem($item['id'], 'pending', 'processing'); + + // Reconstruct the job + $job = $this->reconstructJob($item); + + $this->unlock(); + return $job; + } + + $this->unlock(); + return null; + + } catch (\Exception $e) { + $this->unlock(); + throw $e; + } + } + + /** + * Pop a job from the queue with its queue ID + * + * @return array|null Array with 'job' and 'id' keys + */ + public function popWithId(): ?array + { + if (!$this->lock()) { + return null; + } + + try { + // Get all pending items + $items = $this->getPendingItems(); + + if (empty($items)) { + $this->unlock(); + return null; + } + + // Sort by priority and timestamp + usort($items, function($a, $b) { + $priorityOrder = [ + self::PRIORITY_HIGH => 0, + self::PRIORITY_NORMAL => 1, + self::PRIORITY_LOW => 2, + ]; + + $aPriority = $priorityOrder[$a['priority']] ?? 1; + $bPriority = $priorityOrder[$b['priority']] ?? 1; + + if ($aPriority !== $bPriority) { + return $aPriority - $bPriority; + } + + return $a['timestamp'] <=> $b['timestamp']; + }); + + // Get the first item that's ready to run + $now = new \DateTime(); + foreach ($items as $item) { + if ($item['scheduled_for']) { + $scheduledTime = new \DateTime($item['scheduled_for']); + if ($scheduledTime > $now) { + continue; // Skip items not yet due + } + } + + // Reconstruct the job first before moving it + $job = $this->reconstructJob($item); + + if (!$job) { + // Failed to reconstruct, skip this item + continue; + } + + // Move to processing only if we can reconstruct the job + $this->moveQueueItem($item['id'], 'pending', 'processing'); + + $this->unlock(); + return ['job' => $job, 'id' => $item['id']]; + } + + $this->unlock(); + return null; + + } catch (\Exception $e) { + $this->unlock(); + throw $e; + } + } + + /** + * Mark a job as completed + * + * @param string $queueId + * @return void + */ + public function complete(string $queueId): void + { + $this->moveQueueItem($queueId, 'processing', 'completed'); + + // Clean up old completed items + $this->cleanupCompleted(); + } + + /** + * Mark a job as failed + * + * @param string $queueId + * @param string $error + * @return void + */ + public function fail(string $queueId, string $error = ''): void + { + $item = $this->getQueueItem($queueId, 'processing'); + + if ($item) { + $item['attempts']++; + $item['last_error'] = $error; + $item['failed_at'] = date('c'); + + if ($item['attempts'] < $item['max_attempts']) { + // Move back to pending for retry + $item['retry_at'] = $this->calculateRetryTime($item['attempts']); + $item['scheduled_for'] = $item['retry_at']; + $this->writeQueueItem($item, 'pending'); + $this->deleteQueueItem($queueId, 'processing'); + } else { + // Move to failed (dead letter queue) + $this->writeQueueItem($item, 'failed'); + $this->deleteQueueItem($queueId, 'processing'); + } + } + } + + /** + * Get queue size + * + * @return int + */ + public function size(): int + { + return count($this->getPendingItems()); + } + + /** + * Check if queue is empty + * + * @return bool + */ + public function isEmpty(): bool + { + return $this->size() === 0; + } + + /** + * Get queue statistics + * + * @return array + */ + public function getStatistics(): array + { + return [ + 'pending' => count($this->getPendingItems()), + 'processing' => count($this->getItemsInDirectory('processing')), + 'failed' => count($this->getItemsInDirectory('failed')), + 'completed_today' => $this->countCompletedToday(), + ]; + } + + /** + * Generate a unique queue ID + * + * @param Job $job + * @return string + */ + protected function generateQueueId(Job $job): string + { + return $job->getId() . '_' . uniqid('', true); + } + + /** + * Write queue item to disk + * + * @param array $item + * @param string $directory + * @return void + */ + protected function writeQueueItem(array $item, string $directory): void + { + $path = $this->queuePath . '/' . $directory . '/' . $item['id'] . '.json'; + $file = JsonFile::instance($path); + $file->save($item); + } + + /** + * Read queue item from disk + * + * @param string $queueId + * @param string $directory + * @return array|null + */ + protected function getQueueItem(string $queueId, string $directory): ?array + { + $path = $this->queuePath . '/' . $directory . '/' . $queueId . '.json'; + + if (!file_exists($path)) { + return null; + } + + $file = JsonFile::instance($path); + return $file->content(); + } + + /** + * Delete queue item + * + * @param string $queueId + * @param string $directory + * @return void + */ + protected function deleteQueueItem(string $queueId, string $directory): void + { + $path = $this->queuePath . '/' . $directory . '/' . $queueId . '.json'; + + if (file_exists($path)) { + unlink($path); + } + } + + /** + * Move queue item between directories + * + * @param string $queueId + * @param string $fromDir + * @param string $toDir + * @return void + */ + protected function moveQueueItem(string $queueId, string $fromDir, string $toDir): void + { + $fromPath = $this->queuePath . '/' . $fromDir . '/' . $queueId . '.json'; + $toPath = $this->queuePath . '/' . $toDir . '/' . $queueId . '.json'; + + if (file_exists($fromPath)) { + rename($fromPath, $toPath); + } + } + + /** + * Get all pending items + * + * @return array + */ + protected function getPendingItems(): array + { + return $this->getItemsInDirectory('pending'); + } + + /** + * Get items in a specific directory + * + * @param string $directory + * @return array + */ + protected function getItemsInDirectory(string $directory): array + { + $items = []; + $path = $this->queuePath . '/' . $directory; + + if (!is_dir($path)) { + return $items; + } + + $files = glob($path . '/*.json'); + foreach ($files as $file) { + $jsonFile = JsonFile::instance($file); + $items[] = $jsonFile->content(); + } + + return $items; + } + + /** + * Reconstruct a job from queue item + * + * @param array $item + * @return Job|null + */ + protected function reconstructJob(array $item): ?Job + { + if (isset($item['serialized_job'])) { + // Unserialize the job + try { + $job = unserialize(base64_decode($item['serialized_job'])); + if ($job instanceof Job) { + return $job; + } + } catch (\Exception $e) { + // Failed to unserialize + return null; + } + } + + // Create a new job from command + if (isset($item['command'])) { + $args = $item['arguments'] ?? []; + $job = new Job($item['command'], $args, $item['job_id']); + return $job; + } + + return null; + } + + /** + * Calculate retry time with exponential backoff + * + * @param int $attempts + * @return string + */ + protected function calculateRetryTime(int $attempts): string + { + $backoffSeconds = min(pow(2, $attempts) * 60, 3600); // Max 1 hour + $retryTime = new \DateTime(); + $retryTime->modify("+{$backoffSeconds} seconds"); + return $retryTime->format('c'); + } + + /** + * Clean up old completed items + * + * @return void + */ + protected function cleanupCompleted(): void + { + $items = $this->getItemsInDirectory('completed'); + $cutoff = new \DateTime('-24 hours'); + + foreach ($items as $item) { + if (isset($item['created_at'])) { + $createdAt = new \DateTime($item['created_at']); + if ($createdAt < $cutoff) { + $this->deleteQueueItem($item['id'], 'completed'); + } + } + } + } + + /** + * Count completed jobs today + * + * @return int + */ + protected function countCompletedToday(): int + { + $items = $this->getItemsInDirectory('completed'); + $today = new \DateTime('today'); + $count = 0; + + foreach ($items as $item) { + if (isset($item['created_at'])) { + $createdAt = new \DateTime($item['created_at']); + if ($createdAt >= $today) { + $count++; + } + } + } + + return $count; + } + + /** + * Acquire lock for queue operations + * + * @return bool + */ + protected function lock(): bool + { + $attempts = 0; + $maxAttempts = 50; // 5 seconds total + + while ($attempts < $maxAttempts) { + // Check if lock file exists and is stale (older than 30 seconds) + if (file_exists($this->lockFile)) { + $lockAge = time() - filemtime($this->lockFile); + if ($lockAge > 30) { + // Stale lock, remove it + @unlink($this->lockFile); + } + } + + // Try to acquire lock atomically + $handle = @fopen($this->lockFile, 'x'); + if ($handle !== false) { + fclose($handle); + return true; + } + + $attempts++; + usleep(100000); // 100ms + } + + // Could not acquire lock + return false; + } + + /** + * Release queue lock + * + * @return void + */ + protected function unlock(): void + { + if (file_exists($this->lockFile)) { + unlink($this->lockFile); + } + } +} \ No newline at end of file diff --git a/system/src/Grav/Common/Scheduler/Scheduler.php b/system/src/Grav/Common/Scheduler/Scheduler.php new file mode 100644 index 0000000..8785718 --- /dev/null +++ b/system/src/Grav/Common/Scheduler/Scheduler.php @@ -0,0 +1,1108 @@ +get('scheduler.defaults', []); + $this->config = $config; + + $locator = $grav['locator']; + $this->status_path = $locator->findResource('user-data://scheduler', true, true); + if (!file_exists($this->status_path)) { + Folder::create($this->status_path); + } + + // Initialize modern features (always enabled now) + $this->modernConfig = $grav['config']->get('scheduler.modern', []); + // Always initialize modern features - they're now part of core + $this->initializeModernFeatures($locator); + } + + /** + * Load saved jobs from config/scheduler.yaml file + * + * @return $this + */ + public function loadSavedJobs() + { + // Only load saved jobs if they haven't been loaded yet + if (!empty($this->saved_jobs)) { + return $this; + } + + $this->saved_jobs = []; + $saved_jobs = (array) Grav::instance()['config']->get('scheduler.custom_jobs', []); + + foreach ($saved_jobs as $id => $j) { + $args = $j['args'] ?? []; + $id = Grav::instance()['inflector']->hyphenize($id); + + // Check if job already exists to prevent duplicates + $existingJob = null; + foreach ($this->jobs as $existingJobItem) { + if ($existingJobItem->getId() === $id) { + $existingJob = $existingJobItem; + break; + } + } + + if ($existingJob) { + // Job already exists, just update saved_jobs reference + $this->saved_jobs[] = $existingJob; + continue; + } + + $job = $this->addCommand($j['command'], $args, $id); + + if (isset($j['at'])) { + $job->at($j['at']); + } + + if (isset($j['output'])) { + $mode = isset($j['output_mode']) && $j['output_mode'] === 'append'; + $job->output($j['output'], $mode); + } + + if (isset($j['email'])) { + $job->email($j['email']); + } + + // store in saved_jobs + $this->saved_jobs[] = $job; + } + + return $this; + } + + /** + * Get the queued jobs as background/foreground + * + * @param bool $all + * @return array + */ + public function getQueuedJobs($all = false) + { + $background = []; + $foreground = []; + foreach ($this->jobs as $job) { + if ($all || $job->getEnabled()) { + if ($job->runInBackground()) { + $background[] = $job; + } else { + $foreground[] = $job; + } + } + } + return [$background, $foreground]; + } + + /** + * Get the job queue + * + * @return JobQueue|null + */ + public function getJobQueue(): ?JobQueue + { + return $this->jobQueue; + } + + /** + * Get all jobs if they are disabled or not as one array + * + * @return Job[] + */ + public function getAllJobs() + { + [$background, $foreground] = $this->loadSavedJobs()->getQueuedJobs(true); + + return array_merge($background, $foreground); + } + + /** + * Get a specific Job based on id + * + * @param string $jobid + * @return Job|null + */ + public function getJob($jobid) + { + $all = $this->getAllJobs(); + foreach ($all as $job) { + if ($jobid == $job->getId()) { + return $job; + } + } + return null; + } + + /** + * Queues a PHP function execution. + * + * @param callable $fn The function to execute + * @param array $args Optional arguments to pass to the php script + * @param string|null $id Optional custom identifier + * @return Job + */ + public function addFunction(callable $fn, $args = [], $id = null) + { + $job = new Job($fn, $args, $id); + $this->queueJob($job->configure($this->config)); + + return $job; + } + + /** + * Queue a raw shell command. + * + * @param string $command The command to execute + * @param array $args Optional arguments to pass to the command + * @param string|null $id Optional custom identifier + * @return Job + */ + public function addCommand($command, $args = [], $id = null) + { + $job = new Job($command, $args, $id); + $this->queueJob($job->configure($this->config)); + + return $job; + } + + /** + * Run the scheduler. + * + * @param DateTime|null $runTime Optional, run at specific moment + * @param bool $force force run even if not due + */ + public function run(DateTime $runTime = null, $force = false) + { + // Initialize system jobs if not already done + $grav = Grav::instance(); + if (count($this->jobs) === 0) { + // Trigger event to load system jobs (cache-purge, cache-clear, backups, etc.) + $grav->fireEvent('onSchedulerInitialized', new \RocketTheme\Toolbox\Event\Event(['scheduler' => $this])); + } + + $this->loadSavedJobs(); + + [$background, $foreground] = $this->getQueuedJobs(false); + $alljobs = array_merge($background, $foreground); + + if (null === $runTime) { + $runTime = new DateTime('now'); + } + + // Log scheduler run + if ($this->logger) { + $jobCount = count($alljobs); + $forceStr = $force ? ' (forced)' : ''; + $this->logger->debug("Scheduler run started - {$jobCount} jobs available{$forceStr}", [ + 'time' => $runTime->format('Y-m-d H:i:s') + ]); + } + + // Process jobs based on modern features + if ($this->jobQueue && ($this->modernConfig['queue']['enabled'] ?? false)) { + // Queue jobs for processing + $queuedCount = 0; + foreach ($alljobs as $job) { + if ($job->isDue($runTime) || $force) { + // Add to queue for concurrent processing + $this->jobQueue->push($job); + $queuedCount++; + } + } + + if ($this->logger && $queuedCount > 0) { + $this->logger->debug("Queued {$queuedCount} job(s) for processing"); + } + + // Process queue with workers + $this->processJobsWithWorkers(); + + // When using queue, states are saved by executeJob when jobs complete + // Don't save states here as jobs may still be processing + } else { + // Legacy processing (one at a time) + foreach ($alljobs as $job) { + if ($job->isDue($runTime) || $force) { + $job->run(); + $this->jobs_run[] = $job; + } + } + + // Finish handling any background jobs + foreach ($background as $job) { + $job->finalize(); + } + + // Store states for legacy mode + $this->saveJobStates(); + + // Save history if enabled + if (($this->modernConfig['history']['enabled'] ?? false) && $this->historyPath) { + $this->saveJobHistory(); + } + } + + // Log run summary + if ($this->logger) { + $successCount = 0; + $failureCount = 0; + $failedJobNames = []; + $executedJobs = array_merge($this->executed_jobs, $this->jobs_run); + + foreach ($executedJobs as $job) { + if ($job->isSuccessful()) { + $successCount++; + } else { + $failureCount++; + $failedJobNames[] = $job->getId(); + } + } + + if (count($executedJobs) > 0) { + if ($failureCount > 0) { + $failedList = implode(', ', $failedJobNames); + $this->logger->warning("Scheduler completed: {$successCount} succeeded, {$failureCount} failed (failed: {$failedList})"); + } else { + $this->logger->info("Scheduler completed: {$successCount} job(s) succeeded"); + } + } else { + $this->logger->debug('Scheduler completed: no jobs were due'); + } + } + + // Store run date + file_put_contents("logs/lastcron.run", (new DateTime("now"))->format("Y-m-d H:i:s"), LOCK_EX); + + // Update last run timestamp for health checks + $this->updateLastRun(); + } + + /** + * Reset all collected data of last run. + * + * Call before run() if you call run() multiple times. + * + * @return $this + */ + public function resetRun() + { + // Reset collected data of last run + $this->executed_jobs = []; + $this->failed_jobs = []; + $this->output_schedule = []; + + return $this; + } + + /** + * Get the scheduler verbose output. + * + * @param string $type Allowed: text, html, array + * @return string|array The return depends on the requested $type + */ + public function getVerboseOutput($type = 'text') + { + switch ($type) { + case 'text': + return implode("\n", $this->output_schedule); + case 'html': + return implode('
', $this->output_schedule); + case 'array': + return $this->output_schedule; + default: + throw new InvalidArgumentException('Invalid output type'); + } + } + + /** + * Remove all queued Jobs. + * + * @return $this + */ + public function clearJobs() + { + $this->jobs = []; + + return $this; + } + + /** + * Helper to get the full Cron command + * + * @return string + */ + public function getCronCommand() + { + $command = $this->getSchedulerCommand(); + + return "(crontab -l; echo \"* * * * * {$command} 1>> /dev/null 2>&1\") | crontab -"; + } + + /** + * @param string|null $php + * @return string + */ + public function getSchedulerCommand($php = null) + { + $phpBinaryFinder = new PhpExecutableFinder(); + $php = $php ?? $phpBinaryFinder->find(); + $command = 'cd ' . str_replace(' ', '\ ', GRAV_ROOT) . ';' . $php . ' bin/grav scheduler'; + + return $command; + } + + /** + * Helper to determine if cron-like job is setup + * 0 - Crontab Not found + * 1 - Crontab Found + * 2 - Error + * + * @return int + */ + public function isCrontabSetup() + { + // Check for external triggers + $last_run = @file_get_contents("logs/lastcron.run"); + if (time() - strtotime($last_run) < 120){ + return 1; + } + + // No external triggers found, so do legacy cron checks + $process = new Process(['crontab', '-l']); + $process->run(); + + if ($process->isSuccessful()) { + $output = $process->getOutput(); + $command = str_replace('/', '\/', $this->getSchedulerCommand('.*')); + $full_command = '/^(?!#).* .* .* .* .* ' . $command . '/m'; + + return preg_match($full_command, $output) ? 1 : 0; + } + + $error = $process->getErrorOutput(); + + return Utils::startsWith($error, 'crontab: no crontab') ? 0 : 2; + } + + /** + * Get the Job states file + * + * @return YamlFile + */ + public function getJobStates() + { + return YamlFile::instance($this->status_path . '/status.yaml'); + } + + /** + * Save job states to statys file + * + * @return void + */ + private function saveJobStates() + { + $now = time(); + $new_states = []; + + foreach ($this->jobs_run as $job) { + if ($job->isSuccessful()) { + $new_states[$job->getId()] = ['state' => 'success', 'last-run' => $now]; + $this->pushExecutedJob($job); + } else { + $new_states[$job->getId()] = ['state' => 'failure', 'last-run' => $now, 'error' => $job->getOutput()]; + $this->pushFailedJob($job); + } + } + + $saved_states = $this->getJobStates(); + $saved_states->save(array_merge($saved_states->content(), $new_states)); + } + + /** + * Try to determine who's running the process + * + * @return false|string + */ + public function whoami() + { + $process = new Process(['whoami']); + $process->run(); + + if ($process->isSuccessful()) { + return trim($process->getOutput()); + } + + return $process->getErrorOutput(); + } + + + /** + * Initialize modern features + * + * @param mixed $locator + * @return void + */ + protected function initializeModernFeatures($locator): void + { + // Set up paths + $this->queuePath = $this->modernConfig['queue']['path'] ?? 'user-data://scheduler/queue'; + $this->queuePath = $locator->findResource($this->queuePath, true, true); + + $this->historyPath = $this->modernConfig['history']['path'] ?? 'user-data://scheduler/history'; + $this->historyPath = $locator->findResource($this->historyPath, true, true); + + // Create directories if they don't exist + if (!file_exists($this->queuePath)) { + Folder::create($this->queuePath); + } + + if (!file_exists($this->historyPath)) { + Folder::create($this->historyPath); + } + + // Initialize job queue (always enabled) + $this->jobQueue = new JobQueue($this->queuePath); + + // Initialize scheduler logger + $this->initializeLogger($locator); + + // Configure workers (default to 4 for concurrent processing) + $this->maxWorkers = $this->modernConfig['workers'] ?? 4; + + // Configure webhook + $this->webhookEnabled = $this->modernConfig['webhook']['enabled'] ?? false; + $this->webhookToken = $this->modernConfig['webhook']['token'] ?? null; + + // Configure health check + $this->healthEnabled = $this->modernConfig['health']['enabled'] ?? true; + } + + /** + * Get the job queue + * + * @return JobQueue|null + */ + public function getQueue(): ?JobQueue + { + return $this->jobQueue; + } + + /** + * Initialize the scheduler logger + * + * @param $locator + * @return void + */ + protected function initializeLogger($locator): void + { + $this->logger = new Logger('scheduler'); + + // Single scheduler log file - all levels + $logFile = $locator->findResource('log://scheduler.log', true, true); + $this->logger->pushHandler(new StreamHandler($logFile, Logger::DEBUG)); + } + + /** + * Get the scheduler logger + * + * @return Logger|null + */ + public function getLogger(): ?Logger + { + return $this->logger; + } + + /** + * Check if webhook is enabled + * + * @return bool + */ + public function isWebhookEnabled(): bool + { + return $this->webhookEnabled; + } + + /** + * Get active trigger methods + * + * @return array + */ + public function getActiveTriggers(): array + { + $triggers = []; + + $cronStatus = $this->isCrontabSetup(); + if ($cronStatus === 1) { + $triggers[] = 'cron'; + } + + // Check if webhook is enabled + if ($this->isWebhookEnabled()) { + $triggers[] = 'webhook'; + } + + return $triggers; + } + + /** + * Queue a job for execution in the correct queue. + * + * @param Job $job + * @return void + */ + private function queueJob(Job $job) + { + $this->jobs[] = $job; + + // Store jobs + } + + /** + * Add an entry to the scheduler verbose output array. + * + * @param string $string + * @return void + */ + private function addSchedulerVerboseOutput($string) + { + $now = '[' . (new DateTime('now'))->format('c') . '] '; + $this->output_schedule[] = $now . $string; + // Print to stdoutput in light gray + // echo "\033[37m{$string}\033[0m\n"; + } + + /** + * Push a succesfully executed job. + * + * @param Job $job + * @return Job + */ + private function pushExecutedJob(Job $job) + { + $this->executed_jobs[] = $job; + $command = $job->getCommand(); + $args = $job->getArguments(); + // If callable, log the string Closure + if (is_callable($command)) { + $command = is_string($command) ? $command : 'Closure'; + } + $this->addSchedulerVerboseOutput("Success: {$command} {$args}"); + + return $job; + } + + /** + * Push a failed job. + * + * @param Job $job + * @return Job + */ + private function pushFailedJob(Job $job) + { + $this->failed_jobs[] = $job; + $command = $job->getCommand(); + // If callable, log the string Closure + if (is_callable($command)) { + $command = is_string($command) ? $command : 'Closure'; + } + $output = trim($job->getOutput()); + $this->addSchedulerVerboseOutput("Error: {$command}{$output}"); + + return $job; + } + + /** + * Process jobs using multiple workers + * + * @return void + */ + protected function processJobsWithWorkers(): void + { + if (!$this->jobQueue) { + return; + } + + // Process all queued jobs + while (!$this->jobQueue->isEmpty()) { + // Wait if we've reached max workers + while (count($this->workers) >= $this->maxWorkers) { + foreach ($this->workers as $workerId => $worker) { + $process = null; + if (is_array($worker) && isset($worker['process'])) { + $process = $worker['process']; + } elseif ($worker instanceof Process) { + $process = $worker; + } + + if ($process instanceof Process && !$process->isRunning()) { + // Finalize job if needed + if (is_array($worker) && isset($worker['job'])) { + $worker['job']->finalize(); + + // Save job state + $this->saveJobState($worker['job']); + + // Update queue status + if (isset($worker['queueId']) && $this->jobQueue) { + if ($worker['job']->isSuccessful()) { + $this->jobQueue->complete($worker['queueId']); + } else { + $this->jobQueue->fail($worker['queueId'], $worker['job']->getOutput() ?: 'Job failed'); + } + } + } + unset($this->workers[$workerId]); + } + } + if (count($this->workers) >= $this->maxWorkers) { + usleep(100000); // Wait 100ms + } + } + + // Get next job from queue + $queueItem = $this->jobQueue->popWithId(); + if ($queueItem) { + $this->executeJob($queueItem['job'], $queueItem['id']); + } + } + + // Wait for all remaining workers to complete + foreach ($this->workers as $workerId => $worker) { + if (is_array($worker) && isset($worker['process'])) { + $process = $worker['process']; + if ($process instanceof Process) { + $process->wait(); + + // Finalize and save state for background jobs + if (isset($worker['job'])) { + $worker['job']->finalize(); + $this->saveJobState($worker['job']); + + // Log background job completion + if ($this->logger) { + $job = $worker['job']; + $jobId = $job->getId(); + $command = is_string($job->getCommand()) ? $job->getCommand() : 'Closure'; + + if ($job->isSuccessful()) { + $execTime = method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null; + $timeStr = $execTime ? sprintf(' (%.2fs)', $execTime) : ''; + $this->logger->info("Job '{$jobId}' completed successfully{$timeStr}", [ + 'command' => $command, + 'background' => true + ]); + } else { + $error = trim($job->getOutput()) ?: 'Unknown error'; + $this->logger->error("Job '{$jobId}' failed: {$error}", [ + 'command' => $command, + 'background' => true + ]); + } + } + } + + // Update queue status for background jobs + if (isset($worker['queueId']) && $this->jobQueue) { + $job = $worker['job']; + if ($job->isSuccessful()) { + $this->jobQueue->complete($worker['queueId']); + } else { + $this->jobQueue->fail($worker['queueId'], $job->getOutput() ?: 'Job execution failed'); + } + } + + unset($this->workers[$workerId]); + } + } elseif ($worker instanceof Process) { + // Legacy format + $worker->wait(); + unset($this->workers[$workerId]); + } + } + } + + /** + * Process existing queued jobs + * + * @return void + */ + protected function processQueuedJobs(): void + { + if (!$this->jobQueue) { + return; + } + + // Process any existing queued jobs from previous runs + while (!$this->jobQueue->isEmpty() && count($this->workers) < $this->maxWorkers) { + $job = $this->jobQueue->pop(); + if ($job) { + $this->executeJob($job); + } + } + } + + /** + * Execute a job + * + * @param Job $job + * @param string|null $queueId Queue ID if job came from queue + * @return void + */ + protected function executeJob(Job $job, ?string $queueId = null): void + { + $job->run(); + $this->jobs_run[] = $job; + + // Save job state after execution + $this->saveJobState($job); + + // Check if job runs in background + if ($job->runInBackground()) { + // Background job - track it for later completion + $process = $job->getProcess(); + if ($process && $process->isStarted()) { + $this->workers[] = [ + 'process' => $process, + 'job' => $job, + 'queueId' => $queueId + ]; + // Don't update queue status yet - will be done when process completes + return; + } + } + + // Foreground job or background job that didn't start - update queue status immediately + if ($queueId && $this->jobQueue) { + // Job has already been finalized if it ran in foreground + if (!$job->runInBackground()) { + $job->finalize(); + } + + if ($job->isSuccessful()) { + // Move from processing to completed + $this->jobQueue->complete($queueId); + } else { + // Move from processing to failed + $this->jobQueue->fail($queueId, $job->getOutput() ?: 'Job execution failed'); + } + } + + // Log foreground jobs immediately + if (!$job->runInBackground() && $this->logger) { + $jobId = $job->getId(); + $command = is_string($job->getCommand()) ? $job->getCommand() : 'Closure'; + + if ($job->isSuccessful()) { + $execTime = method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null; + $timeStr = $execTime ? sprintf(' (%.2fs)', $execTime) : ''; + $this->logger->info("Job '{$jobId}' completed successfully{$timeStr}", [ + 'command' => $command + ]); + } else { + $error = trim($job->getOutput()) ?: 'Unknown error'; + $this->logger->error("Job '{$jobId}' failed: {$error}", [ + 'command' => $command + ]); + } + } + } + + /** + * Save state for a single job + * + * @param Job $job + * @return void + */ + protected function saveJobState(Job $job): void + { + $grav = Grav::instance(); + $locator = $grav['locator']; + $statusFile = $locator->findResource('user-data://scheduler/status.yaml', true, true); + + $status = []; + if (file_exists($statusFile)) { + $status = Yaml::parseFile($statusFile) ?: []; + } + + // Update job status + $status[$job->getId()] = [ + 'state' => $job->isSuccessful() ? 'success' : 'failure', + 'last-run' => time(), + ]; + + // Add error if job failed + if (!$job->isSuccessful()) { + $output = $job->getOutput(); + if ($output) { + $status[$job->getId()]['error'] = $output; + } else { + $status[$job->getId()]['error'] = null; + } + } + + file_put_contents($statusFile, Yaml::dump($status)); + } + + /** + * Save job execution history + * + * @return void + */ + protected function saveJobHistory(): void + { + if (!$this->historyPath) { + return; + } + + $history = []; + foreach ($this->jobs_run as $job) { + $history[] = [ + 'id' => $job->getId(), + 'executed_at' => date('c'), + 'success' => $job->isSuccessful(), + 'output' => substr($job->getOutput(), 0, 1000), + ]; + } + + if (!empty($history)) { + $filename = $this->historyPath . '/' . date('Y-m-d') . '.json'; + $existing = file_exists($filename) ? json_decode(file_get_contents($filename), true) : []; + $existing = array_merge($existing, $history); + file_put_contents($filename, json_encode($existing, JSON_PRETTY_PRINT)); + } + } + + /** + * Update last run timestamp + * + * @return void + */ + protected function updateLastRun(): void + { + $lastRunFile = $this->status_path . '/last_run.txt'; + file_put_contents($lastRunFile, date('Y-m-d H:i:s')); + } + + /** + * Get health status + * + * @return array + */ + public function getHealthStatus(): array + { + $lastRunFile = $this->status_path . '/last_run.txt'; + $lastRun = file_exists($lastRunFile) ? file_get_contents($lastRunFile) : null; + + // Initialize system jobs if not already done + $grav = Grav::instance(); + if (count($this->jobs) === 0) { + // Trigger event to load system jobs (cache-purge, cache-clear, backups, etc.) + $grav->fireEvent('onSchedulerInitialized', new \RocketTheme\Toolbox\Event\Event(['scheduler' => $this])); + } + + // Load custom jobs + $this->loadSavedJobs(); + + // Get only enabled jobs for health status + [$background, $foreground] = $this->getQueuedJobs(false); + $enabledJobs = array_merge($background, $foreground); + + $now = new DateTime('now'); + $dueJobs = 0; + + foreach ($enabledJobs as $job) { + if ($job->isDue($now)) { + $dueJobs++; + } + } + + $health = [ + 'status' => 'healthy', + 'last_run' => $lastRun, + 'last_run_age' => null, + 'queue_size' => 0, + 'failed_jobs_24h' => 0, + 'scheduled_jobs' => count($enabledJobs), + 'jobs_due' => $dueJobs, + 'webhook_enabled' => $this->webhookEnabled, + 'health_check_enabled' => $this->healthEnabled, + 'timestamp' => date('c'), + ]; + + // Calculate last run age + if ($lastRun) { + $lastRunTime = new DateTime($lastRun); + $health['last_run_age'] = $now->getTimestamp() - $lastRunTime->getTimestamp(); + } + + // Determine status based on whether jobs are due + if ($dueJobs > 0) { + // Jobs are due but haven't been run + if ($health['last_run_age'] === null || $health['last_run_age'] > 300) { // No run or older than 5 minutes + $health['status'] = 'warning'; + $health['message'] = $dueJobs . ' job(s) are due to run'; + } + } else { + // No jobs are due - this is healthy + $health['status'] = 'healthy'; + $health['message'] = 'No jobs currently due'; + } + + // Add queue stats if available + if ($this->jobQueue) { + $stats = $this->jobQueue->getStatistics(); + $health['queue_size'] = $stats['pending'] ?? 0; + $health['failed_jobs_24h'] = $stats['failed'] ?? 0; + } + + return $health; + } + + /** + * Process webhook trigger + * + * @param string|null $token + * @param string|null $jobId + * @return array + */ + public function processWebhookTrigger($token = null, $jobId = null): array + { + if (!$this->webhookEnabled) { + return ['success' => false, 'message' => 'Webhook triggers are not enabled']; + } + + if ($this->webhookToken && $token !== $this->webhookToken) { + return ['success' => false, 'message' => 'Invalid webhook token']; + } + + // Initialize system jobs if not already done + $grav = Grav::instance(); + if (count($this->jobs) === 0) { + // Trigger event to load system jobs (cache-purge, cache-clear, backups, etc.) + $grav->fireEvent('onSchedulerInitialized', new \RocketTheme\Toolbox\Event\Event(['scheduler' => $this])); + } + + // Load custom jobs + $this->loadSavedJobs(); + + if ($jobId) { + // Force run specific job + $job = $this->getJob($jobId); + if ($job) { + $job->inForeground()->run(); + $this->jobs_run[] = $job; + $this->saveJobStates(); + $this->updateLastRun(); + + return [ + 'success' => $job->isSuccessful(), + 'message' => $job->isSuccessful() ? 'Job force-executed successfully' : 'Job execution failed', + 'job_id' => $jobId, + 'forced' => true, + 'output' => $job->getOutput(), + ]; + } else { + return ['success' => false, 'message' => 'Job not found: ' . $jobId]; + } + } else { + // Run all due jobs + $this->run(); + + return [ + 'success' => true, + 'message' => 'Scheduler executed (due jobs only)', + 'jobs_run' => count($this->jobs_run), + 'timestamp' => date('c'), + ]; + } + } +} diff --git a/system/src/Grav/Common/Scheduler/SchedulerController.php b/system/src/Grav/Common/Scheduler/SchedulerController.php new file mode 100644 index 0000000..6c9808d --- /dev/null +++ b/system/src/Grav/Common/Scheduler/SchedulerController.php @@ -0,0 +1,270 @@ +grav = $grav; + + // Get scheduler instance + $scheduler = $grav['scheduler']; + if ($scheduler instanceof ModernScheduler) { + $this->scheduler = $scheduler; + } else { + // Create ModernScheduler instance if not already + $this->scheduler = new ModernScheduler(); + } + } + + /** + * Handle health check endpoint + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function health(ServerRequestInterface $request): ResponseInterface + { + $config = $this->grav['config']->get('scheduler.modern', []); + + // Check if health endpoint is enabled + if (!($config['health']['enabled'] ?? true)) { + return $this->jsonResponse(['error' => 'Health check disabled'], 403); + } + + // Get health status + $health = $this->scheduler->getHealthStatus(); + + return $this->jsonResponse($health); + } + + /** + * Handle webhook trigger endpoint + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function webhook(ServerRequestInterface $request): ResponseInterface + { + $config = $this->grav['config']->get('scheduler.modern', []); + + // Check if webhook is enabled + if (!($config['webhook']['enabled'] ?? false)) { + return $this->jsonResponse(['error' => 'Webhook triggers disabled'], 403); + } + + // Get authorization header + $authHeader = $request->getHeaderLine('Authorization'); + $token = null; + + if (preg_match('/Bearer\s+(.+)$/i', $authHeader, $matches)) { + $token = $matches[1]; + } + + // Get query parameters + $params = $request->getQueryParams(); + $jobId = $params['job'] ?? null; + + // Process webhook + $result = $this->scheduler->processWebhookTrigger($token, $jobId); + + $statusCode = $result['success'] ? 200 : 400; + return $this->jsonResponse($result, $statusCode); + } + + /** + * Handle statistics endpoint + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function statistics(ServerRequestInterface $request): ResponseInterface + { + // Check if user is admin + $user = $this->grav['user'] ?? null; + if (!$user || !$user->authorize('admin.super')) { + return $this->jsonResponse(['error' => 'Unauthorized'], 401); + } + + $stats = $this->scheduler->getStatistics(); + + return $this->jsonResponse($stats); + } + + /** + * Handle admin AJAX requests for scheduler status + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function adminStatus(ServerRequestInterface $request): ResponseInterface + { + // Check if user is admin + $user = $this->grav['user'] ?? null; + if (!$user || !$user->authorize('admin.scheduler')) { + return $this->jsonResponse(['error' => 'Unauthorized'], 401); + } + + $health = $this->scheduler->getHealthStatus(); + + // Format for admin display + $response = [ + 'health' => $this->formatHealthStatus($health), + 'triggers' => $this->formatTriggers($health['trigger_methods'] ?? []) + ]; + + return $this->jsonResponse($response); + } + + /** + * Format health status for display + * + * @param array $health + * @return string + */ + protected function formatHealthStatus(array $health): string + { + $status = $health['status'] ?? 'unknown'; + $lastRun = $health['last_run'] ?? null; + $queueSize = $health['queue_size'] ?? 0; + $failedJobs = $health['failed_jobs_24h'] ?? 0; + $jobsDue = $health['jobs_due'] ?? 0; + $message = $health['message'] ?? ''; + + $statusBadge = match($status) { + 'healthy' => 'Healthy', + 'warning' => 'Warning', + 'critical' => 'Critical', + default => 'Unknown' + }; + + $html = '
'; + $html .= '

Status: ' . $statusBadge; + if ($message) { + $html .= ' - ' . htmlspecialchars($message); + } + $html .= '

'; + + if ($lastRun) { + $lastRunTime = new \DateTime($lastRun); + $now = new \DateTime(); + $diff = $now->diff($lastRunTime); + + $timeAgo = ''; + if ($diff->d > 0) { + $timeAgo = $diff->d . ' day' . ($diff->d > 1 ? 's' : '') . ' ago'; + } elseif ($diff->h > 0) { + $timeAgo = $diff->h . ' hour' . ($diff->h > 1 ? 's' : '') . ' ago'; + } elseif ($diff->i > 0) { + $timeAgo = $diff->i . ' minute' . ($diff->i > 1 ? 's' : '') . ' ago'; + } else { + $timeAgo = 'Less than a minute ago'; + } + + $html .= '

Last Run: ' . $timeAgo . '

'; + } else { + $html .= '

Last Run: Never

'; + } + + $html .= '

Jobs Due: ' . $jobsDue . '

'; + $html .= '

Queue Size: ' . $queueSize . '

'; + + if ($failedJobs > 0) { + $html .= '

Failed Jobs (24h): ' . $failedJobs . '

'; + } + + $html .= '
'; + + return $html; + } + + /** + * Format triggers for display + * + * @param array $triggers + * @return string + */ + protected function formatTriggers(array $triggers): string + { + if (empty($triggers)) { + return '
No active triggers detected. Please set up cron, systemd, or webhook triggers.
'; + } + + $html = '
'; + $html .= '
    '; + + foreach ($triggers as $trigger) { + $icon = match($trigger) { + 'cron' => '⏰', + 'systemd' => '⚙️', + 'webhook' => '🔗', + 'external' => '🌐', + default => '•' + }; + + $label = match($trigger) { + 'cron' => 'Cron Job', + 'systemd' => 'Systemd Timer', + 'webhook' => 'Webhook Triggers', + 'external' => 'External Triggers', + default => ucfirst($trigger) + }; + + $html .= '
  • ' . $icon . ' ' . $label . ' Active
  • '; + } + + $html .= '
'; + $html .= '
'; + + return $html; + } + + /** + * Create JSON response + * + * @param array $data + * @param int $statusCode + * @return ResponseInterface + */ + protected function jsonResponse(array $data, int $statusCode = 200): ResponseInterface + { + $response = $this->grav['response'] ?? new \Nyholm\Psr7\Response(); + + $response = $response->withStatus($statusCode) + ->withHeader('Content-Type', 'application/json'); + + $body = $response->getBody(); + $body->write(json_encode($data)); + + return $response; + } +} \ No newline at end of file diff --git a/system/src/Grav/Common/Security.php b/system/src/Grav/Common/Security.php new file mode 100644 index 0000000..4dcf8fb --- /dev/null +++ b/system/src/Grav/Common/Security.php @@ -0,0 +1,287 @@ +get('security.sanitize_svg')) { + $content = file_get_contents($filepath); + + return static::detectXss($content, $options); + } + + return null; + } + + /** + * Sanitize SVG string for XSS code + * + * @param string $svg + * @return string + */ + public static function sanitizeSvgString(string $svg): string + { + if (Grav::instance()['config']->get('security.sanitize_svg')) { + $sanitizer = new DOMSanitizer(DOMSanitizer::SVG); + $sanitized = $sanitizer->sanitize($svg); + if (is_string($sanitized)) { + $svg = $sanitized; + } + } + + return $svg; + } + + /** + * Sanitize SVG for XSS code + * + * @param string $file + * @return void + */ + public static function sanitizeSVG(string $file): void + { + if (file_exists($file) && Grav::instance()['config']->get('security.sanitize_svg')) { + $sanitizer = new DOMSanitizer(DOMSanitizer::SVG); + $original_svg = file_get_contents($file); + $clean_svg = $sanitizer->sanitize($original_svg); + + // Quarantine bad SVG files and throw exception + if ($clean_svg !== false ) { + file_put_contents($file, $clean_svg); + } else { + $quarantine_file = Utils::basename($file); + $quarantine_dir = 'log://quarantine'; + Folder::mkdir($quarantine_dir); + file_put_contents("$quarantine_dir/$quarantine_file", $original_svg); + unlink($file); + throw new Exception('SVG could not be sanitized, it has been moved to the logs/quarantine folder'); + } + } + } + + /** + * Detect XSS code in Grav pages + * + * @param Pages $pages + * @param bool $route + * @param callable|null $status + * @return array + */ + public static function detectXssFromPages(Pages $pages, $route = true, callable $status = null) + { + $routes = $pages->getList(null, 0, true); + + // Remove duplicate for homepage + unset($routes['/']); + + $list = []; + + // This needs Symfony 4.1 to work + $status && $status([ + 'type' => 'count', + 'steps' => count($routes), + ]); + + foreach (array_keys($routes) as $route) { + $status && $status([ + 'type' => 'progress', + ]); + + try { + $page = $pages->find($route); + if ($page->exists()) { + // call the content to load/cache it + $header = (array) $page->header(); + $content = $page->value('content'); + + $data = ['header' => $header, 'content' => $content]; + $results = static::detectXssFromArray($data); + + if (!empty($results)) { + $list[$page->rawRoute()] = $results; + } + } + } catch (Exception $e) { + continue; + } + } + + return $list; + } + + /** + * Detect XSS in an array or strings such as $_POST or $_GET + * + * @param array $array Array such as $_POST or $_GET + * @param array|null $options Extra options to be passed. + * @param string $prefix Prefix for returned values. + * @return array Returns flatten list of potentially dangerous input values, such as 'data.content'. + */ + public static function detectXssFromArray(array $array, string $prefix = '', array $options = null) + { + if (null === $options) { + $options = static::getXssDefaults(); + } + + $list = [[]]; + foreach ($array as $key => $value) { + if (is_array($value)) { + $list[] = static::detectXssFromArray($value, $prefix . $key . '.', $options); + } + if ($result = static::detectXss($value, $options)) { + $list[] = [$prefix . $key => $result]; + } + } + + return array_merge(...$list); + } + + /** + * Determine if string potentially has a XSS attack. This simple function does not catch all XSS and it is likely to + * + * return false positives because of it tags all potentially dangerous HTML tags and attributes without looking into + * their content. + * + * @param string|null $string The string to run XSS detection logic on + * @param array|null $options + * @return string|null Type of XSS vector if the given `$string` may contain XSS, false otherwise. + * + * Copies the code from: https://github.com/symphonycms/xssfilter/blob/master/extension.driver.php#L138 + */ + public static function detectXss($string, array $options = null): ?string + { + // Skip any null or non string values + if (null === $string || !is_string($string) || empty($string)) { + return null; + } + + if (null === $options) { + $options = static::getXssDefaults(); + } + + $enabled_rules = (array)($options['enabled_rules'] ?? null); + $dangerous_tags = (array)($options['dangerous_tags'] ?? null); + if (!$dangerous_tags) { + $enabled_rules['dangerous_tags'] = false; + } + $invalid_protocols = (array)($options['invalid_protocols'] ?? null); + if (!$invalid_protocols) { + $enabled_rules['invalid_protocols'] = false; + } + $enabled_rules = array_filter($enabled_rules, static function ($val) { return !empty($val); }); + if (!$enabled_rules) { + return null; + } + + // Keep a copy of the original string before cleaning up + $orig = $string; + + // URL decode + $string = urldecode($string); + + // Convert Hexadecimals + $string = (string)preg_replace_callback('!(&#|\\\)[xX]([0-9a-fA-F]+);?!u', static function ($m) { + return chr(hexdec($m[2])); + }, $string); + + // Clean up entities + $string = preg_replace('!(&#[0-9]+);?!u', '$1;', $string); + + // Decode entities + $string = html_entity_decode($string, ENT_NOQUOTES | ENT_HTML5, 'UTF-8'); + + // Strip whitespace characters + $string = preg_replace('!\s!u', ' ', $string); + $stripped = preg_replace('!\s!u', '', $string); + + // Set the patterns we'll test against + $patterns = [ + // Match any attribute starting with "on" or xmlns + 'on_events' => '#(<[^>]+[a-z\x00-\x20\"\'\/])(on[a-z]+|xmlns)\s*=[\s|\'\"].*[\s|\'\"]>#iUu', + + // Match javascript:, livescript:, vbscript:, mocha:, feed: and data: protocols + 'invalid_protocols' => '#(' . implode('|', array_map('preg_quote', $invalid_protocols, ['#'])) . ')(:|\&\#58)\S.*?#iUu', + + // Match -moz-bindings + 'moz_binding' => '#-moz-binding[a-z\x00-\x20]*:#u', + + // Match style attributes + 'html_inline_styles' => '#(<[^>]+[a-z\x00-\x20\"\'\/])(style=[^>]*(url\:|x\:expression).*)>?#iUu', + + // Match potentially dangerous tags + 'dangerous_tags' => '#]*>?#ui' + ]; + + // Iterate over rules and return label if fail + foreach ($patterns as $name => $regex) { + if (!empty($enabled_rules[$name])) { + if (preg_match($regex, $string) || preg_match($regex, $stripped) || preg_match($regex, $orig)) { + return $name; + } + } + } + + return null; + } + + public static function getXssDefaults(): array + { + /** @var Config $config */ + $config = Grav::instance()['config']; + + return [ + 'enabled_rules' => $config->get('security.xss_enabled'), + 'dangerous_tags' => array_map('trim', $config->get('security.xss_dangerous_tags')), + 'invalid_protocols' => array_map('trim', $config->get('security.xss_invalid_protocols')), + ]; + } + + public static function cleanDangerousTwig(string $string): string + { + if ($string === '') { + return $string; + } + + $bad_twig = [ + 'twig_array_map', + 'twig_array_filter', + 'call_user_func', + 'registerUndefinedFunctionCallback', + 'undefined_functions', + 'twig.getFunction', + 'core.setEscaper', + 'twig.safe_functions', + 'read_file', + ]; + $string = preg_replace('/(({{\s*|{%\s*)[^}]*?(' . implode('|', $bad_twig) . ')[^}]*?(\s*}}|\s*%}))/i', '{# $1 #}', $string); + return $string; + } +} diff --git a/system/src/Grav/Common/Service/AccountsServiceProvider.php b/system/src/Grav/Common/Service/AccountsServiceProvider.php new file mode 100644 index 0000000..2318106 --- /dev/null +++ b/system/src/Grav/Common/Service/AccountsServiceProvider.php @@ -0,0 +1,157 @@ +addTypes($config->get('permissions.types', [])); + + $array = $config->get('permissions.actions'); + if (is_array($array)) { + $actions = PermissionsReader::fromArray($array, $permissions->getTypes()); + $permissions->addActions($actions); + } + + $event = new PermissionsRegisterEvent($permissions); + $container->dispatchEvent($event); + + return $permissions; + }; + + $container['accounts'] = function (Container $container) { + $type = $this->initialize($container); + + return $type === 'flex' ? $this->flexAccounts($container) : $this->regularAccounts($container); + }; + + $container['user_groups'] = static function (Container $container) { + /** @var Flex $flex */ + $flex = $container['flex']; + $directory = $flex->getDirectory('user-groups'); + + return $directory ? $directory->getIndex() : null; + }; + + $container['users'] = $container->factory(static function (Container $container) { + user_error('Grav::instance()[\'users\'] is deprecated since Grav 1.6, use Grav::instance()[\'accounts\'] instead', E_USER_DEPRECATED); + + return $container['accounts']; + }); + } + + /** + * @param Container $container + * @return string + */ + protected function initialize(Container $container): string + { + $isDefined = defined('GRAV_USER_INSTANCE'); + $type = strtolower($isDefined ? GRAV_USER_INSTANCE : $container['config']->get('system.accounts.type', 'regular')); + + if ($type === 'flex') { + if (!$isDefined) { + define('GRAV_USER_INSTANCE', 'FLEX'); + } + + /** @var EventDispatcher $dispatcher */ + $dispatcher = $container['events']; + + // Stop /admin/user from working, display error instead. + $dispatcher->addListener( + 'onAdminPage', + static function (Event $event) { + $grav = Grav::instance(); + $admin = $grav['admin']; + [$base,$location,] = $admin->getRouteDetails(); + if ($location !== 'user' || isset($grav['flex_objects'])) { + return; + } + + /** @var PageInterface $page */ + $page = $event['page']; + $page->init(new SplFileInfo('plugin://admin/pages/admin/error.md')); + $page->routable(true); + $header = $page->header(); + $header->title = 'Please install missing plugin'; + $page->content("## Please install and enable **[Flex Objects]({$base}/plugins/flex-objects)** plugin. It is required to edit **Flex User Accounts**."); + + /** @var Header $header */ + $header = $page->header(); + $directory = $grav['accounts']->getFlexDirectory(); + $menu = $directory->getConfig('admin.menu.list'); + $header->access = $menu['authorize'] ?? ['admin.super']; + }, + 100000 + ); + } elseif (!$isDefined) { + define('GRAV_USER_INSTANCE', 'REGULAR'); + } + + return $type; + } + + /** + * @param Container $container + * @return DataUser\UserCollection + */ + protected function regularAccounts(Container $container) + { + // Use User class for backwards compatibility. + return new DataUser\UserCollection(User::class); + } + + /** + * @param Container $container + * @return FlexIndexInterface|null + */ + protected function flexAccounts(Container $container) + { + /** @var Flex $flex */ + $flex = $container['flex']; + $directory = $flex->getDirectory('user-accounts'); + + return $directory ? $directory->getIndex() : null; + } +} diff --git a/system/src/Grav/Common/Service/AssetsServiceProvider.php b/system/src/Grav/Common/Service/AssetsServiceProvider.php new file mode 100644 index 0000000..bb71a09 --- /dev/null +++ b/system/src/Grav/Common/Service/AssetsServiceProvider.php @@ -0,0 +1,32 @@ +setup(); + + return $backups; + }; + } +} diff --git a/system/src/Grav/Common/Service/ConfigServiceProvider.php b/system/src/Grav/Common/Service/ConfigServiceProvider.php new file mode 100644 index 0000000..0c87554 --- /dev/null +++ b/system/src/Grav/Common/Service/ConfigServiceProvider.php @@ -0,0 +1,206 @@ +init(); + + return $setup; + }; + + $container['blueprints'] = function ($c) { + return static::blueprints($c); + }; + + $container['config'] = function ($c) { + $config = static::load($c); + + // After configuration has been loaded, we can disable YAML compatibility if strict mode has been enabled. + if (!$config->get('system.strict_mode.yaml_compat', true)) { + YamlFile::globalSettings(['compat' => false, 'native' => true]); + } + + return $config; + }; + + $container['mime'] = function ($c) { + /** @var Config $config */ + $config = $c['config']; + $mimes = $config->get('mime.types', []); + foreach ($config->get('media.types', []) as $ext => $media) { + if (!empty($media['mime'])) { + $mimes[$ext] = array_unique(array_merge([$media['mime']], $mimes[$ext] ?? [])); + } + } + + return MimeTypes::createFromMimes($mimes); + }; + + $container['languages'] = function ($c) { + return static::languages($c); + }; + + $container['language'] = function ($c) { + return new Language($c); + }; + } + + /** + * @param Container $container + * @return mixed + */ + public static function blueprints(Container $container) + { + /** Setup $setup */ + $setup = $container['setup']; + + /** @var UniformResourceLocator $locator */ + $locator = $container['locator']; + + $cache = $locator->findResource('cache://compiled/blueprints', true, true); + + $files = []; + $paths = $locator->findResources('blueprints://config'); + $files += (new ConfigFileFinder)->locateFiles($paths); + $paths = $locator->findResources('plugins://'); + $files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'blueprints'); + $paths = $locator->findResources('themes://'); + $files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths, 'blueprints'); + + $blueprints = new CompiledBlueprints($cache, $files, GRAV_ROOT); + + return $blueprints->name("master-{$setup->environment}")->load(); + } + + /** + * @param Container $container + * @return Config + */ + public static function load(Container $container) + { + /** Setup $setup */ + $setup = $container['setup']; + + /** @var UniformResourceLocator $locator */ + $locator = $container['locator']; + + $cache = $locator->findResource('cache://compiled/config', true, true); + + $files = []; + $paths = $locator->findResources('config://'); + $files += (new ConfigFileFinder)->locateFiles($paths); + $paths = $locator->findResources('plugins://'); + $files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths); + $paths = $locator->findResources('themes://'); + $files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths); + + $compiled = new CompiledConfig($cache, $files, GRAV_ROOT); + $compiled->setBlueprints(function () use ($container) { + return $container['blueprints']; + }); + + $config = $compiled->name("master-{$setup->environment}")->load(); + $config->environment = $setup->environment; + + return $config; + } + + /** + * @param Container $container + * @return mixed + */ + public static function languages(Container $container) + { + /** @var Setup $setup */ + $setup = $container['setup']; + + /** @var Config $config */ + $config = $container['config']; + + /** @var UniformResourceLocator $locator */ + $locator = $container['locator']; + + $cache = $locator->findResource('cache://compiled/languages', true, true); + $files = []; + + // Process languages only if enabled in configuration. + if ($config->get('system.languages.translations', true)) { + $paths = $locator->findResources('languages://'); + $files += (new ConfigFileFinder)->locateFiles($paths); + $paths = $locator->findResources('plugins://'); + $files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'languages'); + $paths = static::pluginFolderPaths($paths, 'languages'); + $files += (new ConfigFileFinder)->locateFiles($paths); + } + + $languages = new CompiledLanguages($cache, $files, GRAV_ROOT); + + return $languages->name("master-{$setup->environment}")->load(); + } + + /** + * Find specific paths in plugins + * + * @param array $plugins + * @param string $folder_path + * @return array + */ + protected static function pluginFolderPaths($plugins, $folder_path) + { + $paths = []; + + foreach ($plugins as $path) { + $iterator = new DirectoryIterator($path); + + /** @var DirectoryIterator $directory */ + foreach ($iterator as $directory) { + if (!$directory->isDir() || $directory->isDot()) { + continue; + } + + // Path to the languages folder + $lang_path = $directory->getPathName() . '/' . $folder_path; + + // If this folder exists, add it to the list of paths + if (file_exists($lang_path)) { + $paths []= $lang_path; + } + } + } + return $paths; + } +} diff --git a/system/src/Grav/Common/Service/ErrorServiceProvider.php b/system/src/Grav/Common/Service/ErrorServiceProvider.php new file mode 100644 index 0000000..87919a8 --- /dev/null +++ b/system/src/Grav/Common/Service/ErrorServiceProvider.php @@ -0,0 +1,30 @@ + $config->get('system.flex', [])]); + FlexFormFlash::setFlex($flex); + + $accountsEnabled = $config->get('system.accounts.type', 'regular') === 'flex'; + $pagesEnabled = $config->get('system.pages.type', 'regular') === 'flex'; + + // Add built-in types from Grav. + if ($pagesEnabled) { + $flex->addDirectoryType( + 'pages', + 'blueprints://flex/pages.yaml', + [ + 'enabled' => $pagesEnabled + ] + ); + } + if ($accountsEnabled) { + $flex->addDirectoryType( + 'user-accounts', + 'blueprints://flex/user-accounts.yaml', + [ + 'enabled' => $accountsEnabled, + 'data' => [ + 'storage' => $this->getFlexAccountsStorage($config), + ] + ] + ); + $flex->addDirectoryType( + 'user-groups', + 'blueprints://flex/user-groups.yaml', + [ + 'enabled' => $accountsEnabled + ] + ); + } + + // Call event to register Flex Directories. + $event = new FlexRegisterEvent($flex); + $container->dispatchEvent($event); + + return $flex; + }; + } + + /** + * @param Config $config + * @return array + */ + private function getFlexAccountsStorage(Config $config): array + { + $value = $config->get('system.accounts.storage', 'file'); + if (is_array($value)) { + return $value; + } + + if ($value === 'folder') { + return [ + 'class' => UserFolderStorage::class, + 'options' => [ + 'file' => 'user', + 'pattern' => '{FOLDER}/{KEY:2}/{KEY}/{FILE}{EXT}', + 'key' => 'storage_key', + 'indexed' => true, + 'case_sensitive' => false + ], + ]; + } + + if ($value === 'file') { + return [ + 'class' => UserFileStorage::class, + 'options' => [ + 'pattern' => '{FOLDER}/{KEY}{EXT}', + 'key' => 'username', + 'indexed' => true, + 'case_sensitive' => false + ], + ]; + } + + return []; + } +} diff --git a/system/src/Grav/Common/Service/InflectorServiceProvider.php b/system/src/Grav/Common/Service/InflectorServiceProvider.php new file mode 100644 index 0000000..1a27494 --- /dev/null +++ b/system/src/Grav/Common/Service/InflectorServiceProvider.php @@ -0,0 +1,32 @@ +findResource('log://grav.log', true, true); + $log->pushHandler(new StreamHandler($log_file, Logger::DEBUG)); + + return $log; + }; + } +} diff --git a/system/src/Grav/Common/Service/OutputServiceProvider.php b/system/src/Grav/Common/Service/OutputServiceProvider.php new file mode 100644 index 0000000..8157e3f --- /dev/null +++ b/system/src/Grav/Common/Service/OutputServiceProvider.php @@ -0,0 +1,39 @@ +processSite($page->templateFormat()); + }; + } +} diff --git a/system/src/Grav/Common/Service/PagesServiceProvider.php b/system/src/Grav/Common/Service/PagesServiceProvider.php new file mode 100644 index 0000000..bce0a16 --- /dev/null +++ b/system/src/Grav/Common/Service/PagesServiceProvider.php @@ -0,0 +1,140 @@ +findResource('system://pages/notfound.md'); + $page = new Page(); + $page->init(new SplFileInfo($path)); + $page->routable(false); + + return $page; + }; + + return; + } + + $container['page'] = static function (Grav $grav) { + /** @var Pages $pages */ + $pages = $grav['pages']; + + /** @var Config $config */ + $config = $grav['config']; + + /** @var Uri $uri */ + $uri = $grav['uri']; + + $path = $uri->path() ? urldecode($uri->path()) : '/'; // Don't trim to support trailing slash default routes + $page = $pages->dispatch($path); + + // Redirection tests + if ($page) { + // some debugger override logic + if ($page->debugger() === false) { + $grav['debugger']->enabled(false); + } + + if ($config->get('system.force_ssl')) { + $scheme = $uri->scheme(true); + if ($scheme !== 'https') { + $url = 'https://' . $uri->host() . $uri->uri(); + $grav->redirect($url); + } + } + + $route = $page->route(); + if ($route && \in_array($uri->method(), ['GET', 'HEAD'], true)) { + $pageExtension = $page->urlExtension(); + $url = $pages->route($route) . $pageExtension; + + if ($uri->params()) { + if ($url === '/') { //Avoid double slash + $url = $uri->params(); + } else { + $url .= $uri->params(); + } + } + if ($uri->query()) { + $url .= '?' . $uri->query(); + } + if ($uri->fragment()) { + $url .= '#' . $uri->fragment(); + } + + /** @var Language $language */ + $language = $grav['language']; + + $redirect_default_route = $page->header()->redirect_default_route ?? $config->get('system.pages.redirect_default_route', 0); + $redirectCode = (int) $redirect_default_route; + + // Language-specific redirection scenarios + if ($language->enabled() && ($language->isLanguageInUrl() xor $language->isIncludeDefaultLanguage())) { + $grav->redirect($url, $redirectCode); + } + + // Default route test and redirect + if ($redirectCode) { + $uriExtension = $uri->extension(); + $uriExtension = null !== $uriExtension ? '.' . $uriExtension : ''; + + if ($route !== $path || ($pageExtension !== $uriExtension + && \in_array($pageExtension, ['', '.htm', '.html'], true) + && \in_array($uriExtension, ['', '.htm', '.html'], true))) { + $grav->redirect($url, $redirectCode); + } + } + } + } + + // if page is not found, try some fallback stuff + if (!$page || !$page->routable()) { + // Try fallback URL stuff... + $page = $grav->fallbackUrl($path); + + if (!$page) { + $path = $grav['locator']->findResource('system://pages/notfound.md'); + $page = new Page(); + $page->init(new SplFileInfo($path)); + $page->routable(false); + } + } + + return $page; + }; + } +} diff --git a/system/src/Grav/Common/Service/RequestServiceProvider.php b/system/src/Grav/Common/Service/RequestServiceProvider.php new file mode 100644 index 0000000..715fa4c --- /dev/null +++ b/system/src/Grav/Common/Service/RequestServiceProvider.php @@ -0,0 +1,103 @@ + $headerValue) { + if ('content-type' !== strtolower($headerName)) { + continue; + } + + $contentType = strtolower(trim(explode(';', $headerValue, 2)[0])); + switch ($contentType) { + case 'application/x-www-form-urlencoded': + case 'multipart/form-data': + $post = $_POST; + break 2; + case 'application/json': + case 'application/vnd.api+json': + try { + $json = file_get_contents('php://input'); + $post = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($post)) { + $post = null; + } + } catch (JsonException $e) { + $post = null; + } + break 2; + } + } + } + + // Remove _url from ngnix routes. + $get = $_GET; + unset($get['_url']); + if (isset($server['QUERY_STRING'])) { + $query = $server['QUERY_STRING']; + if (strpos($query, '_url=') !== false) { + parse_str($query, $query); + unset($query['_url']); + $server['QUERY_STRING'] = http_build_query($query); + } + } + + return $creator->fromArrays($server, $headers, $_COOKIE, $get, $post, $_FILES, fopen('php://input', 'rb') ?: null); + }; + + $container['route'] = $container->factory(function () { + return clone Uri::getCurrentRoute(); + }); + } +} diff --git a/system/src/Grav/Common/Service/SchedulerServiceProvider.php b/system/src/Grav/Common/Service/SchedulerServiceProvider.php new file mode 100644 index 0000000..8b2b04b --- /dev/null +++ b/system/src/Grav/Common/Service/SchedulerServiceProvider.php @@ -0,0 +1,64 @@ +get('scheduler.modern', []); + if ($modernConfig['enabled'] ?? false) { + // Initialize components + $queuePath = $c['locator']->findResource('user-data://scheduler/queue', true, true); + $statusPath = $c['locator']->findResource('user-data://scheduler/status.yaml', true, true); + + // Set modern configuration on scheduler + $scheduler->setModernConfig($modernConfig); + + // Initialize job queue if enabled + if ($modernConfig['queue']['enabled'] ?? false) { + $jobQueue = new JobQueue($queuePath); + $scheduler->setJobQueue($jobQueue); + } + + // Initialize workers if enabled + if ($modernConfig['workers']['enabled'] ?? false) { + $workerCount = $modernConfig['workers']['count'] ?? 2; + $workers = []; + for ($i = 0; $i < $workerCount; $i++) { + $workers[] = new JobWorker("worker-{$i}"); + } + $scheduler->setWorkers($workers); + } + } + + return $scheduler; + }; + } +} diff --git a/system/src/Grav/Common/Service/SessionServiceProvider.php b/system/src/Grav/Common/Service/SessionServiceProvider.php new file mode 100644 index 0000000..c292207 --- /dev/null +++ b/system/src/Grav/Common/Service/SessionServiceProvider.php @@ -0,0 +1,134 @@ +get('system.session.enabled', false); + $cookie_secure = $config->get('system.session.secure', false) + || ($config->get('system.session.secure_https', true) && $uri->scheme(true) === 'https'); + $cookie_httponly = (bool)$config->get('system.session.httponly', true); + $cookie_lifetime = (int)$config->get('system.session.timeout', 1800); + $cookie_domain = $config->get('system.session.domain'); + $cookie_path = $config->get('system.session.path'); + $cookie_samesite = $config->get('system.session.samesite', 'Lax'); + + if (null === $cookie_domain) { + $cookie_domain = $uri->host(); + if ($cookie_domain === 'localhost') { + $cookie_domain = ''; + } + } + + if (null === $cookie_path) { + $cookie_path = '/' . trim(Uri::filterPath($uri->rootUrl(false)), '/'); + } + // Session cookie path requires trailing slash. + $cookie_path = rtrim($cookie_path, '/') . '/'; + + // Activate admin if we're inside the admin path. + $is_admin = false; + if ($config->get('plugins.admin.enabled')) { + $admin_base = '/' . trim($config->get('plugins.admin.route'), '/'); + + // Uri::route() is not processed yet, let's quickly get what we need. + $current_route = str_replace(Uri::filterPath($uri->rootUrl(false)), '', parse_url($uri->url(true), PHP_URL_PATH)); + + // Test to see if path starts with a supported language + admin base + $lang = Utils::pathPrefixedByLangCode($current_route); + $lang_admin_base = '/' . $lang . $admin_base; + + // Check no language, simple language prefix (en) and region specific language prefix (en-US). + if (Utils::startsWith($current_route, $admin_base) || Utils::startsWith($current_route, $lang_admin_base)) { + $cookie_lifetime = $config->get('plugins.admin.session.timeout', 1800); + $enabled = $is_admin = true; + } + } + + // Fix for HUGE session timeouts. + if ($cookie_lifetime > 99999999999) { + $cookie_lifetime = 9999999999; + } + + $session_prefix = $c['inflector']->hyphenize($config->get('system.session.name', 'grav-site')); + $session_uniqueness = $config->get('system.session.uniqueness', 'path') === 'path' ? substr(md5(GRAV_ROOT), 0, 7) : md5($config->get('security.salt')); + + $session_name = $session_prefix . '-' . $session_uniqueness; + + if ($is_admin && $config->get('system.session.split', true)) { + $session_name .= '-admin'; + } + + // Define session service. + $options = [ + 'name' => $session_name, + 'cookie_lifetime' => $cookie_lifetime, + 'cookie_path' => $cookie_path, + 'cookie_domain' => $cookie_domain, + 'cookie_secure' => $cookie_secure, + 'cookie_httponly' => $cookie_httponly, + 'cookie_samesite' => $cookie_samesite + ] + (array) $config->get('system.session.options'); + + $session = new Session($options); + $session->setAutoStart($enabled); + + return $session; + }; + + // Define session message service. + $container['messages'] = function ($c) { + if (!isset($c['session']) || !$c['session']->isStarted()) { + /** @var Debugger $debugger */ + $debugger = $c['debugger']; + $debugger->addMessage('Inactive session: session messages may disappear', 'warming'); + + return new Messages(); + } + + /** @var Session $session */ + $session = $c['session']; + + if (!$session->messages instanceof Messages) { + $session->messages = new Messages(); + } + + return $session->messages; + }; + } +} diff --git a/system/src/Grav/Common/Service/StreamsServiceProvider.php b/system/src/Grav/Common/Service/StreamsServiceProvider.php new file mode 100644 index 0000000..4c12ba1 --- /dev/null +++ b/system/src/Grav/Common/Service/StreamsServiceProvider.php @@ -0,0 +1,56 @@ +initializeLocator($locator); + + return $locator; + }; + + $container['streams'] = function (Container $container) { + /** @var Setup $setup */ + $setup = $container['setup']; + + /** @var UniformResourceLocator $locator */ + $locator = $container['locator']; + + // Set locator to both streams. + Stream::setLocator($locator); + ReadOnlyStream::setLocator($locator); + + return new StreamBuilder($setup->getStreams()); + }; + } +} diff --git a/system/src/Grav/Common/Service/TaskServiceProvider.php b/system/src/Grav/Common/Service/TaskServiceProvider.php new file mode 100644 index 0000000..d0f494b --- /dev/null +++ b/system/src/Grav/Common/Service/TaskServiceProvider.php @@ -0,0 +1,55 @@ +getParsedBody(); + + $task = $body['task'] ?? $c['uri']->param('task'); + if (null !== $task) { + $task = htmlspecialchars(strip_tags($task), ENT_QUOTES, 'UTF-8'); + } + + return $task ?: null; + }; + + $container['action'] = function (Grav $c) { + /** @var ServerRequestInterface $request */ + $request = $c['request']; + $body = $request->getParsedBody(); + + $action = $body['action'] ?? $c['uri']->param('action'); + if (null !== $action) { + $action = htmlspecialchars(strip_tags($action), ENT_QUOTES, 'UTF-8'); + } + + return $action ?: null; + }; + } +} diff --git a/system/src/Grav/Common/Session.php b/system/src/Grav/Common/Session.php new file mode 100644 index 0000000..7a299e8 --- /dev/null +++ b/system/src/Grav/Common/Session.php @@ -0,0 +1,202 @@ +getInstance() method instead. + */ + public static function instance() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->getInstance() method instead', E_USER_DEPRECATED); + + return static::getInstance(); + } + + /** + * Initialize session. + * + * Code in this function has been moved into SessionServiceProvider class. + * + * @return void + */ + public function init() + { + if ($this->autoStart && !$this->isStarted()) { + $this->start(); + + $this->autoStart = false; + } + } + + /** + * @param bool $auto + * @return $this + */ + public function setAutoStart($auto) + { + $this->autoStart = (bool)$auto; + + return $this; + } + + /** + * Returns attributes. + * + * @return array Attributes + * @deprecated 1.5 Use ->getAll() method instead. + */ + public function all() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->getAll() method instead', E_USER_DEPRECATED); + + return $this->getAll(); + } + + /** + * Checks if the session was started. + * + * @return bool + * @deprecated 1.5 Use ->isStarted() method instead. + */ + public function started() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->isStarted() method instead', E_USER_DEPRECATED); + + return $this->isStarted(); + } + + /** + * Store something in session temporarily. + * + * @param string $name + * @param mixed $object + * @return $this + */ + public function setFlashObject($name, $object) + { + $this->__set($name, serialize($object)); + + return $this; + } + + /** + * Return object and remove it from session. + * + * @param string $name + * @return mixed + */ + public function getFlashObject($name) + { + $serialized = $this->__get($name); + + $object = is_string($serialized) ? unserialize($serialized, ['allowed_classes' => true]) : $serialized; + + $this->__unset($name); + + if ($name === 'files-upload') { + $grav = Grav::instance(); + + // Make sure that Forms 3.0+ has been installed. + if (null === $object && isset($grav['forms'])) { +// user_error( +// __CLASS__ . '::' . __FUNCTION__ . '(\'files-upload\') is deprecated since Grav 1.6, use $form->getFlash()->getLegacyFiles() instead', +// E_USER_DEPRECATED +// ); + + /** @var Uri $uri */ + $uri = $grav['uri']; + /** @var Forms|null $form */ + $form = $grav['forms']->getActiveForm(); // @phpstan-ignore-line (form plugin) + + $sessionField = base64_encode($uri->url); + + /** @var FormFlash|null $flash */ + $flash = $form ? $form->getFlash() : null; // @phpstan-ignore-line (form plugin) + $object = $flash && method_exists($flash, 'getLegacyFiles') ? [$sessionField => $flash->getLegacyFiles()] : null; + } + } + + return $object; + } + + /** + * Store something in cookie temporarily. + * + * @param string $name + * @param mixed $object + * @param int $time + * @return $this + * @throws JsonException + */ + public function setFlashCookieObject($name, $object, $time = 60) + { + setcookie($name, json_encode($object, JSON_THROW_ON_ERROR), $this->getCookieOptions($time)); + + return $this; + } + + /** + * Return object and remove it from the cookie. + * + * @param string $name + * @return mixed|null + * @throws JsonException + */ + public function getFlashCookieObject($name) + { + if (isset($_COOKIE[$name])) { + $cookie = $_COOKIE[$name]; + setcookie($name, '', $this->getCookieOptions(-42000)); + + return json_decode($cookie, false, 512, JSON_THROW_ON_ERROR); + } + + return null; + } + + /** + * @return void + */ + protected function onBeforeSessionStart(): void + { + $event = new BeforeSessionStartEvent($this); + + $grav = Grav::instance(); + $grav->dispatchEvent($event); + } + + /** + * @return void + */ + protected function onSessionStart(): void + { + $event = new SessionStartEvent($this); + + $grav = Grav::instance(); + $grav->dispatchEvent($event); + } +} diff --git a/system/src/Grav/Common/Taxonomy.php b/system/src/Grav/Common/Taxonomy.php new file mode 100644 index 0000000..d2dfbb3 --- /dev/null +++ b/system/src/Grav/Common/Taxonomy.php @@ -0,0 +1,181 @@ +grav = $grav; + $this->language = $grav['language']; + $this->taxonomy_map[$this->language->getLanguage()] = []; + } + + /** + * Takes an individual page and processes the taxonomies configured in its header. It + * then adds those taxonomies to the map + * + * @param PageInterface $page the page to process + * @param array|null $page_taxonomy + */ + public function addTaxonomy(PageInterface $page, $page_taxonomy = null) + { + if (!$page->published()) { + return; + } + + if (!$page_taxonomy) { + $page_taxonomy = $page->taxonomy(); + } + + if (empty($page_taxonomy)) { + return; + } + + /** @var Config $config */ + $config = $this->grav['config']; + $taxonomies = (array)$config->get('site.taxonomies'); + foreach ($taxonomies as $taxonomy) { + // Skip invalid taxonomies. + if (!\is_string($taxonomy)) { + continue; + } + $current = $page_taxonomy[$taxonomy] ?? null; + foreach ((array)$current as $item) { + $this->iterateTaxonomy($page, $taxonomy, '', $item); + } + } + } + + /** + * Iterate through taxonomy fields + * + * Reduces [taxonomy_type] to dot-notation where necessary + * + * @param PageInterface $page The Page to process + * @param string $taxonomy Taxonomy type to add + * @param string $key Taxonomy type to concatenate + * @param iterable|string $value Taxonomy value to add or iterate + * @return void + */ + public function iterateTaxonomy(PageInterface $page, string $taxonomy, string $key, $value) + { + if (is_iterable($value)) { + foreach ($value as $identifier => $item) { + $identifier = "{$key}.{$identifier}"; + $this->iterateTaxonomy($page, $taxonomy, $identifier, $item); + } + } elseif (is_string($value)) { + if (!empty($key)) { + $taxonomy .= $key; + } + $active = $this->language->getLanguage(); + $this->taxonomy_map[$active][$taxonomy][(string) $value][$page->path()] = ['slug' => $page->slug()]; + } + } + + /** + * Returns a new Page object with the sub-pages containing all the values set for a + * particular taxonomy. + * + * @param array $taxonomies taxonomies to search, eg ['tag'=>['animal','cat']] + * @param string $operator can be 'or' or 'and' (defaults to 'and') + * @return Collection Collection object set to contain matches found in the taxonomy map + */ + public function findTaxonomy($taxonomies, $operator = 'and') + { + $matches = []; + $results = []; + $active = $this->language->getLanguage(); + + foreach ((array)$taxonomies as $taxonomy => $items) { + foreach ((array)$items as $item) { + $matches[] = $this->taxonomy_map[$active][$taxonomy][$item] ?? []; + } + } + + if (strtolower($operator) === 'or') { + foreach ($matches as $match) { + $results = array_merge($results, $match); + } + } else { + $results = $matches ? array_pop($matches) : []; + foreach ($matches as $match) { + $results = array_intersect_key($results, $match); + } + } + + return new Collection($results, ['taxonomies' => $taxonomies]); + } + + /** + * Gets and Sets the taxonomy map + * + * @param array|null $var the taxonomy map + * @return array the taxonomy map + */ + public function taxonomy($var = null) + { + $active = $this->language->getLanguage(); + + if ($var) { + $this->taxonomy_map[$active] = $var; + } + + return $this->taxonomy_map[$active] ?? []; + } + + /** + * Gets item keys per taxonomy + * + * @param string $taxonomy taxonomy name + * @return array keys of this taxonomy + */ + public function getTaxonomyItemKeys($taxonomy) + { + $active = $this->language->getLanguage(); + return isset($this->taxonomy_map[$active][$taxonomy]) ? array_keys($this->taxonomy_map[$active][$taxonomy]) : []; + } +} diff --git a/system/src/Grav/Common/Theme.php b/system/src/Grav/Common/Theme.php new file mode 100644 index 0000000..c1ad0b8 --- /dev/null +++ b/system/src/Grav/Common/Theme.php @@ -0,0 +1,87 @@ +config["themes.{$this->name}"] ?? []; + } + + /** + * Persists to disk the theme parameters currently stored in the Grav Config object + * + * @param string $name The name of the theme whose config it should store. + * @return bool + */ + public static function saveConfig($name) + { + if (!$name) { + return false; + } + + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + $filename = 'config://themes/' . $name . '.yaml'; + $file = YamlFile::instance((string)$locator->findResource($filename, true, true)); + $content = $grav['config']->get('themes.' . $name); + $file->save($content); + $file->free(); + unset($file); + + return true; + } + + /** + * Load blueprints. + * + * @return void + */ + protected function loadBlueprint() + { + if (!$this->blueprint) { + $grav = Grav::instance(); + /** @var Themes $themes */ + $themes = $grav['themes']; + $data = $themes->get($this->name); + \assert($data !== null); + $this->blueprint = $data->blueprints(); + } + } +} diff --git a/system/src/Grav/Common/Themes.php b/system/src/Grav/Common/Themes.php new file mode 100644 index 0000000..6db663e --- /dev/null +++ b/system/src/Grav/Common/Themes.php @@ -0,0 +1,417 @@ +grav = $grav; + $this->config = $grav['config']; + + // Register instance as autoloader for theme inheritance + spl_autoload_register([$this, 'autoloadTheme']); + } + + /** + * @return void + */ + public function init() + { + /** @var Themes $themes */ + $themes = $this->grav['themes']; + $themes->configure(); + + $this->initTheme(); + } + + /** + * @return void + */ + public function initTheme() + { + if ($this->inited === false) { + /** @var Themes $themes */ + $themes = $this->grav['themes']; + + try { + $instance = $themes->load(); + } catch (InvalidArgumentException $e) { + throw new RuntimeException($this->current() . ' theme could not be found'); + } + + // Register autoloader. + if (method_exists($instance, 'autoload')) { + $instance->autoload(); + } + + // Register event listeners. + if ($instance instanceof EventSubscriberInterface) { + /** @var EventDispatcher $events */ + $events = $this->grav['events']; + $events->addSubscriber($instance); + } + + // Register blueprints. + if (is_dir('theme://blueprints/pages')) { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + $locator->addPath('blueprints', '', ['theme://blueprints'], ['user', 'blueprints']); + } + + // Register form fields. + if (method_exists($instance, 'getFormFieldTypes')) { + /** @var Plugins $plugins */ + $plugins = $this->grav['plugins']; + $plugins->formFieldTypes = $instance->getFormFieldTypes() + $plugins->formFieldTypes; + } + + $this->grav['theme'] = $instance; + + $this->grav->fireEvent('onThemeInitialized'); + + $this->inited = true; + } + } + + /** + * Return list of all theme data with their blueprints. + * + * @return array + */ + public function all() + { + $list = []; + + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + $iterator = $locator->getIterator('themes://'); + + /** @var DirectoryIterator $directory */ + foreach ($iterator as $directory) { + if (!$directory->isDir() || $directory->isDot()) { + continue; + } + + $theme = $directory->getFilename(); + + try { + $result = $this->get($theme); + } catch (Exception $e) { + $exception = new RuntimeException(sprintf('Theme %s: %s', $theme, $e->getMessage()), $e->getCode(), $e); + + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addMessage("Theme {$theme} cannot be loaded, please check Exceptions tab", 'error'); + $debugger->addException($exception); + + continue; + } + + if ($result) { + $list[$theme] = $result; + } + } + ksort($list, SORT_NATURAL | SORT_FLAG_CASE); + + return $list; + } + + /** + * Get theme configuration or throw exception if it cannot be found. + * + * @param string $name + * @return Data|null + * @throws RuntimeException + */ + public function get($name) + { + if (!$name) { + throw new RuntimeException('Theme name not provided.'); + } + + $blueprints = new Blueprints('themes://'); + $blueprint = $blueprints->get("{$name}/blueprints"); + + // Load default configuration. + $file = CompiledYamlFile::instance("themes://{$name}/{$name}" . YAML_EXT); + + // ensure this is a valid theme + if (!$file->exists()) { + return null; + } + + // Find thumbnail. + $thumb = "themes://{$name}/thumbnail.jpg"; + $path = $this->grav['locator']->findResource($thumb, false); + + if ($path) { + $blueprint->set('thumbnail', $this->grav['base_url'] . '/' . $path); + } + + $obj = new Data((array)$file->content(), $blueprint); + + // Override with user configuration. + $obj->merge($this->config->get('themes.' . $name) ?: []); + + // Save configuration always to user/config. + $file = CompiledYamlFile::instance("config://themes/{$name}" . YAML_EXT); + $obj->file($file); + + return $obj; + } + + /** + * Return name of the current theme. + * + * @return string + */ + public function current() + { + return (string)$this->config->get('system.pages.theme'); + } + + /** + * Load current theme. + * + * @return Theme + */ + public function load() + { + // NOTE: ALL THE LOCAL VARIABLES ARE USED INSIDE INCLUDED FILE, DO NOT REMOVE THEM! + $grav = $this->grav; + $config = $this->config; + $name = $this->current(); + $class = null; + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + // Start by attempting to load the theme.php file. + $file = $locator('theme://theme.php') ?: $locator("theme://{$name}.php"); + if ($file) { + // Local variables available in the file: $grav, $config, $name, $file + $class = include $file; + if (!\is_object($class) || !is_subclass_of($class, Theme::class, true)) { + $class = null; + } + } elseif (!$locator('theme://') && !defined('GRAV_CLI')) { + $response = new Response(500, [], "Theme '$name' does not exist, unable to display page."); + + $grav->close($response); + } + + // If the class hasn't been initialized yet, guess the class name and create a new instance. + if (null === $class) { + $themeClassFormat = [ + 'Grav\\Theme\\' . Inflector::camelize($name), + 'Grav\\Theme\\' . ucfirst($name) + ]; + + foreach ($themeClassFormat as $themeClass) { + if (is_subclass_of($themeClass, Theme::class, true)) { + $class = new $themeClass($grav, $config, $name); + break; + } + } + } + + // Finally if everything else fails, just create a new instance from the default Theme class. + if (null === $class) { + $class = new Theme($grav, $config, $name); + } + + $this->config->set('theme', $config->get('themes.' . $name)); + + return $class; + } + + /** + * Configure and prepare streams for current template. + * + * @return void + * @throws InvalidArgumentException + */ + public function configure() + { + $name = $this->current(); + $config = $this->config; + + $this->loadConfiguration($name, $config); + + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + $registered = stream_get_wrappers(); + + $schemes = $config->get("themes.{$name}.streams.schemes", []); + $schemes += [ + 'theme' => [ + 'type' => 'ReadOnlyStream', + 'paths' => $locator->findResources("themes://{$name}", false) + ] + ]; + + foreach ($schemes as $scheme => $config) { + if (isset($config['paths'])) { + $locator->addPath($scheme, '', $config['paths']); + } + if (isset($config['prefixes'])) { + foreach ($config['prefixes'] as $prefix => $paths) { + $locator->addPath($scheme, $prefix, $paths); + } + } + + if (in_array($scheme, $registered, true)) { + stream_wrapper_unregister($scheme); + } + $type = !empty($config['type']) ? $config['type'] : 'ReadOnlyStream'; + if ($type[0] !== '\\') { + $type = '\\RocketTheme\\Toolbox\\StreamWrapper\\' . $type; + } + + if (!stream_wrapper_register($scheme, $type)) { + throw new InvalidArgumentException("Stream '{$type}' could not be initialized."); + } + } + + // Load languages after streams has been properly initialized + $this->loadLanguages($this->config); + } + + /** + * Load theme configuration. + * + * @param string $name Theme name + * @param Config $config Configuration class + * @return void + */ + protected function loadConfiguration($name, Config $config) + { + $themeConfig = CompiledYamlFile::instance("themes://{$name}/{$name}" . YAML_EXT)->content(); + $config->joinDefaults("themes.{$name}", $themeConfig); + } + + /** + * Load theme languages. + * Reads ALL language files from theme stream and merges them. + * + * @param Config $config Configuration class + * @return void + */ + protected function loadLanguages(Config $config) + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + if ($config->get('system.languages.translations', true)) { + $language_files = array_reverse($locator->findResources('theme://languages' . YAML_EXT)); + foreach ($language_files as $language_file) { + $language = CompiledYamlFile::instance($language_file)->content(); + $this->grav['languages']->mergeRecursive($language); + } + $languages_folders = array_reverse($locator->findResources('theme://languages')); + foreach ($languages_folders as $languages_folder) { + $languages = []; + $iterator = new DirectoryIterator($languages_folder); + foreach ($iterator as $file) { + if ($file->getExtension() !== 'yaml') { + continue; + } + $languages[$file->getBasename('.yaml')] = CompiledYamlFile::instance($file->getPathname())->content(); + } + $this->grav['languages']->mergeRecursive($languages); + } + } + } + + /** + * Autoload theme classes for inheritance + * + * @param string $class Class name + * @return mixed|false FALSE if unable to load $class; Class name if + * $class is successfully loaded + */ + protected function autoloadTheme($class) + { + $prefix = 'Grav\\Theme\\'; + if (false !== strpos($class, $prefix)) { + // Remove prefix from class + $class = substr($class, strlen($prefix)); + $locator = $this->grav['locator']; + + // First try lowercase version of the classname. + $path = strtolower($class); + $file = $locator("themes://{$path}/theme.php") ?: $locator("themes://{$path}/{$path}.php"); + + if ($file) { + return include_once $file; + } + + // Replace namespace tokens to directory separators + $path = $this->grav['inflector']->hyphenize($class); + $file = $locator("themes://{$path}/theme.php") ?: $locator("themes://{$path}/{$path}.php"); + + // Load class + if ($file) { + return include_once $file; + } + + // Try Old style theme classes + $path = preg_replace('#\\\|_(?!.+\\\)#', '/', $class); + \assert(null !== $path); + + $path = strtolower($path); + $file = $locator("themes://{$path}/theme.php") ?: $locator("themes://{$path}/{$path}.php"); + + // Load class + if ($file) { + return include_once $file; + } + } + + return false; + } +} diff --git a/system/src/Grav/Common/Twig/Exception/TwigException.php b/system/src/Grav/Common/Twig/Exception/TwigException.php new file mode 100644 index 0000000..ae500da --- /dev/null +++ b/system/src/Grav/Common/Twig/Exception/TwigException.php @@ -0,0 +1,21 @@ +locator = Grav::instance()['locator']; + } + + /** + * @return TwigFilter[] + */ + public function getFilters() + { + return [ + new TwigFilter('file_exists', [$this, 'file_exists']), + new TwigFilter('fileatime', [$this, 'fileatime']), + new TwigFilter('filectime', [$this, 'filectime']), + new TwigFilter('filemtime', [$this, 'filemtime']), + new TwigFilter('filesize', [$this, 'filesize']), + new TwigFilter('filetype', [$this, 'filetype']), + new TwigFilter('is_dir', [$this, 'is_dir']), + new TwigFilter('is_file', [$this, 'is_file']), + new TwigFilter('is_link', [$this, 'is_link']), + new TwigFilter('is_readable', [$this, 'is_readable']), + new TwigFilter('is_writable', [$this, 'is_writable']), + new TwigFilter('is_writeable', [$this, 'is_writable']), + new TwigFilter('lstat', [$this, 'lstat']), + new TwigFilter('getimagesize', [$this, 'getimagesize']), + new TwigFilter('exif_read_data', [$this, 'exif_read_data']), + new TwigFilter('read_exif_data', [$this, 'exif_read_data']), + new TwigFilter('exif_imagetype', [$this, 'exif_imagetype']), + new TwigFilter('hash_file', [$this, 'hash_file']), + new TwigFilter('hash_hmac_file', [$this, 'hash_hmac_file']), + new TwigFilter('md5_file', [$this, 'md5_file']), + new TwigFilter('sha1_file', [$this, 'sha1_file']), + new TwigFilter('get_meta_tags', [$this, 'get_meta_tags']), + new TwigFilter('pathinfo', [$this, 'pathinfo']), + ]; + } + + /** + * Return a list of all functions. + * + * @return TwigFunction[] + */ + public function getFunctions() + { + return [ + new TwigFunction('file_exists', [$this, 'file_exists']), + new TwigFunction('fileatime', [$this, 'fileatime']), + new TwigFunction('filectime', [$this, 'filectime']), + new TwigFunction('filemtime', [$this, 'filemtime']), + new TwigFunction('filesize', [$this, 'filesize']), + new TwigFunction('filetype', [$this, 'filetype']), + new TwigFunction('is_dir', [$this, 'is_dir']), + new TwigFunction('is_file', [$this, 'is_file']), + new TwigFunction('is_link', [$this, 'is_link']), + new TwigFunction('is_readable', [$this, 'is_readable']), + new TwigFunction('is_writable', [$this, 'is_writable']), + new TwigFunction('is_writeable', [$this, 'is_writable']), + new TwigFunction('lstat', [$this, 'lstat']), + new TwigFunction('getimagesize', [$this, 'getimagesize']), + new TwigFunction('exif_read_data', [$this, 'exif_read_data']), + new TwigFunction('read_exif_data', [$this, 'exif_read_data']), + new TwigFunction('exif_imagetype', [$this, 'exif_imagetype']), + new TwigFunction('hash_file', [$this, 'hash_file']), + new TwigFunction('hash_hmac_file', [$this, 'hash_hmac_file']), + new TwigFunction('md5_file', [$this, 'md5_file']), + new TwigFunction('sha1_file', [$this, 'sha1_file']), + new TwigFunction('get_meta_tags', [$this, 'get_meta_tags']), + new TwigFunction('pathinfo', [$this, 'pathinfo']), + ]; + } + + /** + * @param string $filename + * @return bool + */ + public function file_exists($filename): bool + { + if (!$this->checkFilename($filename)) { + return false; + } + + return file_exists($filename); + } + + /** + * @param string $filename + * @return int|false + */ + public function fileatime($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return fileatime($filename); + } + + /** + * @param string $filename + * @return int|false + */ + public function filectime($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return filectime($filename); + } + + /** + * @param string $filename + * @return int|false + */ + public function filemtime($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return filemtime($filename); + } + + /** + * @param string $filename + * @return int|false + */ + public function filesize($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return filesize($filename); + } + + /** + * @param string $filename + * @return string|false + */ + public function filetype($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return filetype($filename); + } + + /** + * @param string $filename + * @return bool + */ + public function is_dir($filename): bool + { + if (!$this->checkFilename($filename)) { + return false; + } + + return is_dir($filename); + } + + /** + * @param string $filename + * @return bool + */ + public function is_file($filename): bool + { + if (!$this->checkFilename($filename)) { + return false; + } + + return is_file($filename); + } + + /** + * @param string $filename + * @return bool + */ + public function is_link($filename): bool + { + if (!$this->checkFilename($filename)) { + return false; + } + + return is_link($filename); + } + + /** + * @param string $filename + * @return bool + */ + public function is_readable($filename): bool + { + if (!$this->checkFilename($filename)) { + return false; + } + + return is_readable($filename); + } + + /** + * @param string $filename + * @return bool + */ + public function is_writable($filename): bool + { + if (!$this->checkFilename($filename)) { + return false; + } + + return is_writable($filename); + } + + /** + * @param string $filename + * @return array|false + */ + public function lstat($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return lstat($filename); + } + + /** + * @param string $filename + * @return array|false + */ + public function getimagesize($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return getimagesize($filename); + } + + /** + * @param string $filename + * @param string|null $required_sections + * @param bool $as_arrays + * @param bool $read_thumbnail + * @return array|false + */ + public function exif_read_data($filename, ?string $required_sections = null, bool $as_arrays = false, bool $read_thumbnail = false) + { + if (!Utils::functionExists('exif_read_data') || !$this->checkFilename($filename)) { + return false; + } + + return @exif_read_data($filename, $required_sections, $as_arrays, $read_thumbnail); + } + + /** + * @param string $filename + * @return int|false + */ + public function exif_imagetype($filename) + { + if (!Utils::functionExists('exif_imagetype') || !$this->checkFilename($filename)) { + return false; + } + + return @exif_imagetype($filename); + } + + /** + * @param string $algo + * @param string $filename + * @param bool $binary + * @return string|false + */ + public function hash_file(string $algo, string $filename, bool $binary = false) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return hash_file($algo, $filename, $binary); + } + + /** + * @param string $algo + * @param string $filename + * @param string $key + * @param bool $binary + * @return string|false + */ + public function hash_hmac_file(string $algo, string $filename, string $key, bool $binary = false) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return hash_hmac_file($algo, $filename, $key, $binary); + } + + /** + * @param string $filename + * @param bool $binary + * @return string|false + */ + public function md5_file($filename, bool $binary = false) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return md5_file($filename, $binary); + } + + /** + * @param string $filename + * @param bool $binary + * @return string|false + */ + public function sha1_file($filename, bool $binary = false) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return sha1_file($filename, $binary); + } + + /** + * @param string $filename + * @return array|false + */ + public function get_meta_tags($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return get_meta_tags($filename); + } + + /** + * @param string $path + * @param int|null $flags + * @return string|string[] + */ + public function pathinfo($path, $flags = null) + { + return Utils::pathinfo($path, $flags); + } + + /** + * @param string $filename + * @return bool + */ + private function checkFilename($filename): bool + { + return is_string($filename) && (!str_contains($filename, '://') || $this->locator->isStream($filename)); + } +} diff --git a/system/src/Grav/Common/Twig/Extension/GravExtension.php b/system/src/Grav/Common/Twig/Extension/GravExtension.php new file mode 100644 index 0000000..9a8eeb2 --- /dev/null +++ b/system/src/Grav/Common/Twig/Extension/GravExtension.php @@ -0,0 +1,1756 @@ +grav = Grav::instance(); + $this->debugger = $this->grav['debugger'] ?? null; + $this->config = $this->grav['config']; + } + + /** + * Register some standard globals + * + * @return array + */ + public function getGlobals(): array + { + return [ + 'grav' => $this->grav, + ]; + } + + /** + * Return a list of all filters. + * + * @return array + */ + public function getFilters(): array + { + return [ + new TwigFilter('*ize', [$this, 'inflectorFilter']), + new TwigFilter('absolute_url', [$this, 'absoluteUrlFilter']), + new TwigFilter('contains', [$this, 'containsFilter']), + new TwigFilter('chunk_split', [$this, 'chunkSplitFilter']), + new TwigFilter('nicenumber', [$this, 'niceNumberFunc']), + new TwigFilter('nicefilesize', [$this, 'niceFilesizeFunc']), + new TwigFilter('nicetime', [$this, 'nicetimeFunc']), + new TwigFilter('defined', [$this, 'definedDefaultFilter']), + new TwigFilter('ends_with', [$this, 'endsWithFilter']), + new TwigFilter('fieldName', [$this, 'fieldNameFilter']), + new TwigFilter('parent_field', [$this, 'fieldParentFilter']), + new TwigFilter('ksort', [$this, 'ksortFilter']), + new TwigFilter('ltrim', [$this, 'ltrimFilter']), + new TwigFilter('markdown', [$this, 'markdownFunction'], ['needs_context' => true, 'is_safe' => ['html']]), + new TwigFilter('md5', [$this, 'md5Filter']), + new TwigFilter('base32_encode', [$this, 'base32EncodeFilter']), + new TwigFilter('base32_decode', [$this, 'base32DecodeFilter']), + new TwigFilter('base64_encode', [$this, 'base64EncodeFilter']), + new TwigFilter('base64_decode', [$this, 'base64DecodeFilter']), + new TwigFilter('randomize', [$this, 'randomizeFilter']), + new TwigFilter('modulus', [$this, 'modulusFilter']), + new TwigFilter('rtrim', [$this, 'rtrimFilter']), + new TwigFilter('pad', [$this, 'padFilter']), + new TwigFilter('regex_replace', [$this, 'regexReplace']), + new TwigFilter('safe_email', [$this, 'safeEmailFilter'], ['is_safe' => ['html']]), + new TwigFilter('safe_truncate', [Utils::class, 'safeTruncate']), + new TwigFilter('safe_truncate_html', [Utils::class, 'safeTruncateHTML']), + new TwigFilter('sort_by_key', [$this, 'sortByKeyFilter']), + new TwigFilter('starts_with', [$this, 'startsWithFilter']), + new TwigFilter('truncate', [Utils::class, 'truncate']), + new TwigFilter('truncate_html', [Utils::class, 'truncateHTML']), + new TwigFilter('json_decode', [$this, 'jsonDecodeFilter']), + new TwigFilter('array_unique', 'array_unique'), + new TwigFilter('basename', 'basename'), + new TwigFilter('dirname', 'dirname'), + new TwigFilter('print_r', [$this, 'print_r']), + new TwigFilter('yaml_encode', [$this, 'yamlEncodeFilter']), + new TwigFilter('yaml_decode', [$this, 'yamlDecodeFilter']), + new TwigFilter('nicecron', [$this, 'niceCronFilter']), + new TwigFilter('replace_last', [$this, 'replaceLastFilter']), + + // Translations + new TwigFilter('t', [$this, 'translate'], ['needs_environment' => true]), + new TwigFilter('tl', [$this, 'translateLanguage']), + new TwigFilter('ta', [$this, 'translateArray']), + + // Casting values + new TwigFilter('string', [$this, 'stringFilter']), + new TwigFilter('int', [$this, 'intFilter'], ['is_safe' => ['all']]), + new TwigFilter('bool', [$this, 'boolFilter']), + new TwigFilter('float', [$this, 'floatFilter'], ['is_safe' => ['all']]), + new TwigFilter('array', [$this, 'arrayFilter']), + new TwigFilter('yaml', [$this, 'yamlFilter']), + + // Object Types + new TwigFilter('get_type', [$this, 'getTypeFunc']), + new TwigFilter('of_type', [$this, 'ofTypeFunc']), + + // PHP methods + new TwigFilter('count', 'count'), + new TwigFilter('array_diff', 'array_diff'), + + // Security fixes + new TwigFilter('filter', [$this, 'filterFunc'], ['needs_environment' => true]), + new TwigFilter('map', [$this, 'mapFunc'], ['needs_environment' => true]), + new TwigFilter('reduce', [$this, 'reduceFunc'], ['needs_environment' => true]), + ]; + } + + /** + * Return a list of all functions. + * + * @return array + */ + public function getFunctions(): array + { + return [ + new TwigFunction('array', [$this, 'arrayFilter']), + new TwigFunction('array_key_value', [$this, 'arrayKeyValueFunc']), + new TwigFunction('array_key_exists', 'array_key_exists'), + new TwigFunction('array_unique', 'array_unique'), + new TwigFunction('array_intersect', [$this, 'arrayIntersectFunc']), + new TwigFunction('array_diff', 'array_diff'), + new TwigFunction('authorize', [$this, 'authorize']), + new TwigFunction('debug', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]), + new TwigFunction('dump', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]), + new TwigFunction('vardump', [$this, 'vardumpFunc']), + new TwigFunction('print_r', [$this, 'print_r']), + new TwigFunction('http_response_code', 'http_response_code'), + new TwigFunction('evaluate', [$this, 'evaluateStringFunc'], ['needs_context' => true]), + new TwigFunction('evaluate_twig', [$this, 'evaluateTwigFunc'], ['needs_context' => true]), + new TwigFunction('gist', [$this, 'gistFunc']), + new TwigFunction('nonce_field', [$this, 'nonceFieldFunc']), + new TwigFunction('pathinfo', 'pathinfo'), + new TwigFunction('parseurl', 'parse_url'), + new TwigFunction('random_string', [$this, 'randomStringFunc']), + new TwigFunction('repeat', [$this, 'repeatFunc']), + new TwigFunction('regex_replace', [$this, 'regexReplace']), + new TwigFunction('regex_filter', [$this, 'regexFilter']), + new TwigFunction('regex_match', [$this, 'regexMatch']), + new TwigFunction('regex_split', [$this, 'regexSplit']), + new TwigFunction('string', [$this, 'stringFilter']), + new TwigFunction('url', [$this, 'urlFunc']), + new TwigFunction('json_decode', [$this, 'jsonDecodeFilter']), + new TwigFunction('get_cookie', [$this, 'getCookie']), + new TwigFunction('redirect_me', [$this, 'redirectFunc']), + new TwigFunction('range', [$this, 'rangeFunc']), + new TwigFunction('isajaxrequest', [$this, 'isAjaxFunc']), + new TwigFunction('exif', [$this, 'exifFunc']), + new TwigFunction('media_directory', [$this, 'mediaDirFunc']), + new TwigFunction('body_class', [$this, 'bodyClassFunc'], ['needs_context' => true]), + new TwigFunction('theme_var', [$this, 'themeVarFunc'], ['needs_context' => true]), + new TwigFunction('header_var', [$this, 'pageHeaderVarFunc'], ['needs_context' => true]), + new TwigFunction('read_file', [$this, 'readFileFunc']), + new TwigFunction('nicenumber', [$this, 'niceNumberFunc']), + new TwigFunction('nicefilesize', [$this, 'niceFilesizeFunc']), + new TwigFunction('nicetime', [$this, 'nicetimeFunc']), + new TwigFunction('cron', [$this, 'cronFunc']), + new TwigFunction('svg_image', [$this, 'svgImageFunction']), + new TwigFunction('xss', [$this, 'xssFunc']), + new TwigFunction('unique_id', [$this, 'uniqueId']), + + // Translations + new TwigFunction('t', [$this, 'translate'], ['needs_environment' => true]), + new TwigFunction('tl', [$this, 'translateLanguage']), + new TwigFunction('ta', [$this, 'translateArray']), + + // Object Types + new TwigFunction('get_type', [$this, 'getTypeFunc']), + new TwigFunction('of_type', [$this, 'ofTypeFunc']), + + // PHP methods + new TwigFunction('is_numeric', 'is_numeric'), + new TwigFunction('is_iterable', 'is_iterable'), + new TwigFunction('is_countable', 'is_countable'), + new TwigFunction('is_null', 'is_null'), + new TwigFunction('is_string', 'is_string'), + new TwigFunction('is_array', 'is_array'), + new TwigFunction('is_object', 'is_object'), + new TwigFunction('count', 'count'), + new TwigFunction('array_diff', 'array_diff'), + new TwigFunction('parse_url', 'parse_url'), + + // Security fixes + new TwigFunction('filter', [$this, 'filterFunc'], ['needs_environment' => true]), + new TwigFunction('map', [$this, 'mapFunc'], ['needs_environment' => true]), + new TwigFunction('reduce', [$this, 'reduceFunc'], ['needs_environment' => true]), + ]; + } + + /** + * @return array + */ + public function getTokenParsers(): array + { + return [ + new TwigTokenParserRender(), + new TwigTokenParserThrow(), + new TwigTokenParserTryCatch(), + new TwigTokenParserScript(), + new TwigTokenParserStyle(), + new TwigTokenParserLink(), + new TwigTokenParserMarkdown(), + new TwigTokenParserSwitch(), + new TwigTokenParserCache(), + ]; + } + + /** + * @param mixed $var + * @return string + */ + public function print_r($var) + { + return print_r($var, true); + } + + /** + * Filters field name by changing dot notation into array notation. + * + * @param string $str + * @return string + */ + public function fieldNameFilter($str) + { + $path = explode('.', rtrim($str, '.')); + + return array_shift($path) . ($path ? '[' . implode('][', $path) . ']' : ''); + } + + /** + * Filters field name by changing dot notation into array notation. + * + * @param string $str + * @return string + */ + public function fieldParentFilter($str) + { + $path = explode('.', rtrim($str, '.')); + array_pop($path); + + return implode('.', $path); + } + + /** + * Protects email address. + * + * @param string $str + * @return string + */ + public function safeEmailFilter($str) + { + static $list = [ + '"' => '"', + "'" => ''', + '&' => '&', + '<' => '<', + '>' => '>', + '@' => '@' + ]; + + $characters = mb_str_split($str, 1, 'UTF-8'); + + $encoded = ''; + foreach ($characters as $chr) { + $encoded .= $list[$chr] ?? (random_int(0, 1) ? '&#' . mb_ord($chr) . ';' : $chr); + } + + return $encoded; + } + + /** + * Returns array in a random order. + * + * @param array|Traversable $original + * @param int $offset Can be used to return only slice of the array. + * @return array + */ + public function randomizeFilter($original, $offset = 0) + { + if ($original instanceof Traversable) { + $original = iterator_to_array($original, false); + } + + if (!is_array($original)) { + return $original; + } + + $sorted = []; + $random = array_slice($original, $offset); + shuffle($random); + + $sizeOf = count($original); + for ($x = 0; $x < $sizeOf; $x++) { + if ($x < $offset) { + $sorted[] = $original[$x]; + } else { + $sorted[] = array_shift($random); + } + } + + return $sorted; + } + + /** + * Returns the modulus of an integer + * + * @param string|int $number + * @param int $divider + * @param array|null $items array of items to select from to return + * @return int + */ + public function modulusFilter($number, $divider, $items = null) + { + if (is_string($number)) { + $number = strlen($number); + } + + $remainder = $number % $divider; + + if (is_array($items)) { + return $items[$remainder] ?? $items[0]; + } + + return $remainder; + } + + /** + * Inflector supports following notations: + * + * `{{ 'person'|pluralize }} => people` + * `{{ 'shoes'|singularize }} => shoe` + * `{{ 'welcome page'|titleize }} => "Welcome Page"` + * `{{ 'send_email'|camelize }} => SendEmail` + * `{{ 'CamelCased'|underscorize }} => camel_cased` + * `{{ 'Something Text'|hyphenize }} => something-text` + * `{{ 'something_text_to_read'|humanize }} => "Something text to read"` + * `{{ '181'|monthize }} => 5` + * `{{ '10'|ordinalize }} => 10th` + * + * @param string $action + * @param string $data + * @param int|null $count + * @return string + */ + public function inflectorFilter($action, $data, $count = null) + { + $action .= 'ize'; + + /** @var Inflector $inflector */ + $inflector = $this->grav['inflector']; + + if (in_array( + $action, + ['titleize', 'camelize', 'underscorize', 'hyphenize', 'humanize', 'ordinalize', 'monthize'], + true + )) { + return $inflector->{$action}($data); + } + + if (in_array($action, ['pluralize', 'singularize'], true)) { + return $count ? $inflector->{$action}($data, $count) : $inflector->{$action}($data); + } + + return $data; + } + + /** + * Return MD5 hash from the input. + * + * @param string $str + * @return string + */ + public function md5Filter($str) + { + return md5($str); + } + + /** + * Return Base32 encoded string + * + * @param string $str + * @return string + */ + public function base32EncodeFilter($str) + { + return Base32::encode($str); + } + + /** + * Return Base32 decoded string + * + * @param string $str + * @return string + */ + public function base32DecodeFilter($str) + { + return Base32::decode($str); + } + + /** + * Return Base64 encoded string + * + * @param string $str + * @return string + */ + public function base64EncodeFilter($str) + { + return base64_encode((string) $str); + } + + /** + * Return Base64 decoded string + * + * @param string $str + * @return string|false + */ + public function base64DecodeFilter($str) + { + return base64_decode($str); + } + + /** + * Sorts a collection by key + * + * @param array $input + * @param string $filter + * @param int $direction + * @param int $sort_flags + * @return array + */ + public function sortByKeyFilter($input, $filter, $direction = SORT_ASC, $sort_flags = SORT_REGULAR) + { + return Utils::sortArrayByKey($input, $filter, $direction, $sort_flags); + } + + /** + * Return ksorted collection. + * + * @param array|null $array + * @return array + */ + public function ksortFilter($array) + { + if (null === $array) { + $array = []; + } + ksort($array); + + return $array; + } + + /** + * Wrapper for chunk_split() function + * + * @param string $value + * @param int $chars + * @param string $split + * @return string + */ + public function chunkSplitFilter($value, $chars, $split = '-') + { + return chunk_split($value, $chars, $split); + } + + /** + * determine if a string contains another + * + * @param string $haystack + * @param string $needle + * @return string|bool + * @todo returning $haystack here doesn't make much sense + */ + public function containsFilter($haystack, $needle) + { + if (empty($needle)) { + return $haystack; + } + + return (strpos($haystack, (string) $needle) !== false); + } + + /** + * Gets a human readable output for cron syntax + * + * @param string $at + * @return string + */ + public function niceCronFilter($at) + { + $cron = new Cron($at); + return $cron->getText('en'); + } + + /** + * @param string|mixed $str + * @param string $search + * @param string $replace + * @return string|mixed + */ + public function replaceLastFilter($str, $search, $replace) + { + if (is_string($str) && ($pos = mb_strrpos($str, $search)) !== false) { + $str = mb_substr($str, 0, $pos) . $replace . mb_substr($str, $pos + mb_strlen($search)); + } + + return $str; + } + + /** + * Get Cron object for a crontab 'at' format + * + * @param string $at + * @return CronExpression + */ + public function cronFunc($at) + { + return CronExpression::factory($at); + } + + /** + * displays a facebook style 'time ago' formatted date/time + * + * @param string $date + * @param bool $long_strings + * @param bool $show_tense + * @return string + */ + public function nicetimeFunc($date, $long_strings = true, $show_tense = true) + { + if (empty($date)) { + return $this->grav['language']->translate('GRAV.NICETIME.NO_DATE_PROVIDED'); + } + + if ($long_strings) { + $periods = [ + 'NICETIME.SECOND', + 'NICETIME.MINUTE', + 'NICETIME.HOUR', + 'NICETIME.DAY', + 'NICETIME.WEEK', + 'NICETIME.MONTH', + 'NICETIME.YEAR', + 'NICETIME.DECADE' + ]; + } else { + $periods = [ + 'NICETIME.SEC', + 'NICETIME.MIN', + 'NICETIME.HR', + 'NICETIME.DAY', + 'NICETIME.WK', + 'NICETIME.MO', + 'NICETIME.YR', + 'NICETIME.DEC' + ]; + } + + $lengths = ['60', '60', '24', '7', '4.35', '12', '10']; + + $now = time(); + + // check if unix timestamp + if ((string)(int)$date === (string)$date) { + $unix_date = $date; + } else { + $unix_date = strtotime($date); + } + + // check validity of date + if (empty($unix_date)) { + return $this->grav['language']->translate('GRAV.NICETIME.BAD_DATE'); + } + + // is it future date or past date + if ($now > $unix_date) { + $difference = $now - $unix_date; + $tense = $this->grav['language']->translate('GRAV.NICETIME.AGO'); + } elseif ($now == $unix_date) { + $difference = $now - $unix_date; + $tense = $this->grav['language']->translate('GRAV.NICETIME.JUST_NOW'); + } else { + $difference = $unix_date - $now; + $tense = $this->grav['language']->translate('GRAV.NICETIME.FROM_NOW'); + } + + for ($j = 0; $difference >= $lengths[$j] && $j < count($lengths) - 1; $j++) { + $difference /= $lengths[$j]; + } + + $difference = round($difference); + + if ($difference != 1) { + $periods[$j] .= '_PLURAL'; + } + + if ($this->grav['language']->getTranslation( + $this->grav['language']->getLanguage(), + $periods[$j] . '_MORE_THAN_TWO' + ) + ) { + if ($difference > 2) { + $periods[$j] .= '_MORE_THAN_TWO'; + } + } + + $periods[$j] = $this->grav['language']->translate('GRAV.'.$periods[$j]); + + if ($now == $unix_date) { + return $tense; + } + + $time = "{$difference} {$periods[$j]}"; + $time .= $show_tense ? " {$tense}" : ''; + + return $time; + } + + /** + * Allow quick check of a string for XSS Vulnerabilities + * + * @param string|array $data + * @return bool|string|array + */ + public function xssFunc($data) + { + if (!is_array($data)) { + return Security::detectXss($data); + } + + $results = Security::detectXssFromArray($data); + $results_parts = array_map(static function ($value, $key) { + return $key.': \''.$value . '\''; + }, array_values($results), array_keys($results)); + + return implode(', ', $results_parts); + } + + /** + * Generates a random string with configurable length, prefix and suffix. + * Unlike the built-in `uniqid()`, this string is non-conflicting and safe + * + * @param int $length + * @param array $options + * @return string + * @throws \Exception + */ + public function uniqueId(int $length = 9, array $options = ['prefix' => '', 'suffix' => '']): string + { + return Utils::uniqueId($length, $options); + } + + /** + * @param string $string + * @return string + */ + public function absoluteUrlFilter($string) + { + $url = $this->grav['uri']->base(); + $string = preg_replace('/((?:href|src) *= *[\'"](?!(http|ftp)))/i', "$1$url", $string); + + return $string; + } + + /** + * @param array $context + * @param string $string + * @param bool $block Block or Line processing + * @return string + */ + public function markdownFunction($context, $string, $block = true) + { + $page = $context['page'] ?? null; + return Utils::processMarkdown($string, $block, $page); + } + + /** + * @param string $haystack + * @param string $needle + * @return bool + */ + public function startsWithFilter($haystack, $needle) + { + return Utils::startsWith($haystack, $needle); + } + + /** + * @param string $haystack + * @param string $needle + * @return bool + */ + public function endsWithFilter($haystack, $needle) + { + return Utils::endsWith($haystack, $needle); + } + + /** + * @param mixed $value + * @param null $default + * @return mixed|null + */ + public function definedDefaultFilter($value, $default = null) + { + return $value ?? $default; + } + + /** + * @param string $value + * @param string|null $chars + * @return string + */ + public function rtrimFilter($value, $chars = null) + { + return null !== $chars ? rtrim($value, $chars) : rtrim($value); + } + + /** + * @param string $value + * @param string|null $chars + * @return string + */ + public function ltrimFilter($value, $chars = null) + { + return null !== $chars ? ltrim($value, $chars) : ltrim($value); + } + + /** + * Returns a string from a value. If the value is array, return it json encoded + * + * @param mixed $value + * @return string + */ + public function stringFilter($value) + { + // Format the array as a string + if (is_array($value)) { + return json_encode($value); + } + + // Boolean becomes '1' or '0' + if (is_bool($value)) { + $value = (int)$value; + } + + // Cast the other values to string. + return (string)$value; + } + + /** + * Casts input to int. + * + * @param mixed $input + * @return int + */ + public function intFilter($input) + { + return (int) $input; + } + + /** + * Casts input to bool. + * + * @param mixed $input + * @return bool + */ + public function boolFilter($input) + { + return (bool) $input; + } + + /** + * Casts input to float. + * + * @param mixed $input + * @return float + */ + public function floatFilter($input) + { + return (float) $input; + } + + /** + * Casts input to array. + * + * @param mixed $input + * @return array + */ + public function arrayFilter($input) + { + if (is_array($input)) { + return $input; + } + + if (is_object($input)) { + if (method_exists($input, 'toArray')) { + return $input->toArray(); + } + + if ($input instanceof Iterator) { + return iterator_to_array($input); + } + } + + return (array)$input; + } + + /** + * @param array|object $value + * @param int|null $inline + * @param int|null $indent + * @return string + */ + public function yamlFilter($value, $inline = null, $indent = null): string + { + return Yaml::dump($value, $inline, $indent); + } + + /** + * @param Environment $twig + * @return string + */ + public function translate(Environment $twig, ...$args) + { + // If admin and tu filter provided, use it + if (isset($this->grav['admin'])) { + $numargs = count($args); + $lang = null; + + if (($numargs === 3 && is_array($args[1])) || ($numargs === 2 && !is_array($args[1]))) { + $lang = array_pop($args); + /** @var Language $language */ + $language = $this->grav['language']; + if (is_string($lang) && !$language->getLanguageCode($lang)) { + $args[] = $lang; + $lang = null; + } + } elseif ($numargs === 2 && is_array($args[1])) { + $subs = array_pop($args); + $args = array_merge($args, $subs); + } + + return $this->grav['admin']->translate($args, $lang); + } + + $translation = $this->grav['language']->translate($args); + + if ($this->config->get('system.languages.debug', false)) { + $debugger = $this->grav['debugger']; + $debugger->addMessage("$args[0] -> $translation", 'debug'); + } + + return $translation; + } + + /** + * Translate Strings + * + * @param string|array $args + * @param array|null $languages + * @param bool $array_support + * @param bool $html_out + * @return string + */ + public function translateLanguage($args, array $languages = null, $array_support = false, $html_out = false) + { + /** @var Language $language */ + $language = $this->grav['language']; + + return $language->translate($args, $languages, $array_support, $html_out); + } + + /** + * @param string $key + * @param string $index + * @param array|null $lang + * @return string + */ + public function translateArray($key, $index, $lang = null) + { + /** @var Language $language */ + $language = $this->grav['language']; + + return $language->translateArray($key, $index, $lang); + } + + /** + * Repeat given string x times. + * + * @param string $input + * @param int $multiplier + * + * @return string + */ + public function repeatFunc($input, $multiplier) + { + return str_repeat($input, (int) $multiplier); + } + + /** + * Return URL to the resource. + * + * @example {{ url('theme://images/logo.png')|default('http://www.placehold.it/150x100/f4f4f4') }} + * + * @param string $input Resource to be located. + * @param bool $domain True to include domain name. + * @param bool $failGracefully If true, return URL even if the file does not exist. + * @return string|false Returns url to the resource or null if resource was not found. + */ + public function urlFunc($input, $domain = false, $failGracefully = false) + { + return Utils::url($input, $domain, $failGracefully); + } + + /** + * This function will evaluate Twig $twig through the $environment, and return its results. + * + * @param array $context + * @param string $twig + * @return mixed + */ + public function evaluateTwigFunc($context, $twig) + { + + $loader = new FilesystemLoader('.'); + $env = new Environment($loader); + $env->addExtension($this); + + $template = $env->createTemplate($twig); + + return $template->render($context); + } + + /** + * This function will evaluate a $string through the $environment, and return its results. + * + * @param array $context + * @param string $string + * @return mixed + */ + public function evaluateStringFunc($context, $string) + { + return $this->evaluateTwigFunc($context, "{{ $string }}"); + } + + /** + * Based on Twig\Extension\Debug / twig_var_dump + * (c) 2011 Fabien Potencier + * + * @param Environment $env + * @param array $context + */ + public function dump(Environment $env, $context) + { + if (!$env->isDebug() || !$this->debugger) { + return; + } + + $count = func_num_args(); + if (2 === $count) { + $data = []; + foreach ($context as $key => $value) { + if (is_object($value)) { + if (method_exists($value, 'toArray')) { + $data[$key] = $value->toArray(); + } else { + $data[$key] = 'Object (' . get_class($value) . ')'; + } + } else { + $data[$key] = $value; + } + } + $this->debugger->addMessage($data, 'debug'); + } else { + for ($i = 2; $i < $count; $i++) { + $var = func_get_arg($i); + $this->debugger->addMessage($var, 'debug'); + } + } + } + + /** + * Output a Gist + * + * @param string $id + * @param string|false $file + * @return string + */ + public function gistFunc($id, $file = false) + { + $url = 'https://gist.github.com/' . $id . '.js'; + if ($file) { + $url .= '?file=' . $file; + } + return ''; + } + + /** + * Generate a random string + * + * @param int $count + * @return string + */ + public function randomStringFunc($count = 5) + { + return Utils::generateRandomString($count); + } + + /** + * Pad a string to a certain length with another string + * + * @param string $input + * @param int $pad_length + * @param string $pad_string + * @param int $pad_type + * @return string + */ + public static function padFilter($input, $pad_length, $pad_string = ' ', $pad_type = STR_PAD_RIGHT) + { + return str_pad($input, (int)$pad_length, $pad_string, $pad_type); + } + + /** + * Workaround for twig associative array initialization + * Returns a key => val array + * + * @param string $key key of item + * @param string $val value of item + * @param array|null $current_array optional array to add to + * @return array + */ + public function arrayKeyValueFunc($key, $val, $current_array = null) + { + if (empty($current_array)) { + return array($key => $val); + } + + $current_array[$key] = $val; + + return $current_array; + } + + /** + * Wrapper for array_intersect() method + * + * @param array|Collection $array1 + * @param array|Collection $array2 + * @return array|Collection + */ + public function arrayIntersectFunc($array1, $array2) + { + if ($array1 instanceof Collection && $array2 instanceof Collection) { + return $array1->intersect($array2)->toArray(); + } + + return array_intersect($array1, $array2); + } + + /** + * Translate a string + * + * @return string + */ + public function translateFunc() + { + return $this->grav['language']->translate(func_get_args()); + } + + /** + * Authorize an action. Returns true if the user is logged in and + * has the right to execute $action. + * + * @param string|array $action An action or a list of actions. Each + * entry can be a string like 'group.action' + * or without dot notation an associative + * array. + * @return bool Returns TRUE if the user is authorized to + * perform the action, FALSE otherwise. + */ + public function authorize($action) + { + // Admin can use Flex users even if the site does not; make sure we use the right version of the user. + $admin = $this->grav['admin'] ?? null; + if ($admin) { + $user = $admin->user; + } else { + /** @var UserInterface|null $user */ + $user = $this->grav['user'] ?? null; + } + + if (!$user) { + return false; + } + + if (is_array($action)) { + if (Utils::isAssoc($action)) { + // Handle nested access structure. + $actions = Utils::arrayFlattenDotNotation($action); + } else { + // Handle simple access list. + $actions = array_combine($action, array_fill(0, count($action), true)); + } + } else { + // Handle single action. + $actions = [(string)$action => true]; + } + + $count = count($actions); + foreach ($actions as $act => $authenticated) { + // Ignore 'admin.super' if it's not the only value to be checked. + if ($act === 'admin.super' && $count > 1 && $user instanceof FlexObjectInterface) { + continue; + } + + $auth = $user->authorize($act) ?? false; + if (is_bool($auth) && $auth === Utils::isPositive($authenticated)) { + return true; + } + } + + return false; + } + + /** + * Used to add a nonce to a form. Call {{ nonce_field('action') }} specifying a string representing the action. + * + * For maximum protection, ensure that the string representing the action is as specific as possible + * + * @param string $action the action + * @param string $nonceParamName a custom nonce param name + * @return string the nonce input field + */ + public function nonceFieldFunc($action, $nonceParamName = 'nonce') + { + $string = ''; + + return $string; + } + + /** + * Decodes string from JSON. + * + * @param string $str + * @param bool $assoc + * @param int $depth + * @param int $options + * @return array + */ + public function jsonDecodeFilter($str, $assoc = false, $depth = 512, $options = 0) + { + if ($str === null) { + $str = ''; + } + return json_decode(html_entity_decode($str, ENT_COMPAT | ENT_HTML401, 'UTF-8'), $assoc, $depth, $options); + } + + /** + * Used to retrieve a cookie value + * + * @param string $key The cookie name to retrieve + * @return string + */ + public function getCookie($key) + { + $cookie_value = filter_input(INPUT_COOKIE, $key); + + if ($cookie_value === null) { + return null; + } + + return htmlspecialchars(strip_tags($cookie_value), ENT_QUOTES, 'UTF-8'); + } + + /** + * Twig wrapper for PHP's preg_replace method + * + * @param string|string[] $subject the content to perform the replacement on + * @param string|string[] $pattern the regex pattern to use for matches + * @param string|string[] $replace the replacement value either as a string or an array of replacements + * @param int $limit the maximum possible replacements for each pattern in each subject + * @return string|string[]|null the resulting content + */ + public function regexReplace($subject, $pattern, $replace, $limit = -1) + { + return preg_replace($pattern, $replace, $subject, $limit); + } + + /** + * Twig wrapper for PHP's preg_grep method + * + * @param array $array + * @param string $regex + * @param int $flags + * @return array + */ + public function regexFilter($array, $regex, $flags = 0) + { + return preg_grep($regex, $array, $flags); + } + + /** + * Twig wrapper for PHP's preg_match method + * + * @param string $subject the content to perform the match on + * @param string $pattern the regex pattern to use for match + * @param int $flags + * @param int $offset + * @return array|false returns the matches if there is at least one match in the subject for a given pattern or null if not. + */ + public function regexMatch($subject, $pattern, $flags = 0, $offset = 0) + { + if (preg_match($pattern, $subject, $matches, $flags, $offset) === false) { + return false; + } + + return $matches; + } + + /** + * Twig wrapper for PHP's preg_split method + * + * @param string $subject the content to perform the split on + * @param string $pattern the regex pattern to use for split + * @param int $limit the maximum possible splits for the given pattern + * @param int $flags + * @return array|false the resulting array after performing the split operation + */ + public function regexSplit($subject, $pattern, $limit = -1, $flags = 0) + { + return preg_split($pattern, $subject, $limit, $flags); + } + + /** + * redirect browser from twig + * + * @param string $url the url to redirect to + * @param int $statusCode statusCode, default 303 + * @return void + */ + public function redirectFunc($url, $statusCode = 303) + { + $response = new Response($statusCode, ['location' => $url]); + + $this->grav->close($response); + } + + /** + * Generates an array containing a range of elements, optionally stepped + * + * @param int $start Minimum number, default 0 + * @param int $end Maximum number, default `getrandmax()` + * @param int $step Increment between elements in the sequence, default 1 + * @return array + */ + public function rangeFunc($start = 0, $end = 100, $step = 1) + { + return range($start, $end, $step); + } + + /** + * Check if HTTP_X_REQUESTED_WITH has been set to xmlhttprequest, + * in which case we may unsafely assume ajax. Non critical use only. + * + * @return bool True if HTTP_X_REQUESTED_WITH exists and has been set to xmlhttprequest + */ + public function isAjaxFunc() + { + return ( + !empty($_SERVER['HTTP_X_REQUESTED_WITH']) + && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'); + } + + /** + * Get the Exif data for a file + * + * @param string $image + * @param bool $raw + * @return mixed + */ + public function exifFunc($image, $raw = false) + { + if (isset($this->grav['exif'])) { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + if ($locator->isStream($image)) { + $image = $locator->findResource($image); + } + + $exif_reader = $this->grav['exif']->getReader(); + + if ($image && file_exists($image) && $this->config->get('system.media.auto_metadata_exif') && $exif_reader) { + $exif_data = $exif_reader->read($image); + + if ($exif_data) { + if ($raw) { + return $exif_data->getRawData(); + } + + return $exif_data->getData(); + } + } + } + + return null; + } + + /** + * Simple function to read a file based on a filepath and output it + * + * @param string $filepath + * @return bool|string + */ + public function readFileFunc($filepath) + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + if ($locator->isStream($filepath)) { + $filepath = $locator->findResource($filepath); + } + + if ($filepath && file_exists($filepath)) { + return file_get_contents($filepath); + } + + return false; + } + + /** + * Process a folder as Media and return a media object + * + * @param string $media_dir + * @return Media|null + */ + public function mediaDirFunc($media_dir) + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + if ($locator->isStream($media_dir)) { + $media_dir = $locator->findResource($media_dir); + } + + if ($media_dir && file_exists($media_dir)) { + return new Media($media_dir); + } + + return null; + } + + /** + * Dump a variable to the browser + * + * @param mixed $var + * @return void + */ + public function vardumpFunc($var) + { + dump($var); + } + + /** + * Returns a nicer more readable filesize based on bytes + * + * @param int $bytes + * @return string + */ + public function niceFilesizeFunc($bytes) + { + return Utils::prettySize($bytes); + } + + /** + * Returns a nicer more readable number + * + * @param int|float|string $n + * @return string|bool + */ + public function niceNumberFunc($n) + { + if (!is_float($n) && !is_int($n)) { + if (!is_string($n) || $n === '') { + return false; + } + + // Strip any thousand formatting and find the first number. + $list = array_filter(preg_split("/\D+/", str_replace(',', '', $n))); + $n = reset($list); + + if (!is_numeric($n)) { + return false; + } + + $n = (float)$n; + } + + // now filter it; + if ($n > 1000000000000) { + return round($n/1000000000000, 2).' t'; + } + if ($n > 1000000000) { + return round($n/1000000000, 2).' b'; + } + if ($n > 1000000) { + return round($n/1000000, 2).' m'; + } + if ($n > 1000) { + return round($n/1000, 2).' k'; + } + + return number_format($n); + } + + /** + * Get a theme variable + * Will try to get the variable for the current page, if not found, it tries it's parent page on up to root. + * If still not found, will use the theme's configuration value, + * If still not found, will use the $default value passed in + * + * @param array $context Twig Context + * @param string $var variable to be found (using dot notation) + * @param null $default the default value to be used as last resort + * @param PageInterface|null $page an optional page to use for the current page + * @param bool $exists toggle to simply return the page where the variable is set, else null + * @return mixed + */ + public function themeVarFunc($context, $var, $default = null, $page = null, $exists = false) + { + $page = $page ?? $context['page'] ?? Grav::instance()['page'] ?? null; + + // Try to find var in the page headers + if ($page instanceof PageInterface && $page->exists()) { + // Loop over pages and look for header vars + while ($page && !$page->root()) { + $header = new Data((array)$page->header()); + $value = $header->get($var); + if (isset($value)) { + if ($exists) { + return $page; + } + + return $value; + } + $page = $page->parent(); + } + } + + if ($exists) { + return false; + } + + return Grav::instance()['config']->get('theme.' . $var, $default); + } + + /** + * Look for a page header variable in an array of pages working its way through until a value is found + * + * @param array $context + * @param string $var the variable to look for in the page header + * @param string|string[]|null $pages array of pages to check (current page upwards if not null) + * @return mixed + * @deprecated 1.7 Use themeVarFunc() instead + */ + public function pageHeaderVarFunc($context, $var, $pages = null) + { + if (is_array($pages)) { + $page = array_shift($pages); + } else { + $page = null; + } + return $this->themeVarFunc($context, $var, null, $page); + } + + /** + * takes an array of classes, and if they are not set on body_classes + * look to see if they are set in theme config + * + * @param array $context + * @param string|string[] $classes + * @return string + */ + public function bodyClassFunc($context, $classes) + { + + $header = $context['page']->header(); + $body_classes = $header->body_classes ?? ''; + + foreach ((array)$classes as $class) { + if (!empty($body_classes) && Utils::contains($body_classes, $class)) { + continue; + } + + $val = $this->config->get('theme.' . $class, false) ? $class : false; + $body_classes .= $val ? ' ' . $val : ''; + } + + return $body_classes; + } + + /** + * Returns the content of an SVG image and adds extra classes as needed + * + * @param string $path + * @param string|null $classes + * @return string|string[]|null + */ + public static function svgImageFunction($path, $classes = null, $strip_style = false) + { + $path = Utils::fullPath($path); + + $classes = $classes ?: ''; + + if (file_exists($path) && !is_dir($path)) { + $svg = file_get_contents($path); + $classes = " inline-block $classes"; + $matched = false; + + //Remove xml tag if it exists + $svg = preg_replace('/^<\?xml.*\?>/','', $svg); + + //Strip style if needed + if ($strip_style) { + $svg = preg_replace('//s', '', $svg); + } + + //Look for existing class + $svg = preg_replace_callback('/^]*(class=\"([^"]*)\")[^>]*>/', function($matches) use ($classes, &$matched) { + if (isset($matches[2])) { + $new_classes = $matches[2] . $classes; + $matched = true; + return str_replace($matches[1], "class=\"$new_classes\"", $matches[0]); + } + return $matches[0]; + }, $svg + ); + + // no matches found just add the class + if (!$matched) { + $classes = trim($classes); + $svg = str_replace('jsonSerialize(); + } elseif (method_exists($data, 'toArray')) { + $data = $data->toArray(); + } else { + $data = json_decode(json_encode($data), true); + } + } + + return Yaml::dump($data, $inline); + } + + /** + * Decode/Parse data from YAML format + * + * @param string $data + * @return array + */ + public function yamlDecodeFilter($data) + { + return Yaml::parse($data); + } + + /** + * Function/Filter to return the type of variable + * + * @param mixed $var + * @return string + */ + public function getTypeFunc($var) + { + return gettype($var); + } + + /** + * Function/Filter to test type of variable + * + * @param mixed $var + * @param string|null $typeTest + * @param string|null $className + * @return bool + */ + public function ofTypeFunc($var, $typeTest = null, $className = null) + { + + switch ($typeTest) { + default: + return false; + + case 'array': + return is_array($var); + + case 'bool': + return is_bool($var); + + case 'class': + return is_object($var) === true && get_class($var) === $className; + + case 'float': + return is_float($var); + + case 'int': + return is_int($var); + + case 'numeric': + return is_numeric($var); + + case 'object': + return is_object($var); + + case 'scalar': + return is_scalar($var); + + case 'string': + return is_string($var); + } + } + + /** + * @param Environment $env + * @param array $array + * @param callable|string $arrow + * @return array|CallbackFilterIterator + * @throws RuntimeError + */ + function filterFunc(Environment $env, $array, $arrow) + { + if (!$arrow instanceof \Closure && !is_string($arrow) || Utils::isDangerousFunction($arrow)) { + throw new RuntimeError('Twig |filter("' . $arrow . '") is not allowed.'); + } + + return twig_array_filter($env, $array, $arrow); + } + + /** + * @param Environment $env + * @param array $array + * @param callable|string $arrow + * @return array|CallbackFilterIterator + * @throws RuntimeError + */ + function mapFunc(Environment $env, $array, $arrow) + { + if (!$arrow instanceof \Closure && !is_string($arrow) || Utils::isDangerousFunction($arrow)) { + throw new RuntimeError('Twig |map("' . $arrow . '") is not allowed.'); + } + + return twig_array_map($env, $array, $arrow); + } + + /** + * @param Environment $env + * @param array $array + * @param callable|string $arrow + * @return array|CallbackFilterIterator + * @throws RuntimeError + */ + function reduceFunc(Environment $env, $array, $arrow) + { + if (!$arrow instanceof \Closure && !is_string($arrow) || Utils::isDangerousFunction($arrow)) { + throw new RuntimeError('Twig |reduce("' . $arrow . '") is not allowed.'); + } + + return twig_array_map($env, $array, $arrow); + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeCache.php b/system/src/Grav/Common/Twig/Node/TwigNodeCache.php new file mode 100644 index 0000000..5f6fd93 --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeCache.php @@ -0,0 +1,93 @@ + $body]; + + if ($key !== null) { + $nodes['key'] = $key; + } + + if ($lifetime !== null) { + $nodes['lifetime'] = $lifetime; + } + + parent::__construct($nodes, $defaults, $lineno, $tag); + } + + public function compile(Compiler $compiler): void + { + $compiler->addDebugInfo($this); + + + // Generate the cache key + if ($this->hasNode('key')) { + $compiler + ->write('$key = "twigcache-" . ') + ->subcompile($this->getNode('key')) + ->raw(";\n"); + } else { + $compiler + ->write('$key = ') + ->string($this->getAttribute('key')) + ->raw(";\n"); + } + + // Set the cache timeout + if ($this->hasNode('lifetime')) { + $compiler + ->write('$lifetime = ') + ->subcompile($this->getNode('lifetime')) + ->raw(";\n"); + } else { + $compiler + ->write('$lifetime = ') + ->write($this->getAttribute('lifetime')) + ->raw(";\n"); + } + + $compiler + ->write("\$cache = \\Grav\\Common\\Grav::instance()['cache'];\n") + ->write("\$cache_body = \$cache->fetch(\$key);\n") + ->write("if (\$cache_body === false) {\n") + ->indent() + ->write("\\Grav\\Common\\Grav::instance()['debugger']->addMessage(\"Cache Key: \$key, Lifetime: \$lifetime\");\n") + ->write("ob_start();\n") + ->indent() + ->subcompile($this->getNode('body')) + ->outdent() + ->write("\n") + ->write("\$cache_body = ob_get_clean();\n") + ->write("\$cache->save(\$key, \$cache_body, \$lifetime);\n") + ->outdent() + ->write("}\n") + ->write("echo '' === \$cache_body ? '' : new Markup(\$cache_body, \$this->env->getCharset());\n"); + } +} \ No newline at end of file diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeLink.php b/system/src/Grav/Common/Twig/Node/TwigNodeLink.php new file mode 100644 index 0000000..46ade2c --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeLink.php @@ -0,0 +1,114 @@ + $file, 'group' => $group, 'priority' => $priority, 'attributes' => $attributes]; + $nodes = array_filter($nodes); + + parent::__construct($nodes, ['rel' => $rel], $lineno, $tag); + } + + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig Compiler instance + * @return void + * @throws LogicException + */ + public function compile(Compiler $compiler): void + { + $compiler->addDebugInfo($this); + if (!$this->hasNode('file')) { + return; + } + + $compiler->write('$attributes = [\'rel\' => \'' . $this->getAttribute('rel') . '\'];' . "\n"); + if ($this->hasNode('attributes')) { + $compiler + ->write('$attributes += ') + ->subcompile($this->getNode('attributes')) + ->raw(';' . PHP_EOL) + ->write('if (!is_array($attributes)) {' . PHP_EOL) + ->indent() + ->write("throw new UnexpectedValueException('{% {$this->tagName} with x %}: x is not an array');" . PHP_EOL) + ->outdent() + ->write('}' . PHP_EOL); + } + + if ($this->hasNode('group')) { + $compiler + ->write('$group = ') + ->subcompile($this->getNode('group')) + ->raw(';' . PHP_EOL) + ->write('if (!is_string($group)) {' . PHP_EOL) + ->indent() + ->write("throw new UnexpectedValueException('{% {$this->tagName} in x %}: x is not a string');" . PHP_EOL) + ->outdent() + ->write('}' . PHP_EOL); + } else { + $compiler->write('$group = \'head\';' . PHP_EOL); + } + + if ($this->hasNode('priority')) { + $compiler + ->write('$priority = (int)(') + ->subcompile($this->getNode('priority')) + ->raw(');' . PHP_EOL); + } else { + $compiler->write('$priority = 10;' . PHP_EOL); + } + + $compiler->write("\$assets = \\Grav\\Common\\Grav::instance()['assets'];" . PHP_EOL); + $compiler->write("\$block = \$context['block'] ?? null;" . PHP_EOL); + + $compiler + ->write('$file = (string)(') + ->subcompile($this->getNode('file')) + ->raw(');' . PHP_EOL); + + // Assets support. + $compiler->write('$assets->addLink($file, [\'group\' => $group, \'priority\' => $priority] + $attributes);' . PHP_EOL); + + // HtmlBlock support. + $compiler + ->write('if ($block instanceof \Grav\Framework\ContentBlock\HtmlBlock) {' . PHP_EOL) + ->indent() + ->write('$block->addLink([\'href\'=> $file] + $attributes, $priority, $group);' . PHP_EOL) + ->outdent() + ->write('}' . PHP_EOL); + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php b/system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php new file mode 100644 index 0000000..1b9dd6f --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php @@ -0,0 +1,52 @@ + $body], [], $lineno, $tag); + } + + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig Compiler instance + * @return void + */ + public function compile(Compiler $compiler): void + { + $compiler + ->addDebugInfo($this) + ->write('ob_start();' . PHP_EOL) + ->subcompile($this->getNode('body')) + ->write('$content = ob_get_clean();' . PHP_EOL) + ->write('preg_match("/^\s*/", $content, $matches);' . PHP_EOL) + ->write('$lines = explode("\n", $content);' . PHP_EOL) + ->write('$content = preg_replace(\'/^\' . $matches[0]. \'/\', "", $lines);' . PHP_EOL) + ->write('$content = join("\n", $content);' . PHP_EOL) + ->write('echo $this->env->getExtension(\'Grav\Common\Twig\Extension\GravExtension\')->markdownFunction($context, $content);' . PHP_EOL); + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeRender.php b/system/src/Grav/Common/Twig/Node/TwigNodeRender.php new file mode 100644 index 0000000..d0285dc --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeRender.php @@ -0,0 +1,84 @@ + $object, 'layout' => $layout, 'context' => $context]; + $nodes = array_filter($nodes); + + parent::__construct($nodes, [], $lineno, $tag); + } + + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig Compiler instance + * @return void + * @throws LogicException + */ + public function compile(Compiler $compiler): void + { + $compiler->addDebugInfo($this); + $compiler->write('$object = ')->subcompile($this->getNode('object'))->raw(';' . PHP_EOL); + + if ($this->hasNode('layout')) { + $layout = $this->getNode('layout'); + $compiler->write('$layout = ')->subcompile($layout)->raw(';' . PHP_EOL); + } else { + $compiler->write('$layout = null;' . PHP_EOL); + } + + if ($this->hasNode('context')) { + $context = $this->getNode('context'); + $compiler->write('$attributes = ')->subcompile($context)->raw(';' . PHP_EOL); + } else { + $compiler->write('$attributes = null;' . PHP_EOL); + } + + $compiler + ->write('$html = $object->render($layout, $attributes ?? []);' . PHP_EOL) + ->write('$block = $context[\'block\'] ?? null;' . PHP_EOL) + ->write('if ($block instanceof \Grav\Framework\ContentBlock\ContentBlock && $html instanceof \Grav\Framework\ContentBlock\ContentBlock) {' . PHP_EOL) + ->indent() + ->write('$block->addBlock($html);' . PHP_EOL) + ->write('echo $html->getToken();' . PHP_EOL) + ->outdent() + ->write('} else {' . PHP_EOL) + ->indent() + ->write('\Grav\Common\Assets\BlockAssets::registerAssets($html);' . PHP_EOL) + ->write('echo (string)$html;' . PHP_EOL) + ->outdent() + ->write('}' . PHP_EOL) + ; + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeScript.php b/system/src/Grav/Common/Twig/Node/TwigNodeScript.php new file mode 100644 index 0000000..bd80f60 --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeScript.php @@ -0,0 +1,142 @@ + $body, 'file' => $file, 'group' => $group, 'priority' => $priority, 'attributes' => $attributes]; + $nodes = array_filter($nodes); + + parent::__construct($nodes, ['type' => $type], $lineno, $tag); + } + + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig Compiler instance + * @return void + * @throws LogicException + */ + public function compile(Compiler $compiler): void + { + $compiler->addDebugInfo($this); + + if ($this->hasNode('attributes')) { + $compiler + ->write('$attributes = ') + ->subcompile($this->getNode('attributes')) + ->raw(';' . PHP_EOL) + ->write('if (!is_array($attributes)) {' . PHP_EOL) + ->indent() + ->write("throw new UnexpectedValueException('{% {$this->tagName} with x %}: x is not an array');" . PHP_EOL) + ->outdent() + ->write('}' . PHP_EOL); + } else { + $compiler->write('$attributes = [];' . PHP_EOL); + } + + if ($this->hasNode('group')) { + $compiler + ->write('$group = ') + ->subcompile($this->getNode('group')) + ->raw(';' . PHP_EOL) + ->write('if (!is_string($group)) {' . PHP_EOL) + ->indent() + ->write("throw new UnexpectedValueException('{% {$this->tagName} in x %}: x is not a string');" . PHP_EOL) + ->outdent() + ->write('}' . PHP_EOL); + } else { + $compiler->write('$group = \'head\';' . PHP_EOL); + } + + if ($this->hasNode('priority')) { + $compiler + ->write('$priority = (int)(') + ->subcompile($this->getNode('priority')) + ->raw(');' . PHP_EOL); + } else { + $compiler->write('$priority = 10;' . PHP_EOL); + } + + $compiler->write("\$assets = \\Grav\\Common\\Grav::instance()['assets'];" . PHP_EOL); + $compiler->write("\$block = \$context['block'] ?? null;" . PHP_EOL); + + if ($this->hasNode('file')) { + // JS file. + $compiler + ->write('$file = (string)(') + ->subcompile($this->getNode('file')) + ->raw(');' . PHP_EOL); + + $method = $this->getAttribute('type') === 'module' ? 'addJsModule' : 'addJs'; + + // Assets support. + $compiler->write('$assets->' . $method . '($file, [\'group\' => $group, \'priority\' => $priority] + $attributes);' . PHP_EOL); + + $method = $this->getAttribute('type') === 'module' ? 'addModule' : 'addScript'; + + // HtmlBlock support. + $compiler + ->write('if ($block instanceof \Grav\Framework\ContentBlock\HtmlBlock) {' . PHP_EOL) + ->indent() + ->write('$block->' . $method . '([\'src\'=> $file] + $attributes, $priority, $group);' . PHP_EOL) + ->outdent() + ->write('}' . PHP_EOL); + + } else { + // Inline script. + $compiler + ->write('ob_start();' . PHP_EOL) + ->subcompile($this->getNode('body')) + ->write('$content = ob_get_clean();' . PHP_EOL); + + $method = $this->getAttribute('type') === 'module' ? 'addInlineJsModule' : 'addInlineJs'; + + // Assets support. + $compiler->write('$assets->' . $method . '($content, [\'group\' => $group, \'priority\' => $priority] + $attributes);' . PHP_EOL); + + $method = $this->getAttribute('type') === 'module' ? 'addInlineModule' : 'addInlineScript'; + + // HtmlBlock support. + $compiler + ->write('if ($block instanceof \Grav\Framework\ContentBlock\HtmlBlock) {' . PHP_EOL) + ->indent() + ->write('$block->' . $method . '([\'content\'=> $content] + $attributes, $priority, $group);' . PHP_EOL) + ->outdent() + ->write('}' . PHP_EOL); + } + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php b/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php new file mode 100644 index 0000000..6023375 --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php @@ -0,0 +1,133 @@ + $body, 'file' => $file, 'group' => $group, 'priority' => $priority, 'attributes' => $attributes]; + $nodes = array_filter($nodes); + + parent::__construct($nodes, [], $lineno, $tag); + } + + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig Compiler instance + * @return void + * @throws LogicException + */ + public function compile(Compiler $compiler): void + { + $compiler->addDebugInfo($this); + + if ($this->hasNode('attributes')) { + $compiler + ->write('$attributes = ') + ->subcompile($this->getNode('attributes')) + ->raw(';' . PHP_EOL) + ->write('if (!is_array($attributes)) {' . PHP_EOL) + ->indent() + ->write("throw new UnexpectedValueException('{% {$this->tagName} with x %}: x is not an array');" . PHP_EOL) + ->outdent() + ->write('}' . PHP_EOL); + } else { + $compiler->write('$attributes = [];' . PHP_EOL); + } + + if ($this->hasNode('group')) { + $compiler + ->write('$group = ') + ->subcompile($this->getNode('group')) + ->raw(';' . PHP_EOL) + ->write('if (!is_string($group)) {' . PHP_EOL) + ->indent() + ->write("throw new UnexpectedValueException('{% {$this->tagName} in x %}: x is not a string');" . PHP_EOL) + ->outdent() + ->write('}' . PHP_EOL); + } else { + $compiler->write('$group = \'head\';' . PHP_EOL); + } + + if ($this->hasNode('priority')) { + $compiler + ->write('$priority = (int)(') + ->subcompile($this->getNode('priority')) + ->raw(');' . PHP_EOL); + } else { + $compiler->write('$priority = 10;' . PHP_EOL); + } + + $compiler->write("\$assets = \\Grav\\Common\\Grav::instance()['assets'];" . PHP_EOL); + $compiler->write("\$block = \$context['block'] ?? null;" . PHP_EOL); + + if ($this->hasNode('file')) { + // CSS file. + $compiler + ->write('$file = (string)(') + ->subcompile($this->getNode('file')) + ->raw(');' . PHP_EOL); + + // Assets support. + $compiler->write('$assets->addCss($file, [\'group\' => $group, \'priority\' => $priority] + $attributes);' . PHP_EOL); + + // HtmlBlock support. + $compiler + ->write('if ($block instanceof \Grav\Framework\ContentBlock\HtmlBlock) {' . PHP_EOL) + ->indent() + ->write('$block->addStyle([\'href\'=> $file] + $attributes, $priority, $group);' . PHP_EOL) + ->outdent() + ->write('}' . PHP_EOL); + + } else { + // Inline style. + $compiler + ->write('ob_start();' . PHP_EOL) + ->subcompile($this->getNode('body')) + ->write('$content = ob_get_clean();' . PHP_EOL); + + // Assets support. + $compiler->write('$assets->addInlineCss($content, [\'group\' => $group, \'priority\' => $priority] + $attributes);' . PHP_EOL); + + // HtmlBlock support. + $compiler + ->write('if ($block instanceof \Grav\Framework\ContentBlock\HtmlBlock) {' . PHP_EOL) + ->indent() + ->write('$block->addInlineStyle([\'content\'=> $content] + $attributes, $priority, $group);' . PHP_EOL) + ->outdent() + ->write('}' . PHP_EOL); + } + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php b/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php new file mode 100644 index 0000000..d48f9b1 --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php @@ -0,0 +1,88 @@ + $value, 'cases' => $cases, 'default' => $default]; + $nodes = array_filter($nodes); + + parent::__construct($nodes, [], $lineno, $tag); + } + + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig Compiler instance + * @return void + */ + public function compile(Compiler $compiler): void + { + $compiler + ->addDebugInfo($this) + ->write('switch (') + ->subcompile($this->getNode('value')) + ->raw(") {\n") + ->indent(); + + /** @var Node $case */ + foreach ($this->getNode('cases') as $case) { + if (!$case->hasNode('body')) { + continue; + } + + foreach ($case->getNode('values') as $value) { + $compiler + ->write('case ') + ->subcompile($value) + ->raw(":\n"); + } + + $compiler + ->write("{\n") + ->indent() + ->subcompile($case->getNode('body')) + ->write("break;\n") + ->outdent() + ->write("}\n"); + } + + if ($this->hasNode('default')) { + $compiler + ->write("default:\n") + ->write("{\n") + ->indent() + ->subcompile($this->getNode('default')) + ->outdent() + ->write("}\n"); + } + + $compiler + ->outdent() + ->write("}\n"); + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeThrow.php b/system/src/Grav/Common/Twig/Node/TwigNodeThrow.php new file mode 100644 index 0000000..6a0ce85 --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeThrow.php @@ -0,0 +1,52 @@ + $message], ['code' => $code], $lineno, $tag); + } + + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig Compiler instance + * @return void + * @throws LogicException + */ + public function compile(Compiler $compiler): void + { + $compiler->addDebugInfo($this); + + $compiler + ->write('throw new \Grav\Common\Twig\Exception\TwigException(') + ->subcompile($this->getNode('message')) + ->write(', ') + ->write($this->getAttribute('code') ?: 500) + ->write(");\n"); + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php b/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php new file mode 100644 index 0000000..bc93d6c --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php @@ -0,0 +1,67 @@ + $try, 'catch' => $catch]; + $nodes = array_filter($nodes); + + parent::__construct($nodes, [], $lineno, $tag); + } + + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig Compiler instance + * @return void + * @throws LogicException + */ + public function compile(Compiler $compiler): void + { + $compiler->addDebugInfo($this); + + $compiler->write('try {'); + + $compiler + ->indent() + ->subcompile($this->getNode('try')) + ->outdent() + ->write('} catch (\Exception $e) {' . "\n") + ->indent() + ->write('if (isset($context[\'grav\'][\'debugger\'])) $context[\'grav\'][\'debugger\']->addException($e);' . "\n") + ->write('$context[\'e\'] = $e;' . "\n"); + + if ($this->hasNode('catch')) { + $compiler->subcompile($this->getNode('catch')); + } + + $compiler + ->outdent() + ->write("}\n"); + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserCache.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserCache.php new file mode 100644 index 0000000..f4986e8 --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserCache.php @@ -0,0 +1,74 @@ +parser->getStream(); + $lineno = $token->getLine(); + + // Parse the optional key and timeout parameters + $defaults = [ + 'key' => $this->parser->getVarName() . $lineno, + 'lifetime' => Grav::instance()['cache']->getLifetime() + ]; + + $key = null; + $lifetime = null; + while (!$stream->test(Token::BLOCK_END_TYPE)) { + if ($stream->test(Token::STRING_TYPE)) { + $key = $this->parser->getExpressionParser()->parseExpression(); + } elseif ($stream->test(Token::NUMBER_TYPE)) { + $lifetime = $this->parser->getExpressionParser()->parseExpression(); + } else { + throw new \Twig\Error\SyntaxError("Unexpected token type in cache tag.", $token->getLine(), $stream->getSourceContext()); + } + } + + $stream->expect(Token::BLOCK_END_TYPE); + + // Parse the content inside the cache block + $body = $this->parser->subparse([$this, 'decideCacheEnd'], true); + + $stream->expect(Token::BLOCK_END_TYPE); + + return new TwigNodeCache($body, $key, $lifetime, $defaults, $lineno, $this->getTag()); + } + + public function decideCacheEnd(Token $token): bool + { + return $token->test('endcache'); + } + + public function getTag(): string + { + return 'cache'; + } +} \ No newline at end of file diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserLink.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserLink.php new file mode 100644 index 0000000..77cd5a6 --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserLink.php @@ -0,0 +1,109 @@ +getLine(); + + [$rel, $file, $group, $priority, $attributes] = $this->parseArguments($token); + + return new TwigNodeLink($rel, $file, $group, $priority, $attributes, $lineno, $this->getTag()); + } + + /** + * @param Token $token + * @return array + */ + protected function parseArguments(Token $token): array + { + $stream = $this->parser->getStream(); + + + $rel = null; + if ($stream->test(Token::NAME_TYPE, $this->rel)) { + $rel = $stream->getCurrent()->getValue(); + $stream->next(); + } + + $file = null; + if (!$stream->test(Token::NAME_TYPE) && !$stream->test(Token::BLOCK_END_TYPE)) { + $file = $this->parser->getExpressionParser()->parseExpression(); + } + + $group = null; + if ($stream->nextIf(Token::NAME_TYPE, 'at')) { + $group = $this->parser->getExpressionParser()->parseExpression(); + } + + $priority = null; + if ($stream->nextIf(Token::NAME_TYPE, 'priority')) { + $stream->expect(Token::PUNCTUATION_TYPE, ':'); + $priority = $this->parser->getExpressionParser()->parseExpression(); + } + + $attributes = null; + if ($stream->nextIf(Token::NAME_TYPE, 'with')) { + $attributes = $this->parser->getExpressionParser()->parseExpression(); + } + + $stream->expect(Token::BLOCK_END_TYPE); + + return [$rel, $file, $group, $priority, $attributes]; + } + + /** + * Gets the tag name associated with this token parser. + * + * @return string The tag name + */ + public function getTag(): string + { + return 'link'; + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php new file mode 100644 index 0000000..c1943d8 --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php @@ -0,0 +1,59 @@ +getLine(); + $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); + $body = $this->parser->subparse([$this, 'decideMarkdownEnd'], true); + $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); + return new TwigNodeMarkdown($body, $lineno, $this->getTag()); + } + /** + * Decide if current token marks end of Markdown block. + * + * @param Token $token + * @return bool + */ + public function decideMarkdownEnd(Token $token): bool + { + return $token->test('endmarkdown'); + } + /** + * {@inheritdoc} + */ + public function getTag(): string + { + return 'markdown'; + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserRender.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserRender.php new file mode 100644 index 0000000..5e73e91 --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserRender.php @@ -0,0 +1,74 @@ +getLine(); + + [$object, $layout, $context] = $this->parseArguments($token); + + return new TwigNodeRender($object, $layout, $context, $lineno, $this->getTag()); + } + + /** + * @param Token $token + * @return array + */ + protected function parseArguments(Token $token): array + { + $stream = $this->parser->getStream(); + + $object = $this->parser->getExpressionParser()->parseExpression(); + + $layout = null; + if ($stream->nextIf(Token::NAME_TYPE, 'layout')) { + $stream->expect(Token::PUNCTUATION_TYPE, ':'); + $layout = $this->parser->getExpressionParser()->parseExpression(); + } + + $context = null; + if ($stream->nextIf(Token::NAME_TYPE, 'with')) { + $context = $this->parser->getExpressionParser()->parseExpression(); + } + + $stream->expect(Token::BLOCK_END_TYPE); + + return [$object, $layout, $context]; + } + + /** + * Gets the tag name associated with this token parser. + * + * @return string The tag name + */ + public function getTag(): string + { + return 'render'; + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php new file mode 100644 index 0000000..f575f04 --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php @@ -0,0 +1,132 @@ +getLine(); + $stream = $this->parser->getStream(); + + [$type, $file, $group, $priority, $attributes] = $this->parseArguments($token); + + $content = null; + if ($file === null) { + $content = $this->parser->subparse([$this, 'decideBlockEnd'], true); + $stream->expect(Token::BLOCK_END_TYPE); + } + + return new TwigNodeScript($content, $type, $file, $group, $priority, $attributes, $lineno, $this->getTag()); + } + + /** + * @param Token $token + * @return array + */ + protected function parseArguments(Token $token): array + { + $stream = $this->parser->getStream(); + + // Look for deprecated {% script ... in ... %} + if (!$stream->test(Token::BLOCK_END_TYPE) && !$stream->test(Token::OPERATOR_TYPE, 'in')) { + $i = 0; + do { + $token = $stream->look(++$i); + if ($token->test(Token::BLOCK_END_TYPE)) { + break; + } + if ($token->test(Token::OPERATOR_TYPE, 'in') && $stream->look($i+1)->test(Token::STRING_TYPE)) { + user_error("Twig: Using {% script ... in ... %} is deprecated, use {% script ... at ... %} instead", E_USER_DEPRECATED); + + break; + } + } while (true); + } + + $type = null; + if ($stream->test(Token::NAME_TYPE, 'module')) { + $type = $stream->getCurrent()->getValue(); + $stream->next(); + } + + $file = null; + if (!$stream->test(Token::NAME_TYPE) && !$stream->test(Token::OPERATOR_TYPE, 'in') && !$stream->test(Token::BLOCK_END_TYPE)) { + $file = $this->parser->getExpressionParser()->parseExpression(); + } + + $group = null; + if ($stream->nextIf(Token::NAME_TYPE, 'at') || $stream->nextIf(Token::OPERATOR_TYPE, 'in')) { + $group = $this->parser->getExpressionParser()->parseExpression(); + } + + $priority = null; + if ($stream->nextIf(Token::NAME_TYPE, 'priority')) { + $stream->expect(Token::PUNCTUATION_TYPE, ':'); + $priority = $this->parser->getExpressionParser()->parseExpression(); + } + + $attributes = null; + if ($stream->nextIf(Token::NAME_TYPE, 'with')) { + $attributes = $this->parser->getExpressionParser()->parseExpression(); + } + + $stream->expect(Token::BLOCK_END_TYPE); + + return [$type, $file, $group, $priority, $attributes]; + } + + /** + * @param Token $token + * @return bool + */ + public function decideBlockEnd(Token $token): bool + { + return $token->test('endscript'); + } + + /** + * Gets the tag name associated with this token parser. + * + * @return string The tag name + */ + public function getTag(): string + { + return 'script'; + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php new file mode 100644 index 0000000..df5fb81 --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php @@ -0,0 +1,119 @@ +getLine(); + $stream = $this->parser->getStream(); + + [$file, $group, $priority, $attributes] = $this->parseArguments($token); + + $content = null; + if (!$file) { + $content = $this->parser->subparse([$this, 'decideBlockEnd'], true); + $stream->expect(Token::BLOCK_END_TYPE); + } + + return new TwigNodeStyle($content, $file, $group, $priority, $attributes, $lineno, $this->getTag()); + } + + /** + * @param Token $token + * @return array + */ + protected function parseArguments(Token $token): array + { + $stream = $this->parser->getStream(); + + // Look for deprecated {% style ... in ... %} + if (!$stream->test(Token::BLOCK_END_TYPE) && !$stream->test(Token::OPERATOR_TYPE, 'in')) { + $i = 0; + do { + $token = $stream->look(++$i); + if ($token->test(Token::BLOCK_END_TYPE)) { + break; + } + if ($token->test(Token::OPERATOR_TYPE, 'in') && $stream->look($i+1)->test(Token::STRING_TYPE)) { + user_error("Twig: Using {% style ... in ... %} is deprecated, use {% style ... at ... %} instead", E_USER_DEPRECATED); + + break; + } + } while (true); + } + + $file = null; + if (!$stream->test(Token::NAME_TYPE) && !$stream->test(Token::OPERATOR_TYPE, 'in') && !$stream->test(Token::BLOCK_END_TYPE)) { + $file = $this->parser->getExpressionParser()->parseExpression(); + } + + $group = null; + if ($stream->nextIf(Token::NAME_TYPE, 'at') || $stream->nextIf(Token::OPERATOR_TYPE, 'in')) { + $group = $this->parser->getExpressionParser()->parseExpression(); + } + + $priority = null; + if ($stream->nextIf(Token::NAME_TYPE, 'priority')) { + $stream->expect(Token::PUNCTUATION_TYPE, ':'); + $priority = $this->parser->getExpressionParser()->parseExpression(); + } + + $attributes = null; + if ($stream->nextIf(Token::NAME_TYPE, 'with')) { + $attributes = $this->parser->getExpressionParser()->parseExpression(); + } + + $stream->expect(Token::BLOCK_END_TYPE); + + return [$file, $group, $priority, $attributes]; + } + + /** + * @param Token $token + * @return bool + */ + public function decideBlockEnd(Token $token): bool + { + return $token->test('endstyle'); + } + + /** + * Gets the tag name associated with this token parser. + * + * @return string The tag name + */ + public function getTag(): string + { + return 'style'; + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php new file mode 100644 index 0000000..e5b3d40 --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php @@ -0,0 +1,132 @@ +getLine(); + $stream = $this->parser->getStream(); + + $name = $this->parser->getExpressionParser()->parseExpression(); + $stream->expect(Token::BLOCK_END_TYPE); + + // There can be some whitespace between the {% switch %} and first {% case %} tag. + while ($stream->getCurrent()->getType() === Token::TEXT_TYPE && trim($stream->getCurrent()->getValue()) === '') { + $stream->next(); + } + + $stream->expect(Token::BLOCK_START_TYPE); + + $expressionParser = $this->parser->getExpressionParser(); + + $default = null; + $cases = []; + $end = false; + + while (!$end) { + $next = $stream->next(); + + switch ($next->getValue()) { + case 'case': + $values = []; + + while (true) { + $values[] = $expressionParser->parsePrimaryExpression(); + // Multiple allowed values? + if ($stream->test(Token::OPERATOR_TYPE, 'or')) { + $stream->next(); + } else { + break; + } + } + + $stream->expect(Token::BLOCK_END_TYPE); + $body = $this->parser->subparse([$this, 'decideIfFork']); + $cases[] = new Node([ + 'values' => new Node($values), + 'body' => $body + ]); + break; + + case 'default': + $stream->expect(Token::BLOCK_END_TYPE); + $default = $this->parser->subparse([$this, 'decideIfEnd']); + break; + + case 'endswitch': + $end = true; + break; + + default: + throw new SyntaxError(sprintf('Unexpected end of template. Twig was looking for the following tags "case", "default", or "endswitch" to close the "switch" block started at line %d)', $lineno), -1); + } + } + + $stream->expect(Token::BLOCK_END_TYPE); + + return new TwigNodeSwitch($name, new Node($cases), $default, $lineno, $this->getTag()); + } + + /** + * Decide if current token marks switch logic. + * + * @param Token $token + * @return bool + */ + public function decideIfFork(Token $token): bool + { + return $token->test(['case', 'default', 'endswitch']); + } + + /** + * Decide if current token marks end of swtich block. + * + * @param Token $token + * @return bool + */ + public function decideIfEnd(Token $token): bool + { + return $token->test(['endswitch']); + } + + /** + * {@inheritdoc} + */ + public function getTag(): string + { + return 'switch'; + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserThrow.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserThrow.php new file mode 100644 index 0000000..63527ca --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserThrow.php @@ -0,0 +1,55 @@ + + * {% throw 404 'Not Found' %} + * + */ +class TwigTokenParserThrow extends AbstractTokenParser +{ + /** + * Parses a token and returns a node. + * + * @param Token $token + * @return TwigNodeThrow + * @throws SyntaxError + */ + public function parse(Token $token) + { + $lineno = $token->getLine(); + $stream = $this->parser->getStream(); + + $code = $stream->expect(Token::NUMBER_TYPE)->getValue(); + $message = $this->parser->getExpressionParser()->parseExpression(); + $stream->expect(Token::BLOCK_END_TYPE); + + return new TwigNodeThrow((int)$code, $message, $lineno, $this->getTag()); + } + + /** + * Gets the tag name associated with this token parser. + * + * @return string The tag name + */ + public function getTag(): string + { + return 'throw'; + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php new file mode 100644 index 0000000..0cce083 --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php @@ -0,0 +1,81 @@ + + * {% try %} + *
  • {{ user.get('name') }}
  • + * {% catch %} + * {{ e.message }} + * {% endcatch %} + * + */ +class TwigTokenParserTryCatch extends AbstractTokenParser +{ + /** + * Parses a token and returns a node. + * + * @param Token $token + * @return TwigNodeTryCatch + * @throws SyntaxError + */ + public function parse(Token $token) + { + $lineno = $token->getLine(); + $stream = $this->parser->getStream(); + + $stream->expect(Token::BLOCK_END_TYPE); + $try = $this->parser->subparse([$this, 'decideCatch']); + $stream->next(); + $stream->expect(Token::BLOCK_END_TYPE); + $catch = $this->parser->subparse([$this, 'decideEnd']); + $stream->next(); + $stream->expect(Token::BLOCK_END_TYPE); + + return new TwigNodeTryCatch($try, $catch, $lineno, $this->getTag()); + } + + /** + * @param Token $token + * @return bool + */ + public function decideCatch(Token $token): bool + { + return $token->test(['catch']); + } + + /** + * @param Token $token + * @return bool + */ + public function decideEnd(Token $token): bool + { + return $token->test(['endtry']) || $token->test(['endcatch']); + } + + /** + * Gets the tag name associated with this token parser. + * + * @return string The tag name + */ + public function getTag(): string + { + return 'try'; + } +} diff --git a/system/src/Grav/Common/Twig/Twig.php b/system/src/Grav/Common/Twig/Twig.php new file mode 100644 index 0000000..eabc419 --- /dev/null +++ b/system/src/Grav/Common/Twig/Twig.php @@ -0,0 +1,578 @@ +grav = $grav; + $this->twig_paths = []; + } + + /** + * Twig initialization that sets the twig loader chain, then the environment, then extensions + * and also the base set of twig vars + * + * @return $this + */ + public function init() + { + if (null === $this->twig) { + /** @var Config $config */ + $config = $this->grav['config']; + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + /** @var Language $language */ + $language = $this->grav['language']; + + $active_language = $language->getActive(); + + // handle language templates if available + if ($language->enabled()) { + $lang_templates = $locator->findResource('theme://templates/' . ($active_language ?: $language->getDefault())); + if ($lang_templates) { + $this->twig_paths[] = $lang_templates; + } + } + + $this->twig_paths = array_merge($this->twig_paths, $locator->findResources('theme://templates')); + + $this->grav->fireEvent('onTwigTemplatePaths'); + + // Add Grav core templates location + $core_templates = array_merge($locator->findResources('system://templates'), $locator->findResources('system://templates/testing')); + $this->twig_paths = array_merge($this->twig_paths, $core_templates); + + $this->loader = new FilesystemLoader($this->twig_paths); + + // Register all other prefixes as namespaces in twig + foreach ($locator->getPaths('theme') as $prefix => $_) { + if ($prefix === '') { + continue; + } + + $twig_paths = []; + + // handle language templates if available + if ($language->enabled()) { + $lang_templates = $locator->findResource('theme://'.$prefix.'templates/' . ($active_language ?: $language->getDefault())); + if ($lang_templates) { + $twig_paths[] = $lang_templates; + } + } + + $twig_paths = array_merge($twig_paths, $locator->findResources('theme://'.$prefix.'templates')); + + $namespace = trim($prefix, '/'); + $this->loader->setPaths($twig_paths, $namespace); + } + + $this->grav->fireEvent('onTwigLoader'); + + $this->loaderArray = new ArrayLoader([]); + $loader_chain = new ChainLoader([$this->loaderArray, $this->loader]); + + $params = $config->get('system.twig'); + if (!empty($params['cache'])) { + $cachePath = $locator->findResource('cache://twig', true, true); + $params['cache'] = new FilesystemCache($cachePath, FilesystemCache::FORCE_BYTECODE_INVALIDATION); + } + + if (!$config->get('system.strict_mode.twig_compat', false)) { + // Force autoescape on for all files if in strict mode. + $params['autoescape'] = 'html'; + } elseif (!empty($this->autoescape)) { + $params['autoescape'] = $this->autoescape ? 'html' : false; + } + + if (empty($params['autoescape'])) { + user_error('Grav 2.0 will have Twig auto-escaping forced on (can be emulated by turning off \'system.strict_mode.twig_compat\' setting in your configuration)', E_USER_DEPRECATED); + } + + $this->twig = new TwigEnvironment($loader_chain, $params); + + $this->twig->registerUndefinedFunctionCallback(function (string $name) use ($config) { + $allowed = $config->get('system.twig.safe_functions'); + if (is_array($allowed) && in_array($name, $allowed, true) && function_exists($name)) { + return new TwigFunction($name, $name); + } + if ($config->get('system.twig.undefined_functions')) { + if (function_exists($name)) { + if (!Utils::isDangerousFunction($name)) { + user_error("PHP function {$name}() was used as Twig function. This is deprecated in Grav 1.7. Please add it to system configuration: `system.twig.safe_functions`", E_USER_DEPRECATED); + + return new TwigFunction($name, $name); + } + + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addException(new RuntimeException("Blocked potentially dangerous PHP function {$name}() being used as Twig function. If you really want to use it, please add it to system configuration: `system.twig.safe_functions`")); + } + + return new TwigFunction($name, static function () {}); + } + + return false; + }); + + $this->twig->registerUndefinedFilterCallback(function (string $name) use ($config) { + $allowed = $config->get('system.twig.safe_filters'); + if (is_array($allowed) && in_array($name, $allowed, true) && function_exists($name)) { + return new TwigFilter($name, $name); + } + if ($config->get('system.twig.undefined_filters')) { + if (function_exists($name)) { + if (!Utils::isDangerousFunction($name)) { + user_error("PHP function {$name}() used as Twig filter. This is deprecated in Grav 1.7. Please add it to system configuration: `system.twig.safe_filters`", E_USER_DEPRECATED); + + return new TwigFilter($name, $name); + } + + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addException(new RuntimeException("Blocked potentially dangerous PHP function {$name}() being used as Twig filter. If you really want to use it, please add it to system configuration: `system.twig.safe_filters`")); + } + + return new TwigFilter($name, static function () {}); + } + + return false; + }); + + $this->grav->fireEvent('onTwigInitialized'); + + // set default date format if set in config + if ($config->get('system.pages.dateformat.long')) { + /** @var CoreExtension $extension */ + $extension = $this->twig->getExtension(CoreExtension::class); + $extension->setDateFormat($config->get('system.pages.dateformat.long')); + } + // enable the debug extension if required + if ($config->get('system.twig.debug')) { + $this->twig->addExtension(new DebugExtension()); + } + $this->twig->addExtension(new GravExtension()); + $this->twig->addExtension(new FilesystemExtension()); + $this->twig->addExtension(new DeferredExtension()); + $this->twig->addExtension(new StringLoaderExtension()); + + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addTwigProfiler($this->twig); + + $this->grav->fireEvent('onTwigExtensions'); + + /** @var Pages $pages */ + $pages = $this->grav['pages']; + + // Set some standard variables for twig + $this->twig_vars += [ + 'config' => $config, + 'system' => $config->get('system'), + 'theme' => $config->get('theme'), + 'site' => $config->get('site'), + 'uri' => $this->grav['uri'], + 'assets' => $this->grav['assets'], + 'taxonomy' => $this->grav['taxonomy'], + 'browser' => $this->grav['browser'], + 'base_dir' => GRAV_ROOT, + 'home_url' => $pages->homeUrl($active_language), + 'base_url' => $pages->baseUrl($active_language), + 'base_url_absolute' => $pages->baseUrl($active_language, true), + 'base_url_relative' => $pages->baseUrl($active_language, false), + 'base_url_simple' => $this->grav['base_url'], + 'theme_dir' => $locator->findResource('theme://'), + 'theme_url' => $this->grav['base_url'] . '/' . $locator->findResource('theme://', false), + 'html_lang' => $this->grav['language']->getActive() ?: $config->get('site.default_lang', 'en'), + 'language_codes' => new LanguageCodes, + ]; + } + + return $this; + } + + /** + * @return Environment + */ + public function twig() + { + return $this->twig; + } + + /** + * @return FilesystemLoader + */ + public function loader() + { + return $this->loader; + } + + /** + * @return Profile + */ + public function profile() + { + return $this->profile; + } + + + /** + * Adds or overrides a template. + * + * @param string $name The template name + * @param string $template The template source + */ + public function setTemplate($name, $template) + { + $this->loaderArray->setTemplate($name, $template); + } + + /** + * Twig process that renders a page item. It supports two variations: + * 1) Handles modular pages by rendering a specific page based on its modular twig template + * 2) Renders individual page items for twig processing before the site rendering + * + * @param PageInterface $item The page item to render + * @param string|null $content Optional content override + * + * @return string The rendered output + */ + public function processPage(PageInterface $item, $content = null) + { + $content = $content ?? $item->content(); + $content = Security::cleanDangerousTwig($content); + + // override the twig header vars for local resolution + $this->grav->fireEvent('onTwigPageVariables', new Event(['page' => $item])); + $twig_vars = $this->twig_vars; + + $twig_vars['page'] = $item; + $twig_vars['media'] = $item->media(); + $twig_vars['header'] = $item->header(); + $local_twig = clone $this->twig; + + $output = ''; + + try { + if ($item->isModule()) { + $twig_vars['content'] = $content; + $template = $this->getPageTwigTemplate($item); + $output = $content = $local_twig->render($template, $twig_vars); + } + + // Process in-page Twig + if ($item->shouldProcess('twig')) { + $name = '@Page:' . $item->path(); + $this->setTemplate($name, $content); + $output = $local_twig->render($name, $twig_vars); + } + + } catch (LoaderError $e) { + throw new RuntimeException($e->getRawMessage(), 400, $e); + } + + return $output; + } + + /** + * Process a Twig template directly by using a template name + * and optional array of variables + * + * @param string $template template to render with + * @param array $vars Optional variables + * + * @return string + */ + public function processTemplate($template, $vars = []) + { + // override the twig header vars for local resolution + $this->grav->fireEvent('onTwigTemplateVariables'); + $vars += $this->twig_vars; + + try { + $output = $this->twig->render($template, $vars); + } catch (LoaderError $e) { + throw new RuntimeException($e->getRawMessage(), 404, $e); + } + + return $output; + } + + + /** + * Process a Twig template directly by using a Twig string + * and optional array of variables + * + * @param string $string string to render. + * @param array $vars Optional variables + * + * @return string + */ + public function processString($string, array $vars = []) + { + // override the twig header vars for local resolution + $this->grav->fireEvent('onTwigStringVariables'); + $vars += $this->twig_vars; + + $string = Security::cleanDangerousTwig($string); + + $name = '@Var:' . $string; + $this->setTemplate($name, $string); + + try { + $output = $this->twig->render($name, $vars); + } catch (LoaderError $e) { + throw new RuntimeException($e->getRawMessage(), 404, $e); + } + + return $output; + } + + /** + * Twig process that renders the site layout. This is the main twig process that renders the overall + * page and handles all the layout for the site display. + * + * @param string|null $format Output format (defaults to HTML). + * @param array $vars + * @return string the rendered output + * @throws RuntimeException + */ + public function processSite($format = null, array $vars = []) + { + try { + $grav = $this->grav; + + // set the page now it's been processed + $grav->fireEvent('onTwigSiteVariables'); + + /** @var Pages $pages */ + $pages = $grav['pages']; + + /** @var PageInterface $page */ + $page = $grav['page']; + + $content = Security::cleanDangerousTwig($page->content()); + + $twig_vars = $this->twig_vars; + $twig_vars['theme'] = $grav['config']->get('theme'); + $twig_vars['pages'] = $pages->root(); + $twig_vars['page'] = $page; + $twig_vars['header'] = $page->header(); + $twig_vars['media'] = $page->media(); + $twig_vars['content'] = $content; + + // determine if params are set, if so disable twig cache + $params = $grav['uri']->params(null, true); + if (!empty($params)) { + $this->twig->setCache(false); + } + + // Get Twig template layout + $template = $this->getPageTwigTemplate($page, $format); + $page->templateFormat($format); + + $output = $this->twig->render($template, $vars + $twig_vars); + } catch (LoaderError $e) { + throw new RuntimeException($e->getMessage(), 400, $e); + } catch (RuntimeError $e) { + $prev = $e->getPrevious(); + if ($prev instanceof TwigException) { + $code = $prev->getCode() ?: 500; + // Fire onPageNotFound event. + $event = new Event([ + 'page' => $page, + 'code' => $code, + 'message' => $prev->getMessage(), + 'exception' => $prev, + 'route' => $grav['route'], + 'request' => $grav['request'] + ]); + $event = $grav->fireEvent("onDisplayErrorPage.{$code}", $event); + $newPage = $event['page']; + if ($newPage && $newPage !== $page) { + unset($grav['page']); + $grav['page'] = $newPage; + + return $this->processSite($newPage->templateFormat(), $vars); + } + } + + throw $e; + } + + return $output; + } + + /** + * Wraps the FilesystemLoader addPath method (should be used only in `onTwigLoader()` event + * @param string $template_path + * @param string $namespace + * @throws LoaderError + */ + public function addPath($template_path, $namespace = '__main__') + { + $this->loader->addPath($template_path, $namespace); + } + + /** + * Wraps the FilesystemLoader prependPath method (should be used only in `onTwigLoader()` event + * @param string $template_path + * @param string $namespace + * @throws LoaderError + */ + public function prependPath($template_path, $namespace = '__main__') + { + $this->loader->prependPath($template_path, $namespace); + } + + /** + * Simple helper method to get the twig template if it has already been set, else return + * the one being passed in + * NOTE: Modular pages that are injected should not use this pre-set template as it's usually set at the page level + * + * @param string $template the template name + * @return string the template name + */ + public function template(string $template): string + { + if (isset($this->template)) { + $template = $this->template; + unset($this->template); + } + + return $template; + } + + /** + * @param PageInterface $page + * @param string|null $format + * @return string + */ + public function getPageTwigTemplate($page, &$format = null) + { + $template = $page->template(); + $default = $page->isModule() ? 'modular/default' : 'default'; + $extension = $format ?: $page->templateFormat(); + $twig_extension = $extension ? '.'. $extension .TWIG_EXT : TEMPLATE_EXT; + $template_file = $this->template($template . $twig_extension); + + // TODO: no longer needed in Twig 3. + /** @var ExistsLoaderInterface $loader */ + $loader = $this->twig->getLoader(); + if ($loader->exists($template_file)) { + // template.xxx.twig + $page_template = $template_file; + } elseif ($twig_extension !== TEMPLATE_EXT && $loader->exists($template . TEMPLATE_EXT)) { + // template.html.twig + $page_template = $template . TEMPLATE_EXT; + $format = 'html'; + } elseif ($loader->exists($default . $twig_extension)) { + // default.xxx.twig + $page_template = $default . $twig_extension; + } else { + // default.html.twig + $page_template = $default . TEMPLATE_EXT; + $format = 'html'; + } + + return $page_template; + + } + + /** + * Overrides the autoescape setting + * + * @param bool $state + * @return void + * @deprecated 1.5 Auto-escape should always be turned on to protect against XSS issues (can be disabled per template file). + */ + public function setAutoescape($state) + { + if (!$state) { + user_error(__CLASS__ . '::' . __FUNCTION__ . '(false) is deprecated since Grav 1.5', E_USER_DEPRECATED); + } + + $this->autoescape = (bool) $state; + } + +} diff --git a/system/src/Grav/Common/Twig/TwigClockworkDataSource.php b/system/src/Grav/Common/Twig/TwigClockworkDataSource.php new file mode 100644 index 0000000..7d85b7e --- /dev/null +++ b/system/src/Grav/Common/Twig/TwigClockworkDataSource.php @@ -0,0 +1,58 @@ +twig = $twig; + } + + /** + * Register the Twig profiler extension + */ + public function listenToEvents(): void + { + $this->twig->addExtension(new ProfilerExtension($this->profile = new Profile())); + } + + /** + * Adds rendered views to the request + * + * @param Request $request + * @return Request + */ + public function resolve(Request $request) + { + $timeline = (new TwigClockworkDumper())->dump($this->profile); + + $request->viewsData = array_merge($request->viewsData, $timeline->finalize()); + + return $request; + } +} diff --git a/system/src/Grav/Common/Twig/TwigClockworkDumper.php b/system/src/Grav/Common/Twig/TwigClockworkDumper.php new file mode 100644 index 0000000..63c361e --- /dev/null +++ b/system/src/Grav/Common/Twig/TwigClockworkDumper.php @@ -0,0 +1,72 @@ +dumpProfile($profile, $timeline); + + return $timeline; + } + + /** + * @param Profile $profile + * @param Timeline $timeline + * @param null $parent + */ + public function dumpProfile(Profile $profile, Timeline $timeline, $parent = null) + { + $id = $this->lastId++; + + if ($profile->isRoot()) { + $name = $profile->getName(); + } elseif ($profile->isTemplate()) { + $name = $profile->getTemplate(); + } else { + $name = $profile->getTemplate() . '::' . $profile->getType() . '(' . $profile->getName() . ')'; + } + + foreach ($profile as $p) { + $this->dumpProfile($p, $timeline, $id); + } + + $data = $profile->__serialize(); + + $timeline->event($name, [ + 'name' => $id, + 'start' => $data[3]['wt'] ?? null, + 'end' => $data[4]['wt'] ?? null, + 'data' => [ + 'data' => [], + 'memoryUsage' => $data[4]['mu'] ?? null, + 'parent' => $parent + ] + ]); + } +} diff --git a/system/src/Grav/Common/Twig/TwigEnvironment.php b/system/src/Grav/Common/Twig/TwigEnvironment.php new file mode 100644 index 0000000..3ede6ef --- /dev/null +++ b/system/src/Grav/Common/Twig/TwigEnvironment.php @@ -0,0 +1,60 @@ +getLoader(); + if (!$loader->exists($name)) { + continue; + } + } + + // Throws LoaderError: Unable to find template "%s". + return $this->loadTemplate($name); + } + + throw new LoaderError(sprintf('Unable to find one of the following templates: "%s".', implode('", "', $names))); + } +} diff --git a/system/src/Grav/Common/Twig/TwigExtension.php b/system/src/Grav/Common/Twig/TwigExtension.php new file mode 100644 index 0000000..2bd1e73 --- /dev/null +++ b/system/src/Grav/Common/Twig/TwigExtension.php @@ -0,0 +1,21 @@ +get('system.twig.umask_fix', false); + } + + if (self::$umask) { + $dir = dirname($file); + if (!is_dir($dir)) { + $old = umask(0002); + Folder::create($dir); + umask($old); + } + parent::writeCacheFile($file, $content); + chmod($file, 0775); + } else { + parent::writeCacheFile($file, $content); + } + } +} diff --git a/system/src/Grav/Common/Uri.php b/system/src/Grav/Common/Uri.php new file mode 100644 index 0000000..ccf6118 --- /dev/null +++ b/system/src/Grav/Common/Uri.php @@ -0,0 +1,1527 @@ +createFromString($env); + } else { + $this->createFromEnvironment(is_array($env) ? $env : $_SERVER); + } + } + + /** + * Initialize the URI class with a url passed via parameter. + * Used for testing purposes. + * + * @param string $url the URL to use in the class + * @return $this + */ + public function initializeWithUrl($url = '') + { + if ($url) { + $this->createFromString($url); + } + + return $this; + } + + /** + * Initialize the URI class by providing url and root_path arguments + * + * @param string $url + * @param string $root_path + * @return $this + */ + public function initializeWithUrlAndRootPath($url, $root_path) + { + $this->initializeWithUrl($url); + $this->root_path = $root_path; + + return $this; + } + + /** + * Validate a hostname + * + * @param string $hostname The hostname + * @return bool + */ + public function validateHostname($hostname) + { + return (bool)preg_match(static::HOSTNAME_REGEX, $hostname); + } + + /** + * Initializes the URI object based on the url set on the object + * + * @return void + */ + public function init() + { + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + /** @var Language $language */ + $language = $grav['language']; + + // add the port to the base for non-standard ports + if ($this->port && $config->get('system.reverse_proxy_setup') === false) { + $this->base .= ':' . $this->port; + } + + // Handle custom base + $custom_base = rtrim($grav['config']->get('system.custom_base_url', ''), '/'); + if ($custom_base) { + $custom_parts = parse_url($custom_base); + if ($custom_parts === false) { + throw new RuntimeException('Bad configuration: system.custom_base_url'); + } + $orig_root_path = $this->root_path; + $this->root_path = isset($custom_parts['path']) ? rtrim($custom_parts['path'], '/') : ''; + if (isset($custom_parts['scheme'])) { + $this->base = $custom_parts['scheme'] . '://' . $custom_parts['host']; + $this->port = $custom_parts['port'] ?? null; + if ($this->port && $config->get('system.reverse_proxy_setup') === false) { + $this->base .= ':' . $this->port; + } + $this->root = $custom_base; + } else { + $this->root = $this->base . $this->root_path; + } + $this->uri = Utils::replaceFirstOccurrence($orig_root_path, $this->root_path, $this->uri); + } else { + $this->root = $this->base . $this->root_path; + } + + $this->url = $this->base . $this->uri; + + $uri = Utils::replaceFirstOccurrence(static::filterPath($this->root), '', $this->url); + + // remove the setup.php based base if set: + $setup_base = $grav['pages']->base(); + if ($setup_base) { + $uri = preg_replace('|^' . preg_quote($setup_base, '|') . '|', '', $uri); + } + $this->setup_base = $setup_base; + + // process params + $uri = $this->processParams($uri, $config->get('system.param_sep')); + + // set active language + $uri = $language->setActiveFromUri($uri); + + // split the URL and params (and make sure that the path isn't seen as domain) + $bits = static::parseUrl('http://domain.com' . $uri); + + //process fragment + if (isset($bits['fragment'])) { + $this->fragment = $bits['fragment']; + } + + // Get the path. If there's no path, make sure pathinfo() still returns dirname variable + $path = $bits['path'] ?? '/'; + + // remove the extension if there is one set + $parts = Utils::pathinfo($path); + + // set the original basename + $this->basename = $parts['basename']; + + // set the extension + if (isset($parts['extension'])) { + $this->extension = $parts['extension']; + } + + // Strip the file extension for valid page types + if ($this->isValidExtension($this->extension)) { + $path = Utils::replaceLastOccurrence(".{$this->extension}", '', $path); + } + + // set the new url + $this->url = $this->root . $path; + $this->path = static::cleanPath($path); + $this->content_path = trim(Utils::replaceFirstOccurrence($this->base, '', $this->path), '/'); + if ($this->content_path !== '') { + $this->paths = explode('/', $this->content_path); + } + + // Set some Grav stuff + $grav['base_url_absolute'] = $config->get('system.custom_base_url') ?: $this->rootUrl(true); + $grav['base_url_relative'] = $this->rootUrl(false); + $grav['base_url'] = $config->get('system.absolute_urls') ? $grav['base_url_absolute'] : $grav['base_url_relative']; + + RouteFactory::setRoot($this->root_path . $setup_base); + RouteFactory::setLanguage($language->getLanguageURLPrefix()); + RouteFactory::setParamValueDelimiter($config->get('system.param_sep')); + } + + /** + * Return URI path. + * + * @param int|null $id + * @return string|string[] + */ + public function paths($id = null) + { + if ($id !== null) { + return $this->paths[$id]; + } + + return $this->paths; + } + + + /** + * Return route to the current URI. By default route doesn't include base path. + * + * @param bool $absolute True to include full path. + * @param bool $domain True to include domain. Works only if first parameter is also true. + * @return string + */ + public function route($absolute = false, $domain = false) + { + return ($absolute ? $this->rootUrl($domain) : '') . '/' . implode('/', $this->paths); + } + + /** + * Return full query string or a single query attribute. + * + * @param string|null $id Optional attribute. Get a single query attribute if set + * @param bool $raw If true and $id is not set, return the full query array. Otherwise return the query string + * + * @return string|array Returns an array if $id = null and $raw = true + */ + public function query($id = null, $raw = false) + { + if ($id !== null) { + return $this->queries[$id] ?? null; + } + + if ($raw) { + return $this->queries; + } + + if (!$this->queries) { + return ''; + } + + return http_build_query($this->queries); + } + + /** + * Return all or a single query parameter as a URI compatible string. + * + * @param string|null $id Optional parameter name. + * @param boolean $array return the array format or not + * @return null|string|array + */ + public function params($id = null, $array = false) + { + $config = Grav::instance()['config']; + $sep = $config->get('system.param_sep'); + + $params = null; + if ($id === null) { + if ($array) { + return $this->params; + } + $output = []; + foreach ($this->params as $key => $value) { + $output[] = "{$key}{$sep}{$value}"; + $params = '/' . implode('/', $output); + } + } elseif (isset($this->params[$id])) { + if ($array) { + return $this->params[$id]; + } + $params = "/{$id}{$sep}{$this->params[$id]}"; + } + + return $params; + } + + /** + * Get URI parameter. + * + * @param string $id + * @param string|false|null $default + * @return string|false|null + */ + public function param($id, $default = false) + { + if (isset($this->params[$id])) { + return html_entity_decode(rawurldecode($this->params[$id]), ENT_COMPAT | ENT_HTML401, 'UTF-8'); + } + + return $default; + } + + /** + * Gets the Fragment portion of a URI (eg #target) + * + * @param string|null $fragment + * @return string|null + */ + public function fragment($fragment = null) + { + if ($fragment !== null) { + $this->fragment = $fragment; + } + return $this->fragment; + } + + /** + * Return URL. + * + * @param bool $include_host Include hostname. + * @return string + */ + public function url($include_host = false) + { + if ($include_host) { + return $this->url; + } + + $url = Utils::replaceFirstOccurrence($this->base, '', rtrim($this->url, '/')); + + return $url ?: '/'; + } + + /** + * Return the Path + * + * @return string The path of the URI + */ + public function path() + { + return $this->path; + } + + /** + * Return the Extension of the URI + * + * @param string|null $default + * @return string|null The extension of the URI + */ + public function extension($default = null) + { + if (!$this->extension) { + $this->extension = $default; + } + + return $this->extension; + } + + /** + * @return string + */ + public function method() + { + $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper($_SERVER['REQUEST_METHOD']) : 'GET'; + + if ($method === 'POST' && isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) { + $method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']); + } + + return $method; + } + + /** + * Return the scheme of the URI + * + * @param bool|null $raw + * @return string The scheme of the URI + */ + public function scheme($raw = false) + { + if (!$raw) { + $scheme = ''; + if ($this->scheme) { + $scheme = $this->scheme . '://'; + } elseif ($this->host) { + $scheme = '//'; + } + + return $scheme; + } + + return $this->scheme; + } + + + /** + * Return the host of the URI + * + * @return string|null The host of the URI + */ + public function host() + { + return $this->host; + } + + /** + * Return the port number if it can be figured out + * + * @param bool $raw + * @return int|null + */ + public function port($raw = false) + { + $port = $this->port; + // If not in raw mode and port is not set or is 0, figure it out from scheme. + if (!$raw && !$port) { + if ($this->scheme === 'http') { + $this->port = 80; + } elseif ($this->scheme === 'https') { + $this->port = 443; + } + } + + return $this->port ?: null; + } + + /** + * Return user + * + * @return string|null + */ + public function user() + { + return $this->user; + } + + /** + * Return password + * + * @return string|null + */ + public function password() + { + return $this->password; + } + + /** + * Gets the environment name + * + * @return string + */ + public function environment() + { + return $this->env; + } + + + /** + * Return the basename of the URI + * + * @return string The basename of the URI + */ + public function basename() + { + return $this->basename; + } + + /** + * Return the full uri + * + * @param bool $include_root + * @return string + */ + public function uri($include_root = true) + { + if ($include_root) { + return $this->uri; + } + + return Utils::replaceFirstOccurrence($this->root_path, '', $this->uri); + } + + /** + * Return the base of the URI + * + * @return string The base of the URI + */ + public function base() + { + return $this->base; + } + + /** + * Return the base relative URL including the language prefix + * or the base relative url if multi-language is not enabled + * + * @return string The base of the URI + */ + public function baseIncludingLanguage() + { + $grav = Grav::instance(); + + /** @var Pages $pages */ + $pages = $grav['pages']; + + return $pages->baseUrl(null, false); + } + + /** + * Return root URL to the site. + * + * @param bool $include_host Include hostname. + * @return string + */ + public function rootUrl($include_host = false) + { + if ($include_host) { + return $this->root; + } + + return Utils::replaceFirstOccurrence($this->base, '', $this->root); + } + + /** + * Return current page number. + * + * @return int + */ + public function currentPage() + { + $page = (int)($this->params['page'] ?? 1); + + return max(1, $page); + } + + /** + * Return relative path to the referrer defaulting to current or given page. + * + * You should set the third parameter to `true` for redirects as long as you came from the same sub-site and language. + * + * @param string|null $default + * @param string|null $attributes + * @param bool $withoutBaseRoute + * @return string + */ + public function referrer($default = null, $attributes = null, bool $withoutBaseRoute = false) + { + $referrer = $_SERVER['HTTP_REFERER'] ?? null; + + // Check that referrer came from our site. + if ($withoutBaseRoute) { + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + $base = $pages->baseUrl(null, true); + } else { + $base = $this->rootUrl(true); + } + + // Referrer should always have host set and it should come from the same base address. + if (!is_string($referrer) || !str_starts_with($referrer, $base)) { + $referrer = $default ?: $this->route(true, true); + } + + // Relative path from grav root. + $referrer = substr($referrer, strlen($base)); + if ($attributes) { + $referrer .= $attributes; + } + + return $referrer; + } + + /** + * @return string + */ + #[\ReturnTypeWillChange] + public function __toString() + { + return static::buildUrl($this->toArray()); + } + + /** + * @return string + */ + public function toOriginalString() + { + return static::buildUrl($this->toArray(true)); + } + + /** + * @param bool $full + * @return array + */ + public function toArray($full = false) + { + if ($full === true) { + $root_path = $this->root_path ?? ''; + $extension = isset($this->extension) && $this->isValidExtension($this->extension) ? '.' . $this->extension : ''; + $path = $root_path . $this->path . $extension; + } else { + $path = $this->path; + } + + return [ + 'scheme' => $this->scheme, + 'host' => $this->host, + 'port' => $this->port ?: null, + 'user' => $this->user, + 'pass' => $this->password, + 'path' => $path, + 'params' => $this->params, + 'query' => $this->query, + 'fragment' => $this->fragment + ]; + } + + /** + * Calculate the parameter regex based on the param_sep setting + * + * @return string + */ + public static function paramsRegex() + { + return '/\/{1,}([^\:\#\/\?]*' . Grav::instance()['config']->get('system.param_sep') . '[^\:\#\/\?]*)/'; + } + + /** + * Return the IP address of the current user + * + * @return string ip address + */ + public static function ip() + { + $ip = 'UNKNOWN'; + + if (getenv('HTTP_CLIENT_IP')) { + $ip = getenv('HTTP_CLIENT_IP'); + } elseif (getenv('HTTP_CF_CONNECTING_IP')) { + $ip = getenv('HTTP_CF_CONNECTING_IP'); + } elseif (getenv('HTTP_X_FORWARDED_FOR') && Grav::instance()['config']->get('system.http_x_forwarded.ip')) { + $ips = array_map('trim', explode(',', getenv('HTTP_X_FORWARDED_FOR'))); + $ip = array_shift($ips); + } elseif (getenv('HTTP_X_FORWARDED') && Grav::instance()['config']->get('system.http_x_forwarded.ip')) { + $ip = getenv('HTTP_X_FORWARDED'); + } elseif (getenv('HTTP_FORWARDED_FOR')) { + $ip = getenv('HTTP_FORWARDED_FOR'); + } elseif (getenv('HTTP_FORWARDED')) { + $ip = getenv('HTTP_FORWARDED'); + } elseif (getenv('REMOTE_ADDR')) { + $ip = getenv('REMOTE_ADDR'); + } + + return $ip; + } + + /** + * Returns current Uri. + * + * @return \Grav\Framework\Uri\Uri + */ + public static function getCurrentUri() + { + if (!static::$currentUri) { + static::$currentUri = UriFactory::createFromEnvironment($_SERVER); + } + + return static::$currentUri; + } + + /** + * Returns current route. + * + * @return Route + */ + public static function getCurrentRoute() + { + if (!static::$currentRoute) { + /** @var Uri $uri */ + $uri = Grav::instance()['uri']; + + static::$currentRoute = RouteFactory::createFromLegacyUri($uri); + } + + return static::$currentRoute; + } + + /** + * Is this an external URL? if it starts with `http` then yes, else false + * + * @param string $url the URL in question + * @return bool is eternal state + */ + public static function isExternal($url) + { + return (0 === strpos($url, 'http://') || 0 === strpos($url, 'https://') || 0 === strpos($url, '//') || 0 === strpos($url, 'mailto:') || 0 === strpos($url, 'tel:') || 0 === strpos($url, 'ftp://') || 0 === strpos($url, 'ftps://') || 0 === strpos($url, 'news:') || 0 === strpos($url, 'irc:') || 0 === strpos($url, 'gopher:') || 0 === strpos($url, 'nntp:') || 0 === strpos($url, 'feed:') || 0 === strpos($url, 'cvs:') || 0 === strpos($url, 'ssh:') || 0 === strpos($url, 'git:') || 0 === strpos($url, 'svn:') || 0 === strpos($url, 'hg:')); + } + + /** + * The opposite of built-in PHP method parse_url() + * + * @param array $parsed_url + * @return string + */ + public static function buildUrl($parsed_url) + { + $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . ':' : ''; + $authority = isset($parsed_url['host']) ? '//' : ''; + $host = $parsed_url['host'] ?? ''; + $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''; + $user = $parsed_url['user'] ?? ''; + $pass = isset($parsed_url['pass']) ? ':' . $parsed_url['pass'] : ''; + $pass = ($user || $pass) ? "{$pass}@" : ''; + $path = $parsed_url['path'] ?? ''; + $path = !empty($parsed_url['params']) ? rtrim($path, '/') . static::buildParams($parsed_url['params']) : $path; + $query = !empty($parsed_url['query']) ? '?' . $parsed_url['query'] : ''; + $fragment = isset($parsed_url['fragment']) ? '#' . $parsed_url['fragment'] : ''; + + return "{$scheme}{$authority}{$user}{$pass}{$host}{$port}{$path}{$query}{$fragment}"; + } + + /** + * @param array $params + * @return string + */ + public static function buildParams(array $params) + { + if (!$params) { + return ''; + } + + $grav = Grav::instance(); + $sep = $grav['config']->get('system.param_sep'); + + $output = []; + foreach ($params as $key => $value) { + $output[] = "{$key}{$sep}{$value}"; + } + + return '/' . implode('/', $output); + } + + /** + * Converts links from absolute '/' or relative (../..) to a Grav friendly format + * + * @param PageInterface $page the current page to use as reference + * @param string|array $url the URL as it was written in the markdown + * @param string $type the type of URL, image | link + * @param bool $absolute if null, will use system default, if true will use absolute links internally + * @param bool $route_only only return the route, not full URL path + * @return string|array the more friendly formatted url + */ + public static function convertUrl(PageInterface $page, $url, $type = 'link', $absolute = false, $route_only = false) + { + $grav = Grav::instance(); + + $uri = $grav['uri']; + + // Link processing should prepend language + $language = $grav['language']; + $language_append = ''; + if ($type === 'link' && $language->enabled()) { + $language_append = $language->getLanguageURLPrefix(); + } + + // Handle Excerpt style $url array + $url_path = is_array($url) ? $url['path'] : $url; + + $external = false; + $base = $grav['base_url_relative']; + $base_url = rtrim($base . $grav['pages']->base(), '/') . $language_append; + $pages_dir = $grav['locator']->findResource('page://'); + + // if absolute and starts with a base_url move on + if (isset($url['scheme']) && Utils::startsWith($url['scheme'], 'http')) { + $external = true; + } elseif ($url_path === '' && isset($url['fragment'])) { + $external = true; + } elseif ($url_path === '/' || ($base_url !== '' && Utils::startsWith($url_path, $base_url))) { + $url_path = $base_url . $url_path; + } else { + // see if page is relative to this or absolute + if (Utils::startsWith($url_path, '/')) { + $normalized_url = Utils::normalizePath($base_url . $url_path); + $normalized_path = Utils::normalizePath($pages_dir . $url_path); + } else { + $page_route = ($page->home() && !empty($url_path)) ? $page->rawRoute() : $page->route(); + $normalized_url = $base_url . Utils::normalizePath(rtrim($page_route, '/') . '/' . $url_path); + $normalized_path = Utils::normalizePath($page->path() . '/' . $url_path); + } + + // special check to see if path checking is required. + $just_path = Utils::replaceFirstOccurrence($normalized_url, '', $normalized_path); + if ($normalized_url === '/' || $just_path === $page->path()) { + $url_path = $normalized_url; + } else { + $url_bits = static::parseUrl($normalized_path); + $full_path = $url_bits['path']; + $raw_full_path = rawurldecode($full_path); + + if (file_exists($raw_full_path)) { + $full_path = $raw_full_path; + } elseif (!file_exists($full_path)) { + $full_path = false; + } + + if ($full_path) { + $path_info = Utils::pathinfo($full_path); + $page_path = $path_info['dirname']; + $filename = ''; + + if ($url_path === '..') { + $page_path = $full_path; + } else { + // save the filename if a file is part of the path + if (is_file($full_path)) { + if ($path_info['extension'] !== 'md') { + $filename = '/' . $path_info['basename']; + } + } else { + $page_path = $full_path; + } + } + + // get page instances and try to find one that fits + $instances = $grav['pages']->instances(); + if (isset($instances[$page_path])) { + /** @var PageInterface $target */ + $target = $instances[$page_path]; + $url_bits['path'] = $base_url . rtrim($target->route(), '/') . $filename; + + $url_path = Uri::buildUrl($url_bits); + } else { + $url_path = $normalized_url; + } + } else { + $url_path = $normalized_url; + } + } + } + + // handle absolute URLs + if (is_array($url) && !$external && ($absolute === true || $grav['config']->get('system.absolute_urls', false))) { + $url['scheme'] = $uri->scheme(true); + $url['host'] = $uri->host(); + $url['port'] = $uri->port(true); + + // check if page exists for this route, and if so, check if it has SSL enabled + $pages = $grav['pages']; + $routes = $pages->routes(); + + // if this is an image, get the proper path + $url_bits = Utils::pathinfo($url_path); + if (isset($url_bits['extension'])) { + $target_path = $url_bits['dirname']; + } else { + $target_path = $url_path; + } + + // strip base from this path + $target_path = Utils::replaceFirstOccurrence($uri->rootUrl(), '', $target_path); + + // set to / if root + if (empty($target_path)) { + $target_path = '/'; + } + + // look to see if this page exists and has ssl enabled + if (isset($routes[$target_path])) { + $target_page = $pages->get($routes[$target_path]); + if ($target_page) { + $ssl_enabled = $target_page->ssl(); + if ($ssl_enabled !== null) { + if ($ssl_enabled) { + $url['scheme'] = 'https'; + } else { + $url['scheme'] = 'http'; + } + } + } + } + } + + // Handle route only + if ($route_only) { + $url_path = Utils::replaceFirstOccurrence(static::filterPath($base_url), '', $url_path); + } + + // transform back to string/array as needed + if (is_array($url)) { + $url['path'] = $url_path; + } else { + $url = $url_path; + } + + return $url; + } + + /** + * @param string $url + * @return array|false + */ + public static function parseUrl($url) + { + $grav = Grav::instance(); + + // Remove extra slash from streams, parse_url() doesn't like it. + $url = preg_replace('/([^:])(\/{2,})/', '$1/', $url); + + $encodedUrl = preg_replace_callback( + '%[^:/@?&=#]+%usD', + static function ($matches) { + return rawurlencode($matches[0]); + }, + $url + ); + + $parts = parse_url($encodedUrl); + + if (false === $parts) { + return false; + } + + foreach ($parts as $name => $value) { + $parts[$name] = rawurldecode($value); + } + + if (!isset($parts['path'])) { + $parts['path'] = ''; + } + + [$stripped_path, $params] = static::extractParams($parts['path'], $grav['config']->get('system.param_sep')); + + if (!empty($params)) { + $parts['path'] = $stripped_path; + $parts['params'] = $params; + } + + return $parts; + } + + /** + * @param string $uri + * @param string $delimiter + * @return array + */ + public static function extractParams($uri, $delimiter) + { + $params = []; + + if (strpos($uri, $delimiter) !== false) { + preg_match_all(static::paramsRegex(), $uri, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + $param = explode($delimiter, $match[1]); + if (count($param) === 2) { + $plain_var = htmlspecialchars(strip_tags(rawurldecode($param[1])), ENT_QUOTES, 'UTF-8'); + $params[$param[0]] = $plain_var; + $uri = str_replace($match[0], '', $uri); + } + } + } + + return [$uri, $params]; + } + + /** + * Converts links from absolute '/' or relative (../..) to a Grav friendly format + * + * @param PageInterface $page the current page to use as reference + * @param string $markdown_url the URL as it was written in the markdown + * @param string $type the type of URL, image | link + * @param bool|null $relative if null, will use system default, if true will use relative links internally + * + * @return string the more friendly formatted url + */ + public static function convertUrlOld(PageInterface $page, $markdown_url, $type = 'link', $relative = null) + { + $grav = Grav::instance(); + + $language = $grav['language']; + + // Link processing should prepend language + $language_append = ''; + if ($type === 'link' && $language->enabled()) { + $language_append = $language->getLanguageURLPrefix(); + } + $pages_dir = $grav['locator']->findResource('page://'); + if ($relative === null) { + $base = $grav['base_url']; + } else { + $base = $relative ? $grav['base_url_relative'] : $grav['base_url_absolute']; + } + + $base_url = rtrim($base . $grav['pages']->base(), '/') . $language_append; + + // if absolute and starts with a base_url move on + if (Utils::pathinfo($markdown_url, PATHINFO_DIRNAME) === '.' && $page->url() === '/') { + return '/' . $markdown_url; + } + // no path to convert + if ($base_url !== '' && Utils::startsWith($markdown_url, $base_url)) { + return $markdown_url; + } + // if contains only a fragment + if (Utils::startsWith($markdown_url, '#')) { + return $markdown_url; + } + + $target = null; + // see if page is relative to this or absolute + if (Utils::startsWith($markdown_url, '/')) { + $normalized_url = Utils::normalizePath($base_url . $markdown_url); + $normalized_path = Utils::normalizePath($pages_dir . $markdown_url); + } else { + $normalized_url = $base_url . Utils::normalizePath($page->route() . '/' . $markdown_url); + $normalized_path = Utils::normalizePath($page->path() . '/' . $markdown_url); + } + + // special check to see if path checking is required. + $just_path = Utils::replaceFirstOccurrence($normalized_url, '', $normalized_path); + if ($just_path === $page->path()) { + return $normalized_url; + } + + $url_bits = parse_url($normalized_path); + $full_path = $url_bits['path']; + + if (file_exists($full_path)) { + // do nothing + } elseif (file_exists(rawurldecode($full_path))) { + $full_path = rawurldecode($full_path); + } else { + return $normalized_url; + } + + $path_info = Utils::pathinfo($full_path); + $page_path = $path_info['dirname']; + $filename = ''; + + if ($markdown_url === '..') { + $page_path = $full_path; + } else { + // save the filename if a file is part of the path + if (is_file($full_path)) { + if ($path_info['extension'] !== 'md') { + $filename = '/' . $path_info['basename']; + } + } else { + $page_path = $full_path; + } + } + + // get page instances and try to find one that fits + $instances = $grav['pages']->instances(); + if (isset($instances[$page_path])) { + /** @var PageInterface $target */ + $target = $instances[$page_path]; + $url_bits['path'] = $base_url . rtrim($target->route(), '/') . $filename; + + return static::buildUrl($url_bits); + } + + return $normalized_url; + } + + /** + * Adds the nonce to a URL for a specific action + * + * @param string $url the url + * @param string $action the action + * @param string $nonceParamName the param name to use + * + * @return string the url with the nonce + */ + public static function addNonce($url, $action, $nonceParamName = 'nonce') + { + $fake = $url && strpos($url, '/') === 0; + + if ($fake) { + $url = 'http://domain.com' . $url; + } + $uri = new static($url); + $parts = $uri->toArray(); + $nonce = Utils::getNonce($action); + $parts['params'] = ($parts['params'] ?? []) + [$nonceParamName => $nonce]; + + if ($fake) { + unset($parts['scheme'], $parts['host']); + } + + return static::buildUrl($parts); + } + + /** + * Is the passed in URL a valid URL? + * + * @param string $url + * @return bool + */ + public static function isValidUrl($url) + { + $regex = '/^(?:(https?|ftp|telnet):)?\/\/((?:[a-z0-9@:.-]|%[0-9A-F]{2}){3,})(?::(\d+))?((?:\/(?:[a-z0-9-._~!$&\'\(\)\*\+\,\;\=\:\@]|%[0-9A-F]{2})*)*)(?:\?((?:[a-z0-9-._~!$&\'\(\)\*\+\,\;\=\:\/?@]|%[0-9A-F]{2})*))?(?:#((?:[a-z0-9-._~!$&\'\(\)\*\+\,\;\=\:\/?@]|%[0-9A-F]{2})*))?/'; + + return (bool)preg_match($regex, $url); + } + + /** + * Removes extra double slashes and fixes back-slashes + * + * @param string $path + * @return string + */ + public static function cleanPath($path) + { + $regex = '/(\/)\/+/'; + $path = str_replace(['\\', '/ /'], '/', $path); + $path = preg_replace($regex, '$1', $path); + + return $path; + } + + /** + * Filters the user info string. + * + * @param string|null $info The raw user or password. + * @return string The percent-encoded user or password string. + */ + public static function filterUserInfo($info) + { + return $info !== null ? UriPartsFilter::filterUserInfo($info) : ''; + } + + /** + * Filter Uri path. + * + * This method percent-encodes all reserved + * characters in the provided path string. This method + * will NOT double-encode characters that are already + * percent-encoded. + * + * @param string|null $path The raw uri path. + * @return string The RFC 3986 percent-encoded uri path. + * @link http://www.faqs.org/rfcs/rfc3986.html + */ + public static function filterPath($path) + { + return $path !== null ? UriPartsFilter::filterPath($path) : ''; + } + + /** + * Filters the query string or fragment of a URI. + * + * @param string|null $query The raw uri query string. + * @return string The percent-encoded query string. + */ + public static function filterQuery($query) + { + return $query !== null ? UriPartsFilter::filterQueryOrFragment($query) : ''; + } + + /** + * @param array $env + * @return void + */ + protected function createFromEnvironment(array $env) + { + // Build scheme. + if (isset($env['HTTP_X_FORWARDED_PROTO']) && Grav::instance()['config']->get('system.http_x_forwarded.protocol')) { + $this->scheme = $env['HTTP_X_FORWARDED_PROTO']; + } elseif (isset($env['X-FORWARDED-PROTO'])) { + $this->scheme = $env['X-FORWARDED-PROTO']; + } elseif (isset($env['HTTP_CLOUDFRONT_FORWARDED_PROTO'])) { + $this->scheme = $env['HTTP_CLOUDFRONT_FORWARDED_PROTO']; + } elseif (isset($env['REQUEST_SCHEME']) && empty($env['HTTPS'])) { + $this->scheme = $env['REQUEST_SCHEME']; + } else { + $https = $env['HTTPS'] ?? ''; + $this->scheme = (empty($https) || strtolower($https) === 'off') ? 'http' : 'https'; + } + + // Build user and password. + $this->user = $env['PHP_AUTH_USER'] ?? null; + $this->password = $env['PHP_AUTH_PW'] ?? null; + + // Build host. + if (isset($env['HTTP_X_FORWARDED_HOST']) && Grav::instance()['config']->get('system.http_x_forwarded.host')) { + $hostname = $env['HTTP_X_FORWARDED_HOST']; + } else if (isset($env['HTTP_HOST'])) { + $hostname = $env['HTTP_HOST']; + } elseif (isset($env['SERVER_NAME'])) { + $hostname = $env['SERVER_NAME']; + } else { + $hostname = 'localhost'; + } + // Remove port from HTTP_HOST generated $hostname + $hostname = Utils::substrToString($hostname, ':'); + // Validate the hostname + $this->host = $this->validateHostname($hostname) ? $hostname : 'unknown'; + + // Build port. + if (isset($env['HTTP_X_FORWARDED_PORT']) && Grav::instance()['config']->get('system.http_x_forwarded.port')) { + $this->port = (int)$env['HTTP_X_FORWARDED_PORT']; + } elseif (isset($env['X-FORWARDED-PORT'])) { + $this->port = (int)$env['X-FORWARDED-PORT']; + } elseif (isset($env['HTTP_CLOUDFRONT_FORWARDED_PROTO'])) { + // Since AWS Cloudfront does not provide a forwarded port header, + // we have to build the port using the scheme. + $this->port = $this->port(); + } elseif (isset($env['SERVER_PORT'])) { + $this->port = (int)$env['SERVER_PORT']; + } else { + $this->port = null; + } + + if ($this->port === 0 || $this->hasStandardPort()) { + $this->port = null; + } + + // Build path. + $request_uri = $env['REQUEST_URI'] ?? '/'; + $this->path = rawurldecode(parse_url('http://example.com' . $request_uri, PHP_URL_PATH)); + + // Build query string. + $this->query = $env['QUERY_STRING'] ?? ''; + if ($this->query === '') { + $this->query = parse_url('http://example.com' . $request_uri, PHP_URL_QUERY) ?? ''; + } + + // Support ngnix routes. + if (strpos($this->query, '_url=') === 0) { + parse_str($this->query, $query); + unset($query['_url']); + $this->query = http_build_query($query); + } + + // Build fragment. + $this->fragment = null; + + // Filter userinfo, path and query string. + $this->user = $this->user !== null ? static::filterUserInfo($this->user) : null; + $this->password = $this->password !== null ? static::filterUserInfo($this->password) : null; + $this->path = empty($this->path) ? '/' : static::filterPath($this->path); + $this->query = static::filterQuery($this->query); + + $this->reset(); + } + + /** + * Does this Uri use a standard port? + * + * @return bool + */ + protected function hasStandardPort() + { + return (!$this->port || $this->port === 80 || $this->port === 443); + } + + /** + * @param string $url + */ + protected function createFromString($url) + { + // Set Uri parts. + $parts = parse_url($url); + if ($parts === false) { + throw new RuntimeException('Malformed URL: ' . $url); + } + $port = (int)($parts['port'] ?? 0); + + $this->scheme = $parts['scheme'] ?? null; + $this->user = $parts['user'] ?? null; + $this->password = $parts['pass'] ?? null; + $this->host = $parts['host'] ?? null; + $this->port = $port ?: null; + $this->path = $parts['path'] ?? ''; + $this->query = $parts['query'] ?? ''; + $this->fragment = $parts['fragment'] ?? null; + + // Validate the hostname + if ($this->host) { + $this->host = $this->validateHostname($this->host) ? $this->host : 'unknown'; + } + // Filter userinfo, path, query string and fragment. + $this->user = $this->user !== null ? static::filterUserInfo($this->user) : null; + $this->password = $this->password !== null ? static::filterUserInfo($this->password) : null; + $this->path = empty($this->path) ? '/' : static::filterPath($this->path); + $this->query = static::filterQuery($this->query); + $this->fragment = $this->fragment !== null ? static::filterQuery($this->fragment) : null; + + $this->reset(); + } + + /** + * @return void + */ + protected function reset() + { + // resets + parse_str($this->query, $this->queries); + $this->extension = null; + $this->basename = null; + $this->paths = []; + $this->params = []; + $this->env = $this->buildEnvironment(); + $this->uri = $this->path . (!empty($this->query) ? '?' . $this->query : ''); + + $this->base = $this->buildBaseUrl(); + $this->root_path = $this->buildRootPath(); + $this->root = $this->base . $this->root_path; + $this->url = $this->base . $this->uri; + } + + /** + * Get post from either $_POST or JSON response object + * By default returns all data, or can return a single item + * + * @param string|null $element + * @param string|null $filter_type + * @return array|null + */ + public function post($element = null, $filter_type = null) + { + if (!$this->post) { + $content_type = $this->getContentType(); + if ($content_type === 'application/json') { + $json = file_get_contents('php://input'); + $this->post = json_decode($json, true); + } elseif (!empty($_POST)) { + $this->post = (array)$_POST; + } + + $event = new Event(['post' => &$this->post]); + Grav::instance()->fireEvent('onHttpPostFilter', $event); + } + + if ($this->post && null !== $element) { + $item = Utils::getDotNotation($this->post, $element); + if ($filter_type) { + if ($filter_type === FILTER_SANITIZE_STRING || $filter_type === GRAV_SANITIZE_STRING) { + $item = htmlspecialchars(strip_tags($item), ENT_QUOTES, 'UTF-8'); + } else { + $item = filter_var($item, $filter_type); + } + } + return $item; + } + + return $this->post; + } + + /** + * Get content type from request + * + * @param bool $short + * @return null|string + */ + public function getContentType($short = true) + { + $content_type = $_SERVER['CONTENT_TYPE'] ?? $_SERVER['HTTP_CONTENT_TYPE'] ?? $_SERVER['HTTP_ACCEPT'] ?? null; + if ($content_type) { + if ($short) { + return Utils::substrToString($content_type, ';'); + } + } + return $content_type; + } + + /** + * Check if this is a valid Grav extension + * + * @param string|null $extension + * @return bool + */ + public function isValidExtension($extension): bool + { + $extension = (string)$extension; + + return $extension !== '' && in_array($extension, Utils::getSupportPageTypes(), true); + } + + /** + * Allow overriding of any element (be careful!) + * + * @param array $data + * @return Uri + */ + public function setUriProperties($data) + { + foreach (get_object_vars($this) as $property => $default) { + if (!array_key_exists($property, $data)) { + continue; + } + $this->{$property} = $data[$property]; // assign value to object + } + return $this; + } + + + /** + * Compatibility in case getallheaders() is not available on platform + */ + public static function getAllHeaders() + { + if (!function_exists('getallheaders')) { + $headers = []; + foreach ($_SERVER as $name => $value) { + if (substr($name, 0, 5) == 'HTTP_') { + $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value; + } + } + return $headers; + } + return getallheaders(); + } + + /** + * Get the base URI with port if needed + * + * @return string + */ + private function buildBaseUrl() + { + return $this->scheme() . $this->host; + } + + /** + * Get the Grav Root Path + * + * @return string + */ + private function buildRootPath() + { + // In Windows script path uses backslash, convert it: + $scriptPath = str_replace('\\', '/', $_SERVER['PHP_SELF']); + $rootPath = str_replace(' ', '%20', rtrim(substr($scriptPath, 0, strpos($scriptPath, 'index.php')), '/')); + + return $rootPath; + } + + /** + * @return string + */ + private function buildEnvironment() + { + // check for localhost variations + if ($this->host === '127.0.0.1' || $this->host === '::1') { + return 'localhost'; + } + + return $this->host ?: 'unknown'; + } + + /** + * Process any params based in this URL, supports any valid delimiter + * + * @param string $uri + * @param string $delimiter + * @return string + */ + private function processParams(string $uri, string $delimiter = ':'): string + { + if (strpos($uri, $delimiter) !== false) { + preg_match_all(static::paramsRegex(), $uri, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + $param = explode($delimiter, $match[1]); + if (count($param) === 2) { + $plain_var = htmlspecialchars(strip_tags($param[1]), ENT_QUOTES, 'UTF-8'); + $this->params[$param[0]] = $plain_var; + $uri = str_replace($match[0], '', $uri); + } + } + } + return $uri; + } +} diff --git a/system/src/Grav/Common/User/Access.php b/system/src/Grav/Common/User/Access.php new file mode 100644 index 0000000..c93e1be --- /dev/null +++ b/system/src/Grav/Common/User/Access.php @@ -0,0 +1,52 @@ + ['admin.configuration_system'], + 'admin.configuration.site' => ['admin.configuration_site', 'admin.settings'], + 'admin.configuration.media' => ['admin.configuration_media'], + 'admin.configuration.info' => ['admin.configuration_info'], + ]; + + /** + * @param string $action + * @return bool|null + */ + public function get(string $action) + { + $result = parent::get($action); + if (is_bool($result)) { + return $result; + } + + // Get access value. + if (isset($this->aliases[$action])) { + $aliases = $this->aliases[$action]; + foreach ($aliases as $alias) { + $result = parent::get($alias); + if (is_bool($result)) { + return $result; + } + } + } + + return null; + } +} diff --git a/system/src/Grav/Common/User/Authentication.php b/system/src/Grav/Common/User/Authentication.php new file mode 100644 index 0000000..71d4158 --- /dev/null +++ b/system/src/Grav/Common/User/Authentication.php @@ -0,0 +1,61 @@ +get('user/account'); + } + + parent::__construct($items, $blueprints); + } + + /** + * @param string $offset + * @return bool + */ + #[\ReturnTypeWillChange] + public function offsetExists($offset) + { + $value = parent::offsetExists($offset); + + // Handle special case where user was logged in before 'authorized' was added to the user object. + if (false === $value && $offset === 'authorized') { + $value = $this->offsetExists('authenticated'); + } + + return $value; + } + + /** + * @param string $offset + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + $value = parent::offsetGet($offset); + + // Handle special case where user was logged in before 'authorized' was added to the user object. + if (null === $value && $offset === 'authorized') { + $value = $this->offsetGet('authenticated'); + $this->offsetSet($offset, $value); + } + + return $value; + } + + /** + * @return bool + */ + public function isValid(): bool + { + return $this->items !== null; + } + + /** + * Update object with data + * + * @param array $data + * @param array $files + * @return $this + */ + public function update(array $data, array $files = []) + { + // Note: $this->merge() would cause infinite loop as it calls this method. + parent::merge($data); + + return $this; + } + + /** + * Save user + * + * @return void + */ + public function save() + { + /** @var CompiledYamlFile|null $file */ + $file = $this->file(); + if (!$file || !$file->filename()) { + user_error(__CLASS__ . ': calling \$user = new ' . __CLASS__ . "() is deprecated since Grav 1.6, use \$grav['accounts']->load(\$username) or \$grav['accounts']->load('') instead", E_USER_DEPRECATED); + } + + if ($file) { + $username = $this->filterUsername((string)$this->get('username')); + + if (!$file->filename()) { + $locator = Grav::instance()['locator']; + $file->filename($locator->findResource('account://' . $username . YAML_EXT, true, true)); + } + + // if plain text password, hash it and remove plain text + $password = $this->get('password') ?? $this->get('password1'); + if (null !== $password && '' !== $password) { + $password2 = $this->get('password2'); + if (!\is_string($password) || ($password2 && $password !== $password2)) { + throw new \RuntimeException('Passwords did not match.'); + } + + $this->set('hashed_password', Authentication::create($password)); + } + $this->undef('password'); + $this->undef('password1'); + $this->undef('password2'); + + $data = $this->items; + if ($username === $data['username']) { + unset($data['username']); + } + unset($data['authenticated'], $data['authorized']); + + $file->save($data); + + // We need to signal Flex Users about the change. + /** @var Flex|null $flex */ + $flex = Grav::instance()['flex'] ?? null; + $users = $flex ? $flex->getDirectory('user-accounts') : null; + if (null !== $users) { + $users->clearCache(); + } + } + } + + /** + * @return MediaCollectionInterface|Media + */ + public function getMedia() + { + if (null === $this->_media) { + // Media object should only contain avatar, nothing else. + $media = new Media($this->getMediaFolder() ?? '', $this->getMediaOrder(), false); + + $path = $this->getAvatarFile(); + if ($path && is_file($path)) { + $medium = MediumFactory::fromFile($path); + if ($medium) { + $media->add(Utils::basename($path), $medium); + } + } + + $this->_media = $media; + } + + return $this->_media; + } + + /** + * @return string + */ + public function getMediaFolder() + { + return $this->blueprints()->fields()['avatar']['destination'] ?? 'account://avatars'; + } + + /** + * @return array + */ + public function getMediaOrder() + { + return []; + } + + /** + * Serialize user. + * + * @return string[] + */ + public function __sleep() + { + return [ + 'items', + 'storage' + ]; + } + + /** + * Unserialize user. + */ + public function __wakeup() + { + $this->gettersVariable = 'items'; + $this->nestedSeparator = '.'; + + if (null === $this->items) { + $this->items = []; + } + + // Always set blueprints. + if (null === $this->blueprints) { + $this->blueprints = (new Blueprints)->get('user/account'); + } + } + + /** + * Merge two configurations together. + * + * @param array $data + * @return $this + * @deprecated 1.6 Use `->update($data)` instead (same but with data validation & filtering, file upload support). + */ + public function merge(array $data) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->update($data) method instead', E_USER_DEPRECATED); + + return $this->update($data); + } + + /** + * Return media object for the User's avatar. + * + * @return Medium|null + * @deprecated 1.6 Use ->getAvatarImage() method instead. + */ + public function getAvatarMedia() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use getAvatarImage() method instead', E_USER_DEPRECATED); + + return $this->getAvatarImage(); + } + + /** + * Return the User's avatar URL + * + * @return string + * @deprecated 1.6 Use ->getAvatarUrl() method instead. + */ + public function avatarUrl() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use getAvatarUrl() method instead', E_USER_DEPRECATED); + + return $this->getAvatarUrl(); + } + + /** + * Checks user authorization to the action. + * Ensures backwards compatibility + * + * @param string $action + * @return bool + * @deprecated 1.5 Use ->authorize() method instead. + */ + public function authorise($action) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use authorize() method instead', E_USER_DEPRECATED); + + return $this->authorize($action) ?? false; + } + + /** + * Implements Countable interface. + * + * @return int + * @deprecated 1.6 Method makes no sense for user account. + */ + #[\ReturnTypeWillChange] + public function count() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + + return parent::count(); + } + + /** + * @param string $username + * @return string + */ + protected function filterUsername(string $username): string + { + return mb_strtolower($username); + } + + /** + * @return string|null + */ + protected function getAvatarFile(): ?string + { + $avatars = $this->get('avatar'); + if (is_array($avatars) && $avatars) { + $avatar = array_shift($avatars); + return $avatar['path'] ?? null; + } + + return null; + } +} diff --git a/system/src/Grav/Common/User/DataUser/UserCollection.php b/system/src/Grav/Common/User/DataUser/UserCollection.php new file mode 100644 index 0000000..a1def0e --- /dev/null +++ b/system/src/Grav/Common/User/DataUser/UserCollection.php @@ -0,0 +1,163 @@ +className = $className; + } + + /** + * Load user account. + * + * Always creates user object. To check if user exists, use $this->exists(). + * + * @param string $username + * @return UserInterface + */ + public function load($username): UserInterface + { + $username = (string)$username; + + $grav = Grav::instance(); + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + // Filter username. + $username = $this->filterUsername($username); + + $filename = 'account://' . $username . YAML_EXT; + $path = $locator->findResource($filename) ?: $locator->findResource($filename, true, true); + if (!is_string($path)) { + throw new RuntimeException('Internal Error'); + } + $file = CompiledYamlFile::instance($path); + $content = (array)$file->content() + ['username' => $username, 'state' => 'enabled']; + + $userClass = $this->className; + $callable = static function () { + $blueprints = new Blueprints; + + return $blueprints->get('user/account'); + }; + + /** @var UserInterface $user */ + $user = new $userClass($content, $callable); + $user->file($file); + + return $user; + } + + /** + * Find a user by username, email, etc + * + * @param string $query the query to search for + * @param array $fields the fields to search + * @return UserInterface + */ + public function find($query, $fields = ['username', 'email']): UserInterface + { + $fields = (array)$fields; + + $grav = Grav::instance(); + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + $account_dir = $locator->findResource('account://'); + if (!is_string($account_dir)) { + return $this->load(''); + } + + $files = array_diff(scandir($account_dir) ?: [], ['.', '..']); + + // Try with username first, you never know! + if (in_array('username', $fields, true)) { + $user = $this->load($query); + unset($fields[array_search('username', $fields, true)]); + } else { + $user = $this->load(''); + } + + // If not found, try the fields + if (!$user->exists()) { + $query = mb_strtolower($query); + foreach ($files as $file) { + if (Utils::endsWith($file, YAML_EXT)) { + $find_user = $this->load(trim(Utils::pathinfo($file, PATHINFO_FILENAME))); + foreach ($fields as $field) { + if (isset($find_user[$field]) && mb_strtolower($find_user[$field]) === $query) { + return $find_user; + } + } + } + } + } + return $user; + } + + /** + * Remove user account. + * + * @param string $username + * @return bool True if the action was performed + */ + public function delete($username): bool + { + $file_path = Grav::instance()['locator']->findResource('account://' . $username . YAML_EXT); + + return $file_path && unlink($file_path); + } + + /** + * @return int + */ + public function count(): int + { + // check for existence of a user account + $account_dir = $file_path = Grav::instance()['locator']->findResource('account://'); + $accounts = glob($account_dir . '/*.yaml') ?: []; + + return count($accounts); + } + + /** + * @param string $username + * @return string + */ + protected function filterUsername(string $username): string + { + return mb_strtolower($username); + } +} diff --git a/system/src/Grav/Common/User/Group.php b/system/src/Grav/Common/User/Group.php new file mode 100644 index 0000000..c6a14df --- /dev/null +++ b/system/src/Grav/Common/User/Group.php @@ -0,0 +1,172 @@ +get('groups', []); + } + + /** + * Get the groups list + * + * @return array + * @deprecated 1.7, use $grav['user_groups'] Flex UserGroupCollection instead + */ + public static function groupNames() + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use $grav[\'user_groups\'] Flex UserGroupCollection instead', E_USER_DEPRECATED); + + $groups = []; + + foreach (static::groups() as $groupname => $group) { + $groups[$groupname] = $group['readableName'] ?? $groupname; + } + + return $groups; + } + + /** + * Checks if a group exists + * + * @param string $groupname + * @return bool + * @deprecated 1.7, use $grav['user_groups'] Flex UserGroupCollection instead + */ + public static function groupExists($groupname) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use $grav[\'user_groups\'] Flex UserGroupCollection instead', E_USER_DEPRECATED); + + return isset(self::groups()[$groupname]); + } + + /** + * Get a group by name + * + * @param string $groupname + * @return object + * @deprecated 1.7, use $grav['user_groups'] Flex UserGroupCollection instead + */ + public static function load($groupname) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use $grav[\'user_groups\'] Flex UserGroupCollection instead', E_USER_DEPRECATED); + + $groups = self::groups(); + + $content = $groups[$groupname] ?? []; + $content += ['groupname' => $groupname]; + + $blueprints = new Blueprints(); + $blueprint = $blueprints->get('user/group'); + + return new Group($content, $blueprint); + } + + /** + * Save a group + * + * @return void + */ + public function save() + { + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + $blueprints = new Blueprints(); + $blueprint = $blueprints->get('user/group'); + + $config->set("groups.{$this->get('groupname')}", []); + + $fields = $blueprint->fields(); + foreach ($fields as $field) { + if ($field['type'] === 'text') { + $value = $field['name']; + if (isset($this->items['data'][$value])) { + $config->set("groups.{$this->get('groupname')}.{$value}", $this->items['data'][$value]); + } + } + if ($field['type'] === 'array' || $field['type'] === 'permissions') { + $value = $field['name']; + $arrayValues = Utils::getDotNotation($this->items['data'], $field['name']); + + if ($arrayValues) { + foreach ($arrayValues as $arrayIndex => $arrayValue) { + $config->set("groups.{$this->get('groupname')}.{$value}.{$arrayIndex}", $arrayValue); + } + } + } + } + + $type = 'groups'; + $blueprints = $this->blueprints(); + + $filename = CompiledYamlFile::instance($grav['locator']->findResource("config://{$type}.yaml")); + + $obj = new Data($config->get($type), $blueprints); + $obj->file($filename); + $obj->save(); + } + + /** + * Remove a group + * + * @param string $groupname + * @return bool True if the action was performed + * @deprecated 1.7, use $grav['user_groups'] Flex UserGroupCollection instead + */ + public static function remove($groupname) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use $grav[\'user_groups\'] Flex UserGroupCollection instead', E_USER_DEPRECATED); + + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + $blueprints = new Blueprints(); + $blueprint = $blueprints->get('user/group'); + + $type = 'groups'; + + $groups = $config->get($type); + unset($groups[$groupname]); + $config->set($type, $groups); + + $filename = CompiledYamlFile::instance($grav['locator']->findResource("config://{$type}.yaml")); + + $obj = new Data($groups, $blueprint); + $obj->file($filename); + $obj->save(); + + return true; + } +} diff --git a/system/src/Grav/Common/User/Interfaces/AuthorizeInterface.php b/system/src/Grav/Common/User/Interfaces/AuthorizeInterface.php new file mode 100644 index 0000000..1566649 --- /dev/null +++ b/system/src/Grav/Common/User/Interfaces/AuthorizeInterface.php @@ -0,0 +1,26 @@ +exists(). + * + * @param string $username + * @return UserInterface + */ + public function load($username): UserInterface; + + /** + * Find a user by username, email, etc + * + * @param string $query the query to search for + * @param array $fields the fields to search + * @return UserInterface + */ + public function find($query, $fields = ['username', 'email']): UserInterface; + + /** + * Delete user account. + * + * @param string $username + * @return bool True if user account was found and was deleted. + */ + public function delete($username): bool; +} diff --git a/system/src/Grav/Common/User/Interfaces/UserGroupInterface.php b/system/src/Grav/Common/User/Interfaces/UserGroupInterface.php new file mode 100644 index 0000000..2baa91f --- /dev/null +++ b/system/src/Grav/Common/User/Interfaces/UserGroupInterface.php @@ -0,0 +1,18 @@ +get('this.is.my.nested.variable'); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @param string|null $separator Separator, defaults to '.' + * @return mixed Value. + */ + public function get($name, $default = null, $separator = null); + + /** + * Set value by using dot notation for nested arrays/objects. + * + * @example $data->set('this.is.my.nested.variable', $value); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function set($name, $value, $separator = null); + + /** + * Unset value by using dot notation for nested arrays/objects. + * + * @example $data->undef('this.is.my.nested.variable'); + * + * @param string $name Dot separated path to the requested value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function undef($name, $separator = null); + + /** + * Set default value by using dot notation for nested arrays/objects. + * + * @example $data->def('this.is.my.nested.variable', 'default'); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function def($name, $default = null, $separator = null); + + /** + * Join nested values together by using blueprints. + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value Value to be joined. + * @param string $separator Separator, defaults to '.' + * @return $this + * @throws RuntimeException + */ + public function join($name, $value, $separator = '.'); + + /** + * Get nested structure containing default values defined in the blueprints. + * + * Fields without default value are ignored in the list. + + * @return array + */ + public function getDefaults(); + + /** + * Set default values by using blueprints. + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value Value to be joined. + * @param string $separator Separator, defaults to '.' + * @return $this + */ + public function joinDefaults($name, $value, $separator = '.'); + + /** + * Get value from the configuration and join it with given data. + * + * @param string $name Dot separated path to the requested value. + * @param array|object $value Value to be joined. + * @param string $separator Separator, defaults to '.' + * @return array + * @throws RuntimeException + */ + public function getJoined($name, $value, $separator = '.'); + + /** + * Set default values to the configuration if variables were not set. + * + * @param array $data + * @return $this + */ + public function setDefaults(array $data); + + /** + * Update object with data + * + * @param array $data + * @param array $files + * @return $this + */ + public function update(array $data, array $files = []); + + /** + * Returns whether the data already exists in the storage. + * + * NOTE: This method does not check if the data is current. + * + * @return bool + */ + public function exists(); + + /** + * Return unmodified data as raw string. + * + * NOTE: This function only returns data which has been saved to the storage. + * + * @return string + */ + public function raw(); + + /** + * Authenticate user. + * + * If user password needs to be updated, new information will be saved. + * + * @param string $password Plaintext password. + * @return bool + */ + public function authenticate(string $password): bool; + + /** + * Return media object for the User's avatar. + * + * Note: if there's no local avatar image for the user, you should call getAvatarUrl() to get the external avatar URL. + * + * @return Medium|null + */ + public function getAvatarImage(): ?Medium; + + /** + * Return the User's avatar URL. + * + * @return string + */ + public function getAvatarUrl(): string; +} diff --git a/system/src/Grav/Common/User/Traits/UserTrait.php b/system/src/Grav/Common/User/Traits/UserTrait.php new file mode 100644 index 0000000..6126c58 --- /dev/null +++ b/system/src/Grav/Common/User/Traits/UserTrait.php @@ -0,0 +1,233 @@ +get('hashed_password'); + + $isHashed = null !== $hash; + if (!$isHashed) { + // If there is no hashed password, fake verify with default hash. + $hash = Grav::instance()['config']->get('system.security.default_hash'); + } + + // Always execute verify() to protect us from timing attacks, but make the test to fail if hashed password wasn't set. + $result = Authentication::verify($password, $hash) && $isHashed; + + $plaintext_password = $this->get('password'); + if (null !== $plaintext_password) { + // Plain-text password is still stored, check if it matches. + if ($password !== $plaintext_password) { + return false; + } + + // Force hash update to get rid of plaintext password. + $result = 2; + } + + if ($result === 2) { + // Password needs to be updated, save the user. + $this->set('password', $password); + $this->undef('hashed_password'); + $this->save(); + } + + return (bool)$result; + } + + /** + * Checks user authorization to the action. + * + * @param string $action + * @param string|null $scope + * @return bool|null + */ + public function authorize(string $action, string $scope = null): ?bool + { + // User needs to be enabled. + if ($this->get('state', 'enabled') !== 'enabled') { + return false; + } + + // User needs to be logged in. + if (!$this->get('authenticated')) { + return false; + } + + // User needs to be authorized (2FA). + if (strpos($action, 'login') === false && !$this->get('authorized', true)) { + return false; + } + + if (null !== $scope) { + $action = $scope . '.' . $action; + } + + $config = Grav::instance()['config']; + $authorized = false; + + //Check group access level + $groups = (array)$this->get('groups'); + foreach ($groups as $group) { + $permission = $config->get("groups.{$group}.access.{$action}"); + $authorized = Utils::isPositive($permission); + if ($authorized === true) { + break; + } + } + + //Check user access level + $access = $this->get('access'); + if ($access && Utils::getDotNotation($access, $action) !== null) { + $permission = $this->get("access.{$action}"); + $authorized = Utils::isPositive($permission); + } + + return $authorized; + } + + /** + * Return media object for the User's avatar. + * + * Note: if there's no local avatar image for the user, you should call getAvatarUrl() to get the external avatar URL. + * + * @return ImageMedium|StaticImageMedium|null + */ + public function getAvatarImage(): ?Medium + { + $avatars = $this->get('avatar'); + if (is_array($avatars) && $avatars) { + $avatar = array_shift($avatars); + + $media = $this->getMedia(); + $name = $avatar['name'] ?? null; + + $image = $name ? $media[$name] : null; + if ($image instanceof ImageMedium || + $image instanceof StaticImageMedium) { + return $image; + } + } + + return null; + } + + /** + * Return the User's avatar URL + * + * @return string + */ + public function getAvatarUrl(): string + { + // Try to locate avatar image. + $avatar = $this->getAvatarImage(); + if ($avatar) { + return $avatar->url(); + } + + // Try if avatar is a sting (URL). + $avatar = $this->get('avatar'); + if (is_string($avatar)) { + return $avatar; + } + + // Try looking for provider. + $provider = $this->get('provider'); + $provider_options = $this->get($provider); + if (is_array($provider_options)) { + if (isset($provider_options['avatar_url']) && is_string($provider_options['avatar_url'])) { + return $provider_options['avatar_url']; + } + if (isset($provider_options['avatar']) && is_string($provider_options['avatar'])) { + return $provider_options['avatar']; + } + } + + $email = $this->get('email'); + $avatar_generator = Grav::instance()['config']->get('system.accounts.avatar', 'multiavatar'); + if ($avatar_generator === 'gravatar') { + if (!$email) { + return ''; + } + + $hash = md5(strtolower(trim($email))); + + return 'https://www.gravatar.com/avatar/' . $hash; + } + + $hash = $this->get('avatar_hash'); + if (!$hash) { + $username = $this->get('username'); + $hash = md5(strtolower(trim($email ?? $username))); + } + + return $this->generateMultiavatar($hash); + } + + /** + * @param string $hash + * @return string + */ + protected function generateMultiavatar(string $hash): string + { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + $storage = $locator->findResource('image://multiavatar', true, true); + $avatar_file = "{$storage}/{$hash}.svg"; + + if (!file_exists($storage)) { + Folder::create($storage); + } + + if (!file_exists($avatar_file)) { + $mavatar = new Multiavatar(); + + file_put_contents($avatar_file, $mavatar->generate($hash, null, null)); + } + + $avatar_url = $locator->findResource("image://multiavatar/{$hash}.svg", false, true); + + return Utils::url($avatar_url); + + } + + abstract public function get($name, $default = null, $separator = null); + abstract public function set($name, $value, $separator = null); + abstract public function undef($name, $separator = null); + abstract public function save(); +} diff --git a/system/src/Grav/Common/User/User.php b/system/src/Grav/Common/User/User.php new file mode 100644 index 0000000..362a247 --- /dev/null +++ b/system/src/Grav/Common/User/User.php @@ -0,0 +1,144 @@ +exists(). + * + * @param string $username + * @return UserInterface + * @deprecated 1.6 Use $grav['accounts']->load(...) instead. + */ + public static function load($username) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED); + + return static::getCollection()->load($username); + } + + /** + * Find a user by username, email, etc + * + * Always creates user object. To check if user exists, use $this->exists(). + * + * @param string $query the query to search for + * @param array $fields the fields to search + * @return UserInterface + * @deprecated 1.6 Use $grav['accounts']->find(...) instead. + */ + public static function find($query, $fields = ['username', 'email']) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED); + + return static::getCollection()->find($query, $fields); + } + + /** + * Remove user account. + * + * @param string $username + * @return bool True if the action was performed + * @deprecated 1.6 Use $grav['accounts']->delete(...) instead. + */ + public static function remove($username) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->delete() instead', E_USER_DEPRECATED); + + return static::getCollection()->delete($username); + } + + /** + * @return UserCollectionInterface + */ + protected static function getCollection() + { + return Grav::instance()['accounts']; + } + } +} else { + /** + * @deprecated 1.6 Use $grav['accounts'] instead of static calls. In type hints, use UserInterface. + */ + class User extends DataUser\User + { + /** + * Load user account. + * + * Always creates user object. To check if user exists, use $this->exists(). + * + * @param string $username + * @return UserInterface + * @deprecated 1.6 Use $grav['accounts']->load(...) instead. + */ + public static function load($username) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED); + + return static::getCollection()->load($username); + } + + /** + * Find a user by username, email, etc + * + * Always creates user object. To check if user exists, use $this->exists(). + * + * @param string $query the query to search for + * @param array $fields the fields to search + * @return UserInterface + * @deprecated 1.6 Use $grav['accounts']->find(...) instead. + */ + public static function find($query, $fields = ['username', 'email']) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED); + + return static::getCollection()->find($query, $fields); + } + + /** + * Remove user account. + * + * @param string $username + * @return bool True if the action was performed + * @deprecated 1.6 Use $grav['accounts']->delete(...) instead. + */ + public static function remove($username) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->delete() instead', E_USER_DEPRECATED); + + return static::getCollection()->delete($username); + } + + /** + * @return UserCollectionInterface + */ + protected static function getCollection() + { + return Grav::instance()['accounts']; + } + } +} diff --git a/system/src/Grav/Common/Utils.php b/system/src/Grav/Common/Utils.php new file mode 100644 index 0000000..815b708 --- /dev/null +++ b/system/src/Grav/Common/Utils.php @@ -0,0 +1,2227 @@ +schemeExists($scheme)) { + // If scheme does not exists as a stream, assume it's external. + return str_replace(' ', '%20', $input); + } + + // Attempt to find the resource (because of parse_url() we need to put host back to path). + $resource = $locator->findResource("{$scheme}://{$host}{$path}", false); + + if ($resource === false) { + if (!$fail_gracefully) { + return false; + } + + // Return location where the file would be if it was saved. + $resource = $locator->findResource("{$scheme}://{$host}{$path}", false, true); + } + } elseif ($host || $port) { + // If URL doesn't have scheme but has host or port, it is external. + return str_replace(' ', '%20', $input); + } + + if (!empty($resource)) { + // Add query string back. + if (isset($parts['query'])) { + $resource .= '?' . $parts['query']; + } + + // Add fragment back. + if (isset($parts['fragment'])) { + $resource .= '#' . $parts['fragment']; + } + } + } else { + // Not a valid URL (can still be a stream). + $resource = $locator->findResource($input, false); + } + } else { + // Just a path. + /** @var Pages $pages */ + $pages = $grav['pages']; + + // Is this a page? + $page = $pages->find($input, true); + if ($page && $page->routable()) { + return $page->url($domain); + } + + $root = preg_quote($uri->rootUrl(), '#'); + $pattern = '#(' . $root . '$|' . $root . '/)#'; + if (!empty($root) && preg_match($pattern, $input, $matches)) { + $input = static::replaceFirstOccurrence($matches[0], '', $input); + } + + $input = ltrim($input, '/'); + $resource = $input; + } + + if (!$fail_gracefully && $resource === false) { + return false; + } + + $domain = $domain ?: $grav['config']->get('system.absolute_urls', false); + + return rtrim($uri->rootUrl($domain), '/') . '/' . ($resource ?: ''); + } + + /** + * Helper method to find the full path to a file, be it a stream, a relative path, or + * already a full path + * + * @param string $path + * @return string + */ + public static function fullPath($path) + { + $locator = Grav::instance()['locator']; + + if ($locator->isStream($path)) { + $path = $locator->findResource($path, true); + } elseif (!static::startsWith($path, GRAV_ROOT)) { + $base_url = Grav::instance()['base_url']; + $path = GRAV_ROOT . '/' . ltrim(static::replaceFirstOccurrence($base_url, '', $path), '/'); + } + + return $path; + } + + + /** + * Check if the $haystack string starts with the substring $needle + * + * @param string $haystack + * @param string|string[] $needle + * @param bool $case_sensitive + * @return bool + */ + public static function startsWith($haystack, $needle, $case_sensitive = true) + { + $status = false; + + $compare_func = $case_sensitive ? 'mb_strpos' : 'mb_stripos'; + + foreach ((array)$needle as $each_needle) { + $status = $each_needle === '' || $compare_func((string) $haystack, $each_needle) === 0; + if ($status) { + break; + } + } + + return $status; + } + + /** + * Check if the $haystack string ends with the substring $needle + * + * @param string $haystack + * @param string|string[] $needle + * @param bool $case_sensitive + * @return bool + */ + public static function endsWith($haystack, $needle, $case_sensitive = true) + { + $status = false; + + $compare_func = $case_sensitive ? 'mb_strrpos' : 'mb_strripos'; + + foreach ((array)$needle as $each_needle) { + $expectedPosition = mb_strlen((string) $haystack) - mb_strlen($each_needle); + $status = $each_needle === '' || $compare_func((string) $haystack, $each_needle, 0) === $expectedPosition; + if ($status) { + break; + } + } + + return $status; + } + + /** + * Check if the $haystack string contains the substring $needle + * + * @param string $haystack + * @param string|string[] $needle + * @param bool $case_sensitive + * @return bool + */ + public static function contains($haystack, $needle, $case_sensitive = true) + { + $status = false; + + $compare_func = $case_sensitive ? 'mb_strpos' : 'mb_stripos'; + + foreach ((array)$needle as $each_needle) { + $status = $each_needle === '' || $compare_func((string) $haystack, $each_needle) !== false; + if ($status) { + break; + } + } + + return $status; + } + + /** + * Function that can match wildcards + * + * match_wildcard('foo*', $test), // TRUE + * match_wildcard('bar*', $test), // FALSE + * match_wildcard('*bar*', $test), // TRUE + * match_wildcard('**blob**', $test), // TRUE + * match_wildcard('*a?d*', $test), // TRUE + * match_wildcard('*etc**', $test) // TRUE + * + * @param string $wildcard_pattern + * @param string $haystack + * @return false|int + */ + public static function matchWildcard($wildcard_pattern, $haystack) + { + $regex = str_replace( + array("\*", "\?"), // wildcard chars + array('.*', '.'), // regexp chars + preg_quote($wildcard_pattern, '/') + ); + + return preg_match('/^' . $regex . '$/is', $haystack); + } + + /** + * Render simple template filling up the variables in it. If value is not defined, leave it as it was. + * + * @param string $template Template string + * @param array $variables Variables with values + * @param array $brackets Optional array of opening and closing brackets or symbols + * @return string Final string filled with values + */ + public static function simpleTemplate(string $template, array $variables, array $brackets = ['{', '}']): string + { + $opening = $brackets[0] ?? '{'; + $closing = $brackets[1] ?? '}'; + $expression = '/' . preg_quote($opening, '/') . '(.*?)' . preg_quote($closing, '/') . '/'; + $callback = static function ($match) use ($variables) { + return $variables[$match[1]] ?? $match[0]; + }; + + return preg_replace_callback($expression, $callback, $template); + } + + /** + * Returns the substring of a string up to a specified needle. if not found, return the whole haystack + * + * @param string $haystack + * @param string $needle + * @param bool $case_sensitive + * + * @return string + */ + public static function substrToString($haystack, $needle, $case_sensitive = true) + { + $compare_func = $case_sensitive ? 'mb_strpos' : 'mb_stripos'; + + if (static::contains($haystack, $needle, $case_sensitive)) { + return mb_substr($haystack, 0, $compare_func($haystack, $needle, $case_sensitive)); + } + + return $haystack; + } + + /** + * Utility method to replace only the first occurrence in a string + * + * @param string $search + * @param string $replace + * @param string $subject + * + * @return string + */ + public static function replaceFirstOccurrence($search, $replace, $subject) + { + if (!$search) { + return $subject; + } + + $pos = mb_strpos($subject, $search); + if ($pos !== false) { + $subject = static::mb_substr_replace($subject, $replace, $pos, mb_strlen($search)); + } + + + return $subject; + } + + /** + * Utility method to replace only the last occurrence in a string + * + * @param string $search + * @param string $replace + * @param string $subject + * @return string + */ + public static function replaceLastOccurrence($search, $replace, $subject) + { + $pos = strrpos($subject, $search); + + if ($pos !== false) { + $subject = static::mb_substr_replace($subject, $replace, $pos, mb_strlen($search)); + } + + return $subject; + } + + /** + * Multibyte compatible substr_replace + * + * @param string $original + * @param string $replacement + * @param int $position + * @param int $length + * @return string + */ + public static function mb_substr_replace($original, $replacement, $position, $length) + { + $startString = mb_substr($original, 0, $position, 'UTF-8'); + $endString = mb_substr($original, $position + $length, mb_strlen($original), 'UTF-8'); + + return $startString . $replacement . $endString; + } + + /** + * Merge two objects into one. + * + * @param object $obj1 + * @param object $obj2 + * + * @return object + */ + public static function mergeObjects($obj1, $obj2) + { + return (object)array_merge((array)$obj1, (array)$obj2); + } + + /** + * @param array $array + * @return bool + */ + public static function isAssoc(array $array) + { + return (array_values($array) !== $array); + } + + /** + * Lowercase an entire array. Useful when combined with `in_array()` + * + * @param array $a + * @return array|false + */ + public static function arrayLower(array $a) + { + return array_map('mb_strtolower', $a); + } + + /** + * Simple function to remove item/s in an array by value + * + * @param array $search + * @param string|array $value + * @return array + */ + public static function arrayRemoveValue(array $search, $value) + { + foreach ((array)$value as $val) { + $key = array_search($val, $search); + if ($key !== false) { + unset($search[$key]); + } + } + return $search; + } + + /** + * Recursive Merge with uniqueness + * + * @param array $array1 + * @param array $array2 + * @return array + */ + public static function arrayMergeRecursiveUnique($array1, $array2) + { + if (empty($array1)) { + // Optimize the base case + return $array2; + } + + foreach ($array2 as $key => $value) { + if (is_array($value) && isset($array1[$key]) && is_array($array1[$key])) { + $value = static::arrayMergeRecursiveUnique($array1[$key], $value); + } + $array1[$key] = $value; + } + + return $array1; + } + + /** + * Returns an array with the differences between $array1 and $array2 + * + * @param array $array1 + * @param array $array2 + * @return array + */ + public static function arrayDiffMultidimensional($array1, $array2) + { + $result = array(); + foreach ($array1 as $key => $value) { + if (!is_array($array2) || !array_key_exists($key, $array2)) { + $result[$key] = $value; + continue; + } + if (is_array($value)) { + $recursiveArrayDiff = static::ArrayDiffMultidimensional($value, $array2[$key]); + if (count($recursiveArrayDiff)) { + $result[$key] = $recursiveArrayDiff; + } + continue; + } + if ($value != $array2[$key]) { + $result[$key] = $value; + } + } + + return $result; + } + + /** + * Array combine but supports different array lengths + * + * @param array $arr1 + * @param array $arr2 + * @return array|false + */ + public static function arrayCombine($arr1, $arr2) + { + $count = min(count($arr1), count($arr2)); + + return array_combine(array_slice($arr1, 0, $count), array_slice($arr2, 0, $count)); + } + + /** + * Array is associative or not + * + * @param array $arr + * @return bool + */ + public static function arrayIsAssociative($arr) + { + if ([] === $arr) { + return false; + } + + return array_keys($arr) !== range(0, count($arr) - 1); + } + + /** + * Return the Grav date formats allowed + * + * @return array + */ + public static function dateFormats() + { + $now = new DateTime(); + + $date_formats = [ + 'd-m-Y H:i' => 'd-m-Y H:i (e.g. ' . $now->format('d-m-Y H:i') . ')', + 'Y-m-d H:i' => 'Y-m-d H:i (e.g. ' . $now->format('Y-m-d H:i') . ')', + 'm/d/Y h:i a' => 'm/d/Y h:i a (e.g. ' . $now->format('m/d/Y h:i a') . ')', + 'H:i d-m-Y' => 'H:i d-m-Y (e.g. ' . $now->format('H:i d-m-Y') . ')', + 'h:i a m/d/Y' => 'h:i a m/d/Y (e.g. ' . $now->format('h:i a m/d/Y') . ')', + ]; + $default_format = Grav::instance()['config']->get('system.pages.dateformat.default'); + if ($default_format) { + $date_formats = array_merge([$default_format => $default_format . ' (e.g. ' . $now->format($default_format) . ')'], $date_formats); + } + + return $date_formats; + } + + /** + * Get current date/time + * + * @param string|null $default_format + * @return string + * @throws Exception + */ + public static function dateNow($default_format = null) + { + $now = new DateTime(); + + if (null === $default_format) { + $default_format = Grav::instance()['config']->get('system.pages.dateformat.default'); + } + + return $now->format($default_format); + } + + /** + * Truncate text by number of characters but can cut off words. + * + * @param string $string + * @param int $limit Max number of characters. + * @param bool $up_to_break truncate up to breakpoint after char count + * @param string $break Break point. + * @param string $pad Appended padding to the end of the string. + * @return string + */ + public static function truncate($string, $limit = 150, $up_to_break = false, $break = ' ', $pad = '…') + { + // return with no change if string is shorter than $limit + if (mb_strlen($string) <= $limit) { + return $string; + } + + // is $break present between $limit and the end of the string? + if ($up_to_break && false !== ($breakpoint = mb_strpos($string, $break, $limit))) { + if ($breakpoint < mb_strlen($string) - 1) { + $string = mb_substr($string, 0, $breakpoint) . $pad; + } + } else { + $string = mb_substr($string, 0, $limit) . $pad; + } + + return $string; + } + + /** + * Truncate text by number of characters in a "word-safe" manor. + * + * @param string $string + * @param int $limit + * @return string + */ + public static function safeTruncate($string, $limit = 150) + { + return static::truncate($string, $limit, true); + } + + + /** + * Truncate HTML by number of characters. not "word-safe"! + * + * @param string $text + * @param int $length in characters + * @param string $ellipsis + * @return string + */ + public static function truncateHtml($text, $length = 100, $ellipsis = '...') + { + return Truncator::truncateLetters($text, $length, $ellipsis); + } + + /** + * Truncate HTML by number of characters in a "word-safe" manor. + * + * @param string $text + * @param int $length in words + * @param string $ellipsis + * @return string + */ + public static function safeTruncateHtml($text, $length = 25, $ellipsis = '...') + { + return Truncator::truncateWords($text, $length, $ellipsis); + } + + /** + * Generate a random string of a given length + * + * @param int $length + * @return string + */ + public static function generateRandomString($length = 5) + { + return substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, $length); + } + + /** + * Generates a random string with configurable length, prefix and suffix. + * Unlike the built-in `uniqid()`, this string is non-conflicting and safe + * + * @param int $length + * @param array $options + * @return string + * @throws Exception + */ + public static function uniqueId(int $length = 13, array $options = []): string + { + $options = array_merge(['prefix' => '', 'suffix' => ''], $options); + $bytes = random_bytes(ceil($length / 2)); + + return $options['prefix'] . substr(bin2hex($bytes), 0, $length) . $options['suffix']; + } + + /** + * Provides the ability to download a file to the browser + * + * @param string $file the full path to the file to be downloaded + * @param bool $force_download as opposed to letting browser choose if to download or render + * @param int $sec Throttling, try 0.1 for some speed throttling of downloads + * @param int $bytes Size of chunks to send in bytes. Default is 1024 + * @param array $options Extra options: [mime, download_name, expires] + * @throws Exception + */ + public static function download($file, $force_download = true, $sec = 0, $bytes = 1024, array $options = []) + { + $grav = Grav::instance(); + + if (file_exists($file)) { + // fire download event + $grav->fireEvent('onBeforeDownload', new Event(['file' => $file, 'options' => &$options])); + + $file_parts = static::pathinfo($file); + $mimetype = $options['mime'] ?? static::getMimeByExtension($file_parts['extension']); + $size = filesize($file); // File size + + $grav->cleanOutputBuffers(); + + // required for IE, otherwise Content-Disposition may be ignored + if (ini_get('zlib.output_compression')) { + ini_set('zlib.output_compression', 'Off'); + } + + header('Content-Type: ' . $mimetype); + header('Accept-Ranges: bytes'); + + if ($force_download) { + // output the regular HTTP headers + header('Content-Disposition: attachment; filename="' . ($options['download_name'] ?? $file_parts['basename']) . '"'); + } + + // multipart-download and download resuming support + if (isset($_SERVER['HTTP_RANGE'])) { + [$a, $range] = explode('=', $_SERVER['HTTP_RANGE'], 2); + [$range] = explode(',', $range, 2); + [$range, $range_end] = explode('-', $range); + $range = (int)$range; + if (!$range_end) { + $range_end = $size - 1; + } else { + $range_end = (int)$range_end; + } + $new_length = $range_end - $range + 1; + header('HTTP/1.1 206 Partial Content'); + header("Content-Length: {$new_length}"); + header("Content-Range: bytes {$range}-{$range_end}/{$size}"); + } else { + $range = 0; + $new_length = $size; + header('Content-Length: ' . $size); + + if ($grav['config']->get('system.cache.enabled')) { + $expires = $options['expires'] ?? $grav['config']->get('system.pages.expires'); + if ($expires > 0) { + $expires_date = gmdate('D, d M Y H:i:s T', time() + $expires); + header('Cache-Control: max-age=' . $expires); + header('Expires: ' . $expires_date); + header('Pragma: cache'); + } + header('Last-Modified: ' . gmdate('D, d M Y H:i:s T', filemtime($file))); + + // Return 304 Not Modified if the file is already cached in the browser + if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && + strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= filemtime($file)) { + header('HTTP/1.1 304 Not Modified'); + exit(); + } + } + } + + /* output the file itself */ + $chunksize = $bytes * 8; //you may want to change this + $bytes_send = 0; + + $fp = @fopen($file, 'rb'); + if ($fp) { + if ($range) { + fseek($fp, $range); + } + while (!feof($fp) && (!connection_aborted()) && ($bytes_send < $new_length)) { + $buffer = fread($fp, $chunksize); + echo($buffer); //echo($buffer); // is also possible + flush(); + usleep($sec * 1000000); + $bytes_send += strlen($buffer); + } + fclose($fp); + } else { + throw new RuntimeException('Error - can not open file.'); + } + + exit; + } + } + + /** + * Returns the output render format, usually the extension provided in the URL. (e.g. `html`, `json`, `xml`, etc). + * + * @return string + */ + public static function getPageFormat(): string + { + /** @var Uri $uri */ + $uri = Grav::instance()['uri']; + + // Set from uri extension + $uri_extension = $uri->extension(); + if (is_string($uri_extension) && $uri->isValidExtension($uri_extension)) { + return ($uri_extension); + } + + // Use content negotiation via the `accept:` header + $http_accept = $_SERVER['HTTP_ACCEPT'] ?? null; + if (is_string($http_accept)) { + $negotiator = new Negotiator(); + + $supported_types = static::getSupportPageTypes(['html', 'json']); + $priorities = static::getMimeTypes($supported_types); + + $media_type = $negotiator->getBest($http_accept, $priorities); + $mimetype = $media_type instanceof Accept ? $media_type->getValue() : ''; + + return static::getExtensionByMime($mimetype); + } + + return 'html'; + } + + /** + * Return the mimetype based on filename extension + * + * @param string $extension Extension of file (eg "txt") + * @param string $default + * @return string + */ + public static function getMimeByExtension($extension, $default = 'application/octet-stream') + { + $extension = strtolower($extension); + + // look for some standard types + switch ($extension) { + case null: + return $default; + case 'json': + return 'application/json'; + case 'html': + return 'text/html'; + case 'atom': + return 'application/atom+xml'; + case 'rss': + return 'application/rss+xml'; + case 'xml': + return 'application/xml'; + } + + $media_types = Grav::instance()['config']->get('media.types'); + + return $media_types[$extension]['mime'] ?? $default; + } + + /** + * Get all the mimetypes for an array of extensions + * + * @param array $extensions + * @return array + */ + public static function getMimeTypes(array $extensions) + { + $mimetypes = []; + foreach ($extensions as $extension) { + $mimetype = static::getMimeByExtension($extension, false); + if ($mimetype && !in_array($mimetype, $mimetypes)) { + $mimetypes[] = $mimetype; + } + } + return $mimetypes; + } + + /** + * Return all extensions for given mimetype. The first extension is the default one. + * + * @param string $mime Mime type (eg 'image/jpeg') + * @return string[] List of extensions eg. ['jpg', 'jpe', 'jpeg'] + */ + public static function getExtensionsByMime($mime) + { + $mime = strtolower($mime); + + $media_types = (array)Grav::instance()['config']->get('media.types'); + + $list = []; + foreach ($media_types as $extension => $type) { + if ($extension === '' || $extension === 'defaults') { + continue; + } + + if (isset($type['mime']) && $type['mime'] === $mime) { + $list[] = $extension; + } + } + + return $list; + } + + /** + * Return the mimetype based on filename extension + * + * @param string $mime mime type (eg "text/html") + * @param string $default default value + * @return string + */ + public static function getExtensionByMime($mime, $default = 'html') + { + $mime = strtolower($mime); + + // look for some standard mime types + switch ($mime) { + case '*/*': + case 'text/*': + case 'text/html': + return 'html'; + case 'application/json': + return 'json'; + case 'application/atom+xml': + return 'atom'; + case 'application/rss+xml': + return 'rss'; + case 'application/xml': + return 'xml'; + } + + $media_types = (array)Grav::instance()['config']->get('media.types'); + + foreach ($media_types as $extension => $type) { + if ($extension === 'defaults') { + continue; + } + if (isset($type['mime']) && $type['mime'] === $mime) { + return $extension; + } + } + + return $default; + } + + /** + * Get all the extensions for an array of mimetypes + * + * @param array $mimetypes + * @return array + */ + public static function getExtensions(array $mimetypes) + { + $extensions = []; + foreach ($mimetypes as $mimetype) { + $extension = static::getExtensionByMime($mimetype, false); + if ($extension && !in_array($extension, $extensions, true)) { + $extensions[] = $extension; + } + } + + return $extensions; + } + + /** + * Return the mimetype based on filename + * + * @param string $filename Filename or path to file + * @param string $default default value + * @return string + */ + public static function getMimeByFilename($filename, $default = 'application/octet-stream') + { + return static::getMimeByExtension(static::pathinfo($filename, PATHINFO_EXTENSION), $default); + } + + /** + * Return the mimetype based on existing local file + * + * @param string $filename Path to the file + * @param string $default + * @return string|bool + */ + public static function getMimeByLocalFile($filename, $default = 'application/octet-stream') + { + $type = false; + + // For local files we can detect type by the file content. + if (!stream_is_local($filename) || !file_exists($filename)) { + return false; + } + + // Prefer using finfo if it exists. + if (extension_loaded('fileinfo')) { + $finfo = finfo_open(FILEINFO_SYMLINK | FILEINFO_MIME_TYPE); + $type = finfo_file($finfo, $filename); + finfo_close($finfo); + } else { + // Fall back to use getimagesize() if it is available (not recommended, but better than nothing) + $info = @getimagesize($filename); + if ($info) { + $type = $info['mime']; + } + } + + return $type ?: static::getMimeByFilename($filename, $default); + } + + + /** + * Returns true if filename is considered safe. + * + * @param string $filename + * @return bool + */ + public static function checkFilename($filename): bool + { + $dangerous_extensions = Grav::instance()['config']->get('security.uploads_dangerous_extensions', []); + $extension = mb_strtolower(static::pathinfo($filename, PATHINFO_EXTENSION)); + + return !( + // Empty filenames are not allowed. + !$filename + // Filename should not contain horizontal/vertical tabs, newlines, nils or back/forward slashes. + || strtr($filename, "\t\v\n\r\0\\/", '_______') !== $filename + // Filename should not start or end with dot or space. + || trim($filename, '. ') !== $filename + // Filename should not contain path traversal + || str_replace('..', '', $filename) !== $filename + // File extension should not be part of configured dangerous extensions + || in_array($extension, $dangerous_extensions) + ); + } + + /** + * Unicode-safe version of PHP’s pathinfo() function. + * + * @link https://www.php.net/manual/en/function.pathinfo.php + * + * @param string $path + * @param int|null $flags + * @return array|string + */ + public static function pathinfo($path, int $flags = null) + { + $path = str_replace(['%2F', '%5C'], ['/', '\\'], rawurlencode($path)); + + if (null === $flags) { + $info = pathinfo($path); + } else { + $info = pathinfo($path, $flags); + } + + if (is_array($info)) { + return array_map('rawurldecode', $info); + } + + return rawurldecode($info); + } + + /** + * Unicode-safe version of the PHP basename() function. + * + * @link https://www.php.net/manual/en/function.basename.php + * + * @param string $path + * @param string $suffix + * @return string + */ + public static function basename($path, string $suffix = ''): string + { + return rawurldecode(basename(str_replace(['%2F', '%5C'], '/', rawurlencode($path)), $suffix)); + } + + /** + * Normalize path by processing relative `.` and `..` syntax and merging path + * + * @param string $path + * @return string + */ + public static function normalizePath($path) + { + // Resolve any streams + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if ($locator->isStream($path)) { + $path = $locator->findResource($path); + } + + // Set root properly for any URLs + $root = ''; + preg_match(self::ROOTURL_REGEX, $path, $matches); + if ($matches) { + $root = $matches[1]; + $path = $matches[2]; + } + + // Strip off leading / to ensure explode is accurate + if (static::startsWith($path, '/')) { + $root .= '/'; + $path = ltrim($path, '/'); + } + + // If there are any relative paths (..) handle those + if (static::contains($path, '..')) { + $segments = explode('/', trim($path, '/')); + $ret = []; + foreach ($segments as $segment) { + if (($segment === '.') || $segment === '') { + continue; + } + if ($segment === '..') { + array_pop($ret); + } else { + $ret[] = $segment; + } + } + $path = implode('/', $ret); + } + + // Stick everything back together + $normalized = $root . $path; + + return $normalized; + } + + /** + * Check whether a function exists. + * + * Disabled functions count as non-existing functions, just like in PHP 8+. + * + * @param string $function the name of the function to check + * @return bool + */ + public static function functionExists($function): bool + { + if (!function_exists($function)) { + return false; + } + + // In PHP 7 we need to also exclude disabled methods. + return !static::isFunctionDisabled($function); + } + + /** + * Check whether a function is disabled in the PHP settings + * + * @param string $function the name of the function to check + * @return bool + */ + public static function isFunctionDisabled($function): bool + { + static $list; + + if (null === $list) { + $str = trim(ini_get('disable_functions') . ',' . ini_get('suhosin.executor.func.blacklist'), ','); + $list = $str ? array_flip(preg_split('/\s*,\s*/', $str)) : []; + } + + return array_key_exists($function, $list); + } + + /** + * Get the formatted timezones list + * + * @return array + */ + public static function timezones() + { + $timezones = DateTimeZone::listIdentifiers(DateTimeZone::ALL); + $offsets = []; + $testDate = new DateTime(); + + foreach ($timezones as $zone) { + $tz = new DateTimeZone($zone); + $offsets[$zone] = $tz->getOffset($testDate); + } + + asort($offsets); + + $timezone_list = []; + foreach ($offsets as $timezone => $offset) { + $offset_prefix = $offset < 0 ? '-' : '+'; + $offset_formatted = gmdate('H:i', abs($offset)); + + $pretty_offset = "UTC{$offset_prefix}{$offset_formatted}"; + + $timezone_list[$timezone] = "({$pretty_offset}) " . str_replace('_', ' ', $timezone); + } + + return $timezone_list; + } + + /** + * Recursively filter an array, filtering values by processing them through the $fn function argument + * + * @param array $source the Array to filter + * @param callable $fn the function to pass through each array item + * @return array + */ + public static function arrayFilterRecursive(array $source, $fn) + { + $result = []; + foreach ($source as $key => $value) { + if (is_array($value)) { + $result[$key] = static::arrayFilterRecursive($value, $fn); + continue; + } + if ($fn($key, $value)) { + $result[$key] = $value; // KEEP + continue; + } + } + + return $result; + } + + /** + * Flatten a multi-dimensional associative array into query params. + * + * @param array $array + * @param string $prepend + * @return array + */ + public static function arrayToQueryParams($array, $prepend = '') + { + $results = []; + foreach ($array as $key => $value) { + $name = $prepend ? $prepend . '[' . $key . ']' : $key; + + if (is_array($value)) { + $results = array_merge($results, static::arrayToQueryParams($value, $name)); + } else { + $results[$name] = $value; + } + } + + return $results; + } + + /** + * Flatten an array + * + * @param array $array + * @return array + */ + public static function arrayFlatten($array) + { + $flatten = []; + foreach ($array as $key => $inner) { + if (is_array($inner)) { + foreach ($inner as $inner_key => $value) { + $flatten[$inner_key] = $value; + } + } else { + $flatten[$key] = $inner; + } + } + + return $flatten; + } + + /** + * Flatten a multi-dimensional associative array into dot notation + * + * @param array $array + * @param string $prepend + * @return array + */ + public static function arrayFlattenDotNotation($array, $prepend = '') + { + $results = array(); + foreach ($array as $key => $value) { + if (is_array($value)) { + $results = array_merge($results, static::arrayFlattenDotNotation($value, $prepend . $key . '.')); + } else { + $results[$prepend . $key] = $value; + } + } + + return $results; + } + + /** + * Opposite of flatten, convert flat dot notation array to multi dimensional array. + * + * If any of the parent has a scalar value, all children get ignored: + * + * admin.pages=true + * admin.pages.read=true + * + * becomes + * + * admin: + * pages: true + * + * @param array $array + * @param string $separator + * @return array + */ + public static function arrayUnflattenDotNotation($array, $separator = '.') + { + $newArray = []; + foreach ($array as $key => $value) { + $dots = explode($separator, $key); + if (count($dots) > 1) { + $last = &$newArray[$dots[0]]; + foreach ($dots as $k => $dot) { + if ($k === 0) { + continue; + } + + // Cannot use a scalar value as an array + if (null !== $last && !is_array($last)) { + continue 2; + } + + $last = &$last[$dot]; + } + + // Cannot use a scalar value as an array + if (null !== $last && !is_array($last)) { + continue; + } + + $last = $value; + } else { + $newArray[$key] = $value; + } + } + + return $newArray; + } + + /** + * Checks if the passed path contains the language code prefix + * + * @param string $string The path + * + * @return bool|string Either false or the language + * + */ + public static function pathPrefixedByLangCode($string) + { + $languages_enabled = Grav::instance()['config']->get('system.languages.supported', []); + $parts = explode('/', trim($string, '/')); + + if (count($parts) > 0 && in_array($parts[0], $languages_enabled)) { + return $parts[0]; + } + return false; + } + + /** + * Get the timestamp of a date + * + * @param string $date a String expressed in the system.pages.dateformat.default format, with fallback to a + * strtotime argument + * @param string|null $format a date format to use if possible + * @return int the timestamp + */ + public static function date2timestamp($date, $format = null) + { + $config = Grav::instance()['config']; + $dateformat = $format ?: $config->get('system.pages.dateformat.default'); + + // try to use DateTime and default format + if ($dateformat) { + $datetime = DateTime::createFromFormat($dateformat, $date); + } else { + try { + $datetime = new DateTime($date); + } catch (Exception $e) { + $datetime = false; + } + } + + // fallback to strtotime() if DateTime approach failed + if ($datetime !== false) { + return $datetime->getTimestamp(); + } + + return strtotime($date); + } + + /** + * @param array $array + * @param string $path + * @param null $default + * @return mixed + * + * @deprecated 1.5 Use ->getDotNotation() method instead. + */ + public static function resolve(array $array, $path, $default = null) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->getDotNotation() method instead', E_USER_DEPRECATED); + + return static::getDotNotation($array, $path, $default); + } + + /** + * Checks if a value is positive (true) + * + * @param string $value + * @return bool + */ + public static function isPositive($value) + { + return in_array($value, [true, 1, '1', 'yes', 'on', 'true'], true); + } + + /** + * Checks if a value is negative (false) + * + * @param string $value + * @return bool + */ + public static function isNegative($value) + { + return in_array($value, [false, 0, '0', 'no', 'off', 'false'], true); + } + + /** + * Generates a nonce string to be hashed. Called by self::getNonce() + * We removed the IP portion in this version because it causes too many inconsistencies + * with reverse proxy setups. + * + * @param string $action + * @param bool $previousTick if true, generates the token for the previous tick (the previous 12 hours) + * @return string the nonce string + */ + private static function generateNonceString($action, $previousTick = false) + { + $grav = Grav::instance(); + + $username = isset($grav['user']) ? $grav['user']->username : ''; + $token = session_id(); + $i = self::nonceTick(); + + if ($previousTick) { + $i--; + } + + return ($i . '|' . $action . '|' . $username . '|' . $token . '|' . $grav['config']->get('security.salt')); + } + + /** + * Get the time-dependent variable for nonce creation. + * + * Now a tick lasts a day. Once the day is passed, the nonce is not valid any more. Find a better way + * to ensure nonces issued near the end of the day do not expire in that small amount of time + * + * @return int the time part of the nonce. Changes once every 24 hours + */ + private static function nonceTick() + { + $secondsInHalfADay = 60 * 60 * 12; + + return (int)ceil(time() / $secondsInHalfADay); + } + + /** + * Creates a hashed nonce tied to the passed action. Tied to the current user and time. The nonce for a given + * action is the same for 12 hours. + * + * @param string $action the action the nonce is tied to (e.g. save-user-admin or move-page-homepage) + * @param bool $previousTick if true, generates the token for the previous tick (the previous 12 hours) + * @return string the nonce + */ + public static function getNonce($action, $previousTick = false) + { + // Don't regenerate this again if not needed + if (isset(static::$nonces[$action][$previousTick])) { + return static::$nonces[$action][$previousTick]; + } + $nonce = md5(self::generateNonceString($action, $previousTick)); + static::$nonces[$action][$previousTick] = $nonce; + + return static::$nonces[$action][$previousTick]; + } + + /** + * Verify the passed nonce for the give action + * + * @param string|string[] $nonce the nonce to verify + * @param string $action the action to verify the nonce to + * @return boolean verified or not + */ + public static function verifyNonce($nonce, $action) + { + //Safety check for multiple nonces + if (is_array($nonce)) { + $nonce = array_shift($nonce); + } + + //Nonce generated 0-12 hours ago + if ($nonce === self::getNonce($action)) { + return true; + } + + //Nonce generated 12-24 hours ago + return $nonce === self::getNonce($action, true); + } + + /** + * Simple helper method to get whether or not the admin plugin is active + * + * @return bool + */ + public static function isAdminPlugin() + { + return isset(Grav::instance()['admin']); + } + + /** + * Get a portion of an array (passed by reference) with dot-notation key + * + * @param array $array + * @param string|int|null $key + * @param null $default + * @return mixed + */ + public static function getDotNotation($array, $key, $default = null) + { + if (null === $key) { + return $array; + } + + if (isset($array[$key])) { + return $array[$key]; + } + + foreach (explode('.', $key) as $segment) { + if (!is_array($array) || !array_key_exists($segment, $array)) { + return $default; + } + + $array = $array[$segment]; + } + + return $array; + } + + /** + * Set portion of array (passed by reference) for a dot-notation key + * and set the value + * + * @param array $array + * @param string|int|null $key + * @param mixed $value + * @param bool $merge + * + * @return mixed + */ + public static function setDotNotation(&$array, $key, $value, $merge = false) + { + if (null === $key) { + return $array = $value; + } + + $keys = explode('.', $key); + + while (count($keys) > 1) { + $key = array_shift($keys); + + if (!isset($array[$key]) || !is_array($array[$key])) { + $array[$key] = array(); + } + + $array =& $array[$key]; + } + + $key = array_shift($keys); + + if (!$merge || !isset($array[$key])) { + $array[$key] = $value; + } else { + $array[$key] = array_merge($array[$key], $value); + } + + return $array; + } + + /** + * Utility method to determine if the current OS is Windows + * + * @return bool + */ + public static function isWindows() + { + return strncasecmp(PHP_OS, 'WIN', 3) === 0; + } + + /** + * Utility to determine if the server running PHP is Apache + * + * @return bool + */ + public static function isApache() + { + return isset($_SERVER['SERVER_SOFTWARE']) && strpos($_SERVER['SERVER_SOFTWARE'], 'Apache') !== false; + } + + /** + * Sort a multidimensional array by another array of ordered keys + * + * @param array $array + * @param array $orderArray + * @return array + */ + public static function sortArrayByArray(array $array, array $orderArray) + { + $ordered = []; + foreach ($orderArray as $key) { + if (array_key_exists($key, $array)) { + $ordered[$key] = $array[$key]; + unset($array[$key]); + } + } + return $ordered + $array; + } + + /** + * Sort an array by a key value in the array + * + * @param mixed $array + * @param string|int $array_key + * @param int $direction + * @param int $sort_flags + * @return array + */ + public static function sortArrayByKey($array, $array_key, $direction = SORT_DESC, $sort_flags = SORT_REGULAR) + { + $output = []; + + if (!is_array($array) || !$array) { + return $output; + } + + foreach ($array as $key => $row) { + $output[$key] = $row[$array_key]; + } + + array_multisort($output, $direction, $sort_flags, $array); + + return $array; + } + + /** + * Get relative page path based on a token. + * + * @param string $path + * @param PageInterface|null $page + * @return string + * @throws RuntimeException + */ + public static function getPagePathFromToken($path, PageInterface $page = null) + { + return static::getPathFromToken($path, $page); + } + + /** + * Get relative path based on a token. + * + * Path supports following syntaxes: + * + * 'self@', 'self@/path' + * 'page@:/route', 'page@:/route/filename.ext' + * 'theme@:', 'theme@:/path' + * + * @param string $path + * @param FlexObjectInterface|PageInterface|null $object + * @return string + * @throws RuntimeException + */ + public static function getPathFromToken($path, $object = null) + { + $matches = static::resolveTokenPath($path); + if (null === $matches) { + return $path; + } + + $grav = Grav::instance(); + + switch ($matches[0]) { + case 'self': + if (!$object instanceof MediaInterface) { + throw new RuntimeException(sprintf('Page not available for self@ reference: %s', $path)); + } + + if ($matches[2] === '') { + if ($object->exists()) { + $route = '/' . $matches[1]; + + if ($object instanceof PageInterface) { + return trim($object->relativePagePath() . $route, '/'); + } + + $folder = $object->getMediaFolder(); + if ($folder) { + return trim($folder . $route, '/'); + } + } else { + return ''; + } + } + + break; + case 'page': + if ($matches[1] === '') { + $route = '/' . $matches[2]; + + // Exclude filename from the page lookup. + if (static::pathinfo($route, PATHINFO_EXTENSION)) { + $basename = '/' . static::basename($route); + $route = \dirname($route); + } else { + $basename = ''; + } + + $key = trim($route === '/' ? $grav['config']->get('system.home.alias') : $route, '/'); + if ($object instanceof PageObject) { + $object = $object->getFlexDirectory()->getObject($key); + } elseif (static::isAdminPlugin()) { + /** @var Flex|null $flex */ + $flex = $grav['flex'] ?? null; + $object = $flex ? $flex->getObject($key, 'pages') : null; + } else { + /** @var Pages $pages */ + $pages = $grav['pages']; + $object = $pages->find($route); + } + + if ($object instanceof PageInterface) { + return trim($object->relativePagePath() . $basename, '/'); + } + } + + break; + case 'theme': + if ($matches[1] === '') { + $route = '/' . $matches[2]; + $theme = $grav['locator']->findResource('theme://', false); + if (false !== $theme) { + return trim($theme . $route, '/'); + } + } + + break; + } + + throw new RuntimeException(sprintf('Token path not found: %s', $path)); + } + + /** + * Returns [token, route, path] from '@token/route:/path'. Route and path are optional. If pattern does not match, return null. + * + * @param string $path + * @return string[]|null + */ + protected static function resolveTokenPath(string $path): ?array + { + if (strpos($path, '@') !== false) { + $regex = '/^(@\w+|\w+@|@\w+@)([^:]*)(.*)$/u'; + if (preg_match($regex, $path, $matches)) { + return [ + trim($matches[1], '@'), + trim($matches[2], '/'), + trim($matches[3], ':/') + ]; + } + } + + return null; + } + + /** + * @return int + */ + public static function getUploadLimit() + { + static $max_size = -1; + + if ($max_size < 0) { + $post_max_size = static::parseSize(ini_get('post_max_size')); + if ($post_max_size > 0) { + $max_size = $post_max_size; + } else { + $max_size = 0; + } + + $upload_max = static::parseSize(ini_get('upload_max_filesize')); + if ($upload_max > 0 && $upload_max < $max_size) { + $max_size = $upload_max; + } + } + + return $max_size; + } + + /** + * Convert bytes to the unit specified by the $to parameter. + * + * @param int $bytes The filesize in Bytes. + * @param string $to The unit type to convert to. Accepts K, M, or G for Kilobytes, Megabytes, or Gigabytes, respectively. + * @param int $decimal_places The number of decimal places to return. + * @return int Returns only the number of units, not the type letter. Returns 0 if the $to unit type is out of scope. + * + */ + public static function convertSize($bytes, $to, $decimal_places = 1) + { + $formulas = array( + 'K' => number_format($bytes / 1024, $decimal_places), + 'M' => number_format($bytes / 1048576, $decimal_places), + 'G' => number_format($bytes / 1073741824, $decimal_places) + ); + return $formulas[$to] ?? 0; + } + + /** + * Return a pretty size based on bytes + * + * @param int $bytes + * @param int $precision + * @return string + */ + public static function prettySize($bytes, $precision = 2) + { + $units = array('B', 'KB', 'MB', 'GB', 'TB'); + + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + + // Uncomment one of the following alternatives + $bytes /= 1024 ** $pow; + // $bytes /= (1 << (10 * $pow)); + + return round($bytes, $precision) . ' ' . $units[$pow]; + } + + /** + * Parse a readable file size and return a value in bytes + * + * @param string|int|float $size + * @return int + */ + public static function parseSize($size) + { + $unit = preg_replace('/[^bkmgtpezy]/i', '', $size); + $size = (float)preg_replace('/[^0-9\.]/', '', $size); + + if ($unit) { + $size *= 1024 ** stripos('bkmgtpezy', $unit[0]); + } + + return (int)abs(round($size)); + } + + /** + * Multibyte-safe Parse URL function + * + * @param string $url + * @return array + * @throws InvalidArgumentException + */ + public static function multibyteParseUrl($url) + { + $enc_url = preg_replace_callback( + '%[^:/@?&=#]+%usD', + static function ($matches) { + return urlencode($matches[0]); + }, + $url + ); + + $parts = parse_url($enc_url); + + if ($parts === false) { + $parts = []; + } + + foreach ($parts as $name => $value) { + $parts[$name] = urldecode($value); + } + + return $parts; + } + + /** + * Process a string as markdown + * + * @param string $string + * @param bool $block Block or Line processing + * @param PageInterface|null $page + * @return string + * @throws Exception + */ + public static function processMarkdown($string, $block = true, $page = null) + { + $grav = Grav::instance(); + $page = $page ?? $grav['page'] ?? null; + $defaults = [ + 'markdown' => $grav['config']->get('system.pages.markdown', []), + 'images' => $grav['config']->get('system.images', []) + ]; + $extra = $defaults['markdown']['extra'] ?? false; + + $excerpts = new Excerpts($page, $defaults); + + // Initialize the preferred variant of Parsedown + if ($extra) { + $parsedown = new ParsedownExtra($excerpts); + } else { + $parsedown = new Parsedown($excerpts); + } + + if ($block) { + $string = $parsedown->text((string) $string); + } else { + $string = $parsedown->line((string) $string); + } + + return $string; + } + + public static function toAscii(String $string): String + { + return strtr(utf8_decode($string), + utf8_decode( + 'ŠŒŽšœžŸ¥µÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýÿ'), + 'SOZsozYYuAAAAAAACEEEEIIIIDNOOOOOOUUUUYsaaaaaaaceeeeiiiionoooooouuuuyy'); + } + + /** + * Find the subnet of an ip with CIDR prefix size + * + * @param string $ip + * @param int $prefix + * @return string + */ + public static function getSubnet($ip, $prefix = 64) + { + if (!filter_var($ip, FILTER_VALIDATE_IP)) { + return $ip; + } + + // Packed representation of IP + $ip = (string)inet_pton($ip); + + // Maximum netmask length = same as packed address + $len = 8 * strlen($ip); + if ($prefix > $len) { + $prefix = $len; + } + + $mask = str_repeat('f', $prefix >> 2); + + switch ($prefix & 3) { + case 3: + $mask .= 'e'; + break; + case 2: + $mask .= 'c'; + break; + case 1: + $mask .= '8'; + break; + } + $mask = str_pad($mask, $len >> 2, '0'); + + // Packed representation of netmask + $mask = pack('H*', $mask); + // Bitwise - Take all bits that are both 1 to generate subnet + $subnet = inet_ntop($ip & $mask); + + return $subnet; + } + + /** + * Wrapper to ensure html, htm in the front of the supported page types + * + * @param array|null $defaults + * @return array + */ + public static function getSupportPageTypes(array $defaults = null) + { + $types = Grav::instance()['config']->get('system.pages.types', $defaults); + if (!is_array($types)) { + return []; + } + + // remove html/htm + $types = static::arrayRemoveValue($types, ['html', 'htm']); + + // put them back at the front + $types = array_merge(['html', 'htm'], $types); + + return $types; + } + + /** + * @param string|array|Closure $name + * @return bool + */ + public static function isDangerousFunction($name): bool + { + static $commandExecutionFunctions = [ + 'exec', + 'passthru', + 'system', + 'shell_exec', + 'popen', + 'proc_open', + 'pcntl_exec', + ]; + + static $codeExecutionFunctions = [ + 'assert', + 'preg_replace', + 'create_function', + 'include', + 'include_once', + 'require', + 'require_once' + ]; + + static $callbackFunctions = [ + 'ob_start' => 0, + 'array_diff_uassoc' => -1, + 'array_diff_ukey' => -1, + 'array_filter' => 1, + 'array_intersect_uassoc' => -1, + 'array_intersect_ukey' => -1, + 'array_map' => 0, + 'array_reduce' => 1, + 'array_udiff_assoc' => -1, + 'array_udiff_uassoc' => [-1, -2], + 'array_udiff' => -1, + 'array_uintersect_assoc' => -1, + 'array_uintersect_uassoc' => [-1, -2], + 'array_uintersect' => -1, + 'array_walk_recursive' => 1, + 'array_walk' => 1, + 'assert_options' => 1, + 'uasort' => 1, + 'uksort' => 1, + 'usort' => 1, + 'preg_replace_callback' => 1, + 'spl_autoload_register' => 0, + 'iterator_apply' => 1, + 'call_user_func' => 0, + 'call_user_func_array' => 0, + 'register_shutdown_function' => 0, + 'register_tick_function' => 0, + 'set_error_handler' => 0, + 'set_exception_handler' => 0, + 'session_set_save_handler' => [0, 1, 2, 3, 4, 5], + 'sqlite_create_aggregate' => [2, 3], + 'sqlite_create_function' => 2, + ]; + + static $informationDiscosureFunctions = [ + 'phpinfo', + 'posix_mkfifo', + 'posix_getlogin', + 'posix_ttyname', + 'getenv', + 'get_current_user', + 'proc_get_status', + 'get_cfg_var', + 'disk_free_space', + 'disk_total_space', + 'diskfreespace', + 'getcwd', + 'getlastmo', + 'getmygid', + 'getmyinode', + 'getmypid', + 'getmyuid' + ]; + + static $otherFunctions = [ + 'extract', + 'parse_str', + 'putenv', + 'ini_set', + 'mail', + 'header', + 'proc_nice', + 'proc_terminate', + 'proc_close', + 'pfsockopen', + 'fsockopen', + 'apache_child_terminate', + 'posix_kill', + 'posix_mkfifo', + 'posix_setpgid', + 'posix_setsid', + 'posix_setuid', + 'unserialize', + 'ini_alter', + 'simplexml_load_file', + 'simplexml_load_string', + 'forward_static_call', + 'forward_static_call_array', + ]; + + if (is_string($name)) { + $name = strtolower($name); + } + + if ($name instanceof \Closure) { + return false; + } + + if (is_array($name) || strpos($name, ":") !== false) { + return true; + } + + if (strpos($name, "\\") !== false) { + return true; + } + + if (in_array($name, $commandExecutionFunctions)) { + return true; + } + + if (in_array($name, $codeExecutionFunctions)) { + return true; + } + + if (isset($callbackFunctions[$name])) { + return true; + } + + if (in_array($name, $informationDiscosureFunctions)) { + return true; + } + + if (in_array($name, $otherFunctions)) { + return true; + } + + return static::isFilesystemFunction($name); + } + + /** + * @param string $name + * @return bool + */ + public static function isFilesystemFunction(string $name): bool + { + static $fileWriteFunctions = [ + 'fopen', + 'tmpfile', + 'bzopen', + 'gzopen', + // write to filesystem (partially in combination with reading) + 'chgrp', + 'chmod', + 'chown', + 'copy', + 'file_put_contents', + 'lchgrp', + 'lchown', + 'link', + 'mkdir', + 'move_uploaded_file', + 'rename', + 'rmdir', + 'symlink', + 'tempnam', + 'touch', + 'unlink', + 'imagepng', + 'imagewbmp', + 'image2wbmp', + 'imagejpeg', + 'imagexbm', + 'imagegif', + 'imagegd', + 'imagegd2', + 'iptcembed', + 'ftp_get', + 'ftp_nb_get', + ]; + + static $fileContentFunctions = [ + 'file_get_contents', + 'file', + 'filegroup', + 'fileinode', + 'fileowner', + 'fileperms', + 'glob', + 'is_executable', + 'is_uploaded_file', + 'parse_ini_file', + 'readfile', + 'readlink', + 'realpath', + 'gzfile', + 'readgzfile', + 'stat', + 'imagecreatefromgif', + 'imagecreatefromjpeg', + 'imagecreatefrompng', + 'imagecreatefromwbmp', + 'imagecreatefromxbm', + 'imagecreatefromxpm', + 'ftp_put', + 'ftp_nb_put', + 'hash_update_file', + 'highlight_file', + 'show_source', + 'php_strip_whitespace', + ]; + + static $filesystemFunctions = [ + // read from filesystem + 'file_exists', + 'fileatime', + 'filectime', + 'filemtime', + 'filesize', + 'filetype', + 'is_dir', + 'is_file', + 'is_link', + 'is_readable', + 'is_writable', + 'is_writeable', + 'linkinfo', + 'lstat', + //'pathinfo', + 'getimagesize', + 'exif_read_data', + 'read_exif_data', + 'exif_thumbnail', + 'exif_imagetype', + 'hash_file', + 'hash_hmac_file', + 'md5_file', + 'sha1_file', + 'get_meta_tags', + ]; + + if (in_array($name, $fileWriteFunctions)) { + return true; + } + + if (in_array($name, $fileContentFunctions)) { + return true; + } + + if (in_array($name, $filesystemFunctions)) { + return true; + } + + return false; + } +} diff --git a/system/src/Grav/Common/Yaml.php b/system/src/Grav/Common/Yaml.php new file mode 100644 index 0000000..5364db2 --- /dev/null +++ b/system/src/Grav/Common/Yaml.php @@ -0,0 +1,65 @@ +decode($data); + } + + /** + * @param array $data + * @param int|null $inline + * @param int|null $indent + * @return string + */ + public static function dump($data, $inline = null, $indent = null) + { + if (null === static::$yaml) { + static::init(); + } + + return static::$yaml->encode($data, $inline, $indent); + } + + /** + * @return void + */ + protected static function init() + { + $config = [ + 'inline' => 5, + 'indent' => 2, + 'native' => true, + 'compat' => true + ]; + + static::$yaml = new YamlFormatter($config); + } +} diff --git a/system/src/Grav/Console/Application/Application.php b/system/src/Grav/Console/Application/Application.php new file mode 100644 index 0000000..aefeee4 --- /dev/null +++ b/system/src/Grav/Console/Application/Application.php @@ -0,0 +1,138 @@ +addListener(ConsoleEvents::COMMAND, [$this, 'prepareEnvironment']); + + $this->setDispatcher($dispatcher); + } + + /** + * @param InputInterface $input + * @return string|null + */ + public function getCommandName(InputInterface $input): ?string + { + if ($input->hasParameterOption('--env', true)) { + $this->environment = $input->getParameterOption('--env'); + } + if ($input->hasParameterOption('--lang', true)) { + $this->language = $input->getParameterOption('--lang'); + } + + $this->init(); + + return parent::getCommandName($input); + } + + /** + * @param ConsoleCommandEvent $event + * @return void + */ + public function prepareEnvironment(ConsoleCommandEvent $event): void + { + } + + /** + * @return void + */ + protected function init(): void + { + if ($this->initialized) { + return; + } + + $this->initialized = true; + + $grav = Grav::instance(); + $grav->setup($this->environment); + } + + /** + * Add global --env and --lang options. + * + * @return InputDefinition + */ + protected function getDefaultInputDefinition(): InputDefinition + { + $inputDefinition = parent::getDefaultInputDefinition(); + $inputDefinition->addOption( + new InputOption( + '--env', + '', + InputOption::VALUE_OPTIONAL, + 'Use environment configuration (defaults to localhost)' + ) + ); + $inputDefinition->addOption( + new InputOption( + '--lang', + '', + InputOption::VALUE_OPTIONAL, + 'Language to be used (defaults to en)' + ) + ); + + return $inputDefinition; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return void + */ + protected function configureIO(InputInterface $input, OutputInterface $output) + { + $formatter = $output->getFormatter(); + $formatter->setStyle('normal', new OutputFormatterStyle('white')); + $formatter->setStyle('yellow', new OutputFormatterStyle('yellow', null, ['bold'])); + $formatter->setStyle('red', new OutputFormatterStyle('red', null, ['bold'])); + $formatter->setStyle('cyan', new OutputFormatterStyle('cyan', null, ['bold'])); + $formatter->setStyle('green', new OutputFormatterStyle('green', null, ['bold'])); + $formatter->setStyle('magenta', new OutputFormatterStyle('magenta', null, ['bold'])); + $formatter->setStyle('white', new OutputFormatterStyle('white', null, ['bold'])); + + parent::configureIO($input, $output); + } +} diff --git a/system/src/Grav/Console/Application/CommandLoader/PluginCommandLoader.php b/system/src/Grav/Console/Application/CommandLoader/PluginCommandLoader.php new file mode 100644 index 0000000..05b4097 --- /dev/null +++ b/system/src/Grav/Console/Application/CommandLoader/PluginCommandLoader.php @@ -0,0 +1,103 @@ +commands = []; + + try { + $path = "plugins://{$name}/cli"; + $pattern = '([A-Z]\w+Command\.php)'; + + $commands = is_dir($path) ? Folder::all($path, ['compare' => 'Filename', 'pattern' => '/' . $pattern . '$/usm', 'levels' => 1]) : []; + } catch (RuntimeException $e) { + throw new RuntimeException("Failed to load console commands for plugin {$name}"); + } + + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + foreach ($commands as $command_path) { + $full_path = $locator->findResource("plugins://{$name}/cli/{$command_path}"); + require_once $full_path; + + $command_class = 'Grav\Plugin\Console\\' . preg_replace('/.php$/', '', $command_path); + if (class_exists($command_class)) { + $command = new $command_class(); + if ($command instanceof Command) { + $this->commands[$command->getName()] = $command; + + // If the command has an alias, add that as a possible command name. + $aliases = $this->commands[$command->getName()]->getAliases(); + if (isset($aliases)) { + foreach ($aliases as $alias) { + $this->commands[$alias] = $command; + } + } + } + } + } + } + + /** + * @param string $name + * @return Command + */ + public function get($name): Command + { + $command = $this->commands[$name] ?? null; + if (null === $command) { + throw new CommandNotFoundException(sprintf('The command "%s" does not exist.', $name)); + } + + return $command; + } + + /** + * @param string $name + * @return bool + */ + public function has($name): bool + { + return isset($this->commands[$name]); + } + + /** + * @return string[] + */ + public function getNames(): array + { + return array_keys($this->commands); + } +} diff --git a/system/src/Grav/Console/Application/GpmApplication.php b/system/src/Grav/Console/Application/GpmApplication.php new file mode 100644 index 0000000..61e73d2 --- /dev/null +++ b/system/src/Grav/Console/Application/GpmApplication.php @@ -0,0 +1,42 @@ +addCommands([ + new IndexCommand(), + new VersionCommand(), + new InfoCommand(), + new InstallCommand(), + new UninstallCommand(), + new UpdateCommand(), + new SelfupgradeCommand(), + new DirectInstallCommand(), + ]); + } +} diff --git a/system/src/Grav/Console/Application/GravApplication.php b/system/src/Grav/Console/Application/GravApplication.php new file mode 100644 index 0000000..81e320d --- /dev/null +++ b/system/src/Grav/Console/Application/GravApplication.php @@ -0,0 +1,52 @@ +addCommands([ + new InstallCommand(), + new ComposerCommand(), + new SandboxCommand(), + new CleanCommand(), + new ClearCacheCommand(), + new BackupCommand(), + new NewProjectCommand(), + new SchedulerCommand(), + new SecurityCommand(), + new LogViewerCommand(), + new YamlLinterCommand(), + new ServerCommand(), + new PageSystemValidatorCommand(), + ]); + } +} diff --git a/system/src/Grav/Console/Application/PluginApplication.php b/system/src/Grav/Console/Application/PluginApplication.php new file mode 100644 index 0000000..fc28386 --- /dev/null +++ b/system/src/Grav/Console/Application/PluginApplication.php @@ -0,0 +1,116 @@ +addCommands([ + new PluginListCommand(), + ]); + } + + /** + * @param string $pluginName + * @return void + */ + public function setPluginName(string $pluginName): void + { + $this->pluginName = $pluginName; + } + + /** + * @return string + */ + public function getPluginName(): string + { + return $this->pluginName; + } + + /** + * @param InputInterface|null $input + * @param OutputInterface|null $output + * @return int + * @throws Throwable + */ + public function run(InputInterface $input = null, OutputInterface $output = null): int + { + if (null === $input) { + $argv = $_SERVER['argv'] ?? []; + + $bin = array_shift($argv); + $this->pluginName = array_shift($argv); + $argv = array_merge([$bin], $argv); + + $input = new ArgvInput($argv); + } + + return parent::run($input, $output); + } + + /** + * @return void + */ + protected function init(): void + { + if ($this->initialized) { + return; + } + + parent::init(); + + if (null === $this->pluginName) { + $this->setDefaultCommand('plugins:list'); + + return; + } + + $grav = Grav::instance(); + $grav->initializeCli(); + + /** @var Plugins $plugins */ + $plugins = $grav['plugins']; + + $plugin = $this->pluginName ? $plugins::get($this->pluginName) : null; + if (null === $plugin) { + throw new NamespaceNotFoundException("Plugin \"{$this->pluginName}\" is not installed."); + } + if (!$plugin->enabled) { + throw new NamespaceNotFoundException("Plugin \"{$this->pluginName}\" is not enabled."); + } + + $this->setCommandLoader(new PluginCommandLoader($this->pluginName)); + } +} diff --git a/system/src/Grav/Console/Cli/BackupCommand.php b/system/src/Grav/Console/Cli/BackupCommand.php new file mode 100644 index 0000000..fe3c894 --- /dev/null +++ b/system/src/Grav/Console/Cli/BackupCommand.php @@ -0,0 +1,138 @@ +setName('backup') + ->addArgument( + 'id', + InputArgument::OPTIONAL, + 'The ID of the backup profile to perform without prompting' + ) + ->setDescription('Creates a backup of the Grav instance') + ->setHelp('The backup creates a zipped backup.'); + + $this->source = getcwd(); + } + + /** + * @return int + */ + protected function serve(): int + { + $this->initializeGrav(); + + $input = $this->getInput(); + $io = $this->getIO(); + + $io->title('Grav Backup'); + + if (!class_exists(ZipArchive::class)) { + $io->error('php-zip extension needs to be enabled!'); + return 1; + } + + ProgressBar::setFormatDefinition('zip', 'Archiving %current% files [%bar%] %percent:3s%% %elapsed:6s% %message%'); + + $this->progress = new ProgressBar($this->output, 100); + $this->progress->setFormat('zip'); + + + /** @var Backups $backups */ + $backups = Grav::instance()['backups']; + $backups_list = $backups::getBackupProfiles(); + $backups_names = $backups->getBackupNames(); + + $id = null; + + $inline_id = $input->getArgument('id'); + if (null !== $inline_id && is_numeric($inline_id)) { + $id = $inline_id; + } + + if (null === $id) { + if (count($backups_list) > 1) { + $question = new ChoiceQuestion( + 'Choose a backup?', + $backups_names, + 0 + ); + $question->setErrorMessage('Option %s is invalid.'); + $backup_name = $io->askQuestion($question); + $id = array_search($backup_name, $backups_names, true); + + $io->newLine(); + $io->note('Selected backup: ' . $backup_name); + } else { + $id = 0; + } + } + + $backup = $backups::backup($id, function($args) { $this->outputProgress($args); }); + + $io->newline(2); + $io->success('Backup Successfully Created: ' . $backup); + + return 0; + } + + /** + * @param array $args + * @return void + */ + public function outputProgress(array $args): void + { + switch ($args['type']) { + case 'count': + $steps = $args['steps']; + $freq = (int)($steps > 100 ? round($steps / 100) : $steps); + $this->progress->setMaxSteps($steps); + $this->progress->setRedrawFrequency($freq); + $this->progress->setMessage('Adding files...'); + break; + case 'message': + $this->progress->setMessage($args['message']); + $this->progress->display(); + break; + case 'progress': + if (isset($args['complete']) && $args['complete']) { + $this->progress->finish(); + } else { + $this->progress->advance(); + } + break; + } + } +} diff --git a/system/src/Grav/Console/Cli/CleanCommand.php b/system/src/Grav/Console/Cli/CleanCommand.php new file mode 100644 index 0000000..1418a63 --- /dev/null +++ b/system/src/Grav/Console/Cli/CleanCommand.php @@ -0,0 +1,411 @@ +setName('clean') + ->setDescription('Handles cleaning chores for Grav distribution') + ->setHelp('The clean clean extraneous folders and data'); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->setupConsole($input, $output); + + return $this->cleanPaths() ? 0 : 1; + } + + /** + * @return bool + */ + private function cleanPaths(): bool + { + $success = true; + + $this->io->writeln(''); + $this->io->writeln('DELETING'); + $anything = false; + foreach ($this->paths_to_remove as $path) { + $path = GRAV_ROOT . DS . $path; + try { + if (is_dir($path) && Folder::delete($path)) { + $anything = true; + $this->io->writeln('dir: ' . $path); + } elseif (is_file($path) && @unlink($path)) { + $anything = true; + $this->io->writeln('file: ' . $path); + } + } catch (\Exception $e) { + $success = false; + $this->io->error(sprintf('Failed to delete %s: %s', $path, $e->getMessage())); + } + } + if (!$anything) { + $this->io->writeln(''); + $this->io->writeln('Nothing to clean...'); + } + + return $success; + } + + /** + * Set colors style definition for the formatter. + * + * @param InputInterface $input + * @param OutputInterface $output + * @return void + */ + public function setupConsole(InputInterface $input, OutputInterface $output): void + { + $this->input = $input; + $this->io = new SymfonyStyle($input, $output); + + $this->io->getFormatter()->setStyle('normal', new OutputFormatterStyle('white')); + $this->io->getFormatter()->setStyle('yellow', new OutputFormatterStyle('yellow', null, ['bold'])); + $this->io->getFormatter()->setStyle('red', new OutputFormatterStyle('red', null, ['bold'])); + $this->io->getFormatter()->setStyle('cyan', new OutputFormatterStyle('cyan', null, ['bold'])); + $this->io->getFormatter()->setStyle('green', new OutputFormatterStyle('green', null, ['bold'])); + $this->io->getFormatter()->setStyle('magenta', new OutputFormatterStyle('magenta', null, ['bold'])); + $this->io->getFormatter()->setStyle('white', new OutputFormatterStyle('white', null, ['bold'])); + } +} diff --git a/system/src/Grav/Console/Cli/ClearCacheCommand.php b/system/src/Grav/Console/Cli/ClearCacheCommand.php new file mode 100644 index 0000000..98f693b --- /dev/null +++ b/system/src/Grav/Console/Cli/ClearCacheCommand.php @@ -0,0 +1,104 @@ +setName('cache') + ->setAliases(['clearcache', 'cache-clear']) + ->setDescription('Clears Grav cache') + ->addOption('invalidate', null, InputOption::VALUE_NONE, 'Invalidate cache, but do not remove any files') + ->addOption('purge', null, InputOption::VALUE_NONE, 'If set purge old caches') + ->addOption('all', null, InputOption::VALUE_NONE, 'If set will remove all including compiled, twig, doctrine caches') + ->addOption('assets-only', null, InputOption::VALUE_NONE, 'If set will remove only assets/*') + ->addOption('images-only', null, InputOption::VALUE_NONE, 'If set will remove only images/*') + ->addOption('cache-only', null, InputOption::VALUE_NONE, 'If set will remove only cache/*') + ->addOption('tmp-only', null, InputOption::VALUE_NONE, 'If set will remove only tmp/*') + + ->setHelp('The cache command allows you to interact with Grav cache'); + } + + /** + * @return int + */ + protected function serve(): int + { + // Old versions of Grav called this command after grav upgrade. + // We need make this command to work with older GravCommand instance: + if (!method_exists($this, 'initializePlugins')) { + Cache::clearCache('all'); + + return 0; + } + + $this->initializePlugins(); + $this->cleanPaths(); + + return 0; + } + + /** + * loops over the array of paths and deletes the files/folders + * + * @return void + */ + private function cleanPaths(): void + { + $input = $this->getInput(); + $io = $this->getIO(); + + $io->newLine(); + + if ($input->getOption('purge')) { + $io->writeln('Purging old cache'); + $io->newLine(); + + $msg = Cache::purgeJob(); + $io->writeln($msg); + } else { + $io->writeln('Clearing cache'); + $io->newLine(); + + if ($input->getOption('all')) { + $remove = 'all'; + } elseif ($input->getOption('assets-only')) { + $remove = 'assets-only'; + } elseif ($input->getOption('images-only')) { + $remove = 'images-only'; + } elseif ($input->getOption('cache-only')) { + $remove = 'cache-only'; + } elseif ($input->getOption('tmp-only')) { + $remove = 'tmp-only'; + } elseif ($input->getOption('invalidate')) { + $remove = 'invalidate'; + } else { + $remove = 'standard'; + } + + foreach (Cache::clearCache($remove) as $result) { + $io->writeln($result); + } + } + } +} diff --git a/system/src/Grav/Console/Cli/ComposerCommand.php b/system/src/Grav/Console/Cli/ComposerCommand.php new file mode 100644 index 0000000..8617698 --- /dev/null +++ b/system/src/Grav/Console/Cli/ComposerCommand.php @@ -0,0 +1,64 @@ +setName('composer') + ->addOption( + 'install', + 'i', + InputOption::VALUE_NONE, + 'install the dependencies' + ) + ->addOption( + 'update', + 'u', + InputOption::VALUE_NONE, + 'update the dependencies' + ) + ->setDescription('Updates the composer vendor dependencies needed by Grav.') + ->setHelp('The composer command updates the composer vendor dependencies needed by Grav'); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + $action = $input->getOption('install') ? 'install' : ($input->getOption('update') ? 'update' : 'install'); + + if ($input->getOption('install')) { + $action = 'install'; + } + + // Updates composer first + $io->writeln("\nInstalling vendor dependencies"); + $io->writeln($this->composerUpdate(GRAV_ROOT, $action)); + + return 0; + } +} diff --git a/system/src/Grav/Console/Cli/InstallCommand.php b/system/src/Grav/Console/Cli/InstallCommand.php new file mode 100644 index 0000000..0f9adb2 --- /dev/null +++ b/system/src/Grav/Console/Cli/InstallCommand.php @@ -0,0 +1,302 @@ +setName('install') + ->addOption( + 'symlink', + 's', + InputOption::VALUE_NONE, + 'Symlink the required bits' + ) + ->addOption( + 'plugin', + 'p', + InputOption::VALUE_REQUIRED, + 'Install plugin (symlink)' + ) + ->addOption( + 'theme', + 't', + InputOption::VALUE_REQUIRED, + 'Install theme (symlink)' + ) + ->addArgument( + 'destination', + InputArgument::OPTIONAL, + 'Where to install the required bits (default to current project)' + ) + ->setDescription('Installs the dependencies needed by Grav. Optionally can create symbolic links') + ->setHelp('The install command installs the dependencies needed by Grav. Optionally can create symbolic links'); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + $dependencies_file = '.dependencies'; + $this->destination = $input->getArgument('destination') ?: GRAV_WEBROOT; + + // fix trailing slash + $this->destination = rtrim($this->destination, DS) . DS; + $this->user_path = $this->destination . GRAV_USER_PATH . DS; + if ($local_config_file = $this->loadLocalConfig()) { + $io->writeln('Read local config from ' . $local_config_file . ''); + } + + // Look for dependencies file in ROOT and USER dir + if (file_exists($this->user_path . $dependencies_file)) { + $file = YamlFile::instance($this->user_path . $dependencies_file); + } elseif (file_exists($this->destination . $dependencies_file)) { + $file = YamlFile::instance($this->destination . $dependencies_file); + } else { + $io->writeln('ERROR Missing .dependencies file in user/ folder'); + if ($input->getArgument('destination')) { + $io->writeln('HINT Are you trying to install a plugin or a theme? Make sure you use bin/gpm install , not bin/grav install. This command is only used to install Grav skeletons.'); + } else { + $io->writeln('HINT Are you trying to install Grav? Grav is already installed. You need to run this command only if you download a skeleton from GitHub directly.'); + } + + return 1; + } + + $this->config = $file->content(); + $file->free(); + + // If no config, fail. + if (!$this->config) { + $io->writeln('ERROR invalid YAML in ' . $dependencies_file); + + return 1; + } + + $plugin = $input->getOption('plugin'); + $theme = $input->getOption('theme'); + $name = $plugin ?? $theme; + $symlink = $name || $input->getOption('symlink'); + + if (!$symlink) { + // Updates composer first + $io->writeln("\nInstalling vendor dependencies"); + $io->writeln($this->composerUpdate(GRAV_ROOT, 'install')); + + $error = $this->gitclone(); + } else { + $type = $name ? ($plugin ? 'plugin' : 'theme') : null; + + $error = $this->symlink($name, $type); + } + + return $error; + } + + /** + * Clones from Git + * + * @return int + */ + private function gitclone(): int + { + $io = $this->getIO(); + + $io->newLine(); + $io->writeln('Cloning Bits'); + $io->writeln('============'); + $io->newLine(); + + $error = 0; + $this->destination = rtrim($this->destination, DS); + foreach ($this->config['git'] as $repo => $data) { + $path = $this->destination . DS . $data['path']; + if (!file_exists($path)) { + exec('cd ' . escapeshellarg($this->destination) . ' && git clone -b ' . $data['branch'] . ' --depth 1 ' . $data['url'] . ' ' . $data['path'], $output, $return); + + if (!$return) { + $io->writeln('SUCCESS cloned ' . $data['url'] . ' -> ' . $path . ''); + } else { + $io->writeln('ERROR cloning ' . $data['url']); + $error = 1; + } + + $io->newLine(); + } else { + $io->writeln('' . $path . ' already exists, skipping...'); + $io->newLine(); + } + } + + return $error; + } + + /** + * Symlinks + * + * @param string|null $name + * @param string|null $type + * @return int + */ + private function symlink(string $name = null, string $type = null): int + { + $io = $this->getIO(); + + $io->newLine(); + $io->writeln('Symlinking Bits'); + $io->writeln('==============='); + $io->newLine(); + + if (!$this->local_config) { + $io->writeln('No local configuration available, aborting...'); + $io->newLine(); + + return 1; + } + + $error = 0; + $this->destination = rtrim($this->destination, DS); + + if ($name) { + $src = "grav-{$type}-{$name}"; + $links = [ + $name => [ + 'scm' => 'github', // TODO: make configurable + 'src' => $src, + 'path' => "user/{$type}s/{$name}" + ] + ]; + } else { + $links = $this->config['links']; + } + + foreach ($links as $name => $data) { + $scm = $data['scm'] ?? null; + $src = $data['src'] ?? null; + $path = $data['path'] ?? null; + if (!isset($scm, $src, $path)) { + $io->writeln("Dependency '$name' has broken configuration, skipping..."); + $io->newLine(); + $error = 1; + + continue; + } + + $locations = (array) $this->local_config["{$scm}_repos"]; + $to = $this->destination . DS . $path; + + $from = null; + foreach ($locations as $location) { + $test = rtrim($location, '\\/') . DS . $src; + if (file_exists($test)) { + $from = $test; + continue; + } + } + + if (is_link($to) && !realpath($to)) { + $io->writeln('Removed broken symlink '. $path .''); + unlink($to); + } + if (null === $from) { + $io->writeln('source for ' . $src . ' does not exists, skipping...'); + $io->newLine(); + $error = 1; + } elseif (!file_exists($to)) { + $error = $this->addSymlinks($from, $to, ['name' => $name, 'src' => $src, 'path' => $path]); + $io->newLine(); + } else { + $io->writeln('destination: ' . $path . ' already exists, skipping...'); + $io->newLine(); + } + } + + return $error; + } + + private function addSymlinks(string $from, string $to, array $options): int + { + $io = $this->getIO(); + + $hebe = $this->readHebe($from); + if (null === $hebe) { + symlink($from, $to); + + $io->writeln('SUCCESS symlinked ' . $options['src'] . ' -> ' . $options['path'] . ''); + } else { + $to = GRAV_ROOT; + $name = $options['name']; + $io->writeln("Processing {$name}"); + foreach ($hebe as $section => $symlinks) { + foreach ($symlinks as $symlink) { + $src = trim($symlink['source'], '/'); + $dst = trim($symlink['destination'], '/'); + $s = "{$from}/{$src}"; + $d = "{$to}/{$dst}"; + + if (is_link($d) && !realpath($d)) { + unlink($d); + $io->writeln(' Removed broken symlink '. $dst .''); + } + if (!file_exists($d)) { + symlink($s, $d); + $io->writeln(' symlinked ' . $src . ' -> ' . $dst . ''); + } + } + } + $io->writeln('SUCCESS'); + } + + return 0; + } + + private function readHebe(string $folder): ?array + { + $filename = "{$folder}/hebe.json"; + if (!is_file($filename)) { + return null; + } + + $formatter = new JsonFormatter(); + $file = new JsonFile($filename, $formatter); + $hebe = $file->load(); + $paths = $hebe['platforms']['grav']['nodes'] ?? null; + + return is_array($paths) ? $paths : null; + } +} diff --git a/system/src/Grav/Console/Cli/LogViewerCommand.php b/system/src/Grav/Console/Cli/LogViewerCommand.php new file mode 100644 index 0000000..62ee79c --- /dev/null +++ b/system/src/Grav/Console/Cli/LogViewerCommand.php @@ -0,0 +1,96 @@ +setName('logviewer') + ->addOption( + 'file', + 'f', + InputOption::VALUE_OPTIONAL, + 'custom log file location (default = grav.log)' + ) + ->addOption( + 'lines', + 'l', + InputOption::VALUE_OPTIONAL, + 'number of lines (default = 10)' + ) + ->setDescription('Display the last few entries of Grav log') + ->setHelp('Display the last few entries of Grav log'); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + $file = $input->getOption('file') ?? 'grav.log'; + $lines = $input->getOption('lines') ?? 20; + $verbose = $input->getOption('verbose') ?? false; + + $io->title('Log Viewer'); + + $io->writeln(sprintf('viewing last %s entries in %s', $lines, $file)); + $io->newLine(); + + $viewer = new LogViewer(); + + $grav = Grav::instance(); + + $logfile = $grav['locator']->findResource('log://' . $file); + if (!$logfile) { + $io->error('cannot find the log file: logs/' . $file); + + return 1; + } + + $rows = $viewer->objectTail($logfile, $lines, true); + foreach ($rows as $log) { + $date = $log['date']; + $level_color = LogViewer::levelColor($log['level']); + + if ($date instanceof DateTime) { + $output = "{$log['date']->format('Y-m-d h:i:s')} [<{$level_color}>{$log['level']}]"; + if ($log['trace'] && $verbose) { + $output .= " {$log['message']}\n"; + foreach ((array) $log['trace'] as $index => $tracerow) { + $output .= "{$index}{$tracerow}\n"; + } + } else { + $output .= " {$log['message']}"; + } + $io->writeln($output); + } + } + + return 0; + } +} diff --git a/system/src/Grav/Console/Cli/NewProjectCommand.php b/system/src/Grav/Console/Cli/NewProjectCommand.php new file mode 100644 index 0000000..b6a3c8b --- /dev/null +++ b/system/src/Grav/Console/Cli/NewProjectCommand.php @@ -0,0 +1,75 @@ +setName('new-project') + ->setAliases(['newproject']) + ->addArgument( + 'destination', + InputArgument::REQUIRED, + 'The destination directory of your new Grav project' + ) + ->addOption( + 'symlink', + 's', + InputOption::VALUE_NONE, + 'Symlink the required bits' + ) + ->setDescription('Creates a new Grav project with all the dependencies installed') + ->setHelp("The new-project command is a combination of the `setup` and `install` commands.\nCreates a new Grav instance and performs the installation of all the required dependencies."); + } + + /** + * @return int + */ + protected function serve(): int + { + $io = $this->getIO(); + + $sandboxCommand = $this->getApplication()->find('sandbox'); + $installCommand = $this->getApplication()->find('install'); + + $sandboxArguments = new ArrayInput([ + 'command' => 'sandbox', + 'destination' => $this->input->getArgument('destination'), + '-s' => $this->input->getOption('symlink') + ]); + + $installArguments = new ArrayInput([ + 'command' => 'install', + 'destination' => $this->input->getArgument('destination'), + '-s' => $this->input->getOption('symlink') + ]); + + $error = $sandboxCommand->run($sandboxArguments, $io); + if ($error === 0) { + $error = $installCommand->run($installArguments, $io); + } + + return $error; + } +} diff --git a/system/src/Grav/Console/Cli/PageSystemValidatorCommand.php b/system/src/Grav/Console/Cli/PageSystemValidatorCommand.php new file mode 100644 index 0000000..ce8ffdd --- /dev/null +++ b/system/src/Grav/Console/Cli/PageSystemValidatorCommand.php @@ -0,0 +1,299 @@ + [[]], + 'summary' => [[], [200], [200, true]], + 'content' => [[]], + 'getRawContent' => [[]], + 'rawMarkdown' => [[]], + 'value' => [['content'], ['route'], ['order'], ['ordering'], ['folder'], ['slug'], ['name'], /*['frontmatter'],*/ ['header.menu'], ['header.slug']], + 'title' => [[]], + 'menu' => [[]], + 'visible' => [[]], + 'published' => [[]], + 'publishDate' => [[]], + 'unpublishDate' => [[]], + 'process' => [[]], + 'slug' => [[]], + 'order' => [[]], + //'id' => [[]], + 'modified' => [[]], + 'lastModified' => [[]], + 'folder' => [[]], + 'date' => [[]], + 'dateformat' => [[]], + 'taxonomy' => [[]], + 'shouldProcess' => [['twig'], ['markdown']], + 'isPage' => [[]], + 'isDir' => [[]], + 'exists' => [[]], + + // Forms + 'forms' => [[]], + + // Routing + 'urlExtension' => [[]], + 'routable' => [[]], + 'link' => [[], [false], [true]], + 'permalink' => [[]], + 'canonical' => [[], [false], [true]], + 'url' => [[], [true], [true, true], [true, true, false], [false, false, true, false]], + 'route' => [[]], + 'rawRoute' => [[]], + 'routeAliases' => [[]], + 'routeCanonical' => [[]], + 'redirect' => [[]], + 'relativePagePath' => [[]], + 'path' => [[]], + //'folder' => [[]], + 'parent' => [[]], + 'topParent' => [[]], + 'currentPosition' => [[]], + 'active' => [[]], + 'activeChild' => [[]], + 'home' => [[]], + 'root' => [[]], + + // Translations + 'translatedLanguages' => [[], [false], [true]], + 'untranslatedLanguages' => [[], [false], [true]], + 'language' => [[]], + + // Legacy + 'raw' => [[]], + 'frontmatter' => [[]], + 'httpResponseCode' => [[]], + 'httpHeaders' => [[]], + 'blueprintName' => [[]], + 'name' => [[]], + 'childType' => [[]], + 'template' => [[]], + 'templateFormat' => [[]], + 'extension' => [[]], + 'expires' => [[]], + 'cacheControl' => [[]], + 'ssl' => [[]], + 'metadata' => [[]], + 'eTag' => [[]], + 'filePath' => [[]], + 'filePathClean' => [[]], + 'orderDir' => [[]], + 'orderBy' => [[]], + 'orderManual' => [[]], + 'maxCount' => [[]], + 'modular' => [[]], + 'modularTwig' => [[]], + //'children' => [[]], + 'isFirst' => [[]], + 'isLast' => [[]], + 'prevSibling' => [[]], + 'nextSibling' => [[]], + 'adjacentSibling' => [[]], + 'ancestor' => [[]], + //'inherited' => [[]], + //'inheritedField' => [[]], + 'find' => [['/']], + //'collection' => [[]], + //'evaluate' => [[]], + 'folderExists' => [[]], + //'getOriginal' => [[]], + //'getAction' => [[]], + ]; + + /** @var Grav */ + protected $grav; + + /** + * @return void + */ + protected function configure(): void + { + $this + ->setName('page-system-validator') + ->setDescription('Page validator can be used to compare site before/after update and when migrating to Flex Pages.') + ->addOption('record', 'r', InputOption::VALUE_NONE, 'Record results') + ->addOption('check', 'c', InputOption::VALUE_NONE, 'Compare site against previously recorded results') + ->setHelp('The page-system-validator command can be used to test the pages before and after upgrade'); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + $this->setLanguage('en'); + $this->initializePages(); + + $io->newLine(); + + $this->grav = $grav = Grav::instance(); + + $grav->fireEvent('onPageInitialized', new Event(['page' => $grav['page']])); + + /** @var Config $config */ + $config = $grav['config']; + + if ($input->getOption('record')) { + $io->writeln('Pages: ' . $config->get('system.pages.type', 'page')); + + $io->writeln('Record tests'); + $io->newLine(); + + $results = $this->record(); + $file = $this->getFile('pages-old'); + $file->save($results); + + $io->writeln('Recorded tests to ' . $file->filename()); + } elseif ($input->getOption('check')) { + $io->writeln('Pages: ' . $config->get('system.pages.type', 'page')); + + $io->writeln('Run tests'); + $io->newLine(); + + $new = $this->record(); + $file = $this->getFile('pages-new'); + $file->save($new); + $io->writeln('Recorded tests to ' . $file->filename()); + + $file = $this->getFile('pages-old'); + $old = $file->content(); + + $results = $this->check($old, $new); + $file = $this->getFile('diff'); + $file->save($results); + $io->writeln('Recorded results to ' . $file->filename()); + } else { + $io->writeln('page-system-validator [-r|--record] [-c|--check]'); + } + $io->newLine(); + + return 0; + } + + /** + * @return array + */ + private function record(): array + { + $io = $this->getIO(); + + /** @var Pages $pages */ + $pages = $this->grav['pages']; + $all = $pages->all(); + + $results = []; + $results[''] = $this->recordRow($pages->root()); + foreach ($all as $path => $page) { + if (null === $page) { + $io->writeln('Error on page ' . $path . ''); + continue; + } + + $results[$page->rawRoute()] = $this->recordRow($page); + } + + return json_decode(json_encode($results), true); + } + + /** + * @param PageInterface $page + * @return array + */ + private function recordRow(PageInterface $page): array + { + $results = []; + + foreach ($this->tests as $method => $params) { + $params = $params ?: [[]]; + foreach ($params as $p) { + $result = $page->$method(...$p); + if (in_array($method, ['summary', 'content', 'getRawContent'], true)) { + $result = preg_replace('/name="(form-nonce|__unique_form_id__)" value="[^"]+"/', + 'name="\\1" value="DYNAMIC"', $result); + $result = preg_replace('`src=("|\'|")/images/./././././[^"]+\\1`', + 'src="\\1images/GENERATED\\1', $result); + $result = preg_replace('/\?\d{10}/', '?1234567890', $result); + } elseif ($method === 'httpHeaders' && isset($result['Expires'])) { + $result['Expires'] = 'Thu, 19 Sep 2019 13:10:24 GMT (REPLACED AS DYNAMIC)'; + } elseif ($result instanceof PageInterface) { + $result = $result->rawRoute(); + } elseif (is_object($result)) { + $result = json_decode(json_encode($result), true); + } + + $ps = []; + foreach ($p as $val) { + $ps[] = (string)var_export($val, true); + } + $pstr = implode(', ', $ps); + $call = "->{$method}({$pstr})"; + $results[$call] = $result; + } + } + + return $results; + } + + /** + * @param array $old + * @param array $new + * @return array + */ + private function check(array $old, array $new): array + { + $errors = []; + foreach ($old as $path => $page) { + if (!isset($new[$path])) { + $errors[$path] = 'PAGE REMOVED'; + continue; + } + foreach ($page as $method => $test) { + if (($new[$path][$method] ?? null) !== $test) { + $errors[$path][$method] = ['old' => $test, 'new' => $new[$path][$method]]; + } + } + } + + return $errors; + } + + /** + * @param string $name + * @return CompiledYamlFile + */ + private function getFile(string $name): CompiledYamlFile + { + return CompiledYamlFile::instance('cache://tests/' . $name . '.yaml'); + } +} diff --git a/system/src/Grav/Console/Cli/SandboxCommand.php b/system/src/Grav/Console/Cli/SandboxCommand.php new file mode 100644 index 0000000..883388b --- /dev/null +++ b/system/src/Grav/Console/Cli/SandboxCommand.php @@ -0,0 +1,347 @@ + '/.gitignore', + '/.editorconfig' => '/.editorconfig', + '/CHANGELOG.md' => '/CHANGELOG.md', + '/LICENSE.txt' => '/LICENSE.txt', + '/README.md' => '/README.md', + '/CONTRIBUTING.md' => '/CONTRIBUTING.md', + '/index.php' => '/index.php', + '/composer.json' => '/composer.json', + '/bin' => '/bin', + '/system' => '/system', + '/vendor' => '/vendor', + '/webserver-configs' => '/webserver-configs', + ]; + + /** @var string */ + protected $source; + /** @var string */ + protected $destination; + + /** + * @return void + */ + protected function configure(): void + { + $this + ->setName('sandbox') + ->setDescription('Setup of a base Grav system in your webroot, good for development, playing around or starting fresh') + ->addArgument( + 'destination', + InputArgument::REQUIRED, + 'The destination directory to symlink into' + ) + ->addOption( + 'symlink', + 's', + InputOption::VALUE_NONE, + 'Symlink the base grav system' + ) + ->setHelp("The sandbox command help create a development environment that can optionally use symbolic links to link the core of grav to the git cloned repository.\nGood for development, playing around or starting fresh"); + + $source = getcwd(); + if ($source === false) { + throw new RuntimeException('Internal Error'); + } + $this->source = $source; + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + + $this->destination = $input->getArgument('destination'); + + // Create Some core stuff if it doesn't exist + $error = $this->createDirectories(); + if ($error) { + return $error; + } + + // Copy files or create symlinks + $error = $input->getOption('symlink') ? $this->symlink() : $this->copy(); + if ($error) { + return $error; + } + + $error = $this->pages(); + if ($error) { + return $error; + } + + $error = $this->initFiles(); + if ($error) { + return $error; + } + + $error = $this->perms(); + if ($error) { + return $error; + } + + return 0; + } + + /** + * @return int + */ + private function createDirectories(): int + { + $io = $this->getIO(); + + $io->newLine(); + $io->writeln('Creating Directories'); + $dirs_created = false; + + if (!file_exists($this->destination)) { + Folder::create($this->destination); + } + + foreach ($this->directories as $dir) { + if (!file_exists($this->destination . $dir)) { + $dirs_created = true; + $io->writeln(' ' . $dir . ''); + Folder::create($this->destination . $dir); + } + } + + if (!$dirs_created) { + $io->writeln(' Directories already exist'); + } + + return 0; + } + + /** + * @return int + */ + private function copy(): int + { + $io = $this->getIO(); + + $io->newLine(); + $io->writeln('Copying Files'); + + + foreach ($this->mappings as $source => $target) { + if ((string)(int)$source === (string)$source) { + $source = $target; + } + + $from = $this->source . $source; + $to = $this->destination . $target; + + $io->writeln(' ' . $source . ' -> ' . $to); + @Folder::rcopy($from, $to); + } + + return 0; + } + + /** + * @return int + */ + private function symlink(): int + { + $io = $this->getIO(); + + $io->newLine(); + $io->writeln('Resetting Symbolic Links'); + + // Symlink also tests if using git. + if (is_dir($this->source . '/tests')) { + $this->mappings['/tests'] = '/tests'; + } + + foreach ($this->mappings as $source => $target) { + if ((string)(int)$source === (string)$source) { + $source = $target; + } + + $from = $this->source . $source; + $to = $this->destination . $target; + + $io->writeln(' ' . $source . ' -> ' . $to); + + if (is_dir($to)) { + @Folder::delete($to); + } else { + @unlink($to); + } + symlink($from, $to); + } + + return 0; + } + + /** + * @return int + */ + private function pages(): int + { + $io = $this->getIO(); + + $io->newLine(); + $io->writeln('Pages Initializing'); + + // get pages files and initialize if no pages exist + $pages_dir = $this->destination . '/user/pages'; + $pages_files = array_diff(scandir($pages_dir), ['..', '.']); + + if (count($pages_files) === 0) { + $destination = $this->source . '/user/pages'; + Folder::rcopy($destination, $pages_dir); + $io->writeln(' ' . $destination . ' -> Created'); + } + + return 0; + } + + /** + * @return int + */ + private function initFiles(): int + { + if (!$this->check()) { + return 1; + } + + $io = $this->getIO(); + $io->newLine(); + $io->writeln('File Initializing'); + $files_init = false; + + // Copy files if they do not exist + foreach ($this->files as $source => $target) { + if ((string)(int)$source === (string)$source) { + $source = $target; + } + + $from = $this->source . $source; + $to = $this->destination . $target; + + if (!file_exists($to)) { + $files_init = true; + copy($from, $to); + $io->writeln(' ' . $target . ' -> Created'); + } + } + + if (!$files_init) { + $io->writeln(' Files already exist'); + } + + return 0; + } + + /** + * @return int + */ + private function perms(): int + { + $io = $this->getIO(); + $io->newLine(); + $io->writeln('Permissions Initializing'); + + $dir_perms = 0755; + + $binaries = glob($this->destination . DS . 'bin' . DS . '*'); + + foreach ($binaries as $bin) { + chmod($bin, $dir_perms); + $io->writeln(' bin/' . Utils::basename($bin) . ' permissions reset to ' . decoct($dir_perms)); + } + + $io->newLine(); + + return 0; + } + + /** + * @return bool + */ + private function check(): bool + { + $success = true; + $io = $this->getIO(); + + if (!file_exists($this->destination)) { + $io->writeln(' file: ' . $this->destination . ' does not exist!'); + $success = false; + } + + foreach ($this->directories as $dir) { + if (!file_exists($this->destination . $dir)) { + $io->writeln(' directory: ' . $dir . ' does not exist!'); + $success = false; + } + } + + foreach ($this->mappings as $target => $link) { + if (!file_exists($this->destination . $target)) { + $io->writeln(' mappings: ' . $target . ' does not exist!'); + $success = false; + } + } + + if (!$success) { + $io->newLine(); + $io->writeln('install should be run with --symlink|--s to symlink first'); + } + + return $success; + } +} diff --git a/system/src/Grav/Console/Cli/SchedulerCommand.php b/system/src/Grav/Console/Cli/SchedulerCommand.php new file mode 100644 index 0000000..f7ebc39 --- /dev/null +++ b/system/src/Grav/Console/Cli/SchedulerCommand.php @@ -0,0 +1,276 @@ +setName('scheduler') + ->addOption( + 'install', + 'i', + InputOption::VALUE_NONE, + 'Show Install Command' + ) + ->addOption( + 'jobs', + 'j', + InputOption::VALUE_NONE, + 'Show Jobs Summary' + ) + ->addOption( + 'details', + 'd', + InputOption::VALUE_NONE, + 'Show Job Details' + ) + ->addOption( + 'run', + 'r', + InputOption::VALUE_OPTIONAL, + 'Force run all jobs or a specific job if you specify a specific Job ID', + false + ) + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force all due jobs to run regardless of their schedule' + ) + ->setDescription('Run the Grav Scheduler. Best when integrated with system cron') + ->setHelp("Running without any options will process the Scheduler jobs based on their cron schedule. Use --force to run all jobs immediately."); + } + + /** + * @return int + */ + protected function serve(): int + { + $this->initializePlugins(); + + $grav = Grav::instance(); + $grav['backups']->init(); + $this->initializePages(); + $this->initializeThemes(); + + /** @var Scheduler $scheduler */ + $scheduler = $grav['scheduler']; + $grav->fireEvent('onSchedulerInitialized', new Event(['scheduler' => $scheduler])); + + $input = $this->getInput(); + $io = $this->getIO(); + $error = 0; + + $run = $input->getOption('run'); + $showDetails = $input->getOption('details'); + $showJobs = $input->getOption('jobs'); + $forceRun = $input->getOption('force'); + + // Handle running jobs first if -r flag is present + if ($run !== false) { + if ($run === null || $run === '') { + // Run all jobs when -r is provided without a specific job ID + $io->title('Force Run All Jobs'); + + $jobs = $scheduler->getAllJobs(); + $hasOutput = false; + + foreach ($jobs as $job) { + if ($job->getEnabled()) { + $io->section('Running: ' . $job->getId()); + $job->inForeground()->run(); + + if ($job->isSuccessful()) { + $io->success('Job ' . $job->getId() . ' ran successfully'); + } else { + $error = 1; + $io->error('Job ' . $job->getId() . ' failed to run'); + } + + $output = $job->getOutput(); + if ($output) { + $io->write($output); + $hasOutput = true; + } + } + } + + if (!$hasOutput) { + $io->note('All enabled jobs completed'); + } + } else { + // Run specific job + $io->title('Force Run Job: ' . $run); + + $job = $scheduler->getJob($run); + + if ($job) { + $job->inForeground()->run(); + + if ($job->isSuccessful()) { + $io->success('Job ran successfully...'); + } else { + $error = 1; + $io->error('Job failed to run successfully...'); + } + + $output = $job->getOutput(); + + if ($output) { + $io->write($output); + } + } else { + $error = 1; + $io->error('Could not find a job with id: ' . $run); + } + } + + // Add separator if we're going to show details after + if ($showDetails) { + $io->newLine(); + } + } + + if ($showJobs) { + // Show jobs list + + $jobs = $scheduler->getAllJobs(); + $job_states = (array)$scheduler->getJobStates()->content(); + $rows = []; + + $table = new Table($io); + $table->setStyle('box'); + $headers = ['Job ID', 'Command', 'Run At', 'Status', 'Last Run', 'State']; + + $io->title('Scheduler Jobs Listing'); + + foreach ($jobs as $job) { + $job_status = ucfirst($job_states[$job->getId()]['state'] ?? 'ready'); + $last_run = $job_states[$job->getId()]['last-run'] ?? 0; + $status = $job_status === 'Failure' ? "{$job_status}" : "{$job_status}"; + $state = $job->getEnabled() ? 'Enabled' : 'Disabled'; + $row = [ + $job->getId(), + "{$job->getCommand()}", + "{$job->getAt()}", + $status, + '' . ($last_run === 0 ? 'Never' : date('Y-m-d H:i', $last_run)) . '', + $state, + + ]; + $rows[] = $row; + } + + if (!empty($rows)) { + $table->setHeaders($headers); + $table->setRows($rows); + $table->render(); + } else { + $io->text('no jobs found...'); + } + + $io->newLine(); + $io->note('For error details run "bin/grav scheduler -d"'); + $io->newLine(); + } + + if ($showDetails) { + $jobs = $scheduler->getAllJobs(); + $job_states = (array)$scheduler->getJobStates()->content(); + + $io->title('Job Details'); + + $table = new Table($io); + $table->setStyle('box'); + $table->setHeaders(['Job ID', 'Last Run', 'Next Run', 'Errors']); + $rows = []; + + foreach ($jobs as $job) { + $job_state = $job_states[$job->getId()]; + $error = isset($job_state['error']) ? trim($job_state['error']) : false; + + /** @var CronExpression $expression */ + $expression = $job->getCronExpression(); + $next_run = $expression->getNextRunDate(); + + $row = []; + $row[] = $job->getId(); + if (!is_null($job_state['last-run'])) { + $row[] = '' . date('Y-m-d H:i', $job_state['last-run']) . ''; + } else { + $row[] = 'Never'; + } + $row[] = '' . $next_run->format('Y-m-d H:i') . ''; + + if ($error) { + $row[] = "{$error}"; + } else { + $row[] = 'None'; + } + $rows[] = $row; + } + + $table->setRows($rows); + $table->render(); + } + + if ($input->getOption('install')) { + $io->title('Install Scheduler'); + + $verb = 'install'; + + if ($scheduler->isCrontabSetup()) { + $io->success('All Ready! You have already set up Grav\'s Scheduler in your crontab. You can validate this by running "crontab -l" to list your current crontab entries.'); + $verb = 'reinstall'; + } else { + $user = $scheduler->whoami(); + $error = 1; + $io->error('Can\'t find a crontab for ' . $user . '. You need to set up Grav\'s Scheduler in your crontab'); + } + if (!Utils::isWindows()) { + $io->note("To $verb, run the following command from your terminal:"); + $io->newLine(); + $io->text(trim($scheduler->getCronCommand())); + } else { + $io->note("To $verb, create a scheduled task in Windows."); + $io->text('Learn more at https://learn.getgrav.org/advanced/scheduler'); + } + } elseif (!$showJobs && !$showDetails && $run === false) { + // Run scheduler only if no other options were provided + $scheduler->run(null, $forceRun); + + if ($input->getOption('verbose')) { + $io->title('Running Scheduled Jobs'); + $io->text($scheduler->getVerboseOutput()); + } + } + + return $error; + } +} diff --git a/system/src/Grav/Console/Cli/SecurityCommand.php b/system/src/Grav/Console/Cli/SecurityCommand.php new file mode 100644 index 0000000..bd2c4e7 --- /dev/null +++ b/system/src/Grav/Console/Cli/SecurityCommand.php @@ -0,0 +1,102 @@ +setName('security') + ->setDescription('Capable of running various Security checks') + ->setHelp('The security runs various security checks on your Grav site'); + } + + /** + * @return int + */ + protected function serve(): int + { + $this->initializePages(); + + $io = $this->getIO(); + + /** @var Grav $grav */ + $grav = Grav::instance(); + $this->progress = new ProgressBar($this->output, count($grav['pages']->routes()) - 1); + $this->progress->setFormat('Scanning %current% pages [%bar%] %percent:3s%% %elapsed:6s%'); + $this->progress->setBarWidth(100); + + $io->title('Grav Security Check'); + $io->newline(2); + + $output = Security::detectXssFromPages($grav['pages'], false, [$this, 'outputProgress']); + + $error = 0; + if (!empty($output)) { + $counter = 1; + foreach ($output as $route => $results) { + $results_parts = array_map(static function ($value, $key) { + return $key.': \''.$value . '\''; + }, array_values($results), array_keys($results)); + + $io->writeln($counter++ .' - ' . $route . '' . implode(', ', $results_parts) . ''); + } + + $error = 1; + $io->error('Security Scan complete: ' . count($output) . ' potential XSS issues found...'); + } else { + $io->success('Security Scan complete: No issues found...'); + } + + $io->newline(1); + + return $error; + } + + /** + * @param array $args + * @return void + */ + public function outputProgress(array $args): void + { + switch ($args['type']) { + case 'count': + $steps = $args['steps']; + $freq = (int)($steps > 100 ? round($steps / 100) : $steps); + $this->progress->setMaxSteps($steps); + $this->progress->setRedrawFrequency($freq); + break; + case 'progress': + if (isset($args['complete']) && $args['complete']) { + $this->progress->finish(); + } else { + $this->progress->advance(); + } + break; + } + } +} diff --git a/system/src/Grav/Console/Cli/ServerCommand.php b/system/src/Grav/Console/Cli/ServerCommand.php new file mode 100644 index 0000000..fc373c0 --- /dev/null +++ b/system/src/Grav/Console/Cli/ServerCommand.php @@ -0,0 +1,154 @@ +setName('server') + ->addOption('port', 'p', InputOption::VALUE_OPTIONAL, 'Preferred HTTP port rather than auto-find (default is 8000-9000') + ->addOption('symfony', null, InputOption::VALUE_NONE, 'Force using Symfony server') + ->addOption('php', null, InputOption::VALUE_NONE, 'Force using built-in PHP server') + ->setDescription("Runs built-in web-server, Symfony first, then tries PHP's") + ->setHelp("Runs built-in web-server, Symfony first, then tries PHP's"); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + $io->title('Grav Web Server'); + + // Ensure CLI colors are on + ini_set('cli_server.color', 'on'); + + // Options + $force_symfony = $input->getOption('symfony'); + $force_php = $input->getOption('php'); + + // Find PHP + $executableFinder = new PhpExecutableFinder(); + $php = $executableFinder->find(false); + + $this->ip = '127.0.0.1'; + $this->port = (int)($input->getOption('port') ?? 8000); + + // Get an open port + while (!$this->portAvailable($this->ip, $this->port)) { + $this->port++; + } + + // Setup the commands + $symfony_cmd = ['symfony', 'server:start', '--ansi', '--port=' . $this->port]; + $php_cmd = [$php, '-S', $this->ip.':'.$this->port, 'system/router.php']; + + $commands = [ + self::SYMFONY_SERVER => $symfony_cmd, + self::PHP_SERVER => $php_cmd + ]; + + if ($force_symfony) { + unset($commands[self::PHP_SERVER]); + } elseif ($force_php) { + unset($commands[self::SYMFONY_SERVER]); + } + + $error = 0; + foreach ($commands as $name => $command) { + $process = $this->runProcess($name, $command); + if (!$process) { + $io->note('Starting ' . $name . '...'); + } + + // Should only get here if there's an error running + if (!$process->isRunning() && (($name === self::SYMFONY_SERVER && $force_symfony) || ($name === self::PHP_SERVER))) { + $error = 1; + $io->error('Could not start ' . $name); + } + } + + return $error; + } + + /** + * @param string $name + * @param array $cmd + * @return Process + */ + protected function runProcess(string $name, array $cmd): Process + { + $io = $this->getIO(); + + $process = new Process($cmd); + $process->setTimeout(0); + $process->start(); + + if ($name === self::SYMFONY_SERVER && Utils::contains($process->getErrorOutput(), 'symfony: not found')) { + $io->error('The symfony binary could not be found, please install the CLI tools: https://symfony.com/download'); + $io->warning('Falling back to PHP web server...'); + } + + if ($name === self::PHP_SERVER) { + $io->success('Built-in PHP web server listening on http://' . $this->ip . ':' . $this->port . ' (PHP v' . PHP_VERSION . ')'); + } + + $process->wait(function ($type, $buffer) { + $this->getIO()->write($buffer); + }); + + return $process; + } + + /** + * Simple function test the port + * + * @param string $ip + * @param int $port + * @return bool + */ + protected function portAvailable(string $ip, int $port): bool + { + $fp = @fsockopen($ip, $port, $errno, $errstr, 0.1); + if (!$fp) { + return true; + } + + fclose($fp); + + return false; + } +} diff --git a/system/src/Grav/Console/Cli/YamlLinterCommand.php b/system/src/Grav/Console/Cli/YamlLinterCommand.php new file mode 100644 index 0000000..c794d84 --- /dev/null +++ b/system/src/Grav/Console/Cli/YamlLinterCommand.php @@ -0,0 +1,124 @@ +setName('yamllinter') + ->addOption( + 'all', + 'a', + InputOption::VALUE_NONE, + 'Go through the whole Grav installation' + ) + ->addOption( + 'folder', + 'f', + InputOption::VALUE_OPTIONAL, + 'Go through specific folder' + ) + ->setDescription('Checks various files for YAML errors') + ->setHelp('Checks various files for YAML errors'); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + $io->title('Yaml Linter'); + + $error = 0; + if ($input->getOption('all')) { + $io->section('All'); + $errors = YamlLinter::lint(''); + + if (empty($errors)) { + $io->success('No YAML Linting issues found'); + } else { + $error = 1; + $this->displayErrors($errors, $io); + } + } elseif ($folder = $input->getOption('folder')) { + $io->section($folder); + $errors = YamlLinter::lint($folder); + + if (empty($errors)) { + $io->success('No YAML Linting issues found'); + } else { + $error = 1; + $this->displayErrors($errors, $io); + } + } else { + $io->section('User Configuration'); + $errors = YamlLinter::lintConfig(); + + if (empty($errors)) { + $io->success('No YAML Linting issues with configuration'); + } else { + $error = 1; + $this->displayErrors($errors, $io); + } + + $io->section('Pages Frontmatter'); + $errors = YamlLinter::lintPages(); + + if (empty($errors)) { + $io->success('No YAML Linting issues with pages'); + } else { + $error = 1; + $this->displayErrors($errors, $io); + } + + $io->section('Page Blueprints'); + $errors = YamlLinter::lintBlueprints(); + + if (empty($errors)) { + $io->success('No YAML Linting issues with blueprints'); + } else { + $error = 1; + $this->displayErrors($errors, $io); + } + } + + return $error; + } + + /** + * @param array $errors + * @param SymfonyStyle $io + * @return void + */ + protected function displayErrors(array $errors, SymfonyStyle $io): void + { + $io->error('YAML Linting issues found...'); + foreach ($errors as $path => $error) { + $io->writeln("{$path} - {$error}"); + } + } +} diff --git a/system/src/Grav/Console/ConsoleCommand.php b/system/src/Grav/Console/ConsoleCommand.php new file mode 100644 index 0000000..beee3d0 --- /dev/null +++ b/system/src/Grav/Console/ConsoleCommand.php @@ -0,0 +1,46 @@ +setupConsole($input, $output); + + return $this->serve(); + } + + /** + * Override with your implementation. + * + * @return int + */ + protected function serve() + { + // Return error. + return 1; + } +} diff --git a/system/src/Grav/Console/ConsoleTrait.php b/system/src/Grav/Console/ConsoleTrait.php new file mode 100644 index 0000000..43aadd9 --- /dev/null +++ b/system/src/Grav/Console/ConsoleTrait.php @@ -0,0 +1,338 @@ +argv = $_SERVER['argv'][0]; + $this->input = $input; + $this->output = new SymfonyStyle($input, $output); + + $this->setupGrav(); + } + + public function getInput(): InputInterface + { + return $this->input; + } + + /** + * @return SymfonyStyle + */ + public function getIO(): SymfonyStyle + { + return $this->output; + } + + /** + * Adds an option. + * + * @param string $name The option name + * @param string|array|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts + * @param int|null $mode The option mode: One of the InputOption::VALUE_* constants + * @param string $description A description text + * @param string|string[]|int|bool|null $default The default value (must be null for InputOption::VALUE_NONE) + * @return $this + * @throws InvalidArgumentException If option mode is invalid or incompatible + */ + public function addOption($name, $shortcut = null, $mode = null, $description = '', $default = null) + { + if ($name !== 'env' && $name !== 'lang') { + parent::addOption($name, $shortcut, $mode, $description, $default); + } + + return $this; + } + + /** + * @return void + */ + final protected function setupGrav(): void + { + try { + $language = $this->input->getOption('lang'); + if ($language) { + // Set used language. + $this->setLanguage($language); + } + } catch (InvalidArgumentException $e) {} + + // Initialize cache with CLI compatibility + $grav = Grav::instance(); + $grav['config']->set('system.cache.cli_compatibility', true); + } + + /** + * Initialize Grav. + * + * - Load configuration + * - Initialize logger + * - Disable debugger + * - Set timezone, locale + * - Load plugins (call PluginsLoadedEvent) + * - Set Pages and Users type to be used in the site + * + * Safe to be called multiple times. + * + * @return $this + */ + final protected function initializeGrav() + { + InitializeProcessor::initializeCli(Grav::instance()); + + return $this; + } + + /** + * Set language to be used in CLI. + * + * @param string|null $code + * @return $this + */ + final protected function setLanguage(string $code = null) + { + $this->initializeGrav(); + + $grav = Grav::instance(); + /** @var Language $language */ + $language = $grav['language']; + if ($language->enabled()) { + if ($code && $language->validate($code)) { + $language->setActive($code); + } else { + $language->setActive($language->getDefault()); + } + } + + return $this; + } + + /** + * Properly initialize plugins. + * + * - call $this->initializeGrav() + * - call onPluginsInitialized event + * + * Safe to be called multiple times. + * + * @return $this + */ + final protected function initializePlugins() + { + if (!$this->plugins_initialized) { + $this->plugins_initialized = true; + + $this->initializeGrav(); + + // Initialize plugins. + $grav = Grav::instance(); + $grav['plugins']->init(); + $grav->fireEvent('onPluginsInitialized'); + } + + return $this; + } + + /** + * Properly initialize themes. + * + * - call $this->initializePlugins() + * - initialize theme (call onThemeInitialized event) + * + * Safe to be called multiple times. + * + * @return $this + */ + final protected function initializeThemes() + { + if (!$this->themes_initialized) { + $this->themes_initialized = true; + + $this->initializePlugins(); + + // Initialize themes. + $grav = Grav::instance(); + $grav['themes']->init(); + } + + return $this; + } + + /** + * Properly initialize pages. + * + * - call $this->initializeThemes() + * - initialize assets (call onAssetsInitialized event) + * - initialize twig (calls the twig events) + * - initialize pages (calls onPagesInitialized event) + * + * Safe to be called multiple times. + * + * @return $this + */ + final protected function initializePages() + { + if (!$this->pages_initialized) { + $this->pages_initialized = true; + + $this->initializeThemes(); + + $grav = Grav::instance(); + + // Initialize assets. + $grav['assets']->init(); + $grav->fireEvent('onAssetsInitialized'); + + // Initialize twig. + $grav['twig']->init(); + + // Initialize pages. + $pages = $grav['pages']; + $pages->init(); + $grav->fireEvent('onPagesInitialized', new Event(['pages' => $pages])); + } + + return $this; + } + + /** + * @param string $path + * @return void + */ + public function isGravInstance($path) + { + $io = $this->getIO(); + + if (!file_exists($path)) { + $io->writeln(''); + $io->writeln("ERROR: Destination doesn't exist:"); + $io->writeln(" $path"); + $io->writeln(''); + exit; + } + + if (!is_dir($path)) { + $io->writeln(''); + $io->writeln("ERROR: Destination chosen to install is not a directory:"); + $io->writeln(" $path"); + $io->writeln(''); + exit; + } + + if (!file_exists($path . DS . 'index.php') || !file_exists($path . DS . '.dependencies') || !file_exists($path . DS . 'system' . DS . 'config' . DS . 'system.yaml')) { + $io->writeln(''); + $io->writeln('ERROR: Destination chosen to install does not appear to be a Grav instance:'); + $io->writeln(" $path"); + $io->writeln(''); + exit; + } + } + + /** + * @param string $path + * @param string $action + * @return string|false + */ + public function composerUpdate($path, $action = 'install') + { + $composer = Composer::getComposerExecutor(); + + return system($composer . ' --working-dir=' . escapeshellarg($path) . ' --no-interaction --no-dev --prefer-dist -o '. $action); + } + + /** + * @param array $all + * @return int + * @throws Exception + */ + public function clearCache($all = []) + { + if ($all) { + $all = ['--all' => true]; + } + + $command = new ClearCacheCommand(); + $input = new ArrayInput($all); + return $command->run($input, $this->output); + } + + /** + * @return void + */ + public function invalidateCache() + { + Cache::invalidateCache(); + } + + /** + * Load the local config file + * + * @return string|false The local config file name. false if local config does not exist + */ + public function loadLocalConfig() + { + $home_folder = getenv('HOME') ?: getenv('HOMEDRIVE') . getenv('HOMEPATH'); + $local_config_file = $home_folder . '/.grav/config'; + + if (file_exists($local_config_file)) { + $file = YamlFile::instance($local_config_file); + $this->local_config = $file->content(); + $file->free(); + + return $local_config_file; + } + + return false; + } +} diff --git a/system/src/Grav/Console/Gpm/DirectInstallCommand.php b/system/src/Grav/Console/Gpm/DirectInstallCommand.php new file mode 100644 index 0000000..74a0c6b --- /dev/null +++ b/system/src/Grav/Console/Gpm/DirectInstallCommand.php @@ -0,0 +1,321 @@ +setName('direct-install') + ->setAliases(['directinstall']) + ->addArgument( + 'package-file', + InputArgument::REQUIRED, + 'Installable package local or remote . Can install specific version' + ) + ->addOption( + 'all-yes', + 'y', + InputOption::VALUE_NONE, + 'Assumes yes (or best approach) instead of prompting' + ) + ->addOption( + 'destination', + 'd', + InputOption::VALUE_OPTIONAL, + 'The destination where the package should be installed at. By default this would be where the grav instance has been launched from', + GRAV_ROOT + ) + ->setDescription('Installs Grav, plugin, or theme directly from a file or a URL') + ->setHelp('The direct-install command installs Grav, plugin, or theme directly from a file or a URL'); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + if (!class_exists(ZipArchive::class)) { + $io->title('Direct Install'); + $io->error('php-zip extension needs to be enabled!'); + + return 1; + } + + // Making sure the destination is usable + $this->destination = realpath($input->getOption('destination')); + + if (!Installer::isGravInstance($this->destination) || + !Installer::isValidDestination($this->destination, [Installer::EXISTS, Installer::IS_LINK]) + ) { + $io->writeln('ERROR: ' . Installer::lastErrorMsg()); + + return 1; + } + + $this->all_yes = $input->getOption('all-yes'); + + $package_file = $input->getArgument('package-file'); + + $question = new ConfirmationQuestion("Are you sure you want to direct-install {$package_file} [y|N] ", false); + + $answer = $this->all_yes ? true : $io->askQuestion($question); + + if (!$answer) { + $io->writeln('exiting...'); + $io->newLine(); + + return 1; + } + + $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true); + $tmp_zip = $tmp_dir . uniqid('/Grav-', false); + + $io->newLine(); + $io->writeln("Preparing to install {$package_file}"); + + $zip = null; + if (Response::isRemote($package_file)) { + $io->write(' |- Downloading package... 0%'); + try { + $zip = GPM::downloadPackage($package_file, $tmp_zip); + } catch (RuntimeException $e) { + $io->newLine(); + $io->writeln(" `- ERROR: {$e->getMessage()}"); + $io->newLine(); + + return 1; + } + + if ($zip) { + $io->write("\x0D"); + $io->write(' |- Downloading package... 100%'); + $io->newLine(); + } + } elseif (is_file($package_file)) { + $io->write(' |- Copying package... 0%'); + $zip = GPM::copyPackage($package_file, $tmp_zip); + if ($zip) { + $io->write("\x0D"); + $io->write(' |- Copying package... 100%'); + $io->newLine(); + } + } + + if ($zip && file_exists($zip)) { + $tmp_source = $tmp_dir . uniqid('/Grav-', false); + + $io->write(' |- Extracting package... '); + $extracted = Installer::unZip($zip, $tmp_source); + + if (!$extracted) { + $io->write("\x0D"); + $io->writeln(' |- Extracting package... failed'); + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + + return 1; + } + + $io->write("\x0D"); + $io->writeln(' |- Extracting package... ok'); + + + $type = GPM::getPackageType($extracted); + + if (!$type) { + $io->writeln(" '- ERROR: Not a valid Grav package"); + $io->newLine(); + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + + return 1; + } + + $blueprint = GPM::getBlueprints($extracted); + if ($blueprint) { + if (isset($blueprint['dependencies'])) { + $dependencies = []; + foreach ($blueprint['dependencies'] as $dependency) { + if (is_array($dependency)) { + if (isset($dependency['name'])) { + $dependencies[] = $dependency['name']; + } + if (isset($dependency['github'])) { + $dependencies[] = $dependency['github']; + } + } else { + $dependencies[] = $dependency; + } + } + $io->writeln(' |- Dependencies found... [' . implode(',', $dependencies) . ']'); + + $question = new ConfirmationQuestion(" | '- Dependencies will not be satisfied. Continue ? [y|N] ", false); + $answer = $this->all_yes ? true : $io->askQuestion($question); + + if (!$answer) { + $io->writeln('exiting...'); + $io->newLine(); + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + + return 1; + } + } + } + + if ($type === 'grav') { + $io->write(' |- Checking destination... '); + Installer::isValidDestination(GRAV_ROOT . '/system'); + if (Installer::IS_LINK === Installer::lastErrorCode()) { + $io->write("\x0D"); + $io->writeln(' |- Checking destination... symbolic link'); + $io->writeln(" '- ERROR: symlinks found... " . GRAV_ROOT . ''); + $io->newLine(); + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + + return 1; + } + + $io->write("\x0D"); + $io->writeln(' |- Checking destination... ok'); + + $io->write(' |- Installing package... '); + + $this->upgradeGrav($zip, $extracted); + } else { + $name = GPM::getPackageName($extracted); + + if (!$name) { + $io->writeln('ERROR: Name could not be determined. Please specify with --name|-n'); + $io->newLine(); + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + + return 1; + } + + $install_path = GPM::getInstallPath($type, $name); + $is_update = file_exists($install_path); + + $io->write(' |- Checking destination... '); + + Installer::isValidDestination(GRAV_ROOT . DS . $install_path); + if (Installer::lastErrorCode() === Installer::IS_LINK) { + $io->write("\x0D"); + $io->writeln(' |- Checking destination... symbolic link'); + $io->writeln(" '- ERROR: symlink found... " . GRAV_ROOT . DS . $install_path . ''); + $io->newLine(); + Folder::delete($tmp_source); + Folder::delete($tmp_zip); + + return 1; + } + + $io->write("\x0D"); + $io->writeln(' |- Checking destination... ok'); + + $io->write(' |- Installing package... '); + + Installer::install( + $zip, + $this->destination, + $options = [ + 'install_path' => $install_path, + 'theme' => (($type === 'theme')), + 'is_update' => $is_update + ], + $extracted + ); + + // clear cache after successful upgrade + $this->clearCache(); + } + + Folder::delete($tmp_source); + + $io->write("\x0D"); + + if (Installer::lastErrorCode()) { + $io->writeln(" '- " . Installer::lastErrorMsg() . ''); + $io->newLine(); + } else { + $io->writeln(' |- Installing package... ok'); + $io->writeln(" '- Success! "); + $io->newLine(); + } + } else { + $io->writeln(" '- ERROR: ZIP package could not be found"); + Folder::delete($tmp_zip); + + return 1; + } + + Folder::delete($tmp_zip); + + return 0; + } + + /** + * @param string $zip + * @param string $folder + * @return void + */ + private function upgradeGrav(string $zip, string $folder): void + { + if (!is_dir($folder)) { + Installer::setError('Invalid source folder'); + } + + try { + $script = $folder . '/system/install.php'; + /** Install $installer */ + if ((file_exists($script) && $install = include $script) && is_callable($install)) { + $install($zip); + } else { + throw new RuntimeException('Uploaded archive file is not a valid Grav update package'); + } + } catch (Exception $e) { + Installer::setError($e->getMessage()); + } + } +} diff --git a/system/src/Grav/Console/Gpm/IndexCommand.php b/system/src/Grav/Console/Gpm/IndexCommand.php new file mode 100644 index 0000000..082fe00 --- /dev/null +++ b/system/src/Grav/Console/Gpm/IndexCommand.php @@ -0,0 +1,335 @@ +setName('index') + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force re-fetching the data from remote' + ) + ->addOption( + 'filter', + 'F', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Allows to limit the results based on one or multiple filters input. This can be either portion of a name/slug or a regex' + ) + ->addOption( + 'themes-only', + 'T', + InputOption::VALUE_NONE, + 'Filters the results to only Themes' + ) + ->addOption( + 'plugins-only', + 'P', + InputOption::VALUE_NONE, + 'Filters the results to only Plugins' + ) + ->addOption( + 'updates-only', + 'U', + InputOption::VALUE_NONE, + 'Filters the results to Updatable Themes and Plugins only' + ) + ->addOption( + 'installed-only', + 'I', + InputOption::VALUE_NONE, + 'Filters the results to only the Themes and Plugins you have installed' + ) + ->addOption( + 'sort', + 's', + InputOption::VALUE_REQUIRED, + 'Allows to sort (ASC) the results. SORT can be either "name", "slug", "author", "date"', + 'date' + ) + ->addOption( + 'desc', + 'D', + InputOption::VALUE_NONE, + 'Reverses the order of the output.' + ) + ->addOption( + 'enabled', + 'e', + InputOption::VALUE_NONE, + 'Filters the results to only enabled Themes and Plugins.' + ) + ->addOption( + 'disabled', + 'd', + InputOption::VALUE_NONE, + 'Filters the results to only disabled Themes and Plugins.' + ) + ->setDescription('Lists the plugins and themes available for installation') + ->setHelp('The index command lists the plugins and themes available for installation') + ; + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $this->options = $input->getOptions(); + $this->gpm = new GPM($this->options['force']); + $this->displayGPMRelease(); + $this->data = $this->gpm->getRepository(); + + $data = $this->filter($this->data); + + $io = $this->getIO(); + + if (count($data) === 0) { + $io->writeln('No data was found in the GPM repository stored locally.'); + $io->writeln('Please try clearing cache and running the bin/gpm index -f command again'); + $io->writeln('If this doesn\'t work try tweaking your GPM system settings.'); + $io->newLine(); + $io->writeln('For more help go to:'); + $io->writeln(' -> https://learn.getgrav.org/troubleshooting/common-problems#cannot-connect-to-the-gpm'); + + return 1; + } + + foreach ($data as $type => $packages) { + $io->writeln('' . strtoupper($type) . ' [ ' . count($packages) . ' ]'); + + $packages = $this->sort($packages); + + if (!empty($packages)) { + $io->section('Packages table'); + $table = new Table($io); + $table->setHeaders(['Count', 'Name', 'Slug', 'Version', 'Installed', 'Enabled']); + + $index = 0; + foreach ($packages as $slug => $package) { + $row = [ + 'Count' => $index++ + 1, + 'Name' => '' . Utils::truncate($package->name, 20, false, ' ', '...') . ' ', + 'Slug' => $slug, + 'Version'=> $this->version($package), + 'Installed' => $this->installed($package), + 'Enabled' => $this->enabled($package), + ]; + + $table->addRow($row); + } + + $table->render(); + } + + $io->newLine(); + } + + $io->writeln('You can either get more informations about a package by typing:'); + $io->writeln(" {$this->argv} info "); + $io->newLine(); + $io->writeln('Or you can install a package by typing:'); + $io->writeln(" {$this->argv} install "); + $io->newLine(); + + return 0; + } + + /** + * @param Package $package + * @return string + */ + private function version(Package $package): string + { + $list = $this->gpm->{'getUpdatable' . ucfirst($package->package_type)}(); + $package = $list[$package->slug] ?? $package; + $type = ucfirst(preg_replace('/s$/', '', $package->package_type)); + $updatable = $this->gpm->{'is' . $type . 'Updatable'}($package->slug); + $installed = $this->gpm->{'is' . $type . 'Installed'}($package->slug); + $local = $this->gpm->{'getInstalled' . $type}($package->slug); + + if (!$installed || !$updatable) { + $version = $installed ? $local->version : $package->version; + return "v{$version}"; + } + + return "v{$package->version} -> v{$package->available}"; + } + + /** + * @param Package $package + * @return string + */ + private function installed(Package $package): string + { + $type = ucfirst(preg_replace('/s$/', '', $package->package_type)); + $method = 'is' . $type . 'Installed'; + $installed = $this->gpm->{$method}($package->slug); + + return !$installed ? 'not installed' : 'installed'; + } + + /** + * @param Package $package + * @return string + */ + private function enabled(Package $package): string + { + $type = ucfirst(preg_replace('/s$/', '', $package->package_type)); + $method = 'is' . $type . 'Installed'; + $installed = $this->gpm->{$method}($package->slug); + + $result = ''; + if ($installed) { + $method = 'is' . $type . 'Enabled'; + $enabled = $this->gpm->{$method}($package->slug); + if ($enabled === true) { + $result = 'enabled'; + } elseif ($enabled === false) { + $result = 'disabled'; + } + } + + return $result; + } + + /** + * @param Packages $data + * @return Packages + */ + public function filter(Packages $data): Packages + { + // filtering and sorting + if ($this->options['plugins-only']) { + unset($data['themes']); + } + if ($this->options['themes-only']) { + unset($data['plugins']); + } + + $filter = [ + $this->options['desc'], + $this->options['disabled'], + $this->options['enabled'], + $this->options['filter'], + $this->options['installed-only'], + $this->options['updates-only'], + ]; + + if (count(array_filter($filter))) { + foreach ($data as $type => $packages) { + foreach ($packages as $slug => $package) { + $filter = true; + + // Filtering by string + if ($this->options['filter']) { + $filter = preg_grep('/(' . implode('|', $this->options['filter']) . ')/i', [$slug, $package->name]); + } + + // Filtering updatables only + if ($filter && ($this->options['installed-only'] || $this->options['enabled'] || $this->options['disabled'])) { + $method = ucfirst(preg_replace('/s$/', '', $package->package_type)); + $function = 'is' . $method . 'Installed'; + $filter = $this->gpm->{$function}($package->slug); + } + + // Filtering updatables only + if ($filter && $this->options['updates-only']) { + $method = ucfirst(preg_replace('/s$/', '', $package->package_type)); + $function = 'is' . $method . 'Updatable'; + $filter = $this->gpm->{$function}($package->slug); + } + + // Filtering enabled only + if ($filter && $this->options['enabled']) { + $method = ucfirst(preg_replace('/s$/', '', $package->package_type)); + + // Check if packaged is enabled. + $function = 'is' . $method . 'Enabled'; + $filter = $this->gpm->{$function}($package->slug); + } + + // Filtering disabled only + if ($filter && $this->options['disabled']) { + $method = ucfirst(preg_replace('/s$/', '', $package->package_type)); + + // Check if package is disabled. + $function = 'is' . $method . 'Enabled'; + $enabled_filter = $this->gpm->{$function}($package->slug); + + // Apply filtering results. + if (!( $enabled_filter === false)) { + $filter = false; + } + } + + if (!$filter) { + unset($data[$type][$slug]); + } + } + } + } + + return $data; + } + + /** + * @param AbstractPackageCollection|Plugins|Themes $packages + * @return array + */ + public function sort(AbstractPackageCollection $packages): array + { + $key = $this->options['sort']; + + // Sorting only works once. + return $packages->sort( + function ($a, $b) use ($key) { + switch ($key) { + case 'author': + return strcmp($a->{$key}['name'], $b->{$key}['name']); + default: + return strcmp($a->$key, $b->$key); + } + }, + $this->options['desc'] ? true : false + ); + } +} diff --git a/system/src/Grav/Console/Gpm/InfoCommand.php b/system/src/Grav/Console/Gpm/InfoCommand.php new file mode 100644 index 0000000..0eb6c66 --- /dev/null +++ b/system/src/Grav/Console/Gpm/InfoCommand.php @@ -0,0 +1,191 @@ +setName('info') + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force fetching the new data remotely' + ) + ->addOption( + 'all-yes', + 'y', + InputOption::VALUE_NONE, + 'Assumes yes (or best approach) instead of prompting' + ) + ->addArgument( + 'package', + InputArgument::REQUIRED, + 'The package of which more informations are desired. Use the "index" command for a list of packages' + ) + ->setDescription('Shows more informations about a package') + ->setHelp('The info shows more information about a package'); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + $this->gpm = new GPM($input->getOption('force')); + + $this->all_yes = $input->getOption('all-yes'); + + $this->displayGPMRelease(); + + $foundPackage = $this->gpm->findPackage($input->getArgument('package')); + + if (!$foundPackage) { + $io->writeln("The package '{$input->getArgument('package')}' was not found in the Grav repository."); + $io->newLine(); + $io->writeln('You can list all the available packages by typing:'); + $io->writeln(" {$this->argv} index"); + $io->newLine(); + + return 1; + } + + $io->writeln("Found package '{$input->getArgument('package')}' under the '" . ucfirst($foundPackage->package_type) . "' section"); + $io->newLine(); + $io->writeln("{$foundPackage->name} [{$foundPackage->slug}]"); + $io->writeln(str_repeat('-', strlen($foundPackage->name) + strlen($foundPackage->slug) + 3)); + $io->writeln('' . strip_tags($foundPackage->description_plain) . ''); + $io->newLine(); + + $packageURL = ''; + if (isset($foundPackage->author['url'])) { + $packageURL = '<' . $foundPackage->author['url'] . '>'; + } + + $io->writeln('' . str_pad( + 'Author', + 12 + ) . ': ' . $foundPackage->author['name'] . ' <' . $foundPackage->author['email'] . '> ' . $packageURL); + + foreach ([ + 'version', + 'keywords', + 'date', + 'homepage', + 'demo', + 'docs', + 'guide', + 'repository', + 'bugs', + 'zipball_url', + 'license' + ] as $info) { + if (isset($foundPackage->{$info})) { + $name = ucfirst($info); + $data = $foundPackage->{$info}; + + if ($info === 'zipball_url') { + $name = 'Download'; + } + + if ($info === 'date') { + $name = 'Last Update'; + $data = date('D, j M Y, H:i:s, P ', strtotime($data)); + } + + $name = str_pad($name, 12); + $io->writeln("{$name}: {$data}"); + } + } + + $type = rtrim($foundPackage->package_type, 's'); + $updatable = $this->gpm->{'is' . $type . 'Updatable'}($foundPackage->slug); + $installed = $this->gpm->{'is' . $type . 'Installed'}($foundPackage->slug); + + // display current version if installed and different + if ($installed && $updatable) { + $local = $this->gpm->{'getInstalled'. $type}($foundPackage->slug); + $io->newLine(); + $io->writeln("Currently installed version: {$local->version}"); + $io->newLine(); + } + + // display changelog information + $question = new ConfirmationQuestion( + 'Would you like to read the changelog? [y|N] ', + false + ); + $answer = $this->all_yes ? true : $io->askQuestion($question); + + if ($answer) { + $changelog = $foundPackage->changelog; + + $io->newLine(); + foreach ($changelog as $version => $log) { + $title = $version . ' [' . $log['date'] . ']'; + $content = preg_replace_callback('/\d\.\s\[\]\(#(.*)\)/', static function ($match) { + return "\n" . ucfirst($match[1]) . ':'; + }, $log['content']); + + $io->writeln("{$title}"); + $io->writeln(str_repeat('-', strlen($title))); + $io->writeln($content); + $io->newLine(); + + $question = new ConfirmationQuestion('Press [ENTER] to continue or [q] to quit ', true); + $answer = $this->all_yes ? false : $io->askQuestion($question); + if (!$answer) { + break; + } + $io->newLine(); + } + } + + $io->newLine(); + + if ($installed && $updatable) { + $io->writeln('You can update this package by typing:'); + $io->writeln(" {$this->argv} update {$foundPackage->slug}"); + } else { + $io->writeln('You can install this package by typing:'); + $io->writeln(" {$this->argv} install {$foundPackage->slug}"); + } + + $io->newLine(); + + return 0; + } +} diff --git a/system/src/Grav/Console/Gpm/InstallCommand.php b/system/src/Grav/Console/Gpm/InstallCommand.php new file mode 100644 index 0000000..7c2a047 --- /dev/null +++ b/system/src/Grav/Console/Gpm/InstallCommand.php @@ -0,0 +1,726 @@ +setName('install') + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force re-fetching the data from remote' + ) + ->addOption( + 'all-yes', + 'y', + InputOption::VALUE_NONE, + 'Assumes yes (or best approach) instead of prompting' + ) + ->addOption( + 'destination', + 'd', + InputOption::VALUE_OPTIONAL, + 'The destination where the package should be installed at. By default this would be where the grav instance has been launched from', + GRAV_ROOT + ) + ->addArgument( + 'package', + InputArgument::IS_ARRAY | InputArgument::REQUIRED, + 'Package(s) to install. Use "bin/gpm index" to list packages. Use "bin/gpm direct-install" to install a specific version' + ) + ->setDescription('Performs the installation of plugins and themes') + ->setHelp('The install command allows to install plugins and themes'); + } + + /** + * Allows to set the GPM object, used for testing the class + * + * @param GPM $gpm + */ + public function setGpm(GPM $gpm): void + { + $this->gpm = $gpm; + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + if (!class_exists(ZipArchive::class)) { + $io->title('GPM Install'); + $io->error('php-zip extension needs to be enabled!'); + + return 1; + } + + $this->gpm = new GPM($input->getOption('force')); + + $this->all_yes = $input->getOption('all-yes'); + + $this->displayGPMRelease(); + + $this->destination = realpath($input->getOption('destination')); + + $packages = array_map('strtolower', $input->getArgument('package')); + $this->data = $this->gpm->findPackages($packages); + $this->loadLocalConfig(); + + if (!Installer::isGravInstance($this->destination) || + !Installer::isValidDestination($this->destination, [Installer::EXISTS, Installer::IS_LINK]) + ) { + $io->writeln('ERROR: ' . Installer::lastErrorMsg()); + + return 1; + } + + $io->newLine(); + + if (!$this->data['total']) { + $io->writeln('Nothing to install.'); + $io->newLine(); + + return 0; + } + + if (count($this->data['not_found'])) { + $io->writeln('These packages were not found on Grav: ' . implode( + ', ', + array_keys($this->data['not_found']) + ) . ''); + } + + unset($this->data['not_found'], $this->data['total']); + + if (null !== $this->local_config) { + // Symlinks available, ask if Grav should use them + $this->use_symlinks = false; + $question = new ConfirmationQuestion('Should Grav use the symlinks if available? [y|N] ', false); + + $answer = $this->all_yes ? false : $io->askQuestion($question); + + if ($answer) { + $this->use_symlinks = true; + } + } + + $io->newLine(); + + try { + $dependencies = $this->gpm->getDependencies($packages); + } catch (Exception $e) { + //Error out if there are incompatible packages requirements and tell which ones, and what to do + //Error out if there is any error in parsing the dependencies and their versions, and tell which one is broken + $io->writeln("{$e->getMessage()}"); + + return 1; + } + + if ($dependencies) { + try { + $this->installDependencies($dependencies, 'install', 'The following dependencies need to be installed...'); + $this->installDependencies($dependencies, 'update', 'The following dependencies need to be updated...'); + $this->installDependencies($dependencies, 'ignore', "The following dependencies can be updated as there is a newer version, but it's not mandatory...", false); + } catch (Exception $e) { + $io->writeln('Installation aborted'); + + return 1; + } + + $io->writeln('Dependencies are OK'); + $io->newLine(); + } + + + //We're done installing dependencies. Install the actual packages + foreach ($this->data as $data) { + foreach ($data as $package_name => $package) { + if (array_key_exists($package_name, $dependencies)) { + $io->writeln("Package {$package_name} already installed as dependency"); + } else { + $is_valid_destination = Installer::isValidDestination($this->destination . DS . $package->install_path); + if ($is_valid_destination || Installer::lastErrorCode() == Installer::NOT_FOUND) { + $this->processPackage($package, false); + } else { + if (Installer::lastErrorCode() == Installer::EXISTS) { + try { + $this->askConfirmationIfMajorVersionUpdated($package); + $this->gpm->checkNoOtherPackageNeedsThisDependencyInALowerVersion($package->slug, $package->available, array_keys($data)); + } catch (Exception $e) { + $io->writeln("{$e->getMessage()}"); + + return 1; + } + + $question = new ConfirmationQuestion("The package {$package_name} is already installed, overwrite? [y|N] ", false); + $answer = $this->all_yes ? true : $io->askQuestion($question); + + if ($answer) { + $is_update = true; + $this->processPackage($package, $is_update); + } else { + $io->writeln("Package {$package_name} not overwritten"); + } + } else { + if (Installer::lastErrorCode() == Installer::IS_LINK) { + $io->writeln("Cannot overwrite existing symlink for {$package_name}"); + $io->newLine(); + } + } + } + } + } + } + + if (count($this->demo_processing) > 0) { + foreach ($this->demo_processing as $package) { + $this->installDemoContent($package); + } + } + + // clear cache after successful upgrade + $this->clearCache(); + + return 0; + } + + /** + * If the package is updated from an older major release, show warning and ask confirmation + * + * @param Package $package + * @return void + */ + public function askConfirmationIfMajorVersionUpdated(Package $package): void + { + $io = $this->getIO(); + $package_name = $package->name; + $new_version = $package->available ?: $this->gpm->getLatestVersionOfPackage($package->slug); + $old_version = $package->version; + + $major_version_changed = explode('.', $new_version)[0] !== explode('.', $old_version)[0]; + + if ($major_version_changed) { + if ($this->all_yes) { + $io->writeln("The package {$package_name} will be updated to a new major version {$new_version}, from {$old_version}"); + return; + } + + $question = new ConfirmationQuestion("The package {$package_name} will be updated to a new major version {$new_version}, from {$old_version}. Be sure to read what changed with the new major release. Continue? [y|N] ", false); + + if (!$io->askQuestion($question)) { + $io->writeln("Package {$package_name} not updated"); + exit; + } + } + } + + /** + * Given a $dependencies list, filters their type according to $type and + * shows $message prior to listing them to the user. Then asks the user a confirmation prior + * to installing them. + * + * @param array $dependencies The dependencies array + * @param string $type The type of dependency to show: install, update, ignore + * @param string $message A message to be shown prior to listing the dependencies + * @param bool $required A flag that determines if the installation is required or optional + * @return void + * @throws Exception + */ + public function installDependencies(array $dependencies, string $type, string $message, bool $required = true): void + { + $io = $this->getIO(); + $packages = array_filter($dependencies, static function ($action) use ($type) { + return $action === $type; + }); + if (count($packages) > 0) { + $io->writeln($message); + + foreach ($packages as $dependencyName => $dependencyVersion) { + $io->writeln(" |- Package {$dependencyName}"); + } + + $io->newLine(); + + if ($type === 'install') { + $questionAction = 'Install'; + } else { + $questionAction = 'Update'; + } + + if (count($packages) === 1) { + $questionArticle = 'this'; + } else { + $questionArticle = 'these'; + } + + if (count($packages) === 1) { + $questionNoun = 'package'; + } else { + $questionNoun = 'packages'; + } + + $question = new ConfirmationQuestion("{$questionAction} {$questionArticle} {$questionNoun}? [Y|n] ", true); + $answer = $this->all_yes ? true : $io->askQuestion($question); + + if ($answer) { + foreach ($packages as $dependencyName => $dependencyVersion) { + $package = $this->gpm->findPackage($dependencyName); + $this->processPackage($package, $type === 'update'); + } + $io->newLine(); + } elseif ($required) { + throw new Exception(); + } + } + } + + /** + * @param Package|null $package + * @param bool $is_update True if the package is an update + * @return void + */ + private function processPackage(?Package $package, bool $is_update = false): void + { + $io = $this->getIO(); + + if (!$package) { + $io->writeln('Package not found on the GPM!'); + $io->newLine(); + return; + } + + $symlink = false; + if ($this->use_symlinks) { + if (!isset($package->version) || $this->getSymlinkSource($package)) { + $symlink = true; + } + } + + $symlink ? $this->processSymlink($package) : $this->processGpm($package, $is_update); + + $this->processDemo($package); + } + + /** + * Add package to the queue to process the demo content, if demo content exists + * + * @param Package $package + * @return void + */ + private function processDemo(Package $package): void + { + $demo_dir = $this->destination . DS . $package->install_path . DS . '_demo'; + if (file_exists($demo_dir)) { + $this->demo_processing[] = $package; + } + } + + /** + * Prompt to install the demo content of a package + * + * @param Package $package + * @return void + */ + private function installDemoContent(Package $package): void + { + $io = $this->getIO(); + $demo_dir = $this->destination . DS . $package->install_path . DS . '_demo'; + + if (file_exists($demo_dir)) { + $dest_dir = $this->destination . DS . 'user'; + $pages_dir = $dest_dir . DS . 'pages'; + + // Demo content exists, prompt to install it. + $io->writeln("Attention: {$package->name} contains demo content"); + + $question = new ConfirmationQuestion('Do you wish to install this demo content? [y|N] ', false); + + $answer = $io->askQuestion($question); + + if (!$answer) { + $io->writeln(" '- Skipped! "); + $io->newLine(); + + return; + } + + // if pages folder exists in demo + if (file_exists($demo_dir . DS . 'pages')) { + $pages_backup = 'pages.' . date('m-d-Y-H-i-s'); + $question = new ConfirmationQuestion('This will backup your current `user/pages` folder to `user/' . $pages_backup . '`, continue? [y|N]', false); + $answer = $this->all_yes ? true : $io->askQuestion($question); + + if (!$answer) { + $io->writeln(" '- Skipped! "); + $io->newLine(); + + return; + } + + // backup current pages folder + if (file_exists($dest_dir)) { + if (rename($pages_dir, $dest_dir . DS . $pages_backup)) { + $io->writeln(' |- Backing up pages... ok'); + } else { + $io->writeln(' |- Backing up pages... failed'); + } + } + } + + // Confirmation received, copy over the data + $io->writeln(' |- Installing demo content... ok '); + Folder::rcopy($demo_dir, $dest_dir); + $io->writeln(" '- Success! "); + $io->newLine(); + } + } + + /** + * @param Package $package + * @return array|false + */ + private function getGitRegexMatches(Package $package) + { + if (isset($package->repository)) { + $repository = $package->repository; + } else { + return false; + } + + preg_match(GIT_REGEX, $repository, $matches); + + return $matches; + } + + /** + * @param Package $package + * @return string|false + */ + private function getSymlinkSource(Package $package) + { + $matches = $this->getGitRegexMatches($package); + + foreach ($this->local_config as $paths) { + if (Utils::endsWith($matches[2], '.git')) { + $repo_dir = preg_replace('/\.git$/', '', $matches[2]); + } else { + $repo_dir = $matches[2]; + } + + $paths = (array) $paths; + foreach ($paths as $repo) { + $path = rtrim($repo, '/') . '/' . $repo_dir; + if (file_exists($path)) { + return $path; + } + } + } + + return false; + } + + /** + * @param Package $package + * @return void + */ + private function processSymlink(Package $package): void + { + $io = $this->getIO(); + + exec('cd ' . escapeshellarg($this->destination)); + + $to = $this->destination . DS . $package->install_path; + $from = $this->getSymlinkSource($package); + + $io->writeln("Preparing to Symlink {$package->name}"); + $io->write(' |- Checking source... '); + + if (file_exists($from)) { + $io->writeln('ok'); + + $io->write(' |- Checking destination... '); + $checks = $this->checkDestination($package); + + if (!$checks) { + $io->writeln(" '- Installation failed or aborted."); + $io->newLine(); + } elseif (file_exists($to)) { + $io->writeln(" '- Symlink cannot overwrite an existing package, please remove first"); + $io->newLine(); + } else { + symlink($from, $to); + + // extra white spaces to clear out the buffer properly + $io->writeln(' |- Symlinking package... ok '); + $io->writeln(" '- Success! "); + $io->newLine(); + } + + return; + } + + $io->writeln('not found!'); + $io->writeln(" '- Installation failed or aborted."); + } + + /** + * @param Package $package + * @param bool $is_update + * @return bool + */ + private function processGpm(Package $package, bool $is_update = false) + { + $io = $this->getIO(); + + $version = $package->available ?? $package->version; + $license = Licenses::get($package->slug); + + $io->writeln("Preparing to install {$package->name} [v{$version}]"); + + $io->write(' |- Downloading package... 0%'); + $this->file = $this->downloadPackage($package, $license); + + if (!$this->file) { + $io->writeln(" '- Installation failed or aborted."); + $io->newLine(); + + return false; + } + + $io->write(' |- Checking destination... '); + $checks = $this->checkDestination($package); + + if (!$checks) { + $io->writeln(" '- Installation failed or aborted."); + $io->newLine(); + } else { + $io->write(' |- Installing package... '); + $installation = $this->installPackage($package, $is_update); + if (!$installation) { + $io->writeln(" '- Installation failed or aborted."); + $io->newLine(); + } else { + $io->writeln(" '- Success! "); + $io->newLine(); + + return true; + } + } + + return false; + } + + /** + * @param Package $package + * @param string|null $license + * @return string|null + */ + private function downloadPackage(Package $package, string $license = null) + { + $io = $this->getIO(); + + $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true); + $this->tmp = $tmp_dir . '/Grav-' . uniqid(); + $filename = $package->slug . Utils::basename($package->zipball_url); + $filename = preg_replace('/[\\\\\/:"*?&<>|]+/m', '-', $filename); + $query = ''; + + if (!empty($package->premium)) { + $query = json_encode(array_merge( + $package->premium, + [ + 'slug' => $package->slug, + 'filename' => $package->premium['filename'], + 'license_key' => $license, + 'sid' => md5(GRAV_ROOT) + ] + )); + + $query = '?d=' . base64_encode($query); + } + + try { + $output = Response::get($package->zipball_url . $query, [], [$this, 'progress']); + } catch (Exception $e) { + if (!empty($package->premium) && $e->getCode() === 401) { + $message = 'Unauthorized Premium License Key'; + } else { + $message = $e->getMessage(); + } + + $error = str_replace("\n", "\n | '- ", $message); + $io->write("\x0D"); + // extra white spaces to clear out the buffer properly + $io->writeln(' |- Downloading package... error '); + $io->writeln(" | '- " . $error); + + return null; + } + + Folder::create($this->tmp); + + $io->write("\x0D"); + $io->write(' |- Downloading package... 100%'); + $io->newLine(); + + file_put_contents($this->tmp . DS . $filename, $output); + + return $this->tmp . DS . $filename; + } + + /** + * @param Package $package + * @return bool + */ + private function checkDestination(Package $package): bool + { + $io = $this->getIO(); + + Installer::isValidDestination($this->destination . DS . $package->install_path); + + if (Installer::lastErrorCode() === Installer::IS_LINK) { + $io->write("\x0D"); + $io->writeln(' |- Checking destination... symbolic link'); + + if ($this->all_yes) { + $io->writeln(" | '- Skipped automatically."); + + return false; + } + + $question = new ConfirmationQuestion( + " | '- Destination has been detected as symlink, delete symbolic link first? [y|N] ", + false + ); + $answer = $io->askQuestion($question); + + if (!$answer) { + $io->writeln(" | '- You decided to not delete the symlink automatically."); + + return false; + } + + unlink($this->destination . DS . $package->install_path); + } + + $io->write("\x0D"); + $io->writeln(' |- Checking destination... ok'); + + return true; + } + + /** + * Install a package + * + * @param Package $package + * @param bool $is_update True if it's an update. False if it's an install + * @return bool + */ + private function installPackage(Package $package, bool $is_update = false): bool + { + $io = $this->getIO(); + + $type = $package->package_type; + + Installer::install($this->file, $this->destination, ['install_path' => $package->install_path, 'theme' => $type === 'themes', 'is_update' => $is_update]); + $error_code = Installer::lastErrorCode(); + Folder::delete($this->tmp); + + if ($error_code) { + $io->write("\x0D"); + // extra white spaces to clear out the buffer properly + $io->writeln(' |- Installing package... error '); + $io->writeln(" | '- " . Installer::lastErrorMsg()); + + return false; + } + + $message = Installer::getMessage(); + if ($message) { + $io->write("\x0D"); + // extra white spaces to clear out the buffer properly + $io->writeln(" |- {$message}"); + } + + $io->write("\x0D"); + // extra white spaces to clear out the buffer properly + $io->writeln(' |- Installing package... ok '); + + return true; + } + + /** + * @param array $progress + * @return void + */ + public function progress(array $progress): void + { + $io = $this->getIO(); + + $io->write("\x0D"); + $io->write(' |- Downloading package... ' . str_pad( + $progress['percent'], + 5, + ' ', + STR_PAD_LEFT + ) . '%'); + } +} diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php new file mode 100644 index 0000000..a3c7c6f --- /dev/null +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -0,0 +1,344 @@ +setName('self-upgrade') + ->setAliases(['selfupgrade', 'selfupdate']) + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force re-fetching the data from remote' + ) + ->addOption( + 'all-yes', + 'y', + InputOption::VALUE_NONE, + 'Assumes yes (or best approach) instead of prompting' + ) + ->addOption( + 'overwrite', + 'o', + InputOption::VALUE_NONE, + 'Option to overwrite packages if they already exist' + ) + ->addOption( + 'timeout', + 't', + InputOption::VALUE_OPTIONAL, + 'Option to set the timeout in seconds when downloading the update (0 for no timeout)', + 30 + ) + ->setDescription('Detects and performs an update of Grav itself when available') + ->setHelp('The update command updates Grav itself when a new version is available'); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + if (!class_exists(ZipArchive::class)) { + $io->title('GPM Self Upgrade'); + $io->error('php-zip extension needs to be enabled!'); + + return 1; + } + + $this->upgrader = new Upgrader($input->getOption('force')); + $this->all_yes = $input->getOption('all-yes'); + $this->overwrite = $input->getOption('overwrite'); + $this->timeout = (int) $input->getOption('timeout'); + + $this->displayGPMRelease(); + + $update = $this->upgrader->getAssets()['grav-update']; + + $local = $this->upgrader->getLocalVersion(); + $remote = $this->upgrader->getRemoteVersion(); + $release = strftime('%c', strtotime($this->upgrader->getReleaseDate())); + + if (!$this->upgrader->meetsRequirements()) { + $io->writeln('ATTENTION:'); + $io->writeln(' Grav has increased the minimum PHP requirement.'); + $io->writeln(' You are currently running PHP ' . phpversion() . ', but PHP ' . $this->upgrader->minPHPVersion() . ' is required.'); + $io->writeln(' Additional information: http://getgrav.org/blog/changing-php-requirements'); + $io->newLine(); + $io->writeln('Selfupgrade aborted.'); + $io->newLine(); + + return 1; + } + + if (!$this->overwrite && !$this->upgrader->isUpgradable()) { + $io->writeln("You are already running the latest version of Grav v{$local}"); + $io->writeln("which was released on {$release}"); + + $config = Grav::instance()['config']; + $schema = $config->get('versions.core.grav.schema'); + if ($schema !== GRAV_SCHEMA && version_compare($schema, GRAV_SCHEMA, '<')) { + $io->newLine(); + $io->writeln('However post-install scripts have not been run.'); + if (!$this->all_yes) { + $question = new ConfirmationQuestion( + 'Would you like to run the scripts? [Y|n] ', + true + ); + $answer = $io->askQuestion($question); + } else { + $answer = true; + } + + if ($answer) { + // Finalize installation. + Install::instance()->finalize(); + + $io->write(' |- Running post-install scripts... '); + $io->writeln(" '- Success! "); + $io->newLine(); + } + } + + return 0; + } + + Installer::isValidDestination(GRAV_ROOT . '/system'); + if (Installer::IS_LINK === Installer::lastErrorCode()) { + $io->writeln('ATTENTION: Grav is symlinked, cannot upgrade, aborting...'); + $io->newLine(); + $io->writeln("You are currently running a symbolically linked Grav v{$local}. Latest available is v{$remote}."); + + return 1; + } + + // not used but preloaded just in case! + new ArrayInput([]); + + $io->writeln("Grav v{$remote} is now available [release date: {$release}]."); + $io->writeln('You are currently using v' . GRAV_VERSION . '.'); + + if (!$this->all_yes) { + $question = new ConfirmationQuestion( + 'Would you like to read the changelog before proceeding? [y|N] ', + false + ); + $answer = $io->askQuestion($question); + + if ($answer) { + $changelog = $this->upgrader->getChangelog(GRAV_VERSION); + + $io->newLine(); + foreach ($changelog as $version => $log) { + $title = $version . ' [' . $log['date'] . ']'; + $content = preg_replace_callback('/\d\.\s\[\]\(#(.*)\)/', static function ($match) { + return "\n" . ucfirst($match[1]) . ':'; + }, $log['content']); + + $io->writeln($title); + $io->writeln(str_repeat('-', strlen($title))); + $io->writeln($content); + $io->newLine(); + } + + $question = new ConfirmationQuestion('Press [ENTER] to continue.', true); + $io->askQuestion($question); + } + + $question = new ConfirmationQuestion('Would you like to upgrade now? [y|N] ', false); + $answer = $io->askQuestion($question); + + if (!$answer) { + $io->writeln('Aborting...'); + + return 1; + } + } + + $io->newLine(); + $io->writeln("Preparing to upgrade to v{$remote}.."); + + $io->write(" |- Downloading upgrade [{$this->formatBytes($update['size'])}]... 0%"); + $this->file = $this->download($update); + + $io->write(' |- Installing upgrade... '); + $installation = $this->upgrade(); + + $error = 0; + if (!$installation) { + $io->writeln(" '- Installation failed or aborted."); + $io->newLine(); + $error = 1; + } else { + $io->writeln(" '- Success! "); + $io->newLine(); + } + + if ($this->tmp && is_dir($this->tmp)) { + Folder::delete($this->tmp); + } + + return $error; + } + + /** + * @param array $package + * @return string + */ + private function download(array $package): string + { + $io = $this->getIO(); + + $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true); + $this->tmp = $tmp_dir . '/grav-update-' . uniqid('', false); + $options = [ + 'timeout' => $this->timeout, + ]; + + $output = Response::get($package['download'], $options, [$this, 'progress']); + + Folder::create($this->tmp); + + $io->write("\x0D"); + $io->write(" |- Downloading upgrade [{$this->formatBytes($package['size'])}]... 100%"); + $io->newLine(); + + file_put_contents($this->tmp . DS . $package['name'], $output); + + return $this->tmp . DS . $package['name']; + } + + /** + * @return bool + */ + private function upgrade(): bool + { + $io = $this->getIO(); + + $this->upgradeGrav($this->file); + + $errorCode = Installer::lastErrorCode(); + if ($errorCode) { + $io->write("\x0D"); + // extra white spaces to clear out the buffer properly + $io->writeln(' |- Installing upgrade... error '); + $io->writeln(" | '- " . Installer::lastErrorMsg()); + + return false; + } + + $io->write("\x0D"); + // extra white spaces to clear out the buffer properly + $io->writeln(' |- Installing upgrade... ok '); + + return true; + } + + /** + * @param array $progress + * @return void + */ + public function progress(array $progress): void + { + $io = $this->getIO(); + + $io->write("\x0D"); + $io->write(" |- Downloading upgrade [{$this->formatBytes($progress['filesize']) }]... " . str_pad( + $progress['percent'], + 5, + ' ', + STR_PAD_LEFT + ) . '%'); + } + + /** + * @param int|float $size + * @param int $precision + * @return string + */ + public function formatBytes($size, int $precision = 2): string + { + $base = log($size) / log(1024); + $suffixes = array('', 'k', 'M', 'G', 'T'); + + return round(1024 ** ($base - floor($base)), $precision) . $suffixes[(int)floor($base)]; + } + + /** + * @param string $zip + * @return void + */ + private function upgradeGrav(string $zip): void + { + try { + $folder = Installer::unZip($zip, $this->tmp . '/zip'); + if ($folder === false) { + throw new RuntimeException(Installer::lastErrorMsg()); + } + + $script = $folder . '/system/install.php'; + if ((file_exists($script) && $install = include $script) && is_callable($install)) { + $install($zip); + } else { + throw new RuntimeException('Uploaded archive file is not a valid Grav update package'); + } + } catch (Exception $e) { + Installer::setError($e->getMessage()); + } + } +} diff --git a/system/src/Grav/Console/Gpm/UninstallCommand.php b/system/src/Grav/Console/Gpm/UninstallCommand.php new file mode 100644 index 0000000..628e404 --- /dev/null +++ b/system/src/Grav/Console/Gpm/UninstallCommand.php @@ -0,0 +1,312 @@ +setName('uninstall') + ->addOption( + 'all-yes', + 'y', + InputOption::VALUE_NONE, + 'Assumes yes (or best approach) instead of prompting' + ) + ->addArgument( + 'package', + InputArgument::IS_ARRAY | InputArgument::REQUIRED, + 'The package(s) that are desired to be removed. Use the "index" command for a list of packages' + ) + ->setDescription('Performs the uninstallation of plugins and themes') + ->setHelp('The uninstall command allows to uninstall plugins and themes'); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + $this->gpm = new GPM(); + + $this->all_yes = $input->getOption('all-yes'); + + $packages = array_map('strtolower', $input->getArgument('package')); + $this->data = ['total' => 0, 'not_found' => []]; + + $total = 0; + foreach ($packages as $package) { + $plugin = $this->gpm->getInstalledPlugin($package); + $theme = $this->gpm->getInstalledTheme($package); + if ($plugin || $theme) { + $this->data[strtolower($package)] = $plugin ?: $theme; + $total++; + } else { + $this->data['not_found'][] = $package; + } + } + $this->data['total'] = $total; + + $io->newLine(); + + if (!$this->data['total']) { + $io->writeln('Nothing to uninstall.'); + $io->newLine(); + + return 0; + } + + if (count($this->data['not_found'])) { + $io->writeln('These packages were not found installed: ' . implode( + ', ', + $this->data['not_found'] + ) . ''); + } + + unset($this->data['not_found'], $this->data['total']); + + // Plugins need to be initialized in order to make clearcache to work. + try { + $this->initializePlugins(); + } catch (Throwable $e) { + $io->writeln("Some plugins failed to initialize: {$e->getMessage()}"); + } + + $error = 0; + foreach ($this->data as $slug => $package) { + $io->writeln("Preparing to uninstall {$package->name} [v{$package->version}]"); + + $io->write(' |- Checking destination... '); + $checks = $this->checkDestination($slug, $package); + + if (!$checks) { + $io->writeln(" '- Installation failed or aborted."); + $io->newLine(); + $error = 1; + } else { + $uninstall = $this->uninstallPackage($slug, $package); + + if (!$uninstall) { + $io->writeln(" '- Uninstallation failed or aborted."); + $error = 1; + } else { + $io->writeln(" '- Success! "); + } + } + } + + // clear cache after successful upgrade + $this->clearCache(); + + return $error; + } + + /** + * @param string $slug + * @param Local\Package|Remote\Package $package + * @param bool $is_dependency + * @return bool + */ + private function uninstallPackage($slug, $package, $is_dependency = false): bool + { + $io = $this->getIO(); + + if (!$slug) { + return false; + } + + //check if there are packages that have this as a dependency. Abort and show list + $dependent_packages = $this->gpm->getPackagesThatDependOnPackage($slug); + if (count($dependent_packages) > ($is_dependency ? 1 : 0)) { + $io->newLine(2); + $io->writeln('Uninstallation failed.'); + $io->newLine(); + if (count($dependent_packages) > ($is_dependency ? 2 : 1)) { + $io->writeln('The installed packages ' . implode(', ', $dependent_packages) . ' depends on this package. Please remove those first.'); + } else { + $io->writeln('The installed package ' . implode(', ', $dependent_packages) . ' depends on this package. Please remove it first.'); + } + + $io->newLine(); + return false; + } + + if (isset($package->dependencies)) { + $dependencies = $package->dependencies; + + if ($is_dependency) { + foreach ($dependencies as $key => $dependency) { + if (in_array($dependency['name'], $this->dependencies, true)) { + unset($dependencies[$key]); + } + } + } elseif (count($dependencies) > 0) { + $io->writeln(' `- Dependencies found...'); + $io->newLine(); + } + + foreach ($dependencies as $dependency) { + $this->dependencies[] = $dependency['name']; + + if (is_array($dependency)) { + $dependency = $dependency['name']; + } + if ($dependency === 'grav' || $dependency === 'php') { + continue; + } + + $dependencyPackage = $this->gpm->findPackage($dependency); + + $dependency_exists = $this->packageExists($dependency, $dependencyPackage); + + if ($dependency_exists == Installer::EXISTS) { + $io->writeln("A dependency on {$dependencyPackage->name} [v{$dependencyPackage->version}] was found"); + + $question = new ConfirmationQuestion(" |- Uninstall {$dependencyPackage->name}? [y|N] ", false); + $answer = $this->all_yes ? true : $io->askQuestion($question); + + if ($answer) { + $uninstall = $this->uninstallPackage($dependency, $dependencyPackage, true); + + if (!$uninstall) { + $io->writeln(" '- Uninstallation failed or aborted."); + } else { + $io->writeln(" '- Success! "); + } + $io->newLine(); + } else { + $io->writeln(" '- You decided not to uninstall {$dependencyPackage->name}."); + $io->newLine(); + } + } + } + } + + + $locator = Grav::instance()['locator']; + $path = $locator->findResource($package->package_type . '://' . $slug); + Installer::uninstall($path); + $errorCode = Installer::lastErrorCode(); + + if ($errorCode && $errorCode !== Installer::IS_LINK && $errorCode !== Installer::EXISTS) { + $io->writeln(" |- Uninstalling {$package->name} package... error "); + $io->writeln(" | '- " . Installer::lastErrorMsg() . ''); + + return false; + } + + $message = Installer::getMessage(); + if ($message) { + $io->writeln(" |- {$message}"); + } + + if (!$is_dependency && $this->dependencies) { + $io->writeln("Finishing up uninstalling {$package->name}"); + } + $io->writeln(" |- Uninstalling {$package->name} package... ok "); + + return true; + } + + /** + * @param string $slug + * @param Local\Package|Remote\Package $package + * @return bool + */ + private function checkDestination(string $slug, $package): bool + { + $io = $this->getIO(); + + $exists = $this->packageExists($slug, $package); + + if ($exists === Installer::IS_LINK) { + $io->write("\x0D"); + $io->writeln(' |- Checking destination... symbolic link'); + + if ($this->all_yes) { + $io->writeln(" | '- Skipped automatically."); + + return false; + } + + $question = new ConfirmationQuestion( + " | '- Destination has been detected as symlink, delete symbolic link first? [y|N] ", + false + ); + + $answer = $io->askQuestion($question); + if (!$answer) { + $io->writeln(" | '- You decided not to delete the symlink automatically."); + + return false; + } + } + + $io->write("\x0D"); + $io->writeln(' |- Checking destination... ok'); + + return true; + } + + /** + * Check if package exists + * + * @param string $slug + * @param Local\Package|Remote\Package $package + * @return int + */ + private function packageExists(string $slug, $package): int + { + $path = Grav::instance()['locator']->findResource($package->package_type . '://' . $slug); + Installer::isValidDestination($path); + + return Installer::lastErrorCode(); + } +} diff --git a/system/src/Grav/Console/Gpm/UpdateCommand.php b/system/src/Grav/Console/Gpm/UpdateCommand.php new file mode 100644 index 0000000..6e20c93 --- /dev/null +++ b/system/src/Grav/Console/Gpm/UpdateCommand.php @@ -0,0 +1,289 @@ +setName('update') + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force re-fetching the data from remote' + ) + ->addOption( + 'destination', + 'd', + InputOption::VALUE_OPTIONAL, + 'The grav instance location where the updates should be applied to. By default this would be where the grav cli has been launched from', + GRAV_ROOT + ) + ->addOption( + 'all-yes', + 'y', + InputOption::VALUE_NONE, + 'Assumes yes (or best approach) instead of prompting' + ) + ->addOption( + 'overwrite', + 'o', + InputOption::VALUE_NONE, + 'Option to overwrite packages if they already exist' + ) + ->addOption( + 'plugins', + 'p', + InputOption::VALUE_NONE, + 'Update only plugins' + ) + ->addOption( + 'themes', + 't', + InputOption::VALUE_NONE, + 'Update only themes' + ) + ->addArgument( + 'package', + InputArgument::IS_ARRAY | InputArgument::OPTIONAL, + 'The package or packages that is desired to update. By default all available updates will be applied.' + ) + ->setDescription('Detects and performs an update of plugins and themes when available') + ->setHelp('The update command updates plugins and themes when a new version is available'); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + if (!class_exists(ZipArchive::class)) { + $io->title('GPM Update'); + $io->error('php-zip extension needs to be enabled!'); + + return 1; + } + + $this->upgrader = new Upgrader($input->getOption('force')); + $local = $this->upgrader->getLocalVersion(); + $remote = $this->upgrader->getRemoteVersion(); + if ($local !== $remote) { + $io->writeln('WARNING: A new version of Grav is available. You should update Grav before updating plugins and themes. If you continue without updating Grav, some plugins or themes may stop working.'); + $io->newLine(); + $question = new ConfirmationQuestion('Continue with the update process? [Y|n] ', true); + $answer = $io->askQuestion($question); + + if (!$answer) { + $io->writeln('Update aborted. Exiting...'); + + return 1; + } + } + + $this->gpm = new GPM($input->getOption('force')); + + $this->all_yes = $input->getOption('all-yes'); + $this->overwrite = $input->getOption('overwrite'); + + $this->displayGPMRelease(); + + $this->destination = realpath($input->getOption('destination')); + + if (!Installer::isGravInstance($this->destination)) { + $io->writeln('ERROR: ' . Installer::lastErrorMsg()); + exit; + } + if ($input->getOption('plugins') === false && $input->getOption('themes') === false) { + $list_type = ['plugins' => true, 'themes' => true]; + } else { + $list_type['plugins'] = $input->getOption('plugins'); + $list_type['themes'] = $input->getOption('themes'); + } + + if ($this->overwrite) { + $this->data = $this->gpm->getInstallable($list_type); + $description = ' can be overwritten'; + } else { + $this->data = $this->gpm->getUpdatable($list_type); + $description = ' need updating'; + } + + $only_packages = array_map('strtolower', $input->getArgument('package')); + + if (!$this->overwrite && !$this->data['total']) { + $io->writeln('Nothing to update.'); + + return 0; + } + + $io->write("Found {$this->gpm->countInstalled()} packages installed of which {$this->data['total']}{$description}"); + + $limit_to = $this->userInputPackages($only_packages); + + $io->newLine(); + + unset($this->data['total'], $limit_to['total']); + + + // updates review + $slugs = []; + + $index = 1; + foreach ($this->data as $packages) { + foreach ($packages as $slug => $package) { + if (!array_key_exists($slug, $limit_to) && count($only_packages)) { + continue; + } + + if (!$package->available) { + $package->available = $package->version; + } + + $io->writeln( + // index + str_pad((string)$index++, 2, '0', STR_PAD_LEFT) . '. ' . + // name + '' . str_pad($package->name, 15) . ' ' . + // version + "[v{$package->version} -> v{$package->available}]" + ); + $slugs[] = $slug; + } + } + + if (!$this->all_yes) { + // prompt to continue + $io->newLine(); + $question = new ConfirmationQuestion('Continue with the update process? [Y|n] ', true); + $answer = $io->askQuestion($question); + + if (!$answer) { + $io->writeln('Update aborted. Exiting...'); + + return 1; + } + } + + // finally update + $install_command = $this->getApplication()->find('install'); + + $args = new ArrayInput([ + 'command' => 'install', + 'package' => $slugs, + '-f' => $input->getOption('force'), + '-d' => $this->destination, + '-y' => true + ]); + $command_exec = $install_command->run($args, $io); + + if ($command_exec != 0) { + $io->writeln('Error: An error occurred while trying to install the packages'); + + return 1; + } + + return 0; + } + + /** + * @param array $only_packages + * @return array + */ + private function userInputPackages(array $only_packages): array + { + $io = $this->getIO(); + + $found = ['total' => 0]; + $ignore = []; + + if (!count($only_packages)) { + $io->newLine(); + } else { + foreach ($only_packages as $only_package) { + $find = $this->gpm->findPackage($only_package); + + if (!$find || (!$this->overwrite && !$this->gpm->isUpdatable($find->slug))) { + $name = $find->slug ?? $only_package; + $ignore[$name] = $name; + } else { + $found[$find->slug] = $find; + $found['total']++; + } + } + + if ($found['total']) { + $list = $found; + unset($list['total']); + $list = array_keys($list); + + if ($found['total'] !== $this->data['total']) { + $io->write(", only {$found['total']} will be updated"); + } + + $io->newLine(); + $io->writeln('Limiting updates for only ' . implode( + ', ', + $list + ) . ''); + } + + if (count($ignore)) { + $io->newLine(); + $io->writeln('Packages not found or not requiring updates: ' . implode( + ', ', + $ignore + ) . ''); + } + } + + return $found; + } +} diff --git a/system/src/Grav/Console/Gpm/VersionCommand.php b/system/src/Grav/Console/Gpm/VersionCommand.php new file mode 100644 index 0000000..76be6bb --- /dev/null +++ b/system/src/Grav/Console/Gpm/VersionCommand.php @@ -0,0 +1,125 @@ +setName('version') + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force re-fetching the data from remote' + ) + ->addArgument( + 'package', + InputArgument::IS_ARRAY | InputArgument::OPTIONAL, + 'The package or packages that is desired to know the version of. By default and if not specified this would be grav' + ) + ->setDescription('Shows the version of an installed package. If available also shows pending updates.') + ->setHelp('The version command displays the current version of a package installed and, if available, the available version of pending updates'); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + $this->gpm = new GPM($input->getOption('force')); + $packages = $input->getArgument('package'); + + $installed = false; + + if (!count($packages)) { + $packages = ['grav']; + } + + foreach ($packages as $package) { + $package = strtolower($package); + $name = null; + $version = null; + $updatable = false; + + if ($package === 'grav') { + $name = 'Grav'; + $version = GRAV_VERSION; + $upgrader = new Upgrader(); + + if ($upgrader->isUpgradable()) { + $updatable = " [upgradable: v{$upgrader->getRemoteVersion()}]"; + } + } else { + // get currently installed version + $locator = Grav::instance()['locator']; + $blueprints_path = $locator->findResource('plugins://' . $package . DS . 'blueprints.yaml'); + if (!file_exists($blueprints_path)) { // theme? + $blueprints_path = $locator->findResource('themes://' . $package . DS . 'blueprints.yaml'); + if (!file_exists($blueprints_path)) { + continue; + } + } + + $file = YamlFile::instance($blueprints_path); + $package_yaml = $file->content(); + $file->free(); + + $version = $package_yaml['version']; + + if (!$version) { + continue; + } + + $installed = $this->gpm->findPackage($package); + if ($installed) { + $name = $installed->name; + + if ($this->gpm->isUpdatable($package)) { + $updatable = " [updatable: v{$installed->available}]"; + } + } + } + + $updatable = $updatable ?: ''; + + if ($installed || $package === 'grav') { + $io->writeln("You are running {$name} v{$version}{$updatable}"); + } else { + $io->writeln("Package {$package} not found"); + } + } + + return 0; + } +} diff --git a/system/src/Grav/Console/GpmCommand.php b/system/src/Grav/Console/GpmCommand.php new file mode 100644 index 0000000..62ab950 --- /dev/null +++ b/system/src/Grav/Console/GpmCommand.php @@ -0,0 +1,68 @@ +setupConsole($input, $output); + + $grav = Grav::instance(); + $grav['config']->init(); + $grav['uri']->init(); + // @phpstan-ignore-next-line + $grav['accounts']; + + return $this->serve(); + } + + /** + * Override with your implementation. + * + * @return int + */ + protected function serve() + { + // Return error. + return 1; + } + + /** + * @return void + */ + protected function displayGPMRelease() + { + /** @var Config $config */ + $config = Grav::instance()['config']; + + $io = $this->getIO(); + $io->newLine(); + $io->writeln('GPM Releases Configuration: ' . ucfirst($config->get('system.gpm.releases')) . ''); + $io->newLine(); + } +} diff --git a/system/src/Grav/Console/GravCommand.php b/system/src/Grav/Console/GravCommand.php new file mode 100644 index 0000000..791a5f3 --- /dev/null +++ b/system/src/Grav/Console/GravCommand.php @@ -0,0 +1,52 @@ +setupConsole($input, $output); + + // Old versions of Grav called this command after grav upgrade. + // We need make this command to work with older ConsoleTrait: + if (method_exists($this, 'initializeGrav')) { + $this->initializeGrav(); + } + + return $this->serve(); + } + + /** + * Override with your implementation. + * + * @return int + */ + protected function serve() + { + // Return error. + return 1; + } +} diff --git a/system/src/Grav/Console/Plugin/PluginListCommand.php b/system/src/Grav/Console/Plugin/PluginListCommand.php new file mode 100644 index 0000000..7052201 --- /dev/null +++ b/system/src/Grav/Console/Plugin/PluginListCommand.php @@ -0,0 +1,69 @@ +setHidden(true); + } + + /** + * @return int + */ + protected function serve(): int + { + $bin = $this->argv; + $pattern = '([A-Z]\w+Command\.php)'; + + $io = $this->getIO(); + $io->newLine(); + $io->writeln('Usage:'); + $io->writeln(" {$bin} [slug] [command] [arguments]"); + $io->newLine(); + $io->writeln('Example:'); + $io->writeln(" {$bin} error log -l 1 --trace"); + $io->newLine(); + $io->writeln('Plugins with CLI available:'); + + $plugins = Plugins::all(); + $index = 0; + foreach ($plugins as $name => $plugin) { + if (!$plugin->enabled) { + continue; + } + + $list = Folder::all("plugins://{$name}", ['compare' => 'Pathname', 'pattern' => '/\/cli\/' . $pattern . '$/usm', 'levels' => 1]); + if (!$list) { + continue; + } + + $index++; + $num = str_pad((string)$index, 2, '0', STR_PAD_LEFT); + $io->writeln(' ' . $num . '. ' . str_pad($name, 15) . " {$bin} {$name} list"); + } + + return 0; + } +} diff --git a/system/src/Grav/Console/TerminalObjects/Table.php b/system/src/Grav/Console/TerminalObjects/Table.php new file mode 100644 index 0000000..83b4be2 --- /dev/null +++ b/system/src/Grav/Console/TerminalObjects/Table.php @@ -0,0 +1,38 @@ +column_widths = $this->getColumnWidths(); + $this->table_width = $this->getWidth(); + $this->border = $this->getBorder(); + + $this->buildHeaderRow(); + + foreach ($this->data as $key => $columns) { + $this->rows[] = $this->buildRow($columns); + } + + $this->rows[] = $this->border; + + return $this->rows; + } +} diff --git a/system/src/Grav/Events/BeforeSessionStartEvent.php b/system/src/Grav/Events/BeforeSessionStartEvent.php new file mode 100644 index 0000000..0dd4753 --- /dev/null +++ b/system/src/Grav/Events/BeforeSessionStartEvent.php @@ -0,0 +1,36 @@ +start() right before session_start() call. + * + * @property SessionInterface $session Session instance. + */ +class BeforeSessionStartEvent extends Event +{ + /** @var SessionInterface */ + public $session; + + public function __construct(SessionInterface $session) + { + $this->session = $session; + } + + public function __debugInfo(): array + { + return (array)$this; + } +} diff --git a/system/src/Grav/Events/FlexRegisterEvent.php b/system/src/Grav/Events/FlexRegisterEvent.php new file mode 100644 index 0000000..4de7252 --- /dev/null +++ b/system/src/Grav/Events/FlexRegisterEvent.php @@ -0,0 +1,45 @@ +flex = $flex; + } + + /** + * @return array + */ + public function __debugInfo(): array + { + return (array)$this; + } +} diff --git a/system/src/Grav/Events/PageEvent.php b/system/src/Grav/Events/PageEvent.php new file mode 100644 index 0000000..1928b0d --- /dev/null +++ b/system/src/Grav/Events/PageEvent.php @@ -0,0 +1,18 @@ +permissions = $permissions; + } + + /** + * @return array + */ + public function __debugInfo(): array + { + return (array)$this; + } +} diff --git a/system/src/Grav/Events/PluginsLoadedEvent.php b/system/src/Grav/Events/PluginsLoadedEvent.php new file mode 100644 index 0000000..6729b39 --- /dev/null +++ b/system/src/Grav/Events/PluginsLoadedEvent.php @@ -0,0 +1,53 @@ +grav = $grav; + $this->plugins = $plugins; + } + + /** + * @return array + */ + public function __debugInfo(): array + { + return [ + 'plugins' => $this->plugins + ]; + } +} diff --git a/system/src/Grav/Events/SessionStartEvent.php b/system/src/Grav/Events/SessionStartEvent.php new file mode 100644 index 0000000..63a4908 --- /dev/null +++ b/system/src/Grav/Events/SessionStartEvent.php @@ -0,0 +1,36 @@ +start() right after successful session_start() call. + * + * @property SessionInterface $session Session instance. + */ +class SessionStartEvent extends Event +{ + /** @var SessionInterface */ + public $session; + + public function __construct(SessionInterface $session) + { + $this->session = $session; + } + + public function __debugInfo(): array + { + return (array)$this; + } +} diff --git a/system/src/Grav/Events/TypesEvent.php b/system/src/Grav/Events/TypesEvent.php new file mode 100644 index 0000000..a2b39a4 --- /dev/null +++ b/system/src/Grav/Events/TypesEvent.php @@ -0,0 +1,18 @@ + + */ +class Access implements JsonSerializable, IteratorAggregate, Countable +{ + /** @var string */ + private $name; + /** @var array */ + private $rules; + /** @var array */ + private $ops; + /** @var array */ + private $acl = []; + /** @var array */ + private $inherited = []; + + /** + * Access constructor. + * @param string|array|null $acl + * @param array|null $rules + * @param string $name + */ + public function __construct($acl = null, array $rules = null, string $name = '') + { + $this->name = $name; + $this->rules = $rules ?? []; + $this->ops = ['+' => true, '-' => false]; + if (is_string($acl)) { + $this->acl = $this->resolvePermissions($acl); + } elseif (is_array($acl)) { + $this->acl = $this->normalizeAcl($acl); + } + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param Access $parent + * @param string|null $name + * @return void + */ + public function inherit(Access $parent, string $name = null) + { + // Remove cached null actions from acl. + $acl = $this->getAllActions(); + // Get only inherited actions. + $inherited = array_diff_key($parent->getAllActions(), $acl); + + $this->inherited += $parent->inherited + array_fill_keys(array_keys($inherited), $name ?? $parent->getName()); + $this->acl = array_replace($acl, $inherited); + } + + /** + * Checks user authorization to the action. + * + * @param string $action + * @param string|null $scope + * @return bool|null + */ + public function authorize(string $action, string $scope = null): ?bool + { + if (null !== $scope) { + $action = $scope !== 'test' ? "{$scope}.{$action}" : $action; + } + + return $this->get($action); + } + + /** + * @return array + */ + public function toArray(): array + { + return Utils::arrayUnflattenDotNotation($this->acl); + } + + /** + * @return array + */ + public function getAllActions(): array + { + return array_filter($this->acl, static function($val) { return $val !== null; }); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * @param string $action + * @return bool|null + */ + public function get(string $action) + { + // Get access value. + if (isset($this->acl[$action])) { + return $this->acl[$action]; + } + + // If no value is defined, check the parent access (all true|false). + $pos = strrpos($action, '.'); + $value = $pos ? $this->get(substr($action, 0, $pos)) : null; + + // Cache result for faster lookup. + $this->acl[$action] = $value; + + return $value; + } + + /** + * @param string $action + * @return bool + */ + public function isInherited(string $action): bool + { + return isset($this->inherited[$action]); + } + + /** + * @param string $action + * @return string|null + */ + public function getInherited(string $action): ?string + { + return $this->inherited[$action] ?? null; + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->acl); + } + + /** + * @return int + */ + public function count(): int + { + return count($this->acl); + } + + /** + * @param array $acl + * @return array + */ + protected function normalizeAcl(array $acl): array + { + if (empty($acl)) { + return []; + } + + // Normalize access control list. + $list = []; + foreach (Utils::arrayFlattenDotNotation($acl) as $key => $value) { + if (is_bool($value)) { + $list[$key] = $value; + } elseif ($value === 0 || $value === 1) { + $list[$key] = (bool)$value; + } elseif($value === null) { + continue; + } elseif ($this->rules && is_string($value)) { + $list[$key] = $this->resolvePermissions($value); + } elseif (Utils::isPositive($value)) { + $list[$key] = true; + } elseif (Utils::isNegative($value)) { + $list[$key] = false; + } + } + + return $list; + } + + /** + * @param string $access + * @return array + */ + protected function resolvePermissions(string $access): array + { + $len = strlen($access); + $op = true; + $list = []; + for($count = 0; $count < $len; $count++) { + $letter = $access[$count]; + if (isset($this->rules[$letter])) { + $list[$this->rules[$letter]] = $op; + $op = true; + } elseif (isset($this->ops[$letter])) { + $op = $this->ops[$letter]; + } + } + + return $list; + } +} diff --git a/system/src/Grav/Framework/Acl/Action.php b/system/src/Grav/Framework/Acl/Action.php new file mode 100644 index 0000000..5ea8f84 --- /dev/null +++ b/system/src/Grav/Framework/Acl/Action.php @@ -0,0 +1,204 @@ + + */ +class Action implements IteratorAggregate, Countable +{ + /** @var string */ + public $name; + /** @var string */ + public $type; + /** @var bool */ + public $visible; + /** @var string|null */ + public $label; + /** @var array */ + public $params; + + /** @var Action|null */ + protected $parent; + /** @var array */ + protected $children = []; + + /** + * @param string $name + * @param array $action + */ + public function __construct(string $name, array $action = []) + { + $label = $action['label'] ?? null; + if (!$label) { + if ($pos = mb_strrpos($name, '.')) { + $label = mb_substr($name, $pos + 1); + } else { + $label = $name; + } + $label = Inflector::humanize($label, 'all'); + } + + $this->name = $name; + $this->type = $action['type'] ?? 'action'; + $this->visible = (bool)($action['visible'] ?? true); + $this->label = $label; + unset($action['type'], $action['label']); + $this->params = $action; + + // Include compact rules. + if (isset($action['letters'])) { + foreach ($action['letters'] as $letter => $data) { + $data['letter'] = $letter; + $childName = $this->name . '.' . $data['action']; + unset($data['action']); + $child = new Action($childName, $data); + $this->addChild($child); + } + } + } + + /** + * @return array + */ + public function getParams(): array + { + return $this->params; + } + + /** + * @param string $name + * @return mixed|null + */ + public function getParam(string $name) + { + return $this->params[$name] ?? null; + } + + /** + * @return Action|null + */ + public function getParent(): ?Action + { + return $this->parent; + } + + /** + * @param Action|null $parent + * @return void + */ + public function setParent(?Action $parent): void + { + $this->parent = $parent; + } + + /** + * @return string + */ + public function getScope(): string + { + $pos = mb_strpos($this->name, '.'); + if ($pos) { + return mb_substr($this->name, 0, $pos); + } + + return $this->name; + } + + /** + * @return int + */ + public function getLevels(): int + { + return mb_substr_count($this->name, '.'); + } + + /** + * @return bool + */ + public function hasChildren(): bool + { + return !empty($this->children); + } + + /** + * @return Action[] + */ + public function getChildren(): array + { + return $this->children; + } + + /** + * @param string $name + * @return Action|null + */ + public function getChild(string $name): ?Action + { + return $this->children[$name] ?? null; + } + + /** + * @param Action $child + * @return void + */ + public function addChild(Action $child): void + { + if (mb_strpos($child->name, "{$this->name}.") !== 0) { + throw new RuntimeException('Bad child'); + } + + $child->setParent($this); + $name = mb_substr($child->name, mb_strlen($this->name) + 1); + + $this->children[$name] = $child; + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->children); + } + + /** + * @return int + */ + public function count(): int + { + return count($this->children); + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function __debugInfo() + { + return [ + 'name' => $this->name, + 'type' => $this->type, + 'label' => $this->label, + 'params' => $this->params, + 'actions' => $this->children + ]; + } +} diff --git a/system/src/Grav/Framework/Acl/Permissions.php b/system/src/Grav/Framework/Acl/Permissions.php new file mode 100644 index 0000000..324454a --- /dev/null +++ b/system/src/Grav/Framework/Acl/Permissions.php @@ -0,0 +1,249 @@ + + * @implements IteratorAggregate + */ +class Permissions implements ArrayAccess, Countable, IteratorAggregate +{ + /** @var array */ + protected $instances = []; + /** @var array */ + protected $actions = []; + /** @var array */ + protected $nested = []; + /** @var array */ + protected $types = []; + + /** + * @return array + */ + public function getInstances(): array + { + $iterator = new RecursiveActionIterator($this->actions); + $recursive = new RecursiveIteratorIterator($iterator, RecursiveIteratorIterator::SELF_FIRST); + + return iterator_to_array($recursive); + } + + /** + * @param string $name + * @return bool + */ + public function hasAction(string $name): bool + { + return isset($this->instances[$name]); + } + + /** + * @param string $name + * @return Action|null + */ + public function getAction(string $name): ?Action + { + return $this->instances[$name] ?? null; + } + + /** + * @param Action $action + * @return void + */ + public function addAction(Action $action): void + { + $name = $action->name; + $parent = $this->getParent($name); + if ($parent) { + $parent->addChild($action); + } else { + $this->actions[$name] = $action; + } + + $this->instances[$name] = $action; + + // If Action has children, add those, too. + foreach ($action->getChildren() as $child) { + $this->instances[$child->name] = $child; + } + } + + /** + * @return array + */ + public function getActions(): array + { + return $this->actions; + } + + /** + * @param Action[] $actions + * @return void + */ + public function addActions(array $actions): void + { + foreach ($actions as $action) { + $this->addAction($action); + } + } + + /** + * @param string $name + * @return bool + */ + public function hasType(string $name): bool + { + return isset($this->types[$name]); + } + + /** + * @param string $name + * @return Action|null + */ + public function getType(string $name): ?Action + { + return $this->types[$name] ?? null; + } + + /** + * @param string $name + * @param array $type + * @return void + */ + public function addType(string $name, array $type): void + { + $this->types[$name] = $type; + } + + /** + * @return array + */ + public function getTypes(): array + { + return $this->types; + } + + /** + * @param array $types + * @return void + */ + public function addTypes(array $types): void + { + $types = array_replace($this->types, $types); + + $this->types = $types; + } + + /** + * @param array|null $access + * @return Access + */ + public function getAccess(array $access = null): Access + { + return new Access($access ?? []); + } + + /** + * @param int|string $offset + * @return bool + */ + public function offsetExists($offset): bool + { + return isset($this->nested[$offset]); + } + + /** + * @param int|string $offset + * @return Action|null + */ + public function offsetGet($offset): ?Action + { + return $this->nested[$offset] ?? null; + } + + /** + * @param int|string $offset + * @param mixed $value + * @return void + */ + public function offsetSet($offset, $value): void + { + throw new RuntimeException(__METHOD__ . '(): Not Supported'); + } + + /** + * @param int|string $offset + * @return void + */ + public function offsetUnset($offset): void + { + throw new RuntimeException(__METHOD__ . '(): Not Supported'); + } + + /** + * @return int + */ + public function count(): int + { + return count($this->actions); + } + + /** + * @return ArrayIterator|Traversable + */ + #[\ReturnTypeWillChange] + public function getIterator() + { + return new ArrayIterator($this->actions); + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function __debugInfo() + { + return [ + 'actions' => $this->actions + ]; + } + + /** + * @param string $name + * @return Action|null + */ + protected function getParent(string $name): ?Action + { + if ($pos = strrpos($name, '.')) { + $parentName = substr($name, 0, $pos); + + $parent = $this->getAction($parentName); + if (!$parent) { + $parent = new Action($parentName); + $this->addAction($parent); + } + + return $parent; + } + + return null; + } +} diff --git a/system/src/Grav/Framework/Acl/PermissionsReader.php b/system/src/Grav/Framework/Acl/PermissionsReader.php new file mode 100644 index 0000000..5721452 --- /dev/null +++ b/system/src/Grav/Framework/Acl/PermissionsReader.php @@ -0,0 +1,186 @@ +content(); + $actions = $content['actions'] ?? []; + $types = $content['types'] ?? []; + + return static::fromArray($actions, $types); + } + + /** + * @param array $actions + * @param array $types + * @return Action[] + */ + public static function fromArray(array $actions, array $types): array + { + static::initTypes($types); + + $list = []; + foreach (static::read($actions) as $type => $data) { + $list[$type] = new Action($type, $data); + } + + return $list; + } + + /** + * @param array $actions + * @param string $prefix + * @return array + */ + public static function read(array $actions, string $prefix = ''): array + { + $list = []; + foreach ($actions as $name => $action) { + $prefixName = $prefix . $name; + $list[$prefixName] = null; + + // Support nested sets of actions. + if (isset($action['actions']) && is_array($action['actions'])) { + $innerList = static::read($action['actions'], "{$prefixName}."); + + $list += $innerList; + } + + unset($action['actions']); + + // Add defaults if they exist. + $action = static::addDefaults($action); + + // Build flat list of actions. + $list[$prefixName] = $action; + } + + return $list; + } + + /** + * @param array $types + * @return void + */ + protected static function initTypes(array $types) + { + static::$types = []; + + $dependencies = []; + foreach ($types as $type => $defaults) { + $current = array_fill_keys((array)($defaults['use'] ?? null), null); + $defType = $defaults['type'] ?? $type; + if ($type !== $defType) { + $current[$defaults['type']] = null; + } + + $dependencies[$type] = (object)$current; + } + + // Build dependency tree. + foreach ($dependencies as $type => $dep) { + foreach (get_object_vars($dep) as $k => &$val) { + if (null === $val) { + $val = $dependencies[$k] ?? new stdClass(); + } + } + unset($val); + } + + $encoded = json_encode($dependencies); + if ($encoded === false) { + throw new RuntimeException('json_encode(): failed to encode dependencies'); + } + $dependencies = json_decode($encoded, true); + + foreach (static::getDependencies($dependencies) as $type) { + $defaults = $types[$type] ?? null; + if ($defaults) { + static::$types[$type] = static::addDefaults($defaults); + } + } + } + + /** + * @param array $dependencies + * @return array + */ + protected static function getDependencies(array $dependencies): array + { + $list = [[]]; + foreach ($dependencies as $name => $deps) { + $current = $deps ? static::getDependencies($deps) : []; + $current[] = $name; + + $list[] = $current; + } + + return array_unique(array_merge(...$list)); + } + + /** + * @param array $action + * @return array + */ + protected static function addDefaults(array $action): array + { + $scopes = []; + + // Add used properties. + $use = (array)($action['use'] ?? null); + foreach ($use as $type) { + if (isset(static::$types[$type])) { + $used = static::$types[$type]; + unset($used['type']); + $scopes[] = $used; + } + } + unset($action['use']); + + // Add type defaults. + $type = $action['type'] ?? 'default'; + $defaults = static::$types[$type] ?? null; + if (is_array($defaults)) { + $scopes[] = $defaults; + } + + if ($scopes) { + $scopes[] = $action; + + $action = array_replace_recursive(...$scopes); + + $newType = $defaults['type'] ?? null; + if ($newType && $newType !== $type) { + $action['type'] = $newType; + } + } + + return $action; + } +} diff --git a/system/src/Grav/Framework/Acl/RecursiveActionIterator.php b/system/src/Grav/Framework/Acl/RecursiveActionIterator.php new file mode 100644 index 0000000..f4d4163 --- /dev/null +++ b/system/src/Grav/Framework/Acl/RecursiveActionIterator.php @@ -0,0 +1,64 @@ + + */ +class RecursiveActionIterator implements RecursiveIterator, \Countable +{ + use Constructor, Iterator, Countable; + + public $items; + + /** + * @see \Iterator::key() + * @return string + */ + #[\ReturnTypeWillChange] + public function key() + { + /** @var Action $current */ + $current = $this->current(); + + return $current->name; + } + + /** + * @see \RecursiveIterator::hasChildren() + * @return bool + */ + public function hasChildren(): bool + { + /** @var Action $current */ + $current = $this->current(); + + return $current->hasChildren(); + } + + /** + * @see \RecursiveIterator::getChildren() + * @return RecursiveActionIterator + */ + public function getChildren(): self + { + /** @var Action $current */ + $current = $this->current(); + + return new static($current->getChildren()); + } +} diff --git a/system/src/Grav/Framework/Cache/AbstractCache.php b/system/src/Grav/Framework/Cache/AbstractCache.php new file mode 100644 index 0000000..64394b3 --- /dev/null +++ b/system/src/Grav/Framework/Cache/AbstractCache.php @@ -0,0 +1,32 @@ +init($namespace, $defaultLifetime); + } +} diff --git a/system/src/Grav/Framework/Cache/Adapter/ChainCache.php b/system/src/Grav/Framework/Cache/Adapter/ChainCache.php new file mode 100644 index 0000000..086b75a --- /dev/null +++ b/system/src/Grav/Framework/Cache/Adapter/ChainCache.php @@ -0,0 +1,210 @@ +getMessage(), $e->getCode(), $e); + } + + if (!$caches) { + throw new InvalidArgumentException('At least one cache must be specified'); + } + + foreach ($caches as $cache) { + if (!$cache instanceof CacheInterface) { + throw new InvalidArgumentException( + sprintf( + "The class '%s' does not implement the '%s' interface", + get_class($cache), + CacheInterface::class + ) + ); + } + } + + $this->caches = array_values($caches); + $this->count = count($caches); + } + + /** + * @inheritdoc + */ + public function doGet($key, $miss) + { + foreach ($this->caches as $i => $cache) { + $value = $cache->doGet($key, $miss); + if ($value !== $miss) { + while (--$i >= 0) { + // Update all the previous caches with missing value. + $this->caches[$i]->doSet($key, $value, $this->getDefaultLifetime()); + } + + return $value; + } + } + + return $miss; + } + + /** + * @inheritdoc + */ + public function doSet($key, $value, $ttl) + { + $success = true; + $i = $this->count; + + while ($i--) { + $success = $this->caches[$i]->doSet($key, $value, $ttl) && $success; + } + + return $success; + } + + /** + * @inheritdoc + */ + public function doDelete($key) + { + $success = true; + $i = $this->count; + + while ($i--) { + $success = $this->caches[$i]->doDelete($key) && $success; + } + + return $success; + } + + /** + * @inheritdoc + */ + public function doClear() + { + $success = true; + $i = $this->count; + + while ($i--) { + $success = $this->caches[$i]->doClear() && $success; + } + + return $success; + } + + /** + * @inheritdoc + */ + public function doGetMultiple($keys, $miss) + { + $list = []; + /** + * @var int $i + * @var CacheInterface $cache + */ + foreach ($this->caches as $i => $cache) { + $list[$i] = $cache->doGetMultiple($keys, $miss); + + $keys = array_diff_key($keys, $list[$i]); + + if (!$keys) { + break; + } + } + + // Update all the previous caches with missing values. + $values = []; + /** + * @var int $i + * @var CacheInterface $items + */ + foreach (array_reverse($list) as $i => $items) { + $values += $items; + if ($i && $values) { + $this->caches[$i-1]->doSetMultiple($values, $this->getDefaultLifetime()); + } + } + + return $values; + } + + /** + * @inheritdoc + */ + public function doSetMultiple($values, $ttl) + { + $success = true; + $i = $this->count; + + while ($i--) { + $success = $this->caches[$i]->doSetMultiple($values, $ttl) && $success; + } + + return $success; + } + + /** + * @inheritdoc + */ + public function doDeleteMultiple($keys) + { + $success = true; + $i = $this->count; + + while ($i--) { + $success = $this->caches[$i]->doDeleteMultiple($keys) && $success; + } + + return $success; + } + + /** + * @inheritdoc + */ + public function doHas($key) + { + foreach ($this->caches as $cache) { + if ($cache->doHas($key)) { + return true; + } + } + + return false; + } +} diff --git a/system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php b/system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php new file mode 100644 index 0000000..451a422 --- /dev/null +++ b/system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php @@ -0,0 +1,118 @@ +getMessage(), $e->getCode(), $e); + } + + // Set namespace to Doctrine Cache provider if it was given. + $namespace = $this->getNamespace(); + if ($namespace) { + $doctrineCache->setNamespace($namespace); + } + + $this->driver = $doctrineCache; + } + + /** + * @inheritdoc + */ + public function doGet($key, $miss) + { + $value = $this->driver->fetch($key); + + // Doctrine cache does not differentiate between no result and cached 'false'. Make sure that we do. + return $value !== false || $this->driver->contains($key) ? $value : $miss; + } + + /** + * @inheritdoc + */ + public function doSet($key, $value, $ttl) + { + return $this->driver->save($key, $value, (int) $ttl); + } + + /** + * @inheritdoc + */ + public function doDelete($key) + { + return $this->driver->delete($key); + } + + /** + * @inheritdoc + */ + public function doClear() + { + return $this->driver->deleteAll(); + } + + /** + * @inheritdoc + */ + public function doGetMultiple($keys, $miss) + { + return $this->driver->fetchMultiple($keys); + } + + /** + * @inheritdoc + */ + public function doSetMultiple($values, $ttl) + { + return $this->driver->saveMultiple($values, (int) $ttl); + } + + /** + * @inheritdoc + */ + public function doDeleteMultiple($keys) + { + return $this->driver->deleteMultiple($keys); + } + + /** + * @inheritdoc + */ + public function doHas($key) + { + return $this->driver->contains($key); + } +} diff --git a/system/src/Grav/Framework/Cache/Adapter/FileCache.php b/system/src/Grav/Framework/Cache/Adapter/FileCache.php new file mode 100644 index 0000000..ee75fc6 --- /dev/null +++ b/system/src/Grav/Framework/Cache/Adapter/FileCache.php @@ -0,0 +1,266 @@ +initFileCache($namespace, $folder ?? ''); + } catch (\Psr\SimpleCache\InvalidArgumentException $e) { + throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * @inheritdoc + */ + public function doGet($key, $miss) + { + $now = time(); + $file = $this->getFile($key); + + if (!file_exists($file) || !$h = @fopen($file, 'rb')) { + return $miss; + } + + if ($now >= (int) $expiresAt = fgets($h)) { + fclose($h); + @unlink($file); + } else { + $i = rawurldecode(rtrim((string)fgets($h))); + $value = stream_get_contents($h) ?: ''; + fclose($h); + + if ($i === $key) { + return unserialize($value, ['allowed_classes' => true]); + } + } + + return $miss; + } + + /** + * @inheritdoc + * @throws CacheException + */ + public function doSet($key, $value, $ttl) + { + $expiresAt = time() + (int)$ttl; + + $result = $this->write( + $this->getFile($key, true), + $expiresAt . "\n" . rawurlencode($key) . "\n" . serialize($value), + $expiresAt + ); + + if (!$result && !is_writable($this->directory)) { + throw new CacheException(sprintf('Cache directory is not writable (%s)', $this->directory)); + } + + return $result; + } + + /** + * @inheritdoc + */ + public function doDelete($key) + { + $file = $this->getFile($key); + + $result = false; + if (file_exists($file)) { + $result = @unlink($file); + $result &= !file_exists($file); + } + + return $result; + } + + /** + * @inheritdoc + */ + public function doClear() + { + $result = true; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->directory, FilesystemIterator::SKIP_DOTS)); + + foreach ($iterator as $file) { + $result = ($file->isDir() || @unlink($file) || !file_exists($file)) && $result; + } + + return $result; + } + + /** + * @inheritdoc + */ + public function doHas($key) + { + $file = $this->getFile($key); + + return file_exists($file) && (@filemtime($file) > time() || $this->doGet($key, null)); + } + + /** + * @param string $key + * @param bool $mkdir + * @return string + */ + protected function getFile($key, $mkdir = false) + { + $hash = str_replace('/', '-', base64_encode(hash('sha256', static::class . $key, true))); + $dir = $this->directory . $hash[0] . DIRECTORY_SEPARATOR . $hash[1] . DIRECTORY_SEPARATOR; + + if ($mkdir) { + $this->mkdir($dir); + } + + return $dir . substr($hash, 2, 20); + } + + /** + * @param string $namespace + * @param string $directory + * @return void + * @throws InvalidArgumentException + */ + protected function initFileCache($namespace, $directory) + { + if ($directory === '') { + $directory = sys_get_temp_dir() . '/grav-cache'; + } else { + $directory = realpath($directory) ?: $directory; + } + + if (isset($namespace[0])) { + if (preg_match('#[^-+_.A-Za-z0-9]#', $namespace, $match)) { + throw new InvalidArgumentException(sprintf('Namespace contains "%s" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0])); + } + $directory .= DIRECTORY_SEPARATOR . $namespace; + } + + $this->mkdir($directory); + + $directory .= DIRECTORY_SEPARATOR; + // On Windows the whole path is limited to 258 chars + if ('\\' === DIRECTORY_SEPARATOR && strlen($directory) > 234) { + throw new InvalidArgumentException(sprintf('Cache folder is too long (%s)', $directory)); + } + $this->directory = $directory; + } + + /** + * @param string $file + * @param string $data + * @param int|null $expiresAt + * @return bool + */ + private function write($file, $data, $expiresAt = null) + { + set_error_handler(__CLASS__.'::throwError'); + + try { + if ($this->tmp === null) { + $this->tmp = $this->directory . uniqid('', true); + } + + file_put_contents($this->tmp, $data); + + if ($expiresAt !== null) { + touch($this->tmp, $expiresAt); + } + + return rename($this->tmp, $file); + } finally { + restore_error_handler(); + } + } + + /** + * @param string $dir + * @return void + * @throws RuntimeException + */ + private function mkdir($dir) + { + // Silence error for open_basedir; should fail in mkdir instead. + if (@is_dir($dir)) { + return; + } + + $success = @mkdir($dir, 0777, true); + + if (!$success) { + // Take yet another look, make sure that the folder doesn't exist. + clearstatcache(true, $dir); + if (!@is_dir($dir)) { + throw new RuntimeException(sprintf('Unable to create directory: %s', $dir)); + } + } + } + + /** + * @param int $type + * @param string $message + * @param string $file + * @param int $line + * @return bool + * @internal + * @throws ErrorException + */ + public static function throwError($type, $message, $file, $line) + { + throw new ErrorException($message, 0, $type, $file, $line); + } + + /** + * @return void + */ + #[\ReturnTypeWillChange] + public function __destruct() + { + if ($this->tmp !== null && file_exists($this->tmp)) { + unlink($this->tmp); + } + } +} diff --git a/system/src/Grav/Framework/Cache/Adapter/MemoryCache.php b/system/src/Grav/Framework/Cache/Adapter/MemoryCache.php new file mode 100644 index 0000000..35ca29e --- /dev/null +++ b/system/src/Grav/Framework/Cache/Adapter/MemoryCache.php @@ -0,0 +1,83 @@ +cache)) { + return $miss; + } + + return $this->cache[$key]; + } + + /** + * @param string $key + * @param mixed $value + * @param int $ttl + * @return bool + */ + public function doSet($key, $value, $ttl) + { + $this->cache[$key] = $value; + + return true; + } + + /** + * @param string $key + * @return bool + */ + public function doDelete($key) + { + unset($this->cache[$key]); + + return true; + } + + /** + * @return bool + */ + public function doClear() + { + $this->cache = []; + + return true; + } + + /** + * @param string $key + * @return bool + */ + public function doHas($key) + { + return array_key_exists($key, $this->cache); + } +} diff --git a/system/src/Grav/Framework/Cache/Adapter/SessionCache.php b/system/src/Grav/Framework/Cache/Adapter/SessionCache.php new file mode 100644 index 0000000..c4dbc69 --- /dev/null +++ b/system/src/Grav/Framework/Cache/Adapter/SessionCache.php @@ -0,0 +1,107 @@ +doGetStored($key); + + return $stored ? $stored[self::VALUE] : $miss; + } + + /** + * @param string $key + * @param mixed $value + * @param int $ttl + * @return bool + */ + public function doSet($key, $value, $ttl) + { + $stored = [self::VALUE => $value]; + if (null !== $ttl) { + $stored[self::LIFETIME] = time() + $ttl; + } + + $_SESSION[$this->getNamespace()][$key] = $stored; + + return true; + } + + /** + * @param string $key + * @return bool + */ + public function doDelete($key) + { + unset($_SESSION[$this->getNamespace()][$key]); + + return true; + } + + /** + * @return bool + */ + public function doClear() + { + unset($_SESSION[$this->getNamespace()]); + + return true; + } + + /** + * @param string $key + * @return bool + */ + public function doHas($key) + { + return $this->doGetStored($key) !== null; + } + + /** + * @return string + */ + public function getNamespace() + { + return 'cache-' . parent::getNamespace(); + } + + /** + * @param string $key + * @return mixed|null + */ + protected function doGetStored($key) + { + $stored = $_SESSION[$this->getNamespace()][$key] ?? null; + + if (isset($stored[self::LIFETIME]) && $stored[self::LIFETIME] < time()) { + unset($_SESSION[$this->getNamespace()][$key]); + $stored = null; + } + + return $stored ?: null; + } +} diff --git a/system/src/Grav/Framework/Cache/CacheInterface.php b/system/src/Grav/Framework/Cache/CacheInterface.php new file mode 100644 index 0000000..ab162e5 --- /dev/null +++ b/system/src/Grav/Framework/Cache/CacheInterface.php @@ -0,0 +1,71 @@ + $values + * @param int|null $ttl + * @return mixed + */ + public function doSetMultiple($values, $ttl); + + /** + * @param string[] $keys + * @return mixed + */ + public function doDeleteMultiple($keys); + + /** + * @param string $key + * @return mixed + */ + public function doHas($key); +} diff --git a/system/src/Grav/Framework/Cache/CacheTrait.php b/system/src/Grav/Framework/Cache/CacheTrait.php new file mode 100644 index 0000000..3e28c8b --- /dev/null +++ b/system/src/Grav/Framework/Cache/CacheTrait.php @@ -0,0 +1,373 @@ +namespace = (string) $namespace; + $this->defaultLifetime = $this->convertTtl($defaultLifetime); + $this->miss = new stdClass; + } + + /** + * @param bool $validation + * @return void + */ + public function setValidation($validation) + { + $this->validation = (bool) $validation; + } + + /** + * @return string + */ + protected function getNamespace() + { + return $this->namespace; + } + + /** + * @return int|null + */ + protected function getDefaultLifetime() + { + return $this->defaultLifetime; + } + + /** + * @param string $key + * @param mixed|null $default + * @return mixed|null + * @throws InvalidArgumentException + */ + public function get($key, $default = null) + { + $this->validateKey($key); + + $value = $this->doGet($key, $this->miss); + + return $value !== $this->miss ? $value : $default; + } + + /** + * @param string $key + * @param mixed $value + * @param null|int|DateInterval $ttl + * @return bool + * @throws InvalidArgumentException + */ + public function set($key, $value, $ttl = null) + { + $this->validateKey($key); + + $ttl = $this->convertTtl($ttl); + + // If a negative or zero TTL is provided, the item MUST be deleted from the cache. + return null !== $ttl && $ttl <= 0 ? $this->doDelete($key) : $this->doSet($key, $value, $ttl); + } + + /** + * @param string $key + * @return bool + * @throws InvalidArgumentException + */ + public function delete($key) + { + $this->validateKey($key); + + return $this->doDelete($key); + } + + /** + * @return bool + */ + public function clear() + { + return $this->doClear(); + } + + /** + * @param iterable $keys + * @param mixed|null $default + * @return iterable + * @throws InvalidArgumentException + */ + public function getMultiple($keys, $default = null) + { + if ($keys instanceof Traversable) { + $keys = iterator_to_array($keys, false); + } elseif (!is_array($keys)) { + $isObject = is_object($keys); + throw new InvalidArgumentException( + sprintf( + 'Cache keys must be array or Traversable, "%s" given', + $isObject ? get_class($keys) : gettype($keys) + ) + ); + } + + if (empty($keys)) { + return []; + } + + $this->validateKeys($keys); + $keys = array_unique($keys); + $keys = array_combine($keys, $keys); + + $list = $this->doGetMultiple($keys, $this->miss); + + // Make sure that values are returned in the same order as the keys were given. + $values = []; + foreach ($keys as $key) { + if (!array_key_exists($key, $list) || $list[$key] === $this->miss) { + $values[$key] = $default; + } else { + $values[$key] = $list[$key]; + } + } + + return $values; + } + + /** + * @param iterable $values + * @param null|int|DateInterval $ttl + * @return bool + * @throws InvalidArgumentException + */ + public function setMultiple($values, $ttl = null) + { + if ($values instanceof Traversable) { + $values = iterator_to_array($values, true); + } elseif (!is_array($values)) { + $isObject = is_object($values); + throw new InvalidArgumentException( + sprintf( + 'Cache values must be array or Traversable, "%s" given', + $isObject ? get_class($values) : gettype($values) + ) + ); + } + + $keys = array_keys($values); + + if (empty($keys)) { + return true; + } + + $this->validateKeys($keys); + + $ttl = $this->convertTtl($ttl); + + // If a negative or zero TTL is provided, the item MUST be deleted from the cache. + return null !== $ttl && $ttl <= 0 ? $this->doDeleteMultiple($keys) : $this->doSetMultiple($values, $ttl); + } + + /** + * @param iterable $keys + * @return bool + * @throws InvalidArgumentException + */ + public function deleteMultiple($keys) + { + if ($keys instanceof Traversable) { + $keys = iterator_to_array($keys, false); + } elseif (!is_array($keys)) { + $isObject = is_object($keys); + throw new InvalidArgumentException( + sprintf( + 'Cache keys must be array or Traversable, "%s" given', + $isObject ? get_class($keys) : gettype($keys) + ) + ); + } + + if (empty($keys)) { + return true; + } + + $this->validateKeys($keys); + + return $this->doDeleteMultiple($keys); + } + + /** + * @param string $key + * @return bool + * @throws InvalidArgumentException + */ + public function has($key) + { + $this->validateKey($key); + + return $this->doHas($key); + } + + /** + * @param array $keys + * @param mixed $miss + * @return array + */ + public function doGetMultiple($keys, $miss) + { + $results = []; + + foreach ($keys as $key) { + $value = $this->doGet($key, $miss); + if ($value !== $miss) { + $results[$key] = $value; + } + } + + return $results; + } + + /** + * @param array $values + * @param int|null $ttl + * @return bool + */ + public function doSetMultiple($values, $ttl) + { + $success = true; + + foreach ($values as $key => $value) { + $success = $this->doSet($key, $value, $ttl) && $success; + } + + return $success; + } + + /** + * @param array $keys + * @return bool + */ + public function doDeleteMultiple($keys) + { + $success = true; + + foreach ($keys as $key) { + $success = $this->doDelete($key) && $success; + } + + return $success; + } + + /** + * @param string|mixed $key + * @return void + * @throws InvalidArgumentException + */ + protected function validateKey($key) + { + if (!is_string($key)) { + throw new InvalidArgumentException( + sprintf( + 'Cache key must be string, "%s" given', + is_object($key) ? get_class($key) : gettype($key) + ) + ); + } + if (!isset($key[0])) { + throw new InvalidArgumentException('Cache key length must be greater than zero'); + } + if (strlen($key) > 64) { + throw new InvalidArgumentException( + sprintf('Cache key length must be less than 65 characters, key had %d characters', strlen($key)) + ); + } + if (strpbrk($key, '{}()/\@:') !== false) { + throw new InvalidArgumentException( + sprintf('Cache key "%s" contains reserved characters {}()/\@:', $key) + ); + } + } + + /** + * @param array $keys + * @return void + * @throws InvalidArgumentException + */ + protected function validateKeys($keys) + { + if (!$this->validation) { + return; + } + + foreach ($keys as $key) { + $this->validateKey($key); + } + } + + /** + * @param null|int|DateInterval $ttl + * @return int|null + * @throws InvalidArgumentException + */ + protected function convertTtl($ttl) + { + if ($ttl === null) { + return $this->getDefaultLifetime(); + } + + if (is_int($ttl)) { + return $ttl; + } + + if ($ttl instanceof DateInterval) { + $date = DateTime::createFromFormat('U', '0'); + $ttl = $date ? (int)$date->add($ttl)->format('U') : 0; + } + + throw new InvalidArgumentException( + sprintf( + 'Expiration date must be an integer, a DateInterval or null, "%s" given', + is_object($ttl) ? get_class($ttl) : gettype($ttl) + ) + ); + } +} diff --git a/system/src/Grav/Framework/Cache/Exception/CacheException.php b/system/src/Grav/Framework/Cache/Exception/CacheException.php new file mode 100644 index 0000000..17ec618 --- /dev/null +++ b/system/src/Grav/Framework/Cache/Exception/CacheException.php @@ -0,0 +1,21 @@ + + * @implements FileCollectionInterface + */ +class AbstractFileCollection extends AbstractLazyCollection implements FileCollectionInterface +{ + /** @var string */ + protected $path; + /** @var RecursiveDirectoryIterator|RecursiveUniformResourceIterator */ + protected $iterator; + /** @var callable */ + protected $createObjectFunction; + /** @var callable|null */ + protected $filterFunction; + /** @var int */ + protected $flags; + /** @var int */ + protected $nestingLimit; + + /** + * @param string $path + */ + protected function __construct($path) + { + $this->path = $path; + $this->flags = self::INCLUDE_FILES | self::INCLUDE_FOLDERS; + $this->nestingLimit = 0; + $this->createObjectFunction = [$this, 'createObject']; + + $this->setIterator(); + } + + /** + * @return string + */ + public function getPath() + { + return $this->path; + } + + /** + * @param Criteria $criteria + * @return ArrayCollection + * @phpstan-return ArrayCollection + * @todo Implement lazy matching + */ + public function matching(Criteria $criteria) + { + $expr = $criteria->getWhereExpression(); + + $oldFilter = $this->filterFunction; + if ($expr) { + $visitor = new ClosureExpressionVisitor(); + $filter = $visitor->dispatch($expr); + $this->addFilter($filter); + } + + $filtered = $this->doInitializeByIterator($this->iterator, $this->nestingLimit); + $this->filterFunction = $oldFilter; + + if ($orderings = $criteria->getOrderings()) { + $next = null; + /** + * @var string $field + * @var string $ordering + */ + foreach (array_reverse($orderings) as $field => $ordering) { + $next = ClosureExpressionVisitor::sortByField($field, $ordering === Criteria::DESC ? -1 : 1, $next); + } + /** @phpstan-ignore-next-line */ + if (null === $next) { + throw new RuntimeException('Criteria is missing orderings'); + } + + uasort($filtered, $next); + } else { + ksort($filtered); + } + + $offset = $criteria->getFirstResult(); + $length = $criteria->getMaxResults(); + + if ($offset || $length) { + $filtered = array_slice($filtered, (int)$offset, $length); + } + + return new ArrayCollection($filtered); + } + + /** + * @return void + */ + protected function setIterator() + { + $iteratorFlags = RecursiveDirectoryIterator::SKIP_DOTS + FilesystemIterator::UNIX_PATHS + + FilesystemIterator::CURRENT_AS_SELF + FilesystemIterator::FOLLOW_SYMLINKS; + + if (strpos($this->path, '://')) { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $this->iterator = $locator->getRecursiveIterator($this->path, $iteratorFlags); + } else { + $this->iterator = new RecursiveDirectoryIterator($this->path, $iteratorFlags); + } + } + + /** + * @param callable $filterFunction + * @return $this + */ + protected function addFilter(callable $filterFunction) + { + if ($this->filterFunction) { + $oldFilterFunction = $this->filterFunction; + $this->filterFunction = function ($expr) use ($oldFilterFunction, $filterFunction) { + return $oldFilterFunction($expr) && $filterFunction($expr); + }; + } else { + $this->filterFunction = $filterFunction; + } + + return $this; + } + + /** + * {@inheritDoc} + */ + protected function doInitialize() + { + $filtered = $this->doInitializeByIterator($this->iterator, $this->nestingLimit); + ksort($filtered); + + $this->collection = new ArrayCollection($filtered); + } + + /** + * @param SeekableIterator $iterator + * @param int $nestingLimit + * @return array + * @phpstan-param SeekableIterator $iterator + */ + protected function doInitializeByIterator(SeekableIterator $iterator, $nestingLimit) + { + $children = []; + $objects = []; + $filter = $this->filterFunction; + $objectFunction = $this->createObjectFunction; + + /** @var RecursiveDirectoryIterator $file */ + foreach ($iterator as $file) { + // Skip files if they shouldn't be included. + if (!($this->flags & static::INCLUDE_FILES) && $file->isFile()) { + continue; + } + + // Apply main filter. + if ($filter && !$filter($file)) { + continue; + } + + // Include children if the recursive flag is set. + if (($this->flags & static::RECURSIVE) && $nestingLimit > 0 && $file->hasChildren()) { + $children[] = $file->getChildren(); + } + + // Skip folders if they shouldn't be included. + if (!($this->flags & static::INCLUDE_FOLDERS) && $file->isDir()) { + continue; + } + + $object = $objectFunction($file); + $objects[$object->key] = $object; + } + + if ($children) { + $objects += $this->doInitializeChildren($children, $nestingLimit - 1); + } + + return $objects; + } + + /** + * @param array $children + * @param int $nestingLimit + * @return array + */ + protected function doInitializeChildren(array $children, $nestingLimit) + { + $objects = []; + foreach ($children as $iterator) { + $objects += $this->doInitializeByIterator($iterator, $nestingLimit); + } + + return $objects; + } + + /** + * @param RecursiveDirectoryIterator $file + * @return object + */ + protected function createObject($file) + { + return (object) [ + 'key' => $file->getSubPathname(), + 'type' => $file->isDir() ? 'folder' : 'file:' . $file->getExtension(), + 'url' => method_exists($file, 'getUrl') ? $file->getUrl() : null, + 'pathname' => $file->getPathname(), + 'mtime' => $file->getMTime() + ]; + } +} diff --git a/system/src/Grav/Framework/Collection/AbstractIndexCollection.php b/system/src/Grav/Framework/Collection/AbstractIndexCollection.php new file mode 100644 index 0000000..710c415 --- /dev/null +++ b/system/src/Grav/Framework/Collection/AbstractIndexCollection.php @@ -0,0 +1,574 @@ + + */ +abstract class AbstractIndexCollection implements CollectionInterface +{ + use Serializable; + + /** + * @var array + * @phpstan-var array + */ + private $entries; + + /** + * Initializes a new IndexCollection. + * + * @param array $entries + * @phpstan-param array $entries + */ + public function __construct(array $entries = []) + { + $this->entries = $entries; + } + + /** + * {@inheritDoc} + */ + public function toArray() + { + return $this->loadElements($this->entries); + } + + /** + * {@inheritDoc} + */ + public function first() + { + $value = reset($this->entries); + $key = (string)key($this->entries); + + return $this->loadElement($key, $value); + } + + /** + * {@inheritDoc} + */ + public function last() + { + $value = end($this->entries); + $key = (string)key($this->entries); + + return $this->loadElement($key, $value); + } + + /** + * {@inheritDoc} + */ + #[\ReturnTypeWillChange] + public function key() + { + /** @phpstan-var TKey */ + return (string)key($this->entries); + } + + /** + * {@inheritDoc} + */ + #[\ReturnTypeWillChange] + public function next() + { + $value = next($this->entries); + $key = (string)key($this->entries); + + return $this->loadElement($key, $value); + } + + /** + * {@inheritDoc} + */ + #[\ReturnTypeWillChange] + public function current() + { + $value = current($this->entries); + $key = (string)key($this->entries); + + return $this->loadElement($key, $value); + } + + /** + * {@inheritDoc} + */ + public function remove($key) + { + if (!array_key_exists($key, $this->entries)) { + return null; + } + + $value = $this->entries[$key]; + unset($this->entries[$key]); + + return $this->loadElement((string)$key, $value); + } + + /** + * {@inheritDoc} + */ + public function removeElement($element) + { + $key = $this->isAllowedElement($element) ? $this->getCurrentKey($element) : null; + + if (null !== $key || !isset($this->entries[$key])) { + return false; + } + + unset($this->entries[$key]); + + return true; + } + + /** + * Required by interface ArrayAccess. + * + * @param string|int|null $offset + * @return bool + * @phpstan-param TKey|null $offset + */ + #[\ReturnTypeWillChange] + public function offsetExists($offset) + { + /** @phpstan-ignore-next-line phpstan bug? */ + return $offset !== null ? $this->containsKey($offset) : false; + } + + /** + * Required by interface ArrayAccess. + * + * @param string|int|null $offset + * @return mixed + * @phpstan-param TKey|null $offset + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + /** @phpstan-ignore-next-line phpstan bug? */ + return $offset !== null ? $this->get($offset) : null; + } + + /** + * Required by interface ArrayAccess. + * + * @param string|int|null $offset + * @param mixed $value + * @return void + * @phpstan-param TKey|null $offset + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + if (null === $offset) { + $this->add($value); + } else { + /** @phpstan-ignore-next-line phpstan bug? */ + $this->set($offset, $value); + } + } + + /** + * Required by interface ArrayAccess. + * + * @param string|int|null $offset + * @return void + * @phpstan-param TKey|null $offset + */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset) + { + if ($offset !== null) { + /** @phpstan-ignore-next-line phpstan bug? */ + $this->remove($offset); + } + } + + /** + * {@inheritDoc} + */ + public function containsKey($key) + { + return isset($this->entries[$key]) || array_key_exists($key, $this->entries); + } + + /** + * {@inheritDoc} + */ + public function contains($element) + { + $key = $this->isAllowedElement($element) ? $this->getCurrentKey($element) : null; + + return $key && isset($this->entries[$key]); + } + + /** + * {@inheritDoc} + */ + public function exists(Closure $p) + { + return $this->loadCollection($this->entries)->exists($p); + } + + /** + * {@inheritDoc} + */ + public function indexOf($element) + { + $key = $this->isAllowedElement($element) ? $this->getCurrentKey($element) : null; + + return $key && isset($this->entries[$key]) ? $key : false; + } + + /** + * {@inheritDoc} + */ + public function get($key) + { + if (!isset($this->entries[$key])) { + return null; + } + + return $this->loadElement((string)$key, $this->entries[$key]); + } + + /** + * {@inheritDoc} + */ + public function getKeys() + { + return array_keys($this->entries); + } + + /** + * {@inheritDoc} + */ + public function getValues() + { + return array_values($this->loadElements($this->entries)); + } + + /** + * {@inheritDoc} + */ + #[\ReturnTypeWillChange] + public function count() + { + return count($this->entries); + } + + /** + * {@inheritDoc} + */ + public function set($key, $value) + { + if (!$this->isAllowedElement($value)) { + throw new InvalidArgumentException('Invalid argument $value'); + } + + $this->entries[$key] = $this->getElementMeta($value); + } + + /** + * {@inheritDoc} + */ + public function add($element) + { + if (!$this->isAllowedElement($element)) { + throw new InvalidArgumentException('Invalid argument $element'); + } + + $this->entries[$this->getCurrentKey($element)] = $this->getElementMeta($element); + + return true; + } + + /** + * {@inheritDoc} + */ + public function isEmpty() + { + return empty($this->entries); + } + + /** + * Required by interface IteratorAggregate. + * + * {@inheritDoc} + * @phpstan-return Iterator + */ + #[\ReturnTypeWillChange] + public function getIterator() + { + return new ArrayIterator($this->loadElements()); + } + + /** + * {@inheritDoc} + */ + public function map(Closure $func) + { + return $this->loadCollection($this->entries)->map($func); + } + + /** + * {@inheritDoc} + */ + public function filter(Closure $p) + { + return $this->loadCollection($this->entries)->filter($p); + } + + /** + * {@inheritDoc} + */ + public function forAll(Closure $p) + { + return $this->loadCollection($this->entries)->forAll($p); + } + + /** + * {@inheritDoc} + */ + public function partition(Closure $p) + { + return $this->loadCollection($this->entries)->partition($p); + } + + /** + * Returns a string representation of this object. + * + * @return string + */ + #[\ReturnTypeWillChange] + public function __toString() + { + return __CLASS__ . '@' . spl_object_hash($this); + } + + /** + * {@inheritDoc} + */ + public function clear() + { + $this->entries = []; + } + + /** + * {@inheritDoc} + */ + public function slice($offset, $length = null) + { + return $this->loadElements(array_slice($this->entries, $offset, $length, true)); + } + + /** + * @param int $start + * @param int|null $limit + * @return static + * @phpstan-return static + */ + public function limit($start, $limit = null) + { + return $this->createFrom(array_slice($this->entries, $start, $limit, true)); + } + + /** + * Reverse the order of the items. + * + * @return static + * @phpstan-return static + */ + public function reverse() + { + return $this->createFrom(array_reverse($this->entries)); + } + + /** + * Shuffle items. + * + * @return static + * @phpstan-return static + */ + public function shuffle() + { + $keys = $this->getKeys(); + shuffle($keys); + + return $this->createFrom(array_replace(array_flip($keys), $this->entries)); + } + + /** + * Select items from collection. + * + * Collection is returned in the order of $keys given to the function. + * + * @param array $keys + * @return static + * @phpstan-return static + */ + public function select(array $keys) + { + $list = []; + foreach ($keys as $key) { + if (isset($this->entries[$key])) { + $list[$key] = $this->entries[$key]; + } + } + + return $this->createFrom($list); + } + + /** + * Un-select items from collection. + * + * @param array $keys + * @return static + * @phpstan-return static + */ + public function unselect(array $keys) + { + return $this->select(array_diff($this->getKeys(), $keys)); + } + + /** + * Split collection into chunks. + * + * @param int $size Size of each chunk. + * @return array + * @phpstan-return array> + */ + public function chunk($size) + { + /** @phpstan-var array> */ + return $this->loadCollection($this->entries)->chunk($size); + } + + /** + * @return array + */ + public function __serialize(): array + { + return [ + 'entries' => $this->entries + ]; + } + + /** + * @param array $data + * @return void + */ + public function __unserialize(array $data): void + { + $this->entries = $data['entries']; + } + + /** + * Implements JsonSerializable interface. + * + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->loadCollection()->jsonSerialize(); + } + + /** + * Creates a new instance from the specified elements. + * + * This method is provided for derived classes to specify how a new + * instance should be created when constructor semantics have changed. + * + * @param array $entries Elements. + * @return static + * @phpstan-return static + */ + protected function createFrom(array $entries) + { + return new static($entries); + } + + /** + * @return array + */ + protected function getEntries(): array + { + return $this->entries; + } + + /** + * @param array $entries + * @return void + * @phpstan-param array $entries + */ + protected function setEntries(array $entries): void + { + $this->entries = $entries; + } + + /** + * @param FlexObjectInterface $element + * @return string + * @phpstan-param T $element + * @phpstan-return TKey + */ + protected function getCurrentKey($element) + { + return $element->getKey(); + } + + /** + * @param string $key + * @param mixed $value + * @return mixed|null + */ + abstract protected function loadElement($key, $value); + + /** + * @param array|null $entries + * @return array + * @phpstan-return array + */ + abstract protected function loadElements(array $entries = null): array; + + /** + * @param array|null $entries + * @return CollectionInterface + * @phpstan-return C + */ + abstract protected function loadCollection(array $entries = null): CollectionInterface; + + /** + * @param mixed $value + * @return bool + */ + abstract protected function isAllowedElement($value): bool; + + /** + * @param mixed $element + * @return mixed + */ + abstract protected function getElementMeta($element); +} diff --git a/system/src/Grav/Framework/Collection/AbstractLazyCollection.php b/system/src/Grav/Framework/Collection/AbstractLazyCollection.php new file mode 100644 index 0000000..045685e --- /dev/null +++ b/system/src/Grav/Framework/Collection/AbstractLazyCollection.php @@ -0,0 +1,97 @@ + + * @implements CollectionInterface + */ +abstract class AbstractLazyCollection extends BaseAbstractLazyCollection implements CollectionInterface +{ + /** + * @par ArrayCollection + * @phpstan-var ArrayCollection + */ + protected $collection; + + /** + * {@inheritDoc} + * @phpstan-return ArrayCollection + */ + public function reverse() + { + $this->initialize(); + + return $this->collection->reverse(); + } + + /** + * {@inheritDoc} + * @phpstan-return ArrayCollection + */ + public function shuffle() + { + $this->initialize(); + + return $this->collection->shuffle(); + } + + /** + * {@inheritDoc} + */ + public function chunk($size) + { + $this->initialize(); + + return $this->collection->chunk($size); + } + + /** + * {@inheritDoc} + * @phpstan-param array $keys + * @phpstan-return ArrayCollection + */ + public function select(array $keys) + { + $this->initialize(); + + return $this->collection->select($keys); + } + + /** + * {@inheritDoc} + * @phpstan-param array $keys + * @phpstan-return ArrayCollection + */ + public function unselect(array $keys) + { + $this->initialize(); + + return $this->collection->unselect($keys); + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + $this->initialize(); + + return $this->collection->jsonSerialize(); + } +} diff --git a/system/src/Grav/Framework/Collection/ArrayCollection.php b/system/src/Grav/Framework/Collection/ArrayCollection.php new file mode 100644 index 0000000..a99291f --- /dev/null +++ b/system/src/Grav/Framework/Collection/ArrayCollection.php @@ -0,0 +1,117 @@ + + * @implements CollectionInterface + */ +class ArrayCollection extends BaseArrayCollection implements CollectionInterface +{ + /** + * Reverse the order of the items. + * + * @return static + * @phpstan-return static + */ + public function reverse() + { + $keys = array_reverse($this->toArray()); + + /** @phpstan-var static */ + return $this->createFrom($keys); + } + + /** + * Shuffle items. + * + * @return static + * @phpstan-return static + */ + public function shuffle() + { + $keys = $this->getKeys(); + shuffle($keys); + $keys = array_replace(array_flip($keys), $this->toArray()); + + /** @phpstan-var static */ + return $this->createFrom($keys); + } + + /** + * Split collection into chunks. + * + * @param int $size Size of each chunk. + * @return array + * @phpstan-return array> + */ + public function chunk($size) + { + /** @phpstan-var array> */ + return array_chunk($this->toArray(), $size, true); + } + + /** + * Select items from collection. + * + * Collection is returned in the order of $keys given to the function. + * + * @param array $keys + * @return static + * @phpstan-param TKey[] $keys + * @phpstan-return static + */ + public function select(array $keys) + { + $list = []; + foreach ($keys as $key) { + if ($this->containsKey($key)) { + $list[$key] = $this->get($key); + } + } + + /** @phpstan-var static */ + return $this->createFrom($list); + } + + /** + * Un-select items from collection. + * + * @param array $keys + * @return static + * @phpstan-param TKey[] $keys + * @phpstan-return static + */ + public function unselect(array $keys) + { + $list = array_diff($this->getKeys(), $keys); + + /** @phpstan-var static */ + return $this->select($list); + } + + /** + * Implements JsonSerializable interface. + * + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->toArray(); + } +} diff --git a/system/src/Grav/Framework/Collection/CollectionInterface.php b/system/src/Grav/Framework/Collection/CollectionInterface.php new file mode 100644 index 0000000..a629756 --- /dev/null +++ b/system/src/Grav/Framework/Collection/CollectionInterface.php @@ -0,0 +1,69 @@ + + */ +interface CollectionInterface extends Collection, JsonSerializable +{ + /** + * Reverse the order of the items. + * + * @return CollectionInterface + * @phpstan-return static + */ + public function reverse(); + + /** + * Shuffle items. + * + * @return CollectionInterface + * @phpstan-return static + */ + public function shuffle(); + + /** + * Split collection into chunks. + * + * @param int $size Size of each chunk. + * @return array + * @phpstan-return array> + */ + public function chunk($size); + + /** + * Select items from collection. + * + * Collection is returned in the order of $keys given to the function. + * + * @param array $keys + * @return CollectionInterface + * @phpstan-return static + */ + public function select(array $keys); + + /** + * Un-select items from collection. + * + * @param array $keys + * @return CollectionInterface + * @phpstan-return static + */ + public function unselect(array $keys); +} diff --git a/system/src/Grav/Framework/Collection/FileCollection.php b/system/src/Grav/Framework/Collection/FileCollection.php new file mode 100644 index 0000000..73580fa --- /dev/null +++ b/system/src/Grav/Framework/Collection/FileCollection.php @@ -0,0 +1,97 @@ + + */ +class FileCollection extends AbstractFileCollection +{ + /** + * @param string $path + * @param int $flags + */ + public function __construct($path, $flags = null) + { + parent::__construct($path); + + $this->flags = (int)($flags ?: self::INCLUDE_FILES | self::INCLUDE_FOLDERS | self::RECURSIVE); + + $this->setIterator(); + $this->setFilter(); + $this->setObjectBuilder(); + $this->setNestingLimit(); + } + + /** + * @return int + */ + public function getFlags() + { + return $this->flags; + } + + /** + * @return int + */ + public function getNestingLimit() + { + return $this->nestingLimit; + } + + /** + * @param int $limit + * @return $this + */ + public function setNestingLimit($limit = 99) + { + $this->nestingLimit = (int) $limit; + + return $this; + } + + /** + * @param callable|null $filterFunction + * @return $this + */ + public function setFilter(callable $filterFunction = null) + { + $this->filterFunction = $filterFunction; + + return $this; + } + + /** + * @param callable $filterFunction + * @return $this + */ + public function addFilter(callable $filterFunction) + { + parent::addFilter($filterFunction); + + return $this; + } + + /** + * @param callable|null $objectFunction + * @return $this + */ + public function setObjectBuilder(callable $objectFunction = null) + { + $this->createObjectFunction = $objectFunction ?: [$this, 'createObject']; + + return $this; + } +} diff --git a/system/src/Grav/Framework/Collection/FileCollectionInterface.php b/system/src/Grav/Framework/Collection/FileCollectionInterface.php new file mode 100644 index 0000000..8cbc2ea --- /dev/null +++ b/system/src/Grav/Framework/Collection/FileCollectionInterface.php @@ -0,0 +1,33 @@ + + * @extends Selectable + */ +interface FileCollectionInterface extends CollectionInterface, Selectable +{ + public const INCLUDE_FILES = 1; + public const INCLUDE_FOLDERS = 2; + public const RECURSIVE = 4; + + /** + * @return string + */ + public function getPath(); +} diff --git a/system/src/Grav/Framework/Compat/Serializable.php b/system/src/Grav/Framework/Compat/Serializable.php new file mode 100644 index 0000000..da1cce3 --- /dev/null +++ b/system/src/Grav/Framework/Compat/Serializable.php @@ -0,0 +1,47 @@ +__serialize()); + } + + /** + * @param string $serialized + * @return void + */ + final public function unserialize($serialized): void + { + $this->__unserialize(unserialize($serialized, ['allowed_classes' => $this->getUnserializeAllowedClasses()])); + } + + /** + * @return array|bool + */ + protected function getUnserializeAllowedClasses() + { + return false; + } +} diff --git a/system/src/Grav/Framework/ContentBlock/ContentBlock.php b/system/src/Grav/Framework/ContentBlock/ContentBlock.php new file mode 100644 index 0000000..f580006 --- /dev/null +++ b/system/src/Grav/Framework/ContentBlock/ContentBlock.php @@ -0,0 +1,303 @@ +setContent('my inner content'); + * $outerBlock = ContentBlock::create(); + * $outerBlock->setContent(sprintf('Inside my outer block I have %s.', $innerBlock->getToken())); + * $outerBlock->addBlock($innerBlock); + * echo $outerBlock; + * + * @package Grav\Framework\ContentBlock + */ +class ContentBlock implements ContentBlockInterface +{ + use Serializable; + + /** @var int */ + protected $version = 1; + /** @var string */ + protected $id; + /** @var string */ + protected $tokenTemplate = '@@BLOCK-%s@@'; + /** @var string */ + protected $content = ''; + /** @var array */ + protected $blocks = []; + /** @var string */ + protected $checksum; + /** @var bool */ + protected $cached = true; + + /** + * @param string|null $id + * @return static + */ + public static function create($id = null) + { + return new static($id); + } + + /** + * @param array $serialized + * @return ContentBlockInterface + * @throws InvalidArgumentException + */ + public static function fromArray(array $serialized) + { + try { + $type = $serialized['_type'] ?? null; + $id = $serialized['id'] ?? null; + + if (!$type || !$id || !is_a($type, ContentBlockInterface::class, true)) { + throw new InvalidArgumentException('Bad data'); + } + + /** @var ContentBlockInterface $instance */ + $instance = new $type($id); + $instance->build($serialized); + } catch (Exception $e) { + throw new InvalidArgumentException(sprintf('Cannot unserialize Block: %s', $e->getMessage()), $e->getCode(), $e); + } + + return $instance; + } + + /** + * Block constructor. + * + * @param string|null $id + */ + public function __construct($id = null) + { + $this->id = $id ? (string) $id : $this->generateId(); + } + + /** + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * @return string + */ + public function getToken() + { + return sprintf($this->tokenTemplate, $this->getId()); + } + + /** + * @return array + */ + public function toArray() + { + $blocks = []; + /** @var ContentBlockInterface $block */ + foreach ($this->blocks as $block) { + $blocks[$block->getId()] = $block->toArray(); + } + + $array = [ + '_type' => get_class($this), + '_version' => $this->version, + 'id' => $this->id, + 'cached' => $this->cached + ]; + + if ($this->checksum) { + $array['checksum'] = $this->checksum; + } + + if ($this->content) { + $array['content'] = $this->content; + } + + if ($blocks) { + $array['blocks'] = $blocks; + } + + return $array; + } + + /** + * @return string + */ + public function toString() + { + if (!$this->blocks) { + return (string) $this->content; + } + + $tokens = []; + $replacements = []; + foreach ($this->blocks as $block) { + $tokens[] = $block->getToken(); + $replacements[] = $block->toString(); + } + + return str_replace($tokens, $replacements, (string) $this->content); + } + + /** + * @return string + */ + #[\ReturnTypeWillChange] + public function __toString() + { + try { + return $this->toString(); + } catch (Exception $e) { + return sprintf('Error while rendering block: %s', $e->getMessage()); + } + } + + /** + * @param array $serialized + * @return void + * @throws RuntimeException + */ + public function build(array $serialized) + { + $this->checkVersion($serialized); + + $this->id = $serialized['id'] ?? $this->generateId(); + $this->checksum = $serialized['checksum'] ?? null; + $this->cached = $serialized['cached'] ?? null; + + if (isset($serialized['content'])) { + $this->setContent($serialized['content']); + } + + $blocks = isset($serialized['blocks']) ? (array) $serialized['blocks'] : []; + foreach ($blocks as $block) { + $this->addBlock(self::fromArray($block)); + } + } + + /** + * @return bool + */ + public function isCached() + { + if (!$this->cached) { + return false; + } + + foreach ($this->blocks as $block) { + if (!$block->isCached()) { + return false; + } + } + + return true; + } + + /** + * @return $this + */ + public function disableCache() + { + $this->cached = false; + + return $this; + } + + /** + * @param string $checksum + * @return $this + */ + public function setChecksum($checksum) + { + $this->checksum = $checksum; + + return $this; + } + + /** + * @return string + */ + public function getChecksum() + { + return $this->checksum; + } + + /** + * @param string $content + * @return $this + */ + public function setContent($content) + { + $this->content = $content; + + return $this; + } + + /** + * @param ContentBlockInterface $block + * @return $this + */ + public function addBlock(ContentBlockInterface $block) + { + $this->blocks[$block->getId()] = $block; + + return $this; + } + + /** + * @return array + */ + final public function __serialize(): array + { + return $this->toArray(); + } + + /** + * @param array $data + * @return void + */ + final public function __unserialize(array $data): void + { + $this->build($data); + } + + /** + * @return string + */ + protected function generateId() + { + return uniqid('', true); + } + + /** + * @param array $serialized + * @return void + * @throws RuntimeException + */ + protected function checkVersion(array $serialized) + { + $version = isset($serialized['_version']) ? (int) $serialized['_version'] : 1; + if ($version !== $this->version) { + throw new RuntimeException(sprintf('Unsupported version %s', $version)); + } + } +} diff --git a/system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php b/system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php new file mode 100644 index 0000000..abb264c --- /dev/null +++ b/system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php @@ -0,0 +1,90 @@ +getAssetsFast(); + + $this->sortAssets($assets['styles']); + $this->sortAssets($assets['scripts']); + $this->sortAssets($assets['links']); + $this->sortAssets($assets['html']); + + return $assets; + } + + /** + * @return array + */ + public function getFrameworks() + { + $assets = $this->getAssetsFast(); + + return array_keys($assets['frameworks']); + } + + /** + * @param string $location + * @return array + */ + public function getStyles($location = 'head') + { + return $this->getAssetsInLocation('styles', $location); + } + + /** + * @param string $location + * @return array + */ + public function getScripts($location = 'head') + { + return $this->getAssetsInLocation('scripts', $location); + } + + /** + * @param string $location + * @return array + */ + public function getLinks($location = 'head') + { + return $this->getAssetsInLocation('links', $location); + } + + /** + * @param string $location + * @return array + */ + public function getHtml($location = 'bottom') + { + return $this->getAssetsInLocation('html', $location); + } + + /** + * @return array + */ + public function toArray() + { + $array = parent::toArray(); + + if ($this->frameworks) { + $array['frameworks'] = $this->frameworks; + } + if ($this->styles) { + $array['styles'] = $this->styles; + } + if ($this->scripts) { + $array['scripts'] = $this->scripts; + } + if ($this->links) { + $array['links'] = $this->links; + } + if ($this->html) { + $array['html'] = $this->html; + } + + return $array; + } + + /** + * @param array $serialized + * @return void + * @throws RuntimeException + */ + public function build(array $serialized) + { + parent::build($serialized); + + $this->frameworks = isset($serialized['frameworks']) ? (array) $serialized['frameworks'] : []; + $this->styles = isset($serialized['styles']) ? (array) $serialized['styles'] : []; + $this->scripts = isset($serialized['scripts']) ? (array) $serialized['scripts'] : []; + $this->links = isset($serialized['links']) ? (array) $serialized['links'] : []; + $this->html = isset($serialized['html']) ? (array) $serialized['html'] : []; + } + + /** + * @param string $framework + * @return $this + */ + public function addFramework($framework) + { + $this->frameworks[$framework] = 1; + + return $this; + } + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + * + * @example $block->addStyle('assets/js/my.js'); + * @example $block->addStyle(['href' => 'assets/js/my.js', 'media' => 'screen']); + */ + public function addStyle($element, $priority = 0, $location = 'head') + { + if (!is_array($element)) { + $element = ['href' => (string) $element]; + } + if (empty($element['href'])) { + return false; + } + if (!isset($this->styles[$location])) { + $this->styles[$location] = []; + } + + $id = !empty($element['id']) ? ['id' => (string) $element['id']] : []; + $href = $element['href']; + $type = !empty($element['type']) ? (string) $element['type'] : 'text/css'; + $media = !empty($element['media']) ? (string) $element['media'] : null; + unset( + $element['tag'], + $element['id'], + $element['rel'], + $element['content'], + $element['href'], + $element['type'], + $element['media'] + ); + + $this->styles[$location][md5($href) . sha1($href)] = [ + ':type' => 'file', + ':priority' => (int) $priority, + 'href' => $href, + 'type' => $type, + 'media' => $media, + 'element' => $element + ] + $id; + + return true; + } + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addInlineStyle($element, $priority = 0, $location = 'head') + { + if (!is_array($element)) { + $element = ['content' => (string) $element]; + } + if (empty($element['content'])) { + return false; + } + if (!isset($this->styles[$location])) { + $this->styles[$location] = []; + } + + $content = (string) $element['content']; + $type = !empty($element['type']) ? (string) $element['type'] : 'text/css'; + + unset($element['content'], $element['type']); + + $this->styles[$location][md5($content) . sha1($content)] = [ + ':type' => 'inline', + ':priority' => (int) $priority, + 'content' => $content, + 'type' => $type, + 'element' => $element + ]; + + return true; + } + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addScript($element, $priority = 0, $location = 'head') + { + if (!is_array($element)) { + $element = ['src' => (string) $element]; + } + if (empty($element['src'])) { + return false; + } + if (!isset($this->scripts[$location])) { + $this->scripts[$location] = []; + } + + $src = $element['src']; + $type = !empty($element['type']) ? (string) $element['type'] : 'text/javascript'; + $loading = !empty($element['loading']) ? (string) $element['loading'] : null; + $defer = !empty($element['defer']); + $async = !empty($element['async']); + $handle = !empty($element['handle']) ? (string) $element['handle'] : ''; + + unset($element['src'], $element['type'], $element['loading'], $element['defer'], $element['async'], $element['handle']); + + $this->scripts[$location][md5($src) . sha1($src)] = [ + ':type' => 'file', + ':priority' => (int) $priority, + 'src' => $src, + 'type' => $type, + 'loading' => $loading, + 'defer' => $defer, + 'async' => $async, + 'handle' => $handle, + 'element' => $element + ]; + + return true; + } + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addInlineScript($element, $priority = 0, $location = 'head') + { + if (!is_array($element)) { + $element = ['content' => (string) $element]; + } + if (empty($element['content'])) { + return false; + } + if (!isset($this->scripts[$location])) { + $this->scripts[$location] = []; + } + + $content = (string) $element['content']; + $type = !empty($element['type']) ? (string) $element['type'] : 'text/javascript'; + $loading = !empty($element['loading']) ? (string) $element['loading'] : null; + + unset($element['content'], $element['type'], $element['loading']); + + $this->scripts[$location][md5($content) . sha1($content)] = [ + ':type' => 'inline', + ':priority' => (int) $priority, + 'content' => $content, + 'type' => $type, + 'loading' => $loading, + 'element' => $element + ]; + + return true; + } + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addModule($element, $priority = 0, $location = 'head') + { + if (!is_array($element)) { + $element = ['src' => (string) $element]; + } + + $element['type'] = 'module'; + + return $this->addScript($element, $priority, $location); + } + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addInlineModule($element, $priority = 0, $location = 'head') + { + if (!is_array($element)) { + $element = ['content' => (string) $element]; + } + + $element['type'] = 'module'; + + return $this->addInlineScript($element, $priority, $location); + } + + /** + * @param array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addLink($element, $priority = 0, $location = 'head') + { + if (!is_array($element) || empty($element['rel']) || empty($element['href'])) { + return false; + } + + if (!isset($this->links[$location])) { + $this->links[$location] = []; + } + + $rel = (string) $element['rel']; + $href = (string) $element['href']; + + unset($element['rel'], $element['href']); + + $this->links[$location][md5($href) . sha1($href)] = [ + ':type' => 'file', + ':priority' => (int) $priority, + 'href' => $href, + 'rel' => $rel, + 'element' => $element, + ]; + + return true; + } + + /** + * @param string $html + * @param int $priority + * @param string $location + * @return bool + */ + public function addHtml($html, $priority = 0, $location = 'bottom') + { + if (empty($html) || !is_string($html)) { + return false; + } + if (!isset($this->html[$location])) { + $this->html[$location] = []; + } + + $this->html[$location][md5($html) . sha1($html)] = [ + ':priority' => (int) $priority, + 'html' => $html + ]; + + return true; + } + + /** + * @return array + */ + protected function getAssetsFast() + { + $assets = [ + 'frameworks' => $this->frameworks, + 'styles' => $this->styles, + 'scripts' => $this->scripts, + 'links' => $this->links, + 'html' => $this->html + ]; + + foreach ($this->blocks as $block) { + if ($block instanceof self) { + $blockAssets = $block->getAssetsFast(); + $assets['frameworks'] += $blockAssets['frameworks']; + + foreach ($blockAssets['styles'] as $location => $styles) { + if (!isset($assets['styles'][$location])) { + $assets['styles'][$location] = $styles; + } elseif ($styles) { + $assets['styles'][$location] += $styles; + } + } + + foreach ($blockAssets['scripts'] as $location => $scripts) { + if (!isset($assets['scripts'][$location])) { + $assets['scripts'][$location] = $scripts; + } elseif ($scripts) { + $assets['scripts'][$location] += $scripts; + } + } + + foreach ($blockAssets['links'] as $location => $links) { + if (!isset($assets['links'][$location])) { + $assets['links'][$location] = $links; + } elseif ($links) { + $assets['links'][$location] += $links; + } + } + + foreach ($blockAssets['html'] as $location => $htmls) { + if (!isset($assets['html'][$location])) { + $assets['html'][$location] = $htmls; + } elseif ($htmls) { + $assets['html'][$location] += $htmls; + } + } + } + } + + return $assets; + } + + /** + * @param string $type + * @param string $location + * @return array + */ + protected function getAssetsInLocation($type, $location) + { + $assets = $this->getAssetsFast(); + + if (empty($assets[$type][$location])) { + return []; + } + + $styles = $assets[$type][$location]; + $this->sortAssetsInLocation($styles); + + return $styles; + } + + /** + * @param array $items + * @return void + */ + protected function sortAssetsInLocation(array &$items) + { + $count = 0; + foreach ($items as &$item) { + $item[':order'] = ++$count; + } + unset($item); + + uasort( + $items, + static function ($a, $b) { + return $a[':priority'] <=> $b[':priority'] ?: $a[':order'] <=> $b[':order']; + } + ); + } + + /** + * @param array $array + * @return void + */ + protected function sortAssets(array &$array) + { + foreach ($array as &$items) { + $this->sortAssetsInLocation($items); + } + } +} diff --git a/system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php b/system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php new file mode 100644 index 0000000..833c140 --- /dev/null +++ b/system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php @@ -0,0 +1,130 @@ +addStyle('assets/js/my.js'); + * @example $block->addStyle(['href' => 'assets/js/my.js', 'media' => 'screen']); + */ + public function addStyle($element, $priority = 0, $location = 'head'); + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addInlineStyle($element, $priority = 0, $location = 'head'); + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addScript($element, $priority = 0, $location = 'head'); + + /** + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addInlineScript($element, $priority = 0, $location = 'head'); + + + /** + * Shortcut for writing addScript(['type' => 'module', 'src' => ...]). + * + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addModule($element, $priority = 0, $location = 'head'); + + /** + * Shortcut for writing addInlineScript(['type' => 'module', 'content' => ...]). + * + * @param string|array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addInlineModule($element, $priority = 0, $location = 'head'); + + /** + * @param array $element + * @param int $priority + * @param string $location + * @return bool + */ + public function addLink($element, $priority = 0, $location = 'head'); + + /** + * @param string $html + * @param int $priority + * @param string $location + * @return bool + */ + public function addHtml($html, $priority = 0, $location = 'bottom'); +} diff --git a/system/src/Grav/Framework/Contracts/Media/MediaObjectInterface.php b/system/src/Grav/Framework/Contracts/Media/MediaObjectInterface.php new file mode 100644 index 0000000..75b80f0 --- /dev/null +++ b/system/src/Grav/Framework/Contracts/Media/MediaObjectInterface.php @@ -0,0 +1,52 @@ +|ArrayAccess + * @phpstan-pure + */ + public function getIdentifierMeta(); +} diff --git a/system/src/Grav/Framework/Contracts/Relationships/RelationshipInterface.php b/system/src/Grav/Framework/Contracts/Relationships/RelationshipInterface.php new file mode 100644 index 0000000..c0a7edf --- /dev/null +++ b/system/src/Grav/Framework/Contracts/Relationships/RelationshipInterface.php @@ -0,0 +1,81 @@ + + */ +interface RelationshipInterface extends Countable, IteratorAggregate, JsonSerializable, Serializable +{ + /** + * @return string + * @phpstan-pure + */ + public function getName(): string; + + /** + * @return string + * @phpstan-pure + */ + public function getType(): string; + + /** + * @return bool + * @phpstan-pure + */ + public function isModified(): bool; + + /** + * @return string + * @phpstan-pure + */ + public function getCardinality(): string; + + /** + * @return P + * @phpstan-pure + */ + public function getParent(): IdentifierInterface; + + /** + * @param string $id + * @param string|null $type + * @return bool + * @phpstan-pure + */ + public function has(string $id, string $type = null): bool; + + /** + * @param T $identifier + * @return bool + * @phpstan-pure + */ + public function hasIdentifier(IdentifierInterface $identifier): bool; + + /** + * @param T $identifier + * @return bool + */ + public function addIdentifier(IdentifierInterface $identifier): bool; + + /** + * @param T|null $identifier + * @return bool + */ + public function removeIdentifier(IdentifierInterface $identifier = null): bool; + + /** + * @return iterable + */ + public function getIterator(): iterable; +} diff --git a/system/src/Grav/Framework/Contracts/Relationships/RelationshipsInterface.php b/system/src/Grav/Framework/Contracts/Relationships/RelationshipsInterface.php new file mode 100644 index 0000000..4bd90a3 --- /dev/null +++ b/system/src/Grav/Framework/Contracts/Relationships/RelationshipsInterface.php @@ -0,0 +1,53 @@ +> + * @extends Iterator> + */ +interface RelationshipsInterface extends Countable, ArrayAccess, Iterator, JsonSerializable +{ + /** + * @return bool + * @phpstan-pure + */ + public function isModified(): bool; + + /** + * @return array + */ + public function getModified(): array; + + /** + * @return int + * @phpstan-pure + */ + public function count(): int; + + /** + * @param string $offset + * @return RelationshipInterface|null + */ + public function offsetGet($offset): ?RelationshipInterface; + + /** + * @return RelationshipInterface|null + */ + public function current(): ?RelationshipInterface; + + /** + * @return string + * @phpstan-pure + */ + public function key(): string; +} diff --git a/system/src/Grav/Framework/Contracts/Relationships/ToManyRelationshipInterface.php b/system/src/Grav/Framework/Contracts/Relationships/ToManyRelationshipInterface.php new file mode 100644 index 0000000..723bef6 --- /dev/null +++ b/system/src/Grav/Framework/Contracts/Relationships/ToManyRelationshipInterface.php @@ -0,0 +1,55 @@ + + */ +interface ToManyRelationshipInterface extends RelationshipInterface +{ + /** + * @param positive-int $pos + * @return IdentifierInterface|null + */ + public function getNthIdentifier(int $pos): ?IdentifierInterface; + + /** + * @param string $id + * @param string|null $type + * @return T|null + * @phpstan-pure + */ + public function getIdentifier(string $id, string $type = null): ?IdentifierInterface; + + /** + * @param string $id + * @param string|null $type + * @return T|null + * @phpstan-pure + */ + public function getObject(string $id, string $type = null): ?object; + + /** + * @param iterable $identifiers + * @return bool + */ + public function addIdentifiers(iterable $identifiers): bool; + + /** + * @param iterable $identifiers + * @return bool + */ + public function replaceIdentifiers(iterable $identifiers): bool; + + /** + * @param iterable $identifiers + * @return bool + */ + public function removeIdentifiers(iterable $identifiers): bool; +} diff --git a/system/src/Grav/Framework/Contracts/Relationships/ToOneRelationshipInterface.php b/system/src/Grav/Framework/Contracts/Relationships/ToOneRelationshipInterface.php new file mode 100644 index 0000000..0e6aeb9 --- /dev/null +++ b/system/src/Grav/Framework/Contracts/Relationships/ToOneRelationshipInterface.php @@ -0,0 +1,37 @@ + + */ +interface ToOneRelationshipInterface extends RelationshipInterface +{ + /** + * @param string|null $id + * @param string|null $type + * @return T|null + * @phpstan-pure + */ + public function getIdentifier(string $id = null, string $type = null): ?IdentifierInterface; + + /** + * @param string|null $id + * @param string|null $type + * @return T|null + * @phpstan-pure + */ + public function getObject(string $id = null, string $type = null): ?object; + + /** + * @param T|null $identifier + * @return bool + */ + public function replaceIdentifier(IdentifierInterface $identifier = null): bool; +} diff --git a/system/src/Grav/Framework/Controller/Traits/ControllerResponseTrait.php b/system/src/Grav/Framework/Controller/Traits/ControllerResponseTrait.php new file mode 100644 index 0000000..02d8eab --- /dev/null +++ b/system/src/Grav/Framework/Controller/Traits/ControllerResponseTrait.php @@ -0,0 +1,307 @@ + 599) { + $code = 500; + } + $headers = $headers ?? []; + + return new Response($code, $headers, $content); + } + + /** + * @param array $content + * @param int|null $code + * @param array|null $headers + * @return Response + */ + protected function createJsonResponse(array $content, int $code = null, array $headers = null): ResponseInterface + { + $code = $code ?? $content['code'] ?? 200; + if (null === $code || $code < 100 || $code > 599) { + $code = 200; + } + $headers = ($headers ?? []) + [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-store, max-age=0' + ]; + + return new Response($code, $headers, json_encode($content)); + } + + /** + * @param string $filename + * @param string|resource|StreamInterface $resource + * @param array|null $headers + * @param array|null $options + * @return ResponseInterface + */ + protected function createDownloadResponse(string $filename, $resource, array $headers = null, array $options = null): ResponseInterface + { + // Required for IE, otherwise Content-Disposition may be ignored + if (ini_get('zlib.output_compression')) { + @ini_set('zlib.output_compression', 'Off'); + } + + $headers = $headers ?? []; + $options = $options ?? ['force_download' => true]; + + $file_parts = Utils::pathinfo($filename); + + if (!isset($headers['Content-Type'])) { + $mimetype = Utils::getMimeByExtension($file_parts['extension']); + + $headers['Content-Type'] = $mimetype; + } + + // TODO: add multipart download support. + //$headers['Accept-Ranges'] = 'bytes'; + + if (!empty($options['force_download'])) { + $headers['Content-Disposition'] = 'attachment; filename="' . $file_parts['basename'] . '"'; + } + + if (!isset($headers['Content-Length'])) { + $realpath = realpath($filename); + if ($realpath) { + $headers['Content-Length'] = filesize($realpath); + } + } + + $headers += [ + 'Expires' => 'Mon, 26 Jul 1997 05:00:00 GMT', + 'Last-Modified' => gmdate('D, d M Y H:i:s') . ' GMT', + 'Cache-Control' => 'no-store, no-cache, must-revalidate', + 'Pragma' => 'no-cache' + ]; + + return new Response(200, $headers, $resource); + } + + /** + * @param string $url + * @param int|null $code + * @return Response + */ + protected function createRedirectResponse(string $url, int $code = null): ResponseInterface + { + if (null === $code || $code < 301 || $code > 307) { + $code = (int)$this->getConfig()->get('system.pages.redirect_default_code', 302); + } + + $ext = Utils::pathinfo($url, PATHINFO_EXTENSION); + $accept = $this->getAccept(['application/json', 'text/html']); + if ($ext === 'json' || $accept === 'application/json') { + return $this->createJsonResponse(['code' => $code, 'status' => 'redirect', 'redirect' => $url]); + } + + return new Response($code, ['Location' => $url]); + } + + /** + * @param Throwable $e + * @return ResponseInterface + */ + protected function createErrorResponse(Throwable $e): ResponseInterface + { + $response = $this->getErrorJson($e); + $message = $response['message']; + $code = $response['code']; + $reason = $e instanceof RequestException ? $e->getHttpReason() : null; + $accept = $this->getAccept(['application/json', 'text/html']); + + $request = $this->getRequest(); + $context = $request->getAttributes(); + + /** @var Route $route */ + $route = $context['route'] ?? null; + + $ext = $route ? $route->getExtension() : null; + if ($ext !== 'json' && $accept === 'text/html') { + $method = $request->getMethod(); + + // On POST etc, redirect back to the previous page. + if ($method !== 'GET' && $method !== 'HEAD') { + $this->setMessage($message, 'error'); + $referer = $request->getHeaderLine('Referer'); + + return $this->createRedirectResponse($referer, 303); + } + + // TODO: improve error page + return $this->createHtmlResponse($response['message'], $code); + } + + return new Response($code, ['Content-Type' => 'application/json'], json_encode($response), '1.1', $reason); + } + + /** + * @param Throwable $e + * @return ResponseInterface + */ + protected function createJsonErrorResponse(Throwable $e): ResponseInterface + { + $response = $this->getErrorJson($e); + $reason = $e instanceof RequestException ? $e->getHttpReason() : null; + + return new Response($response['code'], ['Content-Type' => 'application/json'], json_encode($response), '1.1', $reason); + } + + /** + * @param Throwable $e + * @return array + */ + protected function getErrorJson(Throwable $e): array + { + $code = $this->getErrorCode($e instanceof RequestException ? $e->getHttpCode() : $e->getCode()); + if ($e instanceof ValidationException) { + $message = $e->getMessage(); + } else { + $message = htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + $extra = $e instanceof JsonSerializable ? $e->jsonSerialize() : []; + + $response = [ + 'code' => $code, + 'status' => 'error', + 'message' => $message, + 'redirect' => null, + 'error' => [ + 'code' => $code, + 'message' => $message + ] + $extra + ]; + + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + if ($debugger->enabled()) { + $response['error'] += [ + 'type' => get_class($e), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => explode("\n", $e->getTraceAsString()) + ]; + } + + return $response; + } + + /** + * @param int $code + * @return int + */ + protected function getErrorCode(int $code): int + { + static $errorCodes = [ + 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, + 422, 423, 424, 425, 426, 428, 429, 431, 451, 500, 501, 502, 503, 504, 505, 506, 507, 508, 511 + ]; + + if (!in_array($code, $errorCodes, true)) { + $code = 500; + } + + return $code; + } + + /** + * @param array $compare + * @return mixed + */ + protected function getAccept(array $compare) + { + $accepted = []; + foreach ($this->getRequest()->getHeader('Accept') as $accept) { + foreach (explode(',', $accept) as $item) { + if (!$item) { + continue; + } + + $split = explode(';q=', $item); + $mime = array_shift($split); + $priority = array_shift($split) ?? 1.0; + + $accepted[$mime] = $priority; + } + } + + arsort($accepted); + + // TODO: add support for image/* etc + $list = array_intersect($compare, array_keys($accepted)); + if (!$list && (isset($accepted['*/*']) || isset($accepted['*']))) { + return reset($compare); + } + + return reset($list); + } + + /** + * @return ServerRequestInterface + */ + abstract protected function getRequest(): ServerRequestInterface; + + /** + * @param string $message + * @param string $type + * @return $this + */ + abstract protected function setMessage(string $message, string $type = 'info'); + + /** + * @return Config + */ + abstract protected function getConfig(): Config; +} diff --git a/system/src/Grav/Framework/DI/Container.php b/system/src/Grav/Framework/DI/Container.php new file mode 100644 index 0000000..a4aefa4 --- /dev/null +++ b/system/src/Grav/Framework/DI/Container.php @@ -0,0 +1,35 @@ +offsetGet($id); + } + + /** + * @param string $id + * @return bool + */ + public function has($id): bool + { + return $this->offsetExists($id); + } +} diff --git a/system/src/Grav/Framework/File/AbstractFile.php b/system/src/Grav/Framework/File/AbstractFile.php new file mode 100644 index 0000000..b4d3840 --- /dev/null +++ b/system/src/Grav/Framework/File/AbstractFile.php @@ -0,0 +1,444 @@ +filesystem = $filesystem ?? Filesystem::getInstance(); + $this->setFilepath($filepath); + } + + /** + * Unlock file when the object gets destroyed. + */ + #[\ReturnTypeWillChange] + public function __destruct() + { + if ($this->isLocked()) { + $this->unlock(); + } + } + + /** + * @return void + */ + #[\ReturnTypeWillChange] + public function __clone() + { + $this->handle = null; + $this->locked = false; + } + + /** + * @return array + */ + final public function __serialize(): array + { + return ['filesystem_normalize' => $this->filesystem->getNormalization()] + $this->doSerialize(); + } + + /** + * @param array $data + * @return void + */ + final public function __unserialize(array $data): void + { + $this->filesystem = Filesystem::getInstance($data['filesystem_normalize'] ?? null); + + $this->doUnserialize($data); + } + + /** + * {@inheritdoc} + * @see FileInterface::getFilePath() + */ + public function getFilePath(): string + { + return $this->filepath; + } + + /** + * {@inheritdoc} + * @see FileInterface::getPath() + */ + public function getPath(): string + { + if (null === $this->path) { + $this->setPathInfo(); + } + + return $this->path ?? ''; + } + + /** + * {@inheritdoc} + * @see FileInterface::getFilename() + */ + public function getFilename(): string + { + if (null === $this->filename) { + $this->setPathInfo(); + } + + return $this->filename ?? ''; + } + + /** + * {@inheritdoc} + * @see FileInterface::getBasename() + */ + public function getBasename(): string + { + if (null === $this->basename) { + $this->setPathInfo(); + } + + return $this->basename ?? ''; + } + + /** + * {@inheritdoc} + * @see FileInterface::getExtension() + */ + public function getExtension(bool $withDot = false): string + { + if (null === $this->extension) { + $this->setPathInfo(); + } + + return ($withDot ? '.' : '') . $this->extension; + } + + /** + * {@inheritdoc} + * @see FileInterface::exists() + */ + public function exists(): bool + { + return is_file($this->filepath); + } + + /** + * {@inheritdoc} + * @see FileInterface::getCreationTime() + */ + public function getCreationTime(): int + { + return is_file($this->filepath) ? (int)filectime($this->filepath) : time(); + } + + /** + * {@inheritdoc} + * @see FileInterface::getModificationTime() + */ + public function getModificationTime(): int + { + return is_file($this->filepath) ? (int)filemtime($this->filepath) : time(); + } + + /** + * {@inheritdoc} + * @see FileInterface::lock() + */ + public function lock(bool $block = true): bool + { + if (!$this->handle) { + if (!$this->mkdir($this->getPath())) { + throw new RuntimeException('Creating directory failed for ' . $this->filepath); + } + $this->handle = @fopen($this->filepath, 'cb+') ?: null; + if (!$this->handle) { + $error = error_get_last(); + $message = $error['message'] ?? 'Unknown error'; + + throw new RuntimeException("Opening file for writing failed on error {$message}"); + } + } + + $lock = $block ? LOCK_EX : LOCK_EX | LOCK_NB; + + // Some filesystems do not support file locks, only fail if another process holds the lock. + $this->locked = flock($this->handle, $lock, $wouldBlock) || !$wouldBlock; + + return $this->locked; + } + + /** + * {@inheritdoc} + * @see FileInterface::unlock() + */ + public function unlock(): bool + { + if (!$this->handle) { + return false; + } + + if ($this->locked) { + flock($this->handle, LOCK_UN | LOCK_NB); + $this->locked = false; + } + + fclose($this->handle); + $this->handle = null; + + return true; + } + + /** + * {@inheritdoc} + * @see FileInterface::isLocked() + */ + public function isLocked(): bool + { + return $this->locked; + } + + /** + * {@inheritdoc} + * @see FileInterface::isReadable() + */ + public function isReadable(): bool + { + return is_readable($this->filepath) && is_file($this->filepath); + } + + /** + * {@inheritdoc} + * @see FileInterface::isWritable() + */ + public function isWritable(): bool + { + if (!file_exists($this->filepath)) { + return $this->isWritablePath($this->getPath()); + } + + return is_writable($this->filepath) && is_file($this->filepath); + } + + /** + * {@inheritdoc} + * @see FileInterface::load() + */ + public function load() + { + return file_get_contents($this->filepath); + } + + /** + * {@inheritdoc} + * @see FileInterface::save() + */ + public function save($data): void + { + $filepath = $this->filepath; + $dir = $this->getPath(); + + if (!$this->mkdir($dir)) { + throw new RuntimeException('Creating directory failed for ' . $filepath); + } + + try { + if ($this->handle) { + $tmp = true; + // As we are using non-truncating locking, make sure that the file is empty before writing. + if (@ftruncate($this->handle, 0) === false || @fwrite($this->handle, $data) === false) { + // Writing file failed, throw an error. + $tmp = false; + } + } else { + // Support for symlinks. + $realpath = is_link($filepath) ? realpath($filepath) : $filepath; + if ($realpath === false) { + throw new RuntimeException('Failed to save file ' . $filepath); + } + + // Create file with a temporary name and rename it to make the save action atomic. + $tmp = $this->tempname($realpath); + if (@file_put_contents($tmp, $data) === false) { + $tmp = false; + } elseif (@rename($tmp, $realpath) === false) { + @unlink($tmp); + $tmp = false; + } + } + } catch (Exception $e) { + $tmp = false; + } + + if ($tmp === false) { + throw new RuntimeException('Failed to save file ' . $filepath); + } + + // Touch the directory as well, thus marking it modified. + @touch($dir); + } + + /** + * {@inheritdoc} + * @see FileInterface::rename() + */ + public function rename(string $path): bool + { + if ($this->exists() && !@rename($this->filepath, $path)) { + return false; + } + + $this->setFilepath($path); + + return true; + } + + /** + * {@inheritdoc} + * @see FileInterface::delete() + */ + public function delete(): bool + { + return @unlink($this->filepath); + } + + /** + * @param string $dir + * @return bool + * @throws RuntimeException + * @internal + */ + protected function mkdir(string $dir): bool + { + // Silence error for open_basedir; should fail in mkdir instead. + if (@is_dir($dir)) { + return true; + } + + $success = @mkdir($dir, 0777, true); + + if (!$success) { + // Take yet another look, make sure that the folder doesn't exist. + clearstatcache(true, $dir); + if (!@is_dir($dir)) { + return false; + } + } + + return true; + } + + /** + * @return array + */ + protected function doSerialize(): array + { + return [ + 'filepath' => $this->filepath + ]; + } + + /** + * @param array $serialized + * @return void + */ + protected function doUnserialize(array $serialized): void + { + $this->setFilepath($serialized['filepath']); + } + + /** + * @param string $filepath + */ + protected function setFilepath(string $filepath): void + { + $this->filepath = $filepath; + $this->filename = null; + $this->basename = null; + $this->path = null; + $this->extension = null; + } + + protected function setPathInfo(): void + { + /** @var array $pathInfo */ + $pathInfo = $this->filesystem->pathinfo($this->filepath); + + $this->filename = $pathInfo['filename'] ?? null; + $this->basename = $pathInfo['basename'] ?? null; + $this->path = $pathInfo['dirname'] ?? null; + $this->extension = $pathInfo['extension'] ?? null; + } + + /** + * @param string $dir + * @return bool + * @internal + */ + protected function isWritablePath(string $dir): bool + { + if ($dir === '') { + return false; + } + + if (!file_exists($dir)) { + // Recursively look up in the directory tree. + return $this->isWritablePath($this->filesystem->parent($dir)); + } + + return is_dir($dir) && is_writable($dir); + } + + /** + * @param string $filename + * @param int $length + * @return string + */ + protected function tempname(string $filename, int $length = 5) + { + do { + $test = $filename . substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, $length); + } while (file_exists($test)); + + return $test; + } +} diff --git a/system/src/Grav/Framework/File/CsvFile.php b/system/src/Grav/Framework/File/CsvFile.php new file mode 100644 index 0000000..4f41f72 --- /dev/null +++ b/system/src/Grav/Framework/File/CsvFile.php @@ -0,0 +1,40 @@ +formatter = $formatter; + } + + /** + * {@inheritdoc} + * @see FileInterface::load() + */ + public function load() + { + $raw = parent::load(); + + try { + if (!is_string($raw)) { + throw new RuntimeException('Bad Data'); + } + + return $this->formatter->decode($raw); + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf("Failed to load file '%s': %s", $this->getFilePath(), $e->getMessage()), $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + * @see FileInterface::save() + */ + public function save($data): void + { + if (is_string($data)) { + // Make sure that the string is valid data. + try { + $this->formatter->decode($data); + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf("Failed to save file '%s': %s", $this->getFilePath(), $e->getMessage()), $e->getCode(), $e); + } + $encoded = $data; + } else { + $encoded = $this->formatter->encode($data); + } + + parent::save($encoded); + } +} diff --git a/system/src/Grav/Framework/File/File.php b/system/src/Grav/Framework/File/File.php new file mode 100644 index 0000000..968a43c --- /dev/null +++ b/system/src/Grav/Framework/File/File.php @@ -0,0 +1,35 @@ +config = $config; + } + + /** + * @return string + */ + public function getMimeType(): string + { + $mime = $this->getConfig('mime'); + + return is_string($mime) ? $mime : 'application/octet-stream'; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::getDefaultFileExtension() + */ + public function getDefaultFileExtension(): string + { + $extensions = $this->getSupportedFileExtensions(); + + // Call fails on bad configuration. + return reset($extensions) ?: ''; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::getSupportedFileExtensions() + */ + public function getSupportedFileExtensions(): array + { + $extensions = $this->getConfig('file_extension'); + + // Call fails on bad configuration. + return is_string($extensions) ? [$extensions] : $extensions; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::encode() + */ + abstract public function encode($data): string; + + /** + * {@inheritdoc} + * @see FileFormatterInterface::decode() + */ + abstract public function decode($data); + + + /** + * @return array + */ + public function __serialize(): array + { + return ['config' => $this->config]; + } + + /** + * @param array $data + * @return void + */ + public function __unserialize(array $data): void + { + $this->config = $data['config']; + } + + /** + * Get either full configuration or a single option. + * + * @param string|null $name Configuration option (optional) + * @return mixed + */ + protected function getConfig(string $name = null) + { + if (null !== $name) { + return $this->config[$name] ?? null; + } + + return $this->config; + } +} diff --git a/system/src/Grav/Framework/File/Formatter/CsvFormatter.php b/system/src/Grav/Framework/File/Formatter/CsvFormatter.php new file mode 100644 index 0000000..914b8f8 --- /dev/null +++ b/system/src/Grav/Framework/File/Formatter/CsvFormatter.php @@ -0,0 +1,170 @@ + ['.csv', '.tsv'], + 'delimiter' => ',', + 'mime' => 'text/x-csv' + ]; + + parent::__construct($config); + } + + /** + * Returns delimiter used to both encode and decode CSV. + * + * @return string + */ + public function getDelimiter(): string + { + // Call fails on bad configuration. + return $this->getConfig('delimiter'); + } + + /** + * @param array $data + * @param string|null $delimiter + * @return string + * @see FileFormatterInterface::encode() + */ + public function encode($data, $delimiter = null): string + { + if (count($data) === 0) { + return ''; + } + $delimiter = $delimiter ?? $this->getDelimiter(); + $header = array_keys(reset($data)); + + // Encode the field names + $string = $this->encodeLine($header, $delimiter); + + // Encode the data + foreach ($data as $row) { + $string .= $this->encodeLine($row, $delimiter); + } + + return $string; + } + + /** + * @param string $data + * @param string|null $delimiter + * @return array + * @see FileFormatterInterface::decode() + */ + public function decode($data, $delimiter = null): array + { + $delimiter = $delimiter ?? $this->getDelimiter(); + $lines = preg_split('/\r\n|\r|\n/', $data); + if ($lines === false) { + throw new RuntimeException('Decoding CSV failed'); + } + + // Get the field names + $headerStr = array_shift($lines); + if (!$headerStr) { + throw new RuntimeException('CSV header missing'); + } + + $header = str_getcsv($headerStr, $delimiter); + + // Allow for replacing a null string with null/empty value + $null_replace = $this->getConfig('null'); + + // Get the data + $list = []; + $line = null; + try { + foreach ($lines as $line) { + if (!empty($line)) { + $csv_line = str_getcsv($line, $delimiter); + + if ($null_replace) { + array_walk($csv_line, static function (&$el) use ($null_replace) { + $el = str_replace($null_replace, "\0", $el); + }); + } + + $list[] = array_combine($header, $csv_line); + } + } + } catch (Exception $e) { + throw new RuntimeException('Badly formatted CSV line: ' . $line); + } + + return $list; + } + + /** + * @param array $line + * @param string $delimiter + * @return string + */ + protected function encodeLine(array $line, string $delimiter): string + { + foreach ($line as $key => &$value) { + // Oops, we need to convert the line to a string. + if (!is_scalar($value)) { + if (is_array($value) || $value instanceof JsonSerializable || $value instanceof stdClass) { + $value = json_encode($value); + } elseif (is_object($value)) { + if (method_exists($value, 'toJson')) { + $value = $value->toJson(); + } elseif (method_exists($value, 'toArray')) { + $value = json_encode($value->toArray()); + } + } + } + + $value = $this->escape((string)$value); + } + unset($value); + + return implode($delimiter, $line). "\n"; + } + + /** + * @param string $value + * @return string + */ + protected function escape(string $value) + { + if (preg_match('/[,"\r\n]/u', $value)) { + $value = '"' . preg_replace('/"/', '""', $value) . '"'; + } + + return $value; + } +} diff --git a/system/src/Grav/Framework/File/Formatter/FormatterInterface.php b/system/src/Grav/Framework/File/Formatter/FormatterInterface.php new file mode 100644 index 0000000..757e229 --- /dev/null +++ b/system/src/Grav/Framework/File/Formatter/FormatterInterface.php @@ -0,0 +1,12 @@ + '.ini' + ]; + + parent::__construct($config); + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::encode() + */ + public function encode($data): string + { + $string = ''; + foreach ($data as $key => $value) { + $string .= $key . '="' . preg_replace( + ['/"/', '/\\\/', "/\t/", "/\n/", "/\r/"], + ['\"', '\\\\', '\t', '\n', '\r'], + $value + ) . "\"\n"; + } + + return $string; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::decode() + */ + public function decode($data): array + { + $decoded = @parse_ini_string($data); + + if ($decoded === false) { + throw new RuntimeException('Decoding INI failed'); + } + + return $decoded; + } +} diff --git a/system/src/Grav/Framework/File/Formatter/JsonFormatter.php b/system/src/Grav/Framework/File/Formatter/JsonFormatter.php new file mode 100644 index 0000000..9c9b8a0 --- /dev/null +++ b/system/src/Grav/Framework/File/Formatter/JsonFormatter.php @@ -0,0 +1,170 @@ + JSON_FORCE_OBJECT, + 'JSON_HEX_QUOT' => JSON_HEX_QUOT, + 'JSON_HEX_TAG' => JSON_HEX_TAG, + 'JSON_HEX_AMP' => JSON_HEX_AMP, + 'JSON_HEX_APOS' => JSON_HEX_APOS, + 'JSON_INVALID_UTF8_IGNORE' => JSON_INVALID_UTF8_IGNORE, + 'JSON_INVALID_UTF8_SUBSTITUTE' => JSON_INVALID_UTF8_SUBSTITUTE, + 'JSON_NUMERIC_CHECK' => JSON_NUMERIC_CHECK, + 'JSON_PARTIAL_OUTPUT_ON_ERROR' => JSON_PARTIAL_OUTPUT_ON_ERROR, + 'JSON_PRESERVE_ZERO_FRACTION' => JSON_PRESERVE_ZERO_FRACTION, + 'JSON_PRETTY_PRINT' => JSON_PRETTY_PRINT, + 'JSON_UNESCAPED_LINE_TERMINATORS' => JSON_UNESCAPED_LINE_TERMINATORS, + 'JSON_UNESCAPED_SLASHES' => JSON_UNESCAPED_SLASHES, + 'JSON_UNESCAPED_UNICODE' => JSON_UNESCAPED_UNICODE, + //'JSON_THROW_ON_ERROR' => JSON_THROW_ON_ERROR // PHP 7.3 + ]; + + /** @var array */ + protected $decodeOptions = [ + 'JSON_BIGINT_AS_STRING' => JSON_BIGINT_AS_STRING, + 'JSON_INVALID_UTF8_IGNORE' => JSON_INVALID_UTF8_IGNORE, + 'JSON_INVALID_UTF8_SUBSTITUTE' => JSON_INVALID_UTF8_SUBSTITUTE, + 'JSON_OBJECT_AS_ARRAY' => JSON_OBJECT_AS_ARRAY, + //'JSON_THROW_ON_ERROR' => JSON_THROW_ON_ERROR // PHP 7.3 + ]; + + public function __construct(array $config = []) + { + $config += [ + 'file_extension' => '.json', + 'encode_options' => 0, + 'decode_assoc' => true, + 'decode_depth' => 512, + 'decode_options' => 0 + ]; + + parent::__construct($config); + } + + /** + * Returns options used in encode() function. + * + * @return int + */ + public function getEncodeOptions(): int + { + $options = $this->getConfig('encode_options'); + if (!is_int($options)) { + if (is_string($options)) { + $list = preg_split('/[\s,|]+/', $options); + $options = 0; + if ($list) { + foreach ($list as $option) { + if (isset($this->encodeOptions[$option])) { + $options += $this->encodeOptions[$option]; + } + } + } + } else { + $options = 0; + } + } + + return $options; + } + + /** + * Returns options used in decode() function. + * + * @return int + */ + public function getDecodeOptions(): int + { + $options = $this->getConfig('decode_options'); + if (!is_int($options)) { + if (is_string($options)) { + $list = preg_split('/[\s,|]+/', $options); + $options = 0; + if ($list) { + foreach ($list as $option) { + if (isset($this->decodeOptions[$option])) { + $options += $this->decodeOptions[$option]; + } + } + } + } else { + $options = 0; + } + } + + return $options; + } + + /** + * Returns recursion depth used in decode() function. + * + * @return int + * @phpstan-return positive-int + */ + public function getDecodeDepth(): int + { + return $this->getConfig('decode_depth'); + } + + /** + * Returns true if JSON objects will be converted into associative arrays. + * + * @return bool + */ + public function getDecodeAssoc(): bool + { + return $this->getConfig('decode_assoc'); + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::encode() + */ + public function encode($data): string + { + $encoded = @json_encode($data, $this->getEncodeOptions()); + + if ($encoded === false && json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException('Encoding JSON failed: ' . json_last_error_msg()); + } + + return $encoded ?: ''; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::decode() + */ + public function decode($data) + { + $decoded = @json_decode($data, $this->getDecodeAssoc(), $this->getDecodeDepth(), $this->getDecodeOptions()); + + if (null === $decoded && json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException('Decoding JSON failed: ' . json_last_error_msg()); + } + + return $decoded; + } +} diff --git a/system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php b/system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php new file mode 100644 index 0000000..e12b71f --- /dev/null +++ b/system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php @@ -0,0 +1,161 @@ + '.md', + 'header' => 'header', + 'body' => 'markdown', + 'raw' => 'frontmatter', + 'yaml' => ['inline' => 20] + ]; + + parent::__construct($config); + + $this->headerFormatter = $headerFormatter ?? new YamlFormatter($config['yaml']); + } + + /** + * Returns header field used in both encode() and decode(). + * + * @return string + */ + public function getHeaderField(): string + { + return $this->getConfig('header'); + } + + /** + * Returns body field used in both encode() and decode(). + * + * @return string + */ + public function getBodyField(): string + { + return $this->getConfig('body'); + } + + /** + * Returns raw field used in both encode() and decode(). + * + * @return string + */ + public function getRawField(): string + { + return $this->getConfig('raw'); + } + + /** + * Returns header formatter object used in both encode() and decode(). + * + * @return FileFormatterInterface + */ + public function getHeaderFormatter(): FileFormatterInterface + { + return $this->headerFormatter; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::encode() + */ + public function encode($data): string + { + $headerVar = $this->getHeaderField(); + $bodyVar = $this->getBodyField(); + + $header = isset($data[$headerVar]) ? (array) $data[$headerVar] : []; + $body = isset($data[$bodyVar]) ? (string) $data[$bodyVar] : ''; + + // Create Markdown file with YAML header. + $encoded = ''; + if ($header) { + $encoded = "---\n" . trim($this->getHeaderFormatter()->encode($data['header'])) . "\n---\n\n"; + } + $encoded .= $body; + + // Normalize line endings to Unix style. + $encoded = preg_replace("/(\r\n|\r)/u", "\n", $encoded); + if (null === $encoded) { + throw new RuntimeException('Encoding markdown failed'); + } + + return $encoded; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::decode() + */ + public function decode($data): array + { + $headerVar = $this->getHeaderField(); + $bodyVar = $this->getBodyField(); + $rawVar = $this->getRawField(); + + // Define empty content + $content = [ + $headerVar => [], + $bodyVar => '' + ]; + + $headerRegex = "/^---\n(.+?)\n---\n{0,}(.*)$/uis"; + + // Normalize line endings to Unix style. + $data = preg_replace("/(\r\n|\r)/u", "\n", $data); + if (null === $data) { + throw new RuntimeException('Decoding markdown failed'); + } + + // Parse header. + preg_match($headerRegex, ltrim($data), $matches); + if (empty($matches)) { + $content[$bodyVar] = $data; + } else { + // Normalize frontmatter. + $frontmatter = preg_replace("/\n\t/", "\n ", $matches[1]); + if ($rawVar) { + $content[$rawVar] = $frontmatter; + } + $content[$headerVar] = $this->getHeaderFormatter()->decode($frontmatter); + $content[$bodyVar] = $matches[2]; + } + + return $content; + } + + public function __serialize(): array + { + return parent::__serialize() + ['headerFormatter' => $this->headerFormatter]; + } + + public function __unserialize(array $data): void + { + parent::__unserialize($data); + + $this->headerFormatter = $data['headerFormatter'] ?? new YamlFormatter(['inline' => 20]); + } +} diff --git a/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php b/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php new file mode 100644 index 0000000..76ff2b1 --- /dev/null +++ b/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php @@ -0,0 +1,98 @@ + '.ser', + 'decode_options' => ['allowed_classes' => [stdClass::class]] + ]; + + parent::__construct($config); + } + + /** + * Returns options used in decode(). + * + * By default only allow stdClass class. + * + * @return array + */ + public function getOptions() + { + return $this->getConfig('decode_options'); + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::encode() + */ + public function encode($data): string + { + return serialize($this->preserveLines($data, ["\n", "\r"], ['\\n', '\\r'])); + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::decode() + */ + public function decode($data) + { + $classes = $this->getOptions()['allowed_classes'] ?? false; + $decoded = @unserialize($data, ['allowed_classes' => $classes]); + + if ($decoded === false && $data !== serialize(false)) { + throw new RuntimeException('Decoding serialized data failed'); + } + + return $this->preserveLines($decoded, ['\\n', '\\r'], ["\n", "\r"]); + } + + /** + * Preserve new lines, recursive function. + * + * @param mixed $data + * @param array $search + * @param array $replace + * @return mixed + */ + protected function preserveLines($data, array $search, array $replace) + { + if (is_string($data)) { + $data = str_replace($search, $replace, $data); + } elseif (is_array($data)) { + foreach ($data as &$value) { + $value = $this->preserveLines($value, $search, $replace); + } + unset($value); + } + + return $data; + } +} diff --git a/system/src/Grav/Framework/File/Formatter/YamlFormatter.php b/system/src/Grav/Framework/File/Formatter/YamlFormatter.php new file mode 100644 index 0000000..08a6f72 --- /dev/null +++ b/system/src/Grav/Framework/File/Formatter/YamlFormatter.php @@ -0,0 +1,129 @@ + '.yaml', + 'inline' => 5, + 'indent' => 2, + 'native' => true, + 'compat' => true + ]; + + parent::__construct($config); + } + + /** + * @return int + */ + public function getInlineOption(): int + { + return $this->getConfig('inline'); + } + + /** + * @return int + */ + public function getIndentOption(): int + { + return $this->getConfig('indent'); + } + + /** + * @return bool + */ + public function useNativeDecoder(): bool + { + return $this->getConfig('native'); + } + + /** + * @return bool + */ + public function useCompatibleDecoder(): bool + { + return $this->getConfig('compat'); + } + + /** + * @param array $data + * @param int|null $inline + * @param int|null $indent + * @return string + * @see FileFormatterInterface::encode() + */ + public function encode($data, $inline = null, $indent = null): string + { + try { + return YamlParser::dump( + $data, + $inline ? (int) $inline : $this->getInlineOption(), + $indent ? (int) $indent : $this->getIndentOption(), + YamlParser::DUMP_EXCEPTION_ON_INVALID_TYPE + ); + } catch (DumpException $e) { + throw new RuntimeException('Encoding YAML failed: ' . $e->getMessage(), 0, $e); + } + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::decode() + */ + public function decode($data): array + { + // Try native PECL YAML PHP extension first if available. + if (function_exists('yaml_parse') && $this->useNativeDecoder()) { + // Safely decode YAML. + $saved = @ini_get('yaml.decode_php'); + @ini_set('yaml.decode_php', '0'); + $decoded = @yaml_parse($data); + if ($saved !== false) { + @ini_set('yaml.decode_php', $saved); + } + + if ($decoded !== false) { + return (array) $decoded; + } + } + + try { + return (array) YamlParser::parse($data); + } catch (ParseException $e) { + if ($this->useCompatibleDecoder()) { + return (array) FallbackYamlParser::parse($data); + } + + throw new RuntimeException('Decoding YAML failed: ' . $e->getMessage(), 0, $e); + } + } +} diff --git a/system/src/Grav/Framework/File/IniFile.php b/system/src/Grav/Framework/File/IniFile.php new file mode 100644 index 0000000..a50641c --- /dev/null +++ b/system/src/Grav/Framework/File/IniFile.php @@ -0,0 +1,40 @@ +setNormalization() + * @return Filesystem + */ + public static function getInstance(bool $normalize = null): Filesystem + { + if ($normalize === true) { + $instance = &static::$safe; + } elseif ($normalize === false) { + $instance = &static::$unsafe; + } else { + $instance = &static::$default; + } + + if (null === $instance) { + $instance = new static($normalize); + } + + return $instance; + } + + /** + * Always use Filesystem::getInstance() instead. + * + * @param bool|null $normalize + * @internal + */ + protected function __construct(bool $normalize = null) + { + $this->normalize = $normalize; + } + + /** + * Set path normalization. + * + * Default option enables normalization for the streams only, but you can force the normalization to be either + * on or off for every path. Disabling path normalization speeds up the calls, but may cause issues if paths were + * not normalized. + * + * @param bool|null $normalize + * @return Filesystem + */ + public function setNormalization(bool $normalize = null): self + { + return static::getInstance($normalize); + } + + /** + * @return bool|null + */ + public function getNormalization(): ?bool + { + return $this->normalize; + } + + /** + * Force all paths to be normalized. + * + * @return self + */ + public function unsafe(): self + { + return static::getInstance(true); + } + + /** + * Force all paths not to be normalized (speeds up the calls if given paths are known to be normalized). + * + * @return self + */ + public function safe(): self + { + return static::getInstance(false); + } + + /** + * {@inheritdoc} + * @see FilesystemInterface::parent() + */ + public function parent(string $path, int $levels = 1): string + { + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + if ($this->normalize !== false) { + $path = $this->normalizePathPart($path); + } + + if ($path === '' || $path === '.') { + return ''; + } + + [$scheme, $parent] = $this->dirnameInternal($scheme, $path, $levels); + + return $parent !== $path ? $this->toString($scheme, $parent) : ''; + } + + /** + * {@inheritdoc} + * @see FilesystemInterface::normalize() + */ + public function normalize(string $path): string + { + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + $path = $this->normalizePathPart($path); + + return $this->toString($scheme, $path); + } + + /** + * {@inheritdoc} + * @see FilesystemInterface::basename() + */ + public function basename(string $path, ?string $suffix = null): string + { + // Escape path. + $path = str_replace(['%2F', '%5C'], '/', rawurlencode($path)); + + return rawurldecode($suffix ? basename($path, $suffix) : basename($path)); + } + + /** + * {@inheritdoc} + * @see FilesystemInterface::dirname() + */ + public function dirname(string $path, int $levels = 1): string + { + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + if ($this->normalize || ($scheme && null === $this->normalize)) { + $path = $this->normalizePathPart($path); + } + + [$scheme, $path] = $this->dirnameInternal($scheme, $path, $levels); + + return $this->toString($scheme, $path); + } + + /** + * Gets full path with trailing slash. + * + * @param string $path + * @param int $levels + * @return string + * @phpstan-param positive-int $levels + */ + public function pathname(string $path, int $levels = 1): string + { + $path = $this->dirname($path, $levels); + + return $path !== '.' ? $path . '/' : ''; + } + + /** + * {@inheritdoc} + * @see FilesystemInterface::pathinfo() + */ + public function pathinfo(string $path, ?int $options = null) + { + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + if ($this->normalize || ($scheme && null === $this->normalize)) { + $path = $this->normalizePathPart($path); + } + + return $this->pathinfoInternal($scheme, $path, $options); + } + + /** + * @param string|null $scheme + * @param string $path + * @param int $levels + * @return array + * @phpstan-param positive-int $levels + */ + protected function dirnameInternal(?string $scheme, string $path, int $levels = 1): array + { + $path = dirname($path, $levels); + + if (null !== $scheme && $path === '.') { + return [$scheme, '']; + } + + // In Windows dirname() may return backslashes, fix that. + if (DIRECTORY_SEPARATOR !== '/') { + $path = str_replace('\\', '/', $path); + } + + return [$scheme, $path]; + } + + /** + * @param string|null $scheme + * @param string $path + * @param int|null $options + * @return array|string + */ + protected function pathinfoInternal(?string $scheme, string $path, ?int $options = null) + { + $path = str_replace(['%2F', '%5C'], ['/', '\\'], rawurlencode($path)); + + if (null === $options) { + $info = pathinfo($path); + } else { + $info = pathinfo($path, $options); + } + + if (!is_array($info)) { + return rawurldecode($info); + } + + $info = array_map('rawurldecode', $info); + + if (null !== $scheme) { + $info['scheme'] = $scheme; + + /** @phpstan-ignore-next-line because pathinfo('') doesn't have dirname */ + $dirname = $info['dirname'] ?? '.'; + + if ('' !== $dirname && '.' !== $dirname) { + // In Windows dirname may be using backslashes, fix that. + if (DIRECTORY_SEPARATOR !== '/') { + $dirname = str_replace(DIRECTORY_SEPARATOR, '/', $dirname); + } + + $info['dirname'] = $scheme . '://' . $dirname; + } else { + $info = ['dirname' => $scheme . '://'] + $info; + } + } + + return $info; + } + + /** + * Gets a 2-tuple of scheme (may be null) and hierarchical part of a filename (e.g. file:///tmp -> array(file, tmp)). + * + * @param string $filename + * @return array + */ + protected function getSchemeAndHierarchy(string $filename): array + { + $components = explode('://', $filename, 2); + + return 2 === count($components) ? $components : [null, $components[0]]; + } + + /** + * @param string|null $scheme + * @param string $path + * @return string + */ + protected function toString(?string $scheme, string $path): string + { + if ($scheme) { + return $scheme . '://' . $path; + } + + return $path; + } + + /** + * @param string $path + * @return string + * @throws RuntimeException + */ + protected function normalizePathPart(string $path): string + { + // Quick check for empty path. + if ($path === '' || $path === '.') { + return ''; + } + + // Quick check for root. + if ($path === '/') { + return '/'; + } + + // If the last character is not '/' or any of '\', './', '//' and '..' are not found, path is clean and we're done. + if ($path[-1] !== '/' && !preg_match('`(\\\\|\./|//|\.\.)`', $path)) { + return $path; + } + + // Convert backslashes + $path = strtr($path, ['\\' => '/']); + + $parts = explode('/', $path); + + // Keep absolute paths. + $root = ''; + if ($parts[0] === '') { + $root = '/'; + array_shift($parts); + } + + $list = []; + foreach ($parts as $i => $part) { + // Remove empty parts: // and /./ + if ($part === '' || $part === '.') { + continue; + } + + // Resolve /../ by removing path part. + if ($part === '..') { + $test = array_pop($list); + if ($test === null) { + // Oops, user tried to access something outside of our root folder. + throw new RuntimeException("Bad path {$path}"); + } + } else { + $list[] = $part; + } + } + + // Build path back together. + return $root . implode('/', $list); + } +} diff --git a/system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php b/system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php new file mode 100644 index 0000000..d641273 --- /dev/null +++ b/system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php @@ -0,0 +1,84 @@ += 1). + * @return string Returns parent path. + * @throws RuntimeException + * @phpstan-param positive-int $levels + * @api + */ + public function parent(string $path, int $levels = 1): string; + + /** + * Normalize path by cleaning up `\`, `/./`, `//` and `/../`. + * + * @param string $path A filename or path, does not need to exist as a file. + * @return string Returns normalized path. + * @throws RuntimeException + * @api + */ + public function normalize(string $path): string; + + /** + * Unicode-safe and stream-safe `\basename()` replacement. + * + * @param string $path A filename or path, does not need to exist as a file. + * @param string|null $suffix If the filename ends in suffix this will also be cut off. + * @return string + * @api + */ + public function basename(string $path, ?string $suffix = null): string; + + /** + * Unicode-safe and stream-safe `\dirname()` replacement. + * + * @see http://php.net/manual/en/function.dirname.php + * + * @param string $path A filename or path, does not need to exist as a file. + * @param int $levels The number of parent directories to go up (>= 1). + * @return string Returns path to the directory. + * @throws RuntimeException + * @phpstan-param positive-int $levels + * @api + */ + public function dirname(string $path, int $levels = 1): string; + + /** + * Unicode-safe and stream-safe `\pathinfo()` replacement. + * + * @see http://php.net/manual/en/function.pathinfo.php + * + * @param string $path A filename or path, does not need to exist as a file. + * @param int|null $options A PATHINFO_* constant. + * @return array|string + * @api + */ + public function pathinfo(string $path, ?int $options = null); +} diff --git a/system/src/Grav/Framework/Flex/Flex.php b/system/src/Grav/Framework/Flex/Flex.php new file mode 100644 index 0000000..ea931ba --- /dev/null +++ b/system/src/Grav/Framework/Flex/Flex.php @@ -0,0 +1,334 @@ + blueprint file, ...] + * @param array $config + */ + public function __construct(array $types, array $config) + { + $this->config = $config; + $this->types = []; + + foreach ($types as $type => $blueprint) { + if (!file_exists($blueprint)) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addMessage(sprintf('Flex: blueprint for flex type %s is missing', $type), 'error'); + + continue; + } + $this->addDirectoryType($type, $blueprint); + } + } + + /** + * @param string $type + * @param string $blueprint + * @param array $config + * @return $this + */ + public function addDirectoryType(string $type, string $blueprint, array $config = []) + { + $config = array_replace_recursive(['enabled' => true], $this->config, $config); + + $this->types[$type] = new FlexDirectory($type, $blueprint, $config); + + return $this; + } + + /** + * @param FlexDirectory $directory + * @return $this + */ + public function addDirectory(FlexDirectory $directory) + { + $this->types[$directory->getFlexType()] = $directory; + + return $this; + } + + /** + * @param string $type + * @return bool + */ + public function hasDirectory(string $type): bool + { + return isset($this->types[$type]); + } + + /** + * @param array|string[]|null $types + * @param bool $keepMissing + * @return array + */ + public function getDirectories(array $types = null, bool $keepMissing = false): array + { + if ($types === null) { + return $this->types; + } + + // Return the directories in the given order. + $directories = []; + foreach ($types as $type) { + $directories[$type] = $this->types[$type] ?? null; + } + + return $keepMissing ? $directories : array_filter($directories); + } + + /** + * @param string $type + * @return FlexDirectory|null + */ + public function getDirectory(string $type): ?FlexDirectory + { + return $this->types[$type] ?? null; + } + + /** + * @param string $type + * @param array|null $keys + * @param string|null $keyField + * @return FlexCollectionInterface|null + * @phpstan-return FlexCollectionInterface|null + */ + public function getCollection(string $type, array $keys = null, string $keyField = null): ?FlexCollectionInterface + { + $directory = $type ? $this->getDirectory($type) : null; + + return $directory ? $directory->getCollection($keys, $keyField) : null; + } + + /** + * @param array $keys + * @param array $options In addition to the options in getObjects(), following options can be passed: + * collection_class: Class to be used to create the collection. Defaults to ObjectCollection. + * @return FlexCollectionInterface + * @throws RuntimeException + * @phpstan-return FlexCollectionInterface + */ + public function getMixedCollection(array $keys, array $options = []): FlexCollectionInterface + { + $collectionClass = $options['collection_class'] ?? ObjectCollection::class; + if (!is_a($collectionClass, FlexCollectionInterface::class, true)) { + throw new RuntimeException(sprintf('Cannot create collection: Class %s does not exist', $collectionClass)); + } + + $objects = $this->getObjects($keys, $options); + + return new $collectionClass($objects); + } + + /** + * @param array $keys + * @param array $options Following optional options can be passed: + * types: List of allowed types. + * type: Allowed type if types isn't defined, otherwise acts as default_type. + * default_type: Set default type for objects given without type (only used if key_field isn't set). + * keep_missing: Set to true if you want to return missing objects as null. + * key_field: Key field which is used to match the objects. + * @return array + */ + public function getObjects(array $keys, array $options = []): array + { + $type = $options['type'] ?? null; + $defaultType = $options['default_type'] ?? $type ?? null; + $keyField = $options['key_field'] ?? 'flex_key'; + + // Prepare empty result lists for all requested Flex types. + $types = $options['types'] ?? (array)$type ?: null; + if ($types) { + $types = array_fill_keys($types, []); + } + $strict = isset($types); + + $guessed = []; + if ($keyField === 'flex_key') { + // We need to split Flex key lookups into individual directories. + $undefined = []; + $keyFieldFind = 'storage_key'; + + foreach ($keys as $flexKey) { + if (!$flexKey) { + continue; + } + + $flexKey = (string)$flexKey; + // Normalize key and type using fallback to default type if it was set. + [$key, $type, $guess] = $this->resolveKeyAndType($flexKey, $defaultType); + + if ($type === '' && $types) { + // Add keys which are not associated to any Flex type. They will be included to every Flex type. + foreach ($types as $type => &$array) { + $array[] = $key; + $guessed[$key][] = "{$type}.obj:{$key}"; + } + unset($array); + } elseif (!$strict || isset($types[$type])) { + // Collect keys by their Flex type. If allowed types are defined, only include values from those types. + $types[$type][] = $key; + if ($guess) { + $guessed[$key][] = "{$type}.obj:{$key}"; + } + } + } + } else { + // We are using a specific key field, make every key undefined. + $undefined = $keys; + $keyFieldFind = $keyField; + } + + if (!$types) { + return []; + } + + $list = [[]]; + foreach ($types as $type => $typeKeys) { + // Also remember to look up keys from undefined Flex types. + $lookupKeys = $undefined ? array_merge($typeKeys, $undefined) : $typeKeys; + + $collection = $this->getCollection($type, $lookupKeys, $keyFieldFind); + if ($collection && $keyFieldFind !== $keyField) { + $collection = $collection->withKeyField($keyField); + } + + $list[] = $collection ? $collection->toArray() : []; + } + + // Merge objects from individual types back together. + $list = array_merge(...$list); + + // Use the original key ordering. + if (!$guessed) { + $list = array_replace(array_fill_keys($keys, null), $list); + } else { + // We have mixed keys, we need to map flex keys back to storage keys. + $results = []; + foreach ($keys as $key) { + $flexKey = $guessed[$key] ?? $key; + if (is_array($flexKey)) { + $result = null; + foreach ($flexKey as $tryKey) { + if ($result = $list[$tryKey] ?? null) { + // Use the first matching object (conflicting objects will be ignored for now). + break; + } + } + } else { + $result = $list[$flexKey] ?? null; + } + + $results[$key] = $result; + } + + $list = $results; + } + + // Remove missing objects if not asked to keep them. + if (empty($options['keep_missing'])) { + $list = array_filter($list); + } + + return $list; + } + + /** + * @param string $key + * @param string|null $type + * @param string|null $keyField + * @return FlexObjectInterface|null + */ + public function getObject(string $key, string $type = null, string $keyField = null): ?FlexObjectInterface + { + if (null === $type && null === $keyField) { + // Special handling for quick Flex key lookups. + $keyField = 'storage_key'; + [$key, $type] = $this->resolveKeyAndType($key, $type); + } else { + $type = $this->resolveType($type); + } + + if ($type === '' || $key === '') { + return null; + } + + $directory = $this->getDirectory($type); + + return $directory ? $directory->getObject($key, $keyField) : null; + } + + /** + * @return int + */ + public function count(): int + { + return count($this->types); + } + + /** + * @param string $flexKey + * @param string|null $type + * @return array + */ + protected function resolveKeyAndType(string $flexKey, string $type = null): array + { + $guess = false; + if (strpos($flexKey, ':') !== false) { + [$type, $key] = explode(':', $flexKey, 2); + + $type = $this->resolveType($type); + } else { + $key = $flexKey; + $type = (string)$type; + $guess = true; + } + + return [$key, $type, $guess]; + } + + /** + * @param string|null $type + * @return string + */ + protected function resolveType(string $type = null): string + { + if (null !== $type && strpos($type, '.') !== false) { + return preg_replace('|\.obj$|', '', $type) ?? $type; + } + + return $type ?? ''; + } +} diff --git a/system/src/Grav/Framework/Flex/FlexCollection.php b/system/src/Grav/Framework/Flex/FlexCollection.php new file mode 100644 index 0000000..036f274 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexCollection.php @@ -0,0 +1,733 @@ + + * @implements FlexCollectionInterface + */ +class FlexCollection extends ObjectCollection implements FlexCollectionInterface +{ + /** @var FlexDirectory */ + private $_flexDirectory; + + /** @var string */ + private $_keyField = 'storage_key'; + + /** + * Get list of cached methods. + * + * @return array Returns a list of methods with their caching information. + */ + public static function getCachedMethods(): array + { + return [ + 'getTypePrefix' => true, + 'getType' => true, + 'getFlexDirectory' => true, + 'hasFlexFeature' => true, + 'getFlexFeatures' => true, + 'getCacheKey' => true, + 'getCacheChecksum' => false, + 'getTimestamp' => true, + 'hasProperty' => true, + 'getProperty' => true, + 'hasNestedProperty' => true, + 'getNestedProperty' => true, + 'orderBy' => true, + + 'render' => false, + 'isAuthorized' => 'session', + 'search' => true, + 'sort' => true, + 'getDistinctValues' => true + ]; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::createFromArray() + */ + public static function createFromArray(array $entries, FlexDirectory $directory, string $keyField = null) + { + $instance = new static($entries, $directory); + $instance->setKeyField($keyField); + + return $instance; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::__construct() + */ + public function __construct(array $entries = [], FlexDirectory $directory = null) + { + // @phpstan-ignore-next-line + if (get_class($this) === __CLASS__) { + user_error('Using ' . __CLASS__ . ' directly is deprecated since Grav 1.7, use \Grav\Common\Flex\Types\Generic\GenericCollection or your own class instead', E_USER_DEPRECATED); + } + + parent::__construct($entries); + + if ($directory) { + $this->setFlexDirectory($directory)->setKey($directory->getFlexType()); + } + } + + /** + * {@inheritdoc} + * @see FlexCommonInterface::hasFlexFeature() + */ + public function hasFlexFeature(string $name): bool + { + return in_array($name, $this->getFlexFeatures(), true); + } + + /** + * {@inheritdoc} + * @see FlexCommonInterface::hasFlexFeature() + */ + public function getFlexFeatures(): array + { + /** @var array $implements */ + $implements = class_implements($this); + + $list = []; + foreach ($implements as $interface) { + if ($pos = strrpos($interface, '\\')) { + $interface = substr($interface, $pos+1); + } + + $list[] = Inflector::hyphenize(str_replace('Interface', '', $interface)); + } + + return $list; + + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::search() + */ + public function search(string $search, $properties = null, array $options = null) + { + $directory = $this->getFlexDirectory(); + $properties = $directory->getSearchProperties($properties); + $options = $directory->getSearchOptions($options); + + $matching = $this->call('search', [$search, $properties, $options]); + $matching = array_filter($matching); + + if ($matching) { + arsort($matching, SORT_NUMERIC); + } + + /** @var string[] $array */ + $array = array_keys($matching); + + /** @phpstan-var static */ + return $this->select($array); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::sort() + */ + public function sort(array $order) + { + $criteria = Criteria::create()->orderBy($order); + + /** @phpstan-var FlexCollectionInterface $matching */ + $matching = $this->matching($criteria); + + return $matching; + } + + /** + * @param array $filters + * @return static + * @phpstan-return static + */ + public function filterBy(array $filters) + { + $expr = Criteria::expr(); + $criteria = Criteria::create(); + + foreach ($filters as $key => $value) { + $criteria->andWhere($expr->eq($key, $value)); + } + + /** @phpstan-var static */ + return $this->matching($criteria); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexType() + */ + public function getFlexType(): string + { + return $this->_flexDirectory->getFlexType(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getFlexDirectory(): FlexDirectory + { + return $this->_flexDirectory; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getTimestamp() + */ + public function getTimestamp(): int + { + $timestamps = $this->getTimestamps(); + + return $timestamps ? max($timestamps) : time(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getCacheKey(): string + { + return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1((string)json_encode($this->call('getKey'))); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getCacheChecksum(): string + { + $list = []; + /** + * @var string $key + * @var FlexObjectInterface $object + */ + foreach ($this as $key => $object) { + $list[$key] = $object->getCacheChecksum(); + } + + return sha1((string)json_encode($list)); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getTimestamps(): array + { + /** @var int[] $timestamps */ + $timestamps = $this->call('getTimestamp'); + + return $timestamps; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getStorageKeys(): array + { + /** @var string[] $keys */ + $keys = $this->call('getStorageKey'); + + return $keys; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getFlexKeys(): array + { + /** @var string[] $keys */ + $keys = $this->call('getFlexKey'); + + return $keys; + } + + /** + * Get all the values in property. + * + * Supports either single scalar values or array of scalar values. + * + * @param string $property Object property to be used to make groups. + * @param string|null $separator Separator, defaults to '.' + * @return array + */ + public function getDistinctValues(string $property, string $separator = null): array + { + $list = []; + + /** @var FlexObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $value = (array)$element->getNestedProperty($property, null, $separator); + foreach ($value as $v) { + if (is_scalar($v)) { + $t = gettype($v) . (string)$v; + $list[$t] = $v; + } + } + } + + return array_values($list); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::withKeyField() + */ + public function withKeyField(string $keyField = null) + { + $keyField = $keyField ?: 'key'; + if ($keyField === $this->getKeyField()) { + return $this; + } + + $entries = []; + foreach ($this as $key => $object) { + // TODO: remove hardcoded logic + if ($keyField === 'storage_key') { + $entries[$object->getStorageKey()] = $object; + } elseif ($keyField === 'flex_key') { + $entries[$object->getFlexKey()] = $object; + } elseif ($keyField === 'key') { + $entries[$object->getKey()] = $object; + } + } + + return $this->createFrom($entries, $keyField); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getIndex() + */ + public function getIndex() + { + /** @phpstan-var FlexIndexInterface */ + return $this->getFlexDirectory()->getIndex($this->getKeys(), $this->getKeyField()); + } + + /** + * @inheritdoc} + * @see FlexCollectionInterface::getCollection() + * @return $this + */ + public function getCollection() + { + return $this; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::render() + */ + public function render(string $layout = null, array $context = []) + { + if (!$layout) { + $config = $this->getTemplateConfig(); + $layout = $config['collection']['defaults']['layout'] ?? 'default'; + } + + $type = $this->getFlexType(); + + $grav = Grav::instance(); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->startTimer('flex-collection-' . ($debugKey = uniqid($type, false)), 'Render Collection ' . $type . ' (' . $layout . ')'); + + $key = null; + foreach ($context as $value) { + if (!is_scalar($value)) { + $key = false; + break; + } + } + + if ($key !== false) { + $key = md5($this->getCacheKey() . '.' . $layout . json_encode($context)); + $cache = $this->getCache('render'); + } else { + $cache = null; + } + + try { + $data = $cache && $key ? $cache->get($key) : null; + + $block = $data ? HtmlBlock::fromArray($data) : null; + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + $block = null; + } catch (\InvalidArgumentException $e) { + $debugger->addException($e); + $block = null; + } + + $checksum = $this->getCacheChecksum(); + if ($block && $checksum !== $block->getChecksum()) { + $block = null; + } + + if (!$block) { + $block = HtmlBlock::create($key ?: null); + $block->setChecksum($checksum); + if (!$key) { + $block->disableCache(); + } + + $event = new Event([ + 'type' => 'flex', + 'directory' => $this->getFlexDirectory(), + 'collection' => $this, + 'layout' => &$layout, + 'context' => &$context + ]); + $this->triggerEvent('onRender', $event); + + $output = $this->getTemplate($layout)->render( + [ + 'grav' => $grav, + 'config' => $grav['config'], + 'block' => $block, + 'directory' => $this->getFlexDirectory(), + 'collection' => $this, + 'layout' => $layout + ] + $context + ); + + if ($debugger->enabled() && + !($grav['uri']->getContentType() === 'application/json' || $grav['uri']->extension() === 'json')) { + $output = "\n\n{$output}\n\n"; + } + + $block->setContent($output); + + try { + $cache && $key && $block->isCached() && $cache->set($key, $block->toArray()); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + } + } + + $debugger->stopTimer('flex-collection-' . $debugKey); + + return $block; + } + + /** + * @param FlexDirectory $type + * @return $this + */ + public function setFlexDirectory(FlexDirectory $type) + { + $this->_flexDirectory = $type; + + return $this; + } + + /** + * @param string $key + * @return array + */ + public function getMetaData($key): array + { + $object = $this->get($key); + + return $object instanceof FlexObjectInterface ? $object->getMetaData() : []; + } + + /** + * @param string|null $namespace + * @return CacheInterface + */ + public function getCache(string $namespace = null) + { + return $this->_flexDirectory->getCache($namespace); + } + + /** + * @return string + */ + public function getKeyField(): string + { + return $this->_keyField; + } + + /** + * @param string $action + * @param string|null $scope + * @param UserInterface|null $user + * @return static + * @phpstan-return static + */ + public function isAuthorized(string $action, string $scope = null, UserInterface $user = null) + { + $list = $this->call('isAuthorized', [$action, $scope, $user]); + $list = array_filter($list); + + /** @var string[] $keys */ + $keys = array_keys($list); + + /** @phpstan-var static */ + return $this->select($keys); + } + + /** + * @param string $value + * @param string $field + * @return FlexObjectInterface|null + * @phpstan-return T|null + */ + public function find($value, $field = 'id') + { + if ($value) { + foreach ($this as $element) { + if (mb_strtolower($element->getProperty($field)) === mb_strtolower($value)) { + return $element; + } + } + } + + return null; + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + $elements = []; + + /** + * @var string $key + * @var array|FlexObject $object + */ + foreach ($this->getElements() as $key => $object) { + $elements[$key] = is_array($object) ? $object : $object->jsonSerialize(); + } + + return $elements; + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function __debugInfo() + { + return [ + 'type:private' => $this->getFlexType(), + 'key:private' => $this->getKey(), + 'objects_key:private' => $this->getKeyField(), + 'objects:private' => $this->getElements() + ]; + } + + /** + * Creates a new instance from the specified elements. + * + * This method is provided for derived classes to specify how a new + * instance should be created when constructor semantics have changed. + * + * @param array $elements Elements. + * @param string|null $keyField + * @return static + * @phpstan-return static + * @throws \InvalidArgumentException + */ + protected function createFrom(array $elements, $keyField = null) + { + $collection = new static($elements, $this->_flexDirectory); + $collection->setKeyField($keyField ?: $this->_keyField); + + return $collection; + } + + /** + * @return string + */ + protected function getTypePrefix(): string + { + return 'c.'; + } + + /** + * @return array + */ + protected function getTemplateConfig(): array + { + $config = $this->getFlexDirectory()->getConfig('site.templates', []); + $defaults = array_replace($config['defaults'] ?? [], $config['collection']['defaults'] ?? []); + $config['collection']['defaults'] = $defaults; + + return $config; + } + + /** + * @param string $layout + * @return array + */ + protected function getTemplatePaths(string $layout): array + { + $config = $this->getTemplateConfig(); + $type = $this->getFlexType(); + $defaults = $config['collection']['defaults'] ?? []; + + $ext = $defaults['ext'] ?? '.html.twig'; + $types = array_unique(array_merge([$type], (array)($defaults['type'] ?? null))); + $paths = $config['collection']['paths'] ?? [ + 'flex/{TYPE}/collection/{LAYOUT}{EXT}', + 'flex-objects/layouts/{TYPE}/collection/{LAYOUT}{EXT}' + ]; + $table = ['TYPE' => '%1$s', 'LAYOUT' => '%2$s', 'EXT' => '%3$s']; + + $lookups = []; + foreach ($paths as $path) { + $path = Utils::simpleTemplate($path, $table); + foreach ($types as $type) { + $lookups[] = sprintf($path, $type, $layout, $ext); + } + } + + return array_unique($lookups); + } + + /** + * @param string $layout + * @return Template|TemplateWrapper + * @throws LoaderError + * @throws SyntaxError + */ + protected function getTemplate($layout) + { + $grav = Grav::instance(); + + /** @var Twig $twig */ + $twig = $grav['twig']; + + try { + return $twig->twig()->resolveTemplate($this->getTemplatePaths($layout)); + } catch (LoaderError $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + return $twig->twig()->resolveTemplate(['flex/404.html.twig']); + } + } + + /** + * @param string $type + * @return FlexDirectory + */ + protected function getRelatedDirectory($type): ?FlexDirectory + { + /** @var Flex $flex */ + $flex = Grav::instance()['flex']; + + return $flex->getDirectory($type); + } + + /** + * @param string|null $keyField + * @return void + */ + protected function setKeyField($keyField = null): void + { + $this->_keyField = $keyField ?? 'storage_key'; + } + + // DEPRECATED METHODS + + /** + * @param bool $prefix + * @return string + * @deprecated 1.6 Use `->getFlexType()` instead. + */ + public function getType($prefix = false) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); + + $type = $prefix ? $this->getTypePrefix() : ''; + + return $type . $this->getFlexType(); + } + + /** + * @param string $name + * @param object|null $event + * @return $this + * @deprecated 1.7, moved to \Grav\Common\Flex\Traits\FlexObjectTrait + */ + public function triggerEvent(string $name, $event = null) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \Grav\Common\Flex\Traits\FlexObjectTrait', E_USER_DEPRECATED); + + if (null === $event) { + $event = new Event([ + 'type' => 'flex', + 'directory' => $this->getFlexDirectory(), + 'collection' => $this + ]); + } + if (strpos($name, 'onFlexCollection') !== 0 && strpos($name, 'on') === 0) { + $name = 'onFlexCollection' . substr($name, 2); + } + + $grav = Grav::instance(); + if ($event instanceof Event) { + $grav->fireEvent($name, $event); + } else { + $grav->dispatchEvent($event); + } + + + return $this; + } +} diff --git a/system/src/Grav/Framework/Flex/FlexDirectory.php b/system/src/Grav/Framework/Flex/FlexDirectory.php new file mode 100644 index 0000000..fdf2a4b --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexDirectory.php @@ -0,0 +1,1187 @@ +[] + */ + protected $indexes = []; + /** + * @var FlexCollectionInterface|null + * @phpstan-var FlexCollectionInterface|null + */ + protected $collection; + /** @var bool */ + protected $enabled; + /** @var array */ + protected $defaults; + /** @var Config */ + protected $config; + /** @var FlexStorageInterface */ + protected $storage; + /** @var CacheInterface[] */ + protected $cache; + /** @var FlexObjectInterface[] */ + protected $objects; + /** @var string */ + protected $objectClassName; + /** @var string */ + protected $collectionClassName; + /** @var string */ + protected $indexClassName; + + /** @var string|null */ + private $_authorize; + + /** + * FlexDirectory constructor. + * @param string $type + * @param string $blueprint_file + * @param array $defaults + */ + public function __construct(string $type, string $blueprint_file, array $defaults = []) + { + $this->type = $type; + $this->blueprints = []; + $this->blueprint_file = $blueprint_file; + $this->defaults = $defaults; + $this->enabled = !empty($defaults['enabled']); + $this->objects = []; + } + + /** + * @return bool + */ + public function isListed(): bool + { + $grav = Grav::instance(); + + /** @var Flex $flex */ + $flex = $grav['flex']; + $directory = $flex->getDirectory($this->type); + + return null !== $directory; + } + + /** + * @return bool + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * @return string + */ + public function getFlexType(): string + { + return $this->type; + } + + /** + * @return string + */ + public function getTitle(): string + { + return $this->getBlueprintInternal()->get('title', ucfirst($this->getFlexType())); + } + + /** + * @return string + */ + public function getDescription(): string + { + return $this->getBlueprintInternal()->get('description', ''); + } + + /** + * @param string|null $name + * @param mixed $default + * @return mixed + */ + public function getConfig(string $name = null, $default = null) + { + if (null === $this->config) { + $config = $this->getBlueprintInternal()->get('config', []); + $config = is_array($config) ? array_replace_recursive($config, $this->defaults, $this->getDirectoryConfig($config['admin']['views']['configure']['form'] ?? $config['admin']['configure']['form'] ?? null)) : null; + if (!is_array($config)) { + throw new RuntimeException('Bad configuration'); + } + + $this->config = new Config($config); + } + + return null === $name ? $this->config : $this->config->get($name, $default); + } + + /** + * @param string|string[]|null $properties + * @return array + */ + public function getSearchProperties($properties = null): array + { + if (null !== $properties) { + return (array)$properties; + } + + $properties = $this->getConfig('data.search.fields'); + if (!$properties) { + $fields = $this->getConfig('admin.views.list.fields') ?? $this->getConfig('admin.list.fields', []); + foreach ($fields as $property => $value) { + if (!empty($value['link'])) { + $properties[] = $property; + } + } + } + + return $properties; + } + + /** + * @param array|null $options + * @return array + */ + public function getSearchOptions(array $options = null): array + { + if (empty($options['merge'])) { + return $options ?? (array)$this->getConfig('data.search.options'); + } + + unset($options['merge']); + + return $options + (array)$this->getConfig('data.search.options'); + } + + /** + * @param string|null $name + * @param array $options + * @return FlexFormInterface + * @internal + */ + public function getDirectoryForm(string $name = null, array $options = []) + { + $name = $name ?: $this->getConfig('admin.views.configure.form', '') ?: $this->getConfig('admin.configure.form', ''); + + return new FlexDirectoryForm($name ?? '', $this, $options); + } + + /** + * @return Blueprint + * @internal + */ + public function getDirectoryBlueprint() + { + $name = 'configure'; + + $type = $this->getBlueprint(); + $overrides = $type->get("blueprints/{$name}"); + + $path = "blueprints://flex/shared/{$name}.yaml"; + $blueprint = new Blueprint($path); + $blueprint->load(); + if (isset($overrides['fields'])) { + $blueprint->embed('form/fields/tabs/fields', $overrides['fields']); + } + $blueprint->init(); + + return $blueprint; + } + + /** + * @param string $name + * @param array $data + * @return void + * @throws Exception + * @internal + */ + public function saveDirectoryConfig(string $name, array $data) + { + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + $filename = $this->getDirectoryConfigUri($name); + if (file_exists($filename)) { + $filename = $locator->findResource($filename, true); + } else { + $filesystem = Filesystem::getInstance(); + $dirname = $filesystem->dirname($filename); + $basename = $filesystem->basename($filename); + $dirname = $locator->findResource($dirname, true) ?: $locator->findResource($dirname, true, true); + $filename = "{$dirname}/{$basename}"; + } + + $file = YamlFile::instance($filename); + if (!empty($data)) { + $file->save($data); + } else { + $file->delete(); + } + } + + /** + * @param string $name + * @return array + * @internal + */ + public function loadDirectoryConfig(string $name): array + { + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $uri = $this->getDirectoryConfigUri($name); + + // If configuration is found in main configuration, use it. + if (str_starts_with($uri, 'config://')) { + $path = str_replace('/', '.', substr($uri, 9, -5)); + + return (array)$grav['config']->get($path); + } + + // Load the configuration file. + $filename = $locator->findResource($uri, true); + if ($filename === false) { + return []; + } + + $file = YamlFile::instance($filename); + + return $file->content(); + } + + /** + * @param string|null $name + * @return string + */ + public function getDirectoryConfigUri(string $name = null): string + { + $name = $name ?: $this->getFlexType(); + $blueprint = $this->getBlueprint(); + + return $blueprint->get('blueprints/views/configure/file') ?? $blueprint->get('blueprints/configure/file') ?? "config://flex/{$name}.yaml"; + } + + /** + * @param string|null $name + * @return array + */ + protected function getDirectoryConfig(string $name = null): array + { + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + $name = $name ?: $this->getFlexType(); + + return $config->get("flex.{$name}", []); + } + + /** + * Returns a new uninitialized instance of blueprint. + * + * Always use $object->getBlueprint() or $object->getForm()->getBlueprint() instead. + * + * @param string $type + * @param string $context + * @return Blueprint + */ + public function getBlueprint(string $type = '', string $context = '') + { + return clone $this->getBlueprintInternal($type, $context); + } + + /** + * @param string $view + * @return string + */ + public function getBlueprintFile(string $view = ''): string + { + $file = $this->blueprint_file; + if ($view !== '') { + $file = preg_replace('/\.yaml/', "/{$view}.yaml", $file); + } + + return (string)$file; + } + + /** + * Get collection. In the site this will be filtered by the default filters (published etc). + * + * Use $directory->getIndex() if you want unfiltered collection. + * + * @param array|null $keys Array of keys. + * @param string|null $keyField Field to be used as the key. + * @return FlexCollectionInterface + * @phpstan-return FlexCollectionInterface + */ + public function getCollection(array $keys = null, string $keyField = null): FlexCollectionInterface + { + // Get all selected entries. + $index = $this->getIndex($keys, $keyField); + + if (!Utils::isAdminPlugin()) { + // If not in admin, filter the list by using default filters. + $filters = (array)$this->getConfig('site.filter', []); + + foreach ($filters as $filter) { + $index = $index->{$filter}(); + } + } + + return $index; + } + + /** + * Get the full collection of all stored objects. + * + * Use $directory->getCollection() if you want a filtered collection. + * + * @param array|null $keys Array of keys. + * @param string|null $keyField Field to be used as the key. + * @return FlexIndexInterface + * @phpstan-return FlexIndexInterface + */ + public function getIndex(array $keys = null, string $keyField = null): FlexIndexInterface + { + $keyField = $keyField ?? ''; + $index = $this->indexes[$keyField] ?? $this->loadIndex($keyField); + $index = clone $index; + + if (null !== $keys) { + /** @var FlexIndexInterface $index */ + $index = $index->select($keys); + } + + return $index->getIndex(); + } + + /** + * Returns an object if it exists. If no arguments are passed (or both of them are null), method creates a new empty object. + * + * Note: It is not safe to use the object without checking if the user can access it. + * + * @param string|null $key + * @param string|null $keyField Field to be used as the key. + * @return FlexObjectInterface|null + */ + public function getObject($key = null, string $keyField = null): ?FlexObjectInterface + { + if (null === $key) { + return $this->createObject([], ''); + } + + $keyField = $keyField ?? ''; + $index = $this->indexes[$keyField] ?? $this->loadIndex($keyField); + + return $index->get($key); + } + + /** + * @param string|null $namespace + * @return CacheInterface + */ + public function getCache(string $namespace = null) + { + $namespace = $namespace ?: 'index'; + $cache = $this->cache[$namespace] ?? null; + + if (null === $cache) { + try { + $grav = Grav::instance(); + + /** @var Cache $gravCache */ + $gravCache = $grav['cache']; + $config = $this->getConfig('object.cache.' . $namespace); + if (empty($config['enabled'])) { + $cache = new MemoryCache('flex-objects-' . $this->getFlexType()); + } else { + $lifetime = $config['lifetime'] ?? 60; + + $key = $gravCache->getKey(); + if (Utils::isAdminPlugin()) { + $key = substr($key, 0, -1); + } + $cache = new DoctrineCache($gravCache->getCacheDriver(), 'flex-objects-' . $this->getFlexType() . $key, $lifetime); + } + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + $cache = new MemoryCache('flex-objects-' . $this->getFlexType()); + } + + // Disable cache key validation. + $cache->setValidation(false); + $this->cache[$namespace] = $cache; + } + + return $cache; + } + + /** + * @return $this + */ + public function clearCache() + { + $grav = Grav::instance(); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addMessage(sprintf('Flex: Clearing all %s cache', $this->type), 'debug'); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $locator->clearCache(); + + $this->getCache('index')->clear(); + $this->getCache('object')->clear(); + $this->getCache('render')->clear(); + + $this->indexes = []; + $this->objects = []; + + return $this; + } + + /** + * @param string|null $key + * @return string|null + */ + public function getStorageFolder(string $key = null): ?string + { + return $this->getStorage()->getStoragePath($key); + } + + /** + * @param string|null $key + * @return string|null + */ + public function getMediaFolder(string $key = null): ?string + { + return $this->getStorage()->getMediaPath($key); + } + + /** + * @return FlexStorageInterface + */ + public function getStorage(): FlexStorageInterface + { + if (null === $this->storage) { + $this->storage = $this->createStorage(); + } + + return $this->storage; + } + + /** + * @param array $data + * @param string $key + * @param bool $validate + * @return FlexObjectInterface + */ + public function createObject(array $data, string $key = '', bool $validate = false): FlexObjectInterface + { + /** @phpstan-var class-string $className */ + $className = $this->objectClassName ?: $this->getObjectClass(); + if (!is_a($className, FlexObjectInterface::class, true)) { + throw new \RuntimeException('Bad object class: ' . $className); + } + + return new $className($data, $key, $this, $validate); + } + + /** + * @param array $entries + * @param string|null $keyField + * @return FlexCollectionInterface + * @phpstan-return FlexCollectionInterface + */ + public function createCollection(array $entries, string $keyField = null): FlexCollectionInterface + { + /** phpstan-var class-string $className */ + $className = $this->collectionClassName ?: $this->getCollectionClass(); + if (!is_a($className, FlexCollectionInterface::class, true)) { + throw new \RuntimeException('Bad collection class: ' . $className); + } + + return $className::createFromArray($entries, $this, $keyField); + } + + /** + * @param array $entries + * @param string|null $keyField + * @return FlexIndexInterface + * @phpstan-return FlexIndexInterface + */ + public function createIndex(array $entries, string $keyField = null): FlexIndexInterface + { + /** @phpstan-var class-string $className */ + $className = $this->indexClassName ?: $this->getIndexClass(); + if (!is_a($className, FlexIndexInterface::class, true)) { + throw new \RuntimeException('Bad index class: ' . $className); + } + + return $className::createFromArray($entries, $this, $keyField); + } + + /** + * @return string + */ + public function getObjectClass(): string + { + if (!$this->objectClassName) { + $this->objectClassName = $this->getConfig('data.object', GenericObject::class); + } + + return $this->objectClassName; + } + + /** + * @return string + */ + public function getCollectionClass(): string + { + if (!$this->collectionClassName) { + $this->collectionClassName = $this->getConfig('data.collection', GenericCollection::class); + } + + return $this->collectionClassName; + } + + + /** + * @return string + */ + public function getIndexClass(): string + { + if (!$this->indexClassName) { + $this->indexClassName = $this->getConfig('data.index', GenericIndex::class); + } + + return $this->indexClassName; + } + + /** + * @param array $entries + * @param string|null $keyField + * @return FlexCollectionInterface + * @phpstan-return FlexCollectionInterface + */ + public function loadCollection(array $entries, string $keyField = null): FlexCollectionInterface + { + return $this->createCollection($this->loadObjects($entries), $keyField); + } + + /** + * @param array $entries + * @return FlexObjectInterface[] + * @internal + */ + public function loadObjects(array $entries): array + { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + + $keys = []; + $rows = []; + $fetch = []; + + // Build lookup arrays with storage keys for the objects. + foreach ($entries as $key => $value) { + $k = $value['storage_key'] ?? ''; + if ($k === '') { + continue; + } + $v = $this->objects[$k] ?? null; + $keys[$k] = $key; + $rows[$k] = $v; + if (!$v) { + $fetch[] = $k; + } + } + + // Attempt to fetch missing rows from the cache. + if ($fetch) { + $rows = (array)array_replace($rows, $this->loadCachedObjects($fetch)); + } + + // Read missing rows from the storage. + $updated = []; + $storage = $this->getStorage(); + $rows = $storage->readRows($rows, $updated); + + // Create objects from the rows. + $isListed = $this->isListed(); + $list = []; + foreach ($rows as $storageKey => $row) { + $usedKey = $keys[$storageKey]; + + if ($row instanceof FlexObjectInterface) { + $object = $row; + } else { + if ($row === null) { + $debugger->addMessage(sprintf('Flex: Object %s was not found from %s storage', $storageKey, $this->type), 'debug'); + continue; + } + + if (isset($row['__ERROR'])) { + $message = sprintf('Flex: Object %s is broken in %s storage: %s', $storageKey, $this->type, $row['__ERROR']); + $debugger->addException(new RuntimeException($message)); + $debugger->addMessage($message, 'error'); + continue; + } + + if (!isset($row['__META'])) { + $row['__META'] = [ + 'storage_key' => $storageKey, + 'storage_timestamp' => $entries[$usedKey]['storage_timestamp'] ?? 0, + ]; + } + + $key = $row['__META']['key'] ?? $entries[$usedKey]['key'] ?? $usedKey; + $object = $this->createObject($row, $key, false); + $this->objects[$storageKey] = $object; + if ($isListed) { + // If unserialize works for the object, serialize the object to speed up the loading. + $updated[$storageKey] = $object; + } + } + + $list[$usedKey] = $object; + } + + // Store updated rows to the cache. + if ($updated) { + $cache = $this->getCache('object'); + if (!$cache instanceof MemoryCache) { + ///** @var Debugger $debugger */ + //$debugger = Grav::instance()['debugger']; + //$debugger->addMessage(sprintf('Flex: Caching %d %s', \count($entries), $this->type), 'debug'); + } + try { + $cache->setMultiple($updated); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + // TODO: log about the issue. + } + } + + if ($fetch) { + $debugger->stopTimer('flex-objects'); + } + + return $list; + } + + protected function loadCachedObjects(array $fetch): array + { + if (!$fetch) { + return []; + } + + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + + $cache = $this->getCache('object'); + + // Attempt to fetch missing rows from the cache. + $fetched = []; + try { + $loading = count($fetch); + + $debugger->startTimer('flex-objects', sprintf('Flex: Loading %d %s', $loading, $this->type)); + + $fetched = (array)$cache->getMultiple($fetch); + if ($fetched) { + $index = $this->loadIndex('storage_key'); + + // Make sure cached objects are up to date: compare against index checksum/timestamp. + /** + * @var string $key + * @var mixed $value + */ + foreach ($fetched as $key => $value) { + if ($value instanceof FlexObjectInterface) { + $objectMeta = $value->getMetaData(); + } else { + $objectMeta = $value['__META'] ?? []; + } + $indexMeta = $index->getMetaData($key); + + $indexChecksum = $indexMeta['checksum'] ?? $indexMeta['storage_timestamp'] ?? null; + $objectChecksum = $objectMeta['checksum'] ?? $objectMeta['storage_timestamp'] ?? null; + if ($indexChecksum !== $objectChecksum) { + unset($fetched[$key]); + } + } + } + + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + } + + return $fetched; + } + + /** + * @return void + */ + public function reloadIndex(): void + { + $this->getCache('index')->clear(); + $this->getIndex()::loadEntriesFromStorage($this->getStorage()); + + $this->indexes = []; + $this->objects = []; + } + + /** + * @param string $scope + * @param string $action + * @return string + */ + public function getAuthorizeRule(string $scope, string $action): string + { + if (!$this->_authorize) { + $config = $this->getConfig('admin.permissions'); + if ($config) { + $this->_authorize = array_key_first($config) . '.%2$s'; + } else { + $this->_authorize = '%1$s.flex-object.%2$s'; + } + } + + return sprintf($this->_authorize, $scope, $action); + } + + /** + * @param string $type_view + * @param string $context + * @return Blueprint + */ + protected function getBlueprintInternal(string $type_view = '', string $context = '') + { + if (!isset($this->blueprints[$type_view])) { + if (!file_exists($this->blueprint_file)) { + throw new RuntimeException(sprintf('Flex: Blueprint file for %s is missing', $this->type)); + } + + $parts = explode('.', rtrim($type_view, '.'), 2); + $type = array_shift($parts); + $view = array_shift($parts) ?: ''; + + $blueprint = new Blueprint($this->getBlueprintFile($view)); + $blueprint->addDynamicHandler('data', function (array &$field, $property, array &$call) { + $this->dynamicDataField($field, $property, $call); + }); + $blueprint->addDynamicHandler('flex', function (array &$field, $property, array &$call) { + $this->dynamicFlexField($field, $property, $call); + }); + $blueprint->addDynamicHandler('authorize', function (array &$field, $property, array &$call) { + $this->dynamicAuthorizeField($field, $property, $call); + }); + + if ($context) { + $blueprint->setContext($context); + } + + $blueprint->load($type ?: null); + if ($blueprint->get('type') === 'flex-objects' && isset(Grav::instance()['admin'])) { + $blueprintBase = (new Blueprint('plugin://flex-objects/blueprints/flex-objects.yaml'))->load(); + $blueprint->extend($blueprintBase, true); + } + + $this->blueprints[$type_view] = $blueprint; + } + + return $this->blueprints[$type_view]; + } + + /** + * @param array $field + * @param string $property + * @param array $call + * @return void + */ + protected function dynamicDataField(array &$field, $property, array $call) + { + $params = $call['params']; + if (is_array($params)) { + $function = array_shift($params); + } else { + $function = $params; + $params = []; + } + + $object = $call['object']; + if ($function === '\Grav\Common\Page\Pages::pageTypes') { + $params = [$object instanceof PageInterface && $object->isModule() ? 'modular' : 'standard']; + } + + $data = null; + if (is_callable($function)) { + $data = call_user_func_array($function, $params); + } + + // If function returns a value, + if (null !== $data) { + if (is_array($data) && isset($field[$property]) && is_array($field[$property])) { + // Combine field and @data-field together. + $field[$property] += $data; + } else { + // Or create/replace field with @data-field. + $field[$property] = $data; + } + } + } + + /** + * @param array $field + * @param string $property + * @param array $call + * @return void + */ + protected function dynamicFlexField(array &$field, $property, array $call): void + { + $params = (array)$call['params']; + $object = $call['object'] ?? null; + $method = array_shift($params); + $not = false; + if (str_starts_with($method, '!')) { + $method = substr($method, 1); + $not = true; + } elseif (str_starts_with($method, 'not ')) { + $method = substr($method, 4); + $not = true; + } + $method = trim($method); + + if ($object && method_exists($object, $method)) { + $value = $object->{$method}(...$params); + if (is_array($value) && isset($field[$property]) && is_array($field[$property])) { + $value = $this->mergeArrays($field[$property], $value); + } + $value = $not ? !$value : $value; + + if ($property === 'ignore' && $value) { + Blueprint::addPropertyRecursive($field, 'validate', ['ignore' => true]); + } else { + $field[$property] = $value; + } + } + } + + /** + * @param array $field + * @param string $property + * @param array $call + * @return void + */ + protected function dynamicAuthorizeField(array &$field, $property, array $call): void + { + $params = (array)$call['params']; + $object = $call['object'] ?? null; + $permission = array_shift($params); + $not = false; + if (str_starts_with($permission, '!')) { + $permission = substr($permission, 1); + $not = true; + } elseif (str_starts_with($permission, 'not ')) { + $permission = substr($permission, 4); + $not = true; + } + $permission = trim($permission); + + if ($object) { + $value = $object->isAuthorized($permission) ?? false; + + $field[$property] = $not ? !$value : $value; + } + } + + /** + * @param array $array1 + * @param array $array2 + * @return array + */ + protected function mergeArrays(array $array1, array $array2): array + { + foreach ($array2 as $key => $value) { + if (is_array($value) && isset($array1[$key]) && is_array($array1[$key])) { + $array1[$key] = $this->mergeArrays($array1[$key], $value); + } else { + $array1[$key] = $value; + } + } + + return $array1; + } + + /** + * @return FlexStorageInterface + */ + protected function createStorage(): FlexStorageInterface + { + $this->collection = $this->createCollection([]); + + $storage = $this->getConfig('data.storage'); + + if (!is_array($storage)) { + $storage = ['options' => ['folder' => $storage]]; + } + + $className = $storage['class'] ?? SimpleStorage::class; + $options = $storage['options'] ?? []; + + if (!is_a($className, FlexStorageInterface::class, true)) { + throw new \RuntimeException('Bad storage class: ' . $className); + } + + return new $className($options); + } + + /** + * @param string $keyField + * @return FlexIndexInterface + * @phpstan-return FlexIndexInterface + */ + protected function loadIndex(string $keyField): FlexIndexInterface + { + static $i = 0; + + $index = $this->indexes[$keyField] ?? null; + if (null !== $index) { + return $index; + } + + $index = $this->indexes['storage_key'] ?? null; + if (null === $index) { + $i++; + $j = $i; + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->startTimer('flex-keys-' . $this->type . $j, "Flex: Loading {$this->type} index"); + + $storage = $this->getStorage(); + $cache = $this->getCache('index'); + + try { + $keys = $cache->get('__keys'); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + $keys = null; + } + + if (!is_array($keys)) { + /** @phpstan-var class-string $className */ + $className = $this->getIndexClass(); + $keys = $className::loadEntriesFromStorage($storage); + if (!$cache instanceof MemoryCache) { + $debugger->addMessage( + sprintf('Flex: Caching %s index of %d objects', $this->type, count($keys)), + 'debug' + ); + } + try { + $cache->set('__keys', $keys); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + // TODO: log about the issue. + } + } + + $ordering = $this->getConfig('data.ordering', []); + + // We need to do this in two steps as orderBy() calls loadIndex() again and we do not want infinite loop. + $this->indexes['storage_key'] = $index = $this->createIndex($keys, 'storage_key'); + if ($ordering) { + /** @var FlexCollectionInterface $collection */ + $collection = $this->indexes['storage_key']->orderBy($ordering); + $this->indexes['storage_key'] = $index = $collection->getIndex(); + } + + $debugger->stopTimer('flex-keys-' . $this->type . $j); + } + + if ($keyField !== 'storage_key') { + $this->indexes[$keyField] = $index = $index->withKeyField($keyField ?: null); + } + + return $index; + } + + /** + * @param string $action + * @return string + */ + protected function getAuthorizeAction(string $action): string + { + // Handle special action save, which can mean either update or create. + if ($action === 'save') { + $action = 'create'; + } + + return $action; + } + /** + * @return UserInterface|null + */ + protected function getActiveUser(): ?UserInterface + { + /** @var UserInterface|null $user */ + $user = Grav::instance()['user'] ?? null; + + return $user; + } + + /** + * @return string + */ + protected function getAuthorizeScope(): string + { + return isset(Grav::instance()['admin']) ? 'admin' : 'site'; + } + + // DEPRECATED METHODS + + /** + * @return string + * @deprecated 1.6 Use ->getFlexType() method instead. + */ + public function getType(): string + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); + + return $this->type; + } + + /** + * @param array $data + * @param string|null $key + * @return FlexObjectInterface + * @deprecated 1.7 Use $object->update()->save() instead. + */ + public function update(array $data, string $key = null): FlexObjectInterface + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() should not be used anymore: use $object->update()->save() instead.', E_USER_DEPRECATED); + + $object = null !== $key ? $this->getIndex()->get($key): null; + + $storage = $this->getStorage(); + + if (null === $object) { + $object = $this->createObject($data, $key ?? '', true); + $key = $object->getStorageKey(); + + if ($key) { + $storage->replaceRows([$key => $object->prepareStorage()]); + } else { + $storage->createRows([$object->prepareStorage()]); + } + } else { + $oldKey = $object->getStorageKey(); + $object->update($data); + $newKey = $object->getStorageKey(); + + if ($oldKey !== $newKey) { + if (method_exists($object, 'triggerEvent')) { + $object->triggerEvent('move'); + } + $storage->renameRow($oldKey, $newKey); + // TODO: media support. + } + + $object->save(); + } + + try { + $this->clearCache(); + } catch (InvalidArgumentException $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + // Caching failed, but we can ignore that for now. + } + + return $object; + } + + /** + * @param string $key + * @return FlexObjectInterface|null + * @deprecated 1.7 Use $object->delete() instead. + */ + public function remove(string $key): ?FlexObjectInterface + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() should not be used anymore: use $object->delete() instead.', E_USER_DEPRECATED); + + $object = $this->getIndex()->get($key); + if (!$object) { + return null; + } + + $object->delete(); + + return $object; + } +} diff --git a/system/src/Grav/Framework/Flex/FlexDirectoryForm.php b/system/src/Grav/Framework/Flex/FlexDirectoryForm.php new file mode 100644 index 0000000..ec18909 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexDirectoryForm.php @@ -0,0 +1,509 @@ +getDirectoryForm($name, $options); + } + + /** + * FlexForm constructor. + * @param string $name + * @param FlexDirectory $directory + * @param array|null $options + */ + public function __construct(string $name, FlexDirectory $directory, array $options = null) + { + $this->name = $name; + $this->setDirectory($directory); + $this->setName($directory->getFlexType(), $name); + $this->setId($this->getName()); + + $uniqueId = $options['unique_id'] ?? null; + if (!$uniqueId) { + $uniqueId = md5($directory->getFlexType() . '-directory-' . $this->name); + } + $this->setUniqueId($uniqueId); + + $this->setFlashLookupFolder($directory->getDirectoryBlueprint()->get('form/flash_folder') ?? 'tmp://forms/[SESSIONID]'); + $this->form = $options['form'] ?? null; + + if (Utils::isPositive($this->form['disabled'] ?? false)) { + $this->disable(); + } + + $this->initialize(); + } + + /** + * @return $this + */ + public function initialize() + { + $this->messages = []; + $this->submitted = false; + $this->data = new Data($this->directory->loadDirectoryConfig($this->name), $this->getBlueprint()); + $this->files = []; + $this->unsetFlash(); + + /** @var FlexFormFlash $flash */ + $flash = $this->getFlash(); + if ($flash->exists()) { + $data = $flash->getData(); + $includeOriginal = (bool)($this->getBlueprint()->form()['images']['original'] ?? null); + + $directory = $flash->getDirectory(); + if (null === $directory) { + throw new RuntimeException('Flash has no directory'); + } + $this->directory = $directory; + $this->data = $data ? new Data($data, $this->getBlueprint()) : null; + $this->files = $flash->getFilesByFields($includeOriginal); + } + + return $this; + } + + /** + * @param string $uniqueId + * @return void + */ + public function setUniqueId(string $uniqueId): void + { + if ($uniqueId !== '') { + $this->uniqueid = $uniqueId; + } + } + + /** + * @param string $name + * @param mixed $default + * @param string|null $separator + * @return mixed + */ + public function get($name, $default = null, $separator = null) + { + switch (strtolower($name)) { + case 'id': + case 'uniqueid': + case 'name': + case 'noncename': + case 'nonceaction': + case 'action': + case 'data': + case 'files': + case 'errors'; + case 'fields': + case 'blueprint': + case 'page': + $method = 'get' . $name; + return $this->{$method}(); + } + + return $this->traitGet($name, $default, $separator); + } + + /** + * @param string $name + * @param mixed $value + * @param string|null $separator + * @return $this + */ + public function set($name, $value, $separator = null) + { + switch (strtolower($name)) { + case 'id': + case 'uniqueid': + $method = 'set' . $name; + return $this->{$method}(); + } + + return $this->traitSet($name, $value, $separator); + } + + /** + * @return string + */ + public function getName(): string + { + return $this->flexName; + } + + protected function setName(string $type, string $name): void + { + // Make sure that both type and name do not have dash (convert dashes to underscores). + $type = str_replace('-', '_', $type); + $name = str_replace('-', '_', $name); + $this->flexName = $name ? "flex_conf-{$type}-{$name}" : "flex_conf-{$type}"; + } + + /** + * @return Data|object + */ + public function getData() + { + if (null === $this->data) { + $this->data = new Data([], $this->getBlueprint()); + } + + return $this->data; + } + + /** + * Get a value from the form. + * + * Note: Used in form fields. + * + * @param string $name + * @return mixed + */ + public function getValue(string $name) + { + // Attempt to get value from the form data. + $value = $this->data ? $this->data[$name] : null; + + // Return the form data or fall back to the object property. + return $value ?? null; + } + + /** + * @param string $name + * @return array|mixed|null + */ + public function getDefaultValue(string $name) + { + return $this->getBlueprint()->getDefaultValue($name); + } + + /** + * @return array + */ + public function getDefaultValues(): array + { + return $this->getBlueprint()->getDefaults(); + } + /** + * @return string + */ + public function getFlexType(): string + { + return $this->directory->getFlexType(); + } + + /** + * Get form flash object. + * + * @return FormFlashInterface|FlexFormFlash + */ + public function getFlash() + { + if (null === $this->flash) { + $grav = Grav::instance(); + $config = [ + 'session_id' => $this->getSessionId(), + 'unique_id' => $this->getUniqueId(), + 'form_name' => $this->getName(), + 'folder' => $this->getFlashFolder(), + 'id' => $this->getFlashId(), + 'directory' => $this->getDirectory() + ]; + + $this->flash = new FlexFormFlash($config); + $this->flash + ->setUrl($grav['uri']->url) + ->setUser($grav['user'] ?? null); + } + + return $this->flash; + } + + /** + * @return FlexDirectory + */ + public function getDirectory(): FlexDirectory + { + return $this->directory; + } + + /** + * @return Blueprint + */ + public function getBlueprint(): Blueprint + { + if (null === $this->blueprint) { + try { + $blueprint = $this->getDirectory()->getDirectoryBlueprint(); + if ($this->form) { + // We have field overrides available. + $blueprint->extend(['form' => $this->form], true); + $blueprint->init(); + } + } catch (RuntimeException $e) { + if (!isset($this->form['fields'])) { + throw $e; + } + + // Blueprint is not defined, but we have custom form fields available. + $blueprint = new Blueprint(null, ['form' => $this->form]); + $blueprint->load(); + $blueprint->setScope('directory'); + $blueprint->init(); + } + + $this->blueprint = $blueprint; + } + + return $this->blueprint; + } + + /** + * @return Route|null + */ + public function getFileUploadAjaxRoute(): ?Route + { + return null; + } + + /** + * @param string|null $field + * @param string|null $filename + * @return Route|null + */ + public function getFileDeleteAjaxRoute($field = null, $filename = null): ?Route + { + return null; + } + + /** + * @param array $params + * @param string|null $extension + * @return string + */ + public function getMediaTaskRoute(array $params = [], string $extension = null): string + { + return ''; + } + + /** + * @param string $name + * @return mixed|null + */ + #[\ReturnTypeWillChange] + public function __get($name) + { + $method = "get{$name}"; + if (method_exists($this, $method)) { + return $this->{$method}(); + } + + $form = $this->getBlueprint()->form(); + + return $form[$name] ?? null; + } + + /** + * @param string $name + * @param mixed $value + * @return void + */ + #[\ReturnTypeWillChange] + public function __set($name, $value) + { + $method = "set{$name}"; + if (method_exists($this, $method)) { + $this->{$method}($value); + } + } + + /** + * @param string $name + * @return bool + */ + #[\ReturnTypeWillChange] + public function __isset($name) + { + $method = "get{$name}"; + if (method_exists($this, $method)) { + return true; + } + + $form = $this->getBlueprint()->form(); + + return isset($form[$name]); + } + + /** + * @param string $name + * @return void + */ + #[\ReturnTypeWillChange] + public function __unset($name) + { + } + + /** + * @return array|bool + */ + protected function getUnserializeAllowedClasses() + { + return [FlexObject::class]; + } + + /** + * Note: this method clones the object. + * + * @param FlexDirectory $directory + * @return $this + */ + protected function setDirectory(FlexDirectory $directory): self + { + $this->directory = $directory; + + return $this; + } + + /** + * @param string $layout + * @return Template|TemplateWrapper + * @throws LoaderError + * @throws SyntaxError + */ + protected function getTemplate($layout) + { + $grav = Grav::instance(); + + /** @var Twig $twig */ + $twig = $grav['twig']; + + return $twig->twig()->resolveTemplate( + [ + "flex-objects/layouts/{$this->getFlexType()}/form/{$layout}.html.twig", + "flex-objects/layouts/_default/form/{$layout}.html.twig", + "forms/{$layout}/form.html.twig", + 'forms/default/form.html.twig' + ] + ); + } + + /** + * @param array $data + * @param array $files + * @return void + * @throws Exception + */ + protected function doSubmit(array $data, array $files) + { + $this->directory->saveDirectoryConfig($this->name, $data); + + $this->reset(); + } + + /** + * @return array + */ + protected function doSerialize(): array + { + return $this->doTraitSerialize() + [ + 'form' => $this->form, + 'directory' => $this->directory, + 'flexName' => $this->flexName + ]; + } + + /** + * @param array $data + * @return void + */ + protected function doUnserialize(array $data): void + { + $this->doTraitUnserialize($data); + + $this->form = $data['form']; + $this->directory = $data['directory']; + $this->flexName = $data['flexName']; + } + + /** + * Filter validated data. + * + * @param ArrayAccess|Data|null $data + * @phpstan-param ArrayAccess|Data|null $data + */ + protected function filterData($data = null): void + { + if ($data instanceof Data) { + $data->filter(false, true); + } + } +} diff --git a/system/src/Grav/Framework/Flex/FlexForm.php b/system/src/Grav/Framework/Flex/FlexForm.php new file mode 100644 index 0000000..48f7157 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexForm.php @@ -0,0 +1,610 @@ +getObject($key) ?? $directory->createObject([], $key); + } else { + throw new RuntimeException(__METHOD__ . "(): You need to pass option 'directory' or 'object'", 400); + } + + $name = $options['name'] ?? ''; + + // There is no reason to pass object and directory. + unset($options['object'], $options['directory']); + + return $object->getForm($name, $options); + } + + /** + * FlexForm constructor. + * @param string $name + * @param FlexObjectInterface $object + * @param array|null $options + */ + public function __construct(string $name, FlexObjectInterface $object, array $options = null) + { + $this->name = $name; + $this->setObject($object); + + if (isset($options['form']['name'])) { + // Use custom form name. + $this->flexName = $options['form']['name']; + } else { + // Use standard form name. + $this->setName($object->getFlexType(), $name); + } + $this->setId($this->getName()); + + $uniqueId = $options['unique_id'] ?? null; + if (!$uniqueId) { + if ($object->exists()) { + $uniqueId = $object->getStorageKey(); + } elseif ($object->hasKey()) { + $uniqueId = "{$object->getKey()}:new"; + } else { + $uniqueId = "{$object->getFlexType()}:new"; + } + $uniqueId = md5($uniqueId); + } + $this->setUniqueId($uniqueId); + + $directory = $object->getFlexDirectory(); + $this->setFlashLookupFolder($options['flash_folder'] ?? $directory->getBlueprint()->get('form/flash_folder') ?? 'tmp://forms/[SESSIONID]'); + $this->form = $options['form'] ?? null; + + if (Utils::isPositive($this->items['disabled'] ?? $this->form['disabled'] ?? false)) { + $this->disable(); + } + + if (!empty($options['reset'])) { + $this->getFlash()->delete(); + } + + $this->initialize(); + } + + /** + * @return $this + */ + public function initialize() + { + $this->messages = []; + $this->submitted = false; + $this->data = null; + $this->files = []; + $this->unsetFlash(); + + /** @var FlexFormFlash $flash */ + $flash = $this->getFlash(); + if ($flash->exists()) { + $data = $flash->getData(); + if (null !== $data) { + $data = new Data($data, $this->getBlueprint()); + $data->setKeepEmptyValues(true); + $data->setMissingValuesAsNull(true); + } + + $object = $flash->getObject(); + if (null === $object) { + throw new RuntimeException('Flash has no object'); + } + + $this->object = $object; + $this->data = $data; + + $includeOriginal = (bool)($this->getBlueprint()->form()['images']['original'] ?? null); + $this->files = $flash->getFilesByFields($includeOriginal); + } + + return $this; + } + + /** + * @param string $uniqueId + * @return void + */ + public function setUniqueId(string $uniqueId): void + { + if ($uniqueId !== '') { + $this->uniqueid = $uniqueId; + } + } + + /** + * @param string $name + * @param mixed $default + * @param string|null $separator + * @return mixed + */ + public function get($name, $default = null, $separator = null) + { + switch (strtolower($name)) { + case 'id': + case 'uniqueid': + case 'name': + case 'noncename': + case 'nonceaction': + case 'action': + case 'data': + case 'files': + case 'errors'; + case 'fields': + case 'blueprint': + case 'page': + $method = 'get' . $name; + return $this->{$method}(); + } + + return $this->traitGet($name, $default, $separator); + } + + /** + * @param string $name + * @param mixed $value + * @param string|null $separator + * @return FlexForm + */ + public function set($name, $value, $separator = null) + { + switch (strtolower($name)) { + case 'id': + case 'uniqueid': + $method = 'set' . $name; + return $this->{$method}(); + } + + return $this->traitSet($name, $value, $separator); + } + + /** + * @return string + */ + public function getName(): string + { + return $this->flexName; + } + + /** + * @param callable|null $submitMethod + */ + public function setSubmitMethod(?callable $submitMethod): void + { + $this->submitMethod = $submitMethod; + } + + /** + * @param string $type + * @param string $name + */ + protected function setName(string $type, string $name): void + { + // Make sure that both type and name do not have dash (convert dashes to underscores). + $type = str_replace('-', '_', $type); + $name = str_replace('-', '_', $name); + $this->flexName = $name ? "flex-{$type}-{$name}" : "flex-{$type}"; + } + + /** + * @return Data|FlexObjectInterface|object + */ + public function getData() + { + return $this->data ?? $this->getObject(); + } + + /** + * Get a value from the form. + * + * Note: Used in form fields. + * + * @param string $name + * @return mixed + */ + public function getValue(string $name) + { + // Attempt to get value from the form data. + $value = $this->data ? $this->data[$name] : null; + + // Return the form data or fall back to the object property. + return $value ?? $this->getObject()->getFormValue($name); + } + + /** + * @param string $name + * @return array|mixed|null + */ + public function getDefaultValue(string $name) + { + return $this->object->getDefaultValue($name); + } + + /** + * @return array + */ + public function getDefaultValues(): array + { + return $this->object->getDefaultValues(); + } + /** + * @return string + */ + public function getFlexType(): string + { + return $this->object->getFlexType(); + } + + /** + * Get form flash object. + * + * @return FormFlashInterface|FlexFormFlash + */ + public function getFlash() + { + if (null === $this->flash) { + $grav = Grav::instance(); + $config = [ + 'session_id' => $this->getSessionId(), + 'unique_id' => $this->getUniqueId(), + 'form_name' => $this->getName(), + 'folder' => $this->getFlashFolder(), + 'id' => $this->getFlashId(), + 'object' => $this->getObject() + ]; + + $this->flash = new FlexFormFlash($config); + $this->flash + ->setUrl($grav['uri']->url) + ->setUser($grav['user'] ?? null); + } + + return $this->flash; + } + + /** + * @return FlexObjectInterface + */ + public function getObject(): FlexObjectInterface + { + return $this->object; + } + + /** + * @return FlexObjectInterface + */ + public function updateObject(): FlexObjectInterface + { + $data = $this->data instanceof Data ? $this->data->toArray() : []; + $files = $this->files; + + return $this->getObject()->update($data, $files); + } + + /** + * @return Blueprint + */ + public function getBlueprint(): Blueprint + { + if (null === $this->blueprint) { + try { + $blueprint = $this->getObject()->getBlueprint($this->name); + if ($this->form) { + // We have field overrides available. + $blueprint->extend(['form' => $this->form], true); + $blueprint->init(); + } + } catch (RuntimeException $e) { + if (!isset($this->form['fields'])) { + throw $e; + } + + // Blueprint is not defined, but we have custom form fields available. + $blueprint = new Blueprint(null, ['form' => $this->form]); + $blueprint->load(); + $blueprint->setScope('object'); + $blueprint->init(); + } + + $this->blueprint = $blueprint; + } + + return $this->blueprint; + } + + /** + * @return Route|null + */ + public function getFileUploadAjaxRoute(): ?Route + { + $object = $this->getObject(); + if (!method_exists($object, 'route')) { + /** @var Route $route */ + $route = Grav::instance()['route']; + + return $route->withExtension('json')->withGravParam('task', 'media.upload'); + } + + return $object->route('/edit.json/task:media.upload'); + } + + /** + * @param string|null $field + * @param string|null $filename + * @return Route|null + */ + public function getFileDeleteAjaxRoute($field = null, $filename = null): ?Route + { + $object = $this->getObject(); + if (!method_exists($object, 'route')) { + /** @var Route $route */ + $route = Grav::instance()['route']; + + return $route->withExtension('json')->withGravParam('task', 'media.delete'); + } + + return $object->route('/edit.json/task:media.delete'); + } + + /** + * @param array $params + * @param string|null $extension + * @return string + */ + public function getMediaTaskRoute(array $params = [], string $extension = null): string + { + $grav = Grav::instance(); + /** @var Flex $flex */ + $flex = $grav['flex_objects']; + + if (method_exists($flex, 'adminRoute')) { + return $flex->adminRoute($this->getObject(), $params, $extension ?? 'json'); + } + + return ''; + } + + /** + * @param string $name + * @return mixed|null + */ + #[\ReturnTypeWillChange] + public function __get($name) + { + $method = "get{$name}"; + if (method_exists($this, $method)) { + return $this->{$method}(); + } + + $form = $this->getBlueprint()->form(); + + return $form[$name] ?? null; + } + + /** + * @param string $name + * @param mixed $value + * @return void + */ + #[\ReturnTypeWillChange] + public function __set($name, $value) + { + $method = "set{$name}"; + if (method_exists($this, $method)) { + $this->{$method}($value); + } + } + + /** + * @param string $name + * @return bool + */ + #[\ReturnTypeWillChange] + public function __isset($name) + { + $method = "get{$name}"; + if (method_exists($this, $method)) { + return true; + } + + $form = $this->getBlueprint()->form(); + + return isset($form[$name]); + } + + /** + * @param string $name + * @return void + */ + #[\ReturnTypeWillChange] + public function __unset($name) + { + } + + /** + * @return array|bool + */ + protected function getUnserializeAllowedClasses() + { + return [FlexObject::class]; + } + + /** + * Note: this method clones the object. + * + * @param FlexObjectInterface $object + * @return $this + */ + protected function setObject(FlexObjectInterface $object): self + { + $this->object = clone $object; + + return $this; + } + + /** + * @param string $layout + * @return Template|TemplateWrapper + * @throws LoaderError + * @throws SyntaxError + */ + protected function getTemplate($layout) + { + $grav = Grav::instance(); + + /** @var Twig $twig */ + $twig = $grav['twig']; + + return $twig->twig()->resolveTemplate( + [ + "flex-objects/layouts/{$this->getFlexType()}/form/{$layout}.html.twig", + "flex-objects/layouts/_default/form/{$layout}.html.twig", + "forms/{$layout}/form.html.twig", + 'forms/default/form.html.twig' + ] + ); + } + + /** + * @param array $data + * @param array $files + * @return void + * @throws Exception + */ + protected function doSubmit(array $data, array $files) + { + /** @var FlexObject $object */ + $object = clone $this->getObject(); + + $method = $this->submitMethod; + if ($method) { + $method($data, $files, $object); + } else { + $object->update($data, $files); + $object->save(); + } + + $this->setObject($object); + $this->reset(); + } + + /** + * @return array + */ + protected function doSerialize(): array + { + return $this->doTraitSerialize() + [ + 'items' => $this->items, + 'form' => $this->form, + 'object' => $this->object, + 'flexName' => $this->flexName, + 'submitMethod' => $this->submitMethod, + ]; + } + + /** + * @param array $data + * @return void + */ + protected function doUnserialize(array $data): void + { + $this->doTraitUnserialize($data); + + $this->items = $data['items'] ?? null; + $this->form = $data['form'] ?? null; + $this->object = $data['object'] ?? null; + $this->flexName = $data['flexName'] ?? null; + $this->submitMethod = $data['submitMethod'] ?? null; + } + + /** + * Filter validated data. + * + * @param ArrayAccess|Data|null $data + * @return void + * @phpstan-param ArrayAccess|Data|null $data + */ + protected function filterData($data = null): void + { + if ($data instanceof Data) { + $data->filter(true, true); + } + } +} diff --git a/system/src/Grav/Framework/Flex/FlexFormFlash.php b/system/src/Grav/Framework/Flex/FlexFormFlash.php new file mode 100644 index 0000000..e9b6e6f --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexFormFlash.php @@ -0,0 +1,130 @@ +object = $object; + $this->directory = $object->getFlexDirectory(); + } + + /** + * @return FlexObjectInterface|null + */ + public function getObject(): ?FlexObjectInterface + { + return $this->object; + } + + /** + * @param FlexDirectory $directory + */ + public function setDirectory(FlexDirectory $directory): void + { + $this->directory = $directory; + } + + /** + * @return FlexDirectory|null + */ + public function getDirectory(): ?FlexDirectory + { + return $this->directory; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $serialized = parent::jsonSerialize(); + + $object = $this->getObject(); + if ($object instanceof FlexObjectInterface) { + $serialized['object'] = [ + 'type' => $object->getFlexType(), + 'key' => $object->getKey() ?: null, + 'storage_key' => $object->getStorageKey(), + 'timestamp' => $object->getTimestamp(), + 'serialized' => $object->prepareStorage() + ]; + } else { + $directory = $this->getDirectory(); + if ($directory instanceof FlexDirectory) { + $serialized['directory'] = [ + 'type' => $directory->getFlexType() + ]; + } + } + + return $serialized; + } + + /** + * @param array|null $data + * @param array $config + * @return void + */ + protected function init(?array $data, array $config): void + { + parent::init($data, $config); + + $data = $data ?? []; + /** @var FlexObjectInterface|null $object */ + $object = $config['object'] ?? null; + $create = true; + if ($object) { + $directory = $object->getFlexDirectory(); + $create = !$object->exists(); + } elseif (null === ($directory = $config['directory'] ?? null)) { + $flex = $config['flex'] ?? static::$flex; + $type = $data['object']['type'] ?? $data['directory']['type'] ?? null; + $directory = $flex && $type ? $flex->getDirectory($type) : null; + } + + if ($directory && $create && isset($data['object']['serialized'])) { + // TODO: update instead of create new. + $object = $directory->createObject($data['object']['serialized'], $data['object']['key'] ?? ''); + } + + if ($object) { + $this->setObject($object); + } elseif ($directory) { + $this->setDirectory($directory); + } + } +} diff --git a/system/src/Grav/Framework/Flex/FlexIdentifier.php b/system/src/Grav/Framework/Flex/FlexIdentifier.php new file mode 100644 index 0000000..ec47ed8 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexIdentifier.php @@ -0,0 +1,75 @@ + + */ +class FlexIdentifier extends Identifier +{ + /** @var string */ + private $keyField; + /** @var FlexObjectInterface|null */ + private $object = null; + + /** + * @param FlexObjectInterface $object + * @return FlexIdentifier + */ + public static function createFromObject(FlexObjectInterface $object): FlexIdentifier + { + $instance = new static($object->getKey(), $object->getFlexType(), 'key'); + $instance->setObject($object); + + return $instance; + } + + /** + * IdentifierInterface constructor. + * @param string $id + * @param string $type + * @param string $keyField + */ + public function __construct(string $id, string $type, string $keyField = 'key') + { + parent::__construct($id, $type); + + $this->keyField = $keyField; + } + + /** + * @return T + */ + public function getObject(): ?FlexObjectInterface + { + if (!isset($this->object)) { + /** @var Flex $flex */ + $flex = Grav::instance()['flex']; + + $this->object = $flex->getObject($this->getId(), $this->getType(), $this->keyField); + } + + return $this->object; + } + + /** + * @param T $object + */ + public function setObject(FlexObjectInterface $object): void + { + $type = $this->getType(); + if ($type !== $object->getFlexType()) { + throw new RuntimeException(sprintf('Object has to be type %s, %s given', $type, $object->getFlexType())); + } + + $this->object = $object; + } +} diff --git a/system/src/Grav/Framework/Flex/FlexIndex.php b/system/src/Grav/Framework/Flex/FlexIndex.php new file mode 100644 index 0000000..b96fed1 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexIndex.php @@ -0,0 +1,930 @@ + + * @implements FlexIndexInterface + * @mixin C + */ +class FlexIndex extends ObjectIndex implements FlexIndexInterface +{ + const VERSION = 1; + + /** @var FlexDirectory|null */ + private $_flexDirectory; + /** @var string */ + private $_keyField = 'storage_key'; + /** @var array */ + private $_indexKeys; + + /** + * @param FlexDirectory $directory + * @return static + * @phpstan-return static + */ + public static function createFromStorage(FlexDirectory $directory) + { + return static::createFromArray(static::loadEntriesFromStorage($directory->getStorage()), $directory); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::createFromArray() + */ + public static function createFromArray(array $entries, FlexDirectory $directory, string $keyField = null) + { + $instance = new static($entries, $directory); + $instance->setKeyField($keyField); + + return $instance; + } + + /** + * @param FlexStorageInterface $storage + * @return array + */ + public static function loadEntriesFromStorage(FlexStorageInterface $storage): array + { + return $storage->getExistingKeys(); + } + + /** + * You can define indexes for fast lookup. + * + * Primary key: $meta['key'] + * Secondary keys: $meta['my_field'] + * + * @param array $meta + * @param array $data + * @param FlexStorageInterface $storage + * @return void + */ + public static function updateObjectMeta(array &$meta, array $data, FlexStorageInterface $storage) + { + // For backwards compatibility, no need to call this method when you override this method. + static::updateIndexData($meta, $data); + } + + /** + * Initializes a new FlexIndex. + * + * @param array $entries + * @param FlexDirectory|null $directory + */ + public function __construct(array $entries = [], FlexDirectory $directory = null) + { + // @phpstan-ignore-next-line + if (get_class($this) === __CLASS__) { + user_error('Using ' . __CLASS__ . ' directly is deprecated since Grav 1.7, use \Grav\Common\Flex\Types\Generic\GenericIndex or your own class instead', E_USER_DEPRECATED); + } + + parent::__construct($entries); + + $this->_flexDirectory = $directory; + $this->setKeyField(null); + } + + /** + * @return string + */ + public function getKey() + { + return $this->_key ?: $this->getFlexType() . '@@' . spl_object_hash($this); + } + + /** + * {@inheritdoc} + * @see FlexCommonInterface::hasFlexFeature() + */ + public function hasFlexFeature(string $name): bool + { + return in_array($name, $this->getFlexFeatures(), true); + } + + /** + * {@inheritdoc} + * @see FlexCommonInterface::hasFlexFeature() + */ + public function getFlexFeatures(): array + { + /** @var array $implements */ + $implements = class_implements($this->getFlexDirectory()->getCollectionClass()); + + $list = []; + foreach ($implements as $interface) { + if ($pos = strrpos($interface, '\\')) { + $interface = substr($interface, $pos+1); + } + + $list[] = Inflector::hyphenize(str_replace('Interface', '', $interface)); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::search() + */ + public function search(string $search, $properties = null, array $options = null) + { + $directory = $this->getFlexDirectory(); + $properties = $directory->getSearchProperties($properties); + $options = $directory->getSearchOptions($options); + + return $this->__call('search', [$search, $properties, $options]); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::sort() + */ + public function sort(array $orderings) + { + return $this->orderBy($orderings); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::filterBy() + */ + public function filterBy(array $filters) + { + return $this->__call('filterBy', [$filters]); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexType() + */ + public function getFlexType(): string + { + return $this->getFlexDirectory()->getFlexType(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getFlexDirectory(): FlexDirectory + { + if (null === $this->_flexDirectory) { + throw new RuntimeException('Flex Directory not defined, object is not fully defined'); + } + + return $this->_flexDirectory; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getTimestamp() + */ + public function getTimestamp(): int + { + $timestamps = $this->getTimestamps(); + + return $timestamps ? max($timestamps) : time(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getCacheKey() + */ + public function getCacheKey(): string + { + return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1(json_encode($this->getKeys()) . $this->_keyField); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getCacheChecksum() + */ + public function getCacheChecksum(): string + { + $list = []; + foreach ($this->getEntries() as $key => $value) { + $list[$key] = $value['checksum'] ?? $value['storage_timestamp']; + } + + return sha1((string)json_encode($list)); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getTimestamps() + */ + public function getTimestamps(): array + { + return $this->getIndexMap('storage_timestamp'); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getStorageKeys() + */ + public function getStorageKeys(): array + { + return $this->getIndexMap('storage_key'); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexKeys() + */ + public function getFlexKeys(): array + { + // Get storage keys for the objects. + $keys = []; + $type = $this->getFlexDirectory()->getFlexType() . '.obj:'; + + foreach ($this->getEntries() as $key => $value) { + $keys[$key] = $value['flex_key'] ?? $type . $value['storage_key']; + } + + return $keys; + } + + /** + * {@inheritdoc} + * @see FlexIndexInterface::withKeyField() + */ + public function withKeyField(string $keyField = null) + { + $keyField = $keyField ?: 'key'; + if ($keyField === $this->getKeyField()) { + return $this; + } + + $type = $keyField === 'flex_key' ? $this->getFlexDirectory()->getFlexType() . '.obj:' : ''; + $entries = []; + foreach ($this->getEntries() as $key => $value) { + if (!isset($value['key'])) { + $value['key'] = $key; + } + + if (isset($value[$keyField])) { + $entries[$value[$keyField]] = $value; + } elseif ($keyField === 'flex_key') { + $entries[$type . $value['storage_key']] = $value; + } + } + + return $this->createFrom($entries, $keyField); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getIndex() + */ + public function getIndex() + { + return $this; + } + + /** + * @return FlexCollectionInterface + * @phpstan-return C + */ + public function getCollection() + { + return $this->loadCollection(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::render() + */ + public function render(string $layout = null, array $context = []) + { + return $this->__call('render', [$layout, $context]); + } + + /** + * {@inheritdoc} + * @see FlexIndexInterface::getFlexKeys() + */ + public function getIndexMap(string $indexKey = null) + { + if (null === $indexKey) { + return $this->getEntries(); + } + + // Get storage keys for the objects. + $index = []; + foreach ($this->getEntries() as $key => $value) { + $index[$key] = $value[$indexKey] ?? null; + } + + return $index; + } + + /** + * @param string $key + * @return array + */ + public function getMetaData($key): array + { + return $this->getEntries()[$key] ?? []; + } + + /** + * @return string + */ + public function getKeyField(): string + { + return $this->_keyField; + } + + /** + * @param string|null $namespace + * @return CacheInterface + */ + public function getCache(string $namespace = null) + { + return $this->getFlexDirectory()->getCache($namespace); + } + + /** + * @param array $orderings + * @return static + * @phpstan-return static + */ + public function orderBy(array $orderings) + { + if (!$orderings || !$this->count()) { + return $this; + } + + // Handle primary key alias. + $keyField = $this->getFlexDirectory()->getStorage()->getKeyField(); + if ($keyField !== 'key' && $keyField !== 'storage_key' && isset($orderings[$keyField])) { + $orderings['key'] = $orderings[$keyField]; + unset($orderings[$keyField]); + } + + // Check if ordering needs to load the objects. + if (array_diff_key($orderings, $this->getIndexKeys())) { + return $this->__call('orderBy', [$orderings]); + } + + // Ordering can be done by using index only. + $previous = null; + foreach (array_reverse($orderings) as $field => $ordering) { + $field = (string)$field; + if ($this->getKeyField() === $field) { + $keys = $this->getKeys(); + $search = array_combine($keys, $keys) ?: []; + } elseif ($field === 'flex_key') { + $search = $this->getFlexKeys(); + } else { + $search = $this->getIndexMap($field); + } + + // Update current search to match the previous ordering. + if (null !== $previous) { + $search = array_replace($previous, $search); + } + + // Order by current field. + if (strtoupper($ordering) === 'DESC') { + arsort($search, SORT_NATURAL | SORT_FLAG_CASE); + } else { + asort($search, SORT_NATURAL | SORT_FLAG_CASE); + } + + $previous = $search; + } + + return $this->createFrom(array_replace($previous ?? [], $this->getEntries())); + } + + /** + * {@inheritDoc} + */ + public function call($method, array $arguments = []) + { + return $this->__call('call', [$method, $arguments]); + } + + /** + * @param string $name + * @param array $arguments + * @return mixed + */ + #[\ReturnTypeWillChange] + public function __call($name, $arguments) + { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + + /** @phpstan-var class-string $className */ + $className = $this->getFlexDirectory()->getCollectionClass(); + $cachedMethods = $className::getCachedMethods(); + + $flexType = $this->getFlexType(); + + if (!empty($cachedMethods[$name])) { + $type = $cachedMethods[$name]; + if ($type === 'session') { + /** @var Session $session */ + $session = Grav::instance()['session']; + $cacheKey = $session->getId() . ($session->user->username ?? ''); + } else { + $cacheKey = ''; + } + $key = "{$flexType}.idx." . sha1($name . '.' . $cacheKey . json_encode($arguments) . $this->getCacheKey()); + $checksum = $this->getCacheChecksum(); + + $cache = $this->getCache('object'); + + try { + $cached = $cache->get($key); + $test = $cached[0] ?? null; + $result = $test === $checksum ? ($cached[1] ?? null) : null; + + // Make sure the keys aren't changed if the returned type is the same index type. + if ($result instanceof self && $flexType === $result->getFlexType()) { + $result = $result->withKeyField($this->getKeyField()); + } + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + } + + if (!isset($result)) { + $collection = $this->loadCollection(); + $result = $collection->{$name}(...$arguments); + $debugger->addMessage("Cache miss: '{$flexType}::{$name}()'", 'debug'); + + try { + // If flex collection is returned, convert it back to flex index. + if ($result instanceof FlexCollection) { + $cached = $result->getFlexDirectory()->getIndex($result->getKeys(), $this->getKeyField()); + } else { + $cached = $result; + } + + $cache->set($key, [$checksum, $cached]); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + + // TODO: log error. + } + } + } else { + $collection = $this->loadCollection(); + if (\is_callable([$collection, $name])) { + $result = $collection->{$name}(...$arguments); + if (!isset($cachedMethods[$name])) { + $debugger->addMessage("Call '{$flexType}:{$name}()' isn't cached", 'debug'); + } + } else { + $result = null; + } + } + + return $result; + } + + /** + * @return array + */ + public function __serialize(): array + { + return ['type' => $this->getFlexType(), 'entries' => $this->getEntries()]; + } + + /** + * @param array $data + * @return void + */ + public function __unserialize(array $data): void + { + $this->_flexDirectory = Grav::instance()['flex']->getDirectory($data['type']); + $this->setEntries($data['entries']); + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function __debugInfo() + { + return [ + 'type:private' => $this->getFlexType(), + 'key:private' => $this->getKey(), + 'entries_key:private' => $this->getKeyField(), + 'entries:private' => $this->getEntries() + ]; + } + + /** + * @param array $entries + * @param string|null $keyField + * @return static + * @phpstan-return static + */ + protected function createFrom(array $entries, string $keyField = null) + { + /** @phpstan-var static $index */ + $index = new static($entries, $this->getFlexDirectory()); + $index->setKeyField($keyField ?? $this->_keyField); + + return $index; + } + + /** + * @param string|null $keyField + * @return void + */ + protected function setKeyField(string $keyField = null) + { + $this->_keyField = $keyField ?? 'storage_key'; + } + + /** + * @return array + */ + protected function getIndexKeys() + { + if (null === $this->_indexKeys) { + $entries = $this->getEntries(); + $first = reset($entries); + if ($first) { + $keys = array_keys($first); + $keys = array_combine($keys, $keys) ?: []; + } else { + $keys = []; + } + + $this->setIndexKeys($keys); + } + + return $this->_indexKeys; + } + + /** + * @param array $indexKeys + * @return void + */ + protected function setIndexKeys(array $indexKeys) + { + // Add defaults. + $indexKeys += [ + 'key' => 'key', + 'storage_key' => 'storage_key', + 'storage_timestamp' => 'storage_timestamp', + 'flex_key' => 'flex_key' + ]; + + + $this->_indexKeys = $indexKeys; + } + + /** + * @return string + */ + protected function getTypePrefix() + { + return 'i.'; + } + + /** + * @param string $key + * @param mixed $value + * @return ObjectInterface|null + * @phpstan-return T|null + */ + protected function loadElement($key, $value): ?ObjectInterface + { + /** @phpstan-var T[] $objects */ + $objects = $this->getFlexDirectory()->loadObjects([$key => $value]); + + return $objects ? reset($objects): null; + } + + /** + * @param array|null $entries + * @return ObjectInterface[] + * @phpstan-return T[] + */ + protected function loadElements(array $entries = null): array + { + /** @phpstan-var T[] $objects */ + $objects = $this->getFlexDirectory()->loadObjects($entries ?? $this->getEntries()); + + return $objects; + } + + /** + * @param array|null $entries + * @return CollectionInterface + * @phpstan-return C + */ + protected function loadCollection(array $entries = null): CollectionInterface + { + /** @var C $collection */ + $collection = $this->getFlexDirectory()->loadCollection($entries ?? $this->getEntries(), $this->_keyField); + + return $collection; + } + + /** + * @param mixed $value + * @return bool + */ + protected function isAllowedElement($value): bool + { + return $value instanceof FlexObject; + } + + /** + * @param FlexObjectInterface $object + * @return mixed + */ + protected function getElementMeta($object) + { + return $object->getMetaData(); + } + + /** + * @param FlexObjectInterface $element + * @return string + */ + protected function getCurrentKey($element) + { + $keyField = $this->getKeyField(); + if ($keyField === 'storage_key') { + return $element->getStorageKey(); + } + if ($keyField === 'flex_key') { + return $element->getFlexKey(); + } + if ($keyField === 'key') { + return $element->getKey(); + } + + return $element->getKey(); + } + + /** + * @param FlexStorageInterface $storage + * @param array $index Saved index + * @param array $entries Updated index + * @param array $options + * @return array Compiled list of entries + */ + protected static function updateIndexFile(FlexStorageInterface $storage, array $index, array $entries, array $options = []): array + { + $indexFile = static::getIndexFile($storage); + if (null === $indexFile) { + return $entries; + } + + // Calculate removed objects. + $removed = array_diff_key($index, $entries); + + // First get rid of all removed objects. + if ($removed) { + $index = array_diff_key($index, $removed); + } + + if ($entries && empty($options['force_update'])) { + // Calculate difference between saved index and current data. + foreach ($index as $key => $entry) { + $storage_key = $entry['storage_key'] ?? null; + if (isset($entries[$storage_key]) && $entries[$storage_key]['storage_timestamp'] === $entry['storage_timestamp']) { + // Entry is up to date, no update needed. + unset($entries[$storage_key]); + } + } + + if (empty($entries) && empty($removed)) { + // No objects were added, updated or removed. + return $index; + } + } elseif (!$removed) { + // There are no objects and nothing was removed. + return []; + } + + // Index should be updated, lock the index file for saving. + $indexFile->lock(); + + // Read all the data rows into an array using chunks of 100. + $keys = array_fill_keys(array_keys($entries), null); + $chunks = array_chunk($keys, 100, true); + $updated = $added = []; + foreach ($chunks as $keys) { + $rows = $storage->readRows($keys); + + $keyField = $storage->getKeyField(); + + // Go through all the updated objects and refresh their index data. + foreach ($rows as $key => $row) { + if (null !== $row || !empty($options['include_missing'])) { + $entry = $entries[$key] + ['key' => $key]; + if ($keyField !== 'storage_key' && isset($row[$keyField])) { + $entry['key'] = $row[$keyField]; + } + static::updateObjectMeta($entry, $row ?? [], $storage); + if (isset($row['__ERROR'])) { + $entry['__ERROR'] = true; + static::onException(new RuntimeException(sprintf('Object failed to load: %s (%s)', $key, + $row['__ERROR']))); + } + if (isset($index[$key])) { + // Update object in the index. + $updated[$key] = $entry; + } else { + // Add object into the index. + $added[$key] = $entry; + } + + // Either way, update the entry. + $index[$key] = $entry; + } elseif (isset($index[$key])) { + // Remove object from the index. + $removed[$key] = $index[$key]; + unset($index[$key]); + } + } + unset($rows); + } + + // Sort the index before saving it. + ksort($index, SORT_NATURAL | SORT_FLAG_CASE); + + static::onChanges($index, $added, $updated, $removed); + + $indexFile->save(['version' => static::VERSION, 'timestamp' => time(), 'count' => count($index), 'index' => $index]); + $indexFile->unlock(); + + return $index; + } + + /** + * @param array $entry + * @param array $data + * @return void + * @deprecated 1.7 Use static ::updateObjectMeta() method instead. + */ + protected static function updateIndexData(array &$entry, array $data) + { + } + + /** + * @param FlexStorageInterface $storage + * @return array + */ + protected static function loadIndex(FlexStorageInterface $storage) + { + $indexFile = static::getIndexFile($storage); + + if ($indexFile) { + $data = []; + try { + $data = (array)$indexFile->content(); + $version = $data['version'] ?? null; + if ($version !== static::VERSION) { + $data = []; + } + } catch (Exception $e) { + $e = new RuntimeException(sprintf('Index failed to load: %s', $e->getMessage()), $e->getCode(), $e); + + static::onException($e); + } + + if ($data) { + return $data; + } + } + + return ['version' => static::VERSION, 'timestamp' => 0, 'count' => 0, 'index' => []]; + } + + /** + * @param FlexStorageInterface $storage + * @return array + */ + protected static function loadEntriesFromIndex(FlexStorageInterface $storage) + { + $data = static::loadIndex($storage); + + return $data['index'] ?? []; + } + + /** + * @param FlexStorageInterface $storage + * @return CompiledYamlFile|CompiledJsonFile|null + */ + protected static function getIndexFile(FlexStorageInterface $storage) + { + if (!method_exists($storage, 'isIndexed') || !$storage->isIndexed()) { + return null; + } + + $path = $storage->getStoragePath(); + if (!$path) { + return null; + } + + // Load saved index file. + $grav = Grav::instance(); + $locator = $grav['locator']; + $filename = $locator->findResource("{$path}/index.yaml", true, true); + + return CompiledYamlFile::instance($filename); + } + + /** + * @param Exception $e + * @return void + */ + protected static function onException(Exception $e) + { + $grav = Grav::instance(); + + /** @var Logger $logger */ + $logger = $grav['log']; + $logger->addAlert($e->getMessage()); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addException($e); + $debugger->addMessage($e, 'error'); + } + + /** + * @param array $entries + * @param array $added + * @param array $updated + * @param array $removed + * @return void + */ + protected static function onChanges(array $entries, array $added, array $updated, array $removed) + { + $addedCount = count($added); + $updatedCount = count($updated); + $removedCount = count($removed); + + if ($addedCount + $updatedCount + $removedCount) { + $message = sprintf('Index updated, %d objects (%d added, %d updated, %d removed).', count($entries), $addedCount, $updatedCount, $removedCount); + + $grav = Grav::instance(); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addMessage($message, 'debug'); + } + } + + // DEPRECATED METHODS + + /** + * @param bool $prefix + * @return string + * @deprecated 1.6 Use `->getFlexType()` instead. + */ + public function getType($prefix = false) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); + + $type = $prefix ? $this->getTypePrefix() : ''; + + return $type . $this->getFlexType(); + } +} diff --git a/system/src/Grav/Framework/Flex/FlexObject.php b/system/src/Grav/Framework/Flex/FlexObject.php new file mode 100644 index 0000000..600c198 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexObject.php @@ -0,0 +1,1288 @@ + true, + 'getType' => true, + 'getFlexType' => true, + 'getFlexDirectory' => true, + 'hasFlexFeature' => true, + 'getFlexFeatures' => true, + 'getCacheKey' => true, + 'getCacheChecksum' => false, + 'getTimestamp' => true, + 'value' => true, + 'exists' => true, + 'hasProperty' => true, + 'getProperty' => true, + + // FlexAclTrait + 'isAuthorized' => 'session', + ]; + } + + /** + * @param array $elements + * @param array $storage + * @param FlexDirectory $directory + * @param bool $validate + * @return static + */ + public static function createFromStorage(array $elements, array $storage, FlexDirectory $directory, bool $validate = false) + { + $instance = new static($elements, $storage['key'], $directory, $validate); + $instance->setMetaData($storage); + + return $instance; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::__construct() + */ + public function __construct(array $elements, $key, FlexDirectory $directory, bool $validate = false) + { + if (get_class($this) === __CLASS__) { + user_error('Using ' . __CLASS__ . ' directly is deprecated since Grav 1.7, use \Grav\Common\Flex\Types\Generic\GenericObject or your own class instead', E_USER_DEPRECATED); + } + + $this->_flexDirectory = $directory; + + if (isset($elements['__META'])) { + $this->setMetaData($elements['__META']); + unset($elements['__META']); + } + + if ($validate) { + $blueprint = $this->getFlexDirectory()->getBlueprint(); + + $blueprint->validate($elements, ['xss_check' => false]); + + $elements = $blueprint->filter($elements, true, true); + } + + $this->filterElements($elements); + + $this->objectConstruct($elements, $key); + } + + /** + * {@inheritdoc} + * @see FlexCommonInterface::hasFlexFeature() + */ + public function hasFlexFeature(string $name): bool + { + return in_array($name, $this->getFlexFeatures(), true); + } + + /** + * {@inheritdoc} + * @see FlexCommonInterface::hasFlexFeature() + */ + public function getFlexFeatures(): array + { + /** @var array $implements */ + $implements = class_implements($this); + + $list = []; + foreach ($implements as $interface) { + if ($pos = strrpos($interface, '\\')) { + $interface = substr($interface, $pos+1); + } + + $list[] = Inflector::hyphenize(str_replace('Interface', '', $interface)); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getFlexType() + */ + public function getFlexType(): string + { + return $this->_flexDirectory->getFlexType(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getFlexDirectory() + */ + public function getFlexDirectory(): FlexDirectory + { + return $this->_flexDirectory; + } + + /** + * Refresh object from the storage. + * + * @param bool $keepMissing + * @return bool True if the object was refreshed + */ + public function refresh(bool $keepMissing = false): bool + { + $key = $this->getStorageKey(); + if ('' === $key) { + return false; + } + + $storage = $this->getFlexDirectory()->getStorage(); + $meta = $storage->getMetaData([$key])[$key] ?? null; + + $newChecksum = $meta['checksum'] ?? $meta['storage_timestamp'] ?? null; + $curChecksum = $this->_meta['checksum'] ?? $this->_meta['storage_timestamp'] ?? null; + + // Check if object is up to date with the storage. + if (null === $newChecksum || $newChecksum === $curChecksum) { + return false; + } + + // Get current elements (if requested). + $current = $keepMissing ? $this->getElements() : []; + // Get elements from the filesystem. + $elements = $storage->readRows([$key => null])[$key] ?? null; + if (null !== $elements) { + $meta = $elements['__META'] ?? $meta; + unset($elements['__META']); + $this->filterElements($elements); + $newKey = $meta['key'] ?? $this->getKey(); + if ($meta) { + $this->setMetaData($meta); + } + $this->objectConstruct($elements, $newKey); + + if ($current) { + // Inject back elements which are missing in the filesystem. + $data = $this->getBlueprint()->flattenData($current); + foreach ($data as $property => $value) { + if (strpos($property, '.') === false) { + $this->defProperty($property, $value); + } else { + $this->defNestedProperty($property, $value); + } + } + } + + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addMessage("Refreshed {$this->getFlexType()} object {$this->getKey()}", 'debug'); + } + + return true; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getTimestamp() + */ + public function getTimestamp(): int + { + return $this->_meta['storage_timestamp'] ?? 0; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getCacheKey() + */ + public function getCacheKey(): string + { + return $this->hasKey() ? $this->getTypePrefix() . $this->getFlexType() . '.' . $this->getKey() : ''; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getCacheChecksum() + */ + public function getCacheChecksum(): string + { + return (string)($this->_meta['checksum'] ?? $this->getTimestamp()); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::search() + */ + public function search(string $search, $properties = null, array $options = null): float + { + $directory = $this->getFlexDirectory(); + $properties = $directory->getSearchProperties($properties); + $options = $directory->getSearchOptions($options); + + $weight = 0; + foreach ($properties as $property) { + if (strpos($property, '.')) { + $weight += $this->searchNestedProperty($property, $search, $options); + } else { + $weight += $this->searchProperty($property, $search, $options); + } + } + + return $weight > 0 ? min($weight, 1) : 0; + } + + /** + * {@inheritdoc} + * @see ObjectInterface::getFlexKey() + */ + public function getKey() + { + return (string)$this->_key; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getFlexKey() + */ + public function getFlexKey(): string + { + $key = $this->_meta['flex_key'] ?? null; + + if (!$key && $key = $this->getStorageKey()) { + $key = $this->_flexDirectory->getFlexType() . '.obj:' . $key; + } + + return (string)$key; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getStorageKey() + */ + public function getStorageKey(): string + { + return (string)($this->storage_key ?? $this->_meta['storage_key'] ?? null); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getMetaData() + */ + public function getMetaData(): array + { + return $this->_meta ?? []; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::exists() + */ + public function exists(): bool + { + $key = $this->getStorageKey(); + + return $key && $this->getFlexDirectory()->getStorage()->hasKey($key); + } + + /** + * @param string $property + * @param string $search + * @param array|null $options + * @return float + */ + public function searchProperty(string $property, string $search, array $options = null): float + { + $options = $options ?? (array)$this->getFlexDirectory()->getConfig('data.search.options'); + $value = $this->getProperty($property); + + return $this->searchValue($property, $value, $search, $options); + } + + /** + * @param string $property + * @param string $search + * @param array|null $options + * @return float + */ + public function searchNestedProperty(string $property, string $search, array $options = null): float + { + $options = $options ?? (array)$this->getFlexDirectory()->getConfig('data.search.options'); + if ($property === 'key') { + $value = $this->getKey(); + } else { + $value = $this->getNestedProperty($property); + } + + return $this->searchValue($property, $value, $search, $options); + } + + /** + * @param string $name + * @param mixed $value + * @param string $search + * @param array|null $options + * @return float + */ + protected function searchValue(string $name, $value, string $search, array $options = null): float + { + $options = $options ?? []; + + // Ignore empty search strings. + $search = trim($search); + if ($search === '') { + return 0; + } + + // Search only non-empty string values. + if (!is_string($value) || $value === '') { + return 0; + } + + $caseSensitive = $options['case_sensitive'] ?? false; + + $tested = false; + if (($tested |= !empty($options['same_as']))) { + if ($caseSensitive) { + if ($value === $search) { + return (float)$options['same_as']; + } + } elseif (mb_strtolower($value) === mb_strtolower($search)) { + return (float)$options['same_as']; + } + } + if (($tested |= !empty($options['starts_with'])) && Utils::startsWith($value, $search, $caseSensitive)) { + return (float)$options['starts_with']; + } + if (($tested |= !empty($options['ends_with'])) && Utils::endsWith($value, $search, $caseSensitive)) { + return (float)$options['ends_with']; + } + if ((!$tested || !empty($options['contains'])) && Utils::contains($value, $search, $caseSensitive)) { + return (float)($options['contains'] ?? 1); + } + + return 0; + } + + /** + * Get original data before update + * + * @return array + */ + public function getOriginalData(): array + { + return $this->_original ?? []; + } + + /** + * Get diff array from the object. + * + * @return array + */ + public function getDiff(): array + { + $blueprint = $this->getBlueprint(); + + $flattenOriginal = $blueprint->flattenData($this->getOriginalData()); + $flattenElements = $blueprint->flattenData($this->getElements()); + $removedElements = array_diff_key($flattenOriginal, $flattenElements); + $diff = []; + + // Include all added or changed keys. + foreach ($flattenElements as $key => $value) { + $orig = $flattenOriginal[$key] ?? null; + if ($orig !== $value) { + $diff[$key] = ['old' => $orig, 'new' => $value]; + } + } + + // Include all removed keys. + foreach ($removedElements as $key => $value) { + $diff[$key] = ['old' => $value, 'new' => null]; + } + + return $diff; + } + + /** + * Get any changes from the object. + * + * @return array + */ + public function getChanges(): array + { + $diff = $this->getDiff(); + + $data = new Data(); + foreach ($diff as $key => $change) { + $data->set($key, $change['new']); + } + + return $data->toArray(); + } + + /** + * @return string + */ + protected function getTypePrefix(): string + { + return 'o.'; + } + + /** + * Alias of getBlueprint() + * + * @return Blueprint + * @deprecated 1.6 Admin compatibility + */ + public function blueprints() + { + return $this->getBlueprint(); + } + + /** + * @param string|null $namespace + * @return CacheInterface + */ + public function getCache(string $namespace = null) + { + return $this->_flexDirectory->getCache($namespace); + } + + /** + * @param string|null $key + * @return $this + */ + public function setStorageKey($key = null) + { + $this->storage_key = $key ?? ''; + + return $this; + } + + /** + * @param int $timestamp + * @return $this + */ + public function setTimestamp($timestamp = null) + { + $this->storage_timestamp = $timestamp ?? time(); + + return $this; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::render() + */ + public function render(string $layout = null, array $context = []) + { + if (!$layout) { + $config = $this->getTemplateConfig(); + $layout = $config['object']['defaults']['layout'] ?? 'default'; + } + + $type = $this->getFlexType(); + + $grav = Grav::instance(); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->startTimer('flex-object-' . ($debugKey = uniqid($type, false)), 'Render Object ' . $type . ' (' . $layout . ')'); + + $key = $this->getCacheKey(); + + // Disable caching if context isn't all scalars. + if ($key) { + foreach ($context as $value) { + if (!is_scalar($value)) { + $key = ''; + break; + } + } + } + + if ($key) { + // Create a new key which includes layout and context. + $key = md5($key . '.' . $layout . json_encode($context)); + $cache = $this->getCache('render'); + } else { + $cache = null; + } + + try { + $data = $cache ? $cache->get($key) : null; + + $block = $data ? HtmlBlock::fromArray($data) : null; + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + + $block = null; + } catch (\InvalidArgumentException $e) { + $debugger->addException($e); + + $block = null; + } + + $checksum = $this->getCacheChecksum(); + if ($block && $checksum !== $block->getChecksum()) { + $block = null; + } + + if (!$block) { + $block = HtmlBlock::create($key ?: null); + $block->setChecksum($checksum); + if (!$cache) { + $block->disableCache(); + } + + $event = new Event([ + 'type' => 'flex', + 'directory' => $this->getFlexDirectory(), + 'object' => $this, + 'layout' => &$layout, + 'context' => &$context + ]); + $this->triggerEvent('onRender', $event); + + $output = $this->getTemplate($layout)->render( + [ + 'grav' => $grav, + 'config' => $grav['config'], + 'block' => $block, + 'directory' => $this->getFlexDirectory(), + 'object' => $this, + 'layout' => $layout + ] + $context + ); + + if ($debugger->enabled() && + !($grav['uri']->getContentType() === 'application/json' || $grav['uri']->extension() === 'json')) { + $name = $this->getKey() . ' (' . $type . ')'; + $output = "\n\n{$output}\n\n"; + } + + $block->setContent($output); + + try { + $cache && $block->isCached() && $cache->set($key, $block->toArray()); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + } + } + + $debugger->stopTimer('flex-object-' . $debugKey); + + return $block; + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->getElements(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::prepareStorage() + */ + public function prepareStorage(): array + { + return ['__META' => $this->getMetaData()] + $this->getElements(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::update() + */ + public function update(array $data, array $files = []) + { + if ($data) { + // Get currently stored data. + $elements = $this->getElements(); + + // Store original version of the object. + if ($this->_original === null) { + $this->_original = $elements; + } + + $blueprint = $this->getBlueprint(); + + // Process updated data through the object filters. + $this->filterElements($data); + + // Merge existing object to the test data to be validated. + $test = $blueprint->mergeData($elements, $data); + + // Validate and filter elements and throw an error if any issues were found. + $blueprint->validate($test + ['storage_key' => $this->getStorageKey(), 'timestamp' => $this->getTimestamp()], ['xss_check' => false]); + $data = $blueprint->filter($data, true, true); + + // Finally update the object. + $flattenData = $blueprint->flattenData($data); + foreach ($flattenData as $key => $value) { + if ($value === null) { + $this->unsetNestedProperty($key); + } else { + $this->setNestedProperty($key, $value); + } + } + } + + if ($files && method_exists($this, 'setUpdatedMedia')) { + $this->setUpdatedMedia($files); + } + + return $this; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::create() + */ + public function create(string $key = null) + { + if ($key) { + $this->setStorageKey($key); + } + + if ($this->exists()) { + throw new RuntimeException('Cannot create new object (Already exists)'); + } + + return $this->save(); + } + + /** + * @param string|null $key + * @return FlexObject|FlexObjectInterface + */ + public function createCopy(string $key = null) + { + $this->markAsCopy(); + + return $this->create($key); + } + + /** + * @param UserInterface|null $user + */ + public function check(UserInterface $user = null): void + { + // If user has been provided, check if the user has permissions to save this object. + if ($user && !$this->isAuthorized('save', null, $user)) { + throw new \RuntimeException('Forbidden', 403); + } + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::save() + */ + public function save() + { + $this->triggerEvent('onBeforeSave'); + + $storage = $this->getFlexDirectory()->getStorage(); + + $storageKey = $this->getStorageKey() ?: '@@' . spl_object_hash($this); + + $result = $storage->replaceRows([$storageKey => $this->prepareStorage()]); + + if (method_exists($this, 'clearMediaCache')) { + $this->clearMediaCache(); + } + + $value = reset($result); + $meta = $value['__META'] ?? null; + if ($meta) { + /** @phpstan-var class-string $indexClass */ + $indexClass = $this->getFlexDirectory()->getIndexClass(); + $indexClass::updateObjectMeta($meta, $value, $storage); + $this->_meta = $meta; + } + + if ($value) { + $storageKey = $meta['storage_key'] ?? (string)key($result); + if ($storageKey !== '') { + $this->setStorageKey($storageKey); + } + + $newKey = $meta['key'] ?? ($this->hasKey() ? $this->getKey() : null); + $this->setKey($newKey ?? $storageKey); + } + + // FIXME: For some reason locator caching isn't cleared for the file, investigate! + $locator = Grav::instance()['locator']; + $locator->clearCache(); + + if (method_exists($this, 'saveUpdatedMedia')) { + $this->saveUpdatedMedia(); + } + + try { + $this->getFlexDirectory()->reloadIndex(); + if (method_exists($this, 'clearMediaCache')) { + $this->clearMediaCache(); + } + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + // Caching failed, but we can ignore that for now. + } + + $this->triggerEvent('onAfterSave'); + + return $this; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::delete() + */ + public function delete() + { + if (!$this->exists()) { + return $this; + } + + $this->triggerEvent('onBeforeDelete'); + + $this->getFlexDirectory()->getStorage()->deleteRows([$this->getStorageKey() => $this->prepareStorage()]); + + try { + $this->getFlexDirectory()->reloadIndex(); + if (method_exists($this, 'clearMediaCache')) { + $this->clearMediaCache(); + } + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + // Caching failed, but we can ignore that for now. + } + + $this->triggerEvent('onAfterDelete'); + + return $this; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getBlueprint() + */ + public function getBlueprint(string $name = '') + { + if (!isset($this->_blueprint[$name])) { + $blueprint = $this->doGetBlueprint($name); + $blueprint->setScope('object'); + $blueprint->setObject($this); + + $this->_blueprint[$name] = $blueprint->init(); + } + + return $this->_blueprint[$name]; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getForm() + */ + public function getForm(string $name = '', array $options = null) + { + $hash = $name . '-' . md5(json_encode($options, JSON_THROW_ON_ERROR)); + if (!isset($this->_forms[$hash])) { + $this->_forms[$hash] = $this->createFormObject($name, $options); + } + + return $this->_forms[$hash]; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getDefaultValue() + */ + public function getDefaultValue(string $name, string $separator = null) + { + $separator = $separator ?: '.'; + $path = explode($separator, $name); + $offset = array_shift($path); + + $current = $this->getDefaultValues(); + + if (!isset($current[$offset])) { + return null; + } + + $current = $current[$offset]; + + while ($path) { + $offset = array_shift($path); + + if ((is_array($current) || $current instanceof ArrayAccess) && isset($current[$offset])) { + $current = $current[$offset]; + } elseif (is_object($current) && isset($current->{$offset})) { + $current = $current->{$offset}; + } else { + return null; + } + }; + + return $current; + } + + /** + * @return array + */ + public function getDefaultValues(): array + { + return $this->getBlueprint()->getDefaults(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getFormValue() + */ + public function getFormValue(string $name, $default = null, string $separator = null) + { + if ($name === 'storage_key') { + return $this->getStorageKey(); + } + if ($name === 'storage_timestamp') { + return $this->getTimestamp(); + } + + return $this->getNestedProperty($name, $default, $separator); + } + + /** + * @param FlexDirectory $directory + */ + public function setFlexDirectory(FlexDirectory $directory): void + { + $this->_flexDirectory = $directory; + } + + /** + * Returns a string representation of this object. + * + * @return string + */ + #[\ReturnTypeWillChange] + public function __toString() + { + return $this->getFlexKey(); + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function __debugInfo() + { + return [ + 'type:private' => $this->getFlexType(), + 'storage_key:protected' => $this->getStorageKey(), + 'storage_timestamp:protected' => $this->getTimestamp(), + 'key:private' => $this->getKey(), + 'elements:private' => $this->getElements(), + 'storage:private' => $this->getMetaData() + ]; + } + + /** + * Clone object. + */ + #[\ReturnTypeWillChange] + public function __clone() + { + // Allows future compatibility as parent::__clone() works. + } + + protected function markAsCopy(): void + { + $meta = $this->getMetaData(); + $meta['copy'] = true; + $this->_meta = $meta; + } + + /** + * @param string $name + * @return Blueprint + */ + protected function doGetBlueprint(string $name = ''): Blueprint + { + return $this->_flexDirectory->getBlueprint($name ? '.' . $name : $name); + } + + /** + * @param array $meta + */ + protected function setMetaData(array $meta): void + { + $this->_meta = $meta; + } + + /** + * @return array + */ + protected function doSerialize(): array + { + return [ + 'type' => $this->getFlexType(), + 'key' => $this->getKey(), + 'elements' => $this->getElements(), + 'storage' => $this->getMetaData() + ]; + } + + /** + * @param array $serialized + * @param FlexDirectory|null $directory + * @return void + */ + protected function doUnserialize(array $serialized, FlexDirectory $directory = null): void + { + $type = $serialized['type'] ?? 'unknown'; + + if (!isset($serialized['key'], $serialized['type'], $serialized['elements'])) { + throw new \InvalidArgumentException("Cannot unserialize '{$type}': Bad data"); + } + + if (null === $directory) { + $directory = $this->getFlexContainer()->getDirectory($type); + if (!$directory) { + throw new \InvalidArgumentException("Cannot unserialize Flex type '{$type}': Directory not found"); + } + } + + $this->setFlexDirectory($directory); + $this->setMetaData($serialized['storage']); + $this->setKey($serialized['key']); + $this->setElements($serialized['elements']); + } + + /** + * @return array + */ + protected function getTemplateConfig() + { + $config = $this->getFlexDirectory()->getConfig('site.templates', []); + $defaults = array_replace($config['defaults'] ?? [], $config['object']['defaults'] ?? []); + $config['object']['defaults'] = $defaults; + + return $config; + } + + /** + * @param string $layout + * @return array + */ + protected function getTemplatePaths(string $layout): array + { + $config = $this->getTemplateConfig(); + $type = $this->getFlexType(); + $defaults = $config['object']['defaults'] ?? []; + + $ext = $defaults['ext'] ?? '.html.twig'; + $types = array_unique(array_merge([$type], (array)($defaults['type'] ?? null))); + $paths = $config['object']['paths'] ?? [ + 'flex/{TYPE}/object/{LAYOUT}{EXT}', + 'flex-objects/layouts/{TYPE}/object/{LAYOUT}{EXT}' + ]; + $table = ['TYPE' => '%1$s', 'LAYOUT' => '%2$s', 'EXT' => '%3$s']; + + $lookups = []; + foreach ($paths as $path) { + $path = Utils::simpleTemplate($path, $table); + foreach ($types as $type) { + $lookups[] = sprintf($path, $type, $layout, $ext); + } + } + + return array_unique($lookups); + } + + /** + * Filter data coming to constructor or $this->update() request. + * + * NOTE: The incoming data can be an arbitrary array so do not assume anything from its content. + * + * @param array $elements + */ + protected function filterElements(array &$elements): void + { + if (isset($elements['storage_key'])) { + $elements['storage_key'] = trim($elements['storage_key']); + } + if (isset($elements['storage_timestamp'])) { + $elements['storage_timestamp'] = (int)$elements['storage_timestamp']; + } + + unset($elements['_post_entries_save']); + } + + /** + * This methods allows you to override form objects in child classes. + * + * @param string $name Form name + * @param array|null $options Form optiosn + * @return FlexFormInterface + */ + protected function createFormObject(string $name, array $options = null) + { + return new FlexForm($name, $this, $options); + } + + /** + * @param string $action + * @return string + */ + protected function getAuthorizeAction(string $action): string + { + // Handle special action save, which can mean either update or create. + if ($action === 'save') { + $action = $this->exists() ? 'update' : 'create'; + } + + return $action; + } + + /** + * Method to reset blueprints if the type changes. + * + * @return void + * @since 1.7.18 + */ + protected function resetBlueprints(): void + { + $this->_blueprint = []; + } + + // DEPRECATED METHODS + + /** + * @param bool $prefix + * @return string + * @deprecated 1.6 Use `->getFlexType()` instead. + */ + public function getType($prefix = false) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); + + $type = $prefix ? $this->getTypePrefix() : ''; + + return $type . $this->getFlexType(); + } + + /** + * @param string $name + * @param mixed|null $default + * @param string|null $separator + * @return mixed + * + * @deprecated 1.6 Use ->getFormValue() method instead. + */ + public function value($name, $default = null, $separator = null) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.6, use ->getFormValue() method instead', E_USER_DEPRECATED); + + return $this->getFormValue($name, $default, $separator); + } + + /** + * @param string $name + * @param object|null $event + * @return $this + * @deprecated 1.7 Moved to \Grav\Common\Flex\Traits\FlexObjectTrait + */ + public function triggerEvent(string $name, $event = null) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \Grav\Common\Flex\Traits\FlexObjectTrait', E_USER_DEPRECATED); + + if (null === $event) { + $event = new Event([ + 'type' => 'flex', + 'directory' => $this->getFlexDirectory(), + 'object' => $this + ]); + } + if (strpos($name, 'onFlexObject') !== 0 && strpos($name, 'on') === 0) { + $name = 'onFlexObject' . substr($name, 2); + } + + $grav = Grav::instance(); + if ($event instanceof Event) { + $grav->fireEvent($name, $event); + } else { + $grav->dispatchEvent($event); + } + + return $this; + } + + /** + * @param array $storage + * @deprecated 1.7 Use `->setMetaData()` instead. + */ + protected function setStorage(array $storage): void + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use ->setMetaData() method instead', E_USER_DEPRECATED); + + $this->setMetaData($storage); + } + + /** + * @return array + * @deprecated 1.7 Use `->getMetaData()` instead. + */ + protected function getStorage(): array + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use ->getMetaData() method instead', E_USER_DEPRECATED); + + return $this->getMetaData(); + } + + /** + * @param string $layout + * @return Template|TemplateWrapper + * @throws LoaderError + * @throws SyntaxError + * @deprecated 1.7 Moved to \Grav\Common\Flex\Traits\GravTrait + */ + protected function getTemplate($layout) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \Grav\Common\Flex\Traits\GravTrait', E_USER_DEPRECATED); + + $grav = Grav::instance(); + + /** @var Twig $twig */ + $twig = $grav['twig']; + + try { + return $twig->twig()->resolveTemplate($this->getTemplatePaths($layout)); + } catch (LoaderError $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + return $twig->twig()->resolveTemplate(['flex/404.html.twig']); + } + } + + /** + * @return Flex + * @deprecated 1.7 Moved to \Grav\Common\Flex\Traits\GravTrait + */ + protected function getFlexContainer(): Flex + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \Grav\Common\Flex\Traits\GravTrait', E_USER_DEPRECATED); + + /** @var Flex $flex */ + $flex = Grav::instance()['flex']; + + return $flex; + } + + /** + * @return UserInterface|null + * @deprecated 1.7 Moved to \Grav\Common\Flex\Traits\GravTrait + */ + protected function getActiveUser(): ?UserInterface + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \Grav\Common\Flex\Traits\GravTrait', E_USER_DEPRECATED); + + /** @var UserInterface|null $user */ + $user = Grav::instance()['user'] ?? null; + + return $user; + } + + /** + * @return string + * @deprecated 1.7 Moved to \Grav\Common\Flex\Traits\GravTrait + */ + protected function getAuthorizeScope(): string + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \Grav\Common\Flex\Traits\GravTrait', E_USER_DEPRECATED); + + return isset(Grav::instance()['admin']) ? 'admin' : 'site'; + } +} diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php new file mode 100644 index 0000000..2fd6c7e --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php @@ -0,0 +1,33 @@ + + */ +interface FlexCollectionInterface extends FlexCommonInterface, ObjectCollectionInterface, NestedObjectInterface +{ + /** + * Creates a Flex Collection from an array. + * + * @used-by FlexDirectory::createCollection() Official method to create a Flex Collection. + * + * @param FlexObjectInterface[] $entries Associated array of Flex Objects to be included in the collection. + * @param FlexDirectory $directory Flex Directory where all the objects belong into. + * @param string|null $keyField Key field used to index the collection. + * @return static Returns a new Flex Collection. + */ + public static function createFromArray(array $entries, FlexDirectory $directory, string $keyField = null); + + /** + * Creates a new Flex Collection. + * + * @used-by FlexDirectory::createCollection() Official method to create Flex Collection. + * + * @param FlexObjectInterface[] $entries Associated array of Flex Objects to be included in the collection. + * @param FlexDirectory|null $directory Flex Directory where all the objects belong into. + * @throws InvalidArgumentException + */ + public function __construct(array $entries = [], FlexDirectory $directory = null); + + /** + * Search a string from the collection. + * + * @param string $search Search string. + * @param string|string[]|null $properties Properties to search for, defaults to configured properties. + * @param array|null $options Search options, defaults to configured options. + * @return FlexCollectionInterface Returns a Flex Collection with only matching objects. + * @phpstan-return static + * @api + */ + public function search(string $search, $properties = null, array $options = null); + + /** + * Sort the collection. + * + * @param array $orderings Pair of [property => 'ASC'|'DESC', ...]. + * + * @return FlexCollectionInterface Returns a sorted version from the collection. + * @phpstan-return static + */ + public function sort(array $orderings); + + /** + * Filter collection by filter array with keys and values. + * + * @param array $filters + * @return FlexCollectionInterface + * @phpstan-return static + */ + public function filterBy(array $filters); + + /** + * Get timestamps from all the objects in the collection. + * + * This method can be used for example in caching. + * + * @return int[] Returns [key => timestamp, ...] pairs. + */ + public function getTimestamps(): array; + + /** + * Get storage keys from all the objects in the collection. + * + * @see FlexDirectory::getObject() If you want to get Flex Object from the Flex Directory. + * + * @return string[] Returns [key => storage_key, ...] pairs. + */ + public function getStorageKeys(): array; + + /** + * Get Flex keys from all the objects in the collection. + * + * @see Flex::getObjects() If you want to get list of Flex Objects from any Flex Directory. + * + * @return string[] Returns[key => flex_key, ...] pairs. + */ + public function getFlexKeys(): array; + + /** + * Return new collection with a different key. + * + * @param string|null $keyField Switch key field of the collection. + * @return FlexCollectionInterface Returns a new Flex Collection with new key field. + * @phpstan-return static + * @api + */ + public function withKeyField(string $keyField = null); + + /** + * Get Flex Index from the Flex Collection. + * + * @return FlexIndexInterface Returns a Flex Index from the current collection. + * @phpstan-return FlexIndexInterface + */ + public function getIndex(); + + /** + * Load all the objects into memory, + * + * @return FlexCollectionInterface + * @phpstan-return static + */ + public function getCollection(); + + /** + * Get metadata associated to the object + * + * @param string $key Key. + * @return array + */ + public function getMetaData($key): array; +} diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexCommonInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexCommonInterface.php new file mode 100644 index 0000000..181bf2a --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexCommonInterface.php @@ -0,0 +1,79 @@ +getBlueprint() or $object->getForm()->getBlueprint() instead. + * + * @param string $type + * @param string $context + * @return Blueprint + */ + public function getBlueprint(string $type = '', string $context = ''); + + /** + * @param string $view + * @return string + */ + public function getBlueprintFile(string $view = ''): string; + + /** + * Get collection. In the site this will be filtered by the default filters (published etc). + * + * Use $directory->getIndex() if you want unfiltered collection. + * + * @param array|null $keys Array of keys. + * @param string|null $keyField Field to be used as the key. + * @return FlexCollectionInterface + * @phpstan-return FlexCollectionInterface + */ + public function getCollection(array $keys = null, string $keyField = null): FlexCollectionInterface; + + /** + * Get the full collection of all stored objects. + * + * Use $directory->getCollection() if you want a filtered collection. + * + * @param array|null $keys Array of keys. + * @param string|null $keyField Field to be used as the key. + * @return FlexIndexInterface + * @phpstan-return FlexIndexInterface + */ + public function getIndex(array $keys = null, string $keyField = null): FlexIndexInterface; + + /** + * Returns an object if it exists. If no arguments are passed (or both of them are null), method creates a new empty object. + * + * Note: It is not safe to use the object without checking if the user can access it. + * + * @param string|null $key + * @param string|null $keyField Field to be used as the key. + * @return FlexObjectInterface|null + */ + public function getObject($key = null, string $keyField = null): ?FlexObjectInterface; + + /** + * @param string|null $namespace + * @return CacheInterface + */ + public function getCache(string $namespace = null); + + /** + * @return $this + */ + public function clearCache(); + + /** + * @param string|null $key + * @return string|null + */ + public function getStorageFolder(string $key = null): ?string; + + /** + * @param string|null $key + * @return string|null + */ + public function getMediaFolder(string $key = null): ?string; + + /** + * @return FlexStorageInterface + */ + public function getStorage(): FlexStorageInterface; + + /** + * @param array $data + * @param string $key + * @param bool $validate + * @return FlexObjectInterface + */ + public function createObject(array $data, string $key = '', bool $validate = false): FlexObjectInterface; + + /** + * @param array $entries + * @param string|null $keyField + * @return FlexCollectionInterface + * @phpstan-return FlexCollectionInterface + */ + public function createCollection(array $entries, string $keyField = null): FlexCollectionInterface; + + /** + * @param array $entries + * @param string|null $keyField + * @return FlexIndexInterface + * @phpstan-return FlexIndexInterface + */ + public function createIndex(array $entries, string $keyField = null): FlexIndexInterface; + + /** + * @return string + */ + public function getObjectClass(): string; + + /** + * @return string + */ + public function getCollectionClass(): string; + + /** + * @return string + */ + public function getIndexClass(): string; + + /** + * @param array $entries + * @param string|null $keyField + * @return FlexCollectionInterface + * @phpstan-return FlexCollectionInterface + */ + public function loadCollection(array $entries, string $keyField = null): FlexCollectionInterface; + + /** + * @param array $entries + * @return FlexObjectInterface[] + * @internal + */ + public function loadObjects(array $entries): array; + + /** + * @return void + */ + public function reloadIndex(): void; + + /** + * @param string $scope + * @param string $action + * @return string + */ + public function getAuthorizeRule(string $scope, string $action): string; +} diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexFormInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexFormInterface.php new file mode 100644 index 0000000..c2e8453 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexFormInterface.php @@ -0,0 +1,51 @@ + + */ +interface FlexIndexInterface extends FlexCollectionInterface +{ + /** + * Helper method to create Flex Index. + * + * @used-by FlexDirectory::getIndex() Official method to get Index from a Flex Directory. + * + * @param FlexDirectory $directory Flex directory. + * @return static Returns a new Flex Index. + */ + public static function createFromStorage(FlexDirectory $directory); + + /** + * Method to load index from the object storage, usually filesystem. + * + * @used-by FlexDirectory::getIndex() Official method to get Index from a Flex Directory. + * + * @param FlexStorageInterface $storage Flex Storage associated to the directory. + * @return array Returns a list of existing objects [storage_key => [storage_key => xxx, storage_timestamp => 123456, ...]] + */ + public static function loadEntriesFromStorage(FlexStorageInterface $storage): array; + + /** + * Return new collection with a different key. + * + * @param string|null $keyField Switch key field of the collection. + * @return static Returns a new Flex Collection with new key field. + * @phpstan-return static + * @api + */ + public function withKeyField(string $keyField = null); + + /** + * @param string|null $indexKey + * @return array + */ + public function getIndexMap(string $indexKey = null); +} diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexInterface.php new file mode 100644 index 0000000..4e38163 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexInterface.php @@ -0,0 +1,100 @@ + + */ + public function getDirectories(array $types = null, bool $keepMissing = false): array; + + /** + * @param string $type + * @return FlexDirectory|null + */ + public function getDirectory(string $type): ?FlexDirectory; + + /** + * @param string $type + * @param array|null $keys + * @param string|null $keyField + * @return FlexCollectionInterface|null + * @phpstan-return FlexCollectionInterface|null + */ + public function getCollection(string $type, array $keys = null, string $keyField = null): ?FlexCollectionInterface; + + /** + * @param array $keys + * @param array $options In addition to the options in getObjects(), following options can be passed: + * collection_class: Class to be used to create the collection. Defaults to ObjectCollection. + * @return FlexCollectionInterface + * @throws RuntimeException + * @phpstan-return FlexCollectionInterface + */ + public function getMixedCollection(array $keys, array $options = []): FlexCollectionInterface; + + /** + * @param array $keys + * @param array $options Following optional options can be passed: + * types: List of allowed types. + * type: Allowed type if types isn't defined, otherwise acts as default_type. + * default_type: Set default type for objects given without type (only used if key_field isn't set). + * keep_missing: Set to true if you want to return missing objects as null. + * key_field: Key field which is used to match the objects. + * @return array + */ + public function getObjects(array $keys, array $options = []): array; + + /** + * @param string $key + * @param string|null $type + * @param string|null $keyField + * @return FlexObjectInterface|null + */ + public function getObject(string $key, string $type = null, string $keyField = null): ?FlexObjectInterface; + + /** + * @return int + */ + public function count(): int; +} diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexObjectFormInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexObjectFormInterface.php new file mode 100644 index 0000000..41ff68e --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexObjectFormInterface.php @@ -0,0 +1,27 @@ + + * @used-by \Grav\Framework\Flex\FlexObject + * @since 1.6 + */ +interface FlexObjectInterface extends FlexCommonInterface, NestedObjectInterface, ArrayAccess +{ + /** + * Construct a new Flex Object instance. + * + * @used-by FlexDirectory::createObject() Method to create Flex Object. + * + * @param array $elements Array of object properties. + * @param string $key Identifier key for the new object. + * @param FlexDirectory $directory Flex Directory the object belongs into. + * @param bool $validate True if the object should be validated against blueprint. + * @throws InvalidArgumentException + */ + public function __construct(array $elements, $key, FlexDirectory $directory, bool $validate = false); + + /** + * Search a string from the object, returns weight between 0 and 1. + * + * Note: If you override this function, make sure you return value in range 0...1! + * + * @used-by FlexCollectionInterface::search() If you want to search a string from a Flex Collection. + * + * @param string $search Search string. + * @param string|string[]|null $properties Properties to search for, defaults to configured properties. + * @param array|null $options Search options, defaults to configured options. + * @return float Returns a weight between 0 and 1. + * @api + */ + public function search(string $search, $properties = null, array $options = null): float; + + /** + * Returns true if object has a key. + * + * @return bool + */ + public function hasKey(); + + /** + * Get a unique key for the object. + * + * Flex Keys can be used without knowing the Directory the Object belongs into. + * + * @see Flex::getObject() If you want to get Flex Object from any Flex Directory. + * @see Flex::getObjects() If you want to get list of Flex Objects from any Flex Directory. + * + * NOTE: Please do not override the method! + * + * @return string Returns Flex Key of the object. + * @api + */ + public function getFlexKey(): string; + + /** + * Get an unique storage key (within the directory) which is used for figuring out the filename or database id. + * + * @see FlexDirectory::getObject() If you want to get Flex Object from the Flex Directory. + * @see FlexDirectory::getCollection() If you want to get Flex Collection with selected keys from the Flex Directory. + * + * @return string Returns storage key of the Object. + * @api + */ + public function getStorageKey(): string; + + /** + * Get index data associated to the object. + * + * @return array Returns metadata of the object. + */ + public function getMetaData(): array; + + /** + * Returns true if the object exists in the storage. + * + * @return bool Returns `true` if the object exists, `false` otherwise. + * @api + */ + public function exists(): bool; + + /** + * Prepare object for saving into the storage. + * + * @return array Returns an array of object properties containing only scalars and arrays. + */ + public function prepareStorage(): array; + + /** + * Updates object in the memory. + * + * @see FlexObjectInterface::save() You need to save the object after calling this method. + * + * @param array $data Data containing updated properties with their values. To unset a value, use `null`. + * @param array|UploadedFileInterface[] $files List of uploaded files to be saved within the object. + * @return static + * @throws RuntimeException + * @api + */ + public function update(array $data, array $files = []); + + /** + * Create new object into the storage. + * + * @see FlexDirectory::createObject() If you want to create a new object instance. + * @see FlexObjectInterface::update() If you want to update properties of the object. + * + * @param string|null $key Optional new key. If key isn't given, random key will be associated to the object. + * @return static + * @throws RuntimeException if object already exists. + * @api + */ + public function create(string $key = null); + + /** + * Save object into the storage. + * + * @see FlexObjectInterface::update() If you want to update properties of the object. + * + * @return static + * @api + */ + public function save(); + + /** + * Delete object from the storage. + * + * @return static + * @api + */ + public function delete(); + + /** + * Returns the blueprint of the object. + * + * @see FlexObjectInterface::getForm() + * @used-by FlexForm::getBlueprint() + * + * @param string $name Name of the Blueprint form. Used to create customized forms for different use cases. + * @return Blueprint Returns a Blueprint. + */ + public function getBlueprint(string $name = ''); + + /** + * Returns a form instance for the object. + * + * @param string $name Name of the form. Can be used to create customized forms for different use cases. + * @param array|null $options Options can be used to further customize the form. + * @return FlexFormInterface Returns a Form. + * @api + */ + public function getForm(string $name = '', array $options = null); + + /** + * Returns default value suitable to be used in a form for the given property. + * + * @see FlexObjectInterface::getForm() + * + * @param string $name Property name. + * @param string|null $separator Optional nested property separator. + * @return mixed|null Returns default value of the field, null if there is no default value. + */ + public function getDefaultValue(string $name, string $separator = null); + + /** + * Returns default values suitable to be used in a form for the given property. + * + * @see FlexObjectInterface::getForm() + * + * @return array Returns default values. + */ + public function getDefaultValues(): array; + + /** + * Returns raw value suitable to be used in a form for the given property. + * + * @see FlexObjectInterface::getForm() + * + * @param string $name Property name. + * @param mixed $default Default value. + * @param string|null $separator Optional nested property separator. + * @return mixed Returns value of the field. + */ + public function getFormValue(string $name, $default = null, string $separator = null); +} diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexStorageInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexStorageInterface.php new file mode 100644 index 0000000..7dca589 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexStorageInterface.php @@ -0,0 +1,138 @@ + [storage_key => key, storage_timestamp => timestamp], ...]`. + */ + public function getExistingKeys(): array; + + /** + * Check if the key exists in the storage. + * + * @param string $key Storage key of an object. + * @return bool Returns `true` if the key exists in the storage, `false` otherwise. + */ + public function hasKey(string $key): bool; + + /** + * Check if the key exists in the storage. + * + * @param string[] $keys Storage key of an object. + * @return bool[] Returns keys with `true` if the key exists in the storage, `false` otherwise. + */ + public function hasKeys(array $keys): array; + + /** + * Create new rows into the storage. + * + * New keys will be assigned when the objects are created. + * + * @param array $rows List of rows as `[row, ...]`. + * @return array Returns created rows as `[key => row, ...] pairs. + */ + public function createRows(array $rows): array; + + /** + * Read rows from the storage. + * + * If you pass object or array as value, that value will be used to save I/O. + * + * @param array $rows Array of `[key => row, ...]` pairs. + * @param array|null $fetched Optional reference to store only fetched items. + * @return array Returns rows. Note that non-existing rows will have `null` as their value. + */ + public function readRows(array $rows, array &$fetched = null): array; + + /** + * Update existing rows in the storage. + * + * @param array $rows Array of `[key => row, ...]` pairs. + * @return array Returns updated rows. Note that non-existing rows will not be saved and have `null` as their value. + */ + public function updateRows(array $rows): array; + + /** + * Delete rows from the storage. + * + * @param array $rows Array of `[key => row, ...]` pairs. + * @return array Returns deleted rows. Note that non-existing rows have `null` as their value. + */ + public function deleteRows(array $rows): array; + + /** + * Replace rows regardless if they exist or not. + * + * All rows should have a specified key for replace to work properly. + * + * @param array $rows Array of `[key => row, ...]` pairs. + * @return array Returns both created and updated rows. + */ + public function replaceRows(array $rows): array; + + /** + * @param string $src + * @param string $dst + * @return bool + */ + public function copyRow(string $src, string $dst): bool; + + /** + * @param string $src + * @param string $dst + * @return bool + */ + public function renameRow(string $src, string $dst): bool; + + /** + * Get filesystem path for the collection or object storage. + * + * @param string|null $key Optional storage key. + * @return string|null Path in the filesystem. Can be URI or null if storage is not filesystem based. + */ + public function getStoragePath(string $key = null): ?string; + + /** + * Get filesystem path for the collection or object media. + * + * @param string|null $key Optional storage key. + * @return string|null Path in the filesystem. Can be URI or null if media isn't supported. + */ + public function getMediaPath(string $key = null): ?string; +} diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexTranslateInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexTranslateInterface.php new file mode 100644 index 0000000..4e7e8f7 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexTranslateInterface.php @@ -0,0 +1,51 @@ + + */ +class FlexPageCollection extends FlexCollection +{ + /** + * @return array + */ + public static function getCachedMethods(): array + { + return [ + // Collection filtering + 'withPublished' => true, + 'withVisible' => true, + 'withRoutable' => true, + + 'isFirst' => true, + 'isLast' => true, + + // Find objects + 'prevSibling' => false, + 'nextSibling' => false, + 'adjacentSibling' => false, + 'currentPosition' => true, + + 'getNextOrder' => false, + ] + parent::getCachedMethods(); + } + + /** + * @param bool $bool + * @return static + * @phpstan-return static + */ + public function withPublished(bool $bool = true) + { + /** @var string[] $list */ + $list = array_keys(array_filter($this->call('isPublished', [$bool]))); + + /** @phpstan-var static */ + return $this->select($list); + } + + /** + * @param bool $bool + * @return static + * @phpstan-return static + */ + public function withVisible(bool $bool = true) + { + /** @var string[] $list */ + $list = array_keys(array_filter($this->call('isVisible', [$bool]))); + + /** @phpstan-var static */ + return $this->select($list); + } + + /** + * @param bool $bool + * @return static + * @phpstan-return static + */ + public function withRoutable(bool $bool = true) + { + /** @var string[] $list */ + $list = array_keys(array_filter($this->call('isRoutable', [$bool]))); + + /** @phpstan-var static */ + return $this->select($list); + } + + /** + * Check to see if this item is the first in the collection. + * + * @param string $path + * @return bool True if item is first. + */ + public function isFirst($path): bool + { + $keys = $this->getKeys(); + $first = reset($keys); + + return $path === $first; + } + + /** + * Check to see if this item is the last in the collection. + * + * @param string $path + * @return bool True if item is last. + */ + public function isLast($path): bool + { + $keys = $this->getKeys(); + $last = end($keys); + + return $path === $last; + } + + /** + * Gets the previous sibling based on current position. + * + * @param string $path + * @return PageInterface|false The previous item. + * @phpstan-return T|false + */ + public function prevSibling($path) + { + return $this->adjacentSibling($path, -1); + } + + /** + * Gets the next sibling based on current position. + * + * @param string $path + * @return PageInterface|false The next item. + * @phpstan-return T|false + */ + public function nextSibling($path) + { + return $this->adjacentSibling($path, 1); + } + + /** + * Returns the adjacent sibling based on a direction. + * + * @param string $path + * @param int $direction either -1 or +1 + * @return PageInterface|false The sibling item. + * @phpstan-return T|false + */ + public function adjacentSibling($path, $direction = 1) + { + $keys = $this->getKeys(); + $direction = (int)$direction; + $pos = array_search($path, $keys, true); + + if (is_int($pos)) { + $pos += $direction; + if (isset($keys[$pos])) { + return $this[$keys[$pos]]; + } + } + + return false; + } + + /** + * Returns the item in the current position. + * + * @param string $path the path the item + * @return int|null The index of the current page, null if not found. + */ + public function currentPosition($path): ?int + { + $pos = array_search($path, $this->getKeys(), true); + + return is_int($pos) ? $pos : null; + } + + /** + * @return string + */ + public function getNextOrder() + { + $directory = $this->getFlexDirectory(); + + $collection = $directory->getIndex(); + $keys = $collection->getStorageKeys(); + + // Assign next free order. + $last = null; + $order = 0; + foreach ($keys as $folder => $key) { + preg_match(FlexPageIndex::ORDER_PREFIX_REGEX, $folder, $test); + $test = $test[0] ?? null; + if ($test && $test > $order) { + $order = $test; + $last = $key; + } + } + + /** @var FlexPageObject|null $last */ + $last = $collection[$last]; + + return sprintf('%d.', $last ? $last->getFormValue('order') + 1 : 1); + } +} diff --git a/system/src/Grav/Framework/Flex/Pages/FlexPageIndex.php b/system/src/Grav/Framework/Flex/Pages/FlexPageIndex.php new file mode 100644 index 0000000..3ca6d04 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Pages/FlexPageIndex.php @@ -0,0 +1,48 @@ + + */ +class FlexPageIndex extends FlexIndex +{ + public const ORDER_PREFIX_REGEX = '/^\d+\./u'; + + /** + * @param string $route + * @return string + * @internal + */ + public static function normalizeRoute(string $route) + { + static $case_insensitive; + + if (null === $case_insensitive) { + $case_insensitive = Grav::instance()['config']->get('system.force_lowercase_urls', false); + } + + return $case_insensitive ? mb_strtolower($route) : $route; + } +} diff --git a/system/src/Grav/Framework/Flex/Pages/FlexPageObject.php b/system/src/Grav/Framework/Flex/Pages/FlexPageObject.php new file mode 100644 index 0000000..54b295b --- /dev/null +++ b/system/src/Grav/Framework/Flex/Pages/FlexPageObject.php @@ -0,0 +1,496 @@ +header)) { + $this->header = clone($this->header); + } + } + + /** + * @return array + */ + public static function getCachedMethods(): array + { + return [ + // Page Content Interface + 'header' => false, + 'summary' => true, + 'content' => true, + 'value' => false, + 'media' => false, + 'title' => true, + 'menu' => true, + 'visible' => true, + 'published' => true, + 'publishDate' => true, + 'unpublishDate' => true, + 'process' => true, + 'slug' => true, + 'order' => true, + 'id' => true, + 'modified' => true, + 'lastModified' => true, + 'folder' => true, + 'date' => true, + 'dateformat' => true, + 'taxonomy' => true, + 'shouldProcess' => true, + 'isPage' => true, + 'isDir' => true, + 'folderExists' => true, + + // Page + 'isPublished' => true, + 'isOrdered' => true, + 'isVisible' => true, + 'isRoutable' => true, + 'getCreated_Timestamp' => true, + 'getPublish_Timestamp' => true, + 'getUnpublish_Timestamp' => true, + 'getUpdated_Timestamp' => true, + ] + parent::getCachedMethods(); + } + + /** + * @param bool $test + * @return bool + */ + public function isPublished(bool $test = true): bool + { + $time = time(); + $start = $this->getPublish_Timestamp(); + $stop = $this->getUnpublish_Timestamp(); + + return $this->published() && $start <= $time && (!$stop || $time <= $stop) === $test; + } + + /** + * @param bool $test + * @return bool + */ + public function isOrdered(bool $test = true): bool + { + return ($this->order() !== false) === $test; + } + + /** + * @param bool $test + * @return bool + */ + public function isVisible(bool $test = true): bool + { + return $this->visible() === $test; + } + + /** + * @param bool $test + * @return bool + */ + public function isRoutable(bool $test = true): bool + { + return $this->routable() === $test; + } + + /** + * @return int + */ + public function getCreated_Timestamp(): int + { + return $this->getFieldTimestamp('created_date') ?? 0; + } + + /** + * @return int + */ + public function getPublish_Timestamp(): int + { + return $this->getFieldTimestamp('publish_date') ?? $this->getCreated_Timestamp(); + } + + /** + * @return int|null + */ + public function getUnpublish_Timestamp(): ?int + { + return $this->getFieldTimestamp('unpublish_date'); + } + + /** + * @return int + */ + public function getUpdated_Timestamp(): int + { + return $this->getFieldTimestamp('updated_date') ?? $this->getPublish_Timestamp(); + } + + /** + * @inheritdoc + */ + public function getFormValue(string $name, $default = null, string $separator = null) + { + $test = new stdClass(); + + $value = $this->pageContentValue($name, $test); + if ($value !== $test) { + return $value; + } + + switch ($name) { + case 'name': + return $this->getProperty('template'); + case 'route': + return $this->hasKey() ? '/' . $this->getKey() : null; + case 'header.permissions.groups': + $encoded = json_encode($this->getPermissions()); + if ($encoded === false) { + throw new RuntimeException('json_encode(): failed to encode group permissions'); + } + + return json_decode($encoded, true); + } + + return parent::getFormValue($name, $default, $separator); + } + + /** + * Get master storage key. + * + * @return string + * @see FlexObjectInterface::getStorageKey() + */ + public function getMasterKey(): string + { + $key = (string)($this->storage_key ?? $this->getMetaData()['storage_key'] ?? null); + if (($pos = strpos($key, '|')) !== false) { + $key = substr($key, 0, $pos); + } + + return $key; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getCacheKey() + */ + public function getCacheKey(): string + { + return $this->hasKey() ? $this->getTypePrefix() . $this->getFlexType() . '.' . $this->getKey() . '.' . $this->getLanguage() : ''; + } + + /** + * @param string|null $key + * @return FlexObjectInterface + */ + public function createCopy(string $key = null) + { + $this->copy(); + + return parent::createCopy($key); + } + + /** + * @param array|bool $reorder + * @return FlexObject|FlexObjectInterface + */ + public function save($reorder = true) + { + return parent::save(); + } + + /** + * Gets the Page Unmodified (original) version of the page. + * + * Assumes that object has been cloned before modifying it. + * + * @return FlexPageObject|null The original version of the page. + */ + public function getOriginal() + { + return $this->_originalObject; + } + + /** + * Store the Page Unmodified (original) version of the page. + * + * Can be called multiple times, only the first call matters. + * + * @return void + */ + public function storeOriginal(): void + { + if (null === $this->_originalObject) { + $this->_originalObject = clone $this; + } + } + + /** + * Get display order for the associated media. + * + * @return array + */ + public function getMediaOrder(): array + { + $order = $this->getNestedProperty('header.media_order'); + + if (is_array($order)) { + return $order; + } + + if (!$order) { + return []; + } + + return array_map('trim', explode(',', $order)); + } + + // Overrides for header properties. + + /** + * Common logic to load header properties. + * + * @param string $property + * @param mixed $var + * @param callable $filter + * @return mixed|null + */ + protected function loadHeaderProperty(string $property, $var, callable $filter) + { + // We have to use parent methods in order to avoid loops. + $value = null === $var ? parent::getProperty($property) : null; + if (null === $value) { + $value = $filter($var ?? $this->getProperty('header')->get($property)); + + parent::setProperty($property, $value); + if ($this->doHasProperty($property)) { + $value = parent::getProperty($property); + } + } + + return $value; + } + + /** + * Common logic to load header properties. + * + * @param string $property + * @param mixed $var + * @param callable $filter + * @return mixed|null + */ + protected function loadProperty(string $property, $var, callable $filter) + { + // We have to use parent methods in order to avoid loops. + $value = null === $var ? parent::getProperty($property) : null; + if (null === $value) { + $value = $filter($var); + + parent::setProperty($property, $value); + if ($this->doHasProperty($property)) { + $value = parent::getProperty($property); + } + } + + return $value; + } + + /** + * @param string $property + * @param mixed $default + * @return mixed + */ + public function getProperty($property, $default = null) + { + $method = static::$headerProperties[$property] ?? static::$calculatedProperties[$property] ?? null; + if ($method && method_exists($this, $method)) { + return $this->{$method}(); + } + + return parent::getProperty($property, $default); + } + + /** + * @param string $property + * @param mixed $value + * @return $this + */ + public function setProperty($property, $value) + { + $method = static::$headerProperties[$property] ?? static::$calculatedProperties[$property] ?? null; + if ($method && method_exists($this, $method)) { + $this->{$method}($value); + + return $this; + } + + parent::setProperty($property, $value); + + return $this; + } + + /** + * @param string $property + * @param mixed $value + * @param string|null $separator + * @return $this + */ + public function setNestedProperty($property, $value, $separator = null) + { + $separator = $separator ?: '.'; + if (strpos($property, 'header' . $separator) === 0) { + $this->getProperty('header')->set(str_replace('header' . $separator, '', $property), $value, $separator); + + return $this; + } + + parent::setNestedProperty($property, $value, $separator); + + return $this; + } + + /** + * @param string $property + * @param string|null $separator + * @return $this + */ + public function unsetNestedProperty($property, $separator = null) + { + $separator = $separator ?: '.'; + if (strpos($property, 'header' . $separator) === 0) { + $this->getProperty('header')->undef(str_replace('header' . $separator, '', $property), $separator); + + return $this; + } + + parent::unsetNestedProperty($property, $separator); + + return $this; + } + + /** + * @param array $elements + * @param bool $extended + * @return void + */ + protected function filterElements(array &$elements, bool $extended = false): void + { + // Markdown storage conversion to page structure. + if (array_key_exists('content', $elements)) { + $elements['markdown'] = $elements['content']; + unset($elements['content']); + } + + if (!$extended) { + $folder = !empty($elements['folder']) ? trim($elements['folder']) : ''; + + if ($folder) { + $order = !empty($elements['order']) ? (int)$elements['order'] : null; + // TODO: broken + $elements['storage_key'] = $order ? sprintf('%02d.%s', $order, $folder) : $folder; + } + } + + parent::filterElements($elements); + } + + /** + * @param string $field + * @return int|null + */ + protected function getFieldTimestamp(string $field): ?int + { + $date = $this->getFieldDateTime($field); + + return $date ? $date->getTimestamp() : null; + } + + /** + * @param string $field + * @return DateTime|null + */ + protected function getFieldDateTime(string $field): ?DateTime + { + try { + $value = $this->getProperty($field); + if (is_numeric($value)) { + $value = '@' . $value; + } + $date = $value ? new DateTime($value) : null; + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + $date = null; + } + + return $date; + } + + /** + * @return UserCollectionInterface|null + * @internal + */ + protected function loadAccounts() + { + return Grav::instance()['accounts'] ?? null; + } +} diff --git a/system/src/Grav/Framework/Flex/Pages/Traits/PageAuthorsTrait.php b/system/src/Grav/Framework/Flex/Pages/Traits/PageAuthorsTrait.php new file mode 100644 index 0000000..ba02176 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Pages/Traits/PageAuthorsTrait.php @@ -0,0 +1,249 @@ + */ + private $_authors; + /** @var array|null */ + private $_permissionsCache; + + /** + * Returns true if object has the named author. + * + * @param string $username + * @return bool + */ + public function hasAuthor(string $username): bool + { + $authors = (array)$this->getNestedProperty('header.permissions.authors'); + if (empty($authors)) { + return false; + } + + foreach ($authors as $author) { + if ($username === $author) { + return true; + } + } + + return false; + } + + /** + * Get list of all author objects. + * + * @return array + */ + public function getAuthors(): array + { + if (null === $this->_authors) { + $this->_authors = $this->loadAuthors($this->getNestedProperty('header.permissions.authors', [])); + } + + return $this->_authors; + } + + /** + * @param bool $inherit + * @return array + */ + public function getPermissions(bool $inherit = false) + { + if (null === $this->_permissionsCache) { + $permissions = []; + if ($inherit && $this->getNestedProperty('header.permissions.inherit', true)) { + $parent = $this->parent(); + if ($parent && method_exists($parent, 'getPermissions')) { + $permissions = $parent->getPermissions($inherit); + } + } + + $this->_permissionsCache = $this->loadPermissions($permissions); + } + + return $this->_permissionsCache; + } + + /** + * @param iterable $authors + * @return array + */ + protected function loadAuthors(iterable $authors): array + { + $accounts = $this->loadAccounts(); + if (null === $accounts || empty($authors)) { + return []; + } + + $list = []; + foreach ($authors as $username) { + if (!is_string($username)) { + throw new InvalidArgumentException('Iterable should return username (string).', 500); + } + $list[] = $accounts->load($username); + } + + return $list; + } + + /** + * @param string $action + * @param string|null $scope + * @param UserInterface|null $user + * @param bool $isAuthor + * @return bool|null + */ + public function isParentAuthorized(string $action, string $scope = null, UserInterface $user = null, bool $isAuthor = false): ?bool + { + $scope = $scope ?? $this->getAuthorizeScope(); + + $isMe = null === $user; + if ($isMe) { + $user = $this->getActiveUser(); + } + + if (null === $user) { + return false; + } + + return $this->isAuthorizedByGroup($user, $action, $scope, $isMe, $isAuthor); + } + + /** + * @param UserInterface $user + * @param string $action + * @param string $scope + * @param bool $isMe + * @return bool|null + */ + protected function isAuthorizedOverride(UserInterface $user, string $action, string $scope, bool $isMe): ?bool + { + if ($action === 'delete' && $this->root()) { + // Do not allow deleting root. + return false; + } + + $isAuthor = !$isMe || $user->authorized ? $this->hasAuthor($user->username) : false; + + return $this->isAuthorizedByGroup($user, $action, $scope, $isMe, $isAuthor) ?? parent::isAuthorizedOverride($user, $action, $scope, $isMe); + } + + /** + * Group authorization works as follows: + * + * 1. if any of the groups deny access, return false + * 2. else if any of the groups allow access, return true + * 3. else return null + * + * @param UserInterface $user + * @param string $action + * @param string $scope + * @param bool $isMe + * @param bool $isAuthor + * @return bool|null + */ + protected function isAuthorizedByGroup(UserInterface $user, string $action, string $scope, bool $isMe, bool $isAuthor): ?bool + { + $authorized = null; + + // In admin we want to check against group permissions. + $pageGroups = $this->getPermissions(); + $userGroups = (array)$user->groups; + + /** @var Access $access */ + foreach ($pageGroups as $group => $access) { + if ($group === 'defaults') { + // Special defaults permissions group does not apply to guest. + if ($isMe && !$user->authorized) { + continue; + } + } elseif ($group === 'authors') { + if (!$isAuthor) { + continue; + } + } elseif (!in_array($group, $userGroups, true)) { + continue; + } + + $auth = $access->authorize($action); + if (is_bool($auth)) { + if ($auth === false) { + return false; + } + + $authorized = true; + } + } + + if (null === $authorized && $this->getNestedProperty('header.permissions.inherit', true)) { + // Authorize against parent page. + $parent = $this->parent(); + if ($parent && method_exists($parent, 'isParentAuthorized')) { + $authorized = $parent->isParentAuthorized($action, $scope, !$isMe ? $user : null, $isAuthor); + } + } + + return $authorized; + } + + /** + * @param array $parent + * @return array + */ + protected function loadPermissions(array $parent = []): array + { + static $rules = [ + 'c' => 'create', + 'r' => 'read', + 'u' => 'update', + 'd' => 'delete', + 'p' => 'publish', + 'l' => 'list' + ]; + + $permissions = $this->getNestedProperty('header.permissions.groups'); + $name = $this->root() ? '' : '/' . $this->getKey(); + + $list = []; + if (is_array($permissions)) { + foreach ($permissions as $group => $access) { + $list[$group] = new Access($access, $rules, $name); + } + } + foreach ($parent as $group => $access) { + if (isset($list[$group])) { + $object = $list[$group]; + } else { + $object = new Access([], $rules, $name); + $list[$group] = $object; + } + + $object->inherit($access); + } + + return $list; + } +} diff --git a/system/src/Grav/Framework/Flex/Pages/Traits/PageContentTrait.php b/system/src/Grav/Framework/Flex/Pages/Traits/PageContentTrait.php new file mode 100644 index 0000000..2eb3789 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Pages/Traits/PageContentTrait.php @@ -0,0 +1,842 @@ + 'slug', + 'routes' => false, + 'title' => 'title', + 'language' => 'language', + 'template' => 'template', + 'menu' => 'menu', + 'routable' => 'routable', + 'visible' => 'visible', + 'redirect' => 'redirect', + 'external_url' => false, + 'order_dir' => 'orderDir', + 'order_by' => 'orderBy', + 'order_manual' => 'orderManual', + 'dateformat' => 'dateformat', + 'date' => 'date', + 'markdown_extra' => false, + 'taxonomy' => 'taxonomy', + 'max_count' => 'maxCount', + 'process' => 'process', + 'published' => 'published', + 'publish_date' => 'publishDate', + 'unpublish_date' => 'unpublishDate', + 'expires' => 'expires', + 'cache_control' => 'cacheControl', + 'etag' => 'eTag', + 'last_modified' => 'lastModified', + 'ssl' => 'ssl', + 'template_format' => 'templateFormat', + 'debugger' => false, + ]; + + /** @var array */ + protected static $calculatedProperties = [ + 'name' => 'name', + 'parent' => 'parent', + 'parent_key' => 'parentStorageKey', + 'folder' => 'folder', + 'order' => 'order', + 'template' => 'template', + ]; + + /** @var object|null */ + protected $header; + + /** @var string|null */ + protected $_summary; + + /** @var string|null */ + protected $_content; + + /** + * Method to normalize the route. + * + * @param string $route + * @return string + * @internal + */ + public static function normalizeRoute($route): string + { + $case_insensitive = Grav::instance()['config']->get('system.force_lowercase_urls'); + + return $case_insensitive ? mb_strtolower($route) : $route; + } + + /** + * @inheritdoc + * @return Header + */ + public function header($var = null) + { + if (null !== $var) { + $this->setProperty('header', $var); + } + + return $this->getProperty('header'); + } + + /** + * @inheritdoc + */ + public function summary($size = null, $textOnly = false): string + { + return $this->processSummary($size, $textOnly); + } + + /** + * @inheritdoc + */ + public function setSummary($summary): void + { + $this->_summary = $summary; + } + + /** + * @inheritdoc + * @throws Exception + */ + public function content($var = null): string + { + if (null !== $var) { + $this->_content = $var; + } + + return $this->_content ?? $this->processContent($this->getRawContent()); + } + + /** + * @inheritdoc + */ + public function getRawContent(): string + { + return $this->_content ?? $this->getArrayProperty('markdown') ?? ''; + } + + /** + * @inheritdoc + */ + public function setRawContent($content): void + { + $this->_content = $content ?? ''; + } + + /** + * @inheritdoc + */ + public function rawMarkdown($var = null): string + { + if ($var !== null) { + $this->setProperty('markdown', $var); + } + + return $this->getProperty('markdown') ?? ''; + } + + /** + * @inheritdoc + * + * Implement by calling: + * + * $test = new \stdClass(); + * $value = $this->pageContentValue($name, $test); + * if ($value !== $test) { + * return $value; + * } + * return parent::value($name, $default); + */ + abstract public function value($name, $default = null, $separator = null); + + /** + * @inheritdoc + */ + public function media($var = null): Media + { + if ($var instanceof Media) { + $this->setProperty('media', $var); + } + + return $this->getProperty('media'); + } + + /** + * @inheritdoc + */ + public function title($var = null): string + { + return $this->loadHeaderProperty( + 'title', + $var, + function ($value) { + return trim($value ?? ($this->root() ? '' : ucfirst($this->slug()))); + } + ); + } + + /** + * @inheritdoc + */ + public function menu($var = null): string + { + return $this->loadHeaderProperty( + 'menu', + $var, + function ($value) { + return trim($value ?: $this->title()); + } + ); + } + + /** + * @inheritdoc + */ + public function visible($var = null): bool + { + $value = $this->loadHeaderProperty( + 'visible', + $var, + function ($value) { + return ($value ?? $this->order() !== false) && !$this->isModule(); + } + ); + + return $value && $this->published(); + } + + /** + * @inheritdoc + */ + public function published($var = null): bool + { + return $this->loadHeaderProperty( + 'published', + $var, + static function ($value) { + return (bool)($value ?? true); + } + ); + } + + /** + * @inheritdoc + */ + public function publishDate($var = null): ?int + { + return $this->loadHeaderProperty( + 'publish_date', + $var, + function ($value) { + return $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : null; + } + ); + } + + /** + * @inheritdoc + */ + public function unpublishDate($var = null): ?int + { + return $this->loadHeaderProperty( + 'unpublish_date', + $var, + function ($value) { + return $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : null; + } + ); + } + + /** + * @inheritdoc + */ + public function process($var = null): array + { + return $this->loadHeaderProperty( + 'process', + $var, + function ($value) { + $value = array_replace(Grav::instance()['config']->get('system.pages.process', []), is_array($value) ? $value : []); + foreach ($value as $process => $status) { + $value[$process] = (bool)$status; + } + + return $value; + } + ); + } + + /** + * @inheritdoc + */ + public function slug($var = null) + { + return $this->loadHeaderProperty( + 'slug', + $var, + function ($value) { + if (is_string($value)) { + return $value; + } + + $folder = $this->folder(); + if (null === $folder) { + return null; + } + + $folder = preg_replace(static::PAGE_ORDER_PREFIX_REGEX, '', $folder); + if (null === $folder) { + return null; + } + + return static::normalizeRoute($folder); + } + ); + } + + /** + * @inheritdoc + */ + public function order($var = null) + { + $property = $this->loadProperty( + 'order', + $var, + function ($value) { + if (null === $value) { + $folder = $this->folder(); + if (null !== $folder) { + preg_match(static::PAGE_ORDER_REGEX, $folder, $order); + } + + $value = $order[1] ?? false; + } + + if ($value === '') { + $value = false; + } + if ($value !== false) { + $value = (int)$value; + } + + return $value; + } + ); + + return $property !== false ? sprintf('%02d.', $property) : false; + } + + /** + * @inheritdoc + */ + public function id($var = null): string + { + $property = 'id'; + $value = null === $var ? $this->getProperty($property) : null; + if (null === $value) { + $value = $this->language() . ($var ?? ($this->modified() . md5('flex-' . $this->getFlexType() . '-' . $this->getKey()))); + + $this->setProperty($property, $value); + if ($this->doHasProperty($property)) { + $value = $this->getProperty($property); + } + } + + return $value; + } + + /** + * @inheritdoc + */ + public function modified($var = null): int + { + $property = 'modified'; + $value = null === $var ? $this->getProperty($property) : null; + if (null === $value) { + $value = (int)($var ?: $this->getTimestamp()); + + $this->setProperty($property, $value); + if ($this->doHasProperty($property)) { + $value = $this->getProperty($property); + } + } + + return $value; + } + + /** + * @inheritdoc + */ + public function lastModified($var = null): bool + { + return $this->loadHeaderProperty( + 'last_modified', + $var, + static function ($value) { + return (bool)($value ?? Grav::instance()['config']->get('system.pages.last_modified')); + } + ); + } + + /** + * @inheritdoc + */ + public function date($var = null): int + { + return $this->loadHeaderProperty( + 'date', + $var, + function ($value) { + $value = $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : false; + + return $value ?: $this->modified(); + } + ); + } + + /** + * @inheritdoc + */ + public function dateformat($var = null): ?string + { + return $this->loadHeaderProperty( + 'dateformat', + $var, + static function ($value) { + return $value; + } + ); + } + + /** + * @inheritdoc + */ + public function taxonomy($var = null): array + { + return $this->loadHeaderProperty( + 'taxonomy', + $var, + static function ($value) { + if (is_array($value)) { + // make sure first level are arrays + array_walk($value, static function (&$val) { + $val = (array) $val; + }); + // make sure all values are strings + array_walk_recursive($value, static function (&$val) { + $val = (string) $val; + }); + } + + return $value ?? []; + } + ); + } + + /** + * @inheritdoc + */ + public function shouldProcess($process): bool + { + $test = $this->process(); + + return !empty($test[$process]); + } + + /** + * @inheritdoc + */ + public function isPage(): bool + { + return !in_array($this->template(), ['', 'folder'], true); + } + + /** + * @inheritdoc + */ + public function isDir(): bool + { + return !$this->isPage(); + } + + /** + * @return bool + */ + public function isModule(): bool + { + return $this->modularTwig(); + } + + /** + * @param Header|stdClass|array|null $value + * @return Header + */ + protected function offsetLoad_header($value) + { + if ($value instanceof Header) { + return $value; + } + + if (null === $value) { + $value = []; + } elseif ($value instanceof stdClass) { + $value = (array)$value; + } + + return new Header($value); + } + + /** + * @param Header|stdClass|array|null $value + * @return Header + */ + protected function offsetPrepare_header($value) + { + return $this->offsetLoad_header($value); + } + + /** + * @param Header|null $value + * @return array + */ + protected function offsetSerialize_header(?Header $value) + { + return $value ? $value->toArray() : []; + } + + /** + * @param string $name + * @param mixed|null $default + * @return mixed + */ + protected function pageContentValue($name, $default = null) + { + switch ($name) { + case 'frontmatter': + $frontmatter = $this->getArrayProperty('frontmatter'); + if ($frontmatter === null) { + $header = $this->prepareStorage()['header'] ?? null; + if ($header) { + $formatter = new YamlFormatter(); + $frontmatter = $formatter->encode($header); + } else { + $frontmatter = ''; + } + } + return $frontmatter; + case 'content': + return $this->getProperty('markdown'); + case 'order': + return (string)$this->order(); + case 'menu': + return $this->menu(); + case 'ordering': + return $this->order() !== false ? '1' : '0'; + case 'folder': + $folder = $this->folder(); + + return null !== $folder ? preg_replace(static::PAGE_ORDER_PREFIX_REGEX, '', $folder) : ''; + case 'slug': + return $this->slug(); + case 'published': + return $this->published(); + case 'visible': + return $this->visible(); + case 'media': + return $this->media()->all(); + case 'media.file': + return $this->media()->files(); + case 'media.video': + return $this->media()->videos(); + case 'media.image': + return $this->media()->images(); + case 'media.audio': + return $this->media()->audios(); + } + + return $default; + } + + /** + * @param int|null $size + * @param bool $textOnly + * @return string + */ + protected function processSummary($size = null, $textOnly = false): string + { + $config = (array)Grav::instance()['config']->get('site.summary'); + $config_page = (array)$this->getNestedProperty('header.summary'); + if ($config_page) { + $config = array_merge($config, $config_page); + } + + // Summary is not enabled, return the whole content. + if (empty($config['enabled'])) { + return $this->content(); + } + + $content = $this->_summary ?? $this->content(); + if ($textOnly) { + $content = strip_tags($content); + } + $content_size = mb_strwidth($content, 'utf-8'); + $summary_size = $this->_summary !== null ? $content_size : $this->getProperty('summary_size'); + + // Return calculated summary based on summary divider's position. + $format = $config['format'] ?? ''; + + // Return entire page content on wrong/unknown format. + if ($format !== 'long' && $format !== 'short') { + return $content; + } + + if ($format === 'short' && null !== $summary_size) { + // Slice the string on breakpoint. + if ($content_size > $summary_size) { + return mb_substr($content, 0, $summary_size); + } + + return $content; + } + + // If needed, get summary size from the config. + $size = $size ?? $config['size'] ?? null; + + // Return calculated summary based on defaults. + $size = is_numeric($size) ? (int)$size : -1; + if ($size < 0) { + $size = 300; + } + + // If the size is zero or smaller than the summary limit, return the entire page content. + if ($size === 0 || $content_size <= $size) { + return $content; + } + + // Only return string but not html, wrap whatever html tag you want when using. + if ($textOnly) { + return mb_strimwidth($content, 0, $size, '...', 'UTF-8'); + } + + $summary = Utils::truncateHTML($content, $size); + + return html_entity_decode($summary, ENT_COMPAT | ENT_HTML5, 'UTF-8'); + } + + /** + * Gets and Sets the content based on content portion of the .md file + * + * @param string $content + * @return string + * @throws Exception + */ + protected function processContent($content): string + { + $content = is_string($content) ? $content : ''; + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + $process_markdown = $this->shouldProcess('markdown'); + $process_twig = $this->shouldProcess('twig') || $this->isModule(); + $cache_enable = $this->getNestedProperty('header.cache_enable') ?? $config->get('system.cache.enabled', true); + + $twig_first = $this->getNestedProperty('header.twig_first') ?? $config->get('system.pages.twig_first', false); + $never_cache_twig = $this->getNestedProperty('header.never_cache_twig') ?? $config->get('system.pages.never_cache_twig', false); + + if ($cache_enable) { + $cache = $this->getCache('render'); + $key = md5($this->getCacheKey() . '-content'); + $cached = $cache->get($key); + if ($cached && $cached['checksum'] === $this->getCacheChecksum()) { + $this->_content = $cached['content'] ?? ''; + $this->_content_meta = $cached['content_meta'] ?? null; + + if ($process_twig && $never_cache_twig) { + $this->_content = $this->processTwig($this->_content); + } + } + } + + if (null === $this->_content) { + $markdown_options = []; + if ($process_markdown) { + // Build markdown options. + $markdown_options = (array)$config->get('system.pages.markdown'); + $markdown_page_options = (array)$this->getNestedProperty('header.markdown'); + if ($markdown_page_options) { + $markdown_options = array_merge($markdown_options, $markdown_page_options); + } + + // pages.markdown_extra is deprecated, but still check it... + if (!isset($markdown_options['extra'])) { + $extra = $this->getNestedProperty('header.markdown_extra') ?? $config->get('system.pages.markdown_extra'); + if (null !== $extra) { + user_error('Configuration option \'system.pages.markdown_extra\' is deprecated since Grav 1.5, use \'system.pages.markdown.extra\' instead', E_USER_DEPRECATED); + + $markdown_options['extra'] = $extra; + } + } + } + $options = [ + 'markdown' => $markdown_options, + 'images' => $config->get('system.images', []) + ]; + + $this->_content = $content; + $grav->fireEvent('onPageContentRaw', new Event(['page' => $this])); + + if ($twig_first && !$never_cache_twig) { + if ($process_twig) { + $this->_content = $this->processTwig($this->_content); + } + + if ($process_markdown) { + $this->_content = $this->processMarkdown($this->_content, $options); + } + + // Content Processed but not cached yet + $grav->fireEvent('onPageContentProcessed', new Event(['page' => $this])); + } else { + if ($process_markdown) { + $options['keep_twig'] = $process_twig; + $this->_content = $this->processMarkdown($this->_content, $options); + } + + // Content Processed but not cached yet + $grav->fireEvent('onPageContentProcessed', new Event(['page' => $this])); + + if ($cache_enable && $never_cache_twig) { + $this->cachePageContent(); + } + + if ($process_twig) { + \assert(is_string($this->_content)); + $this->_content = $this->processTwig($this->_content); + } + } + + if ($cache_enable && !$never_cache_twig) { + $this->cachePageContent(); + } + } + + \assert(is_string($this->_content)); + + // Handle summary divider + $delimiter = $config->get('site.summary.delimiter', '==='); + $divider_pos = mb_strpos($this->_content, "

    {$delimiter}

    "); + if ($divider_pos !== false) { + $this->setProperty('summary_size', $divider_pos); + $this->_content = str_replace("

    {$delimiter}

    ", '', $this->_content); + } + + // Fire event when Page::content() is called + $grav->fireEvent('onPageContent', new Event(['page' => $this])); + + return $this->_content; + } + + /** + * Process the Twig page content. + * + * @param string $content + * @return string + */ + protected function processTwig($content): string + { + /** @var Twig $twig */ + $twig = Grav::instance()['twig']; + + /** @var PageInterface $this */ + return $twig->processPage($this, $content); + } + + /** + * Process the Markdown content. + * + * Uses Parsedown or Parsedown Extra depending on configuration. + * + * @param string $content + * @param array $options + * @return string + * @throws Exception + */ + protected function processMarkdown($content, array $options = []): string + { + /** @var PageInterface $self */ + $self = $this; + + $excerpts = new Excerpts($self, $options); + + // Initialize the preferred variant of markdown parser. + if (isset($options['extra'])) { + $parsedown = new ParsedownExtra($excerpts); + } else { + $parsedown = new Parsedown($excerpts); + } + + $keepTwig = (bool)($options['keep_twig'] ?? false); + if ($keepTwig) { + $token = [ + '/' . Utils::generateRandomString(3), + Utils::generateRandomString(3) . '/' + ]; + // Base64 encode any twig. + $content = preg_replace_callback( + ['/({#.*?#})/mu', '/({{.*?}})/mu', '/({%.*?%})/mu'], + static function ($matches) use ($token) { return $token[0] . base64_encode($matches[1]) . $token[1]; }, + $content + ); + } + + $content = $parsedown->text($content); + + if ($keepTwig) { + // Base64 decode the encoded twig. + $content = preg_replace_callback( + ['`' . $token[0] . '([A-Za-z0-9+/]+={0,2})' . $token[1] . '`mu'], + static function ($matches) { return base64_decode($matches[1]); }, + $content + ); + } + + return $content; + } + + abstract protected function loadHeaderProperty(string $property, $var, callable $filter); +} diff --git a/system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php b/system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php new file mode 100644 index 0000000..f989a2e --- /dev/null +++ b/system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php @@ -0,0 +1,1124 @@ +getFlexDirectory()->getStorage(); + if (method_exists($storage, 'readRaw')) { + return $storage->readRaw($this->getStorageKey()); + } + + $array = $this->prepareStorage(); + $formatter = new MarkdownFormatter(); + + return $formatter->encode($array); + } + + /** + * Gets and Sets the page frontmatter + * + * @param string|null $var + * @return string + */ + public function frontmatter($var = null): string + { + if (null !== $var) { + $formatter = new YamlFormatter(); + $this->setProperty('frontmatter', $var); + $this->setProperty('header', $formatter->decode($var)); + + return $var; + } + + $storage = $this->getFlexDirectory()->getStorage(); + if (method_exists($storage, 'readFrontmatter')) { + return $storage->readFrontmatter($this->getStorageKey()); + } + + $array = $this->prepareStorage(); + $formatter = new YamlFormatter(); + + return $formatter->encode($array['header'] ?? []); + } + + /** + * Modify a header value directly + * + * @param string $key + * @param string|array $value + * @return void + */ + public function modifyHeader($key, $value): void + { + $this->setNestedProperty("header.{$key}", $value); + } + + /** + * @return int + */ + public function httpResponseCode(): int + { + $code = (int)$this->getNestedProperty('header.http_response_code'); + + return $code ?: 200; + } + + /** + * @return array + */ + public function httpHeaders(): array + { + $headers = []; + + $format = $this->templateFormat(); + $cache_control = $this->cacheControl(); + $expires = $this->expires(); + + // Set Content-Type header. + $headers['Content-Type'] = Utils::getMimeByExtension($format, 'text/html'); + + // Calculate Expires Headers if set to > 0. + if ($expires > 0) { + $expires_date = gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT'; + if (!$cache_control) { + $headers['Cache-Control'] = 'max-age=' . $expires; + } + $headers['Expires'] = $expires_date; + } + + // Set Cache-Control header. + if ($cache_control) { + $headers['Cache-Control'] = strtolower($cache_control); + } + + // Set Last-Modified header. + if ($this->lastModified()) { + $last_modified_date = gmdate('D, d M Y H:i:s', $this->modified()) . ' GMT'; + $headers['Last-Modified'] = $last_modified_date; + } + + // Calculate ETag based on the serialized page and modified time. + if ($this->eTag()) { + $headers['ETag'] = '1'; + } + + // Set Vary: Accept-Encoding header. + $grav = Grav::instance(); + if ($grav['config']->get('system.pages.vary_accept_encoding', false)) { + $headers['Vary'] = 'Accept-Encoding'; + } + + return $headers; + } + + /** + * Get the contentMeta array and initialize content first if it's not already + * + * @return array + */ + public function contentMeta(): array + { + // Content meta is generated during the content is being rendered, so make sure we have done it. + $this->content(); + + return $this->_content_meta ?? []; + } + + /** + * Add an entry to the page's contentMeta array + * + * @param string $name + * @param string $value + * @return void + */ + public function addContentMeta($name, $value): void + { + $this->_content_meta[$name] = $value; + } + + /** + * Return the whole contentMeta array as it currently stands + * + * @param string|null $name + * @return string|array|null + */ + public function getContentMeta($name = null) + { + if ($name) { + return $this->_content_meta[$name] ?? null; + } + + return $this->_content_meta ?? []; + } + + /** + * Sets the whole content meta array in one shot + * + * @param array $content_meta + * @return array + */ + public function setContentMeta($content_meta): array + { + return $this->_content_meta = $content_meta; + } + + /** + * Fires the onPageContentProcessed event, and caches the page content using a unique ID for the page + */ + public function cachePageContent(): void + { + $value = [ + 'checksum' => $this->getCacheChecksum(), + 'content' => $this->_content, + 'content_meta' => $this->_content_meta + ]; + + $cache = $this->getCache('render'); + $key = md5($this->getCacheKey() . '-content'); + + $cache->set($key, $value); + } + + /** + * Get file object to the page. + * + * @return MarkdownFile|null + */ + public function file(): ?MarkdownFile + { + // TODO: + throw new RuntimeException(__METHOD__ . '(): Not Implemented'); + } + + /** + * Prepare move page to new location. Moves also everything that's under the current page. + * + * You need to call $this->save() in order to perform the move. + * + * @param PageInterface $parent New parent page. + * @return $this + */ + public function move(PageInterface $parent) + { + if ($this->route() === $parent->route()) { + throw new RuntimeException('Failed: Cannot set page parent to self'); + } + $rawRoute = $this->rawRoute(); + if ($rawRoute && Utils::startsWith($parent->rawRoute(), $rawRoute)) { + throw new RuntimeException('Failed: Cannot set page parent to a child of current page'); + } + + $this->storeOriginal(); + + // TODO: + throw new RuntimeException(__METHOD__ . '(): Not Implemented'); + } + + /** + * Prepare a copy from the page. Copies also everything that's under the current page. + * + * Returns a new Page object for the copy. + * You need to call $this->save() in order to perform the move. + * + * @param PageInterface|null $parent New parent page. + * @return $this + */ + public function copy(PageInterface $parent = null) + { + $this->storeOriginal(); + + $filesystem = Filesystem::getInstance(false); + + $parentStorageKey = ltrim($filesystem->dirname("/{$this->getMasterKey()}"), '/'); + + /** @var FlexPageIndex> $index */ + $index = $this->getFlexDirectory()->getIndex(); + + if ($parent) { + if ($parent instanceof FlexPageObject) { + $k = $parent->getMasterKey(); + if ($k !== $parentStorageKey) { + $parentStorageKey = $k; + } + } else { + throw new RuntimeException('Cannot copy page, parent is of unknown type'); + } + } else { + $parent = $parentStorageKey + ? $this->getFlexDirectory()->getObject($parentStorageKey, 'storage_key') + : (method_exists($index, 'getRoot') ? $index->getRoot() : null); + } + + // Find non-existing key. + $parentKey = $parent ? $parent->getKey() : ''; + if ($this instanceof FlexPageObject) { + $key = trim($parentKey . '/' . $this->folder(), '/'); + $key = preg_replace(static::PAGE_ORDER_PREFIX_REGEX, '', $key); + \assert(is_string($key)); + } else { + $key = trim($parentKey . '/' . Utils::basename($this->getKey()), '/'); + } + + if ($index->containsKey($key)) { + $key = preg_replace('/\d+$/', '', $key); + $i = 1; + do { + $i++; + $test = "{$key}{$i}"; + } while ($index->containsKey($test)); + $key = $test; + } + $folder = Utils::basename($key); + + // Get the folder name. + $order = $this->getProperty('order'); + if ($order) { + $order++; + } + + $parts = []; + if ($parentStorageKey !== '') { + $parts[] = $parentStorageKey; + } + $parts[] = $order ? sprintf('%02d.%s', $order, $folder) : $folder; + + // Finally update the object. + $this->setKey($key); + $this->setStorageKey(implode('/', $parts)); + + $this->markAsCopy(); + + return $this; + } + + /** + * Get the blueprint name for this page. Use the blueprint form field if set + * + * @return string + */ + public function blueprintName(): string + { + if (!isset($_POST['blueprint'])) { + return $this->template(); + } + + $post_value = $_POST['blueprint']; + $sanitized_value = htmlspecialchars(strip_tags($post_value), ENT_QUOTES, 'UTF-8'); + + return $sanitized_value ?: $this->template(); + } + + /** + * Validate page header. + * + * @return void + * @throws Exception + */ + public function validate(): void + { + $blueprint = $this->getBlueprint(); + $blueprint->validate($this->toArray()); + } + + /** + * Filter page header from illegal contents. + * + * @return void + */ + public function filter(): void + { + $blueprints = $this->getBlueprint(); + $values = $blueprints->filter($this->toArray()); + if ($values && isset($values['header'])) { + $this->header($values['header']); + } + } + + /** + * Get unknown header variables. + * + * @return array + */ + public function extra(): array + { + $data = $this->prepareStorage(); + + return $this->getBlueprint()->extra((array)($data['header'] ?? []), 'header.'); + } + + /** + * Convert page to an array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'header' => (array)$this->header(), + 'content' => (string)$this->getFormValue('content') + ]; + } + + /** + * Convert page to YAML encoded string. + * + * @return string + */ + public function toYaml(): string + { + return Yaml::dump($this->toArray(), 20); + } + + /** + * Convert page to JSON encoded string. + * + * @return string + */ + public function toJson(): string + { + $json = json_encode($this->toArray()); + if (!is_string($json)) { + throw new RuntimeException('Internal error'); + } + + return $json; + } + + /** + * Gets and sets the name field. If no name field is set, it will return 'default.md'. + * + * @param string|null $var The name of this page. + * @return string The name of this page. + */ + public function name($var = null): string + { + return $this->loadProperty( + 'name', + $var, + function ($value) { + $value = $value ?? $this->getMetaData()['template'] ?? 'default'; + if (!preg_match('/\.md$/', $value)) { + $language = $this->language(); + if ($language) { + // TODO: better language support + $value .= ".{$language}"; + } + $value .= '.md'; + } + $value = preg_replace('|^modular/|', '', $value); + + $this->unsetProperty('template'); + + return $value; + } + ); + } + + /** + * Returns child page type. + * + * @return string + */ + public function childType(): string + { + return (string)$this->getNestedProperty('header.child_type'); + } + + /** + * Gets and sets the template field. This is used to find the correct Twig template file to render. + * If no field is set, it will return the name without the .md extension + * + * @param string|null $var the template name + * @return string the template name + */ + public function template($var = null): string + { + return $this->loadHeaderProperty( + 'template', + $var, + function ($value) { + return trim($value ?? (($this->isModule() ? 'modular/' : '') . str_replace($this->extension(), '', $this->name()))); + } + ); + } + + /** + * Allows a page to override the output render format, usually the extension provided in the URL. + * (e.g. `html`, `json`, `xml`, etc). + * + * @param string|null $var + * @return string + */ + public function templateFormat($var = null): string + { + return $this->loadHeaderProperty( + 'template_format', + $var, + function ($value) { + return ltrim($value ?? $this->getNestedProperty('header.append_url_extension') ?: Utils::getPageFormat(), '.'); + } + ); + } + + /** + * Gets and sets the extension field. + * + * @param string|null $var + * @return string + */ + public function extension($var = null): string + { + if (null !== $var) { + $this->setProperty('format', $var); + } + + $language = $this->language(); + if ($language) { + $language = '.' . $language; + } + $format = '.' . ($this->getProperty('format') ?? Utils::pathinfo($this->name(), PATHINFO_EXTENSION)); + + return $language . $format; + } + + /** + * Gets and sets the expires field. If not set will return the default + * + * @param int|null $var The new expires value. + * @return int The expires value + */ + public function expires($var = null): int + { + return $this->loadHeaderProperty( + 'expires', + $var, + static function ($value) { + return (int)($value ?? Grav::instance()['config']->get('system.pages.expires')); + } + ); + } + + /** + * Gets and sets the cache-control property. If not set it will return the default value (null) + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control for more details on valid options + * + * @param string|null $var + * @return string|null + */ + public function cacheControl($var = null): ?string + { + return $this->loadHeaderProperty( + 'cache_control', + $var, + static function ($value) { + return ((string)($value ?? Grav::instance()['config']->get('system.pages.cache_control'))) ?: null; + } + ); + } + + /** + * @param bool|null $var + * @return bool|null + */ + public function ssl($var = null): ?bool + { + return $this->loadHeaderProperty( + 'ssl', + $var, + static function ($value) { + return $value ? (bool)$value : null; + } + ); + } + + /** + * Returns the state of the debugger override setting for this page + * + * @return bool + */ + public function debugger(): bool + { + return (bool)$this->getNestedProperty('header.debugger', true); + } + + /** + * Function to merge page metadata tags and build an array of Metadata objects + * that can then be rendered in the page. + * + * @param array|null $var an Array of metadata values to set + * @return array an Array of metadata values for the page + */ + public function metadata($var = null): array + { + if ($var !== null) { + $this->_metadata = (array)$var; + } + + // if not metadata yet, process it. + if (null === $this->_metadata) { + $this->_metadata = []; + + $config = Grav::instance()['config']; + + // Set the Generator tag + $defaultMetadata = ['generator' => 'GravCMS']; + $siteMetadata = $config->get('site.metadata', []); + $headerMetadata = $this->getNestedProperty('header.metadata', []); + + // Get initial metadata for the page + $metadata = array_merge($defaultMetadata, $siteMetadata, $headerMetadata); + + $header_tag_http_equivs = ['content-type', 'default-style', 'refresh', 'x-ua-compatible', 'content-security-policy']; + $escape = !$config->get('system.strict_mode.twig_compat', false) || $config->get('system.twig.autoescape', true); + + // Build an array of meta objects.. + foreach ($metadata as $key => $value) { + // Lowercase the key + $key = strtolower($key); + + // If this is a property type metadata: "og", "twitter", "facebook" etc + // Backward compatibility for nested arrays in metas + if (is_array($value)) { + foreach ($value as $property => $prop_value) { + $prop_key = $key . ':' . $property; + $this->_metadata[$prop_key] = [ + 'name' => $prop_key, + 'property' => $prop_key, + 'content' => $escape ? htmlspecialchars($prop_value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $prop_value + ]; + } + } elseif ($value) { + // If it this is a standard meta data type + if (in_array($key, $header_tag_http_equivs, true)) { + $this->_metadata[$key] = [ + 'http_equiv' => $key, + 'content' => $escape ? htmlspecialchars($value, ENT_COMPAT, 'UTF-8') : $value + ]; + } elseif ($key === 'charset') { + $this->_metadata[$key] = ['charset' => $escape ? htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $value]; + } else { + // if it's a social metadata with separator, render as property + $separator = strpos($key, ':'); + $hasSeparator = $separator && $separator < strlen($key) - 1; + $entry = [ + 'content' => $escape ? htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $value + ]; + + if ($hasSeparator && !Utils::startsWith($key, 'twitter')) { + $entry['property'] = $key; + } else { + $entry['name'] = $key; + } + + $this->_metadata[$key] = $entry; + } + } + } + } + + return $this->_metadata; + } + + /** + * Reset the metadata and pull from header again + */ + public function resetMetadata(): void + { + $this->_metadata = null; + } + + /** + * Gets and sets the option to show the etag header for the page. + * + * @param bool|null $var show etag header + * @return bool show etag header + */ + public function eTag($var = null): bool + { + return $this->loadHeaderProperty( + 'etag', + $var, + static function ($value) { + return (bool)($value ?? Grav::instance()['config']->get('system.pages.etag')); + } + ); + } + + /** + * Gets and sets the path to the .md file for this Page object. + * + * @param string|null $var the file path + * @return string|null the file path + */ + public function filePath($var = null): ?string + { + if (null !== $var) { + // TODO: + throw new RuntimeException(__METHOD__ . '(string): Not Implemented'); + } + + $folder = $this->getStorageFolder(); + if (!$folder) { + return null; + } + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $folder = $locator->isStream($folder) ? $locator->getResource($folder) : GRAV_ROOT . "/{$folder}"; + + return $folder . '/' . ($this->isPage() ? $this->name() : 'default.md'); + } + + /** + * Gets the relative path to the .md file + * + * @return string|null The relative file path + */ + public function filePathClean(): ?string + { + $folder = $this->getStorageFolder(); + if (!$folder) { + return null; + } + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $folder = $locator->isStream($folder) ? $locator->getResource($folder, false) : $folder; + + return $folder . '/' . ($this->isPage() ? $this->name() : 'default.md'); + } + + /** + * Gets and sets the order by which any sub-pages should be sorted. + * + * @param string|null $var the order, either "asc" or "desc" + * @return string the order, either "asc" or "desc" + */ + public function orderDir($var = null): string + { + return $this->loadHeaderProperty( + 'order_dir', + $var, + static function ($value) { + return strtolower(trim($value) ?: Grav::instance()['config']->get('system.pages.order.dir')) === 'desc' ? 'desc' : 'asc'; + } + ); + } + + /** + * Gets and sets the order by which the sub-pages should be sorted. + * + * default - is the order based on the file system, ie 01.Home before 02.Advark + * title - is the order based on the title set in the pages + * date - is the order based on the date set in the pages + * folder - is the order based on the name of the folder with any numerics omitted + * + * @param string|null $var supported options include "default", "title", "date", and "folder" + * @return string supported options include "default", "title", "date", and "folder" + */ + public function orderBy($var = null): string + { + return $this->loadHeaderProperty( + 'order_by', + $var, + static function ($value) { + return trim($value) ?: Grav::instance()['config']->get('system.pages.order.by'); + } + ); + } + + /** + * Gets the manual order set in the header. + * + * @param string|null $var supported options include "default", "title", "date", and "folder" + * @return array + */ + public function orderManual($var = null): array + { + return $this->loadHeaderProperty( + 'order_manual', + $var, + static function ($value) { + return (array)$value; + } + ); + } + + /** + * Gets and sets the maxCount field which describes how many sub-pages should be displayed if the + * sub_pages header property is set for this page object. + * + * @param int|null $var the maximum number of sub-pages + * @return int the maximum number of sub-pages + */ + public function maxCount($var = null): int + { + return $this->loadHeaderProperty( + 'max_count', + $var, + static function ($value) { + return (int)($value ?? Grav::instance()['config']->get('system.pages.list.count')); + } + ); + } + + /** + * Gets and sets the modular var that helps identify this page is a modular child + * + * @param bool|null $var true if modular_twig + * @return bool true if modular_twig + * @deprecated 1.7 Use ->isModule() or ->modularTwig() method instead. + */ + public function modular($var = null): bool + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use ->isModule() or ->modularTwig() method instead', E_USER_DEPRECATED); + + return $this->modularTwig($var); + } + + /** + * Gets and sets the modular_twig var that helps identify this page as a modular child page that will need + * twig processing handled differently from a regular page. + * + * @param bool|null $var true if modular_twig + * @return bool true if modular_twig + */ + public function modularTwig($var = null): bool + { + if ($var !== null) { + $this->setProperty('modular_twig', (bool)$var); + if ($var) { + $this->visible(false); + } + } + + return (bool)($this->getProperty('modular_twig') ?? strpos($this->slug(), '_') === 0); + } + + /** + * Returns children of this page. + * + * @return PageCollectionInterface|FlexIndexInterface + */ + public function children() + { + $meta = $this->getMetaData(); + $keys = array_keys($meta['children'] ?? []); + $prefix = $this->getMasterKey(); + if ($prefix) { + foreach ($keys as &$key) { + $key = $prefix . '/' . $key; + } + unset($key); + } + + return $this->getFlexDirectory()->getIndex($keys, 'storage_key'); + } + + /** + * Check to see if this item is the first in an array of sub-pages. + * + * @return bool True if item is first. + */ + public function isFirst(): bool + { + $parent = $this->parent(); + $children = $parent ? $parent->children() : null; + if ($children instanceof FlexCollectionInterface) { + $children = $children->withKeyField(); + } + + return $children instanceof PageCollectionInterface ? $children->isFirst($this->getKey()) : true; + } + + /** + * Check to see if this item is the last in an array of sub-pages. + * + * @return bool True if item is last + */ + public function isLast(): bool + { + $parent = $this->parent(); + $children = $parent ? $parent->children() : null; + if ($children instanceof FlexCollectionInterface) { + $children = $children->withKeyField(); + } + + return $children instanceof PageCollectionInterface ? $children->isLast($this->getKey()) : true; + } + + /** + * Gets the previous sibling based on current position. + * + * @return PageInterface|false the previous Page item + */ + public function prevSibling() + { + return $this->adjacentSibling(-1); + } + + /** + * Gets the next sibling based on current position. + * + * @return PageInterface|false the next Page item + */ + public function nextSibling() + { + return $this->adjacentSibling(1); + } + + /** + * Returns the adjacent sibling based on a direction. + * + * @param int $direction either -1 or +1 + * @return PageInterface|false the sibling page + */ + public function adjacentSibling($direction = 1) + { + $parent = $this->parent(); + $children = $parent ? $parent->children() : null; + if ($children instanceof FlexCollectionInterface) { + $children = $children->withKeyField(); + } + + if ($children instanceof PageCollectionInterface) { + $child = $children->adjacentSibling($this->getKey(), $direction); + if ($child instanceof PageInterface) { + return $child; + } + } + + return false; + } + + /** + * Helper method to return an ancestor page. + * + * @param string|null $lookup Name of the parent folder + * @return PageInterface|null page you were looking for if it exists + */ + public function ancestor($lookup = null) + { + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->ancestor($this->getProperty('parent_route'), $lookup); + } + + /** + * Helper method to return an ancestor page to inherit from. The current + * page object is returned. + * + * @param string $field Name of the parent folder + * @return PageInterface|null + */ + public function inherited($field) + { + [$inherited, $currentParams] = $this->getInheritedParams($field); + + $this->modifyHeader($field, $currentParams); + + return $inherited; + } + + /** + * Helper method to return an ancestor field only to inherit from. The + * first occurrence of an ancestor field will be returned if at all. + * + * @param string $field Name of the parent folder + * @return array + */ + public function inheritedField($field): array + { + [, $currentParams] = $this->getInheritedParams($field); + + return $currentParams; + } + + /** + * Method that contains shared logic for inherited() and inheritedField() + * + * @param string $field Name of the parent folder + * @return array + */ + protected function getInheritedParams($field): array + { + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + $inherited = $pages->inherited($this->getProperty('parent_route'), $field); + $inheritedParams = $inherited ? (array)$inherited->value('header.' . $field) : []; + $currentParams = (array)$this->getFormValue('header.' . $field); + if ($inheritedParams && is_array($inheritedParams)) { + $currentParams = array_replace_recursive($inheritedParams, $currentParams); + } + + return [$inherited, $currentParams]; + } + + /** + * Helper method to return a page. + * + * @param string $url the url of the page + * @param bool $all + * @return PageInterface|null page you were looking for if it exists + */ + public function find($url, $all = false) + { + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->find($url, $all); + } + + /** + * Get a collection of pages in the current context. + * + * @param string|array $params + * @param bool $pagination + * @return PageCollectionInterface|Collection + * @throws InvalidArgumentException + */ + public function collection($params = 'content', $pagination = true) + { + if (is_string($params)) { + // Look into a page header field. + $params = (array)$this->getFormValue('header.' . $params); + } elseif (!is_array($params)) { + throw new InvalidArgumentException('Argument should be either header variable name or array of parameters'); + } + + if (!$pagination) { + $params['pagination'] = false; + } + $context = [ + 'pagination' => $pagination, + 'self' => $this + ]; + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->getCollection($params, $context); + } + + /** + * @param string|array $value + * @param bool $only_published + * @return PageCollectionInterface|Collection + */ + public function evaluate($value, $only_published = true) + { + $params = [ + 'items' => $value, + 'published' => $only_published + ]; + $context = [ + 'event' => false, + 'pagination' => false, + 'url_taxonomy_filters' => false, + 'self' => $this + ]; + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->getCollection($params, $context); + } + + /** + * Returns whether or not the current folder exists + * + * @return bool + */ + public function folderExists(): bool + { + return $this->exists() || is_dir($this->getStorageFolder() ?? ''); + } + + /** + * Gets the action. + * + * @return string|null The Action string. + */ + public function getAction(): ?string + { + $meta = $this->getMetaData(); + if (!empty($meta['copy'])) { + return 'copy'; + } + if (isset($meta['storage_key']) && $this->getStorageKey() !== $meta['storage_key']) { + return 'move'; + } + + return null; + } +} diff --git a/system/src/Grav/Framework/Flex/Pages/Traits/PageRoutableTrait.php b/system/src/Grav/Framework/Flex/Pages/Traits/PageRoutableTrait.php new file mode 100644 index 0000000..619934e --- /dev/null +++ b/system/src/Grav/Framework/Flex/Pages/Traits/PageRoutableTrait.php @@ -0,0 +1,550 @@ +loadHeaderProperty( + 'url_extension', + null, + function ($value) { + if ($this->home()) { + return ''; + } + + return $value ?? Grav::instance()['config']->get('system.pages.append_url_extension', ''); + } + ); + } + + /** + * Gets and Sets whether or not this Page is routable, ie you can reach it via a URL. + * The page must be *routable* and *published* + * + * @param bool|null $var true if the page is routable + * @return bool true if the page is routable + */ + public function routable($var = null): bool + { + $value = $this->loadHeaderProperty( + 'routable', + $var, + static function ($value) { + return $value ?? true; + } + ); + + return $value && $this->published() && !$this->isModule() && !$this->root() && $this->getLanguages(true); + } + + /** + * Gets the URL for a page - alias of url(). + * + * @param bool $include_host + * @return string the permalink + */ + public function link($include_host = false): string + { + return $this->url($include_host); + } + + /** + * Gets the URL with host information, aka Permalink. + * @return string The permalink. + */ + public function permalink(): string + { + return $this->url(true, false, true, true); + } + + /** + * Returns the canonical URL for a page + * + * @param bool $include_lang + * @return string + */ + public function canonical($include_lang = true): string + { + return $this->url(true, true, $include_lang); + } + + /** + * Gets the url for the Page. + * + * @param bool $include_host Defaults false, but true would include http://yourhost.com + * @param bool $canonical true to return the canonical URL + * @param bool $include_base + * @param bool $raw_route + * @return string The url. + */ + public function url($include_host = false, $canonical = false, $include_base = true, $raw_route = false): string + { + // Override any URL when external_url is set + $external = $this->getNestedProperty('header.external_url'); + if ($external) { + return $external; + } + + $grav = Grav::instance(); + + /** @var Pages $pages */ + $pages = $grav['pages']; + + /** @var Config $config */ + $config = $grav['config']; + + // get base route (multi-site base and language) + $route = $include_base ? $pages->baseRoute() : ''; + + // add full route if configured to do so + if (!$include_host && $config->get('system.absolute_urls', false)) { + $include_host = true; + } + + if ($canonical) { + $route .= $this->routeCanonical(); + } elseif ($raw_route) { + $route .= $this->rawRoute(); + } else { + $route .= $this->route(); + } + + /** @var Uri $uri */ + $uri = $grav['uri']; + $url = $uri->rootUrl($include_host) . '/' . trim($route, '/') . $this->urlExtension(); + + return Uri::filterPath($url); + } + + /** + * Gets the route for the page based on the route headers if available, else from + * the parents route and the current Page's slug. + * + * @param string $var Set new default route. + * @return string|null The route for the Page. + */ + public function route($var = null): ?string + { + if (null !== $var) { + // TODO: not the best approach, but works... + $this->setNestedProperty('header.routes.default', $var); + } + + // Return default route if given. + $default = $this->getNestedProperty('header.routes.default'); + if (is_string($default)) { + return $default; + } + + return $this->routeInternal(); + } + + /** + * @return string|null + */ + protected function routeInternal(): ?string + { + $route = $this->_route; + if (null !== $route) { + return $route; + } + + if ($this->root()) { + return null; + } + + // Root and orphan nodes have no route. + $parent = $this->parent(); + if (!$parent) { + return null; + } + + if ($parent->home()) { + /** @var Config $config */ + $config = Grav::instance()['config']; + $hide = (bool)$config->get('system.home.hide_in_urls', false); + $route = '/' . ($hide ? '' : $parent->slug()); + } else { + $route = $parent->route(); + } + + if ($route !== '' && $route !== '/') { + $route .= '/'; + } + + if (!$this->home()) { + $route .= $this->slug(); + } + + $this->_route = $route; + + return $route; + } + + /** + * Helper method to clear the route out so it regenerates next time you use it + */ + public function unsetRouteSlug(): void + { + // TODO: + throw new RuntimeException(__METHOD__ . '(): Not Implemented'); + } + + /** + * Gets and Sets the page raw route + * + * @param string|null $var + * @return string|null + */ + public function rawRoute($var = null): ?string + { + if (null !== $var) { + // TODO: + throw new RuntimeException(__METHOD__ . '(string): Not Implemented'); + } + + if ($this->root()) { + return null; + } + + return '/' . $this->getKey(); + } + + /** + * Gets the route aliases for the page based on page headers. + * + * @param array|null $var list of route aliases + * @return array The route aliases for the Page. + */ + public function routeAliases($var = null): array + { + if (null !== $var) { + $this->setNestedProperty('header.routes.aliases', (array)$var); + } + + $aliases = (array)$this->getNestedProperty('header.routes.aliases'); + $default = $this->getNestedProperty('header.routes.default'); + if ($default) { + $aliases[] = $default; + } + + return $aliases; + } + + /** + * Gets the canonical route for this page if its set. If provided it will use + * that value, else if it's `true` it will use the default route. + * + * @param string|null $var + * @return string|null + */ + public function routeCanonical($var = null): ?string + { + if (null !== $var) { + $this->setNestedProperty('header.routes.canonical', (array)$var); + } + + $canonical = $this->getNestedProperty('header.routes.canonical'); + + return is_string($canonical) ? $canonical : $this->route(); + } + + /** + * Gets the redirect set in the header. + * + * @param string|null $var redirect url + * @return string|null + */ + public function redirect($var = null): ?string + { + return $this->loadHeaderProperty( + 'redirect', + $var, + static function ($value) { + return trim($value) ?: null; + } + ); + } + + /** + * Returns the clean path to the page file + * + * Needed in admin for Page Media. + */ + public function relativePagePath(): ?string + { + $folder = $this->getMediaFolder(); + if (!$folder) { + return null; + } + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $path = $locator->isStream($folder) ? $locator->findResource($folder, false) : $folder; + + return is_string($path) ? $path : null; + } + + /** + * Gets and sets the path to the folder where the .md for this Page object resides. + * This is equivalent to the filePath but without the filename. + * + * @param string|null $var the path + * @return string|null the path + */ + public function path($var = null): ?string + { + if (null !== $var) { + // TODO: + throw new RuntimeException(__METHOD__ . '(string): Not Implemented'); + } + + $path = $this->_path; + if ($path) { + return $path; + } + + if ($this->root()) { + $folder = $this->getFlexDirectory()->getStorageFolder(); + } else { + $folder = $this->getStorageFolder(); + } + + if ($folder) { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $folder = $locator->isStream($folder) ? $locator->getResource($folder) : GRAV_ROOT . "/{$folder}"; + } + + return $this->_path = is_string($folder) ? $folder : null; + } + + /** + * Get/set the folder. + * + * @param string|null $var Optional path, including numeric prefix. + * @return string|null + */ + public function folder($var = null): ?string + { + return $this->loadProperty( + 'folder', + $var, + function ($value) { + if (null === $value) { + $value = $this->getMasterKey() ?: $this->getKey(); + } + + return Utils::basename($value) ?: null; + } + ); + } + + /** + * Get/set the folder. + * + * @param string|null $var Optional path, including numeric prefix. + * @return string|null + */ + public function parentStorageKey($var = null): ?string + { + return $this->loadProperty( + 'parent_key', + $var, + function ($value) { + if (null === $value) { + $filesystem = Filesystem::getInstance(false); + $value = $this->getMasterKey() ?: $this->getKey(); + $value = ltrim($filesystem->dirname("/{$value}"), '/') ?: ''; + } + + return $value; + } + ); + } + + /** + * Gets and Sets the parent object for this page + * + * @param PageInterface|null $var the parent page object + * @return PageInterface|null the parent page object if it exists. + */ + public function parent(PageInterface $var = null) + { + if (null !== $var) { + // TODO: + throw new RuntimeException(__METHOD__ . '(PageInterface): Not Implemented'); + } + + if ($this->_parentCache || $this->root()) { + return $this->_parentCache; + } + + // Use filesystem as \dirname() does not work in Windows because of '/foo' becomes '\'. + $filesystem = Filesystem::getInstance(false); + $directory = $this->getFlexDirectory(); + $parentKey = ltrim($filesystem->dirname("/{$this->getKey()}"), '/'); + if ('' !== $parentKey) { + $parent = $directory->getObject($parentKey); + $language = $this->getLanguage(); + if ($language && $parent && method_exists($parent, 'getTranslation')) { + $parent = $parent->getTranslation($language) ?? $parent; + } + + $this->_parentCache = $parent; + } else { + $index = $directory->getIndex(); + + $this->_parentCache = \is_callable([$index, 'getRoot']) ? $index->getRoot() : null; + } + + return $this->_parentCache; + } + + /** + * Gets the top parent object for this page. Can return page itself. + * + * @return PageInterface The top parent page object. + */ + public function topParent() + { + $topParent = $this; + while ($topParent) { + $parent = $topParent->parent(); + if (!$parent || !$parent->parent()) { + break; + } + $topParent = $parent; + } + + return $topParent; + } + + /** + * Returns the item in the current position. + * + * @return int|null the index of the current page. + */ + public function currentPosition(): ?int + { + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if ($collection instanceof PageCollectionInterface && $path = $this->path()) { + return $collection->currentPosition($path); + } + + return 1; + } + + /** + * Returns whether or not this page is the currently active page requested via the URL. + * + * @return bool True if it is active + */ + public function active(): bool + { + $grav = Grav::instance(); + $uri_path = rtrim(urldecode($grav['uri']->path()), '/') ?: '/'; + $routes = $grav['pages']->routes(); + + return isset($routes[$uri_path]) && $routes[$uri_path] === $this->path(); + } + + /** + * Returns whether or not this URI's URL contains the URL of the active page. + * Or in other words, is this page's URL in the current URL + * + * @return bool True if active child exists + */ + public function activeChild(): bool + { + $grav = Grav::instance(); + /** @var Uri $uri */ + $uri = $grav['uri']; + /** @var Pages $pages */ + $pages = $grav['pages']; + $uri_path = rtrim(urldecode($uri->path()), '/'); + $routes = $pages->routes(); + + if (isset($routes[$uri_path])) { + $page = $pages->find($uri->route()); + /** @var PageInterface|null $child_page */ + $child_page = $page ? $page->parent() : null; + while ($child_page && !$child_page->root()) { + if ($this->path() === $child_page->path()) { + return true; + } + $child_page = $child_page->parent(); + } + } + + return false; + } + + /** + * Returns whether or not this page is the currently configured home page. + * + * @return bool True if it is the homepage + */ + public function home(): bool + { + $home = Grav::instance()['config']->get('system.home.alias'); + + return '/' . $this->getKey() === $home; + } + + /** + * Returns whether or not this page is the root node of the pages tree. + * + * @param bool|null $var + * @return bool True if it is the root + */ + public function root($var = null): bool + { + if (null !== $var) { + $this->root = (bool)$var; + } + + return $this->root === true || $this->getKey() === '/'; + } +} diff --git a/system/src/Grav/Framework/Flex/Pages/Traits/PageTranslateTrait.php b/system/src/Grav/Framework/Flex/Pages/Traits/PageTranslateTrait.php new file mode 100644 index 0000000..7af1b4f --- /dev/null +++ b/system/src/Grav/Framework/Flex/Pages/Traits/PageTranslateTrait.php @@ -0,0 +1,291 @@ +translatedLanguages(true); + } + + /** + * @param string|null $languageCode + * @param bool|null $fallback + * @return bool + */ + public function hasTranslation(string $languageCode = null, bool $fallback = null): bool + { + $code = $this->findTranslation($languageCode, $fallback); + + return null !== $code; + } + + /** + * @param string|null $languageCode + * @param bool|null $fallback + * @return FlexObjectInterface|PageInterface|null + */ + public function getTranslation(string $languageCode = null, bool $fallback = null) + { + if ($this->root()) { + return $this; + } + + $code = $this->findTranslation($languageCode, $fallback); + if (null === $code) { + $object = null; + } elseif ('' === $code) { + $object = $this->getLanguage() ? $this->getFlexDirectory()->getObject($this->getMasterKey(), 'storage_key') : $this; + } else { + $meta = $this->getMetaData(); + $meta['template'] = $this->getLanguageTemplates()[$code] ?? $meta['template']; + $key = $this->getStorageKey() . '|' . $meta['template'] . '.' . $code; + $meta['storage_key'] = $key; + $meta['lang'] = $code; + $object = $this->getFlexDirectory()->loadObjects([$key => $meta])[$key] ?? null; + } + + return $object; + } + + /** + * @param bool $includeDefault If set to true, return separate entries for '' and 'en' (default) language. + * @return array + */ + public function getAllLanguages(bool $includeDefault = false): array + { + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + $languages = $language->getLanguages(); + if (!$languages) { + return []; + } + + $translated = $this->getLanguageTemplates(); + + if ($includeDefault) { + $languages[] = ''; + } elseif (isset($translated[''])) { + $default = $language->getDefault(); + if (is_bool($default)) { + $default = ''; + } + $translated[$default] = $translated['']; + unset($translated['']); + } + + $languages = array_fill_keys($languages, false); + $translated = array_fill_keys(array_keys($translated), true); + + return array_replace($languages, $translated); + } + + /** + * Returns all translated languages. + * + * @param bool $includeDefault If set to true, return separate entries for '' and 'en' (default) language. + * @return array + */ + public function getLanguages(bool $includeDefault = false): array + { + $languages = $this->getLanguageTemplates(); + + if (!$includeDefault && isset($languages[''])) { + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + $default = $language->getDefault(); + if (is_bool($default)) { + $default = ''; + } + $languages[$default] = $languages['']; + unset($languages['']); + } + + return array_keys($languages); + } + + /** + * @return string + */ + public function getLanguage(): string + { + return $this->language() ?? ''; + } + + /** + * @param string|null $languageCode + * @param bool|null $fallback + * @return string|null + */ + public function findTranslation(string $languageCode = null, bool $fallback = null): ?string + { + $translated = $this->getLanguageTemplates(); + + // If there's no translations (including default), we have an empty folder. + if (!$translated) { + return ''; + } + + // FIXME: only published is not implemented... + $languages = $this->getFallbackLanguages($languageCode, $fallback); + + $language = null; + foreach ($languages as $code) { + if (isset($translated[$code])) { + $language = $code; + break; + } + } + + return $language; + } + + /** + * Return an array with the routes of other translated languages + * + * @param bool $onlyPublished only return published translations + * @return array the page translated languages + */ + public function translatedLanguages($onlyPublished = false): array + { + // FIXME: only published is not implemented... + $translated = $this->getLanguageTemplates(); + if (!$translated) { + return $translated; + } + + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + $languages = $language->getLanguages(); + $languages[] = ''; + + $translated = array_intersect_key($translated, array_flip($languages)); + $list = array_fill_keys($languages, null); + foreach ($translated as $languageCode => $languageFile) { + $path = ($languageCode ? '/' : '') . $languageCode; + $list[$languageCode] = "{$path}/{$this->getKey()}"; + } + + return array_filter($list); + } + + /** + * Return an array listing untranslated languages available + * + * @param bool $includeUnpublished also list unpublished translations + * @return array the page untranslated languages + */ + public function untranslatedLanguages($includeUnpublished = false): array + { + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + + $languages = $language->getLanguages(); + $translated = array_keys($this->translatedLanguages(!$includeUnpublished)); + + return array_values(array_diff($languages, $translated)); + } + + /** + * Get page language + * + * @param string|null $var + * @return string|null + */ + public function language($var = null): ?string + { + return $this->loadHeaderProperty( + 'lang', + $var, + function ($value) { + $value = $value ?? $this->getMetaData()['lang'] ?? ''; + + return trim($value) ?: null; + } + ); + } + + /** + * @return array + */ + protected function getLanguageTemplates(): array + { + if (null === $this->_languages) { + $template = $this->getProperty('template'); + $meta = $this->getMetaData(); + $translations = $meta['markdown'] ?? []; + $list = []; + foreach ($translations as $code => $search) { + if (isset($search[$template])) { + // Use main template if possible. + $list[$code] = $template; + } elseif (!empty($search)) { + // Fall back to first matching template. + $list[$code] = key($search); + } + } + + $this->_languages = $list; + } + + return $this->_languages; + } + + /** + * @param string|null $languageCode + * @param bool|null $fallback + * @return array + */ + protected function getFallbackLanguages(string $languageCode = null, bool $fallback = null): array + { + $fallback = $fallback ?? true; + if (!$fallback && null !== $languageCode) { + return [$languageCode]; + } + + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + $languageCode = $languageCode ?? ($language->getLanguage() ?: ''); + if ($languageCode === '' && $fallback) { + return $language->getFallbackLanguages(null, true); + } + + return $fallback ? $language->getFallbackLanguages($languageCode, true) : [$languageCode]; + } +} diff --git a/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php b/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php new file mode 100644 index 0000000..e02ef53 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php @@ -0,0 +1,232 @@ +hasKey((string)$key); + } + + return $list; + } + + /** + * {@inheritDoc} + * @see FlexStorageInterface::getKeyField() + */ + public function getKeyField(): string + { + return $this->keyField; + } + + /** + * @param array $keys + * @param bool $includeParams + * @return string + */ + public function buildStorageKey(array $keys, bool $includeParams = true): string + { + $key = $keys['key'] ?? ''; + $params = $includeParams ? $this->buildStorageKeyParams($keys) : ''; + + return $params ? "{$key}|{$params}" : $key; + } + + /** + * @param array $keys + * @return string + */ + public function buildStorageKeyParams(array $keys): string + { + return ''; + } + + /** + * @param array $row + * @return array + */ + public function extractKeysFromRow(array $row): array + { + return [ + 'key' => $this->normalizeKey($row[$this->keyField] ?? '') + ]; + } + + /** + * @param string $key + * @return array + */ + public function extractKeysFromStorageKey(string $key): array + { + return [ + 'key' => $key + ]; + } + + /** + * @param string|array $formatter + * @return void + */ + protected function initDataFormatter($formatter): void + { + // Initialize formatter. + if (!is_array($formatter)) { + $formatter = ['class' => $formatter]; + } + $formatterClassName = $formatter['class'] ?? JsonFormatter::class; + $formatterOptions = $formatter['options'] ?? []; + + if (!is_a($formatterClassName, FileFormatterInterface::class, true)) { + throw new \InvalidArgumentException('Bad Data Formatter'); + } + + $this->dataFormatter = new $formatterClassName($formatterOptions); + } + + /** + * @param string $filename + * @return string|null + */ + protected function detectDataFormatter(string $filename): ?string + { + if (preg_match('|(\.[a-z0-9]*)$|ui', $filename, $matches)) { + switch ($matches[1]) { + case '.json': + return JsonFormatter::class; + case '.yaml': + return YamlFormatter::class; + case '.md': + return MarkdownFormatter::class; + } + } + + return null; + } + + /** + * @param string $filename + * @return CompiledJsonFile|CompiledYamlFile|CompiledMarkdownFile + */ + protected function getFile(string $filename) + { + $filename = $this->resolvePath($filename); + + // TODO: start using the new file classes. + switch ($this->dataFormatter->getDefaultFileExtension()) { + case '.json': + $file = CompiledJsonFile::instance($filename); + break; + case '.yaml': + $file = CompiledYamlFile::instance($filename); + break; + case '.md': + $file = CompiledMarkdownFile::instance($filename); + break; + default: + throw new RuntimeException('Unknown extension type ' . $this->dataFormatter->getDefaultFileExtension()); + } + + return $file; + } + + /** + * @param string $path + * @return string + */ + protected function resolvePath(string $path): string + { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + if (!$locator->isStream($path)) { + return GRAV_ROOT . "/{$path}"; + } + + return $locator->getResource($path); + } + + /** + * Generates a random, unique key for the row. + * + * @return string + */ + protected function generateKey(): string + { + return substr(hash('sha256', random_bytes($this->keyLen)), 0, $this->keyLen); + } + + /** + * @param string $key + * @return string + */ + public function normalizeKey(string $key): string + { + if ($this->caseSensitive === true) { + return $key; + } + + return mb_strtolower($key); + } + + /** + * Checks if a key is valid. + * + * @param string $key + * @return bool + */ + protected function validateKey(string $key): bool + { + return $key && (bool) preg_match('/^[^\\/?*:;{}\\\\\\n]+$/u', $key); + } +} diff --git a/system/src/Grav/Framework/Flex/Storage/FileStorage.php b/system/src/Grav/Framework/Flex/Storage/FileStorage.php new file mode 100644 index 0000000..1fe8c18 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/FileStorage.php @@ -0,0 +1,160 @@ +dataPattern = '{FOLDER}/{KEY}{EXT}'; + + if (!isset($options['formatter']) && isset($options['pattern'])) { + $options['formatter'] = $this->detectDataFormatter($options['pattern']); + } + + parent::__construct($options); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getMediaPath() + */ + public function getMediaPath(string $key = null): ?string + { + $path = $this->getStoragePath(); + if (!$path) { + return null; + } + + return $key ? "{$path}/{$key}" : $path; + } + + /** + * @param string $src + * @param string $dst + * @return bool + */ + public function copyRow(string $src, string $dst): bool + { + if ($this->hasKey($dst)) { + throw new RuntimeException("Cannot copy object: key '{$dst}' is already taken"); + } + + if (!$this->hasKey($src)) { + return false; + } + + return true; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::renameRow() + */ + public function renameRow(string $src, string $dst): bool + { + if (!$this->hasKey($src)) { + return false; + } + + // Remove old file. + $path = $this->getPathFromKey($src); + $file = $this->getFile($path); + $file->delete(); + $file->free(); + unset($file); + + return true; + } + + /** + * @param string $src + * @param string $dst + * @return bool + */ + protected function copyFolder(string $src, string $dst): bool + { + // Nothing to copy. + return true; + } + + /** + * @param string $src + * @param string $dst + * @return bool + */ + protected function moveFolder(string $src, string $dst): bool + { + // Nothing to move. + return true; + } + + /** + * @param string $key + * @return bool + */ + protected function canDeleteFolder(string $key): bool + { + return false; + } + + /** + * {@inheritdoc} + */ + protected function getKeyFromPath(string $path): string + { + return Utils::basename($path, $this->dataFormatter->getDefaultFileExtension()); + } + + /** + * {@inheritdoc} + */ + protected function buildIndex(): array + { + $this->clearCache(); + + $path = $this->getStoragePath(); + if (!$path || !file_exists($path)) { + return []; + } + + $flags = FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS; + $iterator = new FilesystemIterator($path, $flags); + $list = []; + /** @var SplFileInfo $info */ + foreach ($iterator as $filename => $info) { + if (!$info->isFile() || !($key = $this->getKeyFromPath($filename)) || strpos($info->getFilename(), '.') === 0) { + continue; + } + + $list[$key] = $this->getObjectMeta($key); + } + + ksort($list, SORT_NATURAL | SORT_FLAG_CASE); + + return $list; + } +} diff --git a/system/src/Grav/Framework/Flex/Storage/FolderStorage.php b/system/src/Grav/Framework/Flex/Storage/FolderStorage.php new file mode 100644 index 0000000..6f03d9d --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/FolderStorage.php @@ -0,0 +1,708 @@ + '%1$s', 'KEY' => '%2$s', 'KEY:2' => '%3$s', 'FILE' => '%4$s', 'EXT' => '%5$s']; + /** @var string Filename for the object. */ + protected $dataFile; + /** @var string File extension for the object. */ + protected $dataExt; + /** @var bool */ + protected $prefixed; + /** @var bool */ + protected $indexed; + /** @var array */ + protected $meta = []; + + /** + * {@inheritdoc} + */ + public function __construct(array $options) + { + if (!isset($options['folder'])) { + throw new InvalidArgumentException("Argument \$options is missing 'folder'"); + } + + $this->initDataFormatter($options['formatter'] ?? []); + $this->initOptions($options); + } + + /** + * @return bool + */ + public function isIndexed(): bool + { + return $this->indexed; + } + + /** + * @return void + */ + public function clearCache(): void + { + $this->meta = []; + } + + /** + * @param string[] $keys + * @param bool $reload + * @return array + */ + public function getMetaData(array $keys, bool $reload = false): array + { + $list = []; + foreach ($keys as $key) { + $list[$key] = $this->getObjectMeta((string)$key, $reload); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getExistingKeys() + */ + public function getExistingKeys(): array + { + return $this->buildIndex(); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::hasKey() + */ + public function hasKey(string $key): bool + { + $meta = $this->getObjectMeta($key); + + return array_key_exists('exists', $meta) ? $meta['exists'] : !empty($meta['storage_timestamp']); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::createRows() + */ + public function createRows(array $rows): array + { + $list = []; + foreach ($rows as $key => $row) { + $list[$key] = $this->saveRow('@@', $row); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::readRows() + */ + public function readRows(array $rows, array &$fetched = null): array + { + $list = []; + foreach ($rows as $key => $row) { + if (null === $row || is_scalar($row)) { + // Only load rows which haven't been loaded before. + $key = (string)$key; + $list[$key] = $this->loadRow($key); + + if (null !== $fetched) { + $fetched[$key] = $list[$key]; + } + } else { + // Keep the row if it has been loaded. + $list[$key] = $row; + } + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::updateRows() + */ + public function updateRows(array $rows): array + { + $list = []; + foreach ($rows as $key => $row) { + $key = (string)$key; + $list[$key] = $this->hasKey($key) ? $this->saveRow($key, $row) : null; + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::deleteRows() + */ + public function deleteRows(array $rows): array + { + $list = []; + $baseMediaPath = $this->getMediaPath(); + foreach ($rows as $key => $row) { + $key = (string)$key; + if (!$this->hasKey($key)) { + $list[$key] = null; + } else { + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + $list[$key] = $this->deleteFile($file); + + if ($this->canDeleteFolder($key)) { + $storagePath = $this->getStoragePath($key); + $mediaPath = $this->getMediaPath($key); + + if ($storagePath) { + $this->deleteFolder($storagePath, true); + } + if ($mediaPath && $mediaPath !== $storagePath && $mediaPath !== $baseMediaPath) { + $this->deleteFolder($mediaPath, true); + } + } + } + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::replaceRows() + */ + public function replaceRows(array $rows): array + { + $list = []; + foreach ($rows as $key => $row) { + $key = (string)$key; + $list[$key] = $this->saveRow($key, $row); + } + + return $list; + } + + /** + * @param string $src + * @param string $dst + * @return bool + * @throws RuntimeException + */ + public function copyRow(string $src, string $dst): bool + { + if ($this->hasKey($dst)) { + throw new RuntimeException("Cannot copy object: key '{$dst}' is already taken"); + } + + if (!$this->hasKey($src)) { + return false; + } + + $srcPath = $this->getStoragePath($src); + $dstPath = $this->getStoragePath($dst); + if (!$srcPath || !$dstPath) { + return false; + } + + return $this->copyFolder($srcPath, $dstPath); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::renameRow() + * @throws RuntimeException + */ + public function renameRow(string $src, string $dst): bool + { + if (!$this->hasKey($src)) { + return false; + } + + $srcPath = $this->getStoragePath($src); + $dstPath = $this->getStoragePath($dst); + if (!$srcPath || !$dstPath) { + throw new RuntimeException("Destination path '{$dst}' is empty"); + } + + if ($srcPath === $dstPath) { + return true; + } + + if ($this->hasKey($dst)) { + throw new RuntimeException("Cannot rename object '{$src}': key '{$dst}' is already taken $srcPath $dstPath"); + } + + return $this->moveFolder($srcPath, $dstPath); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getStoragePath() + */ + public function getStoragePath(string $key = null): ?string + { + if (null === $key || $key === '') { + $path = $this->dataFolder; + } else { + $parts = $this->parseKey($key, false); + $options = [ + $this->dataFolder, // {FOLDER} + $parts['key'], // {KEY} + $parts['key:2'], // {KEY:2} + '***', // {FILE} + '***' // {EXT} + ]; + + $path = rtrim(explode('***', sprintf($this->dataPattern, ...$options))[0], '/'); + } + + return $path; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getMediaPath() + */ + public function getMediaPath(string $key = null): ?string + { + return $this->getStoragePath($key); + } + + /** + * Get filesystem path from the key. + * + * @param string $key + * @return string + */ + public function getPathFromKey(string $key): string + { + $parts = $this->parseKey($key); + $options = [ + $this->dataFolder, // {FOLDER} + $parts['key'], // {KEY} + $parts['key:2'], // {KEY:2} + $parts['file'], // {FILE} + $this->dataExt // {EXT} + ]; + + return sprintf($this->dataPattern, ...$options); + } + + /** + * @param string $key + * @param bool $variations + * @return array + */ + public function parseKey(string $key, bool $variations = true): array + { + $keys = [ + 'key' => $key, + 'key:2' => mb_substr($key, 0, 2), + ]; + if ($variations) { + $keys['file'] = $this->dataFile; + } + + return $keys; + } + + /** + * Get key from the filesystem path. + * + * @param string $path + * @return string + */ + protected function getKeyFromPath(string $path): string + { + return Utils::basename($path); + } + + /** + * Prepares the row for saving and returns the storage key for the record. + * + * @param array $row + * @return void + */ + protected function prepareRow(array &$row): void + { + if (array_key_exists($this->keyField, $row)) { + $key = $row[$this->keyField]; + if ($key === $this->normalizeKey($key)) { + unset($row[$this->keyField]); + } + } + } + + /** + * @param string $key + * @return array + */ + protected function loadRow(string $key): ?array + { + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + try { + $data = (array)$file->content(); + if (isset($data[0])) { + throw new RuntimeException('Broken object file'); + } + + // Add key field to the object. + $keyField = $this->keyField; + if ($keyField !== 'storage_key' && !isset($data[$keyField])) { + $data[$keyField] = $key; + } + } catch (RuntimeException $e) { + $data = ['__ERROR' => $e->getMessage()]; + } finally { + $file->free(); + unset($file); + } + + $data['__META'] = $this->getObjectMeta($key); + + return $data; + } + + /** + * @param string $key + * @param array $row + * @return array + */ + protected function saveRow(string $key, array $row): array + { + try { + if (isset($row[$this->keyField])) { + $key = $row[$this->keyField]; + } + if (strpos($key, '@@') !== false) { + $key = $this->getNewKey(); + } + + $key = $this->normalizeKey($key); + + // Check if the row already exists and if the key has been changed. + $oldKey = $row['__META']['storage_key'] ?? null; + if (is_string($oldKey) && $oldKey !== $key) { + $isCopy = $row['__META']['copy'] ?? false; + if ($isCopy) { + $this->copyRow($oldKey, $key); + } else { + $this->renameRow($oldKey, $key); + } + } + + $this->prepareRow($row); + unset($row['__META'], $row['__ERROR']); + + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + + $file->save($row); + + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex saveFile(%s): %s', $path ?? $key, $e->getMessage())); + } finally { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $locator->clearCache(); + + if (isset($file)) { + $file->free(); + unset($file); + } + } + + $row['__META'] = $this->getObjectMeta($key, true); + + return $row; + } + + /** + * @param File $file + * @return array|string + */ + protected function deleteFile(File $file) + { + $filename = $file->filename(); + try { + $data = $file->content(); + if ($file->exists()) { + $file->delete(); + } + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex deleteFile(%s): %s', $filename, $e->getMessage())); + } finally { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $locator->clearCache(); + + $file->free(); + } + + return $data; + } + + /** + * @param string $src + * @param string $dst + * @return bool + */ + protected function copyFolder(string $src, string $dst): bool + { + try { + Folder::copy($this->resolvePath($src), $this->resolvePath($dst)); + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex copyFolder(%s, %s): %s', $src, $dst, $e->getMessage())); + } finally { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $locator->clearCache(); + } + + return true; + } + + /** + * @param string $src + * @param string $dst + * @return bool + */ + protected function moveFolder(string $src, string $dst): bool + { + try { + Folder::move($this->resolvePath($src), $this->resolvePath($dst)); + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex moveFolder(%s, %s): %s', $src, $dst, $e->getMessage())); + } finally { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $locator->clearCache(); + } + + return true; + } + + /** + * @param string $path + * @param bool $include_target + * @return bool + */ + protected function deleteFolder(string $path, bool $include_target = false): bool + { + try { + return Folder::delete($this->resolvePath($path), $include_target); + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex deleteFolder(%s): %s', $path, $e->getMessage())); + } finally { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $locator->clearCache(); + } + } + + /** + * @param string $key + * @return bool + */ + protected function canDeleteFolder(string $key): bool + { + return true; + } + + /** + * Returns list of all stored keys in [key => timestamp] pairs. + * + * @return array + */ + protected function buildIndex(): array + { + $this->clearCache(); + + $path = $this->getStoragePath(); + if (!$path || !file_exists($path)) { + return []; + } + + if ($this->prefixed) { + $list = $this->buildPrefixedIndexFromFilesystem($path); + } else { + $list = $this->buildIndexFromFilesystem($path); + } + + ksort($list, SORT_NATURAL | SORT_FLAG_CASE); + + return $list; + } + + /** + * @param string $key + * @param bool $reload + * @return array + */ + protected function getObjectMeta(string $key, bool $reload = false): array + { + if (!$reload && isset($this->meta[$key])) { + return $this->meta[$key]; + } + + if ($key && strpos($key, '@@') === false) { + $filename = $this->getPathFromKey($key); + $modified = is_file($filename) ? filemtime($filename) : 0; + } else { + $modified = 0; + } + + $meta = [ + 'storage_key' => $key, + 'storage_timestamp' => $modified + ]; + + $this->meta[$key] = $meta; + + return $meta; + } + + /** + * @param string $path + * @return array + */ + protected function buildIndexFromFilesystem($path) + { + $flags = FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS; + + $iterator = new FilesystemIterator($path, $flags); + $list = []; + /** @var SplFileInfo $info */ + foreach ($iterator as $filename => $info) { + if (!$info->isDir() || strpos($info->getFilename(), '.') === 0) { + continue; + } + + $key = $this->getKeyFromPath($filename); + $meta = $this->getObjectMeta($key); + if ($meta['storage_timestamp']) { + $list[$key] = $meta; + } + } + + return $list; + } + + /** + * @param string $path + * @return array + */ + protected function buildPrefixedIndexFromFilesystem($path) + { + $flags = FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS; + + $iterator = new FilesystemIterator($path, $flags); + $list = [[]]; + /** @var SplFileInfo $info */ + foreach ($iterator as $filename => $info) { + if (!$info->isDir() || strpos($info->getFilename(), '.') === 0) { + continue; + } + + $list[] = $this->buildIndexFromFilesystem($filename); + } + + return array_merge(...$list); + } + + /** + * @return string + */ + protected function getNewKey(): string + { + // Make sure that the file doesn't exist. + do { + $key = $this->generateKey(); + } while (file_exists($this->getPathFromKey($key))); + + return $key; + } + + /** + * @param array $options + * @return void + */ + protected function initOptions(array $options): void + { + $extension = $this->dataFormatter->getDefaultFileExtension(); + + /** @var string $pattern */ + $pattern = !empty($options['pattern']) ? $options['pattern'] : $this->dataPattern; + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $folder = $options['folder']; + if ($locator->isStream($folder)) { + $folder = $locator->getResource($folder, false); + } + + $this->dataFolder = $folder; + $this->dataFile = $options['file'] ?? 'item'; + $this->dataExt = $extension; + if (mb_strpos($pattern, '{FILE}') === false && mb_strpos($pattern, '{EXT}') === false) { + if (isset($options['file'])) { + $pattern .= '/{FILE}{EXT}'; + } else { + $filesystem = Filesystem::getInstance(true); + $this->dataFile = Utils::basename($pattern, $extension); + $pattern = $filesystem->dirname($pattern) . '/{FILE}{EXT}'; + } + } + $this->prefixed = (bool)($options['prefixed'] ?? strpos($pattern, '/{KEY:2}/')); + $this->indexed = (bool)($options['indexed'] ?? false); + $this->keyField = $options['key'] ?? 'storage_key'; + $this->keyLen = (int)($options['key_len'] ?? 32); + $this->caseSensitive = (bool)($options['case_sensitive'] ?? true); + + $pattern = Utils::simpleTemplate($pattern, $this->variables); + if (!$pattern) { + throw new RuntimeException('Bad storage folder pattern'); + } + + $this->dataPattern = $pattern; + } +} diff --git a/system/src/Grav/Framework/Flex/Storage/SimpleStorage.php b/system/src/Grav/Framework/Flex/Storage/SimpleStorage.php new file mode 100644 index 0000000..99c7102 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/SimpleStorage.php @@ -0,0 +1,507 @@ +detectDataFormatter($options['folder']); + $this->initDataFormatter($formatter); + + $filesystem = Filesystem::getInstance(true); + + $extension = $this->dataFormatter->getDefaultFileExtension(); + $pattern = Utils::basename($options['folder']); + + $this->dataPattern = Utils::basename($pattern, $extension) . $extension; + $this->dataFolder = $filesystem->dirname($options['folder']); + $this->keyField = $options['key'] ?? 'storage_key'; + $this->keyLen = (int)($options['key_len'] ?? 32); + $this->prefix = $options['prefix'] ?? null; + + // Make sure that the data folder exists. + if (!file_exists($this->dataFolder)) { + try { + Folder::create($this->dataFolder); + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex: %s', $e->getMessage())); + } + } + } + + /** + * @return void + */ + public function clearCache(): void + { + $this->data = null; + $this->modified = 0; + } + + /** + * @param string[] $keys + * @param bool $reload + * @return array + */ + public function getMetaData(array $keys, bool $reload = false): array + { + if (null === $this->data || $reload) { + $this->buildIndex(); + } + + $list = []; + foreach ($keys as $key) { + $list[$key] = $this->getObjectMeta((string)$key); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getExistingKeys() + */ + public function getExistingKeys(): array + { + return $this->buildIndex(); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::hasKey() + */ + public function hasKey(string $key): bool + { + if (null === $this->data) { + $this->buildIndex(); + } + + return $key && strpos($key, '@@') === false && isset($this->data[$key]); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::createRows() + */ + public function createRows(array $rows): array + { + if (null === $this->data) { + $this->buildIndex(); + } + + $list = []; + foreach ($rows as $key => $row) { + $list[$key] = $this->saveRow('@@', $rows); + } + + if ($list) { + $this->save(); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::readRows() + */ + public function readRows(array $rows, array &$fetched = null): array + { + if (null === $this->data) { + $this->buildIndex(); + } + + $list = []; + foreach ($rows as $key => $row) { + if (null === $row || is_scalar($row)) { + // Only load rows which haven't been loaded before. + $key = (string)$key; + $list[$key] = $this->hasKey($key) ? $this->loadRow($key) : null; + if (null !== $fetched) { + $fetched[$key] = $list[$key]; + } + } else { + // Keep the row if it has been loaded. + $list[$key] = $row; + } + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::updateRows() + */ + public function updateRows(array $rows): array + { + if (null === $this->data) { + $this->buildIndex(); + } + + $save = false; + $list = []; + foreach ($rows as $key => $row) { + $key = (string)$key; + if ($this->hasKey($key)) { + $list[$key] = $this->saveRow($key, $row); + $save = true; + } else { + $list[$key] = null; + } + } + + if ($save) { + $this->save(); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::deleteRows() + */ + public function deleteRows(array $rows): array + { + if (null === $this->data) { + $this->buildIndex(); + } + + $list = []; + foreach ($rows as $key => $row) { + $key = (string)$key; + if ($this->hasKey($key)) { + unset($this->data[$key]); + $list[$key] = $row; + } + } + + if ($list) { + $this->save(); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::replaceRows() + */ + public function replaceRows(array $rows): array + { + if (null === $this->data) { + $this->buildIndex(); + } + + $list = []; + foreach ($rows as $key => $row) { + $list[$key] = $this->saveRow((string)$key, $row); + } + + if ($list) { + $this->save(); + } + + return $list; + } + + /** + * @param string $src + * @param string $dst + * @return bool + */ + public function copyRow(string $src, string $dst): bool + { + if ($this->hasKey($dst)) { + throw new RuntimeException("Cannot copy object: key '{$dst}' is already taken"); + } + + if (!$this->hasKey($src)) { + return false; + } + + $this->data[$dst] = $this->data[$src]; + + return true; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::renameRow() + */ + public function renameRow(string $src, string $dst): bool + { + if (null === $this->data) { + $this->buildIndex(); + } + + if ($this->hasKey($dst)) { + throw new RuntimeException("Cannot rename object: key '{$dst}' is already taken"); + } + + if (!$this->hasKey($src)) { + return false; + } + + // Change single key in the array without changing the order or value. + $keys = array_keys($this->data); + $keys[array_search($src, $keys, true)] = $dst; + + $data = array_combine($keys, $this->data); + if (false === $data) { + throw new LogicException('Bad data'); + } + + $this->data = $data; + + return true; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getStoragePath() + */ + public function getStoragePath(string $key = null): ?string + { + return $this->dataFolder . '/' . $this->dataPattern; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getMediaPath() + */ + public function getMediaPath(string $key = null): ?string + { + return null; + } + + /** + * Prepares the row for saving and returns the storage key for the record. + * + * @param array $row + */ + protected function prepareRow(array &$row): void + { + unset($row[$this->keyField]); + } + + /** + * @param string $key + * @return array + */ + protected function loadRow(string $key): ?array + { + $data = $this->data[$key] ?? []; + if ($this->keyField !== 'storage_key') { + $data[$this->keyField] = $key; + } + $data['__META'] = $this->getObjectMeta($key); + + return $data; + } + + /** + * @param string $key + * @param array $row + * @return array + */ + protected function saveRow(string $key, array $row): array + { + try { + if (isset($row[$this->keyField])) { + $key = $row[$this->keyField]; + } + if (strpos($key, '@@') !== false) { + $key = $this->getNewKey(); + } + + // Check if the row already exists and if the key has been changed. + $oldKey = $row['__META']['storage_key'] ?? null; + if (is_string($oldKey) && $oldKey !== $key) { + $isCopy = $row['__META']['copy'] ?? false; + if ($isCopy) { + $this->copyRow($oldKey, $key); + } else { + $this->renameRow($oldKey, $key); + } + } + + $this->prepareRow($row); + unset($row['__META'], $row['__ERROR']); + + $this->data[$key] = $row; + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex saveRow(%s): %s', $key, $e->getMessage())); + } + + $row['__META'] = $this->getObjectMeta($key, true); + + return $row; + } + + /** + * @param string $key + * @param bool $variations + * @return array + */ + public function parseKey(string $key, bool $variations = true): array + { + return [ + 'key' => $key, + ]; + } + + protected function save(): void + { + if (null === $this->data) { + $this->buildIndex(); + } + + try { + $path = $this->getStoragePath(); + if (!$path) { + throw new RuntimeException('Storage path is not defined'); + } + $file = $this->getFile($path); + if ($this->prefix) { + $data = new Data((array)$file->content()); + $content = $data->set($this->prefix, $this->data)->toArray(); + } else { + $content = $this->data; + } + $file->save($content); + $this->modified = (int)$file->modified(); // cast false to 0 + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex save(): %s', $e->getMessage())); + } finally { + if (isset($file)) { + $file->free(); + unset($file); + } + } + } + + /** + * Get key from the filesystem path. + * + * @param string $path + * @return string + */ + protected function getKeyFromPath(string $path): string + { + return Utils::basename($path); + } + + /** + * Returns list of all stored keys in [key => timestamp] pairs. + * + * @return array + */ + protected function buildIndex(): array + { + $path = $this->getStoragePath(); + if (!$path) { + $this->data = []; + + return []; + } + + $file = $this->getFile($path); + $this->modified = (int)$file->modified(); // cast false to 0 + + $content = (array) $file->content(); + if ($this->prefix) { + $data = new Data($content); + $content = $data->get($this->prefix, []); + } + + $file->free(); + unset($file); + + $this->data = $content; + + $list = []; + foreach ($this->data as $key => $info) { + $list[$key] = $this->getObjectMeta((string)$key); + } + + return $list; + } + + /** + * @param string $key + * @param bool $reload + * @return array + */ + protected function getObjectMeta(string $key, bool $reload = false): array + { + $modified = isset($this->data[$key]) ? $this->modified : 0; + + return [ + 'storage_key' => $key, + 'key' => $key, + 'storage_timestamp' => $modified + ]; + } + + /** + * @return string + */ + protected function getNewKey(): string + { + if (null === $this->data) { + $this->buildIndex(); + } + + // Make sure that the key doesn't exist. + do { + $key = $this->generateKey(); + } while (isset($this->data[$key])); + + return $key; + } +} diff --git a/system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php b/system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php new file mode 100644 index 0000000..6f340a5 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php @@ -0,0 +1,126 @@ +getAuthorizeAction($action); + $scope = $scope ?? $this->getAuthorizeScope(); + + $isMe = null === $user; + if ($isMe) { + $user = $this->getActiveUser(); + } + + if (null === $user) { + return false; + } + + // Finally authorize against given action. + return $this->isAuthorizedOverride($user, $action, $scope, $isMe); + } + + /** + * Please override this method + * + * @param UserInterface $user + * @param string $action + * @param string $scope + * @param bool $isMe + * @return bool|null + */ + protected function isAuthorizedOverride(UserInterface $user, string $action, string $scope, bool $isMe): ?bool + { + return $this->isAuthorizedAction($user, $action, $scope, $isMe); + } + + /** + * Check if user is authorized for the action. + * + * @param UserInterface $user + * @param string $action + * @param string $scope + * @param bool $isMe + * @return bool|null + */ + protected function isAuthorizedAction(UserInterface $user, string $action, string $scope, bool $isMe): ?bool + { + // Check if the action has been denied in the flex type configuration. + $directory = $this instanceof FlexDirectory ? $this : $this->getFlexDirectory(); + $config = $directory->getConfig(); + $allowed = $config->get("{$scope}.actions.{$action}") ?? $config->get("actions.{$action}") ?? true; + if (false === $allowed) { + return false; + } + + // TODO: Not needed anymore with flex users, remove in 2.0. + $auth = $user instanceof FlexObjectInterface ? null : $user->authorize('admin.super'); + if (true === $auth) { + return true; + } + + // Finally authorize the action. + return $user->authorize($this->getAuthorizeRule($scope, $action), !$isMe ? 'test' : null); + } + + /** + * @param UserInterface $user + * @return bool|null + * @deprecated 1.7 Not needed for Flex Users. + */ + protected function isAuthorizedSuperAdmin(UserInterface $user): ?bool + { + // Action authorization includes super user authorization if using Flex Users. + if ($user instanceof FlexObjectInterface) { + return null; + } + + return $user->authorize('admin.super'); + } + + /** + * @param string $scope + * @param string $action + * @return string + */ + protected function getAuthorizeRule(string $scope, string $action): string + { + if ($this instanceof FlexDirectory) { + return $this->getAuthorizeRule($scope, $action); + } + + return $this->getFlexDirectory()->getAuthorizeRule($scope, $action); + } +} diff --git a/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php b/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php new file mode 100644 index 0000000..35e3900 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php @@ -0,0 +1,576 @@ +exists() ? $this->getFlexDirectory()->getStorageFolder($this->getStorageKey()) : null; + } + + /** + * @return string|null + */ + public function getMediaFolder() + { + return $this->exists() ? $this->getFlexDirectory()->getMediaFolder($this->getStorageKey()) : null; + } + + /** + * @return MediaCollectionInterface + */ + public function getMedia() + { + $media = $this->media; + if (null === $media) { + $media = $this->getExistingMedia(); + + // Include uploaded media to the object media. + $this->addUpdatedMedia($media); + } + + return $media; + } + + /** + * @param string $field + * @return MediaCollectionInterface|null + */ + public function getMediaField(string $field): ?MediaCollectionInterface + { + // Field specific media. + $settings = $this->getFieldSettings($field); + if (!empty($settings['media_field'])) { + $var = 'destination'; + } elseif (!empty($settings['media_picker_field'])) { + $var = 'folder'; + } + + if (empty($var)) { + // Not a media field. + $media = null; + } elseif ($settings['self']) { + // Uses main media. + $media = $this->getMedia(); + } else { + // Uses custom media. + $media = new Media($settings[$var]); + $this->addUpdatedMedia($media); + } + + return $media; + } + + /** + * @param string $field + * @return array|null + */ + public function getFieldSettings(string $field): ?array + { + if ($field === '') { + return null; + } + + // Load settings for the field. + $schema = $this->getBlueprint()->schema(); + $settings = (array)$schema->getProperty($field); + if (!is_array($settings)) { + return null; + } + + $type = $settings['type'] ?? ''; + + // Media field. + if (!empty($settings['media_field']) || array_key_exists('destination', $settings) || in_array($type, ['avatar', 'file', 'pagemedia'], true)) { + $settings['media_field'] = true; + $var = 'destination'; + } + + // Media picker field. + if (!empty($settings['media_picker_field']) || in_array($type, ['filepicker', 'pagemediaselect'], true)) { + $settings['media_picker_field'] = true; + $var = 'folder'; + } + + // Set media folder for media fields. + if (isset($var)) { + $folder = $settings[$var] ?? ''; + if (in_array(rtrim($folder, '/'), ['', '@self', 'self@', '@self@'], true)) { + $settings[$var] = $this->getMediaFolder(); + $settings['self'] = true; + } else { + $settings[$var] = Utils::getPathFromToken($folder, $this); + $settings['self'] = false; + } + } + + return $settings; + } + + /** + * @param string $field + * @return array + * @internal + */ + protected function getMediaFieldSettings(string $field): array + { + $settings = $this->getFieldSettings($field) ?? []; + + return $settings + ['accept' => '*', 'limit' => 1000, 'self' => true]; + } + + /** + * @return array + */ + protected function getMediaFields(): array + { + // Load settings for the field. + $schema = $this->getBlueprint()->schema(); + + $list = []; + foreach ($schema->getState()['items'] as $field => $settings) { + if (isset($settings['type']) && (in_array($settings['type'], ['avatar', 'file', 'pagemedia']) || !empty($settings['destination']))) { + $list[] = $field; + } + } + + return $list; + } + + /** + * @param array|mixed $value + * @param array $settings + * @return array|mixed + */ + protected function parseFileProperty($value, array $settings = []) + { + if (!is_array($value)) { + return $value; + } + + $media = $this->getMedia(); + $originalMedia = is_callable([$this, 'getOriginalMedia']) ? $this->getOriginalMedia() : null; + + $list = []; + foreach ($value as $filename => $info) { + if (!is_array($info)) { + $list[$filename] = $info; + continue; + } + + if (is_int($filename)) { + $filename = $info['path'] ?? $info['name']; + } + + /** @var Medium|null $imageFile */ + $imageFile = $media[$filename]; + + /** @var Medium|null $originalFile */ + $originalFile = $originalMedia ? $originalMedia[$filename] : null; + + $url = $imageFile ? $imageFile->url() : null; + $originalUrl = $originalFile ? $originalFile->url() : null; + $list[$filename] = [ + 'name' => $info['name'] ?? null, + 'type' => $info['type'] ?? null, + 'size' => $info['size'] ?? null, + 'path' => $filename, + 'thumb_url' => $url, + 'image_url' => $originalUrl ?? $url + ]; + if ($originalFile) { + $list[$filename]['cropData'] = (object)($originalFile->metadata()['upload']['crop'] ?? []); + } + } + + return $list; + } + + /** + * @param UploadedFileInterface $uploadedFile + * @param string|null $filename + * @param string|null $field + * @return void + * @internal + */ + public function checkUploadedMediaFile(UploadedFileInterface $uploadedFile, string $filename = null, string $field = null) + { + $media = $this->getMedia(); + if (!$media instanceof MediaUploadInterface) { + throw new RuntimeException("Media for {$this->getFlexDirectory()->getFlexType()} doesn't support file uploads."); + } + + $media->checkUploadedFile($uploadedFile, $filename, $this->getMediaFieldSettings($field ?? '')); + } + + /** + * @param UploadedFileInterface $uploadedFile + * @param string|null $filename + * @param string|null $field + * @return void + * @internal + */ + public function uploadMediaFile(UploadedFileInterface $uploadedFile, string $filename = null, string $field = null): void + { + $settings = $this->getMediaFieldSettings($field ?? ''); + + $media = $field ? $this->getMediaField($field) : $this->getMedia(); + if (!$media instanceof MediaUploadInterface) { + throw new RuntimeException("Media for {$this->getFlexDirectory()->getFlexType()} doesn't support file uploads."); + } + + $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings); + $media->copyUploadedFile($uploadedFile, $filename, $settings); + $this->clearMediaCache(); + } + + /** + * @param string $filename + * @return void + * @internal + */ + public function deleteMediaFile(string $filename): void + { + $media = $this->getMedia(); + if (!$media instanceof MediaUploadInterface) { + throw new RuntimeException("Media for {$this->getFlexDirectory()->getFlexType()} doesn't support file uploads."); + } + + $media->deleteFile($filename); + $this->clearMediaCache(); + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function __debugInfo() + { + return parent::__debugInfo() + [ + 'uploads:private' => $this->getUpdatedMedia() + ]; + } + + /** + * @param string|null $field + * @param string $filename + * @param MediaObjectInterface|null $image + * @return MediaObject|UploadedMediaObject + */ + protected function buildMediaObject(?string $field, string $filename, MediaObjectInterface $image = null) + { + if (!$image) { + $media = $field ? $this->getMediaField($field) : null; + if ($media) { + $image = $media[$filename]; + } + } + + return new MediaObject($field, $filename, $image, $this); + } + + /** + * @param string|null $field + * @return array + */ + protected function buildMediaList(?string $field): array + { + $names = $field ? (array)$this->getNestedProperty($field) : []; + $media = $field ? $this->getMediaField($field) : null; + if (null === $media) { + $media = $this->getMedia(); + } + + $list = []; + foreach ($names as $key => $val) { + $name = is_string($val) ? $val : $key; + $medium = $media[$name]; + if ($medium) { + if ($medium->uploaded_file) { + $upload = $medium->uploaded_file; + $id = $upload instanceof FormFlashFile ? $upload->getId() : "{$field}-{$name}"; + + $list[] = new UploadedMediaObject($id, $field, $name, $upload); + } else { + $list[] = $this->buildMediaObject($field, $name, $medium); + } + } + } + + return $list; + } + + /** + * @param array $files + * @return void + */ + protected function setUpdatedMedia(array $files): void + { + $media = $this->getMedia(); + if (!$media instanceof MediaUploadInterface) { + return; + } + + $filesystem = Filesystem::getInstance(false); + + $list = []; + foreach ($files as $field => $group) { + $field = (string)$field; + // Ignore files without a field and resized images. + if ($field === '' || strpos($field, '/')) { + continue; + } + + // Load settings for the field. + $settings = $this->getMediaFieldSettings($field); + foreach ($group as $filename => $file) { + if ($file) { + // File upload. + $filename = $file->getClientFilename(); + + /** @var FormFlashFile $file */ + $data = $file->jsonSerialize(); + unset($data['tmp_name'], $data['path']); + } else { + // File delete. + $data = null; + } + + if ($file) { + // Check file upload against media limits (except for max size). + $filename = $media->checkUploadedFile($file, $filename, ['filesize' => 0] + $settings); + } + + $self = $settings['self']; + if ($this->_loadMedia && $self) { + $filepath = $filename; + } else { + $filepath = "{$settings['destination']}/{$filename}"; + } + + // Calculate path without the retina scaling factor. + $realpath = $filesystem->pathname($filepath) . str_replace(['@3x', '@2x'], '', Utils::basename($filepath)); + + $list[$filename] = [$file, $settings]; + + $path = str_replace('.', "\n", $field); + if (null !== $data) { + $data['name'] = $filename; + $data['path'] = $filepath; + + $this->setNestedProperty("{$path}\n{$realpath}", $data, "\n"); + } else { + $this->unsetNestedProperty("{$path}\n{$realpath}", "\n"); + } + } + } + + $this->clearMediaCache(); + + $this->_uploads = $list; + } + + /** + * @param MediaCollectionInterface $media + */ + protected function addUpdatedMedia(MediaCollectionInterface $media): void + { + $updated = false; + foreach ($this->getUpdatedMedia() as $filename => $upload) { + if (is_array($upload)) { + /** @var array{UploadedFileInterface,array} $upload */ + $settings = $upload[1]; + if (isset($settings['destination']) && $settings['destination'] === $media->getPath()) { + $upload = $upload[0]; + } else { + $upload = false; + } + } + if (false !== $upload) { + $medium = $upload ? MediumFactory::fromUploadedFile($upload) : null; + $updated = true; + if ($medium) { + $medium->uploaded = true; + $medium->uploaded_file = $upload; + $media->add($filename, $medium); + } elseif (is_callable([$media, 'hide'])) { + $media->hide($filename); + } + } + } + + if ($updated) { + $media->setTimestamps(); + } + } + + /** + * @return array + */ + protected function getUpdatedMedia(): array + { + return $this->_uploads; + } + + /** + * @return void + */ + protected function saveUpdatedMedia(): void + { + $media = $this->getMedia(); + if (!$media instanceof MediaUploadInterface) { + return; + } + + // Upload/delete altered files. + /** + * @var string $filename + * @var UploadedFileInterface|array|null $file + */ + foreach ($this->getUpdatedMedia() as $filename => $file) { + if (is_array($file)) { + [$file, $settings] = $file; + } else { + $settings = null; + } + if ($file instanceof UploadedFileInterface) { + $media->copyUploadedFile($file, $filename, $settings); + } else { + $media->deleteFile($filename, $settings); + } + } + + $this->setUpdatedMedia([]); + $this->clearMediaCache(); + } + + /** + * @return void + */ + protected function freeMedia(): void + { + $this->unsetObjectProperty('media'); + } + + /** + * @param string $uri + * @return Medium|null + */ + protected function createMedium($uri) + { + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + $file = $uri && $locator->isStream($uri) ? $locator->findResource($uri) : $uri; + + return is_string($file) && file_exists($file) ? MediumFactory::fromFile($file) : null; + } + + /** + * @return CacheInterface + */ + protected function getMediaCache() + { + return $this->getCache('object'); + } + + /** + * @return MediaCollectionInterface + */ + protected function offsetLoad_media() + { + return $this->getMedia(); + } + + /** + * @return null + */ + protected function offsetSerialize_media() + { + return null; + } + + /** + * @return FlexDirectory + */ + abstract public function getFlexDirectory(): FlexDirectory; + + /** + * @return string + */ + abstract public function getStorageKey(): string; + + /** + * @param string $filename + * @return void + * @deprecated 1.7 Use Media class that implements MediaUploadInterface instead. + */ + public function checkMediaFilename(string $filename) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use Media class that implements MediaUploadInterface instead', E_USER_DEPRECATED); + + // Check the file extension. + $extension = strtolower(Utils::pathinfo($filename, PATHINFO_EXTENSION)); + + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + // If not a supported type, return + if (!$extension || !$config->get("media.types.{$extension}")) { + $language = $grav['language']; + throw new RuntimeException($language->translate('PLUGIN_ADMIN.UNSUPPORTED_FILE_TYPE') . ': ' . $extension, 400); + } + } +} diff --git a/system/src/Grav/Framework/Flex/Traits/FlexRelatedDirectoryTrait.php b/system/src/Grav/Framework/Flex/Traits/FlexRelatedDirectoryTrait.php new file mode 100644 index 0000000..e162f8c --- /dev/null +++ b/system/src/Grav/Framework/Flex/Traits/FlexRelatedDirectoryTrait.php @@ -0,0 +1,59 @@ + + */ + protected function getCollectionByProperty($type, $property) + { + $directory = $this->getRelatedDirectory($type); + $collection = $directory->getCollection(); + $list = $this->getNestedProperty($property) ?: []; + + /** @var FlexCollectionInterface $collection */ + $collection = $collection->filter(static function ($object) use ($list) { + return in_array($object->getKey(), $list, true); + }); + + return $collection; + } + + /** + * @param string $type + * @return FlexDirectory + * @throws RuntimeException + */ + protected function getRelatedDirectory($type): FlexDirectory + { + $directory = $this->getFlexContainer()->getDirectory($type); + if (!$directory) { + throw new RuntimeException(ucfirst($type). ' directory does not exist!'); + } + + return $directory; + } +} diff --git a/system/src/Grav/Framework/Flex/Traits/FlexRelationshipsTrait.php b/system/src/Grav/Framework/Flex/Traits/FlexRelationshipsTrait.php new file mode 100644 index 0000000..2a73eba --- /dev/null +++ b/system/src/Grav/Framework/Flex/Traits/FlexRelationshipsTrait.php @@ -0,0 +1,61 @@ +_relationships)) { + $blueprint = $this->getBlueprint(); + $options = $blueprint->get('config/relationships', []); + $parent = FlexIdentifier::createFromObject($this); + + $this->_relationships = new Relationships($parent, $options); + } + + return $this->_relationships; + } + + /** + * @param string $name + * @return RelationshipInterface|null + */ + public function getRelationship(string $name): ?RelationshipInterface + { + return $this->getRelationships()[$name]; + } + + protected function resetRelationships(): void + { + $this->_relationships = null; + } + + /** + * @param iterable $collection + * @return array + */ + protected function buildFlexIdentifierList(iterable $collection): array + { + $list = []; + foreach ($collection as $object) { + $list[] = FlexIdentifier::createFromObject($object); + } + + return $list; + } +} diff --git a/system/src/Grav/Framework/Form/FormFlash.php b/system/src/Grav/Framework/Form/FormFlash.php new file mode 100644 index 0000000..c70a5e0 --- /dev/null +++ b/system/src/Grav/Framework/Form/FormFlash.php @@ -0,0 +1,586 @@ + $args[0], + 'unique_id' => $args[1] ?? null, + 'form_name' => $args[2] ?? null, + ]; + $config = array_filter($config, static function ($val) { + return $val !== null; + }); + } + + $this->id = $config['id'] ?? ''; + $this->sessionId = $config['session_id'] ?? ''; + $this->uniqueId = $config['unique_id'] ?? ''; + + $this->setUser($config['user'] ?? null); + + $folder = $config['folder'] ?? ($this->sessionId ? 'tmp://forms/' . $this->sessionId : ''); + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + $this->folder = $folder && $locator->isStream($folder) ? $locator->findResource($folder, true, true) : $folder; + + $this->init($this->loadStoredForm(), $config); + } + + /** + * @param array|null $data + * @param array $config + */ + protected function init(?array $data, array $config): void + { + if (null === $data) { + $this->exists = false; + $this->formName = $config['form_name'] ?? ''; + $this->url = ''; + $this->createdTimestamp = $this->updatedTimestamp = time(); + $this->files = []; + } else { + $this->exists = true; + $this->formName = $data['form'] ?? $config['form_name'] ?? ''; + $this->url = $data['url'] ?? ''; + $this->user = $data['user'] ?? null; + $this->updatedTimestamp = $data['timestamps']['updated'] ?? time(); + $this->createdTimestamp = $data['timestamps']['created'] ?? $this->updatedTimestamp; + $this->data = $data['data'] ?? null; + $this->files = $data['files'] ?? []; + } + } + + /** + * Load raw flex flash data from the filesystem. + * + * @return array|null + */ + protected function loadStoredForm(): ?array + { + $file = $this->getTmpIndex(); + $exists = $file && $file->exists(); + + $data = null; + if ($exists) { + try { + $data = (array)$file->content(); + } catch (Exception $e) { + } + } + + return $data; + } + + /** + * @inheritDoc + */ + public function getId(): string + { + return $this->id && $this->uniqueId ? $this->id . '/' . $this->uniqueId : ''; + } + + /** + * @inheritDoc + */ + public function getSessionId(): string + { + return $this->sessionId; + } + + /** + * @inheritDoc + */ + public function getUniqueId(): string + { + return $this->uniqueId; + } + + /** + * @return string + * @deprecated 1.6.11 Use '->getUniqueId()' method instead. + */ + public function getUniqieId(): string + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6.11, use ->getUniqueId() method instead', E_USER_DEPRECATED); + + return $this->getUniqueId(); + } + + /** + * @inheritDoc + */ + public function getFormName(): string + { + return $this->formName; + } + + + /** + * @inheritDoc + */ + public function getUrl(): string + { + return $this->url; + } + + /** + * @inheritDoc + */ + public function getUsername(): string + { + return $this->user['username'] ?? ''; + } + + /** + * @inheritDoc + */ + public function getUserEmail(): string + { + return $this->user['email'] ?? ''; + } + + /** + * @inheritDoc + */ + public function getCreatedTimestamp(): int + { + return $this->createdTimestamp; + } + + /** + * @inheritDoc + */ + public function getUpdatedTimestamp(): int + { + return $this->updatedTimestamp; + } + + + /** + * @inheritDoc + */ + public function getData(): ?array + { + return $this->data; + } + + /** + * @inheritDoc + */ + public function setData(?array $data): void + { + $this->data = $data; + } + + /** + * @inheritDoc + */ + public function exists(): bool + { + return $this->exists; + } + + /** + * @inheritDoc + */ + public function save(bool $force = false) + { + if (!($this->folder && $this->uniqueId)) { + return $this; + } + + if ($force || $this->data || $this->files) { + // Only save if there is data or files to be saved. + $file = $this->getTmpIndex(); + if ($file) { + $file->save($this->jsonSerialize()); + $this->exists = true; + } + } elseif ($this->exists) { + // Delete empty form flash if it exists (it carries no information). + return $this->delete(); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function delete() + { + if ($this->folder && $this->uniqueId) { + $this->removeTmpDir(); + $this->files = []; + $this->exists = false; + } + + return $this; + } + + /** + * @inheritDoc + */ + public function getFilesByField(string $field): array + { + if (!isset($this->uploadObjects[$field])) { + $objects = []; + foreach ($this->files[$field] ?? [] as $name => $upload) { + $objects[$name] = $upload ? new FormFlashFile($field, $upload, $this) : null; + } + $this->uploadedFiles[$field] = $objects; + } + + return $this->uploadedFiles[$field]; + } + + /** + * @inheritDoc + */ + public function getFilesByFields($includeOriginal = false): array + { + $list = []; + foreach ($this->files as $field => $values) { + if (!$includeOriginal && strpos($field, '/')) { + continue; + } + $list[$field] = $this->getFilesByField($field); + } + + return $list; + } + + /** + * @inheritDoc + */ + public function addUploadedFile(UploadedFileInterface $upload, string $field = null, array $crop = null): string + { + $tmp_dir = $this->getTmpDir(); + $tmp_name = Utils::generateRandomString(12); + $name = $upload->getClientFilename(); + if (!$name) { + throw new RuntimeException('Uploaded file has no filename'); + } + + // Prepare upload data for later save + $data = [ + 'name' => $name, + 'type' => $upload->getClientMediaType(), + 'size' => $upload->getSize(), + 'tmp_name' => $tmp_name + ]; + + Folder::create($tmp_dir); + $upload->moveTo("{$tmp_dir}/{$tmp_name}"); + + $this->addFileInternal($field, $name, $data, $crop); + + return $name; + } + + /** + * @inheritDoc + */ + public function addFile(string $filename, string $field, array $crop = null): bool + { + if (!file_exists($filename)) { + throw new RuntimeException("File not found: {$filename}"); + } + + // Prepare upload data for later save + $data = [ + 'name' => Utils::basename($filename), + 'type' => Utils::getMimeByLocalFile($filename), + 'size' => filesize($filename), + ]; + + $this->addFileInternal($field, $data['name'], $data, $crop); + + return true; + } + + /** + * @inheritDoc + */ + public function removeFile(string $name, string $field = null): bool + { + if (!$name) { + return false; + } + + $field = $field ?: 'undefined'; + + $upload = $this->files[$field][$name] ?? null; + if (null !== $upload) { + $this->removeTmpFile($upload['tmp_name'] ?? ''); + } + $upload = $this->files[$field . '/original'][$name] ?? null; + if (null !== $upload) { + $this->removeTmpFile($upload['tmp_name'] ?? ''); + } + + // Mark file as deleted. + $this->files[$field][$name] = null; + $this->files[$field . '/original'][$name] = null; + + unset( + $this->uploadedFiles[$field][$name], + $this->uploadedFiles[$field . '/original'][$name] + ); + + return true; + } + + /** + * @inheritDoc + */ + public function clearFiles() + { + foreach ($this->files as $files) { + foreach ($files as $upload) { + $this->removeTmpFile($upload['tmp_name'] ?? ''); + } + } + + $this->files = []; + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array + { + return [ + 'form' => $this->formName, + 'id' => $this->getId(), + 'unique_id' => $this->uniqueId, + 'url' => $this->url, + 'user' => $this->user, + 'timestamps' => [ + 'created' => $this->createdTimestamp, + 'updated' => time(), + ], + 'data' => $this->data, + 'files' => $this->files + ]; + } + + /** + * @param string $url + * @return $this + */ + public function setUrl(string $url): self + { + $this->url = $url; + + return $this; + } + + /** + * @param UserInterface|null $user + * @return $this + */ + public function setUser(UserInterface $user = null) + { + if ($user && $user->username) { + $this->user = [ + 'username' => $user->username, + 'email' => $user->email ?? '' + ]; + } else { + $this->user = null; + } + + return $this; + } + + /** + * @param string|null $username + * @return $this + */ + public function setUserName(string $username = null): self + { + $this->user['username'] = $username; + + return $this; + } + + /** + * @param string|null $email + * @return $this + */ + public function setUserEmail(string $email = null): self + { + $this->user['email'] = $email; + + return $this; + } + + /** + * @return string + */ + public function getTmpDir(): string + { + return $this->folder && $this->uniqueId ? "{$this->folder}/{$this->uniqueId}" : ''; + } + + /** + * @return ?YamlFile + */ + protected function getTmpIndex(): ?YamlFile + { + $tmpDir = $this->getTmpDir(); + + // Do not use CompiledYamlFile as the file can change multiple times per second. + return $tmpDir ? YamlFile::instance($tmpDir . '/index.yaml') : null; + } + + /** + * @param string $name + */ + protected function removeTmpFile(string $name): void + { + $tmpDir = $this->getTmpDir(); + $filename = $tmpDir ? $tmpDir . '/' . $name : ''; + if ($name && $filename && is_file($filename)) { + unlink($filename); + } + } + + /** + * @return void + */ + protected function removeTmpDir(): void + { + // Make sure that index file cache gets always cleared. + $file = $this->getTmpIndex(); + if ($file) { + $file->free(); + } + + $tmpDir = $this->getTmpDir(); + if ($tmpDir && file_exists($tmpDir)) { + Folder::delete($tmpDir); + } + } + + /** + * @param string|null $field + * @param string $name + * @param array $data + * @param array|null $crop + * @return void + */ + protected function addFileInternal(?string $field, string $name, array $data, array $crop = null): void + { + if (!($this->folder && $this->uniqueId)) { + throw new RuntimeException('Cannot upload files: form flash folder not defined'); + } + + $field = $field ?: 'undefined'; + if (!isset($this->files[$field])) { + $this->files[$field] = []; + } + + $oldUpload = $this->files[$field][$name] ?? null; + + if ($crop) { + // Deal with crop upload + if ($oldUpload) { + $originalUpload = $this->files[$field . '/original'][$name] ?? null; + if ($originalUpload) { + // If there is original file already present, remove the modified file + $this->files[$field . '/original'][$name]['crop'] = $crop; + $this->removeTmpFile($oldUpload['tmp_name'] ?? ''); + } else { + // Otherwise make the previous file as original + $oldUpload['crop'] = $crop; + $this->files[$field . '/original'][$name] = $oldUpload; + } + } else { + $this->files[$field . '/original'][$name] = [ + 'name' => $name, + 'type' => $data['type'], + 'crop' => $crop + ]; + } + } else { + // Deal with replacing upload + $originalUpload = $this->files[$field . '/original'][$name] ?? null; + $this->files[$field . '/original'][$name] = null; + + $this->removeTmpFile($oldUpload['tmp_name'] ?? ''); + $this->removeTmpFile($originalUpload['tmp_name'] ?? ''); + } + + // Prepare data to be saved later + $this->files[$field][$name] = $data; + } +} diff --git a/system/src/Grav/Framework/Form/FormFlashFile.php b/system/src/Grav/Framework/Form/FormFlashFile.php new file mode 100644 index 0000000..f0c7144 --- /dev/null +++ b/system/src/Grav/Framework/Form/FormFlashFile.php @@ -0,0 +1,266 @@ +id = $flash->getId() ?: $flash->getUniqueId(); + $this->field = $field; + $this->upload = $upload; + $this->flash = $flash; + + $tmpFile = $this->getTmpFile(); + if (!$tmpFile && $this->isOk()) { + $this->upload['error'] = \UPLOAD_ERR_NO_FILE; + } + + if (!isset($this->upload['size'])) { + $this->upload['size'] = $tmpFile && $this->isOk() ? filesize($tmpFile) : 0; + } + } + + /** + * @return StreamInterface + */ + public function getStream() + { + $this->validateActive(); + + $tmpFile = $this->getTmpFile(); + if (null === $tmpFile) { + throw new RuntimeException('No temporary file'); + } + + $resource = fopen($tmpFile, 'rb'); + if (false === $resource) { + throw new RuntimeException('No temporary file'); + } + + return Stream::create($resource); + } + + /** + * @param string $targetPath + * @return void + */ + public function moveTo($targetPath) + { + $this->validateActive(); + + if (!is_string($targetPath) || empty($targetPath)) { + throw new InvalidArgumentException('Invalid path provided for move operation; must be a non-empty string'); + } + $tmpFile = $this->getTmpFile(); + if (null === $tmpFile) { + throw new RuntimeException('No temporary file'); + } + + $this->moved = copy($tmpFile, $targetPath); + + if (false === $this->moved) { + throw new RuntimeException(sprintf('Uploaded file could not be moved to %s', $targetPath)); + } + + $filename = $this->getClientFilename(); + if ($filename) { + $this->flash->removeFile($filename, $this->field); + } + } + + public function getId(): string + { + return $this->id; + } + + /** + * @return string + */ + public function getField(): string + { + return $this->field; + } + + /** + * @return int + */ + public function getSize() + { + return $this->upload['size']; + } + + /** + * @return int + */ + public function getError() + { + return $this->upload['error'] ?? \UPLOAD_ERR_OK; + } + + /** + * @return string + */ + public function getClientFilename() + { + return $this->upload['name'] ?? 'unknown'; + } + + /** + * @return string + */ + public function getClientMediaType() + { + return $this->upload['type'] ?? 'application/octet-stream'; + } + + /** + * @return bool + */ + public function isMoved(): bool + { + return $this->moved; + } + + /** + * @return array + */ + public function getMetaData(): array + { + if (isset($this->upload['crop'])) { + return ['crop' => $this->upload['crop']]; + } + + return []; + } + + /** + * @return string + */ + public function getDestination() + { + return $this->upload['path'] ?? ''; + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->upload; + } + + /** + * @return void + */ + public function checkXss(): void + { + $tmpFile = $this->getTmpFile(); + $mime = $this->getClientMediaType(); + if (Utils::contains($mime, 'svg', false)) { + $response = Security::detectXssFromSvgFile($tmpFile); + if ($response) { + throw new RuntimeException(sprintf('SVG file XSS check failed on %s', $response)); + } + } + } + + /** + * @return string|null + */ + public function getTmpFile(): ?string + { + $tmpName = $this->upload['tmp_name'] ?? null; + + if (!$tmpName) { + return null; + } + + $tmpFile = $this->flash->getTmpDir() . '/' . $tmpName; + + return file_exists($tmpFile) ? $tmpFile : null; + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function __debugInfo() + { + return [ + 'id:private' => $this->id, + 'field:private' => $this->field, + 'moved:private' => $this->moved, + 'upload:private' => $this->upload, + ]; + } + + /** + * @return void + * @throws RuntimeException if is moved or not ok + */ + private function validateActive(): void + { + if (!$this->isOk()) { + throw new RuntimeException('Cannot retrieve stream due to upload error'); + } + + if ($this->moved) { + throw new RuntimeException('Cannot retrieve stream after it has already been moved'); + } + + if (!$this->getTmpFile()) { + throw new RuntimeException('Cannot retrieve stream as the file is missing'); + } + } + + /** + * @return bool return true if there is no upload error + */ + private function isOk(): bool + { + return \UPLOAD_ERR_OK === $this->getError(); + } +} diff --git a/system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php b/system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php new file mode 100644 index 0000000..8076f91 --- /dev/null +++ b/system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php @@ -0,0 +1,42 @@ +|Data|null */ + private $data; + /** @var UploadedFileInterface[] */ + private $files = []; + /** @var FormFlashInterface|null */ + private $flash; + /** @var string */ + private $flashFolder; + /** @var Blueprint */ + private $blueprint; + + /** + * @return string + */ + public function getId(): string + { + return $this->id; + } + + /** + * @param string $id + */ + public function setId(string $id): void + { + $this->id = $id; + } + + /** + * @return void + */ + public function disable(): void + { + $this->enabled = false; + } + + /** + * @return void + */ + public function enable(): void + { + $this->enabled = true; + } + + /** + * @return bool + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * @return string + */ + public function getUniqueId(): string + { + return $this->uniqueid; + } + + /** + * @param string $uniqueId + * @return void + */ + public function setUniqueId(string $uniqueId): void + { + $this->uniqueid = $uniqueId; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @return string + */ + public function getFormName(): string + { + return $this->name; + } + + /** + * @return string + */ + public function getNonceName(): string + { + return 'form-nonce'; + } + + /** + * @return string + */ + public function getNonceAction(): string + { + return 'form'; + } + + /** + * @return string + */ + public function getNonce(): string + { + return Utils::getNonce($this->getNonceAction()); + } + + /** + * @return string + */ + public function getAction(): string + { + return ''; + } + + /** + * @return string + */ + public function getTask(): string + { + return $this->getBlueprint()->get('form/task') ?? ''; + } + + /** + * @param string|null $name + * @return mixed + */ + public function getData(string $name = null) + { + return null !== $name ? $this->data[$name] : $this->data; + } + + /** + * @return array|UploadedFileInterface[] + */ + public function getFiles(): array + { + return $this->files; + } + + /** + * @param string $name + * @return mixed|null + */ + public function getValue(string $name) + { + return $this->data[$name] ?? null; + } + + /** + * @param string $name + * @return mixed|null + */ + public function getDefaultValue(string $name) + { + $path = explode('.', $name); + $offset = array_shift($path); + + $current = $this->getDefaultValues(); + + if (!isset($current[$offset])) { + return null; + } + + $current = $current[$offset]; + + while ($path) { + $offset = array_shift($path); + + if ((is_array($current) || $current instanceof ArrayAccess) && isset($current[$offset])) { + $current = $current[$offset]; + } elseif (is_object($current) && isset($current->{$offset})) { + $current = $current->{$offset}; + } else { + return null; + } + }; + + return $current; + } + + /** + * @return array + */ + public function getDefaultValues(): array + { + return $this->getBlueprint()->getDefaults(); + } + + /** + * @param ServerRequestInterface $request + * @return FormInterface|$this + */ + public function handleRequest(ServerRequestInterface $request): FormInterface + { + // Set current form to be active. + $grav = Grav::instance(); + $forms = $grav['forms'] ?? null; + if ($forms) { + $forms->setActiveForm($this); + + /** @var Twig $twig */ + $twig = $grav['twig']; + $twig->twig_vars['form'] = $this; + } + + try { + [$data, $files] = $this->parseRequest($request); + + $this->submit($data, $files); + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addException($e); + + $this->setError($e->getMessage()); + } + + return $this; + } + + /** + * @param ServerRequestInterface $request + * @return FormInterface|$this + */ + public function setRequest(ServerRequestInterface $request): FormInterface + { + [$data, $files] = $this->parseRequest($request); + + $this->data = new Data($data, $this->getBlueprint()); + $this->files = $files; + + return $this; + } + + /** + * @return bool + */ + public function isValid(): bool + { + return $this->status === 'success'; + } + + /** + * @return string|null + */ + public function getError(): ?string + { + return !$this->isValid() ? $this->message : null; + } + + /** + * @return array + */ + public function getErrors(): array + { + return !$this->isValid() ? $this->messages : []; + } + + /** + * @return bool + */ + public function isSubmitted(): bool + { + return $this->submitted; + } + + /** + * @return bool + */ + public function validate(): bool + { + if (!$this->isValid()) { + return false; + } + + try { + $this->validateData($this->data); + $this->validateUploads($this->getFiles()); + } catch (ValidationException $e) { + $this->setErrors($e->getMessages()); + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + $this->setError($e->getMessage()); + } + + $this->filterData($this->data); + + return $this->isValid(); + } + + /** + * @param array $data + * @param UploadedFileInterface[]|null $files + * @return FormInterface|$this + */ + public function submit(array $data, array $files = null): FormInterface + { + try { + if ($this->isSubmitted()) { + throw new RuntimeException('Form has already been submitted'); + } + + $this->data = new Data($data, $this->getBlueprint()); + $this->files = $files ?? []; + + if (!$this->validate()) { + return $this; + } + + $this->doSubmit($this->data->toArray(), $this->files); + + $this->submitted = true; + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + $this->setError($e->getMessage()); + } + + return $this; + } + + /** + * @return void + */ + public function reset(): void + { + // Make sure that the flash object gets deleted. + $this->getFlash()->delete(); + + $this->data = null; + $this->files = []; + $this->status = 'success'; + $this->message = null; + $this->messages = []; + $this->submitted = false; + $this->flash = null; + } + + /** + * @return array + */ + public function getFields(): array + { + return $this->getBlueprint()->fields(); + } + + /** + * @return array + */ + public function getButtons(): array + { + return $this->getBlueprint()->get('form/buttons') ?? []; + } + + /** + * @return array + */ + public function getTasks(): array + { + return $this->getBlueprint()->get('form/tasks') ?? []; + } + + /** + * @return Blueprint + */ + abstract public function getBlueprint(): Blueprint; + + /** + * Get form flash object. + * + * @return FormFlashInterface + */ + public function getFlash() + { + if (null === $this->flash) { + $grav = Grav::instance(); + $config = [ + 'session_id' => $this->getSessionId(), + 'unique_id' => $this->getUniqueId(), + 'form_name' => $this->getName(), + 'folder' => $this->getFlashFolder(), + 'id' => $this->getFlashId() + ]; + + $this->flash = new FormFlash($config); + $this->flash->setUrl($grav['uri']->url)->setUser($grav['user'] ?? null); + } + + return $this->flash; + } + + /** + * Get all available form flash objects for this form. + * + * @return FormFlashInterface[] + */ + public function getAllFlashes(): array + { + $folder = $this->getFlashFolder(); + if (!$folder || !is_dir($folder)) { + return []; + } + + $name = $this->getName(); + + $list = []; + /** @var SplFileInfo $file */ + foreach (new FilesystemIterator($folder) as $file) { + $uniqueId = $file->getFilename(); + $config = [ + 'session_id' => $this->getSessionId(), + 'unique_id' => $uniqueId, + 'form_name' => $name, + 'folder' => $this->getFlashFolder(), + 'id' => $this->getFlashId() + ]; + $flash = new FormFlash($config); + if ($flash->exists() && $flash->getFormName() === $name) { + $list[] = $flash; + } + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FormInterface::render() + */ + public function render(string $layout = null, array $context = []) + { + if (null === $layout) { + $layout = 'default'; + } + + $grav = Grav::instance(); + + $block = HtmlBlock::create(); + $block->disableCache(); + + $output = $this->getTemplate($layout)->render( + ['grav' => $grav, 'config' => $grav['config'], 'block' => $block, 'form' => $this, 'layout' => $layout] + $context + ); + + $block->setContent($output); + + return $block; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->doSerialize(); + } + + /** + * @return array + */ + final public function __serialize(): array + { + return $this->doSerialize(); + } + + /** + * @param array $data + * @return void + */ + final public function __unserialize(array $data): void + { + $this->doUnserialize($data); + } + + protected function getSessionId(): string + { + if (null === $this->sessionid) { + /** @var Grav $grav */ + $grav = Grav::instance(); + + /** @var SessionInterface|null $session */ + $session = $grav['session'] ?? null; + + $this->sessionid = $session ? ($session->getId() ?? '') : ''; + } + + return $this->sessionid; + } + + /** + * @param string $sessionId + * @return void + */ + protected function setSessionId(string $sessionId): void + { + $this->sessionid = $sessionId; + } + + /** + * @return void + */ + protected function unsetFlash(): void + { + $this->flash = null; + } + + /** + * @return string|null + */ + protected function getFlashFolder(): ?string + { + $grav = Grav::instance(); + + /** @var UserInterface|null $user */ + $user = $grav['user'] ?? null; + if (null !== $user && $user->exists()) { + $username = $user->username; + $mediaFolder = $user->getMediaFolder(); + } else { + $username = null; + $mediaFolder = null; + } + $session = $grav['session'] ?? null; + $sessionId = $session ? $session->getId() : null; + + // Fill template token keys/value pairs. + $dataMap = [ + '[FORM_NAME]' => $this->getName(), + '[SESSIONID]' => $sessionId ?? '!!', + '[USERNAME]' => $username ?? '!!', + '[USERNAME_OR_SESSIONID]' => $username ?? $sessionId ?? '!!', + '[ACCOUNT]' => $mediaFolder ?? '!!' + ]; + + $flashLookupFolder = $this->getFlashLookupFolder(); + + $path = str_replace(array_keys($dataMap), array_values($dataMap), $flashLookupFolder); + + // Make sure we only return valid paths. + return strpos($path, '!!') === false ? rtrim($path, '/') : null; + } + + /** + * @return string|null + */ + protected function getFlashId(): ?string + { + // Fill template token keys/value pairs. + $dataMap = [ + '[FORM_NAME]' => $this->getName(), + '[SESSIONID]' => 'session', + '[USERNAME]' => '!!', + '[USERNAME_OR_SESSIONID]' => '!!', + '[ACCOUNT]' => 'account' + ]; + + $flashLookupFolder = $this->getFlashLookupFolder(); + + $path = str_replace(array_keys($dataMap), array_values($dataMap), $flashLookupFolder); + + // Make sure we only return valid paths. + return strpos($path, '!!') === false ? rtrim($path, '/') : null; + } + + /** + * @return string + */ + protected function getFlashLookupFolder(): string + { + if (null === $this->flashFolder) { + $this->flashFolder = $this->getBlueprint()->get('form/flash_folder') ?? 'tmp://forms/[SESSIONID]'; + } + + return $this->flashFolder; + } + + /** + * @param string $folder + * @return void + */ + protected function setFlashLookupFolder(string $folder): void + { + $this->flashFolder = $folder; + } + + /** + * Set a single error. + * + * @param string $error + * @return void + */ + protected function setError(string $error): void + { + $this->status = 'error'; + $this->message = $error; + } + + /** + * Set all errors. + * + * @param array $errors + * @return void + */ + protected function setErrors(array $errors): void + { + $this->status = 'error'; + $this->messages = $errors; + } + + /** + * @param string $layout + * @return Template|TemplateWrapper + * @throws LoaderError + * @throws SyntaxError + */ + protected function getTemplate($layout) + { + $grav = Grav::instance(); + + /** @var Twig $twig */ + $twig = $grav['twig']; + + return $twig->twig()->resolveTemplate( + [ + "forms/{$layout}/form.html.twig", + 'forms/default/form.html.twig' + ] + ); + } + + /** + * Parse PSR-7 ServerRequest into data and files. + * + * @param ServerRequestInterface $request + * @return array + */ + protected function parseRequest(ServerRequestInterface $request): array + { + $method = $request->getMethod(); + if (!in_array($method, ['PUT', 'POST', 'PATCH'])) { + throw new RuntimeException(sprintf('FlexForm: Bad HTTP method %s', $method)); + } + + $body = (array)$request->getParsedBody(); + $data = isset($body['data']) ? $this->decodeData($body['data']) : null; + + $flash = $this->getFlash(); + /* + if (null !== $data) { + $flash->setData($data); + $flash->save(); + } + */ + + $blueprint = $this->getBlueprint(); + $includeOriginal = (bool)($blueprint->form()['images']['original'] ?? null); + $files = $flash->getFilesByFields($includeOriginal); + + $data = $blueprint->processForm($data ?? [], $body['toggleable_data'] ?? []); + + return [ + $data, + $files + ]; + } + + /** + * Validate data and throw validation exceptions if validation fails. + * + * @param ArrayAccess|Data|null $data + * @return void + * @throws ValidationException + * @phpstan-param ArrayAccess|Data|null $data + * @throws Exception + */ + protected function validateData($data = null): void + { + if ($data instanceof Data) { + $data->validate(); + } + } + + /** + * Filter validated data. + * + * @param ArrayAccess|Data|null $data + * @return void + * @phpstan-param ArrayAccess|Data|null $data + */ + protected function filterData($data = null): void + { + if ($data instanceof Data) { + $data->filter(); + } + } + + /** + * Validate all uploaded files. + * + * @param array $files + * @return void + */ + protected function validateUploads(array $files): void + { + foreach ($files as $file) { + if (null === $file) { + continue; + } + if ($file instanceof UploadedFileInterface) { + $this->validateUpload($file); + } else { + $this->validateUploads($file); + } + } + } + + /** + * Validate uploaded file. + * + * @param UploadedFileInterface $file + * @return void + */ + protected function validateUpload(UploadedFileInterface $file): void + { + // Handle bad filenames. + $filename = $file->getClientFilename(); + if ($filename && !Utils::checkFilename($filename)) { + $grav = Grav::instance(); + throw new RuntimeException( + sprintf($grav['language']->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_UPLOAD', null, true), $filename, 'Bad filename') + ); + } + + if ($file instanceof FormFlashFile) { + $file->checkXss(); + } + } + + /** + * Decode POST data + * + * @param array $data + * @return array + */ + protected function decodeData($data): array + { + if (!is_array($data)) { + return []; + } + + // Decode JSON encoded fields and merge them to data. + if (isset($data['_json'])) { + $data = array_replace_recursive($data, $this->jsonDecode($data['_json'])); + + unset($data['_json']); + } + + return $data; + } + + /** + * Recursively JSON decode POST data. + * + * @param array $data + * @return array + */ + protected function jsonDecode(array $data): array + { + foreach ($data as $key => &$value) { + if (is_array($value)) { + $value = $this->jsonDecode($value); + } elseif (trim($value) === '') { + unset($data[$key]); + } else { + $value = json_decode($value, true); + if ($value === null && json_last_error() !== JSON_ERROR_NONE) { + unset($data[$key]); + $this->setError("Badly encoded JSON data (for {$key}) was sent to the form"); + } + } + } + + return $data; + } + + /** + * @return array + */ + protected function doSerialize(): array + { + $data = $this->data instanceof Data ? $this->data->toArray() : null; + + return [ + 'name' => $this->name, + 'id' => $this->id, + 'uniqueid' => $this->uniqueid, + 'submitted' => $this->submitted, + 'status' => $this->status, + 'message' => $this->message, + 'messages' => $this->messages, + 'data' => $data, + 'files' => $this->files, + ]; + } + + /** + * @param array $data + * @return void + */ + protected function doUnserialize(array $data): void + { + $this->name = $data['name']; + $this->id = $data['id']; + $this->uniqueid = $data['uniqueid']; + $this->submitted = $data['submitted'] ?? false; + $this->status = $data['status'] ?? 'success'; + $this->message = $data['message'] ?? null; + $this->messages = $data['messages'] ?? []; + $this->data = isset($data['data']) ? new Data($data['data'], $this->getBlueprint()) : null; + $this->files = $data['files'] ?? []; + } +} diff --git a/system/src/Grav/Framework/Interfaces/RenderInterface.php b/system/src/Grav/Framework/Interfaces/RenderInterface.php new file mode 100644 index 0000000..51807e4 --- /dev/null +++ b/system/src/Grav/Framework/Interfaces/RenderInterface.php @@ -0,0 +1,38 @@ +render('custom', ['variable' => 'value']); + * @example {% render object layout 'custom' with { variable: 'value' } %} + * + * @param string|null $layout Layout to be used. + * @param array $context Extra context given to the renderer. + * + * @return ContentBlockInterface|HtmlBlock Returns `HtmlBlock` containing the rendered output. + * @api + */ + public function render(string $layout = null, array $context = []); +} diff --git a/system/src/Grav/Framework/Logger/Processors/UserProcessor.php b/system/src/Grav/Framework/Logger/Processors/UserProcessor.php new file mode 100644 index 0000000..ffe46d9 --- /dev/null +++ b/system/src/Grav/Framework/Logger/Processors/UserProcessor.php @@ -0,0 +1,34 @@ +exists()) { + $record['extra']['user'] = ['username' => $user->username, 'email' => $user->email]; + } + + return $record; + } +} diff --git a/system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php b/system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php new file mode 100644 index 0000000..69f7acd --- /dev/null +++ b/system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php @@ -0,0 +1,23 @@ + + * @extends Iterator + */ +interface MediaCollectionInterface extends ArrayAccess, Countable, Iterator +{ +} diff --git a/system/src/Grav/Framework/Media/Interfaces/MediaInterface.php b/system/src/Grav/Framework/Media/Interfaces/MediaInterface.php new file mode 100644 index 0000000..39d0501 --- /dev/null +++ b/system/src/Grav/Framework/Media/Interfaces/MediaInterface.php @@ -0,0 +1,37 @@ +get('this.is.my.nested.variable'); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @param string|null $separator Separator, defaults to '.' + * @return mixed Value. + */ + public function get($name, $default = null, $separator = null); +} diff --git a/system/src/Grav/Framework/Media/MediaIdentifier.php b/system/src/Grav/Framework/Media/MediaIdentifier.php new file mode 100644 index 0000000..986e997 --- /dev/null +++ b/system/src/Grav/Framework/Media/MediaIdentifier.php @@ -0,0 +1,150 @@ + + */ +class MediaIdentifier extends Identifier +{ + /** @var MediaObjectInterface|null */ + private $object = null; + + /** + * @param MediaObjectInterface $object + * @return MediaIdentifier + */ + public static function createFromObject(MediaObjectInterface $object): MediaIdentifier + { + $instance = new static($object->getId()); + $instance->setObject($object); + + return $instance; + } + + /** + * @param string $id + */ + public function __construct(string $id) + { + parent::__construct($id, 'media'); + } + + /** + * @return T + */ + public function getObject(): ?MediaObjectInterface + { + if (!isset($this->object)) { + $type = $this->getType(); + $id = $this->getId(); + + $parts = explode('/', $id); + if ($type === 'media' && str_starts_with($id, 'uploads/')) { + array_shift($parts); + [, $folder, $uniqueId, $field, $filename] = $this->findFlash($parts); + + $flash = $this->getFlash($folder, $uniqueId); + if ($flash->exists()) { + + $uploadedFile = $flash->getFilesByField($field)[$filename] ?? null; + + $this->object = UploadedMediaObject::createFromFlash($flash, $field, $filename, $uploadedFile); + } + } else { + $type = array_shift($parts); + $key = array_shift($parts); + $field = array_shift($parts); + $filename = implode('/', $parts); + + $flexObject = $this->getFlexObject($type, $key); + if ($flexObject && method_exists($flexObject, 'getMediaField') && method_exists($flexObject, 'getMedia')) { + $media = $field !== 'media' ? $flexObject->getMediaField($field) : $flexObject->getMedia(); + $image = null; + if ($media) { + $image = $media[$filename]; + } + + $this->object = new MediaObject($field, $filename, $image, $flexObject); + } + } + + if (!isset($this->object)) { + throw new \RuntimeException(sprintf('Object not found for identifier {type: "%s", id: "%s"}', $type, $id)); + } + } + + return $this->object; + } + + /** + * @param T $object + */ + public function setObject(MediaObjectInterface $object): void + { + $type = $this->getType(); + $objectType = $object->getType(); + + if ($type !== $objectType) { + throw new \RuntimeException(sprintf('Object has to be type %s, %s given', $type, $objectType)); + } + + $this->object = $object; + } + + protected function findFlash(array $parts): ?array + { + $type = array_shift($parts); + if ($type === 'account') { + /** @var UserInterface|null $user */ + $user = Grav::instance()['user'] ?? null; + $folder = $user->getMediaFolder(); + } else { + $folder = 'tmp://'; + } + + if (!$folder) { + return null; + } + + do { + $part = array_shift($parts); + $folder .= "/{$part}"; + } while (!str_starts_with($part, 'flex-')); + + $uniqueId = array_shift($parts); + $field = array_shift($parts); + $filename = implode('/', $parts); + + return [$type, $folder, $uniqueId, $field, $filename]; + } + + protected function getFlash(string $folder, string $uniqueId): FlexFormFlash + { + $config = [ + 'unique_id' => $uniqueId, + 'folder' => $folder + ]; + + return new FlexFormFlash($config); + } + + protected function getFlexObject(string $type, string $key): ?FlexObjectInterface + { + /** @var Flex $flex */ + $flex = Grav::instance()['flex']; + + return $flex->getObject($key, $type); + } +} diff --git a/system/src/Grav/Framework/Media/MediaObject.php b/system/src/Grav/Framework/Media/MediaObject.php new file mode 100644 index 0000000..8a438bf --- /dev/null +++ b/system/src/Grav/Framework/Media/MediaObject.php @@ -0,0 +1,215 @@ +field = $field; + $this->filename = $filename; + $this->media = $media; + $this->object = $object; + } + + /** + * @return string + */ + public function getType(): string + { + return 'media'; + } + + /** + * @return string + */ + public function getId(): string + { + $field = $this->field; + $object = $this->object; + $path = $field ? "/{$field}/" : '/media/'; + + return $object->getType() . '/' . $object->getKey() . $path . basename($this->filename); + } + + /** + * @return bool + */ + public function exists(): bool + { + return $this->media !== null; + } + + /** + * @return array + */ + public function getMeta(): array + { + if (!isset($this->media)) { + return []; + } + + return $this->media->getMeta(); + } + + /** + * @param string $field + * @return mixed|null + */ + public function get(string $field) + { + if (!isset($this->media)) { + return null; + } + + return $this->media->get($field); + } + + /** + * @return string + */ + public function getUrl(): string + { + if (!isset($this->media)) { + return ''; + } + + return $this->media->url(); + } + + /** + * Create media response. + * + * @param array $actions + * @return Response + */ + public function createResponse(array $actions): ResponseInterface + { + if (!isset($this->media)) { + return $this->create404Response($actions); + } + + $media = $this->media; + + if ($actions) { + $media = $this->processMediaActions($media, $actions); + } + + // FIXME: This only works for images + if (!$media instanceof ImageMedium) { + throw new \RuntimeException('Not Implemented', 500); + } + + $filename = $media->path(false); + $time = filemtime($filename); + $size = filesize($filename); + $body = fopen($filename, 'rb'); + $headers = [ + 'Content-Type' => $media->get('mime'), + 'Last-Modified' => gmdate('D, d M Y H:i:s', $time) . ' GMT', + 'ETag' => sprintf('%x-%x', $size, $time) + ]; + + return new Response(200, $headers, $body); + } + + /** + * Process media actions + * + * @param GravMediaObjectInterface $medium + * @param array $actions + * @return GravMediaObjectInterface + */ + protected function processMediaActions(GravMediaObjectInterface $medium, array $actions): GravMediaObjectInterface + { + // loop through actions for the image and call them + foreach ($actions as $method => $params) { + $matches = []; + + if (preg_match('/\[(.*)]/', $params, $matches)) { + $args = [explode(',', $matches[1])]; + } else { + $args = explode(',', $params); + } + + try { + $medium->{$method}(...$args); + } catch (Throwable $e) { + // Ignore all errors for now and just skip the action. + } + } + + return $medium; + } + + /** + * @param array $actions + * @return Response + */ + protected function create404Response(array $actions): Response + { + // Display placeholder image. + $filename = static::$placeholderImage; + + $time = filemtime($filename); + $size = filesize($filename); + $body = fopen($filename, 'rb'); + $headers = [ + 'Content-Type' => 'image/svg', + 'Last-Modified' => gmdate('D, d M Y H:i:s', $time) . ' GMT', + 'ETag' => sprintf('%x-%x', $size, $time) + ]; + + return new Response(404, $headers, $body); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'type' => $this->getType(), + 'id' => $this->getId() + ]; + } + + /** + * @return string[] + */ + public function __debugInfo(): array + { + return $this->jsonSerialize(); + } +} diff --git a/system/src/Grav/Framework/Media/UploadedMediaObject.php b/system/src/Grav/Framework/Media/UploadedMediaObject.php new file mode 100644 index 0000000..0fe12e1 --- /dev/null +++ b/system/src/Grav/Framework/Media/UploadedMediaObject.php @@ -0,0 +1,172 @@ +getId(); + + return new static($id, $field, $filename, $uploadedFile); + } + + /** + * @param string $id + * @param string|null $field + * @param string $filename + * @param UploadedFileInterface|null $uploadedFile + */ + public function __construct(string $id, ?string $field, string $filename, ?UploadedFileInterface $uploadedFile = null) + { + $this->id = $id; + $this->field = $field; + $this->filename = $filename; + $this->uploadedFile = $uploadedFile; + if ($uploadedFile) { + $this->meta = [ + 'filename' => $uploadedFile->getClientFilename(), + 'mime' => $uploadedFile->getClientMediaType(), + 'size' => $uploadedFile->getSize() + ]; + } else { + $this->meta = []; + } + } + + /** + * @return string + */ + public function getType(): string + { + return 'media'; + } + + /** + * @return string + */ + public function getId(): string + { + $id = $this->id; + $field = $this->field; + $path = $field ? "/{$field}/" : ''; + + return 'uploads/' . $id . $path . basename($this->filename); + } + + /** + * @return bool + */ + public function exists(): bool + { + //return $this->uploadedFile !== null; + return false; + } + + /** + * @return array + */ + public function getMeta(): array + { + return $this->meta; + } + + /** + * @param string $field + * @return mixed|null + */ + public function get(string $field) + { + return $this->meta[$field] ?? null; + } + + /** + * @return string + */ + public function getUrl(): string + { + return ''; + } + + /** + * @return UploadedFileInterface|null + */ + public function getUploadedFile(): ?UploadedFileInterface + { + return $this->uploadedFile; + } + + /** + * @param array $actions + * @return Response + */ + public function createResponse(array $actions): ResponseInterface + { + // Display placeholder image. + $filename = static::$placeholderImage; + + $time = filemtime($filename); + $size = filesize($filename); + $body = fopen($filename, 'rb'); + $headers = [ + 'Content-Type' => 'image/svg', + 'Last-Modified' => gmdate('D, d M Y H:i:s', $time) . ' GMT', + 'ETag' => sprintf('%x-%x', $size, $time) + ]; + + return new Response(404, $headers, $body); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'type' => $this->getType(), + 'id' => $this->getId() + ]; + } + + /** + * @return string[] + */ + public function __debugInfo(): array + { + return $this->jsonSerialize(); + } +} diff --git a/system/src/Grav/Framework/Mime/MimeTypes.php b/system/src/Grav/Framework/Mime/MimeTypes.php new file mode 100644 index 0000000..9347651 --- /dev/null +++ b/system/src/Grav/Framework/Mime/MimeTypes.php @@ -0,0 +1,107 @@ + ['mime/type', 'mime/type2']] + */ + public static function createFromMimes(array $mimes): self + { + $extensions = []; + foreach ($mimes as $ext => $list) { + foreach ($list as $mime) { + $list = $extensions[$mime] ?? []; + if (!in_array($ext, $list, true)) { + $list[] = $ext; + $extensions[$mime] = $list; + } + } + } + + return new static($extensions, $mimes); + } + + /** + * @param string $extension + * @return string|null + */ + public function getMimeType(string $extension): ?string + { + $extension = $this->cleanInput($extension); + + return $this->mimes[$extension][0] ?? null; + } + + /** + * @param string $mime + * @return string|null + */ + public function getExtension(string $mime): ?string + { + $mime = $this->cleanInput($mime); + + return $this->extensions[$mime][0] ?? null; + } + + /** + * @param string $extension + * @return array + */ + public function getMimeTypes(string $extension): array + { + $extension = $this->cleanInput($extension); + + return $this->mimes[$extension] ?? []; + } + + /** + * @param string $mime + * @return array + */ + public function getExtensions(string $mime): array + { + $mime = $this->cleanInput($mime); + + return $this->extensions[$mime] ?? []; + } + + /** + * @param string $input + * @return string + */ + protected function cleanInput(string $input): string + { + return strtolower(trim($input)); + } + + /** + * @param array $extensions + * @param array $mimes + */ + protected function __construct(array $extensions, array $mimes) + { + $this->extensions = $extensions; + $this->mimes = $mimes; + } +} diff --git a/system/src/Grav/Framework/Object/Access/ArrayAccessTrait.php b/system/src/Grav/Framework/Object/Access/ArrayAccessTrait.php new file mode 100644 index 0000000..ab68667 --- /dev/null +++ b/system/src/Grav/Framework/Object/Access/ArrayAccessTrait.php @@ -0,0 +1,66 @@ +hasProperty($offset); + } + + /** + * Returns the value at specified offset. + * + * @param mixed $offset The offset to retrieve. + * @return mixed Can return all value types. + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->getProperty($offset); + } + + /** + * Assigns a value to the specified offset. + * + * @param mixed $offset The offset to assign the value to. + * @param mixed $value The value to set. + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + $this->setProperty($offset, $value); + } + + /** + * Unsets an offset. + * + * @param mixed $offset The offset to unset. + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset) + { + $this->unsetProperty($offset); + } +} diff --git a/system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php b/system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php new file mode 100644 index 0000000..ee224ad --- /dev/null +++ b/system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php @@ -0,0 +1,66 @@ +hasNestedProperty($offset); + } + + /** + * Returns the value at specified offset. + * + * @param mixed $offset The offset to retrieve. + * @return mixed Can return all value types. + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->getNestedProperty($offset); + } + + /** + * Assigns a value to the specified offset. + * + * @param mixed $offset The offset to assign the value to. + * @param mixed $value The value to set. + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + $this->setNestedProperty($offset, $value); + } + + /** + * Unsets an offset. + * + * @param mixed $offset The offset to unset. + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset) + { + $this->unsetNestedProperty($offset); + } +} diff --git a/system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php b/system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php new file mode 100644 index 0000000..5e591f1 --- /dev/null +++ b/system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php @@ -0,0 +1,120 @@ +getIterator() as $id => $element) { + $list[$id] = $element->hasNestedProperty($property, $separator); + } + + return $list; + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if not set. + * @param string|null $separator Separator, defaults to '.' + * @return array Key/Value pairs of the properties. + */ + public function getNestedProperty($property, $default = null, $separator = null) + { + $list = []; + + /** @var NestedObjectInterface $element */ + foreach ($this->getIterator() as $id => $element) { + $list[$id] = $element->getNestedProperty($property, $default, $separator); + } + + return $list; + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function setNestedProperty($property, $value, $separator = null) + { + /** @var NestedObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $element->setNestedProperty($property, $value, $separator); + } + + return $this; + } + + /** + * @param string $property Object property to be updated. + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function unsetNestedProperty($property, $separator = null) + { + /** @var NestedObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $element->unsetNestedProperty($property, $separator); + } + + return $this; + } + + /** + * @param string $property Object property to be updated. + * @param string $default Default value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function defNestedProperty($property, $default, $separator = null) + { + /** @var NestedObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $element->defNestedProperty($property, $default, $separator); + } + + return $this; + } + + /** + * Group items in the collection by a field. + * + * @param string $property Object property to be used to make groups. + * @param string|null $separator Separator, defaults to '.' + * @return array + */ + public function group($property, $separator = null) + { + $list = []; + + /** @var NestedObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $list[(string) $element->getNestedProperty($property, null, $separator)][] = $element; + } + + return $list; + } +} diff --git a/system/src/Grav/Framework/Object/Access/NestedPropertyTrait.php b/system/src/Grav/Framework/Object/Access/NestedPropertyTrait.php new file mode 100644 index 0000000..83b94b5 --- /dev/null +++ b/system/src/Grav/Framework/Object/Access/NestedPropertyTrait.php @@ -0,0 +1,180 @@ +getNestedProperty($property, $test, $separator) !== $test; + } + + /** + * @param string $property Object property to be fetched. + * @param mixed|null $default Default value if property has not been set. + * @param string|null $separator Separator, defaults to '.' + * @return mixed Property value. + */ + public function getNestedProperty($property, $default = null, $separator = null) + { + $separator = $separator ?: '.'; + $path = explode($separator, (string) $property); + $offset = array_shift($path); + + if (!$this->hasProperty($offset)) { + return $default; + } + + $current = $this->getProperty($offset); + + while ($path) { + // Get property of nested Object. + if ($current instanceof ObjectInterface) { + if (method_exists($current, 'getNestedProperty')) { + return $current->getNestedProperty(implode($separator, $path), $default, $separator); + } + return $current->getProperty(implode($separator, $path), $default); + } + + $offset = array_shift($path); + + if ((is_array($current) || is_a($current, 'ArrayAccess')) && isset($current[$offset])) { + $current = $current[$offset]; + } elseif (is_object($current) && isset($current->{$offset})) { + $current = $current->{$offset}; + } else { + return $default; + } + }; + + return $current; + } + + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + * @throws RuntimeException + */ + public function setNestedProperty($property, $value, $separator = null) + { + $separator = $separator ?: '.'; + $path = explode($separator, $property); + $offset = array_shift($path); + + if (!$path) { + $this->setProperty($offset, $value); + + return $this; + } + + $current = &$this->doGetProperty($offset, null, true); + + while ($path) { + $offset = array_shift($path); + + // Handle arrays and scalars. + if ($current === null) { + $current = [$offset => []]; + } elseif (is_array($current)) { + if (!isset($current[$offset])) { + $current[$offset] = []; + } + } else { + throw new RuntimeException("Cannot set nested property {$property} on non-array value"); + } + + $current = &$current[$offset]; + }; + + $current = $value; + + return $this; + } + + /** + * @param string $property Object property to be updated. + * @param string|null $separator Separator, defaults to '.' + * @return $this + * @throws RuntimeException + */ + public function unsetNestedProperty($property, $separator = null) + { + $separator = $separator ?: '.'; + $path = explode($separator, $property); + $offset = array_shift($path); + + if (!$path) { + $this->unsetProperty($offset); + + return $this; + } + + $last = array_pop($path); + $current = &$this->doGetProperty($offset, null, true); + + while ($path) { + $offset = array_shift($path); + + // Handle arrays and scalars. + if ($current === null) { + return $this; + } + if (is_array($current)) { + if (!isset($current[$offset])) { + return $this; + } + } else { + throw new RuntimeException("Cannot unset nested property {$property} on non-array value"); + } + + $current = &$current[$offset]; + }; + + unset($current[$last]); + + return $this; + } + + /** + * @param string $property Object property to be updated. + * @param mixed $default Default value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + * @throws RuntimeException + */ + public function defNestedProperty($property, $default, $separator = null) + { + if (!$this->hasNestedProperty($property, $separator)) { + $this->setNestedProperty($property, $default, $separator); + } + + return $this; + } +} diff --git a/system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php b/system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php new file mode 100644 index 0000000..c8e47bc --- /dev/null +++ b/system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php @@ -0,0 +1,66 @@ +hasProperty($offset); + } + + /** + * Returns the value at specified offset. + * + * @param mixed $offset The offset to retrieve. + * @return mixed Can return all value types. + */ + #[\ReturnTypeWillChange] + public function __get($offset) + { + return $this->getProperty($offset); + } + + /** + * Assigns a value to the specified offset. + * + * @param mixed $offset The offset to assign the value to. + * @param mixed $value The value to set. + * @return void + */ + #[\ReturnTypeWillChange] + public function __set($offset, $value) + { + $this->setProperty($offset, $value); + } + + /** + * Magic method to unset the attribute + * + * @param mixed $offset The name value to unset + * @return void + */ + #[\ReturnTypeWillChange] + public function __unset($offset) + { + $this->unsetProperty($offset); + } +} diff --git a/system/src/Grav/Framework/Object/ArrayObject.php b/system/src/Grav/Framework/Object/ArrayObject.php new file mode 100644 index 0000000..c5ad0bc --- /dev/null +++ b/system/src/Grav/Framework/Object/ArrayObject.php @@ -0,0 +1,31 @@ + + */ +class ArrayObject implements NestedObjectInterface, ArrayAccess +{ + use ObjectTrait; + use ArrayPropertyTrait; + use NestedPropertyTrait; + use OverloadedPropertyTrait; + use NestedArrayAccessTrait; +} diff --git a/system/src/Grav/Framework/Object/Base/ObjectCollectionTrait.php b/system/src/Grav/Framework/Object/Base/ObjectCollectionTrait.php new file mode 100644 index 0000000..36c7329 --- /dev/null +++ b/system/src/Grav/Framework/Object/Base/ObjectCollectionTrait.php @@ -0,0 +1,377 @@ +getTypePrefix() : ''; + + if (static::$type) { + return $type . static::$type; + } + + $class = get_class($this); + + return $type . strtolower(substr($class, strrpos($class, '\\') + 1)); + } + + /** + * @return string + */ + public function getKey() + { + return $this->_key ?: $this->getType() . '@@' . spl_object_hash($this); + } + + /** + * @return bool + */ + public function hasKey() + { + return !empty($this->_key); + } + + /** + * @param string $property Object property name. + * @return bool[] True if property has been defined (can be null). + */ + public function hasProperty($property) + { + return $this->doHasProperty($property); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @return mixed[] Property values. + */ + public function getProperty($property, $default = null) + { + return $this->doGetProperty($property, $default); + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @return $this + */ + public function setProperty($property, $value) + { + $this->doSetProperty($property, $value); + + return $this; + } + + /** + * @param string $property Object property to be unset. + * @return $this + */ + public function unsetProperty($property) + { + $this->doUnsetProperty($property); + + return $this; + } + + /** + * @param string $property Object property to be defined. + * @param mixed $default Default value. + * @return $this + */ + public function defProperty($property, $default) + { + if (!$this->hasProperty($property)) { + $this->setProperty($property, $default); + } + + return $this; + } + + /** + * @return array + */ + final public function __serialize(): array + { + return $this->doSerialize(); + } + + /** + * @param array $data + * @return void + */ + final public function __unserialize(array $data): void + { + if (method_exists($this, 'initObjectProperties')) { + $this->initObjectProperties(); + } + + $this->doUnserialize($data); + } + + + /** + * @return array + */ + protected function doSerialize() + { + return [ + 'key' => $this->getKey(), + 'type' => $this->getType(), + 'elements' => $this->getElements() + ]; + } + + /** + * @param array $data + * @return void + */ + protected function doUnserialize(array $data) + { + if (!isset($data['key'], $data['type'], $data['elements']) || $data['type'] !== $this->getType()) { + throw new InvalidArgumentException("Cannot unserialize '{$this->getType()}': Bad data"); + } + + $this->setKey($data['key']); + $this->setElements($data['elements']); + } + + /** + * Implements JsonSerializable interface. + * + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->doSerialize(); + } + + /** + * Returns a string representation of this object. + * + * @return string + */ + #[\ReturnTypeWillChange] + public function __toString() + { + return $this->getKey(); + } + + /** + * @param string $key + * @return $this + */ + public function setKey($key) + { + $this->_key = (string) $key; + + return $this; + } + + /** + * Create a copy from this collection by cloning all objects in the collection. + * + * @return static + */ + public function copy() + { + $list = []; + foreach ($this->getIterator() as $key => $value) { + /** @phpstan-ignore-next-line */ + $list[$key] = is_object($value) ? clone $value : $value; + } + + /** @phpstan-var static */ + return $this->createFrom($list); + } + + /** + * @return string[] + */ + public function getObjectKeys() + { + return $this->call('getKey'); + } + + /** + * @param string $property Object property to be matched. + * @return bool[] Key/Value pairs of the properties. + */ + public function doHasProperty($property) + { + $list = []; + + /** @var ObjectInterface $element */ + foreach ($this->getIterator() as $id => $element) { + $list[$id] = (bool)$element->hasProperty($property); + } + + return $list; + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if not set. + * @param bool $doCreate Not being used. + * @return mixed[] Key/Value pairs of the properties. + */ + public function &doGetProperty($property, $default = null, $doCreate = false) + { + $list = []; + + /** @var ObjectInterface $element */ + foreach ($this->getIterator() as $id => $element) { + $list[$id] = $element->getProperty($property, $default); + } + + return $list; + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @return $this + */ + public function doSetProperty($property, $value) + { + /** @var ObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $element->setProperty($property, $value); + } + + return $this; + } + + /** + * @param string $property Object property to be updated. + * @return $this + */ + public function doUnsetProperty($property) + { + /** @var ObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $element->unsetProperty($property); + } + + return $this; + } + + /** + * @param string $property Object property to be updated. + * @param mixed $default Default value. + * @return $this + */ + public function doDefProperty($property, $default) + { + /** @var ObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $element->defProperty($property, $default); + } + + return $this; + } + + /** + * @param string $method Method name. + * @param array $arguments List of arguments passed to the function. + * @return mixed[] Return values. + */ + public function call($method, array $arguments = []) + { + $list = []; + + /** + * @var string|int $id + * @var ObjectInterface $element + */ + foreach ($this->getIterator() as $id => $element) { + $callable = [$element, $method]; + $list[$id] = is_callable($callable) ? call_user_func_array($callable, $arguments) : null; + } + + return $list; + } + + /** + * Group items in the collection by a field and return them as associated array. + * + * @param string $property + * @return array + * @phpstan-return array + */ + public function group($property) + { + $list = []; + + /** @var ObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $list[(string) $element->getProperty($property)][] = $element; + } + + return $list; + } + + /** + * Group items in the collection by a field and return them as associated array of collections. + * + * @param string $property + * @return static[] + * @phpstan-return array> + */ + public function collectionGroup($property) + { + $collections = []; + foreach ($this->group($property) as $id => $elements) { + /** @phpstan-var static $collection */ + $collection = $this->createFrom($elements); + + $collections[$id] = $collection; + } + + return $collections; + } +} diff --git a/system/src/Grav/Framework/Object/Base/ObjectTrait.php b/system/src/Grav/Framework/Object/Base/ObjectTrait.php new file mode 100644 index 0000000..215a3be --- /dev/null +++ b/system/src/Grav/Framework/Object/Base/ObjectTrait.php @@ -0,0 +1,202 @@ +getTypePrefix() : ''; + + if (static::$type) { + return $type . static::$type; + } + + $class = get_class($this); + return $type . strtolower(substr($class, strrpos($class, '\\') + 1)); + } + + /** + * @return string + */ + public function getKey() + { + return $this->_key ?: $this->getType() . '@@' . spl_object_hash($this); + } + + /** + * @return bool + */ + public function hasKey() + { + return !empty($this->_key); + } + + /** + * @param string $property Object property name. + * @return bool True if property has been defined (can be null). + */ + public function hasProperty($property) + { + return $this->doHasProperty($property); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @return mixed Property value. + */ + public function getProperty($property, $default = null) + { + return $this->doGetProperty($property, $default); + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @return $this + */ + public function setProperty($property, $value) + { + $this->doSetProperty($property, $value); + + return $this; + } + + /** + * @param string $property Object property to be unset. + * @return $this + */ + public function unsetProperty($property) + { + $this->doUnsetProperty($property); + + return $this; + } + + /** + * @param string $property Object property to be defined. + * @param mixed $default Default value. + * @return $this + */ + public function defProperty($property, $default) + { + if (!$this->hasProperty($property)) { + $this->setProperty($property, $default); + } + + return $this; + } + + /** + * @return array + */ + final public function __serialize(): array + { + return $this->doSerialize(); + } + + /** + * @param array $data + * @return void + */ + final public function __unserialize(array $data): void + { + if (method_exists($this, 'initObjectProperties')) { + $this->initObjectProperties(); + } + + $this->doUnserialize($data); + } + + /** + * @return array + */ + protected function doSerialize() + { + return ['key' => $this->getKey(), 'type' => $this->getType(), 'elements' => $this->getElements()]; + } + + /** + * @param array $serialized + * @return void + */ + protected function doUnserialize(array $serialized) + { + if (!isset($serialized['key'], $serialized['type'], $serialized['elements']) || $serialized['type'] !== $this->getType()) { + throw new InvalidArgumentException("Cannot unserialize '{$this->getType()}': Bad data"); + } + + $this->setKey($serialized['key']); + $this->setElements($serialized['elements']); + } + + /** + * Implements JsonSerializable interface. + * + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->doSerialize(); + } + + /** + * Returns a string representation of this object. + * + * @return string + */ + #[\ReturnTypeWillChange] + public function __toString() + { + return $this->getKey(); + } + + /** + * @param string $key + * @return $this + */ + protected function setKey($key) + { + $this->_key = (string) $key; + + return $this; + } +} diff --git a/system/src/Grav/Framework/Object/Collection/ObjectExpressionVisitor.php b/system/src/Grav/Framework/Object/Collection/ObjectExpressionVisitor.php new file mode 100644 index 0000000..0103817 --- /dev/null +++ b/system/src/Grav/Framework/Object/Collection/ObjectExpressionVisitor.php @@ -0,0 +1,240 @@ +{$accessor}(); + break; + } + } + + if ($op) { + $function = 'filter' . ucfirst(strtolower($op)); + if (method_exists(static::class, $function)) { + $value = static::$function($value); + } + } + + return $value; + } + + /** + * @param string $str + * @return string + */ + public static function filterLower($str) + { + return mb_strtolower($str); + } + + /** + * @param string $str + * @return string + */ + public static function filterUpper($str) + { + return mb_strtoupper($str); + } + + /** + * @param string $str + * @return int + */ + public static function filterLength($str) + { + return mb_strlen($str); + } + + /** + * @param string $str + * @return string + */ + public static function filterLtrim($str) + { + return ltrim($str); + } + + /** + * @param string $str + * @return string + */ + public static function filterRtrim($str) + { + return rtrim($str); + } + + /** + * @param string $str + * @return string + */ + public static function filterTrim($str) + { + return trim($str); + } + + /** + * Helper for sorting arrays of objects based on multiple fields + orientations. + * + * Comparison between two strings is natural and case insensitive. + * + * @param string $name + * @param int $orientation + * @param Closure|null $next + * + * @return Closure + */ + public static function sortByField($name, $orientation = 1, Closure $next = null) + { + if (!$next) { + $next = function ($a, $b) { + return 0; + }; + } + + return function ($a, $b) use ($name, $next, $orientation) { + $aValue = static::getObjectFieldValue($a, $name); + $bValue = static::getObjectFieldValue($b, $name); + + if ($aValue === $bValue) { + return $next($a, $b); + } + + // For strings we use natural case insensitive sorting. + if (is_string($aValue) && is_string($bValue)) { + return strnatcasecmp($aValue, $bValue) * $orientation; + } + + return (($aValue > $bValue) ? 1 : -1) * $orientation; + }; + } + + /** + * {@inheritDoc} + */ + public function walkComparison(Comparison $comparison) + { + $field = $comparison->getField(); + $value = $comparison->getValue()->getValue(); // shortcut for walkValue() + + switch ($comparison->getOperator()) { + case Comparison::EQ: + return function ($object) use ($field, $value) { + return static::getObjectFieldValue($object, $field) === $value; + }; + + case Comparison::NEQ: + return function ($object) use ($field, $value) { + return static::getObjectFieldValue($object, $field) !== $value; + }; + + case Comparison::LT: + return function ($object) use ($field, $value) { + return static::getObjectFieldValue($object, $field) < $value; + }; + + case Comparison::LTE: + return function ($object) use ($field, $value) { + return static::getObjectFieldValue($object, $field) <= $value; + }; + + case Comparison::GT: + return function ($object) use ($field, $value) { + return static::getObjectFieldValue($object, $field) > $value; + }; + + case Comparison::GTE: + return function ($object) use ($field, $value) { + return static::getObjectFieldValue($object, $field) >= $value; + }; + + case Comparison::IN: + return function ($object) use ($field, $value) { + return in_array(static::getObjectFieldValue($object, $field), $value, true); + }; + + case Comparison::NIN: + return function ($object) use ($field, $value) { + return !in_array(static::getObjectFieldValue($object, $field), $value, true); + }; + + case Comparison::CONTAINS: + return function ($object) use ($field, $value) { + return false !== strpos(static::getObjectFieldValue($object, $field), $value); + }; + + case Comparison::MEMBER_OF: + return function ($object) use ($field, $value) { + $fieldValues = static::getObjectFieldValue($object, $field); + if (!is_array($fieldValues)) { + $fieldValues = iterator_to_array($fieldValues); + } + return in_array($value, $fieldValues, true); + }; + + case Comparison::STARTS_WITH: + return function ($object) use ($field, $value) { + return 0 === strpos(static::getObjectFieldValue($object, $field), $value); + }; + + case Comparison::ENDS_WITH: + return function ($object) use ($field, $value) { + return $value === substr(static::getObjectFieldValue($object, $field), -strlen($value)); + }; + + default: + throw new RuntimeException('Unknown comparison operator: ' . $comparison->getOperator()); + } + } +} diff --git a/system/src/Grav/Framework/Object/Identifiers/Identifier.php b/system/src/Grav/Framework/Object/Identifiers/Identifier.php new file mode 100644 index 0000000..69f41d2 --- /dev/null +++ b/system/src/Grav/Framework/Object/Identifiers/Identifier.php @@ -0,0 +1,66 @@ +id = $id; + $this->type = $type; + } + + /** + * @return string + * @phpstan-pure + */ + public function getId(): string + { + return $this->id; + } + + /** + * @return string + * @phpstan-pure + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'type' => $this->type, + 'id' => $this->id + ]; + } + + /** + * @return array + */ + public function __debugInfo(): array + { + return $this->jsonSerialize(); + } +} diff --git a/system/src/Grav/Framework/Object/Interfaces/NestedObjectCollectionInterface.php b/system/src/Grav/Framework/Object/Interfaces/NestedObjectCollectionInterface.php new file mode 100644 index 0000000..e84423e --- /dev/null +++ b/system/src/Grav/Framework/Object/Interfaces/NestedObjectCollectionInterface.php @@ -0,0 +1,64 @@ + + */ +interface NestedObjectCollectionInterface extends ObjectCollectionInterface +{ + /** + * @param string $property Object property name. + * @param string|null $separator Separator, defaults to '.' + * @return bool[] List of [key => bool] pairs. + */ + public function hasNestedProperty($property, $separator = null); + + /** + * @param string $property Object property to be fetched. + * @param mixed|null $default Default value if property has not been set. + * @param string|null $separator Separator, defaults to '.' + * @return mixed[] List of [key => value] pairs. + */ + public function getNestedProperty($property, $default = null, $separator = null); + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + * @throws RuntimeException + */ + public function setNestedProperty($property, $value, $separator = null); + + /** + * @param string $property Object property to be defined. + * @param mixed $default Default value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + * @throws RuntimeException + */ + public function defNestedProperty($property, $default, $separator = null); + + /** + * @param string $property Object property to be unset. + * @param string|null $separator Separator, defaults to '.' + * @return $this + * @throws RuntimeException + */ + public function unsetNestedProperty($property, $separator = null); +} diff --git a/system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php b/system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php new file mode 100644 index 0000000..61d4d5c --- /dev/null +++ b/system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php @@ -0,0 +1,60 @@ + + * @extends Selectable + */ +interface ObjectCollectionInterface extends CollectionInterface, Selectable, Serializable +{ + /** + * @return string + */ + public function getType(); + + /** + * @return string + */ + public function getKey(); + + /** + * @param string $key + * @return $this + */ + public function setKey($key); + + /** + * @param string $property Object property name. + * @return bool[] List of [key => bool] pairs. + */ + public function hasProperty($property); + + /** + * @param string $property Object property to be fetched. + * @param mixed|null $default Default value if property has not been set. + * @return mixed[] List of [key => value] pairs. + */ + public function getProperty($property, $default = null); + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @return $this + */ + public function setProperty($property, $value); + + /** + * @param string $property Object property to be defined. + * @param mixed $default Default value. + * @return $this + */ + public function defProperty($property, $default); + + /** + * @param string $property Object property to be unset. + * @return $this + */ + public function unsetProperty($property); + + /** + * Create a copy from this collection by cloning all objects in the collection. + * + * @return static + * @phpstan-return static + */ + public function copy(); + + /** + * @return array + */ + public function getObjectKeys(); + + /** + * @param string $name Method name. + * @param array $arguments List of arguments passed to the function. + * @return array Return values. + */ + public function call($name, array $arguments = []); + + /** + * Group items in the collection by a field and return them as associated array. + * + * @param string $property + * @return array + */ + public function group($property); + + /** + * Group items in the collection by a field and return them as associated array of collections. + * + * @param string $property + * @return static[] + * @phpstan-return array> + */ + public function collectionGroup($property); + + /** + * @param array $ordering + * @return ObjectCollectionInterface + * @phpstan-return static + */ + public function orderBy(array $ordering); + + /** + * @param int $start + * @param int|null $limit + * @return ObjectCollectionInterface + * @phpstan-return static + */ + public function limit($start, $limit = null); +} diff --git a/system/src/Grav/Framework/Object/Interfaces/ObjectInterface.php b/system/src/Grav/Framework/Object/Interfaces/ObjectInterface.php new file mode 100644 index 0000000..042f500 --- /dev/null +++ b/system/src/Grav/Framework/Object/Interfaces/ObjectInterface.php @@ -0,0 +1,63 @@ + + */ +class LazyObject implements NestedObjectInterface, ArrayAccess +{ + use ObjectTrait; + use LazyPropertyTrait; + use NestedPropertyTrait; + use OverloadedPropertyTrait; + use NestedArrayAccessTrait; +} diff --git a/system/src/Grav/Framework/Object/ObjectCollection.php b/system/src/Grav/Framework/Object/ObjectCollection.php new file mode 100644 index 0000000..0060e0a --- /dev/null +++ b/system/src/Grav/Framework/Object/ObjectCollection.php @@ -0,0 +1,131 @@ + + * @implements NestedObjectCollectionInterface + */ +class ObjectCollection extends ArrayCollection implements NestedObjectCollectionInterface +{ + /** @phpstan-use ObjectCollectionTrait */ + use ObjectCollectionTrait; + use NestedPropertyCollectionTrait { + NestedPropertyCollectionTrait::group insteadof ObjectCollectionTrait; + } + + /** + * @param array $elements + * @param string|null $key + * @throws InvalidArgumentException + */ + public function __construct(array $elements = [], $key = null) + { + parent::__construct($this->setElements($elements)); + + $this->setKey($key ?? ''); + } + + /** + * @param array $ordering + * @return static + * @phpstan-return static + */ + public function orderBy(array $ordering) + { + $criteria = Criteria::create()->orderBy($ordering); + + return $this->matching($criteria); + } + + /** + * @param int $start + * @param int|null $limit + * @return static + * @phpstan-return static + */ + public function limit($start, $limit = null) + { + /** @phpstan-var static */ + return $this->createFrom($this->slice($start, $limit)); + } + + /** + * @param Criteria $criteria + * @return static + * @phpstan-return static + */ + public function matching(Criteria $criteria) + { + $expr = $criteria->getWhereExpression(); + $filtered = $this->getElements(); + + if ($expr) { + $visitor = new ObjectExpressionVisitor(); + $filter = $visitor->dispatch($expr); + $filtered = array_filter($filtered, $filter); + } + + if ($orderings = $criteria->getOrderings()) { + $next = null; + foreach (array_reverse($orderings) as $field => $ordering) { + $next = ObjectExpressionVisitor::sortByField($field, $ordering === Criteria::DESC ? -1 : 1, $next); + } + + /** @phpstan-ignore-next-line */ + if ($next) { + uasort($filtered, $next); + } + } + + $offset = $criteria->getFirstResult(); + $length = $criteria->getMaxResults(); + + if ($offset || $length) { + $filtered = array_slice($filtered, (int)$offset, $length); + } + + /** @phpstan-var static */ + return $this->createFrom($filtered); + } + + /** + * @return array + * @phpstan-return array + */ + protected function getElements() + { + return $this->toArray(); + } + + /** + * @param array $elements + * @return array + * @phpstan-return array + */ + protected function setElements(array $elements) + { + /** @phpstan-var array $elements */ + return $elements; + } +} diff --git a/system/src/Grav/Framework/Object/ObjectIndex.php b/system/src/Grav/Framework/Object/ObjectIndex.php new file mode 100644 index 0000000..2ec9470 --- /dev/null +++ b/system/src/Grav/Framework/Object/ObjectIndex.php @@ -0,0 +1,281 @@ + + * @implements NestedObjectCollectionInterface + */ +abstract class ObjectIndex extends AbstractIndexCollection implements NestedObjectCollectionInterface +{ + /** @var string */ + protected static $type; + + /** @var string */ + protected $_key; + + /** + * @param bool $prefix + * @return string + */ + public function getType($prefix = true) + { + $type = $prefix ? $this->getTypePrefix() : ''; + + if (static::$type) { + return $type . static::$type; + } + + $class = get_class($this); + return $type . strtolower(substr($class, strrpos($class, '\\') + 1)); + } + + /** + * @return string + */ + public function getKey() + { + return $this->_key ?: $this->getType() . '@@' . spl_object_hash($this); + } + + /** + * @param string $key + * @return $this + */ + public function setKey($key) + { + $this->_key = $key; + + return $this; + } + + /** + * @param string $property Object property name. + * @return bool[] True if property has been defined (can be null). + */ + public function hasProperty($property) + { + return $this->__call('hasProperty', [$property]); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @return mixed[] Property values. + */ + public function getProperty($property, $default = null) + { + return $this->__call('getProperty', [$property, $default]); + } + + /** + * @param string $property Object property to be updated. + * @param string $value New value. + * @return ObjectCollectionInterface + * @phpstan-return C + */ + public function setProperty($property, $value) + { + return $this->__call('setProperty', [$property, $value]); + } + + /** + * @param string $property Object property to be defined. + * @param mixed $default Default value. + * @return ObjectCollectionInterface + * @phpstan-return C + */ + public function defProperty($property, $default) + { + return $this->__call('defProperty', [$property, $default]); + } + + /** + * @param string $property Object property to be unset. + * @return ObjectCollectionInterface + * @phpstan-return C + */ + public function unsetProperty($property) + { + return $this->__call('unsetProperty', [$property]); + } + + /** + * @param string $property Object property name. + * @param string|null $separator Separator, defaults to '.' + * @return bool[] True if property has been defined (can be null). + */ + public function hasNestedProperty($property, $separator = null) + { + return $this->__call('hasNestedProperty', [$property, $separator]); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @param string|null $separator Separator, defaults to '.' + * @return mixed[] Property values. + */ + public function getNestedProperty($property, $default = null, $separator = null) + { + return $this->__call('getNestedProperty', [$property, $default, $separator]); + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' + * @return ObjectCollectionInterface + * @phpstan-return C + */ + public function setNestedProperty($property, $value, $separator = null) + { + return $this->__call('setNestedProperty', [$property, $value, $separator]); + } + + /** + * @param string $property Object property to be defined. + * @param mixed $default Default value. + * @param string|null $separator Separator, defaults to '.' + * @return ObjectCollectionInterface + * @phpstan-return C + */ + public function defNestedProperty($property, $default, $separator = null) + { + return $this->__call('defNestedProperty', [$property, $default, $separator]); + } + + /** + * @param string $property Object property to be unset. + * @param string|null $separator Separator, defaults to '.' + * @return ObjectCollectionInterface + * @phpstan-return C + */ + public function unsetNestedProperty($property, $separator = null) + { + return $this->__call('unsetNestedProperty', [$property, $separator]); + } + + /** + * Create a copy from this collection by cloning all objects in the collection. + * + * @return static + * @return static + */ + public function copy() + { + $list = []; + foreach ($this->getIterator() as $key => $value) { + /** @phpstan-ignore-next-line */ + $list[$key] = is_object($value) ? clone $value : $value; + } + + /** @phpstan-var static */ + return $this->createFrom($list); + } + + /** + * @return array + */ + public function getObjectKeys() + { + return $this->getKeys(); + } + + /** + * @param array $ordering + * @return ObjectCollectionInterface + * @phpstan-return C + */ + public function orderBy(array $ordering) + { + return $this->__call('orderBy', [$ordering]); + } + + /** + * @param string $method + * @param array $arguments + * @return array|mixed + */ + public function call($method, array $arguments = []) + { + return $this->__call('call', [$method, $arguments]); + } + + /** + * Group items in the collection by a field and return them as associated array. + * + * @param string $property + * @return array + */ + public function group($property) + { + return $this->__call('group', [$property]); + } + + /** + * Group items in the collection by a field and return them as associated array of collections. + * + * @param string $property + * @return ObjectCollectionInterface[] + * @phpstan-return C[] + */ + public function collectionGroup($property) + { + return $this->__call('collectionGroup', [$property]); + } + + /** + * @param Criteria $criteria + * @return ObjectCollectionInterface + * @phpstan-return C + */ + public function matching(Criteria $criteria) + { + $collection = $this->loadCollection($this->getEntries()); + + /** @phpstan-var C $matching */ + $matching = $collection->matching($criteria); + + return $matching; + } + + /** + * @param string $name + * @param array $arguments + * @return mixed + */ + #[\ReturnTypeWillChange] + abstract public function __call($name, $arguments); + + /** + * @return string + */ + protected function getTypePrefix() + { + return ''; + } +} diff --git a/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php b/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php new file mode 100644 index 0000000..a5e89e5 --- /dev/null +++ b/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php @@ -0,0 +1,115 @@ +setElements($elements); + $this->setKey($key ?? ''); + } + + /** + * @param string $property Object property name. + * @return bool True if property has been defined (can be null). + */ + protected function doHasProperty($property) + { + return array_key_exists($property, $this->_elements); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @param bool $doCreate Set true to create variable. + * @return mixed Property value. + */ + protected function &doGetProperty($property, $default = null, $doCreate = false) + { + if (!array_key_exists($property, $this->_elements)) { + if ($doCreate) { + $this->_elements[$property] = null; + } else { + return $default; + } + } + + return $this->_elements[$property]; + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @return void + */ + protected function doSetProperty($property, $value) + { + $this->_elements[$property] = $value; + } + + /** + * @param string $property Object property to be unset. + * @return void + */ + protected function doUnsetProperty($property) + { + unset($this->_elements[$property]); + } + + /** + * @param string $property + * @param mixed|null $default + * @return mixed|null + */ + protected function getElement($property, $default = null) + { + return array_key_exists($property, $this->_elements) ? $this->_elements[$property] : $default; + } + + /** + * @return array + */ + protected function getElements() + { + return array_filter($this->_elements, static function ($val) { + return $val !== null; + }); + } + + /** + * @param array $elements + * @return void + */ + protected function setElements(array $elements) + { + $this->_elements = $elements; + } + + abstract protected function setKey($key); +} diff --git a/system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php b/system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php new file mode 100644 index 0000000..a2ca5be --- /dev/null +++ b/system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php @@ -0,0 +1,114 @@ +offsetLoad($offset, $value)` called first time object property gets accessed + * - `$this->offsetPrepare($offset, $value)` called on every object property set + * - `$this->offsetSerialize($offset, $value)` called when the raw or serialized object property value is needed + * + * @package Grav\Framework\Object\Property + */ +trait LazyPropertyTrait +{ + use ArrayPropertyTrait, ObjectPropertyTrait { + ObjectPropertyTrait::__construct insteadof ArrayPropertyTrait; + ArrayPropertyTrait::doHasProperty as hasArrayProperty; + ArrayPropertyTrait::doGetProperty as getArrayProperty; + ArrayPropertyTrait::doSetProperty as setArrayProperty; + ArrayPropertyTrait::doUnsetProperty as unsetArrayProperty; + ArrayPropertyTrait::getElement as getArrayElement; + ArrayPropertyTrait::getElements as getArrayElements; + ArrayPropertyTrait::setElements insteadof ObjectPropertyTrait; + ObjectPropertyTrait::doHasProperty as hasObjectProperty; + ObjectPropertyTrait::doGetProperty as getObjectProperty; + ObjectPropertyTrait::doSetProperty as setObjectProperty; + ObjectPropertyTrait::doUnsetProperty as unsetObjectProperty; + ObjectPropertyTrait::getElement as getObjectElement; + ObjectPropertyTrait::getElements as getObjectElements; + } + + /** + * @param string $property Object property name. + * @return bool True if property has been defined (can be null). + */ + protected function doHasProperty($property) + { + return $this->hasArrayProperty($property) || $this->hasObjectProperty($property); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @param bool $doCreate + * @return mixed Property value. + */ + protected function &doGetProperty($property, $default = null, $doCreate = false) + { + if ($this->hasObjectProperty($property)) { + return $this->getObjectProperty($property, $default, function ($default = null) use ($property) { + return $this->getArrayProperty($property, $default); + }); + } + + return $this->getArrayProperty($property, $default, $doCreate); + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @return void + */ + protected function doSetProperty($property, $value) + { + if ($this->hasObjectProperty($property)) { + $this->setObjectProperty($property, $value); + } else { + $this->setArrayProperty($property, $value); + } + } + + /** + * @param string $property Object property to be unset. + * @return void + */ + protected function doUnsetProperty($property) + { + $this->hasObjectProperty($property) ? $this->unsetObjectProperty($property) : $this->unsetArrayProperty($property); + } + + /** + * @param string $property + * @param mixed|null $default + * @return mixed|null + */ + protected function getElement($property, $default = null) + { + if ($this->isPropertyLoaded($property)) { + return $this->getObjectElement($property, $default); + } + + return $this->getArrayElement($property, $default); + } + + /** + * @return array + */ + protected function getElements() + { + return $this->getObjectElements() + $this->getArrayElements(); + } +} diff --git a/system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php b/system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php new file mode 100644 index 0000000..4df00a1 --- /dev/null +++ b/system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php @@ -0,0 +1,121 @@ +offsetLoad($offset, $value)` called first time object property gets accessed + * - `$this->offsetPrepare($offset, $value)` called on every object property set + * - `$this->offsetSerialize($offset, $value)` called when the raw or serialized object property value is needed + + * + * @package Grav\Framework\Object\Property + */ +trait MixedPropertyTrait +{ + use ArrayPropertyTrait, ObjectPropertyTrait { + ObjectPropertyTrait::__construct insteadof ArrayPropertyTrait; + ArrayPropertyTrait::doHasProperty as hasArrayProperty; + ArrayPropertyTrait::doGetProperty as getArrayProperty; + ArrayPropertyTrait::doSetProperty as setArrayProperty; + ArrayPropertyTrait::doUnsetProperty as unsetArrayProperty; + ArrayPropertyTrait::getElement as getArrayElement; + ArrayPropertyTrait::getElements as getArrayElements; + ArrayPropertyTrait::setElements as setArrayElements; + ObjectPropertyTrait::doHasProperty as hasObjectProperty; + ObjectPropertyTrait::doGetProperty as getObjectProperty; + ObjectPropertyTrait::doSetProperty as setObjectProperty; + ObjectPropertyTrait::doUnsetProperty as unsetObjectProperty; + ObjectPropertyTrait::getElement as getObjectElement; + ObjectPropertyTrait::getElements as getObjectElements; + ObjectPropertyTrait::setElements as setObjectElements; + } + + /** + * @param string $property Object property name. + * @return bool True if property has been defined (can be null). + */ + protected function doHasProperty($property) + { + return $this->hasArrayProperty($property) || $this->hasObjectProperty($property); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @param bool $doCreate + * @return mixed Property value. + */ + protected function &doGetProperty($property, $default = null, $doCreate = false) + { + if ($this->hasObjectProperty($property)) { + return $this->getObjectProperty($property); + } + + return $this->getArrayProperty($property, $default, $doCreate); + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @return void + */ + protected function doSetProperty($property, $value) + { + $this->hasObjectProperty($property) + ? $this->setObjectProperty($property, $value) : $this->setArrayProperty($property, $value); + } + + /** + * @param string $property Object property to be unset. + * @return void + */ + protected function doUnsetProperty($property) + { + $this->hasObjectProperty($property) ? + $this->unsetObjectProperty($property) : $this->unsetArrayProperty($property); + } + + /** + * @param string $property + * @param mixed|null $default + * @return mixed|null + */ + protected function getElement($property, $default = null) + { + if ($this->hasObjectProperty($property)) { + return $this->getObjectElement($property, $default); + } + + return $this->getArrayElement($property, $default); + } + + /** + * @return array + */ + protected function getElements() + { + return $this->getObjectElements() + $this->getArrayElements(); + } + + /** + * @param array $elements + * @return void + */ + protected function setElements(array $elements) + { + $this->setObjectElements(array_intersect_key($elements, $this->_definedProperties)); + $this->setArrayElements(array_diff_key($elements, $this->_definedProperties)); + } +} diff --git a/system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php b/system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php new file mode 100644 index 0000000..3bc3bb6 --- /dev/null +++ b/system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php @@ -0,0 +1,213 @@ +offsetLoad($offset, $value)` called first time object property gets accessed + * - `$this->offsetPrepare($offset, $value)` called on every object property set + * - `$this->offsetSerialize($offset, $value)` called when the raw or serialized object property value is needed + * + * @package Grav\Framework\Object\Property + */ +trait ObjectPropertyTrait +{ + /** @var array */ + private $_definedProperties; + + /** + * @param array $elements + * @param string|null $key + * @throws InvalidArgumentException + */ + public function __construct(array $elements = [], $key = null) + { + $this->initObjectProperties(); + $this->setElements($elements); + $this->setKey($key ?? ''); + } + + /** + * @param string $property Object property name. + * @return bool True if property has been loaded. + */ + protected function isPropertyLoaded($property) + { + return !empty($this->_definedProperties[$property]); + } + + /** + * @param string $offset + * @param mixed $value + * @return mixed + */ + protected function offsetLoad($offset, $value) + { + $methodName = "offsetLoad_{$offset}"; + + return method_exists($this, $methodName)? $this->{$methodName}($value) : $value; + } + + /** + * @param string $offset + * @param mixed $value + * @return mixed + */ + protected function offsetPrepare($offset, $value) + { + $methodName = "offsetPrepare_{$offset}"; + + return method_exists($this, $methodName) ? $this->{$methodName}($value) : $value; + } + + /** + * @param string $offset + * @param mixed $value + * @return mixed + */ + protected function offsetSerialize($offset, $value) + { + $methodName = "offsetSerialize_{$offset}"; + + return method_exists($this, $methodName) ? $this->{$methodName}($value) : $value; + } + + /** + * @param string $property Object property name. + * @return bool True if property has been defined (can be null). + */ + protected function doHasProperty($property) + { + return array_key_exists($property, $this->_definedProperties); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @param callable|bool $doCreate Set true to create variable. + * @return mixed Property value. + */ + protected function &doGetProperty($property, $default = null, $doCreate = false) + { + if (!array_key_exists($property, $this->_definedProperties)) { + throw new InvalidArgumentException("Property '{$property}' does not exist in the object!"); + } + + if (empty($this->_definedProperties[$property])) { + if ($doCreate === true) { + $this->_definedProperties[$property] = true; + $this->{$property} = null; + } elseif (is_callable($doCreate)) { + $this->_definedProperties[$property] = true; + $this->{$property} = $this->offsetLoad($property, $doCreate()); + } else { + return $default; + } + } + + return $this->{$property}; + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @return void + * @throws InvalidArgumentException + */ + protected function doSetProperty($property, $value) + { + if (!array_key_exists($property, $this->_definedProperties)) { + throw new InvalidArgumentException("Property '{$property}' does not exist in the object!"); + } + + $this->_definedProperties[$property] = true; + $this->{$property} = $this->offsetPrepare($property, $value); + } + + /** + * @param string $property Object property to be unset. + * @return void + */ + protected function doUnsetProperty($property) + { + if (!array_key_exists($property, $this->_definedProperties)) { + return; + } + + $this->_definedProperties[$property] = false; + $this->{$property} = null; + } + + /** + * @return void + */ + protected function initObjectProperties() + { + $this->_definedProperties = []; + foreach (get_object_vars($this) as $property => $value) { + if ($property[0] !== '_') { + $this->_definedProperties[$property] = ($value !== null); + } + } + } + + /** + * @param string $property + * @param mixed|null $default + * @return mixed|null + */ + protected function getElement($property, $default = null) + { + if (empty($this->_definedProperties[$property])) { + return $default; + } + + return $this->offsetSerialize($property, $this->{$property}); + } + + /** + * @return array + */ + protected function getElements() + { + $properties = array_intersect_key(get_object_vars($this), array_filter($this->_definedProperties)); + + $elements = []; + foreach ($properties as $offset => $value) { + $serialized = $this->offsetSerialize($offset, $value); + if ($serialized !== null) { + $elements[$offset] = $this->offsetSerialize($offset, $value); + } + } + + return $elements; + } + + /** + * @param array $elements + * @return void + */ + protected function setElements(array $elements) + { + foreach ($elements as $property => $value) { + $this->setProperty($property, $value); + } + } +} diff --git a/system/src/Grav/Framework/Object/PropertyObject.php b/system/src/Grav/Framework/Object/PropertyObject.php new file mode 100644 index 0000000..d7d1aba --- /dev/null +++ b/system/src/Grav/Framework/Object/PropertyObject.php @@ -0,0 +1,32 @@ + + */ +class PropertyObject implements NestedObjectInterface, ArrayAccess +{ + use ObjectTrait; + use ObjectPropertyTrait; + use NestedPropertyTrait; + use OverloadedPropertyTrait; + use NestedArrayAccessTrait; +} diff --git a/system/src/Grav/Framework/Pagination/AbstractPagination.php b/system/src/Grav/Framework/Pagination/AbstractPagination.php new file mode 100644 index 0000000..e170b4c --- /dev/null +++ b/system/src/Grav/Framework/Pagination/AbstractPagination.php @@ -0,0 +1,429 @@ + 'page', + 'limit' => 10, + 'display' => 5, + 'opening' => 0, + 'ending' => 0, + 'url' => null, + 'param' => null, + 'use_query_param' => false + ]; + /** @var array */ + private $items; + + /** + * @return bool + */ + public function isEnabled(): bool + { + return $this->count() > 1; + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @return Route|null + */ + public function getRoute(): ?Route + { + return $this->route; + } + + /** + * @return int + */ + public function getTotalPages(): int + { + return $this->pages; + } + + /** + * @return int + */ + public function getPageNumber(): int + { + return $this->page ?? 1; + } + + /** + * @param int $count + * @return int|null + */ + public function getPrevNumber(int $count = 1): ?int + { + $page = $this->page - $count; + + return $page >= 1 ? $page : null; + } + + /** + * @param int $count + * @return int|null + */ + public function getNextNumber(int $count = 1): ?int + { + $page = $this->page + $count; + + return $page <= $this->pages ? $page : null; + } + + /** + * @param int $page + * @param string|null $label + * @return PaginationPage|null + */ + public function getPage(int $page, string $label = null): ?PaginationPage + { + if ($page < 1 || $page > $this->pages) { + return null; + } + + $start = ($page - 1) * $this->limit; + $type = $this->getOptions()['type']; + $param = $this->getOptions()['param']; + $useQuery = $this->getOptions()['use_query_param']; + if ($type === 'page') { + $param = $param ?? 'page'; + $offset = $page; + } else { + $param = $param ?? 'start'; + $offset = $start; + } + + if ($useQuery) { + $route = $this->route->withQueryParam($param, $offset); + } else { + $route = $this->route->withGravParam($param, $offset); + } + + return new PaginationPage( + [ + 'label' => $label ?? (string)$page, + 'number' => $page, + 'offset_start' => $start, + 'offset_end' => min($start + $this->limit, $this->total) - 1, + 'enabled' => $page !== $this->page || $this->viewAll, + 'active' => $page === $this->page, + 'route' => $route + ] + ); + } + + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ + public function getFirstPage(string $label = null, int $count = 0): ?PaginationPage + { + return $this->getPage(1 + $count, $label ?? $this->getOptions()['label_first'] ?? null); + } + + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ + public function getPrevPage(string $label = null, int $count = 1): ?PaginationPage + { + return $this->getPage($this->page - $count, $label ?? $this->getOptions()['label_prev'] ?? null); + } + + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ + public function getNextPage(string $label = null, int $count = 1): ?PaginationPage + { + return $this->getPage($this->page + $count, $label ?? $this->getOptions()['label_next'] ?? null); + } + + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ + public function getLastPage(string $label = null, int $count = 0): ?PaginationPage + { + return $this->getPage($this->pages - $count, $label ?? $this->getOptions()['label_last'] ?? null); + } + + /** + * @return int + */ + public function getStart(): int + { + return $this->start ?? 0; + } + + /** + * @return int + */ + public function getLimit(): int + { + return $this->limit; + } + + /** + * @return int + */ + public function getTotal(): int + { + return $this->total; + } + + /** + * @return int + */ + public function count(): int + { + $this->loadItems(); + + return count($this->items); + } + + /** + * @return ArrayIterator + * @phpstan-return ArrayIterator + */ + #[\ReturnTypeWillChange] + public function getIterator() + { + $this->loadItems(); + + return new ArrayIterator($this->items); + } + + /** + * @return array + */ + public function getPages(): array + { + $this->loadItems(); + + return $this->items; + } + + /** + * @return void + */ + protected function loadItems() + { + $this->calculateRange(); + + // Make list like: 1 ... 4 5 6 ... 10 + $range = range($this->pagesStart, $this->pagesStop); + //$range[] = 1; + //$range[] = $this->pages; + natsort($range); + $range = array_unique($range); + + $this->items = []; + foreach ($range as $i) { + $this->items[$i] = $this->getPage($i); + } + } + + /** + * @param Route $route + * @return $this + */ + protected function setRoute(Route $route) + { + $this->route = $route; + + return $this; + } + + /** + * @param array|null $options + * @return $this + */ + protected function setOptions(array $options = null) + { + $this->options = $options ? array_merge($this->defaultOptions, $options) : $this->defaultOptions; + + return $this; + } + + /** + * @param int|null $page + * @return $this + */ + protected function setPage(int $page = null) + { + $this->page = (int)max($page, 1); + $this->start = null; + + return $this; + } + + /** + * @param int|null $start + * @return $this + */ + protected function setStart(int $start = null) + { + $this->start = (int)max($start, 0); + $this->page = null; + + return $this; + } + + /** + * @param int|null $limit + * @return $this + */ + protected function setLimit(int $limit = null) + { + $this->limit = (int)max($limit ?? $this->getOptions()['limit'], 0); + + // No limit, display all records in a single page. + $this->viewAll = !$limit; + + return $this; + } + + /** + * @param int $total + * @return $this + */ + protected function setTotal(int $total) + { + $this->total = (int)max($total, 0); + + return $this; + } + + /** + * @param Route $route + * @param int $total + * @param int|null $pos + * @param int|null $limit + * @param array|null $options + * @return void + */ + protected function initialize(Route $route, int $total, int $pos = null, int $limit = null, array $options = null) + { + $this->setRoute($route); + $this->setOptions($options); + $this->setTotal($total); + if ($this->getOptions()['type'] === 'start') { + $this->setStart($pos); + } else { + $this->setPage($pos); + } + $this->setLimit($limit); + $this->calculateLimits(); + } + + /** + * @return void + */ + protected function calculateLimits() + { + $limit = $this->limit; + $total = $this->total; + + if (!$limit || $limit > $total) { + // All records fit into a single page. + $this->start = 0; + $this->page = 1; + $this->pages = 1; + + return; + } + + if (null === $this->start) { + // If we are using page, convert it to start. + $this->start = (int)(($this->page - 1) * $limit); + } + + if ($this->start > $total - $limit) { + // If start is greater than total count (i.e. we are asked to display records that don't exist) + // then set start to display the last natural page of results. + $this->start = (int)max(0, (ceil($total / $limit) - 1) * $limit); + } + + // Set the total pages and current page values. + $this->page = (int)ceil(($this->start + 1) / $limit); + $this->pages = (int)ceil($total / $limit); + } + + /** + * @return void + */ + protected function calculateRange() + { + $options = $this->getOptions(); + $displayed = $options['display']; + $opening = $options['opening']; + $ending = $options['ending']; + + // Set the pagination iteration loop values. + $this->pagesStart = $this->page - (int)($displayed / 2); + if ($this->pagesStart < 1 + $opening) { + $this->pagesStart = 1 + $opening; + } + if ($this->pagesStart + $displayed - $opening > $this->pages) { + $this->pagesStop = $this->pages; + if ($this->pages < $displayed) { + $this->pagesStart = 1 + $opening; + } else { + $this->pagesStart = $this->pages - $displayed + 1 + $opening; + } + } else { + $this->pagesStop = (int)max(1, $this->pagesStart + $displayed - 1 - $ending); + } + } +} diff --git a/system/src/Grav/Framework/Pagination/AbstractPaginationPage.php b/system/src/Grav/Framework/Pagination/AbstractPaginationPage.php new file mode 100644 index 0000000..7ed55ae --- /dev/null +++ b/system/src/Grav/Framework/Pagination/AbstractPaginationPage.php @@ -0,0 +1,78 @@ +options['active'] ?? false; + } + + /** + * @return bool + */ + public function isEnabled(): bool + { + return $this->options['enabled'] ?? false; + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @return int|null + */ + public function getNumber(): ?int + { + return $this->options['number'] ?? null; + } + + /** + * @return string + */ + public function getLabel(): string + { + return $this->options['label'] ?? (string)$this->getNumber(); + } + + /** + * @return string|null + */ + public function getUrl(): ?string + { + return $this->options['route'] ? (string)$this->options['route']->getUri() : null; + } + + /** + * @param array $options + */ + protected function setOptions(array $options): void + { + $this->options = $options; + } +} diff --git a/system/src/Grav/Framework/Pagination/Interfaces/PaginationInterface.php b/system/src/Grav/Framework/Pagination/Interfaces/PaginationInterface.php new file mode 100644 index 0000000..98b313d --- /dev/null +++ b/system/src/Grav/Framework/Pagination/Interfaces/PaginationInterface.php @@ -0,0 +1,104 @@ + + */ +interface PaginationInterface extends Countable, IteratorAggregate +{ + /** + * @return int + */ + public function getTotalPages(): int; + + /** + * @return int + */ + public function getPageNumber(): int; + + /** + * @param int $count + * @return int|null + */ + public function getPrevNumber(int $count = 1): ?int; + + /** + * @param int $count + * @return int|null + */ + public function getNextNumber(int $count = 1): ?int; + + /** + * @return int + */ + public function getStart(): int; + + /** + * @return int + */ + public function getLimit(): int; + + /** + * @return int + */ + public function getTotal(): int; + + /** + * @return int + */ + public function count(): int; + + /** + * @return array + */ + public function getOptions(): array; + + /** + * @param int $page + * @param string|null $label + * @return PaginationPage|null + */ + public function getPage(int $page, string $label = null): ?PaginationPage; + + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ + public function getFirstPage(string $label = null, int $count = 0): ?PaginationPage; + + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ + public function getPrevPage(string $label = null, int $count = 1): ?PaginationPage; + + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ + public function getNextPage(string $label = null, int $count = 1): ?PaginationPage; + + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ + public function getLastPage(string $label = null, int $count = 0): ?PaginationPage; +} diff --git a/system/src/Grav/Framework/Pagination/Interfaces/PaginationPageInterface.php b/system/src/Grav/Framework/Pagination/Interfaces/PaginationPageInterface.php new file mode 100644 index 0000000..5158e94 --- /dev/null +++ b/system/src/Grav/Framework/Pagination/Interfaces/PaginationPageInterface.php @@ -0,0 +1,47 @@ +initialize($route, $total, $pos, $limit, $options); + } +} diff --git a/system/src/Grav/Framework/Pagination/PaginationPage.php b/system/src/Grav/Framework/Pagination/PaginationPage.php new file mode 100644 index 0000000..73249ed --- /dev/null +++ b/system/src/Grav/Framework/Pagination/PaginationPage.php @@ -0,0 +1,26 @@ +setOptions($options); + } +} diff --git a/system/src/Grav/Framework/Psr7/AbstractUri.php b/system/src/Grav/Framework/Psr7/AbstractUri.php new file mode 100644 index 0000000..271de35 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/AbstractUri.php @@ -0,0 +1,412 @@ + 80, + 'https' => 443 + ]; + + /** @var string Uri scheme. */ + private $scheme = ''; + /** @var string Uri user. */ + private $user = ''; + /** @var string Uri password. */ + private $password = ''; + /** @var string Uri host. */ + private $host = ''; + /** @var int|null Uri port. */ + private $port; + /** @var string Uri path. */ + private $path = ''; + /** @var string Uri query string (without ?). */ + private $query = ''; + /** @var string Uri fragment (without #). */ + private $fragment = ''; + + /** + * Please define constructor which calls $this->init(). + */ + abstract public function __construct(); + + /** + * @inheritdoc + */ + public function getScheme() + { + return $this->scheme; + } + + /** + * @inheritdoc + */ + public function getAuthority() + { + $authority = $this->host; + + $userInfo = $this->getUserInfo(); + if ($userInfo !== '') { + $authority = $userInfo . '@' . $authority; + } + + if ($this->port !== null) { + $authority .= ':' . $this->port; + } + + return $authority; + } + + /** + * @inheritdoc + */ + public function getUserInfo() + { + $userInfo = $this->user; + + if ($this->password !== '') { + $userInfo .= ':' . $this->password; + } + + return $userInfo; + } + + /** + * @inheritdoc + */ + public function getHost() + { + return $this->host; + } + + /** + * @inheritdoc + */ + public function getPort() + { + return $this->port; + } + + /** + * @inheritdoc + */ + public function getPath() + { + return $this->path; + } + + /** + * @inheritdoc + */ + public function getQuery() + { + return $this->query; + } + + /** + * @inheritdoc + */ + public function getFragment() + { + return $this->fragment; + } + + /** + * @inheritdoc + */ + public function withScheme($scheme) + { + $scheme = UriPartsFilter::filterScheme($scheme); + + if ($this->scheme === $scheme) { + return $this; + } + + $new = clone $this; + $new->scheme = $scheme; + $new->unsetDefaultPort(); + $new->validate(); + + return $new; + } + + /** + * @inheritdoc + * @throws InvalidArgumentException + */ + public function withUserInfo($user, $password = null) + { + $user = UriPartsFilter::filterUserInfo($user); + $password = UriPartsFilter::filterUserInfo($password ?? ''); + + if ($this->user === $user && $this->password === $password) { + return $this; + } + + $new = clone $this; + $new->user = $user; + $new->password = $user !== '' ? $password : ''; + $new->validate(); + + return $new; + } + + /** + * @inheritdoc + */ + public function withHost($host) + { + $host = UriPartsFilter::filterHost($host); + + if ($this->host === $host) { + return $this; + } + + $new = clone $this; + $new->host = $host; + $new->validate(); + + return $new; + } + + /** + * @inheritdoc + */ + public function withPort($port) + { + $port = UriPartsFilter::filterPort($port); + + if ($this->port === $port) { + return $this; + } + + $new = clone $this; + $new->port = $port; + $new->unsetDefaultPort(); + $new->validate(); + + return $new; + } + + /** + * @inheritdoc + */ + public function withPath($path) + { + $path = UriPartsFilter::filterPath($path); + + if ($this->path === $path) { + return $this; + } + + $new = clone $this; + $new->path = $path; + $new->validate(); + + return $new; + } + + /** + * @inheritdoc + */ + public function withQuery($query) + { + $query = UriPartsFilter::filterQueryOrFragment($query); + + if ($this->query === $query) { + return $this; + } + + $new = clone $this; + $new->query = $query; + + return $new; + } + + /** + * @inheritdoc + * @throws InvalidArgumentException + */ + public function withFragment($fragment) + { + $fragment = UriPartsFilter::filterQueryOrFragment($fragment); + + if ($this->fragment === $fragment) { + return $this; + } + + $new = clone $this; + $new->fragment = $fragment; + + return $new; + } + + /** + * @return string + */ + #[\ReturnTypeWillChange] + public function __toString() + { + return $this->getUrl(); + } + + /** + * @return array + */ + protected function getParts() + { + return [ + 'scheme' => $this->scheme, + 'host' => $this->host, + 'port' => $this->port, + 'user' => $this->user, + 'pass' => $this->password, + 'path' => $this->path, + 'query' => $this->query, + 'fragment' => $this->fragment + ]; + } + + /** + * Return the fully qualified base URL ( like http://getgrav.org ). + * + * Note that this method never includes a trailing / + * + * @return string + */ + protected function getBaseUrl() + { + $uri = ''; + + $scheme = $this->getScheme(); + if ($scheme !== '') { + $uri .= $scheme . ':'; + } + + $authority = $this->getAuthority(); + if ($authority !== '' || $scheme === 'file') { + $uri .= '//' . $authority; + } + + return $uri; + } + + /** + * @return string + */ + protected function getUrl() + { + $uri = $this->getBaseUrl() . $this->getPath(); + + $query = $this->getQuery(); + if ($query !== '') { + $uri .= '?' . $query; + } + + $fragment = $this->getFragment(); + if ($fragment !== '') { + $uri .= '#' . $fragment; + } + + return $uri; + } + + /** + * @return string + */ + protected function getUser() + { + return $this->user; + } + + /** + * @return string + */ + protected function getPassword() + { + return $this->password; + } + + /** + * @param array $parts + * @return void + * @throws InvalidArgumentException + */ + protected function initParts(array $parts) + { + $this->scheme = isset($parts['scheme']) ? UriPartsFilter::filterScheme($parts['scheme']) : ''; + $this->user = isset($parts['user']) ? UriPartsFilter::filterUserInfo($parts['user']) : ''; + $this->password = isset($parts['pass']) ? UriPartsFilter::filterUserInfo($parts['pass']) : ''; + $this->host = isset($parts['host']) ? UriPartsFilter::filterHost($parts['host']) : ''; + $this->port = isset($parts['port']) ? UriPartsFilter::filterPort((int)$parts['port']) : null; + $this->path = isset($parts['path']) ? UriPartsFilter::filterPath($parts['path']) : ''; + $this->query = isset($parts['query']) ? UriPartsFilter::filterQueryOrFragment($parts['query']) : ''; + $this->fragment = isset($parts['fragment']) ? UriPartsFilter::filterQueryOrFragment($parts['fragment']) : ''; + + $this->unsetDefaultPort(); + $this->validate(); + } + + /** + * @return void + * @throws InvalidArgumentException + */ + private function validate() + { + if ($this->host === '' && ($this->scheme === 'http' || $this->scheme === 'https')) { + throw new InvalidArgumentException('Uri with a scheme must have a host'); + } + + if ($this->getAuthority() === '') { + if (0 === strpos($this->path, '//')) { + throw new InvalidArgumentException('The path of a URI without an authority must not start with two slashes \'//\''); + } + if ($this->scheme === '' && false !== strpos(explode('/', $this->path, 2)[0], ':')) { + throw new InvalidArgumentException('A relative URI must not have a path beginning with a segment containing a colon'); + } + } elseif (isset($this->path[0]) && $this->path[0] !== '/') { + throw new InvalidArgumentException('The path of a URI with an authority must start with a slash \'/\' or be empty'); + } + } + + /** + * @return bool + */ + protected function isDefaultPort() + { + $scheme = $this->scheme; + $port = $this->port; + + return $this->port === null + || (isset(static::$defaultPorts[$scheme]) && $port === static::$defaultPorts[$scheme]); + } + + /** + * @return void + */ + private function unsetDefaultPort() + { + if ($this->isDefaultPort()) { + $this->port = null; + } + } +} diff --git a/system/src/Grav/Framework/Psr7/Request.php b/system/src/Grav/Framework/Psr7/Request.php new file mode 100644 index 0000000..4339ee8 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Request.php @@ -0,0 +1,34 @@ +message = new \Nyholm\Psr7\Request($method, $uri, $headers, $body, $version); + } +} diff --git a/system/src/Grav/Framework/Psr7/Response.php b/system/src/Grav/Framework/Psr7/Response.php new file mode 100644 index 0000000..508f9be --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Response.php @@ -0,0 +1,265 @@ +message = new \Nyholm\Psr7\Response($status, $headers, $body, $version, $reason); + } + + /** + * Json. + * + * Note: This method is not part of the PSR-7 standard. + * + * This method prepares the response object to return an HTTP Json + * response to the client. + * + * @param mixed $data The data + * @param int|null $status The HTTP status code. + * @param int $options Json encoding options + * @param int $depth Json encoding max depth + * @return static + * @phpstan-param positive-int $depth + */ + public function withJson($data, int $status = null, int $options = 0, int $depth = 512): ResponseInterface + { + $json = (string) json_encode($data, $options, $depth); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException(json_last_error_msg(), json_last_error()); + } + + $response = $this->getResponse() + ->withHeader('Content-Type', 'application/json;charset=utf-8') + ->withBody(new Stream($json)); + + if ($status !== null) { + $response = $response->withStatus($status); + } + + $new = clone $this; + $new->message = $response; + + return $new; + } + + /** + * Redirect. + * + * Note: This method is not part of the PSR-7 standard. + * + * This method prepares the response object to return an HTTP Redirect + * response to the client. + * + * @param string $url The redirect destination. + * @param int|null $status The redirect HTTP status code. + * @return static + */ + public function withRedirect(string $url, $status = null): ResponseInterface + { + $response = $this->getResponse()->withHeader('Location', $url); + + if ($status === null) { + $status = 302; + } + + $new = clone $this; + $new->message = $response->withStatus($status); + + return $new; + } + + /** + * Is this response empty? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isEmpty(): bool + { + return in_array($this->getResponse()->getStatusCode(), [204, 205, 304], true); + } + + + /** + * Is this response OK? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isOk(): bool + { + return $this->getResponse()->getStatusCode() === 200; + } + + /** + * Is this response a redirect? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isRedirect(): bool + { + return in_array($this->getResponse()->getStatusCode(), [301, 302, 303, 307, 308], true); + } + + /** + * Is this response forbidden? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + * @api + */ + public function isForbidden(): bool + { + return $this->getResponse()->getStatusCode() === 403; + } + + /** + * Is this response not Found? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isNotFound(): bool + { + return $this->getResponse()->getStatusCode() === 404; + } + + /** + * Is this response informational? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isInformational(): bool + { + $response = $this->getResponse(); + + return $response->getStatusCode() >= 100 && $response->getStatusCode() < 200; + } + + /** + * Is this response successful? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isSuccessful(): bool + { + $response = $this->getResponse(); + + return $response->getStatusCode() >= 200 && $response->getStatusCode() < 300; + } + + /** + * Is this response a redirection? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isRedirection(): bool + { + $response = $this->getResponse(); + + return $response->getStatusCode() >= 300 && $response->getStatusCode() < 400; + } + + /** + * Is this response a client error? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isClientError(): bool + { + $response = $this->getResponse(); + + return $response->getStatusCode() >= 400 && $response->getStatusCode() < 500; + } + + /** + * Is this response a server error? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isServerError(): bool + { + $response = $this->getResponse(); + + return $response->getStatusCode() >= 500 && $response->getStatusCode() < 600; + } + + /** + * Convert response to string. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return string + */ + public function __toString(): string + { + $response = $this->getResponse(); + $output = sprintf( + 'HTTP/%s %s %s%s', + $response->getProtocolVersion(), + $response->getStatusCode(), + $response->getReasonPhrase(), + self::EOL + ); + + foreach ($response->getHeaders() as $name => $values) { + $output .= sprintf('%s: %s', $name, $response->getHeaderLine($name)) . self::EOL; + } + + $output .= self::EOL; + $output .= $response->getBody(); + + return $output; + } +} diff --git a/system/src/Grav/Framework/Psr7/ServerRequest.php b/system/src/Grav/Framework/Psr7/ServerRequest.php new file mode 100644 index 0000000..858151a --- /dev/null +++ b/system/src/Grav/Framework/Psr7/ServerRequest.php @@ -0,0 +1,364 @@ +message = new \Nyholm\Psr7\ServerRequest($method, $uri, $headers, $body, $version, $serverParams); + } + + /** + * Get serverRequest content character set, if known. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return string|null + */ + public function getContentCharset(): ?string + { + $mediaTypeParams = $this->getMediaTypeParams(); + + if (isset($mediaTypeParams['charset'])) { + return $mediaTypeParams['charset']; + } + + return null; + } + + /** + * Get serverRequest content type. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return string|null The serverRequest content type, if known + */ + public function getContentType(): ?string + { + $result = $this->getRequest()->getHeader('Content-Type'); + + return $result ? $result[0] : null; + } + + /** + * Get serverRequest content length, if known. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return int|null + */ + public function getContentLength(): ?int + { + $result = $this->getRequest()->getHeader('Content-Length'); + + return $result ? (int) $result[0] : null; + } + + /** + * Fetch cookie value from cookies sent by the client to the server. + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $key The attribute name. + * @param mixed $default Default value to return if the attribute does not exist. + * + * @return mixed + */ + public function getCookieParam($key, $default = null) + { + $cookies = $this->getRequest()->getCookieParams(); + + return $cookies[$key] ?? $default; + } + + /** + * Get serverRequest media type, if known. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return string|null The serverRequest media type, minus content-type params + */ + public function getMediaType(): ?string + { + $contentType = $this->getContentType(); + + if ($contentType) { + $contentTypeParts = preg_split('/\s*[;,]\s*/', $contentType); + if ($contentTypeParts === false) { + return null; + } + return strtolower($contentTypeParts[0]); + } + + return null; + } + + /** + * Get serverRequest media type params, if known. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return mixed[] + */ + public function getMediaTypeParams(): array + { + $contentType = $this->getContentType(); + $contentTypeParams = []; + + if ($contentType) { + $contentTypeParts = preg_split('/\s*[;,]\s*/', $contentType); + if ($contentTypeParts !== false) { + $contentTypePartsLength = count($contentTypeParts); + for ($i = 1; $i < $contentTypePartsLength; $i++) { + $paramParts = explode('=', $contentTypeParts[$i]); + $contentTypeParams[strtolower($paramParts[0])] = $paramParts[1]; + } + } + } + + return $contentTypeParams; + } + + /** + * Fetch serverRequest parameter value from body or query string (in that order). + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $key The parameter key. + * @param string|null $default The default value. + * + * @return mixed The parameter value. + */ + public function getParam($key, $default = null) + { + $postParams = $this->getParsedBody(); + $getParams = $this->getQueryParams(); + $result = $default; + + if (is_array($postParams) && isset($postParams[$key])) { + $result = $postParams[$key]; + } elseif (is_object($postParams) && property_exists($postParams, $key)) { + $result = $postParams->$key; + } elseif (isset($getParams[$key])) { + $result = $getParams[$key]; + } + + return $result; + } + + /** + * Fetch associative array of body and query string parameters. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return mixed[] + */ + public function getParams(): array + { + $params = $this->getQueryParams(); + $postParams = $this->getParsedBody(); + + if ($postParams) { + $params = array_merge($params, (array)$postParams); + } + + return $params; + } + + /** + * Fetch parameter value from serverRequest body. + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $key + * @param mixed $default + * + * @return mixed + */ + public function getParsedBodyParam($key, $default = null) + { + $postParams = $this->getParsedBody(); + $result = $default; + + if (is_array($postParams) && isset($postParams[$key])) { + $result = $postParams[$key]; + } elseif (is_object($postParams) && property_exists($postParams, $key)) { + $result = $postParams->{$key}; + } + + return $result; + } + + /** + * Fetch parameter value from query string. + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $key + * @param mixed $default + * + * @return mixed + */ + public function getQueryParam($key, $default = null) + { + $getParams = $this->getQueryParams(); + + return $getParams[$key] ?? $default; + } + + /** + * Retrieve a server parameter. + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public function getServerParam($key, $default = null) + { + $serverParams = $this->getRequest()->getServerParams(); + + return $serverParams[$key] ?? $default; + } + + /** + * Does this serverRequest use a given method? + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $method HTTP method + * @return bool + */ + public function isMethod($method): bool + { + return $this->getRequest()->getMethod() === $method; + } + + /** + * Is this a DELETE serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isDelete(): bool + { + return $this->isMethod('DELETE'); + } + + /** + * Is this a GET serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isGet(): bool + { + return $this->isMethod('GET'); + } + + /** + * Is this a HEAD serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isHead(): bool + { + return $this->isMethod('HEAD'); + } + + /** + * Is this a OPTIONS serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isOptions(): bool + { + return $this->isMethod('OPTIONS'); + } + + /** + * Is this a PATCH serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isPatch(): bool + { + return $this->isMethod('PATCH'); + } + + /** + * Is this a POST serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isPost(): bool + { + return $this->isMethod('POST'); + } + + /** + * Is this a PUT serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isPut(): bool + { + return $this->isMethod('PUT'); + } + + /** + * Is this an XHR serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isXhr(): bool + { + return $this->getRequest()->getHeaderLine('X-Requested-With') === 'XMLHttpRequest'; + } +} diff --git a/system/src/Grav/Framework/Psr7/Stream.php b/system/src/Grav/Framework/Psr7/Stream.php new file mode 100644 index 0000000..03f8536 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Stream.php @@ -0,0 +1,43 @@ +stream = \Nyholm\Psr7\Stream::create($body); + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/MessageDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/MessageDecoratorTrait.php new file mode 100644 index 0000000..9e5201c --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/MessageDecoratorTrait.php @@ -0,0 +1,140 @@ + + */ +trait MessageDecoratorTrait +{ + /** @var MessageInterface */ + private $message; + + /** + * Returns the decorated message. + * + * Since the underlying Message is immutable as well + * exposing it is not an issue, because it's state cannot be altered + * + * @return MessageInterface + */ + public function getMessage(): MessageInterface + { + return $this->message; + } + + /** + * {@inheritdoc} + */ + public function getProtocolVersion(): string + { + return $this->message->getProtocolVersion(); + } + + /** + * {@inheritdoc} + */ + public function withProtocolVersion($version): self + { + $new = clone $this; + $new->message = $this->message->withProtocolVersion($version); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getHeaders(): array + { + return $this->message->getHeaders(); + } + + /** + * {@inheritdoc} + */ + public function hasHeader($header): bool + { + return $this->message->hasHeader($header); + } + + /** + * {@inheritdoc} + */ + public function getHeader($header): array + { + return $this->message->getHeader($header); + } + + /** + * {@inheritdoc} + */ + public function getHeaderLine($header): string + { + return $this->message->getHeaderLine($header); + } + + /** + * {@inheritdoc} + */ + public function getBody(): StreamInterface + { + return $this->message->getBody(); + } + + /** + * {@inheritdoc} + */ + public function withHeader($header, $value): self + { + $new = clone $this; + $new->message = $this->message->withHeader($header, $value); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withAddedHeader($header, $value): self + { + $new = clone $this; + $new->message = $this->message->withAddedHeader($header, $value); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withoutHeader($header): self + { + $new = clone $this; + $new->message = $this->message->withoutHeader($header); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withBody(StreamInterface $body): self + { + $new = clone $this; + $new->message = $this->message->withBody($body); + + return $new; + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php new file mode 100644 index 0000000..a24a13f --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php @@ -0,0 +1,112 @@ + + */ +trait RequestDecoratorTrait +{ + use MessageDecoratorTrait { + getMessage as private; + } + + /** + * Returns the decorated request. + * + * Since the underlying Request is immutable as well + * exposing it is not an issue, because it's state cannot be altered + * + * @return RequestInterface + */ + public function getRequest(): RequestInterface + { + /** @var RequestInterface $message */ + $message = $this->getMessage(); + + return $message; + } + + /** + * Exchanges the underlying request with another. + * + * @param RequestInterface $request + * @return self + */ + public function withRequest(RequestInterface $request): self + { + $new = clone $this; + $new->message = $request; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getRequestTarget(): string + { + return $this->getRequest()->getRequestTarget(); + } + + /** + * {@inheritdoc} + */ + public function withRequestTarget($requestTarget): self + { + $new = clone $this; + $new->message = $this->getRequest()->withRequestTarget($requestTarget); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getMethod(): string + { + return $this->getRequest()->getMethod(); + } + + /** + * {@inheritdoc} + */ + public function withMethod($method): self + { + $new = clone $this; + $new->message = $this->getRequest()->withMethod($method); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getUri(): UriInterface + { + return $this->getRequest()->getUri(); + } + + /** + * {@inheritdoc} + */ + public function withUri(UriInterface $uri, $preserveHost = false): self + { + $new = clone $this; + $new->message = $this->getRequest()->withUri($uri, $preserveHost); + + return $new; + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php new file mode 100644 index 0000000..1bf29a6 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php @@ -0,0 +1,82 @@ + + */ +trait ResponseDecoratorTrait +{ + use MessageDecoratorTrait { + getMessage as private; + } + + /** + * Returns the decorated response. + * + * Since the underlying Response is immutable as well + * exposing it is not an issue, because it's state cannot be altered + * + * @return ResponseInterface + */ + public function getResponse(): ResponseInterface + { + /** @var ResponseInterface $message */ + $message = $this->getMessage(); + + return $message; + } + + /** + * Exchanges the underlying response with another. + * + * @param ResponseInterface $response + * + * @return self + */ + public function withResponse(ResponseInterface $response): self + { + $new = clone $this; + $new->message = $response; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getStatusCode(): int + { + return $this->getResponse()->getStatusCode(); + } + + /** + * {@inheritdoc} + */ + public function withStatus($code, $reasonPhrase = ''): self + { + $new = clone $this; + $new->message = $this->getResponse()->withStatus($code, $reasonPhrase); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getReasonPhrase(): string + { + return $this->getResponse()->getReasonPhrase(); + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php new file mode 100644 index 0000000..4a4bd7f --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php @@ -0,0 +1,176 @@ +getMessage(); + + return $message; + } + + /** + * @inheritdoc + */ + public function getAttribute($name, $default = null) + { + return $this->getRequest()->getAttribute($name, $default); + } + + /** + * @inheritdoc + */ + public function getAttributes() + { + return $this->getRequest()->getAttributes(); + } + + + /** + * @inheritdoc + */ + public function getCookieParams() + { + return $this->getRequest()->getCookieParams(); + } + + /** + * @inheritdoc + */ + public function getParsedBody() + { + return $this->getRequest()->getParsedBody(); + } + + /** + * @inheritdoc + */ + public function getQueryParams() + { + return $this->getRequest()->getQueryParams(); + } + + /** + * @inheritdoc + */ + public function getServerParams() + { + return $this->getRequest()->getServerParams(); + } + + /** + * @inheritdoc + */ + public function getUploadedFiles() + { + return $this->getRequest()->getUploadedFiles(); + } + + /** + * @inheritdoc + */ + public function withAttribute($name, $value) + { + $new = clone $this; + $new->message = $this->getRequest()->withAttribute($name, $value); + + return $new; + } + + /** + * @param array $attributes + * @return ServerRequestInterface + */ + public function withAttributes(array $attributes) + { + $new = clone $this; + foreach ($attributes as $attribute => $value) { + $new->message = $new->withAttribute($attribute, $value); + } + + return $new; + } + + /** + * @inheritdoc + */ + public function withoutAttribute($name) + { + $new = clone $this; + $new->message = $this->getRequest()->withoutAttribute($name); + + return $new; + } + + /** + * @inheritdoc + */ + public function withCookieParams(array $cookies) + { + $new = clone $this; + $new->message = $this->getRequest()->withCookieParams($cookies); + + return $new; + } + + /** + * @inheritdoc + */ + public function withParsedBody($data) + { + $new = clone $this; + $new->message = $this->getRequest()->withParsedBody($data); + + return $new; + } + + /** + * @inheritdoc + */ + public function withQueryParams(array $query) + { + $new = clone $this; + $new->message = $this->getRequest()->withQueryParams($query); + + return $new; + } + + /** + * @inheritdoc + */ + public function withUploadedFiles(array $uploadedFiles) + { + $new = clone $this; + $new->message = $this->getRequest()->withUploadedFiles($uploadedFiles); + + return $new; + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php new file mode 100644 index 0000000..a1c820e --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php @@ -0,0 +1,153 @@ +stream->__toString(); + } + + /** + * @return void + */ + #[\ReturnTypeWillChange] + public function __destruct() + { + $this->stream->close(); + } + + /** + * {@inheritdoc} + */ + public function close(): void + { + $this->stream->close(); + } + + /** + * {@inheritdoc} + */ + public function detach() + { + return $this->stream->detach(); + } + + /** + * {@inheritdoc} + */ + public function getSize(): ?int + { + return $this->stream->getSize(); + } + + /** + * {@inheritdoc} + */ + public function tell(): int + { + return $this->stream->tell(); + } + + /** + * {@inheritdoc} + */ + public function eof(): bool + { + return $this->stream->eof(); + } + + /** + * {@inheritdoc} + */ + public function isSeekable(): bool + { + return $this->stream->isSeekable(); + } + + /** + * {@inheritdoc} + */ + public function seek($offset, $whence = \SEEK_SET): void + { + $this->stream->seek($offset, $whence); + } + + /** + * {@inheritdoc} + */ + public function rewind(): void + { + $this->stream->rewind(); + } + + /** + * {@inheritdoc} + */ + public function isWritable(): bool + { + return $this->stream->isWritable(); + } + + /** + * {@inheritdoc} + */ + public function write($string): int + { + return $this->stream->write($string); + } + + /** + * {@inheritdoc} + */ + public function isReadable(): bool + { + return $this->stream->isReadable(); + } + + /** + * {@inheritdoc} + */ + public function read($length): string + { + return $this->stream->read($length); + } + + /** + * {@inheritdoc} + */ + public function getContents(): string + { + return $this->stream->getContents(); + } + + /** + * {@inheritdoc} + */ + public function getMetadata($key = null) + { + return $this->stream->getMetadata($key); + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php new file mode 100644 index 0000000..e0a3e44 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php @@ -0,0 +1,73 @@ +uploadedFile->getStream(); + } + + /** + * @param string $targetPath + */ + public function moveTo($targetPath): void + { + $this->uploadedFile->moveTo($targetPath); + } + + /** + * @return int|null + */ + public function getSize(): ?int + { + return $this->uploadedFile->getSize(); + } + + /** + * @return int + */ + public function getError(): int + { + return $this->uploadedFile->getError(); + } + + /** + * @return string|null + */ + public function getClientFilename(): ?string + { + return $this->uploadedFile->getClientFilename(); + } + + /** + * @return string|null + */ + public function getClientMediaType(): ?string + { + return $this->uploadedFile->getClientMediaType(); + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/UriDecorationTrait.php b/system/src/Grav/Framework/Psr7/Traits/UriDecorationTrait.php new file mode 100644 index 0000000..f697812 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/UriDecorationTrait.php @@ -0,0 +1,188 @@ +uri->__toString(); + } + + /** + * @return string + */ + public function getScheme(): string + { + return $this->uri->getScheme(); + } + + /** + * @return string + */ + public function getAuthority(): string + { + return $this->uri->getAuthority(); + } + + /** + * @return string + */ + public function getUserInfo(): string + { + return $this->uri->getUserInfo(); + } + + /** + * @return string + */ + public function getHost(): string + { + return $this->uri->getHost(); + } + + /** + * @return int|null + */ + public function getPort(): ?int + { + return $this->uri->getPort(); + } + + /** + * @return string + */ + public function getPath(): string + { + return $this->uri->getPath(); + } + + /** + * @return string + */ + public function getQuery(): string + { + return $this->uri->getQuery(); + } + + /** + * @return string + */ + public function getFragment(): string + { + return $this->uri->getFragment(); + } + + /** + * @param string $scheme + * @return UriInterface + */ + public function withScheme($scheme): UriInterface + { + $new = clone $this; + $new->uri = $this->uri->withScheme($scheme); + + /** @var UriInterface $new */ + return $new; + } + + /** + * @param string $user + * @param string|null $password + * @return UriInterface + */ + public function withUserInfo($user, $password = null): UriInterface + { + $new = clone $this; + $new->uri = $this->uri->withUserInfo($user, $password); + + /** @var UriInterface $new */ + return $new; + } + + /** + * @param string $host + * @return UriInterface + */ + public function withHost($host): UriInterface + { + $new = clone $this; + $new->uri = $this->uri->withHost($host); + + /** @var UriInterface $new */ + return $new; + } + + /** + * @param int|null $port + * @return UriInterface + */ + public function withPort($port): UriInterface + { + $new = clone $this; + $new->uri = $this->uri->withPort($port); + + /** @var UriInterface $new */ + return $new; + } + + /** + * @param string $path + * @return UriInterface + */ + public function withPath($path): UriInterface + { + $new = clone $this; + $new->uri = $this->uri->withPath($path); + + /** @var UriInterface $new */ + return $new; + } + + /** + * @param string $query + * @return UriInterface + */ + public function withQuery($query): UriInterface + { + $new = clone $this; + $new->uri = $this->uri->withQuery($query); + + /** @var UriInterface $new */ + return $new; + } + + /** + * @param string $fragment + * @return UriInterface + */ + public function withFragment($fragment): UriInterface + { + $new = clone $this; + $new->uri = $this->uri->withFragment($fragment); + + /** @var UriInterface $new */ + return $new; + } +} diff --git a/system/src/Grav/Framework/Psr7/UploadedFile.php b/system/src/Grav/Framework/Psr7/UploadedFile.php new file mode 100644 index 0000000..d3edac0 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/UploadedFile.php @@ -0,0 +1,70 @@ +uploadedFile = new \Nyholm\Psr7\UploadedFile($streamOrFile, $size, $errorStatus, $clientFilename, $clientMediaType); + } + + /** + * @param array $meta + * @return $this + */ + public function setMeta(array $meta) + { + $this->meta = $meta; + + return $this; + } + + /** + * @param array $meta + * @return $this + */ + public function addMeta(array $meta) + { + $this->meta = array_merge($this->meta, $meta); + + return $this; + } + + /** + * @return array + */ + public function getMeta(): array + { + return $this->meta; + } +} diff --git a/system/src/Grav/Framework/Psr7/Uri.php b/system/src/Grav/Framework/Psr7/Uri.php new file mode 100644 index 0000000..631268d --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Uri.php @@ -0,0 +1,135 @@ +uri = new \Nyholm\Psr7\Uri($uri); + } + + /** + * @return array + */ + public function getQueryParams(): array + { + return UriFactory::parseQuery($this->getQuery()); + } + + /** + * @param array $params + * @return UriInterface + */ + public function withQueryParams(array $params): UriInterface + { + $query = UriFactory::buildQuery($params); + + return $this->withQuery($query); + } + + /** + * Whether the URI has the default port of the current scheme. + * + * `$uri->getPort()` may return the standard port. This method can be used for some non-http/https Uri. + * + * @return bool + */ + public function isDefaultPort(): bool + { + return $this->getPort() === null || GuzzleUri::isDefaultPort($this); + } + + /** + * Whether the URI is absolute, i.e. it has a scheme. + * + * An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true + * if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative + * to another URI, the base URI. Relative references can be divided into several forms: + * - network-path references, e.g. '//example.com/path' + * - absolute-path references, e.g. '/path' + * - relative-path references, e.g. 'subpath' + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4 + */ + public function isAbsolute(): bool + { + return GuzzleUri::isAbsolute($this); + } + + /** + * Whether the URI is a network-path reference. + * + * A relative reference that begins with two slash characters is termed an network-path reference. + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public function isNetworkPathReference(): bool + { + return GuzzleUri::isNetworkPathReference($this); + } + + /** + * Whether the URI is a absolute-path reference. + * + * A relative reference that begins with a single slash character is termed an absolute-path reference. + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public function isAbsolutePathReference(): bool + { + return GuzzleUri::isAbsolutePathReference($this); + } + + /** + * Whether the URI is a relative-path reference. + * + * A relative reference that does not begin with a slash character is termed a relative-path reference. + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public function isRelativePathReference(): bool + { + return GuzzleUri::isRelativePathReference($this); + } + + /** + * Whether the URI is a same-document reference. + * + * A same-document reference refers to a URI that is, aside from its fragment + * component, identical to the base URI. When no base URI is given, only an empty + * URI reference (apart from its fragment) is considered a same-document reference. + * + * @param UriInterface|null $base An optional base URI to compare against + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.4 + */ + public function isSameDocumentReference(UriInterface $base = null): bool + { + return GuzzleUri::isSameDocumentReference($this, $base); + } +} diff --git a/system/src/Grav/Framework/Relationships/Relationships.php b/system/src/Grav/Framework/Relationships/Relationships.php new file mode 100644 index 0000000..6485682 --- /dev/null +++ b/system/src/Grav/Framework/Relationships/Relationships.php @@ -0,0 +1,217 @@ + + */ +class Relationships implements RelationshipsInterface +{ + /** @var P */ + protected $parent; + /** @var array */ + protected $options; + + /** @var RelationshipInterface[] */ + protected $relationships; + + /** + * Relationships constructor. + * @param P $parent + * @param array $options + */ + public function __construct(IdentifierInterface $parent, array $options) + { + $this->parent = $parent; + $this->options = $options; + $this->relationships = []; + } + + /** + * @return bool + * @phpstan-pure + */ + public function isModified(): bool + { + return !empty($this->getModified()); + } + + /** + * @return RelationshipInterface[] + * @phpstan-pure + */ + public function getModified(): array + { + $list = []; + foreach ($this->relationships as $name => $relationship) { + if ($relationship->isModified()) { + $list[$name] = $relationship; + } + } + + return $list; + } + + /** + * @return int + * @phpstan-pure + */ + public function count(): int + { + return count($this->options); + } + + /** + * @param string $offset + * @return bool + * @phpstan-pure + */ + public function offsetExists($offset): bool + { + return isset($this->options[$offset]); + } + + /** + * @param string $offset + * @return RelationshipInterface|null + */ + public function offsetGet($offset): ?RelationshipInterface + { + if (!isset($this->relationships[$offset])) { + $options = $this->options[$offset] ?? null; + if (null === $options) { + return null; + } + + $this->relationships[$offset] = $this->createRelationship($offset, $options); + } + + return $this->relationships[$offset]; + } + + /** + * @param string $offset + * @param mixed $value + * @return never-return + */ + public function offsetSet($offset, $value) + { + throw new RuntimeException('Setting relationship is not supported', 500); + } + + /** + * @param string $offset + * @return never-return + */ + public function offsetUnset($offset) + { + throw new RuntimeException('Removing relationship is not allowed', 500); + } + + /** + * @return RelationshipInterface|null + */ + public function current(): ?RelationshipInterface + { + $name = key($this->options); + if ($name === null) { + return null; + } + + return $this->offsetGet($name); + } + + /** + * @return string + * @phpstan-pure + */ + public function key(): string + { + return key($this->options); + } + + /** + * @return void + * @phpstan-pure + */ + public function next(): void + { + next($this->options); + } + + /** + * @return void + * @phpstan-pure + */ + public function rewind(): void + { + reset($this->options); + } + + /** + * @return bool + * @phpstan-pure + */ + public function valid(): bool + { + return key($this->options) !== null; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $list = []; + foreach ($this as $name => $relationship) { + $list[$name] = $relationship->jsonSerialize(); + } + + return $list; + } + + /** + * @param string $name + * @param array $options + * @return ToOneRelationship|ToManyRelationship + */ + private function createRelationship(string $name, array $options): RelationshipInterface + { + $data = null; + + $parent = $this->parent; + if ($parent instanceof FlexIdentifier) { + $object = $parent->getObject(); + if (!method_exists($object, 'initRelationship')) { + throw new RuntimeException(sprintf('Bad relationship %s', $name), 500); + } + + $data = $object->initRelationship($name); + } + + $cardinality = $options['cardinality'] ?? ''; + switch ($cardinality) { + case 'to-one': + $relationship = new ToOneRelationship($parent, $name, $options, $data); + break; + case 'to-many': + $relationship = new ToManyRelationship($parent, $name, $options, $data ?? []); + break; + default: + throw new RuntimeException(sprintf('Bad relationship cardinality %s', $cardinality), 500); + } + + return $relationship; + } +} diff --git a/system/src/Grav/Framework/Relationships/ToManyRelationship.php b/system/src/Grav/Framework/Relationships/ToManyRelationship.php new file mode 100644 index 0000000..3ea501b --- /dev/null +++ b/system/src/Grav/Framework/Relationships/ToManyRelationship.php @@ -0,0 +1,259 @@ + + */ +class ToManyRelationship implements ToManyRelationshipInterface +{ + /** @template-use RelationshipTrait */ + use RelationshipTrait; + use Serializable; + + /** @var IdentifierInterface[] */ + protected $identifiers = []; + + /** + * ToManyRelationship constructor. + * @param string $name + * @param IdentifierInterface $parent + * @param iterable $identifiers + */ + public function __construct(IdentifierInterface $parent, string $name, array $options, iterable $identifiers = []) + { + $this->parent = $parent; + $this->name = $name; + + $this->parseOptions($options); + $this->addIdentifiers($identifiers); + + $this->modified = false; + } + + /** + * @return string + * @phpstan-pure + */ + public function getCardinality(): string + { + return 'to-many'; + } + + /** + * @return int + * @phpstan-pure + */ + public function count(): int + { + return count($this->identifiers); + } + + /** + * @return array + */ + public function fetch(): array + { + $list = []; + foreach ($this->identifiers as $identifier) { + if (is_callable([$identifier, 'getObject'])) { + $identifier = $identifier->getObject(); + } + $list[] = $identifier; + } + + return $list; + } + + /** + * @param string $id + * @param string|null $type + * @return bool + * @phpstan-pure + */ + public function has(string $id, string $type = null): bool + { + return $this->getIdentifier($id, $type) !== null; + } + + /** + * @param positive-int $pos + * @return IdentifierInterface|null + */ + public function getNthIdentifier(int $pos): ?IdentifierInterface + { + $items = array_keys($this->identifiers); + $key = $items[$pos - 1] ?? null; + if (null === $key) { + return null; + } + + return $this->identifiers[$key] ?? null; + } + + /** + * @param string $id + * @param string|null $type + * @return IdentifierInterface|null + * @phpstan-pure + */ + public function getIdentifier(string $id, string $type = null): ?IdentifierInterface + { + if (null === $type) { + $type = $this->getType(); + } + + if ($type === 'media' && !str_contains($id, '/')) { + $name = $this->name; + $id = $this->parent->getType() . '/' . $this->parent->getId() . '/'. $name . '/' . $id; + } + + $key = "{$type}/{$id}"; + + return $this->identifiers[$key] ?? null; + } + + /** + * @param string $id + * @param string|null $type + * @return T|null + */ + public function getObject(string $id, string $type = null): ?object + { + $identifier = $this->getIdentifier($id, $type); + if ($identifier && is_callable([$identifier, 'getObject'])) { + $identifier = $identifier->getObject(); + } + + return $identifier; + } + + /** + * @param IdentifierInterface $identifier + * @return bool + */ + public function addIdentifier(IdentifierInterface $identifier): bool + { + return $this->addIdentifiers([$identifier]); + } + + /** + * @param IdentifierInterface|null $identifier + * @return bool + */ + public function removeIdentifier(IdentifierInterface $identifier = null): bool + { + return !$identifier || $this->removeIdentifiers([$identifier]); + } + + /** + * @param iterable $identifiers + * @return bool + */ + public function addIdentifiers(iterable $identifiers): bool + { + foreach ($identifiers as $identifier) { + $type = $identifier->getType(); + $id = $identifier->getId(); + $key = "{$type}/{$id}"; + + $this->identifiers[$key] = $this->checkIdentifier($identifier); + $this->modified = true; + } + + return true; + } + + /** + * @param iterable $identifiers + * @return bool + */ + public function replaceIdentifiers(iterable $identifiers): bool + { + $this->identifiers = []; + $this->modified = true; + + return $this->addIdentifiers($identifiers); + } + + /** + * @param iterable $identifiers + * @return bool + */ + public function removeIdentifiers(iterable $identifiers): bool + { + foreach ($identifiers as $identifier) { + $type = $identifier->getType(); + $id = $identifier->getId(); + $key = "{$type}/{$id}"; + + unset($this->identifiers[$key]); + $this->modified = true; + } + + return true; + } + + /** + * @return iterable + * @phpstan-pure + */ + public function getIterator(): iterable + { + return new ArrayIterator($this->identifiers); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $list = []; + foreach ($this->getIterator() as $item) { + $list[] = $item->jsonSerialize(); + } + + return $list; + } + + /** + * @return array + */ + public function __serialize(): array + { + return [ + 'parent' => $this->parent, + 'name' => $this->name, + 'type' => $this->type, + 'options' => $this->options, + 'modified' => $this->modified, + 'identifiers' => $this->identifiers, + ]; + } + + /** + * @param array $data + * @return void + */ + public function __unserialize(array $data): void + { + $this->parent = $data['parent']; + $this->name = $data['name']; + $this->type = $data['type']; + $this->options = $data['options']; + $this->modified = $data['modified']; + $this->identifiers = $data['identifiers']; + } +} diff --git a/system/src/Grav/Framework/Relationships/ToOneRelationship.php b/system/src/Grav/Framework/Relationships/ToOneRelationship.php new file mode 100644 index 0000000..9b09651 --- /dev/null +++ b/system/src/Grav/Framework/Relationships/ToOneRelationship.php @@ -0,0 +1,207 @@ + + */ +class ToOneRelationship implements ToOneRelationshipInterface +{ + /** @template-use RelationshipTrait */ + use RelationshipTrait; + use Serializable; + + /** @var IdentifierInterface|null */ + protected $identifier = null; + + public function __construct(IdentifierInterface $parent, string $name, array $options, IdentifierInterface $identifier = null) + { + $this->parent = $parent; + $this->name = $name; + + $this->parseOptions($options); + $this->replaceIdentifier($identifier); + + $this->modified = false; + } + + /** + * @return string + * @phpstan-pure + */ + public function getCardinality(): string + { + return 'to-one'; + } + + /** + * @return int + * @phpstan-pure + */ + public function count(): int + { + return $this->identifier ? 1 : 0; + } + + /** + * @return object|null + */ + public function fetch(): ?object + { + $identifier = $this->identifier; + if (is_callable([$identifier, 'getObject'])) { + $identifier = $identifier->getObject(); + } + + return $identifier; + } + + + /** + * @param string|null $id + * @param string|null $type + * @return bool + * @phpstan-pure + */ + public function has(string $id = null, string $type = null): bool + { + return $this->getIdentifier($id, $type) !== null; + } + + /** + * @param string|null $id + * @param string|null $type + * @return IdentifierInterface|null + * @phpstan-pure + */ + public function getIdentifier(string $id = null, string $type = null): ?IdentifierInterface + { + if ($id && $this->getType() === 'media' && !str_contains($id, '/')) { + $name = $this->name; + $id = $this->parent->getType() . '/' . $this->parent->getId() . '/'. $name . '/' . $id; + } + + $identifier = $this->identifier ?? null; + if (null === $identifier || ($type && $type !== $identifier->getType()) || ($id && $id !== $identifier->getId())) { + return null; + } + + return $identifier; + } + + /** + * @param string|null $id + * @param string|null $type + * @return T|null + */ + public function getObject(string $id = null, string $type = null): ?object + { + $identifier = $this->getIdentifier($id, $type); + if ($identifier && is_callable([$identifier, 'getObject'])) { + $identifier = $identifier->getObject(); + } + + return $identifier; + } + + /** + * @param IdentifierInterface $identifier + * @return bool + */ + public function addIdentifier(IdentifierInterface $identifier): bool + { + $this->identifier = $this->checkIdentifier($identifier); + $this->modified = true; + + return true; + } + + /** + * @param IdentifierInterface|null $identifier + * @return bool + */ + public function replaceIdentifier(IdentifierInterface $identifier = null): bool + { + if ($identifier === null) { + $this->identifier = null; + $this->modified = true; + + return true; + } + + return $this->addIdentifier($identifier); + } + + /** + * @param IdentifierInterface|null $identifier + * @return bool + */ + public function removeIdentifier(IdentifierInterface $identifier = null): bool + { + if (null === $identifier || $this->has($identifier->getId(), $identifier->getType())) { + $this->identifier = null; + $this->modified = true; + + return true; + } + + return false; + } + + /** + * @return iterable + * @phpstan-pure + */ + public function getIterator(): iterable + { + return new ArrayIterator((array)$this->identifier); + } + + /** + * @return array|null + */ + public function jsonSerialize(): ?array + { + return $this->identifier ? $this->identifier->jsonSerialize() : null; + } + + /** + * @return array + */ + public function __serialize(): array + { + return [ + 'parent' => $this->parent, + 'name' => $this->name, + 'type' => $this->type, + 'options' => $this->options, + 'modified' => $this->modified, + 'identifier' => $this->identifier, + ]; + } + + /** + * @param array $data + * @return void + */ + public function __unserialize(array $data): void + { + $this->parent = $data['parent']; + $this->name = $data['name']; + $this->type = $data['type']; + $this->options = $data['options']; + $this->modified = $data['modified']; + $this->identifier = $data['identifier']; + } +} diff --git a/system/src/Grav/Framework/Relationships/Traits/RelationshipTrait.php b/system/src/Grav/Framework/Relationships/Traits/RelationshipTrait.php new file mode 100644 index 0000000..dbe146f --- /dev/null +++ b/system/src/Grav/Framework/Relationships/Traits/RelationshipTrait.php @@ -0,0 +1,128 @@ +name; + } + + /** + * @return string + * @phpstan-pure + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return bool + * @phpstan-pure + */ + public function isModified(): bool + { + return $this->modified; + } + + /** + * @return IdentifierInterface + * @phpstan-pure + */ + public function getParent(): IdentifierInterface + { + return $this->parent; + } + + /** + * @param IdentifierInterface $identifier + * @return bool + * @phpstan-pure + */ + public function hasIdentifier(IdentifierInterface $identifier): bool + { + return $this->getIdentifier($identifier->getId(), $identifier->getType()) !== null; + } + + /** + * @return int + * @phpstan-pure + */ + abstract public function count(): int; + + /** + * @return void + * @phpstan-pure + */ + public function check(): void + { + $min = $this->options['min'] ?? 0; + $max = $this->options['max'] ?? 0; + + if ($min || $max) { + $count = $this->count(); + if ($min && $count < $min) { + throw new RuntimeException(sprintf('%s relationship has too few objects in it', $this->name)); + } + if ($max && $count > $max) { + throw new RuntimeException(sprintf('%s relationship has too many objects in it', $this->name)); + } + } + } + + /** + * @param IdentifierInterface $identifier + * @return IdentifierInterface + */ + private function checkIdentifier(IdentifierInterface $identifier): IdentifierInterface + { + if ($this->type !== $identifier->getType()) { + throw new RuntimeException(sprintf('Bad identifier type %s', $identifier->getType())); + } + + if (get_class($identifier) !== Identifier::class) { + return $identifier; + } + + if ($this->type === 'media') { + return new MediaIdentifier($identifier->getId()); + } + + return new FlexIdentifier($identifier->getId(), $identifier->getType()); + } + + private function parseOptions(array $options): void + { + $this->type = $options['type']; + $this->options = $options; + } +} diff --git a/system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php b/system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php new file mode 100644 index 0000000..d7ce203 --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php @@ -0,0 +1,49 @@ +invalidMiddleware = $invalidMiddleware; + } + + /** + * Return the invalid middleware + * + * @return mixed|null + */ + public function getInvalidMiddleware() + { + return $this->invalidMiddleware; + } +} diff --git a/system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php b/system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php new file mode 100644 index 0000000..b8662f2 --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php @@ -0,0 +1,37 @@ +getMethod()), ['PUT', 'PATCH', 'DELETE'])) { + parent::__construct($request, 'Method Not Allowed', 405, $previous); + } else { + parent::__construct($request, 'Not Found', 404, $previous); + } + } +} diff --git a/system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php b/system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php new file mode 100644 index 0000000..9c16782 --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php @@ -0,0 +1,20 @@ + 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Time-out', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Request Entity Too Large', + 414 => 'Request-URI Too Large', + 415 => 'Unsupported Media Type', + 416 => 'Requested range not satisfiable', + 417 => 'Expectation Failed', + 418 => 'I\'m a teapot', + 419 => 'Page Expired', + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Unordered Collection', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 451 => 'Unavailable For Legal Reasons', + + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Time-out', + 505 => 'HTTP Version not supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 511 => 'Network Authentication Required', + ]; + + /** @var ServerRequestInterface */ + private $request; + + /** + * @param ServerRequestInterface $request + * @param string $message + * @param int $code + * @param Throwable|null $previous + */ + public function __construct(ServerRequestInterface $request, string $message, int $code = 500, Throwable $previous = null) + { + $this->request = $request; + + parent::__construct($message, $code, $previous); + } + + /** + * @return ServerRequestInterface + */ + public function getRequest(): ServerRequestInterface + { + return $this->request; + } + + public function getHttpCode(): int + { + $code = $this->getCode(); + + return isset(self::$phrases[$code]) ? $code : 500; + } + + public function getHttpReason(): ?string + { + return self::$phrases[$this->getCode()] ?? self::$phrases[500]; + } +} diff --git a/system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php b/system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php new file mode 100644 index 0000000..f07abbe --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php @@ -0,0 +1,78 @@ +handle($request); + } catch (Throwable $exception) { + $code = $exception->getCode(); + if ($exception instanceof ValidationException) { + $message = $exception->getMessage(); + } else { + $message = htmlspecialchars($exception->getMessage(), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + $extra = $exception instanceof JsonSerializable ? $exception->jsonSerialize() : []; + + $response = [ + 'code' => $code, + 'status' => 'error', + 'message' => $message, + 'error' => [ + 'code' => $code, + 'message' => $message, + ] + $extra + ]; + + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + if ($debugger->enabled()) { + $response['error'] += [ + 'type' => get_class($exception), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => explode("\n", $exception->getTraceAsString()), + ]; + } + + /** @var string $json */ + $json = json_encode($response, JSON_THROW_ON_ERROR); + + return new Response($code ?: 500, ['Content-Type' => 'application/json'], $json); + } + } +} diff --git a/system/src/Grav/Framework/RequestHandler/Middlewares/MultipartRequestSupport.php b/system/src/Grav/Framework/RequestHandler/Middlewares/MultipartRequestSupport.php new file mode 100644 index 0000000..35231dd --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Middlewares/MultipartRequestSupport.php @@ -0,0 +1,123 @@ +getHeaderLine('content-type'); + $method = $request->getMethod(); + if (!str_starts_with($contentType, 'multipart/form-data') || !in_array($method, ['PUT', 'PATH'], true)) { + return $handler->handle($request); + } + + $boundary = explode('; boundary=', $contentType, 2)[1] ?? ''; + $parts = explode("--{$boundary}", $request->getBody()->getContents()); + $parts = array_slice($parts, 1, count($parts) - 2); + + $params = []; + $files = []; + foreach ($parts as $part) { + $this->processPart($params, $files, $part); + } + + return $handler->handle($request->withParsedBody($params)->withUploadedFiles($files)); + } + + /** + * @param array $params + * @param array $files + * @param string $part + * @return void + */ + protected function processPart(array &$params, array &$files, string $part): void + { + $part = ltrim($part, "\r\n"); + [$rawHeaders, $body] = explode("\r\n\r\n", $part, 2); + + // Parse headers. + $rawHeaders = explode("\r\n", $rawHeaders); + $headers = array_reduce( + $rawHeaders, + static function (array $headers, $header) { + [$name, $value] = explode(':', $header); + $headers[strtolower($name)] = ltrim($value, ' '); + + return $headers; + }, + [] + ); + + if (!isset($headers['content-disposition'])) { + return; + } + + // Parse content disposition header. + $contentDisposition = $headers['content-disposition']; + preg_match('/^(.+); *name="([^"]+)"(; *filename="([^"]+)")?/', $contentDisposition, $matches); + $name = $matches[2]; + $filename = $matches[4] ?? null; + + if ($filename !== null) { + $stream = Stream::create($body); + $this->addFile($files, $name, new UploadedFile($stream, strlen($body), UPLOAD_ERR_OK, $filename, $headers['content-type'] ?? null)); + } elseif (strpos($contentDisposition, 'filename') !== false) { + // Not uploaded file. + $stream = Stream::create(''); + $this->addFile($files, $name, new UploadedFile($stream, 0, UPLOAD_ERR_NO_FILE)); + } else { + // Regular field. + $params[$name] = substr($body, 0, -2); + } + } + + /** + * @param array $files + * @param string $name + * @param UploadedFileInterface $file + * @return void + */ + protected function addFile(array &$files, string $name, UploadedFileInterface $file): void + { + if (strpos($name, '[]') === strlen($name) - 2) { + $name = substr($name, 0, -2); + + if (isset($files[$name]) && is_array($files[$name])) { + $files[$name][] = $file; + } else { + $files[$name] = [$file]; + } + } else { + $files[$name] = $file; + } + } +} diff --git a/system/src/Grav/Framework/RequestHandler/RequestHandler.php b/system/src/Grav/Framework/RequestHandler/RequestHandler.php new file mode 100644 index 0000000..e82d3d6 --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/RequestHandler.php @@ -0,0 +1,80 @@ +middleware = $middleware; + $this->handler = $default; + $this->container = $container; + } + + /** + * Add callable initializing Middleware that will be executed as soon as possible. + * + * @param string $name + * @param callable $callable + * @return $this + */ + public function addCallable(string $name, callable $callable): self + { + if (null !== $this->container) { + assert($this->container instanceof Container); + $this->container[$name] = $callable; + } + + array_unshift($this->middleware, $name); + + return $this; + } + + /** + * Add Middleware that will be executed as soon as possible. + * + * @param string $name + * @param MiddlewareInterface $middleware + * @return $this + */ + public function addMiddleware(string $name, MiddlewareInterface $middleware): self + { + if (null !== $this->container) { + assert($this->container instanceof Container); + $this->container[$name] = $middleware; + } + + array_unshift($this->middleware, $name); + + return $this; + } +} diff --git a/system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php b/system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php new file mode 100644 index 0000000..6efefe8 --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php @@ -0,0 +1,64 @@ + */ + protected $middleware; + + /** @var callable */ + protected $handler; + + /** @var ContainerInterface|null */ + protected $container; + + /** + * {@inheritdoc} + * @throws InvalidArgumentException + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $middleware = array_shift($this->middleware); + + // Use default callable if there is no middleware. + if ($middleware === null) { + return call_user_func($this->handler, $request); + } + + if ($middleware instanceof MiddlewareInterface) { + return $middleware->process($request, clone $this); + } + + if (null === $this->container || !$this->container->has($middleware)) { + throw new InvalidArgumentException( + sprintf('The middleware is not a valid %s and is not passed in the Container', MiddlewareInterface::class), + $middleware + ); + } + + array_unshift($this->middleware, $this->container->get($middleware)); + + return $this->handle($request); + } +} diff --git a/system/src/Grav/Framework/Route/Route.php b/system/src/Grav/Framework/Route/Route.php new file mode 100644 index 0000000..4129693 --- /dev/null +++ b/system/src/Grav/Framework/Route/Route.php @@ -0,0 +1,452 @@ +initParts($parts); + } + + /** + * @return array + */ + public function getParts() + { + return [ + 'path' => $this->getUriPath(true), + 'query' => $this->getUriQuery(), + 'grav' => [ + 'root' => $this->root, + 'language' => $this->language, + 'route' => $this->route, + 'extension' => $this->extension, + 'grav_params' => $this->gravParams, + 'query_params' => $this->queryParams, + ], + ]; + } + + /** + * @return string + */ + public function getRootPrefix() + { + return $this->root; + } + + /** + * @return string + */ + public function getLanguage() + { + return $this->language; + } + + /** + * @return string + */ + public function getLanguagePrefix() + { + return $this->language !== '' ? '/' . $this->language : ''; + } + + /** + * @param string|null $language + * @return string + */ + public function getBase(string $language = null): string + { + $parts = [$this->root]; + + if (null === $language) { + $language = $this->language; + } + + if ($language !== '') { + $parts[] = $language; + } + + return implode('/', $parts); + } + + /** + * @param int $offset + * @param int|null $length + * @return string + */ + public function getRoute($offset = 0, $length = null) + { + if ($offset !== 0 || $length !== null) { + return ($offset === 0 ? '/' : '') . implode('/', $this->getRouteParts($offset, $length)); + } + + return '/' . $this->route; + } + + /** + * @return string + */ + public function getExtension() + { + return $this->extension; + } + + /** + * @param int $offset + * @param int|null $length + * @return array + */ + public function getRouteParts($offset = 0, $length = null) + { + $parts = explode('/', $this->route); + + if ($offset !== 0 || $length !== null) { + $parts = array_slice($parts, $offset, $length); + } + + return $parts; + } + + /** + * Return array of both query and Grav parameters. + * + * If a parameter exists in both, prefer Grav parameter. + * + * @return array + */ + public function getParams() + { + return $this->gravParams + $this->queryParams; + } + + /** + * @return array + */ + public function getGravParams() + { + return $this->gravParams; + } + + /** + * @return array + */ + public function getQueryParams() + { + return $this->queryParams; + } + + /** + * Return value of the parameter, looking into both Grav parameters and query parameters. + * + * If the parameter exists in both, return Grav parameter. + * + * @param string $param + * @return string|array|null + */ + public function getParam($param) + { + return $this->getGravParam($param) ?? $this->getQueryParam($param); + } + + /** + * @param string $param + * @return string|null + */ + public function getGravParam($param) + { + return $this->gravParams[$param] ?? null; + } + + /** + * @param string $param + * @return string|array|null + */ + public function getQueryParam($param) + { + return $this->queryParams[$param] ?? null; + } + + /** + * Allow the ability to set the route to something else + * + * @param string $route + * @return Route + */ + public function withRoute($route) + { + $new = $this->copy(); + $new->route = $route; + + return $new; + } + + /** + * Allow the ability to set the root to something else + * + * @param string $root + * @return Route + */ + public function withRoot($root) + { + $new = $this->copy(); + $new->root = $root; + + return $new; + } + + /** + * @param string|null $language + * @return Route + */ + public function withLanguage($language) + { + $new = $this->copy(); + $new->language = $language ?? ''; + + return $new; + } + + /** + * @param string $path + * @return Route + */ + public function withAddedPath($path) + { + $new = $this->copy(); + $new->route .= '/' . ltrim($path, '/'); + + return $new; + } + + /** + * @param string $extension + * @return Route + */ + public function withExtension($extension) + { + $new = $this->copy(); + $new->extension = $extension; + + return $new; + } + + /** + * @param string $param + * @param mixed $value + * @return Route + */ + public function withGravParam($param, $value) + { + return $this->withParam('gravParams', $param, null !== $value ? (string)$value : null); + } + + /** + * @param string $param + * @param mixed $value + * @return Route + */ + public function withQueryParam($param, $value) + { + return $this->withParam('queryParams', $param, $value); + } + + /** + * @return Route + */ + public function withoutParams() + { + return $this->withoutGravParams()->withoutQueryParams(); + } + + /** + * @return Route + */ + public function withoutGravParams() + { + $new = $this->copy(); + $new->gravParams = []; + + return $new; + } + + /** + * @return Route + */ + public function withoutQueryParams() + { + $new = $this->copy(); + $new->queryParams = []; + + return $new; + } + + /** + * @return Uri + */ + public function getUri() + { + return UriFactory::createFromParts($this->getParts()); + } + + /** + * @param bool $includeRoot + * @return string + */ + public function toString(bool $includeRoot = false) + { + $url = $this->getUriPath($includeRoot); + + if ($this->queryParams) { + $url .= '?' . $this->getUriQuery(); + } + + return rtrim($url,'/'); + } + + /** + * @return string + * @deprecated 1.6 Use ->toString(true) or ->getUri() instead. + */ + #[\ReturnTypeWillChange] + public function __toString() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() will change in the future to return route, not relative url: use ->toString(true) or ->getUri() instead.', E_USER_DEPRECATED); + + return $this->toString(true); + } + + /** + * @param string $type + * @param string $param + * @param mixed $value + * @return Route + */ + protected function withParam($type, $param, $value) + { + $values = $this->{$type} ?? []; + $oldValue = $values[$param] ?? null; + + if ($oldValue === $value) { + return $this; + } + + $new = $this->copy(); + if ($value === null) { + unset($values[$param]); + } else { + $values[$param] = $value; + } + + $new->{$type} = $values; + + return $new; + } + + /** + * @return Route + */ + protected function copy() + { + return clone $this; + } + + /** + * @param bool $includeRoot + * @return string + */ + protected function getUriPath($includeRoot = false) + { + $parts = $includeRoot ? [$this->root] : ['']; + + if ($this->language !== '') { + $parts[] = $this->language; + } + + $parts[] = $this->extension ? $this->route . '.' . $this->extension : $this->route; + + + if ($this->gravParams) { + $parts[] = RouteFactory::buildParams($this->gravParams); + } + + return implode('/', $parts); + } + + /** + * @return string + */ + protected function getUriQuery() + { + return UriFactory::buildQuery($this->queryParams); + } + + /** + * @param array $parts + * @return void + */ + protected function initParts(array $parts) + { + if (isset($parts['grav'])) { + $gravParts = $parts['grav']; + $this->root = $gravParts['root']; + $this->language = $gravParts['language']; + $this->route = $gravParts['route']; + $this->extension = $gravParts['extension'] ?? ''; + $this->gravParams = $gravParts['params'] ?? []; + $this->queryParams = $parts['query_params'] ?? []; + } else { + $this->root = RouteFactory::getRoot(); + $this->language = RouteFactory::getLanguage(); + + $path = $parts['path'] ?? '/'; + if (isset($parts['params'])) { + $this->route = trim(rawurldecode($path), '/'); + $this->gravParams = $parts['params']; + } else { + $this->route = trim(RouteFactory::stripParams($path, true), '/'); + $this->gravParams = RouteFactory::getParams($path); + } + if (isset($parts['query'])) { + $this->queryParams = UriFactory::parseQuery($parts['query']); + } + } + } +} diff --git a/system/src/Grav/Framework/Route/RouteFactory.php b/system/src/Grav/Framework/Route/RouteFactory.php new file mode 100644 index 0000000..8e654a1 --- /dev/null +++ b/system/src/Grav/Framework/Route/RouteFactory.php @@ -0,0 +1,236 @@ +toArray(); + $parts += [ + 'grav' => [] + ]; + $path = $parts['path'] ?? ''; + $parts['grav'] += [ + 'root' => self::$root, + 'language' => self::$language, + 'route' => trim($path, '/'), + 'params' => $parts['params'] ?? [], + ]; + + return static::createFromParts($parts); + } + + /** + * @param string $path + * @return Route + */ + public static function createFromString(string $path): Route + { + $path = ltrim($path, '/'); + if (self::$language && mb_strpos($path, self::$language) === 0) { + $path = ltrim(mb_substr($path, mb_strlen(self::$language)), '/'); + } + + $parts = [ + 'path' => $path, + 'query' => '', + 'query_params' => [], + 'grav' => [ + 'root' => self::$root, + 'language' => self::$language, + 'route' => static::trimParams($path), + 'params' => static::getParams($path) + ], + ]; + + return new Route($parts); + } + + /** + * @return string + */ + public static function getRoot(): string + { + return self::$root; + } + + /** + * @param string $root + */ + public static function setRoot($root): void + { + self::$root = rtrim($root, '/'); + } + + /** + * @return string + */ + public static function getLanguage(): string + { + return self::$language; + } + + /** + * @param string $language + */ + public static function setLanguage(string $language): void + { + self::$language = trim($language, '/'); + } + + /** + * @return string + */ + public static function getParamValueDelimiter(): string + { + return self::$delimiter; + } + + /** + * @param string $delimiter + */ + public static function setParamValueDelimiter(string $delimiter): void + { + self::$delimiter = $delimiter ?: ':'; + } + + /** + * @param array $params + * @return string + */ + public static function buildParams(array $params): string + { + if (!$params) { + return ''; + } + + $delimiter = self::$delimiter; + + $output = []; + foreach ($params as $key => $value) { + $output[] = "{$key}{$delimiter}{$value}"; + } + + return implode('/', $output); + } + + /** + * @param string $path + * @param bool $decode + * @return string + */ + public static function stripParams(string $path, bool $decode = false): string + { + $pos = strpos($path, self::$delimiter); + + if ($pos === false) { + return $path; + } + + $path = dirname(substr($path, 0, $pos)); + if ($path === '.') { + return ''; + } + + return $decode ? rawurldecode($path) : $path; + } + + /** + * @param string $path + * @return array + */ + public static function getParams(string $path): array + { + $params = ltrim(substr($path, strlen(static::stripParams($path))), '/'); + + return $params !== '' ? static::parseParams($params) : []; + } + + /** + * @param string $str + * @return string + */ + public static function trimParams(string $str): string + { + if ($str === '') { + return $str; + } + + $delimiter = self::$delimiter; + + /** @var array $params */ + $params = explode('/', $str); + $list = []; + foreach ($params as $param) { + if (mb_strpos($param, $delimiter) === false) { + $list[] = $param; + } + } + + return implode('/', $list); + } + + /** + * @param string $str + * @return array + */ + public static function parseParams(string $str): array + { + if ($str === '') { + return []; + } + + $delimiter = self::$delimiter; + + /** @var array $params */ + $params = explode('/', $str); + $list = []; + foreach ($params as &$param) { + /** @var array $parts */ + $parts = explode($delimiter, $param, 2); + if (isset($parts[1])) { + $var = rawurldecode($parts[0]); + $val = rawurldecode($parts[1]); + $list[$var] = $val; + } + } + + return $list; + } +} diff --git a/system/src/Grav/Framework/Session/Exceptions/SessionException.php b/system/src/Grav/Framework/Session/Exceptions/SessionException.php new file mode 100644 index 0000000..f27fe8a --- /dev/null +++ b/system/src/Grav/Framework/Session/Exceptions/SessionException.php @@ -0,0 +1,20 @@ + $message, 'scope' => $scope]; + + // don't add duplicates + if (!array_key_exists($key, $this->messages)) { + $this->messages[$key] = $item; + } + + return $this; + } + + /** + * Clear message queue. + * + * @param string|null $scope + * @return $this + */ + public function clear(string $scope = null): Messages + { + if ($scope === null) { + if ($this->messages !== []) { + $this->isCleared = true; + $this->messages = []; + } + } else { + foreach ($this->messages as $key => $message) { + if ($message['scope'] === $scope) { + $this->isCleared = true; + unset($this->messages[$key]); + } + } + } + + return $this; + } + + /** + * @return bool + */ + public function isCleared(): bool + { + return $this->isCleared; + } + + /** + * Fetch all messages. + * + * @param string|null $scope + * @return array + */ + public function all(string $scope = null): array + { + if ($scope === null) { + return array_values($this->messages); + } + + $messages = []; + foreach ($this->messages as $message) { + if ($message['scope'] === $scope) { + $messages[] = $message; + } + } + + return $messages; + } + + /** + * Fetch and clear message queue. + * + * @param string|null $scope + * @return array + */ + public function fetch(string $scope = null): array + { + $messages = $this->all($scope); + $this->clear($scope); + + return $messages; + } + + /** + * @return array + */ + public function __serialize(): array + { + return [ + 'messages' => $this->messages + ]; + } + + /** + * @param array $data + * @return void + */ + public function __unserialize(array $data): void + { + $this->messages = $data['messages']; + } +} diff --git a/system/src/Grav/Framework/Session/Session.php b/system/src/Grav/Framework/Session/Session.php new file mode 100644 index 0000000..41732c8 --- /dev/null +++ b/system/src/Grav/Framework/Session/Session.php @@ -0,0 +1,562 @@ +isSessionStarted()) { + session_unset(); + session_destroy(); + } + + // Set default options. + $options += [ + 'cache_limiter' => 'nocache', + 'use_trans_sid' => 0, + 'use_cookies' => 1, + 'lazy_write' => 1, + 'use_strict_mode' => 1 + ]; + + $this->setOptions($options); + + session_register_shutdown(); + + self::$instance = $this; + } + + /** + * @inheritdoc + */ + public function getId() + { + return session_id() ?: null; + } + + /** + * @inheritdoc + */ + public function setId($id) + { + session_id($id); + + return $this; + } + + /** + * @inheritdoc + */ + public function getName() + { + return session_name() ?: null; + } + + /** + * @inheritdoc + */ + public function setName($name) + { + session_name($name); + + return $this; + } + + /** + * @inheritdoc + */ + public function setOptions(array $options) + { + if (headers_sent() || \PHP_SESSION_ACTIVE === session_status()) { + return; + } + + $allowedOptions = [ + 'save_path' => true, + 'name' => true, + 'save_handler' => true, + 'gc_probability' => true, + 'gc_divisor' => true, + 'gc_maxlifetime' => true, + 'serialize_handler' => true, + 'cookie_lifetime' => true, + 'cookie_path' => true, + 'cookie_domain' => true, + 'cookie_secure' => true, + 'cookie_httponly' => true, + 'use_strict_mode' => true, + 'use_cookies' => true, + 'use_only_cookies' => true, + 'cookie_samesite' => true, + 'referer_check' => true, + 'cache_limiter' => true, + 'cache_expire' => true, + 'use_trans_sid' => true, + 'trans_sid_tags' => true, + 'trans_sid_hosts' => true, + 'sid_length' => true, + 'sid_bits_per_character' => true, + 'upload_progress.enabled' => true, + 'upload_progress.cleanup' => true, + 'upload_progress.prefix' => true, + 'upload_progress.name' => true, + 'upload_progress.freq' => true, + 'upload_progress.min-freq' => true, + 'lazy_write' => true + ]; + + foreach ($options as $key => $value) { + if (is_array($value)) { + // Allow nested options. + foreach ($value as $key2 => $value2) { + $ckey = "{$key}.{$key2}"; + if (isset($value2, $allowedOptions[$ckey])) { + $this->setOption($ckey, $value2); + } + } + } elseif (isset($value, $allowedOptions[$key])) { + $this->setOption($key, $value); + } + } + } + + /** + * @inheritdoc + */ + public function start($readonly = false) + { + if (\PHP_SAPI === 'cli') { + return $this; + } + + $sessionName = $this->getName(); + if (null === $sessionName) { + return $this; + } + + $sessionExists = isset($_COOKIE[$sessionName]); + + // Protection against invalid session cookie names throwing exception: http://php.net/manual/en/function.session-id.php#116836 + if ($sessionExists && !preg_match('/^[-,a-zA-Z0-9]{1,128}$/', $_COOKIE[$sessionName])) { + unset($_COOKIE[$sessionName]); + $sessionExists = false; + } + + $options = $this->options; + if ($readonly) { + $options['read_and_close'] = '1'; + } + + try { + $success = @session_start($options); + if (!$success) { + $last = error_get_last(); + $error = $last ? $last['message'] : 'Unknown error'; + + throw new RuntimeException($error); + } + + // Handle changing session id. + if ($this->__isset('session_destroyed')) { + $newId = $this->__get('session_new_id'); + if (!$newId || $this->__get('session_destroyed') < time() - 300) { + // Should not happen usually. This could be attack or due to unstable network. Destroy this session. + $this->invalidate(); + + throw new RuntimeException('Obsolete session access.', 500); + } + + // Not fully expired yet. Could be lost cookie by unstable network. Start session with new session id. + session_write_close(); + + // Start session with new session id. + $useStrictMode = $options['use_strict_mode'] ?? 0; + if ($useStrictMode) { + ini_set('session.use_strict_mode', '0'); + } + session_id($newId); + if ($useStrictMode) { + ini_set('session.use_strict_mode', '1'); + } + + $success = @session_start($options); + if (!$success) { + $last = error_get_last(); + $error = $last ? $last['message'] : 'Unknown error'; + + throw new RuntimeException($error); + } + } + } catch (Exception $e) { + throw new SessionException('Failed to start session: ' . $e->getMessage(), 500); + } + + $this->started = true; + $this->onSessionStart(); + + try { + $user = $this->__get('user'); + if ($user && (!$user instanceof UserInterface || (method_exists($user, 'isValid') && !$user->isValid()))) { + throw new RuntimeException('Bad user'); + } + } catch (Throwable $e) { + $this->invalidate(); + throw new SessionException('Invalid User object, session destroyed.', 500); + } + + + // Extend the lifetime of the session. + if ($sessionExists) { + $this->setCookie(); + } + + return $this; + } + + /** + * Regenerate session id but keep the current session information. + * + * Session id must be regenerated on login, logout or after long time has been passed. + * + * @return $this + * @since 1.7 + */ + public function regenerateId() + { + if (!$this->isSessionStarted()) { + return $this; + } + + // TODO: session_create_id() segfaults in PHP 7.3 (PHP bug #73461), remove phpstan rule when removing this one. + if (PHP_VERSION_ID < 70400) { + $newId = 0; + } else { + // Session id creation may fail with some session storages. + $newId = @session_create_id() ?: 0; + } + + // Set destroyed timestamp for the old session as well as pointer to the new id. + $this->__set('session_destroyed', time()); + $this->__set('session_new_id', $newId); + + // Keep the old session alive to avoid lost sessions by unstable network. + if (!$newId) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addMessage('Session fixation lost session detection is turned of due to server limitations.', 'warning'); + + session_regenerate_id(false); + } else { + session_write_close(); + + // Start session with new session id. + $useStrictMode = $this->options['use_strict_mode'] ?? 0; + if ($useStrictMode) { + ini_set('session.use_strict_mode', '0'); + } + session_id($newId); + if ($useStrictMode) { + ini_set('session.use_strict_mode', '1'); + } + + $this->removeCookie(); + + $this->onBeforeSessionStart(); + + $success = @session_start($this->options); + if (!$success) { + $last = error_get_last(); + $error = $last ? $last['message'] : 'Unknown error'; + + throw new RuntimeException($error); + } + + $this->onSessionStart(); + } + + // New session does not have these. + $this->__unset('session_destroyed'); + $this->__unset('session_new_id'); + + return $this; + } + + /** + * @inheritdoc + */ + public function invalidate() + { + $name = $this->getName(); + if (null !== $name) { + $this->removeCookie(); + + setcookie( + $name, + '', + $this->getCookieOptions(-42000) + ); + } + + if ($this->isSessionStarted()) { + session_unset(); + session_destroy(); + } + + $this->started = false; + + return $this; + } + + /** + * @inheritdoc + */ + public function close() + { + if ($this->started) { + session_write_close(); + } + + $this->started = false; + + return $this; + } + + /** + * @inheritdoc + */ + public function clear() + { + session_unset(); + + return $this; + } + + /** + * @inheritdoc + */ + public function getAll() + { + return $_SESSION; + } + + /** + * @inheritdoc + */ + #[\ReturnTypeWillChange] + public function getIterator() + { + return new ArrayIterator($_SESSION); + } + + /** + * @inheritdoc + */ + public function isStarted() + { + return $this->started; + } + + /** + * @inheritdoc + */ + #[\ReturnTypeWillChange] + public function __isset($name) + { + return isset($_SESSION[$name]); + } + + /** + * @inheritdoc + */ + #[\ReturnTypeWillChange] + public function __get($name) + { + return $_SESSION[$name] ?? null; + } + + /** + * @inheritdoc + */ + #[\ReturnTypeWillChange] + public function __set($name, $value) + { + $_SESSION[$name] = $value; + } + + /** + * @inheritdoc + */ + #[\ReturnTypeWillChange] + public function __unset($name) + { + unset($_SESSION[$name]); + } + + /** + * http://php.net/manual/en/function.session-status.php#113468 + * Check if session is started nicely. + * @return bool + */ + protected function isSessionStarted() + { + return \PHP_SAPI !== 'cli' ? \PHP_SESSION_ACTIVE === session_status() : false; + } + + protected function onBeforeSessionStart(): void + { + } + + protected function onSessionStart(): void + { + } + + /** + * Store something in cookie temporarily. + * + * @param int|null $lifetime + * @return array + */ + public function getCookieOptions(int $lifetime = null): array + { + $params = session_get_cookie_params(); + + return [ + 'expires' => time() + ($lifetime ?? $params['lifetime']), + 'path' => $params['path'], + 'domain' => $params['domain'], + 'secure' => $params['secure'], + 'httponly' => $params['httponly'], + 'samesite' => $params['samesite'] + ]; + } + + /** + * @return void + */ + protected function setCookie(): void + { + $this->removeCookie(); + + $sessionName = $this->getName(); + $sessionId = $this->getId(); + if (null === $sessionName || null === $sessionId) { + return; + } + + setcookie( + $sessionName, + $sessionId, + $this->getCookieOptions() + ); + } + + protected function removeCookie(): void + { + $search = " {$this->getName()}="; + $cookies = []; + $found = false; + + foreach (headers_list() as $header) { + // Identify cookie headers + if (strpos($header, 'Set-Cookie:') === 0) { + // Add all but session cookie(s). + if (!str_contains($header, $search)) { + $cookies[] = $header; + } else { + $found = true; + } + } + } + + // Nothing to do. + if (false === $found) { + return; + } + + // Remove all cookies and put back all but session cookie. + header_remove('Set-Cookie'); + foreach($cookies as $cookie) { + header($cookie, false); + } + } + + /** + * @param string $key + * @param mixed $value + * @return void + */ + protected function setOption($key, $value) + { + if (!is_string($value)) { + if (is_bool($value)) { + $value = $value ? '1' : '0'; + } else { + $value = (string)$value; + } + } + + $this->options[$key] = $value; + ini_set("session.{$key}", $value); + } +} diff --git a/system/src/Grav/Framework/Session/SessionInterface.php b/system/src/Grav/Framework/Session/SessionInterface.php new file mode 100644 index 0000000..d2f8308 --- /dev/null +++ b/system/src/Grav/Framework/Session/SessionInterface.php @@ -0,0 +1,159 @@ + + */ +interface SessionInterface extends IteratorAggregate +{ + /** + * Get current session instance. + * + * @return Session + * @throws RuntimeException + */ + public static function getInstance(); + + /** + * Get session ID + * + * @return string|null Session ID + */ + public function getId(); + + /** + * Set session ID + * + * @param string $id Session ID + * @return $this + */ + public function setId($id); + + /** + * Get session name + * + * @return string|null + */ + public function getName(); + + /** + * Set session name + * + * @param string $name + * @return $this + */ + public function setName($name); + + /** + * Sets session.* ini variables. + * + * @param array $options + * @return void + * @see http://php.net/session.configuration + */ + public function setOptions(array $options); + + /** + * Starts the session storage + * + * @param bool $readonly + * @return $this + * @throws RuntimeException + */ + public function start($readonly = false); + + /** + * Invalidates the current session. + * + * @return $this + */ + public function invalidate(); + + /** + * Force the session to be saved and closed + * + * @return $this + */ + public function close(); + + /** + * Free all session variables. + * + * @return $this + */ + public function clear(); + + /** + * Returns all session variables. + * + * @return array + */ + public function getAll(); + + /** + * Retrieve an external iterator + * + * @return ArrayIterator Return an ArrayIterator of $_SESSION + * @phpstan-return ArrayIterator + */ + #[\ReturnTypeWillChange] + public function getIterator(); + + /** + * Checks if the session was started. + * + * @return bool + */ + public function isStarted(); + + /** + * Checks if session variable is defined. + * + * @param string $name + * @return bool + */ + #[\ReturnTypeWillChange] + public function __isset($name); + + /** + * Returns session variable. + * + * @param string $name + * @return mixed + */ + #[\ReturnTypeWillChange] + public function __get($name); + + /** + * Sets session variable. + * + * @param string $name + * @param mixed $value + * @return void + */ + #[\ReturnTypeWillChange] + public function __set($name, $value); + + /** + * Removes session variable. + * + * @param string $name + * @return void + */ + #[\ReturnTypeWillChange] + public function __unset($name); +} diff --git a/system/src/Grav/Framework/Uri/Uri.php b/system/src/Grav/Framework/Uri/Uri.php new file mode 100644 index 0000000..87ca329 --- /dev/null +++ b/system/src/Grav/Framework/Uri/Uri.php @@ -0,0 +1,216 @@ +initParts($parts); + } + + /** + * @return string + */ + public function getUser() + { + return parent::getUser(); + } + + /** + * @return string + */ + public function getPassword() + { + return parent::getPassword(); + } + + /** + * @return array + */ + public function getParts() + { + return parent::getParts(); + } + + /** + * @return string + */ + public function getUrl() + { + return parent::getUrl(); + } + + /** + * @return string + */ + public function getBaseUrl() + { + return parent::getBaseUrl(); + } + + /** + * @param string $key + * @return string|null + */ + public function getQueryParam($key) + { + $queryParams = $this->getQueryParams(); + + return $queryParams[$key] ?? null; + } + + /** + * @param string $key + * @return UriInterface + */ + public function withoutQueryParam($key) + { + return GuzzleUri::withoutQueryValue($this, $key); + } + + /** + * @param string $key + * @param string|null $value + * @return UriInterface + */ + public function withQueryParam($key, $value) + { + return GuzzleUri::withQueryValue($this, $key, $value); + } + + /** + * @return array + */ + public function getQueryParams() + { + if ($this->queryParams === null) { + $this->queryParams = UriFactory::parseQuery($this->getQuery()); + } + + return $this->queryParams; + } + + /** + * @param array $params + * @return UriInterface + */ + public function withQueryParams(array $params) + { + $query = UriFactory::buildQuery($params); + + return $this->withQuery($query); + } + + /** + * Whether the URI has the default port of the current scheme. + * + * `$uri->getPort()` may return the standard port. This method can be used for some non-http/https Uri. + * + * @return bool + */ + public function isDefaultPort() + { + return $this->getPort() === null || GuzzleUri::isDefaultPort($this); + } + + /** + * Whether the URI is absolute, i.e. it has a scheme. + * + * An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true + * if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative + * to another URI, the base URI. Relative references can be divided into several forms: + * - network-path references, e.g. '//example.com/path' + * - absolute-path references, e.g. '/path' + * - relative-path references, e.g. 'subpath' + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4 + */ + public function isAbsolute() + { + return GuzzleUri::isAbsolute($this); + } + + /** + * Whether the URI is a network-path reference. + * + * A relative reference that begins with two slash characters is termed an network-path reference. + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public function isNetworkPathReference() + { + return GuzzleUri::isNetworkPathReference($this); + } + + /** + * Whether the URI is a absolute-path reference. + * + * A relative reference that begins with a single slash character is termed an absolute-path reference. + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public function isAbsolutePathReference() + { + return GuzzleUri::isAbsolutePathReference($this); + } + + /** + * Whether the URI is a relative-path reference. + * + * A relative reference that does not begin with a slash character is termed a relative-path reference. + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public function isRelativePathReference() + { + return GuzzleUri::isRelativePathReference($this); + } + + /** + * Whether the URI is a same-document reference. + * + * A same-document reference refers to a URI that is, aside from its fragment + * component, identical to the base URI. When no base URI is given, only an empty + * URI reference (apart from its fragment) is considered a same-document reference. + * + * @param UriInterface|null $base An optional base URI to compare against + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.4 + */ + public function isSameDocumentReference(UriInterface $base = null) + { + return GuzzleUri::isSameDocumentReference($this, $base); + } +} diff --git a/system/src/Grav/Framework/Uri/UriFactory.php b/system/src/Grav/Framework/Uri/UriFactory.php new file mode 100644 index 0000000..f112acd --- /dev/null +++ b/system/src/Grav/Framework/Uri/UriFactory.php @@ -0,0 +1,171 @@ + $scheme, + 'user' => $user, + 'pass' => $pass, + 'host' => $host, + 'port' => $port, + 'path' => $path, + 'query' => $query + ]; + } + + /** + * UTF-8 aware parse_url() implementation. + * + * @param string $url + * @return array + * @throws InvalidArgumentException + */ + public static function parseUrl($url) + { + if (!is_string($url)) { + throw new InvalidArgumentException('URL must be a string'); + } + + $encodedUrl = preg_replace_callback( + '%[^:/@?&=#]+%u', + static function ($matches) { + return rawurlencode($matches[0]); + }, + $url + ); + + $parts = is_string($encodedUrl) ? parse_url($encodedUrl) : false; + if ($parts === false) { + throw new InvalidArgumentException("Malformed URL: {$url}"); + } + + return $parts; + } + + /** + * Parse query string and return it as an array. + * + * @param string $query + * @return mixed + */ + public static function parseQuery($query) + { + parse_str($query, $params); + + return $params; + } + + /** + * Build query string from variables. + * + * @param array $params + * @return string + */ + public static function buildQuery(array $params) + { + if (!$params) { + return ''; + } + + $separator = ini_get('arg_separator.output') ?: '&'; + + return http_build_query($params, '', $separator, PHP_QUERY_RFC3986); + } +} diff --git a/system/src/Grav/Framework/Uri/UriPartsFilter.php b/system/src/Grav/Framework/Uri/UriPartsFilter.php new file mode 100644 index 0000000..eda7084 --- /dev/null +++ b/system/src/Grav/Framework/Uri/UriPartsFilter.php @@ -0,0 +1,145 @@ += 0 && $port <= 65535))) { + return $port; + } + + throw new InvalidArgumentException('Uri port must be null or an integer between 0 and 65535'); + } + + /** + * Filter Uri path. + * + * This method percent-encodes all reserved characters in the provided path string. This method + * will NOT double-encode characters that are already percent-encoded. + * + * @param string $path The raw uri path. + * @return string The RFC 3986 percent-encoded uri path. + * @throws InvalidArgumentException If the path is invalid. + * @link http://www.faqs.org/rfcs/rfc3986.html + */ + public static function filterPath($path) + { + if (!is_string($path)) { + throw new InvalidArgumentException('Uri path must be a string'); + } + + return preg_replace_callback( + '/(?:[^a-zA-Z0-9_\-\.~:@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/u', + function ($match) { + return rawurlencode($match[0]); + }, + $path + ) ?? ''; + } + + /** + * Filters the query string or fragment of a URI. + * + * @param string $query The raw uri query string. + * @return string The percent-encoded query string. + * @throws InvalidArgumentException If the query is invalid. + */ + public static function filterQueryOrFragment($query) + { + if (!is_string($query)) { + throw new InvalidArgumentException('Uri query string and fragment must be a string'); + } + + return preg_replace_callback( + '/(?:[^a-zA-Z0-9_\-\.~!\$&\'\(\)\*\+,;=%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/u', + function ($match) { + return rawurlencode($match[0]); + }, + $query + ) ?? ''; + } +} diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php new file mode 100644 index 0000000..0db7036 --- /dev/null +++ b/system/src/Grav/Installer/Install.php @@ -0,0 +1,400 @@ + [ + 'name' => 'PHP', + 'versions' => [ + '8.1' => '8.1.0', + '8.0' => '8.0.0', + '7.4' => '7.4.1', + '7.3' => '7.3.6', + '' => '8.0.13' + ] + ], + 'grav' => [ + 'name' => 'Grav', + 'versions' => [ + '1.6' => '1.6.0', + '' => '1.6.28' + ] + ], + 'plugins' => [ + 'admin' => [ + 'name' => 'Admin', + 'optional' => true, + 'versions' => [ + '1.9' => '1.9.0', + '' => '1.9.13' + ] + ], + 'email' => [ + 'name' => 'Email', + 'optional' => true, + 'versions' => [ + '3.0' => '3.0.0', + '' => '3.0.10' + ] + ], + 'form' => [ + 'name' => 'Form', + 'optional' => true, + 'versions' => [ + '4.1' => '4.1.0', + '4.0' => '4.0.0', + '3.0' => '3.0.0', + '' => '4.1.2' + ] + ], + 'login' => [ + 'name' => 'Login', + 'optional' => true, + 'versions' => [ + '3.3' => '3.3.0', + '3.0' => '3.0.0', + '' => '3.3.6' + ] + ], + ] + ]; + + /** @var array */ + public $ignores = [ + 'backup', + 'cache', + 'images', + 'logs', + 'tmp', + 'user', + '.htaccess', + 'robots.txt' + ]; + + /** @var array */ + private $classMap = [ + InstallException::class => __DIR__ . '/InstallException.php', + Versions::class => __DIR__ . '/Versions.php', + VersionUpdate::class => __DIR__ . '/VersionUpdate.php', + VersionUpdater::class => __DIR__ . '/VersionUpdater.php', + YamlUpdater::class => __DIR__ . '/YamlUpdater.php', + ]; + + /** @var string|null */ + private $zip; + + /** @var string|null */ + private $location; + + /** @var VersionUpdater|null */ + private $updater; + + /** @var static */ + private static $instance; + + /** + * @return static + */ + public static function instance() + { + if (null === self::$instance) { + self::$instance = new static(); + } + + return self::$instance; + } + + private function __construct() + { + } + + /** + * @param string|null $zip + * @return $this + */ + public function setZip(?string $zip) + { + $this->zip = $zip; + + return $this; + } + + /** + * @param string|null $zip + * @return void + */ + #[\ReturnTypeWillChange] + public function __invoke(?string $zip) + { + $this->zip = $zip; + + $failedRequirements = $this->checkRequirements(); + if ($failedRequirements) { + $error = ['Following requirements have failed:']; + + foreach ($failedRequirements as $name => $req) { + $error[] = "{$req['title']} >= v{$req['minimum']} required, you have v{$req['installed']}"; + } + + $errors = implode("
    \n", $error); + if (\defined('GRAV_CLI') && GRAV_CLI) { + $errors = "\n\n" . strip_tags($errors) . "\n\n"; + $errors .= <<prepare(); + $this->install(); + $this->finalize(); + } + + /** + * NOTE: This method can only be called after $grav['plugins']->init(). + * + * @return array List of failed requirements. If the list is empty, installation can go on. + */ + public function checkRequirements(): array + { + $results = []; + + $this->checkVersion($results, 'php', 'php', $this->requires['php'], PHP_VERSION); + $this->checkVersion($results, 'grav', 'grav', $this->requires['grav'], GRAV_VERSION); + $this->checkPlugins($results, $this->requires['plugins']); + + return $results; + } + + /** + * @return void + * @throws RuntimeException + */ + public function prepare(): void + { + // Locate the new Grav update and the target site from the filesystem. + $location = realpath(__DIR__); + $target = realpath(GRAV_ROOT . '/index.php'); + + if (!$location) { + throw new RuntimeException('Internal Error', 500); + } + + if ($target && dirname($location, 4) === dirname($target)) { + // We cannot copy files into themselves, abort! + throw new RuntimeException('Grav has already been installed here!', 400); + } + + // Load the installer classes. + foreach ($this->classMap as $class_name => $path) { + // Make sure that none of the Grav\Installer classes have been loaded, otherwise installation may fail! + if (class_exists($class_name, false)) { + throw new RuntimeException(sprintf('Cannot update Grav, class %s has already been loaded!', $class_name), 500); + } + + require $path; + } + + $this->legacySupport(); + + $this->location = dirname($location, 4); + + $versions = Versions::instance(USER_DIR . 'config/versions.yaml'); + $this->updater = new VersionUpdater('core/grav', __DIR__ . '/updates', $this->getVersion(), $versions); + + $this->updater->preflight(); + } + + /** + * @return void + * @throws RuntimeException + */ + public function install(): void + { + if (!$this->location) { + throw new RuntimeException('Oops, installer was run without prepare()!', 500); + } + + try { + if (null === $this->updater) { + $versions = Versions::instance(USER_DIR . 'config/versions.yaml'); + $this->updater = new VersionUpdater('core/grav', __DIR__ . '/updates', $this->getVersion(), $versions); + } + + // Update user/config/version.yaml before copying the files to avoid frontend from setting the version schema. + $this->updater->install(); + + Installer::install( + $this->zip ?? '', + GRAV_ROOT, + ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $this->ignores], + $this->location, + !($this->zip && is_file($this->zip)) + ); + } catch (Exception $e) { + Installer::setError($e->getMessage()); + } + + $errorCode = Installer::lastErrorCode(); + + $success = !(is_string($errorCode) || ($errorCode & (Installer::ZIP_OPEN_ERROR | Installer::ZIP_EXTRACT_ERROR))); + + if (!$success) { + throw new RuntimeException(Installer::lastErrorMsg()); + } + } + + /** + * @return void + * @throws RuntimeException + */ + public function finalize(): void + { + // Finalize can be run without installing Grav first. + if (null === $this->updater) { + $versions = Versions::instance(USER_DIR . 'config/versions.yaml'); + $this->updater = new VersionUpdater('core/grav', __DIR__ . '/updates', GRAV_VERSION, $versions); + $this->updater->install(); + } + + $this->updater->postflight(); + + Cache::clearCache('all'); + + clearstatcache(); + if (function_exists('opcache_reset')) { + @opcache_reset(); + } + } + + /** + * @param array $results + * @param string $type + * @param string $name + * @param array $check + * @param string|null $version + * @return void + */ + protected function checkVersion(array &$results, $type, $name, array $check, $version): void + { + if (null === $version && !empty($check['optional'])) { + return; + } + + $major = $minor = 0; + $versions = $check['versions'] ?? []; + foreach ($versions as $major => $minor) { + if (!$major || version_compare($version ?? '0', $major, '<')) { + continue; + } + + if (version_compare($version ?? '0', $minor, '>=')) { + return; + } + + break; + } + + if (!$major) { + $minor = reset($versions); + } + + $recommended = end($versions); + + if (version_compare($recommended, $minor, '<=')) { + $recommended = null; + } + + $results[$name] = [ + 'type' => $type, + 'name' => $name, + 'title' => $check['name'] ?? $name, + 'installed' => $version, + 'minimum' => $minor, + 'recommended' => $recommended + ]; + } + + /** + * @param array $results + * @param array $plugins + * @return void + */ + protected function checkPlugins(array &$results, array $plugins): void + { + if (!class_exists('Plugins')) { + return; + } + + foreach ($plugins as $name => $check) { + $plugin = Plugins::get($name); + if (!$plugin) { + $this->checkVersion($results, 'plugin', $name, $check, null); + continue; + } + + $blueprint = $plugin->blueprints(); + $version = (string)$blueprint->get('version'); + $check['name'] = ($blueprint->get('name') ?? $check['name'] ?? $name) . ' Plugin'; + $this->checkVersion($results, 'plugin', $name, $check, $version); + } + } + + /** + * @return string + */ + protected function getVersion(): string + { + $definesFile = "{$this->location}/system/defines.php"; + $content = file_get_contents($definesFile); + if (false === $content) { + return ''; + } + + preg_match("/define\('GRAV_VERSION', '([^']+)'\);/mu", $content, $matches); + + return $matches[1] ?? ''; + } + + protected function legacySupport(): void + { + // Support install for Grav 1.6.0 - 1.6.20 by loading the original class from the older version of Grav. + class_exists(\Grav\Console\Cli\CacheCommand::class, true); + } +} diff --git a/system/src/Grav/Installer/InstallException.php b/system/src/Grav/Installer/InstallException.php new file mode 100644 index 0000000..1192849 --- /dev/null +++ b/system/src/Grav/Installer/InstallException.php @@ -0,0 +1,29 @@ +getCode(), $previous); + } +} diff --git a/system/src/Grav/Installer/VersionUpdate.php b/system/src/Grav/Installer/VersionUpdate.php new file mode 100644 index 0000000..1fde783 --- /dev/null +++ b/system/src/Grav/Installer/VersionUpdate.php @@ -0,0 +1,83 @@ +revision = $name; + [$this->version, $this->date, $this->patch] = explode('_', $name); + $this->updater = $updater; + $this->methods = require $file; + } + + public function getRevision(): string + { + return $this->revision; + } + + public function getVersion(): string + { + return $this->version; + } + + public function getDate(): string + { + return $this->date; + } + + public function getPatch(): string + { + return $this->patch; + } + + public function getUpdater(): VersionUpdater + { + return $this->updater; + } + + /** + * Run right before installation. + */ + public function preflight(VersionUpdater $updater): void + { + $method = $this->methods['preflight'] ?? null; + if ($method instanceof Closure) { + $method->call($this); + } + } + + /** + * Runs right after installation. + */ + public function postflight(VersionUpdater $updater): void + { + $method = $this->methods['postflight'] ?? null; + if ($method instanceof Closure) { + $method->call($this); + } + } +} diff --git a/system/src/Grav/Installer/VersionUpdater.php b/system/src/Grav/Installer/VersionUpdater.php new file mode 100644 index 0000000..75a3b04 --- /dev/null +++ b/system/src/Grav/Installer/VersionUpdater.php @@ -0,0 +1,133 @@ +name = $name; + $this->path = $path; + $this->version = $version; + $this->versions = $versions; + + $this->loadUpdates(); + } + + /** + * Pre-installation method. + */ + public function preflight(): void + { + foreach ($this->updates as $revision => $update) { + $update->preflight($this); + } + } + + /** + * Install method. + */ + public function install(): void + { + $versions = $this->getVersions(); + $versions->updateVersion($this->name, $this->version); + $versions->save(); + } + + /** + * Post-installation method. + */ + public function postflight(): void + { + $versions = $this->getVersions(); + + foreach ($this->updates as $revision => $update) { + $update->postflight($this); + + $versions->setSchema($this->name, $revision); + $versions->save(); + } + } + + /** + * @return Versions + */ + public function getVersions(): Versions + { + return $this->versions; + } + + /** + * @param string|null $name + * @return string|null + */ + public function getExtensionVersion(string $name = null): ?string + { + return $this->versions->getVersion($name ?? $this->name); + } + + /** + * @param string|null $name + * @return string|null + */ + public function getExtensionSchema(string $name = null): ?string + { + return $this->versions->getSchema($name ?? $this->name); + } + + /** + * @param string|null $name + * @return array + */ + public function getExtensionHistory(string $name = null): array + { + return $this->versions->getHistory($name ?? $this->name); + } + + protected function loadUpdates(): void + { + $this->updates = []; + + $schema = $this->getExtensionSchema(); + $iterator = new DirectoryIterator($this->path); + foreach ($iterator as $item) { + if (!$item->isFile() || $item->getExtension() !== 'php') { + continue; + } + + $revision = $item->getBasename('.php'); + if (!$schema || version_compare($revision, $schema, '>')) { + $realPath = $item->getRealPath(); + if ($realPath) { + $this->updates[$revision] = new VersionUpdate($realPath, $this); + } + } + } + + uksort($this->updates, 'version_compare'); + } +} diff --git a/system/src/Grav/Installer/Versions.php b/system/src/Grav/Installer/Versions.php new file mode 100644 index 0000000..8b819ce --- /dev/null +++ b/system/src/Grav/Installer/Versions.php @@ -0,0 +1,329 @@ +updated) { + return false; + } + + file_put_contents($this->filename, Yaml::dump($this->items, 4, 2)); + + $this->updated = false; + + return true; + } + + /** + * @return array + */ + public function getAll(): array + { + return $this->items; + } + + /** + * @return array|null + */ + public function getGrav(): ?array + { + return $this->get('core/grav'); + } + + /** + * @return array + */ + public function getPlugins(): array + { + return $this->get('plugins', []); + } + + /** + * @param string $name + * @return array|null + */ + public function getPlugin(string $name): ?array + { + return $this->get("plugins/{$name}"); + } + + /** + * @return array + */ + public function getThemes(): array + { + return $this->get('themes', []); + } + + /** + * @param string $name + * @return array|null + */ + public function getTheme(string $name): ?array + { + return $this->get("themes/{$name}"); + } + + /** + * @param string $extension + * @return array|null + */ + public function getExtension(string $extension): ?array + { + return $this->get($extension); + } + + /** + * @param string $extension + * @param array|null $value + */ + public function setExtension(string $extension, ?array $value): void + { + if (null !== $value) { + $this->set($extension, $value); + } else { + $this->undef($extension); + } + } + + /** + * @param string $extension + * @return string|null + */ + public function getVersion(string $extension): ?string + { + $version = $this->get("{$extension}/version", null); + + return is_string($version) ? $version : null; + } + + /** + * @param string $extension + * @param string|null $version + */ + public function setVersion(string $extension, ?string $version): void + { + $this->updateHistory($extension, $version); + } + + /** + * NOTE: Updates also history. + * + * @param string $extension + * @param string|null $version + */ + public function updateVersion(string $extension, ?string $version): void + { + $this->set("{$extension}/version", $version); + $this->updateHistory($extension, $version); + } + + /** + * @param string $extension + * @return string|null + */ + public function getSchema(string $extension): ?string + { + $version = $this->get("{$extension}/schema", null); + + return is_string($version) ? $version : null; + } + + /** + * @param string $extension + * @param string|null $schema + */ + public function setSchema(string $extension, ?string $schema): void + { + if (null !== $schema) { + $this->set("{$extension}/schema", $schema); + } else { + $this->undef("{$extension}/schema"); + } + } + + /** + * @param string $extension + * @return array + */ + public function getHistory(string $extension): array + { + $name = "{$extension}/history"; + $history = $this->get($name, []); + + // Fix for broken Grav 1.6 history + if ($extension === 'grav') { + $history = $this->fixHistory($history); + } + + return $history; + } + + /** + * @param string $extension + * @param string|null $version + */ + public function updateHistory(string $extension, ?string $version): void + { + $name = "{$extension}/history"; + $history = $this->getHistory($extension); + $history[] = ['version' => $version, 'date' => gmdate('Y-m-d H:i:s')]; + $this->set($name, $history); + } + + /** + * Clears extension history. Useful when creating skeletons. + * + * @param string $extension + */ + public function removeHistory(string $extension): void + { + $this->undef("{$extension}/history"); + } + + /** + * @param array $history + * @return array + */ + private function fixHistory(array $history): array + { + if (isset($history['version'], $history['date'])) { + $fix = [['version' => $history['version'], 'date' => $history['date']]]; + unset($history['version'], $history['date']); + $history = array_merge($fix, $history); + } + + return $history; + } + + /** + * Get value by using dot notation for nested arrays/objects. + * + * @param string $name Slash separated path to the requested value. + * @param mixed $default Default value (or null). + * @return mixed Value. + */ + private function get(string $name, $default = null) + { + $path = explode('/', $name); + $current = $this->items; + + foreach ($path as $field) { + if (is_array($current) && isset($current[$field])) { + $current = $current[$field]; + } else { + return $default; + } + } + + return $current; + } + + /** + * Set value by using dot notation for nested arrays/objects. + * + * @param string $name Slash separated path to the requested value. + * @param mixed $value New value. + */ + private function set(string $name, $value): void + { + $path = explode('/', $name); + $current = &$this->items; + + foreach ($path as $field) { + // Handle arrays and scalars. + if (!is_array($current)) { + $current = [$field => []]; + } elseif (!isset($current[$field])) { + $current[$field] = []; + } + $current = &$current[$field]; + } + + $current = $value; + $this->updated = true; + } + + /** + * Unset value by using dot notation for nested arrays/objects. + * + * @param string $name Dot separated path to the requested value. + */ + private function undef(string $name): void + { + $path = $name !== '' ? explode('/', $name) : []; + if (!$path) { + return; + } + + $var = array_pop($path); + $current = &$this->items; + + foreach ($path as $field) { + if (!is_array($current) || !isset($current[$field])) { + return; + } + $current = &$current[$field]; + } + + unset($current[$var]); + $this->updated = true; + } + + private function __construct(string $filename) + { + $this->filename = $filename; + $content = is_file($filename) ? file_get_contents($filename) : null; + if (false === $content) { + throw new \RuntimeException('Versions file cannot be read'); + } + $this->items = $content ? Yaml::parse($content) : []; + } +} diff --git a/system/src/Grav/Installer/YamlUpdater.php b/system/src/Grav/Installer/YamlUpdater.php new file mode 100644 index 0000000..c43ba2c --- /dev/null +++ b/system/src/Grav/Installer/YamlUpdater.php @@ -0,0 +1,431 @@ +updated) { + return false; + } + + try { + if (!$this->isHandWritten()) { + $yaml = Yaml::dump($this->items, 5, 2); + } else { + $yaml = implode("\n", $this->lines); + + $items = Yaml::parse($yaml); + if ($items !== $this->items) { + throw new \RuntimeException('Failed saving the content'); + } + } + + file_put_contents($this->filename, $yaml); + + } catch (\Exception $e) { + throw new \RuntimeException('Failed to update ' . basename($this->filename) . ': ' . $e->getMessage()); + } + + return true; + } + + /** + * @return bool + */ + public function isHandWritten(): bool + { + return !empty($this->comments); + } + + /** + * @return array + */ + public function getComments(): array + { + $comments = []; + foreach ($this->lines as $i => $line) { + if ($this->isLineEmpty($line)) { + $comments[$i+1] = $line; + } elseif ($comment = $this->getInlineComment($line)) { + $comments[$i+1] = $comment; + } + } + + return $comments; + } + + /** + * @param string $variable + * @param mixed $value + */ + public function define(string $variable, $value): void + { + // If variable has already value, we're good. + if ($this->get($variable) !== null) { + return; + } + + // If one of the parents isn't array, we're good, too. + if (!$this->canDefine($variable)) { + return; + } + + $this->set($variable, $value); + if (!$this->isHandWritten()) { + return; + } + + $parts = explode('.', $variable); + + $lineNos = $this->findPath($this->lines, $parts); + $count = count($lineNos); + $last = array_key_last($lineNos); + + $value = explode("\n", trim(Yaml::dump([$last => $this->get(implode('.', array_keys($lineNos)))], max(0, 5-$count), 2))); + $currentLine = array_pop($lineNos) ?: 0; + $parentLine = array_pop($lineNos); + + if ($parentLine !== null) { + $c = $this->getLineIndentation($this->lines[$parentLine] ?? ''); + $n = $this->getLineIndentation($this->lines[$parentLine+1] ?? $this->lines[$parentLine] ?? ''); + $indent = $n > $c ? $n : $c + 2; + } else { + $indent = 0; + array_unshift($value, ''); + } + $spaces = str_repeat(' ', $indent); + foreach ($value as &$line) { + $line = $spaces . $line; + } + unset($line); + + array_splice($this->lines, abs($currentLine)+1, 0, $value); + } + + public function undefine(string $variable): void + { + // If variable does not have value, we're good. + if ($this->get($variable) === null) { + return; + } + + // If one of the parents isn't array, we're good, too. + if (!$this->canDefine($variable)) { + return; + } + + $this->undef($variable); + if (!$this->isHandWritten()) { + return; + } + + // TODO: support also removing property from handwritten configuration file. + } + + private function __construct(string $filename) + { + $content = is_file($filename) ? (string)file_get_contents($filename) : ''; + $content = rtrim(str_replace(["\r\n", "\r"], "\n", $content)); + + $this->filename = $filename; + $this->lines = explode("\n", $content); + $this->comments = $this->getComments(); + $this->items = $content ? Yaml::parse($content) : []; + } + + /** + * Return array of offsets for the parent nodes. Negative value means position, but not found. + * + * @param array $lines + * @param array $parts + * @return int[] + */ + private function findPath(array $lines, array $parts) + { + $test = true; + $indent = -1; + $current = array_shift($parts); + + $j = 1; + $found = []; + $space = ''; + foreach ($lines as $i => $line) { + if ($this->isLineEmpty($line)) { + if ($this->isLineComment($line) && $this->getLineIndentation($line) > $indent) { + $j = $i; + } + continue; + } + + if ($test === true) { + $test = false; + $spaces = strlen($line) - strlen(ltrim($line, ' ')); + if ($spaces <= $indent) { + $found[$current] = -$j; + + return $found; + } + + $indent = $spaces; + $space = $indent ? str_repeat(' ', $indent) : ''; + } + + + if (0 === \strncmp($line, $space, strlen($space))) { + $pattern = "/^{$space}(['\"]?){$current}\\1\:/"; + + if (preg_match($pattern, $line)) { + $found[$current] = $i; + $current = array_shift($parts); + if ($current === null) { + return $found; + } + $test = true; + } + } else { + $found[$current] = -$j; + + return $found; + } + + $j = $i; + } + + $found[$current] = -$j; + + return $found; + } + + /** + * Returns true if the current line is blank or if it is a comment line. + * + * @param string $line Contents of the line + * @return bool Returns true if the current line is empty or if it is a comment line, false otherwise + */ + private function isLineEmpty(string $line): bool + { + return $this->isLineBlank($line) || $this->isLineComment($line); + } + + /** + * Returns true if the current line is blank. + * + * @param string $line Contents of the line + * @return bool Returns true if the current line is blank, false otherwise + */ + private function isLineBlank(string $line): bool + { + return '' === trim($line, ' '); + } + + /** + * Returns true if the current line is a comment line. + * + * @param string $line Contents of the line + * @return bool Returns true if the current line is a comment line, false otherwise + */ + private function isLineComment(string $line): bool + { + //checking explicitly the first char of the trim is faster than loops or strpos + $ltrimmedLine = ltrim($line, ' '); + + return '' !== $ltrimmedLine && '#' === $ltrimmedLine[0]; + } + + /** + * @param string $line + * @return bool + */ + private function isInlineComment(string $line): bool + { + return $this->getInlineComment($line) !== null; + } + + /** + * @param string $line + * @return string|null + */ + private function getInlineComment(string $line): ?string + { + $pos = strpos($line, ' #'); + if (false === $pos) { + return null; + } + + $parts = explode(' #', $line); + $part = ''; + while ($part .= array_shift($parts)) { + // Remove quoted values. + $part = preg_replace('/(([\'"])[^\2]*\2)/', '', $part); + assert(null !== $part); + $part = preg_split('/[\'"]/', $part, 2); + assert(false !== $part); + if (!isset($part[1])) { + $part = $part[0]; + array_unshift($parts, str_repeat(' ', strlen($part) - strlen(trim($part, ' ')))); + break; + } + $part = $part[1]; + } + + + return implode(' #', $parts); + } + + /** + * Returns the current line indentation. + * + * @param string $line + * @return int The current line indentation + */ + private function getLineIndentation(string $line): int + { + return \strlen($line) - \strlen(ltrim($line, ' ')); + } + + /** + * Get value by using dot notation for nested arrays/objects. + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @return mixed Value. + */ + private function get(string $name, $default = null) + { + $path = explode('.', $name); + $current = $this->items; + + foreach ($path as $field) { + if (is_array($current) && isset($current[$field])) { + $current = $current[$field]; + } else { + return $default; + } + } + + return $current; + } + + /** + * Set value by using dot notation for nested arrays/objects. + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value New value. + */ + private function set(string $name, $value): void + { + $path = explode('.', $name); + $current = &$this->items; + + foreach ($path as $field) { + // Handle arrays and scalars. + if (!is_array($current)) { + $current = [$field => []]; + } elseif (!isset($current[$field])) { + $current[$field] = []; + } + $current = &$current[$field]; + } + + $current = $value; + $this->updated = true; + } + + /** + * Unset value by using dot notation for nested arrays/objects. + * + * @param string $name Dot separated path to the requested value. + */ + private function undef(string $name): void + { + $path = $name !== '' ? explode('.', $name) : []; + if (!$path) { + return; + } + + $var = array_pop($path); + $current = &$this->items; + + foreach ($path as $field) { + if (!is_array($current) || !isset($current[$field])) { + return; + } + $current = &$current[$field]; + } + + unset($current[$var]); + $this->updated = true; + } + + /** + * Get value by using dot notation for nested arrays/objects. + * + * @param string $name Dot separated path to the requested value. + * @return bool + */ + private function canDefine(string $name): bool + { + $path = explode('.', $name); + $current = $this->items; + + foreach ($path as $field) { + if (is_array($current)) { + if (!isset($current[$field])) { + return true; + } + $current = $current[$field]; + } else { + return false; + } + } + + return true; + } +} diff --git a/system/src/Grav/Installer/updates/1.7.0_2020-11-20_1.php b/system/src/Grav/Installer/updates/1.7.0_2020-11-20_1.php new file mode 100644 index 0000000..6120665 --- /dev/null +++ b/system/src/Grav/Installer/updates/1.7.0_2020-11-20_1.php @@ -0,0 +1,24 @@ + null, + 'postflight' => + function () { + /** @var VersionUpdate $this */ + try { + // Keep old defaults for backwards compatibility. + $yaml = YamlUpdater::instance(GRAV_ROOT . '/user/config/system.yaml'); + $yaml->define('twig.autoescape', false); + $yaml->define('strict_mode.yaml_compat', true); + $yaml->define('strict_mode.twig_compat', true); + $yaml->define('strict_mode.blueprint_compat', true); + $yaml->save(); + } catch (\Exception $e) { + throw new InstallException('Could not update system configuration to maintain backwards compatibility', $e); + } + } +]; diff --git a/system/src/Twig/DeferredExtension/DeferredBlockNode.php b/system/src/Twig/DeferredExtension/DeferredBlockNode.php new file mode 100755 index 0000000..6ae974f --- /dev/null +++ b/system/src/Twig/DeferredExtension/DeferredBlockNode.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Twig\DeferredExtension; + +use Twig\Compiler; +use Twig\Node\BlockNode; + +final class DeferredBlockNode extends BlockNode +{ + public function compile(Compiler $compiler) : void + { + $name = $this->getAttribute('name'); + + $compiler + ->write("public function block_$name(\$context, array \$blocks = [])\n", "{\n") + ->indent() + ->write("\$this->deferred->defer(\$this, '$name');\n") + ->outdent() + ->write("}\n\n") + ; + + $compiler + ->addDebugInfo($this) + ->write("public function block_{$name}_deferred(\$context, array \$blocks = [])\n", "{\n") + ->indent() + ->subcompile($this->getNode('body')) + ->write("\$this->deferred->resolve(\$this, \$context, \$blocks);\n") + ->outdent() + ->write("}\n\n") + ; + } +} diff --git a/system/src/Twig/DeferredExtension/DeferredDeclareNode.php b/system/src/Twig/DeferredExtension/DeferredDeclareNode.php new file mode 100644 index 0000000..ba05121 --- /dev/null +++ b/system/src/Twig/DeferredExtension/DeferredDeclareNode.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Twig\DeferredExtension; + +use Twig\Compiler; +use Twig\Node\Node; + +final class DeferredDeclareNode extends Node +{ + public function compile(Compiler $compiler) : void + { + $compiler + ->write("private \$deferred;\n") + ; + } +} \ No newline at end of file diff --git a/system/src/Twig/DeferredExtension/DeferredExtension.php b/system/src/Twig/DeferredExtension/DeferredExtension.php new file mode 100644 index 0000000..f27c2a3 --- /dev/null +++ b/system/src/Twig/DeferredExtension/DeferredExtension.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Twig\DeferredExtension; + +use Twig\Environment; +use Twig\Extension\AbstractExtension; +use Twig\Template; + +final class DeferredExtension extends AbstractExtension +{ + private $blocks = []; + + public function getTokenParsers() : array + { + return [new DeferredTokenParser()]; + } + + public function getNodeVisitors() : array + { + if (Environment::VERSION_ID < 20000) { + // Twig 1.x support + return [new DeferredNodeVisitorCompat()]; + } + + return [new DeferredNodeVisitor()]; + } + + public function defer(Template $template, string $blockName) : void + { + $templateName = $template->getTemplateName(); + $this->blocks[$templateName][] = $blockName; + $index = \count($this->blocks[$templateName]) - 1; + + \ob_start(function (string $buffer) use ($index, $templateName) { + unset($this->blocks[$templateName][$index]); + + return $buffer; + }); + } + + public function resolve(Template $template, array $context, array $blocks) : void + { + $templateName = $template->getTemplateName(); + if (empty($this->blocks[$templateName])) { + return; + } + + while ($blockName = \array_pop($this->blocks[$templateName])) { + $buffer = \ob_get_clean(); + + $blocks[$blockName] = [$template, 'block_'.$blockName.'_deferred']; + $template->displayBlock($blockName, $context, $blocks); + + echo $buffer; + } + + if ($parent = $template->getParent($context)) { + $this->resolve($parent, $context, $blocks); + } + } +} diff --git a/system/src/Twig/DeferredExtension/DeferredInitializeNode.php b/system/src/Twig/DeferredExtension/DeferredInitializeNode.php new file mode 100644 index 0000000..0653f5c --- /dev/null +++ b/system/src/Twig/DeferredExtension/DeferredInitializeNode.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Twig\DeferredExtension; + +use Twig\Compiler; +use Twig\Node\Node; + +final class DeferredInitializeNode extends Node +{ + public function compile(Compiler $compiler) : void + { + $compiler + ->write("\$this->deferred = \$this->env->getExtension('".DeferredExtension::class."');\n") + ; + } +} diff --git a/system/src/Twig/DeferredExtension/DeferredNode.php b/system/src/Twig/DeferredExtension/DeferredNode.php new file mode 100755 index 0000000..2ac73bd --- /dev/null +++ b/system/src/Twig/DeferredExtension/DeferredNode.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Twig\DeferredExtension; + +use Twig\Compiler; +use Twig\Node\Node; + +final class DeferredNode extends Node +{ + public function compile(Compiler $compiler) : void + { + $compiler + ->write("\$this->deferred->resolve(\$this, \$context, \$blocks);\n") + ; + } +} diff --git a/system/src/Twig/DeferredExtension/DeferredNodeVisitor.php b/system/src/Twig/DeferredExtension/DeferredNodeVisitor.php new file mode 100644 index 0000000..6f61487 --- /dev/null +++ b/system/src/Twig/DeferredExtension/DeferredNodeVisitor.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Twig\DeferredExtension; + +use Twig\Environment; +use Twig\Node\ModuleNode; +use Twig\Node\Node; +use Twig\NodeVisitor\NodeVisitorInterface; + +final class DeferredNodeVisitor implements NodeVisitorInterface +{ + private $hasDeferred = false; + + public function enterNode(Node $node, Environment $env) : Node + { + if (!$this->hasDeferred && $node instanceof DeferredBlockNode) { + $this->hasDeferred = true; + } + + return $node; + } + + public function leaveNode(Node $node, Environment $env) : ?Node + { + if ($this->hasDeferred && $node instanceof ModuleNode) { + $node->getNode('constructor_end')->setNode('deferred_initialize', new DeferredInitializeNode()); + $node->getNode('display_end')->setNode('deferred_resolve', new DeferredResolveNode()); + $node->getNode('class_end')->setNode('deferred_declare', new DeferredDeclareNode()); + $this->hasDeferred = false; + } + + return $node; + } + + public function getPriority() : int + { + return 0; + } +} diff --git a/system/src/Twig/DeferredExtension/DeferredNodeVisitorCompat.php b/system/src/Twig/DeferredExtension/DeferredNodeVisitorCompat.php new file mode 100644 index 0000000..aa61b72 --- /dev/null +++ b/system/src/Twig/DeferredExtension/DeferredNodeVisitorCompat.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Twig\DeferredExtension; + +use Twig\Environment; +use Twig\Node\ModuleNode; +use Twig\Node\Node; +use Twig\NodeVisitor\NodeVisitorInterface; + +final class DeferredNodeVisitorCompat implements NodeVisitorInterface +{ + private $hasDeferred = false; + + /** + * @param \Twig_NodeInterface $node + * @param Environment $env + * @return Node + */ + public function enterNode(\Twig_NodeInterface $node, Environment $env): Node + { + if (!$this->hasDeferred && $node instanceof DeferredBlockNode) { + $this->hasDeferred = true; + } + + \assert($node instanceof Node); + + return $node; + } + + /** + * @param \Twig_NodeInterface $node + * @param Environment $env + * @return Node|null + */ + public function leaveNode(\Twig_NodeInterface $node, Environment $env): ?Node + { + if ($this->hasDeferred && $node instanceof ModuleNode) { + $node->getNode('constructor_end')->setNode('deferred_initialize', new DeferredInitializeNode()); + $node->getNode('display_end')->setNode('deferred_resolve', new DeferredResolveNode()); + $node->getNode('class_end')->setNode('deferred_declare', new DeferredDeclareNode()); + $this->hasDeferred = false; + } + + \assert($node instanceof Node); + + return $node; + } + + /** + * @return int + */ + public function getPriority() : int + { + return 0; + } +} diff --git a/system/src/Twig/DeferredExtension/DeferredResolveNode.php b/system/src/Twig/DeferredExtension/DeferredResolveNode.php new file mode 100644 index 0000000..72e0e29 --- /dev/null +++ b/system/src/Twig/DeferredExtension/DeferredResolveNode.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Twig\DeferredExtension; + +use Twig\Compiler; +use Twig\Node\Node; + +final class DeferredResolveNode extends Node +{ + public function compile(Compiler $compiler) : void + { + $compiler + ->write("\$this->deferred->resolve(\$this, \$context, \$blocks);\n") + ; + } +} diff --git a/system/src/Twig/DeferredExtension/DeferredTokenParser.php b/system/src/Twig/DeferredExtension/DeferredTokenParser.php new file mode 100644 index 0000000..1870ae0 --- /dev/null +++ b/system/src/Twig/DeferredExtension/DeferredTokenParser.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Twig\DeferredExtension; + +use Twig\Node\BlockNode; +use Twig\Node\Node; +use Twig\Parser; +use Twig\Token; +use Twig\TokenParser\AbstractTokenParser; +use Twig\TokenParser\BlockTokenParser; + +final class DeferredTokenParser extends AbstractTokenParser +{ + private $blockTokenParser; + + public function setParser(Parser $parser) : void + { + parent::setParser($parser); + + $this->blockTokenParser = new BlockTokenParser(); + $this->blockTokenParser->setParser($parser); + } + + public function parse(Token $token) : Node + { + $stream = $this->parser->getStream(); + $nameToken = $stream->next(); + $deferredToken = $stream->nextIf(Token::NAME_TYPE, 'deferred'); + $stream->injectTokens([$nameToken]); + + $node = $this->blockTokenParser->parse($token); + + if ($deferredToken) { + $this->replaceBlockNode($nameToken->getValue()); + } + + return $node; + } + + public function getTag() : string + { + return 'block'; + } + + private function replaceBlockNode(string $name) : void + { + $block = $this->parser->getBlock($name)->getNode('0'); + $this->parser->setBlock($name, $this->createDeferredBlockNode($block)); + } + + private function createDeferredBlockNode(BlockNode $block) : DeferredBlockNode + { + $name = $block->getAttribute('name'); + $deferredBlock = new DeferredBlockNode($name, new Node([]), $block->getTemplateLine()); + + foreach ($block as $nodeName => $node) { + $deferredBlock->setNode($nodeName, $node); + } + + if ($sourceContext = $block->getSourceContext()) { + $deferredBlock->setSourceContext($sourceContext); + } + + return $deferredBlock; + } +} diff --git a/system/templates/default.html.twig b/system/templates/default.html.twig new file mode 100644 index 0000000..f18206b --- /dev/null +++ b/system/templates/default.html.twig @@ -0,0 +1,4 @@ +{# Default output if no theme #} +

    ERROR: {{ page.template() ~'.'~ page.templateFormat() ~".twig" }} template not found for page: {{ page.route() }}

    +

    {{ page.title() }}

    +{{ page.content()|raw }} diff --git a/system/templates/external.html.twig b/system/templates/external.html.twig new file mode 100644 index 0000000..3fa3508 --- /dev/null +++ b/system/templates/external.html.twig @@ -0,0 +1 @@ +{# Default external template #} diff --git a/system/templates/flex/404.html.twig b/system/templates/flex/404.html.twig new file mode 100644 index 0000000..adf4f65 --- /dev/null +++ b/system/templates/flex/404.html.twig @@ -0,0 +1,4 @@ +{% set item = collection ?? object %} +{% set type = collection ? 'collection' : 'object' %} + +ERROR: Layout '{{ layout }}' for flex {{ type }} '{{ item.flexType() }}' was not found. \ No newline at end of file diff --git a/system/templates/flex/_default/collection/debug.html.twig b/system/templates/flex/_default/collection/debug.html.twig new file mode 100644 index 0000000..5a37835 --- /dev/null +++ b/system/templates/flex/_default/collection/debug.html.twig @@ -0,0 +1,5 @@ +

    {{ directory.getTitle() }} debug dump

    + +{% for object in collection %} + {% render object layout: layout %} +{% endfor %} diff --git a/system/templates/flex/_default/object/debug.html.twig b/system/templates/flex/_default/object/debug.html.twig new file mode 100644 index 0000000..dc961cd --- /dev/null +++ b/system/templates/flex/_default/object/debug.html.twig @@ -0,0 +1,4 @@ +
    +

    {{ object.key }}

    +
    {{ object.jsonSerialize()|yaml_encode }}
    +
    \ No newline at end of file diff --git a/system/templates/modular/default.html.twig b/system/templates/modular/default.html.twig new file mode 100644 index 0000000..f18206b --- /dev/null +++ b/system/templates/modular/default.html.twig @@ -0,0 +1,4 @@ +{# Default output if no theme #} +

    ERROR: {{ page.template() ~'.'~ page.templateFormat() ~".twig" }} template not found for page: {{ page.route() }}

    +

    {{ page.title() }}

    +{{ page.content()|raw }} diff --git a/system/templates/partials/messages.html.twig b/system/templates/partials/messages.html.twig new file mode 100644 index 0000000..261b3fc --- /dev/null +++ b/system/templates/partials/messages.html.twig @@ -0,0 +1,14 @@ +{% set status_mapping = {'info':'green', 'error': 'red', 'warning': 'yellow'} %} + +{% if grav.messages.all %} +
    + {% for message in grav.messages.fetch %} + + {% set scope = message.scope|e %} + {% set color = status_mapping[scope] %} + +

    {{ message.message|raw }}

    + + {% endfor %} +
    +{% endif %} diff --git a/system/templates/partials/metadata.html.twig b/system/templates/partials/metadata.html.twig new file mode 100644 index 0000000..fcf1217 --- /dev/null +++ b/system/templates/partials/metadata.html.twig @@ -0,0 +1,3 @@ +{% for meta in page.metadata %} + +{% endfor %} diff --git a/tests/_bootstrap.php b/tests/_bootstrap.php new file mode 100644 index 0000000..618781d --- /dev/null +++ b/tests/_bootstrap.php @@ -0,0 +1,35 @@ +init(); + + // This must be set first before the other init + $grav['config']->set('system.languages.supported', ['en', 'fr', 'vi']); + $grav['config']->set('system.languages.default_lang', 'en'); + + foreach (array_keys($grav['setup']->getStreams()) as $stream) { + @stream_wrapper_unregister($stream); + } + + $grav['streams']; + + $grav['uri']->init(); + $grav['debugger']->init(); + $grav['assets']->init(); + + $grav['config']->set('system.cache.enabled', false); + $grav['locator']->addPath('tests', '', 'tests', false); + + return $grav; +}; + +Fixtures::add('grav', $grav); diff --git a/tests/_support/AcceptanceTester.php b/tests/_support/AcceptanceTester.php new file mode 100644 index 0000000..4c7dcbb --- /dev/null +++ b/tests/_support/AcceptanceTester.php @@ -0,0 +1,26 @@ +t!fmROooqL-Tz8o|Tx<=pzP32bY^zA}n1m`*SZ zayH~~sh*$2f56|-lb6+ai;Tk!uM0i7lF_H0{5Zi`GEH@D+5JM9lSj1nKe)T&;m-;` z_diSd=LVjNxEHYH)e#|P({1W0Z@V{d{K+CJxk<%)f5U2*^1@Eu4L`D?f>!k1m^N>z z$H%+^=cVf}u-|r`eeuO5&XA|aU$*!&muD*Wsr@eOopdYsS7GFfT{%^Z_bbv2jV|)7 zpBi*{E6Yu+0_pvPdZ<#k#~Xw_4`vsh3VLn7h~tUHZ`TtSwkz_?3jAU7@a?opiQms8cudkC$rRsg7umFT zp>6NVpdI;LvqNOg#<43ZIh=ofz(-?G#k;)AyK*nEsWc}4E&N*MwLid{oujB&wp)>r zf#C)a2Y53wi83RC8d;8ufd>}w4oezAOyq#)fCqelH!B-RmJtYDfOIC%Tm}XJO6sxU literal 0 HcmV?d00001 diff --git a/tests/fake/nested-site/user/pages/01.item1/home-cache-image.jpg b/tests/fake/nested-site/user/pages/01.item1/home-cache-image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3170ac497409e7c7923d25e3eae4b9e4a920b211 GIT binary patch literal 156699 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�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

    xa_u8c)hsQoXBakm44Rj@Hu~WCdpmw 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�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

    xa_u8c)hsQoXBakm44Rj@Hu~WCdpmw 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�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

    xa_u8c)hsQoXBakm44Rj@Hu~WCdpmw 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 literal 0 HcmV?d00001 diff --git a/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg b/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3170ac497409e7c7923d25e3eae4b9e4a920b211 GIT binary patch literal 156699 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�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

    xa_u8c)hsQoXBakm44Rj@Hu~WCdpmw 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: animalgrav = Fixtures::get('grav'); + $this->directInstallCommand = new DirectInstallCommand(); + } +} + +/** + * Why this test file is empty + * + * Wasn't able to call a symfony\console. Kept having $output problem. + * symfony console \NullOutput didn't cut it. + * + * We would also need to Mock tests since downloading packages would + * make tests slow and unreliable. But it's not worth the time ATM. + * + * Look at Gpm/InstallCommandTest.php + * + * For the full story: https://git.io/vSlI3 + */ diff --git a/tests/functional/_bootstrap.php b/tests/functional/_bootstrap.php new file mode 100644 index 0000000..8a88555 --- /dev/null +++ b/tests/functional/_bootstrap.php @@ -0,0 +1,2 @@ +getName() === 'findResource'; + } + + /** + * @param MethodReflection $methodReflection + * @param MethodCall $methodCall + * @param Scope $scope + * @return Type + */ + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + $first = $methodCall->getArgs()[2] ?? false; + if ($first) { + return new StringType(); + } + + return ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); + } +} diff --git a/tests/phpstan/extension.neon b/tests/phpstan/extension.neon new file mode 100644 index 0000000..ef44d0b --- /dev/null +++ b/tests/phpstan/extension.neon @@ -0,0 +1,5 @@ +services: + - + class: PHPStan\Toolbox\UniformResourceLocatorExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension diff --git a/tests/phpstan/phpstan-bootstrap.php b/tests/phpstan/phpstan-bootstrap.php new file mode 100644 index 0000000..b145fa1 --- /dev/null +++ b/tests/phpstan/phpstan-bootstrap.php @@ -0,0 +1,7 @@ + $autoload]); +$grav->setup('tests'); +$grav['config']->init(); + +// Find all plugins in Grav installation and autoload their classes. + +/** @var UniformResourceLocator $locator */ +$locator = Grav::instance()['locator']; +$iterator = $locator->getIterator('plugins://'); +/** @var DirectoryIterator $directory */ +foreach ($iterator as $directory) { + if (!$directory->isDir()) { + continue; + } + $plugin = $directory->getBasename(); + $file = $directory->getPathname() . '/' . $plugin . '.php'; + $classloader = null; + if (file_exists($file)) { + require_once $file; + + $pluginClass = "\\Grav\\Plugin\\{$plugin}Plugin"; + + if (is_subclass_of($pluginClass, Plugin::class, true)) { + $class = new $pluginClass($plugin, $grav); + if (is_callable([$class, 'autoload'])) { + $classloader = $class->autoload(); + } + } + } + if (null === $classloader) { + $autoloader = $directory->getPathname() . '/vendor/autoload.php'; + if (file_exists($autoloader)) { + require $autoloader; + } + } +} + +define('GANTRY_DEBUGGER', true); +define('GANTRY5_DEBUG', true); +define('GANTRY5_PLATFORM', 'grav'); +define('GANTRY5_ROOT', GRAV_ROOT); +define('GANTRY5_VERSION', '@version@'); +define('GANTRY5_VERSION_DATE', '@versiondate@'); +define('GANTRYADMIN_PATH', ''); diff --git a/tests/phpstan/plugins.neon b/tests/phpstan/plugins.neon new file mode 100644 index 0000000..82570cc --- /dev/null +++ b/tests/phpstan/plugins.neon @@ -0,0 +1,70 @@ +includes: + #- '../../vendor/phpstan/phpstan-strict-rules/rules.neon' + - '../../vendor/phpstan/phpstan-deprecation-rules/rules.neon' + - 'extension.neon' +parameters: + fileExtensions: + - php + excludePaths: + - %currentWorkingDirectory%/user/plugins/*/vendor/* + - %currentWorkingDirectory%/user/plugins/*/tests/* + - %currentWorkingDirectory%/user/plugins/gantry5/src/platforms + - %currentWorkingDirectory%/user/plugins/gantry5/src/classes/Gantry/Framework/Services/ErrorServiceProvider.php + # Ignore vendor dev dependencies and tests + - */vendor/*/*/tests + - */vendor/behat + - */vendor/codeception + - */vendor/phpstan + - */vendor/phpunit + - */vendor/phpspec + - */vendor/phpdocumentor + - */vendor/sebastian + - */vendor/theseer + - */vendor/webmozart + bootstrapFiles: + - plugins-bootstrap.php + inferPrivatePropertyTypeFromConstructor: true + reportUnmatchedIgnoredErrors: false + + # These checks are new in phpstan 1, ignore them for now. + checkMissingIterableValueType: false + checkGenericClassInNonGenericObjectType: false + + universalObjectCratesClasses: + - Grav\Common\Config\Config + - Grav\Common\Config\Languages + - Grav\Common\Config\Setup + - Grav\Common\Data\Data + - Grav\Common\GPM\Common\Package + - Grav\Common\GPM\Local\Package + - Grav\Common\GPM\Remote\Package + - Grav\Common\Page\Header + - Grav\Common\Session + - Gantry\Component\Config\Config + dynamicConstantNames: + - GRAV_CLI + - GANTRY_DEBUGGER + - GANTRY5_DEBUG + - GANTRY5_VERSION + - GANTRY5_VERSION_DATE + - GANTRY5_PLATFORM + - GANTRY5_ROOT + ignoreErrors: + # New in phpstan 1, ignore them for now. + - '#Unsafe usage of new static\(\)#' + - '#Cannot instantiate interface Grav\\Framework\\#' + + # PSR-16 Exception interfaces do not extend \Throwable + - '#PHPDoc tag \@throws with type (.*|)?Psr\\SimpleCache\\(CacheException|InvalidArgumentException)(|.*)? is not subtype of Throwable#' + + - '#Access to an undefined property RocketTheme\\Toolbox\\Event\\Event::#' + - '#Instantiation of deprecated class RocketTheme\\Toolbox\\Event\\Event#' + - '#extends deprecated class RocketTheme\\Toolbox\\Event\\Event#' + - '#implements deprecated interface RocketTheme\\Toolbox\\Event\\EventSubscriberInterface#' + - '#Call to method __construct\(\) of deprecated class RocketTheme\\Toolbox\\Event\\Event#' + - '#Call to deprecated method (stopPropagation|isPropagationStopped)\(\) of class Symfony\\Component\\EventDispatcher\\Event#' + - '#Call to an undefined method Grav\\Plugin\\ApartmentData\\Application\\Application::#' + - '#Parameter \#1 \$lineNumberStyle of method ScssPhp\\ScssPhp\\Compiler::setLineNumberStyle\(\) expects string, int given#' + + # Deprecated event class + - '#has typehint with deprecated class RocketTheme\\Toolbox\\Event\\Event#' diff --git a/tests/unit.suite.yml b/tests/unit.suite.yml new file mode 100644 index 0000000..02dc9b1 --- /dev/null +++ b/tests/unit.suite.yml @@ -0,0 +1,9 @@ +# Codeception Test Suite Configuration +# +# Suite for unit (internal) tests. + +class_name: UnitTester +modules: + enabled: + - Asserts + - \Helper\Unit \ No newline at end of file diff --git a/tests/unit/Grav/Common/AssetsTest.php b/tests/unit/Grav/Common/AssetsTest.php new file mode 100644 index 0000000..57539a6 --- /dev/null +++ b/tests/unit/Grav/Common/AssetsTest.php @@ -0,0 +1,847 @@ +grav = $grav(); + $this->assets = $this->grav['assets']; + } + + protected function _after(): void + { + } + + public function testAddingAssets(): void + { + //test add() + $this->assets->add('test.css'); + + $css = $this->assets->css(); + self::assertSame('' . PHP_EOL, $css); + + $array = $this->assets->getCss(); + + /** @var Assets\BaseAsset $item */ + $item = reset($array); + $actual = json_encode($item); + $expected = ' + { + "type":"css", + "elements":{ + "asset":"\/test.css", + "asset_type":"css", + "order":0, + "group":"head", + "position":"pipeline", + "priority":10, + "attributes":{ + "type":"text\/css", + "rel":"stylesheet" + }, + "modified":false, + "query":"" + } + }'; + self::assertJsonStringEqualsJsonString($expected, $actual); + + $this->assets->add('test.js'); + $js = $this->assets->js(); + self::assertSame('' . PHP_EOL, $js); + + $array = $this->assets->getJs(); + + /** @var Assets\BaseAsset $item */ + $item = reset($array); + $actual = json_encode($item); + $expected = ' + { + "type":"js", + "elements":{ + "asset":"\/test.js", + "asset_type":"js", + "order":0, + "group":"head", + "position":"pipeline", + "priority":10, + "attributes":[ + + ], + "modified":false, + "query":"" + } + }'; + self::assertJsonStringEqualsJsonString($expected, $actual); + + //test addCss(). Test adding asset to a separate group + $this->assets->reset(); + $this->assets->addCSS('test.css'); + $css = $this->assets->css(); + self::assertSame('' . PHP_EOL, $css); + + $array = $this->assets->getCss(); + /** @var Assets\BaseAsset $item */ + $item = reset($array); + $actual = json_encode($item); + $expected = ' + { + "type":"css", + "elements":{ + "asset":"\/test.css", + "asset_type":"css", + "order":0, + "group":"head", + "position":"pipeline", + "priority":10, + "attributes":{ + "type":"text\/css", + "rel":"stylesheet" + }, + "modified":false, + "query":"" + } + }'; + self::assertJsonStringEqualsJsonString($expected, $actual); + + //test addCss(). Testing with remote URL + $this->assets->reset(); + $this->assets->addCSS('http://www.somesite.com/test.css'); + $css = $this->assets->css(); + self::assertSame('' . PHP_EOL, $css); + + $array = $this->assets->getCss(); + /** @var Assets\BaseAsset $item */ + $item = reset($array); + $actual = json_encode($item); + $expected = ' + { + "type":"css", + "elements":{ + "asset":"http:\/\/www.somesite.com\/test.css", + "asset_type":"css", + "order":0, + "group":"head", + "position":"pipeline", + "priority":10, + "attributes":{ + "type":"text\/css", + "rel":"stylesheet" + }, + "query":"" + } + }'; + self::assertJsonStringEqualsJsonString($expected, $actual); + + //test addCss() adding asset to a separate group, and with an alternate rel attribute + $this->assets->reset(); + $this->assets->addCSS('test.css', ['group' => 'alternate', 'rel' => 'alternate']); + $css = $this->assets->css('alternate'); + self::assertSame('' . PHP_EOL, $css); + + //test addJs() + $this->assets->reset(); + $this->assets->addJs('test.js'); + $js = $this->assets->js(); + self::assertSame('' . PHP_EOL, $js); + + $array = $this->assets->getJs(); + /** @var Assets\BaseAsset $item */ + $item = reset($array); + $actual = json_encode($item); + $expected = ' + { + "type":"js", + "elements":{ + "asset":"\/test.js", + "asset_type":"js", + "order":0, + "group":"head", + "position":"pipeline", + "priority":10, + "attributes":[], + "modified":false, + "query":"" + } + }'; + self::assertJsonStringEqualsJsonString($expected, $actual); + + //Test CSS Groups + $this->assets->reset(); + $this->assets->addCSS('test.css', ['group' => 'footer']); + $css = $this->assets->css(); + self::assertEmpty($css); + $css = $this->assets->css('footer'); + self::assertSame('' . PHP_EOL, $css); + + $array = $this->assets->getCss(); + /** @var Assets\BaseAsset $item */ + $item = reset($array); + $actual = json_encode($item); + $expected = ' + { + "type": "css", + "elements": { + "asset": "/test.css", + "asset_type": "css", + "order": 0, + "group": "footer", + "position": "pipeline", + "priority": 10, + "attributes": { + "type": "text/css", + "rel": "stylesheet" + }, + "modified": false, + "query": "" + } + } + '; + self::assertJsonStringEqualsJsonString($expected, $actual); + + //Test JS Groups + $this->assets->reset(); + $this->assets->addJs('test.js', ['group' => 'footer']); + $js = $this->assets->js(); + self::assertEmpty($js); + $js = $this->assets->js('footer'); + self::assertSame('' . PHP_EOL, $js); + + $array = $this->assets->getJs(); + /** @var Assets\BaseAsset $item */ + $item = reset($array); + $actual = json_encode($item); + $expected = ' + { + "type": "js", + "elements": { + "asset": "/test.js", + "asset_type": "js", + "order": 0, + "group": "footer", + "position": "pipeline", + "priority": 10, + "attributes": [], + "modified": false, + "query": "" + } + }'; + self::assertJsonStringEqualsJsonString($expected, $actual); + + //Test async / defer + $this->assets->reset(); + $this->assets->addJs('test.js', ['loading' => 'async']); + $js = $this->assets->js(); + self::assertSame('' . PHP_EOL, $js); + + $array = $this->assets->getJs(); + /** @var Assets\BaseAsset $item */ + $item = reset($array); + $actual = json_encode($item); + $expected = ' + { + "type": "js", + "elements": { + "asset": "/test.js", + "asset_type": "js", + "order": 0, + "group": "head", + "position": "pipeline", + "priority": 10, + "attributes": { + "loading": "async" + }, + "modified": false, + "query": "" + } + }'; + self::assertJsonStringEqualsJsonString($expected, $actual); + + $this->assets->reset(); + $this->assets->addJs('test.js', ['loading' => 'defer']); + $js = $this->assets->js(); + self::assertSame('' . PHP_EOL, $js); + + $array = $this->assets->getJs(); + /** @var Assets\BaseAsset $item */ + $item = reset($array); + $actual = json_encode($item); + $expected = ' + { + "type": "js", + "elements": { + "asset": "/test.js", + "asset_type": "js", + "order": 0, + "group": "head", + "position": "pipeline", + "priority": 10, + "attributes": { + "loading": "defer" + }, + "modified": false, + "query": "" + } + }'; + self::assertJsonStringEqualsJsonString($expected, $actual); + + //Test inline + $this->assets->reset(); + $this->assets->setJsPipeline(true); + $this->assets->addJs('/system/assets/jquery/jquery-3.x.min.js'); + $js = $this->assets->js('head', ['loading' => 'inline']); + self::assertStringContainsString('"jquery",[],function()', $js); + + $this->assets->reset(); + $this->assets->setCssPipeline(true); + $this->assets->addCss('/system/assets/debugger/phpdebugbar.css'); + $css = $this->assets->css('head', ['loading' => 'inline']); + self::assertStringContainsString('div.phpdebugbar', $css); + + $this->assets->reset(); + $this->assets->setCssPipeline(true); + $this->assets->addCss('https://fonts.googleapis.com/css?family=Roboto'); + $css = $this->assets->css('head', ['loading' => 'inline']); + self::assertStringContainsString('font-family:\'Roboto\';', $css); + + //Test adding media queries + $this->assets->reset(); + $this->assets->add('test.css', ['media' => 'only screen and (min-width: 640px)']); + $css = $this->assets->css(); + self::assertSame('' . PHP_EOL, $css); + } + + public function testAddingAssetPropertiesWithArray(): void + { + //Test adding assets with object to define properties + $this->assets->reset(); + $this->assets->addJs('test.js', ['loading' => 'async']); + $js = $this->assets->js(); + self::assertSame('' . PHP_EOL, $js); + $this->assets->reset(); + } + + public function testAddingJSAssetPropertiesWithArrayFromCollection(): void + { + //Test adding properties with array + $this->assets->reset(); + $this->assets->addJs('jquery', ['loading' => 'async']); + $js = $this->assets->js(); + self::assertSame('' . PHP_EOL, $js); + + //Test priority too + $this->assets->reset(); + $this->assets->addJs('jquery', ['loading' => 'async', 'priority' => 1]); + $this->assets->addJs('test.js', ['loading' => 'async', 'priority' => 2]); + $js = $this->assets->js(); + self::assertSame('' . PHP_EOL . + '' . PHP_EOL, $js); + + //Test multiple groups + $this->assets->reset(); + $this->assets->addJs('jquery', ['loading' => 'async', 'priority' => 1, 'group' => 'footer']); + $this->assets->addJs('test.js', ['loading' => 'async', 'priority' => 2]); + $js = $this->assets->js(); + self::assertSame('' . PHP_EOL, $js); + $js = $this->assets->js('footer'); + self::assertSame('' . PHP_EOL, $js); + + //Test adding array of assets + //Test priority too + $this->assets->reset(); + $this->assets->addJs(['jquery', 'test.js'], ['loading' => 'async']); + $js = $this->assets->js(); + + self::assertSame('' . PHP_EOL . + '' . PHP_EOL, $js); + } + + public function testAddingLegacyFormat(): void + { + // regular CSS add + //test addCss(). Test adding asset to a separate group + $this->assets->reset(); + $this->assets->addCSS('test.css', 15, true, 'bottom', 'async'); + $css = $this->assets->css('bottom'); + self::assertSame('' . PHP_EOL, $css); + + $array = $this->assets->getCss(); + /** @var Assets\BaseAsset $item */ + $item = reset($array); + $actual = json_encode($item); + $expected = ' + { + "type":"css", + "elements":{ + "asset":"\/test.css", + "asset_type":"css", + "order":0, + "group":"bottom", + "position":"pipeline", + "priority":15, + "attributes":{ + "type":"text\/css", + "rel":"stylesheet", + "loading":"async" + }, + "modified":false, + "query":"" + } + }'; + self::assertJsonStringEqualsJsonString($expected, $actual); + + $this->assets->reset(); + $this->assets->addJs('test.js', 15, false, 'defer', 'bottom'); + $js = $this->assets->js('bottom'); + self::assertSame('' . PHP_EOL, $js); + + $array = $this->assets->getJs(); + /** @var Assets\BaseAsset $item */ + $item = reset($array); + $actual = json_encode($item); + $expected = ' + { + "type": "js", + "elements": { + "asset": "/test.js", + "asset_type": "js", + "order": 0, + "group": "bottom", + "position": "after", + "priority": 15, + "attributes": { + "loading": "defer" + }, + "modified": false, + "query": "" + } + }'; + self::assertJsonStringEqualsJsonString($expected, $actual); + + + $this->assets->reset(); + $this->assets->addInlineCss('body { color: black }', 15, 'bottom'); + $css = $this->assets->css('bottom'); + self::assertSame('' . PHP_EOL, $css); + + $this->assets->reset(); + $this->assets->addInlineJs('alert("test")', 15, 'bottom', ['id' => 'foo']); + $js = $this->assets->js('bottom'); + self::assertSame('' . PHP_EOL, $js); + } + + public function testAddingCSSAssetPropertiesWithArrayFromCollection(): void + { + $this->assets->registerCollection('test', ['/system/assets/whoops.css']); + + //Test priority too + $this->assets->reset(); + $this->assets->addCss('test', ['priority' => 1]); + $this->assets->addCss('test.css', ['priority' => 2]); + $css = $this->assets->css(); + self::assertSame('' . PHP_EOL . + '' . PHP_EOL, $css); + + //Test multiple groups + $this->assets->reset(); + $this->assets->addCss('test', ['priority' => 1, 'group' => 'footer']); + $this->assets->addCss('test.css', ['priority' => 2]); + $css = $this->assets->css(); + self::assertSame('' . PHP_EOL, $css); + $css = $this->assets->css('footer'); + self::assertSame('' . PHP_EOL, $css); + + //Test adding array of assets + //Test priority too + $this->assets->reset(); + $this->assets->addCss(['test', 'test.css'], ['loading' => 'async']); + $css = $this->assets->css(); + self::assertSame('' . PHP_EOL . + '' . PHP_EOL, $css); + } + + public function testAddingAssetPropertiesWithArrayFromCollectionAndParameters(): void + { + $this->assets->registerCollection('collection_multi_params', [ + 'foo.js' => [ 'defer' => true ], + 'bar.js' => [ 'integrity' => 'sha512-abc123' ], + 'foobar.css' => [ 'defer' => null, 'loading' => null ] + ]); + + // # Test adding properties with array + $this->assets->addJs('collection_multi_params', ['loading' => 'async']); + $js = $this->assets->js(); + + // expected output + $expected = [ + '', + '', + '', + ]; + + self::assertCount(count($expected), array_filter(explode("\n", $js))); + self::assertSame(implode("\n", $expected) . PHP_EOL, $js); + + // # Test priority as second argument + render JS should not have any css + $this->assets->reset(); + $this->assets->add('low_priority.js', 1); + $this->assets->add('collection_multi_params', 2); + $js = $this->assets->js(); + + // expected output + $expected = [ + '', + '', + '', + ]; + + self::assertCount(3, array_filter(explode("\n", $js))); + self::assertSame(implode("\n", $expected) . PHP_EOL, $js); + + // # Test rendering CSS, should not have any JS + $this->assets->reset(); + $this->assets->add('collection_multi_params', [ 'class' => '__classname' ]); + $css = $this->assets->css(); + + // expected output + $expected = [ + '', + ]; + + + self::assertCount(1, array_filter(explode("\n", $css))); + self::assertSame(implode("\n", $expected) . PHP_EOL, $css); + } + + public function testPriorityOfAssets(): void + { + $this->assets->reset(); + $this->assets->add('test.css'); + $this->assets->add('test-after.css'); + + $css = $this->assets->css(); + self::assertSame('' . PHP_EOL . + '' . PHP_EOL, $css); + + //---------------- + $this->assets->reset(); + $this->assets->add('test-after.css', 1); + $this->assets->add('test.css', 2); + + $css = $this->assets->css(); + self::assertSame('' . PHP_EOL . + '' . PHP_EOL, $css); + + //---------------- + $this->assets->reset(); + $this->assets->add('test-after.css', 1); + $this->assets->add('test.css', 2); + $this->assets->add('test-before.css', 3); + + $css = $this->assets->css(); + self::assertSame('' . PHP_EOL . + '' . PHP_EOL . + '' . PHP_EOL, $css); + } + + public function testPipeline(): void + { + $this->assets->reset(); + + //File not existing. Pipeline searches for that file without reaching it. Output is empty. + $this->assets->add('test.css', null, true); + $this->assets->setCssPipeline(true); + $css = $this->assets->css(); + self::assertRegExp('##', $css); + + //Add a core Grav CSS file, which is found. Pipeline will now return a file + $this->assets->add('/system/assets/debugger/phpdebugbar', null, true); + $css = $this->assets->css(); + self::assertRegExp('##', $css); + } + + public function testPipelineWithTimestamp(): void + { + $this->assets->reset(); + $this->assets->setTimestamp('foo'); + $this->assets->setCssPipeline(true); + + //Add a core Grav CSS file, which is found. Pipeline will now return a file + $this->assets->add('/system/assets/debugger.css', null, true); + $css = $this->assets->css(); + self::assertRegExp('##', $css); + } + + public function testInline(): void + { + $this->assets->reset(); + + //File not existing. Pipeline searches for that file without reaching it. Output is empty. + $this->assets->add('test.css', ['loading' => 'inline']); + $css = $this->assets->css(); + self::assertSame("\n", $css); + + $this->assets->reset(); + //Add a core Grav CSS file, which is found. Pipeline will now return its content. + $this->assets->addCss('https://fonts.googleapis.com/css?family=Roboto', ['loading' => 'inline']); + $this->assets->addCss('/system/assets/debugger/phpdebugbar.css', ['loading' => 'inline']); + $css = $this->assets->css(); + self::assertStringContainsString('font-family: \'Roboto\';', $css); + self::assertStringContainsString('div.phpdebugbar-header', $css); + } + + public function testInlinePipeline(): void + { + $this->assets->reset(); + $this->assets->setCssPipeline(true); + + //File not existing. Pipeline searches for that file without reaching it. Output is empty. + $this->assets->add('test.css'); + $css = $this->assets->css('head', ['loading' => 'inline']); + self::assertSame("\n", $css); + + //Add a core Grav CSS file, which is found. Pipeline will now return its content. + $this->assets->addCss('https://fonts.googleapis.com/css?family=Roboto', null, true); + $this->assets->add('/system/assets/debugger/phpdebugbar.css', null, true); + $css = $this->assets->css('head', ['loading' => 'inline']); + self::assertStringContainsString('font-family:\'Roboto\';', $css); + self::assertStringContainsString('div.phpdebugbar', $css); + } + + public function testAddAsyncJs(): void + { + $this->assets->reset(); + $this->assets->addAsyncJs('jquery'); + $js = $this->assets->js(); + self::assertSame('' . PHP_EOL, $js); + } + + public function testAddDeferJs(): void + { + $this->assets->reset(); + $this->assets->addDeferJs('jquery'); + $js = $this->assets->js(); + self::assertSame('' . PHP_EOL, $js); + } + + public function testTimestamps(): void + { + // local CSS nothing extra + $this->assets->reset(); + $this->assets->setTimestamp('foo'); + $this->assets->addCSS('test.css'); + $css = $this->assets->css(); + self::assertSame('' . PHP_EOL, $css); + + // local CSS already with param + $this->assets->reset(); + $this->assets->setTimestamp('foo'); + $this->assets->addCSS('test.css?bar'); + $css = $this->assets->css(); + self::assertSame('' . PHP_EOL, $css); + + // external CSS already + $this->assets->reset(); + $this->assets->setTimestamp('foo'); + $this->assets->addCSS('http://somesite.com/test.css'); + $css = $this->assets->css(); + self::assertSame('' . PHP_EOL, $css); + + // external CSS already with param + $this->assets->reset(); + $this->assets->setTimestamp('foo'); + $this->assets->addCSS('http://somesite.com/test.css?bar'); + $css = $this->assets->css(); + self::assertSame('' . PHP_EOL, $css); + + // local JS nothing extra + $this->assets->reset(); + $this->assets->setTimestamp('foo'); + $this->assets->addJs('test.js'); + $css = $this->assets->js(); + self::assertSame('' . PHP_EOL, $css); + + // local JS already with param + $this->assets->reset(); + $this->assets->setTimestamp('foo'); + $this->assets->addJs('test.js?bar'); + $css = $this->assets->js(); + self::assertSame('' . PHP_EOL, $css); + + // external JS already + $this->assets->reset(); + $this->assets->setTimestamp('foo'); + $this->assets->addJs('http://somesite.com/test.js'); + $css = $this->assets->js(); + self::assertSame('' . PHP_EOL, $css); + + // external JS already with param + $this->assets->reset(); + $this->assets->setTimestamp('foo'); + $this->assets->addJs('http://somesite.com/test.js?bar'); + $css = $this->assets->js(); + self::assertSame('' . PHP_EOL, $css); + } + + public function testAddInlineCss(): void + { + $this->assets->reset(); + $this->assets->addInlineCss('body { color: black }'); + $css = $this->assets->css(); + self::assertSame('' . PHP_EOL, $css); + } + + public function testAddInlineJs(): void + { + $this->assets->reset(); + $this->assets->addInlineJs('alert("test")'); + $js = $this->assets->js(); + self::assertSame('' . PHP_EOL, $js); + } + + public function testGetCollections(): void + { + self::assertIsArray($this->assets->getCollections()); + self::assertContains('jquery', array_keys($this->assets->getCollections())); + self::assertContains('system://assets/jquery/jquery-3.x.min.js', $this->assets->getCollections()); + } + + public function testExists(): void + { + self::assertTrue($this->assets->exists('jquery')); + self::assertFalse($this->assets->exists('another-unexisting-library')); + } + + public function testRegisterCollection(): void + { + $this->assets->registerCollection('debugger', ['/system/assets/debugger.css']); + self::assertTrue($this->assets->exists('debugger')); + self::assertContains('debugger', array_keys($this->assets->getCollections())); + } + + public function testRegisterCollectionWithParameters(): void + { + $this->assets->registerCollection('collection_multi_params', [ + 'foo.js' => [ 'defer' => true ], + 'bar.js' => [ 'integrity' => 'sha512-abc123' ], + 'foobar.css' => [ 'defer' => null ], + ]); + + self::assertTrue($this->assets->exists('collection_multi_params')); + + $collection = $this->assets->getCollections()['collection_multi_params']; + self::assertArrayHasKey('foo.js', $collection); + self::assertArrayHasKey('bar.js', $collection); + self::assertArrayHasKey('foobar.css', $collection); + self::assertArrayHasKey('defer', $collection['foo.js']); + self::assertArrayHasKey('defer', $collection['foobar.css']); + + self::assertNull($collection['foobar.css']['defer']); + self::assertTrue($collection['foo.js']['defer']); + } + + public function testReset(): void + { + $this->assets->addInlineJs('alert("test")'); + $this->assets->reset(); + self::assertCount(0, (array) $this->assets->getJs()); + + $this->assets->addAsyncJs('jquery'); + $this->assets->reset(); + self::assertCount(0, (array) $this->assets->getJs()); + + $this->assets->addInlineCss('body { color: black }'); + $this->assets->reset(); + self::assertCount(0, (array) $this->assets->getCss()); + + $this->assets->add('/system/assets/debugger.css', null, true); + $this->assets->reset(); + self::assertCount(0, (array) $this->assets->getCss()); + } + + public function testResetJs(): void + { + $this->assets->addInlineJs('alert("test")'); + $this->assets->resetJs(); + self::assertCount(0, (array) $this->assets->getJs()); + + $this->assets->addAsyncJs('jquery'); + $this->assets->resetJs(); + self::assertCount(0, (array) $this->assets->getJs()); + } + + public function testResetCss(): void + { + $this->assets->addInlineCss('body { color: black }'); + $this->assets->resetCss(); + self::assertCount(0, (array) $this->assets->getCss()); + + $this->assets->add('/system/assets/debugger.css', null, true); + $this->assets->resetCss(); + self::assertCount(0, (array) $this->assets->getCss()); + } + + public function testAddDirCss(): void + { + $this->assets->addDirCss('/system'); + + self::assertIsArray($this->assets->getCss()); + self::assertGreaterThan(0, (array) $this->assets->getCss()); + self::assertIsArray($this->assets->getJs()); + self::assertCount(0, (array) $this->assets->getJs()); + + $this->assets->reset(); + $this->assets->addDirCss('/system/assets'); + + self::assertIsArray($this->assets->getCss()); + self::assertGreaterThan(0, (array) $this->assets->getCss()); + self::assertIsArray($this->assets->getJs()); + self::assertCount(0, (array) $this->assets->getJs()); + + $this->assets->reset(); + $this->assets->addDirJs('/system'); + + self::assertIsArray($this->assets->getCss()); + self::assertCount(0, (array) $this->assets->getCss()); + self::assertIsArray($this->assets->getJs()); + self::assertGreaterThan(0, (array) $this->assets->getJs()); + + $this->assets->reset(); + $this->assets->addDirJs('/system/assets'); + + self::assertIsArray($this->assets->getCss()); + self::assertCount(0, (array) $this->assets->getCss()); + self::assertIsArray($this->assets->getJs()); + self::assertGreaterThan(0, (array) $this->assets->getJs()); + + $this->assets->reset(); + $this->assets->addDir('/system/assets'); + + self::assertIsArray($this->assets->getCss()); + self::assertGreaterThan(0, (array) $this->assets->getCss()); + self::assertIsArray($this->assets->getJs()); + self::assertGreaterThan(0, (array) $this->assets->getJs()); + + //Use streams + $this->assets->reset(); + $this->assets->addDir('system://assets'); + + self::assertIsArray($this->assets->getCss()); + self::assertGreaterThan(0, (array) $this->assets->getCss()); + self::assertIsArray($this->assets->getJs()); + self::assertGreaterThan(0, (array) $this->assets->getJs()); + } +} diff --git a/tests/unit/Grav/Common/BrowserTest.php b/tests/unit/Grav/Common/BrowserTest.php new file mode 100644 index 0000000..a1033d8 --- /dev/null +++ b/tests/unit/Grav/Common/BrowserTest.php @@ -0,0 +1,51 @@ +grav = $grav(); + } + + protected function _after(): void + { + } + + public function testGetBrowser(): void + { + /* Already covered by PhpUserAgent tests */ + } + + public function testGetPlatform(): void + { + /* Already covered by PhpUserAgent tests */ + } + + public function testGetLongVersion(): void + { + /* Already covered by PhpUserAgent tests */ + } + + public function testGetVersion(): void + { + /* Already covered by PhpUserAgent tests */ + } + + public function testIsHuman(): void + { + //Already Partially covered by PhpUserAgent tests + + //Make sure it recognizes the test as not human + self::assertFalse($this->grav['browser']->isHuman()); + } +} diff --git a/tests/unit/Grav/Common/ComposerTest.php b/tests/unit/Grav/Common/ComposerTest.php new file mode 100644 index 0000000..8c73a1f --- /dev/null +++ b/tests/unit/Grav/Common/ComposerTest.php @@ -0,0 +1,31 @@ +loadBlueprint('strict'); + + $blueprint->validate(['test' => 'string']); + } + + /** + * @depends testValidateStrict + */ + public function testValidateStrictRequired(): void + { + $blueprint = $this->loadBlueprint('strict'); + + $this->expectException(\Grav\Common\Data\ValidationException::class); + $blueprint->validate([]); + } + + /** + * @depends testValidateStrict + */ + public function testValidateStrictExtra(): void + { + $blueprint = $this->loadBlueprint('strict'); + + $blueprint->validate(['test' => 'string', 'wrong' => 'field']); + } + + /** + * @depends testValidateStrict + */ + public function testValidateStrictExtraException(): void + { + $blueprint = $this->loadBlueprint('strict'); + + /** @var Config $config */ + $config = Grav::instance()['config']; + $var = 'system.strict_mode.blueprint_strict_compat'; + $config->set($var, false); + + $this->expectException(\Grav\Common\Data\ValidationException::class); + $blueprint->validate(['test' => 'string', 'wrong' => 'field']); + + $config->set($var, true); + } + + /** + * @param string $filename + * @return Blueprint + */ + protected function loadBlueprint($filename): Blueprint + { + $blueprint = new Blueprint('strict'); + $blueprint->setContext(dirname(__DIR__, 3). '/data/blueprints'); + $blueprint->load()->init(); + + return $blueprint; + } +} diff --git a/tests/unit/Grav/Common/GPM/GPMTest.php b/tests/unit/Grav/Common/GPM/GPMTest.php new file mode 100644 index 0000000..684ed3d --- /dev/null +++ b/tests/unit/Grav/Common/GPM/GPMTest.php @@ -0,0 +1,329 @@ +data[$search] ?? false; + } + + /** + * @inheritdoc + */ + public function findPackages($searches = []) + { + return $this->data; + } +} + +/** + * Class InstallCommandTest + */ +class GpmTest extends \Codeception\TestCase\Test +{ + /** @var Grav $grav */ + protected $grav; + + /** @var GpmStub */ + protected $gpm; + + protected function _before(): void + { + $this->grav = Fixtures::get('grav'); + $this->gpm = new GpmStub(); + } + + protected function _after(): void + { + } + + public function testCalculateMergedDependenciesOfPackages(): void + { + ////////////////////////////////////////////////////////////////////////////////////////// + // First working example + ////////////////////////////////////////////////////////////////////////////////////////// + $this->gpm->data = [ + 'admin' => (object)[ + 'dependencies' => [ + ['name' => 'grav', 'version' => '>=1.0.10'], + ['name' => 'form', 'version' => '~2.0'], + ['name' => 'login', 'version' => '>=2.0'], + ['name' => 'errors', 'version' => '*'], + ['name' => 'problems'], + ] + ], + 'test' => (object)[ + 'dependencies' => [ + ['name' => 'errors', 'version' => '>=1.0'] + ] + ], + 'grav', + 'form' => (object)[ + 'dependencies' => [ + ['name' => 'errors', 'version' => '>=3.2'] + ] + ] + + + ]; + + $packages = ['admin', 'test']; + + $dependencies = $this->gpm->calculateMergedDependenciesOfPackages($packages); + + self::assertIsArray($dependencies); + self::assertCount(5, $dependencies); + + self::assertSame('>=1.0.10', $dependencies['grav']); + self::assertArrayHasKey('errors', $dependencies); + self::assertArrayHasKey('problems', $dependencies); + + ////////////////////////////////////////////////////////////////////////////////////////// + // Second working example + ////////////////////////////////////////////////////////////////////////////////////////// + $packages = ['admin', 'form']; + + $dependencies = $this->gpm->calculateMergedDependenciesOfPackages($packages); + self::assertIsArray($dependencies); + self::assertCount(5, $dependencies); + self::assertSame('>=3.2', $dependencies['errors']); + + ////////////////////////////////////////////////////////////////////////////////////////// + // Third working example + ////////////////////////////////////////////////////////////////////////////////////////// + $this->gpm->data = [ + + 'admin' => (object)[ + 'dependencies' => [ + ['name' => 'errors', 'version' => '>=4.0'], + ] + ], + 'test' => (object)[ + 'dependencies' => [ + ['name' => 'errors', 'version' => '>=1.0'] + ] + ], + 'another' => (object)[ + 'dependencies' => [ + ['name' => 'errors', 'version' => '>=3.2'] + ] + ] + + ]; + + $packages = ['admin', 'test', 'another']; + + + $dependencies = $this->gpm->calculateMergedDependenciesOfPackages($packages); + self::assertIsArray($dependencies); + self::assertCount(1, $dependencies); + self::assertSame('>=4.0', $dependencies['errors']); + + + + ////////////////////////////////////////////////////////////////////////////////////////// + // Test alpha / beta / rc + ////////////////////////////////////////////////////////////////////////////////////////// + $this->gpm->data = [ + 'admin' => (object)[ + 'dependencies' => [ + ['name' => 'package1', 'version' => '>=4.0.0-rc1'], + ['name' => 'package4', 'version' => '>=3.2.0'], + ] + ], + 'test' => (object)[ + 'dependencies' => [ + ['name' => 'package1', 'version' => '>=4.0.0-rc2'], + ['name' => 'package2', 'version' => '>=3.2.0-alpha'], + ['name' => 'package3', 'version' => '>=3.2.0-alpha.2'], + ['name' => 'package4', 'version' => '>=3.2.0-alpha'], + ] + ], + 'another' => (object)[ + 'dependencies' => [ + ['name' => 'package2', 'version' => '>=3.2.0-beta.11'], + ['name' => 'package3', 'version' => '>=3.2.0-alpha.1'], + ['name' => 'package4', 'version' => '>=3.2.0-beta'], + ] + ] + ]; + + $packages = ['admin', 'test', 'another']; + + + $dependencies = $this->gpm->calculateMergedDependenciesOfPackages($packages); + self::assertSame('>=4.0.0-rc2', $dependencies['package1']); + self::assertSame('>=3.2.0-beta.11', $dependencies['package2']); + self::assertSame('>=3.2.0-alpha.2', $dependencies['package3']); + self::assertSame('>=3.2.0', $dependencies['package4']); + + + ////////////////////////////////////////////////////////////////////////////////////////// + // Raise exception if no version is specified + ////////////////////////////////////////////////////////////////////////////////////////// + $this->gpm->data = [ + + 'admin' => (object)[ + 'dependencies' => [ + ['name' => 'errors', 'version' => '>=4.0'], + ] + ], + 'test' => (object)[ + 'dependencies' => [ + ['name' => 'errors', 'version' => '>='] + ] + ], + + ]; + + $packages = ['admin', 'test']; + + try { + $this->gpm->calculateMergedDependenciesOfPackages($packages); + self::fail('Expected Exception not thrown'); + } catch (Exception $e) { + self::assertEquals(EXCEPTION_BAD_FORMAT, $e->getCode()); + self::assertStringStartsWith('Bad format for version of dependency', $e->getMessage()); + } + + ////////////////////////////////////////////////////////////////////////////////////////// + // Raise exception if incompatible versions are specified + ////////////////////////////////////////////////////////////////////////////////////////// + $this->gpm->data = [ + 'admin' => (object)[ + 'dependencies' => [ + ['name' => 'errors', 'version' => '~4.0'], + ] + ], + 'test' => (object)[ + 'dependencies' => [ + ['name' => 'errors', 'version' => '~3.0'] + ] + ], + ]; + + $packages = ['admin', 'test']; + + try { + $this->gpm->calculateMergedDependenciesOfPackages($packages); + self::fail('Expected Exception not thrown'); + } catch (Exception $e) { + self::assertEquals(EXCEPTION_INCOMPATIBLE_VERSIONS, $e->getCode()); + self::assertStringEndsWith('required in two incompatible versions', $e->getMessage()); + } + + ////////////////////////////////////////////////////////////////////////////////////////// + // Test dependencies of dependencies + ////////////////////////////////////////////////////////////////////////////////////////// + $this->gpm->data = [ + 'admin' => (object)[ + 'dependencies' => [ + ['name' => 'grav', 'version' => '>=1.0.10'], + ['name' => 'form', 'version' => '~2.0'], + ['name' => 'login', 'version' => '>=2.0'], + ['name' => 'errors', 'version' => '*'], + ['name' => 'problems'], + ] + ], + 'login' => (object)[ + 'dependencies' => [ + ['name' => 'antimatter', 'version' => '>=1.0'] + ] + ], + 'grav', + 'antimatter' => (object)[ + 'dependencies' => [ + ['name' => 'something', 'version' => '>=3.2'] + ] + ] + + + ]; + + $packages = ['admin']; + + $dependencies = $this->gpm->calculateMergedDependenciesOfPackages($packages); + + self::assertIsArray($dependencies); + self::assertCount(7, $dependencies); + + self::assertSame('>=1.0.10', $dependencies['grav']); + self::assertArrayHasKey('errors', $dependencies); + self::assertArrayHasKey('problems', $dependencies); + self::assertArrayHasKey('antimatter', $dependencies); + self::assertArrayHasKey('something', $dependencies); + self::assertSame('>=3.2', $dependencies['something']); + } + + public function testVersionFormatIsNextSignificantRelease(): void + { + self::assertFalse($this->gpm->versionFormatIsNextSignificantRelease('>=1.0')); + self::assertFalse($this->gpm->versionFormatIsNextSignificantRelease('>=2.3.4')); + self::assertFalse($this->gpm->versionFormatIsNextSignificantRelease('>=2.3.x')); + self::assertFalse($this->gpm->versionFormatIsNextSignificantRelease('1.0')); + self::assertTrue($this->gpm->versionFormatIsNextSignificantRelease('~2.3.x')); + self::assertTrue($this->gpm->versionFormatIsNextSignificantRelease('~2.0')); + } + + public function testVersionFormatIsEqualOrHigher(): void + { + self::assertTrue($this->gpm->versionFormatIsEqualOrHigher('>=1.0')); + self::assertTrue($this->gpm->versionFormatIsEqualOrHigher('>=2.3.4')); + self::assertTrue($this->gpm->versionFormatIsEqualOrHigher('>=2.3.x')); + self::assertFalse($this->gpm->versionFormatIsEqualOrHigher('~2.3.x')); + self::assertFalse($this->gpm->versionFormatIsEqualOrHigher('1.0')); + } + + public function testCheckNextSignificantReleasesAreCompatible(): void + { + /* + * ~1.0 is equivalent to >=1.0 < 2.0.0 + * ~1.2 is equivalent to >=1.2 <2.0.0 + * ~1.2.3 is equivalent to >=1.2.3 <1.3.0 + */ + self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('1.0', '1.2')); + self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('1.2', '1.0')); + self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('1.0', '1.0.10')); + self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('1.1', '1.1.10')); + self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('30.0', '30.10')); + self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('1.0', '1.1.10')); + self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('1.0', '1.8')); + self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('1.0.1', '1.1')); + self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('2.0.0-beta', '2.0')); + self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('2.0.0-rc.1', '2.0')); + self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('2.0', '2.0.0-alpha')); + + self::assertFalse($this->gpm->checkNextSignificantReleasesAreCompatible('1.0', '2.2')); + self::assertFalse($this->gpm->checkNextSignificantReleasesAreCompatible('1.0.0-beta.1', '2.0')); + self::assertFalse($this->gpm->checkNextSignificantReleasesAreCompatible('0.9.99', '1.0.0')); + self::assertFalse($this->gpm->checkNextSignificantReleasesAreCompatible('0.9.99', '1.0.10')); + self::assertFalse($this->gpm->checkNextSignificantReleasesAreCompatible('0.9.99', '1.0.10.2')); + } + + public function testCalculateVersionNumberFromDependencyVersion(): void + { + self::assertSame('2.0', $this->gpm->calculateVersionNumberFromDependencyVersion('>=2.0')); + self::assertSame('2.0.2', $this->gpm->calculateVersionNumberFromDependencyVersion('>=2.0.2')); + self::assertSame('2.0.2', $this->gpm->calculateVersionNumberFromDependencyVersion('~2.0.2')); + self::assertSame('1', $this->gpm->calculateVersionNumberFromDependencyVersion('~1')); + self::assertNull($this->gpm->calculateVersionNumberFromDependencyVersion('')); + self::assertNull($this->gpm->calculateVersionNumberFromDependencyVersion('*')); + self::assertSame('2.0.2', $this->gpm->calculateVersionNumberFromDependencyVersion('2.0.2')); + } +} diff --git a/tests/unit/Grav/Common/Helpers/ExcerptsTest.php b/tests/unit/Grav/Common/Helpers/ExcerptsTest.php new file mode 100644 index 0000000..8cb473c --- /dev/null +++ b/tests/unit/Grav/Common/Helpers/ExcerptsTest.php @@ -0,0 +1,120 @@ +grav = $grav(); + $this->pages = $this->grav['pages']; + $this->config = $this->grav['config']; + $this->uri = $this->grav['uri']; + $this->language = $this->grav['language']; + $this->old_home = $this->config->get('system.home.alias'); + $this->config->set('system.home.alias', '/item1'); + $this->config->set('system.absolute_urls', false); + $this->config->set('system.languages.supported', []); + + unset($this->grav['language']); + $this->grav['language'] = new Language($this->grav); + + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + $locator->addPath('page', '', 'tests/fake/nested-site/user/pages', false); + $this->pages->init(); + + $defaults = [ + 'extra' => false, + 'auto_line_breaks' => false, + 'auto_url_links' => false, + 'escape_markup' => false, + 'special_chars' => ['>' => 'gt', '<' => 'lt'], + ]; + $this->page = $this->pages->find('/item2/item2-2'); + $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init(); + } + + protected function _after(): void + { + $this->config->set('system.home.alias', $this->old_home); + } + + + public function testProcessImageHtml(): void + { + self::assertRegexp( + '|Sample Image|', + Excerpts::processImageHtml('Sample Image', $this->page) + ); + self::assertRegexp( + '|Sample Image|', + Excerpts::processImageHtml('Sample Image', $this->page) + ); + } + + public function testNoProcess(): void + { + self::assertStringStartsWith( + 'regular process') + ); + + self::assertStringStartsWith( + 'noprocess') + ); + + self::assertStringStartsWith( + 'noprocess=id') + ); + } + + public function testTarget(): void + { + self::assertStringStartsWith( + 'only target') + ); + self::assertStringStartsWith( + 'target and rel') + ); + } +} diff --git a/tests/unit/Grav/Common/InflectorTest.php b/tests/unit/Grav/Common/InflectorTest.php new file mode 100644 index 0000000..63e6e99 --- /dev/null +++ b/tests/unit/Grav/Common/InflectorTest.php @@ -0,0 +1,147 @@ +grav = $grav(); + $this->inflector = $this->grav['inflector']; + } + + protected function _after(): void + { + } + + public function testPluralize(): void + { + self::assertSame('words', $this->inflector->pluralize('word')); + self::assertSame('kisses', $this->inflector->pluralize('kiss')); + self::assertSame('volcanoes', $this->inflector->pluralize('volcanoe')); + self::assertSame('cherries', $this->inflector->pluralize('cherry')); + self::assertSame('days', $this->inflector->pluralize('day')); + self::assertSame('knives', $this->inflector->pluralize('knife')); + } + + public function testSingularize(): void + { + self::assertSame('word', $this->inflector->singularize('words')); + self::assertSame('kiss', $this->inflector->singularize('kisses')); + self::assertSame('volcanoe', $this->inflector->singularize('volcanoe')); + self::assertSame('cherry', $this->inflector->singularize('cherries')); + self::assertSame('day', $this->inflector->singularize('days')); + self::assertSame('knife', $this->inflector->singularize('knives')); + } + + public function testTitleize(): void + { + self::assertSame('This String Is Titleized', $this->inflector->titleize('ThisStringIsTitleized')); + self::assertSame('This String Is Titleized', $this->inflector->titleize('this string is titleized')); + self::assertSame('This String Is Titleized', $this->inflector->titleize('this_string_is_titleized')); + self::assertSame('This String Is Titleized', $this->inflector->titleize('this-string-is-titleized')); + self::assertSame('Échelle Synoptique', $this->inflector->titleize('échelle synoptique')); + + self::assertSame('This string is titleized', $this->inflector->titleize('ThisStringIsTitleized', 'first')); + self::assertSame('This string is titleized', $this->inflector->titleize('this string is titleized', 'first')); + self::assertSame('This string is titleized', $this->inflector->titleize('this_string_is_titleized', 'first')); + self::assertSame('This string is titleized', $this->inflector->titleize('this-string-is-titleized', 'first')); + self::assertSame('Échelle synoptique', $this->inflector->titleize('échelle synoptique', 'first')); + } + + public function testCamelize(): void + { + self::assertSame('ThisStringIsCamelized', $this->inflector->camelize('This String Is Camelized')); + self::assertSame('ThisStringIsCamelized', $this->inflector->camelize('thisStringIsCamelized')); + self::assertSame('ThisStringIsCamelized', $this->inflector->camelize('This_String_Is_Camelized')); + self::assertSame('ThisStringIsCamelized', $this->inflector->camelize('this string is camelized')); + self::assertSame('GravSPrettyCoolMy1', $this->inflector->camelize("Grav's Pretty Cool. My #1!")); + } + + public function testUnderscorize(): void + { + self::assertSame('this_string_is_underscorized', $this->inflector->underscorize('This String Is Underscorized')); + self::assertSame('this_string_is_underscorized', $this->inflector->underscorize('ThisStringIsUnderscorized')); + self::assertSame('this_string_is_underscorized', $this->inflector->underscorize('This_String_Is_Underscorized')); + self::assertSame('this_string_is_underscorized', $this->inflector->underscorize('This-String-Is-Underscorized')); + } + + public function testHyphenize(): void + { + self::assertSame('this-string-is-hyphenized', $this->inflector->hyphenize('This String Is Hyphenized')); + self::assertSame('this-string-is-hyphenized', $this->inflector->hyphenize('ThisStringIsHyphenized')); + self::assertSame('this-string-is-hyphenized', $this->inflector->hyphenize('This-String-Is-Hyphenized')); + self::assertSame('this-string-is-hyphenized', $this->inflector->hyphenize('This_String_Is_Hyphenized')); + } + + public function testHumanize(): void + { + //self::assertSame('This string is humanized', $this->inflector->humanize('ThisStringIsHumanized')); + self::assertSame('This string is humanized', $this->inflector->humanize('this_string_is_humanized')); + //self::assertSame('This string is humanized', $this->inflector->humanize('this-string-is-humanized')); + + self::assertSame('This String Is Humanized', $this->inflector->humanize('this_string_is_humanized', 'all')); + //self::assertSame('This String Is Humanized', $this->inflector->humanize('this-string-is-humanized'), 'all'); + } + + public function testVariablize(): void + { + self::assertSame('thisStringIsVariablized', $this->inflector->variablize('This String Is Variablized')); + self::assertSame('thisStringIsVariablized', $this->inflector->variablize('ThisStringIsVariablized')); + self::assertSame('thisStringIsVariablized', $this->inflector->variablize('This_String_Is_Variablized')); + self::assertSame('thisStringIsVariablized', $this->inflector->variablize('this string is variablized')); + self::assertSame('gravSPrettyCoolMy1', $this->inflector->variablize("Grav's Pretty Cool. My #1!")); + } + + public function testTableize(): void + { + self::assertSame('people', $this->inflector->tableize('Person')); + self::assertSame('pages', $this->inflector->tableize('Page')); + self::assertSame('blog_pages', $this->inflector->tableize('BlogPage')); + self::assertSame('admin_dependencies', $this->inflector->tableize('adminDependency')); + self::assertSame('admin_dependencies', $this->inflector->tableize('admin-dependency')); + self::assertSame('admin_dependencies', $this->inflector->tableize('admin_dependency')); + } + + public function testClassify(): void + { + self::assertSame('Person', $this->inflector->classify('people')); + self::assertSame('Page', $this->inflector->classify('pages')); + self::assertSame('BlogPage', $this->inflector->classify('blog_pages')); + self::assertSame('AdminDependency', $this->inflector->classify('admin_dependencies')); + } + + public function testOrdinalize(): void + { + self::assertSame('1st', $this->inflector->ordinalize(1)); + self::assertSame('2nd', $this->inflector->ordinalize(2)); + self::assertSame('3rd', $this->inflector->ordinalize(3)); + self::assertSame('4th', $this->inflector->ordinalize(4)); + self::assertSame('5th', $this->inflector->ordinalize(5)); + self::assertSame('16th', $this->inflector->ordinalize(16)); + self::assertSame('51st', $this->inflector->ordinalize(51)); + self::assertSame('111th', $this->inflector->ordinalize(111)); + self::assertSame('123rd', $this->inflector->ordinalize(123)); + } + + public function testMonthize(): void + { + self::assertSame(0, $this->inflector->monthize(10)); + self::assertSame(1, $this->inflector->monthize(33)); + self::assertSame(1, $this->inflector->monthize(41)); + self::assertSame(11, $this->inflector->monthize(364)); + } +} diff --git a/tests/unit/Grav/Common/Language/LanguageCodesTest.php b/tests/unit/Grav/Common/Language/LanguageCodesTest.php new file mode 100644 index 0000000..3450c88 --- /dev/null +++ b/tests/unit/Grav/Common/Language/LanguageCodesTest.php @@ -0,0 +1,27 @@ +grav = $grav(); + $this->pages = $this->grav['pages']; + $this->config = $this->grav['config']; + $this->uri = $this->grav['uri']; + $this->language = $this->grav['language']; + $this->old_home = $this->config->get('system.home.alias'); + $this->config->set('system.home.alias', '/item1'); + $this->config->set('system.absolute_urls', false); + $this->config->set('system.languages.supported', []); + + unset($this->grav['language']); + $this->grav['language'] = new Language($this->grav); + + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + $locator->addPath('page', '', 'tests/fake/nested-site/user/pages', false); + $this->pages->init(); + + $defaults = [ + 'markdown' => [ + 'extra' => false, + 'auto_line_breaks' => false, + 'auto_url_links' => false, + 'escape_markup' => false, + 'special_chars' => ['>' => 'gt', '<' => 'lt'], + ], + 'images' => $this->config->get('system.images', []) + ]; + $page = $this->pages->find('/item2/item2-2'); + + $excerpts = new Excerpts($page, $defaults); + $this->parsedown = new Parsedown($excerpts); + } + + protected function _after(): void + { + $this->config->set('system.home.alias', $this->old_home); + } + + public function testImages(): void + { + $this->config->set('system.languages.supported', ['fr','en']); + unset($this->grav['language']); + $this->grav['language'] = new Language($this->grav); + $this->uri->initializeWithURL('http://testing.dev/fr/item2/item2-2')->init(); + + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg)') + ); + self::assertRegexp( + '|

    <\/p>|', + $this->parsedown->text('![](cache-image.jpg?cropResize=200,200&foo)') + ); + + $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init(); + + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg)') + ); + self::assertRegexp( + '|

    <\/p>|', + $this->parsedown->text('![](cache-image.jpg?cropResize=200,200&foo)') + ); + self::assertRegexp( + '|

    <\/p>|', + $this->parsedown->text('![](/home-cache-image.jpg?cache)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](missing-image.jpg)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](/home-missing-image.jpg)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](/home-missing-image.jpg)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](https://getgrav-grav.netdna-ssl.com/user/pages/media/grav-logo.svg)') + ); + } + + public function testImagesSubDir(): void + { + $this->config->set('system.images.cache_all', false); + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init(); + + self::assertRegexp( + '|

    <\/p>|', + $this->parsedown->text('![](/home-cache-image.jpg?cache)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg)') + ); + self::assertRegexp( + '|

    <\/p>|', + $this->parsedown->text('![](cache-image.jpg?cache)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](missing-image.jpg)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](/home-missing-image.jpg)') + ); + } + + public function testImagesAbsoluteUrls(): void + { + $this->config->set('system.absolute_urls', true); + $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init(); + + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg)') + ); + self::assertRegexp( + '|

    <\/p>|', + $this->parsedown->text('![](cache-image.jpg?cache)') + ); + self::assertRegexp( + '|

    <\/p>|', + $this->parsedown->text('![](/home-cache-image.jpg?cache)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](missing-image.jpg)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](/home-missing-image.jpg)') + ); + } + + public function testImagesSubDirAbsoluteUrls(): void + { + $this->config->set('system.absolute_urls', true); + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init(); + + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg)') + ); + self::assertRegexp( + '|

    <\/p>|', + $this->parsedown->text('![](cache-image.jpg?cache)') + ); + self::assertRegexp( + '|

    <\/p>|', + $this->parsedown->text('![](/home-cache-image.jpg?cropResize=200,200)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](missing-image.jpg)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](/home-missing-image.jpg)') + ); + } + + public function testImagesAttributes(): void + { + $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init(); + + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg "My Title")') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg?classes=foo)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg?classes=foo,bar)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg?id=foo)') + ); + self::assertSame( + '

    Alt Text

    ', + $this->parsedown->text('![Alt Text](sample-image.jpg?id=foo)') + ); + self::assertSame( + '

    Alt Text

    ', + $this->parsedown->text('![Alt Text](sample-image.jpg?class=bar&id=foo)') + ); + self::assertSame( + '

    Alt Text

    ', + $this->parsedown->text('![Alt Text](sample-image.jpg?class=bar&id=foo "My Title")') + ); + } + + public function testImagesDefaults(): void + { + /** + * Testing default 'loading' + */ + + $this->setImagesDefaults(['loading' => 'auto']); + + + // loading should NOT be added to image by default + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg)') + ); + + // loading="lazy" should be added when default is overridden by ?loading=lazy + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg?loading=lazy)') + ); + + $this->setImagesDefaults(['loading' => 'lazy']); + + // loading="lazy" should be added by default + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg)') + ); + + // loading should not be added when default is overridden by ?loading=auto + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg?loading=auto)') + ); + + // loading="eager" should be added when default is overridden by ?loading=eager + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg?loading=eager)') + ); + + } + + public function testCLSAutoSizes(): void + { + $this->config->set('system.images.cls.auto_sizes', false); + $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init(); + + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg)') + ); + + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg?height=1&width=1)') + ); + + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg?autoSizes=true)') + ); + + $this->config->set('system.images.cls.auto_sizes', true); + + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg?reset)') + ); + + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg?height=1&width=1)') + ); + + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg?autoSizes=false)') + ); + + self::assertRegExp( + '/width="400" height="200"/', + $this->parsedown->text('![](sample-image.jpg?reset&resize=400,200)') + ); + + $this->config->set('system.images.cls.retina_scale', 2); + + + self::assertRegExp( + '/width="400" height="200"/', + $this->parsedown->text('![](sample-image.jpg?reset&resize=800,400)') + ); + + $this->config->set('system.images.cls.retina_scale', 4); + + self::assertRegExp( + '/width="200" height="100"/', + $this->parsedown->text('![](sample-image.jpg?reset&resize=800,400)') + ); + + self::assertRegExp( + '/width="266" height="133"/', + $this->parsedown->text('![](sample-image.jpg?reset&resize=800,400&retinaScale=3)') + ); + + $this->config->set('system.images.cls.aspect_ratio', true); + + self::assertRegExp( + '/style="--aspect-ratio: 800\/400;"/', + $this->parsedown->text('![](sample-image.jpg?reset&resize=800,400)') + ); + + $this->config->set('system.images.cls.aspect_ratio', false); + + self::assertRegExp( + '/style="--aspect-ratio: 800\/400;"/', + $this->parsedown->text('![](sample-image.jpg?reset&resize=800,400&aspectRatio=true)') + ); + + } + + public function testRootImages(): void + { + $this->uri->initializeWithURL('http://testing.dev/')->init(); + + $defaults = [ + 'markdown' => [ + 'extra' => false, + 'auto_line_breaks' => false, + 'auto_url_links' => false, + 'escape_markup' => false, + 'special_chars' => ['>' => 'gt', '<' => 'lt'], + ], + 'images' => $this->config->get('system.images', []) + ]; + $page = $this->pages->find('/'); + $excerpts = new Excerpts($page, $defaults); + $this->parsedown = new Parsedown($excerpts); + + self::assertSame( + '

    ', + $this->parsedown->text('![](home-sample-image.jpg)') + ); + self::assertRegexp( + '|

    <\/p>|', + $this->parsedown->text('![](home-cache-image.jpg?cache)') + ); + self::assertRegexp( + '|

    <\/p>|', + $this->parsedown->text('![](home-cache-image.jpg?cropResize=200,200&foo)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](/home-missing-image.jpg)') + ); + + $this->config->set('system.languages.supported', ['fr','en']); + unset($this->grav['language']); + $this->grav['language'] = new Language($this->grav); + $this->uri->initializeWithURL('http://testing.dev/fr/item2/item2-2')->init(); + + self::assertSame( + '

    ', + $this->parsedown->text('![](home-sample-image.jpg)') + ); + } + + public function testRootImagesSubDirAbsoluteUrls(): void + { + $this->config->set('system.absolute_urls', true); + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init(); + + self::assertSame( + '

    ', + $this->parsedown->text('![](sample-image.jpg)') + ); + self::assertRegexp( + '|

    <\/p>|', + $this->parsedown->text('![](cache-image.jpg?cache)') + ); + self::assertRegexp( + '|

    <\/p>|', + $this->parsedown->text('![](/home-cache-image.jpg?cropResize=200,200)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](missing-image.jpg)') + ); + self::assertSame( + '

    ', + $this->parsedown->text('![](/home-missing-image.jpg)') + ); + } + + public function testRootAbsoluteLinks(): void + { + $this->uri->initializeWithURL('http://testing.dev/')->init(); + + $defaults = [ + 'markdown' => [ + 'extra' => false, + 'auto_line_breaks' => false, + 'auto_url_links' => false, + 'escape_markup' => false, + 'special_chars' => ['>' => 'gt', '<' => 'lt'], + ], + 'images' => $this->config->get('system.images', []) + ]; + $page = $this->pages->find('/'); + $excerpts = new Excerpts($page, $defaults); + $this->parsedown = new Parsedown($excerpts); + + self::assertSame( + '

    Down a Level

    ', + $this->parsedown->text('[Down a Level](item1-3)') + ); + + self::assertSame( + '

    Peer Page

    ', + $this->parsedown->text('[Peer Page](../item2)') + ); + + self::assertSame( + '

    With Query

    ', + $this->parsedown->text('[With Query](?foo=bar)') + ); + self::assertSame( + '

    With Param

    ', + $this->parsedown->text('[With Param](/foo:bar)') + ); + self::assertSame( + '

    With Anchor

    ', + $this->parsedown->text('[With Anchor](#foo)') + ); + + $this->config->set('system.languages.supported', ['fr','en']); + unset($this->grav['language']); + $this->grav['language'] = new Language($this->grav); + $this->uri->initializeWithURL('http://testing.dev/fr/item2/item2-2')->init(); + + self::assertSame( + '

    Peer Page

    ', + $this->parsedown->text('[Peer Page](../item2)') + ); + self::assertSame( + '

    Down a Level

    ', + $this->parsedown->text('[Down a Level](item1-3)') + ); + self::assertSame( + '

    With Query

    ', + $this->parsedown->text('[With Query](?foo=bar)') + ); + self::assertSame( + '

    With Param

    ', + $this->parsedown->text('[With Param](/foo:bar)') + ); + self::assertSame( + '

    With Anchor

    ', + $this->parsedown->text('[With Anchor](#foo)') + ); + } + + + public function testAnchorLinksLangRelativeUrls(): void + { + $this->config->set('system.languages.supported', ['fr','en']); + unset($this->grav['language']); + $this->grav['language'] = new Language($this->grav); + $this->uri->initializeWithURL('http://testing.dev/fr/item2/item2-2')->init(); + + self::assertSame( + '

    Current Anchor

    ', + $this->parsedown->text('[Current Anchor](#foo)') + ); + self::assertSame( + '

    Root Anchor

    ', + $this->parsedown->text('[Root Anchor](/#foo)') + ); + self::assertSame( + '

    Peer Anchor

    ', + $this->parsedown->text('[Peer Anchor](../item2-1#foo)') + ); + self::assertSame( + '

    Peer Anchor 2

    ', + $this->parsedown->text('[Peer Anchor 2](../item2-1/#foo)') + ); + } + + public function testAnchorLinksLangAbsoluteUrls(): void + { + $this->config->set('system.absolute_urls', true); + $this->config->set('system.languages.supported', ['fr','en']); + unset($this->grav['language']); + $this->grav['language'] = new Language($this->grav); + $this->uri->initializeWithURL('http://testing.dev/fr/item2/item2-2')->init(); + + self::assertSame( + '

    Current Anchor

    ', + $this->parsedown->text('[Current Anchor](#foo)') + ); + self::assertSame( + '

    Peer Anchor

    ', + $this->parsedown->text('[Peer Anchor](../item2-1#foo)') + ); + self::assertSame( + '

    Peer Anchor 2

    ', + $this->parsedown->text('[Peer Anchor 2](../item2-1/#foo)') + ); + self::assertSame( + '

    Root Anchor

    ', + $this->parsedown->text('[Root Anchor](/#foo)') + ); + } + + + public function testExternalLinks(): void + { + self::assertSame( + '

    cnn.com

    ', + $this->parsedown->text('[cnn.com](http://www.cnn.com)') + ); + self::assertSame( + '

    google.com

    ', + $this->parsedown->text('[google.com](https://www.google.com)') + ); + self::assertSame( + '

    complex url

    ', + $this->parsedown->text('[complex url](https://github.com/getgrav/grav/issues/new?title=[add-resource]%20New%20Plugin/Theme&body=Hello%20**There**)') + ); + } + + public function testExternalLinksSubDir(): void + { + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init(); + + self::assertSame( + '

    cnn.com

    ', + $this->parsedown->text('[cnn.com](http://www.cnn.com)') + ); + self::assertSame( + '

    google.com

    ', + $this->parsedown->text('[google.com](https://www.google.com)') + ); + } + + public function testExternalLinksSubDirAbsoluteUrls(): void + { + $this->config->set('system.absolute_urls', true); + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init(); + + self::assertSame( + '

    cnn.com

    ', + $this->parsedown->text('[cnn.com](http://www.cnn.com)') + ); + self::assertSame( + '

    google.com

    ', + $this->parsedown->text('[google.com](https://www.google.com)') + ); + } + + public function testAnchorLinksRelativeUrls(): void + { + $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init(); + + self::assertSame( + '

    Current Anchor

    ', + $this->parsedown->text('[Current Anchor](#foo)') + ); + self::assertSame( + '

    Root Anchor

    ', + $this->parsedown->text('[Root Anchor](/#foo)') + ); + self::assertSame( + '

    Peer Anchor

    ', + $this->parsedown->text('[Peer Anchor](../item2-1#foo)') + ); + self::assertSame( + '

    Peer Anchor 2

    ', + $this->parsedown->text('[Peer Anchor 2](../item2-1/#foo)') + ); + } + + public function testAnchorLinksAbsoluteUrls(): void + { + $this->config->set('system.absolute_urls', true); + $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init(); + + self::assertSame( + '

    Current Anchor

    ', + $this->parsedown->text('[Current Anchor](#foo)') + ); + self::assertSame( + '

    Peer Anchor

    ', + $this->parsedown->text('[Peer Anchor](../item2-1#foo)') + ); + self::assertSame( + '

    Peer Anchor 2

    ', + $this->parsedown->text('[Peer Anchor 2](../item2-1/#foo)') + ); + self::assertSame( + '

    Root Anchor

    ', + $this->parsedown->text('[Root Anchor](/#foo)') + ); + } + + public function testAnchorLinksWithPortAbsoluteUrls(): void + { + $this->config->set('system.absolute_urls', true); + $this->uri->initializeWithURL('http://testing.dev:8080/item2/item2-2')->init(); + + self::assertSame( + '

    Peer Anchor

    ', + $this->parsedown->text('[Peer Anchor](../item2-1#foo)') + ); + self::assertSame( + '

    Peer Anchor 2

    ', + $this->parsedown->text('[Peer Anchor 2](../item2-1/#foo)') + ); + self::assertSame( + '

    Current Anchor

    ', + $this->parsedown->text('[Current Anchor](#foo)') + ); + self::assertSame( + '

    Root Anchor

    ', + $this->parsedown->text('[Root Anchor](/#foo)') + ); + } + + public function testAnchorLinksSubDirRelativeUrls(): void + { + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init(); + + self::assertSame( + '

    Peer Anchor

    ', + $this->parsedown->text('[Peer Anchor](../item2-1#foo)') + ); + self::assertSame( + '

    Peer Anchor 2

    ', + $this->parsedown->text('[Peer Anchor 2](../item2-1/#foo)') + ); + self::assertSame( + '

    Current Anchor

    ', + $this->parsedown->text('[Current Anchor](#foo)') + ); + self::assertSame( + '

    Root Anchor

    ', + $this->parsedown->text('[Root Anchor](/#foo)') + ); + } + + public function testAnchorLinksSubDirAbsoluteUrls(): void + { + $this->config->set('system.absolute_urls', true); + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init(); + + self::assertSame( + '

    Peer Anchor

    ', + $this->parsedown->text('[Peer Anchor](../item2-1#foo)') + ); + self::assertSame( + '

    Peer Anchor 2

    ', + $this->parsedown->text('[Peer Anchor 2](../item2-1/#foo)') + ); + self::assertSame( + '

    Current Anchor

    ', + $this->parsedown->text('[Current Anchor](#foo)') + ); + self::assertSame( + '

    Root Anchor

    ', + $this->parsedown->text('[Root Anchor](/#foo)') + ); + } + + public function testSlugRelativeLinks(): void + { + $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init(); + + self::assertSame( + '

    Up to Root Level

    ', + $this->parsedown->text('[Up to Root Level](../..)') + ); + self::assertSame( + '

    Peer Page

    ', + $this->parsedown->text('[Peer Page](../item2-1)') + ); + self::assertSame( + '

    Down a Level

    ', + $this->parsedown->text('[Down a Level](item2-2-1)') + ); + self::assertSame( + '

    Up a Level

    ', + $this->parsedown->text('[Up a Level](..)') + ); + self::assertSame( + '

    Up and Down

    ', + $this->parsedown->text('[Up and Down](../../item3/item3-3)') + ); + self::assertSame( + '

    Down a Level with Query

    ', + $this->parsedown->text('[Down a Level with Query](item2-2-1?foo=bar)') + ); + self::assertSame( + '

    Up a Level with Query

    ', + $this->parsedown->text('[Up a Level with Query](../?foo=bar)') + ); + self::assertSame( + '

    Up and Down with Query

    ', + $this->parsedown->text('[Up and Down with Query](../../item3/item3-3?foo=bar)') + ); + self::assertSame( + '

    Up and Down with Param

    ', + $this->parsedown->text('[Up and Down with Param](../../item3/item3-3/foo:bar)') + ); + self::assertSame( + '

    Up and Down with Anchor

    ', + $this->parsedown->text('[Up and Down with Anchor](../../item3/item3-3#foo)') + ); + } + + public function testSlugRelativeLinksAbsoluteUrls(): void + { + $this->config->set('system.absolute_urls', true); + $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init(); + + self::assertSame( + '

    Peer Page

    ', + $this->parsedown->text('[Peer Page](../item2-1)') + ); + self::assertSame( + '

    Down a Level

    ', + $this->parsedown->text('[Down a Level](item2-2-1)') + ); + self::assertSame( + '

    Up a Level

    ', + $this->parsedown->text('[Up a Level](..)') + ); + self::assertSame( + '

    Up to Root Level

    ', + $this->parsedown->text('[Up to Root Level](../..)') + ); + self::assertSame( + '

    Up and Down

    ', + $this->parsedown->text('[Up and Down](../../item3/item3-3)') + ); + self::assertSame( + '

    Down a Level with Query

    ', + $this->parsedown->text('[Down a Level with Query](item2-2-1?foo=bar)') + ); + self::assertSame( + '

    Up a Level with Query

    ', + $this->parsedown->text('[Up a Level with Query](../?foo=bar)') + ); + self::assertSame( + '

    Up and Down with Query

    ', + $this->parsedown->text('[Up and Down with Query](../../item3/item3-3?foo=bar)') + ); + self::assertSame( + '

    Up and Down with Param

    ', + $this->parsedown->text('[Up and Down with Param](../../item3/item3-3/foo:bar)') + ); + self::assertSame( + '

    Up and Down with Anchor

    ', + $this->parsedown->text('[Up and Down with Anchor](../../item3/item3-3#foo)') + ); + } + + public function testSlugRelativeLinksSubDir(): void + { + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init(); + + self::assertSame( + '

    Peer Page

    ', + $this->parsedown->text('[Peer Page](../item2-1)') + ); + self::assertSame( + '

    Down a Level

    ', + $this->parsedown->text('[Down a Level](item2-2-1)') + ); + self::assertSame( + '

    Up a Level

    ', + $this->parsedown->text('[Up a Level](..)') + ); + self::assertSame( + '

    Up to Root Level

    ', + $this->parsedown->text('[Up to Root Level](../..)') + ); + self::assertSame( + '

    Up and Down

    ', + $this->parsedown->text('[Up and Down](../../item3/item3-3)') + ); + self::assertSame( + '

    Down a Level with Query

    ', + $this->parsedown->text('[Down a Level with Query](item2-2-1?foo=bar)') + ); + self::assertSame( + '

    Up a Level with Query

    ', + $this->parsedown->text('[Up a Level with Query](../?foo=bar)') + ); + self::assertSame( + '

    Up and Down with Query

    ', + $this->parsedown->text('[Up and Down with Query](../../item3/item3-3?foo=bar)') + ); + self::assertSame( + '

    Up and Down with Param

    ', + $this->parsedown->text('[Up and Down with Param](../../item3/item3-3/foo:bar)') + ); + self::assertSame( + '

    Up and Down with Anchor

    ', + $this->parsedown->text('[Up and Down with Anchor](../../item3/item3-3#foo)') + ); + } + + public function testSlugRelativeLinksSubDirAbsoluteUrls(): void + { + $this->config->set('system.absolute_urls', true); + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init(); + + self::assertSame( + '

    Peer Page

    ', + $this->parsedown->text('[Peer Page](../item2-1)') + ); + self::assertSame( + '

    Down a Level

    ', + $this->parsedown->text('[Down a Level](item2-2-1)') + ); + self::assertSame( + '

    Up a Level

    ', + $this->parsedown->text('[Up a Level](..)') + ); + self::assertSame( + '

    Up to Root Level

    ', + $this->parsedown->text('[Up to Root Level](../..)') + ); + self::assertSame( + '

    Up and Down

    ', + $this->parsedown->text('[Up and Down](../../item3/item3-3)') + ); + self::assertSame( + '

    Down a Level with Query

    ', + $this->parsedown->text('[Down a Level with Query](item2-2-1?foo=bar)') + ); + self::assertSame( + '

    Up a Level with Query

    ', + $this->parsedown->text('[Up a Level with Query](../?foo=bar)') + ); + self::assertSame( + '

    Up and Down with Query

    ', + $this->parsedown->text('[Up and Down with Query](../../item3/item3-3?foo=bar)') + ); + self::assertSame( + '

    Up and Down with Param

    ', + $this->parsedown->text('[Up and Down with Param](../../item3/item3-3/foo:bar)') + ); + self::assertSame( + '

    Up and Down with Anchor

    ', + $this->parsedown->text('[Up and Down with Anchor](../../item3/item3-3#foo)') + ); + } + + + public function testDirectoryRelativeLinks(): void + { + $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init(); + + self::assertSame( + '

    Up and Down with Param

    ', + $this->parsedown->text('[Up and Down with Param](../../03.item3/03.item3-3/foo:bar)') + ); + self::assertSame( + '

    Peer Page

    ', + $this->parsedown->text('[Peer Page](../01.item2-1)') + ); + self::assertSame( + '

    Down a Level

    ', + $this->parsedown->text('[Down a Level](01.item2-2-1)') + ); + self::assertSame( + '

    Up and Down

    ', + $this->parsedown->text('[Up and Down](../../03.item3/03.item3-3)') + ); + self::assertSame( + '

    Down a Level with Query

    ', + $this->parsedown->text('[Down a Level with Query](01.item2-2-1?foo=bar)') + ); + self::assertSame( + '

    Up and Down with Query

    ', + $this->parsedown->text('[Up and Down with Query](../../03.item3/03.item3-3?foo=bar)') + ); + self::assertSame( + '

    Up and Down with Anchor

    ', + $this->parsedown->text('[Up and Down with Anchor](../../03.item3/03.item3-3#foo)') + ); + } + + + public function testAbsoluteLinks(): void + { + $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init(); + + self::assertSame( + '

    Root

    ', + $this->parsedown->text('[Root](/)') + ); + self::assertSame( + '

    Peer Page

    ', + $this->parsedown->text('[Peer Page](/item2/item2-1)') + ); + self::assertSame( + '

    Down a Level

    ', + $this->parsedown->text('[Down a Level](/item2/item2-2/item2-2-1)') + ); + self::assertSame( + '

    Up a Level

    ', + $this->parsedown->text('[Up a Level](/item2)') + ); + self::assertSame( + '

    With Query

    ', + $this->parsedown->text('[With Query](/item2?foo=bar)') + ); + self::assertSame( + '

    With Param

    ', + $this->parsedown->text('[With Param](/item2/foo:bar)') + ); + self::assertSame( + '

    With Anchor

    ', + $this->parsedown->text('[With Anchor](/item2#foo)') + ); + } + + public function testDirectoryAbsoluteLinksSubDir(): void + { + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init(); + + self::assertSame( + '

    Root

    ', + $this->parsedown->text('[Root](/)') + ); + self::assertSame( + '

    Peer Page

    ', + $this->parsedown->text('[Peer Page](/item2/item2-1)') + ); + self::assertSame( + '

    Down a Level

    ', + $this->parsedown->text('[Down a Level](/item2/item2-2/item2-2-1)') + ); + self::assertSame( + '

    Up a Level

    ', + $this->parsedown->text('[Up a Level](/item2)') + ); + self::assertSame( + '

    With Query

    ', + $this->parsedown->text('[With Query](/item2?foo=bar)') + ); + self::assertSame( + '

    With Param

    ', + $this->parsedown->text('[With Param](/item2/foo:bar)') + ); + self::assertSame( + '

    With Anchor

    ', + $this->parsedown->text('[With Anchor](/item2#foo)') + ); + } + + public function testDirectoryAbsoluteLinksSubDirAbsoluteUrl(): void + { + $this->config->set('system.absolute_urls', true); + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init(); + + self::assertSame( + '

    Root

    ', + $this->parsedown->text('[Root](/)') + ); + self::assertSame( + '

    Peer Page

    ', + $this->parsedown->text('[Peer Page](/item2/item2-1)') + ); + self::assertSame( + '

    Down a Level

    ', + $this->parsedown->text('[Down a Level](/item2/item2-2/item2-2-1)') + ); + self::assertSame( + '

    Up a Level

    ', + $this->parsedown->text('[Up a Level](/item2)') + ); + self::assertSame( + '

    With Query

    ', + $this->parsedown->text('[With Query](/item2?foo=bar)') + ); + self::assertSame( + '

    With Param

    ', + $this->parsedown->text('[With Param](/item2/foo:bar)') + ); + self::assertSame( + '

    With Anchor

    ', + $this->parsedown->text('[With Anchor](/item2#foo)') + ); + } + + public function testSpecialProtocols(): void + { + $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init(); + + self::assertSame( + '

    mailto

    ', + $this->parsedown->text('[mailto](mailto:user@domain.com)') + ); + self::assertSame( + '

    xmpp

    ', + $this->parsedown->text('[xmpp](xmpp:xyx@domain.com)') + ); + self::assertSame( + '

    tel

    ', + $this->parsedown->text('[tel](tel:123-555-12345)') + ); + self::assertSame( + '

    sms

    ', + $this->parsedown->text('[sms](sms:123-555-12345)') + ); + self::assertSame( + '

    ts.example.com

    ', + $this->parsedown->text('[ts.example.com](rdp://ts.example.com)') + ); + } + + public function testSpecialProtocolsSubDir(): void + { + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init(); + + self::assertSame( + '

    mailto

    ', + $this->parsedown->text('[mailto](mailto:user@domain.com)') + ); + self::assertSame( + '

    xmpp

    ', + $this->parsedown->text('[xmpp](xmpp:xyx@domain.com)') + ); + self::assertSame( + '

    tel

    ', + $this->parsedown->text('[tel](tel:123-555-12345)') + ); + self::assertSame( + '

    sms

    ', + $this->parsedown->text('[sms](sms:123-555-12345)') + ); + self::assertSame( + '

    ts.example.com

    ', + $this->parsedown->text('[ts.example.com](rdp://ts.example.com)') + ); + } + + public function testSpecialProtocolsSubDirAbsoluteUrl(): void + { + $this->config->set('system.absolute_urls', true); + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init(); + + self::assertSame( + '

    mailto

    ', + $this->parsedown->text('[mailto](mailto:user@domain.com)') + ); + self::assertSame( + '

    xmpp

    ', + $this->parsedown->text('[xmpp](xmpp:xyx@domain.com)') + ); + self::assertSame( + '

    tel

    ', + $this->parsedown->text('[tel](tel:123-555-12345)') + ); + self::assertSame( + '

    sms

    ', + $this->parsedown->text('[sms](sms:123-555-12345)') + ); + self::assertSame( + '

    ts.example.com

    ', + $this->parsedown->text('[ts.example.com](rdp://ts.example.com)') + ); + } + + public function testReferenceLinks(): void + { + $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init(); + + $sample = '[relative link][r_relative] + [r_relative]: ../item2-3#blah'; + self::assertSame( + '

    relative link

    ', + $this->parsedown->text($sample) + ); + + $sample = '[absolute link][r_absolute] + [r_absolute]: /item3#blah'; + self::assertSame( + '

    absolute link

    ', + $this->parsedown->text($sample) + ); + + $sample = '[external link][r_external] + [r_external]: http://www.cnn.com'; + self::assertSame( + '

    external link

    ', + $this->parsedown->text($sample) + ); + } + + public function testAttributeLinks(): void + { + $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init(); + + self::assertSame( + '

    Anchor Class

    ', + $this->parsedown->text('[Anchor Class](?classes=button#something)') + ); + self::assertSame( + '

    Relative Class

    ', + $this->parsedown->text('[Relative Class](../item2-3?classes=button)') + ); + self::assertSame( + '

    Relative ID

    ', + $this->parsedown->text('[Relative ID](../item2-3?id=unique)') + ); + self::assertSame( + '

    External

    ', + $this->parsedown->text('[External](https://github.com/getgrav/grav?classes=button,big)') + ); + self::assertSame( + '

    Relative Noprocess

    ', + $this->parsedown->text('[Relative Noprocess](../item2-3?id=unique&noprocess)') + ); + self::assertSame( + '

    Relative Target

    ', + $this->parsedown->text('[Relative Target](../item2-3?target=_blank)') + ); + self::assertSame( + '

    Relative Rel

    ', + $this->parsedown->text('[Relative Rel](../item2-3?rel=nofollow)') + ); + self::assertSame( + '

    Relative Mixed

    ', + $this->parsedown->text('[Relative Mixed](../item2-3?foo=bar&baz=qux&rel=nofollow&class=button)') + ); + } + + public function testInvalidLinks(): void + { + $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init(); + + self::assertSame( + '

    Non Existent Page

    ', + $this->parsedown->text('[Non Existent Page](no-page)') + ); + self::assertSame( + '

    Existent File

    ', + $this->parsedown->text('[Existent File](existing-file.zip)') + ); + self::assertSame( + '

    Non Existent File

    ', + $this->parsedown->text('[Non Existent File](missing-file.zip)') + ); + } + + public function testInvalidLinksSubDir(): void + { + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init(); + + self::assertSame( + '

    Non Existent Page

    ', + $this->parsedown->text('[Non Existent Page](no-page)') + ); + self::assertSame( + '

    Existent File

    ', + $this->parsedown->text('[Existent File](existing-file.zip)') + ); + self::assertSame( + '

    Non Existent File

    ', + $this->parsedown->text('[Non Existent File](missing-file.zip)') + ); + } + + public function testInvalidLinksSubDirAbsoluteUrl(): void + { + $this->config->set('system.absolute_urls', true); + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init(); + + self::assertSame( + '

    Non Existent Page

    ', + $this->parsedown->text('[Non Existent Page](no-page)') + ); + self::assertSame( + '

    Existent File

    ', + $this->parsedown->text('[Existent File](existing-file.zip)') + ); + self::assertSame( + '

    Non Existent File

    ', + $this->parsedown->text('[Non Existent File](missing-file.zip)') + ); + } + + + /** + * @param $string + * + * @return mixed + */ + private function stripLeadingWhitespace($string) + { + return preg_replace('/^\s*(.*)/', '', $string); + } + + private function setImagesDefaults($defaults) { + $defaults = [ + 'images' => [ + 'defaults' => $defaults + ], + ]; + $page = $this->pages->find('/item2/item2-2'); + $excerpts = new Excerpts($page, $defaults); + $this->parsedown = new Parsedown($excerpts); + } +} diff --git a/tests/unit/Grav/Common/Page/PagesTest.php b/tests/unit/Grav/Common/Page/PagesTest.php new file mode 100644 index 0000000..edff75b --- /dev/null +++ b/tests/unit/Grav/Common/Page/PagesTest.php @@ -0,0 +1,299 @@ +grav = $grav(); + $this->pages = $this->grav['pages']; + $this->grav['config']->set('system.home.alias', '/home'); + + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + $locator->addPath('page', '', 'tests/fake/simple-site/user/pages', false); + $this->pages->init(); + } + + public function testBase(): void + { + self::assertSame('', $this->pages->base()); + $this->pages->base('/test'); + self::assertSame('/test', $this->pages->base()); + $this->pages->base(''); + self::assertSame($this->pages->base(), ''); + } + + public function testLastModified(): void + { + self::assertNull($this->pages->lastModified()); + $this->pages->lastModified('test'); + self::assertSame('test', $this->pages->lastModified()); + } + + public function testInstances(): void + { + self::assertIsArray($this->pages->instances()); + foreach ($this->pages->instances() as $instance) { + self::assertInstanceOf(PageInterface::class, $instance); + } + } + + public function testRoutes(): void + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + $folder = $locator->findResource('tests://'); + + self::assertIsArray($this->pages->routes()); + self::assertSame($folder . '/fake/simple-site/user/pages/01.home', $this->pages->routes()['/']); + self::assertSame($folder . '/fake/simple-site/user/pages/01.home', $this->pages->routes()['/home']); + self::assertSame($folder . '/fake/simple-site/user/pages/02.blog', $this->pages->routes()['/blog']); + self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-one', $this->pages->routes()['/blog/post-one']); + self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-two', $this->pages->routes()['/blog/post-two']); + self::assertSame($folder . '/fake/simple-site/user/pages/03.about', $this->pages->routes()['/about']); + } + + public function testAddPage(): void + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + $folder = $locator->findResource('tests://'); + + $path = $folder . '/fake/single-pages/01.simple-page/default.md'; + $aPage = new Page(); + $aPage->init(new \SplFileInfo($path)); + + $this->pages->addPage($aPage, '/new-page'); + + self::assertContains('/new-page', array_keys($this->pages->routes())); + self::assertSame($folder . '/fake/single-pages/01.simple-page', $this->pages->routes()['/new-page']); + } + + public function testSort(): void + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + $folder = $locator->findResource('tests://'); + + $aPage = $this->pages->find('/blog'); + $subPagesSorted = $this->pages->sort($aPage); + + self::assertIsArray($subPagesSorted); + self::assertCount(2, $subPagesSorted); + + self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted)[0]); + self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted)[1]); + + self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted)); + self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted)); + + self::assertSame(['slug' => 'post-one'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-one']); + self::assertSame(['slug' => 'post-two'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-two']); + + $subPagesSorted = $this->pages->sort($aPage, null, 'desc'); + + self::assertIsArray($subPagesSorted); + self::assertCount(2, $subPagesSorted); + + self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted)[0]); + self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted)[1]); + + self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted)); + self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted)); + + self::assertSame(['slug' => 'post-one'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-one']); + self::assertSame(['slug' => 'post-two'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-two']); + } + + public function testSortCollection(): void + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + $folder = $locator->findResource('tests://'); + + $aPage = $this->pages->find('/blog'); + $subPagesSorted = $this->pages->sortCollection($aPage->children(), $aPage->orderBy()); + + self::assertIsArray($subPagesSorted); + self::assertCount(2, $subPagesSorted); + + self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted)[0]); + self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted)[1]); + + self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted)); + self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted)); + + self::assertSame(['slug' => 'post-one'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-one']); + self::assertSame(['slug' => 'post-two'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-two']); + + $subPagesSorted = $this->pages->sortCollection($aPage->children(), $aPage->orderBy(), 'desc'); + + self::assertIsArray($subPagesSorted); + self::assertCount(2, $subPagesSorted); + + self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted)[0]); + self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted)[1]); + + self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted)); + self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted)); + + self::assertSame(['slug' => 'post-one'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-one']); + self::assertSame(['slug' => 'post-two'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-two']); + } + + public function testGet(): void + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + $folder = $locator->findResource('tests://'); + + //Page existing + $aPage = $this->pages->get($folder . '/fake/simple-site/user/pages/03.about'); + self::assertInstanceOf(PageInterface::class, $aPage); + + //Page not existing + $anotherPage = $this->pages->get($folder . '/fake/simple-site/user/pages/03.non-existing'); + self::assertNull($anotherPage); + } + + public function testChildren(): void + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + $folder = $locator->findResource('tests://'); + + //Page existing + $children = $this->pages->children($folder . '/fake/simple-site/user/pages/02.blog'); + self::assertInstanceOf('Grav\Common\Page\Collection', $children); + + //Page not existing + $children = $this->pages->children($folder . '/fake/whatever/non-existing'); + self::assertSame([], $children->toArray()); + } + + public function testDispatch(): void + { + $aPage = $this->pages->dispatch('/blog'); + self::assertInstanceOf(PageInterface::class, $aPage); + + $aPage = $this->pages->dispatch('/about'); + self::assertInstanceOf(PageInterface::class, $aPage); + + $aPage = $this->pages->dispatch('/blog/post-one'); + self::assertInstanceOf(PageInterface::class, $aPage); + + //Page not existing + $aPage = $this->pages->dispatch('/non-existing'); + self::assertNull($aPage); + } + + public function testRoot(): void + { + $root = $this->pages->root(); + self::assertInstanceOf(PageInterface::class, $root); + self::assertSame('pages', $root->folder()); + } + + public function testBlueprints(): void + { + } + + public function testAll() + { + self::assertIsObject($this->pages->all()); + self::assertIsArray($this->pages->all()->toArray()); + foreach ($this->pages->all() as $page) { + self::assertInstanceOf(PageInterface::class, $page); + } + } + + public function testGetList(): void + { + $list = $this->pages->getList(); + self::assertIsArray($list); + self::assertSame('—-▸ Home', $list['/']); + self::assertSame('—-▸ Blog', $list['/blog']); + } + + public function testTranslatedLanguages(): void + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + $folder = $locator->findResource('tests://'); + + $page = $this->pages->get($folder . '/fake/simple-site/user/pages/04.page-translated'); + $this->assertInstanceOf(PageInterface::class, $page); + $translatedLanguages = $page->translatedLanguages(); + $this->assertIsArray($translatedLanguages); + $this->assertSame(["en" => "/page-translated", "fr" => "/page-translated"], $translatedLanguages); + } + + public function testLongPathTranslatedLanguages(): void + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + $folder = $locator->findResource('tests://'); + $page = $this->pages->get($folder . '/fake/simple-site/user/pages/05.translatedlong/part2'); + $this->assertInstanceOf(PageInterface::class, $page); + $translatedLanguages = $page->translatedLanguages(); + $this->assertIsArray($translatedLanguages); + $this->assertSame(["en" => "/translatedlong/part2", "fr" => "/translatedlong/part2"], $translatedLanguages); + } + + public function testGetTypes(): void + { + } + + public function testTypes(): void + { + } + + public function testModularTypes(): void + { + } + + public function testPageTypes(): void + { + } + + public function testAccessLevels(): void + { + } + + public function testParents(): void + { + } + + public function testParentsRawRoutes(): void + { + } + + public function testGetHomeRoute(): void + { + } + + public function testResetPages(): void + { + } +} diff --git a/tests/unit/Grav/Common/Twig/Extensions/GravExtensionTest.php b/tests/unit/Grav/Common/Twig/Extensions/GravExtensionTest.php new file mode 100644 index 0000000..2adb023 --- /dev/null +++ b/tests/unit/Grav/Common/Twig/Extensions/GravExtensionTest.php @@ -0,0 +1,202 @@ +grav = Fixtures::get('grav'); + $this->twig_ext = new GravExtension(); + } + + public function testInflectorFilter(): void + { + self::assertSame('people', $this->twig_ext->inflectorFilter('plural', 'person')); + self::assertSame('shoe', $this->twig_ext->inflectorFilter('singular', 'shoes')); + self::assertSame('Welcome Page', $this->twig_ext->inflectorFilter('title', 'welcome page')); + self::assertSame('SendEmail', $this->twig_ext->inflectorFilter('camel', 'send_email')); + self::assertSame('camel_cased', $this->twig_ext->inflectorFilter('underscor', 'CamelCased')); + self::assertSame('something-text', $this->twig_ext->inflectorFilter('hyphen', 'Something Text')); + self::assertSame('Something text to read', $this->twig_ext->inflectorFilter('human', 'something_text_to_read')); + self::assertSame(5, $this->twig_ext->inflectorFilter('month', '175')); + self::assertSame('10th', $this->twig_ext->inflectorFilter('ordinal', '10')); + } + + public function testMd5Filter(): void + { + self::assertSame(md5('grav'), $this->twig_ext->md5Filter('grav')); + self::assertSame(md5('devs@getgrav.org'), $this->twig_ext->md5Filter('devs@getgrav.org')); + } + + public function testKsortFilter(): void + { + $object = array("name"=>"Bob","age"=>8,"colour"=>"red"); + self::assertSame(array("age"=>8,"colour"=>"red","name"=>"Bob"), $this->twig_ext->ksortFilter($object)); + } + + public function testContainsFilter(): void + { + self::assertTrue($this->twig_ext->containsFilter('grav', 'grav')); + self::assertTrue($this->twig_ext->containsFilter('So, I found this new cms, called grav, and it\'s pretty awesome guys', 'grav')); + } + + public function testNicetimeFilter(): void + { + $now = time(); + $threeMinutes = time() - (60*3); + $threeHours = time() - (60*60*3); + $threeDays = time() - (60*60*24*3); + $threeMonths = time() - (60*60*24*30*3); + $threeYears = time() - (60*60*24*365*3); + $measures = ['minutes','hours','days','months','years']; + + self::assertSame('No date provided', $this->twig_ext->nicetimeFunc(null)); + + for ($i=0; $itwig_ext->nicetimeFunc($$time)); + } + } + + public function testRandomizeFilter(): void + { + $array = [1,2,3,4,5]; + self::assertContains(2, $this->twig_ext->randomizeFilter($array)); + self::assertSame($array, $this->twig_ext->randomizeFilter($array, 5)); + self::assertSame($array[0], $this->twig_ext->randomizeFilter($array, 1)[0]); + self::assertSame($array[3], $this->twig_ext->randomizeFilter($array, 4)[3]); + self::assertSame($array[1], $this->twig_ext->randomizeFilter($array, 4)[1]); + } + + public function testModulusFilter(): void + { + self::assertSame(3, $this->twig_ext->modulusFilter(3, 4)); + self::assertSame(1, $this->twig_ext->modulusFilter(11, 2)); + self::assertSame(0, $this->twig_ext->modulusFilter(10, 2)); + self::assertSame(2, $this->twig_ext->modulusFilter(10, 4)); + } + + public function testAbsoluteUrlFilter(): void + { + } + + public function testMarkdownFilter(): void + { + } + + public function testStartsWithFilter(): void + { + } + + public function testEndsWithFilter(): void + { + } + + public function testDefinedDefaultFilter(): void + { + } + + public function testRtrimFilter(): void + { + } + + public function testLtrimFilter(): void + { + } + + public function testRepeatFunc(): void + { + } + + public function testRegexReplace(): void + { + } + + public function testUrlFunc(): void + { + } + + public function testEvaluateFunc(): void + { + } + + public function testDump(): void + { + } + + public function testGistFunc(): void + { + } + + public function testRandomStringFunc(): void + { + } + + public function testPadFilter(): void + { + } + + public function testArrayFunc(): void + { + self::assertSame( + 'this is my text', + $this->twig_ext->regexReplace('

    this is my text

    ', '(<\/?p>)', '') + ); + self::assertSame( + 'this is my text', + $this->twig_ext->regexReplace('

    this is my text

    ', ['(

    )','(<\/p>)'], ['','']) + ); + } + + public function testArrayKeyValue(): void + { + self::assertSame( + ['meat' => 'steak'], + $this->twig_ext->arrayKeyValueFunc('meat', 'steak') + ); + self::assertSame( + ['fruit' => 'apple', 'meat' => 'steak'], + $this->twig_ext->arrayKeyValueFunc('meat', 'steak', ['fruit' => 'apple']) + ); + } + + public function stringFunc(): void + { + } + + public function testRangeFunc(): void + { + $hundred = []; + for ($i = 0; $i <= 100; $i++) { + $hundred[] = $i; + } + + + self::assertSame([0], $this->twig_ext->rangeFunc(0, 0)); + self::assertSame([0, 1, 2], $this->twig_ext->rangeFunc(0, 2)); + + self::assertSame([0, 5, 10, 15], $this->twig_ext->rangeFunc(0, 16, 5)); + + // default (min 0, max 100, step 1) + self::assertSame($hundred, $this->twig_ext->rangeFunc()); + + // 95 items, starting from 5, (min 5, max 100, step 1) + self::assertSame(array_slice($hundred, 5), $this->twig_ext->rangeFunc(5)); + + // reversed range + self::assertSame(array_reverse($hundred), $this->twig_ext->rangeFunc(100, 0)); + self::assertSame([4, 2, 0], $this->twig_ext->rangeFunc(4, 0, 2)); + } +} diff --git a/tests/unit/Grav/Common/UriTest.php b/tests/unit/Grav/Common/UriTest.php new file mode 100644 index 0000000..c36ce52 --- /dev/null +++ b/tests/unit/Grav/Common/UriTest.php @@ -0,0 +1,1178 @@ + [ + 'scheme' => '', + 'user' => null, + 'password' => null, + 'host' => null, + 'port' => null, + 'path' => '/path', + 'query' => '', + 'fragment' => null, + + 'route' => '/path', + 'paths' => ['path'], + 'params' => null, + 'url' => '/path', + 'environment' => 'unknown', + 'basename' => 'path', + 'base' => '', + 'currentPage' => 1, + 'rootUrl' => '', + 'extension' => null, + 'addNonce' => '/path/nonce:{{nonce}}', + ], + '//localhost/' => [ + 'scheme' => '//', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => null, + 'path' => '/', + 'query' => '', + 'fragment' => null, + + 'route' => '/', + 'paths' => [], + 'params' => null, + 'url' => '/', + 'environment' => 'localhost', + 'basename' => '', + 'base' => '//localhost', + 'currentPage' => 1, + 'rootUrl' => '//localhost', + 'extension' => null, + 'addNonce' => '//localhost/nonce:{{nonce}}', + ], + 'http://localhost/' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 80, + 'path' => '/', + 'query' => '', + 'fragment' => null, + + 'route' => '/', + 'paths' => [], + 'params' => null, + 'url' => '/', + 'environment' => 'localhost', + 'basename' => '', + 'base' => 'http://localhost', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost', + 'extension' => null, + 'addNonce' => 'http://localhost/nonce:{{nonce}}', + ], + 'http://127.0.0.1/' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => '127.0.0.1', + 'port' => 80, + 'path' => '/', + 'query' => '', + 'fragment' => null, + + 'route' => '/', + 'paths' => [], + 'params' => null, + 'url' => '/', + 'environment' => 'localhost', + 'basename' => '', + 'base' => 'http://127.0.0.1', + 'currentPage' => 1, + 'rootUrl' => 'http://127.0.0.1', + 'extension' => null, + 'addNonce' => 'http://127.0.0.1/nonce:{{nonce}}', + ], + 'https://localhost/' => [ + 'scheme' => 'https://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 443, + 'path' => '/', + 'query' => '', + 'fragment' => null, + + 'route' => '/', + 'paths' => [], + 'params' => null, + 'url' => '/', + 'environment' => 'localhost', + 'basename' => '', + 'base' => 'https://localhost', + 'currentPage' => 1, + 'rootUrl' => 'https://localhost', + 'extension' => null, + 'addNonce' => 'https://localhost/nonce:{{nonce}}', + ], + 'http://localhost:8080/grav/it/ueper' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 8080, + 'path' => '/grav/it/ueper', + 'query' => '', + 'fragment' => null, + + 'route' => '/grav/it/ueper', + 'paths' => ['grav', 'it', 'ueper'], + 'params' => null, + 'url' => '/grav/it/ueper', + 'environment' => 'localhost', + 'basename' => 'ueper', + 'base' => 'http://localhost:8080', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost:8080', + 'extension' => null, + 'addNonce' => 'http://localhost:8080/grav/it/ueper/nonce:{{nonce}}', + ], + 'http://localhost:8080/grav/it/ueper:xxx' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 8080, + 'path' => '/grav/it', + 'query' => '', + 'fragment' => null, + + 'route' => '/grav/it', + 'paths' => ['grav', 'it'], + 'params' => '/ueper:xxx', + 'url' => '/grav/it', + 'environment' => 'localhost', + 'basename' => 'it', + 'base' => 'http://localhost:8080', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost:8080', + 'extension' => null, + 'addNonce' => 'http://localhost:8080/grav/it/ueper:xxx/nonce:{{nonce}}', + ], + 'http://localhost:8080/grav/it/ueper:xxx/page:/test:yyy' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 8080, + 'path' => '/grav/it', + 'query' => '', + 'fragment' => null, + + 'route' => '/grav/it', + 'paths' => ['grav', 'it'], + 'params' => '/ueper:xxx/page:/test:yyy', + 'url' => '/grav/it', + 'environment' => 'localhost', + 'basename' => 'it', + 'base' => 'http://localhost:8080', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost:8080', + 'extension' => null, + 'addNonce' => 'http://localhost:8080/grav/it/ueper:xxx/page:/test:yyy/nonce:{{nonce}}', + ], + 'http://localhost:8080/grav/it/ueper?test=x' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 8080, + 'path' => '/grav/it/ueper', + 'query' => 'test=x', + 'fragment' => null, + + 'route' => '/grav/it/ueper', + 'paths' => ['grav', 'it', 'ueper'], + 'params' => null, + 'url' => '/grav/it/ueper', + 'environment' => 'localhost', + 'basename' => 'ueper', + 'base' => 'http://localhost:8080', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost:8080', + 'extension' => null, + 'addNonce' => 'http://localhost:8080/grav/it/ueper/nonce:{{nonce}}?test=x', + ], + 'http://localhost:80/grav/it/ueper?test=x' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 80, + 'path' => '/grav/it/ueper', + 'query' => 'test=x', + 'fragment' => null, + + 'route' => '/grav/it/ueper', + 'paths' => ['grav', 'it', 'ueper'], + 'params' => null, + 'url' => '/grav/it/ueper', + 'environment' => 'localhost', + 'basename' => 'ueper', + 'base' => 'http://localhost:80', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost:80', + 'extension' => null, + 'addNonce' => 'http://localhost:80/grav/it/ueper/nonce:{{nonce}}?test=x', + ], + 'http://localhost/grav/it/ueper?test=x' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 80, + 'path' => '/grav/it/ueper', + 'query' => 'test=x', + 'fragment' => null, + + 'route' => '/grav/it/ueper', + 'paths' => ['grav', 'it', 'ueper'], + 'params' => null, + 'url' => '/grav/it/ueper', + 'environment' => 'localhost', + 'basename' => 'ueper', + 'base' => 'http://localhost', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost', + 'extension' => null, + 'addNonce' => 'http://localhost/grav/it/ueper/nonce:{{nonce}}?test=x', + ], + 'http://grav/grav/it/ueper' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'grav', + 'port' => 80, + 'path' => '/grav/it/ueper', + 'query' => '', + 'fragment' => null, + + 'route' => '/grav/it/ueper', + 'paths' => ['grav', 'it', 'ueper'], + 'params' => null, + 'url' => '/grav/it/ueper', + 'environment' => 'grav', + 'basename' => 'ueper', + 'base' => 'http://grav', + 'currentPage' => 1, + 'rootUrl' => 'http://grav', + 'extension' => null, + 'addNonce' => 'http://grav/grav/it/ueper/nonce:{{nonce}}', + ], + 'https://username:password@api.getgrav.com:4040/v1/post/128/page:x/?all=1' => [ + 'scheme' => 'https://', + 'user' => 'username', + 'password' => 'password', + 'host' => 'api.getgrav.com', + 'port' => 4040, + 'path' => '/v1/post/128/', // FIXME <- + 'query' => 'all=1', + 'fragment' => null, + + 'route' => '/v1/post/128', + 'paths' => ['v1', 'post', '128'], + 'params' => '/page:x', + 'url' => '/v1/post/128', + 'environment' => 'api.getgrav.com', + 'basename' => '128', + 'base' => 'https://api.getgrav.com:4040', + 'currentPage' => 1, + 'rootUrl' => 'https://api.getgrav.com:4040', + 'extension' => null, + 'addNonce' => 'https://username:password@api.getgrav.com:4040/v1/post/128/page:x/nonce:{{nonce}}?all=1', + 'toOriginalString' => 'https://username:password@api.getgrav.com:4040/v1/post/128/page:x?all=1' + ], + 'https://google.com:443/' => [ + 'scheme' => 'https://', + 'user' => null, + 'password' => null, + 'host' => 'google.com', + 'port' => 443, + 'path' => '/', + 'query' => '', + 'fragment' => null, + + 'route' => '/', + 'paths' => [], + 'params' => null, + 'url' => '/', + 'environment' => 'google.com', + 'basename' => '', + 'base' => 'https://google.com:443', + 'currentPage' => 1, + 'rootUrl' => 'https://google.com:443', + 'extension' => null, + 'addNonce' => 'https://google.com:443/nonce:{{nonce}}', + ], + // Path tests. + 'http://localhost:8080/a/b/c/d' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 8080, + 'path' => '/a/b/c/d', + 'query' => '', + 'fragment' => null, + + 'route' => '/a/b/c/d', + 'paths' => ['a', 'b', 'c', 'd'], + 'params' => null, + 'url' => '/a/b/c/d', + 'environment' => 'localhost', + 'basename' => 'd', + 'base' => 'http://localhost:8080', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost:8080', + 'extension' => null, + 'addNonce' => 'http://localhost:8080/a/b/c/d/nonce:{{nonce}}', + ], + 'http://localhost:8080/a/b/c/d/e/f/a/b/c/d/e/f/a/b/c/d/e/f' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 8080, + 'path' => '/a/b/c/d/e/f/a/b/c/d/e/f/a/b/c/d/e/f', + 'query' => '', + 'fragment' => null, + + 'route' => '/a/b/c/d/e/f/a/b/c/d/e/f/a/b/c/d/e/f', + 'paths' => ['a', 'b', 'c', 'd', 'e', 'f', 'a', 'b', 'c', 'd', 'e', 'f', 'a', 'b', 'c', 'd', 'e', 'f'], + 'params' => null, + 'url' => '/a/b/c/d/e/f/a/b/c/d/e/f/a/b/c/d/e/f', + 'environment' => 'localhost', + 'basename' => 'f', + 'base' => 'http://localhost:8080', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost:8080', + 'extension' => null, + 'addNonce' => 'http://localhost:8080/a/b/c/d/e/f/a/b/c/d/e/f/a/b/c/d/e/f/nonce:{{nonce}}', + ], + 'http://localhost/this is the path/my page' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 80, + 'path' => '/this%20is%20the%20path/my%20page', + 'query' => '', + 'fragment' => null, + + 'route' => '/this%20is%20the%20path/my%20page', + 'paths' => ['this%20is%20the%20path', 'my%20page'], + 'params' => null, + 'url' => '/this%20is%20the%20path/my%20page', + 'environment' => 'localhost', + 'basename' => 'my%20page', + 'base' => 'http://localhost', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost', + 'extension' => null, + 'addNonce' => 'http://localhost/this%20is%20the%20path/my%20page/nonce:{{nonce}}', + 'toOriginalString' => 'http://localhost/this%20is%20the%20path/my%20page' + ], + 'http://localhost/pölöpölö/päläpälä' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 80, + 'path' => '/p%C3%B6l%C3%B6p%C3%B6l%C3%B6/p%C3%A4l%C3%A4p%C3%A4l%C3%A4', + 'query' => '', + 'fragment' => null, + + 'route' => '/p%C3%B6l%C3%B6p%C3%B6l%C3%B6/p%C3%A4l%C3%A4p%C3%A4l%C3%A4', + 'paths' => ['p%C3%B6l%C3%B6p%C3%B6l%C3%B6', 'p%C3%A4l%C3%A4p%C3%A4l%C3%A4'], + 'params' => null, + 'url' => '/p%C3%B6l%C3%B6p%C3%B6l%C3%B6/p%C3%A4l%C3%A4p%C3%A4l%C3%A4', + 'environment' => 'localhost', + 'basename' => 'p%C3%A4l%C3%A4p%C3%A4l%C3%A4', + 'base' => 'http://localhost', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost', + 'extension' => null, + 'addNonce' => 'http://localhost/p%C3%B6l%C3%B6p%C3%B6l%C3%B6/p%C3%A4l%C3%A4p%C3%A4l%C3%A4/nonce:{{nonce}}', + 'toOriginalString' => 'http://localhost/p%C3%B6l%C3%B6p%C3%B6l%C3%B6/p%C3%A4l%C3%A4p%C3%A4l%C3%A4' + ], + // Query params tests. + 'http://localhost:8080/grav/it/ueper?test=x&test2=y' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 8080, + 'path' => '/grav/it/ueper', + 'query' => 'test=x&test2=y', + 'fragment' => null, + + 'route' => '/grav/it/ueper', + 'paths' => ['grav', 'it', 'ueper'], + 'params' => null, + 'url' => '/grav/it/ueper', + 'environment' => 'localhost', + 'basename' => 'ueper', + 'base' => 'http://localhost:8080', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost:8080', + 'extension' => null, + 'addNonce' => 'http://localhost:8080/grav/it/ueper/nonce:{{nonce}}?test=x&test2=y', + ], + 'http://localhost:8080/grav/it/ueper?test=x&test2=y&test3=x&test4=y' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 8080, + 'path' => '/grav/it/ueper', + 'query' => 'test=x&test2=y&test3=x&test4=y', + 'fragment' => null, + + 'route' => '/grav/it/ueper', + 'paths' => ['grav', 'it', 'ueper'], + 'params' => null, + 'url' => '/grav/it/ueper', + 'environment' => 'localhost', + 'basename' => 'ueper', + 'base' => 'http://localhost:8080', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost:8080', + 'extension' => null, + 'addNonce' => 'http://localhost:8080/grav/it/ueper/nonce:{{nonce}}?test=x&test2=y&test3=x&test4=y', + ], + 'http://localhost:8080/grav/it/ueper?test=x&test2=y&test3=x&test4=y/test' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 8080, + 'path' => '/grav/it/ueper', + 'query' => 'test=x&test2=y&test3=x&test4=y%2Ftest', + 'fragment' => null, + + 'route' => '/grav/it/ueper', + 'paths' => ['grav', 'it', 'ueper'], + 'params' => null, + 'url' => '/grav/it/ueper', + 'environment' => 'localhost', + 'basename' => 'ueper', + 'base' => 'http://localhost:8080', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost:8080', + 'extension' => null, + 'addNonce' => 'http://localhost:8080/grav/it/ueper/nonce:{{nonce}}?test=x&test2=y&test3=x&test4=y/test', + ], + // Port tests. + 'http://localhost/a-page' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 80, + 'path' => '/a-page', + 'query' => '', + 'fragment' => null, + + 'route' => '/a-page', + 'paths' => ['a-page'], + 'params' => null, + 'url' => '/a-page', + 'environment' => 'localhost', + 'basename' => 'a-page', + 'base' => 'http://localhost', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost', + 'extension' => null, + 'addNonce' => 'http://localhost/a-page/nonce:{{nonce}}', + ], + 'http://localhost:8080/a-page' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 8080, + 'path' => '/a-page', + 'query' => '', + 'fragment' => null, + + 'route' => '/a-page', + 'paths' => ['a-page'], + 'params' => null, + 'url' => '/a-page', + 'environment' => 'localhost', + 'basename' => 'a-page', + 'base' => 'http://localhost:8080', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost:8080', + 'extension' => null, + 'addNonce' => 'http://localhost:8080/a-page/nonce:{{nonce}}', + ], + 'http://localhost:443/a-page' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 443, + 'path' => '/a-page', + 'query' => '', + 'fragment' => null, + + 'route' => '/a-page', + 'paths' => ['a-page'], + 'params' => null, + 'url' => '/a-page', + 'environment' => 'localhost', + 'basename' => 'a-page', + 'base' => 'http://localhost:443', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost:443', + 'extension' => null, + 'addNonce' => 'http://localhost:443/a-page/nonce:{{nonce}}', + ], + // Extension tests. + 'http://localhost/a-page.html' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 80, + 'path' => '/a-page', + 'query' => '', + 'fragment' => null, + + 'route' => '/a-page', + 'paths' => ['a-page'], + 'params' => null, + 'url' => '/a-page', + 'environment' => 'localhost', + 'basename' => 'a-page.html', + 'base' => 'http://localhost', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost', + 'extension' => 'html', + 'addNonce' => 'http://localhost/a-page.html/nonce:{{nonce}}', + 'toOriginalString' => 'http://localhost/a-page.html', + ], + 'http://localhost/a-page.json' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 80, + 'path' => '/a-page', + 'query' => '', + 'fragment' => null, + + 'route' => '/a-page', + 'paths' => ['a-page'], + 'params' => null, + 'url' => '/a-page', + 'environment' => 'localhost', + 'basename' => 'a-page.json', + 'base' => 'http://localhost', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost', + 'extension' => 'json', + 'addNonce' => 'http://localhost/a-page.json/nonce:{{nonce}}', + 'toOriginalString' => 'http://localhost/a-page.json', + ], + 'http://localhost/admin/ajax.json/task:getnewsfeed' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 80, + 'path' => '/admin/ajax', + 'query' => '', + 'fragment' => null, + + 'route' => '/admin/ajax', + 'paths' => ['admin', 'ajax'], + 'params' => '/task:getnewsfeed', + 'url' => '/admin/ajax', + 'environment' => 'localhost', + 'basename' => 'ajax.json', + 'base' => 'http://localhost', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost', + 'extension' => 'json', + 'addNonce' => 'http://localhost/admin/ajax.json/task:getnewsfeed/nonce:{{nonce}}', + 'toOriginalString' => 'http://localhost/admin/ajax.json/task:getnewsfeed', + ], + 'http://localhost/grav/admin/media.json/route:L1VzZXJzL3JodWsvd29ya3NwYWNlL2dyYXYtZGVtby1zYW1wbGVyL3VzZXIvYXNzZXRzL3FRMXB4Vk1ERTNJZzh5Ni5qcGc=/task:removeFileFromBlueprint/proute:/blueprint:Y29uZmlnL2RldGFpbHM=/type:config/field:deep.nested.custom_file/path:dXNlci9hc3NldHMvcVExcHhWTURFM0lnOHk2LmpwZw==' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 80, + 'path' => '/grav/admin/media', + 'query' => '', + 'fragment' => null, + + 'route' => '/grav/admin/media', + 'paths' => ['grav','admin','media'], + 'params' => '/route:L1VzZXJzL3JodWsvd29ya3NwYWNlL2dyYXYtZGVtby1zYW1wbGVyL3VzZXIvYXNzZXRzL3FRMXB4Vk1ERTNJZzh5Ni5qcGc=/task:removeFileFromBlueprint/proute:/blueprint:Y29uZmlnL2RldGFpbHM=/type:config/field:deep.nested.custom_file/path:dXNlci9hc3NldHMvcVExcHhWTURFM0lnOHk2LmpwZw==', + 'url' => '/grav/admin/media', + 'environment' => 'localhost', + 'basename' => 'media.json', + 'base' => 'http://localhost', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost', + 'extension' => 'json', + 'addNonce' => 'http://localhost/grav/admin/media.json/route:L1VzZXJzL3JodWsvd29ya3NwYWNlL2dyYXYtZGVtby1zYW1wbGVyL3VzZXIvYXNzZXRzL3FRMXB4Vk1ERTNJZzh5Ni5qcGc=/task:removeFileFromBlueprint/proute:/blueprint:Y29uZmlnL2RldGFpbHM=/type:config/field:deep.nested.custom_file/path:dXNlci9hc3NldHMvcVExcHhWTURFM0lnOHk2LmpwZw==/nonce:{{nonce}}', + 'toOriginalString' => 'http://localhost/grav/admin/media.json/route:L1VzZXJzL3JodWsvd29ya3NwYWNlL2dyYXYtZGVtby1zYW1wbGVyL3VzZXIvYXNzZXRzL3FRMXB4Vk1ERTNJZzh5Ni5qcGc=/task:removeFileFromBlueprint/proute:/blueprint:Y29uZmlnL2RldGFpbHM=/type:config/field:deep.nested.custom_file/path:dXNlci9hc3NldHMvcVExcHhWTURFM0lnOHk2LmpwZw==', + ], + 'http://localhost/a-page.foo' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 80, + 'path' => '/a-page.foo', + 'query' => '', + 'fragment' => null, + + 'route' => '/a-page.foo', + 'paths' => ['a-page.foo'], + 'params' => null, + 'url' => '/a-page.foo', + 'environment' => 'localhost', + 'basename' => 'a-page.foo', + 'base' => 'http://localhost', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost', + 'extension' => 'foo', + 'addNonce' => 'http://localhost/a-page.foo/nonce:{{nonce}}', + 'toOriginalString' => 'http://localhost/a-page.foo' + ], + // Fragment tests. + 'http://localhost:8080/a/b/c#my-fragment' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 8080, + 'path' => '/a/b/c', + 'query' => '', + 'fragment' => 'my-fragment', + + 'route' => '/a/b/c', + 'paths' => ['a', 'b', 'c'], + 'params' => null, + 'url' => '/a/b/c', + 'environment' => 'localhost', + 'basename' => 'c', + 'base' => 'http://localhost:8080', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost:8080', + 'extension' => null, + 'addNonce' => 'http://localhost:8080/a/b/c/nonce:{{nonce}}#my-fragment', + ], + // Attacks. + '">://localhost' => [ + 'scheme' => '', + 'user' => null, + 'password' => null, + 'host' => null, + 'port' => null, + 'path' => '/localhost', + 'query' => '', + 'fragment' => null, + + 'route' => '/localhost', + 'paths' => ['localhost'], + 'params' => '/script%3E:', + 'url' => '/localhost', + 'environment' => 'unknown', + 'basename' => 'localhost', + 'base' => '', + 'currentPage' => 1, + 'rootUrl' => '', + 'extension' => null, + //'addNonce' => '%22%3E%3Cscript%3Ealert%3C/localhost/script%3E:/nonce:{{nonce}}', // FIXME <- + 'toOriginalString' => '/localhost/script%3E:' // FIXME <- + ], + 'http://">' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'unknown', + 'port' => 80, + 'path' => '/script%3E', + 'query' => '', + 'fragment' => null, + + 'route' => '/script%3E', + 'paths' => ['script%3E'], + 'params' => null, + 'url' => '/script%3E', + 'environment' => 'unknown', + 'basename' => 'script%3E', + 'base' => 'http://unknown', + 'currentPage' => 1, + 'rootUrl' => 'http://unknown', + 'extension' => null, + 'addNonce' => 'http://unknown/script%3E/nonce:{{nonce}}', + 'toOriginalString' => 'http://unknown/script%3E' + ], + 'http://localhost/">' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 80, + 'path' => '/%22%3E%3Cscript%3Ealert%3C/script%3E', + 'query' => '', + 'fragment' => null, + + 'route' => '/%22%3E%3Cscript%3Ealert%3C/script%3E', + 'paths' => ['%22%3E%3Cscript%3Ealert%3C', 'script%3E'], + 'params' => null, + 'url' => '/%22%3E%3Cscript%3Ealert%3C/script%3E', + 'environment' => 'localhost', + 'basename' => 'script%3E', + 'base' => 'http://localhost', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost', + 'extension' => null, + 'addNonce' => 'http://localhost/%22%3E%3Cscript%3Ealert%3C/script%3E/nonce:{{nonce}}', + 'toOriginalString' => 'http://localhost/%22%3E%3Cscript%3Ealert%3C/script%3E' + ], + 'http://localhost/something/p1:foo/p2:">' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 80, + 'path' => '/something/script%3E', + 'query' => '', + 'fragment' => null, + + 'route' => '/something/script%3E', + 'paths' => ['something', 'script%3E'], + 'params' => '/p1:foo/p2:%22%3E%3Cscript%3Ealert%3C', + 'url' => '/something/script%3E', + 'environment' => 'localhost', + 'basename' => 'script%3E', + 'base' => 'http://localhost', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost', + 'extension' => null, + //'addNonce' => 'http://localhost/something/script%3E/p1:foo/p2:%22%3E%3Cscript%3Ealert%3C/nonce:{{nonce}}', // FIXME <- + 'toOriginalString' => 'http://localhost/something/script%3E/p1:foo/p2:%22%3E%3Cscript%3Ealert%3C' + ], + 'http://localhost/something?p=">' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 80, + 'path' => '/something', + 'query' => 'p=%22%3E%3Cscript%3Ealert%3C%2Fscript%3E', + 'fragment' => null, + + 'route' => '/something', + 'paths' => ['something'], + 'params' => null, + 'url' => '/something', + 'environment' => 'localhost', + 'basename' => 'something', + 'base' => 'http://localhost', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost', + 'extension' => null, + 'addNonce' => 'http://localhost/something/nonce:{{nonce}}?p=%22%3E%3Cscript%3Ealert%3C/script%3E', + 'toOriginalString' => 'http://localhost/something?p=%22%3E%3Cscript%3Ealert%3C/script%3E' + ], + 'http://localhost/something#">' => [ + 'scheme' => 'http://', + 'user' => null, + 'password' => null, + 'host' => 'localhost', + 'port' => 80, + 'path' => '/something', + 'query' => '', + 'fragment' => '%22%3E%3Cscript%3Ealert%3C/script%3E', + + 'route' => '/something', + 'paths' => ['something'], + 'params' => null, + 'url' => '/something', + 'environment' => 'localhost', + 'basename' => 'something', + 'base' => 'http://localhost', + 'currentPage' => 1, + 'rootUrl' => 'http://localhost', + 'extension' => null, + 'addNonce' => 'http://localhost/something/nonce:{{nonce}}#%22%3E%3Cscript%3Ealert%3C/script%3E', + 'toOriginalString' => 'http://localhost/something#%22%3E%3Cscript%3Ealert%3C/script%3E' + ], + 'https://www.getgrav.org/something/"><' => [ + 'scheme' => 'https://', + 'user' => null, + 'password' => null, + 'host' => 'www.getgrav.org', + 'port' => 443, + 'path' => '/something/%22%3E%3Cscript%3Eeval%28atob%28%22aGlzdG9yeS5wdXNoU3RhdGUoJycsJycsJy8nKTskKCdoZWFkLGJvZHknKS5odG1sKCcnKS5sb2FkKCcvJyk7JC5wb3N0KCcvYWRtaW4nLGZ1bmN0aW9uKGRhdGEpeyQucG9zdCgkKGRhdGEpLmZpbmQoJ1tpZD1hZG1pbi11c2VyLWRldGFpbHNdIGEnKS5hdHRyKCdocmVmJykseydhZG1pbi1ub25jZSc6JChkYXRhKS5maW5kKCdbZGF0YS1jbGVhci1jYWNoZV0nKS5hdHRyKCdkYXRhLWNsZWFyLWNhY2hlJykuc3BsaXQoJzonKS5wb3AoKS50cmltKCksJ2RhdGFbcGFzc3dvcmRdJzonSW0zdjFsaDR4eDByJywndGFzayc6J3NhdmUnfSl9KQ==%22%29%29%3C/script%3E%3C', + 'query' => '', + 'fragment' => null, + + 'route' => '/something/%22%3E%3Cscript%3Eeval%28atob%28%22aGlzdG9yeS5wdXNoU3RhdGUoJycsJycsJy8nKTskKCdoZWFkLGJvZHknKS5odG1sKCcnKS5sb2FkKCcvJyk7JC5wb3N0KCcvYWRtaW4nLGZ1bmN0aW9uKGRhdGEpeyQucG9zdCgkKGRhdGEpLmZpbmQoJ1tpZD1hZG1pbi11c2VyLWRldGFpbHNdIGEnKS5hdHRyKCdocmVmJykseydhZG1pbi1ub25jZSc6JChkYXRhKS5maW5kKCdbZGF0YS1jbGVhci1jYWNoZV0nKS5hdHRyKCdkYXRhLWNsZWFyLWNhY2hlJykuc3BsaXQoJzonKS5wb3AoKS50cmltKCksJ2RhdGFbcGFzc3dvcmRdJzonSW0zdjFsaDR4eDByJywndGFzayc6J3NhdmUnfSl9KQ==%22%29%29%3C/script%3E%3C', + 'paths' => ['something', '%22%3E%3Cscript%3Eeval%28atob%28%22aGlzdG9yeS5wdXNoU3RhdGUoJycsJycsJy8nKTskKCdoZWFkLGJvZHknKS5odG1sKCcnKS5sb2FkKCcvJyk7JC5wb3N0KCcvYWRtaW4nLGZ1bmN0aW9uKGRhdGEpeyQucG9zdCgkKGRhdGEpLmZpbmQoJ1tpZD1hZG1pbi11c2VyLWRldGFpbHNdIGEnKS5hdHRyKCdocmVmJykseydhZG1pbi1ub25jZSc6JChkYXRhKS5maW5kKCdbZGF0YS1jbGVhci1jYWNoZV0nKS5hdHRyKCdkYXRhLWNsZWFyLWNhY2hlJykuc3BsaXQoJzonKS5wb3AoKS50cmltKCksJ2RhdGFbcGFzc3dvcmRdJzonSW0zdjFsaDR4eDByJywndGFzayc6J3NhdmUnfSl9KQ==%22%29%29%3C', 'script%3E%3C'], + 'params' => null, + 'url' => '/something/%22%3E%3Cscript%3Eeval%28atob%28%22aGlzdG9yeS5wdXNoU3RhdGUoJycsJycsJy8nKTskKCdoZWFkLGJvZHknKS5odG1sKCcnKS5sb2FkKCcvJyk7JC5wb3N0KCcvYWRtaW4nLGZ1bmN0aW9uKGRhdGEpeyQucG9zdCgkKGRhdGEpLmZpbmQoJ1tpZD1hZG1pbi11c2VyLWRldGFpbHNdIGEnKS5hdHRyKCdocmVmJykseydhZG1pbi1ub25jZSc6JChkYXRhKS5maW5kKCdbZGF0YS1jbGVhci1jYWNoZV0nKS5hdHRyKCdkYXRhLWNsZWFyLWNhY2hlJykuc3BsaXQoJzonKS5wb3AoKS50cmltKCksJ2RhdGFbcGFzc3dvcmRdJzonSW0zdjFsaDR4eDByJywndGFzayc6J3NhdmUnfSl9KQ==%22%29%29%3C/script%3E%3C', + 'environment' => 'www.getgrav.org', + 'basename' => 'script%3E%3C', + 'base' => 'https://www.getgrav.org', + 'currentPage' => 1, + 'rootUrl' => 'https://www.getgrav.org', + 'extension' => null, + 'addNonce' => 'https://www.getgrav.org/something/%22%3E%3Cscript%3Eeval%28atob%28%22aGlzdG9yeS5wdXNoU3RhdGUoJycsJycsJy8nKTskKCdoZWFkLGJvZHknKS5odG1sKCcnKS5sb2FkKCcvJyk7JC5wb3N0KCcvYWRtaW4nLGZ1bmN0aW9uKGRhdGEpeyQucG9zdCgkKGRhdGEpLmZpbmQoJ1tpZD1hZG1pbi11c2VyLWRldGFpbHNdIGEnKS5hdHRyKCdocmVmJykseydhZG1pbi1ub25jZSc6JChkYXRhKS5maW5kKCdbZGF0YS1jbGVhci1jYWNoZV0nKS5hdHRyKCdkYXRhLWNsZWFyLWNhY2hlJykuc3BsaXQoJzonKS5wb3AoKS50cmltKCksJ2RhdGFbcGFzc3dvcmRdJzonSW0zdjFsaDR4eDByJywndGFzayc6J3NhdmUnfSl9KQ==%22%29%29%3C/script%3E%3C/nonce:{{nonce}}', + 'toOriginalString' => 'https://www.getgrav.org/something/%22%3E%3Cscript%3Eeval%28atob%28%22aGlzdG9yeS5wdXNoU3RhdGUoJycsJycsJy8nKTskKCdoZWFkLGJvZHknKS5odG1sKCcnKS5sb2FkKCcvJyk7JC5wb3N0KCcvYWRtaW4nLGZ1bmN0aW9uKGRhdGEpeyQucG9zdCgkKGRhdGEpLmZpbmQoJ1tpZD1hZG1pbi11c2VyLWRldGFpbHNdIGEnKS5hdHRyKCdocmVmJykseydhZG1pbi1ub25jZSc6JChkYXRhKS5maW5kKCdbZGF0YS1jbGVhci1jYWNoZV0nKS5hdHRyKCdkYXRhLWNsZWFyLWNhY2hlJykuc3BsaXQoJzonKS5wb3AoKS50cmltKCksJ2RhdGFbcGFzc3dvcmRdJzonSW0zdjFsaDR4eDByJywndGFzayc6J3NhdmUnfSl9KQ==%22%29%29%3C/script%3E%3C' + ], + ]; + + protected function _before(): void + { + $grav = Fixtures::get('grav'); + $this->grav = $grav(); + $this->uri = $this->grav['uri']; + $this->config = $this->grav['config']; + } + + protected function _after(): void + { + } + + protected function runTestSet(array $tests, $method, $params = []): void + { + foreach ($tests as $url => $candidates) { + if (!array_key_exists($method, $candidates) && $method !== 'toOriginalString') { + continue; + } + if ($method === 'addNonce') { + $nonce = Utils::getNonce('test-action'); + $expected = str_replace('{{nonce}}', $nonce, $candidates[$method]); + + self::assertSame($expected, Uri::addNonce($url, 'test-action')); + + continue; + } + + $this->uri->initializeWithURL($url)->init(); + if ($method === 'toOriginalString' && !isset($candidates[$method])) { + $expected = $url; + } else { + $expected = $candidates[$method]; + } + + if ($params) { + $result = call_user_func_array([$this->uri, $method], $params); + } else { + $result = $this->uri->{$method}(); + } + + self::assertSame($expected, $result, "Test \$url->{$method}() for {$url}"); + // Deal with $url->query($key) + if ($method === 'query') { + parse_str($expected, $queryParams); + foreach ($queryParams as $key => $value) { + self::assertSame($value, $this->uri->{$method}($key), "Test \$url->{$method}('{$key}') for {$url}"); + } + self::assertNull($this->uri->{$method}('non-existing'), "Test \$url->{$method}('non-existing') for {$url}"); + } + } + } + + public function testValidatingHostname(): void + { + self::assertTrue($this->uri->validateHostname('localhost')); + self::assertTrue($this->uri->validateHostname('google.com')); + self::assertTrue($this->uri->validateHostname('google.it')); + self::assertTrue($this->uri->validateHostname('goog.le')); + self::assertTrue($this->uri->validateHostname('goog.wine')); + self::assertTrue($this->uri->validateHostname('goog.localhost')); + + self::assertFalse($this->uri->validateHostname('localhost:80')); + self::assertFalse($this->uri->validateHostname('http://localhost')); + self::assertFalse($this->uri->validateHostname('localhost!')); + } + + public function testToString(): void + { + $this->runTestSet($this->tests, 'toOriginalString'); + } + + public function testScheme(): void + { + $this->runTestSet($this->tests, 'scheme'); + } + + public function testUser(): void + { + $this->runTestSet($this->tests, 'user'); + } + + public function testPassword(): void + { + $this->runTestSet($this->tests, 'password'); + } + + public function testHost(): void + { + $this->runTestSet($this->tests, 'host'); + } + + public function testPort(): void + { + $this->runTestSet($this->tests, 'port'); + } + + public function testPath(): void + { + $this->runTestSet($this->tests, 'path'); + } + + public function testQuery(): void + { + $this->runTestSet($this->tests, 'query'); + } + + public function testFragment(): void + { + $this->runTestSet($this->tests, 'fragment'); + + $this->uri->fragment('something-new'); + self::assertSame('something-new', $this->uri->fragment()); + } + + public function testPaths(): void + { + $this->runTestSet($this->tests, 'paths'); + } + + public function testRoute(): void + { + $this->runTestSet($this->tests, 'route'); + } + + public function testParams(): void + { + $this->runTestSet($this->tests, 'params'); + + $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx')->init(); + self::assertSame('/ueper:xxx', $this->uri->params('ueper')); + $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx/test:yyy')->init(); + self::assertSame('/ueper:xxx', $this->uri->params('ueper')); + self::assertSame('/test:yyy', $this->uri->params('test')); + $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx++/test:yyy')->init(); + self::assertSame('/ueper:xxx++/test:yyy', $this->uri->params()); + self::assertSame('/ueper:xxx++', $this->uri->params('ueper')); + self::assertSame('/test:yyy', $this->uri->params('test')); + $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx++/test:yyy#something')->init(); + self::assertSame('/ueper:xxx++/test:yyy', $this->uri->params()); + self::assertSame('/ueper:xxx++', $this->uri->params('ueper')); + self::assertSame('/test:yyy', $this->uri->params('test')); + $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx++/test:yyy?foo=bar')->init(); + self::assertSame('/ueper:xxx++/test:yyy', $this->uri->params()); + self::assertSame('/ueper:xxx++', $this->uri->params('ueper')); + self::assertSame('/test:yyy', $this->uri->params('test')); + $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper?test=x')->init(); + self::assertNull($this->uri->params()); + self::assertNull($this->uri->params('ueper')); + $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper?test=x&test2=y')->init(); + self::assertNull($this->uri->params()); + self::assertNull($this->uri->params('ueper')); + $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper?test=x&test2=y&test3=x&test4=y')->init(); + self::assertNull($this->uri->params()); + self::assertNull($this->uri->params('ueper')); + $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper?test=x&test2=y&test3=x&test4=y/test')->init(); + self::assertNull($this->uri->params()); + self::assertNull($this->uri->params('ueper')); + $this->uri->initializeWithURL('http://localhost:8080/a/b/c/d')->init(); + self::assertNull($this->uri->params()); + self::assertNull($this->uri->params('ueper')); + $this->uri->initializeWithURL('http://localhost:8080/a/b/c/d/e/f/a/b/c/d/e/f/a/b/c/d/e/f')->init(); + self::assertNull($this->uri->params()); + self::assertNull($this->uri->params('ueper')); + } + + public function testParam(): void + { + $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx')->init(); + self::assertSame('xxx', $this->uri->param('ueper')); + $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx/test:yyy')->init(); + self::assertSame('xxx', $this->uri->param('ueper')); + self::assertSame('yyy', $this->uri->param('test')); + $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx++/test:yy%20y/foo:bar_baz-bank')->init(); + self::assertSame('xxx++', $this->uri->param('ueper')); + self::assertSame('yy y', $this->uri->param('test')); + self::assertSame('bar_baz-bank', $this->uri->param('foo')); + } + + public function testUrl(): void + { + $this->runTestSet($this->tests, 'url'); + } + + public function testExtension(): void + { + $this->runTestSet($this->tests, 'extension'); + + $this->uri->initializeWithURL('http://localhost/a-page')->init(); + self::assertSame('x', $this->uri->extension('x')); + } + + public function testEnvironment(): void + { + $this->runTestSet($this->tests, 'environment'); + } + + public function testBasename(): void + { + $this->runTestSet($this->tests, 'basename'); + } + + public function testBase(): void + { + $this->runTestSet($this->tests, 'base'); + } + + public function testRootUrl(): void + { + $this->runTestSet($this->tests, 'rootUrl', [true]); + + $this->uri->initializeWithUrlAndRootPath('https://localhost/grav/page-foo', '/grav')->init(); + self::assertSame('/grav', $this->uri->rootUrl()); + self::assertSame('https://localhost/grav', $this->uri->rootUrl(true)); + } + + public function testCurrentPage(): void + { + $this->runTestSet($this->tests, 'currentPage'); + + $this->uri->initializeWithURL('http://localhost:8080/a-page/page:2')->init(); + self::assertSame(2, $this->uri->currentPage()); + } + + public function testReferrer(): void + { + $this->uri->initializeWithURL('http://localhost/foo/page:test')->init(); + self::assertSame('/foo', $this->uri->referrer()); + $this->uri->initializeWithURL('http://localhost/foo/bar/page:test')->init(); + self::assertSame('/foo/bar', $this->uri->referrer()); + } + + public function testIp(): void + { + $this->uri->initializeWithURL('http://localhost/foo/page:test')->init(); + self::assertSame('UNKNOWN', Uri::ip()); + } + + public function testIsExternal(): void + { + $this->uri->initializeWithURL('http://localhost/')->init(); + self::assertFalse(Uri::isExternal('/test')); + self::assertFalse(Uri::isExternal('/foo/bar')); + self::assertTrue(Uri::isExternal('http://localhost/test')); + self::assertTrue(Uri::isExternal('http://google.it/test')); + } + + public function testBuildUrl(): void + { + $parsed_url = [ + 'scheme' => 'http', + 'host' => 'localhost', + 'port' => 8080, + ]; + + self::assertSame('http://localhost:8080', Uri::buildUrl($parsed_url)); + + $parsed_url = [ + 'scheme' => 'http', + 'host' => 'localhost', + 'port' => 8080, + 'user' => 'foo', + 'pass' => 'bar', + 'path' => '/test', + 'query' => 'x=2', + 'fragment' => 'xxx', + ]; + + self::assertSame('http://foo:bar@localhost:8080/test?x=2#xxx', Uri::buildUrl($parsed_url)); + + /** @var Uri $uri */ + $uri = Grav::instance()['uri']; + + $uri->initializeWithUrlAndRootPath('https://testing.dev/subdir/path1/path2/file.html', '/subdir')->init(); + self::assertSame('https://testing.dev/subdir/path1/path2/file.html', Uri::buildUrl($uri->toArray(true))); + + $uri->initializeWithUrlAndRootPath('https://testing.dev/subdir/path1/path2/file.foo', '/subdir')->init(); + self::assertSame('https://testing.dev/subdir/path1/path2/file.foo', Uri::buildUrl($uri->toArray(true))); + + $uri->initializeWithUrlAndRootPath('https://testing.dev/subdir/path1/path2/file.html', '/subdir/path1')->init(); + self::assertSame('https://testing.dev/subdir/path1/path2/file.html', Uri::buildUrl($uri->toArray(true))); + + $uri->initializeWithUrlAndRootPath('https://testing.dev/subdir/path1/path2/file.html/foo:blah/bang:boom', '/subdir')->init(); + self::assertSame('https://testing.dev/subdir/path1/path2/file.html/foo:blah/bang:boom', Uri::buildUrl($uri->toArray(true))); + + $uri->initializeWithUrlAndRootPath('https://testing.dev/subdir/path1/path2/file.html/foo:blah/bang:boom?fig=something', '/subdir')->init(); + self::assertSame('https://testing.dev/subdir/path1/path2/file.html/foo:blah/bang:boom?fig=something', Uri::buildUrl($uri->toArray(true))); + } + + public function testConvertUrl(): void + { + } + + public function testAddNonce(): void + { + $this->runTestSet($this->tests, 'addNonce'); + } + + public function testCustomBase(): void + { + $current_base = $this->config->get('system.custom_base_url'); + $this->config->set('system.custom_base_url', '/test'); + $this->uri->initializeWithURL('https://mydomain.example.com:8090/test/korteles/kodai%20something?test=true#some-fragment')->init(); + + $this->assertSame([ + "scheme" => "https", + "host" => "mydomain.example.com", + "port" => 8090, + "user" => null, + "pass" => null, + "path" => "/korteles/kodai%20something", + "params" => [], + "query" => "test=true", + "fragment" => "some-fragment", + ], $this->uri->toArray()); + + $this->config->set('system.custom_base_url', $current_base); + } +} diff --git a/tests/unit/Grav/Common/UtilsTest.php b/tests/unit/Grav/Common/UtilsTest.php new file mode 100644 index 0000000..9a29ad7 --- /dev/null +++ b/tests/unit/Grav/Common/UtilsTest.php @@ -0,0 +1,572 @@ +grav = $grav(); + $this->uri = $this->grav['uri']; + } + + protected function _after(): void + { + } + + public function testStartsWith(): void + { + self::assertTrue(Utils::startsWith('english', 'en')); + self::assertTrue(Utils::startsWith('English', 'En')); + self::assertTrue(Utils::startsWith('ENGLISH', 'EN')); + self::assertTrue(Utils::startsWith( + 'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH', + 'EN' + )); + + self::assertFalse(Utils::startsWith('english', 'En')); + self::assertFalse(Utils::startsWith('English', 'EN')); + self::assertFalse(Utils::startsWith('ENGLISH', 'en')); + self::assertFalse(Utils::startsWith( + 'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH', + 'e' + )); + + self::assertTrue(Utils::startsWith('english', 'En', false)); + self::assertTrue(Utils::startsWith('English', 'EN', false)); + self::assertTrue(Utils::startsWith('ENGLISH', 'en', false)); + self::assertTrue(Utils::startsWith( + 'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH', + 'e', + false + )); + } + + public function testEndsWith(): void + { + self::assertTrue(Utils::endsWith('english', 'sh')); + self::assertTrue(Utils::endsWith('EngliSh', 'Sh')); + self::assertTrue(Utils::endsWith('ENGLISH', 'SH')); + self::assertTrue(Utils::endsWith( + 'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH', + 'ENGLISH' + )); + + self::assertFalse(Utils::endsWith('english', 'de')); + self::assertFalse(Utils::endsWith('EngliSh', 'sh')); + self::assertFalse(Utils::endsWith('ENGLISH', 'Sh')); + self::assertFalse(Utils::endsWith( + 'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH', + 'DEUSTCH' + )); + + self::assertTrue(Utils::endsWith('english', 'SH', false)); + self::assertTrue(Utils::endsWith('EngliSh', 'sH', false)); + self::assertTrue(Utils::endsWith('ENGLISH', 'sh', false)); + self::assertTrue(Utils::endsWith( + 'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH', + 'english', + false + )); + } + + public function testContains(): void + { + self::assertTrue(Utils::contains('english', 'nglis')); + self::assertTrue(Utils::contains('EngliSh', 'gliSh')); + self::assertTrue(Utils::contains('ENGLISH', 'ENGLI')); + self::assertTrue(Utils::contains( + 'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH', + 'ENGLISH' + )); + + self::assertFalse(Utils::contains('EngliSh', 'GLI')); + self::assertFalse(Utils::contains('EngliSh', 'English')); + self::assertFalse(Utils::contains('ENGLISH', 'SCH')); + self::assertFalse(Utils::contains( + 'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH', + 'DEUSTCH' + )); + + self::assertTrue(Utils::contains('EngliSh', 'GLI', false)); + self::assertTrue(Utils::contains('EngliSh', 'ENGLISH', false)); + self::assertTrue(Utils::contains('ENGLISH', 'ish', false)); + self::assertTrue(Utils::contains( + 'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH', + 'english', + false + )); + } + + public function testSubstrToString(): void + { + self::assertEquals('en', Utils::substrToString('english', 'glish')); + self::assertEquals('english', Utils::substrToString('english', 'test')); + self::assertNotEquals('en', Utils::substrToString('english', 'lish')); + + self::assertEquals('en', Utils::substrToString('english', 'GLISH', false)); + self::assertEquals('english', Utils::substrToString('english', 'TEST', false)); + self::assertNotEquals('en', Utils::substrToString('english', 'LISH', false)); + } + + public function testMergeObjects(): void + { + $obj1 = new stdClass(); + $obj1->test1 = 'x'; + $obj2 = new stdClass(); + $obj2->test2 = 'y'; + + $objMerged = Utils::mergeObjects($obj1, $obj2); + + self::arrayHasKey('test1', (array) $objMerged); + self::arrayHasKey('test2', (array) $objMerged); + } + + public function testDateFormats(): void + { + $dateFormats = Utils::dateFormats(); + self::assertIsArray($dateFormats); + self::assertContainsOnly('string', $dateFormats); + + $default_format = $this->grav['config']->get('system.pages.dateformat.default'); + + if ($default_format !== null) { + self::assertArrayHasKey($default_format, $dateFormats); + } + } + + public function testTruncate(): void + { + self::assertEquals('engli' . '…', Utils::truncate('english', 5)); + self::assertEquals('english', Utils::truncate('english')); + self::assertEquals('This is a string to truncate', Utils::truncate('This is a string to truncate')); + self::assertEquals('Th' . '…', Utils::truncate('This is a string to truncate', 2)); + self::assertEquals('engli' . '...', Utils::truncate('english', 5, true, " ", "...")); + self::assertEquals('english', Utils::truncate('english')); + self::assertEquals('This is a string to truncate', Utils::truncate('This is a string to truncate')); + self::assertEquals('This' . '…', Utils::truncate('This is a string to truncate', 3, true)); + self::assertEquals('', 6, true)); + } + + public function testSafeTruncate(): void + { + self::assertEquals('This' . '…', Utils::safeTruncate('This is a string to truncate', 1)); + self::assertEquals('This' . '…', Utils::safeTruncate('This is a string to truncate', 4)); + self::assertEquals('This is' . '…', Utils::safeTruncate('This is a string to truncate', 5)); + } + + public function testTruncateHtml(): void + { + self::assertEquals('T...', Utils::truncateHtml('This is a string to truncate', 1)); + self::assertEquals('This is...', Utils::truncateHtml('This is a string to truncate', 7)); + self::assertEquals('

    T...

    ', Utils::truncateHtml('

    This is a string to truncate

    ', 1)); + self::assertEquals('

    This...

    ', Utils::truncateHtml('

    This is a string to truncate

    ', 4)); + self::assertEquals('

    This is a...

    ', Utils::truncateHtml('

    This is a string to truncate

    ', 10)); + self::assertEquals('

    This is a string to truncate

    ', Utils::truncateHtml('

    This is a string to truncate

    ', 100)); + self::assertEquals('', Utils::truncateHtml('', 6)); + self::assertEquals('
    1. item 1 so...
    ', Utils::truncateHtml('
    1. item 1 something
    2. item 2 bold
    ', 10)); + self::assertEquals("

    This is a string.

    \n

    It splits two lines.

    ", Utils::truncateHtml("

    This is a string.

    \n

    It splits two lines.

    ", 100)); + } + + public function testSafeTruncateHtml(): void + { + self::assertEquals('This...', Utils::safeTruncateHtml('This is a string to truncate', 1)); + self::assertEquals('This is a...', Utils::safeTruncateHtml('This is a string to truncate', 3)); + self::assertEquals('

    This...

    ', Utils::safeTruncateHtml('

    This is a string to truncate

    ', 1)); + self::assertEquals('

    This is...

    ', Utils::safeTruncateHtml('

    This is a string to truncate

    ', 2)); + self::assertEquals('

    This is a string to...

    ', Utils::safeTruncateHtml('

    This is a string to truncate

    ', 5)); + self::assertEquals('

    This is a string to truncate

    ', Utils::safeTruncateHtml('

    This is a string to truncate

    ', 20)); + self::assertEquals('', Utils::safeTruncateHtml('', 6)); + self::assertEquals('
    1. item 1 something
    2. item 2...
    ', Utils::safeTruncateHtml('
    1. item 1 something
    2. item 2 bold
    ', 5)); + } + + public function testGenerateRandomString(): void + { + self::assertNotEquals(Utils::generateRandomString(), Utils::generateRandomString()); + self::assertNotEquals(Utils::generateRandomString(20), Utils::generateRandomString(20)); + } + + public function download(): void + { + } + + public function testGetMimeByExtension(): void + { + self::assertEquals('application/octet-stream', Utils::getMimeByExtension('')); + self::assertEquals('text/html', Utils::getMimeByExtension('html')); + self::assertEquals('application/json', Utils::getMimeByExtension('json')); + self::assertEquals('application/atom+xml', Utils::getMimeByExtension('atom')); + self::assertEquals('application/rss+xml', Utils::getMimeByExtension('rss')); + self::assertEquals('image/jpeg', Utils::getMimeByExtension('jpg')); + self::assertEquals('image/png', Utils::getMimeByExtension('png')); + self::assertEquals('text/plain', Utils::getMimeByExtension('txt')); + self::assertEquals('application/msword', Utils::getMimeByExtension('doc')); + self::assertEquals('application/octet-stream', Utils::getMimeByExtension('foo')); + self::assertEquals('foo/bar', Utils::getMimeByExtension('foo', 'foo/bar')); + self::assertEquals('text/html', Utils::getMimeByExtension('foo', 'text/html')); + } + + public function testGetExtensionByMime(): void + { + self::assertEquals('html', Utils::getExtensionByMime('*/*')); + self::assertEquals('html', Utils::getExtensionByMime('text/*')); + self::assertEquals('html', Utils::getExtensionByMime('text/html')); + self::assertEquals('json', Utils::getExtensionByMime('application/json')); + self::assertEquals('atom', Utils::getExtensionByMime('application/atom+xml')); + self::assertEquals('rss', Utils::getExtensionByMime('application/rss+xml')); + self::assertEquals('jpg', Utils::getExtensionByMime('image/jpeg')); + self::assertEquals('png', Utils::getExtensionByMime('image/png')); + self::assertEquals('txt', Utils::getExtensionByMime('text/plain')); + self::assertEquals('doc', Utils::getExtensionByMime('application/msword')); + self::assertEquals('html', Utils::getExtensionByMime('foo/bar')); + self::assertEquals('baz', Utils::getExtensionByMime('foo/bar', 'baz')); + } + + public function testNormalizePath(): void + { + self::assertEquals('/test', Utils::normalizePath('/test')); + self::assertEquals('test', Utils::normalizePath('test')); + self::assertEquals('test', Utils::normalizePath('../test')); + self::assertEquals('/test', Utils::normalizePath('/../test')); + self::assertEquals('/test2', Utils::normalizePath('/test/../test2')); + self::assertEquals('/test3', Utils::normalizePath('/test/../test2/../test3')); + + self::assertEquals('//cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.css', Utils::normalizePath('//cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.css')); + self::assertEquals('//use.fontawesome.com/releases/v5.8.1/css/all.css', Utils::normalizePath('//use.fontawesome.com/releases/v5.8.1/css/all.css')); + self::assertEquals('//use.fontawesome.com/releases/v5.8.1/webfonts/fa-brands-400.eot', Utils::normalizePath('//use.fontawesome.com/releases/v5.8.1/css/../webfonts/fa-brands-400.eot')); + + self::assertEquals('http://cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.css', Utils::normalizePath('http://cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.css')); + self::assertEquals('http://use.fontawesome.com/releases/v5.8.1/css/all.css', Utils::normalizePath('http://use.fontawesome.com/releases/v5.8.1/css/all.css')); + self::assertEquals('http://use.fontawesome.com/releases/v5.8.1/webfonts/fa-brands-400.eot', Utils::normalizePath('http://use.fontawesome.com/releases/v5.8.1/css/../webfonts/fa-brands-400.eot')); + + self::assertEquals('https://cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.css', Utils::normalizePath('https://cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.css')); + self::assertEquals('https://use.fontawesome.com/releases/v5.8.1/css/all.css', Utils::normalizePath('https://use.fontawesome.com/releases/v5.8.1/css/all.css')); + self::assertEquals('https://use.fontawesome.com/releases/v5.8.1/webfonts/fa-brands-400.eot', Utils::normalizePath('https://use.fontawesome.com/releases/v5.8.1/css/../webfonts/fa-brands-400.eot')); + } + + public function testIsFunctionDisabled(): void + { + $disabledFunctions = explode(',', ini_get('disable_functions')); + + if ($disabledFunctions[0]) { + self::assertEquals(Utils::isFunctionDisabled($disabledFunctions[0]), true); + } + } + + public function testTimezones(): void + { + $timezones = Utils::timezones(); + + self::assertIsArray($timezones); + self::assertContainsOnly('string', $timezones); + } + + public function testArrayFilterRecursive(): void + { + $array = [ + 'test' => '', + 'test2' => 'test2' + ]; + + $array = Utils::arrayFilterRecursive($array, function ($k, $v) { + return !(is_null($v) || $v === ''); + }); + + self::assertContainsOnly('string', $array); + self::assertArrayNotHasKey('test', $array); + self::assertArrayHasKey('test2', $array); + self::assertEquals('test2', $array['test2']); + } + + public function testPathPrefixedByLangCode(): void + { + $languagesEnabled = $this->grav['config']->get('system.languages.supported', []); + $arrayOfLanguages = ['en', 'de', 'it', 'es', 'dk', 'el']; + $languagesNotEnabled = array_diff($arrayOfLanguages, $languagesEnabled); + $oneLanguageNotEnabled = reset($languagesNotEnabled); + + if (count($languagesEnabled)) { + $languageCodePathPrefix = Utils::pathPrefixedByLangCode('/' . $languagesEnabled[0] . '/test'); + $this->assertIsString($languageCodePathPrefix); + $this->assertTrue(in_array($languageCodePathPrefix, $languagesEnabled)); + } + + self::assertFalse(Utils::pathPrefixedByLangCode('/' . $oneLanguageNotEnabled . '/test')); + self::assertFalse(Utils::pathPrefixedByLangCode('/test')); + self::assertFalse(Utils::pathPrefixedByLangCode('/xx')); + self::assertFalse(Utils::pathPrefixedByLangCode('/xx/')); + self::assertFalse(Utils::pathPrefixedByLangCode('/')); + } + + public function testDate2timestamp(): void + { + $timestamp = strtotime('10 September 2000'); + self::assertSame($timestamp, Utils::date2timestamp('10 September 2000')); + self::assertSame($timestamp, Utils::date2timestamp('2000-09-10 00:00:00')); + } + + public function testResolve(): void + { + $array = [ + 'test' => [ + 'test2' => 'test2Value' + ] + ]; + + self::assertEquals('test2Value', Utils::resolve($array, 'test.test2')); + } + + public function testGetDotNotation(): void + { + $array = [ + 'test' => [ + 'test2' => 'test2Value', + 'test3' => [ + 'test4' => 'test4Value' + ] + ] + ]; + + self::assertEquals('test2Value', Utils::getDotNotation($array, 'test.test2')); + self::assertEquals('test4Value', Utils::getDotNotation($array, 'test.test3.test4')); + self::assertEquals('defaultValue', Utils::getDotNotation($array, 'test.non_existent', 'defaultValue')); + } + + public function testSetDotNotation(): void + { + $array = [ + 'test' => [ + 'test2' => 'test2Value', + 'test3' => [ + 'test4' => 'test4Value' + ] + ] + ]; + + $new = [ + 'test1' => 'test1Value' + ]; + + Utils::setDotNotation($array, 'test.test3.test4', $new); + self::assertEquals('test1Value', $array['test']['test3']['test4']['test1']); + } + + public function testIsPositive(): void + { + self::assertTrue(Utils::isPositive(true)); + self::assertTrue(Utils::isPositive(1)); + self::assertTrue(Utils::isPositive('1')); + self::assertTrue(Utils::isPositive('yes')); + self::assertTrue(Utils::isPositive('on')); + self::assertTrue(Utils::isPositive('true')); + self::assertFalse(Utils::isPositive(false)); + self::assertFalse(Utils::isPositive(0)); + self::assertFalse(Utils::isPositive('0')); + self::assertFalse(Utils::isPositive('no')); + self::assertFalse(Utils::isPositive('off')); + self::assertFalse(Utils::isPositive('false')); + self::assertFalse(Utils::isPositive('some')); + self::assertFalse(Utils::isPositive(2)); + } + + public function testGetNonce(): void + { + self::assertIsString(Utils::getNonce('test-action')); + self::assertIsString(Utils::getNonce('test-action', true)); + self::assertSame(Utils::getNonce('test-action'), Utils::getNonce('test-action')); + self::assertNotSame(Utils::getNonce('test-action'), Utils::getNonce('test-action2')); + } + + public function testVerifyNonce(): void + { + self::assertTrue(Utils::verifyNonce(Utils::getNonce('test-action'), 'test-action')); + } + + public function testGetPagePathFromToken(): void + { + self::assertEquals('', Utils::getPagePathFromToken('')); + self::assertEquals('/test/path', Utils::getPagePathFromToken('/test/path')); + } + + public function testUrl(): void + { + $this->uri->initializeWithUrl('http://testing.dev/path1/path2')->init(); + + // Fail hard + self::assertSame(false, Utils::url('', true)); + self::assertSame(false, Utils::url('')); + self::assertSame(false, Utils::url(new stdClass())); + self::assertSame(false, Utils::url(['foo','bar','baz'])); + self::assertSame(false, Utils::url('user://does/not/exist')); + + // Fail Gracefully + self::assertSame('/', Utils::url('/', false, true)); + self::assertSame('/', Utils::url('', false, true)); + self::assertSame('/', Utils::url(new stdClass(), false, true)); + self::assertSame('/', Utils::url(['foo','bar','baz'], false, true)); + self::assertSame('/user/does/not/exist', Utils::url('user://does/not/exist', false, true)); + + // Simple paths + self::assertSame('/', Utils::url('/')); + self::assertSame('/path1', Utils::url('/path1')); + self::assertSame('/path1/path2', Utils::url('/path1/path2')); + self::assertSame('/random/path1/path2', Utils::url('/random/path1/path2')); + self::assertSame('/foobar.jpg', Utils::url('/foobar.jpg')); + self::assertSame('/path1/foobar.jpg', Utils::url('/path1/foobar.jpg')); + self::assertSame('/path1/path2/foobar.jpg', Utils::url('/path1/path2/foobar.jpg')); + self::assertSame('/random/path1/path2/foobar.jpg', Utils::url('/random/path1/path2/foobar.jpg')); + + // Simple paths with domain + self::assertSame('http://testing.dev/', Utils::url('/', true)); + self::assertSame('http://testing.dev/path1', Utils::url('/path1', true)); + self::assertSame('http://testing.dev/path1/path2', Utils::url('/path1/path2', true)); + self::assertSame('http://testing.dev/random/path1/path2', Utils::url('/random/path1/path2', true)); + self::assertSame('http://testing.dev/foobar.jpg', Utils::url('/foobar.jpg', true)); + self::assertSame('http://testing.dev/path1/foobar.jpg', Utils::url('/path1/foobar.jpg', true)); + self::assertSame('http://testing.dev/path1/path2/foobar.jpg', Utils::url('/path1/path2/foobar.jpg', true)); + self::assertSame('http://testing.dev/random/path1/path2/foobar.jpg', Utils::url('/random/path1/path2/foobar.jpg', true)); + + // Relative paths from Grav root. + self::assertSame('/subdir', Utils::url('subdir')); + self::assertSame('/subdir/path1', Utils::url('subdir/path1')); + self::assertSame('/subdir/path1/path2', Utils::url('subdir/path1/path2')); + self::assertSame('/path1', Utils::url('path1')); + self::assertSame('/path1/path2', Utils::url('path1/path2')); + self::assertSame('/foobar.jpg', Utils::url('foobar.jpg')); + self::assertSame('http://testing.dev/foobar.jpg', Utils::url('foobar.jpg', true)); + + // Relative paths from Grav root with domain. + self::assertSame('http://testing.dev/foobar.jpg', Utils::url('foobar.jpg', true)); + self::assertSame('http://testing.dev/foobar.jpg', Utils::url('/foobar.jpg', true)); + self::assertSame('http://testing.dev/path1/foobar.jpg', Utils::url('/path1/foobar.jpg', true)); + + // All Non-existing streams should be treated as external URI / protocol. + self::assertSame('http://domain.com/path', Utils::url('http://domain.com/path')); + self::assertSame('ftp://domain.com/path', Utils::url('ftp://domain.com/path')); + self::assertSame('sftp://domain.com/path', Utils::url('sftp://domain.com/path')); + self::assertSame('ssh://domain.com', Utils::url('ssh://domain.com')); + self::assertSame('pop://domain.com', Utils::url('pop://domain.com')); + self::assertSame('foo://bar/baz', Utils::url('foo://bar/baz')); + self::assertSame('foo://bar/baz', Utils::url('foo://bar/baz', true)); + self::assertSame('mailto:joe@domain.com', Utils::url('mailto:joe@domain.com', true)); // FIXME <- + } + + public function testUrlWithRoot(): void + { + $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/path1/path2', '/subdir')->init(); + + // Fail hard + self::assertSame(false, Utils::url('', true)); + self::assertSame(false, Utils::url('')); + self::assertSame(false, Utils::url(new stdClass())); + self::assertSame(false, Utils::url(['foo','bar','baz'])); + self::assertSame(false, Utils::url('user://does/not/exist')); + + // Fail Gracefully + self::assertSame('/subdir/', Utils::url('/', false, true)); + self::assertSame('/subdir/', Utils::url('', false, true)); + self::assertSame('/subdir/', Utils::url(new stdClass(), false, true)); + self::assertSame('/subdir/', Utils::url(['foo','bar','baz'], false, true)); + self::assertSame('/subdir/user/does/not/exist', Utils::url('user://does/not/exist', false, true)); + + // Simple paths + self::assertSame('/subdir/', Utils::url('/')); + self::assertSame('/subdir/path1', Utils::url('/path1')); + self::assertSame('/subdir/path1/path2', Utils::url('/path1/path2')); + self::assertSame('/subdir/random/path1/path2', Utils::url('/random/path1/path2')); + self::assertSame('/subdir/foobar.jpg', Utils::url('/foobar.jpg')); + self::assertSame('/subdir/path1/foobar.jpg', Utils::url('/path1/foobar.jpg')); + self::assertSame('/subdir/path1/path2/foobar.jpg', Utils::url('/path1/path2/foobar.jpg')); + self::assertSame('/subdir/random/path1/path2/foobar.jpg', Utils::url('/random/path1/path2/foobar.jpg')); + + // Simple paths with domain + self::assertSame('http://testing.dev/subdir/', Utils::url('/', true)); + self::assertSame('http://testing.dev/subdir/path1', Utils::url('/path1', true)); + self::assertSame('http://testing.dev/subdir/path1/path2', Utils::url('/path1/path2', true)); + self::assertSame('http://testing.dev/subdir/random/path1/path2', Utils::url('/random/path1/path2', true)); + self::assertSame('http://testing.dev/subdir/foobar.jpg', Utils::url('/foobar.jpg', true)); + self::assertSame('http://testing.dev/subdir/path1/foobar.jpg', Utils::url('/path1/foobar.jpg', true)); + self::assertSame('http://testing.dev/subdir/path1/path2/foobar.jpg', Utils::url('/path1/path2/foobar.jpg', true)); + self::assertSame('http://testing.dev/subdir/random/path1/path2/foobar.jpg', Utils::url('/random/path1/path2/foobar.jpg', true)); + + // Absolute Paths including the grav base. + self::assertSame('/subdir/', Utils::url('/subdir')); + self::assertSame('/subdir/', Utils::url('/subdir/')); + self::assertSame('/subdir/path1', Utils::url('/subdir/path1')); + self::assertSame('/subdir/path1/path2', Utils::url('/subdir/path1/path2')); + self::assertSame('/subdir/foobar.jpg', Utils::url('/subdir/foobar.jpg')); + self::assertSame('/subdir/path1/foobar.jpg', Utils::url('/subdir/path1/foobar.jpg')); + + // Absolute paths from Grav root with domain. + self::assertSame('http://testing.dev/subdir/', Utils::url('/subdir', true)); + self::assertSame('http://testing.dev/subdir/', Utils::url('/subdir/', true)); + self::assertSame('http://testing.dev/subdir/path1', Utils::url('/subdir/path1', true)); + self::assertSame('http://testing.dev/subdir/path1/path2', Utils::url('/subdir/path1/path2', true)); + self::assertSame('http://testing.dev/subdir/foobar.jpg', Utils::url('/subdir/foobar.jpg', true)); + self::assertSame('http://testing.dev/subdir/path1/foobar.jpg', Utils::url('/subdir/path1/foobar.jpg', true)); + + // Relative paths from Grav root. + self::assertSame('/subdir/sub', Utils::url('/sub')); + self::assertSame('/subdir/subdir', Utils::url('subdir')); + self::assertSame('/subdir/subdir2/sub', Utils::url('/subdir2/sub')); + self::assertSame('/subdir/subdir/path1', Utils::url('subdir/path1')); + self::assertSame('/subdir/subdir/path1/path2', Utils::url('subdir/path1/path2')); + self::assertSame('/subdir/path1', Utils::url('path1')); + self::assertSame('/subdir/path1/path2', Utils::url('path1/path2')); + self::assertSame('/subdir/foobar.jpg', Utils::url('foobar.jpg')); + self::assertSame('http://testing.dev/subdir/foobar.jpg', Utils::url('foobar.jpg', true)); + + // All Non-existing streams should be treated as external URI / protocol. + self::assertSame('http://domain.com/path', Utils::url('http://domain.com/path')); + self::assertSame('ftp://domain.com/path', Utils::url('ftp://domain.com/path')); + self::assertSame('sftp://domain.com/path', Utils::url('sftp://domain.com/path')); + self::assertSame('ssh://domain.com', Utils::url('ssh://domain.com')); + self::assertSame('pop://domain.com', Utils::url('pop://domain.com')); + self::assertSame('foo://bar/baz', Utils::url('foo://bar/baz')); + self::assertSame('foo://bar/baz', Utils::url('foo://bar/baz', true)); + // self::assertSame('mailto:joe@domain.com', Utils::url('mailto:joe@domain.com', true)); // FIXME <- + } + + public function testUrlWithStreams(): void + { + } + + public function testUrlwithExternals(): void + { + $this->uri->initializeWithUrl('http://testing.dev/path1/path2')->init(); + self::assertSame('http://foo.com', Utils::url('http://foo.com')); + self::assertSame('https://foo.com', Utils::url('https://foo.com')); + self::assertSame('//foo.com', Utils::url('//foo.com')); + self::assertSame('//foo.com?param=x', Utils::url('//foo.com?param=x')); + } + + public function testCheckFilename(): void + { + // configure extension for consistent results + /** @var \Grav\Common\Config\Config $config */ + $config = $this->grav['config']; + $config->set('security.uploads_dangerous_extensions', ['php', 'html', 'htm', 'exe', 'js']); + + self::assertFalse(Utils::checkFilename('foo.php')); + self::assertFalse(Utils::checkFilename('foo.PHP')); + self::assertFalse(Utils::checkFilename('bar.js')); + + self::assertTrue(Utils::checkFilename('foo.json')); + self::assertTrue(Utils::checkFilename('foo.xml')); + self::assertTrue(Utils::checkFilename('foo.yaml')); + self::assertTrue(Utils::checkFilename('foo.yml')); + } +} diff --git a/tests/unit/Grav/Console/Gpm/InstallCommandTest.php b/tests/unit/Grav/Console/Gpm/InstallCommandTest.php new file mode 100644 index 0000000..94aef1a --- /dev/null +++ b/tests/unit/Grav/Console/Gpm/InstallCommandTest.php @@ -0,0 +1,28 @@ +grav = Fixtures::get('grav'); + $this->installCommand = new InstallCommand(); + } + + protected function _after(): void + { + } +} diff --git a/tests/unit/Grav/Framework/File/Formatter/CsvFormatterTest.php b/tests/unit/Grav/Framework/File/Formatter/CsvFormatterTest.php new file mode 100644 index 0000000..7bff4e2 --- /dev/null +++ b/tests/unit/Grav/Framework/File/Formatter/CsvFormatterTest.php @@ -0,0 +1,48 @@ + 1, 'col2' => 2, 'col3' => 3], + ['col1' => 'aaa', 'col2' => 'bbb', 'col3' => 'ccc'], + ]; + + $encoded = (new CsvFormatter())->encode($data); + + $lines = array_filter(explode(PHP_EOL, $encoded)); + + self::assertCount(3, $lines); + self::assertEquals('col1,col2,col3', $lines[0]); + } + + /** + * TBD - If indexes are all numeric, what's the purpose + * of displaying header + */ + public function testEncodeWithIndexColumns(): void + { + $data = [ + [0 => 1, 1 => 2, 2 => 3], + ]; + + $encoded = (new CsvFormatter())->encode($data); + + $lines = array_filter(explode(PHP_EOL, $encoded)); + + self::assertCount(2, $lines); + self::assertEquals('0,1,2', $lines[0]); + } + + public function testEncodeEmptyData(): void + { + $encoded = (new CsvFormatter())->encode([]); + self::assertEquals('', $encoded); + } +} diff --git a/tests/unit/Grav/Framework/Filesystem/FilesystemTest.php b/tests/unit/Grav/Framework/Filesystem/FilesystemTest.php new file mode 100644 index 0000000..2aea40c --- /dev/null +++ b/tests/unit/Grav/Framework/Filesystem/FilesystemTest.php @@ -0,0 +1,338 @@ + [ + 'parent' => '', + 'normalize' => '', + 'dirname' => '', + 'pathinfo' => [ + 'basename' => '', + 'filename' => '', + ] + ], + '.' => [ + 'parent' => '', + 'normalize' => '', + 'dirname' => '.', + 'pathinfo' => [ + 'dirname' => '.', + 'basename' => '.', + 'extension' => '', + 'filename' => '', + ] + ], + './' => [ + 'parent' => '', + 'normalize' => '', + 'dirname' => '.', + 'pathinfo' => [ + 'dirname' => '.', + 'basename' => '.', + 'extension' => '', + 'filename' => '', + ] + ], + '././.' => [ + 'parent' => '', + 'normalize' => '', + 'dirname' => './.', + 'pathinfo' => [ + 'dirname' => './.', + 'basename' => '.', + 'extension' => '', + 'filename' => '', + ] + ], + '.file' => [ + 'parent' => '.', + 'normalize' => '.file', + 'dirname' => '.', + 'pathinfo' => [ + 'dirname' => '.', + 'basename' => '.file', + 'extension' => 'file', + 'filename' => '', + ] + ], + '/' => [ + 'parent' => '', + 'normalize' => '/', + 'dirname' => '/', + 'pathinfo' => [ + 'dirname' => '/', + 'basename' => '', + 'filename' => '', + ] + ], + '/absolute' => [ + 'parent' => '/', + 'normalize' => '/absolute', + 'dirname' => '/', + 'pathinfo' => [ + 'dirname' => '/', + 'basename' => 'absolute', + 'filename' => 'absolute', + ] + ], + '/absolute/' => [ + 'parent' => '/', + 'normalize' => '/absolute', + 'dirname' => '/', + 'pathinfo' => [ + 'dirname' => '/', + 'basename' => 'absolute', + 'filename' => 'absolute', + ] + ], + '/very/long/absolute/path' => [ + 'parent' => '/very/long/absolute', + 'normalize' => '/very/long/absolute/path', + 'dirname' => '/very/long/absolute', + 'pathinfo' => [ + 'dirname' => '/very/long/absolute', + 'basename' => 'path', + 'filename' => 'path', + ] + ], + '/very/long/absolute/../path' => [ + 'parent' => '/very/long', + 'normalize' => '/very/long/path', + 'dirname' => '/very/long/absolute/..', + 'pathinfo' => [ + 'dirname' => '/very/long/absolute/..', + 'basename' => 'path', + 'filename' => 'path', + ] + ], + 'relative' => [ + 'parent' => '.', + 'normalize' => 'relative', + 'dirname' => '.', + 'pathinfo' => [ + 'dirname' => '.', + 'basename' => 'relative', + 'filename' => 'relative', + ] + ], + 'very/long/relative/path' => [ + 'parent' => 'very/long/relative', + 'normalize' => 'very/long/relative/path', + 'dirname' => 'very/long/relative', + 'pathinfo' => [ + 'dirname' => 'very/long/relative', + 'basename' => 'path', + 'filename' => 'path', + ] + ], + 'path/to/file.jpg' => [ + 'parent' => 'path/to', + 'normalize' => 'path/to/file.jpg', + 'dirname' => 'path/to', + 'pathinfo' => [ + 'dirname' => 'path/to', + 'basename' => 'file.jpg', + 'extension' => 'jpg', + 'filename' => 'file', + ] + ], + 'user://' => [ + 'parent' => '', + 'normalize' => 'user://', + 'dirname' => 'user://', + 'pathinfo' => [ + 'dirname' => 'user://', + 'basename' => '', + 'filename' => '', + 'scheme' => 'user', + ] + ], + 'user://.' => [ + 'parent' => '', + 'normalize' => 'user://', + 'dirname' => 'user://', + 'pathinfo' => [ + 'dirname' => 'user://', + 'basename' => '', + 'filename' => '', + 'scheme' => 'user', + ] + ], + 'user://././.' => [ + 'parent' => '', + 'normalize' => 'user://', + 'dirname' => 'user://', + 'pathinfo' => [ + 'dirname' => 'user://', + 'basename' => '', + 'filename' => '', + 'scheme' => 'user', + ] + ], + 'user://./././file' => [ + 'parent' => 'user://', + 'normalize' => 'user://file', + 'dirname' => 'user://', + 'pathinfo' => [ + 'dirname' => 'user://', + 'basename' => 'file', + 'filename' => 'file', + 'scheme' => 'user', + ] + ], + 'user://./././folder/file' => [ + 'parent' => 'user://folder', + 'normalize' => 'user://folder/file', + 'dirname' => 'user://folder', + 'pathinfo' => [ + 'dirname' => 'user://folder', + 'basename' => 'file', + 'filename' => 'file', + 'scheme' => 'user', + ] + ], + 'user://.file' => [ + 'parent' => 'user://', + 'normalize' => 'user://.file', + 'dirname' => 'user://', + 'pathinfo' => [ + 'dirname' => 'user://', + 'basename' => '.file', + 'extension' => 'file', + 'filename' => '', + 'scheme' => 'user', + ] + ], + 'user:///' => [ + 'parent' => '', + 'normalize' => 'user:///', + 'dirname' => 'user:///', + 'pathinfo' => [ + 'dirname' => 'user:///', + 'basename' => '', + 'filename' => '', + 'scheme' => 'user', + ] + ], + 'user:///absolute' => [ + 'parent' => 'user:///', + 'normalize' => 'user:///absolute', + 'dirname' => 'user:///', + 'pathinfo' => [ + 'dirname' => 'user:///', + 'basename' => 'absolute', + 'filename' => 'absolute', + 'scheme' => 'user', + ] + ], + 'user:///very/long/absolute/path' => [ + 'parent' => 'user:///very/long/absolute', + 'normalize' => 'user:///very/long/absolute/path', + 'dirname' => 'user:///very/long/absolute', + 'pathinfo' => [ + 'dirname' => 'user:///very/long/absolute', + 'basename' => 'path', + 'filename' => 'path', + 'scheme' => 'user', + ] + ], + 'user://relative' => [ + 'parent' => 'user://', + 'normalize' => 'user://relative', + 'dirname' => 'user://', + 'pathinfo' => [ + 'dirname' => 'user://', + 'basename' => 'relative', + 'filename' => 'relative', + 'scheme' => 'user', + ] + ], + 'user://very/long/relative/path' => [ + 'parent' => 'user://very/long/relative', + 'normalize' => 'user://very/long/relative/path', + 'dirname' => 'user://very/long/relative', + 'pathinfo' => [ + 'dirname' => 'user://very/long/relative', + 'basename' => 'path', + 'filename' => 'path', + 'scheme' => 'user', + ] + ], + 'user://path/to/file.jpg' => [ + 'parent' => 'user://path/to', + 'normalize' => 'user://path/to/file.jpg', + 'dirname' => 'user://path/to', + 'pathinfo' => [ + 'dirname' => 'user://path/to', + 'basename' => 'file.jpg', + 'extension' => 'jpg', + 'filename' => 'file', + 'scheme' => 'user', + ] + ], + ]; + + protected function _before(): void + { + $this->class = Filesystem::getInstance(); + } + + protected function _after(): void + { + unset($this->class); + } + + /** + * @param array $tests + * @param string $method + */ + protected function runTestSet(array $tests, $method): void + { + $class = $this->class; + foreach ($tests as $path => $candidates) { + if (!array_key_exists($method, $candidates)) { + continue; + } + + $expected = $candidates[$method]; + + $result = $class->{$method}($path); + + self::assertSame($expected, $result, "Test {$method}('{$path}')"); + + if (function_exists($method) && !strpos($path, '://')) { + $cmp_result = $method($path); + + self::assertSame($cmp_result, $result, "Compare to original {$method}('{$path}')"); + } + } + } + + public function testParent(): void + { + $this->runTestSet($this->tests, 'parent'); + } + + public function testNormalize(): void + { + $this->runTestSet($this->tests, 'normalize'); + } + + public function testDirname(): void + { + $this->runTestSet($this->tests, 'dirname'); + } + + public function testPathinfo(): void + { + $this->runTestSet($this->tests, 'pathinfo'); + } +} diff --git a/tests/unit/_bootstrap.php b/tests/unit/_bootstrap.php new file mode 100644 index 0000000..5a76515 --- /dev/null +++ b/tests/unit/_bootstrap.php @@ -0,0 +1,3 @@ +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 ANB{r; literal 0 HcmV?d00001 diff --git a/user/pages/01.home/02.diag/Ptest.png b/user/pages/01.home/02.diag/Ptest.png new file mode 100644 index 0000000000000000000000000000000000000000..b53a08c327f2045809d08b73886d0a80edb2240b GIT binary patch literal 51400 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 V(L=*jG)|UWH?e{@U-3@=e*givw{`#k literal 0 HcmV?d00001 diff --git a/user/pages/01.home/02.diag/default.md b/user/pages/01.home/02.diag/default.md new file mode 100644 index 0000000..adf27ee --- /dev/null +++ b/user/pages/01.home/02.diag/default.md @@ -0,0 +1,17 @@ +--- +title: diag +--- + +##### - Heyyyy, c’est trop bien il neige ! ça fait des années que je n'en ai pas vu autant ! + +###### - Ouais c’est trop cool hahaha *tremblement de froid* Par contre il caille, t’aurais pu te dépêcher ! + +##### - Désolé, j’ai vu le message tard… + +###### - T’inquiète pas.. je vais te le faire payer hahaha *Dit-il avec après avoir fais une boule de neige* + +##### - Même pas peur, je vais tout esquiver + +###### - On va voir ça ! On parie ? + + diff --git a/user/pages/01.home/03.boule/boule.md b/user/pages/01.home/03.boule/boule.md new file mode 100644 index 0000000..80f0030 --- /dev/null +++ b/user/pages/01.home/03.boule/boule.md @@ -0,0 +1,5 @@ +--- +title: boule +--- + + \ No newline at end of file diff --git a/user/pages/01.home/debut.md b/user/pages/01.home/debut.md new file mode 100644 index 0000000..377c7d7 --- /dev/null +++ b/user/pages/01.home/debut.md @@ -0,0 +1,18 @@ +--- +title: Home +body_classes: 'title-center title-h1h2' +content: + items: + - '@self.children' + limit: 5 + order: + by: date + dir: desc + pagination: true + url_taxonomy_filters: true +--- + + + + + diff --git a/user/plugins/.gitkeep b/user/plugins/.gitkeep new file mode 100644 index 0000000..8c3b423 --- /dev/null +++ b/user/plugins/.gitkeep @@ -0,0 +1 @@ +/* @copyright Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved. */ diff --git a/user/themes/.gitkeep b/user/themes/.gitkeep new file mode 100644 index 0000000..8c3b423 --- /dev/null +++ b/user/themes/.gitkeep @@ -0,0 +1 @@ +/* @copyright Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved. */ diff --git a/webserver-configs/Caddyfile b/webserver-configs/Caddyfile new file mode 100644 index 0000000..cfceced --- /dev/null +++ b/webserver-configs/Caddyfile @@ -0,0 +1,31 @@ +# To use this file simply install caddy and run the command below from the root of your Grav site +# Once running it will redirect http://localhost to https://localhost (new default for Caddy2) +# More infromation here: https://caddyserver.com/docs/ +# +# $ caddy run --config webserver-configs/Caddyfile + +localhost +encode gzip +root * . +file_server + +php_fastcgi 127.0.0.1:9000 + +# Begin - Security +# deny all direct access for these folders +rewrite /(\.git|cache|bin|logs|backups|tests)/.* /403 + +# deny running scripts inside core system folders +rewrite /(system|vendor)/.*\.(txt|xml|md|html|htm|shtml|shtm|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ /403 + +# deny running scripts inside user folder +rewrite /user/.*\.(txt|md|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ /403 + +# deny access to specific files in the root folder +rewrite /(LICENSE\.txt|composer\.lock|composer\.json|nginx\.conf|web\.config|htaccess\.txt|\.htaccess) /403 + +respond /403 403 +## End - Security + +# global rewrite should come last. +try_files {path} {path}/ /index.php?_url={uri}&{query} diff --git a/webserver-configs/Caddyfile-0.8.x b/webserver-configs/Caddyfile-0.8.x new file mode 100644 index 0000000..9e977a9 --- /dev/null +++ b/webserver-configs/Caddyfile-0.8.x @@ -0,0 +1,33 @@ +# Caddyfile for Caddy 0.8.x and below + +:8080 +gzip +fastcgi / 127.0.0.1:9000 php + +# Begin - Security +# deny all direct access for these folders +rewrite { + r /(\.git|cache|bin|logs|backups|tests)/.*$ + status 403 +} +# deny running scripts inside core system folders +rewrite { + r /(system|vendor)/.*\.(txt|xml|md|html|htm|shtml|shtm|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ + status 403 +} +# deny running scripts inside user folder +rewrite { + r /user/.*\.(txt|md|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ + status 403 +} +# deny access to specific files in the root folder +rewrite { + r /(LICENSE\.txt|composer\.lock|composer\.json|nginx\.conf|web\.config|htaccess\.txt|\.htaccess) + status 403 +} +## End - Security + +# global rewrite should come last. +rewrite { + to {path} {path}/ /index.php?_url={uri}&{query} +} diff --git a/webserver-configs/htaccess.txt b/webserver-configs/htaccess.txt new file mode 100644 index 0000000..15436a7 --- /dev/null +++ b/webserver-configs/htaccess.txt @@ -0,0 +1,78 @@ + + +RewriteEngine On + +## Begin RewriteBase +# If you are getting 500 or 404 errors on subpages, you may have to uncomment the RewriteBase entry +# You should change the '/' to your appropriate subfolder. For example if you have +# your Grav install at the root of your site '/' should work, else it might be something +# along the lines of: RewriteBase / +## + +# RewriteBase / + +## End - RewriteBase + +## Begin - X-Forwarded-Proto +# In some hosted or load balanced environments, SSL negotiation happens upstream. +# In order for Grav to recognize the connection as secure, you need to uncomment +# the following lines. +# +# RewriteCond %{HTTP:X-Forwarded-Proto} https +# RewriteRule .* - [E=HTTPS:on] +# +## End - X-Forwarded-Proto + +## Begin - Exploits +# If you experience problems on your site block out the operations listed below +# This attempts to block the most common type of exploit `attempts` to Grav +# +# Block out any script trying to use twig tags in URL. +RewriteCond %{REQUEST_URI} ({{|}}|{%|%}) [OR] +RewriteCond %{QUERY_STRING} ({{|}}|{%25|%25}) [OR] +# Block out any script trying to base64_encode data within the URL. +RewriteCond %{QUERY_STRING} base64_encode[^(]*\([^)]*\) [OR] +# Block out any script that includes a

    E?}98Fmx)Kxgxr0l)GlZO*Ujvy955 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%0DW<3G;&>gwh7~aItTd|7 z_gfG81?Q*$U_h6`Z$vrr`^!Vje{j{-eulV3wTEuDitSB8Bg&~Jit6OKJb{q5ZKuM#XjyxkB}|9a!*xH#mPoP=R1Kl(EO zurOY;cka<~5m!qgf44>b4(vp}MiV9Wc}?}F-tLYwnz;(Y7x?zAKm9+`l-T-?uM~Sa zkY8zb!_hSGy4BE}5gSk~O2uS;%h~CF8T`X*eMnby-u9TfzVN48e+dc&v1@K`={B*W zDoU}>pTFJgfJzW>dN&9DOoFxGFITp5x{kkh^iBxygR{84>MUZeKna|l$HVFb{FuaOtcM+xtU_v{ zYu!;;pF*sB+=e^X4sVC{lvNUNrT`<6)-zEMX3z6B+Cm7Wg&S(_fB~vb0A=F6^{aGS zf67?3=eps|t4?v5TzEkrqh@)W3;YXKI}N*iPTQEi`?w*d24+5*4?)gGh%2F14PRtO{_^9_^6}pIr`VuMoqW^)}%4% z=#k#{A9nBrT%>0l~!z| zxG06hw%nfcnlbezHs`REK$O63+lR3&H}tnC0&<(dt0Kdb%EUw+y8ReiwzQnA_t;-T zHZ}t4&!jen)P^3uB)B$Z2{pD?(6B3%Q>cXH8e6E2@#hy4BuT8bcZTt4qaMz9oqxV@ar8aFZs+*@QX_3&L#;BJ-J;iglvLEZz}L&&xr^ZF%y~fx z%4be!a3%~Dy&z~RK&V~0N!<4Yxf@ZaWg)frb3Wt3-}$imWL&EihRytq3dgtRGs7Je z@OTxNQ1RP_pHD|`h^ctJadGgyF2UD&m`%N>ekt}XW@rIFEWc#6^iGh~{&%S(pqK}1 zDC-*vc$-Hy>vhl#;=T|Jk;wrfeJl?zi8>}ebjV##GNkxY&Y}~ ze-9BF>Ji}{Z8Y&`>MgxLKTKP4wB72oorx(*pLH9()3o~aszR6%4fXq{V6xU0eCla+ z^{1OG+yLZK$i7XK`5bs^GPu2FueMGty zNt4VarMOYXGFKuB$y9_!WG*rs!#ULzWvV1e9FioVl7tL}O2R?rnQ+YHn1}N|YwzRU z-}}D%pL;(!d+)W^dYp2CuFm;!Ai>xc_ATzGd5C6=(TO` z!^nSuE9c~8ujCdxdH-#hKw&Rs930hVrAu;sfIoE{y{vhCE|^Ip+fg^|6DEa=3^PyYOB09N&SE@w_~^BRXc0_mB+#$Rg<%3zVACm2a(9p z?+N`cH=zjuI`k|FI_HHug^MNe-~dR}l~*|ZnwkZjUD4odh_jWk{y(0$aUFa^* z)Hv|tAMyYE28fb|X&p5PGH#bQZjZPIeVdy z^5vT6t*~>|RF6Px85wc1T-ovFGP`sJ{N^6}zpJ+{9ew@Kvh7z}xlP5X+)6wL0vg!W z4-Hf7Q-TyYgOBBP-oO6XS5?tNsWq|DiV-gf$@|4VFCC~x2~ud9vQmw6n+im2vDcF6InV?F z7`%I>ZtPj6%w3=TLVXX@4gx2vwG2;C7E(7N>4f^*qpKexD?Jb~SpU4OK_eN6*{FTp zpa}fI<+rW?WfMy^gl`tlLoVSq+dawpw)woIBFt(M)PJ-rjNRJ4`B?6WUEMjW3%RSD zvW~)i9p7<%N#2JB`v)sZfgSks$%KA`^pRrF&t>wjt_UW-X`rk8(}w4*%aGjh9jihn z8_fVV6(#mn=51$r9<;1n_k`>{c(R}eCk*#@X+KW!$O$6R<+_)p-7D|0gIllXZ({?l zA?6Rm*@XR_O^^&m*Yj`XTxw&~3prnCw1zj>!W0@vn>}5ReI(qpz|*&r>~?io6BW|| z03oWk{a7A&2#{C!3I~k&=9c%~ME8zmL-z8)ntN+(c6z#NR+6fZJ(%F`TpNE+X_Z4hY7LN$pW0(X!LbFjPcW_j-GC;fmEUwihsudBIl4zc4y4c1 zi5mH5`^pL({5HdE@*!2!)>=9Nup4+wq3}dn(X5a-qOm3nN zyayFRuozH|-nBd{ZeKCaJ&r$ki#aD( z1A$UBB0|R)#94TtwZ(SY8XO}IDB9K9VP4Ek5TNr1NW)q?DZ#C03&bQs-o7!zd=0*!c^Eh2?0s94h|+J zBdZUYZsilYlb`QL>BVF*^nF)@hVDw*$Ahb5L#ifrz((`(z|$vJRF`Dh;I|$Sz-dfQ z$sI$P%Q(qRaC#=&Z=Xo@N~DPh9pskT-wDrPx-iCp{iCV^5?#f= z5u|kX^Qw^YCN`+gwLB>>a$A<*sdl7ph_t$e^u5sAiOZl5e0cWr6>cq0+4L)Ev-W6T zzQ#hvhW`Yy@bgCuO{jap1pQunq(%pOqfZ)U&lE>qV>d=4)vQdNJzfGNCz(l;hQeY( ze*A#$JAdVTMeh5G!m3{=MGg-T+8$l*^>L3|+nLp|u~ica(Eh9hlZr@_3|?$~P^=BE z2H|?hrLKyni9=GeOq?K~TdsM8E$-sn{G0}OZnY@ZK(62m?|`pXi&YRM)3d$1?b?4*G-gt{MuIv)tVA^S0k{b(NZycq~j3Yy0DYhLW$2o6~2i%Qk` z*DsI^`DCIf2x1K&BFD$vf@6gZmqo-E;x5nLT}#J%cEcm0K=RBjKK1{QvAEnda|!lV z%zLeM(SS6BDKg>YbOl-Ef%x;(GZ!}~3nui5xmvC`mNeVWXfpi7RO;1x{d^J|fqKNH zoQQpDiI+KcqyX6$eZQ>o17jA&0g#$+wAs>_a6ZA#TZB5CMZ^Y%%NXFn_a!w-!x&aqdo`Q0!}Z?+||E~KcG;-r6lZYAY@8L0}qFFR)jTk%kdkJxscM>upIcxe$@^|2&Hk6RhL7q}=C15$8Dy(2nA_o4Cu4n=#t zn__^s^a^c4zuWZJ+Jy?uk{VlltR37?7jNMK{!4=3WDXnp&OOi# z&j$tQRSU42cFx{*xOw06j`f zoHf*gSZrGVD{Yn+dVoNT*=R*|GB9+6i8}a9s25`Z`68>5riTlEU?BAVLG+{WA|LPG z4@TtBC*d8Xpf$O!8`Oyw1v7eYYaKp*hcZd@oBMSD(pkMynY;&uhjL7OXVsH6)cv4Yya|22RM^Z35rhr*EGG&W z*OvqNp(5zUjfs1Nzp{3$HOtM#K6B&Ny`kwT1i^_Nt7C(!CYHR8tT>iVye_t-SIQad zp#h(1(Dg&6b>ug_A1d`cA4AFt0i20M8UtWy_w zn$k{ZiCL8qHaW&%g#6<>_u%>!+lFlrb^G;{Ev%B}MM8QB#|eDZBFzl%$A3aNum(Ze zaG0uq(M=~~EUtr1d1?3O(JiMSCS(HD!~cR(-bgrCTj?ux z1tmF|KG(h249>%zf%h*~xcz(#?~Oh(KMGu?6`9UzsAhP^G$VO-Syc`Bfa;d3b84ZR z7>dj45XRmBH2z%m9x3;p;4RdPcNWmrk&$kRfL0)J&GpHr{dHf0tZiKv)Z4Wy5S^$ z)khiy9dV%Zb}`|Fct|GJ;yOTxYxb>k5<{dNq&)M)u8k0?Yv&`0H_GLXJyFu=2{2Qr&YJyW3vXjSrw?r<+ATpD~D@~0E9VyGbIL6#S z1@>-^y9f~@^yRNXMdwl_+r)>A>dNqsghS_{PIL}9!qJ^HcUXfVIA|PokD7Inhv`#J zgi74!pg67@l!X%dSbu@y09|+UKVGop)t}+6#0(yjo!lm4veaO3hpDx|y+okI0C!;C zFwl&ms2`O#1AE9V53p+mph0O?#QO9**fJ5^V!%Ug&%0Wi^pHk`jtC#0Vc%Eyxs-F= zT|&%UyA_IEJ6m^n6v9>oRgo>AM!Abs0kmgE*BT5HJcdI zrae8lIzOsvqVs?li(3FRWo%h>7=JO158wmp$W^w}h^Rsf%$wvR*0D_JeaQ74enPg{ zr>t0K#G=^ynyvjOZFW6ib?ZfdZHSe^dB)haZ5Y~dh9gUT#J7L7SdzXoOripuF9z{* zK!+NMO}B63il87vR8EI6sG_i9NK_g8r`%UTj-Y& zf<~t5rcgw47u2YpPcXy#_Bx0sp^vzS@Lp9|mDS=L{v|3e@n?{Y=4f06Ds_lrh~aU2 z^apa$e(6)MUlL-VLaNtjhNxK?Fc<<6p`5RiL8F@nwQDz);0)zhM9dGO7huex)x^(` zQ!|6obxVvil8f$3afl()r9CzY>QqHZQz=M>%oi-iCv?aUx~^R7uWu`Q<^kO>@)B1N z!csDGCD5Og6L2tnT)z{0`RG$PaR~OELM=|CzdLp-;nex_OMk4<461DZcLtRu6Oe=9 z>7M=hvot$mza`pdN2eHg2A?4;1k#}{d60vH&U+3RoPY-}1cr^MD<;HHM3-9Z8xf1M0HrX6$&&uhQ+Cv436%o2^c=(SkEJ_^2fG ztwWPm3F++ru^#GTfLaj}kfe*VIf2_qrJdM>P+2x^tV{*YVSvhoqzdTj(rG5FjKagSc+vMv3I+DJ$L?PG64CR)%lRh4FEslg2tyrHn+%Wzj3LJ z&>-m!6NUvI4Mw&Nr$Zzz&V~ab8<4+-Z4I%-P8r#8XedKrKKWD=ObM*YY1tXTnpzVf ztOj>Y+KXW?BLqAHZ#+wF&gCQwVj@E-4VNkELEO-T?e&o>%3BAf$REu@1u|MK$*-BG z^*|F>3%|j>ottSp!E_@H#*HiW3}DA!R9;}$wZXAUXytv|=6ebr0&L|hkN%*yWdO zdA+}WKCJ}&piV;B+DcePnmcnH_Dg&=ozOg`_-Q+f8>P0y+#e-~nTv3)z+$OfDhN4~ z#2>}urQa`w@=mahy_ZIVEL`IKo5O3z3Pn-qH<0!vfmbC7>`7bjU-~|`BV9h7mE%Hdm42xoocy8zBHeoy#+z?lqu3-M_ycsWvG_BG|+18JxHlZiI$c1E5Di4>r)BA#(=C zBW5jzEytozMAT_AVq*I<}ku`voUAdEX6qy4E>_F~D zIpva#4Yt3l!D`kg9_kH#K4l}pSHvJi7@RBdxT_Q~`Hm@$KH<>i)NfXyVE3Lw>3E6{ zjgIWbhc=*+L%l3+7Fg4t9){%NbS{060=sbBb(wOQUr|Dux4MOY090jy<*?|%*6!Hb z6T5b-aQlkCI1jxT6{)H%%@_E-V6MDX&%cs$CaKSo=^$=vT~o9}MBN6Ew;mS#2Q0W> zN>J9C|FK{|yMRU_cJiDpPaNQr>66o+EiL&aje!dALPu06HsH3|Ib*L`*$=@cniGPd z|JC60{~@3pDriL>i>aW}Bl~2qKh5qZ|##dqn{L}Jq19$U*$Rnq&UYM z6d(v1O|Uan5#>eD*uW6Fqmq&}m~gaqHoP5r39191Ad=Ac5cnJM2ng%nKdq*|VqsiJ zT27tGjX8^OXeaG5;ETHr-ZjC90*ro)u}Vtrum4}=uijs%74!O zjSXBqvXnz$Ba7liy_-^hG!3T_g(-l5jPSw$gKzeAtKKi{cESNZ#N>L~7oNcyI{hhj z+Y6o8a5p+G7yL$np?@kKM__{oCQelX`#CLfkHriNx2 zbwU%f!3+LXu8|Pz^haSW8YhI>+u-y9hxLGw~yS*E|8=V=(SS|zYc?LqlFw|5R# zcAGG`7)dHRa8?(ufB>Dk-U@HIEL|v+FTh3%O zV}D5?t%>El^ntCV9UbY5nLhC+1&=)lQ=MekWU#Up@HuhyUSkiZ_B;^1W1X>wZRiu4 zZ(8@t>ROkg(Dj8L_91p5kM#RJ9*ZZhtFvoO(=C`Cu-r)#Lx;!0_6_5uLB^nt zhLMQK$fb-#e#W@k8Q%E{D^FVsN^YxzBpl3QrXgw`9bF#AXc{4=nuN}zc3v~(dL(?i zlJ8o5^?Ly*PoJG85oKe_trJd)muoG|mDcD*-L5d?YuhZ``g#qE(&%%xf=l$4o`EZh zeeZ2(xhCv8L1&@3shGcj-` zA#m&c6~r>oj|kC7bHSL$*A#K-QV+Wc(VADQ6A!y{9)9bQG&EDs0Jvx#XQTv7ZhRH3}15R5$HqZ0vd`fAjdf*BmhAF^abH1gYMa3f= z9Gjdzjp>^R&3dGpJRCUTG1Ag#*OrsHF#hIXq?1E%rc-;UW}XnY!O{$g<|@pXSf3eq z!KY$h#!2y`8XEa0U#=4^)tWuLJz8+9E$`8yNdBfwrIZ!#%S-nW<%uKPrL>B(zFYyx zdlI5~0z|BhG^5W*$rO#8w#Q1{2yx|h{k=b6ZdC=2w_p8%C~?f+=6rW87>&Zc`LxmG zQQ>q4T_L@oiHw~kO?};l(KV^%<-Nn!t{!mYdfA?iUPE!*Z-4gEz;Y`ljYe~9P&ujn0>jBZlS z|G>t3t(4j(;^-oPzADB-P`whFTljLhmD11j5b>0@G9p^-E-6~MrnR7t)A&01T>9!|+2D=|A`>bf{ zd(Ng=sgfA`LaOI@B2Q6xNSs)zsdI7TC?mZjeJP2dfpcWC{A{x@iBUnEyuF?-2to$- zv0sNVrl#F3dHa3pS-(rev!suwKwqtnW(M?C#3-2qewk#+D@13?#yI>Mton3a;!FMw z$r||YlPo9S-aS>>KC16nSq=DvEct%+@WB`*09eZ}Nw1SnND|fFP!4pu!OL*NR+Z^z z(CtP45}f{Kk+X&81?m0d@6p8_?NA_yuAw(UqYLjpeA;Jv>ZafChX_s-El%+^dt{MD zdm+P`SU|b65Mb(`%SGWkAO`x^@GVJ!XUB!Jg2XckOJy6oNE83i9OPkZ>h?eWhaI1)NauSe$Mb;!At2HhI>uzCGXYx@?T<{^5SM{%Em>wyH1|H+N|Cu;5vh|hdpJIdV_hhe_3kiK{DSadtchz_x^|Q$ zIM(~5imra&@kC*bVufGiw(BE5lk=<=^%XKMSNrVDnR0t;1Wlda;*Ks6X1yo8zhaE`-?bj_iPA;+2fp7v5Rb z?;Ac{PZ8kOi}!b&D&0!-bsRQLdSR1+`UU-unAz`(lu^F?ttaTH{>+W3sDWt$Ox_?& zUML5(xlL&z@5kukXVP1USRvdzh-|IP$>IM|5IoRNxdW$*Ds=#%UGiX|_l(YBZ# zojxMRq&c25k|!9p_IbpjRo`gl3G?5w9$b3ktV)MH{_4}_q>1)m4pO&p`sL$A9*c`Z zb2V0iyhH}ieFvXk;V|1Ka;*)=LY~HZ%MDE0{u+c%2erwS6xGd&09P=mP6zqHhwegm zed71M0vI(aSoOUn+x43oyNbA#xbD&7#=tSwW3;P~HJF(rEm=8T&Nw5SgG-v{Ut4V2 zt-uPrc*eFkoK>apAKSpC*pa{RJAh%y zW*bg?Vm&vz6cX!wHs6T2M`S5`19jd#V!C94F+<@~#nn?eZlF3dB1=E-{MZRAZ#wmU z=J=gGw`>TCH>+f*cc9^YhTsfoiA#djTVk5NRIy~b^es(gc-AE0>h+6`sqZth&p7=W zTw)>kg>09d?#|0%?2=cg^CwSvrUChypX|-ZUs4(E2|OLnLTo2aYAy`rrt_G?nQN$n zA6(PGC*RJsMUC<**VF?oVqGO%zK2C!>?6o8P}7zaenwG2PT#bLe3)vh93|Ho^bSmn ze@}{O_n(rWtCP35zSf(Va>m}MG&){$+T3EVlts4m+kHw(@(MB=g(d&in4Fs&sO#;$ zIhs53H!HLheqR~um+zI(CuFK$6U{xocW0d1Ye4p&=A^w$*U#5B)2aSNyU%s!jH>mw z^4-sMQO$OR?-hq(fvAQWtR#$_@$Kb#d)<95R?~<7oBnUJY@cs!h8c}#lPwD@EuKaP zmGMYDicC8jbxcA2S*_jdP1fllg#c=#lw~j%&z7zPyX(pMnVD3(%va)*S$W>V`)>zK zdBszl+jj8GsjKKRWUa)ZvuHTb2J@#edo+vd!V9LueDDL8;e>b~c&Wf-m5$!BnX|}0 z89aU`nHzcRH1`xnyPA;Fm=xpnBW?Dz9uw2;rRz3j?c#Fgv`}u}Px+kc?6}wAb*iy^ zeSHud!&AMdekDEDBxN(;m)Vn0-#_*58XY$2B<_BwGf@&BFUl*>5?y*}Xw#g=uJFnA z(zcXjZ0eTAFKGB!vD3J2GpqQfd!0!Oi%NsF{D-O2FV+d0-wniaVl?+uHyNjeX~sL_ zSv7{$$XvUju*w@nxA6tf+_YI_4Krme&J=jLinKNa20k8(U~5XOwL6;gmK*F*`nvN> zH~`XT_tr><{Xwz!9TmIhpXx`}H`%;`=?c=PyiDrv_erMT3Y@vkM-|>;V{=zq(p2eB z%jg!SCqCnYOk*Z)2Nh&LNlcA+7Y86CN#iwFV+@{xrIeVxsP8efrxd2-iIc@Aa*m)$?ZPkvu!PSb5_} zXY*as_MPIj07BZ7f;(V^&gMY(ES|=UXKV@#{1&D%j2UHO#Scaw!{8uu(F*9>;y-5& zgeq@MueCF-nVf7EHcpAq%(D#ri}g^PeQmX@-4!!SkaH~!;4W}TPr`fq`dV;<1b+7H zG9FOPKHKJ9Le-o-bwyuA*LkWt&&GFlUQ0!{Poyg>g1mGM8Je=St|>a0`Vkg#di=9% zU2XJum5*o)wHbz1Q{QmCWg!$={IHf5f!(%=(O0_*7?G*Dv))0jJXwxDEg-f0_VyA% zuC7;SyGs822oN@bnrx}{KqIr9(Ar7Vr-fK8cHAn>j*6ObIlui?eY)wOx%pa{-B+hP zTpDEF*OvO_T}|j)nmDELx|Fb3GKN=kcGTC(D{#bi8ioq0b!M;;+@2FtdpFjpYyIe1 zGa;@%-7q(66)9i2Fdrc`=gscic3MYXmH3j8hiGwAQ_I=Y`NUc(FU=mLIkUF*Pi9%9 zn55OG)A@gzHa|?@V=7+G)X<|OSY;9x>r|UcX!a< z9(JrZI>=x1Km71^K;+#cN6Ig`xH#_$-y>dYSy~wP7gg18k=U(A962&6>w8e7&3MIJ z*hV};L-wm@`i5=bgt2RAJP^G~8yAyCOG_Oz>$m=!QLyApIp`_$$?M`CMrZA+xk~3> zgU8WS3<7w*b?f<|k@I%DVxa}JCI?b=+QL#Ockc75?upR?tFr)O1CxWbb9HrxQ)*wm zdOx8#H56mY($ssYpzULL?0h|x2+T{XGp=^wp>k?E##Zv@W1fAMSy;7d*Sb`&e>f-R zaoX#%UN~>oxemG2^O>Xmqtg%jVB+jy2HyHj^dcYnZQDyWDlgw8G*X)U(==drP(*}R zAoxP^d*h}|`<;=MeE{XZh^m_1punyQ&FT=&P~T&1GP^>RY~eSF5lQW$cxudIu*4k#_1??><|#vwxG~+%frkR{B_s}b$qaPU<3g9f`+H_>bT?o3yHt~ z%2>h1#2c?%jeI?xy)qd9Ku!d!LKa6Ggd7tXPhM#nB90wknF@nTMsizXV zSqerV&uu1Y1;>2FNwnl#FIz=rAv?PEIO_>7{AMh@HjLT4g0>fSu+^EEmeosI#Xcr6 z7Q4Dy&bG{q6{R9>Vv=HPOv{S6?Qw@N?D|gFxkxI4$lN;XZ6u$`e%7=8nsyS@PH94De?Q$33LJwMNU(yEp_t?CNrBWc? zRbdK_^b;ByziQkhMV9l8HXc9xfk3Ro=33^3R%jA zO>qA`(L1CKalT;0OZ{?f5^Pk5djiMkoHSR^j5gIt&C6crE0!n3G#M~9-=o)?J!b5n zklGZTSr#&#B&`s#iC5#ez+h9jgRrsy?`d@2)1EguqQ-k0%I zY({R=actQLaWPLq=AL|(@yV`Q>^4t~?_?G@DYiR@#_{y^J|iv?Gl9Um062VbJUBJt zTU;a;pQkrfQ@$&|%?AQ>vEcubws5x_vCb5C4V~C8f(12=jBuZ$;R*^x4;wm%ZdESg zIsQO&oywAiCN44~zh2fS7^Fu6sfYd^5DNj5SX(_drkQr~q!pT5t;LJujvOZ$qY3XUj$Z2xoW$IIN zc?a)E+lWff)>bvAF)S8DEy}lLi;>zQCR9h#-xYr46{q9%cN8c2!NKxKpcfR)Vy=e- zzncHw2P$+2OW^H_a8H~-T?TyTc|JTfQoJY#6S1^7TgfJ1T?y7Zq>+m{)Lg!bB zSKwHbFbklGO)q|IwMf(q(kYShEHaDa=LyAB8Ewap^xROn^XcEi+C3`c+HVA`hI> z2%RE1|G3l_b3K|NvDspivMT)CjKixtf-us!Fgv}hdm|C>*}0y_-TjWYa3ueUO;C}!lDR8LR4Jce7HZ7Uyj)8m$#c`>@xC(oGHW|5N8?3 zIog;%(Ca0{^IN@tFh8|Hl9iQp=jOOnJ$hLPpNgy1z!jpR$kOT69c4z5X(g5U^TfA} zFVmB8sP}n!pqs zf8r>NJd@rQC|s}LZ89cm5YYn2wJg2qm>2d~LwBjQ_lU9xX`SO$*PG8SmAfQYE>Yl#AWz>Ci&o==6yLc7A z&_9>9HJ)N=8n~p^^1Moh>p76d9pDcUMl?aX!221tcUHoF!M z#OIQJyZURav`tC&`>35@BXk;EGtJ^49m4d5tL`smY80%3Oj0f{;4x+2-zW)$)=sBz zkry&D%WM^5x-W0sxba{O)uf#k5G_mWjYr;Z;LOFHWbe0@dE9EV6;_+=K{f2K`Bit$ zt+7jFuN>=$^&I$(Wsczy?@#P9H(_a#fPmHHoC=r#Dg@ji%9pmGFz=?J+aSao^tK z0gK5ljDZtw-}We(DgFh{6FnS)s4v&RaH$6$+_-ccrYJnm@0MnzveCbq<%bfP+4~j~ zQk7CoUf~GfR)CVC`uP*4@OfldtL{oaM=0BvD^E4gx{FCp-!bNr5f|BE$!HL$h8Bw{ zY%Xe;T7W-rD#Ktpp1I~1P#hS8ndX-DtU^>ol^$H80yd)Sy|Jq4F^E7aHV!J-(toO>pcw2*#f# zv8-@YbDVrC8G9kg#=|YOhovRmI)NKt&Fsum-*%=?ob&z9mzIHSnIur$?LZNcYkZ>{ z<||(MjBp85BNl)?dh~w6Ks?RVIgo{`>VJ?EK3Gu~UsYm!q;mx= zO3*;R#`$P*t`32#sAp~88XP&BzcN2@7k_PSM8AS%GSJInasUmygeD*WfH3O~jvFFQ z7gxl=!SN8pq^YJ%!G``*kGNl??h--hHX!DKi60am`By0txM~(wm;v;~_)XO6K#Y-9 z=oy&DFd@b2Q_T5_D7^$3ee)PvPqYlt`WvjNgT|-lY-4X^`9mJVT|dCkD5GwQaJhd+ zzLOH7z&krSy$2fmOl6{iH%eoFq}gVlJ;Y|4;FIk3&UCaKzS0=j;ts@exoQ$KoI^hO z4uqh1Cmt;$4#6KUVg@=uPb<1bURl)eYt7ZxBz!J2+T>C;y3 z326eC;q`vAr(QjkYhjW$lLTz9BD30I+O_4mL6>=YvZw zXE22)Uev4{`Uq*=;6SxgD?A(Ubfn$or3G($=6n@F_WtzZ_#o-vJIlQ7><+g;E*i!+ z`sE|50~NL3`_|k6B{0lG&w76pQUsdCq$IXCf|4=5_P>E@YNTAxeSw2VO96^`36thY zZOxEDBs*RknkfZ6aKkbyP^q{hy4)I;@yS3fzYOt(BOu2$4IlH>=ai5=j51yNbn-tg z(vpkPZ0;zmrc*e@+p3nI>8+p5#K%_AXu$(C5GTI|$6m>T{F){EIVD&{NNw^* zV7DgK>fl~5S*ji;AGt&*dKky<05$Ix;|=%4MuDqRhEcc_YVjp`k9~rT!qhDG@TZur zUU|hcT1JcdfM{#r%#NMpVYk^Y*q^dP#bx9HApa&bighgoWm&EltZ@Lm=nN?c29)1-La*Z4awt9KmIV!z$bNus9@5 z4uAx9NBrm-nrHecW4utqfqPtzu17c;tTBJ5XDyRo&`h$=p0~sn+S5?8((NDOlY9a0 zc9RtidZ4qCCn(9wGKgm`$yPZiagTf%e0e^kOFs!65mxZTmM<=)hu!tV9jc{ao~NPZ zX_}~%=09RSgyysyx4jjg{`C0|RLdpdG@V+HB2vKi0+ECBi#!}DwW<4ic;zMBD#tZn zUiIK9NR62H_>17!{;ilpNqWxqW+O--8Wg0yg&kY3WS+taG))0n35ri&`duh1xKOyx z3s*J=1Dtr~w5q$GJMrI_J_K|M2_(g{y)i^KhqMStSE;ke6A`x&K^% zfGW{vr`7Huyx@jxxu5pN#y07`<)=LfHi~*ch~Xr;-7G^7n|e{U-VQ^$gOgYUgKX)6 zXyU(fCio7Yvvl0cQJ3P~x^+EE({9KsQ0=EL(N84JC7_&4gEk6!eEPr9oc`20A%6;y zl9I-#XMAs5|M*`J@cI*l_65R4e{wc$Upff2QF`mWDjC7#*cS>rx{mH7$9zB>#BW$i ze4?*?q65=R0`aTn_3J&~(iuJ9k@0_16ib|%tv#`85WH}g(G6(g;xcx!-tiRyVS@+q zY*sXZPYpt#ZX0mirk!uLKRVst5clS2?(k^y=w;k=Z>Z`PGXYP?awnmq4Z^JQ#_&9H zh5S7ZR8K&RuXp-Ri_)70!!H5#A9}vH1Vv|>W!WQmN8q5r+2DoJguwqf@hUOenNoFW zh~(AATIRwT2#Vl~;B0M}UYcCVuaI&a2PG;e0(2Y8*@?r~`=4y3vP1Br>+KHxHm23H zRM>Yf5JEuDuu+8mt3&UR6L_Lv{6_-WC)eB_ zm<+lP1NIdopnu6S?22&iZ2n0!KBAR;bmj<5{^;gEC5ZjMAog2>66})FEPleFo(qxq z3&3I|5MQW;F!LVFGO6c+{F*MS+5WhYbplUc1I3XC-6X5-Kk(T36`*lJnRUj}S&rfB z?;@SP(xTG9ux8KnjPb_p_#2{*YD7_s$Y*rPsJT<>rC5oX0GyFWR= zxj(KP7-mweub*6a>6TjT7jf+njJP0w@x@<`^bpG&-t=9tX@+UQXtD^`kljgg&fkLM z1z+E>HvlEoYirb+hzR^)jsJ2Ze+yO3d?K}J3LImudMKR9T7x7(@WJP0a5o((fhmL- zPYw6@HyuKZZ5|OkZO_X?$V)IwZI;8a$#W|z)j^>D{(w@q>>1kn|AhJ+|1bN@cld*^ z-O7H28Om!r0E;tF;D|lW5JiJ45qN@SWO`?w>w_{z(-&qk3tmEgG>}E`0)&bIqFRpp zh0OrXyK$&c3rI>HRYKtc{X{)xe>@FV$AyCnfFg2jkX7c+baOq5$Ps}saun9b8@~K1 zcuhw+X#%r{_lK%Y)b8Dh{dwEJ9&7~ve&UN1@Pgem7T#`9PfC%r2of%s;=#;ion596 z0aIhLlrfm$Ki&0=YtNBMqiq9|W>~3O@TBxo45}uCi+lgReJ3%;FTEvDY&Uf+IBsfp z1GV>%^*oS~gxHjT*T}TVIsw%IBb;LT9f$IwIjpJ&e9P(k@vJKD^km4ey)ng6ekKw3 zpaw3%Gs9#1iI~}t5@&G>0{RZMghqaIz4mXfX5>7_XNuAQ?!m%uC6e)3%f}P4J~VZp zdhfuh+2N5RLc#V(2>=S22PiD}C1k5g&BkRPJT4j~r-2~{TRdKZ4%-G+a9kN&aU>*5 zh(BO>(bRi;8}$YXVJzo@351TU%QC#&aS)dKq6?2aZh7~wghO$INCim?b5@~0DJ17% zy_5)XOxdVb%kl&4;b2%nz&beyJccUI2J72n)UU7)Tye<40g1<+C$sE}(=?aG9mAhN z)80tjkkhyjSkI5!lMKN7U(c#&1PQ8z#`M(7(v8n>h#G6WcI%+|Q3%mkeq1djN(YOX z4`D--6*LntwVtB^?fgh$U^4A+@0gl^hN<7~vlqv1nnR`EV5LQ*266A_;r63(xTOLcM+D|=)tzK|Y5TlZ)`PQc_SkUL!ycViIvx~ICl-_a@nrxk{HQC}M&YI`^y}~WKHTTEF zkwlAdD8{{&YhV$onXrrPy|#+3OsgLk3^NjfLe%2`p2(q|9nWI)y_B>thr@r7JWU*I zh0RdZu{6)2@8PlAxX?cmreEHZf-($cXhu#1s|-10D;V7qARrh!LiigJZQKsQEx6oX zqEBA@7ceYDtag8usazZ}fovwzBb(k8E!beh(j*4ahwJkyAeMucp(vDMiE7wtoIgps zBOX((>8p!@2Dzfc&4vcimu1Bcvcuk0n8z>hlk+9=LxvUZ&G1#1A!!75vz5Eul$BteYv0VBKGQ$`H?81MLbzNg zxCn85(XOO_!rV(|2^M+|Vvu0>(t|*zT?epeTH(58q}Y=lORo^)zR5@R4HZ&*t0?;( z!gScfgy3|EJ=QY2f~cBexr^*i=_d+iOf){N=iYIOkoOuQk;qb=&Gy_L>Nh+cDRl6_ zL412e;nWpy$*kMvN@GeIc`s_yPC@Ln4%B;^xyhMJlK1D)1cQw$nl9>RwUfW?qVnKD z1Q==%TAt~m(U3)YdHscM>+9P1R0gEyxH8Yt?g=Y{y-i^VvGW#Ux&-xroLbuhE$FE^M#yC+j70LRpVPrB=$y5RgnA%qJ^tP=AtU2butU&hxi2U^M!E%}Jiq1Uq&s1a0utLYm zfBfjFGd=rp-rNnSLA*l9tindXo%lcg-I@}emg4Eg^i)gPwF%RJPUw@HD zB1N^8=WEl`tuq?)n37tga8Gc|RnC^OvDJ_DjMb4jT}+WK!_?M0$aYmXW@#?Xfo;g~ zyX?nYDJze~9}CsU(R_n!`Dq$A26D59KO?#Fv_(-G06Tu<0T%j#v9!t)KyJ7OgeJQ3 zg#Da{zO^q=5@5M;(;L@bTV0N7ytqdf$Gk5>dyw@sZmlp1q&U%6kOl9RKG}OX7xoW? zxB4tvs?d((CT$PPm@s-IP)H->mO8Y&R-DBon+Bmc`#}_H!So&c;?*lue$&P`rp${c zu;USvH>_)BpXs7{E2n%<5Geo;YHY9w-A66uOL^N0Ak=!izt<}ANDOo4sI0LPz*WW3Brno-L&jiR7q#i0ox}xm}J|C7Psj%~+ z`8U75!6*m!!aW!*>fuI_bRS*M% z_Qu#{cu1Y#D%B(WL;bx$m2-1dqnM6EwgDmqOwn0}By;3Oyh8U9T9MB8BkGA(o4t}+ zgyO9Th#%n&996aSoF1EP7%cX<9*mQCP$Yjdf+U}8{jZg#l9qu9R`ZLATI=*e;UH1C z++KmAx{t?XT?1TgEBi-iy0qh9c&Q>3Ugw-1oN`>113yxaJkI_gRr829d`H> zC@P1fR`K%(*FF+mA7j!g3+ZJ2{voADvjf_$Lv9JtpQcjVH$GtEakVlQLX+(!r8 zG=6TehXhX-JcL-NcLf4sJndc*SqGF1jacUb(6_z_dsRDI+tZV#dY_#ptnjL=%J$bk z(2LL8)XVSX`!b3O;T%6^-rs*Codr2<_3Vs&L?{@>ikb;RXTf_xuH<7p!SlufMLfQ= zFFw24KV-hcPMx%$spA#$`|cG(q0)A8t~H&bARaH9;S%ZMhXd9GD8Uphu@}{1q*38k zooimgs+fEVHK5otl>d82hujAK!X-$WvR7bw$#Jf{Do2`!`s>yz5N}sbojNeg+Yt{5 z9f`eCKTB>DeftoqJgvmpjm=+&A0R~PQ%1C~31sfRhEkJoL;0!M9wdkA?(cIox$0#I zO12b~QzGLXVGD&le5q#T81K_5jyHKohd}m@G9PCNNc8V=82=(KT}NEaH^Sf+ye`i0 zmneW*<)dj3tM_d(tRcGGqA{gW`9`Np;!m!=jP&698e*g7_;wA{%^#x?;Je>uOs~8VxQyjakIFVb zt9rSbMQQL-mL;4R;*%Zug~ogC8#rYj-KJ9SXHSgssUbyB7ybw)r+<;cC_fFwNgA9T z8v(hg6sOujM4{o_>F-biSXeRGM9GEojw4-~WYoQR@mrnee>HfW;OwY@af2%YRWf}D zDdMz#U-O?e^R+*8->@K2!;%@(m8U2a(x@2g~5{KNFUmkw$89N{ES;UG^Q`2hBuzEnw)1{+{K#No4R zc>x8ZhSZO*>~P|RH+t_8$(LePx7mkT2J*;-A&wT#6?rH#AGGGrvx zll+PeY;0_+5-S&!4}p4Y21}?85C@% z;Fue$E;C)+#mn22I&6XpE%p;{&re^|Sa1c~QJ?g!a^ttyIa^QtMuk&o7RC;un<9Wk zxzkY@5pa6g%E^aR2TXB8~qAfA>2$YYqJbx4~ooyk_7ia0QrnJ$G( zXd%f3*@0DkZM?ww#`1Yx+QAUm6wFP6OLvYrDBtfi-lhxUBE32NH^H{3Ldd}yD&--{ zl+3Q~49*coQe<$EunLW7XUw(-LtyNaKD+XI7#$=#om)2`_l$VLX`iX1tR1jADKLIq zy@_yW7VRKKNKIdL_UD&;){Pr6^F-xB`23@`%$%n^w#|NsLzx4X5YYu-FXXmx(OXwE zK=msWdAXBvu|ct*nkz7PN+L@qImvB?Kzbndyb-n-z}L205r20)=kh|jM-5+w{{0?H zt@o`H42;-3w=!{stk1EoH(IJ`SM8eNXz6hn9j`r;8 zKPw+SdFUisz#KgYTjb7Em}djC;+7)^I0DmAo@g0$x-5MreLwmMK^;zyM|sRQ-IlZu zYvqZz1G<4ZY~&@(9f1rzlY*z=7;JGF5gfp+ORsa>%GYx@Y@zTed$;3T)@I~xX-hiE z@gF8o%4obop-|BU+LA*S+vC7`z;%R;4RnDU`RyMG%YwWH)1xmtYY4nZ^+wx7Urfh^J~5b{}eHo@rO$V~eSvEw2xNk_l2 zAP;ef?q9SyXO1@SDEh!@hiVm|~<ECTpZuT$UB)tZn0i+pY9s^8OT?tchHAE^W(q5iNZ);fu8{1 z>!5g1Z14*4gLzs#%xan%XMznuH4#!Sep93QG6q}uLYFxs?jeu^2EKNE`Lf{ex)H>+ z&3lFWFBGbu#)ItSeEECjBM5y)Le82$F*}e;NCN^h7EGYTdL2OxjY7F01!v)MwL>WuNrII?N&Jw_jVf%UL0=v#`dx5b_`Wgq(!M@^1^7K-L}yuQ zd;GcXmTMLrnIsD?`**!-S2EG5Bev(7zpbp@;lc{9?IL;}WhHznMMcxs;JoUwoha4< zyUxCYoBuB*3K5MIHbA}#x$@orn6X|J(Kts)v?xywO)yct(6FfPaMpo|xGB6{rT z`iGCu62f#*-Y$b4=jfg=jH{~yv&eb-aja^Y0Z#{$KOo1vQh7SpJnBRpQ;R=3LsBl^ zr2UD}3iqFII>_k+n+74+4@&t?fHDv!@--W`@dU(rIrZPa6|VYy4UCx@fT+{LtZS-7 zq~ycs5KRN_xK>naGj5Zg14-+=UAQcAiSizM2XLd)NztXcVnlNtZPY7WWs#<15LcJ+z#v`2;B3R621qr7xNzO=`;V>7%YE&x>+ z@CU6G21Z}N3uXPmdsetTU|l6<51C6jcXopNuVL$n1RRuO<*>AY`>#@bmQ+c6nw11) zHVAqGdoz!P$bi5BABRDzOr_%%og}5&)ZzD z?evCE&_yxdWs9x+HxpGZz<$81GsP2YcSKaJM%gVw&NoD!+6$&9-h%s>I}mVJoq-BN zD4u?oZEYh1obiZ8vq+?b1_}|)?1hKtSq23%OxOuRA5Z|Mv0%Oe+6o=O!cJ3yj~iek zxXL(M;5b%@S^j@>^9r)3bw22c#x zHs3k@^#N`U_|^VsNbvrQfWzkr1ls~|Cgn*>X9s^IuI;f-=)7@VU5g(iRG+fY3Qa6q zncHRF#9Yh%;***$m&`U69UOa}zZNrudEit1BAioNrn6ug?y?4iIqC{{4BpZhL%C|vAj|v)tTc82g#PZ$=A?|3Q?P(410h=jc20IkB9}Z(I)mq;qj=oPF^B zjqK^ciXxQELKIQaASAsa&XMl>{@>s8`M5XEIs5Eq?=^hacdfOb*wuUj* zIM#z2!11{F--d*L2b$v6!niWUJZY!d4?lB1^+1^cKIn}HI3yISUbE2zc5NUHTt%ce z?LMr6Bthp=jpn9Jb5EjZJ<~8pdn7{zUhY{>AwV37 z;)8-sTRg)j^zZAwH|fbj`w{z^FGc z;I2?2%%9b|9rW27!W(l4p#1Ns!F?ZD;=dj(wR6Z`knPaW{`bl({9hM3f2 zpnJkoV>b(bf#9j#_OQ#D6_^v`%NGq>%R~O!kN$F%>MLxnAZJKqtjw-{X_zxpA0r$?A-eyyTI89j= z56f0y)77z>?(&wCbu}Me#maLvo#BQ>0aphAwVE|0`LmIf>WFjepDc1svE;GgD`z*>;10L;%9X^bjC#;}ESQikmvZg}%>xGJT zWe|DB2^c=I{43D<$f&=`9z67_{u(Upw(St+Lg8{UYU)BIsTEI{r`k?OpBf>#Lr($g zyxbaDQ;2wFrs(mnIR=bdGT>m8wuVwHt7)_$qj&#hUv3}bAum&P|90jwFcn?EZJQ6E{mFKz`k`?gtb&T+Id?$%{d9l#yv>?P!qp>A45k`3=4082i}MT7DHd$*3{T4kqUf7FivEHEVnE2Fq;T?v{2Fe z9h%9$JdV#hM-lDNV~#>N2w_xt!kmqPrMh%@EwLQckHr~B7?o~hrcKvq(?2Zt<1iQC zUcU8GtanQjN}8a=4&A*;vfFF!Qu?W{5}po3OIrU`r{gl5Mqbl3(E);tmO*$Ty-Z7uDZ+Wh_sfhVjTV^v_! z6*tr3c%HKIU?>QO$mn~xD(lvBnngve|Ad~gm@+#fi?%i5`*w-&%iz9 za=G+30DStvitz1*57a^TAtkCcbX;(`0>mAGwS^l_Bq`FA%iDk8O#-z$qatOI$!fR} zPnq}tlyao+$H)b#kWIvsP-?;(qt|=C-Q{<1ta9+#@ME-ytl|L0eB~^*9Zo;!qsA3D z>Lm7^p~V$1%0KL2o@_UT!ucg#A; zH5_#qrtYLdjkstA+}V(Lodi{o+)%lAd{-bXtRQdPj7lLPKgVJ%aUa<0XjD>7T@=J) zATzsvy63Cn{>Op|U_BJmO`1%#LPW|NbBE`rK0zVxo$Xk|j^8}P$1=3wz%ZDI7MEHS zzPQd|o_8?Z#R`t}lGcFaGyvuBxZfuBF67ldPPP|q_%u@_2%Vew!iUF0Tya(*JJWjX z%jXXO{J7f~NTtZN<2AZaON$)vw5q($;1hF50V@_O!b1Y=J``)@N;MOdyQSdpI$LiL zn|4^EyHGuX*k(o+9s|y}`x=OCR*Zf3>K$otr~)2)nrqBZM~4auP^sM=CLl71jFwsT z-;?MoZtj1A67qwD6#?&qnTfCkcfhJcGao#8dn07|Ur{2XC&*yV7Z+3yMQb(*Kf3Mm z5=sn+ThrgZhK%)$+ZD%FkBh05qP8pQc$dT(x!W+Z@<})?2Zd6K3!Gp$<1yK`%lt-R z@I}GsD4fd}u^;IeBq@Ts%knlODAiKgE*R~vv4+n>THygBJR!Hi+|736XXmQCIlwrk1-mR?Pz8fAFm&@Rh@I~L15uF zTfv;Tbs>42F+J~fi?!Rh1c0H{5zNjofDiAJ0K`t)Y1U#Y2&gDR2c(1rf%uicrda{y z3_cqrzo4KjbN^}1o5LGm+=&j!+hr^4uKDU;@2+4OS z;c&`ap8_Wk$&`S)M%tsQJOIwSz!a$Pv(Q4^&V2|~O$tGgOyAF#C-1C>^6x~6DH=Oa z4sVHjcxNz^I`#vo?L-vgNHsoF57bAHtF?|EbJ9<(oV<(~2>ojxPp(6-Lfk?;z9gjk zh7rZiBiC2<09+qxv%7uU&XJWBCIA~T^OcXd^;PT{kBFA)!}q9Mo?ddN?f70 zjkZ;?+X73!$tp#RY{$a8&2g~WV*#id)gVIW))S*OkrO;lE&`#Y8>+!__GsKL!x~Zd zitFrMV`#FY@nr<>)t4DR0)nu5O}$;%A)@BYC^n4%$^7IiuVH96qdNdM*ezzeqDL46 zA2ksz0U>YRlNHycTS$5l`_ib3b{wx^0XRx5hrG2509l2#Ygbgj;%<%44H9nD&E8J{ z88<)_+pBi|B{Y2G2@t(JUV@tfKdO49f3X-Y0?c7b?p&I=ASYdv2hT>btQT$sNNC zc#~2~6vA8aa4W2;c7(`e`d@bfhe`_0%la&iR*H`X4M`MFnQZv<33%mI%ErW|p}4AW zJbmqO4W1-a4jBNla$A&AMKi4BGE{JiD@UC~ggBAg@v^xTa8^hvrcaG%M?1}Z>KvWU z|2_$d4wb{O#k?B!2+o1juyF5LE_*e8y~rm}gj&7B_y2)o3(_@NBkkd|rS}Mm@`Q7V z65-uW2CjkznXeqKw0xTB76d_rmA1JZ^23P|rsHwpNSZ5PC0clcDos z4Qz1u8^BIfoW2hq>r0oaWQyD5M2I>S=Uwu_Mw3DMt$r1+?=5bLWL!gmzqSvLO9;}B zLLWhoz722|*qh2&#tO(G)hRf>lW`j@Zt(i@U_F>XVlwLgsnOBVhSRE2ghX6UnoDrC z$77>!`P42Bzls)wqGG|dua1Gahl|*M`k##9agP~%e4tTJ(D!J9Bh4sNhk)a` z=9R)wol>Wi=* z!zumu{cGPgKhww-$eWC^X@o|Q9^2mp*ZQ?k`k|{(Hz@Y zK|&#vm8?HVH~lsD)3xcz_2rjS>l2$-QReG(Xjc_G-P^{yFg~pHN$6n=7YXtNrsLCZ z_mDiCE9C%LmfU5G7Txz^_T7YUV5|n{m~3G5?VXd!RRn(T+ySz8TIW9v+CgB}0ocjU zjfucrleSV04Sk}181cE|n%v3#?!Sg?uT4C&LgQwgg;hz;b-&Q^F-@=&T6e4uKRQd- zf_m4Yy}9!Oi=`SVur6}r6$I-{R?;;9rF#On*5ttCVvyzridn}RQIrTKeQ$ZVdwN)h z^*$M^Yi70=tz)v?vuS$}K?`^R#KXC+@&eETc}W6ugQk{`uVOV45!pCHkw`_>V=HX% zQkOn)jCwY9Uo!g691iIo<%OLvk1bFu#}xfh;Th0K=txV1_2vS-6M{VaZvdy@ z3+cm1RRpics)JDTs$c?-;a)h}?jsC7f;y&m6g@L6?p#IlEUv(>f3E#`K2V+fR)#b0 zyQtl22&1sbO91>Y0>JJ>7zXm8pQ4TK(gf-kJ5bL(HzLBiW?R3sn1dShCT_>Q~%) zL1pEv`{5v!a81`KN@Sg9sI%jj?fVOXq*672l?}hyz%Y7n+tp;VtI#kjmQ%2mIPhomOK4@7Ic!woV!`#ezD* ztj+K2VzPni_G41Gyu9TW+CS9JjgD->OTFP~VHX#}>nA_otlEz<@J^qM!|8Jd1Q`=% zc%0?7e#m(a;BN4ju_#^|-8?ddE-E7Av=mJ$=6uLn($W=6) zu*282+gWnE2KWL?cX@e+OgSKltpiGqIF1Sq{qx~#wJM9e9*|@je}hyvSNz9HsKB#p zxZk3zls~uAtOlIGHba4RSj2AJg&)}&>F?Hf4BI+@j+|#zg+w_u0vNh;2awmV2s!2slWL!>1+KUYE!%Lbe;A<~YN-rR%j0*4= zpB-M6!w1#riLpQG{ibdX9D_X>O@NZV&tMGj+d6z0@>Hid-EB>pZ+VnPH9CI;af#GH zEZIacS)ZD(OoOffL>mnUPR_O>s72?Qwt} zeU$n(&jXc3d7P}taa+C&{YR>K>->)&H)zaSPz(?W{>TpBbpASu3499$i=nvU5~Q5h zs+3{BBA2e-JToqtnEiG9_?9`yp#bU-gYXZ6N<;(k_}@WsO%o&>qt^!t?(o0AhTkLO zHS#?1)7qJDrduzLPyxW&wcr#dro1>+fDxO4qw+ey@Y7Gw@ zql4h9tC6(slFiuwO@EOF@&~Pxg^i$%q?PXk_H7r)m&>moF*!5bocBGz0%Tbx2}vBDAb@M4ZrG`ptu0;*d98JlypCgu15=z9Cj@&(HYQrH?fN{ z5_cUnj3K$ckoj>Dv~&bH#K@7kE~ZS#EWf<3kO>UX`xk2g)v|v2hm)w0j<|ZLgAV5N z*kMW;Wsn4Qk826eCvJWz1fUMv<*{VD4sSXHeSQ{b4jZ7~hg)I#>%a8nB(!r@t3+#S z(@%Q<=ZTS7ME_cWwcNvlyrLc5#n6GZ1J0v_ILr!8Vx*XOEviQ+$X-re#&g88Qp`Le zFx++@tVt^+jk*$AWG~$ZWIEk|P*Rg_c0i(jjvOi)UfWtu#B6<0jtBC+Z~%Q|Ak}Ks zX=l9G*Jh~G-a2Mu6b%bbWEniNqo-}KKLo9o81#r5(34(%4jLg~#9jg^LBA&{d zMd6he7Z>+Erh&7R)gkrmufG+{T%iB}8<@WeI6wzs!rYWCiP|jknXdrBTwJ~etH^ko z&Q+b(du(&bs*xbzj5h#lR|sZi1@Q*7v0eH(_=+M1clJ?XeBEyRG!cR`I-eL19FuW@ zj$DNrhCtO2$b*8py;0od&ESEN7ChW5Jm?yLn3nmi2IUTT66 z@DsqZ&dQVzJJz4kndnz|&~a}k&*2qV-3stZX0A7{T?KOg-> zK^7xuNHt`9t5ls3%Rm$OyMsSiuomv>^e-{htQ$imLlnMefoHsU-6nd|+V!8XiUg#< zQ3aYpLAP%gZO?ax%A8;CRFZblS0rTcr`^BOco*Jy|? zuWMNxxFIaECZ$nV*xkiv#*|~PL4pC?*MP^odG9};RwW!J1>aOgNB}LvyaC=b4D<0;t`xlNA*+i7_ZCwk8ngP z;PNm?{=HWt3GYvV8{GHW;!UQpt@)*Q*&-n#!7eYkuy9cg9=^Ks zlF~^71S1zMx$vsLPAQ&F&H1Oi2e83&tHbD8 zTB#c^YdKh{5BMMlFI<8H3`7D;eXxe!%d^%+b=pvk43D)a3vQ5FpRJkqNFc0a_J72bbPI3!588R4N_Wt-Iy%+{ICd(ovwyGSK`mxXqIUG^{&zC6IdTINSK)3g7xc1b z3St-9^PWrjnNghuX*cs!K4CxS6?O7eF|;d$IC)9WaT&xaEhDEtX*#~5y>;y7LSyKU&dBD>o{2B_ z-m5XGoW)35g{QgZi~JYPr|6YGecz4vL%6u_=!e16fNaJta>z5`dMHs=*utm3TEp#T zTSw8EC^LUO59=g11B2>RqQ?ZE>#7%2rY`(Cz}@#ondt=8UpX9oE->t*>44!&<-jH{%`vUBEE#s>Xu2S6 zjXni^6H3xzVpRZ0u6JNybR%L~m)$n|EoAKJ8Lx*AS1OEvpJGezVYDI%-{E zu|}l4>QMJ}Cf-ew%g9_arF9v2A6ufgfS=7Cz%VYIf%(_=a|S&kmO>GDZ>tvobQ0J6 zwbkM5%uxXcnSa>bd>1aB>D$N?z-M&S$E4mwA9G3dkCiYo3MaJFJ`zLfG62@?^jrXZ{It{s$#y?G8P4qBjfh5 zX4z`AklI(V0++Vo9ZOV%$U?ZXUm9 za&tWPG%Gz=6rjH@0kgTut~A+wI>jcB5vCw&#G}zZ+xVk`*YiQzgi?Trr}>0;NVSTX zF#9pE1>!fB)hV{O-ZXX+dotdf^F$*#CBc}#(cHmtEo=_&CW&QakFWo!zbzsi0kg*$ z>dYYt25WWLqpX2Vi3w?v&ZYFfPcjAlI(0NXp`b6Za<7HXMgGIf5_1)#UrBqieFGWc zoxSnv*MI}QKG-0GwUz!^x~`u`m|A&CW+?(kU^u}aC<-bK;~sG78ZGzdKU{l0E8OMP zfh5WERW5N_Cx*R_A0$p8Es~6`oH9uXJ#Mk9>V1}^H|)EJ_~*vTAodXs?>{%AE9ZQ&5lhK4vcQJ%3Y?r;P@#-;?7J;!5ndl)c@ zf}WrI!Bi{^KI%~)7vS^CA5f{(@L9qA*U(_5Sg3}oN-0L(z9=bk`<-rQ$QX_@5&%{3 zdM>mt%;pJSO>G#;l1#CV^$7g+b6ANTBU}RgRbpyoRjuUF^wAjhdeApNxZjhFD@%dT zie0lxeeC2R!dX|j7{~qGh23*azivnfju|f4%X&sEG)RUYS7M4vV#h=+XhI{(qbAI$ zF}s>YVuC9KtI2=0T+~QS-e@JSk>l{FqXYcB3)f)e&nfGJX*>n8o2)!C^mh-NlEXqu zNW@m@h4D8%Z1V^23*We&-X=A^=17a7YinfvZn(A6oHQkH*{PPhS>C2)m8FPZu$%A9 zL1Nm;Kx~6x2;Vs~dxbZ469tVRaxy&=nHEMoqnep*80MKLzgNc7Jz~>VC8p9j{%`?5 z)y_V3{p7D#c|BXcUfbsB?*GKtKJ{-<@dKW0!i;k@gTr#*0>Ii9xmQPWV!G#Ow@BJ# zwWZ;3{;bekT8^+K#+_|CS8gQ6bHwVY_*2Gn(VVcFZL`15PBivj&8t<5t%@UJn0rgr z`e?@R^ge~@3Hhz6wcKedysW3>iZ=#;&x5bQMUX*RRd)x~z}{xdA1gfP;_A3>6|W~% z4vQ9qU1n>2pU@H09`UJN>+AO)kC59+t8yS;>=-$s%u+`HsTmB~Q~m8wjNYnpx7ppR zQ&J4`2C{cgG}*r%x+cH^4@8|Tm73Np@<(dL?8TO!$M}WU^W^md?Mkl>z}L0a*a`Yw zy0q+_alyTj5MIy0A9p|7r=E+On9g(0hN_aA(CHWTdUZV)WE2sOZUEj z&#Vhe*GpYt9{-e-h1%E8ntM2jj44`sSPLYdksk)9!_-~5pTW34(W z^ltcJTcP0S-Z*e`Vus(~AsrUnA-Us+h>W8ISkj%&qa_ONGQljop2K!K5%lq22onj4 zSId16SGxU##V(J~%AVPF1cN~D!m#5esA79y|wem zCIbVveuLYeFwCr_y)I+uTI^OO2TLQU+sClxT|Uq8gMkNK0ZljzJ{QpIsVoUH?Tb8} zeaDFC9jRaqT~B2CAU?8$dQt%P6p#f}`}0 zL!+~c%8(fdz*=jd)2f@&Rk$GG^x49ecAV_!+5(UGDg)ce`hs1Hy3}5 zj%t>I&tOE0C0^L<{o=ed*`?31bagvW?-gblF$~Yhl`m6IvpX4AlOGscUT|6NXL$C& z_uPTp>qZ8KlP3V(01>cCEx>cUsr@}Gqw0|NE1P&`^j8c^jHSTLGv~Vkp_SyPYydW? z6{Tz1e^7q4vp;J=pRG(!M0Q^PMCQy~U@|^8ECO8KtmWY7FYh8v=2Ps(K9r%z)AyeL zbHeX}GT`CC$g_LW4l!N0ZqyTeEWkWACj_)6pKA4|b8e(I_x@e=J8XbTw^TG1(N`zQxV5-)Ln`ebKz9twZ zafZEUJ9!0o;%a*Vy3d-zY7*l%7xbFGxl^6tnSvRA4eKr_0y@ADPgdlc-tU`QD(h4NoipWG37DLe zn(*DT#5LJ(cM^lNL$I!dN5MR^2Chqd((#YcO}}wSJ`Wza6Rxl?*(atb%IOgB%xV1$ zK7})MU0n|6G@*Shq35<~9?pE|;C$lwdR3?2O%ObQpb{Q(>z=$y~Yf=YC z>)mgDI^WR8rgWZ?w8XX{;~^?JcIDtJFu8ActR%Sk#AtacJUkYVn3lx@QEO}e4GFoe zqt0`N>GcCQzx=S>gHKLWC#i9+wbflNf=?}X11-}*)C5xz!J7|TyrSj(p5X6ayN@0D zANCS#I9%WFUEIB>@LC)>;o##?(c;7kiL@xjwJZHu3UEg?|>7Bc~QO$Z#8B=Fo zI+71{?KGdsvj!Hwv-vjA*_7j0ot>G@hEO>+X?ngPXmE1yTr6%YU+xAf4PCQAd`3)x z#YdUy{_cfzJ$JmyrP%dK*93=LnY}J%QEwP6UqpnVU30E{PlPSy`Qh_gX7=vy(ib|i zv(p+`a*e1tPE0ULSGcyQwl?;jn_;xE?)9Q|kN= z*k?GA=##7hTzHS~Dgy{eWEf_UGT#KVK2>Nv1;KmDfxTHVUGyAa#*tk^KmWxXk=}Fp z>$SG@oQp*(!TYWR(%9&-6k(qFt6-4e=I0RzwUl3X5~qA@G6I-OK$MmmBuA^t-dyKK ziWTR;)v-oDfG;X%!U2}!R2biso+vf4g+~jqcW;4%cF!1n41{9+pEALqJSPR59yT(A zm?UYLF$S6Lm75ug70u3dn#&z|@ttDS714x9DikLMN6lAi$a+4Y$-w7hfPb`zrO=w1 zkRVJq$QIL?G>04|`T@ve`K1>(=FcKnKH$cTqZ%T#{yqh15sT~T9`O7DG&(Hbo#z4& zS#=fee+CUR+O?}dci}-hkAE3l+~HW32k@-~ zb@bq2@oVENu2o`Uj7PJEt7L=fLncuk_w)FEFz*R2xe3tGbnIgZ@HOi~qfQFEJ)bob z1&Y+)f3=|MP+qO;%ygvt7Y$hcFM`S)R@S9Gx28IPh~@hXHN?}GW7eXJi}JU z28~ls{dQuL5`X~CMLRwaf65E-2Y{@b%{e!og^yPOWXBEAtQVh}Qgl5x_m=shv*1B* zvwfq@l3Z|FD8&C`ZS8`7hYiS$DUutrEWNC=truQWgvOIC|4+Y7(V2)zhj^PB2Q6P@ zg#MMRHM+eOcU5Uz-bu+ySznF~kDMl9{pGR6l0 zoccI~x0N=&8s=-W-1srFP_xV#LIQ^XlnzBEIvuM0dC|Pw2jZnv= zag#zCVG<|UkHBI5n}G_~u8LZYm7Ea>eu~T~`bn?)5$QfWpV{2oKY@pfFnPPMpVW-2 z`D>TcOp;Z1*(?`+-g8re+t~*O#ETiY!A#|6Q`Q-L#dhDE>ouH3zBiWMj`eU$Y3`Mn z$&q-Jrz}LKncgr5SiT-P? zwW(bsYr~`1NLt$2p+cbv;r|+UheeDP*vjArkLX;x#G)uOQBExk2FsSS#n;;~6t-y9 zhN4&De4mT*7yNoJ-mbRcqx-BQtmZO*Dfzj8MykrlHnRiDOuYJG5jk98w&~6Tvh@xJ z*pV?MTD(m&Sn1Ciar5=ZXj8BoOuYCL6yPO?e}<=?2Ywh3GyTz0xv`Bk%cVr&_T1Q*f@g*_7T%DL7ZV zPGmaTebX$8j#5N(VXdH=8H(eJ<@Ka&9=t@{87DGa3`GfPTR?3lTD|@vSyQ0|uczpr z8iV5|3Eh7S!rMLQJK!=Bj$qF(wcWn_dtN(R{(}D3KxokVImd`Sj^-PR^B+6;zZJ@y zNU>iu#{y!2ix`ZYdjf~aq}ji!hrwv)ern)Yt1J6|sg>@IrF!5wTCf?PCPaw5#XAe` zM&#woZP`1jSF>4L`IWxBHXSi<-6BWxY;N+*CPSkfw`RvP{W$}d+bd6QgHIDV-heP= zDQp=9g+31!dC5}tYX)>=@p>xnY-!p`8TKQPTkb*X*I^?c5zhR*#H$wnr&y5=L);BU zpxkbPyBwl?FSe&NbBoRH1DmoHK`9RAm=VJ0=a2JK!vDju5a_55tC5H0#;U^8VxA|@ zFi>uuFz-`%!1LZMuvLB=r!GVKM^VJh;iZoWR{~j?je7s>-OD`xi;E#2R14rc@U-uO znSCB|nSX@}Q(+_)dMQfM1$w`#hhTr^zr2w7=b%@e1zY(*H${au2ni5_~g0 zZgd5x114Uu9WSY4k+x(=)t;uSi2!Iv1QI)u9=mt?e~ZrSU4P|nryei3oChx*y;~q5@N?U~EJ5qhQvvME8=NYHzEWN?;@ZJE9+Kxqe+Izn_&c zCw#mqIEw1C$Ui@I23R`x-W(*Af!c_J5hcI=KM}p$CLZrH|EGQW!fLPCl+GYr3m35( z4qLLZdGnGV1%QJ}^pEo{?&hG&RRYigZW)Cx7xr`7Ys}<)M`Wl@$VNb^7`C~nf*PC= zatcrJ(SI(geEtj$E72}!1}7>QtiGKrx#xeH8Qt@@sfD*#Xf(yhdaIMCVHP_DVZF34 zLO3uDC0wOKW(RPp^gl7Gt_g_iQ`tTg&szm_n7zm6M05`52+&2MCQ&hjPl~&OftA%D z`c>6R-Zw$Sne|7k^r&D9sD}#!sa$4~7lHzS z5g@TqG)jm9Bzj@O+ru!YGJ7v94irwqpW!0aBM_1(2^xkEXMQ&R6*Ec7OX*c3;8lRF z$Q_oIYl>toa>W>CGi22>`?9_ap4GmJ_W&~g=yKR(ZIzuw|GBUkrUy3vFt`8#P(At$ zJ0i-bfYG6HS%_`BU8|GQY~S=~iNl*bwz#=28e0l4*mZxFduIVtxdz^-+)=^5QcMV8 zb1R7O&NJv9G;uUrpa#kp_H&PEG}+&1wr_r$A}oTVeYZ-7ugpm~b2?**mG=P!E)G7c z0t{a9Wj-+X(QNq&-EM1oS0B46M2F3u|DXKLAm)0t$0d@Pa-SIaDiXWh!6jpC`OS}r zg>~PO?_g%TLii9~0z)grgr4)L-sqwaXB*k02wVQB;^=K#8BLQroE8fWb z3Jpz8-@{Q=c6q<4AV3lnD>^FNg&QwNH*S4b(~(s6Z`|+`f-pJRzF4ZM*S$m$@I&J- zZ%$q4`E?i|Cy`O8xZ4=_cg+_7fgEvNt9g-bMahT~y>1v^$VNJeqZHKzh`y`+)IejX zPDay=##K~%kM&+K%UbudKKebFlyt9)ahyv3$Y)a}hb(!_S}l=2aY$TkkrqOb z9RC}Uu>_6?{;gM@uVI!lfs5mZf2gK`=p1rv7?Q)TVD;9qmvlNYwOYW*uXCSvszHAI z|0CDrzW|8Fr>R`)>&uHb_PvVGQdAsOBC(NmM^33R=o*1QZb1SWe`QU?NXxC_Mf5@4 zsWIn9YGOkXZd;TQ))DT8l$O<;0I>#-+ms@8j@Sm?josh&fZ@SBIvsfFgtXa@Z15Y5 zT-eM;4Ru=U{S~}u?2>WpoUI%e&X6>twY1Z74p)`!)5i_Pmqmrf$T}cVl=0+^Uc#9- zS`>=|p(2$QNCtbusR$UG9AYA8>Q&P;pJ1An8-6T6$g@SG9wv>hL{BQJC*1}^|K6u_ z_!L@0@!d9T%D>S z1`Hh#lhjd(Hv{;lZZNf;T&Y{ot3$8*ZsR(P>~#0JR4G@fM`Gn_UQbIaxAFLy{+kpg zRcjl}rltmP=GRnJkMMEUT4YuVL}W|xz0N%(?3aM_hMX?ubejRfG5ce*_O%1&Q#SkCeFDU%#KhAKf5wOdY)4)V0J%zL z7TBO}7f5=cpH0$5V%hL5-qvF?3uZHHg+VeeaOfpsPxXNv5F^6f5zYdMc&+~pV|{eZ zl^DGl2cMB#)Lx1%^S=e2`e40K*}kC{*3cn?Z)l1|_m0x5uP!t`_P%OP-~=8U^+6Dc zAhE6h6jI|K#|BS!qHt7KQ-wg%T}X!!f=o-dIuX4D*^iY%JbYnb=)%9!n;7=l1IoN9 zpKmcf#U8VV>;&@502Z-m5A?)7`b|9kClA*W3grtlh1KqD(6FKHpZmRNsn7jam5@-h zseu3&2%EM{_{5w(#y~jxr*j9Pz?*a?*8xX(vYdO3-8TjO*v=&XSScRV9f)6f!5ymr zjI5-a=(bpuVR!SkmvjIYyu+tWi-EvF!9VX~m-Q^BBT!t|ato?65MGR~{|OQ{QZSJL z72(tY$trM{g)qnl5jB_iWiGD_4+J1TAz9V>PEKb{KD#+I)jzc#$PP7gru$f-y1XbhJWsj_fyHdv%mq>$O5kdqrCSR(mSD4tHvhr!IR4Qgn*lUkj56Lj7Z8({JCBJ)Op{k*elfWdQWJPj?_Lgg5+)o#oJV~@akKsW(h;ov6nq-)!*Ic$!ZG+NLWhTa- zXjRcgx(jBra&me)V#Ec}Johg_N7og+*J;uXmeMq7EmvtxuU-wLnJ>z2qUjM?y##@5 zSq}RCaxl8^VftueOhRw>UY>YtP;A*X(0>ugs6f9s+jWY|$lEHN9z@kQj93WoEPQzkyQd=*vmJA}rO8;agrcla_<`&% zJQKZ7{iU)03NRU+b}?TN3{s&1Xwp6kDV@CmtGNRkreZ*^n|(2kkDjap{1e4o9zcsF z@rmbtSSqUpWsVrRi&G8j1wL{^yDoeJp@$Y(+%Weh>Z&UzHElwxdeDrzD638hJj%a( zQ3qz8u&sC}K-8xG9*?j^dr(d&oI5kO97w zT$}V!)Cf=|KvX{$c|hj`E40qUnadqW#aVTtbXNxs>Ee90?%pv+gH{y z1}5EdE3PK>WZYLMM5n%=SPlsr!VZW44`aGkXeCJXV2ciL$%Z>cOP?q?Kf~izPs3W6 z?&H(@`i}34e!Gsu4GK8#2MSoSQ@)TcaXpe%T?>k+BTNUPF3%}F6U9sYBGPe4F}dn` zzMri|S{wAKfK4fWIeB`j51Vdma4!r`Ux3 zGll30=BKTa_RI@XiKzxjnu`twaXbi4n8)h}gn6@Nvmt2sZlhJ$NX`n~^-z^$9;O~6 z!IZm+p+yiCIw(jn&oHW@m=sc(LA}7jAw3j2O}TZI)UnLdNhgk(Ott?gy-v}D8f9K1 z7f2AgorHO7t8OW>dt|BL*EP3XSX+1G-loYMrKk=TIjE&vg6L?3DK(c1VlrV$wL~$& z2HFW@efj||vu+=$+uys}uYEHIXk!{wQ;96UJ!2&`zz<>|o_FU~bIH!T(b76iL!v{xhmZ!XogFv~hE0MrKSfGIkf z0>4O78B)pdSnqq-w{BhN(JKW8hFXVqBev?jT8hmKwYC7zHmmL*+t%fMCJgG1Z_xxF zD}fKcpie|)`4Sq4dxm;K*eNr-vLF}W33=J@5PP@j-rm~RDsAkvfCs=jbS{k8Kx|v& z9ry^y7&lp>+aD4`6rbRal^Xax?`dYQ(;#z7WFH6^rsBXuhw#a#(UDxhOpUYp<eZOw1)#Q{UeC+BHlbBz}`Sjt3rONt$umY4>x4)E>_|3J!2PQM$7 zDii_o%O5Os%sOoX-7M6a`db;{CIZ1gJnlk~y%tHFh`auDx}Fc;~M(i#{LY-Uax;AK?R%0P-$N z@?q0UmxgqDzKy+mnHP0QvBx3$!Yk9$&+rnwp!A5e4<)^!p99FjCB<5XhWew(MU`vJ z{J>LFx-Y4yGDy$vTvAQCjaq=KL&)#x=t7gYt}FN|9w7ePAnjd)wVe6Q{y|yepRwpI zI6x6HurF)4z#e2LUh(Ba(Sd``cq?L`@QXM$z8z6v2jAV?^jQ@FRRlY+`NK;HyMNV4 z#bwpdVMeikXhtjqhVeAnB**XWSmjZCxua{2ln>xX9w}J$Pv4ODs}ks1?tyvxT^!f} zZMZF(;9Tpz#X}giXuzBE!rLfuVZ>3ygF)E;0zxvzl$*DLfFd6ml7!wXUH-g-4AWH7 zbTPd(NQOx28Yopm^>l~iZxiA!gwQVBH8~v`d8 zXi{aK4KsfXWe90bmvob^%u!;JnS!iuxJyLqN80V<6>xB8qSVIS-&66-gpjKI0Lv3h zloh;Z4oE!|=E1N9y$Q9kbB4pYGjCm?VwDX`d@eLqQ+=3^jo48I3Nq3vaRdOFj{GEM+;vTR7$NR`i?i;vH=i!3Lb(s(-)36 zkFV4{fkw$zgcA8aL1DL}$b(1O5qu>Id%%PA2MMJQ$*;1R$BPhiV4>8=@0JqMPkd|l!MS+E< z?gz zKEMlr*=CFOrrgz^cvn@f;73opj=R(UMrv3<`z9^EsnRbmT0IohzL&4@1H-! z)Y@GYnUVseDRuG2I*&ZoXcS{0DlFO9Bz1A&ko32>fwW&@&UE=%8a)KKPnD!_DlIdA3x*sm zZ8}tpQ_M#Fk5cM5*q|kpsGNs^(j4XA1#SACg2J=TJ|8AHN?X)(Mcrydx(U5NHv4R*?z#_G#7fd^EJpr>o{WH!E0}q3`I;yTfNo=B@L-rJlc;9*W^R9#3vygO z3?zd4z8fs>-{Fv_=F6{fyv~$xgBCY)z$voiZwXqa9&Ac*Dga5~kF}bxMNZq{z)!=l zh@IWW9v`pShRbkp!Q)wa&mpBrN>WdUU;}E?KH_&r%GlNZ3dQ&2??9XV1Uhd9l~fHe zdwl_ITY&`&fvtsH>oF7QUvDPdrp6%a&FQZam~d~TzTRZPQjYsJP^j-Qw`kxyUTHC- z5Ggnv#cT&6M6_(whKB~6O+;_dSScu?g>EVGHw3bcVMS1iQz>c_>~&qE!enQyYC&&E zLF-Mq%-lVrGXo9nl>+xwmERM6gZb11EOgwY(;O2{`;JV_CPF~CRm}LkZxX&mL~#yP zD+A!~bg!e%6gPQKI^DG1GxYCxsN{xvngk^HbR~H-y;yv&);Z4(BsE`im>cfu+GV%@ z0I3XU0lo|u2&6^snQ+CLIBE+8=^^`Z=++r1Er*Nf%U)!4g7OUk11nOD{;JNF-$`_a z8WxB(bKJO^VAAE9O7GT_{c}0Ywfx#P<=-$!k;qAv``xoeins~zNWe2&{VAV2a-^8ZvY{4iUu>9IElz=?sU+DL`?>^p9=R>8PK> zPA2@>n(3;ntaEW7zAU^_JGmqjA+F0dSjp>Y6#jL~k;+lAXj()|{?e!s8{TsDJ{o#( z8<@X2X~^TJ=j%u*k$)B5LSvG@JubbvgW@ODt&5|WNzZFzms?suy%pTc-yp-v zOnLX8+fT5%jm@KqNE9-4w^ zR~-~uSh^!*Bn}Kx%aM5uI|gN-h=tm{CJOoz*xcOpMge+BarUuwBe!U5!~C%h$Lv=a{=x+fGU}g-gJjY zZFReBQH5R@ONXug-Ua8FR(wf(p{e>=O_&FHOejp*sdgH?0rbu=DtE!Jiy286g3n87 z_wil3c9Tq@k3!cBRoH@-xJUR!L(na4jbDT^A785n0Vp7wAl-P@W?${|71~ku-rg+ev1bcU_A{+ zFiuVm(NoW!&on$7ZE9C&iqL=@|F3a7b|ofzd;8-0$c2RHXn@w zYHFUJe|b7?4H%>;es>KwX-6>e0ItBoHnA3TbEKK2EvJ+pk$@d(^2(MKzf2nN(=tV@BVW4b?m$(L)uyoP*c> zl$jQ6xLKimeWLv+IJ47w;|#l?>}?_=MfsQsQ;_NPD4rs!fK1H2GqT)1JYo5CKMX^? z@__F~ABA5x?Dc8;>Pw`(0v_vwmHGLV2@cmG1Dpx@gFA~a!9U14#ohPLa1h48h|i%c ztf8>g85#>nJYg-yP06$ny&*3~ObhB5-cI|t`Ge5VQ2eP?gr?^Ld*ZN!B0!u)C}ol6 z)EN}zq0RrXq=8rv`xo2MKCnsPfug=Cz%Pksxc%7_sZ$~Qoq)L%Z zmT;O^b&ldKg^+S;8XkSz+^_f=AG&W6n_BKRdY4eNefU(%H^zhPP%d-7`p*XkZ;gd$ zuI7iow48_AosbQN999V5lixE<3=kM!cO8U+5xhY>mQdLIvUR3%DW&{{{N;qjjqDd|;WypKQa->NNj_;f z^g8ztQ!|v~^Tc5#r%Jf)E`CL5`_=tVz(@d@roN>}oP$u9d~9V)7E}P1tUXdmc?ub2 zV9@JQXb)|U6{ZtXrFa_J3$QQgK0DSQ(~UDbO%E_>9oZUUy?ENoBx&Fez4xGB?>*{R zwVlk2!mvI*we9Hk(M7)fr1UB-O5|mL;hKS-(#;;WJMxEUCuw^@NIm}X5?U3Eum+flX*q6qK22^plt~o| zva5IFvs)bUV4IYN7SY05F2wA#4pwsls!I?3-a44CJw;D$g5XF3{1$<MB6L>xu*pGBE~>j#2LEWZ}%MKUc$)|W^g*%5&S*)+i2Dv zgY$A-Li13nF+ma}mU@w_)bYJ8VOM<-Oj=j_{Pz^eul0=#-{NW?yt{bgVIl{pqEQ@0 zp^=r${$oK%x9%6U^g?i6^6(Gq7!2}*P&z&V4~RatOu0nGBY8p>`6_DZ(@n2FVX?`tGoG2fT}9dnzmx%vE=&*Weh=)GjrR zp`BOv5B+r!4Y$n`nWZ)hKvdVSpUwO&rC|Zf$uEGBGK^jW&7r$|wl_x;Ia)-%Vu@Z5 zxcq+K|D)@=tK1kw&?2+}m?z2AX`}&$=AY6c2l^l5b!#JT6M|Kq3gz{zPY?+NAgzw% zik1er^{@}*I;IXNXVip(yT}cC2`EepLsYNUCN!z91?>ArmS7nKuYfy3*NKOtS1mj3 zWL*YH2wa~&y?0d#2U7}xwU1;W8~qusb1Up$Pl5l}+pW0nCSVhBW*zYhFaiq)+bzTc z^!d_`B}VXyq0kg^65Llb;0xg>R7-^TkY*W0uLU9i#GZ5!_|Z7!W@6GDLe+XI0{Pdc zCx7E;^A1SnUQA2m6>I*belRSFeunhBbJ7b?clDPJx?G5Zx6NDXk_K->g<|1jbhBlr zCHb!t{}?6?>7f3uFDr}=G{LYiNGZkbk#CO&%W=LPKgz{?YFD?F*!VZk8XhF;>ItfYacRW4~TCEIx;(w8g$ z?{vUS2$GddyAo-5nG4XEr;U7w9w`_NU-5r1b;z=f+EJk%$o_IZ`Zdyp4pIfAODUp6 zcCY6H2PpHA7wh!h>Y5wGe-Fc9wJw){{XXOs%h!u+v8JB@0rO`5YSdKtU*7o-HYlPv(!+|4BqO8U7wL@EC)`>OMPsrMLuP}h|>q>#L;NZmR z=*Hwe@1H|FuzyBvv0#S@u{4x=9$qAok=gr2#W-S3+E;IAoE%C-$~C_WL2`xHX;CbA zgXmefkFzdJTvHc12ns~tJRVx{S=KbA#N6O3d|CTY*god_5A{BX55TPt5TnLxkqI2U z1B-;hz9kVdMy)Ev%7T}nxQ++-ds@TClpG=G6%K2_bIc!g8q%;xY-xj532`Df4iJqP z%)zh1IxKlYNLUy!XLsO9+1fmT5rJFg)A9z_1S&_$by*$wC43tK58xZekU%smyuMBp zgm>tlxw=Db;((+FX^It4Dr|g%_A|k6#3h~I+V_bJXaiypJI|^DV}i*uw5*x9jI9~C z#}t7_Q-3D^Z{?L+D8MHRfSFgo)*8=4kL176O^nOHHC0eUy(6#(0cH(}!)mApSVMx; zM3z$7b0Bv%LilXyoFR9B`{sR?766>+hGD2G44g%A9rom-1J{U)_5ELf=ptl%$z8Cm z@#k@t?~4Jp7LclR=v11J+Bln%@q4?EaJ+Jr3&s7L*fZcjg@z#)1$eM^{+ujKObtrC zanzF`R5L`8wL7-9=`wMe5%sIJOz>7Af`kp6gpoG^D`^x7+`D0%RDb+Bf;w*8xF+qJ z_9T5+Wi()ois+F4a_wa%oSG3%45q^rOr->(B+Ms{1K4j28yJA26X8XOBBGUK)K$3> z!o5KUNDO~APc`aJvj15vS~&3XP@tm(A7_2p(=}cz1wKhMkU2gxuSPh+-d-qt^){$3x#r@9#7JJT zKp0jHnc)Wy(dKpT@6YET$8`8{G3^*i_w+4^Rl7!L>r6Z6@%YTo*;+nC?mL0g1Lk01 z&h8M(k`BbC<%m;qVohFRXh6AM7Q7BN&})l?koE>ml1CYI3|@IheHT6n29IDD&AMMy ztB+Tvo`cWYl$kaU*;D0ISfH;DFC!w1PTVu1*hDnrSW2$&B>u}~3kcxZcmx27<4Af? z#|ALuivNMyPgOnN^El{CO5AH6?0Qaqb5@fV)7!o-ke?Fh_1q8O~A~#e?SAm8J zsZjm<7twm-lwI$2Ch7}-WYBRRzy=7YRi&SH*22csmE5J)@#y9|Og|mY2Pk9|85B&h zGprV&H5@`g--stc2$s25^K~lH&pChM#1l_o9b&XeeJeez2x6lNzW?!^!RG8d3~I{ zs7bN$g~@$`f>*aZH<@-^J0tSiO3{QU^?DcbukX0yr-Q>t4J$EU7#sRPnhi0ecam3FGkhBb2 zi1I9K$Spi%NE$NyAS$NeOXgE}lcdyyaP4Xf$oPpn(Bp}&va-*nR-pok_MFS39eXFq zBA!sK!z;2oqiiW-FWLsdQhYpYC7XornEX=N_WTDj!WxUi-6HQi7mJ$` z&qUAFDYHx2N@j1ButoqV`R{is;e08R%xX!XNm` zjz*)wDp%Wn2{F>e1vx&u8(bB<-*0|;q#B7t!<%zPecpNMk z-GMF#{~{-nVCyv$m)1aAMi25;cZxyCg`ZQ2wy$_P{J z0ArF(A7%aQrXLY!79iyI1u@oA+2fT4@tB8c&%QE^HT3V;K$=3yp^P>?LQ8Bo@bpaj zf@$mlO|7vDTyuSP?h_}C=oaffbVPsTy;%b9!oQ||Ap}4?D{hTJk~2B3+Dxu#|kBD5M) z(9Wn<+z-lZfp;b8;yIh0qTWW$SC;q^Qp}V-@RmvoX{sXZaSC4RgAfr$+j8NA};W zIsaT1T&C2|gFGQ)B#L+UnmW8TC|?lb9?9x*s=Nvtrrw&EmF)^Tw8U8KviEZgZf zi9#kvn8mQl+=)B+*vUjsSDFnK(Mf~y+v=ZohiDWN02I^cF$A;RG*g9sNDQv)+e+yL zA}=1XWTfi=ld?_UQ$G+!%EzZ?G;NM?&%OoYT|SEvM91yp1se>KTzyap6Vrwhhl5T-q>or| zjl?eW*T%sAI$?TX=>2S$cxm{z!Zjte304cmjY6nVz~4ktb$w^*VwFD0RYV1H#j788 zqwelSw-Z-KEmBdn9l1n_d=EIl$xQ;8WPuf0g}>BOdqPAu_Rp&!Rsby!^E5UGP>+CG zc=nnL!&*egM-7+Q!LFKGe!^PL1YL(&!=AVIR5=!=pc$Cb)_6AloOY-+Bl2pSg<{#L3}?O zzgcSL`xwYUoz*uP8~S-`AP94a=!PvU`f+!yqcEtdx_Kt!CL+;QmbH}E+&JB{@2?)! zi0jj}?-?eI?WUm!zj%a>Yt|Z}NHz{n=oes8AMH{G5xPSvF~yRUTrTjnJWnIT8+0q} z1MwLdM2!OXXnrc69fx_G1OVD61OT*SY$F}=we?c>_YkkeJ*NF_97F0;0tsmxxcX~{ zHpOcM!GXbW`Tq@KlmPF%AUU3^y3l}!g^NLuyOGK*0}roD%N$W5#)f{2%^WrBF`Oh) z?0F~YRYJEgu^w-nqPt5z9&winB2|ytbVQd|8<3Np;pbr389*fO zy)u5v6c@vfS!BD)liqaZwWgW)gelO6Y6uebFC98BGU>T;T7us$G9vA8_Nn1}^2@dzv|m7!5Yf+= zoBiT^C?v~z+s*w4{;6EHFU;CB!nCFR5%hc1xFepBDQekx%jEOL)avOxRzjIIY$D7# z$VQl&#}m$ZgKuKnIApq&sFzB03qoKBkcav!$3^HsrhNTB8JTPX_d66uYFK8IXM?ci zg7;#%OUFa*{KJ$(l<-ZIpU2(=@=S@P0U5&{eFC4jXF?Q-Fy%8#w;eQvkSzNfcNOud z65%H!jQ?GbV4o&fPLj+3SI%1#+S?7u{0g;r_EFGlt*G*=lAOBIvvgopo_6Z+^REwM zQ8@}@-PZ`UHoYvRAc0?AGFz29L7S_7C>|Y?j$wU&P5t}Q^p4cEC21+}(k)!53@^W7 zY2ikw-&hGE+A=<3Sn*&c39qbr^cXOxe?+Pu3-5tp$8(3RTy|b2xP%U5{!qHK-GaNEE-{1Ffo+Diy?QWc_f2yNSBCC|Pt>!w2d$$kj`id2mu8>cKgoZhKLu?pe9}nq z9ZVDgh8({-mMgbyBtL+$hTOf(Sd*6jd;0bE(4+IUcLId!Z!e{!2~W~a82J93jA(u0 zt$n-jpTUYv#QZn2!KM+%g`wQfJ>zk4}ioVFR&MC}}P3$F(s zS0&_&ivo}H20EB)ZZ9{1_!DYz1DNnA$@a9@fNSn0Tr-!``^%Dx#KjEc#LNdnKTUlD z7;Y~;)t^z#q+&4((XSA5Il1EED(La(5-DwIhxtPEpW0jC6-AK1>j{q&eQ9cA7tv)r zXK9beAyL*S*rf~-Om%WFgA>{}AwhuT8a;@e{AoU`zgIP)^We|3;KK;6Gv&5YoD>>_ zFX0H?B`tKJ*{_fC{O~|aE@lOZW~B|H^_0-^2H1)hiL^CuIb~d+I6$@jpaguBj3r#Fi`}Z;y*`RJ=>5lRS$OuJ2Hm5)~Neiw~b_9Qw z&B~DNb0todQk`qJqiUZlrX|(_;The6uF_rO1k9c)cO2>xeI;ODC|+{PE9cChLzKvC zLd?o(vV$lbugDjC7<+ZpOp1IxdX`z7Wg%J|(OCZt?T+L(??Qqp8xZD6RvKRwU|lfb zx~}rleW6aO(DxJ2_n*0*cQ!M&3dd)cFlA@Jpbu7egC}4WBJD5-I(P`lhBd^$pxcv~m z-az)sdp(h!jX-+V1u2U&U%0^E;oC@B10!ZTwAWYmpGC63o&gzMeiK0UDi>r~^CIKR z^J(&Jj@Y0rszSuLtDIaNoO%z1|R7e{o}jwyymJ1&T)91>-d9*%uLpu}1HO zjy8-li^?{;Zss*P@EQ~}#mA?y-hUZxlqYS?7J^+Q*gB9O-=+quA6sbYvG=ss50DHk z_so&ZRasm`!xqu5Y`cy*1D`4%$Haq#xNigc zQ?4O|UtBU%)sj>$+$Q_7yt@2qUT zfqq7pmGR-g4Kim$Nv5j~jXvwcc>Ow?(-@%j2>7ovllYgi0U(LVK}J7#)WGv&p@d|# zXSc1K%hNaV(ifi&QM9oGVXH9t5)7N-&mEKht|S@RLp*(jd%Q%Ksbh_$ZFXEAm<_h) zOjsf=2Ub@A0NYsFZZnxw9Xw8;m_|1&9FF^neMq=uK|}>7oAFHcCP>{aKIlwEuMeLe zn$EMuDJpKQ7X$T})Q%=wv!R>Y@u;M${au>E(iv`X?zYVu$y_01~`Z=G6aXt`bKU<$zddjNkO30g+NKX z5&kEP#qmjQjPh)q3eix{Qxn|j$fSqB?LaqAs%5GXC}tuuR3?4 zJmcDZKM9LawtaRvJRZGB0Y2reD;0nhL{zn9G7Ql;n|d=o{cNiIST<6=)W@E6_L5Ff z5@D^Z<2J85xT(w#*FIFd!Y&F!y43xa+1#zoR~uMJdH7l|^&3c~9pgw?sD;xKkzDg`E71l-)m(6WP! zNaz(KZ^ykN4_;xucD}Ns&Ff7JmKnR#ZwVhJ(|k9u^(_!J6%4cFs0sl*Dp^U!D4Q~Q zH=?czU+qATL%k<1+ox4+-|5O*cuS2^qbBMTz^uZr%de6cOKw$tdCZP9ub_e-cMED;Z9|(#ZvozVjP-jV?ryu!hVXOF>0) z;~>`diKZ`wZ`pt{qR1Ft4m3s*fPr3KvT(y9GCP3nOJH;Lkc+GsH{HTs3J#L!w~M#| z9|P4iLqzEpRZhDSlE{6^EgCp6cj&?Ny!py$_r})-9nyo7j3|g1fa;SjB#QJE;|Ybm z+ViP9h(A0l7xaqq$%OM~bT^%M;SssJSlq~Qgl_BbfYI^CuLF3VT^FJk{KP;Lhbr9 zf<`sp488gJGuBZ3!Bz+#5hF7dIP2nO=(-HRXvGvy&x1J(cz7W2(40?z2R3@`{bm!$ zPzxfLPkf|T*g-GHVN28pFRM)=h9&iOD8J^F(Srka5H2sG62JR!Y8}0Nf|UctB}{tU z@U{rky<~1#080_5PO)S_gTiw&F<5%Hf)^ zk|znaopm{}+-`yOmM2Q<1E~H@xT6}gd;0wR^_sS*1t^8am>#b+P@-=>?zS&?I<=}GBni<{S zS5+)kq>H5*_T9Z4pu}(~@6-@WpJgF^}CW=f+_}z>yAv{+%{4 z!Uz%}a*LFCj2FKIJ^w9tcMH#XyM2UfSz_*=< z?uA8rhaP=Y>rs-T6;|A(V6=`VE+0S3sePyO*AAGS_b&%{c9TBy<<~_wMJc8iEn-~z zgL&$8Aw*siBhCm0LiEs(S%?tnqzX{!Gm#3!3SukTHuQJOUZZO^$_8i`3z7o|JPvg;pwv%>C+d| zmKP^l(ye>ZnJmDv7CTq;)&16WDND5@J$ZrVglI?&hYldh#^cZbEA~sn~K>Q=&_wdvL zb6flV4TX{vSnLEIHTEyZ3m%<1UwJE^gp&n)PyBOkF4xbr^<=sSmk~rGD7LJ9IF7d! zAULwKGFP!wfUDl6ZFU)hRJp<|X64jtkaL5c*Frpf`DMHIk0eWGRQ?)LVX%+^jnNoB zGP}+Xyxb6~SucO265z4d_l+v;O(I7Kv$pe{gPOBK(IXE|!^6@>hX+rkR?BtLm?ZMC zrM>-Vpb2S)tuU=`A{mGc+!ZT4vRF{@9zWxsD(QDiC?HQ(0<(WB4?Tpr82!N1Tm}|I zK3Jp%TT=8H-v*IuAwkGQ(pFXmG#5!Q?r~#&QIj~yU*{{%8k1&?t*BiNxxCN>+O z?g^yVjpehP103YXBwaLJKGN@X_59wwm~ zf)2OWJH!y#6YDtW=LLZf#$oj${BD^qkHKf!$*Z++yoOLE)npR@#UlWU_&(`njDm)2 zHiuD(4BHCK<1(0sMVnpIQNCIW26c=DrO!A$qX)CX^w+H2{;zk<^zI^rae@j`YBqtj5BY19=^*|`RD zxUs2~m@mLRAb`(Aw0+T2`NQS4Q<#ph*R^N4W)P)Bj1802>vV{6mw+cD_YQrAE<@tH zyJX>cVDCoJ)q~GA{wy5s>at0#=31vx0Pqj{sz)SiRE31;Jmz?rO;Q1E^8_Kq-`Eqyl*W!HHNUA03@1 zICr#5EOwUO{}g5*RK7es!zmmbEai4*=S64d0IE=osY0RrI9X5`o;*^g3lnl&nI>6$ z@0OwL50=-c0Kf+%TfCM}1C#H~%`Nqy4^0m`pITK!=kBcA0NW42THM;~duLv_U2B3=k$1yl8A5i>u-h9mD6t_Xo z`LwSj8PuK`VR{1vmTb+8k+|zW?IB(Wc`s(HIecqeE(2Ij*U-$xxgcR?s(6Ep$m1p)a(Y;fCNRrsIaMnxAUBH=+t42_1~b`YwalyW*k z1W;D=?UQ$m`piXO{vGCJNO~>;s{&Q-f_2FJn3JS4P$rnu)HBpPU3YhY~+m zPV*spPK7YTpI$wxdG+T|VaL|}bU)(GYz-Tu9=sK>=?VnU<>$!Q&!B7z${=m)O+$#g z1S-GXUkua(nrhD?YJ^IQRNr@S8XNc0+$ko|Ows*5NoHPYtZ=#y!&{<`mCv2nRv|ik zj}g9(Is`A-8`t&{rtmj}HP2_&?;4rmztnp;eyT`yl&0$qhlc+*Ka_z9E#{{0Nux{&Gq z<0=fh3iv0#T*xM!x+WYRu-IGAErGOkCF%lOK z>7(IMby4ltdz8mpKxfNzcw??<3NNfvhA_v#H>(pCSdA0@W~cA}=7I2+Y!lQ2Trhk5 z4`XyW84L~4c0&)8pN6@mzRXfo8yC9-iq~o+!6F?ZxGGOtvdwxS!Af=V!M}p3Zd>ud*Y%R86TKIua^9Az3hHU(ztZ51hrLCbMk+e*5v0J$QSa<0R zuh6Z5VDQV23-|o8+I45_SH)+G4BL7-Cv1J+^(&oNw}L6#E_rqVJz5UU9Mm~H5j$(( zGGJxSz|#;YfXRQc^5bAE``}YS&h9hAx)qG#X{#^k=34y*5J2HB-WYG-Lh;k{5k&l< z{thWHpa)4tTK-u0%=F_)MBP{6CMdT{5AH*JbKyz9s7K;WO*O&Q=$yMN_3`{;0mfd7=P6NtV`_PR`_x<`;A@83g>Gul2eU^AM-)%mlf+pNuC z!n&_%JpUN*1kAq?VMdZaWug(}bw^%n4(MI&DtOY`U)OCRbBG3Gx|55clV5i6;v)r5 z61EKWG>eUMbE(55-!C96Pk9!Yycs-FPA)|I%8klDC;7Q$WP! zyGzQu?$lX7pp!cdKd{JOzj}8O0FHkMR{XX)!gO{U998R)0+gb@MHHRQh%P)T4br>f zb!6Hau*vO>3iKBA2D1h_g~;pfJN-dymHobQqn9gmcn<`li=XD}pwzLk_n8l|EEJyY z^_#hv!({tTz?$;;b~}=0@GaV~=rZsI615=ZxA*+qN+EOaiLZI9G}hqe%~z`TT{RB| z5N8la+mv$p7^peVJ|o7Xqds@y1im;+RE2@+5?zJ)_`b-c-Jd8K;rsxfw}8u*e>h`5 z9AT{SPSIU)d>JNOZ#=g)2eZBmg<0S*H~a_!>cOo&wVaX&9=t>@04v{23abDdInbhV z!Tr;tOB9^R=8MeVYovAXODI z7eI=3LQR2K`4|4-*22NFw{Cg8Den zH~CHt=KCh6?{i@=y%V<&J)2%6Ii4$qC(z!s%+KQ(1msnKu`$;mb8oiDU{aZI8>W?| zAi@z3gd>p^%l;`fhOC$&4celV6`8F7E2;~bh?T<(+|Qh*YKuhLiA>mZw*A;S-^0Kx*d(jRLDjTJZ{Ev2fF>O}>*K9+>L>esF z`jkLQ;iK)XRbZ*1$qTY{FO4l!eQ!0csDhugqWbR&MeXL2L>P1JOko~#A+nyrJnt4Q zVCRM~1Na6~4O;Oes@!WMcYo?|>3H&n@1#TmOUhcy^m$`I)GJQ>;ij$hNX?#sJdy}` zteksk%3aq{m*-U$A#{*s85Siari&7u1bfa)t%$`yD^>&90^Y{u1g(V-OnhZmkV!af z_QyJmAr7Yu4eg3bPA0_Xa!h= zoAgO-H4(1@0vqp6O%}dpfz4X{RNl=rH{$$wsgf}TNTtVs4}jfklTmSZ z@Ng^aNx&!1kgq$+C;JedO4W>G=^OTS$Hym5CyZ_wXcT6p9V~U;b*qvf6ON=x#@idK z1euaf^nx3#_YBrZm7S1wrgopsfg{sPwIRS(t<^Z{8NYrN@oZQ}aw0^i+t!!ZnFJpi zA)W&J71%$>&};LB;-$(H-ieD-lSl0Pu-ceE4z2iBp2w7oTYgI4U}hq*6|e^ilTC+| zEOoyWd<=oO!rh=QzTxy{illXjAkCF?`d&`fju~uhJUnhTrls+;;vAZ9;MqZ=t-P0I z@N3h*EFVkXQ}-L&>b(sWjrS)cNMeDHS#_n%AUarPk$vEj(3o< zsv|ZFR`OMdYA^bei7Q_{|Di|pdc$oJ^AxUv8nj>hX#qMF!-aQ9yDk;V7^+U2j!K=_ ztdE>oyTWZCESvWCKc>;O+!t6ZY}z481Xw5%k%$*O@s6NU);lhwD{lbN!ESP{ ztfTgMm6Q&eT35<;4^@_7bWTD73mg({L{VTC1u zU}KL=>viQe2uijijq0?wvAW*@A~Sv*0sFAbZiTlMDi(oee{dQj%jko%LSbTy3q80Awsp>GFWb{ySeP;{OBk+fEsLgB4vGBTxoik6W7%M4 zFUe;XH+~X_Rh!}UfS#Sf!eKjBYZ)X;7GTjj;cpqR%o5MY_F0s2mttEYfbq(gZN7xa zh?d&GlyjM_?>n;k2cM!T1M3!|>n5lr?7~VZ2`hofyMWGpk39MWlZ+gpd885SK!I)- zC7J36EJZ*-&b8Dwa?fG}+lbTbC{ z8efA5Z&cnZYDzDQ0r7Ku;E*_Ht zr6Y#Z_`}8z<(-6HSUFJ4wC!y!BZD|hZfK&Zc}G`Q>c}Qok3JLYSk4hTAst7jn@0`_Q`(qvSp8Yauclain| zgRby*Fa*r)HPiVgpQE*?mHygx=x$gOk)(q+F|%lKA^^7ZCWk4vOo37&O%){vVxi*R z59-b@ETZi$4%A)*KrKwrEY|UJ_RHoO`yiREY`v0IlM{*vfiJhX>JRmVx5!CIXNsBr zmn=T{2jtO{D#ONmi1uluQhLdu_ELB~rp1)p{ekl~af3Omd!B5D6Sb;j_iI#WQU3hR zF%+fJ2T5!94WeW#&;ZZAB4FhaxV`S z0I^%!`nd41SS1b1(3l`DYT1IbX~U5)+KPYjVjtVV&IUC){AlazV!dif(h36qw#Z$nn@fo-aScVAWU4n~fZ>%~G zoBXsSM>CcokDSg3Yxqs?9r43dZ%N zS)^o3C+1mUTVNK{?c-*hE41-+K+)R{6*4H}kJ{vPTAZj6U=Ax7%ZFqJY}LSR*ddOD zd5sf2@3Nq*(=`p{9N|Y8O##*DV%V1#>?p;wC25N(JqF}mtKQ)u^$g%@0 zC?fFUWBE0i8=nO%H?LYT^^)}D!cy}Tr(Vv*7Wqp7n}66&=J?B-vWI_Gu3jRh9Qbdb z!WMh}?xEXu`E8ZeHMrfeL%MOwdtyH>7dv;~6s>1ELZl6bX(<Q=+rWKELwWNV`8?`-1n5_M!$%9-jk@?yTV|a#T{%n0XBmPc1G7^D_tQCTtB|oSQt8Q%h)q_Nlvb@)j1k03J(S}4z$$Q>ysp9 z04B1rqcP8FE#k13OxFqzR@mA}ELlQK$+7g}56)r-EBvbTg0@v=rqT0x9KvWBe9cTx zEISk(-L@oxSah148<$&uWYWM|_YtB!*3WqP*GHX=jheTcb|Ali6L3L6l{+%Ls`EuL zLFU!=Cj*5d&iL9;7Lf$*nS9mG&Te=<-+&M;*%ta=95&RzzpId`|ZI%+D*^t!~gJ0)TLADTa2r-E+>UrTzZQAn6z{*qjx zbGRj7ak9zH&`aXBwihli(lcuBYAoaqVFR);hGu1zf~G45OHLxO%H>%! zX|F@4=3}+>phelGJ`1RvlieTpL;%LHjGRYhEk}K0EA2~6a!&Hdp)2_vV3Q>trl$E4 zyCsN`vQZDu-TiJ+W-V{EK1%HaO{?&^E$Ny@A)$70PZp9T`1XH}3$Ow|pKnilC}nk) z_TiORe$(o5#2?^HHfloIDoi6WDTS-g30k41<4hu zB&OCJ(rrA+$L=Gv_T8d8b~?-8%?GKMa@*SIz+xqM>Wo}^`U_cdCEI}Zvluy1RvW@Y zb_$=P1!eH8Pw@p8UAXAczB|5Zq;`%a(a!Uwj7#xHLJpX5)qhKb7hisL;pTpF)>a=T zdGHM%Dm$Of7E`_5R5zUKX{Q@_atqnKauE{b*l^Qr%Du(HYylz8AcPRp&<(6Ru}M~% zDp(jFMIB%0xRF$20u)kH8*fj(2)r>xQnV7m^tNZJ& z;{D1)jO$#xK`fT=p*%PDG40F2s{ug9;aLm9GpqQ-jT^m9)g;MW98TNP3y7rqSsXFP zlCZN+Ue|I)p>K4K%!v^#?94Sb?jU6u-(5j8tXy4G9o1xOe14zbL)zt8>9KLF5n2OrWCy{N=cKP^46>5#ZlALZP}Bgo0DqCYf09ct-$ z$~;%J+;*G0t<#rvUB`%Cu;U#q6}~P96vo<*-EMkCWf-`wqyu4u7CEaoChFiBP*sGt zFZ1#c#W-4KM784S~c8W#tS7%=v1ZI$xY!9E@SL+ghP=Xi8;O}`hN z)y6tN;_gS1ZDeJN?4gY$dfbMWmIb=FEDnsl+A74q+zw{O_wBBW2Bj1*r&I5Jk3 zvQvnh5ZnY9P)U!YDo_(X^lep1#YwX5j@i(*zfT2|Myl={r=rwVziHe4`2GO9yH={h zb-Zx!;5Z;)A6-g9iv6P_I{PQRScbqh;VKO5nyKmi#Ip^AZNXP-^=~)*MH(7bd5mb} zC8m)RUsmQ#8<^1pu!@ebu~w>r9t=li8mqT%>4(#v&uf2kly&cX0P#tm0`bv(x`7Rz zuh_DAA|y(j+k{N_aYlx7EN}9LxF<8gUm5Fl|Bn*Vb66<@=j5)-Te5NTE-&psFi`xU^;X!jEY?QqdGget=0 zw92m5c*r0Cy*`rZTGGtYQ_pMYVdb>K3|no-lpg@W*q_c@q7nByAilUdg4I}K&9_c zGW@#C-F0jM(JBujW0Iy;20&{$1+xm1N#R(zBQQ$Vy`eCtN!-u|VqHAR^OCpDq++}q z!gOPfj>%G__thozKogXxfiC!H3yW~^Bzg2Ep0v@0Yfu)xLp(ga0ktxabu!Jz+esGFP4+TOfjm4fDGQ(`5q@3V^+Kme< zFoy+ShdUl56oNFJo*43rgvjI*)JRz#D4%#C10yxf9{!M6a4~_jewI0<11$Sj;ZPU> zdP)Bg^zNP}Gij$z>HXXj72PkVUbms90~01EPwMOM6Bfl>vX-2Dfrhf?Ed@Y$BV|j{ z2G3$%vZmRChK*-P@n=}-U;`I{eT+HD-lH7WD)mFN&2ZPCH7QYy?ML*r;(tupe&uiA zg1N(Tg{rPS04)dk99I+@z5JoW5Lb2|J+3gApsngUG!4Tw^a4y?ESc$@BDI{V33s$1 zJKx7>?Ew&4yR0)vEtkF-?D6~dIjT{0_j>L`b+lvcUDO_vARD|~>!_@ZmI^EQEE zPP!&#T?3L3bO&iVhE=Wo^lYjX6902$>(u5!0d}B<-@|T*DMu@8qbMabNsyk0WwDHE z$Nt+h8*E9s|G{)DH3?!xU!iy#D4g!g5pd|b&xa`i!z~g0O6Y8ITnPfTc$kusW;DGT z0{4mGIE})){32rsCJhW388lvE1q^hUtAh?pTZ1O>5=dq|Nf&S-yk}q^WGpjFin)No z$^<+ZXuV@}gfuOd>;8pz$pK>Q8K=(h|E`A@Nr*wtoXl`T{3wJ%_>LCDaw~|>(Pi|q zeyg7k%VH__wc;QXV6O*5L;>d8FS&yxU3?)9w&G%Y;W&juUt!@ARDLJFb@;j>G~_D8 za7s#5VvZwn*$k7zUaaw{8G|EWkzo1f=wkXI5Jm<)7l&{KTlJ&YN;d4~?hOR_#fRb& z8+J{4VLR46iYhlpjZLF3Jmh7W>yl8w4FS)0gF@mxJ#B)7mJ>LH51pF_-#%qqAar4E zfYbnfZFt*Dt2?qao^Inu zGHM&Qy<9Q73E(@FY|e^9u)^|P=(u8|;maSUXXqK~1f;OVm$(1BR!xer8qi`)I1nI;rKYbgD~S>LyA=C^@g#Bjb0ki8W)EBf4vQ8cqP`m!5A4rF z*fuaYCO|@1hsg;8Ps|=oQ(~W3-+rar+g`Z+$#xsH%G*^GU!mk)9><{$?(_5x=xRCIS!Q1_*(m8`H>j*}Ya98!L;1|^E-v6jbhWcTy5!>K6n z>~I*CC6Oyq{lJG}6FEKL|AMnTTuGy7FJ7G|Bg(2K@N!lFWix?Ml+Cc+pn}u|&?*m^ z6D3}0l(yir7SU$=g{lQuX)SbAbQ&CXOnPHYX2|#>!W56LTie0XV=o=v zi!(MBPuN&sT!AFQ7F07J#b(?M)q#}4loUfc=QZY#O@pB(2kd^;#zlG5Q+dGF(O*Ca z6m0jy;(^pgalnm)Bmih|NoT0akV?gSb-xY%fXMUZa1dFKONP%@ILGmRg&m<5p2Fub ze9K}|^uU%4E1uc!B|;f1=x)u|eE^JZRFCj2^EpQ*>i-=DMs1Z1S>tA{vJNAy(3`=| z&iOY8|7V#)o|o;`8>bmvwv!wfK2)Fv{kEDz+5Ekm`P6E#WH|ud5}+UB@O;IuLQ)0n z6=>W*9_KEe(Lmy({*%MLH<8;M3y7qmvTJWo^%UghiQfAsVp(cX8UQcPw2@m*F;e@a zM9v^+QI3E15GkvK><4dl;cLL;Dy>X(|G`)?o@AgQT>k9X>M3X>xcmVK*HC5{=U@Xe zC}^@}nr*&p`wa)#c16CYdb$R?xglf#EzpA9))ILTt$TzkDh?Y|#I7b})NvjaxD1Xx zM8zbE$mAncV}cMa+PW8ur-NKbr^b^|iSV~>R4Rb^!lw_A&tQtNqJM?h*=PuG~22ueuq!r{j zbV&glaPiqe^gauohV-?K0xIRH;9#I~h)Cy2<9S~AK(uYsakU^65sezEV@sN=Qk(mg zhD#yi>D;}M5NLY{*hSuLX=N*O{d?M^ps}2!} zj1Pq;a$0|t$N#GtZMclkIQ9aqtRl?;q>=6H@PmrW&kb)kMbs&cHQSJOh#y6nAoi7? z>UMGwROc*rN9>kd;XDgIP7hKZwOYAQOl>DGUX=QeCmijdzxb!7YZi&%3kmNsUZ$hF z#R)1R8hIxzEeMM8LY2sL#XS6g^y1r>Vw^V=j-2xZ{jRO3FyxRzf3Z^&X}|sEL9z@} zdFsOBJ%9+ufmKtC55UZFD$y#CML#8ol+y@R9tNgBFaWXKjDukr9v-w@&k$#6z?a-& zIdU0py`oWNv7QdRXk2p_dG4L452rj~@KL9GWU|CcFZf_%W9!yVNyiORv_h1oVPYXv z_wd#}w?gt~e-%1vifz3>y*_HaTgZN*oo8 zVfK$8;GC8+7ixIyd6DQ8mT>9XL@YSzh|b2XvsroS(Pixj|CmV6G7#-@v4O&o41<>) z>&hR<>GA~TKMn_tfB8q^8U)y|OveQ)#`@ibHC+iQv!%CIgHgj}ykXn)R|v#d`~^_> zgxvlP17cq%N6V80GvQJ8HTFX^JPoKT0h{UXD+=xy7#qpnpYqwJh18r;0@&SYsp-ss zEeRccI)ac(Y4p8TUcC5P z7ikdIJ)&25hA=|DR!q$>h_*q`#OBtYxP5b@ zc>le2;vHBrto%g{N&e}UQ8mkFY$)3=uib(?%Mu?t1MH%+T$g4SSZY*UK|XX#N(de& zeVBxUOfXXth+Y`M8z7B(Z6+&4W_s*Tx}I_Hc_AONOAxnEQhZu+n$KA6*s?9bMOmUm z*Qz!w_3;DX(IRIhaIgM~x%Z%!$QSnnPHo#U6=rPij18!|10OB74Ixeu$C6!dC?QW(c{3Fgz58)uM-k{ z1l7apJC?QAYV2#*WHy}4A%F*;HGi?^x0d<`cOqlULdbOUIh=R{ET{LxP2~H|L*F%i zjCJHRy2aZ<77Q252~r~=$C30V8oY+?ui~kVD{mW3nx5=LxB)f_)k0JwcO(+>ViKVA zl~h7~Alj|~6PpaSkFNfI2(dexLdAp-MMsWFKbyvvvw*4RTpSN;`8}lKT6`ae=c-l! z`c4mmt4Jt!$SEnEvCp_amNO^fbV{=ZX-MlOVJ2pvCGO`bVxUlyArgoe6;GsQiH~*q zxcwZEb^iFX0%jwZ>k>T)jCj*s_&)xSuu5pgCUVw=we3@j-57j;&sbeY_p?R2(~&87 z5{jtcA4n+BQ#Kis@C=w!lg@gX5Up0g2wwBn%*D3chnn7GeJSuvfDfk-eP*Ks6PZ@Fz019AE&ZI3 zkPvUuCTpX(eP$zrhii-ooa_a@oiDa!rcE0WbbHadLf{^GG8TMrD!mw{N_(Z$UKSJT z#8|?>6wY0TEKBjnis?L9su&bai%Yb=R9m1i#(X{}cZrB_?9ISQq_Wam=&);C_7px? z0W(6C^B*Uw0KxH(JmWCvvcj{X86n{Nd*#$6b`W<5}lnUjS*GHZr^4E^*32a@R~ zOut`KF4(|3DN!@-Nox%046H-H;*mSBWMEFKsm2YE$m?x+3g>Jst%Lx+S9 z2ZfS%u%5>j?3k!8iGmV-6v{x5eAo1RU)wXr$==;&k(xvZJ=qIT2s+pwt#*PO80*sw zfP_FuT#Y@Uj)~?R{4u;dwm=fV>Q|eEOpp7(@sH=E$ypNok@o)#(y9s^{rMY6m5L1D z?fO&xKdWIy?KMpyEL~!Ek}VnVtl)%{v{^F1iCFn-8ZJ0HSugMwR9dmT$B7DE(tCli zAaKzQq)KsCMmnA?Qdc^3rs*eMo9Gg+paJXdIWWx-TESyH3*wcTY-_g5r~)KhJIOBYy;FRuy2P$@_{~f{BC&Br9DF|cBn3vKuG`5I&k}aXg?Ovx)LppwID(A4 zS8k#CU9sxFAn7UenwadQa#$c1ZeNqPgbJdvZo>mloUN=#E~e9mrbR=QklK^Hv< zq6bGtRdwxyE}=Q?Yan4iyVPYM`>d~)&u$W2442>kZ4hSlTZGgPCha8kl`IYV+8{HZ z)v^Jq`&e=vj2+6*YW*FX+QRPndsSD5iXzKrod;Gf<iX~na*|7Ryjyc z0bBQ{aFG;RU;laZes&7PLN9S~&KX~a^CShqpH-Ko z-6<>3jRQM@K=^gaZiTd{4@$T<&>tZzO}p3g?mYjMxo*BZdhv-ISgc*CNy%+QZ#_6k z-No#3x&4z+V~1$S)PmnW++&#Cp1Z`0jBo7qWjHtVyD!l1%;k`H6M?S&#G0++0zjgAgBPc6w(WmSG9$uisZ4D!{c z*6o?54<}t(9G$=8$6GA^;0Do#wb+Y%GQO)DX3x&|5%ihulu7cOH83we5B}oelYSh8_jV>_z6!;+M1|JP+%a&##yO z$Dr8P9iCbzywo`I*#61bfM_1Uo2-UV-TLmn8$d)#C}m@#IT`?jU%j@&2SEc0x(44~ zZofYlXu7kEo8qt0p>K8X-;Wj-Ym&$F4oLpYD}KwV$XpUpQAtP!V>g6e1-GbPg6y&i zkw0V{nLo2OlR7FnMGYu89!ewn^CC>!UvHjf{EXngFSbbRCPh6k6M{D2-w;%zugraU zjl{}jF$t{~jVFQYa-z|Sr*3+byx_)9p zFM@Oq^NBRBrT=ww-rsm>`REZ;X|uK ziZ-G+uX|XLss%sy**W$2vxK@A>n}sfyPof40=zHqE!qCO4jG<>l8pLK+|;xq1&mkO z{%W59Vk=PQ)qlhgF0l}@lT|A>E_(azm(v)~jO!9kt_1bR+Aqm%>GE(yP?z!^>H*G3rbp|ganeD_}#KLbWKC2pO}UgnJd9}f*t#a4(|$$Qc6uXSY>?m zMB8gg_%4|QVG_n6A7Kr~(D$OM;EE*Rc8X9jBd);q@2H6OHB#t`F$-J&^dHZ{>b%7D z?5sqNaXBeCQjDGaN{A3nEsotBbY*Y%a7 zNjwWnjuNYTp*{f&9w7+ETI(p|R%7|>b%fg(gm|__3~J^Qu%zJ>d?1btIhn=C0rRde#yMh`?J=NZ54l3M<9 z2+k(Tu=$HN0x!LQM$3k#9917*4^`D6l+Bo+dj_9A(!Swbqnk3w| zaynovB(>r&M+Z@)m^9zHJEltHz3urPMG_`{svN;OJy?Y%rmZV{=kCy&tna0-3U>Ht zK1p(6Zb*O|{6>oqfwn4H=;-FSnLp%V%4d37;yjf9r>z>Oo{#`X{n@usF1P5yg|wD2DDabjc{2yCa0#@VJg-;0~bTc(jH)Kf4kP0bs;}UhFk<>9XDHUlh z9ZFp)uA!n4A(|Yefl7z23~7>PO;SlpiWJHJ?(a05bMF5=&;RSWXMg+Kd#$zCde^Y` zu0Pnnb}Bk!&5TD16%mkhWh|@sk6Y zZr}|Jh;EK|`G=!Xlsm#)7Z)ck6I0>?hjU?G<*?AE<)x7HZA;Q*7zNq&}`QQr7Z#s;-)@DBu-Sc`1{u^vxSk^OJ z2YUZF_KY&2Fyj{`$4-6VuYvxw1D6x;2I{-0JhB~{S$QQ#J^)E_=cr48mMQ;D{f-SKg4#JXOe-InH^8^`y!AGB+kk#KgrMD)I#U(TFN+9#$w^5mg^{Dt8thcc*a*hR1)e2i z#!7b!v2pNg4G#V`D>O4`Ppl|E&GU+E{<8ZII}v@(C87cockOOwyRS5%!fyCSo`by| z;S_ru@7yPI!t+Us6j)t0hmDKZN7kR8#*zw0wITpNf!*JKrWnLF{Y!Om=P&)TMPfmc zS-5p~dES}1)cdRjq$V$0L7v=P@1^YC)z#<|898U6C>hvpk{N-aRaTBELEgk`NS7blu{5q! z`0-8|I<~DFhYU%SJXVBZmN!7tVzj)Zskcqb`>PIB}uV8bM009)DkfG$9>?-?IZHk5E+u@L+Rtq#I z=ISlwDoU!y(Z|H;Ni&eQzbh; zemqL`Xs|fCsK{c=EAO?D&iev&KosgJhuO=Aduen(4az277iBjjT&5^a6h~IYr<_Ad z;af?&RV&^INi7v*_$tcAxJxxifl<8xQ)azDnyg1l6CT zxv<8$R&8&x>h1Of9!525037w4XO8xT!&4|JcxtEUZqZ!t%Q`Y$a{*R|_>u1fFLxyZ z1zTFLffs}TjQAS%hiL7cRn!Yr^}fFr;=i$;uKr0xq0i)#Um=^~SU0L{ACcD3u3{@( zj&+l-_z#rqF1^@-+7@hKqLqi268D>NcKJ~@gr%kZ+pu3*d|gVny$1c%hw2_R8q4o6 zHIjddS}jxNx9ME?x0rkhPvtnDv$a+Oc`Mdk0M=DVj;nfuvuNpKvc11M!jPzb@60Um z@t-QCLJcjgH1ldtP20U``xS>imN73NBST0!Vi8}w<(HSQ-dVesesDg?m~r~>XPkb` zC@^HNn-T}nDIVGV8}29{bXrqKIUW*)Lt~)#6A;X};n8~+`+JSka!yiSbIhWEc{5PV zmVYM}l7NLg-rxU1-RHwGcQb2tl9`s9K{)5;ZyJ^Yme={%Ht;~%-J*&tx!cfyG`bd& zujfDaEfPDs5m9-#uPI1{)su)HVM9CiYq%jC-BwU=;iyu(udvWiblJd)S}wQG>v$18 z>@icLPhE_@>O~x4=nax60zY8=o!ChXswX!lM)^l#`6&|3W{M9FryV@f7V~X(@4c-6 z9c)Vw8RTGX$5c4P$h)9&6*i4bjG!X?Z(mG_%8v2qqtgHaR%l_p2FKLCXNMDMU9Heb zX~zYayFl-Qol4QNe8@zj$dE*E0F0~v!qEbnywA^$g-S^B~}71XFaL0rM?UB9m2 zqmxhZU-6Wi1P1uavUW^X`#LtpGNj`&`gAf;e4G^Pjp(zSR_Q-C+AeI0`Au@RWZ_iz zY~A?M!`0LAp65wa(v&_w-~w}1a%Usk0Yo8%?#yFP$KtK-l_z!8gQ1zif!)cH(nDy2iEIsT=JJsR%D38FhWVm z4lJd@UE=I;7t0@|w$EKX5y07-Q#KLkWK(T!ZtbR+#t$d)${Pf5L%^e zU!HQwMcQA85U|3am1hiT<)A0mM3DL^3ZZ~Tl&|n(V&&g|-IS>PG&~C{6PXbGpqm$- zcf#bv%q=>rAt=9q?K9CFGVLfX80`I>k9a-E^xDA0Tc20`KdP@%4-*WKlPEZU{g?Ba zj(o7A|L^Awu&x41u=SO zEKzKuQ5W(b;;YaVkj>~6%vf}hCDDX1cA_z*5ks2R;8V-lhgP5S;)dm;?Aqz2&}SJN zHKQcfL-?6`=DHC@=dZ5?#Jq)Kpw(nSKo7^hSdo6$ch%de^W%@eHPO}&KC2JYYg{L_ zKAmlkZCr3aEz%am{CXyyjc6H&0WcMyZsNfe&ONbPQSj49|VH6Zb!veRjpFpHIcoo*42pN{sA>gp3bcRNA&yShGsw(K9tFuLm%B^uug4S zEBLuCKEQM{d+A=zSVlyjncNWm@KSm96_0AC`$^ynI=Vp?*ld! zCA?xA+IZa%x0L(sL|273i#5H-u^|YE2>fT5kKsz|X^TBLIQ` z5@qi%PBzL0ZGfneqA@wad^F}=c&|`_nB9 zdyG^kO0z)}8K4x-O9~!d4ZS%$lAYJ+u;Y3P>AG~x20t);SVc) zq2SdQP3|nP!eY$dj?L2;|SR zOQ=RRWR2T7{eqS#P`E4VsM4f1+ur?x(!*1~Qla&&_nWsK%yQ))%k#FVMHiu}Cfd^Y z>9Zl04h4Ypl)asCNWKAy6A-=dH-&#_efQ=TZ9Lby8D{IXm7)d^i2qs1$kfbQD#9rZ zSK4Jn(u ze+R^qeSOF$IwzSqwVj!Qn9&=vfVc;8-B~DzJr-q- z%`t1GeWjpZ$iv9guQ&_`^zR7?Vx)T_R&4vGf*;M{B(q~h3#EOT^#@P-n0`v`Y1ojO zo@4(8Z|{x+2t8*Wqnh6v`F05J#i#!Lc{>8+=HL4rI}bGHt#KVfpmCZrb^~a#UZ6G_ zm0#$wm@QbqLOr<15~_0Y?~tpF!J3D|ER9!2YJ1CdtLm9o5m#bJu4rnw2FYm|{D@dm zgo57O0Z501|Cjg%brsbJm#I@XCu+A|DY43kn-AlDRa1Y@OgB?hEUFhJWAmvrQmR91 zz(@y^y+zY<>RFAOg)HU>5%fJ%pWIz(q=N)Fl{x}4(tt;C^g*rLw=Rd*Y_K(`HWeP| z%>5A;-P8zjP|ZJs4575|ur$ye^#Ww$Xj)%Cf(l<=t|9S|6eB(8OP1fQ7u_!N-XoiK zR8sX4%&x_cjkHokfR^);zt|(6zpPu#19-CC~w#4#bjG=9fPy zVn>`7QNg@<#A??1&wCGu_5NsP4-nR^vbNcZ;*(=QEG4pb0#p@}71upW`S^@A#Dcy{ zq%6C+zoxaW=IHhHD-qpE8= zty>@YwA2j_o`qk+v_(|*THLGtnsoh;n>qoiQxUvHV1*cP^3XeKJv(t3n(-`RyGS8C zqE7yz`;n^qr_drNXbY^KRcg(J&PR`IvHFl}JbDqap|p#dhj#^=&(kU;Vsct;((=3* z;m7BOA|S zyjlA__xYOp2%yGN=JMt=h%&>zx81rpC$eu~(7HI$IkUQ^{+s<&Y~RNvxQMycu{st$;$S3t-_CWAhfSZBJh|T#+?3XXY7Xb>W(d$HIb=n zB7NXOT~tRI`sc=s`?C3n2MOjCpczdH`v!>*PHQMKPQR;7oa)6d=5}_Z`r^NZysap` za+bK(zQV8?mGhZ7U=!=qvq^1LwPvI!hDm_1KQa278RQeA0aBoiS zltgEbSDnt52tEwjrC3iFKv4>ob36>S=5HyAUgGX= zQl5se&bhxrZUUtY)Z};}R9<{Az11VJTpx?t!qNVdD}kV*G^P-6Mc$KZ=PZrodIk;~ zheXcJ*9cthD;&i>+Z$Z*0FV5Iv($w`T3aY;Z1Ns6Cf!CiIH)B_3f7tRa|cnjS<~ty zW@N9O@c=a^_tSICRAz|N`ewqu1}-;%;f~kTU-vTQBQ|^yS@ff15!2U7fw^w@c8|GD z+3bjAzEXJs5uYK0NXzn;{er+5ycdGw5m1aHJU_8A( zTiMq6Gl^)Br$NoVSH?xj-J+I~p3@)OsBAQi7Twi;yBL7rn9GeKsJPzV-j*SXKvz_) z@*BtKAV^gzRKGElK6zLcFsXWcAqTst@y_|l zwng3g=F-4$tfk*$mLH8N5hSao%uX3==m$~p`)Cg7b&=%|vrSRvpKGlPpo*c^1RoXc zXLpNG3&Lg)U3-|<2T(ur%H_#X9!#N}sF`9&FS_g}hKSG<`nb7n@L0*4ngHOVrzBQV zMr-JSS{B!^Dg@q~ssD9ZQF2U%kuOlM!>%D4hf25Um+QrH#SxyE^K-UF-xj3g*rDt} zegJDG|0nIsFJFC8*S>7)lDXI3-u_|5!9A(f9h>&MD0g;z`tePI_U%RHg|OT^F)hLx zYmv>-==-Lrc|v#Ch(-&O-5F6iUTtFl8aE@#GqEf+8sbK?Sp=2u_3r2kcxt^h9gypV zph`IKXu+nK5@01Ay)pDs9Jvq>=RS?1QOTBJaie|n-z(An9&Ylei$If@o7Z<|!_hFw zlfq}}epP2~xHs(@pV2HV3_oEFig$X(VQN9k36b?fqzBq9tIq8hqV$`SWdB9;{r*?u z;kcb4X#U>}SCZ;uIrkkB{&{NW@)}q^MR9n4QA*A(;Y8yfQj$^ks`TS5-!8x~ z1m};fm2tQC&LQmFR6_e!>c3FYdtb5Vwc(}FPGD(Y1)m6I@%9YM8!1b|d2z6MdglGl zcV1Ipb=po*yrb5l^!;S|#;moGtL0|~spjt!ltzeDF|V(t=!Dtot3xJ~5tK%);dG`rlNEoyztVZuuS7d=T%QiSi1_5JiPFBR`C*6e z-qH~4fD=1GT6DNmmXwj6`NpwE_HfPPC7@i8?yY)rNKGF+Vq|_!`1CdtrJFE{7 z@R3xt@)MSg-83d#K2OD1uPj_xP3^!G>49DoF^%`AEU|-(Q;*qIa&m!D6iRajUp<8g zB*Ej3PoYZw&l3G!o@<}`UR~@*w!~&-@u1>PXC2gT46z6}|4ofY7D+u$&(QjG_7Ypm z1B1Qnd1`L#d@Oq%_|Yl7IcISAd$i6~0ZQd%B*bt~_elsbo3@v}X=_Y|kL$ni>M`5j zYb$FHrB!!)sHmwS$|BAoCtICAn=V0xYmzp6bMZGgc@BKEs))KC=gHabe z3w*HHw1}kTJp4S}=wY0!R6IVxQu=ZJ_))=Ml3V#hbMhZA!v>A@%E2I_^2WiKrOv@{h!o|UVTZ~97^23H#I+M4}FSWHs|ptoy*5Q9X<>H z)8u7fYFFc5t9^?ja=dnXIqZfqI_4sMoLcNsgB4;+ho!UrIw^&4i}IqVWU1~?avC8D zi=<3-ZHT7@4ZBxBm-yRi|R-~A%KKr^KHVmYc*%?ax__X|1oje7j>pb87xT8Ds ziFeled$;2J0baZczqQ{H8ge}$?8hGThmD7YajJjK>bNzt%o@AKgAj^?zpJKt_Mb>*B#dLalh<` z1u>%n>u83CgUau6MMY*kSN!Y}?jn^z)G9zB$HNo?^AlCKkCz=$jibMYC&~5PB;lny zZm7s;@BD;F_jGgYupVuKprL%8(QR6OPpbc0gi1RJWF?t!jwXyl#UZrvXwrk_grSVi z=+BIx(B__LGw9ZXgQ-)I&8tQAWBksF;lq1}8%DRa*hpCcpn0IqiQ4u#{0sS)`TT>Z zCQ{n7Vw-`>cv))eMiCvZR2E8|2UbR{t$3 zYk)3$O=!S}<9oCmbD_q*%A8g17E{j{n$gPj+NUA(GWt};T)a6;cKyn!tpj&UR)#D4 z72l>UQS&+cCzl*CH!qI0M624>-T>sLZ?@3d7s=pgxzT>`mlj+G1@{T`IfqXdantqe ztLd5y=kJb#sA0-}KcC_BE`pe+k%u0-b?MyoYk#tPpDn|1BD>W`ALi!N@(UB0QaGUY z$p(HSoy2BSxFn-b@BGjK)3H_`915i*)}_qaJb}LM{>8IgGoUf2$5=Y=6}q~UuYBe9 zrfI|p8+j_Hg^OBsx>9d1bP-{#*}8ZO_Xqx6Fs^#dx3L)*!4?I+gn4<5s08D9KEB*J zf!tbHbwQioX!C7-E_rY^7u8YFXIRuldEhva-gk>g2OGGW(2hO-{ALU+*uwD`Jee&= z=d_QSgk)A%1!>$+4HZy&n5aQGt)mS3o!v)jPC>k;fro`-*ANxoC8(s^9yYE=~; zBfvZpae*kWk>{hvi`%zo+nv}Qtp1bMPVLtm`JKKwYAdZHPhMP_6#u4d&K6*X#UeyG`Y@d~6&35LKivQp#(;%SmqnSzjsA?kSnBUvRZy1efg|k7 zb+vxuBrjvT*6P~Ze&VzOYV?^i4bCIQw6p z{LVn>qp~dbBa5ZDmr(o;ZB0nH3N^xb!pLK7!zh(>#0c_`<-51CBHwOuMP9XFq3$*gzn!0fNA?IUfXV=-X28O`HiOlfe49?dX1us^gqIG<6 zoP*$1_8M^U@54ykk3Nio74Cjs&;WPNh_KubCNAV&VB(pxtU8xf90z%CEcewOYosIJ zpUoxXwn4R%KH|i859ft^2{9obdpH#UjnQN>e#Nf+MQruLEx~CPVR;K>*YB)F6ZbqH znr8}8X`@4f`B-)1;&py<2)la^EJCrZ+Pz;8r;tbeM}*Idj{ogSq_QJ!8@QaE z2^Q#TZQ&XvZ5i?%y-A_8^5uEdR<7aUq3YvS>YI4l_OI-h+aY2X&*!l3`WtG*NPKp~ z6D#CHt17P5^Kij5e~axi;bO5;O!>mOEZp9n;sPx*XZ1pEOAxqx$~c8$1Lf97;8G@* zGIeg*$RKEpc;|>7ItWzFUVi0AsOz>y8tfJ(GF7P%V=4d02a3St1-s&WxIyKK9r1$2 z#iEb*7B*oQ%7ir-L#d(~b;-zZ8^+RGH~yyKFpQTn?_mZm?jU;@N1saH#hM^u=KZOF z3RDZBrnVbgazrzXjsC&dRAh33XyT=sTx!-J^vHvqe7)FuqvZe!Ex$2b=g9s9>uXz! z+Le-(naX|+fAN*?v{^?$-^jxXyJ_u-%YU6Bs1KYbM7;m*8y6FqH+O8^!56ls&}%*D zpb1%?7yI0j3#i1)64R+zR^YXM<0^SgVosX%2qi*Bw>$oL-6~*&nz4U)K1V!DS zzZoO6K0TLn5;PP4CXw;0+*^==nym*h+VuzL&-59l@5hIt3Q4O6g#+`Nk8%TgUD1DYzP?^7HN;~^!wS?xTN8b>fA)DU3A}evtN#|i;#cPD^d zfgRm0TvAfJV`-JPNcD%1HB5i8sgsrks64?bj~q|&j);A_eDcWphPDv6(8|6fd$>j7 zx?oxudAnrgEC9SK{0TTdcKAp9%P6O`z14>dY7Bo&g*`m-{L`qAk+wJhaGm2RvI199 z4*lPiQ!1MFT|Q}b@@@|3qKs&rUHRO0MX^hdA9Q&42)xh(H~*uSjH)SHzkN=qY6i$1 zNAxc*K%~iQr?Y#YxOtis7L7guT05un$pagN63ezFO?tfMOrXBjK6%B+Lu!>Tx zjau_Ko3J$7LP@8goy7Jge&u#S9rI;i?BsF3cxVy`d`{~ax8ZS&Pn`nt`QOk%joXcO zXq0z~{efI6$(b@V@@F@QhY|yO?uaZGi+#Nu*M zjr1?Ls_p(!!pIv2oHV3e+Vi6A@HNoQYyHB>DlX3RMS2|x$qCg`L%OvaiRCyq!iUy(?VxPAThkU3eDWA`)89lj* z_e(OykwIMC$!%e)UVr~XAY&Vt>_eCwn7f1vs$I$#^}mj#o34UGPb3%6m-EiC6c;bv ze@Knbb-&>F8T2{Jr*LWCW#MeFzBY|?QStWwv6Q72Bz<&gApmpg`=p1o1j>OMnIisZ z`RC>${(E0xJEIxdpjqnXQVU}Q&!-V zsD0o|p#MZ8J>I#h7!pGk*1jteXCqFX!Mp{4!ug4arLoT!h=@yUe7`F)e@tPjs@~J^ z@bK|6SF%NaGNy30Y}HylW5$)+%1dz~%{S7G#1RWF0A^7O-t zTYu?swqQ6?j@5Rjp)LseiTGvD#Tyl(Ag$tU+_1}B)r9r-=5%g^rSg;rPqC;*MK{j> zHo4IGVG?WW&FN;`C}w}zE*-#S`(4opa>Ot9bI7`X-|a8{>ipFtZ#?p_Vn9BCPNzg2 zE09mf$b<(ItL|D_B?%*jem0&Vzr!Sqyp2C3mui=+A4~oI{<2M}7OnkH;w23~tW~!O zsHM?T+=%DmSDdOA3vs!b2?y}}uH>TG!wqRkTjiT$U_6$c9r6NG^0rJa&QKy!!HX?e zElxHOuQ9I`BlFTClppB~f1Ldd;OJgy;^7@2qwV@>B(}l%JI&TKJ#R};)MqB&_UqiP z97~z<^x&@^wTY;}wBQX^FG=OX2Po?NdP=bPWq7JKf+eenv(yzolvHPb#xs8Tlr3Ye zZtiaX7i@|l%W!E`E0;jAoSkeUEBgZDL6~@WTRY{rhS;@q-)vLJ_E9v#D86puw^AVb ziE|pB0R(S9gTgAm594Q$El_Mq^b>a*V(EkQ%~Wi#6hFu?dOaU;GO=NI@UA}?N)J}) z*x`3frKA4@CzxfWKO?$1tgT%2KA1<0=Q#qgIljTTqiZuqk72>?LtlL3dwqt}>+@c{ z9BpV_`V_Rjx7@Ed(d}67%A_iZ30JatnQInv@w^^mfkC|q?lCTKM+?!XzerKc=<{$B zS>#tv*>z09_Q9~PBI`YFgs9&dZ2(WcVo~a8+M%O1W=NM*eEgm79vg6vwlS00t}9>_ zv-<}>l#fL=#B5hPcR=Wv7KGi3E_8MG4{@|<8P7E2`>$cjSr9q^zw&LtOnHQ8rp_%k z(Y{bLeD5puxnug226~_7@Y^O!lT1^vk<%AIn}&*QrG0;lwYpaGSQBQWgPOI!A-6Qx zkbu=llPP4<+?J_N-!yCdAxQ1+40v@(dlddV>6{k<->#fe-hE#f4dCM4;ZKMucP9le zBM-yuJhZahothUXQBzh>up1mO>Gt5#QGGHuW+F~sYaoxKKJDztaG|+3jP!v&1`;lo zMoujLBRzQ1auuJ+>;7@rzC-)i!QtRGY08K;M4*!HwqLN;APF!8wV5-PPr1VdU>jNk z5vF9lt+Q?07Z}ypax;`H#nYT$Vktnk$R*XhdIHjYP5bh}DPsYTwdzWtpt6xk`i+3) z?tVGAkp7+_ujBVi4*{Q$ibK?g?8%(|Gwo<#Q^rYHwcQVJg=b&*_H~yWmL3W7oWe=K zu4z;tGedcI=I{Gg_JwQzAAUzg|Xv!?f*#szDScS<0JN5D+l0JK)A6eJx zY(HNEnLn-hf3vqoFoAAdJ5%AsdoiWKe;(h+x9&FPR3xllIBgM=a@F;-=yGAJB5u#w z!O_w2g4VHEGT^tG7_hh4#>#gaCx+UQ=qV>x8(LagZic*FRm5Z_b}kNzYK*PhN;_1x zgUiVyjan=M;X%o@7Z@nue3fI+PSs&MxN{f3diD5?^wP^Ft#tQ`R84SrPM|pF$CeQ| z&${*?(G+o&0X;07P~y07Hc6dM=05t3@SC4cacNnC)NMz9!8_0&iRZqN-{@UU{$n-kZ;R);gY=P3I?8r+j#9Yc+UxIn;9Z|Ph41BB5H+vw9xJ{sSH0%T z#z~>Fq20q9K`=J?=c4`_hzeG^R`Z=S`bEVQs{ccTiY1$JBm^d{vHi&pmbKPyp&bh5 z3GXE75$G=$$L^>I()fQ!aFM`&kl^biMX*dQ%fb4HHW&UhxgD{f~ZnP~ubP|@T8R301cN!&Q1)KMdmH8(zVg*7!?)X`f&SEIj zkkXE34e)?p@<7J?`|n)1=2zU&ybsN0A&6pcXD4})k+xNvgY!flNgzC= zq@#_rJ0WS6A#nKgLu_LmeN4_hU;Bl=*niy~GWiEkcuRP3N`*ABl9*U*0GU_?CU%1- z$KWvMAPk(*%e^|Ya9&3ayuj+M@VQ4nLu=R?c$if8zJd4r?dT)$q8*AV5uP_kBbwng zLV4!jxpnfDpz{;&pQj)Bh!h*VXw&9I5sdsW1iko0s*=-BRseD`L=8gbdD49^>3=U5_n!Vj|dpb~B%4V?eaIif3|7H z1~Jb_GPmY;vF;p$qRH#Y_fyn1Re1AW?4dTH5l`BIk*}9rp_;4l0Ns?VH0j$o#Y3Mv z^U$F2Wbw=ybmysn0;URe=;yKMj5=Avq2;qUSpOaN8*ReGVMMoBVlZ2Ep*k8 zuR0DvaE`}e?~X9jTeOt5AAgR6Qm^?q__dR{a3^=r)8x{{(v={TKl@|hM9=Vq=vmDq zYt!=8S*S7NTR4`BJ-jk#5g#sP6#8gVH~qI-nK!-|bGVNHVjhSHa14iK-gQFN=-);W zJD}Q!+o3!4N&bYR1tY&KW--}9sur76w}IbMKxR&E93OhdPIz>PnY&_hd6r=1#aBI zXW^7_J)C?Pq4s#_EuKg=z25c@a760?HZizHD2uFL%j;`?tWngI`0Y|f<9R!r6fYTz zPi^7WZ1mQf2j=mwbQj(DUcSu_>^}@5MDi%MwIKvKz5L){t&9U@&pQ4C1 z&ShEja^Kt=waBTd*1->m6y@qNMXTdB4OZ)XXT`|VbEhN!k;h6DS0@$i23ut-q4ek{3$8CW= zg3F?FZ`1(`Mi5?!<(1+{UXG`C9x-N@ibNkb?)ZdTgbGWy&^lgoL(|H0=XdJyS@g7= zx5s4{REup7n>cCnacjiePx_3iH>TYMn7A7_p2!>6{=r73wo^hq9XAy@_>r`#)soiP zTsElEAO1aEa%$*9YoMhb;sS^@{lnel;jVnI+nR!xo;JR+FUEMVhB|oDi}WAVq9_<{ zf=N-qV2+v7f8?pmpFvmh*UH%tW2s^Wc3w#N^njxU&4bDiUV1{7R-3Z*7ok=_6lZRT z(z|!-1;J%!b635&4jQ+KADhFMwty!^!*Yh`m>wISvf3nfJ5-0N(6Xyn5i(NX_!why z{_3B%_+Pg4irc-i5^5>Itp0^1;b=ljvkCNp^-=B26aH?Kp&Mg)=_!!cLSBGULs_j$ zPa1dG0i97j@fJZa_v{S&gW)zisd-aS)Luzg6>W$L=eH#EZPqnM_>Qz0M`EYoDtBOk z(ea!b_1g|n4ezDO06RVRVa1-yz`AqvM)$~5am8Y8a@bC_K^Dy!2HKCajqP8=ZKTii zw|QXfv;0yKnHf)Pp)zXm_PH$8DHTUkun?oMMxQDSa(sqntCRTkqN50Z;WrDRM%V4i zxD%p>`c~mwu4dZ`+cs05%G?VgJ{NtwVXT$q*5UJYsHTh{v8Jv+#)68~cEDaOPLL1Y zQV9B5%HvU~$zApERv8+Vfobn{sKs%9j7hS6to|t#uB90C9oVNr#ULGSk@jPYv`T%W zxIJJM)z5XspOl}%?ki1}N~syj+-OUgxGbQ?Cd(QlH-s&l#+5ctgF{PW*sKhURga@t zW+D`P=W`xdyoqApoL`p9IL5|uc6~JGy{KuD#g2E_=eby+1VUw&FdhRuyWjSMNB zXPKGZ5>9%8?p(`T*ke>7>JQhxYD$G?4;c{9`NHORla_N%>GZ+G*oX_l4pIKl_p8PO zG+YM6X8Qw5sO#crOEc2l`q^iqLt1VsX=e^>DRCnt93svcGGsU*e|r{RwY#$ps7Nm6 z|H(I1L&iv%lf-TUP5a!s+l{l}g-Plu8dU9{V(Ffx&mWin9;6~gG8_Y9^zH~7G@H?2 zJAMKJNQ)!Ms^m#`R7*e|;zRQ>V9gQOtaOB1z!O;c9~(UP3#Q8g4xfi(5d`qd@gbUl zaML3Gh}Q8F24TfRD4oygQllwhb<*p(0uimhKdMJBM~D@VaO6sSUx`$Fa*IEr!{9^? zFhw)yx0W}#+yUYt`7GzMUocz9o1BjhaPGYIIT9TlPjMl|SYtbxzO``mpN$~q|D<6L zz24R7sI9?rn<6q032wxL8(lJ-9xhD#(;``xTb{6;KGmB6RxWjQ#>fYY3ztPbG~_Gn z_7PCWMUeT3BZC#(ex;m!VD3r`eEfuCSW+Qn#4MioO5)U5atY-}qKUR~T=Gaq%>Pj` z2p!2217;=fo2?#Cv%HU$Xx;~CVT6Jb-Ld~w+WF#nrQc*(ganp2%Q6e#OM(}C$wB{5 z9v{(aaKam~ARop^Y2R$FD3Z}%iOp)Q^95Ava-r1xhMPI_x>X^+HT4;fRmtCQA{rOB z<*!AcoHuXB4BV^gb%#Hq*?`@Km_91#Jwir=rdQ;tsP5z|JhJ3b5kLsSiC<*buO;Cc zjz)~JLp*EBp0iQqMORN?)YTv|J>GOIYryYt2H1k$6wsRzBrXV^9Yas_`ONB4-ieZ7Ob zFrr87Y3U~{wl`b5aJkggbuOCjw*&Vn|T(q&VKbWRcRoa3|1sy#@ccoV6U;CsaA9OzpE8kFl| zZoG-<)>DV4^GSX(IB^G(cL-`;VznigZYo^1y~%9rQVmwqocVpYQn#2aV4l(^mKZGW z<{1qok|4M_yA^C9uYqnjti91SiC?ukQJ@}*P8=~!Js=M_xf}y$*c;m1FIqi;@2tx| zWb^q(4|_;eo=Tqu*+4Y>bP2{vAD~#`iGyFQ?tmq{CB>}twUiK)97F8wMb{w^GaDxA zKRT@bX1O~8+x&zG_8>wBMFj5Zwuicbx#KDI^N@%q@e{eRy=Nx1JXNb@wBtf=nBpN)rKcgZ6@71AWf+$ zn+cM*N4p%$?+~fF;CQ-bJZ>Y)k1bEGs!*NBy)*vnSLAdm`nFi>V7iN503xpo+YAU{D4p+(7XMh}bIemPYm6j_4V$5a9z&wQMSAKwzQn zk<(mp(g8gcukVqR!sr2ZZ~{_OdOMvXo`6rPJBdGb#G<0)cciCtBuF(fAVg7f@>hF5 zqt?rkN1mY49=H#ixH7ehJE^k$$_EbLzpSMe%}d;NX`o1IX{tFZaTykr_NMm?ty4tJ zHjA|o4iPkZD>Z5qCto>9YF@DQ8IgL0)jz5yT4n^(O+mJYM1%6wm@~q|9lm#GW4gO1 zw7|r-!@?b+5|bPgX{d%jg#{ zRzRo|DBEierYn7c^za70D?apuo<;P-@_sN~3}c!+9s?(i`bZ}#1xrH4(d~OH6Kv!5V5ON43DET6Ch)2(Z?%gh+UEcKb7dDl<(fT zBi+rT#Si%Cn&nfZDE<8tsg;X+S7UHZpO?T z7so4{_erT8YD4rX53d_`c}|cD0IQ`?qO?K|S5c(JKs#UJiGi|G1si)H-DL1!Pa47T zi+BPihI>VIK5hMa%~GW_Q6L`}qys4ZKdZYz9M_l8H}kdjQ!MNT3+D~COJ$Fjp}Qfk z$U!njK&a#3vKxzlq76>qB>cU*QNHL#E&&*MSgZO5nj6hP2GuvB>qz{r21TlesH)bH zMl_m9+^+V;5o+o?`3sM%1y!$32E{@M#okRHO@-kF`7-OiZcVsbi2@xEoiyUq96rtE zW;E>Z8tnc|P&t-c`igtQ-{h@p&G@6q^}80-rbPbGzZd{ z8GL0ah8Be)UsLXo0_(~1)!xi-M#{QC={GM(g3DG1mR*-8d2MojNET~sCR7s^5M2$8 z$306;;9NF2dUu`Z1!X;mDY6!i=9cTzMP_QJX{{6_u_9cL{Xblvb+z1mB4k6R!8Tol zRHiDC=z*hS&lLeVd)`$p|0uL;aR_ulPj(1>4n%u9%6hW6H<7S31u3On*y}8G^}^oa zRxn{B91S_|c#PB{Cc;08j1+kJHdUaVEIha(-0GcfxAQdF_H`xrY;@<(_fuXr8Jze^ zbOHelGO=8lU8Z4Ow@`{3!Fx>D!pfWfXQ_V-PFKyf9h7_Y07UZ{5Er<2fk(g7&t^4? zSvW1BT4dJ!Nz2#bDQT{pXYb}$H9)QIJs<%L05c2j?a1t4Bx<)m%&Xm2T}1p61!|CVs6m+JKgyi0pmyxR zH})4vBMDUMBErp6kIJ~VHV9Gh3;soZ{#xMQ37q1B~yTfJ>?vhfnqmoSjQRvkO_2@*Osb9jE0=t|E zd6q(9MZT8xcdi5xt6I(LSYIf-y=6J-T1+CaQ;ERx*09^WSnb^>qK3WR7T>ULL-$fW z5{1p9)b;164}L}M!*AXpbhvPPrT(kb!ydQw_=}F0ri)>&IFtJ0Mlx5PN;9j(@-?2{ zkA4ZUG|KaaYBjHfvsVc>6M=E;C`#cJH>=LRt_tkm;SV{y6l?l=-1aOnLPTtSt3;IB z$P{IGe{wXW&y3FT^aPIPuXbJfNKNeMqd~%#8xh=v<_<}VpG=KbllpZXA7AFzwTo+ezlx{eNJM%w;`;CJUh+muU+i! zYC8KV$B;h^E4?0TE}E>l{j<4pF%0{^a?gJI^h@vHIsWSJXP5iN2>n2u?8ni6Xd$6I zMy|nTooCjs(8Ze4SF#}uust7l5^3Ox#5CA=0xC&IsMX9}?}3ogcrk1v5_7g93MnLX zK@R(p;~_#0q>1_u(L3R7k%-ASu1XQfCa}*seV5{ zKs0zNml)pNO`p}Hw=bT^*N(^}{Ldp$0ov3~j{nMrx_e(=)8f1hf<_fK6XeOlsex=H z@L--_pFfu#2JR`k$S6asAsHP$gxO5yX3;ODz{aNvjZHXY-AO%A`pXKFnV?cScEV&` zypSHp;UKeuCGxtOetyo)SM@pBbP0tY!4|kBxy{Sp zp727TvYFIiaEN!q8Zp(}maLG56+p3PNSuUtBPd87epnUk!V3qn?q5Z9(Kk1{we~;k zR2SD=C@r1vhwBHJ{w3!M8=eaz8BeN~ydxG*d`dAv77R@Jzq%hyUTC`?@^tOwPCvkB{ z3^~IDMml%K7j0#|`o19O+FhY`tnKn#YWPtdq>6szX@3e?#r-_wM1}%a|#alS9j0~hG zzfMP{Qtr?M`k~ocw1D7Qmjy?@j+%+NA0C`}dLe(!mY8*`=*`<+EplIwYjLi-Ypz^3OMRb;tU= z5uh1V?VO;5{Y?p>voJMtHv=wt%eQu4r!Bv^b-aE@$1mn%w)}=8U{Ib<1VHngI#-Y) zL`z@)LSu7L*NotA+fEd`#sM8KFk&N3<1bhcBM+nbocVJHMac)95CO%YYxd@q z+vE{lTMd#e$rl-2N;n*2@#~T=?hz(u3MWVH^->L{JKl%fG(&E(9wf<21hp7$9qAT{ zjM_!{M^wbkX1^nh3$+xnl@E>D8w5&-Xk1#v7ocxY#ucH@kVpw{d*|8b^Jry4wEe3^ z;`z+`Al*-jC#}}IITu349)11o;Iw~xm8;HVBQ5aH76Nzdj11$6>}gMu5*T^jL@kP% ztN;PJ-zj9%?k2AJbcL-_sO;XK5r2Z&%~cOMy$3MVc*@h#d>=WjDX$r+z1#SQhoB#F zl1nV;WVG%|O3r>?UBWh%tQ51_N^6%1x;AeH{Vs=o?z{59IbtHMVbVKIB~__G(Im?z zh|xUN3k-4}(Vobyv;h5!C!*G#&J8po`gFj1|GnJW;=o zLvYl$%V?ZYMDnxnj*w8@k$E1w6DK=q4$iro{v@UG>({<3aZ8rdQ#>zhDlreG#yYv-3UyT~!HFv$Ip<9(;)BldwNe$m+UtC%@5dh52A?d^eZC^fBiL z6&3oc`#t~i0h8Up5H@jZnPcMa_tgmpqD>W}AN~+0tC1qZ`H8HJAu_q2(OxcK<0?kF zYA_=mn)&-0Xyzpa=dab)iExTe-CxuDtKPXEy=4CUxHxMqo0fIu*fw5H(UND?naZ9X z_y{d1FYmqPW%3`w4PKaWW(J7UPKa|&?z=1pb(n4E71hE`&rZotR?!>NH>Ri*>gu=) z;Or_`r!6kaF5yJ1*Ni7!uNQN?rF8l3$}X{}Evz5VYwl~ONP8|FBd5Km;&4of!)K0| zIwR;WHtls)YgRgsQ!9b3Dmu{M6(+*kwKfFWFyRHMbQ^h$T(F`Rl*acBy!9XaYMW&k za&i$X)?JoQ>g}tQ3F$-}x>XOQ?>5!uup>q~n&+bT6y^DE5&v|X&+|C$8@w#!`FHTl zgmf6qyQk+$-5U7-6v7(Z0Dk5|cz9L87eaqc#_B#LV#qwPZ#Lns;^{LF#&~Qqx-M7L z%c-E~b1s^%j;P(1c47VpL#HxBflDz9MY=YVbs6a=ESXOW(B)p52?-C9niREerc}LK zrjR^FlQFi>5U67X`<*#(ql`p3!bZ67NH;3BUh{LohUrD$r3DNlk86^7BWP7#e^{_l z-JJ0RCt%*IJ?N3aF&mmzVqDzw?TU$S=T|G_zdbjXwN!-|({XCZHYDT_g1wk)pSIhS zbNh^un$w=3xASFVtepRuRTYJIJm&IAj9aht++H4aa8e z*O6)138}GgmDK3lHO?rId$l%{mpQFEec@K%wW>$)0fk9x#^k@dE#Qf>bSs4x!t&2t zps<3|8f46Xs*LG-5Pb9bVEomUtQBljGV}s$Sg^iZ`N6a)!5l=RN5Pl|%$3*#VxRxx zXI#=0Jw5~0v716#VgmX49Z4uDN+d*J{Y0 z<7|K7har43a~Iy?7_6(*JWIL{G)obZB;idsf+qLz0pq!xdIe|QolaIM=(!g;zr-v> zZnFL!vDIV38(@7cS9B4h=SYkm?x=cWrD)3{^xS9}>#PgSRfF*{yUqsl zDHUTrBACx=ZMK%osy+CFHGP!q^zGS_Dwb~LTVC}spRF{E;4=-w`J&HRp>Sm;LxbzO zEEDFRxu~erIkWi&hf<)=(bHeGIN$SNgHV@l)J>-FH!P zqhFyh_kp?jajHc<_x4Qh|2;qDr<|6C;NDMVQaJ3@Ql#q!CYCarEWb-~MXHIDMqj0G z$+mej@WI_ty>d4$g@Ey2bMCkTAz!nKZ&&mR*1n6>ftz<2QI_kU34f zpvC@SVwIJzcC5?y-ftbYPGLb7@$Nk}ICuC?ne80e=ihI|T$58U{Z%G0uLWx<7i&U# z!0wq4%VISYUA;Ly3bwYuaV{V1k3JyF@RM*U>seiY#};w#l2z3fF=ExbBV7iwP1C(` z+IsCYk+kYaoX5Ad{%g^_utfi$O);Ommk&M*Hw^XeKRlS{fD;9QJ4~_}<*%}w zEGc(kll|iwa-;raWnrzitez-6pddzHUiWQ+!tf_)G$kEDLKlNsIR~m?C#BkkJHfw38+Bypqd#BNG~t1jIk@wjG@Tqu`?X zDOfkw1J}kEE7K9vSE_)yRMCt&H8MW@!HUEASJbb10QuMzs^>HV8i~{X(CC_xH+8>k z8)rwBZd$7d>n-FUX_TkvW(oAjn+33Xw(v3EUexS#n#u7MhWI z;(2!hCpe)oW^}y_w(LA#veJPp>JXW;+=Cwrqn3%3g@ioi<{W;J%(efDVg7A9!C4NW z__(l!t2Ce7Cd!u?r2z9IR`H&d?&EM14GgQQKIkeJ524k=%ZsvK!w2RR~TA?G-q z6`I`bg(6HL(mf>Y>MOa$+>@Jdzx(AY#KUpjvfoL7@vGb~U&YIEc)4NA?EkpIH2QN; zW(PK8ntI4%;2|;`8VykZTMT>!`IQ85aJRS-m+*%Cn}dPl=&vz%sO4$U%ze_0+^JX* zrY$~QK?!%yy+kgN(`tZQx_QNDi^ZHiEbDuG`#};|V!k7T0P{2irLXHMPN8B%&$w2~ zS3`p`Jq(S0ijV?L>yCnsn~cfDNTZKK1v*Nmex1+9D_<^MBmuFDFLhk2rqfuJM_9WGm6(Di?5C^zVIe2p( z%$FVcIk=GPbV-LL_%JD4$N?NWHIb=e9L0T#s>38uOcwmF`yX9j0#4Q1hP_azL~}@_ zdDth4D8xA$HPEyT6?UVFP?^Wwp`4OtR6^xM8Fw5*#x18(M3RtcOHqU^(>DM2ySDni z@4x?rXtn0P4#{V-bW6ylK9SAfvvjA2>_K4ZV|4cRv z(}T6w*i8Cg0oKU=UtlA5E|zKwS&T2l^8b%Y#6C{9^~a;CpJ4PCF?#1Uw9zOa`|%4e zcK6Mr24r&!d%x^cUv4uIH4PfdZ)lkG45v1{EnX4+_P^psinRjY5lvp!gqK}*|DcTn0Gb zB^<)DB07CM5|}Oj_3xDGA}nGVB%u8!paaY_0JQ(XF1ZTO!@W>q-OuO9OoyfXuMv{Q z2WY9LiRyj^4x+S@J2-uVF3q53GZAflvl{Yotl640->l0!r^q;}@>oi=>(Ic*xSm5J zF{9`o=Q9?YQ2&%p11B(&V+|+jRMEW^Y}dyvC-xYZb-l4GKoHAhb~L6knNJ6@i?>gA zoe|wH)B9{ASLr}Z&A5Q0oi~PW8^=sXG;>8U5GS0~jcDI_gM;(Fow+jly&;FD=nGWj zOlNK^=K7Hk;%A;ZxDolfSzuGb@Pp8!uT;XcwZO| z;vAwH!-U!L0nb$Bg^y-^gWEDX!V1L`+lAl+5{ADQ`|q~H`3Q(U%o8NuApG@Dd{nET zmUHSt<=CUfFgiIpoD~KQ&R%VarQe2DUbId9_{P{h=7*~x?qS!xPW-oND}Jcry;idZ zlqr~3yiU@xfa)}v;hI$QV;4b6IkB~htE@=qZS8YZ<6}!fiQSb4mX;UV90#mgjkVS2 z6XrF{nB=rC%D)j0di8F+U>=>XK3ME;XhMXat&LP_zwsB>u&CDP=#K{SN*|chf6Seb z`$$grW6QDZG0J6jSDfGl2Rpj%vBbWE1SmjfGTNgI&mR zT+JZB=d@99jQiUoh)vKDDB@0GoSd-y1JcF#l4pN@-Z10Gr_|v4nBlKZZl|J3vzi^; zT8)z&AB`qx+)dgOVYwoq+ptsae~3oAo{Ly{Pt{1<7JWk*e?95M4<9M&D-(og zAMj|<7qh@4uNpG**O|?D#+4`M^8f^?{PI;N(@l0BtUx9SlG;~t;`c)h7Y95z<*d4T zqOy*$SYtx$>m)LGvg%yFGRN7$M@|JEH2{R%z}UKuX%erQcjtBDd?ZT+O@{6@USGS# z^}5oBaOPE4ry?06a@akv0HLq|N)7W7j_U6@axMAJZ_0p3-{Wyi*Wub6%fL5`;%J#6 zXw*@6>zIL}8*JnWP09;!%nBKSPO<)-7a7Ncx(X7%`c2yJer{sm-={Xe6C_|m_Y=wM zax`VT(}yZQ82A1<$mTr3W^PNxszq4w4k?@?X?vqeLDC89m}O(AS#j|(JI$D-Cf+56 zNuPr|PVM}xy}`_*yP!8N8Zb@O$VIQT=YC!P&{@TxeR|D_m#sY?T(M1k#!z`((yKa^_IKYhiKWcsV}oHEW3ff_$5P}a>#Q#ft% zj^;6x#|~#z!6qDW7Bi;MGNDRlpm${c7|Qy~o5^Q(e$Gl{;K23##LuU?n-9HBWayjC zu&%m?$MzUE1@Cl7EwxVF8;9ww?wvP=;ypB(*W?7RY8E6ZTDSqv@)<624mB($XCYE) zYQYhmyv{IU=GPiUe6?H2X(J2#f;go>zbhzpypGn1Y0+}+oN|U4m|+&y>c`KRP&kk< zTw%&IKZk6-=kl`mp@u3BDOwR0se0C_sUUTTu($6-ZWOd&!}ayvo{q2j5~8WjAbLG^ zN-9hBYj@nsSzGRa3`Rj9CsTc&IHacrH5QbDi`^lq6N1Zn?1# z8_gS4F`3h`-@p8sYgYH8J(ZnG_6jv9p-Ra~%N*xJu}(r>W^T~9^eMzgeQW;_v%f^E zFThTU`-GO<^G>kAnJ}~$_Q!EPBV?1-TZ>G>DrKR=o4!elzf|@e*&*84UMw^6GCyq` z+epo=qXVXbGFbn|p6(2ZY>-$x81zM6__{IO4zH!qZ&PXAXTBf_cbs~oFz zeTk!d)T!QebxktMe^ZK13wxa?iL%1*_oL}`HtOpVH2;zqYQ2v`6*;l56BRagI4(1M z03^!evY6=!*K6m1d$U$%jrSQg1Wd1aGBwrX(kQ*25o0G6RyiNv`cez_DoRS#8`wu{ z4^q!rk>yo}3(F(or+93J54dPGv^Z?7+gf(dc@K&u_t~}Id|I8t-)8TCY5qQ$H9f1} zB_^k=%{MeOE6F0Y{m^e%J6xjHfy0&ySi|B&p)Pj4e^`!V zzfq%0hn@1=Mb^n54_LW7SG1qB)0Cg6cOJ*1bhv%JJAY>mLG6=EZPfF6dfE=Vb9S$i z-yYn8rD7NVS~NePLH&MGHd7+3co7Z~KHf6QgCjTDeJu`d^)W+!`jpK*lq9TX*0nFm zZF2J*>Z>gpimkNie=>$DG&c*%TbynC+~6O}pmTG$FJ!x%Jz`1Z&(o)Zi#MabmkqDezX&a0thE=X7QXZ!*OU#ZjEpkfFaas{?B1`OP zPhqAjp2&atPJMX!MXM{<)n|zDeuIjAuc_*NLK08@PUtAw39c?Hwuy}#!(Bv@z6ct< z*C=hVF5aT5Vj%D5Ul1;>vZ)DHNNoefmce=6r`OK9QdE>9W706e$BNyXpZM>~y?7jV z)8m$pi}r%_tY|xR-yz~)dO-e6hi%y{M&np zK|^s^L?CJB70YCnttX*5@1!&fe_4J7av8Of^H?gDZFGwGu$`a9rc)XwF=bj`9o&=) zrmVP?QEr#t?%;Nyy>{3vBg^s8c*-Ucp{{00tm_%InwaM1Yjde^m4fT`P8~zF_j0Dz zRXq(~74l~7Xz<5g<1$wyBz9+c@_1T(d)G|xMsyJ{*i=9xU|!3b%PaEp@YIi z&HC~TtXPbQs`s0XYBqN5hE6H%C(pg*vk`Sr74|4%TS$3(5WWdt&f@Q#UEZ&_3$3?M z9~M@P z#aRs-iF#zahoE$hK?Bx-zSzZGmQy(2;NFcK=ZX|sGEL@ElbpnRrFJ}BWftUHUsf5> zS$`!rcDz$7S})* z3;(e&NPo7#kv*}zwMQv=?@8q}WHwje6%^A}kDWO|NXE4Ar&)ZV7?0Rd5E01}cdNd}rwOjDC?1W#9 z`+!xAp&ekfu0Q}+z|csAPgAorD>^>YNsMtVYuj2KH(5Ppy1KvqnhPUpQv$9FDsaf z!n2H9%^wcl?J-9qO1{$jj#|gZXX(uXT@FzNJNV8ND*V~*b*xj@)t$9 z2ir-pflZK&;tqY$TRDkcPi-lSkBZ+-<2mZJ3VKG7Ca0LpjeV5;CX36V-ck`A&+iw{ zqhiCN=I2+~{b&h;~R&!WSU`;9pZYNW^qOBT)6Ojg{hB2W&j!{kHBDGx*`I zGUi{O%tpR{8dwWyPBSNEw#!8Up&}ptZHXy%z4o+!ltvKJin`t*Ii3kjeG_dN5R^}+ za4J!2Zr0=RjF|F0-CJVuK))^VbmRdKB}{T8%=@;$v%EUYye=$)LU0U(|p45tUa^^0Y&ZDrC-n zV;gycSc&WE@?S+b*|rak+qv|eLDi*fw6I;l!1C0o#fp;8MG8#PYyQWgqOi$JmLU(Z zKh;-S-^YJ>_3tN2sW?*?3lJvbEx*6`h5HF4Fq!!nWt0!t9q)yMWC!qdNU?<5jyN)$ z5%2)pKdb<394ksCAoR;Sn&)>i_HrWQ8AR6w8nwQ|#A7tR9eWWPpBZ*hVC zB=*GJWlY%sKbgq#hV`S))6^Yfb?$6)<(*j@Of@Djwoax50j;ey7skil!7~rLIof-5 ztn&taL{?9X>-W9{6165n;H6UVy_(Xf*{}JW|i>R>O(R1z1vYF45hAW@%=+3r8KI<1}teqyBuk|W%{{ADcB8+2ZJEb`LfpqPJ;`Rpxc5>j~ z9hHYqyPB2-mTObGujn%MhRoCfuJvf`Gd4bou!-0**6$tTMEPdvKDnq#KbfLx2|uox zAi~!M!@w}oL}Cjk9_5aT+S*VX8K>xt5l<5&sTS0dl3sOxeS5@;%6^X$Nw%B4tUHV3 zX^d2ueg2vQaCyJs4MQ1H{Fksbmb~>?#RrVc0~6VG-+EJ0nXFKz<8*L5vcijV>cY1r zd`e{aMN^#DU_5P$H0gN$#YnuED%@akxc*0TtR+Np6r72@2{h#;@TKg3uMu+~Igy)z z6O4xMs~xURgAGIcVL4-Xwx zK4$ColQ0`*GY+`)b&BN?Wi~Z*y+#7~%x@RQ@H-%KDzwL^k=o2S1V7a$oPAjhH;+sP zo4}{jU#TC~^-*;6g{2hZ#)JG~!rCbjeBv-B{6U$fMaTaVw>| zJy85U&3;E^mKGJ&-X3p5bOf=nH;ECBc}%`Ix3^OS$vv~|^C;`W;i(wVcfqJ}+}gT9 zvOec9+X&E`$79f{1_9>8h*ItETDzP=CMv!DW7Lk3uQzB&N{>Ipq|$zaoxbsn>4BG6 zkl^0l>empLY}c3c-b&&{5Bc_+64~#G=|-9qL(!|{m7uBG=4*l=9@f9U@43tn!ht=A zm8MfO9>%x0z+l|99DM8l$;@&x6?C?KbO&+9bRR2$C=7ToG@>yfUu`nwk`UUOHETLA z6$lzh2!1K=;I`>h;}W=Z6Pe12nC2~$sGy6#o)eH0wbOwi=7Ht|-c}G2J59=%k6J!X zH==4Emz|jX4?+Xp+Ux4lGlTVJ!@NpC7y4m7Ea&3eNrF&>^#=PxkmL)Gm48_=bz9~;B z4+hwj7?@aJp(8x&rw8-Gt$EaTh1>pNn}nrA@?H)9hfCgl;GJ7pp!jYG)USMYpb9ycKMW1&}q&_f_mBbmlCnjZOM2(t~!ZO&{RD`g*xP#yx z6@nqE>Md?Vd0Yx@t+_v5Rq2RX+OQNyWIvX=lz3PWP?r#s?{5-#gwRbZB*lw4b0Cj8 zG-!GA`D6Ia%{i%AaiWEm*IyiPaqRi(AvkoaX4@iSOGHdhz9^z>5=&eJ_ZSm|HiDCx zzYBheHE(cjz=K0TSjp=4G(Vny{x*j?a?L$Xoaci&b542d1d$1!`qjA2;3%U=8G}TD^FKdfq@*Hexzil2wM-IYwbHOmD_E_F1z0J%^C0K#}N4{ zGcy_|yjNf|qD@B#pMY6*o}Q1$o31wSH>IScV_H)t*>A1Q9QIhFC39TfolkM6fF1E2 zpCxM%x^^W7Q}^1Ru`S`H#T+s z1w5xCW>ex5KkrN`k;Mex;yx$=U~0E982h9+2h)Y3?Tg zA$9A^E8o0vNQhpR(kBfZ?6qm&sc?g{ zDu2Rw1~*S{s5`dI=IO4nTqXGc75(ck^tn^MISwFe@6CvdYn!)kxj*3fzz!+34E(aYTiRDR+xqFq~Ueqw-TZm ziN=JF=Y8yLUEgwpLwQ?F=OTHXAE2~q)*DsX@0Y}f+#hZ|L4_y46Sq_fR1H9uq<94Y zQ6tN5G`3|893)eP4{0ZsSk2{p74p}@QnKyi(}EwdA3rva7vU;3I}Kccceihy6a-cP zl)EOpX4|@$GHLCqj65&9we+)H!$ZUHM~H&rQ5i&p6y2f~2K5*< zfVYlZSWIy!#6tJttyTUJKARsS6z*!>2+R=T?7n3MyT(wuAA4BkFI_qMu+t_D{+sl| zy~{})p@TO1u@w%WTrC1guc@=0xK#_Y84oC~C_;`GHww0lNvX-k2n(DEPzE_dbtsKBpDCR!g@k$S0#>%-@V5l3 zxT7sQ#!=FU!?GbM6Vn<*5=TT4*z zH&7cb8N9_KGQ4Q`3PL(gx-c!LoC_dEwU>qE0cPL^@XCBCz(E;9n~>V?aa{fDX zP?v6OC48-F(63LPbUcSwbPs5QWvXi+KM}c$lh)dT(ZomNIjk|H2-aBmd^%zzWjAhAg<^FGVV@F9gP8Qjw?Gkf zPS<`%adz2Cc^Mfpmj)3feAiJl=|hdsup9o({(^4I!YX3eX*M@+_dk2%eBzuI(Nya- zV#io=Pn(hc$3({<%`bD$koi|AbgY`8d9*=xRc}r=7P%GE#^TMKVaoQ_9t@vL@eRO8 zofV)W9msz34=_L5R86vCamFn`0!u)pNMgtp(Xt^aN-b+MvK*=cmh+`^JRZ)!hDvdk z?6ANTVrVKivW<9k&VFE5GwMMXo9e7;1!u9pMU1z*D?Q77bk%I`W#;RH*_ae^oYbG{ zX3738HUTx1%IywWAv8W{5-P&lerZv#pM>R**=Usip~M@#3#XAh&!{IQgzCT}a>-R_ z0r$B0f(6Y)Hfo{A%KHVwtEsZ}cb-UJoI{f=PDFzK*X z$ad+@xm6}(D2=Vi=G-)nf3M9@8I1~J+Jr)W8bUq;+zXPn?0<&?M!!q7c{&F!xN?&k z-fVH0#DFj$(4hXT+(+fD#qgG16VFcERqlBA$SYMEp+s59LuWiS>pPkY5CSfGbM%Z6 zNu6DCbd0i8fW-ur|C0IBbRwqeT$9UHS*~h@WmnK0RMMyTW(1pLnvWClNU=y&a?8q< z$+GnZUSsZlZdK=pw`P-&82>`|AYLMxCEee;+`4G-(z=1@iF9{}28A!WDB}NPuCNp= zB>c<@I6nb2!bD{3@L1pHPUTm^m>S5BtQuo-?|?Z1y)ExnZ@K<8Az``JK%W$qxjSe9 zfYT%}#Sl`_mvNz1f~CJG{hZfLt(@&^<$X$cJ&V=V!ft zU%i<_7Y1~00SIFxc-7$-+$2s2zl9?@ER2Ls94ARJSqq*WsvjofZXwI4Dai^Q*f@{! z2qF>na_^N6qP9%9%As1;`<5Q7R;Q+WK>C> zUK2K1y|A*tx@hOT?F~7fH++y$*4Z_dx|NEm7l67f+q!jScoXk8A-$rY{v0|*&{J~u z96)>U9m1xl>itK+Dj&FL+})NSeUl10H(A{y3~Fc~zsX?AP}wRgil_0EUWIli#D6H3 zA;xoVAqoQY;32#0^E)-pW>koX#v}becs1)!VD$Z2t*tFBPYz7usKZ>iUK=_+E*(=~ z8#S{xeds_`Y0Ys27E5yruAK>bm;~zAJI1%*BTtm{`8dch%`twIal-i-*M^H^tWT+_@wk5LVcy zNOPMqQqSbfey*m>4uoUe{BHI9&`A{z$a&7cWn?{I7TEuMwk`9nlKW=^y2=^0yG=9GRm) zs5LG3>0-b$2MMv9eC}NsTwcIo%Hy$6jKR}1HNj(VVA)ajKR&}bNvEnfas4!k_1oaL z7;kOVErWc0_}sbZ#E zg|*P21vyfx7BcrULTeAlWUV@B=conM|J~;)qMJR_`WOXChR8TJgq;J%(I&!9c33-0 zsjY70#wh!<`W#Ah2Zo4=ahd`X6A#Antzc4SD-k@akg1Thle*ulFX^6;PUNMvVeX`)qOm_|XvLQR<=&RaUVpe)yH~{qO-=`i$pLmBU9|cNzGYH2R zG?{*2OEX#*`|^{eR4q~YNZEH%W*WuwM{z#SIHnctEiVfE8J{;u!cI2!R-giG42ND_UEDkIC@?MHjoDSqaMYU~WByFoe!)4f5OHF{1 zRm3c7%CXiWk@0t8AOkb|{Kl!EU^QiF`?l^jQqTAmBBUWl*|+WrAxMxA0Q?pRM9Bx^ zR4Bq)pRZ*mpitw!2aHQ0B9!hfbPb>afo_+tF<3orB+L@Q5p4exX1OH>iOy&hdN@oy z^6MU+MN{KZdDIxhc=Hey0H84o`tCQWr*6lhazTv9&dtzk2-A9OKdD+deJMr8R_|?6 zB6Iq@>?TIlHSLxtOHg5W=eM*J*m+fjG<xUKs!XJipAU7Hh> zs>kEA|MdnEvrU||nc~&g0Gs_HrgYldHF<%7%Sn$;cXMYbBr+;xl)u-#^B$Xbm|{;1 zhKjVDcz;YC*#Ld946_X)z5*tEQ*x6^8@q|Xgb&XEr8P>bte(~7e2}0&>|)2MZ#;&C z%{gpWfr(^&IB?)~<0}}b?xxZK6(}olaq*L=!T{@H+`r-WIQ4jtPeR*8=Mge%D zvPTG5TGAEUF!E<0TZ6F=f3S8H%j;LW-I>n*k4ZGD$6o_kMe&E=LiEHNT%7AodR{$d zth3^20#&IZ9JamUw}eaHwx2%4G5cS{&l#x$zmtGjW6RDN*+tS6rBwV@&IQ|J^HN9? z#;q7qePI2(WBv8@o)M!tOYOn0QXbfFl9zG`h8*NmGFyQZ`2O_Mbxpt<*xt~dUNa0} zfW9>D+*jkj0wSLJc{AL7A_L^{TdC#cVS(hIbvI2T{$Kc;r8(&Gky8GS^+Q?|CZ{77 z{_NrCtG13t*FQ(4YjXX?{rHx&l4F8uqG_{GN`|GbIJ{g->Wibz7phit2P}KT zF_i3ko!X4(HcQ;WHUiTLQCY?)Cd>(w3O#!c%naL7AaLIKROjxS=a$J)jKcpi61Yz= zADvV0Tl=SJ(4*$wK{MXvKkut?SVZ9Dw^E&fgp$tK218_L*Dj3dJQ4xg(XDu#(WV{K z?Kk)>`F;D=u*v>8)sYc&lZOXrSOkOELUAMkNx1LSnLt7!yvgG_VAC4gk!vJ1jRhdR zQF%;zohZdJzk5D-UDLdER=nl#7^!HsB(qG@NZ8LSvr@e^&}h4qe#AxaIhvhj6Vd=H zE0DPgoO(PfWoG@C`*~K!r_C-9sa_Ca#nYg1fujZ`wszx0xX*|mFpQds_7H+c363Gx z0BW@{;s63&h1@=o~Neda{#P4_u6HJJDu12p_yepYa;GxanT!~H6kc5;+4xcf8kjp~c=3<2{m=rc_Y z;x0;Qfx&~}QMY8e&n&EOt`^S3fyQoMcXO^f*wWFVIJry!I~nbkC3`m zRSh)TplH5_o)200e3WuXDlAM9*yjW$g6;>M7ZkWl5y#o08vr0KGc0vqD+sTgV_CK% zoYHULOwpOQ?v`oAiSF9&`ea-LD?2W5{5%+^z}2EhKyp41UC}+%LRDWL%JO*yz`yn} znR$H#D4krLeW(d__ib0NA(lb-EFLH#t<@>*+r+DRH5B}7#HiRv_{4U*$*}40(xD{2MJv87gj~c`39Yk*h z^T4@u$dDe5fHQMfS+P?gLC$pgzMtv1K$J?2M~R}k>Z#t-umEB7qC5Z16n~*fVowV5 zv)ojK&n0DP`&#!(K6a@8hT_YP;5PW zd5eI%>m)bDuMzXW{{8kd4GHe%pIK?^dR&6n)7n0J8`)|A10rcN-@+`YD7_|o^w=Oy z(Zc#K`F)EsGaEhTj^n;ex+K~rOxMjE(pOUU5_jG-)(#K()T7={O9{^tc*<9&fzefO zY1|;_zTSCFfeHshI&A`3vtKMy=LeRTB?qsex}xXn*XW4aF+ zP>o0z(;U&xgUA>9L!;2z$?okaKVQE(^R_JR<+dX=#g-MT%-E$oa#1?8H^NZ&xw&W&k)fmoA4L!>gcsW?gn{{g?7 zWZ<&O9!#U6m)EIkY{bBeCoW={us!zlV#+xZ*`joIe`gu0`N9m;uHc75R26`J;S)ld z$RzHYXT{DS7CaD%oA+y~lSy-ae@Js{P9%Sn>1kKmk4-W1$m;Vnk=f*=MvzIdeh{B! zHi2R}W+|>wg7cg&!9C4Ct6^_KucI*ISn#$*TJe7b$pVorayTe=6PZv`S8(B$!X`># z5YvfW4kXDp)~Z^uda5Y=ksIkOuUty3`#}saS@o6QvL~^g2m;#R~Cqv;?NDQ zxfY4ooS9~alj?=_ntbh%w+^q{HQ`VE5C{J!;5*l);OJtW2o{EdCYHk)dl zhjXF0*4SMIF^V^nXgm3C9T^pgqL)}{*cR?lAa!l;c1qTJzaoC~RYPm;THLz@)eCQa z8QFSZ`lgP;Rf}+E#g3mmMXb+1bO~GF5LI8ktmq|Nmw_nPnv9(IUz4DRnDHz!|Pkzwr#MMHMze_-ck$SEWu)aZ`J+koRJlS zsuiuP6%5yF{`BCfmY35(06Uu#irxb*PKr}Clg*v^DRXEuK!ny*580+B7# z4ZukCB@q}I=YmVNUMif}kdc(Qs)LzrxJ>XM+Oh<>% zVaON`Z7_4t$tGnZifXGq?y|~OI>+LaVu2{voQUp6;^=79nZ-PrtSuoUGyK~f3!^*n zbkVqf%R7PnuU_=Dwc2tX(vpLF;om8S?UOyi-C>gwX}5n!8Cb* zhj8!Elv`FHllet#AH}+zDVZaE_lnP1HEJ0)oHrF8jTV?eN8a1cS}Vp;M47TaBBMwwb=yGb#8h}99s^DW0>YSy^8 z@Pm=;7~0xKy2JdEX<4@P=X&GkMRc*5y~fQmt8ad$f~FGGVIfp2DEC((fEC(m)800* zUmIJ8oLGh}Xy)P}95)>m4Uy{ zD{*lFEr@&wNB`%|tGefArhgo4zH)=q#6;LK2z%Q06RkR%=fROg0tr&crKST;Xq1BuQN3LbqkK?{L zk(w$klihy?NgTi5&4YKEe#YbU9!G0D42VN%l}QLOO0PsI=i-@H&K@~M=$%1U1JJ8) zM6kTAzj%QGREEIKG{M#iiV4dC9irGBN2>NO`Jlv#fS^1SVZVW&XWVEK z!l|!2|EaNceI>zmMt#|@MsH|U_Jw%VdR>f=*imVoKV@Xe>t{ETMR*OF+3yve73}O( z8MzI1kPgMIV%%dNSxJa8i%(m<7vYJ4N8oMco(fh6slywB5P|no+}6h@+NTo1!I2~? z&kAO9U*XH%OCfWH%-zP$4a2)pk*4<=Ve5S$D(s|0NRxyCrdlv%CvM~g38My?xv-}! zX4ia*C6>ANn4+JFNr?GzacTxSkxdtpFfNd6PvG$v5p)pYV9 z&prYo-DwLvP7vq-WuuJdhy>u48{`CHw6eZ9hnMZl+hNO^|!N#NMCS`#{ja9Aa z{K7Ju#50sjL8r+G8krPw8j6j`W((se)0x>Pq!=M&FAPiz+(<1$oU(kdqN1{PBB){= z+Z{qt`SdC#pLAvqlSw)bsh9_ZtRzsdvpA^h+Pz8AZsHxXiOqQQ_{VbhE9moCHtgqalWT{vf1DBw9b4mDClkC*^1xlBj5qPkWYINH z4g^wMi2dOxhB0XF*lFRwVj(j}-HV=MER_-RK|d08+i;rN1t|^uwrf91+=pk2zXE-Z zR29j9u;at2kG9F4iLrMpR%m!a zO2;xIJx8}P2qMg${?68JHfj*NQ6xZrh%-nazxEg88E$)4;9rF82eg7P`bMOvrp9Dm zU0*MzVk1EvNlhJ?gS?6~7SNadCQt&_NoHG22x+20m4)yG4CLpWSeoIFEn3JvA|g~w zQ@6mix7O=FjRL--qfQ(9Q$=Pqgz%z4h^z*dfH7lhww@_#K(^@}}= zl?JdeYFUqwkKsl~H}55Ze9%5~_x4q>38j;sA;<|cz$l$xO4M>!juwMr=%|_7Ph3}) ztsYqQ8Yt)S`B}0|=|qI}BZ846=XQhB3VvDbXDnn3DOuK>Nu1x* z?#)(c7^G5Ld6|&m#n|y$o%agp4)M{R87_4 z+?cDI>Y`+pQdD_mU7dG-*Z00x1u9;i-S|OMLZUWg)CCm=cN(jj+F^c@;-mFsbzrdt zYo16+f$;fx@}XF1ncm_+iuzEA&sek8)#J3=kJS$518o`2IYrSic19@p(pTgB$gUN> zyv*rR1vwv>-J9TKSX$7p`^y%vb()Ws%~n)|3RYLt*O$aZSrzA1-mp^2j>)b`h|l(v zKeT96WGK#dn0@c~nCPpR%t$hs`OVRLA5pGoV6laC4_vnmKD_v*l3qo^Y zind|*R8?_oLg4z?=zjaavIP`-`#^n9Q;vJz6{Rp8FDF-f`^BT$sCvS@JbRCb)2HJ4 zQr=Vg`-&eQVo3!}`CW0v^Iks*zXVM5X~A$$6JJnb_BFky#<|Qplxb3zE;`BymJMC* zzt)B7Qb&5$^FuW*4$>099-`htZ8FL7)Ilb5T}+S(ebqocdV z)x~4V!Ao^}Ohz1cFzdpibT5ces&-hS9|PW&7QO8)(M!Gi^&C9h`ZFdy8=0rHZPT`n zq59l<>jika>>XL{$t-QWHzy&c*?I{i!V40S3(nbU-Q)sag*z#0VDc&geSCao8Y${c zAb(Eo8~IYzuIu$Q+WhNSN>w>n+4mFP<*Q_A|5Pw+8SKcM-aA+uIfM@V0lTtgJbsJ-WGS=E6TeIWxKX#mHofJLAaAsLBz8iB>dK`K{wvox8&I>v~Px-Q5?Rps016 z)Kq8af#7r3=Tn=Pn0uLcZZnB#uHIKFLZvjH6rB1uQ%d<}-AMWAdwy>+)k$8>j_ud= zlvKXEOr(-%7^Mt{%hH*F`M)lrp%N(==JWW;Qz-7+@9ph+o<9U;F8K3P0^*Fz44QAO zhUj+Y{r1K`eAwEY>3?mwX$f{RBV(0&ep44HPX4!EGUneU5xcnDgixK_r=+MT*i2DU zRX??^G^y$S$(9_Cftr^pCf$K>kU8Jy4!UTck@L!Y^7K*6i8Z++nZyqDd25qLa~BV7 zg+3JEM_(X&mo`C0uI>8aip33#h-gqL4)>hgpPh5!PMKy|S*rM3xHt3jMu+O=ddRD* z=t~yduJzs7Pi!2;zI(8|Erv21d>kL0a2Wr?i}iWTr47m=pPxi0Sgh6Z1S?ooi_GxL zmy0YFmBM|qjg;bznX%D!mj9T~lcrP`*yUYGT?KhajE_FY(A&KQ521cHH2;2mJm-{M zOLTu0@zX|N5=87UDf=Pl+9R1i=Y`=Iiq+NA(^G6@r07?A<0ToR>5xP9eqW=8M*X1T zn3xmweWV^haUbnJKgml4tHUpcn67Z0sxMe1$4Ua2z<#`fEl!EIt@MyzrShzIo>fC* zKpK$SAo|M`KE`)^AE(h$r!xytoYw^i&B7y)Oh$Tzxq_mCo2m@8?K0`mKBSkGkrFSMO(OB8f284w$s;WWbMzC?lVqtrbS zc46>@>TsWQJZdaldyh4k2E2Q8q{JgpDgxwoca?ajX{Dqz(L|H6Cije_e3LPC6I z-SvBb>;!AEKm9}dUmWF`7V@x7vBF`T1{|lw1r7d){ zJf|pz;+w;D)wd6wbnI>oj_Kc`K*>g?*1x>gS$=w(v7C+`86&H-zxMvpic9AZS#p~M zQ=~6#Cr0@B^4VBBduw<1fhK4J`9oSOQ-%wov)w$Bql*rGA0x`HNs|hfHu9XUJ27;t z!O`3iy04`oE#0iIL`EaxDjqLTfR(WsKW@&INV6<2hyL}F6x&@(OKbSY*RQK}Tn8Cl zp8cHYgy^#9{^Wi4MCN4p^BeN}tk)bpj*Jf*n4~?Voi6<7-GcP!ATkHxL|DimCm+Io zAuKfIL$efhwe(yCi+y=Bj#2qMnBN%w@vr;ySGYeMqw2N2>TV2f;E%)rxpvEnO4$MSP0q_gJD zAcCfeLq#u@giLy-_lgRcx!c2;^+<%Rv0qGoGqW@na}=)T(5NvI zJ19fk1x1NJB=Vm&4%2dpxZNdAZ*qsv0@BvDcX8=z$;*r2f9*2(IxrmFa>vfNyR0GI zA~e-}-X!YG;je|}HxYs2cJ{>P-BU!`637e-A9WGF4|Y7^c!-0817i%uy-fTk?RXp< z6vA|*5gNoqnt3L{(!GDvJpYB^d@8aTV&#abN{=4DCe3HAix&xjxZ7XG9fm`>dCwMG1A2 z{f8?`d6fNb%(ZMF2{ASTJ=@44@e@MHYDszawG5Da_6gLTEMDiH7g!O$#}4>5(1nfF z;>T2PUGnqxukV@3t992-7hQQQW8)Z#J-NSXvV(V-;~P!P^C|O$BF`c*7HfDK1aamr z&haoM5>#;vRy@3d32Td?=V=M1JQk6F(Gm6=|0UVl6?#xiitF;f6E&><=jA5@#p!0V z6fNFRjHJ_Lt?mrA-Icxz#%Er(-%58jDu}We< z0yDKOpUf28UV@Cwm1meeWnUwejN^Bhse73@wiz1#R*W0_yR=LUA#Ru1T$ZMSAS0%~Ax^(@bOC0zTuW!Qho_6Ztf8WAfBQ(7vN|_bPFrog@K(xa z2l2es*m6*H3!<@?QBnH8FT@;tZ*2{S$Z@xO*h-pf#@vwAzE4FyuehBX{eKze-V!We zwb8Fv-^!Y(3=L&<9=udmf1kuO&b{U9zGk;4q{sI~9uJWas`GkL*+qw}UOa%%-k{Tk z>P=Ov=5p6UI?3imisY!^@X1=jdo@NnbGx_noBw*OF}O zQ7O2i^>;1yR1p8ZU9w&VU$RSe$_BbRguMCT1hV>7B%ZXHtgzRIksj|!HWPR{`@2%6 zmaE`vj-8L;67!UkLr>EC+aRi1tNs+1^#!uhUh^+k5|i1{JkWIj+P0tQmDA()Qol)m zwny1JL+mLoj&&0|WDMPQc%lt%BKu?&QZzR>xh{dA^kqqS?U=0SgfXHO@da3Wgf^N> zWFHIHxnbJweis+SVt9(JFz`#eLRRKK&s2Z21^?~TmSFd1TY8UPym4LVhwkRgR{68k zkG|dXxBm8!_3vmikW)0Rpg28_RR~uRjO+xP-q1}Vq^Zd&BC5u=cg|)WycCEKL#m8O zI5Z|l*SlwfAS2k)BFkmCStV-Gm4u#Noi@?52=BOK}HL>Y=dGnx&vYA;~SvN=^8m>6o4{7DPst?|e;k}>iWiEPb zBq|cytKpGe^YrSU811c9LK&!(rjUt zf)7<)MM8%wN}+2#`#vc>^G5~f2Vby$8cP}`c%+nfd(EkkHJ41vlV=d{^!wcswNZ0V z_Mv;U<{xp_=hJY;CB?ltdo+9EJFARzn~ zMvSlp4ieLHaq2BmzYlY6q{P)kLXa4P_5+dKhOH~+l7 zl*D}$FD1QVO|Yc12@)2nIuO=nEM|67d2-ZJe2}Kh6(fr*j}Do7f6Jkv3<(*<@uHW? zjtCJGi6yt5jvA+Z%>Ud@5D;O`uWI7ww<*EPad@RiM@)`)FXmzrYuj>t$($IHm~ywO zk>yFt(%oPcTi-?-{tPYj^RbDMww?vYc`FOSfk{(ww&aqB1ta}VefQG)KZwf9CZ-~p zcU>~pcakuo<4NC1F<8nColexi`O9yIxzk8E{u3Rb=J*Z`4MiChhlLAA!pmLv2V1#+ zw>dLsmCN~X+N_hgBu*T}nXt1<7B66X9K6`h#KbBAM9Jze>nmUlN=X$Gn8jU=*Ocu& zj{9(m>T()gz;Br&8?&CyGhCi^7Z83L&gEit;gka=+v8)3?jC&T@CTWOrcNu%OzqYD zCvp{!y&uOkUs=gb9zTw?KfNOK5k}5k{xEx{NLwT=U*ri(0cp89n5RZzT>IPWJ6^>E zs|;r&2fn>;{#kQDaC&t1b&(HCM@8WxZIC?E{uIA;kt|~Ahmm`@(W9vLTf-)+`mWC% zU;GN}!ZhA&nKCC8CQc8@_f=8d7-4o&#os4WZAOP5O15s|kq@p?KmK|`4yUm_=IuT5 zEuqNryk%(4;8E}52+cVmxW8R0to{X2Nn9y(j*}*{NagHf%$*L>H}LiqbzB6WzRb+A zSgWHO_ny-D?w5N;d2F>Qc?&#pS?@QW@%EY)6)-l{sMx4Z$FPlNW=y~g@|>WbieY;0 zr! zFX-wccRkH%ny1OHt35wy*i1bCM=HV}&_oDaS8YKSmNU+9m*|c9Na_3-{~GKzMa{Y5 z_cE&3aV@P`MdE$ z%mc0v6BD|qiZ>XF?gjwT_wTg2$M}anIo-VPKrhHN#K1LUv`BVKQ@Zbux3{(tGE7WnsR=Hz^#s)GWlq#pysVzZVBEjUkW$wR}jX~|@rS-bDv8WRci|8e}|Lh^>H zGxqj^!NE#@yPIr-0hlXbLi>ae4A*hjMXROUINIce0Jhk+#hkfy`kZhion!~xlorU8 zN>O;00Na0Ghtc)EDw0A69TdAo{p6W=(dxbQye)xvOvAyMKmJ;A5wFyy*ZJG4hGh#_ zr@h)2cySytdD2g;hk^*dR#lqJ3O7=4U-s4mVy9c>VW(M}!ot?-t0{41xt&IYl$?;M8r0ZnBa=} zFu|wEpxGXGj&|=QLydcRr6^2sw(ca+7fmaD`4V!hCh`&p@hng8#XslN0CT#>9Jc#k z*$w!7k=5Qk&o!5FzYgv*XFinToEetJtl$>J%zN1TfL}vYP%ga{{ht}7XiUR(@cXWD zvH=Kib6!zCoU7D%6ebdAD=3?S92Nr2-cG*n1m>lbm{VwiI|)J7u3kM&^aWj=IKuve zpDH=;YQF^$=#*%Z|8V1Qq$a*c9iJGD@LhMsi!Jy*+4G%ND%m?l#M@6o*j`b2yhbc9 z;5CDp8B?e8jD-^leo-Vu=?85S5ohL=)cedtsE~C_@_ehz6fQ6AEUh{`FS3m6?ey&zZ{35QlBec;uPOKaeW4`fjd`H-#E8#r@?uxy++_W!p=jn79t{l+I zJ<;mRb-!a+3U~h;6KMlbrQr`fd_)0BT_ZMG1#jWr8{8=>8w;_wvB<_txEhG~qp8%> zL|?7~{#88!i(z!>36bsdUrN5+kg#<>pKD*BCUq;vh>XcA+NeZ^VXsYe<#*}qdt$qd zH&cD@-$d^wcN{@q!spMQpDgQO$qp|TkqQf;We+IZ+kdw260tq4>+sjK(wb zI#Hg86a8L&Zr|Yp)5t>LYWtfwmV9LVw-^#zl@pdbEn12J^9i867ImYCW4^yvTEO0k zS_Q7((W&~TIeEPOGU325W$(>tsDq>~vX3@J8R0v%&zPB%nXKe4W)QhV>@;atoj;Yz zI|u_gXC~yOnNC>RYZQUIia7wnY3A{ENLB=R-=H=E0XrYpUP_;CT5%qg2wo#?=S~A) zjD$(}ae?R;CgB^(Np^eKp@J=gMUxpSK1>()0 z>%W~H&v7J`s+%HcK#4{ux{IC(_C7Q6iJyrftkD?;Z{|aaz7AM7`!tr@>{r4cl~+;cmNN_ zUo|S%AU=b=)`ibMH=eV~(twHpa)e9O4pjh1%&$8NbN3+-P?u85g;~qy z<7d*f-*}JK(hqFhFK$CXD;_uF;+&)eh!CB;;d29MvH11d9g#%cvKu61fYI6v?W?HkDL?J0;%UALLL)DcBQklK~*V3*08*L4_G1n9f*l&C*#zkf4>2$=|D`}!aoD8IctJj3_Z zB#{fmdbJc=aBrS}{%X`G^@bMt4R;V7*tw3(d&0+aM1Y2xrWNup5FnQ{qkndlxj1Zn z0A65mlr|9$%#dLZxZT)8KwF_C^H6lpMcM78xQ8%+R*hZd#GMwl&eO_Emp%@jvrJbGP3XVQBoIlF!`v0c%i#C?Gl%lTd(H>J;$@rZU>1phd zSTUGM7YNuxCVuSm7(sG~*bdS&Of)ah15zxS8kU5&W$_Du?{-3?y~%%=Q-Pxi3UEia zXGiW|B3c9r=}2L3Jnfc?fC$+W>jnDUVf4WkYLB7-G5=;H5dn#fA{NWlyg2nF@l=2H=TJd;d6CmA=c!VOs(tIL( z;qiDU2LS9!!O;mmPO75v5vf4RMfR<}LVD%jU3mVW3)T$ywPOsU==Y3@T=w$tOFN&t z?j-i&7qs6Tl1(Wd#MB`aERfwNH3bs5k>-spyX+WpMYeRcjT!kNYy|tv!8#}G1jmF6 zPQ~`dqrFa;Hh}Sof0quZT&6*~GMRu)h!#Z7`-rX;#^RUkuIw68nokc92S6k&3oIZg zQYdzJG@KEX{RVFH~etq-Tx zq|t=dWNDIHKS7weY0lrttOk@?Ym=hBxOn^_^@a7WS00oh! zpkVWhqH7f9m$Fcl#JT0b#Nf-2VnaybkCJUWXJ zp5&fdM@!L>_;<8iP)@|)M4m&Z_7a@6BZ3DIVYsMYzLUv*BI$Kj;K9C)qRBS(Yc^mMaei9i!y&GyjhRJ1 zvPUNN+wfh6;1uvz?w#c9S&jtRM%tI$y9|EfDr9j2*3dD}`8C;0^nC`0jGoepx%@)r zJTsNa8lmJ?^Hu}ILDUHzax}9`UoWGZyJ{0+ zvvj1v9teHF+K&bwQ^(cOquoA;fo;BA9hH z0^gWJLYA(6e;K-s+y3L~=FowzQ~vV^(ElB8P)ahu(uhkk6IgMUcg5X*WL)eSWF3UP zxB%c`D`0Q#5ni13W1rw_)Co3t_}#*nj;Z_7Vn?gu$GesO+9InV@qdl(&N+`z1a!bE zGWJ~RNr4*q�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

    6vs!JD|A3prFY#&hj8}}^K_83jompiH7 zwzY)-9aes{`>nY+Gb|2M##1ubgMwTOnx2w@&H#z*I6Lan@jGwJD<#Lr--DGVQSMb} zvd627{V}e}Z>m^j@5?JvBE5^PNJoKzP~1DuGAG(;n(-Lv2}92~+;%ZDt=AP|kkpuh z;neVKVnKv(G4M^cNrY1CFQuvJ!s%pJQoeXU==mf|x8I z+(!4&`9Mf;0GaC&H{8d!b=m4qpM5U2$!%yODX@6=<}cJ+Y6ry{sRrBYb0-?H@K2-P z9xMJrk)+h%rq~g~dp9$b3!ch4{VHuxbHIb@3B9_47iV-|R>rCy_Kj2eh%38E)*~)A zh|foujgeM*ZFe8#qT;13>h+OJZ?e|x8Grehi=qW2@_1K6 zNvQ_vV^Mb_F$OkB7caiwr8~ zq5-z-7p(*;Pk72d3hVxI3VdC=_gX`PQ>5QM5s3`_0a_F(du8)&jV=7}Qv_`iV4ZDL zmzL2(Xzd3}V<|4G4+yOug8b9u12|1)Z=z1(^`+Q-6am{K?|`9dskh_NaIccvk&SG1 zix{Z9Mvw^pbW+u>QY)75)8UYyHHB>S%t+Rhj=bguqRQiBbIyX#A3J1y9g_FlJxWs4 z@iwEnX*gZ=Elk&lC6r}W9n>;6$|Xea(CHN$-|pPGJzUE-V27+-?(vQB9oxvNCp+$Y z-#uc-6o%H+UrXCfI%*MmYrNKI_R_+=D*@#!C(zja-3zk<{=<>Mn(Og{9+tDr>rr9a z+M)@&16_>X6vwr|Ls^JPdvxjZKwUBEKb+?OSKpm9HtNX214F*>zlNpRdDCJO_Xqy~ D;HKE@ literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-msg.png b/system/images/media/thumb-msg.png new file mode 100644 index 0000000000000000000000000000000000000000..624ac17c9f5e9483d42622e251ff1229b2187656 GIT binary patch literal 3319 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-msi.png b/system/images/media/thumb-msi.png new file mode 100644 index 0000000000000000000000000000000000000000..96473f5e0dce769e972573f82147b20088b53f4b GIT binary patch literal 2594 zcmcH*2UAlCle-IDBvkM7P(nEyDxwrY$~-SXI0dB1Cn}tPf(UX5ND1XGC`Ks;6b11F zIcc5{;8X}Egen0Plp+uaNDaLP-g|i^ANT!-+nJr6-JRXpovnAzIN2*H>{O7Fl2US@ zkX@yuzG3`>a^G!9sxQ6Aw+JBsa0H~JD(@?zm!!A&Z^B*ePfC?{YRyVXeQR*$w7ac+ z4fyv6vJBxb`AFj=n$!W`>x3l|Nn9H|yMf~p$<0=H6~@Jw1j8jivp`A>IJJ)dmjewg zV=yW?$AQ*hyiJJuaG{YE>}j9)?I^Nkc8UeAKzIosiRHlyo45lDba@Bg3h&I@gNe|2%(rHt6ThHK>T_X;f$lfO;Gy``ehMI>w+iO@%T1) zT7*CE7jGc=z#@iVlG-t3LyVUVBcrQW_Y=e!Yzf3Y*q~oMbn64` z-vC{1fFfJruoh@|1-sr1jjdt53)ts5%##C(#5lDL&iEvL(kt$s$0Fau54*(${bIoi zcDfeys)H`nK`m2g`7pv0q9M)D*H!G@82aps__uaAu^mqS2v-V_)Gj#UJybt|-e`eb z-+>)7XoUcwb-+0M~*Q10OuHSn8yEEgyu0g=z4#l!X zbHAvA^Rk3WS^1k!_MQgiUDeHXFPu}yOt@xtMTR809?$Uosx%f)BU?i|xY`oi0KHyC|9pp8h{0jO&Q$uq+VBM#_y3GRJC%8%piqok{1;v z+3U}(qstPiPHjJWWHTm$I`a*U?ZDvhyAo%Teh>-V%_+&3cIXIW&Zz*ZvN2Ee7VjO% zDvmey_o7Xf9bJ06G3v$Axlg)FD-n+6Q3D@qcHX-nqa*85HeM%cbfLBq^#LNbFgCd| zAf3@as`{d&ar1V_S+MF7VRhTllPAuSZ!QRuflYF_h&X&2mMz|laq|Fp}y3Knl z3-U2NW~HprLSPG*(iK{^mwt@r6yh*7|C~QBA)C{-1+KFA`X14&q&gWJ=(E6K4L}4NGb_^bJ@*DskbiTrCS2P zw;t+{q5e2%OEjn7B=0_$X!2`ZT#hqi)2#f9MHB<}AgClwZYUb&VnJVvR;tAM0RQx& z`;!~$r8RcM{qzIt z={u?Bpq#FB4w0PxZPe|=XX}puzPh97(>!NAx;mMAyxOZ&P!!4^)up@oC6=Vgz!@Z8 zB33)}a%|nPzkuyQEHv%E8|9*+zw-r|`oO26HTujULQJoN8|gYVa#F`MZ7aJ#ss|98 zvbr-^hmwN_oo)Mpt6}b?1rUuWIngzr3bcT7>PY8bzTljx8hM-R5f_CUeHxP))(OnZ z^KPQ&kGBF>3C((GB~+;L&Z`)z+AtS?`j*B6%4?T>cFyP!wL`JT;O z)oh$7QQww$jp0`=^tuboO~@KyGPBCb-#>19O%BII{^n;c7wSZ zTO&fw%ds?ZAin8A!u|VHZHY`0Uz*e4n6-~?ad9R~uaAi2Y7kUt!#yK6>rQ=7E_KjY z+MRU2Rj5LiD@eX2`H|_YMK&oMG@Mbo(rILMGMD7aSb1>3taK9Kr>nHs#HbSl_^YGN zv3HWx{gYdkxLUitSmw1iN=>8wtVt_!P%5n4m$vQ|Ku=tl&)&>Erl^jGn)n^|IlgAw u!N@HBLO~$(eY@2t>oshJ2=X}zY9{Rzfk5j# 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 P{|z!Wu+T3#f8)u&1|*n; literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-odb.png b/system/images/media/thumb-odb.png new file mode 100644 index 0000000000000000000000000000000000000000..31fa431ba537d918aa68b083cbbb6f54d42b6b5b GIT binary patch literal 2912 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-odc.png b/system/images/media/thumb-odc.png new file mode 100644 index 0000000000000000000000000000000000000000..2d31147a848639f071fe7a9216cc8a01415def33 GIT binary patch literal 3239 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-odf.png b/system/images/media/thumb-odf.png new file mode 100644 index 0000000000000000000000000000000000000000..5361e05743b80264eafa9a9ac063f324e0097ce5 GIT binary patch literal 2496 zcmbuA{XY{39LLukQO*e$Ct^js96el)3q9Nv=29!%C{L$cQ=W=E$GYiqxn!l1RIbv4 zhjdD6u`!REvDq;8Xl!g_&tp$)JNGx-=k@x&KCjRBm+ueX&+GNRapHKW<;E==0RVty z82Cs80I&}E-cuLW2M;L?=BM7yzo?Yc z_?`!h$!p`I$%Out;lbA&=D>nLy}B|g5sgd5Iim!=YPn)^3^m7n#GKOU^$fY})il#! zG-&m!y+ZyoHvJT?=N*?rm&-ozW(vr|`Q+ij1;Ol+vYj^@hwuG$sIQ7P0U7B2I5+!* zNt=>MsWM3ehczNvM2wUEm>9i2F&f;5#_;EgDI+sWN|Jc-&k1tgD4~l#w_?!0ot=mhc_NLvmCL~j=EM3h@G)XFoqA%R zyJ?1Xk4|mmu+V%S6ojKTF#B%*oM^3^dSYMx6J zFBX!AR}F@nla$mEoKUU8EebP<_-xV;cS(7CpzFmnL#EZLR#zk%&B=l8IuJ=#S70$-uir4w-%iFn+i068_@G0&@IOeGBUHO{bxMGM`6xxZO7 zvPAThMXzB@(&W<1qeJCXN=P62tDM(aKZ5e_g$OnLEMn)bp1nawPQU=_aqlI^yVQ0 ziqU!z_g~S1W;Mz&x~Zp}mg*RL6+%i2Lhf_^07V9sLVx)DMSN|owVQf5GhXeJ6BPg6S*%V=mXIH_1i1Ig0f z#a%A;ygQy)O0$^ZfR<#a?#Y^-dpGU7!kLWADy*0 zc=y=UeJ6Nq-Wwn}v9y(If=W4;#Wg#W;5UQeh~XeKu*`z3I~{<_!B-ez+M67~#ZWX* zoQQ`nrV4>rA+lo#w*PKXTFee`+qQi*)1KhEScIb^duN>@H*0{>u#E8DxW3M|o@9ni z>K|8l#v<-r!6YCv*O$UJQ1}%bXhX>S70*oL>j}uHhMV%;y|`&q^@a7Q zK(*a{=+}GwQN>5s?t1vcE@dM`o9anao@(TL@^M*$Quz>cd>&(W+8_7m9V5U;bd;z) zKLW(M@DG^?eOyt+p%+Bv;Y|+736ibBZ=C8qP{o8THMVh3c*i-U92NZ<0SqIS`x{P=8 zkCSL%Z7Ji1D`=PIWEQv1tFF~YZQc=q^wGMcr>y-VgZhu;-G;nfBMf|_9S_gU9HWDJ z;M;u&?k3SE=9yCEEL@W1o-XucUbTZp$5N3=X!m^cq9cHf>?sHUO7Mc-iT?w1b1<{(ip6#e@_WvU-FHpS4+mNuL0nVuys724t(^0> z@xxxkH^5@37FiHCFuiudes3}3{2QP$&;%dwtwUnYHppea9XKKzjPx$Dg2v)d4ld#b z>*TCZH!!hX88nV3#jByLF6AmdlpL;(5`n^I!0OU%@0-W-i|A~?vkFit$J7a zLu(%AB^Y^z^rK>l-HsZ2?r!4H>ZP9C#B(ik*%^zIbF)06%@wn*rg@R(6l0v|L2Ix@ zLE%X(Go+Mx86wX)1G-b@)&ck5TeL0Ix#pC{U!?$6B}^L(lP|0F>k;*a@Pi)%I=|Um z`T1-dt)gpd^fv`uO%tm^uW72XRd1xgbhZbMy0eW`fny#!@KP8FA%y-<@&EuD7iDHn We&WyP4G_YA2n!27exxPnSH!=R(@G@( literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-odg.png b/system/images/media/thumb-odg.png new file mode 100644 index 0000000000000000000000000000000000000000..a9c7603cb36ed84be552a5691386a763c38a8d93 GIT binary patch literal 3069 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-odp.png b/system/images/media/thumb-odp.png new file mode 100644 index 0000000000000000000000000000000000000000..d18d2c3320988ffbcb5c44edfed23343cf7ab6a0 GIT binary patch literal 2871 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 OEbdyF!v6gG%l`la#=#^2 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-odt.png b/system/images/media/thumb-odt.png new file mode 100644 index 0000000000000000000000000000000000000000..752ecc02650cb3ffce599586af9cd1d1781ab834 GIT binary patch literal 2414 zcmbuA`8U)HAIBw2sC22cG8MNhSyHKs>y9K^$dVR?YNW{W*lWhE#zi;VH!Vn-dO~E& zG9+7;F(ZR!?2LUdmKla&W|;B0?!WLn?{hxqb6)SC-{-vkzG!o1ll(4u2?>c!)>anw z5)$i>e{aM3wWjoD!|K|T@<3leOGp&ND~N7NukpG7`!nVec`bV;B_t>s-f18tSe= z$4j6-09b{=eCYoSQ$JuWZLzSj1bu1H5(f1yP=5nf#ZZ48>YSlH9vB%gQ3t|#Ah`j_ z73POvr~n!;uqp!aGPK13vl6CTVPOR3`heXI-6_DIhS_fD{Qy%`5OSgA5iCu?FcCz2 z=zIl}jj%KcEHaE$!9+c9I)D}lGc=g_3B$!8UIG3LtnguR96AzUaRQj-AmBhV7RGCU z-3BX*K=uIgJ!lF5(oOjJ7c|_4>1OEr2qPsRV8ci$H2MIG1cQ07!h;?HG~=N8A@pYf z`3|(j!2BRkLqH^io>ZVmL+5MY_QMZ<5X{2Qa2WamA^|kG!}03l@ff zavy$1K*tLh$N~B@U{=874`{_h_Z#@}9~k`#6i*oY4voG*z6*`s(3K24CNMq$Eec4t zpeqp=nZRWL?J10W1x^<<1p+k`y5GWV53uPlS_X4H&=d^q&ta$#S|7t$H89d)xCrJL zz-L2uDs;Yt`C$+Xfzt`RF%ZtdKsJom!uWTXYJz1R%=N-_3(R%_s{sabp+6I-!9f2T zdfox$9xTs8?|bO|FHpRoD+v}^&=drdl(uT8QV9u}Icp2FV`wkC-y>ve*Sg2U`z)?X zDkc13b@a)W%7}sN$sOkn8XniU>33`m$KEGb@_7ST-$(jabtD5m%}~EgsNW<^EL38T z@=q6Kmm8kix^8WU|9iee!C=^t0MSCZtnP*CFSwZsU!Bm;Z3!ZN;Nd>~((Nbst%WojJR3Dbp3ZQbuC%iojiz!FNdiR+We5cuB`JC`}&)wu= z1v<-A=vGiiIFTWT_9T*Jq#GA|Ki4Yg=kHv@=~O&s_sMVd@-giDmPl`YiF|^)dznTO zy)OnuoJ^6jF8w;Q3)eodMnXlogz?GwXunELw5Mw_%8H(qla9l1mJeTz)-PM@w56P3fbm_`xkrGE-J){?%JJ?(7itX=`#y zH4)OOu9?`5i2B`Zk9&1Xm4t3j#w9IEb8}(3=4lz}vbI9Mu+DeQf;X76Ckk@|kwa51 zgbjsGh;hX1X46lFmK>HsWFALHO}*FPfF08g?PI+CZRf$%7FFy6-!BQamp3TIo_kyJ zZVR(SgQz5n+rPtvcoxazfBZthpnZhXX>JZCkg_nRkfVGFZ!vpb-bh=zY|N{M&LEiK zZ2a)fj+$1y=DC1WROxxmMI-c9@sUvH{OxHW4=-W}s8g$3%^0S!4T=g*^N#5#+LIyo zRTO1nv$R4$Che~QxAmAiM3l;cO<3+8W+|z4?9?5ShfdCR_1}^Mro6^b+9PMmKX7BJ zNggw~JD7fR*}@ocKo_O_FtNzPg8l8R=k>DlA6CEt=yT48< z3v1MUT+=^xg(i4A+JU#oE;8Fw?1x+)k;PU~-inirtJ3l3qZLQ}WvIa}K z_PCZmNT__WZ0nXZ&OwckLi^yNWi6QpUuraTa(6PH2x@e7k4%K(S`^vmo+IR5*e7tRn- zHBkdJWvQJ?xPC`x#7v}??O#RLQe~#nNU*o~3UYGzQpw)3x{!$*^4Co$FJ+9#L_dTj zR`OC#-E1Q^t$c+qjtCLiY*C*MaPn@>m??0s>!XdR#XXZLJ5o^Jzacl|T%XV_XsIem z7$VlImA!3pU4!ysqIM<7QqDiBrhe0%{5vt%cf`%aYf0^V5qh?lW`!6B=51DCMP+&9 zPH@gKT2{@4DC18WzR@lyD=N2M{=!4klb^A{x4K6%F-TLmp}mj8;9fZ;ei3v3ri~4p zV>7!9w1pcZ6R`{7xZmb&JeGZPY_V~tay?Q|PvqJhI`1_l{Gv7(L+4TpOOI!mY98Y5 zzQ`Un9E_EcGkwK9N*|W(vPn%{WK}cAXs_^$r&8A6oc`SBplh6~gkd|O={big5swrl zCbn-snjSc#SBm(iiR*OLFmC4RR+0Ac8aJLVS47d#uj8Vn8hME$ikz@2ZBKJ#_+0M+ zw0{n^qT4*~cZ4fBhUcZ~F5M>jAS{xricll@dD*rrzgo3Y^Q@3a%jUbqNMhcdR$W!h zQe;uYqm0*^=Vuo>mn$MnLQl*VyXsRIo0`6z69;dgru5Ml+AGv13F7>}#A~;1vC| nTFIt&C$|58a#_=oC|;;S#hnR_F}QN@UrSk^wz0@Fcfzl41rSF=KBG zV;jaG+gN9;*~T)@^UU)+^Lfwt{)O*8=Q{VfuIrrphx^?3IakyTOA~QXc~K!DA#pR) z%QuCDHYNOv9l{$*xpO1B(YCl>u(%*3R2(k`J8#|KoBVE?Tofv#E6xZBZML~_&Dz+c zg0}>q04zXIL0Bu8H^4Wo;0Xi*DhH0HgW12Kj2;L=1PeSAgaz)u08BN|zkr~qplK2| zBk{N(I=8;T1nYc3#VFsg7Pwaj+^q(Nmk>T8SO!stT7W(S!>C{tLO=9EAuXV5J&@A} z-LB$Awu7a^e7AbwVFNI=iuTSUzEprb#-HFKpMFDa)9{^Y-WnghO5!DVLI2bNmSo;_ zGLOQ6Eyz4`67Smp-;)A-?1i4wK*AuuZvnBd0do8KF?6tN4jEcP{2RgPHME`$$8|zB zRlM#$NarkKUBw$+MjlatYh)gK1+}dKcznUlN?!Fi{Fn;3)B`IJ8rTfps^lefLQfij z+6maIl9$#Ez3qa08i3DzP~SWfMF+hoKtK~XvxW{WBF$59A_E#*Mq)d_tX}9B8-Bxp z>Ly^u90DT(4+`*m9{JM84{rn4AoR~VI?qF$>wuple7{CuWEp9kgeSOY)i{i$00l$* z)@eAX85~$d;yWSQ6wFycGkc)0HqfaSc+mnf{~)2Q;LBFfhYFO9@E`mFI%eUwOz1m{ z-#!D^Ou+Z+fcM=HV-~^D!1Qh?e}M1b1h&n<-VFe235jS2U$udxQGUT7|8)l#(FS(R zB4b>{t_B!fL{gd1LBYv+HPLC&XDri&^}XF6e7NzvmA!&P9lWe0)2&2%yhs zV9PYzz=5fg@H-~NoI{crP~i~2atz**z}qP#BobkE`GWPcfq541g$z}`Y1u*|gPP=M zmMB%HnqzcPOjXyIyhBA(#3CU-dqToiYYjTPjjc9=mhkdCuX|a%UL}u3>kd^&4*3 ztYe?kfileCyW=Y{3sEptfqg~t9hRvbHK1a`yt_i#=|oulTwE0KP>&v;;I!0})24lK zc&zJLv9V`b@zdO+?fxedvdx?|ZOK852b=fZKK{_8ct*3gbzH>b5+~|{6|OSo@sNLs zR`zKE)ElRh&?-t0M@Bm1OICF#+JP3`76HfOxK&ZIu_X8AX>84mq`{PU&qd?h|%3)c*WOYb>wqx~oH4q36@MZ_j({KlNnxdSm`REfxx(VdM}( zV7f-2)h+)U-NYx49;HZsR&(!mIV4qUq?;dnC$8daUf|8Jy|FeuXH!im8Fwu(I8}vE z0MtL_!&D*2(xUg1AA0!4AOourg= zU02PJjD$hzOg4Q0Ov9hvI`O`AccfESUQm`J$QQk$6~@wXtQsU4oIgWESy>}{6~k;K z^YMw$l2J{YeQAX&`2KMGzTJ7Z4H>Qelj+hn^{Kht6>pmo&Tay4ICvdP|7cyXQ__VP z6mux;OE}qK-gWI{Y)Z^)g~Do8xxtY6AqlQR|3#9|T+7)PUlc9Q$H~!Bs3mt!QAqex zgfj=4&DcH0jWXlmW^@k2PWj0874Z~(7xVqGN|94#jyE=&tr1)iU#AuY6tq6^g7t$& zGIC5kM{~TNrzqgJj&C2w#iU#g$<6cJefrimbY&ut8qXt_yOn5pIvV_9e#(q>RUX#D z$$N6IwBM+@oI1d}$HXjfmc#!LDbeLo5{G);sY>}Dz!a$+Ymo_j7)Exxd1BO2+1CY#vW-PiYl?K#6Dv>GJkRRIewu_=pImK`_%kc zjMfMJclh!nfRbU%&}hdb4)fZmia9<_tgT>Yem7{bZ@ZrbyqQud?^DU$KI0OqAu$6L z5=8Vg6Z{A#)JwzjtTS{JZ_5s`r$mH7@9)5_OZ`USV^C!O!);uLC9IB}mkQm@!E~=Fo|8R) zK3MrcWg7knih331f|nj8u|$M%`gh^v`ThH9F0rP&>i4YxzA@edRI0>=V@8bGE9TQ< ze|#Nqh}(8kgMO{wi|J_}5iLU#mL*^)={!Tn@fRN#RI1HjkiB&B-o7SA7aMp z4HMGE4x!W2AAj5ZRoGY7pKEu@(x=2|K}7a|?$f)6$yU8iisEd|;^VHR7O@pe24g2& zK66GcUNMv;b+j9i8%W;oy&D)n)2mP~aalvJqw2>_X=t7u@Zh9Nbh^E#I;4pR@4N3G z^Rb5F`%Kf^epqkhUQ*TQU-;eIN~p8k#Mij8-|d!I|0VN<@;)|kS^M;D<#940toW=( zfPO(`ta{3s#kj><@E&0uAWo1K26-)DT zG|enIJK|MYntZWumvkw`cTM#nE`{YMU1;Z{=t33EX@+%vuT#UhImy@9MGFTmUsaLL zcwq3VK_tiC{t-_*MuLz%nW}r?tN*oCBOl{#!(-{}B$v?03q^)^n9fmpZ(h_5+Rui3 z?Rogvz0P#}d_H+$!yS9-;r#^f&r!L9JNZ)Z$}coW-I(dML(@=Mo~@*9rOchplFQa7 z)~lykv_Gz8rth1|JBT$7LRX?Tdn8pe-b_nipH)0&Ac?mF!hH-o-*m`{NyK>x?}IEQ zKC~RtC;Fg)HEeU9e!&Vi&TwVs4Cl)GV6)Ab)!3l-D_3KyO-r?laNO{;e_f8EG|3i~dKg}iC&Z|x7 z7sRBiGoY7}#4}yK_Q8ENm2&blJnoPZ7UP)xVw<&}3tlD&Xfy-2EwIe|i*nO$noIss z40$tR6gV74j>wn^-+s?5GrC2B;A=O^@lK%!*s|t!#->!dU;Hw|aGT{YqBg7Dcy5B@ z6}(Wp2R7DQV%?qL2o3sG<#6#trMG0U6w-)Cu$_GQ!CGF)F4L6fcCb}{-|YAGIkklm zpNhl7m*jM-=UfjZh^{dfuHx5{CUw1QMHXnWYj?X^LbMe^_k5F_^hs9g#SUDr97HTz z2QmM!5(snEUR!+0Ho=l$PW&-MSc{*c1bIwtUbbxe?nSFrKzEr|866vD+B+3IxtnVe z`C<}ZM9}-Bdw=CeU(@UOZ_&~;_I3Yw^*d)ZCe%1(9Yb3(-%hm`#z6rxVT%E{&yuQw zcAa_sjXl-4=^k1Ta_d=VbA9n|B-U1jM?S?UxxYL z&1hbmk;jM(GkLAe&-a|)zwo{1+;h+UoO?g_+<0!o6@AEa&(-uIIFD?iO2sS=5dj2@j*_O}e z2feima@GLbmsB?>5>MF~_+?>MQ-%L+VG@((gxJJg<=*I8gi$CYv@z~;=bjat#&X(q27l+T$ z5#KO%)02#yot@r}Hh6y4(&EDJ_4O!UH$+iEw4cZDAmw{M3Fz~_tRM&I;|k48#i6TN zzZT;{K790km;5o9K01s+Aq&%ze=z7pIceF6F;#Hr!aNgKTixCE?MnuDg|#$Er{#eY zxLj^#d{kmY;O9>O_R7l7Ip#+%m$aCVdEoe;%-MuczjY3W(14B${xCr5L6sF1rYBbx z71n$$Nr?&rM}{^aD^eoE&=sYh0zAKy`^vxMaoFtK7=EKu|ig}g97x5i#YzzWt@ zSC?5!1!+kw*aik|bdcOTHuSxR*g7=OPi)4uVCyTO5JF>JQ(X;hmg&ZH^TLp(zMv9zAq%E4vS8&XU*<*=xY8AL|p(z$Awq+n}YIVrRQ zJ+w1u8Kr&0LV3f?jc#D-4=H+d9Sxm*QSSi_KlWg0Q$r@;jvZP&brxgR8h1t^LnfR- zc7yhrnA?gZ(NE9J+>If;aXqrIgrbtJU=0Xs&t*eQ<&2?(K#to*mB1Qfe1hJC^u>VZ z4%hgUTj9{nhQ-6qS!x_-36J@l0<``>=J`L-r3P;H0}x8V=0>U*V5&_+ee)I`TY0Z% zoNaxra*(I3*5(FPA%$mkj;g`~V;P=?Vh4g0pDzU%MUIROvceyK;wn4V?G3tJ&vT#z|MG zp4ie;o%`B1*FOW+Mh9CQFWbn@^-X?$9l*Vok9E()_n%75Zt>@ZfxTK_Qy!39O)%Yf7fYD8ee5xGIrAsFjjjrcpZ%+H!FbM zAYp8wRPF@5JdHPB!weeuk>Kv#)l7N9&g--O!p%Bxf1K1qZwt^oq%&c;QaCr7cBTJ# z|5pgR4pV^L`qOP>mp<4t{vVDC|$W!y<~d=6;dN zY}8*_u$DFFIX9mTW6P7bq-+oKuG{;1QM=;kj9%;ph^V+2J;U_1+0^!RiV~iTu6prH z12u?$R%!MwM5_OF?$#NHArFc_!8~^2kUw#wS}UzlklP+7jiW*osb1`KED;lPI+S|) zWGmkYR{IMTC-TOkYjXOXkHxUJ$O+z+jH!rXL3Yul^QJ&|%RoMzLa1T_ndZdm#}SQ} z^%KkT6U#KL6TAZJV?Em2wDE!f%F_F#rY{-6&ff6wAYCK2dIP z32I*Xs0Bk-2cz*A8)ye(pMyjP$=1a`ukqWo4~>2t3@m={^(gG(lT@rSfm4(8lazbT zDj&h>UK5WC@@@v`2oLHoax@g_2=j)q6ISvE?z*-e)kze33MzY;8G}-G+Fznx-DqO> z=glZEZqvB*-?pmoTT z`{0SwT}u8gW_b5B$I1H&63O=B0g^BPA#|^<zI3UHhgt#{(oA>eMPU zk4jr>aD&YW-RIb5ExkadQ~nzVvqqqXOo3Zc*~3Ir+Dw$R1gS?sRQ|GbB<=l}5ShG|yZ@XEd`>=f<~PXw#apbfQ{Fi( zZ5xDB-VPVPeq>}Ushi`UoXZ^6K53H^aYQHiAvMtSxB1hqjKh`6ozj`sH%KAm!LKC8 z+i7kMgk|MA;AmgY;7sz?6tXf|iFfYFdl)176;8GQ2@oaximbO>f+KI#=N(x`Fz)1b za~#IjULli}rK*w=M*Ij`)#Lz~Qge*(G2}^C-72l0y~~>i;b-rY%l%eGOSk1MM>BV5 zQ2#s?x~9BQ(%LNvv6Okrtx( zpr3b5;>>O>bpF*IgnlG_P>g5%4tHr}1V!zsET}lOH#m8&M!vS@U+uRJ#46ofm@~^V z$gu}oFLc@p-B#6@Mo~KKHIKAgeP-PQ_m9sLp3-K^4eYdyj*UD_{9{uff_Fah`?X}p z^m8vC9nwhVjm=fx`ynf^9b~BF1dJ4aG=e>#2NdUVn)*B++!%ROUTc9>ve*QNJWv6O zcj(bTm5R%|wsK+Ty6|#&>WpRC&Jb3Let_I}Z1^Bg7la0i-!3fhlcTzr@*vY8g$JC( zPj+Hh5sPkr706v}nhQJBx1~g>C^eEuJCfx(g+% zo$?R#UhP^Y7ViHEA5=^tnU>mJ`Zo>v@tPSr_o7vY)|_gw#TmK_;sc$bM^8w z*C6Ily2(BNgB4`|?Ul=_e?Crna)^eyr*0xf)m<%2d$9i;`0u{M|1(p$TDiIxELxLV Q?*EJEKQ_@pYQKv7ALOHBDF6Tf literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-pct.png b/system/images/media/thumb-pct.png new file mode 100644 index 0000000000000000000000000000000000000000..54cc3aea193f9755377a8b44c15a86ab4b2daa2b GIT binary patch literal 2248 zcmbtU`BxH%7N(Ni#%#=SUa84c=Dd84r|h)2RP^$Rl`WbzTI!fKF#nFP(?T60|2{SZ(YCRoRb_$ z>uAK}C3ELzu=yzpg?xp-sL^Pe37GoIVn$zQPD<2Q_C!CWMXgpBWh8WwiMZnQf{$-= zQ=(eyD^OX9VAL zM@z+Pjn!zuGH+{hW0}WMDV2GtF?sM9gM`x$Tg)2)9coeO@fyeo}DqLX>^)^&}maK_5bJLX8 zI=M`yQYqI~1)Mqd%+#1jz@KH0Z>&oRpmUN+3*a5i)xzbalHB(l zO?dv|0s))5xbU^Nv$>`mS&J)NUgFNrPF10^c5q^p-bBEB&Pr^mE@4lMpt6(dDvIen z9jhWiZx^XB4K_C1-$^1)G8wfvT%Sz z`gN(8&*MM4f9K%aA)favuh#&z9hT7v1*XkCY6Xs8NIow(WOW@ zv8;eO)JN}WXN?WjRup!U2rEn6sqrsujaB7EX;mdzRp?BYyn{ypfW79fSHO3|Swi+! zoGI75U9A1zBYz*5dTs`_k4GdV2n-Ge?l%JI{)9+q*_z2X*!T0TqF0x+QIB33F1a$7CXYs32XiEvgPiWGKK*J5Ti$i;{V(A( z3U<+UObEApeV{2m ziNJr`KuU;8hq}z=Xr*L#ureG4Dgl<=p)$h*>QEC#KSp0eNMLvBpHvZt$Xw^F?2~@q zcyiQeu*$vPL30^$P8luplb5}!T^Oq$Hnb@xT|IZS%S5-SLnzx$nlM1U9fp+7u` z1EYck{2$P@pieRUBe-&>?=dzO9$ZWv%vkSKNayX9U!oN<3YH_}acW0BBNQuBZKQdU zpE)E<{d$lBMP|NKJ9d}n!_|&+d)pq}}gx-Mkf{4w( zO&Psr+aZkm0l(Xa;XrQMy5?so0aoyH@vlROC}@mrT#Ek45f7~(d&rWjF9AL?TqxpM zcsgulBXilFV-%tTgnu%j##`_1V7eAC=LVVk8>{n$i5{DKif z?XlBe@qu@IU&zI5t;xG99b?p*n|j012##~$l3hxQk9ZRsd|~2}qcRGdYbbG7gS%C| z2Lf*hUGhu}Y)#NIP>h&81ifxS&BIqpA6wH>PkZiIvJWy;&qJkS;ZiwK{U7i$(944H zI=ZFKAwsw5G9nCk-WqADJQv_b-pBw!@?gp{he?k^rSxzq7|b$8zkK7knbIMQ!Xf94 z&?Vq~ma9)K%-G0$py&YF25gUay$RYUEc(GSbru-pk2q>)S9r<5;u3rRv)P{^*74UP zBj#&VG&f;itTIybwL6WYuU!DkFh?Uk{1v7|_1-gqfoR7djG+STv8)Q67uR7Ghgzi` zVgqZf^CsX}LgY4m-10|_&WrD01I@Cf0#Z7T1q_NqB)a(SGEQ5W#OM!;K>G6mZF|I( zQmvqu2yFT_BSv|wDj!s7C`lZ6a{)`;lc*IGfN=5)H0-_!ckwM#gx(8F9<_m_gQyIVRPI?NUNw~bYQ!1H9ZA$P z)E^E+RFmHYyJj7yedOu5$0D@TL0hBQs$++pp4EZ4+Fog~Kh?@jD;?6Fxm;X@v;e`K zc6=}=iaGOeI{iD};}C0@QjS@_)_9g@h?yNLn6lnc65iNd%$-a>8`htERn3IMGR!z~ z=Nu0|MGe#din({dCeI2I3`>wrrhm-BI~b)r=~urJ6vl&W^wLzWZ#552zfl%qqE(Jw z#JQP#KZ(ytA4De5%f6oFrSM@0n+Voh7&Qpe4@;oYL)KZJRA7Y3UfqU)r)ycKvHHWq zpt+Kh20mtFd!1nxXs+0q5@j}FtQGVc5jy?;vy(`EN@rLO@|Qq?c&2C{toA4*`&I9| z-C29vydT?uuj&|e23tc%`Qq+;5JUk>7$TJ1O+BlpNh<|e0(Ix!>9!4AJhRFo6>pzF zo$cWqIBMGWtCWk3=ll0+2P;UXUJqlH&}sASS+=UP^QM)+F@`o)wh$L735k&kzW*n$ e_uFjI70uqFdCOMsihtF8%Fs z5d{aJC@2~rj1a<`$p|BZO$fYXK@t)O86Q30pU~%=d!BpmIrpdMIrq=wK}RgiY|Rh| zgoQuqa0mji1pEifmn}M9qA2>sWpoJ$1Q3Xixhu6%#*2DMLdX$c#Cz5T7=c*oe>~u% zpGR{o$m#yGx3g?yfGv~MPYAd(GK0Z@?qcG)+e?S|&-%GtQc30L5N~EWpVzC?>t`1h ziuyT+nrpRs{ZC}#htZ*`vEg7!Qw)PLKC2v3$XOE6lv;JTg(#R+&S|wiq`DV_JlXu5 zC#lZ2rT!e1)Gd?9H5#F6Hol$4nU-Cox02zB1-$GLV1BCH0SgooU8H7ZW z|6#M5CdZGE8!j;@??wg*!m;C&rpAf!PN^8v#dgi;Nr{dbFXCP80o7FZoStA%N;P>6`={VNfpj^=0?ddET zVL2B&+T|D)2ke}1XZkiOP zbu!M=$VXcnexZ^5$qgA@OfOR1jSe~q77i=qvCOuQNvNCRzT6fs-jb zNxwSz+qNZ5Gv84vmVEzw`Ken!EcHhz2lu?X*hD#h!ra7MO6>dk@P*!napGdHO}xP)p=<2`iT@e(k>)WTTx++0-1V`rWBbhVj^33|e=BIJ=QT zn#zjn{L}hOjg13TQj!lVvSgWwAG*f2_NL7ho^nsdb3;U~yDL)HQSYB{M?d55le{IF zdR&^DvW^2-1xaczw4J#;^3g7Mjo`;C`+v{2bSoRjwmn@Ftwh;GI6xf#u>zI}_P!I> zkbVN|NF7zBV(%BF*4nPfb5z^MUONgp5)ys@KW54;ZO!r=y<`WrqN>i9BQN3!PJ3+} zVN9(DkT@!}UkP&tf$b;4UwxHZRgTXyd?acG)Yd&lT@~p6h ziq9rE$AN2sE2>^10Gq&heB&=4<0f;R>7J_%zwJfIb8G>pAb*(i$EoCk&s&P2B&Bejepn#hBH7)l7%*~&AP@c?|YfzwbHo&SER$@--6Qts9l#0%gG zuBlA4>D(D|dUx75TV&vyT}Rwc71LyBXGKoH9?_dF?b*B@Y*&Fec}8>}4b|F%be za=!#Nh(d6?Esej4zgBu%<_XwCh1JiXwSsvjoD2r~eN(l^OZ@5$G6Q}T>b5T!WU0{X zD=rSjO+aru$`ZYG2MXHRBraPQ-MnSHyaRE^On;i2h1#VULx{~B+ z3eE>p!U*I@SFIO(%q|ZlV8b(Gp1T55ZoBqFe7O18%ivH04|5vKEwsIBQRS^*Z7{rg zc9q}h1ZAGSd?t&7Th^|QLv8Q?*wX>jaLRF$Fv z=^rfYE^>whjBsmQg(i_QT@!Dr@Br+o(u3{Rg2dJc{^!1`{G6SJFzQH!OA6?boA2p; z1{}AR9q;*~#W+>FIH%Th05-C1)#o2NL$V#-lBB^s8zAx5FhybwmkGE)gOB4P0= zWhoJ7GeK7$uXw-JkA}*rRwWKzs@XS4Scjr!U=N$ZOl^YCdTU2_dDx;Vc#?WigFXqq zwU!++%X5Kt6;zfm;T*H78-DmBdo@lx>}GDGj>!S8xAsh^rx_7 zW5NjPRh!y39L)M_hL|zqX-p8-h(8Mh~_+0rDn3hvl4hB0x&Jo2P zz_}o^u3N|iy0$DN9ApC^1;|XomH|~qHk}^@5gC9a3 z!@qG2lp>w+<^X7dyUNET{1yU!|HTNfHJ9^*%`~>aEIzS$YL(IDZII=&F!Wd6UJq>l z{$E{0o(>rXQNx^i~m lc1H45i+@q~|Bq_%ngb~=(al3v*Y^D5`1=JNe(!r8`#(6y_l5uf literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-pdf.png b/system/images/media/thumb-pdf.png new file mode 100644 index 0000000000000000000000000000000000000000..5d816eeabfbb13597cef68b46b8af3a93531963f GIT binary patch literal 1823 zcmbu9`#02y8^`Up8P}D2yDIBbEV0M7%Wko<&PU2eZhNXt=^_kr8)Y)(nr6a|Y>m;P zl1iU0LrX3Z6H1uT3^vK#N(QyeeC9Lv`+V7dVc+LG=Xsvjc|X6s&qMhoG{Dq&qp_Zz zo@pTDzh6)9GwNCyu3IIoQGMFghKUD5Ks~*Nt0w9w?CSm*VSj+1Uftm4Pph;1m*9hY zc!$H)i;IeZ0SWz)7=|S^HNsMwPNx$V7YlzcRtyg-r>1l&m2zxMe6Lz7lj+oI-tl8P ztyWTBFKuhnN~OFbhj|f4Gzg**3bjflN+zpjW)MOeT0~I{4kC#O>V*Yq%TvW)e`!!u zy|kneiF6tbO8HIw@uR%IA1%n2GnwKC_q8gOfJhWoR3K?-f~-vbiR0+SJPj()N+bwA zg?BVUIWnSEC`8vQRPWy_M@Kb$zVLF9?8OTt6)$=8NHsSnZEfYBj8w0z$X~za$3#n? zJ(JYdC^#I|{JbD1TU2>d((pu7S;>z&i53(f$w_=%jI^mqTva8=%~5^$Ag;cPk}mS& zVg(uJ`O#7GSKTs3yH+koFOhl2j-rJH>g9i=&sxRsJ@w*(AUjLi*oY)03NGX#X=j9` zrK0k3&B_XLI+dT0Af#SZ%}k5$+|i1~lKMI{FAqsc7GAj`Dlb!xzLWR$%3gFKX9=>- zPT9+s>QA4fEiKCNaY0UwATv{Njwo$tK=61Ck0)a=q)m;o_IB0uwD4M~kX9lfrYqmQ zlhoECXHJVBJ(M;#^H0T#@5B6rQ~byiYA#pa+biqnkoWaT>gr@&T?#f^BM`_sJ7nG6 z3Jx3ntx!2Rp%@xEn8N#0B>VS2A#-dsrKA#iMI)Y|z9&+|-$?Z(ho zoN<`5rfKa!<~};tA#i7Bl4e(AM{V01@1L^HA$?m`bM;?@YmUu6;I5 z^;LiJV=L`$4_4|@sI}Ea_~*#2u=c4Sj(vCnF=(v9g}FWb;CWlC>a%sW1E5yG8kg{{ zlhpK-56rh%dMtHI4ianT4yX6IgNYj*iyove8MS0VoH5eXT{>0v{Ww&(E6h$CQKLR zk$Y<@BASxXmQ4L4#HM)DQ?S5#ee>7&rGc->!NH+?A5^pwy$A2M3CH<3lUu(PGQ2zs zO%Hzh)xfqfE~>=Qd(4pL19`r+w3nW~0c_dK@XR<|VnyN_S`wLsZqOHwm(T;H;9`pj z&4DGBZ(sUiSl`HJnYO3Qkmdl5oBR3)+c|P#mb7Ezgr>#xyThDH^q@2}|*fd$XH_Gi}Tc z&K`f63@Ycz@kX>CS@}H-UC0FK@l`Xvt1WH@cE1sjLtPNj>=#^cCTZWgq7T6ut5?f= zGpM~9@6uN8>$uc*AipP_Xhd^jE!<;gmhH-{VHx<9$9`^U$#f0E(yd|F1Ae0bWnN)J z8rcdZg)IV^wwBWW;y;zUs9~Gn#fJOHkAXQp9UZA$b@0J1-hZ1jWza1>Xu`FVaM{ z*y3|IIkyIwb?UTphDL!DkDIO5w{N?$CgtzVu=FqCq30DaVir5diW?j)%V`57rZmq(6B{t}B zz3A4+1NO%(Ft!vRa))keRBMtnRm4ApHzO58R9A@&CIJTAoXNoN*0>~d_#&w}cLOHj z4&YWu0!*!OZ+t+izVUFIi{Y>*xFKcM0+Ub)SOsa>#^Bzcq2*@heUW>q7V8^Nr4Ulv z*oN-Wpgu!+BI1cQJD+VRY`q`Ooc@ET)r6OO%t;LtaQ_dO)+!N|w=Xa9yf6k_(JOBUy literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-pif.png b/system/images/media/thumb-pif.png new file mode 100644 index 0000000000000000000000000000000000000000..6c702557b4e845836626e4440a2dfd1c89c408c2 GIT binary patch literal 1103 zcmbtS{ZmwB6u#81;4)y6m5IR}OvW`V1Sr3TMC3TH`~{ww_q@;ZocElW_nae) zI27W-*}@?R!X+#;D3Tx?M61W?c}A%}-ezYmRst1H5yXvrXX|k`!yVEhLjnnja_bC( z_eC6x3dY@|knzx0h~Y}K#KLeN?I*!lPafe4nlDn`bLcY0oWl+z?c-R zpP=n?v~V%if-V~O_)wRjEgnmg=(&K7G+3YD;Q(|sQ03t6G2G9>*iC4zLY@p=H75ST zoDwZDFh7F)1fI-ec?xZDn7o7eJ{ZR!ON8Bu$Fo?_pfdv^)30Xj52xf_Sbr zEQpHwM)N=;i*%=mV3p6h4YkBEeM#CLR*JcFGteN)9%>l-Bs#{iBgFR4tBA_*_^I1<-fK0ZF$QT(9zwFoDfrGI~tA}Q@a z{ui|MaKoU__Rv5oIxoQB=2cFf`8Fk)5*5;Vb7B!0#%|x>;w+95SE%J;u*n%GIVxQK6NOL^2b$1AQ0 zHaf_)+g-_H3%BZn_ZGkCby`##lTQ|8*5yctn3!=_iFQ@IdWs(+l2fiZQWI04yJ@ph z!xFm3zs}v2Ui6Y*XsQp_R=ra$yu{(vRqmI|3TpduZqZS_zXe~UqF3?@k!vwrCVl8S zQ@AfzAV{)J#wb|o8nSQ4@_Od?=7UT_0zO`CPgd6z(wjXaXNBzut*Xu;zs%e;!lS)5 z#ha7=%p*8SHSm-%j$8w4555y-N5s`Qa@W7(X|>e*W%wFyIXuJ8 dJWaKy-f>8MyK;VEdq}~m5EgtWND}xd{Xfd=E?xit literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-pkg.png b/system/images/media/thumb-pkg.png new file mode 100644 index 0000000000000000000000000000000000000000..521fa1bc760254576a92fa295cc0aff093935bce GIT binary patch literal 2660 zcmcJQ=Tj316UI>^9R$3SQwRi%2qK4qiXJsc7f=y71<^y%93UtlMFG)7rJP9UB|zvs zfj~kFMSAExKtd-Wv?LH9lt6g7dH;snnVp^IH#^TfA9iNL?pd3P37-?@g*(Y zowdKuZKy=&r9`AeeoT$}w7av-+1jkd70k~}92^`Ve*_&I>~C*#j_5jzQIeZ9(A`>; z5tAAJEhFx0K^mf_EDsqOl$98Uih=VE4t95Ul79pqd88nM_jY&CS#isYwDzBsIm!RD z)Zvgx&1S4DQbq@~pyr>uUpDEySkkpeIgH5N-?CtJ0)?n$h*>qSQBegbL|@rC%}^d>@C+t12L@<>NBVRmY~C_Q?lzq7BS zsk|_4VSa`Sej?4sCs1f^1hMreGbZW5zZmmdbCAcIMB*cX(opqiBGah-Xowkqx>O~q8 zyahGV)2|eOd>)dcTziI8rs!Hx72+^(ilrj4^A_2uA`z+?l)%zCW36D;^ozZacuWPi#7Oqd+mrPKr z(>rL1$feKWJ?kM}c5Z%YFO!JCTxcM9;rxT-!y0Ts?pu$7kKR7h>8CV|>gsN-RDHDZ z8_+akqREaSkIuB7+tNa`*E8dv?rUr++?kW6vZS|J(gneuy6}liUHG%HGb&pN6%_$E z+D^CE%kJ#7oBWcQ4|U6nwuRZz9PqU3if03-_iE+%mDNnHrvGbLD5LZFjxq|w`dfDp zednI^a7JGDyv9gmef7F%=10KZ$a$hIy4{^5w=$g3FmCd@9sjG=YeeCf7xZ+@GJL-- znR2BVqSsS>oO=~Yj1wX9M#L3)Z$o%?0jr_0{?!UNJXXZUTb4dUp=JcPTqiQv8i9`x z7e$8UiWs`MyDuI9PkThG`^2?e4`nNrwE>;`0$d~2fdLo19H`hVq@$*FfUD)RhGdy}2Rgm3pnXbv?KK2_@%k=Gd*1TWVAX*cXAM-&u;X z?x(yA2%<=7w$x@}S=4hdqzS&+)huog-^3N@dI&BlOvShBg9N3{IY)TmxdILdI2V61 z2AOP_yH?8bu!W83aRty7pj=?JQ(viL>8|G;j9O?Ex7JWO0L#1(s%~w2;zdPdN+%M8R!W89WcjLdZ9F0pkG}Ko8A()Z3 z%Ed4ciSfwr`vVheuo-wn+gJkvnr;Px0(J09=50>@QYff&e8FTH{;!FWd$=~$eulJ#i7VhA9_pwZ#5yoXhVtni{`~Y(`BKuNgggsfyNL4dpy2SClw*lf8c<9q3 z)+&OB5JdIwleASpW}T20yCbHFSHHl20UGNaUtGI`<~)r90Y~Iy@Jh(E)jxAFJ9Md+ z?Q0otT0M6U3IURidNO#DLp)E!y0S;RK^Zf88)Gfu+5aNTuDKed0x(xJ3&J1ec^D$Z z4n8^1w{NL0;FsM=XtFwr70Pi_YKyQ}-{W0&X}bZ-{w*7F_*Jjj1Wg;N{h;$ZM*`dm zdhS0{e!nQMJIfR;fr`9n77l4~|2%4fKAEBJa3bK(1xfG-=$o|Kya{^lyAVc}e!A;N zyHK>N#MK*X6ZsGI2@~;IUR`Z1i9G=GTcI49E5$@jA@{YOoy#86ZYsE8n>YTKn1+(o zAaIMfP2Be5iQKnxNzP-yMW94=$CF5M$&Tsm@I&nu_2Y*z;iO`^rksU7r-zq?j~V*l hZ}=a6y#HI9@c==(?^cSjc#D65xv{ko#_%uL{{R+JRe1ma literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-pl.png b/system/images/media/thumb-pl.png new file mode 100644 index 0000000000000000000000000000000000000000..840d716f1908108ffdb8024a80140510693bd7f1 GIT binary patch literal 1066 zcmbtSeNdAH7=NkZt#NRYNNNOcyB(d;MTbRdF7Bl*Hmu1l>P`*JGAD&}Qo`;)%8@9h z@-i(vJj05-A_YXR5g@4JV?)^3_-e2r$v*fJ*VKcR=lMO)@ARpN;u9XBW?hRz(fG?#n16-wB>GCxu^*0BEKK2n5q%}FRAJhH2@QPzVzLv9k1(Z!XBMp= zqDzj!a`e%#HoUA(m&LEFtDX2Zy*KaS&4m%4uZ8N2E`PRsjcHS<&g8A=&Ue|l9LIQ+=ubL)4s`7UP7M;(lbm##OrY-|4f>Rg($UVS>AuZguBamOn5g;^c1 z+xEARTLL$p9Pxg$f}gxk)p4Y!23Xn`MFw9YeMJ$reT8b84CVbn3e)H&MXEh8V<%nm zMuBhZoV>oKF1d72apNVC%rnP-rX;tlFn?DVZzmmPm)#Z4aFlT(wj-I+1u5f2^-(pE z(`$JL>7$#RnG*U2b?2BoCz)=gF5fquCyNWG&&XmFypX3Oh1c^Gl_A+N*90W&IkENOPlcM4vxF=1Uzb08&M^*etf zzo3OHY9RS#WjlV@lhXZ^lB>COd&vD(na=N#9NEk^R*@ItxS|gzuY%cr!tw&+o?%hh zoNO*z`5tYGq~bV6e%hO{qE2$VzL4~=)^BJGWoe@|JzZ4^k)%jTrUm7WTq#JW?W?xb zzLt|`Q#U%blS7Wcj4WFBwmyh^f>f}V4S9lGL3W*qj8$b`>i%B!s;21A#%zieDp>tL xvzm$g(xkk~e-hjc10&~E)e(>J5G$>t39jeX3iAoMB{E@|5DO)O>co!}{{h@77SjL# literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-png.png b/system/images/media/thumb-png.png new file mode 100644 index 0000000000000000000000000000000000000000..ce1f0a6b9887ab8080494f9cfcf6c71a1781f4cd GIT binary patch literal 2530 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-pps.png b/system/images/media/thumb-pps.png new file mode 100644 index 0000000000000000000000000000000000000000..5448204c87ce65924eb2ab6606ad58e1c8e56f9f GIT binary patch literal 2497 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-ppt.png b/system/images/media/thumb-ppt.png new file mode 100644 index 0000000000000000000000000000000000000000..bd649e1a55a15ca09705f919b1955bb1d1ae87e9 GIT binary patch literal 1573 zcmbu8X;V`N6ox}3E}1QH?SQuqg*@ywjnns4{LbcD5+0Y6@(PCGq_}Wctxk@A zLd{OBqEn=j#lG&=toN~!#d)=QU9DDAQsdeis%C@}$#LOIr9vi^WDuT<7w34x{lf!2 z6JsMo{oM<5A}Tp?a(q-SlTHbE(j_sM!*2OdKF%AeD=RFaBy%~Rn(Incsx|S#?Aq$; z+UnKmYId-El9^t@VRXbRRaESUw0chDK;e` zqMVw^EF?A7(6f{Ottcih%Vi^jy^MTfePywFeVxx8$V`kOC;d~y%*w@uiP4De$8DMwFmc+~g5+yBOu_9lbpIMSD%*_Z_mgNH8 za4HlvJvm;SlT?)Tj@{Wb)YsL~RFj*QkVk^D5@SejaP?KR{+_naN`-1|ZELc=ulrp30Vu}OFwB7%JkQCj+nw}*>L(me1N4Z52^v&nF_5hjy(gVcj&qK#Y&KyE#gLIy* z1+u+PVnFT%JO`~YAU)m;nRgPcz`D6Z0ay}1*xt?$xS)maIIus{m{*HscJJfQAAo6w8q0R=Ku zjN8ZEC%7A`n~LzD!rS)5>)!Ws0-bkwF8M~m2DQ~#2sufbR`&GUrS6}opC*K=YJYAi<<-kAo?VZ1kkSu=c-)f%Bs_!U zSP8@&naH#A!I3rpiu^v5p=b;S&xHsT-Z%iY!}v^PL@2DC!`BCGC8Q@7EY`2kIb6*s zU)~uTq~u`r>ll}y$wfQmsTGGB?1o1<;QVYa{ZK#3z0F^b+yR)Gx@ps?u8`%kN2xk@ zL0AXpVLYXT49N~VH}UZCxeM}HTR3zsitb5_w1e-h z{Z8}aAls3{rH4aWbKstv^LE#w9E^OwE_W!+W34>pZ+oum0%hvj?A4)LN*`+Oanb|- zZ-M2C8%C9s1-J~mQQy>Pqqk8d*Z7i$+q#X8DrF&%BZ4491G({*fN{R)M0LD{`w#Q8 BAkY8+ literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-pptx.png b/system/images/media/thumb-pptx.png new file mode 100644 index 0000000000000000000000000000000000000000..d351be8e23cdf63212e2f15521c3b51a18bf520d GIT binary patch literal 2560 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-psd.png b/system/images/media/thumb-psd.png new file mode 100644 index 0000000000000000000000000000000000000000..31fb6efa6eaa7b81dcf2d8d5b4595ff324dd32cb GIT binary patch literal 2613 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 SePa~zk7B5AN-ntO`0{^dm|}GR literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-pub.png b/system/images/media/thumb-pub.png new file mode 100644 index 0000000000000000000000000000000000000000..ce82b5c00a541b32952ac77ebf8bdc8575efabdf GIT binary patch literal 2137 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-ra.png b/system/images/media/thumb-ra.png new file mode 100644 index 0000000000000000000000000000000000000000..39eaffdc52d81c92219d5dc475e649ff4a44bcf3 GIT binary patch literal 2223 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-rar.png b/system/images/media/thumb-rar.png new file mode 100644 index 0000000000000000000000000000000000000000..37d0b3cc4c6a46abdf763380f30cbce37e149851 GIT binary patch literal 2695 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 N))sfnD{lPx{(k{}b>IL1 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-raw.png b/system/images/media/thumb-raw.png new file mode 100644 index 0000000000000000000000000000000000000000..2d26f299668705f43fdb70b622350ea5907b1cc4 GIT binary patch literal 3433 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 YuE(lOA>N44md}ot$LSOAj$Oh13!<`oZU6uP literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-rom.png b/system/images/media/thumb-rom.png new file mode 100644 index 0000000000000000000000000000000000000000..e1eeb34897200638d60b7ea9a9fafb196c8b5db8 GIT binary patch literal 3088 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-rpm.png b/system/images/media/thumb-rpm.png new file mode 100644 index 0000000000000000000000000000000000000000..093e21aadb617b42fe1de3fefce3150985239a7e GIT binary patch literal 2566 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 tUttpH&agDp&?q6F_)jnC-{NJ7@tg{{zrHNrwOc literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-rss.png b/system/images/media/thumb-rss.png new file mode 100644 index 0000000000000000000000000000000000000000..424295a88d0eb659d3228f61eeb85d5f7500e08c GIT binary patch literal 3091 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-sql.png b/system/images/media/thumb-sql.png new file mode 100644 index 0000000000000000000000000000000000000000..56ca91d06e8b1431b69b49c5883448723d3f95cf GIT binary patch literal 2929 zcmcJQ`#TegAIBw~sFa-4Np;92MW>P@xg-hkbtiWzB#~!1 znayQ$na$YjhHZ>(W@9sK-_9R#KF{-c-p~8}!{?{h=Xqy3Ioy)pr@T)}N=n|w+QLOj zYS+7eS9bTWq|F~L{v|TOH|%dnNj2u~-}c}0%kPSDxn(X@H+phON=iD^$?mqLZ7qC9 zEKcpm)?lgGa9qhK(XJLQkw^+g2sq}df6GW>H(I#09o9bTT!(M~BJQ<}I06ytqAePC z-5v-hvw5?uHLg&!xxFKnNVdh|Un&+$B09!A8;1xhYjdl7kA|VGot*{lx=+)vYyIHN zGMB%()j^=U)DQADwq01L(XC;SClo#Dnn27gQ#pLb+Qyxx;qnPGlEw-E zA=iYW+x3I*hh{)CwE5L_CSUNN6_wD1{y2;unqN+Wq93-QqQGNiP=E%F-9qY)IQ^3}l?OA^b3mufkB_b4(JOmj;`vK$DA6voCrv zxr5X04MU;rqXSeXc8Sx9r*Sqmm-vE~nFSby*?^mm@0xhohaG3Io2C~sU{lk}+-eN< zWiMu$x%wGN0L?6n)7e#%l=`W85{tLU6NGn+eL>E01eWCHt{RmPe?)|ZvJlbNZ#O#U-L*8k?{;VnbLy^ZbiNB$08S2>ZazFcmfKCAKN)z zi=AsH(219)`L><@UfIz0V*yA! z#{ORGq19R)-=c2P;XhIG&8QMUOyAoMz3RxtY8&On6!C)47WwlV>&@#a4R zE}&zhTM%-=eI7Ca=$+IJA6k{wO$RQBG(uFI48`s>QZCI15DySXHt*|ao z@%&Q2r>YfKv<~9L6{P~j4AN!iDi)9p%sr5DtKhQEU4Da;0rhgSr=?F>pM z6``&G+~-{RbnJjl$$7@^0Pup9W1~S`q}vZ*0EE4I=CLtgvDkHFH3SVAv2z|O#G-N& zub(0Ttk#Xj{Bm@qC$5$`oDS8xHI-#szK5M-kfeP_|0Zsztu%2bN!H@KS(SO_^MeMq zK)33oz?@p1Z+{*>NR8_w2UAB0_?H%6gA0=sbZtxh9h4)VZl8_(a6nxlH&v{K-_=%h zeYa-WpgQ}CN?3Dox{X3ctPQZwYsaq{(^b$;*Bmg@{F7(w-DQ1W4`7{y(eC_*tW#ZT z!Ax&ODrt)Gf`^84Tj5?G%wAW8Dh4kjsQ7jK87 z;`gkORUf#J)D$*@G#Fp3V$(t{z_Wi3xFJ7`{mn(%z!{>~>tARcqRpHPBVNuR@ z+FEiJLQg$T;rb+U=n1g0Mt)iP1m?}8$XxnxfCv=L71b3WC_vtiO-9=`y z^~?2(c56SgvODH|l6^NcA1kA_6WB>E=~DOcMkB)uYmA`}qT6u;A{S}yRfQJ(rJdHm zH0r?n7fBAiyT4Az%;h0vyZ4kn7T2=hxYyR7h=^`+i5^w9R3%N6V3kXwFV=EfX6x8Z zWAulvm@B%{T(Dk$V^eaKMD<4>{oY&}$3=Q#u)fV!!*cdO?cYkc=Ncjtz{69+Em`-! ztVMa7@Gog79)8sR;1@aNNpnwhD%v{E^ufHre;4frx)VAbRN>YKJ(K^p&o}tZ<+w@j zqW+AJ#3Wf*7$&v)(D|c1hyF5}iR^~>Yq0-mQOtm*+E2bxH2ivLJJJzk_@Z4Q;n$zg zp7}n{k5u!pTHx7fJyZ$MbutBbCMUXW``*6i2R0SnCmda8L=v(w*!_SUXX!@$M3mr= zvZCRo2HX6EvzRiRJ80+_GAQ-(`Xg38NaX<1412sXBF6~Uo@Ul3qK2U4Xku}A^FU`q z2rBatXe1rU=(%pC1obV6T0}^Yg)Gdz^I30j zRc3-><6{b<ikx5qA=7DyAW~H<4PTVc?E54V6 zmk7e{{Q!Hl9(|cI&u>!{|7F8o?kk-AnhnUU%!@UyS21%cj{~!OvDR}GV4=EZg(ihH;!F*m@)kMl5J=m6}TOApk8Ck&D)++r)?o3=d_5S&~=HgSsqD{B%m;f)3 zOxc?tpKE5=TX$r+^p9U9_U>MZcDkb1cU5iGWag3{L}~Nvjaw@}{rS(1OF@Tvz~*tA z5OoC`zx86hYmJFc#7NF~!L`!VIL+aTDfSUp)hu-xHSF--z(+0hV7rWF?h#z38Z(*T z?gxQzyU+I@*GM<(G_eK7Tug`uJJu1r86^A_d{X~{x&U&rRmrD|VI$KNmhdh!1#V`_ zKU$sGCOY?--(5VUC-`mt#nYo zFcT3~z@K!kCC463XU(Hz0iVtv^h$q#8hMV_>38Q1oCW-my|2((6c2V#{?cC%lls$fym*^MjDW@?INw4NC3+w!aa7+CSM4whW}GUuVk0K{5fDDD?IBD`k2UJq~9~?@)>sZCT8KK{!Je zR*M(vQI_1i37w+cE{=QQ2dnq*q zr-#^^b}@fe&nLC+u0bmW{H|I(MLshx%bf&CSe~Xg(qgJf2{dF%U0Ln|8dCx}hzlDV zNeK5t-O|yX<*1aH#2O?#^_GD`7by1^^7b5=$N!PJQNMRN^!pLq8>$5Qbg&a(^5biRHv!59qRVqmHfY#p;?k$ny1Sh9lfoK{`EGN4iUE5OHw5BaP?C@GO%8T+SO3TW$Y_nBc%~Z^@u(dVUVl`7U zZELmGd(+Z9Ksn@6KoLO!Q4~Q15m62~yUz9@e?WIlEXz={0)|wwqERoYS5;bV9GRfj=|1y$e@{*PK1!EsG)YvF zC%WqmXLLY3e;(JH&lqZ%o}@_@I5O!OOt(-WZ=ac(S1KM4Q)rk~h8Jmfn6TTZ~;pS&2S7dhva5=-2ND|f$iy(+* zvuTvx*_j9;hO!`n;CqY4=);Q(U@TIys?z9mr!n1AE6dl3m;wfkFJI}HnZDKE_nOTt z85{A(B0B}sT>}0!B4%u9k+mc(9vexZ5J#npuUHJSSTrtMLeC1q2&g*VxKgW`T$Z8d z1ivw9Rh%)Bcut|!+$3Sk*~~Wn)Q1U97LB~B)Ar5^`o;6fgCwO^Tg_!R@VLHM#4jU* zUuUMG`}-DD%D#Ew%dwGoGQkIfAd2P&C1R0Md6|H&n;h>J@;e06`1#qVBSRs$-sY(Z z>VgPHr)JW~;RG~hPFTWX{Me6MTva_9rj)T5e@$|@%S-jV@wek+@KHKVA`Zo)t`N}E zE6ZQ_Jm_G*Kq0??>#bmqma-VXjtu=VK32#a9+HS>UMCeB7;Jh2_5oc=V=ZumP%EZw zFAsdX)#r4ZJHQtJcyDUxJd(V5Q~a}yn>LSra|AKXy~gRP+nhN`nUdzn#>6oo)I3hbEUQ3>Z@8h)5Wl>V%!3+<$jPWch)~) zUG8afp@?)yO?4bE znmv*e*XWqm+3^l&|Me$gHYh1JNpievKuwFf z7)Ec;%toe^4KotqqGKGWVN)greeN72Vg_{l~Y&jg}+nA|-`!UXI$I>a+)yC04rGD{pLj{IB62)&I zDIo=`*HJ*QbLXA(`GkX{8#EDyC~9h4UJzlRIWh5f^v@FS-rxbjvLy8$Ssw6$IR@IZ z$Njj+&h$DTIBx|-d25)jPE$$Rg=0;j3LA@}hB|(PFwTSElI!B_866w`)i36pAviU! zIC8tge7STypA@O9%PP+#}$H0~n~jTt!DF&fEM53*5Tzuqi;Es*>!(fAoSz zM}?IGgLr%ZNG7JU_1C5H)!c)o`;3k9S#Yn-`h_8j{Q7InnI#9UA|-G-7C36GD7B;SX! z|D?9Pv~WINrGBvd+#G_z);?@{tw2C8>wuo*{pR|wisjID+d|#$ZPl+_?(jR|Sq(k) zH{hA=Kk9(ns@s05Uv`?I7&o7W2a)rE@}2f~sqL?A5)zIRFvIJXGXu7M5nQEm$yZ8uLWU#ROdo&@tEz*)^H02+lqojoovlHO<<(;R*JR5-jodCt`mNkm{f|@R@rpo-(Yx|n{7rOVQH z7cFXDHVqIn&_rW%W3cs8W`|*ZRZ}cgb1yl_Rn;{GLZA74!n{cI$SE$8y`4MTcK2%< z9FNg-80EXaL8c@hL84fe&k?VzES&#q_p1pYWp`G6il~N=`>h7z0t>T#Wdg7hx33>4 zSk#>Rl6=iohCZbZWW1*UK#(4 ze>fN*XuF;L&d#Q|2$bH^Y6d8iCqK4}1d}#}?+9EAg2(&gA1Gai!`un1H&5qb4&eU& z4!ietnM+aX5+m3fbN_ZTNPlTk=GgodX)x^+9KoB7G_1XcPg^kd`4GGo&kr?%IlMK? zc^!2H?v7-yR1ECgcLVr5#SzSq=a2AQZ6`PFSbcU8Ffx_tE;mZiOEe7zih_-Sjn z(yhS)zt@r3*=Px+hNnhJqBC&*RpC+v)#B%ne-Q!yUtO!v?z+JVglc}*h5y5VeS>|P IPDjE12WpWHQvd(} literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-svg.png b/system/images/media/thumb-svg.png new file mode 100644 index 0000000000000000000000000000000000000000..f6dd2103a6efc08477debc23df6fa0659362bd9d GIT binary patch literal 3359 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-tex.png b/system/images/media/thumb-tex.png new file mode 100644 index 0000000000000000000000000000000000000000..5903671ee3bef8a5aeaf7480705308e9636e2a4e GIT binary patch literal 1716 zcmbtUi&v8c6h~zsZzPjohO4G#rW{Qq6w^--1_^XJf@_&fH=U(sk7X}b_(Lq*IfEC`lwR}dGn$J-~HnFiDR$l zFL4%MvLw&VRxO2F&RP6Ru{>+H+h+w0bE1cO)1t+y13}vgXdR`elNZwHfIPPvn}JF?t?D{8XdoY4qI1 zCDJ4dy`*Q;gb(bgSF;?;>yL>9PUJC^YzcB>;yZ)S>n?ON=`uX3cMCfEJr6;a4d>_d3$olEyPVOnFJmczP}?HyeV20GVhU{K>2lS{;n z=TfiNML}xJF#eIg!kG+1$nNveu4Ernl`p){SN&;)A#TFJbsT37%2U&4cuz{ZB$7%G5wP;R`chB z+5_n~i`~6TQQ;Tu2?hc|nppG!l9W056ENZ9=RF$Hb~uY9T8;)V?x-7IYu^9vlLAs^ znr}-5teM$-5`1>B4jXlLTh=J#={CfC$dvg*k3Y!Cp67zyP$xFR+iwp@ZBl3g*5_FuYG1KO(MGAJDKP?*IAgo;Dh?Bi z4&P98Kc}rJF$1scy}viD@P>a=swkR7`m zZN|4GJf}^ye!L1#mo(D?BHCJEn!&)otIbhc!|XR;kDOy%XswFJdBLCI`%^J~D7Z0{ zfb+L^YYO*5gI_s2&Z<~Q^4B(gV9q^9VgJy48&)j1u0I1-DwO)BM8GkuxRQm#G0Sq+ zH_()~SF@2HdZciL5%Lw#d(OlAT_wJ#(#{>(LVbn))qh`3yM3U{^?&?}|3V+c7OgE$ VQ_U{S(|cD9F~{N~JC0_c{sk`DkhcH; literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-tga.png b/system/images/media/thumb-tga.png new file mode 100644 index 0000000000000000000000000000000000000000..996106a15b91d4984714d7a6db6cce6bfe9a7180 GIT binary patch literal 2655 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-thm.png b/system/images/media/thumb-thm.png new file mode 100644 index 0000000000000000000000000000000000000000..e7cb9a24b1027dc39d37e0e7f65821887c5818ed GIT binary patch literal 1666 zcmbtT`BPH|5DrQd5fmlf7E>EUY}F1bSVyprkpTq30xFdvDtM4m!2rf1f_+gbD)PWm zjEDybM6PhDQ3$7UDIoU^5eYCHAtYelnfDt126txX+i!Pvf7tyt_K=6OsqtE48jWV^ z>ayR9Ml*oEy3u@nMtZV_)Pvz!;2=Pw6~r$lPSW+ zrGiA}u(BlGXgB=O6UhprC<=Xf6Mb_Rugf3?>d}&TL>P$GiYOe>jdW5v0?EAyKX9i= z9h~At5jfFTLs2A!!4!s|MStstTj59EguDS3c)+Pg(7Y>%@FZ680B?IuPAiD63i$C+ zwBQ=4nb1uQVby7PLoPWphD!fIp8kP1zd#G4@QxA^*1&>;6iLF5d=TL&R2qwjgE3hG z{Nxytb^?CvixytT8ztoI6!EEv!cpCXoFXv1<&{p+L84!fXU7pSo9HUX-aXNc_mkQg za&8*QzJNCuAsNAV+Z$AJiByl{AF@fUS~u2%$r5!_pV8tttUMXbyNsltMV^NsIgvz9 z6@_Za=@I0qziy}*t4bkNqvV_t&F2wAABj(m=<7S=7Zq9rB55ay!4If}OU|jV%7@t7 z$3%ZE(OE`xSL%k_NO%@bJ&fu1*QzqRSe!ql5tKqF-UaCNX;age--~&D9@3u z)Zg3JbwA*9saM(mh0AcJRj7fgjr0R|nFfyQ&Luduq;0+Fm9smNtGBLbslhzKn?ApJ zs?(!MQ4ZyeQ&atx@?=^6sMb-hly9-s$*0@$8e)WFp{`=C-QM=5h+r-+N2~=d?%W5* zOf;>*;unrfBn`5bS#f&X{dE@X1H}<+UQV_aC<~67&TCKI;R?krF3eJA!dn@vaA;yE zL+l`2&f-G54ticIWA^Y(>_Vgwh5Ep!ZBM9O>2g*y6u&1_>IlsPIxqI;j%@>s4KvG> zFZvpjn3iCU>P4RzWGvzYiU-b{i(H_IO03c2@X=ssemFblKzWV*F1I6N#++-qGn09p z%$H}<1BM%JrJGwQO0dRGW3SCFk#Bcf9D(ivw(ZKuY-Tkm>-H|{x&|^9du?UQC-z&q zykW-lN-Z<`zB@)Bo%bO1ZCEryE5tFXJ?h?VIS8TO*)`o22r3$Y1YPQ?!Q^#qlV5{W6wG?eI;0 zORuFmVFwsw%-JO9c6b#LcAofAbAu@W2?sOJpVs(2=^m{9yxYO#Q_TGJp!3U^mNtxlwPeF%M;4q_jp{st5~DFq%{R>7F@P3rSYPM zGD?Q+Vsgz{fsjA`IkS3f)i(AMbCx%hZ*@6a(KR+|`!0N`5hs&5D^Z3Z9Da!BXw+Ui z*qxBh*Z_t8O8e|kV6T*#YCqJN?F4Y>uA!4t_CMQ%JMW!MXA1bNykD~3{;)$VRD%wH z&BEV{3r21POp@`NU0yMVa<`OvXxs*(d+GJ7SfvSJ9uF9CgZ^7ww9N6iKb@D<2v-0Q z0VI}ZEm%@5Og6MOQzYEmJ{K^#O!O->6Cq|=@%4I%kR4{bKpTN2$1ySz;4D~lb&|h+ zmFN%z>(N|~Bc+k{7E3{fapIr}r*bWDn~px)R%ffno)-CFV5phM7n%vW3JE}liN?t< zzOjgIy*kLP&&pSBZfL!l#cN)x$Y(n7WzDUDgXSQ^w6#OCrc-4I++MIK;BaK<>0>F#SgK(Yx2h|t0BCwm>|dsC$?0zUf_;m3`C}m( yH*5ROt}D}L18aL7(&t7L?E?ni^2=X)uUC=uxjU;s6V7>`uiVwiW52}l1o%Id5q{19 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-tiff.png b/system/images/media/thumb-tiff.png new file mode 100644 index 0000000000000000000000000000000000000000..9c80b01742966cb8963fdbb89d50556d010dea70 GIT binary patch literal 690 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKL9LzwG?TN?!0V$SrM_)$E)e-c@Ne8%D+ zcPEB*=VV?2IlTcsA+9rfs#eaMuxsP8Q%ClE{qpJ8&mZsJzWV?F|F*RYk00D|=f>r~ zfBrmw`f%IYMeCMKU$bb+?#(L>@7eU{_wPm1+P18oH*aFYin;w8m(Tk7iOTlf9~JD{`c?SxfAM_&+Xf~X5seri?3Zey=mp#ClBvlI(uyX zBAd-Wzopr090xT0RR91 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-tmp.png b/system/images/media/thumb-tmp.png new file mode 100644 index 0000000000000000000000000000000000000000..7d4332c32e4ec3d125e9678c832ac56e205a9ac2 GIT binary patch literal 2040 zcmbtV2~$&t8jUT%2qL)D6qk7WM16>g4_B0>7omirVgpJMM5)mT2xvfAlAtfag++-0 zp-~ovL_`E+6J&KmgJ6)I0D=~f#b&#?$xU*z6n{g{%$aY#GvAz@3OYQ zRX%wFKP(al1xl&s4KR!}G$`_Nq4b-Q&@)JLv-;gT-R!JEt(GM+&lFW>k4(DFRqx+mx=Qp4rTo&V-!K_LFQey2^gxpB>zo}5=~AEc zmHii(rq*1z)7lwv-4Qk30WpHv==CDLr*K+neul7ZigO^=yt@c!bLOM;x)i{Z?qaY7 zwN~Wt8JEtoEyUWD<;!>jfW_u@4ULY)+lib_Ih%?0Ag3~a=xfO$PIb8zFB@3Bu3;oN zp3T!xz;~?2;dUeAbv!NLMXdq&ASd(+jZQwqv?(_ut(ds>(m8n7Zg6NX*4CU#>$(M} zk{wWy>~rz3)kKOqG^yn$5fBm(Gfm&OeR$7$ zmy{AfaG+~Y#OyWE*wyO~Zf^jZLaF{(uq?$=m%V_r)W+rRu%FZL;*GfO!CGI#hG@3z zXLzrjzPd=Pf*)Mlh=V@Eac5aT4Yb@y;H4 z=Y_iknaNy2&$r{3FQ=PRZ|a>q$Bf6mx1b`cEkwRMYH=JqnNkf+bhXjC+r8adSk=v! zMAa0RyN77Ae-C>wC$L<6?wWYj^u+3!Nm{x+F+b;FXNBns8>b(0rGeV9i5wW>&9c(& zY$hhMf9UF-@64igryjpD%WA}%v{`nqXbKHG+xaPVru9JI{D8s#5J7faspjcg*3z(n zM+>{3tizF(k>jeL8e~KG8U9_jJ}?+vk0bra6ZZ^lOB{HRJzBa)ux^(Ln8HdbDQq1x z92?3_d>+zg8Y-#Vfg>%qX@0Eu8F|N;$#~v*?N?T#(-sr(S60%Ro>a6RFY&Pt@(p2s zY4zP_#j6FvGdh0zGzu>sxpElGZTzAvakJtfK&}@(kNjKosGXQ$=}=|6^Zc;g%9iFh zfA8In3!RAcpFVS*xH6)r533S<5?-xe5;t@bMU+F-(xkDznbJ?o)3kyj3z*M@>0t&a{ zxquE)`;DDgX0M%Kwvo^iXj1IYP8}64Si+!St-AqHs=%_rkFCW91PqT?07N}Arqui0 z7{gBR!L%*-mZ;R1onXqnBd)N=kvf5p)9VE_%-&ss4O}1!M;ZgTo%;zZO^Tz~R>zM- zw*bmTR3IT|7q&Q-eVD(-e!YM_KiP$k;6ZJ%5*9EkMePM$EYbVi;u|8%aI;s1Xz-t- l+ieX?_I!K7{|-h=Bc`BX(~P)u-nnm1ULFVC%iK;d{{i#aC1(Ht literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-ttf.png b/system/images/media/thumb-ttf.png new file mode 100644 index 0000000000000000000000000000000000000000..d1e35945661b8cabde6a560291157a3515bde2d5 GIT binary patch literal 615 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKL9LzwG?TN?!0V$SrM_)$E)e-c@Ne8%D+ zcPEB*=VV?2IXMA7A+G-qNBzGs>;IdJ|F2H@|KZO6^9}#+Ec*`xmwW%;UG@LxhyQvR8K9RGhT<^QRo{})>SUu^$>sq6pq z!~gHC{r~&>|62?He|h@<`|JNFbN`<%{(mCVc)sT{pc`g-x;TbZ+%fpL)%3y zj-C#Srto*4{=I*ZX4blF*;R)#b2iWUa$eB@dn zK5=-*IfXOY)BEqvfA7_9?8qYMB7jc(l1;fir@B)=X}-m!npWpmpY`VH3A?+!RG9rt z{ckPfzRvYo5e9pEmoGkZak9eqxO=QVqYM{`ZaCZ~u64WG^VzDFT1&@B2wk@{nG8 QuK;9@r>mdKI;Vst0BzFXKmY&$ literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-txt.png b/system/images/media/thumb-txt.png new file mode 100644 index 0000000000000000000000000000000000000000..fcedca47788930f7e90a8e9122bbab51f8ff5f23 GIT binary patch literal 1853 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 qANPVvN>blvd#wGAxBZve2V+{bN&I`34a6t literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-vb.png b/system/images/media/thumb-vb.png new file mode 100644 index 0000000000000000000000000000000000000000..1d2029b0fe9d47dde249a8ee5a4e783ea73db022 GIT binary patch literal 2074 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-vcf.png b/system/images/media/thumb-vcf.png new file mode 100644 index 0000000000000000000000000000000000000000..450d349e3d7d4016fffa6123c54842656ef87d74 GIT binary patch literal 2581 zcmbuA_g52!7RMtkieS_}ls>UotZ20sc@+^6L_m-Lf-DI$KqJTyME0g=TSQ)&6-7Z5 z5+IB)3@eO)jIa_2Az_3NG9ZK%vIBvnwEsfyIrrRiKj+@>{pEYk&2)6I2C8bR0ssJ@ z4am|N0638QcdC4`Pg?y4<@@E!N9OkC06=4o+OEIyet#g!+1dht#DBv90F)j$-gC9O z(O>+G+#%cB+mp*J2R}^nR!SCz6$(YtRGY=%hXuj9Tp@QKt&%Gg0eGZQf3fRGrE!0W z(=a?|?(@1-_?C)|nQS&0C?RdJKhj3KR_4UJJ0t6Kp-c+GmbEOCJx38cd%N~SFv;%D zqHx0(S95FN{lq3~lDkw!8>R49%NNI{wwCAktMEnK43A?sRBkp<8Z=%{6|6rYG%ni9t=iv(Q zXcd1)ie00b4!oDi_n`PX8*Ewk%G{rn{vs-t$X(8u?phY}JVp^NBNc8Vm7OcI0-5x; z=`PXkcJO#ThC{K!e$1Zj9a>-X9;-$zksgdxzF!z#k??2v9J+8bme}%>=2+4@#Fdr;kl?v*aleJ8OP__*jtGPE+?lV?>XYj*3f31l% zRY}Lwgd5v?yS0po*YktYT^UudwkzMOXH9Nyi+VT|+y>*tOm`fyC7#%tJk{2}N^M>u zg^V|hZ?a~2oOE(W3aS17vpt3L1H}tN(UYi10xE2xsf|6oCKa$mTUoO`X(Y7mP`OC9 zji8S+M4JsP;vciU&&i!plPLa8E|O6U7x6Wmk2^J?3x^Ke01tYL2p*=3SVttnWx;A*Z1 zy(&fnT(4TghSxxfjMG41TgeKkyosCQ1THX*f*gztm7b;C_H{Kmr9|FGlIo)tkG2`s!8tc%rfBkg(tKIzQgmPB{1Z zzR^-|G6zoe-{=EEcVpG$`xWJleLHeE#8#e#qN9oMZ-i8Th`^P9ZXnXso_-f{7Hq2M*=MRB777( zWWU&ys;A}w7S>MM(1V5X+nqp`9UWxy8Zz6akHKoT!2KJ(hG=1W^JjrpU=8I-rw%{e z%-0fB4@4j6L0oBV26IrYx==62 z>rjoM%BFblUhiO+nOM7CaA>WWCkq*I4l^0!iDva^;uX_S$vo)O*dF7(Km2!+9tWhyfFfnEy*h-AmRdEj@yynk0*wtH7x8 z4vT*#V!=CFlSr;ayqa5ysq{?z4E-qZ! z_7t`Ln0r834AEZm5U8&?8-D_yFXmwz_F}TKbgxzLj^+-~x%a6BOw$6})ZMU%=%6l~ z(xv0ndnD6ldR#s{AS*v~q@^(k6xa^YK1{&5Dr!ll7dw6p4~bS5HRapa;&rFhbk3rS zR{Q3vwCI;x4aAuh9)_y-2Nl?>p_hanfdqDP7{ZJ82tM$iCrL-z3~jOp&AR?mJ(V zAFoejgS>BiC)wmN-(??$x%QrW7}d&enRJ=_ak@OjG(k`A*UImEX`j`y@h_Pm+W z%lOvXxE5)xd!_Jn5e}NzGG6n?F)@WgbZll*rD{zNw3is~5ct_bZnPo*NHT_~R03#g zk1r;`F662nWho^fJ`jti)EhN6jc$mdL%B`%nU#tScb(iHHiHv$Vpfw3jESKmG>Dg^ zL-;(oy#0;Y)j$W(u?^~PT{~n$Su|X?O9>GOO$6ATQA>-ojY{z5e|l?H9c*`WkvGYi z&BC#G@`<-*KX?`=yGu{Q_gb#=+$W#fg(xA`kyFRr+IZ#cc#;WOC-vG_Ao51$S#JoO z+23_ChkeT+{o2{?qSZg`N$4|(28t%Xk`;AdIsbKhh5Y49pHk}eI-Q&pnyusn2$I%! zQQnlu?*76`@p1Isw@cyf$3eY~er6`wn4k(73tCPrijG5$N&W*$R}ag>*?0Gr$xkze z1#?u|=H=DN(_u~EBX!MdizAin1>smHr-X?$RZQd!-~IAm{IGvkgWzdj;J&p&X8Y=s P{{}Wz4wgs@zh{2|f}?oi literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-wav.png b/system/images/media/thumb-wav.png new file mode 100644 index 0000000000000000000000000000000000000000..d56a77d3835e96cd211d4e19260cbeefec74d183 GIT binary patch literal 3601 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 X4i)G4?TL2`|D&F$JX0=Hd>;CL6qK4O literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-webm.png b/system/images/media/thumb-webm.png new file mode 100644 index 0000000000000000000000000000000000000000..cfb6bdcd4bbff86f13c309e2a98d4449a7ef1803 GIT binary patch literal 3843 zcmc(icTm(xo5zQ&f-)FDk`B15OITD8abN`oaY2DW5JXT!l86W_umnMgib&8wkeq}e zCx;9}Sd}nCl$?WrAX(!4;>?S;x7Ph}_10D0Rb5wgcXdCXdQw$aefz~VqSpR>BKt5H z%>FCdnuZt*8|nMp!?Al)Y1z!&-8k(CdISuH@`@L+*$)^grr^8B0NECu z?nXwN;r2ox!-}~&3*@^%bx}apeIVN&?k;B}-#{0}q1x9#h9%rjg<3MB&`)YukhhS*{Sn(XJ3We(5fTe*j zEsMFe3KX~@Qyoy#TZl>mi@X4`4fG)ita%Bf-3H!T!1QdO&;x3S0rLJpSEgCu4)h@z zEPe!5h5#AX$V4kt9}P8UFgF&NE3?SwPt45~xT6Se%|kwSffQf3H5Z*51SwB|T<4x& z58lOK_>`|`5{$e?R>m2AM~p>U*7v5JqNih5pBZU?{B-D`w}SRs8czFGXvmhFaVXd2 z+YkK%_ZK+a@;N;UuO2TaVdcBx?XKP_w(Uf|fy|hAyG7X5Zmu0K)J4*5nmLToz_2~V zuhx*Rd{$Bk4bb6#3TyW z=|D+4b7`tc|w;B>h6>giWWcb}1)ELy;|y#6TtiO@#XJ+Y;&;ro z?#}-5#Ql0@CdPH}E`@$O->lqhb;zmwevYhi^g(ScZz8pl7sq8Mso&PpsjeF|b}#bA zW^K83P@iJ|iv;B;d0P3HLG;l^pI;Zr;TE3{*p!XU2^P+!ms@w;zuaQmVZ?(=sq`Wm zl2p=(tv9>GwF}4Q3=8i?lv}%-b>A@ER5^v4NF6xIx$dEv6ipQmjE)U;+&OLXpkmag z!a~Yh`%buE72A467jcigGLqC6t;~70-!)^BQ^t(lXQ!|vMHsrGC)()q*ifEi@=>48 zo1#Nnb-4{5~oiuRJH74 z{XSgI;BXL@nq!N+=g?!o*ALhZBGnu;o(vvrcH7K+|Ff4;wMu&s5rbcoDn zHJf2rOvX7&-${z#r$gq@xeedeaN^4YAHQw)Sh}o*?CQSW#oqP3~Z^P?iII8CxoyV8{a!e3FX; z&uKm`U@vRq9rM2l-(K;XU-e^OTWAOg-!LxR*RUs(edDRD&xz?NVrW~(AMkdU6X9Ew z42i$d<2gLXx7h_4`22jdc4aJ9S=+KN@xb05^NK?LFmGCcLXmn(R14}cpLD&Re!WqA z@KXJkHG#0fto?@d=koZ2xg=GsCS(TkxLcd$OmO$AhQ4v5FRMlXexxh;vRh+M<`?@Q z*{DjQaU{+*UB5T_o&ju3tf1jJ;*a&@lj*E>ub$;iB}zNhRMA2frCbprBLRUl;#I+r zM>+^vl=EchLBAXK#v>ZV^A+ehx}%XW0?5H4^; zR!Sv%USq~|^~hg0UVwdiJ2G_|d!A>gOZzn2Tsf@Wr7EB8^V=Q!@s|t*7hK8qyRv4J z8U^QMKReuwL-Rr-=3t70A8VAs2!EL_P#u!eVE%+)e30wlWVdJa3m*K>Cl5ncd4|{M zV*$FxqnS;)^%LNRv)N#NYKAwNyl-PsBMLLyVD(|VzUKj4n5}|+WUiW({K|}Kn?F;k zD3=~7qenUto2YjXyIJ>DWC%}7qqET1(Y3=iDZof>6(y<-Rq;jdJ4nBo5Es21+w`bY z-S=jhnlP=QF`5EpmlW2$7Y}i_oET8;o)mR)8I2B}s?HZUA#zONKtsuf&#l+~Nm8FC zPZRmaWR*Eh$zv*=qS&vzOU_7#Vuc=Lr>WyGBII6tcinRxSJKrJ`m*Aah~HWO68n-c zdoAzOXja7jilG-V(B4*+iESrB(UWq)=Ag&vJN#a!hf3QeLd32rl2CPyfEYt-_^ zAve)f&937^i%NLjx=F+m-3 z7fvgFiJK-zIOL`Nkm=E1Qkprec_33+;<;23)%0X=De|x=By)$#ZhALDT11pXa?NEk zBRrSUpjFVyzS~e^Ne}!am?KRFqi7L4ls~M{r09jqeee)p@-=jETIId&B;;c2UdZ`Z+%uZ$;(>Hj1?9 zw7RCk)7Zr2weXr6Sy7zpFa53W6zV3_F4YdTR@QJtCZ9r2?AK8DpradovAs^;oABOa zT(t1c5ldUq2o;h%o~Sw~#v@rYeMh@Rz6F=~y4>y|pG@Lc>}*c?=@x6k%ctVO#U}pN zq)%D3dh~>B4?HI&Sb#4ll*@jvO1Nq~8$N-5xQSAdDM`*N84_irmrXw2F~dI@4gYZx+9TaQdk^#e2w{ zUmOVfNpE4y1}8dgCYltq`v8z9m_|>J<=)y`BxgN&QuCDOT2pv{uE~M5Qg!1fR{{KM zagyRmi4c@GZKXI*o@?WgO)s(U&}?SAf0Fo{qc-_<1^vbC7isZR>lZVlBY2IBiLLvz z(b1Da~$Bw6HE;EkJdd;CoE6CJ2n=FC*=m5=<~>s`ossC{jQ#; z6<6e6iyaq>Wg~$*0={tt!mb`C)kDLkk9qt`nv;rjJSdEbw^OUD&xzSy*GQy}2lQFU zDw%VhogQ~&tq*fhf(?h&3VS2nZuI{-j~lfZc|*q2xZ{t2pWE0&(2OKVpIkXNQ;g&W z7`1z4!9{a6D{kkthb=O^J8alyOEn@H8m+;77X|fys z51QDYFAvW@QQ%;`AL67ed!53~vP8eD)*q7ZeiysqB$dCR@6;Yb(mlB!%p2S%wrQONeZ#+)0IqAxk8(+>jZ|plo#& z!(`1m#te!vcEi}$=k=cVZ+O4wJm)#j_lM6<-|sn3w7IGN5uW2bY;0^t3=Q-w+1U1B z|2qdb_MFcyO}l%G6Mfm_G8h|aLJ z21~zzngZ5Do&L9+++Cjli3W`Dv+36Q*DbsC!Q!dx#j-2o;An$ge`2ef}+ ztO}-Dp#33qy@2^X==>YHqM-c|v;_k-7uv&MYX!(}V3P%t%`nprOQW#DfFJi@VGrUC zL>E|~!NMT41wmIN^v1(LI*e7r;t*^u!%Qd8J^(Er2H(SQ5s*`%?==i$!1fvpyam<> z%yz*r4q5|XZ4Q2w!pbCcJcgkHV3MIN6h^C{H3-J*p~Vm8$uRmEXa&%p3@a0`J_pmS zFiC`#`_PvN(=9NV13j_Oi-qxjVS@!7Phg!1?V&))gs~cE4TPT&us94qpTWR8=)?fM z4jA8IYZd0Yft&`zco-@Kk{7H_L0bs?A^@Wv=KF!1t|zHZVq-gGXQ+4CI%IHeXf5n0 z@l^Ao40ri&zeMb_^k?R|NUSXXLs9H-@~|GKaMe@wtHQ#1r8xuCN@r#t6~4;nm5!t_ z+d8y#XlItRnPlG#wJ;jREGU0=HeGrznEyxpS_pdOhf>zi6;8*CrYna}yOZ-|6%;o=#|j(<14yebhCHN&E;(Cvby$Ks~eT~x_5WeD^MxK znS6aQgXUWnF@qbPh1cxJZl2kv2|o?Z5K~!o7gro)FgTS~<>)10JA~I_wJjsXqVM;# z@+%RyVwtZ@hH9zS< zSZ+zqVb{;NMr(YVK<)=}g)V;csv)2BPJg-4L=O*pnv3s(K@ujZqlNLQAW6*2Ht?C{ zq<}?E>30dz9^_sj0^5B(ETZ9vg)A!vM_dbv_Qid4GJi5*VKJK4C}A*UJHd-DE3cQ$4J z)fbCWd0*@HBpg*Zb$sT=hnGxI=}=4_^ZeF2zk6y5Emp3v z1r6d&pXazM(@oW`w>w%pd3OzSLXUJ|Gt<*+9p3Os2;kzHou1J}oLp zN?X|VGDYOSqvk0SkCC*QexAklUGcFPmcosTbl$u_^(m>N-+0)yMtSMPV;ml@PqtgI zs3^~?=SjL@@4nm)MhqV7U2C2{>^g(EUyVC@CE=BdEGwg6oo}>uzrK{bctXHvZI_`r zqOYY`+x9ZTU)8^^E_=Z#7Nd|nM&&hID93@Y;P??^so( zAR|S`8N61*Uaj24EQ?Y~O02oLBrisbwx1d)IA)e$*b#R0HmA~j46ZY2vwtWn8M92y z843>0@GyGH?y=r|!e>`HQgTjUKS9shC1vI~VWiUZ@;RcKHimR)ySq`1QTX_r9QF*( zu%g}nINIqMhSch}N_hK4?P2K~lt;N{`40E(C96y%oolzimxq2+P?%J-dSKFJv8zGF zshm!05dNaCeRji);VhvoSE1YX!o+8R^Y#gyT;fh7Ee4DHK&Hl=QTpMq&q3-iU8wnE z;>Qar&)=eQEJI59O^SWrB(1Y=l$=nnicDP*k6^DZ$T)J6@Y-MLJ5ouL;qOI|h&O)H zw!4JQ%?fA`Cq=uXolR19a=iouNt_mp$M?vL~t4Xtzj1G~4@&+yT7 zzF>}>8N1ODLDiAtuYNgoYb`x3-YwAQ+M6iHpnYc*j4S!C8{v%dvM34eCG?OMEOj2rAFsya-4we4y zm4e$wuWC=1ZDrMY=oojG(mpe2ym`_L{rTmTI+vBY0FE@--PrD#VxxukV~bpbD``EN znRw=ve!6e=45KrCxLW%n|NY@W)IsK3&%Z0^T0yo6Jlc9r(wPR6 zC8ML}$r4f8aaU>`ZmR@TYaTr4`hMaGS(znF5p|Y4H2d6!vT`{l={9Qa`DBxiAv=3Q zR;Aor0NNbkpBS33K93e7#jUg3p@&{Nq0CR=Th2*Vu6>xxWQ}n4vTLOcq8qknaTM}D zSfr`(D&A!1*Q9{{ozHz1+If{n^4r2HS@&NIoyL%Ae?>C+sGIVKcmvp{vNrou>)t-3 ze)?*5>3ZMY2Pea>$r|3TR>^@sYboC?%@HM(cqN?8wHPgo_oA_?!&=BDF}OXkD1+HT zHSP0s2w;9#KM4nnkLblo%;RM*sob<%9_MQeY3q5|y;-pS(9h}t#{0ZGvR}0KH}PM> zE7CT-c)&CKCc?i>rRXlr>f!ml*o72+b$kDNN*zt!ys#nH=S;uDBe5nzl}hbc?F>C` z&FA_ul$-B2km z&8V!k{e;7R!_+A->)b(vr&X7oLUZ&%1m}pqB{ z*FPoK=cpjX-&GVuGxF!Q^u$SHuXdrzGXvC5ho^ov-9JSoHends4445VuPG_H! zkYk#QC){`2f1B1bb4pTnFJsD{WNuUL>eVtnhn$lhGep^@qVT$+p1&}$H=fYV4G^s2 z`)h(?@5-K)g$B4{PW+6k*xA*@3!KF%pU1vkjdv{OIjE;JYw4^xkh|mOspE1~2tRY@ zMvhFdx`beqs*M!-{0WSDY~cBeDZ~yJ^odIryel?EN&QiW<#qVCH2zr9We+{8I3}h$ z=9}-QK|m@E=+~%tbB5MzBj?kFEYae+>?*_iDARbX7A;M{4iPXbwUcex<`X|-nj{gW z;pTNXWEp8oE;KW#rlml1av7@p+Lmmp_R^{1^Q)m8DosL+KQHq&P9Eev8&FCWvqK2s zuizD9@PrSiZ`cTucs2+l*?TpNEv|2?JFdZ45!?D?yJQe=6Jxg)dCgWZYO(&+1|dj; zzdC4`r^}r-?%@+7&%Ys;TrPCyoFv-lD3>JUON>Nv`d=&vm`Ka96QZ28yf+J>mAz<> zTO&OZ20e(8*uO;#x$Nc-@Wx#*gD{iJP76ghGBz(INALbUdLPtl({`{G`0Uh06 zh>N%k_>)1~be{cL7#vjfRO#qy;#g@mm;UVk@-Wa+qeBi{U#Fv^N4?ZF((tjj$V*A^ zb+9@=KRY`+-TJ)|8|-s_e*X0fydXX4_~?jwe0+Ft;QPk%V1NJU@Q_A3p;D>-Z>>Lm zcpKyd`Ew?~(PpT(+yC9`)6>(bv5{bB`)}11NwCn${I4e`Co~!@@V(vS=&+xI_2$NU zSx#n2c3Lm41OCx3#N`cX^LKt~Vsg}{`pVMLfj-I}8RqBSgKe7_8H(_6otYf(YHJPi zbPjxP`_a{5u%|0ECOqwPB!x_#othv_jDPZYpNWXBD$3hf`}H*uzVvfpb$MxSdg}N3 zTDZ4MZFzA`NkOoSeOnWnyt}hQB9-T65f|o~YpeUZu-S>9mluC_V45O*-I{8vFb%bh zHOO!;mxWmZ)a~8qV1H#nPH|Rhbb#j`dH3Xmy7Y7YbBOOeVS4@7N=-?APEy?3>PlNv z1F|r8e0cCjV|_wca8YIovM>kN+T7dOQTjE#6hN75UlCb=B*?R(E%{8<1tinJEi1gyFuPoo!NGd2tg8xwf*Ln;eh$6j+g$jr-n` z6(5@%9Y)^SDa=UTr%)(+dp~Dq5+gpMk>w-(y@=4jl_g?FOXJ?|?(*Wo_iy!4{vL6m z0d*B6v(uBPvck_Helt@O^E1aB zN(;?60&VH&SR0?Ks~Y(dwq_4QuHpE*c5gCEUjFNEwOi^ykXnlHRH~i59->U1b$eg+83`J$DlqKT%eaZ4L-l46yoo=(;(OHko;vFg+*U z_0J!({15R#VC>b@JL&qT-25)mWBSH&tFyyyl~SaAg$<4wht=%IU`08w-W+6|O5=n$ z0sGKA8zaWtR5+$=F7G9{*h9;lk!N8~!7+bJ_V%vfSJG38523mY1i$2 z?u>n|PDOb1z}0Z%gKW@%z?Spa-E7dkM6H>m6Nx6XaX0-lCYdtRx+CieHGO;)*wAK+ z2FaCb1%(#2%HLp7hR#-UENqsqJDN2F`7yc(D|E}NK<@5&Cd7PzN_sRaAeaL6s6TjN zd_c~sLn9;qPm(8&JfsZ~`wWv;(Ui?AQ|E`Q7)Y@ZiM_x%y!d&FFTm^$r?u4va_tIO zBSw-z=4HcipicY*m0)3C{uU-C)9wpk-k6o*pPv?ZhEaTSZ#SetmttDfr1@`CVIh8S zzkYpclst4d#)9kWGijfgdu%PxRNm!X{W-Z)kuw!@!6xknf1^6Avl4q(p59ha1GhzqSUcpzQ##N%ZQ!(Q1Zuer0_mG>@db&VWpI_#SMX4as z(yQi{a?a29!ZK9^6Z-}E#D|3a-Lm1vwU*pdyaM<=rsar%SVnS*q6Y?3^d1~He)}%;;rtrEmqd+4)*a8=LAZ6H^dnHPgKK}1 zsuOT!>xRDPNFi#hIgM{;PC_8KQZcB<+pBf=`%auL7rrQJV#0!32=++j0PaoLcYi77 zE3Wi($Nl4C+{R{NL%E@G=~=8|v*7(KC_~jWL8i$$6htVIRk})BbSUnUc~kAw$RBfr z+$ZZMgpCYceO5hzv73885lyM29l{N?%I z&$`)k%nxoeV`qh?y56brl$l#+uw9Zdl0OY{L&cqvTSUrDNiFP+0OotK@`Wvyc_4Yb z;bY0F)2eRa8xpeIu+nSJLYsbpj)KT*mgF@!ak-sduviP{`w0wReJdZ_igeX`b7s7p61(|5{68A{WqdB7dJ0*F=Jys-3O zJ_AVLV8nxR3-X{*V}WDCY1Gj&c87fhwAW?LEfmtpi?Iec20j!D~*%p+Cv>3vK;=lH(6rEN+zVxq=}Fe4!2sD zW0L0Z8F<2`<(1u;0Ey9GA?a^f&D-Y(jHoWzglU5(uY3Xj$!?R)f?jt9NZP@pq<4yo zmQ2FnBm>Jmc&Azjm(W?-E~CNC)eaXHUW}dvmL&w}@7xo$(|t;wJx%LO5ODE_#W zu$oXwp@gd-Fpg_E;Js2CH^5!K`-Xwx=KOFgB(Jkmb?i+JUZJelw)77MVIn>XNtyWy z97lj*!@AroZr4lMU9GXEHs;3T(kL-i$z<1W3|_>1R$IVvpIdLEXy7-mKp>Mv#;pz3 zoUVjHL)GfT&6>lGNDd9g5UnXiSV$+M0afD&|FJW4uiY$8I+{~m%4%!&--Y^nIPDXf z+yn}_kvtTS&J)>ZSBU5A5Nc_|GUiQyI32}~yde*zM7c0&jzxCZ@A~q9_+D4!lK`h@ zo-Gu}@+-Lb?h6Jp`uM@A8Ge>0*Pa}iCVjYkv5nd#%ZkPl5<9Kg$_7C=W?u-={C-@1DM?NwM3IBQUnLaT9pKkkWnVglz@ z$-adEC$kIM9(n&UBIQjPDS0vtIE5GoX=?JcN8Iu2RNy(OwR%ALy3NIV|P zZ850yNP@PPk)D#A)U;WNDDDofVzQ2rtO^;n^y%$coVsW)Em9?``az}^iZ2YdUvaN4 z@!PlX?H_Wh?`$bjGy^`xy>zzDZ-_n6;m5D8aUlF~mqzT?ixb^!Gulo1aqZ2rgp{7v z^OEO0ut)rO^pEJBgn1L)kqgoeFd}=mrnhP+#%0GCCD?kisBOf$d1LG1egPDpTRY*N zXa9nD@r1_sG;9+alQ7!AtJ0?;C;etbxIP0sobwDMI)RrHS+rRpTHoPNcP-+7Lqd3L z*^l#asJj$Wsj1GP>I>>k(LPDu8qWa_MAS_Tube5FyCg8>$s1yFe8diV+yx!AaJu}=eiD*NHJO#rbq_-HU+Y@M>RsI3zf3Ba23(inRIR5I)EDIN> zAI>PH9*E@#>bMrVOT85!u|YJ*pgsUnJAJZ5l(|6fXX4gH4JCEIhTT;unTTZ|&PvqF zfPwz9NuKtdfV9mctK8eB4x8@WGham!_u!R}a!-FhzqmKdzE)Nc|FVmBMk)rgC}x}@ z%5}OF`q@f;oqqZ!b3u7`6M-Q>Rrot$=wFD+12E9gE+9=Ey4G)G;|h6#*guf=o(hP+ zakqGhUYr{4Rc_(c1ERb@q*g}q)s9hdJ(RNohe{kthZhzE2=rwKqd-kcfJfQK%9|?r zH@zPPAlt^i{85R7S)$?}D@e$EN`0s0WbKr6Pi~OvW0WSt^%xM~{VYeu>(5ufH}_5b zLtFgVz+)UYBni!(@l_EKm!G7Z{`7(X3-e^R zq~u8a%CPOES?f8v6cBY58$BzLdY|j@rI9JS&*EXAF=L6D2+l5nWv;|02)m|)R?!sD zrivXkP<7d6J~mi(hOFa49Q11zhZPPPNGNQC>}< z>wbZx+@#r@>VNmhhUbwPgFyv@U|5`gNGdg3zQ^fea{S){4>)J?w!iWD2Oo;#qSJGY L7wSm0*P;IhE_R3) literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-woff.png b/system/images/media/thumb-woff.png new file mode 100644 index 0000000000000000000000000000000000000000..777d642c9fdf184343d33cdc7a795e754081e35b GIT binary patch literal 3421 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-woff2.png b/system/images/media/thumb-woff2.png new file mode 100644 index 0000000000000000000000000000000000000000..420fffd858ba5338e32e1ddc36bbd85f11082644 GIT binary patch literal 3927 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 QxBrv|PfT?ywVmJp4_}@tCIA2c literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-wpd.png b/system/images/media/thumb-wpd.png new file mode 100644 index 0000000000000000000000000000000000000000..0c4d4a5e2b9c7e4acb79c9a9531f26ebe07514ec GIT binary patch literal 3127 zcmcIm`#Teg8`qGeyCaooU7b|N_fT@J(FnQD;<%Gb2vNCLT31n-5{0O!+&jim?w1+c z%ov-ou`y$t&1J)8SKrPbaX!!UdEV!JpZ9q`pC8`mectD8E|<4&J+M_tNol)-Js6^- zv-BRi;qf7(jofQLtQ2NI%M|yXDg)C32uIX{i7CC}DJY@iB2|aeWy+Voh4io%B1iyqCg_<;jo4ldDn5h^CIEc@{&{`FQAg& zj+1WUG4HA5(0)`uZ+=m{j^T1{4Pt|_Jr}!MZVzJHSxnoWwwy6yA$5!+5`Lpkw6d6J zE(gEBon2ccE-hT>Zt+5QS4>R~E^y1ICb%L|H)qZlgY?57yV!FS{&FlHT{$%wHPA=p zFC%8AI@xn#xom*Pz1r9HkT@8C?eW2MH!>NSqr+LF!yT+yCsYSvkr#yRaqI1Do|_R! zBrd%jLkqmW(A~)dTnYhKGfi{oX-9HccZRTU#!2ZT_*4Rpw!(ifg7-vsO$e6n598la zMzK5&n#*1mi{l3|#S`PDG;00K6k~NIpEBy&+xc^r(KI^~J%D~kCEpz$h`{wttqSaV z+UjPeL^3IPc`*);NgV3WAQ9SF%<0vYSLBi5MczNmX=hZ&6;!)OCIcherd9+kb2Hx= z^p-j1$I0<$BtpVaf93R~dvE8x;en>P8Q91mX=%|L-JLs5s-C8?g=CDZ!?ztu|3uK5t!a{Q}T^f^QJp)g;24--JLIwhSF)rW zTXO$Owu&m^{!my;<-8iSBP3-lseXHp?^*mb=tP;=cOy75?n^)exTsca^!u(E(&b5C z?~qoRE(|6!?&w>8dd~F^$^keU`b^pT>hA>zh(#M_G@w*-{IH8FWWU-lXXOlv(qE#! zEfjvA|4BCUq>*sD3HjVIsN+iudox$xtt7G8p{UIa0(AU9KU!6cw}iWVqr1Gn8CQ;k zy~uHn+$QA(3ZmI^#c&3!e8MG zh{wEz*BU^%2X3|^TXYqf=)>G+48PL05le%>X|4EM)tLv;eTe9|U!C=m$5gg_akCM$ zfw?t4)~@q^;jnUveNoz(RN#%U!Mit@Alwq#aB=f+;10JPl0kpnU1+ED9HD*PpQ%8~ zK~^C_`DBwvhsB-q@R+Xi*eF{NP*HMxEyFb7P3GYw+Ubmm;PjXD4xYrl`o`w^t|-ucbUQ(vJl(j0R+!eDJxfswlnLu5?+&p zyW<}fEK0v8ey?#`g!m78dzqV5cw; z)}jyY0JBf3ua{zhNtlzQ;u*0ZUb;sLv~zj-AyX5*f{6C220>w`+xsU$difXIt@=*Q zy5{_h2~4agk9fQfSd+OMZAA&m@!$i*zI*Av#*{)vci(lW5M~57$6QEA8pG0G0NxAU zRafrUGK4Ef7E?^YwETge$qlIVI7A{nAV}(O>{%AN2VJ^A$=-<5GTaqYd_4yVL*Gw% z7B&XqH_;eZG$*(=Cz_15gs$a4?j87;jsxilY{F+J1jP}UqO8j|S9GQ5pQ6<6(YN!) z_J?7_4-qMC6~Ujv4!il|KzS#^O|Ed?wITi0`ovyGJri^djL*Np?ePlG)w8#|5B*b_ zVB$qIJ(#i2HH9Yf?sdTlSKxJqW4U~Fy)C{B6lL;F%=oGvdhJ@Vn?Epb|BK(<6Han1C$~1a&flq1 zpfYvs`3qENA`Ig)U4Jr%0oQ+Ef!#)a~aMOv%$%4KT1m>-nCH z(I*WS#m^ew&KF%MLL?l~7)+h>bF2lNESPyhFKf1o{Su`ixzaIrk)P0PF%z&2On0R- zQ-L}iTjpWNQB z$1_8kwv~C)qfE!;AfHEooQ)tV;(-CzFW{ys!gE7(0-HIp6HIjs3*^)i1rI<6ZtX+6 zB7a>_vrQ=xX#ifDwYgP{VcPFWum&eG9mj5S*izOZH>~-da}Vot)%3U*>_t(tzI)mQ zLu&X=aG)(MxJCk=JqB-XJ(?FYud1fGJEQTf0lOq7qH(j)*Ykk=RQgiO({oE-t8dPc zLz$mqz8LLAK1)>8wC%Qks>p|1Hq=0cq-MuJ%P8H<*|TuOh=cksd!&JNwL;>{yh=&C zrv0?a+kK?GCy(cJJ)35~$JGw17%`%635FUW<0f}i5{?u~9Dyr=&Te}1*Xv=8pEevz zqaMv4g*BXUh3H8R=VD**W$ z1LSY7zOKPQcIoE6$MI?J2MS(or)j;b0zq6&s#Jy>A#1XdTf=vbwA?yq&w0Odv@*1k zcp*uMa@H_ni_`SsQ13Nu(zBCLoc4qG9z3*@M?m73|bAR`o-?`hl=SJyhsh(ixWoKeyI-#znjAUXu68DcD zKYEDNJZfVc8Wy|1H2-2^Du2Uq@aWi~f5a21dY7rR=i(9*6SI)c13eXJHh~rk_)!j& zVc1%Rp#%U197=x_47>&g9eQ6tvnd?x0DT|md+_5UY?GnI4DgZAW&?yIpzi|X0H&*< z(*uYpu)7A-Nth@Gav#jr!{!3)(O`ESdP0CU4ck9qp#^$^U}XTh{GinmNVzbIhPKDh zY6b02pv4?E=V4*Ppf3!5_Q32H zn5u$y2N=tT)<>{03;nO4)f#qI0qY2R8?c0hfmoo7z+4kdmcl9#<{Dvt6BgS5>j3R` zF!VPpcfoKP9PC1e3rvsbom2q20C0}rVcuu!p;heWC1=LW*cCp z7RHNUdjtLtG3fROLIUh>!E`Nj2g3d~;Jl&J z9S*i(I1?s505KU>`l0V7P^X~xIqXni{wwsN^cK!bGBL5E)s_F!^BY|m-wqtS$KSqg z%;(b`aKil*I%04L?PEM-Acnd zMrQ~5_%#LN953h5{bbS#e*b4J|9^te2&R*k0yQQ}%`I;&%u`9GCndeeRFiwZ9Ggc* zD5HIF<)bQ?*hVf>)nj7CX50*C(`1Rda|46kntrWl#V0YR40*;K#AVEb*(m2Z^gHk3 z_U}kU3h9?Krm90V)wN|joTlw*M{;PBZVM3}<`$&Fw!2(5=(3nFi^Q-rCAR+L&cEBg zDI}T;GW!Y;r_~Vt)bFu;9CXujdvDA8&KMV6uTl(%vAi)5kf!?PGO3>N;Uza}EIwIg zQg1%qXGP?->ie^{_lgpZE{$M1J(4VAPn7#^vhpxr8IhE9e9)rvq$+qRG5qd_oE)s_ z63gl6r&5em;!Bc0)lTv<^>afJkyE8~(J&aVvzR1~%(6g~V;G+i6}QMe-eD(M`i09J zqS28QU7D>D`fQGJgG{%gb-T%?AmwBeZKAbK04u$6b%h)@XRes}rfw$)xNm90DoQ^A7V?vHCo6M6VSJMHsbd3_r4 zDJd^>f^7zybx_o}U74Y6lT@xEYyV>DjS;0c-T3m2rbiqtzFtFajzL|FGuwLgQu+e@ z+hyEcRY!${9eg02rdJho-tg<^?FS+s-<_9c_iNEo6`F-80GZ!sabR zR?dgiY~BfHSC)_$iSE8M?DnUzAZDAVfd18B;o$bqm3Qq4^m!3nWbb`o2d7j}j1bUfEbJma%<*4X|Roy-&C zHwDx%4ezwpHP5CVO+DQwAa9y~G4f6t)qS$iFqDBuJ8H#Vmh9M7th`DWq*N2dU)MBG zAGb7hLVrYMT=S8`Sy~hmDn4C&Wt_hMMMR_J*rjzB6DudJXkGFr>wy{v7Nii4-nYH< zpOV(vW{+Oc4Uc#2hSl`o2Sz48HYbpQ@?lw>1}&eZnei^^1N#bufOE9;K@PO8h0jc1 zk}#z>fv@R8I7=X1h1{r?e+pYuF{U{7wrX;2w4X=t)Ci}ad?k8d-@o}3j(#9f9%vzl zvd`t`alYM+FP-7Ty4Flp)hjWI{ zeC(S=ayV+5hrgibqxgv$QC`fLq&Y24c_xeNT&d!9_&B$larZSWSJUv;wdNa3Cw*Lt z1en9Elvt_JWR<2HwNt6ygEliQ)s%5fn~L)L`soeX6YsqOd+A z*9HuOJ}oq<2wnf9+pT-}`L`Aw)U!OUeVO49`L+?lCkjj(A!sdFvxcs|Ym@uZVxsY| zK_T%EPA165aff8)g?!7QJvI+cao<_YsdpKoQZ+^fx2Gg0YkM`71aipqQua$~9pOjw z=boCc+q^YND+o{LyVilZ_xQDg+Ejuop{z)G&(wbU>Yt5Lf5f>B{HPjni9c+JC;8(0 z2Q4(cVjO&`TcsSV@R1W~d<@KWE}It=GW)%kB-*UL9do<}CLp^g!Etj!(_k-fSr!=a z7iVr~`Xi$6a~9TEJuV80?#xtI)+*j}rKnop4Xg)7C81A$EY%(1y&tnsGylN7s59t| z7;1Kxk9lJEuUxwg1@xZ55r|Dxp;Zd#z4^EcfDE$_%xVn?<+;c;`WUtM1 z*?=PAjcP8$up&=A>osn$LRPRd$fC0UretTZ63M$99_fp*zKKBBOxdG3xBi}N8X-8(L>DxCtUn*`?|d2$MZB@)BPhd z+p1GoB?E}+X{Va~pYOolHBtYp>EZ zkayi9X(eV$n)Ut;#~k zE%6TJ>W@mbsrJA7xSZ9uRH&^L5Xg4sMc~Ea%_pkwhgjYxFkiuPlU=1obOk<1L|`4P zso0$q!jJRC$_>7eBwgHV2|kp)Qw{Tz%Z?gm)2C#aN1RK%RA{@Os~lZATo3|n4??By zjhc-6jvb$U=mdYKq;EBr4Eu`KqhhXWUzMrj8Az{^>`OwX3a7B#>5#;FAfg{L7*#sv zxw@(@#-m8lfivQkLeoR>bBaAqD_^b$>h2aKzK)$mOXTxXQR3BDGsi&nq@(Hw=Nymd zmtl@HEnYO>eIgO*T0(tJ)K5B!cMAXQXI`zoLWI%9v{3P%#FI{~a@{ETo&W^f=C|<+ zNiPa*>TF9{at`^No$d3}x)QN=#$0)`8Cj9#yz0dX<}vG+dRVNVy$+{TotVF$XJmW( zR~bW4byQe0)VsC0jW+WY<_fx!IM*tC7!5qn?6B6GBNnW^rChDI%Q|XU&mAepv#i}D z)sk!EBgdnCfq~W_B|Qz;2-L`RHX#`RE-_p6BKz#CDUP+KM|iy`Wy2Gy2&pVn{)6cm zyCG8xub^*e`O17ff@Cy>Q_5+`jU|V1O52+se>R!gmy8^d3+I%;&NOpJR+T$uo^R;I zC?S8O>Doko`cbP*sdFHhf3ry=NTjqFaGgQ$p_%;7Q0dH|G`dOap zClrwYOuFMRv+jTrQP7U7HYuVy;XScZ$9GGj`F!NE-+9s4o%wTf{6Rs+U&;rM(Q&nM z8K@#y8kmtr2BP{t6>l0s+xtd^U$;XfQ9y>&9Ql4!8Xzp9w< zD#4Qfv3cQud6C(WG_qnN<3=a)jdO&l5U$G3%Oyf`B6yKwZlQE$&XygnO7UHt$lkYF vIvDY+mdr-J^Ge^dDRKY+T5mU8J`s~7(Th~gR) literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-wsf.png b/system/images/media/thumb-wsf.png new file mode 100644 index 0000000000000000000000000000000000000000..82ca9b2b42312deb2c1340cad47e58e5b7662314 GIT binary patch literal 3028 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-xlsx.png b/system/images/media/thumb-xlsx.png new file mode 100644 index 0000000000000000000000000000000000000000..5dc6163c7a7b18ecd737d4a0e91e742e19d473a2 GIT binary patch literal 3481 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 literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-xml.png b/system/images/media/thumb-xml.png new file mode 100644 index 0000000000000000000000000000000000000000..1faa621d301aca1540599e8690e75520529d01e8 GIT binary patch literal 2557 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 M^iB0jAHELzA9Kos8UO$Q literal 0 HcmV?d00001 diff --git a/system/images/media/thumb-zip.png b/system/images/media/thumb-zip.png new file mode 100644 index 0000000000000000000000000000000000000000..8e6abd48bd730bab1358808821d64e2f8b10857e GIT binary patch literal 1628 zcmbtTXH=616b&^f5ET(UfFh=jDvsKMqE%3XC>wE$6-7(csE9QvNCY)biVBV@Mv$sl zR7k>JVJjJAk&sCSE0BDUMG`VdNQ(W{|GsnHxcA)me%+T98xuOqWq}I_1ez5d7P1!v za>)IfPSd8C+7krZR0E%eMZ-X#@*IfugyR%;K`-K}- zO4x%X+HCfV4U7rvoq92g*q4&8*9f%<&0}%3wBIC->t>R+}vvL$FTT z{$cg)#t@H`cV=~PrDNky$bxFYklJ9*A@Zq1$`VfhLn41@Z2V9eIk|z6RL`JF75!>G zvW0b`ijvXNZM0Z3Tf18%@(N2@n9J_v6@=W z8;rpa|LW{HTG4s?GefrW0jP) zUVt{FWXe=0tEozZDW4{4mdL;Kk31mo2Q}kYnwaFlQMJ*WM-|@x!bMX>?R~(2Mqk|B zcf6AFwo5W*n#d;bUem=@0_m5&k<=#UuN9qtGbA-VLtQdeuS%Cg;_J*7yl6O&B1GW3 zo>PRUYG^p_;Q2awC13iU)%%JjsuvFTsr4z1U3ih~&kkODFOWoX-}9QL3>1eMI!pCwvFlBD z;qJx@^`>1-=W2qsAl}T3c9*UzsBIJ!hqya>Au^3#Ytg=8^^GR4pV6yG{0teHxgXXU zpOn7-K(02L=gj(wLLK3sphHGSI2NjtxqPKcz#j>Mv#1ohnK;40xu(RwsvWma`f9oC z?E3E)^?0o1fPI&{HV=_7WJ#s>nP`=$BJmW3e&;0Wf!DRAc*`mO!YGPa@2uUo^2abw zACh7#r)vc>HKS)x7Mqw`rb%g3IPb%q2Lx5br;*vS=3*bcDDX3MW=0~A^-eAhl9&V7 zKagx`;)rgB`Mq*C4vt^NUP)?5i!T{l5g_wUSOnSN!s0Dl9G9ARojaEAKY!cpBaY?@ z7I~d50F7`-j8Da&8ihXVEFm{aiQPWJyE`7Dq5x_j8|L7b8HM!zkk-a-foPe{5W&48 z{-3rh64Gx}C;l|gyU1cH*s`(F2u}~F2n@Y>nDHoam-49z-EXu|te{ z5cL?Uz3dy*IuXcTOM2@yH^lHd>Rt-7GPT1|AfH?skbH$sv;{YzMEL6;!R2t^nF@WV z7&_y*rS&v-A|-0Kde&tpzf1CdS3GUoxuoJ{Ny zHk|C#G2Pcow_Mn~DZ}@r4;cQ;Kc^_@H3YSu8u?41=W%G*XGJ(N|JoWS*2>hX6+u@5 z=S*q1%foYQy|b7nZw>IR1AVcn+a literal 0 HcmV?d00001 diff --git a/system/images/media/thumb.png b/system/images/media/thumb.png new file mode 100644 index 0000000000000000000000000000000000000000..1efa0e732d9cce6dc3873d24fb1ee2bbdf134ab8 GIT binary patch literal 1200 zcmbtSc{JMx9FNkckFwS3ZLd_-*j9A)YMt`1#ZhNnQ&epeLycP1S?5;vveLKdYR=~v zD;gD}qmG0UiG+xsAR(v7;mS?YM9!se|Lp&M-}m!A$9vzuKHuu7h#(8IGiD$V$RZ>- zAO-|7di(|_j19>sWKL%wliT3yU=V1i)?5QQZtzBVF+qNyfhF5LgN8u-JYk*#_@84pkFV1<*Oei%Pon9YtIkGtO83yzDB20X|=C*cys%^4@tyd*H>};y@|_&_TufYSp{*Az<1-1^Ar5PNo0E#i$keL8n6xI^`WTOWYkszLh0wc6f5BnF zSd4XvC=NHH&}e*6h!FJD9~9C&f3KcGDqO}Ns#K#q_A?ee4L?^+CU$Sq@_>c%6@utU zSwmjQ1s2p=E%Q*iuXqgz`Plv41RP4O(@yPhLoriz6w-HiO!gA4i%HF2UigzinXrc% zT04dd2>{35rwZvo@oP~aD8$4u-v}IS0=^S_JbF;UEEP{GnEG)W;NA~iC=|EL7swTf zv=Nrg^C#2N4DG(fNWMknW=WGbk&R(Agz2Tiiy2mqec5f<@(|bq_}AFfYF=3BfqOP# z1s(N|(@}k~Irjp+S;zHA#9KSBwI9ukue6_reVO%*Qypb0sfXPk{!#p>e{WaW^`4ZX zsu;`|kbAqSn3{PKM(ic1314vLBYrFSHL zmg8T^6Xw2K=3S2b)b?rOVC%&a8xx=`nPg-6B5tS^Zdp>BuFE+0j_LUGgr&YpX5{4^ z(w2SWY5dLmHY09%6%Mwkbt#@SYgt8`XU7TCo1Fjvz+dB#`1DcLK5s_gmn#C zeqMfUGU&;(-+Uu2aue#Byqv!(m1SD3R!ywg8SeyC_ZH@rtE92L&QjY9g7-B1>L+2J z@i-K5T3e%2%kS?u`$YKEIkiB$n|&JSjZW<%K@r@e&kD#%ukGabp$~nvf$#~NF7)u{ zBiM=B7PRbw9Gdg;TqLG|Y_}RJbS({csYTU>2~N>Y0n5FO4J_01B?0U4Ucry-h$Hou z?2b&d$7udz&yo*Js=~8;+(VH>V-PEHBmV4FcyQ{KtyNfP;K@qx|JUsy*bTRt5q*O- g^xvs|?5=4{Sbu@U9`+^(35*CB@Jp`z7mNsTV*mgE literal 0 HcmV?d00001 diff --git a/system/images/watermark.png b/system/images/watermark.png new file mode 100644 index 0000000000000000000000000000000000000000..0f38a75c40d48ba2fd062a82eb50090f4cc61133 GIT binary patch literal 95789 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%0D2)uoNCMzd4 z4#kTL#oXu@n|bCUT5!dX{{bK=muucT!s8qNM%;`z^jbhDM!bJ^86LrH?o%%R6+eKa zT*VKXBBH&y|NN8X#hm3vF-YYZN*%axg_A?&jbNBY9whVQs|4n*a1rf>oYobl?Zc`a zvTE=D;!u?;M17i~Y)R|7JnVD#H5bvY-&l*J^!X;-2eDzot+=>Vn5RHiuoooI@7`Za z?MpvT{5WHV6v@!FyhS(s{;KfOp{nEDY5^OshFiUj1mNX+gA4{Rd&=`2T4PkkPGxS+ zMbU6}1PonH1t#*^?JQ|9hxN96P^JZq=r4-XIil`PVr5mJWTF4gUKVHe>e@+13Y<`4 zNAQq>Q^62XL4EUYw%F$YTZLpN{YkxzF`|pV8m2gA45qW=H07gat$xR0lxLNQ`4;YG z*LxUdO=$~w7zuO|=V2!1hk4TT`7CSf^_nsrRk58yYw&v7>mv8gr~giNj-b5Gm!%uB zS-FgcrNeIYdt!2vL6JoyDxx&nacQ#5XOi5xpr>&W{!cdD04TfgD|E38#c7|<@sU41 z_T*1!*&a{zC9CS~ml*p;PF}AZo}KGv*L<}AT2X)m(DX&BPJeZ`ondl9mRnUK+&&p?#h#8<@)BktPi1`AU zG}>IKg-A*qyZ8lU+mQ$);(-$uWl8c;>Ky}Hw7%O3X<#4|Kc@k>%l5as(mnkt1}AyN zuznp3S-zY@sp=QYRDYaQumX;`g#!IWyQFd>Lj$E;a}>TSm-}W2twH&#ua5Kzv4A)N zlIj4UeHi=;&~Ci0IdOC7p*`hVnP`H!K`M3!F@tDaXI1P|+Ij{#ty!1?mJaNj(>NS8 z%UL&SxPbORE}&hO188UB-{k>-_AU+bgds)$%N~ee2k-%#UgB%Fl~)i$mqSfpnGVKm z%xIF4^rg67G~j@S7+~>9eW3&XxEB=?-j$BdOUy>XdB4;jHkN`q-2p+Y5<5<+C0o4n zXwTM`&v3C|HU&f#^SLEW)xcLsB!S1oY~?H9dfe(c!xG~wQW;?DB< zE>b?Yxfg!(G{CrBiI!isYjJ0X%&gD%0RLIm)_QuZ@e9RyZk2^Qwq=tH7yGHl;_l}v zs(9&WbW9h%=nvbL>8|(pMf}m(o%4AZN$;>v#^IVqY94RGYA;<9w>`;Igu*aXFoi_A+%ElxP*3{jfHq%iyQ$6 z?JpRlTYb&~S+H@VP8M<2lU+g%mP1h+{4Jt5HWWBE5+uK7zaRGxJBBVP)q5KR5ZdWB z-QyfWdoq{M?vW#57O~bfdJovwpk4Pr{35gqW;2G)zVlxnM2#@8){1e!@+6P>lCL?Lu*0Mne227@aEqxv_8xGvUAP_#GB}hy#;P0$}np)|JO6(JzVCdnU4CGv{^1 zRN|etgC9Hq9$$Y!S{Yh^By56}wm(7^Q0S$ok>Y=R_Z zeHd=ud^E?8M%ZG6`7vYjjJyz@Spd9ni&6pHp`TO~H?&{Jo#Gj7$d6 zGA=oYwkK+q1lZer`Z}eyXV+@HFqmocC}9^Tod|L@9mS3fTZcTSh4i-BoeXbWMW3h? z^5hz7*i2{%6V6_cxwnMG#}xoVTuqI9za+zlr;h1#$f6uFGC}94o889->ygCJ!d_*A zO6k@_b=U0)|HC$Js|~oBng`Lml?1M_I+eQ%PNa8#SRZhxi37TgUB~^@y40rBvM)Uw z53(+(rrXWdwB}3~yLtHN8zb@h+}q(_g=CZBQTkfmMRJZmH`H!{`>Xp~~kj ze=;}4TX5|Yx-6Y^iUkFW8VLsOpK%w3C8)90opgEP`Mlb&N7J^0^E5dT%NiM#0ABC( zx0s9#?LjtdYhZKP++>{1gqo|;6|5P2G%$18&vdnIV%kGN;etp3!uxksa8pb)f3wYx T&xفشل التحقق من صحة:' + INVALID_INPUT: 'إدخال غير صحيح في' + MISSING_REQUIRED_FIELD: 'حقل مطلوب مفقود:' + XSS_ISSUES: "مشاكل XSS محتملة تم اكتشافها في حقل '%s' '" + MONTHS_OF_THE_YEAR: + - 'كانون الثاني' + - 'شباط' + - 'آذار/ مارس' + - 'نيسان' + - 'أيار' + - 'حزيران' + - 'تموز' + - 'آب' + - 'أيلول' + - 'تشرين الأول' + - 'تشرين الثاني' + - 'كانون الأول' + DAYS_OF_THE_WEEK: + - 'الاثنين' + - 'الثلاثاء' + - 'الأربعاء' + - 'الخميس' + - 'الجمعة' + - 'السبت' + - 'الأحد' + YES: "نعم" + NO: "لا" + CRON: + EVERY: كل + EVERY_HOUR: كل ساعة + EVERY_MINUTE: كل دقيقة + EVERY_DAY_OF_WEEK: كل يوم في الأسبوع + EVERY_DAY_OF_MONTH: كل يوم في الشهر + EVERY_MONTH: ' كل شهر' + TEXT_PERIOD: كل + TEXT_MINS: ' في دقيقة(دقائق) بعد الساعة' + TEXT_TIME: ' في :' + TEXT_DOW: ' في ' + TEXT_MONTH: ' من ' + TEXT_DOM: ' في ' + ERROR1: الوسم %s غير مدعوم! + ERROR2: عدد عناصر غير صالح. + ERROR4: تعبير غير معروف diff --git a/system/languages/bg.yaml b/system/languages/bg.yaml new file mode 100644 index 0000000..8dc893a --- /dev/null +++ b/system/languages/bg.yaml @@ -0,0 +1,72 @@ +--- +GRAV: + NICETIME: + NO_DATE_PROVIDED: Не е въведена дата + BAD_DATE: Невалидна дата + AGO: преди + FROM_NOW: от сега + JUST_NOW: току що + SECOND: секунда + MINUTE: минута + HOUR: час + DAY: ден + WEEK: седмица + MONTH: месец + YEAR: година + DECADE: десетилетие + SEC: сек + MIN: мин + HR: ч + WK: седм + MO: мес + YR: г + DEC: дстлт + SECOND_PLURAL: секунди + MINUTE_PLURAL: минути + HOUR_PLURAL: часа + DAY_PLURAL: дена + WEEK_PLURAL: седмици + MONTH_PLURAL: месеца + YEAR_PLURAL: години + DECADE_PLURAL: десетилетия + SEC_PLURAL: сек + MIN_PLURAL: мин + HR_PLURAL: ч + WK_PLURAL: седм + MO_PLURAL: мес + YR_PLURAL: г + DEC_PLURAL: дстлт + FORM: + VALIDATION_FAIL: 'Неуспешна проверка:' + INVALID_INPUT: 'Невалидно въвеждане в' + MISSING_REQUIRED_FIELD: 'Липсва задължително поле:' + MONTHS_OF_THE_YEAR: + - 'януари' + - 'февруари' + - 'март' + - 'април' + - 'май' + - 'юни' + - 'юли' + - 'август' + - 'септември' + - 'октомври' + - 'ноември' + - 'декември' + DAYS_OF_THE_WEEK: + - 'понеделник' + - 'вторник' + - 'сряда' + - 'четвъртък' + - 'петък' + - 'събота' + - 'неделя' + YES: "Да" + NO: "Не" + CRON: + EVERY: всеки + EVERY_HOUR: Всеки час + EVERY_MINUTE: Всяка минута + EVERY_DAY_OF_WEEK: Всеки ден от седмицата + EVERY_DAY_OF_MONTH: Всеки ден от месеца + EVERY_MONTH: Всеки месец diff --git a/system/languages/ca.yaml b/system/languages/ca.yaml new file mode 100644 index 0000000..cb2f479 --- /dev/null +++ b/system/languages/ca.yaml @@ -0,0 +1,87 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# S'ha produït un error: Frontmatter invàlid\n\nRuta: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_UNCOUNTABLE: + - '' + - 'informació' + - '' + - '' + - '' + - '' + - '' + - '' + NICETIME: + NO_DATE_PROVIDED: No s'ha proporcionat data + BAD_DATE: Data invàlida + AGO: abans + FROM_NOW: des d'ara + JUST_NOW: Ara mateix + SECOND: segon + MINUTE: minut + HOUR: hora + DAY: dia + WEEK: setmana + MONTH: mes + YEAR: any + DECADE: dècada + SEC: s + HR: h + WK: setm. + MO: m. + YR: a. + DEC: dèc. + SECOND_PLURAL: segons + MINUTE_PLURAL: minuts + HOUR_PLURAL: hores + DAY_PLURAL: dies + WEEK_PLURAL: setmanes + MONTH_PLURAL: mesos + YEAR_PLURAL: anys + DECADE_PLURAL: dècades + SEC_PLURAL: s + MIN_PLURAL: min + HR_PLURAL: h + WK_PLURAL: setm. + MO_PLURAL: mesos + YR_PLURAL: anys + DEC_PLURAL: dèc. + FORM: + VALIDATION_FAIL: 'Ha fallat la validació:' + INVALID_INPUT: 'Entrada no vàlida a' + MISSING_REQUIRED_FIELD: 'Falta camp obligatori:' + XSS_ISSUES: "Detectats potencials problemes XSS al camp '%s'" + MONTHS_OF_THE_YEAR: + - 'Gener' + - 'Febrer' + - 'Març' + - 'Abril' + - 'Maig' + - 'Juny' + - 'Juliol' + - 'Agost' + - 'Setembre' + - 'Octubre' + - 'Novembre' + - 'Desembre' + DAYS_OF_THE_WEEK: + - 'Dilluns' + - 'Dimarts' + - 'Dimecres' + - 'Dijous' + - 'Divendres' + - 'Dissabte' + - 'Diumenge' + YES: "Sí" + NO: "No" + CRON: + EVERY: cada + EVERY_HOUR: cada hora + EVERY_MINUTE: cada minut + EVERY_DAY_OF_WEEK: cada dia de la setmana + EVERY_DAY_OF_MONTH: cada dia del mes + EVERY_MONTH: cada mes + TEXT_PERIOD: Cada + ERROR1: L'etiqueta %s no està suportada! + ERROR2: Nombre d'elements incorrecte + ERROR3: El jquery_element s'ha d'establir a la configuració de jqCron + ERROR4: Expressió no reconeguda diff --git a/system/languages/cs.yaml b/system/languages/cs.yaml new file mode 100644 index 0000000..54aa138 --- /dev/null +++ b/system/languages/cs.yaml @@ -0,0 +1,147 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Chyba: Chybná hlavička\n\nCesta: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'vybavení' + - 'informace' + - 'rýže' + - 'peníze' + - 'druhy' + - 'série' + - 'ryba' + - 'ovce' + INFLECTOR_IRREGULAR: + 'person': 'lidé' + 'man': 'muži' + 'child': 'děti' + 'sex': 'pohlaví' + 'move': 'pohyby' + INFLECTOR_ORDINALS: + 'default': '.' + 'first': '.' + 'second': '.' + 'third': '.' + NICETIME: + NO_DATE_PROVIDED: Datum nebylo vloženo + BAD_DATE: Chybné datum + AGO: zpět + FROM_NOW: od teď + JUST_NOW: právě teď + SECOND: sekunda + MINUTE: minuta + HOUR: hodina + DAY: den + WEEK: týden + MONTH: měsíc + YEAR: rok + DECADE: dekáda + SEC: sek + MIN: min + HR: hod + WK: t + MO: m + YR: r + DEC: dek + SECOND_PLURAL: sekundy + MINUTE_PLURAL: minuty + HOUR_PLURAL: hodiny + DAY_PLURAL: dny + WEEK_PLURAL: týdny + MONTH_PLURAL: měsíce + YEAR_PLURAL: roky + DECADE_PLURAL: dekády + SEC_PLURAL: sek + MIN_PLURAL: min + HR_PLURAL: hod + WK_PLURAL: t + MO_PLURAL: m + YR_PLURAL: r + DEC_PLURAL: dek + FORM: + VALIDATION_FAIL: 'Ověření se nezdařilo:' + INVALID_INPUT: 'Neplatný vstup v' + MISSING_REQUIRED_FIELD: 'Chybí požadované pole:' + XSS_ISSUES: "Byly zjištěny možné problémy XSS v poli '%s'" + MONTHS_OF_THE_YEAR: + - 'leden' + - 'únor' + - 'březen' + - 'duben' + - 'květen' + - 'červen' + - 'červenec' + - 'srpen' + - 'září' + - 'říjen' + - 'listopad' + - 'prosinec' + DAYS_OF_THE_WEEK: + - 'pondělí' + - 'úterý' + - 'středa' + - 'čtvrtek' + - 'pátek' + - 'sobota' + - 'neděle' + YES: "Ano" + NO: "Ne" + CRON: + EVERY: každý + EVERY_HOUR: každou hodinu + EVERY_MINUTE: každou minutu + EVERY_DAY_OF_WEEK: každý den v týdnu + EVERY_DAY_OF_MONTH: každý den v měsíci + EVERY_MONTH: každý měsíc + TEXT_PERIOD: Every + TEXT_MINS: ' at minute(s) past the hour' + TEXT_TIME: ' at :' + TEXT_DOW: ' on ' + TEXT_MONTH: ' of ' + TEXT_DOM: ' on ' + ERROR1: Tag %s není podporován! + ERROR2: Chybný počet prvků + ERROR3: jquery_element musí být nastaven v nastaveních pro jqCron + ERROR4: Nerozpoznaný výraz diff --git a/system/languages/da.yaml b/system/languages/da.yaml new file mode 100644 index 0000000..f23477c --- /dev/null +++ b/system/languages/da.yaml @@ -0,0 +1,90 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\nTitel: %1$s\n---\n\n# Fejl: Ugyldigt frontmatter\n\nSti: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_UNCOUNTABLE: + - 'udstyr' + - 'information' + - 'ris' + - 'penge' + - 'arter' + - 'Serier' + - 'fisk' + - 'får' + INFLECTOR_IRREGULAR: + 'person': 'personer' + 'man': 'mænd' + 'child': 'børn' + 'sex': 'køn' + 'move': 'flyt' + NICETIME: + NO_DATE_PROVIDED: Ingen dato angivet + BAD_DATE: Ugyldig dato + AGO: siden + FROM_NOW: fra nu + JUST_NOW: lige nu + SECOND: sekund + MINUTE: minut + HOUR: time + DAY: dag + WEEK: uge + MONTH: måned + YEAR: år + DECADE: årti + SEC: sek + MIN: min. + HR: t + WK: u + MO: md + YR: år + DEC: årti + SECOND_PLURAL: sekunder + MINUTE_PLURAL: minutter + HOUR_PLURAL: timer + DAY_PLURAL: dage + WEEK_PLURAL: uger + MONTH_PLURAL: måneder + YEAR_PLURAL: år + DECADE_PLURAL: årtier + SEC_PLURAL: sek + MIN_PLURAL: min + HR_PLURAL: timer + WK_PLURAL: uger + MO_PLURAL: mdr + YR_PLURAL: år + DEC_PLURAL: årtier + FORM: + VALIDATION_FAIL: 'Validering mislykkedes:' + INVALID_INPUT: 'Ugyldigt input i' + MISSING_REQUIRED_FIELD: 'Mangler obligatorisk felt:' + MONTHS_OF_THE_YEAR: + - 'januar' + - 'februar' + - 'mars' + - 'april' + - 'mai' + - 'juni' + - 'juli' + - 'august' + - 'september' + - 'oktober' + - 'november' + - 'desember' + DAYS_OF_THE_WEEK: + - 'mandag' + - 'tirsdag' + - 'onsdag' + - 'torsdag' + - 'fredag' + - 'lørdag' + - 'søndag' + CRON: + EVERY: hver + EVERY_HOUR: hver time + EVERY_MINUTE: hvert minut + EVERY_DAY_OF_WEEK: alle ugens dage + EVERY_DAY_OF_MONTH: alle dage i måneden + EVERY_MONTH: hver måned + TEXT_PERIOD: Hver + TEXT_MINS: ' ved minut(ter) over timen' + ERROR1: Tagget %s understøttes ikke! + ERROR2: Ugyldigt antal elementer diff --git a/system/languages/de.yaml b/system/languages/de.yaml new file mode 100644 index 0000000..45c24e7 --- /dev/null +++ b/system/languages/de.yaml @@ -0,0 +1,147 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n# Fehler: Frontmatter enthält Fehler\n\nPfad: `%2$s`\n\n**%3$s ** \n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ice' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1ies' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2ves' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'Ausstattung' + - 'Informationen' + - 'Reis' + - 'Geld' + - 'Arten' + - 'Serie' + - 'Fisch' + - 'Schaf' + INFLECTOR_IRREGULAR: + 'person': 'Personen' + 'man': 'Menschen' + 'child': 'Kinder' + 'sex': 'Geschlecht' + 'move': 'Züge' + INFLECTOR_ORDINALS: + 'default': '.' + 'first': '.' + 'second': '.' + 'third': '.' + NICETIME: + NO_DATE_PROVIDED: Kein Datum angegeben + BAD_DATE: Falsches Datum + AGO: her + FROM_NOW: ab jetzt + JUST_NOW: jetzt gerade + SECOND: Sekunde + MINUTE: Minute + HOUR: Stunde + DAY: Tag + WEEK: Woche + MONTH: Monat + YEAR: Jahr + DECADE: Jahrzehnt + SEC: Sek. + MIN: Min. + HR: Std. + WK: Wo. + MO: Mo. + YR: J. + DEC: Dez + SECOND_PLURAL: Sekunden + MINUTE_PLURAL: Minuten + HOUR_PLURAL: Stunden + DAY_PLURAL: Tage + WEEK_PLURAL: Wochen + MONTH_PLURAL: Monate + YEAR_PLURAL: Jahre + DECADE_PLURAL: Jahrzehnte + SEC_PLURAL: Sekunden + MIN_PLURAL: Minuten + HR_PLURAL: Stunden + WK_PLURAL: Wochen + MO_PLURAL: Monate + YR_PLURAL: Jahre + DEC_PLURAL: Jahrzehnten + FORM: + VALIDATION_FAIL: 'Überprüfung fehlgeschlagen:' + INVALID_INPUT: 'Ungültige Eingabe in' + MISSING_REQUIRED_FIELD: 'Erforderliches Feld fehlt:' + XSS_ISSUES: "Potenzielle XSS-Probleme im Feld '%s' erkannt" + MONTHS_OF_THE_YEAR: + - 'Januar' + - 'Februar' + - 'März' + - 'April' + - 'Mai' + - 'Juni' + - 'Juli' + - 'August' + - 'September' + - 'Oktober' + - 'November' + - 'Dezember' + DAYS_OF_THE_WEEK: + - 'Montag' + - 'Dienstag' + - 'Mittwoch' + - 'Donnerstag' + - 'Freitag' + - 'Samstag' + - 'Sonntag' + YES: "Ja" + NO: "Nein" + CRON: + EVERY: jede + EVERY_HOUR: jede Stunde + EVERY_MINUTE: Jede Minute + EVERY_DAY_OF_WEEK: jeden Tag der Woche + EVERY_DAY_OF_MONTH: jeden Tag des Monats + EVERY_MONTH: jeden Monat + TEXT_PERIOD: Alle + TEXT_MINS: ' bei Minuten nach der vollen Stunde (n)' + TEXT_TIME: ' bei :' + TEXT_DOW: ' auf ' + TEXT_MONTH: ' von ' + TEXT_DOM: ' auf ' + ERROR1: Der Tag %s wird nicht unterstützt! + ERROR2: Ungültige Anzahl von Elementen + ERROR3: jquery_element sollte in den jqCron Einstellungen gesetzt werden + ERROR4: Unbekannter Ausdruck diff --git a/system/languages/el.yaml b/system/languages/el.yaml new file mode 100644 index 0000000..28619f8 --- /dev/null +++ b/system/languages/el.yaml @@ -0,0 +1,144 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\nΤίτλος: %1$s\n---\n\n# Σφάλμα: Μη έγκυρη διαδρομή Frontmatter\n\nΔιαδρομή: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'εξοπλισμός' + - 'πληροφοριες' + - 'rice' + - 'χρήματα' + - 'είδη' + - 'σειρές' + - 'ψάρι' + - 'πρόβατο' + INFLECTOR_IRREGULAR: + 'person': 'άνθρωποι' + 'man': 'άνδρες' + 'child': 'παιδιά' + 'sex': 'φύλο' + 'move': 'κινήσεις' + INFLECTOR_ORDINALS: + 'default': 'th' + 'first': 'st' + 'second': 'nd' + 'third': 'rd' + NICETIME: + NO_DATE_PROVIDED: Δεν δόθηκε καμία ημερομηνία + BAD_DATE: Εσφαλμένη ημερομηνία + AGO: πρίν + FROM_NOW: από τώρα + JUST_NOW: μόλις τώρα + SECOND: δευτερόλεπτο + MINUTE: λεπτό + HOUR: ώρα + DAY: ημέρα + WEEK: εβδομάδα + MONTH: μήνας + YEAR: έτος + DECADE: δεκαετία + SEC: δευτερόλεπτο + MIN: λεπτό + HR: ώρα + WK: εβδ + MO: μην + YR: έτος + DEC: δεκαετία + SECOND_PLURAL: δευτερόλεπτα + MINUTE_PLURAL: λεπτά + HOUR_PLURAL: ώρες + DAY_PLURAL: ημέρες + WEEK_PLURAL: εβδομάδες + MONTH_PLURAL: μήνες + YEAR_PLURAL: έτη + DECADE_PLURAL: δεκαετίες + SEC_PLURAL: δευτ. + MIN_PLURAL: λεπτά + HR_PLURAL: ώρες + WK_PLURAL: εβδομάδες + MO_PLURAL: μήνες + YR_PLURAL: έτη + DEC_PLURAL: δεκαετίες + FORM: + VALIDATION_FAIL: 'Η επικύρωση απέτυχε:' + INVALID_INPUT: 'Μη έγκυρα δεδομένα σε' + MISSING_REQUIRED_FIELD: 'Λείπει το απαιτούμενο πεδίο:' + MONTHS_OF_THE_YEAR: + - 'Ιανουάριος' + - 'Φεβρουάριος' + - 'Μάρτιος' + - 'Απρίλιος' + - 'Μάιος' + - 'Ιούνιος' + - 'Ιούλιος' + - 'Αύγουστος' + - 'Σεπτέμβριος' + - 'Οκτώβριος' + - 'Νοέμβριος' + - 'Δεκέμβριος' + DAYS_OF_THE_WEEK: + - 'Δευτέρα' + - 'Τρίτη' + - 'Τετάρτη' + - 'Πέμπτη' + - 'Παρασκευή' + - 'Σάββατο' + - 'Κυριακή' + CRON: + EVERY: κάθε + EVERY_HOUR: κάθε ώρα + EVERY_MINUTE: κάθε λεπτό + EVERY_DAY_OF_WEEK: κάθε μέρα της εβδομάδος + EVERY_DAY_OF_MONTH: κάθε μέρα του μήνα + EVERY_MONTH: κάθε μήνα + TEXT_PERIOD: Κάθε + TEXT_MINS: ' κατά λεπτό(ά) μετά την ώρα' + TEXT_TIME: ' στο :' + TEXT_DOW: ' στις ' + TEXT_MONTH: ' από ' + TEXT_DOM: ' στις ' + ERROR1: Η ετικέτα %s δεν υποστηρίζεται! + ERROR2: Μη έγκυρος αριθμός στοιχείων + ERROR3: Το jquery_element θα έπρεπε να οριστεί στις ρυθμίσεις του jqCron + ERROR4: Μη αναγνωρισμένη έκφραση diff --git a/system/languages/en.yaml b/system/languages/en.yaml new file mode 100644 index 0000000..7a8a68c --- /dev/null +++ b/system/languages/en.yaml @@ -0,0 +1,121 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Error: Invalid Frontmatter\n\nPath: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + '/s$/i': '' + INFLECTOR_UNCOUNTABLE: ['equipment', 'information', 'rice', 'money', 'species', 'series', 'fish', 'sheep'] + INFLECTOR_IRREGULAR: + 'person': 'people' + 'man': 'men' + 'child': 'children' + 'sex': 'sexes' + 'move': 'moves' + INFLECTOR_ORDINALS: + 'default': 'th' + 'first': 'st' + 'second': 'nd' + 'third': 'rd' + NICETIME: + NO_DATE_PROVIDED: No date provided + BAD_DATE: Bad date + AGO: ago + FROM_NOW: from now + JUST_NOW: just now + SECOND: second + MINUTE: minute + HOUR: hour + DAY: day + WEEK: week + MONTH: month + YEAR: year + DECADE: decade + SEC: sec + MIN: min + HR: hr + WK: wk + MO: mo + YR: yr + DEC: dec + SECOND_PLURAL: seconds + MINUTE_PLURAL: minutes + HOUR_PLURAL: hours + DAY_PLURAL: days + WEEK_PLURAL: weeks + MONTH_PLURAL: months + YEAR_PLURAL: years + DECADE_PLURAL: decades + SEC_PLURAL: secs + MIN_PLURAL: mins + HR_PLURAL: hrs + WK_PLURAL: wks + MO_PLURAL: mos + YR_PLURAL: yrs + DEC_PLURAL: decs + FORM: + VALIDATION_FAIL: 'Validation failed:' + INVALID_INPUT: 'Invalid input in' + MISSING_REQUIRED_FIELD: 'Missing required field:' + XSS_ISSUES: "Potential XSS issues detected in '%s' field" + MONTHS_OF_THE_YEAR: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] + DAYS_OF_THE_WEEK: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] + YES: "Yes" + NO: "No" + CRON: + EVERY: every + EVERY_HOUR: every hour + EVERY_MINUTE: every minute + EVERY_DAY_OF_WEEK: every day of the week + EVERY_DAY_OF_MONTH: every day of the month + EVERY_MONTH: every month + TEXT_PERIOD: Every + TEXT_MINS: ' at minute(s) past the hour' + TEXT_TIME: ' at :' + TEXT_DOW: ' on ' + TEXT_MONTH: ' of ' + TEXT_DOM: ' on ' + ERROR1: The tag %s is not supported! + ERROR2: Bad number of elements + ERROR3: The jquery_element should be set into jqCron settings + ERROR4: Unrecognized expression diff --git a/system/languages/eo.yaml b/system/languages/eo.yaml new file mode 100644 index 0000000..0da621f --- /dev/null +++ b/system/languages/eo.yaml @@ -0,0 +1,40 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Eraro: Nevalida Frontmatter\n\nVojo: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/sis$/i': 'j' + NICETIME: + FROM_NOW: ekde nun + JUST_NOW: Ĝuste nun + SECOND: sekundo + MINUTE: minuto + HOUR: horo + DAY: tago + WEEK: semajno + MONTH: monato + YEAR: jaro + DECADE: jardeko + SEC: sek. + MIN: min. + HR: horo + SECOND_PLURAL: sekundoj + MINUTE_PLURAL: minutoj + HOUR_PLURAL: horoj + DAY_PLURAL: tagoj + WEEK_PLURAL: semajnoj + MONTH_PLURAL: monatoj + YEAR_PLURAL: jaroj + DECADE_PLURAL: jardekoj + MONTHS_OF_THE_YEAR: + - 'januaro' + - 'februaro' + - 'marto' + - 'aprilo' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' diff --git a/system/languages/es.yaml b/system/languages/es.yaml new file mode 100644 index 0000000..3786565 --- /dev/null +++ b/system/languages/es.yaml @@ -0,0 +1,107 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntítulo: %1$s\n---\n\n# Error: Prefacio no válido\n\nRuta: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1ios' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_UNCOUNTABLE: + - 'equipamiento' + - 'información' + - 'arroz' + - 'dinero' + - 'especies' + - 'series' + - 'pescado' + - 'oveja' + INFLECTOR_IRREGULAR: + 'person': 'personas' + 'man': 'hombres' + 'child': 'niños' + 'sex': 'sexos' + 'move': 'movido' + INFLECTOR_ORDINALS: + 'first': '.º' + 'second': '.º' + 'third': '.º' + NICETIME: + NO_DATE_PROVIDED: No se proporcionó fecha + BAD_DATE: Fecha errónea + AGO: antes + FROM_NOW: desde ahora + JUST_NOW: hace un momento + SECOND: segundo + MINUTE: minuto + HOUR: hora + DAY: día + WEEK: semana + MONTH: mes + YEAR: año + DECADE: década + SEC: seg + MIN: min + HR: h + WK: sem + MO: mes + YR: año + DEC: déc + SECOND_PLURAL: segundos + MINUTE_PLURAL: minutos + HOUR_PLURAL: horas + DAY_PLURAL: días + WEEK_PLURAL: semanas + MONTH_PLURAL: meses + YEAR_PLURAL: años + DECADE_PLURAL: décadas + SEC_PLURAL: segs + MIN_PLURAL: mins + HR_PLURAL: hs + WK_PLURAL: sem + MO_PLURAL: mes + YR_PLURAL: años + DEC_PLURAL: décadas + FORM: + VALIDATION_FAIL: 'Falló la validación: ' + INVALID_INPUT: 'Dato inválido en: ' + MISSING_REQUIRED_FIELD: 'Falta el campo requerido: ' + XSS_ISSUES: "Se detectaron potenciales problemas XSS en el campo '%s'" + MONTHS_OF_THE_YEAR: + - 'Enero' + - 'Febrero' + - 'Marzo' + - 'Abril' + - 'Mayo' + - 'Junio' + - 'Julio' + - 'Agosto' + - 'Septiembre' + - 'Octubre' + - 'Noviembre' + - 'Diciembre' + DAYS_OF_THE_WEEK: + - 'Lunes' + - 'Martes' + - 'Miércoles' + - 'Jueves' + - 'Viernes' + - 'Sábado' + - 'Domingo' + YES: "Sí" + NO: "No" + CRON: + EVERY: cada + EVERY_HOUR: cada hora + EVERY_MINUTE: cada minuto + EVERY_DAY_OF_WEEK: cada día de la semana + EVERY_DAY_OF_MONTH: cada día del mes + EVERY_MONTH: cada mes + TEXT_PERIOD: Cada + TEXT_MINS: ' a minuto(s) después de la hora' + TEXT_TIME: ' a :' + TEXT_DOW: ' en ' + TEXT_MONTH: ' de' + TEXT_DOM: ' en' + ERROR1: No se admite la etiqueta %s. + ERROR2: El número de elementos es erróneo + ERROR3: El jquery_element debería establecerse en la configuración del jqCron + ERROR4: Expresión no reconocida diff --git a/system/languages/et.yaml b/system/languages/et.yaml new file mode 100644 index 0000000..2bc1920 --- /dev/null +++ b/system/languages/et.yaml @@ -0,0 +1,108 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\npealkiri: %1$s\n---\n\n# Viga: vigane Frontmatter'i\n\nasukoht: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(octop|vir)us$/i': '\1i' + INFLECTOR_SINGULAR: + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/(x|ch|ss|sh)es$/i': '\1' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + INFLECTOR_UNCOUNTABLE: + - '' + - 'informatsioon' + - 'riis' + - 'raha' + - '' + - '' + - 'kala' + - 'lammas' + INFLECTOR_IRREGULAR: + 'person': 'inimesed' + 'man': 'mees' + 'child': 'lapsed' + INFLECTOR_ORDINALS: + 'default': '.' + 'first': '.' + 'second': '.' + 'third': '.' + NICETIME: + NO_DATE_PROVIDED: Kuupäev määramata + BAD_DATE: Vigane kuupäev + AGO: tagasi + FROM_NOW: praegusest + JUST_NOW: just nüüd + SECOND: sekund + MINUTE: minut + HOUR: tundi + DAY: päev + WEEK: nädal + MONTH: kuu + YEAR: aasta + DECADE: 10 aastat + SEC: sek + MIN: min + HR: t + WK: näd + MO: k. + YR: a. + DEC: dekaad + SECOND_PLURAL: sekundit + MINUTE_PLURAL: minutit + HOUR_PLURAL: tundi + DAY_PLURAL: päeva + WEEK_PLURAL: nädalat + MONTH_PLURAL: kuud + YEAR_PLURAL: aastat + DECADE_PLURAL: dekaadi + SEC_PLURAL: sekundit + MIN_PLURAL: min + HR_PLURAL: t + WK_PLURAL: näd + MO_PLURAL: kuud + YR_PLURAL: aastat + DEC_PLURAL: dek. + FORM: + VALIDATION_FAIL: 'Kinnitamine nurjus:' + INVALID_INPUT: 'Vigane sisend:' + MISSING_REQUIRED_FIELD: 'Nõutud väli puudub:' + XSS_ISSUES: "Tuvastasime '%s' väljal võimaliku XSS-riski" + MONTHS_OF_THE_YEAR: + - 'jaanuar' + - 'veebruar' + - 'märts' + - 'aprill' + - 'mai' + - 'juuni' + - 'juuli' + - 'august' + - 'september' + - 'oktoober' + - 'november' + - 'detsember' + DAYS_OF_THE_WEEK: + - 'esmaspäev' + - 'teisipäev' + - 'kolmapäev' + - 'neljapäev' + - 'reede' + - 'laupäev' + - 'pühapäev' + YES: "Jah" + NO: "Ei" + CRON: + EVERY: iga + EVERY_HOUR: iga tund + EVERY_MINUTE: iga minut + EVERY_DAY_OF_WEEK: nädala igal päeval + EVERY_DAY_OF_MONTH: kuu igal päeval + EVERY_MONTH: iga kuu + TEXT_PERIOD: Iga + ERROR1: Silt %s pole toetatud! + ERROR2: Vale elementide arv + ERROR3: jqCron seadetes peaks olema määratud jquery_element + ERROR4: Tundmatu väljend diff --git a/system/languages/eu.yaml b/system/languages/eu.yaml new file mode 100644 index 0000000..91c3c8e --- /dev/null +++ b/system/languages/eu.yaml @@ -0,0 +1,62 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "--- title: %1$s --- # Errorea: Baliogabeko Frontmatter Bidea: `%2$s` **%3$s** ``` %4$s ```" + NICETIME: + NO_DATE_PROVIDED: Ez da datarik ezarri + BAD_DATE: Okerreko data + AGO: ' duela' + FROM_NOW: oraindik aurrera + SECOND: segundo + MINUTE: minutu + HOUR: ordua + DAY: egun + WEEK: astea + MONTH: hilabetea + YEAR: urtea + DECADE: hamarkada + SEC: seg + HR: h + WK: ast + MO: hil + YR: urt + DEC: ham + SECOND_PLURAL: segundo + MINUTE_PLURAL: minutu + HOUR_PLURAL: ordu + DAY_PLURAL: egun + WEEK_PLURAL: aste + MONTH_PLURAL: hilabete + YEAR_PLURAL: urte + DECADE_PLURAL: hamarkada + SEC_PLURAL: segundo + MIN_PLURAL: minutu + HR_PLURAL: h + WK_PLURAL: ast + MO_PLURAL: hil + YR_PLURAL: urt + DEC_PLURAL: ham + FORM: + VALIDATION_FAIL: 'Balidazioak huts egin du' + INVALID_INPUT: 'Baliogabeko sarrera' + MISSING_REQUIRED_FIELD: 'Derrigorrezko eremua bete gabe:' + MONTHS_OF_THE_YEAR: + - 'Urtarrila' + - 'Otsaila' + - 'Martxoa' + - 'Apirila' + - 'Maiatza' + - 'Ekaina' + - 'Uztaila' + - 'Abuztua' + - 'Iraila' + - 'Urria' + - 'Azaroa' + - 'Abendua' + DAYS_OF_THE_WEEK: + - 'Astelehena' + - 'Asteartea' + - 'Azteazkena' + - 'Osteguna' + - 'Ostirala' + - 'Larunbata' + - 'Igandea' diff --git a/system/languages/fa.yaml b/system/languages/fa.yaml new file mode 100644 index 0000000..96b96dc --- /dev/null +++ b/system/languages/fa.yaml @@ -0,0 +1,62 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\nعنوان: %1$s\n---\n\n# خطا: Frontmatter غلط\n\nمسیر: %2$s\n\n**%3$s**\n\n```\n%4$s\n```" + NICETIME: + NO_DATE_PROVIDED: تاریخی ارائه نشده + BAD_DATE: تاریخ اشتباه + AGO: قبل + FROM_NOW: از حالا + SECOND: ثانیه + MINUTE: دقیقه + HOUR: ساعت + DAY: روز + WEEK: هفته + MONTH: ماه + YEAR: سال + DECADE: دهه + SEC: ثانیه + MIN: دقیقه + HR: ساعت + WK: هفته + MO: ماه + YR: سال + DEC: دهه + SECOND_PLURAL: ثانیه + MINUTE_PLURAL: دقیقه + HOUR_PLURAL: ساعت + DAY_PLURAL: روز + WEEK_PLURAL: هفته + MONTH_PLURAL: ماه + YEAR_PLURAL: سال + DECADE_PLURAL: دهه + SEC_PLURAL: ثانیه + MIN_PLURAL: دقیقه + HR_PLURAL: ساعت + WK_PLURAL: هفته + YR_PLURAL: سال + DEC_PLURAL: دهه + FORM: + VALIDATION_FAIL: 'سنجش اعتبار ناموفق بود' + INVALID_INPUT: 'ورودی نامعتبر در' + MISSING_REQUIRED_FIELD: 'قسمت ضروری جا افتاده:' + MONTHS_OF_THE_YEAR: + - 'ژانویه' + - 'فوریه' + - 'مارس' + - 'آوریل' + - 'می' + - 'ژوئن' + - 'ژوئیه' + - 'اوت' + - 'سپتامبر' + - 'اکتبر' + - 'نوامبر' + - 'دسامبر' + DAYS_OF_THE_WEEK: + - 'دوشنبه' + - 'سه‌ شنبه' + - 'چهارشنبه' + - 'پنج شنبه' + - 'جمعه' + - 'شنبه' + - 'یک‌شنبه' diff --git a/system/languages/fi.yaml b/system/languages/fi.yaml new file mode 100644 index 0000000..d0513fa --- /dev/null +++ b/system/languages/fi.yaml @@ -0,0 +1,134 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\notsikko: %1$s\n---\n\n# Virhe: Virheellinen Frontmatter\n\nPolku: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - '' + - '' + - 'riisi' + - 'raha' + - 'lajit' + - '' + - 'kala' + - 'lammas' + INFLECTOR_IRREGULAR: + 'person': 'ihmiset' + 'man': 'miehet' + 'child': 'lapset' + 'sex': 'sukupuoli' + INFLECTOR_ORDINALS: + 'default': '.' + 'first': '.' + 'second': '.' + 'third': '.' + NICETIME: + NO_DATE_PROVIDED: Päivämäärää ei annettu + BAD_DATE: Virheellinen päivämäärä + AGO: sitten + FROM_NOW: tästä lähtien + JUST_NOW: juuri nyt + SECOND: sekuntti + MINUTE: minuutti + HOUR: tunti + DAY: päivä + WEEK: viikko + MONTH: kuukausi + YEAR: vuosi + DECADE: vuosikymmen + SEC: sek + MIN: min + HR: h + WK: vk + MO: kk + YR: v + DEC: vuosikymmen + SECOND_PLURAL: sekuntia + MINUTE_PLURAL: minuuttia + HOUR_PLURAL: tuntia + DAY_PLURAL: päivää + WEEK_PLURAL: viikkoa + MONTH_PLURAL: kuukautta + YEAR_PLURAL: vuotta + DECADE_PLURAL: vuosikymmentä + SEC_PLURAL: sek + MIN_PLURAL: min + HR_PLURAL: h + WK_PLURAL: v + MO_PLURAL: kk + YR_PLURAL: v + DEC_PLURAL: vuosikymmentä + FORM: + VALIDATION_FAIL: 'Vahvistus epäonnistui:' + INVALID_INPUT: 'Syöte ei kelpaa' + MISSING_REQUIRED_FIELD: 'Puuttuva pakollinen kenttä:' + MONTHS_OF_THE_YEAR: + - 'Tammikuu' + - 'Helmikuu' + - 'Maaliskuu' + - 'Huhtikuu' + - 'Toukokuu' + - 'Kesäkuuta' + - 'Heinäkuu' + - 'Elokuu' + - 'Syyskuu' + - 'Lokakuu' + - 'Marraskuu' + - 'Joulukuu' + DAYS_OF_THE_WEEK: + - 'Maanantai' + - 'Tiistai' + - 'Keskiviikko' + - 'Torstai' + - 'Perjantai' + - 'Lauantai' + - 'Sunnuntai' + CRON: + EVERY: joka + EVERY_HOUR: joka tunti + EVERY_MINUTE: joka minuutti + EVERY_DAY_OF_WEEK: viikon jokaisena päivänä + EVERY_DAY_OF_MONTH: kuukauden jokaisena päivänä + EVERY_MONTH: joka kuukausi + TEXT_PERIOD: Joka diff --git a/system/languages/fr.yaml b/system/languages/fr.yaml new file mode 100644 index 0000000..edf7d76 --- /dev/null +++ b/system/languages/fr.yaml @@ -0,0 +1,147 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitre: %1$s\n---\n\n# Erreur : Frontmatter invalide\n\nChemin: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1es' + '/(bu)s$/i': 'Bus' + '/(alias|status)/i': 'alias|status' + '/(octop|vir)us$/i': 'virus' + '/(ax|test)is$/i': '\1s' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ouvelles' + INFLECTOR_UNCOUNTABLE: + - 'équipement' + - 'information' + - 'riz' + - 'argent' + - 'espèces' + - 'séries' + - 'poisson' + - 'mouton' + INFLECTOR_IRREGULAR: + 'person': 'personnes' + 'man': 'hommes' + 'child': 'enfants' + 'sex': 'sexes' + 'move': 'déplacements' + INFLECTOR_ORDINALS: + 'default': 'ème' + 'first': 'er' + 'second': 'ème' + 'third': 'ème' + NICETIME: + NO_DATE_PROVIDED: Aucune date fournie + BAD_DATE: Date erronée + AGO: plus tôt + FROM_NOW: à partir de maintenant + JUST_NOW: à l'instant + SECOND: seconde + MINUTE: minute + HOUR: heure + DAY: jour + WEEK: semaine + MONTH: mois + YEAR: année + DECADE: décennie + SEC: sec. + MIN: min. + HR: hr. + WK: sem. + MO: m + YR: an + DEC: déc + SECOND_PLURAL: secondes + MINUTE_PLURAL: minutes + HOUR_PLURAL: heures + DAY_PLURAL: jours + WEEK_PLURAL: semaines + MONTH_PLURAL: mois + YEAR_PLURAL: années + DECADE_PLURAL: décennies + SEC_PLURAL: s + MIN_PLURAL: m + HR_PLURAL: h + WK_PLURAL: sem + MO_PLURAL: mois + YR_PLURAL: a + DEC_PLURAL: décs + FORM: + VALIDATION_FAIL: 'La validation a échoué :' + INVALID_INPUT: 'Saisie non valide' + MISSING_REQUIRED_FIELD: 'Champ obligatoire manquant :' + XSS_ISSUES: "Erreurs XSS probablement détectées dans le champ '%s'" + MONTHS_OF_THE_YEAR: + - 'janvier' + - 'février' + - 'mars' + - 'avril' + - 'mai' + - 'juin' + - 'juillet' + - 'août' + - 'septembre' + - 'octobre' + - 'novembre' + - 'décembre' + DAYS_OF_THE_WEEK: + - 'lundi' + - 'mardi' + - 'mercredi' + - 'jeudi' + - 'vendredi' + - 'samedi' + - 'dimanche' + YES: "Oui" + NO: "Non" + CRON: + EVERY: chaque + EVERY_HOUR: toutes les heures + EVERY_MINUTE: chaque minute + EVERY_DAY_OF_WEEK: tous les jours de la semaine + EVERY_DAY_OF_MONTH: tous les jours du mois + EVERY_MONTH: chaque mois + TEXT_PERIOD: Chaque + TEXT_MINS: ' à minute(s) après l''heure' + TEXT_TIME: ' à:' + TEXT_DOW: ' sur ' + TEXT_MONTH: ' de ' + TEXT_DOM: ' sur ' + ERROR1: La balise %s n'est pas prise en charge ! + ERROR2: Nombre invalide d'éléments + ERROR3: L'élément jquery_element doit être défini dans les paramètres jqCron + ERROR4: Expression non reconnue diff --git a/system/languages/gl.yaml b/system/languages/gl.yaml new file mode 100644 index 0000000..b9e581f --- /dev/null +++ b/system/languages/gl.yaml @@ -0,0 +1,147 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntítulo: %1$s\n---\n\n# Erro: Limiar incorrecto\n\nRuta: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1' + '/(octop|vir)us$/i': '\1' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1ces' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1' + '/(cris|ax|test)es$/i': '\1es' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1se' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2se' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'equipo' + - 'información' + - 'arroz' + - 'diñeiro' + - 'especies' + - 'series' + - 'peixe' + - 'ovella' + INFLECTOR_IRREGULAR: + 'person': 'xente' + 'man': 'home' + 'child': 'neno' + 'sex': 'sexos' + 'move': 'move' + INFLECTOR_ORDINALS: + 'default': 'º' + 'first': 'º' + 'second': 'º' + 'third': 'º' + NICETIME: + NO_DATE_PROVIDED: Non fornece unha data + BAD_DATE: Data errada + AGO: hai + FROM_NOW: dende agora + JUST_NOW: xusto agora + SECOND: segundo + MINUTE: minuto + HOUR: hora + DAY: día + WEEK: semana + MONTH: mes + YEAR: ano + DECADE: década + SEC: seg + MIN: min + HR: hr + WK: Sem + MO: m + YR: a + DEC: dec + SECOND_PLURAL: segundos + MINUTE_PLURAL: minutos + HOUR_PLURAL: horas + DAY_PLURAL: días + WEEK_PLURAL: semanas + MONTH_PLURAL: meses + YEAR_PLURAL: anos + DECADE_PLURAL: décadas + SEC_PLURAL: segs + MIN_PLURAL: mins + HR_PLURAL: hrs + WK_PLURAL: sem + MO_PLURAL: mes + YR_PLURAL: a + DEC_PLURAL: deca + FORM: + VALIDATION_FAIL: 'Fallou a validación:' + INVALID_INPUT: 'Entrada incorrecta en' + MISSING_REQUIRED_FIELD: 'Falta un campo requirido:' + XSS_ISSUES: "Detectáronse posibles problemas XSS no campo '% s'" + MONTHS_OF_THE_YEAR: + - 'xaneiro' + - 'febreiro' + - 'marzo' + - 'abril' + - 'maio' + - 'xuño' + - 'xullo' + - 'agosto' + - 'setembro' + - 'outubro' + - 'novembro' + - 'decembro' + DAYS_OF_THE_WEEK: + - 'luns' + - 'martes' + - 'mércores' + - 'xoves' + - 'venres' + - 'sábado' + - 'domingo' + YES: "Si" + NO: "Non" + CRON: + EVERY: cada + EVERY_HOUR: Cada hora + EVERY_MINUTE: Cada minuto + EVERY_DAY_OF_WEEK: cada día da semana + EVERY_DAY_OF_MONTH: cada día do mes + EVERY_MONTH: cada mes + TEXT_PERIOD: Cada + TEXT_MINS: ' dentro de minuto(s) despois da hora' + TEXT_TIME: ' dentro :' + TEXT_DOW: ' o ' + TEXT_MONTH: ' de ' + TEXT_DOM: ' o ' + ERROR1: A etiqueta %s non é compatíbel! + ERROR2: Mal número de elementos + ERROR3: O jquery_element debería estar determinado na configuración de jqCron + ERROR4: Expresión non recoñecida diff --git a/system/languages/he.yaml b/system/languages/he.yaml new file mode 100644 index 0000000..25e0399 --- /dev/null +++ b/system/languages/he.yaml @@ -0,0 +1,99 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\nכותרת: %1$s\n---\n# שגיאה: Fronmatter לא חוקי\nנתיב: `%2$s`\n**%3$s**\n```\n%4$s\n```" + INFLECTOR_UNCOUNTABLE: + - 'ציוד' + - 'מידע' + - 'אורז' + - 'כסף' + - 'מינים' + - 'סדרה' + - 'דג' + - 'כבשה' + INFLECTOR_IRREGULAR: + 'person': 'אנשים' + 'man': 'גברים' + 'child': 'ילדים' + 'sex': 'מינים' + 'move': 'מהלכים' + NICETIME: + NO_DATE_PROVIDED: לא סופק תאריך + BAD_DATE: תאריך פגום + AGO: לפני + FROM_NOW: כרגע + JUST_NOW: כרגע + SECOND: שנייה + MINUTE: דקה + HOUR: שעה + DAY: יום + WEEK: שבוע + MONTH: חודש + YEAR: שנה + DECADE: עשור + SEC: שנ' + MIN: דק' + HR: ש' + WK: שב' + MO: חו' + YR: שני' + DEC: עש' + SECOND_PLURAL: שניות + MINUTE_PLURAL: דקות + HOUR_PLURAL: שעות + DAY_PLURAL: ימים + WEEK_PLURAL: שבועות + MONTH_PLURAL: חודשים + YEAR_PLURAL: שנים + DECADE_PLURAL: עשורים + SEC_PLURAL: שנ' + MIN_PLURAL: דק' + HR_PLURAL: ש' + WK_PLURAL: שב' + MO_PLURAL: חו' + YR_PLURAL: שני' + DEC_PLURAL: עש' + FORM: + VALIDATION_FAIL: 'האימות נכשל:' + INVALID_INPUT: 'קלט לא חוקי' + MISSING_REQUIRED_FIELD: 'שדות חובה חסרים:' + XSS_ISSUES: "בעיות XSS פוטנציאליות זוהו בשדה '%s'" + MONTHS_OF_THE_YEAR: + - 'ינואר' + - 'פברואר' + - 'מרץ' + - 'אפריל' + - 'מאי' + - 'יוני' + - 'יולי' + - 'אוגוסט' + - 'ספטמבר' + - 'אוקטובר' + - 'נובמבר' + - 'דצמבר' + DAYS_OF_THE_WEEK: + - 'שני' + - 'שלישי' + - 'רביעי' + - 'חמישי' + - 'שישי' + - 'שבת' + - 'ראשון' + YES: "כן" + NO: "לא" + CRON: + EVERY: בכל + EVERY_HOUR: בכל שעה + EVERY_MINUTE: כל דקה + EVERY_DAY_OF_WEEK: כל יום בשבוע + EVERY_DAY_OF_MONTH: בכל יום בחודש + EVERY_MONTH: כל חודש + TEXT_PERIOD: כל + TEXT_MINS: 'ב דקות אחרי השעה' + TEXT_TIME: 'ב :' + TEXT_DOW: 'ב ' + TEXT_MONTH: 'של ' + TEXT_DOM: 'ב ' + ERROR1: התגית %s אינו נתמכת + ERROR2: מספר לא חוקי של משתנים. + ERROR3: יש להגדיר את ה-jquery_element להגדרות jqCron + ERROR4: ביטוי לא מזוהה diff --git a/system/languages/hr.yaml b/system/languages/hr.yaml new file mode 100644 index 0000000..31b4154 --- /dev/null +++ b/system/languages/hr.yaml @@ -0,0 +1,104 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\nnaslov: %1$s\n---\n\n# Pogreška: nevažeći frontmatter\n\nPutanja datoteke: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_UNCOUNTABLE: + - 'oprema' + - 'informacija' + - 'riža' + - 'novac' + - 'vrsta' + - 'serija' + - 'riba' + - 'ovca' + INFLECTOR_IRREGULAR: + 'person': 'osobe' + 'man': 'ljudi' + 'child': 'djeca' + 'sex': 'spolovi' + 'move': 'Pomakni' + INFLECTOR_ORDINALS: + 'default': '.' + 'first': '.' + 'second': '.' + 'third': '.' + NICETIME: + NO_DATE_PROVIDED: Datum nije upisan + BAD_DATE: Pogrešan datum + AGO: prije + FROM_NOW: od sada + JUST_NOW: upravo sad + SECOND: sekunda + MINUTE: minuta + HOUR: sat + DAY: dan + WEEK: tjedan + MONTH: mjesec + YEAR: godina + DECADE: desetljeće + SEC: sek + MIN: min + HR: sat + WK: t + MO: m + YR: g + DEC: des + SECOND_PLURAL: sekundi + MINUTE_PLURAL: minuta + HOUR_PLURAL: sati + DAY_PLURAL: dan + WEEK_PLURAL: tjedana + MONTH_PLURAL: mjeseci + YEAR_PLURAL: godina + DECADE_PLURAL: desetljeća + SEC_PLURAL: sek + MIN_PLURAL: min + HR_PLURAL: sat + WK_PLURAL: t + MO_PLURAL: m + YR_PLURAL: g + DEC_PLURAL: des + FORM: + VALIDATION_FAIL: 'Validacija nije uspjela:' + INVALID_INPUT: 'Pogrešan unos u' + MISSING_REQUIRED_FIELD: 'Nedostaje obavezno polje:' + XSS_ISSUES: "Potencijalni XSS problemi otkriveni u polju '%s'" + MONTHS_OF_THE_YEAR: + - 'Siječanj' + - 'Veljača' + - 'Ožujak' + - 'Travanj' + - 'Svibanj' + - 'Lipanj' + - 'Srpanj' + - 'Kolovoz' + - 'Rujan' + - 'Listopad' + - 'Studeni' + - 'Prosinac' + DAYS_OF_THE_WEEK: + - 'Ponedjeljak' + - 'Utorak' + - 'Srijeda' + - 'Četvrtak' + - 'Petak' + - 'Subota' + - 'Nedjelja' + YES: "Da" + NO: "Ne" + CRON: + EVERY: svaki + EVERY_HOUR: svaki sat + EVERY_MINUTE: svake minute + EVERY_DAY_OF_WEEK: svaki dan u tjednu + EVERY_DAY_OF_MONTH: svaki dan u mjesecu + EVERY_MONTH: svaki mjesec + TEXT_PERIOD: Svakih + TEXT_MINS: ' u minut(e) nakon sata' + TEXT_TIME: ' u :' + TEXT_DOW: ' na ' + TEXT_MONTH: ' ' + TEXT_DOM: ' na ' + ERROR1: Oznaka %s nije podržana! + ERROR2: Pogrešan broj elemenata. + ERROR3: jquery_element treba postaviti u postavke jqCron + ERROR4: Izraz nije prepoznat diff --git a/system/languages/hu.yaml b/system/languages/hu.yaml new file mode 100644 index 0000000..2624cf9 --- /dev/null +++ b/system/languages/hu.yaml @@ -0,0 +1,97 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ncím: %1$s\n---\n\n# Hiba: Érvénytelen Frontmatter\n\nElérési út: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_UNCOUNTABLE: + - 'felszerelés' + - 'információ' + - 'rizs' + - 'pénz' + - 'fajok' + - 'sorozat' + - 'hal' + - 'juh' + INFLECTOR_IRREGULAR: + 'person': 'személyek' + 'man': 'férfiak' + 'child': 'gyerekek' + 'sex': 'nemek' + 'move': 'lépések' + INFLECTOR_ORDINALS: + 'default': '.' + 'first': '.' + 'second': '.' + 'third': '.' + NICETIME: + NO_DATE_PROVIDED: Nincs dátum megadva + BAD_DATE: Hibás dátum + AGO: elteltével + FROM_NOW: mostantól + JUST_NOW: épp most + SECOND: másodperc + MINUTE: perc + HOUR: óra + DAY: nap + WEEK: hét + MONTH: hónap + YEAR: év + DECADE: évtized + SEC: mp + MIN: p + HR: ó + WK: hét + MO: hó + YR: év + DEC: évt + SECOND_PLURAL: másodperc + MINUTE_PLURAL: perc + HOUR_PLURAL: óra + DAY_PLURAL: nap + WEEK_PLURAL: hét + MONTH_PLURAL: hónap + YEAR_PLURAL: év + DECADE_PLURAL: évtized + SEC_PLURAL: mp + MIN_PLURAL: perc + HR_PLURAL: ó + WK_PLURAL: hét + MO_PLURAL: hó + YR_PLURAL: év + DEC_PLURAL: évt + FORM: + VALIDATION_FAIL: 'Érvényesítés nem sikerült:' + INVALID_INPUT: 'A megadott érték érvénytelen:' + MISSING_REQUIRED_FIELD: 'Ez a kötelező mező nincs kitöltve:' + MONTHS_OF_THE_YEAR: + - 'január' + - 'február' + - 'március' + - 'április' + - 'május' + - 'június' + - 'július' + - 'augusztus' + - 'szeptember' + - 'október' + - 'november' + - 'december' + DAYS_OF_THE_WEEK: + - 'hétfő' + - 'kedd' + - 'szerda' + - 'csütörtök' + - 'péntek' + - 'szombat' + - 'vasárnap' + CRON: + EVERY: minden + EVERY_HOUR: óránként + EVERY_MINUTE: percenként + EVERY_DAY_OF_WEEK: a hét minden napján + EVERY_DAY_OF_MONTH: a hónap minden napján + EVERY_MONTH: minden hónapban + TEXT_PERIOD: Minden + TEXT_MINS: ' perccel az óra elteltével' + ERROR1: A %s címke nem engedélyezett! + ERROR2: Hibás elemszám + ERROR3: A jquery_element-et a jqCron beállítsokban kell meghatározni + ERROR4: Ismeretlen kifejezés diff --git a/system/languages/id.yaml b/system/languages/id.yaml new file mode 100644 index 0000000..8107235 --- /dev/null +++ b/system/languages/id.yaml @@ -0,0 +1,147 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Error: Frontmatter tidak valid\n\nLokasi: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'Peralatan' + - 'Informasi ' + - 'Nasi' + - 'Uang' + - 'Jenis' + - 'Seri' + - 'Ikan' + - 'Domba' + INFLECTOR_IRREGULAR: + 'person': 'Orang-orang' + 'man': 'Pria' + 'child': 'Balita' + 'sex': 'Jenis Kelamin' + 'move': 'pindahkan' + INFLECTOR_ORDINALS: + 'default': 'ke' + 'first': 'pertama' + 'second': 'nd' + 'third': 'rd' + NICETIME: + NO_DATE_PROVIDED: Tidak ada tanggal yang disediakan + BAD_DATE: Format tanggal salah + AGO: yang lalu + FROM_NOW: dari sekarang + JUST_NOW: baru saja + SECOND: detik + MINUTE: menit + HOUR: jam + DAY: hari + WEEK: pekan + MONTH: bulan + YEAR: tahun + DECADE: dekade + SEC: detik + MIN: menit + HR: ' jam' + WK: minggu + MO: bulan + YR: tahun + DEC: desimal + SECOND_PLURAL: detik + MINUTE_PLURAL: menit + HOUR_PLURAL: jam + DAY_PLURAL: hari + WEEK_PLURAL: pekan + MONTH_PLURAL: bulan + YEAR_PLURAL: tahun + DECADE_PLURAL: dekade + SEC_PLURAL: detik + MIN_PLURAL: menit + HR_PLURAL: jam + WK_PLURAL: minggu + MO_PLURAL: bulan + YR_PLURAL: tahun + DEC_PLURAL: dekade + FORM: + VALIDATION_FAIL: 'Validasi gagal:' + INVALID_INPUT: 'Input tidak valid di' + MISSING_REQUIRED_FIELD: 'Data yang diperlukan belum terisi:' + XSS_ISSUES: "Isu berpotensial XSS terdeteksi dalam baris %s" + MONTHS_OF_THE_YEAR: + - 'Januari' + - 'Februari' + - 'Maret' + - 'April' + - 'Mei' + - 'Juni' + - 'Juli' + - 'Agustus' + - 'September' + - 'Oktober' + - 'November' + - 'Desember' + DAYS_OF_THE_WEEK: + - 'Senin' + - 'Selasa' + - 'Rabu' + - 'Kamis' + - 'Jum''at' + - 'Sabtu' + - 'Minggu' + YES: "Ya" + NO: "Tidak" + CRON: + EVERY: Setiap + EVERY_HOUR: Setiap jam + EVERY_MINUTE: Setiap menit + EVERY_DAY_OF_WEEK: Setiap hari selama seminggu + EVERY_DAY_OF_MONTH: Setiap hari dalam sebulan + EVERY_MONTH: setiap bulan + TEXT_PERIOD: Setiap + TEXT_MINS: 'dalam menit setelah jam yang lalu' + TEXT_TIME: ' pada :' + TEXT_DOW: ' pada ' + TEXT_MONTH: ' pada ' + TEXT_DOM: ' pada ' + ERROR1: Tag %s tidak didukung! + ERROR2: Jumlah elemen yang buruk + ERROR3: jquery_element harus diatur ke dalam pengaturan jqCron + ERROR4: Ekspresi tidak dikenal diff --git a/system/languages/is.yaml b/system/languages/is.yaml new file mode 100644 index 0000000..c6f8f5d --- /dev/null +++ b/system/languages/is.yaml @@ -0,0 +1,80 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitill: %1$s\n---\n\n# Villa: Ógilt efni á forsíðu\n\nSlóð: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_UNCOUNTABLE: + - '' + - 'upplýsingar' + - '' + - '' + - '' + - '' + - '' + - '' + NICETIME: + NO_DATE_PROVIDED: Engin dagsetning gefin + BAD_DATE: Röng dagsetning + AGO: síðan + JUST_NOW: í þessu + SECOND: sekúndu + MINUTE: mínútu + HOUR: klukkustund + DAY: degi + WEEK: viku + MONTH: mánuði + YEAR: ári + DECADE: áratug + SEC: sek + MIN: mín + HR: klst + WK: vk + MO: mán + YR: ár + DEC: árat + SECOND_PLURAL: sekúndum + MINUTE_PLURAL: mínútum + HOUR_PLURAL: klukkustundum + DAY_PLURAL: dögum + WEEK_PLURAL: vikum + MONTH_PLURAL: mánuðum + YEAR_PLURAL: árum + DECADE_PLURAL: áratugum + SEC_PLURAL: sek + MIN_PLURAL: mín + HR_PLURAL: klst + WK_PLURAL: vik + MO_PLURAL: mán + YR_PLURAL: árum + DEC_PLURAL: árat + FORM: + VALIDATION_FAIL: 'Sannvottun mistókst:' + INVALID_INPUT: 'Ógilt inntak í' + MISSING_REQUIRED_FIELD: 'Vantar nauðsynlegan reit:' + MONTHS_OF_THE_YEAR: + - 'janúar' + - 'Febrúar' + - 'Mars' + - 'Apríl' + - 'Maí' + - 'Júní' + - 'Júlí' + - 'Ágúst' + - 'September' + - 'Október' + - 'Nóvember' + - 'Desember' + DAYS_OF_THE_WEEK: + - 'Mánudagur' + - 'Þriðjudagur' + - 'Miðvikudagur' + - 'Fimmtudagur' + - 'Föstudagur' + - 'Laugardagur' + - 'Sunnudagur' + CRON: + TEXT_TIME: ' á :' + TEXT_DOW: ' á ' + TEXT_MONTH: ' af ' + TEXT_DOM: ' á ' + ERROR1: Merkið %s er ekki stutt! + ERROR3: Það ætti að setja jquery_element inn í stillingar jqCron + ERROR4: Óþekkt segð diff --git a/system/languages/it.yaml b/system/languages/it.yaml new file mode 100644 index 0000000..f366eb6 --- /dev/null +++ b/system/languages/it.yaml @@ -0,0 +1,147 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---Titolo: %1$s---# Errore: Frontmatter non valido: '%2$s' * *%3$s * * ' '%4$s ' '" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'dotazione' + - 'informazione' + - 'riso' + - 'denaro' + - 'specie' + - 'serie' + - 'pesce' + - 'pecora' + INFLECTOR_IRREGULAR: + 'person': 'persone' + 'man': 'uomini' + 'child': 'bambino' + 'sex': 'sessi' + 'move': 'sposta' + INFLECTOR_ORDINALS: + 'default': '°' + 'first': '°' + 'second': 'o' + 'third': 'o' + NICETIME: + NO_DATE_PROVIDED: Nessuna data fornita + BAD_DATE: Data non valida + AGO: fa + FROM_NOW: da adesso + JUST_NOW: ora + SECOND: secondo + MINUTE: minuto + HOUR: ora + DAY: giorno + WEEK: settimana + MONTH: mese + YEAR: anno + DECADE: decennio + SEC: sec + MIN: min + HR: ora + WK: settimana + MO: mese + YR: anno + DEC: decennio + SECOND_PLURAL: secondi + MINUTE_PLURAL: minuti + HOUR_PLURAL: ore + DAY_PLURAL: giorni + WEEK_PLURAL: settimane + MONTH_PLURAL: mesi + YEAR_PLURAL: anni + DECADE_PLURAL: decadi + SEC_PLURAL: secondi + MIN_PLURAL: minuti + HR_PLURAL: ore + WK_PLURAL: settimane + MO_PLURAL: mesi + YR_PLURAL: anni + DEC_PLURAL: decenni + FORM: + VALIDATION_FAIL: 'Validazione fallita:' + INVALID_INPUT: 'Input non valido in' + MISSING_REQUIRED_FIELD: 'Campo richiesto mancante:' + XSS_ISSUES: "Rilevati potenziali problemi di XSS nel campo '%s'" + MONTHS_OF_THE_YEAR: + - 'Gennaio' + - 'Febbraio' + - 'Marzo' + - 'Aprile' + - 'Maggio' + - 'Giugno' + - 'Luglio' + - 'Agosto' + - 'Settembre' + - 'Ottobre' + - 'Novembre' + - 'Dicembre' + DAYS_OF_THE_WEEK: + - 'Lunedì' + - 'Martedì' + - 'Mercoledì' + - 'Giovedì' + - 'Venerdì' + - 'Sabato' + - 'Domenica' + YES: "Sì" + NO: "No" + CRON: + EVERY: ogni + EVERY_HOUR: ogni ora + EVERY_MINUTE: ogni minuto + EVERY_DAY_OF_WEEK: ogni giorno della settimana + EVERY_DAY_OF_MONTH: ogni giorno del mese + EVERY_MONTH: ogni mese + TEXT_PERIOD: Ogni + TEXT_MINS: ' a minuto(i) dall''inizio dell''ora' + TEXT_TIME: ' alle :' + TEXT_DOW: ' su ' + TEXT_MONTH: ' di ' + TEXT_DOM: ' di ' + ERROR1: Il tag %s non è supportato! + ERROR2: Numero di elementi non valido + ERROR3: Il jquery_element deve essere impostato nelle impostazioni di jqCron + ERROR4: Espressione non riconosciuta diff --git a/system/languages/ja.yaml b/system/languages/ja.yaml new file mode 100644 index 0000000..16c015c --- /dev/null +++ b/system/languages/ja.yaml @@ -0,0 +1,81 @@ +--- +GRAV: + INFLECTOR_UNCOUNTABLE: + - '' + - '情報' + - '' + - 'お金' + - '' + - '' + - '魚' + - 'ヒツジ' + INFLECTOR_IRREGULAR: + 'person': 'みんな' + 'man': '人' + 'child': '子供' + 'sex': '性別' + 'move': '移動' + INFLECTOR_ORDINALS: + 'first': '番目' + NICETIME: + NO_DATE_PROVIDED: 日付が設定されていません + BAD_DATE: 不正な日付 + AGO: 前 + SECOND: 秒 + MINUTE: 分 + HOUR: 時 + DAY: 日 + WEEK: 週 + MONTH: 月 + YEAR: 年 + DECADE: 10年 + SEC: 秒 + MIN: 分 + HR: 時 + WK: 週 + MO: 月 + YR: 年 + SECOND_PLURAL: 秒 + MINUTE_PLURAL: 分 + HOUR_PLURAL: 時 + DAY_PLURAL: 日 + WEEK_PLURAL: 週 + MONTH_PLURAL: 月 + YEAR_PLURAL: 年 + DECADE_PLURAL: 10年 + SEC_PLURAL: 秒 + MIN_PLURAL: 分 + HR_PLURAL: 時 + WK_PLURAL: 週 + MO_PLURAL: 月 + YR_PLURAL: 年 + DEC_PLURAL: 10年 + FORM: + VALIDATION_FAIL: 'バリデーション失敗 :' + INVALID_INPUT: '不正な入力:' + MISSING_REQUIRED_FIELD: '必須項目が入力されていません:' + MONTHS_OF_THE_YEAR: + - '1月' + - '2月' + - '3月' + - '4月' + - '5月' + - '6月' + - '7月' + - '8月' + - '9月' + - '10月' + - '11月' + - '12月' + DAYS_OF_THE_WEEK: + - '月' + - '火' + - '水' + - '木' + - '金' + - '土' + - '日' + CRON: + EVERY: 毎 + EVERY_MONTH: 毎月 + ERROR1: 共有タイプ %s はサポートされていません diff --git a/system/languages/ko.yaml b/system/languages/ko.yaml new file mode 100644 index 0000000..f7dca33 --- /dev/null +++ b/system/languages/ko.yaml @@ -0,0 +1,90 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# 오류: 무효의 Frontmatter\n\n경로: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_UNCOUNTABLE: + - '장비' + - '정보' + - '' + - '' + - '' + - '시리즈' + - '물고기' + - '' + INFLECTOR_IRREGULAR: + 'person': '사람들' + NICETIME: + NO_DATE_PROVIDED: 제공된 날짜가 없습니다 + BAD_DATE: 잘못된 날짜 + AGO: 전 + FROM_NOW: 후 + JUST_NOW: 방금 + SECOND: 초 + MINUTE: 분 + HOUR: 시간 + DAY: 일 + WEEK: 주 + MONTH: 개월 + YEAR: 년 + DECADE: 년간 + SEC: 초 + MIN: 분 + HR: 시간 + WK: 주 + MO: 개월 + YR: 년 + DEC: 년간 + SECOND_PLURAL: 초 + MINUTE_PLURAL: 분 + HOUR_PLURAL: 시간 + DAY_PLURAL: 일 + WEEK_PLURAL: 주 + MONTH_PLURAL: 개월 + YEAR_PLURAL: 년 + DECADE_PLURAL: 년간 + SEC_PLURAL: 초 + MIN_PLURAL: 분 + HR_PLURAL: 시간 + WK_PLURAL: 주 + MO_PLURAL: 개월 + YR_PLURAL: 년 + DEC_PLURAL: 년간 + FORM: + VALIDATION_FAIL: '유효성 검사 실패:' + INVALID_INPUT: '잘못된 입력' + MISSING_REQUIRED_FIELD: '누락 된 필수 필드:' + XSS_ISSUES: "'%s' 필드에서 잠재적인 XSS 문제가 감지되었습니다." + MONTHS_OF_THE_YEAR: + - '일월' + - '이월' + - '삼월' + - '사월' + - '오월' + - '유월' + - '칠월' + - '팔월' + - '구월' + - '시월' + - '십일월' + - '십이월' + DAYS_OF_THE_WEEK: + - '월요일' + - '화요일' + - '수요일' + - '목요일' + - '금요일' + - '토요일' + - '일요일' + YES: "네" + NO: "아니요" + CRON: + EVERY: 모두 + EVERY_HOUR: 매 시간 + EVERY_MINUTE: 매 분 + EVERY_DAY_OF_WEEK: 일주일간 매일 + EVERY_DAY_OF_MONTH: 일개월간 매일 + EVERY_MONTH: 매달 + TEXT_PERIOD: 모든 + ERROR1: '%s 태그는 지원되지 않습니다. ' + ERROR2: 잘못된 요소 수 + ERROR3: jquery_element는 jqCron 설정에서 설정할 수 있습니다. + ERROR4: 인식할 수 없는 표현 diff --git a/system/languages/lt.yaml b/system/languages/lt.yaml new file mode 100644 index 0000000..88914ed --- /dev/null +++ b/system/languages/lt.yaml @@ -0,0 +1,78 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Klaida: klaidinga įžanginė konfigūracija\n\nPath: `%2$s`\n\n**%3$s**\n\n```\n %4$s\n```" + INFLECTOR_UNCOUNTABLE: + - '' + - '' + - 'ryžiai' + - 'pinigai' + - 'prieskoniai' + - 'serijos' + - 'žuvis' + - 'avis' + INFLECTOR_IRREGULAR: + 'person': 'žmonės' + 'man': 'žmogus' + 'child': 'vaikai' + 'sex': 'lytys' + 'move': 'juda' + NICETIME: + NO_DATE_PROVIDED: Nenurodyta data + BAD_DATE: Neteisinga data + AGO: prieš + FROM_NOW: nuo dabar + SECOND: sekundė + MINUTE: minutė + HOUR: valanda + DAY: diena + WEEK: savaitė + MONTH: mėnuo + YEAR: metai + DECADE: dešimtmetis + SEC: sek. + MIN: min. + HR: val. + WK: sav. + MO: mėn. + YR: m. + DEC: dešimtmetis + SECOND_PLURAL: sekundės + MINUTE_PLURAL: minutės + HOUR_PLURAL: valandos + DAY_PLURAL: dienos + WEEK_PLURAL: savaitės + MONTH_PLURAL: mėnesiai + YEAR_PLURAL: metai + DECADE_PLURAL: dešimtmečiai + SEC_PLURAL: sek. + MIN_PLURAL: min. + HR_PLURAL: val. + WK_PLURAL: sav. + MO_PLURAL: mėn. + YR_PLURAL: m. + DEC_PLURAL: dešimtmečiai + FORM: + VALIDATION_FAIL: 'Patvirtinimas nepavyko:' + INVALID_INPUT: 'Neteisingai įvesta į' + MISSING_REQUIRED_FIELD: 'Būtina užpildyti laukelį:' + MONTHS_OF_THE_YEAR: + - 'Sausis' + - 'Vasaris' + - 'Kovas' + - 'Balandis' + - 'Gegužė' + - 'Birželis' + - 'Liepa' + - 'Rugpjūtis' + - 'Rugsėjis' + - 'Spalis' + - 'Lakpritis' + - 'Gruodis' + DAYS_OF_THE_WEEK: + - 'Pirmadienis' + - 'Antradienis' + - 'Trečiadienis' + - 'Ketvirtadienis' + - 'Penktadienis' + - 'Šeštadienis' + - 'Sekmadienis' diff --git a/system/languages/lv.yaml b/system/languages/lv.yaml new file mode 100644 index 0000000..b096c96 --- /dev/null +++ b/system/languages/lv.yaml @@ -0,0 +1,84 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\nNosaukums: %1$s\n---\n\n# Kļūda: Nederīgs Frontmatter\n\nCeļš: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_ORDINALS: + 'default': '.' + 'first': '.' + 'second': '.' + 'third': '.' + NICETIME: + NO_DATE_PROVIDED: Nav norādīts datums + BAD_DATE: Nederīgs datums + AGO: iepriekš + FROM_NOW: no šī brīža + JUST_NOW: tikko + SECOND: sekundes + MINUTE: minūte + HOUR: stunda + DAY: diena + WEEK: nedēļa + MONTH: mēnesis + YEAR: gads + DECADE: dekāde + SEC: s + MIN: m + HR: st + WK: ned + MO: mēn. + YR: g. + DEC: dec + SECOND_PLURAL: sekundes + MINUTE_PLURAL: minūtes + HOUR_PLURAL: stundas + DAY_PLURAL: dienas + WEEK_PLURAL: nedēļas + MONTH_PLURAL: mēneši + YEAR_PLURAL: gadi + DECADE_PLURAL: desmitgades + SEC_PLURAL: s + MIN_PLURAL: m + HR_PLURAL: st. + WK_PLURAL: ned. + MO_PLURAL: mēn. + YR_PLURAL: g. + DEC_PLURAL: d + FORM: + VALIDATION_FAIL: 'Validācija neizdevās:' + INVALID_INPUT: 'Nederīga ievade' + MISSING_REQUIRED_FIELD: 'Laukā trūkst datu' + XSS_ISSUES: "Atrastas iespējamas XSS problēmas laukā '%s'" + MONTHS_OF_THE_YEAR: + - 'Janvāris' + - 'Februāris' + - 'Marts' + - 'Aprīlis' + - 'Maijs' + - 'Jūnijs' + - 'Jūlijs' + - 'Augusts' + - 'Septembris' + - 'Oktobris' + - 'Novembris' + - 'Decembris' + DAYS_OF_THE_WEEK: + - 'Pirmdiena' + - 'Otrdiena' + - 'Trešdiena' + - 'Ceturtdiena' + - 'Piektdiena' + - 'Sestdiena' + - 'Svētdiena' + YES: "Jā" + NO: "Nē" + CRON: + EVERY: katru + EVERY_HOUR: katru stundu + EVERY_MINUTE: katru minūti + EVERY_DAY_OF_WEEK: katru nedēļas dienu + EVERY_DAY_OF_MONTH: katru mēneša dienu + EVERY_MONTH: katru mēnesi + TEXT_PERIOD: Katru + ERROR1: Marķieris %s nav atbalstīts! + ERROR2: Nederīgs elementu skaits + ERROR3: jquery_element nevajadzētu definēt jqCron iestatījumos + ERROR4: Neatpazīta izteiksme diff --git a/system/languages/mn.yaml b/system/languages/mn.yaml new file mode 100644 index 0000000..73cda0e --- /dev/null +++ b/system/languages/mn.yaml @@ -0,0 +1,147 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\nГарчиг: %1$s\n---\n\n# Алдаа: Буруу Формат\n\nЗам: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1зүүд' + '/^(ox)$/i': '\1ууд' + '/([m|l])ouse$/i': '\1ууд' + '/(matr|vert|ind)ix|ex$/i': '\1иксүүд' + '/(x|ch|ss|sh)$/i': '\1үүд' + '/([^aeiouy]|qu)ies$/i': '\1үүд' + '/([^aeiouy]|qu)y$/i': '\1үүд' + '/(hive)$/i': '\1үүд' + '/(?:([^f])fe|([lr])f)$/i': '\1\2үүд' + '/sis$/i': 'үүд' + '/([ti])um$/i': '\1үүд' + '/(buffal|tomat)o$/i': '\1үүд' + '/(bu)s$/i': '\1үүд' + '/(alias|status)/i': '\1үүд' + '/(octop|vir)us$/i': '\1үүд' + '/(ax|test)is$/i': '\1үүд' + '/s$/i': 'үүд' + '/$/': 'үүд' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1икс' + '/(vert|ind)ices$/i': '\1икс' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1' + '/(cris|ax|test)es$/i': '\1' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1' + '/(s)eries$/i': '\1' + '/([^aeiouy]|qu)ies$/i': '\1үүд' + '/([lr])ves$/i': '\1' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1' + '/(^analy)ses$/i': '\1' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2үүд' + '/([ti])a$/i': '\1' + '/(n)ews$/i': '\1' + INFLECTOR_UNCOUNTABLE: + - 'тоног төхөөрөмж' + - 'Мэдээлэл' + - 'будаа' + - 'мөнгө' + - 'төрөл зүйл' + - 'цуврал' + - 'загас' + - 'хонь' + INFLECTOR_IRREGULAR: + 'person': 'хүмүүс' + 'man': 'эрчүүд' + 'child': 'хүүхэд' + 'sex': 'хүйс' + 'move': 'хөдөлгөөн' + INFLECTOR_ORDINALS: + 'default': 'th' + 'first': 'st' + 'second': 'nd' + 'third': 'rd' + NICETIME: + NO_DATE_PROVIDED: Огноо алга + BAD_DATE: Буруу огноо + AGO: өмнө + FROM_NOW: одооноос + JUST_NOW: дөнгөж сая + SECOND: секунд + MINUTE: минут + HOUR: цаг + DAY: өдөр + WEEK: долоо хоног + MONTH: сар + YEAR: он + DECADE: арван жил + SEC: сек + MIN: мин + HR: цаг + WK: д.х. + MO: сар + YR: он + DEC: арван жил + SECOND_PLURAL: секунд + MINUTE_PLURAL: минут + HOUR_PLURAL: цаг + DAY_PLURAL: өдрүүд + WEEK_PLURAL: долоо хоногууд + MONTH_PLURAL: сарууд + YEAR_PLURAL: онууд + DECADE_PLURAL: арван жилүүд + SEC_PLURAL: сек.-үүд + MIN_PLURAL: мин.-ууд + HR_PLURAL: цагууд + WK_PLURAL: д.х.-ууд + MO_PLURAL: сарууд + YR_PLURAL: жилүүд + DEC_PLURAL: арван жилүүд + FORM: + VALIDATION_FAIL: 'Баталгаажуулалт амжилтгүй боллоо:' + INVALID_INPUT: 'Буруу өгөгдөл дараахид' + MISSING_REQUIRED_FIELD: 'Шаардлагатай талбар дутуу байна:' + XSS_ISSUES: "'%s' талбарт XSS -ийн болзошгүй асуудлууд илэрсэн" + MONTHS_OF_THE_YEAR: + - '1-р сар' + - '2-р сар' + - '3-р сар' + - '4-р сар' + - '5 сар' + - '6 сар' + - '7 сар' + - '8 сар' + - '9 сар' + - '10 сар' + - '11 сар' + - '12 сар' + DAYS_OF_THE_WEEK: + - 'Даваа гараг' + - 'Мягмар гараг' + - 'Лхагва гараг' + - 'Пүрэв гараг' + - 'Баасан гараг' + - 'Бямба гараг' + - 'Ням гараг' + YES: "Тийм" + NO: "Үгүй" + CRON: + EVERY: бүрийн + EVERY_HOUR: цаг бүрийн + EVERY_MINUTE: минут бүрийн + EVERY_DAY_OF_WEEK: долоо хоногийн өдөр болгонд + EVERY_DAY_OF_MONTH: сарын өдөр болгонд + EVERY_MONTH: сар болгон + TEXT_PERIOD: Бүрийн + TEXT_MINS: ' энэ сүүлийн цагийн минутад' + TEXT_TIME: ' : -д' + TEXT_DOW: ' -д' + TEXT_MONTH: ' -ын' + TEXT_DOM: ' -т' + ERROR1: '%s -н утга нь дэмжигддэггүй!' + ERROR2: Элементүүдийн тоо хэмжээ буруу + ERROR3: jquery_element нь jqCron тохиргоонд хийгдсэн байх ёстой + ERROR4: Танигдаагүй илэрхийлэл diff --git a/system/languages/my.yaml b/system/languages/my.yaml new file mode 100644 index 0000000..3236cd1 --- /dev/null +++ b/system/languages/my.yaml @@ -0,0 +1,147 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\nခေါင်းစဥ်: %1$s\n---\n\n# အမှား - Frontmatter မမှန်ကန်ပါ\n\nလမ်းကြောင်း `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'ကိရိယာ' + - 'အချက်အလက်' + - 'ဆန်' + - 'ငွေ' + - 'မျိုးစိတ်' + - 'အတွဲများ' + - 'ငါး' + - 'သိုးများ' + INFLECTOR_IRREGULAR: + 'person': 'လူ' + 'man': 'ယောက်ျား' + 'child': 'ကလေးများ' + 'sex': 'လိင်' + 'move': 'ရွှေ့ခြင်း' + INFLECTOR_ORDINALS: + 'default': 'th' + 'first': 'st' + 'second': 'nd' + 'third': 'rd' + NICETIME: + NO_DATE_PROVIDED: နေ့စွဲ မသတ်မှတ်ထား + BAD_DATE: ရက်စွဲမမှန်ပါ + AGO: လွန်ခဲ့တဲ့ + FROM_NOW: ယခုမှ + JUST_NOW: အခုပဲ + SECOND: ဒုတိယ + MINUTE: မိနစ် + HOUR: နာရီ + DAY: နေ့ + WEEK: တစ်ပတ် + MONTH: လ + YEAR: နှစ် + DECADE: ဆယ်စုနှစ် + SEC: စက္ကန့် + MIN: မိနစ် + HR: နာရီ + WK: တစ်ပတ် + MO: လ + YR: နှစ် + DEC: ဒီဇင်ဘာ + SECOND_PLURAL: စက္ကန့် + MINUTE_PLURAL: မိနစ် + HOUR_PLURAL: နာရီ + DAY_PLURAL: နေ့ + WEEK_PLURAL: ရက်သတ္တပတ် + MONTH_PLURAL: လ + YEAR_PLURAL: နှစ် + DECADE_PLURAL: ဆယ်စုနှစ်များစွ + SEC_PLURAL: စက္ကန့် + MIN_PLURAL: မိနစ် + HR_PLURAL: နာရီ + WK_PLURAL: အပတ် + MO_PLURAL: လ + YR_PLURAL: နှစ် + DEC_PLURAL: ဆယ်စုနှစ် + FORM: + VALIDATION_FAIL: ' အတည်ပြုခြင်းမအောင်မြင်ပါ: ' + INVALID_INPUT: 'ထည့်သွင်းမှုမမှန်ပါ' + MISSING_REQUIRED_FIELD: 'လိုအပ်သောအကွက်ပျောက်နေသည်' + XSS_ISSUES: "XSS ပြဿနာ ဖြစ်နိုင်ချေ ကို '%s' အကွက်တွင် တွေ့" + MONTHS_OF_THE_YEAR: + - 'ဇန်နဝါရီ' + - 'ဖေဖော်ဝါရီ' + - 'မတ်' + - 'ဧပြီ' + - 'မေ' + - 'ဇွန်' + - 'ဇူလိုင်' + - 'သြဂုတ်' + - 'စက်တင်ဘာ' + - 'အောက်တိုဘာ' + - 'နိုဝင်ဘာ' + - 'ဒီဇင်ဘာ' + DAYS_OF_THE_WEEK: + - 'တနင်္လာ' + - ' အင်္ဂါ' + - 'ဗုဒ္ဓဟူး' + - 'ကြာသပတေး' + - 'သောကြာ' + - 'စနေ' + - 'တနင်္ဂနွေ' + YES: "လုပ်" + NO: "မလုပ်" + CRON: + EVERY: အမြဲတမ်း + EVERY_HOUR: နာရီတိုင်း + EVERY_MINUTE: မိနစ်တိုင်း + EVERY_DAY_OF_WEEK: တစ်ပတ်လုံး နေ့တိုင်း + EVERY_DAY_OF_MONTH: တစ်လလုံး နေ့တိုင်း + EVERY_MONTH: လစဉ်လတိုင်း + TEXT_PERIOD: တိုင်း + TEXT_MINS: 'နာရီ ကျော်ပြီး မိနစ် တွင်' + TEXT_TIME: ' : တွင် ' + TEXT_DOW: ' ပေါ်တွင် ' + TEXT_MONTH: '၏ ' + TEXT_DOM: ' တွင် ' + ERROR1: ဤ %s တက် ကိုပံ့ပိုးမထားပါ။ + ERROR2: လိုအပ်သောထည့်သွင်း နာပတ် အမှားဖြစ်နေသည် + ERROR3: jquery_element ကို jqCron ဆက်တင် တွင်ထားရမည် + ERROR4: အသိအမှတ်မပြုသော အသုံးအနှုန်း diff --git a/system/languages/nb.yaml b/system/languages/nb.yaml new file mode 100644 index 0000000..8033e79 --- /dev/null +++ b/system/languages/nb.yaml @@ -0,0 +1,4 @@ +--- +GRAV: + MONTHS_OF_THE_YEAR: ['januar', 'februar', 'mars', 'april', 'mai', 'juni', 'juli', 'august', 'september', 'oktober', 'november', 'desember'] + DAYS_OF_THE_WEEK: ['mandag', 'tirsdag', 'onsdag', 'torsdag', 'fredag', 'lørdag', 'søndag'] diff --git a/system/languages/nl.yaml b/system/languages/nl.yaml new file mode 100644 index 0000000..0ad6c96 --- /dev/null +++ b/system/languages/nl.yaml @@ -0,0 +1,147 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitel: %1$s\n---\n\n# Fout: ongeldige frontmatter\n\nPad: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'uitrusting' + - 'informatie' + - 'rijst' + - 'geld' + - 'soorten' + - 'reeks' + - 'vis' + - 'schaap' + INFLECTOR_IRREGULAR: + 'person': 'personen' + 'man': 'mensen' + 'child': 'kinderen' + 'sex': 'geslacht' + 'move': 'verplaatsen' + INFLECTOR_ORDINALS: + 'default': 'th' + 'first': 'st' + 'second': 'nd' + 'third': 'rd' + NICETIME: + NO_DATE_PROVIDED: geen datum opgegeven + BAD_DATE: Datumformaat onjuist + AGO: geleden + FROM_NOW: vanaf nu + JUST_NOW: zojuist + SECOND: seconde + MINUTE: minuut + HOUR: uur + DAY: dag + WEEK: week + MONTH: maand + YEAR: jaar + DECADE: decennium + SEC: s + MIN: min + HR: u + WK: week + MO: ma + YR: j + DEC: decennia + SECOND_PLURAL: seconden + MINUTE_PLURAL: minuten + HOUR_PLURAL: uren + DAY_PLURAL: dagen + WEEK_PLURAL: weken + MONTH_PLURAL: maanden + YEAR_PLURAL: jaren + DECADE_PLURAL: decennia + SEC_PLURAL: seconden + MIN_PLURAL: minuten + HR_PLURAL: uren + WK_PLURAL: weken + MO_PLURAL: maanden + YR_PLURAL: jaren + DEC_PLURAL: decennia + FORM: + VALIDATION_FAIL: 'Validatie mislukt:' + INVALID_INPUT: 'Ongeldige invoer in' + MISSING_REQUIRED_FIELD: 'Ontbrekend verplicht veld:' + XSS_ISSUES: "Mogelijke XSS-problemen ontdekt in '%s' veld" + MONTHS_OF_THE_YEAR: + - 'Januari' + - 'Februari' + - 'Maart' + - 'April' + - 'Mei' + - 'Juni' + - 'Juli' + - 'Augustus' + - 'September' + - 'Oktober' + - 'November' + - 'December' + DAYS_OF_THE_WEEK: + - 'Maandag' + - 'Dinsdag' + - 'Woensdag' + - 'Donderdag' + - 'Vrijdag' + - 'Zaterdag' + - 'Zondag' + YES: "Ja" + NO: "Nee" + CRON: + EVERY: elke + EVERY_HOUR: elk uur + EVERY_MINUTE: elke minuut + EVERY_DAY_OF_WEEK: elke dag van de week + EVERY_DAY_OF_MONTH: elke dag van de maand + EVERY_MONTH: elke maand + TEXT_PERIOD: Elke + TEXT_MINS: ' minuten te laat' + TEXT_TIME: ' op :' + TEXT_DOW: ' op ' + TEXT_MONTH: ' van ' + TEXT_DOM: ' op ' + ERROR1: De tag %s wordt niet ondersteund! + ERROR2: Slecht aantal elementen + ERROR3: Het jquery_element moet ingesteld worden in de jqCron instellingen + ERROR4: Onbekende expressie diff --git a/system/languages/no.yaml b/system/languages/no.yaml new file mode 100644 index 0000000..2a93e6e --- /dev/null +++ b/system/languages/no.yaml @@ -0,0 +1,82 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\nTittel: %1$s\n---\n\n# Feilmelding: Ugyldig Frontmatter\n\nSti: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_UNCOUNTABLE: + - 'utstyr' + - 'informasjon' + - 'ris' + - 'penger' + - 'arter' + - 'serier' + - 'fisk' + - 'sau' + INFLECTOR_IRREGULAR: + 'person': 'folk' + 'man': 'menn' + 'child': 'barn' + 'sex': 'kjønn' + 'move': 'trekk' + NICETIME: + NO_DATE_PROVIDED: Ingen dato gitt + BAD_DATE: Ugyldig dato + AGO: siden + FROM_NOW: fra nå + JUST_NOW: akkurat nå + SECOND: sekund + MINUTE: minutt + HOUR: time + DAY: dag + WEEK: uke + MONTH: måned + YEAR: år + DECADE: tiår + SEC: sek + HR: t + WK: uke + MO: må + YR: år + DEC: tiår + SECOND_PLURAL: sekunder + MINUTE_PLURAL: minutter + HOUR_PLURAL: timer + DAY_PLURAL: dager + WEEK_PLURAL: uker + MONTH_PLURAL: måneder + YEAR_PLURAL: år + DECADE_PLURAL: tiår + SEC_PLURAL: sek + MIN_PLURAL: min + HR_PLURAL: timer + WK_PLURAL: uker + MO_PLURAL: md + YR_PLURAL: år + DEC_PLURAL: årtier + FORM: + VALIDATION_FAIL: 'Godkjenning mislyktes:' + INVALID_INPUT: 'Ugyldig innhold i' + MISSING_REQUIRED_FIELD: 'Mangler påkrevd felt:' + MONTHS_OF_THE_YEAR: + - 'januar' + - 'februar' + - 'mars' + - 'april' + - 'mai' + - 'juni' + - 'juli' + - 'august' + - 'september' + - 'oktober' + - 'november' + - 'desember' + DAYS_OF_THE_WEEK: + - 'mandag' + - 'tirsdag' + - 'onsdag' + - 'torsdag' + - 'fredag' + - 'lørdag' + - 'søndag' + CRON: + EVERY: hver + EVERY_HOUR: hver time + EVERY_MINUTE: hvert minutt diff --git a/system/languages/pl.yaml b/system/languages/pl.yaml new file mode 100644 index 0000000..360e41e --- /dev/null +++ b/system/languages/pl.yaml @@ -0,0 +1,100 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Error: Nieprawidłowy Frontmatter\n\nPath: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_SINGULAR: + '/(alias|status)es$/i': '\1' + INFLECTOR_UNCOUNTABLE: + - 'wyposażenie' + - 'informacja' + - '' + - 'pieniądze' + - '' + - '' + - 'ryba' + - 'owca' + INFLECTOR_IRREGULAR: + 'person': 'człowiek' + 'man': 'mężczyźni' + 'child': 'dzieci' + 'sex': 'płci' + INFLECTOR_ORDINALS: + 'first': 'pierwszy' + 'second': 'drugi' + 'third': 'trzeci' + NICETIME: + NO_DATE_PROVIDED: Nie podano daty + BAD_DATE: Zła data + AGO: temu + FROM_NOW: od teraz + JUST_NOW: właśnie teraz + SECOND: sekunda + MINUTE: minuta + HOUR: godzina + DAY: dzień + WEEK: tydzień + MONTH: miesiąc + YEAR: rok + DECADE: dekada + SEC: sek + MIN: minuta + HR: godz + WK: tydz + MO: m-c + YR: rok + DEC: dekada + SECOND_PLURAL: sekund + MINUTE_PLURAL: minut + HOUR_PLURAL: godzin + DAY_PLURAL: dni + WEEK_PLURAL: tygodnie + MONTH_PLURAL: miesięcy + YEAR_PLURAL: lat + DECADE_PLURAL: dekad + SEC_PLURAL: sek + MIN_PLURAL: min + HR_PLURAL: godz + WK_PLURAL: tyg + MO_PLURAL: m-ce + YR_PLURAL: lat + DEC_PLURAL: dekad + FORM: + VALIDATION_FAIL: 'Weryfikacja nie powiodła się:' + INVALID_INPUT: 'Nieprawidłowe dane wejściowe' + MISSING_REQUIRED_FIELD: 'Opuszczono wymagane pole:' + XSS_ISSUES: "Potencjalne problemy XSS wykryte w polu '%s'" + MONTHS_OF_THE_YEAR: + - 'Styczeń' + - 'Luty' + - 'Marzec' + - 'Kwiecień' + - 'Maj' + - 'Czerwiec' + - 'Lipiec' + - 'Sierpień' + - 'Wrzesień' + - 'Październik' + - 'Listopad' + - 'Grudzień' + DAYS_OF_THE_WEEK: + - 'Poniedziałek' + - 'Wtorek' + - 'Środa' + - 'Czwartek' + - 'Piątek' + - 'Sobota' + - 'Niedziela' + YES: "Tak" + NO: "Nie" + CRON: + EVERY: każdy + EVERY_HOUR: każdą godzinę + EVERY_MINUTE: każdą minutę + EVERY_DAY_OF_WEEK: każdego dnia tygodnia + EVERY_DAY_OF_MONTH: każdego dnia miesiące + EVERY_MONTH: każdego miesiąca + TEXT_PERIOD: Każdego + TEXT_MINS: 'o minut po godzinie' + TEXT_TIME: 'o :' + ERROR1: Znacznik %s nie jest wspierany! + ERROR2: Nieprawidłowa liczba elementów + ERROR4: Wyrażenie nierozpoznane diff --git a/system/languages/pt.yaml b/system/languages/pt.yaml new file mode 100644 index 0000000..c2442f3 --- /dev/null +++ b/system/languages/pt.yaml @@ -0,0 +1,147 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Erro: Frontmatter Inválido\n\nLocalização: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'equipamento' + - 'informação' + - 'arroz' + - 'dinheiro' + - 'espécie' + - 'série' + - 'peixe' + - 'ovelha' + INFLECTOR_IRREGULAR: + 'person': 'pessoas' + 'man': 'homens' + 'child': 'crianças' + 'sex': 'sexos' + 'move': 'movimentos' + INFLECTOR_ORDINALS: + 'default': 'º' + 'first': 'º' + 'second': 'º' + 'third': 'º' + NICETIME: + NO_DATE_PROVIDED: Nenhuma data fornecida + BAD_DATE: Data inválida + AGO: há + FROM_NOW: a partir de agora + JUST_NOW: mesmo agora + SECOND: segundo + MINUTE: minuto + HOUR: hora + DAY: dia + WEEK: semana + MONTH: mês + YEAR: ano + DECADE: década + SEC: seg + MIN: min + HR: hora + WK: semana + MO: mês + YR: ano + DEC: década + SECOND_PLURAL: segundos + MINUTE_PLURAL: minutos + HOUR_PLURAL: horas + DAY_PLURAL: dias + WEEK_PLURAL: semanas + MONTH_PLURAL: meses + YEAR_PLURAL: anos + DECADE_PLURAL: décadas + SEC_PLURAL: segs + MIN_PLURAL: mins + HR_PLURAL: hrs + WK_PLURAL: sems + MO_PLURAL: meses + YR_PLURAL: anos + DEC_PLURAL: décadas + FORM: + VALIDATION_FAIL: 'Falha na validação:' + INVALID_INPUT: 'Dados inseridos são inválidos em' + MISSING_REQUIRED_FIELD: 'Campo obrigatório em falta:' + XSS_ISSUES: "Potenciais problemas de XSS detectados no campo '%s'" + MONTHS_OF_THE_YEAR: + - 'Janeiro' + - 'Fevereiro' + - 'Março' + - 'Abril' + - 'Maio' + - 'Junho' + - 'Julho' + - 'Agosto' + - 'Setembro' + - 'Outubro' + - 'Novembro' + - 'Dezembro' + DAYS_OF_THE_WEEK: + - 'Segunda-feira' + - 'Terça-feira' + - 'Quarta-feira' + - 'Quinta-feira' + - 'Sexta-feira' + - 'Sábado' + - 'Domingo' + YES: "Sim" + NO: "Não" + CRON: + EVERY: cada + EVERY_HOUR: cada hora + EVERY_MINUTE: cada minuto + EVERY_DAY_OF_WEEK: todos os dias da semana + EVERY_DAY_OF_MONTH: todos os dias do mês + EVERY_MONTH: todos os meses + TEXT_PERIOD: Cada + TEXT_MINS: ' em minuto(s) após a hora' + TEXT_TIME: ' em :' + TEXT_DOW: ' em ' + TEXT_MONTH: ' de ' + TEXT_DOM: ' em ' + ERROR1: A tag %s não é suportada! + ERROR2: Número de elementos inválido + ERROR3: O jquery_element deve ser definido nas configurações do jqCron + ERROR4: Expressão não reconhecida diff --git a/system/languages/ro.yaml b/system/languages/ro.yaml new file mode 100644 index 0000000..dc22b20 --- /dev/null +++ b/system/languages/ro.yaml @@ -0,0 +1,96 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\nTitlu: %1$s\n---\n# Eroare: Frontmatter este invalid\n\nCalea: `%2$s`\n\n**%3$s**\n\n```\n%4$s" + INFLECTOR_UNCOUNTABLE: + - 'echipament' + - 'informaţie' + - 'orez' + - 'bani' + - 'specii' + - 'serii' + - 'peşte' + - 'oaie' + INFLECTOR_IRREGULAR: + 'person': 'persoane' + 'man': 'bărbați' + 'child': 'copii' + 'sex': 'sexe' + 'move': 'mutări' + NICETIME: + NO_DATE_PROVIDED: Nu există o dată prevăzută + BAD_DATE: Dată incorectă + AGO: în urmă + FROM_NOW: de acum + JUST_NOW: chiar acum + SECOND: secundă + MINUTE: minut + HOUR: oră + DAY: zi + WEEK: săptămână + MONTH: lună + YEAR: an + DECADE: decadă + SEC: secunde + MIN: minute + HR: oră + WK: săpt + MO: lună + YR: an + DEC: decadă + SECOND_PLURAL: secunde + MINUTE_PLURAL: minute + HOUR_PLURAL: ore + DAY_PLURAL: zile + WEEK_PLURAL: săptămâni + MONTH_PLURAL: luni + YEAR_PLURAL: ani + DECADE_PLURAL: decade + SEC_PLURAL: sec + MIN_PLURAL: min + HR_PLURAL: ore + WK_PLURAL: săpt + MO_PLURAL: luni + YR_PLURAL: ani + DEC_PLURAL: decenii + FORM: + VALIDATION_FAIL: 'Validare nereușită' + INVALID_INPUT: 'Date incorecte în' + MISSING_REQUIRED_FIELD: 'Câmp obligatoriu lipsă:' + MONTHS_OF_THE_YEAR: + - 'Ianuarie' + - 'Februarie' + - 'Martie' + - 'Aprilie' + - 'Mai' + - 'Iunie' + - 'Iulie' + - 'August' + - 'Septembrie' + - 'Octombrie' + - 'Noiembrie' + - 'Decembrie' + DAYS_OF_THE_WEEK: + - 'Luni' + - 'Marți' + - 'Miercuri' + - 'Joi' + - 'Vineri' + - 'Sâmbătă' + - 'Duminică' + CRON: + EVERY: la fiecare + EVERY_HOUR: la fiecare oră + EVERY_MINUTE: la fiecare minut + EVERY_DAY_OF_WEEK: fiecare zi a săptămânii + EVERY_DAY_OF_MONTH: fiecare zi a lunii + EVERY_MONTH: fiecare lună + TEXT_PERIOD: Fiecare + TEXT_MINS: ' la minut(e) ale fiecărei ore' + TEXT_TIME: ' la :' + TEXT_DOW: ' pe ' + TEXT_MONTH: 'al(e) ' + TEXT_DOM: ' pe ' + ERROR1: Eticheta %s nu este acceptată! + ERROR2: Număr nevalid de elemente + ERROR3: jquery_element ar trebui setat în opțiunile jqCron + ERROR4: Expresie necunoscută diff --git a/system/languages/ru.yaml b/system/languages/ru.yaml new file mode 100644 index 0000000..4829005 --- /dev/null +++ b/system/languages/ru.yaml @@ -0,0 +1,114 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Ошибка: недопустимое содержимое Frontmatter\n\nПуть: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_SINGULAR: + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': "\\1\n" + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + INFLECTOR_UNCOUNTABLE: + - 'экипировка' + - 'информация' + - 'рис' + - 'деньги' + - 'виды' + - 'серии' + - 'рыба' + - 'овца' + INFLECTOR_IRREGULAR: + 'person': 'люди' + 'man': 'человек' + 'child': 'дети' + 'sex': 'пол' + 'move': 'движется' + INFLECTOR_ORDINALS: + 'default': 'й' + 'first': 'й' + 'second': 'й' + 'third': 'й' + NICETIME: + NO_DATE_PROVIDED: Дата не указана + BAD_DATE: Неверная дата + AGO: назад + FROM_NOW: теперь + JUST_NOW: только что + SECOND: секунда + MINUTE: минута + HOUR: час + DAY: день + WEEK: неделя + MONTH: месяц + YEAR: год + DECADE: десятилетие + SEC: сек + MIN: мин + HR: ч + WK: нед + MO: мес + YR: г + DEC: дстлт + SECOND_PLURAL: сек + MINUTE_PLURAL: мин + HOUR_PLURAL: ч + DAY_PLURAL: д + WEEK_PLURAL: нед + MONTH_PLURAL: мес + YEAR_PLURAL: г + DECADE_PLURAL: дстлт + SEC_PLURAL: сек + MIN_PLURAL: мин + HR_PLURAL: ч + WK_PLURAL: нед + MO_PLURAL: мес + YR_PLURAL: г + DEC_PLURAL: дстлт + FORM: + VALIDATION_FAIL: 'Проверка не удалась:' + INVALID_INPUT: 'Неверный ввод в' + MISSING_REQUIRED_FIELD: 'Отсутствует необходимое поле:' + XSS_ISSUES: "Обнаружены потенциальные XSS проблемы в поле '%s'" + MONTHS_OF_THE_YEAR: + - 'январь' + - 'февраль' + - 'март' + - 'апрель' + - 'май' + - 'июнь' + - 'июль' + - 'август' + - 'сентябрь' + - 'октябрь' + - 'ноябрь' + - 'декабрь' + DAYS_OF_THE_WEEK: + - 'понедельник' + - 'вторник' + - 'среда' + - 'четверг' + - 'пятница' + - 'суббота' + - 'воскресенье' + YES: "Да" + NO: "Нет" + CRON: + EVERY: раз в + EVERY_HOUR: раз в час + EVERY_MINUTE: раз в минуту + EVERY_DAY_OF_WEEK: каждый день недели + EVERY_DAY_OF_MONTH: каждый день недели + EVERY_MONTH: раз в месяц + TEXT_PERIOD: Каждый + TEXT_MINS: ' в минуте(ах) за час' + TEXT_TIME: ' в :' + TEXT_DOW: ' на ' + TEXT_MONTH: ' из ' + TEXT_DOM: ' на ' + ERROR1: Тег %s не поддерживается! + ERROR2: Неверное количество элементов + ERROR3: jquery_element должен быть установлен в настройки jqCron + ERROR4: Выражение не распознано diff --git a/system/languages/si.yaml b/system/languages/si.yaml new file mode 100644 index 0000000..7a895da --- /dev/null +++ b/system/languages/si.yaml @@ -0,0 +1,120 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\nමාතෘකාව: %1$s\n---\n\n# දෝෂය: වලංගු නොවන ඉදිරිපස\n\nමාර්ගය: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/([m|l])ouse$/i': '\1අයිස්' + '/(matr|vert|ind)ix|ex$/i': '\1අයිස්' + '/(?:([^f])fe|([lr])f)$/i': '\1\2වෙස්' + '/([ti])um$/i': '\1අ' + '/(buffal|tomat)o$/i': '\1ඕඑස්' + '/(bu)s$/i': '\1සෙස්' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1 අප' + '/(cris|ax|test)es$/i': '\1 වේ' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1 භාවිතා කරන්න' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ඕවී' + '/(s)eries$/i': '\1මාලා' + '/(^analy)ses$/i': '\1සිස්' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2සිස්' + '/([ti])a$/i': '\1ම්' + INFLECTOR_UNCOUNTABLE: + - 'උපකරණ' + - 'විස්තර' + - 'සහල්' + - 'මුදල' + - 'විශේෂ' + - 'මාලාවක්' + - 'මාළු' + - 'බැටළුවන්' + INFLECTOR_IRREGULAR: + 'person': 'මහජන' + 'man': 'මිනිසුන්' + 'child': 'දරුවන්' + 'sex': 'ලිංගිකත්වය' + 'move': 'චලනය කරයි' + INFLECTOR_ORDINALS: + 'first': 'ශාන්ත' + NICETIME: + NO_DATE_PROVIDED: දිනයක් සපයා නැත + BAD_DATE: නරක දිනය + AGO: පෙර + FROM_NOW: මෙතැන් සිට + JUST_NOW: මේ දැන් + SECOND: දෙවැනි + MINUTE: මිනිත්තුව + HOUR: පැය + DAY: දින + WEEK: සතිය + MONTH: මස + YEAR: වර්ෂය + DECADE: දශකය + SEC: තත්පර + MIN: මිනි + HR: පැය + YR: වසර + DEC: දෙසැ + SECOND_PLURAL: තත්පර + MINUTE_PLURAL: මිනිත්තු + HOUR_PLURAL: පැය + DAY_PLURAL: දින + WEEK_PLURAL: සති + MONTH_PLURAL: මාස + YEAR_PLURAL: වසර + DECADE_PLURAL: දශක + SEC_PLURAL: තත්පර + MIN_PLURAL: මිනිත්තු + HR_PLURAL: පැය + WK_PLURAL: සති + YR_PLURAL: වසර + DEC_PLURAL: දෙසැ + FORM: + VALIDATION_FAIL: 'වලංගු කිරීම අසාර්ථක විය:' + INVALID_INPUT: 'වලංගු නොවන ආදානය' + MISSING_REQUIRED_FIELD: 'අවශ්‍ය ක්ෂේත්‍රය අස්ථානගත වී ඇත:' + XSS_ISSUES: "විභව XSS ගැටළු '%s' ක්ෂේත්‍රයේ අනාවරණය විය" + MONTHS_OF_THE_YEAR: + - 'ජනවාරි' + - 'පෙබරවාරි' + - 'මාර්තු' + - 'අප්රේල්' + - 'මැයි' + - 'ජූනි' + - 'ජුලි' + - 'අගෝස්තු' + - 'සැප්තැම්බර්' + - 'ඔක්තෝම්බර්' + - 'නොවැම්බර්' + - 'දෙසැම්බර්' + DAYS_OF_THE_WEEK: + - 'සඳුදා' + - 'අඟහරුවාදා' + - 'බදාදා' + - 'බ්රහස්පතින්දා' + - 'සිකුරාදා' + - 'සෙනසුරාදා' + - 'ඉරිදා' + YES: "ඔව්" + NO: "නැත" + CRON: + EVERY: සෑම + EVERY_HOUR: සෑම පැයකටම + EVERY_MINUTE: සෑම විනාඩියකටම + EVERY_DAY_OF_WEEK: සතියේ සෑම දිනකම + EVERY_DAY_OF_MONTH: මාසයේ සෑම දිනකම + EVERY_MONTH: සෑම මාසයකම + TEXT_PERIOD: සෑම + TEXT_MINS: ' පැයට පසු විනාඩි කින්' + TEXT_TIME: ' :ට' + TEXT_DOW: ' මත' + TEXT_MONTH: ' ' + TEXT_DOM: ' මත' + ERROR1: ටැගය %s සහාය නොදක්වයි! + ERROR2: නරක මූලද්රව්ය සංඛ්යාව + ERROR3: jquery_element jqCron සැකසුම් වලට සැකසිය යුතුය + ERROR4: හඳුනා නොගත් ප්‍රකාශනය diff --git a/system/languages/sk.yaml b/system/languages/sk.yaml new file mode 100644 index 0000000..9543239 --- /dev/null +++ b/system/languages/sk.yaml @@ -0,0 +1,144 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Chyba: Chybný frontmatter\n\nPath: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'vybavenie' + - 'informácie' + - 'ryža' + - 'peniaze' + - 'druhy' + - 'séria' + - 'ryba' + - 'ovce' + INFLECTOR_IRREGULAR: + 'person': 'ľudia' + 'man': 'muži' + 'child': 'deti' + 'sex': 'pohlavia' + 'move': 'pohyby' + INFLECTOR_ORDINALS: + 'default': '.' + 'first': '.' + 'second': '.' + 'third': '.' + NICETIME: + NO_DATE_PROVIDED: Neposkytnutý žiaden dátum + BAD_DATE: Nesprávny dátum + AGO: pred + FROM_NOW: odteraz + JUST_NOW: práve teraz + SECOND: sekunda + MINUTE: minúta + HOUR: hodina + DAY: deň + WEEK: týždeň + MONTH: mesiac + YEAR: rok + DECADE: desaťročie + SEC: sek + MIN: min + HR: hod + WK: t + MO: m + YR: r + DEC: dec + SECOND_PLURAL: sekúnd + MINUTE_PLURAL: minút + HOUR_PLURAL: hodín + DAY_PLURAL: dní + WEEK_PLURAL: týždňov + MONTH_PLURAL: mesiacov + YEAR_PLURAL: rokov + DECADE_PLURAL: dekád + SEC_PLURAL: sek + MIN_PLURAL: min + HR_PLURAL: hod + WK_PLURAL: t + MO_PLURAL: mes. + YR_PLURAL: rokov + DEC_PLURAL: dekád + FORM: + VALIDATION_FAIL: 'Overenie zlyhalo:' + INVALID_INPUT: 'Neplatný vstup v' + MISSING_REQUIRED_FIELD: 'Chýba vyžadované pole:' + MONTHS_OF_THE_YEAR: + - 'Január' + - 'Február' + - 'Marec' + - 'Apríl' + - 'Máj' + - 'Jún' + - 'Júl' + - 'August' + - 'September' + - 'Október' + - 'November' + - 'December' + DAYS_OF_THE_WEEK: + - 'Pondelok' + - 'Utorok' + - 'Streda' + - 'Štvrtok' + - 'Piatok' + - 'Sobota' + - 'Nedeľa' + CRON: + EVERY: každý + EVERY_HOUR: každú hodinu + EVERY_MINUTE: každú minútu + EVERY_DAY_OF_WEEK: každý deň v týždni + EVERY_DAY_OF_MONTH: každý deň v mesiaci + EVERY_MONTH: každý mesiac + TEXT_PERIOD: Každý + TEXT_MINS: ' at minute(s) past the hour' + TEXT_TIME: ' at :' + TEXT_DOW: ' on ' + TEXT_MONTH: ' of ' + TEXT_DOM: ' on ' + ERROR1: Tag %s nieje podporovaný! + ERROR2: Chybný počet položiek + ERROR3: jquery_element musí byť nastavený v nastaveniach pre jqCron + ERROR4: Neznámy výraz diff --git a/system/languages/sl.yaml b/system/languages/sl.yaml new file mode 100644 index 0000000..dc09814 --- /dev/null +++ b/system/languages/sl.yaml @@ -0,0 +1,85 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Napaka: Neveljavna Frontmatter\n\nPath: `%2$s`\n\n**%3$s ** \n\n```\n%4$s \n```" + INFLECTOR_UNCOUNTABLE: + - 'oprema' + - 'informacija' + - 'riž' + - 'denar' + - 'vrste' + - 'serija' + - 'riba' + - 'ovca' + INFLECTOR_IRREGULAR: + 'person': 'ljudje' + NICETIME: + NO_DATE_PROVIDED: Datum ni na voljo + BAD_DATE: Neveljaven datum + AGO: pred + FROM_NOW: od zdaj + SECOND: sekunda + MINUTE: minuta + HOUR: ura + DAY: dan + WEEK: teden + MONTH: mesec + YEAR: leto + DECADE: desetletje + SEC: sek + HR: ur + WK: T. + MO: m + YR: l + DEC: des + SECOND_PLURAL: sekund + MINUTE_PLURAL: minut + HOUR_PLURAL: ure + DAY_PLURAL: dnevi + WEEK_PLURAL: tednov + MONTH_PLURAL: mesecev + YEAR_PLURAL: leta + DECADE_PLURAL: desetletja + SEC_PLURAL: s + MIN_PLURAL: min + HR_PLURAL: ur + WK_PLURAL: t + MO_PLURAL: m + YR_PLURAL: l + DEC_PLURAL: des + FORM: + VALIDATION_FAIL: 'Preverjanje veljavnosti ni uspelo:' + INVALID_INPUT: 'Neveljaven vnos v' + MISSING_REQUIRED_FIELD: 'Manjka obvezno polje:' + MONTHS_OF_THE_YEAR: + - 'Januar' + - 'Februar' + - 'Marec' + - 'april' + - 'Maj' + - 'Junij' + - 'Julij' + - 'Avgust' + - 'september' + - 'Oktober' + - 'november' + - 'december' + DAYS_OF_THE_WEEK: + - 'Ponedeljek' + - 'Torek' + - 'Sreda' + - 'Četrtek' + - 'Petek' + - 'Sobota' + - 'Nedelja' + YES: "Da" + NO: "Ne" + CRON: + EVERY: vsak + EVERY_HOUR: vsako uro + EVERY_MINUTE: vsako minuto + EVERY_DAY_OF_WEEK: vsak dan v tednu + EVERY_DAY_OF_MONTH: vsak dan v mesecu + EVERY_MONTH: vsak mesec + ERROR1: Oznaka %s ni podprta! + ERROR2: Napačno število elementov. + ERROR4: Neznan izraz diff --git a/system/languages/sr.yaml b/system/languages/sr.yaml new file mode 100644 index 0000000..498d182 --- /dev/null +++ b/system/languages/sr.yaml @@ -0,0 +1,147 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\nнаслов: %1$s\n---\n\n# Грешка: неисправан Frontmatter\n\nПутања: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'опрема' + - 'информација' + - 'пиринач' + - 'новац' + - 'врсте' + - 'серије' + - 'риба' + - 'овца' + INFLECTOR_IRREGULAR: + 'person': 'особе' + 'man': 'људи' + 'child': 'деца' + 'sex': 'полови' + 'move': 'помери' + INFLECTOR_ORDINALS: + 'default': 'ти' + 'first': 'први' + 'second': 'други' + 'third': 'трећи' + NICETIME: + NO_DATE_PROVIDED: Нема датума + BAD_DATE: Погрешан датум + AGO: од пре + FROM_NOW: од сада + JUST_NOW: управо сада + SECOND: секунда + MINUTE: минута + HOUR: сат + DAY: дан + WEEK: недеља + MONTH: месец + YEAR: година + DECADE: декада + SEC: сек + MIN: мин + HR: сат + WK: нед + MO: мес + YR: год + DEC: дек + SECOND_PLURAL: секунди + MINUTE_PLURAL: минута + HOUR_PLURAL: сати + DAY_PLURAL: дана + WEEK_PLURAL: недеља + MONTH_PLURAL: месеци + YEAR_PLURAL: године(а) + DECADE_PLURAL: декаде(а) + SEC_PLURAL: сек + MIN_PLURAL: мин + HR_PLURAL: сати + WK_PLURAL: недеља + MO_PLURAL: месеци + YR_PLURAL: година + DEC_PLURAL: декада + FORM: + VALIDATION_FAIL: 'Провера неуспела:' + INVALID_INPUT: 'Неисправан унос у' + MISSING_REQUIRED_FIELD: 'Недостаје обавезн поље:' + XSS_ISSUES: "Потенцијална грешка у XSS-у детектована у пољу '%s' " + MONTHS_OF_THE_YEAR: + - 'Јануар' + - 'Фебруар' + - 'Март' + - 'Април' + - 'Мај' + - 'Јуни' + - 'Јули' + - 'Август' + - 'Септембар' + - 'Октобар' + - 'Новембар' + - 'Децембар' + DAYS_OF_THE_WEEK: + - 'Понедељак' + - 'Уторак' + - 'Среда' + - 'Четвртак' + - 'Петак' + - 'Субота' + - 'Недеља' + YES: "Да" + NO: "Не" + CRON: + EVERY: сваки + EVERY_HOUR: сваки сат + EVERY_MINUTE: сваки минут + EVERY_DAY_OF_WEEK: сваки дан у недељи + EVERY_DAY_OF_MONTH: сваки дан у месецу + EVERY_MONTH: сваки месец + TEXT_PERIOD: Сваки + TEXT_MINS: ' у минути(а) прошлог сата' + TEXT_TIME: ' у :' + TEXT_DOW: ' на ' + TEXT_MONTH: ' од ' + TEXT_DOM: ' на ' + ERROR1: Таг %s није подржан! + ERROR2: Погрешан број елемената + ERROR3: јquery_element би требао да буде постављен у jqCron подешавању + ERROR4: Непрепознат израз diff --git a/system/languages/sv.yaml b/system/languages/sv.yaml new file mode 100644 index 0000000..bf76bef --- /dev/null +++ b/system/languages/sv.yaml @@ -0,0 +1,100 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "--- titel: %1$s --- # Fel: Ogiltig Frontmatter-sökväg: `%2$s` **%3$s** ``` %4$s ```" + INFLECTOR_UNCOUNTABLE: + - 'utrustning' + - 'information' + - 'ris' + - 'pengar' + - 'arter' + - 'serier' + - 'fisk' + - 'får' + INFLECTOR_IRREGULAR: + 'person': 'personer' + 'man': 'män' + 'child': 'barn' + 'sex': 'kön' + 'move': 'flytta' + INFLECTOR_ORDINALS: + 'default': ':e' + 'first': ':a' + 'second': ':a' + 'third': ':e' + NICETIME: + NO_DATE_PROVIDED: Inget datum har angivits + BAD_DATE: Ogiltigt datum + AGO: sedan + FROM_NOW: fr.o.m nu + JUST_NOW: just nu + SECOND: sekund + MINUTE: minut + HOUR: timme + DAY: dag + WEEK: vecka + MONTH: månad + YEAR: år + DECADE: årtionde + SEC: sek + MIN: min + HR: t + WK: v + MO: m + YR: år + DEC: dec + SECOND_PLURAL: sekunder + MINUTE_PLURAL: minuter + HOUR_PLURAL: timmar + DAY_PLURAL: dagar + WEEK_PLURAL: veckor + MONTH_PLURAL: månader + YEAR_PLURAL: år + DECADE_PLURAL: årtionden + SEC_PLURAL: sek + MIN_PLURAL: min + HR_PLURAL: t + WK_PLURAL: v + MO_PLURAL: må + YR_PLURAL: år + DEC_PLURAL: dec + FORM: + VALIDATION_FAIL: 'Kontrollen misslyckades:' + INVALID_INPUT: 'Ogiltig indata i' + MISSING_REQUIRED_FIELD: 'Obligatoriskt fält måste fyllas i:' + MONTHS_OF_THE_YEAR: + - 'Januari' + - 'Februari' + - 'Mars' + - 'April' + - 'Maj' + - 'Juni' + - 'Juli' + - 'Augusti' + - 'September' + - 'Oktober' + - 'November' + - 'December' + DAYS_OF_THE_WEEK: + - 'Måndag' + - 'Tisdag' + - 'Onsdag' + - 'Torsdag' + - 'Fredag' + - 'Lördag' + - 'Söndag' + CRON: + EVERY: varje + EVERY_HOUR: varje timme + EVERY_MINUTE: varje minut + EVERY_DAY_OF_WEEK: varje veckodag + EVERY_DAY_OF_MONTH: alla månadens dagar + EVERY_MONTH: varje månad + TEXT_PERIOD: Varje + TEXT_MINS: ' timmens :e minut' + TEXT_TIME: ' kl :' + TEXT_DOW: ' ' + TEXT_MONTH: ' ' + TEXT_DOM: ' ' + ERROR1: Taggen %s stöds inte! + ERROR2: Ogiltigt antal element + ERROR4: Uttrycket känns inte igen diff --git a/system/languages/sw.yaml b/system/languages/sw.yaml new file mode 100644 index 0000000..9bb40d6 --- /dev/null +++ b/system/languages/sw.yaml @@ -0,0 +1,147 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\nkichwa: %1$s\n---\n\n# Kosa: Mbele ya Mbele\n\nNjia: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'vifaa' + - 'habari' + - 'mchele' + - 'pesa' + - 'spishi' + - 'mfululizo' + - 'samaki' + - 'kondoo' + INFLECTOR_IRREGULAR: + 'person': 'watu' + 'man': 'wanaume' + 'child': 'watoto' + 'sex': 'jinsia' + 'move': 'songa' + INFLECTOR_ORDINALS: + 'default': 'th' + 'first': 'st' + 'second': 'nd' + 'third': 'rd' + NICETIME: + NO_DATE_PROVIDED: Hakuna tarehe iliyotolewa + BAD_DATE: Tarehe mbaya + AGO: zilizopita + FROM_NOW: kuanzia sasa + JUST_NOW: sasa hivi + SECOND: pili + MINUTE: dakika + HOUR: saa + DAY: siku + WEEK: wiki + MONTH: mwezi + YEAR: mwaka + DECADE: muongo + SEC: sec + MIN: min + HR: hr + WK: wk + MO: mo + YR: yr + DEC: dec + SECOND_PLURAL: sekunde + MINUTE_PLURAL: dakika + HOUR_PLURAL: masaa + DAY_PLURAL: siku + WEEK_PLURAL: wiki + MONTH_PLURAL: miezi + YEAR_PLURAL: miaka + DECADE_PLURAL: miongo + SEC_PLURAL: secs + MIN_PLURAL: mins + HR_PLURAL: hrs + WK_PLURAL: wks + MO_PLURAL: mos + YR_PLURAL: yrs + DEC_PLURAL: decs + FORM: + VALIDATION_FAIL: ' Uthibitishaji umeshindwa: ' + INVALID_INPUT: 'Ingizo batili katika' + MISSING_REQUIRED_FIELD: 'Sehemu inayokosekana inahitajika:' + XSS_ISSUES: "Masuala yanayowezekana ya XSS yamegunduliwa katika uwanja wa '% s" + MONTHS_OF_THE_YEAR: + - 'Januari' + - 'Februari' + - 'Machi' + - 'Aprili' + - 'Mei' + - 'Juni' + - 'Julai' + - 'Agosti' + - 'Septemba' + - 'Oktoba' + - 'Novemba' + - 'Desemba' + DAYS_OF_THE_WEEK: + - 'Jumatatu' + - 'Jumanne' + - 'Jumatano' + - 'Alhamisi' + - 'Ijumaa' + - 'Jumamosi' + - 'Jumapili' + YES: "Ndiyo" + NO: "Hapana" + CRON: + EVERY: kila + EVERY_HOUR: kila saa + EVERY_MINUTE: kila dakika + EVERY_DAY_OF_WEEK: kila siku ya juma + EVERY_DAY_OF_MONTH: kila siku ya mwezi + EVERY_MONTH: kila mwezi + TEXT_PERIOD: Kila + TEXT_MINS: ' saa dakika (saa) zilizopita saa' + TEXT_TIME: ' saa : ' + TEXT_DOW: ' kwenye ' + TEXT_MONTH: ' ya ' + TEXT_DOM: ' kwenye ' + ERROR1: Lebo% s haitumiki! + ERROR2: Idadi mbaya ya vitu + ERROR3: Jquery_element inapaswa kuwekwa kwenye mipangilio ya jqCron + ERROR4: Maneno yasiyotambulika diff --git a/system/languages/th.yaml b/system/languages/th.yaml new file mode 100644 index 0000000..762f063 --- /dev/null +++ b/system/languages/th.yaml @@ -0,0 +1,147 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Error: Invalid Frontmatter\n\nPath: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'อุปกรณ์' + - 'ข้อมูล' + - 'ข้าว' + - 'เงิน' + - 'สายพันธุ์' + - 'ซีรีส์' + - 'ปลา' + - 'แกะ' + INFLECTOR_IRREGULAR: + 'person': 'คน' + 'man': 'ผู้ชาย' + 'child': 'เด็กเด็ก' + 'sex': 'เพศ' + 'move': 'ย้าย' + INFLECTOR_ORDINALS: + 'default': 'th' + 'first': 'st' + 'second': 'nd' + 'third': 'rd' + NICETIME: + NO_DATE_PROVIDED: ไม่มีวันที่ให้ + BAD_DATE: รูปแบบวันที่ผิด + AGO: ที่ผ่านมา + FROM_NOW: จากตอนนี้ + JUST_NOW: เมื่อกี้ + SECOND: วินาที + MINUTE: นาที + HOUR: ชั่วโมง + DAY: วัน + WEEK: สัปดาห์ + MONTH: เดือน + YEAR: ปี + DECADE: ทศวรรษที่ผ่านมา + SEC: วิ + MIN: นาที + HR: ชม. + WK: wk + MO: mo + YR: yr + DEC: dec + SECOND_PLURAL: วินาที + MINUTE_PLURAL: นาที + HOUR_PLURAL: ชั่วโมง + DAY_PLURAL: วัน + WEEK_PLURAL: สัปดาห์ + MONTH_PLURAL: เดือน + YEAR_PLURAL: ปี + DECADE_PLURAL: ทศวรรษที่ผ่านมา + SEC_PLURAL: วินาที + MIN_PLURAL: นาที + HR_PLURAL: ชั่วโมง + WK_PLURAL: wks + MO_PLURAL: mos + YR_PLURAL: ปี + DEC_PLURAL: decs + FORM: + VALIDATION_FAIL: 'ตรวจสอบล้มเหลว: ' + INVALID_INPUT: 'ป้อนข้อมูลไม่ถูกต้องใน' + MISSING_REQUIRED_FIELD: 'ขาดข้อมูลที่จำเป็น:' + XSS_ISSUES: "ตรวจพบปัญหา XSS ที่เป็นไปได้ในฟิลด์ '%s'" + MONTHS_OF_THE_YEAR: + - 'มกราคม' + - 'กุมภาพันธ์' + - 'มีนาคม' + - 'เมษายน' + - 'พฤษภาคม' + - 'มิถุนายน' + - 'กรกฏาคม' + - 'สิงหาคม' + - 'กันยายน' + - 'ตุลาคม' + - 'พฤศจิกายน' + - 'ธันวาคม' + DAYS_OF_THE_WEEK: + - 'จันทร์' + - 'อังคาร' + - 'พุธ' + - 'พฤหัสบดี' + - 'ศุกร์' + - 'เสาร์' + - 'อาทิตย์' + YES: "ใช่" + NO: "ไม่" + CRON: + EVERY: ทุก ๆ + EVERY_HOUR: ทุกชั่วโมง + EVERY_MINUTE: ทุกนาที + EVERY_DAY_OF_WEEK: ทุกวันในสัปดาห์ + EVERY_DAY_OF_MONTH: ทุกวันของเดือน + EVERY_MONTH: ทุกเดือน + TEXT_PERIOD: ทุก ๆ + TEXT_MINS: ' ที่ นาทีที่ผ่านไปแล้ว' + TEXT_TIME: ' เวลา :' + TEXT_DOW: ' บน ' + TEXT_MONTH: ' จาก ' + TEXT_DOM: ' บน ' + ERROR1: ไม่รองรับแท็ก %s! + ERROR2: จำนวนองค์ประกอบไม่ดี + ERROR3: ควรตั้งค่า jquery_element เป็นการตั้งค่า jqCron + ERROR4: นิพจน์ที่ไม่รู้จัก diff --git a/system/languages/tr.yaml b/system/languages/tr.yaml new file mode 100644 index 0000000..47f32db --- /dev/null +++ b/system/languages/tr.yaml @@ -0,0 +1,100 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\nBaşlık: %1$s\n---\n\n# Hata: Geçersiz Önbölüm\n\nYol: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_UNCOUNTABLE: + - 'ekipman' + - 'bilgi' + - 'pirinç' + - 'para' + - 'türler' + - 'seriler' + - 'balık' + - 'koyun' + INFLECTOR_IRREGULAR: + 'person': 'kişi' + 'man': 'erkek' + 'child': 'çocuklar' + 'sex': 'cinsiyet' + 'move': 'taşınmış' + INFLECTOR_ORDINALS: + 'default': '#F' + 'first': ' 1.' + 'second': ' 2.' + 'third': ' 3.' + NICETIME: + NO_DATE_PROVIDED: Sağlanan tarih yok + BAD_DATE: Yanlış tarih + AGO: önce + FROM_NOW: şu andan itibaren + JUST_NOW: şimdi + SECOND: saniye + MINUTE: dakika + HOUR: saat + DAY: gün + WEEK: hafta + MONTH: ay + YEAR: yıl + DECADE: onyıl + SEC: sn + MIN: dk + HR: sa + WK: hft + MO: ay + YR: yl + DEC: onyl + SECOND_PLURAL: saniye + MINUTE_PLURAL: dakika + HOUR_PLURAL: saat + DAY_PLURAL: gün + WEEK_PLURAL: hafta + MONTH_PLURAL: ay + YEAR_PLURAL: yıl + DECADE_PLURAL: onyıl + SEC_PLURAL: sn + MIN_PLURAL: dk + HR_PLURAL: sa + WK_PLURAL: hft + MO_PLURAL: ay + YR_PLURAL: yıl + DEC_PLURAL: onyl + FORM: + VALIDATION_FAIL: 'Doğrulama başarısız:' + INVALID_INPUT: 'Geçersiz bilgi girişi' + MISSING_REQUIRED_FIELD: 'Gerekli alan eksik:' + MONTHS_OF_THE_YEAR: + - 'Ocak' + - 'Şubat' + - 'Mart' + - 'Nisan' + - 'Mayıs' + - 'Haziran' + - 'Temmuz' + - 'Ağustos' + - 'Eylül' + - 'Ekim' + - 'Kasım' + - 'Aralık' + DAYS_OF_THE_WEEK: + - 'Pazartesi' + - 'Salı' + - 'Çarşamba' + - 'Perşembe' + - 'Cuma' + - 'Cumartesi' + - 'Pazar' + YES: "Evet" + NO: "Hayır" + CRON: + EVERY: her + EVERY_HOUR: saatte bir + EVERY_MINUTE: dakikada bir + EVERY_DAY_OF_WEEK: haftanın her günü + EVERY_DAY_OF_MONTH: ayın her günü + EVERY_MONTH: her ay + TEXT_PERIOD: Her + TEXT_MINS: ' saatin dakikasında' + TEXT_TIME: ' da' + ERROR1: Etiket %s desteklenmiyor! + ERROR2: Kötü eleman sayısı + ERROR3: jquery_element jqCron ayarları içinde tanımlanmalı + ERROR4: Tanınmayan ifade diff --git a/system/languages/uk.yaml b/system/languages/uk.yaml new file mode 100644 index 0000000..8a138a4 --- /dev/null +++ b/system/languages/uk.yaml @@ -0,0 +1,63 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Помилка: Недопустимий вміст\n\nPath: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + NICETIME: + NO_DATE_PROVIDED: Не вказана дата + BAD_DATE: Невірна дата + AGO: назад + FROM_NOW: відтепер + SECOND: секунда + MINUTE: хвилина + HOUR: година + DAY: день + WEEK: тиждень + MONTH: місяць + YEAR: рік + DECADE: десятиріччя + SEC: с + MIN: хв + HR: год + WK: тиж. + MO: міс. + YR: р. + DEC: рр. + SECOND_PLURAL: секунди + MINUTE_PLURAL: хвилини + HOUR_PLURAL: години + DAY_PLURAL: дні + WEEK_PLURAL: тижні + MONTH_PLURAL: місяці + YEAR_PLURAL: роки + DECADE_PLURAL: десятиріччя + SEC_PLURAL: с + MIN_PLURAL: хв + HR_PLURAL: год + WK_PLURAL: тиж. + MO_PLURAL: міс. + YR_PLURAL: рр. + DEC_PLURAL: рр. + FORM: + VALIDATION_FAIL: 'Перевірка не вдалася:' + INVALID_INPUT: 'Невірне введення в' + MISSING_REQUIRED_FIELD: 'Відсутнє обов''язкове поле:' + MONTHS_OF_THE_YEAR: + - 'Січень' + - 'Лютий' + - 'Березень' + - 'Квітень' + - 'Травень' + - 'Червень' + - 'Липень' + - 'Серпень' + - 'Вересень' + - 'Жовтень' + - 'Листопад' + - 'Грудень' + DAYS_OF_THE_WEEK: + - 'Понеділок' + - 'Вівторок' + - 'Середа' + - 'Четвер' + - 'П''ятниця' + - 'Субота' + - 'Неділя' diff --git a/system/languages/vi.yaml b/system/languages/vi.yaml new file mode 100644 index 0000000..9e3a0f4 --- /dev/null +++ b/system/languages/vi.yaml @@ -0,0 +1,63 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntiêu đề: %1$s\n---\n\n# Error: Trang không hợp lệ\n\nĐường dẫn: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + NICETIME: + NO_DATE_PROVIDED: Không có ngày được cung cấp + BAD_DATE: Ngày không hợp lệ + AGO: cách đây + FROM_NOW: từ bây giờ + SECOND: giây + MINUTE: phút + HOUR: giờ + DAY: ngày + WEEK: tuần + MONTH: tháng + YEAR: năm + DECADE: thập kỷ + SEC: giây + MIN: phút + HR: giờ + WK: tuần + MO: tháng + YR: năm + DEC: thập kỷ + SECOND_PLURAL: giây + MINUTE_PLURAL: phút + HOUR_PLURAL: giờ + DAY_PLURAL: ngày + WEEK_PLURAL: tuần + MONTH_PLURAL: tháng + YEAR_PLURAL: năm + DECADE_PLURAL: thập kỷ + SEC_PLURAL: giây + MIN_PLURAL: phút + HR_PLURAL: giờ + WK_PLURAL: tuần + MO_PLURAL: tháng + YR_PLURAL: năm + DEC_PLURAL: thập kỷ + FORM: + VALIDATION_FAIL: 'Xác nhận thất bại:' + INVALID_INPUT: 'Dữ liệu nhập không hợp lệ cho' + MISSING_REQUIRED_FIELD: 'Thiếu trường bắt buộc:' + MONTHS_OF_THE_YEAR: + - 'Tháng 1' + - 'Tháng 2' + - 'Tháng 3' + - 'Tháng 4' + - 'Tháng 5' + - 'Tháng 6' + - 'Tháng 7' + - 'Tháng 8' + - 'Tháng 9' + - 'Tháng 10' + - 'Tháng 11' + - 'Tháng 12' + DAYS_OF_THE_WEEK: + - 'Thứ 2' + - 'Thứ 3' + - 'Thứ 4' + - 'Thứ 5' + - 'Thứ 6' + - 'Thứ 7' + - 'Chủ Nhật' diff --git a/system/languages/zh-cn.yaml b/system/languages/zh-cn.yaml new file mode 100644 index 0000000..d1afaa4 --- /dev/null +++ b/system/languages/zh-cn.yaml @@ -0,0 +1,146 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\n标题: %1$s\n---\n\n# 错误:无效参数\n\n位置: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - '装备' + - '信息' + - '大米' + - '钱' + - '物种' + - '系列' + - '鱼' + - '羊' + INFLECTOR_IRREGULAR: + 'person': '人员' + 'man': '男人' + 'child': '儿童' + 'sex': '性别' + 'move': '移动' + INFLECTOR_ORDINALS: + 'default': 'th' + 'first': 'st' + 'second': 'md' + 'third': 'rd' + NICETIME: + NO_DATE_PROVIDED: 无日期信息 + BAD_DATE: 无效日期 + AGO: 前 + FROM_NOW: 距今 + JUST_NOW: 刚刚 + SECOND: 秒 + MINUTE: 分钟 + HOUR: 小时 + DAY: 天 + WEEK: 周 + MONTH: 月 + YEAR: 年 + DECADE: 十年 + SEC: 秒 + MIN: 分钟 + HR: 小时 + WK: 周 + MO: 月 + YR: 年 + DEC: 年代 + SECOND_PLURAL: 秒 + MINUTE_PLURAL: 分 + HOUR_PLURAL: 小时 + DAY_PLURAL: 天 + WEEK_PLURAL: 周 + MONTH_PLURAL: 月 + YEAR_PLURAL: 年 + DECADE_PLURAL: 十年 + SEC_PLURAL: 秒 + MIN_PLURAL: 分 + HR_PLURAL: 时 + WK_PLURAL: 周 + MO_PLURAL: 月 + YR_PLURAL: 年 + DEC_PLURAL: 年代 + FORM: + VALIDATION_FAIL: '验证失败:' + INVALID_INPUT: '无效输入' + MISSING_REQUIRED_FIELD: '必填字段缺失:' + MONTHS_OF_THE_YEAR: + - '1月' + - '2月' + - '3月' + - '4月' + - '5月' + - '6月' + - '7月' + - '8月' + - '9月' + - '10月' + - '11月' + - '12月' + DAYS_OF_THE_WEEK: + - '星期一' + - '星期二' + - '星期三' + - '星期四' + - '星期五' + - '星期六' + - '星期日' + YES: "是" + NO: "否" + CRON: + EVERY: 每隔 + EVERY_HOUR: 每小时 + EVERY_MINUTE: 每分钟 + EVERY_DAY_OF_WEEK: 一周中的每一天 + EVERY_DAY_OF_MONTH: 月份中的每一天 + EVERY_MONTH: 每月 + TEXT_PERIOD: 所有 + TEXT_MINS: ' 在 小时过后的分钟' + TEXT_TIME: ' 在 :' + TEXT_DOW: ' on ' + TEXT_MONTH: ' of ' + TEXT_DOM: ' on ' + ERROR1: 不支持分享类型 %s + ERROR2: 无效数字 + ERROR3: 请在 jqCron 设置中设定 jquery_element + ERROR4: 无法识别表达式 diff --git a/system/languages/zh-tw.yaml b/system/languages/zh-tw.yaml new file mode 100644 index 0000000..779cae9 --- /dev/null +++ b/system/languages/zh-tw.yaml @@ -0,0 +1,79 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# 錯誤: 不正確的 Frontmatter\n\n路徑: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + NICETIME: + NO_DATE_PROVIDED: 沒有提供日期 + BAD_DATE: 錯誤日期 + AGO: 之前 + FROM_NOW: 之後 + JUST_NOW: 剛剛 + SECOND: 秒 + MINUTE: 分 + HOUR: 小時 + DAY: 天 + WEEK: 週 + MONTH: 月 + YEAR: 年 + DECADE: 十年 + SEC: 秒 + MIN: 分 + HR: 小時 + WK: 週 + MO: 月 + YR: 年 + DEC: 十年 + SECOND_PLURAL: 秒 + MINUTE_PLURAL: 分 + HOUR_PLURAL: 小時 + DAY_PLURAL: 天 + WEEK_PLURAL: 週 + MONTH_PLURAL: 月 + YEAR_PLURAL: 年 + DECADE_PLURAL: 十年 + SEC_PLURAL: 秒 + MIN_PLURAL: 分 + HR_PLURAL: 時 + WK_PLURAL: 週 + MO_PLURAL: 月 + YR_PLURAL: 年 + DEC_PLURAL: 十年 + FORM: + VALIDATION_FAIL: '確驗證失敗:' + INVALID_INPUT: '無效輸入:' + MISSING_REQUIRED_FIELD: '遺漏必填欄位:' + MONTHS_OF_THE_YEAR: + - '一月' + - '二月' + - '三月' + - '四月' + - '五月' + - '六月' + - '七月' + - '八月' + - '九月' + - '十月' + - '十一月' + - '十二月' + DAYS_OF_THE_WEEK: + - '星期一' + - '星期二' + - '星期三' + - '星期四' + - '星期五' + - '星期六' + - '星期日' + YES: "是" + NO: "否" + CRON: + EVERY: 每 + EVERY_HOUR: 每小時 + EVERY_MINUTE: 每分鐘 + EVERY_DAY_OF_WEEK: 每一天 + EVERY_DAY_OF_MONTH: 每一天 + EVERY_MONTH: 每個月 + TEXT_PERIOD: 每 + TEXT_MINS: ' 的 分' + TEXT_TIME: ' :' + TEXT_DOW: ' 的 ' + TEXT_MONTH: ' 的 ' + TEXT_DOM: ' 的 ' diff --git a/system/languages/zh.yaml b/system/languages/zh.yaml new file mode 100644 index 0000000..d1afaa4 --- /dev/null +++ b/system/languages/zh.yaml @@ -0,0 +1,146 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\n标题: %1$s\n---\n\n# 错误:无效参数\n\n位置: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - '装备' + - '信息' + - '大米' + - '钱' + - '物种' + - '系列' + - '鱼' + - '羊' + INFLECTOR_IRREGULAR: + 'person': '人员' + 'man': '男人' + 'child': '儿童' + 'sex': '性别' + 'move': '移动' + INFLECTOR_ORDINALS: + 'default': 'th' + 'first': 'st' + 'second': 'md' + 'third': 'rd' + NICETIME: + NO_DATE_PROVIDED: 无日期信息 + BAD_DATE: 无效日期 + AGO: 前 + FROM_NOW: 距今 + JUST_NOW: 刚刚 + SECOND: 秒 + MINUTE: 分钟 + HOUR: 小时 + DAY: 天 + WEEK: 周 + MONTH: 月 + YEAR: 年 + DECADE: 十年 + SEC: 秒 + MIN: 分钟 + HR: 小时 + WK: 周 + MO: 月 + YR: 年 + DEC: 年代 + SECOND_PLURAL: 秒 + MINUTE_PLURAL: 分 + HOUR_PLURAL: 小时 + DAY_PLURAL: 天 + WEEK_PLURAL: 周 + MONTH_PLURAL: 月 + YEAR_PLURAL: 年 + DECADE_PLURAL: 十年 + SEC_PLURAL: 秒 + MIN_PLURAL: 分 + HR_PLURAL: 时 + WK_PLURAL: 周 + MO_PLURAL: 月 + YR_PLURAL: 年 + DEC_PLURAL: 年代 + FORM: + VALIDATION_FAIL: '验证失败:' + INVALID_INPUT: '无效输入' + MISSING_REQUIRED_FIELD: '必填字段缺失:' + MONTHS_OF_THE_YEAR: + - '1月' + - '2月' + - '3月' + - '4月' + - '5月' + - '6月' + - '7月' + - '8月' + - '9月' + - '10月' + - '11月' + - '12月' + DAYS_OF_THE_WEEK: + - '星期一' + - '星期二' + - '星期三' + - '星期四' + - '星期五' + - '星期六' + - '星期日' + YES: "是" + NO: "否" + CRON: + EVERY: 每隔 + EVERY_HOUR: 每小时 + EVERY_MINUTE: 每分钟 + EVERY_DAY_OF_WEEK: 一周中的每一天 + EVERY_DAY_OF_MONTH: 月份中的每一天 + EVERY_MONTH: 每月 + TEXT_PERIOD: 所有 + TEXT_MINS: ' 在 小时过后的分钟' + TEXT_TIME: ' 在 :' + TEXT_DOW: ' on ' + TEXT_MONTH: ' of ' + TEXT_DOM: ' on ' + ERROR1: 不支持分享类型 %s + ERROR2: 无效数字 + ERROR3: 请在 jqCron 设置中设定 jquery_element + ERROR4: 无法识别表达式 diff --git a/system/pages/notfound.md b/system/pages/notfound.md new file mode 100644 index 0000000..9228664 --- /dev/null +++ b/system/pages/notfound.md @@ -0,0 +1,6 @@ +--- +title: Not Found +routable: false +notfound: true +expires: 0 +--- diff --git a/system/router.php b/system/router.php new file mode 100644 index 0000000..dc2cc04 --- /dev/null +++ b/system/router.php @@ -0,0 +1,55 @@ +load('example.xml'); + * foreach(new DOMLettersIterator($doc) as $letter) echo $letter; + * + * NB: If you only need characters without their position + * in the document, use DOMNode->textContent instead. + * + * @author porneL http://pornel.net + * @license Public Domain + * @url https://github.com/antoligy/dom-string-iterators + * + * @implements Iterator + */ +final class DOMLettersIterator implements Iterator +{ + /** @var DOMElement */ + private $start; + /** @var DOMElement|null */ + private $current; + /** @var int */ + private $offset = -1; + /** @var int|null */ + private $key; + /** @var array|null */ + private $letters; + + /** + * expects DOMElement or DOMDocument (see DOMDocument::load and DOMDocument::loadHTML) + * + * @param DOMNode $el + */ + public function __construct(DOMNode $el) + { + if ($el instanceof DOMDocument) { + $el = $el->documentElement; + } + + if (!$el instanceof DOMElement) { + throw new InvalidArgumentException('Invalid arguments, expected DOMElement or DOMDocument'); + } + + $this->start = $el; + } + + /** + * Returns position in text as DOMText node and character offset. + * (it's NOT a byte offset, you must use mb_substr() or similar to use this offset properly). + * node may be NULL if iterator has finished. + * + * @return array + */ + public function currentTextPosition(): array + { + return [$this->current, $this->offset]; + } + + /** + * Returns DOMElement that is currently being iterated or NULL if iterator has finished. + * + * @return DOMElement|null + */ + public function currentElement(): ?DOMElement + { + return $this->current ? $this->current->parentNode : null; + } + + // Implementation of Iterator interface + + /** + * @return int|null + */ + public function key(): ?int + { + return $this->key; + } + + /** + * @return void + */ + public function next(): void + { + if (null === $this->current) { + return; + } + + if ($this->current->nodeType === XML_TEXT_NODE || $this->current->nodeType === XML_CDATA_SECTION_NODE) { + if ($this->offset === -1) { + preg_match_all('/./us', $this->current->textContent, $m); + $this->letters = $m[0]; + } + + $this->offset++; + $this->key++; + if ($this->letters && $this->offset < count($this->letters)) { + return; + } + + $this->offset = -1; + } + + while ($this->current->nodeType === XML_ELEMENT_NODE && $this->current->firstChild) { + $this->current = $this->current->firstChild; + if ($this->current->nodeType === XML_TEXT_NODE || $this->current->nodeType === XML_CDATA_SECTION_NODE) { + $this->next(); + return; + } + } + + while (!$this->current->nextSibling && $this->current->parentNode) { + $this->current = $this->current->parentNode; + if ($this->current === $this->start) { + $this->current = null; + return; + } + } + + $this->current = $this->current->nextSibling; + + $this->next(); + } + + /** + * Return the current element + * @link https://php.net/manual/en/iterator.current.php + * + * @return string|null + */ + public function current(): ?string + { + return $this->letters ? $this->letters[$this->offset] : null; + } + + /** + * Checks if current position is valid + * @link https://php.net/manual/en/iterator.valid.php + * + * @return bool + */ + public function valid(): bool + { + return (bool)$this->current; + } + + /** + * @return void + */ + public function rewind(): void + { + $this->current = $this->start; + $this->offset = -1; + $this->key = 0; + $this->letters = []; + + $this->next(); + } +} + diff --git a/system/src/DOMWordsIterator.php b/system/src/DOMWordsIterator.php new file mode 100644 index 0000000..fb7c2e3 --- /dev/null +++ b/system/src/DOMWordsIterator.php @@ -0,0 +1,158 @@ +load('example.xml'); + * foreach(new DOMWordsIterator($doc) as $word) echo $word; + * + * @author pjgalbraith http://www.pjgalbraith.com + * @author porneL http://pornel.net (based on DOMLettersIterator available at http://pornel.net/source/domlettersiterator.php) + * @license Public Domain + * @url https://github.com/antoligy/dom-string-iterators + * + * @implements Iterator + */ + +final class DOMWordsIterator implements Iterator +{ + /** @var DOMElement */ + private $start; + /** @var DOMElement|null */ + private $current; + /** @var int */ + private $offset = -1; + /** @var int|null */ + private $key; + /** @var array>|null */ + private $words; + + /** + * expects DOMElement or DOMDocument (see DOMDocument::load and DOMDocument::loadHTML) + * + * @param DOMNode $el + */ + public function __construct(DOMNode $el) + { + if ($el instanceof DOMDocument) { + $el = $el->documentElement; + } + + if (!$el instanceof DOMElement) { + throw new InvalidArgumentException('Invalid arguments, expected DOMElement or DOMDocument'); + } + + $this->start = $el; + } + + /** + * Returns position in text as DOMText node and character offset. + * (it's NOT a byte offset, you must use mb_substr() or similar to use this offset properly). + * node may be NULL if iterator has finished. + * + * @return array + */ + public function currentWordPosition(): array + { + return [$this->current, $this->offset, $this->words]; + } + + /** + * Returns DOMElement that is currently being iterated or NULL if iterator has finished. + * + * @return DOMElement|null + */ + public function currentElement(): ?DOMElement + { + return $this->current ? $this->current->parentNode : null; + } + + // Implementation of Iterator interface + + /** + * Return the key of the current element + * @link https://php.net/manual/en/iterator.key.php + * @return int|null + */ + public function key(): ?int + { + return $this->key; + } + + /** + * @return void + */ + public function next(): void + { + if (null === $this->current) { + return; + } + + if ($this->current->nodeType === XML_TEXT_NODE || $this->current->nodeType === XML_CDATA_SECTION_NODE) { + if ($this->offset === -1) { + $this->words = preg_split("/[\n\r\t ]+/", $this->current->textContent, -1, PREG_SPLIT_NO_EMPTY|PREG_SPLIT_OFFSET_CAPTURE) ?: []; + } + $this->offset++; + + if ($this->words && $this->offset < count($this->words)) { + $this->key++; + return; + } + $this->offset = -1; + } + + while ($this->current->nodeType === XML_ELEMENT_NODE && $this->current->firstChild) { + $this->current = $this->current->firstChild; + if ($this->current->nodeType === XML_TEXT_NODE || $this->current->nodeType === XML_CDATA_SECTION_NODE) { + $this->next(); + return; + } + } + + while (!$this->current->nextSibling && $this->current->parentNode) { + $this->current = $this->current->parentNode; + if ($this->current === $this->start) { + $this->current = null; + return; + } + } + + $this->current = $this->current->nextSibling; + + $this->next(); + } + + /** + * Return the current element + * @link https://php.net/manual/en/iterator.current.php + * @return string|null + */ + public function current(): ?string + { + return $this->words ? (string)$this->words[$this->offset][0] : null; + } + + /** + * Checks if current position is valid + * @link https://php.net/manual/en/iterator.valid.php + * @return bool + */ + public function valid(): bool + { + return (bool)$this->current; + } + + public function rewind(): void + { + $this->current = $this->start; + $this->offset = -1; + $this->key = 0; + $this->words = []; + + $this->next(); + } +} diff --git a/system/src/Grav/Common/Assets.php b/system/src/Grav/Common/Assets.php new file mode 100644 index 0000000..afb4e63 --- /dev/null +++ b/system/src/Grav/Common/Assets.php @@ -0,0 +1,595 @@ +get('system.assets'); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $this->assets_dir = $locator->findResource('asset://'); + $this->assets_url = $locator->findResource('asset://', false); + + $this->config($asset_config); + + // Register any preconfigured collections + foreach ((array) $this->collections as $name => $collection) { + $this->registerCollection($name, (array)$collection); + } + } + + /** + * Set up configuration options. + * + * All the class properties except 'js' and 'css' are accepted here. + * Also, an extra option 'autoload' may be passed containing an array of + * assets and/or collections that will be automatically added on startup. + * + * @param array $config Configurable options. + * @return $this + */ + public function config(array $config) + { + foreach ($config as $key => $value) { + if ($this->hasProperty($key)) { + $this->setProperty($key, $value); + } elseif (Utils::startsWith($key, 'css_') || Utils::startsWith($key, 'js_')) { + $this->pipeline_options[$key] = $value; + } + } + + // Add timestamp if it's enabled + if ($this->enable_asset_timestamp) { + $this->timestamp = Grav::instance()['cache']->getKey(); + } + + return $this; + } + + /** + * Add an asset or a collection of assets. + * + * It automatically detects the asset type (JavaScript, CSS or collection). + * You may add more than one asset passing an array as argument. + * + * @param string|string[] $asset + * @return $this + */ + public function add($asset) + { + if (!$asset) { + return $this; + } + + $args = func_get_args(); + + // More than one asset + if (is_array($asset)) { + foreach ($asset as $index => $location) { + $params = array_slice($args, 1); + if (is_array($location)) { + $params = array_shift($params); + if (is_numeric($params)) { + $params = [ 'priority' => $params ]; + } + $params = [array_replace_recursive([], $location, $params)]; + $location = $index; + } + + $params = array_merge([$location], $params); + call_user_func_array([$this, 'add'], $params); + } + } elseif (isset($this->collections[$asset])) { + array_shift($args); + $args = array_merge([$this->collections[$asset]], $args); + call_user_func_array([$this, 'add'], $args); + } else { + // Get extension + $path = parse_url($asset, PHP_URL_PATH); + $extension = $path ? Utils::pathinfo($path, PATHINFO_EXTENSION) : ''; + + // JavaScript or CSS + if ($extension !== '') { + $extension = strtolower($extension); + if ($extension === 'css') { + call_user_func_array([$this, 'addCss'], $args); + } elseif ($extension === 'js') { + call_user_func_array([$this, 'addJs'], $args); + } elseif ($extension === 'mjs') { + call_user_func_array([$this, 'addJsModule'], $args); + } + } + } + + return $this; + } + + /** + * @param string $collection + * @param string $type + * @param string|string[] $asset + * @param array $options + * @return $this + */ + protected function addType($collection, $type, $asset, $options) + { + if (is_array($asset)) { + foreach ($asset as $index => $location) { + $assetOptions = $options; + if (is_array($location)) { + $assetOptions = array_replace_recursive([], $options, $location); + $location = $index; + } + $this->addType($collection, $type, $location, $assetOptions); + } + + return $this; + } + + if ($this->isValidType($type) && isset($this->collections[$asset])) { + $this->addType($collection, $type, $this->collections[$asset], $options); + return $this; + } + + // If pipeline disabled, set to position if provided, else after + if (isset($options['pipeline'])) { + if ($options['pipeline'] === false) { + + $exclude_type = $this->getBaseType($type); + + $excludes = strtolower($exclude_type . '_pipeline_before_excludes'); + if ($this->{$excludes}) { + $default = 'after'; + } else { + $default = 'before'; + } + + $options['position'] = $options['position'] ?? $default; + } + + unset($options['pipeline']); + } + + // Add timestamp + $timestamp_override = $options['timestamp'] ?? true; + + if (filter_var($timestamp_override, FILTER_VALIDATE_BOOLEAN)) { + $options['timestamp'] = $this->timestamp; + } else { + $options['timestamp'] = null; + } + + // Set order + $group = $options['group'] ?? 'head'; + $position = $options['position'] ?? 'pipeline'; + + $orderKey = "{$type}|{$group}|{$position}"; + if (!isset($this->order[$orderKey])) { + $this->order[$orderKey] = 0; + } + + $options['order'] = $this->order[$orderKey]++; + + // Create asset of correct type + $asset_object = new $type(); + + // If exists + if ($asset_object->init($asset, $options)) { + $this->$collection[md5($asset)] = $asset_object; + } + + return $this; + } + + /** + * Add a CSS asset or a collection of assets. + * + * @return $this + */ + public function addLink($asset) + { + return $this->addType($this::LINK_COLLECTION, $this::LINK_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::LINK_TYPE)); + } + + /** + * Add a CSS asset or a collection of assets. + * + * @return $this + */ + public function addCss($asset) + { + return $this->addType($this::CSS_COLLECTION, $this::CSS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::CSS_TYPE)); + } + + /** + * Add an Inline CSS asset or a collection of assets. + * + * @return $this + */ + public function addInlineCss($asset) + { + return $this->addType($this::CSS_COLLECTION, $this::INLINE_CSS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::INLINE_CSS_TYPE)); + } + + /** + * Add a JS asset or a collection of assets. + * + * @return $this + */ + public function addJs($asset) + { + return $this->addType($this::JS_COLLECTION, $this::JS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::JS_TYPE)); + } + + /** + * Add an Inline JS asset or a collection of assets. + * + * @return $this + */ + public function addInlineJs($asset) + { + return $this->addType($this::JS_COLLECTION, $this::INLINE_JS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::INLINE_JS_TYPE)); + } + + /** + * Add a JS asset or a collection of assets. + * + * @return $this + */ + public function addJsModule($asset) + { + return $this->addType($this::JS_MODULE_COLLECTION, $this::JS_MODULE_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::JS_MODULE_TYPE)); + } + + /** + * Add an Inline JS asset or a collection of assets. + * + * @return $this + */ + public function addInlineJsModule($asset) + { + return $this->addType($this::JS_MODULE_COLLECTION, $this::INLINE_JS_MODULE_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::INLINE_JS_MODULE_TYPE)); + } + + /** + * Add/replace collection. + * + * @param string $collectionName + * @param array $assets + * @param bool $overwrite + * @return $this + */ + public function registerCollection($collectionName, array $assets, $overwrite = false) + { + if ($overwrite || !isset($this->collections[$collectionName])) { + $this->collections[$collectionName] = $assets; + } + + return $this; + } + + /** + * @param array $assets + * @param string $key + * @param string $value + * @param bool $sort + * @return array|false + */ + protected function filterAssets($assets, $key, $value, $sort = false) + { + $results = array_filter($assets, function ($asset) use ($key, $value) { + + if ($key === 'position' && $value === 'pipeline') { + $type = $asset->getType(); + if ($type === 'jsmodule') { + $type = 'js_module'; + } + + if ($asset->getRemote() && $this->{strtolower($type) . '_pipeline_include_externals'} === false && $asset['position'] === 'pipeline') { + if ($this->{strtolower($type) . '_pipeline_before_excludes'}) { + $asset->setPosition('after'); + } else { + $asset->setPosition('before'); + } + return false; + } + } + + if ($asset[$key] === $value) { + return true; + } + return false; + }); + + if ($sort && !empty($results)) { + $results = $this->sortAssets($results); + } + + + return $results; + } + + /** + * @param array $assets + * @return array + */ + protected function sortAssets($assets) + { + uasort($assets, static function ($a, $b) { + return $b['priority'] <=> $a['priority'] ?: $a['order'] <=> $b['order']; + }); + + return $assets; + } + + /** + * @param string $type + * @param string $group + * @param array $attributes + * @return string + */ + public function render($type, $group = 'head', $attributes = []) + { + $before_output = ''; + $pipeline_output = ''; + $after_output = ''; + + $assets = 'assets_' . $type; + $pipeline_enabled = $type . '_pipeline'; + $render_pipeline = 'render' . ucfirst($type); + + $group_assets = $this->filterAssets($this->$assets, 'group', $group); + $pipeline_assets = $this->filterAssets($group_assets, 'position', 'pipeline', true); + $before_assets = $this->filterAssets($group_assets, 'position', 'before', true); + $after_assets = $this->filterAssets($group_assets, 'position', 'after', true); + + // Pipeline + if ($this->{$pipeline_enabled} ?? false) { + $options = array_merge($this->pipeline_options, ['timestamp' => $this->timestamp]); + + $pipeline = new Pipeline($options); + $pipeline_output = $pipeline->$render_pipeline($pipeline_assets, $group, $attributes); + } else { + foreach ($pipeline_assets as $asset) { + $pipeline_output .= $asset->render(); + } + } + + // Before Pipeline + foreach ($before_assets as $asset) { + $before_output .= $asset->render(); + } + + // After Pipeline + foreach ($after_assets as $asset) { + $after_output .= $asset->render(); + } + + return $before_output . $pipeline_output . $after_output; + } + + + /** + * Build the CSS link tags. + * + * @param string $group name of the group + * @param array $attributes + * @return string + */ + public function css($group = 'head', $attributes = [], $include_link = true) + { + $output = ''; + + if ($include_link) { + $output = $this->link($group, $attributes); + } + + $output .= $this->render(self::CSS, $group, $attributes); + + return $output; + } + + /** + * Build the CSS link tags. + * + * @param string $group name of the group + * @param array $attributes + * @return string + */ + public function link($group = 'head', $attributes = []) + { + return $this->render(self::LINK, $group, $attributes); + } + + /** + * Build the JavaScript script tags. + * + * @param string $group name of the group + * @param array $attributes + * @return string + */ + public function js($group = 'head', $attributes = [], $include_js_module = true) + { + $output = $this->render(self::JS, $group, $attributes); + + if ($include_js_module) { + $output .= $this->jsModule($group, $attributes); + } + + return $output; + } + + /** + * Build the Javascript Modules tags + * + * @param string $group + * @param array $attributes + * @return string + */ + public function jsModule($group = 'head', $attributes = []) + { + return $this->render(self::JS_MODULE, $group, $attributes); + } + + /** + * @param string $group + * @param array $attributes + * @return string + */ + public function all($group = 'head', $attributes = []) + { + $output = $this->css($group, $attributes, false); + $output .= $this->link($group, $attributes); + $output .= $this->js($group, $attributes, false); + $output .= $this->jsModule($group, $attributes); + return $output; + } + + /** + * @param class-string $type + * @return bool + */ + protected function isValidType($type) + { + return in_array($type, [self::CSS_TYPE, self::JS_TYPE, self::JS_MODULE_TYPE]); + } + + /** + * @param class-string $type + * @return string + */ + protected function getBaseType($type) + { + switch ($type) { + case $this::JS_TYPE: + case $this::INLINE_JS_TYPE: + $base_type = $this::JS; + break; + case $this::JS_MODULE_TYPE: + case $this::INLINE_JS_MODULE_TYPE: + $base_type = $this::JS_MODULE; + break; + default: + $base_type = $this::CSS; + } + + return $base_type; + } +} diff --git a/system/src/Grav/Common/Assets/BaseAsset.php b/system/src/Grav/Common/Assets/BaseAsset.php new file mode 100644 index 0000000..29082f8 --- /dev/null +++ b/system/src/Grav/Common/Assets/BaseAsset.php @@ -0,0 +1,283 @@ + 'head', + 'position' => 'pipeline', + 'priority' => 10, + 'modified' => null, + 'asset' => null + ]; + + // Merge base defaults + $elements = array_merge($base_config, $elements); + + parent::__construct($elements, $key); + } + + /** + * @param string|false $asset + * @param array $options + * @return $this|false + */ + public function init($asset, $options) + { + if (!$asset) { + return false; + } + + $config = Grav::instance()['config']; + $uri = Grav::instance()['uri']; + + // set attributes + foreach ($options as $key => $value) { + if ($this->hasProperty($key)) { + $this->setProperty($key, $value); + } else { + $this->attributes[$key] = $value; + } + } + + // Force priority to be an int + $this->priority = (int) $this->priority; + + // Do some special stuff for CSS/JS (not inline) + if (!Utils::startsWith($this->getType(), 'inline')) { + $this->base_url = rtrim($uri->rootUrl($config->get('system.absolute_urls')), '/') . '/'; + $this->remote = static::isRemoteLink($asset); + + // Move this to render? + if (!$this->remote) { + $asset_parts = parse_url($asset); + if (isset($asset_parts['query'])) { + $this->query = $asset_parts['query']; + unset($asset_parts['query']); + $asset = Uri::buildUrl($asset_parts); + } + + $locator = Grav::instance()['locator']; + + if ($locator->isStream($asset)) { + $path = $locator->findResource($asset, true); + } else { + $path = GRAV_WEBROOT . $asset; + } + + // If local file is missing return + if ($path === false) { + return false; + } + + $file = new SplFileInfo($path); + + $asset = $this->buildLocalLink($file->getPathname()); + + $this->modified = $file->isFile() ? $file->getMTime() : false; + } + } + + $this->asset = $asset; + + return $this; + } + + /** + * @return string|false + */ + public function getAsset() + { + return $this->asset; + } + + /** + * @return bool + */ + public function getRemote() + { + return $this->remote; + } + + /** + * @param string $position + * @return $this + */ + public function setPosition($position) + { + $this->position = $position; + + return $this; + } + + /** + * Receive asset location and return the SRI integrity hash + * + * @param string $input + * @return string + */ + public static function integrityHash($input) + { + $grav = Grav::instance(); + $uri = $grav['uri']; + + $assetsConfig = $grav['config']->get('system.assets'); + + if (!self::isRemoteLink($input) && !empty($assetsConfig['enable_asset_sri']) && $assetsConfig['enable_asset_sri']) { + $input = preg_replace('#^' . $uri->rootUrl() . '#', '', $input); + $asset = File::instance(GRAV_WEBROOT . $input); + + if ($asset->exists()) { + $dataToHash = $asset->content(); + $hash = hash('sha256', $dataToHash, true); + $hash_base64 = base64_encode($hash); + + return ' integrity="sha256-' . $hash_base64 . '"'; + } + } + + return ''; + } + + + /** + * + * Get the last modification time of asset + * + * @param string $asset the asset string reference + * + * @return string the last modifcation time or false on error + */ +// protected function getLastModificationTime($asset) +// { +// $file = GRAV_WEBROOT . $asset; +// if (Grav::instance()['locator']->isStream($asset)) { +// $file = $this->buildLocalLink($asset, true); +// } +// +// return file_exists($file) ? filemtime($file) : false; +// } + + /** + * + * Build local links including grav asset shortcodes + * + * @param string $asset the asset string reference + * + * @return string|false the final link url to the asset + */ + protected function buildLocalLink($asset) + { + if ($asset) { + return $this->base_url . ltrim(Utils::replaceFirstOccurrence(GRAV_WEBROOT, '', $asset), '/'); + } + return false; + } + + + /** + * Implements JsonSerializable interface. + * + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return ['type' => $this->getType(), 'elements' => $this->getElements()]; + } + + /** + * Placeholder for AssetUtilsTrait method + * + * @param string $file + * @param string $dir + * @param bool $local + * @return string + */ + protected function cssRewrite($file, $dir, $local) + { + return ''; + } + + /** + * Finds relative JS urls() and rewrites the URL with an absolute one + * + * @param string $file the css source file + * @param string $dir local relative path to the css file + * @param bool $local is this a local or remote asset + * @return string + */ + protected function jsRewrite($file, $dir, $local) + { + return ''; + } +} diff --git a/system/src/Grav/Common/Assets/BlockAssets.php b/system/src/Grav/Common/Assets/BlockAssets.php new file mode 100644 index 0000000..211a6a1 --- /dev/null +++ b/system/src/Grav/Common/Assets/BlockAssets.php @@ -0,0 +1,207 @@ +getAssets(); + foreach ($types as $type => $groups) { + switch ($type) { + case 'frameworks': + static::registerFrameworks($assets, $groups); + break; + case 'styles': + static::registerStyles($assets, $groups); + break; + case 'scripts': + static::registerScripts($assets, $groups); + break; + case 'links': + static::registerLinks($assets, $groups); + break; + case 'html': + static::registerHtml($assets, $groups); + break; + } + } + } + + /** + * @param Assets $assets + * @param array $list + * @return void + */ + protected static function registerFrameworks(Assets $assets, array $list): void + { + if ($list) { + throw new \RuntimeException('Not Implemented'); + } + } + + /** + * @param Assets $assets + * @param array $groups + * @return void + */ + protected static function registerStyles(Assets $assets, array $groups): void + { + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + foreach ($groups as $group => $styles) { + foreach ($styles as $style) { + switch ($style[':type']) { + case 'file': + $options = [ + 'priority' => $style[':priority'], + 'group' => $group, + 'type' => $style['type'], + 'media' => $style['media'] + ] + $style['element']; + + $assets->addCss(static::getRelativeUrl($style['href'], $config->get('system.assets.css_pipeline')), $options); + break; + case 'inline': + $options = [ + 'priority' => $style[':priority'], + 'group' => $group, + 'type' => $style['type'], + ] + $style['element']; + + $assets->addInlineCss($style['content'], $options); + break; + } + } + } + } + + /** + * @param Assets $assets + * @param array $groups + * @return void + */ + protected static function registerScripts(Assets $assets, array $groups): void + { + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + foreach ($groups as $group => $scripts) { + $group = $group === 'footer' ? 'bottom' : $group; + + foreach ($scripts as $script) { + switch ($script[':type']) { + case 'file': + $options = [ + 'group' => $group, + 'priority' => $script[':priority'], + 'src' => $script['src'], + 'type' => $script['type'], + 'loading' => $script['loading'], + 'defer' => $script['defer'], + 'async' => $script['async'], + 'handle' => $script['handle'] + ] + $script['element']; + + $assets->addJs(static::getRelativeUrl($script['src'], $config->get('system.assets.js_pipeline')), $options); + break; + case 'inline': + $options = [ + 'priority' => $script[':priority'], + 'group' => $group, + 'type' => $script['type'], + 'loading' => $script['loading'] + ] + $script['element']; + + $assets->addInlineJs($script['content'], $options); + break; + } + } + } + } + + /** + * @param Assets $assets + * @param array $groups + * @return void + */ + protected static function registerLinks(Assets $assets, array $groups): void + { + foreach ($groups as $group => $links) { + foreach ($links as $link) { + $href = $link['href']; + $options = [ + 'group' => $group, + 'priority' => $link[':priority'], + 'rel' => $link['rel'], + ] + $link['element']; + + $assets->addLink($href, $options); + } + } + } + + /** + * @param Assets $assets + * @param array $groups + * @return void + */ + protected static function registerHtml(Assets $assets, array $groups): void + { + if ($groups) { + throw new \RuntimeException('Not Implemented'); + } + } + + /** + * @param string $url + * @param bool $pipeline + * @return string + */ + protected static function getRelativeUrl($url, $pipeline) + { + $grav = Grav::instance(); + + $base = rtrim($grav['base_url'], '/') ?: '/'; + + if (strpos($url, $base) === 0) { + if ($pipeline) { + // Remove file timestamp if CSS pipeline has been enabled. + $url = preg_replace('|[?#].*|', '', $url); + } + + return substr($url, strlen($base) - 1); + } + return $url; + } +} diff --git a/system/src/Grav/Common/Assets/Css.php b/system/src/Grav/Common/Assets/Css.php new file mode 100644 index 0000000..b7c1c21 --- /dev/null +++ b/system/src/Grav/Common/Assets/Css.php @@ -0,0 +1,52 @@ + 'css', + 'attributes' => [ + 'type' => 'text/css', + 'rel' => 'stylesheet' + ] + ]; + + $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements); + + parent::__construct($merged_attributes, $key); + } + + /** + * @return string + */ + public function render() + { + if (isset($this->attributes['loading']) && $this->attributes['loading'] === 'inline') { + $buffer = $this->gatherLinks([$this], self::CSS_ASSET); + return "\n"; + } + + return 'renderAttributes() . $this->integrityHash($this->asset) . ">\n"; + } +} diff --git a/system/src/Grav/Common/Assets/InlineCss.php b/system/src/Grav/Common/Assets/InlineCss.php new file mode 100644 index 0000000..1f31448 --- /dev/null +++ b/system/src/Grav/Common/Assets/InlineCss.php @@ -0,0 +1,44 @@ + 'css', + 'position' => 'after' + ]; + + $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements); + + parent::__construct($merged_attributes, $key); + } + + /** + * @return string + */ + public function render() + { + return 'renderAttributes(). ">\n" . trim($this->asset) . "\n\n"; + } +} diff --git a/system/src/Grav/Common/Assets/InlineJs.php b/system/src/Grav/Common/Assets/InlineJs.php new file mode 100644 index 0000000..bf5837c --- /dev/null +++ b/system/src/Grav/Common/Assets/InlineJs.php @@ -0,0 +1,44 @@ + 'js', + 'position' => 'after' + ]; + + $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements); + + parent::__construct($merged_attributes, $key); + } + + /** + * @return string + */ + public function render() + { + return 'renderAttributes(). ">\n" . trim($this->asset) . "\n\n"; + } +} diff --git a/system/src/Grav/Common/Assets/InlineJsModule.php b/system/src/Grav/Common/Assets/InlineJsModule.php new file mode 100644 index 0000000..091117c --- /dev/null +++ b/system/src/Grav/Common/Assets/InlineJsModule.php @@ -0,0 +1,46 @@ + 'js_module', + 'attributes' => ['type' => 'module'], + 'position' => 'after' + ]; + + $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements); + + parent::__construct($merged_attributes, $key); + } + + /** + * @return string + */ + public function render() + { + return 'renderAttributes(). ">\n" . trim($this->asset) . "\n\n"; + } + +} diff --git a/system/src/Grav/Common/Assets/Js.php b/system/src/Grav/Common/Assets/Js.php new file mode 100644 index 0000000..e68eb43 --- /dev/null +++ b/system/src/Grav/Common/Assets/Js.php @@ -0,0 +1,48 @@ + 'js', + ]; + + $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements); + + parent::__construct($merged_attributes, $key); + } + + /** + * @return string + */ + public function render() + { + if (isset($this->attributes['loading']) && $this->attributes['loading'] === 'inline') { + $buffer = $this->gatherLinks([$this], self::JS_ASSET); + return 'renderAttributes() . ">\n" . trim($buffer) . "\n\n"; + } + + return '\n"; + } +} diff --git a/system/src/Grav/Common/Assets/JsModule.php b/system/src/Grav/Common/Assets/JsModule.php new file mode 100644 index 0000000..419e984 --- /dev/null +++ b/system/src/Grav/Common/Assets/JsModule.php @@ -0,0 +1,49 @@ + 'js_module', + 'attributes' => ['type' => 'module'] + ]; + + $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements); + + parent::__construct($merged_attributes, $key); + } + + /** + * @return string + */ + public function render() + { + if (isset($this->attributes['loading']) && $this->attributes['loading'] === 'inline') { + $buffer = $this->gatherLinks([$this], self::JS_MODULE_ASSET); + return 'renderAttributes() . ">\n" . trim($buffer) . "\n\n"; + } + + return '\n"; + } +} diff --git a/system/src/Grav/Common/Assets/Link.php b/system/src/Grav/Common/Assets/Link.php new file mode 100644 index 0000000..975d153 --- /dev/null +++ b/system/src/Grav/Common/Assets/Link.php @@ -0,0 +1,43 @@ + 'link', + ]; + + $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements); + + parent::__construct($merged_attributes, $key); + } + + /** + * @return string + */ + public function render() + { + return 'renderAttributes() . $this->integrityHash($this->asset) . ">\n"; + } +} diff --git a/system/src/Grav/Common/Assets/Pipeline.php b/system/src/Grav/Common/Assets/Pipeline.php new file mode 100644 index 0000000..e740012 --- /dev/null +++ b/system/src/Grav/Common/Assets/Pipeline.php @@ -0,0 +1,347 @@ +base_url = rtrim($uri->rootUrl($config->get('system.absolute_urls')), '/') . '/'; + $this->assets_dir = $locator->findResource('asset://'); + if (!$this->assets_dir) { + // Attempt to create assets folder if it doesn't exist yet. + $this->assets_dir = $locator->findResource('asset://', true, true); + Folder::mkdir($this->assets_dir); + $locator->clearCache(); + } + + $this->assets_url = $locator->findResource('asset://', false); + } + + /** + * Minify and concatenate CSS + * + * @param array $assets + * @param string $group + * @param array $attributes + * @return bool|string URL or generated content if available, else false + */ + public function renderCss($assets, $group, $attributes = []) + { + // temporary list of assets to pipeline + $inline_group = false; + + if (array_key_exists('loading', $attributes) && $attributes['loading'] === 'inline') { + $inline_group = true; + unset($attributes['loading']); + } + + // Store Attributes + $this->attributes = array_merge(['type' => 'text/css', 'rel' => 'stylesheet'], $attributes); + + // Compute uid based on assets and timestamp + $json_assets = json_encode($assets); + $uid = md5($json_assets . (int)$this->css_minify . (int)$this->css_rewrite . $group); + $file = $uid . '.css'; + $relative_path = "{$this->base_url}{$this->assets_url}/{$file}"; + + $filepath = "{$this->assets_dir}/{$file}"; + if (file_exists($filepath)) { + $buffer = file_get_contents($filepath) . "\n"; + } else { + //if nothing found get out of here! + if (empty($assets)) { + return false; + } + + // Concatenate files + $buffer = $this->gatherLinks($assets, self::CSS_ASSET); + + // Minify if required + if ($this->shouldMinify('css')) { + $minifier = new CSS(); + $minifier->add($buffer); + $buffer = $minifier->minify(); + } + + // Write file + if (trim($buffer) !== '') { + file_put_contents($filepath, $buffer); + } + } + + if ($inline_group) { + $output = "\n"; + } else { + $this->asset = $relative_path; + $output = 'renderAttributes() . BaseAsset::integrityHash($this->asset) . ">\n"; + } + + return $output; + } + + /** + * Minify and concatenate JS files. + * + * @param array $assets + * @param string $group + * @param array $attributes + * @return bool|string URL or generated content if available, else false + */ + public function renderJs($assets, $group, $attributes = [], $type = self::JS_ASSET) + { + // temporary list of assets to pipeline + $inline_group = false; + + if (array_key_exists('loading', $attributes) && $attributes['loading'] === 'inline') { + $inline_group = true; + unset($attributes['loading']); + } + + // Store Attributes + $this->attributes = $attributes; + + // Compute uid based on assets and timestamp + $json_assets = json_encode($assets); + $uid = md5($json_assets . $this->js_minify . $group); + $file = $uid . '.js'; + $relative_path = "{$this->base_url}{$this->assets_url}/{$file}"; + + $filepath = "{$this->assets_dir}/{$file}"; + if (file_exists($filepath)) { + $buffer = file_get_contents($filepath) . "\n"; + } else { + //if nothing found get out of here! + if (empty($assets)) { + return false; + } + + // Concatenate files + $buffer = $this->gatherLinks($assets, $type); + + // Minify if required + if ($this->shouldMinify('js')) { + $minifier = new JS(); + $minifier->add($buffer); + $buffer = $minifier->minify(); + } + + // Write file + if (trim($buffer) !== '') { + file_put_contents($filepath, $buffer); + } + } + + if ($inline_group) { + $output = 'renderAttributes(). ">\n" . $buffer . "\n\n"; + } else { + $this->asset = $relative_path; + $output = '\n"; + } + + return $output; + } + + /** + * Minify and concatenate JS files. + * + * @param array $assets + * @param string $group + * @param array $attributes + * @return bool|string URL or generated content if available, else false + */ + public function renderJs_Module($assets, $group, $attributes = []) + { + $attributes['type'] = 'module'; + return $this->renderJs($assets, $group, $attributes, self::JS_MODULE_ASSET); + } + + /** + * Finds relative CSS urls() and rewrites the URL with an absolute one + * + * @param string $file the css source file + * @param string $dir , $local relative path to the css file + * @param bool $local is this a local or remote asset + * @return string + */ + protected function cssRewrite($file, $dir, $local) + { + // Strip any sourcemap comments + $file = preg_replace(self::CSS_SOURCEMAP_REGEX, '', $file); + + // Find any css url() elements, grab the URLs and calculate an absolute path + // Then replace the old url with the new one + $file = (string)preg_replace_callback(self::CSS_URL_REGEX, function ($matches) use ($dir, $local) { + $isImport = count($matches) > 3 && $matches[3] === '@import'; + + if ($isImport) { + $old_url = $matches[5]; + } else { + $old_url = $matches[2]; + } + + // Ensure link is not rooted to web server, a data URL, or to a remote host + if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url) || Utils::startsWith($old_url, 'data:') || $this->isRemoteLink($old_url)) { + return $matches[0]; + } + + // clean leading / + $old_url = Utils::normalizePath($dir . '/' . $old_url); + if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url)) { + $old_url = ltrim($old_url, '/'); + } + + $new_url = ($local ? $this->base_url : '') . $old_url; + + if ($isImport) { + return str_replace($matches[5], $new_url, $matches[0]); + } else { + return str_replace($matches[2], $new_url, $matches[0]); + } + }, $file); + + return $file; + } + + /** + * Finds relative JS urls() and rewrites the URL with an absolute one + * + * @param string $file the css source file + * @param string $dir local relative path to the css file + * @param bool $local is this a local or remote asset + * @return string + */ + protected function jsRewrite($file, $dir, $local) + { + // Find any js import elements, grab the URLs and calculate an absolute path + // Then replace the old url with the new one + $file = (string)preg_replace_callback(self::JS_IMPORT_REGEX, function ($matches) use ($dir, $local) { + + $old_url = $matches[1]; + + // Ensure link is not rooted to web server, a data URL, or to a remote host + if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url) || $this->isRemoteLink($old_url)) { + return $matches[0]; + } + + // clean leading / + $old_url = Utils::normalizePath($dir . '/' . $old_url); + $old_url = str_replace('/./', '/', $old_url); + if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url)) { + $old_url = ltrim($old_url, '/'); + } + + $new_url = ($local ? $this->base_url : '') . $old_url; + + return str_replace($matches[1], $new_url, $matches[0]); + }, $file); + + return $file; + } + + /** + * @param string $type + * @return bool + */ + private function shouldMinify($type = 'css') + { + $check = $type . '_minify'; + $win_check = $type . '_minify_windows'; + + $minify = (bool) $this->$check; + + // If this is a Windows server, and minify_windows is false (default value) skip the + // minification process because it will cause Apache to die/crash due to insufficient + // ThreadStackSize in httpd.conf - See: https://bugs.php.net/bug.php?id=47689 + if (stripos(php_uname('s'), 'WIN') === 0 && !$this->{$win_check}) { + $minify = false; + } + + return $minify; + } +} diff --git a/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php b/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php new file mode 100644 index 0000000..5b6de9c --- /dev/null +++ b/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php @@ -0,0 +1,215 @@ +rootUrl(true); + + // Sanity check for local URLs with absolute URL's enabled + if (Utils::startsWith($link, $base)) { + return false; + } + + return (0 === strpos($link, 'http://') || 0 === strpos($link, 'https://') || 0 === strpos($link, '//')); + } + + /** + * Download and concatenate the content of several links. + * + * @param array $assets + * @param int $type + * @return string + */ + protected function gatherLinks(array $assets, int $type = self::CSS_ASSET): string + { + $buffer = ''; + foreach ($assets as $asset) { + $local = true; + + $link = $asset->getAsset(); + $relative_path = $link; + + if (static::isRemoteLink($link)) { + $local = false; + if (0 === strpos($link, '//')) { + $link = 'http:' . $link; + } + $relative_dir = dirname($relative_path); + } else { + // Fix to remove relative dir if grav is in one + if (($this->base_url !== '/') && Utils::startsWith($relative_path, $this->base_url)) { + $base_url = '#' . preg_quote($this->base_url, '#') . '#'; + $relative_path = ltrim(preg_replace($base_url, '/', $link, 1), '/'); + } + + $relative_dir = dirname($relative_path); + $link = GRAV_ROOT . '/' . $relative_path; + } + + // TODO: looks like this is not being used. + $file = $this->fetch_command instanceof Closure ? @$this->fetch_command->__invoke($link) : @file_get_contents($link); + + // No file found, skip it... + if ($file === false) { + continue; + } + + // Double check last character being + if ($type === self::JS_ASSET || $type === self::JS_MODULE_ASSET) { + $file = rtrim($file, ' ;') . ';'; + } + + // If this is CSS + the file is local + rewrite enabled + if ($type === self::CSS_ASSET && $this->css_rewrite) { + $file = $this->cssRewrite($file, $relative_dir, $local); + } + + if ($type === self::JS_MODULE_ASSET) { + $file = $this->jsRewrite($file, $relative_dir, $local); + } + + $file = rtrim($file) . PHP_EOL; + $buffer .= $file; + } + + // Pull out @imports and move to top + if ($type === self::CSS_ASSET) { + $buffer = $this->moveImports($buffer); + } + + return $buffer; + } + + /** + * Moves @import statements to the top of the file per the CSS specification + * + * @param string $file the file containing the combined CSS files + * @return string the modified file with any @imports at the top of the file + */ + protected function moveImports($file) + { + $regex = '{@import.*?["\']([^"\']+)["\'].*?;}'; + + $imports = []; + + $file = (string)preg_replace_callback($regex, static function ($matches) use (&$imports) { + $imports[] = $matches[0]; + + return ''; + }, $file); + + return implode("\n", $imports) . "\n\n" . $file; + } + + /** + * + * Build an HTML attribute string from an array. + * + * @return string + */ + protected function renderAttributes() + { + $html = ''; + $no_key = ['loading']; + + foreach ($this->attributes as $key => $value) { + if ($value === null) { + continue; + } + + if (is_numeric($key)) { + $key = $value; + } + if (is_array($value)) { + $value = implode(' ', $value); + } + + if (in_array($key, $no_key, true)) { + $element = htmlentities($value, ENT_QUOTES, 'UTF-8', false); + } else { + $element = $key . '="' . htmlentities($value, ENT_QUOTES, 'UTF-8', false) . '"'; + } + + $html .= ' ' . $element; + } + + return $html; + } + + /** + * Render Querystring + * + * @param string|null $asset + * @return string + */ + protected function renderQueryString($asset = null) + { + $querystring = ''; + + $asset = $asset ?? $this->asset; + $attributes = $this->attributes; + + if (!empty($this->query)) { + if (Utils::contains($asset, '?')) { + $querystring .= '&' . $this->query; + } else { + $querystring .= '?' . $this->query; + } + } + + if ($this->timestamp) { + if ($querystring || Utils::contains($asset, '?')) { + $querystring .= '&' . $this->timestamp; + } else { + $querystring .= '?' . $this->timestamp; + } + } + + return $querystring; + } +} diff --git a/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php b/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php new file mode 100644 index 0000000..99ac726 --- /dev/null +++ b/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php @@ -0,0 +1,137 @@ + null, 'pipeline' => true, 'loading' => null, 'group' => null]; + $arguments = $this->createArgumentsFromLegacy($args, $defaults); + break; + + case (Assets::INLINE_JS_TYPE): + $defaults = ['priority' => null, 'group' => null, 'attributes' => null]; + $arguments = $this->createArgumentsFromLegacy($args, $defaults); + + // special case to handle old attributes being passed in + if (isset($arguments['attributes'])) { + $old_attributes = $arguments['attributes']; + if (is_array($old_attributes)) { + $arguments = array_merge($arguments, $old_attributes); + } else { + $arguments['type'] = $old_attributes; + } + } + unset($arguments['attributes']); + + break; + + case (Assets::INLINE_CSS_TYPE): + $defaults = ['priority' => null, 'group' => null]; + $arguments = $this->createArgumentsFromLegacy($args, $defaults); + break; + + default: + case (Assets::CSS_TYPE): + $defaults = ['priority' => null, 'pipeline' => true, 'group' => null, 'loading' => null]; + $arguments = $this->createArgumentsFromLegacy($args, $defaults); + } + + return $arguments; + } + + /** + * @param array $args + * @param array $defaults + * @return array + */ + protected function createArgumentsFromLegacy(array $args, array $defaults) + { + // Remove arguments with old default values. + $arguments = []; + foreach ($args as $arg) { + $default = current($defaults); + if ($arg !== $default) { + $arguments[key($defaults)] = $arg; + } + next($defaults); + } + + return $arguments; + } + + /** + * Convenience wrapper for async loading of JavaScript + * + * @param string|array $asset + * @param int $priority + * @param bool $pipeline + * @param string $group name of the group + * @return Assets + * @deprecated Please use dynamic method with ['loading' => 'async']. + */ + public function addAsyncJs($asset, $priority = 10, $pipeline = true, $group = 'head') + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use dynamic method with [\'loading\' => \'async\']', E_USER_DEPRECATED); + + return $this->addJs($asset, $priority, $pipeline, 'async', $group); + } + + /** + * Convenience wrapper for deferred loading of JavaScript + * + * @param string|array $asset + * @param int $priority + * @param bool $pipeline + * @param string $group name of the group + * @return Assets + * @deprecated Please use dynamic method with ['loading' => 'defer']. + */ + public function addDeferJs($asset, $priority = 10, $pipeline = true, $group = 'head') + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use dynamic method with [\'loading\' => \'defer\']', E_USER_DEPRECATED); + + return $this->addJs($asset, $priority, $pipeline, 'defer', $group); + } +} diff --git a/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php b/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php new file mode 100644 index 0000000..6b264a1 --- /dev/null +++ b/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php @@ -0,0 +1,350 @@ +collections[$asset]) || isset($this->assets_css[$asset]) || isset($this->assets_js[$asset]); + } + + /** + * Return the array of all the registered collections + * + * @return array + */ + public function getCollections() + { + return $this->collections; + } + + /** + * Set the array of collections explicitly + * + * @param array $collections + * @return $this + */ + public function setCollection($collections) + { + $this->collections = $collections; + + return $this; + } + + /** + * Return the array of all the registered CSS assets + * If a $key is provided, it will try to return only that asset + * else it will return null + * + * @param string|null $key the asset key + * @return array + */ + public function getCss($key = null) + { + if (null !== $key) { + $asset_key = md5($key); + + return $this->assets_css[$asset_key] ?? null; + } + + return $this->assets_css; + } + + /** + * Return the array of all the registered JS assets + * If a $key is provided, it will try to return only that asset + * else it will return null + * + * @param string|null $key the asset key + * @return array + */ + public function getJs($key = null) + { + if (null !== $key) { + $asset_key = md5($key); + + return $this->assets_js[$asset_key] ?? null; + } + + return $this->assets_js; + } + + /** + * Set the whole array of CSS assets + * + * @param array $css + * @return $this + */ + public function setCss($css) + { + $this->assets_css = $css; + + return $this; + } + + /** + * Set the whole array of JS assets + * + * @param array $js + * @return $this + */ + public function setJs($js) + { + $this->assets_js = $js; + + return $this; + } + + /** + * Removes an item from the CSS array if set + * + * @param string $key The asset key + * @return $this + */ + public function removeCss($key) + { + $asset_key = md5($key); + if (isset($this->assets_css[$asset_key])) { + unset($this->assets_css[$asset_key]); + } + + return $this; + } + + /** + * Removes an item from the JS array if set + * + * @param string $key The asset key + * @return $this + */ + public function removeJs($key) + { + $asset_key = md5($key); + if (isset($this->assets_js[$asset_key])) { + unset($this->assets_js[$asset_key]); + } + + return $this; + } + + /** + * Sets the state of CSS Pipeline + * + * @param bool $value + * @return $this + */ + public function setCssPipeline($value) + { + $this->css_pipeline = (bool)$value; + + return $this; + } + + /** + * Sets the state of JS Pipeline + * + * @param bool $value + * @return $this + */ + public function setJsPipeline($value) + { + $this->js_pipeline = (bool)$value; + + return $this; + } + + /** + * Reset all assets. + * + * @return $this + */ + public function reset() + { + $this->resetCss(); + $this->resetJs(); + $this->setCssPipeline(false); + $this->setJsPipeline(false); + $this->order = []; + + return $this; + } + + /** + * Reset JavaScript assets. + * + * @return $this + */ + public function resetJs() + { + $this->assets_js = []; + + return $this; + } + + /** + * Reset CSS assets. + * + * @return $this + */ + public function resetCss() + { + $this->assets_css = []; + + return $this; + } + + /** + * Explicitly set's a timestamp for assets + * + * @param string|int $value + */ + public function setTimestamp($value) + { + $this->timestamp = $value; + } + + /** + * Get the timestamp for assets + * + * @param bool $include_join + * @return string|null + */ + public function getTimestamp($include_join = true) + { + if ($this->timestamp) { + return $include_join ? '?' . $this->timestamp : $this->timestamp; + } + + return null; + } + + /** + * Add all assets matching $pattern within $directory. + * + * @param string $directory Relative to the Grav root path, or a stream identifier + * @param string $pattern (regex) + * @return $this + */ + public function addDir($directory, $pattern = self::DEFAULT_REGEX) + { + $root_dir = GRAV_ROOT; + + // Check if $directory is a stream. + if (strpos($directory, '://')) { + $directory = Grav::instance()['locator']->findResource($directory, null); + } + + // Get files + $files = $this->rglob($root_dir . DIRECTORY_SEPARATOR . $directory, $pattern, $root_dir . '/'); + + // No luck? Nothing to do + if (!$files) { + return $this; + } + + // Add CSS files + if ($pattern === self::CSS_REGEX) { + foreach ($files as $file) { + $this->addCss($file); + } + + return $this; + } + + // Add JavaScript files + if ($pattern === self::JS_REGEX) { + foreach ($files as $file) { + $this->addJs($file); + } + + return $this; + } + + // Add JavaScript Module files + if ($pattern === self::JS_MODULE_REGEX) { + foreach ($files as $file) { + $this->addJsModule($file); + } + + return $this; + } + + // Unknown pattern. + foreach ($files as $asset) { + $this->add($asset); + } + + return $this; + } + + /** + * Add all JavaScript assets within $directory + * + * @param string $directory Relative to the Grav root path, or a stream identifier + * @return $this + */ + public function addDirJs($directory) + { + return $this->addDir($directory, self::JS_REGEX); + } + + /** + * Add all CSS assets within $directory + * + * @param string $directory Relative to the Grav root path, or a stream identifier + * @return $this + */ + public function addDirCss($directory) + { + return $this->addDir($directory, self::CSS_REGEX); + } + + /** + * Recursively get files matching $pattern within $directory. + * + * @param string $directory + * @param string $pattern (regex) + * @param string|null $ltrim Will be trimmed from the left of the file path + * @return array + */ + protected function rglob($directory, $pattern, $ltrim = null) + { + $iterator = new RegexIterator(new RecursiveIteratorIterator(new RecursiveDirectoryIterator( + $directory, + FilesystemIterator::SKIP_DOTS + )), $pattern); + $offset = strlen($ltrim); + $files = []; + + foreach ($iterator as $file) { + $files[] = substr($file->getPathname(), $offset); + } + + return $files; + } +} diff --git a/system/src/Grav/Common/Backup/Backups.php b/system/src/Grav/Common/Backup/Backups.php new file mode 100644 index 0000000..31fc42c --- /dev/null +++ b/system/src/Grav/Common/Backup/Backups.php @@ -0,0 +1,325 @@ +addListener('onSchedulerInitialized', [$this, 'onSchedulerInitialized']); + + $grav->fireEvent('onBackupsInitialized', new Event(['backups' => $this])); + } + + /** + * @return void + */ + public function setup() + { + if (null === static::$backup_dir) { + $grav = Grav::instance(); + static::$backup_dir = $grav['locator']->findResource('backup://', true, true); + Folder::create(static::$backup_dir); + } + } + + /** + * @param Event $event + * @return void + */ + public function onSchedulerInitialized(Event $event) + { + $grav = Grav::instance(); + + /** @var Scheduler $scheduler */ + $scheduler = $event['scheduler']; + + /** @var Inflector $inflector */ + $inflector = $grav['inflector']; + + foreach (static::getBackupProfiles() as $id => $profile) { + $at = $profile['schedule_at']; + $name = $inflector::hyphenize($profile['name']); + $logs = 'logs/backup-' . $name . '.out'; + /** @var Job $job */ + $job = $scheduler->addFunction('Grav\Common\Backup\Backups::backup', [$id], $name); + $job->at($at); + $job->output($logs); + $job->backlink('/tools/backups'); + } + } + + /** + * @param string $backup + * @param string $base_url + * @return string + */ + public function getBackupDownloadUrl($backup, $base_url) + { + $param_sep = Grav::instance()['config']->get('system.param_sep', ':'); + $download = urlencode(base64_encode(Utils::basename($backup))); + $url = rtrim(Grav::instance()['uri']->rootUrl(true), '/') . '/' . trim( + $base_url, + '/' + ) . '/task' . $param_sep . 'backup/download' . $param_sep . $download . '/admin-nonce' . $param_sep . Utils::getNonce('admin-form'); + + return $url; + } + + /** + * @return array + */ + public static function getBackupProfiles() + { + return Grav::instance()['config']->get('backups.profiles'); + } + + /** + * @return array + */ + public static function getPurgeConfig() + { + return Grav::instance()['config']->get('backups.purge'); + } + + /** + * @return array + */ + public function getBackupNames() + { + return array_column(static::getBackupProfiles(), 'name'); + } + + /** + * @return float|int + */ + public static function getTotalBackupsSize() + { + $backups = static::getAvailableBackups(); + + return $backups ? array_sum(array_column($backups, 'size')) : 0; + } + + /** + * @param bool $force + * @return array + */ + public static function getAvailableBackups($force = false) + { + if ($force || null === static::$backups) { + static::$backups = []; + + $grav = Grav::instance(); + $backups_itr = new GlobIterator(static::$backup_dir . '/*.zip', FilesystemIterator::KEY_AS_FILENAME); + $inflector = $grav['inflector']; + $long_date_format = DATE_RFC2822; + + /** + * @var string $name + * @var SplFileInfo $file + */ + foreach ($backups_itr as $name => $file) { + if (preg_match(static::BACKUP_FILENAME_REGEXZ, $name, $matches)) { + $date = DateTime::createFromFormat(static::BACKUP_DATE_FORMAT, $matches[2]); + $timestamp = $date->getTimestamp(); + $backup = new stdClass(); + $backup->title = $inflector->titleize($matches[1]); + $backup->time = $date; + $backup->date = $date->format($long_date_format); + $backup->filename = $name; + $backup->path = $file->getPathname(); + $backup->size = $file->getSize(); + static::$backups[$timestamp] = $backup; + } + } + // Reverse Key Sort to get in reverse date order + krsort(static::$backups); + } + + return static::$backups; + } + + /** + * Backup + * + * @param int $id + * @param callable|null $status + * @return string|null + */ + public static function backup($id = 0, callable $status = null) + { + $grav = Grav::instance(); + + $profiles = static::getBackupProfiles(); + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + if (isset($profiles[$id])) { + $backup = (object) $profiles[$id]; + } else { + throw new RuntimeException('No backups defined...'); + } + + $name = $grav['inflector']->underscorize($backup->name); + $date = date(static::BACKUP_DATE_FORMAT, time()); + $filename = trim($name, '_') . '--' . $date . '.zip'; + $destination = static::$backup_dir . DS . $filename; + $max_execution_time = ini_set('max_execution_time', '600'); + $backup_root = $backup->root; + + if ($locator->isStream($backup_root)) { + $backup_root = $locator->findResource($backup_root); + } else { + $backup_root = rtrim(GRAV_ROOT . $backup_root, DS) ?: DS; + } + + if (!$backup_root || !file_exists($backup_root)) { + throw new RuntimeException("Backup location: {$backup_root} does not exist..."); + } + + $options = [ + 'exclude_files' => static::convertExclude($backup->exclude_files ?? ''), + 'exclude_paths' => static::convertExclude($backup->exclude_paths ?? ''), + ]; + + $archiver = Archiver::create('zip'); + $archiver->setArchive($destination)->setOptions($options)->compress($backup_root, $status)->addEmptyFolders($options['exclude_paths'], $status); + + $status && $status([ + 'type' => 'message', + 'message' => 'Done...', + ]); + + $status && $status([ + 'type' => 'progress', + 'complete' => true + ]); + + if ($max_execution_time !== false) { + ini_set('max_execution_time', $max_execution_time); + } + + // Log the backup + $grav['log']->notice('Backup Created: ' . $destination); + + // Fire Finished event + $grav->fireEvent('onBackupFinished', new Event(['backup' => $destination])); + + // Purge anything required + static::purge(); + + // Log + $log = JsonFile::instance($locator->findResource("log://backup.log", true, true)); + $log->content([ + 'time' => time(), + 'location' => $destination + ]); + $log->save(); + + return $destination; + } + + /** + * @return void + * @throws Exception + */ + public static function purge() + { + $purge_config = static::getPurgeConfig(); + $trigger = $purge_config['trigger']; + $backups = static::getAvailableBackups(true); + + switch ($trigger) { + case 'number': + $backups_count = count($backups); + if ($backups_count > $purge_config['max_backups_count']) { + $last = end($backups); + unlink($last->path); + static::purge(); + } + break; + + case 'time': + $last = end($backups); + $now = new DateTime(); + $interval = $now->diff($last->time); + if ($interval->days > $purge_config['max_backups_time']) { + unlink($last->path); + static::purge(); + } + break; + + default: + $used_space = static::getTotalBackupsSize(); + $max_space = $purge_config['max_backups_space'] * 1024 * 1024 * 1024; + if ($used_space > $max_space) { + $last = end($backups); + unlink($last->path); + static::purge(); + } + break; + } + } + + /** + * @param string $exclude + * @return array + */ + protected static function convertExclude($exclude) + { + // Split by newlines, commas, or multiple spaces + $lines = preg_split("/[\r\n,]+|[\s]{2,}/", $exclude); + // Remove empty values and trim + $lines = array_filter(array_map('trim', $lines)); + + return array_map('trim', $lines, array_fill(0, count($lines), '/')); + } +} diff --git a/system/src/Grav/Common/Browser.php b/system/src/Grav/Common/Browser.php new file mode 100644 index 0000000..c383865 --- /dev/null +++ b/system/src/Grav/Common/Browser.php @@ -0,0 +1,153 @@ +useragent = parse_user_agent(); + } catch (InvalidArgumentException $e) { + $this->useragent = parse_user_agent("Mozilla/5.0 (compatible; Unknown;)"); + } + } + + /** + * Get the current browser identifier + * + * Currently detected browsers: + * + * Android Browser + * BlackBerry Browser + * Camino + * Kindle / Silk + * Firefox / Iceweasel + * Safari + * Internet Explorer + * IEMobile + * Chrome + * Opera + * Midori + * Vivaldi + * TizenBrowser + * Lynx + * Wget + * Curl + * + * @return string the lowercase browser name + */ + public function getBrowser() + { + return strtolower($this->useragent['browser']); + } + + /** + * Get the current platform identifier + * + * Currently detected platforms: + * + * Desktop + * -> Windows + * -> Linux + * -> Macintosh + * -> Chrome OS + * Mobile + * -> Android + * -> iPhone + * -> iPad / iPod Touch + * -> Windows Phone OS + * -> Kindle + * -> Kindle Fire + * -> BlackBerry + * -> Playbook + * -> Tizen + * Console + * -> Nintendo 3DS + * -> New Nintendo 3DS + * -> Nintendo Wii + * -> Nintendo WiiU + * -> PlayStation 3 + * -> PlayStation 4 + * -> PlayStation Vita + * -> Xbox 360 + * -> Xbox One + * + * @return string the lowercase platform name + */ + public function getPlatform() + { + return strtolower($this->useragent['platform']); + } + + /** + * Get the current full version identifier + * + * @return string the browser full version identifier + */ + public function getLongVersion() + { + return $this->useragent['version']; + } + + /** + * Get the current major version identifier + * + * @return int the browser major version identifier + */ + public function getVersion() + { + $version = explode('.', $this->getLongVersion()); + + return (int)$version[0]; + } + + /** + * Determine if the request comes from a human, or from a bot/crawler + * + * @return bool + */ + public function isHuman() + { + $browser = $this->getBrowser(); + if (empty($browser)) { + return false; + } + + if (preg_match('~(bot|crawl)~i', $browser)) { + return false; + } + + return true; + } + + /** + * Determine if “Do Not Track” is set by browser + * @see https://www.w3.org/TR/tracking-dnt/ + * + * @return bool + */ + public function isTrackable(): bool + { + return !(isset($_SERVER['HTTP_DNT']) && $_SERVER['HTTP_DNT'] === '1'); + } +} diff --git a/system/src/Grav/Common/Cache.php b/system/src/Grav/Common/Cache.php new file mode 100644 index 0000000..c7afcbe --- /dev/null +++ b/system/src/Grav/Common/Cache.php @@ -0,0 +1,743 @@ +init($grav); + } + + /** + * Initialization that sets a base key and the driver based on configuration settings + * + * @param Grav $grav + * @return void + */ + public function init(Grav $grav) + { + $this->config = $grav['config']; + $this->now = time(); + + if (null === $this->enabled) { + $this->enabled = (bool)$this->config->get('system.cache.enabled'); + } + + /** @var Uri $uri */ + $uri = $grav['uri']; + + $prefix = $this->config->get('system.cache.prefix'); + $uniqueness = substr(md5($uri->rootUrl(true) . $this->config->key() . GRAV_VERSION), 2, 8); + + // Cache key allows us to invalidate all cache on configuration changes. + $this->key = ($prefix ?: 'g') . '-' . $uniqueness; + $this->cache_dir = $grav['locator']->findResource('cache://doctrine/' . $uniqueness, true, true); + $this->driver_setting = $this->config->get('system.cache.driver'); + $this->driver = $this->getCacheDriver(); + $this->driver->setNamespace($this->key); + + /** @var EventDispatcher $dispatcher */ + $dispatcher = Grav::instance()['events']; + $dispatcher->addListener('onSchedulerInitialized', [$this, 'onSchedulerInitialized']); + } + + /** + * @return CacheInterface + */ + public function getSimpleCache() + { + if (null === $this->simpleCache) { + $cache = new \Grav\Framework\Cache\Adapter\DoctrineCache($this->driver, '', $this->getLifetime()); + + // Disable cache key validation. + $cache->setValidation(false); + + $this->simpleCache = $cache; + } + + return $this->simpleCache; + } + + /** + * Deletes old cache files based on age + * + * @return int + */ + public function purgeOldCache() + { + // Get the max age for cache files from config (default 30 days) + $max_age_days = $this->config->get('system.cache.purge_max_age_days', 30); + $max_age_seconds = $max_age_days * 86400; // Convert days to seconds + $now = time(); + $count = 0; + + // First, clean up old orphaned cache directories (not the current one) + $cache_dir = dirname($this->cache_dir); + $current = Utils::basename($this->cache_dir); + + foreach (new DirectoryIterator($cache_dir) as $file) { + $dir = $file->getBasename(); + if ($dir === $current || $file->isDot() || $file->isFile()) { + continue; + } + + // Check if directory is old and empty or very old (90+ days) + $dir_age = $now - $file->getMTime(); + if ($dir_age > 7776000) { // 90 days + Folder::delete($file->getPathname()); + $count++; + } + } + + // Now clean up old cache files within the current cache directory + if (is_dir($this->cache_dir)) { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->cache_dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $file_age = $now - $file->getMTime(); + if ($file_age > $max_age_seconds) { + @unlink($file->getPathname()); + $count++; + } + } + } + } + + // Also clean up old files in compiled cache + $grav = Grav::instance(); + $compiled_dir = $this->config->get('system.cache.compiled_dir', 'cache://compiled'); + $compiled_path = $grav['locator']->findResource($compiled_dir, true); + + if ($compiled_path && is_dir($compiled_path)) { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($compiled_path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $file_age = $now - $file->getMTime(); + // Compiled files can be kept longer (60 days) + if ($file_age > ($max_age_seconds * 2)) { + @unlink($file->getPathname()); + $count++; + } + } + } + } + + return $count; + } + + /** + * Public accessor to set the enabled state of the cache + * + * @param bool|int $enabled + * @return void + */ + public function setEnabled($enabled) + { + $this->enabled = (bool)$enabled; + } + + /** + * Returns the current enabled state + * + * @return bool + */ + public function getEnabled() + { + return $this->enabled; + } + + /** + * Get cache state + * + * @return string + */ + public function getCacheStatus() + { + return 'Cache: [' . ($this->enabled ? 'true' : 'false') . '] Setting: [' . $this->driver_setting . '] Driver: [' . $this->driver_name . ']'; + } + + /** + * Automatically picks the cache mechanism to use. If you pick one manually it will use that + * If there is no config option for $driver in the config, or it's set to 'auto', it will + * pick the best option based on which cache extensions are installed. + * + * @return DoctrineCache\CacheProvider The cache driver to use + */ + public function getCacheDriver() + { + $setting = $this->driver_setting; + $driver_name = 'file'; + + // CLI compatibility requires a non-volatile cache driver + if ($this->config->get('system.cache.cli_compatibility') && ( + $setting === 'auto' || $this->isVolatileDriver($setting))) { + $setting = $driver_name; + } + + if (!$setting || $setting === 'auto') { + if (extension_loaded('apcu')) { + $driver_name = 'apcu'; + } elseif (extension_loaded('wincache')) { + $driver_name = 'wincache'; + } + } else { + $driver_name = $setting; + } + + $this->driver_name = $driver_name; + + switch ($driver_name) { + case 'apc': + case 'apcu': + $driver = new DoctrineCache\ApcuCache(); + break; + + case 'wincache': + $driver = new DoctrineCache\WinCacheCache(); + break; + + case 'memcache': + if (extension_loaded('memcache')) { + $memcache = new \Memcache(); + $memcache->connect( + $this->config->get('system.cache.memcache.server', 'localhost'), + $this->config->get('system.cache.memcache.port', 11211) + ); + $driver = new DoctrineCache\MemcacheCache(); + $driver->setMemcache($memcache); + } else { + throw new LogicException('Memcache PHP extension has not been installed'); + } + break; + + case 'memcached': + if (extension_loaded('memcached')) { + $memcached = new \Memcached(); + $memcached->addServer( + $this->config->get('system.cache.memcached.server', 'localhost'), + $this->config->get('system.cache.memcached.port', 11211) + ); + $driver = new DoctrineCache\MemcachedCache(); + $driver->setMemcached($memcached); + } else { + throw new LogicException('Memcached PHP extension has not been installed'); + } + break; + + case 'redis': + if (extension_loaded('redis')) { + $redis = new \Redis(); + $socket = $this->config->get('system.cache.redis.socket', false); + $password = $this->config->get('system.cache.redis.password', false); + $databaseId = $this->config->get('system.cache.redis.database', 0); + + if ($socket) { + $redis->connect($socket); + } else { + $redis->connect( + $this->config->get('system.cache.redis.server', 'localhost'), + $this->config->get('system.cache.redis.port', 6379) + ); + } + + // Authenticate with password if set + if ($password && !$redis->auth($password)) { + throw new \RedisException('Redis authentication failed'); + } + + // Select alternate ( !=0 ) database ID if set + if ($databaseId && !$redis->select($databaseId)) { + throw new \RedisException('Could not select alternate Redis database ID'); + } + + $driver = new DoctrineCache\RedisCache(); + $driver->setRedis($redis); + } else { + throw new LogicException('Redis PHP extension has not been installed'); + } + break; + + default: + $driver = new DoctrineCache\FilesystemCache($this->cache_dir); + break; + } + + return $driver; + } + + /** + * Gets a cached entry if it exists based on an id. If it does not exist, it returns false + * + * @param string $id the id of the cached entry + * @return mixed|bool returns the cached entry, can be any type, or false if doesn't exist + */ + public function fetch($id) + { + if ($this->enabled) { + return $this->driver->fetch($id); + } + + return false; + } + + /** + * Stores a new cached entry. + * + * @param string $id the id of the cached entry + * @param array|object|int $data the data for the cached entry to store + * @param int|null $lifetime the lifetime to store the entry in seconds + */ + public function save($id, $data, $lifetime = null) + { + if ($this->enabled) { + if ($lifetime === null) { + $lifetime = $this->getLifetime(); + } + $this->driver->save($id, $data, $lifetime); + } + } + + /** + * Deletes an item in the cache based on the id + * + * @param string $id the id of the cached data entry + * @return bool true if the item was deleted successfully + */ + public function delete($id) + { + if ($this->enabled) { + return $this->driver->delete($id); + } + + return false; + } + + /** + * Deletes all cache + * + * @return bool + */ + public function deleteAll() + { + if ($this->enabled) { + return $this->driver->deleteAll(); + } + + return false; + } + + /** + * Returns a boolean state of whether or not the item exists in the cache based on id key + * + * @param string $id the id of the cached data entry + * @return bool true if the cached items exists + */ + public function contains($id) + { + if ($this->enabled) { + return $this->driver->contains(($id)); + } + + return false; + } + + /** + * Getter method to get the cache key + * + * @return string + */ + public function getKey() + { + return $this->key; + } + + /** + * Setter method to set key (Advanced) + * + * @param string $key + * @return void + */ + public function setKey($key) + { + $this->key = $key; + $this->driver->setNamespace($this->key); + } + + /** + * Helper method to clear all Grav caches + * + * @param string $remove standard|all|assets-only|images-only|cache-only + * @return array + */ + public static function clearCache($remove = 'standard') + { + $locator = Grav::instance()['locator']; + $output = []; + $user_config = USER_DIR . 'config/system.yaml'; + + switch ($remove) { + case 'all': + $remove_paths = self::$all_remove; + break; + case 'assets-only': + $remove_paths = self::$assets_remove; + break; + case 'images-only': + $remove_paths = self::$images_remove; + break; + case 'cache-only': + $remove_paths = self::$cache_remove; + break; + case 'tmp-only': + $remove_paths = self::$tmp_remove; + break; + case 'invalidate': + $remove_paths = []; + break; + default: + if (Grav::instance()['config']->get('system.cache.clear_images_by_default')) { + $remove_paths = self::$standard_remove; + } else { + $remove_paths = self::$standard_remove_no_images; + } + } + + // Delete entries in the doctrine cache if required + if (in_array($remove, ['all', 'standard'])) { + $cache = Grav::instance()['cache']; + $cache->driver->deleteAll(); + } + + // Clearing cache event to add paths to clear + Grav::instance()->fireEvent('onBeforeCacheClear', new Event(['remove' => $remove, 'paths' => &$remove_paths])); + + foreach ($remove_paths as $stream) { + // Convert stream to a real path + try { + $path = $locator->findResource($stream, true, true); + if ($path === false) { + continue; + } + + $anything = false; + $files = glob($path . '/*'); + + if (is_array($files)) { + foreach ($files as $file) { + if (is_link($file)) { + $output[] = 'Skipping symlink: ' . $file; + } elseif (is_file($file)) { + if (@unlink($file)) { + $anything = true; + } + } elseif (is_dir($file)) { + if (Folder::delete($file, false)) { + $anything = true; + } + } + } + } + + if ($anything) { + $output[] = 'Cleared: ' . $path . '/*'; + } + } catch (Exception $e) { + // stream not found or another error while deleting files. + $output[] = 'ERROR: ' . $e->getMessage(); + } + } + + $output[] = ''; + + if (($remove === 'all' || $remove === 'standard') && file_exists($user_config)) { + touch($user_config); + + $output[] = 'Touched: ' . $user_config; + $output[] = ''; + } + + // Clear stat cache + @clearstatcache(); + + // Clear opcache + if (function_exists('opcache_reset')) { + @opcache_reset(); + } + + Grav::instance()->fireEvent('onAfterCacheClear', new Event(['remove' => $remove, 'output' => &$output])); + + return $output; + } + + /** + * @return void + */ + public static function invalidateCache() + { + $user_config = USER_DIR . 'config/system.yaml'; + + if (file_exists($user_config)) { + touch($user_config); + } + + // Clear stat cache + @clearstatcache(); + + // Clear opcache + if (function_exists('opcache_reset')) { + @opcache_reset(); + } + } + + /** + * Set the cache lifetime programmatically + * + * @param int $future timestamp + * @return void + */ + public function setLifetime($future) + { + if (!$future) { + return; + } + + $interval = (int)($future - $this->now); + if ($interval > 0 && $interval < $this->getLifetime()) { + $this->lifetime = $interval; + } + } + + + /** + * Retrieve the cache lifetime (in seconds) + * + * @return int + */ + public function getLifetime() + { + if ($this->lifetime === null) { + $this->lifetime = (int)($this->config->get('system.cache.lifetime') ?: 604800); // 1 week default + } + + return $this->lifetime; + } + + /** + * Returns the current driver name + * + * @return string + */ + public function getDriverName() + { + return $this->driver_name; + } + + /** + * Returns the current driver setting + * + * @return string + */ + public function getDriverSetting() + { + return $this->driver_setting; + } + + /** + * is this driver a volatile driver in that it resides in PHP process memory + * + * @param string $setting + * @return bool + */ + public function isVolatileDriver($setting) + { + return in_array($setting, ['apc', 'apcu', 'xcache', 'wincache'], true); + } + + /** + * Static function to call as a scheduled Job to purge old Doctrine files + * + * @param bool $echo + * + * @return string|void + */ + public static function purgeJob($echo = false) + { + /** @var Cache $cache */ + $cache = Grav::instance()['cache']; + $deleted_items = $cache->purgeOldCache(); + + $max_age = $cache->config->get('system.cache.purge_max_age_days', 30); + $msg = 'Purged ' . $deleted_items . ' old cache items (files older than ' . $max_age . ' days)'; + + if ($echo) { + echo $msg; + } else { + return $msg; + } + } + + /** + * Static function to call as a scheduled Job to clear Grav cache + * + * @param string $type + * @return void + */ + public static function clearJob($type) + { + $result = static::clearCache($type); + static::invalidateCache(); + + echo strip_tags(implode("\n", $result)); + } + + /** + * @param Event $event + * @return void + */ + public function onSchedulerInitialized(Event $event) + { + /** @var Scheduler $scheduler */ + $scheduler = $event['scheduler']; + $config = Grav::instance()['config']; + + // File Cache Purge + $at = $config->get('system.cache.purge_at'); + $name = 'cache-purge'; + $logs = 'logs/' . $name . '.out'; + + $job = $scheduler->addFunction('Grav\Common\Cache::purgeJob', [true], $name); + $job->at($at); + $job->output($logs); + $job->backlink('/config/system#caching'); + + // Cache Clear + $at = $config->get('system.cache.clear_at'); + $clear_type = $config->get('system.cache.clear_job_type'); + $name = 'cache-clear'; + $logs = 'logs/' . $name . '.out'; + + $job = $scheduler->addFunction('Grav\Common\Cache::clearJob', [$clear_type], $name); + $job->at($at); + $job->output($logs); + $job->backlink('/config/system#caching'); + } +} diff --git a/system/src/Grav/Common/Composer.php b/system/src/Grav/Common/Composer.php new file mode 100644 index 0000000..f47f1c6 --- /dev/null +++ b/system/src/Grav/Common/Composer.php @@ -0,0 +1,67 @@ +path = $path ? rtrim($path, '\\/') . '/' : ''; + $this->cacheFolder = $cacheFolder; + $this->files = $files; + } + + /** + * Get filename for the compiled PHP file. + * + * @param string|null $name + * @return $this + */ + public function name($name = null) + { + if (!$this->name) { + $this->name = $name ?: md5(json_encode(array_keys($this->files))); + } + + return $this; + } + + /** + * Function gets called when cached configuration is saved. + * + * @return void + */ + public function modified() + { + } + + /** + * Get timestamp of compiled configuration + * + * @return int Timestamp of compiled configuration + */ + public function timestamp() + { + return $this->timestamp ?: time(); + } + + /** + * Load the configuration. + * + * @return mixed + */ + public function load() + { + if ($this->object) { + return $this->object; + } + + $filename = $this->createFilename(); + if (!$this->loadCompiledFile($filename) && $this->loadFiles()) { + $this->saveCompiledFile($filename); + } + + return $this->object; + } + + /** + * Returns checksum from the configuration files. + * + * You can set $this->checksum = false to disable this check. + * + * @return bool|string + */ + public function checksum() + { + if (null === $this->checksum) { + $this->checksum = md5(json_encode($this->files) . $this->version); + } + + return $this->checksum; + } + + /** + * @return string + */ + protected function createFilename() + { + return "{$this->cacheFolder}/{$this->name()->name}.php"; + } + + /** + * Create configuration object. + * + * @param array $data + * @return void + */ + abstract protected function createObject(array $data = []); + + /** + * Finalize configuration object. + * + * @return void + */ + abstract protected function finalizeObject(); + + /** + * Load single configuration file and append it to the correct position. + * + * @param string $name Name of the position. + * @param string|string[] $filename File(s) to be loaded. + * @return void + */ + abstract protected function loadFile($name, $filename); + + /** + * Load and join all configuration files. + * + * @return bool + * @internal + */ + protected function loadFiles() + { + $this->createObject(); + + $list = array_reverse($this->files); + foreach ($list as $files) { + foreach ($files as $name => $item) { + $this->loadFile($name, $this->path . $item['file']); + } + } + + $this->finalizeObject(); + + return true; + } + + /** + * Load compiled file. + * + * @param string $filename + * @return bool + * @internal + */ + protected function loadCompiledFile($filename) + { + if (!file_exists($filename)) { + return false; + } + + $cache = include $filename; + if (!is_array($cache) + || !isset($cache['checksum'], $cache['data'], $cache['@class']) + || $cache['@class'] !== get_class($this) + ) { + return false; + } + + // Load real file if cache isn't up to date (or is invalid). + if ($cache['checksum'] !== $this->checksum()) { + return false; + } + + $this->createObject($cache['data']); + $this->timestamp = $cache['timestamp'] ?? 0; + + $this->finalizeObject(); + + return true; + } + + /** + * Save compiled file. + * + * @param string $filename + * @return void + * @throws RuntimeException + * @internal + */ + protected function saveCompiledFile($filename) + { + $file = PhpFile::instance($filename); + + // Attempt to lock the file for writing. + try { + $file->lock(false); + } catch (Exception $e) { + // Another process has locked the file; we will check this in a bit. + } + + if ($file->locked() === false) { + // File was already locked by another process. + return; + } + + $cache = [ + '@class' => get_class($this), + 'timestamp' => time(), + 'checksum' => $this->checksum(), + 'files' => $this->files, + 'data' => $this->getState() + ]; + + $file->save($cache); + $file->unlock(); + $file->free(); + + $this->modified(); + } + + /** + * @return array + */ + protected function getState() + { + return $this->object->toArray(); + } +} diff --git a/system/src/Grav/Common/Config/CompiledBlueprints.php b/system/src/Grav/Common/Config/CompiledBlueprints.php new file mode 100644 index 0000000..d142b4f --- /dev/null +++ b/system/src/Grav/Common/Config/CompiledBlueprints.php @@ -0,0 +1,131 @@ +version = 2; + } + + /** + * Returns checksum from the configuration files. + * + * You can set $this->checksum = false to disable this check. + * + * @return bool|string + */ + public function checksum() + { + if (null === $this->checksum) { + $this->checksum = md5(json_encode($this->files) . json_encode($this->getTypes()) . $this->version); + } + + return $this->checksum; + } + + /** + * Create configuration object. + * + * @param array $data + */ + protected function createObject(array $data = []) + { + $this->object = (new BlueprintSchema($data))->setTypes($this->getTypes()); + } + + /** + * Get list of form field types. + * + * @return array + */ + protected function getTypes() + { + return Grav::instance()['plugins']->formFieldTypes ?: []; + } + + /** + * Finalize configuration object. + * + * @return void + */ + protected function finalizeObject() + { + } + + /** + * Load single configuration file and append it to the correct position. + * + * @param string $name Name of the position. + * @param array $files Files to be loaded. + * @return void + */ + protected function loadFile($name, $files) + { + // Load blueprint file. + $blueprint = new Blueprint($files); + + $this->object->embed($name, $blueprint->load()->toArray(), '/', true); + } + + /** + * Load and join all configuration files. + * + * @return bool + * @internal + */ + protected function loadFiles() + { + $this->createObject(); + + // Convert file list into parent list. + $list = []; + /** @var array $files */ + foreach ($this->files as $files) { + foreach ($files as $name => $item) { + $list[$name][] = $this->path . $item['file']; + } + } + + // Load files. + foreach ($list as $name => $files) { + $this->loadFile($name, $files); + } + + $this->finalizeObject(); + + return true; + } + + /** + * @return array + */ + protected function getState() + { + return $this->object->getState(); + } +} diff --git a/system/src/Grav/Common/Config/CompiledConfig.php b/system/src/Grav/Common/Config/CompiledConfig.php new file mode 100644 index 0000000..1c9af6e --- /dev/null +++ b/system/src/Grav/Common/Config/CompiledConfig.php @@ -0,0 +1,114 @@ +version = 1; + } + + /** + * Set blueprints for the configuration. + * + * @param callable $blueprints + * @return $this + */ + public function setBlueprints(callable $blueprints) + { + $this->callable = $blueprints; + + return $this; + } + + /** + * @param bool $withDefaults + * @return mixed + */ + public function load($withDefaults = false) + { + $this->withDefaults = $withDefaults; + + return parent::load(); + } + + /** + * Create configuration object. + * + * @param array $data + * @return void + */ + protected function createObject(array $data = []) + { + if ($this->withDefaults && empty($data) && is_callable($this->callable)) { + $blueprints = $this->callable; + $data = $blueprints()->getDefaults(); + } + + $this->object = new Config($data, $this->callable); + } + + /** + * Finalize configuration object. + * + * @return void + */ + protected function finalizeObject() + { + $this->object->checksum($this->checksum()); + $this->object->timestamp($this->timestamp()); + } + + /** + * Function gets called when cached configuration is saved. + * + * @return void + */ + public function modified() + { + $this->object->modified(true); + } + + /** + * Load single configuration file and append it to the correct position. + * + * @param string $name Name of the position. + * @param string $filename File to be loaded. + * @return void + */ + protected function loadFile($name, $filename) + { + $file = CompiledYamlFile::instance($filename); + $this->object->join($name, $file->content(), '/'); + $file->free(); + } +} diff --git a/system/src/Grav/Common/Config/CompiledLanguages.php b/system/src/Grav/Common/Config/CompiledLanguages.php new file mode 100644 index 0000000..e7df547 --- /dev/null +++ b/system/src/Grav/Common/Config/CompiledLanguages.php @@ -0,0 +1,83 @@ +version = 1; + } + + /** + * Create configuration object. + * + * @param array $data + * @return void + */ + protected function createObject(array $data = []) + { + $this->object = new Languages($data); + } + + /** + * Finalize configuration object. + * + * @return void + */ + protected function finalizeObject() + { + $this->object->checksum($this->checksum()); + $this->object->timestamp($this->timestamp()); + } + + + /** + * Function gets called when cached configuration is saved. + * + * @return void + */ + public function modified() + { + $this->object->modified(true); + } + + /** + * Load single configuration file and append it to the correct position. + * + * @param string $name Name of the position. + * @param string $filename File to be loaded. + * @return void + */ + protected function loadFile($name, $filename) + { + $file = CompiledYamlFile::instance($filename); + if (preg_match('|languages\.yaml$|', $filename)) { + $this->object->mergeRecursive((array) $file->content()); + } else { + $this->object->mergeRecursive([$name => $file->content()]); + } + $file->free(); + } +} diff --git a/system/src/Grav/Common/Config/Config.php b/system/src/Grav/Common/Config/Config.php new file mode 100644 index 0000000..566d7c9 --- /dev/null +++ b/system/src/Grav/Common/Config/Config.php @@ -0,0 +1,156 @@ +key) { + $this->key = md5($this->checksum . $this->timestamp); + } + + return $this->key; + } + + /** + * @param string|null $checksum + * @return string|null + */ + public function checksum($checksum = null) + { + if ($checksum !== null) { + $this->checksum = $checksum; + } + + return $this->checksum; + } + + /** + * @param bool|null $modified + * @return bool + */ + public function modified($modified = null) + { + if ($modified !== null) { + $this->modified = $modified; + } + + return $this->modified; + } + + /** + * @param int|null $timestamp + * @return int + */ + public function timestamp($timestamp = null) + { + if ($timestamp !== null) { + $this->timestamp = $timestamp; + } + + return $this->timestamp; + } + + /** + * @return $this + */ + public function reload() + { + $grav = Grav::instance(); + + // Load new configuration. + $config = ConfigServiceProvider::load($grav); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + + if ($config->modified()) { + // Update current configuration. + $this->items = $config->toArray(); + $this->checksum($config->checksum()); + $this->modified(true); + + $debugger->addMessage('Configuration was changed and saved.'); + } + + return $this; + } + + /** + * @return void + */ + public function debug() + { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + + $debugger->addMessage('Environment Name: ' . $this->environment); + if ($this->modified()) { + $debugger->addMessage('Configuration reloaded and cached.'); + } + } + + /** + * @return void + */ + public function init() + { + $setup = Grav::instance()['setup']->toArray(); + foreach ($setup as $key => $value) { + if ($key === 'streams' || !is_array($value)) { + // Optimized as streams and simple values are fully defined in setup. + $this->items[$key] = $value; + } else { + $this->joinDefaults($key, $value); + } + } + + // Legacy value - Override the media.upload_limit based on PHP values + $this->items['system']['media']['upload_limit'] = Utils::getUploadLimit(); + } + + /** + * @return mixed + * @deprecated 1.5 Use Grav::instance()['languages'] instead. + */ + public function getLanguages() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use Grav::instance()[\'languages\'] instead', E_USER_DEPRECATED); + + return Grav::instance()['languages']; + } +} diff --git a/system/src/Grav/Common/Config/ConfigFileFinder.php b/system/src/Grav/Common/Config/ConfigFileFinder.php new file mode 100644 index 0000000..773022d --- /dev/null +++ b/system/src/Grav/Common/Config/ConfigFileFinder.php @@ -0,0 +1,273 @@ +base = $base ? "{$base}/" : ''; + + return $this; + } + + /** + * Return all locations for all the files with a timestamp. + * + * @param array $paths List of folders to look from. + * @param string $pattern Pattern to match the file. Pattern will also be removed from the key. + * @param int $levels Maximum number of recursive directories. + * @return array + */ + public function locateFiles(array $paths, $pattern = '|\.yaml$|', $levels = -1) + { + $list = []; + foreach ($paths as $folder) { + $list += $this->detectRecursive($folder, $pattern, $levels); + } + + return $list; + } + + /** + * Return all locations for all the files with a timestamp. + * + * @param array $paths List of folders to look from. + * @param string $pattern Pattern to match the file. Pattern will also be removed from the key. + * @param int $levels Maximum number of recursive directories. + * @return array + */ + public function getFiles(array $paths, $pattern = '|\.yaml$|', $levels = -1) + { + $list = []; + foreach ($paths as $folder) { + $path = trim(Folder::getRelativePath($folder), '/'); + + $files = $this->detectRecursive($folder, $pattern, $levels); + + $list += $files[trim($path, '/')]; + } + + return $list; + } + + /** + * Return all paths for all the files with a timestamp. + * + * @param array $paths List of folders to look from. + * @param string $pattern Pattern to match the file. Pattern will also be removed from the key. + * @param int $levels Maximum number of recursive directories. + * @return array + */ + public function listFiles(array $paths, $pattern = '|\.yaml$|', $levels = -1) + { + $list = []; + foreach ($paths as $folder) { + $list = array_merge_recursive($list, $this->detectAll($folder, $pattern, $levels)); + } + + return $list; + } + + /** + * Find filename from a list of folders. + * + * Note: Only finds the last override. + * + * @param string $filename + * @param array $folders + * @return array + */ + public function locateFileInFolder($filename, array $folders) + { + $list = []; + foreach ($folders as $folder) { + $list += $this->detectInFolder($folder, $filename); + } + + return $list; + } + + /** + * Find filename from a list of folders. + * + * @param array $folders + * @param string|null $filename + * @return array + */ + public function locateInFolders(array $folders, $filename = null) + { + $list = []; + foreach ($folders as $folder) { + $path = trim(Folder::getRelativePath($folder), '/'); + $list[$path] = $this->detectInFolder($folder, $filename); + } + + return $list; + } + + /** + * Return all existing locations for a single file with a timestamp. + * + * @param array $paths Filesystem paths to look up from. + * @param string $name Configuration file to be located. + * @param string $ext File extension (optional, defaults to .yaml). + * @return array + */ + public function locateFile(array $paths, $name, $ext = '.yaml') + { + $filename = preg_replace('|[.\/]+|', '/', $name) . $ext; + + $list = []; + foreach ($paths as $folder) { + $path = trim(Folder::getRelativePath($folder), '/'); + + if (is_file("{$folder}/{$filename}")) { + $modified = filemtime("{$folder}/{$filename}"); + } else { + $modified = 0; + } + $basename = $this->base . $name; + $list[$path] = [$basename => ['file' => "{$path}/{$filename}", 'modified' => $modified]]; + } + + return $list; + } + + /** + * Detects all directories with a configuration file and returns them with last modification time. + * + * @param string $folder Location to look up from. + * @param string $pattern Pattern to match the file. Pattern will also be removed from the key. + * @param int $levels Maximum number of recursive directories. + * @return array + * @internal + */ + protected function detectRecursive($folder, $pattern, $levels) + { + $path = trim(Folder::getRelativePath($folder), '/'); + + if (is_dir($folder)) { + // Find all system and user configuration files. + $options = [ + 'levels' => $levels, + 'compare' => 'Filename', + 'pattern' => $pattern, + 'filters' => [ + 'pre-key' => $this->base, + 'key' => $pattern, + 'value' => function (RecursiveDirectoryIterator $file) use ($path) { + return ['file' => "{$path}/{$file->getSubPathname()}", 'modified' => $file->getMTime()]; + } + ], + 'key' => 'SubPathname' + ]; + + $list = Folder::all($folder, $options); + + ksort($list); + } else { + $list = []; + } + + return [$path => $list]; + } + + /** + * Detects all directories with the lookup file and returns them with last modification time. + * + * @param string $folder Location to look up from. + * @param string|null $lookup Filename to be located (defaults to directory name). + * @return array + * @internal + */ + protected function detectInFolder($folder, $lookup = null) + { + $folder = rtrim($folder, '/'); + $path = trim(Folder::getRelativePath($folder), '/'); + $base = $path === $folder ? '' : ($path ? substr($folder, 0, -strlen($path)) : $folder . '/'); + + $list = []; + + if (is_dir($folder)) { + $iterator = new DirectoryIterator($folder); + foreach ($iterator as $directory) { + if (!$directory->isDir() || $directory->isDot()) { + continue; + } + + $name = $directory->getFilename(); + $find = ($lookup ?: $name) . '.yaml'; + $filename = "{$path}/{$name}/{$find}"; + + if (file_exists($base . $filename)) { + $basename = $this->base . $name; + $list[$basename] = ['file' => $filename, 'modified' => filemtime($base . $filename)]; + } + } + } + + return $list; + } + + /** + * Detects all plugins with a configuration file and returns them with last modification time. + * + * @param string $folder Location to look up from. + * @param string $pattern Pattern to match the file. Pattern will also be removed from the key. + * @param int $levels Maximum number of recursive directories. + * @return array + * @internal + */ + protected function detectAll($folder, $pattern, $levels) + { + $path = trim(Folder::getRelativePath($folder), '/'); + + if (is_dir($folder)) { + // Find all system and user configuration files. + $options = [ + 'levels' => $levels, + 'compare' => 'Filename', + 'pattern' => $pattern, + 'filters' => [ + 'pre-key' => $this->base, + 'key' => $pattern, + 'value' => function (RecursiveDirectoryIterator $file) use ($path) { + return ["{$path}/{$file->getSubPathname()}" => $file->getMTime()]; + } + ], + 'key' => 'SubPathname' + ]; + + $list = Folder::all($folder, $options); + + ksort($list); + } else { + $list = []; + } + + return $list; + } +} diff --git a/system/src/Grav/Common/Config/Languages.php b/system/src/Grav/Common/Config/Languages.php new file mode 100644 index 0000000..ac9ede6 --- /dev/null +++ b/system/src/Grav/Common/Config/Languages.php @@ -0,0 +1,107 @@ +checksum = $checksum; + } + + return $this->checksum; + } + + /** + * @param bool|null $modified + * @return bool + */ + public function modified($modified = null) + { + if ($modified !== null) { + $this->modified = $modified; + } + + return $this->modified; + } + + /** + * @param int|null $timestamp + * @return int + */ + public function timestamp($timestamp = null) + { + if ($timestamp !== null) { + $this->timestamp = $timestamp; + } + + return $this->timestamp; + } + + /** + * @return void + */ + public function reformat() + { + if (isset($this->items['plugins'])) { + $this->items = array_merge_recursive($this->items, $this->items['plugins']); + unset($this->items['plugins']); + } + } + + /** + * @param array $data + * @return void + */ + public function mergeRecursive(array $data) + { + $this->items = Utils::arrayMergeRecursiveUnique($this->items, $data); + } + + /** + * @param string $lang + * @return array + */ + public function flattenByLang($lang) + { + $language = $this->items[$lang]; + return Utils::arrayFlattenDotNotation($language); + } + + /** + * @param array $array + * @return array + */ + public function unflatten($array) + { + return Utils::arrayUnflattenDotNotation($array); + } +} diff --git a/system/src/Grav/Common/Config/Setup.php b/system/src/Grav/Common/Config/Setup.php new file mode 100644 index 0000000..62995d4 --- /dev/null +++ b/system/src/Grav/Common/Config/Setup.php @@ -0,0 +1,423 @@ + 'unknown', + '127.0.0.1' => 'localhost', + '::1' => 'localhost' + ]; + + /** + * @var string|null Current environment normalized to lower case. + */ + public static $environment; + + /** @var string */ + public static $securityFile = 'config://security.yaml'; + + /** @var array */ + protected $streams = [ + 'user' => [ + 'type' => 'ReadOnlyStream', + 'force' => true, + 'prefixes' => [ + '' => [] // Set in constructor + ] + ], + 'cache' => [ + 'type' => 'Stream', + 'force' => true, + 'prefixes' => [ + '' => [], // Set in constructor + 'images' => ['images'] + ] + ], + 'log' => [ + 'type' => 'Stream', + 'force' => true, + 'prefixes' => [ + '' => [] // Set in constructor + ] + ], + 'tmp' => [ + 'type' => 'Stream', + 'force' => true, + 'prefixes' => [ + '' => [] // Set in constructor + ] + ], + 'backup' => [ + 'type' => 'Stream', + 'force' => true, + 'prefixes' => [ + '' => [] // Set in constructor + ] + ], + 'environment' => [ + 'type' => 'ReadOnlyStream' + // If not defined, environment will be set up in the constructor. + ], + 'system' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['system'], + ] + ], + 'asset' => [ + 'type' => 'Stream', + 'prefixes' => [ + '' => ['assets'], + ] + ], + 'blueprints' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['environment://blueprints', 'user://blueprints', 'system://blueprints'], + ] + ], + 'config' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['environment://config', 'user://config', 'system://config'], + ] + ], + 'plugins' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['user://plugins'], + ] + ], + 'plugin' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['user://plugins'], + ] + ], + 'themes' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['user://themes'], + ] + ], + 'languages' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['environment://languages', 'user://languages', 'system://languages'], + ] + ], + 'image' => [ + 'type' => 'Stream', + 'prefixes' => [ + '' => ['user://images', 'system://images'] + ] + ], + 'page' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['user://pages'] + ] + ], + 'user-data' => [ + 'type' => 'Stream', + 'force' => true, + 'prefixes' => [ + '' => ['user://data'] + ] + ], + 'account' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['user://accounts'] + ] + ], + ]; + + /** + * @param Container|array $container + */ + public function __construct($container) + { + // Configure main streams. + $abs = str_starts_with(GRAV_SYSTEM_PATH, '/'); + $this->streams['system']['prefixes'][''] = $abs ? ['system', GRAV_SYSTEM_PATH] : ['system']; + $this->streams['user']['prefixes'][''] = [GRAV_USER_PATH]; + $this->streams['cache']['prefixes'][''] = [GRAV_CACHE_PATH]; + $this->streams['log']['prefixes'][''] = [GRAV_LOG_PATH]; + $this->streams['tmp']['prefixes'][''] = [GRAV_TMP_PATH]; + $this->streams['backup']['prefixes'][''] = [GRAV_BACKUP_PATH]; + + // If environment is not set, look for the environment variable and then the constant. + $environment = static::$environment ?? + (defined('GRAV_ENVIRONMENT') ? GRAV_ENVIRONMENT : (getenv('GRAV_ENVIRONMENT') ?: null)); + + // If no environment is set, make sure we get one (CLI or hostname). + if (null === $environment) { + if (defined('GRAV_CLI')) { + $request = null; + $uri = null; + $environment = 'cli'; + } else { + /** @var ServerRequestInterface $request */ + $request = $container['request']; + $uri = $request->getUri(); + $environment = $uri->getHost(); + } + } + + // Resolve server aliases to the proper environment. + static::$environment = static::$environments[$environment] ?? $environment; + + // Pre-load setup.php which contains our initial configuration. + // Configuration may contain dynamic parts, which is why we need to always load it. + // If GRAV_SETUP_PATH has been defined, use it, otherwise use defaults. + $setupFile = defined('GRAV_SETUP_PATH') ? GRAV_SETUP_PATH : (getenv('GRAV_SETUP_PATH') ?: null); + if (null !== $setupFile) { + // Make sure that the custom setup file exists. Terminates the script if not. + if (!str_starts_with($setupFile, '/')) { + $setupFile = GRAV_WEBROOT . '/' . $setupFile; + } + if (!is_file($setupFile)) { + echo 'GRAV_SETUP_PATH is defined but does not point to existing setup file.'; + exit(1); + } + } else { + $setupFile = GRAV_WEBROOT . '/setup.php'; + if (!is_file($setupFile)) { + $setupFile = GRAV_WEBROOT . '/' . GRAV_USER_PATH . '/setup.php'; + } + if (!is_file($setupFile)) { + $setupFile = null; + } + } + $setup = $setupFile ? (array) include $setupFile : []; + + // Add default streams defined in beginning of the class. + if (!isset($setup['streams']['schemes'])) { + $setup['streams']['schemes'] = []; + } + $setup['streams']['schemes'] += $this->streams; + + // Initialize class. + parent::__construct($setup); + + $this->def('environment', static::$environment); + + // Figure out path for the current environment. + $envPath = defined('GRAV_ENVIRONMENT_PATH') ? GRAV_ENVIRONMENT_PATH : (getenv('GRAV_ENVIRONMENT_PATH') ?: null); + if (null === $envPath) { + // Find common path for all environments and append current environment into it. + $envPath = defined('GRAV_ENVIRONMENTS_PATH') ? GRAV_ENVIRONMENTS_PATH : (getenv('GRAV_ENVIRONMENTS_PATH') ?: null); + if (null !== $envPath) { + $envPath .= '/'; + } else { + // Use default location. Start with Grav 1.7 default. + $envPath = GRAV_WEBROOT. '/' . GRAV_USER_PATH . '/env'; + if (is_dir($envPath)) { + $envPath = 'user://env/'; + } else { + // Fallback to Grav 1.6 default. + $envPath = 'user://'; + } + } + $envPath .= $this->get('environment'); + } + + // Set up environment. + $this->def('environment', static::$environment); + $this->def('streams.schemes.environment.prefixes', ['' => [$envPath]]); + } + + /** + * @return $this + * @throws RuntimeException + * @throws InvalidArgumentException + */ + public function init() + { + $locator = new UniformResourceLocator(GRAV_WEBROOT); + $files = []; + + $guard = 5; + do { + $check = $files; + $this->initializeLocator($locator); + $files = $locator->findResources('config://streams.yaml'); + + if ($check === $files) { + break; + } + + // Update streams. + foreach (array_reverse($files) as $path) { + $file = CompiledYamlFile::instance($path); + $content = (array)$file->content(); + if (!empty($content['schemes'])) { + $this->items['streams']['schemes'] = $content['schemes'] + $this->items['streams']['schemes']; + } + } + } while (--$guard); + + if (!$guard) { + throw new RuntimeException('Setup: Configuration reload loop detected!'); + } + + // Make sure we have valid setup. + $this->check($locator); + + return $this; + } + + /** + * Initialize resource locator by using the configuration. + * + * @param UniformResourceLocator $locator + * @return void + * @throws BadMethodCallException + */ + public function initializeLocator(UniformResourceLocator $locator) + { + $locator->reset(); + + $schemes = (array) $this->get('streams.schemes', []); + + foreach ($schemes as $scheme => $config) { + if (isset($config['paths'])) { + $locator->addPath($scheme, '', $config['paths']); + } + + $override = $config['override'] ?? false; + $force = $config['force'] ?? false; + + if (isset($config['prefixes'])) { + foreach ((array)$config['prefixes'] as $prefix => $paths) { + $locator->addPath($scheme, $prefix, $paths, $override, $force); + } + } + } + } + + /** + * Get available streams and their types from the configuration. + * + * @return array + */ + public function getStreams() + { + $schemes = []; + foreach ((array) $this->get('streams.schemes') as $scheme => $config) { + $type = $config['type'] ?? 'ReadOnlyStream'; + if ($type[0] !== '\\') { + $type = '\\RocketTheme\\Toolbox\\StreamWrapper\\' . $type; + } + + $schemes[$scheme] = $type; + } + + return $schemes; + } + + /** + * @param UniformResourceLocator $locator + * @return void + * @throws InvalidArgumentException + * @throws BadMethodCallException + * @throws RuntimeException + */ + protected function check(UniformResourceLocator $locator) + { + $streams = $this->items['streams']['schemes'] ?? null; + if (!is_array($streams)) { + throw new InvalidArgumentException('Configuration is missing streams.schemes!'); + } + $diff = array_keys(array_diff_key($this->streams, $streams)); + if ($diff) { + throw new InvalidArgumentException( + sprintf('Configuration is missing keys %s from streams.schemes!', implode(', ', $diff)) + ); + } + + try { + // If environment is found, remove all missing override locations (B/C compatibility). + if ($locator->findResource('environment://', true)) { + $force = $this->get('streams.schemes.environment.force', false); + if (!$force) { + $prefixes = $this->get('streams.schemes.environment.prefixes.'); + $update = false; + foreach ($prefixes as $i => $prefix) { + if ($locator->isStream($prefix)) { + if ($locator->findResource($prefix, true)) { + break; + } + } elseif (file_exists($prefix)) { + break; + } + + unset($prefixes[$i]); + $update = true; + } + + if ($update) { + $this->set('streams.schemes.environment.prefixes', ['' => array_values($prefixes)]); + $this->initializeLocator($locator); + } + } + } + + if (!$locator->findResource('environment://config', true)) { + // If environment does not have its own directory, remove it from the lookup. + $prefixes = $this->get('streams.schemes.environment.prefixes'); + $prefixes['config'] = []; + + $this->set('streams.schemes.environment.prefixes', $prefixes); + $this->initializeLocator($locator); + } + + // Create security.yaml salt if it doesn't exist into existing configuration environment if possible. + $securityFile = Utils::basename(static::$securityFile); + $securityFolder = substr(static::$securityFile, 0, -\strlen($securityFile)); + $securityFolder = $locator->findResource($securityFolder, true) ?: $locator->findResource($securityFolder, true, true); + $filename = "{$securityFolder}/{$securityFile}"; + + $security_file = CompiledYamlFile::instance($filename); + $security_content = (array)$security_file->content(); + + if (!isset($security_content['salt'])) { + $security_content = array_merge($security_content, ['salt' => Utils::generateRandomString(14)]); + $security_file->content($security_content); + $security_file->save(); + $security_file->free(); + } + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Grav failed to initialize: %s', $e->getMessage()), 500, $e); + } + } +} diff --git a/system/src/Grav/Common/Data/Blueprint.php b/system/src/Grav/Common/Data/Blueprint.php new file mode 100644 index 0000000..296dc54 --- /dev/null +++ b/system/src/Grav/Common/Data/Blueprint.php @@ -0,0 +1,594 @@ +blueprintSchema) { + $this->blueprintSchema = clone $this->blueprintSchema; + } + } + + /** + * @param string $scope + * @return void + */ + public function setScope($scope) + { + $this->scope = $scope; + } + + /** + * @param object $object + * @return void + */ + public function setObject($object) + { + $this->object = $object; + } + + /** + * Set default values for field types. + * + * @param array $types + * @return $this + */ + public function setTypes(array $types) + { + $this->initInternals(); + + $this->blueprintSchema->setTypes($types); + + return $this; + } + + /** + * @param string $name + * @return array|mixed|null + * @since 1.7 + */ + public function getDefaultValue(string $name) + { + $path = explode('.', $name); + $current = $this->getDefaults(); + + foreach ($path as $field) { + if (is_object($current) && isset($current->{$field})) { + $current = $current->{$field}; + } elseif (is_array($current) && isset($current[$field])) { + $current = $current[$field]; + } else { + return null; + } + } + + return $current; + } + + /** + * Get nested structure containing default values defined in the blueprints. + * + * Fields without default value are ignored in the list. + * + * @return array + */ + public function getDefaults() + { + $this->initInternals(); + + if (null === $this->defaults) { + $this->defaults = $this->blueprintSchema->getDefaults(); + } + + return $this->defaults; + } + + /** + * Initialize blueprints with its dynamic fields. + * + * @return $this + */ + public function init() + { + foreach ($this->dynamic as $key => $data) { + // Locate field. + $path = explode('/', $key); + $current = &$this->items; + + foreach ($path as $field) { + if (is_object($current)) { + // Handle objects. + if (!isset($current->{$field})) { + $current->{$field} = []; + } + + $current = &$current->{$field}; + } else { + // Handle arrays and scalars. + if (!is_array($current)) { + $current = [$field => []]; + } elseif (!isset($current[$field])) { + $current[$field] = []; + } + + $current = &$current[$field]; + } + } + + // Set dynamic property. + foreach ($data as $property => $call) { + $action = $call['action']; + $method = 'dynamic' . ucfirst($action); + $call['object'] = $this->object; + + if (isset($this->handlers[$action])) { + $callable = $this->handlers[$action]; + $callable($current, $property, $call); + } elseif (method_exists($this, $method)) { + $this->{$method}($current, $property, $call); + } + } + } + + return $this; + } + + /** + * Extend blueprint with another blueprint. + * + * @param BlueprintForm|array $extends + * @param bool $append + * @return $this + */ + public function extend($extends, $append = false) + { + parent::extend($extends, $append); + + $this->deepInit($this->items); + + return $this; + } + + /** + * @param string $name + * @param mixed $value + * @param string $separator + * @param bool $append + * @return $this + */ + public function embed($name, $value, $separator = '/', $append = false) + { + parent::embed($name, $value, $separator, $append); + + $this->deepInit($this->items); + + return $this; + } + + /** + * Merge two arrays by using blueprints. + * + * @param array $data1 + * @param array $data2 + * @param string|null $name Optional + * @param string $separator Optional + * @return array + */ + public function mergeData(array $data1, array $data2, $name = null, $separator = '.') + { + $this->initInternals(); + + return $this->blueprintSchema->mergeData($data1, $data2, $name, $separator); + } + + /** + * Process data coming from a form. + * + * @param array $data + * @param array $toggles + * @return array + */ + public function processForm(array $data, array $toggles = []) + { + $this->initInternals(); + + return $this->blueprintSchema->processForm($data, $toggles); + } + + /** + * Return data fields that do not exist in blueprints. + * + * @param array $data + * @param string $prefix + * @return array + */ + public function extra(array $data, $prefix = '') + { + $this->initInternals(); + + return $this->blueprintSchema->extra($data, $prefix); + } + + /** + * Validate data against blueprints. + * + * @param array $data + * @param array $options + * @return void + * @throws RuntimeException + */ + public function validate(array $data, array $options = []) + { + $this->initInternals(); + + $this->blueprintSchema->validate($data, $options); + } + + /** + * Filter data by using blueprints. + * + * @param array $data + * @param bool $missingValuesAsNull + * @param bool $keepEmptyValues + * @return array + */ + public function filter(array $data, bool $missingValuesAsNull = false, bool $keepEmptyValues = false) + { + $this->initInternals(); + + return $this->blueprintSchema->filter($data, $missingValuesAsNull, $keepEmptyValues) ?? []; + } + + + /** + * Flatten data by using blueprints. + * + * @param array $data Data to be flattened. + * @param bool $includeAll True if undefined properties should also be included. + * @param string $name Property which will be flattened, useful for flattening repeating data. + * @return array + */ + public function flattenData(array $data, bool $includeAll = false, string $name = '') + { + $this->initInternals(); + + return $this->blueprintSchema->flattenData($data, $includeAll, $name); + } + + + /** + * Return blueprint data schema. + * + * @return BlueprintSchema + */ + public function schema() + { + $this->initInternals(); + + return $this->blueprintSchema; + } + + /** + * @param string $name + * @param callable $callable + * @return void + */ + public function addDynamicHandler(string $name, callable $callable): void + { + $this->handlers[$name] = $callable; + } + + /** + * Initialize validator. + * + * @return void + */ + protected function initInternals() + { + if (null === $this->blueprintSchema) { + $types = Grav::instance()['plugins']->formFieldTypes; + + $this->blueprintSchema = new BlueprintSchema; + + if ($types) { + $this->blueprintSchema->setTypes($types); + } + + $this->blueprintSchema->embed('', $this->items); + $this->blueprintSchema->init(); + $this->defaults = null; + } + } + + /** + * @param string $filename + * @return array + */ + protected function loadFile($filename) + { + $file = CompiledYamlFile::instance($filename); + $content = (array)$file->content(); + $file->free(); + + return $content; + } + + /** + * @param string|array $path + * @param string|null $context + * @return array + */ + protected function getFiles($path, $context = null) + { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + if (is_string($path) && !$locator->isStream($path)) { + if (is_file($path)) { + return [$path]; + } + + // Find path overrides. + if (null === $context) { + $paths = (array) ($this->overrides[$path] ?? null); + } else { + $paths = []; + } + + // Add path pointing to default context. + if ($context === null) { + $context = $this->context; + } + + if ($context && $context[strlen($context)-1] !== '/') { + $context .= '/'; + } + + $path = $context . $path; + + if (!preg_match('/\.yaml$/', $path)) { + $path .= '.yaml'; + } + + $paths[] = $path; + } else { + $paths = (array) $path; + } + + $files = []; + foreach ($paths as $lookup) { + if (is_string($lookup) && strpos($lookup, '://')) { + $files = array_merge($files, $locator->findResources($lookup)); + } else { + $files[] = $lookup; + } + } + + return array_values(array_unique($files)); + } + + /** + * @param array $field + * @param string $property + * @param array $call + * @return void + */ + protected function dynamicData(array &$field, $property, array &$call) + { + $params = $call['params']; + + if (is_array($params)) { + $function = array_shift($params); + } else { + $function = $params; + $params = []; + } + + [$o, $f] = explode('::', $function, 2); + + $data = null; + if (!$f) { + if (function_exists($o)) { + $data = call_user_func_array($o, $params); + } + } else { + if (method_exists($o, $f)) { + $data = call_user_func_array([$o, $f], $params); + } + } + + // If function returns a value, + if (null !== $data) { + if (is_array($data) && isset($field[$property]) && is_array($field[$property])) { + // Combine field and @data-field together. + $field[$property] += $data; + } else { + // Or create/replace field with @data-field. + $field[$property] = $data; + } + } + } + + /** + * @param array $field + * @param string $property + * @param array $call + * @return void + */ + protected function dynamicConfig(array &$field, $property, array &$call) + { + $params = $call['params']; + if (is_array($params)) { + $value = array_shift($params); + $params = array_shift($params); + } else { + $value = $params; + $params = []; + } + + $default = $field[$property] ?? null; + $config = Grav::instance()['config']->get($value, $default); + if (!empty($field['value_only'])) { + $config = array_combine($config, $config); + } + + if (null !== $config) { + if (!empty($params['append']) && is_array($config) && isset($field[$property]) && is_array($field[$property])) { + // Combine field and @config-field together. + $field[$property] += $config; + } else { + // Or create/replace field with @config-field. + $field[$property] = $config; + } + } + } + + /** + * @param array $field + * @param string $property + * @param array $call + * @return void + */ + protected function dynamicSecurity(array &$field, $property, array &$call) + { + if ($property || !empty($field['validate']['ignore'])) { + return; + } + + $grav = Grav::instance(); + $actions = (array)$call['params']; + + /** @var UserInterface|null $user */ + $user = $grav['user'] ?? null; + $success = null !== $user; + if ($success) { + $success = $this->resolveActions($user, $actions); + } + if (!$success) { + static::addPropertyRecursive($field, 'validate', ['ignore' => true]); + } + } + + /** + * @param UserInterface|null $user + * @param array $actions + * @param string $op + * @return bool + */ + protected function resolveActions(?UserInterface $user, array $actions, string $op = 'and') + { + if (null === $user) { + return false; + } + + $c = $i = count($actions); + foreach ($actions as $key => $action) { + if (!is_int($key) && is_array($actions)) { + $i -= $this->resolveActions($user, $action, $key); + } elseif ($user->authorize($action)) { + $i--; + } + } + + if ($op === 'and') { + return $i === 0; + } + + return $c !== $i; + } + + /** + * @param array $field + * @param string $property + * @param array $call + * @return void + */ + protected function dynamicScope(array &$field, $property, array &$call) + { + if ($property && $property !== 'ignore') { + return; + } + + $scopes = (array)$call['params']; + $matches = in_array($this->scope, $scopes, true); + if ($this->scope && $property !== 'ignore') { + $matches = !$matches; + } + + if ($matches) { + static::addPropertyRecursive($field, 'validate', ['ignore' => true]); + return; + } + } + + /** + * @param array $field + * @param string $property + * @param mixed $value + * @return void + */ + public static function addPropertyRecursive(array &$field, $property, $value) + { + if (is_array($value) && isset($field[$property]) && is_array($field[$property])) { + $field[$property] = array_merge_recursive($field[$property], $value); + } else { + $field[$property] = $value; + } + + if (!empty($field['fields'])) { + foreach ($field['fields'] as $key => &$child) { + static::addPropertyRecursive($child, $property, $value); + } + } + } +} diff --git a/system/src/Grav/Common/Data/BlueprintSchema.php b/system/src/Grav/Common/Data/BlueprintSchema.php new file mode 100644 index 0000000..8db6654 --- /dev/null +++ b/system/src/Grav/Common/Data/BlueprintSchema.php @@ -0,0 +1,461 @@ + true, 'xss_check' => true]; + + /** @var array */ + protected $ignoreFormKeys = [ + 'title' => true, + 'help' => true, + 'placeholder' => true, + 'placeholder_key' => true, + 'placeholder_value' => true, + 'fields' => true + ]; + + /** + * @return array + */ + public function getTypes() + { + return $this->types; + } + + /** + * @param string $name + * @return array + */ + public function getType($name) + { + return $this->types[$name] ?? []; + } + + /** + * @param string $name + * @return array|null + */ + public function getNestedRules(string $name) + { + return $this->getNested($name); + } + + /** + * Validate data against blueprints. + * + * @param array $data + * @param array $options + * @return void + * @throws RuntimeException + */ + public function validate(array $data, array $options = []) + { + try { + $validation = $this->items['']['form']['validation'] ?? 'loose'; + $messages = $this->validateArray($data, $this->nested, $validation === 'strict', $options['xss_check'] ?? true); + } catch (RuntimeException $e) { + throw (new ValidationException($e->getMessage(), $e->getCode(), $e))->setMessages(); + } + + if (!empty($messages)) { + throw (new ValidationException('', 400))->setMessages($messages); + } + } + + /** + * @param array $data + * @param array $toggles + * @return array + */ + public function processForm(array $data, array $toggles = []) + { + return $this->processFormRecursive($data, $toggles, $this->nested) ?? []; + } + + /** + * Filter data by using blueprints. + * + * @param array $data Incoming data, for example from a form. + * @param bool $missingValuesAsNull Include missing values as nulls. + * @param bool $keepEmptyValues Include empty values. + * @return array + */ + public function filter(array $data, $missingValuesAsNull = false, $keepEmptyValues = false) + { + $this->buildIgnoreNested($this->nested); + + return $this->filterArray($data, $this->nested, '', $missingValuesAsNull, $keepEmptyValues) ?? []; + } + + /** + * Flatten data by using blueprints. + * + * @param array $data Data to be flattened. + * @param bool $includeAll True if undefined properties should also be included. + * @param string $name Property which will be flattened, useful for flattening repeating data. + * @return array + */ + public function flattenData(array $data, bool $includeAll = false, string $name = '') + { + $prefix = $name !== '' ? $name . '.' : ''; + + $list = []; + if ($includeAll) { + $items = $name !== '' ? $this->getProperty($name)['fields'] ?? [] : $this->items; + foreach ($items as $key => $rules) { + $type = $rules['type'] ?? ''; + $ignore = (bool) array_filter((array)($rules['validate']['ignore'] ?? [])) ?? false; + if (!str_starts_with($type, '_') && !str_contains($key, '*') && $ignore !== true) { + $list[$prefix . $key] = null; + } + } + } + + $nested = $this->getNestedRules($name); + + return array_replace($list, $this->flattenArray($data, $nested, $prefix)); + } + + /** + * @param array $data + * @param array $rules + * @param string $prefix + * @return array + */ + protected function flattenArray(array $data, array $rules, string $prefix) + { + $array = []; + + foreach ($data as $key => $field) { + $val = $rules[$key] ?? $rules['*'] ?? null; + $rule = is_string($val) ? $this->items[$val] : null; + + if ($rule || isset($val['*'])) { + // Item has been defined in blueprints. + $array[$prefix.$key] = $field; + } elseif (is_array($field) && is_array($val)) { + // Array has been defined in blueprints. + $array += $this->flattenArray($field, $val, $prefix . $key . '.'); + } else { + // Undefined/extra item. + $array[$prefix.$key] = $field; + } + } + + return $array; + } + + /** + * @param array $data + * @param array $rules + * @param bool $strict + * @param bool $xss + * @return array + * @throws RuntimeException + */ + protected function validateArray(array $data, array $rules, bool $strict, bool $xss = true) + { + $messages = $this->checkRequired($data, $rules); + + foreach ($data as $key => $child) { + $val = $rules[$key] ?? $rules['*'] ?? null; + $rule = is_string($val) ? $this->items[$val] : null; + $checkXss = $xss; + + if ($rule) { + // Item has been defined in blueprints. + if (!empty($rule['disabled']) || !empty($rule['validate']['ignore'])) { + // Skip validation in the ignored field. + continue; + } + + $messages += Validation::validate($child, $rule); + + if (isset($rule['validate']['match']) || isset($rule['validate']['match_exact']) || isset($rule['validate']['match_any'])) { + $ruleKey = current(array_intersect(['match', 'match_exact', 'match_any'], array_keys($rule['validate']))); + $otherKey = $rule['validate'][$ruleKey] ?? null; + $otherVal = $data[$otherKey] ?? null; + $otherLabel = $this->items[$otherKey]['label'] ?? $otherKey; + $currentVal = $data[$key] ?? null; + $currentLabel = $this->items[$key]['label'] ?? $key; + + // Determine comparison type (loose, strict, substring) + // Perform comparison: + $isValid = false; + if ($ruleKey === 'match') { + $isValid = ($currentVal == $otherVal); + } elseif ($ruleKey === 'match_exact') { + $isValid = ($currentVal === $otherVal); + } elseif ($ruleKey === 'match_any') { + // If strings: + if (is_string($currentVal) && is_string($otherVal)) { + $isValid = (strlen($currentVal) && strlen($otherVal) && (str_contains($currentVal, + $otherVal) || strpos($otherVal, $currentVal) !== false)); + } + // If arrays: + if (is_array($currentVal) && is_array($otherVal)) { + $common = array_intersect($currentVal, $otherVal); + $isValid = !empty($common); + } + } + if (!$isValid) { + $messages[$rule['name']][] = sprintf(Grav::instance()['language']->translate('PLUGIN_FORM.VALIDATION_MATCH'), $currentLabel, $otherLabel); + } + } + + } elseif (is_array($child) && is_array($val)) { + // Array has been defined in blueprints. + $messages += $this->validateArray($child, $val, $strict); + $checkXss = false; + + } elseif ($strict) { + // Undefined/extra item in strict mode. + /** @var Config $config */ + $config = Grav::instance()['config']; + if (!$config->get('system.strict_mode.blueprint_strict_compat', true)) { + throw new RuntimeException(sprintf('%s is not defined in blueprints', $key), 400); + } + + user_error(sprintf('Having extra key %s in your data is deprecated with blueprint having \'validation: strict\'', $key), E_USER_DEPRECATED); + } + + if ($checkXss) { + $messages += Validation::checkSafety($child, $rule ?: ['name' => $key]); + } + } + + return $messages; + } + + /** + * @param array $data + * @param array $rules + * @param string $parent + * @param bool $missingValuesAsNull + * @param bool $keepEmptyValues + * @return array|null + */ + protected function filterArray(array $data, array $rules, string $parent, bool $missingValuesAsNull, bool $keepEmptyValues) + { + $results = []; + + foreach ($data as $key => $field) { + $val = $rules[$key] ?? $rules['*'] ?? null; + $rule = is_string($val) ? $this->items[$val] : $this->items[$parent . $key] ?? null; + + if (!empty($rule['disabled']) || !empty($rule['validate']['ignore'])) { + // Skip any data in the ignored field. + unset($results[$key]); + continue; + } + + if (null === $field) { + if ($missingValuesAsNull) { + $results[$key] = null; + } else { + unset($results[$key]); + } + continue; + } + + $isParent = isset($val['*']); + $type = $rule['type'] ?? null; + + if (!$isParent && $type && $type !== '_parent') { + $field = Validation::filter($field, $rule); + } elseif (is_array($field) && is_array($val)) { + // Array has been defined in blueprints. + $k = $isParent ? '*' : $key; + $field = $this->filterArray($field, $val, $parent . $k . '.', $missingValuesAsNull, $keepEmptyValues); + + if (null === $field) { + // Nested parent has no values. + unset($results[$key]); + continue; + } + } elseif (isset($rules['validation']) && $rules['validation'] === 'strict') { + // Skip any extra data. + continue; + } + + if ($keepEmptyValues || (null !== $field && (!is_array($field) || !empty($field)))) { + $results[$key] = $field; + } + } + + return $results ?: null; + } + + /** + * @param array $nested + * @param string $parent + * @return bool + */ + protected function buildIgnoreNested(array $nested, $parent = '') + { + $ignore = true; + foreach ($nested as $key => $val) { + $key = $parent . $key; + if (is_array($val)) { + $ignore = $this->buildIgnoreNested($val, $key . '.') && $ignore; // Keep the order! + } else { + $child = $this->items[$key] ?? null; + $ignore = $ignore && (!$child || !empty($child['disabled']) || !empty($child['validate']['ignore'])); + } + } + if ($ignore) { + $key = trim($parent, '.'); + $this->items[$key]['validate']['ignore'] = true; + } + + return $ignore; + } + + /** + * @param array|null $data + * @param array $toggles + * @param array $nested + * @return array|null + */ + protected function processFormRecursive(?array $data, array $toggles, array $nested) + { + foreach ($nested as $key => $value) { + if ($key === '') { + continue; + } + if ($key === '*') { + // TODO: Add support to collections. + continue; + } + if (is_array($value)) { + // Special toggle handling for all the nested data. + $toggle = $toggles[$key] ?? []; + if (!is_array($toggle)) { + if (!$toggle) { + $data[$key] = null; + + continue; + } + + $toggle = []; + } + // Recursively fetch the items. + $childData = $data[$key] ?? null; + if (null !== $childData && !is_array($childData)) { + throw new \RuntimeException(sprintf("Bad form data for field collection '%s': %s used instead of an array", $key, gettype($childData))); + } + $data[$key] = $this->processFormRecursive($data[$key] ?? null, $toggle, $value); + } else { + $field = $this->get($value); + // Do not add the field if: + if ( + // Not an input field + !$field + // Field has been disabled + || !empty($field['disabled']) + // Field validation is set to be ignored + || !empty($field['validate']['ignore']) + // Field is overridable and the toggle is turned off + || (!empty($field['overridable']) && empty($toggles[$key])) + ) { + continue; + } + if (!isset($data[$key])) { + $data[$key] = null; + } + } + } + + return $data; + } + + /** + * @param array $data + * @param array $fields + * @return array + */ + protected function checkRequired(array $data, array $fields) + { + $messages = []; + + foreach ($fields as $name => $field) { + if (!is_string($field)) { + continue; + } + + $field = $this->items[$field]; + + // Skip ignored field, it will not be required. + if (!empty($field['disabled']) || !empty($field['validate']['ignore'])) { + continue; + } + + // Skip overridable fields without value. + // TODO: We need better overridable support, which is not just ignoring required values but also looking if defaults are good. + if (!empty($field['overridable']) && !isset($data[$name])) { + continue; + } + + // Check if required. + if (isset($field['validate']['required']) + && $field['validate']['required'] === true) { + if (isset($data[$name])) { + continue; + } + if ($field['type'] === 'file' && isset($data['data']['name'][$name])) { //handle case of file input fields required + continue; + } + + $value = $field['label'] ?? $field['name']; + $language = Grav::instance()['language']; + $message = sprintf($language->translate('GRAV.FORM.MISSING_REQUIRED_FIELD', null, true) . ' %s', $language->translate($value)); + $messages[$field['name']][] = $message; + } + } + + return $messages; + } + + /** + * @param array $field + * @param string $property + * @param array $call + * @return void + */ + protected function dynamicConfig(array &$field, $property, array &$call) + { + $value = $call['params']; + + $default = $field[$property] ?? null; + $config = Grav::instance()['config']->get($value, $default); + + if (null !== $config) { + $field[$property] = $config; + } + } +} diff --git a/system/src/Grav/Common/Data/Blueprints.php b/system/src/Grav/Common/Data/Blueprints.php new file mode 100644 index 0000000..cef67d1 --- /dev/null +++ b/system/src/Grav/Common/Data/Blueprints.php @@ -0,0 +1,121 @@ +search = $search; + } + + /** + * Get blueprint. + * + * @param string $type Blueprint type. + * @return Blueprint + * @throws RuntimeException + */ + public function get($type) + { + if (!isset($this->instances[$type])) { + $blueprint = $this->loadFile($type); + $this->instances[$type] = $blueprint; + } + + return $this->instances[$type]; + } + + /** + * Get all available blueprint types. + * + * @return array List of type=>name + */ + public function types() + { + if ($this->types === null) { + $this->types = []; + + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + // Get stream / directory iterator. + if ($locator->isStream($this->search)) { + $iterator = $locator->getIterator($this->search); + } else { + $iterator = new DirectoryIterator($this->search); + } + + foreach ($iterator as $file) { + if (!$file->isFile() || '.' . $file->getExtension() !== YAML_EXT) { + continue; + } + $name = $file->getBasename(YAML_EXT); + $this->types[$name] = ucfirst(str_replace('_', ' ', $name)); + } + } + + return $this->types; + } + + + /** + * Load blueprint file. + * + * @param string $name Name of the blueprint. + * @return Blueprint + */ + protected function loadFile($name) + { + $blueprint = new Blueprint($name); + + if (is_array($this->search) || is_object($this->search)) { + // Page types. + $blueprint->setOverrides($this->search); + $blueprint->setContext('blueprints://pages'); + } else { + $blueprint->setContext($this->search); + } + + try { + $blueprint->load()->init(); + } catch (RuntimeException $e) { + $log = Grav::instance()['log']; + $log->error(sprintf('Blueprint %s cannot be loaded: %s', $name, $e->getMessage())); + + throw $e; + } + + return $blueprint; + } +} diff --git a/system/src/Grav/Common/Data/Data.php b/system/src/Grav/Common/Data/Data.php new file mode 100644 index 0000000..5a91516 --- /dev/null +++ b/system/src/Grav/Common/Data/Data.php @@ -0,0 +1,343 @@ +items = $items; + if (null !== $blueprints) { + $this->blueprints = $blueprints; + } + } + + /** + * @param bool $value + * @return $this + */ + public function setKeepEmptyValues(bool $value) + { + $this->keepEmptyValues = $value; + + return $this; + } + + /** + * @param bool $value + * @return $this + */ + public function setMissingValuesAsNull(bool $value) + { + $this->missingValuesAsNull = $value; + + return $this; + } + + /** + * Get value by using dot notation for nested arrays/objects. + * + * @example $value = $data->value('this.is.my.nested.variable'); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @param string $separator Separator, defaults to '.' + * @return mixed Value. + */ + public function value($name, $default = null, $separator = '.') + { + return $this->get($name, $default, $separator); + } + + /** + * Join nested values together by using blueprints. + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value Value to be joined. + * @param string $separator Separator, defaults to '.' + * @return $this + * @throws RuntimeException + */ + public function join($name, $value, $separator = '.') + { + $old = $this->get($name, null, $separator); + if ($old !== null) { + if (!is_array($old)) { + throw new RuntimeException('Value ' . $old); + } + + if (is_object($value)) { + $value = (array) $value; + } elseif (!is_array($value)) { + throw new RuntimeException('Value ' . $value); + } + + $value = $this->blueprints()->mergeData($old, $value, $name, $separator); + } + + $this->set($name, $value, $separator); + + return $this; + } + + /** + * Get nested structure containing default values defined in the blueprints. + * + * Fields without default value are ignored in the list. + + * @return array + */ + public function getDefaults() + { + return $this->blueprints()->getDefaults(); + } + + /** + * Set default values by using blueprints. + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value Value to be joined. + * @param string $separator Separator, defaults to '.' + * @return $this + */ + public function joinDefaults($name, $value, $separator = '.') + { + if (is_object($value)) { + $value = (array) $value; + } + + $old = $this->get($name, null, $separator); + if ($old !== null) { + $value = $this->blueprints()->mergeData($value, $old, $name, $separator); + } + + $this->set($name, $value, $separator); + + return $this; + } + + /** + * Get value from the configuration and join it with given data. + * + * @param string $name Dot separated path to the requested value. + * @param array|object $value Value to be joined. + * @param string $separator Separator, defaults to '.' + * @return array + * @throws RuntimeException + */ + public function getJoined($name, $value, $separator = '.') + { + if (is_object($value)) { + $value = (array) $value; + } elseif (!is_array($value)) { + throw new RuntimeException('Value ' . $value); + } + + $old = $this->get($name, null, $separator); + + if ($old === null) { + // No value set; no need to join data. + return $value; + } + + if (!is_array($old)) { + throw new RuntimeException('Value ' . $old); + } + + // Return joined data. + return $this->blueprints()->mergeData($old, $value, $name, $separator); + } + + + /** + * Merge two configurations together. + * + * @param array $data + * @return $this + */ + public function merge(array $data) + { + $this->items = $this->blueprints()->mergeData($this->items, $data); + + return $this; + } + + /** + * Set default values to the configuration if variables were not set. + * + * @param array $data + * @return $this + */ + public function setDefaults(array $data) + { + $this->items = $this->blueprints()->mergeData($data, $this->items); + + return $this; + } + + /** + * Validate by blueprints. + * + * @return $this + * @throws Exception + */ + public function validate() + { + $this->blueprints()->validate($this->items); + + return $this; + } + + /** + * @return $this + */ + public function filter() + { + $args = func_get_args(); + $missingValuesAsNull = (bool)(array_shift($args) ?? $this->missingValuesAsNull); + $keepEmptyValues = (bool)(array_shift($args) ?? $this->keepEmptyValues); + + $this->items = $this->blueprints()->filter($this->items, $missingValuesAsNull, $keepEmptyValues); + + return $this; + } + + /** + * Get extra items which haven't been defined in blueprints. + * + * @return array + */ + public function extra() + { + return $this->blueprints()->extra($this->items); + } + + /** + * Return blueprints. + * + * @return Blueprint + */ + public function blueprints() + { + if (null === $this->blueprints) { + $this->blueprints = new Blueprint(); + } elseif (is_callable($this->blueprints)) { + // Lazy load blueprints. + $blueprints = $this->blueprints; + $this->blueprints = $blueprints(); + } + + return $this->blueprints; + } + + /** + * Save data if storage has been defined. + * + * @return void + * @throws RuntimeException + */ + public function save() + { + $file = $this->file(); + if ($file) { + $file->save($this->items); + } + } + + /** + * Returns whether the data already exists in the storage. + * + * NOTE: This method does not check if the data is current. + * + * @return bool + */ + public function exists() + { + $file = $this->file(); + + return $file && $file->exists(); + } + + /** + * Return unmodified data as raw string. + * + * NOTE: This function only returns data which has been saved to the storage. + * + * @return string + */ + public function raw() + { + $file = $this->file(); + + return $file ? $file->raw() : ''; + } + + /** + * Set or get the data storage. + * + * @param FileInterface|null $storage Optionally enter a new storage. + * @return FileInterface|null + */ + public function file(FileInterface $storage = null) + { + if ($storage) { + $this->storage = $storage; + } + + return $this->storage; + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->items; + } +} diff --git a/system/src/Grav/Common/Data/DataInterface.php b/system/src/Grav/Common/Data/DataInterface.php new file mode 100644 index 0000000..ed33692 --- /dev/null +++ b/system/src/Grav/Common/Data/DataInterface.php @@ -0,0 +1,84 @@ +value('this.is.my.nested.variable'); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @param string $separator Separator, defaults to '.' + * @return mixed Value. + */ + public function value($name, $default = null, $separator = '.'); + + /** + * Merge external data. + * + * @param array $data + * @return mixed + */ + public function merge(array $data); + + /** + * Return blueprints. + * + * @return Blueprint + */ + public function blueprints(); + + /** + * Validate by blueprints. + * + * @return $this + * @throws Exception + */ + public function validate(); + + /** + * Filter all items by using blueprints. + * + * @return $this + */ + public function filter(); + + /** + * Get extra items which haven't been defined in blueprints. + * + * @return array + */ + public function extra(); + + /** + * Save data into the file. + * + * @return void + */ + public function save(); + + /** + * Set or get the data storage. + * + * @param FileInterface|null $storage Optionally enter a new storage. + * @return FileInterface + */ + public function file(FileInterface $storage = null); +} diff --git a/system/src/Grav/Common/Data/Validation.php b/system/src/Grav/Common/Data/Validation.php new file mode 100644 index 0000000..e668f61 --- /dev/null +++ b/system/src/Grav/Common/Data/Validation.php @@ -0,0 +1,1238 @@ +translate($field['validate']['message']) + : $language->translate('GRAV.FORM.INVALID_INPUT') . ' "' . $language->translate($name) . '"'; + + + // Validate type with fallback type text. + $method = 'type' . str_replace('-', '_', $type); + + // If this is a YAML field validate/filter as such + if (isset($field['yaml']) && $field['yaml'] === true) { + $method = 'typeYaml'; + } + + $messages = []; + + $success = method_exists(__CLASS__, $method) ? self::$method($value, $validate, $field) : true; + if (!$success) { + $messages[$field['name']][] = $message; + } + + // Check individual rules. + foreach ($validate as $rule => $params) { + $method = 'validate' . ucfirst(str_replace('-', '_', $rule)); + + if (method_exists(__CLASS__, $method)) { + $success = self::$method($value, $params); + + if (!$success) { + $messages[$field['name']][] = $message; + } + } + } + + return $messages; + } + + /** + * @param mixed $value + * @param array $field + * @return array + */ + public static function checkSafety($value, array $field) + { + $messages = []; + + $type = $field['validate']['type'] ?? $field['type'] ?? 'text'; + $options = $field['xss_check'] ?? []; + if ($options === false || $type === 'unset') { + return $messages; + } + if (!is_array($options)) { + $options = []; + } + + $name = ucfirst($field['label'] ?? $field['name'] ?? 'UNKNOWN'); + + /** @var UserInterface $user */ + $user = Grav::instance()['user'] ?? null; + /** @var Config $config */ + $config = Grav::instance()['config']; + + $xss_whitelist = $config->get('security.xss_whitelist', 'admin.super'); + + // Get language class. + /** @var Language $language */ + $language = Grav::instance()['language']; + + if (!static::authorize($xss_whitelist, $user)) { + $defaults = Security::getXssDefaults(); + $options += $defaults; + $options['enabled_rules'] += $defaults['enabled_rules']; + if (!empty($options['safe_protocols'])) { + $options['invalid_protocols'] = array_diff($options['invalid_protocols'], $options['safe_protocols']); + } + if (!empty($options['safe_tags'])) { + $options['dangerous_tags'] = array_diff($options['dangerous_tags'], $options['safe_tags']); + } + + if (is_string($value)) { + $violation = Security::detectXss($value, $options); + if ($violation) { + $messages[$name][] = $language->translate(['GRAV.FORM.XSS_ISSUES', $language->translate($name)], null, true); + } + } elseif (is_array($value)) { + $violations = Security::detectXssFromArray($value, "{$name}.", $options); + if ($violations) { + $messages[$name][] = $language->translate(['GRAV.FORM.XSS_ISSUES', $language->translate($name)], null, true); + } + } + } + + return $messages; + } + + /** + * Checks user authorisation to the action. + * + * @param string|string[] $action + * @param UserInterface|null $user + * @return bool + */ + public static function authorize($action, UserInterface $user = null) + { + if (!$user) { + return false; + } + + $action = (array)$action; + foreach ($action as $a) { + // Ignore 'admin.super' if it's not the only value to be checked. + if ($a === 'admin.super' && count($action) > 1 && $user instanceof FlexObjectInterface) { + continue; + } + + if ($user->authorize($a)) { + return true; + } + } + + return false; + } + + /** + * Filter value against a blueprint field definition. + * + * @param mixed $value + * @param array $field + * @return mixed Filtered value. + */ + public static function filter($value, array $field) + { + $validate = (array)($field['filter'] ?? $field['validate'] ?? null); + + // If value isn't required, we will return null if empty value is given. + if (($value === null || $value === '') && empty($validate['required'])) { + return null; + } + + if (!isset($field['type'])) { + $field['type'] = 'text'; + } + $type = $field['filter']['type'] ?? $field['validate']['type'] ?? $field['type']; + + $method = 'filter' . ucfirst(str_replace('-', '_', $type)); + + // If this is a YAML field validate/filter as such + if (isset($field['yaml']) && $field['yaml'] === true) { + $method = 'filterYaml'; + } + + if (!method_exists(__CLASS__, $method)) { + $method = isset($field['array']) && $field['array'] === true ? 'filterArray' : 'filterText'; + } + + return self::$method($value, $validate, $field); + } + + /** + * HTML5 input: text + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeText($value, array $params, array $field) + { + if (!is_string($value) && !is_numeric($value)) { + return false; + } + + $value = (string)$value; + + if (!empty($params['trim'])) { + $value = trim($value); + } + + $value = preg_replace("/\r\n|\r/um", "\n", $value); + $len = mb_strlen($value); + + $min = (int)($params['min'] ?? 0); + if ($min && $len < $min) { + return false; + } + + $multiline = isset($params['multiline']) && $params['multiline']; + + $max = (int)($params['max'] ?? ($multiline ? 65536 : 2048)); + if ($max && $len > $max) { + return false; + } + + $step = (int)($params['step'] ?? 0); + if ($step && ($len - $min) % $step === 0) { + return false; + } + + if (!$multiline && preg_match('/\R/um', $value)) { + return false; + } + + return true; + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return string + */ + protected static function filterText($value, array $params, array $field) + { + if (!is_string($value) && !is_numeric($value)) { + return ''; + } + + $value = (string)$value; + + if (!empty($params['trim'])) { + $value = trim($value); + } + + return preg_replace("/\r\n|\r/um", "\n", $value); + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return string|null + */ + protected static function filterCheckbox($value, array $params, array $field) + { + $value = (string)$value; + $field_value = (string)($field['value'] ?? '1'); + + return $value === $field_value ? $value : null; + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return array|array[]|false|string[] + */ + protected static function filterCommaList($value, array $params, array $field) + { + return is_array($value) ? $value : preg_split('/\s*,\s*/', $value, -1, PREG_SPLIT_NO_EMPTY); + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return bool + */ + public static function typeCommaList($value, array $params, array $field) + { + if (!isset($params['max'])) { + $params['max'] = 2048; + } + + return is_array($value) ? true : self::typeText($value, $params, $field); + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return array|array[]|false|string[] + */ + protected static function filterLines($value, array $params, array $field) + { + return is_array($value) ? $value : preg_split('/\s*[\r\n]+\s*/', $value, -1, PREG_SPLIT_NO_EMPTY); + } + + /** + * @param mixed $value + * @param array $params + * @return string + */ + protected static function filterLower($value, array $params) + { + return mb_strtolower($value); + } + + /** + * @param mixed $value + * @param array $params + * @return string + */ + protected static function filterUpper($value, array $params) + { + return mb_strtoupper($value); + } + + + /** + * HTML5 input: textarea + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeTextarea($value, array $params, array $field) + { + if (!isset($params['multiline'])) { + $params['multiline'] = true; + } + + return self::typeText($value, $params, $field); + } + + /** + * HTML5 input: password + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typePassword($value, array $params, array $field) + { + if (!isset($params['max'])) { + $params['max'] = 256; + } + + return self::typeText($value, $params, $field); + } + + /** + * HTML5 input: hidden + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeHidden($value, array $params, array $field) + { + return self::typeText($value, $params, $field); + } + + /** + * Custom input: checkbox list + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeCheckboxes($value, array $params, array $field) + { + // Set multiple: true so checkboxes can easily use min/max counts to control number of options required + $field['multiple'] = true; + + return self::typeArray((array) $value, $params, $field); + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return array|null + */ + protected static function filterCheckboxes($value, array $params, array $field) + { + return self::filterArray($value, $params, $field); + } + + /** + * HTML5 input: checkbox + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeCheckbox($value, array $params, array $field) + { + $value = (string)$value; + $field_value = (string)($field['value'] ?? '1'); + + return $value === $field_value; + } + + /** + * HTML5 input: radio + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeRadio($value, array $params, array $field) + { + return self::typeArray((array) $value, $params, $field); + } + + /** + * Custom input: toggle + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeToggle($value, array $params, array $field) + { + if (is_bool($value)) { + $value = (int)$value; + } + + return self::typeArray((array) $value, $params, $field); + } + + /** + * Custom input: file + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeFile($value, array $params, array $field) + { + return self::typeArray((array)$value, $params, $field); + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return array + */ + protected static function filterFile($value, array $params, array $field) + { + return (array)$value; + } + + /** + * HTML5 input: select + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeSelect($value, array $params, array $field) + { + return self::typeArray((array) $value, $params, $field); + } + + /** + * HTML5 input: number + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeNumber($value, array $params, array $field) + { + if (!is_numeric($value)) { + return false; + } + + $value = (float)$value; + + $min = 0; + if (isset($params['min'])) { + $min = (float)$params['min']; + if ($value < $min) { + return false; + } + } + + if (isset($params['max'])) { + $max = (float)$params['max']; + if ($value > $max) { + return false; + } + } + + if (isset($params['step'])) { + $step = (float)$params['step']; + // Count of how many steps we are above/below the minimum value. + $pos = ($value - $min) / $step; + $pos = round($pos, 10); + return is_int(static::filterNumber($pos, $params, $field)); + } + + return true; + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return float|int + */ + protected static function filterNumber($value, array $params, array $field) + { + return (string)(int)$value !== (string)(float)$value ? (float)$value : (int)$value; + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return string + */ + protected static function filterDateTime($value, array $params, array $field) + { + $format = Grav::instance()['config']->get('system.pages.dateformat.default'); + if ($format) { + $converted = new DateTime($value); + return $converted->format($format); + } + return $value; + } + + /** + * HTML5 input: range + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeRange($value, array $params, array $field) + { + return self::typeNumber($value, $params, $field); + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return float|int + */ + protected static function filterRange($value, array $params, array $field) + { + return self::filterNumber($value, $params, $field); + } + + /** + * HTML5 input: color + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeColor($value, array $params, array $field) + { + return (bool)preg_match('/^\#[0-9a-fA-F]{3}[0-9a-fA-F]{3}?$/u', $value); + } + + /** + * HTML5 input: email + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeEmail($value, array $params, array $field) + { + if (empty($value)) { + return false; + } + + if (!isset($params['max'])) { + $params['max'] = 320; + } + + $values = !is_array($value) ? explode(',', preg_replace('/\s+/', '', $value)) : $value; + + foreach ($values as $val) { + if (!(self::typeText($val, $params, $field) && strpos($val, '@', 1))) { + return false; + } + } + + return true; + } + + /** + * HTML5 input: url + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeUrl($value, array $params, array $field) + { + if (!isset($params['max'])) { + $params['max'] = 2048; + } + + return self::typeText($value, $params, $field) && filter_var($value, FILTER_VALIDATE_URL); + } + + /** + * HTML5 input: datetime + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeDatetime($value, array $params, array $field) + { + if ($value instanceof DateTime) { + return true; + } + if (!is_string($value)) { + return false; + } + if (!isset($params['format'])) { + return false !== strtotime($value); + } + + $dateFromFormat = DateTime::createFromFormat($params['format'], $value); + + return $dateFromFormat && $value === date($params['format'], $dateFromFormat->getTimestamp()); + } + + /** + * HTML5 input: datetime-local + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeDatetimeLocal($value, array $params, array $field) + { + return self::typeDatetime($value, $params, $field); + } + + /** + * HTML5 input: date + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeDate($value, array $params, array $field) + { + if (!isset($params['format'])) { + $params['format'] = 'Y-m-d'; + } + + return self::typeDatetime($value, $params, $field); + } + + /** + * HTML5 input: time + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeTime($value, array $params, array $field) + { + if (!isset($params['format'])) { + $params['format'] = 'H:i'; + } + + return self::typeDatetime($value, $params, $field); + } + + /** + * HTML5 input: month + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeMonth($value, array $params, array $field) + { + if (!isset($params['format'])) { + $params['format'] = 'Y-m'; + } + + return self::typeDatetime($value, $params, $field); + } + + /** + * HTML5 input: week + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeWeek($value, array $params, array $field) + { + if (!isset($params['format']) && !preg_match('/^\d{4}-W\d{2}$/u', $value)) { + return false; + } + + return self::typeDatetime($value, $params, $field); + } + + /** + * Custom input: array + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeArray($value, array $params, array $field) + { + if (!is_array($value)) { + return false; + } + + if (isset($field['multiple'])) { + if (isset($params['min']) && count($value) < $params['min']) { + return false; + } + + if (isset($params['max']) && count($value) > $params['max']) { + return false; + } + + $min = $params['min'] ?? 0; + if (isset($params['step']) && (count($value) - $min) % $params['step'] === 0) { + return false; + } + } + + // If creating new values is allowed, no further checks are needed. + $validateOptions = $field['validate']['options'] ?? null; + if (!empty($field['selectize']['create']) || $validateOptions === 'ignore') { + return true; + } + + $options = $field['options'] ?? []; + $use = $field['use'] ?? 'values'; + + if ($validateOptions) { + // Use custom options structure. + foreach ($options as &$option) { + $option = $option[$validateOptions] ?? null; + } + unset($option); + $options = array_values($options); + } elseif (empty($field['selectize']) || empty($field['multiple'])) { + $options = array_keys($options); + } + if ($use === 'keys') { + $value = array_keys($value); + } + + return !($options && array_diff($value, $options)); + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return array|null + */ + protected static function filterFlatten_array($value, $params, $field) + { + $value = static::filterArray($value, $params, $field); + + return is_array($value) ? Utils::arrayUnflattenDotNotation($value) : null; + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return array|null + */ + protected static function filterArray($value, $params, $field) + { + $values = (array) $value; + $options = isset($field['options']) ? array_keys($field['options']) : []; + $multi = $field['multiple'] ?? false; + + if (count($values) === 1 && isset($values[0]) && $values[0] === '') { + return null; + } + + + if ($options) { + $useKey = isset($field['use']) && $field['use'] === 'keys'; + foreach ($values as $key => $val) { + $values[$key] = $useKey ? (bool) $val : $val; + } + } + + if ($multi) { + foreach ($values as $key => $val) { + if (is_array($val)) { + $val = implode(',', $val); + $values[$key] = array_map('trim', explode(',', $val)); + } else { + $values[$key] = trim($val); + } + } + } + + $ignoreEmpty = isset($field['ignore_empty']) && Utils::isPositive($field['ignore_empty']); + $valueType = $params['value_type'] ?? null; + $keyType = $params['key_type'] ?? null; + if ($ignoreEmpty || $valueType || $keyType) { + $values = static::arrayFilterRecurse($values, ['value_type' => $valueType, 'key_type' => $keyType, 'ignore_empty' => $ignoreEmpty]); + } + + return $values; + } + + /** + * @param array $values + * @param array $params + * @return array + */ + protected static function arrayFilterRecurse(array $values, array $params): array + { + foreach ($values as $key => &$val) { + if ($params['key_type']) { + switch ($params['key_type']) { + case 'int': + $result = is_int($key); + break; + case 'string': + $result = is_string($key); + break; + default: + $result = false; + } + if (!$result) { + unset($values[$key]); + } + } + if (is_array($val)) { + $val = static::arrayFilterRecurse($val, $params); + if ($params['ignore_empty'] && empty($val)) { + unset($values[$key]); + } + } else { + if ($params['value_type'] && $val !== '' && $val !== null) { + switch ($params['value_type']) { + case 'bool': + if (Utils::isPositive($val)) { + $val = true; + } elseif (Utils::isNegative($val)) { + $val = false; + } else { + // Ignore invalid bool values. + $val = null; + } + break; + case 'int': + $val = (int)$val; + break; + case 'float': + $val = (float)$val; + break; + case 'string': + $val = (string)$val; + break; + case 'trim': + $val = trim($val); + break; + } + } + + if ($params['ignore_empty'] && ($val === '' || $val === null)) { + unset($values[$key]); + } + } + } + + return $values; + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return bool + */ + public static function typeList($value, array $params, array $field) + { + if (!is_array($value)) { + return false; + } + + if (isset($field['fields'])) { + foreach ($value as $key => $item) { + foreach ($field['fields'] as $subKey => $subField) { + $subKey = trim($subKey, '.'); + $subValue = $item[$subKey] ?? null; + self::validate($subValue, $subField); + } + } + } + + return true; + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return array + */ + protected static function filterList($value, array $params, array $field) + { + return (array) $value; + } + + /** + * @param mixed $value + * @param array $params + * @return array + */ + public static function filterYaml($value, $params) + { + if (!is_string($value)) { + return $value; + } + + return (array) Yaml::parse($value); + } + + /** + * Custom input: ignore (will not validate) + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeIgnore($value, array $params, array $field) + { + return true; + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return mixed + */ + public static function filterIgnore($value, array $params, array $field) + { + return $value; + } + + /** + * Input value which can be ignored. + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeUnset($value, array $params, array $field) + { + return true; + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return null + */ + public static function filterUnset($value, array $params, array $field) + { + return null; + } + + // HTML5 attributes (min, max and range are handled inside the types) + + /** + * @param mixed $value + * @param bool $params + * @return bool + */ + public static function validateRequired($value, $params) + { + if (is_scalar($value)) { + return (bool) $params !== true || $value !== ''; + } + + return (bool) $params !== true || !empty($value); + } + + /** + * @param mixed $value + * @param string $params + * @return bool + */ + public static function validatePattern($value, $params) + { + return (bool) preg_match("`^{$params}$`u", $value); + } + + // Internal types + + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ + public static function validateAlpha($value, $params) + { + return ctype_alpha($value); + } + + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ + public static function validateAlnum($value, $params) + { + return ctype_alnum($value); + } + + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ + public static function typeBool($value, $params) + { + return is_bool($value) || $value == 1 || $value == 0; + } + + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ + public static function validateBool($value, $params) + { + return is_bool($value) || $value == 1 || $value == 0; + } + + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ + protected static function filterBool($value, $params) + { + return (bool) $value; + } + + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ + public static function validateDigit($value, $params) + { + return ctype_digit($value); + } + + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ + public static function validateFloat($value, $params) + { + return is_float(filter_var($value, FILTER_VALIDATE_FLOAT)); + } + + /** + * @param mixed $value + * @param mixed $params + * @return float + */ + protected static function filterFloat($value, $params) + { + return (float) $value; + } + + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ + public static function validateHex($value, $params) + { + return ctype_xdigit($value); + } + + /** + * Custom input: int + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeInt($value, array $params, array $field) + { + $params['step'] = max(1, (int)($params['step'] ?? 0)); + + return self::typeNumber($value, $params, $field); + } + + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ + public static function validateInt($value, $params) + { + return is_numeric($value) && (int)$value == $value; + } + + /** + * @param mixed $value + * @param mixed $params + * @return int + */ + protected static function filterInt($value, $params) + { + return (int)$value; + } + + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ + public static function validateArray($value, $params) + { + return is_array($value) || ($value instanceof ArrayAccess && $value instanceof Traversable && $value instanceof Countable); + } + + /** + * @param mixed $value + * @param mixed $params + * @return array + */ + public static function filterItem_List($value, $params) + { + return array_values(array_filter($value, static function ($v) { + return !empty($v); + })); + } + + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ + public static function validateJson($value, $params) + { + return (bool) (@json_decode($value)); + } +} diff --git a/system/src/Grav/Common/Data/ValidationException.php b/system/src/Grav/Common/Data/ValidationException.php new file mode 100644 index 0000000..14b4bef --- /dev/null +++ b/system/src/Grav/Common/Data/ValidationException.php @@ -0,0 +1,67 @@ +messages = $messages; + + $language = Grav::instance()['language']; + $this->message = $language->translate('GRAV.FORM.VALIDATION_FAIL', null, true) . ' ' . $this->message; + + foreach ($messages as $list) { + $list = array_unique($list); + foreach ($list as $message) { + $this->message .= '
    ' . htmlspecialchars($message, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + } + + return $this; + } + + public function setSimpleMessage(bool $escape = true): void + { + $first = reset($this->messages); + $message = reset($first); + + $this->message = $escape ? htmlspecialchars($message, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $message; + } + + /** + * @return array + */ + public function getMessages(): array + { + return $this->messages; + } + + public function jsonSerialize(): array + { + return ['validation' => $this->messages]; + } +} diff --git a/system/src/Grav/Common/Debugger.php b/system/src/Grav/Common/Debugger.php new file mode 100644 index 0000000..a68edb8 --- /dev/null +++ b/system/src/Grav/Common/Debugger.php @@ -0,0 +1,1148 @@ +currentTime = microtime(true); + + if (!defined('GRAV_REQUEST_TIME')) { + define('GRAV_REQUEST_TIME', $this->currentTime); + } + + $this->requestTime = $_SERVER['REQUEST_TIME_FLOAT'] ?? GRAV_REQUEST_TIME; + + // Set deprecation collector. + $this->setErrorHandler(); + } + + /** + * @return Clockwork|null + */ + public function getClockwork(): ?Clockwork + { + return $this->enabled ? $this->clockwork : null; + } + + /** + * Initialize the debugger + * + * @return $this + * @throws DebugBarException + */ + public function init() + { + if ($this->initialized) { + return $this; + } + + $this->grav = Grav::instance(); + $this->config = $this->grav['config']; + + // Enable/disable debugger based on configuration. + $this->enabled = (bool)$this->config->get('system.debugger.enabled'); + $this->censored = (bool)$this->config->get('system.debugger.censored', false); + + if ($this->enabled) { + $this->initialized = true; + + $clockwork = $debugbar = null; + + switch ($this->config->get('system.debugger.provider', 'debugbar')) { + case 'clockwork': + $this->clockwork = $clockwork = new Clockwork(); + break; + default: + $this->debugbar = $debugbar = new DebugBar(); + } + + $plugins_config = (array)$this->config->get('plugins'); + ksort($plugins_config); + + if ($clockwork) { + $log = $this->grav['log']; + $clockwork->setStorage(new FileStorage('cache://clockwork')); + if (extension_loaded('xdebug')) { + $clockwork->addDataSource(new XdebugDataSource()); + } + if ($log instanceof Logger) { + $clockwork->addDataSource(new MonologDataSource($log)); + } + + $timeline = $clockwork->timeline(); + if ($this->requestTime !== GRAV_REQUEST_TIME) { + $event = $timeline->event('Server'); + $event->finalize($this->requestTime, GRAV_REQUEST_TIME); + } + if ($this->currentTime !== GRAV_REQUEST_TIME) { + $event = $timeline->event('Loading'); + $event->finalize(GRAV_REQUEST_TIME, $this->currentTime); + } + $event = $timeline->event('Site Setup'); + $event->finalize($this->currentTime, microtime(true)); + } + + if ($this->censored) { + $censored = ['CENSORED' => true]; + } + + if ($debugbar) { + $debugbar->addCollector(new PhpInfoCollector()); + $debugbar->addCollector(new MessagesCollector()); + if (!$this->censored) { + $debugbar->addCollector(new RequestDataCollector()); + } + $debugbar->addCollector(new TimeDataCollector($this->requestTime)); + $debugbar->addCollector(new MemoryCollector()); + $debugbar->addCollector(new ExceptionsCollector()); + $debugbar->addCollector(new ConfigCollector($censored ?? (array)$this->config->get('system'), 'Config')); + $debugbar->addCollector(new ConfigCollector($censored ?? $plugins_config, 'Plugins')); + $debugbar->addCollector(new ConfigCollector($this->config->get('streams.schemes'), 'Streams')); + + if ($this->requestTime !== GRAV_REQUEST_TIME) { + $debugbar['time']->addMeasure('Server', $debugbar['time']->getRequestStartTime(), GRAV_REQUEST_TIME); + } + if ($this->currentTime !== GRAV_REQUEST_TIME) { + $debugbar['time']->addMeasure('Loading', GRAV_REQUEST_TIME, $this->currentTime); + } + $debugbar['time']->addMeasure('Site Setup', $this->currentTime, microtime(true)); + } + + $this->addMessage('Grav v' . GRAV_VERSION . ' - PHP ' . PHP_VERSION); + $this->config->debug(); + + if ($clockwork) { + $clockwork->info('System Configuration', $censored ?? $this->config->get('system')); + $clockwork->info('Plugins Configuration', $censored ?? $plugins_config); + $clockwork->info('Streams', $this->config->get('streams.schemes')); + } + } + + return $this; + } + + public function finalize(): void + { + if ($this->clockwork && $this->enabled) { + $this->stopProfiling('Profiler Analysis'); + $this->addMeasures(); + + $deprecations = $this->getDeprecations(); + $count = count($deprecations); + if (!$count) { + return; + } + + /** @var UserData $userData */ + $userData = $this->clockwork->userData('Deprecated'); + $userData->counters([ + 'Deprecated' => count($deprecations) + ]); + /* + foreach ($deprecations as &$deprecation) { + $d = $deprecation; + unset($d['message']); + $this->clockwork->log('deprecated', $deprecation['message'], $d); + } + unset($deprecation); + */ + + $userData->table('Your site is using following deprecated features', $deprecations); + } + } + + public function logRequest(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + if (!$this->enabled || !$this->clockwork) { + return $response; + } + + $clockwork = $this->clockwork; + + $this->finalize(); + + $clockwork->timeline()->finalize($request->getAttribute('request_time')); + + if ($this->censored) { + $censored = 'CENSORED'; + $request = $request + ->withCookieParams([$censored => '']) + ->withUploadedFiles([]) + ->withHeader('cookie', $censored); + $request = $request->withParsedBody([$censored => '']); + } + + $clockwork->addDataSource(new PsrMessageDataSource($request, $response)); + + $clockwork->resolveRequest(); + $clockwork->storeRequest(); + + $clockworkRequest = $clockwork->getRequest(); + + $response = $response + ->withHeader('X-Clockwork-Id', $clockworkRequest->id) + ->withHeader('X-Clockwork-Version', $clockwork::VERSION); + + $response = $response->withHeader('X-Clockwork-Path', Utils::url('/__clockwork/')); + + return $response->withHeader('Server-Timing', ServerTiming::fromRequest($clockworkRequest)->value()); + } + + + public function debuggerRequest(RequestInterface $request): Response + { + $clockwork = $this->clockwork; + + $headers = [ + 'Content-Type' => 'application/json', + 'Grav-Internal-SkipShutdown' => 1 + ]; + + $path = $request->getUri()->getPath(); + $clockworkDataUri = '#/__clockwork(?:/(?[0-9-]+))?(?:/(?(?:previous|next)))?(?:/(?\d+))?#'; + if (preg_match($clockworkDataUri, $path, $matches) === false) { + $response = ['message' => 'Bad Input']; + + return new Response(400, $headers, json_encode($response)); + } + + $id = $matches['id'] ?? null; + $direction = $matches['direction'] ?? 'latest'; + $count = $matches['count'] ?? null; + + $storage = $clockwork->getStorage(); + + if ($direction === 'previous') { + $data = $storage->previous($id, $count); + } elseif ($direction === 'next') { + $data = $storage->next($id, $count); + } elseif ($direction === 'latest' || $id === 'latest') { + $data = $storage->latest(); + } else { + $data = $storage->find($id); + } + + if (preg_match('#(?[0-9-]+|latest)/extended#', $path)) { + $clockwork->extendRequest($data); + } + + if (!$data) { + $response = ['message' => 'Not Found']; + + return new Response(404, $headers, json_encode($response)); + } + + $data = is_array($data) ? array_map(static function ($item) { + return $item->toArray(); + }, $data) : $data->toArray(); + + return new Response(200, $headers, json_encode($data)); + } + + /** + * @return void + */ + protected function addMeasures(): void + { + if (!$this->enabled) { + return; + } + + $nowTime = microtime(true); + $clkTimeLine = $this->clockwork ? $this->clockwork->timeline() : null; + $debTimeLine = $this->debugbar ? $this->debugbar['time'] : null; + foreach ($this->timers as $name => $data) { + $description = $data[0]; + $startTime = $data[1] ?? null; + $endTime = $data[2] ?? $nowTime; + if ($clkTimeLine) { + $event = $clkTimeLine->event($description); + $event->finalize($startTime, $endTime); + } elseif ($debTimeLine) { + if ($endTime - $startTime < 0.001) { + continue; + } + + $debTimeLine->addMeasure($description ?? $name, $startTime, $endTime); + } + } + $this->timers = []; + } + + /** + * Set/get the enabled state of the debugger + * + * @param bool|null $state If null, the method returns the enabled value. If set, the method sets the enabled state + * @return bool + */ + public function enabled($state = null) + { + if ($state !== null) { + $this->enabled = (bool)$state; + } + + return $this->enabled; + } + + /** + * Add the debugger assets to the Grav Assets + * + * @return $this + */ + public function addAssets() + { + if ($this->enabled) { + // Only add assets if Page is HTML + $page = $this->grav['page']; + if ($page->templateFormat() !== 'html') { + return $this; + } + + /** @var Assets $assets */ + $assets = $this->grav['assets']; + + // Clockwork specific assets + if ($this->clockwork) { + if ($this->config->get('plugins.clockwork-web.enabled')) { + $route = Utils::url($this->grav['config']->get('plugins.clockwork-web.route')); + } else { + $route = 'https://github.com/getgrav/grav-plugin-clockwork-web'; + } + $assets->addCss('/system/assets/debugger/clockwork.css'); + $assets->addJs('/system/assets/debugger/clockwork.js', [ + 'id' => 'clockwork-script', + 'data-route' => $route + ]); + } + + + // Debugbar specific assets + if ($this->debugbar) { + // Add jquery library + $assets->add('jquery', 101); + + $this->renderer = $this->debugbar->getJavascriptRenderer(); + $this->renderer->setIncludeVendors(false); + + [$css_files, $js_files] = $this->renderer->getAssets(null, JavascriptRenderer::RELATIVE_URL); + + foreach ((array)$css_files as $css) { + $assets->addCss($css); + } + + $assets->addCss('/system/assets/debugger/phpdebugbar.css', ['loading' => 'inline']); + + foreach ((array)$js_files as $js) { + $assets->addJs($js); + } + } + } + + return $this; + } + + /** + * @param int $limit + * @return array + */ + public function getCaller($limit = 2) + { + $trace = debug_backtrace(false, $limit); + + return array_pop($trace); + } + + /** + * Adds a data collector + * + * @param DataCollectorInterface $collector + * @return $this + * @throws DebugBarException + */ + public function addCollector($collector) + { + if ($this->debugbar && !$this->debugbar->hasCollector($collector->getName())) { + $this->debugbar->addCollector($collector); + } + + return $this; + } + + /** + * Returns a data collector + * + * @param string $name + * @return DataCollectorInterface|null + * @throws DebugBarException + */ + public function getCollector($name) + { + if ($this->debugbar && $this->debugbar->hasCollector($name)) { + return $this->debugbar->getCollector($name); + } + + return null; + } + + /** + * Displays the debug bar + * + * @return $this + */ + public function render() + { + if ($this->enabled && $this->debugbar) { + // Only add assets if Page is HTML + $page = $this->grav['page']; + if (!$this->renderer || $page->templateFormat() !== 'html') { + return $this; + } + + $this->addMeasures(); + $this->addDeprecations(); + + echo $this->renderer->render(); + } + + return $this; + } + + /** + * Sends the data through the HTTP headers + * + * @return $this + */ + public function sendDataInHeaders() + { + if ($this->enabled && $this->debugbar) { + $this->addMeasures(); + $this->addDeprecations(); + $this->debugbar->sendDataInHeaders(); + } + + return $this; + } + + /** + * Returns collected debugger data. + * + * @return array|null + */ + public function getData() + { + if (!$this->enabled || !$this->debugbar) { + return null; + } + + $this->addMeasures(); + $this->addDeprecations(); + $this->timers = []; + + return $this->debugbar->getData(); + } + + /** + * Hierarchical Profiler support. + * + * @param callable $callable + * @param string|null $message + * @return mixed + */ + public function profile(callable $callable, string $message = null) + { + $this->startProfiling(); + $response = $callable(); + $this->stopProfiling($message); + + return $response; + } + + public function addTwigProfiler(Environment $twig): void + { + $clockwork = $this->getClockwork(); + if ($clockwork) { + $source = new TwigClockworkDataSource($twig); + $source->listenToEvents(); + $clockwork->addDataSource($source); + } + } + + /** + * Start profiling code. + * + * @return void + */ + public function startProfiling(): void + { + if ($this->enabled && extension_loaded('tideways_xhprof')) { + $this->profiling++; + if ($this->profiling === 1) { + // @phpstan-ignore-next-line + \tideways_xhprof_enable(TIDEWAYS_XHPROF_FLAGS_NO_BUILTINS); + } + } + } + + /** + * Stop profiling code. Returns profiling array or null if profiling couldn't be done. + * + * @param string|null $message + * @return array|null + */ + public function stopProfiling(string $message = null): ?array + { + $timings = null; + if ($this->enabled && extension_loaded('tideways_xhprof')) { + $profiling = $this->profiling - 1; + if ($profiling === 0) { + // @phpstan-ignore-next-line + $timings = \tideways_xhprof_disable(); + $timings = $this->buildProfilerTimings($timings); + + if ($this->clockwork) { + /** @var UserData $userData */ + $userData = $this->clockwork->userData('Profiler'); + $userData->counters([ + 'Calls' => count($timings) + ]); + $userData->table('Profiler', $timings); + } else { + $this->addMessage($message ?? 'Profiler Analysis', 'debug', $timings); + } + } + $this->profiling = max(0, $profiling); + } + + return $timings; + } + + /** + * @param array $timings + * @return array + */ + protected function buildProfilerTimings(array $timings): array + { + // Filter method calls which take almost no time. + $timings = array_filter($timings, function ($value) { + return $value['wt'] > 50; + }); + + uasort($timings, function (array $a, array $b) { + return $b['wt'] <=> $a['wt']; + }); + + $table = []; + foreach ($timings as $key => $timing) { + $parts = explode('==>', $key); + $method = $this->parseProfilerCall(array_pop($parts)); + $context = $this->parseProfilerCall(array_pop($parts)); + + // Skip redundant method calls. + if ($context === 'Grav\Framework\RequestHandler\RequestHandler::handle()') { + continue; + } + + // Do not profile library calls. + if (strpos($context, 'Grav\\') !== 0) { + continue; + } + + $table[] = [ + 'Context' => $context, + 'Method' => $method, + 'Calls' => $timing['ct'], + 'Time (ms)' => $timing['wt'] / 1000, + ]; + } + + return $table; + } + + /** + * @param string|null $call + * @return mixed|string|null + */ + protected function parseProfilerCall(?string $call) + { + if (null === $call) { + return ''; + } + if (strpos($call, '@')) { + [$call,] = explode('@', $call); + } + if (strpos($call, '::')) { + [$class, $call] = explode('::', $call); + } + + if (!isset($class)) { + return $call; + } + + // It is also possible to display twig files, but they are being logged in views. + /* + if (strpos($class, '__TwigTemplate_') === 0 && class_exists($class)) { + $env = new Environment(); + / ** @var Template $template * / + $template = new $class($env); + + return $template->getTemplateName(); + } + */ + + return "{$class}::{$call}()"; + } + + /** + * Start a timer with an associated name and description + * + * @param string $name + * @param string|null $description + * @return $this + */ + public function startTimer($name, $description = null) + { + $this->timers[$name] = [$description, microtime(true)]; + + return $this; + } + + /** + * Stop the named timer + * + * @param string $name + * @return $this + */ + public function stopTimer($name) + { + if (isset($this->timers[$name])) { + $endTime = microtime(true); + $this->timers[$name][] = $endTime; + } + + return $this; + } + + /** + * Dump variables into the Messages tab of the Debug Bar + * + * @param mixed $message + * @param string $label + * @param mixed|bool $isString + * @return $this + */ + public function addMessage($message, $label = 'info', $isString = true) + { + if ($this->enabled) { + if ($this->censored) { + if (!is_scalar($message)) { + $message = 'CENSORED'; + } + if (!is_scalar($isString)) { + $isString = ['CENSORED']; + } + } + + if ($this->debugbar) { + if (is_array($isString)) { + $message = $isString; + $isString = false; + } elseif (is_string($isString)) { + $message = $isString; + $isString = true; + } + $this->debugbar['messages']->addMessage($message, $label, $isString); + } + + if ($this->clockwork) { + $context = $isString; + if (!is_scalar($message)) { + $context = $message; + $message = gettype($context); + } + if (is_bool($context)) { + $context = []; + } elseif (!is_array($context)) { + $type = gettype($context); + $context = [$type => $context]; + } + + $this->clockwork->log($label, $message, $context); + } + } + + return $this; + } + + /** + * @param string $name + * @param object $event + * @param EventDispatcherInterface $dispatcher + * @param float|null $time + * @return $this + */ + public function addEvent(string $name, $event, EventDispatcherInterface $dispatcher, float $time = null) + { + if ($this->enabled && $this->clockwork) { + $time = $time ?? microtime(true); + $duration = (microtime(true) - $time) * 1000; + + $data = null; + if ($event && method_exists($event, '__debugInfo')) { + $data = $event; + } + + $listeners = []; + foreach ($dispatcher->getListeners($name) as $listener) { + $listeners[] = $this->resolveCallable($listener); + } + + $this->clockwork->addEvent($name, $data, $time, ['listeners' => $listeners, 'duration' => $duration]); + } + + return $this; + } + + /** + * Dump exception into the Messages tab of the Debug Bar + * + * @param Throwable $e + * @return Debugger + */ + public function addException(Throwable $e) + { + if ($this->initialized && $this->enabled) { + if ($this->debugbar) { + $this->debugbar['exceptions']->addThrowable($e); + } + + if ($this->clockwork) { + /** @var UserData $exceptions */ + $exceptions = $this->clockwork->userData('Exceptions'); + $exceptions->data(['message' => $e->getMessage()]); + + $this->clockwork->alert($e->getMessage(), ['exception' => $e]); + } + } + + return $this; + } + + /** + * @return void + */ + public function setErrorHandler() + { + $this->errorHandler = set_error_handler( + [$this, 'deprecatedErrorHandler'] + ); + } + + /** + * @param int $errno + * @param string $errstr + * @param string $errfile + * @param int $errline + * @return bool + */ + public function deprecatedErrorHandler($errno, $errstr, $errfile, $errline) + { + if ($errno !== E_USER_DEPRECATED && $errno !== E_DEPRECATED) { + if ($this->errorHandler) { + return call_user_func($this->errorHandler, $errno, $errstr, $errfile, $errline); + } + + return true; + } + + if (!$this->enabled) { + return true; + } + + // Figure out error scope from the error. + $scope = 'unknown'; + if (stripos($errstr, 'grav') !== false) { + $scope = 'grav'; + } elseif (strpos($errfile, '/twig/') !== false) { + $scope = 'twig'; + // TODO: remove when upgrading to Twig 2+ + if (str_contains($errstr, '#[\ReturnTypeWillChange]') || str_contains($errstr, 'Passing null to parameter')) { + return true; + } + } elseif (stripos($errfile, '/yaml/') !== false) { + $scope = 'yaml'; + } elseif (strpos($errfile, '/vendor/') !== false) { + $scope = 'vendor'; + } + + // Clean up backtrace to make it more useful. + $backtrace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT); + + // Skip current call. + array_shift($backtrace); + + // Find yaml file where the error happened. + if ($scope === 'yaml') { + foreach ($backtrace as $current) { + if (isset($current['args'])) { + foreach ($current['args'] as $arg) { + if ($arg instanceof SplFileInfo) { + $arg = $arg->getPathname(); + } + if (is_string($arg) && preg_match('/.+\.(yaml|md)$/i', $arg)) { + $errfile = $arg; + $errline = 0; + + break 2; + } + } + } + } + } + + // Filter arguments. + $cut = 0; + $previous = null; + foreach ($backtrace as $i => &$current) { + if (isset($current['args'])) { + $args = []; + foreach ($current['args'] as $arg) { + if (is_string($arg)) { + $arg = "'" . $arg . "'"; + if (mb_strlen($arg) > 100) { + $arg = 'string'; + } + } elseif (is_bool($arg)) { + $arg = $arg ? 'true' : 'false'; + } elseif (is_scalar($arg)) { + $arg = $arg; + } elseif (is_object($arg)) { + $arg = get_class($arg) . ' $object'; + } elseif (is_array($arg)) { + $arg = '$array'; + } else { + $arg = '$object'; + } + + $args[] = $arg; + } + $current['args'] = $args; + } + + $object = $current['object'] ?? null; + unset($current['object']); + + $reflection = null; + if ($object instanceof TemplateWrapper) { + $reflection = new ReflectionObject($object); + $property = $reflection->getProperty('template'); + $property->setAccessible(true); + $object = $property->getValue($object); + } + + if ($object instanceof Template) { + $file = $current['file'] ?? null; + + if (preg_match('`(Template.php|TemplateWrapper.php)$`', $file)) { + $current = null; + continue; + } + + $debugInfo = $object->getDebugInfo(); + + $line = 1; + if (!$reflection) { + foreach ($debugInfo as $codeLine => $templateLine) { + if ($codeLine <= $current['line']) { + $line = $templateLine; + break; + } + } + } + + $src = $object->getSourceContext(); + //$code = preg_split('/\r\n|\r|\n/', $src->getCode()); + //$current['twig']['twig'] = trim($code[$line - 1]); + $current['twig']['file'] = $src->getPath(); + $current['twig']['line'] = $line; + + $prevFile = $previous['file'] ?? null; + if ($prevFile && $file === $prevFile) { + $prevLine = $previous['line']; + + $line = 1; + foreach ($debugInfo as $codeLine => $templateLine) { + if ($codeLine <= $prevLine) { + $line = $templateLine; + break; + } + } + + //$previous['twig']['twig'] = trim($code[$line - 1]); + $previous['twig']['file'] = $src->getPath(); + $previous['twig']['line'] = $line; + } + + $cut = $i; + } elseif ($object instanceof ProcessorInterface) { + $cut = $cut ?: $i; + break; + } + + $previous = &$backtrace[$i]; + } + unset($current); + + if ($cut) { + $backtrace = array_slice($backtrace, 0, $cut + 1); + } + $backtrace = array_values(array_filter($backtrace)); + + // Skip vendor libraries and the method where error was triggered. + foreach ($backtrace as $i => $current) { + if (!isset($current['file'])) { + continue; + } + if (strpos($current['file'], '/vendor/') !== false) { + $cut = $i + 1; + continue; + } + if (isset($current['function']) && ($current['function'] === 'user_error' || $current['function'] === 'trigger_error')) { + $cut = $i + 1; + continue; + } + + break; + } + + if ($cut) { + $backtrace = array_slice($backtrace, $cut); + } + $backtrace = array_values(array_filter($backtrace)); + + $current = reset($backtrace); + + // If the issue happened inside twig file, change the file and line to match that file. + $file = $current['twig']['file'] ?? ''; + if ($file) { + $errfile = $file; + $errline = $current['twig']['line'] ?? 0; + } + + $deprecation = [ + 'scope' => $scope, + 'message' => $errstr, + 'file' => $errfile, + 'line' => $errline, + 'trace' => $backtrace, + 'count' => 1 + ]; + + $this->deprecations[] = $deprecation; + + // Do not pass forward. + return true; + } + + /** + * @return array + */ + protected function getDeprecations(): array + { + if (!$this->deprecations) { + return []; + } + + $list = []; + /** @var array $deprecated */ + foreach ($this->deprecations as $deprecated) { + $list[] = $this->getDepracatedMessage($deprecated)[0]; + } + + return $list; + } + + /** + * @return void + * @throws DebugBarException + */ + protected function addDeprecations() + { + if (!$this->deprecations) { + return; + } + + $collector = new MessagesCollector('deprecated'); + $this->addCollector($collector); + $collector->addMessage('Your site is using following deprecated features:'); + + /** @var array $deprecated */ + foreach ($this->deprecations as $deprecated) { + list($message, $scope) = $this->getDepracatedMessage($deprecated); + + $collector->addMessage($message, $scope); + } + } + + /** + * @param array $deprecated + * @return array + */ + protected function getDepracatedMessage($deprecated) + { + $scope = $deprecated['scope']; + + $trace = []; + if (isset($deprecated['trace'])) { + foreach ($deprecated['trace'] as $current) { + $class = $current['class'] ?? ''; + $type = $current['type'] ?? ''; + $function = $this->getFunction($current); + if (isset($current['file'])) { + $current['file'] = str_replace(GRAV_ROOT . '/', '', $current['file']); + } + + unset($current['class'], $current['type'], $current['function'], $current['args']); + + if (isset($current['twig'])) { + $trace[] = $current['twig']; + } else { + $trace[] = ['call' => $class . $type . $function] + $current; + } + } + } + + $array = [ + 'message' => $deprecated['message'], + 'file' => $deprecated['file'], + 'line' => $deprecated['line'], + 'trace' => $trace + ]; + + return [ + array_filter($array), + $scope + ]; + } + + /** + * @param array $trace + * @return string + */ + protected function getFunction($trace) + { + if (!isset($trace['function'])) { + return ''; + } + + return $trace['function'] . '(' . implode(', ', $trace['args'] ?? []) . ')'; + } + + /** + * @param callable $callable + * @return string + */ + protected function resolveCallable(callable $callable) + { + if (is_array($callable)) { + return get_class($callable[0]) . '->' . $callable[1] . '()'; + } + + return 'unknown'; + } +} diff --git a/system/src/Grav/Common/Errors/BareHandler.php b/system/src/Grav/Common/Errors/BareHandler.php new file mode 100644 index 0000000..36a3122 --- /dev/null +++ b/system/src/Grav/Common/Errors/BareHandler.php @@ -0,0 +1,33 @@ +getInspector(); + $code = $inspector->getException()->getCode(); + if (($code >= 400) && ($code < 600)) { + $this->getRun()->sendHttpCode($code); + } + + return Handler::QUIT; + } +} diff --git a/system/src/Grav/Common/Errors/Errors.php b/system/src/Grav/Common/Errors/Errors.php new file mode 100644 index 0000000..367d8c1 --- /dev/null +++ b/system/src/Grav/Common/Errors/Errors.php @@ -0,0 +1,85 @@ +get('system.errors'); + $jsonRequest = $_SERVER && isset($_SERVER['HTTP_ACCEPT']) && $_SERVER['HTTP_ACCEPT'] === 'application/json'; + + // Setup Whoops-based error handler + $system = new SystemFacade; + $whoops = new Run($system); + + $verbosity = 1; + + if (isset($config['display'])) { + if (is_int($config['display'])) { + $verbosity = $config['display']; + } else { + $verbosity = $config['display'] ? 1 : 0; + } + } + + switch ($verbosity) { + case 1: + $error_page = new PrettyPageHandler(); + $error_page->setPageTitle('Crikey! There was an error...'); + $error_page->addResourcePath(GRAV_ROOT . '/system/assets'); + $error_page->addCustomCss('whoops.css'); + $whoops->prependHandler($error_page); + break; + case -1: + $whoops->prependHandler(new BareHandler); + break; + default: + $whoops->prependHandler(new SimplePageHandler); + break; + } + + if ($jsonRequest || Misc::isAjaxRequest()) { + $whoops->prependHandler(new JsonResponseHandler()); + } + + if (isset($config['log']) && $config['log']) { + $logger = $grav['log']; + $whoops->pushHandler(function ($exception, $inspector, $run) use ($logger) { + try { + $logger->addCritical($exception->getMessage() . ' - Trace: ' . $exception->getTraceAsString()); + } catch (Exception $e) { + echo $e; + } + }); + } + + $whoops->register(); + + // Re-register deprecation handler. + $grav['debugger']->setErrorHandler(); + } +} diff --git a/system/src/Grav/Common/Errors/Resources/error.css b/system/src/Grav/Common/Errors/Resources/error.css new file mode 100644 index 0000000..11ce3fd --- /dev/null +++ b/system/src/Grav/Common/Errors/Resources/error.css @@ -0,0 +1,52 @@ +html, body { + height: 100% +} +body { + margin:0 3rem; + padding:0; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 1.5rem; + line-height: 1.4; + display: -webkit-box; /* OLD - iOS 6-, Safari 3.1-6 */ + display: -moz-box; /* OLD - Firefox 19- (buggy but mostly works) */ + display: -ms-flexbox; /* TWEENER - IE 10 */ + display: -webkit-flex; /* NEW - Chrome */ + display: flex; + -webkit-align-items: center; + align-items: center; + -webkit-justify-content: center; + justify-content: center; +} +.container { + margin: 0rem; + max-width: 600px; + padding-bottom:5rem; +} + +header { + color: #000; + font-size: 4rem; + letter-spacing: 2px; + line-height: 1.1; + margin-bottom: 2rem; +} +p { + font-family: Optima, Segoe, "Segoe UI", Candara, Calibri, Arial, sans-serif; + color: #666; +} + +h5 { + font-weight: normal; + color: #999; + font-size: 1rem; +} + +h6 { + font-weight: normal; + color: #999; +} + +code { + font-weight: bold; + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; +} diff --git a/system/src/Grav/Common/Errors/Resources/layout.html.php b/system/src/Grav/Common/Errors/Resources/layout.html.php new file mode 100644 index 0000000..6699959 --- /dev/null +++ b/system/src/Grav/Common/Errors/Resources/layout.html.php @@ -0,0 +1,30 @@ + + + + + + Whoops there was an error! + + + +