commit d6b981517c76e1f927ed23fad6e0bf437a6ca57f Author: AVE.cms Date: Mon Jul 27 12:58:44 2026 +0300 AVE.cms 3.3 build 0.31 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6cb2ae1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +.DS_Store + +# Runtime configuration +/configs/db.config.php + +# Runtime state and secrets +/storage/* +!/storage/.htaccess + +# Cache, logs and sessions +/tmp/* +!/tmp/.htaccess + +# Uploaded files +/uploads/* +!/uploads/.htaccess +!/uploads/index.php + +# Installed optional modules +/modules/* diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..ceb90ff --- /dev/null +++ b/.htaccess @@ -0,0 +1,81 @@ +# AVE.cms public root + +Options -Indexes -MultiViews +DirectoryIndex index.php +AddDefaultCharset UTF-8 + +# Keep modern static formats portable across minimal Apache installations. + + AddType image/avif avif + AddType image/webp webp + AddType font/woff2 woff2 + AddType application/manifest+json webmanifest + + + + AddOutputFilterByType DEFLATE text/html text/plain text/css text/javascript + AddOutputFilterByType DEFLATE application/javascript application/json application/xml image/svg+xml + + + + ExpiresActive On + ExpiresByType text/css "access plus 7 days" + ExpiresByType application/javascript "access plus 7 days" + ExpiresByType text/javascript "access plus 7 days" + ExpiresByType image/avif "access plus 30 days" + ExpiresByType image/gif "access plus 30 days" + ExpiresByType image/jpeg "access plus 30 days" + ExpiresByType image/png "access plus 30 days" + ExpiresByType image/svg+xml "access plus 30 days" + ExpiresByType image/webp "access plus 30 days" + ExpiresByType font/woff2 "access plus 1 year" + + +# Source maps, dumps, logs and environment files are never public downloads. + + + Require all denied + + + + + RewriteEngine On + RewriteBase / + + # Preserve bearer/basic credentials for the native API behind CGI/FastCGI. + RewriteCond %{HTTP:Authorization} . + RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # AVE.cms uses the host without the legacy www prefix. Respect HTTPS both + # directly and behind a reverse proxy; TLS itself belongs to the web server. + RewriteCond %{HTTPS} =on [OR] + RewriteCond %{HTTP:X-Forwarded-Proto} =https [NC] + RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC] + RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L,NE] + + RewriteCond %{HTTPS} !=on + RewriteCond %{HTTP:X-Forwarded-Proto} !https [NC] + RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC] + RewriteRule ^ http://%1%{REQUEST_URI} [R=301,L,NE] + + # The clean installer remains available before the database config exists. + RewriteRule ^setup/?$ setup/index.php [L,QSA] + + # Internal implementation and runtime data must not be addressed directly. + RewriteRule ^(?:configs|storage|system|tmp)(?:/|$) - [R=404,L] + # Module packages expose only static files from their declared asset roots. + # Manifests, migrations, views, LESS sources and executable code stay private. + RewriteRule ^modules/[A-Za-z0-9_-]+/(?:(?:app|admin)/)?assets/.+\.(?:css|js|mjs|svg|png|jpe?g|gif|webp|avif|ico|woff2?|ttf|otf|eot|mp4|webm)$ - [L,NC] + RewriteRule ^modules(?:/|$) - [R=404,L,NC] + RewriteRule ^setup/(?!index\.php$).*\.php$ - [R=404,L,NC] + RewriteRule ^PACKAGE-MANIFEST\.json$ - [R=404,L,NC] + + # Expose one canonical public front controller URL. + RewriteCond %{THE_REQUEST} \s/+index\.php(?:[?\s]) [NC] + RewriteRule ^index\.php$ / [R=301,L] + + RewriteRule ^index\.php$ - [L] + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [L,QSA] + diff --git a/PACKAGE-MANIFEST.json b/PACKAGE-MANIFEST.json new file mode 100644 index 0000000..37c4f63 --- /dev/null +++ b/PACKAGE-MANIFEST.json @@ -0,0 +1,3626 @@ +{ + "format": "ave-core-package-v2", + "product": "AVE.cms", + "version": "3.3", + "build": "0.31", + "package": "core", + "built_at": "2026-07-27T09:53:48+00:00", + "installed_modules": 0, + "bundled_optional_modules": [], + "optional_after_install": [ + "setup/" + ], + "core_fingerprint": "2a79bbbfa29729ad2c31f47f5496f632384f6abc0e62ec888b45e4a18b77b5cc", + "files": { + ".htaccess": "71c06b7bc41b09c347a562ca72768acaa0ee1ef8388c749fc67956c793d8a826", + "README.md": "f1d366f4f481b866bf67b8c35c766bbf74eb7534ab1a1caf055f47b6720b5fe3", + "adminx/.htaccess": "d03ea2d2972af8b7545a2d57c6253f303d15adeb30b775e161d887ca5ff73378", + "adminx/Support/AdminLocale.php": "a7fe84d86d65c9f021106172b1789c8bc6221c36b2fe39cf883ca9a505e3e8ad", + "adminx/Support/CodeEditor.php": "51532980b1990e595b796d884c1a8bcc46f77f88344386e5153b4bef4cae973c", + "adminx/Support/InterfaceSettings.php": "43280d104e731781213f7af119ed21130e8b29e103fbccb77ed01a1ca7441b8f", + "adminx/Support/ModuleExtensions.php": "e1a0407638b16a773bfe414c214015aef5bcd6cb4cc6fdf772c1fd1ff43ad086", + "adminx/Support/Notifications.php": "ab08805d571cd3d8a4b2a09fdc6ce96fb4c7b5ec75691322cf8a4e1a142112a3", + "adminx/Support/PhpCode.php": "5cdd56c7deddd713dd06412b64da729c8a411aa99f46958fc29121055740cd07", + "adminx/Support/ReAuth.php": "f8c2146fb4b8f03b7d0cd2f3576e3f39fe2749823c5d6f1dca494268dcaba303", + "adminx/Support/Roles.php": "6be335dd643317f77bd7c2db92f25895e601fe72818befd62f6aeb3ecac9fce7", + "adminx/Support/RouteGuard.php": "e9e20c097c0db354851546585be72bfc71a5d14652871f8bd1160e845d4dab1b", + "adminx/Support/SavedViews.php": "4548ca129f270184e61cb670e7f44a1c103099a6a6c2820bed1a11bc18f227c4", + "adminx/Support/SectionHelp.php": "c12ff274abc7f508d7ec28f02c770acdedb62390e1661914ecf740a3ea8b62f8", + "adminx/Support/SensitiveRoutes.php": "8d40c429d6abbc1416e3797908672e551d2b3abe64cb1763c6c410e7edb9dbce", + "adminx/Support/SystemFeatures.php": "f18ada623710f8c3efc6588c037227f6074e75bad9a3820aeef60b4d8d4b20a4", + "adminx/Support/SystemHealth.php": "286aee452e90f71ad64437bd39d6dd985d312eb85bbb711490d3a0b631a16aea", + "adminx/Support/Twig/AdminConstantExpression.php": "44bb7d2d978e3c873d457ba1ca7b8021c6f5292e1a4f8b74e49348d01dc84b1d", + "adminx/Support/Twig/AdminLocaleExtension.php": "1fb6c1d1c35d74fdb4a578cd15b3f19441725db6f11b336dc6948ed982842f3a", + "adminx/Support/Twig/AdminLocaleNodeVisitor.php": "ccf387680fe8b0e11bd0fe0d23769be8b7ed9afc363cb9a802db5c4aeb7d405b", + "adminx/Support/Twig/AdminTextNode.php": "e51d8d3ee49411f42c8a874cd283f60c1fa0af6506b34568a119ebe2d18d7268", + "adminx/assets/css/adminkit.css": "4c8371e04dd1d31058f9a17e4ab741665e94aac388d7307b8115ca613c562c5f", + "adminx/assets/fonts/NunitoSans-Bold.woff2": "2d48580b7e1be60fa94b8092ebe86471fe0adfe5ca75242a7af2042ac9da825f", + "adminx/assets/fonts/NunitoSans-ExtraBold.woff2": "66612ee9d1cdc341c9eec8a43104bbf515e7cc52821558bfc7dfcf33c8cb0cc1", + "adminx/assets/fonts/NunitoSans-Regular.woff2": "878adc25278872933f004783f0ebbe092cebb3f9161aa30cffb2b9a8a07db040", + "adminx/assets/fonts/OpenSans-Bold.woff2": "d801f2d20d5201944e8354efaf063e8627e2b3cf36c8e20cc5fe917a90c1e894", + "adminx/assets/fonts/OpenSans-Regular.woff2": "a26ed465673123933c5510730a7286f49016a40fa94062021d1dc4c9bfeddbd3", + "adminx/assets/fonts/OpenSans-SemiBold.woff2": "7d963e1d2f61c11c2e8bd2eb528777b5a55569708d50a7ee7bd097d7fafe6da4", + "adminx/assets/img/logo.svg": "685d9bf037b33fbcfc200947d5051c5325762f8ab8a29ac9e1e587076321e463", + "adminx/assets/js/adminx.js": "dbcfc5ea3918f551e7d1b9dfaeca6f2637b763403434e25d4d17cc60bbe91162", + "adminx/assets/js/editor-codemirror.js": "90a04c219e84f5391a40b0d1fc807ecfe4ca582a198bc202bdc5aa6dccec51fa", + "adminx/assets/js/editor-tiptap.js": "9bf4fd26e441bb5e53aa73ec68fc8cd481399d0660446cc6e27cf1521711092c", + "adminx/assets/js/media-picker.js": "cc2374621634fb4430c066f0318cee326becf724cb4a5f9810b325740a903ed3", + "adminx/assets/js/saved-views.js": "30c02121634f6f035dcff6a730e6fe300a1892fc9321971f142dcff600802f30", + "adminx/assets/src/editor-tiptap.js": "5639dd0d6f844aa4066fa89833b4e7f63ffda2b7bbca40e3edef8959af02bf57", + "adminx/assets/vendor/codemirror/addon/dialog/dialog.css": "5df690d771f12118348562dd2a90060433ae2edeccd0908abcaa4480b1e3e468", + "adminx/assets/vendor/codemirror/addon/dialog/dialog.js": "1eea0e500dce10d8594d8d681a91d1b71a4209355c9c29885e86e26b2d76685e", + "adminx/assets/vendor/codemirror/addon/edit/closetag.js": "700b8aa7c46e040287e32ffd1edb86a42d09edc5b2a9594241590b154c0232bd", + "adminx/assets/vendor/codemirror/addon/edit/matchbrackets.js": "5068c4c078aa75505850d2b7993bd805a8d0396202ddfc37429b8247010f9a8c", + "adminx/assets/vendor/codemirror/addon/search/search.js": "8949cd96092b539263f282a5db3041093984488db1a57c19ad35fe6abc5210a7", + "adminx/assets/vendor/codemirror/addon/search/searchcursor.js": "0756923c498eb59d0adeda03a45c2ae07dd5de76ed0a923388e1cd386f34c249", + "adminx/assets/vendor/codemirror/addon/selection/active-line.js": "8a8862483cbb09b0c8f11536dac87664d4e42106f8e088c21c70871cbe2866b0", + "adminx/assets/vendor/codemirror/index.php": "39b8fa45e136761e8349fb97163fedc7ed77d9447b6bb13a0e93a9d01d043e55", + "adminx/assets/vendor/codemirror/lib/codemirror.css": "3d6583942b0ef9beab287cfd76005e4b972a4e605b79b7c0b4d5e712acb75ef7", + "adminx/assets/vendor/codemirror/lib/codemirror.js": "8edab9a4e41d0ae94506f6d931760973ed61f948bf458468fd86f42c6454eb58", + "adminx/assets/vendor/codemirror/mode/clike/clike.js": "d3642e97a0564afd9e528f7edf7aa067a87da4b376ce523fc23afb8751c8a8b6", + "adminx/assets/vendor/codemirror/mode/css/css.js": "5ffb5e26a1d751c71f59605ece20311a3782ee3974ec66b0fc59332bbf1432cd", + "adminx/assets/vendor/codemirror/mode/htmlmixed/htmlmixed.js": "7fc77809d3b06ce7a567a2136e0d087d95e2e5b78a27aecfb69a3420d0637064", + "adminx/assets/vendor/codemirror/mode/javascript/javascript.js": "b7fc5c0406a44eef06390d236469e0a4db8d451e4c7fbe58f9d2014fd5ed7200", + "adminx/assets/vendor/codemirror/mode/php/php.js": "fdc315d51b4dbc014815988dbda01a4440e644b8afd831077d1c2b4ddd121395", + "adminx/assets/vendor/codemirror/mode/smarty/smarty.js": "b2b63a3345b57f981de5b57f5af1dc1e420af67e08c5d1268ea7b294ec5f6590", + "adminx/assets/vendor/codemirror/mode/smartymixed/smartymixed.js": "8f0343824d0466346d7e43d29a35bfbec3837b684b316b3806c7313aa0af059e", + "adminx/assets/vendor/codemirror/mode/sql/sql.js": "a803f06c3a3bcf10b130cd1fae71d46cea6f7ef1ed44addfda7ccf82983d21ce", + "adminx/assets/vendor/codemirror/mode/xml/xml.js": "ca11cf5446cc70709bd133adbfa2deabc7f754455edfe3add280b2f372be8efb", + "adminx/assets/vendor/codemirror/theme/3024-day.css": "7c9126a1d8a38be70a6d96872f8af03215203f3c6595975314cc4f75f792b8f8", + "adminx/assets/vendor/codemirror/theme/3024-night.css": "93ada2f28acc83a589c2d65147497ef5732237b2a9e6dabb3d69d486e1d3a447", + "adminx/assets/vendor/codemirror/theme/abcdef.css": "19f003c8687837b195a768d39a015fae149485ef354726161b511defaa020f99", + "adminx/assets/vendor/codemirror/theme/ambiance-mobile.css": "356cf9d539a70462808d76851e24bb0049676621d00b6d851c2b1ee2b0f6ac57", + "adminx/assets/vendor/codemirror/theme/ambiance.css": "499ce8dd8badcb5f6c622820cea32fe368497388da7c5d458699942f07b0d555", + "adminx/assets/vendor/codemirror/theme/ayu-dark.css": "7b847f4f96d2fe219d287d9ec95a5cd4760e623ffd6f73836b7126a09439a0ad", + "adminx/assets/vendor/codemirror/theme/ayu-mirage.css": "a745e9453de1fbc5fefcd7b3688072a241c58fd85c5e3e8d0cd832a699ab163b", + "adminx/assets/vendor/codemirror/theme/base16-dark.css": "7a6192764bd3da158f07560942f47c6efb315dcc462b77e32d4fa140c7fd2241", + "adminx/assets/vendor/codemirror/theme/base16-light.css": "8231d684da4a0351e1921b325af06041b79e2e4e9f0aa1d053f15d2148e3eff6", + "adminx/assets/vendor/codemirror/theme/bespin.css": "d43da55a7859eabd94a7735eff8205c136812e2bdb037f85ab026320fd9b7b43", + "adminx/assets/vendor/codemirror/theme/blackboard.css": "54f0811f00d130b3c3283cf679e7fbdcb8f93a6da88ae4011739220a816ce451", + "adminx/assets/vendor/codemirror/theme/cobalt.css": "3acd2a2cd9aefaeca3b41a451e26408400e0d958b8e845ad4bcd5edf5ff901e0", + "adminx/assets/vendor/codemirror/theme/colorforth.css": "47f95a4cc84b64cedb03dcaf5337427cee85dd4495822acb30f7e1bab83bd687", + "adminx/assets/vendor/codemirror/theme/darcula.css": "fc3056faeebd99116991a794b87fee2299662eb480bff47dae107d3fe6005625", + "adminx/assets/vendor/codemirror/theme/dracula.css": "02fdf6049608ddd8d0d7cf6403b6e5b251330740dfc393774f68bb64727b3c6e", + "adminx/assets/vendor/codemirror/theme/duotone-dark.css": "438636d2fa5b25adfe3b9144481a029e6f4bc27f5a6f8c2e2c394dd5cfe1ffb4", + "adminx/assets/vendor/codemirror/theme/duotone-light.css": "510d10e3a3bd1f695c9eb034a8186834f9b20986fd8c41bc1484a58c9f3ae3c9", + "adminx/assets/vendor/codemirror/theme/eclipse.css": "5a1cc914b42631e60dcf7a7efd412a189733107ae06508986b0cd7efa8239eea", + "adminx/assets/vendor/codemirror/theme/elegant.css": "02df81678be8cf0a780e430395b0f64eb778feac2216a3b1ecab52848abc8fe6", + "adminx/assets/vendor/codemirror/theme/erlang-dark.css": "f3286c8dda18ca92aded2aa39ad75ff5167c4c07caddd191f1ba8a3251ff7d48", + "adminx/assets/vendor/codemirror/theme/gruvbox-dark.css": "e3f475d558609f612918d7c0e614dd7e20b4f8bc1b15c532fcc537619184cd6d", + "adminx/assets/vendor/codemirror/theme/hopscotch.css": "2074da3b102459c64bb9d760c790d5dfbc4a1805a026419fe9494918adca5d54", + "adminx/assets/vendor/codemirror/theme/icecoder.css": "ae8270b75ecb44407b1cab60c4ef335f961462477aa90cbe0b4a9a4cf06dff8e", + "adminx/assets/vendor/codemirror/theme/idea.css": "c52c1d627b1ed6ad74293c28ae35553f9de96f2fff926f7e4018618247cb3c98", + "adminx/assets/vendor/codemirror/theme/isotope.css": "05a29f6e56dbdbaf74c75c585ad66ba6f4313dafc06e8f041b343fb851421ab1", + "adminx/assets/vendor/codemirror/theme/lesser-dark.css": "b772ec1c4b63a8f31af9bc9ee4361a02b5b660cf238ac0a05694aed8da8330a0", + "adminx/assets/vendor/codemirror/theme/liquibyte.css": "b799a2bc34fd750b6c8b10a3e0c40fea2ee03504799d13bb7545d11ed1f5b355", + "adminx/assets/vendor/codemirror/theme/lucario.css": "a3dcbebff7ba256321b92ea4b034fc15cb8fe081010fbc6760bd59a0dff8d06f", + "adminx/assets/vendor/codemirror/theme/material-darker.css": "5835aabae48149dbf6d47cf61160155f030e33882b4a80c35f9a3d60ed068680", + "adminx/assets/vendor/codemirror/theme/material-ocean.css": "2e5444ffd9d91f0f29596f364a390d3e860be50cbce5f5a48f96ab16f121cc23", + "adminx/assets/vendor/codemirror/theme/material-palenight.css": "9de6ac1e59a8d3cc45d55a5b5eb198f116f77647e2a6ee4917d67b734324002b", + "adminx/assets/vendor/codemirror/theme/material.css": "60029b4251daa621da342f982a7ee7e3398dd6d77a65ab348aff375abf4283b7", + "adminx/assets/vendor/codemirror/theme/mbo.css": "23f2216dd2d4ffad8a6cec20dde749f90daf87b45b98f35e19816e54552831c3", + "adminx/assets/vendor/codemirror/theme/mdn-like.css": "0fe4efda34252164bbf50f2b44cf51e985af27928150310dcd1022df3bee64fd", + "adminx/assets/vendor/codemirror/theme/midnight.css": "0802a6173fac71fae408d2a6189dff08e014482a2e13fa452750cec9155e9968", + "adminx/assets/vendor/codemirror/theme/monokai.css": "2463dc6fd92019a0c7ca2aaa01d03116b280fa7c6ae01bf21df7c107d9b683e8", + "adminx/assets/vendor/codemirror/theme/moxer.css": "f88c5ab1daba0ba8526ddd02fc10aee8fa746e4b2c7f01b91229e38a33d3602a", + "adminx/assets/vendor/codemirror/theme/neat.css": "58c2c2e5bc69c2f2e2a2e619a3799a0bd70a8754c137104daab4a39e53f42503", + "adminx/assets/vendor/codemirror/theme/neo.css": "a919e6daa7d3fd0e76fbb0c2dab8268f6f998d381bf6cd76802eadb8b6f4b6fd", + "adminx/assets/vendor/codemirror/theme/night.css": "0c5dde41710597bebc4e4213244b888be77ee451c7e394d8a3a2ea9a88703ee5", + "adminx/assets/vendor/codemirror/theme/nord.css": "a238fa5f171a8b612170eb67cfdd0f1c4c731ac5e10d10ea6fdf1642dd1ee1b1", + "adminx/assets/vendor/codemirror/theme/oceanic-next.css": "a4357f020adb30ff7810eabefa84da32f910052b05a63395826984bdf238bbdd", + "adminx/assets/vendor/codemirror/theme/panda-syntax.css": "c6d5827216777e2c3014c07110a7082444379b115260c6d4e0244cfa7eddf820", + "adminx/assets/vendor/codemirror/theme/paraiso-dark.css": "fc551f1a32ca11d4023af6dc87ee3785fbb5fe5044cdac12057f0af9c1731d97", + "adminx/assets/vendor/codemirror/theme/paraiso-light.css": "25ff9301f48317d13586f74c95eb8e665fb0e8e1364b41b6219d218a96d7f5a8", + "adminx/assets/vendor/codemirror/theme/pastel-on-dark.css": "d13d34489985a2a203d321f15afb8f6b681b74e16b81e8c80c8b8ad9df56159e", + "adminx/assets/vendor/codemirror/theme/railscasts.css": "e9ba2e428ab0547969c02c2e76fbdcc7ea296165620c652b220fa7427989ba48", + "adminx/assets/vendor/codemirror/theme/rubyblue.css": "dd8d9e324b7e04d114c83ceb8301f892e99dce2e9e17ed6a19bb4c1622be384c", + "adminx/assets/vendor/codemirror/theme/seti.css": "7af4f98f42f6bcf74a74f606ecd6af6bdc6c5549be258d7726e9a89367b5d12d", + "adminx/assets/vendor/codemirror/theme/shadowfox.css": "58d588462fc40bbf090491907ffe6694b09406c76f88b7bb08e7acc67f497904", + "adminx/assets/vendor/codemirror/theme/solarized.css": "916d1a310867b799d368088184302d22502fb6bd7e92de493f198530b6b0f868", + "adminx/assets/vendor/codemirror/theme/ssms.css": "f96e548ae553d03312feafbf4c4aabef3b026cf87f0346ae754e5ea1f97d7e13", + "adminx/assets/vendor/codemirror/theme/the-matrix.css": "ff123c011dca5c5c4b1ae7650f98afbf0e4eeb49f5a02c44e848a802ac5fd07b", + "adminx/assets/vendor/codemirror/theme/tomorrow-night-bright.css": "45ebddfc249b14c21d4e67f4671812ac874c59f11fb4ec27f20c3ce50e7b7103", + "adminx/assets/vendor/codemirror/theme/tomorrow-night-eighties.css": "6b015d31eaaaa08bd35d85582ceabd11bc2b6f708c1a052c0f7a9450ce2366b1", + "adminx/assets/vendor/codemirror/theme/ttcn.css": "9ed02395f2a6de9f96ce814b67ae73c4facaad6c0c48f7cfe659bcd3dffc5310", + "adminx/assets/vendor/codemirror/theme/twilight.css": "96d029308363b671bc25f30fd439d1704ce4505b8c36297e3ca411a0045df23b", + "adminx/assets/vendor/codemirror/theme/vibrant-ink.css": "61ea92dad1d7104298dbe749d8a02fa97ea8bdc1616e66e5cf700b8ec0862c9d", + "adminx/assets/vendor/codemirror/theme/xq-dark.css": "cac8e85d76159c45b663f229cc07057e289bcd9eda42f7c7e9c8ad395d7e8c74", + "adminx/assets/vendor/codemirror/theme/xq-light.css": "296975411e50d1a448544dd8d09503e01ea1e3cb91c68f507182c2038b302d39", + "adminx/assets/vendor/codemirror/theme/yeti.css": "f058bfa68018f67b8d165ab420447c9c99ef589be08dd0fe429a4adb7aad1f07", + "adminx/assets/vendor/codemirror/theme/yonce.css": "3027100dd387290cc4f9fccf0203fa5fa035dc021aa66182aeb755ac05bce1e4", + "adminx/assets/vendor/codemirror/theme/zenburn.css": "47780bcc6d429d434392aed5b63c26efed6bce3d34df1158573700ee189eab2f", + "adminx/assets/vendor/tabler/fonts/tabler-icons.woff2": "cce741fcb6c526dfea3c0d48ff486279ac723617cd5b4de2bd5d726e94cef477", + "adminx/assets/vendor/tabler/tabler-icons.min.css": "676509f34c06d34b75e4227e4c36105e5d220b458f609fffec247f20a8d72a8d", + "adminx/core-permissions.php": "9ec2e97e40c625f1878249232db60569747098dfb6c81fc0a3654d09807e6daa", + "adminx/index.php": "493cb39959a6b58903bd7dd470930b8f69ed859cf596cda1f8076b08579e97b4", + "adminx/language/en/client.xml": "f80a1538b75c0a727db745f21dac354c99693f13a952a26e36d7fe2687a54e59", + "adminx/language/en/common.xml": "e5f9972786f1d2a8e05f86913b07c361b82811f99cf51bee6d38e9ed275fc995", + "adminx/language/en/interface.xml": "80ccb298e1ae8ea31c533832072b99c1eb1369871f6d1b357635aa59622d9d52", + "adminx/language/en/runtime.xml": "3d96eaeb04460caf6230d087cd23eb29878a5b9d047556fdac2a33674105728c", + "adminx/language/en/section-help.xml": "f82ba8d3b78d06d26b92afb81e036b241c8dd3ef9e4ded6158a0be10491e90b9", + "adminx/language/ru/client.xml": "841a585b544fbdad086db647fb8d0a64678a5947b84f10bd9fc8a10efdadaf78", + "adminx/language/ru/common.xml": "d27d863bf82591dbe857317d97a888a23980c87529e2b4c1d4125a6c0ba20966", + "adminx/language/ru/interface.xml": "03fa6351745d2e47c2b3407da65c8b7b048b8e862409dc9bb934e939b9439c75", + "adminx/language/ru/runtime.xml": "35c0d0a201b45a83169b54fdd4633b4b7f254e68f25e8b2872b3d5aaf417a363", + "adminx/language/ru/section-help.xml": "e821c8589580e4fb898f6f7352691f81348303edab99c338c942511c9bfdca65", + "adminx/modules/Auth/Controller.php": "bad2d7fe5f7eaf4b7f22661945d95df5a3201498b2920e17de5ede6f9f560686", + "adminx/modules/Auth/language/en/interface.xml": "175f757bdc70cb47353a379a6a2303d4903ae7d446fd9a1362919a35517d11a2", + "adminx/modules/Auth/language/en/runtime.xml": "2a549aa418e35f02d97ce89bc9a88103ac39153526e2c0a11deb689920a3da69", + "adminx/modules/Auth/language/ru/interface.xml": "f8d95a4a8039dabdca7d5f3095feb4904714225b1a285527fd43b0cab65bcf79", + "adminx/modules/Auth/language/ru/runtime.xml": "94f025e2ddd2327ced6aa4740dba80025ae1d71b08f2ce6bd7de9a222277e058", + "adminx/modules/Auth/module.php": "da1ef0924fdaee46a2c1333c43ed05d3afcb02ca16f1994b0eea840a657a4f12", + "adminx/modules/Auth/view/login.twig": "9848bfbd9da64798c3ba4508dbf7bd413541c3e6870ea95fe3bdf07d12133b95", + "adminx/modules/Blocks/Controller.php": "891d958a63e9bc60eeb947b5535a61de3f338428aba9c3f9e38b8dd72c27c880", + "adminx/modules/Blocks/Model.php": "1a6d971f4a777e3f91385271ae8e207bacb10cdc1e236921f43a9578f6f9bcb5", + "adminx/modules/Blocks/Revisions.php": "b67cfbad6830a30dd7f99f5dc4108b33f2bc5d40fe979cab6fb03c3c8f749577", + "adminx/modules/Blocks/Syntax.php": "9b2eb8d40171633cdbef6cf21545958f801f4983d62176e9256bec8e4763a624", + "adminx/modules/Blocks/assets/blocks.css": "a5451ed2acea3a5fd42771188dfa8c85b6911645699e0c219fda519b6175e2e9", + "adminx/modules/Blocks/assets/blocks.js": "efcda86cd75c8bc2bce2b9ebd32e1a1c51da15119f65f7aa63e45c8d7dd73970", + "adminx/modules/Blocks/language/en/interface.xml": "6b358b561f81febe1a3284045ff20132737e624a04b2481b51681e258f748e22", + "adminx/modules/Blocks/language/en/runtime.xml": "cd76ddcfb5751496dc4cc160dc2c6f38ae5699a3a5c0041b5fb214fa434f04e1", + "adminx/modules/Blocks/language/ru/interface.xml": "6048c75a95fb474287cf4903fe1b82f2608741b355134be885eadf511da5b656", + "adminx/modules/Blocks/language/ru/runtime.xml": "594a41e03eb86238ad18a45dc5c0f71b15e8f8eecc2a912e3f9fb5439bbedc4c", + "adminx/modules/Blocks/migrations/001_create_sysblocks_tables.sql": "492d9c428f5baa305c420821cef852ced24cca6fb661d12ac57d79453103a335", + "adminx/modules/Blocks/migrations/002_create_sysblock_revisions.sql": "7a5d78fe304edcb48f9bf8891c99f60c1c295ead95444bc59883a8945b074f35", + "adminx/modules/Blocks/migrations/003_native_public_document_context.sql": "3bc0481b3f85c799436a7f70262b8bd3b5cb87f1b3c4c87ac30393c03cce78d2", + "adminx/modules/Blocks/migrations/004_native_database_calls.sql": "e2d80eb748a00bda506826321b5837c5056774d835312382c6517c0f26828320", + "adminx/modules/Blocks/migrations/005_correct_native_database_escape.sql": "cf313f8f4644d04fe87b6f4f955e5828ca172462853cd6a2495ed92ce3c4cd3f", + "adminx/modules/Blocks/migrations/006_native_public_quick_edit.sql": "8a726be0cd16231b2c3de0d545b090a6974c8faee8f8d2d4c44d54c58f665efc", + "adminx/modules/Blocks/migrations/007_cache_safe_public_quick_edit.sql": "87238cbaa8bd027909226d1fe7239cf17bc3a7d80f746037f9b5b7284aba855c", + "adminx/modules/Blocks/migrations/008_deferred_public_quick_edit_id.sql": "05e6bbc9c4bf69fac86bbbc02ed8f331e3fe3a9d8041effe9cfb3aa9feb854ba", + "adminx/modules/Blocks/migrations/009_disable_public_php_endpoint.sql": "51df49d8b8d90188aa8cce882b3222ecf3a75a9afd84297caf2c33f346678db0", + "adminx/modules/Blocks/migrations/010_disable_retired_catalog_endpoints.sql": "c80e95a32c596ed073ca1e15ae027f2e3f813d67867c31926ea5060e3ff5dace", + "adminx/modules/Blocks/migrations/011_sysblock_editor_mode.php": "e8c4046116dc2241b1eed3cb677515497fc07c25425628e4d349688df698743d", + "adminx/modules/Blocks/migrations/012_merge_visual_blocks.php": "5c9cd6efd66a027134621859da1059b2c403bacb732885d6b405699cbce8fc14", + "adminx/modules/Blocks/module.php": "e196ec71bc17da48f9e1663f74db4a7de8fcd28d0f32a3f36beebd8247656322", + "adminx/modules/Blocks/view/index.twig": "39f9225b6a95c65549d0c861afb7db7879ca338c251290653dded6ab7cb715cc", + "adminx/modules/Catalog/Controller.php": "ef5819e817071525e6c987194d2569455c0d6be5ad76b4dff6b30e76019159a7", + "adminx/modules/Catalog/Model.php": "e7c7cffd91754e04dac322c31a64b420739c671079a7f8e7effb550f5019ee13", + "adminx/modules/Catalog/assets/catalog-drawer.css": "07ac184a0fbc18954117d5e4d559574400cf6cb7d9b511d852120c00cf17a059", + "adminx/modules/Catalog/assets/catalog.css": "5587eed0a0c7d22fb8e841b238df2f9477857b0c75d90f232c5816897ee6bfce", + "adminx/modules/Catalog/assets/catalog.js": "24c1be107fdc88889a0b537b3cc93b32dcf0d83bfe97fefdf9bf10012a132b31", + "adminx/modules/Catalog/language/en/client.xml": "9b156a155a1e6f1a2024f4be5ab8d1051b1ec2adf4b9a0ede71afe11e6d4558f", + "adminx/modules/Catalog/language/en/interface.xml": "1007d3fb9158846dd36f519d13909502e16d3c0a9ca2c49dc24bec088c487ea7", + "adminx/modules/Catalog/language/en/runtime.xml": "f30a0b64665a64de1682251402c720bc9e526138f682c5dd2e9fbf3ffb87f2b0", + "adminx/modules/Catalog/language/ru/client.xml": "a4747370c4510ad0a56344ce3ee79d460585363253e983053b6743e613132a51", + "adminx/modules/Catalog/language/ru/interface.xml": "15d8de452eef296375ba40d557ce15ac0363881a7b763b7111c8cc221e9861ff", + "adminx/modules/Catalog/language/ru/runtime.xml": "fe59945f4ceb2bf2d85699623369e7e5889613b8cecd8551a390c78023531d14", + "adminx/modules/Catalog/migrations/005_add_catalog_purpose.sql": "006ff237e25042a4fa9b9f74d5079741eeee1aa52db0cd996ea4c8b817e6700f", + "adminx/modules/Catalog/module.php": "6d8b0d0bc67d314c3aee6f5813c36d32b6f4bc0de884515e13b059927c3aa9ce", + "adminx/modules/Catalog/view/edit.twig": "6ebdc547619025da9fb9b177875c715b8d9bb5ee6ec3e059e309c1ef7483f931", + "adminx/modules/Catalog/view/index.twig": "754c7c4dc81176e8c99b3d9e8c1fda2f62f03df341c3df1f7393f72d43c6f8e2", + "adminx/modules/Console/Controller.php": "af0309a50d7f4366279be8ea12959cf7718a3c68b895a65ec5846554fd6a4857", + "adminx/modules/Console/Model.php": "53aad480b8d4e5d68c4e9345d219ea5cbff6d04eee7ce4aa15deab6f00dac1cc", + "adminx/modules/Console/Runner.php": "69d38df0f96d7de1d2fdcaa0e62c25c545854cfc29a26f9f9375059c1465e906", + "adminx/modules/Console/assets/console.css": "e2645bf876bfeafc15e6f4e5ad9889adb773aea35ddc42ff151d8ed1f8866eef", + "adminx/modules/Console/assets/console.js": "f0f2cec892c4b5cdeb11730bf42e9cf68ff779974338086cdf92618ebcacdb10", + "adminx/modules/Console/language/en/client.xml": "02bef78a30b83340eb8968297639b6283dc642236e853bef413e5b7d2ef70315", + "adminx/modules/Console/language/en/interface.xml": "089a1e8498b482d1464d5b1bb8584744a44db76676fd005d035f252a6aa1a7c5", + "adminx/modules/Console/language/en/runtime.xml": "f5e7e0fdb46831867fea6d8119584405c1f2c127110a1b45cac411edea502f74", + "adminx/modules/Console/language/ru/client.xml": "ac6d8862da76c4faebf1d4432103d173fb36baa67d2b39c3eebce5c1dece4589", + "adminx/modules/Console/language/ru/interface.xml": "55f68a6bb3fea3fbaf953482b36593a1f3e585d3a99b6e2da30d6c097915ed08", + "adminx/modules/Console/language/ru/runtime.xml": "441ebbfbd7f0e21cef5d298ec5fe314d3236cddd57e05f05fe60eb1ac3d1b8b0", + "adminx/modules/Console/migrations/001_create_console_snippets.sql": "70272e04d744eca3d48416d9ffc83d78ad00a02d2a6f68bc388618e350c427e5", + "adminx/modules/Console/module.php": "1ff91297cbdf4a9cf0f83262f44b720d52ff235e59918b0f45544d26a079eff1", + "adminx/modules/Console/view/index.twig": "c64fe9632db751357ed98cf2ddca524001e71200297e1b50f0d5c827483933af", + "adminx/modules/Customers/Controller.php": "1aebb30a2d85343ace1719fca451a105cfe9e79cd3fcde6dbf8b6d907c2dee23", + "adminx/modules/Customers/Model.php": "df097775eacfe0aac5c3b7ad4fdab7ff0816f57b77f94ed7993acd221725a3e6", + "adminx/modules/Customers/assets/customers.css": "adabc3a85737d66cb4042a4262f4ed703643b469c379468c5b7e626131fd2f9e", + "adminx/modules/Customers/assets/customers.js": "babfd39619f4a45717e98b15dc3ec29976f7206a805880b0735c10a5de601b6a", + "adminx/modules/Customers/language/en/client.xml": "21310e86bb84cc1fbaf65fad3f09bb4d058e820d919a9af6ae5e2700c2e8d1b7", + "adminx/modules/Customers/language/en/interface.xml": "573a7ca126beeaff77a8865d6114dac0429b588d8061f939c5ed7af316d4070b", + "adminx/modules/Customers/language/en/runtime.xml": "c9e6e3c654f97f0a7d047af51e07958a27da21659635f1a04d463070d4a112a3", + "adminx/modules/Customers/language/ru/client.xml": "45b4958eb3dbeebb403440054ad640c4e548c15a0b68c3edacd95abd8b936e61", + "adminx/modules/Customers/language/ru/interface.xml": "3a469c1a855f36613b56e3a5e3073579b849663c3639fae753f0b281ffb67e0b", + "adminx/modules/Customers/language/ru/runtime.xml": "2733e5d3af9ba28fe9225f1205344773d1876d6c0e6e72e943c71b91f13ba62d", + "adminx/modules/Customers/migrations/001_normalize_public_groups.sql": "4cad06703e4d5b810e425ff5f440e7e0bea493d93ee261cd5195cb5c28443698", + "adminx/modules/Customers/migrations/002_public_auth_schema.sql": "478f539ca45144dfc78395f5f79854bae1aaaff8e07cdc455132c1075cf4fe9a", + "adminx/modules/Customers/migrations/003_normalize_last_visit.sql": "3183a3a96915629ceda11a18fb8552d898fdccf64c448fee334089d3a32be935", + "adminx/modules/Customers/migrations/004_materialize_public_auth_schema.sql": "682fd96a76d21e8420c938378832f00691fce80963a798168827995234f765cd", + "adminx/modules/Customers/migrations/005_checkout_registration.sql": "4e5ac6d4d5868f9e1c91b896fc03ad0982d5716287101e721a3820d21abe1744", + "adminx/modules/Customers/migrations/006_phone_identity.php": "97e8aa457fd8f8ade781a03a98bdc7ee03cac5734782f9e72fb0b76bdbd2cdbb", + "adminx/modules/Customers/module.php": "ecdbc7004a9096bccf8064c6ac2fc78262966dfa74e463069edd21b47f6c50b4", + "adminx/modules/Customers/view/index.twig": "5f19c30e03367b0bcbc83256d727eb19ee045ab3475521531faf486a4d446fc4", + "adminx/modules/Dashboard/Controller.php": "7e6ad0610df7581bb010cee574d8d093c4737af439bd2b0763142f50ddee1c54", + "adminx/modules/Dashboard/Widgets.php": "56fc7ba7c1f9e30d7eae8c61524b6441b1c706a2cd56b1db883bb8bff7186eed", + "adminx/modules/Dashboard/assets/dashboard.css": "b38927851888ccb5bcaaae3c22243ff7d727b1ea2a585053d935a404908e4900", + "adminx/modules/Dashboard/assets/dashboard.js": "ac683db96673fb4f70b8cb2d23ab7eda2251f96cc2852223f5ee68bd11a911e7", + "adminx/modules/Dashboard/language/en/common.xml": "10445e46f7445366125f761c2d29567637d6d2c9ba1e9766136280ff28c3d465", + "adminx/modules/Dashboard/language/en/interface.xml": "58a20edaaf485d5a3131d7684974c2d18bedea408a1e28f5f47fcd3fae025159", + "adminx/modules/Dashboard/language/en/runtime.xml": "ea0f729fe25b2554614d22cbcf320f518fe8521411eb76469cfbe1b5aa259767", + "adminx/modules/Dashboard/language/ru/common.xml": "877574f94ff436cbd64a81fd1b8448efb75244b2fbdc258e9a57f4eaf47a6078", + "adminx/modules/Dashboard/language/ru/interface.xml": "b1a030382924d5a7adb4e0f9db56da95723b055791da48f18ff75bf535179cbd", + "adminx/modules/Dashboard/language/ru/runtime.xml": "700418374efc27723967f14c0db8a7314586c3e4de819ddda1a734dfaa4a4966", + "adminx/modules/Dashboard/module.php": "3c6939a1b5d07948a45f18287b5f95685a542db68cd18ac7685735e3c2572845", + "adminx/modules/Dashboard/view/index.twig": "00ef6338aa822c33acbd74d3cf38f4db35e2999a7d9e053115fe529fcaccf8d0", + "adminx/modules/Dashboard/view/widgets/recent-documents.twig": "d03f36f047574f8ce57fdcfd79b26d9b20ca01dba69ce7c71a3e9a277f0105d0", + "adminx/modules/Dashboard/view/widgets/shortcuts.twig": "af165425ea7889cdc3edbd72c7f0dd9dc5c4c06486bc4ab37816112cfb696718", + "adminx/modules/Dashboard/view/widgets/system-kpis.twig": "52dab540a189443b76811ac4b457a566cf90134366749aa9c7d82e2d23ffdaa3", + "adminx/modules/Database/Backup.php": "17e115967f1468803c07a1514df84ae9111b8f2b0e0a936efb7ce0312dc88458", + "adminx/modules/Database/BackupRestore.php": "370eef15faf49ee0e8e1f24fdac89a38ae2833a24d9c02c6778786de03ee8a41", + "adminx/modules/Database/Controller.php": "c3f59beb5874b8959a5a37c49fecc9b6b48581bf20284b73290c44fb91a14363", + "adminx/modules/Database/Model.php": "52c23131d07bc798fb2a3489f91ae909985e60d2965e3cd5fbaf5ab441d85477", + "adminx/modules/Database/SqlDumpReader.php": "f0b3a501d0afea0ae50d35b43e901844e14d9faec286738956dc6f2b1c5ea4b3", + "adminx/modules/Database/assets/database.css": "fafcc546e3c5d5b72656a17e4a150ec9eff37e91b5ac0c8a1f0058d475ec8295", + "adminx/modules/Database/assets/database.js": "f54e2756a54eedf5654eee7357a022478988293c9419bb7ad024eb8f69ad8c0a", + "adminx/modules/Database/language/en/client.xml": "4464d65adfd150e597436cade1d36b0c0436654fb4c710218fe2fad117b12369", + "adminx/modules/Database/language/en/interface.xml": "da319d6e408fd13179bb4094bff6cab3c72880f691f2e80a3ff13245144014d7", + "adminx/modules/Database/language/en/runtime.xml": "180dbaa39dc46cafbd06c7f6d05cb033739d95550337be5a31d50d1332369f4e", + "adminx/modules/Database/language/ru/client.xml": "34c082c997f7bee0c42cd5f55fdc7fdc349210f7b6baa0dfbb097cba14636be5", + "adminx/modules/Database/language/ru/interface.xml": "0b947fc4f6cd8c003df636bd19bf0caacd8d8bc26159b702d0caabb283237330", + "adminx/modules/Database/language/ru/runtime.xml": "e9969019c449ae9ec41d92be7a7604d46b4f05fa1e2b9cf3f3456781d34b8b7e", + "adminx/modules/Database/migrations/001_mysql_legacy_index_compatibility.php": "e9a021b5643933d6ca000d54bd1a12e339080939aa84fd3ec28d0093a5b8222c", + "adminx/modules/Database/migrations/002_module_lifecycle_recovery.php": "779b04c8b35f3d81657848016536a9e3ef70fb76dd3ce5b1ba4b4a72166f7a2d", + "adminx/modules/Database/migrations/003_document_fields_text_composite_index.php": "541a8df87dd5fec6946f26dcca88f78a2bf5e09608f9300baa8532202020ffbf", + "adminx/modules/Database/module.php": "61c12758ca1a7b59907c13cd9dbcc39ce771fc671edba665d7b6d4bcf82c9aca", + "adminx/modules/Database/view/index.twig": "e1b18f0ea24c95683477c7ea28bd6094e8589e6dad2acb627434ff7241d7a410", + "adminx/modules/Documents/Controller.php": "ae8fe6ec76b2521fcbd336913a3de9750dbe07f80b568adc45c4f228f66d0259", + "adminx/modules/Documents/DocumentHookRunner.php": "7e0cd24ed5791b6d5010e4a05351b709be09b6df1e700a3ff06685b9078b2b1d", + "adminx/modules/Documents/EditConflict.php": "ebc3ebb92c3fc3ccf3909d2ff517014fc888ee20576a520b4979f68bde632b97", + "adminx/modules/Documents/Model.php": "ec5b52309ee149123ed333f4373c5c7e94045701314e887a1ae870a4d7667058", + "adminx/modules/Documents/Revisions.php": "5d40ba1ae4c9563e14464f795b054f9d4edb05e02c8a719bd796126054616132", + "adminx/modules/Documents/assets/documents.css": "f09b629990eb6beabb6909ed239ac761a2da2a6e902d0b588506f3ce76a0a477", + "adminx/modules/Documents/assets/documents.js": "acfb3b096decdb6b725118c9013f1098878f13737df0f23c0df2744289260577", + "adminx/modules/Documents/assets/redirects.js": "7297896b340be6cabecfeb220dabca1d63c8040e2ceaf0d3a816f955c3ef45eb", + "adminx/modules/Documents/language/en/client.xml": "75c2550fd7ab123d14efda13c575d474b3989216c9adbeb70d08b3f40e6523e5", + "adminx/modules/Documents/language/en/interface.xml": "f3a30b3c565d2e01c841c843dc529fed9844dc2e095f90b7902008ded7214ed2", + "adminx/modules/Documents/language/en/runtime.xml": "7009fa27d67ab168fecd9d9bf182216dfb0397d9c006eb6563ee3c464daa5b5e", + "adminx/modules/Documents/language/ru/client.xml": "b5c50b64528eb4a7cc324222e6e51d88889d892be2581baa6c68fb877916f9f9", + "adminx/modules/Documents/language/ru/interface.xml": "718386366f913c590c2bb881f49ad3b7d436de82faeec30655a7c2f9b14d58e0", + "adminx/modules/Documents/language/ru/runtime.xml": "6c3e9a2dbf862bef5a76ff0bb600084fcd9d5d1a7f0cbfd02377242b74fcd759", + "adminx/modules/Documents/migrations/001_correct_breadcrumb_title.sql": "e2b612f19cbc2c419d1a3d2c684909b4055811a7121f4debdf6b83e99bd214cf", + "adminx/modules/Documents/migrations/002_correct_legacy_breadcrumb_title.sql": "1c1a2880dcc9ea69ca907767a6333a562d6f0f30da159e35c55377c0594fb8a2", + "adminx/modules/Documents/migrations/003_create_document_api_tokens.sql": "15432eac6ce1b6f867b5a2ccdeac28900ab2382d0dc775ebe13e305e93603b87", + "adminx/modules/Documents/migrations/004_optimize_public_document_indexes.sql": "5356de50e144f8cbce84a611a941d0a247767d1776c95bfbcb13f8198ae0b6d4", + "adminx/modules/Documents/migrations/005_rename_document_teaser_to_excerpt.sql": "f9d6d2a16547349b434fa8aca7a8ca76301717aa54111f93f3899f14989c14d3", + "adminx/modules/Documents/migrations/006_configure_product_media_paths.php": "59f336cfc27f9f05cbd3d08c9c3e56ac6ee57bb01cfb6343199c1baa0539be13", + "adminx/modules/Documents/migrations/007_index_document_short_alias.sql": "3075ce4422aabb90a0784edc881e6f7802dd03d1cef79e875666c269b3e7eecb", + "adminx/modules/Documents/migrations/008_seed_clean_not_found_document.php": "40cd93c5037c61fd56b5e27677829b6380736df86d6fe0581c9f1a425b2f475e", + "adminx/modules/Documents/migrations/009_deduplicate_document_short_aliases.php": "5c2c8a0f072315ebb19df1fdb2829b3e5b96bf28236df17d0dac63ce34b3e944", + "adminx/modules/Documents/migrations/010_reset_dangling_rubric_templates.sql": "0a5d49654207b38fe54656376f80099e2db206f12d3caf82f1281a9aa65156d7", + "adminx/modules/Documents/migrations/011_normalize_document_enum_values.sql": "dd84bd0612525bbece6dfad2e2233a5b4ddf20fe43c653c63956f9bb610b11fd", + "adminx/modules/Documents/migrations/012_default_legacy_language_columns.php": "503b840d9024c277f89385982f5bef48e9b62f3fa53567dc2254b5e5684b6378", + "adminx/modules/Documents/migrations/013_document_edit_version.php": "ac50cfbaf4ad60bb87b066bf3b16e60c736b82ed8afd5bd11722d192c0eb4101", + "adminx/modules/Documents/migrations/014_document_relation_edges.sql": "3491ade2987535f8db1b3b00ff27f5a2062b02a3a1cb2e707d2752ff446b566c", + "adminx/modules/Documents/migrations/015_document_creation_presets.sql": "a123f8867ab6f6346002d08317acac37062c2792913ad14102c81812d45571bd", + "adminx/modules/Documents/migrations/016_reconcile_document_content_columns.php": "06671bb26bf29479dce24888fb3712c0d20e2aac5b3d70196604ce4097c875fc", + "adminx/modules/Documents/module.php": "9fd49d13abaa566c76d4d2d7b0e4e2dac2b7d58d1f27e3545400b3a1c900ebfe", + "adminx/modules/Documents/view/api.twig": "60e004a449d5df2b39e24a073acdf3622b2d379e668bca5834d59bf9e3e07294", + "adminx/modules/Documents/view/edit.twig": "c3836a0fa03a5b8d9434e110f21ba24bf8fb9bbbdd3acb20e6fbbdf472cb4c80", + "adminx/modules/Documents/view/index.twig": "b6f005bbb66550e43f59d75b8311f290e6c728687473a1a7da2dd74af56b8a7e", + "adminx/modules/Documents/view/redirects.twig": "0d8e6c0431c3cb4571a40f021ad3f55a9c2df1092b500fa3735258bb80231d7a", + "adminx/modules/Documents/view/views.twig": "07ae0d509df51aeb551ae5aea2a19766c1cb00fd255ab3d9734ffdb7e55764c8", + "adminx/modules/Events/Controller.php": "6713fbd79269d2bdd159a15b8f77ac1de7cbd40ac4e487778bc61a4901e85026", + "adminx/modules/Events/Model.php": "bf62126857bc4e43c1c6c594b0f51bedeee76a58bb92b990be43f925c2eca4aa", + "adminx/modules/Events/assets/events.css": "8a21ffdee25078635c59c79290dfe14b941fd63859fffd2d17d13fd5b488fac1", + "adminx/modules/Events/assets/events.js": "b07c0e4433ca6c0ac1a5b8bbabd7e025f23833a2443c03cddc0b76d6be2765bf", + "adminx/modules/Events/language/en/client.xml": "e198b73ef8a84a7606f98ca6caa8039edfac2bcf3b4652c56b8dd9460dbd193e", + "adminx/modules/Events/language/en/interface.xml": "8ba72b194174cff23b1678a34930d0fcc8fbb51465a6e0457cd503c2d0119dc1", + "adminx/modules/Events/language/en/runtime.xml": "e6d14b92215da7d85bc8460f2e42410c0005f51c1245533b9d57c62fc211a457", + "adminx/modules/Events/language/ru/client.xml": "ca18c62786e33a251fd3c903fefb64d0fed84a5f8a62d23299ab81b8f51284c1", + "adminx/modules/Events/language/ru/interface.xml": "701b5862e628fd5e4d64ac4df5983d1f40b8aa1b2fc67b4eccde0a1143338e27", + "adminx/modules/Events/language/ru/runtime.xml": "339a6e6c117e3e14e871acd38ab398ec4f54f0bee3785a9c3f5d90048899a9ec", + "adminx/modules/Events/migrations/001_materialize_event_logs.sql": "59500ca428021c9dce543ff9a7711e2b729f7e9566ec6277be09939e78b9ddd0", + "adminx/modules/Events/module.php": "d506c314dfc71abe7216ec80243f46549a833a6cc3371333d1e7a6435312c46b", + "adminx/modules/Events/view/index.twig": "eade5c87c3cb76dd8ea4795ad72927848a40fb0f042e4938bf31d785f8c33b4b", + "adminx/modules/Groups/Controller.php": "37959bbc4cf73ea2968616c195763fbd1bbe14cea90c01d4dae5fad2970803d6", + "adminx/modules/Groups/Model.php": "6ebd6edc714a580c6123b3298249c596c0e510d55af0e2faf2e83c4ddd084371", + "adminx/modules/Groups/Setup.php": "b7140a87474e343455fa81809f8f8f2dfe7e1ee3640c5f024a1bb06ebd783865", + "adminx/modules/Groups/assets/groups.css": "6d16a57db9ab5f42f8722b52dcecefb02a51d68360b9855187dfece83b36191d", + "adminx/modules/Groups/assets/groups.js": "1e8e6c9b183cae19587e823f037e2dec5e69aedf10629c555edeffe68369a64b", + "adminx/modules/Groups/language/en/client.xml": "dbceb0c80a844d05f3c370dbc495a71142818c0c9e3ff94c33586bea89f07831", + "adminx/modules/Groups/language/en/interface.xml": "54469c9116673d33e55fb26426dae68ab864a64d3dd2afb99312ac6ac14f8bc7", + "adminx/modules/Groups/language/en/runtime.xml": "7ab07b963b2b29f046f6c363f04c1104de6d5914f5e70b6b8cd42f5770847168", + "adminx/modules/Groups/language/ru/client.xml": "320b9b55d6eaf6f4d3643f3fa4675544a779540ec34b3b94e03deac6f57579b0", + "adminx/modules/Groups/language/ru/interface.xml": "d6175d32b3e971aa03f4f6650967cc261ca0bfc04928d5c7cbbedf536b6c1a8c", + "adminx/modules/Groups/language/ru/runtime.xml": "eea7d7d2b88befb79b50d00c89dc63d0bdfa95883483d9606ce0605c03c92402", + "adminx/modules/Groups/migrations/001_register_public_debug_permission.sql": "a81a4843b74ad72aca16c8016b767dca248b2ea950673d99c6945d1b8b867fe9", + "adminx/modules/Groups/migrations/002_remove_observer_role.sql": "310c6d1a9bfa5693f4a3f680f63279ea9dd040e51284d6cf5de9ad519bd2152d", + "adminx/modules/Groups/migrations/003_seed_default_roles.sql": "75cda12582ae86e538fdd455d064eacfcadf5f4dc50e51838718f154d73ed750", + "adminx/modules/Groups/migrations/004_merge_legacy_roles.sql": "3ca98bc7f0348d04594e42beb8768a6fde6a732d39e7528068efc0b89d9c5ce0", + "adminx/modules/Groups/migrations/005_register_development_site_permission.sql": "0a87db84297b7a8b5959217b82b6dbdc88507a822b342e145ddb7d800f50a73e", + "adminx/modules/Groups/module.php": "9f3bd0191fba171c4bad40ada6274e91b1a9f8c5e4d994bd27c02a2a894b7965", + "adminx/modules/Groups/view/list.twig": "7cb21300defc1873604c0af45b98698f91708057cc0853caef6f66bf0277caa5", + "adminx/modules/Media/Controller.php": "0ecaaa26824e737b30aaa0b68f63e7405056a58e399ded779568109416ee7eca", + "adminx/modules/Media/Model.php": "12f4fa4eec705bcecbbaedfe07a7e25ef57062fa72e937333ade840557be1cbc", + "adminx/modules/Media/assets/media.css": "f3e04f005cd3930de211245f912ff35044ee3f5f2b5d86d7384b1dbd026d592f", + "adminx/modules/Media/assets/media.js": "0182e6b649a3993a5ec15f73a63ab89c330c5f3d1d901f848a16e66952615c9a", + "adminx/modules/Media/language/en/client.xml": "06cecdb6cacdd0871334a3da0a3c7be27d7f835e2a048d37ea31ae4f37469270", + "adminx/modules/Media/language/en/interface.xml": "2eab0a0d6acf094c43c690e4c09683378a26dfaec7e258c23f3f7bfb1fc9e9a7", + "adminx/modules/Media/language/en/runtime.xml": "8768433d736f0e8b3508b1a68de1281b2f841c2a9decbd8195603a31bff3a92f", + "adminx/modules/Media/language/ru/client.xml": "272d1eddafc77c79533be4fa89d5daa2d560c1860bc2bab4a1fbf6d609849768", + "adminx/modules/Media/language/ru/interface.xml": "743b1b2e5f498ce05225aca69f866abe2f24765a260baa9d81c1947954029fa9", + "adminx/modules/Media/language/ru/runtime.xml": "538db360abf2d61e32d6476846173ea4f5115703021362314a9100df3854d6f7", + "adminx/modules/Media/module.php": "6e5b38b7da0d2fd506900af70a8813162e3e6215fe403e969ab914a9c20ced1e", + "adminx/modules/Media/view/file.twig": "c338f333ac0424c224e5d73d1e3ac1854000155f3a06d6137992660bb2f407b2", + "adminx/modules/Media/view/index.twig": "4dab58fac2cbb79d83c3e70bbcf6545dd375d8b2397b2a3fd1e85ec8d0b83ec2", + "adminx/modules/Modules/Controller.php": "a3b42def293e69921dc72c575146d6c5828127c66c53ce62d30a0cecebba5051", + "adminx/modules/Modules/ModuleArchiveInstaller.php": "22f015a6695de9f4fea12a580adfb76837e59ee4e9de92c153782c5a40a97560", + "adminx/modules/Modules/ModuleRepository.php": "651efb2eb73beefd4d486761d0a554d45725f58aab47bcb6e88d7d61a0b8b87c", + "adminx/modules/Modules/NativeModuleAdapter.php": "aa27011ea06d71b440476d23496c06d5a55f776a3c35c1c9e37eb44239616b8b", + "adminx/modules/Modules/assets/modules.css": "c5f0ce469db594c6706dd4e9766413fa49428572306f61603cf787afd9c29881", + "adminx/modules/Modules/assets/modules.js": "dcb0622e6a571f25d9478a937ea0d298ca2185624b87469b1ab3afe5483b0a0e", + "adminx/modules/Modules/language/en/client.xml": "0fbfd035fbea33f38e12163a97e463bb27e4f7feb27a93d41e1a703e956512d7", + "adminx/modules/Modules/language/en/interface.xml": "21596992d04ddbdafc36aeb26d3451f042571166426a6a0007f698db9baf0007", + "adminx/modules/Modules/language/en/runtime.xml": "df647071e7691a8dc3634568ec775e09886e57845834772177083724d7d93e56", + "adminx/modules/Modules/language/ru/client.xml": "01b635097b86fcf9fdb76979e5275a2497880d9e029a2bf2192530e0628b6bcc", + "adminx/modules/Modules/language/ru/interface.xml": "ee33c8914bb44478a8801ae23522954c860a6ecb007b848bb956fc07d5be4ce7", + "adminx/modules/Modules/language/ru/runtime.xml": "271e31a8ae1586ed045861933a6db150427c78be7560942185633f35cf90686a", + "adminx/modules/Modules/module.php": "173ff87577a9016d30cb9fa928d05eb4f532264d8f27c7f84aa443ffb6e20492", + "adminx/modules/Modules/view/index.twig": "2e9e19bd26082dbab628f979e8469a7a1cc6281901c2230755f44ad5a5d51ecd", + "adminx/modules/Navigation/Controller.php": "796d25525322eb63e0428ad5f9964ae52ff3d91fc9aeee275c9f854aeb99a5de", + "adminx/modules/Navigation/Model.php": "484e16c2c36d4880e7428bca45f8d50e4a838d40d58c98230a635c830582ca3b", + "adminx/modules/Navigation/assets/navigation.css": "030f3dc05b22ba5559540ae6dbb64b2842350645f14e834fdc15ff269d7ece78", + "adminx/modules/Navigation/assets/navigation.js": "2dec089bf9f9d4eeac8119e07f8ca7e0c0914f2175eec1549e48103c9577d30e", + "adminx/modules/Navigation/language/en/client.xml": "9a0ad0248941cd6377ada049666387581bea0dd76353d34779b4c1343b74ba50", + "adminx/modules/Navigation/language/en/interface.xml": "5ec5a64240356c4b87f65baea911762e64e88dd652c1fd7c801079247d697b79", + "adminx/modules/Navigation/language/en/runtime.xml": "341b984ede453a135b6e155fd29c08d9d5a2ab16e62046379f982d9a56dc4a81", + "adminx/modules/Navigation/language/ru/client.xml": "46f18714dea8e751f33baa049f3684d824109e49e23b1b325ef37c8a043f1c7e", + "adminx/modules/Navigation/language/ru/interface.xml": "cd5ac333b85534be6a81377a1b40fbd449174cf2d1f019d25b3bb3f5ba0713a5", + "adminx/modules/Navigation/language/ru/runtime.xml": "45d994c39fa7fa092525ed4a61d6775008f3993f4cdcdd615d61e5f15a749cff", + "adminx/modules/Navigation/migrations/001_create_navigation_tables.sql": "43c937b8d7d7f5b70569c591db58ee8592386b988e8cf148fae723b1f68b3012", + "adminx/modules/Navigation/module.php": "1be9c185ff36f11d4d03aa93a75ca0b889f6884a006ec801ad9005527b69e235", + "adminx/modules/Navigation/view/index.twig": "870b617bc857aeb3838e76ce79f0a7fbb81ff299eaa0155767fef5b86fbdf65d", + "adminx/modules/Registrations/Controller.php": "90ef3b986abd7b5062cd02009a8cb8b174081fd217166c019666fcb94904d8f6", + "adminx/modules/Registrations/Model.php": "689ba41add40cd61edba2659742931c4a9256ec4caa98066996b17f34a50091a", + "adminx/modules/Registrations/Schema.php": "d61127fd2e8b5a9c53ac3c4a9f0348edacb9552674a3fd5708bdf246e6bb072d", + "adminx/modules/Registrations/assets/registrations.css": "0d7a49bd09c22a968c3b2d51148c54510c564cf63578099931c441197a54406f", + "adminx/modules/Registrations/assets/registrations.js": "fdb1647b7f9e1ca9ddf3bbac81dd6a3405af5616b878f24f51ca17c216f938de", + "adminx/modules/Registrations/language/en/client.xml": "a58b1540a3505be6b05ac2260914f9796c894b059086694ea722cd37cda3d056", + "adminx/modules/Registrations/language/en/interface.xml": "7ab5d6b95157c613e5d010570ea4fbd95c96d257267764060dca415fa23b7d77", + "adminx/modules/Registrations/language/en/runtime.xml": "69986681ad2573d277a63a9b87395aef2d82a3043cf7f3c9db5d146ce1be67dd", + "adminx/modules/Registrations/language/ru/client.xml": "665a6fc93d2dd3ac3c5453cab99fc655e3208ad06262c26c90758edd67481751", + "adminx/modules/Registrations/language/ru/interface.xml": "072128419f1ce3ddd7f4faa3d1605ec34e0368ef137ee2ab87e4b5d2dee5682e", + "adminx/modules/Registrations/language/ru/runtime.xml": "cd3fc8a2f1a1e0afb2476d29947ec29dd8d1586254ca7f32420c73316b8226ae", + "adminx/modules/Registrations/migrations/001_create_registration_certificates.sql": "e843c57ec87589057a7f63d2b2957afc68417d4dcbad255cf77d0c073916e976", + "adminx/modules/Registrations/migrations/002_register_registration_permissions.sql": "b905299b8da097a3706e6f93cc1376287383e77ae9825a255d566cb01da23e6f", + "adminx/modules/Registrations/migrations/uninstall.sql": "41ef0a6f74661e513314140e4f65e1b859a8c47c050afffa6696928e8e31788e", + "adminx/modules/Registrations/module.php": "b98ab875ae628efd3f729f84a1c9f24bd2d4f96e05664b5f455a8c60934ec7ef", + "adminx/modules/Registrations/view/edit.twig": "2e7e7859acd0da4be37990d96dd2fcc9224cae62e89d47f55803ad99d070c82d", + "adminx/modules/Registrations/view/index.twig": "16e488d00227ee1c78e1df68fdf671f75e96d188f18832482c069e4402851884", + "adminx/modules/Requests/Controller.php": "c6d2c56bf10f28cfdda15c0f6d75e134ab5029087954fc44857323462029f1bb", + "adminx/modules/Requests/Model.php": "5a4c038c97b643fd603d16244bf7cb5654bc1d91b72bfec18a533203ac9ee7f9", + "adminx/modules/Requests/assets/requests.css": "a0f0a671d1dd2e16eb2a26b36c4e5381437c3996563f00cc03bf447bfadb7355", + "adminx/modules/Requests/assets/requests.js": "980bc0c1c311a40e0c49facf0a2b0dc984a3b05c8291ffc6a66f7942762b74af", + "adminx/modules/Requests/language/en/client.xml": "544406edaf2b568062e697f7264b5c5fbe3fc45c27e019615601e831612db0bc", + "adminx/modules/Requests/language/en/interface.xml": "766190d3f850f6fc414a4646d786f79f394ee8f03524b3a2ae820fbe792d4602", + "adminx/modules/Requests/language/en/runtime.xml": "8f618793f78ea2941557d9eb9c253f8b12ac6b60b76e7158801461b30dbfea28", + "adminx/modules/Requests/language/ru/client.xml": "9dc1d047bd12190cce6197b20e4bc4b3a1597c387873ffd99d59710d7bbe60d9", + "adminx/modules/Requests/language/ru/interface.xml": "2a5e72c60d70a0a7e7243dba02bab846807269da888a9468976719987b50284c", + "adminx/modules/Requests/language/ru/runtime.xml": "6e5b2487c9b3aeefebcf1c93e4458b0f902d1a334117d935dd9a0631d3c4547e", + "adminx/modules/Requests/migrations/001_native_database_calls.sql": "cdab530043491203e61b5e5ed325da707e99797d3737019b27f36f0495639344", + "adminx/modules/Requests/migrations/002_correct_native_database_escape.sql": "6ad515dcc5e4ca5854f754c4de95a7f7723401db5edf593e178fdaa46f4bf2ac", + "adminx/modules/Requests/migrations/003_condition_groups.sql": "b27df4f09a0b127da59224a5b75889a5e9792e2b7820807e2a6e5f34a7556eb1", + "adminx/modules/Requests/migrations/004_recompile_condition_groups.sql": "72e862a88b6c6151549bda40768995a5b20305b758966a92df70de3c29aaa585", + "adminx/modules/Requests/migrations/005_rebuild_native_condition_cache.sql": "72e862a88b6c6151549bda40768995a5b20305b758966a92df70de3c29aaa585", + "adminx/modules/Requests/migrations/006_sargable_condition_cache.sql": "72e862a88b6c6151549bda40768995a5b20305b758966a92df70de3c29aaa585", + "adminx/modules/Requests/migrations/007_result_contract.php": "ffdd37d84855bbee6b732a15d8d138e1d9dfd3f1bef446417666288cc1a448fc", + "adminx/modules/Requests/migrations/008_preview_renderer.php": "324fc8b78550e9a2b0bf50a8c33397e4da99402a956f7e758caa662c6a08bf41", + "adminx/modules/Requests/migrations/009_native_executor.php": "4b1d16f28bf303bfd2b48a54071c000b284862fc441ea06175013b40c28bd59c", + "adminx/modules/Requests/migrations/010_native_audit_result.php": "82bc935e5a4b5802e8aeb95984bb3ea50ca6b67155cac58891e681cdf17599fc", + "adminx/modules/Requests/migrations/011_order_tiebreaker.php": "533f3bd0cd40af166feb13dd08ad1a57dbc0a14e044cc10827ffcfc091ff178f", + "adminx/modules/Requests/migrations/012_condition_value_sources.php": "c874ea024343e4d698cda7a138e13b23776a58f51706c2a16102ded1234b2aa2", + "adminx/modules/Requests/migrations/013_sort_rules.php": "4c58c56d9766c16517bc2ceee1d6313b1728e564a332fe9a5cdf69a04d7afe70", + "adminx/modules/Requests/module.php": "d950a229b063efda170bec7995678eddd27cffe706c500f8b979b938afa044f9", + "adminx/modules/Requests/view/edit.twig": "319e06df37e3d5d449d7a7c6b2078b2049ca230109f9b754de550b3c34aab282", + "adminx/modules/Requests/view/index.twig": "164490c16e041b928e1ecf604915656399690055d1f7de4bc88caf5f5abd485d", + "adminx/modules/Requests/view/native-audit.twig": "42120fd41aa4afa39560fea3f2625a4e78a61cb4f9ccc0c0388ca319d4f39c06", + "adminx/modules/Rubrics/AdminView.php": "a866300dda84253046f88d19a4467ae1a545c8b1dcac7672d35be986bb38a180", + "adminx/modules/Rubrics/Controller.php": "02cd13f57d5d85647bbc79b93a283e273c32327428f787343ab149524269d516", + "adminx/modules/Rubrics/FieldAdapter.php": "edc4b730499111917360670632573b8d28cb7c192f71e88e8c60c7c8c3e981d1", + "adminx/modules/Rubrics/FieldAdminEditors.php": "d3bb0a8cd7e4b44db93b780ec95c49d3974bb3fc3167110a2f8ef044e921c348", + "adminx/modules/Rubrics/FieldConditionImpact.php": "77600152ef7e6a98cbfcdff1bc638aa6b1a664048bc2b839188b56a1ebf0a735", + "adminx/modules/Rubrics/FieldEditors/AbstractFieldEditor.php": "88eafb30d27e83f20d4c9120567ecc3193b0a11636424e3a96fbfcdeb70a171a", + "adminx/modules/Rubrics/FieldEditors/ChoiceFieldEditor.php": "bedce6236a9ca6d0cc6da23589313b9e6694a5b0f804ae85021691d3f8b4e57a", + "adminx/modules/Rubrics/FieldEditors/FieldEditorInterface.php": "fbf290cf1d6304a07c5bb753782d4473682b0cce78c6301c743ef05e19b45fed", + "adminx/modules/Rubrics/FieldEditors/ListFieldEditor.php": "bc1c7ead2ed6666f7861418ed2222166a44870edc5efb401226a5b9d80e549a2", + "adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php": "7aa8364d698b6581a191ced8dfe681dc210da499904ca392c5f306d1e1d03414", + "adminx/modules/Rubrics/FieldEditors/RelationFieldEditor.php": "9504549a3b81893ec8a114a286a960450bbd302cb2f8f9af97660c8332f91925", + "adminx/modules/Rubrics/FieldEditors/SimpleFieldEditor.php": "fb4df66a47e820095d5be0856a77f2217a5b1a69640c89c9791ba984248effd7", + "adminx/modules/Rubrics/FieldTypes.php": "c6cc64b0e1b1fb29b65275d337e5747b9f9f68ca46171e7e7f07129cd27b8409", + "adminx/modules/Rubrics/Model.php": "0e9c3a8b54fc862b46e4f86a71d899d97ac7caff3c75bb287418c1b63e2e0115", + "adminx/modules/Rubrics/RubricFieldPresets.php": "80109bd9f066f077ad19e48d7c7599e4dbf7b1d0c06c54db9fc921992bca2e77", + "adminx/modules/Rubrics/RubricFieldSetLinks.php": "41cd27e5947680eb184dafba5fd95f707ccad0c20fab2ca948cf882ada0adefe", + "adminx/modules/Rubrics/RubricRevisions.php": "084118e4b55b077244bacc7916674a26b3cbe86a08d2e12084dc05f2798b2090", + "adminx/modules/Rubrics/RubricSchemaImpact.php": "e82f93175f031d5772d9d8fd71541748e505273d1622027615abb80e3a97bd5f", + "adminx/modules/Rubrics/assets/rubrics.css": "345ca91e59044d21756fe5831aee00bd0ca04c603ab32d5c6a28065eba4fa567", + "adminx/modules/Rubrics/assets/rubrics.js": "986e6adf8f3bfff0385a70c4889bf05d5af251538c48569b26889a29a500e287", + "adminx/modules/Rubrics/language/en/interface.xml": "c04706d2f8902cedb2e4c1f00e25c995c6e207831ef5295d6c90ef2c64a61468", + "adminx/modules/Rubrics/language/en/runtime.xml": "659663c346f6fa58996dc60b852ba048d04580b734d2f711af998bcfca2e855a", + "adminx/modules/Rubrics/language/ru/interface.xml": "72ae7e3db1cc884f61392c70db0c5bd6b0363d8c712c6ad1730fb50ba1cfb05c", + "adminx/modules/Rubrics/language/ru/runtime.xml": "8cf25cede84b47e321e0b2b586a34dbe8fb6b777c752f8e489bd0dfae0867b15", + "adminx/modules/Rubrics/migrations/001_create_rubrics_tables.sql": "a827a92e9d83361569b7e1e7c65b65cb019d1675a9078e05002de25b3e62c72c", + "adminx/modules/Rubrics/migrations/002_rubric_field_settings.sql": "4406f7ce1a9e9b0a105c4a33588791d41f12a55449baeca2565d5573e79f798d", + "adminx/modules/Rubrics/migrations/003_native_public_document_context.sql": "eb440a23ced2794776fff47a55609d76489af324f14a084ca916a5ecf2ec5b4a", + "adminx/modules/Rubrics/migrations/004_native_database_calls.sql": "92b13172ec35e4c38983b845b692f8a2c7c54d2ff12ed5b32e2b6c7cff030ded", + "adminx/modules/Rubrics/migrations/005_correct_native_database_escape.sql": "d7d6e2256e4933e683ba5be39143278599c57595f6379e2a5810acfb95199eb2", + "adminx/modules/Rubrics/migrations/006_admin_document_views.sql": "2f80d3344479c97e0e82085acb412fbc68781cb9afc4df7daa75626241d57db1", + "adminx/modules/Rubrics/migrations/007_rubric_open_graph.sql": "b8f4a20bf60c90c224555b91778d9db4ad2c0af78294a9e89bc59a6e11b77a72", + "adminx/modules/Rubrics/migrations/008_normalize_field_layout_width.sql": "824d16ed3233ddcc5c8cb452aa68fc307146526c226989c417bfb7b7223ab2db", + "adminx/modules/Rubrics/migrations/009_expand_clean_starter_rubric.php": "761250ad6136eabd5165af9c3df982c6d62e406371be19176c9b83a67ea0ba91", + "adminx/modules/Rubrics/migrations/010_form_conditions.php": "11a812695bf8e18d285d066ef75ba52e8b6a1010b3c692b5becc3fd836eb2c12", + "adminx/modules/Rubrics/migrations/011_schema_revisions.sql": "d9a4d98640bc8b24b650009b90781f577b846e6d9f7309b7537ab5139dd61064", + "adminx/modules/Rubrics/migrations/012_linked_field_sets.sql": "595229c2353f911beef5bb04d552080be5e1c8a36f440d9c06ed9400dd33ff10", + "adminx/modules/Rubrics/migrations/013_group_form_conditions.php": "86544b9f2304b2c75917fe49b33da255c7677aedcbc9b76fbe13f9cd65568695", + "adminx/modules/Rubrics/module.php": "e9e65910b3366c767eb468d5a5b443c7de6b8ebddf7c6b9acdf303d5c98333b7", + "adminx/modules/Rubrics/view/index.twig": "4b6f220c811c9a1a524ad3f8a67ac5c206a5e8688d4c2053a76a48ec47015857", + "adminx/modules/Security/Controller.php": "c7f2263f460057191402dacf8948b822f9b33bbacbe94e6e9ad871613147fc86", + "adminx/modules/Security/assets/security.css": "fd8c477d07c38450587153402380040b72fa9a92c60edd532c4b21046cfe375a", + "adminx/modules/Security/assets/security.js": "f270dd0b910b9aa4815680b8d80d66ffb31bc7862f74e628cc60efba4205a99b", + "adminx/modules/Security/language/en/client.xml": "f474d114b7a8dc3dd0b8438109e9ddb4dfb89717807c6733965f5b410270475c", + "adminx/modules/Security/language/en/interface.xml": "1a64dbe2ea97b26131f6d7237d9cc89d55cc236dbdfaeee125fe5ba359c35928", + "adminx/modules/Security/language/en/runtime.xml": "968b9f0fe17b31d8ea7d3ef942aad19d123ae4abe19f111fc1efdd89249af7da", + "adminx/modules/Security/language/ru/client.xml": "77e5f3aeb0d673b31735840b5915bcd8508365f93bbb2337cfb5cc2fd06d6b94", + "adminx/modules/Security/language/ru/interface.xml": "934110ec57234db479a8764143250553a1b355d120fb4b7d9d866879e887ae61", + "adminx/modules/Security/language/ru/runtime.xml": "cbeafc1cbc995206868fa3a3c3ee8d64d2dab21c1360c38ce6f69611d0d09a9e", + "adminx/modules/Security/migrations/001_create_ip_blocks.sql": "832aff733e2c25a43164c60a601dba08c21fa29ac9e826568055dcdb65417a76", + "adminx/modules/Security/module.php": "557308dd9dbe114e5c7129e1cfbc06f25ded104e08c473513e2f388ba945198f", + "adminx/modules/Security/view/index.twig": "b70bb298a3e1aed377d31bc6dd6b7937cbdcb71cd7098113b8b026ba9dbfab84", + "adminx/modules/Settings/Constants.php": "9cb232e8f0356fdf6f8f8d8f428486eddde281b58e486adbbcb276b88993faa7", + "adminx/modules/Settings/Controller.php": "90aff255d936753ab763ec534a097e9988f6c737bd5be2ba1194a7700a372e72", + "adminx/modules/Settings/Model.php": "93afb7c30aae1ccfe665b148d182e0d2992c043b0731c228689e9060a546a5a7", + "adminx/modules/Settings/ProductionDiagnostics.php": "a832245666ae4c00ae6969507686a9cb6db4f9152620ab1e21b67cf60d0ac902", + "adminx/modules/Settings/Schema.php": "7367374ace3e25fd86e5a829ebedb0358ba38bf5be0cfab9b859f1e5d3cf0525", + "adminx/modules/Settings/assets/settings.css": "91d075a30e57b07f9d9faaefa9d01f06092a6e4cd322b3e14c4881dbd29cb697", + "adminx/modules/Settings/assets/settings.js": "b2112d9338f82013c48b57926a9f9340dd8453aa354544a9de25036f5ff8a06e", + "adminx/modules/Settings/language/en/client.xml": "28cd3896b4574e87d9968ca180bf02ca783181e11448068426f7f23503253acf", + "adminx/modules/Settings/language/en/interface.xml": "0472feff0bbe3588a2cf49e17fd0f775f9c263add6cf5628a6b4ec48d90c62d7", + "adminx/modules/Settings/language/en/runtime.xml": "9cf8627efb2fc5a2da3ec42fa0c2467134480ca96d921228a0e5b13b7de59749", + "adminx/modules/Settings/language/ru/client.xml": "10031d39c8911a075a461acfca9825e20e9d75836f702e41fd864b7af14bf061", + "adminx/modules/Settings/language/ru/interface.xml": "0851c5623181ffdba24e8438c2609c7c0f32b06e9f06a693194b6a04b01622af", + "adminx/modules/Settings/language/ru/runtime.xml": "30f6f63cfbb6dcb4cca6343eca154e1ede24f53441b8dd0438770c3b80f57649", + "adminx/modules/Settings/migrations/001_create_settings_tables.sql": "69d880693f1f4a68b5c917035841cf32de8015229504fbb7676992aa71c1e4fc", + "adminx/modules/Settings/migrations/002_normalize_breadcrumb_separator.sql": "fc9af9771803b47ad3ddc21bc5ef827dffa6fb0325436132d9a63619716ce292", + "adminx/modules/Settings/migrations/003_remove_unused_constants.sql": "20201881c41cd92bef70903deb0b6771d29756a7d60c22f734c57696fa6c7446", + "adminx/modules/Settings/migrations/004_seed_core_display_defaults.sql": "31aab8dd3cfd944bc406343f580ed753470191e7918516c9e7c2b9a8381ba7b9", + "adminx/modules/Settings/migrations/005_order_system_navigation.php": "df82c4966f28ff3b7d9cf184013477c60a4ae635345f21809bd58b94310e8957", + "adminx/modules/Settings/migrations/006_create_admin_saved_views.sql": "69cf1cc085a31b2310f82fe55ad6e0abbb6f14707715205e5a53f06334759a99", + "adminx/modules/Settings/module.php": "a754703ca23db15f500d3edaaa912abeb2097c32bf8615b31605f6018c952199", + "adminx/modules/Settings/view/index.twig": "c21736be16c030c12ca87efe15877419422dae8493c33605e2d1a753a65c1e3e", + "adminx/modules/Templates/Controller.php": "00b1758860293b56b052a8c41f7174d0d97ba1d78e012b80f8e9f29194eb77d4", + "adminx/modules/Templates/Model.php": "4f7a9082f462170533c0c140ef085f6a33111d20061b464864eb86066d50289f", + "adminx/modules/Templates/Revisions.php": "59ddb9b0e65ce12df10a6c1d370c5988ecfe14307852bc157731f7a13b16e0c8", + "adminx/modules/Templates/Syntax.php": "a0172c03a4e091f5fcaafde39713189bc341c1cf7ac9ae1eed50c5cec657bd24", + "adminx/modules/Templates/TagRegistry.php": "3c6b4f15a60597d909f9f2d16b0c78791293a9d053a1c2ee8f9a9f8bdcce9d3d", + "adminx/modules/Templates/assets/templates.css": "083100ac836b6b1cdf3f8e2d3ed9dac08bc4504dd6f8299562df24dd133a50d3", + "adminx/modules/Templates/assets/templates.js": "6684f9d2c1f3a956fb111035b5390dd62ca2afa79ca75cc0acde84a8715d1f81", + "adminx/modules/Templates/language/en/client.xml": "d7280440a3c00674ae2db6a2c837a8114fbfd4fa27590b7e0ddd72395fb08887", + "adminx/modules/Templates/language/en/interface.xml": "50d541fc8b9af282413030476e7677ff2fb07ae20168311aa130d37e2b9670f7", + "adminx/modules/Templates/language/en/runtime.xml": "4d9c9214729aadcacb7e062417b8befb2965f3184ca39334e2aef7fd90fb47b5", + "adminx/modules/Templates/language/ru/client.xml": "4785a6f6423ed47bf7aa65e8638e7b7f1da518c994245b916bfca95ceb41ea72", + "adminx/modules/Templates/language/ru/interface.xml": "7707a322165559dee0f6cc850aac581bc754ee9027ad37b9c6ffc0035e76ef5f", + "adminx/modules/Templates/language/ru/runtime.xml": "800d784c94fcee282dc396f38ffeed40595de7adae019ff960f206a7baf23731", + "adminx/modules/Templates/migrations/001_create_templates_tables.sql": "eab8a337e8bd6b21936bfe908d20bb2d21e18e33ceef9df7245c4c0a4bb14259", + "adminx/modules/Templates/migrations/002_create_template_revisions.sql": "d7d05e99795ef1d376b16c55f46bf45cabccc76e11695dd95eecfd15e845b542", + "adminx/modules/Templates/module.php": "0c5740a2494ce0335e5b94a63ed509921e2c3f40e32981a03996dbc9f814ba4f", + "adminx/modules/Templates/view/index.twig": "7c53cd26dd6bd6fd4af757ed53e239c0ba21578c75aa6cbc575ffb5d0c6db454", + "adminx/modules/Themes/Controller.php": "85af532e079d799c0bad87921b99587c946fe92ec073b4016eea650e5c4ef12d", + "adminx/modules/Themes/Model.php": "e291c6d4bbd964e7086180e7329df2867e2748c8f007596a95c88a954159e320", + "adminx/modules/Themes/Revisions.php": "04ee9814b4f0f329ad9c1983333f5f923343bd0179d68897e21102dfea97c9df", + "adminx/modules/Themes/assets/themes.css": "a7a7e9faf7dd415451ed1e4d1e71da214f33b8b4802259c1654314fda1b79ee1", + "adminx/modules/Themes/assets/themes.js": "6c1b2346f475f97299b8657ca3d867293680c40df929ca467c4c30dc81d76c0e", + "adminx/modules/Themes/language/en/client.xml": "5f9d0a1ca7320e8590468e626433fff92b55cb9ca38d8723d4a363feb77dc3b5", + "adminx/modules/Themes/language/en/interface.xml": "30365f6c02c048fa2d0e94feb82bda45bdf8cc9fa665a7943593bb60636f368a", + "adminx/modules/Themes/language/en/runtime.xml": "66f4efbea3acf5be49cda9931841873e7c8bd68e263ef7a247233b4d762e9887", + "adminx/modules/Themes/language/ru/client.xml": "76e7f668e80d169a667f79264f21c679d2e5e58c723bdc94859cf101b9130406", + "adminx/modules/Themes/language/ru/interface.xml": "98d23ffc4bb40203fc1d5572b845934931da0c7b992408ffac52cdcf7351f4c7", + "adminx/modules/Themes/language/ru/runtime.xml": "f8046256e75de65f6f20c8d5b5bd6a927653ca4dc8b6b8f205756b255b624c51", + "adminx/modules/Themes/migrations/001_create_theme_asset_revisions.sql": "21a2ce27d0b97be6493cdb86bffbfb0cb8b16762cc7b0025d83bec71d56a760c", + "adminx/modules/Themes/migrations/002_place_theme_navigation.php": "62a2f2953f3d5bb8df7e39c83a1de83a56008952540c8c28c6a12f33a8fd7f95", + "adminx/modules/Themes/module.php": "2f4ca527a426bd8973ff2f3070404c350ad8e860094544d071f807e46b2e56cf", + "adminx/modules/Themes/view/index.twig": "68a06ac9ee43b0052c877e89c411a1d149f212418def78e44a1f4a73d80344a9", + "adminx/modules/Updates/Controller.php": "1aef3e6cec043e4e66ab862b4f0c7081d452bc8a1295fc125860eb0b02fa89ce", + "adminx/modules/Updates/assets/updates.css": "bbd75e2ae0b6b1fc66a69b13cfd6a7244fc02dbf90895e33bdcd1340b0b0b815", + "adminx/modules/Updates/assets/updates.js": "c3788da4b31718e297fefcc47e740f20be10342f3c637aa0a7c4678ab1bcd920", + "adminx/modules/Updates/language/en/client.xml": "d1f09120fed6c918cbbd4df166627bc44579d530f61c77c753cf147582ce3399", + "adminx/modules/Updates/language/en/interface.xml": "a9ac07a22f1e021eef53690adb1396efbf6f2b2c2f75948f48cd31e77c2acfd0", + "adminx/modules/Updates/language/en/runtime.xml": "2a5c16070fbb6cb8c2097442b230c4e22c80013ac16dbfdd88b344f826e6c195", + "adminx/modules/Updates/language/ru/client.xml": "7dc664bbf759fe501c96a145dd6a052f2dd5541890a8aeb1d533fee378cde0f3", + "adminx/modules/Updates/language/ru/interface.xml": "1d8a827ac996bf7da2d15f75b52d2205f0f36105cd73b50cebbbf1e99ea98bc7", + "adminx/modules/Updates/language/ru/runtime.xml": "45d622ea8c6f772d5f53690bd7c08beb55efcbd51c7308f0336c995885b68258", + "adminx/modules/Updates/module.php": "c26612bb4383e4433543a9e822bc18360b57815af67875b29434bfdc8e263dd2", + "adminx/modules/Updates/view/index.twig": "8d975051ca96fd99902714bd60b6a85131d6304578ea65a3dbf732523b638ece", + "adminx/modules/Users/Controller.php": "a2c3808077662612bfafbb80430a0a7a028e80d6d9300c1c7079067b5993d5ac", + "adminx/modules/Users/Model.php": "0cfff6474cfef89cbac69e9f4804c9347eec23eb7435a3fb45f7b033c3c6f6c8", + "adminx/modules/Users/assets/users.css": "98853d9eb68c8d4b99250d456185a30cbb41437254ab5f35113fdf4aa850ccd6", + "adminx/modules/Users/assets/users.js": "a9d7bd3b5b2e44988ac1c5d270d44f896b7cf1796608961caa0d8129508d11bb", + "adminx/modules/Users/language/en/client.xml": "d1d17febaf2fbc74243f0e799bae71b32c656ceed81da7fc62f13dbf73ca7b4e", + "adminx/modules/Users/language/en/interface.xml": "ae023172c71b0519bb2aabdcf8a7a162ec4c45585182a3da653916e95c450fca", + "adminx/modules/Users/language/en/runtime.xml": "b68f556143021c97fd369813ee0fcdfe66af5febcc74ed757b53605b2b797b10", + "adminx/modules/Users/language/ru/client.xml": "69d389f961f82692717c4bfdb6057700c23f09e226410108053ec9f1574295f9", + "adminx/modules/Users/language/ru/interface.xml": "ceb48f73f3e11c1bfb8ad0aa5716012c6331feefe9bad3809afba8f28a64785a", + "adminx/modules/Users/language/ru/runtime.xml": "00c74dd13ee0fb0a578f0465ebd74a9c2b87d43399ee0e22ddbc911df2043b6c", + "adminx/modules/Users/migrations/001_normalize_public_session_activity.sql": "e72f1cacea2cff5f74f7baebe3cb1f154b87fd7368541b1a3a5d2300a0deae86", + "adminx/modules/Users/migrations/002_harden_public_remember_tokens.php": "85c92acd3ed962dfc4bf85b78e07de87569b1f0aacb2c616287c95bb39fbee2a", + "adminx/modules/Users/module.php": "9df770b412405038bc0539722f369b801cb9b6d7ca9ea1e60218e11b101447a3", + "adminx/modules/Users/view/list.twig": "ae771b2dcd7f374ad6c2a3fbdbbb115a0ba0f4ee382ce5ca43990e911ae5411a", + "adminx/view/404.twig": "86f93ca1cb44b0c225a03a33704b903f8a96e1697a05d40b0b71dedb8f665769", + "adminx/view/_pagination.twig": "9d5e761594ae97d7cb7b518592028adfd2041b5d99f736b24a318b2766e350dd", + "adminx/view/_saved_views.twig": "51217212a3b0ef7437de4dd55a07fdf55aaa3138155b00f65fd85af686412da1", + "adminx/view/auth.twig": "e44eb6337248470418be7015886e6e5d7bf5b0021fc917f4cc0dcd98c6492d85", + "adminx/view/error.twig": "1187e6d0c325e0bbf6a7bd9836a5ef6d96cb8f72a0599050d3da7120683a6202", + "adminx/view/main.twig": "2ec8f2ffd5d6d3f2fd45027ab30cc9c8a611b0dd4e0c5baa081fce8021a3fb43", + "changelog.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "configs/db.config.example.php": "e42bdb47aa7d1bc9cd85cb36e05acf940cafd951d6220664c485d1b971f18809", + "configs/index.php": "48e6b249e3d83530187c095ede0198af498c59f384bd458db74f9e0c2306eba9", + "configs/public.config.php": "7047f7acfb9f64e56c7320c3cdc306522a187f5f9e12cef6e95937446ee07441", + "help/README.md": "d12965e7851dec5241dd74db731c110a7e0f4acfd7139ae28ce21d53193b1123", + "help/administration/README.md": "a6658aa4a593abcb9f6df35edc4781529a518438bffb8fca77ba5502a161d294", + "help/administration/console.md": "5efb2adbee857eaf0b8c2816ef1556addb2c9b21420bf972c6bdf62911fa9ad4", + "help/administration/dashboard.md": "98a93834bb648b4b94fead19d31c371d988013028ece38caf9d4b8d455628e93", + "help/administration/operations.md": "4c585d6c3236f11656000332ff99c066187eb36cd860852c7fc857020487c05a", + "help/administration/settings.md": "89ff256f830a827929847d0d30662e1af0a2cefcf7bff214d6875d7c14a164da", + "help/administration/updates.md": "1fe89c7ab6a1cac896bcbf447e130acc3792c7c93b3d2b063160dda32e1b9ca8", + "help/administration/users.md": "4f035c98f136e7a3b794d3634c2d22809bfb07b3be3f8ddf3d403ae07d96c14e", + "help/api/README.md": "13f2b6663760feb1660dd3ba5d2b0b2c3528d00540c45efa040a54494fb511a6", + "help/api/authentication.md": "71f289056bbcd688c2ed19c1af43e4b233db92d76760b95ac9e63efddea99c08", + "help/api/documents.md": "717101e0fedee8ddd987d499a9ceffb19b4d24e0a04481aef72cc6330e88fa43", + "help/api/errors.md": "630270fb28d5d385e368f3b0cc1c5cc2acb3c56607bb8c9af62c2e62a6d09993", + "help/content/README.md": "b52c0cfe8e5d6c114a33eb2c6d2452a0a36b5335e4f76061375d0aed4712bb17", + "help/content/blocks.md": "1d55be2a9f1a48df426e5856a54307d77b352d0037d68f08ed7e16a322d9ef8c", + "help/content/catalog.md": "8f40be9562c8031ba035043d16f744cf625c5f761c7896a4180e1e751c8e0a81", + "help/content/content-studio.md": "5d200853c3601be6ef8b4c82fe98d1bb7e82b9e28e562fe4857d06f6aa226c81", + "help/content/documents.md": "e5d7f51af5488cf3363326697bc5dec56f377b45963fcd6a029a354871661afd", + "help/content/navigation.md": "a02d0f88b5a3aa67fd14a2c26a48c9c3c4bb73f4824fa453c20f3e2ef4ef24b8", + "help/content/requests.md": "6487e6c6a41b2cda89912dc535b279a1562bd6514b8f0a48037616fb32bfc1e3", + "help/content/rubrics.md": "556805ddb3216f0b3659337b86c03d72274962ebab0bda082f4b8d31c54661e4", + "help/content/templates.md": "c6fae2ef91c741479d1f34fe66d00e83f2d2ec1aba1a4099773a04a6e1b6552c", + "help/content/themes.md": "439b9dceb6b49a66c05c94804d06a37d8abd18ed4c26660c3234c6bc35a2a620", + "help/content/twig-components.md": "cc977a81090acd7cecf0f4550914eda716701f0b93eb5988f92b7738964bb166", + "help/core/README.md": "c8c42e4915e049e2821cbb548a496bd4caaa9c7921a205db5d7ddaa9445ace42", + "help/core/auth.md": "acc76eb4b00f07a6d54b6482e8f18c2aaedd304290acc6bfa2d73aad130a739e", + "help/core/bootstrap.md": "72b6759b1066e2906dc855dac13880e0227c1d70f751f81365c6afae2cf919f7", + "help/core/controllers.md": "6767a22647bd06b7af5bfee2a9268bce95d67baaf3f4a828b88233def1e4c665", + "help/core/permissions.md": "3147cfd90fd1139961822505e611c444e54bea4ab66b86f1c0da121301fcc57a", + "help/core/routing.md": "320dbb50b97f918fff0f24d8c7f6a7acd6e1d5c29f9dd4c73d56619c09b2204f", + "help/core/sessions-csrf.md": "08ac0274e31ca93b90f0e58879027668bacc09878049647bf8b3d0cb1b04f0f9", + "help/database/README.md": "8177fe10ce6cc44ad4f33c2c4c05a7d6af8491346e025869cdf4d04f1593be15", + "help/database/cache.md": "705197358a0d2cfc876a1d221d47bf1867a4e95957f446737fa3a54ecf3b1692", + "help/database/crud.md": "c1358a4943e19f36aa558300700cd9fd701ae16c2d069561ed7ac214e7a8b344", + "help/database/placeholders.md": "8e729e0fed961f221a936ea70c5b449206506ce3a7baf8e658f1b3714da34a4e", + "help/database/queries.md": "ab557fcf9f31e8409501902c299e939747b5a222f19457515bf0d150084ca63e", + "help/database/results.md": "c32999f9945a18917445947f8dcf3fb1c219d5546c0394cdfc53b17d89bae11e", + "help/database/security.md": "581d5a89b5ea7cc01f7800878dd879b48e48a912f4a4c81c5f5dadd9b990e556", + "help/database/tables.md": "1bcc1bc26b4096d1a85aa1a8aa4f25297753edf9d74a344b2109dad51badabd7", + "help/database/transactions.md": "da679da1e7656be20048566f25b51c6018a0d7eff1f2e5d54228fb3d54071aff", + "help/debug/README.md": "41b7954c443330efe28cd3a14519c7c8076165a9d96c0dc3d3781a111e757085", + "help/debug/custom-data.md": "445ed27dbda5b0ca035504e2f0cda023746def5edd3aa1f62189e133946e293b", + "help/en/README.md": "cdf84634ac731f1f434ec496b171307c38fccd6e6df335dd4dbf074e3e2cc12e", + "help/en/administration/README.md": "fadfb46c1f45d3d7afbbd6e323221b099fb9b338cf3c17bcb8826b26a67b9372", + "help/en/administration/console.md": "0938f6598c01381d79235a6b524d34f0f9a35fa37a35e7042baf04cf4f4c195e", + "help/en/administration/dashboard.md": "1945cb5191944f41a88e5a59eb91f1baef550f9a1d751817b08be5112d23d122", + "help/en/administration/operations.md": "cea8833c931040aa0d00ba0a181270cf95b6ca95f199b6c250c0c8b839b86b97", + "help/en/administration/settings.md": "68586a8a8a397d9b5f76d52867f5a1ea0247ee47087faec22802b2f8c4c2c755", + "help/en/administration/updates.md": "a0ec3d0929602c59935836b5e03e35679b86058bc482dcb37475f892262401be", + "help/en/administration/users.md": "018541f3fae298feb40f0c162bf813b2ef0ee4e782c7067dc5c4efb207177db4", + "help/en/api/README.md": "660c017b0480b988a5e9a31a3e2b8a412999364bd5d901a9684acf3cf1a1d2bb", + "help/en/api/authentication.md": "23ef6699d3082373dfff77253a79ac5179c0088d3f69e1d1145c8cc7e05efa25", + "help/en/api/documents.md": "266d2cf298bfffd62a02289b97bc01be508d6dcb71d0a8b0b3bf840afa5307c1", + "help/en/api/errors.md": "4c8ca63333fa082aa56cff33c1ad3659bc8030524e490a2f43d122ac4ef68309", + "help/en/content/README.md": "4da5eeeca08496e4f4d2fd8634da5ff021c24744b70b6a16d646dc0a8a644b9e", + "help/en/content/blocks.md": "34657f0a7ab977bf78d5b7645bf80a0755f40830bab4d4620ca7f39ea5992210", + "help/en/content/catalog.md": "b22e818f4fc9f7d683df31c6357dd97a3eca9834b8c5cb1e9b0d80f240280668", + "help/en/content/content-studio.md": "746a9f538d951f7f53de0e80db9f08e3bac0351b39ad286177b1ebcb04d3a76b", + "help/en/content/documents.md": "369712175c276d241ab5545ff35c2df56e62d0445c4506ff6b7610d9def035b0", + "help/en/content/navigation.md": "18f6e033c5c01eecbd7a3bc4b7c557f272f0039c811ea6f4ec2354206b0a5f21", + "help/en/content/requests.md": "152b82072cc9276482bf6921a607b7a5564d211644d3ebba66083c8554fd2845", + "help/en/content/rubrics.md": "e933018a487059b7d1a867c08c1f34fa2f3a40a68dec60b1bbfb492cc9d5308c", + "help/en/content/templates.md": "f91c141dfee841a1af8321625788cecb9dabaef75901e188127815b6d5b5ce6b", + "help/en/content/themes.md": "e9d0d80383c02840480410224b84cb14b097e3b30124b47f600e2c6c569142ac", + "help/en/content/twig-components.md": "c1e100c88678c044245f213b734efba232fe30b623ba8ccd34be9399d3098809", + "help/en/core/README.md": "ce0c4d0757cec5786cd79fdeb8a839250a947d87e2b5a5905f7a8010db78df6e", + "help/en/core/auth.md": "235ae7b48e66e6c8893650d5a2225e12a02e24871977099eaa41acff7d74192a", + "help/en/core/bootstrap.md": "e2c7b022ccf6bf9dd2e888d3cde42bea82d36be382d8d0cbd508d3facee43f7b", + "help/en/core/controllers.md": "968107531f9bd1a714a62d9f390704f29136fb531f1b72a36eaa72cb0cb76952", + "help/en/core/permissions.md": "6e6f1d5d1c20c85f6e3d71aa3f4a7de8e822ca08358aae38981bc7f7082b13ec", + "help/en/core/routing.md": "24c3ef29ca9882ea0b76cb3d449cc74dd544038b5310d43f7f1fefcab9ad1645", + "help/en/core/sessions-csrf.md": "9658e34c59e3f2b76577efa3f889500eab141f08adad94e2d27e5751b7731c26", + "help/en/database/README.md": "11ace925ab12df0eb704c4be3145a40f34d724de0f1dd8be298ae28a056f9cae", + "help/en/database/cache.md": "d2955c017f07d5a21117912d8d7b151debbabf1d120abf336ecbb40cfd8230c7", + "help/en/database/crud.md": "6d98e6e2784e9c5733774164f58d4106c0fc0fed2c7dff9369e2fcbc8a982dbb", + "help/en/database/placeholders.md": "7b9174018006fe78e1b334d522e1b0036f2f900c54b64e5bd4142beb64d0f7ff", + "help/en/database/queries.md": "9e2f31009e9e012af8b8a06ae8fc4ca2872cfc45957ebe53d47135e4bc0a7aba", + "help/en/database/results.md": "ec428e28364e7a3508716ed5df8cd0b1206516cdf98b43b54e69865663a87bf9", + "help/en/database/security.md": "f2d0b218fe9ba8c8c5fcef2b6a832cebc58e0af26400a13f0c4a32d7d2ce1d93", + "help/en/database/tables.md": "a13431858a26ca867464a0d6a1c608f13d491e152e6ef345d4007acf323b0ca6", + "help/en/database/transactions.md": "9cd1b15425647281d40d11d7d0453f8758a1c41859ce3bf1de5d091ccbf2246d", + "help/en/debug/README.md": "4000fae72a034ec3be9d1059fd0e0a850efa8911dbd4b8498d8cb0b23ddfa4a0", + "help/en/debug/custom-data.md": "0cd068b8d74bd98240bb86d9d4bc01d2630129eaeff8eec88b1630a094016f32", + "help/en/fields/README.md": "46c4fb2b44f1fef2f25ed2be3c730dd1d4e1aa0f48950e6069c3772acdcc5b17", + "help/en/fields/development.md": "4a953779ec8f08a79e235bd658d58ebc69f5758e2d8c55d49d5bc074e5aa0a41", + "help/en/fields/operations.md": "7f3e2e991df10e5bc8bee9bc7251b9bd96e5137aaffc5142401f80afadd752aa", + "help/en/fields/settings.md": "a3556e6bea0982f91153926b0096518f299f341935f188fad4a3934f651b2f2c", + "help/en/fields/storage.md": "2e8d0faf13e79f508e6f91e78c3b988811d3c39379c1fa79de441e06a44ff64d", + "help/en/fields/templates.md": "634dfc6e4f14b3beb5c84cdfaf5d2371535c51dd464638474ae5b67a4dba0f8a", + "help/en/fields/types.md": "db8451912c4910fe0bc416cdfcf23f16a8978dbfe83f481f887e2fd3f3832bb0", + "help/en/helpers/Arr.md": "bc2e9ed94583257944aab682803b8b01873d5a8c472071cfc24ccceb624e3742", + "help/en/helpers/Cookie.md": "4acb6a82f70c16dab506a48ac4e0294b01ce1c19e2aaf14c44135dbfc036f5ac", + "help/en/helpers/Crypt.md": "add75b8cbcc701c38d1babe9055ef8d446e66a0214897f16ebc9f6a49dfa49f3", + "help/en/helpers/Date.md": "6e7b688b452a02b6f2e64ab9ed953239f59042db2d0ea5fd92c781169cc04832", + "help/en/helpers/Debug.md": "259477e13bad3c56210541c546f550cd6739525f82f5ef998e30036d998b4931", + "help/en/helpers/Dir.md": "a427b4ff451447913c781e24248449d60c1b040b27c187a784ac715f1da2f7a5", + "help/en/helpers/File.md": "f646b3c9313d202d0139a1da5578c237cc62edac11c18af7a1a6bbc2884e0ebb", + "help/en/helpers/Hooks.md": "46079c76d8ad11cc950ab6243bade28fa254818544d595a94c246433308bbe4c", + "help/en/helpers/Html.md": "98c77771ef84d282ee659756a07d1980cc1fbb492dcb3c28fd72b32c21658ccb", + "help/en/helpers/Ip.md": "a87b7755bcf306937a5a97e964005640c4a6d628a11de210c07f0ecb4ad30635", + "help/en/helpers/Json.md": "d54aac0b4ea57916d145ce0a0c51f33833e07c940937fe750f4e6c102229b987", + "help/en/helpers/Locales.md": "730badd5a01f8c25ba6ef5c14c3205db2d0db3dfa2655b25d8b659f9f18e3ee5", + "help/en/helpers/MarketParser.md": "3258826ec5933814ffbe3607b5673f38ac71a1abada44dfc9d749334763f622c", + "help/en/helpers/Number.md": "8e38723a1a33a8184de3c72d993198eac4647b62278819fab726e1f94db4a6ca", + "help/en/helpers/Phone.md": "2e7b9712f7312040987c048e666cbb9ade19c8d0da3769c31b64b2a45fc086fe", + "help/en/helpers/README.md": "7ef254465ee442f686c8a807eece8c5332e4739ab66b166108ddf9e68fd04add", + "help/en/helpers/Request.md": "3e919b34a2559e818938895a6e92d3ca87550774712d03913044e58a4d304d99", + "help/en/helpers/Response.md": "ca68c1d5cdd133daaa78077e00a15fd702bc47890ad33c224656ab4c33c17e52", + "help/en/helpers/RussianStemmer.md": "c55946a78392ac8c0534187cb2b4232f5f0aa6276114e0c8e31aeb75beaf9fa0", + "help/en/helpers/SearchText.md": "42213dae0a4634936c01a67a4beff51167921b5e2e7f71c62179acc1eb2ddc1e", + "help/en/helpers/Secure.md": "c95d170799ea7bae219b16f55385db17fd5f52db2898b59fb949fd87fa0a8e50", + "help/en/helpers/SoftDelete.md": "34912f62fa21bff29d5124280f11ce911f74f2a9fe836ac230879d0f992094a8", + "help/en/helpers/Str.md": "8c850a76b9932046bdf0232735a0050bbaa8ba4dcc2526d819862adfe2af368f", + "help/en/helpers/TagParser.md": "4508767f8ed6c122a28f67db21355315beb8718dea08a7931f9c8e00922e0a31", + "help/en/helpers/Url.md": "c20635df3ddbb596812611fccc861aac482bdce88e7a01cdc57d13490f6102f6", + "help/en/helpers/Valid.md": "739d5da3989b3d49bf958f670e5e3670dad143058433b616da0f17af276e09ff", + "help/en/helpers/Zip.md": "e77e4a66c0c4373b25d665d7e95eae1d5a0af4bb1589f5efb8985622231ba13a", + "help/en/hooks/README.md": "92ee41f7b9b9daba4c1bde7099bfd221dd3499efde9d8f3f9b56ffdbad0cfd9a", + "help/en/hooks/catalog.md": "4541de3babb6f5a6dcdd8c495bdb0adfd6ab6036ed5bbbbb4bb3e55e06ab9b12", + "help/en/hooks/debugging.md": "910e07cbe4a802efdc7ce439eb9eb0e6231d667ee527664711bcc8e863e0bb34", + "help/en/hooks/documents.md": "94d713a991561c8fb7642c4bea1efc491d9255aa3c6c867006ebfb89cb270e4f", + "help/en/hooks/fields.md": "4fa1dce004be2b1cac106fcde0959908880d2e8992be9df0e5b981aa166672c6", + "help/en/hooks/lifecycle.md": "ec82cd9ccb2bc48e9054ec1876c40552859da4ead45129f920026f821f84e87f", + "help/en/installation/README.md": "a122a89fc4a910064f02fc906a0fdf3a9098147f9ed2f98a75185ed66d190f29", + "help/en/installation/admin-directory.md": "26a7b2198078f8ea11dba7fa6a0699cf35c66e78fc88279c82012e6a01f59a38", + "help/en/media/README.md": "cb9abeaed976e18cd7b294f7c078b7cfbda339e0022853d3656c7a29b7fed13e", + "help/en/modules/README.md": "103e48b81c47f8b545df1563a0bb6726aed73d1f2d3c7b9f5960121ea61522eb", + "help/en/modules/antispam.md": "1808f910d530f7068e60e2a9306c15ae5b69cccc30ee3e492f35cd535004f65d", + "help/en/modules/banners.md": "b6046d0094b0554c31b56dd15ea299e247f5f3dc13c8ca8fce5714bb6c3193cd", + "help/en/modules/benchmark.md": "e7b9d233abd386db41413f45c79639a3bebfc37241bdbab461028e87a5615f87", + "help/en/modules/comments.md": "854896934b2d93d6527f9ede94e85b5e9aecbc282fdaeb9572f810c7dd773dba", + "help/en/modules/commerce.md": "d3ce1e24e4d719adc9e1ada79dcc562205f7833d88b9944b5113c9abf7ce79cd", + "help/en/modules/commerceml.md": "250c24aa6b88a814930960e79350d88d78407e56fc564677430ab7e216befae7", + "help/en/modules/contacts.md": "3d4d7b109a7a7bf20ceda0cb0da65634131a837c7d39c4a1433f317c3bad3512", + "help/en/modules/content-packages.md": "4b89df6d05fa75e484e38e404d53fa7ecc6b61aa6930fbf74cf9b6611bfbf532", + "help/en/modules/contributions.md": "ea01a8cdd61e5df55e45353ae1c05804095646b2d33e70704fc6abc8722f294f", + "help/en/modules/dashboard-widgets.md": "363ebd63b5d28c23f4be2e3cded9e690db78cb0b79c459217eced1151e2b6a8f", + "help/en/modules/demo-site.md": "d8ccb9d14f664b905c89d81fd046116d1b6fb45f2c0105ff2876d5c19b16f57e", + "help/en/modules/document-import.md": "cf0726b5cf95b6549ae6732ea37ce127ef3a1fe3b70109d83d74f5f48e4cf0f7", + "help/en/modules/experiments.md": "226ab03a0bc93dcb1bfbb0ca8df6df7caabf82c949fc653b72e9fff7cd812c5b", + "help/en/modules/faq.md": "647e30e267ac260742a20cb264ae9882c907fb056068a4339c88795792c17344", + "help/en/modules/file-security.md": "48f6c59bb95a8065163aceaad99471128e9be5bcb5f2d96512ad64223e580067", + "help/en/modules/files.md": "91a51a8b16b0b89ab97ce110eb1ab1882b4319b40412ec3941521942b1a7621c", + "help/en/modules/galleries.md": "16039100658a3d869f2edf0cf7ae59af59f91052bfa7fb7c257a986b44719b9f", + "help/en/modules/header-actions.md": "d5126af7516e5fbc71eb8bc18b20fe00b20e25b987c285df0119578dc7a8628a", + "help/en/modules/help-viewer.md": "50411de1d2069be54fdb6aac44285bf860f1bff61b903a2f4328d82b747598d7", + "help/en/modules/interactions.md": "3cb36ab97d91ab3f2ea1400259cbcd5072fc140a9c2b0295583feadad566c7af", + "help/en/modules/legacy-migration.md": "a137a6be5043c0b6c85de052608c624a5ae5b33202638b01d8e733346eee5625", + "help/en/modules/localization.md": "f84bd43534cb3147f5675908deed2503271e9a96ef1cacca8b3fab17d8917f6c", + "help/en/modules/migrations.md": "5a2d09f1252ad7c4670d06db1a1dd029593856fabd219005bfc0d9891bd6c2f2", + "help/en/modules/module-php.md": "d61b3a47097623c1d969b716d78d08939f446980648b0718c1dcef45b7af8f36", + "help/en/modules/not-found.md": "77ab48c7428a8f053938a780986d9c2d435be5c7aebc1d70093574776436c691", + "help/en/modules/notifications.md": "858eff7639b1b2c3b49d06f222928f44f8bd6be541e750682c01a79a3abcf478", + "help/en/modules/oauth-providers.md": "9bde340e64c98b9100d322db465a1180b5aecc60ff7222bf1a0bfb4f1daec015", + "help/en/modules/operations.md": "56c39f40464f75aefeb58d185082fe8d351ed77aae7b7eaf2800ac698095930b", + "help/en/modules/packages.md": "4977df2fc7c614f79df9d9c4aaf23d48cc74dea0463fe3a66b123e80de7bb277", + "help/en/modules/personal-tools.md": "90967b72fc40e8e98b6088c098c1ab362d057d802daa3a1635f7587de5cc7971", + "help/en/modules/polls.md": "a8f86b04024cfcf490e9ee2f5dc2e4293ec088cdcc48dce11c074b02310c1180", + "help/en/modules/popups.md": "46a2d6171349d186cfd18af984c7a65dc1184f62406a700767233f3176e789ea", + "help/en/modules/ratings.md": "37fdf968a33dab9224ede4f71bf093a8d9121613574961341fc0e436a2bd321a", + "help/en/modules/related.md": "1bacc84e67364da53fba3a215c2b2337aa86f7bb75174b20a8bd6576d54d4ccf", + "help/en/modules/repository.md": "1f835de827a64b04d6c00a18c060fbd6c414a7367daf8f10e9b6c6106dcaacb2", + "help/en/modules/reviews.md": "0175bd201536c306b2583858a248740bf0445cb39b5ffe37dc76a9f6a306c0bd", + "help/en/modules/rss.md": "22958cb500ddfcd83201086b36e43365d51f9f125b8663ec38e2e30eded4d0ae", + "help/en/modules/scheduler.md": "461c67e31e392c90bf60a6e0d9d55c65719a15183cc5454916305b9c4d0af4bd", + "help/en/modules/search-analytics.md": "3e21c935d76171a5a74cba9fc2904092d2d30062918c2f7ff1988aae0e499264", + "help/en/modules/search.md": "a3e323119de8f3783c758db745ff28483feeb93685cd8e998771ccc119e18212", + "help/en/modules/seo-audit.md": "e18adcb79847fb5a1cedc8cfff03b5938eac1c2e2d22816ecc5e5b02b48b95d3", + "help/en/modules/site-readiness.md": "dbccccf0633f0e72d32cc8a2b0de319dea576f28b67f0d9c2907106c97fef1c1", + "help/en/modules/smsc-auth.md": "789c352d0d9efa5d1c4d88f88b508d925b08518704326613ce35cadd1bb87fb1", + "help/en/modules/system-health.md": "f43c3745ee1437f392724d35299d208dd73ff344a3efff60273cc7b23c6fa731", + "help/en/modules/traffic-analytics.md": "5ed8a48a4d05350d8dced3db8fadcedbf32a2bf80147da39872a0f591ca8ce84", + "help/en/modules/tutorial.md": "2e6bcf3e7de1799fadddbe5b5dd56de613c91baf2a4d6c45232eb2144279780c", + "help/en/security/README.md": "1d539e283cc4b6f4510b5b43e56396eaadd04a5219cc0cf3afee9cf238f6ce0a", + "help/fields/README.md": "01d359b4ebeac9401be857443c4c16aee73933cd7d6307e0a25c2e3db6f98d5a", + "help/fields/development.md": "4b3de5adc867d34dab7d3a658eefd4deb0622b9c62f1a4fa5a6b0f24ecb264cc", + "help/fields/operations.md": "658f5ba5ff96cc99192d5486275d2ce9f98555cd8c8d43aed9f8f08a14bf5ce1", + "help/fields/settings.md": "e1216af1489ead6ae4a1fad514aab68afbefdfcef548be11759ef947410547b9", + "help/fields/storage.md": "502a8b0ab9c22b5e023f71f94cd0960bf00506c9cc2368ad72f54c354595c116", + "help/fields/templates.md": "7538e7df6ee2e7535a106c97c6a3d980678a0f148132d530bfcada9fcad4be7b", + "help/fields/types.md": "3197b168942ad3bdad5bf645cdaf4d54765f52307b6947041a7a255f5225bb60", + "help/helpers/Arr.md": "187a7feeb2e5bc5f73b40fb0e5b848a4416c6c26b96149899d36ec1be860811e", + "help/helpers/Cookie.md": "5782699caa1ede29fc84208357371b7418d06edf15eedce49988cada809fe5a9", + "help/helpers/Crypt.md": "60c6c71b89c1069c508b946a3adfbbbfe0446d25e4a06239880938526639fc1c", + "help/helpers/Date.md": "a0d952346f8196180a20a356974b6ed635a04001d74434cb16eb0e4152d7a4e5", + "help/helpers/Debug.md": "8c9452dce860db34ba82b84feb4058c10394f723177ab3e8c314ace6b84abef2", + "help/helpers/Dir.md": "9d78c4cc65d65f378c920d53c90fa640d95eccaf5b7fdb1d4ec1c346fd27f3d4", + "help/helpers/File.md": "23d58093efd27e7d0aa056929e7ccf8f34542e965d32294cd8509fddb6dd9cb4", + "help/helpers/Hooks.md": "349290d7a51566158a6b2df0cad5e95157b6626eda9b82cb24cae63b4aec504a", + "help/helpers/Html.md": "a2a4c7ceaca12fb71bcc6e5ebdb3686af33cb505d41265cd394281f6da09c828", + "help/helpers/Ip.md": "97c95c8dd2a643554ccf1d1c5c5d81f2157a927b86522f67734f95360cab878c", + "help/helpers/Json.md": "ca3eb027c12bab78408944defb5d0bb23d26b21849110e84517c02765a81d2ac", + "help/helpers/Locales.md": "2f5f49c50fd299db4c22027ab863b9c7fe9616a81b999223981fc73be8b32e13", + "help/helpers/MarketParser.md": "3395a9b76f550789555bc381e8c1f983ea1e89f9c74fb13a8c452e518d501468", + "help/helpers/Number.md": "8aa5045a5ee11a7a799c2af46001f1053d68eeef52ff94b814ca0e115a39fad9", + "help/helpers/Phone.md": "6ec580bfe3519d2fd7bd38dee1a554cada334a10883183ce8ab34fe6ef7dc5ef", + "help/helpers/README.md": "a8c8472ee6042d85eb15aad1981c8f5f13c1c3fce036db56fb3ff77bef5ae397", + "help/helpers/Request.md": "93b98e898273dc5a1e0cc77babd14487181464bff2c30cb8e08f5425c520ae61", + "help/helpers/Response.md": "d96965599876b9244a6a2deaee2f18f9d033959a0f0f7b942c33e3aa1fcf143f", + "help/helpers/RussianStemmer.md": "10a849612da6b01d3fba062550a7765b1bfd6f558c2751e5ceb67887924bd2c3", + "help/helpers/SearchText.md": "314fcbffaa95ba52840bf07d0a0070372b51f0db62dcea4ff770184a467d6152", + "help/helpers/Secure.md": "17ffdeb9c226be816e0c00dc92c90bd86643261709bc889fd238afef5963b61a", + "help/helpers/SoftDelete.md": "787d30d2880a304d22b3086a3a5f8deb5d282b5617bb0c74e0b642295f54c39d", + "help/helpers/Str.md": "34b54c40ba492a5899e32fcd695380ec458518ca287ccaafbda4c0f38d9bfcf7", + "help/helpers/TagParser.md": "eb9e8a75e37bf7ccb936f1f888efae23da9b9f54fdc255bd205327fb5f77dfda", + "help/helpers/Url.md": "cc8195f741cbc8cff91819100835eb9383795ea24db9bb2b45fb9496c7a8a416", + "help/helpers/Valid.md": "384c0c880fafdda2c69dcc6340f09e6e4481c7beb04fb600e568e7b3e3dc6e8a", + "help/helpers/Zip.md": "48966186d2812305d3bb74058e53c454085f52b6422e2a3189768e77531faaf2", + "help/hooks/README.md": "562b3d8c96ffa407fddbb327d51b68d7a60e8be494bd9364a7c3ce14807c5f20", + "help/hooks/catalog.md": "cc1043d2827f52ced69a559a4e52b6c297365ea8075ee4594ee211edade5833d", + "help/hooks/debugging.md": "643ccb475d890220598b6d07387dd2c1a65735e7768c9616a26eaab7a1862407", + "help/hooks/documents.md": "6380c1674a13ac0d3caeecaca1e3f5019b43dd44e868eb271906d6fe2c0bec07", + "help/hooks/fields.md": "62737f4bfcced38ca8089267fccccd2a5769551f79cd884b496d40f04e9ffa87", + "help/hooks/lifecycle.md": "0d73b7eea62dbc1cc5f3a07449f465105ad8a477adf85b22d2adbc53529b81d2", + "help/installation/README.md": "6a9c434bbe81f493ad3b07ad83fd8c8a8739423f67f3e657b5a9a3dba0d2738a", + "help/installation/admin-directory.md": "fcb31d8bad1c18dd7e5c6843dc4f7b8c4139dbceaa670a7c4e0c060479f5ef9d", + "help/media/README.md": "2693af05de4dc54b280427607dfa5824e3098944b93ba9b92f76715060b9864d", + "help/modules/README.md": "b901471d9439915bcefa728957aa20ad351e3fe5753f8cc62b43013e0cbbd753", + "help/modules/antispam.md": "ddcb858c6c80ee140addbdcd0e27ae3f7900100f1745ee006edabf52d0955fff", + "help/modules/banners.md": "e8faa2a923c5f5c39a0b7caf80a511d03fb680bd2604ddc59ec0362a244ee77f", + "help/modules/benchmark.md": "6c31f16fc3f083f29c65398c38b78609a3d94da168d30a93825af8ee9650eb0d", + "help/modules/comments.md": "a9b9a4ff122af3a2842b2e85d007973af935a07ce721f933ddaabe1d07e0e1a9", + "help/modules/commerce.md": "21b0f353294be599d47647e539aa5760440ef2e7fd2b4efab80c4c02490446ed", + "help/modules/commerceml.md": "c74a3408799c72996c86dd86fc608cc9ab49467f5fa938a6f2e5ad1568085d18", + "help/modules/contacts.md": "8df21307a300a335aaf243cb42a107829114ac031da128cfdfc6f4b94452dd3a", + "help/modules/content-packages.md": "8dde0ebc897b7891a6afadb6491f4797bef26622918addab67f0f4ebfd719678", + "help/modules/contributions.md": "70489671ec9c74b3ac2933b1f70982cf0fe59dc69fc91c667a70ad1d3783b27b", + "help/modules/dashboard-widgets.md": "db967aa0dc13d9c72631e219b6b3769ffc350211eb0ed86a467e5a3baa1462d0", + "help/modules/demo-site.md": "6dcb58f90c00d921ab1ab097424ea27845079d810f026a128f3ff9ea190982cd", + "help/modules/document-import.md": "579d5270328a962c0d171cfebef07a9709153c2a992ca22b8f07637dfc2b3c91", + "help/modules/experiments.md": "aa42fb997906eb5e2e15f8d5ba61fd8c93e7f9368ac10f042d9917a2364120b7", + "help/modules/faq.md": "ac536c5fdb5269f54f55475319d351c27993c37609769c92025a2e00be5c8c0e", + "help/modules/file-security.md": "5e8fc01b5c1f8b73f117f1ecb305e2ee642db92edbbe932cfb416b16c7ff705d", + "help/modules/files.md": "86305ab6e33640990d36f03ea39ba319d2984aba0bd7f300cb035ef497e94aaf", + "help/modules/galleries.md": "b87c143d2c3625dff4c4cae5fa09202fce0719fe2d03efaa0388f73797f493c6", + "help/modules/header-actions.md": "134cdd5cf4f8b1ef59c22b971b3d4faaf3ef4c493385100481e7e852d3e163ad", + "help/modules/help-viewer.md": "175db88e150ee02dadd8b841ea74262490b8211c67bc42bf05cf1dbafba1a25f", + "help/modules/interactions.md": "6c62a4abb3422609265b1898cb448dd6bd840edeec98f30fb0062c6d18808e18", + "help/modules/legacy-migration.md": "f3025bed1df1af4dc3ae99206681e26c5fa00a57fea54e4fd770d502e5304dea", + "help/modules/localization.md": "f6a58cba016a55c7a9a88c594ae25fc79828411dae13a70aa84a54860821ae43", + "help/modules/migrations.md": "e144ba009df225508d5f73e9ce71e17d4a379f9ddff49e640c2c1234e389588f", + "help/modules/module-php.md": "3594f19c10381b4d450b6ce5b9b1d83b2f413067d67f7cca334d3a14cdbf2a0e", + "help/modules/not-found.md": "50e45e048e07e27190b8583928c8fe20cd77c5e3401adfc47b9b2effa1ccd799", + "help/modules/notifications.md": "3d1b4218410bef62916908c909cb58ad570b2b3949657f79dc198afa204dd6ce", + "help/modules/oauth-providers.md": "834009cf17629433a998486f29331f09b285902b9076f1afd2eaaf9ea5492f6a", + "help/modules/operations.md": "07cb9879b636d2c2d54fef0774498a608d855f0328c7d536b0fa71a123abfa48", + "help/modules/packages.md": "9ec909565ec66d39489cf2a89d36d31c0e67408088fe864697d2089ac21b5131", + "help/modules/personal-tools.md": "3d31c43946003ab61818d8c580a5a44baa6bbd48037de33de7c88a0d51c5aa3a", + "help/modules/polls.md": "4d13eabeb2a546ff036c8095cecd3e8a6781ecc0de7446b27c4b26b152397ee8", + "help/modules/popups.md": "f95ab2440beafdce8801aebae32070e3b4d4f04148bcc9e95a0aa3231ac96411", + "help/modules/ratings.md": "e4c8da2672ccec5ce085c507919fd016ef204427ce919f1a9c2b1c0146fe7c8c", + "help/modules/related.md": "007631556df983656542bf039cad79426bebc61ca2eaed2a90750e2f0954a220", + "help/modules/repository.md": "e371ac47aaf81408242408d795df6cfdf62cbaffa232192cc4cff7dfb5e6c5ff", + "help/modules/reviews.md": "a16bba5fee4d17f6fe0813f54c168fc98b4fc30050a349efd34a21b7b757b2b6", + "help/modules/rss.md": "0f8fe5fa5bd4991b432068a5a520d4a45d41dd6a2c6011f7f293f74c8d9c1588", + "help/modules/scheduler.md": "96272f91cbb17b5fd12b463e1f5909bc825a226b0fd53e1a8e728c066fc97d07", + "help/modules/search-analytics.md": "0444e0dacabe408358bcdfda4b6b2a475095e8b1c0ffc1361ff58c4db0576d35", + "help/modules/search.md": "ff25a35ff67a27e4ea59baaa99a78826cc91d518c93ec44f690f405532ea6007", + "help/modules/seo-audit.md": "b0fb78eda0e83c0711b7717fcc730e7bc0cb86c6d692f21aa749cc4975f0ee27", + "help/modules/site-readiness.md": "2c7f845ece693ef14f23ea2b3b226f38ebfed305fc67bf842fae971f0e12255a", + "help/modules/smsc-auth.md": "57a5c3686d7267c166edc858deb7144af4d3002eb6b9d01d8f2ceea5a9877e00", + "help/modules/system-health.md": "671d44d89f0d3ada4fcaa4ef3d8a7662a6c77dea6c0cd567d8f3e923d29e12a5", + "help/modules/traffic-analytics.md": "12f966acc6ffdf73e2d40682c6db203f8dc3918ba5461a78607c1d783ed54d02", + "help/modules/tutorial.md": "4239eef8d793b7bb799c901c821b229e456c7602ac694c0e3ffc5028b4eb57c8", + "help/security/README.md": "55520074d4ae0a8bba1a980d3c30ae64e9f59ccd41ac8dd4082336f8a47da166", + "index.php": "251150d41c4d1dc799b993004d3f6749f194da2d8229980ff219fc81f01ea615", + "license.txt": "b8093fa95967d0f6024a5334bc3119c694aba6f51be2fce66be6939f68c39e93", + "recovery.php": "2ecded66156c70919f9e83451ede4bbc1a5d5e5636e9362b2317ef05f733004f", + "setup/.htaccess": "a25c35a118024ed8b958455ccb123a5f899ad4f1103977e53d50f94b5499c7ea", + "setup/Installer.php": "f73d369b5629244e8a38895d1fa35ad9ed725523914a0c6034ed3a31de095483", + "setup/Localization.php": "e6de0faeaa493fc317907099df2ab58c76f7b643ac70ea233deaa5bcb86d79db", + "setup/PackageExtractor.php": "57cb5473905992333936d7984221e5188cd72756d6e48206a42d96e8ab59442d", + "setup/assets/installer.css": "4b968e19d2b2b5ca1b4fe265f6ee78ff8812b4967751ad71501a7a20af43082e", + "setup/assets/installer.js": "892a982b872390fea21455562353408da906114b973ccbfc3a3cce06465ab1e8", + "setup/core-migrations.php": "520ba71a85b8035c52d340696e17203e3c89829b939b4e1129010b66e695a067", + "setup/index.php": "63bff35919aa2f4cc9ac817b92020d01d5e8a8d6ec387071e0d1ece1edaedc15", + "setup/language/en.php": "cc62f651ff62f75a5c1cdc591d0284ab6e7271175061fa8a0e026ec16df06d7a", + "setup/language/ru.php": "8522b13c4f147804538eaa857585296db811c736e1361e6b403daecea05f34f8", + "setup/recovery.php": "359ae9b79508a382bdde175824b18a5bdb0fd69ddb7776c7d18ac9eb826d127a", + "setup/schema.sql": "c23be35e4eb2e2fd755a5a359c44e3ddf9b33614e2d966b5676aed9770975757", + "storage/.htaccess": "ca761a92f2b6eb47feffcf200a8fd846e7fdd8abe065008d29beff0319ca3496", + "system/App/Common/AdminAssets.php": "89364e67c30bde6df608eaa59c20584e7979995710500e421cc27a54e2d3a262", + "system/App/Common/AdminLocation.php": "d2729b0d627ddbdeb7f0c4b110664ed949f373946f80929c51688391c64352de", + "system/App/Common/ApiTokenRepository.php": "94c01d985776ba8c8a3b7c533b1c17425e2af713fb24df01d822bd191a8acff5", + "system/App/Common/AuditLog.php": "25ade9afb4ff843e341120ef3a204f6b72993cd9917e101237db8f47e245298b", + "system/App/Common/Auth.php": "886b33e077babf3b8d9fc4c14bb4535ee12aed8a34799e16e4f73ab657c6cbba", + "system/App/Common/Auth/IdentityLinker.php": "c614215dc98de43a6dd31c7c4435cac3540acd886b0f450f6e0fdc1537276563", + "system/App/Common/Auth/PublicUserNames.php": "0345d1f452e0a9accaa9b98eea71f27a428692a5b4f2ad225a10ad8bcf777684", + "system/App/Common/Auth/PublicUserProvider.php": "b24beb04d30756d8f7ed30ace4d0e5f70882e5a28989080b6c377baffd8a77d3", + "system/App/Common/Cache.php": "9092c5409d874ea76d55630df10362946543f8051c6ab838572f4badfcf8c4d2", + "system/App/Common/CacheKey.php": "47400cd90d1880d061995bfea585b56210e18a870607d9b1cf7f0eeaccf15032", + "system/App/Common/CompiledModuleRegistry.php": "9ab055fa759f4041944bb94bfc2e28361986a09b742210981928bd6ee15f758e", + "system/App/Common/Controller.php": "b80ca1f6f1f2cbff6effdcbdb9e9606b5aa9877922ab442cf7b1ae1798aa9f00", + "system/App/Common/CoreUpdate/DatabaseBackup.php": "6f32b82fc8ec06b5fee851dbd1c455210f2e41bf1f5290317f358d152cfc7669", + "system/App/Common/CoreUpdate/PatchPackage.php": "2e54a4c7e10c56f2cc77afecb97f75d69742696982d0aaf554c35f4d7469ed52", + "system/App/Common/CoreUpdate/ReleaseState.php": "8917d093677b05b3dc0b4ee97e8b26d18f9a98af78a3f87123fd7779d024af41", + "system/App/Common/CoreUpdate/UpdateJob.php": "60edfe53acfaaf55898488d889a0abf62d226fdbfe17740059eec5cb2e6b3173", + "system/App/Common/CoreUpdate/UpdateRepository.php": "adb226ce961f920ab97cf195e277c9e60a93ab846bde0e34492ba66b53caae16", + "system/App/Common/CsvLogWriter.php": "9deaa3e2bd85eec300c65e89e22549f48aa9e2850ad50b2ee8826f8d4fa53aa1", + "system/App/Common/DatabaseConfiguration.php": "3eec09e9a0d9302761ca6b9ebfcb47b01aaded1bb7ed5834b1d55cac806918d3", + "system/App/Common/DatabaseSchema.php": "2958c57ae90c35824c76c17e4230ae3041a9be3d06b1a2916264ba5f8b5a834c", + "system/App/Common/Db/Core/ConnectionManager.php": "dcd609e118916eb8ae5c0f2d5e38e18d3f9631395342de44f104dac3c47908c8", + "system/App/Common/Db/Core/ErrorHandler.php": "3da721f57d469fa6f339bb99cea8fd2a98c2bf11beccaf2d574005090a29dbd2", + "system/App/Common/Db/Core/MysqliDriver.php": "130b99106ef29ceb69939ea5a5dd820d2745333154b28daee7da21bac835466f", + "system/App/Common/Db/Core/QueryBuilder.php": "21b6952491b140899a7b1c864b5c0eb17241f590153682530504ddf8d4ee599c", + "system/App/Common/Db/Core/QueryCache.php": "c3764c2e36984d26f3443470e3898cc454ffd9adab8724085fcaf003fd446841", + "system/App/Common/Db/Core/QueryDebug.php": "4165770b044542a7c1bc909b1f6e677e3a00e61cb9884d2ce2b0c5f5bf212cc0", + "system/App/Common/Db/Core/QueryExecutor.php": "b35ee243d0ea9991fd32dda8807432afc4618dd5f08250ab6a8b2056e07470ff", + "system/App/Common/Db/Core/QueryLogger.php": "af954d965ee83f52b1038ae3ae2e25bbc862174b58342829ee39bcdf19949a68", + "system/App/Common/Db/Core/TransactionManager.php": "093e0bcadd8fa06ae24c016e0a132bb14f03ecf9123376ba477620f8812c2cea", + "system/App/Common/Db/DB.php": "2a2318e4e5012344ede470845369450cb273edbc4e10cc6dadf3ec23c6a6fa54", + "system/App/Common/Db/DB_Eval.php": "7e1a6372981fd292f1b64b910d7121d9d3ad3d79c46f9489b0e2ae04032e068b", + "system/App/Common/Db/DB_Exception.php": "42ca284dc47a574feed5b7eef9aa7b34c5226ad0950a61a0763e007fe15813d4", + "system/App/Common/Db/DB_Result.php": "0481bd3cf33374eeec690a5dcefaf1a1e9f9a7540effe25dc782efa8e94719df", + "system/App/Common/Db/DB_Where.php": "6ea85a6e93228cf59020f0163a2daa9a33ce1ef6479a40628c82e5c299c557bb", + "system/App/Common/DebugChannel.php": "278f6bb7bdf6341ac593b4554310b2ddaa8e39819f6ed18aef2285acab21e5d5", + "system/App/Common/Dispatcher.php": "188578002947ec52bd0e756c4eb738f800241af0c278efb166d8e79f33c6ad01", + "system/App/Common/DistributionProfile.php": "4dd4798fdc849350ea37bf9618d7d2b0305993848949923b247f1a2a9ac6cb37", + "system/App/Common/ErrorReport.php": "aaf7114ad3ad09297f40b2fdb2f28f3851c5387d13e1c033259645a423535845", + "system/App/Common/Errors.php": "5c24c0f7e9810a23317b20aafcec68a0529b9136a4044d47c92be9e40d6a5fee", + "system/App/Common/Exceptions.php": "83b5dbbb5791789a9ba5fa69ac049037742e5d8622e40e125860405935f7e0e9", + "system/App/Common/FileCacheInvalidator.php": "24d34113c9909e3c56d74ea35ed61a1744c20f7fa7ecaef49761f2a28a774a18", + "system/App/Common/Flash.php": "8fcdafb920b374a6f346c10a42e3f846003fccba97f4bb48fa7028aae2a0091b", + "system/App/Common/HookCatalog.php": "3cb5a62c901ec173097cea78935f4e00b05473ecdcd4e9be2ec0f4ee6ed07e28", + "system/App/Common/IpBlocker.php": "5536f4802233588671fa5aaace1c017ae8f52590b64213d5d9681a608ff3c3b9", + "system/App/Common/Language.php": "587672d76ade8b5e1b30e98796e3656a833515db4d09b44717da443e0fa7ac1f", + "system/App/Common/Lifecycle.php": "4a59040f213232dfc9cc5c3a9bee491eab5916b65accb47c9972ecfb2573b68e", + "system/App/Common/LifecycleEvent.php": "68a94bcf55a62dc88f0b464ae2ed13c4056f2722fa550cf31f4633f313c8e359", + "system/App/Common/Loader/LOAD.md": "5a402322a87c6d9d633cf58671ea410a80b2ce18c1f56cfca3c9d96e697db099", + "system/App/Common/Loader/Load.php": "9f2c6435d66787ba371ebd9a4d204bc02ff81720fdfb7b57b5987c9a97d7d9dc", + "system/App/Common/Lock.php": "12e23715b17fd5295e6ea410e42d93c72dce66d1ae20749a58dd3911ad5b1e50", + "system/App/Common/LoginThrottle.php": "de6550ad689f5743dee92e5071bbff1a12a68ffaadcbe08e2e5fffccc51f98d3", + "system/App/Common/Logs.php": "d23e9a3ba1ef925b37b756d4dcbba47b42edaa0ad732804b4c99d1dd2c0d8ec5", + "system/App/Common/ModuleLifecyclePreflight.php": "66749f25d63dde05a06cffeb75a0a22c5ead68e51ef6ac71578384fecd8520c9", + "system/App/Common/ModuleManager.php": "4ee256b23222b7501fb756e6e7b4422c53eca16540a9ffe0b2878c773d9a1479", + "system/App/Common/ModuleMigrator.php": "d9aea1aa006a1736d8314823053ecbb970cc7553f9af315b8b6fc092ffb07d83", + "system/App/Common/ModuleSecretStore.php": "6ef62294077db7f09b85ce26f3167b39cd980547d1f21bee213b59a9c7e2e6da", + "system/App/Common/ModuleSettings.php": "b3280ad547e59bfcefb4a338339a36905fab5db1548345b0f00e257d275a6294", + "system/App/Common/ModuleTagRegistry.php": "9e68b64303825a25ef4f71f31a7adedee9d2752e4d1d06162605da1dc510a6a5", + "system/App/Common/Navigation.php": "1a1b0a3552d941dc0fc59c334ab705e86a73ee96ee42871dc958012bec1d81f6", + "system/App/Common/NotFoundLog.php": "3b1cdeefd05ae250e90d1a05dd9e1bc0ba2cb0c2cbb89d504fe009151f20452a", + "system/App/Common/OutboundHttpClient.php": "2b3f4beef15f399624e21abef4ea58b9a214b1f1e935ae3dad6531e34bb763e3", + "system/App/Common/PackageModuleRuntime.php": "8afcbf0439fb3b69b6b9e098109eeb2fc1521d3435d5d2d654085d5bd2b2a470", + "system/App/Common/Permission.php": "8e2241bca829de0104741dc2518860cd15489866dc310437e07aeab2bf0f0355", + "system/App/Common/Proxy.php": "707564208ec8c24ac2ffb5bf41cba34f1b39059ce6bda31a69aaf4b116d864e2", + "system/App/Common/PublicAsset.php": "06f9ae414f7336206073e07597669991098114c396e80748c654c82b370a39c4", + "system/App/Common/PublicAuthSettings.php": "521599642c667a00b610dabea8a4a8f2ef79af48fb6c907878882f8252297040", + "system/App/Common/PublicBootstrap.php": "1208a8797bbe695d4607fa804f654402883bd47f926e0a39fa8766928c16e509", + "system/App/Common/PublicConfiguration.php": "1daf6c26de04277f8618be4e25453aa479de9f7dfc09ba770d405eb7f9454760", + "system/App/Common/PublicModuleRuntime.php": "5aafede8a2c0cf35023419a024566ca453676ea2dd9274a4d5ca99faa9273d15", + "system/App/Common/PublicRouteRegistry.php": "dc5129b12f0969a32ed605c8bece174fed11b679ce4328d426099e3e38ccf688", + "system/App/Common/PublicSysblockRegistry.php": "0b9112a40f98699db6d2ae802e346fee27ece77de8720b2dc30d8b5cf207ed81", + "system/App/Common/PublicTagRegistry.php": "787e3cf4014b501fcbf24dcdbe552a0247e07586826f5ae4584b86bb781b8d5d", + "system/App/Common/RateLimiter.php": "906313be7ff8e2570dfac8b7c261fa74d6e814e052c40ceeac1cd840c7cca3c8", + "system/App/Common/Ref.php": "e5e55093871022e86a7c8e8841e2e150922a7454500668dfb6f30fd50aa6044b", + "system/App/Common/ReferrerLog.php": "a2da447a548011c9ae6f8be2ef4aacac2a3d4c38b62a19d7ea5442589cb7febe", + "system/App/Common/Registry.php": "7b4ddbb3f96a7ad54a8d43e151cb595e598d3620b368d21721927ce9d54e1ea3", + "system/App/Common/ResponseSecurityHeaders.php": "0c20afd7d42f0d878c53f6088dbe4c03b6fd26e57d82a040c775fef180b24a7e", + "system/App/Common/Router.php": "4b0dd3fd9f732177070ce3326b691a8decfbe698aa0d8cf687a18f6c02b73d91", + "system/App/Common/RuntimeConstantSchema.php": "08daf898ed9bfa12940adf8ddcd3e0dac811d28de8b798770afc42cbd20f1125", + "system/App/Common/RuntimeConstants.php": "decb1277dada75ee2b5c7d531f064db7ffecef5b3ad71d306111b2a45121d34f", + "system/App/Common/RuntimeDirectoryGuard.php": "adc9ae97a5e5ae494e3b58073f195426daf2915bdae02c4e3e07312df272fa6b", + "system/App/Common/Session.php": "fc2c805cd57e622b5a030afa4bc9fd490055b82b7ff1993945ec664a536f7371", + "system/App/Common/Session/SessionDB.php": "6340cd88d5ce61ca39601347275547293a0b857cdd3978a4293eaa71076f83db", + "system/App/Common/Session/SessionFiles.php": "9be6009241d0cf6f2f909ddc6881a3dc3cf1de0aefd5ef6103de8032c997dbf0", + "system/App/Common/Session/SessionNative.php": "3ca6de1038e0b26e03e0797e9b1dc321ec383d85ad058503509d6b369d88168f", + "system/App/Common/Settings.php": "d739b499a2d3c893f387cf3f5033815b863edf08414b1565063fb9d5e7d53f0e", + "system/App/Common/SettingsRegistry.php": "0a24cf4e55e48d05a8af09ac00bbcbcc1179385254af8fd914e754964a1362b8", + "system/App/Common/StoredPhpRuntime.php": "2bd4fa83c970cfa0b8002b11cf36bd0def7b37ea19c025f7c6b2f37f0de38286", + "system/App/Common/SystemTables.php": "dc1830e972a32a87770bcfa3b4240524f49bb9f93df75d48e8ae05c0cc2004fa", + "system/App/Common/Template.php": "5a0cacde5059fa9918321e6baa71a09a754a024055dedffae161486c51902f45", + "system/App/Common/TemporaryDirectory.php": "4aad5e498b0ef5483427c18ec6eaaf9db64ffd1348a0ee3f445da080987e4bd1", + "system/App/Common/TrafficAttribution.php": "cef0bce9cbd2ca3d8dd297cf1b969669ed1cb996cfa175841ecc6836ca7eb8e0", + "system/App/Common/Twig.php": "c6b62e0d1a9576082babe49d9d31e8f9f63016f819a418dd723839851a32c455", + "system/App/Common/Twig/TwigExtensions.php": "b9db3c89ea361b8b4ed754741d5692fc05831a7523a7f9832a9128c313a5b8e4", + "system/App/Common/UploadPolicy.php": "a8ad388681a65a31981767c2d1c17f635f4c490a17803f35d3548a47758c596d", + "system/App/Common/WebMaintenance.php": "a91f9ee8371da6c6a020d9ce39e1f9f7b517b2644ea51ada65f1f487ff337e6b", + "system/App/Content/BasketTables.php": "1c4d1d9a5ff894c6ebb0edbcfd7bb6f791124e46dd8bed3b70fb0ef2c15069a0", + "system/App/Content/Blocks/ContentBlockRegistry.php": "54f8818bab252b8e1135f053cddb24e0c8cd91b9b395aad71e32a39db1ef5569", + "system/App/Content/CatalogMenuRepository.php": "1ef7e4fac76f1b1571eb85611002cbb18bfd61829471fb189024c651efed8355", + "system/App/Content/CatalogTables.php": "5abd93b17142ea5c57a0726c8c6e3dcaf896790cf4ba31ea69be19a828ac9517", + "system/App/Content/ContactsTables.php": "d0042c761b854903b483e1b3a30d6014c596eef1a03c40cdbedae4fecb3c25d8", + "system/App/Content/ContentTables.php": "a63105d8f3859921a9c1d17a4b8c69d3950ff0294de144814bd2b4768d236fde", + "system/App/Content/DocumentTerms.php": "353d0a36e0cef77d4016f9edf9c2a2a3a27db953a3eb4cb91d7ec49af18b0609", + "system/App/Content/Documents/ContentCacheInvalidator.php": "d2bfd72d4df1a05e281b88543fabb8356ffc9799d37286c3767c6a0173ea91b1", + "system/App/Content/Documents/DocumentAliasRegistry.php": "aa87cb8b65990f480f5b4f5dea83948dec9758fb4069b43eeba4be7f7752be51", + "system/App/Content/Documents/DocumentAliasTemplate.php": "47bbcead460d5d4160daea31ebfd36ecc3657f74f905162f20ee0d1eae78e3db", + "system/App/Content/Documents/DocumentCreationPresetRepository.php": "a58de0560e194d8386932a4e2e0e807a097ba1bad1f56cc8d11587f6c20e54c3", + "system/App/Content/Documents/DocumentExcerpt.php": "8c5742f976a492531af95f2291e8c824c3a9804a54a27997f9f51a716a57f19d", + "system/App/Content/Documents/DocumentHookContext.php": "021d6e15aad1bcfb9168149189ff7cae13b1f88020a76c07d27f22ecd7d6b5a7", + "system/App/Content/Documents/DocumentHookException.php": "a2158338b544e5ba49226efa3a80c52b4f60c88d1112a3ca1a72f50043abe0a0", + "system/App/Content/Documents/DocumentHookRuntime.php": "349ec3b4f0bbb9229299538796e018d6a506a61732fdfb7fea10769feae94fde", + "system/App/Content/Documents/DocumentMediaDraft.php": "7a32aa57a7da2489b4a4af58acbbb007187ba22bec29ca123a185706a5a65ce3", + "system/App/Content/Documents/DocumentMediaFinalization.php": "5ea218f79f835a4fc25da0199aab87137e620b60c31d8e2f587a1b911b3af6b1", + "system/App/Content/Documents/DocumentMediaFinalizer.php": "785487028e221726c6bc15662f83ed985701a8e5d21e9fc9605e816ef4c835b7", + "system/App/Content/Documents/DocumentMediaPath.php": "23b40c98d3cf9a73df3b7f7083b58b60dd80b8bda71e3e3a6354342cc72322a8", + "system/App/Content/Documents/DocumentMutationService.php": "44e83fad0d2cd04df2d1a9338891984f52b06f0b886caef59ef2f6d1281c6784", + "system/App/Content/Documents/DocumentRevisionPayload.php": "c7f83350de98b6d98531508eafecc4a2b5a0551c7f2ba77a9e5b954fd1287ea0", + "system/App/Content/Documents/DocumentRubricCodeRunner.php": "093fd372fc01f0ff1640379715c058ef52db2451e66b65bed84cc2d51b49ec2f", + "system/App/Content/Documents/DocumentSaveEvent.php": "8a2798041cd72be693278cf91c04358a7f486d1ca8035b17651455f199312492", + "system/App/Content/Documents/DocumentSaveEvents.php": "52fe587fc4419b5ec86144f522b3028682b384a52dafb9690e0928f8e68acdb8", + "system/App/Content/Documents/DocumentSaveRejected.php": "4ebdc1c5aa622f6389b107320a7786b8acde89438f6c56a660317deb623cec5f", + "system/App/Content/Documents/DocumentSnapshotBuilder.php": "1d9437433d721198fbd18c6cb141657da93c569fabba46bc59466981b68b5ee3", + "system/App/Content/Documents/DocumentSnapshotRepository.php": "54faed521c064f84cc4017e65ea0dba8b9bb34571240a90c083eb076c9a11b1a", + "system/App/Content/Documents/DocumentSnapshotStore.php": "e61a7873204ba76b50d2ae7f087a59442cb93fa10ad5c3f0ac0087d941dd2075", + "system/App/Content/Documents/FieldTemplateManifest.php": "8eca7f9c9056ad5492b2b22cc55b407d8a65b32f5889775a5ecf6b4caae7ab8b", + "system/App/Content/Documents/RubricSchemaBuilder.php": "a391211bbdbde83a6b8f4ce2ccf98bad2ad2f5d39f35edb5e262782729dd86f2", + "system/App/Content/ExtensionTables.php": "7e59c929815afc570dbd6e0af754339ee94a1cb32b928edbc6d50cf34bcbe085", + "system/App/Content/Fields/AbstractFieldType.php": "a6be3a44263f07cddcdbeee600e48f224cbd948948740cd7cdda34c2d7fa394d", + "system/App/Content/Fields/DocumentMediaFieldTrait.php": "4f5845f44cae6bfa3333cf39ba56edeae580359dc5edbc89fc64cefa7912eb34", + "system/App/Content/Fields/DocumentMediaFieldType.php": "f2a461a3780354159a915f4f107a479c7326f255357ee82eec25d3bfc658b4d1", + "system/App/Content/Fields/DocumentRelationFieldTrait.php": "eb397fd4aabeea93cf4d280f7c680f10d92a17596bafaea8c3eddc4bedb17c78", + "system/App/Content/Fields/DocumentRelationIndex.php": "40551bf019428d1f6f81f07f3b8f45d0ebdc349387303e4d9085c413d3453444", + "system/App/Content/Fields/DocumentRelationValue.php": "d5366bddfcceb62d0503da6e9e4e9bb612a57a96f85a2695c815b98d0100aaa0", + "system/App/Content/Fields/FieldConditionEvaluator.php": "93ef44322ee4da475a2d7b7c0964d13578a67ededb3da8a5d7e5ebcd05aecf66", + "system/App/Content/Fields/FieldContext.php": "97e4d4171bba1ce5c5185068c33eaeb91fdfab670b1ce3fc9f56e10f5cca1d0b", + "system/App/Content/Fields/FieldInstallationAudit.php": "0e9817806c2a8b1f9ca64ce9c4f8bbc97942942d7e77fbb9fd62dbf6070a1216", + "system/App/Content/Fields/FieldLifecycle.php": "ee33d7f6e821f71d39bba73a73782021a061edbdd0be7b34164f8b94ad505904", + "system/App/Content/Fields/FieldRegistry.php": "c993388ddd8ec9cd8496d3e812da83a271ad30271a8ee87b79b2f94df84aba9a", + "system/App/Content/Fields/FieldSettings.php": "eb849a290e61d7149f69475d1319b06e5b19cd56f1cafdec78f95f37a20e475b", + "system/App/Content/Fields/FieldSettingsForm.php": "c3988a6fd9b51ae3a2efbbe77fdbc9539090c8682c97329d4d40c64a9c1cd899", + "system/App/Content/Fields/FieldType.php": "47979aaa49b773e2a00288ffccb746218b58ece8fa6d178a90d0cc9c853055d9", + "system/App/Content/Fields/FieldValidationException.php": "1a1d6d5278d6f8ff697e5afe75e0a6f59db2b12121c93b786fd87f8b81287717", + "system/App/Content/Fields/FieldValidator.php": "5408849670818b379a66d5db9b7aa0d94ebbd8438d7788124f62cd221dae5561", + "system/App/Content/Fields/FieldValueCodec.php": "66b6981228683dd9b6c8bd850fe2fe84de5ac3101bf9abbbb91664a7edda5f2f", + "system/App/Content/Fields/HtmlSanitizer.php": "a716694280998f65bdfd6adef26c0d0b7e2f912413fa531a0b2070a09f6cfee0", + "system/App/Content/Fields/MediaFieldValue.php": "5821060785f859b414a448d4998a9e7b40df69d536574f0fbe600f1d7611ea35", + "system/App/Content/Fields/PublicFieldRuntime.php": "9abd8e5dd43c56b27dcbf87d258cd9764ae1d67e95a397a63a6c6516f7a417f8", + "system/App/Content/Fields/PublicFieldTemplateContext.php": "785d6757710dee59d8d30756917235bb4f49eb5bd743c01d2365c05d74bf0a5a", + "system/App/Content/Fields/PublicFieldTemplateResolver.php": "72c0750c50129cbabc0a97147fe2f2f4b2b3811cd77f4de197dc3753db46903a", + "system/App/Content/Fields/SerialFieldType.php": "61685c9a19c0fe9aede73f16f7bdd730dc3562d6223e7b3d4eae5e3e303fce1f", + "system/App/Content/Fields/Templates/date/field-req-177.php": "4dddad6279ed747d2d4546108e22c6a3fef042d0d352be8c96de73d88639d4c2", + "system/App/Content/Fields/Templates/date/field-req-185.php": "23788ff41c2bf3331ed455e41ee0d51a27d7b195f6a7f376a4a3548cd0b6af81", + "system/App/Content/Fields/Templates/date/field-req-187.php": "fad69a87af9aaae52ab7a24cb88d49dac1d9f3ad604eb79cb24c6d9d8555909c", + "system/App/Content/Fields/Templates/doc_from_rub_search/field-req-137.php": "d6063dc9910e224a8e4c182e3e80d1eb5d91b1f150391a04c4cc58b2de64f375", + "system/App/Content/Fields/Templates/doc_from_rub_search/field-req-180.php": "1d9dd5820679cc4c65c93d2bba428e5d9028dbda0325f00639e777fe8aab550d", + "system/App/Content/Fields/Templates/image_mega/field-doc-22-1.php": "5b7c3e6a0bb9085f1622ba5114c8887f34007c011b3e931b555514fc43687c14", + "system/App/Content/Fields/Templates/image_mega/field-doc-22-2.php": "8b7b566dc64cd6fd9993fdf7542e52977629cf33ccfd02fe0eb98cdee07db7de", + "system/App/Content/Fields/Templates/image_mega/field-doc-22.php": "cab8ad33b2e780c305c4793126e66f393639176db917c102ecfb93e24b563488", + "system/App/Content/Fields/Templates/image_mega/field-req-22.php": "dab5bb2cafc214f195c53dd4032451c857e0e225056b0ba83599da7eb01cba36", + "system/App/Content/Fields/Templates/image_multi/field-doc-123.php": "d8b51ba166248b65eacacdce86d52dd69d7a3140f2ee23510df54200938ec25f", + "system/App/Content/Fields/Templates/image_multi/field-req-22.php": "da544da1c22a7d276da5921589b84ae52c5f6ff522143170de521e09ae209049", + "system/App/Content/Fields/Templates/image_single/field-doc-14.php": "67acf2ec1ee7600a1d2ef8a23567d457a858d5b43eff3bc687d77378d2d71c42", + "system/App/Content/Fields/Templates/image_single/field-doc-18.php": "ce9188feb56e64e61faad54674376d2822a15a544f1841163ca708442f84e5f0", + "system/App/Content/Fields/Templates/image_single/field-req-14.php": "8bf6ffd26a5a861ae844c35fc8539532fa71c0bde40eb297cceef9a00fa431e2", + "system/App/Content/Fields/Templates/image_single/field-req-142.php": "13f0db85d7c077e96ae8c8e32a854fc29f5d7259843b10ac62cedcad61d090f7", + "system/App/Content/Fields/Templates/image_single/field-req-159.php": "7692d5f09db63ca8bdd36871904b0ae1906488be5fd795347bde02d15881e9f7", + "system/App/Content/Fields/Templates/multi_links/field-doc-137.php": "c490f9d98503dc3b9f0e55ee6287ea508c358b6cb636f8984293e1c01a9d4864", + "system/App/Content/Fields/Templates/multi_links/field-doc-181.php": "e7cff37603f088d32c3dda333f5d47d6930e15e84baede7519539d714b0fab66", + "system/App/Content/Fields/Templates/single_line_numeric/field-doc-200.php": "c5ccd1afe8938f8459a14e9199b39190eee7ffc3e15a8bdcfddaa2e7875b0b27", + "system/App/Content/Fields/Templates/single_line_numeric/field-req-157.php": "50ceb1ac667ee0bf5f8bcc46d07343023fd89087f357ea701ca9d31f89e7194e", + "system/App/Content/Fields/Types/AddressValue.php": "ae244e819453247a385f604b7ee4df3735bb6474346686bc29a0cc32c35133ec", + "system/App/Content/Fields/Types/Analoque.php": "d4169e7474f700831a5b572163a75185e410447c2f6c56050226fdbcc5a61e19", + "system/App/Content/Fields/Types/Catalog.php": "2126e5a6bcb097f96c5851a8b84a5fbbf04c0c83de200491b9a68d51caf5b597", + "system/App/Content/Fields/Types/Checkbox.php": "ad762af437e80054e0cdc1e5e16712899590c2806d6557b1dd730088392a0e7c", + "system/App/Content/Fields/Types/CheckboxMulti.php": "81fe85ee7d0d55c26d9bd260a0e75aaed4ac516d6da77cf3071bbe309b1c1cc5", + "system/App/Content/Fields/Types/ChoiceValue.php": "fda95e47a525e6e6e49b6a74fc23876b657be091b0ecfe3bfbaface6af9cb662", + "system/App/Content/Fields/Types/Code.php": "b517c1ad6bdde09ddc80ad83065ad30575d8a9fee804bfc54b3324af9c334d98", + "system/App/Content/Fields/Types/ColorValue.php": "8a68a4fdf628dc30922357bb0918235f373e76034d137afa270004edcd947740", + "system/App/Content/Fields/Types/ContactValue.php": "b6f8533a8de393b4c760ea69423b8bd1c1fa0717bc5a608cca7710f58c751f11", + "system/App/Content/Fields/Types/Content.php": "e7e41bca3dc574e5735446593ba875d59ec1baf4769cb130ecdcecefda65709d", + "system/App/Content/Fields/Types/Date.php": "4952e8ff31abdc8d133e6f812e075847a49790c9fdd1865357cbb4080b0bb8f0", + "system/App/Content/Fields/Types/DateTimeValue.php": "592761636f12f9674d928d9ee7c21307b2c8402de6e9a2c2b537e5cfb51fe07d", + "system/App/Content/Fields/Types/DimensionsValue.php": "36a323ab98c2c011dee03eb30ee9ebd55f0450aa74645b0031f7202bcff77a7a", + "system/App/Content/Fields/Types/DocFiles.php": "89f032e3ad3980ca52d878965e71efba8f98a01a95daa0fc17feb228da925541", + "system/App/Content/Fields/Types/DocFromRub.php": "03c7186a57511312a617bf77abee571ff3f1941685bffceff417c9adbd6d60af", + "system/App/Content/Fields/Types/DocFromRubAll.php": "127b8a437e82865010d28f72be02d6ec604f62bd92f6defd696cc7b5358d7c49", + "system/App/Content/Fields/Types/DocFromRubCheck.php": "ae1ae082ab0eb68d687e91e8c5533181c4bf2e63479fbe59c5ec6e57e1a491d7", + "system/App/Content/Fields/Types/DocFromRubMulti.php": "5ebbc0abf7c5f0306d46c01e2173f3750c418377883605a75d4deea3be0f9218", + "system/App/Content/Fields/Types/DocFromRubSearch.php": "0b91d898710c1829de2a404e4e259c0cf926dc7e987be9eaf2e535f9ab86cfb9", + "system/App/Content/Fields/Types/Download.php": "8906a382c05547afd112778b02f0fd21a1061bcde4e3b72d7e3682acd13fa88f", + "system/App/Content/Fields/Types/DropDown.php": "e8252d211d9e30478352a7eea14f758f054f3c03b12b9874cff2390315e1cfa5", + "system/App/Content/Fields/Types/DropDownKey.php": "b50c064a38b10da7065851ea9b8f0fecd74d6a438dd7a64ac4abfba89ae71282", + "system/App/Content/Fields/Types/ImageMega.php": "c6e17503fabfeab926ae695b4e2a7c5dd99f60dd5bf0ec84b04bec6b96c6292a", + "system/App/Content/Fields/Types/ImageMulti.php": "cc1d9a5d8367e111a3b4da9eb5c7112a78fa438be505e5f32ac5ad579e2c7a2f", + "system/App/Content/Fields/Types/ImageSingle.php": "a4592703b10f25e1a97e82d1cbf79515233a8ce93d35598f9c77f37db70f4c21", + "system/App/Content/Fields/Types/Link.php": "6c3b7031d8a1b78371c041b142a90a373217afce12e637d29cc0a6b4ed126d7b", + "system/App/Content/Fields/Types/MultiCheckbox.php": "26f4f9d1bbd7cbc24bcfb827565e9415e27f31fe1a2bc644e1f42760c7d1f213", + "system/App/Content/Fields/Types/MultiLine.php": "9cdba0fb14c1053a2c88c667f436eed2da243836d49ca6c3240612f32bdfab52", + "system/App/Content/Fields/Types/MultiLineSimple.php": "0286b13ad1a2e83b5fdc8b11f05bcb99b80b38a018650c25e58141785fda0fda", + "system/App/Content/Fields/Types/MultiLineSlim.php": "4c39e5810a9e2df82a7e03af7eab36b07c20c863fc31d3aabc2e172103b41d84", + "system/App/Content/Fields/Types/MultiLinks.php": "a4d1ae15f0c23934a5185fe7407605079873f7a018c6ddfae0998f2d1c3312f6", + "system/App/Content/Fields/Types/MultiList.php": "1ae0262a38c7b1b3ad67688bed05beb620745969b36fa5ffc6b7b052f16a40f8", + "system/App/Content/Fields/Types/MultiListSingle.php": "45b86fb20ef18d9ffc6d1f3281b7ca8e6593269e778383cab8cdda0cfed2b9a4", + "system/App/Content/Fields/Types/MultiListTriple.php": "431c96998d0d9316a63c9521d5c2b306f06501576632f4deb5c04eb3c25e0891", + "system/App/Content/Fields/Types/MultiSelect.php": "cfd50ea68a31fbbfb069e4830077b0183ddc7686efdcd97f7509e9108564ec18", + "system/App/Content/Fields/Types/NumberValue.php": "8fe7bc2aee7df6031598384627c8c38b8b4a5b04d796c25ee8aab768805d34ad", + "system/App/Content/Fields/Types/OptionFieldSupport.php": "73015455939f82179cc0b861d3b94faf6fac27bcec303f958d252c31f8744871", + "system/App/Content/Fields/Types/PackagesValue.php": "b83d3ac394c42204ab89d4edcc5bba85e95c8850d07839f53273e958db346c11", + "system/App/Content/Fields/Types/PeriodValue.php": "afe067af7705b027652e2210ae784977a4fa00344667b672c3314d463d238197", + "system/App/Content/Fields/Types/RangeValue.php": "396b6dcc4f4b4a1875c033c31dbf7b1e4ccd120a79bd86cbde813d7ec57bfa40", + "system/App/Content/Fields/Types/RichText.php": "90db4b29a796916533260de916443d6e4a76bc4329622fca7481f0597de8468a", + "system/App/Content/Fields/Types/SingleLine.php": "5d41fa26af3bb6dc0ceac95534ea228b3355df4ad2856277f62034232e31e41b", + "system/App/Content/Fields/Types/SingleLineNumeric.php": "a7cf2fec71fe4c0efa6a6d97dbee69510627068d1a5192661d3051ff660448ea", + "system/App/Content/Fields/Types/SingleLineNumericThree.php": "38f5c7a093933e879d6930323ace96ab8b3acdd482ef8231af9cb0a80df48153", + "system/App/Content/Fields/Types/SingleLineNumericTwo.php": "53d0fa7544b7c612602d4e73ab1cb6c650f5351e004d1f81d476e85a1f8b1ac5", + "system/App/Content/Fields/Types/Tags.php": "3718568f249d5c75bd9da4c67266efc93f45a3120b6f470f2cab48a3afc114f7", + "system/App/Content/Fields/Types/Teasers.php": "cc49fbf43feaa1e875df65a8b7c3dc47c044ff52ddfdaa6bd3ece8adfdf9891c", + "system/App/Content/Fields/Types/TextToImage.php": "5a272c24db87738b6d0fb81f73f9af6cb74efa1baa03d74b3d6c8783c0fd5724", + "system/App/Content/Fields/Types/Youtube.php": "4724937ec212360e8da7d28f4e1a35e2308325de77b867545236a6645d9278b7", + "system/App/Content/Packages/ContentPackageWriter.php": "dea33706a29e11d2a04ca6904bad49f8ff6c062b2cc1eeef1bf9ba18f49adb00", + "system/App/Content/Packages/PackageDependencyContext.php": "8a1f8ca6bbb7add9d8af11d28cdf7137c9a9eaf25b528b0259cf9bd0be00c8b7", + "system/App/Content/Packages/PackageModuleProvisioner.php": "28a922ac5471d86ea0c467de11164c97fecd0ee432814e79927bb875995629cc", + "system/App/Content/PaymentPrograms.php": "3f6a6c1b5297001d0ee5027fa6fccb2323d5a603abd045421a83cba65e4e0ec8", + "system/App/Content/ProductShipping.php": "4a33a877983a8509ce35fdd88ed95db43591d3b35d5e4f5d9a5b67c566434c9b", + "system/App/Content/PublicShellTables.php": "f89cd224436da9d1122c988a3a0ac19e5bef85351c51649506fe16a05c514cd9", + "system/App/Content/PublicUserTables.php": "56745969410ab9e8e4272103657f805e346336db34166461de7f2113a6a52ac2", + "system/App/Content/Requests/NativeRequestExecutor.php": "988a1635bb4602b55edb89a1fb8dd26ee0537c074c77fc86b00ecbd8800517ba", + "system/App/Content/Requests/NativeRequestPlanCompiler.php": "9e583cac4c41ced0c105d27c76786857ee50d15bb86d0049e6562924eab78779", + "system/App/Content/Requests/RequestConditionCompiler.php": "fa076e03e00907a7c47c6072bfb8f6a8406378c723e331fceedfaad59068d58e", + "system/App/Content/Requests/RequestConditionValue.php": "5a6122e53c9c4dd8d60691248c27d36057a77170c0c8bf0d8dc6a6236600f7ca", + "system/App/Content/Requests/RequestExecutorComparator.php": "11ad4524e3c86e270b343d5b52960a5b98a9d602d78bb199e92f69f4c64fb47b", + "system/App/Content/Requests/RequestMatchExplainer.php": "884f01b19bf0a8cbee7694a799088b7d9d63297eb1980e79c797661f069ccff0", + "system/App/Content/Requests/RequestOrderDiagnostics.php": "cf11ec143b145457dd004fc1c2e65c8215fc39c42519d90f291987e62596490f", + "system/App/Content/Requests/RequestRendererRegistry.php": "11b8496d0135fc54e86b3442bf1b2871a0e6010dea54ac2d3ef05f44cf1bf9ae", + "system/App/Content/Requests/RequestResultContract.php": "4dfcd11227117722651f9f7e7c54cb7458754ccaa64b31b6f8deb161e7704a4c", + "system/App/Content/Requests/RequestRuntimeValue.php": "9f0b9eeec1bf32cee8c13e1298e6b64c5a59f51c05d86b798523125a91ea8274", + "system/App/Content/Requests/RequestTieBreakerAdvisor.php": "bd547d4dd74c45246e3a49bfef3320964d723c520fce3339ea5884b89aabd08f", + "system/App/Content/Requests/RequestViewPreview.php": "ab80344f456d50aef43716572179b411a60af47523a71bbd45d317cc5967e899", + "system/App/Content/Rubrics/FieldSetMerge.php": "15ae21c53a11a3c40f59abe3b117c640506def0355964216ad7350f8cf8e66d5", + "system/App/Content/Rubrics/FieldSetRegistry.php": "a9df395474b9a28cdd1dd5dba40fe49e485835218afe472c414b9696094b9a70", + "system/App/Content/Rubrics/RubricFieldMutationService.php": "3fec278925d14f7ec2efed63ba51cd91def333220ca4e18f3a42a202a8806674", + "system/App/Content/Rubrics/RubricMutationService.php": "e3190ef1ddd94bdaf077148ce84c5758b2a32683369730a2b847dbbd0d72a74c", + "system/App/Content/Templates/TemplateMutationService.php": "8c7e52202454e35eb0c8176325cc23ba760ac40ff0430cbba5e1446c83cf7a87", + "system/App/Frontend/Auth/AuthService.php": "58a551f0c372194ffc2cca3e12f1e9a8ab743d07682f6b2c086ec1f7bc77ad54", + "system/App/Frontend/Auth/Controller.php": "e6e44f8db67f822edda968f9503790ec6c80142c0e3d0fefe61a5f3eab186234", + "system/App/Frontend/Auth/Feature.php": "60ff394cbf95ea3ca3f35476a6eb2bc4016fe472be85cd11ebac117635a54440", + "system/App/Frontend/Auth/FormTemplateRepository.php": "97882ee4b46e0c4e31493a44831564409b5a64c096363aaf812f181d13abae64", + "system/App/Frontend/Auth/OAuth/HttpClient.php": "b09acf92b312bd05ca78500fccaac3b77d36a1046d64d3615a00d60af4e3594f", + "system/App/Frontend/Auth/OAuth/IdentityRepository.php": "41cca9011a1f0e56090cc40361594ba223316516997d52384a29fdebfba6a707", + "system/App/Frontend/Auth/OAuth/OAuthFlow.php": "842e9c9bea61578bdf124dc8bd1921e5c505349822a95e5812bf09e2710f2278", + "system/App/Frontend/Auth/OAuth/OAuthService.php": "c310a2a6756425a4b2875b12c048b0476dd5b424e89561ac1fa73aebfb3cf045", + "system/App/Frontend/Auth/OAuth/ProviderInterface.php": "c99525fd51a1bebc24c189f52f1a08bcef087b98ffa960d1b378deb68022d9ba", + "system/App/Frontend/Auth/OAuth/ProviderRegistry.php": "e0ff4bc12ef2349d4f914299c78715438d9fbd44ab98e00937534786baa07800", + "system/App/Frontend/Auth/Phone/ChallengeRepository.php": "1e53d355aaf0adcc8e10a2b1b50e638916fd688c62a7ed6a4bd9c161c14a6390", + "system/App/Frontend/Auth/Phone/Controller.php": "bb07bfb74a89742c2dbeeb7edf79f02ae43ef6a252d02a3bb7c3c78144cf280d", + "system/App/Frontend/Auth/Phone/PhoneAuthService.php": "90e96d1616df285713dd9f41f84af5a21aee2af3f1ddf8ef86dfba619f6ecd62", + "system/App/Frontend/Auth/Phone/ProviderInterface.php": "55107c8f6a89bea01b91314dfe523b684bccf5bf9f1a3c8f047fd3a509aa6d00", + "system/App/Frontend/Auth/Phone/ProviderRegistry.php": "fbeb488c9b63843085b680500ecafd61389a29f55e0d76692550939e378d2f16", + "system/App/Frontend/Auth/ProfileRepository.php": "9cd7be35b048faf407a925e6369a5895b8bfafe6cf756eb3c4321ea4979a9af6", + "system/App/Frontend/Auth/Registration/CheckoutRegistrationGateInterface.php": "6ce038a47e21b594a47bee16075dbf102775b493efd5f2304fa8f80341d34bec", + "system/App/Frontend/Auth/Registration/EmailRegistrationGate.php": "52617ab06e53ac64c3168fac48260c3c8382d7eb2bb9dbc280942f7fb3bfaedc", + "system/App/Frontend/Auth/Registration/RegistrationGateInterface.php": "99e73d0d6db972bb5b2c2a7a21dd50823a9bcef7d05937c525d0e7096775aedd", + "system/App/Frontend/Auth/Registration/RegistrationGateRegistry.php": "bc7c8546780daacd23243d16778062384f043661674956d1a5a47a8b6440eb44", + "system/App/Frontend/Auth/Renderer.php": "2a14b5593495e1b587b79ebc7715a4bd37784a104030837e10b056bfed63ea3a", + "system/App/Frontend/Auth/Tags.php": "92fc1c466e720be36a0adce5a735e47c94bf88d9a3e70ba9d62196ef63e639f5", + "system/App/Frontend/Auth/TokenRepository.php": "5e09b154d4747bfb3d2795a9b449ae4aefffe47508edef92d1a7b392c0371207", + "system/App/Frontend/Auth/UserRepository.php": "21bfe56cb9f2f74ab9c58947cac9e8132159afddc0900d7d7136e919c3482bac", + "system/App/Frontend/Auth/assets/auth.css": "29724588981e3127908cb70b33fbc9711894e1e716d9618aa14309b172d65070", + "system/App/Frontend/Auth/assets/auth.js": "840c7322fc21f9a8dd61af8cd16e0ceed36d6beaeac45d948bedbcb87b6012c4", + "system/App/Frontend/Auth/view/login.twig": "bbe2c12ce95f16cb2b2a07f945c8b8798e9386e9e3cbf16ddf5d0a3ca3944016", + "system/App/Frontend/Auth/view/message.twig": "4e9a660af7d0deae9424a07550816c401902e75279414b11657e0eddf573deea", + "system/App/Frontend/Auth/view/oauth.twig": "7ccb2c7ae36a378586334887c32fda96909cb6fa235601f1f697d4c8049af648", + "system/App/Frontend/Auth/view/oauth_connections.twig": "e2d845ce9d906ca12d3f642e92ede1aa415203f7dc2ac472006f40e80eb8bf27", + "system/App/Frontend/Auth/view/overview.twig": "97ad0cd5f72f63b6c92139f7cba54041840131975fdca417352dacf7f64ddb68", + "system/App/Frontend/Auth/view/panel.twig": "3e7237263702462969b937254b6e2d4268599352f801ed4681db7b1aab07f508", + "system/App/Frontend/Auth/view/password.twig": "099d7c1e48649faba57eb50df9bd9a091c79dad12e9c1fee9b38c3f2e025ed80", + "system/App/Frontend/Auth/view/phone.twig": "969628667cd4d518987c1b9bb1632e70f44de6c9373e7e92b5ef8d94584066fd", + "system/App/Frontend/Auth/view/profile.twig": "bd2e46b731ac0e3454c91c746a81c20690bea613803363aa58f4a6ae50b494eb", + "system/App/Frontend/Auth/view/register.twig": "1f97ce555e5aafbcf2b4f6942e3603e798a7ee2b5c3dcf197fb5afba44978810", + "system/App/Frontend/Auth/view/remember.twig": "fb170509256e4ecf13ebe5f64cae8cbd6ad8ef143abe645f4bc1f269e0d8a0fe", + "system/App/Frontend/Auth/view/reset.twig": "5dff5378aa0e2203fd67fe9f1dd61586ee51e3d3f6138011b9d2b5dec4da31aa", + "system/App/Frontend/BlockRenderer.php": "1d23d7ff85ba1052f896e204d1885f99ec2c0f6382e01d5d1903d69fe661305b", + "system/App/Frontend/Bootstrap.php": "f67e5d9aa1dbbf228085b67ba4258169695ccc349f4c4a38ed9e1848e7e666dd", + "system/App/Frontend/BreadcrumbRenderer.php": "6db4cbc31bc4e887a8ab4222c0359008dcc8d08bca969e9e3c3a098db12794b4", + "system/App/Frontend/Captcha/Controller.php": "eb398d3bdc711a0543dd293b00f250e05bda477fa4577e76b33fafc23f7d2eb2", + "system/App/Frontend/Captcha/Feature.php": "8e31714dc3101a3da8bac23989cba32ee9776a9f1fa5ec888a50cc65c8494972", + "system/App/Frontend/ConfiguredPaginationRenderer.php": "df4d28a386e4180fd0dbddbc30f08ca8c6345e2f3b0d196482fa9d2a5d4f4d32", + "system/App/Frontend/ContentCollectionTags.php": "3efbbc93bf1244d0cf198615cdced96a0a3f94c442b926d4fb1b578b62b7a951", + "system/App/Frontend/Debug/PublicDebugCollector.php": "6755ad710e9d045b2bfa74745354f717c101236b5d64ad5530fd779468969a00", + "system/App/Frontend/Debug/PublicDebugToolbar.php": "4df82a613b12bde3fe75c07418d2be87bf1b8ce0888cac072de5f3f0a9149710", + "system/App/Frontend/Debug/PublicLayoutInspector.php": "8a7f5ea4cff9834ad13cac7e0d2380d878978e406e71860e1ad9ca183e427dab", + "system/App/Frontend/DocumentAccessPolicy.php": "13102e35dada9eb0e174189b976605257b48a32756e801d1ec5c358d0c0912b8", + "system/App/Frontend/DocumentApi/Controller.php": "7c5ffecf7e1515710931d7c31b5b2c5f555f8d35778a1fc202a8136b07c2490c", + "system/App/Frontend/DocumentApi/Feature.php": "afe7143ed3739b704064927d08fa9b2c79e7050e19e08aca5e4e6638ba5c6994", + "system/App/Frontend/DocumentFieldRenderer.php": "1b9eeeb27e40274f8f73bac1a34da993b799731575fef36e790a7f5d87fa7e65", + "system/App/Frontend/DocumentFieldRepository.php": "fc8e80455feca50e4bb5bff3566eb6814fcca1994f1167bd8dd7972f1c87ac69", + "system/App/Frontend/DocumentPaginationRenderer.php": "560c9441fb758bc760b14f37bd88c39c36207c1717a18f43b5c57599a61231ed", + "system/App/Frontend/DocumentRenderCache.php": "9dd0e9e666021e00eda8c4d09cf8f197f17468b8ad545b4d357654bf646bfc15", + "system/App/Frontend/DocumentRepository.php": "8aa13746bc4cbb6ff1d1e0550a0cc94a6e5d9f3c0431a3f982668758d2ff1454", + "system/App/Frontend/DocumentRevisionPreview.php": "372b751f673411722c2de5f04ee1fac57b745e327a2259916313781a3f175e89", + "system/App/Frontend/DocumentRevisionRepository.php": "bbfb1c5a77c979b9998cc647a0e410150ca33727f6caf46f7c49f6ed01774f45", + "system/App/Frontend/DocumentRouteRepository.php": "68b7a9c4972613c7fae886c74273cced26d5749d9242f0cf7463176c1ac0d854", + "system/App/Frontend/DocumentUrlParser.php": "11afb3add9d61d918bc41c8eb8f7d78e5702860b7be293f3567fefa30bccb334", + "system/App/Frontend/DocumentViewTracker.php": "d6bec8d8d263c8fa4e388fe9dd0b977f8142805742e2acc0dbab5701b1f75ede", + "system/App/Frontend/ExternalContentDispatcher.php": "e9fca1fff73d2b819a46b8817f1e070f71d93563342ea2044631b21decf067f4", + "system/App/Frontend/Feeds/Controller.php": "2d446f4bb973c781ce01bf4831721b3e5fac1f6e1642f1862d86da8e8f4c271d", + "system/App/Frontend/Feeds/Feature.php": "1322bf61d4296c79ad8825671ed556cca79bc9b2eed46cbee8b4f70d19f6ca6b", + "system/App/Frontend/Feeds/FeedPresets.php": "96123dc9dd5c82a325db64092681b79e9f1b00997a697f6bf26e7d49c732cd26", + "system/App/Frontend/Feeds/Generator.php": "4234369a293bad237fe0d36dd3c78da616d03ce37070ac72988893f796773da9", + "system/App/Frontend/Feeds/PathController.php": "faf72ea5ba08256c8566a5ee9a73adfcabd59ef0f510c108a6003b659659635b", + "system/App/Frontend/Feeds/Repository.php": "d45113f59327efbba03747f2acafee018e0253815838275f3a6879cdee939584", + "system/App/Frontend/Feeds/Schema.php": "a4603347d631e6a748e0b245901f952736754e4e5e73c96289ed17a1998f5a31", + "system/App/Frontend/Feeds/Service.php": "3d0f3badbc07a9d47e5902c106be270a24a71e2d64a636ca909b2393bdeb59f7", + "system/App/Frontend/FieldEndpoint.php": "0bb0200a52169ecb77498df32d6ee3053324844cfa2e9200d6b3b819dd4c6aa8", + "system/App/Frontend/HiddenContentRenderer.php": "62d34471f729ee37811b17f3eb80a4c6031dddc04f7b7bcc377cb79cc353649d", + "system/App/Frontend/Media/Controller.php": "1a6102b612e14f7f595a0279d1ec47e1fbd828cb87f0bee0f619e6ca2213845e", + "system/App/Frontend/Media/Feature.php": "0e64bf07a84459947071409945edf8cfcbe696b8296a4e0572ea2e605849f3c6", + "system/App/Frontend/Media/ImageSourceExtractor.php": "8738a966c9046c0faa86279761780ee26a63894f0bc473742b4b941daad1cc5b", + "system/App/Frontend/Media/ThumbnailGateway.php": "556d46b0a6d8f1a289eb76f2ea2828f7b9c3b16b33934d3888201327f82dff61", + "system/App/Frontend/Media/ThumbnailPresetPolicy.php": "59a6e2ffadc2f8fef3bae4ea11a32a230a6c8cfe22e008e0347505460281baf0", + "system/App/Frontend/Media/ThumbnailPresetScanner.php": "d972f234a36e66679eff7cf6256a6adf35a51f9c6a9f3a2b23781d0118e4dba4", + "system/App/Frontend/Media/ThumbnailStorage.php": "594c1cd2fb24dbff8ff92d6b450b20080270c0fe7d04efc6f47022344a890b48", + "system/App/Frontend/Media/ThumbnailUrl.php": "a84292ca6f3cf2976f1098027fbb1792854e1d4a8dde5c068f73e025a5d1fa11", + "system/App/Frontend/Media/WatermarkService.php": "99d2a1c95d907aca7374b48dfe51413885923943909f3507ef030f87b156f215", + "system/App/Frontend/MetaGenerator.php": "7809c2e95bb893bac356e228867043f9b03c3dfa19e8fbc64db1b82241738473", + "system/App/Frontend/ModuleTagRenderer.php": "0b41289126478a1aeda6cf7119f8f3d5f89b43bb7684c92ab5447d5dab408795", + "system/App/Frontend/NavigationRenderer.php": "d176186b06d56832e640a8b3d53ccad4aff04e3834b261d83670d675271e4d00", + "system/App/Frontend/NavigationRepository.php": "3714ca40102621191837fbed7511873327250ecb186c54bb0cce89781bdb0db1", + "system/App/Frontend/PageComponentRenderer.php": "4176d2748b73b1c7cb72963cf67cfafd66246d93119728d9d688e6a48b882485", + "system/App/Frontend/PageShellRenderer.php": "e87c2ca686fbf6fd721a35656e752aaf590633a196c93715f75d36ddd78245fb", + "system/App/Frontend/PageTemplateRepository.php": "07af7abf99d0faf9909767685fb392ebc2acbf03aa8172a7701e5001d66d863b", + "system/App/Frontend/PageTemplateResolver.php": "1af594b212cd4e30d5de361415a8e238493995d8bf955df9291e40169afe621c", + "system/App/Frontend/PaginationRenderer.php": "27dda732ec0547a389c4843a77b59090014c5cf0339dbebee51abf4fd0bc7ed6", + "system/App/Frontend/PublicAvatar.php": "5a7afe665f25fbf4bc09a7861b9b84e98f8c294adbe9b047455e5deacda508f9", + "system/App/Frontend/PublicBootstrapEntry.php": "09ede5b61b3c7bd02d0dd35446abc29278a4d40f70d943558b733817a5f04147", + "system/App/Frontend/PublicDocumentApi.php": "12e690c5d4556b59dcf301bdadbe1476cb94eb0a7704ceae9c03af3d7e37aefd", + "system/App/Frontend/PublicDocumentResolver.php": "cf6174104ca13ea7069468546da285d683ef637e9b6ac01071d08ffed79c3eae", + "system/App/Frontend/PublicEnvironment.php": "c4243da40b37a873ee2f466e0c5600acc7da66fe5ecfe637abc92cc2b0bd9636", + "system/App/Frontend/PublicFieldApi.php": "43b707e1b0ec05c74dc4185ca5015c56a5d39000c3c3ffcdbb11f66bc3994b74", + "system/App/Frontend/PublicHtmlResponse.php": "47c6082f6c665ee922df4c3d84c066a583d55a7dfff5739c14a30a1246e4be10", + "system/App/Frontend/PublicKernel.php": "8445d0ccbff461a6f5ad62785496b69a12e7a080923f24252017c3a42143b2e6", + "system/App/Frontend/PublicMailer.php": "6b38793f507368513ab883054ca139c8e07bdc2b3dd11c3ec58e48dfa992fb4d", + "system/App/Frontend/PublicNotFoundResponder.php": "52f165cd61251cea4189f6fa876e77b4934cf25cd973e7be14eb592f0999a390", + "system/App/Frontend/PublicNotice.php": "b0eca5f46e64d4b0f33feadd126c81c8072a7ca959c13c2194defb8a3a01f027", + "system/App/Frontend/PublicPageContext.php": "cc41038f45307b8abbd18b843cd0d82e1f77e8bc36d2807fef17c5a49b4f5c49", + "system/App/Frontend/PublicPageLifecycle.php": "e973e9648354eb36c8bd00c84c6a124910c82f5eba9c456c042960927e1b485f", + "system/App/Frontend/PublicPageRenderer.php": "10cb6a6b8f6e00998619ca4e92b4c85324cf19ccf955806647ccd09e94971a5a", + "system/App/Frontend/PublicProfiler.php": "81d276cae6c51474987d9099e1f916a7e24b0ed03abf0819e3a9f08152196c52", + "system/App/Frontend/PublicRedirectResponder.php": "67b0915e9820fb5d0f4270d86ea39d99502acc49c86997aef7fe0d9ef3fe7b60", + "system/App/Frontend/PublicRequestApi.php": "803113eaeaf272d423ba39331e0dfa25a540251aab4961d2b796a6734215ed37", + "system/App/Frontend/PublicSettings.php": "e70b9a478cefe4eb9f55ba0cde37ed14e270be345d08773e8a6b736be3bef321", + "system/App/Frontend/QuickEdit/PublicQuickEdit.php": "97e6a1498886b74b0a21338732b238f185ea80bedd06a84cba0c70e9aad3fd8b", + "system/App/Frontend/RequestFieldRenderer.php": "01d6e96d630727f740874334e7ffbdfbdc076d896a6d8a390723c8aaaec41c97", + "system/App/Frontend/RequestItemContext.php": "28f70292d7feeb77b88246d1d3299ff54447afc1aa6c525211835a4cfd51ebb4", + "system/App/Frontend/RequestItemRenderer.php": "a941b0ec8eb558792499fb0b9ea5b2ff049a59ddb0b61c3f364aa62fe959fd52", + "system/App/Frontend/RequestListReadModel.php": "7eedb83e42382cada17e60484108702a52f8a890b59d93cd921e194025af6064", + "system/App/Frontend/RequestListRenderer.php": "03baf9b1df3e0488854ff2e0a2c1eff09bec0610244ac3385b009290d5c90a1d", + "system/App/Frontend/RequestRenderer.php": "a064f09d308adda286400177a39b4d14a4f115f0bf4b1c31e559277ddd84286d", + "system/App/Frontend/RequestRepository.php": "f0f97117e26da1dd93af7994b7d70ed99c7fd3dd1cd20a88cf799fe1a2b30f49", + "system/App/Frontend/RequestSort.php": "8585e125c76a1494aa6c481c9de1ba6849491099927be7cabf75604d37b4403e", + "system/App/Frontend/RubricPermissionResolver.php": "1223d6c2839952c98475bf8d802e4c1c9f058be3b2cadabe03fe7bb8cf3973af", + "system/App/Frontend/RubricTemplateRenderer.php": "87ebe5c718f25e0bad5ae1e178d2c5cdaa86e0744f97183ef6d0b1bbeb2dc617", + "system/App/Frontend/SiteAccess.php": "a1cc5a86ab9f88c22214b9cb642556d7e6ffe73b00084de988ddd730805410fc", + "system/App/Frontend/Sitemap/Controller.php": "7a1504cb00897bba10ba0d6d297c7338f8c9eda8782577ef1ef8f8316a35846a", + "system/App/Frontend/Sitemap/Feature.php": "7ff7afcc57c8533acdd1050a40a0c4ca65815668f5e8c72d4b02bdd6c92ae8fd", + "system/App/Frontend/Sitemap/Repository.php": "f7d645987328e97bd01b8204cea3f40fe22921c7f07806ebeba2fe53be80f02f", + "system/App/Frontend/StoredTemplateApi.php": "042e21a93a6c914ac6cf53465b5292833a8240645870ac1bcf29e6fed0a2288f", + "system/App/Frontend/ThemeAssets.php": "5870bace8823561820b64752e5dba16e434449e12de317ad4085087b31232396", + "system/App/Frontend/ThemePageShellRenderer.php": "483e2cf8ef91099b5ff3b3c1d70aa3a4d2ddf354add2bff650ec523150384ea9", + "system/App/Frontend/ThemeSettings.php": "77509ac00183373005f2456a58abf2c8608d6677751f7540d8cb8f5db36284a4", + "system/App/Frontend/ThemeSettingsTags.php": "13fa6b59c8518dca33f6b3482e0f9a00c14a248d430756f62a9b0c5175346fab", + "system/App/Frontend/view/content/list.twig": "34bcf0e7afbdfc53f02c5629f98f5551c7a80d3121dad70e513fbd0c7466d75c", + "system/App/Functions/custom.php": "173e6aef242ae4899d8b0d39f41b8235f7b38d75a5017954cdd6287f43c36dc4", + "system/App/Helpers/Arr.php": "b7e5c439521da8ee1f85d978e561e9466da94d70f5172620deb94677fc964aaf", + "system/App/Helpers/Cookie.php": "f6648c6e0366528c5f04d77ad35cf59f71c4eac2e4c092da1d59ac332b648b5f", + "system/App/Helpers/Crypt.php": "45d4f4b441bc0ad0e160a3d1461095cee28a7372d6ee15fbfd0a4b1ba1e5c6f5", + "system/App/Helpers/Date.php": "2ed2f352f52316484be9f915c73f90357d89aaf6945a398dcea1bed136c2c454", + "system/App/Helpers/Debug.php": "d495a96e844ed9109ec8eb71227e5f02155f61b08b719793d8be16a543f3c106", + "system/App/Helpers/Dir.php": "0791ed320ab894e2c768d16768c227e9c68bbc627723eae289f587551037fb66", + "system/App/Helpers/File.php": "26299102f0ac69eb7c47ea4ecf37f9be29487cc66bcb0347deae1eebd6216043", + "system/App/Helpers/Hooks.php": "266e65cd317245297652756f2cbfb0ae4af44f7f1249d9009d1c955594994803", + "system/App/Helpers/Html.php": "4b4b6387bf40ef4f91fef02ef115ee4b7230694eed93633ec5caa6afb195e013", + "system/App/Helpers/Ip.php": "e982ea7edd23850100b31bd4a82fac97bd9dc4615e13a3e559da266d8299b6ba", + "system/App/Helpers/Json.php": "05765a9e559bdbc78f35c0a97bf139a475fe83430f3100ae2cef26979b9cde3f", + "system/App/Helpers/Locales.php": "f03ab5f4abfdb45eda598cd977e80759f3b7e677b8e43074e6bb51c0c965b508", + "system/App/Helpers/MarketParser.php": "b0381686f962d0066f8fe3bb563cd85448769fdf949c3a3ff5def34ead10a92e", + "system/App/Helpers/Number.php": "96f05c954df90dad805741b89df90169b857b1802e493c8c62ddbe7dd364e860", + "system/App/Helpers/Phone.php": "0171df8f6df4df7f4a3fdf168aadcd23461c00499d1da4a9ecbd24d8e611112f", + "system/App/Helpers/Request.php": "29cc2cf20e1e4acd6e2f23c3de9b9606aec3f44d0b3577613b4fde376f7ae858", + "system/App/Helpers/Response.php": "beea1596703526b7a2424459e392487e8bc8a9ce216f8d70bd2b09564709e0b9", + "system/App/Helpers/RussianStemmer.php": "60a896c8254290ae554273132c103c1c209a14944f52cfd631e7a3ae13145529", + "system/App/Helpers/SearchText.php": "f9fed5c018bf3337dcbcda97ef0b7219f11f13993678e61eb50858403e30dead", + "system/App/Helpers/Secure.php": "99a5a170cd574e5b6a6c3b433c5b8bac92407fba62088c93e893e215f18162a3", + "system/App/Helpers/SoftDelete.php": "9b85f227bbbc12e4bddd9bcf211b477044041b87bf94d1a422f80d46a10c3ccd", + "system/App/Helpers/Str.php": "7eddedb821b476f5284573171899a97ab653b23fc2777cbef780a802a1e9aac2", + "system/App/Helpers/TagParser.php": "7cf90d3af459df482aba5594873bd6807f9944288061d06934668e4bb9c18540", + "system/App/Helpers/Url.php": "418ed3e2682f9097b62d495769864843acc2a9650a90a98d24542acdfdb0946f", + "system/App/Helpers/Valid.php": "d5ebfabf53755ae3e66acade5dcaebd9ca0f5f151f988e1fa0a51d3dba9a0983", + "system/App/Helpers/Zip.php": "cf34358f6908b026e745bb94e5355b4376d46d31d3dfe78cabbf118c04f745a8", + "system/bootstrap.php": "b65f00c1b32ee9d9ed8df7f7750108216fa1b3a347242822be9838891ccf629c", + "system/distribution.json": "c498272f17ce6f0fbd79401fb264089d6380799a0a102c7f6e5c2fa2149439f7", + "system/language/ru/lang.xml": "7d46cc9822f064a1af4d6528ccfe2dd4a9cc318912e327fd807befd2ca3dfd11", + "system/preload.php": "66bfb1aaf36ba85945b729b09a37e96ead0be1cc2749ebd9abd4b9da4f25ba0e", + "system/public.php": "3f72b5bf44bc0ba5ba6fb6b3adc2e2f9d5e9902d9cdfa3d7b748f4aebd179b87", + "system/release.php": "17d3bd33447e6f6e7c8b20a68332fe696c9eb78be26af1573f2f82d445cb3647", + "system/vendor/Captcha/fonts/palatino_linotype_bold.png": "38b6a1d4a4bd1d0c056205119c5e24176913ac0d898371f53a0339952a319365", + "system/vendor/Captcha/fonts/perpetua_bold.png": "55dbbe97dae128dea47b5e8d640414b3d813119c854c688037a20a581ec8fdc4", + "system/vendor/Captcha/fonts/times_bold.png": "d3d32ce1f418e3c3c3b1afe3b6acabadb36207bc12368f5e86420cf1a529ff8f", + "system/vendor/Captcha/form_example.php": "4f67884017f9664f598d7a2aab82a376ca7eac00ff35dc3497b577e4b077aba4", + "system/vendor/Captcha/index.php": "1189818667493e7c8e51c8926128f25587c174d0c430de28d3e3c349bf1f24ae", + "system/vendor/Captcha/kcaptcha.php": "a4e77dff7d6e84db9a705ac1ad6b3107bba7a886b905ea16763d83e4c08c43be", + "system/vendor/Captcha/kcaptcha_config.php": "2ee0c8825f2f82b1cf8386530ffc9d7c76318d992e2f284f2c56794c7b609613", + "system/vendor/Captcha/util/font_preparer.php": "963beee492212993d9cae1991d5d0a298260e98632c8265e1a293e25729a0a5b", + "system/vendor/ImageToolbox/ImageToolbox.php": "a8e6a20bbeb0b6841b038a506c00dcfbd54f6e795197714fe028b3af54427f4c", + "system/vendor/MobileDetect/Mobile_Detect.php": "e75b6dd2081f1ef99bb6bc205ee6bb5135dc6cfc1d3c844c42726d436df7dcd8", + "system/vendor/PHPExcel/PHPExcel.php": "e5212b36393f221d7fa4ceb54998b2a5fe50c8d21530312053c28a39cf0c93c8", + "system/vendor/PHPExcel/PHPExcel/Autoloader.php": "176997258cfbfb6fc9daf1cec7de77818c8f94c6d440e48525d6f9d3517380cc", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/APC.php": "b1345a8c724f4fc145c78f46d706911514b9995479662478110a895bfcdee4f1", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/CacheBase.php": "fae3956a5a3622d119190f4f7395557616e2ad1a52a530c0e3ebd01fae4c92e9", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/DiscISAM.php": "cea06024145bcf3d5788b377c63e9f11c2625bddbde026a03669a4ff292e89e8", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/ICache.php": "3df2be5a90656c7ee3384a9ef747c5211cc0df790e8340e4432c8fc8ec97df6e", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/Igbinary.php": "51ff1eba66369851e356f29b6c5fcc15a587f131ea2c400c5e4a1391100fb804", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/Memcache.php": "da23b822363606c572608f05e6bf89f0515d3235ecd8731588906f49cc049877", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/Memory.php": "8279fa6a69e278585b18dc6c5bc69afdd5dd2b37ba3843f5dab15068d438535d", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/MemoryGZip.php": "2b381075b13c926efa065149dd75c552c0924687dbdea80ae85e8afedf522802", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/MemorySerialized.php": "97a8dbe3dbc94a5a8381f9d30e7b1a14a0785a5d9d117e90ffe350975d4da9a8", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/PHPTemp.php": "a42c90812f3e69d51ce7b6c2dfc5f356ef77619656b8af7f9061ca55260c8210", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/SQLite.php": "e90426892219fcb9bf72004adcd7cdbc98c949dee33c0af5e46b7846ef5f5114", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/SQLite3.php": "0894c245d85ac16456d46de70743fcb37a08a0fe703c78cc685380467121393c", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/Wincache.php": "1d136968c439954a149e0799a14b674a4dda202540ac9ee28f92911c401c239f", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorageFactory.php": "e16e86c3035a2755e96e775bfca32d196a39a7497af5d1d4a7ba4008509486fd", + "system/vendor/PHPExcel/PHPExcel/CalcEngine/CyclicReferenceStack.php": "70475da1976584ac569f503372079f5c3e9a00a72b1d31b8235c508a42e25d96", + "system/vendor/PHPExcel/PHPExcel/CalcEngine/Logger.php": "f73f70fd2c129f4c49b3661d7fa1f69694152873597480d5bb9cfbbc99dd7906", + "system/vendor/PHPExcel/PHPExcel/Calculation.php": "29f3f0cb39bfdab5ba7caa4f28efa5cb91ce7df2488fb72c4943e44d36fbf7b5", + "system/vendor/PHPExcel/PHPExcel/Calculation/Database.php": "11e759cf90ba025be190bfd59f0e83b9eb95f68df4e35a6559e88b5202056d78", + "system/vendor/PHPExcel/PHPExcel/Calculation/DateTime.php": "e8fbfa73a63bb0b82b6d9720f32e96247ce6291b1eea3972b064bb556a5725eb", + "system/vendor/PHPExcel/PHPExcel/Calculation/Engineering.php": "0bc9eea353045c8646b8255b5d05127ce1e1b4414f8870bf28436ec688a85f81", + "system/vendor/PHPExcel/PHPExcel/Calculation/Exception.php": "f316d2610a778f40a07aa634340d0cb770df4804f0d3e58f628a289b9ecc21b9", + "system/vendor/PHPExcel/PHPExcel/Calculation/ExceptionHandler.php": "7ce271f0b0bc5de220d5c19bf53aef9d4c2e9f0a62bb4a5ad3a86f3e24f9d006", + "system/vendor/PHPExcel/PHPExcel/Calculation/Financial.php": "a2287548bc4efe21b0e26438b36da3a58f17c29050fff222d93cdda3b7e2fef2", + "system/vendor/PHPExcel/PHPExcel/Calculation/FormulaParser.php": "da07d6727395e71874176ce70f815e20e0385c5538be84b5808aacc179689277", + "system/vendor/PHPExcel/PHPExcel/Calculation/FormulaToken.php": "ba589ea444a072ff4119072670e221450083a8326533c903faba13717cf4ebb0", + "system/vendor/PHPExcel/PHPExcel/Calculation/Function.php": "db58fdfaea5958574270bcf6f8add3017e324be7fa3ea76b9fb20f0e15a76be2", + "system/vendor/PHPExcel/PHPExcel/Calculation/Functions.php": "311c2a0838a31e8f7e380bced4641626cf4d9dea8c858e321c9c5ddfded324a6", + "system/vendor/PHPExcel/PHPExcel/Calculation/Logical.php": "793aea53983a4e14e7f49052ae9cea86be42e012e0d96f51a132abcdca42875d", + "system/vendor/PHPExcel/PHPExcel/Calculation/LookupRef.php": "4aeeef0ec7ebd8936adcbc77b771995652b787e71972dea261cec9d1224efbc9", + "system/vendor/PHPExcel/PHPExcel/Calculation/MathTrig.php": "e3c37f0d3294dfc6b590314c46474ae32b4c313682c368f3c59b39c86f04c7f6", + "system/vendor/PHPExcel/PHPExcel/Calculation/Statistical.php": "eabf0f64b820e09b1a70b481170bf116d116d6f705c86c85804eac128b69eeb2", + "system/vendor/PHPExcel/PHPExcel/Calculation/TextData.php": "0a10acea60e357dd1efff31a83045f34eb607d46346b77a8234e877f7c58e608", + "system/vendor/PHPExcel/PHPExcel/Calculation/Token/Stack.php": "990773f83cdad0bcdb87e90a87dc40602721cdbec85bb3e068832420af70083f", + "system/vendor/PHPExcel/PHPExcel/Calculation/functionlist.txt": "3c33bb8c404cb0ef5706644efd997b5e9e9000f4d0704c9039f2942880d62010", + "system/vendor/PHPExcel/PHPExcel/Cell.php": "108361014b36b8498ccaccb2d788c3ed837649b3ea01d0d0db0ddf0a3834b73b", + "system/vendor/PHPExcel/PHPExcel/Cell/AdvancedValueBinder.php": "fffbeac3f1983be53fdc324cb881a6918de5e92b5a51a76e400398381c120c4d", + "system/vendor/PHPExcel/PHPExcel/Cell/DataType.php": "559bc51b929521a382f875f26aed69c0de3692d412ac704964074d039b56a1bf", + "system/vendor/PHPExcel/PHPExcel/Cell/DataValidation.php": "651098376b9887c82023d5ba6704f8dc2117bfdfeedbef0ea951c740126ebda9", + "system/vendor/PHPExcel/PHPExcel/Cell/DefaultValueBinder.php": "8dea2e32c1c3fcb80318932f7cac07627afa301a418ae8212203c36907ff3260", + "system/vendor/PHPExcel/PHPExcel/Cell/Hyperlink.php": "b1d487cbc10a82e1bd382d5e091642de964520b2febff2ad27d64823a8ee50ec", + "system/vendor/PHPExcel/PHPExcel/Cell/IValueBinder.php": "cac253ce7756c34908386a2f6d1e0fdf1a7fd87937de9ba362e896efb85aea55", + "system/vendor/PHPExcel/PHPExcel/Chart.php": "64ba7afbfad9b6455a95c6d67c7274f9636540d2071641b5f3ed32027fd6d9b1", + "system/vendor/PHPExcel/PHPExcel/Chart/Axis.php": "37c59bcd585b7f5d9269dc52366f5877d579286afd9ef583e24d237b3b3e7116", + "system/vendor/PHPExcel/PHPExcel/Chart/DataSeries.php": "91edbb0b7d6eb1a72e6d07624e2246d569781cf1cdc8085666dbfb7b68de4e79", + "system/vendor/PHPExcel/PHPExcel/Chart/DataSeriesValues.php": "e05b40bce218c2b23786015441a663908d7766b8299a8038f336561aa51f93d8", + "system/vendor/PHPExcel/PHPExcel/Chart/Exception.php": "6aa439898226ff3a5c65e9756a11bd69012ba73441599992d6d9275c81f94de9", + "system/vendor/PHPExcel/PHPExcel/Chart/GridLines.php": "a1e5305584c999758403cd30bb7e5dcdba0afe0251910b1d75a13292f73a21ac", + "system/vendor/PHPExcel/PHPExcel/Chart/Layout.php": "3d8720f53accc379dcead125686afa4c6461dc977ef735436ab309c02f42ab6a", + "system/vendor/PHPExcel/PHPExcel/Chart/Legend.php": "7d83540736adabed72b93d7111e05e0b2e2804ce99f9388719ba3c68bf1d9f19", + "system/vendor/PHPExcel/PHPExcel/Chart/PlotArea.php": "e5e048be2772eadaec48608d21189dac47a78011bde9b5a80e85a91e6b3829e1", + "system/vendor/PHPExcel/PHPExcel/Chart/Properties.php": "175fe830303e344304fd9fe27af5b2d6f5572d019074438fd9df58516715e053", + "system/vendor/PHPExcel/PHPExcel/Chart/Renderer/PHP Charting Libraries.txt": "d0d99ca8503bbed7f60a7388d78f0742fa92f656eeb7eae46f3155d5b3e0d538", + "system/vendor/PHPExcel/PHPExcel/Chart/Renderer/jpgraph.php": "19098caa76f62a43c30bfa58746714bcf1d831772eb7943aae9f760e52df9d35", + "system/vendor/PHPExcel/PHPExcel/Chart/Title.php": "ada6bb6f3a19c4b363ba1e0be68659ab3749421a24006b9a809d44a23866a336", + "system/vendor/PHPExcel/PHPExcel/Comment.php": "268fe6355bef7c7ec452752e61e1d43fc8b3e157e22a26847d56e9c9ee83ab1e", + "system/vendor/PHPExcel/PHPExcel/DocumentProperties.php": "c65b23eb0723ad5785b10a0439ed7960964c62b9255f0ecf14249e00afddc222", + "system/vendor/PHPExcel/PHPExcel/DocumentSecurity.php": "dd970fd3e70d27c16921bc60ed14d190444448db232b19d9ecdf4e50037b5c95", + "system/vendor/PHPExcel/PHPExcel/Exception.php": "965c20a889cf2c763af2dac177c0e51ca7c80fe3ac180b8537d3aecb5eae4896", + "system/vendor/PHPExcel/PHPExcel/HashTable.php": "60d62d5a0c23a93d565cbab16e2ddfad0b6569f765f791c694d6a9c2c3e64df2", + "system/vendor/PHPExcel/PHPExcel/Helper/HTML.php": "392bc6571a8ef09d373e657fedda9b0f754e9be0961960a784f068e244319e32", + "system/vendor/PHPExcel/PHPExcel/IComparable.php": "4bd85ddd1239ee8616f26cf15d960f2eaaf495c36111d25cb946241a8f09ecad", + "system/vendor/PHPExcel/PHPExcel/IOFactory.php": "999f14e6110b0b2fc34ed43c24ed59fab3e93b71a7b00b10a45949509dc200b8", + "system/vendor/PHPExcel/PHPExcel/NamedRange.php": "5f037c6c5221d2d20501756e8c305b98f92ba2bfab5b034fd007027b048c9269", + "system/vendor/PHPExcel/PHPExcel/Reader/Abstract.php": "cfe35dda7567dc2642377d12162adb7dcb2b288894764fdf0bb8f70287b93275", + "system/vendor/PHPExcel/PHPExcel/Reader/CSV.php": "89b310a1799f58fa66545248777722920fe7bad07b329bd46debe78e6a3d1a78", + "system/vendor/PHPExcel/PHPExcel/Reader/DefaultReadFilter.php": "eb50dcdb04ff27abd3e2402b4085bec05830d0cbf67dd584448c671afe05b97c", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel2007.php": "0f56fc44a84966556fefa0b7b59f3084282db88a3f2cf5fd161472e9aac05daa", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel2007/Chart.php": "a27d71dd59d45a25bf03c7721c90eda7684aeb616854499eec8524949e467dfe", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel2007/Theme.php": "bb844169afd57339e10884e7d2e2bd1a2c20a79abc76d1e8287c85069b48bfe2", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5.php": "6b45bb7d0af7714b5a10a982cb7b54233c75854880c642272fe4276a4a97b0ab", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/Color.php": "6a00a04ceb23adeeb2cb3c2295976f266d3509c685210bc5da626c403d39eecb", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/Color/BIFF5.php": "4a69cf73c40f510b236b5f4804f886e4379c943ade15b9f76a6052d26aa729bb", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/Color/BIFF8.php": "ff8836fb6a894d75b96d0d5aac9ab185369bf72dab68b30a09a42de8f30e82a9", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/Color/BuiltIn.php": "3af79cbe83bc896047abb0bd7688ca33c119551d0706f9742c5086c3460d8dd5", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/ErrorCode.php": "1a61f26bcf3b9a8d964826f7aa550b0f8729491aca957f5794a621f336790dfd", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/Escher.php": "8eac765bffc1860312f5ea8bb210b8a25c3f8acfbf01526bc394e3325d14b449", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/MD5.php": "6a3490454d3559360853c49f03a747693e46f5eb895bb7727105490ea67eedfe", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/RC4.php": "1d421a93ead6fbd653ef88c27da07d1bf6fdaecac013d5562cbf8945ee540e67", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/Style/Border.php": "4475f1734e1287b146bbc582797d8cc083ea4f464f86a7f2b0279c18d40eb857", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/Style/FillPattern.php": "cb673d9253b2041460e619160dbc1cf9b39768de17440ab90420331eefdbb826", + "system/vendor/PHPExcel/PHPExcel/Reader/Exception.php": "2651c9d1bc6daf44b520709cb4d6cda959dfff76b8181220126e2b5845d4add6", + "system/vendor/PHPExcel/PHPExcel/Reader/Gnumeric.php": "e94ae89114df09df0b409dade587f4794ad76d47267e561be5ac3f54add34cef", + "system/vendor/PHPExcel/PHPExcel/Reader/HTML.php": "bfef2b5414e6a6ba124ee6c5805be88aa95992e070fa8eb2d37c1acafa2e59e3", + "system/vendor/PHPExcel/PHPExcel/Reader/IReadFilter.php": "2c998d941da6ebc4aa6c292097d777002a27eecc23298431f445a02e97f2064e", + "system/vendor/PHPExcel/PHPExcel/Reader/IReader.php": "a074c3cd0e32525511ae4270cc6fd59d98379f0a597088bc2ada6dcccccf41e5", + "system/vendor/PHPExcel/PHPExcel/Reader/OOCalc.php": "b9cf1b965d58bf9eceb10a02c51e3d2b5599daf6f744b0ca0d825bb00d3f8e7b", + "system/vendor/PHPExcel/PHPExcel/Reader/SYLK.php": "52544c33063947a69f59d07dc772600f3b38d3fc0d779913263bd58c51000277", + "system/vendor/PHPExcel/PHPExcel/ReferenceHelper.php": "d9c9fd9d9ae35706055424c9d417193b094dedfbfa44bd26f8f8a246c5f7700d", + "system/vendor/PHPExcel/PHPExcel/RichText.php": "60b4a86a25a91f2d20b07e75e444a3bab7d192e1e12e14107554712e5f202cf9", + "system/vendor/PHPExcel/PHPExcel/RichText/ITextElement.php": "f3cccd6a68e3ced7f99cccf298670a9d131314d98270e1a3af1ba5dd046e73fe", + "system/vendor/PHPExcel/PHPExcel/RichText/Run.php": "c91a6c29c59b584daea3532bd77e1facd57e71c398cf0ac859c2352f921a274c", + "system/vendor/PHPExcel/PHPExcel/RichText/TextElement.php": "134b4e93a2a7ca70066e7c7d1e0bfa7a5be4ddd44277d1ed8b36ffe9824e386e", + "system/vendor/PHPExcel/PHPExcel/Settings.php": "39d81ed224656ee253a6b54d7a42e2a4ead08e4f603ea54503ac0e8858fc16d1", + "system/vendor/PHPExcel/PHPExcel/Shared/CodePage.php": "26cea812167107dc78d909dbb74e81a15fb9e57c88a86c55e0c8484d603fb2a8", + "system/vendor/PHPExcel/PHPExcel/Shared/Date.php": "ee8fb4c01e1395de4fdf390e9c804d11af46bc9f5be2b7e6fd3885309da94856", + "system/vendor/PHPExcel/PHPExcel/Shared/Drawing.php": "e801b0001cef48c12cf056c55cacafa0af4eee76e7aa909c6baa513c874cdcd0", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher.php": "55e38ae2c218886d1525156fcbbb0d4970b5f3d51da3e2b1453ecfac29bc0b56", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher/DgContainer.php": "83aba0354949254d1a6cdb1def6e05a11a3a1cc11c181bc521c469679f3be914", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher/DgContainer/SpgrContainer.php": "4165bdf0998da332dbe0f47c4c9d9fa9ee1876c71e714e3155f39e4cda52aeef", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher/DgContainer/SpgrContainer/SpContainer.php": "f099afc09e02d2111977607662383e1317fb258be5901f8a1378100dc0ccaf34", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher/DggContainer.php": "51bb69d2b82e086d91c28f074f25a3fad5d09ab287e63de23bef8e1f3f679342", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher/DggContainer/BstoreContainer.php": "001e5c13ead9a6f47bd7c62d38711f8ad4bcff8ac94c2252501a11b8e4a87201", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher/DggContainer/BstoreContainer/BSE.php": "a5ccf662935364b4a6994050d39168e3eb3060f1ae9fb316082db6b44be79893", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher/DggContainer/BstoreContainer/BSE/Blip.php": "0e14f1a285ee35728ca94400c000312076b3ddeb052c4d27a0f7c6612cf62291", + "system/vendor/PHPExcel/PHPExcel/Shared/Excel5.php": "151c32822662898d366a8782e58f37c2cd3a5173746ec026cee75c5f6000fdd1", + "system/vendor/PHPExcel/PHPExcel/Shared/File.php": "509b7c668212bd1c406f946900439edb07433d5deb7fd6d1ab4531d78defd08f", + "system/vendor/PHPExcel/PHPExcel/Shared/Font.php": "b7e5aee540527673246d2268be619ea578ab779c5577e7e021e18a21eddff06e", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/CHANGELOG.TXT": "11cf84f266695a9cce9efea781444b683edb44abe02e406814bdee544c72b6bb", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/CholeskyDecomposition.php": "4aef587f172636aa5d752df6c409dda67ba39d4124743e95b75d01a037698925", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/EigenvalueDecomposition.php": "915a5963f0b45d4c418a454f03f9a560f422d6a4087d6dfd96a6fef71759867a", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/LUDecomposition.php": "b23ceb5b2c94d0a974f577ddac0f9465721b3fd29a7346391f2bb515670fd0c3", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/Matrix.php": "7f328ea85a1bd9385617429a9e2d32d52156dfd51bd49d5c1a5062a29353b254", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/QRDecomposition.php": "410ecb97dec001dcde6823191bba7ddda7c73dddf0063ed219f1aa864c703b11", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/SingularValueDecomposition.php": "188fd6e473eae2fb348186142afe4707308405e6cc11c0900431ae0fe3af6c51", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/utils/Error.php": "1902148dd3d1f01805f7ab1f32816e27f87fbdeca44d46a795c8898efa751653", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/utils/Maths.php": "d7f344513ac784557df40b4530a333823620fa902405fcf427991909a87db0bf", + "system/vendor/PHPExcel/PHPExcel/Shared/OLE.php": "21fc465fee9a396f694646aec77ed94423d20c0a37cd69c8c7e9245cd86e3844", + "system/vendor/PHPExcel/PHPExcel/Shared/OLE/ChainedBlockStream.php": "ae2a2b2a9a1ed5671caec97e6902615fa0b3c977de16c842d1d56af4a7aaaabf", + "system/vendor/PHPExcel/PHPExcel/Shared/OLE/PPS.php": "f3ec6e2e25259678c39143e9c33d32b35310238295163a2f2e1404f58624efcf", + "system/vendor/PHPExcel/PHPExcel/Shared/OLE/PPS/File.php": "1323bcca3f4a90e41a45f522171d25852bfff2033e96b3c2b121f2b32118ad74", + "system/vendor/PHPExcel/PHPExcel/Shared/OLE/PPS/Root.php": "9a26faaf20a9a0f6c06f7e381c1cf3a9a86ba828506f13cc850cea536174cf01", + "system/vendor/PHPExcel/PHPExcel/Shared/OLERead.php": "c04b276071d3d4f48250fe45673e1b5b9214658c621f16e19884d4ba3f648490", + "system/vendor/PHPExcel/PHPExcel/Shared/PCLZip/gnu-lgpl.txt": "a190dc9c8043755d90f8b0a75fa66b9e42d4af4c980bf5ddc633f0124db3cee7", + "system/vendor/PHPExcel/PHPExcel/Shared/PCLZip/pclzip.lib.php": "077c36a57fd4147e4e144aefb1fa016e6026a43264da5135cbbfd5d61cbedff3", + "system/vendor/PHPExcel/PHPExcel/Shared/PCLZip/readme.txt": "71f246800a8ad6b9bdbf526c21056f8d34f37e256dd3f533cc9c9ddac36fd169", + "system/vendor/PHPExcel/PHPExcel/Shared/PasswordHasher.php": "2f711b9a0341c6d73fb8b8fcf2daef5a6c6277f73e8094f3cfa9e7e7afbc327a", + "system/vendor/PHPExcel/PHPExcel/Shared/String.php": "7b86537a3d4413fea0724fe3fa3e9ca39ee1d8a0d7ce7b766d9fb7dbf35bdd7f", + "system/vendor/PHPExcel/PHPExcel/Shared/TimeZone.php": "06425a11d3860c884a20dbef3128073a0689dfd50ede21b4ccab96a37f931d38", + "system/vendor/PHPExcel/PHPExcel/Shared/XMLWriter.php": "4af810be318ecdafca83aa08f518b71c463f23d3d826161b25ecd726111b5c71", + "system/vendor/PHPExcel/PHPExcel/Shared/ZipArchive.php": "d607f50a8b79c29b9b961b11d44940937f4727be0f942a4e8f5431314197623b", + "system/vendor/PHPExcel/PHPExcel/Shared/ZipStreamWrapper.php": "7c29aea4396f2c23cdf28a8a7f11e64a2f8a627396ebaf36471c20d6222d1b6f", + "system/vendor/PHPExcel/PHPExcel/Shared/trend/bestFitClass.php": "ee4b2f8268272d342a973fc93b655f753d23ffa5df8f1fde7fac7bc9ddb6ba40", + "system/vendor/PHPExcel/PHPExcel/Shared/trend/exponentialBestFitClass.php": "c43a2647ed582ed067f8f62cae01bad1b53ae5f06d5f84e4b11909df9b1f7dac", + "system/vendor/PHPExcel/PHPExcel/Shared/trend/linearBestFitClass.php": "b5ae54f813e6811020137b1e80bb9000923737b5344b9231a460238918606c94", + "system/vendor/PHPExcel/PHPExcel/Shared/trend/logarithmicBestFitClass.php": "c718c14ae4c43f08556e1337b932d346a11313c4d5204343f43ccc996b0d6fe2", + "system/vendor/PHPExcel/PHPExcel/Shared/trend/polynomialBestFitClass.php": "9f6349413feae6c4bcce045b5b8fc551997b647dc35db948142a5eda51a94123", + "system/vendor/PHPExcel/PHPExcel/Shared/trend/powerBestFitClass.php": "ee119b8f370b22926b81b3eba43bc2929e7b12b590a40e20db8848dcfcce5aa1", + "system/vendor/PHPExcel/PHPExcel/Shared/trend/trendClass.php": "7beace3f5b3be0f2df217c407d58958e93cb38c0deeeacb9542964f53a8a07e6", + "system/vendor/PHPExcel/PHPExcel/Style.php": "896b18a7a76e1c1d26aa6669ee34260e4f087deae6929dae99ac02a108afe85b", + "system/vendor/PHPExcel/PHPExcel/Style/Alignment.php": "73c3609cc256ed502987a45be6f89098d90b81e95eb7f1527ef1a34a3b541b5b", + "system/vendor/PHPExcel/PHPExcel/Style/Border.php": "ec58dc836ffaf28e54407431c701caad5ad2e25bf09cbaef847b324933a6ffdc", + "system/vendor/PHPExcel/PHPExcel/Style/Borders.php": "85fd3ff4f71bc7b3d0b6d37c9e77b92372e4642e7589426c7a82ab32a6ea4d59", + "system/vendor/PHPExcel/PHPExcel/Style/Color.php": "f7213bbf69a2a52a9299df91193b4398e63d4988e00d7972decc86fea5153764", + "system/vendor/PHPExcel/PHPExcel/Style/Conditional.php": "4468fc9f7cce6a401009c55afcfa0b073f38fd10c990d111a65907e725c4c58b", + "system/vendor/PHPExcel/PHPExcel/Style/Fill.php": "1871528a2d96a1de577847c2c9fffea5f2d20daa43cf61d47c81bd54ad98deee", + "system/vendor/PHPExcel/PHPExcel/Style/Font.php": "e8206ba2a9921440c92f1fa45de5e323730bd5f002de8fe872dfee8bf7f85689", + "system/vendor/PHPExcel/PHPExcel/Style/NumberFormat.php": "1d56f0fa95c7664f0607f9aed1bf60f194c412f1ea182f18e78e05aee871d72d", + "system/vendor/PHPExcel/PHPExcel/Style/Protection.php": "9a44d157c592124a0087fd531531ee6755227207629c1d7c577d053e01bcd58c", + "system/vendor/PHPExcel/PHPExcel/Style/Supervisor.php": "f964ee1a2cc1460803fbcfcc16f4665748619ebf8379bb2f7110f3e5cea4fc9e", + "system/vendor/PHPExcel/PHPExcel/Worksheet.php": "72e6d25461aa8cda04fc621b821d3b1dff253250c78fd8aeef9076166d548f16", + "system/vendor/PHPExcel/PHPExcel/Worksheet/AutoFilter.php": "089e1da1bb10fa271cab715e68660b0afa5d85bb219fa8577e0957c6912e52a9", + "system/vendor/PHPExcel/PHPExcel/Worksheet/AutoFilter/Column.php": "75d46185ca609649bdb9d7497c334de567816adc6be884102606ed30b78cf3ca", + "system/vendor/PHPExcel/PHPExcel/Worksheet/AutoFilter/Column/Rule.php": "b0e432e7fa49a9f13d6a16e02d4aee6a4f185bf66683bb1751bb500d81a52031", + "system/vendor/PHPExcel/PHPExcel/Worksheet/BaseDrawing.php": "5e9f8492e19cea264575c388765022cc6bce8de4235f67df0acb81381447f92d", + "system/vendor/PHPExcel/PHPExcel/Worksheet/CellIterator.php": "423aa818e5079036f7f055ccdd8ba2b04c2b01322f5a9221b984a57dd71d080b", + "system/vendor/PHPExcel/PHPExcel/Worksheet/Column.php": "95a37d04c939749fc480a6017060f75efead5b3bbcd707ab5efa555f7a1d9e47", + "system/vendor/PHPExcel/PHPExcel/Worksheet/ColumnCellIterator.php": "042cae643752742e96f4f1753414179707031df96e49437ac1fc25b468f3b136", + "system/vendor/PHPExcel/PHPExcel/Worksheet/ColumnDimension.php": "cea41a344a7ac4947be6bb0acff03bd1f5ec0a263d9ef8c97ad7c60f58624a97", + "system/vendor/PHPExcel/PHPExcel/Worksheet/ColumnIterator.php": "65f45ba6c053b1199d95aa0f91eb7da8e5ea5d0c7a9c34ea05d598e05391fda7", + "system/vendor/PHPExcel/PHPExcel/Worksheet/Dimension.php": "5322e9489d8aad405e76aa8ed816e73f2379b4de051943b8db9fcc6794d5f080", + "system/vendor/PHPExcel/PHPExcel/Worksheet/Drawing.php": "071408f130bc2c0eac65d4be0115db509dfc84fd91a5d60431889c12b156d3c4", + "system/vendor/PHPExcel/PHPExcel/Worksheet/Drawing/Shadow.php": "7372ca871d409667589d660ce3303210ffd7b6bdb31bef7c8cd4569c0333272a", + "system/vendor/PHPExcel/PHPExcel/Worksheet/HeaderFooter.php": "35f1f3edd67246c65df9d9bf1e7fa5aebd457f1c3f7f397d5b997d09b147b951", + "system/vendor/PHPExcel/PHPExcel/Worksheet/HeaderFooterDrawing.php": "9657824a3a0f9dbd2f265a684218d466a9d77dfc7152352f6231153eac0ac8a0", + "system/vendor/PHPExcel/PHPExcel/Worksheet/MemoryDrawing.php": "599db66efde292538683accb255e7c7e083d04f5b71c9a33c0ecf6fb9360b20a", + "system/vendor/PHPExcel/PHPExcel/Worksheet/PageMargins.php": "86c69924b19c5f1fc3787f1fb7901d00bd1e36fac177f933552972318eec395e", + "system/vendor/PHPExcel/PHPExcel/Worksheet/PageSetup.php": "19c962f3ca274544548c778dda12951b81e281ff90ccaae0358010364e876fdb", + "system/vendor/PHPExcel/PHPExcel/Worksheet/Protection.php": "5c48a771c0d244acb3a4a628326b347b32f9c36954025d417e3a10333b62501e", + "system/vendor/PHPExcel/PHPExcel/Worksheet/Row.php": "8e9a82fcc8b81270b1df2a6139bae8d6aa12e90ebcedede2d1e106296162c7ca", + "system/vendor/PHPExcel/PHPExcel/Worksheet/RowCellIterator.php": "b78b4d50c44ed99d43050e1e6994eaf501255b6cc04ded84215ae8fc22611183", + "system/vendor/PHPExcel/PHPExcel/Worksheet/RowDimension.php": "24d927242fe680aa7d8f995f3ee429d3b4fe36fbd247e30f55a206dd3beef569", + "system/vendor/PHPExcel/PHPExcel/Worksheet/RowIterator.php": "ac180a7176602df99bb803828521292db2af85721957aad2f3cf8679630c8850", + "system/vendor/PHPExcel/PHPExcel/Worksheet/SheetView.php": "4922aa82d68dbddc8acc23a644a537ff80786f48e749db584fd9bb6f479c5900", + "system/vendor/PHPExcel/PHPExcel/WorksheetIterator.php": "f36a7ed7c21e68b8b290a17a397a1b099dee43c04c49518c60b57bb8c30a3445", + "system/vendor/PHPExcel/PHPExcel/Writer/Abstract.php": "3d366c23122cc88eb24a10bd9e626d37ed9203e57c92985f194951a5c77b5672", + "system/vendor/PHPExcel/PHPExcel/Writer/CSV.php": "d2fbaf24a890a6bb142dc0fb8e7eda750bd08b8f0ddb662f622a2e699dd5cbbc", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007.php": "5d4e53b43731281c72da63e3f76ec05df3572c6193078eb7d0e4662b9e1150e8", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Chart.php": "6e0b9ed1dedf6ad58799da8fb6438e4ba63b362339d2da72c1dcec787f08fd27", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Comments.php": "577d39af4848035d26dc2ac861deb1170013ab8e144a89d0cef4e55b2571f97e", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/ContentTypes.php": "93bdf09dcb9c64e0b72cd805bad3c022ab4d9f85b2576dc506e8e7876fe43a85", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/DocProps.php": "fb46e10360fbecee055b465a75391648c91287d7258ca176d587ccb3414679b9", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Drawing.php": "cc2e6671b034f087b1c82e3b05c9c4d1d8681b075bef0738177832046464e7f4", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Rels.php": "a3d2d6c1cb9ded56377f80cb5067c809f6b097c7175f900e8860aea4d45818f6", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/RelsRibbon.php": "19ad20f4cae05446e08827667eb0bc853e01bab10e33582eb480efc6aae87832", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/RelsVBA.php": "62121731cd9dc4f39af22a693942a8d5acf33dc7ae302e9746ae2942422b5d4e", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/StringTable.php": "229cf2398974522900a02c41fde8d9f54fdb6a7724e8a943301a73d4cec00bb3", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Style.php": "404c7d877ec66279cafa0771271bf784da77f78290cb242155188532208ff808", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Theme.php": "f2b4a872bc71dbc634f37aefbda7c9ee3fdd85756ecea6ce8f9e218fe9c15ffc", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Workbook.php": "1f8f82bbb3885643d44a905ef37280e2bc0af48c3a5c4a1de2e2882d6df9a81b", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Worksheet.php": "efad6252b15360c61874a05b8439e918d1a0464351f91ec68b2b70c82c19028a", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/WriterPart.php": "4ae0007e93275ab1408e1220e7e8e825f772194212e7af8b7b195220df33768e", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5.php": "3a0eb8ef3d9c47a6a64dd6a93d144ec432a5579335553b2d3439c64423e81350", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5/BIFFwriter.php": "6b88b9a269914cfd0598bdcec144c71e3af32803f8998b9c83d25a422a79c9c5", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5/Escher.php": "d7248e4d6c84948cb4e72568f79fad8e933bf9f27f0f8689b4610f7ebfef4b41", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5/Font.php": "48ae0fbfecb7dd120cb30c4fe1087f68686be1c42fba1b599a983b50a3183b17", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5/Parser.php": "126d92c7ed15f96ae146c473691ec95c5158caeb7ca36f63c37cf627703b03a4", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5/Workbook.php": "9d474effff4bf379394f1a5fbf812e8a9bd3535b63af1dcf8573bf3bd7f9145b", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5/Worksheet.php": "96d5a22d1372866efb12ba35188743e49c32fb510007aca9df2ceed269fa50e3", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5/Xf.php": "91705f20719d6bd8665ae52d6b057b7e4b8e162b08e0c9c2897a8d974fd22967", + "system/vendor/PHPExcel/PHPExcel/Writer/Exception.php": "e56dc6c5343b9a956d1aa65ce94d3b9e9901fc7fcea5579dffa7e287b24ffccd", + "system/vendor/PHPExcel/PHPExcel/Writer/HTML.php": "1d68f77589141c154a44d524f29f2bd37bb7d5c24d6cc3490cf9de001cd2cc9e", + "system/vendor/PHPExcel/PHPExcel/Writer/IWriter.php": "ba85d90d585aa02f44158c7f431ac942a4ef046cceba49e89e47efc0f571b321", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument.php": "0a0e563cd0d9e02cb7c7c018a48f211da10566ac273d46abf7250e20de29b6ca", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/Cell/Comment.php": "165d2245ba38aec837e9a2353f9777ae0fb6bcd18dacf9d04f930cfcc7d9d8da", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/Content.php": "9cb7d4ab6840e7a0dcbe62201b530a6546badc63515b04b92487325c423e2c50", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/Meta.php": "790b55ae0e1efb22e7255f19194804bb92571e0c9cc7c073811e33fe27881670", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/MetaInf.php": "aeeb00e93b467aeb15e73bb42a0de4e5c1ccd95e5f9be0adcad286d6242896be", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/Mimetype.php": "95c1365306f40c7d3370dfabf06a557826f5bc8a797ac7eaa82e78698003b707", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/Settings.php": "5cd89d84d0b8703a224d320bab40dd1780dfca3c875ef9d684fc34c5d8ec24f8", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/Styles.php": "a44436dd9d71f622bc75c42523439453bccef0f437f320f5be494ca3b69fcfb3", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/Thumbnails.php": "4c221de81bf19e7565d7325d977ecfd300b23dd11b9d6f31dfd79c6fad0a53a6", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/WriterPart.php": "6ff1c31d5e39f1e592a652512acffd7e7c916052b8b7bcea84b2a81727ee3561", + "system/vendor/PHPExcel/PHPExcel/Writer/PDF.php": "8aabf2c3638ef8ab5fd9c1b8cd2f5504b453b2135e0cc51374066463ebe463b2", + "system/vendor/PHPExcel/PHPExcel/Writer/PDF/Core.php": "294e4c42ee4620672a77aadca573a3044dcc659fc8f6ff80fbbf4730f92d3c2c", + "system/vendor/PHPExcel/PHPExcel/Writer/PDF/DomPDF.php": "b98e9dbd51812ce82ead00fc9e8379046c145701379689f9db0f227c62749717", + "system/vendor/PHPExcel/PHPExcel/Writer/PDF/mPDF.php": "4ce52eab565e0d3f4e0194c28a0e0cafb55aabde1c00e57348069e1f45ed9b81", + "system/vendor/PHPExcel/PHPExcel/Writer/PDF/tcPDF.php": "e833aee29f84f7ddd15ea85292414515e0a7529453a0536cbe4889161d6c9d6c", + "system/vendor/PHPExcel/PHPExcel/locale/en/uk/config": "ee1b4bd4f91aa13fbb83071150fd5277c876ecc9637af1d5bbb706d2bcc3b607", + "system/vendor/PHPExcel/PHPExcel/locale/ru/config": "4e29bedad4af4eae0cba2240a28f008b36cec19d9ea5fb2de0e84eac231a1073", + "system/vendor/PHPExcel/PHPExcel/locale/ru/functions": "782394b0f70cafa6c30e67d5e13f062ff147cf3a1b894e51aa75bcd69505013a", + "system/vendor/SqlFormatter/SqlFormatter.php": "8e2feb1d6510b4a537f6f0611cea74946ebdb43029d9c04f8c14afbd83144128", + "system/vendor/SwiftMailer/classes/Swift.php": "cc7b045bdf101adebe8c155425034bf4eadba0756b680bb998b81c6075fbc3e5", + "system/vendor/SwiftMailer/classes/Swift/Attachment.php": "de0e77402a61debb20bff60afa77aac3a476af6b5a43a0984d3252421509edaf", + "system/vendor/SwiftMailer/classes/Swift/ByteStream/AbstractFilterableInputStream.php": "26bfc166918f6fa71e688233d078de8f8c4dcdf8bd1ebfac4bc4a1653976ed60", + "system/vendor/SwiftMailer/classes/Swift/ByteStream/ArrayByteStream.php": "21be0bd59db1a078909162ba842ee303b21bfeafa90f2a74070e8cd911522bcc", + "system/vendor/SwiftMailer/classes/Swift/ByteStream/FileByteStream.php": "ad9c447bceaddd95133320c6846f29fc9dcbf4b3d10d0321099e75a960cdc28d", + "system/vendor/SwiftMailer/classes/Swift/ByteStream/TemporaryFileByteStream.php": "78e88e709d6c2fb7db8d57f70266cc9fafd91f1eb9040ea411aada741e06b847", + "system/vendor/SwiftMailer/classes/Swift/CharacterReader.php": "bd85f8d00529168b72650395d6dcb8bfff3afd50d9f585b0e1de17db78a06c03", + "system/vendor/SwiftMailer/classes/Swift/CharacterReader/GenericFixedWidthReader.php": "119add2a6dbb48a44f9f93cb5f187f93843497ab0f406eed11d3254f27798e23", + "system/vendor/SwiftMailer/classes/Swift/CharacterReader/UsAsciiReader.php": "3c132318f6fed5c3c92feb9206c513132569537ff9fc105e4618e4a29a074aea", + "system/vendor/SwiftMailer/classes/Swift/CharacterReader/Utf8Reader.php": "bdd1f950c9e0a10d7a8b8537bbb87ed443efed3dceffb43c053ba4893a67d409", + "system/vendor/SwiftMailer/classes/Swift/CharacterReaderFactory.php": "627c0a3154359a38de298917a2666ea6b9275d40b7e722e0cd88b671cfb2e0f7", + "system/vendor/SwiftMailer/classes/Swift/CharacterReaderFactory/SimpleCharacterReaderFactory.php": "b7fb82cd4a2ea685e7a8e12def6b18262b8be6b4c2d6623022cc55c5170ebcc7", + "system/vendor/SwiftMailer/classes/Swift/CharacterStream.php": "01d572791cef189e1cba9d7c41620a63dc3c03b9112794e61349613059f58d13", + "system/vendor/SwiftMailer/classes/Swift/CharacterStream/ArrayCharacterStream.php": "83ca41d8560218c2866b474d0111b78e20a883f4811bbb34373186e121beec24", + "system/vendor/SwiftMailer/classes/Swift/CharacterStream/NgCharacterStream.php": "7cfc1b8e6c8ace94d60964b95bb0a5e8eaed22fa6e8e9a4a8a4bf1eab6ac3760", + "system/vendor/SwiftMailer/classes/Swift/ConfigurableSpool.php": "c53b912cb99870246bc9d7e4c11330b83d066c4480d47785f43fccc2b5c491c3", + "system/vendor/SwiftMailer/classes/Swift/DependencyContainer.php": "5721749ac808552637f4923bb926ef5c46e64e22f1354942b168031db15379f6", + "system/vendor/SwiftMailer/classes/Swift/DependencyException.php": "2a43ac176f39b9cbefe44ddc108f62e36c1665c4370c98a138744f5057bcc202", + "system/vendor/SwiftMailer/classes/Swift/EmbeddedFile.php": "998ac31be12ab9ebb6e7578aed2fdd9d520499e23142ecb51a87105b75cbe21b", + "system/vendor/SwiftMailer/classes/Swift/Encoder.php": "c4f1b107f6fffc825d72ad90740cf45702db6d75787cf12e59a4dd76e1589a5f", + "system/vendor/SwiftMailer/classes/Swift/Encoder/Base64Encoder.php": "58ae7a569b634c0b6c560c4d295c172c3f1ba89860cd3248a669fa87d01b0336", + "system/vendor/SwiftMailer/classes/Swift/Encoder/QpEncoder.php": "86e9290d7853441d268be27ed2807bedb443cb767b7ccdd43bb242f2c3ae513d", + "system/vendor/SwiftMailer/classes/Swift/Encoder/Rfc2231Encoder.php": "664526a13a25dae69c2a223387c5dbef282c67a21798535e0e3e1e165d5d2f97", + "system/vendor/SwiftMailer/classes/Swift/Encoding.php": "9496f23cd6606f4ba9c1511cdbeffbd1a6adc8c191361d83dbae0822e06089fd", + "system/vendor/SwiftMailer/classes/Swift/Events/CommandEvent.php": "bdce52de633d0867505de82d0d56da947edba02c172a84e13b59a8f8af1590d8", + "system/vendor/SwiftMailer/classes/Swift/Events/CommandListener.php": "62e6336fb6ea15e3c43dd76a157a3c03ab853091c6c1dbad62fd8201dfc5f551", + "system/vendor/SwiftMailer/classes/Swift/Events/Event.php": "3795ebac94932f023d173f8786e068d7cfea63a27a6f180da0839e0a5a477ab3", + "system/vendor/SwiftMailer/classes/Swift/Events/EventDispatcher.php": "83f796592674366e7c369ba11a04677bedf5444c05f75a766b98cef2783290dc", + "system/vendor/SwiftMailer/classes/Swift/Events/EventListener.php": "aabebe118ada2fd6efa325519dcd2ca0610b2ab2f0d0828a04306ab371e8e32c", + "system/vendor/SwiftMailer/classes/Swift/Events/EventObject.php": "2f5ea60f923085eb74b82177df5659e76b6f532b767101c18e9b9e02abc7e132", + "system/vendor/SwiftMailer/classes/Swift/Events/ResponseEvent.php": "38c5e81a17719faeb7441a6e0d9f5c64bdf0136ce6b58a1ddbee17a05c88587f", + "system/vendor/SwiftMailer/classes/Swift/Events/ResponseListener.php": "216670a86f9316a3f9f3be4f7f279be096f2628d7682a5b87c267caae7d01a6b", + "system/vendor/SwiftMailer/classes/Swift/Events/SendEvent.php": "0f038b019a520de7ac4d3aaa219934a8aa30406803283c31a67e8391e8df2282", + "system/vendor/SwiftMailer/classes/Swift/Events/SendListener.php": "d09ed409df6bf3e7e15b014f8e3b7a32b00f52b7576753424d12d5fc919f5ff9", + "system/vendor/SwiftMailer/classes/Swift/Events/SimpleEventDispatcher.php": "6c19b5dc98ad589e877fc2acad251f3181da64a983e1ebdb840bc30d721a28c1", + "system/vendor/SwiftMailer/classes/Swift/Events/TransportChangeEvent.php": "49f77ce35c85b407c7398945da7d11d31aa57dbf934b7132cb2137d9b174ffc7", + "system/vendor/SwiftMailer/classes/Swift/Events/TransportChangeListener.php": "64dab6b366587ccdc793f7eee9c6c6d5b4a4ae45bde1b45fc976d5e15c96eb07", + "system/vendor/SwiftMailer/classes/Swift/Events/TransportExceptionEvent.php": "7076efea22aa78199d431138c48b59c4edeed91e5939d74eb1df49577aefaf49", + "system/vendor/SwiftMailer/classes/Swift/Events/TransportExceptionListener.php": "f691e419110b078ddaeaf18bfbdbabcad7df60ddfff8cc59a828d50f812eee36", + "system/vendor/SwiftMailer/classes/Swift/FailoverTransport.php": "e124b8758ee92d9b89493ef12095b173d774e053c4f53a8cb587c07489e248e1", + "system/vendor/SwiftMailer/classes/Swift/FileSpool.php": "0a029b105ac369dbab9806108c9da51858e344c446fc734627e5fc94a40ded1a", + "system/vendor/SwiftMailer/classes/Swift/FileStream.php": "75d91a40abb2dac6bd82ae933ed36e18c61b5cbe7a5bfa885fecc53a5ed6d85f", + "system/vendor/SwiftMailer/classes/Swift/Filterable.php": "32ab20e7d4c489a3d0f2d30e6c15ab83c0dbd887dbd773c508ea288194dd1b62", + "system/vendor/SwiftMailer/classes/Swift/Image.php": "7d84d8f560b0199855ceae8fd65a5f19bb6f36e5a5d7e764c2da4a9c21dd72a2", + "system/vendor/SwiftMailer/classes/Swift/InputByteStream.php": "acc60a9b741d1b9d31028dd1abbdb0ccbeefe18c1820ef5dae5cdce6e484bb17", + "system/vendor/SwiftMailer/classes/Swift/IoException.php": "ae5169cd93f9a638785ae08ecc9c56cc5fc8af6ca47ac394cb5c391c6284087e", + "system/vendor/SwiftMailer/classes/Swift/KeyCache.php": "99b308e9fa58f615ebc07fb7e74fd52a1811725fcb2f8deae995c4a4db58ccd1", + "system/vendor/SwiftMailer/classes/Swift/KeyCache/ArrayKeyCache.php": "20e9f298dbacdca1755a0b9fbffdde75a7758fac02680cf24a08c8eefd8015d5", + "system/vendor/SwiftMailer/classes/Swift/KeyCache/DiskKeyCache.php": "b9ffff205a3209726cbe8170de32155a2b3740ff570719210982f2327e320850", + "system/vendor/SwiftMailer/classes/Swift/KeyCache/KeyCacheInputStream.php": "23bc3c771b5a844b4f0b8666de3e62ce39cf2b84e143df2538b6236569087388", + "system/vendor/SwiftMailer/classes/Swift/KeyCache/NullKeyCache.php": "f4f2294b1d01cd8b662ef155c2464ec537ea91f2ba131e1bfdbd356550f45900", + "system/vendor/SwiftMailer/classes/Swift/KeyCache/SimpleKeyCacheInputStream.php": "9a2f9d3b4b7e76dedea0c3494a3249f955d43c3504c5b64eab5e40da595e364e", + "system/vendor/SwiftMailer/classes/Swift/LoadBalancedTransport.php": "8d20b6ddea6cbf1687dad8cfaefe47e0a3c0e9a42c567759e5c37a45befaef0f", + "system/vendor/SwiftMailer/classes/Swift/MailTransport.php": "d5d657bdc2901f8dc6e7eb85d6ab648519dce9b64d544b3c2df288fd3fe7a848", + "system/vendor/SwiftMailer/classes/Swift/Mailer.php": "c4b64fd63a04dc245418f4bf804aa8f362b45ee566c1b34aa6a5aca9854640e4", + "system/vendor/SwiftMailer/classes/Swift/Mailer/ArrayRecipientIterator.php": "f4db2c946757f5ccc1a7da7589a7627936719792da7816ba46c2aef774c7ea5e", + "system/vendor/SwiftMailer/classes/Swift/Mailer/RecipientIterator.php": "c2fd2c83a072be99b683978145257c5ed4ff98d4e61e324d11f0c8c5dd4f23f2", + "system/vendor/SwiftMailer/classes/Swift/MemorySpool.php": "c368a909df89371242cc6c33cfa422bc168f38dcd26d34be2cbc9a2be48e60f5", + "system/vendor/SwiftMailer/classes/Swift/Message.php": "4692f82d5e0b5bc7883f259a258023148f97ed98ab750a7d87a48d868cdf0a11", + "system/vendor/SwiftMailer/classes/Swift/Mime/Attachment.php": "06500588072dd46f99f2a05b4ec77469882d4c86ce38c609d638d2404d570334", + "system/vendor/SwiftMailer/classes/Swift/Mime/CharsetObserver.php": "53a09c5121b92b7d40ec82c08329f83c59c5a501b8b71dca08591ca408749160", + "system/vendor/SwiftMailer/classes/Swift/Mime/ContentEncoder.php": "af6cc1901985f753952e915639c939453377d5841d4da2e36945ab2d1cc86fea", + "system/vendor/SwiftMailer/classes/Swift/Mime/ContentEncoder/Base64ContentEncoder.php": "1e1dae96c806e8197b95e4db2e887fb10d852d8c8f2b5709c5c6946c744d2e43", + "system/vendor/SwiftMailer/classes/Swift/Mime/ContentEncoder/NativeQpContentEncoder.php": "2e03c5a2cfc967718d466f143d08d7bbf24289fa46f7c073c3324561b142db3d", + "system/vendor/SwiftMailer/classes/Swift/Mime/ContentEncoder/PlainContentEncoder.php": "b56e0f7790c0500f6c40a3e947263735f999b563ff11bdc626e1ada592a5388b", + "system/vendor/SwiftMailer/classes/Swift/Mime/ContentEncoder/QpContentEncoder.php": "754eddc04b93f18e23bbbf223ae4e900c72f5c195b482c9678980a155dbb42a6", + "system/vendor/SwiftMailer/classes/Swift/Mime/ContentEncoder/QpContentEncoderProxy.php": "b476082147a58086c65d6cd558ac555cf5f84c02b6e4d7b752dacfd89dcfb815", + "system/vendor/SwiftMailer/classes/Swift/Mime/ContentEncoder/RawContentEncoder.php": "7cc32b1eb62b958ea064d1d771428a77ef5a84ed623ffe3c5585de5d47d24410", + "system/vendor/SwiftMailer/classes/Swift/Mime/EmbeddedFile.php": "4086652a341db735dfd637a458030cf90c25933fd50f18bcad4b80557c14ec07", + "system/vendor/SwiftMailer/classes/Swift/Mime/EncodingObserver.php": "49037cc39b972458d7ed4638c93ad9ed3bf932c0cda954c4f716ea1a38433677", + "system/vendor/SwiftMailer/classes/Swift/Mime/Grammar.php": "ffc2e51357ea9d9170eec65674f9cb39f7b46a512c2d307b3728879645182ac3", + "system/vendor/SwiftMailer/classes/Swift/Mime/Header.php": "3a4d60531d5358b8364667957058a5278c50c73402e4003af80024d85a9e8403", + "system/vendor/SwiftMailer/classes/Swift/Mime/HeaderEncoder.php": "28ea4292d2583208c74742dec98200ebd3623be34a5ef7e78cefc5bd1e8f7dda", + "system/vendor/SwiftMailer/classes/Swift/Mime/HeaderEncoder/Base64HeaderEncoder.php": "fefb302e132ca3591c3138b0471bfac6581943beef12c2bfc3531f76690d7f95", + "system/vendor/SwiftMailer/classes/Swift/Mime/HeaderEncoder/QpHeaderEncoder.php": "057e33365db571bfd3c3be538c1c14e94e12ed9a36668b9dc4775cc572839209", + "system/vendor/SwiftMailer/classes/Swift/Mime/HeaderFactory.php": "1b88e93857601790bfafc566fa85a5930b311a9377d3f1fd3b637d737e2bf2b5", + "system/vendor/SwiftMailer/classes/Swift/Mime/HeaderSet.php": "438b346609c6c0812b931c5420dba2c207c93c9324f8d1a9195c69bad15aa0d2", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/AbstractHeader.php": "c315730fb69968242e25cecc10c36701df0b5613835c1d21b7686a4a94045861", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/DateHeader.php": "6d22d6f2d5e649edcd10df60e68b38b7035c4856c7a84f46f00bb7c6c68d66f0", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/IdentificationHeader.php": "5012b8e53c152e430cd2d0837caa4ee048a7be842c0cfc3a0042cdc01ade2576", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/MailboxHeader.php": "64bb854926c041646e071434eccb95f4d9ce2f7b8273d66b50a9e0e223313a4c", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/OpenDKIMHeader.php": "3b8a09c9c0588d9f87567e9011b51e4d7fcb87c63ea24a8700513651ce6c5d82", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/ParameterizedHeader.php": "e1a6a966df5792e889e6bbe0043542228ed782a466e5b6587771bb3a3b974e53", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/PathHeader.php": "06bef091eeddf9581cbf51db9c2d63a59fb00e288d5b70fec7c65dd0ce698c04", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/UnstructuredHeader.php": "2077af6d2540ae4946b4092929a70242d57da85a92304e57094027cbbaf8da1f", + "system/vendor/SwiftMailer/classes/Swift/Mime/Message.php": "adc103ed654d9bcfe31c975a6d7098b3238a7b72ef5f55037cc1f6b5cad249aa", + "system/vendor/SwiftMailer/classes/Swift/Mime/MimeEntity.php": "7832320ecf3ff9e897a92c0360fc7ce5fbae46f130a03fd17ea7dfff6e857b18", + "system/vendor/SwiftMailer/classes/Swift/Mime/MimePart.php": "359471f108c7ee553024d0200b91e38e558c6486a06f81bdde1e28cfde915000", + "system/vendor/SwiftMailer/classes/Swift/Mime/ParameterizedHeader.php": "f0beff43d1ea595c2c592fa5dca91ce36093ba765148803f22e3357d94f2090f", + "system/vendor/SwiftMailer/classes/Swift/Mime/SimpleHeaderFactory.php": "0cf5d578125290aba1efeb3ff26369c0aebc579d769d78280b5da354ec23dc6e", + "system/vendor/SwiftMailer/classes/Swift/Mime/SimpleHeaderSet.php": "e1d3588518c7334a5e85a30229c12912b53df2b80fd38016c817d3ecafceed5d", + "system/vendor/SwiftMailer/classes/Swift/Mime/SimpleMessage.php": "7059d95e33510e448ffe07a844f893c33ee72960c63d29d09e30df61b66095da", + "system/vendor/SwiftMailer/classes/Swift/Mime/SimpleMimeEntity.php": "852c2f04f53a15c8ae75d41f363c9cf8df1566ff93fd0bedbfa620fa9d326334", + "system/vendor/SwiftMailer/classes/Swift/MimePart.php": "656860cb840a2e5fee1c4256416e1745446ef9a098a2f0e7278064c519e9b055", + "system/vendor/SwiftMailer/classes/Swift/NullTransport.php": "18c9ea87903a1e810dd95fc9e8abdaf67afeb184d089c06de76d98bfed324f71", + "system/vendor/SwiftMailer/classes/Swift/OutputByteStream.php": "0d9ec16916f337de270e954bb0772e7d08339ad1e134ab02ff6b342597430091", + "system/vendor/SwiftMailer/classes/Swift/Plugins/AntiFloodPlugin.php": "4b3ec14736247b2014c17d944eac61d9c5c5a0876bbfee78a66dfbd90ee9a936", + "system/vendor/SwiftMailer/classes/Swift/Plugins/BandwidthMonitorPlugin.php": "bc6a267b8eb6ed32a0a04a382f933afaf3e0c70d0c171407b779858b566bca02", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Decorator/Replacements.php": "e9b8c8be605b7797d74e93a857ce86df564f72f363d5a06848eb04720ce1d012", + "system/vendor/SwiftMailer/classes/Swift/Plugins/DecoratorPlugin.php": "33d4a04b1e7a6d17b95592edf8b655773985f269b8d06cec9eef70547b67ef70", + "system/vendor/SwiftMailer/classes/Swift/Plugins/ImpersonatePlugin.php": "dff3a9408a41735b0fdd68b4c0316650c24fc65c182bf161d6b50b98821262ff", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Logger.php": "acc2f013b4fb735a9ff0ae2396538172b8bf83b9e0afb1fa0b3ca74a036d1993", + "system/vendor/SwiftMailer/classes/Swift/Plugins/LoggerPlugin.php": "b8fc70a82c574c62524b72ef67e126a30511a1ecc4d3c234ca91db4b99f0b04d", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Loggers/ArrayLogger.php": "2270d42fa03a14daa747398ff72ab006f20decd56d007e58961b55e5f81af85c", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Loggers/EchoLogger.php": "90836b4cc6311c133f47b2b1efa19ccc6aa66fe324d3e314aab1caee18d95f5e", + "system/vendor/SwiftMailer/classes/Swift/Plugins/MessageLogger.php": "62b0be3592c0937e6fea3dfbce69ab9c4e047ac8bb546ed13db77fb70439bbe7", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Pop/Pop3Connection.php": "162280cb9a7079a8eaf52f51ea94d0d60c98a4c3d012de51315a23605eebdf3b", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Pop/Pop3Exception.php": "e02e3a563507a76fc2917ca33d9d54b0b0243d16793d5751db4c05f5256f8024", + "system/vendor/SwiftMailer/classes/Swift/Plugins/PopBeforeSmtpPlugin.php": "24edef4444a6cce732f60e538be190242e2835825cfd6f66ce3b9485e839ee9e", + "system/vendor/SwiftMailer/classes/Swift/Plugins/RedirectingPlugin.php": "2fd0791b95929c51e6f4ea32a710c5c2070d2f9373812bacf91b1823d4710fd2", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Reporter.php": "f0d382eccbd6b795df8d3ea73e844c9d556016748632ad68b4064008a7b40c14", + "system/vendor/SwiftMailer/classes/Swift/Plugins/ReporterPlugin.php": "ea950c30d731a224b027581bb0bdea78045147d8730def8d12af69a16865c97d", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Reporters/HitReporter.php": "810d85db709bb855efcc44163692745525babb537e90bcc4cbb740cb23fa1a58", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Reporters/HtmlReporter.php": "423d7babb9558a59b172c25842549b912ffe5d2cb8f33a6ea452f9bd03a1e82c", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Sleeper.php": "ee0b383cd3904b5f6ab5ea293905646419a9d44dbaf39fa55fa8ae6e091a0fd9", + "system/vendor/SwiftMailer/classes/Swift/Plugins/ThrottlerPlugin.php": "81f9e78311bfa9e584ef5a6c591114a37c3a7c45632855f97e8a2899bae978b4", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Timer.php": "0e5580057aa1f859b9c79a30ed0d72ddbb415c8260f50ce20715edcdfdfa543c", + "system/vendor/SwiftMailer/classes/Swift/Preferences.php": "f74cc96cf5558ede2fb377ec01003690483721f16728f9e73ff8e6637d14d127", + "system/vendor/SwiftMailer/classes/Swift/ReplacementFilterFactory.php": "a9dcfa2b3e70356aa2728fe41c691b9ff0483c0ff178c1d2b442b02fc3664695", + "system/vendor/SwiftMailer/classes/Swift/RfcComplianceException.php": "d531b3e8a6b65f979f2091b4302b6970e1bb8ac3e2be616d0340e20fccf15acb", + "system/vendor/SwiftMailer/classes/Swift/SendmailTransport.php": "13d7e69415ef317d4cf22e05341acf7f45bfbadacc843a31838fb61041ce99ca", + "system/vendor/SwiftMailer/classes/Swift/SignedMessage.php": "ca04f62014d5022fa49ff5748b2105a2b99b1d12e2999a6bef16d9ddbdb77f68", + "system/vendor/SwiftMailer/classes/Swift/Signer.php": "9f39f2953a7b35b9858fdf42bc24936c8de83dbf71ccfe8f171dde63e7c74323", + "system/vendor/SwiftMailer/classes/Swift/Signers/BodySigner.php": "066dec11563ab79062195111312d5a522f8a4afab4ef2efc80e6f6fd61135278", + "system/vendor/SwiftMailer/classes/Swift/Signers/DKIMSigner.php": "62fe28fa969e95a135bd8fe050fe3561a1ad17d2e4e97f2b29b53ff9f5a4f5a2", + "system/vendor/SwiftMailer/classes/Swift/Signers/DomainKeySigner.php": "f5db96ffc3eba9e70b966450e91ec97b887d6365a1f636b22ee2f756b4ee6d5c", + "system/vendor/SwiftMailer/classes/Swift/Signers/HeaderSigner.php": "1cdd861b425b214a7370e04f477ca8a83dd4a839017c67a16597f2fb93462897", + "system/vendor/SwiftMailer/classes/Swift/Signers/OpenDKIMSigner.php": "549f53001db8629c721903512c72086744ff9f836e3f65f37edcfd3effaf0778", + "system/vendor/SwiftMailer/classes/Swift/Signers/SMimeSigner.php": "bb7d4718d258bf4cc3489edf12ad0899a82fc895274d6caa776db8095a70f388", + "system/vendor/SwiftMailer/classes/Swift/SmtpTransport.php": "b447ae45c6381d02757307ceb57c940a43e83d57df68348d96814d58b12a2e17", + "system/vendor/SwiftMailer/classes/Swift/Spool.php": "f4b6c81e13eb378f9ec29017134a310d00635cb7d9b9afdb4cb1e218eda5baec", + "system/vendor/SwiftMailer/classes/Swift/SpoolTransport.php": "28fabb4e5a1deddbe2cbe90a659d0c54b7e205c5ffa3c1a29b79c1576617d09c", + "system/vendor/SwiftMailer/classes/Swift/StreamFilter.php": "08d7876641f7ff7cc94c3f9e911dd247b776237303ab37aa5f8e66fcde4c17ff", + "system/vendor/SwiftMailer/classes/Swift/StreamFilters/ByteArrayReplacementFilter.php": "05b5eb0eb22e9fba950e0dbddeff6128ce59c424696396c8b9908583f0314711", + "system/vendor/SwiftMailer/classes/Swift/StreamFilters/StringReplacementFilter.php": "e59b6dbabca309a26c3209c03af1a42419467dd998080c46266ffe797a6cf966", + "system/vendor/SwiftMailer/classes/Swift/StreamFilters/StringReplacementFilterFactory.php": "106efdd660078787d8569311a06f8a9052c475ce600d99921c47b29d2a4539dd", + "system/vendor/SwiftMailer/classes/Swift/SwiftException.php": "9f0124d5c18fc20a0baa9f9a66c8606de381d37f7249ddcd40b38743753560ba", + "system/vendor/SwiftMailer/classes/Swift/Transport.php": "dc93280eee5c71f8ae06b4712fa9758621c69c9947b21ea8d8e6a970b478ec34", + "system/vendor/SwiftMailer/classes/Swift/Transport/AbstractSmtpTransport.php": "e8ecb0d167fb4c649c285447cbf73c3aa73fe35cc7f58451661af4125ef60ae8", + "system/vendor/SwiftMailer/classes/Swift/Transport/Esmtp/Auth/CramMd5Authenticator.php": "44be928b541ceb7c0e5f4e99953b51b0b7c5088abd1878205bd184af4c6fdfbb", + "system/vendor/SwiftMailer/classes/Swift/Transport/Esmtp/Auth/LoginAuthenticator.php": "09a9613109de54376892ce02b1628ffb568d12a0d2240db116467b567789eab6", + "system/vendor/SwiftMailer/classes/Swift/Transport/Esmtp/Auth/NTLMAuthenticator.php": "bb2632080b89e47dcec88d0ea9af9c5aac8c085f88de1da999fb4e51be1f113a", + "system/vendor/SwiftMailer/classes/Swift/Transport/Esmtp/Auth/PlainAuthenticator.php": "eb97daa8f2148d80ab74902b0a7921d95240e068eb92198732b070aa68b26970", + "system/vendor/SwiftMailer/classes/Swift/Transport/Esmtp/Auth/XOAuth2Authenticator.php": "3eefe9132a007d9cbe6d4ca617162449b2d9321f1e307fb6c0c5fe6f8fe39b4b", + "system/vendor/SwiftMailer/classes/Swift/Transport/Esmtp/AuthHandler.php": "c41a2336dd912fc78e167f200c97ff34f9e313b97ec8a1cf7d78662a61d7aa27", + "system/vendor/SwiftMailer/classes/Swift/Transport/Esmtp/Authenticator.php": "823de8ef43b20e9ab70cd06f852eca9584672493c2714e0ca9c9caa614edbd4c", + "system/vendor/SwiftMailer/classes/Swift/Transport/EsmtpHandler.php": "19139da48299cf00b7fa3a6a135c788c9f29ab9ef468255d6e4cba02b83bfa12", + "system/vendor/SwiftMailer/classes/Swift/Transport/EsmtpTransport.php": "2439c0b4b1151d034534a21f1d3d2990b7a767941a7c2359c3aa6068cc577315", + "system/vendor/SwiftMailer/classes/Swift/Transport/FailoverTransport.php": "23f65b72fef17d75a0a728b38c0925c6a20aaadcef7814e9010253c05ce233ac", + "system/vendor/SwiftMailer/classes/Swift/Transport/IoBuffer.php": "c5632093309c96808bf9402b5726cadd0f0957c6f45ee52b1b206273dce4d4d3", + "system/vendor/SwiftMailer/classes/Swift/Transport/LoadBalancedTransport.php": "373c8df6227c0cb8fbe7d09b03683927697ec80ece17ff9a11626f95a9731afc", + "system/vendor/SwiftMailer/classes/Swift/Transport/MailInvoker.php": "ed63eb6f71fce2c5788f5a4395f763e81ed54d9312068854444eb836c8feb758", + "system/vendor/SwiftMailer/classes/Swift/Transport/MailTransport.php": "299404d425e55b922daf3c813eaded1840252989b506eb1e14b80489efc34c3b", + "system/vendor/SwiftMailer/classes/Swift/Transport/NullTransport.php": "05d466e1ae94cb65783a7fc594805134e7e056edff3363fbcbe32172cf8adf1a", + "system/vendor/SwiftMailer/classes/Swift/Transport/SendmailTransport.php": "32a9dceb55dd1974b047763be678788a244eeb0b978708023a2518ba0130eab6", + "system/vendor/SwiftMailer/classes/Swift/Transport/SimpleMailInvoker.php": "31e14f87ac09409c522da65639e75d16866384b123a77384c149227d93276d0d", + "system/vendor/SwiftMailer/classes/Swift/Transport/SmtpAgent.php": "f71154062a27624c2d528a395f2752e104d9cf81024c6bc5685cb4c5546c437c", + "system/vendor/SwiftMailer/classes/Swift/Transport/SpoolTransport.php": "1339b62d368cc81ca1f6ddfee975ca49a8ab85c231d75644d38841fd8d940900", + "system/vendor/SwiftMailer/classes/Swift/Transport/StreamBuffer.php": "42913ba587910b819993d6fb51ec1298b97adfaf99c3e13b23d28651348dfae0", + "system/vendor/SwiftMailer/classes/Swift/TransportException.php": "d6036f4adc6593787ff29c85e63236f36dd797d61065237c14c601e4d709fdef", + "system/vendor/SwiftMailer/classes/Swift/Validate.php": "cb85e644f1918c69446b4101f595f254d09cd4d0ff4788b0c9051b244f8de90a", + "system/vendor/SwiftMailer/dependency_maps/cache_deps.php": "06ed36c21446686d0cf9f35e6d7dabea80a101709300c3c825d1906e4b8eb814", + "system/vendor/SwiftMailer/dependency_maps/message_deps.php": "af8ea89fa40754c8b0aac7ebeff5019eabe5085ee17bd4af31414af50bf3720d", + "system/vendor/SwiftMailer/dependency_maps/mime_deps.php": "c9ead952e2c1b3a66b8ee09dbafe9f2f83cf40aea7927d3b9507bcaccb637034", + "system/vendor/SwiftMailer/dependency_maps/transport_deps.php": "b878589fbae8c3bff726557878bc2a49130fac56863ec9f65447a6d3f98da82c", + "system/vendor/SwiftMailer/mime_types.php": "41152f8a7a15ff67c8bda2296d709aaea47f83275a10cd04fb26cd2f8ed6bb75", + "system/vendor/SwiftMailer/preferences.php": "ba0ef23f4382e76eb77133c98e1c5b43d896da6c3692600968077298bbab9e35", + "system/vendor/SwiftMailer/swift_init.php": "7601d95f96c233d6d1aa211681aac58deb942cf91f2c2ed0e946396dcca57979", + "system/vendor/SwiftMailer/swift_required.php": "aa9681c7c0dea48e693f2dcb65270d0b82ae9f1158fdaab452c80a327de7104e", + "system/vendor/SwiftMailer/swift_required_pear.php": "51455bdf7196a2a232e4ffe94ad359ce78c034fc7561bb1bb5c29ee19966d3f3", + "system/vendor/SwiftMailer/swiftmailer_generate_mimes_config.php": "6421ee3574b4f687b9397ffb7d39c0d95699d3d95e4b1e5c7a8cabfd9e7e3959", + "system/vendor/SwiftMailer/version.txt": "a0b7f3ad59f77cd8c32c2aeed1ddd50a606b2574ea54274a598a321ebdabfde7", + "system/vendor/Twig/Cache/CacheInterface.php": "62f15d630d499936e163d675304457aee29fda05d83c0ea74cb53003f7348df4", + "system/vendor/Twig/Cache/FilesystemCache.php": "ba4856e86a9cbc2f743d0602cfde38d64bcfa99c2b9e6a62b222c3c4616b98e1", + "system/vendor/Twig/Cache/NullCache.php": "637ad19bdb8495ce437fad067e56ccbbf65e10c7ccb738595841835579b8f5fb", + "system/vendor/Twig/Compiler.php": "7c62ef01b5ff44366fbd80af343911c65e6929044aa19bacd6a46b689f607761", + "system/vendor/Twig/Environment.php": "5190c45b8879755d6b486edc95b253b7bba0ff54d34bf20a37724356dade3a86", + "system/vendor/Twig/Error/Error.php": "4e5c5c7176eabff592bdadefe5eb2c0b70ccccaf81ef1b399e3ee4560e5b8a3e", + "system/vendor/Twig/Error/LoaderError.php": "4e711bc8a016d4e5ebf88b1505424a70908d780185689d91394b575144b32fa4", + "system/vendor/Twig/Error/RuntimeError.php": "ca743030185a96ccdbd0a7a4a5a4edcdf0b347d86b57fdecac80e2177312bcc4", + "system/vendor/Twig/Error/SyntaxError.php": "480328a455a37058fd383b29402ea1ee5c1b6861160e96c63f6647c83b5a14b3", + "system/vendor/Twig/ExpressionParser.php": "c716cf402d7c6d0c3d138b34922c9791148f09aa33e939748dffb8a433e3bfb6", + "system/vendor/Twig/Extension/AbstractExtension.php": "94c0e2fa79f8019a9d0617afd32ec3ac53c41e61d8fc1d8857a20d3889cdfe58", + "system/vendor/Twig/Extension/CoreExtension.php": "230f0a4c43730b64c6f547edccb18442bbb672b7f8f375c5c714cece5b544a42", + "system/vendor/Twig/Extension/DebugExtension.php": "895121dfb960b03849dbedaef8a387047e8d3871a67322cce06c023442c80f4a", + "system/vendor/Twig/Extension/EscaperExtension.php": "390a37e6fe5ac218fb048b3e69b47402388c5416675b75ea3b997a9557533bf6", + "system/vendor/Twig/Extension/ExtensionInterface.php": "9d55d8137d2870554fd154c4c6c6d5800ebba2e94f51d00d2c6816095bdbe9d3", + "system/vendor/Twig/Extension/GlobalsInterface.php": "40b095640be0f33fbb3203b90f1926af5cae8c9381d903f28a981183eaefca0e", + "system/vendor/Twig/Extension/OptimizerExtension.php": "8e0977ae7119524249e229d751424712231a29461e58b0279598636903948f90", + "system/vendor/Twig/Extension/ProfilerExtension.php": "744081e4636c6cf5c747ec56c425ac9d7ce39b10d288cb797f7bcde2e078d70a", + "system/vendor/Twig/Extension/RuntimeExtensionInterface.php": "b9b2072ca63c3f069d05d9202d8120fdee2700e5b3e6b3884cbb2d86281d9203", + "system/vendor/Twig/Extension/SandboxExtension.php": "81600003c7a60ca1aca412b58624fa52f22b4d06c5e4216a581bd549fd889f66", + "system/vendor/Twig/Extension/StagingExtension.php": "7e561a9455630f8206bc820ee9a9e19ce8333bf42f649516e0228ea3d1db5f49", + "system/vendor/Twig/Extension/StringLoaderExtension.php": "b32d8c4878aec00b537a00f7b2797a7175e8e9ad1214d78856df364adbebc5d3", + "system/vendor/Twig/ExtensionSet.php": "fdc250ae787a598c8c608f692dd1fb5f09653b4b6cb5bae41c9463aba4a6a7ee", + "system/vendor/Twig/FileExtensionEscapingStrategy.php": "15074bdf93d3f612f2f606cac42f654c73425827ed24874066c52043c49be435", + "system/vendor/Twig/Lexer.php": "1a923cb0ce839eeb9948bee18acbff1f8c9aaf1713f8ea6ebf37d2eb133e381f", + "system/vendor/Twig/Loader/ArrayLoader.php": "b7076bc791a0b20ccae54fd5a13c99ecd6182d93a711b943925191c97e1821b3", + "system/vendor/Twig/Loader/ChainLoader.php": "7c8b564053879a653d075391a88ff820713c72ce23badde281f259204255d886", + "system/vendor/Twig/Loader/FilesystemLoader.php": "645f06d34d6d5a2629a6fef332d3496720899f087b1595b114d320394a8d2415", + "system/vendor/Twig/Loader/LoaderInterface.php": "48208def4d6f146d5f03071a1140f6f0ddd70a2d5cd0ec3b9ae7cc33c3273c1c", + "system/vendor/Twig/Markup.php": "8cf61d884aeab843b614ca8eefd262b3f414cc71047f47f094261f4be18e0ef2", + "system/vendor/Twig/Node/AutoEscapeNode.php": "435f54f77925b973735ca556cbae6939a08fb5a02a40ae034462930f58e9a750", + "system/vendor/Twig/Node/BlockNode.php": "7781208831e85938b57d5ed35e53d9d728fe695a76800aad62b98efe3a9de14a", + "system/vendor/Twig/Node/BlockReferenceNode.php": "004e3acd1c7a481b2f8161b0a103bcc88665d0a85e6774378fde3eda01fed607", + "system/vendor/Twig/Node/BodyNode.php": "31ac7988f7113411df0dc2d91a78cb10587513593403095e80806ca78aa2eed1", + "system/vendor/Twig/Node/CheckSecurityCallNode.php": "4462221684e3e69a17371f6143b58aa89c56bad2bc98063562dd1fb9116d6fa0", + "system/vendor/Twig/Node/CheckSecurityNode.php": "81df033066c877ee02a7a331bb90dca705acfd89c17fcda5ef4b95b9caa281b4", + "system/vendor/Twig/Node/CheckToStringNode.php": "3c405c8c03c1d246a75b2784bdd2ab098b3e86eb7c54456e767cf4dba588a7b2", + "system/vendor/Twig/Node/DeprecatedNode.php": "775fa07c7a992fb3577daa41a8fdc686b350b190dd0305901e9f40c828e32877", + "system/vendor/Twig/Node/DoNode.php": "9f2382982d2012bcb66ef90781315f917fd3f8a8b5fad81839787cb5dade2e4e", + "system/vendor/Twig/Node/EmbedNode.php": "48f7d463d12ad4b380f35a0dab029f02dc95df5d0798b742d5704ceb7a82bf40", + "system/vendor/Twig/Node/Expression/AbstractExpression.php": "c2d78a904670694b6c7c5234f0ed7978ec0555845bce07b0c17b2ac13205e3d0", + "system/vendor/Twig/Node/Expression/ArrayExpression.php": "90dfd0d786509386d10cf390decefe6f096278f0ddd484905ea936a68aa36cdd", + "system/vendor/Twig/Node/Expression/ArrowFunctionExpression.php": "855c8dcf11e149776479ff45e7e09c4359995b9312d49ed763b92c80e6ce6908", + "system/vendor/Twig/Node/Expression/AssignNameExpression.php": "4477dd313fec6c2c2137b5547d84c08f57639b9b05ed8b7f0d18233ecc41006e", + "system/vendor/Twig/Node/Expression/Binary/AbstractBinary.php": "01e12e5e83bd5d97459054d67cf45fd5516b757fa6c171e7e1c8698053750d0c", + "system/vendor/Twig/Node/Expression/Binary/AddBinary.php": "b035fe27bce5e71c5a146319233a52e954a0629b54a513a5256e9c914ad71a5e", + "system/vendor/Twig/Node/Expression/Binary/AndBinary.php": "47151ae9435bbf1c5217206a7efee7e32722fc6c7782f9ae40e49757fb1dbac0", + "system/vendor/Twig/Node/Expression/Binary/BitwiseAndBinary.php": "7ed9c903be184f0da18cf93593339c02234e907904604deccaccd76426b3b8c7", + "system/vendor/Twig/Node/Expression/Binary/BitwiseOrBinary.php": "272189daaac5a0edc4b106ca612e821cf0b1f67ebec6456b82dde6a9bfcb7289", + "system/vendor/Twig/Node/Expression/Binary/BitwiseXorBinary.php": "54d9949edf9a671127085dfeaac6a5df23451053cc4f7192349a0270cb391f04", + "system/vendor/Twig/Node/Expression/Binary/ConcatBinary.php": "4aa776ab1ad46c8d8c4fc54f6880515849ccad2a7e4a6a79631b145b944989b3", + "system/vendor/Twig/Node/Expression/Binary/DivBinary.php": "217eddc4ed13d25cc26dde9f1ac719c23f7fb0643fe167d0538b7ac9b9fff6de", + "system/vendor/Twig/Node/Expression/Binary/EndsWithBinary.php": "9525865f7cdb5944977a0ec5c780d29fed8dd4a893eeb09f6fd7be762205fd0f", + "system/vendor/Twig/Node/Expression/Binary/EqualBinary.php": "d01c17925aabde3e5d7f42f371ee6fafc01a2317a4149bd1bbf2a59a5bb084fe", + "system/vendor/Twig/Node/Expression/Binary/FloorDivBinary.php": "d6003048918becb5c19c2ab0548c6e388954cae0beab4cad4e49eac6aca1f655", + "system/vendor/Twig/Node/Expression/Binary/GreaterBinary.php": "de9bd868a73f112c6064bd0ba211a5c47048383eb5e9353abdaace76ed9299c3", + "system/vendor/Twig/Node/Expression/Binary/GreaterEqualBinary.php": "01d261da8cd8a173ea9b7266d9323fb0c3e6ca8d2ce1d184481e0261e8e2a6bf", + "system/vendor/Twig/Node/Expression/Binary/InBinary.php": "c3b80d85af7c1a99eb0c45aad1e3cd7bdd30dc0ff34d6b77d3b0a8bafb1bb548", + "system/vendor/Twig/Node/Expression/Binary/LessBinary.php": "7455718aa4e78a016cdd147a22349162b65da488add191215c261f5d2525ed96", + "system/vendor/Twig/Node/Expression/Binary/LessEqualBinary.php": "578890c4a0edfe9053b6d0349e6e4ca4785f8cec5f1b8d325e96d28b4ce3c5a3", + "system/vendor/Twig/Node/Expression/Binary/MatchesBinary.php": "fd20498f45a624cc5b1b0312547ee78eb8e72d998663673aa471f6b85eb1a547", + "system/vendor/Twig/Node/Expression/Binary/ModBinary.php": "541832f4eb5ba908072af43e86c8fcffcad4d825bc23bab87e6d84e3f8987525", + "system/vendor/Twig/Node/Expression/Binary/MulBinary.php": "c3bd5f32ec0585a5fdca6af30461291c034cbe6f56d368365e1c3e70abd401ec", + "system/vendor/Twig/Node/Expression/Binary/NotEqualBinary.php": "ab0d439c73a541f36bd7f7cff521553fb49fd58d64834818b7de7f8796f80df0", + "system/vendor/Twig/Node/Expression/Binary/NotInBinary.php": "3a9f2ba8079263d2addf5ac07f5a7599a5eaaf75b4ecea8b5058f730c0e76f5f", + "system/vendor/Twig/Node/Expression/Binary/OrBinary.php": "932cff4689ffea91c34b5531ec40b3af48dbda683e3bfa0e46aefd31f7d9bfcd", + "system/vendor/Twig/Node/Expression/Binary/PowerBinary.php": "ecc85f6d1223c3a05d42c632f58923c0f66a0f12468e827428b56e46f061c119", + "system/vendor/Twig/Node/Expression/Binary/RangeBinary.php": "a0e80268c2e78fe95bff5aaf1d51289aad090a24aaf66206639c5da474765fe1", + "system/vendor/Twig/Node/Expression/Binary/SpaceshipBinary.php": "c2acafd12eaf298f12224eb30480ed0dd83f3006ac16fd6d94b96caa65a8983e", + "system/vendor/Twig/Node/Expression/Binary/StartsWithBinary.php": "d6891226571f3b4189d6f5e194a9b0310e7e88bf20764c28410c9ce5d7da288b", + "system/vendor/Twig/Node/Expression/Binary/SubBinary.php": "aab41843c3be5d0e76a26ac6732cab6ce3dde67659b74b1d1bfb410682140480", + "system/vendor/Twig/Node/Expression/BlockReferenceExpression.php": "41ccb421f7a27cfb53d0e44f91b49160a1d6cef5864c0690f5f0cfcf4b33a9ef", + "system/vendor/Twig/Node/Expression/CallExpression.php": "7347e0007271ebf7fdff375cf86d1d158453e7502810238ffe5a06ebbd0abea3", + "system/vendor/Twig/Node/Expression/ConditionalExpression.php": "d4d157ecfc5c1c4cfe10648affcbef7d90636aaaec0f87c604df2716699458f4", + "system/vendor/Twig/Node/Expression/ConstantExpression.php": "e944f8d523c00ae78f904ff866defe75c3ce263631eba9d0d7dfc59958363b49", + "system/vendor/Twig/Node/Expression/Filter/DefaultFilter.php": "770b513009e972ce8c89f5dca5aad217f0caea4129e53b1ff13d1fdcb9edf98f", + "system/vendor/Twig/Node/Expression/FilterExpression.php": "0970a681b86360f13265bc2e57c047293336b005f3aba545040c9c446718c7e7", + "system/vendor/Twig/Node/Expression/FunctionExpression.php": "37603da21ca633c5efca63b941e1b3068ae25c6f39a2fd0f72d9e1f8e85a7685", + "system/vendor/Twig/Node/Expression/GetAttrExpression.php": "a2cc4a9326315681ef547ba815fbeddb3e80ad38a1d5945aafe2c22c121d8d9d", + "system/vendor/Twig/Node/Expression/InlinePrint.php": "59df93a262df702dba943ea2753b001556618e99123cc45bf7a6e2fe3657cfb9", + "system/vendor/Twig/Node/Expression/MethodCallExpression.php": "5d0b612753c4fecc8d5af555641bed172866bd03edcdf2c9d76c99c403bc4423", + "system/vendor/Twig/Node/Expression/NameExpression.php": "f741b1064db30e00a4cb0f122ea8d15458a45cf284a85f2c74a9886f121c97f8", + "system/vendor/Twig/Node/Expression/NullCoalesceExpression.php": "db2799a3aadd8d44fca5787d162f1f45beaeb60f070004dbbcda08fed486efda", + "system/vendor/Twig/Node/Expression/ParentExpression.php": "f5a4991a01b59abcef35add9a7f273a9a577e8e95089230b27764d617bb401e5", + "system/vendor/Twig/Node/Expression/TempNameExpression.php": "0fcce7f089124a2e28dbacff59509d4d38b64a04e8737cc469fb53be62284245", + "system/vendor/Twig/Node/Expression/Test/ConstantTest.php": "64a5945aeded2ff29da00d68f195832550641590c05023d0dcf393c160d298bd", + "system/vendor/Twig/Node/Expression/Test/DefinedTest.php": "475cce348d404caa87c4d1e56bf2e7d833618cd1cef0dd635f1bd07c17e46ebb", + "system/vendor/Twig/Node/Expression/Test/DivisiblebyTest.php": "c5cbf403867b95fa4ab74fdb9ed109d75792b5f3f508dea7eb0843a9cac7b7a7", + "system/vendor/Twig/Node/Expression/Test/EvenTest.php": "88e9d96adf0d361979ecc6eda1731d696c550ac3689fa4caadcffa0ff429b184", + "system/vendor/Twig/Node/Expression/Test/NullTest.php": "bd5846d821b9a5b4941616564884aa50bca606f324854c34b531b393e2ae5b6c", + "system/vendor/Twig/Node/Expression/Test/OddTest.php": "007fe8b9ecec45bc41065601c2aaa8f1a5ffaf01c37e67b300b59251dea03195", + "system/vendor/Twig/Node/Expression/Test/SameasTest.php": "2e15af9d600b8825650cfa1d38a69e03b2afea47ece4726d5751f6a03dea80d0", + "system/vendor/Twig/Node/Expression/TestExpression.php": "34570576855da1d642a605525c310f9ee25587071955ce210018bf9acbd554c2", + "system/vendor/Twig/Node/Expression/Unary/AbstractUnary.php": "61adc643ccd06efe22e28f1543537c00d055d866b3b77dd485f160a1023b975d", + "system/vendor/Twig/Node/Expression/Unary/NegUnary.php": "e0cddb75987dfb975b7d78f68c73ed662782c6b86baac5958b13a9b558b9fc56", + "system/vendor/Twig/Node/Expression/Unary/NotUnary.php": "c952c2fc047637c0ba73e3f1826cfcd9c60302861bea2b392b53609a56f2d07f", + "system/vendor/Twig/Node/Expression/Unary/PosUnary.php": "678643618d2da427e9e36f3d88a92520fd356e0ba4aff7c219bc70f7e204da53", + "system/vendor/Twig/Node/Expression/VariadicExpression.php": "1c4d12389e24673c9ece86701816acb21f28f5e079c5efad4e89434b9a92b64c", + "system/vendor/Twig/Node/FlushNode.php": "15279f82b1c23534b4708b759ac5b0e357f1197acf3b5e1c3a77004531ac6db9", + "system/vendor/Twig/Node/ForLoopNode.php": "174a0efda8222bf8079f8679e470e7a21c530bd7e8ea8335e031dabe2620338b", + "system/vendor/Twig/Node/ForNode.php": "1f66810785bffa51333dbe49583e0ac21523d585422250452c743b9d093ccaa3", + "system/vendor/Twig/Node/IfNode.php": "b53e1a86327ce3fb45796106245d9a081045c2f0592e53eab44a3b3b059a3f8e", + "system/vendor/Twig/Node/ImportNode.php": "b1acf0e6eb569def988a6b130c47d8a0e90733228652537e68d600ff43bce97e", + "system/vendor/Twig/Node/IncludeNode.php": "47a875b7e4804a59087219349054f99e3c144298994eab1e8c8fc11aedeedae0", + "system/vendor/Twig/Node/MacroNode.php": "d188b81fc30058f659cb66330dbd0d6abab982a092d59f4b24452420e97a4e21", + "system/vendor/Twig/Node/ModuleNode.php": "f8071a7271582f682f52ca1f563677f2e45dfa0c132ab93f7b4dfe36a960b3d7", + "system/vendor/Twig/Node/Node.php": "b5d901fccec2c976de671f53e0f8b51c1ce205d4ff3209f6714899cab0a88cb5", + "system/vendor/Twig/Node/NodeCaptureInterface.php": "e4206184ddce15ab36790bdc5b673c94a5bba14346be9a271c8fa86ea98803a4", + "system/vendor/Twig/Node/NodeOutputInterface.php": "5664a04d80fe066cd31e560dd6f53955cb7b6398214d8334a61a94e239007f8e", + "system/vendor/Twig/Node/PrintNode.php": "3d803d67960d62d3072229610cfd2381a09dd0b9f8e08464e85c075c4c416af8", + "system/vendor/Twig/Node/SandboxNode.php": "cd020e2423adb29d5952e89b6a7cd22b6f92fc4a5fc0cbc92fa3cdb84e55a9b5", + "system/vendor/Twig/Node/SetNode.php": "8a9fadc19b1c0613382972e5ec778317e5c52636162354f450d27fb5daeaaa06", + "system/vendor/Twig/Node/TextNode.php": "b4879acfd88a523b6854ea20d9dafe53a6f59e161a20cbdd89fc991b70d1cf09", + "system/vendor/Twig/Node/WithNode.php": "1989ad0c18778a07fc1e07fe1564ebad1d1cbc21ebef488fbf832a098903ad5c", + "system/vendor/Twig/NodeTraverser.php": "52721b1770c27dde99c99c38ca672d8143957665a6ad94eeda9f754c4b31086a", + "system/vendor/Twig/NodeVisitor/AbstractNodeVisitor.php": "42fbc052ac17200da16a64054476c1867c58aa53f365443ffe85dbecea64add4", + "system/vendor/Twig/NodeVisitor/EscaperNodeVisitor.php": "36f5ed3778235899638c40a33e8ae8d8af27c0722828cc80b1ad53cc09af2507", + "system/vendor/Twig/NodeVisitor/MacroAutoImportNodeVisitor.php": "258831cf4c695e3e128e583abafde2d668205901dd6fc65614207b48d13f7991", + "system/vendor/Twig/NodeVisitor/NodeVisitorInterface.php": "7f9578716f2fae5e27964623a855b277a9c549958d1b22db2bda5999b5f0fdb0", + "system/vendor/Twig/NodeVisitor/OptimizerNodeVisitor.php": "55ef4d17876b306fa9c7591d0755fb794abe366b85b5175c4c2a94b98d781804", + "system/vendor/Twig/NodeVisitor/SafeAnalysisNodeVisitor.php": "b42b538fb8c367b0ece6da96f29344c5650f06f78c923a081d2d11951d8f62e1", + "system/vendor/Twig/NodeVisitor/SandboxNodeVisitor.php": "58451e56c97bb7f8c1d2d3163652770ae5ec58961952631ef57934fb7689dc81", + "system/vendor/Twig/Parser.php": "15e2baa7d317c1a7b0d0bd98c89ea3efa83c02c13229b4dcc6b57e50da17bcd7", + "system/vendor/Twig/Profiler/Dumper/BaseDumper.php": "41ae2d2ba115b1a2adfb6550da0dd44224e0836ecc56a25acf8eec0a5a9110a9", + "system/vendor/Twig/Profiler/Dumper/BlackfireDumper.php": "4192ce7dc29a239e7905541631619a71f7efa9310c98e3a353d474621d7696c7", + "system/vendor/Twig/Profiler/Dumper/HtmlDumper.php": "011860fb2c926fa2c9bfdcfe089453e41d906efaffaddf881524f66dc4f49596", + "system/vendor/Twig/Profiler/Dumper/TextDumper.php": "8b1721dfefd5d962b12df022d3d4ef29ba0964c62c363fee3137194ee68054ff", + "system/vendor/Twig/Profiler/Node/EnterProfileNode.php": "3f8fd6baf8813b3f6df4447a5b2c902746121bb5e92357aaffbbe4e8cd9d9704", + "system/vendor/Twig/Profiler/Node/LeaveProfileNode.php": "857725c76d5e6f19ba4e0aa4d77b4eae284cbaaaa5f6b6e7af2ddf2970d2d022", + "system/vendor/Twig/Profiler/NodeVisitor/ProfilerNodeVisitor.php": "213fa5a5d8cc3a0ab4609a9295c2371cb0ea63414db05b89b2c683a9387aa4c4", + "system/vendor/Twig/Profiler/Profile.php": "c060c14452330d0eff498e8c1a6079958996b3a15d8d85867dd331bf284e4224", + "system/vendor/Twig/RuntimeLoader/ContainerRuntimeLoader.php": "b01c123706dbb80f28464376977d4ea213592c0ef70d79a7b3058a769894c3b6", + "system/vendor/Twig/RuntimeLoader/FactoryRuntimeLoader.php": "658e5318fb3b629e799544882bed4cf7e7a9033fea6fb57a828249a28e2d00a9", + "system/vendor/Twig/RuntimeLoader/RuntimeLoaderInterface.php": "cdd7d6b3cb9470b75640036c9557c82f260b810efba8f56393f3306517fc9784", + "system/vendor/Twig/Sandbox/SecurityError.php": "a3f23ace70e2d5f97df6e0ed424069c6c6fd25c1d2b0f55f9bd1a66ca7629c72", + "system/vendor/Twig/Sandbox/SecurityNotAllowedFilterError.php": "59bf9e33fc13a2cb1a2664b69ecb1298d5fc50523384a851087ca65ea661ce2a", + "system/vendor/Twig/Sandbox/SecurityNotAllowedFunctionError.php": "6405a5957d28743c26786e3d1cf3384d86804fcf16eda229869d3ef02339873a", + "system/vendor/Twig/Sandbox/SecurityNotAllowedMethodError.php": "0ed94d48c2c13cee6ee7c98f3562391027934b200b5e2a3591b529dee9856359", + "system/vendor/Twig/Sandbox/SecurityNotAllowedPropertyError.php": "802c8f1478fabca15ca895f15a4b4d0fb524ba5a2195096404121f6ad85b51d2", + "system/vendor/Twig/Sandbox/SecurityNotAllowedTagError.php": "31f38a342b8cc9b63720993f742801ca422931abf8d1fef435181c4d8380025f", + "system/vendor/Twig/Sandbox/SecurityPolicy.php": "53aeecf52fd341c1535ea4d17b2700aae0779ec724a12a26aeba78289793245b", + "system/vendor/Twig/Sandbox/SecurityPolicyInterface.php": "f132061ace73670f1ce890800d00d71a4731bc9fe1b833f5f61b4b77fb5fc399", + "system/vendor/Twig/Source.php": "cff62c57f12e1a041d74d2f2868e0773ac4795cc8cc8719fd6b4263f580c7993", + "system/vendor/Twig/Template.php": "e8dc54c84a18aefea1de591a451c1ff02c13bbefaab966b7d425fe5273f412aa", + "system/vendor/Twig/TemplateWrapper.php": "7a34d174828f8c78b7e02346710ebd1b6e67d1105873ca304e65e6648618a2ed", + "system/vendor/Twig/Test/IntegrationTestCase.php": "82687fe915d4bd08a425000d2a20497a42f4672ea719b292f69f4f7505e3a7aa", + "system/vendor/Twig/Test/NodeTestCase.php": "eed6c413b025ec8668c9fe81d0baca02b3441c082611dc0aba2a8f313e2bc1f3", + "system/vendor/Twig/Token.php": "a73563d55d15d19348f1281c2df2f30c9d53c5aff8e0a8bf92c5820de0cb0259", + "system/vendor/Twig/TokenParser/AbstractTokenParser.php": "457529f2baa5d3cbc0138376cf5e07f8a6833d19d56d943b78dc5216aa41f456", + "system/vendor/Twig/TokenParser/ApplyTokenParser.php": "0529baa38384dccbcb4daeee22bc5cbe18bc73cba78430b8e49b4c719571a977", + "system/vendor/Twig/TokenParser/AutoEscapeTokenParser.php": "9a9e2f7f5e5ad2667746eb44bb8c19b26bf2dd4db4a3fbb1eb5c8cb801c86a78", + "system/vendor/Twig/TokenParser/BlockTokenParser.php": "ccb2e6e1bb3e42b45e92c4905886a083048c2affcf2397b98e83ee4d2c366286", + "system/vendor/Twig/TokenParser/DeprecatedTokenParser.php": "1c0e3c9e21e40e7fb6af1f9bc6a7f0c7808c0a056df0b6f9131b9915d4f5c157", + "system/vendor/Twig/TokenParser/DoTokenParser.php": "c25c06daef388f886e7140833cbf5fbd00052d886bc6b96684ca1899b00d0d32", + "system/vendor/Twig/TokenParser/EmbedTokenParser.php": "6b362fc2523204bcdc9caaf1eeb644ee7d38b3de681d6a0c3ed8b020daf87ecd", + "system/vendor/Twig/TokenParser/ExtendsTokenParser.php": "8aab806d498f798b649e7f22cc12cf00fffb7287b6adb32179d03b46a1b0b0c1", + "system/vendor/Twig/TokenParser/FlushTokenParser.php": "09923f9d8427c031f2e47186bcdaebb96701f36f83729dfcc244db07f222683a", + "system/vendor/Twig/TokenParser/ForTokenParser.php": "95f321e52aad294b21f3a90bd7a020ba94d78ce0bfa3ef8d1bf7768e6373cc3d", + "system/vendor/Twig/TokenParser/FromTokenParser.php": "9dbb878a8d1a5555c9630728e5cc493b84bd0ff6f3f97dcf61fbadf8c9e2132a", + "system/vendor/Twig/TokenParser/IfTokenParser.php": "a410ca9195c7169a97e780a4cb06f3b2dcac0c0ab9731902344a35eca55f7e4a", + "system/vendor/Twig/TokenParser/ImportTokenParser.php": "44b29847c0672b7b8f9f5a345ac5af3f32704907d6a967e906e8187fdc178cd1", + "system/vendor/Twig/TokenParser/IncludeTokenParser.php": "7bf8ea6ac134baa3a96d155d16c6f69436ad190200d46175e65fc0202c41ede3", + "system/vendor/Twig/TokenParser/MacroTokenParser.php": "1079fe620d646c1c767a2bc5a4ec60cad4fe584166ad598f0eda5510bc81a4ef", + "system/vendor/Twig/TokenParser/SandboxTokenParser.php": "55aae075cdfdfb38ac353186156853d819ed250917e327959c0ed0cddaadf10c", + "system/vendor/Twig/TokenParser/SetTokenParser.php": "3085cdf157ab8f1c1e3105471fe044baef8e40721e5115c9e99386aa08e25210", + "system/vendor/Twig/TokenParser/TokenParserInterface.php": "6a84c58ba454f3c60d13d9a59ed8c24118caf1f69b3e78ada3e760a1dece379d", + "system/vendor/Twig/TokenParser/UseTokenParser.php": "8ca95ef79b629ae8ac3740899901d41fa49f79e6687f1b55262feb7d020138d5", + "system/vendor/Twig/TokenParser/WithTokenParser.php": "3023e1f140707c8334f0513e9f4ee6659fb5accd53043b7d5d8f8b0d11073262", + "system/vendor/Twig/TokenStream.php": "ba865109fb57e1583ed8fc695b4b88b511de3a4f14810b6960ad454e8599a1b0", + "system/vendor/Twig/TwigFilter.php": "89cf5bd14582adbb9f0289bc90245f619c5ac6ca12100b21c45ed92b421654b3", + "system/vendor/Twig/TwigFunction.php": "b2608a8f80849bbfcda89c275e1269f0b79d5922ea27a738ee41fd217405b172", + "system/vendor/Twig/TwigTest.php": "5729e418333fb252b37c02f51996596e99048f85afe21e71d0ffcf27962aed5a", + "system/vendor/Twig/Util/DeprecationCollector.php": "602852aee1f4f72fc45ffc4aa21b2ed67aaa51de0fe28ec54cc75abf12ec7ddf", + "system/vendor/Twig/Util/TemplateDirIterator.php": "b52ad2c2cda8efdb9b6b3719fdd64c5a3e38bba1ca440c1941132ac60d756ec7", + "templates/index.php": "1a0b826446e8c8211372f1f45a6ace298395116cd80bc1cbb2cdef7762038a95", + "templates/public/index.php": "c57a4bb3eb5ae6c0394cc748a14dc7f703a9d819333f181f0edffffa680100bc", + "templates/public/templates/.htaccess": "ef94d4c2ac2a038b6c2ae673d9573333a0a20f7fac9e2d75af11c6c387a157a3", + "templates/public/theme.json": "4fb596d58dbda7f58c26c4f3640c56d6f6981f402ffbd368229adddc23683690", + "tmp/.htaccess": "6b817083ef9d947002e5ffe1e216de9b1ecd9bba813f0d8493b25dab83001699", + "uploads/.htaccess": "90cd571b4c9f09a63732254fbcdca92cc7da8560a6e5b4d9ec7e58ac40e528bf", + "uploads/index.php": "e91434fe4986c22a2bba7ceda8e59d1aa2c9f943353035c83939cfd4f423fdd8", + "robots.txt": "d8119db904b34c2aab74cc2b6a09d13119f7c19add8b51ffbf4717eba65e8218" + }, + "file_owners": { + ".htaccess": "core", + "README.md": "core", + "adminx/.htaccess": "core", + "adminx/Support/AdminLocale.php": "core", + "adminx/Support/CodeEditor.php": "core", + "adminx/Support/InterfaceSettings.php": "core", + "adminx/Support/ModuleExtensions.php": "core", + "adminx/Support/Notifications.php": "core", + "adminx/Support/PhpCode.php": "core", + "adminx/Support/ReAuth.php": "core", + "adminx/Support/Roles.php": "core", + "adminx/Support/RouteGuard.php": "core", + "adminx/Support/SavedViews.php": "core", + "adminx/Support/SectionHelp.php": "core", + "adminx/Support/SensitiveRoutes.php": "core", + "adminx/Support/SystemFeatures.php": "core", + "adminx/Support/SystemHealth.php": "core", + "adminx/Support/Twig/AdminConstantExpression.php": "core", + "adminx/Support/Twig/AdminLocaleExtension.php": "core", + "adminx/Support/Twig/AdminLocaleNodeVisitor.php": "core", + "adminx/Support/Twig/AdminTextNode.php": "core", + "adminx/assets/css/adminkit.css": "core", + "adminx/assets/fonts/NunitoSans-Bold.woff2": "core", + "adminx/assets/fonts/NunitoSans-ExtraBold.woff2": "core", + "adminx/assets/fonts/NunitoSans-Regular.woff2": "core", + "adminx/assets/fonts/OpenSans-Bold.woff2": "core", + "adminx/assets/fonts/OpenSans-Regular.woff2": "core", + "adminx/assets/fonts/OpenSans-SemiBold.woff2": "core", + "adminx/assets/img/logo.svg": "core", + "adminx/assets/js/adminx.js": "core", + "adminx/assets/js/editor-codemirror.js": "core", + "adminx/assets/js/editor-tiptap.js": "core", + "adminx/assets/js/media-picker.js": "core", + "adminx/assets/js/saved-views.js": "core", + "adminx/assets/src/editor-tiptap.js": "core", + "adminx/assets/vendor/codemirror/addon/dialog/dialog.css": "core", + "adminx/assets/vendor/codemirror/addon/dialog/dialog.js": "core", + "adminx/assets/vendor/codemirror/addon/edit/closetag.js": "core", + "adminx/assets/vendor/codemirror/addon/edit/matchbrackets.js": "core", + "adminx/assets/vendor/codemirror/addon/search/search.js": "core", + "adminx/assets/vendor/codemirror/addon/search/searchcursor.js": "core", + "adminx/assets/vendor/codemirror/addon/selection/active-line.js": "core", + "adminx/assets/vendor/codemirror/index.php": "core", + "adminx/assets/vendor/codemirror/lib/codemirror.css": "core", + "adminx/assets/vendor/codemirror/lib/codemirror.js": "core", + "adminx/assets/vendor/codemirror/mode/clike/clike.js": "core", + "adminx/assets/vendor/codemirror/mode/css/css.js": "core", + "adminx/assets/vendor/codemirror/mode/htmlmixed/htmlmixed.js": "core", + "adminx/assets/vendor/codemirror/mode/javascript/javascript.js": "core", + "adminx/assets/vendor/codemirror/mode/php/php.js": "core", + "adminx/assets/vendor/codemirror/mode/smarty/smarty.js": "core", + "adminx/assets/vendor/codemirror/mode/smartymixed/smartymixed.js": "core", + "adminx/assets/vendor/codemirror/mode/sql/sql.js": "core", + "adminx/assets/vendor/codemirror/mode/xml/xml.js": "core", + "adminx/assets/vendor/codemirror/theme/3024-day.css": "core", + "adminx/assets/vendor/codemirror/theme/3024-night.css": "core", + "adminx/assets/vendor/codemirror/theme/abcdef.css": "core", + "adminx/assets/vendor/codemirror/theme/ambiance-mobile.css": "core", + "adminx/assets/vendor/codemirror/theme/ambiance.css": "core", + "adminx/assets/vendor/codemirror/theme/ayu-dark.css": "core", + "adminx/assets/vendor/codemirror/theme/ayu-mirage.css": "core", + "adminx/assets/vendor/codemirror/theme/base16-dark.css": "core", + "adminx/assets/vendor/codemirror/theme/base16-light.css": "core", + "adminx/assets/vendor/codemirror/theme/bespin.css": "core", + "adminx/assets/vendor/codemirror/theme/blackboard.css": "core", + "adminx/assets/vendor/codemirror/theme/cobalt.css": "core", + "adminx/assets/vendor/codemirror/theme/colorforth.css": "core", + "adminx/assets/vendor/codemirror/theme/darcula.css": "core", + "adminx/assets/vendor/codemirror/theme/dracula.css": "core", + "adminx/assets/vendor/codemirror/theme/duotone-dark.css": "core", + "adminx/assets/vendor/codemirror/theme/duotone-light.css": "core", + "adminx/assets/vendor/codemirror/theme/eclipse.css": "core", + "adminx/assets/vendor/codemirror/theme/elegant.css": "core", + "adminx/assets/vendor/codemirror/theme/erlang-dark.css": "core", + "adminx/assets/vendor/codemirror/theme/gruvbox-dark.css": "core", + "adminx/assets/vendor/codemirror/theme/hopscotch.css": "core", + "adminx/assets/vendor/codemirror/theme/icecoder.css": "core", + "adminx/assets/vendor/codemirror/theme/idea.css": "core", + "adminx/assets/vendor/codemirror/theme/isotope.css": "core", + "adminx/assets/vendor/codemirror/theme/lesser-dark.css": "core", + "adminx/assets/vendor/codemirror/theme/liquibyte.css": "core", + "adminx/assets/vendor/codemirror/theme/lucario.css": "core", + "adminx/assets/vendor/codemirror/theme/material-darker.css": "core", + "adminx/assets/vendor/codemirror/theme/material-ocean.css": "core", + "adminx/assets/vendor/codemirror/theme/material-palenight.css": "core", + "adminx/assets/vendor/codemirror/theme/material.css": "core", + "adminx/assets/vendor/codemirror/theme/mbo.css": "core", + "adminx/assets/vendor/codemirror/theme/mdn-like.css": "core", + "adminx/assets/vendor/codemirror/theme/midnight.css": "core", + "adminx/assets/vendor/codemirror/theme/monokai.css": "core", + "adminx/assets/vendor/codemirror/theme/moxer.css": "core", + "adminx/assets/vendor/codemirror/theme/neat.css": "core", + "adminx/assets/vendor/codemirror/theme/neo.css": "core", + "adminx/assets/vendor/codemirror/theme/night.css": "core", + "adminx/assets/vendor/codemirror/theme/nord.css": "core", + "adminx/assets/vendor/codemirror/theme/oceanic-next.css": "core", + "adminx/assets/vendor/codemirror/theme/panda-syntax.css": "core", + "adminx/assets/vendor/codemirror/theme/paraiso-dark.css": "core", + "adminx/assets/vendor/codemirror/theme/paraiso-light.css": "core", + "adminx/assets/vendor/codemirror/theme/pastel-on-dark.css": "core", + "adminx/assets/vendor/codemirror/theme/railscasts.css": "core", + "adminx/assets/vendor/codemirror/theme/rubyblue.css": "core", + "adminx/assets/vendor/codemirror/theme/seti.css": "core", + "adminx/assets/vendor/codemirror/theme/shadowfox.css": "core", + "adminx/assets/vendor/codemirror/theme/solarized.css": "core", + "adminx/assets/vendor/codemirror/theme/ssms.css": "core", + "adminx/assets/vendor/codemirror/theme/the-matrix.css": "core", + "adminx/assets/vendor/codemirror/theme/tomorrow-night-bright.css": "core", + "adminx/assets/vendor/codemirror/theme/tomorrow-night-eighties.css": "core", + "adminx/assets/vendor/codemirror/theme/ttcn.css": "core", + "adminx/assets/vendor/codemirror/theme/twilight.css": "core", + "adminx/assets/vendor/codemirror/theme/vibrant-ink.css": "core", + "adminx/assets/vendor/codemirror/theme/xq-dark.css": "core", + "adminx/assets/vendor/codemirror/theme/xq-light.css": "core", + "adminx/assets/vendor/codemirror/theme/yeti.css": "core", + "adminx/assets/vendor/codemirror/theme/yonce.css": "core", + "adminx/assets/vendor/codemirror/theme/zenburn.css": "core", + "adminx/assets/vendor/tabler/fonts/tabler-icons.woff2": "core", + "adminx/assets/vendor/tabler/tabler-icons.min.css": "core", + "adminx/core-permissions.php": "core", + "adminx/index.php": "core", + "adminx/language/en/client.xml": "core", + "adminx/language/en/common.xml": "core", + "adminx/language/en/interface.xml": "core", + "adminx/language/en/runtime.xml": "core", + "adminx/language/en/section-help.xml": "core", + "adminx/language/ru/client.xml": "core", + "adminx/language/ru/common.xml": "core", + "adminx/language/ru/interface.xml": "core", + "adminx/language/ru/runtime.xml": "core", + "adminx/language/ru/section-help.xml": "core", + "adminx/modules/Auth/Controller.php": "core", + "adminx/modules/Auth/language/en/interface.xml": "core", + "adminx/modules/Auth/language/en/runtime.xml": "core", + "adminx/modules/Auth/language/ru/interface.xml": "core", + "adminx/modules/Auth/language/ru/runtime.xml": "core", + "adminx/modules/Auth/module.php": "core", + "adminx/modules/Auth/view/login.twig": "core", + "adminx/modules/Blocks/Controller.php": "core", + "adminx/modules/Blocks/Model.php": "core", + "adminx/modules/Blocks/Revisions.php": "core", + "adminx/modules/Blocks/Syntax.php": "core", + "adminx/modules/Blocks/assets/blocks.css": "core", + "adminx/modules/Blocks/assets/blocks.js": "core", + "adminx/modules/Blocks/language/en/interface.xml": "core", + "adminx/modules/Blocks/language/en/runtime.xml": "core", + "adminx/modules/Blocks/language/ru/interface.xml": "core", + "adminx/modules/Blocks/language/ru/runtime.xml": "core", + "adminx/modules/Blocks/migrations/001_create_sysblocks_tables.sql": "core", + "adminx/modules/Blocks/migrations/002_create_sysblock_revisions.sql": "core", + "adminx/modules/Blocks/migrations/003_native_public_document_context.sql": "core", + "adminx/modules/Blocks/migrations/004_native_database_calls.sql": "core", + "adminx/modules/Blocks/migrations/005_correct_native_database_escape.sql": "core", + "adminx/modules/Blocks/migrations/006_native_public_quick_edit.sql": "core", + "adminx/modules/Blocks/migrations/007_cache_safe_public_quick_edit.sql": "core", + "adminx/modules/Blocks/migrations/008_deferred_public_quick_edit_id.sql": "core", + "adminx/modules/Blocks/migrations/009_disable_public_php_endpoint.sql": "core", + "adminx/modules/Blocks/migrations/010_disable_retired_catalog_endpoints.sql": "core", + "adminx/modules/Blocks/migrations/011_sysblock_editor_mode.php": "core", + "adminx/modules/Blocks/migrations/012_merge_visual_blocks.php": "core", + "adminx/modules/Blocks/module.php": "core", + "adminx/modules/Blocks/view/index.twig": "core", + "adminx/modules/Catalog/Controller.php": "core", + "adminx/modules/Catalog/Model.php": "core", + "adminx/modules/Catalog/assets/catalog-drawer.css": "core", + "adminx/modules/Catalog/assets/catalog.css": "core", + "adminx/modules/Catalog/assets/catalog.js": "core", + "adminx/modules/Catalog/language/en/client.xml": "core", + "adminx/modules/Catalog/language/en/interface.xml": "core", + "adminx/modules/Catalog/language/en/runtime.xml": "core", + "adminx/modules/Catalog/language/ru/client.xml": "core", + "adminx/modules/Catalog/language/ru/interface.xml": "core", + "adminx/modules/Catalog/language/ru/runtime.xml": "core", + "adminx/modules/Catalog/migrations/005_add_catalog_purpose.sql": "core", + "adminx/modules/Catalog/module.php": "core", + "adminx/modules/Catalog/view/edit.twig": "core", + "adminx/modules/Catalog/view/index.twig": "core", + "adminx/modules/Console/Controller.php": "core", + "adminx/modules/Console/Model.php": "core", + "adminx/modules/Console/Runner.php": "core", + "adminx/modules/Console/assets/console.css": "core", + "adminx/modules/Console/assets/console.js": "core", + "adminx/modules/Console/language/en/client.xml": "core", + "adminx/modules/Console/language/en/interface.xml": "core", + "adminx/modules/Console/language/en/runtime.xml": "core", + "adminx/modules/Console/language/ru/client.xml": "core", + "adminx/modules/Console/language/ru/interface.xml": "core", + "adminx/modules/Console/language/ru/runtime.xml": "core", + "adminx/modules/Console/migrations/001_create_console_snippets.sql": "core", + "adminx/modules/Console/module.php": "core", + "adminx/modules/Console/view/index.twig": "core", + "adminx/modules/Customers/Controller.php": "core", + "adminx/modules/Customers/Model.php": "core", + "adminx/modules/Customers/assets/customers.css": "core", + "adminx/modules/Customers/assets/customers.js": "core", + "adminx/modules/Customers/language/en/client.xml": "core", + "adminx/modules/Customers/language/en/interface.xml": "core", + "adminx/modules/Customers/language/en/runtime.xml": "core", + "adminx/modules/Customers/language/ru/client.xml": "core", + "adminx/modules/Customers/language/ru/interface.xml": "core", + "adminx/modules/Customers/language/ru/runtime.xml": "core", + "adminx/modules/Customers/migrations/001_normalize_public_groups.sql": "core", + "adminx/modules/Customers/migrations/002_public_auth_schema.sql": "core", + "adminx/modules/Customers/migrations/003_normalize_last_visit.sql": "core", + "adminx/modules/Customers/migrations/004_materialize_public_auth_schema.sql": "core", + "adminx/modules/Customers/migrations/005_checkout_registration.sql": "core", + "adminx/modules/Customers/migrations/006_phone_identity.php": "core", + "adminx/modules/Customers/module.php": "core", + "adminx/modules/Customers/view/index.twig": "core", + "adminx/modules/Dashboard/Controller.php": "core", + "adminx/modules/Dashboard/Widgets.php": "core", + "adminx/modules/Dashboard/assets/dashboard.css": "core", + "adminx/modules/Dashboard/assets/dashboard.js": "core", + "adminx/modules/Dashboard/language/en/common.xml": "core", + "adminx/modules/Dashboard/language/en/interface.xml": "core", + "adminx/modules/Dashboard/language/en/runtime.xml": "core", + "adminx/modules/Dashboard/language/ru/common.xml": "core", + "adminx/modules/Dashboard/language/ru/interface.xml": "core", + "adminx/modules/Dashboard/language/ru/runtime.xml": "core", + "adminx/modules/Dashboard/module.php": "core", + "adminx/modules/Dashboard/view/index.twig": "core", + "adminx/modules/Dashboard/view/widgets/recent-documents.twig": "core", + "adminx/modules/Dashboard/view/widgets/shortcuts.twig": "core", + "adminx/modules/Dashboard/view/widgets/system-kpis.twig": "core", + "adminx/modules/Database/Backup.php": "core", + "adminx/modules/Database/BackupRestore.php": "core", + "adminx/modules/Database/Controller.php": "core", + "adminx/modules/Database/Model.php": "core", + "adminx/modules/Database/SqlDumpReader.php": "core", + "adminx/modules/Database/assets/database.css": "core", + "adminx/modules/Database/assets/database.js": "core", + "adminx/modules/Database/language/en/client.xml": "core", + "adminx/modules/Database/language/en/interface.xml": "core", + "adminx/modules/Database/language/en/runtime.xml": "core", + "adminx/modules/Database/language/ru/client.xml": "core", + "adminx/modules/Database/language/ru/interface.xml": "core", + "adminx/modules/Database/language/ru/runtime.xml": "core", + "adminx/modules/Database/migrations/001_mysql_legacy_index_compatibility.php": "core", + "adminx/modules/Database/migrations/002_module_lifecycle_recovery.php": "core", + "adminx/modules/Database/migrations/003_document_fields_text_composite_index.php": "core", + "adminx/modules/Database/module.php": "core", + "adminx/modules/Database/view/index.twig": "core", + "adminx/modules/Documents/Controller.php": "core", + "adminx/modules/Documents/DocumentHookRunner.php": "core", + "adminx/modules/Documents/EditConflict.php": "core", + "adminx/modules/Documents/Model.php": "core", + "adminx/modules/Documents/Revisions.php": "core", + "adminx/modules/Documents/assets/documents.css": "core", + "adminx/modules/Documents/assets/documents.js": "core", + "adminx/modules/Documents/assets/redirects.js": "core", + "adminx/modules/Documents/language/en/client.xml": "core", + "adminx/modules/Documents/language/en/interface.xml": "core", + "adminx/modules/Documents/language/en/runtime.xml": "core", + "adminx/modules/Documents/language/ru/client.xml": "core", + "adminx/modules/Documents/language/ru/interface.xml": "core", + "adminx/modules/Documents/language/ru/runtime.xml": "core", + "adminx/modules/Documents/migrations/001_correct_breadcrumb_title.sql": "core", + "adminx/modules/Documents/migrations/002_correct_legacy_breadcrumb_title.sql": "core", + "adminx/modules/Documents/migrations/003_create_document_api_tokens.sql": "core", + "adminx/modules/Documents/migrations/004_optimize_public_document_indexes.sql": "core", + "adminx/modules/Documents/migrations/005_rename_document_teaser_to_excerpt.sql": "core", + "adminx/modules/Documents/migrations/006_configure_product_media_paths.php": "core", + "adminx/modules/Documents/migrations/007_index_document_short_alias.sql": "core", + "adminx/modules/Documents/migrations/008_seed_clean_not_found_document.php": "core", + "adminx/modules/Documents/migrations/009_deduplicate_document_short_aliases.php": "core", + "adminx/modules/Documents/migrations/010_reset_dangling_rubric_templates.sql": "core", + "adminx/modules/Documents/migrations/011_normalize_document_enum_values.sql": "core", + "adminx/modules/Documents/migrations/012_default_legacy_language_columns.php": "core", + "adminx/modules/Documents/migrations/013_document_edit_version.php": "core", + "adminx/modules/Documents/migrations/014_document_relation_edges.sql": "core", + "adminx/modules/Documents/migrations/015_document_creation_presets.sql": "core", + "adminx/modules/Documents/migrations/016_reconcile_document_content_columns.php": "core", + "adminx/modules/Documents/module.php": "core", + "adminx/modules/Documents/view/api.twig": "core", + "adminx/modules/Documents/view/edit.twig": "core", + "adminx/modules/Documents/view/index.twig": "core", + "adminx/modules/Documents/view/redirects.twig": "core", + "adminx/modules/Documents/view/views.twig": "core", + "adminx/modules/Events/Controller.php": "core", + "adminx/modules/Events/Model.php": "core", + "adminx/modules/Events/assets/events.css": "core", + "adminx/modules/Events/assets/events.js": "core", + "adminx/modules/Events/language/en/client.xml": "core", + "adminx/modules/Events/language/en/interface.xml": "core", + "adminx/modules/Events/language/en/runtime.xml": "core", + "adminx/modules/Events/language/ru/client.xml": "core", + "adminx/modules/Events/language/ru/interface.xml": "core", + "adminx/modules/Events/language/ru/runtime.xml": "core", + "adminx/modules/Events/migrations/001_materialize_event_logs.sql": "core", + "adminx/modules/Events/module.php": "core", + "adminx/modules/Events/view/index.twig": "core", + "adminx/modules/Groups/Controller.php": "core", + "adminx/modules/Groups/Model.php": "core", + "adminx/modules/Groups/Setup.php": "core", + "adminx/modules/Groups/assets/groups.css": "core", + "adminx/modules/Groups/assets/groups.js": "core", + "adminx/modules/Groups/language/en/client.xml": "core", + "adminx/modules/Groups/language/en/interface.xml": "core", + "adminx/modules/Groups/language/en/runtime.xml": "core", + "adminx/modules/Groups/language/ru/client.xml": "core", + "adminx/modules/Groups/language/ru/interface.xml": "core", + "adminx/modules/Groups/language/ru/runtime.xml": "core", + "adminx/modules/Groups/migrations/001_register_public_debug_permission.sql": "core", + "adminx/modules/Groups/migrations/002_remove_observer_role.sql": "core", + "adminx/modules/Groups/migrations/003_seed_default_roles.sql": "core", + "adminx/modules/Groups/migrations/004_merge_legacy_roles.sql": "core", + "adminx/modules/Groups/migrations/005_register_development_site_permission.sql": "core", + "adminx/modules/Groups/module.php": "core", + "adminx/modules/Groups/view/list.twig": "core", + "adminx/modules/Media/Controller.php": "core", + "adminx/modules/Media/Model.php": "core", + "adminx/modules/Media/assets/media.css": "core", + "adminx/modules/Media/assets/media.js": "core", + "adminx/modules/Media/language/en/client.xml": "core", + "adminx/modules/Media/language/en/interface.xml": "core", + "adminx/modules/Media/language/en/runtime.xml": "core", + "adminx/modules/Media/language/ru/client.xml": "core", + "adminx/modules/Media/language/ru/interface.xml": "core", + "adminx/modules/Media/language/ru/runtime.xml": "core", + "adminx/modules/Media/module.php": "core", + "adminx/modules/Media/view/file.twig": "core", + "adminx/modules/Media/view/index.twig": "core", + "adminx/modules/Modules/Controller.php": "core", + "adminx/modules/Modules/ModuleArchiveInstaller.php": "core", + "adminx/modules/Modules/ModuleRepository.php": "core", + "adminx/modules/Modules/NativeModuleAdapter.php": "core", + "adminx/modules/Modules/assets/modules.css": "core", + "adminx/modules/Modules/assets/modules.js": "core", + "adminx/modules/Modules/language/en/client.xml": "core", + "adminx/modules/Modules/language/en/interface.xml": "core", + "adminx/modules/Modules/language/en/runtime.xml": "core", + "adminx/modules/Modules/language/ru/client.xml": "core", + "adminx/modules/Modules/language/ru/interface.xml": "core", + "adminx/modules/Modules/language/ru/runtime.xml": "core", + "adminx/modules/Modules/module.php": "core", + "adminx/modules/Modules/view/index.twig": "core", + "adminx/modules/Navigation/Controller.php": "core", + "adminx/modules/Navigation/Model.php": "core", + "adminx/modules/Navigation/assets/navigation.css": "core", + "adminx/modules/Navigation/assets/navigation.js": "core", + "adminx/modules/Navigation/language/en/client.xml": "core", + "adminx/modules/Navigation/language/en/interface.xml": "core", + "adminx/modules/Navigation/language/en/runtime.xml": "core", + "adminx/modules/Navigation/language/ru/client.xml": "core", + "adminx/modules/Navigation/language/ru/interface.xml": "core", + "adminx/modules/Navigation/language/ru/runtime.xml": "core", + "adminx/modules/Navigation/migrations/001_create_navigation_tables.sql": "core", + "adminx/modules/Navigation/module.php": "core", + "adminx/modules/Navigation/view/index.twig": "core", + "adminx/modules/Registrations/Controller.php": "core", + "adminx/modules/Registrations/Model.php": "core", + "adminx/modules/Registrations/Schema.php": "core", + "adminx/modules/Registrations/assets/registrations.css": "core", + "adminx/modules/Registrations/assets/registrations.js": "core", + "adminx/modules/Registrations/language/en/client.xml": "core", + "adminx/modules/Registrations/language/en/interface.xml": "core", + "adminx/modules/Registrations/language/en/runtime.xml": "core", + "adminx/modules/Registrations/language/ru/client.xml": "core", + "adminx/modules/Registrations/language/ru/interface.xml": "core", + "adminx/modules/Registrations/language/ru/runtime.xml": "core", + "adminx/modules/Registrations/migrations/001_create_registration_certificates.sql": "core", + "adminx/modules/Registrations/migrations/002_register_registration_permissions.sql": "core", + "adminx/modules/Registrations/migrations/uninstall.sql": "core", + "adminx/modules/Registrations/module.php": "core", + "adminx/modules/Registrations/view/edit.twig": "core", + "adminx/modules/Registrations/view/index.twig": "core", + "adminx/modules/Requests/Controller.php": "core", + "adminx/modules/Requests/Model.php": "core", + "adminx/modules/Requests/assets/requests.css": "core", + "adminx/modules/Requests/assets/requests.js": "core", + "adminx/modules/Requests/language/en/client.xml": "core", + "adminx/modules/Requests/language/en/interface.xml": "core", + "adminx/modules/Requests/language/en/runtime.xml": "core", + "adminx/modules/Requests/language/ru/client.xml": "core", + "adminx/modules/Requests/language/ru/interface.xml": "core", + "adminx/modules/Requests/language/ru/runtime.xml": "core", + "adminx/modules/Requests/migrations/001_native_database_calls.sql": "core", + "adminx/modules/Requests/migrations/002_correct_native_database_escape.sql": "core", + "adminx/modules/Requests/migrations/003_condition_groups.sql": "core", + "adminx/modules/Requests/migrations/004_recompile_condition_groups.sql": "core", + "adminx/modules/Requests/migrations/005_rebuild_native_condition_cache.sql": "core", + "adminx/modules/Requests/migrations/006_sargable_condition_cache.sql": "core", + "adminx/modules/Requests/migrations/007_result_contract.php": "core", + "adminx/modules/Requests/migrations/008_preview_renderer.php": "core", + "adminx/modules/Requests/migrations/009_native_executor.php": "core", + "adminx/modules/Requests/migrations/010_native_audit_result.php": "core", + "adminx/modules/Requests/migrations/011_order_tiebreaker.php": "core", + "adminx/modules/Requests/migrations/012_condition_value_sources.php": "core", + "adminx/modules/Requests/migrations/013_sort_rules.php": "core", + "adminx/modules/Requests/module.php": "core", + "adminx/modules/Requests/view/edit.twig": "core", + "adminx/modules/Requests/view/index.twig": "core", + "adminx/modules/Requests/view/native-audit.twig": "core", + "adminx/modules/Rubrics/AdminView.php": "core", + "adminx/modules/Rubrics/Controller.php": "core", + "adminx/modules/Rubrics/FieldAdapter.php": "core", + "adminx/modules/Rubrics/FieldAdminEditors.php": "core", + "adminx/modules/Rubrics/FieldConditionImpact.php": "core", + "adminx/modules/Rubrics/FieldEditors/AbstractFieldEditor.php": "core", + "adminx/modules/Rubrics/FieldEditors/ChoiceFieldEditor.php": "core", + "adminx/modules/Rubrics/FieldEditors/FieldEditorInterface.php": "core", + "adminx/modules/Rubrics/FieldEditors/ListFieldEditor.php": "core", + "adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php": "core", + "adminx/modules/Rubrics/FieldEditors/RelationFieldEditor.php": "core", + "adminx/modules/Rubrics/FieldEditors/SimpleFieldEditor.php": "core", + "adminx/modules/Rubrics/FieldTypes.php": "core", + "adminx/modules/Rubrics/Model.php": "core", + "adminx/modules/Rubrics/RubricFieldPresets.php": "core", + "adminx/modules/Rubrics/RubricFieldSetLinks.php": "core", + "adminx/modules/Rubrics/RubricRevisions.php": "core", + "adminx/modules/Rubrics/RubricSchemaImpact.php": "core", + "adminx/modules/Rubrics/assets/rubrics.css": "core", + "adminx/modules/Rubrics/assets/rubrics.js": "core", + "adminx/modules/Rubrics/language/en/interface.xml": "core", + "adminx/modules/Rubrics/language/en/runtime.xml": "core", + "adminx/modules/Rubrics/language/ru/interface.xml": "core", + "adminx/modules/Rubrics/language/ru/runtime.xml": "core", + "adminx/modules/Rubrics/migrations/001_create_rubrics_tables.sql": "core", + "adminx/modules/Rubrics/migrations/002_rubric_field_settings.sql": "core", + "adminx/modules/Rubrics/migrations/003_native_public_document_context.sql": "core", + "adminx/modules/Rubrics/migrations/004_native_database_calls.sql": "core", + "adminx/modules/Rubrics/migrations/005_correct_native_database_escape.sql": "core", + "adminx/modules/Rubrics/migrations/006_admin_document_views.sql": "core", + "adminx/modules/Rubrics/migrations/007_rubric_open_graph.sql": "core", + "adminx/modules/Rubrics/migrations/008_normalize_field_layout_width.sql": "core", + "adminx/modules/Rubrics/migrations/009_expand_clean_starter_rubric.php": "core", + "adminx/modules/Rubrics/migrations/010_form_conditions.php": "core", + "adminx/modules/Rubrics/migrations/011_schema_revisions.sql": "core", + "adminx/modules/Rubrics/migrations/012_linked_field_sets.sql": "core", + "adminx/modules/Rubrics/migrations/013_group_form_conditions.php": "core", + "adminx/modules/Rubrics/module.php": "core", + "adminx/modules/Rubrics/view/index.twig": "core", + "adminx/modules/Security/Controller.php": "core", + "adminx/modules/Security/assets/security.css": "core", + "adminx/modules/Security/assets/security.js": "core", + "adminx/modules/Security/language/en/client.xml": "core", + "adminx/modules/Security/language/en/interface.xml": "core", + "adminx/modules/Security/language/en/runtime.xml": "core", + "adminx/modules/Security/language/ru/client.xml": "core", + "adminx/modules/Security/language/ru/interface.xml": "core", + "adminx/modules/Security/language/ru/runtime.xml": "core", + "adminx/modules/Security/migrations/001_create_ip_blocks.sql": "core", + "adminx/modules/Security/module.php": "core", + "adminx/modules/Security/view/index.twig": "core", + "adminx/modules/Settings/Constants.php": "core", + "adminx/modules/Settings/Controller.php": "core", + "adminx/modules/Settings/Model.php": "core", + "adminx/modules/Settings/ProductionDiagnostics.php": "core", + "adminx/modules/Settings/Schema.php": "core", + "adminx/modules/Settings/assets/settings.css": "core", + "adminx/modules/Settings/assets/settings.js": "core", + "adminx/modules/Settings/language/en/client.xml": "core", + "adminx/modules/Settings/language/en/interface.xml": "core", + "adminx/modules/Settings/language/en/runtime.xml": "core", + "adminx/modules/Settings/language/ru/client.xml": "core", + "adminx/modules/Settings/language/ru/interface.xml": "core", + "adminx/modules/Settings/language/ru/runtime.xml": "core", + "adminx/modules/Settings/migrations/001_create_settings_tables.sql": "core", + "adminx/modules/Settings/migrations/002_normalize_breadcrumb_separator.sql": "core", + "adminx/modules/Settings/migrations/003_remove_unused_constants.sql": "core", + "adminx/modules/Settings/migrations/004_seed_core_display_defaults.sql": "core", + "adminx/modules/Settings/migrations/005_order_system_navigation.php": "core", + "adminx/modules/Settings/migrations/006_create_admin_saved_views.sql": "core", + "adminx/modules/Settings/module.php": "core", + "adminx/modules/Settings/view/index.twig": "core", + "adminx/modules/Templates/Controller.php": "core", + "adminx/modules/Templates/Model.php": "core", + "adminx/modules/Templates/Revisions.php": "core", + "adminx/modules/Templates/Syntax.php": "core", + "adminx/modules/Templates/TagRegistry.php": "core", + "adminx/modules/Templates/assets/templates.css": "core", + "adminx/modules/Templates/assets/templates.js": "core", + "adminx/modules/Templates/language/en/client.xml": "core", + "adminx/modules/Templates/language/en/interface.xml": "core", + "adminx/modules/Templates/language/en/runtime.xml": "core", + "adminx/modules/Templates/language/ru/client.xml": "core", + "adminx/modules/Templates/language/ru/interface.xml": "core", + "adminx/modules/Templates/language/ru/runtime.xml": "core", + "adminx/modules/Templates/migrations/001_create_templates_tables.sql": "core", + "adminx/modules/Templates/migrations/002_create_template_revisions.sql": "core", + "adminx/modules/Templates/module.php": "core", + "adminx/modules/Templates/view/index.twig": "core", + "adminx/modules/Themes/Controller.php": "core", + "adminx/modules/Themes/Model.php": "core", + "adminx/modules/Themes/Revisions.php": "core", + "adminx/modules/Themes/assets/themes.css": "core", + "adminx/modules/Themes/assets/themes.js": "core", + "adminx/modules/Themes/language/en/client.xml": "core", + "adminx/modules/Themes/language/en/interface.xml": "core", + "adminx/modules/Themes/language/en/runtime.xml": "core", + "adminx/modules/Themes/language/ru/client.xml": "core", + "adminx/modules/Themes/language/ru/interface.xml": "core", + "adminx/modules/Themes/language/ru/runtime.xml": "core", + "adminx/modules/Themes/migrations/001_create_theme_asset_revisions.sql": "core", + "adminx/modules/Themes/migrations/002_place_theme_navigation.php": "core", + "adminx/modules/Themes/module.php": "core", + "adminx/modules/Themes/view/index.twig": "core", + "adminx/modules/Updates/Controller.php": "core", + "adminx/modules/Updates/assets/updates.css": "core", + "adminx/modules/Updates/assets/updates.js": "core", + "adminx/modules/Updates/language/en/client.xml": "core", + "adminx/modules/Updates/language/en/interface.xml": "core", + "adminx/modules/Updates/language/en/runtime.xml": "core", + "adminx/modules/Updates/language/ru/client.xml": "core", + "adminx/modules/Updates/language/ru/interface.xml": "core", + "adminx/modules/Updates/language/ru/runtime.xml": "core", + "adminx/modules/Updates/module.php": "core", + "adminx/modules/Updates/view/index.twig": "core", + "adminx/modules/Users/Controller.php": "core", + "adminx/modules/Users/Model.php": "core", + "adminx/modules/Users/assets/users.css": "core", + "adminx/modules/Users/assets/users.js": "core", + "adminx/modules/Users/language/en/client.xml": "core", + "adminx/modules/Users/language/en/interface.xml": "core", + "adminx/modules/Users/language/en/runtime.xml": "core", + "adminx/modules/Users/language/ru/client.xml": "core", + "adminx/modules/Users/language/ru/interface.xml": "core", + "adminx/modules/Users/language/ru/runtime.xml": "core", + "adminx/modules/Users/migrations/001_normalize_public_session_activity.sql": "core", + "adminx/modules/Users/migrations/002_harden_public_remember_tokens.php": "core", + "adminx/modules/Users/module.php": "core", + "adminx/modules/Users/view/list.twig": "core", + "adminx/view/404.twig": "core", + "adminx/view/_pagination.twig": "core", + "adminx/view/_saved_views.twig": "core", + "adminx/view/auth.twig": "core", + "adminx/view/error.twig": "core", + "adminx/view/main.twig": "core", + "changelog.txt": "core", + "configs/db.config.example.php": "core", + "configs/index.php": "core", + "configs/public.config.php": "core", + "help/README.md": "core", + "help/administration/README.md": "core", + "help/administration/console.md": "core", + "help/administration/dashboard.md": "core", + "help/administration/operations.md": "core", + "help/administration/settings.md": "core", + "help/administration/updates.md": "core", + "help/administration/users.md": "core", + "help/api/README.md": "core", + "help/api/authentication.md": "core", + "help/api/documents.md": "core", + "help/api/errors.md": "core", + "help/content/README.md": "core", + "help/content/blocks.md": "core", + "help/content/catalog.md": "core", + "help/content/content-studio.md": "core", + "help/content/documents.md": "core", + "help/content/navigation.md": "core", + "help/content/requests.md": "core", + "help/content/rubrics.md": "core", + "help/content/templates.md": "core", + "help/content/themes.md": "core", + "help/content/twig-components.md": "core", + "help/core/README.md": "core", + "help/core/auth.md": "core", + "help/core/bootstrap.md": "core", + "help/core/controllers.md": "core", + "help/core/permissions.md": "core", + "help/core/routing.md": "core", + "help/core/sessions-csrf.md": "core", + "help/database/README.md": "core", + "help/database/cache.md": "core", + "help/database/crud.md": "core", + "help/database/placeholders.md": "core", + "help/database/queries.md": "core", + "help/database/results.md": "core", + "help/database/security.md": "core", + "help/database/tables.md": "core", + "help/database/transactions.md": "core", + "help/debug/README.md": "core", + "help/debug/custom-data.md": "core", + "help/en/README.md": "core", + "help/en/administration/README.md": "core", + "help/en/administration/console.md": "core", + "help/en/administration/dashboard.md": "core", + "help/en/administration/operations.md": "core", + "help/en/administration/settings.md": "core", + "help/en/administration/updates.md": "core", + "help/en/administration/users.md": "core", + "help/en/api/README.md": "core", + "help/en/api/authentication.md": "core", + "help/en/api/documents.md": "core", + "help/en/api/errors.md": "core", + "help/en/content/README.md": "core", + "help/en/content/blocks.md": "core", + "help/en/content/catalog.md": "core", + "help/en/content/content-studio.md": "core", + "help/en/content/documents.md": "core", + "help/en/content/navigation.md": "core", + "help/en/content/requests.md": "core", + "help/en/content/rubrics.md": "core", + "help/en/content/templates.md": "core", + "help/en/content/themes.md": "core", + "help/en/content/twig-components.md": "core", + "help/en/core/README.md": "core", + "help/en/core/auth.md": "core", + "help/en/core/bootstrap.md": "core", + "help/en/core/controllers.md": "core", + "help/en/core/permissions.md": "core", + "help/en/core/routing.md": "core", + "help/en/core/sessions-csrf.md": "core", + "help/en/database/README.md": "core", + "help/en/database/cache.md": "core", + "help/en/database/crud.md": "core", + "help/en/database/placeholders.md": "core", + "help/en/database/queries.md": "core", + "help/en/database/results.md": "core", + "help/en/database/security.md": "core", + "help/en/database/tables.md": "core", + "help/en/database/transactions.md": "core", + "help/en/debug/README.md": "core", + "help/en/debug/custom-data.md": "core", + "help/en/fields/README.md": "core", + "help/en/fields/development.md": "core", + "help/en/fields/operations.md": "core", + "help/en/fields/settings.md": "core", + "help/en/fields/storage.md": "core", + "help/en/fields/templates.md": "core", + "help/en/fields/types.md": "core", + "help/en/helpers/Arr.md": "core", + "help/en/helpers/Cookie.md": "core", + "help/en/helpers/Crypt.md": "core", + "help/en/helpers/Date.md": "core", + "help/en/helpers/Debug.md": "core", + "help/en/helpers/Dir.md": "core", + "help/en/helpers/File.md": "core", + "help/en/helpers/Hooks.md": "core", + "help/en/helpers/Html.md": "core", + "help/en/helpers/Ip.md": "core", + "help/en/helpers/Json.md": "core", + "help/en/helpers/Locales.md": "core", + "help/en/helpers/MarketParser.md": "core", + "help/en/helpers/Number.md": "core", + "help/en/helpers/Phone.md": "core", + "help/en/helpers/README.md": "core", + "help/en/helpers/Request.md": "core", + "help/en/helpers/Response.md": "core", + "help/en/helpers/RussianStemmer.md": "core", + "help/en/helpers/SearchText.md": "core", + "help/en/helpers/Secure.md": "core", + "help/en/helpers/SoftDelete.md": "core", + "help/en/helpers/Str.md": "core", + "help/en/helpers/TagParser.md": "core", + "help/en/helpers/Url.md": "core", + "help/en/helpers/Valid.md": "core", + "help/en/helpers/Zip.md": "core", + "help/en/hooks/README.md": "core", + "help/en/hooks/catalog.md": "core", + "help/en/hooks/debugging.md": "core", + "help/en/hooks/documents.md": "core", + "help/en/hooks/fields.md": "core", + "help/en/hooks/lifecycle.md": "core", + "help/en/installation/README.md": "core", + "help/en/installation/admin-directory.md": "core", + "help/en/media/README.md": "core", + "help/en/modules/README.md": "core", + "help/en/modules/antispam.md": "core", + "help/en/modules/banners.md": "core", + "help/en/modules/benchmark.md": "core", + "help/en/modules/comments.md": "core", + "help/en/modules/commerce.md": "core", + "help/en/modules/commerceml.md": "core", + "help/en/modules/contacts.md": "core", + "help/en/modules/content-packages.md": "core", + "help/en/modules/contributions.md": "core", + "help/en/modules/dashboard-widgets.md": "core", + "help/en/modules/demo-site.md": "core", + "help/en/modules/document-import.md": "core", + "help/en/modules/experiments.md": "core", + "help/en/modules/faq.md": "core", + "help/en/modules/file-security.md": "core", + "help/en/modules/files.md": "core", + "help/en/modules/galleries.md": "core", + "help/en/modules/header-actions.md": "core", + "help/en/modules/help-viewer.md": "core", + "help/en/modules/interactions.md": "core", + "help/en/modules/legacy-migration.md": "core", + "help/en/modules/localization.md": "core", + "help/en/modules/migrations.md": "core", + "help/en/modules/module-php.md": "core", + "help/en/modules/not-found.md": "core", + "help/en/modules/notifications.md": "core", + "help/en/modules/oauth-providers.md": "core", + "help/en/modules/operations.md": "core", + "help/en/modules/packages.md": "core", + "help/en/modules/personal-tools.md": "core", + "help/en/modules/polls.md": "core", + "help/en/modules/popups.md": "core", + "help/en/modules/ratings.md": "core", + "help/en/modules/related.md": "core", + "help/en/modules/repository.md": "core", + "help/en/modules/reviews.md": "core", + "help/en/modules/rss.md": "core", + "help/en/modules/scheduler.md": "core", + "help/en/modules/search-analytics.md": "core", + "help/en/modules/search.md": "core", + "help/en/modules/seo-audit.md": "core", + "help/en/modules/site-readiness.md": "core", + "help/en/modules/smsc-auth.md": "core", + "help/en/modules/system-health.md": "core", + "help/en/modules/traffic-analytics.md": "core", + "help/en/modules/tutorial.md": "core", + "help/en/security/README.md": "core", + "help/fields/README.md": "core", + "help/fields/development.md": "core", + "help/fields/operations.md": "core", + "help/fields/settings.md": "core", + "help/fields/storage.md": "core", + "help/fields/templates.md": "core", + "help/fields/types.md": "core", + "help/helpers/Arr.md": "core", + "help/helpers/Cookie.md": "core", + "help/helpers/Crypt.md": "core", + "help/helpers/Date.md": "core", + "help/helpers/Debug.md": "core", + "help/helpers/Dir.md": "core", + "help/helpers/File.md": "core", + "help/helpers/Hooks.md": "core", + "help/helpers/Html.md": "core", + "help/helpers/Ip.md": "core", + "help/helpers/Json.md": "core", + "help/helpers/Locales.md": "core", + "help/helpers/MarketParser.md": "core", + "help/helpers/Number.md": "core", + "help/helpers/Phone.md": "core", + "help/helpers/README.md": "core", + "help/helpers/Request.md": "core", + "help/helpers/Response.md": "core", + "help/helpers/RussianStemmer.md": "core", + "help/helpers/SearchText.md": "core", + "help/helpers/Secure.md": "core", + "help/helpers/SoftDelete.md": "core", + "help/helpers/Str.md": "core", + "help/helpers/TagParser.md": "core", + "help/helpers/Url.md": "core", + "help/helpers/Valid.md": "core", + "help/helpers/Zip.md": "core", + "help/hooks/README.md": "core", + "help/hooks/catalog.md": "core", + "help/hooks/debugging.md": "core", + "help/hooks/documents.md": "core", + "help/hooks/fields.md": "core", + "help/hooks/lifecycle.md": "core", + "help/installation/README.md": "core", + "help/installation/admin-directory.md": "core", + "help/media/README.md": "core", + "help/modules/README.md": "core", + "help/modules/antispam.md": "core", + "help/modules/banners.md": "core", + "help/modules/benchmark.md": "core", + "help/modules/comments.md": "core", + "help/modules/commerce.md": "core", + "help/modules/commerceml.md": "core", + "help/modules/contacts.md": "core", + "help/modules/content-packages.md": "core", + "help/modules/contributions.md": "core", + "help/modules/dashboard-widgets.md": "core", + "help/modules/demo-site.md": "core", + "help/modules/document-import.md": "core", + "help/modules/experiments.md": "core", + "help/modules/faq.md": "core", + "help/modules/file-security.md": "core", + "help/modules/files.md": "core", + "help/modules/galleries.md": "core", + "help/modules/header-actions.md": "core", + "help/modules/help-viewer.md": "core", + "help/modules/interactions.md": "core", + "help/modules/legacy-migration.md": "core", + "help/modules/localization.md": "core", + "help/modules/migrations.md": "core", + "help/modules/module-php.md": "core", + "help/modules/not-found.md": "core", + "help/modules/notifications.md": "core", + "help/modules/oauth-providers.md": "core", + "help/modules/operations.md": "core", + "help/modules/packages.md": "core", + "help/modules/personal-tools.md": "core", + "help/modules/polls.md": "core", + "help/modules/popups.md": "core", + "help/modules/ratings.md": "core", + "help/modules/related.md": "core", + "help/modules/repository.md": "core", + "help/modules/reviews.md": "core", + "help/modules/rss.md": "core", + "help/modules/scheduler.md": "core", + "help/modules/search-analytics.md": "core", + "help/modules/search.md": "core", + "help/modules/seo-audit.md": "core", + "help/modules/site-readiness.md": "core", + "help/modules/smsc-auth.md": "core", + "help/modules/system-health.md": "core", + "help/modules/traffic-analytics.md": "core", + "help/modules/tutorial.md": "core", + "help/security/README.md": "core", + "index.php": "core", + "license.txt": "core", + "recovery.php": "core", + "setup/.htaccess": "core", + "setup/Installer.php": "core", + "setup/Localization.php": "core", + "setup/PackageExtractor.php": "core", + "setup/assets/installer.css": "core", + "setup/assets/installer.js": "core", + "setup/core-migrations.php": "core", + "setup/index.php": "core", + "setup/language/en.php": "core", + "setup/language/ru.php": "core", + "setup/recovery.php": "core", + "setup/schema.sql": "core", + "storage/.htaccess": "core", + "system/App/Common/AdminAssets.php": "core", + "system/App/Common/AdminLocation.php": "core", + "system/App/Common/ApiTokenRepository.php": "core", + "system/App/Common/AuditLog.php": "core", + "system/App/Common/Auth.php": "core", + "system/App/Common/Auth/IdentityLinker.php": "core", + "system/App/Common/Auth/PublicUserNames.php": "core", + "system/App/Common/Auth/PublicUserProvider.php": "core", + "system/App/Common/Cache.php": "core", + "system/App/Common/CacheKey.php": "core", + "system/App/Common/CompiledModuleRegistry.php": "core", + "system/App/Common/Controller.php": "core", + "system/App/Common/CoreUpdate/DatabaseBackup.php": "core", + "system/App/Common/CoreUpdate/PatchPackage.php": "core", + "system/App/Common/CoreUpdate/ReleaseState.php": "core", + "system/App/Common/CoreUpdate/UpdateJob.php": "core", + "system/App/Common/CoreUpdate/UpdateRepository.php": "core", + "system/App/Common/CsvLogWriter.php": "core", + "system/App/Common/DatabaseConfiguration.php": "core", + "system/App/Common/DatabaseSchema.php": "core", + "system/App/Common/Db/Core/ConnectionManager.php": "core", + "system/App/Common/Db/Core/ErrorHandler.php": "core", + "system/App/Common/Db/Core/MysqliDriver.php": "core", + "system/App/Common/Db/Core/QueryBuilder.php": "core", + "system/App/Common/Db/Core/QueryCache.php": "core", + "system/App/Common/Db/Core/QueryDebug.php": "core", + "system/App/Common/Db/Core/QueryExecutor.php": "core", + "system/App/Common/Db/Core/QueryLogger.php": "core", + "system/App/Common/Db/Core/TransactionManager.php": "core", + "system/App/Common/Db/DB.php": "core", + "system/App/Common/Db/DB_Eval.php": "core", + "system/App/Common/Db/DB_Exception.php": "core", + "system/App/Common/Db/DB_Result.php": "core", + "system/App/Common/Db/DB_Where.php": "core", + "system/App/Common/DebugChannel.php": "core", + "system/App/Common/Dispatcher.php": "core", + "system/App/Common/DistributionProfile.php": "core", + "system/App/Common/ErrorReport.php": "core", + "system/App/Common/Errors.php": "core", + "system/App/Common/Exceptions.php": "core", + "system/App/Common/FileCacheInvalidator.php": "core", + "system/App/Common/Flash.php": "core", + "system/App/Common/HookCatalog.php": "core", + "system/App/Common/IpBlocker.php": "core", + "system/App/Common/Language.php": "core", + "system/App/Common/Lifecycle.php": "core", + "system/App/Common/LifecycleEvent.php": "core", + "system/App/Common/Loader/LOAD.md": "core", + "system/App/Common/Loader/Load.php": "core", + "system/App/Common/Lock.php": "core", + "system/App/Common/LoginThrottle.php": "core", + "system/App/Common/Logs.php": "core", + "system/App/Common/ModuleLifecyclePreflight.php": "core", + "system/App/Common/ModuleManager.php": "core", + "system/App/Common/ModuleMigrator.php": "core", + "system/App/Common/ModuleSecretStore.php": "core", + "system/App/Common/ModuleSettings.php": "core", + "system/App/Common/ModuleTagRegistry.php": "core", + "system/App/Common/Navigation.php": "core", + "system/App/Common/NotFoundLog.php": "core", + "system/App/Common/OutboundHttpClient.php": "core", + "system/App/Common/PackageModuleRuntime.php": "core", + "system/App/Common/Permission.php": "core", + "system/App/Common/Proxy.php": "core", + "system/App/Common/PublicAsset.php": "core", + "system/App/Common/PublicAuthSettings.php": "core", + "system/App/Common/PublicBootstrap.php": "core", + "system/App/Common/PublicConfiguration.php": "core", + "system/App/Common/PublicModuleRuntime.php": "core", + "system/App/Common/PublicRouteRegistry.php": "core", + "system/App/Common/PublicSysblockRegistry.php": "core", + "system/App/Common/PublicTagRegistry.php": "core", + "system/App/Common/RateLimiter.php": "core", + "system/App/Common/Ref.php": "core", + "system/App/Common/ReferrerLog.php": "core", + "system/App/Common/Registry.php": "core", + "system/App/Common/ResponseSecurityHeaders.php": "core", + "system/App/Common/Router.php": "core", + "system/App/Common/RuntimeConstantSchema.php": "core", + "system/App/Common/RuntimeConstants.php": "core", + "system/App/Common/RuntimeDirectoryGuard.php": "core", + "system/App/Common/Session.php": "core", + "system/App/Common/Session/SessionDB.php": "core", + "system/App/Common/Session/SessionFiles.php": "core", + "system/App/Common/Session/SessionNative.php": "core", + "system/App/Common/Settings.php": "core", + "system/App/Common/SettingsRegistry.php": "core", + "system/App/Common/StoredPhpRuntime.php": "core", + "system/App/Common/SystemTables.php": "core", + "system/App/Common/Template.php": "core", + "system/App/Common/TemporaryDirectory.php": "core", + "system/App/Common/TrafficAttribution.php": "core", + "system/App/Common/Twig.php": "core", + "system/App/Common/Twig/TwigExtensions.php": "core", + "system/App/Common/UploadPolicy.php": "core", + "system/App/Common/WebMaintenance.php": "core", + "system/App/Content/BasketTables.php": "core", + "system/App/Content/Blocks/ContentBlockRegistry.php": "core", + "system/App/Content/CatalogMenuRepository.php": "core", + "system/App/Content/CatalogTables.php": "core", + "system/App/Content/ContactsTables.php": "core", + "system/App/Content/ContentTables.php": "core", + "system/App/Content/DocumentTerms.php": "core", + "system/App/Content/Documents/ContentCacheInvalidator.php": "core", + "system/App/Content/Documents/DocumentAliasRegistry.php": "core", + "system/App/Content/Documents/DocumentAliasTemplate.php": "core", + "system/App/Content/Documents/DocumentCreationPresetRepository.php": "core", + "system/App/Content/Documents/DocumentExcerpt.php": "core", + "system/App/Content/Documents/DocumentHookContext.php": "core", + "system/App/Content/Documents/DocumentHookException.php": "core", + "system/App/Content/Documents/DocumentHookRuntime.php": "core", + "system/App/Content/Documents/DocumentMediaDraft.php": "core", + "system/App/Content/Documents/DocumentMediaFinalization.php": "core", + "system/App/Content/Documents/DocumentMediaFinalizer.php": "core", + "system/App/Content/Documents/DocumentMediaPath.php": "core", + "system/App/Content/Documents/DocumentMutationService.php": "core", + "system/App/Content/Documents/DocumentRevisionPayload.php": "core", + "system/App/Content/Documents/DocumentRubricCodeRunner.php": "core", + "system/App/Content/Documents/DocumentSaveEvent.php": "core", + "system/App/Content/Documents/DocumentSaveEvents.php": "core", + "system/App/Content/Documents/DocumentSaveRejected.php": "core", + "system/App/Content/Documents/DocumentSnapshotBuilder.php": "core", + "system/App/Content/Documents/DocumentSnapshotRepository.php": "core", + "system/App/Content/Documents/DocumentSnapshotStore.php": "core", + "system/App/Content/Documents/FieldTemplateManifest.php": "core", + "system/App/Content/Documents/RubricSchemaBuilder.php": "core", + "system/App/Content/ExtensionTables.php": "core", + "system/App/Content/Fields/AbstractFieldType.php": "core", + "system/App/Content/Fields/DocumentMediaFieldTrait.php": "core", + "system/App/Content/Fields/DocumentMediaFieldType.php": "core", + "system/App/Content/Fields/DocumentRelationFieldTrait.php": "core", + "system/App/Content/Fields/DocumentRelationIndex.php": "core", + "system/App/Content/Fields/DocumentRelationValue.php": "core", + "system/App/Content/Fields/FieldConditionEvaluator.php": "core", + "system/App/Content/Fields/FieldContext.php": "core", + "system/App/Content/Fields/FieldInstallationAudit.php": "core", + "system/App/Content/Fields/FieldLifecycle.php": "core", + "system/App/Content/Fields/FieldRegistry.php": "core", + "system/App/Content/Fields/FieldSettings.php": "core", + "system/App/Content/Fields/FieldSettingsForm.php": "core", + "system/App/Content/Fields/FieldType.php": "core", + "system/App/Content/Fields/FieldValidationException.php": "core", + "system/App/Content/Fields/FieldValidator.php": "core", + "system/App/Content/Fields/FieldValueCodec.php": "core", + "system/App/Content/Fields/HtmlSanitizer.php": "core", + "system/App/Content/Fields/MediaFieldValue.php": "core", + "system/App/Content/Fields/PublicFieldRuntime.php": "core", + "system/App/Content/Fields/PublicFieldTemplateContext.php": "core", + "system/App/Content/Fields/PublicFieldTemplateResolver.php": "core", + "system/App/Content/Fields/SerialFieldType.php": "core", + "system/App/Content/Fields/Templates/date/field-req-177.php": "core", + "system/App/Content/Fields/Templates/date/field-req-185.php": "core", + "system/App/Content/Fields/Templates/date/field-req-187.php": "core", + "system/App/Content/Fields/Templates/doc_from_rub_search/field-req-137.php": "core", + "system/App/Content/Fields/Templates/doc_from_rub_search/field-req-180.php": "core", + "system/App/Content/Fields/Templates/image_mega/field-doc-22-1.php": "core", + "system/App/Content/Fields/Templates/image_mega/field-doc-22-2.php": "core", + "system/App/Content/Fields/Templates/image_mega/field-doc-22.php": "core", + "system/App/Content/Fields/Templates/image_mega/field-req-22.php": "core", + "system/App/Content/Fields/Templates/image_multi/field-doc-123.php": "core", + "system/App/Content/Fields/Templates/image_multi/field-req-22.php": "core", + "system/App/Content/Fields/Templates/image_single/field-doc-14.php": "core", + "system/App/Content/Fields/Templates/image_single/field-doc-18.php": "core", + "system/App/Content/Fields/Templates/image_single/field-req-14.php": "core", + "system/App/Content/Fields/Templates/image_single/field-req-142.php": "core", + "system/App/Content/Fields/Templates/image_single/field-req-159.php": "core", + "system/App/Content/Fields/Templates/multi_links/field-doc-137.php": "core", + "system/App/Content/Fields/Templates/multi_links/field-doc-181.php": "core", + "system/App/Content/Fields/Templates/single_line_numeric/field-doc-200.php": "core", + "system/App/Content/Fields/Templates/single_line_numeric/field-req-157.php": "core", + "system/App/Content/Fields/Types/AddressValue.php": "core", + "system/App/Content/Fields/Types/Analoque.php": "core", + "system/App/Content/Fields/Types/Catalog.php": "core", + "system/App/Content/Fields/Types/Checkbox.php": "core", + "system/App/Content/Fields/Types/CheckboxMulti.php": "core", + "system/App/Content/Fields/Types/ChoiceValue.php": "core", + "system/App/Content/Fields/Types/Code.php": "core", + "system/App/Content/Fields/Types/ColorValue.php": "core", + "system/App/Content/Fields/Types/ContactValue.php": "core", + "system/App/Content/Fields/Types/Content.php": "core", + "system/App/Content/Fields/Types/Date.php": "core", + "system/App/Content/Fields/Types/DateTimeValue.php": "core", + "system/App/Content/Fields/Types/DimensionsValue.php": "core", + "system/App/Content/Fields/Types/DocFiles.php": "core", + "system/App/Content/Fields/Types/DocFromRub.php": "core", + "system/App/Content/Fields/Types/DocFromRubAll.php": "core", + "system/App/Content/Fields/Types/DocFromRubCheck.php": "core", + "system/App/Content/Fields/Types/DocFromRubMulti.php": "core", + "system/App/Content/Fields/Types/DocFromRubSearch.php": "core", + "system/App/Content/Fields/Types/Download.php": "core", + "system/App/Content/Fields/Types/DropDown.php": "core", + "system/App/Content/Fields/Types/DropDownKey.php": "core", + "system/App/Content/Fields/Types/ImageMega.php": "core", + "system/App/Content/Fields/Types/ImageMulti.php": "core", + "system/App/Content/Fields/Types/ImageSingle.php": "core", + "system/App/Content/Fields/Types/Link.php": "core", + "system/App/Content/Fields/Types/MultiCheckbox.php": "core", + "system/App/Content/Fields/Types/MultiLine.php": "core", + "system/App/Content/Fields/Types/MultiLineSimple.php": "core", + "system/App/Content/Fields/Types/MultiLineSlim.php": "core", + "system/App/Content/Fields/Types/MultiLinks.php": "core", + "system/App/Content/Fields/Types/MultiList.php": "core", + "system/App/Content/Fields/Types/MultiListSingle.php": "core", + "system/App/Content/Fields/Types/MultiListTriple.php": "core", + "system/App/Content/Fields/Types/MultiSelect.php": "core", + "system/App/Content/Fields/Types/NumberValue.php": "core", + "system/App/Content/Fields/Types/OptionFieldSupport.php": "core", + "system/App/Content/Fields/Types/PackagesValue.php": "core", + "system/App/Content/Fields/Types/PeriodValue.php": "core", + "system/App/Content/Fields/Types/RangeValue.php": "core", + "system/App/Content/Fields/Types/RichText.php": "core", + "system/App/Content/Fields/Types/SingleLine.php": "core", + "system/App/Content/Fields/Types/SingleLineNumeric.php": "core", + "system/App/Content/Fields/Types/SingleLineNumericThree.php": "core", + "system/App/Content/Fields/Types/SingleLineNumericTwo.php": "core", + "system/App/Content/Fields/Types/Tags.php": "core", + "system/App/Content/Fields/Types/Teasers.php": "core", + "system/App/Content/Fields/Types/TextToImage.php": "core", + "system/App/Content/Fields/Types/Youtube.php": "core", + "system/App/Content/Packages/ContentPackageWriter.php": "core", + "system/App/Content/Packages/PackageDependencyContext.php": "core", + "system/App/Content/Packages/PackageModuleProvisioner.php": "core", + "system/App/Content/PaymentPrograms.php": "core", + "system/App/Content/ProductShipping.php": "core", + "system/App/Content/PublicShellTables.php": "core", + "system/App/Content/PublicUserTables.php": "core", + "system/App/Content/Requests/NativeRequestExecutor.php": "core", + "system/App/Content/Requests/NativeRequestPlanCompiler.php": "core", + "system/App/Content/Requests/RequestConditionCompiler.php": "core", + "system/App/Content/Requests/RequestConditionValue.php": "core", + "system/App/Content/Requests/RequestExecutorComparator.php": "core", + "system/App/Content/Requests/RequestMatchExplainer.php": "core", + "system/App/Content/Requests/RequestOrderDiagnostics.php": "core", + "system/App/Content/Requests/RequestRendererRegistry.php": "core", + "system/App/Content/Requests/RequestResultContract.php": "core", + "system/App/Content/Requests/RequestRuntimeValue.php": "core", + "system/App/Content/Requests/RequestTieBreakerAdvisor.php": "core", + "system/App/Content/Requests/RequestViewPreview.php": "core", + "system/App/Content/Rubrics/FieldSetMerge.php": "core", + "system/App/Content/Rubrics/FieldSetRegistry.php": "core", + "system/App/Content/Rubrics/RubricFieldMutationService.php": "core", + "system/App/Content/Rubrics/RubricMutationService.php": "core", + "system/App/Content/Templates/TemplateMutationService.php": "core", + "system/App/Frontend/Auth/AuthService.php": "core", + "system/App/Frontend/Auth/Controller.php": "core", + "system/App/Frontend/Auth/Feature.php": "core", + "system/App/Frontend/Auth/FormTemplateRepository.php": "core", + "system/App/Frontend/Auth/OAuth/HttpClient.php": "core", + "system/App/Frontend/Auth/OAuth/IdentityRepository.php": "core", + "system/App/Frontend/Auth/OAuth/OAuthFlow.php": "core", + "system/App/Frontend/Auth/OAuth/OAuthService.php": "core", + "system/App/Frontend/Auth/OAuth/ProviderInterface.php": "core", + "system/App/Frontend/Auth/OAuth/ProviderRegistry.php": "core", + "system/App/Frontend/Auth/Phone/ChallengeRepository.php": "core", + "system/App/Frontend/Auth/Phone/Controller.php": "core", + "system/App/Frontend/Auth/Phone/PhoneAuthService.php": "core", + "system/App/Frontend/Auth/Phone/ProviderInterface.php": "core", + "system/App/Frontend/Auth/Phone/ProviderRegistry.php": "core", + "system/App/Frontend/Auth/ProfileRepository.php": "core", + "system/App/Frontend/Auth/Registration/CheckoutRegistrationGateInterface.php": "core", + "system/App/Frontend/Auth/Registration/EmailRegistrationGate.php": "core", + "system/App/Frontend/Auth/Registration/RegistrationGateInterface.php": "core", + "system/App/Frontend/Auth/Registration/RegistrationGateRegistry.php": "core", + "system/App/Frontend/Auth/Renderer.php": "core", + "system/App/Frontend/Auth/Tags.php": "core", + "system/App/Frontend/Auth/TokenRepository.php": "core", + "system/App/Frontend/Auth/UserRepository.php": "core", + "system/App/Frontend/Auth/assets/auth.css": "core", + "system/App/Frontend/Auth/assets/auth.js": "core", + "system/App/Frontend/Auth/view/login.twig": "core", + "system/App/Frontend/Auth/view/message.twig": "core", + "system/App/Frontend/Auth/view/oauth.twig": "core", + "system/App/Frontend/Auth/view/oauth_connections.twig": "core", + "system/App/Frontend/Auth/view/overview.twig": "core", + "system/App/Frontend/Auth/view/panel.twig": "core", + "system/App/Frontend/Auth/view/password.twig": "core", + "system/App/Frontend/Auth/view/phone.twig": "core", + "system/App/Frontend/Auth/view/profile.twig": "core", + "system/App/Frontend/Auth/view/register.twig": "core", + "system/App/Frontend/Auth/view/remember.twig": "core", + "system/App/Frontend/Auth/view/reset.twig": "core", + "system/App/Frontend/BlockRenderer.php": "core", + "system/App/Frontend/Bootstrap.php": "core", + "system/App/Frontend/BreadcrumbRenderer.php": "core", + "system/App/Frontend/Captcha/Controller.php": "core", + "system/App/Frontend/Captcha/Feature.php": "core", + "system/App/Frontend/ConfiguredPaginationRenderer.php": "core", + "system/App/Frontend/ContentCollectionTags.php": "core", + "system/App/Frontend/Debug/PublicDebugCollector.php": "core", + "system/App/Frontend/Debug/PublicDebugToolbar.php": "core", + "system/App/Frontend/Debug/PublicLayoutInspector.php": "core", + "system/App/Frontend/DocumentAccessPolicy.php": "core", + "system/App/Frontend/DocumentApi/Controller.php": "core", + "system/App/Frontend/DocumentApi/Feature.php": "core", + "system/App/Frontend/DocumentFieldRenderer.php": "core", + "system/App/Frontend/DocumentFieldRepository.php": "core", + "system/App/Frontend/DocumentPaginationRenderer.php": "core", + "system/App/Frontend/DocumentRenderCache.php": "core", + "system/App/Frontend/DocumentRepository.php": "core", + "system/App/Frontend/DocumentRevisionPreview.php": "core", + "system/App/Frontend/DocumentRevisionRepository.php": "core", + "system/App/Frontend/DocumentRouteRepository.php": "core", + "system/App/Frontend/DocumentUrlParser.php": "core", + "system/App/Frontend/DocumentViewTracker.php": "core", + "system/App/Frontend/ExternalContentDispatcher.php": "core", + "system/App/Frontend/Feeds/Controller.php": "core", + "system/App/Frontend/Feeds/Feature.php": "core", + "system/App/Frontend/Feeds/FeedPresets.php": "core", + "system/App/Frontend/Feeds/Generator.php": "core", + "system/App/Frontend/Feeds/PathController.php": "core", + "system/App/Frontend/Feeds/Repository.php": "core", + "system/App/Frontend/Feeds/Schema.php": "core", + "system/App/Frontend/Feeds/Service.php": "core", + "system/App/Frontend/FieldEndpoint.php": "core", + "system/App/Frontend/HiddenContentRenderer.php": "core", + "system/App/Frontend/Media/Controller.php": "core", + "system/App/Frontend/Media/Feature.php": "core", + "system/App/Frontend/Media/ImageSourceExtractor.php": "core", + "system/App/Frontend/Media/ThumbnailGateway.php": "core", + "system/App/Frontend/Media/ThumbnailPresetPolicy.php": "core", + "system/App/Frontend/Media/ThumbnailPresetScanner.php": "core", + "system/App/Frontend/Media/ThumbnailStorage.php": "core", + "system/App/Frontend/Media/ThumbnailUrl.php": "core", + "system/App/Frontend/Media/WatermarkService.php": "core", + "system/App/Frontend/MetaGenerator.php": "core", + "system/App/Frontend/ModuleTagRenderer.php": "core", + "system/App/Frontend/NavigationRenderer.php": "core", + "system/App/Frontend/NavigationRepository.php": "core", + "system/App/Frontend/PageComponentRenderer.php": "core", + "system/App/Frontend/PageShellRenderer.php": "core", + "system/App/Frontend/PageTemplateRepository.php": "core", + "system/App/Frontend/PageTemplateResolver.php": "core", + "system/App/Frontend/PaginationRenderer.php": "core", + "system/App/Frontend/PublicAvatar.php": "core", + "system/App/Frontend/PublicBootstrapEntry.php": "core", + "system/App/Frontend/PublicDocumentApi.php": "core", + "system/App/Frontend/PublicDocumentResolver.php": "core", + "system/App/Frontend/PublicEnvironment.php": "core", + "system/App/Frontend/PublicFieldApi.php": "core", + "system/App/Frontend/PublicHtmlResponse.php": "core", + "system/App/Frontend/PublicKernel.php": "core", + "system/App/Frontend/PublicMailer.php": "core", + "system/App/Frontend/PublicNotFoundResponder.php": "core", + "system/App/Frontend/PublicNotice.php": "core", + "system/App/Frontend/PublicPageContext.php": "core", + "system/App/Frontend/PublicPageLifecycle.php": "core", + "system/App/Frontend/PublicPageRenderer.php": "core", + "system/App/Frontend/PublicProfiler.php": "core", + "system/App/Frontend/PublicRedirectResponder.php": "core", + "system/App/Frontend/PublicRequestApi.php": "core", + "system/App/Frontend/PublicSettings.php": "core", + "system/App/Frontend/QuickEdit/PublicQuickEdit.php": "core", + "system/App/Frontend/RequestFieldRenderer.php": "core", + "system/App/Frontend/RequestItemContext.php": "core", + "system/App/Frontend/RequestItemRenderer.php": "core", + "system/App/Frontend/RequestListReadModel.php": "core", + "system/App/Frontend/RequestListRenderer.php": "core", + "system/App/Frontend/RequestRenderer.php": "core", + "system/App/Frontend/RequestRepository.php": "core", + "system/App/Frontend/RequestSort.php": "core", + "system/App/Frontend/RubricPermissionResolver.php": "core", + "system/App/Frontend/RubricTemplateRenderer.php": "core", + "system/App/Frontend/SiteAccess.php": "core", + "system/App/Frontend/Sitemap/Controller.php": "core", + "system/App/Frontend/Sitemap/Feature.php": "core", + "system/App/Frontend/Sitemap/Repository.php": "core", + "system/App/Frontend/StoredTemplateApi.php": "core", + "system/App/Frontend/ThemeAssets.php": "core", + "system/App/Frontend/ThemePageShellRenderer.php": "core", + "system/App/Frontend/ThemeSettings.php": "core", + "system/App/Frontend/ThemeSettingsTags.php": "core", + "system/App/Frontend/view/content/list.twig": "core", + "system/App/Functions/custom.php": "core", + "system/App/Helpers/Arr.php": "core", + "system/App/Helpers/Cookie.php": "core", + "system/App/Helpers/Crypt.php": "core", + "system/App/Helpers/Date.php": "core", + "system/App/Helpers/Debug.php": "core", + "system/App/Helpers/Dir.php": "core", + "system/App/Helpers/File.php": "core", + "system/App/Helpers/Hooks.php": "core", + "system/App/Helpers/Html.php": "core", + "system/App/Helpers/Ip.php": "core", + "system/App/Helpers/Json.php": "core", + "system/App/Helpers/Locales.php": "core", + "system/App/Helpers/MarketParser.php": "core", + "system/App/Helpers/Number.php": "core", + "system/App/Helpers/Phone.php": "core", + "system/App/Helpers/Request.php": "core", + "system/App/Helpers/Response.php": "core", + "system/App/Helpers/RussianStemmer.php": "core", + "system/App/Helpers/SearchText.php": "core", + "system/App/Helpers/Secure.php": "core", + "system/App/Helpers/SoftDelete.php": "core", + "system/App/Helpers/Str.php": "core", + "system/App/Helpers/TagParser.php": "core", + "system/App/Helpers/Url.php": "core", + "system/App/Helpers/Valid.php": "core", + "system/App/Helpers/Zip.php": "core", + "system/bootstrap.php": "core", + "system/distribution.json": "core", + "system/language/ru/lang.xml": "core", + "system/preload.php": "core", + "system/public.php": "core", + "system/release.php": "core", + "system/vendor/Captcha/fonts/palatino_linotype_bold.png": "core", + "system/vendor/Captcha/fonts/perpetua_bold.png": "core", + "system/vendor/Captcha/fonts/times_bold.png": "core", + "system/vendor/Captcha/form_example.php": "core", + "system/vendor/Captcha/index.php": "core", + "system/vendor/Captcha/kcaptcha.php": "core", + "system/vendor/Captcha/kcaptcha_config.php": "core", + "system/vendor/Captcha/util/font_preparer.php": "core", + "system/vendor/ImageToolbox/ImageToolbox.php": "core", + "system/vendor/MobileDetect/Mobile_Detect.php": "core", + "system/vendor/PHPExcel/PHPExcel.php": "core", + "system/vendor/PHPExcel/PHPExcel/Autoloader.php": "core", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/APC.php": "core", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/CacheBase.php": "core", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/DiscISAM.php": "core", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/ICache.php": "core", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/Igbinary.php": "core", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/Memcache.php": "core", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/Memory.php": "core", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/MemoryGZip.php": "core", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/MemorySerialized.php": "core", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/PHPTemp.php": "core", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/SQLite.php": "core", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/SQLite3.php": "core", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorage/Wincache.php": "core", + "system/vendor/PHPExcel/PHPExcel/CachedObjectStorageFactory.php": "core", + "system/vendor/PHPExcel/PHPExcel/CalcEngine/CyclicReferenceStack.php": "core", + "system/vendor/PHPExcel/PHPExcel/CalcEngine/Logger.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/Database.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/DateTime.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/Engineering.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/Exception.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/ExceptionHandler.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/Financial.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/FormulaParser.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/FormulaToken.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/Function.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/Functions.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/Logical.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/LookupRef.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/MathTrig.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/Statistical.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/TextData.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/Token/Stack.php": "core", + "system/vendor/PHPExcel/PHPExcel/Calculation/functionlist.txt": "core", + "system/vendor/PHPExcel/PHPExcel/Cell.php": "core", + "system/vendor/PHPExcel/PHPExcel/Cell/AdvancedValueBinder.php": "core", + "system/vendor/PHPExcel/PHPExcel/Cell/DataType.php": "core", + "system/vendor/PHPExcel/PHPExcel/Cell/DataValidation.php": "core", + "system/vendor/PHPExcel/PHPExcel/Cell/DefaultValueBinder.php": "core", + "system/vendor/PHPExcel/PHPExcel/Cell/Hyperlink.php": "core", + "system/vendor/PHPExcel/PHPExcel/Cell/IValueBinder.php": "core", + "system/vendor/PHPExcel/PHPExcel/Chart.php": "core", + "system/vendor/PHPExcel/PHPExcel/Chart/Axis.php": "core", + "system/vendor/PHPExcel/PHPExcel/Chart/DataSeries.php": "core", + "system/vendor/PHPExcel/PHPExcel/Chart/DataSeriesValues.php": "core", + "system/vendor/PHPExcel/PHPExcel/Chart/Exception.php": "core", + "system/vendor/PHPExcel/PHPExcel/Chart/GridLines.php": "core", + "system/vendor/PHPExcel/PHPExcel/Chart/Layout.php": "core", + "system/vendor/PHPExcel/PHPExcel/Chart/Legend.php": "core", + "system/vendor/PHPExcel/PHPExcel/Chart/PlotArea.php": "core", + "system/vendor/PHPExcel/PHPExcel/Chart/Properties.php": "core", + "system/vendor/PHPExcel/PHPExcel/Chart/Renderer/PHP Charting Libraries.txt": "core", + "system/vendor/PHPExcel/PHPExcel/Chart/Renderer/jpgraph.php": "core", + "system/vendor/PHPExcel/PHPExcel/Chart/Title.php": "core", + "system/vendor/PHPExcel/PHPExcel/Comment.php": "core", + "system/vendor/PHPExcel/PHPExcel/DocumentProperties.php": "core", + "system/vendor/PHPExcel/PHPExcel/DocumentSecurity.php": "core", + "system/vendor/PHPExcel/PHPExcel/Exception.php": "core", + "system/vendor/PHPExcel/PHPExcel/HashTable.php": "core", + "system/vendor/PHPExcel/PHPExcel/Helper/HTML.php": "core", + "system/vendor/PHPExcel/PHPExcel/IComparable.php": "core", + "system/vendor/PHPExcel/PHPExcel/IOFactory.php": "core", + "system/vendor/PHPExcel/PHPExcel/NamedRange.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Abstract.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/CSV.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/DefaultReadFilter.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel2007.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel2007/Chart.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel2007/Theme.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/Color.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/Color/BIFF5.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/Color/BIFF8.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/Color/BuiltIn.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/ErrorCode.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/Escher.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/MD5.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/RC4.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/Style/Border.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Excel5/Style/FillPattern.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Exception.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/Gnumeric.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/HTML.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/IReadFilter.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/IReader.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/OOCalc.php": "core", + "system/vendor/PHPExcel/PHPExcel/Reader/SYLK.php": "core", + "system/vendor/PHPExcel/PHPExcel/ReferenceHelper.php": "core", + "system/vendor/PHPExcel/PHPExcel/RichText.php": "core", + "system/vendor/PHPExcel/PHPExcel/RichText/ITextElement.php": "core", + "system/vendor/PHPExcel/PHPExcel/RichText/Run.php": "core", + "system/vendor/PHPExcel/PHPExcel/RichText/TextElement.php": "core", + "system/vendor/PHPExcel/PHPExcel/Settings.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/CodePage.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/Date.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/Drawing.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher/DgContainer.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher/DgContainer/SpgrContainer.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher/DgContainer/SpgrContainer/SpContainer.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher/DggContainer.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher/DggContainer/BstoreContainer.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher/DggContainer/BstoreContainer/BSE.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/Escher/DggContainer/BstoreContainer/BSE/Blip.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/Excel5.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/File.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/Font.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/CHANGELOG.TXT": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/CholeskyDecomposition.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/EigenvalueDecomposition.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/LUDecomposition.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/Matrix.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/QRDecomposition.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/SingularValueDecomposition.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/utils/Error.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/JAMA/utils/Maths.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/OLE.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/OLE/ChainedBlockStream.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/OLE/PPS.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/OLE/PPS/File.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/OLE/PPS/Root.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/OLERead.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/PCLZip/gnu-lgpl.txt": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/PCLZip/pclzip.lib.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/PCLZip/readme.txt": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/PasswordHasher.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/String.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/TimeZone.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/XMLWriter.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/ZipArchive.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/ZipStreamWrapper.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/trend/bestFitClass.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/trend/exponentialBestFitClass.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/trend/linearBestFitClass.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/trend/logarithmicBestFitClass.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/trend/polynomialBestFitClass.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/trend/powerBestFitClass.php": "core", + "system/vendor/PHPExcel/PHPExcel/Shared/trend/trendClass.php": "core", + "system/vendor/PHPExcel/PHPExcel/Style.php": "core", + "system/vendor/PHPExcel/PHPExcel/Style/Alignment.php": "core", + "system/vendor/PHPExcel/PHPExcel/Style/Border.php": "core", + "system/vendor/PHPExcel/PHPExcel/Style/Borders.php": "core", + "system/vendor/PHPExcel/PHPExcel/Style/Color.php": "core", + "system/vendor/PHPExcel/PHPExcel/Style/Conditional.php": "core", + "system/vendor/PHPExcel/PHPExcel/Style/Fill.php": "core", + "system/vendor/PHPExcel/PHPExcel/Style/Font.php": "core", + "system/vendor/PHPExcel/PHPExcel/Style/NumberFormat.php": "core", + "system/vendor/PHPExcel/PHPExcel/Style/Protection.php": "core", + "system/vendor/PHPExcel/PHPExcel/Style/Supervisor.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/AutoFilter.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/AutoFilter/Column.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/AutoFilter/Column/Rule.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/BaseDrawing.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/CellIterator.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/Column.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/ColumnCellIterator.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/ColumnDimension.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/ColumnIterator.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/Dimension.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/Drawing.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/Drawing/Shadow.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/HeaderFooter.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/HeaderFooterDrawing.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/MemoryDrawing.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/PageMargins.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/PageSetup.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/Protection.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/Row.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/RowCellIterator.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/RowDimension.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/RowIterator.php": "core", + "system/vendor/PHPExcel/PHPExcel/Worksheet/SheetView.php": "core", + "system/vendor/PHPExcel/PHPExcel/WorksheetIterator.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Abstract.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/CSV.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Chart.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Comments.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/ContentTypes.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/DocProps.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Drawing.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Rels.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/RelsRibbon.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/RelsVBA.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/StringTable.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Style.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Theme.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Workbook.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Worksheet.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/WriterPart.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5/BIFFwriter.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5/Escher.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5/Font.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5/Parser.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5/Workbook.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5/Worksheet.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Excel5/Xf.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/Exception.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/HTML.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/IWriter.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/Cell/Comment.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/Content.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/Meta.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/MetaInf.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/Mimetype.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/Settings.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/Styles.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/Thumbnails.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/OpenDocument/WriterPart.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/PDF.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/PDF/Core.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/PDF/DomPDF.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/PDF/mPDF.php": "core", + "system/vendor/PHPExcel/PHPExcel/Writer/PDF/tcPDF.php": "core", + "system/vendor/PHPExcel/PHPExcel/locale/en/uk/config": "core", + "system/vendor/PHPExcel/PHPExcel/locale/ru/config": "core", + "system/vendor/PHPExcel/PHPExcel/locale/ru/functions": "core", + "system/vendor/SqlFormatter/SqlFormatter.php": "core", + "system/vendor/SwiftMailer/classes/Swift.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Attachment.php": "core", + "system/vendor/SwiftMailer/classes/Swift/ByteStream/AbstractFilterableInputStream.php": "core", + "system/vendor/SwiftMailer/classes/Swift/ByteStream/ArrayByteStream.php": "core", + "system/vendor/SwiftMailer/classes/Swift/ByteStream/FileByteStream.php": "core", + "system/vendor/SwiftMailer/classes/Swift/ByteStream/TemporaryFileByteStream.php": "core", + "system/vendor/SwiftMailer/classes/Swift/CharacterReader.php": "core", + "system/vendor/SwiftMailer/classes/Swift/CharacterReader/GenericFixedWidthReader.php": "core", + "system/vendor/SwiftMailer/classes/Swift/CharacterReader/UsAsciiReader.php": "core", + "system/vendor/SwiftMailer/classes/Swift/CharacterReader/Utf8Reader.php": "core", + "system/vendor/SwiftMailer/classes/Swift/CharacterReaderFactory.php": "core", + "system/vendor/SwiftMailer/classes/Swift/CharacterReaderFactory/SimpleCharacterReaderFactory.php": "core", + "system/vendor/SwiftMailer/classes/Swift/CharacterStream.php": "core", + "system/vendor/SwiftMailer/classes/Swift/CharacterStream/ArrayCharacterStream.php": "core", + "system/vendor/SwiftMailer/classes/Swift/CharacterStream/NgCharacterStream.php": "core", + "system/vendor/SwiftMailer/classes/Swift/ConfigurableSpool.php": "core", + "system/vendor/SwiftMailer/classes/Swift/DependencyContainer.php": "core", + "system/vendor/SwiftMailer/classes/Swift/DependencyException.php": "core", + "system/vendor/SwiftMailer/classes/Swift/EmbeddedFile.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Encoder.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Encoder/Base64Encoder.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Encoder/QpEncoder.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Encoder/Rfc2231Encoder.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Encoding.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Events/CommandEvent.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Events/CommandListener.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Events/Event.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Events/EventDispatcher.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Events/EventListener.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Events/EventObject.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Events/ResponseEvent.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Events/ResponseListener.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Events/SendEvent.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Events/SendListener.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Events/SimpleEventDispatcher.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Events/TransportChangeEvent.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Events/TransportChangeListener.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Events/TransportExceptionEvent.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Events/TransportExceptionListener.php": "core", + "system/vendor/SwiftMailer/classes/Swift/FailoverTransport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/FileSpool.php": "core", + "system/vendor/SwiftMailer/classes/Swift/FileStream.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Filterable.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Image.php": "core", + "system/vendor/SwiftMailer/classes/Swift/InputByteStream.php": "core", + "system/vendor/SwiftMailer/classes/Swift/IoException.php": "core", + "system/vendor/SwiftMailer/classes/Swift/KeyCache.php": "core", + "system/vendor/SwiftMailer/classes/Swift/KeyCache/ArrayKeyCache.php": "core", + "system/vendor/SwiftMailer/classes/Swift/KeyCache/DiskKeyCache.php": "core", + "system/vendor/SwiftMailer/classes/Swift/KeyCache/KeyCacheInputStream.php": "core", + "system/vendor/SwiftMailer/classes/Swift/KeyCache/NullKeyCache.php": "core", + "system/vendor/SwiftMailer/classes/Swift/KeyCache/SimpleKeyCacheInputStream.php": "core", + "system/vendor/SwiftMailer/classes/Swift/LoadBalancedTransport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/MailTransport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mailer.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mailer/ArrayRecipientIterator.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mailer/RecipientIterator.php": "core", + "system/vendor/SwiftMailer/classes/Swift/MemorySpool.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Message.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/Attachment.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/CharsetObserver.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/ContentEncoder.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/ContentEncoder/Base64ContentEncoder.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/ContentEncoder/NativeQpContentEncoder.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/ContentEncoder/PlainContentEncoder.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/ContentEncoder/QpContentEncoder.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/ContentEncoder/QpContentEncoderProxy.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/ContentEncoder/RawContentEncoder.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/EmbeddedFile.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/EncodingObserver.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/Grammar.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/Header.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/HeaderEncoder.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/HeaderEncoder/Base64HeaderEncoder.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/HeaderEncoder/QpHeaderEncoder.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/HeaderFactory.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/HeaderSet.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/AbstractHeader.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/DateHeader.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/IdentificationHeader.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/MailboxHeader.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/OpenDKIMHeader.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/ParameterizedHeader.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/PathHeader.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/Headers/UnstructuredHeader.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/Message.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/MimeEntity.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/MimePart.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/ParameterizedHeader.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/SimpleHeaderFactory.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/SimpleHeaderSet.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/SimpleMessage.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Mime/SimpleMimeEntity.php": "core", + "system/vendor/SwiftMailer/classes/Swift/MimePart.php": "core", + "system/vendor/SwiftMailer/classes/Swift/NullTransport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/OutputByteStream.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/AntiFloodPlugin.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/BandwidthMonitorPlugin.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Decorator/Replacements.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/DecoratorPlugin.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/ImpersonatePlugin.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Logger.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/LoggerPlugin.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Loggers/ArrayLogger.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Loggers/EchoLogger.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/MessageLogger.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Pop/Pop3Connection.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Pop/Pop3Exception.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/PopBeforeSmtpPlugin.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/RedirectingPlugin.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Reporter.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/ReporterPlugin.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Reporters/HitReporter.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Reporters/HtmlReporter.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Sleeper.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/ThrottlerPlugin.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Plugins/Timer.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Preferences.php": "core", + "system/vendor/SwiftMailer/classes/Swift/ReplacementFilterFactory.php": "core", + "system/vendor/SwiftMailer/classes/Swift/RfcComplianceException.php": "core", + "system/vendor/SwiftMailer/classes/Swift/SendmailTransport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/SignedMessage.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Signer.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Signers/BodySigner.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Signers/DKIMSigner.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Signers/DomainKeySigner.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Signers/HeaderSigner.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Signers/OpenDKIMSigner.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Signers/SMimeSigner.php": "core", + "system/vendor/SwiftMailer/classes/Swift/SmtpTransport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Spool.php": "core", + "system/vendor/SwiftMailer/classes/Swift/SpoolTransport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/StreamFilter.php": "core", + "system/vendor/SwiftMailer/classes/Swift/StreamFilters/ByteArrayReplacementFilter.php": "core", + "system/vendor/SwiftMailer/classes/Swift/StreamFilters/StringReplacementFilter.php": "core", + "system/vendor/SwiftMailer/classes/Swift/StreamFilters/StringReplacementFilterFactory.php": "core", + "system/vendor/SwiftMailer/classes/Swift/SwiftException.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/AbstractSmtpTransport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/Esmtp/Auth/CramMd5Authenticator.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/Esmtp/Auth/LoginAuthenticator.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/Esmtp/Auth/NTLMAuthenticator.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/Esmtp/Auth/PlainAuthenticator.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/Esmtp/Auth/XOAuth2Authenticator.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/Esmtp/AuthHandler.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/Esmtp/Authenticator.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/EsmtpHandler.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/EsmtpTransport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/FailoverTransport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/IoBuffer.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/LoadBalancedTransport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/MailInvoker.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/MailTransport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/NullTransport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/SendmailTransport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/SimpleMailInvoker.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/SmtpAgent.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/SpoolTransport.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Transport/StreamBuffer.php": "core", + "system/vendor/SwiftMailer/classes/Swift/TransportException.php": "core", + "system/vendor/SwiftMailer/classes/Swift/Validate.php": "core", + "system/vendor/SwiftMailer/dependency_maps/cache_deps.php": "core", + "system/vendor/SwiftMailer/dependency_maps/message_deps.php": "core", + "system/vendor/SwiftMailer/dependency_maps/mime_deps.php": "core", + "system/vendor/SwiftMailer/dependency_maps/transport_deps.php": "core", + "system/vendor/SwiftMailer/mime_types.php": "core", + "system/vendor/SwiftMailer/preferences.php": "core", + "system/vendor/SwiftMailer/swift_init.php": "core", + "system/vendor/SwiftMailer/swift_required.php": "core", + "system/vendor/SwiftMailer/swift_required_pear.php": "core", + "system/vendor/SwiftMailer/swiftmailer_generate_mimes_config.php": "core", + "system/vendor/SwiftMailer/version.txt": "core", + "system/vendor/Twig/Cache/CacheInterface.php": "core", + "system/vendor/Twig/Cache/FilesystemCache.php": "core", + "system/vendor/Twig/Cache/NullCache.php": "core", + "system/vendor/Twig/Compiler.php": "core", + "system/vendor/Twig/Environment.php": "core", + "system/vendor/Twig/Error/Error.php": "core", + "system/vendor/Twig/Error/LoaderError.php": "core", + "system/vendor/Twig/Error/RuntimeError.php": "core", + "system/vendor/Twig/Error/SyntaxError.php": "core", + "system/vendor/Twig/ExpressionParser.php": "core", + "system/vendor/Twig/Extension/AbstractExtension.php": "core", + "system/vendor/Twig/Extension/CoreExtension.php": "core", + "system/vendor/Twig/Extension/DebugExtension.php": "core", + "system/vendor/Twig/Extension/EscaperExtension.php": "core", + "system/vendor/Twig/Extension/ExtensionInterface.php": "core", + "system/vendor/Twig/Extension/GlobalsInterface.php": "core", + "system/vendor/Twig/Extension/OptimizerExtension.php": "core", + "system/vendor/Twig/Extension/ProfilerExtension.php": "core", + "system/vendor/Twig/Extension/RuntimeExtensionInterface.php": "core", + "system/vendor/Twig/Extension/SandboxExtension.php": "core", + "system/vendor/Twig/Extension/StagingExtension.php": "core", + "system/vendor/Twig/Extension/StringLoaderExtension.php": "core", + "system/vendor/Twig/ExtensionSet.php": "core", + "system/vendor/Twig/FileExtensionEscapingStrategy.php": "core", + "system/vendor/Twig/Lexer.php": "core", + "system/vendor/Twig/Loader/ArrayLoader.php": "core", + "system/vendor/Twig/Loader/ChainLoader.php": "core", + "system/vendor/Twig/Loader/FilesystemLoader.php": "core", + "system/vendor/Twig/Loader/LoaderInterface.php": "core", + "system/vendor/Twig/Markup.php": "core", + "system/vendor/Twig/Node/AutoEscapeNode.php": "core", + "system/vendor/Twig/Node/BlockNode.php": "core", + "system/vendor/Twig/Node/BlockReferenceNode.php": "core", + "system/vendor/Twig/Node/BodyNode.php": "core", + "system/vendor/Twig/Node/CheckSecurityCallNode.php": "core", + "system/vendor/Twig/Node/CheckSecurityNode.php": "core", + "system/vendor/Twig/Node/CheckToStringNode.php": "core", + "system/vendor/Twig/Node/DeprecatedNode.php": "core", + "system/vendor/Twig/Node/DoNode.php": "core", + "system/vendor/Twig/Node/EmbedNode.php": "core", + "system/vendor/Twig/Node/Expression/AbstractExpression.php": "core", + "system/vendor/Twig/Node/Expression/ArrayExpression.php": "core", + "system/vendor/Twig/Node/Expression/ArrowFunctionExpression.php": "core", + "system/vendor/Twig/Node/Expression/AssignNameExpression.php": "core", + "system/vendor/Twig/Node/Expression/Binary/AbstractBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/AddBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/AndBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/BitwiseAndBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/BitwiseOrBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/BitwiseXorBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/ConcatBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/DivBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/EndsWithBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/EqualBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/FloorDivBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/GreaterBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/GreaterEqualBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/InBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/LessBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/LessEqualBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/MatchesBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/ModBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/MulBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/NotEqualBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/NotInBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/OrBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/PowerBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/RangeBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/SpaceshipBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/StartsWithBinary.php": "core", + "system/vendor/Twig/Node/Expression/Binary/SubBinary.php": "core", + "system/vendor/Twig/Node/Expression/BlockReferenceExpression.php": "core", + "system/vendor/Twig/Node/Expression/CallExpression.php": "core", + "system/vendor/Twig/Node/Expression/ConditionalExpression.php": "core", + "system/vendor/Twig/Node/Expression/ConstantExpression.php": "core", + "system/vendor/Twig/Node/Expression/Filter/DefaultFilter.php": "core", + "system/vendor/Twig/Node/Expression/FilterExpression.php": "core", + "system/vendor/Twig/Node/Expression/FunctionExpression.php": "core", + "system/vendor/Twig/Node/Expression/GetAttrExpression.php": "core", + "system/vendor/Twig/Node/Expression/InlinePrint.php": "core", + "system/vendor/Twig/Node/Expression/MethodCallExpression.php": "core", + "system/vendor/Twig/Node/Expression/NameExpression.php": "core", + "system/vendor/Twig/Node/Expression/NullCoalesceExpression.php": "core", + "system/vendor/Twig/Node/Expression/ParentExpression.php": "core", + "system/vendor/Twig/Node/Expression/TempNameExpression.php": "core", + "system/vendor/Twig/Node/Expression/Test/ConstantTest.php": "core", + "system/vendor/Twig/Node/Expression/Test/DefinedTest.php": "core", + "system/vendor/Twig/Node/Expression/Test/DivisiblebyTest.php": "core", + "system/vendor/Twig/Node/Expression/Test/EvenTest.php": "core", + "system/vendor/Twig/Node/Expression/Test/NullTest.php": "core", + "system/vendor/Twig/Node/Expression/Test/OddTest.php": "core", + "system/vendor/Twig/Node/Expression/Test/SameasTest.php": "core", + "system/vendor/Twig/Node/Expression/TestExpression.php": "core", + "system/vendor/Twig/Node/Expression/Unary/AbstractUnary.php": "core", + "system/vendor/Twig/Node/Expression/Unary/NegUnary.php": "core", + "system/vendor/Twig/Node/Expression/Unary/NotUnary.php": "core", + "system/vendor/Twig/Node/Expression/Unary/PosUnary.php": "core", + "system/vendor/Twig/Node/Expression/VariadicExpression.php": "core", + "system/vendor/Twig/Node/FlushNode.php": "core", + "system/vendor/Twig/Node/ForLoopNode.php": "core", + "system/vendor/Twig/Node/ForNode.php": "core", + "system/vendor/Twig/Node/IfNode.php": "core", + "system/vendor/Twig/Node/ImportNode.php": "core", + "system/vendor/Twig/Node/IncludeNode.php": "core", + "system/vendor/Twig/Node/MacroNode.php": "core", + "system/vendor/Twig/Node/ModuleNode.php": "core", + "system/vendor/Twig/Node/Node.php": "core", + "system/vendor/Twig/Node/NodeCaptureInterface.php": "core", + "system/vendor/Twig/Node/NodeOutputInterface.php": "core", + "system/vendor/Twig/Node/PrintNode.php": "core", + "system/vendor/Twig/Node/SandboxNode.php": "core", + "system/vendor/Twig/Node/SetNode.php": "core", + "system/vendor/Twig/Node/TextNode.php": "core", + "system/vendor/Twig/Node/WithNode.php": "core", + "system/vendor/Twig/NodeTraverser.php": "core", + "system/vendor/Twig/NodeVisitor/AbstractNodeVisitor.php": "core", + "system/vendor/Twig/NodeVisitor/EscaperNodeVisitor.php": "core", + "system/vendor/Twig/NodeVisitor/MacroAutoImportNodeVisitor.php": "core", + "system/vendor/Twig/NodeVisitor/NodeVisitorInterface.php": "core", + "system/vendor/Twig/NodeVisitor/OptimizerNodeVisitor.php": "core", + "system/vendor/Twig/NodeVisitor/SafeAnalysisNodeVisitor.php": "core", + "system/vendor/Twig/NodeVisitor/SandboxNodeVisitor.php": "core", + "system/vendor/Twig/Parser.php": "core", + "system/vendor/Twig/Profiler/Dumper/BaseDumper.php": "core", + "system/vendor/Twig/Profiler/Dumper/BlackfireDumper.php": "core", + "system/vendor/Twig/Profiler/Dumper/HtmlDumper.php": "core", + "system/vendor/Twig/Profiler/Dumper/TextDumper.php": "core", + "system/vendor/Twig/Profiler/Node/EnterProfileNode.php": "core", + "system/vendor/Twig/Profiler/Node/LeaveProfileNode.php": "core", + "system/vendor/Twig/Profiler/NodeVisitor/ProfilerNodeVisitor.php": "core", + "system/vendor/Twig/Profiler/Profile.php": "core", + "system/vendor/Twig/RuntimeLoader/ContainerRuntimeLoader.php": "core", + "system/vendor/Twig/RuntimeLoader/FactoryRuntimeLoader.php": "core", + "system/vendor/Twig/RuntimeLoader/RuntimeLoaderInterface.php": "core", + "system/vendor/Twig/Sandbox/SecurityError.php": "core", + "system/vendor/Twig/Sandbox/SecurityNotAllowedFilterError.php": "core", + "system/vendor/Twig/Sandbox/SecurityNotAllowedFunctionError.php": "core", + "system/vendor/Twig/Sandbox/SecurityNotAllowedMethodError.php": "core", + "system/vendor/Twig/Sandbox/SecurityNotAllowedPropertyError.php": "core", + "system/vendor/Twig/Sandbox/SecurityNotAllowedTagError.php": "core", + "system/vendor/Twig/Sandbox/SecurityPolicy.php": "core", + "system/vendor/Twig/Sandbox/SecurityPolicyInterface.php": "core", + "system/vendor/Twig/Source.php": "core", + "system/vendor/Twig/Template.php": "core", + "system/vendor/Twig/TemplateWrapper.php": "core", + "system/vendor/Twig/Test/IntegrationTestCase.php": "core", + "system/vendor/Twig/Test/NodeTestCase.php": "core", + "system/vendor/Twig/Token.php": "core", + "system/vendor/Twig/TokenParser/AbstractTokenParser.php": "core", + "system/vendor/Twig/TokenParser/ApplyTokenParser.php": "core", + "system/vendor/Twig/TokenParser/AutoEscapeTokenParser.php": "core", + "system/vendor/Twig/TokenParser/BlockTokenParser.php": "core", + "system/vendor/Twig/TokenParser/DeprecatedTokenParser.php": "core", + "system/vendor/Twig/TokenParser/DoTokenParser.php": "core", + "system/vendor/Twig/TokenParser/EmbedTokenParser.php": "core", + "system/vendor/Twig/TokenParser/ExtendsTokenParser.php": "core", + "system/vendor/Twig/TokenParser/FlushTokenParser.php": "core", + "system/vendor/Twig/TokenParser/ForTokenParser.php": "core", + "system/vendor/Twig/TokenParser/FromTokenParser.php": "core", + "system/vendor/Twig/TokenParser/IfTokenParser.php": "core", + "system/vendor/Twig/TokenParser/ImportTokenParser.php": "core", + "system/vendor/Twig/TokenParser/IncludeTokenParser.php": "core", + "system/vendor/Twig/TokenParser/MacroTokenParser.php": "core", + "system/vendor/Twig/TokenParser/SandboxTokenParser.php": "core", + "system/vendor/Twig/TokenParser/SetTokenParser.php": "core", + "system/vendor/Twig/TokenParser/TokenParserInterface.php": "core", + "system/vendor/Twig/TokenParser/UseTokenParser.php": "core", + "system/vendor/Twig/TokenParser/WithTokenParser.php": "core", + "system/vendor/Twig/TokenStream.php": "core", + "system/vendor/Twig/TwigFilter.php": "core", + "system/vendor/Twig/TwigFunction.php": "core", + "system/vendor/Twig/TwigTest.php": "core", + "system/vendor/Twig/Util/DeprecationCollector.php": "core", + "system/vendor/Twig/Util/TemplateDirIterator.php": "core", + "templates/index.php": "core", + "templates/public/index.php": "core", + "templates/public/templates/.htaccess": "core", + "templates/public/theme.json": "core", + "tmp/.htaccess": "core", + "uploads/.htaccess": "core", + "uploads/index.php": "core", + "robots.txt": "core" + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..94c7eed --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# AVE.cms + +**AVE.cms 3.3 build 0.31** — модульная система управления сайтами на PHP. +Она подходит для проектов, в которых структуру содержимого нужно собирать из +собственных рубрик, полей, документов, запросов, блоков и шаблонов, не +привязываясь к заранее заданному типу сайта. + +AVE.cms включает публичное ядро и единую панель управления. `adminx/` — только +имя каталога панели по умолчанию: его можно изменить во время установки. + +## Основные возможности + +- конструктор рубрик и форм редактирования документов; +- расширяемый реестр типов полей; +- шаблоны сайта, рубрик, запросов, навигации и блоков; +- запросы с группами условий, сортировкой, пагинацией и предпросмотром; +- древовидная навигация с собственными шаблонами уровней; +- файловый браузер, редактор изображений, WebP и управляемые миниатюры; +- роли, права, пользователи панели и публичные аккаунты; +- ревизии содержимого, аудит действий и системные события; +- файловый и теговый кеш с контролируемой инвалидацией; +- HTTP API, хуки жизненного цикла и модульные расширения; +- защищённая публичная отладка для администраторов; +- обновления ядра и каталог устанавливаемых модулей; +- русская и английская локализация панели управления. + +Публичная часть сайта остаётся одноязычной. Язык панели управления каждый +пользователь выбирает независимо. + +## Как устроен контент + +| Сущность | Назначение | +| --- | --- | +| **Шаблон сайта** | Формирует общий HTML-каркас страницы и подключает содержимое, навигацию, блоки и ассеты темы. | +| **Рубрика** | Описывает тип материала: набор полей, форму редактирования, правила URL и шаблоны вывода. | +| **Поле** | Хранит отдельную часть данных документа и определяет её редактор, проверку и публичное представление. | +| **Документ** | Содержит значения системных и пользовательских полей конкретной рубрики. | +| **Запрос** | Выбирает документы по условиям, сортирует их и выводит через шаблон списка и элемента. | +| **Навигация** | Собирает древовидные меню и управляет шаблонами каждого уровня. | +| **Блок** | Вставляет повторно используемый фрагмент содержимого или вычисляемый результат по тегу. | +| **Модуль** | Добавляет самостоятельную возможность, маршруты, права, настройки, хуки и элементы панели. | + +При открытии страницы ядро находит документ, загружает его рубрику и поля, +применяет назначенные шаблоны, обрабатывает системные теги и подключает +навигацию, блоки и установленные модули. Подробная схема приведена в разделе +[«Как AVE.cms собирает сайт»](help/content/README.md). + +## Требования + +- PHP 7.3 или новее; +- MySQL 5.7 или новее с InnoDB и `utf8mb4`; +- расширения PHP `mysqli`, `mbstring`, `json` и `openssl`; +- расширения `curl`, `zip` и GD рекомендуются для сетевых операций, ZIP-пакетов + и обработки изображений; +- право записи в `configs/`, `storage/`, `tmp/` и `uploads/`; +- Apache с `mod_rewrite` либо эквивалентные правила маршрутизации веб-сервера. + +Точные результаты проверки окружения установщик показывает до изменения базы +данных. + +## Быстрая установка + +1. Создайте пустую базу данных и пользователя с правами на её изменение. +2. Распакуйте архив AVE.cms в корневой каталог сайта, сохранив `.htaccess`. +3. Откройте сайт в браузере. При отсутствии конфигурации он направит вас на + `/setup/`. +4. Укажите параметры базы, префикс таблиц, язык панели и данные первого + администратора. +5. При необходимости измените имя каталога панели управления. По умолчанию это + `adminx`. +6. Завершите установку, войдите в панель и настройте структуру будущего сайта. + +Установщик создаёт минимальный рабочий сайт: основной шаблон, рубрику с +заголовком и richtext-полем, главную страницу и служебную страницу 404. +Дополнительные возможности устанавливаются отдельно через раздел модулей. + +Полная инструкция: [Установка AVE.cms](help/installation/README.md). + +## Модули и расширение + +Модули физически устанавливаются и удаляются. Пакет может добавить: + +- публичные и административные маршруты; +- таблицы и последовательные миграции; +- пункты меню, действия в шапке и виджеты; +- права доступа и настройки; +- обработчики хуков и событий; +- публичные ассеты и шаблоны. + +Модуль можно загрузить ZIP-архивом или получить из подключённого каталога. +Удаление модуля удаляет его файлы и принадлежащие ему данные согласно контракту +пакета. + +Документация: [работа с модулями](help/modules/README.md). + +## Структура проекта + +```text +adminx/ панель управления и её разделы +configs/ конфигурация установленной системы +help/ пользовательская и публичная техническая документация +modules/ физически устанавливаемые модули +setup/ мастер чистой установки +storage/ служебные данные, секреты и состояние системы +system/App/ ядро AVE.cms +system/vendor/ встроенные сторонние библиотеки runtime +templates/ темы и публичные шаблоны +tmp/ кеш и временные файлы +uploads/ пользовательские медиафайлы +index.php публичная точка входа +``` + +Публичный запрос проходит через `index.php` и `App\Frontend\PublicKernel`. +Панель использует то же ядро, но имеет собственную точку входа и не определяет +работу публичной части. Проект использует собственный загрузчик и встроенные +библиотеки runtime: для установки готовой версии Composer и Node.js не нужны. +Новый PHP-код должен сохранять совместимость с PHP 7.3. Поддерживаемые точки +расширения описаны в разделах [HTTP API](help/api/README.md), +[хуки и события](help/hooks/README.md) и +[модульная система](help/modules/README.md). + +## Документация + +Начальная точка: [help/README.md](help/README.md). + +- [управление системой](help/administration/README.md); +- [рубрики, документы, запросы и шаблоны](help/content/README.md); +- [поля документов](help/fields/README.md); +- [медиа и миниатюры](help/media/README.md); +- [модульная система](help/modules/README.md); +- [публичная отладка](help/debug/README.md); +- [безопасность](help/security/README.md); +- [работа с базой данных](help/database/README.md); +- [English documentation](help/en/README.md). + +## Безопасность + +- Не публикуйте `configs/db.config.php`, секреты модулей и содержимое + `storage/`. +- Оставляйте публичный Debug Toolbar доступным только доверенным + администраторам. +- Устанавливайте модули и обновления только из доверенного источника. +- Для Nginx вручную перенесите правила защиты `uploads/` и `modules/`, + приведённые в инструкции по установке. +- Перед обновлением рабочей системы создавайте проверенную резервную копию. + +Об уязвимостях сообщайте непублично на `support@ave-cms.ru`. + +## Лицензия + +AVE.cms распространяется по лицензии GNU General Public License версии 2 или +любой более поздней версии по вашему выбору (`GPL-2.0-or-later`). +Полный текст находится в [license.txt](license.txt). + +Официальный сайт: [ave-cms.ru](https://ave-cms.ru/) diff --git a/adminx/.htaccess b/adminx/.htaccess new file mode 100644 index 0000000..34f5c6f --- /dev/null +++ b/adminx/.htaccess @@ -0,0 +1,27 @@ +###################### ADMINX_HTACCESS_BEGIN ########################## +# Роутинг панели: все несуществующие пути внутри текущего каталога заворачиваем +# на index.php (чистые URL). Реальные файлы (assets, css, js) отдаются +# напрямую. Правила подкаталога перекрывают корневой .htaccess для этого дерева. + +Options -Indexes +FollowSymLinks + + + RewriteEngine On + # Only the front controller may be executed over HTTP. Module PHP files are + # include-only; their assets remain available through the file rule below. + RewriteRule ^(?!index\.php$).*\.php$ - [R=404,L] + + # //modules совпадает с физической директорией модулей. + # Сам экран менеджера должен попасть во front controller, а вложенные + # modules//assets/* ниже по-прежнему отдаются как реальные файлы. + RewriteRule ^modules/?$ index.php [L,QSA] + + # Реальные файлы и директории — напрямую (css/js/картинки/аватары). + RewriteCond %{REQUEST_FILENAME} -f [OR] + RewriteCond %{REQUEST_FILENAME} -d + RewriteRule ^ - [L] + + # Всё остальное — во фронт-контроллер админки. + RewriteRule . index.php [L,QSA] + +####################### ADMINX_HTACCESS_END ########################### diff --git a/adminx/Support/AdminLocale.php b/adminx/Support/AdminLocale.php new file mode 100644 index 0000000..11b87ce --- /dev/null +++ b/adminx/Support/AdminLocale.php @@ -0,0 +1,342 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\Language; + use App\Common\Session; + use App\Common\Settings; + + /** + * Независимая локаль интерфейса Adminx. + * + * Публичный сайт остаётся одноязычным. Выбор панели хранится отдельно в + * admin_language и не изменяет PUBLIC_LANGUAGE/current_language. + */ + class AdminLocale + { + const DEFAULT_LOCALE = 'ru'; + const SESSION_KEY = 'admin_language'; + + protected function __construct() + { + } + + public static function current() + { + $locale = strtolower(trim((string) Session::get(self::SESSION_KEY))); + if (self::supports($locale)) { + return $locale; + } + + try { + $locale = strtolower(trim((string) Settings::get('admin_default_language', self::DEFAULT_LOCALE))); + } catch (\Throwable $e) { + $locale = self::DEFAULT_LOCALE; + } + + return self::supports($locale) ? $locale : self::DEFAULT_LOCALE; + } + + public static function set($locale) + { + $locale = strtolower(trim((string) $locale)); + if (!self::supports($locale)) { + return false; + } + + Session::set(self::SESSION_KEY, $locale); + return true; + } + + public static function supports($locale) + { + $locale = strtolower(trim((string) $locale)); + return $locale !== '' && isset(self::locales()[$locale]); + } + + /** + * Доступные языки определяются физическими словарями каркаса. + */ + public static function locales() + { + $labels = array( + 'ru' => 'Русский', + 'en' => 'English', + ); + $locales = array(); + $root = rtrim(ADMINX_PATH, '/\\') . DS . 'language'; + $directories = is_dir($root) ? glob($root . DS . '*', GLOB_ONLYDIR) : array(); + + foreach ((array) $directories as $directory) { + $code = strtolower((string) basename($directory)); + if (!preg_match('/^[a-z]{2}(?:-[a-z]{2})?$/', $code) + || !glob($directory . DS . '*.xml')) { + continue; + } + + $locales[$code] = isset($labels[$code]) ? $labels[$code] : strtoupper($code); + } + + if (!isset($locales[self::DEFAULT_LOCALE])) { + $locales[self::DEFAULT_LOCALE] = $labels[self::DEFAULT_LOCALE]; + } + + return $locales; + } + + /** + * Загрузить словари каркаса и зарегистрированных admin-модулей. + * + * Language сохраняет первый загруженный ключ, поэтому сначала идёт + * выбранная локаль, затем русский fallback только для отсутствующих фраз. + */ + public static function boot(array $modules = array()) + { + Language::reset(); + $locale = self::current(); + $selected = self::readLocale($locale, $modules); + $source = $locale === self::DEFAULT_LOCALE + ? $selected + : self::readLocale(self::DEFAULT_LOCALE, $modules); + + Language::merge($selected); + if ($locale !== self::DEFAULT_LOCALE) { + Language::merge($source); + } + + Language::$lang = $locale; + Language::setSourceTranslations(self::buildSourceTranslations($locale, $source, $selected)); + } + + public static function text($key, $fallback = '') + { + return Language::has($key) ? (string) Language::get($key) : (string) $fallback; + } + + + /** + * Перевести статичный фрагмент шаблона или служебное сообщение. + * + * Метод вызывается только для исходного текста интерфейса. Динамические + * названия документов, товаров и другие пользовательские данные через + * него не проходят. + */ + public static function translateMarkup($markup) + { + return Language::translateSource((string) $markup); + } + + + public static function sourceTranslations() + { + return Language::sourceTranslations(); + } + + + /** + * Небольшой словарь стабильных ключей, которые читает общий JavaScript. + */ + public static function clientDictionary() + { + $keys = array( + 'btn_cancel', + 'btn_close', + 'btn_confirm', + 'btn_logout', + 'confirm_action', + 'confirm_title', + 'field_current_password', + 'help_load_error', + 'help_loading', + 'help_not_found', + 'help_title', + 'logout_message', + 'logout_title', + 'reauth_error', + 'reauth_hint', + 'reauth_reason', + 'reauth_subtitle', + 'reauth_title', + 'server_unavailable', + 'tag_copied', + ); + $dictionary = array(); + + foreach ($keys as $key) { + if (Language::has($key)) { + $dictionary[$key] = (string) Language::get($key); + } + } + + return $dictionary; + } + + + /** + * Исходные фразы только из client.xml загруженных модулей. + * + * Полный серверный словарь не встраивается в каждую HTML-страницу. + */ + public static function clientSourceTranslations(array $modules = array()) + { + $locale = self::current(); + if ($locale === self::DEFAULT_LOCALE) { + return array(); + } + + $source = self::readClientLocale(self::DEFAULT_LOCALE, $modules); + $target = self::readClientLocale($locale, $modules); + return self::buildSourceTranslations($locale, $source, $target); + } + + /** + * Навигация переводится по стабильному коду, а не исходной подписи. + */ + public static function translateNavigation(array $items) + { + foreach ($items as &$item) { + $code = isset($item['code']) ? (string) $item['code'] : ''; + if ($code !== '') { + $fallback = isset($item['label']) ? $item['label'] : $code; + $item['label'] = self::text('nav_' . $code, self::translateMarkup($fallback)); + } + + $group = isset($item['group']) ? (string) $item['group'] : ''; + if ($group !== '') { + $item['group'] = self::text('nav_group_' . self::groupKey($group), self::translateMarkup($group)); + } + } + + unset($item); + + return $items; + } + + protected static function buildSourceTranslations($locale, array $source, array $target) + { + if ($locale === self::DEFAULT_LOCALE) { + return array(); + } + + $translations = array(); + + foreach ($source as $key => $value) { + if ($value === '' || !isset($target[$key]) || $target[$key] === '' || $target[$key] === $value) { + continue; + } + + if (!isset($translations[$value])) { + $translations[$value] = $target[$key]; + } + } + + return $translations; + } + + + protected static function readLocale($locale, array $modules) + { + $directories = array( + rtrim(ADMINX_PATH, '/\\') . DS . 'language' . DS . $locale, + ); + + foreach ($modules as $module) { + $basePath = isset($module['base_path']) ? (string) $module['base_path'] : ''; + if ($basePath !== '') { + $directories[] = rtrim($basePath, '/\\') . DS . 'language' . DS . $locale; + } + } + + $phrases = array(); + foreach ($directories as $directory) { + $files = is_dir($directory) ? glob(rtrim($directory, '/\\') . DS . '*.xml') : array(); + sort($files, SORT_STRING); + + foreach ($files as $file) { + $xml = @simplexml_load_file($file); + if (!$xml) { + continue; + } + + foreach ($xml as $phrase) { + $key = trim((string) $phrase['data']); + if ($key !== '' && !isset($phrases[$key])) { + $phrases[$key] = (string) $phrase; + } + } + } + } + + return $phrases; + } + + + protected static function readClientLocale($locale, array $modules) + { + $files = array( + rtrim(ADMINX_PATH, '/\\') . DS . 'language' . DS . $locale . DS . 'client.xml', + ); + + foreach ($modules as $module) { + $basePath = isset($module['base_path']) ? (string) $module['base_path'] : ''; + if ($basePath !== '') { + $files[] = rtrim($basePath, '/\\') . DS . 'language' . DS . $locale . DS . 'client.xml'; + } + } + + $phrases = array(); + foreach ($files as $file) { + if (!is_file($file)) { + continue; + } + + $xml = @simplexml_load_file($file); + if (!$xml) { + continue; + } + + foreach ($xml as $phrase) { + $key = trim((string) $phrase['data']); + if ($key !== '' && !isset($phrases[$key])) { + $phrases[$key] = (string) $phrase; + } + } + } + + return $phrases; + } + + protected static function groupKey($group) + { + $map = array( + 'Обзор' => 'overview', + 'Контент' => 'content', + 'Каталог' => 'catalog', + 'Продажи' => 'sales', + 'Аналитика' => 'analytics', + 'Интеграции' => 'integrations', + 'Система' => 'system', + 'Справка' => 'help', + 'fields' => 'fields', + 'modules_repository' => 'modules_repository', + 'site_readiness' => 'site_readiness', + 'source' => 'source', + ); + + return isset($map[$group]) ? $map[$group] : strtolower(preg_replace('/[^a-z0-9_-]+/i', '_', $group)); + } + } diff --git a/adminx/Support/CodeEditor.php b/adminx/Support/CodeEditor.php new file mode 100644 index 0000000..8d8916d --- /dev/null +++ b/adminx/Support/CodeEditor.php @@ -0,0 +1,100 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + + /** + * Shared подключение CodeMirror 5 для source/code-полей новой админки. + * + * Библиотека принадлежит Adminx и хранится рядом с его общими assets. + */ + class CodeEditor + { + protected static $codeMirrorLoaded = false; + protected static $tiptapLoaded = false; + + public static function useCodeMirror($mode = 'htmlmixed') + { + $mode = (string) $mode; + $base = ADMINX_BASE . '/assets/vendor/codemirror'; + + if (!self::$codeMirrorLoaded) { + AdminAssets::addStyle($base . '/lib/codemirror.css', 0); + AdminAssets::addStyle($base . '/addon/dialog/dialog.css', 0); + + if (defined('CODEMIRROR_THEME') && CODEMIRROR_THEME !== '' && CODEMIRROR_THEME !== 'default') { + AdminAssets::addStyle($base . '/theme/' . rawurlencode(CODEMIRROR_THEME) . '.css', 0); + } + + AdminAssets::addScript($base . '/lib/codemirror.js', 20); + AdminAssets::addScript($base . '/mode/xml/xml.js', 21); + AdminAssets::addScript($base . '/mode/javascript/javascript.js', 21); + AdminAssets::addScript($base . '/mode/css/css.js', 21); + AdminAssets::addScript($base . '/mode/clike/clike.js', 21); + AdminAssets::addScript($base . '/mode/php/php.js', 21); + AdminAssets::addScript($base . '/mode/smarty/smarty.js', 21); + AdminAssets::addScript($base . '/mode/smartymixed/smartymixed.js', 21); + AdminAssets::addScript($base . '/mode/htmlmixed/htmlmixed.js', 21); + AdminAssets::addScript($base . '/mode/sql/sql.js', 21); + AdminAssets::addScript($base . '/addon/edit/closetag.js', 22); + AdminAssets::addScript($base . '/addon/edit/matchbrackets.js', 22); + AdminAssets::addScript($base . '/addon/selection/active-line.js', 22); + AdminAssets::addScript($base . '/addon/dialog/dialog.js', 22); + AdminAssets::addScript($base . '/addon/search/searchcursor.js', 22); + AdminAssets::addScript($base . '/addon/search/search.js', 22); + + AdminAssets::addScript(ADMINX_BASE . '/assets/js/editor-codemirror.js', 30); + self::$codeMirrorLoaded = true; + } + + if ($mode !== '') { + self::useMode($mode); + } + } + + public static function useTiptap() + { + if (self::$tiptapLoaded) { + return; + } + + AdminAssets::addScript(ADMINX_BASE . '/assets/js/editor-tiptap.js', 31); + self::$tiptapLoaded = true; + } + + public static function useRichEditor() + { + self::useCodeMirror('htmlmixed'); + self::useTiptap(); + } + + protected static function useMode($mode) + { + $base = ADMINX_BASE . '/assets/vendor/codemirror'; + if ($mode === 'text/css') { + AdminAssets::addScript($base . '/mode/css/css.js', 21); + } elseif ($mode === 'text/javascript' || $mode === 'application/json') { + AdminAssets::addScript($base . '/mode/javascript/javascript.js', 21); + } elseif ($mode === 'application/x-httpd-php' || $mode === 'php') { + AdminAssets::addScript($base . '/mode/php/php.js', 21); + } elseif ($mode === 'text/x-sql' || $mode === 'sql') { + AdminAssets::addScript($base . '/mode/sql/sql.js', 21); + } + } + + } diff --git a/adminx/Support/InterfaceSettings.php b/adminx/Support/InterfaceSettings.php new file mode 100644 index 0000000..f68a3af --- /dev/null +++ b/adminx/Support/InterfaceSettings.php @@ -0,0 +1,235 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\ModuleSettings; + use App\Common\Settings; + + /** Adminx-only order and visibility settings for navigation and dashboard. */ + class InterfaceSettings + { + const MODULE = 'adminx_interface'; + + public static function navigationCatalog(array $items) + { + return self::navigationHierarchy(self::arrange('navigation', self::prepare($items, 'code'), false)); + } + + public static function applyNavigation(array $items) + { + return self::navigationHierarchy(self::arrange('navigation', self::prepare($items, 'code'), true)); + } + + public static function dashboardCatalog(array $items) + { + return self::arrange('dashboard', self::prepare($items, 'layout_code'), false); + } + + public static function applyDashboard(array $items) + { + return self::arrange('dashboard', self::prepare($items, 'layout_code'), true); + } + + public static function saveNavigation(array $rows, array $available) + { + return self::save('navigation', $rows, $available); + } + + public static function saveDashboard(array $rows, array $available) + { + $saved = self::save('dashboard', $rows, $available); + $byCode = array(); + foreach ($available as $item) { + if (!empty($item['layout_code'])) { + $byCode[(string) $item['layout_code']] = $item; + } + } + + $moduleVisibility = array(); + foreach ($saved as $row) { + $code = (string) $row['code']; + if (isset($byCode[$code]) && !empty($byCode[$code]['module_code'])) { + $moduleCode = (string) $byCode[$code]['module_code']; + $moduleVisibility[$moduleCode] = !empty($moduleVisibility[$moduleCode]) || !empty($row['visible']); + } + } + + foreach ($moduleVisibility as $moduleCode => $visible) { + ModuleSettings::set('adminx_dashboard_enabled', $visible, $moduleCode, 'bool'); + } + + return $saved; + } + + protected static function prepare(array $items, $codeKey) + { + $prepared = array(); + foreach ($items as $index => $item) { + if (!is_array($item) || empty($item[$codeKey])) { + continue; + } + + $item['layout_code'] = (string) $item[$codeKey]; + if (!isset($item['sort_order'])) { + $item['sort_order'] = ($index + 1) * 10; + } + + if (!isset($item['default_visible'])) { + $item['default_visible'] = true; + } + + $prepared[] = $item; + } + + return $prepared; + } + + protected static function arrange($scope, array $items, $visibleOnly) + { + $config = self::config($scope); + $position = array(); + $visibility = array(); + foreach ($config as $index => $row) { + $position[$row['code']] = $index; + $visibility[$row['code']] = !empty($row['visible']); + } + + foreach ($items as &$item) { + $code = (string) $item['layout_code']; + $item['layout_visible'] = isset($visibility[$code]) + ? $visibility[$code] + : !empty($item['default_visible']); + $item['_layout_position'] = isset($position[$code]) ? $position[$code] : null; + } + + unset($item); + + usort($items, function ($a, $b) { + $ap = $a['_layout_position']; + $bp = $b['_layout_position']; + if ($ap !== null || $bp !== null) { + if ($ap === null) return 1; + if ($bp === null) return -1; + if ($ap !== $bp) return $ap < $bp ? -1 : 1; + } + + $as = isset($a['sort_order']) ? (int) $a['sort_order'] : 1000; + $bs = isset($b['sort_order']) ? (int) $b['sort_order'] : 1000; + if ($as !== $bs) return $as < $bs ? -1 : 1; + return strcmp((string) $a['layout_code'], (string) $b['layout_code']); + }); + + foreach ($items as &$item) { + unset($item['_layout_position']); + } + + unset($item); + + if (!$visibleOnly) { + return $items; + } + + return array_values(array_filter($items, function ($item) { + return !empty($item['layout_visible']); + })); + } + + protected static function navigationHierarchy(array $items) + { + $roots = array(); + $children = array(); + foreach ($items as $item) { + $parent = isset($item['parent']) ? (string) $item['parent'] : ''; + if ($parent === '') { + $roots[] = $item; + } else { + if (!isset($children[$parent])) { + $children[$parent] = array(); + } + + $children[$parent][] = $item; + } + } + + $result = array(); + foreach ($roots as $root) { + $result[] = $root; + $code = (string) $root['layout_code']; + if (isset($children[$code])) { + foreach ($children[$code] as $child) { + $result[] = $child; + } + } + } + + return $result; + } + + protected static function save($scope, array $rows, array $available) + { + $allowed = array(); + foreach ($available as $item) { + $code = isset($item['layout_code']) ? (string) $item['layout_code'] : (isset($item['code']) ? (string) $item['code'] : ''); + if ($code !== '') { + $allowed[$code] = true; + } + } + + $result = array(); + $seen = array(); + foreach ($rows as $row) { + $code = is_array($row) && isset($row['code']) ? trim((string) $row['code']) : ''; + if ($code === '' || !isset($allowed[$code]) || isset($seen[$code])) { + continue; + } + + $seen[$code] = true; + $result[] = array('code' => $code, 'visible' => !empty($row['visible'])); + } + + foreach (array_keys($allowed) as $code) { + if (!isset($seen[$code])) { + $result[] = array('code' => $code, 'visible' => true); + } + } + + Settings::set(self::key($scope), $result, 'json'); + return $result; + } + + protected static function config($scope) + { + $value = Settings::get(self::key($scope), array()); + if (!is_array($value)) { + return array(); + } + + $result = array(); + foreach ($value as $row) { + if (is_array($row) && !empty($row['code'])) { + $result[] = array('code' => (string) $row['code'], 'visible' => !empty($row['visible'])); + } + } + + return $result; + } + + protected static function key($scope) + { + return self::MODULE . '.' . (string) $scope; + } + } diff --git a/adminx/Support/ModuleExtensions.php b/adminx/Support/ModuleExtensions.php new file mode 100644 index 0000000..46da5a6 --- /dev/null +++ b/adminx/Support/ModuleExtensions.php @@ -0,0 +1,293 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\ModuleManager; + use App\Common\ModuleSettings; + use App\Common\Navigation; + use App\Common\Permission; + + /** + * UI-реестр adminx-модулей. + * + * Ядро хранит neutral descriptor, а этот слой подключает только adminx-вклады: + * меню, действия шапки, dashboard-виджеты и уведомления. + */ + class ModuleExtensions + { + protected static $booted = false; + protected static $extensions = array(); + + public static function boot() + { + if (self::$booted) { + return; + } + + self::$booted = true; + + foreach (ModuleManager::all() as $module) { + if (empty($module['admin_extension']) || !is_array($module['admin_extension'])) { + continue; + } + + self::$extensions[$module['code']] = array( + 'module' => $module, + 'config' => $module['admin_extension'], + ); + if (!empty($module['installed']) && !empty($module['enabled'])) { + self::registerMenu($module['admin_extension']); + } + } + + } + + public static function headerActions() + { + return self::contributions('header'); + } + + public static function dashboardWidgets(array $visibleCodes = null) + { + return self::contributions('dashboard', $visibleCodes); + } + + /** Dashboard descriptors without running providers; used by interface settings. */ + public static function dashboardDefinitions() + { + self::boot(); + $out = array(); + foreach (self::$extensions as $code => $extension) { + $module = self::currentModule($code, $extension['module']); + if (empty($module['installed']) || empty($module['enabled'])) { + continue; + } + + $config = $extension['config']; + if (empty($config['dashboard'])) { + continue; + } + + foreach (self::normalizeItems($config['dashboard']) as $index => $item) { + if (!is_array($item)) { + continue; + } + + $out[] = array( + 'layout_code' => self::contributionCode($code, 'dashboard', $item, $index), + 'module_code' => $code, + 'label' => AdminLocale::translateMarkup(isset($item['label']) ? (string) $item['label'] : (isset($module['name']) ? (string) $module['name'] : $code)), + 'description' => AdminLocale::translateMarkup(isset($item['description']) ? (string) $item['description'] : (isset($module['description']) ? (string) $module['description'] : '')), + 'icon' => isset($item['icon']) ? (string) $item['icon'] : (isset($config['icon']) ? (string) $config['icon'] : 'ti ti-layout-dashboard'), + 'sort_order' => 100 + (isset($item['sort_order']) ? (int) $item['sort_order'] : 100), + 'default_visible' => self::presentationEnabled($code, 'dashboard'), + ); + } + } + + return $out; + } + + public static function notifications($base, array $summary) + { + foreach (self::contributions('notifications') as $contribution) { + $data = isset($contribution['data']) ? $contribution['data'] : array(); + $items = isset($data['items']) && is_array($data['items']) ? $data['items'] : (self::isList($data) ? $data : array()); + foreach ($items as $item) { + if (!is_array($item) || empty($item['title'])) { + continue; + } + + $count = isset($item['count']) ? max(0, (int) $item['count']) : 1; + if ($count < 1) { + continue; + } + + $url = isset($item['url']) ? (string) $item['url'] : ''; + if ($url !== '' && $url[0] === '/') { + $url = rtrim((string) $base, '/') . $url; + } + + $summary['items'][] = array_merge(array( + 'icon' => 'ti ti-bell', + 'bg' => '#e8f1ff', + 'fg' => '#2563eb', + 'text' => '', + 'url' => $url, + 'count' => $count, + ), $item, array( + 'url' => $url, + 'count' => $count, + 'text' => AdminLocale::translateMarkup(isset($item['text']) ? (string) $item['text'] : ''), + 'title' => AdminLocale::translateMarkup(isset($item['title']) ? (string) $item['title'] : ''), + )); + $summary['total'] += $count; + if (!empty($item['key'])) { + $key = (string) $item['key']; + $summary[$key] = isset($summary[$key]) ? (int) $summary[$key] + $count : $count; + } + } + } + + return $summary; + } + + public static function catalog() + { + self::boot(); + $items = array(); + foreach (self::$extensions as $code => $extension) { + $module = self::currentModule($code, $extension['module']); + $config = $extension['config']; + $items[] = array( + 'code' => $code, + 'name' => AdminLocale::translateMarkup(isset($module['name']) ? $module['name'] : $code), + 'description' => AdminLocale::translateMarkup(isset($module['description']) ? $module['description'] : ''), + 'author' => isset($module['author']) ? $module['author'] : '', + 'admin_url' => isset($config['url']) ? (string) $config['url'] : '', + 'feature' => isset($config['feature']) ? (string) $config['feature'] : '', + 'icon' => isset($config['icon']) ? (string) $config['icon'] : 'ti ti-puzzle', + 'module' => $module, + ); + } + + return $items; + } + + protected static function contributions($type, array $visibleCodes = null) + { + self::boot(); + $out = array(); + $visible = $visibleCodes === null ? null : array_fill_keys($visibleCodes, true); + foreach (self::$extensions as $code => $extension) { + $module = self::currentModule($code, $extension['module']); + if (empty($module['installed']) || empty($module['enabled'])) { + continue; + } + + $config = $extension['config']; + if (empty($config[$type])) { + continue; + } + + if (($type === 'header' || $type === 'dashboard') && !self::presentationEnabled($code, $type)) { + continue; + } + + foreach (self::normalizeItems($config[$type]) as $index => $item) { + if (!is_array($item) || !self::allowed($item)) { + continue; + } + + $layoutCode = self::contributionCode($code, $type, $item, $index); + if ($visible !== null && !isset($visible[$layoutCode])) { + continue; + } + + try { + $data = isset($item['provider']) && is_callable($item['provider']) + ? call_user_func($item['provider']) + : (isset($item['data']) ? $item['data'] : array()); + } catch (\Throwable $e) { + $data = array(); + } + + $item['module_code'] = $code; + $item['layout_code'] = $layoutCode; + $item['label'] = AdminLocale::translateMarkup(isset($item['label']) ? (string) $item['label'] : (isset($module['name']) ? (string) $module['name'] : $code)); + if (isset($item['description'])) { + $item['description'] = AdminLocale::translateMarkup((string) $item['description']); + } + + $item['icon'] = isset($item['icon']) ? (string) $item['icon'] : (isset($config['icon']) ? (string) $config['icon'] : 'ti ti-layout-dashboard'); + $item['data'] = is_array($data) ? $data : array(); + $item['sort_order'] = isset($item['sort_order']) ? (int) $item['sort_order'] : 100; + $out[] = $item; + } + } + + usort($out, function ($a, $b) { + return $a['sort_order'] <=> $b['sort_order']; + }); + return $out; + } + + protected static function contributionCode($moduleCode, $type, array $item, $index) + { + $suffix = isset($item['code']) && trim((string) $item['code']) !== '' + ? trim((string) $item['code']) + : (string) ((int) $index + 1); + return 'module.' . (string) $moduleCode . '.' . (string) $type . '.' . $suffix; + } + + protected static function registerMenu(array $config) + { + if (empty($config['menu'])) { + return; + } + + $items = is_callable($config['menu']) ? call_user_func($config['menu']) : $config['menu']; + if (is_array($items) && !self::isList($items)) { + $items = array($items); + } + + if (is_array($items)) { + Navigation::addMany($items); + } + } + + protected static function normalizeItems($items) + { + if (!is_array($items)) { + return array(); + } + + return self::isList($items) ? $items : array($items); + } + + protected static function isList(array $items) + { + return empty($items) || array_keys($items) === range(0, count($items) - 1); + } + + protected static function allowed(array $item) + { + $permission = isset($item['permission']) ? (string) $item['permission'] : ''; + if ($permission !== '' && !Permission::check($permission)) { + return false; + } + + return empty($item['visible']) || !is_callable($item['visible']) || (bool) call_user_func($item['visible']); + } + + protected static function currentModule($code, array $fallback) + { + foreach (ModuleManager::all() as $module) { + if ($module['code'] === (string) $code) { + return $module; + } + } + + return $fallback; + } + + /** Управляемая администратором видимость UI-вклада модуля. */ + protected static function presentationEnabled($code, $type) + { + return (bool) ModuleSettings::get('adminx_' . $type . '_enabled', true, $code); + } + } diff --git a/adminx/Support/Notifications.php b/adminx/Support/Notifications.php new file mode 100644 index 0000000..b2649ea --- /dev/null +++ b/adminx/Support/Notifications.php @@ -0,0 +1,58 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\Permission; + + /** + * Пустая системная сводка. Прикладные источники подключаются владельцами + * через ModuleExtensions и исчезают вместе с модулем. + */ + class Notifications + { + /** @return array{total:int,orders:int,messages:int,items:array} */ + public static function summary($base) + { + $summary = array( + 'total' => 0, + 'orders' => 0, + 'messages' => 0, + 'items' => array(), + ); + + $sqlErrors = SystemHealth::sqlErrorCount(); + if ($sqlErrors > 0 && Permission::check('view_events')) { + $summary['total'] += $sqlErrors; + $summary['database_errors'] = $sqlErrors; + $summary['items'][] = array( + 'key' => 'database_errors', + 'title' => AdminLocale::text('notification_database_errors', 'Ошибки базы данных'), + 'text' => AdminLocale::text( + 'notification_database_errors_hint', + 'Проверьте SQL-журнал системных событий.' + ), + 'url' => rtrim((string) $base, '/') . '/events?source=sql', + 'count' => $sqlErrors, + 'icon' => 'ti ti-database-exclamation', + 'bg' => '#feecec', + 'fg' => '#dc2626', + ); + } + + return $summary; + } + } diff --git a/adminx/Support/PhpCode.php b/adminx/Support/PhpCode.php new file mode 100644 index 0000000..07293af --- /dev/null +++ b/adminx/Support/PhpCode.php @@ -0,0 +1,63 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + class PhpCode + { + public static function source($code) + { + $code = trim((string) $code); + if (preg_match('/^<\?(?:php|=)?/i', $code)) { + return $code; + } + + return " false, 'message' => 'Не удалось создать временный файл.', 'output' => '', 'line' => 0); + } + + $file = $directory . '/lint-' . bin2hex(random_bytes(8)) . '.php'; + if (@file_put_contents($file, self::source($code)) === false) { + @unlink($file); + return array('ok' => false, 'message' => 'Не удалось записать временный файл.', 'output' => '', 'line' => 0); + } + + $php = defined('PHP_BINARY') && PHP_BINARY ? PHP_BINARY : 'php'; + $output = array(); + $status = 0; + @exec(escapeshellarg($php) . ' -l ' . escapeshellarg($file) . ' 2>&1', $output, $status); + @unlink($file); + $text = trim(implode("\n", $output)); + $text = str_replace($file, 'код', $text); + $line = 0; + if (preg_match('/ on line ([0-9]+)/i', $text, $match)) { + $line = (int) $match[1]; + } + + return array( + 'ok' => $status === 0 && $text !== '', + 'message' => $status === 0 ? 'Синтаксис PHP без ошибок.' : 'В PHP-коде есть синтаксическая ошибка.', + 'output' => $text, + 'line' => $line, + ); + } + } diff --git a/adminx/Support/ReAuth.php b/adminx/Support/ReAuth.php new file mode 100644 index 0000000..fedb151 --- /dev/null +++ b/adminx/Support/ReAuth.php @@ -0,0 +1,86 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\Auth; + use App\Common\Session; + + /** Short-lived password confirmation for high-risk Adminx actions. */ + class ReAuth + { + const DEFAULT_TTL = 300; + const MAX_TTL = 900; + const SESSION_KEY = '_adminx_reauth'; + + public static function confirm($password) + { + $user = Auth::user(); + $hash = self::passwordHash($user); + if (!$user || $hash === '' || !password_verify((string) $password, $hash)) { + self::forget(); + return false; + } + + Session::set(self::SESSION_KEY, array( + 'user_id' => (int) $user['id'], + 'confirmed_at' => time(), + 'password_fingerprint' => hash('sha256', $hash), + )); + + return true; + } + + public static function valid($ttl = self::DEFAULT_TTL) + { + $user = Auth::user(); + $state = Session::get(self::SESSION_KEY); + $hash = self::passwordHash($user); + $ttl = self::normalizeTtl($ttl); + + if (!$user || !is_array($state) || $hash === '') { + return false; + } + + $valid = isset($state['user_id'], $state['confirmed_at'], $state['password_fingerprint']) + && (int) $state['user_id'] === (int) $user['id'] + && (int) $state['confirmed_at'] >= time() - $ttl + && hash_equals((string) $state['password_fingerprint'], hash('sha256', $hash)); + + if (!$valid) { + self::forget(); + } + + return $valid; + } + + public static function forget() + { + Session::del(self::SESSION_KEY); + } + + public static function normalizeTtl($ttl) + { + return max(30, min(self::MAX_TTL, (int) $ttl)); + } + + protected static function passwordHash($user) + { + return is_array($user) && isset($user['password_hash']) + ? trim((string) $user['password_hash']) + : ''; + } + } diff --git a/adminx/Support/Roles.php b/adminx/Support/Roles.php new file mode 100644 index 0000000..0272102 --- /dev/null +++ b/adminx/Support/Roles.php @@ -0,0 +1,86 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Common\SystemTables; + + /** + * Общий справочник ролей: расшифровка code → человекочитаемое имя. + * + * Источник — таблица {prefix}_roles (RBAC). Используется везде, где роль + * показывается пользователю (список/фильтры/формы), чтобы не светить голый код + * (`admin` → «Администратор»). + */ + class Roles + { + /** @var array|null кеш карты на запрос */ + protected static $map = null; + + /** Фолбэк-имена, если таблица ролей ещё не создана. */ + protected static function fallback() + { + return array( + 'admin' => 'Администратор', + 'guest' => 'Гостевая', + 'moderator' => 'Модератор', + 'user' => 'Пользователи', + ); + } + + /** Карта code => name (из БД, иначе фолбэк). */ + public static function map() + { + if (self::$map !== null) { + return self::$map; + } + + $map = array(); + try { + $exists = DB::query('SHOW TABLES LIKE %s', SystemTables::table('roles'))->getValue(); + if ($exists) { + $rows = DB::query('SELECT code, name FROM ' . SystemTables::table('roles') . ' ORDER BY is_system DESC, name ASC')->getAll(); + foreach ($rows ?: array() as $row) { + $row = (array) $row; + $code = trim((string) $row['code']); + if ($code !== '') { + $map[$code] = (string) $row['name'] !== '' ? (string) $row['name'] : $code; + } + } + } + } catch (\Throwable $e) { + $map = array(); + } + + self::$map = $map ?: self::fallback(); + return self::$map; + } + + /** Имя роли по коду (или сам код, если не найдено). */ + public static function name($code) + { + $code = (string) $code; + $map = self::map(); + return isset($map[$code]) ? $map[$code] : $code; + } + + /** Валидные коды ролей. */ + public static function codes() + { + return array_keys(self::map()); + } + } diff --git a/adminx/Support/RouteGuard.php b/adminx/Support/RouteGuard.php new file mode 100644 index 0000000..d73fcc4 --- /dev/null +++ b/adminx/Support/RouteGuard.php @@ -0,0 +1,96 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\ModuleManager; + use App\Common\Permission; + use App\Common\Session; + use App\Common\Settings; + use App\Common\Twig; + use App\Helpers\Request; + use App\Helpers\Response; + + /** Enforces route permissions and CSRF before an Adminx controller is invoked. */ + class RouteGuard + { + public static function enforce(array $route, $isPublic = false) + { + $options = isset($route['options']) && is_array($route['options']) ? $route['options'] : array(); + try { + SensitiveRoutes::validate($route); + } catch (\InvalidArgumentException $e) { + self::deny(500, 'Ошибка политики маршрута', $e->getMessage()); + } + + $permission = isset($options['permission']) ? trim((string) $options['permission']) : ''; + if (!$isPublic && $permission !== '' && !Permission::checkAcp($permission)) { + self::deny(403, 'Доступ запрещён', 'У вашей роли нет права на просмотр этого раздела.'); + } + + $method = isset($route['method']) ? strtoupper((string) $route['method']) : Request::method(); + $csrfRequired = !isset($options['csrf']) || $options['csrf'] !== false; + if ($csrfRequired && !in_array($method, array('GET', 'HEAD', 'OPTIONS'), true)) { + $token = (string) Request::header('X-CSRF-Token', ''); + if ($token === '') { + $token = Request::postStr('_csrf', ''); + } + + try { + Session::verifyCsrf($token); + } catch (\Throwable $e) { + self::deny(419, 'Сессия устарела', 'Обновите страницу и повторите действие.'); + } + } + + if (!$isPublic && !empty($options['reauth']) && (bool) Settings::get('adminx_reauth_enabled', false)) { + $config = is_array($options['reauth']) ? $options['reauth'] : array(); + $ttl = isset($config['ttl']) ? ReAuth::normalizeTtl($config['ttl']) : ReAuth::DEFAULT_TTL; + if (!ReAuth::valid($ttl)) { + $reason = isset($config['reason']) && trim((string) $config['reason']) !== '' + ? trim((string) $config['reason']) + : 'Это действие изменяет исполняемый код системы.'; + self::deny(428, 'Подтвердите пароль', 'Для продолжения подтвердите пароль текущего пользователя.', array( + 'reauth_required' => true, + 'reauth_url' => (defined('ADMINX_BASE') ? ADMINX_BASE : '') . '/reauth', + 'reason' => $reason, + 'ttl' => $ttl, + )); + } + } + } + + protected static function deny($status, $title, $message, array $data = array()) + { + $accept = strtolower((string) Request::header('Accept', '')); + if (Request::isAjax() || strpos($accept, 'application/json') !== false) { + Response::json(array( + 'success' => false, + 'message' => (string) $message, + 'data' => empty($data) ? new \stdClass() : $data, + 'errors' => new \stdClass(), + ), (int) $status); + } + + Response::setStatus((int) $status); + echo Twig::twig()->render('@adminx/error.twig', array_merge(ModuleManager::viewGlobals(), array( + 'status' => (int) $status, + 'title' => (string) $title, + 'message' => (string) $message, + ))); + Request::shutDown(); + } + } diff --git a/adminx/Support/SavedViews.php b/adminx/Support/SavedViews.php new file mode 100644 index 0000000..73b8653 --- /dev/null +++ b/adminx/Support/SavedViews.php @@ -0,0 +1,209 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\SystemTables; + use DB; + + /** Персональные именованные наборы фильтров для рабочих списков Adminx. */ + class SavedViews + { + const MAX_VIEWS = 20; + + public static function all($scope, $userId, array $allowedFields) + { + $scope = self::scope($scope); + $userId = self::userId($userId); + $rows = DB::query( + 'SELECT id,title,filters_json,updated_at FROM ' . self::table() . ' WHERE user_id=%i AND scope=%s ORDER BY title,id', + $userId, + $scope + )->getAll(); + + $result = array(); + foreach ((array) $rows as $row) { + $id = is_array($row) && isset($row['id']) ? self::id($row['id']) : ''; + $title = is_array($row) && isset($row['title']) ? self::title($row['title']) : ''; + if ($id === '' || $title === '') { + continue; + } + + $filters = json_decode(isset($row['filters_json']) ? (string) $row['filters_json'] : '{}', true); + $result[] = array( + 'id' => $id, + 'title' => $title, + 'filters' => self::filters(is_array($filters) ? $filters : array(), $allowedFields), + 'updated_at' => isset($row['updated_at']) ? (int) $row['updated_at'] : 0, + ); + } + + return array_slice($result, 0, self::MAX_VIEWS); + } + + public static function save($scope, $userId, $title, array $filters, array $allowedFields) + { + $title = self::title($title); + if ($title === '') { + throw new \InvalidArgumentException('Укажите название представления'); + } + + $scope = self::scope($scope); + $userId = self::userId($userId); + $rows = self::all($scope, $userId, $allowedFields); + $normalized = self::filters($filters, $allowedFields); + $matchedId = ''; + foreach ($rows as $row) { + if (mb_strtolower($row['title'], 'UTF-8') !== mb_strtolower($title, 'UTF-8')) { + continue; + } + + $matchedId = (string) $row['id']; + break; + } + + $data = array( + 'title' => $title, + 'filters_json' => self::json($normalized), + 'updated_at' => time(), + ); + if ($matchedId !== '') { + DB::Update(self::table(), $data, 'id=%s AND user_id=%i AND scope=%s', $matchedId, $userId, $scope); + } else { + if (count($rows) >= self::MAX_VIEWS) { + throw new \InvalidArgumentException('Можно сохранить не более ' . self::MAX_VIEWS . ' представлений'); + } + + DB::Insert(self::table(), array_merge($data, array( + 'id' => self::newId(), + 'user_id' => $userId, + 'scope' => $scope, + ))); + } + + return self::all($scope, $userId, $allowedFields); + } + + public static function delete($scope, $userId, $id, array $allowedFields) + { + $id = self::id($id); + if ($id === '') { + throw new \InvalidArgumentException('Представление не найдено'); + } + + $scope = self::scope($scope); + $userId = self::userId($userId); + $exists = (int) DB::query( + 'SELECT COUNT(*) FROM ' . self::table() . ' WHERE id=%s AND user_id=%i AND scope=%s', + $id, + $userId, + $scope + )->getValue(); + if (!$exists) { + throw new \InvalidArgumentException('Представление не найдено'); + } + + DB::Delete(self::table(), 'id=%s AND user_id=%i AND scope=%s', $id, $userId, $scope); + return self::all($scope, $userId, $allowedFields); + } + + protected static function filters($filters, array $allowedFields) + { + if (!is_array($filters)) { + return array(); + } + + $allowed = array_fill_keys($allowedFields, true); + $result = array(); + foreach ($filters as $name => $value) { + $name = (string) $name; + if (!isset($allowed[$name]) || is_array($value) || is_object($value)) { + continue; + } + + $value = trim((string) $value); + if (mb_strlen($value, 'UTF-8') > 500) { + $value = mb_substr($value, 0, 500, 'UTF-8'); + } + + $result[$name] = $value; + } + + foreach ($allowedFields as $name) { + if (!array_key_exists($name, $result)) { + $result[$name] = ''; + } + } + + return $result; + } + + protected static function table() + { + return SystemTables::table('admin_saved_views'); + } + + protected static function scope($scope) + { + $scope = strtolower(trim((string) $scope)); + if (!preg_match('/^[a-z0-9_.-]{2,64}$/', $scope)) { + throw new \InvalidArgumentException('Некорректная область представления'); + } + + return $scope; + } + + protected static function userId($userId) + { + $userId = (int) $userId; + if ($userId <= 0) { + throw new \InvalidArgumentException('Пользователь не определён'); + } + + return $userId; + } + + protected static function title($title) + { + $title = preg_replace('/\s+/u', ' ', trim((string) $title)); + return mb_substr($title, 0, 60, 'UTF-8'); + } + + protected static function id($id) + { + $id = strtolower(trim((string) $id)); + return preg_match('/^[a-f0-9]{12}$/', $id) ? $id : ''; + } + + protected static function newId() + { + try { + return bin2hex(random_bytes(6)); + } catch (\Throwable $e) { + return substr(sha1(uniqid('', true) . mt_rand()), 0, 12); + } + } + + protected static function json(array $value) + { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new \InvalidArgumentException('Не удалось подготовить фильтры'); + } + + return $json; + } + } diff --git a/adminx/Support/SectionHelp.php b/adminx/Support/SectionHelp.php new file mode 100644 index 0000000..95796fe --- /dev/null +++ b/adminx/Support/SectionHelp.php @@ -0,0 +1,175 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + /** Contextual help shown on Adminx section pages. */ + class SectionHelp + { + public static function forPath($path) + { + $path = '/' . trim((string) $path, '/'); + $moduleHelp = self::moduleDefinition($path); + if ($moduleHelp !== null) { + return $moduleHelp; + } + + foreach (self::definitions() as $definition) { + if (preg_match($definition['pattern'], $path)) { + unset($definition['pattern']); + return $definition; + } + } + + return null; + } + + protected static function moduleDefinition($path) + { + foreach (\App\Common\ModuleManager::all() as $module) { + $extension = isset($module['admin_extension']) && is_array($module['admin_extension']) ? $module['admin_extension'] : array(); + $url = isset($extension['url']) ? '/' . trim((string) $extension['url'], '/') : ''; + $help = isset($extension['help']) && is_array($extension['help']) ? $extension['help'] : array(); + if ($url === '' || !$help || ($path !== $url && strpos($path, $url . '/') !== 0)) { + continue; + } + + $title = trim(isset($help['title']) ? (string) $help['title'] : (isset($module['name']) ? (string) $module['name'] : '')); + $description = trim(isset($help['description']) ? (string) $help['description'] : ''); + $items = array(); + foreach (isset($help['items']) && is_array($help['items']) ? $help['items'] : array() as $item) { + $item = trim((string) $item); + if ($item !== '') { + $items[] = $item; + } + } + + if ($title === '' || $description === '' || !$items) { + return null; + } + + return array( + 'title' => AdminLocale::translateMarkup($title), + 'description' => AdminLocale::translateMarkup($description), + 'items' => array_map(array(AdminLocale::class, 'translateMarkup'), $items), + 'note' => AdminLocale::translateMarkup(trim(isset($help['note']) ? (string) $help['note'] : '')), + 'doc' => trim(isset($help['doc']) ? (string) $help['doc'] : ''), + ); + } + + return null; + } + + protected static function definitions() + { + return array( + self::item('#^/documents/redirects$#', 'Редиректы документов', 'Управляет старыми адресами страниц и перенаправляет посетителей на актуальные документы.', array('Ищите редирект по старому URL, названию или ID документа.', 'Коды 301/308 подходят для постоянного переноса, 302/307 — для временного.', 'Перед удалением убедитесь, что старый адрес больше не используется во внешних ссылках.'), 'Каждый старый URL должен быть уникальным и вести на существующий документ.'), + self::item('#^/documents/views$#', 'Статистика просмотров', 'Показывает посещаемость документов по дням и помогает находить наиболее востребованные страницы.', array('Период влияет только на подневную статистику.', 'Общий счётчик документа хранится отдельно и не очищается вместе с дневными данными.', 'Очистка статистики необратима и предназначена для обслуживания.'), 'Для бизнес-аналитики используйте внешнюю систему аналитики; здесь хранится внутренняя статистика CMS.'), + self::item('#^/documents/(?:create|\d+/edit)$#', 'Редактор документа', 'Создаёт и изменяет страницу сайта: её адрес, публикацию, SEO и значения полей выбранной рубрики.', array('Набор полей и их группы задаются рубрикой.', 'Изменение alias может автоматически создать редирект со старого URL.', 'Ctrl+S сохраняет изменения и оставляет редактор открытым; ревизии позволяют вернуться к прошлой версии.'), 'Смена рубрики существующего документа запрещена без отдельной миграции полей.'), + self::item('#^/documents#', 'Документы', 'Основной реестр страниц и товарных карточек сайта, построенных на рубриках и их полях.', array('Фильтруйте документы по рубрике, состоянию и названию.', 'Публикацию, копирование и удаление можно выполнять из списка или пакетно.', 'Удалённый документ сначала попадает в корзину и только затем может быть удалён окончательно.'), 'Документ №1 и системная страница 404 защищены от окончательного удаления.', 'content/documents'), + + self::item('#^/catalog/products/shipping$#', 'Доставка и упаковка', 'Показывает готовность товарных данных к расчёту доставки и позволяет редактировать грузовые места без открытия документа.', array('Каждое грузовое место хранит количество, вес и три габарита.', 'Расчёт включается отдельно и использует только полностью заполненную упаковку.', 'Фильтр помогает быстро найти товары без размеров или с выключенным расчётом.'), 'Вес и размеры указываются для одного грузового места; количество умножает итоговые показатели.'), + self::item('#^/catalog/products(?:/.*)?$#', 'Товары каталога', 'Показывает товарные документы в быстром индексе каталога: цены, остатки, изображения и категории.', array('Переиндексация обновляет проекцию товара из полей документа.', 'Редактор товара остаётся редактором документа, но открывается из каталога.', 'Фильтры списка не меняют данные товара.'), 'После изменения каталоговых полей индекс обычно обновляется автоматически.'), + self::item('#^/catalog/quick-edit$#', 'Быстрое заполнение товаров', 'Позволяет массово уточнять основные товарные данные прямо в таблице без открытия каждого документа.', array('Изменённая ячейка сохраняется отдельно по AJAX.', 'Фильтры помогают отобрать товары без артикула, цены или обязательных признаков.', 'Состояние строки показывает публикацию и доступность товара.'), 'Используйте редактор документа для полей, которых нет в таблице быстрого заполнения.'), + self::item('#^/catalog/variant-groups(?:/.*)?$#', 'Группы вариантов', 'Объединяет отдельные товары с собственными URL и артикулами в одну карточку публичного листинга.', array('Основной вариант представляет группу до выбора цвета или исполнения.', 'Порядок вариантов определяет порядок переключателей на публичной карточке.', 'Выключенный вариант остаётся документом, но не предлагается покупателю.'), 'Каждый вариант должен сохранять собственные цену, остаток, URL и артикул.'), + self::item('#^/catalog/card-templates$#', 'Карточки товаров', 'Управляет Twig-разметкой и CSS товарной карточки отдельно для разных мест публичного сайта.', array('Черновик можно проверять на живых данных товара без публикации.', 'Контексты назначают представление главной странице, каталогу и другим листингам.', 'Ревизии сохраняют историю черновиков и опубликованных версий.'), 'Публичный сайт меняется только после публикации представления и включения нативного режима нужного контекста.'), + self::item('#^/catalog/filter-templates$#', 'Шаблоны фильтров', 'Управляет Twig-разметкой, CSS и клиентским поведением фасетов товарного каталога.', array('Алгоритм выборки остаётся в каталоге, а шаблон отвечает за представление.', 'Черновик можно проверить до публикации.', 'Теги шаблона выводят название фильтра, варианты и рассчитанные количества товаров.'), 'После публикации проверьте выбор, сброс и повторное применение нескольких фильтров.'), + self::item('#^/catalog/\d+/\d+$#', 'Конструктор каталога', 'Связывает поле типа catalog с деревом разделов, документами, полями и публичными фильтрами.', array('Разделы задают структуру каталога и набор доступных полей.', 'Условия фильтров определяют SQL-ограничения публичной выборки.', 'Порядок полей и фильтров меняется перетаскиванием и сохраняется сразу.'), 'Изменения структуры влияют на редактор документов и публичный каталог.'), + self::item('#^/catalog#', 'Каталог', 'Управляет структурами каталогов, товарным индексом, фильтрами и связью с документами.', array('Каждый каталог создаётся для конкретного поля рубрики типа catalog.', 'Товары являются документами и не дублируются в отдельной основной таблице.', 'Для вариантов одного товара используются группы вариантов.'), 'Начинайте с выбора каталога, затем настраивайте разделы, поля и фильтры.', 'content/catalog'), + + self::item('#^/rubrics(?:/.*)?$#', 'Рубрики и поля', 'Определяет типы документов: набор полей, группы, шаблоны вывода, права и код жизненного цикла.', array('Поля являются отдельными сущностями со своим типом, настройками и шаблоном.', 'Группы организуют длинные формы документов и не влияют на публичный URL.', 'Код до/после сохранения выполняется при изменении документа и требует проверки синтаксиса.'), 'Удаляйте рубрики и поля только после проверки связанных документов и шаблонов.', 'content/rubrics'), + self::item('#^/themes#', 'Темы публичного сайта', 'Управляет файлами оформления, подключениями CSS/JavaScript и способом сборки публичной страницы.', array('Файлы темы находятся в templates/<код темы> и проходят проверку пути и расширения.', 'Во вкладке «Настройки» выбирается нативная сборка AVE.cms или Twig-оболочка темы.', 'Текстовые файлы получают ревизии; тему можно экспортировать или импортировать безопасным ZIP-пакетом.'), 'Ассеты и переопределения темы работают в обоих режимах сборки.', 'content/themes'), + self::item('#^/templates#', 'Шаблоны страниц', 'Хранит внешнюю HTML/PHP-оболочку сайта, в которую подставляется содержимое документа и системные теги.', array('Шаблон №1 является системным и не удаляется.', 'Сохранение обновляет БД, файловый кеш и создаёт ревизию.', 'Палитра тегов вставляет зарегистрированные блоки, навигации и значения страницы.'), 'Ошибка PHP в основном шаблоне может нарушить весь публичный сайт — используйте проверку перед сохранением.', 'content/templates'), + self::item('#^/blocks#', 'Блоки', 'Единый реестр HTML-фрагментов, текста и исполняемой логики для шаблонов и документов.', array('Редактор выбирается отдельно для каждого блока: PHP, HTML, rich text или обычный текст.', 'Основной публичный вызов использует тег sysblock; старый тег block поддерживается как синоним.', 'Группы организуют список и не влияют на публичный вывод.'), 'PHP включайте только для блоков, которым действительно нужна серверная логика.', 'content/blocks'), + self::item('#^/navigation#', 'Навигация', 'Создаёт меню сайта: шаблоны уровней, пункты, вложенность, условия показа и связи с документами.', array('Каждая навигация имеет собственный тег для вставки в шаблон.', 'Пункты можно сортировать и переносить между уровнями.', 'Связанный документ позволяет не вводить URL вручную.'), 'После изменения структуры очищается кеш соответствующей навигации.', 'content/navigation'), + self::item('#^/requests#', 'Запросы', 'Формирует выборки документов по рубрикам и полям, а затем выводит их через шаблоны элементов и списка.', array('Условия определяют, какие документы попадут в результат.', 'Основной и постраничный шаблоны отвечают только за представление.', 'External/AJAX-флаги разрешают прямой вызов запроса и требуют осторожности.'), 'Проверяйте запрос после изменения условий: он может использоваться сразу в нескольких страницах.', 'content/requests'), + self::item('#^/media#', 'Медиа и файлы', 'Управляет файлами в uploads: загрузкой, папками, изображениями, превью и конвертацией WebP.', array('Сетка и список показывают одни и те же физические файлы.', 'Редактор изображений создаёт производные файлы для crop и resize.', 'При удалении исходника удаляются его превью и связанная WebP-копия.'), 'Не размещайте служебные PHP-файлы в uploads; раздел предназначен только для пользовательских медиа.', 'media'), + + self::item('#^/system/orders#', 'Заказы и корзина', 'Обрабатывает заказы, состояния оплаты и доставки, шаблоны корзины и брошенные сессии покупателей.', array('Карточка заказа фиксирует товары, цены и выбранные варианты на момент оформления.', 'Способы оплаты настраиваются во вкладке «Оплата», реквизиты провайдеров — во вкладке «Gateways».', 'Брошенные корзины очищаются по сроку хранения и доступны для диагностики.'), 'Отмена и возврат могут влиять на платёжный шлюз — проверяйте итоговый статус операции.'), + self::item('#^/system/customers#', 'Пользователи сайта', 'Управляет публичными аккаунтами, регистрацией, OAuth-гейтами и полями профиля.', array('Email-регистрация, восстановление доступа и создание аккаунта после заказа входят в ядро.', 'Дополнительные поля независимо подключаются к регистрации и личному кабинету.', 'Подключённые OAuth-гейты открывают ту же публичную сессию, что и обычная форма входа.', 'Отключение аккаунта запрещает вход, но сохраняет связанные данные.'), 'Email остаётся основным идентификатором; дополнительные способы входа поставляются отдельными модулями.'), + self::item('#^/content/contacts/forms/\d+$#', 'Конструктор формы', 'Настраивает поля, шаблон формы, получателей, письмо и поведение после отправки.', array('Теги полей доступны в шаблоне формы и письма.', 'Получатели задаются по одному на строку.', 'История обращений хранит отправленные значения и ответы менеджеров.'), 'После изменения шаблона отправьте тестовое обращение и проверьте письмо.'), + self::item('#^/content/contacts#', 'Формы и обращения', 'Управляет публичными формами обратной связи и журналом сообщений посетителей.', array('Форма состоит из полей, шаблонов и почтовых настроек.', 'История позволяет менять статус обращения и отвечать посетителю.', 'Копирование формы создаёт независимый набор настроек.'), 'Проверяйте адреса получателей и настройки системной почты перед публикацией формы.'), + self::item('#^/content/rss#', 'RSS-каналы', 'Публикует документы выбранной рубрики в XML-ленте по настраиваемому адресу /rss/{alias}.', array('Заголовок и описание можно брать из полей рубрики.', 'Количество элементов ограничивает размер выдаваемой ленты.', 'Префикс /rss/ зарезервирован и не может использоваться документами.'), 'После изменения алиаса обновите внешние подписки и проверьте XML из списка.'), + self::item('#^/content/feeds#', 'Товарные фиды', 'Формирует управляемые XML/YML-выгрузки товаров для партнеров и торговых площадок.', array('Категории определяют товарную выборку, исключения применяются после дочерних разделов.', 'Поля XML и характеристики связываются с индексом каталога или полями товарной рубрики.', 'Предпросмотр не меняет кеш, а пересоздание атомарно публикует новый файл.'), 'Не меняйте закрепленные legacy URL у действующих партнеров; редактируйте связанную конфигурацию фида.'), + self::item('#^/content/price-import#', 'Импорт цен', 'Обновляет цены товаров из XLSX после обязательного предварительного просмотра изменений.', array('Сначала загрузите файл или получите его по настроенной HTTPS-ссылке.', 'Выберите лист и сформируйте предварительный просмотр.', 'Применение изменяет только подтверждённые строки и записывает результат в историю.'), 'Не применяйте импорт, если предварительный просмотр показывает неожиданные товары или пустые цены.'), + + self::item('#^/users#', 'Пользователи', 'Управляет сотрудниками, которые могут входить в панель управления, их ролями и активностью.', array('Роль определяет доступные разделы и действия.', 'Отключённый пользователь не может войти, но сохраняется в журнале действий.', 'Нельзя отключить или удалить собственную активную учётную запись.'), 'Публичные аккаунты находятся в отдельном разделе «Пользователи сайта».', 'administration/users'), + self::item('#^/roles#', 'Роли и права', 'Определяет набор разрешений для администраторов и разграничивает доступ к функциям системы.', array('Права сгруппированы по модулям и назначаются роли целиком.', 'Системная роль admin всегда имеет полный доступ.', 'Роль с назначенными пользователями нельзя удалить.'), 'После изменения роли права применяются при следующей проверке доступа пользователя.'), + self::item('#^/settings/main$#', 'Основные настройки', 'Управляет публичным доступом, параметрами сайта, почты, документов и вывода.', array('Режим разработки возвращает посетителям временную страницу 503 и закрывает динамические маршруты от индексации.', 'Сотрудник с отдельным правом продолжает видеть документы, модули и API.', 'Поля сгруппированы по назначению, а тип значения проверяется перед сохранением.', 'Секреты устанавливаемых модулей не должны храниться в общих настройках.'), 'Перед открытием сайта переключите публичный доступ и проверьте страницу в отдельном браузере без авторизации.'), + self::item('#^/settings/interface$#', 'Настройка интерфейса', 'Определяет порядок и видимость пунктов левого меню и виджетов дашборда.', array('Перетаскивание меняет порядок только для панели управления.', 'Скрытие пункта не отменяет право роли и не блокирует прямой URL.', 'Виджеты установленных модулей появляются в списке автоматически.'), 'Доступ к функциям всегда ограничивайте через роли и права, а не только через видимость меню.'), + self::item('#^/settings/security$#', 'Подтверждение критических действий', 'Управляет повторным запросом пароля перед изменением исполняемого кода и установкой модулей.', array('Подтверждение действует пять минут для текущего пользователя и IP-адреса.', 'Отключение повторного пароля не отменяет проверку прав роли и CSRF-токена.', 'Настройка распространяется на блоки, шаблоны, рубрики, запросы, навигацию и другие операции с кодом.'), 'По умолчанию подтверждение выключено; на рабочем сайте его можно включить как дополнительный защитный слой.'), + self::item('#^/settings/constants$#', 'Системные константы', 'Хранит типизированные значения конфигурации, доступные ядру и публичному сайту.', array('Тип определяет допустимый редактор и формат сохранённого значения.', 'Перед добавлением проверьте, нет ли уже настройки с тем же назначением.', 'Удаляйте константу только после поиска её использования в коде и шаблонах.'), 'Название константы является программным контрактом и не должно меняться без миграции.'), + self::item('#^/settings/paginations$#', 'Шаблоны пагинации', 'Настраивает HTML постраничной навигации для документов и сохранённых запросов.', array('Базовый шаблон защищён от удаления.', 'Теги страниц, текущего состояния и ссылок подставляются во время рендера.', 'После изменения проверьте первую, среднюю и последнюю страницы списка.'), 'Шаблон должен сохранять доступность ссылок и понятное обозначение текущей страницы.'), + self::item('#^/settings/maintenance$#', 'Обслуживание', 'Очищает адресные кеши и накопленные служебные данные AVE.cms.', array('Каждая кнопка работает только со своим источником данных.', 'Очистка кеша не удаляет документы и настройки.', 'Ревизии и статистика просмотров удаляются необратимо.'), 'Перед очисткой данных, которые нельзя восстановить из документов, создайте резервную копию.'), + self::item('#^/settings/diagnostics$#', 'Диагностика системы', 'Проверяет PHP, расширения, доступ к каталогам, почту, данные модулей и готовность публичной части.', array('Проверки не отправляют письма и не выполняют внешние операции.', 'Красные состояния нужно устранить до публикации.', 'Тяжёлые проверки могут кратковременно кешироваться.'), 'Диагностика ядра не заменяет отдельную проверку подключённых модулей и внешних сервисов.'), + self::item('#^/settings/files$#', 'Системные файлы', 'Даёт контролируемый редактор разрешённых PHP-файлов конфигурации и пользовательских функций.', array('Список файлов ограничен системой и не является файловым менеджером.', 'Перед сохранением выполняется проверка PHP-синтаксиса.', 'Ошибка в таком файле может повлиять и на панель, и на публичный сайт.'), 'Используйте этот раздел для небольших проектных дополнений; крупную функциональность оформляйте модулем.'), + self::item('#^/settings/benchmark$#', 'Производительность', 'Измеряет скорость PHP, базы данных, файловых операций и кеша на текущем сервере.', array('Результаты нужны для сравнения одного окружения во времени, а не разных проектов.', 'Повторяйте тест после обновления PHP, БД или инфраструктуры.', 'Тест создаёт кратковременную нагрузку и сохраняет историю запусков.'), 'Оценивайте не только общий балл, но и конкретный медленный этап.'), + self::item('#^/settings#', 'Настройки системы', 'Собирает общие параметры AVE.cms, интерфейс, константы, пагинацию, обслуживание и диагностику.', array('Каждый подраздел имеет собственный URL и назначение.', 'Типизированные значения проверяются перед сохранением.', 'Настройки устанавливаемых модулей хранятся вместе с соответствующим модулем.'), 'Меняйте только параметры, назначение которых понятно и проверено на текущем сайте.', 'administration/settings'), + self::item('#^/events#', 'Системные события', 'Объединяет аудит действий, ошибки, SQL-журнал, 404 и статистику внешних переходов.', array('Цвет и состояние помогают отличать успешные операции, предупреждения и ошибки.', 'Переходы группируются по посетителю, странице входа и источнику; открытые IP и произвольные query-параметры не сохраняются.', 'Фильтры и CSV-экспорт работают отдельно для каждого источника.'), 'Очистка журнала необратима и не исправляет причину ошибки.'), + self::item('#^/security/ip-blocks#', 'IP-блокировки', 'Управляет ручными блокировками адресов, а автоматические ограничения выполняет RateLimiter.', array('Блокировка может быть постоянной или иметь дату окончания.', 'Причина видна администраторам и записывается в аудит.', 'Разблокировка возвращает доступ сразу.'), 'Не блокируйте адрес reverse proxy без проверки реального клиентского IP.'), + self::item('#^/database#', 'База данных', 'Показывает рабочие таблицы AVE.cms, выполняет обслуживание, применяет миграции ядра и управляет полными резервными копиями.', array('OPTIMIZE применяется только к выбранной таблице или ко всей рабочей схеме после подтверждения.', 'Применение миграций не восстанавливает данные: это отдельная операция развития структуры ядра.', 'Новые дампы используют {{prefix}}, поэтому могут восстанавливаться с другим dbpref.', 'Перед восстановлением система проверяет файл и автоматически создаёт страховочную копию.'), 'Восстановление полностью заменяет таблицы текущей установки; рабочую копию дополнительно храните вне сервера.', 'database'), + self::item('#^/kanban$#', 'Канбан', 'Организует личные рабочие карточки по колонкам и показывает доску на дашборде.', array('Карточки и порядок сохраняются без перезагрузки.', 'Колонки можно настроить под собственный процесс.', 'Каждый сотрудник работает со своей доской.'), 'Не используйте канбан как журнал системных ошибок: для них предназначены события и Todo.'), + self::item('#^/notes$#', 'Заметки', 'Хранит личные текстовые заметки сотрудника и закрепляет важные записи на дашборде.', array('Заметки видны только их владельцу.', 'Закрепление влияет на порядок выдачи.', 'Быстрое создание доступно из шапки, если вклад модуля включён.'), 'Не храните в заметках пароли, токены и другие секреты.'), + self::item('#^/reminders$#', 'Напоминания', 'Хранит личные задачи с датой и выводит просроченные пункты в общем колокольчике.', array('Срок определяет состояние напоминания.', 'Выполнение сохраняется сразу.', 'Просроченный счётчик формируется отдельно для текущего сотрудника.'), 'Удаление напоминания необратимо; выполненный пункт можно вернуть в работу.'), + self::item('#^/notfound$#', 'Ошибки 404', 'Показывает несуществующие публичные адреса и помогает создать документ или редирект.', array('Сначала проверьте источник переходов и частоту запроса.', 'Для старого адреса существующей страницы создайте постоянный редирект.', 'Случайные сканирующие URL можно удалить из журнала без создания страницы.'), 'Редирект должен вести на действительно соответствующий документ, а не всегда на главную.'), + self::item('#^/seo-audit$#', 'SEO-аудит', 'Проверяет документы на технические и редакционные проблемы, влияющие на поисковое представление.', array('Фильтры помогают сначала разобрать критичные и массовые проблемы.', 'Оценка документа складывается из нескольких независимых проверок.', 'Исправления выполняются в редакторе документа и проявляются после повторного аудита.'), 'Автоматическая оценка является подсказкой и не заменяет проверку содержания страницы.', 'modules/seo-audit'), + self::item('#^/todo$#', 'Todo', 'Хранит личный список замечаний и следующих действий для текущего администратора.', array('Задачу можно создать из выпадающего списка в шапке на любой странице панели управления.', 'Чекбокс сразу сохраняет состояние без перезагрузки.', 'Каждый пользователь видит только собственные задачи.'), 'Удаление задачи необратимо; выполненные пункты можно вернуть в работу обычным чекбоксом.'), + self::item('#^/modules/search#', 'Поиск по сайту', 'Индексирует опубликованные документы и формирует обычную страницу результатов и JSON для живого поиска.', array('Области позволяют отдельно искать по товарам, новостям, статьям и другим наборам рубрик.', 'Алгоритм определяет разбор строки и вес точного совпадения, заголовка и содержимого.', 'Штатный HTML можно заменить шаблонами модуля или существующим запросом документов.'), 'После изменения рубрик или индексируемых полей выполните полную переиндексацию.', 'modules/search'), + self::item('#^/modules/commerceml#', 'Обмен с 1С', 'Принимает CommerceML по защищённому адресу и сопоставляет товары 1С с документами товарной рубрики.', array('Для каждой интеграции создайте отдельный профиль с логином, паролем и рубрикой.', 'Поля внешнего ID, артикула, цены и остатка выбираются из полей назначенной рубрики.', 'Пробный XML можно загрузить из списка профилей и проверить результат в журнале обменов.'), 'Создание неизвестных товаров выключено по умолчанию: сначала проверьте сопоставление на существующих документах.'), + self::item('#^/modules/experiments#', 'A/B-тесты', 'Распределяет посетителей между вариантами и собирает показы и конверсии без смешивания вариантов в общем кеше.', array('Код эксперимента используется в шаблонном теге, API и атрибутах отслеживания конверсии.', 'Вес вариантов задаёт распределение внутри выбранного охвата аудитории.', 'Черновик не участвует в публичном выводе; перед запуском проверьте каждый вариант.'), 'Изменение цены штатным вариантом влияет только на представление и не меняет сумму заказа.'), + self::item('#^/modules/popups#', 'Всплывающие окна', 'Управляет кампаниями поп-апов, условиями показа, A/B-вариантами и собранными заявками.', array('Сценарий определяет момент показа: задержку, прокрутку или попытку ухода.', 'Пути включения и исключения принимают по одному шаблону на строку; символ * заменяет произвольную часть.', 'HTML вариантов контролируется проектом, а заявки обрабатываются на отдельной вкладке.'), 'Проверьте кампанию на тестовом URL и мобильном экране до включения автоматической вставки.'), + self::item('#^/modules/reviews#', 'Отзывы', 'Собирает оценки и отзывы к документам, позволяет модерировать публикацию и отвечать от имени компании.', array('Состояние определяет, попадёт ли отзыв в публичный список.', 'Признак подтверждённой покупки устанавливается только после проверки заказа.', 'Ответ компании сохраняется вместе с отзывом и выводится его публичным шаблоном.'), 'Удаление отзыва необратимо; спорный материал безопаснее сначала отклонить.', 'modules/reviews'), + self::item('#^/modules/scheduler#', 'Планировщик', 'Собирает фоновые задачи установленных модулей в единый реестр, запускает их по расписанию и хранит журнал.', array('Защищённый URL добавляется в планировщик хостинга с интервалом в одну минуту.', 'Cron-выражение и активность каждой задачи меняются прямо в таблице.', 'Ручной запуск использует тот же обработчик и попадает в общий журнал.'), 'После смены секретного URL обновите задание на хостинге: старый адрес сразу перестанет работать.'), + self::item('#^/modules/search-analytics#', 'Поисковые запросы', 'Показывает реальные поисковые фразы посетителей, частоту запросов и случаи пустой выдачи.', array('Фильтр по области разделяет спрос по новостям, товарам, статьям и другим настроенным областям.', 'Состояние и заметка позволяют отмечать разобранные запросы и планировать новый контент.', 'Показатель «Результатов сейчас» помогает проверить, исправлена ли нулевая выдача.'), 'Очистка истории удаляет аналитические данные, но не затрагивает поисковый индекс.'), + self::item('#^/modules/system-health#', 'Мониторинг здоровья', 'Проверяет доступность базы данных, диска, фоновых задач и заданных внешних HTTPS-сервисов.', array('Текущий снимок показывает независимый результат каждой инфраструктурной проверки.', 'История помогает отличить разовый сбой от повторяющейся проблемы.', 'Для внешнего сервиса задаются ожидаемый HTTP-статус и ограниченный таймаут.'), 'Мониторинг фиксирует рабочее состояние, но не заменяет приёмочный чек-лист перед запуском сайта.'), + self::item('#^/modules/traffic-analytics#', 'Источники трафика', 'Группирует входящие переходы по UTM-меткам, источникам, кампаниям и посадочным страницам.', array('Период и фильтры одинаково применяются к сводке, таблицам и CSV-экспорту.', 'UTM-конструктор собирает ссылку в браузере и не сохраняет введённые значения.', 'Один вход посетителя на ту же страницу за день агрегируется без хранения IP.'), 'Перед очисткой выбранного периода выгрузите CSV, если данные нужны для отчётности.'), + self::item('#^/modules/antispam#', 'Антиспам', 'Настраивает профили защиты публичных форм и показывает журнал отклонённых отправок.', array('Профиль объединяет лимиты, проверку скрытого поля и анализ содержимого для конкретной формы.', 'Журнал объясняет, какое правило остановило запрос, и помогает скорректировать чувствительность.', 'Доверенные исключения применяйте только к контролируемым интеграциям.'), 'Не ослабляйте общий профиль из-за одного ложного срабатывания: создайте отдельный профиль для нужной формы.'), + self::item('#^/modules/interactions#', 'Взаимодействия', 'Хранит общий журнал оценок, реакций и голосов, который используют прикладные модули.', array('Канал определяет допустимые действия и типы объектов.', 'Агрегаты ускоряют публичный вывод счётчиков и рейтингов.', 'Пересчёт восстанавливает агрегаты из исходного журнала действий.'), 'Обычно этот раздел не вставляется в шаблон напрямую: публичный интерфейс предоставляют рейтинги, опросы и комментарии.'), + self::item('#^/modules/ratings#', 'Рейтинги', 'Добавляет на документы и другие разрешённые объекты настраиваемую шкалу оценок.', array('Документы включают страницы, товары, новости и статьи, построенные на рубриках.', 'Тег [mod_rating] использует текущий документ; явная цель задаётся типом и ID.', 'Кнопки тегов вставляют доступные значения в HTML-шаблон рейтинга.'), 'Голоса и итоговые значения хранятся в модуле «Взаимодействия».'), + self::item('#^/modules/comments#', 'Комментарии', 'Управляет древовидными обсуждениями, премодерацией, антиспамом и публичными шаблонами.', array('Разрешите только те типы объектов, на которых действительно нужны обсуждения.', 'Шаблоны списка, комментария и формы имеют собственные наборы тегов.', 'Публикация, скрытие и отметка спама выполняются из общего журнала.'), 'Оценка комментариев использует отдельный канал модуля «Взаимодействия».'), + self::item('#^/modules/polls#', 'Опросы', 'Создаёт одиночные и множественные голосования с расписанием, доступом и управляемыми результатами.', array('Для публикации вставьте [mod_poll:ID] в документ, блок или шаблон.', 'Архив опросов доступен по настраиваемому публичному URL.', 'Шаблоны опроса, варианта, результата и архива имеют разные наборы тегов.'), 'Удаление опроса также удаляет варианты ответа и собранные голоса.'), + self::item('#^/modules/galleries#', 'Галереи', 'Собирает изображения медиатеки в упорядоченные сетки и слайдеры с собственными публичными URL.', array('Файлы остаются в медиатеке; галерея хранит только связи, подписи и порядок.', 'Тег [mod_gallery:ID] выводит коллекцию в документе, блоке или шаблоне.', 'Отдельные шаблоны управляют оболочкой галереи и разметкой одного изображения.'), 'Удаление галереи не удаляет исходные файлы и созданные для них превью.'), + self::item('#^/modules/banners#', 'Баннеры', 'Управляет рекламными местами, ротацией изображений, расписанием и внутренней статистикой переходов.', array('Стабильный код места используется в теге [mod_banner:КОД].', 'Вес влияет только на стратегию «По весу»; равномерная ротация выбирает материал с меньшим числом показов.', 'Изображения выбираются из общей медиатеки и не удаляются вместе с баннером.'), 'Нулевой лимит означает отсутствие ограничения; сброс статистики необратим.'), + self::item('#^/modules/faq#', 'Вопросы и ответы', 'Собирает частые вопросы в независимые коллекции с публичной страницей, тегом, API и Schema.org.', array('Стабильный код используется в теге [mod_faq:КОД] и публичном URL.', 'Ответ оформляется в rich-text редакторе, а вопросы сортируются перетаскиванием.', 'JSON-LD включает только опубликованные вопросы опубликованной коллекции.'), 'После изменения базового URL обновите ссылки в шаблонах и проверьте, что новый адрес не занят документом.'), + self::item('#^/modules/related#', 'Похожие материалы', 'Подбирает связанные документы по смыслу, ручным связям или стабильному кольцевому порядку.', array('Профиль задаёт рубрики, признаки, веса, лимит и способ вывода.', 'Встроенный шаблон подходит для новой разметки, существующий запрос — для готовых карточек сайта.', 'Тег [mod_related:КОД] использует текущий документ, а API принимает его ID явно.'), 'После установки и массового изменения контента перестройте индекс на отдельной вкладке.'), + self::item('#^/modules/demo-site#', 'Демо-сайт', 'Устанавливает и обслуживает демонстрационный набор рубрик, документов, навигации и шаблонов.', array('Предпросмотр показывает состав пакета до изменения данных.', 'Повторное применение использует ledger пакета и не должно создавать дубли.', 'Удаление затрагивает только сущности, созданные этим пакетом.'), 'Перед применением к сайту с собственным контентом сделайте резервную копию базы и публичных шаблонов.'), + self::item('#^/modules/site-readiness#', 'Готовность сайта', 'Проверяет базовые настройки, документы, шаблоны, модули и окружение перед публикацией сайта.', array('Профиль определяет глубину и набор проверяемых подсистем.', 'Красные результаты требуют исправления, предупреждения нужно оценивать в контексте проекта.', 'Проверка ничего не исправляет и не меняет публичные данные.'), 'Запускайте полный профиль после изменения структуры сайта, модулей или серверного окружения.'), + self::item('#^/modules/vk-oauth#', 'Вход через VK ID', 'Подключает VK ID как дополнительный способ входа в существующий публичный аккаунт.', array('Redirect URL из настроек должен точно совпадать с адресом в приложении VK.', 'Внешний профиль связывается с общим аккаунтом сайта, а не создаёт отдельную сессию.', 'Секрет приложения хранится только в настройках gateway.'), 'Проверяйте вход и привязку на тестовом аккаунте после смены домена или ключей приложения.'), + self::item('#^/modules/yandex-oauth#', 'Вход через Яндекс', 'Подключает Яндекс ID как дополнительный способ входа в существующий публичный аккаунт.', array('Callback URL из настроек должен точно совпадать с адресом OAuth-приложения.', 'Полученный профиль связывается с общим аккаунтом сайта.', 'Client secret не должен попадать в публичный шаблон или журнал.'), 'После смены домена обновите callback в панели Яндекса и настройках модуля.'), + self::item('#^/modules/smsc-auth#', 'Вход по SMS', 'Подключает SMSC для регистрации и беспарольного входа по подтверждённому номеру телефона.', array('API-ключ хранится в закрытом хранилище секретов.', 'Код одноразовый, ограничен по времени и числу попыток.', 'Телефон относится к общей учётной записи, поэтому отдельный профиль для SMS не создаётся.'), 'Перед включением на сайте отправьте тестовое SMS и проверьте вход существующего и нового пользователя.', 'modules/smsc-auth'), + self::item('#^/modules/yookassa#', 'YooKassa', 'Настраивает платёжный gateway YooKassa для оформления и контроля оплаты заказов.', array('Идентификатор магазина и секрет применяются только серверной частью.', 'Callback принимает изменения статуса от платёжной системы.', 'Тестовый и рабочий режимы должны использовать соответствующие ключи.'), 'Перед рабочим запуском проведите полный тест оплаты, отмены и повторного callback.'), + self::item('#^/modules/psb#', 'ПСБ / СФР', 'Настраивает отдельный платёжный сценарий для товаров, доступных к оплате электронным сертификатом.', array('Признак доступности и код ТСР задаются в данных товара.', 'Gateway показывается только для подходящего состава заказа.', 'Ключи и callback используются только серверной частью.'), 'Проверьте смешанную корзину и отказ платежа до включения способа на публичном сайте.'), + self::item('#^/modules/file-security#', 'Безопасность файлов', 'Проверяет целостность исходников по SHA-256, ищет подозрительные конструкции и защищает загрузки.', array('Сначала сформируйте эталон на заведомо проверенной версии проекта.', 'Находка эвристики является причиной проверить файл, а не доказательством заражения.', 'В карантин автоматически можно переместить только загрузки и временные вложения; системные файлы исправляются вручную.'), 'Не принимайте изменённый файл в эталон, пока не проверили причину изменения и его содержимое.'), + self::item('#^/modules#', 'Модули', 'Устанавливает и обновляет расширения сайта, их таблицы, шаблоны и состояние подключения.', array('Системные функции не отображаются как устанавливаемые модули.', 'Update применяет предусмотренную модулем схему обновления.', 'Uninstall может удалить данные модуля, если это заложено его manifest.'), 'Проверяйте совместимость версии и резервную копию перед обновлением модуля.', 'modules'), + self::item('#^/system/document-jobs#', 'Операции с документами', 'Запускает управляемые пакетные задания над документами и хранит подробный журнал выполнения.', array('Сначала проверьте PHP-синтаксис задания.', 'Runner выполняет работу пошагово с lock и отображает прогресс.', 'Остановленное задание можно проанализировать по журналу.'), 'Большие задания запускайте небольшими пакетами и контролируйте ошибки после каждого шага.'), + self::item('#^/system/console#', 'PHP-консоль', 'Выполняет административные PHP-сниппеты в ограниченном дочернем процессе для диагностики и обслуживания.', array('Сниппеты можно сохранять для повторного использования.', 'Перед запуском выполняется проверка синтаксиса.', 'Время, память и размер вывода ограничены.'), 'Консоль выполняет код с доступом к системе — используйте её только для проверенных операций.', 'administration/console'), + self::item('#^/system/updates#', 'Обновления AVE.cms', 'Устанавливает подписанные патчи ядра с проверкой локальных изменений, резервной копией и откатом.', array('Источник использует отдельный публичный RSA-ключ владельца AVE.cms.', 'Подготовка патча не меняет файлы до отдельного подтверждения установки.', 'Незавершённое задание продолжается с сохранённого этапа после повторного открытия раздела.'), 'Не удаляйте storage/updates и не копируйте полную сборку поверх сайта во время активного обновления.', 'administration/updates'), + self::item('#^/(?:dashboard)?$#', 'Дашборд', 'Показывает общее состояние AVE.cms, последние документы и виджеты установленных модулей.', array('Состав и порядок виджетов меняются в настройках интерфейса.', 'Последние документы и системные показатели дают быстрый переход в профильный раздел.', 'Модульные виджеты появляются только у пользователей с соответствующим правом.'), 'Используйте дашборд как отправную точку, а подробные данные смотрите в соответствующих разделах.', 'administration/dashboard'), + ); + } + + protected static function item($pattern, $title, $description, array $items, $note, $doc = '') + { + return array( + 'pattern' => $pattern, + 'title' => AdminLocale::translateMarkup($title), + 'description' => AdminLocale::translateMarkup($description), + 'items' => array_map(array(AdminLocale::class, 'translateMarkup'), $items), + 'note' => AdminLocale::translateMarkup($note), + 'doc' => (string) $doc, + ); + } + } diff --git a/adminx/Support/SensitiveRoutes.php b/adminx/Support/SensitiveRoutes.php new file mode 100644 index 0000000..d4bdc56 --- /dev/null +++ b/adminx/Support/SensitiveRoutes.php @@ -0,0 +1,154 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use InvalidArgumentException; + + /** Metadata contract and runtime inventory for high-risk Adminx routes. */ + class SensitiveRoutes + { + protected static $operations = array( + 'module.code' => 'Установка или изменение исполняемого кода модуля', + 'core.update' => 'Установка подписанного обновления ядра', + 'stored_php.execute' => 'Выполнение сохранённого PHP-кода', + 'stored_php.write' => 'Сохранение PHP-кода, исполняемого runtime', + 'theme_assets.write' => 'Изменение исполняемых CSS/JavaScript и файлов публичной темы', + 'legacy_migration.write' => 'Изменение реквизитов исходной системы', + 'legacy_migration.execute' => 'Очистка стартовых данных и миграция старой AVE.cms', + 'database.restore' => 'Полная замена рабочей схемы данными из резервной копии', + ); + protected static $required = array( + 'POST /modules/archive/install' => array('module.code', 'install_module_code'), + 'POST /modules/lifecycle/{code}/install' => array('module.code', 'install_module_code'), + 'POST /modules/lifecycle/{code}/update' => array('module.code', 'install_module_code'), + 'POST /modules/lifecycle/{code}/reinstall' => array('module.code', 'install_module_code'), + 'POST /modules/lifecycle/{code}/repair' => array('module.code', 'install_module_code'), + 'POST /modules/lifecycle/{code}/uninstall' => array('module.code', 'install_module_code'), + 'POST /modules/lifecycle/{code}/remove' => array('module.code', 'install_module_code'), + 'POST /system/updates/settings' => array('core.update', 'install_core_updates'), + 'POST /system/updates/upload' => array('core.update', 'install_core_updates'), + 'POST /system/updates/download/{id}' => array('core.update', 'install_core_updates'), + 'POST /system/updates/jobs/{id}/step' => array('core.update', 'install_core_updates'), + 'POST /system/updates/jobs/{id}/rollback' => array('core.update', 'install_core_updates'), + 'POST /system/console/execute' => array('stored_php.execute', 'execute_console'), + 'POST /blocks' => array('stored_php.write', 'manage_blocks'), + 'POST /blocks/{id}' => array('stored_php.write', 'manage_blocks'), + 'POST /blocks/{id}/copy' => array('stored_php.write', 'manage_blocks'), + 'POST /blocks/revisions/{revision}/restore' => array('stored_php.write', 'manage_blocks'), + 'POST /templates' => array('stored_php.write', 'manage_templates'), + 'POST /templates/{id}' => array('stored_php.write', 'manage_templates'), + 'POST /templates/{id}/copy' => array('stored_php.write', 'manage_templates'), + 'POST /templates/revisions/{revision}/restore' => array('stored_php.write', 'manage_templates'), + 'POST /rubrics' => array('stored_php.write', 'manage_rubrics'), + 'POST /rubrics/{id}' => array('stored_php.write', 'manage_rubrics'), + 'POST /rubrics/{id}/templates/main' => array('stored_php.write', 'manage_rubrics'), + 'POST /rubrics/{id}/templates' => array('stored_php.write', 'manage_rubrics'), + 'POST /rubrics/templates/{template}' => array('stored_php.write', 'manage_rubrics'), + 'POST /requests' => array('stored_php.write', 'manage_requests'), + 'POST /requests/{id}' => array('stored_php.write', 'manage_requests'), + 'POST /requests/{id}/copy' => array('stored_php.write', 'manage_requests'), + 'POST /requests/{id}/conditions' => array('stored_php.write', 'manage_requests'), + 'POST /navigation' => array('stored_php.write', 'manage_navigation'), + 'POST /navigation/{id}' => array('stored_php.write', 'manage_navigation'), + 'POST /navigation/{id}/copy' => array('stored_php.write', 'manage_navigation'), + 'POST /modules/content-packages/import' => array('stored_php.write', 'import_content_packages'), + 'POST /system/document-jobs' => array('stored_php.write', 'manage_document_jobs'), + 'POST /system/document-jobs/{id}' => array('stored_php.write', 'manage_document_jobs'), + 'POST /system/document-jobs/{id}/start' => array('stored_php.execute', 'execute_document_jobs'), + 'POST /system/document-jobs/{id}/step' => array('stored_php.execute', 'execute_document_jobs'), + 'POST /themes/file' => array('theme_assets.write', 'manage_themes'), + 'POST /themes/files/create' => array('theme_assets.write', 'manage_themes'), + 'POST /themes/upload' => array('theme_assets.write', 'manage_themes'), + 'POST /themes/path/delete' => array('theme_assets.write', 'manage_themes'), + 'POST /themes/manifest' => array('theme_assets.write', 'manage_themes'), + 'POST /themes/create' => array('theme_assets.write', 'manage_themes'), + 'POST /themes/activate' => array('theme_assets.write', 'manage_themes'), + 'POST /themes/import' => array('theme_assets.write', 'manage_themes'), + 'POST /themes/delete' => array('theme_assets.write', 'manage_themes'), + 'POST /themes/revisions/{id}/restore' => array('theme_assets.write', 'manage_themes'), + 'POST /modules/legacy-migration/profiles' => array('legacy_migration.write', 'manage_legacy_migration'), + 'POST /modules/legacy-migration/profiles/{id}/delete' => array('legacy_migration.write', 'manage_legacy_migration'), + 'POST /modules/legacy-migration/runs/{id}/start' => array('legacy_migration.execute', 'run_legacy_migration'), + 'POST /modules/legacy-migration/runs/{id}/rollback' => array('legacy_migration.execute', 'run_legacy_migration'), + 'POST /modules/legacy-migration/runs/{id}/files' => array('legacy_migration.execute', 'run_legacy_migration'), + 'POST /database/backup/restore' => array('database.restore', 'manage_database'), + ); + + public static function validate(array $route) + { + $options = isset($route['options']) && is_array($route['options']) ? $route['options'] : array(); + $operation = isset($options['sensitive']) ? trim((string) $options['sensitive']) : ''; + $key = self::routeKey($route); + $required = isset(self::$required[$key]) ? self::$required[$key] : null; + if ($operation === '' && !$required) { + return; + } + + if ($required && ($operation !== $required[0] + || !isset($options['permission']) + || (string) $options['permission'] !== $required[1])) { + throw new InvalidArgumentException('Нарушена политика чувствительного маршрута ' . $key); + } + + if (!isset(self::$operations[$operation])) { + throw new InvalidArgumentException('Неизвестная чувствительная операция: ' . $operation); + } + + $method = isset($route['method']) ? strtoupper((string) $route['method']) : ''; + if (in_array($method, array('GET', 'HEAD', 'OPTIONS'), true)) { + throw new InvalidArgumentException('Чувствительный маршрут не может использовать безопасный HTTP-метод'); + } + + if (empty($options['permission']) || empty($options['reauth'])) { + throw new InvalidArgumentException('Чувствительный маршрут обязан объявить permission и reauth'); + } + } + + public static function inventory(array $routes) + { + $items = array(); + foreach ($routes as $route) { + self::validate($route); + $options = isset($route['options']) && is_array($route['options']) ? $route['options'] : array(); + if (empty($options['sensitive'])) { + continue; + } + + $items[] = array( + 'method' => isset($route['method']) ? (string) $route['method'] : '', + 'pattern' => isset($route['pattern']) ? (string) $route['pattern'] : '', + 'operation' => (string) $options['sensitive'], + 'permission' => (string) $options['permission'], + 'reauth' => $options['reauth'], + ); + } + + return $items; + } + + public static function required() + { + return self::$required; + } + + protected static function routeKey(array $route) + { + return strtoupper(isset($route['method']) ? (string) $route['method'] : '') + . ' ' + . (isset($route['pattern']) ? (string) $route['pattern'] : ''); + } + } diff --git a/adminx/Support/SystemFeatures.php b/adminx/Support/SystemFeatures.php new file mode 100644 index 0000000..b8a3192 --- /dev/null +++ b/adminx/Support/SystemFeatures.php @@ -0,0 +1,32 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + class SystemFeatures + { + public static function enabled($feature) + { + $global = getenv('ADMINX_SYSTEM_TOOLS'); + if ($global !== false && $global !== '' && !filter_var($global, FILTER_VALIDATE_BOOLEAN)) { + return false; + } + + $key = 'ADMINX_SYSTEM_' . strtoupper(preg_replace('/[^A-Za-z0-9]+/', '_', (string) $feature)); + $value = getenv($key); + return $value === false || $value === '' ? true : filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + } diff --git a/adminx/Support/SystemHealth.php b/adminx/Support/SystemHealth.php new file mode 100644 index 0000000..85fbde9 --- /dev/null +++ b/adminx/Support/SystemHealth.php @@ -0,0 +1,97 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\Cache; + use App\Common\NotFoundLog; + + /** Lightweight counters shared by the dashboard and topbar notifications. */ + class SystemHealth + { + protected static $sqlErrors; + protected static $notFound; + + public static function sqlErrorCount() + { + if (self::$sqlErrors !== null) { + return self::$sqlErrors; + } + + $path = BASEPATH . '/tmp/logs/sql.csv'; + self::$sqlErrors = self::countCsvIncrementally($path); + return self::$sqlErrors; + } + + protected static function countCsvIncrementally($path) + { + if (!is_file($path)) { + Cache::forget('adminx.system-health.sql-log'); + return 0; + } + + $stat = @stat($path); + if (!is_array($stat)) { return 0; } + $state = Cache::get('adminx.system-health.sql-log', array()); + $sameFile = is_array($state) + && isset($state['device'], $state['inode'], $state['size'], $state['modified'], $state['count']) + && (int) $state['device'] === (int) $stat['dev'] + && (int) $state['inode'] === (int) $stat['ino']; + if ($sameFile + && (int) $state['size'] === (int) $stat['size'] + && (int) $state['modified'] === (int) $stat['mtime']) { + return (int) $state['count']; + } + + $count = $sameFile && (int) $state['size'] < (int) $stat['size'] + ? (int) $state['count'] + : 0; + $offset = $sameFile && (int) $state['size'] < (int) $stat['size'] + ? (int) $state['size'] + : 0; + $handle = @fopen($path, 'rb'); + if ($handle === false) { return $count; } + if ($offset > 0) { fseek($handle, $offset); } + while (($row = fgetcsv($handle, 0, ',')) !== false) { + if (isset($row[0]) && $row[0] !== '') { $count++; } + } + + fclose($handle); + Cache::set('adminx.system-health.sql-log', array( + 'device' => (int) $stat['dev'], + 'inode' => (int) $stat['ino'], + 'size' => (int) $stat['size'], + 'modified' => (int) $stat['mtime'], + 'count' => $count, + ), 0); + return $count; + } + + public static function notFoundStats() + { + if (self::$notFound !== null) { + return self::$notFound; + } + + try { + self::$notFound = NotFoundLog::stats(); + } catch (\Throwable $e) { + self::$notFound = array('urls' => 0, 'hits' => 0, 'unresolved' => 0); + } + + return self::$notFound; + } + } diff --git a/adminx/Support/Twig/AdminConstantExpression.php b/adminx/Support/Twig/AdminConstantExpression.php new file mode 100644 index 0000000..e81b60d --- /dev/null +++ b/adminx/Support/Twig/AdminConstantExpression.php @@ -0,0 +1,34 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support\Twig; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use Twig\Compiler; + use Twig\Node\Expression\ConstantExpression; + + /** + * Строковая константа Twig с отложенным переводом интерфейса. + */ + class AdminConstantExpression extends ConstantExpression + { + public function compile(Compiler $compiler): void + { + $compiler + ->raw('\App\Adminx\Support\AdminLocale::translateMarkup(') + ->repr($this->getAttribute('value')) + ->raw(')'); + } + } diff --git a/adminx/Support/Twig/AdminLocaleExtension.php b/adminx/Support/Twig/AdminLocaleExtension.php new file mode 100644 index 0000000..a335947 --- /dev/null +++ b/adminx/Support/Twig/AdminLocaleExtension.php @@ -0,0 +1,46 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support\Twig; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Adminx\Support\AdminLocale; + use Twig\Extension\AbstractExtension; + use Twig\TwigFilter; + use Twig\TwigFunction; + + class AdminLocaleExtension extends AbstractExtension + { + public function getNodeVisitors() + { + return array(new AdminLocaleNodeVisitor()); + } + + + public function getFilters() + { + return array( + new TwigFilter('admin_trans', array(AdminLocale::class, 'translateMarkup')), + ); + } + + + public function getFunctions() + { + return array( + new TwigFunction('admin_trans', array(AdminLocale::class, 'translateMarkup')), + ); + } + } diff --git a/adminx/Support/Twig/AdminLocaleNodeVisitor.php b/adminx/Support/Twig/AdminLocaleNodeVisitor.php new file mode 100644 index 0000000..e78bdec --- /dev/null +++ b/adminx/Support/Twig/AdminLocaleNodeVisitor.php @@ -0,0 +1,57 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support\Twig; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use Twig\Environment; + use Twig\Node\Node; + use Twig\Node\Expression\ConstantExpression; + use Twig\Node\TextNode; + use Twig\NodeVisitor\NodeVisitorInterface; + + class AdminLocaleNodeVisitor implements NodeVisitorInterface + { + public function enterNode(Node $node, Environment $environment): Node + { + return $node; + } + + + public function leaveNode(Node $node, Environment $environment): ?Node + { + if ($node instanceof TextNode && !$node instanceof AdminTextNode) { + return new AdminTextNode( + (string) $node->getAttribute('data'), + (int) $node->getTemplateLine() + ); + } + + if ($node instanceof ConstantExpression && !$node instanceof AdminConstantExpression) { + $value = $node->getAttribute('value'); + if (is_string($value) && preg_match('/[А-Яа-яЁё]/u', $value)) { + return new AdminConstantExpression($value, (int) $node->getTemplateLine()); + } + } + + return $node; + } + + + public function getPriority() + { + return 0; + } + } diff --git a/adminx/Support/Twig/AdminTextNode.php b/adminx/Support/Twig/AdminTextNode.php new file mode 100644 index 0000000..2c33567 --- /dev/null +++ b/adminx/Support/Twig/AdminTextNode.php @@ -0,0 +1,38 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support\Twig; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use Twig\Compiler; + use Twig\Node\TextNode; + + /** + * Статичный Twig-фрагмент Adminx, переводимый во время рендера. + * + * Перевод не компилируется в кеш Twig, поэтому разные администраторы могут + * использовать разные языки с одним набором скомпилированных шаблонов. + */ + class AdminTextNode extends TextNode + { + public function compile(Compiler $compiler): void + { + $compiler + ->addDebugInfo($this) + ->write('echo \\App\\Adminx\\Support\\AdminLocale::translateMarkup(') + ->string($this->getAttribute('data')) + ->raw(");\n"); + } + } diff --git a/adminx/assets/css/adminkit.css b/adminx/assets/css/adminkit.css new file mode 100644 index 0000000..71a85c6 --- /dev/null +++ b/adminx/assets/css/adminkit.css @@ -0,0 +1,6585 @@ +@font-face { + font-family: 'Adminx Sans'; + src: url('../fonts/OpenSans-Regular.woff2') format('woff2'); + font-style: normal; + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: 'Adminx Sans'; + src: url('../fonts/OpenSans-SemiBold.woff2') format('woff2'); + font-style: normal; + font-weight: 600; + font-display: swap; +} +@font-face { + font-family: 'Adminx Sans'; + src: url('../fonts/OpenSans-Bold.woff2') format('woff2'); + font-style: normal; + font-weight: 700; + font-display: swap; +} +@font-face { + font-family: 'Adminx Display'; + src: url('../fonts/NunitoSans-Regular.woff2') format('woff2'); + font-style: normal; + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: 'Adminx Display'; + src: url('../fonts/NunitoSans-Bold.woff2') format('woff2'); + font-style: normal; + font-weight: 700; + font-display: swap; +} +@font-face { + font-family: 'Adminx Display'; + src: url('../fonts/NunitoSans-ExtraBold.woff2') format('woff2'); + font-style: normal; + font-weight: 800; + font-display: swap; +} +/* ============================================================ + 1. ДИЗАЙН-ТОКЕНЫ + ============================================================ */ +:root { + /* Палитра */ + --blue-50: #eff6ff; + --blue-100: #dbeafe; + --blue-200: #bfdbfe; + --blue-500: #3b82f6; + --blue-600: #2563eb; + --blue-700: #1d4ed8; + --cyan-50: #ecfeff; + --cyan-100: #cffafe; + --cyan-500: #06b6d4; + --cyan-600: #0891b2; + --cyan-700: #0e7490; + --teal-50: #f0fdfa; + --teal-100: #ccfbf1; + --teal-500: #14b8a6; + --teal-600: #0d9488; + --teal-700: #0f766e; + --green-50: #f0fdf4; + --green-100: #dcfce7; + --green-500: #22c55e; + --green-600: #16a34a; + --green-700: #15803d; + --lime-100: #ecfccb; + --lime-500: #84cc16; + --lime-600: #65a30d; + --amber-50: #fffbeb; + --amber-100: #fef3c7; + --amber-500: #f59e0b; + --amber-600: #d97706; + --amber-700: #b45309; + --orange-50: #fff7ed; + --orange-100: #ffedd5; + --orange-500: #f97316; + --orange-600: #ea580c; + --red-50: #fef2f2; + --red-100: #fee2e2; + --red-500: #ef4444; + --red-600: #dc2626; + --red-700: #b91c1c; + --violet-50: #f5f3ff; + --violet-100: #ede9fe; + --violet-500: #8b5cf6; + --violet-600: #7c3aed; + --violet-700: #6d28d9; + --indigo-100: #e0e7ff; + --indigo-500: #6366f1; + --indigo-600: #4f46e5; + --pink-50: #fdf2f8; + --pink-100: #fce7f3; + --pink-500: #ec4899; + --pink-600: #db2777; + --gray-50: #f8fafc; + --gray-100: #f1f5f9; + --gray-200: #e2e8f0; + --gray-300: #cbd5e1; + --gray-400: #94a3b8; + --gray-500: #64748b; + --gray-600: #475569; + --gray-700: #334155; + --gray-800: #1e293b; + --gray-900: #0f172a; + /* Семантика */ + --color-primary: var(--blue-600); + --color-primary-hover: var(--blue-700); + --color-primary-soft: var(--blue-50); + --color-success: var(--green-600); + --color-success-soft: var(--green-50); + --color-warning: var(--amber-500); + --color-warning-soft: var(--amber-50); + --color-danger: var(--red-600); + --color-danger-soft: var(--red-50); + --color-violet: var(--violet-600); + --color-violet-soft: var(--violet-50); + --color-cyan: var(--cyan-600); + --color-cyan-soft: var(--cyan-50); + --color-pink: var(--pink-600); + --background-page: #f2f4f8; + --background-surface: #ffffff; + --background-muted: var(--gray-50); + --background-inset: var(--gray-100); + --background-header: rgba(255, 255, 255, 0.92); + /* Совместимые семантические имена для модульных поверхностей. */ + --surface-card: var(--background-surface); + --surface-muted: var(--background-muted); + --text-primary: var(--gray-900); + --text-secondary: var(--gray-600); + --text-muted: var(--gray-400); + --text-on-color: #ffffff; + --text-link: var(--blue-600); + --border-default: var(--gray-200); + --border-strong: var(--gray-300); + --border-focus: var(--blue-500); + --radius-xs: 6px; + --radius-sm: 8px; + --radius-md: 10px; + --radius-lg: 12px; + --radius-full: 999px; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --shadow-dropdown: 0 4px 16px rgba(15, 23, 42, 0.1), 0 1px 3px rgba(15, 23, 42, 0.08); + --shadow-modal: 0 20px 50px rgba(15, 23, 42, 0.22); + --shadow-card-hover: 0 2px 8px rgba(15, 23, 42, 0.06); + --font-body: 'Adminx Sans', system-ui, sans-serif; + --font-display: 'Adminx Display', 'Adminx Sans', sans-serif; + --font-mono: ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; + --topbar-h: 56px; + --sidebar-w: 260px; + --sidebar-w-collapsed: 64px; + --content-max: 1440px; + --focus-ring: 0 0 0 3px rgba(59, 130, 246, 0.28); +} +[data-theme="dark"] { + --color-primary: var(--blue-500); + --color-primary-hover: #60a5fa; + --color-primary-soft: rgba(59, 130, 246, 0.14); + --color-success: #34d399; + --color-success-soft: rgba(34, 197, 94, 0.14); + --color-warning: #fbbf24; + --color-warning-soft: rgba(245, 158, 11, 0.14); + --color-danger: #f87171; + --color-danger-soft: rgba(239, 68, 68, 0.15); + --color-violet: #a78bfa; + --color-violet-soft: rgba(139, 92, 246, 0.15); + --color-cyan: #22d3ee; + --color-cyan-soft: rgba(6, 182, 212, 0.14); + --color-pink: #f472b6; + --background-page: #0b1120; + --background-surface: #131b2e; + --background-muted: #182238; + --background-inset: #1c2740; + --background-header: rgba(19, 27, 46, 0.92); + --text-primary: #e6ecf7; + --text-secondary: #9aa8c2; + --text-muted: #5d6b87; + --text-link: #60a5fa; + --border-default: #24304d; + --border-strong: #334366; + --border-focus: var(--blue-500); + --shadow-dropdown: 0 6px 20px rgba(0, 0, 0, 0.45); + --shadow-modal: 0 24px 60px rgba(0, 0, 0, 0.6); + --shadow-card-hover: 0 2px 8px rgba(0, 0, 0, 0.3); + --focus-ring: 0 0 0 3px rgba(96, 165, 250, 0.32); +} +/* ============================================================ + 2. БАЗА + ============================================================ */ +*, +*::before, +*::after { + box-sizing: border-box; +} +html { + scroll-behavior: smooth; +} +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} +body { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.55; + color: var(--text-primary); + background: var(--background-page); + -webkit-font-smoothing: antialiased; +} +body.no-scroll, +html.drawer-active, +body.drawer-active { + overflow: hidden; + overscroll-behavior: none; +} +h1, +h2, +h3, +h4 { + font-family: var(--font-display); + margin: 0; + line-height: 1.25; + letter-spacing: -0.01em; +} +h1 { + font-size: 28px; + font-weight: 800; +} +h2 { + font-size: 22px; + font-weight: 800; +} +h3 { + font-size: 17px; + font-weight: 700; +} +h4 { + font-size: 14px; + font-weight: 700; +} +p { + margin: 0; +} +a { + color: var(--text-link); + text-decoration: none; +} +a:hover { + text-decoration: none; +} +code, +.mono { + font-family: var(--font-mono); + font-size: 0.92em; +} +kbd { + font-family: var(--font-mono); + font-size: 11px; + padding: 2px 6px; + border: 1px solid var(--border-default); + border-bottom-width: 2px; + border-radius: 6px; + background: var(--background-muted); + color: var(--text-secondary); +} +:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + border-radius: var(--radius-xs); +} +::placeholder { + color: var(--text-muted); +} +hr { + border: 0; + border-top: 1px solid var(--border-default); + margin: var(--space-6) 0; +} +.text-secondary { + color: var(--text-secondary); +} +.text-muted { + color: var(--text-muted); +} +.text-sm { + font-size: 12.5px; +} +.text-xs { + font-size: 11.5px; +} +/* Утилиты компоновки */ +.stack { + display: flex; + flex-direction: column; + gap: var(--space-4); +} +.stack-sm { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.cluster { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} +.cluster-lg { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-4); +} +.between { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; +} +.grid { + display: grid; + gap: var(--space-4); +} +.grid-2 { + grid-template-columns: repeat(2, 1fr); +} +.grid-3 { + grid-template-columns: repeat(3, 1fr); +} +.grid-4 { + grid-template-columns: repeat(4, 1fr); +} +.grid-auto { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); +} +.grid-auto-sm { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); +} +@media (max-width: 1100px) { + .grid-4 { + grid-template-columns: repeat(2, 1fr); + } + .grid-3 { + grid-template-columns: repeat(2, 1fr); + } +} +@media (max-width: 680px) { + .grid-4, + .grid-3, + .grid-2 { + grid-template-columns: 1fr; + } +} +/* ============================================================ + 3. КАРКАС: TOPBAR / SIDEBAR / MAIN + ============================================================ */ +.topbar { + position: sticky; + top: 0; + z-index: 90; + height: var(--topbar-h); + display: flex; + align-items: center; + gap: var(--space-3); + padding: 0 var(--space-4); + background: var(--background-header); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border-default); +} +.topbar-logo { + display: flex; + align-items: center; + gap: 2px; + font-family: var(--font-display); + font-weight: 800; + font-size: 15px; + white-space: nowrap; +} +.topbar-logo .logo-img { + height: 24px; + width: auto; + display: block; +} +.topbar-logo .logo-suffix { + letter-spacing: -0.01em; + margin-left: -5px; +} +.topbar-logo .logo-mark { + width: 28px; + height: 28px; + border-radius: 8px; + background: linear-gradient(135deg, var(--blue-600), var(--violet-600)); + display: grid; + place-items: center; + color: #fff; + font-size: 15px; +} +.topbar-right { + margin-left: auto; + display: flex; + align-items: center; + gap: var(--space-2); +} +.topbar-module-actions { + display: flex; + align-items: center; + gap: var(--space-2); +} +.locale-form { + margin: 0; +} +.locale-form .dd-item { + width: 100%; + min-height: 40px; +} +.locale-form .dd-item.active { + color: var(--color-primary); + background: var(--color-primary-soft); +} +.locale-form .dd-item > .ti { + margin-left: auto; +} +.locale-code { + display: inline-grid; + place-items: center; + min-width: 28px; + height: 22px; + padding: 0 5px; + border-radius: var(--radius-sm); + background: var(--background-inset); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + line-height: 1; +} +.locale-form .dd-item.active .locale-code { + background: var(--background-surface); + color: var(--color-primary); +} +.env-badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + padding: 3px 9px; + border-radius: var(--radius-full); + background: var(--color-warning-soft); + color: var(--amber-700); + border: 1px solid color-mix(in srgb, var(--color-warning) 35%, transparent); +} +[data-theme="dark"] .env-badge { + color: var(--color-warning); +} +.api-status { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); +} +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-success); + box-shadow: 0 0 0 3px var(--color-success-soft); +} +.status-dot.pulse { + animation: pulse 2s infinite; +} +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} +/* Уведомления в шапке */ +.topbar-bell > [data-dropdown] { + position: relative; +} +.topbar-badge { + position: absolute; + top: 0; + right: 0; + min-width: 16px; + height: 16px; + padding: 0 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + line-height: 1; + color: #fff; + background: var(--color-danger); + border-radius: 999px; + border: 2px solid var(--background-page); + font-variant-numeric: tabular-nums; +} +.notif-menu { + width: 330px; + padding: 0; + overflow: hidden; +} +.notif-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 11px 14px; + border-bottom: 1px solid var(--border-default); +} +.notif-head b { + font-size: 13px; +} +.notif-list { + max-height: 60vh; + overflow-y: auto; + padding: 5px; +} +.notif-item { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: center; + gap: 11px; + padding: 9px 8px; + border-radius: var(--radius-sm); + color: inherit; + text-decoration: none; + transition: background-color 0.12s; +} +.notif-item:hover { + background: var(--background-muted); + text-decoration: none; +} +.notif-item .icon-tile { + width: 36px; + height: 36px; +} +.notif-body { + min-width: 0; +} +.notif-body b { + display: block; + font-size: 13px; +} +.notif-body small { + display: block; + margin-top: 2px; + color: var(--text-secondary); + font-size: 11.5px; +} +.notif-count { + font-weight: 700; + font-size: 13px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} +.notif-empty { + padding: 30px 16px; + text-align: center; + color: var(--text-secondary); + font-size: 13px; +} +.notif-empty i { + display: block; + font-size: 28px; + margin-bottom: 6px; + color: var(--color-success); +} +/* Заголовок в меню пользователя */ +.dd-user { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 11px; +} +.avatar { + width: 30px; + height: 30px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 12px; + font-weight: 600; + color: #fff; + background: var(--indigo-500); + flex-shrink: 0; +} +.avatar.sm { + width: 24px; + height: 24px; + font-size: 10px; +} +.avatar.lg { + width: 44px; + height: 44px; + font-size: 16px; +} +.notif-btn { + position: relative; +} +.notif-badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 15px; + height: 15px; + padding: 0 3px; + border-radius: 8px; + background: var(--red-500); + color: #fff; + font-size: 9.5px; + font-weight: 700; + display: grid; + place-items: center; + border: 2px solid var(--background-surface); +} +.app-shell { + display: flex; + min-height: calc(100vh - var(--topbar-h)); +} +.adminx-popup-mode .app-shell { + min-height: 100vh; +} +.sidebar { + width: var(--sidebar-w); + flex-shrink: 0; + position: sticky; + top: calc(var(--topbar-h) + var(--space-3)); + align-self: flex-start; + height: calc(100vh - var(--topbar-h) - var(--space-6)); + margin: var(--space-3) 0 var(--space-3) var(--space-3); + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + overflow: hidden; + transition: width 0.2s ease; +} +.sidebar-scroll { + overflow-y: auto; + padding: var(--space-3); + flex: 1; +} +.nav-group { + margin-bottom: var(--space-4); +} +.nav-group-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 40px; + padding: 0 10px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-family: inherit; + cursor: pointer; + text-align: left; +} +.nav-group-toggle span { + min-width: 0; + overflow: hidden; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} +.nav-group-toggle i { + width: 16px; + margin-left: auto; + flex: 0 0 16px; + font-size: 14px; + text-align: center; + transform: rotate(0); + transition: transform 0.18s cubic-bezier(0.2, 0, 0, 1); +} +.nav-group-toggle:hover { + background: var(--background-muted); + color: var(--text-secondary); +} +.nav-group-toggle:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} +.nav-group.has-active .nav-group-toggle { + color: var(--color-primary); +} +.nav-group.is-collapsed .nav-group-toggle i { + transform: rotate(-90deg); +} +.nav-group-panel { + display: grid; + grid-template-rows: 1fr; + opacity: 1; +} +.nav-group-content { + min-height: 0; + overflow: hidden; +} +.nav-groups-ready .nav-group.is-collapsed .nav-group-panel { + grid-template-rows: 0fr; + opacity: 0; +} +.nav-groups-animated .nav-group-panel { + transition: grid-template-rows 0.18s cubic-bezier(0.2, 0, 0, 1), opacity 0.14s ease; +} +.nav-group.is-collapsed { + margin-bottom: 2px; +} +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 10px; + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + background: none; + width: 100%; + text-align: left; + font-family: inherit; + white-space: nowrap; + position: relative; +} +.nav-item i { + font-size: 17px; + flex-shrink: 0; + width: 18px; + text-align: center; +} +.nav-item:hover { + background: var(--background-muted); + color: var(--text-primary); + text-decoration: none; +} +.nav-item.active { + background: var(--color-primary-soft); + color: var(--color-primary); + font-weight: 600; +} +.nav-item .nav-count { + margin-left: auto; + font-size: 10.5px; + font-weight: 600; + color: var(--text-muted); + background: var(--background-inset); + padding: 1px 7px; + border-radius: var(--radius-full); +} +.nav-item .nav-chev { + margin-left: auto; + font-size: 14px; + transition: transform 0.15s; +} +.nav-item[aria-expanded="true"] .nav-chev { + transform: rotate(90deg); +} +.nav-sub { + display: none; + flex-direction: column; + gap: 1px; + margin: 2px 0 4px; + padding-left: 24px; +} +.nav-sub.open { + display: flex; +} +.nav-sub .nav-item { + font-size: 12.5px; + padding: 5px 10px; +} +.sidebar-footer { + border-top: 1px solid var(--border-default); + padding: 8px; + display: flex; + align-items: center; + gap: 10px; +} +.sidebar-user { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 6px 8px; + border: none; + background: none; + cursor: pointer; + font-family: inherit; + text-align: left; + border-radius: var(--radius-sm); + color: var(--text-primary); +} +.sidebar-user:hover { + background: var(--background-muted); +} +.sidebar-user .sf-text { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.25; +} +.sidebar-user .sf-name { + font-size: 12.5px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sidebar-user .sf-role { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sidebar-user .sf-chev { + margin-left: auto; + font-size: 15px; + color: var(--text-muted); + flex-shrink: 0; +} +.dropdown-menu.up { + top: auto; + bottom: calc(100% + 6px); +} +.sidebar.collapsed { + width: var(--sidebar-w-collapsed); +} +.sidebar.collapsed .nav-group-toggle { + display: none; +} +.sidebar.collapsed .nav-group-panel { + grid-template-rows: 1fr!important; + opacity: 1 !important; +} +.sidebar.collapsed .nav-item { + justify-content: center; + padding: 8px 0; +} +.sidebar.collapsed .nav-item span, +.sidebar.collapsed .nav-item .nav-count, +.sidebar.collapsed .nav-item .nav-chev, +.sidebar.collapsed .sidebar-footer .sf-text { + display: none; +} +.sidebar.collapsed .nav-sub { + display: none !important; +} +.sidebar.collapsed .sidebar-footer { + justify-content: center; +} +.sidebar-backdrop { + display: none; +} +.main { + flex: 1; + min-width: 0; + padding: var(--space-4) var(--space-5) var(--space-6); +} +.main-inner { + max-width: var(--content-max); + margin: 0 auto; +} +.adminx-popup-mode .main { + min-height: 100vh; +} +.breadcrumbs { + display: flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + color: var(--text-muted); + flex-wrap: wrap; +} +.breadcrumbs a { + color: var(--text-secondary); +} +.breadcrumbs i { + font-size: 12px; +} +.page-header { + margin: var(--space-3) 0 var(--space-6); +} +@media (max-width: 960px) { + .sidebar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + height: 100vh; + margin: 0; + border-radius: 0; + z-index: 120; + transform: translateX(-105%); + transition: transform 0.22s ease; + width: 280px; + } + .sidebar.mobile-open { + transform: translateX(0); + } + .sidebar.collapsed { + width: 280px; + } + .sidebar.collapsed .nav-group-toggle { + display: flex; + } + .sidebar.collapsed .nav-group.is-collapsed .nav-group-panel { + grid-template-rows: 0fr!important; + opacity: 0 !important; + } + .sidebar.collapsed .nav-item { + justify-content: flex-start; + padding: 7px 10px; + } + .sidebar.collapsed .nav-item span, + .sidebar.collapsed .nav-item .nav-count, + .sidebar.collapsed .nav-item .nav-chev { + display: inline; + } + .sidebar-backdrop { + display: block; + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.5); + z-index: 110; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + } + .sidebar-backdrop.visible { + opacity: 1; + pointer-events: auto; + } + .main { + padding: var(--space-3) var(--space-3) 120px; + } + .env-badge, + .api-status { + display: none; + } +} +@media (max-width: 520px) { + .topbar { + padding-left: 8px; + padding-right: 8px; + gap: 6px; + } + .topbar-right { + gap: 4px; + } + .topbar-site-link, + .topbar-cache, + .topbar-help, + .topbar-module-actions { + display: none !important; + } + #userMenu > [data-dropdown] { + width: 36px; + min-width: 36px; + padding: 0!important; + justify-content: center; + gap: 0 !important; + } + #userMenu > [data-dropdown] > .ti-chevron-down { + display: none; + } +} +/* ============================================================ + 4. SECTION HEADERS + ============================================================ */ +.section { + margin-bottom: var(--space-12); + scroll-margin-top: calc(var(--topbar-h) + 16px); +} +.section-header { + display: flex; + gap: var(--space-4); + align-items: flex-start; + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--border-default); + margin-bottom: var(--space-6); + position: relative; +} +.section-header::after { + content: ""; + position: absolute; + left: 0; + bottom: -1px; + width: 56px; + height: 2px; + background: var(--section-accent, var(--color-primary)); + border-radius: 2px; +} +.section-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + display: grid; + place-items: center; + font-size: 20px; + flex-shrink: 0; + background: var(--section-accent-soft, var(--color-primary-soft)); + color: var(--section-accent, var(--color-primary)); +} +.section-eyebrow { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--section-accent, var(--color-primary)); +} +.section-desc { + color: var(--text-secondary); + font-size: 13.5px; + margin-top: 4px; + max-width: 640px; +} +.section-header-right { + margin-left: auto; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + flex-shrink: 0; +} +@media (max-width: 680px) { + .section-header { + flex-wrap: wrap; + } + .section-header-right { + margin-left: 0; + align-items: flex-start; + width: 100%; + } +} +.subsection { + margin-bottom: var(--space-8); +} +.subsection-header { + display: flex; + align-items: baseline; + gap: var(--space-3); + margin-bottom: var(--space-4); + flex-wrap: wrap; +} +.subsection-header h3 { + display: flex; + align-items: center; + gap: 8px; +} +.subsection-header .sub-desc { + color: var(--text-muted); + font-size: 12.5px; +} +.admin-section-help { + margin: 0 0 18px; +} +.admin-section-help details { + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--blue-500) 24%, var(--border-default)); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-primary-soft) 64%, var(--background-surface)); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.025), 0 5px 16px rgba(37, 99, 235, 0.035); +} +.admin-section-help summary { + position: relative; + display: grid; + grid-template-columns: 36px minmax(0, 1fr) 24px; + align-items: center; + gap: 12px; + min-height: 62px; + padding: 11px 14px; + list-style: none; + cursor: pointer; + user-select: none; +} +.admin-section-help summary::-webkit-details-marker { + display: none; +} +.admin-section-help summary::after { + content: ""; + position: absolute; + inset: 0; + background: var(--blue-500); + opacity: 0; + pointer-events: none; + transition: opacity 0.16s cubic-bezier(0.2, 0, 0, 1); +} +.admin-section-help summary:hover::after { + opacity: 0.035; +} +.admin-section-help .icon-tile { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + background: var(--blue-100); + color: var(--blue-600); + font-size: 18px; +} +.admin-section-help summary > span:nth-child(2) { + display: grid; + gap: 3px; + min-width: 0; +} +.admin-section-help summary b { + font-size: 14px; + line-height: 1.3; + letter-spacing: 0; + color: var(--text-primary); + text-wrap: balance; +} +.admin-section-help summary small { + color: var(--text-secondary); + font-size: 12.5px; + line-height: 1.45; + text-wrap: pretty; +} +.section-help-chevron { + justify-self: center; + color: var(--text-muted); + transition-property: transform, color; + transition-duration: 0.18s; + transition-timing-function: cubic-bezier(0.2, 0, 0, 1); +} +.admin-section-help details[open] .section-help-chevron { + transform: rotate(180deg); + color: var(--blue-600); +} +.section-help-body { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.8fr); + gap: 18px; + padding: 13px 16px 15px 62px; + border-top: 1px solid color-mix(in srgb, var(--blue-500) 16%, var(--border-default)); +} +.section-help-body ul { + display: grid; + gap: 7px; + margin: 0; + padding: 0; + list-style: none; +} +.section-help-body li { + position: relative; + padding-left: 16px; + color: var(--text-secondary); + font-size: 12.5px; + line-height: 1.5; + text-wrap: pretty; +} +.section-help-body li::before { + content: ""; + position: absolute; + top: 0.58em; + left: 1px; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--blue-500); +} +.section-help-body p { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0; + padding: 10px 11px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--amber-100) 66%, var(--background-surface)); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; + text-wrap: pretty; +} +.section-help-body p i { + flex: 0 0 auto; + margin-top: 1px; + color: var(--amber-600); + font-size: 16px; +} +[data-theme="dark"] .section-help-body p { + background: color-mix(in srgb, var(--amber-500) 11%, var(--background-surface)); + color: var(--text-primary); +} +[data-theme="dark"] .section-help-body p i { + color: #fbbf24; +} +@media (max-width: 760px) { + .admin-section-help { + margin-bottom: 14px; + } + .admin-section-help summary { + grid-template-columns: 32px minmax(0, 1fr) 20px; + gap: 9px; + padding: 10px 11px; + } + .admin-section-help .icon-tile { + width: 32px; + height: 32px; + font-size: 16px; + } + .admin-section-help summary small { + font-size: 12px; + } + .section-help-body { + grid-template-columns: 1fr; + gap: 12px; + padding: 12px; + } +} +/* ============================================================ + 5. КНОПКИ + ============================================================ */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + height: 36px; + padding: 0 14px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + background: var(--background-inset); + color: var(--text-primary); + transition: background 0.12s, border-color 0.12s, color 0.12s; + user-select: none; +} +.btn i { + font-size: 16px; +} +.btn:hover { + background: var(--gray-200); +} +[data-theme="dark"] .btn:hover { + background: var(--border-strong); +} +.btn:focus-visible { + box-shadow: var(--focus-ring); +} +.btn:active { + transform: translateY(1px); +} +.btn-primary { + background: var(--color-primary); + color: #fff; +} +.btn-primary:hover { + background: var(--color-primary-hover); +} +.btn-secondary { + background: var(--background-surface); + border-color: var(--border-strong); + color: var(--text-primary); +} +.btn-secondary:hover { + background: var(--background-muted); +} +.btn-outline { + background: transparent; + border-color: var(--color-primary); + color: var(--color-primary); +} +.btn-outline:hover { + background: var(--color-primary-soft); +} +.btn-ghost { + background: transparent; + color: var(--text-secondary); +} +.btn-ghost:hover { + background: var(--background-inset); + color: var(--text-primary); +} +.btn-soft { + background: var(--color-primary-soft); + color: var(--color-primary); +} +.btn-soft:hover { + background: var(--blue-100); +} +[data-theme="dark"] .btn-soft:hover { + background: rgba(59, 130, 246, 0.24); +} +.btn-danger { + background: var(--red-600); + color: #fff; +} +.btn-danger:hover { + background: var(--red-700); +} +.btn-danger-soft { + background: var(--color-danger-soft); + color: var(--color-danger); +} +.btn-success { + background: var(--green-600); + color: #fff; +} +.btn-success:hover { + background: var(--green-700); +} +.btn-warning { + background: var(--amber-500); + color: #fff; +} +.btn-warning:hover { + background: var(--amber-600); +} +.btn-violet { + background: var(--violet-600); + color: #fff; +} +.btn-violet:hover { + background: var(--violet-700); +} +.btn-cyan { + background: var(--cyan-600); + color: #fff; +} +.btn-cyan:hover { + background: var(--cyan-700); +} +.btn:disabled, +.btn.is-disabled { + opacity: 0.55; + cursor: not-allowed; + pointer-events: none; +} +.btn-xs { + height: 26px; + padding: 0 9px; + font-size: 11.5px; + border-radius: var(--radius-xs); +} +.btn-xs i { + font-size: 13px; +} +.btn-sm { + height: 31px; + padding: 0 11px; + font-size: 12.5px; +} +.btn-lg { + height: 42px; + padding: 0 20px; + font-size: 14px; + border-radius: var(--radius-md); +} +.btn-icon { + width: 36px; + padding: 0; +} +.btn-icon.btn-sm { + width: 31px; +} +.btn-icon.btn-xs { + width: 26px; +} +.btn.is-loading { + position: relative; + color: transparent!important; + pointer-events: none; +} +.btn.is-loading::after { + content: ""; + position: absolute; + width: 15px; + height: 15px; + border: 2px solid rgba(255, 255, 255, 0.4); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +.btn.is-loading.btn-secondary::after, +.btn.is-loading.btn-ghost::after { + border-color: var(--gray-300); + border-top-color: var(--text-secondary); +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.btn-group { + display: inline-flex; +} +.btn-group .btn { + border-radius: 0; + border-right-width: 0; +} +.btn-group .btn:first-child { + border-radius: var(--radius-sm) 0 0 var(--radius-sm); +} +.btn-group .btn:last-child { + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + border-right-width: 1px; +} +.split-btn { + display: inline-flex; +} +.split-btn > .btn:first-child { + border-radius: var(--radius-sm) 0 0 var(--radius-sm); +} +.split-btn .dropdown { + display: inline-flex; +} +.split-btn .dropdown > .btn { + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + padding: 0 9px; + border-left: 1px solid rgba(255, 255, 255, 0.28); +} +.fab { + position: fixed; + right: 24px; + bottom: 24px; + width: 52px; + height: 52px; + border-radius: 50%; + background: var(--color-primary); + color: #fff; + border: none; + font-size: 22px; + cursor: pointer; + box-shadow: var(--shadow-dropdown); + display: grid; + place-items: center; + z-index: 80; +} +.fab:hover { + background: var(--color-primary-hover); +} +.segmented { + display: inline-flex; + background: var(--background-inset); + border-radius: var(--radius-sm); + padding: 3px; + gap: 2px; +} +.segmented button { + border: none; + background: transparent; + font: inherit; + font-size: 12.5px; + font-weight: 600; + color: var(--text-secondary); + padding: 5px 12px; + border-radius: var(--radius-xs); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; +} +.segmented button[aria-pressed="true"] { + background: var(--background-surface); + color: var(--text-primary); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.1); +} +/* ============================================================ + 6. BADGES / STATUS + ============================================================ */ +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11.5px; + font-weight: 600; + padding: 2px 9px; + border-radius: var(--radius-full); + background: var(--background-inset); + color: var(--text-secondary); + white-space: nowrap; +} +.badge i { + font-size: 12px; +} +.badge-dot::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +.badge-blue { + background: var(--blue-100); + color: var(--blue-700); +} +.badge-cyan { + background: var(--cyan-100); + color: var(--cyan-700); +} +.badge-teal { + background: var(--teal-100); + color: var(--teal-700); +} +.badge-green { + background: var(--green-100); + color: var(--green-700); +} +.badge-lime { + background: var(--lime-100); + color: var(--lime-600); +} +.badge-amber { + background: var(--amber-100); + color: var(--amber-700); +} +.badge-orange { + background: var(--orange-100); + color: var(--orange-600); +} +.badge-red { + background: var(--red-100); + color: var(--red-700); +} +.badge-violet { + background: var(--violet-100); + color: var(--violet-700); +} +.badge-indigo { + background: var(--indigo-100); + color: var(--indigo-600); +} +.badge-pink { + background: var(--pink-100); + color: var(--pink-600); +} +[data-theme="dark"] .badge-blue { + background: rgba(59, 130, 246, 0.18); + color: #93c5fd; +} +[data-theme="dark"] .badge-cyan { + background: rgba(6, 182, 212, 0.18); + color: #67e8f9; +} +[data-theme="dark"] .badge-teal { + background: rgba(20, 184, 166, 0.18); + color: #5eead4; +} +[data-theme="dark"] .badge-green { + background: rgba(34, 197, 94, 0.18); + color: #86efac; +} +[data-theme="dark"] .badge-lime { + background: rgba(132, 204, 22, 0.18); + color: #bef264; +} +[data-theme="dark"] .badge-amber { + background: rgba(245, 158, 11, 0.18); + color: #fcd34d; +} +[data-theme="dark"] .badge-orange { + background: rgba(249, 115, 22, 0.18); + color: #fdba74; +} +[data-theme="dark"] .badge-red { + background: rgba(239, 68, 68, 0.18); + color: #fca5a5; +} +[data-theme="dark"] .badge-violet { + background: rgba(139, 92, 246, 0.18); + color: #c4b5fd; +} +[data-theme="dark"] .badge-indigo { + background: rgba(99, 102, 241, 0.18); + color: #a5b4fc; +} +[data-theme="dark"] .badge-pink { + background: rgba(236, 72, 153, 0.18); + color: #f9a8d4; +} +.ax-tag { + display: inline-grid; + grid-template-columns: minmax(0, 1fr) 18px; + align-items: center; + gap: 8px; + width: auto; + min-width: 0; + max-width: 100%; + padding: 7px 9px; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: var(--background-muted); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; + text-align: left; + cursor: pointer; + transition: background-color 150ms, border-color 150ms, color 150ms, transform 150ms; +} +.ax-tag span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ax-tag i { + font-size: 16px; + color: var(--text-muted); +} +.ax-tag:hover { + border-color: var(--border-strong); + background: var(--background-inset); +} +.ax-tag:hover i { + color: var(--text-primary); +} +.ax-tag:active { + transform: scale(0.98); +} +/* ============================================================ + 7. КАРТОЧКИ + ============================================================ */ +.card { + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: var(--space-4); +} +.card:hover { + box-shadow: var(--shadow-card-hover); +} +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + margin-bottom: var(--space-3); +} +.card.card-flush { + padding: 0; + overflow: hidden; +} +.card-topline { + border-top: 3px solid var(--card-accent, var(--color-primary)); + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); +} +.card-sideline { + border-left: 3px solid var(--card-accent, var(--color-primary)); +} +.card-solid { + background: var(--card-accent, var(--color-primary)); + border-color: transparent; + color: #fff; +} +.card-solid .text-secondary, +.card-solid .text-muted { + color: rgba(255, 255, 255, 0.75); +} +.card-tint { + background: var(--card-accent-soft, var(--color-primary-soft)); + border-color: color-mix(in srgb, var(--card-accent, var(--color-primary)) 25%, transparent); +} +.card-gradient { + background: linear-gradient(135deg, var(--blue-600), var(--violet-600)); + border: none; + color: #fff; +} +.card-gradient .text-secondary { + color: rgba(255, 255, 255, 0.78); +} +.card-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + margin: var(--space-5) calc(var(--space-4) * -1) calc(var(--space-4) * -1); + padding: var(--space-3) var(--space-4); + border-top: 1px solid var(--border-default); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); + background: var(--background-muted); + flex-wrap: wrap; +} +.kpi-value { + font-family: var(--font-display); + font-size: 26px; + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.1; +} +.kpi-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.card-solid .kpi-label, +.card-gradient .kpi-label { + color: rgba(255, 255, 255, 0.8); +} +.trend { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 12px; + font-weight: 600; +} +.trend.up { + color: var(--color-success); +} +.trend.down { + color: var(--color-danger); +} +.card-solid .trend.up, +.card-gradient .trend.up { + color: #bbf7d0; +} +.card-solid .trend.down { + color: #fecaca; +} +.icon-tile { + width: 38px; + height: 38px; + border-radius: var(--radius-md); + display: grid; + place-items: center; + font-size: 19px; + flex-shrink: 0; + background: var(--tile-bg, var(--color-primary-soft)); + color: var(--tile-fg, var(--color-primary)); +} +/* Модульные подписи вида `.some-stat span` не должны перекрашивать и уменьшать иконку. */ +.icon-tile.icon-tile { + font-size: 19px; + color: var(--tile-fg, var(--color-primary)); +} +.ax-list-card { + padding: 0; + overflow: hidden; +} +.ax-list-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid var(--border-default); +} +.ax-list-title { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} +.ax-list-title .icon-tile { + width: 34px; + height: 34px; + flex: 0 0 34px; + font-size: 17px; +} +.ax-list-title h2 { + margin: 0 0 4px; + font-size: 16px; + line-height: 1.25; + font-weight: 700; + letter-spacing: 0; + text-wrap: balance; +} +.ax-list-title p { + margin: 0; + font-size: 13px; + line-height: 1.4; + font-weight: 400; + text-wrap: pretty; +} +.ax-list-head > .badge { + flex: 0 0 auto; + font-variant-numeric: tabular-nums; +} +@media (max-width: 680px) { + .ax-list-head { + padding: 15px 14px; + } + .ax-list-title p { + font-size: 12.5px; + } +} +.sparkline { + display: flex; + align-items: flex-end; + gap: 2px; + height: 34px; +} +.sparkline span { + flex: 1; + border-radius: 2px 2px 0 0; + background: var(--spark-color, var(--blue-500)); + opacity: 0.85; + min-width: 4px; +} +.progress { + height: 7px; + border-radius: var(--radius-full); + background: var(--background-inset); + overflow: hidden; +} +.progress-bar { + height: 100%; + border-radius: var(--radius-full); + background: var(--progress-color, var(--color-primary)); + transition: width 0.4s ease; +} +.card-solid .progress, +.card-gradient .progress { + background: rgba(255, 255, 255, 0.25); +} +.card-solid .progress-bar, +.card-gradient .progress-bar { + background: #fff; +} +/* ============================================================ + 8. ПОЛЯ ФОРМ + ============================================================ */ +.field { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} +.field-label { + font-size: 12.5px; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 5px; +} +.req { + color: var(--color-danger); +} +.opt { + font-weight: 400; + color: var(--text-muted); + font-size: 11px; +} +.field-hint { + font-size: 11.5px; + color: var(--text-muted); +} +/* строка-подпись под полем: слева пояснение, справа, напр., счётчик символов */ +.field-help { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + font-size: 11.5px; + color: var(--text-muted); +} +.field-help > * { + min-width: 0; +} +.field-error { + font-size: 11.5px; + color: var(--color-danger); + display: flex; + align-items: center; + gap: 4px; +} +.field-success { + font-size: 11.5px; + color: var(--color-success); + display: flex; + align-items: center; + gap: 4px; +} +.field-warning-msg { + font-size: 11.5px; + color: var(--amber-600); + display: flex; + align-items: center; + gap: 4px; +} +.input, +.select, +.textarea { + width: 100%; + font: inherit; + font-size: 13.5px; + color: var(--text-primary); + background: var(--background-surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + height: 36px; + padding: 0 11px; + transition: border-color 0.12s, box-shadow 0.12s; +} +.textarea { + height: auto; + min-height: 84px; + padding: 9px 11px; + resize: vertical; + line-height: 1.5; +} +.select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2.5'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 32px; + cursor: pointer; +} +.input:hover, +.select:hover, +.textarea:hover { + border-color: var(--gray-400); +} +[data-theme="dark"] .input:hover, +[data-theme="dark"] .select:hover, +[data-theme="dark"] .textarea:hover { + border-color: var(--border-strong); +} +.input:focus, +.select:focus, +.textarea:focus { + outline: none; + border-color: var(--border-focus); + box-shadow: var(--focus-ring); +} +.input:disabled, +.select:disabled, +.textarea:disabled { + background: var(--background-inset); + color: var(--text-muted); + cursor: not-allowed; +} +.input[readonly] { + background: var(--background-muted); + color: var(--text-secondary); +} +.field.is-error .input, +.field.is-error .select, +.field.is-error .textarea { + border-color: var(--color-danger); +} +.field.is-error .input:focus { + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2); +} +.field.is-success .input { + border-color: var(--color-success); +} +.field.is-warning .input { + border-color: var(--color-warning); +} +.field.is-loading .input-wrap::after { + content: ""; + position: absolute; + right: 10px; + top: 50%; + margin-top: -7px; + width: 14px; + height: 14px; + border: 2px solid var(--gray-300); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +.input-wrap { + position: relative; + display: flex; + align-items: center; +} +.input-wrap .input { + flex: 1; +} +.input-wrap > i { + position: absolute; + left: 11px; + color: var(--text-muted); + font-size: 15px; + pointer-events: none; +} +.input-wrap > i ~ .input { + padding-left: 33px; +} +.input-wrap .input-action { + position: absolute; + right: 6px; + background: none; + border: none; + cursor: pointer; + color: var(--text-muted); + padding: 4px; + border-radius: var(--radius-xs); + display: grid; + place-items: center; + font-size: 15px; +} +.input-wrap .input-action:hover { + color: var(--text-primary); + background: var(--background-inset); +} +.input-wrap .input-action ~ .input, +.input-wrap .input.has-action { + padding-right: 34px; +} +.input-group { + display: flex; + align-items: stretch; +} +.input-group .input { + border-radius: 0; + position: relative; + z-index: 1; +} +.input-group > :first-child, +.input-group > :first-child .input { + border-top-left-radius: var(--radius-sm); + border-bottom-left-radius: var(--radius-sm); +} +.input-group > :last-child, +.input-group > :last-child .input { + border-top-right-radius: var(--radius-sm); + border-bottom-right-radius: var(--radius-sm); +} +.input-group .input + .input { + margin-left: -1px; +} +.input-addon { + display: flex; + align-items: center; + padding: 0 11px; + background: var(--background-muted); + border: 1px solid var(--border-strong); + color: var(--text-secondary); + font-size: 12.5px; + font-weight: 500; + white-space: nowrap; +} +.input-addon.mono { + font-family: var(--font-mono); + font-size: 12px; +} +.input-addon + .input { + margin-left: -1px; +} +.input + .input-addon { + margin-left: -1px; +} +.input-addon:first-child { + border-radius: var(--radius-sm) 0 0 var(--radius-sm); +} +.input-addon:last-child { + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} +.input-group .select { + border-radius: 0; + width: auto; +} +.char-counter { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); + text-align: right; +} +.char-counter.over { + color: var(--color-danger); +} +.form-grid { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: var(--space-4); +} +.col-1 { + grid-column: span 1; +} +.col-2 { + grid-column: span 2; +} +.col-3 { + grid-column: span 3; +} +.col-4 { + grid-column: span 4; +} +.col-5 { + grid-column: span 5; +} +.col-6 { + grid-column: span 6; +} +.col-7 { + grid-column: span 7; +} +.col-8 { + grid-column: span 8; +} +.col-9 { + grid-column: span 9; +} +.col-10 { + grid-column: span 10; +} +.col-11 { + grid-column: span 11; +} +.col-12 { + grid-column: span 12; +} +@media (max-width: 760px) { + .col-1, + .col-2, + .col-3, + .col-4, + .col-5, + .col-6, + .col-7, + .col-8, + .col-9, + .col-10, + .col-11 { + grid-column: span 12; + } +} +/* checkbox / radio / switch */ +.check, +.radio { + display: flex; + align-items: flex-start; + gap: 9px; + font-size: 13.5px; + cursor: pointer; + user-select: none; +} +.check input, +.radio input { + appearance: none; + width: 17px; + height: 17px; + margin: 1.5px 0 0; + border: 1.5px solid var(--border-strong); + background: var(--background-surface); + cursor: pointer; + flex-shrink: 0; + transition: all 0.12s; + position: relative; +} +.check input { + border-radius: 5px; +} +.radio input { + border-radius: 50%; +} +.check input:checked, +.radio input:checked { + border-color: var(--color-primary); + background: var(--color-primary); +} +.check input:checked::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 4.5px; + height: 9px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: translate(-50%, -60%) rotate(45deg); +} +.check input:indeterminate { + border-color: var(--color-primary); + background: var(--color-primary); +} +.check input:indeterminate::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 9px; + height: 2px; + transform: translate(-50%, -50%); + background: #fff; + border-radius: 1px; +} +.radio input:checked::after { + content: ""; + position: absolute; + inset: 3.5px; + border-radius: 50%; + background: #fff; +} +.check input:focus-visible, +.radio input:focus-visible { + box-shadow: var(--focus-ring); +} +.check input:disabled, +.radio input:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.check.disabled, +.radio.disabled { + color: var(--text-muted); + cursor: not-allowed; +} +.switch { + display: inline-flex; + align-items: center; + gap: 9px; + cursor: pointer; + font-size: 13.5px; + user-select: none; +} +.switch input { + appearance: none; + width: 36px; + height: 20px; + border-radius: var(--radius-full); + background: var(--gray-300); + cursor: pointer; + transition: background 0.15s; + position: relative; + flex-shrink: 0; +} +[data-theme="dark"] .switch input { + background: var(--border-strong); +} +.switch input::after { + content: ""; + position: absolute; + left: 2px; + top: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + transition: left 0.15s; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} +.switch input:checked { + background: var(--color-primary); +} +.switch input:checked::after { + left: 18px; +} +.switch input:focus-visible { + box-shadow: var(--focus-ring); +} +.switch input:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.switch.switch-success input:checked { + background: var(--green-500); +} +input[type="range"].range { + appearance: none; + width: 100%; + height: 5px; + border-radius: var(--radius-full); + background: linear-gradient(to right, var(--color-primary) var(--range-pct, 50%), var(--background-inset) var(--range-pct, 50%)); + cursor: pointer; + border: none; + padding: 0; +} +input[type="range"].range::-webkit-slider-thumb { + appearance: none; + width: 17px; + height: 17px; + border-radius: 50%; + background: #fff; + border: 2px solid var(--color-primary); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + cursor: grab; +} +input[type="color"].color-input { + width: 44px; + height: 36px; + padding: 3px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--background-surface); + cursor: pointer; +} +.swatches { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.swatch { + width: 26px; + height: 26px; + border-radius: var(--radius-xs); + border: 2px solid transparent; + cursor: pointer; + padding: 0; +} +.swatch[aria-pressed="true"] { + border-color: var(--text-primary); + box-shadow: inset 0 0 0 2px var(--background-surface); +} +.file-input-native { + font-size: 13px; + color: var(--text-secondary); +} +.file-input-native::file-selector-button { + font: inherit; + font-size: 12.5px; + font-weight: 600; + margin-right: 10px; + height: 32px; + padding: 0 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--background-surface); + color: var(--text-primary); + cursor: pointer; +} +/* tags / chips / otp / rating / qty */ +.tag-input { + display: flex; + flex-wrap: wrap; + gap: 5px; + align-items: center; + min-height: 38px; + padding: 4px 6px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--background-surface); + cursor: text; +} +.tag-input:focus-within { + border-color: var(--border-focus); + box-shadow: var(--focus-ring); +} +.tag-input input { + border: none; + outline: none; + background: none; + font: inherit; + font-size: 13px; + flex: 1; + min-width: 100px; + color: var(--text-primary); + height: 26px; +} +.chip { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + font-weight: 500; + padding: 3px 5px 3px 9px; + border-radius: var(--radius-xs); + background: var(--color-primary-soft); + color: var(--color-primary); +} +.chip button { + border: none; + background: none; + cursor: pointer; + color: inherit; + opacity: 0.65; + display: grid; + place-items: center; + padding: 1px; + font-size: 12px; + border-radius: 3px; +} +.chip button:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.08); +} +.chip.chip-teal { + background: var(--teal-100); + color: var(--teal-700); +} +.chip.chip-violet { + background: var(--violet-100); + color: var(--violet-700); +} +.chip.chip-amber { + background: var(--amber-100); + color: var(--amber-700); +} +[data-theme="dark"] .chip.chip-teal { + background: rgba(20, 184, 166, 0.18); + color: #5eead4; +} +[data-theme="dark"] .chip.chip-violet { + background: rgba(139, 92, 246, 0.18); + color: #c4b5fd; +} +[data-theme="dark"] .chip.chip-amber { + background: rgba(245, 158, 11, 0.18); + color: #fcd34d; +} +.otp-group { + display: flex; + gap: 8px; +} +.otp-group .input { + width: 44px; + text-align: center; + font-family: var(--font-mono); + font-size: 17px; + font-weight: 600; + padding: 0; +} +.rating { + display: inline-flex; + gap: 2px; +} +.rating button { + border: none; + background: none; + cursor: pointer; + font-size: 21px; + color: var(--gray-300); + padding: 1px; + line-height: 1; +} +[data-theme="dark"] .rating button { + color: var(--border-strong); +} +.rating button.on { + color: var(--amber-500); +} +.qty { + display: inline-flex; + align-items: stretch; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + overflow: hidden; +} +.qty button { + width: 34px; + border: none; + background: var(--background-muted); + cursor: pointer; + font-size: 15px; + color: var(--text-secondary); + display: grid; + place-items: center; +} +.qty button:hover { + background: var(--background-inset); + color: var(--text-primary); +} +.qty input { + width: 56px; + border: none; + border-left: 1px solid var(--border-strong); + border-right: 1px solid var(--border-strong); + text-align: center; + font: inherit; + font-size: 13.5px; + background: var(--background-surface); + color: var(--text-primary); + height: 34px; +} +.qty input:focus { + outline: none; +} +.pw-strength { + display: flex; + gap: 4px; + margin-top: 2px; +} +.pw-strength span { + flex: 1; + height: 4px; + border-radius: 2px; + background: var(--background-inset); +} +.pw-strength[data-level="1"] span:nth-child(1) { + background: var(--red-500); +} +.pw-strength[data-level="2"] span:nth-child(-n+2) { + background: var(--amber-500); +} +.pw-strength[data-level="3"] span:nth-child(-n+3) { + background: var(--lime-500); +} +.pw-strength[data-level="4"] span { + background: var(--green-500); +} +/* combobox / autocomplete */ +.combobox { + position: relative; +} +.combo-list { + position: absolute; + left: 0; + right: 0; + top: calc(100% + 4px); + z-index: 60; + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-dropdown); + max-height: 230px; + overflow-y: auto; + display: none; + padding: 4px; +} +.combo-list.open { + display: block; +} +.combo-option { + padding: 7px 10px; + border-radius: var(--radius-xs); + cursor: pointer; + font-size: 13px; + display: flex; + align-items: center; + gap: 8px; +} +.combo-option:hover, +.combo-option.active { + background: var(--background-muted); +} +.combo-option .co-sub { + margin-left: auto; + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); +} +.combo-empty { + padding: 12px; + text-align: center; + color: var(--text-muted); + font-size: 12.5px; +} +.combo-option mark { + background: var(--amber-100); + color: inherit; + border-radius: 2px; +} +[data-theme="dark"] .combo-option mark { + background: rgba(245, 158, 11, 0.3); +} +.saved-views { + position: relative; + display: flex; + align-items: center; + min-width: 0; +} +.saved-views-main { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} +.saved-views-icon { + width: 32px; + height: 32px; + display: grid; + place-items: center; + color: var(--violet-600); + background: var(--violet-100); + border-radius: var(--radius-sm); + flex: 0 0 auto; +} +.saved-views .select { + width: 210px; + height: 34px; + font-size: 12.5px; +} +.saved-views-editor { + position: absolute; + z-index: 40; + right: 0; + top: calc(100% + 6px); + display: grid; + grid-template-columns: minmax(190px, 1fr) auto auto; + gap: 5px; + width: min(420px, calc(100vw - 40px)); + padding: 8px; + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-dropdown); +} +.saved-views-editor[hidden] { + display: none; +} +.saved-views-editor .input { + height: 32px; +} +.saved-views-cluster { + margin-left: auto; + flex-wrap: nowrap; +} +@media (max-width: 760px) { + .has-saved-views { + flex-wrap: wrap; + } + .has-saved-views > .saved-views-cluster { + width: 100%; + margin-left: 0; + justify-content: flex-start; + } + .saved-views-cluster > .saved-views { + flex: 1; + } + .saved-views-cluster > .badge { + margin-left: auto; + } + .saved-views { + width: 100%; + } + .saved-views-main { + width: 100%; + } + .saved-views .select { + width: 0; + min-width: 0; + flex: 1; + } + .saved-views-editor { + left: 0; + right: auto; + } +} +/* ============================================================ + 9. ТАБЛИЦЫ + ============================================================ */ +.table-wrap { + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + overflow: hidden; +} +.table-toolbar { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--border-default); + flex-wrap: wrap; +} +.table-scroll { + overflow-x: auto; + max-height: 560px; + overflow-y: auto; +} +table.table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + min-width: 900px; +} +.table th { + position: sticky; + top: 0; + z-index: 5; + background: var(--background-muted); + text-align: left; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + padding: 9px 12px; + border-bottom: 1px solid var(--border-default); + white-space: nowrap; + user-select: none; +} +.table th.sortable { + cursor: pointer; +} +.table th.sortable:hover { + color: var(--text-primary); +} +.table th .sort-ind { + font-size: 11px; + margin-left: 3px; + opacity: 0.7; +} +.table td { + padding: 9px 12px; + border-bottom: 1px solid var(--border-default); + vertical-align: middle; +} +.table tbody tr:last-child td { + border-bottom: none; +} +.table tbody :where(tr:nth-child(even)) td { + background: color-mix(in srgb, var(--background-muted) 55%, var(--background-surface)); +} +.table tbody tr:hover td { + background: var(--background-muted); +} +.table tbody tr.selected td { + background: var(--color-primary-soft); +} +.table .sticky-col { + position: sticky; + left: 0; + background: var(--background-surface); + z-index: 2; +} +.table th.sticky-col { + z-index: 6; + background: var(--background-muted); +} +.table tbody :where(tr:nth-child(even)) .sticky-col { + background: color-mix(in srgb, var(--background-muted) 55%, var(--background-surface)); +} +.table tbody tr:hover .sticky-col { + background: var(--background-muted); +} +.table tbody tr.selected .sticky-col { + background: var(--color-primary-soft); +} +.table.table-compact { + min-width: 600px; +} +.table.table-compact td { + padding: 5px 10px; + font-size: 12.5px; +} +.table.table-compact th { + padding: 6px 10px; +} +.table .table-name-column { + width: auto; +} +.table .table-compact-column { + width: 1%; + white-space: nowrap; +} +.table th.table-actions, +.table td.table-actions { + white-space: nowrap; + text-align: right; +} +.table td.table-actions > .cluster { + justify-content: flex-end; + flex-wrap: nowrap; + margin-left: auto; +} +.table th:last-child:empty, +.table th:last-child:has(> .sr-only) { + white-space: nowrap; + text-align: right; +} +.table td:last-child > .cluster { + justify-content: flex-end; + flex-wrap: nowrap; + margin-left: auto; +} +.table-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-top: 1px solid var(--border-default); + flex-wrap: wrap; + font-size: 12.5px; + color: var(--text-secondary); +} +.prod-cell { + display: flex; + align-items: center; + gap: 10px; + min-width: 220px; +} +.prod-thumb { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + flex-shrink: 0; + display: grid; + place-items: center; + font-size: 17px; + color: #fff; +} +.group-row td { + background: var(--background-muted) !important; + font-weight: 700; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary); + cursor: pointer; +} +.row-expand td { + background: var(--background-muted) !important; + padding: var(--space-4); +} +.cell-editable { + cursor: pointer; + border-bottom: 1px dashed var(--border-strong); +} +.cell-editable:hover { + color: var(--color-primary); +} +.cell-edit-input { + width: 90px; + font: inherit; + font-size: 12.5px; + padding: 2px 6px; + border: 1px solid var(--border-focus); + border-radius: var(--radius-xs); + background: var(--background-surface); + color: var(--text-primary); +} +.pagination { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; + padding-top: var(--space-3); +} +/* Единый вид всех вариантов страничных кнопок (.page/.page-link/.page-btn). */ +.pagination .page, +.pagination .page-link, +.page-btn { + min-width: 32px; + height: 32px; + padding: 0 10px; + box-sizing: border-box; + border: 1px solid var(--border-default); + background: var(--background-surface); + border-radius: var(--radius-xs); + font: inherit; + font-size: 12.5px; + font-weight: 600; + color: var(--text-secondary); + text-decoration: none; + cursor: pointer; + display: inline-grid; + place-items: center; + font-variant-numeric: tabular-nums; + transition: border-color 0.12s, color 0.12s, background-color 0.12s; +} +.pagination .page:hover, +.pagination .page-link:hover, +.page-btn:hover { + border-color: var(--border-strong); + color: var(--text-primary); +} +.pagination .page.current, +.pagination .page.active, +.pagination .page-link.active, +.page-btn.active { + background: var(--color-primary); + border-color: var(--color-primary); + color: #fff; + cursor: default; +} +.pagination .page.gap { + min-width: auto; + padding: 0 2px; + border-color: transparent; + background: transparent; + color: var(--text-muted); + cursor: default; +} +.page-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.bulk-bar { + display: none; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-4); + background: var(--color-primary-soft); + border-bottom: 1px solid var(--border-default); + font-size: 13px; + font-weight: 600; + color: var(--color-primary); + flex-wrap: wrap; +} +.bulk-bar.visible { + display: flex; +} +.skeleton { + background: linear-gradient(90deg, var(--background-inset) 25%, var(--background-muted) 50%, var(--background-inset) 75%); + background-size: 200% 100%; + animation: shimmer 1.4s infinite; + border-radius: var(--radius-xs); + color: transparent!important; + user-select: none; +} +@keyframes shimmer { + to { + background-position: -200% 0; + } +} +.matrix-check { + text-align: center; + width: 96px; +} +.matrix-check .check { + display: inline-flex; + margin: 0; + vertical-align: middle; +} +/* ============================================================ + 10. МОДАЛКИ / DRAWER / DROPDOWN + ============================================================ */ +.overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + z-index: 200; + display: none; + align-items: center; + justify-content: center; + padding: var(--space-4); +} +[data-theme="dark"] .overlay { + background: rgba(2, 6, 17, 0.7); +} +.overlay.open { + display: flex; + animation: fadeIn 0.15s ease; +} +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +.modal { + background: var(--background-surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-modal); + width: 100%; + max-width: 480px; + max-height: calc(100vh - 48px); + display: flex; + flex-direction: column; + animation: modalIn 0.18s ease; +} +@keyframes modalIn { + from { + opacity: 0; + transform: translateY(10px) scale(0.98); + } + to { + opacity: 1; + transform: none; + } +} +.modal-lg { + max-width: 760px; +} +.modal-xl { + max-width: 940px; +} +.picker-modal { + width: min(66.666vw, 1200px); + max-width: none; + height: min(66.666vh, 800px); + max-height: calc(100vh - 40px); +} +.picker-modal .modal-header, +.picker-modal .modal-footer { + flex: 0 0 auto; +} +.picker-modal .modal-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.modal-header { + display: flex; + align-items: flex-start; + gap: 12px; + padding: var(--space-5) var(--space-5) 0; +} +.modal-header h3 { + flex: 1; +} +.modal-close { + border: none; + background: none; + cursor: pointer; + color: var(--text-muted); + font-size: 18px; + padding: 4px; + border-radius: var(--radius-xs); + margin: -4px -4px 0 0; +} +.modal-close:hover { + color: var(--text-primary); + background: var(--background-inset); +} +.modal-body { + padding: var(--space-4) var(--space-5); + overflow-y: auto; + flex: 1; +} +.modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-4) var(--space-5); + border-top: 1px solid var(--border-default); + background: var(--background-muted); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); + flex-wrap: wrap; +} +.modal-footer .mf-left { + margin-right: auto; +} +.dialog-icon { + width: 42px; + height: 42px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 21px; + flex-shrink: 0; +} +.dialog-icon.info { + background: var(--color-primary-soft); + color: var(--color-primary); +} +.dialog-icon.success { + background: var(--color-success-soft); + color: var(--color-success); +} +.dialog-icon.warning { + background: var(--color-warning-soft); + color: var(--amber-600); +} +.dialog-icon.error { + background: var(--color-danger-soft); + color: var(--color-danger); +} +.dialog-icon.violet { + background: var(--color-violet-soft); + color: var(--color-violet); +} +.reauth-overlay { + z-index: 10020; +} +.reauth-modal { + max-width: 500px; +} +.reauth-modal .modal-header > div { + min-width: 0; + flex: 1; +} +.reauth-modal .modal-header h3 { + margin: 0; +} +.reauth-subtitle { + margin: 3px 0 0; + color: var(--text-muted); + font-size: 12.5px; +} +.reauth-reason { + margin: 0 0 var(--space-4); + padding: var(--space-3); + border-radius: var(--radius-sm); + background: var(--color-warning-soft); + color: var(--text-primary); + font-size: 13px; + line-height: 1.5; +} +.reauth-hint { + margin: var(--space-3) 0 0; + color: var(--text-muted); + font-size: 11.5px; + line-height: 1.45; +} +.reauth-modal .field-error { + margin-top: 2px; +} +@media (max-width: 640px) { + .overlay { + padding: 0; + align-items: flex-end; + } + .modal { + max-width: 100%; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + max-height: 94vh; + } + .picker-modal { + width: 100%; + height: 94vh; + max-height: 94vh; + } + .modal-footer { + border-radius: 0; + } +} +.drawer-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.5); + z-index: 190; + display: none; +} +.drawer-overlay.open { + display: block; + animation: fadeIn 0.15s; +} +.drawer { + position: fixed; + top: 0; + bottom: 0; + width: 440px; + max-width: 94vw; + background: var(--background-surface); + z-index: 195; + box-shadow: var(--shadow-modal); + display: flex; + flex-direction: column; + transition: transform 0.22s ease; +} +.drawer[hidden] { + display: none; +} +.drawer-right { + right: 0; + transform: translateX(102%); +} +.drawer-left { + left: 0; + transform: translateX(-102%); +} +.drawer.open { + transform: translateX(0); +} +.drawer-lg { + width: 640px; +} +/* Нижняя панель в стеке затеняется, когда поверх открыта вторая */ +.drawer.drawer-under::after { + content: ""; + position: absolute; + inset: 0; + z-index: 5; + background: rgba(15, 23, 42, 0.5); + animation: fadeIn 0.18s ease; +} +[data-theme="dark"] .drawer.drawer-under::after { + background: rgba(0, 0, 0, 0.55); +} +.drawer-header { + display: flex; + align-items: center; + gap: 10px; + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border-default); +} +.drawer-header > :first-child { + flex: 1 1 auto; + min-width: 0; +} +.drawer-header h3 { + margin: 0; + text-wrap: balance; +} +.drawer-header [data-close-drawer] { + flex: 0 0 auto; + width: 40px; + height: 40px; + min-width: 40px; + margin-left: auto; +} +.drawer-body { + padding: var(--space-5); + overflow-y: auto; + flex: 1; +} +.drawer-footer { + padding: var(--space-3) var(--space-5); + border-top: 1px solid var(--border-default); + display: flex; + justify-content: flex-end; + gap: var(--space-2); + background: var(--background-muted); +} +.drawer-footer > [data-close-drawer]:first-child { + margin-right: auto; +} +.drawer-footer > [data-close-drawer]:first-child ~ .btn { + margin-left: 0 !important; +} +.drawer-footer > :not(.btn) + [data-close-drawer] { + margin-right: var(--space-3); +} +.dropdown { + position: relative; + display: inline-block; +} +.dropdown-menu { + position: absolute; + top: calc(100% + 5px); + left: 0; + z-index: 70; + min-width: 200px; + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + box-shadow: var(--shadow-dropdown); + padding: 5px; + display: none; +} +.dropdown-menu.right { + left: auto; + right: 0; +} +.dropdown.open > .dropdown-menu { + display: block; + animation: modalIn 0.12s ease; +} +.dd-item { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 7px 10px; + border: none; + background: none; + border-radius: var(--radius-xs); + font: inherit; + font-size: 13px; + color: var(--text-primary); + cursor: pointer; + text-align: left; +} +.dd-item i { + font-size: 15px; + color: var(--text-muted); + width: 16px; +} +.dd-item:hover { + background: var(--background-muted); +} +.dd-item.danger { + color: var(--color-danger); +} +.dd-item.danger i { + color: var(--color-danger); +} +.dd-item .dd-kbd { + margin-left: auto; +} +.dd-divider { + height: 1px; + background: var(--border-default); + margin: 5px 4px; +} +.dd-label { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-muted); + padding: 6px 10px 3px; +} +.dd-sub { + position: relative; +} +.dd-sub > .dropdown-menu { + top: -6px; + left: calc(100% + 2px); +} +.dd-sub:hover > .dropdown-menu, +.dd-sub.open > .dropdown-menu { + display: block; +} +.context-menu { + position: fixed; + z-index: 250; + min-width: 190px; + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + box-shadow: var(--shadow-dropdown); + padding: 5px; + display: none; +} +.context-menu.open { + display: block; + animation: modalIn 0.1s; +} +/* tooltip & popover */ +[data-tooltip] { + position: relative; +} +/* Тултипы рендерятся плавающим .ax-tooltip во (Adminx.Tooltip): + CSS-::after обрезался любыми блоками с overflow (таблицы, канбан-колонки). + Псевдо-тултип отключён, чтобы не было дублей. */ +[data-tooltip]::after { + content: none; +} +.ax-tooltip { + position: fixed; + z-index: 9990; + pointer-events: none; + max-width: 280px; + background: var(--gray-800); + color: #fff; + font-size: 11.5px; + font-weight: 500; + line-height: 1.35; + padding: 5px 9px; + border-radius: var(--radius-xs); + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.22); + opacity: 0; + transform: translateY(2px); + transition: opacity 0.12s, transform 0.12s; +} +.ax-tooltip.show { + opacity: 1; + transform: translateY(0); +} +[data-theme="dark"] .ax-tooltip { + background: var(--gray-100); + color: var(--gray-900); +} +[data-tooltip-right]::after { + content: attr(data-tooltip-right); + position: absolute; + left: calc(100% + 8px); + top: 50%; + transform: translateY(-50%); + background: var(--gray-800); + color: #fff; + font-size: 11.5px; + padding: 5px 9px; + border-radius: var(--radius-xs); + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.12s; + z-index: 130; +} +[data-tooltip-right]:hover::after { + opacity: 1; +} +.popover { + position: absolute; + z-index: 80; + width: 280px; + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + box-shadow: var(--shadow-dropdown); + padding: var(--space-4); + display: none; + top: calc(100% + 6px); + left: 0; +} +.popover.open { + display: block; + animation: modalIn 0.12s; +} +/* ============================================================ + 11. TOAST / ALERTS / BANNERS + ============================================================ */ +.toast-region { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 300; + display: flex; + flex-direction: column; + gap: 8px; + width: min(360px, calc(100vw - 32px)); +} +.toast { + display: flex; + gap: 10px; + align-items: flex-start; + background: var(--background-surface); + border: 1px solid var(--border-default); + border-left: 3px solid var(--toast-accent, var(--gray-400)); + border-radius: var(--radius-md); + box-shadow: var(--shadow-dropdown); + padding: 11px 12px; + animation: toastIn 0.2s ease; + font-size: 13px; +} +@keyframes toastIn { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: none; + } +} +.toast.leaving { + opacity: 0; + transform: translateX(20px); + transition: all 0.18s; +} +.toast i.toast-ic { + font-size: 17px; + color: var(--toast-accent, var(--gray-400)); + margin-top: 1px; +} +.toast .toast-title { + font-weight: 600; +} +.toast .toast-msg { + color: var(--text-secondary); + font-size: 12.5px; +} +.toast .toast-x { + margin-left: auto; + border: none; + background: none; + color: var(--text-muted); + cursor: pointer; + font-size: 15px; + padding: 2px; + border-radius: 4px; +} +.toast .toast-x:hover { + color: var(--text-primary); +} +.toast .toast-action { + border: none; + background: none; + color: var(--color-primary); + font: inherit; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + padding: 0; + margin-top: 3px; +} +.toast-progress { + height: 3px; + border-radius: 2px; + background: var(--background-inset); + margin-top: 7px; + overflow: hidden; +} +.toast-progress div { + height: 100%; + background: var(--toast-accent, var(--color-primary)); + width: 0; + transition: width 0.25s linear; +} +.toast[data-kind="info"] { + --toast-accent: var(--blue-500); +} +.toast[data-kind="success"] { + --toast-accent: var(--green-500); +} +.toast[data-kind="warning"] { + --toast-accent: var(--amber-500); +} +.toast[data-kind="danger"] { + --toast-accent: var(--red-500); +} +.toast[data-kind="automation"] { + --toast-accent: var(--violet-500); +} +.toast[data-kind="integration"] { + --toast-accent: var(--cyan-500); +} +.alert { + display: flex; + gap: 11px; + align-items: flex-start; + padding: 12px 14px; + border-radius: var(--radius-md); + border: 1px solid; + font-size: 13px; +} +.alert i.alert-ic { + font-size: 17px; + margin-top: 1px; +} +.alert .alert-title { + font-weight: 700; + margin-bottom: 2px; +} +.alert-neutral { + background: var(--background-muted); + border-color: var(--border-default); + color: var(--text-secondary); +} +.alert-info { + background: var(--color-primary-soft); + border-color: color-mix(in srgb, var(--blue-500) 30%, transparent); + color: var(--blue-700); +} +.alert-success { + background: var(--color-success-soft); + border-color: color-mix(in srgb, var(--green-500) 30%, transparent); + color: var(--green-700); +} +.alert-warning { + background: var(--color-warning-soft); + border-color: color-mix(in srgb, var(--amber-500) 35%, transparent); + color: var(--amber-700); +} +.alert-danger { + background: var(--color-danger-soft); + border-color: color-mix(in srgb, var(--red-500) 30%, transparent); + color: var(--red-700); +} +.alert-automation { + background: var(--color-violet-soft); + border-color: color-mix(in srgb, var(--violet-500) 30%, transparent); + color: var(--violet-700); +} +.alert-integration { + background: var(--color-cyan-soft); + border-color: color-mix(in srgb, var(--cyan-500) 30%, transparent); + color: var(--cyan-700); +} +[data-theme="dark"] .alert-info { + color: #93c5fd; +} +[data-theme="dark"] .alert-success { + color: #86efac; +} +[data-theme="dark"] .alert-warning { + color: #fcd34d; +} +[data-theme="dark"] .alert-danger { + color: #fca5a5; +} +[data-theme="dark"] .alert-automation { + color: #c4b5fd; +} +[data-theme="dark"] .alert-integration { + color: #67e8f9; +} +.banner { + display: flex; + align-items: center; + gap: 11px; + padding: 10px 16px; + font-size: 13px; + font-weight: 500; + border-radius: var(--radius-md); +} +.banner .btn { + margin-left: auto; +} +.banner-update { + background: var(--indigo-100); + color: var(--indigo-600); +} +.banner-maintenance { + background: var(--amber-100); + color: var(--amber-700); +} +.banner-offline { + background: var(--gray-800); + color: #fff; +} +[data-theme="dark"] .banner-update { + background: rgba(99, 102, 241, 0.18); + color: #a5b4fc; +} +[data-theme="dark"] .banner-maintenance { + background: rgba(245, 158, 11, 0.16); + color: #fcd34d; +} +/* ============================================================ + 12. NAV-КОМПОНЕНТЫ: TABS / STEPPER / ACCORDION / TREE + ============================================================ */ +.tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--border-default); + overflow-x: auto; +} +.tab { + border: none; + background: none; + font: inherit; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + padding: 9px 14px; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + display: inline-flex; + align-items: center; + gap: 7px; + white-space: nowrap; +} +.tab:hover { + color: var(--text-primary); +} +.tab[aria-selected="true"], +.tab.active, +.tab.is-active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} +.tab .badge { + font-size: 10.5px; + padding: 1px 7px; +} +.tab-panel { + display: none; + padding-top: var(--space-4); +} +.tab-panel.active { + display: block; +} +.tabs-vertical { + display: flex; + gap: var(--space-5); +} +.tabs-vertical .tabs { + flex-direction: column; + border-bottom: none; + border-right: 1px solid var(--border-default); + min-width: 180px; + overflow: visible; +} +.tabs-vertical .tab { + justify-content: flex-start; + border-bottom: none; + border-right: 2px solid transparent; + margin: 0 -1px 0 0; + text-align: left; + padding: 8px 14px; +} +.tabs-vertical .tab[aria-selected="true"], +.tabs-vertical .tab.active, +.tabs-vertical .tab.is-active { + border-right-color: var(--color-primary); + background: var(--color-primary-soft); + border-radius: var(--radius-sm) 0 0 var(--radius-sm); +} +.pills { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.pill { + border: 1px solid var(--border-default); + background: var(--background-surface); + font: inherit; + font-size: 12.5px; + font-weight: 600; + color: var(--text-secondary); + padding: 5px 13px; + border-radius: var(--radius-full); + cursor: pointer; +} +.pill:hover { + border-color: var(--border-strong); +} +.pill[aria-pressed="true"] { + background: var(--color-primary); + border-color: var(--color-primary); + color: #fff; +} +@media (max-width: 680px) { + .tabs-vertical { + flex-direction: column; + } + .tabs-vertical .tabs { + flex-direction: row; + border-right: none; + border-bottom: 1px solid var(--border-default); + } +} +.stepper { + display: flex; + gap: 0; +} +.step { + flex: 1; + display: flex; + align-items: center; + gap: 9px; + position: relative; + padding-right: var(--space-4); +} +.step:not(:last-child)::after { + content: ""; + flex: 1; + height: 2px; + background: var(--border-default); + border-radius: 1px; +} +.step-num { + width: 28px; + height: 28px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 12.5px; + font-weight: 700; + background: var(--background-inset); + color: var(--text-muted); + flex-shrink: 0; + border: 2px solid transparent; +} +.step.active .step-num { + background: var(--color-primary); + color: #fff; +} +.step.done .step-num { + background: var(--color-success-soft); + color: var(--color-success); +} +.step.done:not(:last-child)::after { + background: var(--green-400, #4ade80); +} +.step-label { + font-size: 12.5px; + font-weight: 600; + white-space: nowrap; +} +.step.active .step-label { + color: var(--color-primary); +} +.step:not(.active):not(.done) .step-label { + color: var(--text-muted); +} +@media (max-width: 760px) { + .step-label { + display: none; + } +} +.accordion { + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); + overflow: hidden; +} +.acc-item + .acc-item { + border-top: 1px solid var(--border-default); +} +.acc-trigger { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + padding: 13px 16px; + border: none; + background: none; + font: inherit; + font-size: 13.5px; + font-weight: 600; + color: var(--text-primary); + cursor: pointer; + text-align: left; +} +.acc-trigger:hover { + background: var(--background-muted); +} +.acc-trigger .acc-chev { + order: 2; + margin-left: auto; + transition: transform 0.18s; + color: var(--text-muted); +} +.acc-trigger[aria-expanded="true"] .acc-chev { + transform: rotate(90deg); +} +.acc-panel { + display: none; + padding: 0 16px 16px; + color: var(--text-secondary); + font-size: 13px; +} +.acc-panel.open { + display: block; +} +.tree { + list-style: none; + padding: 0; + margin: 0; + font-size: 13px; +} +.tree ul { + list-style: none; + padding-left: 22px; + margin: 0; + display: none; +} +.tree li.open > ul { + display: block; +} +.tree-row { + display: flex; + align-items: center; + gap: 7px; + padding: 5px 8px; + border-radius: var(--radius-xs); + cursor: pointer; + user-select: none; +} +.tree-row:hover { + background: var(--background-muted); +} +.tree-row i.tree-chev { + font-size: 13px; + color: var(--text-muted); + transition: transform 0.15s; + width: 14px; +} +.tree li.open > .tree-row i.tree-chev { + transform: rotate(90deg); +} +.tree-row i.tree-ic { + color: var(--amber-500); + font-size: 16px; +} +.tree-row i.tree-file { + color: var(--text-muted); + font-size: 15px; +} +/* ============================================================ + 13. DRAG & DROP: KANBAN / SORTABLE / DROPZONE / GALLERY + ============================================================ */ +[draggable="true"] { + cursor: grab; +} +.dragging { + opacity: 0.45; +} +.drag-handle { + cursor: grab; + color: var(--text-muted); + display: inline-flex; + padding: 3px; + border-radius: 4px; + touch-action: none; +} +.drag-handle:hover { + color: var(--text-secondary); + background: var(--background-inset); +} +.kanban { + display: grid; + grid-template-columns: repeat(4, minmax(240px, 1fr)); + gap: var(--space-3); + overflow-x: auto; + align-items: start; +} +@media (max-width: 1100px) { + .kanban { + grid-template-columns: repeat(4, 260px); + } +} +.kanban-col { + background: var(--background-muted); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + max-height: 600px; +} +.kanban-col-header { + display: flex; + align-items: center; + gap: 8px; + padding: 11px 13px; + font-weight: 700; + font-size: 13px; +} +.kanban-col-header .col-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--col-color, var(--gray-400)); +} +.kanban-count { + margin-left: auto; + font-size: 11px; + font-weight: 700; + background: var(--background-surface); + border: 1px solid var(--border-default); + color: var(--text-secondary); + padding: 1px 8px; + border-radius: var(--radius-full); + font-family: var(--font-mono); +} +.kanban-cards { + padding: 0 9px 9px; + display: flex; + flex-direction: column; + gap: 8px; + overflow-y: auto; + min-height: 60px; + flex: 1; +} +.kanban-card { + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + padding: 11px; + font-size: 12.5px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); +} +.kanban-card:active { + cursor: grabbing; +} +.kanban-card .kc-title { + font-weight: 600; + font-size: 13px; + margin-bottom: 6px; +} +.kanban-card .kc-meta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + color: var(--text-muted); + font-size: 11.5px; +} +.kanban-cards.drag-over { + outline: 2px dashed var(--color-primary); + outline-offset: -4px; + border-radius: var(--radius-md); + background: var(--color-primary-soft); +} +.drop-placeholder { + height: 3px; + border-radius: 2px; + background: var(--color-primary); + margin: 2px 0; +} +.prio { + width: 34px; + height: 4px; + border-radius: 2px; + display: inline-block; +} +.prio-high { + background: var(--red-500); +} +.prio-med { + background: var(--amber-500); +} +.prio-low { + background: var(--green-500); +} +ul.sortable { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 7px; +} +.sortable-item { + display: flex; + align-items: center; + gap: 10px; + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + padding: 10px 12px; + font-size: 13px; +} +.sortable-item .si-pos { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + width: 22px; +} +.sortable-item.drag-over-top { + box-shadow: 0 -3px 0 0 var(--color-primary); +} +.sortable-item.drag-over-bottom { + box-shadow: 0 3px 0 0 var(--color-primary); +} +.dropzone { + border: 2px dashed var(--border-strong); + border-radius: var(--radius-lg); + padding: var(--space-8); + text-align: center; + color: var(--text-secondary); + background: var(--background-muted); + transition: all 0.15s; + cursor: pointer; +} +.dropzone:hover { + border-color: var(--color-primary); +} +.dropzone.drag-over { + border-color: var(--color-primary); + background: var(--color-primary-soft); +} +.dropzone i { + font-size: 34px; + color: var(--text-muted); + display: block; + margin-bottom: 8px; +} +.upload-item { + display: flex; + gap: 11px; + align-items: center; + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + padding: 9px 12px; +} +.upload-thumb { + width: 42px; + height: 42px; + border-radius: var(--radius-sm); + object-fit: cover; + flex-shrink: 0; + background: var(--background-inset); +} +.upload-icon { + width: 42px; + height: 42px; + border-radius: var(--radius-sm); + display: grid; + place-items: center; + background: var(--background-inset); + color: var(--text-secondary); + font-size: 19px; + flex-shrink: 0; +} +.gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--space-3); +} +.gallery.list-mode { + grid-template-columns: 1fr; +} +.gallery-item { + position: relative; + margin: 0; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--background-surface); +} +.gallery-item .g-img { + aspect-ratio: 4/3; + display: grid; + place-items: center; + font-size: 30px; + color: #fff; + cursor: pointer; +} +.gallery.list-mode .gallery-item { + display: flex; + align-items: center; + gap: 12px; + padding-right: 10px; +} +.gallery.list-mode .g-img { + width: 76px; + aspect-ratio: 4/3; + font-size: 22px; + flex-shrink: 0; +} +.gallery-item .g-meta { + padding: 8px 10px; + font-size: 11.5px; +} +.gallery-item .g-check { + position: absolute; + top: 7px; + left: 7px; + z-index: 2; +} +.gallery-item .g-cover { + position: absolute; + top: 7px; + right: 7px; + z-index: 2; + border: none; + background: rgba(15, 23, 42, 0.5); + color: #fff; + border-radius: var(--radius-xs); + padding: 3px 5px; + cursor: pointer; + font-size: 13px; + display: grid; + place-items: center; +} +.gallery-item .g-cover.on { + background: var(--amber-500); +} +.gallery-item.selected { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} +.gallery-item.is-cover::after { + content: "Обложка"; + position: absolute; + bottom: 38px; + left: 7px; + background: var(--amber-500); + color: #fff; + font-size: 10px; + font-weight: 700; + padding: 2px 7px; + border-radius: var(--radius-xs); +} +.gallery.list-mode .gallery-item.is-cover::after { + bottom: auto; + top: 7px; + left: 90px; +} +.lightbox-img { + width: min(640px, 90vw); + aspect-ratio: 4/3; + border-radius: var(--radius-md); + display: grid; + place-items: center; + font-size: 64px; + color: #fff; +} +.dual-list { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4); +} +@media (max-width: 680px) { + .dual-list { + grid-template-columns: 1fr; + } +} +.dual-panel { + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); + display: flex; + flex-direction: column; + min-height: 260px; +} +.dual-panel-header { + padding: 10px 13px; + border-bottom: 1px solid var(--border-default); + font-weight: 700; + font-size: 12.5px; + display: flex; + justify-content: space-between; + align-items: center; +} +.dual-panel .sortable { + padding: 9px; + flex: 1; +} +.dual-panel .sortable.drag-over { + background: var(--color-primary-soft); +} +.dual-panel .sortable-item { + padding: 7px 10px; + font-size: 12.5px; +} +.menu-builder-item { + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + padding: 9px 12px; + display: flex; + align-items: center; + gap: 9px; + font-size: 13px; +} +.mb-level-1 { + margin-left: 32px; +} +.mb-level-2 { + margin-left: 64px; +} +.mb-off { + opacity: 0.55; +} +.CodeMirror { + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.55; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} +.CodeMirror-focused { + border-color: var(--border-focus); + box-shadow: var(--focus-ring); +} +.CodeMirror-gutters { + border-right: 1px solid var(--border-default); + background: var(--background-muted); +} +.CodeMirror-linenumber { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 12px; +} +.CodeMirror-activeline-background { + background: var(--background-muted); +} +.CodeMirror pre.CodeMirror-line, +.CodeMirror pre.CodeMirror-line-like { + padding: 0 10px; +} +.CodeMirror-scroll { + min-height: 180px; +} +.CodeMirror-fullscreen { + position: fixed !important; + inset: 12px; + z-index: 500; + width: auto !important; + height: auto !important; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-modal); +} +.CodeMirror-fullscreen .CodeMirror-scroll { + height: 100% !important; +} +.CodeMirror-dialog { + border-bottom: 1px solid var(--border-default); + background: var(--background-surface); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 13px; +} +.CodeMirror-dialog input { + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--background-surface); + color: var(--text-primary); + font: inherit; + height: 32px; + padding: 0 10px; +} +[data-theme="dark"] .CodeMirror:not(.cm-s-material-ocean) { + background: var(--background-surface); + color: var(--text-primary); +} +[data-theme="dark"] .CodeMirror-gutters { + background: var(--background-muted); + border-right-color: var(--border-default); +} +.code-template-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.code-template-tags button { + min-height: 32px; + padding: 3px 7px; + border: 0; + border-radius: 4px; + background: var(--background-muted); + color: var(--text-secondary); + cursor: pointer; + transition: background-color 150ms ease, color 150ms ease, transform 150ms ease; +} +.code-template-tags button:hover { + background: var(--blue-100); + color: var(--blue-700); +} +.code-template-tags button:active { + transform: scale(0.96); +} +.code-template-tags code { + padding: 0; + background: transparent; + color: inherit; + font-size: 12px; +} +.rich-editor { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--background-surface); + overflow: hidden; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} +.rich-editor-toolbar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 10px 12px; + border-bottom: 1px solid var(--border-default); + background: var(--background-muted); +} +.rich-editor-group { + display: inline-flex; + align-items: center; + gap: 2px; + padding-right: 6px; + margin-right: 4px; + border-right: 1px solid var(--border-default); +} +.rich-editor-group:last-child { + border-right: 0; + margin-right: 0; + padding-right: 0; +} +.rich-editor-btn[aria-pressed="true"] { + background: var(--color-primary-soft); + color: var(--color-primary); +} +.rich-editor-block-select { + width: auto; + min-width: 128px; + height: 32px; + padding-top: 0; + padding-bottom: 0; + font-size: 12px; + font-weight: 600; +} +.rich-editor-surface { + background: var(--background-surface); +} +.rich-editor-inline-source { + display: none; + min-height: 260px; + background: var(--gray-900); +} +.rich-editor-source-open .rich-editor-surface { + display: none; +} +.rich-editor-source-open .rich-editor-inline-source { + display: block; +} +.rich-editor-inline-source .CodeMirror { + width: 100%; + min-height: 260px; + border: 0; + border-radius: 0; + box-shadow: none; +} +.rich-editor-source-open .rich-editor-toolbar [disabled]:not([data-rich-command="source"]) { + opacity: 0.42; + cursor: not-allowed; +} +.rich-editor-content { + min-height: 260px; +} +.rich-editor-prose { + min-height: 260px; + padding: 18px 20px; + color: var(--text-primary); + font-size: 14px; + line-height: 1.65; + outline: none; +} +.rich-editor-fixed-height .rich-editor-surface { + height: var(--rich-editor-height); + overflow: auto; +} +.rich-editor.rich-editor-fixed-height .rich-editor-content, +.rich-editor.rich-editor-fixed-height .rich-editor-prose { + min-height: var(--rich-editor-height); +} +.rich-editor-fixed-height .rich-editor-inline-source, +.rich-editor-fixed-height .rich-editor-inline-source .CodeMirror { + height: var(--rich-editor-height); + min-height: 0; +} +.rich-editor-prose > *:first-child { + margin-top: 0; +} +.rich-editor-prose > *:last-child { + margin-bottom: 0; +} +.rich-editor-prose p, +.rich-editor-prose ul, +.rich-editor-prose ol, +.rich-editor-prose blockquote, +.rich-editor-prose pre, +.rich-editor-prose table { + margin: 0 0 14px; +} +.rich-editor-prose h1, +.rich-editor-prose h2, +.rich-editor-prose h3, +.rich-editor-prose h4, +.rich-editor-prose h5, +.rich-editor-prose h6 { + margin: 18px 0 10px; + font-family: var(--font-display); + line-height: 1.25; + font-weight: 800; + letter-spacing: 0; + text-wrap: balance; +} +.rich-editor-prose h1 { + font-size: 26px; +} +.rich-editor-prose h2 { + font-size: 22px; +} +.rich-editor-prose h3 { + font-size: 19px; +} +.rich-editor-prose h4 { + font-size: 17px; +} +.rich-editor-prose h5 { + font-size: 15px; +} +.rich-editor-prose h6 { + font-size: 14px; +} +.rich-editor-show-blocks .rich-editor-prose, +.rich-editor-show-blocks .rich-editor-prose p, +.rich-editor-show-blocks .rich-editor-prose h1, +.rich-editor-show-blocks .rich-editor-prose h2, +.rich-editor-show-blocks .rich-editor-prose h3, +.rich-editor-show-blocks .rich-editor-prose h4, +.rich-editor-show-blocks .rich-editor-prose h5, +.rich-editor-show-blocks .rich-editor-prose h6, +.rich-editor-show-blocks .rich-editor-prose ul, +.rich-editor-show-blocks .rich-editor-prose ol, +.rich-editor-show-blocks .rich-editor-prose li, +.rich-editor-show-blocks .rich-editor-prose blockquote, +.rich-editor-show-blocks .rich-editor-prose pre, +.rich-editor-show-blocks .rich-editor-prose table, +.rich-editor-show-blocks .rich-editor-prose th, +.rich-editor-show-blocks .rich-editor-prose td { + position: relative; + outline: 1px dashed color-mix(in srgb, var(--text-muted) 42%, transparent); + outline-offset: 2px; +} +.rich-editor-show-blocks .rich-editor-prose::before, +.rich-editor-show-blocks .rich-editor-prose p::before, +.rich-editor-show-blocks .rich-editor-prose h1::before, +.rich-editor-show-blocks .rich-editor-prose h2::before, +.rich-editor-show-blocks .rich-editor-prose h3::before, +.rich-editor-show-blocks .rich-editor-prose h4::before, +.rich-editor-show-blocks .rich-editor-prose h5::before, +.rich-editor-show-blocks .rich-editor-prose h6::before, +.rich-editor-show-blocks .rich-editor-prose ul::before, +.rich-editor-show-blocks .rich-editor-prose ol::before, +.rich-editor-show-blocks .rich-editor-prose li::before, +.rich-editor-show-blocks .rich-editor-prose blockquote::before, +.rich-editor-show-blocks .rich-editor-prose pre::before, +.rich-editor-show-blocks .rich-editor-prose table::before, +.rich-editor-show-blocks .rich-editor-prose th::before, +.rich-editor-show-blocks .rich-editor-prose td::before { + position: absolute; + z-index: 2; + top: -8px; + right: 5px; + padding: 0 3px; + background: var(--background-surface); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 9px; + font-weight: 600; + line-height: 1.4; + pointer-events: none; +} +.rich-editor-show-blocks .rich-editor-prose::before { + content: "div"; + top: 2px; +} +.rich-editor-show-blocks .rich-editor-prose p::before { + content: "p"; +} +.rich-editor-show-blocks .rich-editor-prose h1::before { + content: "h1"; +} +.rich-editor-show-blocks .rich-editor-prose h2::before { + content: "h2"; +} +.rich-editor-show-blocks .rich-editor-prose h3::before { + content: "h3"; +} +.rich-editor-show-blocks .rich-editor-prose h4::before { + content: "h4"; +} +.rich-editor-show-blocks .rich-editor-prose h5::before { + content: "h5"; +} +.rich-editor-show-blocks .rich-editor-prose h6::before { + content: "h6"; +} +.rich-editor-show-blocks .rich-editor-prose ul::before { + content: "ul"; +} +.rich-editor-show-blocks .rich-editor-prose ol::before { + content: "ol"; +} +.rich-editor-show-blocks .rich-editor-prose li::before { + content: "li"; +} +.rich-editor-show-blocks .rich-editor-prose blockquote::before { + content: "blockquote"; +} +.rich-editor-show-blocks .rich-editor-prose pre::before { + content: "pre"; + background: var(--gray-900); + color: #aebbd1; +} +.rich-editor-show-blocks .rich-editor-prose table::before { + content: "table"; +} +.rich-editor-show-blocks .rich-editor-prose th::before { + content: "th"; +} +.rich-editor-show-blocks .rich-editor-prose td::before { + content: "td"; +} +.rich-editor-prose ul, +.rich-editor-prose ol { + padding-left: 22px; +} +.rich-editor-prose blockquote { + padding: 10px 14px; + border-left: 3px solid var(--color-primary); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + background: var(--color-primary-soft); + color: var(--text-secondary); +} +.rich-editor-prose code { + font-family: var(--font-mono); + font-size: 12.5px; + padding: 2px 5px; + border-radius: var(--radius-xs); + background: var(--background-inset); +} +.rich-editor-prose pre { + padding: 14px 16px; + border-radius: var(--radius-md); + background: var(--gray-900); + color: #e6ecf7; + overflow: auto; +} +.rich-editor-prose pre code { + padding: 0; + background: transparent; + color: inherit; +} +.rich-editor-prose a { + color: var(--text-link); + text-decoration: underline; + text-underline-offset: 2px; +} +.rich-editor-prose img { + display: block; + max-width: 100%; + height: auto; + border-radius: var(--radius-md); + outline: 1px solid rgba(0, 0, 0, 0.1); + outline-offset: -1px; +} +.rich-editor-prose picture { + display: block; + width: fit-content; + max-width: 100%; +} +[data-theme="dark"] .rich-editor-prose img { + outline-color: rgba(255, 255, 255, 0.1); +} +.rich-editor-prose table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} +.rich-editor-prose th, +.rich-editor-prose td { + position: relative; + min-width: 1em; + padding: 8px 10px; + border: 1px solid var(--border-default); + vertical-align: top; +} +.rich-editor-prose th { + background: var(--background-muted); + font-weight: 700; +} +.rich-editor-prose .selectedCell::after { + content: ""; + position: absolute; + inset: 0; + background: color-mix(in srgb, var(--color-primary) 16%, transparent); + pointer-events: none; +} +.rich-editor-prose .ProseMirror-selectednode { + outline: 2px solid var(--color-primary); +} +.rich-editor-source-modal { + width: min(1200px, calc(100vw - 32px)); + max-width: none; + height: min(82vh, 820px); + max-height: calc(100vh - 32px); +} +.rich-editor-source-modal .modal-body { + padding: 0; + min-height: 0; + overflow: hidden; +} +.rich-editor-source-modal .CodeMirror { + height: 100% !important; + border: 0; + border-radius: 0; + box-shadow: none; +} +.rich-editor-link-modal { + width: min(680px, calc(100vw - 32px)); + max-width: none; +} +.rich-editor-link-body { + display: grid; + gap: 16px; +} +.rich-editor-link-sources { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.rich-editor-link-sources .btn { + justify-content: center; +} +.rich-editor-link-selection { + min-height: 20px; + margin-top: -8px; + font-size: 12px; +} +.rich-editor-document-picker { + width: min(66.666vw, 1000px); + max-width: none; + height: auto; + max-height: calc(100vh - 40px); +} +.rich-editor-document-picker .modal-body { + min-height: 0; + overflow: hidden; +} +.rich-editor-document-search { + flex: 0 0 auto; + margin-bottom: 12px; +} +.rich-editor-document-status { + padding: 30px 20px; + border: 1px dashed var(--border-default); + border-radius: var(--radius-md); + background: var(--background-muted); + color: var(--text-secondary); + text-align: center; +} +.rich-editor-document-list { + display: grid; + gap: 0; + min-height: 0; + max-height: min(52vh, 520px); + overflow-y: auto; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); +} +.rich-editor-document-list:empty { + display: none; +} +.rich-editor-document-item { + display: grid; + grid-template-columns: 62px minmax(0, 1fr) minmax(120px, 190px); + align-items: center; + gap: 12px; + width: 100%; + min-height: 58px; + padding: 9px 12px; + border: 0; + border-bottom: 1px solid var(--border-subtle); + border-radius: 0; + background: transparent; + color: var(--text-primary); + text-align: left; + cursor: pointer; +} +.rich-editor-document-item:hover { + background: var(--color-primary-soft); +} +.rich-editor-document-item:last-child { + border-bottom: 0; +} +.rich-editor-document-item > span:nth-child(2) { + display: grid; + min-width: 0; +} +.rich-editor-document-item b, +.rich-editor-document-item small, +.rich-editor-document-item em { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.rich-editor-document-item small, +.rich-editor-document-item em { + color: var(--text-secondary); + font-size: 11px; + font-style: normal; +} +.rich-editor-document-id { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 12px; +} +.rich-editor-image-modal { + width: min(1040px, calc(100vw - 32px)); + max-width: none; + max-height: calc(100vh - 32px); +} +.rich-editor-image-body { + display: grid; + grid-template-columns: minmax(280px, 0.9fr) minmax(360px, 1.1fr); + gap: 20px; + min-height: 0; +} +.rich-editor-image-preview { + position: relative; + display: grid; + place-items: center; + min-height: 390px; + overflow: hidden; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: linear-gradient(45deg, var(--background-inset) 25%, transparent 25%), linear-gradient(-45deg, var(--background-inset) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--background-inset) 75%), linear-gradient(-45deg, transparent 75%, var(--background-inset) 75%), var(--background-surface); + background-position: 0 0, 0 8px, 8px -8px, -8px 0; + background-size: 16px 16px; +} +.rich-editor-image-preview img { + display: block; + max-width: calc(100% - 28px); + max-height: 520px; + object-fit: contain; + outline: 1px solid rgba(15, 23, 42, 0.12); +} +.rich-editor-image-empty { + display: grid; + justify-items: center; + gap: 8px; + color: var(--text-muted); +} +.rich-editor-image-empty[hidden], +.rich-editor-image-preview img[hidden] { + display: none; +} +.rich-editor-image-empty i { + font-size: 34px; +} +.rich-editor-image-preview-size { + position: absolute; + right: 10px; + bottom: 10px; + min-height: 22px; + padding: 3px 7px; + border-radius: var(--radius-sm); + background: rgba(15, 23, 42, 0.78); + color: #fff; + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.6; +} +.rich-editor-image-preview-size:empty { + display: none; +} +.rich-editor-image-settings { + display: grid; + align-content: start; + gap: 14px; + min-width: 0; +} +.rich-editor-image-picture-switch { + justify-self: start; +} +.rich-editor-image-size { + display: grid; + gap: 12px; + min-width: 0; + margin: 0; + padding: 14px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); +} +.rich-editor-image-size legend { + padding: 0 5px; + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; +} +.rich-editor-image-dimensions { + display: grid; + grid-template-columns: minmax(0, 1fr) 30px minmax(0, 1fr); + align-items: end; + gap: 8px; +} +.rich-editor-image-lock { + display: grid; + place-items: center; + height: 40px; + color: var(--text-muted); +} +.rich-editor-image-scale { + display: grid; + grid-template-columns: minmax(0, 1fr) 48px auto; + align-items: center; + gap: 10px; +} +.rich-editor-image-scale input[type="range"] { + width: 100%; + accent-color: var(--color-primary); +} +.rich-editor-image-scale output { + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 12px; + text-align: right; +} +@media (max-width: 640px) { + .rich-editor-source-modal { + width: 100%; + height: 94vh; + max-height: 94vh; + } + .rich-editor-link-modal, + .rich-editor-image-modal { + width: 100%; + height: 94vh; + max-height: 94vh; + } + .rich-editor-document-picker { + width: 100%; + height: auto; + max-height: 94vh; + } + .rich-editor-link-sources { + grid-template-columns: 1fr; + } + .rich-editor-document-item { + grid-template-columns: 52px minmax(0, 1fr); + } + .rich-editor-document-item em { + display: none; + } + .rich-editor-image-body { + grid-template-columns: 1fr; + } + .rich-editor-image-preview { + min-height: 240px; + } + .rich-editor-image-scale { + grid-template-columns: minmax(0, 1fr) 44px; + } + .rich-editor-image-scale .btn { + grid-column: 1 / -1; + } +} +/* Единый медиа-пикер (Adminx.MediaPicker) — общий для полей, рубрик, + навигации и визуального редактора. */ +.media-picker-overlay.show { + display: flex; +} +.media-picker { + width: min(66.666vw, 1200px); + height: min(66.666vh, 800px); + max-height: calc(100vh - 40px); +} +.media-picker-overlay .modal-body { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.media-picker-tools { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} +.media-picker-tools .input-wrap { + flex: 1 1 auto; + min-width: 0; +} +.media-picker-tools [data-mp-url-toggle] { + flex: 0 0 auto; + white-space: nowrap; +} +.media-picker-url { + display: flex; + gap: 8px; + margin-bottom: 12px; +} +.media-picker-url[hidden] { + display: none; +} +.media-picker-url .input { + flex: 1 1 auto; + min-width: 0; +} +.media-picker-crumbs { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 12px; + min-width: 0; + overflow-x: auto; +} +.media-picker-crumb, +.media-picker-sep { + flex: 0 0 auto; +} +.media-picker-crumb { + border: 0; + background: transparent; + color: var(--blue-600); + font-size: 12px; + cursor: pointer; +} +.media-picker-sep { + color: var(--text-muted); + font-size: 12px; +} +.media-picker-status { + padding: 18px; + color: var(--text-secondary); + text-align: center; +} +.media-picker-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 12px; + grid-auto-rows: max-content; + flex: 1 1 auto; + align-content: start; + min-height: 0; + overflow: auto; +} +.media-picker-item { + display: grid; + grid-template-rows: auto minmax(62px, auto); + gap: 0; + min-width: 0; + padding: 0; + overflow: hidden; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); + color: var(--text-primary); + text-align: left; + cursor: pointer; + transition: border-color 0.14s, box-shadow 0.14s, transform 0.14s; + align-self: start; +} +.media-picker-item:hover { + transform: translateY(-1px); + border-color: var(--border-default); + background: var(--background-muted); + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.07); +} +.media-picker-item:focus-visible { + outline: none; + border-color: var(--blue-200); + border-radius: var(--radius-md); + box-shadow: 0 0 0 2px var(--color-primary-soft); +} +.media-picker-item:active { + transform: translateY(0); +} +.media-picker-item .media-picker-thumb { + position: relative; + display: grid; + place-items: center; + aspect-ratio: 1; + overflow: hidden; + border-bottom: 1px solid var(--border-subtle); + background: var(--background-inset); + color: var(--text-secondary); + font-size: 22px; +} +.media-picker-item img { + width: 100%; + height: 100%; + object-fit: cover; +} +.media-picker-meta { + display: grid; + align-content: center; + gap: 4px; + min-width: 0; + padding: 10px 11px; + background: var(--background-card); +} +.media-picker-item b, +.media-picker-item small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.media-picker-item b { + font-size: 12px; + line-height: 1.3; +} +.media-picker-item small { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--text-secondary); +} +.media-picker-extension { + position: absolute; + right: 7px; + bottom: 7px; + padding: 2px 5px; + border-radius: var(--radius-xs); + background: rgba(15, 23, 42, 0.76); + color: #fff; + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + line-height: 1.4; + text-transform: uppercase; +} +.media-picker-item.is-folder .media-picker-thumb { + width: auto; + height: auto; + aspect-ratio: 1; + border: 0; + border-bottom: 1px solid var(--border-subtle); + border-radius: 0; + background: var(--blue-50); + color: var(--blue-600); +} +.media-picker-item.is-folder { + grid-template-columns: none; + grid-template-rows: auto minmax(62px, auto); + align-items: stretch; + gap: 0; + min-height: 0; + padding: 0; +} +.media-picker-item.is-folder .media-picker-meta { + align-content: center; + padding: 10px 11px; + background: var(--background-card); +} +.media-picker-folder-icon { + display: grid; + place-items: center; + width: 66px; + height: 54px; + border-radius: var(--radius-md); + background: var(--blue-100); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--blue-300) 65%, transparent); +} +.media-picker-folder-icon i { + font-size: 34px; +} +.media-picker-item.is-folder:hover .media-picker-folder-icon { + background: var(--blue-200); +} +.media-picker-count { + font-size: 12px; + color: var(--text-secondary); +} +/* ============================================================ + 14. БОЛЬШИЕ ФОРМЫ / FORM BUILDER / CMS / ПРОЧЕЕ + ============================================================ */ +.bigform-layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: var(--space-5); + align-items: start; +} +@media (max-width: 900px) { + .bigform-layout { + grid-template-columns: 1fr; + } + .bigform-nav { + display: none; + } +} +.bigform-nav { + position: sticky; + top: calc(var(--topbar-h) + 16px); + display: flex; + flex-direction: column; + gap: 2px; +} +.bigform-nav a { + padding: 6px 11px; + border-radius: var(--radius-sm); + font-size: 12.5px; + font-weight: 500; + color: var(--text-secondary); + border-left: 2px solid transparent; +} +.bigform-nav a:hover { + color: var(--text-primary); + background: var(--background-muted); + text-decoration: none; +} +.bigform-nav a.active { + color: var(--color-primary); + border-left-color: var(--color-primary); + background: var(--color-primary-soft); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} +.form-section { + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + margin-bottom: var(--space-4); + scroll-margin-top: calc(var(--topbar-h) + 16px); +} +.form-section-header { + display: flex; + align-items: center; + gap: 10px; + padding: 13px 18px; + cursor: pointer; + user-select: none; +} +.form-section-header h4 { + flex: 1; + font-size: 13.5px; +} +.form-section-header .fs-chev { + color: var(--text-muted); + transition: transform 0.15s; +} +.form-section.collapsed .form-section-header .fs-chev { + transform: rotate(-90deg); +} +.form-section-body { + padding: 4px 18px 18px; + border-top: 1px solid var(--border-default); + padding-top: var(--space-4); +} +.form-section.collapsed .form-section-body { + display: none; +} +.sticky-actions { + position: sticky; + bottom: 0; + z-index: 50; + background: var(--background-header); + backdrop-filter: blur(8px); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 11px 16px; + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; + box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06); + margin-top: var(--space-4); +} +.completeness { + display: flex; + align-items: center; + gap: 9px; + font-size: 12px; + color: var(--text-secondary); +} +.completeness .progress { + width: 110px; +} +.validation-summary { + border: 1px solid color-mix(in srgb, var(--red-500) 30%, transparent); + background: var(--color-danger-soft); + border-radius: var(--radius-md); + padding: 13px 16px; + font-size: 13px; +} +.validation-summary ul { + margin: 6px 0 0; + padding-left: 18px; +} +.validation-summary a { + color: var(--red-700); + font-weight: 600; +} +[data-theme="dark"] .validation-summary a { + color: #fca5a5; +} +.fb-layout { + display: grid; + grid-template-columns: 210px 1fr 250px; + gap: var(--space-4); + align-items: start; +} +@media (max-width: 1000px) { + .fb-layout { + grid-template-columns: 1fr; + } +} +.fb-palette { + display: flex; + flex-direction: column; + gap: 6px; +} +.fb-palette-item { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 11px; + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + font-size: 12.5px; + font-weight: 500; + cursor: grab; +} +.fb-palette-item i { + color: var(--color-violet); + font-size: 15px; +} +.fb-palette-item:hover { + border-color: var(--violet-300, #c4b5fd); +} +.fb-canvas { + background: var(--background-muted); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: var(--space-4); + min-height: 420px; + display: flex; + flex-direction: column; + gap: 8px; +} +.fb-canvas.drag-over { + outline: 2px dashed var(--color-violet); + outline-offset: -4px; +} +.fb-canvas-empty { + border: 2px dashed var(--border-strong); + border-radius: var(--radius-md); + padding: var(--space-8); + text-align: center; + color: var(--text-muted); + font-size: 13px; +} +.fb-el { + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + padding: 10px 12px; + position: relative; +} +.fb-el.selected { + border-color: var(--color-violet); + box-shadow: 0 0 0 3px var(--color-violet-soft); +} +.fb-el-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} +.fb-el-head .fb-type { + font-family: var(--font-mono); + font-size: 10px; + color: var(--color-violet); + background: var(--color-violet-soft); + padding: 1px 7px; + border-radius: var(--radius-full); + text-transform: uppercase; + font-weight: 600; +} +.fb-el-head .fb-tools { + margin-left: auto; + display: flex; + gap: 2px; +} +.fb-el-head .fb-tools button { + border: none; + background: none; + color: var(--text-muted); + cursor: pointer; + padding: 3px; + border-radius: 4px; + font-size: 14px; + display: grid; + place-items: center; +} +.fb-el-head .fb-tools button:hover { + background: var(--background-inset); + color: var(--text-primary); +} +.fb-props .field + .field { + margin-top: var(--space-3); +} +.fb-el .fb-preview { + pointer-events: none; +} +.fb-el.w-50 { + width: calc(50% - 4px); +} +.fb-group { + border: 1px dashed var(--violet-300, #c4b5fd); + border-radius: var(--radius-md); + padding: 10px; + background: var(--color-violet-soft); +} +.serp-preview { + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + padding: 14px 16px; + background: var(--background-surface); +} +.serp-url { + font-size: 12px; + color: var(--green-700); +} +[data-theme="dark"] .serp-url { + color: #86efac; +} +.serp-title { + font-size: 17px; + color: #1a0dab; + font-weight: 500; + margin: 2px 0; +} +[data-theme="dark"] .serp-title { + color: #8ab4f8; +} +.serp-desc { + font-size: 12.5px; + color: var(--text-secondary); +} +.list-row { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 14px; + border-bottom: 1px solid var(--border-default); + font-size: 13px; +} +.list-row:last-child { + border-bottom: none; +} +.list-row:hover { + background: var(--background-muted); +} +.list-panel { + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + overflow: hidden; +} +.virtual-viewport { + height: 320px; + overflow-y: auto; + position: relative; +} +.activity-item { + display: flex; + gap: 12px; + padding: 10px 0; + position: relative; +} +.activity-item::before { + content: ""; + position: absolute; + left: 14px; + top: 38px; + bottom: -8px; + width: 2px; + background: var(--border-default); +} +.activity-item:last-child::before { + display: none; +} +.activity-ic { + width: 30px; + height: 30px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 14px; + flex-shrink: 0; + z-index: 1; +} +.error-screen { + text-align: center; + padding: var(--space-8) var(--space-5); + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius-lg); + background: var(--background-surface); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; +} +.error-screen h4 { + margin: 0; +} +.error-screen p { + margin: 0; + max-width: 36ch; +} +.error-code { + font-family: var(--font-mono); + font-size: 44px; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1; + color: var(--text-primary); +} +.error-ic { + width: 64px; + height: 64px; + border-radius: var(--radius-lg); + display: grid; + place-items: center; + font-size: 32px; + margin: 0 auto 2px; + background: var(--background-inset); + color: var(--text-muted); +} +.cmdk { + max-width: 560px; + width: 100%; + align-self: flex-start; + margin-top: 12vh; + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-modal); + overflow: hidden; +} +.cmdk-input { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 18px; + border-bottom: 1px solid var(--border-default); +} +.cmdk-input input { + flex: 1; + border: none; + outline: none; + background: none; + font: inherit; + font-size: 15px; + color: var(--text-primary); +} +.cmdk-list { + max-height: 340px; + overflow-y: auto; + padding: 7px; +} +.cmdk-item { + display: flex; + align-items: center; + gap: 11px; + padding: 9px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 13.5px; +} +.cmdk-item i { + color: var(--text-muted); + font-size: 16px; + width: 18px; +} +.cmdk-item:hover, +.cmdk-item.active { + background: var(--color-primary-soft); +} +.cmdk-item .cmdk-cat { + margin-left: auto; + font-size: 11px; + color: var(--text-muted); +} +.cmdk-footer { + padding: 9px 16px; + border-top: 1px solid var(--border-default); + display: flex; + gap: 14px; + font-size: 11.5px; + color: var(--text-muted); +} +.notif-panel { + position: fixed; + top: calc(var(--topbar-h) + 8px); + right: 12px; + width: min(380px, calc(100vw - 24px)); + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-dropdown); + z-index: 150; + display: none; + max-height: 70vh; + flex-direction: column; +} +.notif-panel.open { + display: flex; + animation: modalIn 0.15s; +} +.notif-item { + display: flex; + gap: 11px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-default); + font-size: 12.5px; +} +.notif-item.unread { + background: var(--color-primary-soft); +} +.notif-item:last-child { + border-bottom: none; +} +.code-block { + background: var(--gray-900); + color: #c9d6ee; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.7; + padding: 14px 16px; + border-radius: var(--radius-md); + overflow-x: auto; + white-space: pre; +} +[data-theme="dark"] .code-block { + background: #0a0f1d; + border: 1px solid var(--border-default); +} +.carousel { + position: relative; + overflow: hidden; + border-radius: var(--radius-lg); +} +.carousel-track { + display: flex; + transition: transform 0.3s ease; +} +.carousel-slide { + min-width: 100%; + aspect-ratio: 21/8; + display: grid; + place-items: center; + color: #fff; + font-family: var(--font-display); + font-weight: 800; + font-size: 22px; +} +.carousel-nav { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 6px; +} +.carousel-nav button { + width: 8px; + height: 8px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.5); + cursor: pointer; + padding: 0; +} +.carousel-nav button.active { + background: #fff; +} +.carousel-arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + border: none; + background: rgba(15, 23, 42, 0.4); + color: #fff; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + display: grid; + place-items: center; + font-size: 16px; +} +.carousel-arrow:hover { + background: rgba(15, 23, 42, 0.6); +} +.type-specimen { + display: grid; + grid-template-columns: 150px 1fr; + gap: var(--space-2) var(--space-5); + align-items: baseline; +} +.type-specimen .spec-label { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); +} +@media (max-width: 680px) { + .type-specimen { + grid-template-columns: 1fr; + } +} +.demo-row { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: center; +} +.demo-block { + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: var(--space-4); +} +.demo-block > .demo-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + margin-bottom: var(--space-3); +} +/* Доступное визуальное скрытие (для меток скринридера). Без него текст + рендерится как обычный и, напр., распирал таблицы (.commerce-orders-table). */ +.sr-only { + position: absolute!important; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +/* Supplementary styles */ +.spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border-strong); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; + display: inline-block; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.tab-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + margin-left: 7px; + border-radius: 9px; + background: var(--background-inset); + font-size: 10.5px; + font-weight: 700; + font-family: var(--font-mono); +} +.tab[aria-selected="true"] .tab-count, +.tab.active .tab-count, +.tab.is-active .tab-count { + background: var(--color-primary-soft); + color: var(--color-primary); +} +.tab:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.exp-chev { + transition: transform 0.16s ease; + color: var(--text-muted); + flex-shrink: 0; +} +.list-expandable { + cursor: pointer; +} +.list-expandable[aria-expanded="true"] .exp-chev { + transform: rotate(90deg); +} +.list-expand-body { + padding: 4px 16px 14px 42px; + border-bottom: 1px solid var(--border-default); + background: var(--background-muted); +} +.list-expand-body:last-child { + border-bottom: none; +} +.tree { + font-size: 13.5px; +} +.tree-item { + display: flex; + align-items: center; + gap: 7px; + padding: 6px 8px; + border-radius: var(--radius-sm); + cursor: default; +} +.tree-item:hover { + background: var(--background-muted); +} +.tree-toggle { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: none; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; + flex-shrink: 0; +} +.tree-toggle:hover { + background: var(--background-inset); + color: var(--text-primary); +} +.tree-item[aria-expanded="false"] .tree-toggle i { + transform: rotate(-90deg); +} +.tree-toggle i { + transition: transform 0.16s ease; +} +.tree-children { + margin-left: 22px; + border-left: 1px solid var(--border-default); + padding-left: 6px; +} +.avatar.sm { + width: 20px; + height: 20px; + font-size: 9px; +} +.badge-gray { + background: var(--gray-100); + color: var(--gray-600); +} +[data-theme="dark"] .badge-gray { + background: var(--gray-800); + color: var(--gray-300); +} +.input-lg { + height: 44px; +} +.attr-row { + align-items: center; +} +.wz-panel { + min-height: 150px; +} +.between { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +/* ===== Fix pack: недостающие стили и алиасы состояний ===== */ +.overlay.show { + display: flex; + animation: fadeIn 0.15s ease; +} +.drawer:not(.drawer-left) { + right: 0; + transform: translateX(102%); +} +.drawer.show { + transform: translateX(0) !important; +} +.drawer-overlay.show { + display: block; + animation: fadeIn 0.15s ease; +} +.popover.show { + display: block; + animation: modalIn 0.12s ease; +} +.bulk-bar.show { + display: flex; +} +.notif-panel.show { + display: flex; + animation: modalIn 0.15s ease; +} +.sidebar-backdrop.show { + opacity: 1; + pointer-events: auto; +} +.page-btn.current { + background: var(--color-primary); + border-color: var(--color-primary); + color: #fff; +} +.context-menu:not([hidden]) { + display: block; + animation: modalIn 0.1s ease; +} +.combo-list:not([hidden]) { + display: block; +} +.combo-option[aria-selected="true"] { + background: var(--background-muted); +} +/* stepper: точки шага */ +.step-dot { + width: 28px; + height: 28px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 12.5px; + font-weight: 700; + background: var(--background-inset); + color: var(--text-muted); + flex-shrink: 0; + border: 2px solid transparent; +} +.step.active .step-dot { + background: var(--color-primary); + color: #fff; +} +.step.done .step-dot { + background: var(--color-success-soft); + color: var(--color-success); +} +/* toast: тело, закрытие, прогресс */ +.toast { + position: relative; + overflow: hidden; +} +.toast-body { + flex: 1; + min-width: 0; +} +.toast-close { + margin-left: auto; + border: none; + background: none; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + padding: 2px; + border-radius: 4px; + flex-shrink: 0; +} +.toast-close:hover { + color: var(--text-primary); + background: var(--background-muted); +} +.toast .toast-progress { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 3px; + margin: 0; + border-radius: 0; +} +.toast-progress-bar { + height: 100%; + background: var(--toast-accent, var(--color-primary)); + width: 0; + transition: width 0.25s linear; +} +/* галерея: подписи и панель действий */ +.g-cap { + padding: 8px 10px; + font-size: 11.5px; + font-weight: 600; + margin: 0; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.g-actions { + position: absolute; + bottom: 34px; + right: 7px; + z-index: 2; + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.15s ease; +} +.gallery-item:hover .g-actions, +.gallery-item:focus-within .g-actions { + opacity: 1; +} +.gallery-item.is-cover .g-cover { + background: var(--amber-500); +} +.gallery.list-mode .gallery-item { + display: flex; + align-items: center; + gap: 10px; + padding-right: 10px; +} +.gallery.list-mode .g-cap { + flex: 1; +} +.gallery.list-mode .g-actions { + position: static; + opacity: 1; +} +.gallery.list-mode .g-check { + position: static; + order: -1; + margin-left: 10px; +} +.gallery.list-mode .g-cover { + position: static; +} +/* шапка и сетка */ +.logo-text { + font-family: var(--font-display); + font-weight: 800; + font-size: 15px; + letter-spacing: -0.01em; +} +@media (max-width: 640px) { + .hide-mobile { + display: none; + } +} +.col-1 { + grid-column: span 1; +} +.col-2 { + grid-column: span 2; +} +.col-5 { + grid-column: span 5; +} +.col-7 { + grid-column: span 7; +} +@media (max-width: 760px) { + .col-2, + .col-5, + .col-7 { + grid-column: span 12; + } +} +.text-danger { + color: var(--red-600); +} +/* FAB: у кнопки есть data-tooltip, а правило [data-tooltip]{position:relative} идёт позже .fab и перебивало fixed */ +.fab { + position: fixed; +} +/* сайдбар: убираем горизонтальную прокрутку */ +.sidebar-scroll { + overflow-x: hidden; +} +.sidebar .nav-item > span:not(.nav-count) { + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +/* тултипы-подписи справа показываем только в свёрнутом сайдбаре (как в ките) */ +body:not(.sidebar-collapsed) .sidebar [data-tooltip-right]::after { + display: none; +} +/* в свёрнутом режиме снимаем обрезку, чтобы тултип выходил за пределы полосы; + z-index поднимает сайдбар над контентом (но ниже топбара=90), иначе тултип + перекрывают позиционированные иконки/карточки в главном блоке */ +body.sidebar-collapsed .sidebar { + overflow: visible; + z-index: 60; +} +body.sidebar-collapsed .sidebar-scroll { + overflow: visible; + overflow-x: visible; +} +/* вкладки: без внутреннего скролла и с видимой линией активной вкладки */ +.tabs { + overflow: visible; + flex-wrap: wrap; +} +.tabs-vertical .tabs { + flex-wrap: nowrap; +} +@media (max-width: 680px) { + .tabs-vertical .tabs { + flex-wrap: wrap; + } +} +@media (prefers-reduced-motion: reduce) { + .overlay.show, + .drawer, + .popover.show, + .notif-panel.show, + .context-menu:not([hidden]), + .toast { + animation: none!important; + transition: none !important; + } +} +/* ============================================================ + ADMINX — компоненты каркаса, которых нет в AdminKit.html + (экран логина, stat-плитки дашборда, loader, toast-region) + ============================================================ */ +/* --- Центральная часть на всю ширину (не зажимать max-width) --- */ +.main-inner { + max-width: none; + margin: 0; +} +/* --- Loader overlay --- */ +body.is-loading::after { + content: ""; + position: fixed; + inset: 0; + z-index: 9998; + background: rgba(15, 23, 42, 0.08); + backdrop-filter: blur(1px); +} +body.is-loading::before { + content: ""; + position: fixed; + top: 0; + left: 0; + right: 0; + height: 3px; + z-index: 9999; + background: linear-gradient(90deg, transparent, var(--color-primary), transparent); + animation: adminx-bar 1s linear infinite; +} +@keyframes adminx-bar { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} +/* --- Toast region (создаётся из JS) --- */ +.toast-region { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 8px; +} +.toast-region .toast { + min-width: 220px; + max-width: 360px; + padding: 11px 14px; + border-radius: var(--radius-md); + background: var(--toast-fill, var(--gray-700)); + border: none; + box-shadow: var(--shadow-lg); + color: #fff; + font-size: 13px; + font-weight: 500; + opacity: 0; + transform: translateY(8px); + transition: opacity 0.2s, transform 0.2s; +} +.toast-region .toast.show { + opacity: 1; + transform: translateY(0); +} +/* Тосты — сплошная заливка по типу (без цветного бордера) */ +.toast-region .toast .toast-title { + color: #fff; +} +.toast-region .toast .toast-msg { + color: rgba(255, 255, 255, 0.85); +} +.toast-region .toast i.toast-ic { + color: #fff; +} +.toast-region .toast .toast-x, +.toast-region .toast .toast-close { + color: rgba(255, 255, 255, 0.85); +} +.toast-region .toast .toast-x:hover, +.toast-region .toast .toast-close:hover { + color: #fff; + background: rgba(255, 255, 255, 0.16); +} +.toast-region .toast .toast-action { + color: #fff; + text-decoration: underline; +} +.toast-region .toast-success { + --toast-fill: var(--color-success); +} +.toast-region .toast-error { + --toast-fill: var(--color-danger); +} +.toast-region .toast-info { + --toast-fill: var(--color-primary); +} +.toast-region .toast-warning { + --toast-fill: var(--color-warning); +} +/* --- Sidebar collapsed / mobile (каркас) --- */ +body.sidebar-collapsed .sidebar { + width: 64px; +} +body.sidebar-collapsed .sidebar .logo-text, +body.sidebar-collapsed .sidebar .nav-group-toggle, +body.sidebar-collapsed .sidebar .nav-item span, +body.sidebar-collapsed .sidebar .nav-count, +body.sidebar-collapsed .sidebar .nav-item .nav-chev, +body.sidebar-collapsed .sidebar .sf-text, +body.sidebar-collapsed .sidebar .sf-chev { + display: none; +} +body.sidebar-collapsed .sidebar .nav-group-panel { + grid-template-rows: 1fr!important; + opacity: 1 !important; +} +/* иконки по центру узкой полосы, раскрытые подсписки скрыты */ +body.sidebar-collapsed .sidebar .nav-item { + justify-content: center; + padding: 8px 0; +} +body.sidebar-collapsed .sidebar .nav-sub { + display: none !important; +} +body.sidebar-collapsed .sidebar .sidebar-footer { + justify-content: center; + padding-left: 0; + padding-right: 0; +} +body.sidebar-collapsed .sidebar .sidebar-user { + justify-content: center; + padding: 6px 0; +} +body.sidebar-collapsed .main { + margin-left: 0; +} +/* Свёрнутый сайдбар: подменю раскрывается всплывающим flyout при наведении. */ +body.sidebar-collapsed .sidebar .nav-sub.flyout-open { + display: flex!important; + position: fixed; + z-index: 1200; + min-width: 210px; + max-width: 300px; + margin: 0; + padding: 6px; + gap: 1px; + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + box-shadow: 0 8px 30px rgba(15, 23, 42, 0.18); +} +body.sidebar-collapsed .sidebar .nav-sub.flyout-open::before { + content: attr(data-flyout-title); + display: block; + padding: 7px 10px 9px; + margin-bottom: 4px; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + border-bottom: 1px solid var(--border-default); +} +body.sidebar-collapsed .sidebar .nav-sub.flyout-open .nav-item { + justify-content: flex-start; + padding: 6px 10px; + font-size: 12.5px; +} +body.sidebar-collapsed .sidebar .nav-sub.flyout-open .nav-item span { + display: inline !important; +} +body.sidebar-collapsed .sidebar .nav-sub.flyout-open .nav-item .nav-count { + display: inline-block !important; + margin-left: auto; +} +@media (max-width: 900px) { + .sidebar { + position: fixed; + left: 0; + top: var(--topbar-h, 56px); + bottom: 0; + transform: translateX(-100%); + transition: transform 0.2s; + z-index: 80; + } + body.sidebar-open .sidebar { + transform: translateX(0); + } + body.sidebar-open .sidebar-backdrop { + display: block; + } + .sidebar-backdrop { + position: fixed; + inset: 0; + top: var(--topbar-h, 56px); + background: rgba(15, 23, 42, 0.4); + z-index: 70; + } + /* на мобиле сайдбар — полноширинная шторка; свёрнутое состояние не применяем */ + body.sidebar-collapsed .sidebar { + width: 280px; + } + body.sidebar-collapsed .sidebar .logo-text, + body.sidebar-collapsed .sidebar .nav-group-toggle, + body.sidebar-collapsed .sidebar .nav-item span, + body.sidebar-collapsed .sidebar .nav-count, + body.sidebar-collapsed .sidebar .nav-item .nav-chev, + body.sidebar-collapsed .sidebar .sf-text, + body.sidebar-collapsed .sidebar .sf-chev { + display: revert; + } + body.sidebar-collapsed .sidebar .nav-group-toggle { + display: flex; + } + body.sidebar-collapsed .sidebar .nav-group.is-collapsed .nav-group-panel { + grid-template-rows: 0fr!important; + opacity: 0 !important; + } + body.sidebar-collapsed .sidebar .sf-text { + display: flex; + } + body.sidebar-collapsed .sidebar .nav-item { + justify-content: flex-start; + padding: 7px 10px; + } + body.sidebar-collapsed .sidebar .sidebar-footer { + justify-content: flex-start; + padding-left: 8px; + padding-right: 8px; + } + body.sidebar-collapsed .sidebar .sidebar-user { + justify-content: flex-start; + padding: 6px 8px; + } +} +/* --- Экран логина --- */ +.auth-body { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--background-inset, #f1f5f9); +} +.auth-wrap { + width: 100%; + max-width: 400px; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} +.auth-card { + padding: 28px; +} +.auth-head { + text-align: center; + margin-bottom: 18px; +} +.auth-head h1 { + font-size: 22px; + margin: 12px 0 4px; +} +.logo-mark-lg { + width: 52px; + height: 52px; + border-radius: 14px; + font-size: 26px; + margin: 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; +} +.auth-logo-h { + margin: 0 0 8px; + line-height: 0; + text-align: center; +} +.auth-logo { + height: 46px; + width: auto; + display: inline-block; +} +.auth-form { + display: flex; + flex-direction: column; + gap: 14px; +} +.auth-form .switch-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-secondary); +} +.auth-language { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} +.auth-language form { + margin: 0; +} +.auth-language .btn { + min-height: 40px; + gap: 7px; +} +.auth-language .btn.active { + background: var(--color-primary-soft); + color: var(--color-primary); +} +.btn-block { + width: 100%; + justify-content: center; +} +.auth-foot { + text-align: center; +} +/* --- Stat-плитки дашборда --- */ +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 14px; + margin-top: 8px; +} +.stat-tile { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; +} +.stat-ico { + width: 38px; + height: 38px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 19px; + background: var(--color-primary-soft); + color: var(--color-primary); + flex: 0 0 auto; +} +/* Канонические цветовые модификаторы иконки статистики (были продублированы по модулям) */ +.stat-ico.is-blue { + background: var(--blue-100); + color: var(--blue-600); +} +.stat-ico.is-green { + background: var(--green-100); + color: var(--green-600); +} +.stat-ico.is-red { + background: var(--red-100); + color: var(--red-600); +} +.stat-ico.is-amber { + background: var(--amber-100); + color: var(--amber-600); +} +.stat-ico.is-violet { + background: var(--violet-100); + color: var(--violet-600); +} +.stat-ico.is-cyan { + background: var(--cyan-100); + color: var(--cyan-600); +} +.stat-ico.is-gray { + background: var(--gray-100); + color: var(--gray-600); +} +.stat-val { + font-size: 20px; + font-weight: 800; + font-family: var(--font-display, inherit); + font-variant-numeric: tabular-nums; + line-height: 1.1; +} +.stat-label { + font-size: 13px; + color: var(--text-secondary); + margin-top: 2px; +} +/* --- Drawer с формой:
не должен рвать flex-колонку, иначе футер + не прижимается к низу. Форма растягивается, body скроллится, футер снизу. --- */ +.drawer > form { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} +/* --- Строки прав в назначении роли (название + код + описание) --- */ +.perm-row { + align-items: flex-start; +} +.perm-row > span { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} +.perm-row .perm-name { + font-weight: 500; + color: var(--text-primary); +} +.perm-row .perm-code { + opacity: 0.7; +} +.perm-row .perm-desc { + line-height: 1.35; +} +/* --- Единые отступы структурных блоков разделов (без разнобоя) --- */ +/* Подзаголовок под H1 в page-header */ +.page-header p { + margin-top: 5px; +} +/* Панельный заголовок раздела (section-header {module}-panel-header): размеры/отступ + едины, цвет акцента задаётся в модульном less через --section-accent */ +.ax-panel-header { + margin-bottom: 18px; +} +.ax-panel-header h2 { + font-size: 22px; + line-height: 1.2; + font-weight: 800; +} +.ax-panel-header .section-desc { + font-size: 13.5px; + line-height: 1.45; +} +/* Ряд карточек-сводок */ +.ax-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 14px; + margin-bottom: 16px; +} +/* Пустое состояние таблицы */ +/* Канонический вид пустого состояния (раньше .empty-state был без стилей) */ +.empty-state, +.ax-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + text-align: center; + padding: 30px 20px; + color: var(--text-secondary); + font-size: 13.5px; + line-height: 1.45; +} +/* display:flex перебивает браузерный [hidden] — возвращаем скрытие */ +.empty-state[hidden], +.ax-empty[hidden] { + display: none; +} +.empty-state b, +.ax-empty b { + display: block; + color: var(--text-primary); + font-size: 14px; +} +.empty-state small, +.ax-empty small { + color: var(--text-muted); + font-size: 12.5px; +} +.empty-state > i:first-child, +.ax-empty > i:first-child { + margin-bottom: 4px; + font-size: 26px; + color: var(--text-muted); +} +/* --- Цветные иконки действий в таблицах (единый набор для всех разделов) --- */ +.ax-act { + transition: background-color 0.15s, color 0.15s, transform 0.15s; +} +.ax-act:active { + transform: scale(0.96); +} +.ax-act-edit { + color: var(--blue-600); +} +.ax-act-copy { + color: var(--violet-600); +} +.ax-act-view { + color: var(--cyan-600); +} +.ax-act-ok { + color: var(--green-600); +} +.ax-act-warn { + color: var(--amber-600); +} +.ax-act-danger { + color: var(--red-600); +} +/* --- Мелкие утилиты разделов --- */ +.avatar-sm { + width: 26px; + height: 26px; + font-size: 11px; + border-radius: 8px; +} +.input.is-invalid, +.select.is-invalid { + border-color: var(--color-danger); + box-shadow: 0 0 0 3px var(--color-danger-soft); +} +.field-error { + display: block; + color: var(--color-danger); + font-size: 12px; + margin-top: 4px; + min-height: 1px; +} +/* --- Error screen (на случай отсутствия в fixpack) --- */ +.error-screen { + max-width: 460px; + margin: 8vh auto; + text-align: center; +} +.error-screen .error-code { + font-size: 64px; + font-weight: 800; + color: var(--color-primary); + font-family: var(--font-display, inherit); +} +.error-screen h1 { + margin: 8px 0; +} +.error-screen .btn { + margin-top: 18px; +} diff --git a/adminx/assets/fonts/NunitoSans-Bold.woff2 b/adminx/assets/fonts/NunitoSans-Bold.woff2 new file mode 100644 index 0000000..4bff232 Binary files /dev/null and b/adminx/assets/fonts/NunitoSans-Bold.woff2 differ diff --git a/adminx/assets/fonts/NunitoSans-ExtraBold.woff2 b/adminx/assets/fonts/NunitoSans-ExtraBold.woff2 new file mode 100644 index 0000000..1f86e45 Binary files /dev/null and b/adminx/assets/fonts/NunitoSans-ExtraBold.woff2 differ diff --git a/adminx/assets/fonts/NunitoSans-Regular.woff2 b/adminx/assets/fonts/NunitoSans-Regular.woff2 new file mode 100644 index 0000000..27a375b Binary files /dev/null and b/adminx/assets/fonts/NunitoSans-Regular.woff2 differ diff --git a/adminx/assets/fonts/OpenSans-Bold.woff2 b/adminx/assets/fonts/OpenSans-Bold.woff2 new file mode 100644 index 0000000..f8fdcd4 Binary files /dev/null and b/adminx/assets/fonts/OpenSans-Bold.woff2 differ diff --git a/adminx/assets/fonts/OpenSans-Regular.woff2 b/adminx/assets/fonts/OpenSans-Regular.woff2 new file mode 100644 index 0000000..bec5afa Binary files /dev/null and b/adminx/assets/fonts/OpenSans-Regular.woff2 differ diff --git a/adminx/assets/fonts/OpenSans-SemiBold.woff2 b/adminx/assets/fonts/OpenSans-SemiBold.woff2 new file mode 100644 index 0000000..f852111 Binary files /dev/null and b/adminx/assets/fonts/OpenSans-SemiBold.woff2 differ diff --git a/adminx/assets/img/logo.svg b/adminx/assets/img/logo.svg new file mode 100644 index 0000000..f384fef --- /dev/null +++ b/adminx/assets/img/logo.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/adminx/assets/js/adminx.js b/adminx/assets/js/adminx.js new file mode 100644 index 0000000..318b6cb --- /dev/null +++ b/adminx/assets/js/adminx.js @@ -0,0 +1,888 @@ +/** + * Общий слой JS новой админки (/adminx). + * + * Только переиспользуемые примитивы уровня всего приложения (ТЗ §4): + * Loader, Ajax (fetch под контракт success/error), Sidebar, Theme, Dropdown, + * Toast. Логика конкретных разделов живёт в modules//assets/.js + * и цепляется к этому же объекту (Adminx. = {...}). + * + * Без jQuery: fetch, querySelector, addEventListener, classList. + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || {}; + Adminx.i18n = window.AdminxI18n || {}; + Adminx.t = function (key, fallback) { + return Object.prototype.hasOwnProperty.call(Adminx.i18n, key) ? Adminx.i18n[key] : (fallback || key); + }; + Adminx.phrases = window.AdminxPhrases || {}; + Adminx.phraseCache = {}; + Adminx.tr = function (text) { + var translated = String(text == null ? '' : text); + if (Object.prototype.hasOwnProperty.call(Adminx.phraseCache, translated)) { + return Adminx.phraseCache[translated]; + } + if (Object.prototype.hasOwnProperty.call(Adminx.phrases, translated)) { + Adminx.phraseCache[translated] = Adminx.phrases[translated]; + return Adminx.phrases[translated]; + } + var sourceText = translated; + Object.keys(Adminx.phrases).forEach(function (source) { + var startsWithWord = /^[\p{L}\p{N}_]/u.test(source); + var endsWithWord = /[\p{L}\p{N}_]$/u.test(source); + if (startsWithWord || endsWithWord) { + var escaped = source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + translated = translated.replace( + new RegExp( + (startsWithWord ? '(^|[^\\p{L}\\p{N}_])' : '()') + + '(' + escaped + ')' + + (endsWithWord ? '(?=$|[^\\p{L}\\p{N}_])' : ''), + 'gu' + ), + function (match, prefix) { return prefix + Adminx.phrases[source]; } + ); + } else { + translated = translated.split(source).join(Adminx.phrases[source]); + } + }); + Adminx.phraseCache[sourceText] = translated; + return translated; + }; + + Adminx.base = function () { + var m = document.querySelector('meta[name="adminx-base"]'); + return m ? m.getAttribute('content') : ''; + }; + + Adminx.csrf = function () { + var m = document.querySelector('meta[name="csrf-token"]'); + return m ? m.getAttribute('content') : ''; + }; + + // ------------------------------------------------------------------ // + Adminx.Loader = { + show: function () { document.body.classList.add('is-loading'); }, + hide: function () { document.body.classList.remove('is-loading'); } + }; + + // ------------------------------------------------------------------ // + // Повторное подтверждение пароля для операций с исполняемым кодом. + // ------------------------------------------------------------------ // + Adminx.ReAuth = { + pending: null, + + confirm: function (config) { + if (this.pending) { return this.pending; } + var self = this; + this.pending = this.open(config || {}).then(function (confirmed) { + self.pending = null; + return confirmed; + }, function () { + self.pending = null; + return false; + }); + return this.pending; + }, + + open: function (config) { + return new Promise(function (resolve) { + var wasLoading = document.body.classList.contains('is-loading'); + Adminx.Loader.hide(); + var overlay = document.createElement('div'); + overlay.className = 'overlay open reauth-overlay'; + overlay.innerHTML = '' + + '' + + '' + + ''; + + var form = overlay.querySelector('[data-reauth-form]'); + var input = form.elements.password; + var submit = form.querySelector('[type="submit"]'); + var error = form.querySelector('[data-reauth-error]'); + overlay.querySelector('[data-reauth-reason]').textContent = config.reason || Adminx.t('reauth_reason', 'Это действие изменяет исполняемый код системы.'); + + var finish = function (confirmed) { + document.removeEventListener('keydown', onKeydown); + overlay.remove(); + if (wasLoading && confirmed) { Adminx.Loader.show(); } + resolve(confirmed); + }; + var onKeydown = function (event) { + if (event.key === 'Escape' && !submit.disabled) { finish(false); } + }; + + overlay.querySelectorAll('[data-reauth-cancel]').forEach(function (button) { + button.addEventListener('click', function () { if (!submit.disabled) { finish(false); } }); + }); + overlay.addEventListener('click', function (event) { + if (event.target === overlay && !submit.disabled) { finish(false); } + }); + form.addEventListener('submit', function (event) { + event.preventDefault(); + error.hidden = true; + submit.disabled = true; + var data = new FormData(); + data.append('password', input.value); + fetch(config.reauth_url || (Adminx.base() + '/reauth'), { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': Adminx.csrf(), + 'Accept': 'application/json' + }, + credentials: 'same-origin', + body: data + }).then(function (response) { + return response.json().catch(function () { return {}; }).then(function (body) { + return { ok: response.ok, body: body }; + }); + }).then(function (result) { + if (result.ok && result.body.success) { + finish(true); + return; + } + error.textContent = result.body.message || Adminx.t('reauth_error', 'Не удалось подтвердить пароль'); + error.hidden = false; + input.select(); + submit.disabled = false; + }).catch(function () { + error.textContent = Adminx.t('server_unavailable', 'Сервер недоступен. Повторите попытку.'); + error.hidden = false; + submit.disabled = false; + }); + }); + + document.body.appendChild(overlay); + document.addEventListener('keydown', onKeydown); + window.setTimeout(function () { input.focus(); }, 20); + }); + } + }; + + // ------------------------------------------------------------------ // + // Ajax: единый JSON-контракт success/error из App\Common\Controller + // ------------------------------------------------------------------ // + Adminx.Ajax = { + request: function (url, options) { + options = options || {}; + var headers = options.headers || {}; + headers['X-Requested-With'] = 'XMLHttpRequest'; + headers['Accept'] = 'application/json'; + if ((options.method || 'GET').toUpperCase() !== 'GET') { + headers['X-CSRF-Token'] = Adminx.csrf(); + } + return fetch(url, { + method: options.method || 'GET', + headers: headers, + credentials: 'same-origin', + body: options.body || null + }).then(function (res) { + return res.json().catch(function () { return {}; }).then(function (data) { + return { ok: res.ok, status: res.status, data: data }; + }); + }).then(function (payload) { + var responseData = payload.data && payload.data.data ? payload.data.data : {}; + if (payload.status !== 428 || !responseData.reauth_required || options._reauthRetried) { + return payload; + } + + return Adminx.ReAuth.confirm(responseData).then(function (confirmed) { + if (!confirmed) { return payload; } + var retryOptions = {}; + Object.keys(options).forEach(function (key) { retryOptions[key] = options[key]; }); + retryOptions._reauthRetried = true; + return Adminx.Ajax.request(url, retryOptions); + }); + }); + }, + + post: function (url, formData) { + return this.request(url, { method: 'POST', body: formData }); + }, + + /** Обработать стандартный ответ: тост + редирект. */ + handle: function (payload) { + var d = payload.data || {}; + if (d.message) { + Adminx.Toast.show(Adminx.tr(d.message), d.success ? 'success' : 'error'); + } + if (d.redirect) { + window.location.href = d.redirect; + } + return d; + } + }; + + // ------------------------------------------------------------------ // + Adminx.Sidebar = { + KEY: 'adminx-sidebar', + GROUPS_KEY: 'adminx-sidebar-groups', + init: function () { + var self = this; + var toggle = document.getElementById('sidebarToggle'); + var backdrop = document.getElementById('sidebarBackdrop'); + if (toggle) { + toggle.addEventListener('click', function () { + if (window.matchMedia('(max-width: 900px)').matches) { + document.body.classList.toggle('sidebar-open'); + } else { + var collapsed = document.body.classList.toggle('sidebar-collapsed'); + //-- Запоминаем состояние, чтобы оно пережило переход по разделам. + try { localStorage.setItem(self.KEY, collapsed ? 'collapsed' : 'expanded'); } catch (e) {} + } + }); + } + if (backdrop) { + backdrop.addEventListener('click', function () { + document.body.classList.remove('sidebar-open'); + }); + } + document.addEventListener('click', function (event) { + var trigger = event.target.closest('[data-nav-sub]'); + if (!trigger) { return; } + var submenu = document.getElementById(trigger.getAttribute('data-nav-sub')); + if (!submenu) { return; } + var open = trigger.getAttribute('aria-expanded') !== 'true'; + trigger.setAttribute('aria-expanded', open ? 'true' : 'false'); + submenu.classList.toggle('open', open); + }); + + this.initGroups(); + this.initFlyout(); + }, + + //-- Группы меню: пользователь оставляет открытыми только нужные разделы, + //-- а группа текущей страницы раскрывается независимо от сохранённого состояния. + initGroups: function () { + var sidebar = document.getElementById('sidebar'); + if (!sidebar) { return; } + var groups = sidebar.querySelectorAll('[data-nav-group]'); + var saved = {}; + try { + var parsed = JSON.parse(localStorage.getItem(this.GROUPS_KEY) || '{}'); + saved = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch (e) {} + + var hasActive = !!sidebar.querySelector('.nav-item.active'); + groups.forEach(function (group, index) { + var trigger = group.querySelector(':scope > [data-nav-group-toggle]'); + if (!trigger) { return; } + var key = group.getAttribute('data-nav-group-key') || ''; + var active = !!group.querySelector('.nav-item.active'); + var current = active || (!hasActive && index === 0); + var open = current || (Object.prototype.hasOwnProperty.call(saved, key) ? saved[key] === true : false); + group.classList.toggle('is-collapsed', !open); + group.classList.toggle('has-active', active); + trigger.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + sidebar.classList.add('nav-groups-ready'); + window.requestAnimationFrame(function () { + sidebar.classList.add('nav-groups-animated'); + }); + + sidebar.addEventListener('click', function (event) { + var trigger = event.target.closest('[data-nav-group-toggle]'); + if (!trigger || !sidebar.contains(trigger)) { return; } + var group = trigger.closest('[data-nav-group]'); + var key = group ? group.getAttribute('data-nav-group-key') || '' : ''; + if (!group || key === '') { return; } + var open = group.classList.contains('is-collapsed'); + group.classList.toggle('is-collapsed', !open); + trigger.setAttribute('aria-expanded', open ? 'true' : 'false'); + saved[key] = open; + try { localStorage.setItem(Adminx.Sidebar.GROUPS_KEY, JSON.stringify(saved)); } catch (e) {} + }); + }, + + //-- Свёрнутый сайдбар: подменю показывается всплывающим окном при наведении, + //-- чтобы вложенные пункты (Модули, Настройки…) оставались доступны. + initFlyout: function () { + var sidebar = document.getElementById('sidebar'); + if (!sidebar) { return; } + var openSub = null, timer = null; + var collapsed = function () { + return document.body.classList.contains('sidebar-collapsed') + && !window.matchMedia('(max-width: 900px)').matches; + }; + var close = function () { + if (!openSub) { return; } + openSub.classList.remove('flyout-open'); + openSub.style.top = ''; openSub.style.left = ''; + openSub = null; + }; + var cancel = function () { if (timer) { window.clearTimeout(timer); timer = null; } }; + var schedule = function () { cancel(); timer = window.setTimeout(close, 200); }; + var open = function (trigger, sub) { + cancel(); + if (openSub && openSub !== sub) { close(); } + var r = trigger.getBoundingClientRect(); + sub.style.left = (r.right + 6) + 'px'; + sub.style.top = r.top + 'px'; + sub.classList.add('flyout-open'); + openSub = sub; + var sr = sub.getBoundingClientRect(); + if (sr.bottom > window.innerHeight - 10) { + sub.style.top = Math.max(10, window.innerHeight - 10 - sr.height) + 'px'; + } + }; + + sidebar.querySelectorAll('[data-nav-sub]').forEach(function (trigger) { + var sub = document.getElementById(trigger.getAttribute('data-nav-sub')); + if (!sub) { return; } + trigger.addEventListener('mouseenter', function () { if (collapsed()) { open(trigger, sub); } }); + trigger.addEventListener('mouseleave', function () { if (collapsed()) { schedule(); } }); + sub.addEventListener('mouseenter', function () { if (collapsed()) { cancel(); } }); + sub.addEventListener('mouseleave', function () { if (collapsed()) { schedule(); } }); + sub.addEventListener('click', function (e) { if (e.target.closest('a')) { close(); } }); + }); + + window.addEventListener('resize', close); + window.addEventListener('scroll', close, true); + } + }; + + // ------------------------------------------------------------------ // + Adminx.Theme = { + KEY: 'adminx-theme', + init: function () { + var saved = null; + try { saved = localStorage.getItem(this.KEY); } catch (e) {} + if (saved) { document.documentElement.setAttribute('data-theme', saved); } + var btn = document.getElementById('themeToggle'); + var self = this; + if (btn) { + this.sync(btn); + btn.addEventListener('click', function () { + self.toggle(); + self.sync(btn); + }); + } + }, + sync: function (btn) { + var dark = document.documentElement.getAttribute('data-theme') === 'dark'; + var icon = btn.querySelector('[data-theme-icon]'); + var label = btn.querySelector('[data-theme-label]'); + if (icon) { + icon.className = dark ? 'ti ti-sun' : 'ti ti-moon'; + } + if (label) { + label.textContent = dark + ? btn.getAttribute('data-theme-light-label') + : btn.getAttribute('data-theme-dark-label'); + } + }, + toggle: function () { + var cur = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', cur); + try { localStorage.setItem(this.KEY, cur); } catch (e) {} + } + }; + + // ------------------------------------------------------------------ // + Adminx.Dropdown = { + init: function () { + document.addEventListener('click', function (e) { + var trigger = e.target.closest('[data-dropdown]'); + var openMenus = document.querySelectorAll('.dropdown.open'); + if (trigger) { + var dd = trigger.closest('.dropdown'); + var wasOpen = dd.classList.contains('open'); + openMenus.forEach(function (m) { m.classList.remove('open'); }); + if (!wasOpen) { dd.classList.add('open'); } + e.stopPropagation(); + return; + } + openMenus.forEach(function (m) { + if (!m.contains(e.target)) { m.classList.remove('open'); } + }); + }); + } + }; + + // ------------------------------------------------------------------ // + // Меню пользователя в подвале сайдбара. Сайдбар имеет overflow:hidden, + // поэтому всплывающее меню позиционируем fixed вручную (над триггером), + // чтобы оно не обрезалось и работало в свёрнутом состоянии. + // ------------------------------------------------------------------ // + Adminx.SidebarUser = { + init: function () { + var dd = document.getElementById('sidebarUserMenu'); + if (!dd) { return; } + var trigger = dd.querySelector('[data-dropdown]'); + var menu = dd.querySelector('.dropdown-menu'); + if (!trigger || !menu) { return; } + var place = function () { + if (!dd.classList.contains('open')) { return; } + var r = trigger.getBoundingClientRect(); + menu.style.position = 'fixed'; + menu.style.left = Math.round(r.left) + 'px'; + menu.style.right = 'auto'; + menu.style.top = 'auto'; + menu.style.bottom = Math.round(window.innerHeight - r.top + 6) + 'px'; + menu.style.minWidth = Math.max(220, Math.round(r.width)) + 'px'; + }; + // Dropdown.init переключает .open на том же клике; позиционируем после. + trigger.addEventListener('click', function () { setTimeout(place, 0); }); + window.addEventListener('resize', place, { passive: true }); + } + }; + + // Generic tabs for new shared surfaces. Existing module tabs can migrate to + // data-tab-target/data-tab-panel without adding another event listener. + Adminx.Tabs = { + activate: function (root, name) { + if (!root) { return; } + root.querySelectorAll('[data-tab-target]').forEach(function (tab) { + var active = tab.getAttribute('data-tab-target') === name; + tab.setAttribute('aria-selected', active ? 'true' : 'false'); + tab.classList.toggle('active', active); + }); + root.querySelectorAll('[data-tab-panel]').forEach(function (panel) { + panel.hidden = panel.getAttribute('data-tab-panel') !== name; + }); + if (Adminx.CodeEditor) { + setTimeout(function () { Adminx.CodeEditor.refreshAll(); }, 0); + } + }, + init: function () { + var self = this; + document.addEventListener('click', function (event) { + var tab = event.target.closest('[data-tab-target]'); + if (!tab) { return; } + var root = tab.closest('[data-tabs]'); + if (root) { self.activate(root, tab.getAttribute('data-tab-target')); } + }); + } + }; + + // ------------------------------------------------------------------ // + // Drawer: боковая панель (открытие по [data-open-drawer="id"], закрытие + // по [data-close-drawer], клику по оверлею и Esc). + // ------------------------------------------------------------------ // + Adminx.Drawer = { + overlay: function () { return document.getElementById('drawerOverlay'); }, + + _seq: 0, + + // Пересчёт стека открытых панелей: самая поздняя — сверху и активна, + // остальные получают .drawer-under (затеняются) под ней. + restack: function () { + var open = [].slice.call(document.querySelectorAll('.drawer.show')); + open.sort(function (a, b) { + return (Number(a.dataset.drawerSeq) || 0) - (Number(b.dataset.drawerSeq) || 0); + }); + open.forEach(function (d, i) { + var isTop = i === open.length - 1; + d.classList.toggle('drawer-under', !isTop); + d.style.zIndex = String(195 + i * 2); + }); + }, + + // AdminKit (fix-pack) открывает drawer/overlay классом .show (не .open). + open: function (id) { + var el = document.getElementById(id); + if (!el) { return; } + el.dataset.drawerSeq = String(++this._seq); + el.removeAttribute('hidden'); + el.classList.add('show'); + var ov = this.overlay(); + if (ov) { ov.classList.add('show'); } + document.body.classList.add('drawer-active'); + document.documentElement.classList.add('drawer-active'); + this.restack(); + }, + + close: function (id) { + var drawer = id ? document.getElementById(id) : null; + if (!drawer && typeof id === 'object' && id) { drawer = id; } + if (!drawer) { + var openDrawers = document.querySelectorAll('.drawer.show'); + drawer = openDrawers.length ? openDrawers[openDrawers.length - 1] : null; + } + if (drawer) { + drawer.classList.remove('show'); + drawer.classList.remove('drawer-under'); + drawer.style.zIndex = ''; + window.setTimeout(function () { + if (!drawer.classList.contains('show')) { + drawer.setAttribute('hidden', 'hidden'); + document.dispatchEvent(new CustomEvent('adminx:drawer:closed', { detail: { drawer: drawer } })); + } + }, 230); + } + var ov = this.overlay(); + if (!document.querySelector('.drawer.show')) { + if (ov) { ov.classList.remove('show'); } + document.body.classList.remove('drawer-active'); + document.documentElement.classList.remove('drawer-active'); + } + this.restack(); + }, + + init: function () { + var self = this; + document.addEventListener('click', function (e) { + var opener = e.target.closest('[data-open-drawer]'); + if (opener) { self.open(opener.getAttribute('data-open-drawer')); return; } + var closer = e.target.closest('[data-close-drawer]'); + if (closer) { self.close(closer.closest('.drawer')); return; } + if (e.target.id === 'drawerOverlay') { self.close(); } + }); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { self.close(); } + }); + } + }; + + // ------------------------------------------------------------------ // + Adminx.Toast = { + region: function () { + var r = document.getElementById('toastRegion'); + if (!r) { + r = document.createElement('div'); + r.id = 'toastRegion'; + r.className = 'toast-region'; + document.body.appendChild(r); + } + return r; + }, + show: function (message, type) { + var el = document.createElement('div'); + el.className = 'toast toast-' + (type || 'info'); + el.textContent = Adminx.tr(message); + this.region().appendChild(el); + setTimeout(function () { el.classList.add('show'); }, 10); + setTimeout(function () { + el.classList.remove('show'); + setTimeout(function () { el.remove(); }, 250); + }, 3200); + } + }; + + // ------------------------------------------------------------------ // + // Confirm: модальный диалог подтверждения (компонент AdminKit .modal), + // вместо браузерного window.confirm. Никаких нативных промтов. + // + // Adminx.Confirm.open({ kind, title, message, confirmLabel, confirmClass, + // cancelLabel, onConfirm }); + // ------------------------------------------------------------------ // + Adminx.Confirm = { + ICONS: { + info: ['info', 'ti-info-circle'], + success: ['success', 'ti-circle-check'], + warning: ['warning', 'ti-alert-triangle'], + error: ['error', 'ti-alert-circle'] + }, + + open: function (cfg) { + cfg = cfg || {}; + var kind = this.ICONS[cfg.kind] ? cfg.kind : 'warning'; + var ic = this.ICONS[kind]; + + var overlay = document.createElement('div'); + overlay.className = 'overlay'; + + var modal = document.createElement('div'); + modal.className = 'modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + + modal.innerHTML = + '' + + ''; + + overlay.appendChild(modal); + document.body.appendChild(overlay); + // показываем (AdminKit fix-pack: overlay открывается классом .show) + requestAnimationFrame(function () { overlay.classList.add('show'); }); + + var self = this; + var close = function () { + overlay.classList.remove('show'); + setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 180); + }; + var onKey = function (e) { if (e.key === 'Escape') { close(); } }; + + overlay.addEventListener('click', function (e) { + if (e.target === overlay || e.target.closest('[data-cancel]')) { close(); return; } + if (e.target.closest('[data-ok]')) { + close(); + if (typeof cfg.onConfirm === 'function') { cfg.onConfirm(); } + } + }); + document.addEventListener('keydown', onKey); + modal.querySelector('[data-ok]').focus(); + } + }; + + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; + }); + } + + // ------------------------------------------------------------------ // + // Logout: подтверждение + POST через ajax-контракт (ТЗ §8.5) + // ------------------------------------------------------------------ // + Adminx.initLogout = function () { + document.addEventListener('click', function (e) { + var btn = e.target.closest('[data-confirm-logout]'); + if (!btn) { return; } + e.preventDefault(); + var form = document.getElementById('logoutForm'); + if (!form) { return; } + + Adminx.Confirm.open({ + kind: 'info', + title: Adminx.t('logout_title', 'Выйти из админпанели?'), + message: Adminx.t('logout_message', 'Текущая сессия будет завершена.'), + confirmLabel: Adminx.t('btn_logout', 'Выйти'), + confirmClass: 'btn-primary', + onConfirm: function () { + Adminx.Loader.show(); + var fd = new FormData(form); + Adminx.Ajax.post(form.getAttribute('action'), fd).then(function (payload) { + Adminx.Loader.hide(); + var d = Adminx.Ajax.handle(payload); + if (!d.redirect) { window.location.href = Adminx.base() + '/login'; } + }).catch(function () { + Adminx.Loader.hide(); + form.submit(); + }); + } + }); + }); + }; + + // ------------------------------------------------------------------ // + // Тема CodeMirror по умолчанию — dracula. Проставляем data-theme всем + // код-редакторам ДО их инициализации (adminx.js грузится раньше + // editor-codemirror.js), явный data-theme не трогаем. CSS темы подключает + // константа CODEMIRROR_THEME. Работает и для динамики (adminx:content-ready). + // ------------------------------------------------------------------ // + Adminx.CODE_THEME = 'dracula'; + Adminx.applyCodeTheme = function (root) { + (root || document).querySelectorAll('textarea[data-code-editor]:not([data-theme])').forEach(function (t) { + t.setAttribute('data-theme', Adminx.CODE_THEME); + }); + }; + + // ------------------------------------------------------------------ // + // Копирование тега в буфер (эталон — раздел «Системные блоки»). Любой + // элемент с data-ax-copy="[tag:...]" копирует значение и показывает тост. + // ------------------------------------------------------------------ // + Adminx.copyTag = function (text) { + text = String(text || ''); + if (!text) { return; } + var ok = function () { Adminx.Toast.show(Adminx.t('tag_copied', 'Тег скопирован'), 'success'); }; + var fallback = function () { + var ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', 'readonly'); + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + ta.style.top = '0'; + document.body.appendChild(ta); + ta.select(); + try { + if (document.execCommand('copy')) { ok(); } else { Adminx.Toast.show(text, 'info'); } + } catch (e) { Adminx.Toast.show(text, 'info'); } + ta.remove(); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(ok).catch(fallback); + return; + } + fallback(); + }; + + Adminx.initCopyTag = function () { + document.addEventListener('click', function (e) { + var el = e.target.closest('[data-ax-copy]'); + if (!el) { return; } + e.preventDefault(); + Adminx.copyTag(el.getAttribute('data-ax-copy')); + }); + }; + + Adminx.initSectionHelp = function () { + var block = document.querySelector('[data-section-help]'); + if (!block) { return; } + var header = document.querySelector('.page-header'); + var details = block.querySelector('details'); + //-- Инфо-аккордеон всегда свёрнут по умолчанию; открывается только по клику. + if (details) { details.open = false; } + if (header && header.parentNode) { header.insertAdjacentElement('afterend', block); } + block.removeAttribute('hidden'); + }; + + // ------------------------------------------------------------------ // + // Tooltip: плавающая подсказка на уровне . CSS-тултип (::after) + // обрезался любыми блоками с overflow (таблицы, канбан-колонки и т.п.); + // портал во с position:fixed выходит за пределы любых блоков. + // ------------------------------------------------------------------ // + Adminx.Tooltip = { + el: null, + target: null, + init: function () { + var self = this; + document.addEventListener('pointerover', function (e) { + var t = e.target.closest ? e.target.closest('[data-tooltip]') : null; + if (t) { self.show(t); } + }); + document.addEventListener('pointerout', function (e) { + var t = e.target.closest ? e.target.closest('[data-tooltip]') : null; + if (t && t === self.target) { self.hide(); } + }); + document.addEventListener('focusin', function (e) { + var t = e.target.closest ? e.target.closest('[data-tooltip]') : null; + if (t) { self.show(t); } else { self.hide(); } + }); + document.addEventListener('click', function () { self.hide(); }); + window.addEventListener('scroll', function () { self.hide(); }, true); + window.addEventListener('resize', function () { self.hide(); }); + document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { self.hide(); } }); + }, + ensure: function () { + if (this.el) { return this.el; } + var el = document.createElement('div'); + el.className = 'ax-tooltip'; + el.setAttribute('role', 'tooltip'); + document.body.appendChild(el); + this.el = el; + return el; + }, + show: function (target) { + var text = target.getAttribute('data-tooltip'); + if (!text) { return; } + this.target = target; + var el = this.ensure(); + el.textContent = text; + this.position(target, el); + el.classList.add('show'); + }, + position: function (target, el) { + var r = target.getBoundingClientRect(); + el.style.maxWidth = Math.min(280, window.innerWidth - 16) + 'px'; + var tw = el.offsetWidth; + var th = el.offsetHeight; + var gap = 7; + var placement = target.getAttribute('data-tooltip-position') || 'top'; + if (placement === 'right') { + var rightTop = r.top + r.height / 2 - th / 2; + var rightLeft = r.right + gap; + if (rightLeft + tw > window.innerWidth - 8) { rightLeft = r.left - tw - gap; } + el.style.top = Math.round(Math.max(8, Math.min(rightTop, window.innerHeight - th - 8))) + 'px'; + el.style.left = Math.round(Math.max(8, rightLeft)) + 'px'; + return; + } + var top = r.top - th - gap; + if (top < 8) { top = r.bottom + gap; } + var left = r.left + r.width / 2 - tw / 2; + left = Math.max(8, Math.min(left, window.innerWidth - tw - 8)); + el.style.top = Math.round(top) + 'px'; + el.style.left = Math.round(left) + 'px'; + }, + hide: function () { + this.target = null; + if (this.el) { this.el.classList.remove('show'); } + } + }; + + // ------------------------------------------------------------------ // + // Help: контекстная справка раздела в правой AJAX-панели. + // Триггер — любой элемент с data-ax-help="" (топбар, кнопка в + // разделе, подсказка у компонента). Контент грузится из /help/panel. + // ------------------------------------------------------------------ // + Adminx.HelpPanel = { + cache: {}, + + fill: function (drawer, d) { + var title = drawer.querySelector('#helpDrawerTitle'); + var body = drawer.querySelector('[data-help-body]'); + var full = drawer.querySelector('[data-help-full]'); + if (title) { title.textContent = d.title || Adminx.t('help_title', 'Справка'); } + if (body) { body.innerHTML = d.html || ''; Adminx.applyCodeTheme(body); body.scrollTop = 0; } + if (full && d.url) { full.setAttribute('href', d.url); } + }, + + open: function (slug) { + if (!slug) { return; } + var drawer = document.getElementById('helpDrawer'); + if (!drawer) { return; } + Adminx.Drawer.open('helpDrawer'); + if (this.cache[slug]) { this.fill(drawer, this.cache[slug]); return; } + + var body = drawer.querySelector('[data-help-body]'); + if (body) { body.innerHTML = '
' + Adminx.t('help_loading', 'Загрузка…') + '
'; } + + Adminx.Ajax.request(Adminx.base() + '/help/panel?doc=' + encodeURIComponent(slug)).then(function (p) { + var d = p.data || {}; + if (!p.ok || !d.success) { + if (body) { body.innerHTML = '
' + Adminx.t('help_not_found', 'Справка для этого раздела не найдена.') + '
'; } + return; + } + Adminx.HelpPanel.cache[slug] = d; + Adminx.HelpPanel.fill(drawer, d); + }).catch(function () { + if (body) { body.innerHTML = '
' + Adminx.t('help_load_error', 'Не удалось загрузить справку.') + '
'; } + }); + }, + + init: function () { + document.addEventListener('click', function (e) { + var el = e.target.closest ? e.target.closest('[data-ax-help]') : null; + if (!el) { return; } + e.preventDefault(); + e.stopPropagation(); + Adminx.HelpPanel.open(el.getAttribute('data-ax-help')); + }); + } + }; + + // ------------------------------------------------------------------ // + Adminx.init = function () { + Adminx.applyCodeTheme(document); + Adminx.Sidebar.init(); + Adminx.Theme.init(); + Adminx.Dropdown.init(); + Adminx.SidebarUser.init(); + Adminx.Tabs.init(); + Adminx.Drawer.init(); + Adminx.Tooltip.init(); + Adminx.initLogout(); + Adminx.initCopyTag(); + Adminx.initSectionHelp(); + Adminx.HelpPanel.init(); + }; + + document.addEventListener('adminx:content-ready', function (e) { + Adminx.applyCodeTheme(e.detail && e.detail.root ? e.detail.root : document); + }); + + window.Adminx = Adminx; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', Adminx.init); + } else { + Adminx.init(); + } +})(window, document); diff --git a/adminx/assets/js/editor-codemirror.js b/adminx/assets/js/editor-codemirror.js new file mode 100644 index 0000000..9aad709 --- /dev/null +++ b/adminx/assets/js/editor-codemirror.js @@ -0,0 +1,170 @@ +/** + * Shared CodeMirror adapter for /adminx. + * + * Usage: + * + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + function boolAttr(el, name) { + var value = el.getAttribute(name); + return value === '' || value === '1' || value === 'true'; + } + + function numberAttr(el, name, fallback) { + var value = parseInt(el.getAttribute(name), 10); + return isNaN(value) || value <= 0 ? fallback : value; + } + + function normalizeMode(mode) { + if (!mode) { return 'htmlmixed'; } + if (mode === 'php') { return 'application/x-httpd-php'; } + if (mode === 'smarty') { return 'smartymixed'; } + if (mode === 'js') { return 'text/javascript'; } + if (mode === 'css') { return 'text/css'; } + if (mode === 'sql') { return 'text/x-sql'; } + return mode; + } + + Adminx.CodeEditor = { + instances: [], + + init: function (root) { + root = root || document; + if (!window.CodeMirror) { + return; + } + var nodes = root.querySelectorAll('textarea[data-code-editor]:not([data-code-editor-ready])'); + Array.prototype.forEach.call(nodes, this.create.bind(this)); + }, + + create: function (textarea) { + var height = numberAttr(textarea, 'data-height', 420); + var theme = textarea.getAttribute('data-theme') || + (document.documentElement.getAttribute('data-theme') === 'dark' ? 'material-ocean' : 'default'); + var editor = window.CodeMirror.fromTextArea(textarea, { + mode: normalizeMode(textarea.getAttribute('data-mode')), + theme: theme, + lineNumbers: !boolAttr(textarea, 'data-no-lines'), + lineWrapping: !boolAttr(textarea, 'data-no-wrap'), + matchBrackets: true, + autoCloseTags: true, + styleActiveLine: true, + indentUnit: numberAttr(textarea, 'data-indent', 4), + indentWithTabs: true, + readOnly: textarea.readOnly || textarea.disabled || boolAttr(textarea, 'data-readonly'), + extraKeys: { + 'Ctrl-S': function (cm) { Adminx.CodeEditor.saveForm(cm); }, + 'Cmd-S': function (cm) { Adminx.CodeEditor.saveForm(cm); }, + 'F11': function (cm) { Adminx.CodeEditor.toggleFullscreen(cm); }, + 'Cmd-F11': function (cm) { Adminx.CodeEditor.toggleFullscreen(cm); }, + 'Esc': function (cm) { Adminx.CodeEditor.closeFullscreen(cm); } + } + }); + + editor.setSize(textarea.getAttribute('data-width') || '100%', height); + editor.on('change', function (cm) { + cm.save(); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }); + + textarea.setAttribute('data-code-editor-ready', '1'); + textarea._adminxCodeMirror = editor; + this.instances.push(editor); + setTimeout(function () { editor.refresh(); }, 0); + }, + + saveForm: function (cm) { + var form, stayButton; + cm.save(); + form = cm.getTextArea().closest('form'); + if (!form) { return; } + stayButton = form.querySelector('[data-code-save-stay]'); + if (stayButton && !stayButton.disabled) { + stayButton.click(); + return; + } + if (typeof form.requestSubmit === 'function') { + form.requestSubmit(); + } else { + form.submit(); + } + }, + + toggleFullscreen: function (cm) { + var wrapper = cm.getWrapperElement(); + if (wrapper.classList.contains('CodeMirror-fullscreen')) { + this.closeFullscreen(cm); + return; + } + wrapper.classList.add('CodeMirror-fullscreen'); + document.body.style.overflow = 'hidden'; + cm.refresh(); + }, + + closeFullscreen: function (cm) { + var wrapper = cm.getWrapperElement(); + if (!wrapper.classList.contains('CodeMirror-fullscreen')) { return; } + wrapper.classList.remove('CodeMirror-fullscreen'); + document.body.style.overflow = ''; + cm.refresh(); + }, + + refreshAll: function () { + this.instances.forEach(function (editor) { editor.refresh(); }); + }, + + insert: function (textarea, value) { + if (!textarea || !value || textarea.disabled || textarea.readOnly) { return; } + if (textarea._adminxCodeMirror) { + textarea._adminxCodeMirror.replaceSelection(value); + textarea._adminxCodeMirror.focus(); + textarea._adminxCodeMirror.save(); + } else { + var start = typeof textarea.selectionStart === 'number' ? textarea.selectionStart : textarea.value.length; + var end = typeof textarea.selectionEnd === 'number' ? textarea.selectionEnd : start; + textarea.value = textarea.value.slice(0, start) + value + textarea.value.slice(end); + textarea.selectionStart = textarea.selectionEnd = start + value.length; + textarea.focus(); + } + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }, + + insertTag: function (button) { + var palette = button.closest('[data-code-editor-tags]'); + var form = palette ? palette.closest('form') : null; + var name = palette ? palette.getAttribute('data-code-editor-tags') : ''; + var textarea = form && name ? form.elements[name] : null; + this.insert(textarea, button.getAttribute('data-code-editor-tag') || ''); + }, + + // Синхронизировать значения всех редакторов в их textarea перед отправкой формы. + syncAll: function (root) { + var scope = root && root.querySelectorAll ? root : document; + this.instances.forEach(function (editor) { + var ta = editor.getTextArea && editor.getTextArea(); + if (ta && (scope === document || scope.contains(ta))) { editor.save(); } + }); + } + }; + + document.addEventListener('adminx:content-ready', function (e) { + Adminx.CodeEditor.init(e.detail && e.detail.root ? e.detail.root : document); + }); + document.addEventListener('click', function (e) { + var button = e.target.closest('[data-code-editor-tag]'); + if (!button) { return; } + e.preventDefault(); + Adminx.CodeEditor.insertTag(button); + }); + window.addEventListener('resize', function () { Adminx.CodeEditor.refreshAll(); }); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.CodeEditor.init(); }); + } else { + Adminx.CodeEditor.init(); + } +})(window, document); diff --git a/adminx/assets/js/editor-tiptap.js b/adminx/assets/js/editor-tiptap.js new file mode 100644 index 0000000..996278a --- /dev/null +++ b/adminx/assets/js/editor-tiptap.js @@ -0,0 +1,155 @@ +(()=>{function ue(n){this.content=n}ue.prototype={constructor:ue,find:function(n){for(var e=0;e>1}};ue.from=function(n){if(n instanceof ue)return n;var e=[];if(n)for(var t in n)e.push(t,n[t]);return new ue(e)};var Ms=ue;function Ra(n,e,t){for(let r=0;;r++){if(r==n.childCount||r==e.childCount)return n.childCount==e.childCount?null:t;let i=n.child(r),s=e.child(r);if(i==s){t+=i.nodeSize;continue}if(!i.sameMarkup(s))return t;if(i.isText&&i.text!=s.text){let o=i.text,l=s.text,a=0;for(;o[a]==l[a];a++)t++;return a&&a0&&f>0&&c[d-1]==u[f-1];)d--,f--,t--,r--;return d&&f&&d=56320&&n<57344}function La(n){return n>=55296&&n<56320}var x=class n{constructor(e,t){if(this.content=e,this.size=t||0,t==null)for(let r=0;re&&r(a,i+l,s||null,o)!==!1&&a.content.size){let u=l+1;a.nodesBetween(Math.max(0,e-u),Math.min(a.content.size,t-u),r,i+u)}l=c}}descendants(e){this.nodesBetween(0,this.size,e)}textBetween(e,t,r,i){let s="",o=!0;return this.nodesBetween(e,t,(l,a)=>{let c=l.isText?l.text.slice(Math.max(e,a)-a,t-a):l.isLeaf?i?typeof i=="function"?i(l):i:l.type.spec.leafText?l.type.spec.leafText(l):"":"";l.isBlock&&(l.isLeaf&&c||l.isTextblock)&&r&&(o?o=!1:s+=r),s+=c},0),s}append(e){if(!e.size)return this;if(!this.size)return e;let t=this.lastChild,r=e.firstChild,i=this.content.slice(),s=0;for(t.isText&&t.sameMarkup(r)&&(i[i.length-1]=t.withText(t.text+r.text),s=1);se)for(let s=0,o=0;oe&&((ot)&&(l.isText?l=l.cut(Math.max(0,e-o),Math.min(l.text.length,t-o)):l=l.cut(Math.max(0,e-o-1),Math.min(l.content.size,t-o-1))),r.push(l),i+=l.nodeSize),o=a}return new n(r,i)}cutByIndex(e,t){return e==t?n.empty:e==0&&t==this.content.length?this:new n(this.content.slice(e,t))}replaceChild(e,t){let r=this.content[e];if(r==t)return this;let i=this.content.slice(),s=this.size+t.nodeSize-r.nodeSize;return i[e]=t,new n(i,s)}addToStart(e){return new n([e].concat(this.content),this.size+e.nodeSize)}addToEnd(e){return new n(this.content.concat(e),this.size+e.nodeSize)}eq(e){if(this.content.length!=e.content.length)return!1;for(let t=0;tthis.size||e<0)throw new RangeError(`Position ${e} outside of fragment (${this})`);for(let t=0,r=0;;t++){let i=this.child(t),s=r+i.nodeSize;if(s>=e)return s==e?vr(t+1,s):vr(t,r);r=s}}toString(){return"<"+this.toStringInner()+">"}toStringInner(){return this.content.join(", ")}toJSON(){return this.content.length?this.content.map(e=>e.toJSON()):null}static fromJSON(e,t){if(!t)return n.empty;if(!Array.isArray(t))throw new RangeError("Invalid input for Fragment.fromJSON");return n.fromArray(t.map(e.nodeFromJSON))}static fromArray(e){if(!e.length)return n.empty;let t,r=0;for(let i=0;ithis.type.rank&&(t||(t=e.slice(0,i)),t.push(this),r=!0),t&&t.push(s)}}return t||(t=e.slice()),r||t.push(this),t}removeFromSet(e){for(let t=0;tr.type.rank-i.type.rank),t}};W.none=[];var ft=class extends Error{},v=class n{constructor(e,t,r){this.content=e,this.openStart=t,this.openEnd=r}get size(){return this.content.size-this.openStart-this.openEnd}insertAt(e,t){let r=za(this.content,e+this.openStart,t,this.openStart+1,this.openEnd+1);return r&&new n(r,this.openStart,this.openEnd)}removeBetween(e,t){return new n(Pa(this.content,e+this.openStart,t+this.openStart),this.openStart,this.openEnd)}eq(e){return this.content.eq(e.content)&&this.openStart==e.openStart&&this.openEnd==e.openEnd}toString(){return this.content+"("+this.openStart+","+this.openEnd+")"}toJSON(){if(!this.content.size)return null;let e={content:this.content.toJSON()};return this.openStart>0&&(e.openStart=this.openStart),this.openEnd>0&&(e.openEnd=this.openEnd),e}static fromJSON(e,t){if(!t)return n.empty;let r=t.openStart||0,i=t.openEnd||0;if(typeof r!="number"||typeof i!="number")throw new RangeError("Invalid input for Slice.fromJSON");return new n(x.fromJSON(e,t.content),r,i)}static maxOpen(e,t=!0){let r=0,i=0;for(let s=e.firstChild;s&&!s.isLeaf&&(t||!s.type.spec.isolating);s=s.firstChild)r++;for(let s=e.lastChild;s&&!s.isLeaf&&(t||!s.type.spec.isolating);s=s.lastChild)i++;return new n(e,r,i)}};v.empty=new v(x.empty,0,0);function Pa(n,e,t){let{index:r,offset:i}=n.findIndex(e),s=n.maybeChild(r),{index:o,offset:l}=n.findIndex(t);if(i==e||s.isText){if(l!=t&&!n.child(o).isText)throw new RangeError("Removing non-flat range");return n.cut(0,e).append(n.cut(t))}if(r!=o)throw new RangeError("Removing non-flat range");return n.replaceChild(r,s.copy(Pa(s.content,e-i-1,t-i-1)))}function za(n,e,t,r,i,s){let{index:o,offset:l}=n.findIndex(e),a=n.maybeChild(o);if(l==e||a.isText)return s&&r<=0&&i<=0&&!s.canReplace(o,o,t)?null:n.cut(0,e).append(t).append(n.cut(e));let c=za(a.content,e-l-1,t,o==0?r-1:0,o==n.childCount-1?i-1:0,a);return c&&n.replaceChild(o,a.copy(c))}function ap(n,e,t){if(t.openStart>n.depth)throw new ft("Inserted content deeper than insertion position");if(n.depth-t.openStart!=e.depth-t.openEnd)throw new ft("Inconsistent open depths");return Ba(n,e,t,0)}function Ba(n,e,t,r){let i=n.index(r),s=n.node(r);if(i==e.index(r)&&r=0&&n.isText&&n.sameMarkup(e[t])?e[t]=n.withText(e[t].text+n.text):e.push(n)}function vn(n,e,t,r){let i=(e||n).node(t),s=0,o=e?e.index(t):i.childCount;n&&(s=n.index(t),n.depth>t?s++:n.textOffset&&(Mt(n.nodeAfter,r),s++));for(let l=s;li&&As(n,e,i+1),o=r.depth>i&&As(t,r,i+1),l=[];return vn(null,n,i,l),s&&o&&e.index(i)==t.index(i)?(Fa(s,o),Mt(Tt(s,Ha(n,e,t,r,i+1)),l)):(s&&Mt(Tt(s,Ar(n,e,i+1)),l),vn(e,t,i,l),o&&Mt(Tt(o,Ar(t,r,i+1)),l)),vn(r,null,i,l),new x(l)}function Ar(n,e,t){let r=[];if(vn(null,n,t,r),n.depth>t){let i=As(n,e,t+1);Mt(Tt(i,Ar(n,e,t+1)),r)}return vn(e,null,t,r),new x(r)}function cp(n,e){let t=e.depth-n.openStart,i=e.node(t).copy(n.content);for(let s=t-1;s>=0;s--)i=e.node(s).copy(x.from(i));return{start:i.resolveNoCache(n.openStart+t),end:i.resolveNoCache(i.content.size-n.openEnd-t)}}var Er=class n{constructor(e,t,r){this.pos=e,this.path=t,this.parentOffset=r,this.depth=t.length/3-1}resolveDepth(e){return e==null?this.depth:e<0?this.depth+e:e}get parent(){return this.node(this.depth)}get doc(){return this.node(0)}node(e){return this.path[this.resolveDepth(e)*3]}index(e){return this.path[this.resolveDepth(e)*3+1]}indexAfter(e){return e=this.resolveDepth(e),this.index(e)+(e==this.depth&&!this.textOffset?0:1)}start(e){return e=this.resolveDepth(e),e==0?0:this.path[e*3-1]+1}end(e){return e=this.resolveDepth(e),this.start(e)+this.node(e).content.size}before(e){if(e=this.resolveDepth(e),!e)throw new RangeError("There is no position before the top-level node");return e==this.depth+1?this.pos:this.path[e*3-1]}after(e){if(e=this.resolveDepth(e),!e)throw new RangeError("There is no position after the top-level node");return e==this.depth+1?this.pos:this.path[e*3-1]+this.path[e*3].nodeSize}get textOffset(){return this.pos-this.path[this.path.length-1]}get nodeAfter(){let e=this.parent,t=this.index(this.depth);if(t==e.childCount)return null;let r=this.pos-this.path[this.path.length-1],i=e.child(t);return r?e.child(t).cut(r):i}get nodeBefore(){let e=this.index(this.depth),t=this.pos-this.path[this.path.length-1];return t?this.parent.child(e).cut(0,t):e==0?null:this.parent.child(e-1)}posAtIndex(e,t){t=this.resolveDepth(t);let r=this.path[t*3],i=t==0?0:this.path[t*3-1]+1;for(let s=0;s0;t--)if(this.start(t)<=e&&this.end(t)>=e)return t;return 0}blockRange(e=this,t){if(e.pos=0;r--)if(e.pos<=this.end(r)&&(!t||t(this.node(r))))return new At(this,e,r);return null}sameParent(e){return this.pos-this.parentOffset==e.pos-e.parentOffset}max(e){return e.pos>this.pos?e:this}min(e){return e.pos=0&&t<=e.content.size))throw new RangeError("Position "+t+" out of range");let r=[],i=0,s=t;for(let o=e;;){let{index:l,offset:a}=o.content.findIndex(s),c=s-a;if(r.push(o,l,i+a),!c||(o=o.child(l),o.isText))break;s=c-1,i+=a+1}return new n(t,r,s)}static resolveCached(e,t){let r=Sa.get(e);if(r)for(let s=0;se&&this.nodesBetween(e,t,s=>(r.isInSet(s.marks)&&(i=!0),!i)),i}get isBlock(){return this.type.isBlock}get isTextblock(){return this.type.isTextblock}get inlineContent(){return this.type.inlineContent}get isInline(){return this.type.isInline}get isText(){return this.type.isText}get isLeaf(){return this.type.isLeaf}get isAtom(){return this.type.isAtom}toString(){if(this.type.spec.toDebugString)return this.type.spec.toDebugString(this);let e=this.type.name;return this.content.size&&(e+="("+this.content.toStringInner()+")"),$a(this.marks,e)}contentMatchAt(e){let t=this.type.contentMatch.matchFragment(this.content,0,e);if(!t)throw new Error("Called contentMatchAt on a node with invalid content");return t}canReplace(e,t,r=x.empty,i=0,s=r.childCount){let o=this.contentMatchAt(e).matchFragment(r,i,s),l=o&&o.matchFragment(this.content,t);if(!l||!l.validEnd)return!1;for(let a=i;at.type.name)}`);this.content.forEach(t=>t.check())}toJSON(){let e={type:this.type.name};for(let t in this.attrs){e.attrs=this.attrs;break}return this.content.size&&(e.content=this.content.toJSON()),this.marks.length&&(e.marks=this.marks.map(t=>t.toJSON())),e}static fromJSON(e,t){if(!t)throw new RangeError("Invalid input for Node.fromJSON");let r;if(t.marks){if(!Array.isArray(t.marks))throw new RangeError("Invalid mark data for Node.fromJSON");r=t.marks.map(e.markFromJSON)}if(t.type=="text"){if(typeof t.text!="string")throw new RangeError("Invalid text node in JSON");return e.text(t.text,r)}let i=x.fromJSON(e,t.content),s=e.nodeType(t.type).create(t.attrs,i,r);return s.type.checkAttrs(s.attrs),s}};ve.prototype.text=void 0;var Ns=class n extends ve{constructor(e,t,r,i){if(super(e,t,null,i),!r)throw new RangeError("Empty text nodes are not allowed");this.text=r}toString(){return this.type.spec.toDebugString?this.type.spec.toDebugString(this):$a(this.marks,JSON.stringify(this.text))}get textContent(){return this.text}textBetween(e,t){return this.text.slice(e,t)}get nodeSize(){return this.text.length}mark(e){return e==this.marks?this:new n(this.type,this.attrs,this.text,e)}withText(e){return e==this.text?this:new n(this.type,this.attrs,e,this.marks)}cut(e=0,t=this.text.length){return e==0&&t==this.text.length?this:this.withText(this.text.slice(e,t))}eq(e){return this.sameMarkup(e)&&this.text==e.text}toJSON(){let e=super.toJSON();return e.text=this.text,e}};function $a(n,e){for(let t=n.length-1;t>=0;t--)e=n[t].type.name+"("+e+")";return e}var Et=class n{constructor(e){this.validEnd=e,this.next=[],this.wrapCache=[]}static parse(e,t){let r=new Os(e,t);if(r.next==null)return n.empty;let i=_a(r);r.next&&r.err("Unexpected trailing text");let s=bp(yp(i));return kp(s,r),s}matchType(e){for(let t=0;tc.createAndFill()));for(let c=0;c=this.next.length)throw new RangeError(`There's no ${e}th edge in this content match`);return this.next[e]}toString(){let e=[];function t(r){e.push(r);for(let i=0;i{let s=i+(r.validEnd?"*":" ")+" ";for(let o=0;o"+e.indexOf(r.next[o].next);return s}).join(` +`)}};Et.empty=new Et(!0);var Os=class{constructor(e,t){this.string=e,this.nodeTypes=t,this.inline=null,this.pos=0,this.tokens=e.split(/\s*(?=\b|\W|$)/),this.tokens[this.tokens.length-1]==""&&this.tokens.pop(),this.tokens[0]==""&&this.tokens.shift()}get next(){return this.tokens[this.pos]}eat(e){return this.next==e&&(this.pos++||!0)}err(e){throw new SyntaxError(e+" (in content expression '"+this.string+"')")}};function _a(n){let e=[];do e.push(fp(n));while(n.eat("|"));return e.length==1?e[0]:{type:"choice",exprs:e}}function fp(n){let e=[];do e.push(hp(n));while(n.next&&n.next!=")"&&n.next!="|");return e.length==1?e[0]:{type:"seq",exprs:e}}function hp(n){let e=gp(n);for(;;)if(n.eat("+"))e={type:"plus",expr:e};else if(n.eat("*"))e={type:"star",expr:e};else if(n.eat("?"))e={type:"opt",expr:e};else if(n.eat("{"))e=pp(n,e);else break;return e}function va(n){/\D/.test(n.next)&&n.err("Expected number, got '"+n.next+"'");let e=Number(n.next);return n.pos++,e}function pp(n,e){let t=va(n),r=t;return n.eat(",")&&(n.next!="}"?r=va(n):r=-1),n.eat("}")||n.err("Unclosed braced range"),{type:"range",min:t,max:r,expr:e}}function mp(n,e){let t=n.nodeTypes,r=t[e];if(r)return[r];let i=[];for(let s in t){let o=t[s];o.isInGroup(e)&&i.push(o)}return i.length==0&&n.err("No node type or group '"+e+"' found"),i}function gp(n){if(n.eat("(")){let e=_a(n);return n.eat(")")||n.err("Missing closing paren"),e}else if(/\W/.test(n.next))n.err("Unexpected token '"+n.next+"'");else{let e=mp(n,n.next).map(t=>(n.inline==null?n.inline=t.isInline:n.inline!=t.isInline&&n.err("Mixing inline and block content"),{type:"name",value:t}));return n.pos++,e.length==1?e[0]:{type:"choice",exprs:e}}}function yp(n){let e=[[]];return i(s(n,0),t()),e;function t(){return e.push([])-1}function r(o,l,a){let c={term:a,to:l};return e[o].push(c),c}function i(o,l){o.forEach(a=>a.to=l)}function s(o,l){if(o.type=="choice")return o.exprs.reduce((a,c)=>a.concat(s(c,l)),[]);if(o.type=="seq")for(let a=0;;a++){let c=s(o.exprs[a],l);if(a==o.exprs.length-1)return c;i(c,l=t())}else if(o.type=="star"){let a=t();return r(l,a),i(s(o.expr,a),a),[r(a)]}else if(o.type=="plus"){let a=t();return i(s(o.expr,l),a),i(s(o.expr,a),a),[r(a)]}else{if(o.type=="opt")return[r(l)].concat(s(o.expr,l));if(o.type=="range"){let a=l;for(let c=0;c{n[o].forEach(({term:l,to:a})=>{if(!l)return;let c;for(let u=0;u{c||i.push([l,c=[]]),c.indexOf(u)==-1&&c.push(u)})})});let s=e[r.join(",")]=new Et(r.indexOf(n.length-1)>-1);for(let o=0;o-1}get whitespace(){return this.spec.whitespace||(this.spec.code?"pre":"normal")}hasRequiredAttrs(){for(let e in this.attrs)if(this.attrs[e].isRequired)return!0;return!1}compatibleContent(e){return this==e||this.contentMatch.compatible(e.contentMatch)}computeAttrs(e){return!e&&this.defaultAttrs?this.defaultAttrs:ja(this.attrs,e)}create(e=null,t,r){if(this.isText)throw new Error("NodeType.create can't construct text nodes");return new ve(this,this.computeAttrs(e),x.from(t),W.setFrom(r))}createChecked(e=null,t,r){return t=x.from(t),this.checkContent(t),new ve(this,this.computeAttrs(e),t,W.setFrom(r))}createAndFill(e=null,t,r){if(e=this.computeAttrs(e),t=x.from(t),t.size){let o=this.contentMatch.fillBefore(t);if(!o)return null;t=o.append(t)}let i=this.contentMatch.matchFragment(t),s=i&&i.fillBefore(x.empty,!0);return s?new ve(this,e,t.append(s),W.setFrom(r)):null}validContent(e){let t=this.contentMatch.matchFragment(e);if(!t||!t.validEnd)return!1;for(let r=0;r-1}allowsMarks(e){if(this.markSet==null)return!0;for(let t=0;tr[s]=new n(s,t,o));let i=t.spec.topNode||"doc";if(!r[i])throw new RangeError("Schema is missing its top node type ('"+i+"')");if(!r.text)throw new RangeError("Every schema needs a 'text' type");for(let s in r.text.attrs)throw new RangeError("The text node type should not have attributes");return r}};function xp(n,e,t){let r=t.split("|");return i=>{let s=i===null?"null":typeof i;if(r.indexOf(s)<0)throw new RangeError(`Expected value of type ${r} for attribute ${e} on type ${n}, got ${s}`)}}var Rs=class{constructor(e,t,r){this.hasDefault=Object.prototype.hasOwnProperty.call(r,"default"),this.default=r.default,this.validate=typeof r.validate=="string"?xp(e,t,r.validate):r.validate}get isRequired(){return!this.hasDefault}},Mn=class n{constructor(e,t,r,i){this.name=e,this.rank=t,this.schema=r,this.spec=i,this.attrs=Ja(e,i.attrs),this.excluded=null;let s=Wa(this.attrs);this.instance=s?new W(this,s):null}create(e=null){return!e&&this.instance?this.instance:new W(this,ja(this.attrs,e))}static compile(e,t){let r=Object.create(null),i=0;return e.forEach((s,o)=>r[s]=new n(s,i++,t,o)),r}removeFromSet(e){for(var t=0;t-1}},Zt=class{constructor(e){this.linebreakReplacement=null,this.cached=Object.create(null);let t=this.spec={};for(let i in e)t[i]=e[i];t.nodes=Ms.from(e.nodes),t.marks=Ms.from(e.marks||{}),this.nodes=Nr.compile(this.spec.nodes,this),this.marks=Mn.compile(this.spec.marks,this);let r=Object.create(null);for(let i in this.nodes){if(i in this.marks)throw new RangeError(i+" can not be both a node and a mark");let s=this.nodes[i],o=s.spec.content||"",l=s.spec.marks;if(s.contentMatch=r[o]||(r[o]=Et.parse(o,this.nodes)),s.inlineContent=s.contentMatch.inlineContent,s.spec.linebreakReplacement){if(this.linebreakReplacement)throw new RangeError("Multiple linebreak nodes defined");if(!s.isInline||!s.isLeaf)throw new RangeError("Linebreak replacement nodes must be inline leaf nodes");this.linebreakReplacement=s}s.markSet=l=="_"?null:l?Ma(this,l.split(" ")):l==""||!s.inlineContent?[]:null}for(let i in this.marks){let s=this.marks[i],o=s.spec.excludes;s.excluded=o==null?[s]:o==""?[]:Ma(this,o.split(" "))}this.nodeFromJSON=i=>ve.fromJSON(this,i),this.markFromJSON=i=>W.fromJSON(this,i),this.topNodeType=this.nodes[this.spec.topNode||"doc"],this.cached.wrappings=Object.create(null)}node(e,t=null,r,i){if(typeof e=="string")e=this.nodeType(e);else if(e instanceof Nr){if(e.schema!=this)throw new RangeError("Node type from different schema used ("+e.name+")")}else throw new RangeError("Invalid node type: "+e);return e.createChecked(t,r,i)}text(e,t){let r=this.nodes.text;return new Ns(r,r.defaultAttrs,e,W.setFrom(t))}mark(e,t){return typeof e=="string"&&(e=this.marks[e]),e.create(t)}nodeType(e){let t=this.nodes[e];if(!t)throw new RangeError("Unknown node type: "+e);return t}};function Ma(n,e){let t=[];for(let r=0;r-1)&&t.push(o=a)}if(!o)throw new SyntaxError("Unknown mark type: '"+e[r]+"'")}return t}function wp(n){return n.tag!=null}function Sp(n){return n.style!=null}var $e=class n{constructor(e,t){this.schema=e,this.rules=t,this.tags=[],this.styles=[];let r=this.matchedStyles=[];t.forEach(i=>{if(wp(i))this.tags.push(i);else if(Sp(i)){let s=/[^=]*/.exec(i.style)[0];r.indexOf(s)<0&&r.push(s),this.styles.push(i)}}),this.normalizeLists=!this.tags.some(i=>{if(!/^(ul|ol)\b/.test(i.tag)||!i.node)return!1;let s=e.nodes[i.node];return s.contentMatch.matchType(s)})}parse(e,t={}){let r=new Or(this,t,!1);return r.addAll(e,W.none,t.from,t.to),r.finish()}parseSlice(e,t={}){let r=new Or(this,t,!0);return r.addAll(e,W.none,t.from,t.to),v.maxOpen(r.finish())}matchTag(e,t,r){for(let i=r?this.tags.indexOf(r)+1:0;ie.length&&(l.charCodeAt(e.length)!=61||l.slice(e.length+1)!=t))){if(o.getAttrs){let a=o.getAttrs(t);if(a===!1)continue;o.attrs=a||void 0}return o}}}static schemaRules(e){let t=[];function r(i){let s=i.priority==null?50:i.priority,o=0;for(;o{r(o=Aa(o)),o.mark||o.ignore||o.clearMark||(o.mark=i)})}for(let i in e.nodes){let s=e.nodes[i].spec.parseDOM;s&&s.forEach(o=>{r(o=Aa(o)),o.node||o.ignore||o.mark||(o.node=i)})}return t}static fromSchema(e){return e.cached.domParser||(e.cached.domParser=new n(e,n.schemaRules(e)))}},qa={address:!0,article:!0,aside:!0,blockquote:!0,canvas:!0,dd:!0,div:!0,dl:!0,fieldset:!0,figcaption:!0,figure:!0,footer:!0,form:!0,h1:!0,h2:!0,h3:!0,h4:!0,h5:!0,h6:!0,header:!0,hgroup:!0,hr:!0,li:!0,noscript:!0,ol:!0,output:!0,p:!0,pre:!0,section:!0,table:!0,tfoot:!0,ul:!0},vp={head:!0,noscript:!0,object:!0,script:!0,style:!0,title:!0},Ua={ol:!0,ul:!0},Tn=1,Is=2,Cn=4;function Ta(n,e,t){return e!=null?(e?Tn:0)|(e==="full"?Is:0):n&&n.whitespace=="pre"?Tn|Is:t&~Cn}var Qt=class{constructor(e,t,r,i,s,o){this.type=e,this.attrs=t,this.marks=r,this.solid=i,this.options=o,this.content=[],this.activeMarks=W.none,this.match=s||(o&Cn?null:e.contentMatch)}findWrapping(e){if(!this.match){if(!this.type)return[];let t=this.type.contentMatch.fillBefore(x.from(e));if(t)this.match=this.type.contentMatch.matchFragment(t);else{let r=this.type.contentMatch,i;return(i=r.findWrapping(e.type))?(this.match=r,i):null}}return this.match.findWrapping(e.type)}finish(e){if(!(this.options&Tn)){let r=this.content[this.content.length-1],i;if(r&&r.isText&&(i=/[ \t\r\n\u000c]+$/.exec(r.text))){let s=r;r.text.length==i[0].length?this.content.pop():this.content[this.content.length-1]=s.withText(s.text.slice(0,s.text.length-i[0].length))}}let t=x.from(this.content);return!e&&this.match&&(t=t.append(this.match.fillBefore(x.empty,!0))),this.type?this.type.create(this.attrs,t,this.marks):t}inlineContext(e){return this.type?this.type.inlineContent:this.content.length?this.content[0].isInline:e.parentNode&&!qa.hasOwnProperty(e.parentNode.nodeName.toLowerCase())}},Or=class{constructor(e,t,r){this.parser=e,this.options=t,this.isOpen=r,this.open=0,this.localPreserveWS=!1;let i=t.topNode,s,o=Ta(null,t.preserveWhitespace,0)|(r?Cn:0);i?s=new Qt(i.type,i.attrs,W.none,!0,t.topMatch||i.type.contentMatch,o):r?s=new Qt(null,null,W.none,!0,null,o):s=new Qt(e.schema.topNodeType,null,W.none,!0,null,o),this.nodes=[s],this.find=t.findPositions,this.needsBlock=!1}get top(){return this.nodes[this.open]}addDOM(e,t){e.nodeType==3?this.addTextNode(e,t):e.nodeType==1&&this.addElement(e,t)}addTextNode(e,t){let r=e.nodeValue,i=this.top,s=i.options&Is?"full":this.localPreserveWS||(i.options&Tn)>0,{schema:o}=this.parser;if(s==="full"||i.inlineContext(e)||/[^ \t\r\n\u000c]/.test(r)){if(s)if(s==="full")r=r.replace(/\r\n?/g,` +`);else if(o.linebreakReplacement&&/[\r\n]/.test(r)&&this.top.findWrapping(o.linebreakReplacement.create())){let l=r.split(/\r?\n|\r/);for(let a=0;a!a.clearMark(c)):t=t.concat(this.parser.schema.marks[a.mark].create(a.attrs)),a.consuming===!1)l=a;else break}}return t}addElementByRule(e,t,r,i){let s,o;if(t.node)if(o=this.parser.schema.nodes[t.node],o.isLeaf)this.insertNode(o.create(t.attrs),r,e.nodeName=="BR")||this.leafFallback(e,r);else{let a=this.enter(o,t.attrs||null,r,t.preserveWhitespace);a&&(s=!0,r=a)}else{let a=this.parser.schema.marks[t.mark];r=r.concat(a.create(t.attrs))}let l=this.top;if(o&&o.isLeaf)this.findInside(e);else if(i)this.addElement(e,r,i);else if(t.getContent)this.findInside(e),t.getContent(e,this.parser.schema).forEach(a=>this.insertNode(a,r,!1));else{let a=e;typeof t.contentElement=="string"?a=e.querySelector(t.contentElement):typeof t.contentElement=="function"?a=t.contentElement(e):t.contentElement&&(a=t.contentElement),this.findAround(e,a,!0),this.addAll(a,r),this.findAround(e,a,!1)}s&&this.sync(l)&&this.open--}addAll(e,t,r,i){let s=r||0;for(let o=r?e.childNodes[r]:e.firstChild,l=i==null?null:e.childNodes[i];o!=l;o=o.nextSibling,++s)this.findAtPoint(e,s),this.addDOM(o,t);this.findAtPoint(e,s)}findPlace(e,t,r){let i,s;for(let o=this.open,l=0;o>=0;o--){let a=this.nodes[o],c=a.findWrapping(e);if(c&&(!i||i.length>c.length+l)&&(i=c,s=a,!c.length))break;if(a.solid){if(r)break;l+=2}}if(!i)return null;this.sync(s);for(let o=0;o(o.type?o.type.allowsMarkType(c.type):Ea(c.type,e))?(a=c.addToSet(a),!1):!0),this.nodes.push(new Qt(e,t,a,i,null,l)),this.open++,r}closeExtra(e=!1){let t=this.nodes.length-1;if(t>this.open){for(;t>this.open;t--)this.nodes[t-1].content.push(this.nodes[t].finish(e));this.nodes.length=this.open+1}}finish(){return this.open=0,this.closeExtra(this.isOpen),this.nodes[0].finish(!!(this.isOpen||this.options.topOpen))}sync(e){for(let t=this.open;t>=0;t--){if(this.nodes[t]==e)return this.open=t,!0;this.localPreserveWS&&(this.nodes[t].options|=Tn)}return!1}get currentPos(){this.closeExtra();let e=0;for(let t=this.open;t>=0;t--){let r=this.nodes[t].content;for(let i=r.length-1;i>=0;i--)e+=r[i].nodeSize;t&&e++}return e}findAtPoint(e,t){if(this.find)for(let r=0;r-1)return e.split(/\s*\|\s*/).some(this.matchesContext,this);let t=e.split("/"),r=this.options.context,i=!this.isOpen&&(!r||r.parent.type==this.nodes[0].type),s=-(r?r.depth+1:0)+(i?0:1),o=(l,a)=>{for(;l>=0;l--){let c=t[l];if(c==""){if(l==t.length-1||l==0)continue;for(;a>=s;a--)if(o(l-1,a))return!0;return!1}else{let u=a>0||a==0&&i?this.nodes[a].type:r&&a>=s?r.node(a-s).type:null;if(!u||u.name!=c&&!u.isInGroup(c))return!1;a--}}return!0};return o(t.length-1,this.open)}textblockFromContext(){let e=this.options.context;if(e)for(let t=e.depth;t>=0;t--){let r=e.node(t).contentMatchAt(e.indexAfter(t)).defaultType;if(r&&r.isTextblock&&r.defaultAttrs)return r}for(let t in this.parser.schema.nodes){let r=this.parser.schema.nodes[t];if(r.isTextblock&&r.defaultAttrs)return r}}};function Cp(n){for(let e=n.firstChild,t=null;e;e=e.nextSibling){let r=e.nodeType==1?e.nodeName.toLowerCase():null;r&&Ua.hasOwnProperty(r)&&t?(t.appendChild(e),e=t):r=="li"?t=e:r&&(t=null)}}function Mp(n,e){return(n.matches||n.msMatchesSelector||n.webkitMatchesSelector||n.mozMatchesSelector).call(n,e)}function Aa(n){let e={};for(let t in n)e[t]=n[t];return e}function Ea(n,e){let t=e.schema.nodes;for(let r in t){let i=t[r];if(!i.allowsMarkType(n))continue;let s=[],o=l=>{s.push(l);for(let a=0;a{if(s.length||o.marks.length){let l=0,a=0;for(;l=0;i--){let s=this.serializeMark(e.marks[i],e.isInline,t);s&&((s.contentDOM||s.dom).appendChild(r),r=s.dom)}return r}serializeMark(e,t,r={}){let i=this.marks[e.type.name];return i&&Mr(Cr(r),i(e,t),null,e.attrs)}static renderSpec(e,t,r=null,i){return typeof t=="string"?{dom:e.createTextNode(t)}:Mr(e,t,r,i)}static fromSchema(e){return e.cached.domSerializer||(e.cached.domSerializer=new n(this.nodesFromSchema(e),this.marksFromSchema(e)))}static nodesFromSchema(e){let t=Na(e.nodes);return t.text||(t.text=r=>r.text),t}static marksFromSchema(e){return Na(e.marks)}};function Na(n){let e={};for(let t in n){let r=n[t].spec.toDOM;r&&(e[t]=r)}return e}function Cr(n){return n.document||window.document}var Oa=new WeakMap;function Tp(n){let e=Oa.get(n);return e===void 0&&Oa.set(n,e=Ap(n)),e}function Ap(n){let e=null;function t(r){if(r&&typeof r=="object")if(Array.isArray(r))if(typeof r[0]=="string")e||(e=[]),e.push(r);else for(let i=0;i-1)throw new RangeError("Using an array from an attribute object as a DOM spec. This may be an attempted cross site scripting attack.");let o=i.indexOf(" ");o>0&&(t=i.slice(0,o),i=i.slice(o+1));let l,a=t?n.createElementNS(t,i):n.createElement(i),c=e[1],u=1;if(c&&typeof c=="object"&&c.nodeType==null&&!Array.isArray(c)){u=2;for(let d in c)if(c[d]!=null){let f=d.indexOf(" ");f>0?a.setAttributeNS(d.slice(0,f),d.slice(f+1),c[d]):d=="style"&&a.style?a.style.cssText=c[d]:a.setAttribute(d,c[d])}}for(let d=u;du)throw new RangeError("Content hole must be the only child of its parent node");return{dom:a,contentDOM:a}}else if(typeof f=="string")a.appendChild(n.createTextNode(f));else{let{dom:h,contentDOM:p}=Mr(n,f,t,r);if(a.appendChild(h),p){if(l)throw new RangeError("Multiple content holes");l=p}}}return{dom:a,contentDOM:l}}var Ya=65535,Qa=Math.pow(2,16);function Ep(n,e){return n+e*Qa}function Ga(n){return n&Ya}function Np(n){return(n-(n&Ya))/Qa}var Za=1,ec=2,Rr=4,tc=8,Nn=class{constructor(e,t,r){this.pos=e,this.delInfo=t,this.recover=r}get deleted(){return(this.delInfo&tc)>0}get deletedBefore(){return(this.delInfo&(Za|Rr))>0}get deletedAfter(){return(this.delInfo&(ec|Rr))>0}get deletedAcross(){return(this.delInfo&Rr)>0}},Ye=class n{constructor(e,t=!1){if(this.ranges=e,this.inverted=t,!e.length&&n.empty)return n.empty}recover(e){let t=0,r=Ga(e);if(!this.inverted)for(let i=0;ie)break;let c=this.ranges[l+s],u=this.ranges[l+o],d=a+c;if(e<=d){let f=c?e==a?-1:e==d?1:t:t,h=a+i+(f<0?0:u);if(r)return h;let p=e==(t<0?a:d)?null:Ep(l/3,e-a),m=e==a?ec:e==d?Za:Rr;return(t<0?e!=a:e!=d)&&(m|=tc),new Nn(h,m,p)}i+=u-c}return r?e+i:new Nn(e+i,0,null)}touches(e,t){let r=0,i=Ga(t),s=this.inverted?2:1,o=this.inverted?1:2;for(let l=0;le)break;let c=this.ranges[l+s],u=a+c;if(e<=u&&l==i*3)return!0;r+=this.ranges[l+o]-c}return!1}forEach(e){let t=this.inverted?2:1,r=this.inverted?1:2;for(let i=0,s=0;i=0;t--){let i=e.getMirror(t);this.appendMap(e._maps[t].invert(),i!=null&&i>t?r-i-1:void 0)}}invert(){let e=new n;return e.appendMappingInverted(this),e}map(e,t=1){if(this.mirror)return this._map(e,t,!0);for(let r=this.from;rs&&a!o.isAtom||!l.type.allowsMarkType(this.mark.type)?o:o.mark(this.mark.addToSet(o.marks)),i),t.openStart,t.openEnd);return oe.fromReplace(e,this.from,this.to,s)}invert(){return new Qe(this.from,this.to,this.mark)}map(e){let t=e.mapResult(this.from,1),r=e.mapResult(this.to,-1);return t.deleted&&r.deleted||t.pos>=r.pos?null:new n(t.pos,r.pos,this.mark)}merge(e){return e instanceof n&&e.mark.eq(this.mark)&&this.from<=e.to&&this.to>=e.from?new n(Math.min(this.from,e.from),Math.max(this.to,e.to),this.mark):null}toJSON(){return{stepType:"addMark",mark:this.mark.toJSON(),from:this.from,to:this.to}}static fromJSON(e,t){if(typeof t.from!="number"||typeof t.to!="number")throw new RangeError("Invalid input for AddMarkStep.fromJSON");return new n(t.from,t.to,e.markFromJSON(t.mark))}};re.jsonID("addMark",Rn);var Qe=class n extends re{constructor(e,t,r){super(),this.from=e,this.to=t,this.mark=r}apply(e){let t=e.slice(this.from,this.to),r=new v(Fs(t.content,i=>i.mark(this.mark.removeFromSet(i.marks)),e),t.openStart,t.openEnd);return oe.fromReplace(e,this.from,this.to,r)}invert(){return new Rn(this.from,this.to,this.mark)}map(e){let t=e.mapResult(this.from,1),r=e.mapResult(this.to,-1);return t.deleted&&r.deleted||t.pos>=r.pos?null:new n(t.pos,r.pos,this.mark)}merge(e){return e instanceof n&&e.mark.eq(this.mark)&&this.from<=e.to&&this.to>=e.from?new n(Math.min(this.from,e.from),Math.max(this.to,e.to),this.mark):null}toJSON(){return{stepType:"removeMark",mark:this.mark.toJSON(),from:this.from,to:this.to}}static fromJSON(e,t){if(typeof t.from!="number"||typeof t.to!="number")throw new RangeError("Invalid input for RemoveMarkStep.fromJSON");return new n(t.from,t.to,e.markFromJSON(t.mark))}};re.jsonID("removeMark",Qe);var In=class n extends re{constructor(e,t){super(),this.pos=e,this.mark=t}apply(e){let t=e.nodeAt(this.pos);if(!t)return oe.fail("No node at mark step's position");let r=t.type.create(t.attrs,null,this.mark.addToSet(t.marks));return oe.fromReplace(e,this.pos,this.pos+1,new v(x.from(r),0,t.isLeaf?0:1))}invert(e){let t=e.nodeAt(this.pos);if(t){let r=this.mark.addToSet(t.marks);if(r.length==t.marks.length){for(let i=0;ir.pos?null:new n(t.pos,r.pos,i,s,this.slice,this.insert,this.structure)}toJSON(){let e={stepType:"replaceAround",from:this.from,to:this.to,gapFrom:this.gapFrom,gapTo:this.gapTo,insert:this.insert};return this.slice.size&&(e.slice=this.slice.toJSON()),this.structure&&(e.structure=!0),e}static fromJSON(e,t){if(typeof t.from!="number"||typeof t.to!="number"||typeof t.gapFrom!="number"||typeof t.gapTo!="number"||typeof t.insert!="number")throw new RangeError("Invalid input for ReplaceAroundStep.fromJSON");return new n(t.from,t.to,t.gapFrom,t.gapTo,v.fromJSON(e,t.slice),t.insert,!!t.structure)}};re.jsonID("replaceAround",ee);function zs(n,e,t){let r=n.resolve(e),i=t-e,s=r.depth;for(;i>0&&s>0&&r.indexAfter(s)==r.node(s).childCount;)s--,i--;if(i>0){let o=r.node(s).maybeChild(r.indexAfter(s));for(;i>0;){if(!o||o.isLeaf)return!0;o=o.firstChild,i--}}return!1}function Op(n,e,t,r){let i=[],s=[],o,l;n.doc.nodesBetween(e,t,(a,c,u)=>{if(!a.isInline)return;let d=a.marks;if(!r.isInSet(d)&&u.type.allowsMarkType(r.type)){let f=Math.max(c,e),h=Math.min(c+a.nodeSize,t),p=r.addToSet(d);for(let m=0;mn.step(a)),s.forEach(a=>n.step(a))}function Rp(n,e,t,r){let i=[],s=0;n.doc.nodesBetween(e,t,(o,l)=>{if(!o.isInline)return;s++;let a=null;if(r instanceof Mn){let c=o.marks,u;for(;u=r.isInSet(c);)(a||(a=[])).push(u),c=u.removeFromSet(c)}else r?r.isInSet(o.marks)&&(a=[r]):a=o.marks;if(a&&a.length){let c=Math.min(l+o.nodeSize,t);for(let u=0;un.step(new Qe(o.from,o.to,o.style)))}function Hs(n,e,t,r=t.contentMatch,i=!0){let s=n.doc.nodeAt(e),o=[],l=e+1;for(let a=0;a=0;a--)n.step(o[a])}function Ip(n,e,t){return(e==0||n.canReplace(e,n.childCount))&&(t==n.childCount||n.canReplace(0,t))}function Ze(n){let t=n.parent.content.cutByIndex(n.startIndex,n.endIndex);for(let r=n.depth,i=0,s=0;;--r){let o=n.$from.node(r),l=n.$from.index(r)+i,a=n.$to.indexAfter(r)-s;if(rt;p--)m||r.index(p)>0?(m=!0,u=x.from(r.node(p).copy(u)),d++):a--;let f=x.empty,h=0;for(let p=s,m=!1;p>t;p--)m||i.after(p+1)=0;o--){if(r.size){let l=t[o].type.contentMatch.matchFragment(r);if(!l||!l.validEnd)throw new RangeError("Wrapper type given to Transform.wrap does not form valid content of its parent wrapper")}r=x.from(t[o].type.create(t[o].attrs,r))}let i=e.start,s=e.end;n.step(new ee(i,s,i,s,new v(r,0,0),t.length,!0))}function Bp(n,e,t,r,i){if(!r.isTextblock)throw new RangeError("Type given to setBlockType should be a textblock");let s=n.steps.length;n.doc.nodesBetween(e,t,(o,l)=>{let a=typeof i=="function"?i(o):i;if(o.isTextblock&&!o.hasMarkup(r,a)&&Fp(n.doc,n.mapping.slice(s).map(l),r)){let c=null;if(r.schema.linebreakReplacement){let h=r.whitespace=="pre",p=!!r.contentMatch.matchType(r.schema.linebreakReplacement);h&&!p?c=!1:!h&&p&&(c=!0)}c===!1&&rc(n,o,l,s),Hs(n,n.mapping.slice(s).map(l,1),r,void 0,c===null);let u=n.mapping.slice(s),d=u.map(l,1),f=u.map(l+o.nodeSize,1);return n.step(new ee(d,f,d+1,f-1,new v(x.from(r.create(a,null,o.marks)),0,0),1,!0)),c===!0&&nc(n,o,l,s),!1}})}function nc(n,e,t,r){e.forEach((i,s)=>{if(i.isText){let o,l=/\r?\n|\r/g;for(;o=l.exec(i.text);){let a=n.mapping.slice(r).map(t+1+s+o.index);n.replaceWith(a,a+1,e.type.schema.linebreakReplacement.create())}}})}function rc(n,e,t,r){e.forEach((i,s)=>{if(i.type==i.type.schema.linebreakReplacement){let o=n.mapping.slice(r).map(t+1+s);n.replaceWith(o,o+1,e.type.schema.text(` +`))}})}function Fp(n,e,t){let r=n.resolve(e),i=r.index();return r.parent.canReplaceWith(i,i+1,t)}function Hp(n,e,t,r,i){let s=n.doc.nodeAt(e);if(!s)throw new RangeError("No node at given position");t||(t=s.type);let o=t.create(r,null,i||s.marks);if(s.isLeaf)return n.replaceWith(e,e+s.nodeSize,o);if(!t.validContent(s.content))throw new RangeError("Invalid content for node type "+t.name);n.step(new ee(e,e+s.nodeSize,e+1,e+s.nodeSize-1,new v(x.from(o),0,0),1,!0))}function Ae(n,e,t=1,r){let i=n.resolve(e),s=i.depth-t,o=r&&r[r.length-1]||i.parent;if(s<0||i.parent.type.spec.isolating||!i.parent.canReplace(i.index(),i.parent.childCount)||!o.type.validContent(i.parent.content.cutByIndex(i.index(),i.parent.childCount)))return!1;for(let c=i.depth-1,u=t-2;c>s;c--,u--){let d=i.node(c),f=i.index(c);if(d.type.spec.isolating)return!1;let h=d.content.cutByIndex(f,d.childCount),p=r&&r[u+1];p&&(h=h.replaceChild(0,p.type.create(p.attrs)));let m=r&&r[u]||d;if(!d.canReplace(f+1,d.childCount)||!m.type.validContent(h))return!1}let l=i.indexAfter(s),a=r&&r[0];return i.node(s).canReplaceWith(l,l,a?a.type:i.node(s+1).type)}function $p(n,e,t=1,r){let i=n.doc.resolve(e),s=x.empty,o=x.empty;for(let l=i.depth,a=i.depth-t,c=t-1;l>a;l--,c--){s=x.from(i.node(l).copy(s));let u=r&&r[c];o=x.from(u?u.type.create(u.attrs,o):i.node(l).copy(o))}n.step(new le(e,e,new v(s.append(o),t,t),!0))}function Oe(n,e){let t=n.resolve(e),r=t.index();return ic(t.nodeBefore,t.nodeAfter)&&t.parent.canReplace(r,r+1)}function _p(n,e){e.content.size||n.type.compatibleContent(e.type);let t=n.contentMatchAt(n.childCount),{linebreakReplacement:r}=n.type.schema;for(let i=0;i0?(s=r.node(i+1),l++,o=r.node(i).maybeChild(l)):(s=r.node(i).maybeChild(l-1),o=r.node(i+1)),s&&!s.isTextblock&&ic(s,o)&&r.node(i).canReplace(l,l+1))return e;if(i==0)break;e=t<0?r.before(i):r.after(i)}}function Vp(n,e,t){let r=null,{linebreakReplacement:i}=n.doc.type.schema,s=n.doc.resolve(e-t),o=s.node().type;if(i&&o.inlineContent){let u=o.whitespace=="pre",d=!!o.contentMatch.matchType(i);u&&!d?r=!1:!u&&d&&(r=!0)}let l=n.steps.length;if(r===!1){let u=n.doc.resolve(e+t);rc(n,u.node(),u.before(),l)}o.inlineContent&&Hs(n,e+t-1,o,s.node().contentMatchAt(s.index()),r==null);let a=n.mapping.slice(l),c=a.map(e-t);if(n.step(new le(c,a.map(e+t,-1),v.empty,!0)),r===!0){let u=n.doc.resolve(c);nc(n,u.node(),u.before(),n.steps.length)}return n}function Wp(n,e,t){let r=n.resolve(e);if(r.parent.canReplaceWith(r.index(),r.index(),t))return e;if(r.parentOffset==0)for(let i=r.depth-1;i>=0;i--){let s=r.index(i);if(r.node(i).canReplaceWith(s,s,t))return r.before(i+1);if(s>0)return null}if(r.parentOffset==r.parent.content.size)for(let i=r.depth-1;i>=0;i--){let s=r.indexAfter(i);if(r.node(i).canReplaceWith(s,s,t))return r.after(i+1);if(s=0;o--){let l=o==r.depth?0:r.pos<=(r.start(o+1)+r.end(o+1))/2?-1:1,a=r.index(o)+(l>0?1:0),c=r.node(o),u=!1;if(s==1)u=c.canReplace(a,a,i);else{let d=c.contentMatchAt(a).findWrapping(i.firstChild.type);u=d&&c.canReplaceWith(a,a,d[0])}if(u)return l==0?r.pos:l<0?r.before(o+1):r.after(o+1)}return null}function Dn(n,e,t=e,r=v.empty){if(e==t&&!r.size)return null;let i=n.resolve(e),s=n.resolve(t);return sc(i,s,r)?new le(e,t,r):new Bs(i,s,r).fit()}function sc(n,e,t){return!t.openStart&&!t.openEnd&&n.start()==e.start()&&n.parent.canReplace(n.index(),e.index(),t.content)}var Bs=class{constructor(e,t,r){this.$from=e,this.$to=t,this.unplaced=r,this.frontier=[],this.placed=x.empty;for(let i=0;i<=e.depth;i++){let s=e.node(i);this.frontier.push({type:s.type,match:s.contentMatchAt(e.indexAfter(i))})}for(let i=e.depth;i>0;i--)this.placed=x.from(e.node(i).copy(this.placed))}get depth(){return this.frontier.length-1}fit(){for(;this.unplaced.size;){let c=this.findFittable();c?this.placeNodes(c):this.openMore()||this.dropNode()}let e=this.mustMoveInline(),t=this.placed.size-this.depth-this.$from.depth,r=this.$from,i=this.close(e<0?this.$to:r.doc.resolve(e));if(!i)return null;let s=this.placed,o=r.depth,l=i.depth;for(;o&&l&&s.childCount==1;)s=s.firstChild.content,o--,l--;let a=new v(s,o,l);return e>-1?new ee(r.pos,e,this.$to.pos,this.$to.end(),a,t):a.size||r.pos!=this.$to.pos?new le(r.pos,i.pos,a):null}findFittable(){let e=this.unplaced.openStart;for(let t=this.unplaced.content,r=0,i=this.unplaced.openEnd;r1&&(i=0),s.type.spec.isolating&&i<=r){e=r;break}t=s.content}for(let t=1;t<=2;t++)for(let r=t==1?e:this.unplaced.openStart;r>=0;r--){let i,s=null;r?(s=Ls(this.unplaced.content,r-1).firstChild,i=s.content):i=this.unplaced.content;let o=i.firstChild;for(let l=this.depth;l>=0;l--){let{type:a,match:c}=this.frontier[l],u,d=null;if(t==1&&(o?c.matchType(o.type)||(d=c.fillBefore(x.from(o),!1)):s&&a.compatibleContent(s.type)))return{sliceDepth:r,frontierDepth:l,parent:s,inject:d};if(t==2&&o&&(u=c.findWrapping(o.type)))return{sliceDepth:r,frontierDepth:l,parent:s,wrap:u};if(s&&c.matchType(s.type))break}}}openMore(){let{content:e,openStart:t,openEnd:r}=this.unplaced,i=Ls(e,t);return!i.childCount||i.firstChild.isLeaf?!1:(this.unplaced=new v(e,t+1,Math.max(r,i.size+t>=e.size-r?t+1:0)),!0)}dropNode(){let{content:e,openStart:t,openEnd:r}=this.unplaced,i=Ls(e,t);if(i.childCount<=1&&t>0){let s=e.size-t<=t+i.size;this.unplaced=new v(An(e,t-1,1),t-1,s?t-1:r)}else this.unplaced=new v(An(e,t,1),t,r)}placeNodes({sliceDepth:e,frontierDepth:t,parent:r,inject:i,wrap:s}){for(;this.depth>t;)this.closeFrontierNode();if(s)for(let m=0;m1||a==0||m.content.size)&&(d=g,u.push(oc(m.mark(f.allowedMarks(m.marks)),c==1?a:0,c==l.childCount?h:-1)))}let p=c==l.childCount;p||(h=-1),this.placed=En(this.placed,t,x.from(u)),this.frontier[t].match=d,p&&h<0&&r&&r.type==this.frontier[this.depth].type&&this.frontier.length>1&&this.closeFrontierNode();for(let m=0,g=l;m1&&i==this.$to.end(--r);)++i;return i}findCloseLevel(e){e:for(let t=Math.min(this.depth,e.depth);t>=0;t--){let{match:r,type:i}=this.frontier[t],s=t=0;l--){let{match:a,type:c}=this.frontier[l],u=Ps(e,l,c,a,!0);if(!u||u.childCount)continue e}return{depth:t,fit:o,move:s?e.doc.resolve(e.after(t+1)):e}}}}close(e){let t=this.findCloseLevel(e);if(!t)return null;for(;this.depth>t.depth;)this.closeFrontierNode();t.fit.childCount&&(this.placed=En(this.placed,t.depth,t.fit)),e=t.move;for(let r=t.depth+1;r<=e.depth;r++){let i=e.node(r),s=i.type.contentMatch.fillBefore(i.content,!0,e.index(r));this.openFrontierNode(i.type,i.attrs,s)}return e}openFrontierNode(e,t=null,r){let i=this.frontier[this.depth];i.match=i.match.matchType(e),this.placed=En(this.placed,this.depth,x.from(e.create(t,r))),this.frontier.push({type:e,match:e.contentMatch})}closeFrontierNode(){let t=this.frontier.pop().match.fillBefore(x.empty,!0);t.childCount&&(this.placed=En(this.placed,this.frontier.length,t))}};function An(n,e,t){return e==0?n.cutByIndex(t,n.childCount):n.replaceChild(0,n.firstChild.copy(An(n.firstChild.content,e-1,t)))}function En(n,e,t){return e==0?n.append(t):n.replaceChild(n.childCount-1,n.lastChild.copy(En(n.lastChild.content,e-1,t)))}function Ls(n,e){for(let t=0;t1&&(r=r.replaceChild(0,oc(r.firstChild,e-1,r.childCount==1?t-1:0))),e>0&&(r=n.type.contentMatch.fillBefore(r).append(r),t<=0&&(r=r.append(n.type.contentMatch.matchFragment(r).fillBefore(x.empty,!0)))),n.copy(r)}function Ps(n,e,t,r,i){let s=n.node(e),o=i?n.indexAfter(e):n.index(e);if(o==s.childCount&&!t.compatibleContent(s.type))return null;let l=r.fillBefore(s.content,!0,o);return l&&!jp(t,s.content,o)?l:null}function jp(n,e,t){for(let r=t;r0;f--,h--){let p=i.node(f).type.spec;if(p.defining||p.definingAsContext||p.isolating)break;o.indexOf(f)>-1?l=f:i.before(f)==h&&o.splice(1,0,-f)}let a=o.indexOf(l),c=[],u=r.openStart;for(let f=r.content,h=0;;h++){let p=f.firstChild;if(c.push(p),h==r.openStart)break;f=p.content}for(let f=u-1;f>=0;f--){let h=c[f],p=Kp(h.type);if(p&&!h.sameMarkup(i.node(Math.abs(l)-1)))u=f;else if(p||!h.type.isTextblock)break}for(let f=r.openStart;f>=0;f--){let h=(f+u+1)%(r.openStart+1),p=c[h];if(p)for(let m=0;m=0&&(n.replace(e,t,r),!(n.steps.length>d));f--){let h=o[f];h<0||(e=i.before(h),t=s.after(h))}}function lc(n,e,t,r,i){if(er){let s=i.contentMatchAt(0),o=s.fillBefore(n).append(n);n=o.append(s.matchFragment(o).fillBefore(x.empty,!0))}return n}function qp(n,e,t,r){if(!r.isInline&&e==t&&n.doc.resolve(e).parent.content.size){let i=Wp(n.doc,e,r.type);i!=null&&(e=t=i)}n.replaceRange(e,t,new v(x.from(r),0,0))}function Up(n,e,t){let r=n.doc.resolve(e),i=n.doc.resolve(t);if(r.parent.isTextblock&&i.parent.isTextblock&&r.start()!=i.start()&&r.parentOffset==0&&i.parentOffset==0){let o=r.sharedDepth(t),l=!1;for(let a=r.depth;a>o;a--)r.node(a).type.spec.isolating&&(l=!0);for(let a=i.depth;a>o;a--)i.node(a).type.spec.isolating&&(l=!0);if(!l){for(let a=r.depth;a>0&&e==r.start(a);a--)e=r.before(a);for(let a=i.depth;a>0&&t==i.start(a);a--)t=i.before(a);r=n.doc.resolve(e),i=n.doc.resolve(t)}}let s=ac(r,i);for(let o=0;o0&&(a||r.node(l-1).canReplace(r.index(l-1),i.indexAfter(l-1))))return n.delete(r.before(l),i.after(l))}for(let o=1;o<=r.depth&&o<=i.depth;o++)if(e-r.start(o)==r.depth-o&&t>r.end(o)&&i.end(o)-t!=i.depth-o&&r.start(o-1)==i.start(o-1)&&r.node(o-1).canReplace(r.index(o-1),i.index(o-1)))return n.delete(r.before(o),t);n.delete(e,t)}function ac(n,e){let t=[],r=Math.min(n.depth,e.depth);for(let i=r;i>=0;i--){let s=n.start(i);if(se.pos+(e.depth-i)||n.node(i).type.spec.isolating||e.node(i).type.spec.isolating)break;(s==e.start(i)||i==n.depth&&i==e.depth&&n.parent.inlineContent&&e.parent.inlineContent&&i&&e.start(i-1)==s-1)&&t.push(i)}return t}var Ir=class n extends re{constructor(e,t,r){super(),this.pos=e,this.attr=t,this.value=r}apply(e){let t=e.nodeAt(this.pos);if(!t)return oe.fail("No node at attribute step's position");let r=Object.create(null);for(let s in t.attrs)r[s]=t.attrs[s];r[this.attr]=this.value;let i=t.type.create(r,null,t.marks);return oe.fromReplace(e,this.pos,this.pos+1,new v(x.from(i),0,t.isLeaf?0:1))}getMap(){return Ye.empty}invert(e){return new n(this.pos,this.attr,e.nodeAt(this.pos).attrs[this.attr])}map(e){let t=e.mapResult(this.pos,1);return t.deletedAfter?null:new n(t.pos,this.attr,this.value)}toJSON(){return{stepType:"attr",pos:this.pos,attr:this.attr,value:this.value}}static fromJSON(e,t){if(typeof t.pos!="number"||typeof t.attr!="string")throw new RangeError("Invalid input for AttrStep.fromJSON");return new n(t.pos,t.attr,t.value)}};re.jsonID("attr",Ir);var Dr=class n extends re{constructor(e,t){super(),this.attr=e,this.value=t}apply(e){let t=Object.create(null);for(let i in e.attrs)t[i]=e.attrs[i];t[this.attr]=this.value;let r=e.type.create(t,e.content,e.marks);return oe.ok(r)}getMap(){return Ye.empty}invert(e){return new n(this.attr,e.attrs[this.attr])}map(e){return this}toJSON(){return{stepType:"docAttr",attr:this.attr,value:this.value}}static fromJSON(e,t){if(typeof t.attr!="string")throw new RangeError("Invalid input for DocAttrStep.fromJSON");return new n(t.attr,t.value)}};re.jsonID("docAttr",Dr);var tn=class extends Error{};tn=function n(e){let t=Error.call(this,e);return t.__proto__=n.prototype,t};tn.prototype=Object.create(Error.prototype);tn.prototype.constructor=tn;tn.prototype.name="TransformError";var ht=class{constructor(e){this.doc=e,this.steps=[],this.docs=[],this.mapping=new On}get before(){return this.docs.length?this.docs[0]:this.doc}step(e){let t=this.maybeStep(e);if(t.failed)throw new tn(t.failed);return this}maybeStep(e){let t=e.apply(this.doc);return t.failed||this.addStep(e,t.doc),t}get docChanged(){return this.steps.length>0}changedRange(){let e=1e9,t=-1e9;for(let r=0;r{e=Math.min(e,l),t=Math.max(t,a)})}return e==1e9?null:{from:e,to:t}}addStep(e,t){this.docs.push(this.doc),this.steps.push(e),this.mapping.appendMap(e.getMap()),this.doc=t}replace(e,t=e,r=v.empty){let i=Dn(this.doc,e,t,r);return i&&this.step(i),this}replaceWith(e,t,r){return this.replace(e,t,new v(x.from(r),0,0))}delete(e,t){return this.replace(e,t,v.empty)}insert(e,t){return this.replaceWith(e,e,t)}replaceRange(e,t,r){return Jp(this,e,t,r),this}replaceRangeWith(e,t,r){return qp(this,e,t,r),this}deleteRange(e,t){return Up(this,e,t),this}lift(e,t){return Dp(this,e,t),this}join(e,t=1){return Vp(this,e,t),this}wrap(e,t){return zp(this,e,t),this}setBlockType(e,t=e,r,i=null){return Bp(this,e,t,r,i),this}setNodeMarkup(e,t,r=null,i){return Hp(this,e,t,r,i),this}setNodeAttribute(e,t,r){return this.step(new Ir(e,t,r)),this}setDocAttribute(e,t){return this.step(new Dr(e,t)),this}addNodeMark(e,t){return this.step(new In(e,t)),this}removeNodeMark(e,t){let r=this.doc.nodeAt(e);if(!r)throw new RangeError("No node at position "+e);if(t instanceof W)t.isInSet(r.marks)&&this.step(new en(e,t));else{let i=r.marks,s,o=[];for(;s=t.isInSet(i);)o.push(new en(e,s)),i=s.removeFromSet(i);for(let l=o.length-1;l>=0;l--)this.step(o[l])}return this}split(e,t=1,r){return $p(this,e,t,r),this}addMark(e,t,r){return Op(this,e,t,r),this}removeMark(e,t,r){return Rp(this,e,t,r),this}clearIncompatible(e,t,r){return Hs(this,e,t,r),this}};var $s=Object.create(null),N=class{constructor(e,t,r){this.$anchor=e,this.$head=t,this.ranges=r||[new sn(e.min(t),e.max(t))]}get anchor(){return this.$anchor.pos}get head(){return this.$head.pos}get from(){return this.$from.pos}get to(){return this.$to.pos}get $from(){return this.ranges[0].$from}get $to(){return this.ranges[0].$to}get empty(){let e=this.ranges;for(let t=0;t=0;s--){let o=t<0?rn(e.node(0),e.node(s),e.before(s+1),e.index(s),t,r):rn(e.node(0),e.node(s),e.after(s+1),e.index(s)+1,t,r);if(o)return o}return null}static near(e,t=1){return this.findFrom(e,t)||this.findFrom(e,-t)||new ye(e.node(0))}static atStart(e){return rn(e,e,0,0,1)||new ye(e)}static atEnd(e){return rn(e,e,e.content.size,e.childCount,-1)||new ye(e)}static fromJSON(e,t){if(!t||!t.type)throw new RangeError("Invalid input for Selection.fromJSON");let r=$s[t.type];if(!r)throw new RangeError(`No selection type ${t.type} defined`);return r.fromJSON(e,t)}static jsonID(e,t){if(e in $s)throw new RangeError("Duplicate use of selection JSON ID "+e);return $s[e]=t,t.prototype.jsonID=e,t}getBookmark(){return T.between(this.$anchor,this.$head).getBookmark()}};N.prototype.visible=!0;var sn=class{constructor(e,t){this.$from=e,this.$to=t}},cc=!1;function uc(n){!cc&&!n.parent.inlineContent&&(cc=!0,console.warn("TextSelection endpoint not pointing into a node with inline content ("+n.parent.type.name+")"))}var T=class n extends N{constructor(e,t=e){uc(e),uc(t),super(e,t)}get $cursor(){return this.$anchor.pos==this.$head.pos?this.$head:null}map(e,t){let r=e.resolve(t.map(this.head));if(!r.parent.inlineContent)return N.near(r);let i=e.resolve(t.map(this.anchor));return new n(i.parent.inlineContent?i:r,r)}replace(e,t=v.empty){if(super.replace(e,t),t==v.empty){let r=this.$from.marksAcross(this.$to);r&&e.ensureMarks(r)}}eq(e){return e instanceof n&&e.anchor==this.anchor&&e.head==this.head}getBookmark(){return new zr(this.anchor,this.head)}toJSON(){return{type:"text",anchor:this.anchor,head:this.head}}static fromJSON(e,t){if(typeof t.anchor!="number"||typeof t.head!="number")throw new RangeError("Invalid input for TextSelection.fromJSON");return new n(e.resolve(t.anchor),e.resolve(t.head))}static create(e,t,r=t){let i=e.resolve(t);return new this(i,r==t?i:e.resolve(r))}static between(e,t,r){let i=e.pos-t.pos;if((!r||i)&&(r=i>=0?1:-1),!t.parent.inlineContent){let s=N.findFrom(t,r,!0)||N.findFrom(t,-r,!0);if(s)t=s.$head;else return N.near(t,r)}return e.parent.inlineContent||(i==0?e=t:(e=(N.findFrom(e,-r,!0)||N.findFrom(e,r,!0)).$anchor,e.pos0?0:1);i>0?o=0;o+=i){let l=e.child(o);if(l.isAtom){if(!s&&A.isSelectable(l))return A.create(n,t-(i<0?l.nodeSize:0))}else{let a=rn(n,l,t+i,i<0?l.childCount:0,i,s);if(a)return a}t+=l.nodeSize*i}return null}function dc(n,e,t){let r=n.steps.length-1;if(r{o==null&&(o=u)}),n.setSelection(N.near(n.doc.resolve(o),t))}var fc=1,Pr=2,hc=4,Ws=class extends ht{constructor(e){super(e.doc),this.curSelectionFor=0,this.updated=0,this.meta=Object.create(null),this.time=Date.now(),this.curSelection=e.selection,this.storedMarks=e.storedMarks}get selection(){return this.curSelectionFor0}setStoredMarks(e){return this.storedMarks=e,this.updated|=Pr,this}ensureMarks(e){return W.sameSet(this.storedMarks||this.selection.$from.marks(),e)||this.setStoredMarks(e),this}addStoredMark(e){return this.ensureMarks(e.addToSet(this.storedMarks||this.selection.$head.marks()))}removeStoredMark(e){return this.ensureMarks(e.removeFromSet(this.storedMarks||this.selection.$head.marks()))}get storedMarksSet(){return(this.updated&Pr)>0}addStep(e,t){super.addStep(e,t),this.updated=this.updated&~Pr,this.storedMarks=null}setTime(e){return this.time=e,this}replaceSelection(e){return this.selection.replace(this,e),this}replaceSelectionWith(e,t=!0){let r=this.selection;return t&&(e=e.mark(this.storedMarks||(r.empty?r.$from.marks():r.$from.marksAcross(r.$to)||W.none))),r.replaceWith(this,e),this}deleteSelection(){return this.selection.replace(this),this}insertText(e,t,r){let i=this.doc.type.schema;if(t==null)return e?this.replaceSelectionWith(i.text(e),!0):this.deleteSelection();{if(r==null&&(r=t),!e)return this.deleteRange(t,r);let s=this.storedMarks;if(!s){let o=this.doc.resolve(t);s=r==t?o.marks():o.marksAcross(this.doc.resolve(r))}return this.replaceRangeWith(t,r,i.text(e,s)),!this.selection.empty&&this.selection.to==t+e.length&&this.setSelection(N.near(this.selection.$to)),this}}setMeta(e,t){return this.meta[typeof e=="string"?e:e.key]=t,this}getMeta(e){return this.meta[typeof e=="string"?e:e.key]}get isGeneric(){for(let e in this.meta)return!1;return!0}scrollIntoView(){return this.updated|=hc,this}get scrolledIntoView(){return(this.updated&hc)>0}};function pc(n,e){return!e||!n?n:n.bind(e)}var Ot=class{constructor(e,t,r){this.name=e,this.init=pc(t.init,r),this.apply=pc(t.apply,r)}},Xp=[new Ot("doc",{init(n){return n.doc||n.schema.topNodeType.createAndFill()},apply(n){return n.doc}}),new Ot("selection",{init(n,e){return n.selection||N.atStart(e.doc)},apply(n){return n.selection}}),new Ot("storedMarks",{init(n){return n.storedMarks||null},apply(n,e,t,r){return r.selection.$cursor?n.storedMarks:null}}),new Ot("scrollToSelection",{init(){return 0},apply(n,e){return n.scrolledIntoView?e+1:e}})],Ln=class{constructor(e,t){this.schema=e,this.plugins=[],this.pluginsByKey=Object.create(null),this.fields=Xp.slice(),t&&t.forEach(r=>{if(this.pluginsByKey[r.key])throw new RangeError("Adding different instances of a keyed plugin ("+r.key+")");this.plugins.push(r),this.pluginsByKey[r.key]=r,r.spec.state&&this.fields.push(new Ot(r.key,r.spec.state,r))})}},Br=class n{constructor(e){this.config=e}get schema(){return this.config.schema}get plugins(){return this.config.plugins}apply(e){return this.applyTransaction(e).state}filterTransaction(e,t=-1){for(let r=0;rr.toJSON())),e&&typeof e=="object")for(let r in e){if(r=="doc"||r=="selection")throw new RangeError("The JSON fields `doc` and `selection` are reserved");let i=e[r],s=i.spec.state;s&&s.toJSON&&(t[r]=s.toJSON.call(i,this[i.key]))}return t}static fromJSON(e,t,r){if(!t)throw new RangeError("Invalid input for EditorState.fromJSON");if(!e.schema)throw new RangeError("Required config field 'schema' missing");let i=new Ln(e.schema,e.plugins),s=new n(i);return i.fields.forEach(o=>{if(o.name=="doc")s.doc=ve.fromJSON(e.schema,t.doc);else if(o.name=="selection")s.selection=N.fromJSON(s.doc,t.selection);else if(o.name=="storedMarks")t.storedMarks&&(s.storedMarks=t.storedMarks.map(e.schema.markFromJSON));else{if(r)for(let l in r){let a=r[l],c=a.spec.state;if(a.key==o.name&&c&&c.fromJSON&&Object.prototype.hasOwnProperty.call(t,l)){s[o.name]=c.fromJSON.call(a,e,t[l],s);return}}s[o.name]=o.init(e,s)}}),s}};function mc(n,e,t){for(let r in n){let i=n[r];i instanceof Function?i=i.bind(e):r=="handleDOMEvents"&&(i=mc(i,e,{})),t[r]=i}return t}var R=class{constructor(e){this.spec=e,this.props={},e.props&&mc(e.props,this,this.props),this.key=e.key?e.key.key:gc("plugin")}getState(e){return e[this.key]}},_s=Object.create(null);function gc(n){return n in _s?n+"$"+ ++_s[n]:(_s[n]=0,n+"$")}var z=class{constructor(e="key"){this.key=gc(e)}get(e){return e.config.pluginsByKey[this.key]}getState(e){return e[this.key]}};var bc=(n,e)=>n.selection.empty?!1:(e&&e(n.tr.deleteSelection().scrollIntoView()),!0);function kc(n,e){let{$cursor:t}=n.selection;return!t||(e?!e.endOfTextblock("backward",n):t.parentOffset>0)?null:t}var Ks=(n,e,t)=>{let r=kc(n,t);if(!r)return!1;let i=qs(r);if(!i){let o=r.blockRange(),l=o&&Ze(o);return l==null?!1:(e&&e(n.tr.lift(o,l).scrollIntoView()),!0)}let s=i.nodeBefore;if(Ec(n,i,e,-1))return!0;if(r.parent.content.size==0&&(on(s,"end")||A.isSelectable(s)))for(let o=r.depth;;o--){let l=Dn(n.doc,r.before(o),r.after(o),v.empty);if(l&&l.slice.size1)break}return s.isAtom&&i.depth==r.depth-1?(e&&e(n.tr.delete(i.pos-s.nodeSize,i.pos).scrollIntoView()),!0):!1},xc=(n,e,t)=>{let r=kc(n,t);if(!r)return!1;let i=qs(r);return i?Sc(n,i,e):!1},wc=(n,e,t)=>{let r=vc(n,t);if(!r)return!1;let i=Xs(r);return i?Sc(n,i,e):!1};function Sc(n,e,t){let r=e.nodeBefore,i=r,s=e.pos-1;for(;!i.isTextblock;s--){if(i.type.spec.isolating)return!1;let u=i.lastChild;if(!u)return!1;i=u}let o=e.nodeAfter,l=o,a=e.pos+1;for(;!l.isTextblock;a++){if(l.type.spec.isolating)return!1;let u=l.firstChild;if(!u)return!1;l=u}let c=Dn(n.doc,s,a,v.empty);if(!c||c.from!=s||c instanceof le&&c.slice.size>=a-s)return!1;if(t){let u=n.tr.step(c);u.setSelection(T.create(u.doc,s)),t(u.scrollIntoView())}return!0}function on(n,e,t=!1){for(let r=n;r;r=e=="start"?r.firstChild:r.lastChild){if(r.isTextblock)return!0;if(t&&r.childCount!=1)return!1}return!1}var Js=(n,e,t)=>{let{$head:r,empty:i}=n.selection,s=r;if(!i)return!1;if(r.parent.isTextblock){if(t?!t.endOfTextblock("backward",n):r.parentOffset>0)return!1;s=qs(r)}let o=s&&s.nodeBefore;return!o||!A.isSelectable(o)?!1:(e&&e(n.tr.setSelection(A.create(n.doc,s.pos-o.nodeSize)).scrollIntoView()),!0)};function qs(n){if(!n.parent.type.spec.isolating)for(let e=n.depth-1;e>=0;e--){if(n.index(e)>0)return n.doc.resolve(n.before(e+1));if(n.node(e).type.spec.isolating)break}return null}function vc(n,e){let{$cursor:t}=n.selection;return!t||(e?!e.endOfTextblock("forward",n):t.parentOffset{let r=vc(n,t);if(!r)return!1;let i=Xs(r);if(!i)return!1;let s=i.nodeAfter;if(Ec(n,i,e,1))return!0;if(r.parent.content.size==0&&(on(s,"start")||A.isSelectable(s))){let o=Dn(n.doc,r.before(),r.after(),v.empty);if(o&&o.slice.size{let{$head:r,empty:i}=n.selection,s=r;if(!i)return!1;if(r.parent.isTextblock){if(t?!t.endOfTextblock("forward",n):r.parentOffset=0;e--){let t=n.node(e);if(n.index(e)+1{let t=n.selection,r=t instanceof A,i;if(r){if(t.node.isTextblock||!Oe(n.doc,t.from))return!1;i=t.from}else if(i=Nt(n.doc,t.from,-1),i==null)return!1;if(e){let s=n.tr.join(i);r&&s.setSelection(A.create(s.doc,i-n.doc.resolve(i).nodeBefore.nodeSize)),e(s.scrollIntoView())}return!0},Mc=(n,e)=>{let t=n.selection,r;if(t instanceof A){if(t.node.isTextblock||!Oe(n.doc,t.to))return!1;r=t.to}else if(r=Nt(n.doc,t.to,1),r==null)return!1;return e&&e(n.tr.join(r).scrollIntoView()),!0},Tc=(n,e)=>{let{$from:t,$to:r}=n.selection,i=t.blockRange(r),s=i&&Ze(i);return s==null?!1:(e&&e(n.tr.lift(i,s).scrollIntoView()),!0)},Ys=(n,e)=>{let{$head:t,$anchor:r}=n.selection;return!t.parent.type.spec.code||!t.sameParent(r)?!1:(e&&e(n.tr.insertText(` +`).scrollIntoView()),!0)};function Qs(n){for(let e=0;e{let{$head:t,$anchor:r}=n.selection;if(!t.parent.type.spec.code||!t.sameParent(r))return!1;let i=t.node(-1),s=t.indexAfter(-1),o=Qs(i.contentMatchAt(s));if(!o||!i.canReplaceWith(s,s,o))return!1;if(e){let l=t.after(),a=n.tr.replaceWith(l,l,o.createAndFill());a.setSelection(N.near(a.doc.resolve(l),1)),e(a.scrollIntoView())}return!0},eo=(n,e)=>{let t=n.selection,{$from:r,$to:i}=t;if(t instanceof ye||r.parent.inlineContent||i.parent.inlineContent)return!1;let s=Qs(i.parent.contentMatchAt(i.indexAfter()));if(!s||!s.isTextblock)return!1;if(e){let o=(!r.parentOffset&&i.index(){let{$cursor:t}=n.selection;if(!t||t.parent.content.size)return!1;if(t.depth>1&&t.after()!=t.end(-1)){let s=t.before();if(Ae(n.doc,s))return e&&e(n.tr.split(s).scrollIntoView()),!0}let r=t.blockRange(),i=r&&Ze(r);return i==null?!1:(e&&e(n.tr.lift(r,i).scrollIntoView()),!0)};function Yp(n){return(e,t)=>{let{$from:r,$to:i}=e.selection;if(e.selection instanceof A&&e.selection.node.isBlock)return!r.parentOffset||!Ae(e.doc,r.pos)?!1:(t&&t(e.tr.split(r.pos).scrollIntoView()),!0);if(!r.depth)return!1;let s=[],o,l,a=!1,c=!1;for(let h=r.depth;;h--)if(r.node(h).isBlock){a=r.end(h)==r.pos+(r.depth-h),c=r.start(h)==r.pos-(r.depth-h),l=Qs(r.node(h-1).contentMatchAt(r.indexAfter(h-1)));let m=n&&n(i.parent,a,r);s.unshift(m||(a&&l?{type:l}:null)),o=h;break}else{if(h==1)return!1;s.unshift(null)}let u=e.tr;(e.selection instanceof T||e.selection instanceof ye)&&u.deleteSelection();let d=u.mapping.map(r.pos),f=Ae(u.doc,d,s.length,s);if(f||(s[0]=l?{type:l}:null,f=Ae(u.doc,d,s.length,s)),!f)return!1;if(u.split(d,s.length,s),!a&&c&&r.node(o).type!=l){let h=u.mapping.map(r.before(o)),p=u.doc.resolve(h);l&&r.node(o-1).canReplaceWith(p.index(),p.index()+1,l)&&u.setNodeMarkup(u.mapping.map(r.before(o)),l)}return t&&t(u.scrollIntoView()),!0}}var Qp=Yp();var Ac=(n,e)=>{let{$from:t,to:r}=n.selection,i,s=t.sharedDepth(r);return s==0?!1:(i=t.before(s),e&&e(n.tr.setSelection(A.create(n.doc,i))),!0)},Zp=(n,e)=>(e&&e(n.tr.setSelection(new ye(n.doc))),!0);function em(n,e,t){let r=e.nodeBefore,i=e.nodeAfter,s=e.index();return!r||!i||!r.type.compatibleContent(i.type)?!1:!r.content.size&&e.parent.canReplace(s-1,s)?(t&&t(n.tr.delete(e.pos-r.nodeSize,e.pos).scrollIntoView()),!0):!e.parent.canReplace(s,s+1)||!(i.isTextblock||Oe(n.doc,e.pos))?!1:(t&&t(n.tr.join(e.pos).scrollIntoView()),!0)}function Ec(n,e,t,r){let i=e.nodeBefore,s=e.nodeAfter,o,l,a=i.type.spec.isolating||s.type.spec.isolating;if(!a&&em(n,e,t))return!0;let c=!a&&e.parent.canReplace(e.index(),e.index()+1);if(c&&(o=(l=i.contentMatchAt(i.childCount)).findWrapping(s.type))&&l.matchType(o[0]||s.type).validEnd){if(t){let h=e.pos+s.nodeSize,p=x.empty;for(let y=o.length-1;y>=0;y--)p=x.from(o[y].create(null,p));p=x.from(i.copy(p));let m=n.tr.step(new ee(e.pos-1,h,e.pos,h,new v(p,1,0),o.length,!0)),g=m.doc.resolve(h+2*o.length);g.nodeAfter&&g.nodeAfter.type==i.type&&Oe(m.doc,g.pos)&&m.join(g.pos),t(m.scrollIntoView())}return!0}let u=s.type.spec.isolating||r>0&&a?null:N.findFrom(e,1),d=u&&u.$from.blockRange(u.$to),f=d&&Ze(d);if(f!=null&&f>=e.depth)return t&&t(n.tr.lift(d,f).scrollIntoView()),!0;if(c&&on(s,"start",!0)&&on(i,"end")){let h=i,p=[];for(;p.push(h),!h.isTextblock;)h=h.lastChild;let m=s,g=1;for(;!m.isTextblock;m=m.firstChild)g++;if(h.canReplace(h.childCount,h.childCount,m.content)){if(t){let y=x.empty;for(let k=p.length-1;k>=0;k--)y=x.from(p[k].copy(y));let b=n.tr.step(new ee(e.pos-p.length,e.pos+s.nodeSize,e.pos+g,e.pos+s.nodeSize-g,new v(y,p.length,0),0,!0));t(b.scrollIntoView())}return!0}}return!1}function Nc(n){return function(e,t){let r=e.selection,i=n<0?r.$from:r.$to,s=i.depth;for(;i.node(s).isInline;){if(!s)return!1;s--}return i.node(s).isTextblock?(t&&t(e.tr.setSelection(T.create(e.doc,n<0?i.start(s):i.end(s)))),!0):!1}}var no=Nc(-1),ro=Nc(1);function Oc(n,e=null){return function(t,r){let{$from:i,$to:s}=t.selection,o=i.blockRange(s),l=o&&nn(o,n,e);return l?(r&&r(t.tr.wrap(o,l).scrollIntoView()),!0):!1}}function io(n,e=null){return function(t,r){let i=!1;for(let s=0;s{if(i)return!1;if(!(!a.isTextblock||a.hasMarkup(n,e)))if(a.type==n)i=!0;else{let u=t.doc.resolve(c),d=u.index();i=u.parent.canReplaceWith(d,d+1,n)}})}if(!i)return!1;if(r){let s=t.tr;for(let o=0;o=2&&e.$from.node(e.depth-1).type.compatibleContent(t)&&e.startIndex==0){if(e.$from.index(e.depth-1)==0)return!1;let a=o.resolve(e.start-2);s=new At(a,a,e.depth),e.endIndex=0;u--)s=x.from(t[u].type.create(t[u].attrs,s));n.step(new ee(e.start-(r?2:0),e.end,e.start,e.end,new v(s,0,0),t.length,!0));let o=0;for(let u=0;uo.childCount>0&&o.firstChild.type==n);return s?t?r.node(s.depth-1).type==n?im(e,t,n,s):sm(e,t,s):!0:!1}}function im(n,e,t,r){let i=n.tr,s=r.end,o=r.$to.end(r.depth);sm;p--)h-=i.child(p).nodeSize,r.delete(h-1,h+1);let s=r.doc.resolve(t.start),o=s.nodeAfter;if(r.mapping.map(t.end)!=t.start+s.nodeAfter.nodeSize)return!1;let l=t.startIndex==0,a=t.endIndex==i.childCount,c=s.node(-1),u=s.index(-1);if(!c.canReplace(u+(l?0:1),u+1,o.content.append(a?x.empty:x.from(i))))return!1;let d=s.pos,f=d+o.nodeSize;return r.step(new ee(d-(l?1:0),f+(a?1:0),d+1,f-1,new v((l?x.empty:x.from(i.copy(x.empty))).append(a?x.empty:x.from(i.copy(x.empty))),l?0:1,a?0:1),l?0:1)),e(r.scrollIntoView()),!0}function Dc(n){return function(e,t){let{$from:r,$to:i}=e.selection,s=r.blockRange(i,c=>c.childCount>0&&c.firstChild.type==n);if(!s)return!1;let o=s.startIndex;if(o==0)return!1;let l=s.parent,a=l.child(o-1);if(a.type!=n)return!1;if(t){let c=a.lastChild&&a.lastChild.type==l.type,u=x.from(c?n.create():null),d=new v(x.from(n.create(null,x.from(l.type.create(null,u)))),c?3:1,0),f=s.start,h=s.end;t(e.tr.step(new ee(f-(c?3:1),h,f,h,d,1,!0)).scrollIntoView())}return!0}}var ae=function(n){for(var e=0;;e++)if(n=n.previousSibling,!n)return e},un=function(n){let e=n.assignedSlot||n.parentNode;return e&&e.nodeType==11?e.host:e},fo=null,tt=function(n,e,t){let r=fo||(fo=document.createRange());return r.setEnd(n,t==null?n.nodeValue.length:t),r.setStart(n,e||0),r},om=function(){fo=null},Ft=function(n,e,t,r){return t&&(Lc(n,e,t,r,-1)||Lc(n,e,t,r,1))},lm=/^(img|br|input|textarea|hr)$/i;function Lc(n,e,t,r,i){for(var s;;){if(n==t&&e==r)return!0;if(e==(i<0?0:Ie(n))){let o=n.parentNode;if(!o||o.nodeType!=1||Vn(n)||lm.test(n.nodeName)||n.contentEditable=="false")return!1;e=ae(n)+(i<0?0:1),n=o}else if(n.nodeType==1){let o=n.childNodes[e+(i<0?-1:0)];if(o.nodeType==1&&o.contentEditable=="false")if(!((s=o.pmViewDesc)===null||s===void 0)&&s.ignoreForSelection)e+=i;else return!1;else n=o,e=i<0?Ie(n):0}else return!1}}function Ie(n){return n.nodeType==3?n.nodeValue.length:n.childNodes.length}function am(n,e){for(;;){if(n.nodeType==3&&e)return n;if(n.nodeType==1&&e>0){if(n.contentEditable=="false")return null;n=n.childNodes[e-1],e=Ie(n)}else if(n.parentNode&&!Vn(n))e=ae(n),n=n.parentNode;else return null}}function cm(n,e){for(;;){if(n.nodeType==3&&e2),Re=dn||(_e?/Mac/.test(_e.platform):!1),hu=_e?/Win/.test(_e.platform):!1,nt=/Android \d/.test(kt),Wn=!!Pc&&"webkitFontSmoothing"in Pc.documentElement.style,hm=Wn?+(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent)||[0,0])[1]:0;function pm(n){let e=n.defaultView&&n.defaultView.visualViewport;return e?{left:0,right:e.width,top:0,bottom:e.height}:{left:0,right:n.documentElement.clientWidth,top:0,bottom:n.documentElement.clientHeight}}function et(n,e){return typeof n=="number"?n:n[e]}function mm(n){let e=n.getBoundingClientRect(),t=e.width/n.offsetWidth||1,r=e.height/n.offsetHeight||1;return{left:e.left,right:e.left+n.clientWidth*t,top:e.top,bottom:e.top+n.clientHeight*r}}function zc(n,e,t){let r=n.someProp("scrollThreshold")||0,i=n.someProp("scrollMargin")||5,s=n.dom.ownerDocument;for(let o=t||n.dom;o;){if(o.nodeType!=1){o=un(o);continue}let l=o,a=l==s.body,c=a?pm(s):mm(l),u=0,d=0;if(e.topc.bottom-et(r,"bottom")&&(d=e.bottom-e.top>c.bottom-c.top?e.top+et(i,"top")-c.top:e.bottom-c.bottom+et(i,"bottom")),e.leftc.right-et(r,"right")&&(u=e.right-c.right+et(i,"right")),u||d)if(a)s.defaultView.scrollBy(u,d);else{let h=l.scrollLeft,p=l.scrollTop;d&&(l.scrollTop+=d),u&&(l.scrollLeft+=u);let m=l.scrollLeft-h,g=l.scrollTop-p;e={left:e.left-m,top:e.top-g,right:e.right-m,bottom:e.bottom-g}}let f=a?"fixed":getComputedStyle(o).position;if(/^(fixed|sticky)$/.test(f))break;o=f=="absolute"?o.offsetParent:un(o)}}function gm(n){let e=n.dom.getBoundingClientRect(),t=Math.max(0,e.top),r,i;for(let s=(e.left+e.right)/2,o=t+1;o=t-20){r=l,i=a.top;break}}return{refDOM:r,refTop:i,stack:pu(n.dom)}}function pu(n){let e=[],t=n.ownerDocument;for(let r=n;r&&(e.push({dom:r,top:r.scrollTop,left:r.scrollLeft}),n!=t);r=un(r));return e}function ym({refDOM:n,refTop:e,stack:t}){let r=n?n.getBoundingClientRect().top:0;mu(t,r==0?0:r-e)}function mu(n,e){for(let t=0;t=l){o=Math.max(p.bottom,o),l=Math.min(p.top,l);let m=p.left>e.left?p.left-e.left:p.right=(p.left+p.right)/2?1:0));continue}}else p.top>e.top&&!a&&p.left<=e.left&&p.right>=e.left&&(a=u,c={left:Math.max(p.left,Math.min(p.right,e.left)),top:p.top});!t&&(e.left>=p.right&&e.top>=p.top||e.left>=p.left&&e.top>=p.bottom)&&(s=d+1)}}return!t&&a&&(t=a,i=c,r=0),t&&t.nodeType==3?km(t,i):!t||r&&t.nodeType==1?{node:n,offset:s}:gu(t,i)}function km(n,e){let t=n.nodeValue.length,r=document.createRange(),i;for(let s=0;s=(o.left+o.right)/2?1:0)};break}}return r.detach(),i||{node:n,offset:0}}function Ro(n,e){return n.left>=e.left-1&&n.left<=e.right+1&&n.top>=e.top-1&&n.top<=e.bottom+1}function xm(n,e){let t=n.parentNode;return t&&/^li$/i.test(t.nodeName)&&e.left(o.left+o.right)/2?1:-1}return n.docView.posFromDOM(r,i,s)}function Sm(n,e,t,r){let i=-1;for(let s=e,o=!1;s!=n.dom;){let l=n.docView.nearestDesc(s,!0),a;if(!l)return null;if(l.dom.nodeType==1&&(l.node.isBlock&&l.parent||!l.contentDOM)&&((a=l.dom.getBoundingClientRect()).width||a.height)&&(l.node.isBlock&&l.parent&&!/^T(R|BODY|HEAD|FOOT)$/.test(l.dom.nodeName)&&(!o&&a.left>r.left||a.top>r.top?i=l.posBefore:(!o&&a.right-1?i:n.docView.posFromDOM(e,t,-1)}function yu(n,e,t){let r=n.childNodes.length;if(r&&t.tope.top&&i++}let c;Wn&&i&&r.nodeType==1&&(c=r.childNodes[i-1]).nodeType==1&&c.contentEditable=="false"&&c.getBoundingClientRect().top>=e.top&&i--,r==n.dom&&i==r.childNodes.length-1&&r.lastChild.nodeType==1&&e.top>r.lastChild.getBoundingClientRect().bottom?l=n.state.doc.content.size:(i==0||r.nodeType!=1||r.childNodes[i-1].nodeName!="BR")&&(l=Sm(n,r,i,e))}l==null&&(l=wm(n,o,e));let a=n.docView.nearestDesc(o,!0);return{pos:l,inside:a?a.posAtStart-a.border:-1}}function Bc(n){return n.top=0&&i==r.nodeValue.length?(a--,u=1):t<0?a--:c++,Pn(mt(tt(r,a,c),u),u<0)}if(!n.state.doc.resolve(e-(s||0)).parent.inlineContent){if(s==null&&i&&(t<0||i==Ie(r))){let a=r.childNodes[i-1];if(a.nodeType==1)return oo(a.getBoundingClientRect(),!1)}if(s==null&&i=0)}if(s==null&&i&&(t<0||i==Ie(r))){let a=r.childNodes[i-1],c=a.nodeType==3?tt(a,Ie(a)-(o?0:1)):a.nodeType==1&&(a.nodeName!="BR"||!a.nextSibling)?a:null;if(c)return Pn(mt(c,1),!1)}if(s==null&&i=0)}function Pn(n,e){if(n.width==0)return n;let t=e?n.left:n.right;return{top:n.top,bottom:n.bottom,left:t,right:t}}function oo(n,e){if(n.height==0)return n;let t=e?n.top:n.bottom;return{top:t,bottom:t,left:n.left,right:n.right}}function ku(n,e,t){let r=n.state,i=n.root.activeElement;r!=e&&n.updateState(e),i!=n.dom&&n.focus();try{return t()}finally{r!=e&&n.updateState(r),i!=n.dom&&i&&i.focus()}}function Mm(n,e,t){let r=e.selection,i=t=="up"?r.$from:r.$to;return ku(n,e,()=>{let{node:s}=n.docView.domFromPos(i.pos,t=="up"?-1:1);for(;;){let l=n.docView.nearestDesc(s,!0);if(!l)break;if(l.node.isBlock){s=l.contentDOM||l.dom;break}s=l.dom.parentNode}let o=bu(n,i.pos,1);for(let l=s.firstChild;l;l=l.nextSibling){let a;if(l.nodeType==1)a=l.getClientRects();else if(l.nodeType==3)a=tt(l,0,l.nodeValue.length).getClientRects();else continue;for(let c=0;cu.top+1&&(t=="up"?o.top-u.top>(u.bottom-o.top)*2:u.bottom-o.bottom>(o.bottom-u.top)*2))return!1}}return!0})}var Tm=/[\u0590-\u08ac]/;function Am(n,e,t){let{$head:r}=e.selection;if(!r.parent.isTextblock)return!1;let i=r.parentOffset,s=!i,o=i==r.parent.content.size,l=n.domSelection();return l?!Tm.test(r.parent.textContent)||!l.modify?t=="left"||t=="backward"?s:o:ku(n,e,()=>{let{focusNode:a,focusOffset:c,anchorNode:u,anchorOffset:d}=n.domSelectionRange(),f=l.caretBidiLevel;l.modify("move",t,"character");let h=r.depth?n.docView.domAfterPos(r.before()):n.dom,{focusNode:p,focusOffset:m}=n.domSelectionRange(),g=p&&!h.contains(p.nodeType==1?p:p.parentNode)||a==p&&c==m;try{l.collapse(u,d),a&&(a!=u||c!=d)&&l.extend&&l.extend(a,c)}catch(y){}return f!=null&&(l.caretBidiLevel=f),g}):r.pos==r.start()||r.pos==r.end()}var Fc=null,Hc=null,$c=!1;function Em(n,e,t){return Fc==e&&Hc==t?$c:(Fc=e,Hc=t,$c=t=="up"||t=="down"?Mm(n,e,t):Am(n,e,t))}var Le=0,_c=1,It=2,Fe=3,Ht=class{constructor(e,t,r,i){this.parent=e,this.children=t,this.dom=r,this.contentDOM=i,this.dirty=Le,r.pmViewDesc=this}matchesWidget(e){return!1}matchesMark(e){return!1}matchesNode(e,t,r){return!1}matchesHack(e){return!1}parseRule(){return null}stopEvent(e){return!1}get size(){let e=0;for(let t=0;tae(this.contentDOM);else if(this.contentDOM&&this.contentDOM!=this.dom&&this.dom.contains(this.contentDOM))i=e.compareDocumentPosition(this.contentDOM)&2;else if(this.dom.firstChild){if(t==0)for(let s=e;;s=s.parentNode){if(s==this.dom){i=!1;break}if(s.previousSibling)break}if(i==null&&t==e.childNodes.length)for(let s=e;;s=s.parentNode){if(s==this.dom){i=!0;break}if(s.nextSibling)break}}return(i==null?r>0:i)?this.posAtEnd:this.posAtStart}nearestDesc(e,t=!1){for(let r=!0,i=e;i;i=i.parentNode){let s=this.getDesc(i),o;if(s&&(!t||s.node))if(r&&(o=s.nodeDOM)&&!(o.nodeType==1?o.contains(e.nodeType==1?e:e.parentNode):o==e))r=!1;else return s}}getDesc(e){let t=e.pmViewDesc;for(let r=t;r;r=r.parent)if(r==this)return t}posFromDOM(e,t,r){for(let i=e;i;i=i.parentNode){let s=this.getDesc(i);if(s)return s.localPosFromDOM(e,t,r)}return-1}descAt(e){for(let t=0,r=0;te||o instanceof $r){i=e-s;break}s=l}if(i)return this.children[r].domFromPos(i-this.children[r].border,t);for(let s;r&&!(s=this.children[r-1]).size&&s instanceof Fr&&s.side>=0;r--);if(t<=0){let s,o=!0;for(;s=r?this.children[r-1]:null,!(!s||s.dom.parentNode==this.contentDOM);r--,o=!1);return s&&t&&o&&!s.border&&!s.domAtom?s.domFromPos(s.size,t):{node:this.contentDOM,offset:s?ae(s.dom)+1:0}}else{let s,o=!0;for(;s=r=u&&t<=c-a.border&&a.node&&a.contentDOM&&this.contentDOM.contains(a.contentDOM))return a.parseRange(e,t,u);e=o;for(let d=l;d>0;d--){let f=this.children[d-1];if(f.size&&f.dom.parentNode==this.contentDOM&&!f.emptyChildAt(1)){i=ae(f.dom)+1;break}e-=f.size}i==-1&&(i=0)}if(i>-1&&(c>t||l==this.children.length-1)){t=c;for(let u=l+1;up&&ot){let p=l;l=a,a=p}let h=document.createRange();h.setEnd(a.node,a.offset),h.setStart(l.node,l.offset),c.removeAllRanges(),c.addRange(h)}}ignoreMutation(e){return!this.contentDOM&&e.type!="selection"}get contentLost(){return this.contentDOM&&this.contentDOM!=this.dom&&!this.dom.contains(this.contentDOM)}markDirty(e,t){for(let r=0,i=0;i=r:er){let l=r+s.border,a=o-s.border;if(e>=l&&t<=a){this.dirty=e==r||t==o?It:_c,e==l&&t==a&&(s.contentLost||s.dom.parentNode!=this.contentDOM)?s.dirty=Fe:s.markDirty(e-l,t-l);return}else s.dirty=s.dom==s.contentDOM&&s.dom.parentNode==this.contentDOM&&!s.children.length?It:Fe}r=o}this.dirty=It}markParentsDirty(){let e=1;for(let t=this.parent;t;t=t.parent,e++){let r=e==1?It:_c;t.dirty{if(!s)return i;if(s.parent)return s.parent.posBeforeChild(s)})),!t.type.spec.raw){if(o.nodeType!=1){let l=document.createElement("span");l.appendChild(o),o=l}o.contentEditable="false",o.classList.add("ProseMirror-widget")}super(e,[],o,null),this.widget=t,this.widget=t,s=this}matchesWidget(e){return this.dirty==Le&&e.type.eq(this.widget.type)}parseRule(){return{ignore:!0}}stopEvent(e){let t=this.widget.spec.stopEvent;return t?t(e):!1}ignoreMutation(e){return e.type!="selection"||this.widget.spec.ignoreSelection}destroy(){this.widget.type.destroy(this.dom),super.destroy()}get domAtom(){return!0}get ignoreForSelection(){return!!this.widget.type.spec.relaxedSide}get side(){return this.widget.type.side}},go=class extends Ht{constructor(e,t,r,i){super(e,[],t,null),this.textDOM=r,this.text=i}get size(){return this.text.length}localPosFromDOM(e,t){return e!=this.textDOM?this.posAtStart+(t?this.size:0):this.posAtStart+t}domFromPos(e){return{node:this.textDOM,offset:e}}ignoreMutation(e){return e.type==="characterData"&&e.target.nodeValue==e.oldValue}},Lt=class n extends Ht{constructor(e,t,r,i,s){super(e,[],r,i),this.mark=t,this.spec=s}static create(e,t,r,i){let s=i.nodeViews[t.type.name],o=s&&s(t,i,r);return(!o||!o.dom)&&(o=Xe.renderSpec(document,t.type.spec.toDOM(t,r),null,t.attrs)),new n(e,t,o.dom,o.contentDOM||o.dom,o)}parseRule(){return this.dirty&Fe||this.mark.type.spec.reparseInView?null:{mark:this.mark.type.name,attrs:this.mark.attrs,contentElement:this.contentDOM}}matchesMark(e){return this.dirty!=Fe&&this.mark.eq(e)}markDirty(e,t){if(super.markDirty(e,t),this.dirty!=Le){let r=this.parent;for(;!r.node;)r=r.parent;r.dirty0&&(s=xo(s,0,e,r));for(let l=0;l{if(!a)return o;if(a.parent)return a.parent.posBeforeChild(a)},r,i),u=c&&c.dom,d=c&&c.contentDOM;if(t.isText){if(!u)u=document.createTextNode(t.text);else if(u.nodeType!=3)throw new RangeError("Text must be rendered as a DOM text node")}else u||({dom:u,contentDOM:d}=Xe.renderSpec(document,t.type.spec.toDOM(t),null,t.attrs));!d&&!t.isText&&u.nodeName!="BR"&&(u.hasAttribute("contenteditable")||(u.contentEditable="false"),t.type.spec.draggable&&(u.draggable=!0));let f=u;return u=Su(u,r,t),c?a=new yo(e,t,r,i,u,d||null,f,c):t.isText?new Hr(e,t,r,i,u,f):new n(e,t,r,i,u,d||null,f)}parseRule(){if(this.node.type.spec.reparseInView)return null;let e={node:this.node.type.name,attrs:this.node.attrs};if(this.node.type.whitespace=="pre"&&(e.preserveWhitespace="full"),!this.contentDOM)e.getContent=()=>this.node.content;else if(!this.contentLost)e.contentElement=this.contentDOM;else{for(let t=this.children.length-1;t>=0;t--){let r=this.children[t];if(this.dom.contains(r.dom.parentNode)){e.contentElement=r.dom.parentNode;break}}e.contentElement||(e.getContent=()=>x.empty)}return e}matchesNode(e,t,r){return this.dirty==Le&&e.eq(this.node)&&_r(t,this.outerDeco)&&r.eq(this.innerDeco)}get size(){return this.node.nodeSize}get border(){return this.node.isLeaf?0:1}updateChildren(e,t){let r=this.node.inlineContent,i=t,s=e.composing?this.localCompositionInfo(e,t):null,o=s&&s.pos>-1?s:null,l=s&&s.pos<0,a=new ko(this,o&&o.node,e);Im(this.node,this.innerDeco,(c,u,d)=>{c.spec.marks?a.syncToMarks(c.spec.marks,r,e,u):c.type.side>=0&&!d&&a.syncToMarks(u==this.node.childCount?W.none:this.node.child(u).marks,r,e,u),a.placeWidget(c,e,i)},(c,u,d,f)=>{a.syncToMarks(c.marks,r,e,f);let h;a.findNodeMatch(c,u,d,f)||l&&e.state.selection.from>i&&e.state.selection.to-1&&a.updateNodeAt(c,u,d,h,e)||a.updateNextNode(c,u,d,e,f,i)||a.addNode(c,u,d,e,i),i+=c.nodeSize}),a.syncToMarks([],r,e,0),this.node.isTextblock&&a.addTextblockHacks(),a.destroyRest(),(a.changed||this.dirty==It)&&(o&&this.protectLocalComposition(e,o),xu(this.contentDOM,this.children,e),dn&&Dm(this.dom))}localCompositionInfo(e,t){let{from:r,to:i}=e.state.selection;if(!(e.state.selection instanceof T)||rt+this.node.content.size)return null;let s=e.input.compositionNode;if(!s||!this.dom.contains(s.parentNode))return null;if(this.node.inlineContent){let o=s.nodeValue,l=Lm(this.node.content,o,r-t,i-t);return l<0?null:{node:s,pos:l,text:o}}else return{node:s,pos:-1,text:""}}protectLocalComposition(e,{node:t,pos:r,text:i}){if(this.getDesc(t))return;let s=t;for(;s.parentNode!=this.contentDOM;s=s.parentNode){for(;s.previousSibling;)s.parentNode.removeChild(s.previousSibling);for(;s.nextSibling;)s.parentNode.removeChild(s.nextSibling);s.pmViewDesc&&(s.pmViewDesc=void 0)}let o=new go(this,s,t,i);e.input.compositionNodes.push(o),this.children=xo(this.children,r,r+i.length,e,o)}update(e,t,r,i){return this.dirty==Fe||!e.sameMarkup(this.node)?!1:(this.updateInner(e,t,r,i),!0)}updateInner(e,t,r,i){this.updateOuterDeco(t),this.node=e,this.innerDeco=r,this.contentDOM&&this.updateChildren(i,this.posAtStart),this.dirty=Le}updateOuterDeco(e){if(_r(e,this.outerDeco))return;let t=this.nodeDOM.nodeType!=1,r=this.dom;this.dom=wu(this.dom,this.nodeDOM,bo(this.outerDeco,this.node,t),bo(e,this.node,t)),this.dom!=r&&(r.pmViewDesc=void 0,this.dom.pmViewDesc=this),this.outerDeco=e}selectNode(){this.nodeDOM.nodeType==1&&(this.nodeDOM.classList.add("ProseMirror-selectednode"),(this.contentDOM||!this.node.type.spec.draggable)&&(this.nodeDOM.draggable=!0))}deselectNode(){this.nodeDOM.nodeType==1&&(this.nodeDOM.classList.remove("ProseMirror-selectednode"),(this.contentDOM||!this.node.type.spec.draggable)&&this.nodeDOM.removeAttribute("draggable"))}get domAtom(){return this.node.isAtom}};function Vc(n,e,t,r,i){Su(r,e,n);let s=new bt(void 0,n,e,t,r,r,r);return s.contentDOM&&s.updateChildren(i,0),s}var Hr=class n extends bt{constructor(e,t,r,i,s,o){super(e,t,r,i,s,null,o)}parseRule(){let e=this.nodeDOM.parentNode;for(;e&&e!=this.dom&&!e.pmIsDeco;)e=e.parentNode;return{skip:e||!0}}update(e,t,r,i){return this.dirty==Fe||this.dirty!=Le&&!this.inParent()||!e.sameMarkup(this.node)?!1:(this.updateOuterDeco(t),(this.dirty!=Le||e.text!=this.node.text)&&e.text!=this.nodeDOM.nodeValue&&(this.nodeDOM.nodeValue=e.text,i.trackWrites==this.nodeDOM&&(i.trackWrites=null)),this.node=e,this.dirty=Le,!0)}inParent(){let e=this.parent.contentDOM;for(let t=this.nodeDOM;t;t=t.parentNode)if(t==e)return!0;return!1}domFromPos(e){return{node:this.nodeDOM,offset:e}}localPosFromDOM(e,t,r){return e==this.nodeDOM?this.posAtStart+Math.min(t,this.node.text.length):super.localPosFromDOM(e,t,r)}ignoreMutation(e){return e.type!="characterData"&&e.type!="selection"}slice(e,t,r){let i=this.node.cut(e,t),s=document.createTextNode(i.text);return new n(this.parent,i,this.outerDeco,this.innerDeco,s,s)}markDirty(e,t){super.markDirty(e,t),this.dom!=this.nodeDOM&&(e==0||t==this.nodeDOM.nodeValue.length)&&(this.dirty=Fe)}get domAtom(){return!1}isText(e){return this.node.text==e}},$r=class extends Ht{parseRule(){return{ignore:!0}}matchesHack(e){return this.dirty==Le&&this.dom.nodeName==e}get domAtom(){return!0}get ignoreForCoords(){return this.dom.nodeName=="IMG"}},yo=class extends bt{constructor(e,t,r,i,s,o,l,a){super(e,t,r,i,s,o,l),this.spec=a}update(e,t,r,i){if(this.dirty==Fe)return!1;if(this.spec.update&&(this.node.type==e.type||this.spec.multiType)){let s=this.spec.update(e,t,r);return s&&this.updateInner(e,t,r,i),s}else return!this.contentDOM&&!e.isLeaf?!1:super.update(e,t,r,i)}selectNode(){this.spec.selectNode?this.spec.selectNode():super.selectNode()}deselectNode(){this.spec.deselectNode?this.spec.deselectNode():super.deselectNode()}setSelection(e,t,r,i){this.spec.setSelection?this.spec.setSelection(e,t,r.root):super.setSelection(e,t,r,i)}destroy(){this.spec.destroy&&this.spec.destroy(),super.destroy()}stopEvent(e){return this.spec.stopEvent?this.spec.stopEvent(e):!1}ignoreMutation(e){return this.spec.ignoreMutation?this.spec.ignoreMutation(e):super.ignoreMutation(e)}};function xu(n,e,t){let r=n.firstChild,i=!1;for(let s=0;s>1,l=Math.min(o,e.length);for(;s-1)a>this.index&&(this.changed=!0,this.destroyBetween(this.index,a)),this.top=this.top.children[this.index];else{let u=Lt.create(this.top,e[o],t,r);this.top.children.splice(this.index,0,u),this.top=u,this.changed=!0}this.index=0,o++}}findNodeMatch(e,t,r,i){let s=-1,o;if(i>=this.preMatch.index&&(o=this.preMatch.matches[i-this.preMatch.index]).parent==this.top&&o.matchesNode(e,t,r))s=this.top.children.indexOf(o,this.index);else for(let l=this.index,a=Math.min(this.top.children.length,l+5);l0;){let l;for(;;)if(r){let c=t.children[r-1];if(c instanceof Lt)t=c,r=c.children.length;else{l=c,r--;break}}else{if(t==e)break e;r=t.parent.children.indexOf(t),t=t.parent}let a=l.node;if(a){if(a!=n.child(i-1))break;--i,s.set(l,i),o.push(l)}}return{index:i,matched:s,matches:o.reverse()}}function Rm(n,e){return n.type.side-e.type.side}function Im(n,e,t,r){let i=e.locals(n),s=0;if(i.length==0){for(let c=0;cs;)l.push(i[o++]);let p=s+f.nodeSize;if(f.isText){let g=p;o!g.inline):l.slice();r(f,m,e.forChild(s,f),h),s=p}}function Dm(n){if(n.nodeName=="UL"||n.nodeName=="OL"){let e=n.style.cssText;n.style.cssText=e+"; list-style: square !important",window.getComputedStyle(n).listStyle,n.style.cssText=e}}function Lm(n,e,t,r){for(let i=0,s=0;i=t){if(s>=r&&a.slice(r-e.length-l,r-l)==e)return r-e.length;let c=l=0&&c+e.length+l>=t)return l+c;if(t==r&&a.length>=r+e.length-l&&a.slice(r-l,r-l+e.length)==e)return r}}return-1}function xo(n,e,t,r,i){let s=[];for(let o=0,l=0;o=t||u<=e?s.push(a):(ct&&s.push(a.slice(t-c,a.size,r)))}return s}function Io(n,e=null){let t=n.domSelectionRange(),r=n.state.doc;if(!t.focusNode)return null;let i=n.docView.nearestDesc(t.focusNode),s=i&&i.size==0,o=n.docView.posFromDOM(t.focusNode,t.focusOffset,1);if(o<0)return null;let l=r.resolve(o),a,c;if(Gr(t)){for(a=o;i&&!i.node;)i=i.parent;let d=i.node;if(i&&d.isAtom&&A.isSelectable(d)&&i.parent&&!(d.isInline&&um(t.focusNode,t.focusOffset,i.dom))){let f=i.posBefore;c=new A(o==f?l:r.resolve(f))}}else{if(t instanceof n.dom.ownerDocument.defaultView.Selection&&t.rangeCount>1){let d=o,f=o;for(let h=0;h{(t.anchorNode!=r||t.anchorOffset!=i)&&(e.removeEventListener("selectionchange",n.input.hideSelectionGuard),setTimeout(()=>{(!vu(n)||n.state.selection.visible)&&n.dom.classList.remove("ProseMirror-hideselection")},20))})}function zm(n){let e=n.domSelection();if(!e)return;let t=n.cursorWrapper.dom,r=t.nodeName=="IMG";r?e.collapse(t.parentNode,ae(t)+1):e.collapse(t,0),!r&&!n.state.selection.visible&&Ce&&yt<=11&&(t.disabled=!0,t.disabled=!1)}function Cu(n,e){if(e instanceof A){let t=n.docView.descAt(e.from);t!=n.lastSelectedViewDesc&&(qc(n),t&&t.selectNode(),n.lastSelectedViewDesc=t)}else qc(n)}function qc(n){n.lastSelectedViewDesc&&(n.lastSelectedViewDesc.parent&&n.lastSelectedViewDesc.deselectNode(),n.lastSelectedViewDesc=void 0)}function Do(n,e,t,r){return n.someProp("createSelectionBetween",i=>i(n,e,t))||T.between(e,t,r)}function Uc(n){return n.editable&&!n.hasFocus()?!1:Mu(n)}function Mu(n){let e=n.domSelectionRange();if(!e.anchorNode)return!1;try{return n.dom.contains(e.anchorNode.nodeType==3?e.anchorNode.parentNode:e.anchorNode)&&(n.editable||n.dom.contains(e.focusNode.nodeType==3?e.focusNode.parentNode:e.focusNode))}catch(t){return!1}}function Bm(n){let e=n.docView.domFromPos(n.state.selection.anchor,0),t=n.domSelectionRange();return Ft(e.node,e.offset,t.anchorNode,t.anchorOffset)}function wo(n,e){let{$anchor:t,$head:r}=n.selection,i=e>0?t.max(r):t.min(r),s=i.parent.inlineContent?i.depth?n.doc.resolve(e>0?i.after():i.before()):null:i;return s&&N.findFrom(s,e)}function gt(n,e){return n.dispatch(n.state.tr.setSelection(e).scrollIntoView()),!0}function Gc(n,e,t){let r=n.state.selection;if(r instanceof T)if(t.indexOf("s")>-1){let{$head:i}=r,s=i.textOffset?null:e<0?i.nodeBefore:i.nodeAfter;if(!s||s.isText||!s.isLeaf)return!1;let o=n.state.doc.resolve(i.pos+s.nodeSize*(e<0?-1:1));return gt(n,new T(r.$anchor,o))}else if(r.empty){if(n.endOfTextblock(e>0?"forward":"backward")){let i=wo(n.state,e);return i&&i instanceof A?gt(n,i):!1}else if(!(Re&&t.indexOf("m")>-1)){let i=r.$head,s=i.textOffset?null:e<0?i.nodeBefore:i.nodeAfter,o;if(!s||s.isText)return!1;let l=e<0?i.pos-s.nodeSize:i.pos;return s.isAtom||(o=n.docView.descAt(l))&&!o.contentDOM?A.isSelectable(s)?gt(n,new A(e<0?n.state.doc.resolve(i.pos-s.nodeSize):i)):Wn?gt(n,new T(n.state.doc.resolve(e<0?l:l+s.nodeSize))):!1:!1}}else return!1;else{if(r instanceof A&&r.node.isInline)return gt(n,new T(e>0?r.$to:r.$from));{let i=wo(n.state,e);return i?gt(n,i):!1}}}function Vr(n){return n.nodeType==3?n.nodeValue.length:n.childNodes.length}function Bn(n,e){let t=n.pmViewDesc;return t&&t.size==0&&(e<0||n.nextSibling||n.nodeName!="BR")}function an(n,e){return e<0?Fm(n):Hm(n)}function Fm(n){let e=n.domSelectionRange(),t=e.focusNode,r=e.focusOffset;if(!t)return;let i,s,o=!1;for(De&&t.nodeType==1&&r0){if(t.nodeType!=1)break;{let l=t.childNodes[r-1];if(Bn(l,-1))i=t,s=--r;else if(l.nodeType==3)t=l,r=t.nodeValue.length;else break}}else{if(Tu(t))break;{let l=t.previousSibling;for(;l&&Bn(l,-1);)i=t.parentNode,s=ae(l),l=l.previousSibling;if(l)t=l,r=Vr(t);else{if(t=t.parentNode,t==n.dom)break;r=0}}}o?So(n,t,r):i&&So(n,i,s)}function Hm(n){let e=n.domSelectionRange(),t=e.focusNode,r=e.focusOffset;if(!t)return;let i=Vr(t),s,o;for(;;)if(r{n.state==i&&it(n)},50)}function Xc(n,e){let t=n.state.doc.resolve(e);if(!(ie||hu)&&t.parent.inlineContent){let i=n.coordsAtPos(e);if(e>t.start()){let s=n.coordsAtPos(e-1),o=(s.top+s.bottom)/2;if(o>i.top&&o1)return s.lefti.top&&o1)return s.left>i.left?"ltr":"rtl"}}return getComputedStyle(n.dom).direction=="rtl"?"rtl":"ltr"}function Yc(n,e,t){let r=n.state.selection;if(r instanceof T&&!r.empty||t.indexOf("s")>-1||Re&&t.indexOf("m")>-1)return!1;let{$from:i,$to:s}=r;if(!i.parent.inlineContent||n.endOfTextblock(e<0?"up":"down")){let o=wo(n.state,e);if(o&&o instanceof A)return gt(n,o)}if(!i.parent.inlineContent){let o=e<0?i:s,l=r instanceof ye?N.near(o,e):N.findFrom(o,e);return l?gt(n,l):!1}return!1}function Qc(n,e){if(!(n.state.selection instanceof T))return!0;let{$head:t,$anchor:r,empty:i}=n.state.selection;if(!t.sameParent(r))return!0;if(!i)return!1;if(n.endOfTextblock(e>0?"forward":"backward"))return!0;let s=!t.textOffset&&(e<0?t.nodeBefore:t.nodeAfter);if(s&&!s.isText){let o=n.state.tr;return e<0?o.delete(t.pos-s.nodeSize,t.pos):o.delete(t.pos,t.pos+s.nodeSize),n.dispatch(o),!0}return!1}function Zc(n,e,t){n.domObserver.stop(),e.contentEditable=t,n.domObserver.start()}function Vm(n){if(!fe||n.state.selection.$head.parentOffset>0)return!1;let{focusNode:e,focusOffset:t}=n.domSelectionRange();if(e&&e.nodeType==1&&t==0&&e.firstChild&&e.firstChild.contentEditable=="false"){let r=e.firstChild;Zc(n,r,"true"),setTimeout(()=>Zc(n,r,"false"),20)}return!1}function Wm(n){let e="";return n.ctrlKey&&(e+="c"),n.metaKey&&(e+="m"),n.altKey&&(e+="a"),n.shiftKey&&(e+="s"),e}function jm(n,e){let t=e.keyCode,r=Wm(e);if(t==8||Re&&t==72&&r=="c")return Qc(n,-1)||an(n,-1);if(t==46&&!e.shiftKey||Re&&t==68&&r=="c")return Qc(n,1)||an(n,1);if(t==13||t==27)return!0;if(t==37||Re&&t==66&&r=="c"){let i=t==37?Xc(n,n.state.selection.from)=="ltr"?-1:1:-1;return Gc(n,i,r)||an(n,i)}else if(t==39||Re&&t==70&&r=="c"){let i=t==39?Xc(n,n.state.selection.from)=="ltr"?1:-1:1;return Gc(n,i,r)||an(n,i)}else{if(t==38||Re&&t==80&&r=="c")return Yc(n,-1,r)||an(n,-1);if(t==40||Re&&t==78&&r=="c")return Vm(n)||Yc(n,1,r)||an(n,1);if(r==(Re?"m":"c")&&(t==66||t==73||t==89||t==90))return!0}return!1}function Lo(n,e){n.someProp("transformCopied",h=>{e=h(e,n)});let t=[],{content:r,openStart:i,openEnd:s}=e;for(;i>1&&s>1&&r.childCount==1&&r.firstChild.childCount==1;){i--,s--;let h=r.firstChild;t.push(h.type.name,h.attrs!=h.type.defaultAttrs?h.attrs:null),r=h.content}let o=n.someProp("clipboardSerializer")||Xe.fromSchema(n.state.schema),l=Iu(),a=l.createElement("div");a.appendChild(o.serializeFragment(r,{document:l}));let c=a.firstChild,u,d=0;for(;c&&c.nodeType==1&&(u=Ru[c.nodeName.toLowerCase()]);){for(let h=u.length-1;h>=0;h--){let p=l.createElement(u[h]);for(;a.firstChild;)p.appendChild(a.firstChild);a.appendChild(p),d++}c=a.firstChild}c&&c.nodeType==1&&c.setAttribute("data-pm-slice",`${i} ${s}${d?` -${d}`:""} ${JSON.stringify(t)}`);let f=n.someProp("clipboardTextSerializer",h=>h(e,n))||e.content.textBetween(0,e.content.size,` + +`);return{dom:a,text:f,slice:e}}function Au(n,e,t,r,i){let s=i.parent.type.spec.code,o,l;if(!t&&!e)return null;let a=!!e&&(r||s||!t);if(a){if(n.someProp("transformPastedText",f=>{e=f(e,s||r,n)}),s)return l=new v(x.from(n.state.schema.text(e.replace(/\r\n?/g,` +`))),0,0),n.someProp("transformPasted",f=>{l=f(l,n,!0)}),l;let d=n.someProp("clipboardTextParser",f=>f(e,i,r,n));if(d)l=d;else{let f=i.marks(),{schema:h}=n.state,p=Xe.fromSchema(h);o=document.createElement("div"),e.split(/(?:\r\n?|\n)+/).forEach(m=>{let g=o.appendChild(document.createElement("p"));m&&g.appendChild(p.serializeNode(h.text(m,f)))})}}else n.someProp("transformPastedHTML",d=>{t=d(t,n)}),o=Um(t),Wn&&Gm(o);let c=o&&o.querySelector("[data-pm-slice]"),u=c&&/^(\d+) (\d+)(?: -(\d+))? (.*)/.exec(c.getAttribute("data-pm-slice")||"");if(u&&u[3])for(let d=+u[3];d>0;d--){let f=o.firstChild;for(;f&&f.nodeType!=1;)f=f.nextSibling;if(!f)break;o=f}if(l||(l=(n.someProp("clipboardParser")||n.someProp("domParser")||$e.fromSchema(n.state.schema)).parseSlice(o,{preserveWhitespace:!!(a||u),context:i,ruleFromNode(f){return f.nodeName=="BR"&&!f.nextSibling&&f.parentNode&&!Km.test(f.parentNode.nodeName)?{ignore:!0}:null}})),u)l=Xm(eu(l,+u[1],+u[2]),u[4]);else if(l=v.maxOpen(Jm(l.content,i),!0),l.openStart||l.openEnd){let d=0,f=0;for(let h=l.content.firstChild;d{l=d(l,n,a)}),l}var Km=/^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/i;function Jm(n,e){if(n.childCount<2)return n;for(let t=e.depth;t>=0;t--){let i=e.node(t).contentMatchAt(e.index(t)),s,o=[];if(n.forEach(l=>{if(!o)return;let a=i.findWrapping(l.type),c;if(!a)return o=null;if(c=o.length&&s.length&&Nu(a,s,l,o[o.length-1],0))o[o.length-1]=c;else{o.length&&(o[o.length-1]=Ou(o[o.length-1],s.length));let u=Eu(l,a);o.push(u),i=i.matchType(u.type),s=a}}),o)return x.from(o)}return n}function Eu(n,e,t=0){for(let r=e.length-1;r>=t;r--)n=e[r].create(null,x.from(n));return n}function Nu(n,e,t,r,i){if(i1&&(s=0),i=t&&(l=e<0?o.contentMatchAt(0).fillBefore(l,s<=i).append(l):l.append(o.contentMatchAt(o.childCount).fillBefore(x.empty,!0))),n.replaceChild(e<0?0:n.childCount-1,o.copy(l))}function eu(n,e,t){return et})),ao.createHTML(n)):n}function Um(n){let e=/^(\s*]*>)*/.exec(n);e&&(n=n.slice(e[0].length));let t=Iu(),r=t.body,i=/<([a-z][^>\s]+)/i.exec(n),s;if((s=i&&Ru[i[1].toLowerCase()])&&(n=s.map(o=>"<"+o+">").join("")+n+s.map(o=>"").reverse().join("")),r.innerHTML=qm(n),s)for(let o=0;o=0;l-=2){let a=t.nodes[r[l]];if(!a||a.hasRequiredAttrs())break;i=x.from(a.create(r[l+1],i)),s++,o++}return new v(i,s,o)}var be={},ke={},Ym={touchstart:!0,touchmove:!0},Co=class{constructor(){this.shiftKey=!1,this.mouseDown=null,this.lastKeyCode=null,this.lastKeyCodeTime=0,this.lastClick={time:0,x:0,y:0,type:"",button:0},this.lastSelectionOrigin=null,this.lastSelectionTime=0,this.lastIOSEnter=0,this.lastIOSEnterFallbackTimeout=-1,this.lastFocus=0,this.lastTouch=0,this.lastChromeDelete=0,this.composing=!1,this.compositionNode=null,this.composingTimeout=-1,this.compositionNodes=[],this.compositionEndedAt=-2e8,this.compositionID=1,this.badSafariComposition=!1,this.compositionPendingChanges=0,this.domChangeCount=0,this.eventHandlers=Object.create(null),this.hideSelectionGuard=null}};function Qm(n){for(let e in be){let t=be[e];n.dom.addEventListener(e,n.input.eventHandlers[e]=r=>{eg(n,r)&&!Po(n,r)&&(n.editable||!(r.type in ke))&&t(n,r)},Ym[e]?{passive:!0}:void 0)}fe&&n.dom.addEventListener("input",()=>null),Mo(n)}function rt(n,e){n.input.lastSelectionOrigin=e,n.input.lastSelectionTime=Date.now()}function Zm(n){n.input.mouseDown&&n.input.mouseDown.done(),n.domObserver.stop();for(let e in n.input.eventHandlers)n.dom.removeEventListener(e,n.input.eventHandlers[e]);clearTimeout(n.input.composingTimeout),clearTimeout(n.input.lastIOSEnterFallbackTimeout)}function Mo(n){n.someProp("handleDOMEvents",e=>{for(let t in e)n.input.eventHandlers[t]||n.dom.addEventListener(t,n.input.eventHandlers[t]=r=>Po(n,r))})}function Po(n,e){return n.someProp("handleDOMEvents",t=>{let r=t[e.type];return r?r(n,e)||e.defaultPrevented:!1})}function eg(n,e){if(!e.bubbles)return!0;if(e.defaultPrevented)return!1;for(let t=e.target;t!=n.dom;t=t.parentNode)if(!t||t.nodeType==11||t.pmViewDesc&&t.pmViewDesc.stopEvent(e))return!1;return!0}function tg(n,e){!Po(n,e)&&be[e.type]&&(n.editable||!(e.type in ke))&&be[e.type](n,e)}ke.keydown=(n,e)=>{let t=e;if(n.input.shiftKey=t.keyCode==16||t.shiftKey,!Pu(n)&&(n.input.lastKeyCode=t.keyCode,n.input.lastKeyCodeTime=Date.now(),!(nt&&ie&&t.keyCode==13)))if(t.keyCode!=229&&n.domObserver.forceFlush(),dn&&t.keyCode==13&&!t.ctrlKey&&!t.altKey&&!t.metaKey){let r=Date.now();n.input.lastIOSEnter=r,n.input.lastIOSEnterFallbackTimeout=setTimeout(()=>{n.input.lastIOSEnter==r&&(n.someProp("handleKeyDown",i=>i(n,Rt(13,"Enter"))),n.input.lastIOSEnter=0)},200)}else n.someProp("handleKeyDown",r=>r(n,t))||jm(n,t)?t.preventDefault():rt(n,"key")};ke.keyup=(n,e)=>{e.keyCode==16&&(n.input.shiftKey=!1)};ke.keypress=(n,e)=>{let t=e;if(Pu(n)||!t.charCode||t.ctrlKey&&!t.altKey||Re&&t.metaKey)return;if(n.someProp("handleKeyPress",i=>i(n,t))){t.preventDefault();return}let r=n.state.selection;if(!(r instanceof T)||!r.$from.sameParent(r.$to)){let i=String.fromCharCode(t.charCode),s=()=>n.state.tr.insertText(i).scrollIntoView();!/[\r\n]/.test(i)&&!n.someProp("handleTextInput",o=>o(n,r.$from.pos,r.$to.pos,i,s))&&n.dispatch(s()),t.preventDefault()}};function jn(n){return{left:n.clientX,top:n.clientY}}function ng(n,e){let t=e.x-n.clientX,r=e.y-n.clientY;return t*t+r*r<100}function zo(n,e,t,r,i){if(r==-1)return!1;let s=n.state.doc.resolve(r);for(let o=s.depth+1;o>0;o--)if(n.someProp(e,l=>o>s.depth?l(n,t,s.nodeAfter,s.before(o),i,!0):l(n,t,s.node(o),s.before(o),i,!1)))return!0;return!1}function Kn(n,e,t){if(n.focused||n.focus(),n.state.selection.eq(e))return;let r=n.state.tr.setSelection(e);t=="pointer"&&r.setMeta("pointer",!0),n.dispatch(r)}function rg(n,e){if(e==-1)return!1;let t=n.state.doc.resolve(e),r=t.nodeAfter;return r&&r.isAtom&&A.isSelectable(r)?(Kn(n,new A(t),"pointer"),!0):!1}function ig(n,e){if(e==-1)return!1;let t=n.state.selection,r,i;t instanceof A&&(r=t.node);let s=n.state.doc.resolve(e);for(let o=s.depth+1;o>0;o--){let l=o>s.depth?s.nodeAfter:s.node(o);if(A.isSelectable(l)){r&&t.$from.depth>0&&o>=t.$from.depth&&s.before(t.$from.depth+1)==t.$from.pos?i=s.before(t.$from.depth):i=s.before(o);break}}return i!=null?(Kn(n,A.create(n.state.doc,i),"pointer"),!0):!1}function sg(n,e,t,r,i){return zo(n,"handleClickOn",e,t,r)||n.someProp("handleClick",s=>s(n,e,r))||(i?ig(n,t):rg(n,t))}function og(n,e,t,r){return zo(n,"handleDoubleClickOn",e,t,r)||n.someProp("handleDoubleClick",i=>i(n,e,r))}function lg(n,e,t,r){return zo(n,"handleTripleClickOn",e,t,r)||n.someProp("handleTripleClick",i=>i(n,e,r))||ag(n,t,r)}function ag(n,e,t){if(t.button!=0)return!1;let r=Du(n,e,!0),i=n.state.doc;return r?(Kn(n,r,"pointer"),r instanceof T&&i.eq(n.state.doc)&&(n.input.mouseDown=new Ao(n,r)),!0):!1}function Du(n,e,t){let r=n.state.doc;if(e==-1)return r.inlineContent?T.create(r,0,r.content.size):null;let i=r.resolve(e);for(let s=i.depth+1;s>0;s--){let o=s>i.depth?i.nodeAfter:i.node(s),l=i.before(s);if(o.inlineContent)return T.create(r,l+1,l+1+o.content.size);if(t&&A.isSelectable(o))return A.create(r,l)}return null}function Bo(n){return jr(n)}var Lu=Re?"metaKey":"ctrlKey";be.mousedown=(n,e)=>{let t=e;n.input.shiftKey=t.shiftKey;let r=Bo(n),i=Date.now(),s="singleClick";i-n.input.lastClick.time<500&&ng(t,n.input.lastClick)&&!t[Lu]&&n.input.lastClick.button==t.button&&(n.input.lastClick.type=="singleClick"?s="doubleClick":n.input.lastClick.type=="doubleClick"&&(s="tripleClick")),n.input.lastClick={time:i,x:t.clientX,y:t.clientY,type:s,button:t.button},n.input.mouseDown&&n.input.mouseDown.done();let o=n.posAtCoords(jn(t));o&&(s=="singleClick"?n.input.mouseDown=new To(n,o,t,!!r):(s=="doubleClick"?og:lg)(n,o.pos,o.inside,t)?t.preventDefault():rt(n,"pointer"))};var Wr=class{constructor(e){this.view=e,this.mightDrag=null,e.root.addEventListener("mouseup",this.up=this.up.bind(this)),e.root.addEventListener("mousemove",this.move=this.move.bind(this))}up(e){this.done()}move(e){e.buttons==0&&this.done()}done(){this.view.root.removeEventListener("mouseup",this.up),this.view.root.removeEventListener("mousemove",this.move),this.view.input.mouseDown==this&&(this.view.input.mouseDown=null)}delaySelUpdate(){return!1}},To=class extends Wr{constructor(e,t,r,i){super(e),this.pos=t,this.event=r,this.flushed=i,this.delayedSelectionSync=!1,this.startDoc=e.state.doc,this.selectNode=!!r[Lu],this.allowDefault=r.shiftKey;let s,o;if(t.inside>-1)s=e.state.doc.nodeAt(t.inside),o=t.inside;else{let u=e.state.doc.resolve(t.pos);s=u.parent,o=u.depth?u.before():0}let l=i?null:r.target,a=l?e.docView.nearestDesc(l,!0):null;this.target=a&&a.nodeDOM.nodeType==1?a.nodeDOM:null;let{selection:c}=e.state;r.button==0&&(s.type.spec.draggable&&s.type.spec.selectable!==!1||c instanceof A&&c.from<=o&&c.to>o)&&(this.mightDrag={node:s,pos:o,addAttr:!!(this.target&&!this.target.draggable),setUneditable:!!(this.target&&De&&!this.target.hasAttribute("contentEditable"))}),this.target&&this.mightDrag&&(this.mightDrag.addAttr||this.mightDrag.setUneditable)&&(this.view.domObserver.stop(),this.mightDrag.addAttr&&(this.target.draggable=!0),this.mightDrag.setUneditable&&setTimeout(()=>{this.view.input.mouseDown==this&&this.target.setAttribute("contentEditable","false")},20),this.view.domObserver.start()),rt(e,"pointer")}done(){super.done(),this.mightDrag&&this.target&&(this.view.domObserver.stop(),this.mightDrag.addAttr&&this.target.removeAttribute("draggable"),this.mightDrag.setUneditable&&this.target.removeAttribute("contentEditable"),this.view.domObserver.start()),this.delayedSelectionSync&&setTimeout(()=>{this.view.isDestroyed||it(this.view)})}up(e){if(this.done(),!this.view.dom.contains(e.target))return;let t=this.pos;this.view.state.doc!=this.startDoc&&(t=this.view.posAtCoords(jn(e))),this.updateAllowDefault(e),this.allowDefault||!t?rt(this.view,"pointer"):sg(this.view,t.pos,t.inside,e,this.selectNode)?e.preventDefault():e.button==0&&(this.flushed||fe&&this.mightDrag&&!this.mightDrag.node.isAtom||ie&&!this.view.state.selection.visible&&Math.min(Math.abs(t.pos-this.view.state.selection.from),Math.abs(t.pos-this.view.state.selection.to))<=2)?(Kn(this.view,N.near(this.view.state.doc.resolve(t.pos)),"pointer"),e.preventDefault()):rt(this.view,"pointer")}move(e){this.updateAllowDefault(e),rt(this.view,"pointer"),super.move(e)}updateAllowDefault(e){!this.allowDefault&&(Math.abs(this.event.x-e.clientX)>4||Math.abs(this.event.y-e.clientY)>4)&&(this.allowDefault=!0)}delaySelUpdate(){return this.allowDefault?(this.delayedSelectionSync=!0,!0):!1}},Ao=class extends Wr{constructor(e,t){super(e),this.startSelection=t,this.startDoc=e.state.doc}move(e){if(e.buttons==0||this.view.isDestroyed||!this.view.state.doc.eq(this.startDoc)){this.done();return}e.preventDefault(),rt(this.view,"pointer");let t=this.view.posAtCoords(jn(e)),r=t&&Du(this.view,t.inside,!1);if(!r)return;let{doc:i}=this.view.state,s=this.startSelection,[o,l]=r.from{n.input.lastTouch=Date.now(),Bo(n),rt(n,"pointer")};be.touchmove=n=>{n.input.lastTouch=Date.now(),rt(n,"pointer")};be.contextmenu=n=>Bo(n);function Pu(n,e){return n.composing?!0:fe&&Math.abs(Date.now()-n.input.compositionEndedAt)<500?(n.input.compositionEndedAt=-2e8,!0):!1}var cg=nt?5e3:-1;ke.compositionstart=ke.compositionupdate=n=>{if(!n.composing){n.domObserver.flush();let{state:e}=n,t=e.selection.$to;if(e.selection instanceof T&&(e.storedMarks||!t.textOffset&&t.parentOffset&&t.nodeBefore.marks.some(r=>r.type.spec.inclusive===!1)||ie&&hu&&ug(n)))n.markCursor=n.state.storedMarks||t.marks(),jr(n,!0),n.markCursor=null;else if(jr(n,!e.selection.empty),De&&e.selection.empty&&t.parentOffset&&!t.textOffset&&t.nodeBefore.marks.length){let r=n.domSelectionRange();for(let i=r.focusNode,s=r.focusOffset;i&&i.nodeType==1&&s!=0;){let o=s<0?i.lastChild:i.childNodes[s-1];if(!o)break;if(o.nodeType==3){let l=n.domSelection();l&&l.collapse(o,o.nodeValue.length);break}else i=o,s=-1}}n.input.composing=!0}zu(n,cg)};function ug(n){let{focusNode:e,focusOffset:t}=n.domSelectionRange();if(!e||e.nodeType!=1||t>=e.childNodes.length)return!1;let r=e.childNodes[t];return r.nodeType==1&&r.contentEditable=="false"}ke.compositionend=(n,e)=>{n.composing&&(n.input.composing=!1,n.input.compositionEndedAt=Date.now(),n.input.compositionPendingChanges=n.domObserver.pendingRecords().length?n.input.compositionID:0,n.input.compositionNode=null,n.input.badSafariComposition?n.domObserver.forceFlush():n.input.compositionPendingChanges&&Promise.resolve().then(()=>n.domObserver.flush()),n.input.compositionID++,zu(n,20))};function zu(n,e){clearTimeout(n.input.composingTimeout),e>-1&&(n.input.composingTimeout=setTimeout(()=>jr(n),e))}function Bu(n){for(n.composing&&(n.input.composing=!1,n.input.compositionEndedAt=Date.now());n.input.compositionNodes.length>0;)n.input.compositionNodes.pop().markParentsDirty()}function dg(n){let e=n.domSelectionRange();if(!e.focusNode)return null;let t=am(e.focusNode,e.focusOffset),r=cm(e.focusNode,e.focusOffset);if(t&&r&&t!=r){let i=r.pmViewDesc,s=n.domObserver.lastChangedTextNode;if(t==s||r==s)return s;if(!i||!i.isText(r.nodeValue))return r;if(n.input.compositionNode==r){let o=t.pmViewDesc;if(!(!o||!o.isText(t.nodeValue)))return r}}return t||r}function jr(n,e=!1){if(!(nt&&n.domObserver.flushingSoon>=0)){if(n.domObserver.forceFlush(),Bu(n),e||n.docView&&n.docView.dirty){let t=Io(n),r=n.state.selection;return t&&!t.eq(r)?n.dispatch(n.state.tr.setSelection(t)):(n.markCursor||e)&&!r.$from.node(r.$from.sharedDepth(r.to)).inlineContent?n.dispatch(n.state.tr.deleteSelection()):n.updateState(n.state),!0}return!1}}function fg(n,e){if(!n.dom.parentNode)return;let t=n.dom.parentNode.appendChild(document.createElement("div"));t.appendChild(e),t.style.cssText="position: fixed; left: -10000px; top: 10px";let r=getSelection(),i=document.createRange();i.selectNodeContents(e),n.dom.blur(),r.removeAllRanges(),r.addRange(i),setTimeout(()=>{t.parentNode&&t.parentNode.removeChild(t),n.focus()},50)}var Fn=Ce&&yt<15||dn&&hm<604;be.copy=ke.cut=(n,e)=>{let t=e,r=n.state.selection,i=t.type=="cut";if(r.empty)return;let s=Fn?null:t.clipboardData,o=r.content(),{dom:l,text:a}=Lo(n,o);s?(t.preventDefault(),s.clearData(),s.setData("text/html",l.innerHTML),s.setData("text/plain",a)):fg(n,l),i&&n.dispatch(n.state.tr.deleteSelection().scrollIntoView().setMeta("uiEvent","cut"))};function hg(n){return n.openStart==0&&n.openEnd==0&&n.content.childCount==1?n.content.firstChild:null}function pg(n,e){if(!n.dom.parentNode)return;let t=n.input.shiftKey||n.state.selection.$from.parent.type.spec.code,r=n.dom.parentNode.appendChild(document.createElement(t?"textarea":"div"));t||(r.contentEditable="true"),r.style.cssText="position: fixed; left: -10000px; top: 10px",r.focus();let i=n.input.shiftKey&&n.input.lastKeyCode!=45;setTimeout(()=>{n.focus(),r.parentNode&&r.parentNode.removeChild(r),t?Hn(n,r.value,null,i,e):Hn(n,r.textContent,r.innerHTML,i,e)},50)}function Hn(n,e,t,r,i){let s=Au(n,e,t,r,n.state.selection.$from);if(n.someProp("handlePaste",a=>a(n,i,s||v.empty)))return!0;if(!s)return!1;let o=hg(s),l=o?n.state.tr.replaceSelectionWith(o,r):n.state.tr.replaceSelection(s);return n.dispatch(l.scrollIntoView().setMeta("paste",!0).setMeta("uiEvent","paste")),!0}function Fu(n){let e=n.getData("text/plain")||n.getData("Text");if(e)return e;let t=n.getData("text/uri-list");return t?t.replace(/\r?\n/g," "):""}ke.paste=(n,e)=>{let t=e;if(n.composing&&!nt)return;let r=Fn?null:t.clipboardData,i=n.input.shiftKey&&n.input.lastKeyCode!=45;r&&Hn(n,Fu(r),r.getData("text/html"),i,t)?t.preventDefault():pg(n,t)};var Kr=class{constructor(e,t,r){this.slice=e,this.move=t,this.node=r}},mg=Re?"altKey":"ctrlKey";function Hu(n,e){let t;return n.someProp("dragCopies",r=>{t=t||r(e)}),t!=null?!t:!e[mg]}be.dragstart=(n,e)=>{let t=e,r=n.input.mouseDown;if(r&&r.done(),!t.dataTransfer)return;let i=n.state.selection,s=i.empty?null:n.posAtCoords(jn(t)),o;if(!(s&&s.pos>=i.from&&s.pos<=(i instanceof A?i.to-1:i.to))){if(r&&r.mightDrag)o=A.create(n.state.doc,r.mightDrag.pos);else if(t.target&&t.target.nodeType==1){let d=n.docView.nearestDesc(t.target,!0);d&&d.node.type.spec.draggable&&d!=n.docView&&(o=A.create(n.state.doc,d.posBefore))}}let l=(o||n.state.selection).content(),{dom:a,text:c,slice:u}=Lo(n,l);(!t.dataTransfer.files.length||!ie||fu>120)&&t.dataTransfer.clearData(),t.dataTransfer.setData(Fn?"Text":"text/html",a.innerHTML),t.dataTransfer.effectAllowed="copyMove",Fn||t.dataTransfer.setData("text/plain",c),n.dragging=new Kr(u,Hu(n,t),o)};be.dragend=n=>{let e=n.dragging;window.setTimeout(()=>{n.dragging==e&&(n.dragging=null)},50)};ke.dragover=ke.dragenter=(n,e)=>e.preventDefault();ke.drop=(n,e)=>{try{gg(n,e,n.dragging)}finally{n.dragging=null}};function gg(n,e,t){if(!e.dataTransfer)return;let r=n.posAtCoords(jn(e));if(!r)return;let i=n.state.doc.resolve(r.pos),s=t&&t.slice;s?n.someProp("transformPasted",h=>{s=h(s,n,!1)}):s=Au(n,Fu(e.dataTransfer),Fn?null:e.dataTransfer.getData("text/html"),!1,i);let o=!!(t&&Hu(n,e));if(n.someProp("handleDrop",h=>h(n,e,s||v.empty,o))){e.preventDefault();return}if(!s)return;e.preventDefault();let l=s?Lr(n.state.doc,i.pos,s):i.pos;l==null&&(l=i.pos);let a=n.state.tr;if(o){let{node:h}=t;h?h.replace(a):a.deleteSelection()}let c=a.mapping.map(l),u=s.openStart==0&&s.openEnd==0&&s.content.childCount==1,d=a.doc;if(u?a.replaceRangeWith(c,c,s.content.firstChild):a.replaceRange(c,c,s),a.doc.eq(d))return;let f=a.doc.resolve(c);if(u&&A.isSelectable(s.content.firstChild)&&f.nodeAfter&&f.nodeAfter.sameMarkup(s.content.firstChild))a.setSelection(new A(f));else{let h=a.mapping.map(l);a.mapping.maps[a.mapping.maps.length-1].forEach((p,m,g,y)=>h=y),a.setSelection(Do(n,f,a.doc.resolve(h)))}n.focus(),n.dispatch(a.setMeta("uiEvent","drop"))}be.focus=n=>{n.input.lastFocus=Date.now(),n.focused||(n.domObserver.stop(),n.dom.classList.add("ProseMirror-focused"),n.domObserver.start(),n.focused=!0,setTimeout(()=>{n.docView&&n.hasFocus()&&!n.domObserver.currentSelection.eq(n.domSelectionRange())&&it(n)},20))};be.blur=(n,e)=>{let t=e;n.focused&&(n.domObserver.stop(),n.dom.classList.remove("ProseMirror-focused"),n.domObserver.start(),t.relatedTarget&&n.dom.contains(t.relatedTarget)&&n.domObserver.currentSelection.clear(),n.focused=!1)};be.beforeinput=(n,e)=>{if(ie&&nt&&e.inputType=="deleteContentBackward"){n.domObserver.flushSoon();let{domChangeCount:r}=n.input;setTimeout(()=>{if(n.input.domChangeCount!=r||(n.dom.blur(),n.focus(),n.someProp("handleKeyDown",s=>s(n,Rt(8,"Backspace")))))return;let{$cursor:i}=n.state.selection;i&&i.pos>0&&n.dispatch(n.state.tr.delete(i.pos-1,i.pos).scrollIntoView())},50)}};for(let n in ke)be[n]=ke[n];function $n(n,e){if(n==e)return!0;for(let t in n)if(n[t]!==e[t])return!1;for(let t in e)if(!(t in n))return!1;return!0}var Jr=class n{constructor(e,t){this.toDOM=e,this.spec=t||zt,this.side=this.spec.side||0}map(e,t,r,i){let{pos:s,deleted:o}=e.mapResult(t.from+i,this.side<0?-1:1);return o?null:new te(s-r,s-r,this)}valid(){return!0}eq(e){return this==e||e instanceof n&&(this.spec.key&&this.spec.key==e.spec.key||this.toDOM==e.toDOM&&$n(this.spec,e.spec))}destroy(e){this.spec.destroy&&this.spec.destroy(e)}},Pt=class n{constructor(e,t){this.attrs=e,this.spec=t||zt}map(e,t,r,i){let s=e.map(t.from+i,this.spec.inclusiveStart?-1:1)-r,o=e.map(t.to+i,this.spec.inclusiveEnd?1:-1)-r;return s>=o?null:new te(s,o,this)}valid(e,t){return t.from=e&&(!s||s(l.spec))&&r.push(l.copy(l.from+i,l.to+i))}for(let o=0;oe){let l=this.children[o]+1;this.children[o+2].findInner(e-l,t-l,r,i+l,s)}}map(e,t,r){return this==de||e.maps.length==0?this:this.mapInner(e,t,0,0,r||zt)}mapInner(e,t,r,i,s){let o;for(let l=0;l{let c=a+r,u;if(u=_u(t,l,c)){for(i||(i=this.children.slice());sl&&d.to=e){this.children[l]==e&&(r=this.children[l+2]);break}let s=e+1,o=s+t.content.size;for(let l=0;ls&&a.type instanceof Pt){let c=Math.max(s,a.from)-s,u=Math.min(o,a.to)-s;ci.map(e,t,zt));return n.from(r)}forChild(e,t){if(t.isLeaf)return j.empty;let r=[];for(let i=0;it instanceof j)?e:e.reduce((t,r)=>t.concat(r instanceof j?r:r.members),[]))}}forEachSet(e){for(let t=0;t{let g=m-p-(h-f);for(let y=0;yb+u-d)continue;let k=l[y]+u-d;h>=k?l[y+1]=f<=k?-2:-1:f>=u&&g&&(l[y]+=g,l[y+1]+=g)}d+=g}),u=t.maps[c].map(u,-1)}let a=!1;for(let c=0;c=r.content.size){a=!0;continue}let f=t.map(n[c+1]+s,-1),h=f-i,{index:p,offset:m}=r.content.findIndex(d),g=r.maybeChild(p);if(g&&m==d&&m+g.nodeSize==h){let y=l[c+2].mapInner(t,g,u+1,n[c]+s+1,o);y!=de?(l[c]=d,l[c+1]=h,l[c+2]=y):(l[c+1]=-2,a=!0)}else a=!0}if(a){let c=bg(l,n,e,t,i,s,o),u=Ur(c,r,0,o);e=u.local;for(let d=0;dt&&o.to{let c=_u(n,l,a+t);if(c){s=!0;let u=Ur(c,l,t+a+1,r);u!=de&&i.push(a,a+l.nodeSize,u)}});let o=$u(s?Vu(n):n,-t).sort(Bt);for(let l=0;l0;)e++;n.splice(e,0,t)}function co(n){let e=[];return n.someProp("decorations",t=>{let r=t(n.state);r&&r!=de&&e.push(r)}),n.cursorWrapper&&e.push(j.create(n.state.doc,[n.cursorWrapper.deco])),qr.from(e)}var kg={childList:!0,characterData:!0,characterDataOldValue:!0,attributes:!0,attributeOldValue:!0,subtree:!0},xg=Ce&&yt<=11,No=class{constructor(){this.anchorNode=null,this.anchorOffset=0,this.focusNode=null,this.focusOffset=0}set(e){this.anchorNode=e.anchorNode,this.anchorOffset=e.anchorOffset,this.focusNode=e.focusNode,this.focusOffset=e.focusOffset}clear(){this.anchorNode=this.focusNode=null}eq(e){return e.anchorNode==this.anchorNode&&e.anchorOffset==this.anchorOffset&&e.focusNode==this.focusNode&&e.focusOffset==this.focusOffset}},Oo=class{constructor(e,t){this.view=e,this.handleDOMChange=t,this.queue=[],this.flushingSoon=-1,this.observer=null,this.currentSelection=new No,this.onCharData=null,this.suppressingSelectionUpdates=!1,this.lastChangedTextNode=null,this.observer=window.MutationObserver&&new window.MutationObserver(r=>{for(let i=0;ii.type=="childList"&&i.removedNodes.length||i.type=="characterData"&&i.oldValue.length>i.target.nodeValue.length)?this.flushSoon():fe&&e.composing&&r.some(i=>i.type=="childList"&&i.target.nodeName=="TR")?(e.input.badSafariComposition=!0,this.flushSoon()):this.flush()}),xg&&(this.onCharData=r=>{this.queue.push({target:r.target,type:"characterData",oldValue:r.prevValue}),this.flushSoon()}),this.onSelectionChange=this.onSelectionChange.bind(this)}flushSoon(){this.flushingSoon<0&&(this.flushingSoon=window.setTimeout(()=>{this.flushingSoon=-1,this.flush()},20))}forceFlush(){this.flushingSoon>-1&&(window.clearTimeout(this.flushingSoon),this.flushingSoon=-1,this.flush())}start(){this.observer&&(this.observer.takeRecords(),this.observer.observe(this.view.dom,kg)),this.onCharData&&this.view.dom.addEventListener("DOMCharacterDataModified",this.onCharData),this.connectSelection()}stop(){if(this.observer){let e=this.observer.takeRecords();if(e.length){for(let t=0;tthis.flush(),20)}this.observer.disconnect()}this.onCharData&&this.view.dom.removeEventListener("DOMCharacterDataModified",this.onCharData),this.disconnectSelection()}connectSelection(){this.view.dom.ownerDocument.addEventListener("selectionchange",this.onSelectionChange)}disconnectSelection(){this.view.dom.ownerDocument.removeEventListener("selectionchange",this.onSelectionChange)}suppressSelectionUpdates(){this.suppressingSelectionUpdates=!0,setTimeout(()=>this.suppressingSelectionUpdates=!1,50)}onSelectionChange(){if(Uc(this.view)){if(this.suppressingSelectionUpdates)return it(this.view);if(Ce&&yt<=11&&!this.view.state.selection.empty){let e=this.view.domSelectionRange();if(e.focusNode&&Ft(e.focusNode,e.focusOffset,e.anchorNode,e.anchorOffset))return this.flushSoon()}this.flush()}}setCurSelection(){this.currentSelection.set(this.view.domSelectionRange())}ignoreSelectionChange(e){if(!e.focusNode)return!0;let t=new Set,r;for(let s=e.focusNode;s;s=un(s))t.add(s);for(let s=e.anchorNode;s;s=un(s))if(t.has(s)){r=s;break}let i=r&&this.view.docView.nearestDesc(r);if(i&&i.ignoreMutation({type:"selection",target:r.nodeType==3?r.parentNode:r}))return this.setCurSelection(),!0}pendingRecords(){if(this.observer)for(let e of this.observer.takeRecords())this.queue.push(e);return this.queue}flush(){let{view:e}=this;if(!e.docView||this.flushingSoon>-1)return;let t=this.pendingRecords();t.length&&(this.queue=[]);let r=e.domSelectionRange(),i=!this.suppressingSelectionUpdates&&!this.currentSelection.eq(r)&&Uc(e)&&!this.ignoreSelectionChange(r),s=-1,o=-1,l=!1,a=[];if(e.editable)for(let u=0;uu.nodeName=="BR")&&(e.input.lastKeyCode==8||e.input.lastKeyCode==46||ie&&(e.composing||e.input.compositionEndedAt>Date.now()-50)&&t.some(u=>u.type=="childList"&&u.removedNodes.length))){for(let u of a)if(u.nodeName=="BR"&&u.parentNode){let d=u.nextSibling;for(;d&&d.nodeType==1;){if(d.contentEditable=="false"){u.parentNode.removeChild(u);break}d=d.firstChild}}}else if(De&&a.length){let u=a.filter(d=>d.nodeName=="BR");if(u.length==2){let[d,f]=u;d.parentNode&&d.parentNode.parentNode==f.parentNode?f.remove():d.remove()}else{let{focusNode:d}=this.currentSelection;for(let f of u){let h=f.parentNode;h&&h.nodeName=="LI"&&(!d||vg(e,d)!=h)&&f.remove()}}}let c=null;s<0&&i&&e.input.lastFocus>Date.now()-200&&Math.max(e.input.lastTouch,e.input.lastClick.time)-1||i)&&(s>-1&&(e.docView.markDirty(s,o),wg(e)),e.input.badSafariComposition&&(e.input.badSafariComposition=!1,Cg(e,a)),this.handleDOMChange(s,o,l,a),e.docView&&e.docView.dirty?e.updateState(e.state):this.currentSelection.eq(r)||it(e),this.currentSelection.set(r))}registerMutation(e,t){if(t.indexOf(e.target)>-1)return null;let r=this.view.docView.nearestDesc(e.target);if(e.type=="attributes"&&(r==this.view.docView||e.attributeName=="contenteditable"||e.attributeName=="style"&&!e.oldValue&&!e.target.getAttribute("style"))||!r||r.ignoreMutation(e))return null;if(e.type=="childList"){for(let u=0;ui;g--){let y=r.childNodes[g-1],b=y.pmViewDesc;if(y.nodeName=="BR"&&!b){s=g;break}if(!b||b.size)break}let d=n.state.doc,f=n.someProp("domParser")||$e.fromSchema(n.state.schema),h=d.resolve(o),p=null,m=f.parse(r,{topNode:h.parent,topMatch:h.parent.contentMatchAt(h.index()),topOpen:!0,from:i,to:s,preserveWhitespace:h.parent.type.whitespace=="pre"?"full":!0,findPositions:c,ruleFromNode:Tg,context:h});if(c&&c[0].pos!=null){let g=c[0].pos,y=c[1]&&c[1].pos;y==null&&(y=g),p={anchor:g+o,head:y+o}}return{doc:m,sel:p,from:o,to:l}}function Tg(n){let e=n.pmViewDesc;if(e)return e.parseRule();if(n.nodeName=="BR"&&n.parentNode){if(fe&&/^(ul|ol)$/i.test(n.parentNode.nodeName)){let t=document.createElement("div");return t.appendChild(document.createElement("li")),{skip:t}}else if(n.parentNode.lastChild==n||fe&&/^(tr|table)$/i.test(n.parentNode.nodeName))return{ignore:!0}}else if(n.nodeName=="IMG"&&n.getAttribute("mark-placeholder"))return{ignore:!0};return null}var Ag=/^(a|abbr|acronym|b|bd[io]|big|br|button|cite|code|data(list)?|del|dfn|em|i|img|ins|kbd|label|map|mark|meter|output|q|ruby|s|samp|small|span|strong|su[bp]|time|u|tt|var)$/i;function Eg(n,e,t,r,i){let s=n.input.compositionPendingChanges||(n.composing?n.input.compositionID:0);if(n.input.compositionPendingChanges=0,e<0){let M=n.input.lastSelectionTime>Date.now()-50?n.input.lastSelectionOrigin:null,D=Io(n,M);if(D&&!n.state.selection.eq(D)){if(ie&&nt&&n.input.lastKeyCode===13&&Date.now()-100P(n,Rt(13,"Enter"))))return;let B=n.state.tr.setSelection(D);M=="pointer"?B.setMeta("pointer",!0):M=="key"&&B.scrollIntoView(),s&&B.setMeta("composition",s),n.dispatch(B)}return}let o=n.state.doc.resolve(e),l=o.sharedDepth(t);e=o.before(l+1),t=n.state.doc.resolve(t).after(l+1);let a=n.state.selection,c=Mg(n,e,t),u=n.state.doc,d=u.slice(c.from,c.to),f,h;n.input.lastKeyCode===8&&Date.now()-100Date.now()-225||nt)&&i.some(M=>M.nodeType==1&&!Ag.test(M.nodeName))&&(!p||p.endA>=p.endB)&&n.someProp("handleKeyDown",M=>M(n,Rt(13,"Enter")))){n.input.lastIOSEnter=0;return}if(!p)if(r&&a instanceof T&&!a.empty&&a.$head.sameParent(a.$anchor)&&!n.composing&&!(c.sel&&c.sel.anchor!=c.sel.head))p={start:a.from,endA:a.to,endB:a.to};else{if(c.sel){let M=su(n,n.state.doc,c.sel);if(M&&!M.eq(n.state.selection)){let D=n.state.tr.setSelection(M);s&&D.setMeta("composition",s),n.dispatch(D)}}return}n.state.selection.fromn.state.selection.from&&p.start<=n.state.selection.from+2&&n.state.selection.from>=c.from?p.start=n.state.selection.from:p.endA=n.state.selection.to-2&&n.state.selection.to<=c.to&&(p.endB+=n.state.selection.to-p.endA,p.endA=n.state.selection.to)),Ce&&yt<=11&&p.endB==p.start+1&&p.endA==p.start&&p.start>c.from&&c.doc.textBetween(p.start-c.from-1,p.start-c.from+1)==" \xA0"&&(p.start--,p.endA--,p.endB--);let m=c.doc.resolveNoCache(p.start-c.from),g=c.doc.resolveNoCache(p.endB-c.from),y=u.resolve(p.start),b=m.sameParent(g)&&m.parent.inlineContent&&y.end()>=p.endA;if((dn&&n.input.lastIOSEnter>Date.now()-225&&(!b||i.some(M=>M.nodeName=="DIV"||M.nodeName=="P"))||!b&&m.posM(n,Rt(13,"Enter")))){n.input.lastIOSEnter=0;return}if(n.state.selection.anchor>p.start&&Og(u,p.start,p.endA,m,g)&&n.someProp("handleKeyDown",M=>M(n,Rt(8,"Backspace")))){nt&&ie&&n.domObserver.suppressSelectionUpdates();return}ie&&p.endB==p.start&&(n.input.lastChromeDelete=Date.now()),nt&&!b&&m.start()!=g.start()&&g.parentOffset==0&&m.depth==g.depth&&c.sel&&c.sel.anchor==c.sel.head&&c.sel.head==p.endA&&(p.endB-=2,g=c.doc.resolveNoCache(p.endB-c.from),setTimeout(()=>{n.someProp("handleKeyDown",function(M){return M(n,Rt(13,"Enter"))})},20));let k=p.start,w=p.endA,C=M=>{let D=M||n.state.tr.replace(k,w,c.doc.slice(p.start-c.from,p.endB-c.from));if(c.sel){let B=su(n,D.doc,c.sel);B&&!(ie&&n.composing&&B.empty&&(p.start!=p.endB||n.input.lastChromeDeleteit(n),20));let M=C(n.state.tr.delete(k,w)),D=u.resolve(p.start).marksAcross(u.resolve(p.endA));D&&M.ensureMarks(D),n.dispatch(M)}else if(p.endA==p.endB&&(O=Ng(m.parent.content.cut(m.parentOffset,g.parentOffset),y.parent.content.cut(y.parentOffset,p.endA-y.start())))){let M=C(n.state.tr);O.type=="add"?M.addMark(k,w,O.mark):M.removeMark(k,w,O.mark),n.dispatch(M)}else if(m.parent.child(m.index()).isText&&m.index()==g.index()-(g.textOffset?0:1)){let M=m.parent.textBetween(m.parentOffset,g.parentOffset),D=()=>C(n.state.tr.insertText(M,k,w));n.someProp("handleTextInput",B=>B(n,k,w,M,D))||n.dispatch(D())}else n.dispatch(C());else n.dispatch(C())}function su(n,e,t){return Math.max(t.anchor,t.head)>e.content.size?null:Do(n,e.resolve(t.anchor),e.resolve(t.head))}function Ng(n,e){let t=n.firstChild.marks,r=e.firstChild.marks,i=t,s=r,o,l,a;for(let u=0;uu.mark(l.addToSet(u.marks));else if(i.length==0&&s.length==1)l=s[0],o="remove",a=u=>u.mark(l.removeFromSet(u.marks));else return null;let c=[];for(let u=0;ut||uo(o,!0,!1)0&&(e||n.indexAfter(r)==n.node(r).childCount);)r--,i++,e=!1;if(t){let s=n.node(r).maybeChild(n.indexAfter(r));for(;s&&!s.isLeaf;)s=s.firstChild,i++}return i}function Rg(n,e,t,r,i){let s=n.findDiffStart(e,t),o=t+n.size,l=t+e.size;if(s==null)return null;let{a,b:c}=n.findDiffEnd(e,o,l);if(i=="end"){let u=Math.max(0,s-Math.min(a,c));r-=a+u-s}if(a=a?s-r:0;s-=u,c=s+(c-a),a=s}else if(c=c?s-r:0;s-=u,a=s+(a-c),c=s}return{start:s,endA:a,endB:c}}var _n=class{constructor(e,t){this._root=null,this.focused=!1,this.trackWrites=null,this.mounted=!1,this.markCursor=null,this.cursorWrapper=null,this.lastSelectedViewDesc=void 0,this.input=new Co,this.prevDirectPlugins=[],this.pluginViews=[],this.requiresGeckoHackNode=!1,this.dragging=null,this._props=t,this.state=t.state,this.directPlugins=t.plugins||[],this.directPlugins.forEach(uu),this.dispatch=this.dispatch.bind(this),this.dom=e&&e.mount||document.createElement("div"),e&&(e.appendChild?e.appendChild(this.dom):typeof e=="function"?e(this.dom):e.mount&&(this.mounted=!0)),this.editable=au(this),lu(this),this.nodeViews=cu(this),this.docView=Vc(this.state.doc,ou(this),co(this),this.dom,this),this.domObserver=new Oo(this,(r,i,s,o)=>Eg(this,r,i,s,o)),this.domObserver.start(),Qm(this),this.updatePluginViews()}get composing(){return this.input.composing}get props(){if(this._props.state!=this.state){let e=this._props;this._props={};for(let t in e)this._props[t]=e[t];this._props.state=this.state}return this._props}update(e){e.handleDOMEvents!=this._props.handleDOMEvents&&Mo(this);let t=this._props;this._props=e,e.plugins&&(e.plugins.forEach(uu),this.directPlugins=e.plugins),this.updateStateInner(e.state,t)}setProps(e){let t={};for(let r in this._props)t[r]=this._props[r];t.state=this.state;for(let r in e)t[r]=e[r];this.update(t)}updateState(e){this.updateStateInner(e,this._props)}updateStateInner(e,t){var r;let i=this.state,s=!1,o=!1;e.storedMarks&&this.composing&&(Bu(this),o=!0),this.state=e;let l=i.plugins!=e.plugins||this._props.plugins!=t.plugins;if(l||this._props.plugins!=t.plugins||this._props.nodeViews!=t.nodeViews){let h=cu(this);Dg(h,this.nodeViews)&&(this.nodeViews=h,s=!0)}(l||t.handleDOMEvents!=this._props.handleDOMEvents)&&Mo(this),this.editable=au(this),lu(this);let a=co(this),c=ou(this),u=i.plugins!=e.plugins&&!i.doc.eq(e.doc)?"reset":e.scrollToSelection>i.scrollToSelection?"to selection":"preserve",d=s||!this.docView.matchesNode(e.doc,c,a);(d||!e.selection.eq(i.selection))&&(o=!0);let f=u=="preserve"&&o&&this.dom.style.overflowAnchor==null&&gm(this);if(o){this.domObserver.stop();let h=d&&(Ce||ie)&&!this.composing&&!i.selection.empty&&!e.selection.empty&&Ig(i.selection,e.selection);if(d){let m=ie?this.trackWrites=this.domSelectionRange().focusNode:null;this.composing&&(this.input.compositionNode=dg(this)),(s||!this.docView.update(e.doc,c,a,this))&&(this.docView.updateOuterDeco(c),this.docView.destroy(),this.docView=Vc(e.doc,c,a,this.dom,this)),m&&(!this.trackWrites||!this.dom.contains(this.trackWrites))&&(h=!0)}let p=this.input.mouseDown;h||!(p&&this.domObserver.currentSelection.eq(this.domSelectionRange())&&Bm(this)&&p.delaySelUpdate())?it(this,h):(Cu(this,e.selection),this.domObserver.setCurSelection()),this.domObserver.start()}this.updatePluginViews(i),!((r=this.dragging)===null||r===void 0)&&r.node&&!i.doc.eq(e.doc)&&this.updateDraggedNode(this.dragging,i),u=="reset"?this.dom.scrollTop=0:u=="to selection"?this.scrollToSelection():f&&ym(f)}scrollToSelection(){let e=this.domSelectionRange().focusNode;if(!(!e||!this.dom.contains(e.nodeType==1?e:e.parentNode))){if(!this.someProp("handleScrollToSelection",t=>t(this)))if(this.state.selection instanceof A){let t=this.docView.domAfterPos(this.state.selection.from);t.nodeType==1&&zc(this,t.getBoundingClientRect(),e)}else zc(this,this.coordsAtPos(this.state.selection.head,1),e)}}destroyPluginViews(){let e;for(;e=this.pluginViews.pop();)e.destroy&&e.destroy()}updatePluginViews(e){if(!e||e.plugins!=this.state.plugins||this.directPlugins!=this.prevDirectPlugins){this.prevDirectPlugins=this.directPlugins,this.destroyPluginViews();for(let t=0;t0&&st.ownerDocument.getSelection()),this._root=t}return e||document}updateRoot(){this._root=null}posAtCoords(e){return vm(this,e)}coordsAtPos(e,t=1){return bu(this,e,t)}domAtPos(e,t=0){return this.docView.domFromPos(e,t)}nodeDOM(e){let t=this.docView.descAt(e);return t?t.nodeDOM:null}posAtDOM(e,t,r=-1){let i=this.docView.posFromDOM(e,t,r);if(i==null)throw new RangeError("DOM position not inside the editor");return i}endOfTextblock(e,t){return Em(this,t||this.state,e)}pasteHTML(e,t){return Hn(this,"",e,!1,t||new ClipboardEvent("paste"))}pasteText(e,t){return Hn(this,e,null,!0,t||new ClipboardEvent("paste"))}serializeForClipboard(e){return Lo(this,e)}destroy(){this.docView&&(Zm(this),this.destroyPluginViews(),this.mounted?(this.docView.update(this.state.doc,[],co(this),this),this.dom.textContent=""):this.dom.parentNode&&this.dom.parentNode.removeChild(this.dom),this.docView.destroy(),this.docView=null,om())}get isDestroyed(){return this.docView==null}dispatchEvent(e){return tg(this,e)}domSelectionRange(){let e=this.domSelection();return e?fe&&this.root.nodeType===11&&dm(this.dom.ownerDocument)==this.dom&&Sg(this,e)||e:{focusNode:null,focusOffset:0,anchorNode:null,anchorOffset:0}}domSelection(){return this.root.getSelection()}};_n.prototype.dispatch=function(n){let e=this._props.dispatchTransaction;e?e.call(this,n):this.updateState(this.state.apply(n))};function ou(n){let e=Object.create(null);return e.class="ProseMirror",e.contenteditable=String(n.editable),n.someProp("attributes",t=>{if(typeof t=="function"&&(t=t(n.state)),t)for(let r in t)r=="class"?e.class+=" "+t[r]:r=="style"?e.style=(e.style?e.style+";":"")+t[r]:!e[r]&&r!="contenteditable"&&r!="nodeName"&&(e[r]=String(t[r]))}),e.translate||(e.translate="no"),[te.node(0,n.state.doc.content.size,e)]}function lu(n){if(n.markCursor){let e=document.createElement("img");e.className="ProseMirror-separator",e.setAttribute("mark-placeholder","true"),e.setAttribute("alt",""),n.cursorWrapper={dom:e,deco:te.widget(n.state.selection.from,e,{raw:!0,marks:n.markCursor})}}else n.cursorWrapper=null}function au(n){return!n.someProp("editable",e=>e(n.state)===!1)}function Ig(n,e){let t=Math.min(n.$anchor.sharedDepth(n.head),e.$anchor.sharedDepth(e.head));return n.$anchor.start(t)!=e.$anchor.start(t)}function cu(n){let e=Object.create(null);function t(r){for(let i in r)Object.prototype.hasOwnProperty.call(e,i)||(e[i]=r[i])}return n.someProp("nodeViews",t),n.someProp("markViews",t),e}function Dg(n,e){let t=0,r=0;for(let i in n){if(n[i]!=e[i])return!0;t++}for(let i in e)r++;return t!=r}function uu(n){if(n.spec.state||n.spec.filterTransaction||n.spec.appendTransaction)throw new RangeError("Plugins passed directly to the view must not have a state component")}var st={8:"Backspace",9:"Tab",10:"Enter",12:"NumLock",13:"Enter",16:"Shift",17:"Control",18:"Alt",20:"CapsLock",27:"Escape",32:" ",33:"PageUp",34:"PageDown",35:"End",36:"Home",37:"ArrowLeft",38:"ArrowUp",39:"ArrowRight",40:"ArrowDown",44:"PrintScreen",45:"Insert",46:"Delete",59:";",61:"=",91:"Meta",92:"Meta",106:"*",107:"+",108:",",109:"-",110:".",111:"/",144:"NumLock",145:"ScrollLock",160:"Shift",161:"Shift",162:"Control",163:"Control",164:"Alt",165:"Alt",173:"-",186:";",187:"=",188:",",189:"-",190:".",191:"/",192:"`",219:"[",220:"\\",221:"]",222:"'"},Yr={48:")",49:"!",50:"@",51:"#",52:"$",53:"%",54:"^",55:"&",56:"*",57:"(",59:":",61:"+",173:"_",186:":",187:"+",188:"<",189:"_",190:">",191:"?",192:"~",219:"{",220:"|",221:"}",222:'"'},Lg=typeof navigator!="undefined"&&/Mac/.test(navigator.platform),Pg=typeof navigator!="undefined"&&/MSIE \d|Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(navigator.userAgent);for(ne=0;ne<10;ne++)st[48+ne]=st[96+ne]=String(ne);var ne;for(ne=1;ne<=24;ne++)st[ne+111]="F"+ne;var ne;for(ne=65;ne<=90;ne++)st[ne]=String.fromCharCode(ne+32),Yr[ne]=String.fromCharCode(ne);var ne;for(Xr in st)Yr.hasOwnProperty(Xr)||(Yr[Xr]=st[Xr]);var Xr;function Wu(n){var e=Lg&&n.metaKey&&n.shiftKey&&!n.ctrlKey&&!n.altKey||Pg&&n.shiftKey&&n.key&&n.key.length==1||n.key=="Unidentified",t=!e&&n.key||(n.shiftKey?Yr:st)[n.keyCode]||n.key||"Unidentified";return t=="Esc"&&(t="Escape"),t=="Del"&&(t="Delete"),t=="Left"&&(t="ArrowLeft"),t=="Up"&&(t="ArrowUp"),t=="Right"&&(t="ArrowRight"),t=="Down"&&(t="ArrowDown"),t}var zg=typeof navigator!="undefined"&&/Mac|iP(hone|[oa]d)/.test(navigator.platform),Bg=typeof navigator!="undefined"&&/Win/.test(navigator.platform);function Fg(n){let e=n.split(/-(?!$)/),t=e[e.length-1];t=="Space"&&(t=" ");let r,i,s,o;for(let l=0;l{for(var t in e)$g(n,t,{get:e[t],enumerable:!0})};function si(n){let{state:e,transaction:t}=n,{selection:r}=t,{doc:i}=t,{storedMarks:s}=t;return{...e,apply:e.apply.bind(e),applyTransaction:e.applyTransaction.bind(e),plugins:e.plugins,schema:e.schema,reconfigure:e.reconfigure.bind(e),toJSON:e.toJSON.bind(e),get storedMarks(){return s},get selection(){return r},get doc(){return i},get tr(){return r=t.selection,i=t.doc,s=t.storedMarks,t}}}var oi=class{constructor(n){this.editor=n.editor,this.rawCommands=this.editor.extensionManager.commands,this.customState=n.state}get hasCustomState(){return!!this.customState}get state(){return this.customState||this.editor.state}get commands(){let{rawCommands:n,editor:e,state:t}=this,{view:r}=e,{tr:i}=t,s=this.buildProps(i);return Object.fromEntries(Object.entries(n).map(([o,l])=>[o,(...c)=>{let u=l(...c)(s);return!i.getMeta("preventDispatch")&&!this.hasCustomState&&r.dispatch(i),u}]))}get chain(){return()=>this.createChain()}get can(){return()=>this.createCan()}createChain(n,e=!0){let{rawCommands:t,editor:r,state:i}=this,{view:s}=r,o=[],l=!!n,a=n||i.tr,c=()=>(!l&&e&&!a.getMeta("preventDispatch")&&!this.hasCustomState&&s.dispatch(a),o.every(d=>d===!0)),u={...Object.fromEntries(Object.entries(t).map(([d,f])=>[d,(...p)=>{let m=this.buildProps(a,e),g=f(...p)(m);return o.push(g),u}])),run:c};return u}createCan(n){let{rawCommands:e,state:t}=this,r=!1,i=n||t.tr,s=this.buildProps(i,r);return{...Object.fromEntries(Object.entries(e).map(([l,a])=>[l,(...c)=>a(...c)({...s,dispatch:void 0})])),chain:()=>this.createChain(i,r)}}buildProps(n,e=!0){let{rawCommands:t,editor:r,state:i}=this,{view:s}=r,o={tr:n,editor:r,view:s,state:si({state:i,transaction:n}),dispatch:e?()=>{}:void 0,chain:()=>this.createChain(n,e),can:()=>this.createCan(n),get commands(){return Object.fromEntries(Object.entries(t).map(([l,a])=>[l,(...c)=>a(...c)(o)]))}};return o}},nd={};Ko(nd,{blur:()=>_g,clearContent:()=>Vg,clearNodes:()=>Wg,command:()=>jg,createParagraphNear:()=>Kg,cut:()=>Jg,deleteCurrentNode:()=>qg,deleteNode:()=>Ug,deleteRange:()=>Gg,deleteSelection:()=>Qg,enter:()=>Zg,exitCode:()=>ey,extendMarkRange:()=>ty,first:()=>ny,focus:()=>iy,forEach:()=>sy,insertContent:()=>oy,insertContentAt:()=>cy,joinBackward:()=>fy,joinDown:()=>dy,joinForward:()=>hy,joinItemBackward:()=>py,joinItemForward:()=>my,joinTextblockBackward:()=>gy,joinTextblockForward:()=>yy,joinUp:()=>uy,keyboardShortcut:()=>ky,lift:()=>xy,liftEmptyBlock:()=>wy,liftListItem:()=>Sy,newlineInCode:()=>vy,resetAttributes:()=>Cy,scrollIntoView:()=>My,selectAll:()=>Ty,selectNodeBackward:()=>Ay,selectNodeForward:()=>Ey,selectParentNode:()=>Ny,selectTextblockEnd:()=>Oy,selectTextblockStart:()=>Ry,setContent:()=>Iy,setMark:()=>Uy,setMeta:()=>Gy,setNode:()=>Xy,setNodeSelection:()=>Yy,setTextDirection:()=>Qy,setTextSelection:()=>Zy,sinkListItem:()=>e0,splitBlock:()=>t0,splitListItem:()=>n0,toggleList:()=>i0,toggleMark:()=>s0,toggleNode:()=>o0,toggleWrap:()=>l0,undoInputRule:()=>a0,unsetAllMarks:()=>c0,unsetMark:()=>u0,unsetTextDirection:()=>d0,updateAttributes:()=>f0,wrapIn:()=>h0,wrapInList:()=>p0});var _g=()=>({editor:n,view:e})=>(requestAnimationFrame(()=>{var t;n.isDestroyed||(e.dom.blur(),(t=window==null?void 0:window.getSelection())==null||t.removeAllRanges())}),!0),Vg=(n=!0)=>({commands:e})=>e.setContent("",{emitUpdate:n}),Wg=()=>({state:n,tr:e,dispatch:t})=>{let{selection:r}=e,{ranges:i}=r;return t&&i.forEach(({$from:s,$to:o})=>{n.doc.nodesBetween(s.pos,o.pos,(l,a)=>{if(l.type.isText)return;let{doc:c,mapping:u}=e,d=c.resolve(u.map(a)),f=c.resolve(u.map(a+l.nodeSize)),h=d.blockRange(f);if(!h)return;let p=Ze(h);if(l.type.isTextblock){let{defaultType:m}=d.parent.contentMatchAt(d.index());e.setNodeMarkup(h.start,m)}(p||p===0)&&e.lift(h,p)})}),!0},jg=n=>e=>n(e),Kg=()=>({state:n,dispatch:e})=>eo(n,e),Jg=(n,e)=>({editor:t,tr:r})=>{let{state:i}=t,s=i.doc.slice(n.from,n.to);r.deleteRange(n.from,n.to);let o=r.mapping.map(e);return r.insert(o,s.content),r.setSelection(new T(r.doc.resolve(Math.max(o-1,0)))),!0},qg=()=>({tr:n,dispatch:e})=>{let{selection:t}=n,r=t.$anchor.node();if(r.content.size>0)return!1;let i=n.selection.$anchor;for(let s=i.depth;s>0;s-=1)if(i.node(s).type===r.type){if(e){let l=i.before(s),a=i.after(s);n.delete(l,a).scrollIntoView()}return!0}return!1};function U(n,e){if(typeof n=="string"){if(!e.nodes[n])throw Error(`There is no node type named '${n}'. Maybe you forgot to add the extension?`);return e.nodes[n]}return n}var Ug=n=>({tr:e,state:t,dispatch:r})=>{let i=U(n,t.schema),s=e.selection.$anchor;for(let o=s.depth;o>0;o-=1)if(s.node(o).type===i){if(r){let a=s.before(o),c=s.after(o);e.delete(a,c).scrollIntoView()}return!0}return!1},Gg=n=>({tr:e,dispatch:t})=>{let{from:r,to:i}=n;return t&&e.delete(r,i),!0},Xg=n=>n.content?/^text(\*|\+)/.test(n.content):!1,Ku=(n,e,t)=>{if(!n.parent.isInline||t==="left"&&n.pos>n.start()||t==="right"&&n.pos{let r=Ku(n,t,"left"),i=Ku(e,t,"right");return{from:r,to:i}},Qg=()=>({state:n,dispatch:e})=>{if(n.selection.empty)return!1;if(e){let t=n.tr,{ranges:r}=n.selection,i=t.steps.length;r.forEach(s=>{let o=t.mapping.slice(i),l=t.doc.resolve(o.map(s.$from.pos)),a=t.doc.resolve(o.map(s.$to.pos)),{from:c,to:u}=Yg(l,a,n.schema);t.deleteRange(c,u)}),t.scrollIntoView(),e(t)}return!0},Zg=()=>({commands:n})=>n.keyboardShortcut("Enter"),ey=()=>({state:n,dispatch:e})=>Zs(n,e);function Jo(n){return Object.prototype.toString.call(n)==="[object RegExp]"}function ri(n,e,t={strict:!0}){let r=Object.keys(e);return r.length?r.every(i=>t.strict?e[i]===n[i]:Jo(e[i])?e[i].test(n[i]):e[i]===n[i]):!0}function rd(n,e,t={}){return n.find(r=>r.type===e&&ri(Object.fromEntries(Object.keys(t).map(i=>[i,r.attrs[i]])),t))}function Ju(n,e,t={}){return!!rd(n,e,t)}function qo(n,e,t){if(!n||!e)return;let r=n.parent.childAfter(n.parentOffset);if((!r.node||!r.node.marks.some(c=>c.type===e))&&(r=n.parent.childBefore(n.parentOffset)),!r.node||!r.node.marks.some(c=>c.type===e))return;if(!t){let c=r.node.marks.find(u=>u.type===e);c&&(t=c.attrs)}if(!rd([...r.node.marks],e,t))return;let s=r.index,o=n.start()+r.offset,l=s+1,a=o+r.node.nodeSize;for(;s>0&&Ju([...n.parent.child(s-1).marks],e,t);)s-=1,o-=n.parent.child(s).nodeSize;for(;l({tr:t,state:r,dispatch:i})=>{let s=ot(n,r.schema),{doc:o,selection:l}=t,{$from:a,from:c,to:u}=l;if(i){let d=qo(a,s,e);if(d&&d.from<=c&&d.to>=u){let f=T.create(o,d.from,d.to);t.setSelection(f)}}return!0},ny=n=>e=>{let t=typeof n=="function"?n(e):n;for(let r=0;r({editor:t,view:r,tr:i,dispatch:s})=>{e={scrollIntoView:!0,...e};let o=()=>{(ii()||qu())&&r.dom.focus(),ry()&&!ii()&&!qu()&&r.dom.focus({preventScroll:!0}),requestAnimationFrame(()=>{t.isDestroyed||(r.focus(),e!=null&&e.scrollIntoView&&t.commands.scrollIntoView())})};try{if(r.hasFocus()&&n===null||n===!1)return!0}catch(c){return!1}if(s&&n===null&&!id(t.state.selection))return o(),!0;let l=sd(i.doc,n)||t.state.selection,a=t.state.selection.eq(l);return s&&(a||i.setSelection(l),a&&i.storedMarks&&i.setStoredMarks(i.storedMarks),o()),!0},sy=(n,e)=>t=>n.every((r,i)=>e(r,{...t,index:i})),oy=(n,e)=>({tr:t,commands:r})=>r.insertContentAt({from:t.selection.from,to:t.selection.to},n,e),od=n=>{let e=n.childNodes;for(let t=e.length-1;t>=0;t-=1){let r=e[t];r.nodeType===3&&r.nodeValue&&/^(\n\s\s|\n)$/.test(r.nodeValue)?n.removeChild(r):r.nodeType===1&&od(r)}return n};function Qr(n){if(typeof window=="undefined")throw new Error("[tiptap error]: there is no window object available, so this function cannot be used");let e=`${n}`,t=new window.DOMParser().parseFromString(e,"text/html").body;return od(t)}function Xn(n,e,t){if(n instanceof ve||n instanceof x)return n;t={slice:!0,parseOptions:{},...t};let r=typeof n=="object"&&n!==null,i=typeof n=="string";if(r)try{if(Array.isArray(n)&&n.length>0)return x.fromArray(n.map(l=>e.nodeFromJSON(l)));let o=e.nodeFromJSON(n);return t.errorOnInvalidContent&&o.check(),o}catch(s){if(t.errorOnInvalidContent)throw new Error("[tiptap error]: Invalid JSON content",{cause:s});return console.warn("[tiptap warn]: Invalid content.","Passed value:",n,"Error:",s),Xn("",e,t)}if(i){if(t.errorOnInvalidContent){let o=!1,l="",a=new Zt({topNode:e.spec.topNode,marks:e.spec.marks,nodes:e.spec.nodes.append({__tiptap__private__unknown__catch__all__node:{content:"inline*",group:"block",parseDOM:[{tag:"*",getAttrs:c=>(o=!0,l=typeof c=="string"?c:c.outerHTML,null)}]}})});if(t.slice?$e.fromSchema(a).parseSlice(Qr(n),t.parseOptions):$e.fromSchema(a).parse(Qr(n),t.parseOptions),t.errorOnInvalidContent&&o)throw new Error("[tiptap error]: Invalid HTML content",{cause:new Error(`Invalid element found: ${l}`)})}let s=$e.fromSchema(e);return t.slice?s.parseSlice(Qr(n),t.parseOptions).content:s.parse(Qr(n),t.parseOptions)}return Xn("",e,t)}function ly(n,e,t){let r=n.steps.length-1;if(r{o===0&&(o=u)}),n.setSelection(N.near(n.doc.resolve(o),t))}var ay=n=>!("type"in n),cy=(n,e,t)=>({tr:r,dispatch:i,editor:s})=>{var o;if(i){t={parseOptions:s.options.parseOptions,updateSelection:!0,applyInputRules:!1,applyPasteRules:!1,...t};let l,a=g=>{s.emit("contentError",{editor:s,error:g,disableCollaboration:()=>{"collaboration"in s.storage&&typeof s.storage.collaboration=="object"&&s.storage.collaboration&&(s.storage.collaboration.isDisabled=!0)}})},c={preserveWhitespace:"full",...t.parseOptions};if(!t.errorOnInvalidContent&&!s.options.enableContentCheck&&s.options.emitContentError)try{Xn(e,s.schema,{parseOptions:c,errorOnInvalidContent:!0})}catch(g){a(g)}try{l=Xn(e,s.schema,{parseOptions:c,errorOnInvalidContent:(o=t.errorOnInvalidContent)!=null?o:s.options.enableContentCheck})}catch(g){return a(g),!1}let{from:u,to:d}=typeof n=="number"?{from:n,to:n}:{from:n.from,to:n.to},f=!0,h=!0;if((ay(l)?l:[l]).forEach(g=>{g.check(),f=f?g.isText&&g.marks.length===0:!1,h=h?g.isBlock:!1}),u===d&&h){let{parent:g}=r.doc.resolve(u);g.isTextblock&&!g.type.spec.code&&!g.childCount&&(u-=1,d+=1)}let m;if(f){if(Array.isArray(e))m=e.map(g=>g.text||"").join("");else if(e instanceof x){let g="";e.forEach(y=>{y.text&&(g+=y.text)}),m=g}else typeof e=="object"&&e&&e.text?m=e.text:m=e;r.insertText(m,u,d)}else{m=l;let g=r.doc.resolve(u),y=g.node(),b=g.parentOffset===0,k=y.isText||y.isTextblock,w=y.content.size>0;b&&k&&w&&h&&(u=Math.max(0,u-1)),r.replaceWith(u,d,m)}t.updateSelection&&ly(r,r.steps.length-1,-1),t.applyInputRules&&r.setMeta("applyInputRules",{from:u,text:m}),t.applyPasteRules&&r.setMeta("applyPasteRules",{from:u,text:m})}return!0},uy=()=>({state:n,dispatch:e})=>Cc(n,e),dy=()=>({state:n,dispatch:e})=>Mc(n,e),fy=()=>({state:n,dispatch:e})=>Ks(n,e),hy=()=>({state:n,dispatch:e})=>Us(n,e),py=()=>({state:n,dispatch:e,tr:t})=>{try{let r=Nt(n.doc,n.selection.$from.pos,-1);return r==null?!1:(t.join(r,2),e&&e(t),!0)}catch(r){return!1}},my=()=>({state:n,dispatch:e,tr:t})=>{try{let r=Nt(n.doc,n.selection.$from.pos,1);return r==null?!1:(t.join(r,2),e&&e(t),!0)}catch(r){return!1}},gy=()=>({state:n,dispatch:e})=>xc(n,e),yy=()=>({state:n,dispatch:e})=>wc(n,e);function ld(){return typeof navigator!="undefined"?/Mac/.test(navigator.platform):!1}function by(n){let e=n.split(/-(?!$)/),t=e[e.length-1];t==="Space"&&(t=" ");let r,i,s,o;for(let l=0;l({editor:e,view:t,tr:r,dispatch:i})=>{let s=by(n).split(/-(?!$)/),o=s.find(c=>!["Alt","Ctrl","Meta","Shift"].includes(c)),l=new KeyboardEvent("keydown",{key:o==="Space"?" ":o,altKey:s.includes("Alt"),ctrlKey:s.includes("Ctrl"),metaKey:s.includes("Meta"),shiftKey:s.includes("Shift"),bubbles:!0,cancelable:!0}),a=e.captureTransaction(()=>{t.someProp("handleKeyDown",c=>c(t,l))});return a==null||a.steps.forEach(c=>{let u=c.map(r.mapping);u&&i&&r.maybeStep(u)}),!0};function Ve(n,e,t={}){let{from:r,to:i,empty:s}=n.selection,o=e?U(e,n.schema):null,l=[];n.doc.nodesBetween(r,i,(d,f)=>{if(d.isText)return;let h=Math.max(r,f),p=Math.min(i,f+d.nodeSize);l.push({node:d,from:h,to:p})});let a=i-r,c=l.filter(d=>o?o.name===d.node.type.name:!0).filter(d=>ri(d.node.attrs,t,{strict:!1}));return s?!!c.length:c.reduce((d,f)=>d+f.to-f.from,0)>=a}var xy=(n,e={})=>({state:t,dispatch:r})=>{let i=U(n,t.schema);return Ve(t,i,e)?Tc(t,r):!1},wy=()=>({state:n,dispatch:e})=>to(n,e),Sy=n=>({state:e,dispatch:t})=>{let r=U(n,e.schema);return Ic(r)(e,t)},vy=()=>({state:n,dispatch:e})=>Ys(n,e);function li(n,e){return e.nodes[n]?"node":e.marks[n]?"mark":null}function Uu(n,e){let t=typeof e=="string"?[e]:e;return Object.keys(n).reduce((r,i)=>(t.includes(i)||(r[i]=n[i]),r),{})}var Cy=(n,e)=>({tr:t,state:r,dispatch:i})=>{let s=null,o=null,l=li(typeof n=="string"?n:n.name,r.schema);if(!l)return!1;l==="node"&&(s=U(n,r.schema)),l==="mark"&&(o=ot(n,r.schema));let a=!1;return t.selection.ranges.forEach(c=>{r.doc.nodesBetween(c.$from.pos,c.$to.pos,(u,d)=>{s&&s===u.type&&(a=!0,i&&t.setNodeMarkup(d,void 0,Uu(u.attrs,e))),o&&u.marks.length&&u.marks.forEach(f=>{o===f.type&&(a=!0,i&&t.addMark(d,d+u.nodeSize,o.create(Uu(f.attrs,e))))})})}),a},My=()=>({tr:n,dispatch:e})=>(e&&n.scrollIntoView(),!0),Ty=()=>({tr:n,dispatch:e})=>{if(e){let t=new ye(n.doc);n.setSelection(t)}return!0},Ay=()=>({state:n,dispatch:e})=>Js(n,e),Ey=()=>({state:n,dispatch:e})=>Gs(n,e),Ny=()=>({state:n,dispatch:e})=>Ac(n,e),Oy=()=>({state:n,dispatch:e})=>ro(n,e),Ry=()=>({state:n,dispatch:e})=>no(n,e);function Wo(n,e,t={},r={}){return Xn(n,e,{slice:!1,parseOptions:t,errorOnInvalidContent:r.errorOnInvalidContent})}var Iy=(n,{errorOnInvalidContent:e,emitUpdate:t=!0,parseOptions:r={}}={})=>({editor:i,tr:s,dispatch:o,commands:l})=>{let{doc:a}=s;if(r.preserveWhitespace!=="full"){let c=Wo(n,i.schema,r,{errorOnInvalidContent:e!=null?e:i.options.enableContentCheck});return o&&s.replaceWith(0,a.content.size,c).setMeta("preventUpdate",!t),!0}return o&&s.setMeta("preventUpdate",!t),l.insertContentAt({from:0,to:a.content.size},n,{parseOptions:r,errorOnInvalidContent:e!=null?e:i.options.enableContentCheck})};function ad(n,e){let t=ot(e,n.schema),{from:r,to:i,empty:s}=n.selection,o=[];s?(n.storedMarks&&o.push(...n.storedMarks),o.push(...n.selection.$head.marks())):n.doc.nodesBetween(r,i,a=>{o.push(...a.marks)});let l=o.find(a=>a.type.name===t.name);return l?{...l.attrs}:{}}function Uo(n,e){let t=new ht(n);return e.forEach(r=>{r.steps.forEach(i=>{t.step(i)})}),t}function Dy(n){for(let e=0;e{t(i)&&r.push({node:i,pos:s})}),r}function Go(n,e){for(let t=n.depth;t>0;t-=1){let r=n.node(t);if(e(r))return{pos:t>0?n.before(t):0,start:n.start(t),depth:t,node:r}}}function ai(n){return e=>Go(e.$from,n)}function E(n,e,t){return n.config[e]===void 0&&n.parent?E(n.parent,e,t):typeof n.config[e]=="function"?n.config[e].bind({...t,parent:n.parent?E(n.parent,e,t):null}):n.config[e]}function Xo(n){return n.map(e=>{let t={name:e.name,options:e.options,storage:e.storage},r=E(e,"addExtensions",t);return r?[e,...Xo(r())]:e}).flat(10)}function Yo(n,e){let t=Xe.fromSchema(e).serializeFragment(n),i=document.implementation.createHTMLDocument().createElement("div");return i.appendChild(t),i.innerHTML}function ud(n){return typeof n=="function"}function V(n,e=void 0,...t){return ud(n)?e?n.bind(e)(...t):n(...t):n}function Ly(n={}){return Object.keys(n).length===0&&n.constructor===Object}function fn(n){let e=n.filter(i=>i.type==="extension"),t=n.filter(i=>i.type==="node"),r=n.filter(i=>i.type==="mark");return{baseExtensions:e,nodeExtensions:t,markExtensions:r}}function dd(n){let e=[],{nodeExtensions:t,markExtensions:r}=fn(n),i=[...t,...r],s={default:null,validate:void 0,rendered:!0,renderHTML:null,parseHTML:null,keepOnSplit:!0,isRequired:!1},o=t.filter(c=>c.name!=="text").map(c=>c.name),l=r.map(c=>c.name),a=[...o,...l];return n.forEach(c=>{let u={name:c.name,options:c.options,storage:c.storage,extensions:i},d=E(c,"addGlobalAttributes",u);if(!d)return;d().forEach(h=>{let p;Array.isArray(h.types)?p=h.types:h.types==="*"?p=a:h.types==="nodes"?p=o:h.types==="marks"?p=l:p=[],p.forEach(m=>{Object.entries(h.attributes).forEach(([g,y])=>{e.push({type:m,name:g,attribute:{...s,...y}})})})})}),i.forEach(c=>{let u={name:c.name,options:c.options,storage:c.storage},d=E(c,"addAttributes",u);if(!d)return;let f=d();Object.entries(f).forEach(([h,p])=>{let m={...s,...p};typeof(m==null?void 0:m.default)=="function"&&(m.default=m.default()),m!=null&&m.isRequired&&(m==null?void 0:m.default)===void 0&&delete m.default,e.push({type:c.name,name:h,attribute:m})})}),e}function Py(n){let e=[],t="",r=!1,i=!1,s=0,o=n.length;for(let l=0;l0){s-=1,t+=a;continue}if(a===";"&&s===0){e.push(t),t="";continue}}t+=a}return t&&e.push(t),e}function Gu(n){let e=[],t=Py(n||""),r=t.length;for(let i=0;i!!e).reduce((e,t)=>{let r={...e};return Object.entries(t).forEach(([i,s])=>{if(!r[i]){r[i]=s;return}if(i==="class"){let l=s?String(s).split(" "):[],a=r[i]?r[i].split(" "):[],c=l.filter(u=>!a.includes(u));r[i]=[...a,...c].join(" ")}else if(i==="style"){let l=new Map([...Gu(r[i]),...Gu(s)]);r[i]=Array.from(l.entries()).map(([a,c])=>`${a}: ${c}`).join("; ")}else r[i]=s}),r},{})}function hn(n,e){return e.filter(t=>t.type===n.type.name).filter(t=>t.attribute.rendered).map(t=>t.attribute.renderHTML?t.attribute.renderHTML(n.attrs)||{}:{[t.name]:n.attrs[t.name]}).reduce((t,r)=>I(t,r),{})}function zy(n){return typeof n!="string"?n:n.match(/^[+-]?(?:\d*\.)?\d+$/)?Number(n):n==="true"?!0:n==="false"?!1:n}function Xu(n,e){return"style"in n?n:{...n,getAttrs:t=>{let r=n.getAttrs?n.getAttrs(t):n.attrs;if(r===!1)return!1;let i=e.reduce((s,o)=>{let l=o.attribute.parseHTML?o.attribute.parseHTML(t):zy(t.getAttribute(o.name));return l==null?s:{...s,[o.name]:l}},{});return{...r,...i}}}}function Yu(n){return Object.fromEntries(Object.entries(n).filter(([e,t])=>e==="attrs"&&Ly(t)?!1:t!=null))}function Qu(n){var e,t;let r={};return!((e=n==null?void 0:n.attribute)!=null&&e.isRequired)&&"default"in((n==null?void 0:n.attribute)||{})&&(r.default=n.attribute.default),((t=n==null?void 0:n.attribute)==null?void 0:t.validate)!==void 0&&(r.validate=n.attribute.validate),[n.name,r]}function By(n,e){var t;let r=dd(n),{nodeExtensions:i,markExtensions:s}=fn(n),o=(t=i.find(c=>E(c,"topNode")))==null?void 0:t.name,l=Object.fromEntries(i.map(c=>{let u=r.filter(y=>y.type===c.name),d={name:c.name,options:c.options,storage:c.storage,editor:e},f=n.reduce((y,b)=>{let k=E(b,"extendNodeSchema",d);return{...y,...k?k(c):{}}},{}),h=Yu({...f,content:V(E(c,"content",d)),marks:V(E(c,"marks",d)),group:V(E(c,"group",d)),inline:V(E(c,"inline",d)),atom:V(E(c,"atom",d)),selectable:V(E(c,"selectable",d)),draggable:V(E(c,"draggable",d)),code:V(E(c,"code",d)),whitespace:V(E(c,"whitespace",d)),linebreakReplacement:V(E(c,"linebreakReplacement",d)),defining:V(E(c,"defining",d)),isolating:V(E(c,"isolating",d)),attrs:Object.fromEntries(u.map(Qu))}),p=V(E(c,"parseHTML",d));p&&(h.parseDOM=p.map(y=>Xu(y,u)));let m=E(c,"renderHTML",d);m&&(h.toDOM=y=>m({node:y,HTMLAttributes:hn(y,u)}));let g=E(c,"renderText",d);return g&&(h.toText=g),[c.name,h]})),a=Object.fromEntries(s.map(c=>{let u=r.filter(g=>g.type===c.name),d={name:c.name,options:c.options,storage:c.storage,editor:e},f=n.reduce((g,y)=>{let b=E(y,"extendMarkSchema",d);return{...g,...b?b(c):{}}},{}),h=Yu({...f,inclusive:V(E(c,"inclusive",d)),excludes:V(E(c,"excludes",d)),group:V(E(c,"group",d)),spanning:V(E(c,"spanning",d)),code:V(E(c,"code",d)),attrs:Object.fromEntries(u.map(Qu))}),p=V(E(c,"parseHTML",d));p&&(h.parseDOM=p.map(g=>Xu(g,u)));let m=E(c,"renderHTML",d);return m&&(h.toDOM=g=>m({mark:g,HTMLAttributes:hn(g,u)})),[c.name,h]}));return new Zt({topNode:o,nodes:l,marks:a})}function Fy(n){let e=n.filter((t,r)=>n.indexOf(t)!==r);return Array.from(new Set(e))}function Gn(n){return n.sort((t,r)=>{let i=E(t,"priority")||100,s=E(r,"priority")||100;return i>s?-1:ir.name));return t.length&&console.warn(`[tiptap warn]: Duplicate extension names found: [${t.map(r=>`'${r}'`).join(", ")}]. This can lead to issues.`),e}function hd(n,e,t){let{from:r,to:i}=e,{blockSeparator:s=` + +`,textSerializers:o={}}=t||{},l="";return n.nodesBetween(r,i,(a,c,u,d)=>{var f;a.isBlock&&c>r&&(l+=s);let h=o==null?void 0:o[a.type.name];if(h)return u&&(l+=h({node:a,pos:c,parent:u,index:d,range:e})),!1;a.isText&&(l+=(f=a==null?void 0:a.text)==null?void 0:f.slice(Math.max(r,c)-c,i-c))}),l}function Hy(n,e){let t={from:0,to:n.content.size};return hd(n,t,e)}function pd(n){return Object.fromEntries(Object.entries(n.nodes).filter(([,e])=>e.spec.toText).map(([e,t])=>[e,t.spec.toText]))}function $y(n,e){let t=U(e,n.schema),{from:r,to:i}=n.selection,s=[];n.doc.nodesBetween(r,i,l=>{s.push(l)});let o=s.reverse().find(l=>l.type.name===t.name);return o?{...o.attrs}:{}}function Qo(n,e){let t=li(typeof e=="string"?e:e.name,n.schema);return t==="node"?$y(n,e):t==="mark"?ad(n,e):{}}function _y(n,e=JSON.stringify){let t={};return n.filter(r=>{let i=e(r);return Object.prototype.hasOwnProperty.call(t,i)?!1:t[i]=!0})}function Vy(n){let e=_y(n);return e.length===1?e:e.filter((t,r)=>!e.filter((s,o)=>o!==r).some(s=>t.oldRange.from>=s.oldRange.from&&t.oldRange.to<=s.oldRange.to&&t.newRange.from>=s.newRange.from&&t.newRange.to<=s.newRange.to))}function Yn(n){let{mapping:e,steps:t}=n,r=[];return e.maps.forEach((i,s)=>{let o=[];if(i.ranges.length)i.forEach((l,a)=>{o.push({from:l,to:a})});else{let{from:l,to:a}=t[s];if(l===void 0||a===void 0)return;o.push({from:l,to:a})}o.forEach(({from:l,to:a})=>{let c=e.slice(s).map(l,-1),u=e.slice(s).map(a),d=e.invert().map(c,-1),f=e.invert().map(u);r.push({oldRange:{from:d,to:f},newRange:{from:c,to:u}})})}),Vy(r)}function ci(n,e,t){let r=[];return n===e?t.resolve(n).marks().forEach(i=>{let s=t.resolve(n),o=qo(s,i.type);o&&r.push({mark:i,...o})}):t.nodesBetween(n,e,(i,s)=>{!i||(i==null?void 0:i.nodeSize)===void 0||r.push(...i.marks.map(o=>({from:s,to:s+i.nodeSize,mark:o})))}),r}var md=(n,e,t,r=20)=>{let i=n.doc.resolve(t),s=r,o=null;for(;s>0&&o===null;){let l=i.node(s);(l==null?void 0:l.type.name)===e?o=l:s-=1}return[o,s]};function qn(n,e){return e.nodes[n]||e.marks[n]||null}function ni(n,e,t){return Object.fromEntries(Object.entries(t).filter(([r])=>{let i=n.find(s=>s.type===e&&s.name===r);return i?i.attribute.keepOnSplit:!1}))}var Wy=(n,e=500)=>{let t="",r=n.parentOffset;return n.parent.nodesBetween(Math.max(0,r-e),r,(i,s,o,l)=>{var a,c;let u=((c=(a=i.type.spec).toText)==null?void 0:c.call(a,{node:i,pos:s,parent:o,index:l}))||i.textContent||"%leaf%";t+=i.isAtom&&!i.isText?u:u.slice(0,Math.max(0,r-s))}),t};function jo(n,e,t={}){let{empty:r,ranges:i}=n.selection,s=e?ot(e,n.schema):null;if(r)return!!(n.storedMarks||n.selection.$from.marks()).filter(d=>s?s.name===d.type.name:!0).find(d=>ri(d.attrs,t,{strict:!1}));let o=0,l=[];if(i.forEach(({$from:d,$to:f})=>{let h=d.pos,p=f.pos;n.doc.nodesBetween(h,p,(m,g)=>{if(s&&m.inlineContent&&!m.type.allowsMarkType(s))return!1;if(!m.isText&&!m.marks.length)return;let y=Math.max(h,g),b=Math.min(p,g+m.nodeSize),k=b-y;o+=k,l.push(...m.marks.map(w=>({mark:w,from:y,to:b})))})}),o===0)return!1;let a=l.filter(d=>s?s.name===d.mark.type.name:!0).filter(d=>ri(d.mark.attrs,t,{strict:!1})).reduce((d,f)=>d+f.to-f.from,0),c=l.filter(d=>s?d.mark.type!==s&&d.mark.type.excludes(s):!0).reduce((d,f)=>d+f.to-f.from,0);return(a>0?a+c:a)>=o}function jy(n,e,t={}){if(!e)return Ve(n,null,t)||jo(n,null,t);let r=li(e,n.schema);return r==="node"?Ve(n,e,t):r==="mark"?jo(n,e,t):!1}var gd=(n,e)=>{let{$from:t,$to:r,$anchor:i}=n.selection;if(e){let s=ai(l=>l.type.name===e)(n.selection);if(!s)return!1;let o=n.doc.resolve(s.pos+1);return i.pos+1===o.end()}return!(r.parentOffset{let{$from:e,$to:t}=n.selection;return!(e.parentOffset>0||e.pos!==t.pos)};function Zu(n,e){return Array.isArray(e)?e.some(t=>(typeof t=="string"?t:t.name)===n.name):e}function $o(n,e){let{nodeExtensions:t}=fn(e),r=t.find(o=>o.name===n);if(!r)return!1;let i={name:r.name,options:r.options,storage:r.storage},s=V(E(r,"group",i));return typeof s!="string"?!1:s.split(" ").includes("list")}function pn(n,{checkChildren:e=!0,ignoreWhitespace:t=!1}={}){var r;if(t){if(n.type.name==="hardBreak")return!0;if(n.isText)return!/\S/.test((r=n.text)!=null?r:"")}if(n.isText)return!n.text;if(n.isAtom||n.isLeaf)return!1;if(n.content.childCount===0)return!0;if(e){let i=!0;return n.content.forEach(s=>{i!==!1&&(pn(s,{ignoreWhitespace:t,checkChildren:e})||(i=!1))}),i}return!1}function ui(n){return n instanceof A}var bd=class kd{constructor(e){this.position=e}static fromJSON(e){return new kd(e.position)}toJSON(){return{position:this.position}}};function Ky(n,e){let t=e.mapping.mapResult(n.position);return{position:new bd(t.pos),mapResult:t}}function Jy(n){return new bd(n)}function qy(n,e,t){var r;let{selection:i}=e,s=null;if(id(i)&&(s=i.$cursor),s){let l=(r=n.storedMarks)!=null?r:s.marks();return s.parent.type.allowsMarkType(t)&&(!!t.isInSet(l)||!l.some(c=>c.type.excludes(t)))}let{ranges:o}=i;return o.some(({$from:l,$to:a})=>{let c=l.depth===0?n.doc.inlineContent&&n.doc.type.allowsMarkType(t):!1;return n.doc.nodesBetween(l.pos,a.pos,(u,d,f)=>{if(c)return!1;if(u.isInline){let h=!f||f.type.allowsMarkType(t),p=!!t.isInSet(u.marks)||!u.marks.some(m=>m.type.excludes(t));c=h&&p}return!c}),c})}var Uy=(n,e={})=>({tr:t,state:r,dispatch:i})=>{let{selection:s}=t,{empty:o,ranges:l}=s,a=ot(n,r.schema);if(i)if(o){let c=ad(r,a);t.addStoredMark(a.create({...c,...e}))}else l.forEach(c=>{let u=c.$from.pos,d=c.$to.pos;r.doc.nodesBetween(u,d,(f,h)=>{let p=Math.max(h,u),m=Math.min(h+f.nodeSize,d);f.marks.find(y=>y.type===a)?f.marks.forEach(y=>{a===y.type&&t.addMark(p,m,a.create({...y.attrs,...e}))}):t.addMark(p,m,a.create(e))})});return qy(r,t,a)},Gy=(n,e)=>({tr:t})=>(t.setMeta(n,e),!0),Xy=(n,e={})=>({state:t,dispatch:r,chain:i})=>{let s=U(n,t.schema),o;return t.selection.$anchor.sameParent(t.selection.$head)&&(o=t.selection.$anchor.parent.attrs),s.isTextblock?i().command(({commands:l})=>io(s,{...o,...e})(t)?!0:l.clearNodes()).command(({state:l})=>io(s,{...o,...e})(l,r)).run():(console.warn('[tiptap warn]: Currently "setNode()" only supports text block nodes.'),!1)},Yy=n=>({tr:e,dispatch:t})=>{if(t){let{doc:r}=e,i=$t(n,0,r.content.size),s=A.create(r,i);e.setSelection(s)}return!0},Qy=(n,e)=>({tr:t,state:r,dispatch:i})=>{let{selection:s}=r,o,l;return typeof e=="number"?(o=e,l=e):e&&"from"in e&&"to"in e?(o=e.from,l=e.to):(o=s.from,l=s.to),i&&t.doc.nodesBetween(o,l,(a,c)=>{a.isText||t.setNodeMarkup(c,void 0,{...a.attrs,dir:n})}),!0},Zy=n=>({tr:e,dispatch:t})=>{if(t){let{doc:r}=e,{from:i,to:s}=typeof n=="number"?{from:n,to:n}:n,o=T.atStart(r).from,l=T.atEnd(r).to,a=$t(i,o,l),c=$t(s,o,l),u=T.create(r,a,c);e.setSelection(u)}return!0},e0=n=>({state:e,dispatch:t})=>{let r=U(n,e.schema);return Dc(r)(e,t)};function ed(n,e){let t=n.storedMarks||n.selection.$to.parentOffset&&n.selection.$from.marks();if(t){let r=t.filter(i=>e==null?void 0:e.includes(i.type.name));n.tr.ensureMarks(r)}}var t0=({keepMarks:n=!0}={})=>({tr:e,state:t,dispatch:r,editor:i})=>{let{selection:s,doc:o}=e,{$from:l,$to:a}=s,c=i.extensionManager.attributes,u=ni(c,l.node().type.name,l.node().attrs);if(s instanceof A&&s.node.isBlock)return!l.parentOffset||!Ae(o,l.pos)?!1:(r&&(n&&ed(t,i.extensionManager.splittableMarks),e.split(l.pos).scrollIntoView()),!0);if(!l.parent.isBlock)return!1;let d=a.parentOffset===a.parent.content.size,f=l.depth===0?void 0:Dy(l.node(-1).contentMatchAt(l.indexAfter(-1))),h=d&&f?[{type:f,attrs:u}]:void 0,p=Ae(e.doc,e.mapping.map(l.pos),1,h);if(!h&&!p&&Ae(e.doc,e.mapping.map(l.pos),1,f?[{type:f}]:void 0)&&(p=!0,h=f?[{type:f,attrs:u}]:void 0),r){if(p&&(s instanceof T&&e.deleteSelection(),e.split(e.mapping.map(l.pos),1,h),f&&!d&&!l.parentOffset&&l.parent.type!==f)){let m=e.mapping.map(l.before()),g=e.doc.resolve(m);l.node(-1).canReplaceWith(g.index(),g.index()+1,f)&&e.setNodeMarkup(e.mapping.map(l.before()),f)}n&&ed(t,i.extensionManager.splittableMarks),e.scrollIntoView()}return p},n0=(n,e={})=>({tr:t,state:r,dispatch:i,editor:s})=>{var o;let l=U(n,r.schema),{$from:a,$to:c}=r.selection,u=r.selection.node;if(u&&u.isBlock||a.depth<2||!a.sameParent(c))return!1;let d=a.node(-1);if(d.type!==l)return!1;let f=s.extensionManager.attributes;if(a.parent.content.size===0&&a.node(-1).childCount===a.indexAfter(-1)){if(a.depth===2||a.node(-3).type!==l||a.index(-2)!==a.node(-2).childCount-1)return!1;if(i){let y=x.empty,b=a.index(-1)?1:a.index(-2)?2:3;for(let D=a.depth-b;D>=a.depth-3;D-=1)y=x.from(a.node(D).copy(y));let k=a.indexAfter(-1){if(M>-1)return!1;D.isTextblock&&D.content.size===0&&(M=B+1)}),M>-1&&t.setSelection(T.near(t.doc.resolve(M))),t.scrollIntoView()}return!0}let h=c.pos===a.end()?d.contentMatchAt(0).defaultType:null,p={...ni(f,d.type.name,d.attrs),...e},m={...ni(f,a.node().type.name,a.node().attrs),...e};t.delete(a.pos,c.pos);let g=h?[{type:l,attrs:p},{type:h,attrs:m}]:[{type:l,attrs:p}];if(!Ae(t.doc,a.pos,2))return!1;if(i){let{selection:y,storedMarks:b}=r,{splittableMarks:k}=s.extensionManager,w=b||y.$to.parentOffset&&y.$from.marks();if(t.split(a.pos,2,g).scrollIntoView(),!w||!i)return!0;let C=w.filter(O=>k.includes(O.type.name));t.ensureMarks(C)}return!0};function td(n){return!n||n==="1"?null:n}function xd(n,e){return td(n)===td(e)}var _o=(n,e)=>{let t=ai(o=>o.type===e)(n.selection);if(!t)return!0;let r=n.doc.resolve(Math.max(0,t.pos-1)).before(t.depth);if(r===void 0)return!0;let i=n.doc.nodeAt(r);return!(t.node.type===(i==null?void 0:i.type)&&Oe(n.doc,t.pos))||!xd(t.node.attrs.type,i==null?void 0:i.attrs.type)||n.join(t.pos),!0},Vo=(n,e)=>{let t=ai(o=>o.type===e)(n.selection);if(!t)return!0;let r=n.doc.resolve(t.start).after(t.depth);if(r===void 0)return!0;let i=n.doc.nodeAt(r);return!(t.node.type===(i==null?void 0:i.type)&&Oe(n.doc,r))||!xd(t.node.attrs.type,i==null?void 0:i.attrs.type)||n.join(r),!0};function r0(n){let e=n.doc,t=e.firstChild;if(!t)return null;let r=e.resolve(1),i=e.resolve(t.nodeSize-1);return T.between(r,i)}var i0=(n,e,t,r={})=>({editor:i,tr:s,state:o,dispatch:l,chain:a,commands:c,can:u})=>{let{extensions:d,splittableMarks:f}=i.extensionManager,h=U(n,o.schema),p=U(e,o.schema),{selection:m,storedMarks:g}=o,{$from:y,$to:b}=m,k=y.blockRange(b),w=g||m.$to.parentOffset&&m.$from.marks();if(!k)return!1;let C=ai(Q=>$o(Q.type.name,d))(m),O=m.from===0&&m.to===o.doc.content.size,M=o.doc.content.content,D=M.length===1?M[0]:null,B=O&&D&&$o(D.type.name,d)?{node:D,pos:0,depth:0}:null,P=C!=null?C:B,Se=!!C&&k.depth>=1&&k.depth-C.depth<=1,Te=!!B;if((Se||Te)&&P){if(P.node.type===h)return O&&Te?a().command(({tr:Q,dispatch:Z})=>{let J=r0(Q);return J?(Q.setSelection(J),Z&&Z(Q),!0):!1}).liftListItem(p).run():c.liftListItem(p);if($o(P.node.type.name,d)&&h.validContent(P.node.content))return a().command(()=>(s.setNodeMarkup(P.pos,h),!0)).command(()=>_o(s,h)).command(()=>Vo(s,h)).run()}return!t||!w||!l?a().command(()=>u().wrapInList(h,r)?!0:c.clearNodes()).wrapInList(h,r).command(()=>_o(s,h)).command(()=>Vo(s,h)).run():a().command(()=>{let Q=u().wrapInList(h,r),Z=w.filter(J=>f.includes(J.type.name));return s.ensureMarks(Z),Q?!0:c.clearNodes()}).wrapInList(h,r).command(()=>_o(s,h)).command(()=>Vo(s,h)).run()},s0=(n,e={},t={})=>({state:r,commands:i})=>{let{extendEmptyMarkRange:s=!1}=t,o=ot(n,r.schema);return jo(r,o,e)?i.unsetMark(o,{extendEmptyMarkRange:s}):i.setMark(o,e)},o0=(n,e,t={})=>({state:r,commands:i})=>{let s=U(n,r.schema),o=U(e,r.schema),l=Ve(r,s,t),a;return r.selection.$anchor.sameParent(r.selection.$head)&&(a=r.selection.$anchor.parent.attrs),l?i.setNode(o,a):i.setNode(s,{...a,...t})},l0=(n,e={})=>({state:t,commands:r})=>{let i=U(n,t.schema);return Ve(t,i,e)?r.lift(i):r.wrapIn(i,e)},a0=()=>({state:n,dispatch:e})=>{let t=n.plugins;for(let r=0;r=0;a-=1)o.step(l.steps[a].invert(l.docs[a]));if(s.text){let a=o.doc.resolve(s.from).marks();o.replaceWith(s.from,s.to,n.schema.text(s.text,a))}else o.delete(s.from,s.to)}return!0}}return!1},c0=(n={})=>({tr:e,dispatch:t,editor:r})=>{let{ignoreClearable:i=!1}=n,{selection:s}=e,{empty:o,ranges:l}=s;if(o)return!0;let{nonClearableMarks:a}=r.extensionManager;if(t){let c=Object.values(r.schema.marks).filter(u=>i||!a.includes(u.name));l.forEach(u=>{for(let d of c)e.removeMark(u.$from.pos,u.$to.pos,d)})}return!0},u0=(n,e={})=>({tr:t,state:r,dispatch:i})=>{var s;let{extendEmptyMarkRange:o=!1}=e,{selection:l}=t,a=ot(n,r.schema),{$from:c,empty:u,ranges:d}=l;if(!i)return!0;if(u&&o){let{from:f,to:h}=l,p=(s=c.marks().find(g=>g.type===a))==null?void 0:s.attrs,m=qo(c,a,p);m&&(f=m.from,h=m.to),t.removeMark(f,h,a)}else d.forEach(f=>{t.removeMark(f.$from.pos,f.$to.pos,a)});return t.removeStoredMark(a),!0},d0=n=>({tr:e,state:t,dispatch:r})=>{let{selection:i}=t,s,o;return typeof n=="number"?(s=n,o=n):n&&"from"in n&&"to"in n?(s=n.from,o=n.to):(s=i.from,o=i.to),r&&e.doc.nodesBetween(s,o,(l,a)=>{if(l.isText)return;let c={...l.attrs};delete c.dir,e.setNodeMarkup(a,void 0,c)}),!0},f0=(n,e={})=>({tr:t,state:r,dispatch:i})=>{let s=null,o=null,l=li(typeof n=="string"?n:n.name,r.schema);if(!l)return!1;l==="node"&&(s=U(n,r.schema)),l==="mark"&&(o=ot(n,r.schema));let a=!1;return t.selection.ranges.forEach(c=>{let u=c.$from.pos,d=c.$to.pos,f,h,p,m;t.selection.empty?r.doc.nodesBetween(u,d,(g,y)=>{s&&s===g.type&&(a=!0,p=Math.max(y,u),m=Math.min(y+g.nodeSize,d),f=y,h=g)}):r.doc.nodesBetween(u,d,(g,y)=>{y=u&&y<=d&&(s&&s===g.type&&(a=!0,i&&t.setNodeMarkup(y,void 0,{...g.attrs,...e})),o&&g.marks.length&&g.marks.forEach(b=>{if(o===b.type&&(a=!0,i)){let k=Math.max(y,u),w=Math.min(y+g.nodeSize,d);t.addMark(k,w,o.create({...b.attrs,...e}))}}))}),h&&(f!==void 0&&i&&t.setNodeMarkup(f,void 0,{...h.attrs,...e}),o&&h.marks.length&&h.marks.forEach(g=>{o===g.type&&i&&t.addMark(p,m,o.create({...g.attrs,...e}))}))}),a},h0=(n,e={})=>({state:t,dispatch:r})=>{let i=U(n,t.schema);return Oc(i,e)(t,r)},p0=(n,e={})=>({state:t,dispatch:r})=>{let i=U(n,t.schema);return Rc(i,e)(t,r)},m0=class{constructor(){this.callbacks={}}on(n,e){return this.callbacks[n]||(this.callbacks[n]=[]),this.callbacks[n].push(e),this}emit(n,...e){let t=this.callbacks[n];return t&&t.forEach(r=>r.apply(this,e)),this}off(n,e){let t=this.callbacks[n];return t&&(e?this.callbacks[n]=t.filter(r=>r!==e):delete this.callbacks[n]),this}once(n,e){let t=(...r)=>{this.off(n,t),e.apply(this,r)};return this.on(n,t)}removeAllListeners(){this.callbacks={}}};function wd(n,e){let{selection:t}=n,{$from:r}=t;if(t instanceof A){let s=r.index();return r.parent.canReplaceWith(s,s+1,e)}let i=r.depth;for(;i>=0;){let s=r.index(i);if(r.node(i).contentMatchAt(s).matchType(e))return!0;i-=1}return!1}function Zo(n,e,t){let r=document.querySelector(`style[data-tiptap-style${t?`-${t}`:""}]`);if(r!==null)return r;let i=document.createElement("style");return e&&i.setAttribute("nonce",e),i.setAttribute(`data-tiptap-style${t?`-${t}`:""}`,""),i.innerHTML=n,document.getElementsByTagName("head")[0].appendChild(i),i}function g0(n){return typeof n=="number"}function y0(n){return Object.prototype.toString.call(n).slice(8,-1)}function Zr(n){return y0(n)!=="Object"?!1:n.constructor===Object&&Object.getPrototypeOf(n)===Object.prototype}var b0={};Ko(b0,{createAtomBlockMarkdownSpec:()=>k0,createBlockMarkdownSpec:()=>x0,createInlineMarkdownSpec:()=>v0,parseAttributes:()=>el,parseIndentedBlocks:()=>di,renderNestedMarkdownContent:()=>Qn,serializeAttributes:()=>tl});function el(n){if(!(n!=null&&n.trim()))return{};let e={},t=[],r=n.replace(/["']([^"']*)["']/g,c=>(t.push(c),`__QUOTED_${t.length-1}__`)),i=r.match(/(?:^|\s)\.([\w-]+)/g);if(i){let c=i.map(u=>u.trim().slice(1));e.class=c.join(" ")}let s=r.match(/(?:^|\s)#([\w-]+)/);s&&(e.id=s[1]);let o=/([a-zA-Z][\w-]*)\s*=\s*(__QUOTED_\d+__)/g;Array.from(r.matchAll(o)).forEach(([,c,u])=>{var d;let f=parseInt(((d=u.match(/__QUOTED_(\d+)__/))==null?void 0:d[1])||"0",10),h=t[f];h&&(e[c]=h.slice(1,-1))});let a=r.replace(/(?:^|\s)\.([\w-]+)/g,"").replace(/(?:^|\s)#([\w-]+)/g,"").replace(/([a-zA-Z][\w-]*)\s*=\s*__QUOTED_\d+__/g,"").trim();return a&&a.split(/\s+/).filter(Boolean).forEach(u=>{u.match(/^[a-zA-Z][\w-]*$/)&&(e[u]=!0)}),e}function tl(n){if(!n||Object.keys(n).length===0)return"";let e=[];return n.class&&String(n.class).split(/\s+/).filter(Boolean).forEach(r=>e.push(`.${r}`)),n.id&&e.push(`#${n.id}`),Object.entries(n).forEach(([t,r])=>{t==="class"||t==="id"||(r===!0?e.push(t):r!==!1&&r!=null&&e.push(`${t}="${String(r)}"`))}),e.join(" ")}function k0(n){let{nodeName:e,name:t,parseAttributes:r=el,serializeAttributes:i=tl,defaultAttributes:s={},requiredAttributes:o=[],allowedAttributes:l}=n,a=t||e,c=u=>{if(!l)return u;let d={};return l.forEach(f=>{f in u&&(d[f]=u[f])}),d};return{parseMarkdown:(u,d)=>{let f={...s,...u.attributes};return d.createNode(e,f,[])},markdownTokenizer:{name:e,level:"block",start(u){var d;let f=new RegExp(`^:::${a}(?:\\s|$)`,"m"),h=(d=u.match(f))==null?void 0:d.index;return h!==void 0?h:-1},tokenize(u,d,f){let h=new RegExp(`^:::${a}(?:\\s+\\{([^}]*)\\})?\\s*:::(?:\\n|$)`),p=u.match(h);if(!p)return;let m=p[1]||"",g=r(m);if(!o.find(b=>!(b in g)))return{type:e,raw:p[0],attributes:g}}},renderMarkdown:u=>{let d=c(u.attrs||{}),f=i(d),h=f?` {${f}}`:"";return`:::${a}${h} :::`}}}function x0(n){let{nodeName:e,name:t,getContent:r,parseAttributes:i=el,serializeAttributes:s=tl,defaultAttributes:o={},content:l="block",allowedAttributes:a}=n,c=t||e,u=d=>{if(!a)return d;let f={};return a.forEach(h=>{h in d&&(f[h]=d[h])}),f};return{parseMarkdown:(d,f)=>{let h;if(r){let m=r(d);h=typeof m=="string"?[{type:"text",text:m}]:m}else l==="block"?h=f.parseChildren(d.tokens||[]):h=f.parseInline(d.tokens||[]);let p={...o,...d.attributes};return f.createNode(e,p,h)},markdownTokenizer:{name:e,level:"block",start(d){var f;let h=new RegExp(`^:::${c}`,"m"),p=(f=d.match(h))==null?void 0:f.index;return p!==void 0?p:-1},tokenize(d,f,h){var p;let m=new RegExp(`^:::${c}(?:\\s+\\{([^}]*)\\})?\\s*\\n`),g=d.match(m);if(!g)return;let[y,b=""]=g,k=i(b),w=1,C=y.length,O="",M=/^:::([\w-]*)(\s.*)?/gm,D=d.slice(C);for(M.lastIndex=0;;){let B=M.exec(D);if(B===null)break;let P=B.index,Se=B[1];if(!((p=B[2])!=null&&p.endsWith(":::"))){if(Se)w+=1;else if(w-=1,w===0){let Te=D.slice(0,P);O=Te.trim();let Q=d.slice(0,C+P+B[0].length),Z=[];if(O)if(l==="block")for(Z=h.blockTokens(Te),Z.forEach(J=>{J.text&&(!J.tokens||J.tokens.length===0)&&(J.tokens=h.inlineTokens(J.text))});Z.length>0;){let J=Z[Z.length-1];if(J.type==="paragraph"&&(!J.text||J.text.trim()===""))Z.pop();else break}else Z=h.inlineTokens(O);return{type:e,raw:Q,attributes:k,content:O,tokens:Z}}}}}},renderMarkdown:(d,f)=>{let h=u(d.attrs||{}),p=s(h),m=p?` {${p}}`:"",g=f.renderChildren(d.content||[],` + +`);return`:::${c}${m} + +${g} + +:::`}}}function w0(n){if(!n.trim())return{};let e={},t=/(\w+)=(?:"([^"]*)"|'([^']*)')/g,r=t.exec(n);for(;r!==null;){let[,i,s,o]=r;e[i]=s||o,r=t.exec(n)}return e}function S0(n){return Object.entries(n).filter(([,e])=>e!=null).map(([e,t])=>`${e}="${t}"`).join(" ")}function v0(n){let{nodeName:e,name:t,getContent:r,parseAttributes:i=w0,serializeAttributes:s=S0,defaultAttributes:o={},selfClosing:l=!1,allowedAttributes:a}=n,c=t||e,u=f=>{if(!a)return f;let h={};return a.forEach(p=>{let m=typeof p=="string"?p:p.name,g=typeof p=="string"?void 0:p.skipIfDefault;if(m in f){let y=f[m];if(g!==void 0&&y===g)return;h[m]=y}}),h},d=c.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return{parseMarkdown:(f,h)=>{let p={...o,...f.attributes};if(l)return h.createNode(e,p);let m=r?r(f):f.content||"";return m?h.createNode(e,p,[h.createTextNode(m)]):h.createNode(e,p,[])},markdownTokenizer:{name:e,level:"inline",start(f){let h=l?new RegExp(`\\[${d}\\s*[^\\]]*\\]`):new RegExp(`\\[${d}\\s*[^\\]]*\\][\\s\\S]*?\\[\\/${d}\\]`),p=f.match(h),m=p==null?void 0:p.index;return m!==void 0?m:-1},tokenize(f,h,p){let m=l?new RegExp(`^\\[${d}\\s*([^\\]]*)\\]`):new RegExp(`^\\[${d}\\s*([^\\]]*)\\]([\\s\\S]*?)\\[\\/${d}\\]`),g=f.match(m);if(!g)return;let y="",b="";if(l){let[,w]=g;b=w}else{let[,w,C]=g;b=w,y=C||""}let k=i(b.trim());return{type:e,raw:g[0],content:y.trim(),attributes:k}}},renderMarkdown:f=>{let h="";r?h=r(f):f.content&&f.content.length>0&&(h=f.content.filter(y=>y.type==="text").map(y=>y.text).join(""));let p=u(f.attrs||{}),m=s(p),g=m?` ${m}`:"";return l?`[${c}${g}]`:`[${c}${g}]${h}[/${c}]`}}}function di(n,e,t){var r,i,s,o;let l=n.split(` +`),a=[],c="",u=0,d=e.baseIndentSize||2;for(;u0)break;if(f.trim()===""){u+=1,c=`${c}${f} +`;continue}else return}let p=e.extractItemData(h),{indentLevel:m,mainContent:g}=p;c=`${c}${f} +`;let y=[g];for(u+=1;uP.trim()!=="");if(M===-1)break;if((((i=(r=l[u+1+M].match(/^(\s*)/))==null?void 0:r[1])==null?void 0:i.length)||0)>m){y.push(C),c=`${c}${C} +`,u+=1;continue}else break}if((((o=(s=C.match(/^(\s*)/))==null?void 0:s[1])==null?void 0:o.length)||0)>m)y.push(C),c=`${c}${C} +`,u+=1;else break}let b,k=y.slice(1);if(k.length>0){let C=k.map(O=>O.slice(m+d)).join(` +`);C.trim()&&(e.customNestedParser?b=e.customNestedParser(C):b=t.blockTokens(C))}let w=e.createToken(p,b);a.push(w)}if(a.length!==0)return{items:a,raw:c}}function Qn(n,e,t,r){if(!n||!Array.isArray(n.content))return"";let i=typeof t=="function"?t(r):t,[s,...o]=n.content,l=e.renderChildren([s]),a=`${i}${l}`;return o&&o.length>0&&o.forEach((c,u)=>{var d,f;let h=(f=(d=e.renderChild)==null?void 0:d.call(e,c,u+1))!=null?f:e.renderChildren([c]);if(h!=null){let p=h.split(` +`).map(m=>m?e.indent(m):e.indent("")).join(` +`);a+=c.type==="paragraph"?` + +${p}`:` +${p}`}}),a}function Sd(n,e){let t={...n};return Zr(n)&&Zr(e)&&Object.keys(e).forEach(r=>{Zr(e[r])&&Zr(n[r])?t[r]=Sd(n[r],e[r]):t[r]=e[r]}),t}function C0(n,e,t={}){let{state:r}=e,{doc:i,tr:s}=r,o=n;i.descendants((l,a)=>{let c=s.mapping.map(a),u=s.mapping.map(a)+l.nodeSize,d=null;if(l.marks.forEach(h=>{if(h!==o)return!1;d=h}),!d)return;let f=!1;if(Object.keys(t).forEach(h=>{t[h]!==d.attrs[h]&&(f=!0)}),f){let h=n.type.create({...n.attrs,...t});s.removeMark(c,u,n.type),s.addMark(c,u,h)}}),s.docChanged&&e.view.dispatch(s)}var fi=class{constructor(n){var e;this.find=n.find,this.handler=n.handler,this.undoable=(e=n.undoable)!=null?e:!0}},M0=(n,e)=>{if(Jo(e))return e.exec(n);let t=e(n);if(!t)return null;let r=[t.text];return r.index=t.index,r.input=n,r.data=t.data,t.replaceWith&&(t.text.includes(t.replaceWith)||console.warn('[tiptap warn]: "inputRuleMatch.replaceWith" must be part of "inputRuleMatch.text".'),r.push(t.replaceWith)),r};function ei(n){var e;let{editor:t,from:r,to:i,text:s,rules:o,plugin:l}=n,{view:a}=t;if(a.composing)return!1;let c=a.state.doc.resolve(r);if(c.parent.type.spec.code||(e=c.nodeBefore||c.nodeAfter)!=null&&e.marks.find(f=>f.type.spec.code))return!1;let u=!1,d=Wy(c)+s;return o.forEach(f=>{if(u)return;let h=M0(d,f.find);if(!h)return;let p=a.state.tr,m=si({state:a.state,transaction:p}),g={from:r-(h[0].length-s.length),to:i},{commands:y,chain:b,can:k}=new oi({editor:t,state:m});f.handler({state:m,range:g,match:h,commands:y,chain:b,can:k})===null||!p.steps.length||(f.undoable&&p.setMeta(l,{transform:p,from:r,to:i,text:s}),a.dispatch(p),u=!0)}),u}function T0(n){let{editor:e,rules:t}=n,r=new R({state:{init(){return null},apply(i,s,o){let l=i.getMeta(r);if(l)return l;let a=i.getMeta("applyInputRules");return a&&setTimeout(()=>{let{text:u}=a;typeof u=="string"?u=u:u=Yo(x.from(u),o.schema);let{from:d}=a,f=d+u.length;ei({editor:e,from:d,to:f,text:u,rules:t,plugin:r})}),i.selectionSet||i.docChanged?null:s}},props:{handleTextInput(i,s,o,l){return ei({editor:e,from:s,to:o,text:l,rules:t,plugin:r})},handleDOMEvents:{compositionend:i=>(setTimeout(()=>{let{$cursor:s}=i.state.selection;s&&ei({editor:e,from:s.pos,to:s.pos,text:"",rules:t,plugin:r})}),!1)},handleKeyDown(i,s){if(s.key!=="Enter")return!1;let{$cursor:o}=i.state.selection;return o?ei({editor:e,from:o.pos,to:o.pos,text:` +`,rules:t,plugin:r}):!1}},isInputRules:!0});return r}var nl=class{constructor(n={}){this.type="extendable",this.parent=null,this.child=null,this.name="",this.config={name:this.name},this.config={...this.config,...n},this.name=this.config.name}get options(){return{...V(E(this,"addOptions",{name:this.name}))}}get storage(){return{...V(E(this,"addStorage",{name:this.name,options:this.options}))}}configure(n={}){let e=this.extend({...this.config,addOptions:()=>Sd(this.options,n)});return e.name=this.name,e.parent=this.parent,this.child=null,e}extend(n={}){let e=new this.constructor({...this.config,...n});return e.parent=this,this.child=e,e.name="name"in n?n.name:e.parent.name,e}},Ee=class vd extends nl{constructor(){super(...arguments),this.type="mark"}static create(e={}){let t=typeof e=="function"?e():e;return new vd(t)}static handleExit({editor:e,mark:t}){let{tr:r}=e.state,i=e.state.selection.$from;if(i.pos===i.end()){let o=i.marks();if(!!!o.find(c=>(c==null?void 0:c.type.name)===t.name))return!1;let a=o.find(c=>(c==null?void 0:c.type.name)===t.name);return a&&r.removeStoredMark(a),r.insertText(" ",i.pos),e.view.dispatch(r),!0}return!1}configure(e){return super.configure(e)}extend(e){let t=typeof e=="function"?e():e;return super.extend(t)}},A0=class{constructor(n){this.find=n.find,this.handler=n.handler}},E0=(n,e,t)=>{if(Jo(e))return[...n.matchAll(e)];let r=e(n,t);return r?r.map(i=>{let s=[i.text];return s.index=i.index,s.input=n,s.data=i.data,i.replaceWith&&(i.text.includes(i.replaceWith)||console.warn('[tiptap warn]: "pasteRuleMatch.replaceWith" must be part of "pasteRuleMatch.text".'),s.push(i.replaceWith)),s}):[]};function N0(n){let{editor:e,state:t,from:r,to:i,rule:s,pasteEvent:o,dropEvent:l}=n,{commands:a,chain:c,can:u}=new oi({editor:e,state:t}),d=[];return t.doc.nodesBetween(r,i,(h,p)=>{var m,g,y,b,k;if((g=(m=h.type)==null?void 0:m.spec)!=null&&g.code||!(h.isText||h.isTextblock||h.isInline))return;let w=(k=(b=(y=h.content)==null?void 0:y.size)!=null?b:h.nodeSize)!=null?k:0,C=Math.max(r,p),O=Math.min(i,p+w);if(C>=O)return;let M=h.isText?h.text||"":h.textBetween(C-p,O-p,void 0,"\uFFFC");E0(M,s.find,o).forEach(B=>{if(B.index===void 0)return;let P=C+B.index+1,Se=P+B[0].length,Te={from:t.tr.mapping.map(P),to:t.tr.mapping.map(Se)},Q=s.handler({state:t,range:Te,match:B,commands:a,chain:c,can:u,pasteEvent:o,dropEvent:l});d.push(Q)})}),d.every(h=>h!==null)}var ti=null,O0=n=>{var e;let t=new ClipboardEvent("paste",{clipboardData:new DataTransfer});return(e=t.clipboardData)==null||e.setData("text/html",n),t};function R0(n){let{editor:e,rules:t}=n,r=null,i=!1,s=!1,o=typeof ClipboardEvent!="undefined"?new ClipboardEvent("paste"):null,l;try{l=typeof DragEvent!="undefined"?new DragEvent("drop"):null}catch(u){l=null}let a=({state:u,from:d,to:f,rule:h,pasteEvt:p})=>{let m=u.tr,g=si({state:u,transaction:m});if(!(!N0({editor:e,state:g,from:Math.max(d-1,0),to:f.b-1,rule:h,pasteEvent:p,dropEvent:l})||!m.steps.length)){try{l=typeof DragEvent!="undefined"?new DragEvent("drop"):null}catch(b){l=null}return o=typeof ClipboardEvent!="undefined"?new ClipboardEvent("paste"):null,m}};return t.map(u=>new R({view(d){let f=p=>{var m;r=(m=d.dom.parentElement)!=null&&m.contains(p.target)?d.dom.parentElement:null,r&&(ti=e)},h=()=>{ti&&(ti=null)};return window.addEventListener("dragstart",f),window.addEventListener("dragend",h),{destroy(){window.removeEventListener("dragstart",f),window.removeEventListener("dragend",h)}}},props:{handleDOMEvents:{drop:(d,f)=>{if(s=r===d.dom.parentElement,l=f,!s){let h=ti;h!=null&&h.isEditable&&setTimeout(()=>{let p=h.state.selection;p&&h.commands.deleteRange({from:p.from,to:p.to})},10)}return!1},paste:(d,f)=>{var h;let p=(h=f.clipboardData)==null?void 0:h.getData("text/html");return o=f,i=!!(p!=null&&p.includes("data-pm-slice")),!1}}},appendTransaction:(d,f,h)=>{let p=d[0],m=p.getMeta("uiEvent")==="paste"&&!i,g=p.getMeta("uiEvent")==="drop"&&!s,y=p.getMeta("applyPasteRules"),b=!!y;if(!m&&!g&&!b)return;if(b){let{text:C}=y;typeof C=="string"?C=C:C=Yo(x.from(C),h.schema);let{from:O}=y,M=O+C.length,D=O0(C);return a({rule:u,state:h,from:O,to:{b:M},pasteEvt:D})}let k=f.doc.content.findDiffStart(h.doc.content),w=f.doc.content.findDiffEnd(h.doc.content);if(!(!g0(k)||!w||k===w.b))return a({rule:u,state:h,from:k,to:w,pasteEvt:o})}}))}var hi=class{constructor(n,e){this.splittableMarks=[],this.nonClearableMarks=[],this.editor=e,this.baseExtensions=n,this.extensions=fd(n),this.schema=By(this.extensions,e),this.setupExtensions()}get commands(){return this.extensions.reduce((n,e)=>{let t={name:e.name,options:e.options,storage:this.editor.extensionStorage[e.name],editor:this.editor,type:qn(e.name,this.schema)},r=E(e,"addCommands",t);return r?{...n,...r()}:n},{})}get plugins(){let{editor:n}=this;return Gn([...this.extensions].reverse()).flatMap(r=>{let i={name:r.name,options:r.options,storage:this.editor.extensionStorage[r.name],editor:n,type:qn(r.name,this.schema)},s=[],o=E(r,"addKeyboardShortcuts",i),l={};if(r.type==="mark"&&E(r,"exitable",i)&&(l.ArrowRight=()=>Ee.handleExit({editor:n,mark:r})),o){let f=Object.fromEntries(Object.entries(o()).map(([h,p])=>[h,()=>p({editor:n})]));l={...l,...f}}let a=ju(l);s.push(a);let c=E(r,"addInputRules",i);if(Zu(r,n.options.enableInputRules)&&c){let f=c();if(f&&f.length){let h=T0({editor:n,rules:f}),p=Array.isArray(h)?h:[h];s.push(...p)}}let u=E(r,"addPasteRules",i);if(Zu(r,n.options.enablePasteRules)&&u){let f=u();if(f&&f.length){let h=R0({editor:n,rules:f});s.push(...h)}}let d=E(r,"addProseMirrorPlugins",i);if(d){let f=d();s.push(...f)}return s})}get attributes(){return dd(this.extensions)}get nodeViews(){let{editor:n}=this,{nodeExtensions:e}=fn(this.extensions);return Object.fromEntries(e.filter(t=>!!E(t,"addNodeView")).map(t=>{let r=this.attributes.filter(a=>a.type===t.name),i={name:t.name,options:t.options,storage:this.editor.extensionStorage[t.name],editor:n,type:U(t.name,this.schema)},s=E(t,"addNodeView",i);if(!s)return[];let o=s();if(!o)return[];let l=(a,c,u,d,f)=>{let h=hn(a,r);return o({node:a,view:c,getPos:u,decorations:d,innerDecorations:f,editor:n,extension:t,HTMLAttributes:h})};return[t.name,l]}))}dispatchTransaction(n){let{editor:e}=this;return Gn([...this.extensions].reverse()).reduceRight((r,i)=>{let s={name:i.name,options:i.options,storage:this.editor.extensionStorage[i.name],editor:e,type:qn(i.name,this.schema)},o=E(i,"dispatchTransaction",s);return o?l=>{o.call(s,{transaction:l,next:r})}:r},n)}transformPastedHTML(n){let{editor:e}=this;return Gn([...this.extensions]).reduce((r,i)=>{let s={name:i.name,options:i.options,storage:this.editor.extensionStorage[i.name],editor:e,type:qn(i.name,this.schema)},o=E(i,"transformPastedHTML",s);return o?(l,a)=>{let c=r(l,a);return o.call(s,c)}:r},n||(r=>r))}get markViews(){let{editor:n}=this,{markExtensions:e}=fn(this.extensions);return Object.fromEntries(e.filter(t=>!!E(t,"addMarkView")).map(t=>{let r=this.attributes.filter(l=>l.type===t.name),i={name:t.name,options:t.options,storage:this.editor.extensionStorage[t.name],editor:n,type:ot(t.name,this.schema)},s=E(t,"addMarkView",i);if(!s)return[];let o=(l,a,c)=>{let u=hn(l,r);return s()({mark:l,view:a,inline:c,editor:n,extension:t,HTMLAttributes:u,updateAttributes:d=>{C0(l,n,d)}})};return[t.name,o]}))}destroy(){this.extensions.forEach(n=>{let e=n;for(;e.parent;){let t=e.parent;t.child===e&&(t.child=null),e=t}}),this.extensions=[],this.baseExtensions=[],this.schema=null,this.editor=null}setupExtensions(){let n=this.extensions;this.editor.extensionStorage=Object.fromEntries(n.map(e=>[e.name,e.storage])),n.forEach(e=>{var t,r;let i={name:e.name,options:e.options,storage:this.editor.extensionStorage[e.name],editor:this.editor,type:qn(e.name,this.schema)};e.type==="mark"&&(((t=V(E(e,"keepOnSplit",i)))==null||t)&&this.splittableMarks.push(e.name),(r=V(E(e,"clearable",i)))==null||r||this.nonClearableMarks.push(e.name));let s=E(e,"onBeforeCreate",i),o=E(e,"onCreate",i),l=E(e,"onUpdate",i),a=E(e,"onSelectionUpdate",i),c=E(e,"onTransaction",i),u=E(e,"onFocus",i),d=E(e,"onBlur",i),f=E(e,"onDestroy",i);s&&this.editor.on("beforeCreate",s),o&&this.editor.on("create",o),l&&this.editor.on("update",l),a&&this.editor.on("selectionUpdate",a),c&&this.editor.on("transaction",c),u&&this.editor.on("focus",u),d&&this.editor.on("blur",d),f&&this.editor.on("destroy",f)})}};hi.resolve=fd;hi.sort=Gn;hi.flatten=Xo;var I0={};Ko(I0,{ClipboardTextSerializer:()=>Md,Commands:()=>Td,Delete:()=>Ad,Drop:()=>Ed,Editable:()=>Nd,FocusEvents:()=>Rd,Keymap:()=>Id,Paste:()=>Dd,Tabindex:()=>Ld,TextDirection:()=>Pd,focusEventsPluginKey:()=>Od});var $=class Cd extends nl{constructor(){super(...arguments),this.type="extension"}static create(e={}){let t=typeof e=="function"?e():e;return new Cd(t)}configure(e){return super.configure(e)}extend(e){let t=typeof e=="function"?e():e;return super.extend(t)}},Md=$.create({name:"clipboardTextSerializer",addOptions(){return{blockSeparator:void 0}},addProseMirrorPlugins(){return[new R({key:new z("clipboardTextSerializer"),props:{clipboardTextSerializer:()=>{let{editor:n}=this,{state:e,schema:t}=n,{doc:r,selection:i}=e,s=pd(t),{blockSeparator:o}=this.options,l={...o!==void 0?{blockSeparator:o}:{},textSerializers:s};return[...i.ranges].sort((c,u)=>c.$from.pos-u.$from.pos).map(({$from:c,$to:u})=>hd(r,{from:c.pos,to:u.pos},l)).join(o!=null?o:` + +`)}}})]}}),Td=$.create({name:"commands",addCommands(){return{...nd}}}),Ad=$.create({name:"delete",onUpdate({transaction:n,appendedTransactions:e}){var t,r,i;let s=()=>{var o,l,a,c;if((c=(a=(l=(o=this.editor.options.coreExtensionOptions)==null?void 0:o.delete)==null?void 0:l.filterTransaction)==null?void 0:a.call(l,n))!=null?c:n.getMeta("y-sync$"))return;let u=Uo(n.before,[n,...e]);Yn(u).forEach(h=>{u.mapping.mapResult(h.oldRange.from).deletedAfter&&u.mapping.mapResult(h.oldRange.to).deletedBefore&&u.before.nodesBetween(h.oldRange.from,h.oldRange.to,(p,m)=>{let g=m+p.nodeSize-2,y=h.oldRange.from<=m&&g<=h.oldRange.to;this.editor.emit("delete",{type:"node",node:p,from:m,to:g,newFrom:u.mapping.map(m),newTo:u.mapping.map(g),deletedRange:h.oldRange,newRange:h.newRange,partial:!y,editor:this.editor,transaction:n,combinedTransform:u})})});let f=u.mapping;u.steps.forEach((h,p)=>{var m,g;if(h instanceof Qe){let y=f.slice(p).map(h.from,-1),b=f.slice(p).map(h.to),k=f.invert().map(y,-1),w=f.invert().map(b),C=y>0?(m=u.doc.nodeAt(y-1))==null?void 0:m.marks.some(M=>M.eq(h.mark)):!1,O=(g=u.doc.nodeAt(b))==null?void 0:g.marks.some(M=>M.eq(h.mark));this.editor.emit("delete",{type:"mark",mark:h.mark,from:h.from,to:h.to,deletedRange:{from:k,to:w},newRange:{from:y,to:b},partial:!!(O||C),editor:this.editor,transaction:n,combinedTransform:u})}})};(i=(r=(t=this.editor.options.coreExtensionOptions)==null?void 0:t.delete)==null?void 0:r.async)==null||i?setTimeout(s,0):s()}}),Ed=$.create({name:"drop",addProseMirrorPlugins(){return[new R({key:new z("tiptapDrop"),props:{handleDrop:(n,e,t,r)=>{this.editor.emit("drop",{editor:this.editor,event:e,slice:t,moved:r})}}})]}}),Nd=$.create({name:"editable",addProseMirrorPlugins(){return[new R({key:new z("editable"),props:{editable:()=>this.editor.options.editable}})]}}),Od=new z("focusEvents"),Rd=$.create({name:"focusEvents",addProseMirrorPlugins(){let{editor:n}=this;return[new R({key:Od,props:{handleDOMEvents:{focus:(e,t)=>{n.isFocused=!0;let r=n.state.tr.setMeta("focus",{event:t}).setMeta("addToHistory",!1);return e.dispatch(r),!1},blur:(e,t)=>{n.isFocused=!1;let r=n.state.tr.setMeta("blur",{event:t}).setMeta("addToHistory",!1);return e.dispatch(r),!1}}}})]}}),Id=$.create({name:"keymap",addKeyboardShortcuts(){let n=()=>this.editor.commands.first(({commands:o})=>[()=>o.undoInputRule(),()=>o.command(({tr:l})=>{let{selection:a,doc:c}=l,{empty:u,$anchor:d}=a,{pos:f,parent:h}=d,p=d.parent.isTextblock&&f>0?l.doc.resolve(f-1):d,m=p.parent.type.spec.isolating,g=d.pos-d.parentOffset,y=m&&p.parent.childCount===1?g===d.pos:N.atStart(c).from===f;return!u||!h.type.isTextblock||h.textContent.length||!y||y&&d.parent.type.name==="paragraph"?!1:o.clearNodes()}),()=>o.deleteSelection(),()=>o.joinBackward(),()=>o.selectNodeBackward()]),e=()=>this.editor.commands.first(({commands:o})=>[()=>o.deleteSelection(),()=>o.deleteCurrentNode(),()=>o.joinForward(),()=>o.selectNodeForward()]),r={Enter:()=>this.editor.commands.first(({commands:o})=>[()=>o.newlineInCode(),()=>o.createParagraphNear(),()=>o.liftEmptyBlock(),()=>o.splitBlock()]),"Mod-Enter":()=>this.editor.commands.exitCode(),Backspace:n,"Mod-Backspace":n,"Shift-Backspace":n,Delete:e,"Mod-Delete":e,"Mod-a":()=>this.editor.commands.selectAll()},i={...r},s={...r,"Ctrl-h":n,"Alt-Backspace":n,"Ctrl-d":e,"Ctrl-Alt-Backspace":e,"Alt-Delete":e,"Alt-d":e,"Ctrl-a":()=>this.editor.commands.selectTextblockStart(),"Ctrl-e":()=>this.editor.commands.selectTextblockEnd()};return ii()||ld()?s:i},addProseMirrorPlugins(){return[new R({key:new z("clearDocument"),appendTransaction:(n,e,t)=>{if(n.some(m=>m.getMeta("composition")))return;let r=n.some(m=>m.docChanged)&&!e.doc.eq(t.doc),i=n.some(m=>m.getMeta("preventClearDocument"));if(!r||i)return;let{empty:s,from:o,to:l}=e.selection,a=N.atStart(e.doc).from,c=N.atEnd(e.doc).to;if(s||!(o===a&&l===c)||!pn(t.doc))return;let f=t.tr,h=si({state:t,transaction:f}),{commands:p}=new oi({editor:this.editor,state:h});if(p.clearNodes(),!!f.steps.length)return f}})]}}),Dd=$.create({name:"paste",addProseMirrorPlugins(){return[new R({key:new z("tiptapPaste"),props:{handlePaste:(n,e,t)=>{this.editor.emit("paste",{editor:this.editor,event:e,slice:t})}}})]}}),Ld=$.create({name:"tabindex",addOptions(){return{value:void 0}},addProseMirrorPlugins(){return[new R({key:new z("tabindex"),props:{attributes:()=>{var n;return!this.editor.isEditable&&this.options.value===void 0?{}:{tabindex:(n=this.options.value)!=null?n:"0"}}}})]}}),Pd=$.create({name:"textDirection",addOptions(){return{direction:void 0}},addGlobalAttributes(){if(!this.options.direction)return[];let{nodeExtensions:n}=fn(this.extensions);return[{types:n.filter(e=>e.name!=="text").map(e=>e.name),attributes:{dir:{default:this.options.direction,parseHTML:e=>{let t=e.getAttribute("dir");return t&&(t==="ltr"||t==="rtl"||t==="auto")?t:this.options.direction},renderHTML:e=>e.dir?{dir:e.dir}:{}}}}]},addProseMirrorPlugins(){return[new R({key:new z("textDirection"),props:{attributes:()=>{let n=this.options.direction;return n?{dir:n}:{}}}})]}}),D0=class Un{constructor(e,t,r=!1,i=null){this.currentNode=null,this.actualDepth=null,this.isBlock=r,this.resolvedPos=e,this.editor=t,this.currentNode=i}get name(){return this.node.type.name}get node(){return this.currentNode||this.resolvedPos.node()}get element(){return this.editor.view.domAtPos(this.pos).node}get depth(){var e;return(e=this.actualDepth)!=null?e:this.resolvedPos.depth}get pos(){return this.resolvedPos.pos}get content(){return this.node.content}set content(e){let t=this.from,r=this.to;if(this.isBlock){if(this.content.size===0){console.error(`You can\u2019t set content on a block node. Tried to set content on ${this.name} at ${this.pos}`);return}t=this.from+1,r=this.to-1}this.editor.commands.insertContentAt({from:t,to:r},e)}get attributes(){return this.node.attrs}get textContent(){return this.node.textContent}get size(){return this.node.nodeSize}get from(){return this.isBlock?this.pos:this.resolvedPos.start(this.resolvedPos.depth)}get range(){return{from:this.from,to:this.to}}get to(){return this.isBlock?this.pos+this.size:this.resolvedPos.end(this.resolvedPos.depth)+(this.node.isText?0:1)}get parent(){if(this.depth===0)return null;let e=this.resolvedPos.start(this.resolvedPos.depth-1),t=this.resolvedPos.doc.resolve(e);return new Un(t,this.editor)}get before(){let e=this.resolvedPos.doc.resolve(this.from-(this.isBlock?1:2));return e.depth!==this.depth&&(e=this.resolvedPos.doc.resolve(this.from-3)),new Un(e,this.editor)}get after(){let e=this.resolvedPos.doc.resolve(this.to+(this.isBlock?2:1));return e.depth!==this.depth&&(e=this.resolvedPos.doc.resolve(this.to+3)),new Un(e,this.editor)}get children(){let e=[];return this.node.content.forEach((t,r)=>{let i=t.isBlock&&!t.isTextblock,s=t.isAtom&&!t.isText,o=t.isInline,l=this.pos+r+(s?0:1);if(l<0||l>this.resolvedPos.doc.nodeSize-2)return;let a=this.resolvedPos.doc.resolve(l);if(!i&&!o&&a.depth<=this.depth)return;let c=new Un(a,this.editor,i,i||o?t:null);i&&(c.actualDepth=this.depth+1),e.push(c)}),e}get firstChild(){return this.children[0]||null}get lastChild(){let e=this.children;return e[e.length-1]||null}closest(e,t={}){let r=null,i=this.parent;for(;i&&!r;){if(i.node.type.name===e)if(Object.keys(t).length>0){let s=i.node.attrs,o=Object.keys(t);for(let l=0;l{r&&i.length>0||(o.node.type.name===e&&s.every(a=>t[a]===o.node.attrs[a])&&i.push(o),!(r&&i.length>0)&&(i=i.concat(o.querySelectorAll(e,t,r))))}),i}setAttribute(e){let{tr:t}=this.editor.state;t.setNodeMarkup(this.from,void 0,{...this.node.attrs,...e}),this.editor.view.dispatch(t)}},L0=`.ProseMirror { + position: relative; +} + +.ProseMirror { + word-wrap: break-word; + white-space: pre-wrap; + white-space: break-spaces; + -webkit-font-variant-ligatures: none; + font-variant-ligatures: none; + font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */ +} + +.ProseMirror [contenteditable="false"] { + white-space: normal; +} + +.ProseMirror [contenteditable="false"] [contenteditable="true"] { + white-space: pre-wrap; +} + +.ProseMirror pre { + white-space: pre-wrap; +} + +img.ProseMirror-separator { + display: inline !important; + border: none !important; + margin: 0 !important; + width: 0 !important; + height: 0 !important; +} + +.ProseMirror-gapcursor { + display: none; + pointer-events: none; + position: absolute; + margin: 0; +} + +.ProseMirror-gapcursor:after { + content: ""; + display: block; + position: absolute; + top: -2px; + width: 20px; + border-top: 1px solid black; + animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; +} + +@keyframes ProseMirror-cursor-blink { + to { + visibility: hidden; + } +} + +.ProseMirror-hideselection *::selection { + background: transparent; +} + +.ProseMirror-hideselection *::-moz-selection { + background: transparent; +} + +.ProseMirror-hideselection * { + caret-color: transparent; +} + +.ProseMirror-focused .ProseMirror-gapcursor { + display: block; +}`,zd=class extends m0{constructor(n={}){super(),this.css=null,this.className="tiptap",this.editorView=null,this.isFocused=!1,this.destroyed=!1,this.isInitialized=!1,this.extensionStorage={},this.instanceId=Math.random().toString(36).slice(2,9),this.options={element:typeof document!="undefined"?document.createElement("div"):null,content:"",injectCSS:!0,injectNonce:void 0,extensions:[],autofocus:!1,editable:!0,textDirection:void 0,editorProps:{},parseOptions:{},coreExtensionOptions:{},enableInputRules:!0,enablePasteRules:!0,enableCoreExtensions:!0,enableContentCheck:!1,emitContentError:!1,onBeforeCreate:()=>null,onCreate:()=>null,onMount:()=>null,onUnmount:()=>null,onUpdate:()=>null,onSelectionUpdate:()=>null,onTransaction:()=>null,onFocus:()=>null,onBlur:()=>null,onDestroy:()=>null,onContentError:({error:r})=>{throw r},onPaste:()=>null,onDrop:()=>null,onDelete:()=>null,enableExtensionDispatchTransaction:!0},this.isCapturingTransaction=!1,this.capturedTransaction=null,this.utils={getUpdatedPosition:Ky,createMappablePosition:Jy},this.setOptions(n),this.createExtensionManager(),this.createCommandManager(),this.createSchema(),this.on("beforeCreate",this.options.onBeforeCreate),this.emit("beforeCreate",{editor:this}),this.on("mount",this.options.onMount),this.on("unmount",this.options.onUnmount),this.on("contentError",this.options.onContentError),this.on("create",this.options.onCreate),this.on("update",this.options.onUpdate),this.on("selectionUpdate",this.options.onSelectionUpdate),this.on("transaction",this.options.onTransaction),this.on("focus",this.options.onFocus),this.on("blur",this.options.onBlur),this.on("destroy",this.options.onDestroy),this.on("drop",({event:r,slice:i,moved:s})=>this.options.onDrop(r,i,s)),this.on("paste",({event:r,slice:i})=>this.options.onPaste(r,i)),this.on("delete",this.options.onDelete);let e=this.createDoc(),t=sd(e,this.options.autofocus);this.editorState=Br.create({doc:e,schema:this.schema,selection:t||void 0}),this.options.element&&this.mount(this.options.element)}mount(n){if(typeof document=="undefined")throw new Error("[tiptap error]: The editor cannot be mounted because there is no 'document' defined in this environment.");this.createView(n),this.emit("mount",{editor:this}),this.css&&!document.head.contains(this.css)&&document.head.appendChild(this.css),window.setTimeout(()=>{this.isDestroyed||(this.options.autofocus!==!1&&this.options.autofocus!==null&&this.commands.focus(this.options.autofocus),this.emit("create",{editor:this}),this.isInitialized=!0)},0)}unmount(){if(this.editorView){let n=this.editorView.dom;n!=null&&n.editor&&delete n.editor,this.editorView.destroy()}if(this.editorView=null,this.isInitialized=!1,this.css&&!document.querySelectorAll(`.${this.className}`).length)try{typeof this.css.remove=="function"?this.css.remove():this.css.parentNode&&this.css.parentNode.removeChild(this.css)}catch(n){console.warn("Failed to remove CSS element:",n)}this.css=null,this.emit("unmount",{editor:this})}get storage(){return this.extensionStorage}get commands(){return this.commandManager.commands}chain(){return this.commandManager.chain()}can(){return this.commandManager.can()}injectCSS(){this.options.injectCSS&&typeof document!="undefined"&&(this.css=Zo(L0,this.options.injectNonce))}setOptions(n={}){this.options={...this.options,...n},!(!this.editorView||!this.state||this.isDestroyed)&&(this.options.editorProps&&this.view.setProps(this.options.editorProps),this.view.updateState(this.state))}setEditable(n,e=!0){this.setOptions({editable:n}),e&&this.emit("update",{editor:this,transaction:this.state.tr,appendedTransactions:[]})}get isEditable(){return this.options.editable&&this.view&&this.view.editable}get view(){return this.editorView?this.editorView:new Proxy({state:this.editorState,updateState:n=>{this.editorState=n},dispatch:n=>{this.dispatchTransaction(n)},composing:!1,dragging:null,editable:!0,isDestroyed:!1},{get:(n,e)=>{if(this.editorView)return this.editorView[e];if(e==="state")return this.editorState;if(e in n)return Reflect.get(n,e);throw new Error(`[tiptap error]: The editor view is not available. Cannot access view['${e}']. The editor may not be mounted yet.`)}})}get state(){return this.editorView&&(this.editorState=this.view.state),this.editorState}registerPlugin(n,e){let t=ud(e)?e(n,[...this.state.plugins]):[...this.state.plugins,n],r=this.state.reconfigure({plugins:t});return this.view.updateState(r),r}unregisterPlugin(n){if(this.isDestroyed)return;let e=this.state.plugins,t=e;if([].concat(n).forEach(i=>{let s=typeof i=="string"?`${i}$`:i.key;t=t.filter(o=>!o.key.startsWith(s))}),e.length===t.length)return;let r=this.state.reconfigure({plugins:t});return this.view.updateState(r),r}createExtensionManager(){var n,e,t,r;let s=[...this.options.enableCoreExtensions?[Nd,Md.configure({blockSeparator:(e=(n=this.options.coreExtensionOptions)==null?void 0:n.clipboardTextSerializer)==null?void 0:e.blockSeparator}),Td,Rd,Id,Ld.configure({value:(r=(t=this.options.coreExtensionOptions)==null?void 0:t.tabindex)==null?void 0:r.value}),Ed,Dd,Ad,Pd.configure({direction:this.options.textDirection})].filter(o=>typeof this.options.enableCoreExtensions=="object"?this.options.enableCoreExtensions[o.name]!==!1:!0):[],...this.options.extensions].filter(o=>["extension","node","mark"].includes(o==null?void 0:o.type));this.extensionManager=new hi(s,this)}createCommandManager(){this.commandManager=new oi({editor:this})}createSchema(){this.schema=this.extensionManager.schema}createDoc(){let n;try{n=Wo(this.options.content,this.schema,this.options.parseOptions,{errorOnInvalidContent:this.options.enableContentCheck})}catch(e){if(!(e instanceof Error)||!["[tiptap error]: Invalid JSON content","[tiptap error]: Invalid HTML content"].includes(e.message))throw e;this.emit("contentError",{editor:this,error:e,disableCollaboration:()=>{"collaboration"in this.storage&&typeof this.storage.collaboration=="object"&&this.storage.collaboration&&(this.storage.collaboration.isDisabled=!0),this.options.extensions=this.options.extensions.filter(t=>t.name!=="collaboration"),this.createExtensionManager()}}),n=Wo(this.options.content,this.schema,this.options.parseOptions,{errorOnInvalidContent:!1})}return n}createView(n){let{editorProps:e,enableExtensionDispatchTransaction:t}=this.options,r=e.dispatchTransaction||this.dispatchTransaction.bind(this),i=t?this.extensionManager.dispatchTransaction(r):r,s=e.transformPastedHTML,o=this.extensionManager.transformPastedHTML(s);this.editorView=new _n(n,{...e,attributes:{role:"textbox",...e==null?void 0:e.attributes},dispatchTransaction:i,transformPastedHTML:o,state:this.editorState,markViews:this.extensionManager.markViews,nodeViews:this.extensionManager.nodeViews});let l=this.state.reconfigure({plugins:this.extensionManager.plugins});this.view.updateState(l),this.prependClass(),this.injectCSS();let a=this.view.dom;a.editor=this}createNodeViews(){this.view.isDestroyed||this.view.setProps({markViews:this.extensionManager.markViews,nodeViews:this.extensionManager.nodeViews})}prependClass(){this.view.dom.className=`${this.className} ${this.view.dom.className}`}captureTransaction(n){this.isCapturingTransaction=!0,n(),this.isCapturingTransaction=!1;let e=this.capturedTransaction;return this.capturedTransaction=null,e}dispatchTransaction(n){if(this.view.isDestroyed)return;if(this.isCapturingTransaction){if(!this.capturedTransaction){this.capturedTransaction=n;return}n.steps.forEach(c=>{var u;return(u=this.capturedTransaction)==null?void 0:u.step(c)});return}let{state:e,transactions:t}=this.state.applyTransaction(n),r=!this.state.selection.eq(e.selection),i=t.includes(n),s=this.state;if(this.emit("beforeTransaction",{editor:this,transaction:n,nextState:e}),!i)return;this.view.updateState(e),this.emit("transaction",{editor:this,transaction:n,appendedTransactions:t.slice(1)}),r&&this.emit("selectionUpdate",{editor:this,transaction:n});let o=t.findLast(c=>c.getMeta("focus")||c.getMeta("blur")),l=o==null?void 0:o.getMeta("focus"),a=o==null?void 0:o.getMeta("blur");l&&this.emit("focus",{editor:this,event:l.event,transaction:o}),a&&this.emit("blur",{editor:this,event:a.event,transaction:o}),!(n.getMeta("preventUpdate")||!t.some(c=>c.docChanged)||s.doc.eq(e.doc))&&this.emit("update",{editor:this,transaction:n,appendedTransactions:t.slice(1)})}getAttributes(n){return Qo(this.state,n)}isActive(n,e){let t=typeof n=="string"?n:null,r=typeof n=="string"?e:n;return jy(this.state,t,r)}getJSON(){return this.state.doc.toJSON()}getHTML(){return Yo(this.state.doc.content,this.schema)}getText(n){let{blockSeparator:e=` + +`,textSerializers:t={}}=n||{};return Hy(this.state.doc,{blockSeparator:e,textSerializers:{...pd(this.schema),...t}})}get isEmpty(){return pn(this.state.doc)}destroy(){this.destroyed||(this.destroyed=!0,this.emit("destroy"),this.unmount(),this.removeAllListeners(),this.extensionManager.destroy(),this.extensionManager=null,this.schema=null,this.commandManager=null,this.extensionStorage={})}get isDestroyed(){var n,e;return(e=(n=this.editorView)==null?void 0:n.isDestroyed)!=null?e:!0}$node(n,e){var t;return((t=this.$doc)==null?void 0:t.querySelector(n,e))||null}$nodes(n,e){var t;return((t=this.$doc)==null?void 0:t.querySelectorAll(n,e))||null}$pos(n){let e=this.state.doc.resolve(n),t=n>0&&e.nodeAfter&&!e.nodeAfter.isText?e.nodeAfter:null;return new D0(e,this,!1,t)}get $doc(){return this.$pos(0)}};function We(n){return new fi({find:n.find,handler:({state:e,range:t,match:r})=>{let i=V(n.getAttributes,void 0,r);if(i===!1||i===null)return null;let{tr:s}=e,o=r[r.length-1],l=r[0];if(o){let a=l.search(/\S/),c=t.from+l.indexOf(o),u=c+o.length;if(ci(t.from,t.to,e.doc).filter(h=>h.mark.type.excluded.find(m=>m===n.type&&m!==h.mark.type)).filter(h=>h.to>c).length)return null;ut.from&&s.delete(t.from+a,c);let f=t.from+a+o.length;s.addMark(t.from+a,f,n.type.create(i||{})),s.removeStoredMark(n.type)}},undoable:n.undoable})}function pi(n){return new fi({find:n.find,handler:({state:e,range:t,match:r})=>{let i=V(n.getAttributes,void 0,r)||{},{tr:s}=e,o=t.from,l=t.to,a=n.type.create(i);if(r[1]){let c=r[0].lastIndexOf(r[1]),u=o+c;u>l?u=l:l=u+r[1].length;let d=r[0][r[0].length-1];s.insertText(d,o+r[0].length-1),s.replaceWith(u,l,a)}else if(r[0]){let c=n.type.isInline?o:o-1;s.insert(c,n.type.create(i)).delete(s.mapping.map(o),s.mapping.map(l))}s.scrollIntoView()},undoable:n.undoable})}function Zn(n){return new fi({find:n.find,handler:({state:e,range:t,match:r})=>{let i=e.doc.resolve(t.from),s=V(n.getAttributes,void 0,r)||{};if(!i.node(-1).canReplaceWith(i.index(-1),i.indexAfter(-1),n.type))return null;e.tr.delete(t.from,t.to).setBlockType(t.from,t.from,n.type,s)},undoable:n.undoable})}function je(n){return new fi({find:n.find,handler:({state:e,range:t,match:r,chain:i})=>{let s=V(n.getAttributes,void 0,r)||{},o=e.tr.delete(t.from,t.to),a=o.doc.resolve(t.from).blockRange(),c=a&&nn(a,n.type,s);if(!c)return null;if(o.wrap(a,c),n.keepMarks&&n.editor){let{selection:d,storedMarks:f}=e,{splittableMarks:h}=n.editor.extensionManager,p=f||d.$to.parentOffset&&d.$from.marks();if(p){let m=p.filter(g=>h.includes(g.type.name));o.ensureMarks(m)}}if(n.keepAttributes){let d=n.type.name==="bulletList"||n.type.name==="orderedList"?"listItem":"taskList";i().updateAttributes(d,s).run()}let u=o.doc.resolve(t.from-1).nodeBefore;u&&u.type===n.type&&Oe(o.doc,t.from-1)&&(!n.joinPredicate||n.joinPredicate(r,u))&&o.join(t.from-1)},undoable:n.undoable})}var P0=n=>"touches"in n,Bd=class{constructor(n){this.directions=["bottom-left","bottom-right","top-left","top-right"],this.minSize={height:8,width:8},this.preserveAspectRatio=!1,this.classNames={container:"",wrapper:"",handle:"",resizing:""},this.initialWidth=0,this.initialHeight=0,this.aspectRatio=1,this.isResizing=!1,this.activeHandle=null,this.startX=0,this.startY=0,this.startWidth=0,this.startHeight=0,this.isShiftKeyPressed=!1,this.lastEditableState=void 0,this.handleMap=new Map,this.handleMouseMove=l=>{if(!this.isResizing||!this.activeHandle)return;let a=l.clientX-this.startX,c=l.clientY-this.startY;this.handleResize(a,c)},this.handleTouchMove=l=>{if(!this.isResizing||!this.activeHandle)return;let a=l.touches[0];if(!a)return;let c=a.clientX-this.startX,u=a.clientY-this.startY;this.handleResize(c,u)},this.handleMouseUp=()=>{if(!this.isResizing)return;let l=this.element.offsetWidth,a=this.element.offsetHeight;this.onCommit(l,a),this.isResizing=!1,this.activeHandle=null,this.container.dataset.resizeState="false",this.classNames.resizing&&this.container.classList.remove(this.classNames.resizing),document.removeEventListener("mousemove",this.handleMouseMove),document.removeEventListener("mouseup",this.handleMouseUp),document.removeEventListener("keydown",this.handleKeyDown),document.removeEventListener("keyup",this.handleKeyUp)},this.handleKeyDown=l=>{l.key==="Shift"&&(this.isShiftKeyPressed=!0)},this.handleKeyUp=l=>{l.key==="Shift"&&(this.isShiftKeyPressed=!1)};var e,t,r,i,s,o;this.node=n.node,this.editor=n.editor,this.element=n.element,this.element.draggable=!1,this.contentElement=n.contentElement,this.getPos=n.getPos,this.onResize=n.onResize,this.onCommit=n.onCommit,this.onUpdate=n.onUpdate,(e=n.options)!=null&&e.min&&(this.minSize={...this.minSize,...n.options.min}),(t=n.options)!=null&&t.max&&(this.maxSize=n.options.max),(r=n==null?void 0:n.options)!=null&&r.directions&&(this.directions=n.options.directions),(i=n.options)!=null&&i.preserveAspectRatio&&(this.preserveAspectRatio=n.options.preserveAspectRatio),(s=n.options)!=null&&s.className&&(this.classNames={container:n.options.className.container||"",wrapper:n.options.className.wrapper||"",handle:n.options.className.handle||"",resizing:n.options.className.resizing||""}),(o=n.options)!=null&&o.createCustomHandle&&(this.createCustomHandle=n.options.createCustomHandle),this.wrapper=this.createWrapper(),this.container=this.createContainer(),this.applyInitialSize(),this.attachHandles(),this.editor.on("update",this.handleEditorUpdate.bind(this))}get dom(){return this.container}get contentDOM(){var n;return(n=this.contentElement)!=null?n:null}handleEditorUpdate(){let n=this.editor.isEditable;n!==this.lastEditableState&&(this.lastEditableState=n,n?n&&this.handleMap.size===0&&this.attachHandles():this.removeHandles())}update(n,e,t){return n.type!==this.node.type?!1:(this.node=n,this.onUpdate?this.onUpdate(n,e,t):!0)}destroy(){this.isResizing&&(this.container.dataset.resizeState="false",this.classNames.resizing&&this.container.classList.remove(this.classNames.resizing),document.removeEventListener("mousemove",this.handleMouseMove),document.removeEventListener("mouseup",this.handleMouseUp),document.removeEventListener("keydown",this.handleKeyDown),document.removeEventListener("keyup",this.handleKeyUp),this.isResizing=!1,this.activeHandle=null),this.editor.off("update",this.handleEditorUpdate.bind(this)),this.container.remove()}createContainer(){let n=document.createElement("div");return n.dataset.resizeContainer="",n.dataset.node=this.node.type.name,n.style.display=this.node.type.isInline?"inline-flex":"flex",this.classNames.container&&(n.className=this.classNames.container),n.appendChild(this.wrapper),n}createWrapper(){let n=document.createElement("div");return n.style.position="relative",n.style.display="block",n.dataset.resizeWrapper="",this.classNames.wrapper&&(n.className=this.classNames.wrapper),n.appendChild(this.element),n}createHandle(n){let e=document.createElement("div");return e.dataset.resizeHandle=n,e.style.position="absolute",this.classNames.handle&&(e.className=this.classNames.handle),e}positionHandle(n,e){let t=e.includes("top"),r=e.includes("bottom"),i=e.includes("left"),s=e.includes("right");t&&(n.style.top="0"),r&&(n.style.bottom="0"),i&&(n.style.left="0"),s&&(n.style.right="0"),(e==="top"||e==="bottom")&&(n.style.left="0",n.style.right="0"),(e==="left"||e==="right")&&(n.style.top="0",n.style.bottom="0")}attachHandles(){this.directions.forEach(n=>{let e;this.createCustomHandle?e=this.createCustomHandle(n):e=this.createHandle(n),e instanceof HTMLElement||(console.warn(`[ResizableNodeView] createCustomHandle("${n}") did not return an HTMLElement. Falling back to default handle.`),e=this.createHandle(n)),this.createCustomHandle||this.positionHandle(e,n),e.addEventListener("mousedown",t=>this.handleResizeStart(t,n)),e.addEventListener("touchstart",t=>this.handleResizeStart(t,n)),this.handleMap.set(n,e),this.wrapper.appendChild(e)})}removeHandles(){this.handleMap.forEach(n=>n.remove()),this.handleMap.clear()}applyInitialSize(){let n=this.node.attrs.width,e=this.node.attrs.height;n?(this.element.style.width=`${n}px`,this.initialWidth=n):this.initialWidth=this.element.offsetWidth,e?(this.element.style.height=`${e}px`,this.initialHeight=e):this.initialHeight=this.element.offsetHeight,this.initialWidth>0&&this.initialHeight>0&&(this.aspectRatio=this.initialWidth/this.initialHeight)}handleResizeStart(n,e){n.preventDefault(),n.stopPropagation(),this.isResizing=!0,this.activeHandle=e,P0(n)?(this.startX=n.touches[0].clientX,this.startY=n.touches[0].clientY):(this.startX=n.clientX,this.startY=n.clientY),this.startWidth=this.element.offsetWidth,this.startHeight=this.element.offsetHeight,this.startWidth>0&&this.startHeight>0&&(this.aspectRatio=this.startWidth/this.startHeight);let t=this.getPos();this.container.dataset.resizeState="true",this.classNames.resizing&&this.container.classList.add(this.classNames.resizing),document.addEventListener("mousemove",this.handleMouseMove),document.addEventListener("touchmove",this.handleTouchMove),document.addEventListener("mouseup",this.handleMouseUp),document.addEventListener("keydown",this.handleKeyDown),document.addEventListener("keyup",this.handleKeyUp)}handleResize(n,e){if(!this.activeHandle)return;let t=this.preserveAspectRatio||this.isShiftKeyPressed,{width:r,height:i}=this.calculateNewDimensions(this.activeHandle,n,e),s=this.applyConstraints(r,i,t);this.element.style.width=`${s.width}px`,this.element.style.height=`${s.height}px`,this.onResize&&this.onResize(s.width,s.height)}calculateNewDimensions(n,e,t){let r=this.startWidth,i=this.startHeight,s=n.includes("right"),o=n.includes("left"),l=n.includes("bottom"),a=n.includes("top");return s?r=this.startWidth+e:o&&(r=this.startWidth-e),l?i=this.startHeight+t:a&&(i=this.startHeight-t),(n==="right"||n==="left")&&(r=this.startWidth+(s?e:-e)),(n==="top"||n==="bottom")&&(i=this.startHeight+(l?t:-t)),this.preserveAspectRatio||this.isShiftKeyPressed?this.applyAspectRatio(r,i,n):{width:r,height:i}}applyConstraints(n,e,t){var r,i,s,o;if(!t){let c=Math.max(this.minSize.width,n),u=Math.max(this.minSize.height,e);return(r=this.maxSize)!=null&&r.width&&(c=Math.min(this.maxSize.width,c)),(i=this.maxSize)!=null&&i.height&&(u=Math.min(this.maxSize.height,u)),{width:c,height:u}}let l=n,a=e;return lthis.maxSize.width&&(l=this.maxSize.width,a=l/this.aspectRatio),(o=this.maxSize)!=null&&o.height&&a>this.maxSize.height&&(a=this.maxSize.height,l=a*this.aspectRatio),{width:l,height:a}}applyAspectRatio(n,e,t){let r=t==="left"||t==="right",i=t==="top"||t==="bottom";return r?{width:n,height:n/this.aspectRatio}:i?{width:e*this.aspectRatio,height:e}:{width:n,height:n/this.aspectRatio}}};var _=class Fd extends nl{constructor(){super(...arguments),this.type="node"}static create(e={}){let t=typeof e=="function"?e():e;return new Fd(t)}configure(e){return super.configure(e)}extend(e){let t=typeof e=="function"?e():e;return super.extend(t)}};function Pe(n){return new A0({find:n.find,handler:({state:e,range:t,match:r,pasteEvent:i})=>{let s=V(n.getAttributes,void 0,r,i);if(s===!1||s===null)return null;let{tr:o}=e,l=r[r.length-1],a=r[0],c=t.to;if(l){let u=a.search(/\S/),d=t.from+a.indexOf(l),f=d+l.length;if(ci(t.from,t.to,e.doc).filter(m=>m.mark.type.excluded.find(y=>y===n.type&&y!==m.mark.type)).filter(m=>m.to>d).length)return null;ft.from&&o.delete(t.from+u,d),c=t.from+u+l.length,o.addMark(t.from+u,c,n.type.create(s||{})),r.index!==void 0&&r.input!==void 0&&r.index+r[0].length>=r.input.length||o.removeStoredMark(n.type)}}})}var mn=(n,e)=>{if(n==="slot")return 0;if(n instanceof Function)return n(e);let{children:t,...r}=e!=null?e:{};if(n==="svg")throw new Error("SVG elements are not supported in the JSX syntax, use the array syntax instead");return[n,r,t]};function Gd(n,e,t){for(let r=0;;r++){if(r==n.childCount||r==e.childCount)return n.childCount==e.childCount?null:t;let i=n.child(r),s=e.child(r);if(i==s){t+=i.nodeSize;continue}if(!i.sameMarkup(s))return t;if(i.isText&&i.text!=s.text){let o=i.text,l=s.text,a=0;for(;o[a]==l[a];a++)t++;return a&&a0&&f>0&&c[d-1]==u[f-1];)d--,f--,t--,r--;return d&&f&&d=56320&&n<57344}function Qd(n){return n>=55296&&n<56320}var he=class xe{constructor(e,t){if(this.content=e,this.size=t||0,t==null)for(let r=0;re&&r(a,i+l,s||null,o)!==!1&&a.content.size){let u=l+1;a.nodesBetween(Math.max(0,e-u),Math.min(a.content.size,t-u),r,i+u)}l=c}}descendants(e){this.nodesBetween(0,this.size,e)}textBetween(e,t,r,i){let s="",o=!0;return this.nodesBetween(e,t,(l,a)=>{let c=l.isText?l.text.slice(Math.max(e,a)-a,t-a):l.isLeaf?i?typeof i=="function"?i(l):i:l.type.spec.leafText?l.type.spec.leafText(l):"":"";l.isBlock&&(l.isLeaf&&c||l.isTextblock)&&r&&(o?o=!1:s+=r),s+=c},0),s}append(e){if(!e.size)return this;if(!this.size)return e;let t=this.lastChild,r=e.firstChild,i=this.content.slice(),s=0;for(t.isText&&t.sameMarkup(r)&&(i[i.length-1]=t.withText(t.text+r.text),s=1);se)for(let s=0,o=0;oe&&((ot)&&(l.isText?l=l.cut(Math.max(0,e-o),Math.min(l.text.length,t-o)):l=l.cut(Math.max(0,e-o-1),Math.min(l.content.size,t-o-1))),r.push(l),i+=l.nodeSize),o=a}return new xe(r,i)}cutByIndex(e,t){return e==t?xe.empty:e==0&&t==this.content.length?this:new xe(this.content.slice(e,t))}replaceChild(e,t){let r=this.content[e];if(r==t)return this;let i=this.content.slice(),s=this.size+t.nodeSize-r.nodeSize;return i[e]=t,new xe(i,s)}addToStart(e){return new xe([e].concat(this.content),this.size+e.nodeSize)}addToEnd(e){return new xe(this.content.concat(e),this.size+e.nodeSize)}eq(e){if(this.content.length!=e.content.length)return!1;for(let t=0;tthis.size||e<0)throw new RangeError(`Position ${e} outside of fragment (${this})`);for(let t=0,r=0;;t++){let i=this.child(t),s=r+i.nodeSize;if(s>=e)return s==e?mi(t+1,s):mi(t,r);r=s}}toString(){return"<"+this.toStringInner()+">"}toStringInner(){return this.content.join(", ")}toJSON(){return this.content.length?this.content.map(e=>e.toJSON()):null}static fromJSON(e,t){if(!t)return xe.empty;if(!Array.isArray(t))throw new RangeError("Invalid input for Fragment.fromJSON");return xe.fromArray(t.map(e.nodeFromJSON))}static fromArray(e){if(!e.length)return xe.empty;let t,r=0;for(let i=0;ithis.type.rank&&(t||(t=e.slice(0,i)),t.push(this),r=!0),t&&t.push(s)}}return t||(t=e.slice()),r||t.push(this),t}removeFromSet(e){for(let t=0;tr.type.rank-i.type.rank),t}};_t.none=[];var ar=class extends Error{},G=class gn{constructor(e,t,r){this.content=e,this.openStart=t,this.openEnd=r}get size(){return this.content.size-this.openStart-this.openEnd}insertAt(e,t){let r=ef(this.content,e+this.openStart,t,this.openStart+1,this.openEnd+1);return r&&new gn(r,this.openStart,this.openEnd)}removeBetween(e,t){return new gn(Zd(this.content,e+this.openStart,t+this.openStart),this.openStart,this.openEnd)}eq(e){return this.content.eq(e.content)&&this.openStart==e.openStart&&this.openEnd==e.openEnd}toString(){return this.content+"("+this.openStart+","+this.openEnd+")"}toJSON(){if(!this.content.size)return null;let e={content:this.content.toJSON()};return this.openStart>0&&(e.openStart=this.openStart),this.openEnd>0&&(e.openEnd=this.openEnd),e}static fromJSON(e,t){if(!t)return gn.empty;let r=t.openStart||0,i=t.openEnd||0;if(typeof r!="number"||typeof i!="number")throw new RangeError("Invalid input for Slice.fromJSON");return new gn(he.fromJSON(e,t.content),r,i)}static maxOpen(e,t=!0){let r=0,i=0;for(let s=e.firstChild;s&&!s.isLeaf&&(t||!s.type.spec.isolating);s=s.firstChild)r++;for(let s=e.lastChild;s&&!s.isLeaf&&(t||!s.type.spec.isolating);s=s.lastChild)i++;return new gn(e,r,i)}};G.empty=new G(he.empty,0,0);function Zd(n,e,t){let{index:r,offset:i}=n.findIndex(e),s=n.maybeChild(r),{index:o,offset:l}=n.findIndex(t);if(i==e||s.isText){if(l!=t&&!n.child(o).isText)throw new RangeError("Removing non-flat range");return n.cut(0,e).append(n.cut(t))}if(r!=o)throw new RangeError("Removing non-flat range");return n.replaceChild(r,s.copy(Zd(s.content,e-i-1,t-i-1)))}function ef(n,e,t,r,i,s){let{index:o,offset:l}=n.findIndex(e),a=n.maybeChild(o);if(l==e||a.isText)return s&&r<=0&&i<=0&&!s.canReplace(o,o,t)?null:n.cut(0,e).append(t).append(n.cut(e));let c=ef(a.content,e-l-1,t,o==0?r-1:0,o==n.childCount-1?i-1:0,a);return c&&n.replaceChild(o,a.copy(c))}function z0(n,e,t){if(t.openStart>n.depth)throw new ar("Inserted content deeper than insertion position");if(n.depth-t.openStart!=e.depth-t.openEnd)throw new ar("Inconsistent open depths");return tf(n,e,t,0)}function tf(n,e,t,r){let i=n.index(r),s=n.node(r);if(i==e.index(r)&&r=0&&n.isText&&n.sameMarkup(e[t])?e[t]=n.withText(e[t].text+n.text):e.push(n)}function or(n,e,t,r){let i=(e||n).node(t),s=0,o=e?e.index(t):i.childCount;n&&(s=n.index(t),n.depth>t?s++:n.textOffset&&(Vt(n.nodeAfter,r),s++));for(let l=s;li&&ll(n,e,i+1),o=r.depth>i&&ll(t,r,i+1),l=[];return or(null,n,i,l),s&&o&&e.index(i)==t.index(i)?(nf(s,o),Vt(Wt(s,rf(n,e,t,r,i+1)),l)):(s&&Vt(Wt(s,Si(n,e,i+1)),l),or(e,t,i,l),o&&Vt(Wt(o,Si(t,r,i+1)),l)),or(r,null,i,l),new he(l)}function Si(n,e,t){let r=[];if(or(null,n,t,r),n.depth>t){let i=ll(n,e,t+1);Vt(Wt(i,Si(n,e,t+1)),r)}return or(e,null,t,r),new he(r)}function B0(n,e){let t=e.depth-n.openStart,i=e.node(t).copy(n.content);for(let s=t-1;s>=0;s--)i=e.node(s).copy(he.from(i));return{start:i.resolveNoCache(n.openStart+t),end:i.resolveNoCache(i.content.size-n.openEnd-t)}}var Hd=class al{constructor(e,t,r){this.pos=e,this.path=t,this.parentOffset=r,this.depth=t.length/3-1}resolveDepth(e){return e==null?this.depth:e<0?this.depth+e:e}get parent(){return this.node(this.depth)}get doc(){return this.node(0)}node(e){return this.path[this.resolveDepth(e)*3]}index(e){return this.path[this.resolveDepth(e)*3+1]}indexAfter(e){return e=this.resolveDepth(e),this.index(e)+(e==this.depth&&!this.textOffset?0:1)}start(e){return e=this.resolveDepth(e),e==0?0:this.path[e*3-1]+1}end(e){return e=this.resolveDepth(e),this.start(e)+this.node(e).content.size}before(e){if(e=this.resolveDepth(e),!e)throw new RangeError("There is no position before the top-level node");return e==this.depth+1?this.pos:this.path[e*3-1]}after(e){if(e=this.resolveDepth(e),!e)throw new RangeError("There is no position after the top-level node");return e==this.depth+1?this.pos:this.path[e*3-1]+this.path[e*3].nodeSize}get textOffset(){return this.pos-this.path[this.path.length-1]}get nodeAfter(){let e=this.parent,t=this.index(this.depth);if(t==e.childCount)return null;let r=this.pos-this.path[this.path.length-1],i=e.child(t);return r?e.child(t).cut(r):i}get nodeBefore(){let e=this.index(this.depth),t=this.pos-this.path[this.path.length-1];return t?this.parent.child(e).cut(0,t):e==0?null:this.parent.child(e-1)}posAtIndex(e,t){t=this.resolveDepth(t);let r=this.path[t*3],i=t==0?0:this.path[t*3-1]+1;for(let s=0;s0;t--)if(this.start(t)<=e&&this.end(t)>=e)return t;return 0}blockRange(e=this,t){if(e.pos=0;r--)if(e.pos<=this.end(r)&&(!t||t(this.node(r))))return new $0(this,e,r);return null}sameParent(e){return this.pos-this.parentOffset==e.pos-e.parentOffset}max(e){return e.pos>this.pos?e:this}min(e){return e.pos=0&&t<=e.content.size))throw new RangeError("Position "+t+" out of range");let r=[],i=0,s=t;for(let o=e;;){let{index:l,offset:a}=o.content.findIndex(s),c=s-a;if(r.push(o,l,i+a),!c||(o=o.child(l),o.isText))break;s=c-1,i+=a+1}return new al(t,r,s)}static resolveCached(e,t){let r=$d.get(e);if(r)for(let s=0;se&&this.nodesBetween(e,t,s=>(r.isInSet(s.marks)&&(i=!0),!i)),i}get isBlock(){return this.type.isBlock}get isTextblock(){return this.type.isTextblock}get inlineContent(){return this.type.inlineContent}get isInline(){return this.type.isInline}get isText(){return this.type.isText}get isLeaf(){return this.type.isLeaf}get isAtom(){return this.type.isAtom}toString(){if(this.type.spec.toDebugString)return this.type.spec.toDebugString(this);let e=this.type.name;return this.content.size&&(e+="("+this.content.toStringInner()+")"),W0(this.marks,e)}contentMatchAt(e){let t=this.type.contentMatch.matchFragment(this.content,0,e);if(!t)throw new Error("Called contentMatchAt on a node with invalid content");return t}canReplace(e,t,r=he.empty,i=0,s=r.childCount){let o=this.contentMatchAt(e).matchFragment(r,i,s),l=o&&o.matchFragment(this.content,t);if(!l||!l.validEnd)return!1;for(let a=i;at.type.name)}`);this.content.forEach(t=>t.check())}toJSON(){let e={type:this.type.name};for(let t in this.attrs){e.attrs=this.attrs;break}return this.content.size&&(e.content=this.content.toJSON()),this.marks.length&&(e.marks=this.marks.map(t=>t.toJSON())),e}static fromJSON(e,t){if(!t)throw new RangeError("Invalid input for Node.fromJSON");let r;if(t.marks){if(!Array.isArray(t.marks))throw new RangeError("Invalid mark data for Node.fromJSON");r=t.marks.map(e.markFromJSON)}if(t.type=="text"){if(typeof t.text!="string")throw new RangeError("Invalid text node in JSON");return e.text(t.text,r)}let i=he.fromJSON(e,t.content),s=e.nodeType(t.type).create(t.attrs,i,r);return s.type.checkAttrs(s.attrs),s}};V0.prototype.text=void 0;function W0(n,e){for(let t=n.length-1;t>=0;t--)e=n[t].type.name+"("+e+")";return e}var ul=class sf{constructor(e){this.validEnd=e,this.next=[],this.wrapCache=[]}static parse(e,t){let r=new j0(e,t);if(r.next==null)return sf.empty;let i=of(r);r.next&&r.err("Unexpected trailing text");let s=Y0(X0(i));return Q0(s,r),s}matchType(e){for(let t=0;tc.createAndFill()));for(let c=0;c=this.next.length)throw new RangeError(`There's no ${e}th edge in this content match`);return this.next[e]}toString(){let e=[];function t(r){e.push(r);for(let i=0;i{let s=i+(r.validEnd?"*":" ")+" ";for(let o=0;o"+e.indexOf(r.next[o].next);return s}).join(` +`)}};ul.empty=new ul(!0);var j0=class{constructor(n,e){this.string=n,this.nodeTypes=e,this.inline=null,this.pos=0,this.tokens=n.split(/\s*(?=\b|\W|$)/),this.tokens[this.tokens.length-1]==""&&this.tokens.pop(),this.tokens[0]==""&&this.tokens.shift()}get next(){return this.tokens[this.pos]}eat(n){return this.next==n&&(this.pos++||!0)}err(n){throw new SyntaxError(n+" (in content expression '"+this.string+"')")}};function of(n){let e=[];do e.push(K0(n));while(n.eat("|"));return e.length==1?e[0]:{type:"choice",exprs:e}}function K0(n){let e=[];do e.push(J0(n));while(n.next&&n.next!=")"&&n.next!="|");return e.length==1?e[0]:{type:"seq",exprs:e}}function J0(n){let e=G0(n);for(;;)if(n.eat("+"))e={type:"plus",expr:e};else if(n.eat("*"))e={type:"star",expr:e};else if(n.eat("?"))e={type:"opt",expr:e};else if(n.eat("{"))e=q0(n,e);else break;return e}function _d(n){/\D/.test(n.next)&&n.err("Expected number, got '"+n.next+"'");let e=Number(n.next);return n.pos++,e}function q0(n,e){let t=_d(n),r=t;return n.eat(",")&&(n.next!="}"?r=_d(n):r=-1),n.eat("}")||n.err("Unclosed braced range"),{type:"range",min:t,max:r,expr:e}}function U0(n,e){let t=n.nodeTypes,r=t[e];if(r)return[r];let i=[];for(let s in t){let o=t[s];o.isInGroup(e)&&i.push(o)}return i.length==0&&n.err("No node type or group '"+e+"' found"),i}function G0(n){if(n.eat("(")){let e=of(n);return n.eat(")")||n.err("Missing closing paren"),e}else if(/\W/.test(n.next))n.err("Unexpected token '"+n.next+"'");else{let e=U0(n,n.next).map(t=>(n.inline==null?n.inline=t.isInline:n.inline!=t.isInline&&n.err("Mixing inline and block content"),{type:"name",value:t}));return n.pos++,e.length==1?e[0]:{type:"choice",exprs:e}}}function X0(n){let e=[[]];return i(s(n,0),t()),e;function t(){return e.push([])-1}function r(o,l,a){let c={term:a,to:l};return e[o].push(c),c}function i(o,l){o.forEach(a=>a.to=l)}function s(o,l){if(o.type=="choice")return o.exprs.reduce((a,c)=>a.concat(s(c,l)),[]);if(o.type=="seq")for(let a=0;;a++){let c=s(o.exprs[a],l);if(a==o.exprs.length-1)return c;i(c,l=t())}else if(o.type=="star"){let a=t();return r(l,a),i(s(o.expr,a),a),[r(a)]}else if(o.type=="plus"){let a=t();return i(s(o.expr,l),a),i(s(o.expr,a),a),[r(a)]}else{if(o.type=="opt")return[r(l)].concat(s(o.expr,l));if(o.type=="range"){let a=l;for(let c=0;c{n[o].forEach(({term:l,to:a})=>{if(!l)return;let c;for(let u=0;u{c||i.push([l,c=[]]),c.indexOf(u)==-1&&c.push(u)})})});let s=e[r.join(",")]=new ul(r.indexOf(n.length-1)>-1);for(let o=0;o0}get deletedBefore(){return(this.delInfo&(uf|yi))>0}get deletedAfter(){return(this.delInfo&(df|yi))>0}get deletedAcross(){return(this.delInfo&yi)>0}},jt=class yn{constructor(e,t=!1){if(this.ranges=e,this.inverted=t,!e.length&&yn.empty)return yn.empty}recover(e){let t=0,r=Wd(e);if(!this.inverted)for(let i=0;ie)break;let c=this.ranges[l+s],u=this.ranges[l+o],d=a+c;if(e<=d){let f=c?e==a?-1:e==d?1:t:t,h=a+i+(f<0?0:u);if(r)return h;let p=e==(t<0?a:d)?null:Z0(l/3,e-a),m=e==a?df:e==d?uf:yi;return(t<0?e!=a:e!=d)&&(m|=ff),new jd(h,m,p)}i+=u-c}return r?e+i:new jd(e+i,0,null)}touches(e,t){let r=0,i=Wd(t),s=this.inverted?2:1,o=this.inverted?1:2;for(let l=0;le)break;let c=this.ranges[l+s],u=a+c;if(e<=u&&l==i*3)return!0;r+=this.ranges[l+o]-c}return!1}forEach(e){let t=this.inverted?2:1,r=this.inverted?1:2;for(let i=0,s=0;i!o.isAtom||!l.type.allowsMarkType(this.mark.type)?o:o.mark(this.mark.addToSet(o.marks)),i),t.openStart,t.openEnd);return we.fromReplace(e,this.from,this.to,s)}invert(){return new pf(this.from,this.to,this.mark)}map(e){let t=e.mapResult(this.from,1),r=e.mapResult(this.to,-1);return t.deleted&&r.deleted||t.pos>=r.pos?null:new tr(t.pos,r.pos,this.mark)}merge(e){return e instanceof tr&&e.mark.eq(this.mark)&&this.from<=e.to&&this.to>=e.from?new tr(Math.min(this.from,e.from),Math.max(this.to,e.to),this.mark):null}toJSON(){return{stepType:"addMark",mark:this.mark.toJSON(),from:this.from,to:this.to}}static fromJSON(e,t){if(typeof t.from!="number"||typeof t.to!="number")throw new RangeError("Invalid input for AddMarkStep.fromJSON");return new tr(t.from,t.to,e.markFromJSON(t.mark))}};pe.jsonID("addMark",hf);var pf=class nr extends pe{constructor(e,t,r){super(),this.from=e,this.to=t,this.mark=r}apply(e){let t=e.slice(this.from,this.to),r=new G(pl(t.content,i=>i.mark(this.mark.removeFromSet(i.marks)),e),t.openStart,t.openEnd);return we.fromReplace(e,this.from,this.to,r)}invert(){return new hf(this.from,this.to,this.mark)}map(e){let t=e.mapResult(this.from,1),r=e.mapResult(this.to,-1);return t.deleted&&r.deleted||t.pos>=r.pos?null:new nr(t.pos,r.pos,this.mark)}merge(e){return e instanceof nr&&e.mark.eq(this.mark)&&this.from<=e.to&&this.to>=e.from?new nr(Math.min(this.from,e.from),Math.max(this.to,e.to),this.mark):null}toJSON(){return{stepType:"removeMark",mark:this.mark.toJSON(),from:this.from,to:this.to}}static fromJSON(e,t){if(typeof t.from!="number"||typeof t.to!="number")throw new RangeError("Invalid input for RemoveMarkStep.fromJSON");return new nr(t.from,t.to,e.markFromJSON(t.mark))}};pe.jsonID("removeMark",pf);var mf=class rr extends pe{constructor(e,t){super(),this.pos=e,this.mark=t}apply(e){let t=e.nodeAt(this.pos);if(!t)return we.fail("No node at mark step's position");let r=t.type.create(t.attrs,null,this.mark.addToSet(t.marks));return we.fromReplace(e,this.pos,this.pos+1,new G(he.from(r),0,t.isLeaf?0:1))}invert(e){let t=e.nodeAt(this.pos);if(t){let r=this.mark.addToSet(t.marks);if(r.length==t.marks.length){for(let i=0;ir.pos?null:new bi(t.pos,r.pos,i,s,this.slice,this.insert,this.structure)}toJSON(){let e={stepType:"replaceAround",from:this.from,to:this.to,gapFrom:this.gapFrom,gapTo:this.gapTo,insert:this.insert};return this.slice.size&&(e.slice=this.slice.toJSON()),this.structure&&(e.structure=!0),e}static fromJSON(e,t){if(typeof t.from!="number"||typeof t.to!="number"||typeof t.gapFrom!="number"||typeof t.gapTo!="number"||typeof t.insert!="number")throw new RangeError("Invalid input for ReplaceAroundStep.fromJSON");return new bi(t.from,t.to,t.gapFrom,t.gapTo,G.fromJSON(e,t.slice),t.insert,!!t.structure)}};pe.jsonID("replaceAround",yf);function fl(n,e,t){let r=n.resolve(e),i=t-e,s=r.depth;for(;i>0&&s>0&&r.indexAfter(s)==r.node(s).childCount;)s--,i--;if(i>0){let o=r.node(s).maybeChild(r.indexAfter(s));for(;i>0;){if(!o||o.isLeaf)return!0;o=o.firstChild,i--}}return!1}var tb=class ki extends pe{constructor(e,t,r){super(),this.pos=e,this.attr=t,this.value=r}apply(e){let t=e.nodeAt(this.pos);if(!t)return we.fail("No node at attribute step's position");let r=Object.create(null);for(let s in t.attrs)r[s]=t.attrs[s];r[this.attr]=this.value;let i=t.type.create(r,null,t.marks);return we.fromReplace(e,this.pos,this.pos+1,new G(he.from(i),0,t.isLeaf?0:1))}getMap(){return jt.empty}invert(e){return new ki(this.pos,this.attr,e.nodeAt(this.pos).attrs[this.attr])}map(e){let t=e.mapResult(this.pos,1);return t.deletedAfter?null:new ki(t.pos,this.attr,this.value)}toJSON(){return{stepType:"attr",pos:this.pos,attr:this.attr,value:this.value}}static fromJSON(e,t){if(typeof t.pos!="number"||typeof t.attr!="string")throw new RangeError("Invalid input for AttrStep.fromJSON");return new ki(t.pos,t.attr,t.value)}};pe.jsonID("attr",tb);var nb=class hl extends pe{constructor(e,t){super(),this.attr=e,this.value=t}apply(e){let t=Object.create(null);for(let i in e.attrs)t[i]=e.attrs[i];t[this.attr]=this.value;let r=e.type.create(t,e.content,e.marks);return we.ok(r)}getMap(){return jt.empty}invert(e){return new hl(this.attr,e.attrs[this.attr])}map(e){return this}toJSON(){return{stepType:"docAttr",attr:this.attr,value:this.value}}static fromJSON(e,t){if(typeof t.attr!="string")throw new RangeError("Invalid input for DocAttrStep.fromJSON");return new hl(t.attr,t.value)}};pe.jsonID("docAttr",nb);var cr=class extends Error{};cr=function n(e){let t=Error.call(this,e);return t.__proto__=n.prototype,t};cr.prototype=Object.create(Error.prototype);cr.prototype.constructor=cr;cr.prototype.name="TransformError";var sl=Object.create(null),se=class{constructor(n,e,t){this.$anchor=n,this.$head=e,this.ranges=t||[new rb(n.min(e),n.max(e))]}get anchor(){return this.$anchor.pos}get head(){return this.$head.pos}get from(){return this.$from.pos}get to(){return this.$to.pos}get $from(){return this.ranges[0].$from}get $to(){return this.ranges[0].$to}get empty(){let n=this.ranges;for(let e=0;e=0;i--){let s=e<0?bn(n.node(0),n.node(i),n.before(i+1),n.index(i),e,t):bn(n.node(0),n.node(i),n.after(i+1),n.index(i)+1,e,t);if(s)return s}return null}static near(n,e=1){return this.findFrom(n,e)||this.findFrom(n,-e)||new lr(n.node(0))}static atStart(n){return bn(n,n,0,0,1)||new lr(n)}static atEnd(n){return bn(n,n,n.content.size,n.childCount,-1)||new lr(n)}static fromJSON(n,e){if(!e||!e.type)throw new RangeError("Invalid input for Selection.fromJSON");let t=sl[e.type];if(!t)throw new RangeError(`No selection type ${e.type} defined`);return t.fromJSON(n,e)}static jsonID(n,e){if(n in sl)throw new RangeError("Duplicate use of selection JSON ID "+n);return sl[n]=e,e.prototype.jsonID=n,e}getBookmark(){return kn.between(this.$anchor,this.$head).getBookmark()}};se.prototype.visible=!0;var rb=class{constructor(n,e){this.$from=n,this.$to=e}},Kd=!1;function Jd(n){!Kd&&!n.parent.inlineContent&&(Kd=!0,console.warn("TextSelection endpoint not pointing into a node with inline content ("+n.parent.type.name+")"))}var kn=class ir extends se{constructor(e,t=e){Jd(e),Jd(t),super(e,t)}get $cursor(){return this.$anchor.pos==this.$head.pos?this.$head:null}map(e,t){let r=e.resolve(t.map(this.head));if(!r.parent.inlineContent)return se.near(r);let i=e.resolve(t.map(this.anchor));return new ir(i.parent.inlineContent?i:r,r)}replace(e,t=G.empty){if(super.replace(e,t),t==G.empty){let r=this.$from.marksAcross(this.$to);r&&e.ensureMarks(r)}}eq(e){return e instanceof ir&&e.anchor==this.anchor&&e.head==this.head}getBookmark(){return new bf(this.anchor,this.head)}toJSON(){return{type:"text",anchor:this.anchor,head:this.head}}static fromJSON(e,t){if(typeof t.anchor!="number"||typeof t.head!="number")throw new RangeError("Invalid input for TextSelection.fromJSON");return new ir(e.resolve(t.anchor),e.resolve(t.head))}static create(e,t,r=t){let i=e.resolve(t);return new this(i,r==t?i:e.resolve(r))}static between(e,t,r){let i=e.pos-t.pos;if((!r||i)&&(r=i>=0?1:-1),!t.parent.inlineContent){let s=se.findFrom(t,r,!0)||se.findFrom(t,-r,!0);if(s)t=s.$head;else return se.near(t,r)}return e.parent.inlineContent||(i==0?e=t:(e=(se.findFrom(e,-r,!0)||se.findFrom(e,r,!0)).$anchor,e.pos0?0:1);i>0?o=0;o+=i){let l=e.child(o);if(l.isAtom){if(!s&&xn.isSelectable(l))return xn.create(n,t-(i<0?l.nodeSize:0))}else{let a=bn(n,l,t+i,i<0?l.childCount:0,i,s);if(a)return a}t+=l.nodeSize*i}return null}function qd(n,e,t){let r=n.steps.length-1;if(r{o==null&&(o=u)}),n.setSelection(se.near(n.doc.resolve(o),t))}function Ud(n,e){return!e||!n?n:n.bind(e)}var gi=class{constructor(n,e,t){this.name=n,this.init=Ud(e.init,t),this.apply=Ud(e.apply,t)}},o1=[new gi("doc",{init(n){return n.doc||n.schema.topNodeType.createAndFill()},apply(n){return n.doc}}),new gi("selection",{init(n,e){return n.selection||se.atStart(e.doc)},apply(n){return n.selection}}),new gi("storedMarks",{init(n){return n.storedMarks||null},apply(n,e,t,r){return r.selection.$cursor?n.storedMarks:null}}),new gi("scrollToSelection",{init(){return 0},apply(n,e){return n.scrolledIntoView?e+1:e}})],ob=(n,e)=>{var t;let{state:r,view:i}=n,{selection:s}=r;if(!s.empty)return!1;let{$from:o}=s;if(o.parentOffset!==0)return!1;let l=o.depth-1,a=o.node(l),c=o.index(l);if(c===0)return!1;if(a.type===e)return n.commands.lift(e.name);let u=a.child(c-1);if(u.type!==e||!((t=u.lastChild)!=null&&t.isTextblock))return!1;let d=o.before(),h=d-1-1,{tr:p}=r;return p.delete(d,o.after()).insert(h,o.parent.content),p.setSelection(kn.create(p.doc,h)),i.dispatch(p.scrollIntoView()),!0},lb=/^\s*>\s$/,wf=_.create({name:"blockquote",addOptions(){return{HTMLAttributes:{}}},content:"block+",group:"block",defining:!0,parseHTML(){return[{tag:"blockquote"}]},renderHTML({HTMLAttributes:n}){return mn("blockquote",{...I(this.options.HTMLAttributes,n),children:mn("slot",{})})},parseMarkdown:(n,e)=>{var t;let r=(t=e.parseBlockChildren)!=null?t:e.parseChildren;return e.createNode("blockquote",void 0,r(n.tokens||[]))},renderMarkdown:(n,e)=>{if(!n.content)return"";let t=">",r=[];return n.content.forEach((i,s)=>{var o,l;let u=((l=(o=e.renderChild)==null?void 0:o.call(e,i,s))!=null?l:e.renderChildren([i])).split(` +`).map(d=>d.trim()===""?t:`${t} ${d}`);r.push(u.join(` +`))}),r.join(` +${t} +`)},addCommands(){return{setBlockquote:()=>({commands:n})=>n.wrapIn(this.name),toggleBlockquote:()=>({commands:n})=>n.toggleWrap(this.name),unsetBlockquote:()=>({commands:n})=>n.lift(this.name)}},addKeyboardShortcuts(){return{"Mod-Shift-b":()=>this.editor.commands.toggleBlockquote(),Backspace:()=>ob(this.editor,this.type)}},addInputRules(){return[je({find:lb,type:this.type})]}});var ab=/(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))$/,cb=/(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))/g,ub=/(?:^|\s)(__(?!\s+__)((?:[^_]+))__(?!\s+__))$/,db=/(?:^|\s)(__(?!\s+__)((?:[^_]+))__(?!\s+__))/g,Sf=Ee.create({name:"bold",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:"strong"},{tag:"b",getAttrs:n=>n.style.fontWeight!=="normal"&&null},{style:"font-weight=400",clearMark:n=>n.type.name===this.name},{style:"font-weight",getAttrs:n=>/^(bold(er)?|[5-9]\d{2,})$/.test(n)&&null}]},renderHTML({HTMLAttributes:n}){return mn("strong",{...I(this.options.HTMLAttributes,n),children:mn("slot",{})})},markdownTokenName:"strong",parseMarkdown:(n,e)=>e.applyMark("bold",e.parseInline(n.tokens||[])),markdownOptions:{htmlReopen:{open:"",close:""}},renderMarkdown:(n,e)=>`**${e.renderChildren(n)}**`,addCommands(){return{setBold:()=>({commands:n})=>n.setMark(this.name),toggleBold:()=>({commands:n})=>n.toggleMark(this.name),unsetBold:()=>({commands:n})=>n.unsetMark(this.name)}},addKeyboardShortcuts(){return{"Mod-b":()=>this.editor.commands.toggleBold(),"Mod-B":()=>this.editor.commands.toggleBold()}},addInputRules(){return[We({find:ab,type:this.type}),We({find:ub,type:this.type})]},addPasteRules(){return[Pe({find:cb,type:this.type}),Pe({find:db,type:this.type})]}});var fb=n=>{let e=/`([^`]+)`(?!`)$/.exec(n);return!e||e.index>0&&n[e.index-1]==="`"?null:{index:e.index,text:e[0],replaceWith:e[1]}},hb=n=>{let e=/`([^`]+)`(?!`)/g,t=[],r;for(;(r=e.exec(n))!==null;)r.index>0&&n[r.index-1]==="`"||t.push({index:r.index,text:r[0],replaceWith:r[1]});return t},vf=Ee.create({name:"code",addOptions(){return{HTMLAttributes:{}}},excludes:"_",code:!0,exitable:!0,parseHTML(){return[{tag:"code"}]},renderHTML({HTMLAttributes:n}){return["code",I(this.options.HTMLAttributes,n),0]},markdownTokenName:"codespan",parseMarkdown:(n,e)=>e.applyMark("code",[{type:"text",text:n.text||""}]),renderMarkdown:(n,e)=>n.content?`\`${e.renderChildren(n.content)}\``:"",addCommands(){return{setCode:()=>({commands:n})=>n.setMark(this.name),toggleCode:()=>({commands:n})=>n.toggleMark(this.name),unsetCode:()=>({commands:n})=>n.unsetMark(this.name)}},addKeyboardShortcuts(){return{"Mod-e":()=>this.editor.commands.toggleCode()}},addInputRules(){return[We({find:fb,type:this.type})]},addPasteRules(){return[Pe({find:hb,type:this.type})]}});var gl=4,pb=/^```([a-z]+)?[\s\n]$/,mb=/^~~~([a-z]+)?[\s\n]$/,Cf=_.create({name:"codeBlock",addOptions(){return{languageClassPrefix:"language-",exitOnTripleEnter:!0,exitOnArrowDown:!0,defaultLanguage:null,enableTabIndentation:!1,tabSize:gl,HTMLAttributes:{}}},content:"text*",marks:"",group:"block",code:!0,defining:!0,addAttributes(){return{language:{default:this.options.defaultLanguage,parseHTML:n=>{var e;let{languageClassPrefix:t}=this.options;if(!t)return null;let s=[...((e=n.firstElementChild)==null?void 0:e.classList)||[]].filter(o=>o.startsWith(t)).map(o=>o.replace(t,""))[0];return s||null},rendered:!1}}},parseHTML(){return[{tag:"pre",preserveWhitespace:"full"}]},renderHTML({node:n,HTMLAttributes:e}){return["pre",I(this.options.HTMLAttributes,e),["code",{class:n.attrs.language?this.options.languageClassPrefix+n.attrs.language:null},0]]},markdownTokenName:"code",parseMarkdown:(n,e)=>{var t,r;return((t=n.raw)==null?void 0:t.startsWith("```"))===!1&&((r=n.raw)==null?void 0:r.startsWith("~~~"))===!1&&n.codeBlockStyle!=="indented"?[]:e.createNode("codeBlock",{language:n.lang||null},n.text?[e.createTextNode(n.text)]:[])},renderMarkdown:(n,e)=>{var t;let r="",i=((t=n.attrs)==null?void 0:t.language)||"";return n.content?r=[`\`\`\`${i}`,e.renderChildren(n.content),"```"].join(` +`):r=`\`\`\`${i} + +\`\`\``,r},addCommands(){return{setCodeBlock:n=>({commands:e})=>e.setNode(this.name,n),toggleCodeBlock:n=>({commands:e})=>e.toggleNode(this.name,"paragraph",n)}},addKeyboardShortcuts(){return{"Mod-Alt-c":()=>this.editor.commands.toggleCodeBlock(),Backspace:()=>{let{empty:n,$anchor:e}=this.editor.state.selection,t=e.pos===1;return!n||e.parent.type.name!==this.name?!1:t||!e.parent.textContent.length?this.editor.commands.clearNodes():!1},Tab:({editor:n})=>{var e;if(!this.options.enableTabIndentation)return!1;let t=(e=this.options.tabSize)!=null?e:gl,{state:r}=n,{selection:i}=r,{$from:s,empty:o}=i;if(s.parent.type!==this.type)return!1;let l=" ".repeat(t);return o?n.commands.insertContent(l):n.commands.command(({tr:a})=>{let{from:c,to:u}=i,h=r.doc.textBetween(c,u,` +`,` +`).split(` +`).map(p=>l+p).join(` +`);return a.replaceWith(c,u,r.schema.text(h)),!0})},"Shift-Tab":({editor:n})=>{var e;if(!this.options.enableTabIndentation)return!1;let t=(e=this.options.tabSize)!=null?e:gl,{state:r}=n,{selection:i}=r,{$from:s,empty:o}=i;return s.parent.type!==this.type?!1:o?n.commands.command(({tr:l})=>{var a;let{pos:c}=s,u=s.start(),d=s.end(),h=r.doc.textBetween(u,d,` +`,` +`).split(` +`),p=0,m=0,g=c-u;for(let O=0;O=g){p=O;break}m+=h[O].length+1}let b=((a=h[p].match(/^ */))==null?void 0:a[0])||"",k=Math.min(b.length,t);if(k===0)return!0;let w=u;for(let O=0;O{let{from:a,to:c}=i,f=r.doc.textBetween(a,c,` +`,` +`).split(` +`).map(h=>{var p;let m=((p=h.match(/^ */))==null?void 0:p[0])||"",g=Math.min(m.length,t);return h.slice(g)}).join(` +`);return l.replaceWith(a,c,r.schema.text(f)),!0})},Enter:({editor:n})=>{if(!this.options.exitOnTripleEnter)return!1;let{state:e}=n,{selection:t}=e,{$from:r,empty:i}=t;if(!i||r.parent.type!==this.type)return!1;let s=r.parentOffset===r.parent.nodeSize-2,o=r.parent.textContent.endsWith(` + +`);return!s||!o?!1:n.chain().command(({tr:l})=>(l.delete(r.pos-2,r.pos),!0)).exitCode().run()},ArrowDown:({editor:n})=>{if(!this.options.exitOnArrowDown)return!1;let{state:e}=n,{selection:t,doc:r}=e,{$from:i,empty:s}=t;if(!s||i.parent.type!==this.type||!(i.parentOffset===i.parent.nodeSize-2))return!1;let l=i.after();return l===void 0?!1:r.nodeAt(l)?n.commands.command(({tr:c})=>(c.setSelection(N.near(r.resolve(l))),!0)):n.commands.exitCode()}}},addInputRules(){return[Zn({find:pb,type:this.type,getAttributes:n=>({language:n[1]})}),Zn({find:mb,type:this.type,getAttributes:n=>({language:n[1]})})]},addProseMirrorPlugins(){return[new R({key:new z("codeBlockVSCodeHandler"),props:{handlePaste:(n,e)=>{if(!e.clipboardData||this.editor.isActive(this.type.name))return!1;let t=e.clipboardData.getData("text/plain"),r=e.clipboardData.getData("vscode-editor-data"),i=r?JSON.parse(r):void 0,s=i==null?void 0:i.mode;if(!t||!s)return!1;let{tr:o,schema:l}=n.state,a=l.text(t.replace(/\r\n?/g,` +`));return o.replaceSelectionWith(this.type.create({language:s},a)),o.selection.$from.parent.type!==this.type&&o.setSelection(T.near(o.doc.resolve(Math.max(0,o.selection.from-2)))),o.setMeta("paste",!0),n.dispatch(o),!0}}})]}});var Mf=_.create({name:"doc",topNode:!0,content:"block+",renderMarkdown:(n,e)=>n.content?e.renderChildren(n.content,` + +`):""});var Tf=_.create({name:"hardBreak",markdownTokenName:"br",addOptions(){return{keepMarks:!0,HTMLAttributes:{}}},inline:!0,group:"inline",selectable:!1,linebreakReplacement:!0,parseHTML(){return[{tag:"br"}]},renderHTML({HTMLAttributes:n}){return["br",I(this.options.HTMLAttributes,n)]},renderText(){return` +`},renderMarkdown:()=>` +`,parseMarkdown:()=>({type:"hardBreak"}),addCommands(){return{setHardBreak:()=>({commands:n,chain:e,state:t,editor:r})=>n.first([()=>n.exitCode(),()=>n.command(()=>{let{selection:i,storedMarks:s}=t;if(i.$from.parent.type.spec.isolating)return!1;let{keepMarks:o}=this.options,{splittableMarks:l}=r.extensionManager,a=s||i.$to.parentOffset&&i.$from.marks();return e().insertContent({type:this.name}).command(({tr:c,dispatch:u})=>{if(u&&a&&o){let d=a.filter(f=>l.includes(f.type.name));c.ensureMarks(d)}return!0}).run()})])}},addKeyboardShortcuts(){return{"Mod-Enter":()=>this.editor.commands.setHardBreak(),"Shift-Enter":()=>this.editor.commands.setHardBreak()}}});var Af=_.create({name:"heading",addOptions(){return{levels:[1,2,3,4,5,6],HTMLAttributes:{}}},content:"inline*",group:"block",defining:!0,addAttributes(){return{level:{default:1,rendered:!1}}},parseHTML(){return this.options.levels.map(n=>({tag:`h${n}`,attrs:{level:n}}))},renderHTML({node:n,HTMLAttributes:e}){return[`h${this.options.levels.includes(n.attrs.level)?n.attrs.level:this.options.levels[0]}`,I(this.options.HTMLAttributes,e),0]},parseMarkdown:(n,e)=>e.createNode("heading",{level:n.depth||1},e.parseInline(n.tokens||[])),renderMarkdown:(n,e)=>{var t;let r=(t=n.attrs)!=null&&t.level?parseInt(n.attrs.level,10):1,i="#".repeat(r);return n.content?`${i} ${e.renderChildren(n.content)}`:""},addCommands(){return{setHeading:n=>({commands:e})=>this.options.levels.includes(n.level)?e.setNode(this.name,n):!1,toggleHeading:n=>({commands:e})=>this.options.levels.includes(n.level)?e.toggleNode(this.name,"paragraph",n):!1}},addKeyboardShortcuts(){return this.options.levels.reduce((n,e)=>({...n,[`Mod-Alt-${e}`]:()=>this.editor.commands.toggleHeading({level:e})}),{})},addInputRules(){return this.options.levels.map(n=>Zn({find:new RegExp(`^(#{${Math.min(...this.options.levels)},${n}})\\s$`),type:this.type,getAttributes:{level:n}}))}});var Ef=_.create({name:"horizontalRule",addOptions(){return{HTMLAttributes:{},nextNodeType:"paragraph"}},group:"block",parseHTML(){return[{tag:"hr"}]},renderHTML({HTMLAttributes:n}){return["hr",I(this.options.HTMLAttributes,n)]},markdownTokenName:"hr",parseMarkdown:(n,e)=>e.createNode("horizontalRule"),renderMarkdown:()=>"---",addCommands(){return{setHorizontalRule:()=>({chain:n,state:e})=>{if(!wd(e,e.schema.nodes[this.name]))return!1;let{selection:t}=e,{$to:r}=t,i=n();return ui(t)?i.insertContentAt(r.pos,{type:this.name}):i.insertContent({type:this.name}),i.command(({state:s,tr:o,dispatch:l})=>{if(l){let{$to:a}=o.selection,c=a.end();if(a.nodeAfter)a.nodeAfter.isTextblock?o.setSelection(T.create(o.doc,a.pos+1)):a.nodeAfter.isBlock?o.setSelection(A.create(o.doc,a.pos)):o.setSelection(T.create(o.doc,a.pos));else{let u=s.schema.nodes[this.options.nextNodeType]||a.parent.type.contentMatch.defaultType,d=u==null?void 0:u.create();d&&(o.insert(c,d),o.setSelection(T.create(o.doc,c+1)))}o.scrollIntoView()}return!0}).run()}}},addInputRules(){return[pi({find:/^(?:---|—-|___\s|\*\*\*\s)$/,type:this.type})]}});var gb=/(?:^|\s)(\*(?!\s+\*)((?:[^*]+))\*(?!\s+\*))$/,yb=/(?:^|\s)(\*(?!\s+\*)((?:[^*]+))\*(?!\s+\*))/g,bb=/(?:^|\s)(_(?!\s+_)((?:[^_]+))_(?!\s+_))$/,kb=/(?:^|\s)(_(?!\s+_)((?:[^_]+))_(?!\s+_))/g,Nf=Ee.create({name:"italic",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:"em"},{tag:"i",getAttrs:n=>n.style.fontStyle!=="normal"&&null},{style:"font-style=normal",clearMark:n=>n.type.name===this.name},{style:"font-style=italic"}]},renderHTML({HTMLAttributes:n}){return["em",I(this.options.HTMLAttributes,n),0]},addCommands(){return{setItalic:()=>({commands:n})=>n.setMark(this.name),toggleItalic:()=>({commands:n})=>n.toggleMark(this.name),unsetItalic:()=>({commands:n})=>n.unsetMark(this.name)}},markdownTokenName:"em",parseMarkdown:(n,e)=>e.applyMark("italic",e.parseInline(n.tokens||[])),markdownOptions:{htmlReopen:{open:"",close:""}},renderMarkdown:(n,e)=>`*${e.renderChildren(n)}*`,addKeyboardShortcuts(){return{"Mod-i":()=>this.editor.commands.toggleItalic(),"Mod-I":()=>this.editor.commands.toggleItalic()}},addInputRules(){return[We({find:gb,type:this.type}),We({find:bb,type:this.type})]},addPasteRules(){return[Pe({find:yb,type:this.type}),Pe({find:kb,type:this.type})]}});var xb="aaa1rp3bb0ott3vie4c1le2ogado5udhabi7c0ademy5centure6ountant0s9o1tor4d0s1ult4e0g1ro2tna4f0l1rica5g0akhan5ency5i0g1rbus3force5tel5kdn3l0ibaba4pay4lfinanz6state5y2sace3tom5m0azon4ericanexpress7family11x2fam3ica3sterdam8nalytics7droid5quan4z2o0l2partments8p0le4q0uarelle8r0ab1mco4chi3my2pa2t0e3s0da2ia2sociates9t0hleta5torney7u0ction5di0ble3o3spost5thor3o0s4w0s2x0a2z0ure5ba0by2idu3namex4d1k2r0celona5laycard4s5efoot5gains6seball5ketball8uhaus5yern5b0c1t1va3cg1n2d1e0ats2uty4er2rlin4st0buy5t2f1g1h0arti5i0ble3d1ke2ng0o3o1z2j1lack0friday9ockbuster8g1omberg7ue3m0s1w2n0pparibas9o0ats3ehringer8fa2m1nd2o0k0ing5sch2tik2on4t1utique6x2r0adesco6idgestone9oadway5ker3ther5ussels7s1t1uild0ers6siness6y1zz3v1w1y1z0h3ca0b1fe2l0l1vinklein9m0era3p2non3petown5ital0one8r0avan4ds2e0er0s4s2sa1e1h1ino4t0ering5holic7ba1n1re3c1d1enter4o1rn3f0a1d2g1h0anel2nel4rity4se2t2eap3intai5ristmas6ome4urch5i0priani6rcle4sco3tadel4i0c2y3k1l0aims4eaning6ick2nic1que6othing5ud3ub0med6m1n1o0ach3des3ffee4llege4ogne5m0mbank4unity6pany2re3uter5sec4ndos3struction8ulting7tact3ractors9oking4l1p2rsica5untry4pon0s4rses6pa2r0edit0card4union9icket5own3s1uise0s6u0isinella9v1w1x1y0mru3ou3z2dad1nce3ta1e1ing3sun4y2clk3ds2e0al0er2s3gree4livery5l1oitte5ta3mocrat6ntal2ist5si0gn4v2hl2iamonds6et2gital5rect0ory7scount3ver5h2y2j1k1m1np2o0cs1tor4g1mains5t1wnload7rive4tv2ubai3pont4rban5vag2r2z2earth3t2c0o2deka3u0cation8e1g1mail3erck5nergy4gineer0ing9terprises10pson4quipment8r0icsson6ni3s0q1tate5t1u0rovision8s2vents5xchange6pert3osed4ress5traspace10fage2il1rwinds6th3mily4n0s2rm0ers5shion4t3edex3edback6rrari3ero6i0delity5o2lm2nal1nce1ial7re0stone6mdale6sh0ing5t0ness6j1k1lickr3ghts4r2orist4wers5y2m1o0o0d1tball6rd1ex2sale4um3undation8x2r0ee1senius7l1ogans4ntier7tr2ujitsu5n0d2rniture7tbol5yi3ga0l0lery3o1up4me0s3p1rden4y2b0iz3d0n2e0a1nt0ing5orge5f1g0ee3h1i0ft0s3ves2ing5l0ass3e1obal2o4m0ail3bh2o1x2n1odaddy5ld0point6f2odyear5g0le4p1t1v2p1q1r0ainger5phics5tis4een3ipe3ocery4up4s1t1u0cci3ge2ide2tars5ru3w1y2hair2mburg5ngout5us3bo2dfc0bank7ealth0care8lp1sinki6re1mes5iphop4samitsu7tachi5v2k0t2m1n1ockey4ldings5iday5medepot5goods5s0ense7nda3rse3spital5t0ing5t0els3mail5use3w2r1sbc3t1u0ghes5yatt3undai7ibm2cbc2e1u2d1e0ee3fm2kano4l1m0amat4db2mo0bilien9n0c1dustries8finiti5o2g1k1stitute6urance4e4t0ernational10uit4vestments10o1piranga7q1r0ish4s0maili5t0anbul7t0au2v3jaguar4va3cb2e0ep2tzt3welry6io2ll2m0p2nj2o0bs1urg4t1y2p0morgan6rs3uegos4niper7kaufen5ddi3e0rryhotels6properties14fh2g1h1i0a1ds2m1ndle4tchen5wi3m1n1oeln3matsu5sher5p0mg2n2r0d1ed3uokgroup8w1y0oto4z2la0caixa5mborghini8er3nd0rover6xess5salle5t0ino3robe5w0yer5b1c1ds2ease3clerc5frak4gal2o2xus4gbt3i0dl2fe0insurance9style7ghting6ke2lly3mited4o2ncoln4k2ve1ing5k1lc1p2oan0s3cker3us3l1ndon4tte1o3ve3pl0financial11r1s1t0d0a3u0ndbeck6xe1ury5v1y2ma0drid4if1son4keup4n0agement7go3p1rket0ing3s4riott5shalls7ttel5ba2c0kinsey7d1e0d0ia3et2lbourne7me1orial6n0u2rck0msd7g1h1iami3crosoft7l1ni1t2t0subishi9k1l0b1s2m0a2n1o0bi0le4da2e1i1m1nash3ey2ster5rmon3tgage6scow4to0rcycles9v0ie4p1q1r1s0d2t0n1r2u0seum3ic4v1w1x1y1z2na0b1goya4me2vy3ba2c1e0c1t0bank4flix4work5ustar5w0s2xt0direct7us4f0l2g0o2hk2i0co2ke1on3nja3ssan1y5l1o0kia3rton4w0ruz3tv4p1r0a1w2tt2u1yc2z2obi1server7ffice5kinawa6layan0group9lo3m0ega4ne1g1l0ine5oo2pen3racle3nge4g0anic5igins6saka4tsuka4t2vh3pa0ge2nasonic7ris2s1tners4s1y3y2ccw3e0t2f0izer5g1h0armacy6d1ilips5one2to0graphy6s4ysio5ics1tet2ures6d1n0g1k2oneer5zza4k1l0ace2y0station9umbing5s3m1n0c2ohl2ker3litie5rn2st3r0axi3ess3ime3o0d0uctions8f1gressive8mo2perties3y5tection8u0dential9s1t1ub2w0c2y2qa1pon3uebec3st5racing4dio4e0ad1lestate6tor2y4cipes5d0umbrella9hab3ise0n3t2liance6n0t0als5pair3ort3ublican8st0aurant8view0s5xroth6ich0ardli6oh3l1o1p2o0cks3deo3gers4om3s0vp3u0gby3hr2n2w0e2yukyu6sa0arland6fe0ty4kura4le1on3msclub4ung5ndvik0coromant12ofi4p1rl2s1ve2xo3b0i1s2c0b1haeffler7midt4olarships8ol3ule3warz5ience5ot3d1e0arch3t2cure1ity6ek2lect4ner3rvices6ven3w1x0y3fr2g1h0angrila6rp3ell3ia1ksha5oes2p0ping5uji3w3i0lk2na1gles5te3j1k0i0n2y0pe4l0ing4m0art3ile4n0cf3o0ccer3ial4ftbank4ware6hu2lar2utions7ng1y2y2pa0ce3ort2t3r0l2s1t0ada2ples4r1tebank4farm7c0group6ockholm6rage3e3ream4udio2y3yle4u0cks3pplies3y2ort5rf1gery5zuki5v1watch4iss4x1y0dney4stems6z2tab1ipei4lk2obao4rget4tamotors6r2too4x0i3c0i2d0k2eam2ch0nology8l1masek5nnis4va3f1g1h0d1eater2re6iaa2ckets5enda4ps2res2ol4j0maxx4x2k0maxx5l1m0all4n1o0day3kyo3ols3p1ray3shiba5tal3urs3wn2yota3s3r0ade1ing4ining5vel0ers0insurance16ust3v2t1ube2i1nes3shu4v0s2w1z2ua1bank3s2g1k1nicom3versity8o2ol2ps2s1y1z2va0cations7na1guard7c1e0gas3ntures6risign5m\xF6gensberater2ung14sicherung10t2g1i0ajes4deo3g1king4llas4n1p1rgin4sa1ion4va1o3laanderen9n1odka3lvo3te1ing3o2yage5u2wales2mart4ter4ng0gou5tch0es6eather0channel12bcam3er2site5d0ding5ibo2r3f1hoswho6ien2ki2lliamhill9n0dows4e1ners6me2oodside6rk0s2ld3w2s1tc1f3xbox3erox4ihuan4n2xx2yz3yachts4hoo3maxun5ndex5e1odobashi7ga2kohama6u0tube6t1un3za0ppos4ra3ero3ip2m1one3uerich6w2",wb="\u03B5\u03BB1\u03C52\u0431\u04331\u0435\u043B3\u0434\u0435\u0442\u04384\u0435\u044E2\u043A\u0430\u0442\u043E\u043B\u0438\u043A6\u043E\u043C3\u043C\u043A\u04342\u043E\u043D1\u0441\u043A\u0432\u04306\u043E\u043D\u043B\u0430\u0439\u043D5\u0440\u04333\u0440\u0443\u04412\u04442\u0441\u0430\u0439\u04423\u0440\u04313\u0443\u043A\u04403\u049B\u0430\u04373\u0570\u0561\u05753\u05D9\u05E9\u05E8\u05D0\u05DC5\u05E7\u05D5\u05DD3\u0627\u0628\u0648\u0638\u0628\u064A5\u0631\u0627\u0645\u0643\u06485\u0644\u0627\u0631\u062F\u06464\u0628\u062D\u0631\u064A\u06465\u062C\u0632\u0627\u0626\u06315\u0633\u0639\u0648\u062F\u064A\u06296\u0639\u0644\u064A\u0627\u06465\u0645\u063A\u0631\u06285\u0645\u0627\u0631\u0627\u062A5\u06CC\u0631\u0627\u06465\u0628\u0627\u0631\u062A2\u0632\u0627\u06314\u064A\u062A\u06433\u06BE\u0627\u0631\u062A5\u062A\u0648\u0646\u06334\u0633\u0648\u062F\u0627\u06463\u0631\u064A\u06295\u0634\u0628\u0643\u06294\u0639\u0631\u0627\u06422\u06282\u0645\u0627\u06464\u0641\u0644\u0633\u0637\u064A\u06466\u0642\u0637\u06313\u0643\u0627\u062B\u0648\u0644\u064A\u06436\u0648\u06453\u0645\u0635\u06312\u0644\u064A\u0633\u064A\u06275\u0648\u0631\u064A\u062A\u0627\u0646\u064A\u06277\u0642\u06394\u0647\u0645\u0631\u0627\u06475\u067E\u0627\u06A9\u0633\u062A\u0627\u06467\u0680\u0627\u0631\u062A4\u0915\u0949\u092E3\u0928\u0947\u091F3\u092D\u093E\u0930\u09240\u092E\u094D3\u094B\u09245\u0938\u0902\u0917\u0920\u09285\u09AC\u09BE\u0982\u09B2\u09BE5\u09AD\u09BE\u09B0\u09A42\u09F0\u09A44\u0A2D\u0A3E\u0A30\u0A244\u0AAD\u0ABE\u0AB0\u0AA44\u0B2D\u0B3E\u0B30\u0B244\u0B87\u0BA8\u0BCD\u0BA4\u0BBF\u0BAF\u0BBE6\u0BB2\u0B99\u0BCD\u0B95\u0BC86\u0B9A\u0BBF\u0B99\u0BCD\u0B95\u0BAA\u0BCD\u0BAA\u0BC2\u0BB0\u0BCD11\u0C2D\u0C3E\u0C30\u0C24\u0C4D5\u0CAD\u0CBE\u0CB0\u0CA44\u0D2D\u0D3E\u0D30\u0D24\u0D025\u0DBD\u0D82\u0D9A\u0DCF4\u0E04\u0E2D\u0E213\u0E44\u0E17\u0E223\u0EA5\u0EB2\u0EA73\u10D2\u10D42\u307F\u3093\u306A3\u30A2\u30DE\u30BE\u30F34\u30AF\u30E9\u30A6\u30C94\u30B0\u30FC\u30B0\u30EB4\u30B3\u30E02\u30B9\u30C8\u30A23\u30BB\u30FC\u30EB3\u30D5\u30A1\u30C3\u30B7\u30E7\u30F36\u30DD\u30A4\u30F3\u30C84\u4E16\u754C2\u4E2D\u4FE11\u56FD1\u570B1\u6587\u7F513\u4E9A\u9A6C\u900A3\u4F01\u4E1A2\u4F5B\u5C712\u4FE1\u606F2\u5065\u5EB72\u516B\u53662\u516C\u53F81\u76CA2\u53F0\u6E7E1\u70632\u5546\u57CE1\u5E971\u68072\u5609\u91CC0\u5927\u9152\u5E975\u5728\u7EBF2\u5927\u62FF2\u5929\u4E3B\u65593\u5A31\u4E502\u5BB6\u96FB2\u5E7F\u4E1C2\u5FAE\u535A2\u6148\u55842\u6211\u7231\u4F603\u624B\u673A2\u62DB\u80582\u653F\u52A11\u5E9C2\u65B0\u52A0\u57612\u95FB2\u65F6\u5C1A2\u66F8\u7C4D2\u673A\u67842\u6DE1\u9A6C\u95213\u6E38\u620F2\u6FB3\u95802\u70B9\u770B2\u79FB\u52A82\u7EC4\u7EC7\u673A\u67844\u7F51\u57401\u5E971\u7AD91\u7EDC2\u8054\u901A2\u8C37\u6B4C2\u8D2D\u72692\u901A\u8CA92\u96C6\u56E22\u96FB\u8A0A\u76C8\u79D14\u98DE\u5229\u6D663\u98DF\u54C12\u9910\u53852\u9999\u683C\u91CC\u62C93\u6E2F2\uB2F7\uB1371\uCEF42\uC0BC\uC1312\uD55C\uAD6D2",vl="numeric",Cl="ascii",Ml="alpha",fr="asciinumeric",dr="alphanumeric",Tl="domain",Pf="emoji",Sb="scheme",vb="slashscheme",yl="whitespace";function Cb(n,e){return n in e||(e[n]=[]),e[n]}function Kt(n,e,t){e[vl]&&(e[fr]=!0,e[dr]=!0),e[Cl]&&(e[fr]=!0,e[Ml]=!0),e[fr]&&(e[dr]=!0),e[Ml]&&(e[dr]=!0),e[dr]&&(e[Tl]=!0),e[Pf]&&(e[Tl]=!0);for(let r in e){let i=Cb(r,t);i.indexOf(n)<0&&i.push(n)}}function Mb(n,e){let t={};for(let r in e)e[r].indexOf(n)>=0&&(t[r]=!0);return t}function Me(n=null){this.j={},this.jr=[],this.jd=null,this.t=n}Me.groups={};Me.prototype={accepts(){return!!this.t},go(n){let e=this,t=e.j[n];if(t)return t;for(let r=0;rn.ta(e,t,r,i),X=(n,e,t,r,i)=>n.tr(e,t,r,i),Of=(n,e,t,r,i)=>n.ts(e,t,r,i),S=(n,e,t,r,i)=>n.tt(e,t,r,i),ct="WORD",Al="UWORD",zf="ASCIINUMERICAL",Bf="ALPHANUMERICAL",br="LOCALHOST",El="TLD",Nl="UTLD",Ti="SCHEME",wn="SLASH_SCHEME",Rl="NUM",Ol="WS",Il="NL",hr="OPENBRACE",pr="CLOSEBRACE",Ai="OPENBRACKET",Ei="CLOSEBRACKET",Ni="OPENPAREN",Oi="CLOSEPAREN",Ri="OPENANGLEBRACKET",Ii="CLOSEANGLEBRACKET",Di="FULLWIDTHLEFTPAREN",Li="FULLWIDTHRIGHTPAREN",Pi="LEFTCORNERBRACKET",zi="RIGHTCORNERBRACKET",Bi="LEFTWHITECORNERBRACKET",Fi="RIGHTWHITECORNERBRACKET",Hi="FULLWIDTHLESSTHAN",$i="FULLWIDTHGREATERTHAN",_i="AMPERSAND",Vi="APOSTROPHE",Wi="ASTERISK",St="AT",ji="BACKSLASH",Ki="BACKTICK",Ji="CARET",Jt="COLON",Dl="COMMA",qi="DOLLAR",Ke="DOT",Ui="EQUALS",Ll="EXCLAMATION",Be="HYPHEN",mr="PERCENT",Gi="PIPE",Xi="PLUS",Yi="POUND",gr="QUERY",Pl="QUOTE",Ff="FULLWIDTHMIDDLEDOT",zl="SEMI",Je="SLASH",yr="TILDE",Qi="UNDERSCORE",Hf="EMOJI",Zi="SYM",$f=Object.freeze({__proto__:null,ALPHANUMERICAL:Bf,AMPERSAND:_i,APOSTROPHE:Vi,ASCIINUMERICAL:zf,ASTERISK:Wi,AT:St,BACKSLASH:ji,BACKTICK:Ki,CARET:Ji,CLOSEANGLEBRACKET:Ii,CLOSEBRACE:pr,CLOSEBRACKET:Ei,CLOSEPAREN:Oi,COLON:Jt,COMMA:Dl,DOLLAR:qi,DOT:Ke,EMOJI:Hf,EQUALS:Ui,EXCLAMATION:Ll,FULLWIDTHGREATERTHAN:$i,FULLWIDTHLEFTPAREN:Di,FULLWIDTHLESSTHAN:Hi,FULLWIDTHMIDDLEDOT:Ff,FULLWIDTHRIGHTPAREN:Li,HYPHEN:Be,LEFTCORNERBRACKET:Pi,LEFTWHITECORNERBRACKET:Bi,LOCALHOST:br,NL:Il,NUM:Rl,OPENANGLEBRACKET:Ri,OPENBRACE:hr,OPENBRACKET:Ai,OPENPAREN:Ni,PERCENT:mr,PIPE:Gi,PLUS:Xi,POUND:Yi,QUERY:gr,QUOTE:Pl,RIGHTCORNERBRACKET:zi,RIGHTWHITECORNERBRACKET:Fi,SCHEME:Ti,SEMI:zl,SLASH:Je,SLASH_SCHEME:wn,SYM:Zi,TILDE:yr,TLD:El,UNDERSCORE:Qi,UTLD:Nl,UWORD:Al,WORD:ct,WS:Ol}),lt=/[a-z]/,ur=/\p{L}/u,bl=/\p{Emoji}/u;var at=/\d/,kl=/\s/;var Rf="\r",xl=` +`,Tb="\uFE0F",Ab="\u200D",wl="\uFFFC",vi=null,Ci=null;function Eb(n=[]){let e={};Me.groups=e;let t=new Me;vi==null&&(vi=If(xb)),Ci==null&&(Ci=If(wb)),S(t,"'",Vi),S(t,"{",hr),S(t,"}",pr),S(t,"[",Ai),S(t,"]",Ei),S(t,"(",Ni),S(t,")",Oi),S(t,"<",Ri),S(t,">",Ii),S(t,"\uFF08",Di),S(t,"\uFF09",Li),S(t,"\u300C",Pi),S(t,"\u300D",zi),S(t,"\u300E",Bi),S(t,"\u300F",Fi),S(t,"\uFF1C",Hi),S(t,"\uFF1E",$i),S(t,"&",_i),S(t,"*",Wi),S(t,"@",St),S(t,"`",Ki),S(t,"^",Ji),S(t,":",Jt),S(t,",",Dl),S(t,"$",qi),S(t,".",Ke),S(t,"=",Ui),S(t,"!",Ll),S(t,"-",Be),S(t,"%",mr),S(t,"|",Gi),S(t,"+",Xi),S(t,"#",Yi),S(t,"?",gr),S(t,'"',Pl),S(t,"/",Je),S(t,";",zl),S(t,"~",yr),S(t,"_",Qi),S(t,"\\",ji),S(t,"\u30FB",Ff);let r=X(t,at,Rl,{[vl]:!0});X(r,at,r);let i=X(r,lt,zf,{[fr]:!0}),s=X(r,ur,Bf,{[dr]:!0}),o=X(t,lt,ct,{[Cl]:!0});X(o,at,i),X(o,lt,o),X(i,at,i),X(i,lt,i);let l=X(t,ur,Al,{[Ml]:!0});X(l,lt),X(l,at,s),X(l,ur,l),X(s,at,s),X(s,lt),X(s,ur,s);let a=S(t,xl,Il,{[yl]:!0}),c=S(t,Rf,Ol,{[yl]:!0}),u=X(t,kl,Ol,{[yl]:!0});S(t,wl,u),S(c,xl,a),S(c,wl,u),X(c,kl,u),S(u,Rf),S(u,xl),X(u,kl,u),S(u,wl,u);let d=X(t,bl,Hf,{[Pf]:!0});S(d,"#"),X(d,bl,d),S(d,Tb,d);let f=S(d,Ab);S(f,"#"),X(f,bl,d);let h=[[lt,o],[at,i]],p=[[lt,null],[ur,l],[at,s]];for(let m=0;mm[0]>g[0]?1:-1);for(let m=0;m=0?b[Tl]=!0:lt.test(g)?at.test(g)?b[fr]=!0:b[Cl]=!0:b[vl]=!0,Of(t,g,g,b)}return Of(t,"localhost",br,{ascii:!0}),t.jd=new Me(Zi),{start:t,tokens:Object.assign({groups:e},$f)}}function _f(n,e){let t=Nb(e.replace(/[A-Z]/g,l=>l.toLowerCase())),r=t.length,i=[],s=0,o=0;for(;o=0&&(d+=t[o].length,f++),c+=t[o].length,s+=t[o].length,o++;s-=d,o-=f,c-=d,i.push({t:u.t,v:e.slice(s-c,s),s:s-c,e:s})}return i}function Nb(n){let e=[],t=n.length,r=0;for(;r56319||r+1===t||(s=n.charCodeAt(r+1))<56320||s>57343?n[r]:n.slice(r,r+2);e.push(o),r+=o.length}return e}function wt(n,e,t,r,i){let s,o=e.length;for(let l=0;l=0;)s++;if(s>0){e.push(t.join(""));for(let o=parseInt(n.substring(r,r+s),10);o>0;o--)t.pop();r+=s}else t.push(n[r]),r++}return e}var kr={defaultProtocol:"http",events:null,format:Df,formatHref:Df,nl2br:!1,tagName:"a",target:null,rel:null,validate:!0,truncate:1/0,className:null,attributes:null,ignoreTags:[],render:null};function Bl(n,e=null){let t=Object.assign({},kr);n&&(t=Object.assign(t,n instanceof Bl?n.o:n));let r=t.ignoreTags,i=[];for(let s=0;st?r.substring(0,t)+"\u2026":r},toFormattedHref(n){return n.get("formatHref",this.toHref(n.get("defaultProtocol")),this)},startIndex(){return this.tk[0].s},endIndex(){return this.tk[this.tk.length-1].e},toObject(n=kr.defaultProtocol){return{type:this.t,value:this.toString(),isLink:this.isLink,href:this.toHref(n),start:this.startIndex(),end:this.endIndex()}},toFormattedObject(n){return{type:this.t,value:this.toFormattedString(n),isLink:this.isLink,href:this.toFormattedHref(n),start:this.startIndex(),end:this.endIndex()}},validate(n){return n.get("validate",this.toString(),this)},render(n){let e=this,t=this.toHref(n.get("defaultProtocol")),r=n.get("formatHref",t,this),i=n.get("tagName",t,e),s=this.toFormattedString(n),o={},l=n.get("className",t,e),a=n.get("target",t,e),c=n.get("rel",t,e),u=n.getObj("attributes",t,e),d=n.getObj("events",t,e);return o.href=r,l&&(o.class=l),a&&(o.target=a),c&&(o.rel=c),u&&Object.assign(o,u),{tagName:i,attributes:o,content:s,eventListeners:d}}};function es(n,e){class t extends Vf{constructor(i,s){super(i,s),this.t=n}}for(let r in e)t.prototype[r]=e[r];return t.t=n,t}var Ob=es("email",{isLink:!0,toHref(){return"mailto:"+this.toString()}}),Lf=es("text"),Rb=es("nl"),Mi=es("url",{isLink:!0,toHref(n=kr.defaultProtocol){return this.hasProtocol()?this.v:`${n}://${this.v}`},hasProtocol(){let n=this.tk;return n.length>=2&&n[0].t!==br&&n[1].t===Jt}});var ze=n=>new Me(n);function Ib({groups:n}){let e=n.domain.concat([_i,Wi,St,ji,Ki,Ji,qi,Ui,Be,Rl,mr,Gi,Xi,Yi,Je,Zi,yr,Qi]),t=[Vi,Jt,Dl,Ke,Ll,mr,gr,Pl,zl,Ri,Ii,hr,pr,Ei,Ai,Ni,Oi,Di,Li,Pi,zi,Bi,Fi,Hi,$i],r=[_i,Vi,Wi,ji,Ki,Ji,qi,Ui,Be,hr,pr,mr,Gi,Xi,Yi,gr,Je,Zi,yr,Qi],i=ze(),s=S(i,yr);H(s,r,s),H(s,n.domain,s);let o=ze(),l=ze(),a=ze();H(i,n.domain,o),H(i,n.scheme,l),H(i,n.slashscheme,a),H(o,r,s),H(o,n.domain,o);let c=S(o,St);S(s,St,c),S(l,St,c),S(a,St,c);let u=S(s,Ke);H(u,r,s),H(u,n.domain,s);let d=ze();H(c,n.domain,d),H(d,n.domain,d);let f=S(d,Ke);H(f,n.domain,d);let h=ze(Ob);H(f,n.tld,h),H(f,n.utld,h),S(c,br,h);let p=S(d,Be);S(p,Be,p),H(p,n.domain,d),H(h,n.domain,d),S(h,Ke,f),S(h,Be,p);let m=S(o,Be),g=S(o,Ke);S(m,Be,m),H(m,n.domain,o),H(g,r,s),H(g,n.domain,o);let y=ze(Mi);H(g,n.tld,y),H(g,n.utld,y),H(y,n.domain,o),H(y,r,s),S(y,Ke,g),S(y,Be,m),S(y,St,c);let b=S(y,Jt),k=ze(Mi);H(b,n.numeric,k);let w=ze(Mi),C=ze();H(w,e,w),H(w,t,C),H(C,e,w),H(C,t,C),S(y,Je,w),S(k,Je,w);let O=S(l,Jt),M=S(a,Jt),D=S(M,Je),B=S(D,Je);H(l,n.domain,o),S(l,Ke,g),S(l,Be,m),H(a,n.domain,o),S(a,Ke,g),S(a,Be,m),H(O,n.domain,w),S(O,Je,w),S(O,gr,w),H(B,n.domain,w),H(B,e,w),S(B,Je,w);let P=[[hr,pr],[Ai,Ei],[Ni,Oi],[Ri,Ii],[Di,Li],[Pi,zi],[Bi,Fi],[Hi,$i]];for(let Se=0;Se=0&&f++,i++,u++;if(f<0)i-=u,i0&&(s.push(Sl(Lf,e,o)),o=[]),i-=f,u-=f;let h=d.t,p=t.slice(i-u,i);s.push(Sl(h,e,p))}}return o.length>0&&s.push(Sl(Lf,e,o)),s}function Sl(n,e,t){let r=t[0].s,i=t[t.length-1].e,s=e.slice(r,i);return new n(s,t)}var Lb=typeof console!="undefined"&&console&&console.warn||(()=>{}),Pb="until manual call of linkify.init(). Register all schemes and plugins before invoking linkify the first time.",q={scanner:null,parser:null,tokenQueue:[],pluginQueue:[],customSchemes:[],initialized:!1};function Wf(){return Me.groups={},q.scanner=null,q.parser=null,q.tokenQueue=[],q.pluginQueue=[],q.customSchemes=[],q.initialized=!1,q}function Fl(n,e=!1){if(q.initialized&&Lb(`linkifyjs: already initialized - will not register custom scheme "${n}" ${Pb}`),!/^[0-9a-z]+(-[0-9a-z]+)*$/.test(n))throw new Error(`linkifyjs: incorrect scheme format. +1. Must only contain digits, lowercase ASCII letters or "-" +2. Cannot start or end with "-" +3. "-" cannot repeat`);q.customSchemes.push([n,e])}function zb(){q.scanner=Eb(q.customSchemes);for(let n=0;n{let i=e.some(c=>c.docChanged)&&!t.doc.eq(r.doc),s=e.some(c=>c.getMeta("preventAutolink"));if(!i||s)return;let{tr:o}=r,l=Uo(t.doc,[...e]);if(Yn(l).forEach(({newRange:c})=>{let u=cd(r.doc,c,h=>h.isTextblock),d,f;if(u.length>1)d=u[0],f=r.doc.textBetween(d.pos,d.pos+d.node.nodeSize,void 0," ");else if(u.length){let h=r.doc.textBetween(c.from,c.to," "," ");if(!Fb.test(h))return;d=u[0],f=r.doc.textBetween(d.pos,c.to,void 0," ")}if(d&&f){let h=f.split(Bb).filter(Boolean);if(h.length<=0)return!1;let p=h[h.length-1],m=d.pos+f.lastIndexOf(p);if(!p)return!1;let g=ts(p).map(y=>y.toObject(n.defaultProtocol));if(!$b(g))return!1;g.filter(y=>y.isLink).map(y=>({...y,from:m+y.start+1,to:m+y.end+1})).filter(y=>r.schema.marks.code?!r.doc.rangeHasMark(y.from,y.to,r.schema.marks.code):!0).filter(y=>n.validate(y.value)).filter(y=>n.shouldAutoLink(y.value)).forEach(y=>{ci(y.from,y.to,r.doc).some(b=>b.mark.type===n.type)||o.addMark(y.from,y.to,n.type.create({href:y.href}))})}}),!!o.steps.length)return o}})}function Vb(n){return new R({key:new z("handleClickLink"),props:{handleClick:(e,t,r)=>{var i,s;if(r.button!==0||!e.editable)return!1;let o=null;if(r.target instanceof HTMLAnchorElement)o=r.target;else{let a=r.target;if(!a)return!1;let c=n.editor.view.dom;o=a.closest("a"),o&&!c.contains(o)&&(o=null)}if(!o)return!1;let l=!1;if(n.enableClickSelection&&(l=n.editor.commands.extendMarkRange(n.type.name)),n.openOnClick){let a=Qo(e.state,n.type.name),c=(i=o.href)!=null?i:a.href,u=(s=o.target)!=null?s:a.target;c&&(window.open(c,u),l=!0)}return l}}})}function Wb(n){return new R({key:new z("handlePasteLink"),props:{handlePaste:(e,t,r)=>{let{shouldAutoLink:i}=n,{state:s}=e,{selection:o}=s,{empty:l}=o;if(l)return!1;let a="";r.content.forEach(u=>{a+=u.textContent});let c=ns(a,{defaultProtocol:n.defaultProtocol}).find(u=>u.isLink&&u.value===a);return!a||!c||i!==void 0&&!i(c.value)?!1:n.editor.commands.setMark(n.type,{href:c.href})}}})}function qt(n,e){let t=["http","https","ftp","ftps","mailto","tel","callto","sms","cid","xmpp"];return e&&e.forEach(r=>{let i=typeof r=="string"?r:r.scheme;i&&t.push(i)}),!n||n.replace(Hb,"").match(new RegExp(`^(?:(?:${t.map(r=>r.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&")).join("|")}):|[^a-z]|[a-z0-9+.\\-]+(?:[^a-z+.\\-:]|$))`,"i"))}var $l=Ee.create({name:"link",priority:1e3,keepOnSplit:!1,exitable:!0,onCreate(){this.options.validate&&!this.options.shouldAutoLink&&(this.options.shouldAutoLink=this.options.validate,console.warn("The `validate` option is deprecated. Rename to the `shouldAutoLink` option instead.")),this.options.protocols.forEach(n=>{if(typeof n=="string"){Fl(n);return}Fl(n.scheme,n.optionalSlashes)})},onDestroy(){Wf()},inclusive(){return this.options.autolink},addOptions(){return{openOnClick:!0,enableClickSelection:!1,linkOnPaste:!0,autolink:!0,protocols:[],defaultProtocol:"http",HTMLAttributes:{target:"_blank",rel:"noopener noreferrer nofollow",class:null},isAllowedUri:(n,e)=>!!qt(n,e.protocols),validate:n=>!!n,shouldAutoLink:n=>{let e=/^[a-z][a-z0-9+.-]*:\/\//i.test(n),t=/^[a-z][a-z0-9+.-]*:/i.test(n);if(e||t&&!n.includes("@"))return!0;let i=(n.includes("@")?n.split("@").pop():n).split(/[/?#:]/)[0];return!(/^\d{1,3}(\.\d{1,3}){3}$/.test(i)||!/\./.test(i))}}},addAttributes(){var n,e,t;return{href:{default:null,parseHTML(r){return r.getAttribute("href")}},target:{default:(n=this.options.HTMLAttributes.target)!=null?n:null},rel:{default:(e=this.options.HTMLAttributes.rel)!=null?e:null},class:{default:(t=this.options.HTMLAttributes.class)!=null?t:null},title:{default:null}}},parseHTML(){return[{tag:"a[href]",getAttrs:n=>{let e=n.getAttribute("href");return!e||!this.options.isAllowedUri(e,{defaultValidate:t=>!!qt(t,this.options.protocols),protocols:this.options.protocols,defaultProtocol:this.options.defaultProtocol})?!1:null}}]},renderHTML({HTMLAttributes:n}){return this.options.isAllowedUri(n.href,{defaultValidate:e=>!!qt(e,this.options.protocols),protocols:this.options.protocols,defaultProtocol:this.options.defaultProtocol})?["a",I(this.options.HTMLAttributes,n),0]:["a",I(this.options.HTMLAttributes,{...n,href:""}),0]},markdownTokenName:"link",parseMarkdown:(n,e)=>e.applyMark("link",e.parseInline(n.tokens||[]),{href:n.href,title:n.title||null}),renderMarkdown:(n,e)=>{var t,r,i,s;let o=(r=(t=n.attrs)==null?void 0:t.href)!=null?r:"",l=(s=(i=n.attrs)==null?void 0:i.title)!=null?s:"",a=e.renderChildren(n);return l?`[${a}](${o} "${l}")`:`[${a}](${o})`},addCommands(){return{setLink:n=>({chain:e})=>{let{href:t}=n;return this.options.isAllowedUri(t,{defaultValidate:r=>!!qt(r,this.options.protocols),protocols:this.options.protocols,defaultProtocol:this.options.defaultProtocol})?e().setMark(this.name,n).setMeta("preventAutolink",!0).run():!1},toggleLink:n=>({chain:e})=>{let{href:t}=n||{};return t&&!this.options.isAllowedUri(t,{defaultValidate:r=>!!qt(r,this.options.protocols),protocols:this.options.protocols,defaultProtocol:this.options.defaultProtocol})?!1:e().toggleMark(this.name,n,{extendEmptyMarkRange:!0}).setMeta("preventAutolink",!0).run()},unsetLink:()=>({chain:n})=>n().unsetMark(this.name,{extendEmptyMarkRange:!0}).setMeta("preventAutolink",!0).run()}},addPasteRules(){return[Pe({find:n=>{let e=[];if(n){let{protocols:t,defaultProtocol:r}=this.options,i=ns(n).filter(s=>s.isLink&&this.options.isAllowedUri(s.value,{defaultValidate:o=>!!qt(o,t),protocols:t,defaultProtocol:r}));i.length&&i.forEach(s=>{this.options.shouldAutoLink(s.value)&&e.push({text:s.value,data:{href:s.href},index:s.start})})}return e},type:this.type,getAttributes:n=>{var e;return{href:(e=n.data)==null?void 0:e.href}}})]},addProseMirrorPlugins(){let n=[],{protocols:e,defaultProtocol:t}=this.options;return this.options.autolink&&n.push(_b({type:this.type,defaultProtocol:this.options.defaultProtocol,validate:r=>this.options.isAllowedUri(r,{defaultValidate:i=>!!qt(i,e),protocols:e,defaultProtocol:t}),shouldAutoLink:this.options.shouldAutoLink})),n.push(Vb({type:this.type,editor:this.editor,openOnClick:this.options.openOnClick==="whenNotEditable"?!0:this.options.openOnClick,enableClickSelection:this.options.enableClickSelection})),this.options.linkOnPaste&&n.push(Wb({editor:this.editor,defaultProtocol:this.options.defaultProtocol,type:this.type,shouldAutoLink:this.options.shouldAutoLink})),n}}),jf=$l;var jb=Object.defineProperty,Kb=(n,e)=>{for(var t in e)jb(n,t,{get:e[t],enumerable:!0})},Jb="listItem",Kf="textStyle",Jf=/^\s*([-+*])\s$/,jl=_.create({name:"bulletList",addOptions(){return{itemTypeName:"listItem",HTMLAttributes:{},keepMarks:!1,keepAttributes:!1}},group:"block list",content(){return`${this.options.itemTypeName}+`},parseHTML(){return[{tag:"ul"}]},renderHTML({HTMLAttributes:n}){return["ul",I(this.options.HTMLAttributes,n),0]},markdownTokenName:"list",parseMarkdown:(n,e)=>n.type!=="list"||n.ordered?[]:{type:"bulletList",content:n.items?e.parseChildren(n.items):[]},renderMarkdown:(n,e)=>n.content?e.renderChildren(n.content,` +`):"",markdownOptions:{indentsContent:!0},addCommands(){return{toggleBulletList:()=>({commands:n,chain:e})=>this.options.keepAttributes?e().toggleList(this.name,this.options.itemTypeName,this.options.keepMarks).updateAttributes(Jb,this.editor.getAttributes(Kf)).run():n.toggleList(this.name,this.options.itemTypeName,this.options.keepMarks)}},addKeyboardShortcuts(){return{"Mod-Shift-8":()=>this.editor.commands.toggleBulletList()}},addInputRules(){let n=je({find:Jf,type:this.type});return(this.options.keepMarks||this.options.keepAttributes)&&(n=je({find:Jf,type:this.type,keepMarks:this.options.keepMarks,keepAttributes:this.options.keepAttributes,getAttributes:()=>this.editor.getAttributes(Kf),editor:this.editor})),[n]}}),qb=(n,e,t)=>{let{selection:r}=n;if(!r.empty)return null;let{$from:i}=r;if(!i.parent.isTextblock||i.parentOffset!==i.parent.content.size)return null;let s=-1;for(let h=i.depth;h>0;h-=1)if(i.node(h).type.name===e){s=h;break}if(s<0)return null;let o=i.node(s),l=i.index(s);if(l+1>=o.childCount)return null;let a=o.child(l+1);if(!t.includes(a.type.name))return null;let c=n.schema.nodes[e],u=!1;if(a.forEach(h=>{h.type===c&&h.childCount>1&&(u=!0)}),!u)return null;let d=n.doc.resolve(i.after()).nodeAfter;if(!d||!t.includes(d.type.name))return null;let f=[];return d.forEach(h=>{f.push(h)}),f.length===0?null:{listItemDepth:s,nestedList:d,nestedListPos:i.after(),insertPos:i.after(s),items:f}},Ub=(n,e,t,r)=>{let i=qb(n,t,r);if(!i)return!1;let{selection:s}=n,{nestedList:o,nestedListPos:l,insertPos:a,items:c}=i,u=n.tr;u.delete(l,l+o.nodeSize);let d=u.mapping.map(a);return u.insert(d,x.from(c)),u.setSelection(s.map(u.doc,u.mapping)),e&&e(u),!0},Gb=(n,e,t)=>Ub(n.state,n.view.dispatch,e,t),Xf=(n,e)=>$.create({name:`${n}BranchingDeleteKeymap`,priority:101,addKeyboardShortcuts(){let t=()=>Gb(this.editor,n,e);return{Delete:t,"Mod-Delete":t}}}),Yf=[[1e3,"m"],[900,"cm"],[500,"d"],[400,"cd"],[100,"c"],[90,"xc"],[50,"l"],[40,"xl"],[10,"x"],[9,"ix"],[5,"v"],[4,"iv"],[1,"i"]],rs="abcdefghijklmnopqrstuvwxyz",Xb="[a-zA-Z]{1,2}",Kl=String.raw`\d+|[ivxlcdmIVXLCDM]+|${Xb}`;function ss(n){let e=n,t="";for(let[r,i]of Yf)for(;e>=r;)t+=i,e-=r;return t}function Jl(n){return ss(n).toUpperCase()}function Qf(n){let e=n.toLowerCase(),t=0,r=0;for(;t0?r:1}let t=parseInt(n,10);return Number.isNaN(t)?1:t}function Zb(n,e){if(n==="numeric")return String(e);switch(n){case"a":return is(e);case"A":return is(e).toUpperCase();case"i":return ss(e);case"I":return Jl(e);default:return String(e)}}function ek(n){var e;if(n.length===0)return!1;let t=(e=ls(n[0]))!=null?e:"numeric",r=ql(n[0]);if(r<1)return!1;for(let i=0;i{var t;if(n.type!=="list_item")return[];let r=(t=e.parseBlockChildren)!=null?t:e.parseChildren,i=[];if(n.tokens&&n.tokens.length>0){if(ik(n))return{type:"listItem",content:[{type:"paragraph",content:sk(n.text||"",e)}]};if(n.tokens.some(o=>o.type==="paragraph"))i=r(n.tokens);else{let o=n.tokens[0];if(o&&o.type==="text"&&o.tokens&&o.tokens.length>0){if(i=[{type:"paragraph",content:e.parseInline(o.tokens)}],n.tokens.length>1){let a=n.tokens.slice(1),c=r(a);i.push(...c)}}else i=r(n.tokens)}}return i.length===0&&(i=[{type:"paragraph",content:[]}]),{type:"listItem",content:i}},renderMarkdown:(n,e,t)=>Qn(n,e,r=>{var i,s,o,l;if(r.parentType==="bulletList")return"- ";if(r.parentType==="orderedList"){let a=((s=(i=r.meta)==null?void 0:i.parentAttrs)==null?void 0:s.start)||1,c=(l=(o=r.meta)==null?void 0:o.parentAttrs)==null?void 0:l.type,u=a-1+(r.index||0);return rk(c,u,". ")}return"- "},t),addExtensions(){return[Xf(this.name,[this.options.bulletListTypeName,this.options.orderedListTypeName])]},addKeyboardShortcuts(){return{Enter:()=>this.editor.commands.splitListItem(this.name),Tab:()=>this.editor.commands.sinkListItem(this.name),"Shift-Tab":()=>this.editor.commands.liftListItem(this.name)}}}),ok={};Kb(ok,{findListItemPos:()=>as,getNextListDepth:()=>Gl,handleBackspace:()=>_l,handleDelete:()=>Vl,hasListBefore:()=>Zf,hasListItemAfter:()=>lk,hasListItemBefore:()=>ak,listItemHasSubList:()=>ck,nextListIsDeeper:()=>eh,nextListIsHigher:()=>th});var as=(n,e)=>{let{$from:t}=e.selection,r=U(n,e.schema),i=null,s=t.depth,o=t.pos,l=null;for(;s>0&&l===null;)i=t.node(s),i.type===r?l=s:(s-=1,o-=1);return l===null?null:{$pos:e.doc.resolve(o),depth:l}},Gl=(n,e)=>{let t=as(n,e);if(!t)return!1;let[,r]=md(e,n,t.$pos.pos+4);return r},Zf=(n,e,t)=>{let{$anchor:r}=n.selection,i=Math.max(0,r.pos-2),s=n.doc.resolve(i).node();return!(!s||!t.includes(s.type.name))},_l=(n,e,t)=>{if(n.commands.undoInputRule())return!0;if(n.state.selection.from!==n.state.selection.to)return!1;if(!Ve(n.state,e)&&Zf(n.state,e,t)){let{$anchor:r}=n.state.selection,i=n.state.doc.resolve(r.before()-1),s=[];i.node().descendants((a,c)=>{a.type.name===e&&s.push({node:a,pos:c})});let o=s.at(-1);if(!o)return!1;let l=n.state.doc.resolve(i.start()+o.pos+1);return n.chain().cut({from:r.start()-1,to:r.end()+1},l.end()).joinForward().run()}return!Ve(n.state,e)||!yd(n.state)?!1:n.chain().liftListItem(e).run()},eh=(n,e)=>{let t=Gl(n,e),r=as(n,e);return!r||!t?!1:t>r.depth},th=(n,e)=>{let t=Gl(n,e),r=as(n,e);return!r||!t?!1:t{if(!Ve(n.state,e)||!gd(n.state,e))return!1;let{selection:t}=n.state,{$from:r,$to:i}=t;return!t.empty&&r.sameParent(i)?!1:eh(e,n.state)?n.chain().focus(n.state.selection.from+4).lift(e).joinBackward().run():th(e,n.state)?n.chain().joinForward().joinBackward().run():n.commands.joinItemForward()},lk=(n,e)=>{var t;let{$anchor:r}=e.selection,i=e.doc.resolve(r.pos-r.parentOffset-2);return!(i.index()===i.parent.childCount-1||((t=i.nodeAfter)==null?void 0:t.type.name)!==n)},ak=(n,e)=>{var t;let{$anchor:r}=e.selection,i=e.doc.resolve(r.pos-2);return!(i.index()===0||((t=i.nodeBefore)==null?void 0:t.type.name)!==n)},ck=(n,e,t)=>{if(!t)return!1;let r=U(n,e.schema),i=!1;return t.descendants(s=>{s.type===r&&(i=!0)}),i},Xl=$.create({name:"listKeymap",addOptions(){return{listTypes:[{itemName:"listItem",wrapperNames:["bulletList","orderedList"]},{itemName:"taskItem",wrapperNames:["taskList"]}]}},addKeyboardShortcuts(){return{Delete:({editor:n})=>{let e=!1;return this.options.listTypes.forEach(({itemName:t})=>{n.state.schema.nodes[t]!==void 0&&Vl(n,t)&&(e=!0)}),e},"Mod-Delete":({editor:n})=>{let e=!1;return this.options.listTypes.forEach(({itemName:t})=>{n.state.schema.nodes[t]!==void 0&&Vl(n,t)&&(e=!0)}),e},Backspace:({editor:n})=>{let e=!1;return this.options.listTypes.forEach(({itemName:t,wrapperNames:r})=>{n.state.schema.nodes[t]!==void 0&&_l(n,t,r)&&(e=!0)}),e},"Mod-Backspace":({editor:n})=>{let e=!1;return this.options.listTypes.forEach(({itemName:t,wrapperNames:r})=>{n.state.schema.nodes[t]!==void 0&&_l(n,t,r)&&(e=!0)}),e}}}}),Wl=new RegExp(`^(\\s*)(${Kl})([.)])\\s+(.*)$`),uk=new RegExp(`^(\\s*)(${Kl})([.)])\\s+`),dk=/^\s/,xr={heading:/^#{1,6}(?:\s|$)/,bulletItem:/^[-+*]\s+/,codeFence:/^(?:```|~~~)/,thematicBreak:/^(?:(?:-[ \t]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})$/};function fk(n){return Wl.test(n.trimStart())}function hk(n){let e=n.trimStart();return xr.bulletItem.test(e)||fk(e)||xr.heading.test(e)||xr.thematicBreak.test(e)&&!e.startsWith("-")||/^>\s?/.test(e)||xr.codeFence.test(e)}function pk(n){return Object.values(xr).some(e=>e.test(n))}function mk(n){let e=[],t=[],r=!1;return n.forEach(i=>{if(r){t.push(i);return}if(i.trim()===""){r=!0,t.push(i);return}if(e.length>0&&hk(i)){r=!0,t.push(i);return}e.push(i)}),{paragraphLines:e,blockLines:t}}function gk(n){let e=[],t=0,r=0;for(;ts.trim().length>0);if(e.length===0)return null;let t=[];for(let s of e){let o=s.trim().match(yk);if(!o)return null;t.push({marker:o[1],content:o[3]})}let r=t.map(s=>s.marker);return ek(r)?{type:"orderedList",attrs:nk(t[0].marker),content:t.map(s=>({type:"listItem",content:[{type:"paragraph",content:[{type:"text",text:s.content}]}]}))}:null}function nh(n,e,t){let r=[],i=0;for(;ie;)f.push(n[d]),d+=1;if(f.length>0){let h=Math.min(...f.map(m=>m.indent)),p=nh(f,h,t);c.push({type:"list",ordered:!0,start:f[0].number,typeMarker:f[0].type,items:p,raw:f.map(m=>m.raw).join(` +`)})}r.push({type:"list_item",raw:s.raw,tokens:c}),i=d}else i+=1}return r}function kk(n,e){return n.map(t=>{if(t.type!=="list_item")return e.parseChildren([t])[0];let r=[];return t.tokens&&t.tokens.length>0&&t.tokens.forEach(i=>{if(i.type==="paragraph"||i.type==="list"||i.type==="blockquote"||i.type==="code")r.push(...e.parseChildren([i]));else if(i.type==="text"&&i.tokens){let s=e.parseChildren([i]);r.push({type:"paragraph",content:s})}else{let s=e.parseChildren([i]);s.length>0&&r.push(...s)}}),{type:"listItem",content:r}})}var xk="listItem",qf="textStyle",Uf=/^(\d+)\.\s$/;function Gf(n){let e=n.match(/list-style-type\s*:\s*([^;]+)/i);if(!e)return null;switch(e[1].trim().toLowerCase()){case"upper-roman":return"I";case"lower-roman":return"i";case"upper-alpha":case"upper-latin":return"A";case"lower-alpha":case"lower-latin":return"a";default:return null}}var Yl=_.create({name:"orderedList",addOptions(){return{itemTypeName:"listItem",HTMLAttributes:{},keepMarks:!1,keepAttributes:!1}},group:"block list",content(){return`${this.options.itemTypeName}+`},addAttributes(){return{start:{default:1,parseHTML:n=>n.hasAttribute("start")?parseInt(n.getAttribute("start")||"",10):1},type:{default:null,parseHTML:n=>{let e=n.getAttribute("type");if(e)return e;let t=n.getAttribute("style");if(t){let i=Gf(t);if(i)return i}let r=n.querySelector("li");if(r){let i=r.getAttribute("style");if(i){let s=Gf(i);if(s)return s}}return null}}}},parseHTML(){return[{tag:"ol"}]},renderHTML({HTMLAttributes:n}){let{start:e,type:t,...r}=n,i=I(this.options.HTMLAttributes,r);return e!==1&&(i.start=e),t&&t!=="1"&&(i.type=t),["ol",i,0]},markdownTokenName:"list",parseMarkdown:(n,e)=>{if(n.type!=="list"||!n.ordered)return[];let t=n.start||1,r=n.typeMarker,i=n.items?kk(n.items,e):[],s={};return t!==1&&(s.start=t),r&&(s.type=r),Object.keys(s).length>0?{type:"orderedList",attrs:s,content:i}:{type:"orderedList",content:i}},renderMarkdown:(n,e)=>n.content?e.renderChildren(n.content,` +`):"",markdownTokenizer:{name:"orderedList",level:"block",start:n=>{let e=n.match(uk),t=e==null?void 0:e.index;return t!==void 0?t:-1},tokenize:(n,e,t)=>{var r,i;let s=n.split(` +`),[o,l]=gk(s);if(o.length===0)return;let a=nh(o,o[0].indent,t);if(a.length===0)return;let c=((r=o[0])==null?void 0:r.number)||1,u=(i=o[0])==null?void 0:i.type;return{type:"list",ordered:!0,start:c,typeMarker:u,items:a,raw:s.slice(0,l).join(` +`)}}},markdownOptions:{indentsContent:!0},addCommands(){return{toggleOrderedList:()=>({commands:n,chain:e})=>this.options.keepAttributes?e().toggleList(this.name,this.options.itemTypeName,this.options.keepMarks).updateAttributes(xk,this.editor.getAttributes(qf)).run():n.toggleList(this.name,this.options.itemTypeName,this.options.keepMarks)}},addKeyboardShortcuts(){return{"Mod-Shift-7":()=>this.editor.commands.toggleOrderedList()}},addProseMirrorPlugins(){return[new R({props:{handlePaste:(n,e)=>{var t,r;let i=(t=e.clipboardData)==null?void 0:t.getData("text/html");if(i!=null&&i.trim())return!1;let s=(r=e.clipboardData)==null?void 0:r.getData("text/plain");if(!s)return!1;let o=bk(s);if(!o)return!1;try{let l=n.state.schema.nodeFromJSON(o),a=n.state.tr.replaceSelectionWith(l);return n.dispatch(a),!0}catch(l){return!1}}}})]},addInputRules(){let n=(t,r)=>(!r.attrs.type||r.attrs.type==="1")&&r.childCount+r.attrs.start===+t[1],e=je({find:Uf,type:this.type,getAttributes:t=>({start:+t[1]}),joinPredicate:n});return(this.options.keepMarks||this.options.keepAttributes)&&(e=je({find:Uf,type:this.type,keepMarks:this.options.keepMarks,keepAttributes:this.options.keepAttributes,getAttributes:t=>({start:+t[1],...this.editor.getAttributes(qf)}),joinPredicate:n,editor:this.editor})),[e]}}),wk=/^\s*(\[([( |x])?\])\s$/,Sk=_.create({name:"taskItem",addOptions(){return{nested:!1,HTMLAttributes:{},taskListTypeName:"taskList",a11y:void 0}},content(){return this.options.nested?"paragraph block*":"paragraph+"},defining:!0,addAttributes(){return{checked:{default:!1,keepOnSplit:!1,parseHTML:n=>{let e=n.getAttribute("data-checked");return e===""||e==="true"},renderHTML:n=>({"data-checked":n.checked})}}},parseHTML(){return[{tag:`li[data-type="${this.name}"]`,priority:51}]},renderHTML({node:n,HTMLAttributes:e}){return["li",I(this.options.HTMLAttributes,e,{"data-type":this.name}),["label",["input",{type:"checkbox",checked:n.attrs.checked?"checked":null}],["span"]],["div",0]]},parseMarkdown:(n,e)=>{let t=[];if(n.tokens&&n.tokens.length>0?t.push(e.createNode("paragraph",{},e.parseInline(n.tokens))):n.text?t.push(e.createNode("paragraph",{},[e.createNode("text",{text:n.text})])):t.push(e.createNode("paragraph",{},[])),n.nestedTokens&&n.nestedTokens.length>0){let r=e.parseChildren(n.nestedTokens);t.push(...r)}return e.createNode("taskItem",{checked:n.checked||!1},t)},renderMarkdown:(n,e)=>{var t;let i=`- [${(t=n.attrs)!=null&&t.checked?"x":" "}] `;return Qn(n,e,i)},addExtensions(){return this.options.nested?[Xf(this.name,[this.options.taskListTypeName])]:[]},addKeyboardShortcuts(){let n={Enter:()=>this.editor.commands.splitListItem(this.name),"Shift-Tab":()=>this.editor.commands.liftListItem(this.name)};return this.options.nested?{...n,Tab:()=>this.editor.commands.sinkListItem(this.name)}:n},addNodeView(){return({node:n,HTMLAttributes:e,getPos:t,editor:r})=>{let i=document.createElement("li"),s=document.createElement("label"),o=document.createElement("span"),l=document.createElement("input"),a=document.createElement("div"),c=d=>{var f,h;l.ariaLabel=((h=(f=this.options.a11y)==null?void 0:f.checkboxLabel)==null?void 0:h.call(f,d,l.checked))||`Task item checkbox for ${d.textContent||"empty task item"}`};c(n),s.contentEditable="false",l.type="checkbox",l.addEventListener("mousedown",d=>d.preventDefault()),l.addEventListener("change",d=>{if(!r.isEditable&&!this.options.onReadOnlyChecked){l.checked=!l.checked;return}let{checked:f}=d.target;r.isEditable&&typeof t=="function"&&r.chain().focus(void 0,{scrollIntoView:!1}).command(({tr:h})=>{let p=t();if(typeof p!="number")return!1;let m=h.doc.nodeAt(p);return h.setNodeMarkup(p,void 0,{...m==null?void 0:m.attrs,checked:f}),!0}).run(),!r.isEditable&&this.options.onReadOnlyChecked&&(this.options.onReadOnlyChecked(n,f)||(l.checked=!l.checked))}),Object.entries(this.options.HTMLAttributes).forEach(([d,f])=>{i.setAttribute(d,f)}),i.dataset.checked=n.attrs.checked,l.checked=n.attrs.checked,s.append(l,o),i.append(s,a),Object.entries(e).forEach(([d,f])=>{i.setAttribute(d,f)});let u=new Set(Object.keys(e));return{dom:i,contentDOM:a,update:d=>{if(d.type!==this.type)return!1;i.dataset.checked=d.attrs.checked,l.checked=d.attrs.checked,c(d);let f=r.extensionManager.attributes,h=hn(d,f),p=new Set(Object.keys(h)),m=this.options.HTMLAttributes;return u.forEach(g=>{p.has(g)||(g in m?i.setAttribute(g,m[g]):i.removeAttribute(g))}),Object.entries(h).forEach(([g,y])=>{y==null?g in m?i.setAttribute(g,m[g]):i.removeAttribute(g):i.setAttribute(g,y)}),u=p,!0}}}},addInputRules(){return[je({find:wk,type:this.type,getAttributes:n=>({checked:n[n.length-1]==="x"})})]}}),vk=_.create({name:"taskList",addOptions(){return{itemTypeName:"taskItem",HTMLAttributes:{}}},group:"block list",content(){return`${this.options.itemTypeName}+`},parseHTML(){return[{tag:`ul[data-type="${this.name}"]`,priority:51}]},renderHTML({HTMLAttributes:n}){return["ul",I(this.options.HTMLAttributes,n,{"data-type":this.name}),0]},parseMarkdown:(n,e)=>e.createNode("taskList",{},e.parseChildren(n.items||[])),renderMarkdown:(n,e)=>n.content?e.renderChildren(n.content,` +`):"",markdownTokenizer:{name:"taskList",level:"block",start(n){var e;let t=(e=n.match(/^\s*[-+*]\s+\[([ xX])\]\s+/))==null?void 0:e.index;return t!==void 0?t:-1},tokenize(n,e,t){let r=s=>{let o=di(s,{itemPattern:/^(\s*)([-+*])\s+\[([ xX])\]\s+(.*)$/,extractItemData:l=>({indentLevel:l[1].length,mainContent:l[4],checked:l[3].toLowerCase()==="x"}),createToken:(l,a)=>({type:"taskItem",raw:"",mainContent:l.mainContent,indentLevel:l.indentLevel,checked:l.checked,text:l.mainContent,tokens:t.inlineTokens(l.mainContent),nestedTokens:a}),customNestedParser:r},t);return o?[{type:"taskList",raw:o.raw,items:o.items}]:t.blockTokens(s)},i=di(n,{itemPattern:/^(\s*)([-+*])\s+\[([ xX])\]\s+(.*)$/,extractItemData:s=>({indentLevel:s[1].length,mainContent:s[4],checked:s[3].toLowerCase()==="x"}),createToken:(s,o)=>({type:"taskItem",raw:"",mainContent:s.mainContent,indentLevel:s.indentLevel,checked:s.checked,text:s.mainContent,tokens:t.inlineTokens(s.mainContent),nestedTokens:o}),customNestedParser:r},t);if(i)return{type:"taskList",raw:i.raw,items:i.items}}},markdownOptions:{indentsContent:!0},addCommands(){return{toggleTaskList:()=>({commands:n})=>n.toggleList(this.name,this.options.itemTypeName)}},addKeyboardShortcuts(){return{"Mod-Shift-9":()=>this.editor.commands.toggleTaskList()}}}),uv=$.create({name:"listKit",addExtensions(){let n=[];return this.options.bulletList!==!1&&n.push(jl.configure(this.options.bulletList)),this.options.listItem!==!1&&n.push(Ul.configure(this.options.listItem)),this.options.listKeymap!==!1&&n.push(Xl.configure(this.options.listKeymap)),this.options.orderedList!==!1&&n.push(Yl.configure(this.options.orderedList)),this.options.taskItem!==!1&&n.push(Sk.configure(this.options.taskItem)),this.options.taskList!==!1&&n.push(vk.configure(this.options.taskList)),n}});var cs=" ",Ql="\xA0",rh=_.create({name:"paragraph",priority:1e3,addOptions(){return{HTMLAttributes:{}}},group:"block",content:"inline*",parseHTML(){return[{tag:"p"}]},renderHTML({HTMLAttributes:n}){return["p",I(this.options.HTMLAttributes,n),0]},parseMarkdown:(n,e)=>{let t=n.tokens||[];if(t.length===1&&t[0].type==="image")return e.parseChildren([t[0]]);let r=e.parseInline(t);return t.length===1&&t[0].type==="text"&&(t[0].raw===cs||t[0].text===cs||t[0].raw===Ql||t[0].text===Ql)&&r.length===1&&r[0].type==="text"&&(r[0].text===cs||r[0].text===Ql)?e.createNode("paragraph",void 0,[]):e.createNode("paragraph",void 0,r)},renderMarkdown:(n,e,t)=>{var r,i;if(!n)return"";let s=Array.isArray(n.content)?n.content:[];if(s.length===0){let o=Array.isArray((r=t==null?void 0:t.previousNode)==null?void 0:r.content)?t.previousNode.content:[];return((i=t==null?void 0:t.previousNode)==null?void 0:i.type)==="paragraph"&&o.length===0?cs:""}return e.renderChildren(s)},addCommands(){return{setParagraph:()=>({commands:n})=>n.setNode(this.name)}},addKeyboardShortcuts(){return{"Mod-Alt-0":()=>this.editor.commands.setParagraph()}}});var Ck=/(?:^|\s)(~~(?!\s+~~)((?:[^~]+))~~(?!\s+~~))$/,Mk=/(?:^|\s)(~~(?!\s+~~)((?:[^~]+))~~(?!\s+~~))/g,ih=Ee.create({name:"strike",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:"s"},{tag:"del"},{tag:"strike"},{style:"text-decoration",consuming:!1,getAttrs:n=>n.includes("line-through")?{}:!1}]},renderHTML({HTMLAttributes:n}){return["s",I(this.options.HTMLAttributes,n),0]},markdownTokenName:"del",parseMarkdown:(n,e)=>e.applyMark("strike",e.parseInline(n.tokens||[])),renderMarkdown:(n,e)=>`~~${e.renderChildren(n)}~~`,addCommands(){return{setStrike:()=>({commands:n})=>n.setMark(this.name),toggleStrike:()=>({commands:n})=>n.toggleMark(this.name),unsetStrike:()=>({commands:n})=>n.unsetMark(this.name)}},addKeyboardShortcuts(){return{"Mod-Shift-s":()=>this.editor.commands.toggleStrike()}},addInputRules(){return[We({find:Ck,type:this.type})]},addPasteRules(){return[Pe({find:Mk,type:this.type})]}});var sh=_.create({name:"text",group:"inline",parseMarkdown:n=>({type:"text",text:n.text||""}),renderMarkdown:n=>n.text||""});var oh=Ee.create({name:"underline",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:"u"},{style:"text-decoration",consuming:!1,getAttrs:n=>n.includes("underline")?{}:!1}]},renderHTML({HTMLAttributes:n}){return["u",I(this.options.HTMLAttributes,n),0]},parseMarkdown(n,e){return e.applyMark(this.name||"underline",e.parseInline(n.tokens||[]))},renderMarkdown(n,e){return`++${e.renderChildren(n)}++`},markdownTokenizer:{name:"underline",level:"inline",start(n){return n.indexOf("++")},tokenize(n,e,t){let i=/^(\+\+)([\s\S]+?)(\+\+)/.exec(n);if(!i)return;let s=i[2].trim();return{type:"underline",raw:i[0],text:s,tokens:t.inlineTokens(s)}}},addCommands(){return{setUnderline:()=>({commands:n})=>n.setMark(this.name),toggleUnderline:()=>({commands:n})=>n.toggleMark(this.name),unsetUnderline:()=>({commands:n})=>n.unsetMark(this.name)}},addKeyboardShortcuts(){return{"Mod-u":()=>this.editor.commands.toggleUnderline(),"Mod-U":()=>this.editor.commands.toggleUnderline()}}});function lh(n={}){return new R({view(e){return new Zl(e,n)}})}var Zl=class{constructor(e,t){var r;this.editorView=e,this.cursorPos=null,this.element=null,this.timeout=-1,this.lastDragEvent=null,this.width=(r=t.width)!==null&&r!==void 0?r:1,this.color=t.color===!1?void 0:t.color||"black",this.class=t.class,this.handlers=["dragover","dragend","drop","dragleave"].map(i=>{let s=o=>{this[i](o)};return e.dom.addEventListener(i,s),{name:i,handler:s}})}destroy(){this.handlers.forEach(({name:e,handler:t})=>this.editorView.dom.removeEventListener(e,t))}update(e,t){if(this.cursorPos!=null&&t.doc!=e.state.doc)if(this.lastDragEvent){let r=this.computeTarget(this.lastDragEvent);r==this.cursorPos?this.updateOverlay():this.setCursor(r)}else this.updateOverlay()}setCursor(e){e!=this.cursorPos&&(this.cursorPos=e,e==null?(this.element.parentNode.removeChild(this.element),this.element=null):this.updateOverlay())}updateOverlay(){let e=this.editorView.state.doc.resolve(this.cursorPos),t=!e.parent.inlineContent,r,i=this.editorView.dom,s=i.getBoundingClientRect(),o=s.width/i.offsetWidth,l=s.height/i.offsetHeight;if(t){let d=e.nodeBefore,f=e.nodeAfter;if(d||f){let h=this.editorView.nodeDOM(this.cursorPos-(d?d.nodeSize:0));if(h){let p=h.getBoundingClientRect(),m=d?p.bottom:p.top;d&&f&&(m=(m+this.editorView.nodeDOM(this.cursorPos).getBoundingClientRect().top)/2);let g=this.width/2*l;r={left:p.left,right:p.right,top:m-g,bottom:m+g}}}}if(!r){let d=this.editorView.coordsAtPos(this.cursorPos),f=this.width/2*o;r={left:d.left-f,right:d.left+f,top:d.top,bottom:d.bottom}}let a=this.editorView.dom.offsetParent;this.element||(this.element=a.appendChild(document.createElement("div")),this.class&&(this.element.className=this.class),this.element.style.cssText="position: absolute; z-index: 50; pointer-events: none;",this.color&&(this.element.style.backgroundColor=this.color)),this.element.classList.toggle("prosemirror-dropcursor-block",t),this.element.classList.toggle("prosemirror-dropcursor-inline",!t);let c,u;if(!a||a==document.body&&getComputedStyle(a).position=="static")c=-pageXOffset,u=-pageYOffset;else{let d=a.getBoundingClientRect(),f=d.width/a.offsetWidth,h=d.height/a.offsetHeight;c=d.left-a.scrollLeft*f,u=d.top-a.scrollTop*h}this.element.style.left=(r.left-c)/o+"px",this.element.style.top=(r.top-u)/l+"px",this.element.style.width=(r.right-r.left)/o+"px",this.element.style.height=(r.bottom-r.top)/l+"px"}scheduleRemoval(e){clearTimeout(this.timeout),this.timeout=setTimeout(()=>this.setCursor(null),e)}computeTarget(e){let t=this.editorView.posAtCoords({left:e.clientX,top:e.clientY}),r=t&&t.inside>=0&&this.editorView.state.doc.nodeAt(t.inside),i=r&&r.type.spec.disableDropCursor,s=typeof i=="function"?i(this.editorView,t,e):i;if(!t||s)return null;let o=t.pos;if(this.editorView.dragging&&this.editorView.dragging.slice){let l=Lr(this.editorView.state.doc,o,this.editorView.dragging.slice);l!=null&&(o=l)}return o}dragover(e){if(!this.editorView.editable)return;this.lastDragEvent=e;let t=this.computeTarget(e);t!=null&&(this.setCursor(t),this.scheduleRemoval(5e3))}dragend(){this.scheduleRemoval(20)}drop(){this.scheduleRemoval(20)}dragleave(e){this.editorView.dom.contains(e.relatedTarget)||this.setCursor(null)}};var me=class n extends N{constructor(e){super(e,e)}map(e,t){let r=e.resolve(t.map(this.head));return n.valid(r)?new n(r):N.near(r)}content(){return v.empty}eq(e){return e instanceof n&&e.head==this.head}toJSON(){return{type:"gapcursor",pos:this.head}}static fromJSON(e,t){if(typeof t.pos!="number")throw new RangeError("Invalid input for GapCursor.fromJSON");return new n(e.resolve(t.pos))}getBookmark(){return new ea(this.anchor)}static valid(e){let t=e.parent;if(t.inlineContent||!Tk(e)||!Ak(e))return!1;let r=t.type.spec.allowGapCursor;if(r!=null)return r;let i=t.contentMatchAt(e.index()).defaultType;return i&&i.isTextblock}static findGapCursorFrom(e,t,r=!1){e:for(;;){if(!r&&n.valid(e))return e;let i=e.pos,s=null;for(let o=e.depth;;o--){let l=e.node(o);if(t>0?e.indexAfter(o)0){s=l.child(t>0?e.indexAfter(o):e.index(o)-1);break}else if(o==0)return null;i+=t;let a=e.doc.resolve(i);if(n.valid(a))return a}for(;;){let o=t>0?s.firstChild:s.lastChild;if(!o){if(s.isAtom&&!s.isText&&!A.isSelectable(s)){e=e.doc.resolve(i+s.nodeSize*t),r=!1;continue e}break}s=o,i+=t;let l=e.doc.resolve(i);if(n.valid(l))return l}return null}}};me.prototype.visible=!1;me.findFrom=me.findGapCursorFrom;N.jsonID("gapcursor",me);var ea=class n{constructor(e){this.pos=e}map(e){return new n(e.map(this.pos))}resolve(e){let t=e.resolve(this.pos);return me.valid(t)?new me(t):N.near(t)}};function ah(n){return n.isAtom||n.spec.isolating||n.spec.createGapCursor}function Tk(n){for(let e=n.depth;e>=0;e--){let t=n.index(e),r=n.node(e);if(t==0){if(r.type.spec.isolating)return!0;continue}for(let i=r.child(t-1);;i=i.lastChild){if(i.childCount==0&&!i.inlineContent||ah(i.type))return!0;if(i.inlineContent)return!1}}return!0}function Ak(n){for(let e=n.depth;e>=0;e--){let t=n.indexAfter(e),r=n.node(e);if(t==r.childCount){if(r.type.spec.isolating)return!0;continue}for(let i=r.child(t);;i=i.firstChild){if(i.childCount==0&&!i.inlineContent||ah(i.type))return!0;if(i.inlineContent)return!1}}return!0}function ch(){return new R({props:{decorations:Rk,createSelectionBetween(n,e,t){return e.pos==t.pos&&me.valid(t)?new me(t):null},handleClick:Nk,handleKeyDown:Ek,handleDOMEvents:{beforeinput:Ok}}})}var Ek=Jn({ArrowLeft:us("horiz",-1),ArrowRight:us("horiz",1),ArrowUp:us("vert",-1),ArrowDown:us("vert",1)});function us(n,e){let t=n=="vert"?e>0?"down":"up":e>0?"right":"left";return function(r,i,s){let o=r.selection,l=e>0?o.$to:o.$from,a=o.empty;if(o instanceof T){if(!s.endOfTextblock(t)||l.depth==0)return!1;a=!1,l=r.doc.resolve(e>0?l.after():l.before())}let c=me.findGapCursorFrom(l,e,a);return c?(i&&i(r.tr.setSelection(new me(c))),!0):!1}}function Nk(n,e,t){if(!n||!n.editable)return!1;let r=n.state.doc.resolve(e);if(!me.valid(r))return!1;let i=n.posAtCoords({left:t.clientX,top:t.clientY});return i&&i.inside>-1&&A.isSelectable(n.state.doc.nodeAt(i.inside))?!1:(n.dispatch(n.state.tr.setSelection(new me(r))),!0)}function Ok(n,e){if(e.inputType!="insertCompositionText"||!(n.state.selection instanceof me))return!1;let{$from:t}=n.state.selection,r=t.parent.contentMatchAt(t.index()).findWrapping(n.state.schema.nodes.text);if(!r)return!1;let i=x.empty;for(let o=r.length-1;o>=0;o--)i=x.from(r[o].createAndFill(null,i));let s=n.state.tr.replace(t.pos,t.pos,new v(i,0,0));return s.setSelection(T.near(s.doc.resolve(t.pos+1))),n.dispatch(s),!1}function Rk(n){if(!(n.selection instanceof me))return null;let e=document.createElement("div");return e.className="ProseMirror-gapcursor",j.create(n.doc,[te.widget(n.selection.head,e,{key:"gapcursor"})])}var ds=200,ce=function(){};ce.prototype.append=function(e){return e.length?(e=ce.from(e),!this.length&&e||e.length=t?ce.empty:this.sliceInner(Math.max(0,e),Math.min(this.length,t))};ce.prototype.get=function(e){if(!(e<0||e>=this.length))return this.getInner(e)};ce.prototype.forEach=function(e,t,r){t===void 0&&(t=0),r===void 0&&(r=this.length),t<=r?this.forEachInner(e,t,r,0):this.forEachInvertedInner(e,t,r,0)};ce.prototype.map=function(e,t,r){t===void 0&&(t=0),r===void 0&&(r=this.length);var i=[];return this.forEach(function(s,o){return i.push(e(s,o))},t,r),i};ce.from=function(e){return e instanceof ce?e:e&&e.length?new uh(e):ce.empty};var uh=(function(n){function e(r){n.call(this),this.values=r}n&&(e.__proto__=n),e.prototype=Object.create(n&&n.prototype),e.prototype.constructor=e;var t={length:{configurable:!0},depth:{configurable:!0}};return e.prototype.flatten=function(){return this.values},e.prototype.sliceInner=function(i,s){return i==0&&s==this.length?this:new e(this.values.slice(i,s))},e.prototype.getInner=function(i){return this.values[i]},e.prototype.forEachInner=function(i,s,o,l){for(var a=s;a=o;a--)if(i(this.values[a],l+a)===!1)return!1},e.prototype.leafAppend=function(i){if(this.length+i.length<=ds)return new e(this.values.concat(i.flatten()))},e.prototype.leafPrepend=function(i){if(this.length+i.length<=ds)return new e(i.flatten().concat(this.values))},t.length.get=function(){return this.values.length},t.depth.get=function(){return 0},Object.defineProperties(e.prototype,t),e})(ce);ce.empty=new uh([]);var Ik=(function(n){function e(t,r){n.call(this),this.left=t,this.right=r,this.length=t.length+r.length,this.depth=Math.max(t.depth,r.depth)+1}return n&&(e.__proto__=n),e.prototype=Object.create(n&&n.prototype),e.prototype.constructor=e,e.prototype.flatten=function(){return this.left.flatten().concat(this.right.flatten())},e.prototype.getInner=function(r){return rl&&this.right.forEachInner(r,Math.max(i-l,0),Math.min(this.length,s)-l,o+l)===!1)return!1},e.prototype.forEachInvertedInner=function(r,i,s,o){var l=this.left.length;if(i>l&&this.right.forEachInvertedInner(r,i-l,Math.max(s,l)-l,o+l)===!1||s=s?this.right.slice(r-s,i-s):this.left.slice(r,s).append(this.right.slice(0,i-s))},e.prototype.leafAppend=function(r){var i=this.right.leafAppend(r);if(i)return new e(this.left,i)},e.prototype.leafPrepend=function(r){var i=this.left.leafPrepend(r);if(i)return new e(i,this.right)},e.prototype.appendInner=function(r){return this.left.depth>=Math.max(this.right.depth,r.depth)+1?new e(this.left,new e(this.right,r)):new e(this,r)},e})(ce),ta=ce;var Dk=500,Gt=class n{constructor(e,t){this.items=e,this.eventCount=t}popEvent(e,t){if(this.eventCount==0)return null;let r=this.items.length;for(;;r--)if(this.items.get(r-1).selection){--r;break}let i,s;t&&(i=this.remapping(r,this.items.length),s=i.maps.length);let o=e.tr,l,a,c=[],u=[];return this.items.forEach((d,f)=>{if(!d.step){i||(i=this.remapping(r,f+1),s=i.maps.length),s--,u.push(d);return}if(i){u.push(new qe(d.map));let h=d.step.map(i.slice(s)),p;h&&o.maybeStep(h).doc&&(p=o.mapping.maps[o.mapping.maps.length-1],c.push(new qe(p,void 0,void 0,c.length+u.length))),s--,p&&i.appendMap(p,s)}else o.maybeStep(d.step);if(d.selection)return l=i?d.selection.map(i.slice(s)):d.selection,a=new n(this.items.slice(0,r).append(u.reverse().concat(c)),this.eventCount-1),!1},this.items.length,0),{remaining:a,transform:o,selection:l}}addTransform(e,t,r,i){let s=[],o=this.eventCount,l=this.items,a=!i&&l.length?l.get(l.length-1):null;for(let u=0;uPk&&(l=Lk(l,c),o-=c),new n(l.append(s),o)}remapping(e,t){let r=new On;return this.items.forEach((i,s)=>{let o=i.mirrorOffset!=null&&s-i.mirrorOffset>=e?r.maps.length-i.mirrorOffset:void 0;r.appendMap(i.map,o)},e,t),r}addMaps(e){return this.eventCount==0?this:new n(this.items.append(e.map(t=>new qe(t))),this.eventCount)}rebased(e,t){if(!this.eventCount)return this;let r=[],i=Math.max(0,this.items.length-t),s=e.mapping,o=e.steps.length,l=this.eventCount;this.items.forEach(f=>{f.selection&&l--},i);let a=t;this.items.forEach(f=>{let h=s.getMirror(--a);if(h==null)return;o=Math.min(o,h);let p=s.maps[h];if(f.step){let m=e.steps[h].invert(e.docs[h]),g=f.selection&&f.selection.map(s.slice(a+1,h));g&&l++,r.push(new qe(p,m,g))}else r.push(new qe(p))},i);let c=[];for(let f=t;fDk&&(d=d.compress(this.items.length-r.length)),d}emptyItemCount(){let e=0;return this.items.forEach(t=>{t.step||e++}),e}compress(e=this.items.length){let t=this.remapping(0,e),r=t.maps.length,i=[],s=0;return this.items.forEach((o,l)=>{if(l>=e)i.push(o),o.selection&&s++;else if(o.step){let a=o.step.map(t.slice(r)),c=a&&a.getMap();if(r--,c&&t.appendMap(c,r),a){let u=o.selection&&o.selection.map(t.slice(r));u&&s++;let d=new qe(c.invert(),a,u),f,h=i.length-1;(f=i.length&&i[h].merge(d))?i[h]=f:i.push(d)}}else o.map&&r--},this.items.length,0),new n(ta.from(i.reverse()),s)}};Gt.empty=new Gt(ta.empty,0);function Lk(n,e){let t;return n.forEach((r,i)=>{if(r.selection&&e--==0)return t=i,!1}),n.slice(t)}var qe=class n{constructor(e,t,r,i){this.map=e,this.step=t,this.selection=r,this.mirrorOffset=i}merge(e){if(this.step&&e.step&&!e.selection){let t=e.step.merge(this.step);if(t)return new n(t.getMap().invert(),t,this.selection)}}},Ue=class{constructor(e,t,r,i,s){this.done=e,this.undone=t,this.prevRanges=r,this.prevTime=i,this.prevComposition=s}},Pk=20;function zk(n,e,t,r){let i=t.getMeta(Ut),s;if(i)return i.historyState;t.getMeta(Hk)&&(n=new Ue(n.done,n.undone,null,0,-1));let o=t.getMeta("appendedTransaction");if(t.steps.length==0)return n;if(o&&o.getMeta(Ut))return o.getMeta(Ut).redo?new Ue(n.done.addTransform(t,void 0,r,fs(e)),n.undone,dh(t.mapping.maps),n.prevTime,n.prevComposition):new Ue(n.done,n.undone.addTransform(t,void 0,r,fs(e)),null,n.prevTime,n.prevComposition);if(t.getMeta("addToHistory")!==!1&&!(o&&o.getMeta("addToHistory")===!1)){let l=t.getMeta("composition"),a=n.prevTime==0||!o&&n.prevComposition!=l&&(n.prevTime<(t.time||0)-r.newGroupDelay||!Bk(t,n.prevRanges)),c=o?na(n.prevRanges,t.mapping):dh(t.mapping.maps);return new Ue(n.done.addTransform(t,a?e.selection.getBookmark():void 0,r,fs(e)),Gt.empty,c,t.time,l==null?n.prevComposition:l)}else return(s=t.getMeta("rebased"))?new Ue(n.done.rebased(t,s),n.undone.rebased(t,s),na(n.prevRanges,t.mapping),n.prevTime,n.prevComposition):new Ue(n.done.addMaps(t.mapping.maps),n.undone.addMaps(t.mapping.maps),na(n.prevRanges,t.mapping),n.prevTime,n.prevComposition)}function Bk(n,e){if(!e)return!1;if(!n.docChanged)return!0;let t=!1;return n.mapping.maps[0].forEach((r,i)=>{for(let s=0;s=e[s]&&(t=!0)}),t}function dh(n){let e=[];for(let t=n.length-1;t>=0&&e.length==0;t--)n[t].forEach((r,i,s,o)=>e.push(s,o));return e}function na(n,e){if(!n)return null;let t=[];for(let r=0;r{let i=Ut.getState(t);if(!i||(n?i.undone:i.done).eventCount==0)return!1;if(r){let s=Fk(i,t,n);s&&r(e?s.scrollIntoView():s)}return!0}}var ia=hs(!1,!0),sa=hs(!0,!0),zv=hs(!1,!1),Bv=hs(!0,!1);var Wv=$.create({name:"characterCount",addOptions(){return{limit:null,autoTrim:!0,mode:"textSize",textCounter:n=>n.length,wordCounter:n=>n.split(" ").filter(e=>e!=="").length}},addStorage(){return{characters:()=>0,words:()=>0}},onBeforeCreate(){this.storage.characters=n=>{let e=(n==null?void 0:n.node)||this.editor.state.doc;if(((n==null?void 0:n.mode)||this.options.mode)==="textSize"){let r=e.textBetween(0,e.content.size,void 0," ");return this.options.textCounter(r)}return e.nodeSize},this.storage.words=n=>{let e=(n==null?void 0:n.node)||this.editor.state.doc,t=e.textBetween(0,e.content.size," "," ");return this.options.wordCounter(t)}},addProseMirrorPlugins(){let n=!1;return[new R({key:new z("characterCount"),appendTransaction:(e,t,r)=>{if(n)return;let i=this.options.limit,s=this.options.autoTrim;if(i==null||i===0||s===!1){n=!0;return}let o=this.storage.characters({node:r.doc});if(o>i){let l=o-i,a=0,c=l;console.warn(`[CharacterCount] Initial content exceeded limit of ${i} characters. Content was automatically trimmed.`);let u=r.tr.deleteRange(a,c);return n=!0,u}n=!0},filterTransaction:(e,t)=>{let r=this.options.limit;if(!e.docChanged||r===0||r===null||r===void 0)return!0;let i=this.storage.characters({node:t.doc}),s=this.storage.characters({node:e.doc});if(s<=r||i>r&&s>r&&s<=i)return!0;if(i>r&&s>r&&s>i||!e.getMeta("paste"))return!1;let l=e.selection.$head.pos,a=s-r,c=l-a,u=l;return e.deleteRange(c,u),!(this.storage.characters({node:e.doc})>r)}})]}}),gh=$.create({name:"dropCursor",addOptions(){return{color:"currentColor",width:1,class:void 0}},addProseMirrorPlugins(){return[lh(this.options)]}}),Gv=$.create({name:"focus",addOptions(){return{className:"has-focus",mode:"all"}},addProseMirrorPlugins(){return[new R({key:new z("focus"),props:{decorations:({doc:n,selection:e})=>{let{isEditable:t,isFocused:r}=this.editor,{anchor:i}=e,s=[];if(!t||!r)return j.create(n,[]);let o=0;this.options.mode==="deepest"&&n.descendants((a,c)=>{if(a.isText)return;if(!(i>=c&&i<=c+a.nodeSize-1))return!1;o+=1});let l=0;return n.descendants((a,c)=>{if(a.isText||!(i>=c&&i<=c+a.nodeSize-1))return!1;if(l+=1,this.options.mode==="deepest"&&o-l>0||this.options.mode==="shallowest"&&l>1)return this.options.mode==="deepest";s.push(te.node(c,c+a.nodeSize,{class:this.options.className}))}),j.create(n,s)}}})]}}),yh=$.create({name:"gapCursor",addProseMirrorPlugins(){return[ch()]},extendNodeSchema(n){var e;let t={name:n.name,options:n.options,storage:n.storage};return{allowGapCursor:(e=V(E(n,"allowGapCursor",t)))!=null?e:null}}}),bh="placeholder",ph=new z("tiptap__placeholder");function kh(n){let{editor:e,placeholder:t,dataAttribute:r,pos:i,node:s,isEmptyDoc:o,hasAnchor:l,classes:{emptyNode:a,emptyEditor:c}}=n,u=[a];return o&&u.push(c),te.node(i,i+s.nodeSize,{class:u.join(" "),[r]:typeof t=="function"?t({editor:e,node:s,pos:i,hasAnchor:l}):t})}function xh(n,e){return typeof n=="function"?n(e):n}function wh({editor:n,options:e,dataAttribute:t,doc:r,selection:i,from:s,to:o}){let{anchor:l}=i,a=[],c=n.isEmpty;return r.nodesBetween(s,o,(u,d)=>{let f=l>=d&&l<=d+u.nodeSize,h=!u.isLeaf&&pn(u);return u.type.isTextblock&&(f||!e.showOnlyCurrent)&&h&&a.push(kh({editor:n,isEmptyDoc:c,dataAttribute:t,hasAnchor:f,placeholder:e.placeholder,classes:{emptyEditor:e.emptyEditorClass,emptyNode:xh(e.emptyNodeClass,{editor:n,node:u,pos:d,hasAnchor:f})},node:u,pos:d})),e.includeChildren}),a}function Sh({editor:n,options:e,dataAttribute:t,doc:r,selection:i}){if(!(n.isEditable||!e.showOnlyWhenEditable))return null;let{anchor:o}=i,l=[],a=n.isEmpty;if(e.showOnlyCurrent&&!e.includeChildren){let u=r.resolve(o),d=u.depth>0?u.node(1):u.nodeAfter,f=u.depth>0?u.before(1):o;if(d&&d.type.isTextblock&&pn(d)){let h=o>=f&&o<=f+d.nodeSize;l.push(kh({editor:n,isEmptyDoc:a,dataAttribute:t,hasAnchor:h,placeholder:e.placeholder,classes:{emptyEditor:e.emptyEditorClass,emptyNode:xh(e.emptyNodeClass,{editor:n,node:d,pos:f,hasAnchor:h})},node:d,pos:f}))}}else l.push(...wh({editor:n,options:e,dataAttribute:t,doc:r,selection:i,from:0,to:r.content.size}));return j.create(r,l)}function wr(n,e){var t;let r=n.resolve(e);if(r.depth===0){let o=(t=r.nodeAfter)!=null?t:r.nodeBefore;if(!o)return{from:e,to:e};let l=r.nodeAfter?e:e-o.nodeSize;return{from:l,to:l+o.nodeSize}}let i=r.before(1),s=r.node(1);return{from:i,to:i+s.nodeSize}}function Sr(n,e){return{from:Math.max(0,e.from-1),to:Math.min(n.content.size,e.to-1)}}function $k(n,e,t){let r=[];return n.forEach((i,s)=>{let o=s,l=o+i.nodeSize,a=o+1,c=l+1;ae&&r.push({from:o,to:l})}),r}function _k(n){if(n.length===0)return[];let e=[...n].sort((r,i)=>r.from-i.from),t=[{...e[0]}];for(let r=1;re.from?t.push(Sr(n,wr(n,Math.min(e.to,n.content.size+1)-1))):e.fromh.from>=c&&h.to<=u);d.length&&(l=l.remove(d));let f=wh({editor:t,options:r,dataAttribute:i,doc:s,selection:o,from:c,to:u});f.length&&(l=l.add(s,f))}return l}function Jk({editor:n,options:e,dataAttribute:t}){return{init(r,i){let s=Sh({editor:n,options:e,dataAttribute:t,doc:i.doc,selection:i.selection});return s!=null?s:j.empty},apply(r,i,s,o){if(!r.docChanged&&!r.selectionSet)return i;let l=i.map(r.mapping,r.doc),a=Wk(r,s,o);return Kk({decorations:l,ranges:a,editor:n,options:e,dataAttribute:t,doc:o.doc,selection:o.selection})}}}function qk(n){return n.replace(/\s+/g,"-").replace(/[^a-zA-Z0-9-]/g,"").replace(/^[0-9-]+/,"").replace(/^-+/,"").toLowerCase()}function Uk({editor:n,options:e}){let t=e.dataAttribute?`data-${qk(e.dataAttribute)}`:`data-${bh}`,r=e.showOnlyCurrent&&!e.includeChildren;return new R({key:ph,...r?{}:{state:Jk({editor:n,options:e,dataAttribute:t})},props:{decorations:r?({doc:i,selection:s})=>Sh({editor:n,options:e,dataAttribute:t,doc:i,selection:s}):i=>{var s;return e.showOnlyWhenEditable&&!n.isEditable?j.empty:(s=ph.getState(i))!=null?s:j.empty}}})}var lC=$.create({name:"placeholder",addOptions(){return{emptyEditorClass:"is-editor-empty",emptyNodeClass:"is-empty",dataAttribute:bh,placeholder:"Write something \u2026",showOnlyWhenEditable:!0,showOnlyCurrent:!0,includeChildren:!1}},addProseMirrorPlugins(){return[Uk({editor:this.editor,options:this.options})]}}),Gk=`.ProseMirror:not(.ProseMirror-focused) *::selection { + background: transparent; +} + +.ProseMirror:not(.ProseMirror-focused) *::-moz-selection { + background: transparent; +}`,dC=$.create({name:"selection",addOptions(){return{className:"selection"}},addProseMirrorPlugins(){let{editor:n,options:e}=this;return n.options.injectCSS&&typeof document!="undefined"&&Zo(Gk,n.options.injectNonce,"selection"),[new R({key:new z("selection"),props:{decorations(t){return t.selection.empty||n.isFocused||!n.isEditable||ui(t.selection)||n.view.dragging?null:j.create(t.doc,[te.inline(t.selection.from,t.selection.to,{class:e.className})])}}})]}}),Xk="skipTrailingNode";function mh({types:n,node:e}){return e&&Array.isArray(n)&&n.includes(e.type)||(e==null?void 0:e.type)===n}var vh=$.create({name:"trailingNode",addOptions(){return{node:void 0,notAfter:[]}},addProseMirrorPlugins(){var n;let e=new z(this.name),t=this.options.node||((n=this.editor.schema.topNodeType.contentMatch.defaultType)==null?void 0:n.name)||"paragraph",r=Object.entries(this.editor.schema.nodes).map(([,i])=>i).filter(i=>(this.options.notAfter||[]).concat(t).includes(i.name));return[new R({key:e,appendTransaction:(i,s,o)=>{let{doc:l,tr:a,schema:c}=o,u=e.getState(o),d=l.content.size,f=c.nodes[t];if(!i.some(h=>h.getMeta(Xk))&&u)return a.insert(d,f.create())},state:{init:(i,s)=>{let o=s.tr.doc.lastChild;return!mh({node:o,types:r})},apply:(i,s)=>{if(!i.docChanged||i.getMeta("__uniqueIDTransaction"))return s;let o=i.doc.lastChild;return!mh({node:o,types:r})}}})]}}),Ch=$.create({name:"undoRedo",addOptions(){return{depth:100,newGroupDelay:500}},addCommands(){return{undo:()=>({state:n,dispatch:e})=>ia(n,e),redo:()=>({state:n,dispatch:e})=>sa(n,e)}},addProseMirrorPlugins(){return[hh(this.options)]},addKeyboardShortcuts(){return{"Mod-z":()=>this.editor.commands.undo(),"Shift-Mod-z":()=>this.editor.commands.redo(),"Mod-y":()=>this.editor.commands.redo(),"Mod-\u044F":()=>this.editor.commands.undo(),"Shift-Mod-\u044F":()=>this.editor.commands.redo()}}});var Yk=$.create({name:"starterKit",addExtensions(){var n,e,t,r;let i=[];return this.options.bold!==!1&&i.push(Sf.configure(this.options.bold)),this.options.blockquote!==!1&&i.push(wf.configure(this.options.blockquote)),this.options.bulletList!==!1&&i.push(jl.configure(this.options.bulletList)),this.options.code!==!1&&i.push(vf.configure(this.options.code)),this.options.codeBlock!==!1&&i.push(Cf.configure(this.options.codeBlock)),this.options.document!==!1&&i.push(Mf.configure(this.options.document)),this.options.dropcursor!==!1&&i.push(gh.configure(this.options.dropcursor)),this.options.gapcursor!==!1&&i.push(yh.configure(this.options.gapcursor)),this.options.hardBreak!==!1&&i.push(Tf.configure(this.options.hardBreak)),this.options.heading!==!1&&i.push(Af.configure(this.options.heading)),this.options.undoRedo!==!1&&i.push(Ch.configure(this.options.undoRedo)),this.options.horizontalRule!==!1&&i.push(Ef.configure(this.options.horizontalRule)),this.options.italic!==!1&&i.push(Nf.configure(this.options.italic)),this.options.listItem!==!1&&i.push(Ul.configure(this.options.listItem)),this.options.listKeymap!==!1&&i.push(Xl.configure((n=this.options)==null?void 0:n.listKeymap)),this.options.link!==!1&&i.push($l.configure((e=this.options)==null?void 0:e.link)),this.options.orderedList!==!1&&i.push(Yl.configure(this.options.orderedList)),this.options.paragraph!==!1&&i.push(rh.configure(this.options.paragraph)),this.options.strike!==!1&&i.push(ih.configure(this.options.strike)),this.options.text!==!1&&i.push(sh.configure(this.options.text)),this.options.underline!==!1&&i.push(oh.configure((t=this.options)==null?void 0:t.underline)),this.options.trailingNode!==!1&&i.push(vh.configure((r=this.options)==null?void 0:r.trailingNode)),i}}),Mh=Yk;var Qk=/(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/,Zk=_.create({name:"image",addOptions(){return{inline:!1,allowBase64:!1,HTMLAttributes:{},resize:!1}},inline(){return this.options.inline},group(){return this.options.inline?"inline":"block"},draggable:!0,addAttributes(){return{src:{default:null},alt:{default:null},title:{default:null},width:{default:null},height:{default:null}}},parseHTML(){return[{tag:this.options.allowBase64?"img[src]":'img[src]:not([src^="data:"])'}]},renderHTML({HTMLAttributes:n}){return["img",I(this.options.HTMLAttributes,n)]},parseMarkdown:(n,e)=>e.createNode("image",{src:n.href,title:n.title,alt:n.text}),renderMarkdown:n=>{var e,t,r,i,s,o;let l=(t=(e=n.attrs)==null?void 0:e.src)!=null?t:"",a=(i=(r=n.attrs)==null?void 0:r.alt)!=null?i:"",c=(o=(s=n.attrs)==null?void 0:s.title)!=null?o:"";return c?`![${a}](${l} "${c}")`:`![${a}](${l})`},addNodeView(){if(!this.options.resize||!this.options.resize.enabled||typeof document=="undefined")return null;let{directions:n,minWidth:e,minHeight:t,alwaysPreserveAspectRatio:r}=this.options.resize;return({node:i,getPos:s,HTMLAttributes:o,editor:l})=>{let a=document.createElement("img");a.draggable=!1;let c=I(this.options.HTMLAttributes,o);Object.entries(c).forEach(([f,h])=>{if(h!=null)switch(f){case"width":case"height":break;default:a.setAttribute(f,h);break}}),c.src!==null&&(a.src=c.src);let u=new Bd({element:a,editor:l,node:i,getPos:s,onResize:(f,h)=>{a.style.width=`${f}px`,a.style.height=`${h}px`},onCommit:(f,h)=>{let p=s();p!==void 0&&this.editor.chain().setNodeSelection(p).updateAttributes(this.name,{width:f,height:h}).run()},onUpdate:(f,h,p)=>f.type===i.type,options:{directions:n,min:{width:e,height:t},preserveAspectRatio:r===!0}}),d=u.dom;return d.style.visibility="hidden",d.style.pointerEvents="none",a.onload=()=>{d.style.visibility="",d.style.pointerEvents=""},u}},addCommands(){return{setImage:n=>({commands:e})=>e.insertContent({type:this.name,attrs:n})}},addInputRules(){return[pi({find:Qk,type:this.type,getAttributes:n=>{let[,,e,t,r]=n;return{src:t,alt:e,title:r}}})]}}),Th=Zk;var la,aa;if(typeof WeakMap!="undefined"){let n=new WeakMap;la=e=>n.get(e),aa=(e,t)=>(n.set(e,t),t)}else{let n=[],t=0;la=r=>{for(let i=0;i(t==10&&(t=0),n[t++]=r,n[t++]=i)}var Y=class{constructor(n,e,t,r){this.width=n,this.height=e,this.map=t,this.problems=r}findCell(n){for(let e=0;e=t){(s||(s=[])).push({type:"overlong_rowspan",pos:u,n:y-k});break}let w=i+k*e;for(let C=0;Cr&&(s+=c.attrs.colspan)}}for(let o=0;o1&&(t=!0)}e==-1?e=s:e!=s&&(e=Math.max(e,s))}return e}function nx(n,e,t){n.problems||(n.problems=[]);let r={};for(let i=0;i0;e--)if(n.node(e).type.spec.tableRole=="row")return n.node(0).resolve(n.before(e+1));return null}function ix(n){for(let e=n.depth;e>0;e--){let t=n.node(e).type.spec.tableRole;if(t==="cell"||t==="header_cell")return n.node(e)}return null}function He(n){let e=n.selection.$head;for(let t=e.depth;t>0;t--)if(e.node(t).type.spec.tableRole=="row")return!0;return!1}function ks(n){let e=n.selection;if("$anchorCell"in e&&e.$anchorCell)return e.$anchorCell.pos>e.$headCell.pos?e.$anchorCell:e.$headCell;if("node"in e&&e.node&&e.node.type.spec.tableRole=="cell")return e.$anchor;let t=Xt(e.$head)||sx(e.$head);if(t)return t;throw new RangeError(`No cell found around position ${e.head}`)}function sx(n){for(let e=n.nodeAfter,t=n.pos;e;e=e.firstChild,t++){let r=e.type.spec.tableRole;if(r=="cell"||r=="header_cell")return n.doc.resolve(t)}for(let e=n.nodeBefore,t=n.pos;e;e=e.lastChild,t--){let r=e.type.spec.tableRole;if(r=="cell"||r=="header_cell")return n.doc.resolve(t-e.nodeSize)}}function ca(n){return n.parent.type.spec.tableRole=="row"&&!!n.nodeAfter}function ox(n){return n.node(0).resolve(n.pos+n.nodeAfter.nodeSize)}function fa(n,e){return n.depth==e.depth&&n.pos>=e.start(-1)&&n.pos<=e.end(-1)}function zh(n,e,t){let r=n.node(-1),i=Y.get(r),s=n.start(-1),o=i.nextCell(n.pos-s,e,t);return o==null?null:n.node(0).resolve(s+o)}function Yt(n,e,t=1){let r={...n,colspan:n.colspan-t};return r.colwidth&&(r.colwidth=r.colwidth.slice(),r.colwidth.splice(e,t),r.colwidth.some(i=>i>0)||(r.colwidth=null)),r}function Bh(n,e,t=1){let r={...n,colspan:n.colspan+t};if(r.colwidth){r.colwidth=r.colwidth.slice();for(let i=0;iu!=t.pos-s);a.unshift(t.pos-s);let c=a.map(u=>{let d=r.nodeAt(u);if(!d)throw new RangeError(`No cell with offset ${u} found`);let f=s+u+1;return new sn(l.resolve(f),l.resolve(f+d.content.size))});super(c[0].$from,c[0].$to,c),this.$anchorCell=e,this.$headCell=t}map(e,t){let r=e.resolve(t.map(this.$anchorCell.pos)),i=e.resolve(t.map(this.$headCell.pos));if(ca(r)&&ca(i)&&fa(r,i)){let s=this.$anchorCell.node(-1)!=r.node(-1);return s&&this.isRowSelection()?ut.rowSelection(r,i):s&&this.isColSelection()?ut.colSelection(r,i):new ut(r,i)}return T.between(r,i)}content(){let e=this.$anchorCell.node(-1),t=Y.get(e),r=this.$anchorCell.start(-1),i=t.rectBetween(this.$anchorCell.pos-r,this.$headCell.pos-r),s={},o=[];for(let a=i.top;a0||g>0){let y=p.attrs;if(m>0&&(y=Yt(y,0,m)),g>0&&(y=Yt(y,y.colspan-g,g)),h.lefti.bottom){let y={...p.attrs,rowspan:Math.min(h.bottom,i.bottom)-Math.max(h.top,i.top)};h.top0)return!1;let r=e+this.$anchorCell.nodeAfter.attrs.rowspan,i=t+this.$headCell.nodeAfter.attrs.rowspan;return Math.max(r,i)==this.$headCell.node(-1).childCount}static colSelection(e,t=e){let r=e.node(-1),i=Y.get(r),s=e.start(-1),o=i.findCell(e.pos-s),l=i.findCell(t.pos-s),a=e.node(0);return o.top<=l.top?(o.top>0&&(e=a.resolve(s+i.map[o.left])),l.bottom0&&(t=a.resolve(s+i.map[l.left])),o.bottom0)return!1;let o=i+this.$anchorCell.nodeAfter.attrs.colspan,l=s+this.$headCell.nodeAfter.attrs.colspan;return Math.max(o,l)==t.width}eq(e){return e instanceof ut&&e.$anchorCell.pos==this.$anchorCell.pos&&e.$headCell.pos==this.$headCell.pos}static rowSelection(e,t=e){let r=e.node(-1),i=Y.get(r),s=e.start(-1),o=i.findCell(e.pos-s),l=i.findCell(t.pos-s),a=e.node(0);return o.left<=l.left?(o.left>0&&(e=a.resolve(s+i.map[o.top*i.width])),l.right0&&(t=a.resolve(s+i.map[l.top*i.width])),o.right{e.push(te.node(r,r+t.nodeSize,{class:"selectedCell"}))}),j.create(n.doc,e)}function ux({$from:n,$to:e}){if(n.pos==e.pos||n.pos=0&&!(n.after(i+1)=0&&!(e.before(s+1)>e.start(s));s--,r--);return t==r&&/row|table/.test(n.node(i).type.spec.tableRole)}function dx({$from:n,$to:e}){let t,r;for(let i=n.depth;i>0;i--){let s=n.node(i);if(s.type.spec.tableRole==="cell"||s.type.spec.tableRole==="header_cell"){t=s;break}}for(let i=e.depth;i>0;i--){let s=e.node(i);if(s.type.spec.tableRole==="cell"||s.type.spec.tableRole==="header_cell"){r=s;break}}return t!==r&&e.parentOffset===0}function fx(n,e,t){let r=(e||n).selection,i=(e||n).doc,s,o;if(r instanceof A&&(o=r.node.type.spec.tableRole)){if(o=="cell"||o=="header_cell")s=K.create(i,r.from);else if(o=="row"){let l=i.resolve(r.from+1);s=K.rowSelection(l,l)}else if(!t){let l=Y.get(r.node),a=r.from+1,c=a+l.map[l.width*l.height-1];s=K.create(i,a+1,c)}}else r instanceof T&&ux(r)?s=T.create(i,r.from):r instanceof T&&dx(r)&&(s=T.create(i,r.$from.start(),r.$from.end()));return s&&(e||(e=n.tr)).setSelection(s),e}var hx=new z("fix-tables");function Hh(n,e,t,r){let i=n.childCount,s=e.childCount;e:for(let o=0,l=0;o{i.type.spec.tableRole=="table"&&(t=px(n,i,s,t))};return e?e.doc!=n.doc&&Hh(e.doc,n.doc,0,r):n.doc.descendants(r),t}function px(n,e,t,r){let i=Y.get(e);if(!i.problems)return r;r||(r=n.tr);let s=[];for(let a=0;a0){let h="cell";u.firstChild&&(h=u.firstChild.type.spec.tableRole);let p=[];for(let g=0;g0?-1:0;lx(e,r,i+s)&&(s=i==0||i==e.width?null:0);for(let o=0;o0&&i0&&e.map[l-1]==a||i0?-1:0;gx(e,r,i+l)&&(l=i==0||i==e.height?null:0);for(let c=0,u=e.width*i;c0&&i0&&d==e.map[u-e.width]){let f=t.nodeAt(d).attrs;n.setNodeMarkup(n.mapping.slice(l).map(d+r),null,{...f,rowspan:f.rowspan-1}),c+=f.colspan-1}else if(i0&&t[s]==t[s-1]||r.right0&&t[i]==t[i-n]||r.bottom0){let u=a+1+c.content.size,d=Ah(c)?a+1:u;s.replaceWith(d+r.tableStart,u+r.tableStart,l)}s.setSelection(new K(s.doc.resolve(a+r.tableStart))),e(s)}return!0}function ma(n,e){let t=ge(n.schema);return kx(({node:r})=>t[r.type.spec.tableRole])(n,e)}function kx(n){return(e,t)=>{let r=e.selection,i,s;if(r instanceof K){if(r.$anchorCell.pos!=r.$headCell.pos)return!1;i=r.$anchorCell.nodeAfter,s=r.$anchorCell.pos}else{var o;if(i=ix(r.$from),!i)return!1;s=(o=Xt(r.$from))===null||o===void 0?void 0:o.pos}if(i==null||s==null||i.attrs.colspan==1&&i.attrs.rowspan==1)return!1;if(t){let l=i.attrs,a=[],c=l.colwidth;l.rowspan>1&&(l={...l,rowspan:1}),l.colspan>1&&(l={...l,colspan:1});let u=Ge(e),d=e.tr;for(let h=0;h{o.attrs[n]!==e&&s.setNodeMarkup(l,null,{...o.attrs,[n]:e})}):s.setNodeMarkup(i.pos,null,{...i.nodeAfter.attrs,[n]:e}),r(s)}return!0}}function xx(n){return function(e,t){if(!He(e))return!1;if(t){let r=ge(e.schema),i=Ge(e),s=e.tr,o=i.map.cellsInRect(n=="column"?{left:i.left,top:0,right:i.right,bottom:i.map.height}:n=="row"?{left:0,top:i.top,right:i.map.width,bottom:i.bottom}:i),l=o.map(a=>i.table.nodeAt(a));for(let a=0;a{let h=f+s.tableStart,p=o.doc.nodeAt(h);p&&o.setNodeMarkup(h,d,p.attrs)}),r(o)}return!0}}var VC=Sn("row",{useDeprecatedLogic:!0}),WC=Sn("column",{useDeprecatedLogic:!0}),Gh=Sn("cell",{useDeprecatedLogic:!0});function wx(n,e){if(e<0){let t=n.nodeBefore;if(t)return n.pos-t.nodeSize;for(let r=n.index(-1)-1,i=n.before();r>=0;r--){let s=n.node(-1).child(r),o=s.lastChild;if(o)return i-1-o.nodeSize;i-=s.nodeSize}}else{if(n.index()0;r--)if(t.node(r).type.spec.tableRole=="table")return e&&e(n.tr.delete(t.before(r),t.after(r)).scrollIntoView()),!0;return!1}function ps(n,e){let t=n.selection;if(!(t instanceof K))return!1;if(e){let r=n.tr,i=ge(n.schema).cell.createAndFill().content;t.forEachCell((s,o)=>{s.content.eq(i)||r.replace(r.mapping.map(o+1),r.mapping.map(o+s.nodeSize-1),new v(i,0,0))}),r.docChanged&&e(r)}return!0}function Sx(n){if(n.size===0)return null;let{content:e,openStart:t,openEnd:r}=n;for(;e.childCount==1&&(t>0&&r>0||e.child(0).type.spec.tableRole=="table");)t--,r--,e=e.child(0).content;let i=e.child(0),s=i.type.spec.tableRole,o=i.type.schema,l=[];if(s=="row")for(let a=0;a=0;o--){let{rowspan:l,colspan:a}=s.child(o).attrs;for(let c=i;c=e.length&&e.push(x.empty),t[i]r&&(f=f.type.createChecked(Yt(f.attrs,f.attrs.colspan,u+f.attrs.colspan-r),f.content)),c.push(f),u+=f.attrs.colspan;for(let h=1;hi&&(d=d.type.create({...d.attrs,rowspan:Math.max(1,i-d.attrs.rowspan)},d.content)),a.push(d)}s.push(x.from(a))}t=s,e=i}return{width:n,height:e,rows:t}}function Mx(n,e,t,r,i,s,o){let l=n.doc.type.schema,a=ge(l),c,u;if(i>e.width)for(let d=0,f=0;de.height){let d=[];for(let p=0,m=(e.height-1)*e.width;p=e.width?!1:t.nodeAt(e.map[m+p]).type==a.header_cell;d.push(g?u||(u=a.header_cell.createAndFill()):c||(c=a.cell.createAndFill()))}let f=a.row.create(null,x.from(d)),h=[];for(let p=e.height;p{if(!i)return!1;let s=t.selection;if(s instanceof K)return ys(t,r,N.near(s.$headCell,e));if(n!="horiz"&&!s.empty)return!1;let o=Yh(i,n,e);if(o==null)return!1;if(n=="horiz")return ys(t,r,N.near(t.doc.resolve(s.head+e),e));{let l=t.doc.resolve(o),a=zh(l,n,e),c;return a?c=N.near(a,1):e<0?c=N.near(t.doc.resolve(l.before(-1)),-1):c=N.near(t.doc.resolve(l.after(-1)),1),ys(t,r,c)}}}function gs(n,e){return(t,r,i)=>{if(!i)return!1;let s=t.selection,o;if(s instanceof K)o=s;else{let a=Yh(i,n,e);if(a==null)return!1;o=new K(t.doc.resolve(a))}let l=zh(o.$headCell,n,e);return l?ys(t,r,new K(o.$anchorCell,l)):!1}}function Ax(n,e){let t=n.state.doc,r=Xt(t.resolve(e));return r?(n.dispatch(n.state.tr.setSelection(new K(r))),!0):!1}function Ex(n,e,t){if(!He(n.state))return!1;let r=Sx(t),i=n.state.selection;if(i instanceof K){r||(r={width:1,height:1,rows:[x.from(ua(ge(n.state.schema).cell,t))]});let s=i.$anchorCell.node(-1),o=i.$anchorCell.start(-1),l=Y.get(s).rectBetween(i.$anchorCell.pos-o,i.$headCell.pos-o);return r=Cx(r,l.right-l.left,l.bottom-l.top),Rh(n.state,n.dispatch,o,l,r),!0}else if(r){let s=ks(n.state),o=s.start(-1);return Rh(n.state,n.dispatch,o,Y.get(s.node(-1)).findCell(s.pos-o),r),!0}else return!1}function Nx(n,e){var t;if(e.button!=0||e.ctrlKey||e.metaKey)return;let r=Ih(n,e.target),i;if(e.shiftKey&&n.state.selection instanceof K)s(n.state.selection.$anchorCell,e),e.preventDefault();else if(e.shiftKey&&r&&(i=Xt(n.state.selection.$anchor))!=null&&((t=oa(n,e))===null||t===void 0?void 0:t.pos)!=i.pos)s(i,e),e.preventDefault();else if(!r)return;function s(a,c){let u=oa(n,c),d=vt.getState(n.state)==null;if(!u||!fa(a,u))if(d)u=a;else return;let f=new K(a,u);if(d||!n.state.selection.eq(f)){let h=n.state.tr.setSelection(f);d&&h.setMeta(vt,a.pos),n.dispatch(h)}}function o(){n.root.removeEventListener("mouseup",o),n.root.removeEventListener("dragstart",o),n.root.removeEventListener("mousemove",l),vt.getState(n.state)!=null&&n.dispatch(n.state.tr.setMeta(vt,-1))}function l(a){let c=a,u=vt.getState(n.state),d;if(u!=null)d=n.state.doc.resolve(u);else if(Ih(n,c.target)!=r&&(d=oa(n,e),!d))return o();d&&s(d,c)}n.root.addEventListener("mouseup",o),n.root.addEventListener("dragstart",o),n.root.addEventListener("mousemove",l)}function Yh(n,e,t){if(!(n.state.selection instanceof T))return null;let{$head:r}=n.state.selection;for(let i=r.depth-1;i>=0;i--){let s=r.node(i);if((t<0?r.index(i):r.indexAfter(i))!=(t<0?0:s.childCount))return null;if(s.type.spec.tableRole=="cell"||s.type.spec.tableRole=="header_cell"){let o=r.before(i),l=e=="vert"?t>0?"down":"up":t>0?"right":"left";return n.endOfTextblock(l)?o:null}}return null}function Ih(n,e){for(;e&&e!=n.dom;e=e.parentNode)if(e.nodeName=="TD"||e.nodeName=="TH")return e;return null}function oa(n,e){let t=n.posAtCoords({left:e.clientX,top:e.clientY});if(!t)return null;let{inside:r,pos:i}=t;return r>=0&&Xt(n.state.doc.resolve(r))||Xt(n.state.doc.resolve(i))}var Ox=class{constructor(n,e){this.node=n,this.defaultCellMinWidth=e,this.dom=document.createElement("div"),this.dom.className="tableWrapper",this.table=this.dom.appendChild(document.createElement("table")),this.table.style.setProperty("--default-cell-min-width",`${e}px`),this.colgroup=this.table.appendChild(document.createElement("colgroup")),da(n,this.colgroup,this.table,e),this.contentDOM=this.table.appendChild(document.createElement("tbody"))}update(n){return n.type!=this.node.type?!1:(this.node=n,da(n,this.colgroup,this.table,this.defaultCellMinWidth),!0)}ignoreMutation(n){return n.type=="attributes"&&(n.target==this.table||this.colgroup.contains(n.target))}};function da(n,e,t,r,i,s){let o=0,l=!0,a=e.firstChild,c=n.firstChild;if(c){for(let d=0,f=0;dnew r(d,t,f)),new Rx(-1,!1)},apply(o,l){return l.apply(o)}},props:{attributes:o=>{let l=Ne.getState(o);return l&&l.activeHandle>-1?{class:"resize-cursor"}:{}},handleDOMEvents:{mousemove:(o,l)=>{Ix(o,l,n,i)},mouseleave:o=>{Dx(o)},mousedown:(o,l)=>{Lx(o,l,e,t)}},decorations:o=>{let l=Ne.getState(o);if(l&&l.activeHandle>-1)return Hx(o,l.activeHandle)},nodeViews:{}}});return s}var Rx=class bs{constructor(e,t){this.activeHandle=e,this.dragging=t}apply(e){let t=this,r=e.getMeta(Ne);if(r&&r.setHandle!=null)return new bs(r.setHandle,!1);if(r&&r.setDragging!==void 0)return new bs(t.activeHandle,r.setDragging);if(t.activeHandle>-1&&e.docChanged){let i=e.mapping.map(t.activeHandle,-1);return ca(e.doc.resolve(i))||(i=-1),new bs(i,t.dragging)}return t}};function Ix(n,e,t,r){if(!n.editable)return;let i=Ne.getState(n.state);if(i&&!i.dragging){let s=zx(e.target),o=-1;if(s){let{left:l,right:a}=s.getBoundingClientRect();e.clientX-l<=t?o=Dh(n,e,"left",t):a-e.clientX<=t&&(o=Dh(n,e,"right",t))}if(o!=i.activeHandle){if(!r&&o!==-1){let l=n.state.doc.resolve(o),a=l.node(-1),c=Y.get(a),u=l.start(-1);if(c.colCount(l.pos-u)+l.nodeAfter.attrs.colspan-1==c.width-1)return}Zh(n,o)}}}function Dx(n){if(!n.editable)return;let e=Ne.getState(n.state);e&&e.activeHandle>-1&&!e.dragging&&Zh(n,-1)}function Lx(n,e,t,r){var i;if(!n.editable)return!1;let s=(i=n.dom.ownerDocument.defaultView)!==null&&i!==void 0?i:window,o=Ne.getState(n.state);if(!o||o.activeHandle==-1||o.dragging)return!1;let l=n.state.doc.nodeAt(o.activeHandle),a=Px(n,o.activeHandle,l.attrs);n.dispatch(n.state.tr.setMeta(Ne,{setDragging:{startX:e.clientX,startWidth:a}}));function c(d){s.removeEventListener("mouseup",c),s.removeEventListener("mousemove",u);let f=Ne.getState(n.state);f!=null&&f.dragging&&(Bx(n,f.activeHandle,Lh(f.dragging,d,t)),n.dispatch(n.state.tr.setMeta(Ne,{setDragging:null})))}function u(d){if(!d.which)return c(d);let f=Ne.getState(n.state);if(f&&f.dragging){let h=Lh(f.dragging,d,t);Ph(n,f.activeHandle,h,r)}}return Ph(n,o.activeHandle,a,r),s.addEventListener("mouseup",c),s.addEventListener("mousemove",u),e.preventDefault(),!0}function Px(n,e,{colspan:t,colwidth:r}){let i=r&&r[r.length-1];if(i)return i;let s=n.domAtPos(e),o=s.node.childNodes[s.offset].offsetWidth,l=t;if(r)for(let a=0;a$x(n),renderHTML:n=>n.align?{style:`text-align: ${n.align}`}:{}}}var Ss=_.create({name:"tableCell",addOptions(){return{HTMLAttributes:{}}},content:"block+",addAttributes(){return{colspan:{default:1},rowspan:{default:1},colwidth:{default:null,parseHTML:n=>{var e,t;let r=n.getAttribute("colwidth"),i=r?r.split(",").map(s=>parseInt(s,10)):null;if(!i){let s=(e=n.closest("table"))==null?void 0:e.querySelectorAll("colgroup > col"),o=Array.from(((t=n.parentElement)==null?void 0:t.children)||[]).indexOf(n);if(o&&o>-1&&s&&s[o]){let l=s[o].getAttribute("width");return l?[parseInt(l,10)]:null}}return i}},align:rp()}},tableRole:"cell",isolating:!0,parseHTML(){return[{tag:"td"}]},renderHTML({HTMLAttributes:n}){return["td",I(this.options.HTMLAttributes,n),0]}}),vs=_.create({name:"tableHeader",addOptions(){return{HTMLAttributes:{}}},content:"block+",addAttributes(){return{colspan:{default:1},rowspan:{default:1},colwidth:{default:null,parseHTML:n=>{let e=n.getAttribute("colwidth");return e?e.split(",").map(r=>parseInt(r,10)):null}},align:rp()}},tableRole:"header_cell",isolating:!0,parseHTML(){return[{tag:"th"}]},renderHTML({HTMLAttributes:n}){return["th",I(this.options.HTMLAttributes,n),0]}}),Cs=_.create({name:"tableRow",addOptions(){return{HTMLAttributes:{}}},content:"(tableCell | tableHeader)*",tableRole:"row",parseHTML(){return[{tag:"tr"}]},renderHTML({HTMLAttributes:n}){return["tr",I(this.options.HTMLAttributes,n),0]}});function ya(n,e){return e?["width",`${Math.max(e,n)}px`]:["min-width",`${n}px`]}function tp(n,e,t,r,i,s){var o;let l=0,a=!0,c=e.firstChild,u=n.firstChild;if(u!==null)for(let f=0,h=0;f{let r=n.nodes[t];r.spec.tableRole&&(e[r.spec.tableRole]=r)}),n.cached.tableNodeTypes=e,e}function Kx(n,e,t,r,i){let s=jx(n),o=[],l=[];for(let c=0;c{let{selection:e}=n.state;if(!Jx(e))return!1;let t=0,r=Go(e.ranges[0].$from,s=>s.type.name==="table");return r==null||r.node.descendants(s=>{if(s.type.name==="table")return!1;["tableCell","tableHeader"].includes(s.type.name)&&(t+=1)}),t===e.ranges.length?(n.commands.deleteTable(),!0):!1},qx="";function Ux(n){return(n||"").replace(/\s+/g," ").trim()}function Gx(n,e,t={}){var r;let i=(r=t.cellLineSeparator)!=null?r:qx;if(!n||!n.content||n.content.length===0)return"";let s=[];n.content.forEach(m=>{let g=[];m.content&&m.content.forEach(y=>{let b="";y.content&&Array.isArray(y.content)&&y.content.length>1?b=y.content.map(M=>e.renderChildren(M)).join(i):b=y.content?e.renderChildren(y.content):"";let k=Ux(b),w=y.type==="tableHeader",C=_x(y.attrs);g.push({text:k,isHeader:w,align:C})}),s.push(g)});let o=s.reduce((m,g)=>Math.max(m,g.length),0);if(o===0)return"";let l=Array.from({length:o}).fill(0);s.forEach(m=>{var g;for(let y=0;yl[y]&&(l[y]=k),l[y]<3&&(l[y]=3)}});let a=(m,g)=>m+" ".repeat(Math.max(0,g-m.length)),c=s[0],u=c.some(m=>m.isHeader),d=Array.from({length:o}).fill(null);s.forEach(m=>{var g;for(let y=0;yu&&c[g]&&c[g].text||"");return f+=`| ${h.map((m,g)=>a(m,l[g])).join(" | ")} | +`,f+=`| ${l.map((m,g)=>{let y=Math.max(3,m),b=d[g];return b==="left"?`:${"-".repeat(y)}`:b==="right"?`${"-".repeat(y)}:`:b==="center"?`:${"-".repeat(y)}:`:"-".repeat(y)}).join(" | ")} | +`,(u?s.slice(1):s).forEach(m=>{f+=`| ${Array.from({length:o}).fill(0).map((g,y)=>a(m[y]&&m[y].text||"",l[y])).join(" | ")} | +`}),f}var Xx=Gx,ba=_.create({name:"table",addOptions(){return{HTMLAttributes:{},resizable:!1,renderWrapper:!1,handleWidth:5,cellMinWidth:25,View:Vx,lastColumnResizable:!0,allowTableNodeSelection:!1}},content:"tableRow+",tableRole:"table",isolating:!0,group:"block",parseHTML(){return[{tag:"table"}]},renderHTML({node:n,HTMLAttributes:e}){let{colgroup:t,tableWidth:r,tableMinWidth:i}=Wx(n,this.options.cellMinWidth),s=e.style;function o(){return s||(r?`width: ${r}`:`min-width: ${i}`)}let l=["table",I(this.options.HTMLAttributes,e,{style:o()}),t,["tbody",0]];return this.options.renderWrapper?["div",{class:"tableWrapper"},l]:l},parseMarkdown:(n,e)=>{let t=[],r=Array.isArray(n.align)?n.align:[];if(n.header){let i=[];n.header.forEach((s,o)=>{var l;let a=ws((l=r[o])!=null?l:s.align),c=a?{align:a}:{};i.push(e.createNode("tableHeader",c,[{type:"paragraph",content:e.parseInline(s.tokens)}]))}),t.push(e.createNode("tableRow",{},i))}return n.rows&&n.rows.forEach(i=>{let s=[];i.forEach((o,l)=>{var a;let c=ws((a=r[l])!=null?a:o.align),u=c?{align:c}:{};s.push(e.createNode("tableCell",u,[{type:"paragraph",content:e.parseInline(o.tokens)}]))}),t.push(e.createNode("tableRow",{},s))}),e.createNode("table",void 0,t)},renderMarkdown:(n,e)=>Xx(n,e),addCommands(){return{insertTable:({rows:n=3,cols:e=3,withHeaderRow:t=!0}={})=>({tr:r,dispatch:i,editor:s})=>{let o=Kx(s.schema,n,e,t);if(i){let l=r.selection.from+1;r.replaceSelectionWith(o).scrollIntoView().setSelection(T.near(r.doc.resolve(l)))}return!0},addColumnBefore:()=>({state:n,dispatch:e})=>_h(n,e),addColumnAfter:()=>({state:n,dispatch:e})=>Vh(n,e),deleteColumn:()=>({state:n,dispatch:e})=>Wh(n,e),addRowBefore:()=>({state:n,dispatch:e})=>Kh(n,e),addRowAfter:()=>({state:n,dispatch:e})=>Jh(n,e),deleteRow:()=>({state:n,dispatch:e})=>qh(n,e),deleteTable:()=>({state:n,dispatch:e})=>Xh(n,e),mergeCells:()=>({state:n,dispatch:e})=>pa(n,e),splitCell:()=>({state:n,dispatch:e})=>ma(n,e),toggleHeaderColumn:()=>({state:n,dispatch:e})=>Sn("column")(n,e),toggleHeaderRow:()=>({state:n,dispatch:e})=>Sn("row")(n,e),toggleHeaderCell:()=>({state:n,dispatch:e})=>Gh(n,e),mergeOrSplit:()=>({state:n,dispatch:e})=>pa(n,e)?!0:ma(n,e),setCellAttribute:(n,e)=>({state:t,dispatch:r})=>Uh(n,e)(t,r),goToNextCell:()=>({state:n,dispatch:e})=>ga(1)(n,e),goToPreviousCell:()=>({state:n,dispatch:e})=>ga(-1)(n,e),fixTables:()=>({state:n,dispatch:e})=>(e&&ha(n),!0),setCellSelection:n=>({tr:e,dispatch:t})=>{if(t){let r=K.create(e.doc,n.anchorCell,n.headCell);e.setSelection(r)}return!0}}},addKeyboardShortcuts(){return{Tab:()=>this.editor.commands.goToNextCell()?!0:this.editor.can().addRowAfter()?this.editor.chain().addRowAfter().goToNextCell().run():!1,"Shift-Tab":()=>this.editor.commands.goToPreviousCell(),Backspace:xs,"Mod-Backspace":xs,Delete:xs,"Mod-Delete":xs}},addProseMirrorPlugins(){return[...this.options.resizable&&this.editor.isEditable?[Qh({handleWidth:this.options.handleWidth,cellMinWidth:this.options.cellMinWidth,defaultCellMinWidth:this.options.cellMinWidth,View:this.options.View,lastColumnResizable:this.options.lastColumnResizable})]:[],ep({allowTableNodeSelection:this.options.allowTableNodeSelection})]},addNodeView(){let n=this.options.resizable&&this.editor.isEditable,e=this.options.View;return n||!e?null:({node:t,view:r,HTMLAttributes:i})=>{let s=I(this.options.HTMLAttributes,i);return new e(t,this.options.cellMinWidth,r,s)}},extendNodeSchema(n){let e={name:n.name,options:n.options,storage:n.storage};return{tableRole:V(E(n,"tableRole",e))}}}),iM=$.create({name:"tableKit",addExtensions(){let n=[];return this.options.table!==!1&&n.push(ba.configure(this.options.table)),this.options.tableCell!==!1&&n.push(Ss.configure(this.options.tableCell)),this.options.tableHeader!==!1&&n.push(vs.configure(this.options.tableHeader)),this.options.tableRow!==!1&&n.push(Cs.configure(this.options.tableRow)),n}});var ip=Cs;var sp=Ss;var op=vs;var Yx=$.create({name:"textAlign",addOptions(){return{types:[],alignments:["left","center","right","justify"],defaultAlignment:null}},addGlobalAttributes(){return[{types:this.options.types,attributes:{textAlign:{default:this.options.defaultAlignment,parseHTML:n=>{let e=n.style.textAlign;return this.options.alignments.includes(e)?e:this.options.defaultAlignment},renderHTML:n=>n.textAlign?{style:`text-align: ${n.textAlign}`}:{}}}}]},addCommands(){return{setTextAlign:n=>({commands:e})=>this.options.alignments.includes(n)?this.options.types.map(t=>e.updateAttributes(t,{textAlign:n})).some(t=>t):!1,unsetTextAlign:()=>({commands:n})=>this.options.types.map(e=>n.resetAttributes(e,"textAlign")).some(e=>e),toggleTextAlign:n=>({editor:e,commands:t})=>this.options.alignments.includes(n)?e.isActive({textAlign:n})?t.unsetTextAlign():t.setTextAlign(n):!1}},addKeyboardShortcuts(){return{"Mod-Shift-l":()=>this.editor.commands.setTextAlign("left"),"Mod-Shift-e":()=>this.editor.commands.setTextAlign("center"),"Mod-Shift-r":()=>this.editor.commands.setTextAlign("right"),"Mod-Shift-j":()=>this.editor.commands.setTextAlign("justify")}}}),lp=Yx;var Qx=Th.extend({addAttributes:function(){return Object.assign({},this.parent?this.parent():{},{class:{default:null},webp:{default:null}})},parseHTML:function(){return[{tag:"picture",getAttrs:function(n){var e=n.querySelector("img[src]"),t=n.querySelector('source[type="image/webp"], source[srcset$=".webp"]');return e?{src:e.getAttribute("src"),alt:e.getAttribute("alt"),title:e.getAttribute("title"),class:e.getAttribute("class"),width:e.getAttribute("width"),height:e.getAttribute("height"),webp:t?t.getAttribute("srcset"):null}:!1}},{tag:'img[src]:not([src^="data:"])'}]},renderHTML:function(n){var e=Object.assign({},n.HTMLAttributes||{}),t=e.webp;delete e.webp;var r=["img",I(this.options.HTMLAttributes,e)];return t?["picture",{},["source",{srcset:t,type:"image/webp"}],r]:r}});(function(n,e){"use strict";var t=n.Adminx||(n.Adminx={});function r(o,l,a){var c=e.createElement(o);return Object.keys(l||{}).forEach(function(u){u==="class"?c.className=l[u]:u==="dataset"?Object.keys(l[u]).forEach(function(d){c.dataset[d]=l[u][d]}):c.setAttribute(u,l[u])}),a!=null&&(c.innerHTML=a),c}function i(o){return''}function s(o,l,a){t.RichEditor.modalInput({title:o,value:l||""},a)}t.RichEditor={instances:[],init:function(o){o=o||e;var l=o.querySelectorAll("textarea[data-rich-editor]:not([data-rich-editor-ready])");Array.prototype.forEach.call(l,this.create.bind(this))},extensions:function(){return[Mh.configure({link:!1}),jf.configure({openOnClick:!1,autolink:!0,protocols:["http","https","mailto","tel"],HTMLAttributes:{rel:"noopener noreferrer nofollow"}}),Qx.configure({allowBase64:!1}),ba.configure({resizable:!0}),ip,op,sp,lp.configure({types:["heading","paragraph"]})]},create:function(o){var l=o.getAttribute("data-rich-preset")||"full",a=r("div",{class:"rich-editor rich-editor-"+l}),c=r("div",{class:"rich-editor-toolbar",role:"toolbar"}),u=r("div",{class:"rich-editor-surface"}),d=r("div",{class:"rich-editor-content"}),f=parseInt(o.getAttribute("data-editor-height"),10)||0;f>0&&(f=Math.max(140,Math.min(900,f)),a.classList.add("rich-editor-fixed-height"),a.style.setProperty("--rich-editor-height",f+"px")),o.style.display="none",o.parentNode.insertBefore(a,o),a.appendChild(c),a.appendChild(u),u.appendChild(d),a.appendChild(o);var h=new zd({element:d,extensions:this.extensions(),content:o.value||"",editorProps:{attributes:{class:"rich-editor-prose",spellcheck:o.getAttribute("spellcheck")||"true"},handleDoubleClickOn:function(p,m,g,y){return!g||g.type.name!=="image"?!1:(h.chain().setNodeSelection(y).run(),t.RichEditor.openImageEditor(h,g.attrs,y),!0)}},onUpdate:function(){o.value=h.getHTML(),o.setAttribute("data-rich-editor-dirty","1"),o.dispatchEvent(new Event("input",{bubbles:!0}))},onSelectionUpdate:function(){t.RichEditor.syncToolbar(a,h)},onTransaction:function(){t.RichEditor.syncToolbar(a,h)}});this.buildToolbar(c,h),o.setAttribute("data-rich-editor-ready","1"),o._adminxTiptap=h,this.instances.push(h),this.bindForm(o,h),this.syncToolbar(a,h)},button:function(o,l,a,c){var u=r("button",{class:"btn btn-ghost btn-icon btn-sm rich-editor-btn",type:"button","data-rich-command":o,"data-tooltip":l,"aria-label":l},i(a));return u.addEventListener("click",function(){c()}),u},buildToolbar:function(o,l){var a,c=this;function u(){a=r("div",{class:"rich-editor-group"}),o.appendChild(a)}function d(h,p,m,g){a.appendChild(c.button(h,p,m,g))}function f(){var h=r("select",{class:"select rich-editor-block-select","data-rich-block-select":"","aria-label":"\u0424\u043E\u0440\u043C\u0430\u0442 \u0431\u043B\u043E\u043A\u0430","data-tooltip":"\u0424\u043E\u0440\u043C\u0430\u0442 \u0431\u043B\u043E\u043A\u0430"});[["paragraph","\u0410\u0431\u0437\u0430\u0446"],["heading-1","\u0417\u0430\u0433\u043E\u043B\u043E\u0432\u043E\u043A H1"],["heading-2","\u0417\u0430\u0433\u043E\u043B\u043E\u0432\u043E\u043A H2"],["heading-3","\u0417\u0430\u0433\u043E\u043B\u043E\u0432\u043E\u043A H3"],["heading-4","\u0417\u0430\u0433\u043E\u043B\u043E\u0432\u043E\u043A H4"],["heading-5","\u0417\u0430\u0433\u043E\u043B\u043E\u0432\u043E\u043A H5"],["heading-6","\u0417\u0430\u0433\u043E\u043B\u043E\u0432\u043E\u043A H6"]].forEach(function(p){var m=r("option",{value:p[0]});m.textContent=p[1],h.appendChild(m)}),h.addEventListener("change",function(){if(h.value==="paragraph"){l.chain().focus().setParagraph().run();return}l.chain().focus().setHeading({level:parseInt(h.value.replace("heading-",""),10)}).run()}),a.appendChild(h)}u(),d("undo","\u041E\u0442\u043C\u0435\u043D\u0438\u0442\u044C","arrow-back-up",function(){l.chain().focus().undo().run()}),d("redo","\u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u044C","arrow-forward-up",function(){l.chain().focus().redo().run()}),u(),d("bold","\u0416\u0438\u0440\u043D\u044B\u0439","bold",function(){l.chain().focus().toggleBold().run()}),d("italic","\u041A\u0443\u0440\u0441\u0438\u0432","italic",function(){l.chain().focus().toggleItalic().run()}),d("strike","\u0417\u0430\u0447\u0435\u0440\u043A\u043D\u0443\u0442\u044C","strikethrough",function(){l.chain().focus().toggleStrike().run()}),d("code","\u0411\u043B\u043E\u043A \u043A\u043E\u0434\u0430","code",function(){l.chain().focus().toggleCodeBlock().run()}),u(),f(),d("bulletList","\u041C\u0430\u0440\u043A\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u044B\u0439 \u0441\u043F\u0438\u0441\u043E\u043A","list",function(){l.chain().focus().toggleBulletList().run()}),d("orderedList","\u041D\u0443\u043C\u0435\u0440\u043E\u0432\u0430\u043D\u043D\u044B\u0439 \u0441\u043F\u0438\u0441\u043E\u043A","list-numbers",function(){l.chain().focus().toggleOrderedList().run()}),d("blockquote","\u0426\u0438\u0442\u0430\u0442\u0430","blockquote",function(){l.chain().focus().toggleBlockquote().run()}),u(),d("alignLeft","\u041F\u043E \u043B\u0435\u0432\u043E\u043C\u0443 \u043A\u0440\u0430\u044E","align-left",function(){l.chain().focus().setTextAlign("left").run()}),d("alignCenter","\u041F\u043E \u0446\u0435\u043D\u0442\u0440\u0443","align-center",function(){l.chain().focus().setTextAlign("center").run()}),d("alignRight","\u041F\u043E \u043F\u0440\u0430\u0432\u043E\u043C\u0443 \u043A\u0440\u0430\u044E","align-right",function(){l.chain().focus().setTextAlign("right").run()}),u(),d("link","\u0421\u0441\u044B\u043B\u043A\u0430","link",function(){c.openLink(l)}),d("image","\u0418\u0437\u043E\u0431\u0440\u0430\u0436\u0435\u043D\u0438\u0435","photo-plus",function(){c.openMediaPicker(l)}),d("table","\u0422\u0430\u0431\u043B\u0438\u0446\u0430","table-plus",function(){l.chain().focus().insertTable({rows:3,cols:3,withHeaderRow:!0}).run()}),d("showBlocks","\u041F\u043E\u043A\u0430\u0437\u0430\u0442\u044C \u0431\u043B\u043E\u043A\u0438","box-model-2",function(){var h=o.closest(".rich-editor");h&&(h.classList.toggle("rich-editor-show-blocks"),c.syncToolbar(h,l))}),u(),n.CodeMirror&&d("source","\u0418\u0441\u0445\u043E\u0434\u043D\u0438\u043A","code-dots",function(){c.openSource(l)}),d("clear","\u041E\u0447\u0438\u0441\u0442\u0438\u0442\u044C \u0444\u043E\u0440\u043C\u0430\u0442","eraser",function(){l.chain().focus().unsetAllMarks().clearNodes().run()})},syncToolbar:function(o,l){var a={bold:l.isActive("bold"),italic:l.isActive("italic"),strike:l.isActive("strike"),code:l.isActive("codeBlock"),bulletList:l.isActive("bulletList"),orderedList:l.isActive("orderedList"),blockquote:l.isActive("blockquote"),alignLeft:l.isActive({textAlign:"left"}),alignCenter:l.isActive({textAlign:"center"}),alignRight:l.isActive({textAlign:"right"}),link:l.isActive("link"),showBlocks:o.classList.contains("rich-editor-show-blocks"),source:o.classList.contains("rich-editor-source-open")};Object.keys(a).forEach(function(f){var h=o.querySelector('[data-rich-command="'+f+'"]');h&&h.setAttribute("aria-pressed",a[f]?"true":"false")});var c=o.querySelector("[data-rich-block-select]");if(c){for(var u="paragraph",d=1;d<=6;d++)if(l.isActive("heading",{level:d})){u="heading-"+d;break}c.value=u}},bindForm:function(o,l){var a=o.closest("form");a&&a.addEventListener("submit",function(){var c=o.closest(".rich-editor");if(c&&c.classList.contains("rich-editor-source-open")&&c._richSourceEditor){c._richSourceEditor.save(),o.value=c._richSourceEditor.getValue();return}o.getAttribute("data-rich-editor-dirty")==="1"&&(o.value=l.getHTML())})},openSource:function(o){var l=o.view.dom.closest(".rich-editor");if(!(!l||!n.CodeMirror)){var a=l.querySelector(".rich-editor-surface"),c=l.querySelector("textarea[data-rich-editor]"),u=l.querySelector("[data-rich-inline-source]"),d=!l.classList.contains("rich-editor-source-open");if(!d){var f=l._richSourceEditor?l._richSourceEditor.getValue():c?c.value:"";f!==o.getHTML()&&o.commands.setContent(f||"

"),c&&(c.value=o.getHTML()),l.classList.remove("rich-editor-source-open"),this.setSourceControls(l,!1),this.syncToolbar(l,o),o.commands.focus();return}if(!u){u=r("div",{class:"rich-editor-inline-source","data-rich-inline-source":""});var h=r("textarea",{"data-rich-source-textarea":""});u.appendChild(h),a.parentNode.insertBefore(u,a.nextSibling),l._richSourceEditor=n.CodeMirror.fromTextArea(h,{mode:"htmlmixed",theme:e.documentElement.getAttribute("data-theme")==="dark"?"material-ocean":"default",lineNumbers:!0,lineWrapping:!0,matchBrackets:!0,autoCloseTags:!0,styleActiveLine:!0,indentUnit:2}),l._richSourceEditor.on("change",function(m){!c||l._richSourceSyncing||(c.value=m.getValue(),c.setAttribute("data-rich-editor-dirty","1"),c.dispatchEvent(new Event("input",{bubbles:!0})))})}var p=Math.max(260,Math.round(a.getBoundingClientRect().height||0));l._richSourceSyncing=!0,l._richSourceEditor.setValue(o.getHTML()),l._richSourceSyncing=!1,l._richSourceEditor.setSize("100%",p),l.classList.add("rich-editor-source-open"),this.setSourceControls(l,!0),this.syncToolbar(l,o),setTimeout(function(){l._richSourceEditor.refresh(),l._richSourceEditor.focus()},0)}},setSourceControls:function(o,l){o.querySelectorAll(".rich-editor-toolbar [data-rich-command], .rich-editor-toolbar [data-rich-block-select]").forEach(function(a){a.getAttribute("data-rich-command")!=="source"&&(a.disabled=!!l)})},openLink:function(o){var l=this,a=o.getAttributes("link")||{},c=r("div",{class:"overlay rich-editor-link-overlay"}),u=r("div",{class:"modal rich-editor-link-modal",role:"dialog","aria-modal":"true","aria-labelledby":"richEditorLinkTitle"});u.innerHTML='',c.appendChild(u),e.body.appendChild(c),requestAnimationFrame(function(){c.classList.add("show")});var d=c.querySelector("[data-link-url]"),f=c.querySelector("[data-link-class]"),h=c.querySelector("[data-link-selection]"),p=function(){c.classList.remove("show"),setTimeout(function(){c.remove(),e.removeEventListener("keydown",y)},180)},m=function(){var b=String(d.value||"").trim(),k=String(f.value||"").trim().replace(/\s+/g," ");if(!b){o.chain().focus().unsetLink().run(),p();return}o.chain().focus().extendMarkRange("link").setLink({href:b,class:k||null}).run(),p()},g=function(b,k){d.value=b||"",h.textContent=k||"\u0410\u0434\u0440\u0435\u0441 \u0432\u044B\u0431\u0440\u0430\u043D",d.dispatchEvent(new Event("input",{bubbles:!0}))},y=function(b){if(b.key==="Escape"){if(e.querySelector(".rich-editor-document-picker-overlay.show, .media-picker-overlay.show"))return;p()}b.key==="Enter"&&b.target!==f&&(b.preventDefault(),m())};c.addEventListener("click",function(b){if(b.target===c||b.target.closest("[data-link-cancel]")){p();return}if(b.target.closest("[data-link-apply]")){m();return}if(b.target.closest("[data-link-unset]")){o.chain().focus().unsetLink().run(),p();return}if(b.target.closest("[data-link-document]")){l.openDocumentLinkPicker(function(k){var w=String(k.alias||"").trim(),C=w?/^(?:[a-z]+:|\/|#)/i.test(w)?w:"/"+w:"/index.php?id="+k.id;g(C,"\u0414\u043E\u043A\u0443\u043C\u0435\u043D\u0442 #"+k.id+" \xB7 "+(k.title||"\u0411\u0435\u0437 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u044F"))});return}b.target.closest("[data-link-media]")&&t.MediaPicker&&t.MediaPicker.open({type:"all",title:"\u0424\u0430\u0439\u043B \u0434\u043B\u044F \u0441\u0441\u044B\u043B\u043A\u0438",description:"\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0438\u0437\u043E\u0431\u0440\u0430\u0436\u0435\u043D\u0438\u0435 \u0438\u043B\u0438 \u0434\u043E\u043A\u0443\u043C\u0435\u043D\u0442 \u0438\u0437 \u043C\u0435\u0434\u0438\u0430\u0442\u0435\u043A\u0438.",onPick:function(k){k&&k.url&&g(k.url,"\u041C\u0435\u0434\u0438\u0430 \xB7 "+(k.name||k.url))},onUrl:function(k){k&&g(k,"\u0412\u043D\u0435\u0448\u043D\u0438\u0439 \u0430\u0434\u0440\u0435\u0441 \u043C\u0435\u0434\u0438\u0430")}})}),e.addEventListener("keydown",y),d.focus(),d.select()},openDocumentLinkPicker:function(o){var l=this,a=r("div",{class:"overlay rich-editor-document-picker-overlay"}),c=r("div",{class:"modal picker-modal rich-editor-document-picker",role:"dialog","aria-modal":"true","aria-labelledby":"richEditorDocumentPickerTitle"});c.innerHTML='',a.appendChild(c),e.body.appendChild(a),requestAnimationFrame(function(){a.classList.add("show")});var u=a.querySelector("[data-document-link-search]"),d=a.querySelector("[data-document-link-list]"),f=a.querySelector("[data-document-link-status]"),h=a.querySelector("[data-document-link-count]"),p=[],m=null,g=0,y=function(){a.classList.remove("show"),setTimeout(function(){a.remove(),e.removeEventListener("keydown",b)},160)},b=function(w){w.key==="Escape"&&(w.stopImmediatePropagation(),y())},k=function(){var w=++g,C=new URLSearchParams;C.set("q",u.value.trim()),C.set("limit",40),f.hidden=!1,f.textContent="\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...",fetch((n.ADMINX_BASE||"/adminx")+"/documents/picker?"+C.toString(),{headers:{Accept:"application/json"},credentials:"same-origin"}).then(function(O){return O.json()}).then(function(O){w===g&&(p=(O.data||{}).items||[],d.innerHTML=p.map(function(M,D){return'"}).join(""),f.hidden=p.length>0,f.textContent=u.value.trim()?"\u041D\u0438\u0447\u0435\u0433\u043E \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E":"\u0414\u043E\u043A\u0443\u043C\u0435\u043D\u0442\u044B \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u044B",h.textContent=p.length?"\u041F\u043E\u043A\u0430\u0437\u0430\u043D\u043E: "+p.length:"")}).catch(function(){p=[],d.innerHTML="",f.hidden=!1,f.textContent="\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0434\u043E\u043A\u0443\u043C\u0435\u043D\u0442\u044B"})};a.addEventListener("click",function(w){if(w.target===a||w.target.closest("[data-document-link-close]")){y();return}var C=w.target.closest("[data-document-link-index]");if(C){var O=p[parseInt(C.getAttribute("data-document-link-index"),10)];O&&(o(O),y())}}),u.addEventListener("input",function(){clearTimeout(m),m=setTimeout(k,220)}),e.addEventListener("keydown",b),k(),u.focus()},openMediaPicker:function(o){this.openImageEditor(o,{},null)},openImageEditor:function(o,l,a){var c=this;l=l||{};var u=typeof a=="number",d=r("div",{class:"overlay rich-editor-image-overlay"}),f=r("div",{class:"modal rich-editor-image-modal",role:"dialog","aria-modal":"true","aria-labelledby":"richEditorImageTitle"});f.innerHTML='",d.appendChild(f),e.body.appendChild(d),requestAnimationFrame(function(){d.classList.add("show")});var h=d.querySelector("[data-image-src]"),p=d.querySelector("[data-image-webp]"),m=d.querySelector("[data-image-picture]"),g=d.querySelector("[data-image-webp-field]"),y=d.querySelector("[data-image-alt]"),b=d.querySelector("[data-image-class]"),k=d.querySelector("[data-image-width]"),w=d.querySelector("[data-image-height]"),C=d.querySelector("[data-image-scale]"),O=d.querySelector("[data-image-scale-value]"),M=d.querySelector("[data-image-preview-img]"),D=d.querySelector("[data-image-empty]"),B=d.querySelector("[data-image-preview-size]"),P={naturalWidth:parseInt(l.width,10)||0,naturalHeight:parseInt(l.height,10)||0,ratio:l.width&&l.height?parseInt(l.width,10)/parseInt(l.height,10):0,loadingSrc:""},Se=function(){var L=parseInt(k.value,10)||0,F=P.naturalWidth&&L?Math.round(L/P.naturalWidth*100):100;F=Math.max(10,Math.min(200,F)),C.value=F,O.textContent=F+"%",B.textContent=L&&w.value?L+" x "+w.value+" px":""},Te=function(L,F,Ct){L=Math.max(1,Math.round(Number(L)||0)),F=Math.max(1,Math.round(Number(F)||0)),!(!L||!F)&&(Ct?(P.naturalWidth=L,P.naturalHeight=F,P.ratio=L/F):P.ratio||(P.ratio=L/F),k.value=L,w.value=F,Se())},Q=function(L,F){if(L=String(L||"").trim(),P.loadingSrc=L,!L){M.hidden=!0,D.hidden=!1,B.textContent="";return}var Ct=new n.Image;Ct.onload=function(){if(P.loadingSrc===L){M.src=L,M.hidden=!1,D.hidden=!0;var xa=F&&F.width?F.width:Ct.naturalWidth,wa=F&&F.height?F.height:Ct.naturalHeight;!k.value||!w.value||L!==l.src?Te(xa,wa,!0):(P.naturalWidth=xa||parseInt(k.value,10),P.naturalHeight=wa||parseInt(w.value,10),P.ratio=P.naturalWidth/P.naturalHeight,Se())}},Ct.onerror=function(){P.loadingSrc===L&&(M.hidden=!0,D.hidden=!1,D.querySelector("span").textContent="\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0438\u0437\u043E\u0431\u0440\u0430\u0436\u0435\u043D\u0438\u0435")},Ct.src=L},Z=function(L){t.MediaPicker&&t.MediaPicker.open({type:"image",title:L==="webp"?"\u0412\u044B\u0431\u0440\u0430\u0442\u044C WebP":"\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u0438\u0437\u043E\u0431\u0440\u0430\u0436\u0435\u043D\u0438\u0435",description:L==="webp"?"\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0444\u0430\u0439\u043B WebP \u0434\u043B\u044F \u0442\u0435\u0433\u0430 source.":"\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u043E\u0441\u043D\u043E\u0432\u043D\u043E\u0435 \u0438\u0437\u043E\u0431\u0440\u0430\u0436\u0435\u043D\u0438\u0435 \u0438\u0437 \u043C\u0435\u0434\u0438\u0430\u0442\u0435\u043A\u0438.",onPick:function(F){if(!(!F||!F.url)){if(L==="webp"){p.value=F.url,m.checked=!0,g.hidden=!1;return}h.value=F.url,!y.value&&F.name&&(y.value=String(F.name).replace(/\.[^.]+$/,"").replace(/[-_]+/g," ")),k.value="",w.value="",Q(F.url,F)}},onUrl:function(F){F&&(L==="webp"?(p.value=F,m.checked=!0,g.hidden=!1):(h.value=F,k.value="",w.value="",Q(F)))}})},J=function(){d.classList.remove("show"),setTimeout(function(){d.remove(),e.removeEventListener("keydown",ka)},180)},dt=function(){var L=String(h.value||"").trim();if(!L){h.focus();return}var F={src:L,webp:m.checked&&String(p.value||"").trim()||null,alt:String(y.value||"").trim()||null,class:String(b.value||"").trim().replace(/\s+/g," ")||null,width:parseInt(k.value,10)||null,height:parseInt(w.value,10)||null};u?o.chain().focus().setNodeSelection(a).updateAttributes("image",F).run():o.chain().focus().setImage(F).run(),J()},ka=function(L){if(L.key==="Escape"){if(e.querySelector(".media-picker-overlay.show"))return;J()}};m.addEventListener("change",function(){g.hidden=!m.checked,m.checked&&p.focus()}),h.addEventListener("change",function(){k.value="",w.value="",Q(h.value)}),k.addEventListener("input",function(){var L=parseInt(k.value,10)||0;L&&P.ratio&&(w.value=Math.max(1,Math.round(L/P.ratio))),Se()}),w.addEventListener("input",function(){var L=parseInt(w.value,10)||0;L&&P.ratio&&(k.value=Math.max(1,Math.round(L*P.ratio))),Se()}),C.addEventListener("input",function(){var L=parseInt(C.value,10)||100;O.textContent=L+"%",P.naturalWidth&&P.naturalHeight&&(k.value=Math.max(1,Math.round(P.naturalWidth*L/100)),w.value=Math.max(1,Math.round(P.naturalHeight*L/100)),B.textContent=k.value+" x "+w.value+" px")}),d.addEventListener("click",function(L){if(L.target===d||L.target.closest("[data-image-cancel]")){J();return}var F=L.target.closest("[data-image-pick]");if(F){Z(F.getAttribute("data-image-pick"));return}if(L.target.closest("[data-image-original]")&&P.naturalWidth&&P.naturalHeight){Te(P.naturalWidth,P.naturalHeight,!1);return}if(L.target.closest("[data-image-apply]")){dt();return}L.target.closest("[data-image-remove]")&&u&&(o.chain().focus().setNodeSelection(a).deleteSelection().run(),J())}),e.addEventListener("keydown",ka),l.src?Q(l.src):h.focus()},modalInput:function(o,l){var a=r("div",{class:"overlay"}),c=r("div",{class:"modal rich-editor-prompt",role:"dialog","aria-modal":"true"});c.innerHTML='',a.appendChild(c),e.body.appendChild(a),requestAnimationFrame(function(){a.classList.add("show")});var u=a.querySelector("[data-input]"),d=function(){a.classList.remove("show"),setTimeout(function(){a.remove(),e.removeEventListener("keydown",h)},180)},f=function(){var p=u.value||"";d(),l(p)},h=function(p){p.key==="Escape"&&d(),p.key==="Enter"&&f()};a.addEventListener("click",function(p){(p.target===a||p.target.closest("[data-cancel]"))&&d(),p.target.closest("[data-ok]")&&f()}),e.addEventListener("keydown",h),u.focus(),u.select()},escape:function(o){return String(o==null?"":o).replace(/[&<>"]/g,function(l){return{"&":"&","<":"<",">":">",'"':"""}[l]})}},e.addEventListener("adminx:content-ready",function(o){t.RichEditor.init(o.detail&&o.detail.root?o.detail.root:e)}),e.readyState==="loading"?e.addEventListener("DOMContentLoaded",function(){t.RichEditor.init()}):t.RichEditor.init()})(window,document);})(); diff --git a/adminx/assets/js/media-picker.js b/adminx/assets/js/media-picker.js new file mode 100644 index 0000000..117e76a --- /dev/null +++ b/adminx/assets/js/media-picker.js @@ -0,0 +1,168 @@ +/** + * Единый медиа-пикер /adminx. Один компонент для всех мест, где выбирают файл + * из медиатеки (поля документов, значения по умолчанию рубрик, навигация, + * визуальный редактор). Дергает общий эндпоинт GET /media/picker. + * + * Использование: + * Adminx.MediaPicker.open({ + * type: 'image' | 'file' | 'all', // что показывать (по умолчанию image) + * dir: '/uploads', // стартовая папка + * title, description, // тексты шапки (опц.) + * onPick: function (file) { ... }, // выбран файл из медиатеки + * onFolder: function (dir) { ... }, // выбрана открытая папка (опц.) + * onUrl: function (url) { ... } // опц.: показать «вставить по ссылке» + * }); + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + function esc(value) { + return String(value == null ? '' : value).replace(/[&<>"]/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; + }); + } + + Adminx.MediaPicker = { + open: function (options) { + options = options || {}; + var type = (options.type === 'file' || options.type === 'all') ? options.type : 'image'; + var onPick = typeof options.onPick === 'function' ? options.onPick : function () {}; + var onFolder = typeof options.onFolder === 'function' ? options.onFolder : null; + var onUrl = typeof options.onUrl === 'function' ? options.onUrl : null; + var base = (Adminx.base && Adminx.base()) || ''; + var title = options.title || 'Выбрать файл'; + var description = options.description || (type === 'image' ? 'Выберите изображение из медиатеки.' : 'Выберите файл из медиатеки.'); + var placeholder = type === 'image' ? 'Поиск изображений' : 'Поиск файлов'; + + var overlay = document.createElement('div'); + overlay.className = 'overlay media-picker-overlay'; + overlay.innerHTML = + ''; + document.body.appendChild(overlay); + window.requestAnimationFrame(function () { overlay.classList.add('show'); }); + + var state = { dir: options.dir || '/uploads', parent: '', page: 1, pages: 1, q: '', type: type }; + var grid = overlay.querySelector('[data-mp-grid]'); + var crumbs = overlay.querySelector('[data-mp-crumbs]'); + var status = overlay.querySelector('[data-mp-status]'); + var count = overlay.querySelector('[data-mp-count]'); + var search = overlay.querySelector('[data-mp-search]'); + var timer = null; + + function close() { + overlay.classList.remove('show'); + window.setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 160); + } + function onKey(e) { if (e.key === 'Escape') { close(); } } + + function renderCrumbs(items) { + crumbs.innerHTML = ''; + (items || []).forEach(function (item, index) { + if (index > 0) { crumbs.insertAdjacentHTML('beforeend', '/'); } + crumbs.insertAdjacentHTML('beforeend', ''); + }); + } + + function render(data) { + var folders = data.folders || []; + var files = data.files || []; + renderCrumbs(data.breadcrumbs || []); + grid.innerHTML = ''; + folders.forEach(function (folder) { + grid.insertAdjacentHTML('beforeend', ''); + }); + files.forEach(function (file) { + var thumb = file.thumb_url || file.preview_url || file.url || ''; + var isImage = /\.(jpe?g|png|gif|webp|bmp|svg)(\?.*)?$/i.test(thumb); + var preview = isImage ? '' : ''; + var meta = (file.width && file.height ? file.width + 'x' + file.height : '') + (file.size_label ? ' · ' + file.size_label : ''); + grid.insertAdjacentHTML('beforeend', ''); + }); + status.hidden = folders.length + files.length > 0; + status.textContent = state.q ? 'Ничего не найдено' : (state.type === 'image' ? 'В этой папке нет изображений' : 'В этой папке нет файлов'); + count.textContent = data.total ? 'Файлов: ' + data.total + ', страница ' + state.page + ' из ' + state.pages : ''; + overlay.querySelector('[data-mp-prev]').disabled = state.page <= 1; + overlay.querySelector('[data-mp-next]').disabled = state.page >= state.pages; + overlay.querySelector('[data-mp-up]').disabled = !state.parent; + } + + function load(dir, page) { + state.dir = dir || state.dir; + state.page = page || 1; + status.textContent = 'Загрузка…'; + status.hidden = false; + grid.innerHTML = ''; + var params = new URLSearchParams(); + params.set('dir', state.dir); + params.set('page', state.page); + params.set('per_page', 36); + params.set('type', state.type); + if (state.q) { params.set('q', state.q); } + fetch(base + '/media/picker?' + params.toString(), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (res) { return res.json(); }) + .then(function (payload) { + var data = (payload && payload.data) || {}; + state.dir = data.dir || state.dir; + state.parent = data.parent_dir || ''; + state.page = data.page || 1; + state.pages = data.pages || 1; + render(data); + }) + .catch(function () { status.textContent = 'Не удалось загрузить медиа'; status.hidden = false; }); + } + + overlay.addEventListener('click', function (e) { + if (e.target === overlay || e.target.closest('[data-mp-close]')) { close(); return; } + var dir = e.target.closest('[data-mp-dir]'); + if (dir) { load(dir.getAttribute('data-mp-dir') || '/uploads', 1); return; } + var file = e.target.closest('[data-mp-file]'); + if (file && !onFolder) { onPick(JSON.parse(file.getAttribute('data-mp-file') || '{}')); close(); return; } + if (e.target.closest('[data-mp-folder-apply]') && onFolder) { onFolder(state.dir); close(); return; } + if (e.target.closest('[data-mp-prev]') && state.page > 1) { load(state.dir, state.page - 1); } + if (e.target.closest('[data-mp-next]') && state.page < state.pages) { load(state.dir, state.page + 1); } + if (e.target.closest('[data-mp-up]') && state.parent) { load(state.parent, 1); } + if (onUrl) { + if (e.target.closest('[data-mp-url-toggle]')) { + var box = overlay.querySelector('[data-mp-url]'); + box.hidden = !box.hidden; + if (!box.hidden) { box.querySelector('[data-mp-url-input]').focus(); } + } + if (e.target.closest('[data-mp-url-apply]')) { + var value = overlay.querySelector('[data-mp-url-input]').value.trim(); + if (value) { onUrl(value); close(); } + } + } + }); + + search.addEventListener('input', function () { + window.clearTimeout(timer); + timer = window.setTimeout(function () { state.q = search.value.trim(); load(state.dir, 1); }, 220); + }); + document.addEventListener('keydown', onKey); + load(state.dir, 1); + search.focus(); + } + }; +})(window, document); diff --git a/adminx/assets/js/saved-views.js b/adminx/assets/js/saved-views.js new file mode 100644 index 0000000..615890b --- /dev/null +++ b/adminx/assets/js/saved-views.js @@ -0,0 +1,197 @@ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + function parse(value, fallback) { + try { return JSON.parse(value); } catch (error) { return fallback; } + } + + function formFor(root) { + var selector = root.getAttribute('data-filter-form'); + return selector ? document.querySelector(selector) : null; + } + + function fields(root) { + return parse(root.getAttribute('data-fields') || '[]', []); + } + + function currentFilters(root) { + var form = formFor(root), result = {}; + if (!form) { return result; } + fields(root).forEach(function (name) { + var field = form.elements[name]; + result[name] = field ? String(field.value || '') : ''; + }); + return result; + } + + function same(left, right, names) { + return names.every(function (name) { + return String(left[name] || '') === String(right[name] || ''); + }); + } + + function sync(root) { + var select = root.querySelector('[data-saved-views-select]'); + var remove = root.querySelector('[data-saved-views-delete]'); + if (!select || !remove) { return; } + var current = currentFilters(root), names = fields(root), matched = ''; + Array.prototype.some.call(select.options, function (option) { + if (!option.value) { return false; } + if (same(current, parse(option.getAttribute('data-filters') || '{}', {}), names)) { + matched = option.value; + return true; + } + return false; + }); + select.value = matched; + remove.disabled = !matched; + } + + function render(root, views, selected) { + var select = root.querySelector('[data-saved-views-select]'); + if (!select) { return; } + select.innerHTML = ''; + (views || []).forEach(function (view) { + var option = document.createElement('option'); + option.value = view.id; + option.textContent = view.title; + option.setAttribute('data-filters', JSON.stringify(view.filters || {})); + select.appendChild(option); + }); + select.value = selected || ''; + sync(root); + } + + function apply(root, option) { + var form = formFor(root); + if (!form || !option || !option.value) { return; } + var values = parse(option.getAttribute('data-filters') || '{}', {}); + fields(root).forEach(function (name) { + if (form.elements[name]) { form.elements[name].value = values[name] || ''; } + }); + if (form.elements.page) { form.elements.page.value = '1'; } + if (typeof form.requestSubmit === 'function') { + form.requestSubmit(); + } else { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + } + } + + function request(root, url, data, successMessage) { + Adminx.Loader.show(); + return Adminx.Ajax.post(url, data).then(function (payload) { + var response = payload.data || {}; + if (!payload.ok || response.success === false) { + throw new Error(response.message || 'Не удалось сохранить представление'); + } + Adminx.Toast.show(response.message || successMessage, 'success'); + return response.data || {}; + }).catch(function (error) { + Adminx.Toast.show(error.message || 'Не удалось сохранить представление', 'error'); + throw error; + }).then(function (result) { + Adminx.Loader.hide(); + return result; + }, function (error) { + Adminx.Loader.hide(); + throw error; + }); + } + + document.addEventListener('change', function (event) { + var select = event.target.closest('[data-saved-views-select]'); + if (!select) { return; } + var root = select.closest('[data-saved-views]'); + root.querySelector('[data-saved-views-delete]').disabled = !select.value; + apply(root, select.options[select.selectedIndex]); + }); + + document.addEventListener('click', function (event) { + var create = event.target.closest('[data-saved-views-create]'); + if (create) { + var createRoot = create.closest('[data-saved-views]'); + var editor = createRoot.querySelector('[data-saved-views-editor]'); + editor.hidden = false; + var title = editor.querySelector('[data-saved-views-title]'); + title.value = ''; + title.focus(); + return; + } + + var cancel = event.target.closest('[data-saved-views-cancel]'); + if (cancel) { + cancel.closest('[data-saved-views-editor]').hidden = true; + return; + } + + var submit = event.target.closest('[data-saved-views-submit]'); + if (submit) { + var submitRoot = submit.closest('[data-saved-views]'); + var input = submitRoot.querySelector('[data-saved-views-title]'); + var name = input.value.trim(); + if (!name) { + input.focus(); + Adminx.Toast.show('Укажите название представления', 'warning'); + return; + } + submit.disabled = true; + var data = new FormData(); + data.set('_csrf', submitRoot.getAttribute('data-csrf') || ''); + data.set('title', name); + data.set('filters', JSON.stringify(currentFilters(submitRoot))); + request(submitRoot, submitRoot.getAttribute('data-save-url'), data, 'Представление сохранено').then(function (result) { + render(submitRoot, result.views || [], result.id || ''); + submitRoot.querySelector('[data-saved-views-editor]').hidden = true; + }).then(function () { submit.disabled = false; }, function () { submit.disabled = false; }); + return; + } + + var remove = event.target.closest('[data-saved-views-delete]'); + if (remove && !remove.disabled) { + var removeRoot = remove.closest('[data-saved-views]'); + var select = removeRoot.querySelector('[data-saved-views-select]'); + var option = select.options[select.selectedIndex]; + if (!option || !option.value) { return; } + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить представление?', + message: 'Набор фильтров «' + option.textContent + '» будет удалён только у вашей учётной записи.', + confirmLabel: 'Удалить', + confirmClass: 'btn-danger', + onConfirm: function () { + var data = new FormData(); + data.set('_csrf', removeRoot.getAttribute('data-csrf') || ''); + var url = removeRoot.getAttribute('data-delete-url').replace('__ID__', encodeURIComponent(option.value)); + return request(removeRoot, url, data, 'Представление удалено').then(function (result) { + render(removeRoot, result.views || [], ''); + }); + } + }); + } + }); + + document.addEventListener('keydown', function (event) { + if (!event.target.matches('[data-saved-views-title]')) { return; } + if (event.key === 'Enter') { + event.preventDefault(); + event.target.closest('[data-saved-views]').querySelector('[data-saved-views-submit]').click(); + } else if (event.key === 'Escape') { + event.target.closest('[data-saved-views-editor]').hidden = true; + } + }); + + function syncAll(root) { + Array.prototype.forEach.call((root || document).querySelectorAll('[data-saved-views]'), sync); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { syncAll(document); }); + } else { + syncAll(document); + } + document.addEventListener('adminx:content-ready', function (event) { + syncAll(event.detail && event.detail.root ? event.detail.root : document); + }); +})(window, document); diff --git a/adminx/assets/src/editor-tiptap.js b/adminx/assets/src/editor-tiptap.js new file mode 100644 index 0000000..7b0836b --- /dev/null +++ b/adminx/assets/src/editor-tiptap.js @@ -0,0 +1,772 @@ +import { Editor, mergeAttributes } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; +import Link from '@tiptap/extension-link'; +import Image from '@tiptap/extension-image'; +import { Table } from '@tiptap/extension-table'; +import TableRow from '@tiptap/extension-table-row'; +import TableCell from '@tiptap/extension-table-cell'; +import TableHeader from '@tiptap/extension-table-header'; +import TextAlign from '@tiptap/extension-text-align'; + +var EnhancedImage = Image.extend({ + addAttributes: function () { + return Object.assign({}, this.parent ? this.parent() : {}, { + class: { default: null }, + webp: { default: null } + }); + }, + + parseHTML: function () { + return [ + { + tag: 'picture', + getAttrs: function (element) { + var image = element.querySelector('img[src]'); + var source = element.querySelector('source[type="image/webp"], source[srcset$=".webp"]'); + if (!image) { return false; } + return { + src: image.getAttribute('src'), + alt: image.getAttribute('alt'), + title: image.getAttribute('title'), + class: image.getAttribute('class'), + width: image.getAttribute('width'), + height: image.getAttribute('height'), + webp: source ? source.getAttribute('srcset') : null + }; + } + }, + { tag: 'img[src]:not([src^="data:"])' } + ]; + }, + + renderHTML: function (context) { + var attributes = Object.assign({}, context.HTMLAttributes || {}); + var webp = attributes.webp; + delete attributes.webp; + var image = ['img', mergeAttributes(this.options.HTMLAttributes, attributes)]; + if (!webp) { return image; } + return ['picture', {}, ['source', { srcset: webp, type: 'image/webp' }], image]; + } +}); + +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + function el(tag, attrs, html) { + var node = document.createElement(tag); + Object.keys(attrs || {}).forEach(function (key) { + if (key === 'class') { + node.className = attrs[key]; + } else if (key === 'dataset') { + Object.keys(attrs[key]).forEach(function (name) { node.dataset[name] = attrs[key][name]; }); + } else { + node.setAttribute(key, attrs[key]); + } + }); + if (html != null) { + node.innerHTML = html; + } + return node; + } + + function icon(name) { + return ''; + } + + function promptValue(title, value, done) { + Adminx.RichEditor.modalInput({ title: title, value: value || '' }, done); + } + + Adminx.RichEditor = { + instances: [], + + init: function (root) { + root = root || document; + var nodes = root.querySelectorAll('textarea[data-rich-editor]:not([data-rich-editor-ready])'); + Array.prototype.forEach.call(nodes, this.create.bind(this)); + }, + + extensions: function () { + return [ + StarterKit.configure({ link: false }), + Link.configure({ + openOnClick: false, + autolink: true, + protocols: ['http', 'https', 'mailto', 'tel'], + HTMLAttributes: { rel: 'noopener noreferrer nofollow' } + }), + EnhancedImage.configure({ allowBase64: false }), + Table.configure({ resizable: true }), + TableRow, + TableHeader, + TableCell, + TextAlign.configure({ types: ['heading', 'paragraph'] }) + ]; + }, + + create: function (textarea) { + var preset = textarea.getAttribute('data-rich-preset') || 'full'; + var root = el('div', { class: 'rich-editor rich-editor-' + preset }); + var toolbar = el('div', { class: 'rich-editor-toolbar', role: 'toolbar' }); + var surface = el('div', { class: 'rich-editor-surface' }); + var editorEl = el('div', { class: 'rich-editor-content' }); + var configuredHeight = parseInt(textarea.getAttribute('data-editor-height'), 10) || 0; + if (configuredHeight > 0) { + configuredHeight = Math.max(140, Math.min(900, configuredHeight)); + root.classList.add('rich-editor-fixed-height'); + root.style.setProperty('--rich-editor-height', configuredHeight + 'px'); + } + + textarea.style.display = 'none'; + textarea.parentNode.insertBefore(root, textarea); + root.appendChild(toolbar); + root.appendChild(surface); + surface.appendChild(editorEl); + root.appendChild(textarea); + + var editor = new Editor({ + element: editorEl, + extensions: this.extensions(), + content: textarea.value || '', + editorProps: { + attributes: { + class: 'rich-editor-prose', + spellcheck: textarea.getAttribute('spellcheck') || 'true' + }, + handleDoubleClickOn: function (view, pos, node, nodePos) { + if (!node || node.type.name !== 'image') { return false; } + editor.chain().setNodeSelection(nodePos).run(); + Adminx.RichEditor.openImageEditor(editor, node.attrs, nodePos); + return true; + } + }, + onUpdate: function () { + textarea.value = editor.getHTML(); + textarea.setAttribute('data-rich-editor-dirty', '1'); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }, + onSelectionUpdate: function () { + Adminx.RichEditor.syncToolbar(root, editor); + }, + onTransaction: function () { + Adminx.RichEditor.syncToolbar(root, editor); + } + }); + + this.buildToolbar(toolbar, editor); + textarea.setAttribute('data-rich-editor-ready', '1'); + textarea._adminxTiptap = editor; + this.instances.push(editor); + this.bindForm(textarea, editor); + this.syncToolbar(root, editor); + }, + + button: function (name, tooltip, iconName, run) { + var btn = el('button', { + class: 'btn btn-ghost btn-icon btn-sm rich-editor-btn', + type: 'button', + 'data-rich-command': name, + 'data-tooltip': tooltip, + 'aria-label': tooltip + }, icon(iconName)); + btn.addEventListener('click', function () { run(); }); + return btn; + }, + + buildToolbar: function (toolbar, editor) { + var group; + var self = this; + function addGroup() { + group = el('div', { class: 'rich-editor-group' }); + toolbar.appendChild(group); + } + function add(name, tooltip, iconName, run) { + group.appendChild(self.button(name, tooltip, iconName, run)); + } + function addBlockSelect() { + var select = el('select', { + class: 'select rich-editor-block-select', + 'data-rich-block-select': '', + 'aria-label': 'Формат блока', + 'data-tooltip': 'Формат блока' + }); + [ + ['paragraph', 'Абзац'], + ['heading-1', 'Заголовок H1'], + ['heading-2', 'Заголовок H2'], + ['heading-3', 'Заголовок H3'], + ['heading-4', 'Заголовок H4'], + ['heading-5', 'Заголовок H5'], + ['heading-6', 'Заголовок H6'] + ].forEach(function (item) { + var option = el('option', { value: item[0] }); + option.textContent = item[1]; + select.appendChild(option); + }); + select.addEventListener('change', function () { + if (select.value === 'paragraph') { + editor.chain().focus().setParagraph().run(); + return; + } + editor.chain().focus().setHeading({ level: parseInt(select.value.replace('heading-', ''), 10) }).run(); + }); + group.appendChild(select); + } + + addGroup(); + add('undo', 'Отменить', 'arrow-back-up', function () { editor.chain().focus().undo().run(); }); + add('redo', 'Повторить', 'arrow-forward-up', function () { editor.chain().focus().redo().run(); }); + + addGroup(); + add('bold', 'Жирный', 'bold', function () { editor.chain().focus().toggleBold().run(); }); + add('italic', 'Курсив', 'italic', function () { editor.chain().focus().toggleItalic().run(); }); + add('strike', 'Зачеркнуть', 'strikethrough', function () { editor.chain().focus().toggleStrike().run(); }); + add('code', 'Блок кода', 'code', function () { editor.chain().focus().toggleCodeBlock().run(); }); + + addGroup(); + addBlockSelect(); + add('bulletList', 'Маркированный список', 'list', function () { editor.chain().focus().toggleBulletList().run(); }); + add('orderedList', 'Нумерованный список', 'list-numbers', function () { editor.chain().focus().toggleOrderedList().run(); }); + add('blockquote', 'Цитата', 'blockquote', function () { editor.chain().focus().toggleBlockquote().run(); }); + + addGroup(); + add('alignLeft', 'По левому краю', 'align-left', function () { editor.chain().focus().setTextAlign('left').run(); }); + add('alignCenter', 'По центру', 'align-center', function () { editor.chain().focus().setTextAlign('center').run(); }); + add('alignRight', 'По правому краю', 'align-right', function () { editor.chain().focus().setTextAlign('right').run(); }); + + addGroup(); + add('link', 'Ссылка', 'link', function () { self.openLink(editor); }); + add('image', 'Изображение', 'photo-plus', function () { + self.openMediaPicker(editor); + }); + add('table', 'Таблица', 'table-plus', function () { + editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + }); + add('showBlocks', 'Показать блоки', 'box-model-2', function () { + var root = toolbar.closest('.rich-editor'); + if (!root) { return; } + root.classList.toggle('rich-editor-show-blocks'); + self.syncToolbar(root, editor); + }); + + addGroup(); + if (window.CodeMirror) { + add('source', 'Исходник', 'code-dots', function () { self.openSource(editor); }); + } + add('clear', 'Очистить формат', 'eraser', function () { editor.chain().focus().unsetAllMarks().clearNodes().run(); }); + }, + + syncToolbar: function (root, editor) { + var states = { + bold: editor.isActive('bold'), + italic: editor.isActive('italic'), + strike: editor.isActive('strike'), + code: editor.isActive('codeBlock'), + bulletList: editor.isActive('bulletList'), + orderedList: editor.isActive('orderedList'), + blockquote: editor.isActive('blockquote'), + alignLeft: editor.isActive({ textAlign: 'left' }), + alignCenter: editor.isActive({ textAlign: 'center' }), + alignRight: editor.isActive({ textAlign: 'right' }), + link: editor.isActive('link'), + showBlocks: root.classList.contains('rich-editor-show-blocks'), + source: root.classList.contains('rich-editor-source-open') + }; + Object.keys(states).forEach(function (name) { + var btn = root.querySelector('[data-rich-command="' + name + '"]'); + if (btn) { btn.setAttribute('aria-pressed', states[name] ? 'true' : 'false'); } + }); + var blockSelect = root.querySelector('[data-rich-block-select]'); + if (blockSelect) { + var blockValue = 'paragraph'; + for (var level = 1; level <= 6; level++) { + if (editor.isActive('heading', { level: level })) { blockValue = 'heading-' + level; break; } + } + blockSelect.value = blockValue; + } + }, + + bindForm: function (textarea, editor) { + var form = textarea.closest('form'); + if (!form) { return; } + form.addEventListener('submit', function () { + var root = textarea.closest('.rich-editor'); + if (root && root.classList.contains('rich-editor-source-open') && root._richSourceEditor) { + root._richSourceEditor.save(); + textarea.value = root._richSourceEditor.getValue(); + return; + } + if (textarea.getAttribute('data-rich-editor-dirty') === '1') { + textarea.value = editor.getHTML(); + } + }); + }, + + openSource: function (editor) { + var root = editor.view.dom.closest('.rich-editor'); + if (!root || !window.CodeMirror) { return; } + var surface = root.querySelector('.rich-editor-surface'); + var storage = root.querySelector('textarea[data-rich-editor]'); + var source = root.querySelector('[data-rich-inline-source]'); + var opening = !root.classList.contains('rich-editor-source-open'); + + if (!opening) { + var html = root._richSourceEditor ? root._richSourceEditor.getValue() : (storage ? storage.value : ''); + if (html !== editor.getHTML()) { editor.commands.setContent(html || '

'); } + if (storage) { storage.value = editor.getHTML(); } + root.classList.remove('rich-editor-source-open'); + this.setSourceControls(root, false); + this.syncToolbar(root, editor); + editor.commands.focus(); + return; + } + + if (!source) { + source = el('div', { class: 'rich-editor-inline-source', 'data-rich-inline-source': '' }); + var sourceTextarea = el('textarea', { 'data-rich-source-textarea': '' }); + source.appendChild(sourceTextarea); + surface.parentNode.insertBefore(source, surface.nextSibling); + root._richSourceEditor = window.CodeMirror.fromTextArea(sourceTextarea, { + mode: 'htmlmixed', + theme: document.documentElement.getAttribute('data-theme') === 'dark' ? 'material-ocean' : 'default', + lineNumbers: true, + lineWrapping: true, + matchBrackets: true, + autoCloseTags: true, + styleActiveLine: true, + indentUnit: 2 + }); + root._richSourceEditor.on('change', function (cm) { + if (!storage || root._richSourceSyncing) { return; } + storage.value = cm.getValue(); + storage.setAttribute('data-rich-editor-dirty', '1'); + storage.dispatchEvent(new Event('input', { bubbles: true })); + }); + } + + var height = Math.max(260, Math.round(surface.getBoundingClientRect().height || 0)); + root._richSourceSyncing = true; + root._richSourceEditor.setValue(editor.getHTML()); + root._richSourceSyncing = false; + root._richSourceEditor.setSize('100%', height); + root.classList.add('rich-editor-source-open'); + this.setSourceControls(root, true); + this.syncToolbar(root, editor); + setTimeout(function () { root._richSourceEditor.refresh(); root._richSourceEditor.focus(); }, 0); + }, + + setSourceControls: function (root, sourceOpen) { + root.querySelectorAll('.rich-editor-toolbar [data-rich-command], .rich-editor-toolbar [data-rich-block-select]').forEach(function (control) { + if (control.getAttribute('data-rich-command') === 'source') { return; } + control.disabled = !!sourceOpen; + }); + }, + + openLink: function (editor) { + var self = this; + var attributes = editor.getAttributes('link') || {}; + var overlay = el('div', { class: 'overlay rich-editor-link-overlay' }); + var modal = el('div', { class: 'modal rich-editor-link-modal', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'richEditorLinkTitle' }); + modal.innerHTML = + '' + + '' + + ''; + overlay.appendChild(modal); + document.body.appendChild(overlay); + requestAnimationFrame(function () { overlay.classList.add('show'); }); + + var urlInput = overlay.querySelector('[data-link-url]'); + var classInput = overlay.querySelector('[data-link-class]'); + var selection = overlay.querySelector('[data-link-selection]'); + var close = function () { + overlay.classList.remove('show'); + setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 180); + }; + var apply = function () { + var href = String(urlInput.value || '').trim(); + var cssClass = String(classInput.value || '').trim().replace(/\s+/g, ' '); + if (!href) { + editor.chain().focus().unsetLink().run(); + close(); + return; + } + editor.chain().focus().extendMarkRange('link').setLink({ href: href, class: cssClass || null }).run(); + close(); + }; + var setPicked = function (url, label) { + urlInput.value = url || ''; + selection.textContent = label || 'Адрес выбран'; + urlInput.dispatchEvent(new Event('input', { bubbles: true })); + }; + var onKey = function (event) { + if (event.key === 'Escape') { + if (document.querySelector('.rich-editor-document-picker-overlay.show, .media-picker-overlay.show')) { return; } + close(); + } + if (event.key === 'Enter' && event.target !== classInput) { event.preventDefault(); apply(); } + }; + overlay.addEventListener('click', function (event) { + if (event.target === overlay || event.target.closest('[data-link-cancel]')) { close(); return; } + if (event.target.closest('[data-link-apply]')) { apply(); return; } + if (event.target.closest('[data-link-unset]')) { editor.chain().focus().unsetLink().run(); close(); return; } + if (event.target.closest('[data-link-document]')) { + self.openDocumentLinkPicker(function (item) { + var alias = String(item.alias || '').trim(); + var url = alias ? (/^(?:[a-z]+:|\/|#)/i.test(alias) ? alias : '/' + alias) : '/index.php?id=' + item.id; + setPicked(url, 'Документ #' + item.id + ' · ' + (item.title || 'Без названия')); + }); + return; + } + if (event.target.closest('[data-link-media]') && Adminx.MediaPicker) { + Adminx.MediaPicker.open({ + type: 'all', + title: 'Файл для ссылки', + description: 'Выберите изображение или документ из медиатеки.', + onPick: function (file) { if (file && file.url) { setPicked(file.url, 'Медиа · ' + (file.name || file.url)); } }, + onUrl: function (url) { if (url) { setPicked(url, 'Внешний адрес медиа'); } } + }); + } + }); + document.addEventListener('keydown', onKey); + urlInput.focus(); + urlInput.select(); + }, + + openDocumentLinkPicker: function (done) { + var self = this; + var overlay = el('div', { class: 'overlay rich-editor-document-picker-overlay' }); + var modal = el('div', { class: 'modal picker-modal rich-editor-document-picker', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'richEditorDocumentPickerTitle' }); + modal.innerHTML = + '' + + '' + + ''; + overlay.appendChild(modal); + document.body.appendChild(overlay); + requestAnimationFrame(function () { overlay.classList.add('show'); }); + + var search = overlay.querySelector('[data-document-link-search]'); + var list = overlay.querySelector('[data-document-link-list]'); + var status = overlay.querySelector('[data-document-link-status]'); + var count = overlay.querySelector('[data-document-link-count]'); + var items = []; + var timer = null; + var request = 0; + var close = function () { + overlay.classList.remove('show'); + setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 160); + }; + var onKey = function (event) { if (event.key === 'Escape') { event.stopImmediatePropagation(); close(); } }; + var load = function () { + var current = ++request; + var params = new URLSearchParams(); + params.set('q', search.value.trim()); + params.set('limit', 40); + status.hidden = false; + status.textContent = 'Загрузка...'; + fetch((window.ADMINX_BASE || '/adminx') + '/documents/picker?' + params.toString(), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (response) { return response.json(); }) + .then(function (payload) { + if (current !== request) { return; } + items = ((payload.data || {}).items) || []; + list.innerHTML = items.map(function (item, index) { + return ''; + }).join(''); + status.hidden = items.length > 0; + status.textContent = search.value.trim() ? 'Ничего не найдено' : 'Документы не найдены'; + count.textContent = items.length ? 'Показано: ' + items.length : ''; + }) + .catch(function () { items = []; list.innerHTML = ''; status.hidden = false; status.textContent = 'Не удалось загрузить документы'; }); + }; + overlay.addEventListener('click', function (event) { + if (event.target === overlay || event.target.closest('[data-document-link-close]')) { close(); return; } + var row = event.target.closest('[data-document-link-index]'); + if (!row) { return; } + var item = items[parseInt(row.getAttribute('data-document-link-index'), 10)]; + if (item) { done(item); close(); } + }); + search.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(load, 220); }); + document.addEventListener('keydown', onKey); + load(); + search.focus(); + }, + + openMediaPicker: function (editor) { + this.openImageEditor(editor, {}, null); + }, + + openImageEditor: function (editor, current, nodePos) { + var self = this; + current = current || {}; + var editing = typeof nodePos === 'number'; + var overlay = el('div', { class: 'overlay rich-editor-image-overlay' }); + var modal = el('div', { class: 'modal rich-editor-image-modal', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'richEditorImageTitle' }); + modal.innerHTML = + '' + + '' + + ''; + overlay.appendChild(modal); + document.body.appendChild(overlay); + requestAnimationFrame(function () { overlay.classList.add('show'); }); + + var srcInput = overlay.querySelector('[data-image-src]'); + var webpInput = overlay.querySelector('[data-image-webp]'); + var pictureInput = overlay.querySelector('[data-image-picture]'); + var webpField = overlay.querySelector('[data-image-webp-field]'); + var altInput = overlay.querySelector('[data-image-alt]'); + var classInput = overlay.querySelector('[data-image-class]'); + var widthInput = overlay.querySelector('[data-image-width]'); + var heightInput = overlay.querySelector('[data-image-height]'); + var scaleInput = overlay.querySelector('[data-image-scale]'); + var scaleValue = overlay.querySelector('[data-image-scale-value]'); + var preview = overlay.querySelector('[data-image-preview-img]'); + var empty = overlay.querySelector('[data-image-empty]'); + var previewSize = overlay.querySelector('[data-image-preview-size]'); + var state = { + naturalWidth: parseInt(current.width, 10) || 0, + naturalHeight: parseInt(current.height, 10) || 0, + ratio: current.width && current.height ? parseInt(current.width, 10) / parseInt(current.height, 10) : 0, + loadingSrc: '' + }; + + var updateScale = function () { + var width = parseInt(widthInput.value, 10) || 0; + var percent = state.naturalWidth && width ? Math.round(width / state.naturalWidth * 100) : 100; + percent = Math.max(10, Math.min(200, percent)); + scaleInput.value = percent; + scaleValue.textContent = percent + '%'; + previewSize.textContent = width && heightInput.value ? width + ' x ' + heightInput.value + ' px' : ''; + }; + var setDimensions = function (width, height, natural) { + width = Math.max(1, Math.round(Number(width) || 0)); + height = Math.max(1, Math.round(Number(height) || 0)); + if (!width || !height) { return; } + if (natural) { + state.naturalWidth = width; + state.naturalHeight = height; + state.ratio = width / height; + } else if (!state.ratio) { + state.ratio = width / height; + } + widthInput.value = width; + heightInput.value = height; + updateScale(); + }; + var loadPreview = function (src, dimensions) { + src = String(src || '').trim(); + state.loadingSrc = src; + if (!src) { + preview.hidden = true; + empty.hidden = false; + previewSize.textContent = ''; + return; + } + var probe = new window.Image(); + probe.onload = function () { + if (state.loadingSrc !== src) { return; } + preview.src = src; + preview.hidden = false; + empty.hidden = true; + var naturalWidth = dimensions && dimensions.width ? dimensions.width : probe.naturalWidth; + var naturalHeight = dimensions && dimensions.height ? dimensions.height : probe.naturalHeight; + if (!widthInput.value || !heightInput.value || src !== current.src) { + setDimensions(naturalWidth, naturalHeight, true); + } else { + state.naturalWidth = naturalWidth || parseInt(widthInput.value, 10); + state.naturalHeight = naturalHeight || parseInt(heightInput.value, 10); + state.ratio = state.naturalWidth / state.naturalHeight; + updateScale(); + } + }; + probe.onerror = function () { + if (state.loadingSrc !== src) { return; } + preview.hidden = true; + empty.hidden = false; + empty.querySelector('span').textContent = 'Не удалось загрузить изображение'; + }; + probe.src = src; + }; + var choose = function (target) { + if (!Adminx.MediaPicker) { return; } + Adminx.MediaPicker.open({ + type: 'image', + title: target === 'webp' ? 'Выбрать WebP' : 'Выбрать изображение', + description: target === 'webp' ? 'Выберите файл WebP для тега source.' : 'Выберите основное изображение из медиатеки.', + onPick: function (file) { + if (!file || !file.url) { return; } + if (target === 'webp') { + webpInput.value = file.url; + pictureInput.checked = true; + webpField.hidden = false; + return; + } + srcInput.value = file.url; + if (!altInput.value && file.name) { altInput.value = String(file.name).replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' '); } + widthInput.value = ''; + heightInput.value = ''; + loadPreview(file.url, file); + }, + onUrl: function (url) { + if (!url) { return; } + if (target === 'webp') { + webpInput.value = url; + pictureInput.checked = true; + webpField.hidden = false; + } else { + srcInput.value = url; + widthInput.value = ''; + heightInput.value = ''; + loadPreview(url); + } + } + }); + }; + var close = function () { + overlay.classList.remove('show'); + setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 180); + }; + var apply = function () { + var src = String(srcInput.value || '').trim(); + if (!src) { srcInput.focus(); return; } + var attributes = { + src: src, + webp: pictureInput.checked ? String(webpInput.value || '').trim() || null : null, + alt: String(altInput.value || '').trim() || null, + class: String(classInput.value || '').trim().replace(/\s+/g, ' ') || null, + width: parseInt(widthInput.value, 10) || null, + height: parseInt(heightInput.value, 10) || null + }; + if (editing) { + editor.chain().focus().setNodeSelection(nodePos).updateAttributes('image', attributes).run(); + } else { + editor.chain().focus().setImage(attributes).run(); + } + close(); + }; + var onKey = function (event) { + if (event.key === 'Escape') { + if (document.querySelector('.media-picker-overlay.show')) { return; } + close(); + } + }; + + pictureInput.addEventListener('change', function () { webpField.hidden = !pictureInput.checked; if (pictureInput.checked) { webpInput.focus(); } }); + srcInput.addEventListener('change', function () { widthInput.value = ''; heightInput.value = ''; loadPreview(srcInput.value); }); + widthInput.addEventListener('input', function () { + var width = parseInt(widthInput.value, 10) || 0; + if (width && state.ratio) { heightInput.value = Math.max(1, Math.round(width / state.ratio)); } + updateScale(); + }); + heightInput.addEventListener('input', function () { + var height = parseInt(heightInput.value, 10) || 0; + if (height && state.ratio) { widthInput.value = Math.max(1, Math.round(height * state.ratio)); } + updateScale(); + }); + scaleInput.addEventListener('input', function () { + var percent = parseInt(scaleInput.value, 10) || 100; + scaleValue.textContent = percent + '%'; + if (state.naturalWidth && state.naturalHeight) { + widthInput.value = Math.max(1, Math.round(state.naturalWidth * percent / 100)); + heightInput.value = Math.max(1, Math.round(state.naturalHeight * percent / 100)); + previewSize.textContent = widthInput.value + ' x ' + heightInput.value + ' px'; + } + }); + overlay.addEventListener('click', function (event) { + if (event.target === overlay || event.target.closest('[data-image-cancel]')) { close(); return; } + var pick = event.target.closest('[data-image-pick]'); + if (pick) { choose(pick.getAttribute('data-image-pick')); return; } + if (event.target.closest('[data-image-original]') && state.naturalWidth && state.naturalHeight) { setDimensions(state.naturalWidth, state.naturalHeight, false); return; } + if (event.target.closest('[data-image-apply]')) { apply(); return; } + if (event.target.closest('[data-image-remove]') && editing) { editor.chain().focus().setNodeSelection(nodePos).deleteSelection().run(); close(); } + }); + document.addEventListener('keydown', onKey); + if (current.src) { loadPreview(current.src); } else { srcInput.focus(); } + }, + + modalInput: function (cfg, done) { + var overlay = el('div', { class: 'overlay' }); + var modal = el('div', { class: 'modal rich-editor-prompt', role: 'dialog', 'aria-modal': 'true' }); + modal.innerHTML = + '' + + '' + + ''; + overlay.appendChild(modal); + document.body.appendChild(overlay); + requestAnimationFrame(function () { overlay.classList.add('show'); }); + + var input = overlay.querySelector('[data-input]'); + var close = function () { + overlay.classList.remove('show'); + setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 180); + }; + var apply = function () { + var value = input.value || ''; + close(); + done(value); + }; + var onKey = function (e) { + if (e.key === 'Escape') { close(); } + if (e.key === 'Enter') { apply(); } + }; + overlay.addEventListener('click', function (e) { + if (e.target === overlay || e.target.closest('[data-cancel]')) { close(); } + if (e.target.closest('[data-ok]')) { apply(); } + }); + document.addEventListener('keydown', onKey); + input.focus(); + input.select(); + }, + + escape: function (value) { + return String(value == null ? '' : value).replace(/[&<>"]/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; + }); + } + }; + + document.addEventListener('adminx:content-ready', function (e) { + Adminx.RichEditor.init(e.detail && e.detail.root ? e.detail.root : document); + }); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.RichEditor.init(); }); + } else { + Adminx.RichEditor.init(); + } +})(window, document); diff --git a/adminx/assets/vendor/codemirror/addon/dialog/dialog.css b/adminx/assets/vendor/codemirror/addon/dialog/dialog.css new file mode 100644 index 0000000..677c078 --- /dev/null +++ b/adminx/assets/vendor/codemirror/addon/dialog/dialog.css @@ -0,0 +1,32 @@ +.CodeMirror-dialog { + position: absolute; + left: 0; right: 0; + background: inherit; + z-index: 15; + padding: .1em .8em; + overflow: hidden; + color: inherit; +} + +.CodeMirror-dialog-top { + border-bottom: 1px solid #eee; + top: 0; +} + +.CodeMirror-dialog-bottom { + border-top: 1px solid #eee; + bottom: 0; +} + +.CodeMirror-dialog input { + border: none; + outline: none; + background: transparent; + width: 20em; + color: inherit; + font-family: monospace; +} + +.CodeMirror-dialog button { + font-size: 70%; +} diff --git a/adminx/assets/vendor/codemirror/addon/dialog/dialog.js b/adminx/assets/vendor/codemirror/addon/dialog/dialog.js new file mode 100644 index 0000000..23b06a8 --- /dev/null +++ b/adminx/assets/vendor/codemirror/addon/dialog/dialog.js @@ -0,0 +1,161 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +// Open simple dialogs on top of an editor. Relies on dialog.css. + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { + function dialogDiv(cm, template, bottom) { + var wrap = cm.getWrapperElement(); + var dialog; + dialog = wrap.appendChild(document.createElement("div")); + if (bottom) + dialog.className = "CodeMirror-dialog CodeMirror-dialog-bottom"; + else + dialog.className = "CodeMirror-dialog CodeMirror-dialog-top"; + + if (typeof template == "string") { + dialog.innerHTML = template; + } else { // Assuming it's a detached DOM element. + dialog.appendChild(template); + } + CodeMirror.addClass(wrap, 'dialog-opened'); + return dialog; + } + + function closeNotification(cm, newVal) { + if (cm.state.currentNotificationClose) + cm.state.currentNotificationClose(); + cm.state.currentNotificationClose = newVal; + } + + CodeMirror.defineExtension("openDialog", function(template, callback, options) { + if (!options) options = {}; + + closeNotification(this, null); + + var dialog = dialogDiv(this, template, options.bottom); + var closed = false, me = this; + function close(newVal) { + if (typeof newVal == 'string') { + inp.value = newVal; + } else { + if (closed) return; + closed = true; + CodeMirror.rmClass(dialog.parentNode, 'dialog-opened'); + dialog.parentNode.removeChild(dialog); + me.focus(); + + if (options.onClose) options.onClose(dialog); + } + } + + var inp = dialog.getElementsByTagName("input")[0], button; + if (inp) { + inp.focus(); + + if (options.value) { + inp.value = options.value; + if (options.selectValueOnOpen !== false) { + inp.select(); + } + } + + if (options.onInput) + CodeMirror.on(inp, "input", function(e) { options.onInput(e, inp.value, close);}); + if (options.onKeyUp) + CodeMirror.on(inp, "keyup", function(e) {options.onKeyUp(e, inp.value, close);}); + + CodeMirror.on(inp, "keydown", function(e) { + if (options && options.onKeyDown && options.onKeyDown(e, inp.value, close)) { return; } + if (e.keyCode == 27 || (options.closeOnEnter !== false && e.keyCode == 13)) { + inp.blur(); + CodeMirror.e_stop(e); + close(); + } + if (e.keyCode == 13) callback(inp.value, e); + }); + + if (options.closeOnBlur !== false) CodeMirror.on(inp, "blur", close); + } else if (button = dialog.getElementsByTagName("button")[0]) { + CodeMirror.on(button, "click", function() { + close(); + me.focus(); + }); + + if (options.closeOnBlur !== false) CodeMirror.on(button, "blur", close); + + button.focus(); + } + return close; + }); + + CodeMirror.defineExtension("openConfirm", function(template, callbacks, options) { + closeNotification(this, null); + var dialog = dialogDiv(this, template, options && options.bottom); + var buttons = dialog.getElementsByTagName("button"); + var closed = false, me = this, blurring = 1; + function close() { + if (closed) return; + closed = true; + CodeMirror.rmClass(dialog.parentNode, 'dialog-opened'); + dialog.parentNode.removeChild(dialog); + me.focus(); + } + buttons[0].focus(); + for (var i = 0; i < buttons.length; ++i) { + var b = buttons[i]; + (function(callback) { + CodeMirror.on(b, "click", function(e) { + CodeMirror.e_preventDefault(e); + close(); + if (callback) callback(me); + }); + })(callbacks[i]); + CodeMirror.on(b, "blur", function() { + --blurring; + setTimeout(function() { if (blurring <= 0) close(); }, 200); + }); + CodeMirror.on(b, "focus", function() { ++blurring; }); + } + }); + + /* + * openNotification + * Opens a notification, that can be closed with an optional timer + * (default 5000ms timer) and always closes on click. + * + * If a notification is opened while another is opened, it will close the + * currently opened one and open the new one immediately. + */ + CodeMirror.defineExtension("openNotification", function(template, options) { + closeNotification(this, close); + var dialog = dialogDiv(this, template, options && options.bottom); + var closed = false, doneTimer; + var duration = options && typeof options.duration !== "undefined" ? options.duration : 5000; + + function close() { + if (closed) return; + closed = true; + clearTimeout(doneTimer); + CodeMirror.rmClass(dialog.parentNode, 'dialog-opened'); + dialog.parentNode.removeChild(dialog); + } + + CodeMirror.on(dialog, 'click', function(e) { + CodeMirror.e_preventDefault(e); + close(); + }); + + if (duration) + doneTimer = setTimeout(close, duration); + + return close; + }); +}); diff --git a/adminx/assets/vendor/codemirror/addon/edit/closetag.js b/adminx/assets/vendor/codemirror/addon/edit/closetag.js new file mode 100644 index 0000000..b8cbf95 --- /dev/null +++ b/adminx/assets/vendor/codemirror/addon/edit/closetag.js @@ -0,0 +1,184 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +/** + * Tag-closer extension for CodeMirror. + * + * This extension adds an "autoCloseTags" option that can be set to + * either true to get the default behavior, or an object to further + * configure its behavior. + * + * These are supported options: + * + * `whenClosing` (default true) + * Whether to autoclose when the '/' of a closing tag is typed. + * `whenOpening` (default true) + * Whether to autoclose the tag when the final '>' of an opening + * tag is typed. + * `dontCloseTags` (default is empty tags for HTML, none for XML) + * An array of tag names that should not be autoclosed. + * `indentTags` (default is block tags for HTML, none for XML) + * An array of tag names that should, when opened, cause a + * blank line to be added inside the tag, and the blank line and + * closing line to be indented. + * `emptyTags` (default is none) + * An array of XML tag names that should be autoclosed with '/>'. + * + * See demos/closetag.html for a usage example. + */ + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror"), require("../fold/xml-fold")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror", "../fold/xml-fold"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { + CodeMirror.defineOption("autoCloseTags", false, function(cm, val, old) { + if (old != CodeMirror.Init && old) + cm.removeKeyMap("autoCloseTags"); + if (!val) return; + var map = {name: "autoCloseTags"}; + if (typeof val != "object" || val.whenClosing) + map["'/'"] = function(cm) { return autoCloseSlash(cm); }; + if (typeof val != "object" || val.whenOpening) + map["'>'"] = function(cm) { return autoCloseGT(cm); }; + cm.addKeyMap(map); + }); + + var htmlDontClose = ["area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "meta", "param", + "source", "track", "wbr"]; + var htmlIndent = ["applet", "blockquote", "body", "button", "div", "dl", "fieldset", "form", "frameset", "h1", "h2", "h3", "h4", + "h5", "h6", "head", "html", "iframe", "layer", "legend", "object", "ol", "p", "select", "table", "ul"]; + + function autoCloseGT(cm) { + if (cm.getOption("disableInput")) return CodeMirror.Pass; + var ranges = cm.listSelections(), replacements = []; + var opt = cm.getOption("autoCloseTags"); + for (var i = 0; i < ranges.length; i++) { + if (!ranges[i].empty()) return CodeMirror.Pass; + var pos = ranges[i].head, tok = cm.getTokenAt(pos); + var inner = CodeMirror.innerMode(cm.getMode(), tok.state), state = inner.state; + var tagInfo = inner.mode.xmlCurrentTag && inner.mode.xmlCurrentTag(state) + var tagName = tagInfo && tagInfo.name + if (!tagName) return CodeMirror.Pass + + var html = inner.mode.configuration == "html"; + var dontCloseTags = (typeof opt == "object" && opt.dontCloseTags) || (html && htmlDontClose); + var indentTags = (typeof opt == "object" && opt.indentTags) || (html && htmlIndent); + + if (tok.end > pos.ch) tagName = tagName.slice(0, tagName.length - tok.end + pos.ch); + var lowerTagName = tagName.toLowerCase(); + // Don't process the '>' at the end of an end-tag or self-closing tag + if (!tagName || + tok.type == "string" && (tok.end != pos.ch || !/[\"\']/.test(tok.string.charAt(tok.string.length - 1)) || tok.string.length == 1) || + tok.type == "tag" && tagInfo.close || + tok.string.indexOf("/") == (pos.ch - tok.start - 1) || // match something like + dontCloseTags && indexOf(dontCloseTags, lowerTagName) > -1 || + closingTagExists(cm, inner.mode.xmlCurrentContext && inner.mode.xmlCurrentContext(state) || [], tagName, pos, true)) + return CodeMirror.Pass; + + var emptyTags = typeof opt == "object" && opt.emptyTags; + if (emptyTags && indexOf(emptyTags, tagName) > -1) { + replacements[i] = { text: "/>", newPos: CodeMirror.Pos(pos.line, pos.ch + 2) }; + continue; + } + + var indent = indentTags && indexOf(indentTags, lowerTagName) > -1; + replacements[i] = {indent: indent, + text: ">" + (indent ? "\n\n" : "") + "", + newPos: indent ? CodeMirror.Pos(pos.line + 1, 0) : CodeMirror.Pos(pos.line, pos.ch + 1)}; + } + + var dontIndentOnAutoClose = (typeof opt == "object" && opt.dontIndentOnAutoClose); + for (var i = ranges.length - 1; i >= 0; i--) { + var info = replacements[i]; + cm.replaceRange(info.text, ranges[i].head, ranges[i].anchor, "+insert"); + var sel = cm.listSelections().slice(0); + sel[i] = {head: info.newPos, anchor: info.newPos}; + cm.setSelections(sel); + if (!dontIndentOnAutoClose && info.indent) { + cm.indentLine(info.newPos.line, null, true); + cm.indentLine(info.newPos.line + 1, null, true); + } + } + } + + function autoCloseCurrent(cm, typingSlash) { + var ranges = cm.listSelections(), replacements = []; + var head = typingSlash ? "/" : "") replacement += ">"; + replacements[i] = replacement; + } + cm.replaceSelections(replacements); + ranges = cm.listSelections(); + if (!dontIndentOnAutoClose) { + for (var i = 0; i < ranges.length; i++) + if (i == ranges.length - 1 || ranges[i].head.line < ranges[i + 1].head.line) + cm.indentLine(ranges[i].head.line); + } + } + + function autoCloseSlash(cm) { + if (cm.getOption("disableInput")) return CodeMirror.Pass; + return autoCloseCurrent(cm, true); + } + + CodeMirror.commands.closeTag = function(cm) { return autoCloseCurrent(cm); }; + + function indexOf(collection, elt) { + if (collection.indexOf) return collection.indexOf(elt); + for (var i = 0, e = collection.length; i < e; ++i) + if (collection[i] == elt) return i; + return -1; + } + + // If xml-fold is loaded, we use its functionality to try and verify + // whether a given tag is actually unclosed. + function closingTagExists(cm, context, tagName, pos, newTag) { + if (!CodeMirror.scanForClosingTag) return false; + var end = Math.min(cm.lastLine() + 1, pos.line + 500); + var nextClose = CodeMirror.scanForClosingTag(cm, pos, null, end); + if (!nextClose || nextClose.tag != tagName) return false; + // If the immediate wrapping context contains onCx instances of + // the same tag, a closing tag only exists if there are at least + // that many closing tags of that type following. + var onCx = newTag ? 1 : 0 + for (var i = context.length - 1; i >= 0; i--) { + if (context[i] == tagName) ++onCx + else break + } + pos = nextClose.to; + for (var i = 1; i < onCx; i++) { + var next = CodeMirror.scanForClosingTag(cm, pos, null, end); + if (!next || next.tag != tagName) return false; + pos = next.to; + } + return true; + } +}); diff --git a/adminx/assets/vendor/codemirror/addon/edit/matchbrackets.js b/adminx/assets/vendor/codemirror/addon/edit/matchbrackets.js new file mode 100644 index 0000000..2a14728 --- /dev/null +++ b/adminx/assets/vendor/codemirror/addon/edit/matchbrackets.js @@ -0,0 +1,150 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { + var ie_lt8 = /MSIE \d/.test(navigator.userAgent) && + (document.documentMode == null || document.documentMode < 8); + + var Pos = CodeMirror.Pos; + + var matching = {"(": ")>", ")": "(<", "[": "]>", "]": "[<", "{": "}>", "}": "{<", "<": ">>", ">": "<<"}; + + function bracketRegex(config) { + return config && config.bracketRegex || /[(){}[\]]/ + } + + function findMatchingBracket(cm, where, config) { + var line = cm.getLineHandle(where.line), pos = where.ch - 1; + var afterCursor = config && config.afterCursor + if (afterCursor == null) + afterCursor = /(^| )cm-fat-cursor($| )/.test(cm.getWrapperElement().className) + var re = bracketRegex(config) + + // A cursor is defined as between two characters, but in in vim command mode + // (i.e. not insert mode), the cursor is visually represented as a + // highlighted box on top of the 2nd character. Otherwise, we allow matches + // from before or after the cursor. + var match = (!afterCursor && pos >= 0 && re.test(line.text.charAt(pos)) && matching[line.text.charAt(pos)]) || + re.test(line.text.charAt(pos + 1)) && matching[line.text.charAt(++pos)]; + if (!match) return null; + var dir = match.charAt(1) == ">" ? 1 : -1; + if (config && config.strict && (dir > 0) != (pos == where.ch)) return null; + var style = cm.getTokenTypeAt(Pos(where.line, pos + 1)); + + var found = scanForBracket(cm, Pos(where.line, pos + (dir > 0 ? 1 : 0)), dir, style || null, config); + if (found == null) return null; + return {from: Pos(where.line, pos), to: found && found.pos, + match: found && found.ch == match.charAt(0), forward: dir > 0}; + } + + // bracketRegex is used to specify which type of bracket to scan + // should be a regexp, e.g. /[[\]]/ + // + // Note: If "where" is on an open bracket, then this bracket is ignored. + // + // Returns false when no bracket was found, null when it reached + // maxScanLines and gave up + function scanForBracket(cm, where, dir, style, config) { + var maxScanLen = (config && config.maxScanLineLength) || 10000; + var maxScanLines = (config && config.maxScanLines) || 1000; + + var stack = []; + var re = bracketRegex(config) + var lineEnd = dir > 0 ? Math.min(where.line + maxScanLines, cm.lastLine() + 1) + : Math.max(cm.firstLine() - 1, where.line - maxScanLines); + for (var lineNo = where.line; lineNo != lineEnd; lineNo += dir) { + var line = cm.getLine(lineNo); + if (!line) continue; + var pos = dir > 0 ? 0 : line.length - 1, end = dir > 0 ? line.length : -1; + if (line.length > maxScanLen) continue; + if (lineNo == where.line) pos = where.ch - (dir < 0 ? 1 : 0); + for (; pos != end; pos += dir) { + var ch = line.charAt(pos); + if (re.test(ch) && (style === undefined || cm.getTokenTypeAt(Pos(lineNo, pos + 1)) == style)) { + var match = matching[ch]; + if (match && (match.charAt(1) == ">") == (dir > 0)) stack.push(ch); + else if (!stack.length) return {pos: Pos(lineNo, pos), ch: ch}; + else stack.pop(); + } + } + } + return lineNo - dir == (dir > 0 ? cm.lastLine() : cm.firstLine()) ? false : null; + } + + function matchBrackets(cm, autoclear, config) { + // Disable brace matching in long lines, since it'll cause hugely slow updates + var maxHighlightLen = cm.state.matchBrackets.maxHighlightLineLength || 1000; + var marks = [], ranges = cm.listSelections(); + for (var i = 0; i < ranges.length; i++) { + var match = ranges[i].empty() && findMatchingBracket(cm, ranges[i].head, config); + if (match && cm.getLine(match.from.line).length <= maxHighlightLen) { + var style = match.match ? "CodeMirror-matchingbracket" : "CodeMirror-nonmatchingbracket"; + marks.push(cm.markText(match.from, Pos(match.from.line, match.from.ch + 1), {className: style})); + if (match.to && cm.getLine(match.to.line).length <= maxHighlightLen) + marks.push(cm.markText(match.to, Pos(match.to.line, match.to.ch + 1), {className: style})); + } + } + + if (marks.length) { + // Kludge to work around the IE bug from issue #1193, where text + // input stops going to the textare whever this fires. + if (ie_lt8 && cm.state.focused) cm.focus(); + + var clear = function() { + cm.operation(function() { + for (var i = 0; i < marks.length; i++) marks[i].clear(); + }); + }; + if (autoclear) setTimeout(clear, 800); + else return clear; + } + } + + function doMatchBrackets(cm) { + cm.operation(function() { + if (cm.state.matchBrackets.currentlyHighlighted) { + cm.state.matchBrackets.currentlyHighlighted(); + cm.state.matchBrackets.currentlyHighlighted = null; + } + cm.state.matchBrackets.currentlyHighlighted = matchBrackets(cm, false, cm.state.matchBrackets); + }); + } + + CodeMirror.defineOption("matchBrackets", false, function(cm, val, old) { + if (old && old != CodeMirror.Init) { + cm.off("cursorActivity", doMatchBrackets); + if (cm.state.matchBrackets && cm.state.matchBrackets.currentlyHighlighted) { + cm.state.matchBrackets.currentlyHighlighted(); + cm.state.matchBrackets.currentlyHighlighted = null; + } + } + if (val) { + cm.state.matchBrackets = typeof val == "object" ? val : {}; + cm.on("cursorActivity", doMatchBrackets); + } + }); + + CodeMirror.defineExtension("matchBrackets", function() {matchBrackets(this, true);}); + CodeMirror.defineExtension("findMatchingBracket", function(pos, config, oldConfig){ + // Backwards-compatibility kludge + if (oldConfig || typeof config == "boolean") { + if (!oldConfig) { + config = config ? {strict: true} : null + } else { + oldConfig.strict = config + config = oldConfig + } + } + return findMatchingBracket(this, pos, config) + }); + CodeMirror.defineExtension("scanForBracket", function(pos, dir, style, config){ + return scanForBracket(this, pos, dir, style, config); + }); +}); diff --git a/adminx/assets/vendor/codemirror/addon/search/search.js b/adminx/assets/vendor/codemirror/addon/search/search.js new file mode 100644 index 0000000..cecdd52 --- /dev/null +++ b/adminx/assets/vendor/codemirror/addon/search/search.js @@ -0,0 +1,260 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +// Define search commands. Depends on dialog.js or another +// implementation of the openDialog method. + +// Replace works a little oddly -- it will do the replace on the next +// Ctrl-G (or whatever is bound to findNext) press. You prevent a +// replace by making sure the match is no longer selected when hitting +// Ctrl-G. + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror"), require("./searchcursor"), require("../dialog/dialog")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror", "./searchcursor", "../dialog/dialog"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { + "use strict"; + + function searchOverlay(query, caseInsensitive) { + if (typeof query == "string") + query = new RegExp(query.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"), caseInsensitive ? "gi" : "g"); + else if (!query.global) + query = new RegExp(query.source, query.ignoreCase ? "gi" : "g"); + + return {token: function(stream) { + query.lastIndex = stream.pos; + var match = query.exec(stream.string); + if (match && match.index == stream.pos) { + stream.pos += match[0].length || 1; + return "searching"; + } else if (match) { + stream.pos = match.index; + } else { + stream.skipToEnd(); + } + }}; + } + + function SearchState() { + this.posFrom = this.posTo = this.lastQuery = this.query = null; + this.overlay = null; + } + + function getSearchState(cm) { + return cm.state.search || (cm.state.search = new SearchState()); + } + + function queryCaseInsensitive(query) { + return typeof query == "string" && query == query.toLowerCase(); + } + + function getSearchCursor(cm, query, pos) { + // Heuristic: if the query string is all lowercase, do a case insensitive search. + return cm.getSearchCursor(query, pos, {caseFold: queryCaseInsensitive(query), multiline: true}); + } + + function persistentDialog(cm, text, deflt, onEnter, onKeyDown) { + cm.openDialog(text, onEnter, { + value: deflt, + selectValueOnOpen: true, + closeOnEnter: false, + onClose: function() { clearSearch(cm); }, + onKeyDown: onKeyDown + }); + } + + function dialog(cm, text, shortText, deflt, f) { + if (cm.openDialog) cm.openDialog(text, f, {value: deflt, selectValueOnOpen: true}); + else f(prompt(shortText, deflt)); + } + + function confirmDialog(cm, text, shortText, fs) { + if (cm.openConfirm) cm.openConfirm(text, fs); + else if (confirm(shortText)) fs[0](); + } + + function parseString(string) { + return string.replace(/\\([nrt\\])/g, function(match, ch) { + if (ch == "n") return "\n" + if (ch == "r") return "\r" + if (ch == "t") return "\t" + if (ch == "\\") return "\\" + return match + }) + } + + function parseQuery(query) { + var isRE = query.match(/^\/(.*)\/([a-z]*)$/); + if (isRE) { + try { query = new RegExp(isRE[1], isRE[2].indexOf("i") == -1 ? "" : "i"); } + catch(e) {} // Not a regular expression after all, do a string search + } else { + query = parseString(query) + } + if (typeof query == "string" ? query == "" : query.test("")) + query = /x^/; + return query; + } + + function startSearch(cm, state, query) { + state.queryText = query; + state.query = parseQuery(query); + cm.removeOverlay(state.overlay, queryCaseInsensitive(state.query)); + state.overlay = searchOverlay(state.query, queryCaseInsensitive(state.query)); + cm.addOverlay(state.overlay); + if (cm.showMatchesOnScrollbar) { + if (state.annotate) { state.annotate.clear(); state.annotate = null; } + state.annotate = cm.showMatchesOnScrollbar(state.query, queryCaseInsensitive(state.query)); + } + } + + function doSearch(cm, rev, persistent, immediate) { + var state = getSearchState(cm); + if (state.query) return findNext(cm, rev); + var q = cm.getSelection() || state.lastQuery; + if (q instanceof RegExp && q.source == "x^") q = null + if (persistent && cm.openDialog) { + var hiding = null + var searchNext = function(query, event) { + CodeMirror.e_stop(event); + if (!query) return; + if (query != state.queryText) { + startSearch(cm, state, query); + state.posFrom = state.posTo = cm.getCursor(); + } + if (hiding) hiding.style.opacity = 1 + findNext(cm, event.shiftKey, function(_, to) { + var dialog + if (to.line < 3 && document.querySelector && + (dialog = cm.display.wrapper.querySelector(".CodeMirror-dialog")) && + dialog.getBoundingClientRect().bottom - 4 > cm.cursorCoords(to, "window").top) + (hiding = dialog).style.opacity = .4 + }) + }; + persistentDialog(cm, getQueryDialog(cm), q, searchNext, function(event, query) { + var keyName = CodeMirror.keyName(event) + var extra = cm.getOption('extraKeys'), cmd = (extra && extra[keyName]) || CodeMirror.keyMap[cm.getOption("keyMap")][keyName] + if (cmd == "findNext" || cmd == "findPrev" || + cmd == "findPersistentNext" || cmd == "findPersistentPrev") { + CodeMirror.e_stop(event); + startSearch(cm, getSearchState(cm), query); + cm.execCommand(cmd); + } else if (cmd == "find" || cmd == "findPersistent") { + CodeMirror.e_stop(event); + searchNext(query, event); + } + }); + if (immediate && q) { + startSearch(cm, state, q); + findNext(cm, rev); + } + } else { + dialog(cm, getQueryDialog(cm), "Search for:", q, function(query) { + if (query && !state.query) cm.operation(function() { + startSearch(cm, state, query); + state.posFrom = state.posTo = cm.getCursor(); + findNext(cm, rev); + }); + }); + } + } + + function findNext(cm, rev, callback) {cm.operation(function() { + var state = getSearchState(cm); + var cursor = getSearchCursor(cm, state.query, rev ? state.posFrom : state.posTo); + if (!cursor.find(rev)) { + cursor = getSearchCursor(cm, state.query, rev ? CodeMirror.Pos(cm.lastLine()) : CodeMirror.Pos(cm.firstLine(), 0)); + if (!cursor.find(rev)) return; + } + cm.setSelection(cursor.from(), cursor.to()); + cm.scrollIntoView({from: cursor.from(), to: cursor.to()}, 20); + state.posFrom = cursor.from(); state.posTo = cursor.to(); + if (callback) callback(cursor.from(), cursor.to()) + });} + + function clearSearch(cm) {cm.operation(function() { + var state = getSearchState(cm); + state.lastQuery = state.query; + if (!state.query) return; + state.query = state.queryText = null; + cm.removeOverlay(state.overlay); + if (state.annotate) { state.annotate.clear(); state.annotate = null; } + });} + + + function getQueryDialog(cm) { + return '' + cm.phrase("Search:") + ' ' + cm.phrase("(Use /re/ syntax for regexp search)") + ''; + } + function getReplaceQueryDialog(cm) { + return ' ' + cm.phrase("(Use /re/ syntax for regexp search)") + ''; + } + function getReplacementQueryDialog(cm) { + return '' + cm.phrase("With:") + ' '; + } + function getDoReplaceConfirm(cm) { + return '' + cm.phrase("Replace?") + ' '; + } + + function replaceAll(cm, query, text) { + cm.operation(function() { + for (var cursor = getSearchCursor(cm, query); cursor.findNext();) { + if (typeof query != "string") { + var match = cm.getRange(cursor.from(), cursor.to()).match(query); + cursor.replace(text.replace(/\$(\d)/g, function(_, i) {return match[i];})); + } else cursor.replace(text); + } + }); + } + + function replace(cm, all) { + if (cm.getOption("readOnly")) return; + var query = cm.getSelection() || getSearchState(cm).lastQuery; + var dialogText = '' + (all ? cm.phrase("Replace all:") : cm.phrase("Replace:")) + ''; + dialog(cm, dialogText + getReplaceQueryDialog(cm), dialogText, query, function(query) { + if (!query) return; + query = parseQuery(query); + dialog(cm, getReplacementQueryDialog(cm), cm.phrase("Replace with:"), "", function(text) { + text = parseString(text) + if (all) { + replaceAll(cm, query, text) + } else { + clearSearch(cm); + var cursor = getSearchCursor(cm, query, cm.getCursor("from")); + var advance = function() { + var start = cursor.from(), match; + if (!(match = cursor.findNext())) { + cursor = getSearchCursor(cm, query); + if (!(match = cursor.findNext()) || + (start && cursor.from().line == start.line && cursor.from().ch == start.ch)) return; + } + cm.setSelection(cursor.from(), cursor.to()); + cm.scrollIntoView({from: cursor.from(), to: cursor.to()}); + confirmDialog(cm, getDoReplaceConfirm(cm), cm.phrase("Replace?"), + [function() {doReplace(match);}, advance, + function() {replaceAll(cm, query, text)}]); + }; + var doReplace = function(match) { + cursor.replace(typeof query == "string" ? text : + text.replace(/\$(\d)/g, function(_, i) {return match[i];})); + advance(); + }; + advance(); + } + }); + }); + } + + CodeMirror.commands.find = function(cm) {clearSearch(cm); doSearch(cm);}; + CodeMirror.commands.findPersistent = function(cm) {clearSearch(cm); doSearch(cm, false, true);}; + CodeMirror.commands.findPersistentNext = function(cm) {doSearch(cm, false, true, true);}; + CodeMirror.commands.findPersistentPrev = function(cm) {doSearch(cm, true, true, true);}; + CodeMirror.commands.findNext = doSearch; + CodeMirror.commands.findPrev = function(cm) {doSearch(cm, true);}; + CodeMirror.commands.clearSearch = clearSearch; + CodeMirror.commands.replace = replace; + CodeMirror.commands.replaceAll = function(cm) {replace(cm, true);}; +}); diff --git a/adminx/assets/vendor/codemirror/addon/search/searchcursor.js b/adminx/assets/vendor/codemirror/addon/search/searchcursor.js new file mode 100644 index 0000000..816bf77 --- /dev/null +++ b/adminx/assets/vendor/codemirror/addon/search/searchcursor.js @@ -0,0 +1,296 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")) + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod) + else // Plain browser env + mod(CodeMirror) +})(function(CodeMirror) { + "use strict" + var Pos = CodeMirror.Pos + + function regexpFlags(regexp) { + var flags = regexp.flags + return flags != null ? flags : (regexp.ignoreCase ? "i" : "") + + (regexp.global ? "g" : "") + + (regexp.multiline ? "m" : "") + } + + function ensureFlags(regexp, flags) { + var current = regexpFlags(regexp), target = current + for (var i = 0; i < flags.length; i++) if (target.indexOf(flags.charAt(i)) == -1) + target += flags.charAt(i) + return current == target ? regexp : new RegExp(regexp.source, target) + } + + function maybeMultiline(regexp) { + return /\\s|\\n|\n|\\W|\\D|\[\^/.test(regexp.source) + } + + function searchRegexpForward(doc, regexp, start) { + regexp = ensureFlags(regexp, "g") + for (var line = start.line, ch = start.ch, last = doc.lastLine(); line <= last; line++, ch = 0) { + regexp.lastIndex = ch + var string = doc.getLine(line), match = regexp.exec(string) + if (match) + return {from: Pos(line, match.index), + to: Pos(line, match.index + match[0].length), + match: match} + } + } + + function searchRegexpForwardMultiline(doc, regexp, start) { + if (!maybeMultiline(regexp)) return searchRegexpForward(doc, regexp, start) + + regexp = ensureFlags(regexp, "gm") + var string, chunk = 1 + for (var line = start.line, last = doc.lastLine(); line <= last;) { + // This grows the search buffer in exponentially-sized chunks + // between matches, so that nearby matches are fast and don't + // require concatenating the whole document (in case we're + // searching for something that has tons of matches), but at the + // same time, the amount of retries is limited. + for (var i = 0; i < chunk; i++) { + if (line > last) break + var curLine = doc.getLine(line++) + string = string == null ? curLine : string + "\n" + curLine + } + chunk = chunk * 2 + regexp.lastIndex = start.ch + var match = regexp.exec(string) + if (match) { + var before = string.slice(0, match.index).split("\n"), inside = match[0].split("\n") + var startLine = start.line + before.length - 1, startCh = before[before.length - 1].length + return {from: Pos(startLine, startCh), + to: Pos(startLine + inside.length - 1, + inside.length == 1 ? startCh + inside[0].length : inside[inside.length - 1].length), + match: match} + } + } + } + + function lastMatchIn(string, regexp, endMargin) { + var match, from = 0 + while (from <= string.length) { + regexp.lastIndex = from + var newMatch = regexp.exec(string) + if (!newMatch) break + var end = newMatch.index + newMatch[0].length + if (end > string.length - endMargin) break + if (!match || end > match.index + match[0].length) + match = newMatch + from = newMatch.index + 1 + } + return match + } + + function searchRegexpBackward(doc, regexp, start) { + regexp = ensureFlags(regexp, "g") + for (var line = start.line, ch = start.ch, first = doc.firstLine(); line >= first; line--, ch = -1) { + var string = doc.getLine(line) + var match = lastMatchIn(string, regexp, ch < 0 ? 0 : string.length - ch) + if (match) + return {from: Pos(line, match.index), + to: Pos(line, match.index + match[0].length), + match: match} + } + } + + function searchRegexpBackwardMultiline(doc, regexp, start) { + if (!maybeMultiline(regexp)) return searchRegexpBackward(doc, regexp, start) + regexp = ensureFlags(regexp, "gm") + var string, chunkSize = 1, endMargin = doc.getLine(start.line).length - start.ch + for (var line = start.line, first = doc.firstLine(); line >= first;) { + for (var i = 0; i < chunkSize && line >= first; i++) { + var curLine = doc.getLine(line--) + string = string == null ? curLine : curLine + "\n" + string + } + chunkSize *= 2 + + var match = lastMatchIn(string, regexp, endMargin) + if (match) { + var before = string.slice(0, match.index).split("\n"), inside = match[0].split("\n") + var startLine = line + before.length, startCh = before[before.length - 1].length + return {from: Pos(startLine, startCh), + to: Pos(startLine + inside.length - 1, + inside.length == 1 ? startCh + inside[0].length : inside[inside.length - 1].length), + match: match} + } + } + } + + var doFold, noFold + if (String.prototype.normalize) { + doFold = function(str) { return str.normalize("NFD").toLowerCase() } + noFold = function(str) { return str.normalize("NFD") } + } else { + doFold = function(str) { return str.toLowerCase() } + noFold = function(str) { return str } + } + + // Maps a position in a case-folded line back to a position in the original line + // (compensating for codepoints increasing in number during folding) + function adjustPos(orig, folded, pos, foldFunc) { + if (orig.length == folded.length) return pos + for (var min = 0, max = pos + Math.max(0, orig.length - folded.length);;) { + if (min == max) return min + var mid = (min + max) >> 1 + var len = foldFunc(orig.slice(0, mid)).length + if (len == pos) return mid + else if (len > pos) max = mid + else min = mid + 1 + } + } + + function searchStringForward(doc, query, start, caseFold) { + // Empty string would match anything and never progress, so we + // define it to match nothing instead. + if (!query.length) return null + var fold = caseFold ? doFold : noFold + var lines = fold(query).split(/\r|\n\r?/) + + search: for (var line = start.line, ch = start.ch, last = doc.lastLine() + 1 - lines.length; line <= last; line++, ch = 0) { + var orig = doc.getLine(line).slice(ch), string = fold(orig) + if (lines.length == 1) { + var found = string.indexOf(lines[0]) + if (found == -1) continue search + var start = adjustPos(orig, string, found, fold) + ch + return {from: Pos(line, adjustPos(orig, string, found, fold) + ch), + to: Pos(line, adjustPos(orig, string, found + lines[0].length, fold) + ch)} + } else { + var cutFrom = string.length - lines[0].length + if (string.slice(cutFrom) != lines[0]) continue search + for (var i = 1; i < lines.length - 1; i++) + if (fold(doc.getLine(line + i)) != lines[i]) continue search + var end = doc.getLine(line + lines.length - 1), endString = fold(end), lastLine = lines[lines.length - 1] + if (endString.slice(0, lastLine.length) != lastLine) continue search + return {from: Pos(line, adjustPos(orig, string, cutFrom, fold) + ch), + to: Pos(line + lines.length - 1, adjustPos(end, endString, lastLine.length, fold))} + } + } + } + + function searchStringBackward(doc, query, start, caseFold) { + if (!query.length) return null + var fold = caseFold ? doFold : noFold + var lines = fold(query).split(/\r|\n\r?/) + + search: for (var line = start.line, ch = start.ch, first = doc.firstLine() - 1 + lines.length; line >= first; line--, ch = -1) { + var orig = doc.getLine(line) + if (ch > -1) orig = orig.slice(0, ch) + var string = fold(orig) + if (lines.length == 1) { + var found = string.lastIndexOf(lines[0]) + if (found == -1) continue search + return {from: Pos(line, adjustPos(orig, string, found, fold)), + to: Pos(line, adjustPos(orig, string, found + lines[0].length, fold))} + } else { + var lastLine = lines[lines.length - 1] + if (string.slice(0, lastLine.length) != lastLine) continue search + for (var i = 1, start = line - lines.length + 1; i < lines.length - 1; i++) + if (fold(doc.getLine(start + i)) != lines[i]) continue search + var top = doc.getLine(line + 1 - lines.length), topString = fold(top) + if (topString.slice(topString.length - lines[0].length) != lines[0]) continue search + return {from: Pos(line + 1 - lines.length, adjustPos(top, topString, top.length - lines[0].length, fold)), + to: Pos(line, adjustPos(orig, string, lastLine.length, fold))} + } + } + } + + function SearchCursor(doc, query, pos, options) { + this.atOccurrence = false + this.doc = doc + pos = pos ? doc.clipPos(pos) : Pos(0, 0) + this.pos = {from: pos, to: pos} + + var caseFold + if (typeof options == "object") { + caseFold = options.caseFold + } else { // Backwards compat for when caseFold was the 4th argument + caseFold = options + options = null + } + + if (typeof query == "string") { + if (caseFold == null) caseFold = false + this.matches = function(reverse, pos) { + return (reverse ? searchStringBackward : searchStringForward)(doc, query, pos, caseFold) + } + } else { + query = ensureFlags(query, "gm") + if (!options || options.multiline !== false) + this.matches = function(reverse, pos) { + return (reverse ? searchRegexpBackwardMultiline : searchRegexpForwardMultiline)(doc, query, pos) + } + else + this.matches = function(reverse, pos) { + return (reverse ? searchRegexpBackward : searchRegexpForward)(doc, query, pos) + } + } + } + + SearchCursor.prototype = { + findNext: function() {return this.find(false)}, + findPrevious: function() {return this.find(true)}, + + find: function(reverse) { + var result = this.matches(reverse, this.doc.clipPos(reverse ? this.pos.from : this.pos.to)) + + // Implements weird auto-growing behavior on null-matches for + // backwards-compatiblity with the vim code (unfortunately) + while (result && CodeMirror.cmpPos(result.from, result.to) == 0) { + if (reverse) { + if (result.from.ch) result.from = Pos(result.from.line, result.from.ch - 1) + else if (result.from.line == this.doc.firstLine()) result = null + else result = this.matches(reverse, this.doc.clipPos(Pos(result.from.line - 1))) + } else { + if (result.to.ch < this.doc.getLine(result.to.line).length) result.to = Pos(result.to.line, result.to.ch + 1) + else if (result.to.line == this.doc.lastLine()) result = null + else result = this.matches(reverse, Pos(result.to.line + 1, 0)) + } + } + + if (result) { + this.pos = result + this.atOccurrence = true + return this.pos.match || true + } else { + var end = Pos(reverse ? this.doc.firstLine() : this.doc.lastLine() + 1, 0) + this.pos = {from: end, to: end} + return this.atOccurrence = false + } + }, + + from: function() {if (this.atOccurrence) return this.pos.from}, + to: function() {if (this.atOccurrence) return this.pos.to}, + + replace: function(newText, origin) { + if (!this.atOccurrence) return + var lines = CodeMirror.splitLines(newText) + this.doc.replaceRange(lines, this.pos.from, this.pos.to, origin) + this.pos.to = Pos(this.pos.from.line + lines.length - 1, + lines[lines.length - 1].length + (lines.length == 1 ? this.pos.from.ch : 0)) + } + } + + CodeMirror.defineExtension("getSearchCursor", function(query, pos, caseFold) { + return new SearchCursor(this.doc, query, pos, caseFold) + }) + CodeMirror.defineDocExtension("getSearchCursor", function(query, pos, caseFold) { + return new SearchCursor(this, query, pos, caseFold) + }) + + CodeMirror.defineExtension("selectMatches", function(query, caseFold) { + var ranges = [] + var cur = this.getSearchCursor(query, this.getCursor("from"), caseFold) + while (cur.findNext()) { + if (CodeMirror.cmpPos(cur.to(), this.getCursor("to")) > 0) break + ranges.push({anchor: cur.from(), head: cur.to()}) + } + if (ranges.length) + this.setSelections(ranges, 0) + }) +}); diff --git a/adminx/assets/vendor/codemirror/addon/selection/active-line.js b/adminx/assets/vendor/codemirror/addon/selection/active-line.js new file mode 100644 index 0000000..c7b14ce --- /dev/null +++ b/adminx/assets/vendor/codemirror/addon/selection/active-line.js @@ -0,0 +1,72 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { + "use strict"; + var WRAP_CLASS = "CodeMirror-activeline"; + var BACK_CLASS = "CodeMirror-activeline-background"; + var GUTT_CLASS = "CodeMirror-activeline-gutter"; + + CodeMirror.defineOption("styleActiveLine", false, function(cm, val, old) { + var prev = old == CodeMirror.Init ? false : old; + if (val == prev) return + if (prev) { + cm.off("beforeSelectionChange", selectionChange); + clearActiveLines(cm); + delete cm.state.activeLines; + } + if (val) { + cm.state.activeLines = []; + updateActiveLines(cm, cm.listSelections()); + cm.on("beforeSelectionChange", selectionChange); + } + }); + + function clearActiveLines(cm) { + for (var i = 0; i < cm.state.activeLines.length; i++) { + cm.removeLineClass(cm.state.activeLines[i], "wrap", WRAP_CLASS); + cm.removeLineClass(cm.state.activeLines[i], "background", BACK_CLASS); + cm.removeLineClass(cm.state.activeLines[i], "gutter", GUTT_CLASS); + } + } + + function sameArray(a, b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) + if (a[i] != b[i]) return false; + return true; + } + + function updateActiveLines(cm, ranges) { + var active = []; + for (var i = 0; i < ranges.length; i++) { + var range = ranges[i]; + var option = cm.getOption("styleActiveLine"); + if (typeof option == "object" && option.nonEmpty ? range.anchor.line != range.head.line : !range.empty()) + continue + var line = cm.getLineHandleVisualStart(range.head.line); + if (active[active.length - 1] != line) active.push(line); + } + if (sameArray(cm.state.activeLines, active)) return; + cm.operation(function() { + clearActiveLines(cm); + for (var i = 0; i < active.length; i++) { + cm.addLineClass(active[i], "wrap", WRAP_CLASS); + cm.addLineClass(active[i], "background", BACK_CLASS); + cm.addLineClass(active[i], "gutter", GUTT_CLASS); + } + cm.state.activeLines = active; + }); + } + + function selectionChange(cm, sel) { + updateActiveLines(cm, sel.ranges); + } +}); diff --git a/adminx/assets/vendor/codemirror/index.php b/adminx/assets/vendor/codemirror/index.php new file mode 100644 index 0000000..ed2dcab --- /dev/null +++ b/adminx/assets/vendor/codemirror/index.php @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/adminx/assets/vendor/codemirror/lib/codemirror.css b/adminx/assets/vendor/codemirror/lib/codemirror.css new file mode 100644 index 0000000..bc910fb --- /dev/null +++ b/adminx/assets/vendor/codemirror/lib/codemirror.css @@ -0,0 +1,349 @@ +/* BASICS */ + +.CodeMirror { + /* Set height, width, borders, and global font properties here */ + font-family: monospace; + height: 300px; + color: black; + direction: ltr; +} + +/* PADDING */ + +.CodeMirror-lines { + padding: 4px 0; /* Vertical padding around content */ +} +.CodeMirror pre.CodeMirror-line, +.CodeMirror pre.CodeMirror-line-like { + padding: 0 4px; /* Horizontal padding of content */ +} + +.CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { + background-color: white; /* The little square between H and V scrollbars */ +} + +/* GUTTER */ + +.CodeMirror-gutters { + border-right: 1px solid #ddd; + background-color: #f7f7f7; + white-space: nowrap; +} +.CodeMirror-linenumbers {} +.CodeMirror-linenumber { + padding: 0 3px 0 5px; + min-width: 20px; + text-align: right; + color: #999; + white-space: nowrap; +} + +.CodeMirror-guttermarker { color: black; } +.CodeMirror-guttermarker-subtle { color: #999; } + +/* CURSOR */ + +.CodeMirror-cursor { + border-left: 1px solid black; + border-right: none; + width: 0; +} +/* Shown when moving in bi-directional text */ +.CodeMirror div.CodeMirror-secondarycursor { + border-left: 1px solid silver; +} +.cm-fat-cursor .CodeMirror-cursor { + width: auto; + border: 0 !important; + background: #7e7; +} +.cm-fat-cursor div.CodeMirror-cursors { + z-index: 1; +} +.cm-fat-cursor-mark { + background-color: rgba(20, 255, 20, 0.5); + -webkit-animation: blink 1.06s steps(1) infinite; + -moz-animation: blink 1.06s steps(1) infinite; + animation: blink 1.06s steps(1) infinite; +} +.cm-animate-fat-cursor { + width: auto; + border: 0; + -webkit-animation: blink 1.06s steps(1) infinite; + -moz-animation: blink 1.06s steps(1) infinite; + animation: blink 1.06s steps(1) infinite; + background-color: #7e7; +} +@-moz-keyframes blink { + 0% {} + 50% { background-color: transparent; } + 100% {} +} +@-webkit-keyframes blink { + 0% {} + 50% { background-color: transparent; } + 100% {} +} +@keyframes blink { + 0% {} + 50% { background-color: transparent; } + 100% {} +} + +/* Can style cursor different in overwrite (non-insert) mode */ +.CodeMirror-overwrite .CodeMirror-cursor {} + +.cm-tab { display: inline-block; text-decoration: inherit; } + +.CodeMirror-rulers { + position: absolute; + left: 0; right: 0; top: -50px; bottom: 0; + overflow: hidden; +} +.CodeMirror-ruler { + border-left: 1px solid #ccc; + top: 0; bottom: 0; + position: absolute; +} + +/* DEFAULT THEME */ + +.cm-s-default .cm-header {color: blue;} +.cm-s-default .cm-quote {color: #090;} +.cm-negative {color: #d44;} +.cm-positive {color: #292;} +.cm-header, .cm-strong {font-weight: bold;} +.cm-em {font-style: italic;} +.cm-link {text-decoration: underline;} +.cm-strikethrough {text-decoration: line-through;} + +.cm-s-default .cm-keyword {color: #708;} +.cm-s-default .cm-atom {color: #219;} +.cm-s-default .cm-number {color: #164;} +.cm-s-default .cm-def {color: #00f;} +.cm-s-default .cm-variable, +.cm-s-default .cm-punctuation, +.cm-s-default .cm-property, +.cm-s-default .cm-operator {} +.cm-s-default .cm-variable-2 {color: #05a;} +.cm-s-default .cm-variable-3, .cm-s-default .cm-type {color: #085;} +.cm-s-default .cm-comment {color: #a50;} +.cm-s-default .cm-string {color: #a11;} +.cm-s-default .cm-string-2 {color: #f50;} +.cm-s-default .cm-meta {color: #555;} +.cm-s-default .cm-qualifier {color: #555;} +.cm-s-default .cm-builtin {color: #30a;} +.cm-s-default .cm-bracket {color: #997;} +.cm-s-default .cm-tag {color: #170;} +.cm-s-default .cm-attribute {color: #00c;} +.cm-s-default .cm-hr {color: #999;} +.cm-s-default .cm-link {color: #00c;} + +.cm-s-default .cm-error {color: #f00;} +.cm-invalidchar {color: #f00;} + +.CodeMirror-composing { border-bottom: 2px solid; } + +/* Default styles for common addons */ + +div.CodeMirror span.CodeMirror-matchingbracket {color: #0b0;} +div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #a22;} +.CodeMirror-matchingtag { background: rgba(255, 150, 0, .3); } +.CodeMirror-activeline-background {background: #e8f2ff;} + +/* STOP */ + +/* The rest of this file contains styles related to the mechanics of + the editor. You probably shouldn't touch them. */ + +.CodeMirror { + position: relative; + overflow: hidden; + background: white; +} + +.CodeMirror-scroll { + overflow: scroll !important; /* Things will break if this is overridden */ + /* 30px is the magic margin used to hide the element's real scrollbars */ + /* See overflow: hidden in .CodeMirror */ + margin-bottom: -30px; margin-right: -30px; + padding-bottom: 30px; + height: 100%; + outline: none; /* Prevent dragging from highlighting the element */ + position: relative; +} +.CodeMirror-sizer { + position: relative; + border-right: 30px solid transparent; +} + +/* The fake, visible scrollbars. Used to force redraw during scrolling + before actual scrolling happens, thus preventing shaking and + flickering artifacts. */ +.CodeMirror-vscrollbar, .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { + position: absolute; + z-index: 6; + display: none; +} +.CodeMirror-vscrollbar { + right: 0; top: 0; + overflow-x: hidden; + overflow-y: scroll; +} +.CodeMirror-hscrollbar { + bottom: 0; left: 0; + overflow-y: hidden; + overflow-x: scroll; +} +.CodeMirror-scrollbar-filler { + right: 0; bottom: 0; +} +.CodeMirror-gutter-filler { + left: 0; bottom: 0; +} + +.CodeMirror-gutters { + position: absolute; left: 0; top: 0; + min-height: 100%; + z-index: 3; +} +.CodeMirror-gutter { + white-space: normal; + height: 100%; + display: inline-block; + vertical-align: top; + margin-bottom: -30px; +} +.CodeMirror-gutter-wrapper { + position: absolute; + z-index: 4; + background: none !important; + border: none !important; +} +.CodeMirror-gutter-background { + position: absolute; + top: 0; bottom: 0; + z-index: 4; +} +.CodeMirror-gutter-elt { + position: absolute; + cursor: default; + z-index: 4; +} +.CodeMirror-gutter-wrapper ::selection { background-color: transparent } +.CodeMirror-gutter-wrapper ::-moz-selection { background-color: transparent } + +.CodeMirror-lines { + cursor: text; + min-height: 1px; /* prevents collapsing before first draw */ +} +.CodeMirror pre.CodeMirror-line, +.CodeMirror pre.CodeMirror-line-like { + /* Reset some styles that the rest of the page might have set */ + -moz-border-radius: 0; -webkit-border-radius: 0; border-radius: 0; + border-width: 0; + background: transparent; + font-family: inherit; + font-size: inherit; + margin: 0; + white-space: pre; + word-wrap: normal; + line-height: inherit; + color: inherit; + z-index: 2; + position: relative; + overflow: visible; + -webkit-tap-highlight-color: transparent; + -webkit-font-variant-ligatures: contextual; + font-variant-ligatures: contextual; +} +.CodeMirror-wrap pre.CodeMirror-line, +.CodeMirror-wrap pre.CodeMirror-line-like { + word-wrap: break-word; + white-space: pre-wrap; + word-break: normal; +} + +.CodeMirror-linebackground { + position: absolute; + left: 0; right: 0; top: 0; bottom: 0; + z-index: 0; +} + +.CodeMirror-linewidget { + position: relative; + z-index: 2; + padding: 0.1px; /* Force widget margins to stay inside of the container */ +} + +.CodeMirror-widget {} + +.CodeMirror-rtl pre { direction: rtl; } + +.CodeMirror-code { + outline: none; +} + +/* Force content-box sizing for the elements where we expect it */ +.CodeMirror-scroll, +.CodeMirror-sizer, +.CodeMirror-gutter, +.CodeMirror-gutters, +.CodeMirror-linenumber { + -moz-box-sizing: content-box; + box-sizing: content-box; +} + +.CodeMirror-measure { + position: absolute; + width: 100%; + height: 0; + overflow: hidden; + visibility: hidden; +} + +.CodeMirror-cursor { + position: absolute; + pointer-events: none; +} +.CodeMirror-measure pre { position: static; } + +div.CodeMirror-cursors { + visibility: hidden; + position: relative; + z-index: 3; +} +div.CodeMirror-dragcursors { + visibility: visible; +} + +.CodeMirror-focused div.CodeMirror-cursors { + visibility: visible; +} + +.CodeMirror-selected { background: #d9d9d9; } +.CodeMirror-focused .CodeMirror-selected { background: #d7d4f0; } +.CodeMirror-crosshair { cursor: crosshair; } +.CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection { background: #d7d4f0; } +.CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection { background: #d7d4f0; } + +.cm-searching { + background-color: #ffa; + background-color: rgba(255, 255, 0, .4); +} + +/* Used to force a border model for a node */ +.cm-force-border { padding-right: .1px; } + +@media print { + /* Hide the cursor when printing */ + .CodeMirror div.CodeMirror-cursors { + visibility: hidden; + } +} + +/* See issue #2901 */ +.cm-tab-wrap-hack:after { content: ''; } + +/* Help users use markselection to safely style text background */ +span.CodeMirror-selectedtext { background: none; } diff --git a/adminx/assets/vendor/codemirror/lib/codemirror.js b/adminx/assets/vendor/codemirror/lib/codemirror.js new file mode 100644 index 0000000..72267f6 --- /dev/null +++ b/adminx/assets/vendor/codemirror/lib/codemirror.js @@ -0,0 +1,9806 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +// This is CodeMirror (https://codemirror.net), a code editor +// implemented in JavaScript on top of the browser's DOM. +// +// You can find some technical background for some of the code below +// at http://marijnhaverbeke.nl/blog/#cm-internals . + +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global.CodeMirror = factory()); +}(this, (function () { 'use strict'; + + // Kludges for bugs and behavior differences that can't be feature + // detected are enabled based on userAgent etc sniffing. + var userAgent = navigator.userAgent; + var platform = navigator.platform; + + var gecko = /gecko\/\d/i.test(userAgent); + var ie_upto10 = /MSIE \d/.test(userAgent); + var ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(userAgent); + var edge = /Edge\/(\d+)/.exec(userAgent); + var ie = ie_upto10 || ie_11up || edge; + var ie_version = ie && (ie_upto10 ? document.documentMode || 6 : +(edge || ie_11up)[1]); + var webkit = !edge && /WebKit\//.test(userAgent); + var qtwebkit = webkit && /Qt\/\d+\.\d+/.test(userAgent); + var chrome = !edge && /Chrome\//.test(userAgent); + var presto = /Opera\//.test(userAgent); + var safari = /Apple Computer/.test(navigator.vendor); + var mac_geMountainLion = /Mac OS X 1\d\D([8-9]|\d\d)\D/.test(userAgent); + var phantom = /PhantomJS/.test(userAgent); + + var ios = !edge && /AppleWebKit/.test(userAgent) && /Mobile\/\w+/.test(userAgent); + var android = /Android/.test(userAgent); + // This is woefully incomplete. Suggestions for alternative methods welcome. + var mobile = ios || android || /webOS|BlackBerry|Opera Mini|Opera Mobi|IEMobile/i.test(userAgent); + var mac = ios || /Mac/.test(platform); + var chromeOS = /\bCrOS\b/.test(userAgent); + var windows = /win/i.test(platform); + + var presto_version = presto && userAgent.match(/Version\/(\d*\.\d*)/); + if (presto_version) { presto_version = Number(presto_version[1]); } + if (presto_version && presto_version >= 15) { presto = false; webkit = true; } + // Some browsers use the wrong event properties to signal cmd/ctrl on OS X + var flipCtrlCmd = mac && (qtwebkit || presto && (presto_version == null || presto_version < 12.11)); + var captureRightClick = gecko || (ie && ie_version >= 9); + + function classTest(cls) { return new RegExp("(^|\\s)" + cls + "(?:$|\\s)\\s*") } + + var rmClass = function(node, cls) { + var current = node.className; + var match = classTest(cls).exec(current); + if (match) { + var after = current.slice(match.index + match[0].length); + node.className = current.slice(0, match.index) + (after ? match[1] + after : ""); + } + }; + + function removeChildren(e) { + for (var count = e.childNodes.length; count > 0; --count) + { e.removeChild(e.firstChild); } + return e + } + + function removeChildrenAndAdd(parent, e) { + return removeChildren(parent).appendChild(e) + } + + function elt(tag, content, className, style) { + var e = document.createElement(tag); + if (className) { e.className = className; } + if (style) { e.style.cssText = style; } + if (typeof content == "string") { e.appendChild(document.createTextNode(content)); } + else if (content) { for (var i = 0; i < content.length; ++i) { e.appendChild(content[i]); } } + return e + } + // wrapper for elt, which removes the elt from the accessibility tree + function eltP(tag, content, className, style) { + var e = elt(tag, content, className, style); + e.setAttribute("role", "presentation"); + return e + } + + var range; + if (document.createRange) { range = function(node, start, end, endNode) { + var r = document.createRange(); + r.setEnd(endNode || node, end); + r.setStart(node, start); + return r + }; } + else { range = function(node, start, end) { + var r = document.body.createTextRange(); + try { r.moveToElementText(node.parentNode); } + catch(e) { return r } + r.collapse(true); + r.moveEnd("character", end); + r.moveStart("character", start); + return r + }; } + + function contains(parent, child) { + if (child.nodeType == 3) // Android browser always returns false when child is a textnode + { child = child.parentNode; } + if (parent.contains) + { return parent.contains(child) } + do { + if (child.nodeType == 11) { child = child.host; } + if (child == parent) { return true } + } while (child = child.parentNode) + } + + function activeElt() { + // IE and Edge may throw an "Unspecified Error" when accessing document.activeElement. + // IE < 10 will throw when accessed while the page is loading or in an iframe. + // IE > 9 and Edge will throw when accessed in an iframe if document.body is unavailable. + var activeElement; + try { + activeElement = document.activeElement; + } catch(e) { + activeElement = document.body || null; + } + while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement) + { activeElement = activeElement.shadowRoot.activeElement; } + return activeElement + } + + function addClass(node, cls) { + var current = node.className; + if (!classTest(cls).test(current)) { node.className += (current ? " " : "") + cls; } + } + function joinClasses(a, b) { + var as = a.split(" "); + for (var i = 0; i < as.length; i++) + { if (as[i] && !classTest(as[i]).test(b)) { b += " " + as[i]; } } + return b + } + + var selectInput = function(node) { node.select(); }; + if (ios) // Mobile Safari apparently has a bug where select() is broken. + { selectInput = function(node) { node.selectionStart = 0; node.selectionEnd = node.value.length; }; } + else if (ie) // Suppress mysterious IE10 errors + { selectInput = function(node) { try { node.select(); } catch(_e) {} }; } + + function bind(f) { + var args = Array.prototype.slice.call(arguments, 1); + return function(){return f.apply(null, args)} + } + + function copyObj(obj, target, overwrite) { + if (!target) { target = {}; } + for (var prop in obj) + { if (obj.hasOwnProperty(prop) && (overwrite !== false || !target.hasOwnProperty(prop))) + { target[prop] = obj[prop]; } } + return target + } + + // Counts the column offset in a string, taking tabs into account. + // Used mostly to find indentation. + function countColumn(string, end, tabSize, startIndex, startValue) { + if (end == null) { + end = string.search(/[^\s\u00a0]/); + if (end == -1) { end = string.length; } + } + for (var i = startIndex || 0, n = startValue || 0;;) { + var nextTab = string.indexOf("\t", i); + if (nextTab < 0 || nextTab >= end) + { return n + (end - i) } + n += nextTab - i; + n += tabSize - (n % tabSize); + i = nextTab + 1; + } + } + + var Delayed = function() { + this.id = null; + this.f = null; + this.time = 0; + this.handler = bind(this.onTimeout, this); + }; + Delayed.prototype.onTimeout = function (self) { + self.id = 0; + if (self.time <= +new Date) { + self.f(); + } else { + setTimeout(self.handler, self.time - +new Date); + } + }; + Delayed.prototype.set = function (ms, f) { + this.f = f; + var time = +new Date + ms; + if (!this.id || time < this.time) { + clearTimeout(this.id); + this.id = setTimeout(this.handler, ms); + this.time = time; + } + }; + + function indexOf(array, elt) { + for (var i = 0; i < array.length; ++i) + { if (array[i] == elt) { return i } } + return -1 + } + + // Number of pixels added to scroller and sizer to hide scrollbar + var scrollerGap = 30; + + // Returned or thrown by various protocols to signal 'I'm not + // handling this'. + var Pass = {toString: function(){return "CodeMirror.Pass"}}; + + // Reused option objects for setSelection & friends + var sel_dontScroll = {scroll: false}, sel_mouse = {origin: "*mouse"}, sel_move = {origin: "+move"}; + + // The inverse of countColumn -- find the offset that corresponds to + // a particular column. + function findColumn(string, goal, tabSize) { + for (var pos = 0, col = 0;;) { + var nextTab = string.indexOf("\t", pos); + if (nextTab == -1) { nextTab = string.length; } + var skipped = nextTab - pos; + if (nextTab == string.length || col + skipped >= goal) + { return pos + Math.min(skipped, goal - col) } + col += nextTab - pos; + col += tabSize - (col % tabSize); + pos = nextTab + 1; + if (col >= goal) { return pos } + } + } + + var spaceStrs = [""]; + function spaceStr(n) { + while (spaceStrs.length <= n) + { spaceStrs.push(lst(spaceStrs) + " "); } + return spaceStrs[n] + } + + function lst(arr) { return arr[arr.length-1] } + + function map(array, f) { + var out = []; + for (var i = 0; i < array.length; i++) { out[i] = f(array[i], i); } + return out + } + + function insertSorted(array, value, score) { + var pos = 0, priority = score(value); + while (pos < array.length && score(array[pos]) <= priority) { pos++; } + array.splice(pos, 0, value); + } + + function nothing() {} + + function createObj(base, props) { + var inst; + if (Object.create) { + inst = Object.create(base); + } else { + nothing.prototype = base; + inst = new nothing(); + } + if (props) { copyObj(props, inst); } + return inst + } + + var nonASCIISingleCaseWordChar = /[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/; + function isWordCharBasic(ch) { + return /\w/.test(ch) || ch > "\x80" && + (ch.toUpperCase() != ch.toLowerCase() || nonASCIISingleCaseWordChar.test(ch)) + } + function isWordChar(ch, helper) { + if (!helper) { return isWordCharBasic(ch) } + if (helper.source.indexOf("\\w") > -1 && isWordCharBasic(ch)) { return true } + return helper.test(ch) + } + + function isEmpty(obj) { + for (var n in obj) { if (obj.hasOwnProperty(n) && obj[n]) { return false } } + return true + } + + // Extending unicode characters. A series of a non-extending char + + // any number of extending chars is treated as a single unit as far + // as editing and measuring is concerned. This is not fully correct, + // since some scripts/fonts/browsers also treat other configurations + // of code points as a group. + var extendingChars = /[\u0300-\u036f\u0483-\u0489\u0591-\u05bd\u05bf\u05c1\u05c2\u05c4\u05c5\u05c7\u0610-\u061a\u064b-\u065e\u0670\u06d6-\u06dc\u06de-\u06e4\u06e7\u06e8\u06ea-\u06ed\u0711\u0730-\u074a\u07a6-\u07b0\u07eb-\u07f3\u0816-\u0819\u081b-\u0823\u0825-\u0827\u0829-\u082d\u0900-\u0902\u093c\u0941-\u0948\u094d\u0951-\u0955\u0962\u0963\u0981\u09bc\u09be\u09c1-\u09c4\u09cd\u09d7\u09e2\u09e3\u0a01\u0a02\u0a3c\u0a41\u0a42\u0a47\u0a48\u0a4b-\u0a4d\u0a51\u0a70\u0a71\u0a75\u0a81\u0a82\u0abc\u0ac1-\u0ac5\u0ac7\u0ac8\u0acd\u0ae2\u0ae3\u0b01\u0b3c\u0b3e\u0b3f\u0b41-\u0b44\u0b4d\u0b56\u0b57\u0b62\u0b63\u0b82\u0bbe\u0bc0\u0bcd\u0bd7\u0c3e-\u0c40\u0c46-\u0c48\u0c4a-\u0c4d\u0c55\u0c56\u0c62\u0c63\u0cbc\u0cbf\u0cc2\u0cc6\u0ccc\u0ccd\u0cd5\u0cd6\u0ce2\u0ce3\u0d3e\u0d41-\u0d44\u0d4d\u0d57\u0d62\u0d63\u0dca\u0dcf\u0dd2-\u0dd4\u0dd6\u0ddf\u0e31\u0e34-\u0e3a\u0e47-\u0e4e\u0eb1\u0eb4-\u0eb9\u0ebb\u0ebc\u0ec8-\u0ecd\u0f18\u0f19\u0f35\u0f37\u0f39\u0f71-\u0f7e\u0f80-\u0f84\u0f86\u0f87\u0f90-\u0f97\u0f99-\u0fbc\u0fc6\u102d-\u1030\u1032-\u1037\u1039\u103a\u103d\u103e\u1058\u1059\u105e-\u1060\u1071-\u1074\u1082\u1085\u1086\u108d\u109d\u135f\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17b7-\u17bd\u17c6\u17c9-\u17d3\u17dd\u180b-\u180d\u18a9\u1920-\u1922\u1927\u1928\u1932\u1939-\u193b\u1a17\u1a18\u1a56\u1a58-\u1a5e\u1a60\u1a62\u1a65-\u1a6c\u1a73-\u1a7c\u1a7f\u1b00-\u1b03\u1b34\u1b36-\u1b3a\u1b3c\u1b42\u1b6b-\u1b73\u1b80\u1b81\u1ba2-\u1ba5\u1ba8\u1ba9\u1c2c-\u1c33\u1c36\u1c37\u1cd0-\u1cd2\u1cd4-\u1ce0\u1ce2-\u1ce8\u1ced\u1dc0-\u1de6\u1dfd-\u1dff\u200c\u200d\u20d0-\u20f0\u2cef-\u2cf1\u2de0-\u2dff\u302a-\u302f\u3099\u309a\ua66f-\ua672\ua67c\ua67d\ua6f0\ua6f1\ua802\ua806\ua80b\ua825\ua826\ua8c4\ua8e0-\ua8f1\ua926-\ua92d\ua947-\ua951\ua980-\ua982\ua9b3\ua9b6-\ua9b9\ua9bc\uaa29-\uaa2e\uaa31\uaa32\uaa35\uaa36\uaa43\uaa4c\uaab0\uaab2-\uaab4\uaab7\uaab8\uaabe\uaabf\uaac1\uabe5\uabe8\uabed\udc00-\udfff\ufb1e\ufe00-\ufe0f\ufe20-\ufe26\uff9e\uff9f]/; + function isExtendingChar(ch) { return ch.charCodeAt(0) >= 768 && extendingChars.test(ch) } + + // Returns a number from the range [`0`; `str.length`] unless `pos` is outside that range. + function skipExtendingChars(str, pos, dir) { + while ((dir < 0 ? pos > 0 : pos < str.length) && isExtendingChar(str.charAt(pos))) { pos += dir; } + return pos + } + + // Returns the value from the range [`from`; `to`] that satisfies + // `pred` and is closest to `from`. Assumes that at least `to` + // satisfies `pred`. Supports `from` being greater than `to`. + function findFirst(pred, from, to) { + // At any point we are certain `to` satisfies `pred`, don't know + // whether `from` does. + var dir = from > to ? -1 : 1; + for (;;) { + if (from == to) { return from } + var midF = (from + to) / 2, mid = dir < 0 ? Math.ceil(midF) : Math.floor(midF); + if (mid == from) { return pred(mid) ? from : to } + if (pred(mid)) { to = mid; } + else { from = mid + dir; } + } + } + + // BIDI HELPERS + + function iterateBidiSections(order, from, to, f) { + if (!order) { return f(from, to, "ltr", 0) } + var found = false; + for (var i = 0; i < order.length; ++i) { + var part = order[i]; + if (part.from < to && part.to > from || from == to && part.to == from) { + f(Math.max(part.from, from), Math.min(part.to, to), part.level == 1 ? "rtl" : "ltr", i); + found = true; + } + } + if (!found) { f(from, to, "ltr"); } + } + + var bidiOther = null; + function getBidiPartAt(order, ch, sticky) { + var found; + bidiOther = null; + for (var i = 0; i < order.length; ++i) { + var cur = order[i]; + if (cur.from < ch && cur.to > ch) { return i } + if (cur.to == ch) { + if (cur.from != cur.to && sticky == "before") { found = i; } + else { bidiOther = i; } + } + if (cur.from == ch) { + if (cur.from != cur.to && sticky != "before") { found = i; } + else { bidiOther = i; } + } + } + return found != null ? found : bidiOther + } + + // Bidirectional ordering algorithm + // See http://unicode.org/reports/tr9/tr9-13.html for the algorithm + // that this (partially) implements. + + // One-char codes used for character types: + // L (L): Left-to-Right + // R (R): Right-to-Left + // r (AL): Right-to-Left Arabic + // 1 (EN): European Number + // + (ES): European Number Separator + // % (ET): European Number Terminator + // n (AN): Arabic Number + // , (CS): Common Number Separator + // m (NSM): Non-Spacing Mark + // b (BN): Boundary Neutral + // s (B): Paragraph Separator + // t (S): Segment Separator + // w (WS): Whitespace + // N (ON): Other Neutrals + + // Returns null if characters are ordered as they appear + // (left-to-right), or an array of sections ({from, to, level} + // objects) in the order in which they occur visually. + var bidiOrdering = (function() { + // Character types for codepoints 0 to 0xff + var lowTypes = "bbbbbbbbbtstwsbbbbbbbbbbbbbbssstwNN%%%NNNNNN,N,N1111111111NNNNNNNLLLLLLLLLLLLLLLLLLLLLLLLLLNNNNNNLLLLLLLLLLLLLLLLLLLLLLLLLLNNNNbbbbbbsbbbbbbbbbbbbbbbbbbbbbbbbbb,N%%%%NNNNLNNNNN%%11NLNNN1LNNNNNLLLLLLLLLLLLLLLLLLLLLLLNLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLN"; + // Character types for codepoints 0x600 to 0x6f9 + var arabicTypes = "nnnnnnNNr%%r,rNNmmmmmmmmmmmrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrmmmmmmmmmmmmmmmmmmmmmnnnnnnnnnn%nnrrrmrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrmmmmmmmnNmmmmmmrrmmNmmmmrr1111111111"; + function charType(code) { + if (code <= 0xf7) { return lowTypes.charAt(code) } + else if (0x590 <= code && code <= 0x5f4) { return "R" } + else if (0x600 <= code && code <= 0x6f9) { return arabicTypes.charAt(code - 0x600) } + else if (0x6ee <= code && code <= 0x8ac) { return "r" } + else if (0x2000 <= code && code <= 0x200b) { return "w" } + else if (code == 0x200c) { return "b" } + else { return "L" } + } + + var bidiRE = /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac]/; + var isNeutral = /[stwN]/, isStrong = /[LRr]/, countsAsLeft = /[Lb1n]/, countsAsNum = /[1n]/; + + function BidiSpan(level, from, to) { + this.level = level; + this.from = from; this.to = to; + } + + return function(str, direction) { + var outerType = direction == "ltr" ? "L" : "R"; + + if (str.length == 0 || direction == "ltr" && !bidiRE.test(str)) { return false } + var len = str.length, types = []; + for (var i = 0; i < len; ++i) + { types.push(charType(str.charCodeAt(i))); } + + // W1. Examine each non-spacing mark (NSM) in the level run, and + // change the type of the NSM to the type of the previous + // character. If the NSM is at the start of the level run, it will + // get the type of sor. + for (var i$1 = 0, prev = outerType; i$1 < len; ++i$1) { + var type = types[i$1]; + if (type == "m") { types[i$1] = prev; } + else { prev = type; } + } + + // W2. Search backwards from each instance of a European number + // until the first strong type (R, L, AL, or sor) is found. If an + // AL is found, change the type of the European number to Arabic + // number. + // W3. Change all ALs to R. + for (var i$2 = 0, cur = outerType; i$2 < len; ++i$2) { + var type$1 = types[i$2]; + if (type$1 == "1" && cur == "r") { types[i$2] = "n"; } + else if (isStrong.test(type$1)) { cur = type$1; if (type$1 == "r") { types[i$2] = "R"; } } + } + + // W4. A single European separator between two European numbers + // changes to a European number. A single common separator between + // two numbers of the same type changes to that type. + for (var i$3 = 1, prev$1 = types[0]; i$3 < len - 1; ++i$3) { + var type$2 = types[i$3]; + if (type$2 == "+" && prev$1 == "1" && types[i$3+1] == "1") { types[i$3] = "1"; } + else if (type$2 == "," && prev$1 == types[i$3+1] && + (prev$1 == "1" || prev$1 == "n")) { types[i$3] = prev$1; } + prev$1 = type$2; + } + + // W5. A sequence of European terminators adjacent to European + // numbers changes to all European numbers. + // W6. Otherwise, separators and terminators change to Other + // Neutral. + for (var i$4 = 0; i$4 < len; ++i$4) { + var type$3 = types[i$4]; + if (type$3 == ",") { types[i$4] = "N"; } + else if (type$3 == "%") { + var end = (void 0); + for (end = i$4 + 1; end < len && types[end] == "%"; ++end) {} + var replace = (i$4 && types[i$4-1] == "!") || (end < len && types[end] == "1") ? "1" : "N"; + for (var j = i$4; j < end; ++j) { types[j] = replace; } + i$4 = end - 1; + } + } + + // W7. Search backwards from each instance of a European number + // until the first strong type (R, L, or sor) is found. If an L is + // found, then change the type of the European number to L. + for (var i$5 = 0, cur$1 = outerType; i$5 < len; ++i$5) { + var type$4 = types[i$5]; + if (cur$1 == "L" && type$4 == "1") { types[i$5] = "L"; } + else if (isStrong.test(type$4)) { cur$1 = type$4; } + } + + // N1. A sequence of neutrals takes the direction of the + // surrounding strong text if the text on both sides has the same + // direction. European and Arabic numbers act as if they were R in + // terms of their influence on neutrals. Start-of-level-run (sor) + // and end-of-level-run (eor) are used at level run boundaries. + // N2. Any remaining neutrals take the embedding direction. + for (var i$6 = 0; i$6 < len; ++i$6) { + if (isNeutral.test(types[i$6])) { + var end$1 = (void 0); + for (end$1 = i$6 + 1; end$1 < len && isNeutral.test(types[end$1]); ++end$1) {} + var before = (i$6 ? types[i$6-1] : outerType) == "L"; + var after = (end$1 < len ? types[end$1] : outerType) == "L"; + var replace$1 = before == after ? (before ? "L" : "R") : outerType; + for (var j$1 = i$6; j$1 < end$1; ++j$1) { types[j$1] = replace$1; } + i$6 = end$1 - 1; + } + } + + // Here we depart from the documented algorithm, in order to avoid + // building up an actual levels array. Since there are only three + // levels (0, 1, 2) in an implementation that doesn't take + // explicit embedding into account, we can build up the order on + // the fly, without following the level-based algorithm. + var order = [], m; + for (var i$7 = 0; i$7 < len;) { + if (countsAsLeft.test(types[i$7])) { + var start = i$7; + for (++i$7; i$7 < len && countsAsLeft.test(types[i$7]); ++i$7) {} + order.push(new BidiSpan(0, start, i$7)); + } else { + var pos = i$7, at = order.length; + for (++i$7; i$7 < len && types[i$7] != "L"; ++i$7) {} + for (var j$2 = pos; j$2 < i$7;) { + if (countsAsNum.test(types[j$2])) { + if (pos < j$2) { order.splice(at, 0, new BidiSpan(1, pos, j$2)); } + var nstart = j$2; + for (++j$2; j$2 < i$7 && countsAsNum.test(types[j$2]); ++j$2) {} + order.splice(at, 0, new BidiSpan(2, nstart, j$2)); + pos = j$2; + } else { ++j$2; } + } + if (pos < i$7) { order.splice(at, 0, new BidiSpan(1, pos, i$7)); } + } + } + if (direction == "ltr") { + if (order[0].level == 1 && (m = str.match(/^\s+/))) { + order[0].from = m[0].length; + order.unshift(new BidiSpan(0, 0, m[0].length)); + } + if (lst(order).level == 1 && (m = str.match(/\s+$/))) { + lst(order).to -= m[0].length; + order.push(new BidiSpan(0, len - m[0].length, len)); + } + } + + return direction == "rtl" ? order.reverse() : order + } + })(); + + // Get the bidi ordering for the given line (and cache it). Returns + // false for lines that are fully left-to-right, and an array of + // BidiSpan objects otherwise. + function getOrder(line, direction) { + var order = line.order; + if (order == null) { order = line.order = bidiOrdering(line.text, direction); } + return order + } + + // EVENT HANDLING + + // Lightweight event framework. on/off also work on DOM nodes, + // registering native DOM handlers. + + var noHandlers = []; + + var on = function(emitter, type, f) { + if (emitter.addEventListener) { + emitter.addEventListener(type, f, false); + } else if (emitter.attachEvent) { + emitter.attachEvent("on" + type, f); + } else { + var map$$1 = emitter._handlers || (emitter._handlers = {}); + map$$1[type] = (map$$1[type] || noHandlers).concat(f); + } + }; + + function getHandlers(emitter, type) { + return emitter._handlers && emitter._handlers[type] || noHandlers + } + + function off(emitter, type, f) { + if (emitter.removeEventListener) { + emitter.removeEventListener(type, f, false); + } else if (emitter.detachEvent) { + emitter.detachEvent("on" + type, f); + } else { + var map$$1 = emitter._handlers, arr = map$$1 && map$$1[type]; + if (arr) { + var index = indexOf(arr, f); + if (index > -1) + { map$$1[type] = arr.slice(0, index).concat(arr.slice(index + 1)); } + } + } + } + + function signal(emitter, type /*, values...*/) { + var handlers = getHandlers(emitter, type); + if (!handlers.length) { return } + var args = Array.prototype.slice.call(arguments, 2); + for (var i = 0; i < handlers.length; ++i) { handlers[i].apply(null, args); } + } + + // The DOM events that CodeMirror handles can be overridden by + // registering a (non-DOM) handler on the editor for the event name, + // and preventDefault-ing the event in that handler. + function signalDOMEvent(cm, e, override) { + if (typeof e == "string") + { e = {type: e, preventDefault: function() { this.defaultPrevented = true; }}; } + signal(cm, override || e.type, cm, e); + return e_defaultPrevented(e) || e.codemirrorIgnore + } + + function signalCursorActivity(cm) { + var arr = cm._handlers && cm._handlers.cursorActivity; + if (!arr) { return } + var set = cm.curOp.cursorActivityHandlers || (cm.curOp.cursorActivityHandlers = []); + for (var i = 0; i < arr.length; ++i) { if (indexOf(set, arr[i]) == -1) + { set.push(arr[i]); } } + } + + function hasHandler(emitter, type) { + return getHandlers(emitter, type).length > 0 + } + + // Add on and off methods to a constructor's prototype, to make + // registering events on such objects more convenient. + function eventMixin(ctor) { + ctor.prototype.on = function(type, f) {on(this, type, f);}; + ctor.prototype.off = function(type, f) {off(this, type, f);}; + } + + // Due to the fact that we still support jurassic IE versions, some + // compatibility wrappers are needed. + + function e_preventDefault(e) { + if (e.preventDefault) { e.preventDefault(); } + else { e.returnValue = false; } + } + function e_stopPropagation(e) { + if (e.stopPropagation) { e.stopPropagation(); } + else { e.cancelBubble = true; } + } + function e_defaultPrevented(e) { + return e.defaultPrevented != null ? e.defaultPrevented : e.returnValue == false + } + function e_stop(e) {e_preventDefault(e); e_stopPropagation(e);} + + function e_target(e) {return e.target || e.srcElement} + function e_button(e) { + var b = e.which; + if (b == null) { + if (e.button & 1) { b = 1; } + else if (e.button & 2) { b = 3; } + else if (e.button & 4) { b = 2; } + } + if (mac && e.ctrlKey && b == 1) { b = 3; } + return b + } + + // Detect drag-and-drop + var dragAndDrop = function() { + // There is *some* kind of drag-and-drop support in IE6-8, but I + // couldn't get it to work yet. + if (ie && ie_version < 9) { return false } + var div = elt('div'); + return "draggable" in div || "dragDrop" in div + }(); + + var zwspSupported; + function zeroWidthElement(measure) { + if (zwspSupported == null) { + var test = elt("span", "\u200b"); + removeChildrenAndAdd(measure, elt("span", [test, document.createTextNode("x")])); + if (measure.firstChild.offsetHeight != 0) + { zwspSupported = test.offsetWidth <= 1 && test.offsetHeight > 2 && !(ie && ie_version < 8); } + } + var node = zwspSupported ? elt("span", "\u200b") : + elt("span", "\u00a0", null, "display: inline-block; width: 1px; margin-right: -1px"); + node.setAttribute("cm-text", ""); + return node + } + + // Feature-detect IE's crummy client rect reporting for bidi text + var badBidiRects; + function hasBadBidiRects(measure) { + if (badBidiRects != null) { return badBidiRects } + var txt = removeChildrenAndAdd(measure, document.createTextNode("A\u062eA")); + var r0 = range(txt, 0, 1).getBoundingClientRect(); + var r1 = range(txt, 1, 2).getBoundingClientRect(); + removeChildren(measure); + if (!r0 || r0.left == r0.right) { return false } // Safari returns null in some cases (#2780) + return badBidiRects = (r1.right - r0.right < 3) + } + + // See if "".split is the broken IE version, if so, provide an + // alternative way to split lines. + var splitLinesAuto = "\n\nb".split(/\n/).length != 3 ? function (string) { + var pos = 0, result = [], l = string.length; + while (pos <= l) { + var nl = string.indexOf("\n", pos); + if (nl == -1) { nl = string.length; } + var line = string.slice(pos, string.charAt(nl - 1) == "\r" ? nl - 1 : nl); + var rt = line.indexOf("\r"); + if (rt != -1) { + result.push(line.slice(0, rt)); + pos += rt + 1; + } else { + result.push(line); + pos = nl + 1; + } + } + return result + } : function (string) { return string.split(/\r\n?|\n/); }; + + var hasSelection = window.getSelection ? function (te) { + try { return te.selectionStart != te.selectionEnd } + catch(e) { return false } + } : function (te) { + var range$$1; + try {range$$1 = te.ownerDocument.selection.createRange();} + catch(e) {} + if (!range$$1 || range$$1.parentElement() != te) { return false } + return range$$1.compareEndPoints("StartToEnd", range$$1) != 0 + }; + + var hasCopyEvent = (function () { + var e = elt("div"); + if ("oncopy" in e) { return true } + e.setAttribute("oncopy", "return;"); + return typeof e.oncopy == "function" + })(); + + var badZoomedRects = null; + function hasBadZoomedRects(measure) { + if (badZoomedRects != null) { return badZoomedRects } + var node = removeChildrenAndAdd(measure, elt("span", "x")); + var normal = node.getBoundingClientRect(); + var fromRange = range(node, 0, 1).getBoundingClientRect(); + return badZoomedRects = Math.abs(normal.left - fromRange.left) > 1 + } + + // Known modes, by name and by MIME + var modes = {}, mimeModes = {}; + + // Extra arguments are stored as the mode's dependencies, which is + // used by (legacy) mechanisms like loadmode.js to automatically + // load a mode. (Preferred mechanism is the require/define calls.) + function defineMode(name, mode) { + if (arguments.length > 2) + { mode.dependencies = Array.prototype.slice.call(arguments, 2); } + modes[name] = mode; + } + + function defineMIME(mime, spec) { + mimeModes[mime] = spec; + } + + // Given a MIME type, a {name, ...options} config object, or a name + // string, return a mode config object. + function resolveMode(spec) { + if (typeof spec == "string" && mimeModes.hasOwnProperty(spec)) { + spec = mimeModes[spec]; + } else if (spec && typeof spec.name == "string" && mimeModes.hasOwnProperty(spec.name)) { + var found = mimeModes[spec.name]; + if (typeof found == "string") { found = {name: found}; } + spec = createObj(found, spec); + spec.name = found.name; + } else if (typeof spec == "string" && /^[\w\-]+\/[\w\-]+\+xml$/.test(spec)) { + return resolveMode("application/xml") + } else if (typeof spec == "string" && /^[\w\-]+\/[\w\-]+\+json$/.test(spec)) { + return resolveMode("application/json") + } + if (typeof spec == "string") { return {name: spec} } + else { return spec || {name: "null"} } + } + + // Given a mode spec (anything that resolveMode accepts), find and + // initialize an actual mode object. + function getMode(options, spec) { + spec = resolveMode(spec); + var mfactory = modes[spec.name]; + if (!mfactory) { return getMode(options, "text/plain") } + var modeObj = mfactory(options, spec); + if (modeExtensions.hasOwnProperty(spec.name)) { + var exts = modeExtensions[spec.name]; + for (var prop in exts) { + if (!exts.hasOwnProperty(prop)) { continue } + if (modeObj.hasOwnProperty(prop)) { modeObj["_" + prop] = modeObj[prop]; } + modeObj[prop] = exts[prop]; + } + } + modeObj.name = spec.name; + if (spec.helperType) { modeObj.helperType = spec.helperType; } + if (spec.modeProps) { for (var prop$1 in spec.modeProps) + { modeObj[prop$1] = spec.modeProps[prop$1]; } } + + return modeObj + } + + // This can be used to attach properties to mode objects from + // outside the actual mode definition. + var modeExtensions = {}; + function extendMode(mode, properties) { + var exts = modeExtensions.hasOwnProperty(mode) ? modeExtensions[mode] : (modeExtensions[mode] = {}); + copyObj(properties, exts); + } + + function copyState(mode, state) { + if (state === true) { return state } + if (mode.copyState) { return mode.copyState(state) } + var nstate = {}; + for (var n in state) { + var val = state[n]; + if (val instanceof Array) { val = val.concat([]); } + nstate[n] = val; + } + return nstate + } + + // Given a mode and a state (for that mode), find the inner mode and + // state at the position that the state refers to. + function innerMode(mode, state) { + var info; + while (mode.innerMode) { + info = mode.innerMode(state); + if (!info || info.mode == mode) { break } + state = info.state; + mode = info.mode; + } + return info || {mode: mode, state: state} + } + + function startState(mode, a1, a2) { + return mode.startState ? mode.startState(a1, a2) : true + } + + // STRING STREAM + + // Fed to the mode parsers, provides helper functions to make + // parsers more succinct. + + var StringStream = function(string, tabSize, lineOracle) { + this.pos = this.start = 0; + this.string = string; + this.tabSize = tabSize || 8; + this.lastColumnPos = this.lastColumnValue = 0; + this.lineStart = 0; + this.lineOracle = lineOracle; + }; + + StringStream.prototype.eol = function () {return this.pos >= this.string.length}; + StringStream.prototype.sol = function () {return this.pos == this.lineStart}; + StringStream.prototype.peek = function () {return this.string.charAt(this.pos) || undefined}; + StringStream.prototype.next = function () { + if (this.pos < this.string.length) + { return this.string.charAt(this.pos++) } + }; + StringStream.prototype.eat = function (match) { + var ch = this.string.charAt(this.pos); + var ok; + if (typeof match == "string") { ok = ch == match; } + else { ok = ch && (match.test ? match.test(ch) : match(ch)); } + if (ok) {++this.pos; return ch} + }; + StringStream.prototype.eatWhile = function (match) { + var start = this.pos; + while (this.eat(match)){} + return this.pos > start + }; + StringStream.prototype.eatSpace = function () { + var this$1 = this; + + var start = this.pos; + while (/[\s\u00a0]/.test(this.string.charAt(this.pos))) { ++this$1.pos; } + return this.pos > start + }; + StringStream.prototype.skipToEnd = function () {this.pos = this.string.length;}; + StringStream.prototype.skipTo = function (ch) { + var found = this.string.indexOf(ch, this.pos); + if (found > -1) {this.pos = found; return true} + }; + StringStream.prototype.backUp = function (n) {this.pos -= n;}; + StringStream.prototype.column = function () { + if (this.lastColumnPos < this.start) { + this.lastColumnValue = countColumn(this.string, this.start, this.tabSize, this.lastColumnPos, this.lastColumnValue); + this.lastColumnPos = this.start; + } + return this.lastColumnValue - (this.lineStart ? countColumn(this.string, this.lineStart, this.tabSize) : 0) + }; + StringStream.prototype.indentation = function () { + return countColumn(this.string, null, this.tabSize) - + (this.lineStart ? countColumn(this.string, this.lineStart, this.tabSize) : 0) + }; + StringStream.prototype.match = function (pattern, consume, caseInsensitive) { + if (typeof pattern == "string") { + var cased = function (str) { return caseInsensitive ? str.toLowerCase() : str; }; + var substr = this.string.substr(this.pos, pattern.length); + if (cased(substr) == cased(pattern)) { + if (consume !== false) { this.pos += pattern.length; } + return true + } + } else { + var match = this.string.slice(this.pos).match(pattern); + if (match && match.index > 0) { return null } + if (match && consume !== false) { this.pos += match[0].length; } + return match + } + }; + StringStream.prototype.current = function (){return this.string.slice(this.start, this.pos)}; + StringStream.prototype.hideFirstChars = function (n, inner) { + this.lineStart += n; + try { return inner() } + finally { this.lineStart -= n; } + }; + StringStream.prototype.lookAhead = function (n) { + var oracle = this.lineOracle; + return oracle && oracle.lookAhead(n) + }; + StringStream.prototype.baseToken = function () { + var oracle = this.lineOracle; + return oracle && oracle.baseToken(this.pos) + }; + + // Find the line object corresponding to the given line number. + function getLine(doc, n) { + n -= doc.first; + if (n < 0 || n >= doc.size) { throw new Error("There is no line " + (n + doc.first) + " in the document.") } + var chunk = doc; + while (!chunk.lines) { + for (var i = 0;; ++i) { + var child = chunk.children[i], sz = child.chunkSize(); + if (n < sz) { chunk = child; break } + n -= sz; + } + } + return chunk.lines[n] + } + + // Get the part of a document between two positions, as an array of + // strings. + function getBetween(doc, start, end) { + var out = [], n = start.line; + doc.iter(start.line, end.line + 1, function (line) { + var text = line.text; + if (n == end.line) { text = text.slice(0, end.ch); } + if (n == start.line) { text = text.slice(start.ch); } + out.push(text); + ++n; + }); + return out + } + // Get the lines between from and to, as array of strings. + function getLines(doc, from, to) { + var out = []; + doc.iter(from, to, function (line) { out.push(line.text); }); // iter aborts when callback returns truthy value + return out + } + + // Update the height of a line, propagating the height change + // upwards to parent nodes. + function updateLineHeight(line, height) { + var diff = height - line.height; + if (diff) { for (var n = line; n; n = n.parent) { n.height += diff; } } + } + + // Given a line object, find its line number by walking up through + // its parent links. + function lineNo(line) { + if (line.parent == null) { return null } + var cur = line.parent, no = indexOf(cur.lines, line); + for (var chunk = cur.parent; chunk; cur = chunk, chunk = chunk.parent) { + for (var i = 0;; ++i) { + if (chunk.children[i] == cur) { break } + no += chunk.children[i].chunkSize(); + } + } + return no + cur.first + } + + // Find the line at the given vertical position, using the height + // information in the document tree. + function lineAtHeight(chunk, h) { + var n = chunk.first; + outer: do { + for (var i$1 = 0; i$1 < chunk.children.length; ++i$1) { + var child = chunk.children[i$1], ch = child.height; + if (h < ch) { chunk = child; continue outer } + h -= ch; + n += child.chunkSize(); + } + return n + } while (!chunk.lines) + var i = 0; + for (; i < chunk.lines.length; ++i) { + var line = chunk.lines[i], lh = line.height; + if (h < lh) { break } + h -= lh; + } + return n + i + } + + function isLine(doc, l) {return l >= doc.first && l < doc.first + doc.size} + + function lineNumberFor(options, i) { + return String(options.lineNumberFormatter(i + options.firstLineNumber)) + } + + // A Pos instance represents a position within the text. + function Pos(line, ch, sticky) { + if ( sticky === void 0 ) sticky = null; + + if (!(this instanceof Pos)) { return new Pos(line, ch, sticky) } + this.line = line; + this.ch = ch; + this.sticky = sticky; + } + + // Compare two positions, return 0 if they are the same, a negative + // number when a is less, and a positive number otherwise. + function cmp(a, b) { return a.line - b.line || a.ch - b.ch } + + function equalCursorPos(a, b) { return a.sticky == b.sticky && cmp(a, b) == 0 } + + function copyPos(x) {return Pos(x.line, x.ch)} + function maxPos(a, b) { return cmp(a, b) < 0 ? b : a } + function minPos(a, b) { return cmp(a, b) < 0 ? a : b } + + // Most of the external API clips given positions to make sure they + // actually exist within the document. + function clipLine(doc, n) {return Math.max(doc.first, Math.min(n, doc.first + doc.size - 1))} + function clipPos(doc, pos) { + if (pos.line < doc.first) { return Pos(doc.first, 0) } + var last = doc.first + doc.size - 1; + if (pos.line > last) { return Pos(last, getLine(doc, last).text.length) } + return clipToLen(pos, getLine(doc, pos.line).text.length) + } + function clipToLen(pos, linelen) { + var ch = pos.ch; + if (ch == null || ch > linelen) { return Pos(pos.line, linelen) } + else if (ch < 0) { return Pos(pos.line, 0) } + else { return pos } + } + function clipPosArray(doc, array) { + var out = []; + for (var i = 0; i < array.length; i++) { out[i] = clipPos(doc, array[i]); } + return out + } + + var SavedContext = function(state, lookAhead) { + this.state = state; + this.lookAhead = lookAhead; + }; + + var Context = function(doc, state, line, lookAhead) { + this.state = state; + this.doc = doc; + this.line = line; + this.maxLookAhead = lookAhead || 0; + this.baseTokens = null; + this.baseTokenPos = 1; + }; + + Context.prototype.lookAhead = function (n) { + var line = this.doc.getLine(this.line + n); + if (line != null && n > this.maxLookAhead) { this.maxLookAhead = n; } + return line + }; + + Context.prototype.baseToken = function (n) { + var this$1 = this; + + if (!this.baseTokens) { return null } + while (this.baseTokens[this.baseTokenPos] <= n) + { this$1.baseTokenPos += 2; } + var type = this.baseTokens[this.baseTokenPos + 1]; + return {type: type && type.replace(/( |^)overlay .*/, ""), + size: this.baseTokens[this.baseTokenPos] - n} + }; + + Context.prototype.nextLine = function () { + this.line++; + if (this.maxLookAhead > 0) { this.maxLookAhead--; } + }; + + Context.fromSaved = function (doc, saved, line) { + if (saved instanceof SavedContext) + { return new Context(doc, copyState(doc.mode, saved.state), line, saved.lookAhead) } + else + { return new Context(doc, copyState(doc.mode, saved), line) } + }; + + Context.prototype.save = function (copy) { + var state = copy !== false ? copyState(this.doc.mode, this.state) : this.state; + return this.maxLookAhead > 0 ? new SavedContext(state, this.maxLookAhead) : state + }; + + + // Compute a style array (an array starting with a mode generation + // -- for invalidation -- followed by pairs of end positions and + // style strings), which is used to highlight the tokens on the + // line. + function highlightLine(cm, line, context, forceToEnd) { + // A styles array always starts with a number identifying the + // mode/overlays that it is based on (for easy invalidation). + var st = [cm.state.modeGen], lineClasses = {}; + // Compute the base array of styles + runMode(cm, line.text, cm.doc.mode, context, function (end, style) { return st.push(end, style); }, + lineClasses, forceToEnd); + var state = context.state; + + // Run overlays, adjust style array. + var loop = function ( o ) { + context.baseTokens = st; + var overlay = cm.state.overlays[o], i = 1, at = 0; + context.state = true; + runMode(cm, line.text, overlay.mode, context, function (end, style) { + var start = i; + // Ensure there's a token end at the current position, and that i points at it + while (at < end) { + var i_end = st[i]; + if (i_end > end) + { st.splice(i, 1, end, st[i+1], i_end); } + i += 2; + at = Math.min(end, i_end); + } + if (!style) { return } + if (overlay.opaque) { + st.splice(start, i - start, end, "overlay " + style); + i = start + 2; + } else { + for (; start < i; start += 2) { + var cur = st[start+1]; + st[start+1] = (cur ? cur + " " : "") + "overlay " + style; + } + } + }, lineClasses); + context.state = state; + context.baseTokens = null; + context.baseTokenPos = 1; + }; + + for (var o = 0; o < cm.state.overlays.length; ++o) loop( o ); + + return {styles: st, classes: lineClasses.bgClass || lineClasses.textClass ? lineClasses : null} + } + + function getLineStyles(cm, line, updateFrontier) { + if (!line.styles || line.styles[0] != cm.state.modeGen) { + var context = getContextBefore(cm, lineNo(line)); + var resetState = line.text.length > cm.options.maxHighlightLength && copyState(cm.doc.mode, context.state); + var result = highlightLine(cm, line, context); + if (resetState) { context.state = resetState; } + line.stateAfter = context.save(!resetState); + line.styles = result.styles; + if (result.classes) { line.styleClasses = result.classes; } + else if (line.styleClasses) { line.styleClasses = null; } + if (updateFrontier === cm.doc.highlightFrontier) + { cm.doc.modeFrontier = Math.max(cm.doc.modeFrontier, ++cm.doc.highlightFrontier); } + } + return line.styles + } + + function getContextBefore(cm, n, precise) { + var doc = cm.doc, display = cm.display; + if (!doc.mode.startState) { return new Context(doc, true, n) } + var start = findStartLine(cm, n, precise); + var saved = start > doc.first && getLine(doc, start - 1).stateAfter; + var context = saved ? Context.fromSaved(doc, saved, start) : new Context(doc, startState(doc.mode), start); + + doc.iter(start, n, function (line) { + processLine(cm, line.text, context); + var pos = context.line; + line.stateAfter = pos == n - 1 || pos % 5 == 0 || pos >= display.viewFrom && pos < display.viewTo ? context.save() : null; + context.nextLine(); + }); + if (precise) { doc.modeFrontier = context.line; } + return context + } + + // Lightweight form of highlight -- proceed over this line and + // update state, but don't save a style array. Used for lines that + // aren't currently visible. + function processLine(cm, text, context, startAt) { + var mode = cm.doc.mode; + var stream = new StringStream(text, cm.options.tabSize, context); + stream.start = stream.pos = startAt || 0; + if (text == "") { callBlankLine(mode, context.state); } + while (!stream.eol()) { + readToken(mode, stream, context.state); + stream.start = stream.pos; + } + } + + function callBlankLine(mode, state) { + if (mode.blankLine) { return mode.blankLine(state) } + if (!mode.innerMode) { return } + var inner = innerMode(mode, state); + if (inner.mode.blankLine) { return inner.mode.blankLine(inner.state) } + } + + function readToken(mode, stream, state, inner) { + for (var i = 0; i < 10; i++) { + if (inner) { inner[0] = innerMode(mode, state).mode; } + var style = mode.token(stream, state); + if (stream.pos > stream.start) { return style } + } + throw new Error("Mode " + mode.name + " failed to advance stream.") + } + + var Token = function(stream, type, state) { + this.start = stream.start; this.end = stream.pos; + this.string = stream.current(); + this.type = type || null; + this.state = state; + }; + + // Utility for getTokenAt and getLineTokens + function takeToken(cm, pos, precise, asArray) { + var doc = cm.doc, mode = doc.mode, style; + pos = clipPos(doc, pos); + var line = getLine(doc, pos.line), context = getContextBefore(cm, pos.line, precise); + var stream = new StringStream(line.text, cm.options.tabSize, context), tokens; + if (asArray) { tokens = []; } + while ((asArray || stream.pos < pos.ch) && !stream.eol()) { + stream.start = stream.pos; + style = readToken(mode, stream, context.state); + if (asArray) { tokens.push(new Token(stream, style, copyState(doc.mode, context.state))); } + } + return asArray ? tokens : new Token(stream, style, context.state) + } + + function extractLineClasses(type, output) { + if (type) { for (;;) { + var lineClass = type.match(/(?:^|\s+)line-(background-)?(\S+)/); + if (!lineClass) { break } + type = type.slice(0, lineClass.index) + type.slice(lineClass.index + lineClass[0].length); + var prop = lineClass[1] ? "bgClass" : "textClass"; + if (output[prop] == null) + { output[prop] = lineClass[2]; } + else if (!(new RegExp("(?:^|\s)" + lineClass[2] + "(?:$|\s)")).test(output[prop])) + { output[prop] += " " + lineClass[2]; } + } } + return type + } + + // Run the given mode's parser over a line, calling f for each token. + function runMode(cm, text, mode, context, f, lineClasses, forceToEnd) { + var flattenSpans = mode.flattenSpans; + if (flattenSpans == null) { flattenSpans = cm.options.flattenSpans; } + var curStart = 0, curStyle = null; + var stream = new StringStream(text, cm.options.tabSize, context), style; + var inner = cm.options.addModeClass && [null]; + if (text == "") { extractLineClasses(callBlankLine(mode, context.state), lineClasses); } + while (!stream.eol()) { + if (stream.pos > cm.options.maxHighlightLength) { + flattenSpans = false; + if (forceToEnd) { processLine(cm, text, context, stream.pos); } + stream.pos = text.length; + style = null; + } else { + style = extractLineClasses(readToken(mode, stream, context.state, inner), lineClasses); + } + if (inner) { + var mName = inner[0].name; + if (mName) { style = "m-" + (style ? mName + " " + style : mName); } + } + if (!flattenSpans || curStyle != style) { + while (curStart < stream.start) { + curStart = Math.min(stream.start, curStart + 5000); + f(curStart, curStyle); + } + curStyle = style; + } + stream.start = stream.pos; + } + while (curStart < stream.pos) { + // Webkit seems to refuse to render text nodes longer than 57444 + // characters, and returns inaccurate measurements in nodes + // starting around 5000 chars. + var pos = Math.min(stream.pos, curStart + 5000); + f(pos, curStyle); + curStart = pos; + } + } + + // Finds the line to start with when starting a parse. Tries to + // find a line with a stateAfter, so that it can start with a + // valid state. If that fails, it returns the line with the + // smallest indentation, which tends to need the least context to + // parse correctly. + function findStartLine(cm, n, precise) { + var minindent, minline, doc = cm.doc; + var lim = precise ? -1 : n - (cm.doc.mode.innerMode ? 1000 : 100); + for (var search = n; search > lim; --search) { + if (search <= doc.first) { return doc.first } + var line = getLine(doc, search - 1), after = line.stateAfter; + if (after && (!precise || search + (after instanceof SavedContext ? after.lookAhead : 0) <= doc.modeFrontier)) + { return search } + var indented = countColumn(line.text, null, cm.options.tabSize); + if (minline == null || minindent > indented) { + minline = search - 1; + minindent = indented; + } + } + return minline + } + + function retreatFrontier(doc, n) { + doc.modeFrontier = Math.min(doc.modeFrontier, n); + if (doc.highlightFrontier < n - 10) { return } + var start = doc.first; + for (var line = n - 1; line > start; line--) { + var saved = getLine(doc, line).stateAfter; + // change is on 3 + // state on line 1 looked ahead 2 -- so saw 3 + // test 1 + 2 < 3 should cover this + if (saved && (!(saved instanceof SavedContext) || line + saved.lookAhead < n)) { + start = line + 1; + break + } + } + doc.highlightFrontier = Math.min(doc.highlightFrontier, start); + } + + // Optimize some code when these features are not used. + var sawReadOnlySpans = false, sawCollapsedSpans = false; + + function seeReadOnlySpans() { + sawReadOnlySpans = true; + } + + function seeCollapsedSpans() { + sawCollapsedSpans = true; + } + + // TEXTMARKER SPANS + + function MarkedSpan(marker, from, to) { + this.marker = marker; + this.from = from; this.to = to; + } + + // Search an array of spans for a span matching the given marker. + function getMarkedSpanFor(spans, marker) { + if (spans) { for (var i = 0; i < spans.length; ++i) { + var span = spans[i]; + if (span.marker == marker) { return span } + } } + } + // Remove a span from an array, returning undefined if no spans are + // left (we don't store arrays for lines without spans). + function removeMarkedSpan(spans, span) { + var r; + for (var i = 0; i < spans.length; ++i) + { if (spans[i] != span) { (r || (r = [])).push(spans[i]); } } + return r + } + // Add a span to a line. + function addMarkedSpan(line, span) { + line.markedSpans = line.markedSpans ? line.markedSpans.concat([span]) : [span]; + span.marker.attachLine(line); + } + + // Used for the algorithm that adjusts markers for a change in the + // document. These functions cut an array of spans at a given + // character position, returning an array of remaining chunks (or + // undefined if nothing remains). + function markedSpansBefore(old, startCh, isInsert) { + var nw; + if (old) { for (var i = 0; i < old.length; ++i) { + var span = old[i], marker = span.marker; + var startsBefore = span.from == null || (marker.inclusiveLeft ? span.from <= startCh : span.from < startCh); + if (startsBefore || span.from == startCh && marker.type == "bookmark" && (!isInsert || !span.marker.insertLeft)) { + var endsAfter = span.to == null || (marker.inclusiveRight ? span.to >= startCh : span.to > startCh) + ;(nw || (nw = [])).push(new MarkedSpan(marker, span.from, endsAfter ? null : span.to)); + } + } } + return nw + } + function markedSpansAfter(old, endCh, isInsert) { + var nw; + if (old) { for (var i = 0; i < old.length; ++i) { + var span = old[i], marker = span.marker; + var endsAfter = span.to == null || (marker.inclusiveRight ? span.to >= endCh : span.to > endCh); + if (endsAfter || span.from == endCh && marker.type == "bookmark" && (!isInsert || span.marker.insertLeft)) { + var startsBefore = span.from == null || (marker.inclusiveLeft ? span.from <= endCh : span.from < endCh) + ;(nw || (nw = [])).push(new MarkedSpan(marker, startsBefore ? null : span.from - endCh, + span.to == null ? null : span.to - endCh)); + } + } } + return nw + } + + // Given a change object, compute the new set of marker spans that + // cover the line in which the change took place. Removes spans + // entirely within the change, reconnects spans belonging to the + // same marker that appear on both sides of the change, and cuts off + // spans partially within the change. Returns an array of span + // arrays with one element for each line in (after) the change. + function stretchSpansOverChange(doc, change) { + if (change.full) { return null } + var oldFirst = isLine(doc, change.from.line) && getLine(doc, change.from.line).markedSpans; + var oldLast = isLine(doc, change.to.line) && getLine(doc, change.to.line).markedSpans; + if (!oldFirst && !oldLast) { return null } + + var startCh = change.from.ch, endCh = change.to.ch, isInsert = cmp(change.from, change.to) == 0; + // Get the spans that 'stick out' on both sides + var first = markedSpansBefore(oldFirst, startCh, isInsert); + var last = markedSpansAfter(oldLast, endCh, isInsert); + + // Next, merge those two ends + var sameLine = change.text.length == 1, offset = lst(change.text).length + (sameLine ? startCh : 0); + if (first) { + // Fix up .to properties of first + for (var i = 0; i < first.length; ++i) { + var span = first[i]; + if (span.to == null) { + var found = getMarkedSpanFor(last, span.marker); + if (!found) { span.to = startCh; } + else if (sameLine) { span.to = found.to == null ? null : found.to + offset; } + } + } + } + if (last) { + // Fix up .from in last (or move them into first in case of sameLine) + for (var i$1 = 0; i$1 < last.length; ++i$1) { + var span$1 = last[i$1]; + if (span$1.to != null) { span$1.to += offset; } + if (span$1.from == null) { + var found$1 = getMarkedSpanFor(first, span$1.marker); + if (!found$1) { + span$1.from = offset; + if (sameLine) { (first || (first = [])).push(span$1); } + } + } else { + span$1.from += offset; + if (sameLine) { (first || (first = [])).push(span$1); } + } + } + } + // Make sure we didn't create any zero-length spans + if (first) { first = clearEmptySpans(first); } + if (last && last != first) { last = clearEmptySpans(last); } + + var newMarkers = [first]; + if (!sameLine) { + // Fill gap with whole-line-spans + var gap = change.text.length - 2, gapMarkers; + if (gap > 0 && first) + { for (var i$2 = 0; i$2 < first.length; ++i$2) + { if (first[i$2].to == null) + { (gapMarkers || (gapMarkers = [])).push(new MarkedSpan(first[i$2].marker, null, null)); } } } + for (var i$3 = 0; i$3 < gap; ++i$3) + { newMarkers.push(gapMarkers); } + newMarkers.push(last); + } + return newMarkers + } + + // Remove spans that are empty and don't have a clearWhenEmpty + // option of false. + function clearEmptySpans(spans) { + for (var i = 0; i < spans.length; ++i) { + var span = spans[i]; + if (span.from != null && span.from == span.to && span.marker.clearWhenEmpty !== false) + { spans.splice(i--, 1); } + } + if (!spans.length) { return null } + return spans + } + + // Used to 'clip' out readOnly ranges when making a change. + function removeReadOnlyRanges(doc, from, to) { + var markers = null; + doc.iter(from.line, to.line + 1, function (line) { + if (line.markedSpans) { for (var i = 0; i < line.markedSpans.length; ++i) { + var mark = line.markedSpans[i].marker; + if (mark.readOnly && (!markers || indexOf(markers, mark) == -1)) + { (markers || (markers = [])).push(mark); } + } } + }); + if (!markers) { return null } + var parts = [{from: from, to: to}]; + for (var i = 0; i < markers.length; ++i) { + var mk = markers[i], m = mk.find(0); + for (var j = 0; j < parts.length; ++j) { + var p = parts[j]; + if (cmp(p.to, m.from) < 0 || cmp(p.from, m.to) > 0) { continue } + var newParts = [j, 1], dfrom = cmp(p.from, m.from), dto = cmp(p.to, m.to); + if (dfrom < 0 || !mk.inclusiveLeft && !dfrom) + { newParts.push({from: p.from, to: m.from}); } + if (dto > 0 || !mk.inclusiveRight && !dto) + { newParts.push({from: m.to, to: p.to}); } + parts.splice.apply(parts, newParts); + j += newParts.length - 3; + } + } + return parts + } + + // Connect or disconnect spans from a line. + function detachMarkedSpans(line) { + var spans = line.markedSpans; + if (!spans) { return } + for (var i = 0; i < spans.length; ++i) + { spans[i].marker.detachLine(line); } + line.markedSpans = null; + } + function attachMarkedSpans(line, spans) { + if (!spans) { return } + for (var i = 0; i < spans.length; ++i) + { spans[i].marker.attachLine(line); } + line.markedSpans = spans; + } + + // Helpers used when computing which overlapping collapsed span + // counts as the larger one. + function extraLeft(marker) { return marker.inclusiveLeft ? -1 : 0 } + function extraRight(marker) { return marker.inclusiveRight ? 1 : 0 } + + // Returns a number indicating which of two overlapping collapsed + // spans is larger (and thus includes the other). Falls back to + // comparing ids when the spans cover exactly the same range. + function compareCollapsedMarkers(a, b) { + var lenDiff = a.lines.length - b.lines.length; + if (lenDiff != 0) { return lenDiff } + var aPos = a.find(), bPos = b.find(); + var fromCmp = cmp(aPos.from, bPos.from) || extraLeft(a) - extraLeft(b); + if (fromCmp) { return -fromCmp } + var toCmp = cmp(aPos.to, bPos.to) || extraRight(a) - extraRight(b); + if (toCmp) { return toCmp } + return b.id - a.id + } + + // Find out whether a line ends or starts in a collapsed span. If + // so, return the marker for that span. + function collapsedSpanAtSide(line, start) { + var sps = sawCollapsedSpans && line.markedSpans, found; + if (sps) { for (var sp = (void 0), i = 0; i < sps.length; ++i) { + sp = sps[i]; + if (sp.marker.collapsed && (start ? sp.from : sp.to) == null && + (!found || compareCollapsedMarkers(found, sp.marker) < 0)) + { found = sp.marker; } + } } + return found + } + function collapsedSpanAtStart(line) { return collapsedSpanAtSide(line, true) } + function collapsedSpanAtEnd(line) { return collapsedSpanAtSide(line, false) } + + function collapsedSpanAround(line, ch) { + var sps = sawCollapsedSpans && line.markedSpans, found; + if (sps) { for (var i = 0; i < sps.length; ++i) { + var sp = sps[i]; + if (sp.marker.collapsed && (sp.from == null || sp.from < ch) && (sp.to == null || sp.to > ch) && + (!found || compareCollapsedMarkers(found, sp.marker) < 0)) { found = sp.marker; } + } } + return found + } + + // Test whether there exists a collapsed span that partially + // overlaps (covers the start or end, but not both) of a new span. + // Such overlap is not allowed. + function conflictingCollapsedRange(doc, lineNo$$1, from, to, marker) { + var line = getLine(doc, lineNo$$1); + var sps = sawCollapsedSpans && line.markedSpans; + if (sps) { for (var i = 0; i < sps.length; ++i) { + var sp = sps[i]; + if (!sp.marker.collapsed) { continue } + var found = sp.marker.find(0); + var fromCmp = cmp(found.from, from) || extraLeft(sp.marker) - extraLeft(marker); + var toCmp = cmp(found.to, to) || extraRight(sp.marker) - extraRight(marker); + if (fromCmp >= 0 && toCmp <= 0 || fromCmp <= 0 && toCmp >= 0) { continue } + if (fromCmp <= 0 && (sp.marker.inclusiveRight && marker.inclusiveLeft ? cmp(found.to, from) >= 0 : cmp(found.to, from) > 0) || + fromCmp >= 0 && (sp.marker.inclusiveRight && marker.inclusiveLeft ? cmp(found.from, to) <= 0 : cmp(found.from, to) < 0)) + { return true } + } } + } + + // A visual line is a line as drawn on the screen. Folding, for + // example, can cause multiple logical lines to appear on the same + // visual line. This finds the start of the visual line that the + // given line is part of (usually that is the line itself). + function visualLine(line) { + var merged; + while (merged = collapsedSpanAtStart(line)) + { line = merged.find(-1, true).line; } + return line + } + + function visualLineEnd(line) { + var merged; + while (merged = collapsedSpanAtEnd(line)) + { line = merged.find(1, true).line; } + return line + } + + // Returns an array of logical lines that continue the visual line + // started by the argument, or undefined if there are no such lines. + function visualLineContinued(line) { + var merged, lines; + while (merged = collapsedSpanAtEnd(line)) { + line = merged.find(1, true).line + ;(lines || (lines = [])).push(line); + } + return lines + } + + // Get the line number of the start of the visual line that the + // given line number is part of. + function visualLineNo(doc, lineN) { + var line = getLine(doc, lineN), vis = visualLine(line); + if (line == vis) { return lineN } + return lineNo(vis) + } + + // Get the line number of the start of the next visual line after + // the given line. + function visualLineEndNo(doc, lineN) { + if (lineN > doc.lastLine()) { return lineN } + var line = getLine(doc, lineN), merged; + if (!lineIsHidden(doc, line)) { return lineN } + while (merged = collapsedSpanAtEnd(line)) + { line = merged.find(1, true).line; } + return lineNo(line) + 1 + } + + // Compute whether a line is hidden. Lines count as hidden when they + // are part of a visual line that starts with another line, or when + // they are entirely covered by collapsed, non-widget span. + function lineIsHidden(doc, line) { + var sps = sawCollapsedSpans && line.markedSpans; + if (sps) { for (var sp = (void 0), i = 0; i < sps.length; ++i) { + sp = sps[i]; + if (!sp.marker.collapsed) { continue } + if (sp.from == null) { return true } + if (sp.marker.widgetNode) { continue } + if (sp.from == 0 && sp.marker.inclusiveLeft && lineIsHiddenInner(doc, line, sp)) + { return true } + } } + } + function lineIsHiddenInner(doc, line, span) { + if (span.to == null) { + var end = span.marker.find(1, true); + return lineIsHiddenInner(doc, end.line, getMarkedSpanFor(end.line.markedSpans, span.marker)) + } + if (span.marker.inclusiveRight && span.to == line.text.length) + { return true } + for (var sp = (void 0), i = 0; i < line.markedSpans.length; ++i) { + sp = line.markedSpans[i]; + if (sp.marker.collapsed && !sp.marker.widgetNode && sp.from == span.to && + (sp.to == null || sp.to != span.from) && + (sp.marker.inclusiveLeft || span.marker.inclusiveRight) && + lineIsHiddenInner(doc, line, sp)) { return true } + } + } + + // Find the height above the given line. + function heightAtLine(lineObj) { + lineObj = visualLine(lineObj); + + var h = 0, chunk = lineObj.parent; + for (var i = 0; i < chunk.lines.length; ++i) { + var line = chunk.lines[i]; + if (line == lineObj) { break } + else { h += line.height; } + } + for (var p = chunk.parent; p; chunk = p, p = chunk.parent) { + for (var i$1 = 0; i$1 < p.children.length; ++i$1) { + var cur = p.children[i$1]; + if (cur == chunk) { break } + else { h += cur.height; } + } + } + return h + } + + // Compute the character length of a line, taking into account + // collapsed ranges (see markText) that might hide parts, and join + // other lines onto it. + function lineLength(line) { + if (line.height == 0) { return 0 } + var len = line.text.length, merged, cur = line; + while (merged = collapsedSpanAtStart(cur)) { + var found = merged.find(0, true); + cur = found.from.line; + len += found.from.ch - found.to.ch; + } + cur = line; + while (merged = collapsedSpanAtEnd(cur)) { + var found$1 = merged.find(0, true); + len -= cur.text.length - found$1.from.ch; + cur = found$1.to.line; + len += cur.text.length - found$1.to.ch; + } + return len + } + + // Find the longest line in the document. + function findMaxLine(cm) { + var d = cm.display, doc = cm.doc; + d.maxLine = getLine(doc, doc.first); + d.maxLineLength = lineLength(d.maxLine); + d.maxLineChanged = true; + doc.iter(function (line) { + var len = lineLength(line); + if (len > d.maxLineLength) { + d.maxLineLength = len; + d.maxLine = line; + } + }); + } + + // LINE DATA STRUCTURE + + // Line objects. These hold state related to a line, including + // highlighting info (the styles array). + var Line = function(text, markedSpans, estimateHeight) { + this.text = text; + attachMarkedSpans(this, markedSpans); + this.height = estimateHeight ? estimateHeight(this) : 1; + }; + + Line.prototype.lineNo = function () { return lineNo(this) }; + eventMixin(Line); + + // Change the content (text, markers) of a line. Automatically + // invalidates cached information and tries to re-estimate the + // line's height. + function updateLine(line, text, markedSpans, estimateHeight) { + line.text = text; + if (line.stateAfter) { line.stateAfter = null; } + if (line.styles) { line.styles = null; } + if (line.order != null) { line.order = null; } + detachMarkedSpans(line); + attachMarkedSpans(line, markedSpans); + var estHeight = estimateHeight ? estimateHeight(line) : 1; + if (estHeight != line.height) { updateLineHeight(line, estHeight); } + } + + // Detach a line from the document tree and its markers. + function cleanUpLine(line) { + line.parent = null; + detachMarkedSpans(line); + } + + // Convert a style as returned by a mode (either null, or a string + // containing one or more styles) to a CSS style. This is cached, + // and also looks for line-wide styles. + var styleToClassCache = {}, styleToClassCacheWithMode = {}; + function interpretTokenStyle(style, options) { + if (!style || /^\s*$/.test(style)) { return null } + var cache = options.addModeClass ? styleToClassCacheWithMode : styleToClassCache; + return cache[style] || + (cache[style] = style.replace(/\S+/g, "cm-$&")) + } + + // Render the DOM representation of the text of a line. Also builds + // up a 'line map', which points at the DOM nodes that represent + // specific stretches of text, and is used by the measuring code. + // The returned object contains the DOM node, this map, and + // information about line-wide styles that were set by the mode. + function buildLineContent(cm, lineView) { + // The padding-right forces the element to have a 'border', which + // is needed on Webkit to be able to get line-level bounding + // rectangles for it (in measureChar). + var content = eltP("span", null, null, webkit ? "padding-right: .1px" : null); + var builder = {pre: eltP("pre", [content], "CodeMirror-line"), content: content, + col: 0, pos: 0, cm: cm, + trailingSpace: false, + splitSpaces: cm.getOption("lineWrapping")}; + lineView.measure = {}; + + // Iterate over the logical lines that make up this visual line. + for (var i = 0; i <= (lineView.rest ? lineView.rest.length : 0); i++) { + var line = i ? lineView.rest[i - 1] : lineView.line, order = (void 0); + builder.pos = 0; + builder.addToken = buildToken; + // Optionally wire in some hacks into the token-rendering + // algorithm, to deal with browser quirks. + if (hasBadBidiRects(cm.display.measure) && (order = getOrder(line, cm.doc.direction))) + { builder.addToken = buildTokenBadBidi(builder.addToken, order); } + builder.map = []; + var allowFrontierUpdate = lineView != cm.display.externalMeasured && lineNo(line); + insertLineContent(line, builder, getLineStyles(cm, line, allowFrontierUpdate)); + if (line.styleClasses) { + if (line.styleClasses.bgClass) + { builder.bgClass = joinClasses(line.styleClasses.bgClass, builder.bgClass || ""); } + if (line.styleClasses.textClass) + { builder.textClass = joinClasses(line.styleClasses.textClass, builder.textClass || ""); } + } + + // Ensure at least a single node is present, for measuring. + if (builder.map.length == 0) + { builder.map.push(0, 0, builder.content.appendChild(zeroWidthElement(cm.display.measure))); } + + // Store the map and a cache object for the current logical line + if (i == 0) { + lineView.measure.map = builder.map; + lineView.measure.cache = {}; + } else { + (lineView.measure.maps || (lineView.measure.maps = [])).push(builder.map) + ;(lineView.measure.caches || (lineView.measure.caches = [])).push({}); + } + } + + // See issue #2901 + if (webkit) { + var last = builder.content.lastChild; + if (/\bcm-tab\b/.test(last.className) || (last.querySelector && last.querySelector(".cm-tab"))) + { builder.content.className = "cm-tab-wrap-hack"; } + } + + signal(cm, "renderLine", cm, lineView.line, builder.pre); + if (builder.pre.className) + { builder.textClass = joinClasses(builder.pre.className, builder.textClass || ""); } + + return builder + } + + function defaultSpecialCharPlaceholder(ch) { + var token = elt("span", "\u2022", "cm-invalidchar"); + token.title = "\\u" + ch.charCodeAt(0).toString(16); + token.setAttribute("aria-label", token.title); + return token + } + + // Build up the DOM representation for a single token, and add it to + // the line map. Takes care to render special characters separately. + function buildToken(builder, text, style, startStyle, endStyle, css, attributes) { + if (!text) { return } + var displayText = builder.splitSpaces ? splitSpaces(text, builder.trailingSpace) : text; + var special = builder.cm.state.specialChars, mustWrap = false; + var content; + if (!special.test(text)) { + builder.col += text.length; + content = document.createTextNode(displayText); + builder.map.push(builder.pos, builder.pos + text.length, content); + if (ie && ie_version < 9) { mustWrap = true; } + builder.pos += text.length; + } else { + content = document.createDocumentFragment(); + var pos = 0; + while (true) { + special.lastIndex = pos; + var m = special.exec(text); + var skipped = m ? m.index - pos : text.length - pos; + if (skipped) { + var txt = document.createTextNode(displayText.slice(pos, pos + skipped)); + if (ie && ie_version < 9) { content.appendChild(elt("span", [txt])); } + else { content.appendChild(txt); } + builder.map.push(builder.pos, builder.pos + skipped, txt); + builder.col += skipped; + builder.pos += skipped; + } + if (!m) { break } + pos += skipped + 1; + var txt$1 = (void 0); + if (m[0] == "\t") { + var tabSize = builder.cm.options.tabSize, tabWidth = tabSize - builder.col % tabSize; + txt$1 = content.appendChild(elt("span", spaceStr(tabWidth), "cm-tab")); + txt$1.setAttribute("role", "presentation"); + txt$1.setAttribute("cm-text", "\t"); + builder.col += tabWidth; + } else if (m[0] == "\r" || m[0] == "\n") { + txt$1 = content.appendChild(elt("span", m[0] == "\r" ? "\u240d" : "\u2424", "cm-invalidchar")); + txt$1.setAttribute("cm-text", m[0]); + builder.col += 1; + } else { + txt$1 = builder.cm.options.specialCharPlaceholder(m[0]); + txt$1.setAttribute("cm-text", m[0]); + if (ie && ie_version < 9) { content.appendChild(elt("span", [txt$1])); } + else { content.appendChild(txt$1); } + builder.col += 1; + } + builder.map.push(builder.pos, builder.pos + 1, txt$1); + builder.pos++; + } + } + builder.trailingSpace = displayText.charCodeAt(text.length - 1) == 32; + if (style || startStyle || endStyle || mustWrap || css) { + var fullStyle = style || ""; + if (startStyle) { fullStyle += startStyle; } + if (endStyle) { fullStyle += endStyle; } + var token = elt("span", [content], fullStyle, css); + if (attributes) { + for (var attr in attributes) { if (attributes.hasOwnProperty(attr) && attr != "style" && attr != "class") + { token.setAttribute(attr, attributes[attr]); } } + } + return builder.content.appendChild(token) + } + builder.content.appendChild(content); + } + + // Change some spaces to NBSP to prevent the browser from collapsing + // trailing spaces at the end of a line when rendering text (issue #1362). + function splitSpaces(text, trailingBefore) { + if (text.length > 1 && !/ /.test(text)) { return text } + var spaceBefore = trailingBefore, result = ""; + for (var i = 0; i < text.length; i++) { + var ch = text.charAt(i); + if (ch == " " && spaceBefore && (i == text.length - 1 || text.charCodeAt(i + 1) == 32)) + { ch = "\u00a0"; } + result += ch; + spaceBefore = ch == " "; + } + return result + } + + // Work around nonsense dimensions being reported for stretches of + // right-to-left text. + function buildTokenBadBidi(inner, order) { + return function (builder, text, style, startStyle, endStyle, css, attributes) { + style = style ? style + " cm-force-border" : "cm-force-border"; + var start = builder.pos, end = start + text.length; + for (;;) { + // Find the part that overlaps with the start of this text + var part = (void 0); + for (var i = 0; i < order.length; i++) { + part = order[i]; + if (part.to > start && part.from <= start) { break } + } + if (part.to >= end) { return inner(builder, text, style, startStyle, endStyle, css, attributes) } + inner(builder, text.slice(0, part.to - start), style, startStyle, null, css, attributes); + startStyle = null; + text = text.slice(part.to - start); + start = part.to; + } + } + } + + function buildCollapsedSpan(builder, size, marker, ignoreWidget) { + var widget = !ignoreWidget && marker.widgetNode; + if (widget) { builder.map.push(builder.pos, builder.pos + size, widget); } + if (!ignoreWidget && builder.cm.display.input.needsContentAttribute) { + if (!widget) + { widget = builder.content.appendChild(document.createElement("span")); } + widget.setAttribute("cm-marker", marker.id); + } + if (widget) { + builder.cm.display.input.setUneditable(widget); + builder.content.appendChild(widget); + } + builder.pos += size; + builder.trailingSpace = false; + } + + // Outputs a number of spans to make up a line, taking highlighting + // and marked text into account. + function insertLineContent(line, builder, styles) { + var spans = line.markedSpans, allText = line.text, at = 0; + if (!spans) { + for (var i$1 = 1; i$1 < styles.length; i$1+=2) + { builder.addToken(builder, allText.slice(at, at = styles[i$1]), interpretTokenStyle(styles[i$1+1], builder.cm.options)); } + return + } + + var len = allText.length, pos = 0, i = 1, text = "", style, css; + var nextChange = 0, spanStyle, spanEndStyle, spanStartStyle, collapsed, attributes; + for (;;) { + if (nextChange == pos) { // Update current marker set + spanStyle = spanEndStyle = spanStartStyle = css = ""; + attributes = null; + collapsed = null; nextChange = Infinity; + var foundBookmarks = [], endStyles = (void 0); + for (var j = 0; j < spans.length; ++j) { + var sp = spans[j], m = sp.marker; + if (m.type == "bookmark" && sp.from == pos && m.widgetNode) { + foundBookmarks.push(m); + } else if (sp.from <= pos && (sp.to == null || sp.to > pos || m.collapsed && sp.to == pos && sp.from == pos)) { + if (sp.to != null && sp.to != pos && nextChange > sp.to) { + nextChange = sp.to; + spanEndStyle = ""; + } + if (m.className) { spanStyle += " " + m.className; } + if (m.css) { css = (css ? css + ";" : "") + m.css; } + if (m.startStyle && sp.from == pos) { spanStartStyle += " " + m.startStyle; } + if (m.endStyle && sp.to == nextChange) { (endStyles || (endStyles = [])).push(m.endStyle, sp.to); } + // support for the old title property + // https://github.com/codemirror/CodeMirror/pull/5673 + if (m.title) { (attributes || (attributes = {})).title = m.title; } + if (m.attributes) { + for (var attr in m.attributes) + { (attributes || (attributes = {}))[attr] = m.attributes[attr]; } + } + if (m.collapsed && (!collapsed || compareCollapsedMarkers(collapsed.marker, m) < 0)) + { collapsed = sp; } + } else if (sp.from > pos && nextChange > sp.from) { + nextChange = sp.from; + } + } + if (endStyles) { for (var j$1 = 0; j$1 < endStyles.length; j$1 += 2) + { if (endStyles[j$1 + 1] == nextChange) { spanEndStyle += " " + endStyles[j$1]; } } } + + if (!collapsed || collapsed.from == pos) { for (var j$2 = 0; j$2 < foundBookmarks.length; ++j$2) + { buildCollapsedSpan(builder, 0, foundBookmarks[j$2]); } } + if (collapsed && (collapsed.from || 0) == pos) { + buildCollapsedSpan(builder, (collapsed.to == null ? len + 1 : collapsed.to) - pos, + collapsed.marker, collapsed.from == null); + if (collapsed.to == null) { return } + if (collapsed.to == pos) { collapsed = false; } + } + } + if (pos >= len) { break } + + var upto = Math.min(len, nextChange); + while (true) { + if (text) { + var end = pos + text.length; + if (!collapsed) { + var tokenText = end > upto ? text.slice(0, upto - pos) : text; + builder.addToken(builder, tokenText, style ? style + spanStyle : spanStyle, + spanStartStyle, pos + tokenText.length == nextChange ? spanEndStyle : "", css, attributes); + } + if (end >= upto) {text = text.slice(upto - pos); pos = upto; break} + pos = end; + spanStartStyle = ""; + } + text = allText.slice(at, at = styles[i++]); + style = interpretTokenStyle(styles[i++], builder.cm.options); + } + } + } + + + // These objects are used to represent the visible (currently drawn) + // part of the document. A LineView may correspond to multiple + // logical lines, if those are connected by collapsed ranges. + function LineView(doc, line, lineN) { + // The starting line + this.line = line; + // Continuing lines, if any + this.rest = visualLineContinued(line); + // Number of logical lines in this visual line + this.size = this.rest ? lineNo(lst(this.rest)) - lineN + 1 : 1; + this.node = this.text = null; + this.hidden = lineIsHidden(doc, line); + } + + // Create a range of LineView objects for the given lines. + function buildViewArray(cm, from, to) { + var array = [], nextPos; + for (var pos = from; pos < to; pos = nextPos) { + var view = new LineView(cm.doc, getLine(cm.doc, pos), pos); + nextPos = pos + view.size; + array.push(view); + } + return array + } + + var operationGroup = null; + + function pushOperation(op) { + if (operationGroup) { + operationGroup.ops.push(op); + } else { + op.ownsGroup = operationGroup = { + ops: [op], + delayedCallbacks: [] + }; + } + } + + function fireCallbacksForOps(group) { + // Calls delayed callbacks and cursorActivity handlers until no + // new ones appear + var callbacks = group.delayedCallbacks, i = 0; + do { + for (; i < callbacks.length; i++) + { callbacks[i].call(null); } + for (var j = 0; j < group.ops.length; j++) { + var op = group.ops[j]; + if (op.cursorActivityHandlers) + { while (op.cursorActivityCalled < op.cursorActivityHandlers.length) + { op.cursorActivityHandlers[op.cursorActivityCalled++].call(null, op.cm); } } + } + } while (i < callbacks.length) + } + + function finishOperation(op, endCb) { + var group = op.ownsGroup; + if (!group) { return } + + try { fireCallbacksForOps(group); } + finally { + operationGroup = null; + endCb(group); + } + } + + var orphanDelayedCallbacks = null; + + // Often, we want to signal events at a point where we are in the + // middle of some work, but don't want the handler to start calling + // other methods on the editor, which might be in an inconsistent + // state or simply not expect any other events to happen. + // signalLater looks whether there are any handlers, and schedules + // them to be executed when the last operation ends, or, if no + // operation is active, when a timeout fires. + function signalLater(emitter, type /*, values...*/) { + var arr = getHandlers(emitter, type); + if (!arr.length) { return } + var args = Array.prototype.slice.call(arguments, 2), list; + if (operationGroup) { + list = operationGroup.delayedCallbacks; + } else if (orphanDelayedCallbacks) { + list = orphanDelayedCallbacks; + } else { + list = orphanDelayedCallbacks = []; + setTimeout(fireOrphanDelayed, 0); + } + var loop = function ( i ) { + list.push(function () { return arr[i].apply(null, args); }); + }; + + for (var i = 0; i < arr.length; ++i) + loop( i ); + } + + function fireOrphanDelayed() { + var delayed = orphanDelayedCallbacks; + orphanDelayedCallbacks = null; + for (var i = 0; i < delayed.length; ++i) { delayed[i](); } + } + + // When an aspect of a line changes, a string is added to + // lineView.changes. This updates the relevant part of the line's + // DOM structure. + function updateLineForChanges(cm, lineView, lineN, dims) { + for (var j = 0; j < lineView.changes.length; j++) { + var type = lineView.changes[j]; + if (type == "text") { updateLineText(cm, lineView); } + else if (type == "gutter") { updateLineGutter(cm, lineView, lineN, dims); } + else if (type == "class") { updateLineClasses(cm, lineView); } + else if (type == "widget") { updateLineWidgets(cm, lineView, dims); } + } + lineView.changes = null; + } + + // Lines with gutter elements, widgets or a background class need to + // be wrapped, and have the extra elements added to the wrapper div + function ensureLineWrapped(lineView) { + if (lineView.node == lineView.text) { + lineView.node = elt("div", null, null, "position: relative"); + if (lineView.text.parentNode) + { lineView.text.parentNode.replaceChild(lineView.node, lineView.text); } + lineView.node.appendChild(lineView.text); + if (ie && ie_version < 8) { lineView.node.style.zIndex = 2; } + } + return lineView.node + } + + function updateLineBackground(cm, lineView) { + var cls = lineView.bgClass ? lineView.bgClass + " " + (lineView.line.bgClass || "") : lineView.line.bgClass; + if (cls) { cls += " CodeMirror-linebackground"; } + if (lineView.background) { + if (cls) { lineView.background.className = cls; } + else { lineView.background.parentNode.removeChild(lineView.background); lineView.background = null; } + } else if (cls) { + var wrap = ensureLineWrapped(lineView); + lineView.background = wrap.insertBefore(elt("div", null, cls), wrap.firstChild); + cm.display.input.setUneditable(lineView.background); + } + } + + // Wrapper around buildLineContent which will reuse the structure + // in display.externalMeasured when possible. + function getLineContent(cm, lineView) { + var ext = cm.display.externalMeasured; + if (ext && ext.line == lineView.line) { + cm.display.externalMeasured = null; + lineView.measure = ext.measure; + return ext.built + } + return buildLineContent(cm, lineView) + } + + // Redraw the line's text. Interacts with the background and text + // classes because the mode may output tokens that influence these + // classes. + function updateLineText(cm, lineView) { + var cls = lineView.text.className; + var built = getLineContent(cm, lineView); + if (lineView.text == lineView.node) { lineView.node = built.pre; } + lineView.text.parentNode.replaceChild(built.pre, lineView.text); + lineView.text = built.pre; + if (built.bgClass != lineView.bgClass || built.textClass != lineView.textClass) { + lineView.bgClass = built.bgClass; + lineView.textClass = built.textClass; + updateLineClasses(cm, lineView); + } else if (cls) { + lineView.text.className = cls; + } + } + + function updateLineClasses(cm, lineView) { + updateLineBackground(cm, lineView); + if (lineView.line.wrapClass) + { ensureLineWrapped(lineView).className = lineView.line.wrapClass; } + else if (lineView.node != lineView.text) + { lineView.node.className = ""; } + var textClass = lineView.textClass ? lineView.textClass + " " + (lineView.line.textClass || "") : lineView.line.textClass; + lineView.text.className = textClass || ""; + } + + function updateLineGutter(cm, lineView, lineN, dims) { + if (lineView.gutter) { + lineView.node.removeChild(lineView.gutter); + lineView.gutter = null; + } + if (lineView.gutterBackground) { + lineView.node.removeChild(lineView.gutterBackground); + lineView.gutterBackground = null; + } + if (lineView.line.gutterClass) { + var wrap = ensureLineWrapped(lineView); + lineView.gutterBackground = elt("div", null, "CodeMirror-gutter-background " + lineView.line.gutterClass, + ("left: " + (cm.options.fixedGutter ? dims.fixedPos : -dims.gutterTotalWidth) + "px; width: " + (dims.gutterTotalWidth) + "px")); + cm.display.input.setUneditable(lineView.gutterBackground); + wrap.insertBefore(lineView.gutterBackground, lineView.text); + } + var markers = lineView.line.gutterMarkers; + if (cm.options.lineNumbers || markers) { + var wrap$1 = ensureLineWrapped(lineView); + var gutterWrap = lineView.gutter = elt("div", null, "CodeMirror-gutter-wrapper", ("left: " + (cm.options.fixedGutter ? dims.fixedPos : -dims.gutterTotalWidth) + "px")); + cm.display.input.setUneditable(gutterWrap); + wrap$1.insertBefore(gutterWrap, lineView.text); + if (lineView.line.gutterClass) + { gutterWrap.className += " " + lineView.line.gutterClass; } + if (cm.options.lineNumbers && (!markers || !markers["CodeMirror-linenumbers"])) + { lineView.lineNumber = gutterWrap.appendChild( + elt("div", lineNumberFor(cm.options, lineN), + "CodeMirror-linenumber CodeMirror-gutter-elt", + ("left: " + (dims.gutterLeft["CodeMirror-linenumbers"]) + "px; width: " + (cm.display.lineNumInnerWidth) + "px"))); } + if (markers) { for (var k = 0; k < cm.display.gutterSpecs.length; ++k) { + var id = cm.display.gutterSpecs[k].className, found = markers.hasOwnProperty(id) && markers[id]; + if (found) + { gutterWrap.appendChild(elt("div", [found], "CodeMirror-gutter-elt", + ("left: " + (dims.gutterLeft[id]) + "px; width: " + (dims.gutterWidth[id]) + "px"))); } + } } + } + } + + function updateLineWidgets(cm, lineView, dims) { + if (lineView.alignable) { lineView.alignable = null; } + var isWidget = classTest("CodeMirror-linewidget"); + for (var node = lineView.node.firstChild, next = (void 0); node; node = next) { + next = node.nextSibling; + if (isWidget.test(node.className)) { lineView.node.removeChild(node); } + } + insertLineWidgets(cm, lineView, dims); + } + + // Build a line's DOM representation from scratch + function buildLineElement(cm, lineView, lineN, dims) { + var built = getLineContent(cm, lineView); + lineView.text = lineView.node = built.pre; + if (built.bgClass) { lineView.bgClass = built.bgClass; } + if (built.textClass) { lineView.textClass = built.textClass; } + + updateLineClasses(cm, lineView); + updateLineGutter(cm, lineView, lineN, dims); + insertLineWidgets(cm, lineView, dims); + return lineView.node + } + + // A lineView may contain multiple logical lines (when merged by + // collapsed spans). The widgets for all of them need to be drawn. + function insertLineWidgets(cm, lineView, dims) { + insertLineWidgetsFor(cm, lineView.line, lineView, dims, true); + if (lineView.rest) { for (var i = 0; i < lineView.rest.length; i++) + { insertLineWidgetsFor(cm, lineView.rest[i], lineView, dims, false); } } + } + + function insertLineWidgetsFor(cm, line, lineView, dims, allowAbove) { + if (!line.widgets) { return } + var wrap = ensureLineWrapped(lineView); + for (var i = 0, ws = line.widgets; i < ws.length; ++i) { + var widget = ws[i], node = elt("div", [widget.node], "CodeMirror-linewidget" + (widget.className ? " " + widget.className : "")); + if (!widget.handleMouseEvents) { node.setAttribute("cm-ignore-events", "true"); } + positionLineWidget(widget, node, lineView, dims); + cm.display.input.setUneditable(node); + if (allowAbove && widget.above) + { wrap.insertBefore(node, lineView.gutter || lineView.text); } + else + { wrap.appendChild(node); } + signalLater(widget, "redraw"); + } + } + + function positionLineWidget(widget, node, lineView, dims) { + if (widget.noHScroll) { + (lineView.alignable || (lineView.alignable = [])).push(node); + var width = dims.wrapperWidth; + node.style.left = dims.fixedPos + "px"; + if (!widget.coverGutter) { + width -= dims.gutterTotalWidth; + node.style.paddingLeft = dims.gutterTotalWidth + "px"; + } + node.style.width = width + "px"; + } + if (widget.coverGutter) { + node.style.zIndex = 5; + node.style.position = "relative"; + if (!widget.noHScroll) { node.style.marginLeft = -dims.gutterTotalWidth + "px"; } + } + } + + function widgetHeight(widget) { + if (widget.height != null) { return widget.height } + var cm = widget.doc.cm; + if (!cm) { return 0 } + if (!contains(document.body, widget.node)) { + var parentStyle = "position: relative;"; + if (widget.coverGutter) + { parentStyle += "margin-left: -" + cm.display.gutters.offsetWidth + "px;"; } + if (widget.noHScroll) + { parentStyle += "width: " + cm.display.wrapper.clientWidth + "px;"; } + removeChildrenAndAdd(cm.display.measure, elt("div", [widget.node], null, parentStyle)); + } + return widget.height = widget.node.parentNode.offsetHeight + } + + // Return true when the given mouse event happened in a widget + function eventInWidget(display, e) { + for (var n = e_target(e); n != display.wrapper; n = n.parentNode) { + if (!n || (n.nodeType == 1 && n.getAttribute("cm-ignore-events") == "true") || + (n.parentNode == display.sizer && n != display.mover)) + { return true } + } + } + + // POSITION MEASUREMENT + + function paddingTop(display) {return display.lineSpace.offsetTop} + function paddingVert(display) {return display.mover.offsetHeight - display.lineSpace.offsetHeight} + function paddingH(display) { + if (display.cachedPaddingH) { return display.cachedPaddingH } + var e = removeChildrenAndAdd(display.measure, elt("pre", "x", "CodeMirror-line-like")); + var style = window.getComputedStyle ? window.getComputedStyle(e) : e.currentStyle; + var data = {left: parseInt(style.paddingLeft), right: parseInt(style.paddingRight)}; + if (!isNaN(data.left) && !isNaN(data.right)) { display.cachedPaddingH = data; } + return data + } + + function scrollGap(cm) { return scrollerGap - cm.display.nativeBarWidth } + function displayWidth(cm) { + return cm.display.scroller.clientWidth - scrollGap(cm) - cm.display.barWidth + } + function displayHeight(cm) { + return cm.display.scroller.clientHeight - scrollGap(cm) - cm.display.barHeight + } + + // Ensure the lineView.wrapping.heights array is populated. This is + // an array of bottom offsets for the lines that make up a drawn + // line. When lineWrapping is on, there might be more than one + // height. + function ensureLineHeights(cm, lineView, rect) { + var wrapping = cm.options.lineWrapping; + var curWidth = wrapping && displayWidth(cm); + if (!lineView.measure.heights || wrapping && lineView.measure.width != curWidth) { + var heights = lineView.measure.heights = []; + if (wrapping) { + lineView.measure.width = curWidth; + var rects = lineView.text.firstChild.getClientRects(); + for (var i = 0; i < rects.length - 1; i++) { + var cur = rects[i], next = rects[i + 1]; + if (Math.abs(cur.bottom - next.bottom) > 2) + { heights.push((cur.bottom + next.top) / 2 - rect.top); } + } + } + heights.push(rect.bottom - rect.top); + } + } + + // Find a line map (mapping character offsets to text nodes) and a + // measurement cache for the given line number. (A line view might + // contain multiple lines when collapsed ranges are present.) + function mapFromLineView(lineView, line, lineN) { + if (lineView.line == line) + { return {map: lineView.measure.map, cache: lineView.measure.cache} } + for (var i = 0; i < lineView.rest.length; i++) + { if (lineView.rest[i] == line) + { return {map: lineView.measure.maps[i], cache: lineView.measure.caches[i]} } } + for (var i$1 = 0; i$1 < lineView.rest.length; i$1++) + { if (lineNo(lineView.rest[i$1]) > lineN) + { return {map: lineView.measure.maps[i$1], cache: lineView.measure.caches[i$1], before: true} } } + } + + // Render a line into the hidden node display.externalMeasured. Used + // when measurement is needed for a line that's not in the viewport. + function updateExternalMeasurement(cm, line) { + line = visualLine(line); + var lineN = lineNo(line); + var view = cm.display.externalMeasured = new LineView(cm.doc, line, lineN); + view.lineN = lineN; + var built = view.built = buildLineContent(cm, view); + view.text = built.pre; + removeChildrenAndAdd(cm.display.lineMeasure, built.pre); + return view + } + + // Get a {top, bottom, left, right} box (in line-local coordinates) + // for a given character. + function measureChar(cm, line, ch, bias) { + return measureCharPrepared(cm, prepareMeasureForLine(cm, line), ch, bias) + } + + // Find a line view that corresponds to the given line number. + function findViewForLine(cm, lineN) { + if (lineN >= cm.display.viewFrom && lineN < cm.display.viewTo) + { return cm.display.view[findViewIndex(cm, lineN)] } + var ext = cm.display.externalMeasured; + if (ext && lineN >= ext.lineN && lineN < ext.lineN + ext.size) + { return ext } + } + + // Measurement can be split in two steps, the set-up work that + // applies to the whole line, and the measurement of the actual + // character. Functions like coordsChar, that need to do a lot of + // measurements in a row, can thus ensure that the set-up work is + // only done once. + function prepareMeasureForLine(cm, line) { + var lineN = lineNo(line); + var view = findViewForLine(cm, lineN); + if (view && !view.text) { + view = null; + } else if (view && view.changes) { + updateLineForChanges(cm, view, lineN, getDimensions(cm)); + cm.curOp.forceUpdate = true; + } + if (!view) + { view = updateExternalMeasurement(cm, line); } + + var info = mapFromLineView(view, line, lineN); + return { + line: line, view: view, rect: null, + map: info.map, cache: info.cache, before: info.before, + hasHeights: false + } + } + + // Given a prepared measurement object, measures the position of an + // actual character (or fetches it from the cache). + function measureCharPrepared(cm, prepared, ch, bias, varHeight) { + if (prepared.before) { ch = -1; } + var key = ch + (bias || ""), found; + if (prepared.cache.hasOwnProperty(key)) { + found = prepared.cache[key]; + } else { + if (!prepared.rect) + { prepared.rect = prepared.view.text.getBoundingClientRect(); } + if (!prepared.hasHeights) { + ensureLineHeights(cm, prepared.view, prepared.rect); + prepared.hasHeights = true; + } + found = measureCharInner(cm, prepared, ch, bias); + if (!found.bogus) { prepared.cache[key] = found; } + } + return {left: found.left, right: found.right, + top: varHeight ? found.rtop : found.top, + bottom: varHeight ? found.rbottom : found.bottom} + } + + var nullRect = {left: 0, right: 0, top: 0, bottom: 0}; + + function nodeAndOffsetInLineMap(map$$1, ch, bias) { + var node, start, end, collapse, mStart, mEnd; + // First, search the line map for the text node corresponding to, + // or closest to, the target character. + for (var i = 0; i < map$$1.length; i += 3) { + mStart = map$$1[i]; + mEnd = map$$1[i + 1]; + if (ch < mStart) { + start = 0; end = 1; + collapse = "left"; + } else if (ch < mEnd) { + start = ch - mStart; + end = start + 1; + } else if (i == map$$1.length - 3 || ch == mEnd && map$$1[i + 3] > ch) { + end = mEnd - mStart; + start = end - 1; + if (ch >= mEnd) { collapse = "right"; } + } + if (start != null) { + node = map$$1[i + 2]; + if (mStart == mEnd && bias == (node.insertLeft ? "left" : "right")) + { collapse = bias; } + if (bias == "left" && start == 0) + { while (i && map$$1[i - 2] == map$$1[i - 3] && map$$1[i - 1].insertLeft) { + node = map$$1[(i -= 3) + 2]; + collapse = "left"; + } } + if (bias == "right" && start == mEnd - mStart) + { while (i < map$$1.length - 3 && map$$1[i + 3] == map$$1[i + 4] && !map$$1[i + 5].insertLeft) { + node = map$$1[(i += 3) + 2]; + collapse = "right"; + } } + break + } + } + return {node: node, start: start, end: end, collapse: collapse, coverStart: mStart, coverEnd: mEnd} + } + + function getUsefulRect(rects, bias) { + var rect = nullRect; + if (bias == "left") { for (var i = 0; i < rects.length; i++) { + if ((rect = rects[i]).left != rect.right) { break } + } } else { for (var i$1 = rects.length - 1; i$1 >= 0; i$1--) { + if ((rect = rects[i$1]).left != rect.right) { break } + } } + return rect + } + + function measureCharInner(cm, prepared, ch, bias) { + var place = nodeAndOffsetInLineMap(prepared.map, ch, bias); + var node = place.node, start = place.start, end = place.end, collapse = place.collapse; + + var rect; + if (node.nodeType == 3) { // If it is a text node, use a range to retrieve the coordinates. + for (var i$1 = 0; i$1 < 4; i$1++) { // Retry a maximum of 4 times when nonsense rectangles are returned + while (start && isExtendingChar(prepared.line.text.charAt(place.coverStart + start))) { --start; } + while (place.coverStart + end < place.coverEnd && isExtendingChar(prepared.line.text.charAt(place.coverStart + end))) { ++end; } + if (ie && ie_version < 9 && start == 0 && end == place.coverEnd - place.coverStart) + { rect = node.parentNode.getBoundingClientRect(); } + else + { rect = getUsefulRect(range(node, start, end).getClientRects(), bias); } + if (rect.left || rect.right || start == 0) { break } + end = start; + start = start - 1; + collapse = "right"; + } + if (ie && ie_version < 11) { rect = maybeUpdateRectForZooming(cm.display.measure, rect); } + } else { // If it is a widget, simply get the box for the whole widget. + if (start > 0) { collapse = bias = "right"; } + var rects; + if (cm.options.lineWrapping && (rects = node.getClientRects()).length > 1) + { rect = rects[bias == "right" ? rects.length - 1 : 0]; } + else + { rect = node.getBoundingClientRect(); } + } + if (ie && ie_version < 9 && !start && (!rect || !rect.left && !rect.right)) { + var rSpan = node.parentNode.getClientRects()[0]; + if (rSpan) + { rect = {left: rSpan.left, right: rSpan.left + charWidth(cm.display), top: rSpan.top, bottom: rSpan.bottom}; } + else + { rect = nullRect; } + } + + var rtop = rect.top - prepared.rect.top, rbot = rect.bottom - prepared.rect.top; + var mid = (rtop + rbot) / 2; + var heights = prepared.view.measure.heights; + var i = 0; + for (; i < heights.length - 1; i++) + { if (mid < heights[i]) { break } } + var top = i ? heights[i - 1] : 0, bot = heights[i]; + var result = {left: (collapse == "right" ? rect.right : rect.left) - prepared.rect.left, + right: (collapse == "left" ? rect.left : rect.right) - prepared.rect.left, + top: top, bottom: bot}; + if (!rect.left && !rect.right) { result.bogus = true; } + if (!cm.options.singleCursorHeightPerLine) { result.rtop = rtop; result.rbottom = rbot; } + + return result + } + + // Work around problem with bounding client rects on ranges being + // returned incorrectly when zoomed on IE10 and below. + function maybeUpdateRectForZooming(measure, rect) { + if (!window.screen || screen.logicalXDPI == null || + screen.logicalXDPI == screen.deviceXDPI || !hasBadZoomedRects(measure)) + { return rect } + var scaleX = screen.logicalXDPI / screen.deviceXDPI; + var scaleY = screen.logicalYDPI / screen.deviceYDPI; + return {left: rect.left * scaleX, right: rect.right * scaleX, + top: rect.top * scaleY, bottom: rect.bottom * scaleY} + } + + function clearLineMeasurementCacheFor(lineView) { + if (lineView.measure) { + lineView.measure.cache = {}; + lineView.measure.heights = null; + if (lineView.rest) { for (var i = 0; i < lineView.rest.length; i++) + { lineView.measure.caches[i] = {}; } } + } + } + + function clearLineMeasurementCache(cm) { + cm.display.externalMeasure = null; + removeChildren(cm.display.lineMeasure); + for (var i = 0; i < cm.display.view.length; i++) + { clearLineMeasurementCacheFor(cm.display.view[i]); } + } + + function clearCaches(cm) { + clearLineMeasurementCache(cm); + cm.display.cachedCharWidth = cm.display.cachedTextHeight = cm.display.cachedPaddingH = null; + if (!cm.options.lineWrapping) { cm.display.maxLineChanged = true; } + cm.display.lineNumChars = null; + } + + function pageScrollX() { + // Work around https://bugs.chromium.org/p/chromium/issues/detail?id=489206 + // which causes page_Offset and bounding client rects to use + // different reference viewports and invalidate our calculations. + if (chrome && android) { return -(document.body.getBoundingClientRect().left - parseInt(getComputedStyle(document.body).marginLeft)) } + return window.pageXOffset || (document.documentElement || document.body).scrollLeft + } + function pageScrollY() { + if (chrome && android) { return -(document.body.getBoundingClientRect().top - parseInt(getComputedStyle(document.body).marginTop)) } + return window.pageYOffset || (document.documentElement || document.body).scrollTop + } + + function widgetTopHeight(lineObj) { + var height = 0; + if (lineObj.widgets) { for (var i = 0; i < lineObj.widgets.length; ++i) { if (lineObj.widgets[i].above) + { height += widgetHeight(lineObj.widgets[i]); } } } + return height + } + + // Converts a {top, bottom, left, right} box from line-local + // coordinates into another coordinate system. Context may be one of + // "line", "div" (display.lineDiv), "local"./null (editor), "window", + // or "page". + function intoCoordSystem(cm, lineObj, rect, context, includeWidgets) { + if (!includeWidgets) { + var height = widgetTopHeight(lineObj); + rect.top += height; rect.bottom += height; + } + if (context == "line") { return rect } + if (!context) { context = "local"; } + var yOff = heightAtLine(lineObj); + if (context == "local") { yOff += paddingTop(cm.display); } + else { yOff -= cm.display.viewOffset; } + if (context == "page" || context == "window") { + var lOff = cm.display.lineSpace.getBoundingClientRect(); + yOff += lOff.top + (context == "window" ? 0 : pageScrollY()); + var xOff = lOff.left + (context == "window" ? 0 : pageScrollX()); + rect.left += xOff; rect.right += xOff; + } + rect.top += yOff; rect.bottom += yOff; + return rect + } + + // Coverts a box from "div" coords to another coordinate system. + // Context may be "window", "page", "div", or "local"./null. + function fromCoordSystem(cm, coords, context) { + if (context == "div") { return coords } + var left = coords.left, top = coords.top; + // First move into "page" coordinate system + if (context == "page") { + left -= pageScrollX(); + top -= pageScrollY(); + } else if (context == "local" || !context) { + var localBox = cm.display.sizer.getBoundingClientRect(); + left += localBox.left; + top += localBox.top; + } + + var lineSpaceBox = cm.display.lineSpace.getBoundingClientRect(); + return {left: left - lineSpaceBox.left, top: top - lineSpaceBox.top} + } + + function charCoords(cm, pos, context, lineObj, bias) { + if (!lineObj) { lineObj = getLine(cm.doc, pos.line); } + return intoCoordSystem(cm, lineObj, measureChar(cm, lineObj, pos.ch, bias), context) + } + + // Returns a box for a given cursor position, which may have an + // 'other' property containing the position of the secondary cursor + // on a bidi boundary. + // A cursor Pos(line, char, "before") is on the same visual line as `char - 1` + // and after `char - 1` in writing order of `char - 1` + // A cursor Pos(line, char, "after") is on the same visual line as `char` + // and before `char` in writing order of `char` + // Examples (upper-case letters are RTL, lower-case are LTR): + // Pos(0, 1, ...) + // before after + // ab a|b a|b + // aB a|B aB| + // Ab |Ab A|b + // AB B|A B|A + // Every position after the last character on a line is considered to stick + // to the last character on the line. + function cursorCoords(cm, pos, context, lineObj, preparedMeasure, varHeight) { + lineObj = lineObj || getLine(cm.doc, pos.line); + if (!preparedMeasure) { preparedMeasure = prepareMeasureForLine(cm, lineObj); } + function get(ch, right) { + var m = measureCharPrepared(cm, preparedMeasure, ch, right ? "right" : "left", varHeight); + if (right) { m.left = m.right; } else { m.right = m.left; } + return intoCoordSystem(cm, lineObj, m, context) + } + var order = getOrder(lineObj, cm.doc.direction), ch = pos.ch, sticky = pos.sticky; + if (ch >= lineObj.text.length) { + ch = lineObj.text.length; + sticky = "before"; + } else if (ch <= 0) { + ch = 0; + sticky = "after"; + } + if (!order) { return get(sticky == "before" ? ch - 1 : ch, sticky == "before") } + + function getBidi(ch, partPos, invert) { + var part = order[partPos], right = part.level == 1; + return get(invert ? ch - 1 : ch, right != invert) + } + var partPos = getBidiPartAt(order, ch, sticky); + var other = bidiOther; + var val = getBidi(ch, partPos, sticky == "before"); + if (other != null) { val.other = getBidi(ch, other, sticky != "before"); } + return val + } + + // Used to cheaply estimate the coordinates for a position. Used for + // intermediate scroll updates. + function estimateCoords(cm, pos) { + var left = 0; + pos = clipPos(cm.doc, pos); + if (!cm.options.lineWrapping) { left = charWidth(cm.display) * pos.ch; } + var lineObj = getLine(cm.doc, pos.line); + var top = heightAtLine(lineObj) + paddingTop(cm.display); + return {left: left, right: left, top: top, bottom: top + lineObj.height} + } + + // Positions returned by coordsChar contain some extra information. + // xRel is the relative x position of the input coordinates compared + // to the found position (so xRel > 0 means the coordinates are to + // the right of the character position, for example). When outside + // is true, that means the coordinates lie outside the line's + // vertical range. + function PosWithInfo(line, ch, sticky, outside, xRel) { + var pos = Pos(line, ch, sticky); + pos.xRel = xRel; + if (outside) { pos.outside = outside; } + return pos + } + + // Compute the character position closest to the given coordinates. + // Input must be lineSpace-local ("div" coordinate system). + function coordsChar(cm, x, y) { + var doc = cm.doc; + y += cm.display.viewOffset; + if (y < 0) { return PosWithInfo(doc.first, 0, null, -1, -1) } + var lineN = lineAtHeight(doc, y), last = doc.first + doc.size - 1; + if (lineN > last) + { return PosWithInfo(doc.first + doc.size - 1, getLine(doc, last).text.length, null, 1, 1) } + if (x < 0) { x = 0; } + + var lineObj = getLine(doc, lineN); + for (;;) { + var found = coordsCharInner(cm, lineObj, lineN, x, y); + var collapsed = collapsedSpanAround(lineObj, found.ch + (found.xRel > 0 || found.outside > 0 ? 1 : 0)); + if (!collapsed) { return found } + var rangeEnd = collapsed.find(1); + if (rangeEnd.line == lineN) { return rangeEnd } + lineObj = getLine(doc, lineN = rangeEnd.line); + } + } + + function wrappedLineExtent(cm, lineObj, preparedMeasure, y) { + y -= widgetTopHeight(lineObj); + var end = lineObj.text.length; + var begin = findFirst(function (ch) { return measureCharPrepared(cm, preparedMeasure, ch - 1).bottom <= y; }, end, 0); + end = findFirst(function (ch) { return measureCharPrepared(cm, preparedMeasure, ch).top > y; }, begin, end); + return {begin: begin, end: end} + } + + function wrappedLineExtentChar(cm, lineObj, preparedMeasure, target) { + if (!preparedMeasure) { preparedMeasure = prepareMeasureForLine(cm, lineObj); } + var targetTop = intoCoordSystem(cm, lineObj, measureCharPrepared(cm, preparedMeasure, target), "line").top; + return wrappedLineExtent(cm, lineObj, preparedMeasure, targetTop) + } + + // Returns true if the given side of a box is after the given + // coordinates, in top-to-bottom, left-to-right order. + function boxIsAfter(box, x, y, left) { + return box.bottom <= y ? false : box.top > y ? true : (left ? box.left : box.right) > x + } + + function coordsCharInner(cm, lineObj, lineNo$$1, x, y) { + // Move y into line-local coordinate space + y -= heightAtLine(lineObj); + var preparedMeasure = prepareMeasureForLine(cm, lineObj); + // When directly calling `measureCharPrepared`, we have to adjust + // for the widgets at this line. + var widgetHeight$$1 = widgetTopHeight(lineObj); + var begin = 0, end = lineObj.text.length, ltr = true; + + var order = getOrder(lineObj, cm.doc.direction); + // If the line isn't plain left-to-right text, first figure out + // which bidi section the coordinates fall into. + if (order) { + var part = (cm.options.lineWrapping ? coordsBidiPartWrapped : coordsBidiPart) + (cm, lineObj, lineNo$$1, preparedMeasure, order, x, y); + ltr = part.level != 1; + // The awkward -1 offsets are needed because findFirst (called + // on these below) will treat its first bound as inclusive, + // second as exclusive, but we want to actually address the + // characters in the part's range + begin = ltr ? part.from : part.to - 1; + end = ltr ? part.to : part.from - 1; + } + + // A binary search to find the first character whose bounding box + // starts after the coordinates. If we run across any whose box wrap + // the coordinates, store that. + var chAround = null, boxAround = null; + var ch = findFirst(function (ch) { + var box = measureCharPrepared(cm, preparedMeasure, ch); + box.top += widgetHeight$$1; box.bottom += widgetHeight$$1; + if (!boxIsAfter(box, x, y, false)) { return false } + if (box.top <= y && box.left <= x) { + chAround = ch; + boxAround = box; + } + return true + }, begin, end); + + var baseX, sticky, outside = false; + // If a box around the coordinates was found, use that + if (boxAround) { + // Distinguish coordinates nearer to the left or right side of the box + var atLeft = x - boxAround.left < boxAround.right - x, atStart = atLeft == ltr; + ch = chAround + (atStart ? 0 : 1); + sticky = atStart ? "after" : "before"; + baseX = atLeft ? boxAround.left : boxAround.right; + } else { + // (Adjust for extended bound, if necessary.) + if (!ltr && (ch == end || ch == begin)) { ch++; } + // To determine which side to associate with, get the box to the + // left of the character and compare it's vertical position to the + // coordinates + sticky = ch == 0 ? "after" : ch == lineObj.text.length ? "before" : + (measureCharPrepared(cm, preparedMeasure, ch - (ltr ? 1 : 0)).bottom + widgetHeight$$1 <= y) == ltr ? + "after" : "before"; + // Now get accurate coordinates for this place, in order to get a + // base X position + var coords = cursorCoords(cm, Pos(lineNo$$1, ch, sticky), "line", lineObj, preparedMeasure); + baseX = coords.left; + outside = y < coords.top ? -1 : y >= coords.bottom ? 1 : 0; + } + + ch = skipExtendingChars(lineObj.text, ch, 1); + return PosWithInfo(lineNo$$1, ch, sticky, outside, x - baseX) + } + + function coordsBidiPart(cm, lineObj, lineNo$$1, preparedMeasure, order, x, y) { + // Bidi parts are sorted left-to-right, and in a non-line-wrapping + // situation, we can take this ordering to correspond to the visual + // ordering. This finds the first part whose end is after the given + // coordinates. + var index = findFirst(function (i) { + var part = order[i], ltr = part.level != 1; + return boxIsAfter(cursorCoords(cm, Pos(lineNo$$1, ltr ? part.to : part.from, ltr ? "before" : "after"), + "line", lineObj, preparedMeasure), x, y, true) + }, 0, order.length - 1); + var part = order[index]; + // If this isn't the first part, the part's start is also after + // the coordinates, and the coordinates aren't on the same line as + // that start, move one part back. + if (index > 0) { + var ltr = part.level != 1; + var start = cursorCoords(cm, Pos(lineNo$$1, ltr ? part.from : part.to, ltr ? "after" : "before"), + "line", lineObj, preparedMeasure); + if (boxIsAfter(start, x, y, true) && start.top > y) + { part = order[index - 1]; } + } + return part + } + + function coordsBidiPartWrapped(cm, lineObj, _lineNo, preparedMeasure, order, x, y) { + // In a wrapped line, rtl text on wrapping boundaries can do things + // that don't correspond to the ordering in our `order` array at + // all, so a binary search doesn't work, and we want to return a + // part that only spans one line so that the binary search in + // coordsCharInner is safe. As such, we first find the extent of the + // wrapped line, and then do a flat search in which we discard any + // spans that aren't on the line. + var ref = wrappedLineExtent(cm, lineObj, preparedMeasure, y); + var begin = ref.begin; + var end = ref.end; + if (/\s/.test(lineObj.text.charAt(end - 1))) { end--; } + var part = null, closestDist = null; + for (var i = 0; i < order.length; i++) { + var p = order[i]; + if (p.from >= end || p.to <= begin) { continue } + var ltr = p.level != 1; + var endX = measureCharPrepared(cm, preparedMeasure, ltr ? Math.min(end, p.to) - 1 : Math.max(begin, p.from)).right; + // Weigh against spans ending before this, so that they are only + // picked if nothing ends after + var dist = endX < x ? x - endX + 1e9 : endX - x; + if (!part || closestDist > dist) { + part = p; + closestDist = dist; + } + } + if (!part) { part = order[order.length - 1]; } + // Clip the part to the wrapped line. + if (part.from < begin) { part = {from: begin, to: part.to, level: part.level}; } + if (part.to > end) { part = {from: part.from, to: end, level: part.level}; } + return part + } + + var measureText; + // Compute the default text height. + function textHeight(display) { + if (display.cachedTextHeight != null) { return display.cachedTextHeight } + if (measureText == null) { + measureText = elt("pre", null, "CodeMirror-line-like"); + // Measure a bunch of lines, for browsers that compute + // fractional heights. + for (var i = 0; i < 49; ++i) { + measureText.appendChild(document.createTextNode("x")); + measureText.appendChild(elt("br")); + } + measureText.appendChild(document.createTextNode("x")); + } + removeChildrenAndAdd(display.measure, measureText); + var height = measureText.offsetHeight / 50; + if (height > 3) { display.cachedTextHeight = height; } + removeChildren(display.measure); + return height || 1 + } + + // Compute the default character width. + function charWidth(display) { + if (display.cachedCharWidth != null) { return display.cachedCharWidth } + var anchor = elt("span", "xxxxxxxxxx"); + var pre = elt("pre", [anchor], "CodeMirror-line-like"); + removeChildrenAndAdd(display.measure, pre); + var rect = anchor.getBoundingClientRect(), width = (rect.right - rect.left) / 10; + if (width > 2) { display.cachedCharWidth = width; } + return width || 10 + } + + // Do a bulk-read of the DOM positions and sizes needed to draw the + // view, so that we don't interleave reading and writing to the DOM. + function getDimensions(cm) { + var d = cm.display, left = {}, width = {}; + var gutterLeft = d.gutters.clientLeft; + for (var n = d.gutters.firstChild, i = 0; n; n = n.nextSibling, ++i) { + var id = cm.display.gutterSpecs[i].className; + left[id] = n.offsetLeft + n.clientLeft + gutterLeft; + width[id] = n.clientWidth; + } + return {fixedPos: compensateForHScroll(d), + gutterTotalWidth: d.gutters.offsetWidth, + gutterLeft: left, + gutterWidth: width, + wrapperWidth: d.wrapper.clientWidth} + } + + // Computes display.scroller.scrollLeft + display.gutters.offsetWidth, + // but using getBoundingClientRect to get a sub-pixel-accurate + // result. + function compensateForHScroll(display) { + return display.scroller.getBoundingClientRect().left - display.sizer.getBoundingClientRect().left + } + + // Returns a function that estimates the height of a line, to use as + // first approximation until the line becomes visible (and is thus + // properly measurable). + function estimateHeight(cm) { + var th = textHeight(cm.display), wrapping = cm.options.lineWrapping; + var perLine = wrapping && Math.max(5, cm.display.scroller.clientWidth / charWidth(cm.display) - 3); + return function (line) { + if (lineIsHidden(cm.doc, line)) { return 0 } + + var widgetsHeight = 0; + if (line.widgets) { for (var i = 0; i < line.widgets.length; i++) { + if (line.widgets[i].height) { widgetsHeight += line.widgets[i].height; } + } } + + if (wrapping) + { return widgetsHeight + (Math.ceil(line.text.length / perLine) || 1) * th } + else + { return widgetsHeight + th } + } + } + + function estimateLineHeights(cm) { + var doc = cm.doc, est = estimateHeight(cm); + doc.iter(function (line) { + var estHeight = est(line); + if (estHeight != line.height) { updateLineHeight(line, estHeight); } + }); + } + + // Given a mouse event, find the corresponding position. If liberal + // is false, it checks whether a gutter or scrollbar was clicked, + // and returns null if it was. forRect is used by rectangular + // selections, and tries to estimate a character position even for + // coordinates beyond the right of the text. + function posFromMouse(cm, e, liberal, forRect) { + var display = cm.display; + if (!liberal && e_target(e).getAttribute("cm-not-content") == "true") { return null } + + var x, y, space = display.lineSpace.getBoundingClientRect(); + // Fails unpredictably on IE[67] when mouse is dragged around quickly. + try { x = e.clientX - space.left; y = e.clientY - space.top; } + catch (e) { return null } + var coords = coordsChar(cm, x, y), line; + if (forRect && coords.xRel > 0 && (line = getLine(cm.doc, coords.line).text).length == coords.ch) { + var colDiff = countColumn(line, line.length, cm.options.tabSize) - line.length; + coords = Pos(coords.line, Math.max(0, Math.round((x - paddingH(cm.display).left) / charWidth(cm.display)) - colDiff)); + } + return coords + } + + // Find the view element corresponding to a given line. Return null + // when the line isn't visible. + function findViewIndex(cm, n) { + if (n >= cm.display.viewTo) { return null } + n -= cm.display.viewFrom; + if (n < 0) { return null } + var view = cm.display.view; + for (var i = 0; i < view.length; i++) { + n -= view[i].size; + if (n < 0) { return i } + } + } + + // Updates the display.view data structure for a given change to the + // document. From and to are in pre-change coordinates. Lendiff is + // the amount of lines added or subtracted by the change. This is + // used for changes that span multiple lines, or change the way + // lines are divided into visual lines. regLineChange (below) + // registers single-line changes. + function regChange(cm, from, to, lendiff) { + if (from == null) { from = cm.doc.first; } + if (to == null) { to = cm.doc.first + cm.doc.size; } + if (!lendiff) { lendiff = 0; } + + var display = cm.display; + if (lendiff && to < display.viewTo && + (display.updateLineNumbers == null || display.updateLineNumbers > from)) + { display.updateLineNumbers = from; } + + cm.curOp.viewChanged = true; + + if (from >= display.viewTo) { // Change after + if (sawCollapsedSpans && visualLineNo(cm.doc, from) < display.viewTo) + { resetView(cm); } + } else if (to <= display.viewFrom) { // Change before + if (sawCollapsedSpans && visualLineEndNo(cm.doc, to + lendiff) > display.viewFrom) { + resetView(cm); + } else { + display.viewFrom += lendiff; + display.viewTo += lendiff; + } + } else if (from <= display.viewFrom && to >= display.viewTo) { // Full overlap + resetView(cm); + } else if (from <= display.viewFrom) { // Top overlap + var cut = viewCuttingPoint(cm, to, to + lendiff, 1); + if (cut) { + display.view = display.view.slice(cut.index); + display.viewFrom = cut.lineN; + display.viewTo += lendiff; + } else { + resetView(cm); + } + } else if (to >= display.viewTo) { // Bottom overlap + var cut$1 = viewCuttingPoint(cm, from, from, -1); + if (cut$1) { + display.view = display.view.slice(0, cut$1.index); + display.viewTo = cut$1.lineN; + } else { + resetView(cm); + } + } else { // Gap in the middle + var cutTop = viewCuttingPoint(cm, from, from, -1); + var cutBot = viewCuttingPoint(cm, to, to + lendiff, 1); + if (cutTop && cutBot) { + display.view = display.view.slice(0, cutTop.index) + .concat(buildViewArray(cm, cutTop.lineN, cutBot.lineN)) + .concat(display.view.slice(cutBot.index)); + display.viewTo += lendiff; + } else { + resetView(cm); + } + } + + var ext = display.externalMeasured; + if (ext) { + if (to < ext.lineN) + { ext.lineN += lendiff; } + else if (from < ext.lineN + ext.size) + { display.externalMeasured = null; } + } + } + + // Register a change to a single line. Type must be one of "text", + // "gutter", "class", "widget" + function regLineChange(cm, line, type) { + cm.curOp.viewChanged = true; + var display = cm.display, ext = cm.display.externalMeasured; + if (ext && line >= ext.lineN && line < ext.lineN + ext.size) + { display.externalMeasured = null; } + + if (line < display.viewFrom || line >= display.viewTo) { return } + var lineView = display.view[findViewIndex(cm, line)]; + if (lineView.node == null) { return } + var arr = lineView.changes || (lineView.changes = []); + if (indexOf(arr, type) == -1) { arr.push(type); } + } + + // Clear the view. + function resetView(cm) { + cm.display.viewFrom = cm.display.viewTo = cm.doc.first; + cm.display.view = []; + cm.display.viewOffset = 0; + } + + function viewCuttingPoint(cm, oldN, newN, dir) { + var index = findViewIndex(cm, oldN), diff, view = cm.display.view; + if (!sawCollapsedSpans || newN == cm.doc.first + cm.doc.size) + { return {index: index, lineN: newN} } + var n = cm.display.viewFrom; + for (var i = 0; i < index; i++) + { n += view[i].size; } + if (n != oldN) { + if (dir > 0) { + if (index == view.length - 1) { return null } + diff = (n + view[index].size) - oldN; + index++; + } else { + diff = n - oldN; + } + oldN += diff; newN += diff; + } + while (visualLineNo(cm.doc, newN) != newN) { + if (index == (dir < 0 ? 0 : view.length - 1)) { return null } + newN += dir * view[index - (dir < 0 ? 1 : 0)].size; + index += dir; + } + return {index: index, lineN: newN} + } + + // Force the view to cover a given range, adding empty view element + // or clipping off existing ones as needed. + function adjustView(cm, from, to) { + var display = cm.display, view = display.view; + if (view.length == 0 || from >= display.viewTo || to <= display.viewFrom) { + display.view = buildViewArray(cm, from, to); + display.viewFrom = from; + } else { + if (display.viewFrom > from) + { display.view = buildViewArray(cm, from, display.viewFrom).concat(display.view); } + else if (display.viewFrom < from) + { display.view = display.view.slice(findViewIndex(cm, from)); } + display.viewFrom = from; + if (display.viewTo < to) + { display.view = display.view.concat(buildViewArray(cm, display.viewTo, to)); } + else if (display.viewTo > to) + { display.view = display.view.slice(0, findViewIndex(cm, to)); } + } + display.viewTo = to; + } + + // Count the number of lines in the view whose DOM representation is + // out of date (or nonexistent). + function countDirtyView(cm) { + var view = cm.display.view, dirty = 0; + for (var i = 0; i < view.length; i++) { + var lineView = view[i]; + if (!lineView.hidden && (!lineView.node || lineView.changes)) { ++dirty; } + } + return dirty + } + + function updateSelection(cm) { + cm.display.input.showSelection(cm.display.input.prepareSelection()); + } + + function prepareSelection(cm, primary) { + if ( primary === void 0 ) primary = true; + + var doc = cm.doc, result = {}; + var curFragment = result.cursors = document.createDocumentFragment(); + var selFragment = result.selection = document.createDocumentFragment(); + + for (var i = 0; i < doc.sel.ranges.length; i++) { + if (!primary && i == doc.sel.primIndex) { continue } + var range$$1 = doc.sel.ranges[i]; + if (range$$1.from().line >= cm.display.viewTo || range$$1.to().line < cm.display.viewFrom) { continue } + var collapsed = range$$1.empty(); + if (collapsed || cm.options.showCursorWhenSelecting) + { drawSelectionCursor(cm, range$$1.head, curFragment); } + if (!collapsed) + { drawSelectionRange(cm, range$$1, selFragment); } + } + return result + } + + // Draws a cursor for the given range + function drawSelectionCursor(cm, head, output) { + var pos = cursorCoords(cm, head, "div", null, null, !cm.options.singleCursorHeightPerLine); + + var cursor = output.appendChild(elt("div", "\u00a0", "CodeMirror-cursor")); + cursor.style.left = pos.left + "px"; + cursor.style.top = pos.top + "px"; + cursor.style.height = Math.max(0, pos.bottom - pos.top) * cm.options.cursorHeight + "px"; + + if (pos.other) { + // Secondary cursor, shown when on a 'jump' in bi-directional text + var otherCursor = output.appendChild(elt("div", "\u00a0", "CodeMirror-cursor CodeMirror-secondarycursor")); + otherCursor.style.display = ""; + otherCursor.style.left = pos.other.left + "px"; + otherCursor.style.top = pos.other.top + "px"; + otherCursor.style.height = (pos.other.bottom - pos.other.top) * .85 + "px"; + } + } + + function cmpCoords(a, b) { return a.top - b.top || a.left - b.left } + + // Draws the given range as a highlighted selection + function drawSelectionRange(cm, range$$1, output) { + var display = cm.display, doc = cm.doc; + var fragment = document.createDocumentFragment(); + var padding = paddingH(cm.display), leftSide = padding.left; + var rightSide = Math.max(display.sizerWidth, displayWidth(cm) - display.sizer.offsetLeft) - padding.right; + var docLTR = doc.direction == "ltr"; + + function add(left, top, width, bottom) { + if (top < 0) { top = 0; } + top = Math.round(top); + bottom = Math.round(bottom); + fragment.appendChild(elt("div", null, "CodeMirror-selected", ("position: absolute; left: " + left + "px;\n top: " + top + "px; width: " + (width == null ? rightSide - left : width) + "px;\n height: " + (bottom - top) + "px"))); + } + + function drawForLine(line, fromArg, toArg) { + var lineObj = getLine(doc, line); + var lineLen = lineObj.text.length; + var start, end; + function coords(ch, bias) { + return charCoords(cm, Pos(line, ch), "div", lineObj, bias) + } + + function wrapX(pos, dir, side) { + var extent = wrappedLineExtentChar(cm, lineObj, null, pos); + var prop = (dir == "ltr") == (side == "after") ? "left" : "right"; + var ch = side == "after" ? extent.begin : extent.end - (/\s/.test(lineObj.text.charAt(extent.end - 1)) ? 2 : 1); + return coords(ch, prop)[prop] + } + + var order = getOrder(lineObj, doc.direction); + iterateBidiSections(order, fromArg || 0, toArg == null ? lineLen : toArg, function (from, to, dir, i) { + var ltr = dir == "ltr"; + var fromPos = coords(from, ltr ? "left" : "right"); + var toPos = coords(to - 1, ltr ? "right" : "left"); + + var openStart = fromArg == null && from == 0, openEnd = toArg == null && to == lineLen; + var first = i == 0, last = !order || i == order.length - 1; + if (toPos.top - fromPos.top <= 3) { // Single line + var openLeft = (docLTR ? openStart : openEnd) && first; + var openRight = (docLTR ? openEnd : openStart) && last; + var left = openLeft ? leftSide : (ltr ? fromPos : toPos).left; + var right = openRight ? rightSide : (ltr ? toPos : fromPos).right; + add(left, fromPos.top, right - left, fromPos.bottom); + } else { // Multiple lines + var topLeft, topRight, botLeft, botRight; + if (ltr) { + topLeft = docLTR && openStart && first ? leftSide : fromPos.left; + topRight = docLTR ? rightSide : wrapX(from, dir, "before"); + botLeft = docLTR ? leftSide : wrapX(to, dir, "after"); + botRight = docLTR && openEnd && last ? rightSide : toPos.right; + } else { + topLeft = !docLTR ? leftSide : wrapX(from, dir, "before"); + topRight = !docLTR && openStart && first ? rightSide : fromPos.right; + botLeft = !docLTR && openEnd && last ? leftSide : toPos.left; + botRight = !docLTR ? rightSide : wrapX(to, dir, "after"); + } + add(topLeft, fromPos.top, topRight - topLeft, fromPos.bottom); + if (fromPos.bottom < toPos.top) { add(leftSide, fromPos.bottom, null, toPos.top); } + add(botLeft, toPos.top, botRight - botLeft, toPos.bottom); + } + + if (!start || cmpCoords(fromPos, start) < 0) { start = fromPos; } + if (cmpCoords(toPos, start) < 0) { start = toPos; } + if (!end || cmpCoords(fromPos, end) < 0) { end = fromPos; } + if (cmpCoords(toPos, end) < 0) { end = toPos; } + }); + return {start: start, end: end} + } + + var sFrom = range$$1.from(), sTo = range$$1.to(); + if (sFrom.line == sTo.line) { + drawForLine(sFrom.line, sFrom.ch, sTo.ch); + } else { + var fromLine = getLine(doc, sFrom.line), toLine = getLine(doc, sTo.line); + var singleVLine = visualLine(fromLine) == visualLine(toLine); + var leftEnd = drawForLine(sFrom.line, sFrom.ch, singleVLine ? fromLine.text.length + 1 : null).end; + var rightStart = drawForLine(sTo.line, singleVLine ? 0 : null, sTo.ch).start; + if (singleVLine) { + if (leftEnd.top < rightStart.top - 2) { + add(leftEnd.right, leftEnd.top, null, leftEnd.bottom); + add(leftSide, rightStart.top, rightStart.left, rightStart.bottom); + } else { + add(leftEnd.right, leftEnd.top, rightStart.left - leftEnd.right, leftEnd.bottom); + } + } + if (leftEnd.bottom < rightStart.top) + { add(leftSide, leftEnd.bottom, null, rightStart.top); } + } + + output.appendChild(fragment); + } + + // Cursor-blinking + function restartBlink(cm) { + if (!cm.state.focused) { return } + var display = cm.display; + clearInterval(display.blinker); + var on = true; + display.cursorDiv.style.visibility = ""; + if (cm.options.cursorBlinkRate > 0) + { display.blinker = setInterval(function () { return display.cursorDiv.style.visibility = (on = !on) ? "" : "hidden"; }, + cm.options.cursorBlinkRate); } + else if (cm.options.cursorBlinkRate < 0) + { display.cursorDiv.style.visibility = "hidden"; } + } + + function ensureFocus(cm) { + if (!cm.state.focused) { cm.display.input.focus(); onFocus(cm); } + } + + function delayBlurEvent(cm) { + cm.state.delayingBlurEvent = true; + setTimeout(function () { if (cm.state.delayingBlurEvent) { + cm.state.delayingBlurEvent = false; + onBlur(cm); + } }, 100); + } + + function onFocus(cm, e) { + if (cm.state.delayingBlurEvent) { cm.state.delayingBlurEvent = false; } + + if (cm.options.readOnly == "nocursor") { return } + if (!cm.state.focused) { + signal(cm, "focus", cm, e); + cm.state.focused = true; + addClass(cm.display.wrapper, "CodeMirror-focused"); + // This test prevents this from firing when a context + // menu is closed (since the input reset would kill the + // select-all detection hack) + if (!cm.curOp && cm.display.selForContextMenu != cm.doc.sel) { + cm.display.input.reset(); + if (webkit) { setTimeout(function () { return cm.display.input.reset(true); }, 20); } // Issue #1730 + } + cm.display.input.receivedFocus(); + } + restartBlink(cm); + } + function onBlur(cm, e) { + if (cm.state.delayingBlurEvent) { return } + + if (cm.state.focused) { + signal(cm, "blur", cm, e); + cm.state.focused = false; + rmClass(cm.display.wrapper, "CodeMirror-focused"); + } + clearInterval(cm.display.blinker); + setTimeout(function () { if (!cm.state.focused) { cm.display.shift = false; } }, 150); + } + + // Read the actual heights of the rendered lines, and update their + // stored heights to match. + function updateHeightsInViewport(cm) { + var display = cm.display; + var prevBottom = display.lineDiv.offsetTop; + for (var i = 0; i < display.view.length; i++) { + var cur = display.view[i], wrapping = cm.options.lineWrapping; + var height = (void 0), width = 0; + if (cur.hidden) { continue } + if (ie && ie_version < 8) { + var bot = cur.node.offsetTop + cur.node.offsetHeight; + height = bot - prevBottom; + prevBottom = bot; + } else { + var box = cur.node.getBoundingClientRect(); + height = box.bottom - box.top; + // Check that lines don't extend past the right of the current + // editor width + if (!wrapping && cur.text.firstChild) + { width = cur.text.firstChild.getBoundingClientRect().right - box.left - 1; } + } + var diff = cur.line.height - height; + if (diff > .005 || diff < -.005) { + updateLineHeight(cur.line, height); + updateWidgetHeight(cur.line); + if (cur.rest) { for (var j = 0; j < cur.rest.length; j++) + { updateWidgetHeight(cur.rest[j]); } } + } + if (width > cm.display.sizerWidth) { + var chWidth = Math.ceil(width / charWidth(cm.display)); + if (chWidth > cm.display.maxLineLength) { + cm.display.maxLineLength = chWidth; + cm.display.maxLine = cur.line; + cm.display.maxLineChanged = true; + } + } + } + } + + // Read and store the height of line widgets associated with the + // given line. + function updateWidgetHeight(line) { + if (line.widgets) { for (var i = 0; i < line.widgets.length; ++i) { + var w = line.widgets[i], parent = w.node.parentNode; + if (parent) { w.height = parent.offsetHeight; } + } } + } + + // Compute the lines that are visible in a given viewport (defaults + // the the current scroll position). viewport may contain top, + // height, and ensure (see op.scrollToPos) properties. + function visibleLines(display, doc, viewport) { + var top = viewport && viewport.top != null ? Math.max(0, viewport.top) : display.scroller.scrollTop; + top = Math.floor(top - paddingTop(display)); + var bottom = viewport && viewport.bottom != null ? viewport.bottom : top + display.wrapper.clientHeight; + + var from = lineAtHeight(doc, top), to = lineAtHeight(doc, bottom); + // Ensure is a {from: {line, ch}, to: {line, ch}} object, and + // forces those lines into the viewport (if possible). + if (viewport && viewport.ensure) { + var ensureFrom = viewport.ensure.from.line, ensureTo = viewport.ensure.to.line; + if (ensureFrom < from) { + from = ensureFrom; + to = lineAtHeight(doc, heightAtLine(getLine(doc, ensureFrom)) + display.wrapper.clientHeight); + } else if (Math.min(ensureTo, doc.lastLine()) >= to) { + from = lineAtHeight(doc, heightAtLine(getLine(doc, ensureTo)) - display.wrapper.clientHeight); + to = ensureTo; + } + } + return {from: from, to: Math.max(to, from + 1)} + } + + // SCROLLING THINGS INTO VIEW + + // If an editor sits on the top or bottom of the window, partially + // scrolled out of view, this ensures that the cursor is visible. + function maybeScrollWindow(cm, rect) { + if (signalDOMEvent(cm, "scrollCursorIntoView")) { return } + + var display = cm.display, box = display.sizer.getBoundingClientRect(), doScroll = null; + if (rect.top + box.top < 0) { doScroll = true; } + else if (rect.bottom + box.top > (window.innerHeight || document.documentElement.clientHeight)) { doScroll = false; } + if (doScroll != null && !phantom) { + var scrollNode = elt("div", "\u200b", null, ("position: absolute;\n top: " + (rect.top - display.viewOffset - paddingTop(cm.display)) + "px;\n height: " + (rect.bottom - rect.top + scrollGap(cm) + display.barHeight) + "px;\n left: " + (rect.left) + "px; width: " + (Math.max(2, rect.right - rect.left)) + "px;")); + cm.display.lineSpace.appendChild(scrollNode); + scrollNode.scrollIntoView(doScroll); + cm.display.lineSpace.removeChild(scrollNode); + } + } + + // Scroll a given position into view (immediately), verifying that + // it actually became visible (as line heights are accurately + // measured, the position of something may 'drift' during drawing). + function scrollPosIntoView(cm, pos, end, margin) { + if (margin == null) { margin = 0; } + var rect; + if (!cm.options.lineWrapping && pos == end) { + // Set pos and end to the cursor positions around the character pos sticks to + // If pos.sticky == "before", that is around pos.ch - 1, otherwise around pos.ch + // If pos == Pos(_, 0, "before"), pos and end are unchanged + pos = pos.ch ? Pos(pos.line, pos.sticky == "before" ? pos.ch - 1 : pos.ch, "after") : pos; + end = pos.sticky == "before" ? Pos(pos.line, pos.ch + 1, "before") : pos; + } + for (var limit = 0; limit < 5; limit++) { + var changed = false; + var coords = cursorCoords(cm, pos); + var endCoords = !end || end == pos ? coords : cursorCoords(cm, end); + rect = {left: Math.min(coords.left, endCoords.left), + top: Math.min(coords.top, endCoords.top) - margin, + right: Math.max(coords.left, endCoords.left), + bottom: Math.max(coords.bottom, endCoords.bottom) + margin}; + var scrollPos = calculateScrollPos(cm, rect); + var startTop = cm.doc.scrollTop, startLeft = cm.doc.scrollLeft; + if (scrollPos.scrollTop != null) { + updateScrollTop(cm, scrollPos.scrollTop); + if (Math.abs(cm.doc.scrollTop - startTop) > 1) { changed = true; } + } + if (scrollPos.scrollLeft != null) { + setScrollLeft(cm, scrollPos.scrollLeft); + if (Math.abs(cm.doc.scrollLeft - startLeft) > 1) { changed = true; } + } + if (!changed) { break } + } + return rect + } + + // Scroll a given set of coordinates into view (immediately). + function scrollIntoView(cm, rect) { + var scrollPos = calculateScrollPos(cm, rect); + if (scrollPos.scrollTop != null) { updateScrollTop(cm, scrollPos.scrollTop); } + if (scrollPos.scrollLeft != null) { setScrollLeft(cm, scrollPos.scrollLeft); } + } + + // Calculate a new scroll position needed to scroll the given + // rectangle into view. Returns an object with scrollTop and + // scrollLeft properties. When these are undefined, the + // vertical/horizontal position does not need to be adjusted. + function calculateScrollPos(cm, rect) { + var display = cm.display, snapMargin = textHeight(cm.display); + if (rect.top < 0) { rect.top = 0; } + var screentop = cm.curOp && cm.curOp.scrollTop != null ? cm.curOp.scrollTop : display.scroller.scrollTop; + var screen = displayHeight(cm), result = {}; + if (rect.bottom - rect.top > screen) { rect.bottom = rect.top + screen; } + var docBottom = cm.doc.height + paddingVert(display); + var atTop = rect.top < snapMargin, atBottom = rect.bottom > docBottom - snapMargin; + if (rect.top < screentop) { + result.scrollTop = atTop ? 0 : rect.top; + } else if (rect.bottom > screentop + screen) { + var newTop = Math.min(rect.top, (atBottom ? docBottom : rect.bottom) - screen); + if (newTop != screentop) { result.scrollTop = newTop; } + } + + var screenleft = cm.curOp && cm.curOp.scrollLeft != null ? cm.curOp.scrollLeft : display.scroller.scrollLeft; + var screenw = displayWidth(cm) - (cm.options.fixedGutter ? display.gutters.offsetWidth : 0); + var tooWide = rect.right - rect.left > screenw; + if (tooWide) { rect.right = rect.left + screenw; } + if (rect.left < 10) + { result.scrollLeft = 0; } + else if (rect.left < screenleft) + { result.scrollLeft = Math.max(0, rect.left - (tooWide ? 0 : 10)); } + else if (rect.right > screenw + screenleft - 3) + { result.scrollLeft = rect.right + (tooWide ? 0 : 10) - screenw; } + return result + } + + // Store a relative adjustment to the scroll position in the current + // operation (to be applied when the operation finishes). + function addToScrollTop(cm, top) { + if (top == null) { return } + resolveScrollToPos(cm); + cm.curOp.scrollTop = (cm.curOp.scrollTop == null ? cm.doc.scrollTop : cm.curOp.scrollTop) + top; + } + + // Make sure that at the end of the operation the current cursor is + // shown. + function ensureCursorVisible(cm) { + resolveScrollToPos(cm); + var cur = cm.getCursor(); + cm.curOp.scrollToPos = {from: cur, to: cur, margin: cm.options.cursorScrollMargin}; + } + + function scrollToCoords(cm, x, y) { + if (x != null || y != null) { resolveScrollToPos(cm); } + if (x != null) { cm.curOp.scrollLeft = x; } + if (y != null) { cm.curOp.scrollTop = y; } + } + + function scrollToRange(cm, range$$1) { + resolveScrollToPos(cm); + cm.curOp.scrollToPos = range$$1; + } + + // When an operation has its scrollToPos property set, and another + // scroll action is applied before the end of the operation, this + // 'simulates' scrolling that position into view in a cheap way, so + // that the effect of intermediate scroll commands is not ignored. + function resolveScrollToPos(cm) { + var range$$1 = cm.curOp.scrollToPos; + if (range$$1) { + cm.curOp.scrollToPos = null; + var from = estimateCoords(cm, range$$1.from), to = estimateCoords(cm, range$$1.to); + scrollToCoordsRange(cm, from, to, range$$1.margin); + } + } + + function scrollToCoordsRange(cm, from, to, margin) { + var sPos = calculateScrollPos(cm, { + left: Math.min(from.left, to.left), + top: Math.min(from.top, to.top) - margin, + right: Math.max(from.right, to.right), + bottom: Math.max(from.bottom, to.bottom) + margin + }); + scrollToCoords(cm, sPos.scrollLeft, sPos.scrollTop); + } + + // Sync the scrollable area and scrollbars, ensure the viewport + // covers the visible area. + function updateScrollTop(cm, val) { + if (Math.abs(cm.doc.scrollTop - val) < 2) { return } + if (!gecko) { updateDisplaySimple(cm, {top: val}); } + setScrollTop(cm, val, true); + if (gecko) { updateDisplaySimple(cm); } + startWorker(cm, 100); + } + + function setScrollTop(cm, val, forceScroll) { + val = Math.min(cm.display.scroller.scrollHeight - cm.display.scroller.clientHeight, val); + if (cm.display.scroller.scrollTop == val && !forceScroll) { return } + cm.doc.scrollTop = val; + cm.display.scrollbars.setScrollTop(val); + if (cm.display.scroller.scrollTop != val) { cm.display.scroller.scrollTop = val; } + } + + // Sync scroller and scrollbar, ensure the gutter elements are + // aligned. + function setScrollLeft(cm, val, isScroller, forceScroll) { + val = Math.min(val, cm.display.scroller.scrollWidth - cm.display.scroller.clientWidth); + if ((isScroller ? val == cm.doc.scrollLeft : Math.abs(cm.doc.scrollLeft - val) < 2) && !forceScroll) { return } + cm.doc.scrollLeft = val; + alignHorizontally(cm); + if (cm.display.scroller.scrollLeft != val) { cm.display.scroller.scrollLeft = val; } + cm.display.scrollbars.setScrollLeft(val); + } + + // SCROLLBARS + + // Prepare DOM reads needed to update the scrollbars. Done in one + // shot to minimize update/measure roundtrips. + function measureForScrollbars(cm) { + var d = cm.display, gutterW = d.gutters.offsetWidth; + var docH = Math.round(cm.doc.height + paddingVert(cm.display)); + return { + clientHeight: d.scroller.clientHeight, + viewHeight: d.wrapper.clientHeight, + scrollWidth: d.scroller.scrollWidth, clientWidth: d.scroller.clientWidth, + viewWidth: d.wrapper.clientWidth, + barLeft: cm.options.fixedGutter ? gutterW : 0, + docHeight: docH, + scrollHeight: docH + scrollGap(cm) + d.barHeight, + nativeBarWidth: d.nativeBarWidth, + gutterWidth: gutterW + } + } + + var NativeScrollbars = function(place, scroll, cm) { + this.cm = cm; + var vert = this.vert = elt("div", [elt("div", null, null, "min-width: 1px")], "CodeMirror-vscrollbar"); + var horiz = this.horiz = elt("div", [elt("div", null, null, "height: 100%; min-height: 1px")], "CodeMirror-hscrollbar"); + vert.tabIndex = horiz.tabIndex = -1; + place(vert); place(horiz); + + on(vert, "scroll", function () { + if (vert.clientHeight) { scroll(vert.scrollTop, "vertical"); } + }); + on(horiz, "scroll", function () { + if (horiz.clientWidth) { scroll(horiz.scrollLeft, "horizontal"); } + }); + + this.checkedZeroWidth = false; + // Need to set a minimum width to see the scrollbar on IE7 (but must not set it on IE8). + if (ie && ie_version < 8) { this.horiz.style.minHeight = this.vert.style.minWidth = "18px"; } + }; + + NativeScrollbars.prototype.update = function (measure) { + var needsH = measure.scrollWidth > measure.clientWidth + 1; + var needsV = measure.scrollHeight > measure.clientHeight + 1; + var sWidth = measure.nativeBarWidth; + + if (needsV) { + this.vert.style.display = "block"; + this.vert.style.bottom = needsH ? sWidth + "px" : "0"; + var totalHeight = measure.viewHeight - (needsH ? sWidth : 0); + // A bug in IE8 can cause this value to be negative, so guard it. + this.vert.firstChild.style.height = + Math.max(0, measure.scrollHeight - measure.clientHeight + totalHeight) + "px"; + } else { + this.vert.style.display = ""; + this.vert.firstChild.style.height = "0"; + } + + if (needsH) { + this.horiz.style.display = "block"; + this.horiz.style.right = needsV ? sWidth + "px" : "0"; + this.horiz.style.left = measure.barLeft + "px"; + var totalWidth = measure.viewWidth - measure.barLeft - (needsV ? sWidth : 0); + this.horiz.firstChild.style.width = + Math.max(0, measure.scrollWidth - measure.clientWidth + totalWidth) + "px"; + } else { + this.horiz.style.display = ""; + this.horiz.firstChild.style.width = "0"; + } + + if (!this.checkedZeroWidth && measure.clientHeight > 0) { + if (sWidth == 0) { this.zeroWidthHack(); } + this.checkedZeroWidth = true; + } + + return {right: needsV ? sWidth : 0, bottom: needsH ? sWidth : 0} + }; + + NativeScrollbars.prototype.setScrollLeft = function (pos) { + if (this.horiz.scrollLeft != pos) { this.horiz.scrollLeft = pos; } + if (this.disableHoriz) { this.enableZeroWidthBar(this.horiz, this.disableHoriz, "horiz"); } + }; + + NativeScrollbars.prototype.setScrollTop = function (pos) { + if (this.vert.scrollTop != pos) { this.vert.scrollTop = pos; } + if (this.disableVert) { this.enableZeroWidthBar(this.vert, this.disableVert, "vert"); } + }; + + NativeScrollbars.prototype.zeroWidthHack = function () { + var w = mac && !mac_geMountainLion ? "12px" : "18px"; + this.horiz.style.height = this.vert.style.width = w; + this.horiz.style.pointerEvents = this.vert.style.pointerEvents = "none"; + this.disableHoriz = new Delayed; + this.disableVert = new Delayed; + }; + + NativeScrollbars.prototype.enableZeroWidthBar = function (bar, delay, type) { + bar.style.pointerEvents = "auto"; + function maybeDisable() { + // To find out whether the scrollbar is still visible, we + // check whether the element under the pixel in the bottom + // right corner of the scrollbar box is the scrollbar box + // itself (when the bar is still visible) or its filler child + // (when the bar is hidden). If it is still visible, we keep + // it enabled, if it's hidden, we disable pointer events. + var box = bar.getBoundingClientRect(); + var elt$$1 = type == "vert" ? document.elementFromPoint(box.right - 1, (box.top + box.bottom) / 2) + : document.elementFromPoint((box.right + box.left) / 2, box.bottom - 1); + if (elt$$1 != bar) { bar.style.pointerEvents = "none"; } + else { delay.set(1000, maybeDisable); } + } + delay.set(1000, maybeDisable); + }; + + NativeScrollbars.prototype.clear = function () { + var parent = this.horiz.parentNode; + parent.removeChild(this.horiz); + parent.removeChild(this.vert); + }; + + var NullScrollbars = function () {}; + + NullScrollbars.prototype.update = function () { return {bottom: 0, right: 0} }; + NullScrollbars.prototype.setScrollLeft = function () {}; + NullScrollbars.prototype.setScrollTop = function () {}; + NullScrollbars.prototype.clear = function () {}; + + function updateScrollbars(cm, measure) { + if (!measure) { measure = measureForScrollbars(cm); } + var startWidth = cm.display.barWidth, startHeight = cm.display.barHeight; + updateScrollbarsInner(cm, measure); + for (var i = 0; i < 4 && startWidth != cm.display.barWidth || startHeight != cm.display.barHeight; i++) { + if (startWidth != cm.display.barWidth && cm.options.lineWrapping) + { updateHeightsInViewport(cm); } + updateScrollbarsInner(cm, measureForScrollbars(cm)); + startWidth = cm.display.barWidth; startHeight = cm.display.barHeight; + } + } + + // Re-synchronize the fake scrollbars with the actual size of the + // content. + function updateScrollbarsInner(cm, measure) { + var d = cm.display; + var sizes = d.scrollbars.update(measure); + + d.sizer.style.paddingRight = (d.barWidth = sizes.right) + "px"; + d.sizer.style.paddingBottom = (d.barHeight = sizes.bottom) + "px"; + d.heightForcer.style.borderBottom = sizes.bottom + "px solid transparent"; + + if (sizes.right && sizes.bottom) { + d.scrollbarFiller.style.display = "block"; + d.scrollbarFiller.style.height = sizes.bottom + "px"; + d.scrollbarFiller.style.width = sizes.right + "px"; + } else { d.scrollbarFiller.style.display = ""; } + if (sizes.bottom && cm.options.coverGutterNextToScrollbar && cm.options.fixedGutter) { + d.gutterFiller.style.display = "block"; + d.gutterFiller.style.height = sizes.bottom + "px"; + d.gutterFiller.style.width = measure.gutterWidth + "px"; + } else { d.gutterFiller.style.display = ""; } + } + + var scrollbarModel = {"native": NativeScrollbars, "null": NullScrollbars}; + + function initScrollbars(cm) { + if (cm.display.scrollbars) { + cm.display.scrollbars.clear(); + if (cm.display.scrollbars.addClass) + { rmClass(cm.display.wrapper, cm.display.scrollbars.addClass); } + } + + cm.display.scrollbars = new scrollbarModel[cm.options.scrollbarStyle](function (node) { + cm.display.wrapper.insertBefore(node, cm.display.scrollbarFiller); + // Prevent clicks in the scrollbars from killing focus + on(node, "mousedown", function () { + if (cm.state.focused) { setTimeout(function () { return cm.display.input.focus(); }, 0); } + }); + node.setAttribute("cm-not-content", "true"); + }, function (pos, axis) { + if (axis == "horizontal") { setScrollLeft(cm, pos); } + else { updateScrollTop(cm, pos); } + }, cm); + if (cm.display.scrollbars.addClass) + { addClass(cm.display.wrapper, cm.display.scrollbars.addClass); } + } + + // Operations are used to wrap a series of changes to the editor + // state in such a way that each change won't have to update the + // cursor and display (which would be awkward, slow, and + // error-prone). Instead, display updates are batched and then all + // combined and executed at once. + + var nextOpId = 0; + // Start a new operation. + function startOperation(cm) { + cm.curOp = { + cm: cm, + viewChanged: false, // Flag that indicates that lines might need to be redrawn + startHeight: cm.doc.height, // Used to detect need to update scrollbar + forceUpdate: false, // Used to force a redraw + updateInput: 0, // Whether to reset the input textarea + typing: false, // Whether this reset should be careful to leave existing text (for compositing) + changeObjs: null, // Accumulated changes, for firing change events + cursorActivityHandlers: null, // Set of handlers to fire cursorActivity on + cursorActivityCalled: 0, // Tracks which cursorActivity handlers have been called already + selectionChanged: false, // Whether the selection needs to be redrawn + updateMaxLine: false, // Set when the widest line needs to be determined anew + scrollLeft: null, scrollTop: null, // Intermediate scroll position, not pushed to DOM yet + scrollToPos: null, // Used to scroll to a specific position + focus: false, + id: ++nextOpId // Unique ID + }; + pushOperation(cm.curOp); + } + + // Finish an operation, updating the display and signalling delayed events + function endOperation(cm) { + var op = cm.curOp; + if (op) { finishOperation(op, function (group) { + for (var i = 0; i < group.ops.length; i++) + { group.ops[i].cm.curOp = null; } + endOperations(group); + }); } + } + + // The DOM updates done when an operation finishes are batched so + // that the minimum number of relayouts are required. + function endOperations(group) { + var ops = group.ops; + for (var i = 0; i < ops.length; i++) // Read DOM + { endOperation_R1(ops[i]); } + for (var i$1 = 0; i$1 < ops.length; i$1++) // Write DOM (maybe) + { endOperation_W1(ops[i$1]); } + for (var i$2 = 0; i$2 < ops.length; i$2++) // Read DOM + { endOperation_R2(ops[i$2]); } + for (var i$3 = 0; i$3 < ops.length; i$3++) // Write DOM (maybe) + { endOperation_W2(ops[i$3]); } + for (var i$4 = 0; i$4 < ops.length; i$4++) // Read DOM + { endOperation_finish(ops[i$4]); } + } + + function endOperation_R1(op) { + var cm = op.cm, display = cm.display; + maybeClipScrollbars(cm); + if (op.updateMaxLine) { findMaxLine(cm); } + + op.mustUpdate = op.viewChanged || op.forceUpdate || op.scrollTop != null || + op.scrollToPos && (op.scrollToPos.from.line < display.viewFrom || + op.scrollToPos.to.line >= display.viewTo) || + display.maxLineChanged && cm.options.lineWrapping; + op.update = op.mustUpdate && + new DisplayUpdate(cm, op.mustUpdate && {top: op.scrollTop, ensure: op.scrollToPos}, op.forceUpdate); + } + + function endOperation_W1(op) { + op.updatedDisplay = op.mustUpdate && updateDisplayIfNeeded(op.cm, op.update); + } + + function endOperation_R2(op) { + var cm = op.cm, display = cm.display; + if (op.updatedDisplay) { updateHeightsInViewport(cm); } + + op.barMeasure = measureForScrollbars(cm); + + // If the max line changed since it was last measured, measure it, + // and ensure the document's width matches it. + // updateDisplay_W2 will use these properties to do the actual resizing + if (display.maxLineChanged && !cm.options.lineWrapping) { + op.adjustWidthTo = measureChar(cm, display.maxLine, display.maxLine.text.length).left + 3; + cm.display.sizerWidth = op.adjustWidthTo; + op.barMeasure.scrollWidth = + Math.max(display.scroller.clientWidth, display.sizer.offsetLeft + op.adjustWidthTo + scrollGap(cm) + cm.display.barWidth); + op.maxScrollLeft = Math.max(0, display.sizer.offsetLeft + op.adjustWidthTo - displayWidth(cm)); + } + + if (op.updatedDisplay || op.selectionChanged) + { op.preparedSelection = display.input.prepareSelection(); } + } + + function endOperation_W2(op) { + var cm = op.cm; + + if (op.adjustWidthTo != null) { + cm.display.sizer.style.minWidth = op.adjustWidthTo + "px"; + if (op.maxScrollLeft < cm.doc.scrollLeft) + { setScrollLeft(cm, Math.min(cm.display.scroller.scrollLeft, op.maxScrollLeft), true); } + cm.display.maxLineChanged = false; + } + + var takeFocus = op.focus && op.focus == activeElt(); + if (op.preparedSelection) + { cm.display.input.showSelection(op.preparedSelection, takeFocus); } + if (op.updatedDisplay || op.startHeight != cm.doc.height) + { updateScrollbars(cm, op.barMeasure); } + if (op.updatedDisplay) + { setDocumentHeight(cm, op.barMeasure); } + + if (op.selectionChanged) { restartBlink(cm); } + + if (cm.state.focused && op.updateInput) + { cm.display.input.reset(op.typing); } + if (takeFocus) { ensureFocus(op.cm); } + } + + function endOperation_finish(op) { + var cm = op.cm, display = cm.display, doc = cm.doc; + + if (op.updatedDisplay) { postUpdateDisplay(cm, op.update); } + + // Abort mouse wheel delta measurement, when scrolling explicitly + if (display.wheelStartX != null && (op.scrollTop != null || op.scrollLeft != null || op.scrollToPos)) + { display.wheelStartX = display.wheelStartY = null; } + + // Propagate the scroll position to the actual DOM scroller + if (op.scrollTop != null) { setScrollTop(cm, op.scrollTop, op.forceScroll); } + + if (op.scrollLeft != null) { setScrollLeft(cm, op.scrollLeft, true, true); } + // If we need to scroll a specific position into view, do so. + if (op.scrollToPos) { + var rect = scrollPosIntoView(cm, clipPos(doc, op.scrollToPos.from), + clipPos(doc, op.scrollToPos.to), op.scrollToPos.margin); + maybeScrollWindow(cm, rect); + } + + // Fire events for markers that are hidden/unidden by editing or + // undoing + var hidden = op.maybeHiddenMarkers, unhidden = op.maybeUnhiddenMarkers; + if (hidden) { for (var i = 0; i < hidden.length; ++i) + { if (!hidden[i].lines.length) { signal(hidden[i], "hide"); } } } + if (unhidden) { for (var i$1 = 0; i$1 < unhidden.length; ++i$1) + { if (unhidden[i$1].lines.length) { signal(unhidden[i$1], "unhide"); } } } + + if (display.wrapper.offsetHeight) + { doc.scrollTop = cm.display.scroller.scrollTop; } + + // Fire change events, and delayed event handlers + if (op.changeObjs) + { signal(cm, "changes", cm, op.changeObjs); } + if (op.update) + { op.update.finish(); } + } + + // Run the given function in an operation + function runInOp(cm, f) { + if (cm.curOp) { return f() } + startOperation(cm); + try { return f() } + finally { endOperation(cm); } + } + // Wraps a function in an operation. Returns the wrapped function. + function operation(cm, f) { + return function() { + if (cm.curOp) { return f.apply(cm, arguments) } + startOperation(cm); + try { return f.apply(cm, arguments) } + finally { endOperation(cm); } + } + } + // Used to add methods to editor and doc instances, wrapping them in + // operations. + function methodOp(f) { + return function() { + if (this.curOp) { return f.apply(this, arguments) } + startOperation(this); + try { return f.apply(this, arguments) } + finally { endOperation(this); } + } + } + function docMethodOp(f) { + return function() { + var cm = this.cm; + if (!cm || cm.curOp) { return f.apply(this, arguments) } + startOperation(cm); + try { return f.apply(this, arguments) } + finally { endOperation(cm); } + } + } + + // HIGHLIGHT WORKER + + function startWorker(cm, time) { + if (cm.doc.highlightFrontier < cm.display.viewTo) + { cm.state.highlight.set(time, bind(highlightWorker, cm)); } + } + + function highlightWorker(cm) { + var doc = cm.doc; + if (doc.highlightFrontier >= cm.display.viewTo) { return } + var end = +new Date + cm.options.workTime; + var context = getContextBefore(cm, doc.highlightFrontier); + var changedLines = []; + + doc.iter(context.line, Math.min(doc.first + doc.size, cm.display.viewTo + 500), function (line) { + if (context.line >= cm.display.viewFrom) { // Visible + var oldStyles = line.styles; + var resetState = line.text.length > cm.options.maxHighlightLength ? copyState(doc.mode, context.state) : null; + var highlighted = highlightLine(cm, line, context, true); + if (resetState) { context.state = resetState; } + line.styles = highlighted.styles; + var oldCls = line.styleClasses, newCls = highlighted.classes; + if (newCls) { line.styleClasses = newCls; } + else if (oldCls) { line.styleClasses = null; } + var ischange = !oldStyles || oldStyles.length != line.styles.length || + oldCls != newCls && (!oldCls || !newCls || oldCls.bgClass != newCls.bgClass || oldCls.textClass != newCls.textClass); + for (var i = 0; !ischange && i < oldStyles.length; ++i) { ischange = oldStyles[i] != line.styles[i]; } + if (ischange) { changedLines.push(context.line); } + line.stateAfter = context.save(); + context.nextLine(); + } else { + if (line.text.length <= cm.options.maxHighlightLength) + { processLine(cm, line.text, context); } + line.stateAfter = context.line % 5 == 0 ? context.save() : null; + context.nextLine(); + } + if (+new Date > end) { + startWorker(cm, cm.options.workDelay); + return true + } + }); + doc.highlightFrontier = context.line; + doc.modeFrontier = Math.max(doc.modeFrontier, context.line); + if (changedLines.length) { runInOp(cm, function () { + for (var i = 0; i < changedLines.length; i++) + { regLineChange(cm, changedLines[i], "text"); } + }); } + } + + // DISPLAY DRAWING + + var DisplayUpdate = function(cm, viewport, force) { + var display = cm.display; + + this.viewport = viewport; + // Store some values that we'll need later (but don't want to force a relayout for) + this.visible = visibleLines(display, cm.doc, viewport); + this.editorIsHidden = !display.wrapper.offsetWidth; + this.wrapperHeight = display.wrapper.clientHeight; + this.wrapperWidth = display.wrapper.clientWidth; + this.oldDisplayWidth = displayWidth(cm); + this.force = force; + this.dims = getDimensions(cm); + this.events = []; + }; + + DisplayUpdate.prototype.signal = function (emitter, type) { + if (hasHandler(emitter, type)) + { this.events.push(arguments); } + }; + DisplayUpdate.prototype.finish = function () { + var this$1 = this; + + for (var i = 0; i < this.events.length; i++) + { signal.apply(null, this$1.events[i]); } + }; + + function maybeClipScrollbars(cm) { + var display = cm.display; + if (!display.scrollbarsClipped && display.scroller.offsetWidth) { + display.nativeBarWidth = display.scroller.offsetWidth - display.scroller.clientWidth; + display.heightForcer.style.height = scrollGap(cm) + "px"; + display.sizer.style.marginBottom = -display.nativeBarWidth + "px"; + display.sizer.style.borderRightWidth = scrollGap(cm) + "px"; + display.scrollbarsClipped = true; + } + } + + function selectionSnapshot(cm) { + if (cm.hasFocus()) { return null } + var active = activeElt(); + if (!active || !contains(cm.display.lineDiv, active)) { return null } + var result = {activeElt: active}; + if (window.getSelection) { + var sel = window.getSelection(); + if (sel.anchorNode && sel.extend && contains(cm.display.lineDiv, sel.anchorNode)) { + result.anchorNode = sel.anchorNode; + result.anchorOffset = sel.anchorOffset; + result.focusNode = sel.focusNode; + result.focusOffset = sel.focusOffset; + } + } + return result + } + + function restoreSelection(snapshot) { + if (!snapshot || !snapshot.activeElt || snapshot.activeElt == activeElt()) { return } + snapshot.activeElt.focus(); + if (snapshot.anchorNode && contains(document.body, snapshot.anchorNode) && contains(document.body, snapshot.focusNode)) { + var sel = window.getSelection(), range$$1 = document.createRange(); + range$$1.setEnd(snapshot.anchorNode, snapshot.anchorOffset); + range$$1.collapse(false); + sel.removeAllRanges(); + sel.addRange(range$$1); + sel.extend(snapshot.focusNode, snapshot.focusOffset); + } + } + + // Does the actual updating of the line display. Bails out + // (returning false) when there is nothing to be done and forced is + // false. + function updateDisplayIfNeeded(cm, update) { + var display = cm.display, doc = cm.doc; + + if (update.editorIsHidden) { + resetView(cm); + return false + } + + // Bail out if the visible area is already rendered and nothing changed. + if (!update.force && + update.visible.from >= display.viewFrom && update.visible.to <= display.viewTo && + (display.updateLineNumbers == null || display.updateLineNumbers >= display.viewTo) && + display.renderedView == display.view && countDirtyView(cm) == 0) + { return false } + + if (maybeUpdateLineNumberWidth(cm)) { + resetView(cm); + update.dims = getDimensions(cm); + } + + // Compute a suitable new viewport (from & to) + var end = doc.first + doc.size; + var from = Math.max(update.visible.from - cm.options.viewportMargin, doc.first); + var to = Math.min(end, update.visible.to + cm.options.viewportMargin); + if (display.viewFrom < from && from - display.viewFrom < 20) { from = Math.max(doc.first, display.viewFrom); } + if (display.viewTo > to && display.viewTo - to < 20) { to = Math.min(end, display.viewTo); } + if (sawCollapsedSpans) { + from = visualLineNo(cm.doc, from); + to = visualLineEndNo(cm.doc, to); + } + + var different = from != display.viewFrom || to != display.viewTo || + display.lastWrapHeight != update.wrapperHeight || display.lastWrapWidth != update.wrapperWidth; + adjustView(cm, from, to); + + display.viewOffset = heightAtLine(getLine(cm.doc, display.viewFrom)); + // Position the mover div to align with the current scroll position + cm.display.mover.style.top = display.viewOffset + "px"; + + var toUpdate = countDirtyView(cm); + if (!different && toUpdate == 0 && !update.force && display.renderedView == display.view && + (display.updateLineNumbers == null || display.updateLineNumbers >= display.viewTo)) + { return false } + + // For big changes, we hide the enclosing element during the + // update, since that speeds up the operations on most browsers. + var selSnapshot = selectionSnapshot(cm); + if (toUpdate > 4) { display.lineDiv.style.display = "none"; } + patchDisplay(cm, display.updateLineNumbers, update.dims); + if (toUpdate > 4) { display.lineDiv.style.display = ""; } + display.renderedView = display.view; + // There might have been a widget with a focused element that got + // hidden or updated, if so re-focus it. + restoreSelection(selSnapshot); + + // Prevent selection and cursors from interfering with the scroll + // width and height. + removeChildren(display.cursorDiv); + removeChildren(display.selectionDiv); + display.gutters.style.height = display.sizer.style.minHeight = 0; + + if (different) { + display.lastWrapHeight = update.wrapperHeight; + display.lastWrapWidth = update.wrapperWidth; + startWorker(cm, 400); + } + + display.updateLineNumbers = null; + + return true + } + + function postUpdateDisplay(cm, update) { + var viewport = update.viewport; + + for (var first = true;; first = false) { + if (!first || !cm.options.lineWrapping || update.oldDisplayWidth == displayWidth(cm)) { + // Clip forced viewport to actual scrollable area. + if (viewport && viewport.top != null) + { viewport = {top: Math.min(cm.doc.height + paddingVert(cm.display) - displayHeight(cm), viewport.top)}; } + // Updated line heights might result in the drawn area not + // actually covering the viewport. Keep looping until it does. + update.visible = visibleLines(cm.display, cm.doc, viewport); + if (update.visible.from >= cm.display.viewFrom && update.visible.to <= cm.display.viewTo) + { break } + } + if (!updateDisplayIfNeeded(cm, update)) { break } + updateHeightsInViewport(cm); + var barMeasure = measureForScrollbars(cm); + updateSelection(cm); + updateScrollbars(cm, barMeasure); + setDocumentHeight(cm, barMeasure); + update.force = false; + } + + update.signal(cm, "update", cm); + if (cm.display.viewFrom != cm.display.reportedViewFrom || cm.display.viewTo != cm.display.reportedViewTo) { + update.signal(cm, "viewportChange", cm, cm.display.viewFrom, cm.display.viewTo); + cm.display.reportedViewFrom = cm.display.viewFrom; cm.display.reportedViewTo = cm.display.viewTo; + } + } + + function updateDisplaySimple(cm, viewport) { + var update = new DisplayUpdate(cm, viewport); + if (updateDisplayIfNeeded(cm, update)) { + updateHeightsInViewport(cm); + postUpdateDisplay(cm, update); + var barMeasure = measureForScrollbars(cm); + updateSelection(cm); + updateScrollbars(cm, barMeasure); + setDocumentHeight(cm, barMeasure); + update.finish(); + } + } + + // Sync the actual display DOM structure with display.view, removing + // nodes for lines that are no longer in view, and creating the ones + // that are not there yet, and updating the ones that are out of + // date. + function patchDisplay(cm, updateNumbersFrom, dims) { + var display = cm.display, lineNumbers = cm.options.lineNumbers; + var container = display.lineDiv, cur = container.firstChild; + + function rm(node) { + var next = node.nextSibling; + // Works around a throw-scroll bug in OS X Webkit + if (webkit && mac && cm.display.currentWheelTarget == node) + { node.style.display = "none"; } + else + { node.parentNode.removeChild(node); } + return next + } + + var view = display.view, lineN = display.viewFrom; + // Loop over the elements in the view, syncing cur (the DOM nodes + // in display.lineDiv) with the view as we go. + for (var i = 0; i < view.length; i++) { + var lineView = view[i]; + if (lineView.hidden) ; else if (!lineView.node || lineView.node.parentNode != container) { // Not drawn yet + var node = buildLineElement(cm, lineView, lineN, dims); + container.insertBefore(node, cur); + } else { // Already drawn + while (cur != lineView.node) { cur = rm(cur); } + var updateNumber = lineNumbers && updateNumbersFrom != null && + updateNumbersFrom <= lineN && lineView.lineNumber; + if (lineView.changes) { + if (indexOf(lineView.changes, "gutter") > -1) { updateNumber = false; } + updateLineForChanges(cm, lineView, lineN, dims); + } + if (updateNumber) { + removeChildren(lineView.lineNumber); + lineView.lineNumber.appendChild(document.createTextNode(lineNumberFor(cm.options, lineN))); + } + cur = lineView.node.nextSibling; + } + lineN += lineView.size; + } + while (cur) { cur = rm(cur); } + } + + function updateGutterSpace(display) { + var width = display.gutters.offsetWidth; + display.sizer.style.marginLeft = width + "px"; + } + + function setDocumentHeight(cm, measure) { + cm.display.sizer.style.minHeight = measure.docHeight + "px"; + cm.display.heightForcer.style.top = measure.docHeight + "px"; + cm.display.gutters.style.height = (measure.docHeight + cm.display.barHeight + scrollGap(cm)) + "px"; + } + + // Re-align line numbers and gutter marks to compensate for + // horizontal scrolling. + function alignHorizontally(cm) { + var display = cm.display, view = display.view; + if (!display.alignWidgets && (!display.gutters.firstChild || !cm.options.fixedGutter)) { return } + var comp = compensateForHScroll(display) - display.scroller.scrollLeft + cm.doc.scrollLeft; + var gutterW = display.gutters.offsetWidth, left = comp + "px"; + for (var i = 0; i < view.length; i++) { if (!view[i].hidden) { + if (cm.options.fixedGutter) { + if (view[i].gutter) + { view[i].gutter.style.left = left; } + if (view[i].gutterBackground) + { view[i].gutterBackground.style.left = left; } + } + var align = view[i].alignable; + if (align) { for (var j = 0; j < align.length; j++) + { align[j].style.left = left; } } + } } + if (cm.options.fixedGutter) + { display.gutters.style.left = (comp + gutterW) + "px"; } + } + + // Used to ensure that the line number gutter is still the right + // size for the current document size. Returns true when an update + // is needed. + function maybeUpdateLineNumberWidth(cm) { + if (!cm.options.lineNumbers) { return false } + var doc = cm.doc, last = lineNumberFor(cm.options, doc.first + doc.size - 1), display = cm.display; + if (last.length != display.lineNumChars) { + var test = display.measure.appendChild(elt("div", [elt("div", last)], + "CodeMirror-linenumber CodeMirror-gutter-elt")); + var innerW = test.firstChild.offsetWidth, padding = test.offsetWidth - innerW; + display.lineGutter.style.width = ""; + display.lineNumInnerWidth = Math.max(innerW, display.lineGutter.offsetWidth - padding) + 1; + display.lineNumWidth = display.lineNumInnerWidth + padding; + display.lineNumChars = display.lineNumInnerWidth ? last.length : -1; + display.lineGutter.style.width = display.lineNumWidth + "px"; + updateGutterSpace(cm.display); + return true + } + return false + } + + function getGutters(gutters, lineNumbers) { + var result = [], sawLineNumbers = false; + for (var i = 0; i < gutters.length; i++) { + var name = gutters[i], style = null; + if (typeof name != "string") { style = name.style; name = name.className; } + if (name == "CodeMirror-linenumbers") { + if (!lineNumbers) { continue } + else { sawLineNumbers = true; } + } + result.push({className: name, style: style}); + } + if (lineNumbers && !sawLineNumbers) { result.push({className: "CodeMirror-linenumbers", style: null}); } + return result + } + + // Rebuild the gutter elements, ensure the margin to the left of the + // code matches their width. + function renderGutters(display) { + var gutters = display.gutters, specs = display.gutterSpecs; + removeChildren(gutters); + display.lineGutter = null; + for (var i = 0; i < specs.length; ++i) { + var ref = specs[i]; + var className = ref.className; + var style = ref.style; + var gElt = gutters.appendChild(elt("div", null, "CodeMirror-gutter " + className)); + if (style) { gElt.style.cssText = style; } + if (className == "CodeMirror-linenumbers") { + display.lineGutter = gElt; + gElt.style.width = (display.lineNumWidth || 1) + "px"; + } + } + gutters.style.display = specs.length ? "" : "none"; + updateGutterSpace(display); + } + + function updateGutters(cm) { + renderGutters(cm.display); + regChange(cm); + alignHorizontally(cm); + } + + // The display handles the DOM integration, both for input reading + // and content drawing. It holds references to DOM nodes and + // display-related state. + + function Display(place, doc, input, options) { + var d = this; + this.input = input; + + // Covers bottom-right square when both scrollbars are present. + d.scrollbarFiller = elt("div", null, "CodeMirror-scrollbar-filler"); + d.scrollbarFiller.setAttribute("cm-not-content", "true"); + // Covers bottom of gutter when coverGutterNextToScrollbar is on + // and h scrollbar is present. + d.gutterFiller = elt("div", null, "CodeMirror-gutter-filler"); + d.gutterFiller.setAttribute("cm-not-content", "true"); + // Will contain the actual code, positioned to cover the viewport. + d.lineDiv = eltP("div", null, "CodeMirror-code"); + // Elements are added to these to represent selection and cursors. + d.selectionDiv = elt("div", null, null, "position: relative; z-index: 1"); + d.cursorDiv = elt("div", null, "CodeMirror-cursors"); + // A visibility: hidden element used to find the size of things. + d.measure = elt("div", null, "CodeMirror-measure"); + // When lines outside of the viewport are measured, they are drawn in this. + d.lineMeasure = elt("div", null, "CodeMirror-measure"); + // Wraps everything that needs to exist inside the vertically-padded coordinate system + d.lineSpace = eltP("div", [d.measure, d.lineMeasure, d.selectionDiv, d.cursorDiv, d.lineDiv], + null, "position: relative; outline: none"); + var lines = eltP("div", [d.lineSpace], "CodeMirror-lines"); + // Moved around its parent to cover visible view. + d.mover = elt("div", [lines], null, "position: relative"); + // Set to the height of the document, allowing scrolling. + d.sizer = elt("div", [d.mover], "CodeMirror-sizer"); + d.sizerWidth = null; + // Behavior of elts with overflow: auto and padding is + // inconsistent across browsers. This is used to ensure the + // scrollable area is big enough. + d.heightForcer = elt("div", null, null, "position: absolute; height: " + scrollerGap + "px; width: 1px;"); + // Will contain the gutters, if any. + d.gutters = elt("div", null, "CodeMirror-gutters"); + d.lineGutter = null; + // Actual scrollable element. + d.scroller = elt("div", [d.sizer, d.heightForcer, d.gutters], "CodeMirror-scroll"); + d.scroller.setAttribute("tabIndex", "-1"); + // The element in which the editor lives. + d.wrapper = elt("div", [d.scrollbarFiller, d.gutterFiller, d.scroller], "CodeMirror"); + + // Work around IE7 z-index bug (not perfect, hence IE7 not really being supported) + if (ie && ie_version < 8) { d.gutters.style.zIndex = -1; d.scroller.style.paddingRight = 0; } + if (!webkit && !(gecko && mobile)) { d.scroller.draggable = true; } + + if (place) { + if (place.appendChild) { place.appendChild(d.wrapper); } + else { place(d.wrapper); } + } + + // Current rendered range (may be bigger than the view window). + d.viewFrom = d.viewTo = doc.first; + d.reportedViewFrom = d.reportedViewTo = doc.first; + // Information about the rendered lines. + d.view = []; + d.renderedView = null; + // Holds info about a single rendered line when it was rendered + // for measurement, while not in view. + d.externalMeasured = null; + // Empty space (in pixels) above the view + d.viewOffset = 0; + d.lastWrapHeight = d.lastWrapWidth = 0; + d.updateLineNumbers = null; + + d.nativeBarWidth = d.barHeight = d.barWidth = 0; + d.scrollbarsClipped = false; + + // Used to only resize the line number gutter when necessary (when + // the amount of lines crosses a boundary that makes its width change) + d.lineNumWidth = d.lineNumInnerWidth = d.lineNumChars = null; + // Set to true when a non-horizontal-scrolling line widget is + // added. As an optimization, line widget aligning is skipped when + // this is false. + d.alignWidgets = false; + + d.cachedCharWidth = d.cachedTextHeight = d.cachedPaddingH = null; + + // Tracks the maximum line length so that the horizontal scrollbar + // can be kept static when scrolling. + d.maxLine = null; + d.maxLineLength = 0; + d.maxLineChanged = false; + + // Used for measuring wheel scrolling granularity + d.wheelDX = d.wheelDY = d.wheelStartX = d.wheelStartY = null; + + // True when shift is held down. + d.shift = false; + + // Used to track whether anything happened since the context menu + // was opened. + d.selForContextMenu = null; + + d.activeTouch = null; + + d.gutterSpecs = getGutters(options.gutters, options.lineNumbers); + renderGutters(d); + + input.init(d); + } + + // Since the delta values reported on mouse wheel events are + // unstandardized between browsers and even browser versions, and + // generally horribly unpredictable, this code starts by measuring + // the scroll effect that the first few mouse wheel events have, + // and, from that, detects the way it can convert deltas to pixel + // offsets afterwards. + // + // The reason we want to know the amount a wheel event will scroll + // is that it gives us a chance to update the display before the + // actual scrolling happens, reducing flickering. + + var wheelSamples = 0, wheelPixelsPerUnit = null; + // Fill in a browser-detected starting value on browsers where we + // know one. These don't have to be accurate -- the result of them + // being wrong would just be a slight flicker on the first wheel + // scroll (if it is large enough). + if (ie) { wheelPixelsPerUnit = -.53; } + else if (gecko) { wheelPixelsPerUnit = 15; } + else if (chrome) { wheelPixelsPerUnit = -.7; } + else if (safari) { wheelPixelsPerUnit = -1/3; } + + function wheelEventDelta(e) { + var dx = e.wheelDeltaX, dy = e.wheelDeltaY; + if (dx == null && e.detail && e.axis == e.HORIZONTAL_AXIS) { dx = e.detail; } + if (dy == null && e.detail && e.axis == e.VERTICAL_AXIS) { dy = e.detail; } + else if (dy == null) { dy = e.wheelDelta; } + return {x: dx, y: dy} + } + function wheelEventPixels(e) { + var delta = wheelEventDelta(e); + delta.x *= wheelPixelsPerUnit; + delta.y *= wheelPixelsPerUnit; + return delta + } + + function onScrollWheel(cm, e) { + var delta = wheelEventDelta(e), dx = delta.x, dy = delta.y; + + var display = cm.display, scroll = display.scroller; + // Quit if there's nothing to scroll here + var canScrollX = scroll.scrollWidth > scroll.clientWidth; + var canScrollY = scroll.scrollHeight > scroll.clientHeight; + if (!(dx && canScrollX || dy && canScrollY)) { return } + + // Webkit browsers on OS X abort momentum scrolls when the target + // of the scroll event is removed from the scrollable element. + // This hack (see related code in patchDisplay) makes sure the + // element is kept around. + if (dy && mac && webkit) { + outer: for (var cur = e.target, view = display.view; cur != scroll; cur = cur.parentNode) { + for (var i = 0; i < view.length; i++) { + if (view[i].node == cur) { + cm.display.currentWheelTarget = cur; + break outer + } + } + } + } + + // On some browsers, horizontal scrolling will cause redraws to + // happen before the gutter has been realigned, causing it to + // wriggle around in a most unseemly way. When we have an + // estimated pixels/delta value, we just handle horizontal + // scrolling entirely here. It'll be slightly off from native, but + // better than glitching out. + if (dx && !gecko && !presto && wheelPixelsPerUnit != null) { + if (dy && canScrollY) + { updateScrollTop(cm, Math.max(0, scroll.scrollTop + dy * wheelPixelsPerUnit)); } + setScrollLeft(cm, Math.max(0, scroll.scrollLeft + dx * wheelPixelsPerUnit)); + // Only prevent default scrolling if vertical scrolling is + // actually possible. Otherwise, it causes vertical scroll + // jitter on OSX trackpads when deltaX is small and deltaY + // is large (issue #3579) + if (!dy || (dy && canScrollY)) + { e_preventDefault(e); } + display.wheelStartX = null; // Abort measurement, if in progress + return + } + + // 'Project' the visible viewport to cover the area that is being + // scrolled into view (if we know enough to estimate it). + if (dy && wheelPixelsPerUnit != null) { + var pixels = dy * wheelPixelsPerUnit; + var top = cm.doc.scrollTop, bot = top + display.wrapper.clientHeight; + if (pixels < 0) { top = Math.max(0, top + pixels - 50); } + else { bot = Math.min(cm.doc.height, bot + pixels + 50); } + updateDisplaySimple(cm, {top: top, bottom: bot}); + } + + if (wheelSamples < 20) { + if (display.wheelStartX == null) { + display.wheelStartX = scroll.scrollLeft; display.wheelStartY = scroll.scrollTop; + display.wheelDX = dx; display.wheelDY = dy; + setTimeout(function () { + if (display.wheelStartX == null) { return } + var movedX = scroll.scrollLeft - display.wheelStartX; + var movedY = scroll.scrollTop - display.wheelStartY; + var sample = (movedY && display.wheelDY && movedY / display.wheelDY) || + (movedX && display.wheelDX && movedX / display.wheelDX); + display.wheelStartX = display.wheelStartY = null; + if (!sample) { return } + wheelPixelsPerUnit = (wheelPixelsPerUnit * wheelSamples + sample) / (wheelSamples + 1); + ++wheelSamples; + }, 200); + } else { + display.wheelDX += dx; display.wheelDY += dy; + } + } + } + + // Selection objects are immutable. A new one is created every time + // the selection changes. A selection is one or more non-overlapping + // (and non-touching) ranges, sorted, and an integer that indicates + // which one is the primary selection (the one that's scrolled into + // view, that getCursor returns, etc). + var Selection = function(ranges, primIndex) { + this.ranges = ranges; + this.primIndex = primIndex; + }; + + Selection.prototype.primary = function () { return this.ranges[this.primIndex] }; + + Selection.prototype.equals = function (other) { + var this$1 = this; + + if (other == this) { return true } + if (other.primIndex != this.primIndex || other.ranges.length != this.ranges.length) { return false } + for (var i = 0; i < this.ranges.length; i++) { + var here = this$1.ranges[i], there = other.ranges[i]; + if (!equalCursorPos(here.anchor, there.anchor) || !equalCursorPos(here.head, there.head)) { return false } + } + return true + }; + + Selection.prototype.deepCopy = function () { + var this$1 = this; + + var out = []; + for (var i = 0; i < this.ranges.length; i++) + { out[i] = new Range(copyPos(this$1.ranges[i].anchor), copyPos(this$1.ranges[i].head)); } + return new Selection(out, this.primIndex) + }; + + Selection.prototype.somethingSelected = function () { + var this$1 = this; + + for (var i = 0; i < this.ranges.length; i++) + { if (!this$1.ranges[i].empty()) { return true } } + return false + }; + + Selection.prototype.contains = function (pos, end) { + var this$1 = this; + + if (!end) { end = pos; } + for (var i = 0; i < this.ranges.length; i++) { + var range = this$1.ranges[i]; + if (cmp(end, range.from()) >= 0 && cmp(pos, range.to()) <= 0) + { return i } + } + return -1 + }; + + var Range = function(anchor, head) { + this.anchor = anchor; this.head = head; + }; + + Range.prototype.from = function () { return minPos(this.anchor, this.head) }; + Range.prototype.to = function () { return maxPos(this.anchor, this.head) }; + Range.prototype.empty = function () { return this.head.line == this.anchor.line && this.head.ch == this.anchor.ch }; + + // Take an unsorted, potentially overlapping set of ranges, and + // build a selection out of it. 'Consumes' ranges array (modifying + // it). + function normalizeSelection(cm, ranges, primIndex) { + var mayTouch = cm && cm.options.selectionsMayTouch; + var prim = ranges[primIndex]; + ranges.sort(function (a, b) { return cmp(a.from(), b.from()); }); + primIndex = indexOf(ranges, prim); + for (var i = 1; i < ranges.length; i++) { + var cur = ranges[i], prev = ranges[i - 1]; + var diff = cmp(prev.to(), cur.from()); + if (mayTouch && !cur.empty() ? diff > 0 : diff >= 0) { + var from = minPos(prev.from(), cur.from()), to = maxPos(prev.to(), cur.to()); + var inv = prev.empty() ? cur.from() == cur.head : prev.from() == prev.head; + if (i <= primIndex) { --primIndex; } + ranges.splice(--i, 2, new Range(inv ? to : from, inv ? from : to)); + } + } + return new Selection(ranges, primIndex) + } + + function simpleSelection(anchor, head) { + return new Selection([new Range(anchor, head || anchor)], 0) + } + + // Compute the position of the end of a change (its 'to' property + // refers to the pre-change end). + function changeEnd(change) { + if (!change.text) { return change.to } + return Pos(change.from.line + change.text.length - 1, + lst(change.text).length + (change.text.length == 1 ? change.from.ch : 0)) + } + + // Adjust a position to refer to the post-change position of the + // same text, or the end of the change if the change covers it. + function adjustForChange(pos, change) { + if (cmp(pos, change.from) < 0) { return pos } + if (cmp(pos, change.to) <= 0) { return changeEnd(change) } + + var line = pos.line + change.text.length - (change.to.line - change.from.line) - 1, ch = pos.ch; + if (pos.line == change.to.line) { ch += changeEnd(change).ch - change.to.ch; } + return Pos(line, ch) + } + + function computeSelAfterChange(doc, change) { + var out = []; + for (var i = 0; i < doc.sel.ranges.length; i++) { + var range = doc.sel.ranges[i]; + out.push(new Range(adjustForChange(range.anchor, change), + adjustForChange(range.head, change))); + } + return normalizeSelection(doc.cm, out, doc.sel.primIndex) + } + + function offsetPos(pos, old, nw) { + if (pos.line == old.line) + { return Pos(nw.line, pos.ch - old.ch + nw.ch) } + else + { return Pos(nw.line + (pos.line - old.line), pos.ch) } + } + + // Used by replaceSelections to allow moving the selection to the + // start or around the replaced test. Hint may be "start" or "around". + function computeReplacedSel(doc, changes, hint) { + var out = []; + var oldPrev = Pos(doc.first, 0), newPrev = oldPrev; + for (var i = 0; i < changes.length; i++) { + var change = changes[i]; + var from = offsetPos(change.from, oldPrev, newPrev); + var to = offsetPos(changeEnd(change), oldPrev, newPrev); + oldPrev = change.to; + newPrev = to; + if (hint == "around") { + var range = doc.sel.ranges[i], inv = cmp(range.head, range.anchor) < 0; + out[i] = new Range(inv ? to : from, inv ? from : to); + } else { + out[i] = new Range(from, from); + } + } + return new Selection(out, doc.sel.primIndex) + } + + // Used to get the editor into a consistent state again when options change. + + function loadMode(cm) { + cm.doc.mode = getMode(cm.options, cm.doc.modeOption); + resetModeState(cm); + } + + function resetModeState(cm) { + cm.doc.iter(function (line) { + if (line.stateAfter) { line.stateAfter = null; } + if (line.styles) { line.styles = null; } + }); + cm.doc.modeFrontier = cm.doc.highlightFrontier = cm.doc.first; + startWorker(cm, 100); + cm.state.modeGen++; + if (cm.curOp) { regChange(cm); } + } + + // DOCUMENT DATA STRUCTURE + + // By default, updates that start and end at the beginning of a line + // are treated specially, in order to make the association of line + // widgets and marker elements with the text behave more intuitive. + function isWholeLineUpdate(doc, change) { + return change.from.ch == 0 && change.to.ch == 0 && lst(change.text) == "" && + (!doc.cm || doc.cm.options.wholeLineUpdateBefore) + } + + // Perform a change on the document data structure. + function updateDoc(doc, change, markedSpans, estimateHeight$$1) { + function spansFor(n) {return markedSpans ? markedSpans[n] : null} + function update(line, text, spans) { + updateLine(line, text, spans, estimateHeight$$1); + signalLater(line, "change", line, change); + } + function linesFor(start, end) { + var result = []; + for (var i = start; i < end; ++i) + { result.push(new Line(text[i], spansFor(i), estimateHeight$$1)); } + return result + } + + var from = change.from, to = change.to, text = change.text; + var firstLine = getLine(doc, from.line), lastLine = getLine(doc, to.line); + var lastText = lst(text), lastSpans = spansFor(text.length - 1), nlines = to.line - from.line; + + // Adjust the line structure + if (change.full) { + doc.insert(0, linesFor(0, text.length)); + doc.remove(text.length, doc.size - text.length); + } else if (isWholeLineUpdate(doc, change)) { + // This is a whole-line replace. Treated specially to make + // sure line objects move the way they are supposed to. + var added = linesFor(0, text.length - 1); + update(lastLine, lastLine.text, lastSpans); + if (nlines) { doc.remove(from.line, nlines); } + if (added.length) { doc.insert(from.line, added); } + } else if (firstLine == lastLine) { + if (text.length == 1) { + update(firstLine, firstLine.text.slice(0, from.ch) + lastText + firstLine.text.slice(to.ch), lastSpans); + } else { + var added$1 = linesFor(1, text.length - 1); + added$1.push(new Line(lastText + firstLine.text.slice(to.ch), lastSpans, estimateHeight$$1)); + update(firstLine, firstLine.text.slice(0, from.ch) + text[0], spansFor(0)); + doc.insert(from.line + 1, added$1); + } + } else if (text.length == 1) { + update(firstLine, firstLine.text.slice(0, from.ch) + text[0] + lastLine.text.slice(to.ch), spansFor(0)); + doc.remove(from.line + 1, nlines); + } else { + update(firstLine, firstLine.text.slice(0, from.ch) + text[0], spansFor(0)); + update(lastLine, lastText + lastLine.text.slice(to.ch), lastSpans); + var added$2 = linesFor(1, text.length - 1); + if (nlines > 1) { doc.remove(from.line + 1, nlines - 1); } + doc.insert(from.line + 1, added$2); + } + + signalLater(doc, "change", doc, change); + } + + // Call f for all linked documents. + function linkedDocs(doc, f, sharedHistOnly) { + function propagate(doc, skip, sharedHist) { + if (doc.linked) { for (var i = 0; i < doc.linked.length; ++i) { + var rel = doc.linked[i]; + if (rel.doc == skip) { continue } + var shared = sharedHist && rel.sharedHist; + if (sharedHistOnly && !shared) { continue } + f(rel.doc, shared); + propagate(rel.doc, doc, shared); + } } + } + propagate(doc, null, true); + } + + // Attach a document to an editor. + function attachDoc(cm, doc) { + if (doc.cm) { throw new Error("This document is already in use.") } + cm.doc = doc; + doc.cm = cm; + estimateLineHeights(cm); + loadMode(cm); + setDirectionClass(cm); + if (!cm.options.lineWrapping) { findMaxLine(cm); } + cm.options.mode = doc.modeOption; + regChange(cm); + } + + function setDirectionClass(cm) { + (cm.doc.direction == "rtl" ? addClass : rmClass)(cm.display.lineDiv, "CodeMirror-rtl"); + } + + function directionChanged(cm) { + runInOp(cm, function () { + setDirectionClass(cm); + regChange(cm); + }); + } + + function History(startGen) { + // Arrays of change events and selections. Doing something adds an + // event to done and clears undo. Undoing moves events from done + // to undone, redoing moves them in the other direction. + this.done = []; this.undone = []; + this.undoDepth = Infinity; + // Used to track when changes can be merged into a single undo + // event + this.lastModTime = this.lastSelTime = 0; + this.lastOp = this.lastSelOp = null; + this.lastOrigin = this.lastSelOrigin = null; + // Used by the isClean() method + this.generation = this.maxGeneration = startGen || 1; + } + + // Create a history change event from an updateDoc-style change + // object. + function historyChangeFromChange(doc, change) { + var histChange = {from: copyPos(change.from), to: changeEnd(change), text: getBetween(doc, change.from, change.to)}; + attachLocalSpans(doc, histChange, change.from.line, change.to.line + 1); + linkedDocs(doc, function (doc) { return attachLocalSpans(doc, histChange, change.from.line, change.to.line + 1); }, true); + return histChange + } + + // Pop all selection events off the end of a history array. Stop at + // a change event. + function clearSelectionEvents(array) { + while (array.length) { + var last = lst(array); + if (last.ranges) { array.pop(); } + else { break } + } + } + + // Find the top change event in the history. Pop off selection + // events that are in the way. + function lastChangeEvent(hist, force) { + if (force) { + clearSelectionEvents(hist.done); + return lst(hist.done) + } else if (hist.done.length && !lst(hist.done).ranges) { + return lst(hist.done) + } else if (hist.done.length > 1 && !hist.done[hist.done.length - 2].ranges) { + hist.done.pop(); + return lst(hist.done) + } + } + + // Register a change in the history. Merges changes that are within + // a single operation, or are close together with an origin that + // allows merging (starting with "+") into a single event. + function addChangeToHistory(doc, change, selAfter, opId) { + var hist = doc.history; + hist.undone.length = 0; + var time = +new Date, cur; + var last; + + if ((hist.lastOp == opId || + hist.lastOrigin == change.origin && change.origin && + ((change.origin.charAt(0) == "+" && hist.lastModTime > time - (doc.cm ? doc.cm.options.historyEventDelay : 500)) || + change.origin.charAt(0) == "*")) && + (cur = lastChangeEvent(hist, hist.lastOp == opId))) { + // Merge this change into the last event + last = lst(cur.changes); + if (cmp(change.from, change.to) == 0 && cmp(change.from, last.to) == 0) { + // Optimized case for simple insertion -- don't want to add + // new changesets for every character typed + last.to = changeEnd(change); + } else { + // Add new sub-event + cur.changes.push(historyChangeFromChange(doc, change)); + } + } else { + // Can not be merged, start a new event. + var before = lst(hist.done); + if (!before || !before.ranges) + { pushSelectionToHistory(doc.sel, hist.done); } + cur = {changes: [historyChangeFromChange(doc, change)], + generation: hist.generation}; + hist.done.push(cur); + while (hist.done.length > hist.undoDepth) { + hist.done.shift(); + if (!hist.done[0].ranges) { hist.done.shift(); } + } + } + hist.done.push(selAfter); + hist.generation = ++hist.maxGeneration; + hist.lastModTime = hist.lastSelTime = time; + hist.lastOp = hist.lastSelOp = opId; + hist.lastOrigin = hist.lastSelOrigin = change.origin; + + if (!last) { signal(doc, "historyAdded"); } + } + + function selectionEventCanBeMerged(doc, origin, prev, sel) { + var ch = origin.charAt(0); + return ch == "*" || + ch == "+" && + prev.ranges.length == sel.ranges.length && + prev.somethingSelected() == sel.somethingSelected() && + new Date - doc.history.lastSelTime <= (doc.cm ? doc.cm.options.historyEventDelay : 500) + } + + // Called whenever the selection changes, sets the new selection as + // the pending selection in the history, and pushes the old pending + // selection into the 'done' array when it was significantly + // different (in number of selected ranges, emptiness, or time). + function addSelectionToHistory(doc, sel, opId, options) { + var hist = doc.history, origin = options && options.origin; + + // A new event is started when the previous origin does not match + // the current, or the origins don't allow matching. Origins + // starting with * are always merged, those starting with + are + // merged when similar and close together in time. + if (opId == hist.lastSelOp || + (origin && hist.lastSelOrigin == origin && + (hist.lastModTime == hist.lastSelTime && hist.lastOrigin == origin || + selectionEventCanBeMerged(doc, origin, lst(hist.done), sel)))) + { hist.done[hist.done.length - 1] = sel; } + else + { pushSelectionToHistory(sel, hist.done); } + + hist.lastSelTime = +new Date; + hist.lastSelOrigin = origin; + hist.lastSelOp = opId; + if (options && options.clearRedo !== false) + { clearSelectionEvents(hist.undone); } + } + + function pushSelectionToHistory(sel, dest) { + var top = lst(dest); + if (!(top && top.ranges && top.equals(sel))) + { dest.push(sel); } + } + + // Used to store marked span information in the history. + function attachLocalSpans(doc, change, from, to) { + var existing = change["spans_" + doc.id], n = 0; + doc.iter(Math.max(doc.first, from), Math.min(doc.first + doc.size, to), function (line) { + if (line.markedSpans) + { (existing || (existing = change["spans_" + doc.id] = {}))[n] = line.markedSpans; } + ++n; + }); + } + + // When un/re-doing restores text containing marked spans, those + // that have been explicitly cleared should not be restored. + function removeClearedSpans(spans) { + if (!spans) { return null } + var out; + for (var i = 0; i < spans.length; ++i) { + if (spans[i].marker.explicitlyCleared) { if (!out) { out = spans.slice(0, i); } } + else if (out) { out.push(spans[i]); } + } + return !out ? spans : out.length ? out : null + } + + // Retrieve and filter the old marked spans stored in a change event. + function getOldSpans(doc, change) { + var found = change["spans_" + doc.id]; + if (!found) { return null } + var nw = []; + for (var i = 0; i < change.text.length; ++i) + { nw.push(removeClearedSpans(found[i])); } + return nw + } + + // Used for un/re-doing changes from the history. Combines the + // result of computing the existing spans with the set of spans that + // existed in the history (so that deleting around a span and then + // undoing brings back the span). + function mergeOldSpans(doc, change) { + var old = getOldSpans(doc, change); + var stretched = stretchSpansOverChange(doc, change); + if (!old) { return stretched } + if (!stretched) { return old } + + for (var i = 0; i < old.length; ++i) { + var oldCur = old[i], stretchCur = stretched[i]; + if (oldCur && stretchCur) { + spans: for (var j = 0; j < stretchCur.length; ++j) { + var span = stretchCur[j]; + for (var k = 0; k < oldCur.length; ++k) + { if (oldCur[k].marker == span.marker) { continue spans } } + oldCur.push(span); + } + } else if (stretchCur) { + old[i] = stretchCur; + } + } + return old + } + + // Used both to provide a JSON-safe object in .getHistory, and, when + // detaching a document, to split the history in two + function copyHistoryArray(events, newGroup, instantiateSel) { + var copy = []; + for (var i = 0; i < events.length; ++i) { + var event = events[i]; + if (event.ranges) { + copy.push(instantiateSel ? Selection.prototype.deepCopy.call(event) : event); + continue + } + var changes = event.changes, newChanges = []; + copy.push({changes: newChanges}); + for (var j = 0; j < changes.length; ++j) { + var change = changes[j], m = (void 0); + newChanges.push({from: change.from, to: change.to, text: change.text}); + if (newGroup) { for (var prop in change) { if (m = prop.match(/^spans_(\d+)$/)) { + if (indexOf(newGroup, Number(m[1])) > -1) { + lst(newChanges)[prop] = change[prop]; + delete change[prop]; + } + } } } + } + } + return copy + } + + // The 'scroll' parameter given to many of these indicated whether + // the new cursor position should be scrolled into view after + // modifying the selection. + + // If shift is held or the extend flag is set, extends a range to + // include a given position (and optionally a second position). + // Otherwise, simply returns the range between the given positions. + // Used for cursor motion and such. + function extendRange(range, head, other, extend) { + if (extend) { + var anchor = range.anchor; + if (other) { + var posBefore = cmp(head, anchor) < 0; + if (posBefore != (cmp(other, anchor) < 0)) { + anchor = head; + head = other; + } else if (posBefore != (cmp(head, other) < 0)) { + head = other; + } + } + return new Range(anchor, head) + } else { + return new Range(other || head, head) + } + } + + // Extend the primary selection range, discard the rest. + function extendSelection(doc, head, other, options, extend) { + if (extend == null) { extend = doc.cm && (doc.cm.display.shift || doc.extend); } + setSelection(doc, new Selection([extendRange(doc.sel.primary(), head, other, extend)], 0), options); + } + + // Extend all selections (pos is an array of selections with length + // equal the number of selections) + function extendSelections(doc, heads, options) { + var out = []; + var extend = doc.cm && (doc.cm.display.shift || doc.extend); + for (var i = 0; i < doc.sel.ranges.length; i++) + { out[i] = extendRange(doc.sel.ranges[i], heads[i], null, extend); } + var newSel = normalizeSelection(doc.cm, out, doc.sel.primIndex); + setSelection(doc, newSel, options); + } + + // Updates a single range in the selection. + function replaceOneSelection(doc, i, range, options) { + var ranges = doc.sel.ranges.slice(0); + ranges[i] = range; + setSelection(doc, normalizeSelection(doc.cm, ranges, doc.sel.primIndex), options); + } + + // Reset the selection to a single range. + function setSimpleSelection(doc, anchor, head, options) { + setSelection(doc, simpleSelection(anchor, head), options); + } + + // Give beforeSelectionChange handlers a change to influence a + // selection update. + function filterSelectionChange(doc, sel, options) { + var obj = { + ranges: sel.ranges, + update: function(ranges) { + var this$1 = this; + + this.ranges = []; + for (var i = 0; i < ranges.length; i++) + { this$1.ranges[i] = new Range(clipPos(doc, ranges[i].anchor), + clipPos(doc, ranges[i].head)); } + }, + origin: options && options.origin + }; + signal(doc, "beforeSelectionChange", doc, obj); + if (doc.cm) { signal(doc.cm, "beforeSelectionChange", doc.cm, obj); } + if (obj.ranges != sel.ranges) { return normalizeSelection(doc.cm, obj.ranges, obj.ranges.length - 1) } + else { return sel } + } + + function setSelectionReplaceHistory(doc, sel, options) { + var done = doc.history.done, last = lst(done); + if (last && last.ranges) { + done[done.length - 1] = sel; + setSelectionNoUndo(doc, sel, options); + } else { + setSelection(doc, sel, options); + } + } + + // Set a new selection. + function setSelection(doc, sel, options) { + setSelectionNoUndo(doc, sel, options); + addSelectionToHistory(doc, doc.sel, doc.cm ? doc.cm.curOp.id : NaN, options); + } + + function setSelectionNoUndo(doc, sel, options) { + if (hasHandler(doc, "beforeSelectionChange") || doc.cm && hasHandler(doc.cm, "beforeSelectionChange")) + { sel = filterSelectionChange(doc, sel, options); } + + var bias = options && options.bias || + (cmp(sel.primary().head, doc.sel.primary().head) < 0 ? -1 : 1); + setSelectionInner(doc, skipAtomicInSelection(doc, sel, bias, true)); + + if (!(options && options.scroll === false) && doc.cm) + { ensureCursorVisible(doc.cm); } + } + + function setSelectionInner(doc, sel) { + if (sel.equals(doc.sel)) { return } + + doc.sel = sel; + + if (doc.cm) { + doc.cm.curOp.updateInput = 1; + doc.cm.curOp.selectionChanged = true; + signalCursorActivity(doc.cm); + } + signalLater(doc, "cursorActivity", doc); + } + + // Verify that the selection does not partially select any atomic + // marked ranges. + function reCheckSelection(doc) { + setSelectionInner(doc, skipAtomicInSelection(doc, doc.sel, null, false)); + } + + // Return a selection that does not partially select any atomic + // ranges. + function skipAtomicInSelection(doc, sel, bias, mayClear) { + var out; + for (var i = 0; i < sel.ranges.length; i++) { + var range = sel.ranges[i]; + var old = sel.ranges.length == doc.sel.ranges.length && doc.sel.ranges[i]; + var newAnchor = skipAtomic(doc, range.anchor, old && old.anchor, bias, mayClear); + var newHead = skipAtomic(doc, range.head, old && old.head, bias, mayClear); + if (out || newAnchor != range.anchor || newHead != range.head) { + if (!out) { out = sel.ranges.slice(0, i); } + out[i] = new Range(newAnchor, newHead); + } + } + return out ? normalizeSelection(doc.cm, out, sel.primIndex) : sel + } + + function skipAtomicInner(doc, pos, oldPos, dir, mayClear) { + var line = getLine(doc, pos.line); + if (line.markedSpans) { for (var i = 0; i < line.markedSpans.length; ++i) { + var sp = line.markedSpans[i], m = sp.marker; + + // Determine if we should prevent the cursor being placed to the left/right of an atomic marker + // Historically this was determined using the inclusiveLeft/Right option, but the new way to control it + // is with selectLeft/Right + var preventCursorLeft = ("selectLeft" in m) ? !m.selectLeft : m.inclusiveLeft; + var preventCursorRight = ("selectRight" in m) ? !m.selectRight : m.inclusiveRight; + + if ((sp.from == null || (preventCursorLeft ? sp.from <= pos.ch : sp.from < pos.ch)) && + (sp.to == null || (preventCursorRight ? sp.to >= pos.ch : sp.to > pos.ch))) { + if (mayClear) { + signal(m, "beforeCursorEnter"); + if (m.explicitlyCleared) { + if (!line.markedSpans) { break } + else {--i; continue} + } + } + if (!m.atomic) { continue } + + if (oldPos) { + var near = m.find(dir < 0 ? 1 : -1), diff = (void 0); + if (dir < 0 ? preventCursorRight : preventCursorLeft) + { near = movePos(doc, near, -dir, near && near.line == pos.line ? line : null); } + if (near && near.line == pos.line && (diff = cmp(near, oldPos)) && (dir < 0 ? diff < 0 : diff > 0)) + { return skipAtomicInner(doc, near, pos, dir, mayClear) } + } + + var far = m.find(dir < 0 ? -1 : 1); + if (dir < 0 ? preventCursorLeft : preventCursorRight) + { far = movePos(doc, far, dir, far.line == pos.line ? line : null); } + return far ? skipAtomicInner(doc, far, pos, dir, mayClear) : null + } + } } + return pos + } + + // Ensure a given position is not inside an atomic range. + function skipAtomic(doc, pos, oldPos, bias, mayClear) { + var dir = bias || 1; + var found = skipAtomicInner(doc, pos, oldPos, dir, mayClear) || + (!mayClear && skipAtomicInner(doc, pos, oldPos, dir, true)) || + skipAtomicInner(doc, pos, oldPos, -dir, mayClear) || + (!mayClear && skipAtomicInner(doc, pos, oldPos, -dir, true)); + if (!found) { + doc.cantEdit = true; + return Pos(doc.first, 0) + } + return found + } + + function movePos(doc, pos, dir, line) { + if (dir < 0 && pos.ch == 0) { + if (pos.line > doc.first) { return clipPos(doc, Pos(pos.line - 1)) } + else { return null } + } else if (dir > 0 && pos.ch == (line || getLine(doc, pos.line)).text.length) { + if (pos.line < doc.first + doc.size - 1) { return Pos(pos.line + 1, 0) } + else { return null } + } else { + return new Pos(pos.line, pos.ch + dir) + } + } + + function selectAll(cm) { + cm.setSelection(Pos(cm.firstLine(), 0), Pos(cm.lastLine()), sel_dontScroll); + } + + // UPDATING + + // Allow "beforeChange" event handlers to influence a change + function filterChange(doc, change, update) { + var obj = { + canceled: false, + from: change.from, + to: change.to, + text: change.text, + origin: change.origin, + cancel: function () { return obj.canceled = true; } + }; + if (update) { obj.update = function (from, to, text, origin) { + if (from) { obj.from = clipPos(doc, from); } + if (to) { obj.to = clipPos(doc, to); } + if (text) { obj.text = text; } + if (origin !== undefined) { obj.origin = origin; } + }; } + signal(doc, "beforeChange", doc, obj); + if (doc.cm) { signal(doc.cm, "beforeChange", doc.cm, obj); } + + if (obj.canceled) { + if (doc.cm) { doc.cm.curOp.updateInput = 2; } + return null + } + return {from: obj.from, to: obj.to, text: obj.text, origin: obj.origin} + } + + // Apply a change to a document, and add it to the document's + // history, and propagating it to all linked documents. + function makeChange(doc, change, ignoreReadOnly) { + if (doc.cm) { + if (!doc.cm.curOp) { return operation(doc.cm, makeChange)(doc, change, ignoreReadOnly) } + if (doc.cm.state.suppressEdits) { return } + } + + if (hasHandler(doc, "beforeChange") || doc.cm && hasHandler(doc.cm, "beforeChange")) { + change = filterChange(doc, change, true); + if (!change) { return } + } + + // Possibly split or suppress the update based on the presence + // of read-only spans in its range. + var split = sawReadOnlySpans && !ignoreReadOnly && removeReadOnlyRanges(doc, change.from, change.to); + if (split) { + for (var i = split.length - 1; i >= 0; --i) + { makeChangeInner(doc, {from: split[i].from, to: split[i].to, text: i ? [""] : change.text, origin: change.origin}); } + } else { + makeChangeInner(doc, change); + } + } + + function makeChangeInner(doc, change) { + if (change.text.length == 1 && change.text[0] == "" && cmp(change.from, change.to) == 0) { return } + var selAfter = computeSelAfterChange(doc, change); + addChangeToHistory(doc, change, selAfter, doc.cm ? doc.cm.curOp.id : NaN); + + makeChangeSingleDoc(doc, change, selAfter, stretchSpansOverChange(doc, change)); + var rebased = []; + + linkedDocs(doc, function (doc, sharedHist) { + if (!sharedHist && indexOf(rebased, doc.history) == -1) { + rebaseHist(doc.history, change); + rebased.push(doc.history); + } + makeChangeSingleDoc(doc, change, null, stretchSpansOverChange(doc, change)); + }); + } + + // Revert a change stored in a document's history. + function makeChangeFromHistory(doc, type, allowSelectionOnly) { + var suppress = doc.cm && doc.cm.state.suppressEdits; + if (suppress && !allowSelectionOnly) { return } + + var hist = doc.history, event, selAfter = doc.sel; + var source = type == "undo" ? hist.done : hist.undone, dest = type == "undo" ? hist.undone : hist.done; + + // Verify that there is a useable event (so that ctrl-z won't + // needlessly clear selection events) + var i = 0; + for (; i < source.length; i++) { + event = source[i]; + if (allowSelectionOnly ? event.ranges && !event.equals(doc.sel) : !event.ranges) + { break } + } + if (i == source.length) { return } + hist.lastOrigin = hist.lastSelOrigin = null; + + for (;;) { + event = source.pop(); + if (event.ranges) { + pushSelectionToHistory(event, dest); + if (allowSelectionOnly && !event.equals(doc.sel)) { + setSelection(doc, event, {clearRedo: false}); + return + } + selAfter = event; + } else if (suppress) { + source.push(event); + return + } else { break } + } + + // Build up a reverse change object to add to the opposite history + // stack (redo when undoing, and vice versa). + var antiChanges = []; + pushSelectionToHistory(selAfter, dest); + dest.push({changes: antiChanges, generation: hist.generation}); + hist.generation = event.generation || ++hist.maxGeneration; + + var filter = hasHandler(doc, "beforeChange") || doc.cm && hasHandler(doc.cm, "beforeChange"); + + var loop = function ( i ) { + var change = event.changes[i]; + change.origin = type; + if (filter && !filterChange(doc, change, false)) { + source.length = 0; + return {} + } + + antiChanges.push(historyChangeFromChange(doc, change)); + + var after = i ? computeSelAfterChange(doc, change) : lst(source); + makeChangeSingleDoc(doc, change, after, mergeOldSpans(doc, change)); + if (!i && doc.cm) { doc.cm.scrollIntoView({from: change.from, to: changeEnd(change)}); } + var rebased = []; + + // Propagate to the linked documents + linkedDocs(doc, function (doc, sharedHist) { + if (!sharedHist && indexOf(rebased, doc.history) == -1) { + rebaseHist(doc.history, change); + rebased.push(doc.history); + } + makeChangeSingleDoc(doc, change, null, mergeOldSpans(doc, change)); + }); + }; + + for (var i$1 = event.changes.length - 1; i$1 >= 0; --i$1) { + var returned = loop( i$1 ); + + if ( returned ) return returned.v; + } + } + + // Sub-views need their line numbers shifted when text is added + // above or below them in the parent document. + function shiftDoc(doc, distance) { + if (distance == 0) { return } + doc.first += distance; + doc.sel = new Selection(map(doc.sel.ranges, function (range) { return new Range( + Pos(range.anchor.line + distance, range.anchor.ch), + Pos(range.head.line + distance, range.head.ch) + ); }), doc.sel.primIndex); + if (doc.cm) { + regChange(doc.cm, doc.first, doc.first - distance, distance); + for (var d = doc.cm.display, l = d.viewFrom; l < d.viewTo; l++) + { regLineChange(doc.cm, l, "gutter"); } + } + } + + // More lower-level change function, handling only a single document + // (not linked ones). + function makeChangeSingleDoc(doc, change, selAfter, spans) { + if (doc.cm && !doc.cm.curOp) + { return operation(doc.cm, makeChangeSingleDoc)(doc, change, selAfter, spans) } + + if (change.to.line < doc.first) { + shiftDoc(doc, change.text.length - 1 - (change.to.line - change.from.line)); + return + } + if (change.from.line > doc.lastLine()) { return } + + // Clip the change to the size of this doc + if (change.from.line < doc.first) { + var shift = change.text.length - 1 - (doc.first - change.from.line); + shiftDoc(doc, shift); + change = {from: Pos(doc.first, 0), to: Pos(change.to.line + shift, change.to.ch), + text: [lst(change.text)], origin: change.origin}; + } + var last = doc.lastLine(); + if (change.to.line > last) { + change = {from: change.from, to: Pos(last, getLine(doc, last).text.length), + text: [change.text[0]], origin: change.origin}; + } + + change.removed = getBetween(doc, change.from, change.to); + + if (!selAfter) { selAfter = computeSelAfterChange(doc, change); } + if (doc.cm) { makeChangeSingleDocInEditor(doc.cm, change, spans); } + else { updateDoc(doc, change, spans); } + setSelectionNoUndo(doc, selAfter, sel_dontScroll); + + if (doc.cantEdit && skipAtomic(doc, Pos(doc.firstLine(), 0))) + { doc.cantEdit = false; } + } + + // Handle the interaction of a change to a document with the editor + // that this document is part of. + function makeChangeSingleDocInEditor(cm, change, spans) { + var doc = cm.doc, display = cm.display, from = change.from, to = change.to; + + var recomputeMaxLength = false, checkWidthStart = from.line; + if (!cm.options.lineWrapping) { + checkWidthStart = lineNo(visualLine(getLine(doc, from.line))); + doc.iter(checkWidthStart, to.line + 1, function (line) { + if (line == display.maxLine) { + recomputeMaxLength = true; + return true + } + }); + } + + if (doc.sel.contains(change.from, change.to) > -1) + { signalCursorActivity(cm); } + + updateDoc(doc, change, spans, estimateHeight(cm)); + + if (!cm.options.lineWrapping) { + doc.iter(checkWidthStart, from.line + change.text.length, function (line) { + var len = lineLength(line); + if (len > display.maxLineLength) { + display.maxLine = line; + display.maxLineLength = len; + display.maxLineChanged = true; + recomputeMaxLength = false; + } + }); + if (recomputeMaxLength) { cm.curOp.updateMaxLine = true; } + } + + retreatFrontier(doc, from.line); + startWorker(cm, 400); + + var lendiff = change.text.length - (to.line - from.line) - 1; + // Remember that these lines changed, for updating the display + if (change.full) + { regChange(cm); } + else if (from.line == to.line && change.text.length == 1 && !isWholeLineUpdate(cm.doc, change)) + { regLineChange(cm, from.line, "text"); } + else + { regChange(cm, from.line, to.line + 1, lendiff); } + + var changesHandler = hasHandler(cm, "changes"), changeHandler = hasHandler(cm, "change"); + if (changeHandler || changesHandler) { + var obj = { + from: from, to: to, + text: change.text, + removed: change.removed, + origin: change.origin + }; + if (changeHandler) { signalLater(cm, "change", cm, obj); } + if (changesHandler) { (cm.curOp.changeObjs || (cm.curOp.changeObjs = [])).push(obj); } + } + cm.display.selForContextMenu = null; + } + + function replaceRange(doc, code, from, to, origin) { + var assign; + + if (!to) { to = from; } + if (cmp(to, from) < 0) { (assign = [to, from], from = assign[0], to = assign[1]); } + if (typeof code == "string") { code = doc.splitLines(code); } + makeChange(doc, {from: from, to: to, text: code, origin: origin}); + } + + // Rebasing/resetting history to deal with externally-sourced changes + + function rebaseHistSelSingle(pos, from, to, diff) { + if (to < pos.line) { + pos.line += diff; + } else if (from < pos.line) { + pos.line = from; + pos.ch = 0; + } + } + + // Tries to rebase an array of history events given a change in the + // document. If the change touches the same lines as the event, the + // event, and everything 'behind' it, is discarded. If the change is + // before the event, the event's positions are updated. Uses a + // copy-on-write scheme for the positions, to avoid having to + // reallocate them all on every rebase, but also avoid problems with + // shared position objects being unsafely updated. + function rebaseHistArray(array, from, to, diff) { + for (var i = 0; i < array.length; ++i) { + var sub = array[i], ok = true; + if (sub.ranges) { + if (!sub.copied) { sub = array[i] = sub.deepCopy(); sub.copied = true; } + for (var j = 0; j < sub.ranges.length; j++) { + rebaseHistSelSingle(sub.ranges[j].anchor, from, to, diff); + rebaseHistSelSingle(sub.ranges[j].head, from, to, diff); + } + continue + } + for (var j$1 = 0; j$1 < sub.changes.length; ++j$1) { + var cur = sub.changes[j$1]; + if (to < cur.from.line) { + cur.from = Pos(cur.from.line + diff, cur.from.ch); + cur.to = Pos(cur.to.line + diff, cur.to.ch); + } else if (from <= cur.to.line) { + ok = false; + break + } + } + if (!ok) { + array.splice(0, i + 1); + i = 0; + } + } + } + + function rebaseHist(hist, change) { + var from = change.from.line, to = change.to.line, diff = change.text.length - (to - from) - 1; + rebaseHistArray(hist.done, from, to, diff); + rebaseHistArray(hist.undone, from, to, diff); + } + + // Utility for applying a change to a line by handle or number, + // returning the number and optionally registering the line as + // changed. + function changeLine(doc, handle, changeType, op) { + var no = handle, line = handle; + if (typeof handle == "number") { line = getLine(doc, clipLine(doc, handle)); } + else { no = lineNo(handle); } + if (no == null) { return null } + if (op(line, no) && doc.cm) { regLineChange(doc.cm, no, changeType); } + return line + } + + // The document is represented as a BTree consisting of leaves, with + // chunk of lines in them, and branches, with up to ten leaves or + // other branch nodes below them. The top node is always a branch + // node, and is the document object itself (meaning it has + // additional methods and properties). + // + // All nodes have parent links. The tree is used both to go from + // line numbers to line objects, and to go from objects to numbers. + // It also indexes by height, and is used to convert between height + // and line object, and to find the total height of the document. + // + // See also http://marijnhaverbeke.nl/blog/codemirror-line-tree.html + + function LeafChunk(lines) { + var this$1 = this; + + this.lines = lines; + this.parent = null; + var height = 0; + for (var i = 0; i < lines.length; ++i) { + lines[i].parent = this$1; + height += lines[i].height; + } + this.height = height; + } + + LeafChunk.prototype = { + chunkSize: function() { return this.lines.length }, + + // Remove the n lines at offset 'at'. + removeInner: function(at, n) { + var this$1 = this; + + for (var i = at, e = at + n; i < e; ++i) { + var line = this$1.lines[i]; + this$1.height -= line.height; + cleanUpLine(line); + signalLater(line, "delete"); + } + this.lines.splice(at, n); + }, + + // Helper used to collapse a small branch into a single leaf. + collapse: function(lines) { + lines.push.apply(lines, this.lines); + }, + + // Insert the given array of lines at offset 'at', count them as + // having the given height. + insertInner: function(at, lines, height) { + var this$1 = this; + + this.height += height; + this.lines = this.lines.slice(0, at).concat(lines).concat(this.lines.slice(at)); + for (var i = 0; i < lines.length; ++i) { lines[i].parent = this$1; } + }, + + // Used to iterate over a part of the tree. + iterN: function(at, n, op) { + var this$1 = this; + + for (var e = at + n; at < e; ++at) + { if (op(this$1.lines[at])) { return true } } + } + }; + + function BranchChunk(children) { + var this$1 = this; + + this.children = children; + var size = 0, height = 0; + for (var i = 0; i < children.length; ++i) { + var ch = children[i]; + size += ch.chunkSize(); height += ch.height; + ch.parent = this$1; + } + this.size = size; + this.height = height; + this.parent = null; + } + + BranchChunk.prototype = { + chunkSize: function() { return this.size }, + + removeInner: function(at, n) { + var this$1 = this; + + this.size -= n; + for (var i = 0; i < this.children.length; ++i) { + var child = this$1.children[i], sz = child.chunkSize(); + if (at < sz) { + var rm = Math.min(n, sz - at), oldHeight = child.height; + child.removeInner(at, rm); + this$1.height -= oldHeight - child.height; + if (sz == rm) { this$1.children.splice(i--, 1); child.parent = null; } + if ((n -= rm) == 0) { break } + at = 0; + } else { at -= sz; } + } + // If the result is smaller than 25 lines, ensure that it is a + // single leaf node. + if (this.size - n < 25 && + (this.children.length > 1 || !(this.children[0] instanceof LeafChunk))) { + var lines = []; + this.collapse(lines); + this.children = [new LeafChunk(lines)]; + this.children[0].parent = this; + } + }, + + collapse: function(lines) { + var this$1 = this; + + for (var i = 0; i < this.children.length; ++i) { this$1.children[i].collapse(lines); } + }, + + insertInner: function(at, lines, height) { + var this$1 = this; + + this.size += lines.length; + this.height += height; + for (var i = 0; i < this.children.length; ++i) { + var child = this$1.children[i], sz = child.chunkSize(); + if (at <= sz) { + child.insertInner(at, lines, height); + if (child.lines && child.lines.length > 50) { + // To avoid memory thrashing when child.lines is huge (e.g. first view of a large file), it's never spliced. + // Instead, small slices are taken. They're taken in order because sequential memory accesses are fastest. + var remaining = child.lines.length % 25 + 25; + for (var pos = remaining; pos < child.lines.length;) { + var leaf = new LeafChunk(child.lines.slice(pos, pos += 25)); + child.height -= leaf.height; + this$1.children.splice(++i, 0, leaf); + leaf.parent = this$1; + } + child.lines = child.lines.slice(0, remaining); + this$1.maybeSpill(); + } + break + } + at -= sz; + } + }, + + // When a node has grown, check whether it should be split. + maybeSpill: function() { + if (this.children.length <= 10) { return } + var me = this; + do { + var spilled = me.children.splice(me.children.length - 5, 5); + var sibling = new BranchChunk(spilled); + if (!me.parent) { // Become the parent node + var copy = new BranchChunk(me.children); + copy.parent = me; + me.children = [copy, sibling]; + me = copy; + } else { + me.size -= sibling.size; + me.height -= sibling.height; + var myIndex = indexOf(me.parent.children, me); + me.parent.children.splice(myIndex + 1, 0, sibling); + } + sibling.parent = me.parent; + } while (me.children.length > 10) + me.parent.maybeSpill(); + }, + + iterN: function(at, n, op) { + var this$1 = this; + + for (var i = 0; i < this.children.length; ++i) { + var child = this$1.children[i], sz = child.chunkSize(); + if (at < sz) { + var used = Math.min(n, sz - at); + if (child.iterN(at, used, op)) { return true } + if ((n -= used) == 0) { break } + at = 0; + } else { at -= sz; } + } + } + }; + + // Line widgets are block elements displayed above or below a line. + + var LineWidget = function(doc, node, options) { + var this$1 = this; + + if (options) { for (var opt in options) { if (options.hasOwnProperty(opt)) + { this$1[opt] = options[opt]; } } } + this.doc = doc; + this.node = node; + }; + + LineWidget.prototype.clear = function () { + var this$1 = this; + + var cm = this.doc.cm, ws = this.line.widgets, line = this.line, no = lineNo(line); + if (no == null || !ws) { return } + for (var i = 0; i < ws.length; ++i) { if (ws[i] == this$1) { ws.splice(i--, 1); } } + if (!ws.length) { line.widgets = null; } + var height = widgetHeight(this); + updateLineHeight(line, Math.max(0, line.height - height)); + if (cm) { + runInOp(cm, function () { + adjustScrollWhenAboveVisible(cm, line, -height); + regLineChange(cm, no, "widget"); + }); + signalLater(cm, "lineWidgetCleared", cm, this, no); + } + }; + + LineWidget.prototype.changed = function () { + var this$1 = this; + + var oldH = this.height, cm = this.doc.cm, line = this.line; + this.height = null; + var diff = widgetHeight(this) - oldH; + if (!diff) { return } + if (!lineIsHidden(this.doc, line)) { updateLineHeight(line, line.height + diff); } + if (cm) { + runInOp(cm, function () { + cm.curOp.forceUpdate = true; + adjustScrollWhenAboveVisible(cm, line, diff); + signalLater(cm, "lineWidgetChanged", cm, this$1, lineNo(line)); + }); + } + }; + eventMixin(LineWidget); + + function adjustScrollWhenAboveVisible(cm, line, diff) { + if (heightAtLine(line) < ((cm.curOp && cm.curOp.scrollTop) || cm.doc.scrollTop)) + { addToScrollTop(cm, diff); } + } + + function addLineWidget(doc, handle, node, options) { + var widget = new LineWidget(doc, node, options); + var cm = doc.cm; + if (cm && widget.noHScroll) { cm.display.alignWidgets = true; } + changeLine(doc, handle, "widget", function (line) { + var widgets = line.widgets || (line.widgets = []); + if (widget.insertAt == null) { widgets.push(widget); } + else { widgets.splice(Math.min(widgets.length - 1, Math.max(0, widget.insertAt)), 0, widget); } + widget.line = line; + if (cm && !lineIsHidden(doc, line)) { + var aboveVisible = heightAtLine(line) < doc.scrollTop; + updateLineHeight(line, line.height + widgetHeight(widget)); + if (aboveVisible) { addToScrollTop(cm, widget.height); } + cm.curOp.forceUpdate = true; + } + return true + }); + if (cm) { signalLater(cm, "lineWidgetAdded", cm, widget, typeof handle == "number" ? handle : lineNo(handle)); } + return widget + } + + // TEXTMARKERS + + // Created with markText and setBookmark methods. A TextMarker is a + // handle that can be used to clear or find a marked position in the + // document. Line objects hold arrays (markedSpans) containing + // {from, to, marker} object pointing to such marker objects, and + // indicating that such a marker is present on that line. Multiple + // lines may point to the same marker when it spans across lines. + // The spans will have null for their from/to properties when the + // marker continues beyond the start/end of the line. Markers have + // links back to the lines they currently touch. + + // Collapsed markers have unique ids, in order to be able to order + // them, which is needed for uniquely determining an outer marker + // when they overlap (they may nest, but not partially overlap). + var nextMarkerId = 0; + + var TextMarker = function(doc, type) { + this.lines = []; + this.type = type; + this.doc = doc; + this.id = ++nextMarkerId; + }; + + // Clear the marker. + TextMarker.prototype.clear = function () { + var this$1 = this; + + if (this.explicitlyCleared) { return } + var cm = this.doc.cm, withOp = cm && !cm.curOp; + if (withOp) { startOperation(cm); } + if (hasHandler(this, "clear")) { + var found = this.find(); + if (found) { signalLater(this, "clear", found.from, found.to); } + } + var min = null, max = null; + for (var i = 0; i < this.lines.length; ++i) { + var line = this$1.lines[i]; + var span = getMarkedSpanFor(line.markedSpans, this$1); + if (cm && !this$1.collapsed) { regLineChange(cm, lineNo(line), "text"); } + else if (cm) { + if (span.to != null) { max = lineNo(line); } + if (span.from != null) { min = lineNo(line); } + } + line.markedSpans = removeMarkedSpan(line.markedSpans, span); + if (span.from == null && this$1.collapsed && !lineIsHidden(this$1.doc, line) && cm) + { updateLineHeight(line, textHeight(cm.display)); } + } + if (cm && this.collapsed && !cm.options.lineWrapping) { for (var i$1 = 0; i$1 < this.lines.length; ++i$1) { + var visual = visualLine(this$1.lines[i$1]), len = lineLength(visual); + if (len > cm.display.maxLineLength) { + cm.display.maxLine = visual; + cm.display.maxLineLength = len; + cm.display.maxLineChanged = true; + } + } } + + if (min != null && cm && this.collapsed) { regChange(cm, min, max + 1); } + this.lines.length = 0; + this.explicitlyCleared = true; + if (this.atomic && this.doc.cantEdit) { + this.doc.cantEdit = false; + if (cm) { reCheckSelection(cm.doc); } + } + if (cm) { signalLater(cm, "markerCleared", cm, this, min, max); } + if (withOp) { endOperation(cm); } + if (this.parent) { this.parent.clear(); } + }; + + // Find the position of the marker in the document. Returns a {from, + // to} object by default. Side can be passed to get a specific side + // -- 0 (both), -1 (left), or 1 (right). When lineObj is true, the + // Pos objects returned contain a line object, rather than a line + // number (used to prevent looking up the same line twice). + TextMarker.prototype.find = function (side, lineObj) { + var this$1 = this; + + if (side == null && this.type == "bookmark") { side = 1; } + var from, to; + for (var i = 0; i < this.lines.length; ++i) { + var line = this$1.lines[i]; + var span = getMarkedSpanFor(line.markedSpans, this$1); + if (span.from != null) { + from = Pos(lineObj ? line : lineNo(line), span.from); + if (side == -1) { return from } + } + if (span.to != null) { + to = Pos(lineObj ? line : lineNo(line), span.to); + if (side == 1) { return to } + } + } + return from && {from: from, to: to} + }; + + // Signals that the marker's widget changed, and surrounding layout + // should be recomputed. + TextMarker.prototype.changed = function () { + var this$1 = this; + + var pos = this.find(-1, true), widget = this, cm = this.doc.cm; + if (!pos || !cm) { return } + runInOp(cm, function () { + var line = pos.line, lineN = lineNo(pos.line); + var view = findViewForLine(cm, lineN); + if (view) { + clearLineMeasurementCacheFor(view); + cm.curOp.selectionChanged = cm.curOp.forceUpdate = true; + } + cm.curOp.updateMaxLine = true; + if (!lineIsHidden(widget.doc, line) && widget.height != null) { + var oldHeight = widget.height; + widget.height = null; + var dHeight = widgetHeight(widget) - oldHeight; + if (dHeight) + { updateLineHeight(line, line.height + dHeight); } + } + signalLater(cm, "markerChanged", cm, this$1); + }); + }; + + TextMarker.prototype.attachLine = function (line) { + if (!this.lines.length && this.doc.cm) { + var op = this.doc.cm.curOp; + if (!op.maybeHiddenMarkers || indexOf(op.maybeHiddenMarkers, this) == -1) + { (op.maybeUnhiddenMarkers || (op.maybeUnhiddenMarkers = [])).push(this); } + } + this.lines.push(line); + }; + + TextMarker.prototype.detachLine = function (line) { + this.lines.splice(indexOf(this.lines, line), 1); + if (!this.lines.length && this.doc.cm) { + var op = this.doc.cm.curOp + ;(op.maybeHiddenMarkers || (op.maybeHiddenMarkers = [])).push(this); + } + }; + eventMixin(TextMarker); + + // Create a marker, wire it up to the right lines, and + function markText(doc, from, to, options, type) { + // Shared markers (across linked documents) are handled separately + // (markTextShared will call out to this again, once per + // document). + if (options && options.shared) { return markTextShared(doc, from, to, options, type) } + // Ensure we are in an operation. + if (doc.cm && !doc.cm.curOp) { return operation(doc.cm, markText)(doc, from, to, options, type) } + + var marker = new TextMarker(doc, type), diff = cmp(from, to); + if (options) { copyObj(options, marker, false); } + // Don't connect empty markers unless clearWhenEmpty is false + if (diff > 0 || diff == 0 && marker.clearWhenEmpty !== false) + { return marker } + if (marker.replacedWith) { + // Showing up as a widget implies collapsed (widget replaces text) + marker.collapsed = true; + marker.widgetNode = eltP("span", [marker.replacedWith], "CodeMirror-widget"); + if (!options.handleMouseEvents) { marker.widgetNode.setAttribute("cm-ignore-events", "true"); } + if (options.insertLeft) { marker.widgetNode.insertLeft = true; } + } + if (marker.collapsed) { + if (conflictingCollapsedRange(doc, from.line, from, to, marker) || + from.line != to.line && conflictingCollapsedRange(doc, to.line, from, to, marker)) + { throw new Error("Inserting collapsed marker partially overlapping an existing one") } + seeCollapsedSpans(); + } + + if (marker.addToHistory) + { addChangeToHistory(doc, {from: from, to: to, origin: "markText"}, doc.sel, NaN); } + + var curLine = from.line, cm = doc.cm, updateMaxLine; + doc.iter(curLine, to.line + 1, function (line) { + if (cm && marker.collapsed && !cm.options.lineWrapping && visualLine(line) == cm.display.maxLine) + { updateMaxLine = true; } + if (marker.collapsed && curLine != from.line) { updateLineHeight(line, 0); } + addMarkedSpan(line, new MarkedSpan(marker, + curLine == from.line ? from.ch : null, + curLine == to.line ? to.ch : null)); + ++curLine; + }); + // lineIsHidden depends on the presence of the spans, so needs a second pass + if (marker.collapsed) { doc.iter(from.line, to.line + 1, function (line) { + if (lineIsHidden(doc, line)) { updateLineHeight(line, 0); } + }); } + + if (marker.clearOnEnter) { on(marker, "beforeCursorEnter", function () { return marker.clear(); }); } + + if (marker.readOnly) { + seeReadOnlySpans(); + if (doc.history.done.length || doc.history.undone.length) + { doc.clearHistory(); } + } + if (marker.collapsed) { + marker.id = ++nextMarkerId; + marker.atomic = true; + } + if (cm) { + // Sync editor state + if (updateMaxLine) { cm.curOp.updateMaxLine = true; } + if (marker.collapsed) + { regChange(cm, from.line, to.line + 1); } + else if (marker.className || marker.startStyle || marker.endStyle || marker.css || + marker.attributes || marker.title) + { for (var i = from.line; i <= to.line; i++) { regLineChange(cm, i, "text"); } } + if (marker.atomic) { reCheckSelection(cm.doc); } + signalLater(cm, "markerAdded", cm, marker); + } + return marker + } + + // SHARED TEXTMARKERS + + // A shared marker spans multiple linked documents. It is + // implemented as a meta-marker-object controlling multiple normal + // markers. + var SharedTextMarker = function(markers, primary) { + var this$1 = this; + + this.markers = markers; + this.primary = primary; + for (var i = 0; i < markers.length; ++i) + { markers[i].parent = this$1; } + }; + + SharedTextMarker.prototype.clear = function () { + var this$1 = this; + + if (this.explicitlyCleared) { return } + this.explicitlyCleared = true; + for (var i = 0; i < this.markers.length; ++i) + { this$1.markers[i].clear(); } + signalLater(this, "clear"); + }; + + SharedTextMarker.prototype.find = function (side, lineObj) { + return this.primary.find(side, lineObj) + }; + eventMixin(SharedTextMarker); + + function markTextShared(doc, from, to, options, type) { + options = copyObj(options); + options.shared = false; + var markers = [markText(doc, from, to, options, type)], primary = markers[0]; + var widget = options.widgetNode; + linkedDocs(doc, function (doc) { + if (widget) { options.widgetNode = widget.cloneNode(true); } + markers.push(markText(doc, clipPos(doc, from), clipPos(doc, to), options, type)); + for (var i = 0; i < doc.linked.length; ++i) + { if (doc.linked[i].isParent) { return } } + primary = lst(markers); + }); + return new SharedTextMarker(markers, primary) + } + + function findSharedMarkers(doc) { + return doc.findMarks(Pos(doc.first, 0), doc.clipPos(Pos(doc.lastLine())), function (m) { return m.parent; }) + } + + function copySharedMarkers(doc, markers) { + for (var i = 0; i < markers.length; i++) { + var marker = markers[i], pos = marker.find(); + var mFrom = doc.clipPos(pos.from), mTo = doc.clipPos(pos.to); + if (cmp(mFrom, mTo)) { + var subMark = markText(doc, mFrom, mTo, marker.primary, marker.primary.type); + marker.markers.push(subMark); + subMark.parent = marker; + } + } + } + + function detachSharedMarkers(markers) { + var loop = function ( i ) { + var marker = markers[i], linked = [marker.primary.doc]; + linkedDocs(marker.primary.doc, function (d) { return linked.push(d); }); + for (var j = 0; j < marker.markers.length; j++) { + var subMarker = marker.markers[j]; + if (indexOf(linked, subMarker.doc) == -1) { + subMarker.parent = null; + marker.markers.splice(j--, 1); + } + } + }; + + for (var i = 0; i < markers.length; i++) loop( i ); + } + + var nextDocId = 0; + var Doc = function(text, mode, firstLine, lineSep, direction) { + if (!(this instanceof Doc)) { return new Doc(text, mode, firstLine, lineSep, direction) } + if (firstLine == null) { firstLine = 0; } + + BranchChunk.call(this, [new LeafChunk([new Line("", null)])]); + this.first = firstLine; + this.scrollTop = this.scrollLeft = 0; + this.cantEdit = false; + this.cleanGeneration = 1; + this.modeFrontier = this.highlightFrontier = firstLine; + var start = Pos(firstLine, 0); + this.sel = simpleSelection(start); + this.history = new History(null); + this.id = ++nextDocId; + this.modeOption = mode; + this.lineSep = lineSep; + this.direction = (direction == "rtl") ? "rtl" : "ltr"; + this.extend = false; + + if (typeof text == "string") { text = this.splitLines(text); } + updateDoc(this, {from: start, to: start, text: text}); + setSelection(this, simpleSelection(start), sel_dontScroll); + }; + + Doc.prototype = createObj(BranchChunk.prototype, { + constructor: Doc, + // Iterate over the document. Supports two forms -- with only one + // argument, it calls that for each line in the document. With + // three, it iterates over the range given by the first two (with + // the second being non-inclusive). + iter: function(from, to, op) { + if (op) { this.iterN(from - this.first, to - from, op); } + else { this.iterN(this.first, this.first + this.size, from); } + }, + + // Non-public interface for adding and removing lines. + insert: function(at, lines) { + var height = 0; + for (var i = 0; i < lines.length; ++i) { height += lines[i].height; } + this.insertInner(at - this.first, lines, height); + }, + remove: function(at, n) { this.removeInner(at - this.first, n); }, + + // From here, the methods are part of the public interface. Most + // are also available from CodeMirror (editor) instances. + + getValue: function(lineSep) { + var lines = getLines(this, this.first, this.first + this.size); + if (lineSep === false) { return lines } + return lines.join(lineSep || this.lineSeparator()) + }, + setValue: docMethodOp(function(code) { + var top = Pos(this.first, 0), last = this.first + this.size - 1; + makeChange(this, {from: top, to: Pos(last, getLine(this, last).text.length), + text: this.splitLines(code), origin: "setValue", full: true}, true); + if (this.cm) { scrollToCoords(this.cm, 0, 0); } + setSelection(this, simpleSelection(top), sel_dontScroll); + }), + replaceRange: function(code, from, to, origin) { + from = clipPos(this, from); + to = to ? clipPos(this, to) : from; + replaceRange(this, code, from, to, origin); + }, + getRange: function(from, to, lineSep) { + var lines = getBetween(this, clipPos(this, from), clipPos(this, to)); + if (lineSep === false) { return lines } + return lines.join(lineSep || this.lineSeparator()) + }, + + getLine: function(line) {var l = this.getLineHandle(line); return l && l.text}, + + getLineHandle: function(line) {if (isLine(this, line)) { return getLine(this, line) }}, + getLineNumber: function(line) {return lineNo(line)}, + + getLineHandleVisualStart: function(line) { + if (typeof line == "number") { line = getLine(this, line); } + return visualLine(line) + }, + + lineCount: function() {return this.size}, + firstLine: function() {return this.first}, + lastLine: function() {return this.first + this.size - 1}, + + clipPos: function(pos) {return clipPos(this, pos)}, + + getCursor: function(start) { + var range$$1 = this.sel.primary(), pos; + if (start == null || start == "head") { pos = range$$1.head; } + else if (start == "anchor") { pos = range$$1.anchor; } + else if (start == "end" || start == "to" || start === false) { pos = range$$1.to(); } + else { pos = range$$1.from(); } + return pos + }, + listSelections: function() { return this.sel.ranges }, + somethingSelected: function() {return this.sel.somethingSelected()}, + + setCursor: docMethodOp(function(line, ch, options) { + setSimpleSelection(this, clipPos(this, typeof line == "number" ? Pos(line, ch || 0) : line), null, options); + }), + setSelection: docMethodOp(function(anchor, head, options) { + setSimpleSelection(this, clipPos(this, anchor), clipPos(this, head || anchor), options); + }), + extendSelection: docMethodOp(function(head, other, options) { + extendSelection(this, clipPos(this, head), other && clipPos(this, other), options); + }), + extendSelections: docMethodOp(function(heads, options) { + extendSelections(this, clipPosArray(this, heads), options); + }), + extendSelectionsBy: docMethodOp(function(f, options) { + var heads = map(this.sel.ranges, f); + extendSelections(this, clipPosArray(this, heads), options); + }), + setSelections: docMethodOp(function(ranges, primary, options) { + var this$1 = this; + + if (!ranges.length) { return } + var out = []; + for (var i = 0; i < ranges.length; i++) + { out[i] = new Range(clipPos(this$1, ranges[i].anchor), + clipPos(this$1, ranges[i].head)); } + if (primary == null) { primary = Math.min(ranges.length - 1, this.sel.primIndex); } + setSelection(this, normalizeSelection(this.cm, out, primary), options); + }), + addSelection: docMethodOp(function(anchor, head, options) { + var ranges = this.sel.ranges.slice(0); + ranges.push(new Range(clipPos(this, anchor), clipPos(this, head || anchor))); + setSelection(this, normalizeSelection(this.cm, ranges, ranges.length - 1), options); + }), + + getSelection: function(lineSep) { + var this$1 = this; + + var ranges = this.sel.ranges, lines; + for (var i = 0; i < ranges.length; i++) { + var sel = getBetween(this$1, ranges[i].from(), ranges[i].to()); + lines = lines ? lines.concat(sel) : sel; + } + if (lineSep === false) { return lines } + else { return lines.join(lineSep || this.lineSeparator()) } + }, + getSelections: function(lineSep) { + var this$1 = this; + + var parts = [], ranges = this.sel.ranges; + for (var i = 0; i < ranges.length; i++) { + var sel = getBetween(this$1, ranges[i].from(), ranges[i].to()); + if (lineSep !== false) { sel = sel.join(lineSep || this$1.lineSeparator()); } + parts[i] = sel; + } + return parts + }, + replaceSelection: function(code, collapse, origin) { + var dup = []; + for (var i = 0; i < this.sel.ranges.length; i++) + { dup[i] = code; } + this.replaceSelections(dup, collapse, origin || "+input"); + }, + replaceSelections: docMethodOp(function(code, collapse, origin) { + var this$1 = this; + + var changes = [], sel = this.sel; + for (var i = 0; i < sel.ranges.length; i++) { + var range$$1 = sel.ranges[i]; + changes[i] = {from: range$$1.from(), to: range$$1.to(), text: this$1.splitLines(code[i]), origin: origin}; + } + var newSel = collapse && collapse != "end" && computeReplacedSel(this, changes, collapse); + for (var i$1 = changes.length - 1; i$1 >= 0; i$1--) + { makeChange(this$1, changes[i$1]); } + if (newSel) { setSelectionReplaceHistory(this, newSel); } + else if (this.cm) { ensureCursorVisible(this.cm); } + }), + undo: docMethodOp(function() {makeChangeFromHistory(this, "undo");}), + redo: docMethodOp(function() {makeChangeFromHistory(this, "redo");}), + undoSelection: docMethodOp(function() {makeChangeFromHistory(this, "undo", true);}), + redoSelection: docMethodOp(function() {makeChangeFromHistory(this, "redo", true);}), + + setExtending: function(val) {this.extend = val;}, + getExtending: function() {return this.extend}, + + historySize: function() { + var hist = this.history, done = 0, undone = 0; + for (var i = 0; i < hist.done.length; i++) { if (!hist.done[i].ranges) { ++done; } } + for (var i$1 = 0; i$1 < hist.undone.length; i$1++) { if (!hist.undone[i$1].ranges) { ++undone; } } + return {undo: done, redo: undone} + }, + clearHistory: function() { + var this$1 = this; + + this.history = new History(this.history.maxGeneration); + linkedDocs(this, function (doc) { return doc.history = this$1.history; }, true); + }, + + markClean: function() { + this.cleanGeneration = this.changeGeneration(true); + }, + changeGeneration: function(forceSplit) { + if (forceSplit) + { this.history.lastOp = this.history.lastSelOp = this.history.lastOrigin = null; } + return this.history.generation + }, + isClean: function (gen) { + return this.history.generation == (gen || this.cleanGeneration) + }, + + getHistory: function() { + return {done: copyHistoryArray(this.history.done), + undone: copyHistoryArray(this.history.undone)} + }, + setHistory: function(histData) { + var hist = this.history = new History(this.history.maxGeneration); + hist.done = copyHistoryArray(histData.done.slice(0), null, true); + hist.undone = copyHistoryArray(histData.undone.slice(0), null, true); + }, + + setGutterMarker: docMethodOp(function(line, gutterID, value) { + return changeLine(this, line, "gutter", function (line) { + var markers = line.gutterMarkers || (line.gutterMarkers = {}); + markers[gutterID] = value; + if (!value && isEmpty(markers)) { line.gutterMarkers = null; } + return true + }) + }), + + clearGutter: docMethodOp(function(gutterID) { + var this$1 = this; + + this.iter(function (line) { + if (line.gutterMarkers && line.gutterMarkers[gutterID]) { + changeLine(this$1, line, "gutter", function () { + line.gutterMarkers[gutterID] = null; + if (isEmpty(line.gutterMarkers)) { line.gutterMarkers = null; } + return true + }); + } + }); + }), + + lineInfo: function(line) { + var n; + if (typeof line == "number") { + if (!isLine(this, line)) { return null } + n = line; + line = getLine(this, line); + if (!line) { return null } + } else { + n = lineNo(line); + if (n == null) { return null } + } + return {line: n, handle: line, text: line.text, gutterMarkers: line.gutterMarkers, + textClass: line.textClass, bgClass: line.bgClass, wrapClass: line.wrapClass, + widgets: line.widgets} + }, + + addLineClass: docMethodOp(function(handle, where, cls) { + return changeLine(this, handle, where == "gutter" ? "gutter" : "class", function (line) { + var prop = where == "text" ? "textClass" + : where == "background" ? "bgClass" + : where == "gutter" ? "gutterClass" : "wrapClass"; + if (!line[prop]) { line[prop] = cls; } + else if (classTest(cls).test(line[prop])) { return false } + else { line[prop] += " " + cls; } + return true + }) + }), + removeLineClass: docMethodOp(function(handle, where, cls) { + return changeLine(this, handle, where == "gutter" ? "gutter" : "class", function (line) { + var prop = where == "text" ? "textClass" + : where == "background" ? "bgClass" + : where == "gutter" ? "gutterClass" : "wrapClass"; + var cur = line[prop]; + if (!cur) { return false } + else if (cls == null) { line[prop] = null; } + else { + var found = cur.match(classTest(cls)); + if (!found) { return false } + var end = found.index + found[0].length; + line[prop] = cur.slice(0, found.index) + (!found.index || end == cur.length ? "" : " ") + cur.slice(end) || null; + } + return true + }) + }), + + addLineWidget: docMethodOp(function(handle, node, options) { + return addLineWidget(this, handle, node, options) + }), + removeLineWidget: function(widget) { widget.clear(); }, + + markText: function(from, to, options) { + return markText(this, clipPos(this, from), clipPos(this, to), options, options && options.type || "range") + }, + setBookmark: function(pos, options) { + var realOpts = {replacedWith: options && (options.nodeType == null ? options.widget : options), + insertLeft: options && options.insertLeft, + clearWhenEmpty: false, shared: options && options.shared, + handleMouseEvents: options && options.handleMouseEvents}; + pos = clipPos(this, pos); + return markText(this, pos, pos, realOpts, "bookmark") + }, + findMarksAt: function(pos) { + pos = clipPos(this, pos); + var markers = [], spans = getLine(this, pos.line).markedSpans; + if (spans) { for (var i = 0; i < spans.length; ++i) { + var span = spans[i]; + if ((span.from == null || span.from <= pos.ch) && + (span.to == null || span.to >= pos.ch)) + { markers.push(span.marker.parent || span.marker); } + } } + return markers + }, + findMarks: function(from, to, filter) { + from = clipPos(this, from); to = clipPos(this, to); + var found = [], lineNo$$1 = from.line; + this.iter(from.line, to.line + 1, function (line) { + var spans = line.markedSpans; + if (spans) { for (var i = 0; i < spans.length; i++) { + var span = spans[i]; + if (!(span.to != null && lineNo$$1 == from.line && from.ch >= span.to || + span.from == null && lineNo$$1 != from.line || + span.from != null && lineNo$$1 == to.line && span.from >= to.ch) && + (!filter || filter(span.marker))) + { found.push(span.marker.parent || span.marker); } + } } + ++lineNo$$1; + }); + return found + }, + getAllMarks: function() { + var markers = []; + this.iter(function (line) { + var sps = line.markedSpans; + if (sps) { for (var i = 0; i < sps.length; ++i) + { if (sps[i].from != null) { markers.push(sps[i].marker); } } } + }); + return markers + }, + + posFromIndex: function(off) { + var ch, lineNo$$1 = this.first, sepSize = this.lineSeparator().length; + this.iter(function (line) { + var sz = line.text.length + sepSize; + if (sz > off) { ch = off; return true } + off -= sz; + ++lineNo$$1; + }); + return clipPos(this, Pos(lineNo$$1, ch)) + }, + indexFromPos: function (coords) { + coords = clipPos(this, coords); + var index = coords.ch; + if (coords.line < this.first || coords.ch < 0) { return 0 } + var sepSize = this.lineSeparator().length; + this.iter(this.first, coords.line, function (line) { // iter aborts when callback returns a truthy value + index += line.text.length + sepSize; + }); + return index + }, + + copy: function(copyHistory) { + var doc = new Doc(getLines(this, this.first, this.first + this.size), + this.modeOption, this.first, this.lineSep, this.direction); + doc.scrollTop = this.scrollTop; doc.scrollLeft = this.scrollLeft; + doc.sel = this.sel; + doc.extend = false; + if (copyHistory) { + doc.history.undoDepth = this.history.undoDepth; + doc.setHistory(this.getHistory()); + } + return doc + }, + + linkedDoc: function(options) { + if (!options) { options = {}; } + var from = this.first, to = this.first + this.size; + if (options.from != null && options.from > from) { from = options.from; } + if (options.to != null && options.to < to) { to = options.to; } + var copy = new Doc(getLines(this, from, to), options.mode || this.modeOption, from, this.lineSep, this.direction); + if (options.sharedHist) { copy.history = this.history + ; }(this.linked || (this.linked = [])).push({doc: copy, sharedHist: options.sharedHist}); + copy.linked = [{doc: this, isParent: true, sharedHist: options.sharedHist}]; + copySharedMarkers(copy, findSharedMarkers(this)); + return copy + }, + unlinkDoc: function(other) { + var this$1 = this; + + if (other instanceof CodeMirror) { other = other.doc; } + if (this.linked) { for (var i = 0; i < this.linked.length; ++i) { + var link = this$1.linked[i]; + if (link.doc != other) { continue } + this$1.linked.splice(i, 1); + other.unlinkDoc(this$1); + detachSharedMarkers(findSharedMarkers(this$1)); + break + } } + // If the histories were shared, split them again + if (other.history == this.history) { + var splitIds = [other.id]; + linkedDocs(other, function (doc) { return splitIds.push(doc.id); }, true); + other.history = new History(null); + other.history.done = copyHistoryArray(this.history.done, splitIds); + other.history.undone = copyHistoryArray(this.history.undone, splitIds); + } + }, + iterLinkedDocs: function(f) {linkedDocs(this, f);}, + + getMode: function() {return this.mode}, + getEditor: function() {return this.cm}, + + splitLines: function(str) { + if (this.lineSep) { return str.split(this.lineSep) } + return splitLinesAuto(str) + }, + lineSeparator: function() { return this.lineSep || "\n" }, + + setDirection: docMethodOp(function (dir) { + if (dir != "rtl") { dir = "ltr"; } + if (dir == this.direction) { return } + this.direction = dir; + this.iter(function (line) { return line.order = null; }); + if (this.cm) { directionChanged(this.cm); } + }) + }); + + // Public alias. + Doc.prototype.eachLine = Doc.prototype.iter; + + // Kludge to work around strange IE behavior where it'll sometimes + // re-fire a series of drag-related events right after the drop (#1551) + var lastDrop = 0; + + function onDrop(e) { + var cm = this; + clearDragCursor(cm); + if (signalDOMEvent(cm, e) || eventInWidget(cm.display, e)) + { return } + e_preventDefault(e); + if (ie) { lastDrop = +new Date; } + var pos = posFromMouse(cm, e, true), files = e.dataTransfer.files; + if (!pos || cm.isReadOnly()) { return } + // Might be a file drop, in which case we simply extract the text + // and insert it. + if (files && files.length && window.FileReader && window.File) { + var n = files.length, text = Array(n), read = 0; + var markAsReadAndPasteIfAllFilesAreRead = function () { + if (++read == n) { + operation(cm, function () { + pos = clipPos(cm.doc, pos); + var change = {from: pos, to: pos, + text: cm.doc.splitLines( + text.filter(function (t) { return t != null; }).join(cm.doc.lineSeparator())), + origin: "paste"}; + makeChange(cm.doc, change); + setSelectionReplaceHistory(cm.doc, simpleSelection(pos, changeEnd(change))); + })(); + } + }; + var readTextFromFile = function (file, i) { + if (cm.options.allowDropFileTypes && + indexOf(cm.options.allowDropFileTypes, file.type) == -1) { + markAsReadAndPasteIfAllFilesAreRead(); + return + } + var reader = new FileReader; + reader.onerror = function () { return markAsReadAndPasteIfAllFilesAreRead(); }; + reader.onload = function () { + var content = reader.result; + if (/[\x00-\x08\x0e-\x1f]{2}/.test(content)) { + markAsReadAndPasteIfAllFilesAreRead(); + return + } + text[i] = content; + markAsReadAndPasteIfAllFilesAreRead(); + }; + reader.readAsText(file); + }; + for (var i = 0; i < files.length; i++) { readTextFromFile(files[i], i); } + } else { // Normal drop + // Don't do a replace if the drop happened inside of the selected text. + if (cm.state.draggingText && cm.doc.sel.contains(pos) > -1) { + cm.state.draggingText(e); + // Ensure the editor is re-focused + setTimeout(function () { return cm.display.input.focus(); }, 20); + return + } + try { + var text$1 = e.dataTransfer.getData("Text"); + if (text$1) { + var selected; + if (cm.state.draggingText && !cm.state.draggingText.copy) + { selected = cm.listSelections(); } + setSelectionNoUndo(cm.doc, simpleSelection(pos, pos)); + if (selected) { for (var i$1 = 0; i$1 < selected.length; ++i$1) + { replaceRange(cm.doc, "", selected[i$1].anchor, selected[i$1].head, "drag"); } } + cm.replaceSelection(text$1, "around", "paste"); + cm.display.input.focus(); + } + } + catch(e){} + } + } + + function onDragStart(cm, e) { + if (ie && (!cm.state.draggingText || +new Date - lastDrop < 100)) { e_stop(e); return } + if (signalDOMEvent(cm, e) || eventInWidget(cm.display, e)) { return } + + e.dataTransfer.setData("Text", cm.getSelection()); + e.dataTransfer.effectAllowed = "copyMove"; + + // Use dummy image instead of default browsers image. + // Recent Safari (~6.0.2) have a tendency to segfault when this happens, so we don't do it there. + if (e.dataTransfer.setDragImage && !safari) { + var img = elt("img", null, null, "position: fixed; left: 0; top: 0;"); + img.src = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; + if (presto) { + img.width = img.height = 1; + cm.display.wrapper.appendChild(img); + // Force a relayout, or Opera won't use our image for some obscure reason + img._top = img.offsetTop; + } + e.dataTransfer.setDragImage(img, 0, 0); + if (presto) { img.parentNode.removeChild(img); } + } + } + + function onDragOver(cm, e) { + var pos = posFromMouse(cm, e); + if (!pos) { return } + var frag = document.createDocumentFragment(); + drawSelectionCursor(cm, pos, frag); + if (!cm.display.dragCursor) { + cm.display.dragCursor = elt("div", null, "CodeMirror-cursors CodeMirror-dragcursors"); + cm.display.lineSpace.insertBefore(cm.display.dragCursor, cm.display.cursorDiv); + } + removeChildrenAndAdd(cm.display.dragCursor, frag); + } + + function clearDragCursor(cm) { + if (cm.display.dragCursor) { + cm.display.lineSpace.removeChild(cm.display.dragCursor); + cm.display.dragCursor = null; + } + } + + // These must be handled carefully, because naively registering a + // handler for each editor will cause the editors to never be + // garbage collected. + + function forEachCodeMirror(f) { + if (!document.getElementsByClassName) { return } + var byClass = document.getElementsByClassName("CodeMirror"), editors = []; + for (var i = 0; i < byClass.length; i++) { + var cm = byClass[i].CodeMirror; + if (cm) { editors.push(cm); } + } + if (editors.length) { editors[0].operation(function () { + for (var i = 0; i < editors.length; i++) { f(editors[i]); } + }); } + } + + var globalsRegistered = false; + function ensureGlobalHandlers() { + if (globalsRegistered) { return } + registerGlobalHandlers(); + globalsRegistered = true; + } + function registerGlobalHandlers() { + // When the window resizes, we need to refresh active editors. + var resizeTimer; + on(window, "resize", function () { + if (resizeTimer == null) { resizeTimer = setTimeout(function () { + resizeTimer = null; + forEachCodeMirror(onResize); + }, 100); } + }); + // When the window loses focus, we want to show the editor as blurred + on(window, "blur", function () { return forEachCodeMirror(onBlur); }); + } + // Called when the window resizes + function onResize(cm) { + var d = cm.display; + // Might be a text scaling operation, clear size caches. + d.cachedCharWidth = d.cachedTextHeight = d.cachedPaddingH = null; + d.scrollbarsClipped = false; + cm.setSize(); + } + + var keyNames = { + 3: "Pause", 8: "Backspace", 9: "Tab", 13: "Enter", 16: "Shift", 17: "Ctrl", 18: "Alt", + 19: "Pause", 20: "CapsLock", 27: "Esc", 32: "Space", 33: "PageUp", 34: "PageDown", 35: "End", + 36: "Home", 37: "Left", 38: "Up", 39: "Right", 40: "Down", 44: "PrintScrn", 45: "Insert", + 46: "Delete", 59: ";", 61: "=", 91: "Mod", 92: "Mod", 93: "Mod", + 106: "*", 107: "=", 109: "-", 110: ".", 111: "/", 145: "ScrollLock", + 173: "-", 186: ";", 187: "=", 188: ",", 189: "-", 190: ".", 191: "/", 192: "`", 219: "[", 220: "\\", + 221: "]", 222: "'", 63232: "Up", 63233: "Down", 63234: "Left", 63235: "Right", 63272: "Delete", + 63273: "Home", 63275: "End", 63276: "PageUp", 63277: "PageDown", 63302: "Insert" + }; + + // Number keys + for (var i = 0; i < 10; i++) { keyNames[i + 48] = keyNames[i + 96] = String(i); } + // Alphabetic keys + for (var i$1 = 65; i$1 <= 90; i$1++) { keyNames[i$1] = String.fromCharCode(i$1); } + // Function keys + for (var i$2 = 1; i$2 <= 12; i$2++) { keyNames[i$2 + 111] = keyNames[i$2 + 63235] = "F" + i$2; } + + var keyMap = {}; + + keyMap.basic = { + "Left": "goCharLeft", "Right": "goCharRight", "Up": "goLineUp", "Down": "goLineDown", + "End": "goLineEnd", "Home": "goLineStartSmart", "PageUp": "goPageUp", "PageDown": "goPageDown", + "Delete": "delCharAfter", "Backspace": "delCharBefore", "Shift-Backspace": "delCharBefore", + "Tab": "defaultTab", "Shift-Tab": "indentAuto", + "Enter": "newlineAndIndent", "Insert": "toggleOverwrite", + "Esc": "singleSelection" + }; + // Note that the save and find-related commands aren't defined by + // default. User code or addons can define them. Unknown commands + // are simply ignored. + keyMap.pcDefault = { + "Ctrl-A": "selectAll", "Ctrl-D": "deleteLine", "Ctrl-Z": "undo", "Shift-Ctrl-Z": "redo", "Ctrl-Y": "redo", + "Ctrl-Home": "goDocStart", "Ctrl-End": "goDocEnd", "Ctrl-Up": "goLineUp", "Ctrl-Down": "goLineDown", + "Ctrl-Left": "goGroupLeft", "Ctrl-Right": "goGroupRight", "Alt-Left": "goLineStart", "Alt-Right": "goLineEnd", + "Ctrl-Backspace": "delGroupBefore", "Ctrl-Delete": "delGroupAfter", "Ctrl-S": "save", "Ctrl-F": "find", + "Ctrl-G": "findNext", "Shift-Ctrl-G": "findPrev", "Shift-Ctrl-F": "replace", "Shift-Ctrl-R": "replaceAll", + "Ctrl-[": "indentLess", "Ctrl-]": "indentMore", + "Ctrl-U": "undoSelection", "Shift-Ctrl-U": "redoSelection", "Alt-U": "redoSelection", + "fallthrough": "basic" + }; + // Very basic readline/emacs-style bindings, which are standard on Mac. + keyMap.emacsy = { + "Ctrl-F": "goCharRight", "Ctrl-B": "goCharLeft", "Ctrl-P": "goLineUp", "Ctrl-N": "goLineDown", + "Alt-F": "goWordRight", "Alt-B": "goWordLeft", "Ctrl-A": "goLineStart", "Ctrl-E": "goLineEnd", + "Ctrl-V": "goPageDown", "Shift-Ctrl-V": "goPageUp", "Ctrl-D": "delCharAfter", "Ctrl-H": "delCharBefore", + "Alt-D": "delWordAfter", "Alt-Backspace": "delWordBefore", "Ctrl-K": "killLine", "Ctrl-T": "transposeChars", + "Ctrl-O": "openLine" + }; + keyMap.macDefault = { + "Cmd-A": "selectAll", "Cmd-D": "deleteLine", "Cmd-Z": "undo", "Shift-Cmd-Z": "redo", "Cmd-Y": "redo", + "Cmd-Home": "goDocStart", "Cmd-Up": "goDocStart", "Cmd-End": "goDocEnd", "Cmd-Down": "goDocEnd", "Alt-Left": "goGroupLeft", + "Alt-Right": "goGroupRight", "Cmd-Left": "goLineLeft", "Cmd-Right": "goLineRight", "Alt-Backspace": "delGroupBefore", + "Ctrl-Alt-Backspace": "delGroupAfter", "Alt-Delete": "delGroupAfter", "Cmd-S": "save", "Cmd-F": "find", + "Cmd-G": "findNext", "Shift-Cmd-G": "findPrev", "Cmd-Alt-F": "replace", "Shift-Cmd-Alt-F": "replaceAll", + "Cmd-[": "indentLess", "Cmd-]": "indentMore", "Cmd-Backspace": "delWrappedLineLeft", "Cmd-Delete": "delWrappedLineRight", + "Cmd-U": "undoSelection", "Shift-Cmd-U": "redoSelection", "Ctrl-Up": "goDocStart", "Ctrl-Down": "goDocEnd", + "fallthrough": ["basic", "emacsy"] + }; + keyMap["default"] = mac ? keyMap.macDefault : keyMap.pcDefault; + + // KEYMAP DISPATCH + + function normalizeKeyName(name) { + var parts = name.split(/-(?!$)/); + name = parts[parts.length - 1]; + var alt, ctrl, shift, cmd; + for (var i = 0; i < parts.length - 1; i++) { + var mod = parts[i]; + if (/^(cmd|meta|m)$/i.test(mod)) { cmd = true; } + else if (/^a(lt)?$/i.test(mod)) { alt = true; } + else if (/^(c|ctrl|control)$/i.test(mod)) { ctrl = true; } + else if (/^s(hift)?$/i.test(mod)) { shift = true; } + else { throw new Error("Unrecognized modifier name: " + mod) } + } + if (alt) { name = "Alt-" + name; } + if (ctrl) { name = "Ctrl-" + name; } + if (cmd) { name = "Cmd-" + name; } + if (shift) { name = "Shift-" + name; } + return name + } + + // This is a kludge to keep keymaps mostly working as raw objects + // (backwards compatibility) while at the same time support features + // like normalization and multi-stroke key bindings. It compiles a + // new normalized keymap, and then updates the old object to reflect + // this. + function normalizeKeyMap(keymap) { + var copy = {}; + for (var keyname in keymap) { if (keymap.hasOwnProperty(keyname)) { + var value = keymap[keyname]; + if (/^(name|fallthrough|(de|at)tach)$/.test(keyname)) { continue } + if (value == "...") { delete keymap[keyname]; continue } + + var keys = map(keyname.split(" "), normalizeKeyName); + for (var i = 0; i < keys.length; i++) { + var val = (void 0), name = (void 0); + if (i == keys.length - 1) { + name = keys.join(" "); + val = value; + } else { + name = keys.slice(0, i + 1).join(" "); + val = "..."; + } + var prev = copy[name]; + if (!prev) { copy[name] = val; } + else if (prev != val) { throw new Error("Inconsistent bindings for " + name) } + } + delete keymap[keyname]; + } } + for (var prop in copy) { keymap[prop] = copy[prop]; } + return keymap + } + + function lookupKey(key, map$$1, handle, context) { + map$$1 = getKeyMap(map$$1); + var found = map$$1.call ? map$$1.call(key, context) : map$$1[key]; + if (found === false) { return "nothing" } + if (found === "...") { return "multi" } + if (found != null && handle(found)) { return "handled" } + + if (map$$1.fallthrough) { + if (Object.prototype.toString.call(map$$1.fallthrough) != "[object Array]") + { return lookupKey(key, map$$1.fallthrough, handle, context) } + for (var i = 0; i < map$$1.fallthrough.length; i++) { + var result = lookupKey(key, map$$1.fallthrough[i], handle, context); + if (result) { return result } + } + } + } + + // Modifier key presses don't count as 'real' key presses for the + // purpose of keymap fallthrough. + function isModifierKey(value) { + var name = typeof value == "string" ? value : keyNames[value.keyCode]; + return name == "Ctrl" || name == "Alt" || name == "Shift" || name == "Mod" + } + + function addModifierNames(name, event, noShift) { + var base = name; + if (event.altKey && base != "Alt") { name = "Alt-" + name; } + if ((flipCtrlCmd ? event.metaKey : event.ctrlKey) && base != "Ctrl") { name = "Ctrl-" + name; } + if ((flipCtrlCmd ? event.ctrlKey : event.metaKey) && base != "Cmd") { name = "Cmd-" + name; } + if (!noShift && event.shiftKey && base != "Shift") { name = "Shift-" + name; } + return name + } + + // Look up the name of a key as indicated by an event object. + function keyName(event, noShift) { + if (presto && event.keyCode == 34 && event["char"]) { return false } + var name = keyNames[event.keyCode]; + if (name == null || event.altGraphKey) { return false } + // Ctrl-ScrollLock has keyCode 3, same as Ctrl-Pause, + // so we'll use event.code when available (Chrome 48+, FF 38+, Safari 10.1+) + if (event.keyCode == 3 && event.code) { name = event.code; } + return addModifierNames(name, event, noShift) + } + + function getKeyMap(val) { + return typeof val == "string" ? keyMap[val] : val + } + + // Helper for deleting text near the selection(s), used to implement + // backspace, delete, and similar functionality. + function deleteNearSelection(cm, compute) { + var ranges = cm.doc.sel.ranges, kill = []; + // Build up a set of ranges to kill first, merging overlapping + // ranges. + for (var i = 0; i < ranges.length; i++) { + var toKill = compute(ranges[i]); + while (kill.length && cmp(toKill.from, lst(kill).to) <= 0) { + var replaced = kill.pop(); + if (cmp(replaced.from, toKill.from) < 0) { + toKill.from = replaced.from; + break + } + } + kill.push(toKill); + } + // Next, remove those actual ranges. + runInOp(cm, function () { + for (var i = kill.length - 1; i >= 0; i--) + { replaceRange(cm.doc, "", kill[i].from, kill[i].to, "+delete"); } + ensureCursorVisible(cm); + }); + } + + function moveCharLogically(line, ch, dir) { + var target = skipExtendingChars(line.text, ch + dir, dir); + return target < 0 || target > line.text.length ? null : target + } + + function moveLogically(line, start, dir) { + var ch = moveCharLogically(line, start.ch, dir); + return ch == null ? null : new Pos(start.line, ch, dir < 0 ? "after" : "before") + } + + function endOfLine(visually, cm, lineObj, lineNo, dir) { + if (visually) { + if (cm.getOption("direction") == "rtl") { dir = -dir; } + var order = getOrder(lineObj, cm.doc.direction); + if (order) { + var part = dir < 0 ? lst(order) : order[0]; + var moveInStorageOrder = (dir < 0) == (part.level == 1); + var sticky = moveInStorageOrder ? "after" : "before"; + var ch; + // With a wrapped rtl chunk (possibly spanning multiple bidi parts), + // it could be that the last bidi part is not on the last visual line, + // since visual lines contain content order-consecutive chunks. + // Thus, in rtl, we are looking for the first (content-order) character + // in the rtl chunk that is on the last line (that is, the same line + // as the last (content-order) character). + if (part.level > 0 || cm.doc.direction == "rtl") { + var prep = prepareMeasureForLine(cm, lineObj); + ch = dir < 0 ? lineObj.text.length - 1 : 0; + var targetTop = measureCharPrepared(cm, prep, ch).top; + ch = findFirst(function (ch) { return measureCharPrepared(cm, prep, ch).top == targetTop; }, (dir < 0) == (part.level == 1) ? part.from : part.to - 1, ch); + if (sticky == "before") { ch = moveCharLogically(lineObj, ch, 1); } + } else { ch = dir < 0 ? part.to : part.from; } + return new Pos(lineNo, ch, sticky) + } + } + return new Pos(lineNo, dir < 0 ? lineObj.text.length : 0, dir < 0 ? "before" : "after") + } + + function moveVisually(cm, line, start, dir) { + var bidi = getOrder(line, cm.doc.direction); + if (!bidi) { return moveLogically(line, start, dir) } + if (start.ch >= line.text.length) { + start.ch = line.text.length; + start.sticky = "before"; + } else if (start.ch <= 0) { + start.ch = 0; + start.sticky = "after"; + } + var partPos = getBidiPartAt(bidi, start.ch, start.sticky), part = bidi[partPos]; + if (cm.doc.direction == "ltr" && part.level % 2 == 0 && (dir > 0 ? part.to > start.ch : part.from < start.ch)) { + // Case 1: We move within an ltr part in an ltr editor. Even with wrapped lines, + // nothing interesting happens. + return moveLogically(line, start, dir) + } + + var mv = function (pos, dir) { return moveCharLogically(line, pos instanceof Pos ? pos.ch : pos, dir); }; + var prep; + var getWrappedLineExtent = function (ch) { + if (!cm.options.lineWrapping) { return {begin: 0, end: line.text.length} } + prep = prep || prepareMeasureForLine(cm, line); + return wrappedLineExtentChar(cm, line, prep, ch) + }; + var wrappedLineExtent = getWrappedLineExtent(start.sticky == "before" ? mv(start, -1) : start.ch); + + if (cm.doc.direction == "rtl" || part.level == 1) { + var moveInStorageOrder = (part.level == 1) == (dir < 0); + var ch = mv(start, moveInStorageOrder ? 1 : -1); + if (ch != null && (!moveInStorageOrder ? ch >= part.from && ch >= wrappedLineExtent.begin : ch <= part.to && ch <= wrappedLineExtent.end)) { + // Case 2: We move within an rtl part or in an rtl editor on the same visual line + var sticky = moveInStorageOrder ? "before" : "after"; + return new Pos(start.line, ch, sticky) + } + } + + // Case 3: Could not move within this bidi part in this visual line, so leave + // the current bidi part + + var searchInVisualLine = function (partPos, dir, wrappedLineExtent) { + var getRes = function (ch, moveInStorageOrder) { return moveInStorageOrder + ? new Pos(start.line, mv(ch, 1), "before") + : new Pos(start.line, ch, "after"); }; + + for (; partPos >= 0 && partPos < bidi.length; partPos += dir) { + var part = bidi[partPos]; + var moveInStorageOrder = (dir > 0) == (part.level != 1); + var ch = moveInStorageOrder ? wrappedLineExtent.begin : mv(wrappedLineExtent.end, -1); + if (part.from <= ch && ch < part.to) { return getRes(ch, moveInStorageOrder) } + ch = moveInStorageOrder ? part.from : mv(part.to, -1); + if (wrappedLineExtent.begin <= ch && ch < wrappedLineExtent.end) { return getRes(ch, moveInStorageOrder) } + } + }; + + // Case 3a: Look for other bidi parts on the same visual line + var res = searchInVisualLine(partPos + dir, dir, wrappedLineExtent); + if (res) { return res } + + // Case 3b: Look for other bidi parts on the next visual line + var nextCh = dir > 0 ? wrappedLineExtent.end : mv(wrappedLineExtent.begin, -1); + if (nextCh != null && !(dir > 0 && nextCh == line.text.length)) { + res = searchInVisualLine(dir > 0 ? 0 : bidi.length - 1, dir, getWrappedLineExtent(nextCh)); + if (res) { return res } + } + + // Case 4: Nowhere to move + return null + } + + // Commands are parameter-less actions that can be performed on an + // editor, mostly used for keybindings. + var commands = { + selectAll: selectAll, + singleSelection: function (cm) { return cm.setSelection(cm.getCursor("anchor"), cm.getCursor("head"), sel_dontScroll); }, + killLine: function (cm) { return deleteNearSelection(cm, function (range) { + if (range.empty()) { + var len = getLine(cm.doc, range.head.line).text.length; + if (range.head.ch == len && range.head.line < cm.lastLine()) + { return {from: range.head, to: Pos(range.head.line + 1, 0)} } + else + { return {from: range.head, to: Pos(range.head.line, len)} } + } else { + return {from: range.from(), to: range.to()} + } + }); }, + deleteLine: function (cm) { return deleteNearSelection(cm, function (range) { return ({ + from: Pos(range.from().line, 0), + to: clipPos(cm.doc, Pos(range.to().line + 1, 0)) + }); }); }, + delLineLeft: function (cm) { return deleteNearSelection(cm, function (range) { return ({ + from: Pos(range.from().line, 0), to: range.from() + }); }); }, + delWrappedLineLeft: function (cm) { return deleteNearSelection(cm, function (range) { + var top = cm.charCoords(range.head, "div").top + 5; + var leftPos = cm.coordsChar({left: 0, top: top}, "div"); + return {from: leftPos, to: range.from()} + }); }, + delWrappedLineRight: function (cm) { return deleteNearSelection(cm, function (range) { + var top = cm.charCoords(range.head, "div").top + 5; + var rightPos = cm.coordsChar({left: cm.display.lineDiv.offsetWidth + 100, top: top}, "div"); + return {from: range.from(), to: rightPos } + }); }, + undo: function (cm) { return cm.undo(); }, + redo: function (cm) { return cm.redo(); }, + undoSelection: function (cm) { return cm.undoSelection(); }, + redoSelection: function (cm) { return cm.redoSelection(); }, + goDocStart: function (cm) { return cm.extendSelection(Pos(cm.firstLine(), 0)); }, + goDocEnd: function (cm) { return cm.extendSelection(Pos(cm.lastLine())); }, + goLineStart: function (cm) { return cm.extendSelectionsBy(function (range) { return lineStart(cm, range.head.line); }, + {origin: "+move", bias: 1} + ); }, + goLineStartSmart: function (cm) { return cm.extendSelectionsBy(function (range) { return lineStartSmart(cm, range.head); }, + {origin: "+move", bias: 1} + ); }, + goLineEnd: function (cm) { return cm.extendSelectionsBy(function (range) { return lineEnd(cm, range.head.line); }, + {origin: "+move", bias: -1} + ); }, + goLineRight: function (cm) { return cm.extendSelectionsBy(function (range) { + var top = cm.cursorCoords(range.head, "div").top + 5; + return cm.coordsChar({left: cm.display.lineDiv.offsetWidth + 100, top: top}, "div") + }, sel_move); }, + goLineLeft: function (cm) { return cm.extendSelectionsBy(function (range) { + var top = cm.cursorCoords(range.head, "div").top + 5; + return cm.coordsChar({left: 0, top: top}, "div") + }, sel_move); }, + goLineLeftSmart: function (cm) { return cm.extendSelectionsBy(function (range) { + var top = cm.cursorCoords(range.head, "div").top + 5; + var pos = cm.coordsChar({left: 0, top: top}, "div"); + if (pos.ch < cm.getLine(pos.line).search(/\S/)) { return lineStartSmart(cm, range.head) } + return pos + }, sel_move); }, + goLineUp: function (cm) { return cm.moveV(-1, "line"); }, + goLineDown: function (cm) { return cm.moveV(1, "line"); }, + goPageUp: function (cm) { return cm.moveV(-1, "page"); }, + goPageDown: function (cm) { return cm.moveV(1, "page"); }, + goCharLeft: function (cm) { return cm.moveH(-1, "char"); }, + goCharRight: function (cm) { return cm.moveH(1, "char"); }, + goColumnLeft: function (cm) { return cm.moveH(-1, "column"); }, + goColumnRight: function (cm) { return cm.moveH(1, "column"); }, + goWordLeft: function (cm) { return cm.moveH(-1, "word"); }, + goGroupRight: function (cm) { return cm.moveH(1, "group"); }, + goGroupLeft: function (cm) { return cm.moveH(-1, "group"); }, + goWordRight: function (cm) { return cm.moveH(1, "word"); }, + delCharBefore: function (cm) { return cm.deleteH(-1, "char"); }, + delCharAfter: function (cm) { return cm.deleteH(1, "char"); }, + delWordBefore: function (cm) { return cm.deleteH(-1, "word"); }, + delWordAfter: function (cm) { return cm.deleteH(1, "word"); }, + delGroupBefore: function (cm) { return cm.deleteH(-1, "group"); }, + delGroupAfter: function (cm) { return cm.deleteH(1, "group"); }, + indentAuto: function (cm) { return cm.indentSelection("smart"); }, + indentMore: function (cm) { return cm.indentSelection("add"); }, + indentLess: function (cm) { return cm.indentSelection("subtract"); }, + insertTab: function (cm) { return cm.replaceSelection("\t"); }, + insertSoftTab: function (cm) { + var spaces = [], ranges = cm.listSelections(), tabSize = cm.options.tabSize; + for (var i = 0; i < ranges.length; i++) { + var pos = ranges[i].from(); + var col = countColumn(cm.getLine(pos.line), pos.ch, tabSize); + spaces.push(spaceStr(tabSize - col % tabSize)); + } + cm.replaceSelections(spaces); + }, + defaultTab: function (cm) { + if (cm.somethingSelected()) { cm.indentSelection("add"); } + else { cm.execCommand("insertTab"); } + }, + // Swap the two chars left and right of each selection's head. + // Move cursor behind the two swapped characters afterwards. + // + // Doesn't consider line feeds a character. + // Doesn't scan more than one line above to find a character. + // Doesn't do anything on an empty line. + // Doesn't do anything with non-empty selections. + transposeChars: function (cm) { return runInOp(cm, function () { + var ranges = cm.listSelections(), newSel = []; + for (var i = 0; i < ranges.length; i++) { + if (!ranges[i].empty()) { continue } + var cur = ranges[i].head, line = getLine(cm.doc, cur.line).text; + if (line) { + if (cur.ch == line.length) { cur = new Pos(cur.line, cur.ch - 1); } + if (cur.ch > 0) { + cur = new Pos(cur.line, cur.ch + 1); + cm.replaceRange(line.charAt(cur.ch - 1) + line.charAt(cur.ch - 2), + Pos(cur.line, cur.ch - 2), cur, "+transpose"); + } else if (cur.line > cm.doc.first) { + var prev = getLine(cm.doc, cur.line - 1).text; + if (prev) { + cur = new Pos(cur.line, 1); + cm.replaceRange(line.charAt(0) + cm.doc.lineSeparator() + + prev.charAt(prev.length - 1), + Pos(cur.line - 1, prev.length - 1), cur, "+transpose"); + } + } + } + newSel.push(new Range(cur, cur)); + } + cm.setSelections(newSel); + }); }, + newlineAndIndent: function (cm) { return runInOp(cm, function () { + var sels = cm.listSelections(); + for (var i = sels.length - 1; i >= 0; i--) + { cm.replaceRange(cm.doc.lineSeparator(), sels[i].anchor, sels[i].head, "+input"); } + sels = cm.listSelections(); + for (var i$1 = 0; i$1 < sels.length; i$1++) + { cm.indentLine(sels[i$1].from().line, null, true); } + ensureCursorVisible(cm); + }); }, + openLine: function (cm) { return cm.replaceSelection("\n", "start"); }, + toggleOverwrite: function (cm) { return cm.toggleOverwrite(); } + }; + + + function lineStart(cm, lineN) { + var line = getLine(cm.doc, lineN); + var visual = visualLine(line); + if (visual != line) { lineN = lineNo(visual); } + return endOfLine(true, cm, visual, lineN, 1) + } + function lineEnd(cm, lineN) { + var line = getLine(cm.doc, lineN); + var visual = visualLineEnd(line); + if (visual != line) { lineN = lineNo(visual); } + return endOfLine(true, cm, line, lineN, -1) + } + function lineStartSmart(cm, pos) { + var start = lineStart(cm, pos.line); + var line = getLine(cm.doc, start.line); + var order = getOrder(line, cm.doc.direction); + if (!order || order[0].level == 0) { + var firstNonWS = Math.max(0, line.text.search(/\S/)); + var inWS = pos.line == start.line && pos.ch <= firstNonWS && pos.ch; + return Pos(start.line, inWS ? 0 : firstNonWS, start.sticky) + } + return start + } + + // Run a handler that was bound to a key. + function doHandleBinding(cm, bound, dropShift) { + if (typeof bound == "string") { + bound = commands[bound]; + if (!bound) { return false } + } + // Ensure previous input has been read, so that the handler sees a + // consistent view of the document + cm.display.input.ensurePolled(); + var prevShift = cm.display.shift, done = false; + try { + if (cm.isReadOnly()) { cm.state.suppressEdits = true; } + if (dropShift) { cm.display.shift = false; } + done = bound(cm) != Pass; + } finally { + cm.display.shift = prevShift; + cm.state.suppressEdits = false; + } + return done + } + + function lookupKeyForEditor(cm, name, handle) { + for (var i = 0; i < cm.state.keyMaps.length; i++) { + var result = lookupKey(name, cm.state.keyMaps[i], handle, cm); + if (result) { return result } + } + return (cm.options.extraKeys && lookupKey(name, cm.options.extraKeys, handle, cm)) + || lookupKey(name, cm.options.keyMap, handle, cm) + } + + // Note that, despite the name, this function is also used to check + // for bound mouse clicks. + + var stopSeq = new Delayed; + + function dispatchKey(cm, name, e, handle) { + var seq = cm.state.keySeq; + if (seq) { + if (isModifierKey(name)) { return "handled" } + if (/\'$/.test(name)) + { cm.state.keySeq = null; } + else + { stopSeq.set(50, function () { + if (cm.state.keySeq == seq) { + cm.state.keySeq = null; + cm.display.input.reset(); + } + }); } + if (dispatchKeyInner(cm, seq + " " + name, e, handle)) { return true } + } + return dispatchKeyInner(cm, name, e, handle) + } + + function dispatchKeyInner(cm, name, e, handle) { + var result = lookupKeyForEditor(cm, name, handle); + + if (result == "multi") + { cm.state.keySeq = name; } + if (result == "handled") + { signalLater(cm, "keyHandled", cm, name, e); } + + if (result == "handled" || result == "multi") { + e_preventDefault(e); + restartBlink(cm); + } + + return !!result + } + + // Handle a key from the keydown event. + function handleKeyBinding(cm, e) { + var name = keyName(e, true); + if (!name) { return false } + + if (e.shiftKey && !cm.state.keySeq) { + // First try to resolve full name (including 'Shift-'). Failing + // that, see if there is a cursor-motion command (starting with + // 'go') bound to the keyname without 'Shift-'. + return dispatchKey(cm, "Shift-" + name, e, function (b) { return doHandleBinding(cm, b, true); }) + || dispatchKey(cm, name, e, function (b) { + if (typeof b == "string" ? /^go[A-Z]/.test(b) : b.motion) + { return doHandleBinding(cm, b) } + }) + } else { + return dispatchKey(cm, name, e, function (b) { return doHandleBinding(cm, b); }) + } + } + + // Handle a key from the keypress event + function handleCharBinding(cm, e, ch) { + return dispatchKey(cm, "'" + ch + "'", e, function (b) { return doHandleBinding(cm, b, true); }) + } + + var lastStoppedKey = null; + function onKeyDown(e) { + var cm = this; + cm.curOp.focus = activeElt(); + if (signalDOMEvent(cm, e)) { return } + // IE does strange things with escape. + if (ie && ie_version < 11 && e.keyCode == 27) { e.returnValue = false; } + var code = e.keyCode; + cm.display.shift = code == 16 || e.shiftKey; + var handled = handleKeyBinding(cm, e); + if (presto) { + lastStoppedKey = handled ? code : null; + // Opera has no cut event... we try to at least catch the key combo + if (!handled && code == 88 && !hasCopyEvent && (mac ? e.metaKey : e.ctrlKey)) + { cm.replaceSelection("", null, "cut"); } + } + if (gecko && !mac && !handled && code == 46 && e.shiftKey && !e.ctrlKey && document.execCommand) + { document.execCommand("cut"); } + + // Turn mouse into crosshair when Alt is held on Mac. + if (code == 18 && !/\bCodeMirror-crosshair\b/.test(cm.display.lineDiv.className)) + { showCrossHair(cm); } + } + + function showCrossHair(cm) { + var lineDiv = cm.display.lineDiv; + addClass(lineDiv, "CodeMirror-crosshair"); + + function up(e) { + if (e.keyCode == 18 || !e.altKey) { + rmClass(lineDiv, "CodeMirror-crosshair"); + off(document, "keyup", up); + off(document, "mouseover", up); + } + } + on(document, "keyup", up); + on(document, "mouseover", up); + } + + function onKeyUp(e) { + if (e.keyCode == 16) { this.doc.sel.shift = false; } + signalDOMEvent(this, e); + } + + function onKeyPress(e) { + var cm = this; + if (eventInWidget(cm.display, e) || signalDOMEvent(cm, e) || e.ctrlKey && !e.altKey || mac && e.metaKey) { return } + var keyCode = e.keyCode, charCode = e.charCode; + if (presto && keyCode == lastStoppedKey) {lastStoppedKey = null; e_preventDefault(e); return} + if ((presto && (!e.which || e.which < 10)) && handleKeyBinding(cm, e)) { return } + var ch = String.fromCharCode(charCode == null ? keyCode : charCode); + // Some browsers fire keypress events for backspace + if (ch == "\x08") { return } + if (handleCharBinding(cm, e, ch)) { return } + cm.display.input.onKeyPress(e); + } + + var DOUBLECLICK_DELAY = 400; + + var PastClick = function(time, pos, button) { + this.time = time; + this.pos = pos; + this.button = button; + }; + + PastClick.prototype.compare = function (time, pos, button) { + return this.time + DOUBLECLICK_DELAY > time && + cmp(pos, this.pos) == 0 && button == this.button + }; + + var lastClick, lastDoubleClick; + function clickRepeat(pos, button) { + var now = +new Date; + if (lastDoubleClick && lastDoubleClick.compare(now, pos, button)) { + lastClick = lastDoubleClick = null; + return "triple" + } else if (lastClick && lastClick.compare(now, pos, button)) { + lastDoubleClick = new PastClick(now, pos, button); + lastClick = null; + return "double" + } else { + lastClick = new PastClick(now, pos, button); + lastDoubleClick = null; + return "single" + } + } + + // A mouse down can be a single click, double click, triple click, + // start of selection drag, start of text drag, new cursor + // (ctrl-click), rectangle drag (alt-drag), or xwin + // middle-click-paste. Or it might be a click on something we should + // not interfere with, such as a scrollbar or widget. + function onMouseDown(e) { + var cm = this, display = cm.display; + if (signalDOMEvent(cm, e) || display.activeTouch && display.input.supportsTouch()) { return } + display.input.ensurePolled(); + display.shift = e.shiftKey; + + if (eventInWidget(display, e)) { + if (!webkit) { + // Briefly turn off draggability, to allow widgets to do + // normal dragging things. + display.scroller.draggable = false; + setTimeout(function () { return display.scroller.draggable = true; }, 100); + } + return + } + if (clickInGutter(cm, e)) { return } + var pos = posFromMouse(cm, e), button = e_button(e), repeat = pos ? clickRepeat(pos, button) : "single"; + window.focus(); + + // #3261: make sure, that we're not starting a second selection + if (button == 1 && cm.state.selectingText) + { cm.state.selectingText(e); } + + if (pos && handleMappedButton(cm, button, pos, repeat, e)) { return } + + if (button == 1) { + if (pos) { leftButtonDown(cm, pos, repeat, e); } + else if (e_target(e) == display.scroller) { e_preventDefault(e); } + } else if (button == 2) { + if (pos) { extendSelection(cm.doc, pos); } + setTimeout(function () { return display.input.focus(); }, 20); + } else if (button == 3) { + if (captureRightClick) { cm.display.input.onContextMenu(e); } + else { delayBlurEvent(cm); } + } + } + + function handleMappedButton(cm, button, pos, repeat, event) { + var name = "Click"; + if (repeat == "double") { name = "Double" + name; } + else if (repeat == "triple") { name = "Triple" + name; } + name = (button == 1 ? "Left" : button == 2 ? "Middle" : "Right") + name; + + return dispatchKey(cm, addModifierNames(name, event), event, function (bound) { + if (typeof bound == "string") { bound = commands[bound]; } + if (!bound) { return false } + var done = false; + try { + if (cm.isReadOnly()) { cm.state.suppressEdits = true; } + done = bound(cm, pos) != Pass; + } finally { + cm.state.suppressEdits = false; + } + return done + }) + } + + function configureMouse(cm, repeat, event) { + var option = cm.getOption("configureMouse"); + var value = option ? option(cm, repeat, event) : {}; + if (value.unit == null) { + var rect = chromeOS ? event.shiftKey && event.metaKey : event.altKey; + value.unit = rect ? "rectangle" : repeat == "single" ? "char" : repeat == "double" ? "word" : "line"; + } + if (value.extend == null || cm.doc.extend) { value.extend = cm.doc.extend || event.shiftKey; } + if (value.addNew == null) { value.addNew = mac ? event.metaKey : event.ctrlKey; } + if (value.moveOnDrag == null) { value.moveOnDrag = !(mac ? event.altKey : event.ctrlKey); } + return value + } + + function leftButtonDown(cm, pos, repeat, event) { + if (ie) { setTimeout(bind(ensureFocus, cm), 0); } + else { cm.curOp.focus = activeElt(); } + + var behavior = configureMouse(cm, repeat, event); + + var sel = cm.doc.sel, contained; + if (cm.options.dragDrop && dragAndDrop && !cm.isReadOnly() && + repeat == "single" && (contained = sel.contains(pos)) > -1 && + (cmp((contained = sel.ranges[contained]).from(), pos) < 0 || pos.xRel > 0) && + (cmp(contained.to(), pos) > 0 || pos.xRel < 0)) + { leftButtonStartDrag(cm, event, pos, behavior); } + else + { leftButtonSelect(cm, event, pos, behavior); } + } + + // Start a text drag. When it ends, see if any dragging actually + // happen, and treat as a click if it didn't. + function leftButtonStartDrag(cm, event, pos, behavior) { + var display = cm.display, moved = false; + var dragEnd = operation(cm, function (e) { + if (webkit) { display.scroller.draggable = false; } + cm.state.draggingText = false; + off(display.wrapper.ownerDocument, "mouseup", dragEnd); + off(display.wrapper.ownerDocument, "mousemove", mouseMove); + off(display.scroller, "dragstart", dragStart); + off(display.scroller, "drop", dragEnd); + if (!moved) { + e_preventDefault(e); + if (!behavior.addNew) + { extendSelection(cm.doc, pos, null, null, behavior.extend); } + // Work around unexplainable focus problem in IE9 (#2127) and Chrome (#3081) + if (webkit || ie && ie_version == 9) + { setTimeout(function () {display.wrapper.ownerDocument.body.focus(); display.input.focus();}, 20); } + else + { display.input.focus(); } + } + }); + var mouseMove = function(e2) { + moved = moved || Math.abs(event.clientX - e2.clientX) + Math.abs(event.clientY - e2.clientY) >= 10; + }; + var dragStart = function () { return moved = true; }; + // Let the drag handler handle this. + if (webkit) { display.scroller.draggable = true; } + cm.state.draggingText = dragEnd; + dragEnd.copy = !behavior.moveOnDrag; + // IE's approach to draggable + if (display.scroller.dragDrop) { display.scroller.dragDrop(); } + on(display.wrapper.ownerDocument, "mouseup", dragEnd); + on(display.wrapper.ownerDocument, "mousemove", mouseMove); + on(display.scroller, "dragstart", dragStart); + on(display.scroller, "drop", dragEnd); + + delayBlurEvent(cm); + setTimeout(function () { return display.input.focus(); }, 20); + } + + function rangeForUnit(cm, pos, unit) { + if (unit == "char") { return new Range(pos, pos) } + if (unit == "word") { return cm.findWordAt(pos) } + if (unit == "line") { return new Range(Pos(pos.line, 0), clipPos(cm.doc, Pos(pos.line + 1, 0))) } + var result = unit(cm, pos); + return new Range(result.from, result.to) + } + + // Normal selection, as opposed to text dragging. + function leftButtonSelect(cm, event, start, behavior) { + var display = cm.display, doc = cm.doc; + e_preventDefault(event); + + var ourRange, ourIndex, startSel = doc.sel, ranges = startSel.ranges; + if (behavior.addNew && !behavior.extend) { + ourIndex = doc.sel.contains(start); + if (ourIndex > -1) + { ourRange = ranges[ourIndex]; } + else + { ourRange = new Range(start, start); } + } else { + ourRange = doc.sel.primary(); + ourIndex = doc.sel.primIndex; + } + + if (behavior.unit == "rectangle") { + if (!behavior.addNew) { ourRange = new Range(start, start); } + start = posFromMouse(cm, event, true, true); + ourIndex = -1; + } else { + var range$$1 = rangeForUnit(cm, start, behavior.unit); + if (behavior.extend) + { ourRange = extendRange(ourRange, range$$1.anchor, range$$1.head, behavior.extend); } + else + { ourRange = range$$1; } + } + + if (!behavior.addNew) { + ourIndex = 0; + setSelection(doc, new Selection([ourRange], 0), sel_mouse); + startSel = doc.sel; + } else if (ourIndex == -1) { + ourIndex = ranges.length; + setSelection(doc, normalizeSelection(cm, ranges.concat([ourRange]), ourIndex), + {scroll: false, origin: "*mouse"}); + } else if (ranges.length > 1 && ranges[ourIndex].empty() && behavior.unit == "char" && !behavior.extend) { + setSelection(doc, normalizeSelection(cm, ranges.slice(0, ourIndex).concat(ranges.slice(ourIndex + 1)), 0), + {scroll: false, origin: "*mouse"}); + startSel = doc.sel; + } else { + replaceOneSelection(doc, ourIndex, ourRange, sel_mouse); + } + + var lastPos = start; + function extendTo(pos) { + if (cmp(lastPos, pos) == 0) { return } + lastPos = pos; + + if (behavior.unit == "rectangle") { + var ranges = [], tabSize = cm.options.tabSize; + var startCol = countColumn(getLine(doc, start.line).text, start.ch, tabSize); + var posCol = countColumn(getLine(doc, pos.line).text, pos.ch, tabSize); + var left = Math.min(startCol, posCol), right = Math.max(startCol, posCol); + for (var line = Math.min(start.line, pos.line), end = Math.min(cm.lastLine(), Math.max(start.line, pos.line)); + line <= end; line++) { + var text = getLine(doc, line).text, leftPos = findColumn(text, left, tabSize); + if (left == right) + { ranges.push(new Range(Pos(line, leftPos), Pos(line, leftPos))); } + else if (text.length > leftPos) + { ranges.push(new Range(Pos(line, leftPos), Pos(line, findColumn(text, right, tabSize)))); } + } + if (!ranges.length) { ranges.push(new Range(start, start)); } + setSelection(doc, normalizeSelection(cm, startSel.ranges.slice(0, ourIndex).concat(ranges), ourIndex), + {origin: "*mouse", scroll: false}); + cm.scrollIntoView(pos); + } else { + var oldRange = ourRange; + var range$$1 = rangeForUnit(cm, pos, behavior.unit); + var anchor = oldRange.anchor, head; + if (cmp(range$$1.anchor, anchor) > 0) { + head = range$$1.head; + anchor = minPos(oldRange.from(), range$$1.anchor); + } else { + head = range$$1.anchor; + anchor = maxPos(oldRange.to(), range$$1.head); + } + var ranges$1 = startSel.ranges.slice(0); + ranges$1[ourIndex] = bidiSimplify(cm, new Range(clipPos(doc, anchor), head)); + setSelection(doc, normalizeSelection(cm, ranges$1, ourIndex), sel_mouse); + } + } + + var editorSize = display.wrapper.getBoundingClientRect(); + // Used to ensure timeout re-tries don't fire when another extend + // happened in the meantime (clearTimeout isn't reliable -- at + // least on Chrome, the timeouts still happen even when cleared, + // if the clear happens after their scheduled firing time). + var counter = 0; + + function extend(e) { + var curCount = ++counter; + var cur = posFromMouse(cm, e, true, behavior.unit == "rectangle"); + if (!cur) { return } + if (cmp(cur, lastPos) != 0) { + cm.curOp.focus = activeElt(); + extendTo(cur); + var visible = visibleLines(display, doc); + if (cur.line >= visible.to || cur.line < visible.from) + { setTimeout(operation(cm, function () {if (counter == curCount) { extend(e); }}), 150); } + } else { + var outside = e.clientY < editorSize.top ? -20 : e.clientY > editorSize.bottom ? 20 : 0; + if (outside) { setTimeout(operation(cm, function () { + if (counter != curCount) { return } + display.scroller.scrollTop += outside; + extend(e); + }), 50); } + } + } + + function done(e) { + cm.state.selectingText = false; + counter = Infinity; + // If e is null or undefined we interpret this as someone trying + // to explicitly cancel the selection rather than the user + // letting go of the mouse button. + if (e) { + e_preventDefault(e); + display.input.focus(); + } + off(display.wrapper.ownerDocument, "mousemove", move); + off(display.wrapper.ownerDocument, "mouseup", up); + doc.history.lastSelOrigin = null; + } + + var move = operation(cm, function (e) { + if (e.buttons === 0 || !e_button(e)) { done(e); } + else { extend(e); } + }); + var up = operation(cm, done); + cm.state.selectingText = up; + on(display.wrapper.ownerDocument, "mousemove", move); + on(display.wrapper.ownerDocument, "mouseup", up); + } + + // Used when mouse-selecting to adjust the anchor to the proper side + // of a bidi jump depending on the visual position of the head. + function bidiSimplify(cm, range$$1) { + var anchor = range$$1.anchor; + var head = range$$1.head; + var anchorLine = getLine(cm.doc, anchor.line); + if (cmp(anchor, head) == 0 && anchor.sticky == head.sticky) { return range$$1 } + var order = getOrder(anchorLine); + if (!order) { return range$$1 } + var index = getBidiPartAt(order, anchor.ch, anchor.sticky), part = order[index]; + if (part.from != anchor.ch && part.to != anchor.ch) { return range$$1 } + var boundary = index + ((part.from == anchor.ch) == (part.level != 1) ? 0 : 1); + if (boundary == 0 || boundary == order.length) { return range$$1 } + + // Compute the relative visual position of the head compared to the + // anchor (<0 is to the left, >0 to the right) + var leftSide; + if (head.line != anchor.line) { + leftSide = (head.line - anchor.line) * (cm.doc.direction == "ltr" ? 1 : -1) > 0; + } else { + var headIndex = getBidiPartAt(order, head.ch, head.sticky); + var dir = headIndex - index || (head.ch - anchor.ch) * (part.level == 1 ? -1 : 1); + if (headIndex == boundary - 1 || headIndex == boundary) + { leftSide = dir < 0; } + else + { leftSide = dir > 0; } + } + + var usePart = order[boundary + (leftSide ? -1 : 0)]; + var from = leftSide == (usePart.level == 1); + var ch = from ? usePart.from : usePart.to, sticky = from ? "after" : "before"; + return anchor.ch == ch && anchor.sticky == sticky ? range$$1 : new Range(new Pos(anchor.line, ch, sticky), head) + } + + + // Determines whether an event happened in the gutter, and fires the + // handlers for the corresponding event. + function gutterEvent(cm, e, type, prevent) { + var mX, mY; + if (e.touches) { + mX = e.touches[0].clientX; + mY = e.touches[0].clientY; + } else { + try { mX = e.clientX; mY = e.clientY; } + catch(e) { return false } + } + if (mX >= Math.floor(cm.display.gutters.getBoundingClientRect().right)) { return false } + if (prevent) { e_preventDefault(e); } + + var display = cm.display; + var lineBox = display.lineDiv.getBoundingClientRect(); + + if (mY > lineBox.bottom || !hasHandler(cm, type)) { return e_defaultPrevented(e) } + mY -= lineBox.top - display.viewOffset; + + for (var i = 0; i < cm.display.gutterSpecs.length; ++i) { + var g = display.gutters.childNodes[i]; + if (g && g.getBoundingClientRect().right >= mX) { + var line = lineAtHeight(cm.doc, mY); + var gutter = cm.display.gutterSpecs[i]; + signal(cm, type, cm, line, gutter.className, e); + return e_defaultPrevented(e) + } + } + } + + function clickInGutter(cm, e) { + return gutterEvent(cm, e, "gutterClick", true) + } + + // CONTEXT MENU HANDLING + + // To make the context menu work, we need to briefly unhide the + // textarea (making it as unobtrusive as possible) to let the + // right-click take effect on it. + function onContextMenu(cm, e) { + if (eventInWidget(cm.display, e) || contextMenuInGutter(cm, e)) { return } + if (signalDOMEvent(cm, e, "contextmenu")) { return } + if (!captureRightClick) { cm.display.input.onContextMenu(e); } + } + + function contextMenuInGutter(cm, e) { + if (!hasHandler(cm, "gutterContextMenu")) { return false } + return gutterEvent(cm, e, "gutterContextMenu", false) + } + + function themeChanged(cm) { + cm.display.wrapper.className = cm.display.wrapper.className.replace(/\s*cm-s-\S+/g, "") + + cm.options.theme.replace(/(^|\s)\s*/g, " cm-s-"); + clearCaches(cm); + } + + var Init = {toString: function(){return "CodeMirror.Init"}}; + + var defaults = {}; + var optionHandlers = {}; + + function defineOptions(CodeMirror) { + var optionHandlers = CodeMirror.optionHandlers; + + function option(name, deflt, handle, notOnInit) { + CodeMirror.defaults[name] = deflt; + if (handle) { optionHandlers[name] = + notOnInit ? function (cm, val, old) {if (old != Init) { handle(cm, val, old); }} : handle; } + } + + CodeMirror.defineOption = option; + + // Passed to option handlers when there is no old value. + CodeMirror.Init = Init; + + // These two are, on init, called from the constructor because they + // have to be initialized before the editor can start at all. + option("value", "", function (cm, val) { return cm.setValue(val); }, true); + option("mode", null, function (cm, val) { + cm.doc.modeOption = val; + loadMode(cm); + }, true); + + option("indentUnit", 2, loadMode, true); + option("indentWithTabs", false); + option("smartIndent", true); + option("tabSize", 4, function (cm) { + resetModeState(cm); + clearCaches(cm); + regChange(cm); + }, true); + + option("lineSeparator", null, function (cm, val) { + cm.doc.lineSep = val; + if (!val) { return } + var newBreaks = [], lineNo = cm.doc.first; + cm.doc.iter(function (line) { + for (var pos = 0;;) { + var found = line.text.indexOf(val, pos); + if (found == -1) { break } + pos = found + val.length; + newBreaks.push(Pos(lineNo, found)); + } + lineNo++; + }); + for (var i = newBreaks.length - 1; i >= 0; i--) + { replaceRange(cm.doc, val, newBreaks[i], Pos(newBreaks[i].line, newBreaks[i].ch + val.length)); } + }); + option("specialChars", /[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u200b-\u200f\u2028\u2029\ufeff\ufff9-\ufffc]/g, function (cm, val, old) { + cm.state.specialChars = new RegExp(val.source + (val.test("\t") ? "" : "|\t"), "g"); + if (old != Init) { cm.refresh(); } + }); + option("specialCharPlaceholder", defaultSpecialCharPlaceholder, function (cm) { return cm.refresh(); }, true); + option("electricChars", true); + option("inputStyle", mobile ? "contenteditable" : "textarea", function () { + throw new Error("inputStyle can not (yet) be changed in a running editor") // FIXME + }, true); + option("spellcheck", false, function (cm, val) { return cm.getInputField().spellcheck = val; }, true); + option("autocorrect", false, function (cm, val) { return cm.getInputField().autocorrect = val; }, true); + option("autocapitalize", false, function (cm, val) { return cm.getInputField().autocapitalize = val; }, true); + option("rtlMoveVisually", !windows); + option("wholeLineUpdateBefore", true); + + option("theme", "default", function (cm) { + themeChanged(cm); + updateGutters(cm); + }, true); + option("keyMap", "default", function (cm, val, old) { + var next = getKeyMap(val); + var prev = old != Init && getKeyMap(old); + if (prev && prev.detach) { prev.detach(cm, next); } + if (next.attach) { next.attach(cm, prev || null); } + }); + option("extraKeys", null); + option("configureMouse", null); + + option("lineWrapping", false, wrappingChanged, true); + option("gutters", [], function (cm, val) { + cm.display.gutterSpecs = getGutters(val, cm.options.lineNumbers); + updateGutters(cm); + }, true); + option("fixedGutter", true, function (cm, val) { + cm.display.gutters.style.left = val ? compensateForHScroll(cm.display) + "px" : "0"; + cm.refresh(); + }, true); + option("coverGutterNextToScrollbar", false, function (cm) { return updateScrollbars(cm); }, true); + option("scrollbarStyle", "native", function (cm) { + initScrollbars(cm); + updateScrollbars(cm); + cm.display.scrollbars.setScrollTop(cm.doc.scrollTop); + cm.display.scrollbars.setScrollLeft(cm.doc.scrollLeft); + }, true); + option("lineNumbers", false, function (cm, val) { + cm.display.gutterSpecs = getGutters(cm.options.gutters, val); + updateGutters(cm); + }, true); + option("firstLineNumber", 1, updateGutters, true); + option("lineNumberFormatter", function (integer) { return integer; }, updateGutters, true); + option("showCursorWhenSelecting", false, updateSelection, true); + + option("resetSelectionOnContextMenu", true); + option("lineWiseCopyCut", true); + option("pasteLinesPerSelection", true); + option("selectionsMayTouch", false); + + option("readOnly", false, function (cm, val) { + if (val == "nocursor") { + onBlur(cm); + cm.display.input.blur(); + } + cm.display.input.readOnlyChanged(val); + }); + option("disableInput", false, function (cm, val) {if (!val) { cm.display.input.reset(); }}, true); + option("dragDrop", true, dragDropChanged); + option("allowDropFileTypes", null); + + option("cursorBlinkRate", 530); + option("cursorScrollMargin", 0); + option("cursorHeight", 1, updateSelection, true); + option("singleCursorHeightPerLine", true, updateSelection, true); + option("workTime", 100); + option("workDelay", 100); + option("flattenSpans", true, resetModeState, true); + option("addModeClass", false, resetModeState, true); + option("pollInterval", 100); + option("undoDepth", 200, function (cm, val) { return cm.doc.history.undoDepth = val; }); + option("historyEventDelay", 1250); + option("viewportMargin", 10, function (cm) { return cm.refresh(); }, true); + option("maxHighlightLength", 10000, resetModeState, true); + option("moveInputWithCursor", true, function (cm, val) { + if (!val) { cm.display.input.resetPosition(); } + }); + + option("tabindex", null, function (cm, val) { return cm.display.input.getField().tabIndex = val || ""; }); + option("autofocus", null); + option("direction", "ltr", function (cm, val) { return cm.doc.setDirection(val); }, true); + option("phrases", null); + } + + function dragDropChanged(cm, value, old) { + var wasOn = old && old != Init; + if (!value != !wasOn) { + var funcs = cm.display.dragFunctions; + var toggle = value ? on : off; + toggle(cm.display.scroller, "dragstart", funcs.start); + toggle(cm.display.scroller, "dragenter", funcs.enter); + toggle(cm.display.scroller, "dragover", funcs.over); + toggle(cm.display.scroller, "dragleave", funcs.leave); + toggle(cm.display.scroller, "drop", funcs.drop); + } + } + + function wrappingChanged(cm) { + if (cm.options.lineWrapping) { + addClass(cm.display.wrapper, "CodeMirror-wrap"); + cm.display.sizer.style.minWidth = ""; + cm.display.sizerWidth = null; + } else { + rmClass(cm.display.wrapper, "CodeMirror-wrap"); + findMaxLine(cm); + } + estimateLineHeights(cm); + regChange(cm); + clearCaches(cm); + setTimeout(function () { return updateScrollbars(cm); }, 100); + } + + // A CodeMirror instance represents an editor. This is the object + // that user code is usually dealing with. + + function CodeMirror(place, options) { + var this$1 = this; + + if (!(this instanceof CodeMirror)) { return new CodeMirror(place, options) } + + this.options = options = options ? copyObj(options) : {}; + // Determine effective options based on given values and defaults. + copyObj(defaults, options, false); + + var doc = options.value; + if (typeof doc == "string") { doc = new Doc(doc, options.mode, null, options.lineSeparator, options.direction); } + else if (options.mode) { doc.modeOption = options.mode; } + this.doc = doc; + + var input = new CodeMirror.inputStyles[options.inputStyle](this); + var display = this.display = new Display(place, doc, input, options); + display.wrapper.CodeMirror = this; + themeChanged(this); + if (options.lineWrapping) + { this.display.wrapper.className += " CodeMirror-wrap"; } + initScrollbars(this); + + this.state = { + keyMaps: [], // stores maps added by addKeyMap + overlays: [], // highlighting overlays, as added by addOverlay + modeGen: 0, // bumped when mode/overlay changes, used to invalidate highlighting info + overwrite: false, + delayingBlurEvent: false, + focused: false, + suppressEdits: false, // used to disable editing during key handlers when in readOnly mode + pasteIncoming: -1, cutIncoming: -1, // help recognize paste/cut edits in input.poll + selectingText: false, + draggingText: false, + highlight: new Delayed(), // stores highlight worker timeout + keySeq: null, // Unfinished key sequence + specialChars: null + }; + + if (options.autofocus && !mobile) { display.input.focus(); } + + // Override magic textarea content restore that IE sometimes does + // on our hidden textarea on reload + if (ie && ie_version < 11) { setTimeout(function () { return this$1.display.input.reset(true); }, 20); } + + registerEventHandlers(this); + ensureGlobalHandlers(); + + startOperation(this); + this.curOp.forceUpdate = true; + attachDoc(this, doc); + + if ((options.autofocus && !mobile) || this.hasFocus()) + { setTimeout(bind(onFocus, this), 20); } + else + { onBlur(this); } + + for (var opt in optionHandlers) { if (optionHandlers.hasOwnProperty(opt)) + { optionHandlers[opt](this$1, options[opt], Init); } } + maybeUpdateLineNumberWidth(this); + if (options.finishInit) { options.finishInit(this); } + for (var i = 0; i < initHooks.length; ++i) { initHooks[i](this$1); } + endOperation(this); + // Suppress optimizelegibility in Webkit, since it breaks text + // measuring on line wrapping boundaries. + if (webkit && options.lineWrapping && + getComputedStyle(display.lineDiv).textRendering == "optimizelegibility") + { display.lineDiv.style.textRendering = "auto"; } + } + + // The default configuration options. + CodeMirror.defaults = defaults; + // Functions to run when options are changed. + CodeMirror.optionHandlers = optionHandlers; + + // Attach the necessary event handlers when initializing the editor + function registerEventHandlers(cm) { + var d = cm.display; + on(d.scroller, "mousedown", operation(cm, onMouseDown)); + // Older IE's will not fire a second mousedown for a double click + if (ie && ie_version < 11) + { on(d.scroller, "dblclick", operation(cm, function (e) { + if (signalDOMEvent(cm, e)) { return } + var pos = posFromMouse(cm, e); + if (!pos || clickInGutter(cm, e) || eventInWidget(cm.display, e)) { return } + e_preventDefault(e); + var word = cm.findWordAt(pos); + extendSelection(cm.doc, word.anchor, word.head); + })); } + else + { on(d.scroller, "dblclick", function (e) { return signalDOMEvent(cm, e) || e_preventDefault(e); }); } + // Some browsers fire contextmenu *after* opening the menu, at + // which point we can't mess with it anymore. Context menu is + // handled in onMouseDown for these browsers. + on(d.scroller, "contextmenu", function (e) { return onContextMenu(cm, e); }); + on(d.input.getField(), "contextmenu", function (e) { + if (!d.scroller.contains(e.target)) { onContextMenu(cm, e); } + }); + + // Used to suppress mouse event handling when a touch happens + var touchFinished, prevTouch = {end: 0}; + function finishTouch() { + if (d.activeTouch) { + touchFinished = setTimeout(function () { return d.activeTouch = null; }, 1000); + prevTouch = d.activeTouch; + prevTouch.end = +new Date; + } + } + function isMouseLikeTouchEvent(e) { + if (e.touches.length != 1) { return false } + var touch = e.touches[0]; + return touch.radiusX <= 1 && touch.radiusY <= 1 + } + function farAway(touch, other) { + if (other.left == null) { return true } + var dx = other.left - touch.left, dy = other.top - touch.top; + return dx * dx + dy * dy > 20 * 20 + } + on(d.scroller, "touchstart", function (e) { + if (!signalDOMEvent(cm, e) && !isMouseLikeTouchEvent(e) && !clickInGutter(cm, e)) { + d.input.ensurePolled(); + clearTimeout(touchFinished); + var now = +new Date; + d.activeTouch = {start: now, moved: false, + prev: now - prevTouch.end <= 300 ? prevTouch : null}; + if (e.touches.length == 1) { + d.activeTouch.left = e.touches[0].pageX; + d.activeTouch.top = e.touches[0].pageY; + } + } + }); + on(d.scroller, "touchmove", function () { + if (d.activeTouch) { d.activeTouch.moved = true; } + }); + on(d.scroller, "touchend", function (e) { + var touch = d.activeTouch; + if (touch && !eventInWidget(d, e) && touch.left != null && + !touch.moved && new Date - touch.start < 300) { + var pos = cm.coordsChar(d.activeTouch, "page"), range; + if (!touch.prev || farAway(touch, touch.prev)) // Single tap + { range = new Range(pos, pos); } + else if (!touch.prev.prev || farAway(touch, touch.prev.prev)) // Double tap + { range = cm.findWordAt(pos); } + else // Triple tap + { range = new Range(Pos(pos.line, 0), clipPos(cm.doc, Pos(pos.line + 1, 0))); } + cm.setSelection(range.anchor, range.head); + cm.focus(); + e_preventDefault(e); + } + finishTouch(); + }); + on(d.scroller, "touchcancel", finishTouch); + + // Sync scrolling between fake scrollbars and real scrollable + // area, ensure viewport is updated when scrolling. + on(d.scroller, "scroll", function () { + if (d.scroller.clientHeight) { + updateScrollTop(cm, d.scroller.scrollTop); + setScrollLeft(cm, d.scroller.scrollLeft, true); + signal(cm, "scroll", cm); + } + }); + + // Listen to wheel events in order to try and update the viewport on time. + on(d.scroller, "mousewheel", function (e) { return onScrollWheel(cm, e); }); + on(d.scroller, "DOMMouseScroll", function (e) { return onScrollWheel(cm, e); }); + + // Prevent wrapper from ever scrolling + on(d.wrapper, "scroll", function () { return d.wrapper.scrollTop = d.wrapper.scrollLeft = 0; }); + + d.dragFunctions = { + enter: function (e) {if (!signalDOMEvent(cm, e)) { e_stop(e); }}, + over: function (e) {if (!signalDOMEvent(cm, e)) { onDragOver(cm, e); e_stop(e); }}, + start: function (e) { return onDragStart(cm, e); }, + drop: operation(cm, onDrop), + leave: function (e) {if (!signalDOMEvent(cm, e)) { clearDragCursor(cm); }} + }; + + var inp = d.input.getField(); + on(inp, "keyup", function (e) { return onKeyUp.call(cm, e); }); + on(inp, "keydown", operation(cm, onKeyDown)); + on(inp, "keypress", operation(cm, onKeyPress)); + on(inp, "focus", function (e) { return onFocus(cm, e); }); + on(inp, "blur", function (e) { return onBlur(cm, e); }); + } + + var initHooks = []; + CodeMirror.defineInitHook = function (f) { return initHooks.push(f); }; + + // Indent the given line. The how parameter can be "smart", + // "add"/null, "subtract", or "prev". When aggressive is false + // (typically set to true for forced single-line indents), empty + // lines are not indented, and places where the mode returns Pass + // are left alone. + function indentLine(cm, n, how, aggressive) { + var doc = cm.doc, state; + if (how == null) { how = "add"; } + if (how == "smart") { + // Fall back to "prev" when the mode doesn't have an indentation + // method. + if (!doc.mode.indent) { how = "prev"; } + else { state = getContextBefore(cm, n).state; } + } + + var tabSize = cm.options.tabSize; + var line = getLine(doc, n), curSpace = countColumn(line.text, null, tabSize); + if (line.stateAfter) { line.stateAfter = null; } + var curSpaceString = line.text.match(/^\s*/)[0], indentation; + if (!aggressive && !/\S/.test(line.text)) { + indentation = 0; + how = "not"; + } else if (how == "smart") { + indentation = doc.mode.indent(state, line.text.slice(curSpaceString.length), line.text); + if (indentation == Pass || indentation > 150) { + if (!aggressive) { return } + how = "prev"; + } + } + if (how == "prev") { + if (n > doc.first) { indentation = countColumn(getLine(doc, n-1).text, null, tabSize); } + else { indentation = 0; } + } else if (how == "add") { + indentation = curSpace + cm.options.indentUnit; + } else if (how == "subtract") { + indentation = curSpace - cm.options.indentUnit; + } else if (typeof how == "number") { + indentation = curSpace + how; + } + indentation = Math.max(0, indentation); + + var indentString = "", pos = 0; + if (cm.options.indentWithTabs) + { for (var i = Math.floor(indentation / tabSize); i; --i) {pos += tabSize; indentString += "\t";} } + if (pos < indentation) { indentString += spaceStr(indentation - pos); } + + if (indentString != curSpaceString) { + replaceRange(doc, indentString, Pos(n, 0), Pos(n, curSpaceString.length), "+input"); + line.stateAfter = null; + return true + } else { + // Ensure that, if the cursor was in the whitespace at the start + // of the line, it is moved to the end of that space. + for (var i$1 = 0; i$1 < doc.sel.ranges.length; i$1++) { + var range = doc.sel.ranges[i$1]; + if (range.head.line == n && range.head.ch < curSpaceString.length) { + var pos$1 = Pos(n, curSpaceString.length); + replaceOneSelection(doc, i$1, new Range(pos$1, pos$1)); + break + } + } + } + } + + // This will be set to a {lineWise: bool, text: [string]} object, so + // that, when pasting, we know what kind of selections the copied + // text was made out of. + var lastCopied = null; + + function setLastCopied(newLastCopied) { + lastCopied = newLastCopied; + } + + function applyTextInput(cm, inserted, deleted, sel, origin) { + var doc = cm.doc; + cm.display.shift = false; + if (!sel) { sel = doc.sel; } + + var recent = +new Date - 200; + var paste = origin == "paste" || cm.state.pasteIncoming > recent; + var textLines = splitLinesAuto(inserted), multiPaste = null; + // When pasting N lines into N selections, insert one line per selection + if (paste && sel.ranges.length > 1) { + if (lastCopied && lastCopied.text.join("\n") == inserted) { + if (sel.ranges.length % lastCopied.text.length == 0) { + multiPaste = []; + for (var i = 0; i < lastCopied.text.length; i++) + { multiPaste.push(doc.splitLines(lastCopied.text[i])); } + } + } else if (textLines.length == sel.ranges.length && cm.options.pasteLinesPerSelection) { + multiPaste = map(textLines, function (l) { return [l]; }); + } + } + + var updateInput = cm.curOp.updateInput; + // Normal behavior is to insert the new text into every selection + for (var i$1 = sel.ranges.length - 1; i$1 >= 0; i$1--) { + var range$$1 = sel.ranges[i$1]; + var from = range$$1.from(), to = range$$1.to(); + if (range$$1.empty()) { + if (deleted && deleted > 0) // Handle deletion + { from = Pos(from.line, from.ch - deleted); } + else if (cm.state.overwrite && !paste) // Handle overwrite + { to = Pos(to.line, Math.min(getLine(doc, to.line).text.length, to.ch + lst(textLines).length)); } + else if (paste && lastCopied && lastCopied.lineWise && lastCopied.text.join("\n") == inserted) + { from = to = Pos(from.line, 0); } + } + var changeEvent = {from: from, to: to, text: multiPaste ? multiPaste[i$1 % multiPaste.length] : textLines, + origin: origin || (paste ? "paste" : cm.state.cutIncoming > recent ? "cut" : "+input")}; + makeChange(cm.doc, changeEvent); + signalLater(cm, "inputRead", cm, changeEvent); + } + if (inserted && !paste) + { triggerElectric(cm, inserted); } + + ensureCursorVisible(cm); + if (cm.curOp.updateInput < 2) { cm.curOp.updateInput = updateInput; } + cm.curOp.typing = true; + cm.state.pasteIncoming = cm.state.cutIncoming = -1; + } + + function handlePaste(e, cm) { + var pasted = e.clipboardData && e.clipboardData.getData("Text"); + if (pasted) { + e.preventDefault(); + if (!cm.isReadOnly() && !cm.options.disableInput) + { runInOp(cm, function () { return applyTextInput(cm, pasted, 0, null, "paste"); }); } + return true + } + } + + function triggerElectric(cm, inserted) { + // When an 'electric' character is inserted, immediately trigger a reindent + if (!cm.options.electricChars || !cm.options.smartIndent) { return } + var sel = cm.doc.sel; + + for (var i = sel.ranges.length - 1; i >= 0; i--) { + var range$$1 = sel.ranges[i]; + if (range$$1.head.ch > 100 || (i && sel.ranges[i - 1].head.line == range$$1.head.line)) { continue } + var mode = cm.getModeAt(range$$1.head); + var indented = false; + if (mode.electricChars) { + for (var j = 0; j < mode.electricChars.length; j++) + { if (inserted.indexOf(mode.electricChars.charAt(j)) > -1) { + indented = indentLine(cm, range$$1.head.line, "smart"); + break + } } + } else if (mode.electricInput) { + if (mode.electricInput.test(getLine(cm.doc, range$$1.head.line).text.slice(0, range$$1.head.ch))) + { indented = indentLine(cm, range$$1.head.line, "smart"); } + } + if (indented) { signalLater(cm, "electricInput", cm, range$$1.head.line); } + } + } + + function copyableRanges(cm) { + var text = [], ranges = []; + for (var i = 0; i < cm.doc.sel.ranges.length; i++) { + var line = cm.doc.sel.ranges[i].head.line; + var lineRange = {anchor: Pos(line, 0), head: Pos(line + 1, 0)}; + ranges.push(lineRange); + text.push(cm.getRange(lineRange.anchor, lineRange.head)); + } + return {text: text, ranges: ranges} + } + + function disableBrowserMagic(field, spellcheck, autocorrect, autocapitalize) { + field.setAttribute("autocorrect", autocorrect ? "" : "off"); + field.setAttribute("autocapitalize", autocapitalize ? "" : "off"); + field.setAttribute("spellcheck", !!spellcheck); + } + + function hiddenTextarea() { + var te = elt("textarea", null, null, "position: absolute; bottom: -1em; padding: 0; width: 1px; height: 1em; outline: none"); + var div = elt("div", [te], null, "overflow: hidden; position: relative; width: 3px; height: 0px;"); + // The textarea is kept positioned near the cursor to prevent the + // fact that it'll be scrolled into view on input from scrolling + // our fake cursor out of view. On webkit, when wrap=off, paste is + // very slow. So make the area wide instead. + if (webkit) { te.style.width = "1000px"; } + else { te.setAttribute("wrap", "off"); } + // If border: 0; -- iOS fails to open keyboard (issue #1287) + if (ios) { te.style.border = "1px solid black"; } + disableBrowserMagic(te); + return div + } + + // The publicly visible API. Note that methodOp(f) means + // 'wrap f in an operation, performed on its `this` parameter'. + + // This is not the complete set of editor methods. Most of the + // methods defined on the Doc type are also injected into + // CodeMirror.prototype, for backwards compatibility and + // convenience. + + function addEditorMethods(CodeMirror) { + var optionHandlers = CodeMirror.optionHandlers; + + var helpers = CodeMirror.helpers = {}; + + CodeMirror.prototype = { + constructor: CodeMirror, + focus: function(){window.focus(); this.display.input.focus();}, + + setOption: function(option, value) { + var options = this.options, old = options[option]; + if (options[option] == value && option != "mode") { return } + options[option] = value; + if (optionHandlers.hasOwnProperty(option)) + { operation(this, optionHandlers[option])(this, value, old); } + signal(this, "optionChange", this, option); + }, + + getOption: function(option) {return this.options[option]}, + getDoc: function() {return this.doc}, + + addKeyMap: function(map$$1, bottom) { + this.state.keyMaps[bottom ? "push" : "unshift"](getKeyMap(map$$1)); + }, + removeKeyMap: function(map$$1) { + var maps = this.state.keyMaps; + for (var i = 0; i < maps.length; ++i) + { if (maps[i] == map$$1 || maps[i].name == map$$1) { + maps.splice(i, 1); + return true + } } + }, + + addOverlay: methodOp(function(spec, options) { + var mode = spec.token ? spec : CodeMirror.getMode(this.options, spec); + if (mode.startState) { throw new Error("Overlays may not be stateful.") } + insertSorted(this.state.overlays, + {mode: mode, modeSpec: spec, opaque: options && options.opaque, + priority: (options && options.priority) || 0}, + function (overlay) { return overlay.priority; }); + this.state.modeGen++; + regChange(this); + }), + removeOverlay: methodOp(function(spec) { + var this$1 = this; + + var overlays = this.state.overlays; + for (var i = 0; i < overlays.length; ++i) { + var cur = overlays[i].modeSpec; + if (cur == spec || typeof spec == "string" && cur.name == spec) { + overlays.splice(i, 1); + this$1.state.modeGen++; + regChange(this$1); + return + } + } + }), + + indentLine: methodOp(function(n, dir, aggressive) { + if (typeof dir != "string" && typeof dir != "number") { + if (dir == null) { dir = this.options.smartIndent ? "smart" : "prev"; } + else { dir = dir ? "add" : "subtract"; } + } + if (isLine(this.doc, n)) { indentLine(this, n, dir, aggressive); } + }), + indentSelection: methodOp(function(how) { + var this$1 = this; + + var ranges = this.doc.sel.ranges, end = -1; + for (var i = 0; i < ranges.length; i++) { + var range$$1 = ranges[i]; + if (!range$$1.empty()) { + var from = range$$1.from(), to = range$$1.to(); + var start = Math.max(end, from.line); + end = Math.min(this$1.lastLine(), to.line - (to.ch ? 0 : 1)) + 1; + for (var j = start; j < end; ++j) + { indentLine(this$1, j, how); } + var newRanges = this$1.doc.sel.ranges; + if (from.ch == 0 && ranges.length == newRanges.length && newRanges[i].from().ch > 0) + { replaceOneSelection(this$1.doc, i, new Range(from, newRanges[i].to()), sel_dontScroll); } + } else if (range$$1.head.line > end) { + indentLine(this$1, range$$1.head.line, how, true); + end = range$$1.head.line; + if (i == this$1.doc.sel.primIndex) { ensureCursorVisible(this$1); } + } + } + }), + + // Fetch the parser token for a given character. Useful for hacks + // that want to inspect the mode state (say, for completion). + getTokenAt: function(pos, precise) { + return takeToken(this, pos, precise) + }, + + getLineTokens: function(line, precise) { + return takeToken(this, Pos(line), precise, true) + }, + + getTokenTypeAt: function(pos) { + pos = clipPos(this.doc, pos); + var styles = getLineStyles(this, getLine(this.doc, pos.line)); + var before = 0, after = (styles.length - 1) / 2, ch = pos.ch; + var type; + if (ch == 0) { type = styles[2]; } + else { for (;;) { + var mid = (before + after) >> 1; + if ((mid ? styles[mid * 2 - 1] : 0) >= ch) { after = mid; } + else if (styles[mid * 2 + 1] < ch) { before = mid + 1; } + else { type = styles[mid * 2 + 2]; break } + } } + var cut = type ? type.indexOf("overlay ") : -1; + return cut < 0 ? type : cut == 0 ? null : type.slice(0, cut - 1) + }, + + getModeAt: function(pos) { + var mode = this.doc.mode; + if (!mode.innerMode) { return mode } + return CodeMirror.innerMode(mode, this.getTokenAt(pos).state).mode + }, + + getHelper: function(pos, type) { + return this.getHelpers(pos, type)[0] + }, + + getHelpers: function(pos, type) { + var this$1 = this; + + var found = []; + if (!helpers.hasOwnProperty(type)) { return found } + var help = helpers[type], mode = this.getModeAt(pos); + if (typeof mode[type] == "string") { + if (help[mode[type]]) { found.push(help[mode[type]]); } + } else if (mode[type]) { + for (var i = 0; i < mode[type].length; i++) { + var val = help[mode[type][i]]; + if (val) { found.push(val); } + } + } else if (mode.helperType && help[mode.helperType]) { + found.push(help[mode.helperType]); + } else if (help[mode.name]) { + found.push(help[mode.name]); + } + for (var i$1 = 0; i$1 < help._global.length; i$1++) { + var cur = help._global[i$1]; + if (cur.pred(mode, this$1) && indexOf(found, cur.val) == -1) + { found.push(cur.val); } + } + return found + }, + + getStateAfter: function(line, precise) { + var doc = this.doc; + line = clipLine(doc, line == null ? doc.first + doc.size - 1: line); + return getContextBefore(this, line + 1, precise).state + }, + + cursorCoords: function(start, mode) { + var pos, range$$1 = this.doc.sel.primary(); + if (start == null) { pos = range$$1.head; } + else if (typeof start == "object") { pos = clipPos(this.doc, start); } + else { pos = start ? range$$1.from() : range$$1.to(); } + return cursorCoords(this, pos, mode || "page") + }, + + charCoords: function(pos, mode) { + return charCoords(this, clipPos(this.doc, pos), mode || "page") + }, + + coordsChar: function(coords, mode) { + coords = fromCoordSystem(this, coords, mode || "page"); + return coordsChar(this, coords.left, coords.top) + }, + + lineAtHeight: function(height, mode) { + height = fromCoordSystem(this, {top: height, left: 0}, mode || "page").top; + return lineAtHeight(this.doc, height + this.display.viewOffset) + }, + heightAtLine: function(line, mode, includeWidgets) { + var end = false, lineObj; + if (typeof line == "number") { + var last = this.doc.first + this.doc.size - 1; + if (line < this.doc.first) { line = this.doc.first; } + else if (line > last) { line = last; end = true; } + lineObj = getLine(this.doc, line); + } else { + lineObj = line; + } + return intoCoordSystem(this, lineObj, {top: 0, left: 0}, mode || "page", includeWidgets || end).top + + (end ? this.doc.height - heightAtLine(lineObj) : 0) + }, + + defaultTextHeight: function() { return textHeight(this.display) }, + defaultCharWidth: function() { return charWidth(this.display) }, + + getViewport: function() { return {from: this.display.viewFrom, to: this.display.viewTo}}, + + addWidget: function(pos, node, scroll, vert, horiz) { + var display = this.display; + pos = cursorCoords(this, clipPos(this.doc, pos)); + var top = pos.bottom, left = pos.left; + node.style.position = "absolute"; + node.setAttribute("cm-ignore-events", "true"); + this.display.input.setUneditable(node); + display.sizer.appendChild(node); + if (vert == "over") { + top = pos.top; + } else if (vert == "above" || vert == "near") { + var vspace = Math.max(display.wrapper.clientHeight, this.doc.height), + hspace = Math.max(display.sizer.clientWidth, display.lineSpace.clientWidth); + // Default to positioning above (if specified and possible); otherwise default to positioning below + if ((vert == 'above' || pos.bottom + node.offsetHeight > vspace) && pos.top > node.offsetHeight) + { top = pos.top - node.offsetHeight; } + else if (pos.bottom + node.offsetHeight <= vspace) + { top = pos.bottom; } + if (left + node.offsetWidth > hspace) + { left = hspace - node.offsetWidth; } + } + node.style.top = top + "px"; + node.style.left = node.style.right = ""; + if (horiz == "right") { + left = display.sizer.clientWidth - node.offsetWidth; + node.style.right = "0px"; + } else { + if (horiz == "left") { left = 0; } + else if (horiz == "middle") { left = (display.sizer.clientWidth - node.offsetWidth) / 2; } + node.style.left = left + "px"; + } + if (scroll) + { scrollIntoView(this, {left: left, top: top, right: left + node.offsetWidth, bottom: top + node.offsetHeight}); } + }, + + triggerOnKeyDown: methodOp(onKeyDown), + triggerOnKeyPress: methodOp(onKeyPress), + triggerOnKeyUp: onKeyUp, + triggerOnMouseDown: methodOp(onMouseDown), + + execCommand: function(cmd) { + if (commands.hasOwnProperty(cmd)) + { return commands[cmd].call(null, this) } + }, + + triggerElectric: methodOp(function(text) { triggerElectric(this, text); }), + + findPosH: function(from, amount, unit, visually) { + var this$1 = this; + + var dir = 1; + if (amount < 0) { dir = -1; amount = -amount; } + var cur = clipPos(this.doc, from); + for (var i = 0; i < amount; ++i) { + cur = findPosH(this$1.doc, cur, dir, unit, visually); + if (cur.hitSide) { break } + } + return cur + }, + + moveH: methodOp(function(dir, unit) { + var this$1 = this; + + this.extendSelectionsBy(function (range$$1) { + if (this$1.display.shift || this$1.doc.extend || range$$1.empty()) + { return findPosH(this$1.doc, range$$1.head, dir, unit, this$1.options.rtlMoveVisually) } + else + { return dir < 0 ? range$$1.from() : range$$1.to() } + }, sel_move); + }), + + deleteH: methodOp(function(dir, unit) { + var sel = this.doc.sel, doc = this.doc; + if (sel.somethingSelected()) + { doc.replaceSelection("", null, "+delete"); } + else + { deleteNearSelection(this, function (range$$1) { + var other = findPosH(doc, range$$1.head, dir, unit, false); + return dir < 0 ? {from: other, to: range$$1.head} : {from: range$$1.head, to: other} + }); } + }), + + findPosV: function(from, amount, unit, goalColumn) { + var this$1 = this; + + var dir = 1, x = goalColumn; + if (amount < 0) { dir = -1; amount = -amount; } + var cur = clipPos(this.doc, from); + for (var i = 0; i < amount; ++i) { + var coords = cursorCoords(this$1, cur, "div"); + if (x == null) { x = coords.left; } + else { coords.left = x; } + cur = findPosV(this$1, coords, dir, unit); + if (cur.hitSide) { break } + } + return cur + }, + + moveV: methodOp(function(dir, unit) { + var this$1 = this; + + var doc = this.doc, goals = []; + var collapse = !this.display.shift && !doc.extend && doc.sel.somethingSelected(); + doc.extendSelectionsBy(function (range$$1) { + if (collapse) + { return dir < 0 ? range$$1.from() : range$$1.to() } + var headPos = cursorCoords(this$1, range$$1.head, "div"); + if (range$$1.goalColumn != null) { headPos.left = range$$1.goalColumn; } + goals.push(headPos.left); + var pos = findPosV(this$1, headPos, dir, unit); + if (unit == "page" && range$$1 == doc.sel.primary()) + { addToScrollTop(this$1, charCoords(this$1, pos, "div").top - headPos.top); } + return pos + }, sel_move); + if (goals.length) { for (var i = 0; i < doc.sel.ranges.length; i++) + { doc.sel.ranges[i].goalColumn = goals[i]; } } + }), + + // Find the word at the given position (as returned by coordsChar). + findWordAt: function(pos) { + var doc = this.doc, line = getLine(doc, pos.line).text; + var start = pos.ch, end = pos.ch; + if (line) { + var helper = this.getHelper(pos, "wordChars"); + if ((pos.sticky == "before" || end == line.length) && start) { --start; } else { ++end; } + var startChar = line.charAt(start); + var check = isWordChar(startChar, helper) + ? function (ch) { return isWordChar(ch, helper); } + : /\s/.test(startChar) ? function (ch) { return /\s/.test(ch); } + : function (ch) { return (!/\s/.test(ch) && !isWordChar(ch)); }; + while (start > 0 && check(line.charAt(start - 1))) { --start; } + while (end < line.length && check(line.charAt(end))) { ++end; } + } + return new Range(Pos(pos.line, start), Pos(pos.line, end)) + }, + + toggleOverwrite: function(value) { + if (value != null && value == this.state.overwrite) { return } + if (this.state.overwrite = !this.state.overwrite) + { addClass(this.display.cursorDiv, "CodeMirror-overwrite"); } + else + { rmClass(this.display.cursorDiv, "CodeMirror-overwrite"); } + + signal(this, "overwriteToggle", this, this.state.overwrite); + }, + hasFocus: function() { return this.display.input.getField() == activeElt() }, + isReadOnly: function() { return !!(this.options.readOnly || this.doc.cantEdit) }, + + scrollTo: methodOp(function (x, y) { scrollToCoords(this, x, y); }), + getScrollInfo: function() { + var scroller = this.display.scroller; + return {left: scroller.scrollLeft, top: scroller.scrollTop, + height: scroller.scrollHeight - scrollGap(this) - this.display.barHeight, + width: scroller.scrollWidth - scrollGap(this) - this.display.barWidth, + clientHeight: displayHeight(this), clientWidth: displayWidth(this)} + }, + + scrollIntoView: methodOp(function(range$$1, margin) { + if (range$$1 == null) { + range$$1 = {from: this.doc.sel.primary().head, to: null}; + if (margin == null) { margin = this.options.cursorScrollMargin; } + } else if (typeof range$$1 == "number") { + range$$1 = {from: Pos(range$$1, 0), to: null}; + } else if (range$$1.from == null) { + range$$1 = {from: range$$1, to: null}; + } + if (!range$$1.to) { range$$1.to = range$$1.from; } + range$$1.margin = margin || 0; + + if (range$$1.from.line != null) { + scrollToRange(this, range$$1); + } else { + scrollToCoordsRange(this, range$$1.from, range$$1.to, range$$1.margin); + } + }), + + setSize: methodOp(function(width, height) { + var this$1 = this; + + var interpret = function (val) { return typeof val == "number" || /^\d+$/.test(String(val)) ? val + "px" : val; }; + if (width != null) { this.display.wrapper.style.width = interpret(width); } + if (height != null) { this.display.wrapper.style.height = interpret(height); } + if (this.options.lineWrapping) { clearLineMeasurementCache(this); } + var lineNo$$1 = this.display.viewFrom; + this.doc.iter(lineNo$$1, this.display.viewTo, function (line) { + if (line.widgets) { for (var i = 0; i < line.widgets.length; i++) + { if (line.widgets[i].noHScroll) { regLineChange(this$1, lineNo$$1, "widget"); break } } } + ++lineNo$$1; + }); + this.curOp.forceUpdate = true; + signal(this, "refresh", this); + }), + + operation: function(f){return runInOp(this, f)}, + startOperation: function(){return startOperation(this)}, + endOperation: function(){return endOperation(this)}, + + refresh: methodOp(function() { + var oldHeight = this.display.cachedTextHeight; + regChange(this); + this.curOp.forceUpdate = true; + clearCaches(this); + scrollToCoords(this, this.doc.scrollLeft, this.doc.scrollTop); + updateGutterSpace(this.display); + if (oldHeight == null || Math.abs(oldHeight - textHeight(this.display)) > .5) + { estimateLineHeights(this); } + signal(this, "refresh", this); + }), + + swapDoc: methodOp(function(doc) { + var old = this.doc; + old.cm = null; + // Cancel the current text selection if any (#5821) + if (this.state.selectingText) { this.state.selectingText(); } + attachDoc(this, doc); + clearCaches(this); + this.display.input.reset(); + scrollToCoords(this, doc.scrollLeft, doc.scrollTop); + this.curOp.forceScroll = true; + signalLater(this, "swapDoc", this, old); + return old + }), + + phrase: function(phraseText) { + var phrases = this.options.phrases; + return phrases && Object.prototype.hasOwnProperty.call(phrases, phraseText) ? phrases[phraseText] : phraseText + }, + + getInputField: function(){return this.display.input.getField()}, + getWrapperElement: function(){return this.display.wrapper}, + getScrollerElement: function(){return this.display.scroller}, + getGutterElement: function(){return this.display.gutters} + }; + eventMixin(CodeMirror); + + CodeMirror.registerHelper = function(type, name, value) { + if (!helpers.hasOwnProperty(type)) { helpers[type] = CodeMirror[type] = {_global: []}; } + helpers[type][name] = value; + }; + CodeMirror.registerGlobalHelper = function(type, name, predicate, value) { + CodeMirror.registerHelper(type, name, value); + helpers[type]._global.push({pred: predicate, val: value}); + }; + } + + // Used for horizontal relative motion. Dir is -1 or 1 (left or + // right), unit can be "char", "column" (like char, but doesn't + // cross line boundaries), "word" (across next word), or "group" (to + // the start of next group of word or non-word-non-whitespace + // chars). The visually param controls whether, in right-to-left + // text, direction 1 means to move towards the next index in the + // string, or towards the character to the right of the current + // position. The resulting position will have a hitSide=true + // property if it reached the end of the document. + function findPosH(doc, pos, dir, unit, visually) { + var oldPos = pos; + var origDir = dir; + var lineObj = getLine(doc, pos.line); + var lineDir = visually && doc.cm && doc.cm.getOption("direction") == "rtl" ? -dir : dir; + function findNextLine() { + var l = pos.line + lineDir; + if (l < doc.first || l >= doc.first + doc.size) { return false } + pos = new Pos(l, pos.ch, pos.sticky); + return lineObj = getLine(doc, l) + } + function moveOnce(boundToLine) { + var next; + if (visually) { + next = moveVisually(doc.cm, lineObj, pos, dir); + } else { + next = moveLogically(lineObj, pos, dir); + } + if (next == null) { + if (!boundToLine && findNextLine()) + { pos = endOfLine(visually, doc.cm, lineObj, pos.line, lineDir); } + else + { return false } + } else { + pos = next; + } + return true + } + + if (unit == "char") { + moveOnce(); + } else if (unit == "column") { + moveOnce(true); + } else if (unit == "word" || unit == "group") { + var sawType = null, group = unit == "group"; + var helper = doc.cm && doc.cm.getHelper(pos, "wordChars"); + for (var first = true;; first = false) { + if (dir < 0 && !moveOnce(!first)) { break } + var cur = lineObj.text.charAt(pos.ch) || "\n"; + var type = isWordChar(cur, helper) ? "w" + : group && cur == "\n" ? "n" + : !group || /\s/.test(cur) ? null + : "p"; + if (group && !first && !type) { type = "s"; } + if (sawType && sawType != type) { + if (dir < 0) {dir = 1; moveOnce(); pos.sticky = "after";} + break + } + + if (type) { sawType = type; } + if (dir > 0 && !moveOnce(!first)) { break } + } + } + var result = skipAtomic(doc, pos, oldPos, origDir, true); + if (equalCursorPos(oldPos, result)) { result.hitSide = true; } + return result + } + + // For relative vertical movement. Dir may be -1 or 1. Unit can be + // "page" or "line". The resulting position will have a hitSide=true + // property if it reached the end of the document. + function findPosV(cm, pos, dir, unit) { + var doc = cm.doc, x = pos.left, y; + if (unit == "page") { + var pageSize = Math.min(cm.display.wrapper.clientHeight, window.innerHeight || document.documentElement.clientHeight); + var moveAmount = Math.max(pageSize - .5 * textHeight(cm.display), 3); + y = (dir > 0 ? pos.bottom : pos.top) + dir * moveAmount; + + } else if (unit == "line") { + y = dir > 0 ? pos.bottom + 3 : pos.top - 3; + } + var target; + for (;;) { + target = coordsChar(cm, x, y); + if (!target.outside) { break } + if (dir < 0 ? y <= 0 : y >= doc.height) { target.hitSide = true; break } + y += dir * 5; + } + return target + } + + // CONTENTEDITABLE INPUT STYLE + + var ContentEditableInput = function(cm) { + this.cm = cm; + this.lastAnchorNode = this.lastAnchorOffset = this.lastFocusNode = this.lastFocusOffset = null; + this.polling = new Delayed(); + this.composing = null; + this.gracePeriod = false; + this.readDOMTimeout = null; + }; + + ContentEditableInput.prototype.init = function (display) { + var this$1 = this; + + var input = this, cm = input.cm; + var div = input.div = display.lineDiv; + disableBrowserMagic(div, cm.options.spellcheck, cm.options.autocorrect, cm.options.autocapitalize); + + on(div, "paste", function (e) { + if (signalDOMEvent(cm, e) || handlePaste(e, cm)) { return } + // IE doesn't fire input events, so we schedule a read for the pasted content in this way + if (ie_version <= 11) { setTimeout(operation(cm, function () { return this$1.updateFromDOM(); }), 20); } + }); + + on(div, "compositionstart", function (e) { + this$1.composing = {data: e.data, done: false}; + }); + on(div, "compositionupdate", function (e) { + if (!this$1.composing) { this$1.composing = {data: e.data, done: false}; } + }); + on(div, "compositionend", function (e) { + if (this$1.composing) { + if (e.data != this$1.composing.data) { this$1.readFromDOMSoon(); } + this$1.composing.done = true; + } + }); + + on(div, "touchstart", function () { return input.forceCompositionEnd(); }); + + on(div, "input", function () { + if (!this$1.composing) { this$1.readFromDOMSoon(); } + }); + + function onCopyCut(e) { + if (signalDOMEvent(cm, e)) { return } + if (cm.somethingSelected()) { + setLastCopied({lineWise: false, text: cm.getSelections()}); + if (e.type == "cut") { cm.replaceSelection("", null, "cut"); } + } else if (!cm.options.lineWiseCopyCut) { + return + } else { + var ranges = copyableRanges(cm); + setLastCopied({lineWise: true, text: ranges.text}); + if (e.type == "cut") { + cm.operation(function () { + cm.setSelections(ranges.ranges, 0, sel_dontScroll); + cm.replaceSelection("", null, "cut"); + }); + } + } + if (e.clipboardData) { + e.clipboardData.clearData(); + var content = lastCopied.text.join("\n"); + // iOS exposes the clipboard API, but seems to discard content inserted into it + e.clipboardData.setData("Text", content); + if (e.clipboardData.getData("Text") == content) { + e.preventDefault(); + return + } + } + // Old-fashioned briefly-focus-a-textarea hack + var kludge = hiddenTextarea(), te = kludge.firstChild; + cm.display.lineSpace.insertBefore(kludge, cm.display.lineSpace.firstChild); + te.value = lastCopied.text.join("\n"); + var hadFocus = document.activeElement; + selectInput(te); + setTimeout(function () { + cm.display.lineSpace.removeChild(kludge); + hadFocus.focus(); + if (hadFocus == div) { input.showPrimarySelection(); } + }, 50); + } + on(div, "copy", onCopyCut); + on(div, "cut", onCopyCut); + }; + + ContentEditableInput.prototype.prepareSelection = function () { + var result = prepareSelection(this.cm, false); + result.focus = this.cm.state.focused; + return result + }; + + ContentEditableInput.prototype.showSelection = function (info, takeFocus) { + if (!info || !this.cm.display.view.length) { return } + if (info.focus || takeFocus) { this.showPrimarySelection(); } + this.showMultipleSelections(info); + }; + + ContentEditableInput.prototype.getSelection = function () { + return this.cm.display.wrapper.ownerDocument.getSelection() + }; + + ContentEditableInput.prototype.showPrimarySelection = function () { + var sel = this.getSelection(), cm = this.cm, prim = cm.doc.sel.primary(); + var from = prim.from(), to = prim.to(); + + if (cm.display.viewTo == cm.display.viewFrom || from.line >= cm.display.viewTo || to.line < cm.display.viewFrom) { + sel.removeAllRanges(); + return + } + + var curAnchor = domToPos(cm, sel.anchorNode, sel.anchorOffset); + var curFocus = domToPos(cm, sel.focusNode, sel.focusOffset); + if (curAnchor && !curAnchor.bad && curFocus && !curFocus.bad && + cmp(minPos(curAnchor, curFocus), from) == 0 && + cmp(maxPos(curAnchor, curFocus), to) == 0) + { return } + + var view = cm.display.view; + var start = (from.line >= cm.display.viewFrom && posToDOM(cm, from)) || + {node: view[0].measure.map[2], offset: 0}; + var end = to.line < cm.display.viewTo && posToDOM(cm, to); + if (!end) { + var measure = view[view.length - 1].measure; + var map$$1 = measure.maps ? measure.maps[measure.maps.length - 1] : measure.map; + end = {node: map$$1[map$$1.length - 1], offset: map$$1[map$$1.length - 2] - map$$1[map$$1.length - 3]}; + } + + if (!start || !end) { + sel.removeAllRanges(); + return + } + + var old = sel.rangeCount && sel.getRangeAt(0), rng; + try { rng = range(start.node, start.offset, end.offset, end.node); } + catch(e) {} // Our model of the DOM might be outdated, in which case the range we try to set can be impossible + if (rng) { + if (!gecko && cm.state.focused) { + sel.collapse(start.node, start.offset); + if (!rng.collapsed) { + sel.removeAllRanges(); + sel.addRange(rng); + } + } else { + sel.removeAllRanges(); + sel.addRange(rng); + } + if (old && sel.anchorNode == null) { sel.addRange(old); } + else if (gecko) { this.startGracePeriod(); } + } + this.rememberSelection(); + }; + + ContentEditableInput.prototype.startGracePeriod = function () { + var this$1 = this; + + clearTimeout(this.gracePeriod); + this.gracePeriod = setTimeout(function () { + this$1.gracePeriod = false; + if (this$1.selectionChanged()) + { this$1.cm.operation(function () { return this$1.cm.curOp.selectionChanged = true; }); } + }, 20); + }; + + ContentEditableInput.prototype.showMultipleSelections = function (info) { + removeChildrenAndAdd(this.cm.display.cursorDiv, info.cursors); + removeChildrenAndAdd(this.cm.display.selectionDiv, info.selection); + }; + + ContentEditableInput.prototype.rememberSelection = function () { + var sel = this.getSelection(); + this.lastAnchorNode = sel.anchorNode; this.lastAnchorOffset = sel.anchorOffset; + this.lastFocusNode = sel.focusNode; this.lastFocusOffset = sel.focusOffset; + }; + + ContentEditableInput.prototype.selectionInEditor = function () { + var sel = this.getSelection(); + if (!sel.rangeCount) { return false } + var node = sel.getRangeAt(0).commonAncestorContainer; + return contains(this.div, node) + }; + + ContentEditableInput.prototype.focus = function () { + if (this.cm.options.readOnly != "nocursor") { + if (!this.selectionInEditor()) + { this.showSelection(this.prepareSelection(), true); } + this.div.focus(); + } + }; + ContentEditableInput.prototype.blur = function () { this.div.blur(); }; + ContentEditableInput.prototype.getField = function () { return this.div }; + + ContentEditableInput.prototype.supportsTouch = function () { return true }; + + ContentEditableInput.prototype.receivedFocus = function () { + var input = this; + if (this.selectionInEditor()) + { this.pollSelection(); } + else + { runInOp(this.cm, function () { return input.cm.curOp.selectionChanged = true; }); } + + function poll() { + if (input.cm.state.focused) { + input.pollSelection(); + input.polling.set(input.cm.options.pollInterval, poll); + } + } + this.polling.set(this.cm.options.pollInterval, poll); + }; + + ContentEditableInput.prototype.selectionChanged = function () { + var sel = this.getSelection(); + return sel.anchorNode != this.lastAnchorNode || sel.anchorOffset != this.lastAnchorOffset || + sel.focusNode != this.lastFocusNode || sel.focusOffset != this.lastFocusOffset + }; + + ContentEditableInput.prototype.pollSelection = function () { + if (this.readDOMTimeout != null || this.gracePeriod || !this.selectionChanged()) { return } + var sel = this.getSelection(), cm = this.cm; + // On Android Chrome (version 56, at least), backspacing into an + // uneditable block element will put the cursor in that element, + // and then, because it's not editable, hide the virtual keyboard. + // Because Android doesn't allow us to actually detect backspace + // presses in a sane way, this code checks for when that happens + // and simulates a backspace press in this case. + if (android && chrome && this.cm.display.gutterSpecs.length && isInGutter(sel.anchorNode)) { + this.cm.triggerOnKeyDown({type: "keydown", keyCode: 8, preventDefault: Math.abs}); + this.blur(); + this.focus(); + return + } + if (this.composing) { return } + this.rememberSelection(); + var anchor = domToPos(cm, sel.anchorNode, sel.anchorOffset); + var head = domToPos(cm, sel.focusNode, sel.focusOffset); + if (anchor && head) { runInOp(cm, function () { + setSelection(cm.doc, simpleSelection(anchor, head), sel_dontScroll); + if (anchor.bad || head.bad) { cm.curOp.selectionChanged = true; } + }); } + }; + + ContentEditableInput.prototype.pollContent = function () { + if (this.readDOMTimeout != null) { + clearTimeout(this.readDOMTimeout); + this.readDOMTimeout = null; + } + + var cm = this.cm, display = cm.display, sel = cm.doc.sel.primary(); + var from = sel.from(), to = sel.to(); + if (from.ch == 0 && from.line > cm.firstLine()) + { from = Pos(from.line - 1, getLine(cm.doc, from.line - 1).length); } + if (to.ch == getLine(cm.doc, to.line).text.length && to.line < cm.lastLine()) + { to = Pos(to.line + 1, 0); } + if (from.line < display.viewFrom || to.line > display.viewTo - 1) { return false } + + var fromIndex, fromLine, fromNode; + if (from.line == display.viewFrom || (fromIndex = findViewIndex(cm, from.line)) == 0) { + fromLine = lineNo(display.view[0].line); + fromNode = display.view[0].node; + } else { + fromLine = lineNo(display.view[fromIndex].line); + fromNode = display.view[fromIndex - 1].node.nextSibling; + } + var toIndex = findViewIndex(cm, to.line); + var toLine, toNode; + if (toIndex == display.view.length - 1) { + toLine = display.viewTo - 1; + toNode = display.lineDiv.lastChild; + } else { + toLine = lineNo(display.view[toIndex + 1].line) - 1; + toNode = display.view[toIndex + 1].node.previousSibling; + } + + if (!fromNode) { return false } + var newText = cm.doc.splitLines(domTextBetween(cm, fromNode, toNode, fromLine, toLine)); + var oldText = getBetween(cm.doc, Pos(fromLine, 0), Pos(toLine, getLine(cm.doc, toLine).text.length)); + while (newText.length > 1 && oldText.length > 1) { + if (lst(newText) == lst(oldText)) { newText.pop(); oldText.pop(); toLine--; } + else if (newText[0] == oldText[0]) { newText.shift(); oldText.shift(); fromLine++; } + else { break } + } + + var cutFront = 0, cutEnd = 0; + var newTop = newText[0], oldTop = oldText[0], maxCutFront = Math.min(newTop.length, oldTop.length); + while (cutFront < maxCutFront && newTop.charCodeAt(cutFront) == oldTop.charCodeAt(cutFront)) + { ++cutFront; } + var newBot = lst(newText), oldBot = lst(oldText); + var maxCutEnd = Math.min(newBot.length - (newText.length == 1 ? cutFront : 0), + oldBot.length - (oldText.length == 1 ? cutFront : 0)); + while (cutEnd < maxCutEnd && + newBot.charCodeAt(newBot.length - cutEnd - 1) == oldBot.charCodeAt(oldBot.length - cutEnd - 1)) + { ++cutEnd; } + // Try to move start of change to start of selection if ambiguous + if (newText.length == 1 && oldText.length == 1 && fromLine == from.line) { + while (cutFront && cutFront > from.ch && + newBot.charCodeAt(newBot.length - cutEnd - 1) == oldBot.charCodeAt(oldBot.length - cutEnd - 1)) { + cutFront--; + cutEnd++; + } + } + + newText[newText.length - 1] = newBot.slice(0, newBot.length - cutEnd).replace(/^\u200b+/, ""); + newText[0] = newText[0].slice(cutFront).replace(/\u200b+$/, ""); + + var chFrom = Pos(fromLine, cutFront); + var chTo = Pos(toLine, oldText.length ? lst(oldText).length - cutEnd : 0); + if (newText.length > 1 || newText[0] || cmp(chFrom, chTo)) { + replaceRange(cm.doc, newText, chFrom, chTo, "+input"); + return true + } + }; + + ContentEditableInput.prototype.ensurePolled = function () { + this.forceCompositionEnd(); + }; + ContentEditableInput.prototype.reset = function () { + this.forceCompositionEnd(); + }; + ContentEditableInput.prototype.forceCompositionEnd = function () { + if (!this.composing) { return } + clearTimeout(this.readDOMTimeout); + this.composing = null; + this.updateFromDOM(); + this.div.blur(); + this.div.focus(); + }; + ContentEditableInput.prototype.readFromDOMSoon = function () { + var this$1 = this; + + if (this.readDOMTimeout != null) { return } + this.readDOMTimeout = setTimeout(function () { + this$1.readDOMTimeout = null; + if (this$1.composing) { + if (this$1.composing.done) { this$1.composing = null; } + else { return } + } + this$1.updateFromDOM(); + }, 80); + }; + + ContentEditableInput.prototype.updateFromDOM = function () { + var this$1 = this; + + if (this.cm.isReadOnly() || !this.pollContent()) + { runInOp(this.cm, function () { return regChange(this$1.cm); }); } + }; + + ContentEditableInput.prototype.setUneditable = function (node) { + node.contentEditable = "false"; + }; + + ContentEditableInput.prototype.onKeyPress = function (e) { + if (e.charCode == 0 || this.composing) { return } + e.preventDefault(); + if (!this.cm.isReadOnly()) + { operation(this.cm, applyTextInput)(this.cm, String.fromCharCode(e.charCode == null ? e.keyCode : e.charCode), 0); } + }; + + ContentEditableInput.prototype.readOnlyChanged = function (val) { + this.div.contentEditable = String(val != "nocursor"); + }; + + ContentEditableInput.prototype.onContextMenu = function () {}; + ContentEditableInput.prototype.resetPosition = function () {}; + + ContentEditableInput.prototype.needsContentAttribute = true; + + function posToDOM(cm, pos) { + var view = findViewForLine(cm, pos.line); + if (!view || view.hidden) { return null } + var line = getLine(cm.doc, pos.line); + var info = mapFromLineView(view, line, pos.line); + + var order = getOrder(line, cm.doc.direction), side = "left"; + if (order) { + var partPos = getBidiPartAt(order, pos.ch); + side = partPos % 2 ? "right" : "left"; + } + var result = nodeAndOffsetInLineMap(info.map, pos.ch, side); + result.offset = result.collapse == "right" ? result.end : result.start; + return result + } + + function isInGutter(node) { + for (var scan = node; scan; scan = scan.parentNode) + { if (/CodeMirror-gutter-wrapper/.test(scan.className)) { return true } } + return false + } + + function badPos(pos, bad) { if (bad) { pos.bad = true; } return pos } + + function domTextBetween(cm, from, to, fromLine, toLine) { + var text = "", closing = false, lineSep = cm.doc.lineSeparator(), extraLinebreak = false; + function recognizeMarker(id) { return function (marker) { return marker.id == id; } } + function close() { + if (closing) { + text += lineSep; + if (extraLinebreak) { text += lineSep; } + closing = extraLinebreak = false; + } + } + function addText(str) { + if (str) { + close(); + text += str; + } + } + function walk(node) { + if (node.nodeType == 1) { + var cmText = node.getAttribute("cm-text"); + if (cmText) { + addText(cmText); + return + } + var markerID = node.getAttribute("cm-marker"), range$$1; + if (markerID) { + var found = cm.findMarks(Pos(fromLine, 0), Pos(toLine + 1, 0), recognizeMarker(+markerID)); + if (found.length && (range$$1 = found[0].find(0))) + { addText(getBetween(cm.doc, range$$1.from, range$$1.to).join(lineSep)); } + return + } + if (node.getAttribute("contenteditable") == "false") { return } + var isBlock = /^(pre|div|p|li|table|br)$/i.test(node.nodeName); + if (!/^br$/i.test(node.nodeName) && node.textContent.length == 0) { return } + + if (isBlock) { close(); } + for (var i = 0; i < node.childNodes.length; i++) + { walk(node.childNodes[i]); } + + if (/^(pre|p)$/i.test(node.nodeName)) { extraLinebreak = true; } + if (isBlock) { closing = true; } + } else if (node.nodeType == 3) { + addText(node.nodeValue.replace(/\u200b/g, "").replace(/\u00a0/g, " ")); + } + } + for (;;) { + walk(from); + if (from == to) { break } + from = from.nextSibling; + extraLinebreak = false; + } + return text + } + + function domToPos(cm, node, offset) { + var lineNode; + if (node == cm.display.lineDiv) { + lineNode = cm.display.lineDiv.childNodes[offset]; + if (!lineNode) { return badPos(cm.clipPos(Pos(cm.display.viewTo - 1)), true) } + node = null; offset = 0; + } else { + for (lineNode = node;; lineNode = lineNode.parentNode) { + if (!lineNode || lineNode == cm.display.lineDiv) { return null } + if (lineNode.parentNode && lineNode.parentNode == cm.display.lineDiv) { break } + } + } + for (var i = 0; i < cm.display.view.length; i++) { + var lineView = cm.display.view[i]; + if (lineView.node == lineNode) + { return locateNodeInLineView(lineView, node, offset) } + } + } + + function locateNodeInLineView(lineView, node, offset) { + var wrapper = lineView.text.firstChild, bad = false; + if (!node || !contains(wrapper, node)) { return badPos(Pos(lineNo(lineView.line), 0), true) } + if (node == wrapper) { + bad = true; + node = wrapper.childNodes[offset]; + offset = 0; + if (!node) { + var line = lineView.rest ? lst(lineView.rest) : lineView.line; + return badPos(Pos(lineNo(line), line.text.length), bad) + } + } + + var textNode = node.nodeType == 3 ? node : null, topNode = node; + if (!textNode && node.childNodes.length == 1 && node.firstChild.nodeType == 3) { + textNode = node.firstChild; + if (offset) { offset = textNode.nodeValue.length; } + } + while (topNode.parentNode != wrapper) { topNode = topNode.parentNode; } + var measure = lineView.measure, maps = measure.maps; + + function find(textNode, topNode, offset) { + for (var i = -1; i < (maps ? maps.length : 0); i++) { + var map$$1 = i < 0 ? measure.map : maps[i]; + for (var j = 0; j < map$$1.length; j += 3) { + var curNode = map$$1[j + 2]; + if (curNode == textNode || curNode == topNode) { + var line = lineNo(i < 0 ? lineView.line : lineView.rest[i]); + var ch = map$$1[j] + offset; + if (offset < 0 || curNode != textNode) { ch = map$$1[j + (offset ? 1 : 0)]; } + return Pos(line, ch) + } + } + } + } + var found = find(textNode, topNode, offset); + if (found) { return badPos(found, bad) } + + // FIXME this is all really shaky. might handle the few cases it needs to handle, but likely to cause problems + for (var after = topNode.nextSibling, dist = textNode ? textNode.nodeValue.length - offset : 0; after; after = after.nextSibling) { + found = find(after, after.firstChild, 0); + if (found) + { return badPos(Pos(found.line, found.ch - dist), bad) } + else + { dist += after.textContent.length; } + } + for (var before = topNode.previousSibling, dist$1 = offset; before; before = before.previousSibling) { + found = find(before, before.firstChild, -1); + if (found) + { return badPos(Pos(found.line, found.ch + dist$1), bad) } + else + { dist$1 += before.textContent.length; } + } + } + + // TEXTAREA INPUT STYLE + + var TextareaInput = function(cm) { + this.cm = cm; + // See input.poll and input.reset + this.prevInput = ""; + + // Flag that indicates whether we expect input to appear real soon + // now (after some event like 'keypress' or 'input') and are + // polling intensively. + this.pollingFast = false; + // Self-resetting timeout for the poller + this.polling = new Delayed(); + // Used to work around IE issue with selection being forgotten when focus moves away from textarea + this.hasSelection = false; + this.composing = null; + }; + + TextareaInput.prototype.init = function (display) { + var this$1 = this; + + var input = this, cm = this.cm; + this.createField(display); + var te = this.textarea; + + display.wrapper.insertBefore(this.wrapper, display.wrapper.firstChild); + + // Needed to hide big blue blinking cursor on Mobile Safari (doesn't seem to work in iOS 8 anymore) + if (ios) { te.style.width = "0px"; } + + on(te, "input", function () { + if (ie && ie_version >= 9 && this$1.hasSelection) { this$1.hasSelection = null; } + input.poll(); + }); + + on(te, "paste", function (e) { + if (signalDOMEvent(cm, e) || handlePaste(e, cm)) { return } + + cm.state.pasteIncoming = +new Date; + input.fastPoll(); + }); + + function prepareCopyCut(e) { + if (signalDOMEvent(cm, e)) { return } + if (cm.somethingSelected()) { + setLastCopied({lineWise: false, text: cm.getSelections()}); + } else if (!cm.options.lineWiseCopyCut) { + return + } else { + var ranges = copyableRanges(cm); + setLastCopied({lineWise: true, text: ranges.text}); + if (e.type == "cut") { + cm.setSelections(ranges.ranges, null, sel_dontScroll); + } else { + input.prevInput = ""; + te.value = ranges.text.join("\n"); + selectInput(te); + } + } + if (e.type == "cut") { cm.state.cutIncoming = +new Date; } + } + on(te, "cut", prepareCopyCut); + on(te, "copy", prepareCopyCut); + + on(display.scroller, "paste", function (e) { + if (eventInWidget(display, e) || signalDOMEvent(cm, e)) { return } + if (!te.dispatchEvent) { + cm.state.pasteIncoming = +new Date; + input.focus(); + return + } + + // Pass the `paste` event to the textarea so it's handled by its event listener. + var event = new Event("paste"); + event.clipboardData = e.clipboardData; + te.dispatchEvent(event); + }); + + // Prevent normal selection in the editor (we handle our own) + on(display.lineSpace, "selectstart", function (e) { + if (!eventInWidget(display, e)) { e_preventDefault(e); } + }); + + on(te, "compositionstart", function () { + var start = cm.getCursor("from"); + if (input.composing) { input.composing.range.clear(); } + input.composing = { + start: start, + range: cm.markText(start, cm.getCursor("to"), {className: "CodeMirror-composing"}) + }; + }); + on(te, "compositionend", function () { + if (input.composing) { + input.poll(); + input.composing.range.clear(); + input.composing = null; + } + }); + }; + + TextareaInput.prototype.createField = function (_display) { + // Wraps and hides input textarea + this.wrapper = hiddenTextarea(); + // The semihidden textarea that is focused when the editor is + // focused, and receives input. + this.textarea = this.wrapper.firstChild; + }; + + TextareaInput.prototype.prepareSelection = function () { + // Redraw the selection and/or cursor + var cm = this.cm, display = cm.display, doc = cm.doc; + var result = prepareSelection(cm); + + // Move the hidden textarea near the cursor to prevent scrolling artifacts + if (cm.options.moveInputWithCursor) { + var headPos = cursorCoords(cm, doc.sel.primary().head, "div"); + var wrapOff = display.wrapper.getBoundingClientRect(), lineOff = display.lineDiv.getBoundingClientRect(); + result.teTop = Math.max(0, Math.min(display.wrapper.clientHeight - 10, + headPos.top + lineOff.top - wrapOff.top)); + result.teLeft = Math.max(0, Math.min(display.wrapper.clientWidth - 10, + headPos.left + lineOff.left - wrapOff.left)); + } + + return result + }; + + TextareaInput.prototype.showSelection = function (drawn) { + var cm = this.cm, display = cm.display; + removeChildrenAndAdd(display.cursorDiv, drawn.cursors); + removeChildrenAndAdd(display.selectionDiv, drawn.selection); + if (drawn.teTop != null) { + this.wrapper.style.top = drawn.teTop + "px"; + this.wrapper.style.left = drawn.teLeft + "px"; + } + }; + + // Reset the input to correspond to the selection (or to be empty, + // when not typing and nothing is selected) + TextareaInput.prototype.reset = function (typing) { + if (this.contextMenuPending || this.composing) { return } + var cm = this.cm; + if (cm.somethingSelected()) { + this.prevInput = ""; + var content = cm.getSelection(); + this.textarea.value = content; + if (cm.state.focused) { selectInput(this.textarea); } + if (ie && ie_version >= 9) { this.hasSelection = content; } + } else if (!typing) { + this.prevInput = this.textarea.value = ""; + if (ie && ie_version >= 9) { this.hasSelection = null; } + } + }; + + TextareaInput.prototype.getField = function () { return this.textarea }; + + TextareaInput.prototype.supportsTouch = function () { return false }; + + TextareaInput.prototype.focus = function () { + if (this.cm.options.readOnly != "nocursor" && (!mobile || activeElt() != this.textarea)) { + try { this.textarea.focus(); } + catch (e) {} // IE8 will throw if the textarea is display: none or not in DOM + } + }; + + TextareaInput.prototype.blur = function () { this.textarea.blur(); }; + + TextareaInput.prototype.resetPosition = function () { + this.wrapper.style.top = this.wrapper.style.left = 0; + }; + + TextareaInput.prototype.receivedFocus = function () { this.slowPoll(); }; + + // Poll for input changes, using the normal rate of polling. This + // runs as long as the editor is focused. + TextareaInput.prototype.slowPoll = function () { + var this$1 = this; + + if (this.pollingFast) { return } + this.polling.set(this.cm.options.pollInterval, function () { + this$1.poll(); + if (this$1.cm.state.focused) { this$1.slowPoll(); } + }); + }; + + // When an event has just come in that is likely to add or change + // something in the input textarea, we poll faster, to ensure that + // the change appears on the screen quickly. + TextareaInput.prototype.fastPoll = function () { + var missed = false, input = this; + input.pollingFast = true; + function p() { + var changed = input.poll(); + if (!changed && !missed) {missed = true; input.polling.set(60, p);} + else {input.pollingFast = false; input.slowPoll();} + } + input.polling.set(20, p); + }; + + // Read input from the textarea, and update the document to match. + // When something is selected, it is present in the textarea, and + // selected (unless it is huge, in which case a placeholder is + // used). When nothing is selected, the cursor sits after previously + // seen text (can be empty), which is stored in prevInput (we must + // not reset the textarea when typing, because that breaks IME). + TextareaInput.prototype.poll = function () { + var this$1 = this; + + var cm = this.cm, input = this.textarea, prevInput = this.prevInput; + // Since this is called a *lot*, try to bail out as cheaply as + // possible when it is clear that nothing happened. hasSelection + // will be the case when there is a lot of text in the textarea, + // in which case reading its value would be expensive. + if (this.contextMenuPending || !cm.state.focused || + (hasSelection(input) && !prevInput && !this.composing) || + cm.isReadOnly() || cm.options.disableInput || cm.state.keySeq) + { return false } + + var text = input.value; + // If nothing changed, bail. + if (text == prevInput && !cm.somethingSelected()) { return false } + // Work around nonsensical selection resetting in IE9/10, and + // inexplicable appearance of private area unicode characters on + // some key combos in Mac (#2689). + if (ie && ie_version >= 9 && this.hasSelection === text || + mac && /[\uf700-\uf7ff]/.test(text)) { + cm.display.input.reset(); + return false + } + + if (cm.doc.sel == cm.display.selForContextMenu) { + var first = text.charCodeAt(0); + if (first == 0x200b && !prevInput) { prevInput = "\u200b"; } + if (first == 0x21da) { this.reset(); return this.cm.execCommand("undo") } + } + // Find the part of the input that is actually new + var same = 0, l = Math.min(prevInput.length, text.length); + while (same < l && prevInput.charCodeAt(same) == text.charCodeAt(same)) { ++same; } + + runInOp(cm, function () { + applyTextInput(cm, text.slice(same), prevInput.length - same, + null, this$1.composing ? "*compose" : null); + + // Don't leave long text in the textarea, since it makes further polling slow + if (text.length > 1000 || text.indexOf("\n") > -1) { input.value = this$1.prevInput = ""; } + else { this$1.prevInput = text; } + + if (this$1.composing) { + this$1.composing.range.clear(); + this$1.composing.range = cm.markText(this$1.composing.start, cm.getCursor("to"), + {className: "CodeMirror-composing"}); + } + }); + return true + }; + + TextareaInput.prototype.ensurePolled = function () { + if (this.pollingFast && this.poll()) { this.pollingFast = false; } + }; + + TextareaInput.prototype.onKeyPress = function () { + if (ie && ie_version >= 9) { this.hasSelection = null; } + this.fastPoll(); + }; + + TextareaInput.prototype.onContextMenu = function (e) { + var input = this, cm = input.cm, display = cm.display, te = input.textarea; + if (input.contextMenuPending) { input.contextMenuPending(); } + var pos = posFromMouse(cm, e), scrollPos = display.scroller.scrollTop; + if (!pos || presto) { return } // Opera is difficult. + + // Reset the current text selection only if the click is done outside of the selection + // and 'resetSelectionOnContextMenu' option is true. + var reset = cm.options.resetSelectionOnContextMenu; + if (reset && cm.doc.sel.contains(pos) == -1) + { operation(cm, setSelection)(cm.doc, simpleSelection(pos), sel_dontScroll); } + + var oldCSS = te.style.cssText, oldWrapperCSS = input.wrapper.style.cssText; + var wrapperBox = input.wrapper.offsetParent.getBoundingClientRect(); + input.wrapper.style.cssText = "position: static"; + te.style.cssText = "position: absolute; width: 30px; height: 30px;\n top: " + (e.clientY - wrapperBox.top - 5) + "px; left: " + (e.clientX - wrapperBox.left - 5) + "px;\n z-index: 1000; background: " + (ie ? "rgba(255, 255, 255, .05)" : "transparent") + ";\n outline: none; border-width: 0; outline: none; overflow: hidden; opacity: .05; filter: alpha(opacity=5);"; + var oldScrollY; + if (webkit) { oldScrollY = window.scrollY; } // Work around Chrome issue (#2712) + display.input.focus(); + if (webkit) { window.scrollTo(null, oldScrollY); } + display.input.reset(); + // Adds "Select all" to context menu in FF + if (!cm.somethingSelected()) { te.value = input.prevInput = " "; } + input.contextMenuPending = rehide; + display.selForContextMenu = cm.doc.sel; + clearTimeout(display.detectingSelectAll); + + // Select-all will be greyed out if there's nothing to select, so + // this adds a zero-width space so that we can later check whether + // it got selected. + function prepareSelectAllHack() { + if (te.selectionStart != null) { + var selected = cm.somethingSelected(); + var extval = "\u200b" + (selected ? te.value : ""); + te.value = "\u21da"; // Used to catch context-menu undo + te.value = extval; + input.prevInput = selected ? "" : "\u200b"; + te.selectionStart = 1; te.selectionEnd = extval.length; + // Re-set this, in case some other handler touched the + // selection in the meantime. + display.selForContextMenu = cm.doc.sel; + } + } + function rehide() { + if (input.contextMenuPending != rehide) { return } + input.contextMenuPending = false; + input.wrapper.style.cssText = oldWrapperCSS; + te.style.cssText = oldCSS; + if (ie && ie_version < 9) { display.scrollbars.setScrollTop(display.scroller.scrollTop = scrollPos); } + + // Try to detect the user choosing select-all + if (te.selectionStart != null) { + if (!ie || (ie && ie_version < 9)) { prepareSelectAllHack(); } + var i = 0, poll = function () { + if (display.selForContextMenu == cm.doc.sel && te.selectionStart == 0 && + te.selectionEnd > 0 && input.prevInput == "\u200b") { + operation(cm, selectAll)(cm); + } else if (i++ < 10) { + display.detectingSelectAll = setTimeout(poll, 500); + } else { + display.selForContextMenu = null; + display.input.reset(); + } + }; + display.detectingSelectAll = setTimeout(poll, 200); + } + } + + if (ie && ie_version >= 9) { prepareSelectAllHack(); } + if (captureRightClick) { + e_stop(e); + var mouseup = function () { + off(window, "mouseup", mouseup); + setTimeout(rehide, 20); + }; + on(window, "mouseup", mouseup); + } else { + setTimeout(rehide, 50); + } + }; + + TextareaInput.prototype.readOnlyChanged = function (val) { + if (!val) { this.reset(); } + this.textarea.disabled = val == "nocursor"; + }; + + TextareaInput.prototype.setUneditable = function () {}; + + TextareaInput.prototype.needsContentAttribute = false; + + function fromTextArea(textarea, options) { + options = options ? copyObj(options) : {}; + options.value = textarea.value; + if (!options.tabindex && textarea.tabIndex) + { options.tabindex = textarea.tabIndex; } + if (!options.placeholder && textarea.placeholder) + { options.placeholder = textarea.placeholder; } + // Set autofocus to true if this textarea is focused, or if it has + // autofocus and no other element is focused. + if (options.autofocus == null) { + var hasFocus = activeElt(); + options.autofocus = hasFocus == textarea || + textarea.getAttribute("autofocus") != null && hasFocus == document.body; + } + + function save() {textarea.value = cm.getValue();} + + var realSubmit; + if (textarea.form) { + on(textarea.form, "submit", save); + // Deplorable hack to make the submit method do the right thing. + if (!options.leaveSubmitMethodAlone) { + var form = textarea.form; + realSubmit = form.submit; + try { + var wrappedSubmit = form.submit = function () { + save(); + form.submit = realSubmit; + form.submit(); + form.submit = wrappedSubmit; + }; + } catch(e) {} + } + } + + options.finishInit = function (cm) { + cm.save = save; + cm.getTextArea = function () { return textarea; }; + cm.toTextArea = function () { + cm.toTextArea = isNaN; // Prevent this from being ran twice + save(); + textarea.parentNode.removeChild(cm.getWrapperElement()); + textarea.style.display = ""; + if (textarea.form) { + off(textarea.form, "submit", save); + if (!options.leaveSubmitMethodAlone && typeof textarea.form.submit == "function") + { textarea.form.submit = realSubmit; } + } + }; + }; + + textarea.style.display = "none"; + var cm = CodeMirror(function (node) { return textarea.parentNode.insertBefore(node, textarea.nextSibling); }, + options); + return cm + } + + function addLegacyProps(CodeMirror) { + CodeMirror.off = off; + CodeMirror.on = on; + CodeMirror.wheelEventPixels = wheelEventPixels; + CodeMirror.Doc = Doc; + CodeMirror.splitLines = splitLinesAuto; + CodeMirror.countColumn = countColumn; + CodeMirror.findColumn = findColumn; + CodeMirror.isWordChar = isWordCharBasic; + CodeMirror.Pass = Pass; + CodeMirror.signal = signal; + CodeMirror.Line = Line; + CodeMirror.changeEnd = changeEnd; + CodeMirror.scrollbarModel = scrollbarModel; + CodeMirror.Pos = Pos; + CodeMirror.cmpPos = cmp; + CodeMirror.modes = modes; + CodeMirror.mimeModes = mimeModes; + CodeMirror.resolveMode = resolveMode; + CodeMirror.getMode = getMode; + CodeMirror.modeExtensions = modeExtensions; + CodeMirror.extendMode = extendMode; + CodeMirror.copyState = copyState; + CodeMirror.startState = startState; + CodeMirror.innerMode = innerMode; + CodeMirror.commands = commands; + CodeMirror.keyMap = keyMap; + CodeMirror.keyName = keyName; + CodeMirror.isModifierKey = isModifierKey; + CodeMirror.lookupKey = lookupKey; + CodeMirror.normalizeKeyMap = normalizeKeyMap; + CodeMirror.StringStream = StringStream; + CodeMirror.SharedTextMarker = SharedTextMarker; + CodeMirror.TextMarker = TextMarker; + CodeMirror.LineWidget = LineWidget; + CodeMirror.e_preventDefault = e_preventDefault; + CodeMirror.e_stopPropagation = e_stopPropagation; + CodeMirror.e_stop = e_stop; + CodeMirror.addClass = addClass; + CodeMirror.contains = contains; + CodeMirror.rmClass = rmClass; + CodeMirror.keyNames = keyNames; + } + + // EDITOR CONSTRUCTOR + + defineOptions(CodeMirror); + + addEditorMethods(CodeMirror); + + // Set up methods on CodeMirror's prototype to redirect to the editor's document. + var dontDelegate = "iter insert remove copy getEditor constructor".split(" "); + for (var prop in Doc.prototype) { if (Doc.prototype.hasOwnProperty(prop) && indexOf(dontDelegate, prop) < 0) + { CodeMirror.prototype[prop] = (function(method) { + return function() {return method.apply(this.doc, arguments)} + })(Doc.prototype[prop]); } } + + eventMixin(Doc); + CodeMirror.inputStyles = {"textarea": TextareaInput, "contenteditable": ContentEditableInput}; + + // Extra arguments are stored as the mode's dependencies, which is + // used by (legacy) mechanisms like loadmode.js to automatically + // load a mode. (Preferred mechanism is the require/define calls.) + CodeMirror.defineMode = function(name/*, mode, …*/) { + if (!CodeMirror.defaults.mode && name != "null") { CodeMirror.defaults.mode = name; } + defineMode.apply(this, arguments); + }; + + CodeMirror.defineMIME = defineMIME; + + // Minimal default mode. + CodeMirror.defineMode("null", function () { return ({token: function (stream) { return stream.skipToEnd(); }}); }); + CodeMirror.defineMIME("text/plain", "null"); + + // EXTENSIONS + + CodeMirror.defineExtension = function (name, func) { + CodeMirror.prototype[name] = func; + }; + CodeMirror.defineDocExtension = function (name, func) { + Doc.prototype[name] = func; + }; + + CodeMirror.fromTextArea = fromTextArea; + + addLegacyProps(CodeMirror); + + CodeMirror.version = "5.51.0"; + + return CodeMirror; + +}))); diff --git a/adminx/assets/vendor/codemirror/mode/clike/clike.js b/adminx/assets/vendor/codemirror/mode/clike/clike.js new file mode 100644 index 0000000..37da2ec --- /dev/null +++ b/adminx/assets/vendor/codemirror/mode/clike/clike.js @@ -0,0 +1,935 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { +"use strict"; + +function Context(indented, column, type, info, align, prev) { + this.indented = indented; + this.column = column; + this.type = type; + this.info = info; + this.align = align; + this.prev = prev; +} +function pushContext(state, col, type, info) { + var indent = state.indented; + if (state.context && state.context.type == "statement" && type != "statement") + indent = state.context.indented; + return state.context = new Context(indent, col, type, info, null, state.context); +} +function popContext(state) { + var t = state.context.type; + if (t == ")" || t == "]" || t == "}") + state.indented = state.context.indented; + return state.context = state.context.prev; +} + +function typeBefore(stream, state, pos) { + if (state.prevToken == "variable" || state.prevToken == "type") return true; + if (/\S(?:[^- ]>|[*\]])\s*$|\*$/.test(stream.string.slice(0, pos))) return true; + if (state.typeAtEndOfLine && stream.column() == stream.indentation()) return true; +} + +function isTopScope(context) { + for (;;) { + if (!context || context.type == "top") return true; + if (context.type == "}" && context.prev.info != "namespace") return false; + context = context.prev; + } +} + +CodeMirror.defineMode("clike", function(config, parserConfig) { + var indentUnit = config.indentUnit, + statementIndentUnit = parserConfig.statementIndentUnit || indentUnit, + dontAlignCalls = parserConfig.dontAlignCalls, + keywords = parserConfig.keywords || {}, + types = parserConfig.types || {}, + builtin = parserConfig.builtin || {}, + blockKeywords = parserConfig.blockKeywords || {}, + defKeywords = parserConfig.defKeywords || {}, + atoms = parserConfig.atoms || {}, + hooks = parserConfig.hooks || {}, + multiLineStrings = parserConfig.multiLineStrings, + indentStatements = parserConfig.indentStatements !== false, + indentSwitch = parserConfig.indentSwitch !== false, + namespaceSeparator = parserConfig.namespaceSeparator, + isPunctuationChar = parserConfig.isPunctuationChar || /[\[\]{}\(\),;\:\.]/, + numberStart = parserConfig.numberStart || /[\d\.]/, + number = parserConfig.number || /^(?:0x[a-f\d]+|0b[01]+|(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)(u|ll?|l|f)?/i, + isOperatorChar = parserConfig.isOperatorChar || /[+\-*&%=<>!?|\/]/, + isIdentifierChar = parserConfig.isIdentifierChar || /[\w\$_\xa1-\uffff]/, + // An optional function that takes a {string} token and returns true if it + // should be treated as a builtin. + isReservedIdentifier = parserConfig.isReservedIdentifier || false; + + var curPunc, isDefKeyword; + + function tokenBase(stream, state) { + var ch = stream.next(); + if (hooks[ch]) { + var result = hooks[ch](stream, state); + if (result !== false) return result; + } + if (ch == '"' || ch == "'") { + state.tokenize = tokenString(ch); + return state.tokenize(stream, state); + } + if (isPunctuationChar.test(ch)) { + curPunc = ch; + return null; + } + if (numberStart.test(ch)) { + stream.backUp(1) + if (stream.match(number)) return "number" + stream.next() + } + if (ch == "/") { + if (stream.eat("*")) { + state.tokenize = tokenComment; + return tokenComment(stream, state); + } + if (stream.eat("/")) { + stream.skipToEnd(); + return "comment"; + } + } + if (isOperatorChar.test(ch)) { + while (!stream.match(/^\/[\/*]/, false) && stream.eat(isOperatorChar)) {} + return "operator"; + } + stream.eatWhile(isIdentifierChar); + if (namespaceSeparator) while (stream.match(namespaceSeparator)) + stream.eatWhile(isIdentifierChar); + + var cur = stream.current(); + if (contains(keywords, cur)) { + if (contains(blockKeywords, cur)) curPunc = "newstatement"; + if (contains(defKeywords, cur)) isDefKeyword = true; + return "keyword"; + } + if (contains(types, cur)) return "type"; + if (contains(builtin, cur) + || (isReservedIdentifier && isReservedIdentifier(cur))) { + if (contains(blockKeywords, cur)) curPunc = "newstatement"; + return "builtin"; + } + if (contains(atoms, cur)) return "atom"; + return "variable"; + } + + function tokenString(quote) { + return function(stream, state) { + var escaped = false, next, end = false; + while ((next = stream.next()) != null) { + if (next == quote && !escaped) {end = true; break;} + escaped = !escaped && next == "\\"; + } + if (end || !(escaped || multiLineStrings)) + state.tokenize = null; + return "string"; + }; + } + + function tokenComment(stream, state) { + var maybeEnd = false, ch; + while (ch = stream.next()) { + if (ch == "/" && maybeEnd) { + state.tokenize = null; + break; + } + maybeEnd = (ch == "*"); + } + return "comment"; + } + + function maybeEOL(stream, state) { + if (parserConfig.typeFirstDefinitions && stream.eol() && isTopScope(state.context)) + state.typeAtEndOfLine = typeBefore(stream, state, stream.pos) + } + + // Interface + + return { + startState: function(basecolumn) { + return { + tokenize: null, + context: new Context((basecolumn || 0) - indentUnit, 0, "top", null, false), + indented: 0, + startOfLine: true, + prevToken: null + }; + }, + + token: function(stream, state) { + var ctx = state.context; + if (stream.sol()) { + if (ctx.align == null) ctx.align = false; + state.indented = stream.indentation(); + state.startOfLine = true; + } + if (stream.eatSpace()) { maybeEOL(stream, state); return null; } + curPunc = isDefKeyword = null; + var style = (state.tokenize || tokenBase)(stream, state); + if (style == "comment" || style == "meta") return style; + if (ctx.align == null) ctx.align = true; + + if (curPunc == ";" || curPunc == ":" || (curPunc == "," && stream.match(/^\s*(?:\/\/.*)?$/, false))) + while (state.context.type == "statement") popContext(state); + else if (curPunc == "{") pushContext(state, stream.column(), "}"); + else if (curPunc == "[") pushContext(state, stream.column(), "]"); + else if (curPunc == "(") pushContext(state, stream.column(), ")"); + else if (curPunc == "}") { + while (ctx.type == "statement") ctx = popContext(state); + if (ctx.type == "}") ctx = popContext(state); + while (ctx.type == "statement") ctx = popContext(state); + } + else if (curPunc == ctx.type) popContext(state); + else if (indentStatements && + (((ctx.type == "}" || ctx.type == "top") && curPunc != ";") || + (ctx.type == "statement" && curPunc == "newstatement"))) { + pushContext(state, stream.column(), "statement", stream.current()); + } + + if (style == "variable" && + ((state.prevToken == "def" || + (parserConfig.typeFirstDefinitions && typeBefore(stream, state, stream.start) && + isTopScope(state.context) && stream.match(/^\s*\(/, false))))) + style = "def"; + + if (hooks.token) { + var result = hooks.token(stream, state, style); + if (result !== undefined) style = result; + } + + if (style == "def" && parserConfig.styleDefs === false) style = "variable"; + + state.startOfLine = false; + state.prevToken = isDefKeyword ? "def" : style || curPunc; + maybeEOL(stream, state); + return style; + }, + + indent: function(state, textAfter) { + if (state.tokenize != tokenBase && state.tokenize != null || state.typeAtEndOfLine) return CodeMirror.Pass; + var ctx = state.context, firstChar = textAfter && textAfter.charAt(0); + var closing = firstChar == ctx.type; + if (ctx.type == "statement" && firstChar == "}") ctx = ctx.prev; + if (parserConfig.dontIndentStatements) + while (ctx.type == "statement" && parserConfig.dontIndentStatements.test(ctx.info)) + ctx = ctx.prev + if (hooks.indent) { + var hook = hooks.indent(state, ctx, textAfter, indentUnit); + if (typeof hook == "number") return hook + } + var switchBlock = ctx.prev && ctx.prev.info == "switch"; + if (parserConfig.allmanIndentation && /[{(]/.test(firstChar)) { + while (ctx.type != "top" && ctx.type != "}") ctx = ctx.prev + return ctx.indented + } + if (ctx.type == "statement") + return ctx.indented + (firstChar == "{" ? 0 : statementIndentUnit); + if (ctx.align && (!dontAlignCalls || ctx.type != ")")) + return ctx.column + (closing ? 0 : 1); + if (ctx.type == ")" && !closing) + return ctx.indented + statementIndentUnit; + + return ctx.indented + (closing ? 0 : indentUnit) + + (!closing && switchBlock && !/^(?:case|default)\b/.test(textAfter) ? indentUnit : 0); + }, + + electricInput: indentSwitch ? /^\s*(?:case .*?:|default:|\{\}?|\})$/ : /^\s*[{}]$/, + blockCommentStart: "/*", + blockCommentEnd: "*/", + blockCommentContinue: " * ", + lineComment: "//", + fold: "brace" + }; +}); + + function words(str) { + var obj = {}, words = str.split(" "); + for (var i = 0; i < words.length; ++i) obj[words[i]] = true; + return obj; + } + function contains(words, word) { + if (typeof words === "function") { + return words(word); + } else { + return words.propertyIsEnumerable(word); + } + } + var cKeywords = "auto if break case register continue return default do sizeof " + + "static else struct switch extern typedef union for goto while enum const " + + "volatile inline restrict asm fortran"; + + // Keywords from https://en.cppreference.com/w/cpp/keyword includes C++20. + var cppKeywords = "alignas alignof and and_eq audit axiom bitand bitor catch " + + "class compl concept constexpr const_cast decltype delete dynamic_cast " + + "explicit export final friend import module mutable namespace new noexcept " + + "not not_eq operator or or_eq override private protected public " + + "reinterpret_cast requires static_assert static_cast template this " + + "thread_local throw try typeid typename using virtual xor xor_eq"; + + var objCKeywords = "bycopy byref in inout oneway out self super atomic nonatomic retain copy " + + "readwrite readonly strong weak assign typeof nullable nonnull null_resettable _cmd " + + "@interface @implementation @end @protocol @encode @property @synthesize @dynamic @class " + + "@public @package @private @protected @required @optional @try @catch @finally @import " + + "@selector @encode @defs @synchronized @autoreleasepool @compatibility_alias @available"; + + var objCBuiltins = "FOUNDATION_EXPORT FOUNDATION_EXTERN NS_INLINE NS_FORMAT_FUNCTION " + + " NS_RETURNS_RETAINEDNS_ERROR_ENUM NS_RETURNS_NOT_RETAINED NS_RETURNS_INNER_POINTER " + + "NS_DESIGNATED_INITIALIZER NS_ENUM NS_OPTIONS NS_REQUIRES_NIL_TERMINATION " + + "NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_END NS_SWIFT_NAME NS_REFINED_FOR_SWIFT" + + // Do not use this. Use the cTypes function below. This is global just to avoid + // excessive calls when cTypes is being called multiple times during a parse. + var basicCTypes = words("int long char short double float unsigned signed " + + "void bool"); + + // Do not use this. Use the objCTypes function below. This is global just to avoid + // excessive calls when objCTypes is being called multiple times during a parse. + var basicObjCTypes = words("SEL instancetype id Class Protocol BOOL"); + + // Returns true if identifier is a "C" type. + // C type is defined as those that are reserved by the compiler (basicTypes), + // and those that end in _t (Reserved by POSIX for types) + // http://www.gnu.org/software/libc/manual/html_node/Reserved-Names.html + function cTypes(identifier) { + return contains(basicCTypes, identifier) || /.+_t$/.test(identifier); + } + + // Returns true if identifier is a "Objective C" type. + function objCTypes(identifier) { + return cTypes(identifier) || contains(basicObjCTypes, identifier); + } + + var cBlockKeywords = "case do else for if switch while struct enum union"; + var cDefKeywords = "struct enum union"; + + function cppHook(stream, state) { + if (!state.startOfLine) return false + for (var ch, next = null; ch = stream.peek();) { + if (ch == "\\" && stream.match(/^.$/)) { + next = cppHook + break + } else if (ch == "/" && stream.match(/^\/[\/\*]/, false)) { + break + } + stream.next() + } + state.tokenize = next + return "meta" + } + + function pointerHook(_stream, state) { + if (state.prevToken == "type") return "type"; + return false; + } + + // For C and C++ (and ObjC): identifiers starting with __ + // or _ followed by a capital letter are reserved for the compiler. + function cIsReservedIdentifier(token) { + if (!token || token.length < 2) return false; + if (token[0] != '_') return false; + return (token[1] == '_') || (token[1] !== token[1].toLowerCase()); + } + + function cpp14Literal(stream) { + stream.eatWhile(/[\w\.']/); + return "number"; + } + + function cpp11StringHook(stream, state) { + stream.backUp(1); + // Raw strings. + if (stream.match(/(R|u8R|uR|UR|LR)/)) { + var match = stream.match(/"([^\s\\()]{0,16})\(/); + if (!match) { + return false; + } + state.cpp11RawStringDelim = match[1]; + state.tokenize = tokenRawString; + return tokenRawString(stream, state); + } + // Unicode strings/chars. + if (stream.match(/(u8|u|U|L)/)) { + if (stream.match(/["']/, /* eat */ false)) { + return "string"; + } + return false; + } + // Ignore this hook. + stream.next(); + return false; + } + + function cppLooksLikeConstructor(word) { + var lastTwo = /(\w+)::~?(\w+)$/.exec(word); + return lastTwo && lastTwo[1] == lastTwo[2]; + } + + // C#-style strings where "" escapes a quote. + function tokenAtString(stream, state) { + var next; + while ((next = stream.next()) != null) { + if (next == '"' && !stream.eat('"')) { + state.tokenize = null; + break; + } + } + return "string"; + } + + // C++11 raw string literal is "( anything )", where + // can be a string up to 16 characters long. + function tokenRawString(stream, state) { + // Escape characters that have special regex meanings. + var delim = state.cpp11RawStringDelim.replace(/[^\w\s]/g, '\\$&'); + var match = stream.match(new RegExp(".*?\\)" + delim + '"')); + if (match) + state.tokenize = null; + else + stream.skipToEnd(); + return "string"; + } + + function def(mimes, mode) { + if (typeof mimes == "string") mimes = [mimes]; + var words = []; + function add(obj) { + if (obj) for (var prop in obj) if (obj.hasOwnProperty(prop)) + words.push(prop); + } + add(mode.keywords); + add(mode.types); + add(mode.builtin); + add(mode.atoms); + if (words.length) { + mode.helperType = mimes[0]; + CodeMirror.registerHelper("hintWords", mimes[0], words); + } + + for (var i = 0; i < mimes.length; ++i) + CodeMirror.defineMIME(mimes[i], mode); + } + + def(["text/x-csrc", "text/x-c", "text/x-chdr"], { + name: "clike", + keywords: words(cKeywords), + types: cTypes, + blockKeywords: words(cBlockKeywords), + defKeywords: words(cDefKeywords), + typeFirstDefinitions: true, + atoms: words("NULL true false"), + isReservedIdentifier: cIsReservedIdentifier, + hooks: { + "#": cppHook, + "*": pointerHook, + }, + modeProps: {fold: ["brace", "include"]} + }); + + def(["text/x-c++src", "text/x-c++hdr"], { + name: "clike", + keywords: words(cKeywords + " " + cppKeywords), + types: cTypes, + blockKeywords: words(cBlockKeywords + " class try catch"), + defKeywords: words(cDefKeywords + " class namespace"), + typeFirstDefinitions: true, + atoms: words("true false NULL nullptr"), + dontIndentStatements: /^template$/, + isIdentifierChar: /[\w\$_~\xa1-\uffff]/, + isReservedIdentifier: cIsReservedIdentifier, + hooks: { + "#": cppHook, + "*": pointerHook, + "u": cpp11StringHook, + "U": cpp11StringHook, + "L": cpp11StringHook, + "R": cpp11StringHook, + "0": cpp14Literal, + "1": cpp14Literal, + "2": cpp14Literal, + "3": cpp14Literal, + "4": cpp14Literal, + "5": cpp14Literal, + "6": cpp14Literal, + "7": cpp14Literal, + "8": cpp14Literal, + "9": cpp14Literal, + token: function(stream, state, style) { + if (style == "variable" && stream.peek() == "(" && + (state.prevToken == ";" || state.prevToken == null || + state.prevToken == "}") && + cppLooksLikeConstructor(stream.current())) + return "def"; + } + }, + namespaceSeparator: "::", + modeProps: {fold: ["brace", "include"]} + }); + + def("text/x-java", { + name: "clike", + keywords: words("abstract assert break case catch class const continue default " + + "do else enum extends final finally for goto if implements import " + + "instanceof interface native new package private protected public " + + "return static strictfp super switch synchronized this throw throws transient " + + "try volatile while @interface"), + types: words("byte short int long float double boolean char void Boolean Byte Character Double Float " + + "Integer Long Number Object Short String StringBuffer StringBuilder Void"), + blockKeywords: words("catch class do else finally for if switch try while"), + defKeywords: words("class interface enum @interface"), + typeFirstDefinitions: true, + atoms: words("true false null"), + number: /^(?:0x[a-f\d_]+|0b[01_]+|(?:[\d_]+\.?\d*|\.\d+)(?:e[-+]?[\d_]+)?)(u|ll?|l|f)?/i, + hooks: { + "@": function(stream) { + // Don't match the @interface keyword. + if (stream.match('interface', false)) return false; + + stream.eatWhile(/[\w\$_]/); + return "meta"; + } + }, + modeProps: {fold: ["brace", "import"]} + }); + + def("text/x-csharp", { + name: "clike", + keywords: words("abstract as async await base break case catch checked class const continue" + + " default delegate do else enum event explicit extern finally fixed for" + + " foreach goto if implicit in interface internal is lock namespace new" + + " operator out override params private protected public readonly ref return sealed" + + " sizeof stackalloc static struct switch this throw try typeof unchecked" + + " unsafe using virtual void volatile while add alias ascending descending dynamic from get" + + " global group into join let orderby partial remove select set value var yield"), + types: words("Action Boolean Byte Char DateTime DateTimeOffset Decimal Double Func" + + " Guid Int16 Int32 Int64 Object SByte Single String Task TimeSpan UInt16 UInt32" + + " UInt64 bool byte char decimal double short int long object" + + " sbyte float string ushort uint ulong"), + blockKeywords: words("catch class do else finally for foreach if struct switch try while"), + defKeywords: words("class interface namespace struct var"), + typeFirstDefinitions: true, + atoms: words("true false null"), + hooks: { + "@": function(stream, state) { + if (stream.eat('"')) { + state.tokenize = tokenAtString; + return tokenAtString(stream, state); + } + stream.eatWhile(/[\w\$_]/); + return "meta"; + } + } + }); + + function tokenTripleString(stream, state) { + var escaped = false; + while (!stream.eol()) { + if (!escaped && stream.match('"""')) { + state.tokenize = null; + break; + } + escaped = stream.next() == "\\" && !escaped; + } + return "string"; + } + + function tokenNestedComment(depth) { + return function (stream, state) { + var ch + while (ch = stream.next()) { + if (ch == "*" && stream.eat("/")) { + if (depth == 1) { + state.tokenize = null + break + } else { + state.tokenize = tokenNestedComment(depth - 1) + return state.tokenize(stream, state) + } + } else if (ch == "/" && stream.eat("*")) { + state.tokenize = tokenNestedComment(depth + 1) + return state.tokenize(stream, state) + } + } + return "comment" + } + } + + def("text/x-scala", { + name: "clike", + keywords: words( + /* scala */ + "abstract case catch class def do else extends final finally for forSome if " + + "implicit import lazy match new null object override package private protected return " + + "sealed super this throw trait try type val var while with yield _ " + + + /* package scala */ + "assert assume require print println printf readLine readBoolean readByte readShort " + + "readChar readInt readLong readFloat readDouble" + ), + types: words( + "AnyVal App Application Array BufferedIterator BigDecimal BigInt Char Console Either " + + "Enumeration Equiv Error Exception Fractional Function IndexedSeq Int Integral Iterable " + + "Iterator List Map Numeric Nil NotNull Option Ordered Ordering PartialFunction PartialOrdering " + + "Product Proxy Range Responder Seq Serializable Set Specializable Stream StringBuilder " + + "StringContext Symbol Throwable Traversable TraversableOnce Tuple Unit Vector " + + + /* package java.lang */ + "Boolean Byte Character CharSequence Class ClassLoader Cloneable Comparable " + + "Compiler Double Exception Float Integer Long Math Number Object Package Pair Process " + + "Runtime Runnable SecurityManager Short StackTraceElement StrictMath String " + + "StringBuffer System Thread ThreadGroup ThreadLocal Throwable Triple Void" + ), + multiLineStrings: true, + blockKeywords: words("catch class enum do else finally for forSome if match switch try while"), + defKeywords: words("class enum def object package trait type val var"), + atoms: words("true false null"), + indentStatements: false, + indentSwitch: false, + isOperatorChar: /[+\-*&%=<>!?|\/#:@]/, + hooks: { + "@": function(stream) { + stream.eatWhile(/[\w\$_]/); + return "meta"; + }, + '"': function(stream, state) { + if (!stream.match('""')) return false; + state.tokenize = tokenTripleString; + return state.tokenize(stream, state); + }, + "'": function(stream) { + stream.eatWhile(/[\w\$_\xa1-\uffff]/); + return "atom"; + }, + "=": function(stream, state) { + var cx = state.context + if (cx.type == "}" && cx.align && stream.eat(">")) { + state.context = new Context(cx.indented, cx.column, cx.type, cx.info, null, cx.prev) + return "operator" + } else { + return false + } + }, + + "/": function(stream, state) { + if (!stream.eat("*")) return false + state.tokenize = tokenNestedComment(1) + return state.tokenize(stream, state) + } + }, + modeProps: {closeBrackets: {pairs: '()[]{}""', triples: '"'}} + }); + + function tokenKotlinString(tripleString){ + return function (stream, state) { + var escaped = false, next, end = false; + while (!stream.eol()) { + if (!tripleString && !escaped && stream.match('"') ) {end = true; break;} + if (tripleString && stream.match('"""')) {end = true; break;} + next = stream.next(); + if(!escaped && next == "$" && stream.match('{')) + stream.skipTo("}"); + escaped = !escaped && next == "\\" && !tripleString; + } + if (end || !tripleString) + state.tokenize = null; + return "string"; + } + } + + def("text/x-kotlin", { + name: "clike", + keywords: words( + /*keywords*/ + "package as typealias class interface this super val operator " + + "var fun for is in This throw return annotation " + + "break continue object if else while do try when !in !is as? " + + + /*soft keywords*/ + "file import where by get set abstract enum open inner override private public internal " + + "protected catch finally out final vararg reified dynamic companion constructor init " + + "sealed field property receiver param sparam lateinit data inline noinline tailrec " + + "external annotation crossinline const operator infix suspend actual expect setparam" + ), + types: words( + /* package java.lang */ + "Boolean Byte Character CharSequence Class ClassLoader Cloneable Comparable " + + "Compiler Double Exception Float Integer Long Math Number Object Package Pair Process " + + "Runtime Runnable SecurityManager Short StackTraceElement StrictMath String " + + "StringBuffer System Thread ThreadGroup ThreadLocal Throwable Triple Void Annotation Any BooleanArray " + + "ByteArray Char CharArray DeprecationLevel DoubleArray Enum FloatArray Function Int IntArray Lazy " + + "LazyThreadSafetyMode LongArray Nothing ShortArray Unit" + ), + intendSwitch: false, + indentStatements: false, + multiLineStrings: true, + number: /^(?:0x[a-f\d_]+|0b[01_]+|(?:[\d_]+(\.\d+)?|\.\d+)(?:e[-+]?[\d_]+)?)(u|ll?|l|f)?/i, + blockKeywords: words("catch class do else finally for if where try while enum"), + defKeywords: words("class val var object interface fun"), + atoms: words("true false null this"), + hooks: { + "@": function(stream) { + stream.eatWhile(/[\w\$_]/); + return "meta"; + }, + '*': function(_stream, state) { + return state.prevToken == '.' ? 'variable' : 'operator'; + }, + '"': function(stream, state) { + state.tokenize = tokenKotlinString(stream.match('""')); + return state.tokenize(stream, state); + }, + "/": function(stream, state) { + if (!stream.eat("*")) return false; + state.tokenize = tokenNestedComment(1); + return state.tokenize(stream, state) + }, + indent: function(state, ctx, textAfter, indentUnit) { + var firstChar = textAfter && textAfter.charAt(0); + if ((state.prevToken == "}" || state.prevToken == ")") && textAfter == "") + return state.indented; + if ((state.prevToken == "operator" && textAfter != "}" && state.context.type != "}") || + state.prevToken == "variable" && firstChar == "." || + (state.prevToken == "}" || state.prevToken == ")") && firstChar == ".") + return indentUnit * 2 + ctx.indented; + if (ctx.align && ctx.type == "}") + return ctx.indented + (state.context.type == (textAfter || "").charAt(0) ? 0 : indentUnit); + } + }, + modeProps: {closeBrackets: {triples: '"'}} + }); + + def(["x-shader/x-vertex", "x-shader/x-fragment"], { + name: "clike", + keywords: words("sampler1D sampler2D sampler3D samplerCube " + + "sampler1DShadow sampler2DShadow " + + "const attribute uniform varying " + + "break continue discard return " + + "for while do if else struct " + + "in out inout"), + types: words("float int bool void " + + "vec2 vec3 vec4 ivec2 ivec3 ivec4 bvec2 bvec3 bvec4 " + + "mat2 mat3 mat4"), + blockKeywords: words("for while do if else struct"), + builtin: words("radians degrees sin cos tan asin acos atan " + + "pow exp log exp2 sqrt inversesqrt " + + "abs sign floor ceil fract mod min max clamp mix step smoothstep " + + "length distance dot cross normalize ftransform faceforward " + + "reflect refract matrixCompMult " + + "lessThan lessThanEqual greaterThan greaterThanEqual " + + "equal notEqual any all not " + + "texture1D texture1DProj texture1DLod texture1DProjLod " + + "texture2D texture2DProj texture2DLod texture2DProjLod " + + "texture3D texture3DProj texture3DLod texture3DProjLod " + + "textureCube textureCubeLod " + + "shadow1D shadow2D shadow1DProj shadow2DProj " + + "shadow1DLod shadow2DLod shadow1DProjLod shadow2DProjLod " + + "dFdx dFdy fwidth " + + "noise1 noise2 noise3 noise4"), + atoms: words("true false " + + "gl_FragColor gl_SecondaryColor gl_Normal gl_Vertex " + + "gl_MultiTexCoord0 gl_MultiTexCoord1 gl_MultiTexCoord2 gl_MultiTexCoord3 " + + "gl_MultiTexCoord4 gl_MultiTexCoord5 gl_MultiTexCoord6 gl_MultiTexCoord7 " + + "gl_FogCoord gl_PointCoord " + + "gl_Position gl_PointSize gl_ClipVertex " + + "gl_FrontColor gl_BackColor gl_FrontSecondaryColor gl_BackSecondaryColor " + + "gl_TexCoord gl_FogFragCoord " + + "gl_FragCoord gl_FrontFacing " + + "gl_FragData gl_FragDepth " + + "gl_ModelViewMatrix gl_ProjectionMatrix gl_ModelViewProjectionMatrix " + + "gl_TextureMatrix gl_NormalMatrix gl_ModelViewMatrixInverse " + + "gl_ProjectionMatrixInverse gl_ModelViewProjectionMatrixInverse " + + "gl_TexureMatrixTranspose gl_ModelViewMatrixInverseTranspose " + + "gl_ProjectionMatrixInverseTranspose " + + "gl_ModelViewProjectionMatrixInverseTranspose " + + "gl_TextureMatrixInverseTranspose " + + "gl_NormalScale gl_DepthRange gl_ClipPlane " + + "gl_Point gl_FrontMaterial gl_BackMaterial gl_LightSource gl_LightModel " + + "gl_FrontLightModelProduct gl_BackLightModelProduct " + + "gl_TextureColor gl_EyePlaneS gl_EyePlaneT gl_EyePlaneR gl_EyePlaneQ " + + "gl_FogParameters " + + "gl_MaxLights gl_MaxClipPlanes gl_MaxTextureUnits gl_MaxTextureCoords " + + "gl_MaxVertexAttribs gl_MaxVertexUniformComponents gl_MaxVaryingFloats " + + "gl_MaxVertexTextureImageUnits gl_MaxTextureImageUnits " + + "gl_MaxFragmentUniformComponents gl_MaxCombineTextureImageUnits " + + "gl_MaxDrawBuffers"), + indentSwitch: false, + hooks: {"#": cppHook}, + modeProps: {fold: ["brace", "include"]} + }); + + def("text/x-nesc", { + name: "clike", + keywords: words(cKeywords + " as atomic async call command component components configuration event generic " + + "implementation includes interface module new norace nx_struct nx_union post provides " + + "signal task uses abstract extends"), + types: cTypes, + blockKeywords: words(cBlockKeywords), + atoms: words("null true false"), + hooks: {"#": cppHook}, + modeProps: {fold: ["brace", "include"]} + }); + + def("text/x-objectivec", { + name: "clike", + keywords: words(cKeywords + " " + objCKeywords), + types: objCTypes, + builtin: words(objCBuiltins), + blockKeywords: words(cBlockKeywords + " @synthesize @try @catch @finally @autoreleasepool @synchronized"), + defKeywords: words(cDefKeywords + " @interface @implementation @protocol @class"), + dontIndentStatements: /^@.*$/, + typeFirstDefinitions: true, + atoms: words("YES NO NULL Nil nil true false nullptr"), + isReservedIdentifier: cIsReservedIdentifier, + hooks: { + "#": cppHook, + "*": pointerHook, + }, + modeProps: {fold: ["brace", "include"]} + }); + + def("text/x-objectivec++", { + name: "clike", + keywords: words(cKeywords + " " + objCKeywords + " " + cppKeywords), + types: objCTypes, + builtin: words(objCBuiltins), + blockKeywords: words(cBlockKeywords + " @synthesize @try @catch @finally @autoreleasepool @synchronized class try catch"), + defKeywords: words(cDefKeywords + " @interface @implementation @protocol @class class namespace"), + dontIndentStatements: /^@.*$|^template$/, + typeFirstDefinitions: true, + atoms: words("YES NO NULL Nil nil true false nullptr"), + isReservedIdentifier: cIsReservedIdentifier, + hooks: { + "#": cppHook, + "*": pointerHook, + "u": cpp11StringHook, + "U": cpp11StringHook, + "L": cpp11StringHook, + "R": cpp11StringHook, + "0": cpp14Literal, + "1": cpp14Literal, + "2": cpp14Literal, + "3": cpp14Literal, + "4": cpp14Literal, + "5": cpp14Literal, + "6": cpp14Literal, + "7": cpp14Literal, + "8": cpp14Literal, + "9": cpp14Literal, + token: function(stream, state, style) { + if (style == "variable" && stream.peek() == "(" && + (state.prevToken == ";" || state.prevToken == null || + state.prevToken == "}") && + cppLooksLikeConstructor(stream.current())) + return "def"; + } + }, + namespaceSeparator: "::", + modeProps: {fold: ["brace", "include"]} + }); + + def("text/x-squirrel", { + name: "clike", + keywords: words("base break clone continue const default delete enum extends function in class" + + " foreach local resume return this throw typeof yield constructor instanceof static"), + types: cTypes, + blockKeywords: words("case catch class else for foreach if switch try while"), + defKeywords: words("function local class"), + typeFirstDefinitions: true, + atoms: words("true false null"), + hooks: {"#": cppHook}, + modeProps: {fold: ["brace", "include"]} + }); + + // Ceylon Strings need to deal with interpolation + var stringTokenizer = null; + function tokenCeylonString(type) { + return function(stream, state) { + var escaped = false, next, end = false; + while (!stream.eol()) { + if (!escaped && stream.match('"') && + (type == "single" || stream.match('""'))) { + end = true; + break; + } + if (!escaped && stream.match('``')) { + stringTokenizer = tokenCeylonString(type); + end = true; + break; + } + next = stream.next(); + escaped = type == "single" && !escaped && next == "\\"; + } + if (end) + state.tokenize = null; + return "string"; + } + } + + def("text/x-ceylon", { + name: "clike", + keywords: words("abstracts alias assembly assert assign break case catch class continue dynamic else" + + " exists extends finally for function given if import in interface is let module new" + + " nonempty object of out outer package return satisfies super switch then this throw" + + " try value void while"), + types: function(word) { + // In Ceylon all identifiers that start with an uppercase are types + var first = word.charAt(0); + return (first === first.toUpperCase() && first !== first.toLowerCase()); + }, + blockKeywords: words("case catch class dynamic else finally for function if interface module new object switch try while"), + defKeywords: words("class dynamic function interface module object package value"), + builtin: words("abstract actual aliased annotation by default deprecated doc final formal late license" + + " native optional sealed see serializable shared suppressWarnings tagged throws variable"), + isPunctuationChar: /[\[\]{}\(\),;\:\.`]/, + isOperatorChar: /[+\-*&%=<>!?|^~:\/]/, + numberStart: /[\d#$]/, + number: /^(?:#[\da-fA-F_]+|\$[01_]+|[\d_]+[kMGTPmunpf]?|[\d_]+\.[\d_]+(?:[eE][-+]?\d+|[kMGTPmunpf]|)|)/i, + multiLineStrings: true, + typeFirstDefinitions: true, + atoms: words("true false null larger smaller equal empty finished"), + indentSwitch: false, + styleDefs: false, + hooks: { + "@": function(stream) { + stream.eatWhile(/[\w\$_]/); + return "meta"; + }, + '"': function(stream, state) { + state.tokenize = tokenCeylonString(stream.match('""') ? "triple" : "single"); + return state.tokenize(stream, state); + }, + '`': function(stream, state) { + if (!stringTokenizer || !stream.match('`')) return false; + state.tokenize = stringTokenizer; + stringTokenizer = null; + return state.tokenize(stream, state); + }, + "'": function(stream) { + stream.eatWhile(/[\w\$_\xa1-\uffff]/); + return "atom"; + }, + token: function(_stream, state, style) { + if ((style == "variable" || style == "type") && + state.prevToken == ".") { + return "variable-2"; + } + } + }, + modeProps: { + fold: ["brace", "import"], + closeBrackets: {triples: '"'} + } + }); + +}); diff --git a/adminx/assets/vendor/codemirror/mode/css/css.js b/adminx/assets/vendor/codemirror/mode/css/css.js new file mode 100644 index 0000000..05742c5 --- /dev/null +++ b/adminx/assets/vendor/codemirror/mode/css/css.js @@ -0,0 +1,831 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { +"use strict"; + +CodeMirror.defineMode("css", function(config, parserConfig) { + var inline = parserConfig.inline + if (!parserConfig.propertyKeywords) parserConfig = CodeMirror.resolveMode("text/css"); + + var indentUnit = config.indentUnit, + tokenHooks = parserConfig.tokenHooks, + documentTypes = parserConfig.documentTypes || {}, + mediaTypes = parserConfig.mediaTypes || {}, + mediaFeatures = parserConfig.mediaFeatures || {}, + mediaValueKeywords = parserConfig.mediaValueKeywords || {}, + propertyKeywords = parserConfig.propertyKeywords || {}, + nonStandardPropertyKeywords = parserConfig.nonStandardPropertyKeywords || {}, + fontProperties = parserConfig.fontProperties || {}, + counterDescriptors = parserConfig.counterDescriptors || {}, + colorKeywords = parserConfig.colorKeywords || {}, + valueKeywords = parserConfig.valueKeywords || {}, + allowNested = parserConfig.allowNested, + lineComment = parserConfig.lineComment, + supportsAtComponent = parserConfig.supportsAtComponent === true; + + var type, override; + function ret(style, tp) { type = tp; return style; } + + // Tokenizers + + function tokenBase(stream, state) { + var ch = stream.next(); + if (tokenHooks[ch]) { + var result = tokenHooks[ch](stream, state); + if (result !== false) return result; + } + if (ch == "@") { + stream.eatWhile(/[\w\\\-]/); + return ret("def", stream.current()); + } else if (ch == "=" || (ch == "~" || ch == "|") && stream.eat("=")) { + return ret(null, "compare"); + } else if (ch == "\"" || ch == "'") { + state.tokenize = tokenString(ch); + return state.tokenize(stream, state); + } else if (ch == "#") { + stream.eatWhile(/[\w\\\-]/); + return ret("atom", "hash"); + } else if (ch == "!") { + stream.match(/^\s*\w*/); + return ret("keyword", "important"); + } else if (/\d/.test(ch) || ch == "." && stream.eat(/\d/)) { + stream.eatWhile(/[\w.%]/); + return ret("number", "unit"); + } else if (ch === "-") { + if (/[\d.]/.test(stream.peek())) { + stream.eatWhile(/[\w.%]/); + return ret("number", "unit"); + } else if (stream.match(/^-[\w\\\-]*/)) { + stream.eatWhile(/[\w\\\-]/); + if (stream.match(/^\s*:/, false)) + return ret("variable-2", "variable-definition"); + return ret("variable-2", "variable"); + } else if (stream.match(/^\w+-/)) { + return ret("meta", "meta"); + } + } else if (/[,+>*\/]/.test(ch)) { + return ret(null, "select-op"); + } else if (ch == "." && stream.match(/^-?[_a-z][_a-z0-9-]*/i)) { + return ret("qualifier", "qualifier"); + } else if (/[:;{}\[\]\(\)]/.test(ch)) { + return ret(null, ch); + } else if (stream.match(/[\w-.]+(?=\()/)) { + if (/^(url(-prefix)?|domain|regexp)$/.test(stream.current().toLowerCase())) { + state.tokenize = tokenParenthesized; + } + return ret("variable callee", "variable"); + } else if (/[\w\\\-]/.test(ch)) { + stream.eatWhile(/[\w\\\-]/); + return ret("property", "word"); + } else { + return ret(null, null); + } + } + + function tokenString(quote) { + return function(stream, state) { + var escaped = false, ch; + while ((ch = stream.next()) != null) { + if (ch == quote && !escaped) { + if (quote == ")") stream.backUp(1); + break; + } + escaped = !escaped && ch == "\\"; + } + if (ch == quote || !escaped && quote != ")") state.tokenize = null; + return ret("string", "string"); + }; + } + + function tokenParenthesized(stream, state) { + stream.next(); // Must be '(' + if (!stream.match(/\s*[\"\')]/, false)) + state.tokenize = tokenString(")"); + else + state.tokenize = null; + return ret(null, "("); + } + + // Context management + + function Context(type, indent, prev) { + this.type = type; + this.indent = indent; + this.prev = prev; + } + + function pushContext(state, stream, type, indent) { + state.context = new Context(type, stream.indentation() + (indent === false ? 0 : indentUnit), state.context); + return type; + } + + function popContext(state) { + if (state.context.prev) + state.context = state.context.prev; + return state.context.type; + } + + function pass(type, stream, state) { + return states[state.context.type](type, stream, state); + } + function popAndPass(type, stream, state, n) { + for (var i = n || 1; i > 0; i--) + state.context = state.context.prev; + return pass(type, stream, state); + } + + // Parser + + function wordAsValue(stream) { + var word = stream.current().toLowerCase(); + if (valueKeywords.hasOwnProperty(word)) + override = "atom"; + else if (colorKeywords.hasOwnProperty(word)) + override = "keyword"; + else + override = "variable"; + } + + var states = {}; + + states.top = function(type, stream, state) { + if (type == "{") { + return pushContext(state, stream, "block"); + } else if (type == "}" && state.context.prev) { + return popContext(state); + } else if (supportsAtComponent && /@component/i.test(type)) { + return pushContext(state, stream, "atComponentBlock"); + } else if (/^@(-moz-)?document$/i.test(type)) { + return pushContext(state, stream, "documentTypes"); + } else if (/^@(media|supports|(-moz-)?document|import)$/i.test(type)) { + return pushContext(state, stream, "atBlock"); + } else if (/^@(font-face|counter-style)/i.test(type)) { + state.stateArg = type; + return "restricted_atBlock_before"; + } else if (/^@(-(moz|ms|o|webkit)-)?keyframes$/i.test(type)) { + return "keyframes"; + } else if (type && type.charAt(0) == "@") { + return pushContext(state, stream, "at"); + } else if (type == "hash") { + override = "builtin"; + } else if (type == "word") { + override = "tag"; + } else if (type == "variable-definition") { + return "maybeprop"; + } else if (type == "interpolation") { + return pushContext(state, stream, "interpolation"); + } else if (type == ":") { + return "pseudo"; + } else if (allowNested && type == "(") { + return pushContext(state, stream, "parens"); + } + return state.context.type; + }; + + states.block = function(type, stream, state) { + if (type == "word") { + var word = stream.current().toLowerCase(); + if (propertyKeywords.hasOwnProperty(word)) { + override = "property"; + return "maybeprop"; + } else if (nonStandardPropertyKeywords.hasOwnProperty(word)) { + override = "string-2"; + return "maybeprop"; + } else if (allowNested) { + override = stream.match(/^\s*:(?:\s|$)/, false) ? "property" : "tag"; + return "block"; + } else { + override += " error"; + return "maybeprop"; + } + } else if (type == "meta") { + return "block"; + } else if (!allowNested && (type == "hash" || type == "qualifier")) { + override = "error"; + return "block"; + } else { + return states.top(type, stream, state); + } + }; + + states.maybeprop = function(type, stream, state) { + if (type == ":") return pushContext(state, stream, "prop"); + return pass(type, stream, state); + }; + + states.prop = function(type, stream, state) { + if (type == ";") return popContext(state); + if (type == "{" && allowNested) return pushContext(state, stream, "propBlock"); + if (type == "}" || type == "{") return popAndPass(type, stream, state); + if (type == "(") return pushContext(state, stream, "parens"); + + if (type == "hash" && !/^#([0-9a-fA-f]{3,4}|[0-9a-fA-f]{6}|[0-9a-fA-f]{8})$/.test(stream.current())) { + override += " error"; + } else if (type == "word") { + wordAsValue(stream); + } else if (type == "interpolation") { + return pushContext(state, stream, "interpolation"); + } + return "prop"; + }; + + states.propBlock = function(type, _stream, state) { + if (type == "}") return popContext(state); + if (type == "word") { override = "property"; return "maybeprop"; } + return state.context.type; + }; + + states.parens = function(type, stream, state) { + if (type == "{" || type == "}") return popAndPass(type, stream, state); + if (type == ")") return popContext(state); + if (type == "(") return pushContext(state, stream, "parens"); + if (type == "interpolation") return pushContext(state, stream, "interpolation"); + if (type == "word") wordAsValue(stream); + return "parens"; + }; + + states.pseudo = function(type, stream, state) { + if (type == "meta") return "pseudo"; + + if (type == "word") { + override = "variable-3"; + return state.context.type; + } + return pass(type, stream, state); + }; + + states.documentTypes = function(type, stream, state) { + if (type == "word" && documentTypes.hasOwnProperty(stream.current())) { + override = "tag"; + return state.context.type; + } else { + return states.atBlock(type, stream, state); + } + }; + + states.atBlock = function(type, stream, state) { + if (type == "(") return pushContext(state, stream, "atBlock_parens"); + if (type == "}" || type == ";") return popAndPass(type, stream, state); + if (type == "{") return popContext(state) && pushContext(state, stream, allowNested ? "block" : "top"); + + if (type == "interpolation") return pushContext(state, stream, "interpolation"); + + if (type == "word") { + var word = stream.current().toLowerCase(); + if (word == "only" || word == "not" || word == "and" || word == "or") + override = "keyword"; + else if (mediaTypes.hasOwnProperty(word)) + override = "attribute"; + else if (mediaFeatures.hasOwnProperty(word)) + override = "property"; + else if (mediaValueKeywords.hasOwnProperty(word)) + override = "keyword"; + else if (propertyKeywords.hasOwnProperty(word)) + override = "property"; + else if (nonStandardPropertyKeywords.hasOwnProperty(word)) + override = "string-2"; + else if (valueKeywords.hasOwnProperty(word)) + override = "atom"; + else if (colorKeywords.hasOwnProperty(word)) + override = "keyword"; + else + override = "error"; + } + return state.context.type; + }; + + states.atComponentBlock = function(type, stream, state) { + if (type == "}") + return popAndPass(type, stream, state); + if (type == "{") + return popContext(state) && pushContext(state, stream, allowNested ? "block" : "top", false); + if (type == "word") + override = "error"; + return state.context.type; + }; + + states.atBlock_parens = function(type, stream, state) { + if (type == ")") return popContext(state); + if (type == "{" || type == "}") return popAndPass(type, stream, state, 2); + return states.atBlock(type, stream, state); + }; + + states.restricted_atBlock_before = function(type, stream, state) { + if (type == "{") + return pushContext(state, stream, "restricted_atBlock"); + if (type == "word" && state.stateArg == "@counter-style") { + override = "variable"; + return "restricted_atBlock_before"; + } + return pass(type, stream, state); + }; + + states.restricted_atBlock = function(type, stream, state) { + if (type == "}") { + state.stateArg = null; + return popContext(state); + } + if (type == "word") { + if ((state.stateArg == "@font-face" && !fontProperties.hasOwnProperty(stream.current().toLowerCase())) || + (state.stateArg == "@counter-style" && !counterDescriptors.hasOwnProperty(stream.current().toLowerCase()))) + override = "error"; + else + override = "property"; + return "maybeprop"; + } + return "restricted_atBlock"; + }; + + states.keyframes = function(type, stream, state) { + if (type == "word") { override = "variable"; return "keyframes"; } + if (type == "{") return pushContext(state, stream, "top"); + return pass(type, stream, state); + }; + + states.at = function(type, stream, state) { + if (type == ";") return popContext(state); + if (type == "{" || type == "}") return popAndPass(type, stream, state); + if (type == "word") override = "tag"; + else if (type == "hash") override = "builtin"; + return "at"; + }; + + states.interpolation = function(type, stream, state) { + if (type == "}") return popContext(state); + if (type == "{" || type == ";") return popAndPass(type, stream, state); + if (type == "word") override = "variable"; + else if (type != "variable" && type != "(" && type != ")") override = "error"; + return "interpolation"; + }; + + return { + startState: function(base) { + return {tokenize: null, + state: inline ? "block" : "top", + stateArg: null, + context: new Context(inline ? "block" : "top", base || 0, null)}; + }, + + token: function(stream, state) { + if (!state.tokenize && stream.eatSpace()) return null; + var style = (state.tokenize || tokenBase)(stream, state); + if (style && typeof style == "object") { + type = style[1]; + style = style[0]; + } + override = style; + if (type != "comment") + state.state = states[state.state](type, stream, state); + return override; + }, + + indent: function(state, textAfter) { + var cx = state.context, ch = textAfter && textAfter.charAt(0); + var indent = cx.indent; + if (cx.type == "prop" && (ch == "}" || ch == ")")) cx = cx.prev; + if (cx.prev) { + if (ch == "}" && (cx.type == "block" || cx.type == "top" || + cx.type == "interpolation" || cx.type == "restricted_atBlock")) { + // Resume indentation from parent context. + cx = cx.prev; + indent = cx.indent; + } else if (ch == ")" && (cx.type == "parens" || cx.type == "atBlock_parens") || + ch == "{" && (cx.type == "at" || cx.type == "atBlock")) { + // Dedent relative to current context. + indent = Math.max(0, cx.indent - indentUnit); + } + } + return indent; + }, + + electricChars: "}", + blockCommentStart: "/*", + blockCommentEnd: "*/", + blockCommentContinue: " * ", + lineComment: lineComment, + fold: "brace" + }; +}); + + function keySet(array) { + var keys = {}; + for (var i = 0; i < array.length; ++i) { + keys[array[i].toLowerCase()] = true; + } + return keys; + } + + var documentTypes_ = [ + "domain", "regexp", "url", "url-prefix" + ], documentTypes = keySet(documentTypes_); + + var mediaTypes_ = [ + "all", "aural", "braille", "handheld", "print", "projection", "screen", + "tty", "tv", "embossed" + ], mediaTypes = keySet(mediaTypes_); + + var mediaFeatures_ = [ + "width", "min-width", "max-width", "height", "min-height", "max-height", + "device-width", "min-device-width", "max-device-width", "device-height", + "min-device-height", "max-device-height", "aspect-ratio", + "min-aspect-ratio", "max-aspect-ratio", "device-aspect-ratio", + "min-device-aspect-ratio", "max-device-aspect-ratio", "color", "min-color", + "max-color", "color-index", "min-color-index", "max-color-index", + "monochrome", "min-monochrome", "max-monochrome", "resolution", + "min-resolution", "max-resolution", "scan", "grid", "orientation", + "device-pixel-ratio", "min-device-pixel-ratio", "max-device-pixel-ratio", + "pointer", "any-pointer", "hover", "any-hover" + ], mediaFeatures = keySet(mediaFeatures_); + + var mediaValueKeywords_ = [ + "landscape", "portrait", "none", "coarse", "fine", "on-demand", "hover", + "interlace", "progressive" + ], mediaValueKeywords = keySet(mediaValueKeywords_); + + var propertyKeywords_ = [ + "align-content", "align-items", "align-self", "alignment-adjust", + "alignment-baseline", "anchor-point", "animation", "animation-delay", + "animation-direction", "animation-duration", "animation-fill-mode", + "animation-iteration-count", "animation-name", "animation-play-state", + "animation-timing-function", "appearance", "azimuth", "backface-visibility", + "background", "background-attachment", "background-blend-mode", "background-clip", + "background-color", "background-image", "background-origin", "background-position", + "background-repeat", "background-size", "baseline-shift", "binding", + "bleed", "bookmark-label", "bookmark-level", "bookmark-state", + "bookmark-target", "border", "border-bottom", "border-bottom-color", + "border-bottom-left-radius", "border-bottom-right-radius", + "border-bottom-style", "border-bottom-width", "border-collapse", + "border-color", "border-image", "border-image-outset", + "border-image-repeat", "border-image-slice", "border-image-source", + "border-image-width", "border-left", "border-left-color", + "border-left-style", "border-left-width", "border-radius", "border-right", + "border-right-color", "border-right-style", "border-right-width", + "border-spacing", "border-style", "border-top", "border-top-color", + "border-top-left-radius", "border-top-right-radius", "border-top-style", + "border-top-width", "border-width", "bottom", "box-decoration-break", + "box-shadow", "box-sizing", "break-after", "break-before", "break-inside", + "caption-side", "caret-color", "clear", "clip", "color", "color-profile", "column-count", + "column-fill", "column-gap", "column-rule", "column-rule-color", + "column-rule-style", "column-rule-width", "column-span", "column-width", + "columns", "content", "counter-increment", "counter-reset", "crop", "cue", + "cue-after", "cue-before", "cursor", "direction", "display", + "dominant-baseline", "drop-initial-after-adjust", + "drop-initial-after-align", "drop-initial-before-adjust", + "drop-initial-before-align", "drop-initial-size", "drop-initial-value", + "elevation", "empty-cells", "fit", "fit-position", "flex", "flex-basis", + "flex-direction", "flex-flow", "flex-grow", "flex-shrink", "flex-wrap", + "float", "float-offset", "flow-from", "flow-into", "font", "font-feature-settings", + "font-family", "font-kerning", "font-language-override", "font-size", "font-size-adjust", + "font-stretch", "font-style", "font-synthesis", "font-variant", + "font-variant-alternates", "font-variant-caps", "font-variant-east-asian", + "font-variant-ligatures", "font-variant-numeric", "font-variant-position", + "font-weight", "grid", "grid-area", "grid-auto-columns", "grid-auto-flow", + "grid-auto-rows", "grid-column", "grid-column-end", "grid-column-gap", + "grid-column-start", "grid-gap", "grid-row", "grid-row-end", "grid-row-gap", + "grid-row-start", "grid-template", "grid-template-areas", "grid-template-columns", + "grid-template-rows", "hanging-punctuation", "height", "hyphens", + "icon", "image-orientation", "image-rendering", "image-resolution", + "inline-box-align", "justify-content", "justify-items", "justify-self", "left", "letter-spacing", + "line-break", "line-height", "line-stacking", "line-stacking-ruby", + "line-stacking-shift", "line-stacking-strategy", "list-style", + "list-style-image", "list-style-position", "list-style-type", "margin", + "margin-bottom", "margin-left", "margin-right", "margin-top", + "marks", "marquee-direction", "marquee-loop", + "marquee-play-count", "marquee-speed", "marquee-style", "max-height", + "max-width", "min-height", "min-width", "mix-blend-mode", "move-to", "nav-down", "nav-index", + "nav-left", "nav-right", "nav-up", "object-fit", "object-position", + "opacity", "order", "orphans", "outline", + "outline-color", "outline-offset", "outline-style", "outline-width", + "overflow", "overflow-style", "overflow-wrap", "overflow-x", "overflow-y", + "padding", "padding-bottom", "padding-left", "padding-right", "padding-top", + "page", "page-break-after", "page-break-before", "page-break-inside", + "page-policy", "pause", "pause-after", "pause-before", "perspective", + "perspective-origin", "pitch", "pitch-range", "place-content", "place-items", "place-self", "play-during", "position", + "presentation-level", "punctuation-trim", "quotes", "region-break-after", + "region-break-before", "region-break-inside", "region-fragment", + "rendering-intent", "resize", "rest", "rest-after", "rest-before", "richness", + "right", "rotation", "rotation-point", "ruby-align", "ruby-overhang", + "ruby-position", "ruby-span", "shape-image-threshold", "shape-inside", "shape-margin", + "shape-outside", "size", "speak", "speak-as", "speak-header", + "speak-numeral", "speak-punctuation", "speech-rate", "stress", "string-set", + "tab-size", "table-layout", "target", "target-name", "target-new", + "target-position", "text-align", "text-align-last", "text-decoration", + "text-decoration-color", "text-decoration-line", "text-decoration-skip", + "text-decoration-style", "text-emphasis", "text-emphasis-color", + "text-emphasis-position", "text-emphasis-style", "text-height", + "text-indent", "text-justify", "text-outline", "text-overflow", "text-shadow", + "text-size-adjust", "text-space-collapse", "text-transform", "text-underline-position", + "text-wrap", "top", "transform", "transform-origin", "transform-style", + "transition", "transition-delay", "transition-duration", + "transition-property", "transition-timing-function", "unicode-bidi", + "user-select", "vertical-align", "visibility", "voice-balance", "voice-duration", + "voice-family", "voice-pitch", "voice-range", "voice-rate", "voice-stress", + "voice-volume", "volume", "white-space", "widows", "width", "will-change", "word-break", + "word-spacing", "word-wrap", "z-index", + // SVG-specific + "clip-path", "clip-rule", "mask", "enable-background", "filter", "flood-color", + "flood-opacity", "lighting-color", "stop-color", "stop-opacity", "pointer-events", + "color-interpolation", "color-interpolation-filters", + "color-rendering", "fill", "fill-opacity", "fill-rule", "image-rendering", + "marker", "marker-end", "marker-mid", "marker-start", "shape-rendering", "stroke", + "stroke-dasharray", "stroke-dashoffset", "stroke-linecap", "stroke-linejoin", + "stroke-miterlimit", "stroke-opacity", "stroke-width", "text-rendering", + "baseline-shift", "dominant-baseline", "glyph-orientation-horizontal", + "glyph-orientation-vertical", "text-anchor", "writing-mode" + ], propertyKeywords = keySet(propertyKeywords_); + + var nonStandardPropertyKeywords_ = [ + "scrollbar-arrow-color", "scrollbar-base-color", "scrollbar-dark-shadow-color", + "scrollbar-face-color", "scrollbar-highlight-color", "scrollbar-shadow-color", + "scrollbar-3d-light-color", "scrollbar-track-color", "shape-inside", + "searchfield-cancel-button", "searchfield-decoration", "searchfield-results-button", + "searchfield-results-decoration", "zoom" + ], nonStandardPropertyKeywords = keySet(nonStandardPropertyKeywords_); + + var fontProperties_ = [ + "font-family", "src", "unicode-range", "font-variant", "font-feature-settings", + "font-stretch", "font-weight", "font-style" + ], fontProperties = keySet(fontProperties_); + + var counterDescriptors_ = [ + "additive-symbols", "fallback", "negative", "pad", "prefix", "range", + "speak-as", "suffix", "symbols", "system" + ], counterDescriptors = keySet(counterDescriptors_); + + var colorKeywords_ = [ + "aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige", + "bisque", "black", "blanchedalmond", "blue", "blueviolet", "brown", + "burlywood", "cadetblue", "chartreuse", "chocolate", "coral", "cornflowerblue", + "cornsilk", "crimson", "cyan", "darkblue", "darkcyan", "darkgoldenrod", + "darkgray", "darkgreen", "darkkhaki", "darkmagenta", "darkolivegreen", + "darkorange", "darkorchid", "darkred", "darksalmon", "darkseagreen", + "darkslateblue", "darkslategray", "darkturquoise", "darkviolet", + "deeppink", "deepskyblue", "dimgray", "dodgerblue", "firebrick", + "floralwhite", "forestgreen", "fuchsia", "gainsboro", "ghostwhite", + "gold", "goldenrod", "gray", "grey", "green", "greenyellow", "honeydew", + "hotpink", "indianred", "indigo", "ivory", "khaki", "lavender", + "lavenderblush", "lawngreen", "lemonchiffon", "lightblue", "lightcoral", + "lightcyan", "lightgoldenrodyellow", "lightgray", "lightgreen", "lightpink", + "lightsalmon", "lightseagreen", "lightskyblue", "lightslategray", + "lightsteelblue", "lightyellow", "lime", "limegreen", "linen", "magenta", + "maroon", "mediumaquamarine", "mediumblue", "mediumorchid", "mediumpurple", + "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise", + "mediumvioletred", "midnightblue", "mintcream", "mistyrose", "moccasin", + "navajowhite", "navy", "oldlace", "olive", "olivedrab", "orange", "orangered", + "orchid", "palegoldenrod", "palegreen", "paleturquoise", "palevioletred", + "papayawhip", "peachpuff", "peru", "pink", "plum", "powderblue", + "purple", "rebeccapurple", "red", "rosybrown", "royalblue", "saddlebrown", + "salmon", "sandybrown", "seagreen", "seashell", "sienna", "silver", "skyblue", + "slateblue", "slategray", "snow", "springgreen", "steelblue", "tan", + "teal", "thistle", "tomato", "turquoise", "violet", "wheat", "white", + "whitesmoke", "yellow", "yellowgreen" + ], colorKeywords = keySet(colorKeywords_); + + var valueKeywords_ = [ + "above", "absolute", "activeborder", "additive", "activecaption", "afar", + "after-white-space", "ahead", "alias", "all", "all-scroll", "alphabetic", "alternate", + "always", "amharic", "amharic-abegede", "antialiased", "appworkspace", + "arabic-indic", "armenian", "asterisks", "attr", "auto", "auto-flow", "avoid", "avoid-column", "avoid-page", + "avoid-region", "background", "backwards", "baseline", "below", "bidi-override", "binary", + "bengali", "blink", "block", "block-axis", "bold", "bolder", "border", "border-box", + "both", "bottom", "break", "break-all", "break-word", "bullets", "button", "button-bevel", + "buttonface", "buttonhighlight", "buttonshadow", "buttontext", "calc", "cambodian", + "capitalize", "caps-lock-indicator", "caption", "captiontext", "caret", + "cell", "center", "checkbox", "circle", "cjk-decimal", "cjk-earthly-branch", + "cjk-heavenly-stem", "cjk-ideographic", "clear", "clip", "close-quote", + "col-resize", "collapse", "color", "color-burn", "color-dodge", "column", "column-reverse", + "compact", "condensed", "contain", "content", "contents", + "content-box", "context-menu", "continuous", "copy", "counter", "counters", "cover", "crop", + "cross", "crosshair", "currentcolor", "cursive", "cyclic", "darken", "dashed", "decimal", + "decimal-leading-zero", "default", "default-button", "dense", "destination-atop", + "destination-in", "destination-out", "destination-over", "devanagari", "difference", + "disc", "discard", "disclosure-closed", "disclosure-open", "document", + "dot-dash", "dot-dot-dash", + "dotted", "double", "down", "e-resize", "ease", "ease-in", "ease-in-out", "ease-out", + "element", "ellipse", "ellipsis", "embed", "end", "ethiopic", "ethiopic-abegede", + "ethiopic-abegede-am-et", "ethiopic-abegede-gez", "ethiopic-abegede-ti-er", + "ethiopic-abegede-ti-et", "ethiopic-halehame-aa-er", + "ethiopic-halehame-aa-et", "ethiopic-halehame-am-et", + "ethiopic-halehame-gez", "ethiopic-halehame-om-et", + "ethiopic-halehame-sid-et", "ethiopic-halehame-so-et", + "ethiopic-halehame-ti-er", "ethiopic-halehame-ti-et", "ethiopic-halehame-tig", + "ethiopic-numeric", "ew-resize", "exclusion", "expanded", "extends", "extra-condensed", + "extra-expanded", "fantasy", "fast", "fill", "fixed", "flat", "flex", "flex-end", "flex-start", "footnotes", + "forwards", "from", "geometricPrecision", "georgian", "graytext", "grid", "groove", + "gujarati", "gurmukhi", "hand", "hangul", "hangul-consonant", "hard-light", "hebrew", + "help", "hidden", "hide", "higher", "highlight", "highlighttext", + "hiragana", "hiragana-iroha", "horizontal", "hsl", "hsla", "hue", "icon", "ignore", + "inactiveborder", "inactivecaption", "inactivecaptiontext", "infinite", + "infobackground", "infotext", "inherit", "initial", "inline", "inline-axis", + "inline-block", "inline-flex", "inline-grid", "inline-table", "inset", "inside", "intrinsic", "invert", + "italic", "japanese-formal", "japanese-informal", "justify", "kannada", + "katakana", "katakana-iroha", "keep-all", "khmer", + "korean-hangul-formal", "korean-hanja-formal", "korean-hanja-informal", + "landscape", "lao", "large", "larger", "left", "level", "lighter", "lighten", + "line-through", "linear", "linear-gradient", "lines", "list-item", "listbox", "listitem", + "local", "logical", "loud", "lower", "lower-alpha", "lower-armenian", + "lower-greek", "lower-hexadecimal", "lower-latin", "lower-norwegian", + "lower-roman", "lowercase", "ltr", "luminosity", "malayalam", "match", "matrix", "matrix3d", + "media-controls-background", "media-current-time-display", + "media-fullscreen-button", "media-mute-button", "media-play-button", + "media-return-to-realtime-button", "media-rewind-button", + "media-seek-back-button", "media-seek-forward-button", "media-slider", + "media-sliderthumb", "media-time-remaining-display", "media-volume-slider", + "media-volume-slider-container", "media-volume-sliderthumb", "medium", + "menu", "menulist", "menulist-button", "menulist-text", + "menulist-textfield", "menutext", "message-box", "middle", "min-intrinsic", + "mix", "mongolian", "monospace", "move", "multiple", "multiply", "myanmar", "n-resize", + "narrower", "ne-resize", "nesw-resize", "no-close-quote", "no-drop", + "no-open-quote", "no-repeat", "none", "normal", "not-allowed", "nowrap", + "ns-resize", "numbers", "numeric", "nw-resize", "nwse-resize", "oblique", "octal", "opacity", "open-quote", + "optimizeLegibility", "optimizeSpeed", "oriya", "oromo", "outset", + "outside", "outside-shape", "overlay", "overline", "padding", "padding-box", + "painted", "page", "paused", "persian", "perspective", "plus-darker", "plus-lighter", + "pointer", "polygon", "portrait", "pre", "pre-line", "pre-wrap", "preserve-3d", + "progress", "push-button", "radial-gradient", "radio", "read-only", + "read-write", "read-write-plaintext-only", "rectangle", "region", + "relative", "repeat", "repeating-linear-gradient", + "repeating-radial-gradient", "repeat-x", "repeat-y", "reset", "reverse", + "rgb", "rgba", "ridge", "right", "rotate", "rotate3d", "rotateX", "rotateY", + "rotateZ", "round", "row", "row-resize", "row-reverse", "rtl", "run-in", "running", + "s-resize", "sans-serif", "saturation", "scale", "scale3d", "scaleX", "scaleY", "scaleZ", "screen", + "scroll", "scrollbar", "scroll-position", "se-resize", "searchfield", + "searchfield-cancel-button", "searchfield-decoration", + "searchfield-results-button", "searchfield-results-decoration", "self-start", "self-end", + "semi-condensed", "semi-expanded", "separate", "serif", "show", "sidama", + "simp-chinese-formal", "simp-chinese-informal", "single", + "skew", "skewX", "skewY", "skip-white-space", "slide", "slider-horizontal", + "slider-vertical", "sliderthumb-horizontal", "sliderthumb-vertical", "slow", + "small", "small-caps", "small-caption", "smaller", "soft-light", "solid", "somali", + "source-atop", "source-in", "source-out", "source-over", "space", "space-around", "space-between", "space-evenly", "spell-out", "square", + "square-button", "start", "static", "status-bar", "stretch", "stroke", "sub", + "subpixel-antialiased", "super", "sw-resize", "symbolic", "symbols", "system-ui", "table", + "table-caption", "table-cell", "table-column", "table-column-group", + "table-footer-group", "table-header-group", "table-row", "table-row-group", + "tamil", + "telugu", "text", "text-bottom", "text-top", "textarea", "textfield", "thai", + "thick", "thin", "threeddarkshadow", "threedface", "threedhighlight", + "threedlightshadow", "threedshadow", "tibetan", "tigre", "tigrinya-er", + "tigrinya-er-abegede", "tigrinya-et", "tigrinya-et-abegede", "to", "top", + "trad-chinese-formal", "trad-chinese-informal", "transform", + "translate", "translate3d", "translateX", "translateY", "translateZ", + "transparent", "ultra-condensed", "ultra-expanded", "underline", "unset", "up", + "upper-alpha", "upper-armenian", "upper-greek", "upper-hexadecimal", + "upper-latin", "upper-norwegian", "upper-roman", "uppercase", "urdu", "url", + "var", "vertical", "vertical-text", "visible", "visibleFill", "visiblePainted", + "visibleStroke", "visual", "w-resize", "wait", "wave", "wider", + "window", "windowframe", "windowtext", "words", "wrap", "wrap-reverse", "x-large", "x-small", "xor", + "xx-large", "xx-small" + ], valueKeywords = keySet(valueKeywords_); + + var allWords = documentTypes_.concat(mediaTypes_).concat(mediaFeatures_).concat(mediaValueKeywords_) + .concat(propertyKeywords_).concat(nonStandardPropertyKeywords_).concat(colorKeywords_) + .concat(valueKeywords_); + CodeMirror.registerHelper("hintWords", "css", allWords); + + function tokenCComment(stream, state) { + var maybeEnd = false, ch; + while ((ch = stream.next()) != null) { + if (maybeEnd && ch == "/") { + state.tokenize = null; + break; + } + maybeEnd = (ch == "*"); + } + return ["comment", "comment"]; + } + + CodeMirror.defineMIME("text/css", { + documentTypes: documentTypes, + mediaTypes: mediaTypes, + mediaFeatures: mediaFeatures, + mediaValueKeywords: mediaValueKeywords, + propertyKeywords: propertyKeywords, + nonStandardPropertyKeywords: nonStandardPropertyKeywords, + fontProperties: fontProperties, + counterDescriptors: counterDescriptors, + colorKeywords: colorKeywords, + valueKeywords: valueKeywords, + tokenHooks: { + "/": function(stream, state) { + if (!stream.eat("*")) return false; + state.tokenize = tokenCComment; + return tokenCComment(stream, state); + } + }, + name: "css" + }); + + CodeMirror.defineMIME("text/x-scss", { + mediaTypes: mediaTypes, + mediaFeatures: mediaFeatures, + mediaValueKeywords: mediaValueKeywords, + propertyKeywords: propertyKeywords, + nonStandardPropertyKeywords: nonStandardPropertyKeywords, + colorKeywords: colorKeywords, + valueKeywords: valueKeywords, + fontProperties: fontProperties, + allowNested: true, + lineComment: "//", + tokenHooks: { + "/": function(stream, state) { + if (stream.eat("/")) { + stream.skipToEnd(); + return ["comment", "comment"]; + } else if (stream.eat("*")) { + state.tokenize = tokenCComment; + return tokenCComment(stream, state); + } else { + return ["operator", "operator"]; + } + }, + ":": function(stream) { + if (stream.match(/\s*\{/, false)) + return [null, null] + return false; + }, + "$": function(stream) { + stream.match(/^[\w-]+/); + if (stream.match(/^\s*:/, false)) + return ["variable-2", "variable-definition"]; + return ["variable-2", "variable"]; + }, + "#": function(stream) { + if (!stream.eat("{")) return false; + return [null, "interpolation"]; + } + }, + name: "css", + helperType: "scss" + }); + + CodeMirror.defineMIME("text/x-less", { + mediaTypes: mediaTypes, + mediaFeatures: mediaFeatures, + mediaValueKeywords: mediaValueKeywords, + propertyKeywords: propertyKeywords, + nonStandardPropertyKeywords: nonStandardPropertyKeywords, + colorKeywords: colorKeywords, + valueKeywords: valueKeywords, + fontProperties: fontProperties, + allowNested: true, + lineComment: "//", + tokenHooks: { + "/": function(stream, state) { + if (stream.eat("/")) { + stream.skipToEnd(); + return ["comment", "comment"]; + } else if (stream.eat("*")) { + state.tokenize = tokenCComment; + return tokenCComment(stream, state); + } else { + return ["operator", "operator"]; + } + }, + "@": function(stream) { + if (stream.eat("{")) return [null, "interpolation"]; + if (stream.match(/^(charset|document|font-face|import|(-(moz|ms|o|webkit)-)?keyframes|media|namespace|page|supports)\b/i, false)) return false; + stream.eatWhile(/[\w\\\-]/); + if (stream.match(/^\s*:/, false)) + return ["variable-2", "variable-definition"]; + return ["variable-2", "variable"]; + }, + "&": function() { + return ["atom", "atom"]; + } + }, + name: "css", + helperType: "less" + }); + + CodeMirror.defineMIME("text/x-gss", { + documentTypes: documentTypes, + mediaTypes: mediaTypes, + mediaFeatures: mediaFeatures, + propertyKeywords: propertyKeywords, + nonStandardPropertyKeywords: nonStandardPropertyKeywords, + fontProperties: fontProperties, + counterDescriptors: counterDescriptors, + colorKeywords: colorKeywords, + valueKeywords: valueKeywords, + supportsAtComponent: true, + tokenHooks: { + "/": function(stream, state) { + if (!stream.eat("*")) return false; + state.tokenize = tokenCComment; + return tokenCComment(stream, state); + } + }, + name: "css", + helperType: "gss" + }); + +}); diff --git a/adminx/assets/vendor/codemirror/mode/htmlmixed/htmlmixed.js b/adminx/assets/vendor/codemirror/mode/htmlmixed/htmlmixed.js new file mode 100644 index 0000000..8341ac8 --- /dev/null +++ b/adminx/assets/vendor/codemirror/mode/htmlmixed/htmlmixed.js @@ -0,0 +1,152 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror"), require("../xml/xml"), require("../javascript/javascript"), require("../css/css")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror", "../xml/xml", "../javascript/javascript", "../css/css"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { + "use strict"; + + var defaultTags = { + script: [ + ["lang", /(javascript|babel)/i, "javascript"], + ["type", /^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i, "javascript"], + ["type", /./, "text/plain"], + [null, null, "javascript"] + ], + style: [ + ["lang", /^css$/i, "css"], + ["type", /^(text\/)?(x-)?(stylesheet|css)$/i, "css"], + ["type", /./, "text/plain"], + [null, null, "css"] + ] + }; + + function maybeBackup(stream, pat, style) { + var cur = stream.current(), close = cur.search(pat); + if (close > -1) { + stream.backUp(cur.length - close); + } else if (cur.match(/<\/?$/)) { + stream.backUp(cur.length); + if (!stream.match(pat, false)) stream.match(cur); + } + return style; + } + + var attrRegexpCache = {}; + function getAttrRegexp(attr) { + var regexp = attrRegexpCache[attr]; + if (regexp) return regexp; + return attrRegexpCache[attr] = new RegExp("\\s+" + attr + "\\s*=\\s*('|\")?([^'\"]+)('|\")?\\s*"); + } + + function getAttrValue(text, attr) { + var match = text.match(getAttrRegexp(attr)) + return match ? /^\s*(.*?)\s*$/.exec(match[2])[1] : "" + } + + function getTagRegexp(tagName, anchored) { + return new RegExp((anchored ? "^" : "") + "<\/\s*" + tagName + "\s*>", "i"); + } + + function addTags(from, to) { + for (var tag in from) { + var dest = to[tag] || (to[tag] = []); + var source = from[tag]; + for (var i = source.length - 1; i >= 0; i--) + dest.unshift(source[i]) + } + } + + function findMatchingMode(tagInfo, tagText) { + for (var i = 0; i < tagInfo.length; i++) { + var spec = tagInfo[i]; + if (!spec[0] || spec[1].test(getAttrValue(tagText, spec[0]))) return spec[2]; + } + } + + CodeMirror.defineMode("htmlmixed", function (config, parserConfig) { + var htmlMode = CodeMirror.getMode(config, { + name: "xml", + htmlMode: true, + multilineTagIndentFactor: parserConfig.multilineTagIndentFactor, + multilineTagIndentPastTag: parserConfig.multilineTagIndentPastTag + }); + + var tags = {}; + var configTags = parserConfig && parserConfig.tags, configScript = parserConfig && parserConfig.scriptTypes; + addTags(defaultTags, tags); + if (configTags) addTags(configTags, tags); + if (configScript) for (var i = configScript.length - 1; i >= 0; i--) + tags.script.unshift(["type", configScript[i].matches, configScript[i].mode]) + + function html(stream, state) { + var style = htmlMode.token(stream, state.htmlState), tag = /\btag\b/.test(style), tagName + if (tag && !/[<>\s\/]/.test(stream.current()) && + (tagName = state.htmlState.tagName && state.htmlState.tagName.toLowerCase()) && + tags.hasOwnProperty(tagName)) { + state.inTag = tagName + " " + } else if (state.inTag && tag && />$/.test(stream.current())) { + var inTag = /^([\S]+) (.*)/.exec(state.inTag) + state.inTag = null + var modeSpec = stream.current() == ">" && findMatchingMode(tags[inTag[1]], inTag[2]) + var mode = CodeMirror.getMode(config, modeSpec) + var endTagA = getTagRegexp(inTag[1], true), endTag = getTagRegexp(inTag[1], false); + state.token = function (stream, state) { + if (stream.match(endTagA, false)) { + state.token = html; + state.localState = state.localMode = null; + return null; + } + return maybeBackup(stream, endTag, state.localMode.token(stream, state.localState)); + }; + state.localMode = mode; + state.localState = CodeMirror.startState(mode, htmlMode.indent(state.htmlState, "", "")); + } else if (state.inTag) { + state.inTag += stream.current() + if (stream.eol()) state.inTag += " " + } + return style; + }; + + return { + startState: function () { + var state = CodeMirror.startState(htmlMode); + return {token: html, inTag: null, localMode: null, localState: null, htmlState: state}; + }, + + copyState: function (state) { + var local; + if (state.localState) { + local = CodeMirror.copyState(state.localMode, state.localState); + } + return {token: state.token, inTag: state.inTag, + localMode: state.localMode, localState: local, + htmlState: CodeMirror.copyState(htmlMode, state.htmlState)}; + }, + + token: function (stream, state) { + return state.token(stream, state); + }, + + indent: function (state, textAfter, line) { + if (!state.localMode || /^\s*<\//.test(textAfter)) + return htmlMode.indent(state.htmlState, textAfter, line); + else if (state.localMode.indent) + return state.localMode.indent(state.localState, textAfter, line); + else + return CodeMirror.Pass; + }, + + innerMode: function (state) { + return {state: state.localState || state.htmlState, mode: state.localMode || htmlMode}; + } + }; + }, "xml", "javascript", "css"); + + CodeMirror.defineMIME("text/html", "htmlmixed"); +}); diff --git a/adminx/assets/vendor/codemirror/mode/javascript/javascript.js b/adminx/assets/vendor/codemirror/mode/javascript/javascript.js new file mode 100644 index 0000000..16943a9 --- /dev/null +++ b/adminx/assets/vendor/codemirror/mode/javascript/javascript.js @@ -0,0 +1,930 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { +"use strict"; + +CodeMirror.defineMode("javascript", function(config, parserConfig) { + var indentUnit = config.indentUnit; + var statementIndent = parserConfig.statementIndent; + var jsonldMode = parserConfig.jsonld; + var jsonMode = parserConfig.json || jsonldMode; + var isTS = parserConfig.typescript; + var wordRE = parserConfig.wordCharacters || /[\w$\xa1-\uffff]/; + + // Tokenizer + + var keywords = function(){ + function kw(type) {return {type: type, style: "keyword"};} + var A = kw("keyword a"), B = kw("keyword b"), C = kw("keyword c"), D = kw("keyword d"); + var operator = kw("operator"), atom = {type: "atom", style: "atom"}; + + return { + "if": kw("if"), "while": A, "with": A, "else": B, "do": B, "try": B, "finally": B, + "return": D, "break": D, "continue": D, "new": kw("new"), "delete": C, "void": C, "throw": C, + "debugger": kw("debugger"), "var": kw("var"), "const": kw("var"), "let": kw("var"), + "function": kw("function"), "catch": kw("catch"), + "for": kw("for"), "switch": kw("switch"), "case": kw("case"), "default": kw("default"), + "in": operator, "typeof": operator, "instanceof": operator, + "true": atom, "false": atom, "null": atom, "undefined": atom, "NaN": atom, "Infinity": atom, + "this": kw("this"), "class": kw("class"), "super": kw("atom"), + "yield": C, "export": kw("export"), "import": kw("import"), "extends": C, + "await": C + }; + }(); + + var isOperatorChar = /[+\-*&%=<>!?|~^@]/; + var isJsonldKeyword = /^@(context|id|value|language|type|container|list|set|reverse|index|base|vocab|graph)"/; + + function readRegexp(stream) { + var escaped = false, next, inSet = false; + while ((next = stream.next()) != null) { + if (!escaped) { + if (next == "/" && !inSet) return; + if (next == "[") inSet = true; + else if (inSet && next == "]") inSet = false; + } + escaped = !escaped && next == "\\"; + } + } + + // Used as scratch variables to communicate multiple values without + // consing up tons of objects. + var type, content; + function ret(tp, style, cont) { + type = tp; content = cont; + return style; + } + function tokenBase(stream, state) { + var ch = stream.next(); + if (ch == '"' || ch == "'") { + state.tokenize = tokenString(ch); + return state.tokenize(stream, state); + } else if (ch == "." && stream.match(/^\d[\d_]*(?:[eE][+\-]?[\d_]+)?/)) { + return ret("number", "number"); + } else if (ch == "." && stream.match("..")) { + return ret("spread", "meta"); + } else if (/[\[\]{}\(\),;\:\.]/.test(ch)) { + return ret(ch); + } else if (ch == "=" && stream.eat(">")) { + return ret("=>", "operator"); + } else if (ch == "0" && stream.match(/^(?:x[\dA-Fa-f_]+|o[0-7_]+|b[01_]+)n?/)) { + return ret("number", "number"); + } else if (/\d/.test(ch)) { + stream.match(/^[\d_]*(?:n|(?:\.[\d_]*)?(?:[eE][+\-]?[\d_]+)?)?/); + return ret("number", "number"); + } else if (ch == "/") { + if (stream.eat("*")) { + state.tokenize = tokenComment; + return tokenComment(stream, state); + } else if (stream.eat("/")) { + stream.skipToEnd(); + return ret("comment", "comment"); + } else if (expressionAllowed(stream, state, 1)) { + readRegexp(stream); + stream.match(/^\b(([gimyus])(?![gimyus]*\2))+\b/); + return ret("regexp", "string-2"); + } else { + stream.eat("="); + return ret("operator", "operator", stream.current()); + } + } else if (ch == "`") { + state.tokenize = tokenQuasi; + return tokenQuasi(stream, state); + } else if (ch == "#") { + stream.skipToEnd(); + return ret("error", "error"); + } else if (ch == "<" && stream.match("!--") || ch == "-" && stream.match("->")) { + stream.skipToEnd() + return ret("comment", "comment") + } else if (isOperatorChar.test(ch)) { + if (ch != ">" || !state.lexical || state.lexical.type != ">") { + if (stream.eat("=")) { + if (ch == "!" || ch == "=") stream.eat("=") + } else if (/[<>*+\-]/.test(ch)) { + stream.eat(ch) + if (ch == ">") stream.eat(ch) + } + } + return ret("operator", "operator", stream.current()); + } else if (wordRE.test(ch)) { + stream.eatWhile(wordRE); + var word = stream.current() + if (state.lastType != ".") { + if (keywords.propertyIsEnumerable(word)) { + var kw = keywords[word] + return ret(kw.type, kw.style, word) + } + if (word == "async" && stream.match(/^(\s|\/\*.*?\*\/)*[\[\(\w]/, false)) + return ret("async", "keyword", word) + } + return ret("variable", "variable", word) + } + } + + function tokenString(quote) { + return function(stream, state) { + var escaped = false, next; + if (jsonldMode && stream.peek() == "@" && stream.match(isJsonldKeyword)){ + state.tokenize = tokenBase; + return ret("jsonld-keyword", "meta"); + } + while ((next = stream.next()) != null) { + if (next == quote && !escaped) break; + escaped = !escaped && next == "\\"; + } + if (!escaped) state.tokenize = tokenBase; + return ret("string", "string"); + }; + } + + function tokenComment(stream, state) { + var maybeEnd = false, ch; + while (ch = stream.next()) { + if (ch == "/" && maybeEnd) { + state.tokenize = tokenBase; + break; + } + maybeEnd = (ch == "*"); + } + return ret("comment", "comment"); + } + + function tokenQuasi(stream, state) { + var escaped = false, next; + while ((next = stream.next()) != null) { + if (!escaped && (next == "`" || next == "$" && stream.eat("{"))) { + state.tokenize = tokenBase; + break; + } + escaped = !escaped && next == "\\"; + } + return ret("quasi", "string-2", stream.current()); + } + + var brackets = "([{}])"; + // This is a crude lookahead trick to try and notice that we're + // parsing the argument patterns for a fat-arrow function before we + // actually hit the arrow token. It only works if the arrow is on + // the same line as the arguments and there's no strange noise + // (comments) in between. Fallback is to only notice when we hit the + // arrow, and not declare the arguments as locals for the arrow + // body. + function findFatArrow(stream, state) { + if (state.fatArrowAt) state.fatArrowAt = null; + var arrow = stream.string.indexOf("=>", stream.start); + if (arrow < 0) return; + + if (isTS) { // Try to skip TypeScript return type declarations after the arguments + var m = /:\s*(?:\w+(?:<[^>]*>|\[\])?|\{[^}]*\})\s*$/.exec(stream.string.slice(stream.start, arrow)) + if (m) arrow = m.index + } + + var depth = 0, sawSomething = false; + for (var pos = arrow - 1; pos >= 0; --pos) { + var ch = stream.string.charAt(pos); + var bracket = brackets.indexOf(ch); + if (bracket >= 0 && bracket < 3) { + if (!depth) { ++pos; break; } + if (--depth == 0) { if (ch == "(") sawSomething = true; break; } + } else if (bracket >= 3 && bracket < 6) { + ++depth; + } else if (wordRE.test(ch)) { + sawSomething = true; + } else if (/["'\/`]/.test(ch)) { + for (;; --pos) { + if (pos == 0) return + var next = stream.string.charAt(pos - 1) + if (next == ch && stream.string.charAt(pos - 2) != "\\") { pos--; break } + } + } else if (sawSomething && !depth) { + ++pos; + break; + } + } + if (sawSomething && !depth) state.fatArrowAt = pos; + } + + // Parser + + var atomicTypes = {"atom": true, "number": true, "variable": true, "string": true, "regexp": true, "this": true, "jsonld-keyword": true}; + + function JSLexical(indented, column, type, align, prev, info) { + this.indented = indented; + this.column = column; + this.type = type; + this.prev = prev; + this.info = info; + if (align != null) this.align = align; + } + + function inScope(state, varname) { + for (var v = state.localVars; v; v = v.next) + if (v.name == varname) return true; + for (var cx = state.context; cx; cx = cx.prev) { + for (var v = cx.vars; v; v = v.next) + if (v.name == varname) return true; + } + } + + function parseJS(state, style, type, content, stream) { + var cc = state.cc; + // Communicate our context to the combinators. + // (Less wasteful than consing up a hundred closures on every call.) + cx.state = state; cx.stream = stream; cx.marked = null, cx.cc = cc; cx.style = style; + + if (!state.lexical.hasOwnProperty("align")) + state.lexical.align = true; + + while(true) { + var combinator = cc.length ? cc.pop() : jsonMode ? expression : statement; + if (combinator(type, content)) { + while(cc.length && cc[cc.length - 1].lex) + cc.pop()(); + if (cx.marked) return cx.marked; + if (type == "variable" && inScope(state, content)) return "variable-2"; + return style; + } + } + } + + // Combinator utils + + var cx = {state: null, column: null, marked: null, cc: null}; + function pass() { + for (var i = arguments.length - 1; i >= 0; i--) cx.cc.push(arguments[i]); + } + function cont() { + pass.apply(null, arguments); + return true; + } + function inList(name, list) { + for (var v = list; v; v = v.next) if (v.name == name) return true + return false; + } + function register(varname) { + var state = cx.state; + cx.marked = "def"; + if (state.context) { + if (state.lexical.info == "var" && state.context && state.context.block) { + // FIXME function decls are also not block scoped + var newContext = registerVarScoped(varname, state.context) + if (newContext != null) { + state.context = newContext + return + } + } else if (!inList(varname, state.localVars)) { + state.localVars = new Var(varname, state.localVars) + return + } + } + // Fall through means this is global + if (parserConfig.globalVars && !inList(varname, state.globalVars)) + state.globalVars = new Var(varname, state.globalVars) + } + function registerVarScoped(varname, context) { + if (!context) { + return null + } else if (context.block) { + var inner = registerVarScoped(varname, context.prev) + if (!inner) return null + if (inner == context.prev) return context + return new Context(inner, context.vars, true) + } else if (inList(varname, context.vars)) { + return context + } else { + return new Context(context.prev, new Var(varname, context.vars), false) + } + } + + function isModifier(name) { + return name == "public" || name == "private" || name == "protected" || name == "abstract" || name == "readonly" + } + + // Combinators + + function Context(prev, vars, block) { this.prev = prev; this.vars = vars; this.block = block } + function Var(name, next) { this.name = name; this.next = next } + + var defaultVars = new Var("this", new Var("arguments", null)) + function pushcontext() { + cx.state.context = new Context(cx.state.context, cx.state.localVars, false) + cx.state.localVars = defaultVars + } + function pushblockcontext() { + cx.state.context = new Context(cx.state.context, cx.state.localVars, true) + cx.state.localVars = null + } + function popcontext() { + cx.state.localVars = cx.state.context.vars + cx.state.context = cx.state.context.prev + } + popcontext.lex = true + function pushlex(type, info) { + var result = function() { + var state = cx.state, indent = state.indented; + if (state.lexical.type == "stat") indent = state.lexical.indented; + else for (var outer = state.lexical; outer && outer.type == ")" && outer.align; outer = outer.prev) + indent = outer.indented; + state.lexical = new JSLexical(indent, cx.stream.column(), type, null, state.lexical, info); + }; + result.lex = true; + return result; + } + function poplex() { + var state = cx.state; + if (state.lexical.prev) { + if (state.lexical.type == ")") + state.indented = state.lexical.indented; + state.lexical = state.lexical.prev; + } + } + poplex.lex = true; + + function expect(wanted) { + function exp(type) { + if (type == wanted) return cont(); + else if (wanted == ";" || type == "}" || type == ")" || type == "]") return pass(); + else return cont(exp); + }; + return exp; + } + + function statement(type, value) { + if (type == "var") return cont(pushlex("vardef", value), vardef, expect(";"), poplex); + if (type == "keyword a") return cont(pushlex("form"), parenExpr, statement, poplex); + if (type == "keyword b") return cont(pushlex("form"), statement, poplex); + if (type == "keyword d") return cx.stream.match(/^\s*$/, false) ? cont() : cont(pushlex("stat"), maybeexpression, expect(";"), poplex); + if (type == "debugger") return cont(expect(";")); + if (type == "{") return cont(pushlex("}"), pushblockcontext, block, poplex, popcontext); + if (type == ";") return cont(); + if (type == "if") { + if (cx.state.lexical.info == "else" && cx.state.cc[cx.state.cc.length - 1] == poplex) + cx.state.cc.pop()(); + return cont(pushlex("form"), parenExpr, statement, poplex, maybeelse); + } + if (type == "function") return cont(functiondef); + if (type == "for") return cont(pushlex("form"), forspec, statement, poplex); + if (type == "class" || (isTS && value == "interface")) { + cx.marked = "keyword" + return cont(pushlex("form", type == "class" ? type : value), className, poplex) + } + if (type == "variable") { + if (isTS && value == "declare") { + cx.marked = "keyword" + return cont(statement) + } else if (isTS && (value == "module" || value == "enum" || value == "type") && cx.stream.match(/^\s*\w/, false)) { + cx.marked = "keyword" + if (value == "enum") return cont(enumdef); + else if (value == "type") return cont(typename, expect("operator"), typeexpr, expect(";")); + else return cont(pushlex("form"), pattern, expect("{"), pushlex("}"), block, poplex, poplex) + } else if (isTS && value == "namespace") { + cx.marked = "keyword" + return cont(pushlex("form"), expression, statement, poplex) + } else if (isTS && value == "abstract") { + cx.marked = "keyword" + return cont(statement) + } else { + return cont(pushlex("stat"), maybelabel); + } + } + if (type == "switch") return cont(pushlex("form"), parenExpr, expect("{"), pushlex("}", "switch"), pushblockcontext, + block, poplex, poplex, popcontext); + if (type == "case") return cont(expression, expect(":")); + if (type == "default") return cont(expect(":")); + if (type == "catch") return cont(pushlex("form"), pushcontext, maybeCatchBinding, statement, poplex, popcontext); + if (type == "export") return cont(pushlex("stat"), afterExport, poplex); + if (type == "import") return cont(pushlex("stat"), afterImport, poplex); + if (type == "async") return cont(statement) + if (value == "@") return cont(expression, statement) + return pass(pushlex("stat"), expression, expect(";"), poplex); + } + function maybeCatchBinding(type) { + if (type == "(") return cont(funarg, expect(")")) + } + function expression(type, value) { + return expressionInner(type, value, false); + } + function expressionNoComma(type, value) { + return expressionInner(type, value, true); + } + function parenExpr(type) { + if (type != "(") return pass() + return cont(pushlex(")"), expression, expect(")"), poplex) + } + function expressionInner(type, value, noComma) { + if (cx.state.fatArrowAt == cx.stream.start) { + var body = noComma ? arrowBodyNoComma : arrowBody; + if (type == "(") return cont(pushcontext, pushlex(")"), commasep(funarg, ")"), poplex, expect("=>"), body, popcontext); + else if (type == "variable") return pass(pushcontext, pattern, expect("=>"), body, popcontext); + } + + var maybeop = noComma ? maybeoperatorNoComma : maybeoperatorComma; + if (atomicTypes.hasOwnProperty(type)) return cont(maybeop); + if (type == "function") return cont(functiondef, maybeop); + if (type == "class" || (isTS && value == "interface")) { cx.marked = "keyword"; return cont(pushlex("form"), classExpression, poplex); } + if (type == "keyword c" || type == "async") return cont(noComma ? expressionNoComma : expression); + if (type == "(") return cont(pushlex(")"), maybeexpression, expect(")"), poplex, maybeop); + if (type == "operator" || type == "spread") return cont(noComma ? expressionNoComma : expression); + if (type == "[") return cont(pushlex("]"), arrayLiteral, poplex, maybeop); + if (type == "{") return contCommasep(objprop, "}", null, maybeop); + if (type == "quasi") return pass(quasi, maybeop); + if (type == "new") return cont(maybeTarget(noComma)); + if (type == "import") return cont(expression); + return cont(); + } + function maybeexpression(type) { + if (type.match(/[;\}\)\],]/)) return pass(); + return pass(expression); + } + + function maybeoperatorComma(type, value) { + if (type == ",") return cont(expression); + return maybeoperatorNoComma(type, value, false); + } + function maybeoperatorNoComma(type, value, noComma) { + var me = noComma == false ? maybeoperatorComma : maybeoperatorNoComma; + var expr = noComma == false ? expression : expressionNoComma; + if (type == "=>") return cont(pushcontext, noComma ? arrowBodyNoComma : arrowBody, popcontext); + if (type == "operator") { + if (/\+\+|--/.test(value) || isTS && value == "!") return cont(me); + if (isTS && value == "<" && cx.stream.match(/^([^>]|<.*?>)*>\s*\(/, false)) + return cont(pushlex(">"), commasep(typeexpr, ">"), poplex, me); + if (value == "?") return cont(expression, expect(":"), expr); + return cont(expr); + } + if (type == "quasi") { return pass(quasi, me); } + if (type == ";") return; + if (type == "(") return contCommasep(expressionNoComma, ")", "call", me); + if (type == ".") return cont(property, me); + if (type == "[") return cont(pushlex("]"), maybeexpression, expect("]"), poplex, me); + if (isTS && value == "as") { cx.marked = "keyword"; return cont(typeexpr, me) } + if (type == "regexp") { + cx.state.lastType = cx.marked = "operator" + cx.stream.backUp(cx.stream.pos - cx.stream.start - 1) + return cont(expr) + } + } + function quasi(type, value) { + if (type != "quasi") return pass(); + if (value.slice(value.length - 2) != "${") return cont(quasi); + return cont(expression, continueQuasi); + } + function continueQuasi(type) { + if (type == "}") { + cx.marked = "string-2"; + cx.state.tokenize = tokenQuasi; + return cont(quasi); + } + } + function arrowBody(type) { + findFatArrow(cx.stream, cx.state); + return pass(type == "{" ? statement : expression); + } + function arrowBodyNoComma(type) { + findFatArrow(cx.stream, cx.state); + return pass(type == "{" ? statement : expressionNoComma); + } + function maybeTarget(noComma) { + return function(type) { + if (type == ".") return cont(noComma ? targetNoComma : target); + else if (type == "variable" && isTS) return cont(maybeTypeArgs, noComma ? maybeoperatorNoComma : maybeoperatorComma) + else return pass(noComma ? expressionNoComma : expression); + }; + } + function target(_, value) { + if (value == "target") { cx.marked = "keyword"; return cont(maybeoperatorComma); } + } + function targetNoComma(_, value) { + if (value == "target") { cx.marked = "keyword"; return cont(maybeoperatorNoComma); } + } + function maybelabel(type) { + if (type == ":") return cont(poplex, statement); + return pass(maybeoperatorComma, expect(";"), poplex); + } + function property(type) { + if (type == "variable") {cx.marked = "property"; return cont();} + } + function objprop(type, value) { + if (type == "async") { + cx.marked = "property"; + return cont(objprop); + } else if (type == "variable" || cx.style == "keyword") { + cx.marked = "property"; + if (value == "get" || value == "set") return cont(getterSetter); + var m // Work around fat-arrow-detection complication for detecting typescript typed arrow params + if (isTS && cx.state.fatArrowAt == cx.stream.start && (m = cx.stream.match(/^\s*:\s*/, false))) + cx.state.fatArrowAt = cx.stream.pos + m[0].length + return cont(afterprop); + } else if (type == "number" || type == "string") { + cx.marked = jsonldMode ? "property" : (cx.style + " property"); + return cont(afterprop); + } else if (type == "jsonld-keyword") { + return cont(afterprop); + } else if (isTS && isModifier(value)) { + cx.marked = "keyword" + return cont(objprop) + } else if (type == "[") { + return cont(expression, maybetype, expect("]"), afterprop); + } else if (type == "spread") { + return cont(expressionNoComma, afterprop); + } else if (value == "*") { + cx.marked = "keyword"; + return cont(objprop); + } else if (type == ":") { + return pass(afterprop) + } + } + function getterSetter(type) { + if (type != "variable") return pass(afterprop); + cx.marked = "property"; + return cont(functiondef); + } + function afterprop(type) { + if (type == ":") return cont(expressionNoComma); + if (type == "(") return pass(functiondef); + } + function commasep(what, end, sep) { + function proceed(type, value) { + if (sep ? sep.indexOf(type) > -1 : type == ",") { + var lex = cx.state.lexical; + if (lex.info == "call") lex.pos = (lex.pos || 0) + 1; + return cont(function(type, value) { + if (type == end || value == end) return pass() + return pass(what) + }, proceed); + } + if (type == end || value == end) return cont(); + if (sep && sep.indexOf(";") > -1) return pass(what) + return cont(expect(end)); + } + return function(type, value) { + if (type == end || value == end) return cont(); + return pass(what, proceed); + }; + } + function contCommasep(what, end, info) { + for (var i = 3; i < arguments.length; i++) + cx.cc.push(arguments[i]); + return cont(pushlex(end, info), commasep(what, end), poplex); + } + function block(type) { + if (type == "}") return cont(); + return pass(statement, block); + } + function maybetype(type, value) { + if (isTS) { + if (type == ":") return cont(typeexpr); + if (value == "?") return cont(maybetype); + } + } + function maybetypeOrIn(type, value) { + if (isTS && (type == ":" || value == "in")) return cont(typeexpr) + } + function mayberettype(type) { + if (isTS && type == ":") { + if (cx.stream.match(/^\s*\w+\s+is\b/, false)) return cont(expression, isKW, typeexpr) + else return cont(typeexpr) + } + } + function isKW(_, value) { + if (value == "is") { + cx.marked = "keyword" + return cont() + } + } + function typeexpr(type, value) { + if (value == "keyof" || value == "typeof" || value == "infer") { + cx.marked = "keyword" + return cont(value == "typeof" ? expressionNoComma : typeexpr) + } + if (type == "variable" || value == "void") { + cx.marked = "type" + return cont(afterType) + } + if (value == "|" || value == "&") return cont(typeexpr) + if (type == "string" || type == "number" || type == "atom") return cont(afterType); + if (type == "[") return cont(pushlex("]"), commasep(typeexpr, "]", ","), poplex, afterType) + if (type == "{") return cont(pushlex("}"), commasep(typeprop, "}", ",;"), poplex, afterType) + if (type == "(") return cont(commasep(typearg, ")"), maybeReturnType, afterType) + if (type == "<") return cont(commasep(typeexpr, ">"), typeexpr) + } + function maybeReturnType(type) { + if (type == "=>") return cont(typeexpr) + } + function typeprop(type, value) { + if (type == "variable" || cx.style == "keyword") { + cx.marked = "property" + return cont(typeprop) + } else if (value == "?" || type == "number" || type == "string") { + return cont(typeprop) + } else if (type == ":") { + return cont(typeexpr) + } else if (type == "[") { + return cont(expect("variable"), maybetypeOrIn, expect("]"), typeprop) + } else if (type == "(") { + return pass(functiondecl, typeprop) + } + } + function typearg(type, value) { + if (type == "variable" && cx.stream.match(/^\s*[?:]/, false) || value == "?") return cont(typearg) + if (type == ":") return cont(typeexpr) + if (type == "spread") return cont(typearg) + return pass(typeexpr) + } + function afterType(type, value) { + if (value == "<") return cont(pushlex(">"), commasep(typeexpr, ">"), poplex, afterType) + if (value == "|" || type == "." || value == "&") return cont(typeexpr) + if (type == "[") return cont(typeexpr, expect("]"), afterType) + if (value == "extends" || value == "implements") { cx.marked = "keyword"; return cont(typeexpr) } + if (value == "?") return cont(typeexpr, expect(":"), typeexpr) + } + function maybeTypeArgs(_, value) { + if (value == "<") return cont(pushlex(">"), commasep(typeexpr, ">"), poplex, afterType) + } + function typeparam() { + return pass(typeexpr, maybeTypeDefault) + } + function maybeTypeDefault(_, value) { + if (value == "=") return cont(typeexpr) + } + function vardef(_, value) { + if (value == "enum") {cx.marked = "keyword"; return cont(enumdef)} + return pass(pattern, maybetype, maybeAssign, vardefCont); + } + function pattern(type, value) { + if (isTS && isModifier(value)) { cx.marked = "keyword"; return cont(pattern) } + if (type == "variable") { register(value); return cont(); } + if (type == "spread") return cont(pattern); + if (type == "[") return contCommasep(eltpattern, "]"); + if (type == "{") return contCommasep(proppattern, "}"); + } + function proppattern(type, value) { + if (type == "variable" && !cx.stream.match(/^\s*:/, false)) { + register(value); + return cont(maybeAssign); + } + if (type == "variable") cx.marked = "property"; + if (type == "spread") return cont(pattern); + if (type == "}") return pass(); + if (type == "[") return cont(expression, expect(']'), expect(':'), proppattern); + return cont(expect(":"), pattern, maybeAssign); + } + function eltpattern() { + return pass(pattern, maybeAssign) + } + function maybeAssign(_type, value) { + if (value == "=") return cont(expressionNoComma); + } + function vardefCont(type) { + if (type == ",") return cont(vardef); + } + function maybeelse(type, value) { + if (type == "keyword b" && value == "else") return cont(pushlex("form", "else"), statement, poplex); + } + function forspec(type, value) { + if (value == "await") return cont(forspec); + if (type == "(") return cont(pushlex(")"), forspec1, poplex); + } + function forspec1(type) { + if (type == "var") return cont(vardef, forspec2); + if (type == "variable") return cont(forspec2); + return pass(forspec2) + } + function forspec2(type, value) { + if (type == ")") return cont() + if (type == ";") return cont(forspec2) + if (value == "in" || value == "of") { cx.marked = "keyword"; return cont(expression, forspec2) } + return pass(expression, forspec2) + } + function functiondef(type, value) { + if (value == "*") {cx.marked = "keyword"; return cont(functiondef);} + if (type == "variable") {register(value); return cont(functiondef);} + if (type == "(") return cont(pushcontext, pushlex(")"), commasep(funarg, ")"), poplex, mayberettype, statement, popcontext); + if (isTS && value == "<") return cont(pushlex(">"), commasep(typeparam, ">"), poplex, functiondef) + } + function functiondecl(type, value) { + if (value == "*") {cx.marked = "keyword"; return cont(functiondecl);} + if (type == "variable") {register(value); return cont(functiondecl);} + if (type == "(") return cont(pushcontext, pushlex(")"), commasep(funarg, ")"), poplex, mayberettype, popcontext); + if (isTS && value == "<") return cont(pushlex(">"), commasep(typeparam, ">"), poplex, functiondecl) + } + function typename(type, value) { + if (type == "keyword" || type == "variable") { + cx.marked = "type" + return cont(typename) + } else if (value == "<") { + return cont(pushlex(">"), commasep(typeparam, ">"), poplex) + } + } + function funarg(type, value) { + if (value == "@") cont(expression, funarg) + if (type == "spread") return cont(funarg); + if (isTS && isModifier(value)) { cx.marked = "keyword"; return cont(funarg); } + if (isTS && type == "this") return cont(maybetype, maybeAssign) + return pass(pattern, maybetype, maybeAssign); + } + function classExpression(type, value) { + // Class expressions may have an optional name. + if (type == "variable") return className(type, value); + return classNameAfter(type, value); + } + function className(type, value) { + if (type == "variable") {register(value); return cont(classNameAfter);} + } + function classNameAfter(type, value) { + if (value == "<") return cont(pushlex(">"), commasep(typeparam, ">"), poplex, classNameAfter) + if (value == "extends" || value == "implements" || (isTS && type == ",")) { + if (value == "implements") cx.marked = "keyword"; + return cont(isTS ? typeexpr : expression, classNameAfter); + } + if (type == "{") return cont(pushlex("}"), classBody, poplex); + } + function classBody(type, value) { + if (type == "async" || + (type == "variable" && + (value == "static" || value == "get" || value == "set" || (isTS && isModifier(value))) && + cx.stream.match(/^\s+[\w$\xa1-\uffff]/, false))) { + cx.marked = "keyword"; + return cont(classBody); + } + if (type == "variable" || cx.style == "keyword") { + cx.marked = "property"; + return cont(isTS ? classfield : functiondef, classBody); + } + if (type == "number" || type == "string") return cont(isTS ? classfield : functiondef, classBody); + if (type == "[") + return cont(expression, maybetype, expect("]"), isTS ? classfield : functiondef, classBody) + if (value == "*") { + cx.marked = "keyword"; + return cont(classBody); + } + if (isTS && type == "(") return pass(functiondecl, classBody) + if (type == ";" || type == ",") return cont(classBody); + if (type == "}") return cont(); + if (value == "@") return cont(expression, classBody) + } + function classfield(type, value) { + if (value == "?") return cont(classfield) + if (type == ":") return cont(typeexpr, maybeAssign) + if (value == "=") return cont(expressionNoComma) + var context = cx.state.lexical.prev, isInterface = context && context.info == "interface" + return pass(isInterface ? functiondecl : functiondef) + } + function afterExport(type, value) { + if (value == "*") { cx.marked = "keyword"; return cont(maybeFrom, expect(";")); } + if (value == "default") { cx.marked = "keyword"; return cont(expression, expect(";")); } + if (type == "{") return cont(commasep(exportField, "}"), maybeFrom, expect(";")); + return pass(statement); + } + function exportField(type, value) { + if (value == "as") { cx.marked = "keyword"; return cont(expect("variable")); } + if (type == "variable") return pass(expressionNoComma, exportField); + } + function afterImport(type) { + if (type == "string") return cont(); + if (type == "(") return pass(expression); + return pass(importSpec, maybeMoreImports, maybeFrom); + } + function importSpec(type, value) { + if (type == "{") return contCommasep(importSpec, "}"); + if (type == "variable") register(value); + if (value == "*") cx.marked = "keyword"; + return cont(maybeAs); + } + function maybeMoreImports(type) { + if (type == ",") return cont(importSpec, maybeMoreImports) + } + function maybeAs(_type, value) { + if (value == "as") { cx.marked = "keyword"; return cont(importSpec); } + } + function maybeFrom(_type, value) { + if (value == "from") { cx.marked = "keyword"; return cont(expression); } + } + function arrayLiteral(type) { + if (type == "]") return cont(); + return pass(commasep(expressionNoComma, "]")); + } + function enumdef() { + return pass(pushlex("form"), pattern, expect("{"), pushlex("}"), commasep(enummember, "}"), poplex, poplex) + } + function enummember() { + return pass(pattern, maybeAssign); + } + + function isContinuedStatement(state, textAfter) { + return state.lastType == "operator" || state.lastType == "," || + isOperatorChar.test(textAfter.charAt(0)) || + /[,.]/.test(textAfter.charAt(0)); + } + + function expressionAllowed(stream, state, backUp) { + return state.tokenize == tokenBase && + /^(?:operator|sof|keyword [bcd]|case|new|export|default|spread|[\[{}\(,;:]|=>)$/.test(state.lastType) || + (state.lastType == "quasi" && /\{\s*$/.test(stream.string.slice(0, stream.pos - (backUp || 0)))) + } + + // Interface + + return { + startState: function(basecolumn) { + var state = { + tokenize: tokenBase, + lastType: "sof", + cc: [], + lexical: new JSLexical((basecolumn || 0) - indentUnit, 0, "block", false), + localVars: parserConfig.localVars, + context: parserConfig.localVars && new Context(null, null, false), + indented: basecolumn || 0 + }; + if (parserConfig.globalVars && typeof parserConfig.globalVars == "object") + state.globalVars = parserConfig.globalVars; + return state; + }, + + token: function(stream, state) { + if (stream.sol()) { + if (!state.lexical.hasOwnProperty("align")) + state.lexical.align = false; + state.indented = stream.indentation(); + findFatArrow(stream, state); + } + if (state.tokenize != tokenComment && stream.eatSpace()) return null; + var style = state.tokenize(stream, state); + if (type == "comment") return style; + state.lastType = type == "operator" && (content == "++" || content == "--") ? "incdec" : type; + return parseJS(state, style, type, content, stream); + }, + + indent: function(state, textAfter) { + if (state.tokenize == tokenComment) return CodeMirror.Pass; + if (state.tokenize != tokenBase) return 0; + var firstChar = textAfter && textAfter.charAt(0), lexical = state.lexical, top + // Kludge to prevent 'maybelse' from blocking lexical scope pops + if (!/^\s*else\b/.test(textAfter)) for (var i = state.cc.length - 1; i >= 0; --i) { + var c = state.cc[i]; + if (c == poplex) lexical = lexical.prev; + else if (c != maybeelse) break; + } + while ((lexical.type == "stat" || lexical.type == "form") && + (firstChar == "}" || ((top = state.cc[state.cc.length - 1]) && + (top == maybeoperatorComma || top == maybeoperatorNoComma) && + !/^[,\.=+\-*:?[\(]/.test(textAfter)))) + lexical = lexical.prev; + if (statementIndent && lexical.type == ")" && lexical.prev.type == "stat") + lexical = lexical.prev; + var type = lexical.type, closing = firstChar == type; + + if (type == "vardef") return lexical.indented + (state.lastType == "operator" || state.lastType == "," ? lexical.info.length + 1 : 0); + else if (type == "form" && firstChar == "{") return lexical.indented; + else if (type == "form") return lexical.indented + indentUnit; + else if (type == "stat") + return lexical.indented + (isContinuedStatement(state, textAfter) ? statementIndent || indentUnit : 0); + else if (lexical.info == "switch" && !closing && parserConfig.doubleIndentSwitch != false) + return lexical.indented + (/^(?:case|default)\b/.test(textAfter) ? indentUnit : 2 * indentUnit); + else if (lexical.align) return lexical.column + (closing ? 0 : 1); + else return lexical.indented + (closing ? 0 : indentUnit); + }, + + electricInput: /^\s*(?:case .*?:|default:|\{|\})$/, + blockCommentStart: jsonMode ? null : "/*", + blockCommentEnd: jsonMode ? null : "*/", + blockCommentContinue: jsonMode ? null : " * ", + lineComment: jsonMode ? null : "//", + fold: "brace", + closeBrackets: "()[]{}''\"\"``", + + helperType: jsonMode ? "json" : "javascript", + jsonldMode: jsonldMode, + jsonMode: jsonMode, + + expressionAllowed: expressionAllowed, + + skipExpression: function(state) { + var top = state.cc[state.cc.length - 1] + if (top == expression || top == expressionNoComma) state.cc.pop() + } + }; +}); + +CodeMirror.registerHelper("wordChars", "javascript", /[\w$]/); + +CodeMirror.defineMIME("text/javascript", "javascript"); +CodeMirror.defineMIME("text/ecmascript", "javascript"); +CodeMirror.defineMIME("application/javascript", "javascript"); +CodeMirror.defineMIME("application/x-javascript", "javascript"); +CodeMirror.defineMIME("application/ecmascript", "javascript"); +CodeMirror.defineMIME("application/json", {name: "javascript", json: true}); +CodeMirror.defineMIME("application/x-json", {name: "javascript", json: true}); +CodeMirror.defineMIME("application/ld+json", {name: "javascript", jsonld: true}); +CodeMirror.defineMIME("text/typescript", { name: "javascript", typescript: true }); +CodeMirror.defineMIME("application/typescript", { name: "javascript", typescript: true }); + +}); diff --git a/adminx/assets/vendor/codemirror/mode/php/php.js b/adminx/assets/vendor/codemirror/mode/php/php.js new file mode 100644 index 0000000..5f3a143 --- /dev/null +++ b/adminx/assets/vendor/codemirror/mode/php/php.js @@ -0,0 +1,234 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror"), require("../htmlmixed/htmlmixed"), require("../clike/clike")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror", "../htmlmixed/htmlmixed", "../clike/clike"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { + "use strict"; + + function keywords(str) { + var obj = {}, words = str.split(" "); + for (var i = 0; i < words.length; ++i) obj[words[i]] = true; + return obj; + } + + // Helper for phpString + function matchSequence(list, end, escapes) { + if (list.length == 0) return phpString(end); + return function (stream, state) { + var patterns = list[0]; + for (var i = 0; i < patterns.length; i++) if (stream.match(patterns[i][0])) { + state.tokenize = matchSequence(list.slice(1), end); + return patterns[i][1]; + } + state.tokenize = phpString(end, escapes); + return "string"; + }; + } + function phpString(closing, escapes) { + return function(stream, state) { return phpString_(stream, state, closing, escapes); }; + } + function phpString_(stream, state, closing, escapes) { + // "Complex" syntax + if (escapes !== false && stream.match("${", false) || stream.match("{$", false)) { + state.tokenize = null; + return "string"; + } + + // Simple syntax + if (escapes !== false && stream.match(/^\$[a-zA-Z_][a-zA-Z0-9_]*/)) { + // After the variable name there may appear array or object operator. + if (stream.match("[", false)) { + // Match array operator + state.tokenize = matchSequence([ + [["[", null]], + [[/\d[\w\.]*/, "number"], + [/\$[a-zA-Z_][a-zA-Z0-9_]*/, "variable-2"], + [/[\w\$]+/, "variable"]], + [["]", null]] + ], closing, escapes); + } + if (stream.match(/\-\>\w/, false)) { + // Match object operator + state.tokenize = matchSequence([ + [["->", null]], + [[/[\w]+/, "variable"]] + ], closing, escapes); + } + return "variable-2"; + } + + var escaped = false; + // Normal string + while (!stream.eol() && + (escaped || escapes === false || + (!stream.match("{$", false) && + !stream.match(/^(\$[a-zA-Z_][a-zA-Z0-9_]*|\$\{)/, false)))) { + if (!escaped && stream.match(closing)) { + state.tokenize = null; + state.tokStack.pop(); state.tokStack.pop(); + break; + } + escaped = stream.next() == "\\" && !escaped; + } + return "string"; + } + + var phpKeywords = "abstract and array as break case catch class clone const continue declare default " + + "do else elseif enddeclare endfor endforeach endif endswitch endwhile extends final " + + "for foreach function global goto if implements interface instanceof namespace " + + "new or private protected public static switch throw trait try use var while xor " + + "die echo empty exit eval include include_once isset list require require_once return " + + "print unset __halt_compiler self static parent yield insteadof finally"; + var phpAtoms = "true false null TRUE FALSE NULL __CLASS__ __DIR__ __FILE__ __LINE__ __METHOD__ __FUNCTION__ __NAMESPACE__ __TRAIT__"; + var phpBuiltin = "func_num_args func_get_arg func_get_args strlen strcmp strncmp strcasecmp strncasecmp each error_reporting define defined trigger_error user_error set_error_handler restore_error_handler get_declared_classes get_loaded_extensions extension_loaded get_extension_funcs debug_backtrace constant bin2hex hex2bin sleep usleep time mktime gmmktime strftime gmstrftime strtotime date gmdate getdate localtime checkdate flush wordwrap htmlspecialchars htmlentities html_entity_decode md5 md5_file crc32 getimagesize image_type_to_mime_type phpinfo phpversion phpcredits strnatcmp strnatcasecmp substr_count strspn strcspn strtok strtoupper strtolower strpos strrpos strrev hebrev hebrevc nl2br basename dirname pathinfo stripslashes stripcslashes strstr stristr strrchr str_shuffle str_word_count strcoll substr substr_replace quotemeta ucfirst ucwords strtr addslashes addcslashes rtrim str_replace str_repeat count_chars chunk_split trim ltrim strip_tags similar_text explode implode setlocale localeconv parse_str str_pad chop strchr sprintf printf vprintf vsprintf sscanf fscanf parse_url urlencode urldecode rawurlencode rawurldecode readlink linkinfo link unlink exec system escapeshellcmd escapeshellarg passthru shell_exec proc_open proc_close rand srand getrandmax mt_rand mt_srand mt_getrandmax base64_decode base64_encode abs ceil floor round is_finite is_nan is_infinite bindec hexdec octdec decbin decoct dechex base_convert number_format fmod ip2long long2ip getenv putenv getopt microtime gettimeofday getrusage uniqid quoted_printable_decode set_time_limit get_cfg_var magic_quotes_runtime set_magic_quotes_runtime get_magic_quotes_gpc get_magic_quotes_runtime import_request_variables error_log serialize unserialize memory_get_usage var_dump var_export debug_zval_dump print_r highlight_file show_source highlight_string ini_get ini_get_all ini_set ini_alter ini_restore get_include_path set_include_path restore_include_path setcookie header headers_sent connection_aborted connection_status ignore_user_abort parse_ini_file is_uploaded_file move_uploaded_file intval floatval doubleval strval gettype settype is_null is_resource is_bool is_long is_float is_int is_integer is_double is_real is_numeric is_string is_array is_object is_scalar ereg ereg_replace eregi eregi_replace split spliti join sql_regcase dl pclose popen readfile rewind rmdir umask fclose feof fgetc fgets fgetss fread fopen fpassthru ftruncate fstat fseek ftell fflush fwrite fputs mkdir rename copy tempnam tmpfile file file_get_contents file_put_contents stream_select stream_context_create stream_context_set_params stream_context_set_option stream_context_get_options stream_filter_prepend stream_filter_append fgetcsv flock get_meta_tags stream_set_write_buffer set_file_buffer set_socket_blocking stream_set_blocking socket_set_blocking stream_get_meta_data stream_register_wrapper stream_wrapper_register stream_set_timeout socket_set_timeout socket_get_status realpath fnmatch fsockopen pfsockopen pack unpack get_browser crypt opendir closedir chdir getcwd rewinddir readdir dir glob fileatime filectime filegroup fileinode filemtime fileowner fileperms filesize filetype file_exists is_writable is_writeable is_readable is_executable is_file is_dir is_link stat lstat chown touch clearstatcache mail ob_start ob_flush ob_clean ob_end_flush ob_end_clean ob_get_flush ob_get_clean ob_get_length ob_get_level ob_get_status ob_get_contents ob_implicit_flush ob_list_handlers ksort krsort natsort natcasesort asort arsort sort rsort usort uasort uksort shuffle array_walk count end prev next reset current key min max in_array array_search extract compact array_fill range array_multisort array_push array_pop array_shift array_unshift array_splice array_slice array_merge array_merge_recursive array_keys array_values array_count_values array_reverse array_reduce array_pad array_flip array_change_key_case array_rand array_unique array_intersect array_intersect_assoc array_diff array_diff_assoc array_sum array_filter array_map array_chunk array_key_exists array_intersect_key array_combine array_column pos sizeof key_exists assert assert_options version_compare ftok str_rot13 aggregate session_name session_module_name session_save_path session_id session_regenerate_id session_decode session_register session_unregister session_is_registered session_encode session_start session_destroy session_unset session_set_save_handler session_cache_limiter session_cache_expire session_set_cookie_params session_get_cookie_params session_write_close preg_match preg_match_all preg_replace preg_replace_callback preg_split preg_quote preg_grep overload ctype_alnum ctype_alpha ctype_cntrl ctype_digit ctype_lower ctype_graph ctype_print ctype_punct ctype_space ctype_upper ctype_xdigit virtual apache_request_headers apache_note apache_lookup_uri apache_child_terminate apache_setenv apache_response_headers apache_get_version getallheaders mysql_connect mysql_pconnect mysql_close mysql_select_db mysql_create_db mysql_drop_db mysql_query mysql_unbuffered_query mysql_db_query mysql_list_dbs mysql_list_tables mysql_list_fields mysql_list_processes mysql_error mysql_errno mysql_affected_rows mysql_insert_id mysql_result mysql_num_rows mysql_num_fields mysql_fetch_row mysql_fetch_array mysql_fetch_assoc mysql_fetch_object mysql_data_seek mysql_fetch_lengths mysql_fetch_field mysql_field_seek mysql_free_result mysql_field_name mysql_field_table mysql_field_len mysql_field_type mysql_field_flags mysql_escape_string mysql_real_escape_string mysql_stat mysql_thread_id mysql_client_encoding mysql_get_client_info mysql_get_host_info mysql_get_proto_info mysql_get_server_info mysql_info mysql mysql_fieldname mysql_fieldtable mysql_fieldlen mysql_fieldtype mysql_fieldflags mysql_selectdb mysql_createdb mysql_dropdb mysql_freeresult mysql_numfields mysql_numrows mysql_listdbs mysql_listtables mysql_listfields mysql_db_name mysql_dbname mysql_tablename mysql_table_name pg_connect pg_pconnect pg_close pg_connection_status pg_connection_busy pg_connection_reset pg_host pg_dbname pg_port pg_tty pg_options pg_ping pg_query pg_send_query pg_cancel_query pg_fetch_result pg_fetch_row pg_fetch_assoc pg_fetch_array pg_fetch_object pg_fetch_all pg_affected_rows pg_get_result pg_result_seek pg_result_status pg_free_result pg_last_oid pg_num_rows pg_num_fields pg_field_name pg_field_num pg_field_size pg_field_type pg_field_prtlen pg_field_is_null pg_get_notify pg_get_pid pg_result_error pg_last_error pg_last_notice pg_put_line pg_end_copy pg_copy_to pg_copy_from pg_trace pg_untrace pg_lo_create pg_lo_unlink pg_lo_open pg_lo_close pg_lo_read pg_lo_write pg_lo_read_all pg_lo_import pg_lo_export pg_lo_seek pg_lo_tell pg_escape_string pg_escape_bytea pg_unescape_bytea pg_client_encoding pg_set_client_encoding pg_meta_data pg_convert pg_insert pg_update pg_delete pg_select pg_exec pg_getlastoid pg_cmdtuples pg_errormessage pg_numrows pg_numfields pg_fieldname pg_fieldsize pg_fieldtype pg_fieldnum pg_fieldprtlen pg_fieldisnull pg_freeresult pg_result pg_loreadall pg_locreate pg_lounlink pg_loopen pg_loclose pg_loread pg_lowrite pg_loimport pg_loexport http_response_code get_declared_traits getimagesizefromstring socket_import_stream stream_set_chunk_size trait_exists header_register_callback class_uses session_status session_register_shutdown echo print global static exit array empty eval isset unset die include require include_once require_once json_decode json_encode json_last_error json_last_error_msg curl_close curl_copy_handle curl_errno curl_error curl_escape curl_exec curl_file_create curl_getinfo curl_init curl_multi_add_handle curl_multi_close curl_multi_exec curl_multi_getcontent curl_multi_info_read curl_multi_init curl_multi_remove_handle curl_multi_select curl_multi_setopt curl_multi_strerror curl_pause curl_reset curl_setopt_array curl_setopt curl_share_close curl_share_init curl_share_setopt curl_strerror curl_unescape curl_version mysqli_affected_rows mysqli_autocommit mysqli_change_user mysqli_character_set_name mysqli_close mysqli_commit mysqli_connect_errno mysqli_connect_error mysqli_connect mysqli_data_seek mysqli_debug mysqli_dump_debug_info mysqli_errno mysqli_error_list mysqli_error mysqli_fetch_all mysqli_fetch_array mysqli_fetch_assoc mysqli_fetch_field_direct mysqli_fetch_field mysqli_fetch_fields mysqli_fetch_lengths mysqli_fetch_object mysqli_fetch_row mysqli_field_count mysqli_field_seek mysqli_field_tell mysqli_free_result mysqli_get_charset mysqli_get_client_info mysqli_get_client_stats mysqli_get_client_version mysqli_get_connection_stats mysqli_get_host_info mysqli_get_proto_info mysqli_get_server_info mysqli_get_server_version mysqli_info mysqli_init mysqli_insert_id mysqli_kill mysqli_more_results mysqli_multi_query mysqli_next_result mysqli_num_fields mysqli_num_rows mysqli_options mysqli_ping mysqli_prepare mysqli_query mysqli_real_connect mysqli_real_escape_string mysqli_real_query mysqli_reap_async_query mysqli_refresh mysqli_rollback mysqli_select_db mysqli_set_charset mysqli_set_local_infile_default mysqli_set_local_infile_handler mysqli_sqlstate mysqli_ssl_set mysqli_stat mysqli_stmt_init mysqli_store_result mysqli_thread_id mysqli_thread_safe mysqli_use_result mysqli_warning_count"; + CodeMirror.registerHelper("hintWords", "php", [phpKeywords, phpAtoms, phpBuiltin].join(" ").split(" ")); + CodeMirror.registerHelper("wordChars", "php", /[\w$]/); + + var phpConfig = { + name: "clike", + helperType: "php", + keywords: keywords(phpKeywords), + blockKeywords: keywords("catch do else elseif for foreach if switch try while finally"), + defKeywords: keywords("class function interface namespace trait"), + atoms: keywords(phpAtoms), + builtin: keywords(phpBuiltin), + multiLineStrings: true, + hooks: { + "$": function(stream) { + stream.eatWhile(/[\w\$_]/); + return "variable-2"; + }, + "<": function(stream, state) { + var before; + if (before = stream.match(/<<\s*/)) { + var quoted = stream.eat(/['"]/); + stream.eatWhile(/[\w\.]/); + var delim = stream.current().slice(before[0].length + (quoted ? 2 : 1)); + if (quoted) stream.eat(quoted); + if (delim) { + (state.tokStack || (state.tokStack = [])).push(delim, 0); + state.tokenize = phpString(delim, quoted != "'"); + return "string"; + } + } + return false; + }, + "#": function(stream) { + while (!stream.eol() && !stream.match("?>", false)) stream.next(); + return "comment"; + }, + "/": function(stream) { + if (stream.eat("/")) { + while (!stream.eol() && !stream.match("?>", false)) stream.next(); + return "comment"; + } + return false; + }, + '"': function(_stream, state) { + (state.tokStack || (state.tokStack = [])).push('"', 0); + state.tokenize = phpString('"'); + return "string"; + }, + "{": function(_stream, state) { + if (state.tokStack && state.tokStack.length) + state.tokStack[state.tokStack.length - 1]++; + return false; + }, + "}": function(_stream, state) { + if (state.tokStack && state.tokStack.length > 0 && + !--state.tokStack[state.tokStack.length - 1]) { + state.tokenize = phpString(state.tokStack[state.tokStack.length - 2]); + } + return false; + } + } + }; + + CodeMirror.defineMode("php", function(config, parserConfig) { + var htmlMode = CodeMirror.getMode(config, (parserConfig && parserConfig.htmlMode) || "text/html"); + var phpMode = CodeMirror.getMode(config, phpConfig); + + function dispatch(stream, state) { + var isPHP = state.curMode == phpMode; + if (stream.sol() && state.pending && state.pending != '"' && state.pending != "'") state.pending = null; + if (!isPHP) { + if (stream.match(/^<\?\w*/)) { + state.curMode = phpMode; + if (!state.php) state.php = CodeMirror.startState(phpMode, htmlMode.indent(state.html, "", "")) + state.curState = state.php; + return "meta"; + } + if (state.pending == '"' || state.pending == "'") { + while (!stream.eol() && stream.next() != state.pending) {} + var style = "string"; + } else if (state.pending && stream.pos < state.pending.end) { + stream.pos = state.pending.end; + var style = state.pending.style; + } else { + var style = htmlMode.token(stream, state.curState); + } + if (state.pending) state.pending = null; + var cur = stream.current(), openPHP = cur.search(/<\?/), m; + if (openPHP != -1) { + if (style == "string" && (m = cur.match(/[\'\"]$/)) && !/\?>/.test(cur)) state.pending = m[0]; + else state.pending = {end: stream.pos, style: style}; + stream.backUp(cur.length - openPHP); + } + return style; + } else if (isPHP && state.php.tokenize == null && stream.match("?>")) { + state.curMode = htmlMode; + state.curState = state.html; + if (!state.php.context.prev) state.php = null; + return "meta"; + } else { + return phpMode.token(stream, state.curState); + } + } + + return { + startState: function() { + var html = CodeMirror.startState(htmlMode) + var php = parserConfig.startOpen ? CodeMirror.startState(phpMode) : null + return {html: html, + php: php, + curMode: parserConfig.startOpen ? phpMode : htmlMode, + curState: parserConfig.startOpen ? php : html, + pending: null}; + }, + + copyState: function(state) { + var html = state.html, htmlNew = CodeMirror.copyState(htmlMode, html), + php = state.php, phpNew = php && CodeMirror.copyState(phpMode, php), cur; + if (state.curMode == htmlMode) cur = htmlNew; + else cur = phpNew; + return {html: htmlNew, php: phpNew, curMode: state.curMode, curState: cur, + pending: state.pending}; + }, + + token: dispatch, + + indent: function(state, textAfter, line) { + if ((state.curMode != phpMode && /^\s*<\//.test(textAfter)) || + (state.curMode == phpMode && /^\?>/.test(textAfter))) + return htmlMode.indent(state.html, textAfter, line); + return state.curMode.indent(state.curState, textAfter, line); + }, + + blockCommentStart: "/*", + blockCommentEnd: "*/", + lineComment: "//", + + innerMode: function(state) { return {state: state.curState, mode: state.curMode}; } + }; + }, "htmlmixed", "clike"); + + CodeMirror.defineMIME("application/x-httpd-php", "php"); + CodeMirror.defineMIME("application/x-httpd-php-open", {name: "php", startOpen: true}); + CodeMirror.defineMIME("text/x-php", phpConfig); +}); diff --git a/adminx/assets/vendor/codemirror/mode/smarty/smarty.js b/adminx/assets/vendor/codemirror/mode/smarty/smarty.js new file mode 100644 index 0000000..57852fe --- /dev/null +++ b/adminx/assets/vendor/codemirror/mode/smarty/smarty.js @@ -0,0 +1,225 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +/** + * Smarty 2 and 3 mode. + */ + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { + "use strict"; + + CodeMirror.defineMode("smarty", function(config, parserConf) { + var rightDelimiter = parserConf.rightDelimiter || "}"; + var leftDelimiter = parserConf.leftDelimiter || "{"; + var version = parserConf.version || 2; + var baseMode = CodeMirror.getMode(config, parserConf.baseMode || "null"); + + var keyFunctions = ["debug", "extends", "function", "include", "literal"]; + var regs = { + operatorChars: /[+\-*&%=<>!?]/, + validIdentifier: /[a-zA-Z0-9_]/, + stringChar: /['"]/ + }; + + var last; + function cont(style, lastType) { + last = lastType; + return style; + } + + function chain(stream, state, parser) { + state.tokenize = parser; + return parser(stream, state); + } + + // Smarty 3 allows { and } surrounded by whitespace to NOT slip into Smarty mode + function doesNotCount(stream, pos) { + if (pos == null) pos = stream.pos; + return version === 3 && leftDelimiter == "{" && + (pos == stream.string.length || /\s/.test(stream.string.charAt(pos))); + } + + function tokenTop(stream, state) { + var string = stream.string; + for (var scan = stream.pos;;) { + var nextMatch = string.indexOf(leftDelimiter, scan); + scan = nextMatch + leftDelimiter.length; + if (nextMatch == -1 || !doesNotCount(stream, nextMatch + leftDelimiter.length)) break; + } + if (nextMatch == stream.pos) { + stream.match(leftDelimiter); + if (stream.eat("*")) { + return chain(stream, state, tokenBlock("comment", "*" + rightDelimiter)); + } else { + state.depth++; + state.tokenize = tokenSmarty; + last = "startTag"; + return "tag"; + } + } + + if (nextMatch > -1) stream.string = string.slice(0, nextMatch); + var token = baseMode.token(stream, state.base); + if (nextMatch > -1) stream.string = string; + return token; + } + + // parsing Smarty content + function tokenSmarty(stream, state) { + if (stream.match(rightDelimiter, true)) { + if (version === 3) { + state.depth--; + if (state.depth <= 0) { + state.tokenize = tokenTop; + } + } else { + state.tokenize = tokenTop; + } + return cont("tag", null); + } + + if (stream.match(leftDelimiter, true)) { + state.depth++; + return cont("tag", "startTag"); + } + + var ch = stream.next(); + if (ch == "$") { + stream.eatWhile(regs.validIdentifier); + return cont("variable-2", "variable"); + } else if (ch == "|") { + return cont("operator", "pipe"); + } else if (ch == ".") { + return cont("operator", "property"); + } else if (regs.stringChar.test(ch)) { + state.tokenize = tokenAttribute(ch); + return cont("string", "string"); + } else if (regs.operatorChars.test(ch)) { + stream.eatWhile(regs.operatorChars); + return cont("operator", "operator"); + } else if (ch == "[" || ch == "]") { + return cont("bracket", "bracket"); + } else if (ch == "(" || ch == ")") { + return cont("bracket", "operator"); + } else if (/\d/.test(ch)) { + stream.eatWhile(/\d/); + return cont("number", "number"); + } else { + + if (state.last == "variable") { + if (ch == "@") { + stream.eatWhile(regs.validIdentifier); + return cont("property", "property"); + } else if (ch == "|") { + stream.eatWhile(regs.validIdentifier); + return cont("qualifier", "modifier"); + } + } else if (state.last == "pipe") { + stream.eatWhile(regs.validIdentifier); + return cont("qualifier", "modifier"); + } else if (state.last == "whitespace") { + stream.eatWhile(regs.validIdentifier); + return cont("attribute", "modifier"); + } if (state.last == "property") { + stream.eatWhile(regs.validIdentifier); + return cont("property", null); + } else if (/\s/.test(ch)) { + last = "whitespace"; + return null; + } + + var str = ""; + if (ch != "/") { + str += ch; + } + var c = null; + while (c = stream.eat(regs.validIdentifier)) { + str += c; + } + for (var i=0, j=keyFunctions.length; i +* @version 3.0 +* @date 05.07.2013 +*/ +CodeMirror.defineMode("smartymixed", function(config) { + var settings, regs, helpers, parsers, + htmlMixedMode = CodeMirror.getMode(config, "htmlmixed"), + smartyMode = CodeMirror.getMode(config, "smarty"), + + settings = { + rightDelimiter: '}', + leftDelimiter: '{' + }; + + if (config.hasOwnProperty("leftDelimiter")) { + settings.leftDelimiter = config.leftDelimiter; + } + if (config.hasOwnProperty("rightDelimiter")) { + settings.rightDelimiter = config.rightDelimiter; + } + + regs = { + smartyComment: new RegExp("^" + settings.leftDelimiter + "\\*"), + literalOpen: new RegExp(settings.leftDelimiter + "literal" + settings.rightDelimiter), + literalClose: new RegExp(settings.leftDelimiter + "\/literal" + settings.rightDelimiter), + hasLeftDelimeter: new RegExp(".*" + settings.leftDelimiter), + htmlHasLeftDelimeter: new RegExp("[^<>]*" + settings.leftDelimiter) + }; + + helpers = { + chain: function(stream, state, parser) { + state.tokenize = parser; + return parser(stream, state); + }, + + cleanChain: function(stream, state, parser) { + state.tokenize = null; + state.localState = null; + state.localMode = null; + return (typeof parser == "string") ? (parser ? parser : null) : parser(stream, state); + }, + + maybeBackup: function(stream, pat, style) { + var cur = stream.current(); + var close = cur.search(pat), + m; + if (close > - 1) stream.backUp(cur.length - close); + else if (m = cur.match(/<\/?$/)) { + stream.backUp(cur.length); + if (!stream.match(pat, false)) stream.match(cur[0]); + } + return style; + } + }; + + parsers = { + html: function(stream, state) { + if (!state.inLiteral && stream.match(regs.htmlHasLeftDelimeter, false) && state.htmlMixedState.htmlState.tagName === null) { + state.tokenize = parsers.smarty; + state.localMode = smartyMode; + state.localState = smartyMode.startState(htmlMixedMode.indent(state.htmlMixedState, "")); + return helpers.maybeBackup(stream, settings.leftDelimiter, smartyMode.token(stream, state.localState)); + } else if (!state.inLiteral && stream.match(settings.leftDelimiter, false)) { + state.tokenize = parsers.smarty; + state.localMode = smartyMode; + state.localState = smartyMode.startState(htmlMixedMode.indent(state.htmlMixedState, "")); + return helpers.maybeBackup(stream, settings.leftDelimiter, smartyMode.token(stream, state.localState)); + } + return htmlMixedMode.token(stream, state.htmlMixedState); + }, + + smarty: function(stream, state) { + if (stream.match(settings.leftDelimiter, false)) { + if (stream.match(regs.smartyComment, false)) { + return helpers.chain(stream, state, parsers.inBlock("comment", "*" + settings.rightDelimiter)); + } + } else if (stream.match(settings.rightDelimiter, false)) { + stream.eat(settings.rightDelimiter); + state.tokenize = parsers.html; + state.localMode = htmlMixedMode; + state.localState = state.htmlMixedState; + return "tag"; + } + + return helpers.maybeBackup(stream, settings.rightDelimiter, smartyMode.token(stream, state.localState)); + }, + + inBlock: function(style, terminator) { + return function(stream, state) { + while (!stream.eol()) { + if (stream.match(terminator)) { + helpers.cleanChain(stream, state, ""); + break; + } + stream.next(); + } + return style; + }; + } + }; + + return { + startState: function() { + var state = htmlMixedMode.startState(); + return { + token: parsers.html, + localMode: null, + localState: null, + htmlMixedState: state, + tokenize: null, + inLiteral: false + }; + }, + + copyState: function(state) { + var local = null, tok = (state.tokenize || state.token); + if (state.localState) { + local = CodeMirror.copyState((tok != parsers.html ? smartyMode : htmlMixedMode), state.localState); + } + return { + token: state.token, + tokenize: state.tokenize, + localMode: state.localMode, + localState: local, + htmlMixedState: CodeMirror.copyState(htmlMixedMode, state.htmlMixedState), + inLiteral: state.inLiteral + }; + }, + + token: function(stream, state) { + if (stream.match(settings.leftDelimiter, false)) { + if (!state.inLiteral && stream.match(regs.literalOpen, true)) { + state.inLiteral = true; + return "keyword"; + } else if (state.inLiteral && stream.match(regs.literalClose, true)) { + state.inLiteral = false; + return "keyword"; + } + } + if (state.inLiteral && state.localState != state.htmlMixedState) { + state.tokenize = parsers.html; + state.localMode = htmlMixedMode; + state.localState = state.htmlMixedState; + } + + var style = (state.tokenize || state.token)(stream, state); + return style; + }, + + indent: function(state, textAfter) { + if (state.localMode == smartyMode + || (state.inLiteral && !state.localMode) + || regs.hasLeftDelimeter.test(textAfter)) { + return CodeMirror.Pass; + } + return htmlMixedMode.indent(state.htmlMixedState, textAfter); + }, + + innerMode: function(state) { + return { + state: state.localState || state.htmlMixedState, + mode: state.localMode || htmlMixedMode + }; + } + }; +}, +"htmlmixed"); + +CodeMirror.defineMIME("text/x-smarty", "smartymixed"); +// vim: et ts=2 sts=2 sw=2 \ No newline at end of file diff --git a/adminx/assets/vendor/codemirror/mode/sql/sql.js b/adminx/assets/vendor/codemirror/mode/sql/sql.js new file mode 100644 index 0000000..e43495d --- /dev/null +++ b/adminx/assets/vendor/codemirror/mode/sql/sql.js @@ -0,0 +1,503 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { +"use strict"; + +CodeMirror.defineMode("sql", function(config, parserConfig) { + var client = parserConfig.client || {}, + atoms = parserConfig.atoms || {"false": true, "true": true, "null": true}, + builtin = parserConfig.builtin || set(defaultBuiltin), + keywords = parserConfig.keywords || set(sqlKeywords), + operatorChars = parserConfig.operatorChars || /^[*+\-%<>!=&|~^\/]/, + support = parserConfig.support || {}, + hooks = parserConfig.hooks || {}, + dateSQL = parserConfig.dateSQL || {"date" : true, "time" : true, "timestamp" : true}, + backslashStringEscapes = parserConfig.backslashStringEscapes !== false, + brackets = parserConfig.brackets || /^[\{}\(\)\[\]]/, + punctuation = parserConfig.punctuation || /^[;.,:]/ + + function tokenBase(stream, state) { + var ch = stream.next(); + + // call hooks from the mime type + if (hooks[ch]) { + var result = hooks[ch](stream, state); + if (result !== false) return result; + } + + if (support.hexNumber && + ((ch == "0" && stream.match(/^[xX][0-9a-fA-F]+/)) + || (ch == "x" || ch == "X") && stream.match(/^'[0-9a-fA-F]+'/))) { + // hex + // ref: http://dev.mysql.com/doc/refman/5.5/en/hexadecimal-literals.html + return "number"; + } else if (support.binaryNumber && + (((ch == "b" || ch == "B") && stream.match(/^'[01]+'/)) + || (ch == "0" && stream.match(/^b[01]+/)))) { + // bitstring + // ref: http://dev.mysql.com/doc/refman/5.5/en/bit-field-literals.html + return "number"; + } else if (ch.charCodeAt(0) > 47 && ch.charCodeAt(0) < 58) { + // numbers + // ref: http://dev.mysql.com/doc/refman/5.5/en/number-literals.html + stream.match(/^[0-9]*(\.[0-9]+)?([eE][-+]?[0-9]+)?/); + support.decimallessFloat && stream.match(/^\.(?!\.)/); + return "number"; + } else if (ch == "?" && (stream.eatSpace() || stream.eol() || stream.eat(";"))) { + // placeholders + return "variable-3"; + } else if (ch == "'" || (ch == '"' && support.doubleQuote)) { + // strings + // ref: http://dev.mysql.com/doc/refman/5.5/en/string-literals.html + state.tokenize = tokenLiteral(ch); + return state.tokenize(stream, state); + } else if ((((support.nCharCast && (ch == "n" || ch == "N")) + || (support.charsetCast && ch == "_" && stream.match(/[a-z][a-z0-9]*/i))) + && (stream.peek() == "'" || stream.peek() == '"'))) { + // charset casting: _utf8'str', N'str', n'str' + // ref: http://dev.mysql.com/doc/refman/5.5/en/string-literals.html + return "keyword"; + } else if (support.escapeConstant && (ch == "e" || ch == "E") + && (stream.peek() == "'" || (stream.peek() == '"' && support.doubleQuote))) { + // escape constant: E'str', e'str' + // ref: https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-STRINGS-ESCAPE + state.tokenize = function(stream, state) { + return (state.tokenize = tokenLiteral(stream.next(), true))(stream, state); + } + return "keyword"; + } else if (support.commentSlashSlash && ch == "/" && stream.eat("/")) { + // 1-line comment + stream.skipToEnd(); + return "comment"; + } else if ((support.commentHash && ch == "#") + || (ch == "-" && stream.eat("-") && (!support.commentSpaceRequired || stream.eat(" ")))) { + // 1-line comments + // ref: https://kb.askmonty.org/en/comment-syntax/ + stream.skipToEnd(); + return "comment"; + } else if (ch == "/" && stream.eat("*")) { + // multi-line comments + // ref: https://kb.askmonty.org/en/comment-syntax/ + state.tokenize = tokenComment(1); + return state.tokenize(stream, state); + } else if (ch == ".") { + // .1 for 0.1 + if (support.zerolessFloat && stream.match(/^(?:\d+(?:e[+-]?\d+)?)/i)) + return "number"; + if (stream.match(/^\.+/)) + return null + // .table_name (ODBC) + // // ref: http://dev.mysql.com/doc/refman/5.6/en/identifier-qualifiers.html + if (support.ODBCdotTable && stream.match(/^[\w\d_]+/)) + return "variable-2"; + } else if (operatorChars.test(ch)) { + // operators + stream.eatWhile(operatorChars); + return "operator"; + } else if (brackets.test(ch)) { + // brackets + return "bracket"; + } else if (punctuation.test(ch)) { + // punctuation + stream.eatWhile(punctuation); + return "punctuation"; + } else if (ch == '{' && + (stream.match(/^( )*(d|D|t|T|ts|TS)( )*'[^']*'( )*}/) || stream.match(/^( )*(d|D|t|T|ts|TS)( )*"[^"]*"( )*}/))) { + // dates (weird ODBC syntax) + // ref: http://dev.mysql.com/doc/refman/5.5/en/date-and-time-literals.html + return "number"; + } else { + stream.eatWhile(/^[_\w\d]/); + var word = stream.current().toLowerCase(); + // dates (standard SQL syntax) + // ref: http://dev.mysql.com/doc/refman/5.5/en/date-and-time-literals.html + if (dateSQL.hasOwnProperty(word) && (stream.match(/^( )+'[^']*'/) || stream.match(/^( )+"[^"]*"/))) + return "number"; + if (atoms.hasOwnProperty(word)) return "atom"; + if (builtin.hasOwnProperty(word)) return "builtin"; + if (keywords.hasOwnProperty(word)) return "keyword"; + if (client.hasOwnProperty(word)) return "string-2"; + return null; + } + } + + // 'string', with char specified in quote escaped by '\' + function tokenLiteral(quote, backslashEscapes) { + return function(stream, state) { + var escaped = false, ch; + while ((ch = stream.next()) != null) { + if (ch == quote && !escaped) { + state.tokenize = tokenBase; + break; + } + escaped = (backslashStringEscapes || backslashEscapes) && !escaped && ch == "\\"; + } + return "string"; + }; + } + function tokenComment(depth) { + return function(stream, state) { + var m = stream.match(/^.*?(\/\*|\*\/)/) + if (!m) stream.skipToEnd() + else if (m[1] == "/*") state.tokenize = tokenComment(depth + 1) + else if (depth > 1) state.tokenize = tokenComment(depth - 1) + else state.tokenize = tokenBase + return "comment" + } + } + + function pushContext(stream, state, type) { + state.context = { + prev: state.context, + indent: stream.indentation(), + col: stream.column(), + type: type + }; + } + + function popContext(state) { + state.indent = state.context.indent; + state.context = state.context.prev; + } + + return { + startState: function() { + return {tokenize: tokenBase, context: null}; + }, + + token: function(stream, state) { + if (stream.sol()) { + if (state.context && state.context.align == null) + state.context.align = false; + } + if (state.tokenize == tokenBase && stream.eatSpace()) return null; + + var style = state.tokenize(stream, state); + if (style == "comment") return style; + + if (state.context && state.context.align == null) + state.context.align = true; + + var tok = stream.current(); + if (tok == "(") + pushContext(stream, state, ")"); + else if (tok == "[") + pushContext(stream, state, "]"); + else if (state.context && state.context.type == tok) + popContext(state); + return style; + }, + + indent: function(state, textAfter) { + var cx = state.context; + if (!cx) return CodeMirror.Pass; + var closing = textAfter.charAt(0) == cx.type; + if (cx.align) return cx.col + (closing ? 0 : 1); + else return cx.indent + (closing ? 0 : config.indentUnit); + }, + + blockCommentStart: "/*", + blockCommentEnd: "*/", + lineComment: support.commentSlashSlash ? "//" : support.commentHash ? "#" : "--", + closeBrackets: "()[]{}''\"\"``" + }; +}); + + // `identifier` + function hookIdentifier(stream) { + // MySQL/MariaDB identifiers + // ref: http://dev.mysql.com/doc/refman/5.6/en/identifier-qualifiers.html + var ch; + while ((ch = stream.next()) != null) { + if (ch == "`" && !stream.eat("`")) return "variable-2"; + } + stream.backUp(stream.current().length - 1); + return stream.eatWhile(/\w/) ? "variable-2" : null; + } + + // "identifier" + function hookIdentifierDoublequote(stream) { + // Standard SQL /SQLite identifiers + // ref: http://web.archive.org/web/20160813185132/http://savage.net.au/SQL/sql-99.bnf.html#delimited%20identifier + // ref: http://sqlite.org/lang_keywords.html + var ch; + while ((ch = stream.next()) != null) { + if (ch == "\"" && !stream.eat("\"")) return "variable-2"; + } + stream.backUp(stream.current().length - 1); + return stream.eatWhile(/\w/) ? "variable-2" : null; + } + + // variable token + function hookVar(stream) { + // variables + // @@prefix.varName @varName + // varName can be quoted with ` or ' or " + // ref: http://dev.mysql.com/doc/refman/5.5/en/user-variables.html + if (stream.eat("@")) { + stream.match(/^session\./); + stream.match(/^local\./); + stream.match(/^global\./); + } + + if (stream.eat("'")) { + stream.match(/^.*'/); + return "variable-2"; + } else if (stream.eat('"')) { + stream.match(/^.*"/); + return "variable-2"; + } else if (stream.eat("`")) { + stream.match(/^.*`/); + return "variable-2"; + } else if (stream.match(/^[0-9a-zA-Z$\.\_]+/)) { + return "variable-2"; + } + return null; + }; + + // short client keyword token + function hookClient(stream) { + // \N means NULL + // ref: http://dev.mysql.com/doc/refman/5.5/en/null-values.html + if (stream.eat("N")) { + return "atom"; + } + // \g, etc + // ref: http://dev.mysql.com/doc/refman/5.5/en/mysql-commands.html + return stream.match(/^[a-zA-Z.#!?]/) ? "variable-2" : null; + } + + // these keywords are used by all SQL dialects (however, a mode can still overwrite it) + var sqlKeywords = "alter and as asc between by count create delete desc distinct drop from group having in insert into is join like not on or order select set table union update values where limit "; + + // turn a space-separated list into an array + function set(str) { + var obj = {}, words = str.split(" "); + for (var i = 0; i < words.length; ++i) obj[words[i]] = true; + return obj; + } + + var defaultBuiltin = "bool boolean bit blob enum long longblob longtext medium mediumblob mediumint mediumtext time timestamp tinyblob tinyint tinytext text bigint int int1 int2 int3 int4 int8 integer float float4 float8 double char varbinary varchar varcharacter precision real date datetime year unsigned signed decimal numeric" + + // A generic SQL Mode. It's not a standard, it just try to support what is generally supported + CodeMirror.defineMIME("text/x-sql", { + name: "sql", + keywords: set(sqlKeywords + "begin"), + builtin: set(defaultBuiltin), + atoms: set("false true null unknown"), + dateSQL: set("date time timestamp"), + support: set("ODBCdotTable doubleQuote binaryNumber hexNumber") + }); + + CodeMirror.defineMIME("text/x-mssql", { + name: "sql", + client: set("$partition binary_checksum checksum connectionproperty context_info current_request_id error_line error_message error_number error_procedure error_severity error_state formatmessage get_filestream_transaction_context getansinull host_id host_name isnull isnumeric min_active_rowversion newid newsequentialid rowcount_big xact_state object_id"), + keywords: set(sqlKeywords + "begin trigger proc view index for add constraint key primary foreign collate clustered nonclustered declare exec go if use index holdlock nolock nowait paglock readcommitted readcommittedlock readpast readuncommitted repeatableread rowlock serializable snapshot tablock tablockx updlock with"), + builtin: set("bigint numeric bit smallint decimal smallmoney int tinyint money float real char varchar text nchar nvarchar ntext binary varbinary image cursor timestamp hierarchyid uniqueidentifier sql_variant xml table "), + atoms: set("is not null like and or in left right between inner outer join all any some cross unpivot pivot exists"), + operatorChars: /^[*+\-%<>!=^\&|\/]/, + brackets: /^[\{}\(\)]/, + punctuation: /^[;.,:/]/, + backslashStringEscapes: false, + dateSQL: set("date datetimeoffset datetime2 smalldatetime datetime time"), + hooks: { + "@": hookVar + } + }); + + CodeMirror.defineMIME("text/x-mysql", { + name: "sql", + client: set("charset clear connect edit ego exit go help nopager notee nowarning pager print prompt quit rehash source status system tee"), + keywords: set(sqlKeywords + "accessible action add after algorithm all analyze asensitive at authors auto_increment autocommit avg avg_row_length before binary binlog both btree cache call cascade cascaded case catalog_name chain change changed character check checkpoint checksum class_origin client_statistics close coalesce code collate collation collations column columns comment commit committed completion concurrent condition connection consistent constraint contains continue contributors convert cross current current_date current_time current_timestamp current_user cursor data database databases day_hour day_microsecond day_minute day_second deallocate dec declare default delay_key_write delayed delimiter des_key_file describe deterministic dev_pop dev_samp deviance diagnostics directory disable discard distinctrow div dual dumpfile each elseif enable enclosed end ends engine engines enum errors escape escaped even event events every execute exists exit explain extended fast fetch field fields first flush for force foreign found_rows full fulltext function general get global grant grants group group_concat handler hash help high_priority hosts hour_microsecond hour_minute hour_second if ignore ignore_server_ids import index index_statistics infile inner innodb inout insensitive insert_method install interval invoker isolation iterate key keys kill language last leading leave left level limit linear lines list load local localtime localtimestamp lock logs low_priority master master_heartbeat_period master_ssl_verify_server_cert masters match max max_rows maxvalue message_text middleint migrate min min_rows minute_microsecond minute_second mod mode modifies modify mutex mysql_errno natural next no no_write_to_binlog offline offset one online open optimize option optionally out outer outfile pack_keys parser partition partitions password phase plugin plugins prepare preserve prev primary privileges procedure processlist profile profiles purge query quick range read read_write reads real rebuild recover references regexp relaylog release remove rename reorganize repair repeatable replace require resignal restrict resume return returns revoke right rlike rollback rollup row row_format rtree savepoint schedule schema schema_name schemas second_microsecond security sensitive separator serializable server session share show signal slave slow smallint snapshot soname spatial specific sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_no_cache sql_small_result sqlexception sqlstate sqlwarning ssl start starting starts status std stddev stddev_pop stddev_samp storage straight_join subclass_origin sum suspend table_name table_statistics tables tablespace temporary terminated to trailing transaction trigger triggers truncate uncommitted undo uninstall unique unlock upgrade usage use use_frm user user_resources user_statistics using utc_date utc_time utc_timestamp value variables varying view views warnings when while with work write xa xor year_month zerofill begin do then else loop repeat"), + builtin: set("bool boolean bit blob decimal double float long longblob longtext medium mediumblob mediumint mediumtext time timestamp tinyblob tinyint tinytext text bigint int int1 int2 int3 int4 int8 integer float float4 float8 double char varbinary varchar varcharacter precision date datetime year unsigned signed numeric"), + atoms: set("false true null unknown"), + operatorChars: /^[*+\-%<>!=&|^]/, + dateSQL: set("date time timestamp"), + support: set("ODBCdotTable decimallessFloat zerolessFloat binaryNumber hexNumber doubleQuote nCharCast charsetCast commentHash commentSpaceRequired"), + hooks: { + "@": hookVar, + "`": hookIdentifier, + "\\": hookClient + } + }); + + CodeMirror.defineMIME("text/x-mariadb", { + name: "sql", + client: set("charset clear connect edit ego exit go help nopager notee nowarning pager print prompt quit rehash source status system tee"), + keywords: set(sqlKeywords + "accessible action add after algorithm all always analyze asensitive at authors auto_increment autocommit avg avg_row_length before binary binlog both btree cache call cascade cascaded case catalog_name chain change changed character check checkpoint checksum class_origin client_statistics close coalesce code collate collation collations column columns comment commit committed completion concurrent condition connection consistent constraint contains continue contributors convert cross current current_date current_time current_timestamp current_user cursor data database databases day_hour day_microsecond day_minute day_second deallocate dec declare default delay_key_write delayed delimiter des_key_file describe deterministic dev_pop dev_samp deviance diagnostics directory disable discard distinctrow div dual dumpfile each elseif enable enclosed end ends engine engines enum errors escape escaped even event events every execute exists exit explain extended fast fetch field fields first flush for force foreign found_rows full fulltext function general generated get global grant grants group groupby_concat handler hard hash help high_priority hosts hour_microsecond hour_minute hour_second if ignore ignore_server_ids import index index_statistics infile inner innodb inout insensitive insert_method install interval invoker isolation iterate key keys kill language last leading leave left level limit linear lines list load local localtime localtimestamp lock logs low_priority master master_heartbeat_period master_ssl_verify_server_cert masters match max max_rows maxvalue message_text middleint migrate min min_rows minute_microsecond minute_second mod mode modifies modify mutex mysql_errno natural next no no_write_to_binlog offline offset one online open optimize option optionally out outer outfile pack_keys parser partition partitions password persistent phase plugin plugins prepare preserve prev primary privileges procedure processlist profile profiles purge query quick range read read_write reads real rebuild recover references regexp relaylog release remove rename reorganize repair repeatable replace require resignal restrict resume return returns revoke right rlike rollback rollup row row_format rtree savepoint schedule schema schema_name schemas second_microsecond security sensitive separator serializable server session share show shutdown signal slave slow smallint snapshot soft soname spatial specific sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_no_cache sql_small_result sqlexception sqlstate sqlwarning ssl start starting starts status std stddev stddev_pop stddev_samp storage straight_join subclass_origin sum suspend table_name table_statistics tables tablespace temporary terminated to trailing transaction trigger triggers truncate uncommitted undo uninstall unique unlock upgrade usage use use_frm user user_resources user_statistics using utc_date utc_time utc_timestamp value variables varying view views virtual warnings when while with work write xa xor year_month zerofill begin do then else loop repeat"), + builtin: set("bool boolean bit blob decimal double float long longblob longtext medium mediumblob mediumint mediumtext time timestamp tinyblob tinyint tinytext text bigint int int1 int2 int3 int4 int8 integer float float4 float8 double char varbinary varchar varcharacter precision date datetime year unsigned signed numeric"), + atoms: set("false true null unknown"), + operatorChars: /^[*+\-%<>!=&|^]/, + dateSQL: set("date time timestamp"), + support: set("ODBCdotTable decimallessFloat zerolessFloat binaryNumber hexNumber doubleQuote nCharCast charsetCast commentHash commentSpaceRequired"), + hooks: { + "@": hookVar, + "`": hookIdentifier, + "\\": hookClient + } + }); + + // provided by the phpLiteAdmin project - phpliteadmin.org + CodeMirror.defineMIME("text/x-sqlite", { + name: "sql", + // commands of the official SQLite client, ref: https://www.sqlite.org/cli.html#dotcmd + client: set("auth backup bail binary changes check clone databases dbinfo dump echo eqp exit explain fullschema headers help import imposter indexes iotrace limit lint load log mode nullvalue once open output print prompt quit read restore save scanstats schema separator session shell show stats system tables testcase timeout timer trace vfsinfo vfslist vfsname width"), + // ref: http://sqlite.org/lang_keywords.html + keywords: set(sqlKeywords + "abort action add after all analyze attach autoincrement before begin cascade case cast check collate column commit conflict constraint cross current_date current_time current_timestamp database default deferrable deferred detach each else end escape except exclusive exists explain fail for foreign full glob if ignore immediate index indexed initially inner instead intersect isnull key left limit match natural no notnull null of offset outer plan pragma primary query raise recursive references regexp reindex release rename replace restrict right rollback row savepoint temp temporary then to transaction trigger unique using vacuum view virtual when with without"), + // SQLite is weakly typed, ref: http://sqlite.org/datatype3.html. This is just a list of some common types. + builtin: set("bool boolean bit blob decimal double float long longblob longtext medium mediumblob mediumint mediumtext time timestamp tinyblob tinyint tinytext text clob bigint int int2 int8 integer float double char varchar date datetime year unsigned signed numeric real"), + // ref: http://sqlite.org/syntax/literal-value.html + atoms: set("null current_date current_time current_timestamp"), + // ref: http://sqlite.org/lang_expr.html#binaryops + operatorChars: /^[*+\-%<>!=&|/~]/, + // SQLite is weakly typed, ref: http://sqlite.org/datatype3.html. This is just a list of some common types. + dateSQL: set("date time timestamp datetime"), + support: set("decimallessFloat zerolessFloat"), + identifierQuote: "\"", //ref: http://sqlite.org/lang_keywords.html + hooks: { + // bind-parameters ref:http://sqlite.org/lang_expr.html#varparam + "@": hookVar, + ":": hookVar, + "?": hookVar, + "$": hookVar, + // The preferred way to escape Identifiers is using double quotes, ref: http://sqlite.org/lang_keywords.html + "\"": hookIdentifierDoublequote, + // there is also support for backtics, ref: http://sqlite.org/lang_keywords.html + "`": hookIdentifier + } + }); + + // the query language used by Apache Cassandra is called CQL, but this mime type + // is called Cassandra to avoid confusion with Contextual Query Language + CodeMirror.defineMIME("text/x-cassandra", { + name: "sql", + client: { }, + keywords: set("add all allow alter and any apply as asc authorize batch begin by clustering columnfamily compact consistency count create custom delete desc distinct drop each_quorum exists filtering from grant if in index insert into key keyspace keyspaces level limit local_one local_quorum modify nan norecursive nosuperuser not of on one order password permission permissions primary quorum rename revoke schema select set storage superuser table three to token truncate ttl two type unlogged update use user users using values where with writetime"), + builtin: set("ascii bigint blob boolean counter decimal double float frozen inet int list map static text timestamp timeuuid tuple uuid varchar varint"), + atoms: set("false true infinity NaN"), + operatorChars: /^[<>=]/, + dateSQL: { }, + support: set("commentSlashSlash decimallessFloat"), + hooks: { } + }); + + // this is based on Peter Raganitsch's 'plsql' mode + CodeMirror.defineMIME("text/x-plsql", { + name: "sql", + client: set("appinfo arraysize autocommit autoprint autorecovery autotrace blockterminator break btitle cmdsep colsep compatibility compute concat copycommit copytypecheck define describe echo editfile embedded escape exec execute feedback flagger flush heading headsep instance linesize lno loboffset logsource long longchunksize markup native newpage numformat numwidth pagesize pause pno recsep recsepchar release repfooter repheader serveroutput shiftinout show showmode size spool sqlblanklines sqlcase sqlcode sqlcontinue sqlnumber sqlpluscompatibility sqlprefix sqlprompt sqlterminator suffix tab term termout time timing trimout trimspool ttitle underline verify version wrap"), + keywords: set("abort accept access add all alter and any array arraylen as asc assert assign at attributes audit authorization avg base_table begin between binary_integer body boolean by case cast char char_base check close cluster clusters colauth column comment commit compress connect connected constant constraint crash create current currval cursor data_base database date dba deallocate debugoff debugon decimal declare default definition delay delete desc digits dispose distinct do drop else elseif elsif enable end entry escape exception exception_init exchange exclusive exists exit external fast fetch file for force form from function generic goto grant group having identified if immediate in increment index indexes indicator initial initrans insert interface intersect into is key level library like limited local lock log logging long loop master maxextents maxtrans member minextents minus mislabel mode modify multiset new next no noaudit nocompress nologging noparallel not nowait number_base object of off offline on online only open option or order out package parallel partition pctfree pctincrease pctused pls_integer positive positiven pragma primary prior private privileges procedure public raise range raw read rebuild record ref references refresh release rename replace resource restrict return returning returns reverse revoke rollback row rowid rowlabel rownum rows run savepoint schema segment select separate session set share snapshot some space split sql start statement storage subtype successful synonym tabauth table tables tablespace task terminate then to trigger truncate type union unique unlimited unrecoverable unusable update use using validate value values variable view views when whenever where while with work"), + builtin: set("abs acos add_months ascii asin atan atan2 average bfile bfilename bigserial bit blob ceil character chartorowid chr clob concat convert cos cosh count dec decode deref dual dump dup_val_on_index empty error exp false float floor found glb greatest hextoraw initcap instr instrb int integer isopen last_day least length lengthb ln lower lpad ltrim lub make_ref max min mlslabel mod months_between natural naturaln nchar nclob new_time next_day nextval nls_charset_decl_len nls_charset_id nls_charset_name nls_initcap nls_lower nls_sort nls_upper nlssort no_data_found notfound null number numeric nvarchar2 nvl others power rawtohex real reftohex round rowcount rowidtochar rowtype rpad rtrim serial sign signtype sin sinh smallint soundex sqlcode sqlerrm sqrt stddev string substr substrb sum sysdate tan tanh to_char text to_date to_label to_multi_byte to_number to_single_byte translate true trunc uid unlogged upper user userenv varchar varchar2 variance varying vsize xml"), + operatorChars: /^[*\/+\-%<>!=~]/, + dateSQL: set("date time timestamp"), + support: set("doubleQuote nCharCast zerolessFloat binaryNumber hexNumber") + }); + + // Created to support specific hive keywords + CodeMirror.defineMIME("text/x-hive", { + name: "sql", + keywords: set("select alter $elem$ $key$ $value$ add after all analyze and archive as asc before between binary both bucket buckets by cascade case cast change cluster clustered clusterstatus collection column columns comment compute concatenate continue create cross cursor data database databases dbproperties deferred delete delimited desc describe directory disable distinct distribute drop else enable end escaped exclusive exists explain export extended external fetch fields fileformat first format formatted from full function functions grant group having hold_ddltime idxproperties if import in index indexes inpath inputdriver inputformat insert intersect into is items join keys lateral left like limit lines load local location lock locks mapjoin materialized minus msck no_drop nocompress not of offline on option or order out outer outputdriver outputformat overwrite partition partitioned partitions percent plus preserve procedure purge range rcfile read readonly reads rebuild recordreader recordwriter recover reduce regexp rename repair replace restrict revoke right rlike row schema schemas semi sequencefile serde serdeproperties set shared show show_database sort sorted ssl statistics stored streamtable table tables tablesample tblproperties temporary terminated textfile then tmp to touch transform trigger unarchive undo union uniquejoin unlock update use using utc utc_tmestamp view when where while with admin authorization char compact compactions conf cube current current_date current_timestamp day decimal defined dependency directories elem_type exchange file following for grouping hour ignore inner interval jar less logical macro minute month more none noscan over owner partialscan preceding pretty principals protection reload rewrite role roles rollup rows second server sets skewed transactions truncate unbounded unset uri user values window year"), + builtin: set("bool boolean long timestamp tinyint smallint bigint int float double date datetime unsigned string array struct map uniontype key_type utctimestamp value_type varchar"), + atoms: set("false true null unknown"), + operatorChars: /^[*+\-%<>!=]/, + dateSQL: set("date timestamp"), + support: set("ODBCdotTable doubleQuote binaryNumber hexNumber") + }); + + CodeMirror.defineMIME("text/x-pgsql", { + name: "sql", + client: set("source"), + // For PostgreSQL - https://www.postgresql.org/docs/11/sql-keywords-appendix.html + // For pl/pgsql lang - https://github.com/postgres/postgres/blob/REL_11_2/src/pl/plpgsql/src/pl_scanner.c + keywords: set(sqlKeywords + "a abort abs absent absolute access according action ada add admin after aggregate alias all allocate also alter always analyse analyze and any are array array_agg array_max_cardinality as asc asensitive assert assertion assignment asymmetric at atomic attach attribute attributes authorization avg backward base64 before begin begin_frame begin_partition bernoulli between bigint binary bit bit_length blob blocked bom boolean both breadth by c cache call called cardinality cascade cascaded case cast catalog catalog_name ceil ceiling chain char char_length character character_length character_set_catalog character_set_name character_set_schema characteristics characters check checkpoint class class_origin clob close cluster coalesce cobol collate collation collation_catalog collation_name collation_schema collect column column_name columns command_function command_function_code comment comments commit committed concurrently condition condition_number configuration conflict connect connection connection_name constant constraint constraint_catalog constraint_name constraint_schema constraints constructor contains content continue control conversion convert copy corr corresponding cost count covar_pop covar_samp create cross csv cube cume_dist current current_catalog current_date current_default_transform_group current_path current_role current_row current_schema current_time current_timestamp current_transform_group_for_type current_user cursor cursor_name cycle data database datalink datatype date datetime_interval_code datetime_interval_precision day db deallocate debug dec decimal declare default defaults deferrable deferred defined definer degree delete delimiter delimiters dense_rank depends depth deref derived desc describe descriptor detach detail deterministic diagnostics dictionary disable discard disconnect dispatch distinct dlnewcopy dlpreviouscopy dlurlcomplete dlurlcompleteonly dlurlcompletewrite dlurlpath dlurlpathonly dlurlpathwrite dlurlscheme dlurlserver dlvalue do document domain double drop dump dynamic dynamic_function dynamic_function_code each element else elseif elsif empty enable encoding encrypted end end_frame end_partition endexec enforced enum equals errcode error escape event every except exception exclude excluding exclusive exec execute exists exit exp explain expression extension external extract false family fetch file filter final first first_value flag float floor following for force foreach foreign fortran forward found frame_row free freeze from fs full function functions fusion g general generated get global go goto grant granted greatest group grouping groups handler having header hex hierarchy hint hold hour id identity if ignore ilike immediate immediately immutable implementation implicit import in include including increment indent index indexes indicator info inherit inherits initially inline inner inout input insensitive insert instance instantiable instead int integer integrity intersect intersection interval into invoker is isnull isolation join k key key_member key_type label lag language large last last_value lateral lead leading leakproof least left length level library like like_regex limit link listen ln load local localtime localtimestamp location locator lock locked log logged loop lower m map mapping match matched materialized max max_cardinality maxvalue member merge message message_length message_octet_length message_text method min minute minvalue mod mode modifies module month more move multiset mumps name names namespace national natural nchar nclob nesting new next nfc nfd nfkc nfkd nil no none normalize normalized not nothing notice notify notnull nowait nth_value ntile null nullable nullif nulls number numeric object occurrences_regex octet_length octets of off offset oids old on only open operator option options or order ordering ordinality others out outer output over overlaps overlay overriding owned owner p pad parallel parameter parameter_mode parameter_name parameter_ordinal_position parameter_specific_catalog parameter_specific_name parameter_specific_schema parser partial partition pascal passing passthrough password path percent percent_rank percentile_cont percentile_disc perform period permission pg_context pg_datatype_name pg_exception_context pg_exception_detail pg_exception_hint placing plans pli policy portion position position_regex power precedes preceding precision prepare prepared preserve primary print_strict_params prior privileges procedural procedure procedures program public publication query quote raise range rank read reads real reassign recheck recovery recursive ref references referencing refresh regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxx regr_sxy regr_syy reindex relative release rename repeatable replace replica requiring reset respect restart restore restrict result result_oid return returned_cardinality returned_length returned_octet_length returned_sqlstate returning returns reverse revoke right role rollback rollup routine routine_catalog routine_name routine_schema routines row row_count row_number rows rowtype rule savepoint scale schema schema_name schemas scope scope_catalog scope_name scope_schema scroll search second section security select selective self sensitive sequence sequences serializable server server_name session session_user set setof sets share show similar simple size skip slice smallint snapshot some source space specific specific_name specifictype sql sqlcode sqlerror sqlexception sqlstate sqlwarning sqrt stable stacked standalone start state statement static statistics stddev_pop stddev_samp stdin stdout storage strict strip structure style subclass_origin submultiset subscription substring substring_regex succeeds sum symmetric sysid system system_time system_user t table table_name tables tablesample tablespace temp template temporary text then ties time timestamp timezone_hour timezone_minute to token top_level_count trailing transaction transaction_active transactions_committed transactions_rolled_back transform transforms translate translate_regex translation treat trigger trigger_catalog trigger_name trigger_schema trim trim_array true truncate trusted type types uescape unbounded uncommitted under unencrypted union unique unknown unlink unlisten unlogged unnamed unnest until untyped update upper uri usage use_column use_variable user user_defined_type_catalog user_defined_type_code user_defined_type_name user_defined_type_schema using vacuum valid validate validator value value_of values var_pop var_samp varbinary varchar variable_conflict variadic varying verbose version versioning view views volatile warning when whenever where while whitespace width_bucket window with within without work wrapper write xml xmlagg xmlattributes xmlbinary xmlcast xmlcomment xmlconcat xmldeclaration xmldocument xmlelement xmlexists xmlforest xmliterate xmlnamespaces xmlparse xmlpi xmlquery xmlroot xmlschema xmlserialize xmltable xmltext xmlvalidate year yes zone"), + // https://www.postgresql.org/docs/11/datatype.html + builtin: set("bigint int8 bigserial serial8 bit varying varbit boolean bool box bytea character char varchar cidr circle date double precision float8 inet integer int int4 interval json jsonb line lseg macaddr macaddr8 money numeric decimal path pg_lsn point polygon real float4 smallint int2 smallserial serial2 serial serial4 text time without zone with timetz timestamp timestamptz tsquery tsvector txid_snapshot uuid xml"), + atoms: set("false true null unknown"), + operatorChars: /^[*\/+\-%<>!=&|^\/#@?~]/, + backslashStringEscapes: false, + dateSQL: set("date time timestamp"), + support: set("ODBCdotTable decimallessFloat zerolessFloat binaryNumber hexNumber nCharCast charsetCast escapeConstant") + }); + + // Google's SQL-like query language, GQL + CodeMirror.defineMIME("text/x-gql", { + name: "sql", + keywords: set("ancestor and asc by contains desc descendant distinct from group has in is limit offset on order select superset where"), + atoms: set("false true"), + builtin: set("blob datetime first key __key__ string integer double boolean null"), + operatorChars: /^[*+\-%<>!=]/ + }); + + // Greenplum + CodeMirror.defineMIME("text/x-gpsql", { + name: "sql", + client: set("source"), + //https://github.com/greenplum-db/gpdb/blob/master/src/include/parser/kwlist.h + keywords: set("abort absolute access action active add admin after aggregate all also alter always analyse analyze and any array as asc assertion assignment asymmetric at authorization backward before begin between bigint binary bit boolean both by cache called cascade cascaded case cast chain char character characteristics check checkpoint class close cluster coalesce codegen collate column comment commit committed concurrency concurrently configuration connection constraint constraints contains content continue conversion copy cost cpu_rate_limit create createdb createexttable createrole createuser cross csv cube current current_catalog current_date current_role current_schema current_time current_timestamp current_user cursor cycle data database day deallocate dec decimal declare decode default defaults deferrable deferred definer delete delimiter delimiters deny desc dictionary disable discard distinct distributed do document domain double drop dxl each else enable encoding encrypted end enum errors escape every except exchange exclude excluding exclusive execute exists explain extension external extract false family fetch fields filespace fill filter first float following for force foreign format forward freeze from full function global grant granted greatest group group_id grouping handler hash having header hold host hour identity if ignore ilike immediate immutable implicit in including inclusive increment index indexes inherit inherits initially inline inner inout input insensitive insert instead int integer intersect interval into invoker is isnull isolation join key language large last leading least left level like limit list listen load local localtime localtimestamp location lock log login mapping master match maxvalue median merge minute minvalue missing mode modifies modify month move name names national natural nchar new newline next no nocreatedb nocreateexttable nocreaterole nocreateuser noinherit nologin none noovercommit nosuperuser not nothing notify notnull nowait null nullif nulls numeric object of off offset oids old on only operator option options or order ordered others out outer over overcommit overlaps overlay owned owner parser partial partition partitions passing password percent percentile_cont percentile_disc placing plans position preceding precision prepare prepared preserve primary prior privileges procedural procedure protocol queue quote randomly range read readable reads real reassign recheck recursive ref references reindex reject relative release rename repeatable replace replica reset resource restart restrict returning returns revoke right role rollback rollup rootpartition row rows rule savepoint scatter schema scroll search second security segment select sequence serializable session session_user set setof sets share show similar simple smallint some split sql stable standalone start statement statistics stdin stdout storage strict strip subpartition subpartitions substring superuser symmetric sysid system table tablespace temp template temporary text then threshold ties time timestamp to trailing transaction treat trigger trim true truncate trusted type unbounded uncommitted unencrypted union unique unknown unlisten until update user using vacuum valid validation validator value values varchar variadic varying verbose version view volatile web when where whitespace window with within without work writable write xml xmlattributes xmlconcat xmlelement xmlexists xmlforest xmlparse xmlpi xmlroot xmlserialize year yes zone"), + builtin: set("bigint int8 bigserial serial8 bit varying varbit boolean bool box bytea character char varchar cidr circle date double precision float float8 inet integer int int4 interval json jsonb line lseg macaddr macaddr8 money numeric decimal path pg_lsn point polygon real float4 smallint int2 smallserial serial2 serial serial4 text time without zone with timetz timestamp timestamptz tsquery tsvector txid_snapshot uuid xml"), + atoms: set("false true null unknown"), + operatorChars: /^[*+\-%<>!=&|^\/#@?~]/, + dateSQL: set("date time timestamp"), + support: set("ODBCdotTable decimallessFloat zerolessFloat binaryNumber hexNumber nCharCast charsetCast") + }); + + // Spark SQL + CodeMirror.defineMIME("text/x-sparksql", { + name: "sql", + keywords: set("add after all alter analyze and anti archive array as asc at between bucket buckets by cache cascade case cast change clear cluster clustered codegen collection column columns comment commit compact compactions compute concatenate cost create cross cube current current_date current_timestamp database databases datata dbproperties defined delete delimited deny desc describe dfs directories distinct distribute drop else end escaped except exchange exists explain export extended external false fields fileformat first following for format formatted from full function functions global grant group grouping having if ignore import in index indexes inner inpath inputformat insert intersect interval into is items join keys last lateral lazy left like limit lines list load local location lock locks logical macro map minus msck natural no not null nulls of on optimize option options or order out outer outputformat over overwrite partition partitioned partitions percent preceding principals purge range recordreader recordwriter recover reduce refresh regexp rename repair replace reset restrict revoke right rlike role roles rollback rollup row rows schema schemas select semi separated serde serdeproperties set sets show skewed sort sorted start statistics stored stratify struct table tables tablesample tblproperties temp temporary terminated then to touch transaction transactions transform true truncate unarchive unbounded uncache union unlock unset use using values view when where window with"), + builtin: set("tinyint smallint int bigint boolean float double string binary timestamp decimal array map struct uniontype delimited serde sequencefile textfile rcfile inputformat outputformat"), + atoms: set("false true null"), + operatorChars: /^[*\/+\-%<>!=~&|^]/, + dateSQL: set("date time timestamp"), + support: set("ODBCdotTable doubleQuote zerolessFloat") + }); + + // Esper + CodeMirror.defineMIME("text/x-esper", { + name: "sql", + client: set("source"), + // http://www.espertech.com/esper/release-5.5.0/esper-reference/html/appendix_keywords.html + keywords: set("alter and as asc between by count create delete desc distinct drop from group having in insert into is join like not on or order select set table union update values where limit after all and as at asc avedev avg between by case cast coalesce count create current_timestamp day days delete define desc distinct else end escape events every exists false first from full group having hour hours in inner insert instanceof into irstream is istream join last lastweekday left limit like max match_recognize matches median measures metadatasql min minute minutes msec millisecond milliseconds not null offset on or order outer output partition pattern prev prior regexp retain-union retain-intersection right rstream sec second seconds select set some snapshot sql stddev sum then true unidirectional until update variable weekday when where window"), + builtin: {}, + atoms: set("false true null"), + operatorChars: /^[*+\-%<>!=&|^\/#@?~]/, + dateSQL: set("time"), + support: set("decimallessFloat zerolessFloat binaryNumber hexNumber") + }); +}); + +/* + How Properties of Mime Types are used by SQL Mode + ================================================= + + keywords: + A list of keywords you want to be highlighted. + builtin: + A list of builtin types you want to be highlighted (if you want types to be of class "builtin" instead of "keyword"). + operatorChars: + All characters that must be handled as operators. + client: + Commands parsed and executed by the client (not the server). + support: + A list of supported syntaxes which are not common, but are supported by more than 1 DBMS. + * ODBCdotTable: .tableName + * zerolessFloat: .1 + * doubleQuote + * nCharCast: N'string' + * charsetCast: _utf8'string' + * commentHash: use # char for comments + * commentSlashSlash: use // for comments + * commentSpaceRequired: require a space after -- for comments + atoms: + Keywords that must be highlighted as atoms,. Some DBMS's support more atoms than others: + UNKNOWN, INFINITY, UNDERFLOW, NaN... + dateSQL: + Used for date/time SQL standard syntax, because not all DBMS's support same temporal types. +*/ diff --git a/adminx/assets/vendor/codemirror/mode/xml/xml.js b/adminx/assets/vendor/codemirror/mode/xml/xml.js new file mode 100644 index 0000000..73c6e0e --- /dev/null +++ b/adminx/assets/vendor/codemirror/mode/xml/xml.js @@ -0,0 +1,413 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { +"use strict"; + +var htmlConfig = { + autoSelfClosers: {'area': true, 'base': true, 'br': true, 'col': true, 'command': true, + 'embed': true, 'frame': true, 'hr': true, 'img': true, 'input': true, + 'keygen': true, 'link': true, 'meta': true, 'param': true, 'source': true, + 'track': true, 'wbr': true, 'menuitem': true}, + implicitlyClosed: {'dd': true, 'li': true, 'optgroup': true, 'option': true, 'p': true, + 'rp': true, 'rt': true, 'tbody': true, 'td': true, 'tfoot': true, + 'th': true, 'tr': true}, + contextGrabbers: { + 'dd': {'dd': true, 'dt': true}, + 'dt': {'dd': true, 'dt': true}, + 'li': {'li': true}, + 'option': {'option': true, 'optgroup': true}, + 'optgroup': {'optgroup': true}, + 'p': {'address': true, 'article': true, 'aside': true, 'blockquote': true, 'dir': true, + 'div': true, 'dl': true, 'fieldset': true, 'footer': true, 'form': true, + 'h1': true, 'h2': true, 'h3': true, 'h4': true, 'h5': true, 'h6': true, + 'header': true, 'hgroup': true, 'hr': true, 'menu': true, 'nav': true, 'ol': true, + 'p': true, 'pre': true, 'section': true, 'table': true, 'ul': true}, + 'rp': {'rp': true, 'rt': true}, + 'rt': {'rp': true, 'rt': true}, + 'tbody': {'tbody': true, 'tfoot': true}, + 'td': {'td': true, 'th': true}, + 'tfoot': {'tbody': true}, + 'th': {'td': true, 'th': true}, + 'thead': {'tbody': true, 'tfoot': true}, + 'tr': {'tr': true} + }, + doNotIndent: {"pre": true}, + allowUnquoted: true, + allowMissing: true, + caseFold: true +} + +var xmlConfig = { + autoSelfClosers: {}, + implicitlyClosed: {}, + contextGrabbers: {}, + doNotIndent: {}, + allowUnquoted: false, + allowMissing: false, + allowMissingTagName: false, + caseFold: false +} + +CodeMirror.defineMode("xml", function(editorConf, config_) { + var indentUnit = editorConf.indentUnit + var config = {} + var defaults = config_.htmlMode ? htmlConfig : xmlConfig + for (var prop in defaults) config[prop] = defaults[prop] + for (var prop in config_) config[prop] = config_[prop] + + // Return variables for tokenizers + var type, setStyle; + + function inText(stream, state) { + function chain(parser) { + state.tokenize = parser; + return parser(stream, state); + } + + var ch = stream.next(); + if (ch == "<") { + if (stream.eat("!")) { + if (stream.eat("[")) { + if (stream.match("CDATA[")) return chain(inBlock("atom", "]]>")); + else return null; + } else if (stream.match("--")) { + return chain(inBlock("comment", "-->")); + } else if (stream.match("DOCTYPE", true, true)) { + stream.eatWhile(/[\w\._\-]/); + return chain(doctype(1)); + } else { + return null; + } + } else if (stream.eat("?")) { + stream.eatWhile(/[\w\._\-]/); + state.tokenize = inBlock("meta", "?>"); + return "meta"; + } else { + type = stream.eat("/") ? "closeTag" : "openTag"; + state.tokenize = inTag; + return "tag bracket"; + } + } else if (ch == "&") { + var ok; + if (stream.eat("#")) { + if (stream.eat("x")) { + ok = stream.eatWhile(/[a-fA-F\d]/) && stream.eat(";"); + } else { + ok = stream.eatWhile(/[\d]/) && stream.eat(";"); + } + } else { + ok = stream.eatWhile(/[\w\.\-:]/) && stream.eat(";"); + } + return ok ? "atom" : "error"; + } else { + stream.eatWhile(/[^&<]/); + return null; + } + } + inText.isInText = true; + + function inTag(stream, state) { + var ch = stream.next(); + if (ch == ">" || (ch == "/" && stream.eat(">"))) { + state.tokenize = inText; + type = ch == ">" ? "endTag" : "selfcloseTag"; + return "tag bracket"; + } else if (ch == "=") { + type = "equals"; + return null; + } else if (ch == "<") { + state.tokenize = inText; + state.state = baseState; + state.tagName = state.tagStart = null; + var next = state.tokenize(stream, state); + return next ? next + " tag error" : "tag error"; + } else if (/[\'\"]/.test(ch)) { + state.tokenize = inAttribute(ch); + state.stringStartCol = stream.column(); + return state.tokenize(stream, state); + } else { + stream.match(/^[^\s\u00a0=<>\"\']*[^\s\u00a0=<>\"\'\/]/); + return "word"; + } + } + + function inAttribute(quote) { + var closure = function(stream, state) { + while (!stream.eol()) { + if (stream.next() == quote) { + state.tokenize = inTag; + break; + } + } + return "string"; + }; + closure.isInAttribute = true; + return closure; + } + + function inBlock(style, terminator) { + return function(stream, state) { + while (!stream.eol()) { + if (stream.match(terminator)) { + state.tokenize = inText; + break; + } + stream.next(); + } + return style; + } + } + + function doctype(depth) { + return function(stream, state) { + var ch; + while ((ch = stream.next()) != null) { + if (ch == "<") { + state.tokenize = doctype(depth + 1); + return state.tokenize(stream, state); + } else if (ch == ">") { + if (depth == 1) { + state.tokenize = inText; + break; + } else { + state.tokenize = doctype(depth - 1); + return state.tokenize(stream, state); + } + } + } + return "meta"; + }; + } + + function Context(state, tagName, startOfLine) { + this.prev = state.context; + this.tagName = tagName; + this.indent = state.indented; + this.startOfLine = startOfLine; + if (config.doNotIndent.hasOwnProperty(tagName) || (state.context && state.context.noIndent)) + this.noIndent = true; + } + function popContext(state) { + if (state.context) state.context = state.context.prev; + } + function maybePopContext(state, nextTagName) { + var parentTagName; + while (true) { + if (!state.context) { + return; + } + parentTagName = state.context.tagName; + if (!config.contextGrabbers.hasOwnProperty(parentTagName) || + !config.contextGrabbers[parentTagName].hasOwnProperty(nextTagName)) { + return; + } + popContext(state); + } + } + + function baseState(type, stream, state) { + if (type == "openTag") { + state.tagStart = stream.column(); + return tagNameState; + } else if (type == "closeTag") { + return closeTagNameState; + } else { + return baseState; + } + } + function tagNameState(type, stream, state) { + if (type == "word") { + state.tagName = stream.current(); + setStyle = "tag"; + return attrState; + } else if (config.allowMissingTagName && type == "endTag") { + setStyle = "tag bracket"; + return attrState(type, stream, state); + } else { + setStyle = "error"; + return tagNameState; + } + } + function closeTagNameState(type, stream, state) { + if (type == "word") { + var tagName = stream.current(); + if (state.context && state.context.tagName != tagName && + config.implicitlyClosed.hasOwnProperty(state.context.tagName)) + popContext(state); + if ((state.context && state.context.tagName == tagName) || config.matchClosing === false) { + setStyle = "tag"; + return closeState; + } else { + setStyle = "tag error"; + return closeStateErr; + } + } else if (config.allowMissingTagName && type == "endTag") { + setStyle = "tag bracket"; + return closeState(type, stream, state); + } else { + setStyle = "error"; + return closeStateErr; + } + } + + function closeState(type, _stream, state) { + if (type != "endTag") { + setStyle = "error"; + return closeState; + } + popContext(state); + return baseState; + } + function closeStateErr(type, stream, state) { + setStyle = "error"; + return closeState(type, stream, state); + } + + function attrState(type, _stream, state) { + if (type == "word") { + setStyle = "attribute"; + return attrEqState; + } else if (type == "endTag" || type == "selfcloseTag") { + var tagName = state.tagName, tagStart = state.tagStart; + state.tagName = state.tagStart = null; + if (type == "selfcloseTag" || + config.autoSelfClosers.hasOwnProperty(tagName)) { + maybePopContext(state, tagName); + } else { + maybePopContext(state, tagName); + state.context = new Context(state, tagName, tagStart == state.indented); + } + return baseState; + } + setStyle = "error"; + return attrState; + } + function attrEqState(type, stream, state) { + if (type == "equals") return attrValueState; + if (!config.allowMissing) setStyle = "error"; + return attrState(type, stream, state); + } + function attrValueState(type, stream, state) { + if (type == "string") return attrContinuedState; + if (type == "word" && config.allowUnquoted) {setStyle = "string"; return attrState;} + setStyle = "error"; + return attrState(type, stream, state); + } + function attrContinuedState(type, stream, state) { + if (type == "string") return attrContinuedState; + return attrState(type, stream, state); + } + + return { + startState: function(baseIndent) { + var state = {tokenize: inText, + state: baseState, + indented: baseIndent || 0, + tagName: null, tagStart: null, + context: null} + if (baseIndent != null) state.baseIndent = baseIndent + return state + }, + + token: function(stream, state) { + if (!state.tagName && stream.sol()) + state.indented = stream.indentation(); + + if (stream.eatSpace()) return null; + type = null; + var style = state.tokenize(stream, state); + if ((style || type) && style != "comment") { + setStyle = null; + state.state = state.state(type || style, stream, state); + if (setStyle) + style = setStyle == "error" ? style + " error" : setStyle; + } + return style; + }, + + indent: function(state, textAfter, fullLine) { + var context = state.context; + // Indent multi-line strings (e.g. css). + if (state.tokenize.isInAttribute) { + if (state.tagStart == state.indented) + return state.stringStartCol + 1; + else + return state.indented + indentUnit; + } + if (context && context.noIndent) return CodeMirror.Pass; + if (state.tokenize != inTag && state.tokenize != inText) + return fullLine ? fullLine.match(/^(\s*)/)[0].length : 0; + // Indent the starts of attribute names. + if (state.tagName) { + if (config.multilineTagIndentPastTag !== false) + return state.tagStart + state.tagName.length + 2; + else + return state.tagStart + indentUnit * (config.multilineTagIndentFactor || 1); + } + if (config.alignCDATA && /$/, + blockCommentStart: "", + + configuration: config.htmlMode ? "html" : "xml", + helperType: config.htmlMode ? "html" : "xml", + + skipAttribute: function(state) { + if (state.state == attrValueState) + state.state = attrState + }, + + xmlCurrentTag: function(state) { + return state.tagName ? {name: state.tagName, close: state.type == "closeTag"} : null + }, + + xmlCurrentContext: function(state) { + var context = [] + for (var cx = state.context; cx; cx = cx.prev) + if (cx.tagName) context.push(cx.tagName) + return context.reverse() + } + }; +}); + +CodeMirror.defineMIME("text/xml", "xml"); +CodeMirror.defineMIME("application/xml", "xml"); +if (!CodeMirror.mimeModes.hasOwnProperty("text/html")) + CodeMirror.defineMIME("text/html", {name: "xml", htmlMode: true}); + +}); diff --git a/adminx/assets/vendor/codemirror/theme/3024-day.css b/adminx/assets/vendor/codemirror/theme/3024-day.css new file mode 100644 index 0000000..7132655 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/3024-day.css @@ -0,0 +1,41 @@ +/* + + Name: 3024 day + Author: Jan T. Sott (http://github.com/idleberg) + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-3024-day.CodeMirror { background: #f7f7f7; color: #3a3432; } +.cm-s-3024-day div.CodeMirror-selected { background: #d6d5d4; } + +.cm-s-3024-day .CodeMirror-line::selection, .cm-s-3024-day .CodeMirror-line > span::selection, .cm-s-3024-day .CodeMirror-line > span > span::selection { background: #d6d5d4; } +.cm-s-3024-day .CodeMirror-line::-moz-selection, .cm-s-3024-day .CodeMirror-line > span::-moz-selection, .cm-s-3024-day .CodeMirror-line > span > span::selection { background: #d9d9d9; } + +.cm-s-3024-day .CodeMirror-gutters { background: #f7f7f7; border-right: 0px; } +.cm-s-3024-day .CodeMirror-guttermarker { color: #db2d20; } +.cm-s-3024-day .CodeMirror-guttermarker-subtle { color: #807d7c; } +.cm-s-3024-day .CodeMirror-linenumber { color: #807d7c; } + +.cm-s-3024-day .CodeMirror-cursor { border-left: 1px solid #5c5855; } + +.cm-s-3024-day span.cm-comment { color: #cdab53; } +.cm-s-3024-day span.cm-atom { color: #a16a94; } +.cm-s-3024-day span.cm-number { color: #a16a94; } + +.cm-s-3024-day span.cm-property, .cm-s-3024-day span.cm-attribute { color: #01a252; } +.cm-s-3024-day span.cm-keyword { color: #db2d20; } +.cm-s-3024-day span.cm-string { color: #fded02; } + +.cm-s-3024-day span.cm-variable { color: #01a252; } +.cm-s-3024-day span.cm-variable-2 { color: #01a0e4; } +.cm-s-3024-day span.cm-def { color: #e8bbd0; } +.cm-s-3024-day span.cm-bracket { color: #3a3432; } +.cm-s-3024-day span.cm-tag { color: #db2d20; } +.cm-s-3024-day span.cm-link { color: #a16a94; } +.cm-s-3024-day span.cm-error { background: #db2d20; color: #5c5855; } + +.cm-s-3024-day .CodeMirror-activeline-background { background: #e8f2ff; } +.cm-s-3024-day .CodeMirror-matchingbracket { text-decoration: underline; color: #a16a94 !important; } diff --git a/adminx/assets/vendor/codemirror/theme/3024-night.css b/adminx/assets/vendor/codemirror/theme/3024-night.css new file mode 100644 index 0000000..adc5900 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/3024-night.css @@ -0,0 +1,39 @@ +/* + + Name: 3024 night + Author: Jan T. Sott (http://github.com/idleberg) + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-3024-night.CodeMirror { background: #090300; color: #d6d5d4; } +.cm-s-3024-night div.CodeMirror-selected { background: #3a3432; } +.cm-s-3024-night .CodeMirror-line::selection, .cm-s-3024-night .CodeMirror-line > span::selection, .cm-s-3024-night .CodeMirror-line > span > span::selection { background: rgba(58, 52, 50, .99); } +.cm-s-3024-night .CodeMirror-line::-moz-selection, .cm-s-3024-night .CodeMirror-line > span::-moz-selection, .cm-s-3024-night .CodeMirror-line > span > span::-moz-selection { background: rgba(58, 52, 50, .99); } +.cm-s-3024-night .CodeMirror-gutters { background: #090300; border-right: 0px; } +.cm-s-3024-night .CodeMirror-guttermarker { color: #db2d20; } +.cm-s-3024-night .CodeMirror-guttermarker-subtle { color: #5c5855; } +.cm-s-3024-night .CodeMirror-linenumber { color: #5c5855; } + +.cm-s-3024-night .CodeMirror-cursor { border-left: 1px solid #807d7c; } + +.cm-s-3024-night span.cm-comment { color: #cdab53; } +.cm-s-3024-night span.cm-atom { color: #a16a94; } +.cm-s-3024-night span.cm-number { color: #a16a94; } + +.cm-s-3024-night span.cm-property, .cm-s-3024-night span.cm-attribute { color: #01a252; } +.cm-s-3024-night span.cm-keyword { color: #db2d20; } +.cm-s-3024-night span.cm-string { color: #fded02; } + +.cm-s-3024-night span.cm-variable { color: #01a252; } +.cm-s-3024-night span.cm-variable-2 { color: #01a0e4; } +.cm-s-3024-night span.cm-def { color: #e8bbd0; } +.cm-s-3024-night span.cm-bracket { color: #d6d5d4; } +.cm-s-3024-night span.cm-tag { color: #db2d20; } +.cm-s-3024-night span.cm-link { color: #a16a94; } +.cm-s-3024-night span.cm-error { background: #db2d20; color: #807d7c; } + +.cm-s-3024-night .CodeMirror-activeline-background { background: #2F2F2F; } +.cm-s-3024-night .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/abcdef.css b/adminx/assets/vendor/codemirror/theme/abcdef.css new file mode 100644 index 0000000..cf93530 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/abcdef.css @@ -0,0 +1,32 @@ +.cm-s-abcdef.CodeMirror { background: #0f0f0f; color: #defdef; } +.cm-s-abcdef div.CodeMirror-selected { background: #515151; } +.cm-s-abcdef .CodeMirror-line::selection, .cm-s-abcdef .CodeMirror-line > span::selection, .cm-s-abcdef .CodeMirror-line > span > span::selection { background: rgba(56, 56, 56, 0.99); } +.cm-s-abcdef .CodeMirror-line::-moz-selection, .cm-s-abcdef .CodeMirror-line > span::-moz-selection, .cm-s-abcdef .CodeMirror-line > span > span::-moz-selection { background: rgba(56, 56, 56, 0.99); } +.cm-s-abcdef .CodeMirror-gutters { background: #555; border-right: 2px solid #314151; } +.cm-s-abcdef .CodeMirror-guttermarker { color: #222; } +.cm-s-abcdef .CodeMirror-guttermarker-subtle { color: azure; } +.cm-s-abcdef .CodeMirror-linenumber { color: #FFFFFF; } +.cm-s-abcdef .CodeMirror-cursor { border-left: 1px solid #00FF00; } + +.cm-s-abcdef span.cm-keyword { color: darkgoldenrod; font-weight: bold; } +.cm-s-abcdef span.cm-atom { color: #77F; } +.cm-s-abcdef span.cm-number { color: violet; } +.cm-s-abcdef span.cm-def { color: #fffabc; } +.cm-s-abcdef span.cm-variable { color: #abcdef; } +.cm-s-abcdef span.cm-variable-2 { color: #cacbcc; } +.cm-s-abcdef span.cm-variable-3, .cm-s-abcdef span.cm-type { color: #def; } +.cm-s-abcdef span.cm-property { color: #fedcba; } +.cm-s-abcdef span.cm-operator { color: #ff0; } +.cm-s-abcdef span.cm-comment { color: #7a7b7c; font-style: italic;} +.cm-s-abcdef span.cm-string { color: #2b4; } +.cm-s-abcdef span.cm-meta { color: #C9F; } +.cm-s-abcdef span.cm-qualifier { color: #FFF700; } +.cm-s-abcdef span.cm-builtin { color: #30aabc; } +.cm-s-abcdef span.cm-bracket { color: #8a8a8a; } +.cm-s-abcdef span.cm-tag { color: #FFDD44; } +.cm-s-abcdef span.cm-attribute { color: #DDFF00; } +.cm-s-abcdef span.cm-error { color: #FF0000; } +.cm-s-abcdef span.cm-header { color: aquamarine; font-weight: bold; } +.cm-s-abcdef span.cm-link { color: blueviolet; } + +.cm-s-abcdef .CodeMirror-activeline-background { background: #314151; } diff --git a/adminx/assets/vendor/codemirror/theme/ambiance-mobile.css b/adminx/assets/vendor/codemirror/theme/ambiance-mobile.css new file mode 100644 index 0000000..88d332e --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/ambiance-mobile.css @@ -0,0 +1,5 @@ +.cm-s-ambiance.CodeMirror { + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; +} diff --git a/adminx/assets/vendor/codemirror/theme/ambiance.css b/adminx/assets/vendor/codemirror/theme/ambiance.css new file mode 100644 index 0000000..782fca4 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/ambiance.css @@ -0,0 +1,74 @@ +/* ambiance theme for codemirror */ + +/* Color scheme */ + +.cm-s-ambiance .cm-header { color: blue; } +.cm-s-ambiance .cm-quote { color: #24C2C7; } + +.cm-s-ambiance .cm-keyword { color: #cda869; } +.cm-s-ambiance .cm-atom { color: #CF7EA9; } +.cm-s-ambiance .cm-number { color: #78CF8A; } +.cm-s-ambiance .cm-def { color: #aac6e3; } +.cm-s-ambiance .cm-variable { color: #ffb795; } +.cm-s-ambiance .cm-variable-2 { color: #eed1b3; } +.cm-s-ambiance .cm-variable-3, .cm-s-ambiance .cm-type { color: #faded3; } +.cm-s-ambiance .cm-property { color: #eed1b3; } +.cm-s-ambiance .cm-operator { color: #fa8d6a; } +.cm-s-ambiance .cm-comment { color: #555; font-style:italic; } +.cm-s-ambiance .cm-string { color: #8f9d6a; } +.cm-s-ambiance .cm-string-2 { color: #9d937c; } +.cm-s-ambiance .cm-meta { color: #D2A8A1; } +.cm-s-ambiance .cm-qualifier { color: yellow; } +.cm-s-ambiance .cm-builtin { color: #9999cc; } +.cm-s-ambiance .cm-bracket { color: #24C2C7; } +.cm-s-ambiance .cm-tag { color: #fee4ff; } +.cm-s-ambiance .cm-attribute { color: #9B859D; } +.cm-s-ambiance .cm-hr { color: pink; } +.cm-s-ambiance .cm-link { color: #F4C20B; } +.cm-s-ambiance .cm-special { color: #FF9D00; } +.cm-s-ambiance .cm-error { color: #AF2018; } + +.cm-s-ambiance .CodeMirror-matchingbracket { color: #0f0; } +.cm-s-ambiance .CodeMirror-nonmatchingbracket { color: #f22; } + +.cm-s-ambiance div.CodeMirror-selected { background: rgba(255, 255, 255, 0.15); } +.cm-s-ambiance.CodeMirror-focused div.CodeMirror-selected { background: rgba(255, 255, 255, 0.10); } +.cm-s-ambiance .CodeMirror-line::selection, .cm-s-ambiance .CodeMirror-line > span::selection, .cm-s-ambiance .CodeMirror-line > span > span::selection { background: rgba(255, 255, 255, 0.10); } +.cm-s-ambiance .CodeMirror-line::-moz-selection, .cm-s-ambiance .CodeMirror-line > span::-moz-selection, .cm-s-ambiance .CodeMirror-line > span > span::-moz-selection { background: rgba(255, 255, 255, 0.10); } + +/* Editor styling */ + +.cm-s-ambiance.CodeMirror { + line-height: 1.40em; + color: #E6E1DC; + background-color: #202020; + -webkit-box-shadow: inset 0 0 10px black; + -moz-box-shadow: inset 0 0 10px black; + box-shadow: inset 0 0 10px black; +} + +.cm-s-ambiance .CodeMirror-gutters { + background: #3D3D3D; + border-right: 1px solid #4D4D4D; + box-shadow: 0 10px 20px black; +} + +.cm-s-ambiance .CodeMirror-linenumber { + text-shadow: 0px 1px 1px #4d4d4d; + color: #111; + padding: 0 5px; +} + +.cm-s-ambiance .CodeMirror-guttermarker { color: #aaa; } +.cm-s-ambiance .CodeMirror-guttermarker-subtle { color: #111; } + +.cm-s-ambiance .CodeMirror-cursor { border-left: 1px solid #7991E8; } + +.cm-s-ambiance .CodeMirror-activeline-background { + background: none repeat scroll 0% 0% rgba(255, 255, 255, 0.031); +} + +.cm-s-ambiance.CodeMirror, +.cm-s-ambiance .CodeMirror-gutters { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAQAAAAHUWYVAABFFUlEQVQYGbzBCeDVU/74/6fj9HIcx/FRHx9JCFmzMyGRURhLZIkUsoeRfUjS2FNDtr6WkMhO9sm+S8maJfu+Jcsg+/o/c+Z4z/t97/vezy3z+z8ekGlnYICG/o7gdk+wmSHZ1z4pJItqapjoKXWahm8NmV6eOTbWUOp6/6a/XIg6GQqmenJ2lDHyvCFZ2cBDbmtHA043VFhHwXxClWmeYAdLhV00Bd85go8VmaFCkbVkzlQENzfBDZ5gtN7HwF0KDrTwJ0dypSOzpaKCMwQHKTIreYIxlmhXTzTWkVm+LTynZhiSBT3RZQ7aGfjGEd3qyXQ1FDymqbKxpspERQN2MiRjNZlFFQXfCNFm9nM1zpAsoYjmtRTc5ajwuaXc5xrWskT97RaKzAGe5ARHhVUsDbjKklziiX5WROcJwSNCNI+9w1Jwv4Zb2r7lCMZ4oq5C0EdTx+2GzNuKpJ+iFf38JEWkHJn9DNF7mmBDITrWEg0VWL3pHU20tSZnuqWu+R3BtYa8XxV1HO7GyD32UkOpL/yDloINFTmvtId+nmAjxRw40VMwVKiwrKLE4bK5UOVntYwhOcSSXKrJHKPJedocpGjVz/ZMIbnYUPB10/eKCrs5apqpgVmWzBYWpmtKHecJPjaUuEgRDDaU0oZghCJ6zNMQ5ZhDYx05r5v2muQdM0EILtXUsaKiQX9WMEUotagQzFbUNN6NUPC2nm5pxEWGCjMc3GdJHjSU2kORLK/JGSrkfGEIjncU/CYUnOipoYemwj8tST9NsJmB7TUVXtbUtXATJVZXBMvYeTXJfobgJUPmGMP/yFaWonaa6BcFO3nqcIqCozSZoZoSr1g4zJOzuyGnxTEX3lUEJ7WcZgme8ddaWvWJo2AJR9DZU3CUIbhCSG6ybSwN6qtJVnCU2svDTP2ZInOw2cBTrqtQahtNZn9NcJ4l2NaSmSkkP1noZWnVwkLmdUPOwLZEwy2Z3S3R+4rIG9hcbpPXHFVWcQdZkn2FOta3cKWQnNRC5g1LsJah4GCzSVsKnCOY5OAFRTBekyyryeyilhFKva75r4Mc0aWanGEaThcy31s439KKxTzJYY5WTHPU1FtIHjQU3Oip4xlNzj/lBw23dYZVliQa7WAXf4shetcQfatI+jWRDBPmyNeW6A1P5kdDgyYJlba0BIM8BZu1JfrFwItyjcAMR3K0BWOIrtMEXyhyrlVEx3ui5dUBjmB/Q3CXW85R4mBD0s7B+4q5tKUjOlb9qqmhi5AZ6GFIC5HXtOobdYGlVdMVbNJ8toNTFcHxnoL+muBagcctjWnbNMuR00uI7nQESwg5q2qqrKWIfrNUmeQocY6HuyxJV02wj36w00yhpmUFenv4p6fUkZYqLyuinx2RGOjhCXYyJF84oiU00YMOOhhquNdfbOB7gU88pY4xJO8LVdp6/q2voeB4R04vIdhSE40xZObx1HGGJ/ja0LBthFInKaLPPFzuCaYaoj8JjPME8yoyxo6zlBqkiUZYgq00OYMswbWO5NGmq+xhipxHLRW29ARjNKXO0wRnear8XSg4XFPLKEPUS1GqvyLwiuBUoa7zpZ0l5xxFwWmWZC1H5h5FwU8eQ7K+g8UcVY6TMQreVQT/8uQ8Z+ALIXnSEa2pYZQneE9RZbSBNYXfWYJzW/h/4j4Dp1tYVcFIC5019Vyi4ThPqSFCzjGWaHQTBU8q6vrVwgxP9Lkm840imWKpcLCjYTtrKuwvsKSnrvHCXGkSMk9p6lhckfRpIeis+N2PiszT+mFLspyGleUhDwcLrZqmyeylxwjBcKHEapqkmyangyLZRVOijwOtCY5SsG5zL0OwlCJ4y5KznF3EUNDDrinwiyLZRzOXtlBbK5ITHFGLp8Q0R6ab6mS7enI2cFrxOyHvOCFaT1HThS1krjCwqWeurCkk+willhCC+RSZnRXBiZaC5RXRIZYKp2lyfrHwiKPKR0JDzrdU2EFgpidawlFDR6FgXUMNa+g1FY3bUQh2cLCwosRdnuQTS/S+JVrGLeWIvtQUvONJxlqSQYYKpwoN2kaocLjdVsis4Mk80ESF2YpSkzwldjHkjFCUutI/r+EHDU8oCs6yzL3PhWiEooZdFMkymlas4AcI3KmoMMNSQ3tHzjGWCrcJJdYyZC7QFGwjRL9p+MrRkAGWzIaWCn9W0F3TsK01c2ZvQw0byvxuQU0r1lM0qJO7wW0kRIMdDTtXEdzi4VIh+EoIHm0mWtAtpCixlabgn83fKTI7anJe9ST7WIK1DMGpQmYeA58ImV6ezOGOzK2Kgq01pd60cKWiUi9Lievb/0vIDPHQ05Kzt4ddPckQBQtoaurjyHnek/nKzpQLrVgKPjIkh2v4uyezpv+Xoo7fPFXaGFp1vaLKxQ4uUpQQS5VuQs7BCq4xRJv7fwpVvvFEB3j+620haOuocqMhWd6TTPAEx+mdFNGHdranFe95WrWmIvlY4F1Dle2ECgc6cto7SryuqGGGha0tFQ5V53migUKmg6XKAo4qS3mik+0OZpAhOLeZKicacgaYcyx5hypYQE02ZA4xi/pNhOQxR4klNKyqacj+mpxnLTnnGSo85++3ZCZq6lrZkXlGEX3o+C9FieccJbZWVFjC0Yo1FZnJhoYMFoI1hEZ9r6hwg75HwzBNhbZCdJEfJwTPGzJvaKImw1yYX1HDAmpXR+ZJQ/SmgqMNVQb5vgamGwLtt7VwvP7Qk1xpiM5x5Cyv93E06MZmgs0Nya2azIKOYKCGBQQW97RmhKNKF02JZqHEJ4o58qp7X5EcZmc56trXEqzjCBZ1MFGR87Ql2tSTs6CGxS05PTzRQorkbw7aKoKXFDXsYW42VJih/q+FP2BdTzDTwVqOYB13liM50vG7wy28qagyuIXMeQI/Oqq8bcn5wJI50xH00CRntyfpL1T4hydYpoXgNiFzoIUTDZnLNRzh4TBHwbYGDvZkxmlyJloyr6tRihpeUG94GnKtIznREF0tzJG/OOr73JBcrSh1k6WuTprgLU+mnSGnv6Zge0NNz+kTDdH8nuAuTdJDCNb21LCiIuqlYbqGzT3RAoZofQfjFazkqeNWdYaGvYTM001EW2oKPvVk1ldUGSgUtHFwjKM1h9jnFcmy5lChoLNaQMGGDsYbKixlaMBmmsx1QjCfflwTfO/gckW0ruZ3jugKR3R5W9hGUWqCgxuFgsuaCHorotGKzGaeZB9DMsaTnKCpMtwTvOzhYk0rdrArKCqcaWmVk1+F372ur1YkKxgatI8Qfe1gIX9wE9FgS8ESmuABIXnRUbCapcKe+nO7slClSZFzpV/LkLncEb1qiO42fS3R855Su2mCLh62t1SYZZYVmKwIHjREF2uihTzB20JOkz7dkxzYQnK0UOU494wh+VWRc6Un2kpTaVgLDFEkJ/uhzRcI0YKGgpGWOlocBU/a4fKoJ/pEaNV6jip3+Es9VXY078rGnmAdf7t9ylPXS34RBSuYPs1UecZTU78WanhBCHpZ5sAoTz0LGZKjPf9TRypqWEiTvOFglL1fCEY3wY/++rbk7C8bWebA6p6om6PgOL2kp44TFJlVNBXae2rqqdZztOJpT87GQsE9jqCPIe9VReZuQ/CIgacsyZdCpIScSYqcZk8r+nsyCzhyfhOqHGOIvrLknC8wTpFcaYiGC/RU1NRbUeUpocQOnkRpGOrIOcNRx+1uA0UrzhSSt+VyS3SJpnFWkzNDqOFGIWcfR86DnmARTQ1HKIL33ExPiemeOhYSSjzlSUZZuE4TveoJLnBUOFof6KiysCbnAEcZgcUNTDOwkqWu3RWtmGpZwlHhJENdZ3miGz0lJlsKnjbwqSHQjpxnFDlTLLwqJPMZMjd7KrzkSG7VsxXBZE+F8YZkb01Oe00yyRK9psh5SYh29ySPKBo2ylNht7ZkZnsKenjKNJu9PNEyZpaCHv4Kt6RQsLvAVp7M9kIimmCUwGeWqLMmGuIotYMmWNpSahkhZw9FqZsVnKJhsjAHvtHMsTM9fCI06Dx/u3vfUXCqfsKRc4oFY2jMsoo/7DJDwZ1CsIKnJu+J9ldkpmiCxQx1rWjI+T9FwcWWzOuaYH0Hj7klNRVWEQpmaqosakiGNTFHdjS/qnUdmf0NJW5xsL0HhimCCZZSRzmSPTXJQ4aaztAwtZnoabebJ+htCaZ7Cm535ByoqXKbX1WRc4Eh2MkRXWzImVc96Cj4VdOKVxR84VdQsIUM8Psoou2byVHyZFuq7O8otbSQ2UAoeEWTudATLGSpZzVLlXVkPU2Jc+27lsw2jmg5T5VhbeE3BT083K9WsTTkFU/Osi0rC5lRlpwRHUiesNS0sOvmqGML1aRbPAxTJD9ZKtxuob+hhl8cwYGWpJ8nub7t5p6coYbMovZ1BTdaKn1jYD6h4GFDNFyT/Kqe1XCXphXHOKLZmuRSRdBPEfVUXQzJm5YGPGGJdvAEr7hHNdGZnuBvrpciGmopOLf5N0uVMy0FfYToJk90uUCbJupaVpO53UJXR2bVpoU00V2KOo4zMFrBd0Jtz2pa0clT5Q5L8IpQ177mWQejPMEJhuQjS10ref6HHjdEhy1P1EYR7GtO0uSsKJQYLiTnG1rVScj5lyazpqWGl5uBbRWl7m6ixGOOnEsMJR7z8J0n6KMnCdxhiNYQCoZ6CmYLnO8omC3MkW3bktlPmEt/VQQHejL3+dOE5FlPdK/Mq8hZxxJtLyRrepLThYKbLZxkSb5W52vYxNOaOxUF0yxMUPwBTYqCzy01XayYK0sJyWBLqX0MwU5CzoymRzV0EjjeUeLgDpTo6ij42ZAzvD01dHUUTPLU96MdLbBME8nFBn7zJCMtJcZokn8YoqU0FS5WFKyniHobguMcmW8N0XkWZjkyN3hqOMtS08r+/xTBwpZSZ3qiVRX8SzMHHjfUNFjgHEPmY9PL3ykEzxkSre/1ZD6z/NuznuB0RcE1TWTm9zRgfUWVJiG6yrzgmWPXC8EAR4Wxhlad0ZbgQyEz3pG5RVEwwDJH2mgKpjcTiCOzn1lfUWANFbZ2BA8balnEweJC9J0iuaeZoI+ippFCztEKVvckR2iice1JvhVytrQwUAZpgsubCPaU7xUe9vWnaOpaSBEspalykhC9bUlOMpT42ZHca6hyrqKmw/wMR8H5ZmdFoBVJb03O4UL0tSNnvIeRmkrLWqrs78gcrEn2tpcboh0UPOW3UUR9PMk4T4nnNKWmCjlrefhCwxRNztfmIQVdDElvS4m1/WuOujoZCs5XVOjtKPGokJzsYCtFYoWonSPT21DheU/wWhM19FcElwqNGOsp9Q8N/cwXaiND1MmeL1Q5XROtYYgGeFq1aTMsoMmcrKjQrOFQTQ1fmBYhmW6o8Jkjc7iDJRTBIo5kgJD5yMEYA3srCg7VFKwiVJkmRCc5ohGOKhsYMn/XBLdo5taZjlb9YAlGWRimqbCsoY7HFAXLa5I1HPRxMMsQDHFkWtRNniqT9UEeNjcE7RUlrCJ4R2CSJuqlKHWvJXjAUNcITYkenuBRB84TbeepcqTj3zZyFJzgYQdHnqfgI0ddUwS6GqWpsKWhjq9cV0vBAEMN2znq+EBfIWT+pClYw5xsTlJU6GeIBsjGmmANTzJZiIYpgrM0Oa8ZMjd7NP87jxhqGOhJlnQtjuQpB+8aEE00wZFznSJPyHxgH3HkPOsJFvYk8zqCHzTs1BYOa4J3PFU+UVRZxlHDM4YavlNUuMoRveiZA2d7grMNc2g+RbSCEKzmgYsUmWmazFJyoiOZ4KnyhKOGRzWJa0+moyV4TVHDzn51Awtqaphfk/lRQ08FX1iiqxTB/kLwd0VynKfEvI6cd4XMV5bMhZ7gZUWVzYQ6Nm2BYzxJbw3bGthEUUMfgbGeorae6DxHtJoZ6alhZ0+ytiVoK1R4z5PTrOECT/SugseEOlb1MMNR4VRNcJy+V1Hg9ONClSZFZjdHlc6W6FBLdJja2MC5hhpu0DBYEY1TFGwiFAxRRCsYkiM9JRb0JNMVkW6CZYT/2EiTGWmo8k+h4FhDNE7BvppoTSFnmCV5xZKzvcCdDo7VVPnIU+I+Rc68juApC90MwcFCsJ5hDqxgScYKreruyQwTqrzoqDCmhWi4IbhB0Yrt3RGa6GfDv52rKXWhh28dyZaWUvcZeMTBaZoSGyiCtRU5J8iviioHaErs7Jkj61syVzTTgOcUOQ8buFBTYWdL5g3T4qlpe0+wvD63heAXRfCCIed9RbCsp2CiI7raUOYOTU13N8PNHvpaGvayo4a3LLT1lDrVEPT2zLUlheB1R+ZTRfKWJ+dcocLJfi11vyJ51lLqJ0WD7tRwryezjiV5W28uJO9qykzX8JDe2lHl/9oyBwa2UMfOngpXCixvKdXTk3wrsKmiVYdZIqsoWEERjbcUNDuiaQomGoIbFdEHmsyWnuR+IeriKDVLnlawlyNHKwKlSU631PKep8J4Q+ayjkSLKYLhalNHlYvttb6fHm0p6OApsZ4l2VfdqZkjuysy6ysKLlckf1KUutCTs39bmCgEyyoasIWlVaMF7mgmWtBT8Kol5xpH9IGllo8cJdopcvZ2sImlDmMIbtDk3KIpeNiS08lQw11NFPTwVFlPP6pJ2gvRfI7gQUfmNAtf6Gs0wQxDsKGlVBdF8rCa3jzdwMaGHOsItrZk7hAyOzpK9VS06j5F49b0VNGOOfKs3lDToMsMBe9ZWtHFEgxTJLs7qrygKZjUnmCYoeAqeU6jqWuLJup4WghOdvCYJnrSkSzoyRkm5M2StQwVltPkfCAk58tET/CSg+8MUecmotMEnhBKfWBIZsg2ihruMJQaoIm+tkTLKEqspMh00w95gvFCQRtDwTT1gVDDSEVdlwqZfxoQRbK0g+tbiBZxzKlpnpypejdDwTaeOvorMk/IJE10h9CqRe28hhLbe0pMsdSwv4ZbhKivo2BjDWfL8UKJgeavwlwb5KlwhyE4u4XkGE2ytZCznKLCDZZq42VzT8HLCrpruFbIfOIINmh/qCdZ1ZBc65kLHR1Bkyf5zn6pN3SvGKIlFNGplhrO9QSXanLOMQTLCa0YJCRrCZm/CZmrLTm7WzCK4GJDiWUdFeYx1LCFg3NMd0XmCuF3Y5rITLDUsYS9zoHVzwnJoYpSTQoObyEzr4cFBNqYTopoaU/wkyLZ2lPhX/5Y95ulxGTV7KjhWrOZgl8MyUUafjYraNjNU1N3IWcjT5WzWqjwtoarHSUObGYO3GCJZpsBlnJGPd6ZYLyl1GdCA2625IwwJDP8GUKymbzuyPlZlvTUsaUh5zFDhRWFzPKKZLAlWdcQbObgF9tOqOsmB1dqcqYJmWstFbZRRI9poolmqiLnU0POvxScpah2iSL5UJNzgScY5+AuIbpO0YD3NCW+dLMszFSdFCWGqG6eVq2uYVNDdICGD6W7EPRWZEY5gpsE9rUkS3mijzzJnm6UpUFXG1hCUeVoS5WfNcFpblELL2qqrCvMvRfd45oalvKU2tiQ6ePJOVMRXase9iTtLJztPxJKLWpo2CRDcJwn2sWSLKIO1WQWNTCvpVUvOZhgSC40JD0dOctaSqzkCRbXsKlb11Oip6PCJ0IwSJM31j3akRxlP7Rwn6aGaUL0qiLnJkvB3xWZ2+Q1TfCwpQH3G0o92UzmX4o/oJNQMMSQc547wVHhdk+VCw01DFYEnTxzZKAm74QmeNNR1w6WzEhNK15VJzuCdxQ53dRUDws5KvwgBMOEgpcVNe0hZI6RXT1Jd0cyj5nsaEAHgVmGaJIlWdsc5Ui2ElrRR6jrRAttNMEAIWrTDFubkZaok7/AkzfIwfuWVq0jHzuCK4QabtLUMVPB3kJ0oyHTSVFlqMALilJf2Rf8k5aaHtMfayocLBS8L89oKoxpJvnAkDPa0qp5DAUTHKWmCcnthlou8iCKaFFLHWcINd1nyIwXqrSxMNmSs6KmoL2QrKuWtlQ5V0120xQ5vRyZS1rgFkWwhiOwiuQbR0OOVhQM9iS3tiXp4RawRPMp5tDletOOBL95MpM01dZTBM9pkn5qF010rIeHFcFZhmSGpYpTsI6nwhqe5C9ynhlpp5ophuRb6WcJFldkVnVEwwxVfrVkvnWUuNLCg5bgboFHPDlDPDmnK7hUrWiIbjadDclujlZcaokOFup4Ri1kacV6jmrrK1hN9bGwpKEBQ4Q6DvIUXOmo6U5LqQM6EPyiKNjVkPnJkDPNEaxhiFay5ExW1NXVUGqcpYYdPcGiCq7z/TSlbhL4pplWXKd7NZO5QQFrefhRQW/NHOsqcIglc4UhWklR8K0QzbAw08CBDnpbgqXdeD/QUsM4RZXDFBW6WJKe/mFPdH0LtBgiq57wFLzlyQzz82qYx5D5WJP5yVJDW01BfyHnS6HKO/reZqId1WGa4Hkh2kWodJ8i6KoIPlAj2hPt76CzXsVR6koPRzWTfKqIentatYpQw2me4AA3y1Kind3SwoOKZDcFXTwl9tWU6mfgRk9d71sKtlNwrjnYw5tC5n5LdKiGry3JKNlHEd3oaMCFHrazBPMp/uNJ+V7IudcSbeOIdjUEdwl0VHCOZo5t6YluEuaC9mQeMgSfOyKnYGFHcIeQ84yQWbuJYJpZw5CzglDH7gKnWqqM9ZTaXcN0TeYhR84eQtJT76JJ1lREe7WnnvsMmRc9FQ7SBBM9mV3lCUdmHk/S2RAMt0QjFNFqQpWjDPQ01DXWUdDBkXziKPjGEP3VP+zIWU2t7im41FOloyWzn/L6dkUy3VLDaZ6appgDLHPjJEsyvJngWEPUyVBiAaHCTEXwrLvSEbV1e1gKJniicWorC1MUrVjB3uDhJE/wgSOzk1DXpk0k73qCM8xw2UvD5kJmDUfOomqMpWCkJRlvKXGmoeBm18USjVIk04SClxTB6YrgLAPLWYK9HLUt5cmc0vYES8GnTeRc6skZbQkWdxRsIcyBRzx1DbTk9FbU0caTPOgJHhJKnOGIVhQqvKmo0llRw9sabrZkDtdg3PqaKi9oatjY8B+G371paMg6+mZFNNtQ04mWBq3rYLOmtWWQp8KJnpy9DdFensyjdqZ+yY40VJlH8wcdLzC8PZnvHMFUTZUrDTkLyQaGus5X5LzpYAf3i+e/ZlhqGqWhh6Ou6xTR9Z6oi5AZZtp7Mj2EEm8oSpxiYZCHU/1fbGdNNNRRoZMhmilEb2gqHOEJDtXkHK/JnG6IrvbPCwV3NhONVdS1thBMs1T4QOBcTWa2IzhMk2nW5Kyn9tXUtpv9RsG2msxk+ZsQzRQacJncpgke0+T8y5Fzj8BiGo7XlJjaTIlpQs7KFjpqGnKuoyEPeIKnFMkZHvopgh81ySxNFWvJWcKRs70j2FOT012IllEEO1n4pD1513Yg2ssQPOThOkvyrqHUdEXOSEsihmBbTbKX1kLBPWqWkLOqJbjB3GBIZmoa8qWl4CG/iZ7oiA72ZL7TJNeZUY7kFQftDcHHluBzRbCegzMtrRjVQpX2lgoPKKLJAkcbMl01XK2p7yhL8pCBbQ3BN2avJgKvttcrWDK3CiUOVxQ8ZP+pqXKyIxnmBymCg5vJjNfkPK4+c8cIfK8ocVt7kmfd/I5SR1hKvCzUtb+lhgc00ZaO6CyhIQP1Uv4yIZjload72PXX0OIJvnFU+0Zf6MhsJwTfW0r0UwQfW4LNLZl5HK261JCZ4qnBaAreVAS3WrjV0LBnNDUNNDToCEeFfwgcb4gOEqLRhirWkexrCEYKVV711DLYEE1XBEsp5tpTGjorkomKYF9FDXv7fR3BGwbettSxnyL53MBPjsxDZjMh+VUW9NRxq1DhVk+FSxQcaGjV9Pawv6eGByw5qzoy7xk4RsOShqjJwWKe/1pEEfzkobeD/dQJmpqedcyBTy2sr4nGNRH0c0SPWTLrqAc0OQcb/gemKgqucQT7ySWKCn2EUotoCvpZct7RO2sy/QW0IWcXd7pQRQyZVwT2USRO87uhjioTLKV2brpMUcMQRbKH/N2T+UlTpaMls6cmc6CCNy3JdYYSUzzJQ4oSD3oKLncULOiJvjBEC2oqnCJkJluCYy2ZQ5so9YYlZ1VLlQU1mXEW1jZERwj/MUSRc24TdexlqLKfQBtDTScJUV8FszXBEY5ktpD5Ur9hYB4Nb1iikw3JoYpkKX+RodRKFt53MMuRnKSpY31PwYaGaILh3wxJGz9TkTPEETxoCWZrgvOlmyMzxFEwVJE5xZKzvyJ4WxEc16Gd4Xe3Weq4XH2jKRikqOkGQ87hQnC7wBmGYLAnesX3M+S87eFATauuN+Qcrh7xIxXJbUIdMw3JGE3ylCWzrieaqCn4zhGM19TQ3z1oH1AX+pWEqIc7wNGAkULBo/ZxRaV9NNyh4Br3rCHZzbzmSfawBL0dNRwpW1kK9mxPXR9povcdrGSZK9c2k0xwFGzjuniCtRSZCZ6ccZ7gaktmgAOtKbG/JnOkJrjcQTdFMsxRQ2cLY3WTIrlCw1eWKn8R6pvt4GFDso3QoL4a3nLk3G6JrtME3dSenpx7PNFTmga0EaJTLQ061sEeQoWXhSo9LTXsaSjoJQRXeZLtDclbCrYzfzHHeaKjHCVOUkQHO3JeEepr56mhiyaYYKjjNU+Fed1wS5VlhWSqI/hYUdDOkaxiKehoyOnrCV5yBHtbWFqTHCCwtpDcYolesVR5yUzTZBb3RNMd0d6WP+SvhuBmRcGxnuQzT95IC285cr41cLGQ6aJJhmi4TMGempxeimBRQw1tFKV+8jd6KuzoSTqqDxzRtpZkurvKEHxlqXKRIjjfUNNXQsNOsRScoWFLT+YeRZVD3GRN0MdQcKqQjHDMrdGGVu3iYJpQx3WGUvfbmxwFfR20WBq0oYY7LMFhhgYtr8jpaEnaOzjawWWaTP8mMr0t/EPDPoqcnxTBI5o58L7uoWnMrpoqPwgVrlAUWE+V+TQl9rawoyP6QGAlQw2TPRX+YSkxyBC8Z6jhHkXBgQL7WII3DVFnRfCrBfxewv9D6xsyjys4VkhWb9pUU627JllV0YDNHMku/ldNMMXDEo4aFnAkk4U6frNEU4XgZUPmEKHUl44KrzmYamjAbh0JFvGnaTLPu1s9jPCwjFpYiN7z1DTOk/nc07CfDFzmCf7i+bfNHXhDtLeBXzTBT5rkMvWOIxpl4EMh2LGJBu2syDnAEx2naEhHDWMMzPZEhygyS1mS5RTJr5ZkoKbEUoYqr2kqdDUE8ztK7OaIntJkFrIECwv8LJTaVx5XJE86go8dFeZ3FN3rjabCAYpoYEeC9zzJVULBbmZhDyd7ko09ydpNZ3nm2Kee4FPPXHnYEF1nqOFEC08LUVcDvYXkJHW8gTaKCk9YGOeIJhqiE4ToPEepdp7IWFjdwnWaufGMwJJCMtUTTBBK9BGCOy2tGGrJTHIwyEOzp6aPzNMOtlZkDvcEWpP5SVNhfkvDxhmSazTJXYrM9U1E0xwFVwqZQwzJxw6+kGGGUj2FglGGmnb1/G51udRSMNlTw6GGnCcUwVcOpmsqTHa06o72sw1RL02p9z0VbnMLOaIX3QKaYKSCFQzBKEUNHTSc48k53RH9wxGMtpQa5KjjW0W0n6XCCCG4yxNNdhQ4R4l1Ff+2sSd6UFHiIEOyqqFgT01mEUMD+joy75jPhOA+oVVLm309FR4yVOlp4RhLiScNmSmaYF5Pw0STrOIoWMSR2UkRXOMp+M4SHW8o8Zoi6OZgjKOaFar8zZDzkWzvKOjkKBjmCXby8JahhjXULY4KlzgKLvAwxVGhvyd4zxB1d9T0piazmKLCVZY5sKiD0y2ZSYrkUEPUbIk+dlQ4SJHTR50k1DPaUWIdTZW9NJwnJMOECgd7ou/MnppMJ02O1VT4Wsh85MnZzcFTngpXGKo84qmwgKbCL/orR/SzJ2crA+t6Mp94KvxJUeIbT3CQu1uIdlQEOzlKfS3UMcrTiFmOuroocrZrT2AcmamOKg8YomeEKm/rlT2sociMaybaUlFhuqHCM2qIJ+rg4EcDFymiDSxzaHdPcpE62pD5kyM5SBMoA1PaUtfIthS85ig1VPiPPYXgYEMNk4Qq7TXBgo7oT57gPUdwgCHzhIVFPFU6OYJzHAX9m5oNrVjeE61miDrqQ4VSa1oiURTsKHC0IfjNwU2WzK6eqK8jWln4g15TVBnqmDteCJ501PGAocJhhqjZdtBEB6lnhLreFJKxmlKbeGrqLiSThVIbCdGzloasa6lpMQXHCME2boLpJgT7yWaemu6wBONbqGNVRS0PKIL7LckbjmQtR7K8I5qtqel+T/ChJTNIKLjdUMNIRyvOEko9YYl2cwQveBikCNawJKcLBbc7+JM92mysNvd/Fqp8a0k6CNEe7cnZrxlW0wQXaXjaktnRwNOGZKYiONwS7a1JVheq3WgJHlQUGKHKmp4KAxXR/ULURcNgoa4zhKSLpZR3kxRRb0NmD0OFn+UCS7CzI1nbP6+o4x47QZE5xRCt3ZagnYcvmpYQktXdk5YKXTzBC57kKEe0VVuiSYqapssMS3C9p2CKkHOg8B8Pa8p5atrIw3qezIWanMGa5HRDNF6RM9wcacl0N+Q8Z8hsIkSnaIIdHRUOEebAPy1zbCkhM062FCJtif7PU+UtoVXzWKqM1PxXO8cfdruhFQ/a6x3JKYagvVDhQEtNiyiiSQ7OsuRsZUku0CRNDs4Sog6KKjsZgk2bYJqijgsEenoKeniinRXBn/U3lgpPdyDZynQx8IiioMnCep5Ky8mjGs6Wty0l1hUQTcNWswS3WRp2kCNZwJG8omG8JphPUaFbC8lEfabwP7VtM9yoaNCAjpR41VNhrD9LkbN722v0CoZMByFzhaW+MyzRYEWFDQwN2M4/JiT76PuljT3VU/A36eaIThb+R9oZGOAJ9tewkgGvqOMNRWYjT/Cwu99Q8LqDE4TgbLWxJ1jaDDAERsFOFrobgjUsBScaguXU8kKm2RL19tRypSHnHNlHiIZqgufs4opgQdVdwxBNNFBR6kVFqb8ogimOzB6a6HTzrlDHEpYaxjiiA4TMQobkDg2vejjfwJGWmnbVFAw3H3hq2NyQfG7hz4aC+w3BbwbesG0swYayvpAs6++Ri1Vfzx93mFChvyN5xVHTS+0p9aqCAxyZ6ZacZyw5+7uuQkFPR9DDk9NOiE7X1PCYJVjVUqq7JlrHwWALF5nfHNGjApdpqgzx5OwilDhCiDYTgnc9waGW4BdLNNUQvOtpzDOWHDH8D7TR/A/85KljEQu3NREc4Pl/6B1Hhc8Umb5CsKMmGC9EPcxoT2amwHNCmeOEnOPbklnMkbOgIvO5UMOpQrS9UGVdt6iH/fURjhI/WOpaW9OKLYRod6HCUEdOX000wpDZQ6hwg6LgZfOqo1RfT/CrJzjekXOGhpc1VW71ZLbXyyp+93ILbC1kPtIEYx0FIx1VDrLoVzXRKRYWk809yYlC9ImcrinxtabKnzRJk3lAU1OLEN1j2zrYzr2myHRXJFf4h4QKT1qSTzTB5+ZNTzTRkAxX8FcLV2uS8eoQQ2aAkFzvCM72sJIcJET3WPjRk5wi32uSS9rfZajpWEvj9hW42F4o5NytSXYy8IKHay10VYdrcl4SkqscrXpMwyGOgtkajheSxdQqmpxP1L3t4R5PqasFnrQEjytq6qgp9Y09Qx9o4S1FzhUCn1kyHSzBWLemoSGvOqLNhZyBjmCaAUYpMgt4Ck7wBBMMwWKWgjsUwTaGVsxWC1mYoKiyqqeGKYqonSIRQ3KIkHO0pmAxTdBHkbOvfllfr+AA+7gnc50huVKYK393FOyg7rbPO/izI7hE4CnHHHnJ0ogNPRUGeUpsrZZTBJcrovUcJe51BPsr6GkJdhCCsZ6aTtMEb2pqWkqeVtDXE/QVggsU/Nl86d9RMF3DxvZTA58agu810RWawCiSzzXBeU3MMW9oyJUedvNEvQyNu1f10BSMddR1vaLCYpYa/mGocLSiYDcLbQz8aMn5iyF4xBNMs1P0QEOV7o5gaWGuzSeLue4tt3ro7y4Tgm4G/mopdZgl6q0o6KzJWE3mMksNr3r+a6CbT8g5wZNzT9O7fi/zpaOmnz3BRoqos+tv9zMbdpxsqDBOEewtJLt7cg5wtKKbvldpSzRRCD43VFheCI7yZLppggMVBS/KMAdHODJvOwq2NQSbKKKPLdFWQs7Fqo+mpl01JXYRgq8dnGLhTiFzqmWsUMdpllZdbKlyvSdYxhI9YghOtxR8LgSLWHK62mGGVoxzBE8LNWzqH9CUesQzFy5RQzTc56mhi6fgXEWwpKfE5Z7M05ZgZUPmo6auiv8YKzDYwWBLMErIbKHJvOwIrvEdhOBcQ9JdU1NHQ7CXn2XIDFBKU2WAgcX9UAUzDXWd5alwuyJ41Z9rjKLCL4aCp4WarhPm2rH+SaHUYE001JDZ2ZAzXPjdMpZWvC9wmqIB2lLhQ01D5jO06hghWMndbM7yRJMsoCj1vYbnFQVrW9jak3OlEJ3s/96+p33dEPRV5GxiqaGjIthUU6FFEZyqCa5qJrpBdzSw95IUnOPIrCUUjRZQFrbw5PR0R1qiYx3cb6nrWUMrBmmiBQxVHtTew5ICP/ip6g4hed/Akob/32wvBHsIOX83cI8hGeNeNPCIkPmXe8fPKx84OMSRM1MTdXSwjCZ4S30jVGhvqTRak/OVhgGazHuOCud5onEO1lJr6ecVyaOK6H7zqlBlIaHE0oroCgfvGJIdPcmfLNGLjpz7hZwZQpUbFME0A1cIJa7VNORkgfsMBatbKgwwJM9bSvQXeNOvbIjelg6WWvo5kvbKaJJNHexkKNHL9xRyFlH8Ti2riB5wVPhUk7nGkJnoCe428LR/wRGdYIlmWebCyxou1rCk4g/ShugBDX0V0ZQWkh0dOVsagkM0yV6OoLd5ye+pRlsCr0n+KiQrGuq5yJDzrTAXHtLUMduTDBVKrSm3eHL+6ijxhFDX9Z5gVU/wliHYTMiMFpKLNMEywu80wd3meoFmt6VbRMPenhrOc6DVe4pgXU8DnnHakLOIIrlF4FZPIw6R+zxBP0dyq6OOZ4Q5sLKCcz084ok+VsMMyQhNZmmBgX5xIXOEJTmi7VsGTvMTNdHHhpzdbE8Du2oKxgvBqQKdDDnTFOylCFaxR1syz2iqrOI/FEpNc3C6f11/7+ASS6l2inq2ciTrCCzgyemrCL5SVPjQkdPZUmGy2c9Sw9FtR1sS30RmsKPCS4rkIC/2U0MduwucYolGaPjKEyhzmiPYXagyWbYz8LWBDdzRimAXzxx4z8K9hpzlhLq+NiQ97HuKorMUfK/OVvC2JfiHUPCQI/q7J2gjK+tTDNxkCc4TMssqCs4TGtLVwQihyoAWgj9bosU80XGW6Ac9TJGziaUh5+hnFcHOnlaM1iRn29NaqGENTTTSUHCH2tWTeV0osUhH6psuVLjRUmGWhm6OZEshGeNowABHcJ2Bpy2ZszRcKkRXd2QuKVEeXnbfaEq825FguqfgfE2whlChSRMdron+LATTPQ2Z369t4B9C5gs/ylzv+CMmepIDPclFQl13W0rspPd1JOcbghGOEutqCv5qacURQl3dDKyvyJlqKXGPgcM9FfawJAMVmdcspcYKOZc4GjDYkFlK05olNMHyHn4zFNykyOxt99RkHlfwmiHo60l2EKI+mhreEKp080Tbug08BVPcgoqC5zWt+NLDTZ7oNSF51N1qie7Va3uCCwyZbkINf/NED6jzOsBdZjFN8oqG3wxVunqCSYYKf3EdhJyf9YWGf7tRU2oH3VHgPr1fe5J9hOgHd7xQ0y7qBwXr23aGErP0cm64JVjZwsOGqL+mhNgZmhJLW2oY4UhedsyBgzrCKrq7BmcpNVhR6jBPq64Vgi+kn6XE68pp8J5/+0wRHGOpsKenQn9DZntPzjRLZpDAdD2fnSgkG9tmIXnUwQ6WVighs7Yi2MxQ0N3CqYaCXkJ0oyOztMDJjmSSpcpvlrk0RMMOjmArQ04PRV1DO1FwhCVaUVPpKUM03JK5SxPsIWRu8/CGHi8UHChiqGFDTbSRJWeYUDDcH6vJWUxR4k1FXbMUwV6e4AJFXS8oMqsZKqzvYQ9DDQdZckY4aGsIhtlubbd2r3j4QBMoTamdPZk7O/Bf62lacZwneNjQoGcdVU7zJOd7ghsUHOkosagic6cnWc8+4gg285R6zZP5s1/LUbCKIznTwK36PkdwlOrl4U1LwfdCCa+IrvFkmgw1PCAUXKWo0sURXWcI2muKJlgyFzhynCY4RBOsqCjoI1R5zREco0n2Vt09BQtYSizgKNHfUmUrQ5UOCh51BFcLmY7umhYqXKQomOop8bUnWNNQcIiBcYaC6xzMNOS8JQQfeqKBmmglB+97ok/lfk3ygaHSyZaCRTzRxQo6GzLfa2jWBPepw+UmT7SQEJyiyRkhBLMVOfcoMjcK0eZChfUNzFAUzCsEN5vP/X1uP/n/aoMX+K+nw/Hjr/9xOo7j7Pju61tLcgvJpTWXNbfN5jLpi6VfCOviTktKlFusQixdEKWmEBUKNaIpjZRSSOXSgzaaKLdabrm1/9nZ+/f+vd/vz/v9+Xy+zZ7PRorYoZqyLrCwQdEAixxVOEXNNnjX2nUSRlkqGmWowk8lxR50JPy9Bo6qJXaXwNvREBvnThPEPrewryLhcAnj5WE15Fqi8W7R1sAuEu86S4ENikItFN4xkv9Af4nXSnUVcLiA9xzesFpivRRVeFKtsMRaKBhuSbjOELnAUtlSQUpXgdfB4Z1oSbnFEetbQ0IrAe+Y+pqnDcEJFj6S8LDZzZHwY4e3XONNlARraomNEt2bkvGsosA3ioyHm+6jCMbI59wqt4eeara28IzEmyPgoRaUOEDhTVdEJhmCoTWfC0p8aNkCp0oYqih2iqGi4yXeMkOsn4LdLLnmKfh/YogjNsPebeFGR4m9BJHLzB61XQ3BtpISfS2FugsK9FAtLWX1dCRcrCnUp44CNzuCowUZmxSRgYaE6Za0W2u/E7CVXCiI/UOR8aAm1+OSyE3mOUcwyc1zBBeoX1kiKy0Zfxck1Gsyulti11i83QTBF5Kg3pDQThFMVHiPSlK+0cSedng/VaS8bOZbtsBcTcZAR8JP5KeqQ1OYKAi20njdNNRpgnsU//K+JnaXJaGTomr7aYIphoRn9aeShJWKEq9LcozSF7QleEfDI5LYm5bgVkFkRwVDBCVu0DDIkGupo8TZBq+/pMQURYErJQmPKGKjNDkWOLx7Jd5QizdUweIaKrlP7SwJDhZvONjLkOsBBX9UpGxnydhXkfBLQ8IxgojQbLFnJf81JytSljclYYyEFyx0kVBvKWOFJmONpshGAcsduQY5giVNCV51eOdJYo/pLhbvM0uDHSevNKRcrKZIqnCtJeEsO95RoqcgGK4ocZcho1tTYtcZvH41pNQ7vA0WrhIfOSraIIntIAi+NXWCErdbkvrWwjRLrt0NKUdL6KSOscTOdMSOUtBHwL6OLA0vNSdynaWQEnCpIvKaIrJJEbvHkmuNhn6OjM8VkSGSqn1uYJCGHnq9I3aLhNME3t6GjIkO7xrNFumpyTNX/NrwX7CrIRiqqWijI9JO4d1iieykyfiposQIQ8YjjsjlBh6oHWbwRjgYJQn2NgSnNycmJAk3NiXhx44Sxykihxm8ybUwT1OVKySc7vi3OXVkdBJ4AyXBeksDXG0IhgtYY0lY5ahCD0ehborIk5aUWRJviMA7Xt5kyRjonrXENkm8yYqgs8VzgrJmClK20uMM3jRJ0FiQICQF9hdETlLQWRIb5ki6WDfWRPobvO6a4GP5mcOrNzDFELtTkONLh9dXE8xypEg7z8A9jkhrQ6Fhjlg/QVktJXxt4WXzT/03Q8IaQWSqIuEvloQ2mqC9Jfi7wRul4RX3pSPlzpoVlmCtI2jvKHCFhjcM3sN6lqF6HxnKelLjXWbwrpR4xzuCrTUZx2qq9oAh8p6ixCUGr78g8oyjRAtB5CZFwi80VerVpI0h+IeBxa6Zg6kWvpDHaioYYuEsRbDC3eOmC2JvGYLeioxGknL2UATNJN6hmtj1DlpLvDVmocYbrGCVJKOrg4X6DgddLA203BKMFngdJJFtFd7vJLm6KEpc5yjQrkk7M80SGe34X24nSex1Ra5Omgb71JKyg8SrU3i/kARKwWpH0kOGhKkObyfd0ZGjvyXlAkVZ4xRbYJ2irFMkFY1SwyWxr2oo4zlNiV+7zmaweFpT4kR3kaDAFW6xpSqzJay05FtYR4HmZhc9UxKbbfF2V8RG1MBmSaE+kmC6JnaRXK9gsiXhJHl/U0qM0WTcbyhwkYIvFGwjSbjfwhiJt8ZSQU+Bd5+marPMOkVkD0muxYLIfEuhh60x/J92itguihJSEMySVPQnTewnEm+620rTQEMsOfo4/kP/0ARvWjitlpSX7GxBgcMEsd3EEeYWvdytd+Saawi6aCIj1CkGb6Aj9rwhx16Cf3vAwFy5pyLhVonXzy51FDpdEblbkdJbUcEPDEFzQ8qNmhzzLTmmKWKbFCXeEuRabp6rxbvAtLF442QjQ+wEA9eL1xSR7Q0JXzlSHjJ4exq89yR0laScJ/FW6z4a73pFMEfDiRZvuvijIt86RaSFOl01riV2mD1UEvxGk/Geg5aWwGki1zgKPG9J2U8PEg8qYvMsZeytiTRXBMslCU8JSlxi8EabjwUldlDNLfzTUmCgxWsjqWCOHavYAqsknKFIO0yQ61VL5AVFxk6WhEaCAkdJgt9aSkzXlKNX2jEa79waYuc7gq0N3GDJGCBhoiTXUEPsdknCUE1CK0fwsiaylSF2uiDyO4XX3pFhNd7R4itFGc0k/ElBZwWvq+GC6szVeEoS/MZ+qylwpKNKv9Z469UOjqCjwlusicyTxG6VpNxcQ8IncoR4RhLbR+NdpGGmJWOcIzJGUuKPGpQg8rrG21dOMqQssJQ4RxH5jaUqnZuQ0F4Q+cjxLwPtpZbIAk3QTJHQWBE5S1BokoVtDd6lhqr9UpHSUxMcIYl9pojsb8h4SBOsMQcqvOWC2E8EVehqiJ1hrrAEbQxeK0NGZ0Gkq+guSRgniM23bIHVkqwx4hiHd7smaOyglyIyQuM978j4VS08J/A2G1KeMBRo4fBaSNhKUEZfQewVQ/C1I+MgfbEleEzCUw7mKXI0M3hd1EESVji8x5uQ41nxs1q4RMJCCXs7Iq9acpxn22oSDnQ/sJTxsCbHIYZiLyhY05TY0ZLIOQrGaSJDDN4t8pVaIrsqqFdEegtizc1iTew5Q4ayBDMUsQMkXocaYkc0hZua412siZ1rSXlR460zRJ5SlHGe5j801RLMlJTxtaOM3Q1pvxJ45zUlWFD7rsAbpfEm1JHxG0eh8w2R7QQVzBUw28FhFp5QZzq8t2rx2joqulYTWSuJdTYfWwqMFMcovFmSyJPNyLhE4E10pHzYjOC3huArRa571ZsGajQpQx38SBP5pyZB6lMU3khDnp0MBV51BE9o2E+TY5Ml2E8S7C0o6w1xvCZjf0HkVEHCzFoyNmqC+9wdcqN+Tp7jSDheE9ws8Y5V0NJCn2bk2tqSY4okdrEhx1iDN8cSudwepWmAGXKcJXK65H9to8jYQRH7SBF01ESUJdd0TayVInaWhLkOjlXE5irKGOnI6GSWGCJa482zBI9rCr0jyTVcEuzriC1vcr6mwFGSiqy5zMwxBH/TJHwjSPhL8+01kaaSUuMFKTcLEvaUePcrSmwn8DZrgikWb7CGPxkSjhQwrRk57tctmxLsb9sZvL9LSlyuSLlWkqOjwduo8b6Uv1DkmudIeFF2dHCgxVtk8dpIvHpBxhEOdhKk7OLIUSdJ+cSRY57B+0DgGUUlNfpthTfGkauzxrvTsUUaCVhlKeteTXCoJDCa2NOKhOmC4G1H8JBd4OBZReSRGkqcb/CO1PyLJTLB4j1q8JYaIutEjSLX8YKM+a6phdMsdLFUoV5RTm9JSkuDN8WcIon0NZMNZWh1q8C7SJEwV5HxrmnnTrf3KoJBlmCYI2ilSLlfEvlE4011NNgjgthzEua0oKK7JLE7HZHlEl60BLMVFewg4EWNt0ThrVNEVkkiTwpKXSWJzdRENgvKGq4IhjsiezgSFtsfCUq8qki5S1LRQeYQQ4nemmCkImWMw3tFUoUBZk4NOeZYEp4XRKTGa6wJjrWNHBVJR4m3FCnbuD6aak2WsMTh3SZImGCIPKNgsDpVwnsa70K31lCFJZYcwwSMFcQulGTsZuEaSdBXkPGZhu0FsdUO73RHjq8MPGGIfaGIbVTk6iuI3GFgucHrIQkmWSJdBd7BBu+uOryWAhY7+Lki9rK5wtEQzWwvtbqGhIMFwWRJsElsY4m9IIg9L6lCX0VklaPAYkfkZEGDnOWowlBJjtMUkcGK4Lg6EtoZInMUBVYLgn0UsdmCyCz7gIGHFfk+k1QwTh5We7A9x+IdJ6CvIkEagms0hR50eH9UnTQJ+2oiKyVlLFUE+8gBGu8MQ3CppUHesnjTHN4QB/UGPhCTHLFPHMFrCqa73gqObUJGa03wgbhHkrCfpEpzNLE7JDS25FMKhlhKKWKfCgqstLCPu1zBXy0J2ztwjtixBu8UTRn9LVtkmCN2iyFhtME70JHRQ1KVZXqKI/KNIKYMCYs1GUMEKbM1bKOI9LDXC7zbHS+bt+1MTWS9odA9DtrYtpbImQJ2VHh/lisEwaHqUk1kjKTAKknkBEXkbkdMGwq0dnhzLJF3NJH3JVwrqOB4Sca2hti75nmJN0WzxS6UxDYoEpxpa4htVlRjkYE7DZGzJVU72uC9IyhQL4i8YfGWSYLLNcHXloyz7QhNifmKSE9JgfGmuyLhc403Xm9vqcp6gXe3xuuv8F6VJNxkyTHEkHG2g0aKXL0MsXc1bGfgas2//dCONXiNLCX+5mB7eZIl1kHh7ajwpikyzlUUWOVOsjSQlsS+M0R+pPje/dzBXRZGO0rMtgQrLLG9VSu9n6CMXS3BhwYmSoIBhsjNBmZbgusE9BCPCP5triU4VhNbJfE+swSP27aayE8tuTpYYjtrYjMVGZdp2NpS1s6aBnKSHDsbKuplKbHM4a0wMFd/5/DmGyKrJSUaW4IBrqUhx0vyfzTBBLPIUcnZdrAkNsKR0sWRspumSns6Ch0v/qqIbBYUWKvPU/CFoyrDJGwSNFhbA/MlzKqjrO80hRbpKx0Jewsi/STftwGSlKc1JZyAzx05dhLEdnfQvhZOqiHWWEAHC7+30FuRcZUgaO5gpaIK+xsiHRUsqaPElTV40xQZQ107Q9BZE1nryDVGU9ZSQ47bmhBpLcYpUt7S+xuK/FiT8qKjwXYw5ypS2iuCv7q1gtgjhuBuB8LCFY5cUuCNtsQOFcT+4Ih9JX+k8Ea6v0iCIRZOtCT0Et00JW5UeC85Cg0ScK0k411HcG1zKtre3SeITBRk7WfwDhEvaYLTHP9le0m8By0JDwn4TlLW/aJOvGHxdjYUes+ScZigCkYQdNdEOhkiezgShqkx8ueKjI8lDfK2oNiOFvrZH1hS+tk7NV7nOmLHicGWEgubkXKdwdtZknCLJXaCpkrjZBtLZFsDP9CdxWsSr05Sxl6CMmoFbCOgryX40uDtamB7SVmXW4Ihlgpmq+00tBKUUa83WbjLUNkzDmY7cow1JDygyPGlhgGKYKz4vcV7QBNbJIgM11TUqZaMdwTeSguH6rOaw1JRKzaaGyxVm2EJ/uCIrVWUcZUkcp2grMsEjK+DMwS59jQk3Kd6SEq1d0S6uVmO4Bc1lDXTUcHjluCXEq+1OlBDj1pi9zgiXxnKuE0SqTXwhqbETW6RggMEnGl/q49UT2iCzgJvRwVXS2K/d6+ZkyUl7jawSVLit46EwxVljDZwoSQ20sDBihztHfk2yA8NVZghiXwrYHQdfKAOtzsayjhY9bY0yE2CWEeJ9xfzO423xhL5syS2TFJofO2pboHob0nY4GiAgRrvGQEDa/FWSsoaaYl0syRsEt3kWoH3B01shCXhTUWe9w3Bt44SC9QCh3eShQctwbaK2ApLroGCMlZrYqvlY3qYhM0aXpFkPOuoqJ3Dm6fxXrGwVF9gCWZagjPqznfkuMKQ8DPTQRO8ZqG1hPGKEm9IgpGW4DZDgTNriTxvFiq+Lz+0cKfp4wj6OCK9JSnzNSn9LFU7UhKZZMnYwcJ8s8yRsECScK4j5UOB95HFO0CzhY4xJxuCix0lDlEUeMdS6EZBkTsUkZ4K74dugyTXS7aNgL8aqjDfkCE0ZbwkCXpaWCKhl8P7VD5jxykivSyxyZrYERbe168LYu9ZYh86IkscgVLE7tWPKmJv11CgoyJltMEbrohtVAQfO4ImltiHEroYEs7RxAarVpY8AwXMcMReFOTYWe5iiLRQxJ5Q8DtJ8LQhWOhIeFESPGsILhbNDRljNbHzNRlTFbk2S3L0NOS6V1KFJYKUbSTcIIhM0wQ/s2TM0SRMNcQmSap3jCH4yhJZKSkwyRHpYYgsFeQ4U7xoCB7VVOExhXepo9ABBsYbvGWKXPME3lyH95YioZ0gssQRWWbI+FaSMkXijZXwgiTlYdPdkNLaETxlyDVIwqeaEus0aTcYcg0RVOkpR3CSJqIddK+90JCxzsDVloyrFd5ZAr4TBKfaWa6boEA7C7s6EpYaeFPjveooY72mjIccLHJ9HUwVlDhKkmutJDJBwnp1rvulJZggKDRfbXAkvC/4l3ozQOG9a8lxjx0i7nV4jSXc7vhe3OwIxjgSHjdEhhsif9YkPGlus3iLFDnWOFhtCZbJg0UbQcIaR67JjthoCyMEZRwhiXWyxO5QxI6w5NhT4U1WsJvDO60J34fW9hwzwlKij6ZAW9ne4L0s8C6XeBMEkd/LQy1VucBRot6QMlbivaBhoBgjqGiCJNhsqVp/S2SsG6DIONCR0dXhvWbJ+MRRZJkkuEjgDXJjFQW6SSL7GXK8Z2CZg7cVsbWGoKmEpzQ5elpiy8Ryg7dMkLLUEauzeO86CuwlSOlgYLojZWeJ9xM3S1PWfEfKl5ISLQ0MEKR8YOB2QfCxJBjrKPCN4f9MkaSsqoVXJBmP7EpFZ9UQfOoOFwSzBN4MQ8LsGrymlipcJQhmy0GaQjPqCHaXRwuCZwRbqK2Fg9wlClZqYicrIgMdZfxTQ0c7TBIbrChxmuzoKG8XRaSrIhhiyNFJkrC7oIAWMEOQa5aBekPCRknCo4IKPrYkvCDI8aYmY7WFtprgekcJZ3oLIqssCSMtFbQTJKwXYy3BY5oCh2iKPCpJOE+zRdpYgi6O2KmOAgvVCYaU4ySRek1sgyFhJ403QFHiVEmJHwtybO1gs8Hr5+BETQX3War0qZngYGgtVZtoqd6vFSk/UwdZElYqyjrF4HXUeFspIi9IGKf4j92pKGAdCYMVsbcV3kRF0N+R8LUd5PCsIGWoxDtBkCI0nKofdJQxT+LtZflvuc8Q3CjwWkq8KwUpHzkK/NmSsclCL0nseQdj5FRH5CNHSgtLiW80Of5HU9Hhlsga9bnBq3fEVltKfO5IaSTmGjjc4J0otcP7QsJUSQM8pEj5/wCuUuC2DWz8AAAAAElFTkSuQmCC"); +} diff --git a/adminx/assets/vendor/codemirror/theme/ayu-dark.css b/adminx/assets/vendor/codemirror/theme/ayu-dark.css new file mode 100644 index 0000000..fd41ba3 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/ayu-dark.css @@ -0,0 +1,42 @@ +/* Based on https://github.com/dempfi/ayu */ + +.cm-s-ayu-dark.CodeMirror { background: #0a0e14; color: #b3b1ad; } +.cm-s-ayu-dark div.CodeMirror-selected { background: #273747; } +.cm-s-ayu-dark .CodeMirror-line::selection, .cm-s-ayu-dark .CodeMirror-line > span::selection, .cm-s-ayu-dark .CodeMirror-line > span > span::selection { background: rgba(39, 55, 71, 99); } +.cm-s-ayu-dark .CodeMirror-line::-moz-selection, .cm-s-ayu-dark .CodeMirror-line > span::-moz-selection, .cm-s-ayu-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(39, 55, 71, 99); } +.cm-s-ayu-dark .CodeMirror-gutters { background: #0a0e14; border-right: 0px; } +.cm-s-ayu-dark .CodeMirror-guttermarker { color: white; } +.cm-s-ayu-dark .CodeMirror-guttermarker-subtle { color: #3d424d; } +.cm-s-ayu-dark .CodeMirror-linenumber { color: #3d424d; } +.cm-s-ayu-dark .CodeMirror-cursor { border-left: 1px solid #e6b450; } + +.cm-s-ayu-dark span.cm-comment { color: #626a73; } +.cm-s-ayu-dark span.cm-atom { color: #ae81ff; } +.cm-s-ayu-dark span.cm-number { color: #e6b450; } + +.cm-s-ayu-dark span.cm-comment.cm-attribute { color: #ffb454; } +.cm-s-ayu-dark span.cm-comment.cm-def { color: rgba(57, 186, 230, 80); } +.cm-s-ayu-dark span.cm-comment.cm-tag { color: #39bae6; } +.cm-s-ayu-dark span.cm-comment.cm-type { color: #5998a6; } + +.cm-s-ayu-dark span.cm-property, .cm-s-ayu-dark span.cm-attribute { color: #ffb454; } +.cm-s-ayu-dark span.cm-keyword { color: #ff8f40; } +.cm-s-ayu-dark span.cm-builtin { color: #e6b450; } +.cm-s-ayu-dark span.cm-string { color: #c2d94c; } + +.cm-s-ayu-dark span.cm-variable { color: #b3b1ad; } +.cm-s-ayu-dark span.cm-variable-2 { color: #f07178; } +.cm-s-ayu-dark span.cm-variable-3 { color: #39bae6; } +.cm-s-ayu-dark span.cm-type { color: #ff8f40; } +.cm-s-ayu-dark span.cm-def { color: #ffee99; } +.cm-s-ayu-dark span.cm-bracket { color: #f8f8f2; } +.cm-s-ayu-dark span.cm-tag { color: rgba(57, 186, 230, 80); } +.cm-s-ayu-dark span.cm-header { color: #c2d94c; } +.cm-s-ayu-dark span.cm-link { color: #39bae6; } +.cm-s-ayu-dark span.cm-error { color: #ff3333; } + +.cm-s-ayu-dark .CodeMirror-activeline-background { background: #01060e; } +.cm-s-ayu-dark .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} diff --git a/adminx/assets/vendor/codemirror/theme/ayu-mirage.css b/adminx/assets/vendor/codemirror/theme/ayu-mirage.css new file mode 100644 index 0000000..7a5b50c --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/ayu-mirage.css @@ -0,0 +1,43 @@ +/* Based on https://github.com/dempfi/ayu */ + +.cm-s-ayu-mirage.CodeMirror { background: #1f2430; color: #cbccc6; } +.cm-s-ayu-mirage div.CodeMirror-selected { background: #34455a; } +.cm-s-ayu-mirage .CodeMirror-line::selection, .cm-s-ayu-mirage .CodeMirror-line > span::selection, .cm-s-ayu-mirage .CodeMirror-line > span > span::selection { background: #34455a; } +.cm-s-ayu-mirage .CodeMirror-line::-moz-selection, .cm-s-ayu-mirage .CodeMirror-line > span::-moz-selection, .cm-s-ayu-mirage .CodeMirror-line > span > span::-moz-selection { background: rgba(25, 30, 42, 99); } +.cm-s-ayu-mirage .CodeMirror-gutters { background: #1f2430; border-right: 0px; } +.cm-s-ayu-mirage .CodeMirror-guttermarker { color: white; } +.cm-s-ayu-mirage .CodeMirror-guttermarker-subtle { color: rgba(112, 122, 140, 66); } +.cm-s-ayu-mirage .CodeMirror-linenumber { color: rgba(61, 66, 77, 99); } +.cm-s-ayu-mirage .CodeMirror-cursor { border-left: 1px solid #ffcc66; } + +.cm-s-ayu-mirage span.cm-comment { color: #5c6773; font-style:italic; } +.cm-s-ayu-mirage span.cm-atom { color: #ae81ff; } +.cm-s-ayu-mirage span.cm-number { color: #ffcc66; } + +.cm-s-ayu-mirage span.cm-comment.cm-attribute { color: #ffd580; } +.cm-s-ayu-mirage span.cm-comment.cm-def { color: #d4bfff; } +.cm-s-ayu-mirage span.cm-comment.cm-tag { color: #5ccfe6; } +.cm-s-ayu-mirage span.cm-comment.cm-type { color: #5998a6; } + +.cm-s-ayu-mirage span.cm-property { color: #f29e74; } +.cm-s-ayu-mirage span.cm-attribute { color: #ffd580; } +.cm-s-ayu-mirage span.cm-keyword { color: #ffa759; } +.cm-s-ayu-mirage span.cm-builtin { color: #ffcc66; } +.cm-s-ayu-mirage span.cm-string { color: #bae67e; } + +.cm-s-ayu-mirage span.cm-variable { color: #cbccc6; } +.cm-s-ayu-mirage span.cm-variable-2 { color: #f28779; } +.cm-s-ayu-mirage span.cm-variable-3 { color: #5ccfe6; } +.cm-s-ayu-mirage span.cm-type { color: #ffa759; } +.cm-s-ayu-mirage span.cm-def { color: #ffd580; } +.cm-s-ayu-mirage span.cm-bracket { color: rgba(92, 207, 230, 80); } +.cm-s-ayu-mirage span.cm-tag { color: #5ccfe6; } +.cm-s-ayu-mirage span.cm-header { color: #bae67e; } +.cm-s-ayu-mirage span.cm-link { color: #5ccfe6; } +.cm-s-ayu-mirage span.cm-error { color: #ff3333; } + +.cm-s-ayu-mirage .CodeMirror-activeline-background { background: #191e2a; } +.cm-s-ayu-mirage .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} diff --git a/adminx/assets/vendor/codemirror/theme/base16-dark.css b/adminx/assets/vendor/codemirror/theme/base16-dark.css new file mode 100644 index 0000000..026a816 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/base16-dark.css @@ -0,0 +1,38 @@ +/* + + Name: Base16 Default Dark + Author: Chris Kempson (http://chriskempson.com) + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-base16-dark.CodeMirror { background: #151515; color: #e0e0e0; } +.cm-s-base16-dark div.CodeMirror-selected { background: #303030; } +.cm-s-base16-dark .CodeMirror-line::selection, .cm-s-base16-dark .CodeMirror-line > span::selection, .cm-s-base16-dark .CodeMirror-line > span > span::selection { background: rgba(48, 48, 48, .99); } +.cm-s-base16-dark .CodeMirror-line::-moz-selection, .cm-s-base16-dark .CodeMirror-line > span::-moz-selection, .cm-s-base16-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(48, 48, 48, .99); } +.cm-s-base16-dark .CodeMirror-gutters { background: #151515; border-right: 0px; } +.cm-s-base16-dark .CodeMirror-guttermarker { color: #ac4142; } +.cm-s-base16-dark .CodeMirror-guttermarker-subtle { color: #505050; } +.cm-s-base16-dark .CodeMirror-linenumber { color: #505050; } +.cm-s-base16-dark .CodeMirror-cursor { border-left: 1px solid #b0b0b0; } + +.cm-s-base16-dark span.cm-comment { color: #8f5536; } +.cm-s-base16-dark span.cm-atom { color: #aa759f; } +.cm-s-base16-dark span.cm-number { color: #aa759f; } + +.cm-s-base16-dark span.cm-property, .cm-s-base16-dark span.cm-attribute { color: #90a959; } +.cm-s-base16-dark span.cm-keyword { color: #ac4142; } +.cm-s-base16-dark span.cm-string { color: #f4bf75; } + +.cm-s-base16-dark span.cm-variable { color: #90a959; } +.cm-s-base16-dark span.cm-variable-2 { color: #6a9fb5; } +.cm-s-base16-dark span.cm-def { color: #d28445; } +.cm-s-base16-dark span.cm-bracket { color: #e0e0e0; } +.cm-s-base16-dark span.cm-tag { color: #ac4142; } +.cm-s-base16-dark span.cm-link { color: #aa759f; } +.cm-s-base16-dark span.cm-error { background: #ac4142; color: #b0b0b0; } + +.cm-s-base16-dark .CodeMirror-activeline-background { background: #202020; } +.cm-s-base16-dark .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/base16-light.css b/adminx/assets/vendor/codemirror/theme/base16-light.css new file mode 100644 index 0000000..1d5f582 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/base16-light.css @@ -0,0 +1,38 @@ +/* + + Name: Base16 Default Light + Author: Chris Kempson (http://chriskempson.com) + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-base16-light.CodeMirror { background: #f5f5f5; color: #202020; } +.cm-s-base16-light div.CodeMirror-selected { background: #e0e0e0; } +.cm-s-base16-light .CodeMirror-line::selection, .cm-s-base16-light .CodeMirror-line > span::selection, .cm-s-base16-light .CodeMirror-line > span > span::selection { background: #e0e0e0; } +.cm-s-base16-light .CodeMirror-line::-moz-selection, .cm-s-base16-light .CodeMirror-line > span::-moz-selection, .cm-s-base16-light .CodeMirror-line > span > span::-moz-selection { background: #e0e0e0; } +.cm-s-base16-light .CodeMirror-gutters { background: #f5f5f5; border-right: 0px; } +.cm-s-base16-light .CodeMirror-guttermarker { color: #ac4142; } +.cm-s-base16-light .CodeMirror-guttermarker-subtle { color: #b0b0b0; } +.cm-s-base16-light .CodeMirror-linenumber { color: #b0b0b0; } +.cm-s-base16-light .CodeMirror-cursor { border-left: 1px solid #505050; } + +.cm-s-base16-light span.cm-comment { color: #8f5536; } +.cm-s-base16-light span.cm-atom { color: #aa759f; } +.cm-s-base16-light span.cm-number { color: #aa759f; } + +.cm-s-base16-light span.cm-property, .cm-s-base16-light span.cm-attribute { color: #90a959; } +.cm-s-base16-light span.cm-keyword { color: #ac4142; } +.cm-s-base16-light span.cm-string { color: #f4bf75; } + +.cm-s-base16-light span.cm-variable { color: #90a959; } +.cm-s-base16-light span.cm-variable-2 { color: #6a9fb5; } +.cm-s-base16-light span.cm-def { color: #d28445; } +.cm-s-base16-light span.cm-bracket { color: #202020; } +.cm-s-base16-light span.cm-tag { color: #ac4142; } +.cm-s-base16-light span.cm-link { color: #aa759f; } +.cm-s-base16-light span.cm-error { background: #ac4142; color: #505050; } + +.cm-s-base16-light .CodeMirror-activeline-background { background: #DDDCDC; } +.cm-s-base16-light .CodeMirror-matchingbracket { color: #f5f5f5 !important; background-color: #6A9FB5 !important} diff --git a/adminx/assets/vendor/codemirror/theme/bespin.css b/adminx/assets/vendor/codemirror/theme/bespin.css new file mode 100644 index 0000000..60913ba --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/bespin.css @@ -0,0 +1,34 @@ +/* + + Name: Bespin + Author: Mozilla / Jan T. Sott + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-bespin.CodeMirror {background: #28211c; color: #9d9b97;} +.cm-s-bespin div.CodeMirror-selected {background: #36312e !important;} +.cm-s-bespin .CodeMirror-gutters {background: #28211c; border-right: 0px;} +.cm-s-bespin .CodeMirror-linenumber {color: #666666;} +.cm-s-bespin .CodeMirror-cursor {border-left: 1px solid #797977 !important;} + +.cm-s-bespin span.cm-comment {color: #937121;} +.cm-s-bespin span.cm-atom {color: #9b859d;} +.cm-s-bespin span.cm-number {color: #9b859d;} + +.cm-s-bespin span.cm-property, .cm-s-bespin span.cm-attribute {color: #54be0d;} +.cm-s-bespin span.cm-keyword {color: #cf6a4c;} +.cm-s-bespin span.cm-string {color: #f9ee98;} + +.cm-s-bespin span.cm-variable {color: #54be0d;} +.cm-s-bespin span.cm-variable-2 {color: #5ea6ea;} +.cm-s-bespin span.cm-def {color: #cf7d34;} +.cm-s-bespin span.cm-error {background: #cf6a4c; color: #797977;} +.cm-s-bespin span.cm-bracket {color: #9d9b97;} +.cm-s-bespin span.cm-tag {color: #cf6a4c;} +.cm-s-bespin span.cm-link {color: #9b859d;} + +.cm-s-bespin .CodeMirror-matchingbracket { text-decoration: underline; color: white !important;} +.cm-s-bespin .CodeMirror-activeline-background { background: #404040; } diff --git a/adminx/assets/vendor/codemirror/theme/blackboard.css b/adminx/assets/vendor/codemirror/theme/blackboard.css new file mode 100644 index 0000000..b6eaedb --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/blackboard.css @@ -0,0 +1,32 @@ +/* Port of TextMate's Blackboard theme */ + +.cm-s-blackboard.CodeMirror { background: #0C1021; color: #F8F8F8; } +.cm-s-blackboard div.CodeMirror-selected { background: #253B76; } +.cm-s-blackboard .CodeMirror-line::selection, .cm-s-blackboard .CodeMirror-line > span::selection, .cm-s-blackboard .CodeMirror-line > span > span::selection { background: rgba(37, 59, 118, .99); } +.cm-s-blackboard .CodeMirror-line::-moz-selection, .cm-s-blackboard .CodeMirror-line > span::-moz-selection, .cm-s-blackboard .CodeMirror-line > span > span::-moz-selection { background: rgba(37, 59, 118, .99); } +.cm-s-blackboard .CodeMirror-gutters { background: #0C1021; border-right: 0; } +.cm-s-blackboard .CodeMirror-guttermarker { color: #FBDE2D; } +.cm-s-blackboard .CodeMirror-guttermarker-subtle { color: #888; } +.cm-s-blackboard .CodeMirror-linenumber { color: #888; } +.cm-s-blackboard .CodeMirror-cursor { border-left: 1px solid #A7A7A7; } + +.cm-s-blackboard .cm-keyword { color: #FBDE2D; } +.cm-s-blackboard .cm-atom { color: #D8FA3C; } +.cm-s-blackboard .cm-number { color: #D8FA3C; } +.cm-s-blackboard .cm-def { color: #8DA6CE; } +.cm-s-blackboard .cm-variable { color: #FF6400; } +.cm-s-blackboard .cm-operator { color: #FBDE2D; } +.cm-s-blackboard .cm-comment { color: #AEAEAE; } +.cm-s-blackboard .cm-string { color: #61CE3C; } +.cm-s-blackboard .cm-string-2 { color: #61CE3C; } +.cm-s-blackboard .cm-meta { color: #D8FA3C; } +.cm-s-blackboard .cm-builtin { color: #8DA6CE; } +.cm-s-blackboard .cm-tag { color: #8DA6CE; } +.cm-s-blackboard .cm-attribute { color: #8DA6CE; } +.cm-s-blackboard .cm-header { color: #FF6400; } +.cm-s-blackboard .cm-hr { color: #AEAEAE; } +.cm-s-blackboard .cm-link { color: #8DA6CE; } +.cm-s-blackboard .cm-error { background: #9D1E15; color: #F8F8F8; } + +.cm-s-blackboard .CodeMirror-activeline-background { background: #3C3636; } +.cm-s-blackboard .CodeMirror-matchingbracket { outline:1px solid grey;color:white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/cobalt.css b/adminx/assets/vendor/codemirror/theme/cobalt.css new file mode 100644 index 0000000..bbbda3b --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/cobalt.css @@ -0,0 +1,25 @@ +.cm-s-cobalt.CodeMirror { background: #002240; color: white; } +.cm-s-cobalt div.CodeMirror-selected { background: #b36539; } +.cm-s-cobalt .CodeMirror-line::selection, .cm-s-cobalt .CodeMirror-line > span::selection, .cm-s-cobalt .CodeMirror-line > span > span::selection { background: rgba(179, 101, 57, .99); } +.cm-s-cobalt .CodeMirror-line::-moz-selection, .cm-s-cobalt .CodeMirror-line > span::-moz-selection, .cm-s-cobalt .CodeMirror-line > span > span::-moz-selection { background: rgba(179, 101, 57, .99); } +.cm-s-cobalt .CodeMirror-gutters { background: #002240; border-right: 1px solid #aaa; } +.cm-s-cobalt .CodeMirror-guttermarker { color: #ffee80; } +.cm-s-cobalt .CodeMirror-guttermarker-subtle { color: #d0d0d0; } +.cm-s-cobalt .CodeMirror-linenumber { color: #d0d0d0; } +.cm-s-cobalt .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-cobalt span.cm-comment { color: #08f; } +.cm-s-cobalt span.cm-atom { color: #845dc4; } +.cm-s-cobalt span.cm-number, .cm-s-cobalt span.cm-attribute { color: #ff80e1; } +.cm-s-cobalt span.cm-keyword { color: #ffee80; } +.cm-s-cobalt span.cm-string { color: #3ad900; } +.cm-s-cobalt span.cm-meta { color: #ff9d00; } +.cm-s-cobalt span.cm-variable-2, .cm-s-cobalt span.cm-tag { color: #9effff; } +.cm-s-cobalt span.cm-variable-3, .cm-s-cobalt span.cm-def, .cm-s-cobalt .cm-type { color: white; } +.cm-s-cobalt span.cm-bracket { color: #d8d8d8; } +.cm-s-cobalt span.cm-builtin, .cm-s-cobalt span.cm-special { color: #ff9e59; } +.cm-s-cobalt span.cm-link { color: #845dc4; } +.cm-s-cobalt span.cm-error { color: #9d1e15; } + +.cm-s-cobalt .CodeMirror-activeline-background { background: #002D57; } +.cm-s-cobalt .CodeMirror-matchingbracket { outline:1px solid grey;color:white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/colorforth.css b/adminx/assets/vendor/codemirror/theme/colorforth.css new file mode 100644 index 0000000..19095e4 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/colorforth.css @@ -0,0 +1,33 @@ +.cm-s-colorforth.CodeMirror { background: #000000; color: #f8f8f8; } +.cm-s-colorforth .CodeMirror-gutters { background: #0a001f; border-right: 1px solid #aaa; } +.cm-s-colorforth .CodeMirror-guttermarker { color: #FFBD40; } +.cm-s-colorforth .CodeMirror-guttermarker-subtle { color: #78846f; } +.cm-s-colorforth .CodeMirror-linenumber { color: #bababa; } +.cm-s-colorforth .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-colorforth span.cm-comment { color: #ededed; } +.cm-s-colorforth span.cm-def { color: #ff1c1c; font-weight:bold; } +.cm-s-colorforth span.cm-keyword { color: #ffd900; } +.cm-s-colorforth span.cm-builtin { color: #00d95a; } +.cm-s-colorforth span.cm-variable { color: #73ff00; } +.cm-s-colorforth span.cm-string { color: #007bff; } +.cm-s-colorforth span.cm-number { color: #00c4ff; } +.cm-s-colorforth span.cm-atom { color: #606060; } + +.cm-s-colorforth span.cm-variable-2 { color: #EEE; } +.cm-s-colorforth span.cm-variable-3, .cm-s-colorforth span.cm-type { color: #DDD; } +.cm-s-colorforth span.cm-property {} +.cm-s-colorforth span.cm-operator {} + +.cm-s-colorforth span.cm-meta { color: yellow; } +.cm-s-colorforth span.cm-qualifier { color: #FFF700; } +.cm-s-colorforth span.cm-bracket { color: #cc7; } +.cm-s-colorforth span.cm-tag { color: #FFBD40; } +.cm-s-colorforth span.cm-attribute { color: #FFF700; } +.cm-s-colorforth span.cm-error { color: #f00; } + +.cm-s-colorforth div.CodeMirror-selected { background: #333d53; } + +.cm-s-colorforth span.cm-compilation { background: rgba(255, 255, 255, 0.12); } + +.cm-s-colorforth .CodeMirror-activeline-background { background: #253540; } diff --git a/adminx/assets/vendor/codemirror/theme/darcula.css b/adminx/assets/vendor/codemirror/theme/darcula.css new file mode 100644 index 0000000..2ec81a3 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/darcula.css @@ -0,0 +1,53 @@ +/** + Name: IntelliJ IDEA darcula theme + From IntelliJ IDEA by JetBrains + */ + +.cm-s-darcula { font-family: Consolas, Menlo, Monaco, 'Lucida Console', 'Liberation Mono', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Courier New', monospace, serif;} +.cm-s-darcula.CodeMirror { background: #2B2B2B; color: #A9B7C6; } + +.cm-s-darcula span.cm-meta { color: #BBB529; } +.cm-s-darcula span.cm-number { color: #6897BB; } +.cm-s-darcula span.cm-keyword { color: #CC7832; line-height: 1em; font-weight: bold; } +.cm-s-darcula span.cm-def { color: #A9B7C6; font-style: italic; } +.cm-s-darcula span.cm-variable { color: #A9B7C6; } +.cm-s-darcula span.cm-variable-2 { color: #A9B7C6; } +.cm-s-darcula span.cm-variable-3 { color: #9876AA; } +.cm-s-darcula span.cm-type { color: #AABBCC; font-weight: bold; } +.cm-s-darcula span.cm-property { color: #FFC66D; } +.cm-s-darcula span.cm-operator { color: #A9B7C6; } +.cm-s-darcula span.cm-string { color: #6A8759; } +.cm-s-darcula span.cm-string-2 { color: #6A8759; } +.cm-s-darcula span.cm-comment { color: #61A151; font-style: italic; } +.cm-s-darcula span.cm-link { color: #CC7832; } +.cm-s-darcula span.cm-atom { color: #CC7832; } +.cm-s-darcula span.cm-error { color: #BC3F3C; } +.cm-s-darcula span.cm-tag { color: #629755; font-weight: bold; font-style: italic; text-decoration: underline; } +.cm-s-darcula span.cm-attribute { color: #6897bb; } +.cm-s-darcula span.cm-qualifier { color: #6A8759; } +.cm-s-darcula span.cm-bracket { color: #A9B7C6; } +.cm-s-darcula span.cm-builtin { color: #FF9E59; } +.cm-s-darcula span.cm-special { color: #FF9E59; } +.cm-s-darcula span.cm-matchhighlight { color: #FFFFFF; background-color: rgba(50, 89, 48, .7); font-weight: normal;} +.cm-s-darcula span.cm-searching { color: #FFFFFF; background-color: rgba(61, 115, 59, .7); font-weight: normal;} + +.cm-s-darcula .CodeMirror-cursor { border-left: 1px solid #A9B7C6; } +.cm-s-darcula .CodeMirror-activeline-background { background: #323232; } +.cm-s-darcula .CodeMirror-gutters { background: #313335; border-right: 1px solid #313335; } +.cm-s-darcula .CodeMirror-guttermarker { color: #FFEE80; } +.cm-s-darcula .CodeMirror-guttermarker-subtle { color: #D0D0D0; } +.cm-s-darcula .CodeMirrir-linenumber { color: #606366; } +.cm-s-darcula .CodeMirror-matchingbracket { background-color: #3B514D; color: #FFEF28 !important; font-weight: bold; } + +.cm-s-darcula div.CodeMirror-selected { background: #214283; } + +.CodeMirror-hints.darcula { + font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; + color: #9C9E9E; + background-color: #3B3E3F !important; +} + +.CodeMirror-hints.darcula .CodeMirror-hint-active { + background-color: #494D4E !important; + color: #9C9E9E !important; +} diff --git a/adminx/assets/vendor/codemirror/theme/dracula.css b/adminx/assets/vendor/codemirror/theme/dracula.css new file mode 100644 index 0000000..253133e --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/dracula.css @@ -0,0 +1,40 @@ +/* + + Name: dracula + Author: Michael Kaminsky (http://github.com/mkaminsky11) + + Original dracula color scheme by Zeno Rocha (https://github.com/zenorocha/dracula-theme) + +*/ + + +.cm-s-dracula.CodeMirror, .cm-s-dracula .CodeMirror-gutters { + background-color: #282a36 !important; + color: #f8f8f2 !important; + border: none; +} +.cm-s-dracula .CodeMirror-gutters { color: #282a36; } +.cm-s-dracula .CodeMirror-cursor { border-left: solid thin #f8f8f0; } +.cm-s-dracula .CodeMirror-linenumber { color: #6D8A88; } +.cm-s-dracula .CodeMirror-selected { background: rgba(255, 255, 255, 0.10); } +.cm-s-dracula .CodeMirror-line::selection, .cm-s-dracula .CodeMirror-line > span::selection, .cm-s-dracula .CodeMirror-line > span > span::selection { background: rgba(255, 255, 255, 0.10); } +.cm-s-dracula .CodeMirror-line::-moz-selection, .cm-s-dracula .CodeMirror-line > span::-moz-selection, .cm-s-dracula .CodeMirror-line > span > span::-moz-selection { background: rgba(255, 255, 255, 0.10); } +.cm-s-dracula span.cm-comment { color: #6272a4; } +.cm-s-dracula span.cm-string, .cm-s-dracula span.cm-string-2 { color: #f1fa8c; } +.cm-s-dracula span.cm-number { color: #bd93f9; } +.cm-s-dracula span.cm-variable { color: #50fa7b; } +.cm-s-dracula span.cm-variable-2 { color: white; } +.cm-s-dracula span.cm-def { color: #50fa7b; } +.cm-s-dracula span.cm-operator { color: #ff79c6; } +.cm-s-dracula span.cm-keyword { color: #ff79c6; } +.cm-s-dracula span.cm-atom { color: #bd93f9; } +.cm-s-dracula span.cm-meta { color: #f8f8f2; } +.cm-s-dracula span.cm-tag { color: #ff79c6; } +.cm-s-dracula span.cm-attribute { color: #50fa7b; } +.cm-s-dracula span.cm-qualifier { color: #50fa7b; } +.cm-s-dracula span.cm-property { color: #66d9ef; } +.cm-s-dracula span.cm-builtin { color: #50fa7b; } +.cm-s-dracula span.cm-variable-3, .cm-s-dracula span.cm-type { color: #ffb86c; } + +.cm-s-dracula .CodeMirror-activeline-background { background: rgba(255,255,255,0.1); } +.cm-s-dracula .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/duotone-dark.css b/adminx/assets/vendor/codemirror/theme/duotone-dark.css new file mode 100644 index 0000000..88fdc76 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/duotone-dark.css @@ -0,0 +1,35 @@ +/* +Name: DuoTone-Dark +Author: by Bram de Haan, adapted from DuoTone themes by Simurai (http://simurai.com/projects/2016/01/01/duotone-themes) + +CodeMirror template by Jan T. Sott (https://github.com/idleberg), adapted by Bram de Haan (https://github.com/atelierbram/) +*/ + +.cm-s-duotone-dark.CodeMirror { background: #2a2734; color: #6c6783; } +.cm-s-duotone-dark div.CodeMirror-selected { background: #545167!important; } +.cm-s-duotone-dark .CodeMirror-gutters { background: #2a2734; border-right: 0px; } +.cm-s-duotone-dark .CodeMirror-linenumber { color: #545167; } + +/* begin cursor */ +.cm-s-duotone-dark .CodeMirror-cursor { border-left: 1px solid #ffad5c; /* border-left: 1px solid #ffad5c80; */ border-right: .5em solid #ffad5c; /* border-right: .5em solid #ffad5c80; */ opacity: .5; } +.cm-s-duotone-dark .CodeMirror-activeline-background { background: #363342; /* background: #36334280; */ opacity: .5;} +.cm-s-duotone-dark .cm-fat-cursor .CodeMirror-cursor { background: #ffad5c; /* background: #ffad5c80; */ opacity: .5;} +/* end cursor */ + +.cm-s-duotone-dark span.cm-atom, .cm-s-duotone-dark span.cm-number, .cm-s-duotone-dark span.cm-keyword, .cm-s-duotone-dark span.cm-variable, .cm-s-duotone-dark span.cm-attribute, .cm-s-duotone-dark span.cm-quote, .cm-s-duotone-dark span.cm-hr, .cm-s-duotone-dark span.cm-link { color: #ffcc99; } + +.cm-s-duotone-dark span.cm-property { color: #9a86fd; } +.cm-s-duotone-dark span.cm-punctuation, .cm-s-duotone-dark span.cm-unit, .cm-s-duotone-dark span.cm-negative { color: #e09142; } +.cm-s-duotone-dark span.cm-string { color: #ffb870; } +.cm-s-duotone-dark span.cm-operator { color: #ffad5c; } +.cm-s-duotone-dark span.cm-positive { color: #6a51e6; } + +.cm-s-duotone-dark span.cm-variable-2, .cm-s-duotone-dark span.cm-variable-3, .cm-s-duotone-dark span.cm-type, .cm-s-duotone-dark span.cm-string-2, .cm-s-duotone-dark span.cm-url { color: #7a63ee; } +.cm-s-duotone-dark span.cm-def, .cm-s-duotone-dark span.cm-tag, .cm-s-duotone-dark span.cm-builtin, .cm-s-duotone-dark span.cm-qualifier, .cm-s-duotone-dark span.cm-header, .cm-s-duotone-dark span.cm-em { color: #eeebff; } +.cm-s-duotone-dark span.cm-bracket, .cm-s-duotone-dark span.cm-comment { color: #6c6783; } + +/* using #f00 red for errors, don't think any of the colorscheme variables will stand out enough, ... maybe by giving it a background-color ... */ +.cm-s-duotone-dark span.cm-error, .cm-s-duotone-dark span.cm-invalidchar { color: #f00; } + +.cm-s-duotone-dark span.cm-header { font-weight: normal; } +.cm-s-duotone-dark .CodeMirror-matchingbracket { text-decoration: underline; color: #eeebff !important; } diff --git a/adminx/assets/vendor/codemirror/theme/duotone-light.css b/adminx/assets/vendor/codemirror/theme/duotone-light.css new file mode 100644 index 0000000..d99480f --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/duotone-light.css @@ -0,0 +1,36 @@ +/* +Name: DuoTone-Light +Author: by Bram de Haan, adapted from DuoTone themes by Simurai (http://simurai.com/projects/2016/01/01/duotone-themes) + +CodeMirror template by Jan T. Sott (https://github.com/idleberg), adapted by Bram de Haan (https://github.com/atelierbram/) +*/ + +.cm-s-duotone-light.CodeMirror { background: #faf8f5; color: #b29762; } +.cm-s-duotone-light div.CodeMirror-selected { background: #e3dcce !important; } +.cm-s-duotone-light .CodeMirror-gutters { background: #faf8f5; border-right: 0px; } +.cm-s-duotone-light .CodeMirror-linenumber { color: #cdc4b1; } + +/* begin cursor */ +.cm-s-duotone-light .CodeMirror-cursor { border-left: 1px solid #93abdc; /* border-left: 1px solid #93abdc80; */ border-right: .5em solid #93abdc; /* border-right: .5em solid #93abdc80; */ opacity: .5; } +.cm-s-duotone-light .CodeMirror-activeline-background { background: #e3dcce; /* background: #e3dcce80; */ opacity: .5; } +.cm-s-duotone-light .cm-fat-cursor .CodeMirror-cursor { background: #93abdc; /* #93abdc80; */ opacity: .5; } +/* end cursor */ + +.cm-s-duotone-light span.cm-atom, .cm-s-duotone-light span.cm-number, .cm-s-duotone-light span.cm-keyword, .cm-s-duotone-light span.cm-variable, .cm-s-duotone-light span.cm-attribute, .cm-s-duotone-light span.cm-quote, .cm-s-duotone-light-light span.cm-hr, .cm-s-duotone-light-light span.cm-link { color: #063289; } + +.cm-s-duotone-light span.cm-property { color: #b29762; } +.cm-s-duotone-light span.cm-punctuation, .cm-s-duotone-light span.cm-unit, .cm-s-duotone-light span.cm-negative { color: #063289; } +.cm-s-duotone-light span.cm-string, .cm-s-duotone-light span.cm-operator { color: #1659df; } +.cm-s-duotone-light span.cm-positive { color: #896724; } + +.cm-s-duotone-light span.cm-variable-2, .cm-s-duotone-light span.cm-variable-3, .cm-s-duotone-light span.cm-type, .cm-s-duotone-light span.cm-string-2, .cm-s-duotone-light span.cm-url { color: #896724; } +.cm-s-duotone-light span.cm-def, .cm-s-duotone-light span.cm-tag, .cm-s-duotone-light span.cm-builtin, .cm-s-duotone-light span.cm-qualifier, .cm-s-duotone-light span.cm-header, .cm-s-duotone-light span.cm-em { color: #2d2006; } +.cm-s-duotone-light span.cm-bracket, .cm-s-duotone-light span.cm-comment { color: #b6ad9a; } + +/* using #f00 red for errors, don't think any of the colorscheme variables will stand out enough, ... maybe by giving it a background-color ... */ +/* .cm-s-duotone-light span.cm-error { background: #896724; color: #728fcb; } */ +.cm-s-duotone-light span.cm-error, .cm-s-duotone-light span.cm-invalidchar { color: #f00; } + +.cm-s-duotone-light span.cm-header { font-weight: normal; } +.cm-s-duotone-light .CodeMirror-matchingbracket { text-decoration: underline; color: #faf8f5 !important; } + diff --git a/adminx/assets/vendor/codemirror/theme/eclipse.css b/adminx/assets/vendor/codemirror/theme/eclipse.css new file mode 100644 index 0000000..800d603 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/eclipse.css @@ -0,0 +1,23 @@ +.cm-s-eclipse span.cm-meta { color: #FF1717; } +.cm-s-eclipse span.cm-keyword { line-height: 1em; font-weight: bold; color: #7F0055; } +.cm-s-eclipse span.cm-atom { color: #219; } +.cm-s-eclipse span.cm-number { color: #164; } +.cm-s-eclipse span.cm-def { color: #00f; } +.cm-s-eclipse span.cm-variable { color: black; } +.cm-s-eclipse span.cm-variable-2 { color: #0000C0; } +.cm-s-eclipse span.cm-variable-3, .cm-s-eclipse span.cm-type { color: #0000C0; } +.cm-s-eclipse span.cm-property { color: black; } +.cm-s-eclipse span.cm-operator { color: black; } +.cm-s-eclipse span.cm-comment { color: #3F7F5F; } +.cm-s-eclipse span.cm-string { color: #2A00FF; } +.cm-s-eclipse span.cm-string-2 { color: #f50; } +.cm-s-eclipse span.cm-qualifier { color: #555; } +.cm-s-eclipse span.cm-builtin { color: #30a; } +.cm-s-eclipse span.cm-bracket { color: #cc7; } +.cm-s-eclipse span.cm-tag { color: #170; } +.cm-s-eclipse span.cm-attribute { color: #00c; } +.cm-s-eclipse span.cm-link { color: #219; } +.cm-s-eclipse span.cm-error { color: #f00; } + +.cm-s-eclipse .CodeMirror-activeline-background { background: #e8f2ff; } +.cm-s-eclipse .CodeMirror-matchingbracket { outline:1px solid grey; color:black !important; } diff --git a/adminx/assets/vendor/codemirror/theme/elegant.css b/adminx/assets/vendor/codemirror/theme/elegant.css new file mode 100644 index 0000000..45b3ea6 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/elegant.css @@ -0,0 +1,13 @@ +.cm-s-elegant span.cm-number, .cm-s-elegant span.cm-string, .cm-s-elegant span.cm-atom { color: #762; } +.cm-s-elegant span.cm-comment { color: #262; font-style: italic; line-height: 1em; } +.cm-s-elegant span.cm-meta { color: #555; font-style: italic; line-height: 1em; } +.cm-s-elegant span.cm-variable { color: black; } +.cm-s-elegant span.cm-variable-2 { color: #b11; } +.cm-s-elegant span.cm-qualifier { color: #555; } +.cm-s-elegant span.cm-keyword { color: #730; } +.cm-s-elegant span.cm-builtin { color: #30a; } +.cm-s-elegant span.cm-link { color: #762; } +.cm-s-elegant span.cm-error { background-color: #fdd; } + +.cm-s-elegant .CodeMirror-activeline-background { background: #e8f2ff; } +.cm-s-elegant .CodeMirror-matchingbracket { outline:1px solid grey; color:black !important; } diff --git a/adminx/assets/vendor/codemirror/theme/erlang-dark.css b/adminx/assets/vendor/codemirror/theme/erlang-dark.css new file mode 100644 index 0000000..8c8a417 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/erlang-dark.css @@ -0,0 +1,34 @@ +.cm-s-erlang-dark.CodeMirror { background: #002240; color: white; } +.cm-s-erlang-dark div.CodeMirror-selected { background: #b36539; } +.cm-s-erlang-dark .CodeMirror-line::selection, .cm-s-erlang-dark .CodeMirror-line > span::selection, .cm-s-erlang-dark .CodeMirror-line > span > span::selection { background: rgba(179, 101, 57, .99); } +.cm-s-erlang-dark .CodeMirror-line::-moz-selection, .cm-s-erlang-dark .CodeMirror-line > span::-moz-selection, .cm-s-erlang-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(179, 101, 57, .99); } +.cm-s-erlang-dark .CodeMirror-gutters { background: #002240; border-right: 1px solid #aaa; } +.cm-s-erlang-dark .CodeMirror-guttermarker { color: white; } +.cm-s-erlang-dark .CodeMirror-guttermarker-subtle { color: #d0d0d0; } +.cm-s-erlang-dark .CodeMirror-linenumber { color: #d0d0d0; } +.cm-s-erlang-dark .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-erlang-dark span.cm-quote { color: #ccc; } +.cm-s-erlang-dark span.cm-atom { color: #f133f1; } +.cm-s-erlang-dark span.cm-attribute { color: #ff80e1; } +.cm-s-erlang-dark span.cm-bracket { color: #ff9d00; } +.cm-s-erlang-dark span.cm-builtin { color: #eaa; } +.cm-s-erlang-dark span.cm-comment { color: #77f; } +.cm-s-erlang-dark span.cm-def { color: #e7a; } +.cm-s-erlang-dark span.cm-keyword { color: #ffee80; } +.cm-s-erlang-dark span.cm-meta { color: #50fefe; } +.cm-s-erlang-dark span.cm-number { color: #ffd0d0; } +.cm-s-erlang-dark span.cm-operator { color: #d55; } +.cm-s-erlang-dark span.cm-property { color: #ccc; } +.cm-s-erlang-dark span.cm-qualifier { color: #ccc; } +.cm-s-erlang-dark span.cm-special { color: #ffbbbb; } +.cm-s-erlang-dark span.cm-string { color: #3ad900; } +.cm-s-erlang-dark span.cm-string-2 { color: #ccc; } +.cm-s-erlang-dark span.cm-tag { color: #9effff; } +.cm-s-erlang-dark span.cm-variable { color: #50fe50; } +.cm-s-erlang-dark span.cm-variable-2 { color: #e0e; } +.cm-s-erlang-dark span.cm-variable-3, .cm-s-erlang-dark span.cm-type { color: #ccc; } +.cm-s-erlang-dark span.cm-error { color: #9d1e15; } + +.cm-s-erlang-dark .CodeMirror-activeline-background { background: #013461; } +.cm-s-erlang-dark .CodeMirror-matchingbracket { outline:1px solid grey; color:white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/gruvbox-dark.css b/adminx/assets/vendor/codemirror/theme/gruvbox-dark.css new file mode 100644 index 0000000..ded215f --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/gruvbox-dark.css @@ -0,0 +1,37 @@ +/* + + Name: gruvbox-dark + Author: kRkk (https://github.com/krkk) + + Original gruvbox color scheme by Pavel Pertsev (https://github.com/morhetz/gruvbox) + +*/ + +.cm-s-gruvbox-dark.CodeMirror, .cm-s-gruvbox-dark .CodeMirror-gutters { background-color: #282828; color: #bdae93; } +.cm-s-gruvbox-dark .CodeMirror-gutters {background: #282828; border-right: 0px;} +.cm-s-gruvbox-dark .CodeMirror-linenumber {color: #7c6f64;} +.cm-s-gruvbox-dark .CodeMirror-cursor { border-left: 1px solid #ebdbb2; } +.cm-s-gruvbox-dark div.CodeMirror-selected { background: #928374; } +.cm-s-gruvbox-dark span.cm-meta { color: #83a598; } + +.cm-s-gruvbox-dark span.cm-comment { color: #928374; } +.cm-s-gruvbox-dark span.cm-number, span.cm-atom { color: #d3869b; } +.cm-s-gruvbox-dark span.cm-keyword { color: #f84934; } + +.cm-s-gruvbox-dark span.cm-variable { color: #ebdbb2; } +.cm-s-gruvbox-dark span.cm-variable-2 { color: #ebdbb2; } +.cm-s-gruvbox-dark span.cm-variable-3, .cm-s-gruvbox-dark span.cm-type { color: #fabd2f; } +.cm-s-gruvbox-dark span.cm-operator { color: #ebdbb2; } +.cm-s-gruvbox-dark span.cm-callee { color: #ebdbb2; } +.cm-s-gruvbox-dark span.cm-def { color: #ebdbb2; } +.cm-s-gruvbox-dark span.cm-property { color: #ebdbb2; } +.cm-s-gruvbox-dark span.cm-string { color: #b8bb26; } +.cm-s-gruvbox-dark span.cm-string-2 { color: #8ec07c; } +.cm-s-gruvbox-dark span.cm-qualifier { color: #8ec07c; } +.cm-s-gruvbox-dark span.cm-attribute { color: #8ec07c; } + +.cm-s-gruvbox-dark .CodeMirror-activeline-background { background: #3c3836; } +.cm-s-gruvbox-dark .CodeMirror-matchingbracket { background: #928374; color:#282828 !important; } + +.cm-s-gruvbox-dark span.cm-builtin { color: #fe8019; } +.cm-s-gruvbox-dark span.cm-tag { color: #fe8019; } diff --git a/adminx/assets/vendor/codemirror/theme/hopscotch.css b/adminx/assets/vendor/codemirror/theme/hopscotch.css new file mode 100644 index 0000000..7d05431 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/hopscotch.css @@ -0,0 +1,34 @@ +/* + + Name: Hopscotch + Author: Jan T. Sott + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-hopscotch.CodeMirror {background: #322931; color: #d5d3d5;} +.cm-s-hopscotch div.CodeMirror-selected {background: #433b42 !important;} +.cm-s-hopscotch .CodeMirror-gutters {background: #322931; border-right: 0px;} +.cm-s-hopscotch .CodeMirror-linenumber {color: #797379;} +.cm-s-hopscotch .CodeMirror-cursor {border-left: 1px solid #989498 !important;} + +.cm-s-hopscotch span.cm-comment {color: #b33508;} +.cm-s-hopscotch span.cm-atom {color: #c85e7c;} +.cm-s-hopscotch span.cm-number {color: #c85e7c;} + +.cm-s-hopscotch span.cm-property, .cm-s-hopscotch span.cm-attribute {color: #8fc13e;} +.cm-s-hopscotch span.cm-keyword {color: #dd464c;} +.cm-s-hopscotch span.cm-string {color: #fdcc59;} + +.cm-s-hopscotch span.cm-variable {color: #8fc13e;} +.cm-s-hopscotch span.cm-variable-2 {color: #1290bf;} +.cm-s-hopscotch span.cm-def {color: #fd8b19;} +.cm-s-hopscotch span.cm-error {background: #dd464c; color: #989498;} +.cm-s-hopscotch span.cm-bracket {color: #d5d3d5;} +.cm-s-hopscotch span.cm-tag {color: #dd464c;} +.cm-s-hopscotch span.cm-link {color: #c85e7c;} + +.cm-s-hopscotch .CodeMirror-matchingbracket { text-decoration: underline; color: white !important;} +.cm-s-hopscotch .CodeMirror-activeline-background { background: #302020; } diff --git a/adminx/assets/vendor/codemirror/theme/icecoder.css b/adminx/assets/vendor/codemirror/theme/icecoder.css new file mode 100644 index 0000000..5440fbe --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/icecoder.css @@ -0,0 +1,43 @@ +/* +ICEcoder default theme by Matt Pass, used in code editor available at https://icecoder.net +*/ + +.cm-s-icecoder { color: #666; background: #1d1d1b; } + +.cm-s-icecoder span.cm-keyword { color: #eee; font-weight:bold; } /* off-white 1 */ +.cm-s-icecoder span.cm-atom { color: #e1c76e; } /* yellow */ +.cm-s-icecoder span.cm-number { color: #6cb5d9; } /* blue */ +.cm-s-icecoder span.cm-def { color: #b9ca4a; } /* green */ + +.cm-s-icecoder span.cm-variable { color: #6cb5d9; } /* blue */ +.cm-s-icecoder span.cm-variable-2 { color: #cc1e5c; } /* pink */ +.cm-s-icecoder span.cm-variable-3, .cm-s-icecoder span.cm-type { color: #f9602c; } /* orange */ + +.cm-s-icecoder span.cm-property { color: #eee; } /* off-white 1 */ +.cm-s-icecoder span.cm-operator { color: #9179bb; } /* purple */ +.cm-s-icecoder span.cm-comment { color: #97a3aa; } /* grey-blue */ + +.cm-s-icecoder span.cm-string { color: #b9ca4a; } /* green */ +.cm-s-icecoder span.cm-string-2 { color: #6cb5d9; } /* blue */ + +.cm-s-icecoder span.cm-meta { color: #555; } /* grey */ + +.cm-s-icecoder span.cm-qualifier { color: #555; } /* grey */ +.cm-s-icecoder span.cm-builtin { color: #214e7b; } /* bright blue */ +.cm-s-icecoder span.cm-bracket { color: #cc7; } /* grey-yellow */ + +.cm-s-icecoder span.cm-tag { color: #e8e8e8; } /* off-white 2 */ +.cm-s-icecoder span.cm-attribute { color: #099; } /* teal */ + +.cm-s-icecoder span.cm-header { color: #6a0d6a; } /* purple-pink */ +.cm-s-icecoder span.cm-quote { color: #186718; } /* dark green */ +.cm-s-icecoder span.cm-hr { color: #888; } /* mid-grey */ +.cm-s-icecoder span.cm-link { color: #e1c76e; } /* yellow */ +.cm-s-icecoder span.cm-error { color: #d00; } /* red */ + +.cm-s-icecoder .CodeMirror-cursor { border-left: 1px solid white; } +.cm-s-icecoder div.CodeMirror-selected { color: #fff; background: #037; } +.cm-s-icecoder .CodeMirror-gutters { background: #1d1d1b; min-width: 41px; border-right: 0; } +.cm-s-icecoder .CodeMirror-linenumber { color: #555; cursor: default; } +.cm-s-icecoder .CodeMirror-matchingbracket { color: #fff !important; background: #555 !important; } +.cm-s-icecoder .CodeMirror-activeline-background { background: #000; } diff --git a/adminx/assets/vendor/codemirror/theme/idea.css b/adminx/assets/vendor/codemirror/theme/idea.css new file mode 100644 index 0000000..eab3671 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/idea.css @@ -0,0 +1,42 @@ +/** + Name: IDEA default theme + From IntelliJ IDEA by JetBrains + */ + +.cm-s-idea span.cm-meta { color: #808000; } +.cm-s-idea span.cm-number { color: #0000FF; } +.cm-s-idea span.cm-keyword { line-height: 1em; font-weight: bold; color: #000080; } +.cm-s-idea span.cm-atom { font-weight: bold; color: #000080; } +.cm-s-idea span.cm-def { color: #000000; } +.cm-s-idea span.cm-variable { color: black; } +.cm-s-idea span.cm-variable-2 { color: black; } +.cm-s-idea span.cm-variable-3, .cm-s-idea span.cm-type { color: black; } +.cm-s-idea span.cm-property { color: black; } +.cm-s-idea span.cm-operator { color: black; } +.cm-s-idea span.cm-comment { color: #808080; } +.cm-s-idea span.cm-string { color: #008000; } +.cm-s-idea span.cm-string-2 { color: #008000; } +.cm-s-idea span.cm-qualifier { color: #555; } +.cm-s-idea span.cm-error { color: #FF0000; } +.cm-s-idea span.cm-attribute { color: #0000FF; } +.cm-s-idea span.cm-tag { color: #000080; } +.cm-s-idea span.cm-link { color: #0000FF; } +.cm-s-idea .CodeMirror-activeline-background { background: #FFFAE3; } + +.cm-s-idea span.cm-builtin { color: #30a; } +.cm-s-idea span.cm-bracket { color: #cc7; } +.cm-s-idea { font-family: Consolas, Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace, serif;} + + +.cm-s-idea .CodeMirror-matchingbracket { outline:1px solid grey; color:black !important; } + +.CodeMirror-hints.idea { + font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; + color: #616569; + background-color: #ebf3fd !important; +} + +.CodeMirror-hints.idea .CodeMirror-hint-active { + background-color: #a2b8c9 !important; + color: #5c6065 !important; +} \ No newline at end of file diff --git a/adminx/assets/vendor/codemirror/theme/isotope.css b/adminx/assets/vendor/codemirror/theme/isotope.css new file mode 100644 index 0000000..d0d6263 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/isotope.css @@ -0,0 +1,34 @@ +/* + + Name: Isotope + Author: David Desandro / Jan T. Sott + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-isotope.CodeMirror {background: #000000; color: #e0e0e0;} +.cm-s-isotope div.CodeMirror-selected {background: #404040 !important;} +.cm-s-isotope .CodeMirror-gutters {background: #000000; border-right: 0px;} +.cm-s-isotope .CodeMirror-linenumber {color: #808080;} +.cm-s-isotope .CodeMirror-cursor {border-left: 1px solid #c0c0c0 !important;} + +.cm-s-isotope span.cm-comment {color: #3300ff;} +.cm-s-isotope span.cm-atom {color: #cc00ff;} +.cm-s-isotope span.cm-number {color: #cc00ff;} + +.cm-s-isotope span.cm-property, .cm-s-isotope span.cm-attribute {color: #33ff00;} +.cm-s-isotope span.cm-keyword {color: #ff0000;} +.cm-s-isotope span.cm-string {color: #ff0099;} + +.cm-s-isotope span.cm-variable {color: #33ff00;} +.cm-s-isotope span.cm-variable-2 {color: #0066ff;} +.cm-s-isotope span.cm-def {color: #ff9900;} +.cm-s-isotope span.cm-error {background: #ff0000; color: #c0c0c0;} +.cm-s-isotope span.cm-bracket {color: #e0e0e0;} +.cm-s-isotope span.cm-tag {color: #ff0000;} +.cm-s-isotope span.cm-link {color: #cc00ff;} + +.cm-s-isotope .CodeMirror-matchingbracket { text-decoration: underline; color: white !important;} +.cm-s-isotope .CodeMirror-activeline-background { background: #202020; } diff --git a/adminx/assets/vendor/codemirror/theme/lesser-dark.css b/adminx/assets/vendor/codemirror/theme/lesser-dark.css new file mode 100644 index 0000000..f96bf43 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/lesser-dark.css @@ -0,0 +1,47 @@ +/* +http://lesscss.org/ dark theme +Ported to CodeMirror by Peter Kroon +*/ +.cm-s-lesser-dark { + line-height: 1.3em; +} +.cm-s-lesser-dark.CodeMirror { background: #262626; color: #EBEFE7; text-shadow: 0 -1px 1px #262626; } +.cm-s-lesser-dark div.CodeMirror-selected { background: #45443B; } /* 33322B*/ +.cm-s-lesser-dark .CodeMirror-line::selection, .cm-s-lesser-dark .CodeMirror-line > span::selection, .cm-s-lesser-dark .CodeMirror-line > span > span::selection { background: rgba(69, 68, 59, .99); } +.cm-s-lesser-dark .CodeMirror-line::-moz-selection, .cm-s-lesser-dark .CodeMirror-line > span::-moz-selection, .cm-s-lesser-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(69, 68, 59, .99); } +.cm-s-lesser-dark .CodeMirror-cursor { border-left: 1px solid white; } +.cm-s-lesser-dark pre { padding: 0 8px; }/*editable code holder*/ + +.cm-s-lesser-dark.CodeMirror span.CodeMirror-matchingbracket { color: #7EFC7E; }/*65FC65*/ + +.cm-s-lesser-dark .CodeMirror-gutters { background: #262626; border-right:1px solid #aaa; } +.cm-s-lesser-dark .CodeMirror-guttermarker { color: #599eff; } +.cm-s-lesser-dark .CodeMirror-guttermarker-subtle { color: #777; } +.cm-s-lesser-dark .CodeMirror-linenumber { color: #777; } + +.cm-s-lesser-dark span.cm-header { color: #a0a; } +.cm-s-lesser-dark span.cm-quote { color: #090; } +.cm-s-lesser-dark span.cm-keyword { color: #599eff; } +.cm-s-lesser-dark span.cm-atom { color: #C2B470; } +.cm-s-lesser-dark span.cm-number { color: #B35E4D; } +.cm-s-lesser-dark span.cm-def { color: white; } +.cm-s-lesser-dark span.cm-variable { color:#D9BF8C; } +.cm-s-lesser-dark span.cm-variable-2 { color: #669199; } +.cm-s-lesser-dark span.cm-variable-3, .cm-s-lesser-dark span.cm-type { color: white; } +.cm-s-lesser-dark span.cm-property { color: #92A75C; } +.cm-s-lesser-dark span.cm-operator { color: #92A75C; } +.cm-s-lesser-dark span.cm-comment { color: #666; } +.cm-s-lesser-dark span.cm-string { color: #BCD279; } +.cm-s-lesser-dark span.cm-string-2 { color: #f50; } +.cm-s-lesser-dark span.cm-meta { color: #738C73; } +.cm-s-lesser-dark span.cm-qualifier { color: #555; } +.cm-s-lesser-dark span.cm-builtin { color: #ff9e59; } +.cm-s-lesser-dark span.cm-bracket { color: #EBEFE7; } +.cm-s-lesser-dark span.cm-tag { color: #669199; } +.cm-s-lesser-dark span.cm-attribute { color: #81a4d5; } +.cm-s-lesser-dark span.cm-hr { color: #999; } +.cm-s-lesser-dark span.cm-link { color: #7070E6; } +.cm-s-lesser-dark span.cm-error { color: #9d1e15; } + +.cm-s-lesser-dark .CodeMirror-activeline-background { background: #3C3A3A; } +.cm-s-lesser-dark .CodeMirror-matchingbracket { outline:1px solid grey; color:white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/liquibyte.css b/adminx/assets/vendor/codemirror/theme/liquibyte.css new file mode 100644 index 0000000..393825e --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/liquibyte.css @@ -0,0 +1,95 @@ +.cm-s-liquibyte.CodeMirror { + background-color: #000; + color: #fff; + line-height: 1.2em; + font-size: 1em; +} +.cm-s-liquibyte .CodeMirror-focused .cm-matchhighlight { + text-decoration: underline; + text-decoration-color: #0f0; + text-decoration-style: wavy; +} +.cm-s-liquibyte .cm-trailingspace { + text-decoration: line-through; + text-decoration-color: #f00; + text-decoration-style: dotted; +} +.cm-s-liquibyte .cm-tab { + text-decoration: line-through; + text-decoration-color: #404040; + text-decoration-style: dotted; +} +.cm-s-liquibyte .CodeMirror-gutters { background-color: #262626; border-right: 1px solid #505050; padding-right: 0.8em; } +.cm-s-liquibyte .CodeMirror-gutter-elt div { font-size: 1.2em; } +.cm-s-liquibyte .CodeMirror-guttermarker { } +.cm-s-liquibyte .CodeMirror-guttermarker-subtle { } +.cm-s-liquibyte .CodeMirror-linenumber { color: #606060; padding-left: 0; } +.cm-s-liquibyte .CodeMirror-cursor { border-left: 1px solid #eee; } + +.cm-s-liquibyte span.cm-comment { color: #008000; } +.cm-s-liquibyte span.cm-def { color: #ffaf40; font-weight: bold; } +.cm-s-liquibyte span.cm-keyword { color: #c080ff; font-weight: bold; } +.cm-s-liquibyte span.cm-builtin { color: #ffaf40; font-weight: bold; } +.cm-s-liquibyte span.cm-variable { color: #5967ff; font-weight: bold; } +.cm-s-liquibyte span.cm-string { color: #ff8000; } +.cm-s-liquibyte span.cm-number { color: #0f0; font-weight: bold; } +.cm-s-liquibyte span.cm-atom { color: #bf3030; font-weight: bold; } + +.cm-s-liquibyte span.cm-variable-2 { color: #007f7f; font-weight: bold; } +.cm-s-liquibyte span.cm-variable-3, .cm-s-liquibyte span.cm-type { color: #c080ff; font-weight: bold; } +.cm-s-liquibyte span.cm-property { color: #999; font-weight: bold; } +.cm-s-liquibyte span.cm-operator { color: #fff; } + +.cm-s-liquibyte span.cm-meta { color: #0f0; } +.cm-s-liquibyte span.cm-qualifier { color: #fff700; font-weight: bold; } +.cm-s-liquibyte span.cm-bracket { color: #cc7; } +.cm-s-liquibyte span.cm-tag { color: #ff0; font-weight: bold; } +.cm-s-liquibyte span.cm-attribute { color: #c080ff; font-weight: bold; } +.cm-s-liquibyte span.cm-error { color: #f00; } + +.cm-s-liquibyte div.CodeMirror-selected { background-color: rgba(255, 0, 0, 0.25); } + +.cm-s-liquibyte span.cm-compilation { background-color: rgba(255, 255, 255, 0.12); } + +.cm-s-liquibyte .CodeMirror-activeline-background { background-color: rgba(0, 255, 0, 0.15); } + +/* Default styles for common addons */ +.cm-s-liquibyte .CodeMirror span.CodeMirror-matchingbracket { color: #0f0; font-weight: bold; } +.cm-s-liquibyte .CodeMirror span.CodeMirror-nonmatchingbracket { color: #f00; font-weight: bold; } +.CodeMirror-matchingtag { background-color: rgba(150, 255, 0, .3); } +/* Scrollbars */ +/* Simple */ +.cm-s-liquibyte div.CodeMirror-simplescroll-horizontal div:hover, .cm-s-liquibyte div.CodeMirror-simplescroll-vertical div:hover { + background-color: rgba(80, 80, 80, .7); +} +.cm-s-liquibyte div.CodeMirror-simplescroll-horizontal div, .cm-s-liquibyte div.CodeMirror-simplescroll-vertical div { + background-color: rgba(80, 80, 80, .3); + border: 1px solid #404040; + border-radius: 5px; +} +.cm-s-liquibyte div.CodeMirror-simplescroll-vertical div { + border-top: 1px solid #404040; + border-bottom: 1px solid #404040; +} +.cm-s-liquibyte div.CodeMirror-simplescroll-horizontal div { + border-left: 1px solid #404040; + border-right: 1px solid #404040; +} +.cm-s-liquibyte div.CodeMirror-simplescroll-vertical { + background-color: #262626; +} +.cm-s-liquibyte div.CodeMirror-simplescroll-horizontal { + background-color: #262626; + border-top: 1px solid #404040; +} +/* Overlay */ +.cm-s-liquibyte div.CodeMirror-overlayscroll-horizontal div, div.CodeMirror-overlayscroll-vertical div { + background-color: #404040; + border-radius: 5px; +} +.cm-s-liquibyte div.CodeMirror-overlayscroll-vertical div { + border: 1px solid #404040; +} +.cm-s-liquibyte div.CodeMirror-overlayscroll-horizontal div { + border: 1px solid #404040; +} diff --git a/adminx/assets/vendor/codemirror/theme/lucario.css b/adminx/assets/vendor/codemirror/theme/lucario.css new file mode 100644 index 0000000..17a1551 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/lucario.css @@ -0,0 +1,37 @@ +/* + Name: lucario + Author: Raphael Amorim + + Original Lucario color scheme (https://github.com/raphamorim/lucario) +*/ + +.cm-s-lucario.CodeMirror, .cm-s-lucario .CodeMirror-gutters { + background-color: #2b3e50 !important; + color: #f8f8f2 !important; + border: none; +} +.cm-s-lucario .CodeMirror-gutters { color: #2b3e50; } +.cm-s-lucario .CodeMirror-cursor { border-left: solid thin #E6C845; } +.cm-s-lucario .CodeMirror-linenumber { color: #f8f8f2; } +.cm-s-lucario .CodeMirror-selected { background: #243443; } +.cm-s-lucario .CodeMirror-line::selection, .cm-s-lucario .CodeMirror-line > span::selection, .cm-s-lucario .CodeMirror-line > span > span::selection { background: #243443; } +.cm-s-lucario .CodeMirror-line::-moz-selection, .cm-s-lucario .CodeMirror-line > span::-moz-selection, .cm-s-lucario .CodeMirror-line > span > span::-moz-selection { background: #243443; } +.cm-s-lucario span.cm-comment { color: #5c98cd; } +.cm-s-lucario span.cm-string, .cm-s-lucario span.cm-string-2 { color: #E6DB74; } +.cm-s-lucario span.cm-number { color: #ca94ff; } +.cm-s-lucario span.cm-variable { color: #f8f8f2; } +.cm-s-lucario span.cm-variable-2 { color: #f8f8f2; } +.cm-s-lucario span.cm-def { color: #72C05D; } +.cm-s-lucario span.cm-operator { color: #66D9EF; } +.cm-s-lucario span.cm-keyword { color: #ff6541; } +.cm-s-lucario span.cm-atom { color: #bd93f9; } +.cm-s-lucario span.cm-meta { color: #f8f8f2; } +.cm-s-lucario span.cm-tag { color: #ff6541; } +.cm-s-lucario span.cm-attribute { color: #66D9EF; } +.cm-s-lucario span.cm-qualifier { color: #72C05D; } +.cm-s-lucario span.cm-property { color: #f8f8f2; } +.cm-s-lucario span.cm-builtin { color: #72C05D; } +.cm-s-lucario span.cm-variable-3, .cm-s-lucario span.cm-type { color: #ffb86c; } + +.cm-s-lucario .CodeMirror-activeline-background { background: #243443; } +.cm-s-lucario .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/material-darker.css b/adminx/assets/vendor/codemirror/theme/material-darker.css new file mode 100644 index 0000000..45b64ef --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/material-darker.css @@ -0,0 +1,135 @@ +/* + Name: material + Author: Mattia Astorino (http://github.com/equinusocio) + Website: https://material-theme.site/ +*/ + +.cm-s-material-darker.CodeMirror { + background-color: #212121; + color: #EEFFFF; +} + +.cm-s-material-darker .CodeMirror-gutters { + background: #212121; + color: #545454; + border: none; +} + +.cm-s-material-darker .CodeMirror-guttermarker, +.cm-s-material-darker .CodeMirror-guttermarker-subtle, +.cm-s-material-darker .CodeMirror-linenumber { + color: #545454; +} + +.cm-s-material-darker .CodeMirror-cursor { + border-left: 1px solid #FFCC00; +} + +.cm-s-material-darker div.CodeMirror-selected { + background: rgba(97, 97, 97, 0.2); +} + +.cm-s-material-darker.CodeMirror-focused div.CodeMirror-selected { + background: rgba(97, 97, 97, 0.2); +} + +.cm-s-material-darker .CodeMirror-line::selection, +.cm-s-material-darker .CodeMirror-line>span::selection, +.cm-s-material-darker .CodeMirror-line>span>span::selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material-darker .CodeMirror-line::-moz-selection, +.cm-s-material-darker .CodeMirror-line>span::-moz-selection, +.cm-s-material-darker .CodeMirror-line>span>span::-moz-selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material-darker .CodeMirror-activeline-background { + background: rgba(0, 0, 0, 0.5); +} + +.cm-s-material-darker .cm-keyword { + color: #C792EA; +} + +.cm-s-material-darker .cm-operator { + color: #89DDFF; +} + +.cm-s-material-darker .cm-variable-2 { + color: #EEFFFF; +} + +.cm-s-material-darker .cm-variable-3, +.cm-s-material-darker .cm-type { + color: #f07178; +} + +.cm-s-material-darker .cm-builtin { + color: #FFCB6B; +} + +.cm-s-material-darker .cm-atom { + color: #F78C6C; +} + +.cm-s-material-darker .cm-number { + color: #FF5370; +} + +.cm-s-material-darker .cm-def { + color: #82AAFF; +} + +.cm-s-material-darker .cm-string { + color: #C3E88D; +} + +.cm-s-material-darker .cm-string-2 { + color: #f07178; +} + +.cm-s-material-darker .cm-comment { + color: #545454; +} + +.cm-s-material-darker .cm-variable { + color: #f07178; +} + +.cm-s-material-darker .cm-tag { + color: #FF5370; +} + +.cm-s-material-darker .cm-meta { + color: #FFCB6B; +} + +.cm-s-material-darker .cm-attribute { + color: #C792EA; +} + +.cm-s-material-darker .cm-property { + color: #C792EA; +} + +.cm-s-material-darker .cm-qualifier { + color: #DECB6B; +} + +.cm-s-material-darker .cm-variable-3, +.cm-s-material-darker .cm-type { + color: #DECB6B; +} + + +.cm-s-material-darker .cm-error { + color: rgba(255, 255, 255, 1.0); + background-color: #FF5370; +} + +.cm-s-material-darker .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} \ No newline at end of file diff --git a/adminx/assets/vendor/codemirror/theme/material-ocean.css b/adminx/assets/vendor/codemirror/theme/material-ocean.css new file mode 100644 index 0000000..86a6f3c --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/material-ocean.css @@ -0,0 +1,135 @@ +/* + Name: material + Author: Mattia Astorino (http://github.com/equinusocio) + Website: https://material-theme.site/ +*/ + +.cm-s-material-ocean.CodeMirror { + background-color: #0F111A; + color: #8F93A2; +} + +.cm-s-material-ocean .CodeMirror-gutters { + background: #0F111A; + color: #464B5D; + border: none; +} + +.cm-s-material-ocean .CodeMirror-guttermarker, +.cm-s-material-ocean .CodeMirror-guttermarker-subtle, +.cm-s-material-ocean .CodeMirror-linenumber { + color: #464B5D; +} + +.cm-s-material-ocean .CodeMirror-cursor { + border-left: 1px solid #FFCC00; +} + +.cm-s-material-ocean div.CodeMirror-selected { + background: rgba(113, 124, 180, 0.2); +} + +.cm-s-material-ocean.CodeMirror-focused div.CodeMirror-selected { + background: rgba(113, 124, 180, 0.2); +} + +.cm-s-material-ocean .CodeMirror-line::selection, +.cm-s-material-ocean .CodeMirror-line>span::selection, +.cm-s-material-ocean .CodeMirror-line>span>span::selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material-ocean .CodeMirror-line::-moz-selection, +.cm-s-material-ocean .CodeMirror-line>span::-moz-selection, +.cm-s-material-ocean .CodeMirror-line>span>span::-moz-selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material-ocean .CodeMirror-activeline-background { + background: rgba(0, 0, 0, 0.5); +} + +.cm-s-material-ocean .cm-keyword { + color: #C792EA; +} + +.cm-s-material-ocean .cm-operator { + color: #89DDFF; +} + +.cm-s-material-ocean .cm-variable-2 { + color: #EEFFFF; +} + +.cm-s-material-ocean .cm-variable-3, +.cm-s-material-ocean .cm-type { + color: #f07178; +} + +.cm-s-material-ocean .cm-builtin { + color: #FFCB6B; +} + +.cm-s-material-ocean .cm-atom { + color: #F78C6C; +} + +.cm-s-material-ocean .cm-number { + color: #FF5370; +} + +.cm-s-material-ocean .cm-def { + color: #82AAFF; +} + +.cm-s-material-ocean .cm-string { + color: #C3E88D; +} + +.cm-s-material-ocean .cm-string-2 { + color: #f07178; +} + +.cm-s-material-ocean .cm-comment { + color: #464B5D; +} + +.cm-s-material-ocean .cm-variable { + color: #f07178; +} + +.cm-s-material-ocean .cm-tag { + color: #FF5370; +} + +.cm-s-material-ocean .cm-meta { + color: #FFCB6B; +} + +.cm-s-material-ocean .cm-attribute { + color: #C792EA; +} + +.cm-s-material-ocean .cm-property { + color: #C792EA; +} + +.cm-s-material-ocean .cm-qualifier { + color: #DECB6B; +} + +.cm-s-material-ocean .cm-variable-3, +.cm-s-material-ocean .cm-type { + color: #DECB6B; +} + + +.cm-s-material-ocean .cm-error { + color: rgba(255, 255, 255, 1.0); + background-color: #FF5370; +} + +.cm-s-material-ocean .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} \ No newline at end of file diff --git a/adminx/assets/vendor/codemirror/theme/material-palenight.css b/adminx/assets/vendor/codemirror/theme/material-palenight.css new file mode 100644 index 0000000..66d53dd --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/material-palenight.css @@ -0,0 +1,135 @@ +/* + Name: material + Author: Mattia Astorino (http://github.com/equinusocio) + Website: https://material-theme.site/ +*/ + +.cm-s-material-palenight.CodeMirror { + background-color: #292D3E; + color: #A6ACCD; +} + +.cm-s-material-palenight .CodeMirror-gutters { + background: #292D3E; + color: #676E95; + border: none; +} + +.cm-s-material-palenight .CodeMirror-guttermarker, +.cm-s-material-palenight .CodeMirror-guttermarker-subtle, +.cm-s-material-palenight .CodeMirror-linenumber { + color: #676E95; +} + +.cm-s-material-palenight .CodeMirror-cursor { + border-left: 1px solid #FFCC00; +} + +.cm-s-material-palenight div.CodeMirror-selected { + background: rgba(113, 124, 180, 0.2); +} + +.cm-s-material-palenight.CodeMirror-focused div.CodeMirror-selected { + background: rgba(113, 124, 180, 0.2); +} + +.cm-s-material-palenight .CodeMirror-line::selection, +.cm-s-material-palenight .CodeMirror-line>span::selection, +.cm-s-material-palenight .CodeMirror-line>span>span::selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material-palenight .CodeMirror-line::-moz-selection, +.cm-s-material-palenight .CodeMirror-line>span::-moz-selection, +.cm-s-material-palenight .CodeMirror-line>span>span::-moz-selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material-palenight .CodeMirror-activeline-background { + background: rgba(0, 0, 0, 0.5); +} + +.cm-s-material-palenight .cm-keyword { + color: #C792EA; +} + +.cm-s-material-palenight .cm-operator { + color: #89DDFF; +} + +.cm-s-material-palenight .cm-variable-2 { + color: #EEFFFF; +} + +.cm-s-material-palenight .cm-variable-3, +.cm-s-material-palenight .cm-type { + color: #f07178; +} + +.cm-s-material-palenight .cm-builtin { + color: #FFCB6B; +} + +.cm-s-material-palenight .cm-atom { + color: #F78C6C; +} + +.cm-s-material-palenight .cm-number { + color: #FF5370; +} + +.cm-s-material-palenight .cm-def { + color: #82AAFF; +} + +.cm-s-material-palenight .cm-string { + color: #C3E88D; +} + +.cm-s-material-palenight .cm-string-2 { + color: #f07178; +} + +.cm-s-material-palenight .cm-comment { + color: #676E95; +} + +.cm-s-material-palenight .cm-variable { + color: #f07178; +} + +.cm-s-material-palenight .cm-tag { + color: #FF5370; +} + +.cm-s-material-palenight .cm-meta { + color: #FFCB6B; +} + +.cm-s-material-palenight .cm-attribute { + color: #C792EA; +} + +.cm-s-material-palenight .cm-property { + color: #C792EA; +} + +.cm-s-material-palenight .cm-qualifier { + color: #DECB6B; +} + +.cm-s-material-palenight .cm-variable-3, +.cm-s-material-palenight .cm-type { + color: #DECB6B; +} + + +.cm-s-material-palenight .cm-error { + color: rgba(255, 255, 255, 1.0); + background-color: #FF5370; +} + +.cm-s-material-palenight .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} \ No newline at end of file diff --git a/adminx/assets/vendor/codemirror/theme/material.css b/adminx/assets/vendor/codemirror/theme/material.css new file mode 100644 index 0000000..9ac17a3 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/material.css @@ -0,0 +1,135 @@ +/* + Name: material + Author: Mattia Astorino (http://github.com/equinusocio) + Website: https://material-theme.site/ +*/ + +.cm-s-material.CodeMirror { + background-color: #263238; + color: #EEFFFF; +} + +.cm-s-material .CodeMirror-gutters { + background: #263238; + color: #546E7A; + border: none; +} + +.cm-s-material .CodeMirror-guttermarker, +.cm-s-material .CodeMirror-guttermarker-subtle, +.cm-s-material .CodeMirror-linenumber { + color: #546E7A; +} + +.cm-s-material .CodeMirror-cursor { + border-left: 1px solid #FFCC00; +} + +.cm-s-material div.CodeMirror-selected { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material.CodeMirror-focused div.CodeMirror-selected { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material .CodeMirror-line::selection, +.cm-s-material .CodeMirror-line>span::selection, +.cm-s-material .CodeMirror-line>span>span::selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material .CodeMirror-line::-moz-selection, +.cm-s-material .CodeMirror-line>span::-moz-selection, +.cm-s-material .CodeMirror-line>span>span::-moz-selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material .CodeMirror-activeline-background { + background: rgba(0, 0, 0, 0.5); +} + +.cm-s-material .cm-keyword { + color: #C792EA; +} + +.cm-s-material .cm-operator { + color: #89DDFF; +} + +.cm-s-material .cm-variable-2 { + color: #EEFFFF; +} + +.cm-s-material .cm-variable-3, +.cm-s-material .cm-type { + color: #f07178; +} + +.cm-s-material .cm-builtin { + color: #FFCB6B; +} + +.cm-s-material .cm-atom { + color: #F78C6C; +} + +.cm-s-material .cm-number { + color: #FF5370; +} + +.cm-s-material .cm-def { + color: #82AAFF; +} + +.cm-s-material .cm-string { + color: #C3E88D; +} + +.cm-s-material .cm-string-2 { + color: #f07178; +} + +.cm-s-material .cm-comment { + color: #546E7A; +} + +.cm-s-material .cm-variable { + color: #f07178; +} + +.cm-s-material .cm-tag { + color: #FF5370; +} + +.cm-s-material .cm-meta { + color: #FFCB6B; +} + +.cm-s-material .cm-attribute { + color: #C792EA; +} + +.cm-s-material .cm-property { + color: #C792EA; +} + +.cm-s-material .cm-qualifier { + color: #DECB6B; +} + +.cm-s-material .cm-variable-3, +.cm-s-material .cm-type { + color: #DECB6B; +} + + +.cm-s-material .cm-error { + color: rgba(255, 255, 255, 1.0); + background-color: #FF5370; +} + +.cm-s-material .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} \ No newline at end of file diff --git a/adminx/assets/vendor/codemirror/theme/mbo.css b/adminx/assets/vendor/codemirror/theme/mbo.css new file mode 100644 index 0000000..e164fcf --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/mbo.css @@ -0,0 +1,37 @@ +/****************************************************************/ +/* Based on mbonaci's Brackets mbo theme */ +/* https://github.com/mbonaci/global/blob/master/Mbo.tmTheme */ +/* Create your own: http://tmtheme-editor.herokuapp.com */ +/****************************************************************/ + +.cm-s-mbo.CodeMirror { background: #2c2c2c; color: #ffffec; } +.cm-s-mbo div.CodeMirror-selected { background: #716C62; } +.cm-s-mbo .CodeMirror-line::selection, .cm-s-mbo .CodeMirror-line > span::selection, .cm-s-mbo .CodeMirror-line > span > span::selection { background: rgba(113, 108, 98, .99); } +.cm-s-mbo .CodeMirror-line::-moz-selection, .cm-s-mbo .CodeMirror-line > span::-moz-selection, .cm-s-mbo .CodeMirror-line > span > span::-moz-selection { background: rgba(113, 108, 98, .99); } +.cm-s-mbo .CodeMirror-gutters { background: #4e4e4e; border-right: 0px; } +.cm-s-mbo .CodeMirror-guttermarker { color: white; } +.cm-s-mbo .CodeMirror-guttermarker-subtle { color: grey; } +.cm-s-mbo .CodeMirror-linenumber { color: #dadada; } +.cm-s-mbo .CodeMirror-cursor { border-left: 1px solid #ffffec; } + +.cm-s-mbo span.cm-comment { color: #95958a; } +.cm-s-mbo span.cm-atom { color: #00a8c6; } +.cm-s-mbo span.cm-number { color: #00a8c6; } + +.cm-s-mbo span.cm-property, .cm-s-mbo span.cm-attribute { color: #9ddfe9; } +.cm-s-mbo span.cm-keyword { color: #ffb928; } +.cm-s-mbo span.cm-string { color: #ffcf6c; } +.cm-s-mbo span.cm-string.cm-property { color: #ffffec; } + +.cm-s-mbo span.cm-variable { color: #ffffec; } +.cm-s-mbo span.cm-variable-2 { color: #00a8c6; } +.cm-s-mbo span.cm-def { color: #ffffec; } +.cm-s-mbo span.cm-bracket { color: #fffffc; font-weight: bold; } +.cm-s-mbo span.cm-tag { color: #9ddfe9; } +.cm-s-mbo span.cm-link { color: #f54b07; } +.cm-s-mbo span.cm-error { border-bottom: #636363; color: #ffffec; } +.cm-s-mbo span.cm-qualifier { color: #ffffec; } + +.cm-s-mbo .CodeMirror-activeline-background { background: #494b41; } +.cm-s-mbo .CodeMirror-matchingbracket { color: #ffb928 !important; } +.cm-s-mbo .CodeMirror-matchingtag { background: rgba(255, 255, 255, .37); } diff --git a/adminx/assets/vendor/codemirror/theme/mdn-like.css b/adminx/assets/vendor/codemirror/theme/mdn-like.css new file mode 100644 index 0000000..622ed3e --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/mdn-like.css @@ -0,0 +1,46 @@ +/* + MDN-LIKE Theme - Mozilla + Ported to CodeMirror by Peter Kroon + Report bugs/issues here: https://github.com/codemirror/CodeMirror/issues + GitHub: @peterkroon + + The mdn-like theme is inspired on the displayed code examples at: https://developer.mozilla.org/en-US/docs/Web/CSS/animation + +*/ +.cm-s-mdn-like.CodeMirror { color: #999; background-color: #fff; } +.cm-s-mdn-like div.CodeMirror-selected { background: #cfc; } +.cm-s-mdn-like .CodeMirror-line::selection, .cm-s-mdn-like .CodeMirror-line > span::selection, .cm-s-mdn-like .CodeMirror-line > span > span::selection { background: #cfc; } +.cm-s-mdn-like .CodeMirror-line::-moz-selection, .cm-s-mdn-like .CodeMirror-line > span::-moz-selection, .cm-s-mdn-like .CodeMirror-line > span > span::-moz-selection { background: #cfc; } + +.cm-s-mdn-like .CodeMirror-gutters { background: #f8f8f8; border-left: 6px solid rgba(0,83,159,0.65); color: #333; } +.cm-s-mdn-like .CodeMirror-linenumber { color: #aaa; padding-left: 8px; } +.cm-s-mdn-like .CodeMirror-cursor { border-left: 2px solid #222; } + +.cm-s-mdn-like .cm-keyword { color: #6262FF; } +.cm-s-mdn-like .cm-atom { color: #F90; } +.cm-s-mdn-like .cm-number { color: #ca7841; } +.cm-s-mdn-like .cm-def { color: #8DA6CE; } +.cm-s-mdn-like span.cm-variable-2, .cm-s-mdn-like span.cm-tag { color: #690; } +.cm-s-mdn-like span.cm-variable-3, .cm-s-mdn-like span.cm-def, .cm-s-mdn-like span.cm-type { color: #07a; } + +.cm-s-mdn-like .cm-variable { color: #07a; } +.cm-s-mdn-like .cm-property { color: #905; } +.cm-s-mdn-like .cm-qualifier { color: #690; } + +.cm-s-mdn-like .cm-operator { color: #cda869; } +.cm-s-mdn-like .cm-comment { color:#777; font-weight:normal; } +.cm-s-mdn-like .cm-string { color:#07a; font-style:italic; } +.cm-s-mdn-like .cm-string-2 { color:#bd6b18; } /*?*/ +.cm-s-mdn-like .cm-meta { color: #000; } /*?*/ +.cm-s-mdn-like .cm-builtin { color: #9B7536; } /*?*/ +.cm-s-mdn-like .cm-tag { color: #997643; } +.cm-s-mdn-like .cm-attribute { color: #d6bb6d; } /*?*/ +.cm-s-mdn-like .cm-header { color: #FF6400; } +.cm-s-mdn-like .cm-hr { color: #AEAEAE; } +.cm-s-mdn-like .cm-link { color:#ad9361; font-style:italic; text-decoration:none; } +.cm-s-mdn-like .cm-error { border-bottom: 1px solid red; } + +div.cm-s-mdn-like .CodeMirror-activeline-background { background: #efefff; } +div.cm-s-mdn-like span.CodeMirror-matchingbracket { outline:1px solid grey; color: inherit; } + +.cm-s-mdn-like.CodeMirror { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFcAAAAyCAYAAAAp8UeFAAAHvklEQVR42s2b63bcNgyEQZCSHCdt2vd/0tWF7I+Q6XgMXiTtuvU5Pl57ZQKkKHzEAOtF5KeIJBGJ8uvL599FRFREZhFx8DeXv8trn68RuGaC8TRfo3SNp9dlDDHedyLyTUTeRWStXKPZrjtpZxaRw5hPqozRs1N8/enzIiQRWcCgy4MUA0f+XWliDhyL8Lfyvx7ei/Ae3iQFHyw7U/59pQVIMEEPEz0G7XiwdRjzSfC3UTtz9vchIntxvry5iMgfIhJoEflOz2CQr3F5h/HfeFe+GTdLaKcu9L8LTeQb/R/7GgbsfKedyNdoHsN31uRPWrfZ5wsj/NzzRQHuToIdU3ahwnsKPxXCjJITuOsi7XLc7SG/v5GdALs7wf8JjTFiB5+QvTEfRyGOfX3Lrx8wxyQi3sNq46O7QahQiCsRFgqddjBouVEHOKDgXAQHD9gJCr5sMKkEdjwsarG/ww3BMHBU7OBjXnzdyY7SfCxf5/z6ATccrwlKuwC/jhznnPF4CgVzhhVf4xp2EixcBActO75iZ8/fM9zAs2OMzKdslgXWJ9XG8PQoOAMA5fGcsvORgv0doBXyHrCwfLJAOwo71QLNkb8n2Pl6EWiR7OCibtkPaz4Kc/0NNAze2gju3zOwekALDaCFPI5vjPFmgGY5AZqyGEvH1x7QfIb8YtxMnA/b+QQ0aQDAwc6JMFg8CbQZ4qoYEEHbRwNojuK3EHwd7VALSgq+MNDKzfT58T8qdpADrgW0GmgcAS1lhzztJmkAzcPNOQbsWEALBDSlMKUG0Eq4CLAQWvEVQ9WU57gZJwZtgPO3r9oBTQ9WO8TjqXINx8R0EYpiZEUWOF3FxkbJkgU9B2f41YBrIj5ZfsQa0M5kTgiAAqM3ShXLgu8XMqcrQBvJ0CL5pnTsfMB13oB8athpAq2XOQmcGmoACCLydx7nToa23ATaSIY2ichfOdPTGxlasXMLaL0MLZAOwAKIM+y8CmicobGdCcbbK9DzN+yYGVoNNI5iUKTMyYOjPse4A8SM1MmcXgU0toOq1yO/v8FOxlASyc7TgeYaAMBJHcY1CcCwGI/TK4AmDbDyKYBBtFUkRwto8gygiQEaByFgJ00BH2M8JWwQS1nafDXQCidWyOI8AcjDCSjCLk8ngObuAm3JAHAdubAmOaK06V8MNEsKPJOhobSprwQa6gD7DclRQdqcwL4zxqgBrQcabUiBLclRDKAlWp+etPkBaNMA0AKlrHwTdEByZAA4GM+SNluSY6wAzcMNewxmgig5Ks0nkrSpBvSaQHMdKTBAnLojOdYyGpQ254602ZILPdTD1hdlggdIm74jbTp8vDwF5ZYUeLWGJpWsh6XNyXgcYwVoJQTEhhTYkxzZjiU5npU2TaB979TQehlaAVq4kaGpiPwwwLkYUuBbQwocyQTv1tA0+1UFWoJF3iv1oq+qoSk8EQdJmwHkziIF7oOZk14EGitibAdjLYYK78H5vZOhtWpoI0ATGHs0Q8OMb4Ey+2bU2UYztCtA0wFAs7TplGLRVQCcqaFdGSPCeTI1QNIC52iWNzof6Uib7xjEp07mNNoUYmVosVItHrHzRlLgBn9LFyRHaQCtVUMbtTNhoXWiTOO9k/V8BdAc1Oq0ArSQs6/5SU0hckNy9NnXqQY0PGYo5dWJ7nINaN6o958FWin27aBaWRka1r5myvLOAm0j30eBJqCxHLReVclxhxOEN2JfDWjxBtAC7MIH1fVaGdoOp4qJYDgKtKPSFNID2gSnGldrCqkFZ+5UeQXQBIRrSwocbdZYQT/2LwRahBPBXoHrB8nxaGROST62DKUbQOMMzZIC9abkuELfQzQALWTnDNAm8KHWFOJgJ5+SHIvTPcmx1xQyZRhNL5Qci689aXMEaN/uNIWkEwDAvFpOZmgsBaaGnbs1NPa1Jm32gBZAIh1pCtG7TSH4aE0y1uVY4uqoFPisGlpP2rSA5qTecWn5agK6BzSpgAyD+wFaqhnYoSZ1Vwr8CmlTQbrcO3ZaX0NAEyMbYaAlyquFoLKK3SPby9CeVUPThrSJmkCAE0CrKUQadi4DrdSlWhmah0YL9z9vClH59YGbHx1J8VZTyAjQepJjmXwAKTDQI3omc3p1U4gDUf6RfcdYfrUp5ClAi2J3Ba6UOXGo+K+bQrjjssitG2SJzshaLwMtXgRagUNpYYoVkMSBLM+9GGiJZMvduG6DRZ4qc04DMPtQQxOjEtACmhO7K1AbNbQDEggZyJwscFpAGwENhoBeUwh3bWolhe8BTYVKxQEWrSUn/uhcM5KhvUu/+eQu0Lzhi+VrK0PrZZNDQKs9cpYUuFYgMVpD4/NxenJTiMCNqdUEUf1qZWjppLT5qSkkUZbCwkbZMSuVnu80hfSkzRbQeqCZSAh6huR4VtoM2gHAlLf72smuWgE+VV7XpE25Ab2WFDgyhnSuKbs4GuGzCjR+tIoUuMFg3kgcWKLTwRqanJQ2W00hAsenfaApRC42hbCvK1SlE0HtE9BGgneJO+ELamitD1YjjOYnNYVcraGhtKkW0EqVVeDx733I2NH581k1NNxNLG0i0IJ8/NjVaOZ0tYZ2Vtr0Xv7tPV3hkWp9EFkgS/J0vosngTaSoaG06WHi+xObQkaAdlbanP8B2+2l0f90LmUAAAAASUVORK5CYII=); } diff --git a/adminx/assets/vendor/codemirror/theme/midnight.css b/adminx/assets/vendor/codemirror/theme/midnight.css new file mode 100644 index 0000000..fc26474 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/midnight.css @@ -0,0 +1,39 @@ +/* Based on the theme at http://bonsaiden.github.com/JavaScript-Garden */ + +/**/ +.cm-s-midnight .CodeMirror-activeline-background { background: #253540; } + +.cm-s-midnight.CodeMirror { + background: #0F192A; + color: #D1EDFF; +} + +.cm-s-midnight div.CodeMirror-selected { background: #314D67; } +.cm-s-midnight .CodeMirror-line::selection, .cm-s-midnight .CodeMirror-line > span::selection, .cm-s-midnight .CodeMirror-line > span > span::selection { background: rgba(49, 77, 103, .99); } +.cm-s-midnight .CodeMirror-line::-moz-selection, .cm-s-midnight .CodeMirror-line > span::-moz-selection, .cm-s-midnight .CodeMirror-line > span > span::-moz-selection { background: rgba(49, 77, 103, .99); } +.cm-s-midnight .CodeMirror-gutters { background: #0F192A; border-right: 1px solid; } +.cm-s-midnight .CodeMirror-guttermarker { color: white; } +.cm-s-midnight .CodeMirror-guttermarker-subtle { color: #d0d0d0; } +.cm-s-midnight .CodeMirror-linenumber { color: #D0D0D0; } +.cm-s-midnight .CodeMirror-cursor { border-left: 1px solid #F8F8F0; } + +.cm-s-midnight span.cm-comment { color: #428BDD; } +.cm-s-midnight span.cm-atom { color: #AE81FF; } +.cm-s-midnight span.cm-number { color: #D1EDFF; } + +.cm-s-midnight span.cm-property, .cm-s-midnight span.cm-attribute { color: #A6E22E; } +.cm-s-midnight span.cm-keyword { color: #E83737; } +.cm-s-midnight span.cm-string { color: #1DC116; } + +.cm-s-midnight span.cm-variable { color: #FFAA3E; } +.cm-s-midnight span.cm-variable-2 { color: #FFAA3E; } +.cm-s-midnight span.cm-def { color: #4DD; } +.cm-s-midnight span.cm-bracket { color: #D1EDFF; } +.cm-s-midnight span.cm-tag { color: #449; } +.cm-s-midnight span.cm-link { color: #AE81FF; } +.cm-s-midnight span.cm-error { background: #F92672; color: #F8F8F0; } + +.cm-s-midnight .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} diff --git a/adminx/assets/vendor/codemirror/theme/monokai.css b/adminx/assets/vendor/codemirror/theme/monokai.css new file mode 100644 index 0000000..cd4cd55 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/monokai.css @@ -0,0 +1,41 @@ +/* Based on Sublime Text's Monokai theme */ + +.cm-s-monokai.CodeMirror { background: #272822; color: #f8f8f2; } +.cm-s-monokai div.CodeMirror-selected { background: #49483E; } +.cm-s-monokai .CodeMirror-line::selection, .cm-s-monokai .CodeMirror-line > span::selection, .cm-s-monokai .CodeMirror-line > span > span::selection { background: rgba(73, 72, 62, .99); } +.cm-s-monokai .CodeMirror-line::-moz-selection, .cm-s-monokai .CodeMirror-line > span::-moz-selection, .cm-s-monokai .CodeMirror-line > span > span::-moz-selection { background: rgba(73, 72, 62, .99); } +.cm-s-monokai .CodeMirror-gutters { background: #272822; border-right: 0px; } +.cm-s-monokai .CodeMirror-guttermarker { color: white; } +.cm-s-monokai .CodeMirror-guttermarker-subtle { color: #d0d0d0; } +.cm-s-monokai .CodeMirror-linenumber { color: #d0d0d0; } +.cm-s-monokai .CodeMirror-cursor { border-left: 1px solid #f8f8f0; } + +.cm-s-monokai span.cm-comment { color: #75715e; } +.cm-s-monokai span.cm-atom { color: #ae81ff; } +.cm-s-monokai span.cm-number { color: #ae81ff; } + +.cm-s-monokai span.cm-comment.cm-attribute { color: #97b757; } +.cm-s-monokai span.cm-comment.cm-def { color: #bc9262; } +.cm-s-monokai span.cm-comment.cm-tag { color: #bc6283; } +.cm-s-monokai span.cm-comment.cm-type { color: #5998a6; } + +.cm-s-monokai span.cm-property, .cm-s-monokai span.cm-attribute { color: #a6e22e; } +.cm-s-monokai span.cm-keyword { color: #f92672; } +.cm-s-monokai span.cm-builtin { color: #66d9ef; } +.cm-s-monokai span.cm-string { color: #e6db74; } + +.cm-s-monokai span.cm-variable { color: #f8f8f2; } +.cm-s-monokai span.cm-variable-2 { color: #9effff; } +.cm-s-monokai span.cm-variable-3, .cm-s-monokai span.cm-type { color: #66d9ef; } +.cm-s-monokai span.cm-def { color: #fd971f; } +.cm-s-monokai span.cm-bracket { color: #f8f8f2; } +.cm-s-monokai span.cm-tag { color: #f92672; } +.cm-s-monokai span.cm-header { color: #ae81ff; } +.cm-s-monokai span.cm-link { color: #ae81ff; } +.cm-s-monokai span.cm-error { background: #f92672; color: #f8f8f0; } + +.cm-s-monokai .CodeMirror-activeline-background { background: #373831; } +.cm-s-monokai .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} diff --git a/adminx/assets/vendor/codemirror/theme/moxer.css b/adminx/assets/vendor/codemirror/theme/moxer.css new file mode 100644 index 0000000..b3ca35e --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/moxer.css @@ -0,0 +1,143 @@ +/* + Name: Moxer Theme + Author: Mattia Astorino (http://github.com/equinusocio) + Website: https://github.com/moxer-theme/moxer-code +*/ + +.cm-s-moxer.CodeMirror { + background-color: #090A0F; + color: #8E95B4; + line-height: 1.8; +} + +.cm-s-moxer .CodeMirror-gutters { + background: #090A0F; + color: #35394B; + border: none; +} + +.cm-s-moxer .CodeMirror-guttermarker, +.cm-s-moxer .CodeMirror-guttermarker-subtle, +.cm-s-moxer .CodeMirror-linenumber { + color: #35394B; +} + + +.cm-s-moxer .CodeMirror-cursor { + border-left: 1px solid #FFCC00; +} + +.cm-s-moxer div.CodeMirror-selected { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-moxer.CodeMirror-focused div.CodeMirror-selected { + background: #212431; +} + +.cm-s-moxer .CodeMirror-line::selection, +.cm-s-moxer .CodeMirror-line>span::selection, +.cm-s-moxer .CodeMirror-line>span>span::selection { + background: #212431; +} + +.cm-s-moxer .CodeMirror-line::-moz-selection, +.cm-s-moxer .CodeMirror-line>span::-moz-selection, +.cm-s-moxer .CodeMirror-line>span>span::-moz-selection { + background: #212431; +} + +.cm-s-moxer .CodeMirror-activeline-background, +.cm-s-moxer .CodeMirror-activeline-gutter .CodeMirror-linenumber { + background: rgba(33, 36, 49, 0.5); +} + +.cm-s-moxer .cm-keyword { + color: #D46C6C; +} + +.cm-s-moxer .cm-operator { + color: #D46C6C; +} + +.cm-s-moxer .cm-variable-2 { + color: #81C5DA; +} + + +.cm-s-moxer .cm-variable-3, +.cm-s-moxer .cm-type { + color: #f07178; +} + +.cm-s-moxer .cm-builtin { + color: #FFCB6B; +} + +.cm-s-moxer .cm-atom { + color: #A99BE2; +} + +.cm-s-moxer .cm-number { + color: #7CA4C0; +} + +.cm-s-moxer .cm-def { + color: #F5DFA5; +} + +.cm-s-moxer .CodeMirror-line .cm-def ~ .cm-def { + color: #81C5DA; +} + +.cm-s-moxer .cm-string { + color: #B2E4AE; +} + +.cm-s-moxer .cm-string-2 { + color: #f07178; +} + +.cm-s-moxer .cm-comment { + color: #3F445A; +} + +.cm-s-moxer .cm-variable { + color: #8E95B4; +} + +.cm-s-moxer .cm-tag { + color: #FF5370; +} + +.cm-s-moxer .cm-meta { + color: #FFCB6B; +} + +.cm-s-moxer .cm-attribute { + color: #C792EA; +} + +.cm-s-moxer .cm-property { + color: #81C5DA; +} + +.cm-s-moxer .cm-qualifier { + color: #DECB6B; +} + +.cm-s-moxer .cm-variable-3, +.cm-s-moxer .cm-type { + color: #DECB6B; +} + + +.cm-s-moxer .cm-error { + color: rgba(255, 255, 255, 1.0); + background-color: #FF5370; +} + +.cm-s-moxer .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} \ No newline at end of file diff --git a/adminx/assets/vendor/codemirror/theme/neat.css b/adminx/assets/vendor/codemirror/theme/neat.css new file mode 100644 index 0000000..4267b1a --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/neat.css @@ -0,0 +1,12 @@ +.cm-s-neat span.cm-comment { color: #a86; } +.cm-s-neat span.cm-keyword { line-height: 1em; font-weight: bold; color: blue; } +.cm-s-neat span.cm-string { color: #a22; } +.cm-s-neat span.cm-builtin { line-height: 1em; font-weight: bold; color: #077; } +.cm-s-neat span.cm-special { line-height: 1em; font-weight: bold; color: #0aa; } +.cm-s-neat span.cm-variable { color: black; } +.cm-s-neat span.cm-number, .cm-s-neat span.cm-atom { color: #3a3; } +.cm-s-neat span.cm-meta { color: #555; } +.cm-s-neat span.cm-link { color: #3a3; } + +.cm-s-neat .CodeMirror-activeline-background { background: #e8f2ff; } +.cm-s-neat .CodeMirror-matchingbracket { outline:1px solid grey; color:black !important; } diff --git a/adminx/assets/vendor/codemirror/theme/neo.css b/adminx/assets/vendor/codemirror/theme/neo.css new file mode 100644 index 0000000..b28d5c6 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/neo.css @@ -0,0 +1,43 @@ +/* neo theme for codemirror */ + +/* Color scheme */ + +.cm-s-neo.CodeMirror { + background-color:#ffffff; + color:#2e383c; + line-height:1.4375; +} +.cm-s-neo .cm-comment { color:#75787b; } +.cm-s-neo .cm-keyword, .cm-s-neo .cm-property { color:#1d75b3; } +.cm-s-neo .cm-atom,.cm-s-neo .cm-number { color:#75438a; } +.cm-s-neo .cm-node,.cm-s-neo .cm-tag { color:#9c3328; } +.cm-s-neo .cm-string { color:#b35e14; } +.cm-s-neo .cm-variable,.cm-s-neo .cm-qualifier { color:#047d65; } + + +/* Editor styling */ + +.cm-s-neo pre { + padding:0; +} + +.cm-s-neo .CodeMirror-gutters { + border:none; + border-right:10px solid transparent; + background-color:transparent; +} + +.cm-s-neo .CodeMirror-linenumber { + padding:0; + color:#e0e2e5; +} + +.cm-s-neo .CodeMirror-guttermarker { color: #1d75b3; } +.cm-s-neo .CodeMirror-guttermarker-subtle { color: #e0e2e5; } + +.cm-s-neo .CodeMirror-cursor { + width: auto; + border: 0; + background: rgba(155,157,162,0.37); + z-index: 1; +} diff --git a/adminx/assets/vendor/codemirror/theme/night.css b/adminx/assets/vendor/codemirror/theme/night.css new file mode 100644 index 0000000..f631bf4 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/night.css @@ -0,0 +1,27 @@ +/* Loosely based on the Midnight Textmate theme */ + +.cm-s-night.CodeMirror { background: #0a001f; color: #f8f8f8; } +.cm-s-night div.CodeMirror-selected { background: #447; } +.cm-s-night .CodeMirror-line::selection, .cm-s-night .CodeMirror-line > span::selection, .cm-s-night .CodeMirror-line > span > span::selection { background: rgba(68, 68, 119, .99); } +.cm-s-night .CodeMirror-line::-moz-selection, .cm-s-night .CodeMirror-line > span::-moz-selection, .cm-s-night .CodeMirror-line > span > span::-moz-selection { background: rgba(68, 68, 119, .99); } +.cm-s-night .CodeMirror-gutters { background: #0a001f; border-right: 1px solid #aaa; } +.cm-s-night .CodeMirror-guttermarker { color: white; } +.cm-s-night .CodeMirror-guttermarker-subtle { color: #bbb; } +.cm-s-night .CodeMirror-linenumber { color: #f8f8f8; } +.cm-s-night .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-night span.cm-comment { color: #8900d1; } +.cm-s-night span.cm-atom { color: #845dc4; } +.cm-s-night span.cm-number, .cm-s-night span.cm-attribute { color: #ffd500; } +.cm-s-night span.cm-keyword { color: #599eff; } +.cm-s-night span.cm-string { color: #37f14a; } +.cm-s-night span.cm-meta { color: #7678e2; } +.cm-s-night span.cm-variable-2, .cm-s-night span.cm-tag { color: #99b2ff; } +.cm-s-night span.cm-variable-3, .cm-s-night span.cm-def, .cm-s-night span.cm-type { color: white; } +.cm-s-night span.cm-bracket { color: #8da6ce; } +.cm-s-night span.cm-builtin, .cm-s-night span.cm-special { color: #ff9e59; } +.cm-s-night span.cm-link { color: #845dc4; } +.cm-s-night span.cm-error { color: #9d1e15; } + +.cm-s-night .CodeMirror-activeline-background { background: #1C005A; } +.cm-s-night .CodeMirror-matchingbracket { outline:1px solid grey; color:white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/nord.css b/adminx/assets/vendor/codemirror/theme/nord.css new file mode 100644 index 0000000..41a8ad7 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/nord.css @@ -0,0 +1,42 @@ +/* Based on arcticicestudio's Nord theme */ +/* https://github.com/arcticicestudio/nord */ + +.cm-s-nord.CodeMirror { background: #2e3440; color: #d8dee9; } +.cm-s-nord div.CodeMirror-selected { background: #434c5e; } +.cm-s-nord .CodeMirror-line::selection, .cm-s-nord .CodeMirror-line > span::selection, .cm-s-nord .CodeMirror-line > span > span::selection { background: #3b4252; } +.cm-s-nord .CodeMirror-line::-moz-selection, .cm-s-nord .CodeMirror-line > span::-moz-selection, .cm-s-nord .CodeMirror-line > span > span::-moz-selection { background: #3b4252; } +.cm-s-nord .CodeMirror-gutters { background: #2e3440; border-right: 0px; } +.cm-s-nord .CodeMirror-guttermarker { color: #4c566a; } +.cm-s-nord .CodeMirror-guttermarker-subtle { color: #4c566a; } +.cm-s-nord .CodeMirror-linenumber { color: #4c566a; } +.cm-s-nord .CodeMirror-cursor { border-left: 1px solid #f8f8f0; } + +.cm-s-nord span.cm-comment { color: #4c566a; } +.cm-s-nord span.cm-atom { color: #b48ead; } +.cm-s-nord span.cm-number { color: #b48ead; } + +.cm-s-nord span.cm-comment.cm-attribute { color: #97b757; } +.cm-s-nord span.cm-comment.cm-def { color: #bc9262; } +.cm-s-nord span.cm-comment.cm-tag { color: #bc6283; } +.cm-s-nord span.cm-comment.cm-type { color: #5998a6; } + +.cm-s-nord span.cm-property, .cm-s-nord span.cm-attribute { color: #8FBCBB; } +.cm-s-nord span.cm-keyword { color: #81A1C1; } +.cm-s-nord span.cm-builtin { color: #81A1C1; } +.cm-s-nord span.cm-string { color: #A3BE8C; } + +.cm-s-nord span.cm-variable { color: #d8dee9; } +.cm-s-nord span.cm-variable-2 { color: #d8dee9; } +.cm-s-nord span.cm-variable-3, .cm-s-nord span.cm-type { color: #d8dee9; } +.cm-s-nord span.cm-def { color: #8FBCBB; } +.cm-s-nord span.cm-bracket { color: #81A1C1; } +.cm-s-nord span.cm-tag { color: #bf616a; } +.cm-s-nord span.cm-header { color: #b48ead; } +.cm-s-nord span.cm-link { color: #b48ead; } +.cm-s-nord span.cm-error { background: #bf616a; color: #f8f8f0; } + +.cm-s-nord .CodeMirror-activeline-background { background: #3b4252; } +.cm-s-nord .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} diff --git a/adminx/assets/vendor/codemirror/theme/oceanic-next.css b/adminx/assets/vendor/codemirror/theme/oceanic-next.css new file mode 100644 index 0000000..296277b --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/oceanic-next.css @@ -0,0 +1,44 @@ +/* + + Name: oceanic-next + Author: Filype Pereira (https://github.com/fpereira1) + + Original oceanic-next color scheme by Dmitri Voronianski (https://github.com/voronianski/oceanic-next-color-scheme) + +*/ + +.cm-s-oceanic-next.CodeMirror { background: #304148; color: #f8f8f2; } +.cm-s-oceanic-next div.CodeMirror-selected { background: rgba(101, 115, 126, 0.33); } +.cm-s-oceanic-next .CodeMirror-line::selection, .cm-s-oceanic-next .CodeMirror-line > span::selection, .cm-s-oceanic-next .CodeMirror-line > span > span::selection { background: rgba(101, 115, 126, 0.33); } +.cm-s-oceanic-next .CodeMirror-line::-moz-selection, .cm-s-oceanic-next .CodeMirror-line > span::-moz-selection, .cm-s-oceanic-next .CodeMirror-line > span > span::-moz-selection { background: rgba(101, 115, 126, 0.33); } +.cm-s-oceanic-next .CodeMirror-gutters { background: #304148; border-right: 10px; } +.cm-s-oceanic-next .CodeMirror-guttermarker { color: white; } +.cm-s-oceanic-next .CodeMirror-guttermarker-subtle { color: #d0d0d0; } +.cm-s-oceanic-next .CodeMirror-linenumber { color: #d0d0d0; } +.cm-s-oceanic-next .CodeMirror-cursor { border-left: 1px solid #f8f8f0; } + +.cm-s-oceanic-next span.cm-comment { color: #65737E; } +.cm-s-oceanic-next span.cm-atom { color: #C594C5; } +.cm-s-oceanic-next span.cm-number { color: #F99157; } + +.cm-s-oceanic-next span.cm-property { color: #99C794; } +.cm-s-oceanic-next span.cm-attribute, +.cm-s-oceanic-next span.cm-keyword { color: #C594C5; } +.cm-s-oceanic-next span.cm-builtin { color: #66d9ef; } +.cm-s-oceanic-next span.cm-string { color: #99C794; } + +.cm-s-oceanic-next span.cm-variable, +.cm-s-oceanic-next span.cm-variable-2, +.cm-s-oceanic-next span.cm-variable-3 { color: #f8f8f2; } +.cm-s-oceanic-next span.cm-def { color: #6699CC; } +.cm-s-oceanic-next span.cm-bracket { color: #5FB3B3; } +.cm-s-oceanic-next span.cm-tag { color: #C594C5; } +.cm-s-oceanic-next span.cm-header { color: #C594C5; } +.cm-s-oceanic-next span.cm-link { color: #C594C5; } +.cm-s-oceanic-next span.cm-error { background: #C594C5; color: #f8f8f0; } + +.cm-s-oceanic-next .CodeMirror-activeline-background { background: rgba(101, 115, 126, 0.33); } +.cm-s-oceanic-next .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} diff --git a/adminx/assets/vendor/codemirror/theme/panda-syntax.css b/adminx/assets/vendor/codemirror/theme/panda-syntax.css new file mode 100644 index 0000000..de14e91 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/panda-syntax.css @@ -0,0 +1,85 @@ +/* + Name: Panda Syntax + Author: Siamak Mokhtari (http://github.com/siamak/) + CodeMirror template by Siamak Mokhtari (https://github.com/siamak/atom-panda-syntax) +*/ +.cm-s-panda-syntax { + background: #292A2B; + color: #E6E6E6; + line-height: 1.5; + font-family: 'Operator Mono', 'Source Code Pro', Menlo, Monaco, Consolas, Courier New, monospace; +} +.cm-s-panda-syntax .CodeMirror-cursor { border-color: #ff2c6d; } +.cm-s-panda-syntax .CodeMirror-activeline-background { + background: rgba(99, 123, 156, 0.1); +} +.cm-s-panda-syntax .CodeMirror-selected { + background: #FFF; +} +.cm-s-panda-syntax .cm-comment { + font-style: italic; + color: #676B79; +} +.cm-s-panda-syntax .cm-operator { + color: #f3f3f3; +} +.cm-s-panda-syntax .cm-string { + color: #19F9D8; +} +.cm-s-panda-syntax .cm-string-2 { + color: #FFB86C; +} + +.cm-s-panda-syntax .cm-tag { + color: #ff2c6d; +} +.cm-s-panda-syntax .cm-meta { + color: #b084eb; +} + +.cm-s-panda-syntax .cm-number { + color: #FFB86C; +} +.cm-s-panda-syntax .cm-atom { + color: #ff2c6d; +} +.cm-s-panda-syntax .cm-keyword { + color: #FF75B5; +} +.cm-s-panda-syntax .cm-variable { + color: #ffb86c; +} +.cm-s-panda-syntax .cm-variable-2 { + color: #ff9ac1; +} +.cm-s-panda-syntax .cm-variable-3, .cm-s-panda-syntax .cm-type { + color: #ff9ac1; +} + +.cm-s-panda-syntax .cm-def { + color: #e6e6e6; +} +.cm-s-panda-syntax .cm-property { + color: #f3f3f3; +} +.cm-s-panda-syntax .cm-unit { + color: #ffb86c; +} + +.cm-s-panda-syntax .cm-attribute { + color: #ffb86c; +} + +.cm-s-panda-syntax .CodeMirror-matchingbracket { + border-bottom: 1px dotted #19F9D8; + padding-bottom: 2px; + color: #e6e6e6; +} +.cm-s-panda-syntax .CodeMirror-gutters { + background: #292a2b; + border-right-color: rgba(255, 255, 255, 0.1); +} +.cm-s-panda-syntax .CodeMirror-linenumber { + color: #e6e6e6; + opacity: 0.6; +} diff --git a/adminx/assets/vendor/codemirror/theme/paraiso-dark.css b/adminx/assets/vendor/codemirror/theme/paraiso-dark.css new file mode 100644 index 0000000..aa9d207 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/paraiso-dark.css @@ -0,0 +1,38 @@ +/* + + Name: Paraíso (Dark) + Author: Jan T. Sott + + Color scheme by Jan T. Sott (https://github.com/idleberg/Paraiso-CodeMirror) + Inspired by the art of Rubens LP (http://www.rubenslp.com.br) + +*/ + +.cm-s-paraiso-dark.CodeMirror { background: #2f1e2e; color: #b9b6b0; } +.cm-s-paraiso-dark div.CodeMirror-selected { background: #41323f; } +.cm-s-paraiso-dark .CodeMirror-line::selection, .cm-s-paraiso-dark .CodeMirror-line > span::selection, .cm-s-paraiso-dark .CodeMirror-line > span > span::selection { background: rgba(65, 50, 63, .99); } +.cm-s-paraiso-dark .CodeMirror-line::-moz-selection, .cm-s-paraiso-dark .CodeMirror-line > span::-moz-selection, .cm-s-paraiso-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(65, 50, 63, .99); } +.cm-s-paraiso-dark .CodeMirror-gutters { background: #2f1e2e; border-right: 0px; } +.cm-s-paraiso-dark .CodeMirror-guttermarker { color: #ef6155; } +.cm-s-paraiso-dark .CodeMirror-guttermarker-subtle { color: #776e71; } +.cm-s-paraiso-dark .CodeMirror-linenumber { color: #776e71; } +.cm-s-paraiso-dark .CodeMirror-cursor { border-left: 1px solid #8d8687; } + +.cm-s-paraiso-dark span.cm-comment { color: #e96ba8; } +.cm-s-paraiso-dark span.cm-atom { color: #815ba4; } +.cm-s-paraiso-dark span.cm-number { color: #815ba4; } + +.cm-s-paraiso-dark span.cm-property, .cm-s-paraiso-dark span.cm-attribute { color: #48b685; } +.cm-s-paraiso-dark span.cm-keyword { color: #ef6155; } +.cm-s-paraiso-dark span.cm-string { color: #fec418; } + +.cm-s-paraiso-dark span.cm-variable { color: #48b685; } +.cm-s-paraiso-dark span.cm-variable-2 { color: #06b6ef; } +.cm-s-paraiso-dark span.cm-def { color: #f99b15; } +.cm-s-paraiso-dark span.cm-bracket { color: #b9b6b0; } +.cm-s-paraiso-dark span.cm-tag { color: #ef6155; } +.cm-s-paraiso-dark span.cm-link { color: #815ba4; } +.cm-s-paraiso-dark span.cm-error { background: #ef6155; color: #8d8687; } + +.cm-s-paraiso-dark .CodeMirror-activeline-background { background: #4D344A; } +.cm-s-paraiso-dark .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/paraiso-light.css b/adminx/assets/vendor/codemirror/theme/paraiso-light.css new file mode 100644 index 0000000..ae0c755 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/paraiso-light.css @@ -0,0 +1,38 @@ +/* + + Name: Paraíso (Light) + Author: Jan T. Sott + + Color scheme by Jan T. Sott (https://github.com/idleberg/Paraiso-CodeMirror) + Inspired by the art of Rubens LP (http://www.rubenslp.com.br) + +*/ + +.cm-s-paraiso-light.CodeMirror { background: #e7e9db; color: #41323f; } +.cm-s-paraiso-light div.CodeMirror-selected { background: #b9b6b0; } +.cm-s-paraiso-light .CodeMirror-line::selection, .cm-s-paraiso-light .CodeMirror-line > span::selection, .cm-s-paraiso-light .CodeMirror-line > span > span::selection { background: #b9b6b0; } +.cm-s-paraiso-light .CodeMirror-line::-moz-selection, .cm-s-paraiso-light .CodeMirror-line > span::-moz-selection, .cm-s-paraiso-light .CodeMirror-line > span > span::-moz-selection { background: #b9b6b0; } +.cm-s-paraiso-light .CodeMirror-gutters { background: #e7e9db; border-right: 0px; } +.cm-s-paraiso-light .CodeMirror-guttermarker { color: black; } +.cm-s-paraiso-light .CodeMirror-guttermarker-subtle { color: #8d8687; } +.cm-s-paraiso-light .CodeMirror-linenumber { color: #8d8687; } +.cm-s-paraiso-light .CodeMirror-cursor { border-left: 1px solid #776e71; } + +.cm-s-paraiso-light span.cm-comment { color: #e96ba8; } +.cm-s-paraiso-light span.cm-atom { color: #815ba4; } +.cm-s-paraiso-light span.cm-number { color: #815ba4; } + +.cm-s-paraiso-light span.cm-property, .cm-s-paraiso-light span.cm-attribute { color: #48b685; } +.cm-s-paraiso-light span.cm-keyword { color: #ef6155; } +.cm-s-paraiso-light span.cm-string { color: #fec418; } + +.cm-s-paraiso-light span.cm-variable { color: #48b685; } +.cm-s-paraiso-light span.cm-variable-2 { color: #06b6ef; } +.cm-s-paraiso-light span.cm-def { color: #f99b15; } +.cm-s-paraiso-light span.cm-bracket { color: #41323f; } +.cm-s-paraiso-light span.cm-tag { color: #ef6155; } +.cm-s-paraiso-light span.cm-link { color: #815ba4; } +.cm-s-paraiso-light span.cm-error { background: #ef6155; color: #776e71; } + +.cm-s-paraiso-light .CodeMirror-activeline-background { background: #CFD1C4; } +.cm-s-paraiso-light .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/pastel-on-dark.css b/adminx/assets/vendor/codemirror/theme/pastel-on-dark.css new file mode 100644 index 0000000..60435dd --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/pastel-on-dark.css @@ -0,0 +1,52 @@ +/** + * Pastel On Dark theme ported from ACE editor + * @license MIT + * @copyright AtomicPages LLC 2014 + * @author Dennis Thompson, AtomicPages LLC + * @version 1.1 + * @source https://github.com/atomicpages/codemirror-pastel-on-dark-theme + */ + +.cm-s-pastel-on-dark.CodeMirror { + background: #2c2827; + color: #8F938F; + line-height: 1.5; +} +.cm-s-pastel-on-dark div.CodeMirror-selected { background: rgba(221,240,255,0.2); } +.cm-s-pastel-on-dark .CodeMirror-line::selection, .cm-s-pastel-on-dark .CodeMirror-line > span::selection, .cm-s-pastel-on-dark .CodeMirror-line > span > span::selection { background: rgba(221,240,255,0.2); } +.cm-s-pastel-on-dark .CodeMirror-line::-moz-selection, .cm-s-pastel-on-dark .CodeMirror-line > span::-moz-selection, .cm-s-pastel-on-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(221,240,255,0.2); } + +.cm-s-pastel-on-dark .CodeMirror-gutters { + background: #34302f; + border-right: 0px; + padding: 0 3px; +} +.cm-s-pastel-on-dark .CodeMirror-guttermarker { color: white; } +.cm-s-pastel-on-dark .CodeMirror-guttermarker-subtle { color: #8F938F; } +.cm-s-pastel-on-dark .CodeMirror-linenumber { color: #8F938F; } +.cm-s-pastel-on-dark .CodeMirror-cursor { border-left: 1px solid #A7A7A7; } +.cm-s-pastel-on-dark span.cm-comment { color: #A6C6FF; } +.cm-s-pastel-on-dark span.cm-atom { color: #DE8E30; } +.cm-s-pastel-on-dark span.cm-number { color: #CCCCCC; } +.cm-s-pastel-on-dark span.cm-property { color: #8F938F; } +.cm-s-pastel-on-dark span.cm-attribute { color: #a6e22e; } +.cm-s-pastel-on-dark span.cm-keyword { color: #AEB2F8; } +.cm-s-pastel-on-dark span.cm-string { color: #66A968; } +.cm-s-pastel-on-dark span.cm-variable { color: #AEB2F8; } +.cm-s-pastel-on-dark span.cm-variable-2 { color: #BEBF55; } +.cm-s-pastel-on-dark span.cm-variable-3, .cm-s-pastel-on-dark span.cm-type { color: #DE8E30; } +.cm-s-pastel-on-dark span.cm-def { color: #757aD8; } +.cm-s-pastel-on-dark span.cm-bracket { color: #f8f8f2; } +.cm-s-pastel-on-dark span.cm-tag { color: #C1C144; } +.cm-s-pastel-on-dark span.cm-link { color: #ae81ff; } +.cm-s-pastel-on-dark span.cm-qualifier,.cm-s-pastel-on-dark span.cm-builtin { color: #C1C144; } +.cm-s-pastel-on-dark span.cm-error { + background: #757aD8; + color: #f8f8f0; +} +.cm-s-pastel-on-dark .CodeMirror-activeline-background { background: rgba(255, 255, 255, 0.031); } +.cm-s-pastel-on-dark .CodeMirror-matchingbracket { + border: 1px solid rgba(255,255,255,0.25); + color: #8F938F !important; + margin: -1px -1px 0 -1px; +} diff --git a/adminx/assets/vendor/codemirror/theme/railscasts.css b/adminx/assets/vendor/codemirror/theme/railscasts.css new file mode 100644 index 0000000..aeff044 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/railscasts.css @@ -0,0 +1,34 @@ +/* + + Name: Railscasts + Author: Ryan Bates (http://railscasts.com) + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-railscasts.CodeMirror {background: #2b2b2b; color: #f4f1ed;} +.cm-s-railscasts div.CodeMirror-selected {background: #272935 !important;} +.cm-s-railscasts .CodeMirror-gutters {background: #2b2b2b; border-right: 0px;} +.cm-s-railscasts .CodeMirror-linenumber {color: #5a647e;} +.cm-s-railscasts .CodeMirror-cursor {border-left: 1px solid #d4cfc9 !important;} + +.cm-s-railscasts span.cm-comment {color: #bc9458;} +.cm-s-railscasts span.cm-atom {color: #b6b3eb;} +.cm-s-railscasts span.cm-number {color: #b6b3eb;} + +.cm-s-railscasts span.cm-property, .cm-s-railscasts span.cm-attribute {color: #a5c261;} +.cm-s-railscasts span.cm-keyword {color: #da4939;} +.cm-s-railscasts span.cm-string {color: #ffc66d;} + +.cm-s-railscasts span.cm-variable {color: #a5c261;} +.cm-s-railscasts span.cm-variable-2 {color: #6d9cbe;} +.cm-s-railscasts span.cm-def {color: #cc7833;} +.cm-s-railscasts span.cm-error {background: #da4939; color: #d4cfc9;} +.cm-s-railscasts span.cm-bracket {color: #f4f1ed;} +.cm-s-railscasts span.cm-tag {color: #da4939;} +.cm-s-railscasts span.cm-link {color: #b6b3eb;} + +.cm-s-railscasts .CodeMirror-matchingbracket { text-decoration: underline; color: white !important;} +.cm-s-railscasts .CodeMirror-activeline-background { background: #303040; } diff --git a/adminx/assets/vendor/codemirror/theme/rubyblue.css b/adminx/assets/vendor/codemirror/theme/rubyblue.css new file mode 100644 index 0000000..1f181b0 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/rubyblue.css @@ -0,0 +1,25 @@ +.cm-s-rubyblue.CodeMirror { background: #112435; color: white; } +.cm-s-rubyblue div.CodeMirror-selected { background: #38566F; } +.cm-s-rubyblue .CodeMirror-line::selection, .cm-s-rubyblue .CodeMirror-line > span::selection, .cm-s-rubyblue .CodeMirror-line > span > span::selection { background: rgba(56, 86, 111, 0.99); } +.cm-s-rubyblue .CodeMirror-line::-moz-selection, .cm-s-rubyblue .CodeMirror-line > span::-moz-selection, .cm-s-rubyblue .CodeMirror-line > span > span::-moz-selection { background: rgba(56, 86, 111, 0.99); } +.cm-s-rubyblue .CodeMirror-gutters { background: #1F4661; border-right: 7px solid #3E7087; } +.cm-s-rubyblue .CodeMirror-guttermarker { color: white; } +.cm-s-rubyblue .CodeMirror-guttermarker-subtle { color: #3E7087; } +.cm-s-rubyblue .CodeMirror-linenumber { color: white; } +.cm-s-rubyblue .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-rubyblue span.cm-comment { color: #999; font-style:italic; line-height: 1em; } +.cm-s-rubyblue span.cm-atom { color: #F4C20B; } +.cm-s-rubyblue span.cm-number, .cm-s-rubyblue span.cm-attribute { color: #82C6E0; } +.cm-s-rubyblue span.cm-keyword { color: #F0F; } +.cm-s-rubyblue span.cm-string { color: #F08047; } +.cm-s-rubyblue span.cm-meta { color: #F0F; } +.cm-s-rubyblue span.cm-variable-2, .cm-s-rubyblue span.cm-tag { color: #7BD827; } +.cm-s-rubyblue span.cm-variable-3, .cm-s-rubyblue span.cm-def, .cm-s-rubyblue span.cm-type { color: white; } +.cm-s-rubyblue span.cm-bracket { color: #F0F; } +.cm-s-rubyblue span.cm-link { color: #F4C20B; } +.cm-s-rubyblue span.CodeMirror-matchingbracket { color:#F0F !important; } +.cm-s-rubyblue span.cm-builtin, .cm-s-rubyblue span.cm-special { color: #FF9D00; } +.cm-s-rubyblue span.cm-error { color: #AF2018; } + +.cm-s-rubyblue .CodeMirror-activeline-background { background: #173047; } diff --git a/adminx/assets/vendor/codemirror/theme/seti.css b/adminx/assets/vendor/codemirror/theme/seti.css new file mode 100644 index 0000000..814f76f --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/seti.css @@ -0,0 +1,44 @@ +/* + + Name: seti + Author: Michael Kaminsky (http://github.com/mkaminsky11) + + Original seti color scheme by Jesse Weed (https://github.com/jesseweed/seti-syntax) + +*/ + + +.cm-s-seti.CodeMirror { + background-color: #151718 !important; + color: #CFD2D1 !important; + border: none; +} +.cm-s-seti .CodeMirror-gutters { + color: #404b53; + background-color: #0E1112; + border: none; +} +.cm-s-seti .CodeMirror-cursor { border-left: solid thin #f8f8f0; } +.cm-s-seti .CodeMirror-linenumber { color: #6D8A88; } +.cm-s-seti.CodeMirror-focused div.CodeMirror-selected { background: rgba(255, 255, 255, 0.10); } +.cm-s-seti .CodeMirror-line::selection, .cm-s-seti .CodeMirror-line > span::selection, .cm-s-seti .CodeMirror-line > span > span::selection { background: rgba(255, 255, 255, 0.10); } +.cm-s-seti .CodeMirror-line::-moz-selection, .cm-s-seti .CodeMirror-line > span::-moz-selection, .cm-s-seti .CodeMirror-line > span > span::-moz-selection { background: rgba(255, 255, 255, 0.10); } +.cm-s-seti span.cm-comment { color: #41535b; } +.cm-s-seti span.cm-string, .cm-s-seti span.cm-string-2 { color: #55b5db; } +.cm-s-seti span.cm-number { color: #cd3f45; } +.cm-s-seti span.cm-variable { color: #55b5db; } +.cm-s-seti span.cm-variable-2 { color: #a074c4; } +.cm-s-seti span.cm-def { color: #55b5db; } +.cm-s-seti span.cm-keyword { color: #ff79c6; } +.cm-s-seti span.cm-operator { color: #9fca56; } +.cm-s-seti span.cm-keyword { color: #e6cd69; } +.cm-s-seti span.cm-atom { color: #cd3f45; } +.cm-s-seti span.cm-meta { color: #55b5db; } +.cm-s-seti span.cm-tag { color: #55b5db; } +.cm-s-seti span.cm-attribute { color: #9fca56; } +.cm-s-seti span.cm-qualifier { color: #9fca56; } +.cm-s-seti span.cm-property { color: #a074c4; } +.cm-s-seti span.cm-variable-3, .cm-s-seti span.cm-type { color: #9fca56; } +.cm-s-seti span.cm-builtin { color: #9fca56; } +.cm-s-seti .CodeMirror-activeline-background { background: #101213; } +.cm-s-seti .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/shadowfox.css b/adminx/assets/vendor/codemirror/theme/shadowfox.css new file mode 100644 index 0000000..32d59b1 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/shadowfox.css @@ -0,0 +1,52 @@ +/* + + Name: shadowfox + Author: overdodactyl (http://github.com/overdodactyl) + + Original shadowfox color scheme by Firefox + +*/ + +.cm-s-shadowfox.CodeMirror { background: #2a2a2e; color: #b1b1b3; } +.cm-s-shadowfox div.CodeMirror-selected { background: #353B48; } +.cm-s-shadowfox .CodeMirror-line::selection, .cm-s-shadowfox .CodeMirror-line > span::selection, .cm-s-shadowfox .CodeMirror-line > span > span::selection { background: #353B48; } +.cm-s-shadowfox .CodeMirror-line::-moz-selection, .cm-s-shadowfox .CodeMirror-line > span::-moz-selection, .cm-s-shadowfox .CodeMirror-line > span > span::-moz-selection { background: #353B48; } +.cm-s-shadowfox .CodeMirror-gutters { background: #0c0c0d ; border-right: 1px solid #0c0c0d; } +.cm-s-shadowfox .CodeMirror-guttermarker { color: #555; } +.cm-s-shadowfox .CodeMirror-linenumber { color: #939393; } +.cm-s-shadowfox .CodeMirror-cursor { border-left: 1px solid #fff; } + +.cm-s-shadowfox span.cm-comment { color: #939393; } +.cm-s-shadowfox span.cm-atom { color: #FF7DE9; } +.cm-s-shadowfox span.cm-quote { color: #FF7DE9; } +.cm-s-shadowfox span.cm-builtin { color: #FF7DE9; } +.cm-s-shadowfox span.cm-attribute { color: #FF7DE9; } +.cm-s-shadowfox span.cm-keyword { color: #FF7DE9; } +.cm-s-shadowfox span.cm-error { color: #FF7DE9; } + +.cm-s-shadowfox span.cm-number { color: #6B89FF; } +.cm-s-shadowfox span.cm-string { color: #6B89FF; } +.cm-s-shadowfox span.cm-string-2 { color: #6B89FF; } + +.cm-s-shadowfox span.cm-meta { color: #939393; } +.cm-s-shadowfox span.cm-hr { color: #939393; } + +.cm-s-shadowfox span.cm-header { color: #75BFFF; } +.cm-s-shadowfox span.cm-qualifier { color: #75BFFF; } +.cm-s-shadowfox span.cm-variable-2 { color: #75BFFF; } + +.cm-s-shadowfox span.cm-property { color: #86DE74; } + +.cm-s-shadowfox span.cm-def { color: #75BFFF; } +.cm-s-shadowfox span.cm-bracket { color: #75BFFF; } +.cm-s-shadowfox span.cm-tag { color: #75BFFF; } +.cm-s-shadowfox span.cm-link:visited { color: #75BFFF; } + +.cm-s-shadowfox span.cm-variable { color: #B98EFF; } +.cm-s-shadowfox span.cm-variable-3 { color: #d7d7db; } +.cm-s-shadowfox span.cm-link { color: #737373; } +.cm-s-shadowfox span.cm-operator { color: #b1b1b3; } +.cm-s-shadowfox span.cm-special { color: #d7d7db; } + +.cm-s-shadowfox .CodeMirror-activeline-background { background: rgba(185, 215, 253, .15) } +.cm-s-shadowfox .CodeMirror-matchingbracket { outline: solid 1px rgba(255, 255, 255, .25); color: white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/solarized.css b/adminx/assets/vendor/codemirror/theme/solarized.css new file mode 100644 index 0000000..fcd1d70 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/solarized.css @@ -0,0 +1,168 @@ +/* +Solarized theme for code-mirror +http://ethanschoonover.com/solarized +*/ + +/* +Solarized color palette +http://ethanschoonover.com/solarized/img/solarized-palette.png +*/ + +.solarized.base03 { color: #002b36; } +.solarized.base02 { color: #073642; } +.solarized.base01 { color: #586e75; } +.solarized.base00 { color: #657b83; } +.solarized.base0 { color: #839496; } +.solarized.base1 { color: #93a1a1; } +.solarized.base2 { color: #eee8d5; } +.solarized.base3 { color: #fdf6e3; } +.solarized.solar-yellow { color: #b58900; } +.solarized.solar-orange { color: #cb4b16; } +.solarized.solar-red { color: #dc322f; } +.solarized.solar-magenta { color: #d33682; } +.solarized.solar-violet { color: #6c71c4; } +.solarized.solar-blue { color: #268bd2; } +.solarized.solar-cyan { color: #2aa198; } +.solarized.solar-green { color: #859900; } + +/* Color scheme for code-mirror */ + +.cm-s-solarized { + line-height: 1.45em; + color-profile: sRGB; + rendering-intent: auto; +} +.cm-s-solarized.cm-s-dark { + color: #839496; + background-color: #002b36; + text-shadow: #002b36 0 1px; +} +.cm-s-solarized.cm-s-light { + background-color: #fdf6e3; + color: #657b83; + text-shadow: #eee8d5 0 1px; +} + +.cm-s-solarized .CodeMirror-widget { + text-shadow: none; +} + +.cm-s-solarized .cm-header { color: #586e75; } +.cm-s-solarized .cm-quote { color: #93a1a1; } + +.cm-s-solarized .cm-keyword { color: #cb4b16; } +.cm-s-solarized .cm-atom { color: #d33682; } +.cm-s-solarized .cm-number { color: #d33682; } +.cm-s-solarized .cm-def { color: #2aa198; } + +.cm-s-solarized .cm-variable { color: #839496; } +.cm-s-solarized .cm-variable-2 { color: #b58900; } +.cm-s-solarized .cm-variable-3, .cm-s-solarized .cm-type { color: #6c71c4; } + +.cm-s-solarized .cm-property { color: #2aa198; } +.cm-s-solarized .cm-operator { color: #6c71c4; } + +.cm-s-solarized .cm-comment { color: #586e75; font-style:italic; } + +.cm-s-solarized .cm-string { color: #859900; } +.cm-s-solarized .cm-string-2 { color: #b58900; } + +.cm-s-solarized .cm-meta { color: #859900; } +.cm-s-solarized .cm-qualifier { color: #b58900; } +.cm-s-solarized .cm-builtin { color: #d33682; } +.cm-s-solarized .cm-bracket { color: #cb4b16; } +.cm-s-solarized .CodeMirror-matchingbracket { color: #859900; } +.cm-s-solarized .CodeMirror-nonmatchingbracket { color: #dc322f; } +.cm-s-solarized .cm-tag { color: #93a1a1; } +.cm-s-solarized .cm-attribute { color: #2aa198; } +.cm-s-solarized .cm-hr { + color: transparent; + border-top: 1px solid #586e75; + display: block; +} +.cm-s-solarized .cm-link { color: #93a1a1; cursor: pointer; } +.cm-s-solarized .cm-special { color: #6c71c4; } +.cm-s-solarized .cm-em { + color: #999; + text-decoration: underline; + text-decoration-style: dotted; +} +.cm-s-solarized .cm-error, +.cm-s-solarized .cm-invalidchar { + color: #586e75; + border-bottom: 1px dotted #dc322f; +} + +.cm-s-solarized.cm-s-dark div.CodeMirror-selected { background: #073642; } +.cm-s-solarized.cm-s-dark.CodeMirror ::selection { background: rgba(7, 54, 66, 0.99); } +.cm-s-solarized.cm-s-dark .CodeMirror-line::-moz-selection, .cm-s-dark .CodeMirror-line > span::-moz-selection, .cm-s-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(7, 54, 66, 0.99); } + +.cm-s-solarized.cm-s-light div.CodeMirror-selected { background: #eee8d5; } +.cm-s-solarized.cm-s-light .CodeMirror-line::selection, .cm-s-light .CodeMirror-line > span::selection, .cm-s-light .CodeMirror-line > span > span::selection { background: #eee8d5; } +.cm-s-solarized.cm-s-light .CodeMirror-line::-moz-selection, .cm-s-ligh .CodeMirror-line > span::-moz-selection, .cm-s-ligh .CodeMirror-line > span > span::-moz-selection { background: #eee8d5; } + +/* Editor styling */ + + + +/* Little shadow on the view-port of the buffer view */ +.cm-s-solarized.CodeMirror { + -moz-box-shadow: inset 7px 0 12px -6px #000; + -webkit-box-shadow: inset 7px 0 12px -6px #000; + box-shadow: inset 7px 0 12px -6px #000; +} + +/* Remove gutter border */ +.cm-s-solarized .CodeMirror-gutters { + border-right: 0; +} + +/* Gutter colors and line number styling based of color scheme (dark / light) */ + +/* Dark */ +.cm-s-solarized.cm-s-dark .CodeMirror-gutters { + background-color: #073642; +} + +.cm-s-solarized.cm-s-dark .CodeMirror-linenumber { + color: #586e75; + text-shadow: #021014 0 -1px; +} + +/* Light */ +.cm-s-solarized.cm-s-light .CodeMirror-gutters { + background-color: #eee8d5; +} + +.cm-s-solarized.cm-s-light .CodeMirror-linenumber { + color: #839496; +} + +/* Common */ +.cm-s-solarized .CodeMirror-linenumber { + padding: 0 5px; +} +.cm-s-solarized .CodeMirror-guttermarker-subtle { color: #586e75; } +.cm-s-solarized.cm-s-dark .CodeMirror-guttermarker { color: #ddd; } +.cm-s-solarized.cm-s-light .CodeMirror-guttermarker { color: #cb4b16; } + +.cm-s-solarized .CodeMirror-gutter .CodeMirror-gutter-text { + color: #586e75; +} + +/* Cursor */ +.cm-s-solarized .CodeMirror-cursor { border-left: 1px solid #819090; } + +/* Fat cursor */ +.cm-s-solarized.cm-s-light.cm-fat-cursor .CodeMirror-cursor { background: #77ee77; } +.cm-s-solarized.cm-s-light .cm-animate-fat-cursor { background-color: #77ee77; } +.cm-s-solarized.cm-s-dark.cm-fat-cursor .CodeMirror-cursor { background: #586e75; } +.cm-s-solarized.cm-s-dark .cm-animate-fat-cursor { background-color: #586e75; } + +/* Active line */ +.cm-s-solarized.cm-s-dark .CodeMirror-activeline-background { + background: rgba(255, 255, 255, 0.06); +} +.cm-s-solarized.cm-s-light .CodeMirror-activeline-background { + background: rgba(0, 0, 0, 0.06); +} diff --git a/adminx/assets/vendor/codemirror/theme/ssms.css b/adminx/assets/vendor/codemirror/theme/ssms.css new file mode 100644 index 0000000..9494c14 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/ssms.css @@ -0,0 +1,16 @@ +.cm-s-ssms span.cm-keyword { color: blue; } +.cm-s-ssms span.cm-comment { color: darkgreen; } +.cm-s-ssms span.cm-string { color: red; } +.cm-s-ssms span.cm-def { color: black; } +.cm-s-ssms span.cm-variable { color: black; } +.cm-s-ssms span.cm-variable-2 { color: black; } +.cm-s-ssms span.cm-atom { color: darkgray; } +.cm-s-ssms .CodeMirror-linenumber { color: teal; } +.cm-s-ssms .CodeMirror-activeline-background { background: #ffffff; } +.cm-s-ssms span.cm-string-2 { color: #FF00FF; } +.cm-s-ssms span.cm-operator, +.cm-s-ssms span.cm-bracket, +.cm-s-ssms span.cm-punctuation { color: darkgray; } +.cm-s-ssms .CodeMirror-gutters { border-right: 3px solid #ffee62; background-color: #ffffff; } +.cm-s-ssms div.CodeMirror-selected { background: #ADD6FF; } + diff --git a/adminx/assets/vendor/codemirror/theme/the-matrix.css b/adminx/assets/vendor/codemirror/theme/the-matrix.css new file mode 100644 index 0000000..c4c93c1 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/the-matrix.css @@ -0,0 +1,30 @@ +.cm-s-the-matrix.CodeMirror { background: #000000; color: #00FF00; } +.cm-s-the-matrix div.CodeMirror-selected { background: #2D2D2D; } +.cm-s-the-matrix .CodeMirror-line::selection, .cm-s-the-matrix .CodeMirror-line > span::selection, .cm-s-the-matrix .CodeMirror-line > span > span::selection { background: rgba(45, 45, 45, 0.99); } +.cm-s-the-matrix .CodeMirror-line::-moz-selection, .cm-s-the-matrix .CodeMirror-line > span::-moz-selection, .cm-s-the-matrix .CodeMirror-line > span > span::-moz-selection { background: rgba(45, 45, 45, 0.99); } +.cm-s-the-matrix .CodeMirror-gutters { background: #060; border-right: 2px solid #00FF00; } +.cm-s-the-matrix .CodeMirror-guttermarker { color: #0f0; } +.cm-s-the-matrix .CodeMirror-guttermarker-subtle { color: white; } +.cm-s-the-matrix .CodeMirror-linenumber { color: #FFFFFF; } +.cm-s-the-matrix .CodeMirror-cursor { border-left: 1px solid #00FF00; } + +.cm-s-the-matrix span.cm-keyword { color: #008803; font-weight: bold; } +.cm-s-the-matrix span.cm-atom { color: #3FF; } +.cm-s-the-matrix span.cm-number { color: #FFB94F; } +.cm-s-the-matrix span.cm-def { color: #99C; } +.cm-s-the-matrix span.cm-variable { color: #F6C; } +.cm-s-the-matrix span.cm-variable-2 { color: #C6F; } +.cm-s-the-matrix span.cm-variable-3, .cm-s-the-matrix span.cm-type { color: #96F; } +.cm-s-the-matrix span.cm-property { color: #62FFA0; } +.cm-s-the-matrix span.cm-operator { color: #999; } +.cm-s-the-matrix span.cm-comment { color: #CCCCCC; } +.cm-s-the-matrix span.cm-string { color: #39C; } +.cm-s-the-matrix span.cm-meta { color: #C9F; } +.cm-s-the-matrix span.cm-qualifier { color: #FFF700; } +.cm-s-the-matrix span.cm-builtin { color: #30a; } +.cm-s-the-matrix span.cm-bracket { color: #cc7; } +.cm-s-the-matrix span.cm-tag { color: #FFBD40; } +.cm-s-the-matrix span.cm-attribute { color: #FFF700; } +.cm-s-the-matrix span.cm-error { color: #FF0000; } + +.cm-s-the-matrix .CodeMirror-activeline-background { background: #040; } diff --git a/adminx/assets/vendor/codemirror/theme/tomorrow-night-bright.css b/adminx/assets/vendor/codemirror/theme/tomorrow-night-bright.css new file mode 100644 index 0000000..b6dd4a9 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/tomorrow-night-bright.css @@ -0,0 +1,35 @@ +/* + + Name: Tomorrow Night - Bright + Author: Chris Kempson + + Port done by Gerard Braad + +*/ + +.cm-s-tomorrow-night-bright.CodeMirror { background: #000000; color: #eaeaea; } +.cm-s-tomorrow-night-bright div.CodeMirror-selected { background: #424242; } +.cm-s-tomorrow-night-bright .CodeMirror-gutters { background: #000000; border-right: 0px; } +.cm-s-tomorrow-night-bright .CodeMirror-guttermarker { color: #e78c45; } +.cm-s-tomorrow-night-bright .CodeMirror-guttermarker-subtle { color: #777; } +.cm-s-tomorrow-night-bright .CodeMirror-linenumber { color: #424242; } +.cm-s-tomorrow-night-bright .CodeMirror-cursor { border-left: 1px solid #6A6A6A; } + +.cm-s-tomorrow-night-bright span.cm-comment { color: #d27b53; } +.cm-s-tomorrow-night-bright span.cm-atom { color: #a16a94; } +.cm-s-tomorrow-night-bright span.cm-number { color: #a16a94; } + +.cm-s-tomorrow-night-bright span.cm-property, .cm-s-tomorrow-night-bright span.cm-attribute { color: #99cc99; } +.cm-s-tomorrow-night-bright span.cm-keyword { color: #d54e53; } +.cm-s-tomorrow-night-bright span.cm-string { color: #e7c547; } + +.cm-s-tomorrow-night-bright span.cm-variable { color: #b9ca4a; } +.cm-s-tomorrow-night-bright span.cm-variable-2 { color: #7aa6da; } +.cm-s-tomorrow-night-bright span.cm-def { color: #e78c45; } +.cm-s-tomorrow-night-bright span.cm-bracket { color: #eaeaea; } +.cm-s-tomorrow-night-bright span.cm-tag { color: #d54e53; } +.cm-s-tomorrow-night-bright span.cm-link { color: #a16a94; } +.cm-s-tomorrow-night-bright span.cm-error { background: #d54e53; color: #6A6A6A; } + +.cm-s-tomorrow-night-bright .CodeMirror-activeline-background { background: #2a2a2a; } +.cm-s-tomorrow-night-bright .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/tomorrow-night-eighties.css b/adminx/assets/vendor/codemirror/theme/tomorrow-night-eighties.css new file mode 100644 index 0000000..2a9debc --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/tomorrow-night-eighties.css @@ -0,0 +1,38 @@ +/* + + Name: Tomorrow Night - Eighties + Author: Chris Kempson + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-tomorrow-night-eighties.CodeMirror { background: #000000; color: #CCCCCC; } +.cm-s-tomorrow-night-eighties div.CodeMirror-selected { background: #2D2D2D; } +.cm-s-tomorrow-night-eighties .CodeMirror-line::selection, .cm-s-tomorrow-night-eighties .CodeMirror-line > span::selection, .cm-s-tomorrow-night-eighties .CodeMirror-line > span > span::selection { background: rgba(45, 45, 45, 0.99); } +.cm-s-tomorrow-night-eighties .CodeMirror-line::-moz-selection, .cm-s-tomorrow-night-eighties .CodeMirror-line > span::-moz-selection, .cm-s-tomorrow-night-eighties .CodeMirror-line > span > span::-moz-selection { background: rgba(45, 45, 45, 0.99); } +.cm-s-tomorrow-night-eighties .CodeMirror-gutters { background: #000000; border-right: 0px; } +.cm-s-tomorrow-night-eighties .CodeMirror-guttermarker { color: #f2777a; } +.cm-s-tomorrow-night-eighties .CodeMirror-guttermarker-subtle { color: #777; } +.cm-s-tomorrow-night-eighties .CodeMirror-linenumber { color: #515151; } +.cm-s-tomorrow-night-eighties .CodeMirror-cursor { border-left: 1px solid #6A6A6A; } + +.cm-s-tomorrow-night-eighties span.cm-comment { color: #d27b53; } +.cm-s-tomorrow-night-eighties span.cm-atom { color: #a16a94; } +.cm-s-tomorrow-night-eighties span.cm-number { color: #a16a94; } + +.cm-s-tomorrow-night-eighties span.cm-property, .cm-s-tomorrow-night-eighties span.cm-attribute { color: #99cc99; } +.cm-s-tomorrow-night-eighties span.cm-keyword { color: #f2777a; } +.cm-s-tomorrow-night-eighties span.cm-string { color: #ffcc66; } + +.cm-s-tomorrow-night-eighties span.cm-variable { color: #99cc99; } +.cm-s-tomorrow-night-eighties span.cm-variable-2 { color: #6699cc; } +.cm-s-tomorrow-night-eighties span.cm-def { color: #f99157; } +.cm-s-tomorrow-night-eighties span.cm-bracket { color: #CCCCCC; } +.cm-s-tomorrow-night-eighties span.cm-tag { color: #f2777a; } +.cm-s-tomorrow-night-eighties span.cm-link { color: #a16a94; } +.cm-s-tomorrow-night-eighties span.cm-error { background: #f2777a; color: #6A6A6A; } + +.cm-s-tomorrow-night-eighties .CodeMirror-activeline-background { background: #343600; } +.cm-s-tomorrow-night-eighties .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/ttcn.css b/adminx/assets/vendor/codemirror/theme/ttcn.css new file mode 100644 index 0000000..0b14ac3 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/ttcn.css @@ -0,0 +1,64 @@ +.cm-s-ttcn .cm-quote { color: #090; } +.cm-s-ttcn .cm-negative { color: #d44; } +.cm-s-ttcn .cm-positive { color: #292; } +.cm-s-ttcn .cm-header, .cm-strong { font-weight: bold; } +.cm-s-ttcn .cm-em { font-style: italic; } +.cm-s-ttcn .cm-link { text-decoration: underline; } +.cm-s-ttcn .cm-strikethrough { text-decoration: line-through; } +.cm-s-ttcn .cm-header { color: #00f; font-weight: bold; } + +.cm-s-ttcn .cm-atom { color: #219; } +.cm-s-ttcn .cm-attribute { color: #00c; } +.cm-s-ttcn .cm-bracket { color: #997; } +.cm-s-ttcn .cm-comment { color: #333333; } +.cm-s-ttcn .cm-def { color: #00f; } +.cm-s-ttcn .cm-em { font-style: italic; } +.cm-s-ttcn .cm-error { color: #f00; } +.cm-s-ttcn .cm-hr { color: #999; } +.cm-s-ttcn .cm-invalidchar { color: #f00; } +.cm-s-ttcn .cm-keyword { font-weight:bold; } +.cm-s-ttcn .cm-link { color: #00c; text-decoration: underline; } +.cm-s-ttcn .cm-meta { color: #555; } +.cm-s-ttcn .cm-negative { color: #d44; } +.cm-s-ttcn .cm-positive { color: #292; } +.cm-s-ttcn .cm-qualifier { color: #555; } +.cm-s-ttcn .cm-strikethrough { text-decoration: line-through; } +.cm-s-ttcn .cm-string { color: #006400; } +.cm-s-ttcn .cm-string-2 { color: #f50; } +.cm-s-ttcn .cm-strong { font-weight: bold; } +.cm-s-ttcn .cm-tag { color: #170; } +.cm-s-ttcn .cm-variable { color: #8B2252; } +.cm-s-ttcn .cm-variable-2 { color: #05a; } +.cm-s-ttcn .cm-variable-3, .cm-s-ttcn .cm-type { color: #085; } + +.cm-s-ttcn .cm-invalidchar { color: #f00; } + +/* ASN */ +.cm-s-ttcn .cm-accessTypes, +.cm-s-ttcn .cm-compareTypes { color: #27408B; } +.cm-s-ttcn .cm-cmipVerbs { color: #8B2252; } +.cm-s-ttcn .cm-modifier { color:#D2691E; } +.cm-s-ttcn .cm-status { color:#8B4545; } +.cm-s-ttcn .cm-storage { color:#A020F0; } +.cm-s-ttcn .cm-tags { color:#006400; } + +/* CFG */ +.cm-s-ttcn .cm-externalCommands { color: #8B4545; font-weight:bold; } +.cm-s-ttcn .cm-fileNCtrlMaskOptions, +.cm-s-ttcn .cm-sectionTitle { color: #2E8B57; font-weight:bold; } + +/* TTCN */ +.cm-s-ttcn .cm-booleanConsts, +.cm-s-ttcn .cm-otherConsts, +.cm-s-ttcn .cm-verdictConsts { color: #006400; } +.cm-s-ttcn .cm-configOps, +.cm-s-ttcn .cm-functionOps, +.cm-s-ttcn .cm-portOps, +.cm-s-ttcn .cm-sutOps, +.cm-s-ttcn .cm-timerOps, +.cm-s-ttcn .cm-verdictOps { color: #0000FF; } +.cm-s-ttcn .cm-preprocessor, +.cm-s-ttcn .cm-templateMatch, +.cm-s-ttcn .cm-ttcn3Macros { color: #27408B; } +.cm-s-ttcn .cm-types { color: #A52A2A; font-weight:bold; } +.cm-s-ttcn .cm-visibilityModifiers { font-weight:bold; } diff --git a/adminx/assets/vendor/codemirror/theme/twilight.css b/adminx/assets/vendor/codemirror/theme/twilight.css new file mode 100644 index 0000000..b2b1b2a --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/twilight.css @@ -0,0 +1,32 @@ +.cm-s-twilight.CodeMirror { background: #141414; color: #f7f7f7; } /**/ +.cm-s-twilight div.CodeMirror-selected { background: #323232; } /**/ +.cm-s-twilight .CodeMirror-line::selection, .cm-s-twilight .CodeMirror-line > span::selection, .cm-s-twilight .CodeMirror-line > span > span::selection { background: rgba(50, 50, 50, 0.99); } +.cm-s-twilight .CodeMirror-line::-moz-selection, .cm-s-twilight .CodeMirror-line > span::-moz-selection, .cm-s-twilight .CodeMirror-line > span > span::-moz-selection { background: rgba(50, 50, 50, 0.99); } + +.cm-s-twilight .CodeMirror-gutters { background: #222; border-right: 1px solid #aaa; } +.cm-s-twilight .CodeMirror-guttermarker { color: white; } +.cm-s-twilight .CodeMirror-guttermarker-subtle { color: #aaa; } +.cm-s-twilight .CodeMirror-linenumber { color: #aaa; } +.cm-s-twilight .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-twilight .cm-keyword { color: #f9ee98; } /**/ +.cm-s-twilight .cm-atom { color: #FC0; } +.cm-s-twilight .cm-number { color: #ca7841; } /**/ +.cm-s-twilight .cm-def { color: #8DA6CE; } +.cm-s-twilight span.cm-variable-2, .cm-s-twilight span.cm-tag { color: #607392; } /**/ +.cm-s-twilight span.cm-variable-3, .cm-s-twilight span.cm-def, .cm-s-twilight span.cm-type { color: #607392; } /**/ +.cm-s-twilight .cm-operator { color: #cda869; } /**/ +.cm-s-twilight .cm-comment { color:#777; font-style:italic; font-weight:normal; } /**/ +.cm-s-twilight .cm-string { color:#8f9d6a; font-style:italic; } /**/ +.cm-s-twilight .cm-string-2 { color:#bd6b18; } /*?*/ +.cm-s-twilight .cm-meta { background-color:#141414; color:#f7f7f7; } /*?*/ +.cm-s-twilight .cm-builtin { color: #cda869; } /*?*/ +.cm-s-twilight .cm-tag { color: #997643; } /**/ +.cm-s-twilight .cm-attribute { color: #d6bb6d; } /*?*/ +.cm-s-twilight .cm-header { color: #FF6400; } +.cm-s-twilight .cm-hr { color: #AEAEAE; } +.cm-s-twilight .cm-link { color:#ad9361; font-style:italic; text-decoration:none; } /**/ +.cm-s-twilight .cm-error { border-bottom: 1px solid red; } + +.cm-s-twilight .CodeMirror-activeline-background { background: #27282E; } +.cm-s-twilight .CodeMirror-matchingbracket { outline:1px solid grey; color:white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/vibrant-ink.css b/adminx/assets/vendor/codemirror/theme/vibrant-ink.css new file mode 100644 index 0000000..6358ad3 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/vibrant-ink.css @@ -0,0 +1,34 @@ +/* Taken from the popular Visual Studio Vibrant Ink Schema */ + +.cm-s-vibrant-ink.CodeMirror { background: black; color: white; } +.cm-s-vibrant-ink div.CodeMirror-selected { background: #35493c; } +.cm-s-vibrant-ink .CodeMirror-line::selection, .cm-s-vibrant-ink .CodeMirror-line > span::selection, .cm-s-vibrant-ink .CodeMirror-line > span > span::selection { background: rgba(53, 73, 60, 0.99); } +.cm-s-vibrant-ink .CodeMirror-line::-moz-selection, .cm-s-vibrant-ink .CodeMirror-line > span::-moz-selection, .cm-s-vibrant-ink .CodeMirror-line > span > span::-moz-selection { background: rgba(53, 73, 60, 0.99); } + +.cm-s-vibrant-ink .CodeMirror-gutters { background: #002240; border-right: 1px solid #aaa; } +.cm-s-vibrant-ink .CodeMirror-guttermarker { color: white; } +.cm-s-vibrant-ink .CodeMirror-guttermarker-subtle { color: #d0d0d0; } +.cm-s-vibrant-ink .CodeMirror-linenumber { color: #d0d0d0; } +.cm-s-vibrant-ink .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-vibrant-ink .cm-keyword { color: #CC7832; } +.cm-s-vibrant-ink .cm-atom { color: #FC0; } +.cm-s-vibrant-ink .cm-number { color: #FFEE98; } +.cm-s-vibrant-ink .cm-def { color: #8DA6CE; } +.cm-s-vibrant-ink span.cm-variable-2, .cm-s-vibrant span.cm-tag { color: #FFC66D; } +.cm-s-vibrant-ink span.cm-variable-3, .cm-s-vibrant span.cm-def, .cm-s-vibrant span.cm-type { color: #FFC66D; } +.cm-s-vibrant-ink .cm-operator { color: #888; } +.cm-s-vibrant-ink .cm-comment { color: gray; font-weight: bold; } +.cm-s-vibrant-ink .cm-string { color: #A5C25C; } +.cm-s-vibrant-ink .cm-string-2 { color: red; } +.cm-s-vibrant-ink .cm-meta { color: #D8FA3C; } +.cm-s-vibrant-ink .cm-builtin { color: #8DA6CE; } +.cm-s-vibrant-ink .cm-tag { color: #8DA6CE; } +.cm-s-vibrant-ink .cm-attribute { color: #8DA6CE; } +.cm-s-vibrant-ink .cm-header { color: #FF6400; } +.cm-s-vibrant-ink .cm-hr { color: #AEAEAE; } +.cm-s-vibrant-ink .cm-link { color: #5656F3; } +.cm-s-vibrant-ink .cm-error { border-bottom: 1px solid red; } + +.cm-s-vibrant-ink .CodeMirror-activeline-background { background: #27282E; } +.cm-s-vibrant-ink .CodeMirror-matchingbracket { outline:1px solid grey; color:white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/xq-dark.css b/adminx/assets/vendor/codemirror/theme/xq-dark.css new file mode 100644 index 0000000..7da1a0f --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/xq-dark.css @@ -0,0 +1,53 @@ +/* +Copyright (C) 2011 by MarkLogic Corporation +Author: Mike Brevoort + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +.cm-s-xq-dark.CodeMirror { background: #0a001f; color: #f8f8f8; } +.cm-s-xq-dark div.CodeMirror-selected { background: #27007A; } +.cm-s-xq-dark .CodeMirror-line::selection, .cm-s-xq-dark .CodeMirror-line > span::selection, .cm-s-xq-dark .CodeMirror-line > span > span::selection { background: rgba(39, 0, 122, 0.99); } +.cm-s-xq-dark .CodeMirror-line::-moz-selection, .cm-s-xq-dark .CodeMirror-line > span::-moz-selection, .cm-s-xq-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(39, 0, 122, 0.99); } +.cm-s-xq-dark .CodeMirror-gutters { background: #0a001f; border-right: 1px solid #aaa; } +.cm-s-xq-dark .CodeMirror-guttermarker { color: #FFBD40; } +.cm-s-xq-dark .CodeMirror-guttermarker-subtle { color: #f8f8f8; } +.cm-s-xq-dark .CodeMirror-linenumber { color: #f8f8f8; } +.cm-s-xq-dark .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-xq-dark span.cm-keyword { color: #FFBD40; } +.cm-s-xq-dark span.cm-atom { color: #6C8CD5; } +.cm-s-xq-dark span.cm-number { color: #164; } +.cm-s-xq-dark span.cm-def { color: #FFF; text-decoration:underline; } +.cm-s-xq-dark span.cm-variable { color: #FFF; } +.cm-s-xq-dark span.cm-variable-2 { color: #EEE; } +.cm-s-xq-dark span.cm-variable-3, .cm-s-xq-dark span.cm-type { color: #DDD; } +.cm-s-xq-dark span.cm-property {} +.cm-s-xq-dark span.cm-operator {} +.cm-s-xq-dark span.cm-comment { color: gray; } +.cm-s-xq-dark span.cm-string { color: #9FEE00; } +.cm-s-xq-dark span.cm-meta { color: yellow; } +.cm-s-xq-dark span.cm-qualifier { color: #FFF700; } +.cm-s-xq-dark span.cm-builtin { color: #30a; } +.cm-s-xq-dark span.cm-bracket { color: #cc7; } +.cm-s-xq-dark span.cm-tag { color: #FFBD40; } +.cm-s-xq-dark span.cm-attribute { color: #FFF700; } +.cm-s-xq-dark span.cm-error { color: #f00; } + +.cm-s-xq-dark .CodeMirror-activeline-background { background: #27282E; } +.cm-s-xq-dark .CodeMirror-matchingbracket { outline:1px solid grey; color:white !important; } diff --git a/adminx/assets/vendor/codemirror/theme/xq-light.css b/adminx/assets/vendor/codemirror/theme/xq-light.css new file mode 100644 index 0000000..7b182ea --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/xq-light.css @@ -0,0 +1,43 @@ +/* +Copyright (C) 2011 by MarkLogic Corporation +Author: Mike Brevoort + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +.cm-s-xq-light span.cm-keyword { line-height: 1em; font-weight: bold; color: #5A5CAD; } +.cm-s-xq-light span.cm-atom { color: #6C8CD5; } +.cm-s-xq-light span.cm-number { color: #164; } +.cm-s-xq-light span.cm-def { text-decoration:underline; } +.cm-s-xq-light span.cm-variable { color: black; } +.cm-s-xq-light span.cm-variable-2 { color:black; } +.cm-s-xq-light span.cm-variable-3, .cm-s-xq-light span.cm-type { color: black; } +.cm-s-xq-light span.cm-property {} +.cm-s-xq-light span.cm-operator {} +.cm-s-xq-light span.cm-comment { color: #0080FF; font-style: italic; } +.cm-s-xq-light span.cm-string { color: red; } +.cm-s-xq-light span.cm-meta { color: yellow; } +.cm-s-xq-light span.cm-qualifier { color: grey; } +.cm-s-xq-light span.cm-builtin { color: #7EA656; } +.cm-s-xq-light span.cm-bracket { color: #cc7; } +.cm-s-xq-light span.cm-tag { color: #3F7F7F; } +.cm-s-xq-light span.cm-attribute { color: #7F007F; } +.cm-s-xq-light span.cm-error { color: #f00; } + +.cm-s-xq-light .CodeMirror-activeline-background { background: #e8f2ff; } +.cm-s-xq-light .CodeMirror-matchingbracket { outline:1px solid grey;color:black !important;background:yellow; } diff --git a/adminx/assets/vendor/codemirror/theme/yeti.css b/adminx/assets/vendor/codemirror/theme/yeti.css new file mode 100644 index 0000000..d085f72 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/yeti.css @@ -0,0 +1,44 @@ +/* + + Name: yeti + Author: Michael Kaminsky (http://github.com/mkaminsky11) + + Original yeti color scheme by Jesse Weed (https://github.com/jesseweed/yeti-syntax) + +*/ + + +.cm-s-yeti.CodeMirror { + background-color: #ECEAE8 !important; + color: #d1c9c0 !important; + border: none; +} + +.cm-s-yeti .CodeMirror-gutters { + color: #adaba6; + background-color: #E5E1DB; + border: none; +} +.cm-s-yeti .CodeMirror-cursor { border-left: solid thin #d1c9c0; } +.cm-s-yeti .CodeMirror-linenumber { color: #adaba6; } +.cm-s-yeti.CodeMirror-focused div.CodeMirror-selected { background: #DCD8D2; } +.cm-s-yeti .CodeMirror-line::selection, .cm-s-yeti .CodeMirror-line > span::selection, .cm-s-yeti .CodeMirror-line > span > span::selection { background: #DCD8D2; } +.cm-s-yeti .CodeMirror-line::-moz-selection, .cm-s-yeti .CodeMirror-line > span::-moz-selection, .cm-s-yeti .CodeMirror-line > span > span::-moz-selection { background: #DCD8D2; } +.cm-s-yeti span.cm-comment { color: #d4c8be; } +.cm-s-yeti span.cm-string, .cm-s-yeti span.cm-string-2 { color: #96c0d8; } +.cm-s-yeti span.cm-number { color: #a074c4; } +.cm-s-yeti span.cm-variable { color: #55b5db; } +.cm-s-yeti span.cm-variable-2 { color: #a074c4; } +.cm-s-yeti span.cm-def { color: #55b5db; } +.cm-s-yeti span.cm-operator { color: #9fb96e; } +.cm-s-yeti span.cm-keyword { color: #9fb96e; } +.cm-s-yeti span.cm-atom { color: #a074c4; } +.cm-s-yeti span.cm-meta { color: #96c0d8; } +.cm-s-yeti span.cm-tag { color: #96c0d8; } +.cm-s-yeti span.cm-attribute { color: #9fb96e; } +.cm-s-yeti span.cm-qualifier { color: #96c0d8; } +.cm-s-yeti span.cm-property { color: #a074c4; } +.cm-s-yeti span.cm-builtin { color: #a074c4; } +.cm-s-yeti span.cm-variable-3, .cm-s-yeti span.cm-type { color: #96c0d8; } +.cm-s-yeti .CodeMirror-activeline-background { background: #E7E4E0; } +.cm-s-yeti .CodeMirror-matchingbracket { text-decoration: underline; } diff --git a/adminx/assets/vendor/codemirror/theme/yonce.css b/adminx/assets/vendor/codemirror/theme/yonce.css new file mode 100644 index 0000000..975f078 --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/yonce.css @@ -0,0 +1,59 @@ +/* + + Name: yoncé + Author: Thomas MacLean (http://github.com/thomasmaclean) + + Original yoncé color scheme by Mina Markham (https://github.com/minamarkham) + +*/ + +.cm-s-yonce.CodeMirror { background: #1C1C1C; color: #d4d4d4; } /**/ +.cm-s-yonce div.CodeMirror-selected { background: rgba(252, 69, 133, 0.478); } /**/ +.cm-s-yonce .CodeMirror-selectedtext, +.cm-s-yonce .CodeMirror-selected, +.cm-s-yonce .CodeMirror-line::selection, +.cm-s-yonce .CodeMirror-line > span::selection, +.cm-s-yonce .CodeMirror-line > span > span::selection, +.cm-s-yonce .CodeMirror-line::-moz-selection, +.cm-s-yonce .CodeMirror-line > span::-moz-selection, +.cm-s-yonce .CodeMirror-line > span > span::-moz-selection { background: rgba(252, 67, 132, 0.47); } + +.cm-s-yonce.CodeMirror pre { padding-left: 0px; } +.cm-s-yonce .CodeMirror-gutters {background: #1C1C1C; border-right: 0px;} +.cm-s-yonce .CodeMirror-linenumber {color: #777777; padding-right: 10px; } +.cm-s-yonce .CodeMirror-activeline .CodeMirror-linenumber.CodeMirror-gutter-elt { background: #1C1C1C; color: #fc4384; } +.cm-s-yonce .CodeMirror-linenumber { color: #777; } +.cm-s-yonce .CodeMirror-cursor { border-left: 2px solid #FC4384; } +.cm-s-yonce .cm-searching { background: rgba(243, 155, 53, .3) !important; outline: 1px solid #F39B35; } +.cm-s-yonce .cm-searching.CodeMirror-selectedtext { background: rgba(243, 155, 53, .7) !important; color: white; } + +.cm-s-yonce .cm-keyword { color: #00A7AA; } /**/ +.cm-s-yonce .cm-atom { color: #F39B35; } +.cm-s-yonce .cm-number, .cm-s-yonce span.cm-type { color: #A06FCA; } /**/ +.cm-s-yonce .cm-def { color: #98E342; } +.cm-s-yonce .cm-property, +.cm-s-yonce span.cm-variable { color: #D4D4D4; font-style: italic; } +.cm-s-yonce span.cm-variable-2 { color: #da7dae; font-style: italic; } +.cm-s-yonce span.cm-variable-3 { color: #A06FCA; } +.cm-s-yonce .cm-type.cm-def { color: #FC4384; font-style: normal; text-decoration: underline; } +.cm-s-yonce .cm-property.cm-def { color: #FC4384; font-style: normal; } +.cm-s-yonce .cm-callee { color: #FC4384; font-style: normal; } +.cm-s-yonce .cm-operator { color: #FC4384; } /**/ +.cm-s-yonce .cm-qualifier, +.cm-s-yonce .cm-tag { color: #FC4384; } +.cm-s-yonce .cm-tag.cm-bracket { color: #D4D4D4; } +.cm-s-yonce .cm-attribute { color: #A06FCA; } +.cm-s-yonce .cm-comment { color:#696d70; font-style:italic; font-weight:normal; } /**/ +.cm-s-yonce .cm-comment.cm-tag { color: #FC4384 } +.cm-s-yonce .cm-comment.cm-attribute { color: #D4D4D4; } +.cm-s-yonce .cm-string { color:#E6DB74; } /**/ +.cm-s-yonce .cm-string-2 { color:#F39B35; } /*?*/ +.cm-s-yonce .cm-meta { color: #D4D4D4; background: inherit; } +.cm-s-yonce .cm-builtin { color: #FC4384; } /*?*/ +.cm-s-yonce .cm-header { color: #da7dae; } +.cm-s-yonce .cm-hr { color: #98E342; } +.cm-s-yonce .cm-link { color:#696d70; font-style:italic; text-decoration:none; } /**/ +.cm-s-yonce .cm-error { border-bottom: 1px solid #C42412; } + +.cm-s-yonce .CodeMirror-activeline-background { background: #272727; } +.cm-s-yonce .CodeMirror-matchingbracket { outline:1px solid grey; color:#D4D4D4 !important; } diff --git a/adminx/assets/vendor/codemirror/theme/zenburn.css b/adminx/assets/vendor/codemirror/theme/zenburn.css new file mode 100644 index 0000000..781c40a --- /dev/null +++ b/adminx/assets/vendor/codemirror/theme/zenburn.css @@ -0,0 +1,37 @@ +/** + * " + * Using Zenburn color palette from the Emacs Zenburn Theme + * https://github.com/bbatsov/zenburn-emacs/blob/master/zenburn-theme.el + * + * Also using parts of https://github.com/xavi/coderay-lighttable-theme + * " + * From: https://github.com/wisenomad/zenburn-lighttable-theme/blob/master/zenburn.css + */ + +.cm-s-zenburn .CodeMirror-gutters { background: #3f3f3f !important; } +.cm-s-zenburn .CodeMirror-foldgutter-open, .CodeMirror-foldgutter-folded { color: #999; } +.cm-s-zenburn .CodeMirror-cursor { border-left: 1px solid white; } +.cm-s-zenburn { background-color: #3f3f3f; color: #dcdccc; } +.cm-s-zenburn span.cm-builtin { color: #dcdccc; font-weight: bold; } +.cm-s-zenburn span.cm-comment { color: #7f9f7f; } +.cm-s-zenburn span.cm-keyword { color: #f0dfaf; font-weight: bold; } +.cm-s-zenburn span.cm-atom { color: #bfebbf; } +.cm-s-zenburn span.cm-def { color: #dcdccc; } +.cm-s-zenburn span.cm-variable { color: #dfaf8f; } +.cm-s-zenburn span.cm-variable-2 { color: #dcdccc; } +.cm-s-zenburn span.cm-string { color: #cc9393; } +.cm-s-zenburn span.cm-string-2 { color: #cc9393; } +.cm-s-zenburn span.cm-number { color: #dcdccc; } +.cm-s-zenburn span.cm-tag { color: #93e0e3; } +.cm-s-zenburn span.cm-property { color: #dfaf8f; } +.cm-s-zenburn span.cm-attribute { color: #dfaf8f; } +.cm-s-zenburn span.cm-qualifier { color: #7cb8bb; } +.cm-s-zenburn span.cm-meta { color: #f0dfaf; } +.cm-s-zenburn span.cm-header { color: #f0efd0; } +.cm-s-zenburn span.cm-operator { color: #f0efd0; } +.cm-s-zenburn span.CodeMirror-matchingbracket { box-sizing: border-box; background: transparent; border-bottom: 1px solid; } +.cm-s-zenburn span.CodeMirror-nonmatchingbracket { border-bottom: 1px solid; background: none; } +.cm-s-zenburn .CodeMirror-activeline { background: #000000; } +.cm-s-zenburn .CodeMirror-activeline-background { background: #000000; } +.cm-s-zenburn div.CodeMirror-selected { background: #545454; } +.cm-s-zenburn .CodeMirror-focused div.CodeMirror-selected { background: #4f4f4f; } diff --git a/adminx/assets/vendor/tabler/fonts/tabler-icons.woff2 b/adminx/assets/vendor/tabler/fonts/tabler-icons.woff2 new file mode 100644 index 0000000..66f7cb1 Binary files /dev/null and b/adminx/assets/vendor/tabler/fonts/tabler-icons.woff2 differ diff --git a/adminx/assets/vendor/tabler/tabler-icons.min.css b/adminx/assets/vendor/tabler/tabler-icons.min.css new file mode 100644 index 0000000..3df27c7 --- /dev/null +++ b/adminx/assets/vendor/tabler/tabler-icons.min.css @@ -0,0 +1,4 @@ +/*! + * Tabler Icons 3.24.0 by tabler - https://tabler.io + * License - https://github.com/tabler/tabler-icons/blob/master/LICENSE + */@font-face{font-family:"tabler-icons";font-style:normal;font-weight:400;src:url("./fonts/tabler-icons.woff2?v3.24.0") format("woff2"),url("./fonts/tabler-icons.woff?") format("woff"),url("./fonts/tabler-icons.ttf?v3.24.0") format("truetype")}.ti{font-family:"tabler-icons" !important;speak:none;font-style:normal;font-weight:normal;font-variant:normal;text-transform:none;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.ti-a-b:before{content:"\ec36"}.ti-a-b-2:before{content:"\f25f"}.ti-a-b-off:before{content:"\f0a6"}.ti-abacus:before{content:"\f05c"}.ti-abacus-off:before{content:"\f3b6"}.ti-abc:before{content:"\f567"}.ti-access-point:before{content:"\ed1b"}.ti-access-point-off:before{content:"\ed1a"}.ti-accessible:before{content:"\eba9"}.ti-accessible-filled:before{content:"\f6ea"}.ti-accessible-off:before{content:"\f0a7"}.ti-activity:before{content:"\ed23"}.ti-activity-heartbeat:before{content:"\f0db"}.ti-ad:before{content:"\ea02"}.ti-ad-2:before{content:"\ef1f"}.ti-ad-circle:before{content:"\f79e"}.ti-ad-circle-filled:before{content:"\f7d3"}.ti-ad-circle-off:before{content:"\f79d"}.ti-ad-filled:before{content:"\f6eb"}.ti-ad-off:before{content:"\f3b7"}.ti-address-book:before{content:"\f021"}.ti-address-book-off:before{content:"\f3b8"}.ti-adjustments:before{content:"\ea03"}.ti-adjustments-alt:before{content:"\ec37"}.ti-adjustments-bolt:before{content:"\f7fb"}.ti-adjustments-cancel:before{content:"\f7fc"}.ti-adjustments-check:before{content:"\f7fd"}.ti-adjustments-code:before{content:"\f7fe"}.ti-adjustments-cog:before{content:"\f7ff"}.ti-adjustments-dollar:before{content:"\f800"}.ti-adjustments-down:before{content:"\f801"}.ti-adjustments-exclamation:before{content:"\f802"}.ti-adjustments-filled:before{content:"\f6ec"}.ti-adjustments-heart:before{content:"\f803"}.ti-adjustments-horizontal:before{content:"\ec38"}.ti-adjustments-minus:before{content:"\f804"}.ti-adjustments-off:before{content:"\f0a8"}.ti-adjustments-pause:before{content:"\f805"}.ti-adjustments-pin:before{content:"\f806"}.ti-adjustments-plus:before{content:"\f807"}.ti-adjustments-question:before{content:"\f808"}.ti-adjustments-search:before{content:"\f809"}.ti-adjustments-share:before{content:"\f80a"}.ti-adjustments-spark:before{content:"\ffbe"}.ti-adjustments-star:before{content:"\f80b"}.ti-adjustments-up:before{content:"\f80c"}.ti-adjustments-x:before{content:"\f80d"}.ti-aerial-lift:before{content:"\edfe"}.ti-affiliate:before{content:"\edff"}.ti-affiliate-filled:before{content:"\f6ed"}.ti-ai:before{content:"\fee7"}.ti-air-balloon:before{content:"\f4a6"}.ti-air-conditioning:before{content:"\f3a2"}.ti-air-conditioning-disabled:before{content:"\f542"}.ti-air-traffic-control:before{content:"\fb01"}.ti-alarm:before{content:"\ea04"}.ti-alarm-average:before{content:"\fc9e"}.ti-alarm-filled:before{content:"\f709"}.ti-alarm-minus:before{content:"\f630"}.ti-alarm-minus-filled:before{content:"\f70a"}.ti-alarm-off:before{content:"\f0a9"}.ti-alarm-plus:before{content:"\f631"}.ti-alarm-plus-filled:before{content:"\f70b"}.ti-alarm-smoke:before{content:"\100b6"}.ti-alarm-snooze:before{content:"\f632"}.ti-alarm-snooze-filled:before{content:"\f70c"}.ti-album:before{content:"\f022"}.ti-album-off:before{content:"\f3b9"}.ti-alert-circle:before{content:"\ea05"}.ti-alert-circle-filled:before{content:"\f6ee"}.ti-alert-circle-off:before{content:"\fc65"}.ti-alert-hexagon:before{content:"\f80e"}.ti-alert-hexagon-filled:before{content:"\fa34"}.ti-alert-hexagon-off:before{content:"\fc66"}.ti-alert-octagon:before{content:"\ecc6"}.ti-alert-octagon-filled:before{content:"\f6ef"}.ti-alert-small:before{content:"\f80f"}.ti-alert-small-off:before{content:"\fc67"}.ti-alert-square:before{content:"\f811"}.ti-alert-square-filled:before{content:"\fa35"}.ti-alert-square-rounded:before{content:"\f810"}.ti-alert-square-rounded-filled:before{content:"\fa36"}.ti-alert-square-rounded-off:before{content:"\fc68"}.ti-alert-triangle:before{content:"\ea06"}.ti-alert-triangle-filled:before{content:"\f6f0"}.ti-alert-triangle-off:before{content:"\fc69"}.ti-alien:before{content:"\ebde"}.ti-alien-filled:before{content:"\f70d"}.ti-align-box-bottom-center:before{content:"\f530"}.ti-align-box-bottom-center-filled:before{content:"\f70e"}.ti-align-box-bottom-left:before{content:"\f531"}.ti-align-box-bottom-left-filled:before{content:"\f70f"}.ti-align-box-bottom-right:before{content:"\f532"}.ti-align-box-bottom-right-filled:before{content:"\f710"}.ti-align-box-center-bottom:before{content:"\facb"}.ti-align-box-center-middle:before{content:"\f79f"}.ti-align-box-center-middle-filled:before{content:"\f7d4"}.ti-align-box-center-stretch:before{content:"\facc"}.ti-align-box-center-top:before{content:"\facd"}.ti-align-box-left-bottom:before{content:"\f533"}.ti-align-box-left-bottom-filled:before{content:"\f711"}.ti-align-box-left-middle:before{content:"\f534"}.ti-align-box-left-middle-filled:before{content:"\f712"}.ti-align-box-left-stretch:before{content:"\face"}.ti-align-box-left-top:before{content:"\f535"}.ti-align-box-left-top-filled:before{content:"\f713"}.ti-align-box-right-bottom:before{content:"\f536"}.ti-align-box-right-bottom-filled:before{content:"\f714"}.ti-align-box-right-middle:before{content:"\f537"}.ti-align-box-right-middle-filled:before{content:"\f7d5"}.ti-align-box-right-stretch:before{content:"\facf"}.ti-align-box-right-top:before{content:"\f538"}.ti-align-box-right-top-filled:before{content:"\f715"}.ti-align-box-top-center:before{content:"\f539"}.ti-align-box-top-center-filled:before{content:"\f716"}.ti-align-box-top-left:before{content:"\f53a"}.ti-align-box-top-left-filled:before{content:"\f717"}.ti-align-box-top-right:before{content:"\f53b"}.ti-align-box-top-right-filled:before{content:"\f718"}.ti-align-center:before{content:"\ea07"}.ti-align-justified:before{content:"\ea08"}.ti-align-left:before{content:"\ea09"}.ti-align-left-2:before{content:"\ff00"}.ti-align-right:before{content:"\ea0a"}.ti-align-right-2:before{content:"\feff"}.ti-alpha:before{content:"\f543"}.ti-alphabet-arabic:before{content:"\ff2f"}.ti-alphabet-bangla:before{content:"\ff2e"}.ti-alphabet-cyrillic:before{content:"\f1df"}.ti-alphabet-greek:before{content:"\f1e0"}.ti-alphabet-hebrew:before{content:"\ff2d"}.ti-alphabet-korean:before{content:"\ff2c"}.ti-alphabet-latin:before{content:"\f1e1"}.ti-alphabet-thai:before{content:"\ff2b"}.ti-alt:before{content:"\fc54"}.ti-ambulance:before{content:"\ebf5"}.ti-ampersand:before{content:"\f229"}.ti-analyze:before{content:"\f3a3"}.ti-analyze-filled:before{content:"\f719"}.ti-analyze-off:before{content:"\f3ba"}.ti-anchor:before{content:"\eb76"}.ti-anchor-off:before{content:"\f0f7"}.ti-angle:before{content:"\ef20"}.ti-ankh:before{content:"\f1cd"}.ti-antenna:before{content:"\f094"}.ti-antenna-bars-1:before{content:"\ecc7"}.ti-antenna-bars-2:before{content:"\ecc8"}.ti-antenna-bars-3:before{content:"\ecc9"}.ti-antenna-bars-4:before{content:"\ecca"}.ti-antenna-bars-5:before{content:"\eccb"}.ti-antenna-bars-off:before{content:"\f0aa"}.ti-antenna-off:before{content:"\f3bb"}.ti-aperture:before{content:"\eb58"}.ti-aperture-off:before{content:"\f3bc"}.ti-api:before{content:"\effd"}.ti-api-app:before{content:"\effc"}.ti-api-app-off:before{content:"\f0ab"}.ti-api-off:before{content:"\f0f8"}.ti-app-window:before{content:"\efe6"}.ti-app-window-filled:before{content:"\f71a"}.ti-apple:before{content:"\ef21"}.ti-apple-filled:before{content:"\10017"}.ti-apps:before{content:"\ebb6"}.ti-apps-filled:before{content:"\f6f1"}.ti-apps-off:before{content:"\f0ac"}.ti-archery-arrow:before{content:"\fc55"}.ti-archive:before{content:"\ea0b"}.ti-archive-filled:before{content:"\fa82"}.ti-archive-off:before{content:"\f0ad"}.ti-armchair:before{content:"\ef9e"}.ti-armchair-2:before{content:"\efe7"}.ti-armchair-2-off:before{content:"\f3bd"}.ti-armchair-off:before{content:"\f3be"}.ti-arrow-autofit-content:before{content:"\ef31"}.ti-arrow-autofit-content-filled:before{content:"\f6f2"}.ti-arrow-autofit-down:before{content:"\ef32"}.ti-arrow-autofit-height:before{content:"\ef33"}.ti-arrow-autofit-left:before{content:"\ef34"}.ti-arrow-autofit-right:before{content:"\ef35"}.ti-arrow-autofit-up:before{content:"\ef36"}.ti-arrow-autofit-width:before{content:"\ef37"}.ti-arrow-back:before{content:"\ea0c"}.ti-arrow-back-up:before{content:"\eb77"}.ti-arrow-back-up-double:before{content:"\f9ec"}.ti-arrow-badge-down:before{content:"\f60b"}.ti-arrow-badge-down-filled:before{content:"\f7d6"}.ti-arrow-badge-left:before{content:"\f60c"}.ti-arrow-badge-left-filled:before{content:"\f7d7"}.ti-arrow-badge-right:before{content:"\f60d"}.ti-arrow-badge-right-filled:before{content:"\f7d8"}.ti-arrow-badge-up:before{content:"\f60e"}.ti-arrow-badge-up-filled:before{content:"\f7d9"}.ti-arrow-bar-both:before{content:"\fadd"}.ti-arrow-bar-down:before{content:"\ea0d"}.ti-arrow-bar-left:before{content:"\ea0e"}.ti-arrow-bar-right:before{content:"\ea0f"}.ti-arrow-bar-to-down:before{content:"\ec88"}.ti-arrow-bar-to-left:before{content:"\ec89"}.ti-arrow-bar-to-right:before{content:"\ec8a"}.ti-arrow-bar-to-up:before{content:"\ec8b"}.ti-arrow-bar-up:before{content:"\ea10"}.ti-arrow-bear-left:before{content:"\f045"}.ti-arrow-bear-left-2:before{content:"\f044"}.ti-arrow-bear-right:before{content:"\f047"}.ti-arrow-bear-right-2:before{content:"\f046"}.ti-arrow-big-down:before{content:"\edda"}.ti-arrow-big-down-filled:before{content:"\f6c6"}.ti-arrow-big-down-line:before{content:"\efe8"}.ti-arrow-big-down-line-filled:before{content:"\f6c7"}.ti-arrow-big-down-lines:before{content:"\efe9"}.ti-arrow-big-down-lines-filled:before{content:"\f6c8"}.ti-arrow-big-left:before{content:"\eddb"}.ti-arrow-big-left-filled:before{content:"\f6c9"}.ti-arrow-big-left-line:before{content:"\efea"}.ti-arrow-big-left-line-filled:before{content:"\f6ca"}.ti-arrow-big-left-lines:before{content:"\efeb"}.ti-arrow-big-left-lines-filled:before{content:"\f6cb"}.ti-arrow-big-right:before{content:"\eddc"}.ti-arrow-big-right-filled:before{content:"\f6cc"}.ti-arrow-big-right-line:before{content:"\efec"}.ti-arrow-big-right-line-filled:before{content:"\f6cd"}.ti-arrow-big-right-lines:before{content:"\efed"}.ti-arrow-big-right-lines-filled:before{content:"\f6ce"}.ti-arrow-big-up:before{content:"\eddd"}.ti-arrow-big-up-filled:before{content:"\f6cf"}.ti-arrow-big-up-line:before{content:"\efee"}.ti-arrow-big-up-line-filled:before{content:"\f6d0"}.ti-arrow-big-up-lines:before{content:"\efef"}.ti-arrow-big-up-lines-filled:before{content:"\f6d1"}.ti-arrow-bounce:before{content:"\f3a4"}.ti-arrow-capsule:before{content:"\fade"}.ti-arrow-curve-left:before{content:"\f048"}.ti-arrow-curve-right:before{content:"\f049"}.ti-arrow-down:before{content:"\ea16"}.ti-arrow-down-bar:before{content:"\ed98"}.ti-arrow-down-circle:before{content:"\ea11"}.ti-arrow-down-circle-filled:before{content:"\1003b"}.ti-arrow-down-dashed:before{content:"\1006a"}.ti-arrow-down-from-arc:before{content:"\fd86"}.ti-arrow-down-left:before{content:"\ea13"}.ti-arrow-down-left-circle:before{content:"\ea12"}.ti-arrow-down-rhombus:before{content:"\f61d"}.ti-arrow-down-rhombus-filled:before{content:"\1003a"}.ti-arrow-down-right:before{content:"\ea15"}.ti-arrow-down-right-circle:before{content:"\ea14"}.ti-arrow-down-square:before{content:"\ed9a"}.ti-arrow-down-square-filled:before{content:"\10039"}.ti-arrow-down-tail:before{content:"\ed9b"}.ti-arrow-down-to-arc:before{content:"\fd87"}.ti-arrow-elbow-left:before{content:"\f9ed"}.ti-arrow-elbow-right:before{content:"\f9ee"}.ti-arrow-fork:before{content:"\f04a"}.ti-arrow-forward:before{content:"\ea17"}.ti-arrow-forward-up:before{content:"\eb78"}.ti-arrow-forward-up-double:before{content:"\f9ef"}.ti-arrow-guide:before{content:"\f22a"}.ti-arrow-guide-filled:before{content:"\10038"}.ti-arrow-iteration:before{content:"\f578"}.ti-arrow-left:before{content:"\ea19"}.ti-arrow-left-bar:before{content:"\ed9c"}.ti-arrow-left-circle:before{content:"\ea18"}.ti-arrow-left-circle-filled:before{content:"\10037"}.ti-arrow-left-dashed:before{content:"\10069"}.ti-arrow-left-from-arc:before{content:"\fd88"}.ti-arrow-left-rhombus:before{content:"\f61e"}.ti-arrow-left-rhombus-filled:before{content:"\10036"}.ti-arrow-left-right:before{content:"\f04b"}.ti-arrow-left-square:before{content:"\ed9d"}.ti-arrow-left-square-filled:before{content:"\10035"}.ti-arrow-left-tail:before{content:"\ed9e"}.ti-arrow-left-to-arc:before{content:"\fd89"}.ti-arrow-loop-left:before{content:"\ed9f"}.ti-arrow-loop-left-2:before{content:"\f04c"}.ti-arrow-loop-right:before{content:"\eda0"}.ti-arrow-loop-right-2:before{content:"\f04d"}.ti-arrow-merge:before{content:"\f04e"}.ti-arrow-merge-alt-left:before{content:"\fc9f"}.ti-arrow-merge-alt-right:before{content:"\fca0"}.ti-arrow-merge-both:before{content:"\f23b"}.ti-arrow-merge-left:before{content:"\f23c"}.ti-arrow-merge-right:before{content:"\f23d"}.ti-arrow-move-down:before{content:"\f2ba"}.ti-arrow-move-down-filled:before{content:"\10034"}.ti-arrow-move-left:before{content:"\f2bb"}.ti-arrow-move-left-filled:before{content:"\10033"}.ti-arrow-move-right:before{content:"\f2bc"}.ti-arrow-move-right-filled:before{content:"\10032"}.ti-arrow-move-up:before{content:"\f2bd"}.ti-arrow-move-up-filled:before{content:"\10031"}.ti-arrow-narrow-down:before{content:"\ea1a"}.ti-arrow-narrow-down-dashed:before{content:"\10068"}.ti-arrow-narrow-left:before{content:"\ea1b"}.ti-arrow-narrow-left-dashed:before{content:"\10067"}.ti-arrow-narrow-right:before{content:"\ea1c"}.ti-arrow-narrow-right-dashed:before{content:"\10066"}.ti-arrow-narrow-up:before{content:"\ea1d"}.ti-arrow-narrow-up-dashed:before{content:"\10065"}.ti-arrow-ramp-left:before{content:"\ed3c"}.ti-arrow-ramp-left-2:before{content:"\f04f"}.ti-arrow-ramp-left-3:before{content:"\f050"}.ti-arrow-ramp-right:before{content:"\ed3d"}.ti-arrow-ramp-right-2:before{content:"\f051"}.ti-arrow-ramp-right-3:before{content:"\f052"}.ti-arrow-right:before{content:"\ea1f"}.ti-arrow-right-bar:before{content:"\eda1"}.ti-arrow-right-circle:before{content:"\ea1e"}.ti-arrow-right-circle-filled:before{content:"\10030"}.ti-arrow-right-dashed:before{content:"\10064"}.ti-arrow-right-from-arc:before{content:"\fd8a"}.ti-arrow-right-rhombus:before{content:"\f61f"}.ti-arrow-right-rhombus-filled:before{content:"\1002f"}.ti-arrow-right-square:before{content:"\eda2"}.ti-arrow-right-square-filled:before{content:"\1002e"}.ti-arrow-right-tail:before{content:"\eda3"}.ti-arrow-right-to-arc:before{content:"\fd8b"}.ti-arrow-rotary-first-left:before{content:"\f053"}.ti-arrow-rotary-first-right:before{content:"\f054"}.ti-arrow-rotary-last-left:before{content:"\f055"}.ti-arrow-rotary-last-right:before{content:"\f056"}.ti-arrow-rotary-left:before{content:"\f057"}.ti-arrow-rotary-right:before{content:"\f058"}.ti-arrow-rotary-straight:before{content:"\f059"}.ti-arrow-roundabout-left:before{content:"\f22b"}.ti-arrow-roundabout-right:before{content:"\f22c"}.ti-arrow-sharp-turn-left:before{content:"\f05a"}.ti-arrow-sharp-turn-right:before{content:"\f05b"}.ti-arrow-up:before{content:"\ea25"}.ti-arrow-up-bar:before{content:"\eda4"}.ti-arrow-up-circle:before{content:"\ea20"}.ti-arrow-up-circle-filled:before{content:"\1002d"}.ti-arrow-up-dashed:before{content:"\10063"}.ti-arrow-up-from-arc:before{content:"\fd8c"}.ti-arrow-up-left:before{content:"\ea22"}.ti-arrow-up-left-circle:before{content:"\ea21"}.ti-arrow-up-rhombus:before{content:"\f620"}.ti-arrow-up-rhombus-filled:before{content:"\1002c"}.ti-arrow-up-right:before{content:"\ea24"}.ti-arrow-up-right-circle:before{content:"\ea23"}.ti-arrow-up-square:before{content:"\eda6"}.ti-arrow-up-square-filled:before{content:"\1002b"}.ti-arrow-up-tail:before{content:"\eda7"}.ti-arrow-up-to-arc:before{content:"\fd8d"}.ti-arrow-wave-left-down:before{content:"\eda8"}.ti-arrow-wave-left-up:before{content:"\eda9"}.ti-arrow-wave-right-down:before{content:"\edaa"}.ti-arrow-wave-right-up:before{content:"\edab"}.ti-arrow-zig-zag:before{content:"\f4a7"}.ti-arrows-cross:before{content:"\effe"}.ti-arrows-diagonal:before{content:"\ea27"}.ti-arrows-diagonal-2:before{content:"\ea26"}.ti-arrows-diagonal-minimize:before{content:"\ef39"}.ti-arrows-diagonal-minimize-2:before{content:"\ef38"}.ti-arrows-diff:before{content:"\f296"}.ti-arrows-double-ne-sw:before{content:"\edde"}.ti-arrows-double-nw-se:before{content:"\eddf"}.ti-arrows-double-se-nw:before{content:"\ede0"}.ti-arrows-double-sw-ne:before{content:"\ede1"}.ti-arrows-down:before{content:"\edad"}.ti-arrows-down-up:before{content:"\edac"}.ti-arrows-exchange:before{content:"\f1f4"}.ti-arrows-exchange-2:before{content:"\f1f3"}.ti-arrows-horizontal:before{content:"\eb59"}.ti-arrows-join:before{content:"\edaf"}.ti-arrows-join-2:before{content:"\edae"}.ti-arrows-left:before{content:"\edb1"}.ti-arrows-left-down:before{content:"\ee00"}.ti-arrows-left-right:before{content:"\edb0"}.ti-arrows-maximize:before{content:"\ea28"}.ti-arrows-minimize:before{content:"\ea29"}.ti-arrows-move:before{content:"\f22f"}.ti-arrows-move-horizontal:before{content:"\f22d"}.ti-arrows-move-vertical:before{content:"\f22e"}.ti-arrows-random:before{content:"\f095"}.ti-arrows-right:before{content:"\edb3"}.ti-arrows-right-down:before{content:"\ee01"}.ti-arrows-right-left:before{content:"\edb2"}.ti-arrows-shuffle:before{content:"\f000"}.ti-arrows-shuffle-2:before{content:"\efff"}.ti-arrows-sort:before{content:"\eb5a"}.ti-arrows-split:before{content:"\edb5"}.ti-arrows-split-2:before{content:"\edb4"}.ti-arrows-transfer-down:before{content:"\f2cc"}.ti-arrows-transfer-up:before{content:"\f2cd"}.ti-arrows-transfer-up-down:before{content:"\ffac"}.ti-arrows-up:before{content:"\edb7"}.ti-arrows-up-down:before{content:"\edb6"}.ti-arrows-up-left:before{content:"\ee02"}.ti-arrows-up-right:before{content:"\ee03"}.ti-arrows-vertical:before{content:"\eb5b"}.ti-artboard:before{content:"\ea2a"}.ti-artboard-filled:before{content:"\fa83"}.ti-artboard-off:before{content:"\f0ae"}.ti-article:before{content:"\f1e2"}.ti-article-filled:before{content:"\f7da"}.ti-article-off:before{content:"\f3bf"}.ti-aspect-ratio:before{content:"\ed30"}.ti-aspect-ratio-filled:before{content:"\f7db"}.ti-aspect-ratio-off:before{content:"\f0af"}.ti-assembly:before{content:"\f24d"}.ti-assembly-filled:before{content:"\fe9e"}.ti-assembly-off:before{content:"\f3c0"}.ti-asset:before{content:"\f1ce"}.ti-asset-filled:before{content:"\fe9d"}.ti-asterisk:before{content:"\efd5"}.ti-asterisk-simple:before{content:"\efd4"}.ti-at:before{content:"\ea2b"}.ti-at-off:before{content:"\f0b0"}.ti-atom:before{content:"\eb79"}.ti-atom-2:before{content:"\ebdf"}.ti-atom-2-filled:before{content:"\f71b"}.ti-atom-off:before{content:"\f0f9"}.ti-augmented-reality:before{content:"\f023"}.ti-augmented-reality-2:before{content:"\f37e"}.ti-augmented-reality-off:before{content:"\f3c1"}.ti-auth-2fa:before{content:"\eca0"}.ti-automatic-gearbox:before{content:"\fc89"}.ti-automatic-gearbox-filled:before{content:"\1002a"}.ti-automation:before{content:"\fef8"}.ti-avocado:before{content:"\fd8e"}.ti-award:before{content:"\ea2c"}.ti-award-filled:before{content:"\f71c"}.ti-award-off:before{content:"\f0fa"}.ti-axe:before{content:"\ef9f"}.ti-axis-x:before{content:"\ef45"}.ti-axis-y:before{content:"\ef46"}.ti-baby-bottle:before{content:"\f5d2"}.ti-baby-carriage:before{content:"\f05d"}.ti-baby-carriage-filled:before{content:"\fe9c"}.ti-background:before{content:"\fd2c"}.ti-backhoe:before{content:"\ed86"}.ti-backpack:before{content:"\ef47"}.ti-backpack-off:before{content:"\f3c2"}.ti-backslash:before{content:"\fab9"}.ti-backspace:before{content:"\ea2d"}.ti-backspace-filled:before{content:"\f7dc"}.ti-badge:before{content:"\efc2"}.ti-badge-2k:before{content:"\100b5"}.ti-badge-3d:before{content:"\f555"}.ti-badge-3d-filled:before{content:"\fe9b"}.ti-badge-3k:before{content:"\100b4"}.ti-badge-4k:before{content:"\f556"}.ti-badge-4k-filled:before{content:"\fe9a"}.ti-badge-5k:before{content:"\100b3"}.ti-badge-8k:before{content:"\f557"}.ti-badge-8k-filled:before{content:"\fe99"}.ti-badge-ad:before{content:"\f558"}.ti-badge-ad-filled:before{content:"\fe98"}.ti-badge-ad-off:before{content:"\fd8f"}.ti-badge-ar:before{content:"\f559"}.ti-badge-ar-filled:before{content:"\fe97"}.ti-badge-cc:before{content:"\f55a"}.ti-badge-cc-filled:before{content:"\fe96"}.ti-badge-filled:before{content:"\f667"}.ti-badge-hd:before{content:"\f55b"}.ti-badge-hd-filled:before{content:"\fe95"}.ti-badge-off:before{content:"\f0fb"}.ti-badge-sd:before{content:"\f55c"}.ti-badge-sd-filled:before{content:"\fe94"}.ti-badge-tm:before{content:"\f55d"}.ti-badge-tm-filled:before{content:"\fe93"}.ti-badge-vo:before{content:"\f55e"}.ti-badge-vo-filled:before{content:"\fe92"}.ti-badge-vr:before{content:"\f55f"}.ti-badge-vr-filled:before{content:"\fe91"}.ti-badge-wc:before{content:"\f560"}.ti-badge-wc-filled:before{content:"\fe90"}.ti-badges:before{content:"\efc3"}.ti-badges-filled:before{content:"\f7dd"}.ti-badges-off:before{content:"\f0fc"}.ti-baguette:before{content:"\f3a5"}.ti-ball-american-football:before{content:"\ee04"}.ti-ball-american-football-off:before{content:"\f3c3"}.ti-ball-baseball:before{content:"\efa0"}.ti-ball-basketball:before{content:"\ec28"}.ti-ball-bowling:before{content:"\ec29"}.ti-ball-football:before{content:"\ee06"}.ti-ball-football-off:before{content:"\ee05"}.ti-ball-tennis:before{content:"\ec2a"}.ti-ball-volleyball:before{content:"\ec2b"}.ti-balloon:before{content:"\ef3a"}.ti-balloon-filled:before{content:"\fa84"}.ti-balloon-off:before{content:"\f0fd"}.ti-ballpen:before{content:"\f06e"}.ti-ballpen-filled:before{content:"\fa85"}.ti-ballpen-off:before{content:"\f0b1"}.ti-ban:before{content:"\ea2e"}.ti-bandage:before{content:"\eb7a"}.ti-bandage-filled:before{content:"\f7de"}.ti-bandage-off:before{content:"\f3c4"}.ti-barbell:before{content:"\eff0"}.ti-barbell-filled:before{content:"\fe8f"}.ti-barbell-off:before{content:"\f0b2"}.ti-barcode:before{content:"\ebc6"}.ti-barcode-off:before{content:"\f0b3"}.ti-barrel:before{content:"\f0b4"}.ti-barrel-off:before{content:"\f0fe"}.ti-barrier-block:before{content:"\f00e"}.ti-barrier-block-filled:before{content:"\fe8e"}.ti-barrier-block-off:before{content:"\f0b5"}.ti-baseline:before{content:"\f024"}.ti-baseline-density-large:before{content:"\f9f0"}.ti-baseline-density-medium:before{content:"\f9f1"}.ti-baseline-density-small:before{content:"\f9f2"}.ti-basket:before{content:"\ebe1"}.ti-basket-bolt:before{content:"\fb43"}.ti-basket-cancel:before{content:"\fb44"}.ti-basket-check:before{content:"\fb45"}.ti-basket-code:before{content:"\fb46"}.ti-basket-cog:before{content:"\fb47"}.ti-basket-discount:before{content:"\fb48"}.ti-basket-dollar:before{content:"\fb49"}.ti-basket-down:before{content:"\fb4a"}.ti-basket-exclamation:before{content:"\fb4b"}.ti-basket-filled:before{content:"\f7df"}.ti-basket-heart:before{content:"\fb4c"}.ti-basket-minus:before{content:"\fb4d"}.ti-basket-off:before{content:"\f0b6"}.ti-basket-pause:before{content:"\fb4e"}.ti-basket-pin:before{content:"\fb4f"}.ti-basket-plus:before{content:"\fb50"}.ti-basket-question:before{content:"\fb51"}.ti-basket-search:before{content:"\fb52"}.ti-basket-share:before{content:"\fb53"}.ti-basket-star:before{content:"\fb54"}.ti-basket-up:before{content:"\fb55"}.ti-basket-x:before{content:"\fb56"}.ti-bat:before{content:"\f284"}.ti-bath:before{content:"\ef48"}.ti-bath-filled:before{content:"\f71d"}.ti-bath-off:before{content:"\f0ff"}.ti-battery:before{content:"\ea34"}.ti-battery-1:before{content:"\ea2f"}.ti-battery-1-filled:before{content:"\f71e"}.ti-battery-2:before{content:"\ea30"}.ti-battery-2-filled:before{content:"\f71f"}.ti-battery-3:before{content:"\ea31"}.ti-battery-3-filled:before{content:"\f720"}.ti-battery-4:before{content:"\ea32"}.ti-battery-4-filled:before{content:"\f721"}.ti-battery-automotive:before{content:"\ee07"}.ti-battery-automotive-filled:before{content:"\10029"}.ti-battery-charging:before{content:"\ea33"}.ti-battery-charging-2:before{content:"\ef3b"}.ti-battery-eco:before{content:"\ef3c"}.ti-battery-exclamation:before{content:"\ff1d"}.ti-battery-filled:before{content:"\f668"}.ti-battery-off:before{content:"\ed1c"}.ti-battery-spark:before{content:"\ffbd"}.ti-battery-vertical:before{content:"\ff13"}.ti-battery-vertical-1:before{content:"\ff1c"}.ti-battery-vertical-1-filled:before{content:"\10028"}.ti-battery-vertical-2:before{content:"\ff1b"}.ti-battery-vertical-2-filled:before{content:"\10027"}.ti-battery-vertical-3:before{content:"\ff1a"}.ti-battery-vertical-3-filled:before{content:"\10026"}.ti-battery-vertical-4:before{content:"\ff19"}.ti-battery-vertical-4-filled:before{content:"\10025"}.ti-battery-vertical-charging:before{content:"\ff17"}.ti-battery-vertical-charging-2:before{content:"\ff18"}.ti-battery-vertical-eco:before{content:"\ff16"}.ti-battery-vertical-exclamation:before{content:"\ff15"}.ti-battery-vertical-filled:before{content:"\10024"}.ti-battery-vertical-off:before{content:"\ff14"}.ti-beach:before{content:"\ef3d"}.ti-beach-off:before{content:"\f0b7"}.ti-bed:before{content:"\eb5c"}.ti-bed-filled:before{content:"\f7e0"}.ti-bed-flat:before{content:"\fca1"}.ti-bed-flat-filled:before{content:"\fe8d"}.ti-bed-off:before{content:"\f100"}.ti-beer:before{content:"\efa1"}.ti-beer-filled:before{content:"\f7e1"}.ti-beer-off:before{content:"\f101"}.ti-bell:before{content:"\ea35"}.ti-bell-bolt:before{content:"\f812"}.ti-bell-cancel:before{content:"\f813"}.ti-bell-check:before{content:"\f814"}.ti-bell-code:before{content:"\f815"}.ti-bell-cog:before{content:"\f816"}.ti-bell-dollar:before{content:"\f817"}.ti-bell-down:before{content:"\f818"}.ti-bell-exclamation:before{content:"\f819"}.ti-bell-filled:before{content:"\f669"}.ti-bell-heart:before{content:"\f81a"}.ti-bell-minus:before{content:"\ede2"}.ti-bell-minus-filled:before{content:"\f722"}.ti-bell-off:before{content:"\ece9"}.ti-bell-pause:before{content:"\f81b"}.ti-bell-pin:before{content:"\f81c"}.ti-bell-plus:before{content:"\ede3"}.ti-bell-plus-filled:before{content:"\f723"}.ti-bell-question:before{content:"\f81d"}.ti-bell-ringing:before{content:"\ed07"}.ti-bell-ringing-2:before{content:"\ede4"}.ti-bell-ringing-2-filled:before{content:"\f724"}.ti-bell-ringing-filled:before{content:"\f725"}.ti-bell-school:before{content:"\f05e"}.ti-bell-search:before{content:"\f81e"}.ti-bell-share:before{content:"\f81f"}.ti-bell-star:before{content:"\f820"}.ti-bell-up:before{content:"\f821"}.ti-bell-x:before{content:"\ede5"}.ti-bell-x-filled:before{content:"\f726"}.ti-bell-z:before{content:"\eff1"}.ti-bell-z-filled:before{content:"\f727"}.ti-beta:before{content:"\f544"}.ti-bible:before{content:"\efc4"}.ti-bike:before{content:"\ea36"}.ti-bike-filled:before{content:"\10023"}.ti-bike-off:before{content:"\f0b8"}.ti-binary:before{content:"\ee08"}.ti-binary-off:before{content:"\f3c5"}.ti-binary-tree:before{content:"\f5d4"}.ti-binary-tree-2:before{content:"\f5d3"}.ti-binary-tree-2-filled:before{content:"\ff65"}.ti-binary-tree-filled:before{content:"\ff64"}.ti-binoculars:before{content:"\fefe"}.ti-binoculars-filled:before{content:"\ff0b"}.ti-biohazard:before{content:"\ecb8"}.ti-biohazard-filled:before{content:"\fe8c"}.ti-biohazard-off:before{content:"\f0b9"}.ti-blade:before{content:"\f4bd"}.ti-blade-filled:before{content:"\f7e2"}.ti-bleach:before{content:"\f2f3"}.ti-bleach-chlorine:before{content:"\f2f0"}.ti-bleach-no-chlorine:before{content:"\f2f1"}.ti-bleach-off:before{content:"\f2f2"}.ti-blend-mode:before{content:"\feb0"}.ti-blender:before{content:"\fca2"}.ti-blender-filled:before{content:"\10022"}.ti-blob:before{content:"\feaf"}.ti-blob-filled:before{content:"\feb1"}.ti-blockquote:before{content:"\ee09"}.ti-blocks:before{content:"\100b2"}.ti-bluetooth:before{content:"\ea37"}.ti-bluetooth-connected:before{content:"\ecea"}.ti-bluetooth-off:before{content:"\eceb"}.ti-bluetooth-x:before{content:"\f081"}.ti-blur:before{content:"\ef8c"}.ti-blur-off:before{content:"\f3c6"}.ti-bmp:before{content:"\f3a6"}.ti-body-scan:before{content:"\fca3"}.ti-bold:before{content:"\eb7b"}.ti-bold-off:before{content:"\f0ba"}.ti-bolt:before{content:"\ea38"}.ti-bolt-filled:before{content:"\10021"}.ti-bolt-off:before{content:"\ecec"}.ti-bomb:before{content:"\f59c"}.ti-bomb-filled:before{content:"\fa86"}.ti-bone:before{content:"\edb8"}.ti-bone-filled:before{content:"\fe8b"}.ti-bone-off:before{content:"\f0bb"}.ti-bong:before{content:"\f3a7"}.ti-bong-filled:before{content:"\10020"}.ti-bong-off:before{content:"\f3c7"}.ti-book:before{content:"\ea39"}.ti-book-2:before{content:"\efc5"}.ti-book-download:before{content:"\f070"}.ti-book-filled:before{content:"\fa87"}.ti-book-off:before{content:"\f0bc"}.ti-book-upload:before{content:"\f071"}.ti-bookmark:before{content:"\ea3a"}.ti-bookmark-ai:before{content:"\fc8a"}.ti-bookmark-edit:before{content:"\fa5e"}.ti-bookmark-filled:before{content:"\fa88"}.ti-bookmark-minus:before{content:"\fa5f"}.ti-bookmark-off:before{content:"\eced"}.ti-bookmark-plus:before{content:"\fa60"}.ti-bookmark-question:before{content:"\fa61"}.ti-bookmarks:before{content:"\ed08"}.ti-bookmarks-filled:before{content:"\fb1f"}.ti-bookmarks-off:before{content:"\f0bd"}.ti-books:before{content:"\eff2"}.ti-books-off:before{content:"\f0be"}.ti-boom:before{content:"\fdbe"}.ti-boom-filled:before{content:"\fe8a"}.ti-border-all:before{content:"\ea3b"}.ti-border-bottom:before{content:"\ea3c"}.ti-border-bottom-plus:before{content:"\fdbd"}.ti-border-corner-ios:before{content:"\fd98"}.ti-border-corner-pill:before{content:"\fd62"}.ti-border-corner-rounded:before{content:"\fd63"}.ti-border-corner-square:before{content:"\fd64"}.ti-border-corners:before{content:"\f7a0"}.ti-border-horizontal:before{content:"\ea3d"}.ti-border-inner:before{content:"\ea3e"}.ti-border-left:before{content:"\ea3f"}.ti-border-left-plus:before{content:"\fdbc"}.ti-border-none:before{content:"\ea40"}.ti-border-outer:before{content:"\ea41"}.ti-border-radius:before{content:"\eb7c"}.ti-border-right:before{content:"\ea42"}.ti-border-right-plus:before{content:"\fdbb"}.ti-border-sides:before{content:"\f7a1"}.ti-border-style:before{content:"\ee0a"}.ti-border-style-2:before{content:"\ef22"}.ti-border-top:before{content:"\ea43"}.ti-border-top-plus:before{content:"\fdba"}.ti-border-vertical:before{content:"\ea44"}.ti-bottle:before{content:"\ef0b"}.ti-bottle-filled:before{content:"\fa89"}.ti-bottle-off:before{content:"\f3c8"}.ti-bounce-left:before{content:"\f59d"}.ti-bounce-left-filled:before{content:"\fb20"}.ti-bounce-right:before{content:"\f59e"}.ti-bounce-right-filled:before{content:"\fb21"}.ti-bow:before{content:"\f096"}.ti-bow-filled:before{content:"\fe89"}.ti-bowl:before{content:"\f4fa"}.ti-bowl-chopsticks:before{content:"\fd90"}.ti-bowl-chopsticks-filled:before{content:"\fe88"}.ti-bowl-filled:before{content:"\fb22"}.ti-bowl-spoon:before{content:"\fd91"}.ti-bowl-spoon-filled:before{content:"\fe87"}.ti-bowling:before{content:"\100b1"}.ti-box:before{content:"\ea45"}.ti-box-align-bottom:before{content:"\f2a8"}.ti-box-align-bottom-filled:before{content:"\fa8a"}.ti-box-align-bottom-left:before{content:"\f2ce"}.ti-box-align-bottom-left-filled:before{content:"\fa8b"}.ti-box-align-bottom-right:before{content:"\f2cf"}.ti-box-align-bottom-right-filled:before{content:"\fa8c"}.ti-box-align-left:before{content:"\f2a9"}.ti-box-align-left-filled:before{content:"\fa8d"}.ti-box-align-right:before{content:"\f2aa"}.ti-box-align-right-filled:before{content:"\fa8e"}.ti-box-align-top:before{content:"\f2ab"}.ti-box-align-top-filled:before{content:"\fa8f"}.ti-box-align-top-left:before{content:"\f2d0"}.ti-box-align-top-left-filled:before{content:"\fa90"}.ti-box-align-top-right:before{content:"\f2d1"}.ti-box-align-top-right-filled:before{content:"\fa91"}.ti-box-margin:before{content:"\ee0b"}.ti-box-model:before{content:"\ee0c"}.ti-box-model-2:before{content:"\ef23"}.ti-box-model-2-off:before{content:"\f3c9"}.ti-box-model-off:before{content:"\f3ca"}.ti-box-multiple:before{content:"\ee17"}.ti-box-multiple-0:before{content:"\ee0d"}.ti-box-multiple-1:before{content:"\ee0e"}.ti-box-multiple-2:before{content:"\ee0f"}.ti-box-multiple-3:before{content:"\ee10"}.ti-box-multiple-4:before{content:"\ee11"}.ti-box-multiple-5:before{content:"\ee12"}.ti-box-multiple-6:before{content:"\ee13"}.ti-box-multiple-7:before{content:"\ee14"}.ti-box-multiple-8:before{content:"\ee15"}.ti-box-multiple-9:before{content:"\ee16"}.ti-box-multiple-filled:before{content:"\1001f"}.ti-box-off:before{content:"\f102"}.ti-box-padding:before{content:"\ee18"}.ti-braces:before{content:"\ebcc"}.ti-braces-off:before{content:"\f0bf"}.ti-brackets:before{content:"\ebcd"}.ti-brackets-angle:before{content:"\fcb2"}.ti-brackets-angle-off:before{content:"\fcb1"}.ti-brackets-contain:before{content:"\f1e5"}.ti-brackets-contain-end:before{content:"\f1e3"}.ti-brackets-contain-start:before{content:"\f1e4"}.ti-brackets-off:before{content:"\f0c0"}.ti-braille:before{content:"\f545"}.ti-brain:before{content:"\f59f"}.ti-brand-4chan:before{content:"\f494"}.ti-brand-abstract:before{content:"\f495"}.ti-brand-adobe:before{content:"\f0dc"}.ti-brand-adobe-after-effect:before{content:"\ff2a"}.ti-brand-adobe-illustrator:before{content:"\ff29"}.ti-brand-adobe-indesign:before{content:"\ff28"}.ti-brand-adobe-photoshop:before{content:"\ff27"}.ti-brand-adobe-premier:before{content:"\ff26"}.ti-brand-adobe-xd:before{content:"\ff25"}.ti-brand-adonis-js:before{content:"\f496"}.ti-brand-airbnb:before{content:"\ed68"}.ti-brand-airtable:before{content:"\ef6a"}.ti-brand-algolia:before{content:"\f390"}.ti-brand-alipay:before{content:"\f7a2"}.ti-brand-alpine-js:before{content:"\f324"}.ti-brand-amazon:before{content:"\f230"}.ti-brand-amd:before{content:"\f653"}.ti-brand-amie:before{content:"\ffab"}.ti-brand-amigo:before{content:"\f5f9"}.ti-brand-among-us:before{content:"\f205"}.ti-brand-android:before{content:"\ec16"}.ti-brand-angular:before{content:"\ef6b"}.ti-brand-angular-filled:before{content:"\10095"}.ti-brand-ansible:before{content:"\fa70"}.ti-brand-ao3:before{content:"\f5e8"}.ti-brand-appgallery:before{content:"\f231"}.ti-brand-apple:before{content:"\ec17"}.ti-brand-apple-arcade:before{content:"\ed69"}.ti-brand-apple-filled:before{content:"\fd74"}.ti-brand-apple-news:before{content:"\ff24"}.ti-brand-apple-podcast:before{content:"\f1e6"}.ti-brand-appstore:before{content:"\ed24"}.ti-brand-arc:before{content:"\feae"}.ti-brand-asana:before{content:"\edc5"}.ti-brand-astro:before{content:"\fdb9"}.ti-brand-auth0:before{content:"\fcb3"}.ti-brand-aws:before{content:"\fa4c"}.ti-brand-azure:before{content:"\fa4d"}.ti-brand-backbone:before{content:"\f325"}.ti-brand-badoo:before{content:"\f206"}.ti-brand-baidu:before{content:"\f5e9"}.ti-brand-bandcamp:before{content:"\f207"}.ti-brand-bandlab:before{content:"\f5fa"}.ti-brand-beats:before{content:"\f208"}.ti-brand-bebo:before{content:"\ffaa"}.ti-brand-behance:before{content:"\ec6e"}.ti-brand-bilibili:before{content:"\f6d2"}.ti-brand-binance:before{content:"\f5a0"}.ti-brand-bing:before{content:"\edc6"}.ti-brand-bitbucket:before{content:"\edc7"}.ti-brand-bitbucket-filled:before{content:"\100cb"}.ti-brand-blackberry:before{content:"\f568"}.ti-brand-blender:before{content:"\f326"}.ti-brand-blogger:before{content:"\f35a"}.ti-brand-bluesky:before{content:"\fd75"}.ti-brand-booking:before{content:"\edc8"}.ti-brand-bootstrap:before{content:"\ef3e"}.ti-brand-bulma:before{content:"\f327"}.ti-brand-bumble:before{content:"\f5fb"}.ti-brand-bunpo:before{content:"\f4cf"}.ti-brand-c-sharp:before{content:"\f003"}.ti-brand-cake:before{content:"\f7a3"}.ti-brand-cakephp:before{content:"\f7af"}.ti-brand-campaignmonitor:before{content:"\f328"}.ti-brand-carbon:before{content:"\f348"}.ti-brand-cashapp:before{content:"\f391"}.ti-brand-chrome:before{content:"\ec18"}.ti-brand-cinema-4d:before{content:"\fa71"}.ti-brand-citymapper:before{content:"\f5fc"}.ti-brand-cloudflare:before{content:"\fa4e"}.ti-brand-codecov:before{content:"\f329"}.ti-brand-codepen:before{content:"\ec6f"}.ti-brand-codesandbox:before{content:"\ed6a"}.ti-brand-cohost:before{content:"\f5d5"}.ti-brand-coinbase:before{content:"\f209"}.ti-brand-comedy-central:before{content:"\f217"}.ti-brand-coreos:before{content:"\f5fd"}.ti-brand-couchdb:before{content:"\f60f"}.ti-brand-couchsurfing:before{content:"\f392"}.ti-brand-cpp:before{content:"\f5fe"}.ti-brand-craft:before{content:"\fa72"}.ti-brand-crunchbase:before{content:"\f7e3"}.ti-brand-css3:before{content:"\ed6b"}.ti-brand-ctemplar:before{content:"\f4d0"}.ti-brand-cucumber:before{content:"\ef6c"}.ti-brand-cupra:before{content:"\f4d1"}.ti-brand-cypress:before{content:"\f333"}.ti-brand-d3:before{content:"\f24e"}.ti-brand-databricks:before{content:"\fc41"}.ti-brand-days-counter:before{content:"\f4d2"}.ti-brand-dcos:before{content:"\f32a"}.ti-brand-debian:before{content:"\ef57"}.ti-brand-deezer:before{content:"\f78b"}.ti-brand-deliveroo:before{content:"\f4d3"}.ti-brand-deno:before{content:"\f24f"}.ti-brand-denodo:before{content:"\f610"}.ti-brand-deviantart:before{content:"\ecfb"}.ti-brand-digg:before{content:"\fa73"}.ti-brand-dingtalk:before{content:"\f5ea"}.ti-brand-discord:before{content:"\ece3"}.ti-brand-discord-filled:before{content:"\f7e4"}.ti-brand-disney:before{content:"\f20a"}.ti-brand-disqus:before{content:"\edc9"}.ti-brand-django:before{content:"\f349"}.ti-brand-docker:before{content:"\edca"}.ti-brand-doctrine:before{content:"\ef6d"}.ti-brand-dolby-digital:before{content:"\f4d4"}.ti-brand-douban:before{content:"\f5ff"}.ti-brand-dribbble:before{content:"\ec19"}.ti-brand-dribbble-filled:before{content:"\f7e5"}.ti-brand-drops:before{content:"\f4d5"}.ti-brand-drupal:before{content:"\f393"}.ti-brand-edge:before{content:"\ecfc"}.ti-brand-elastic:before{content:"\f611"}.ti-brand-electronic-arts:before{content:"\fa74"}.ti-brand-ember:before{content:"\f497"}.ti-brand-envato:before{content:"\f394"}.ti-brand-etsy:before{content:"\f654"}.ti-brand-evernote:before{content:"\f600"}.ti-brand-facebook:before{content:"\ec1a"}.ti-brand-facebook-filled:before{content:"\f7e6"}.ti-brand-feedly:before{content:"\fa75"}.ti-brand-figma:before{content:"\ec93"}.ti-brand-filezilla:before{content:"\fa76"}.ti-brand-finder:before{content:"\f218"}.ti-brand-firebase:before{content:"\ef6e"}.ti-brand-firefox:before{content:"\ecfd"}.ti-brand-fiverr:before{content:"\f7a4"}.ti-brand-flickr:before{content:"\ecfe"}.ti-brand-flightradar24:before{content:"\f4d6"}.ti-brand-flipboard:before{content:"\f20b"}.ti-brand-flutter:before{content:"\f395"}.ti-brand-fortnite:before{content:"\f260"}.ti-brand-foursquare:before{content:"\ecff"}.ti-brand-framer:before{content:"\ec1b"}.ti-brand-framer-motion:before{content:"\f78c"}.ti-brand-funimation:before{content:"\f655"}.ti-brand-gatsby:before{content:"\f396"}.ti-brand-git:before{content:"\ef6f"}.ti-brand-github:before{content:"\ec1c"}.ti-brand-github-copilot:before{content:"\f4a8"}.ti-brand-github-filled:before{content:"\f7e7"}.ti-brand-gitlab:before{content:"\ec1d"}.ti-brand-gmail:before{content:"\efa2"}.ti-brand-golang:before{content:"\f78d"}.ti-brand-google:before{content:"\ec1f"}.ti-brand-google-analytics:before{content:"\edcb"}.ti-brand-google-big-query:before{content:"\f612"}.ti-brand-google-drive:before{content:"\ec1e"}.ti-brand-google-filled:before{content:"\fd1a"}.ti-brand-google-fit:before{content:"\f297"}.ti-brand-google-home:before{content:"\f601"}.ti-brand-google-maps:before{content:"\fa4f"}.ti-brand-google-one:before{content:"\f232"}.ti-brand-google-photos:before{content:"\f20c"}.ti-brand-google-play:before{content:"\ed25"}.ti-brand-google-podcasts:before{content:"\f656"}.ti-brand-grammarly:before{content:"\f32b"}.ti-brand-graphql:before{content:"\f32c"}.ti-brand-gravatar:before{content:"\edcc"}.ti-brand-grindr:before{content:"\f20d"}.ti-brand-guardian:before{content:"\f4fb"}.ti-brand-gumroad:before{content:"\f5d6"}.ti-brand-hackerrank:before{content:"\ff23"}.ti-brand-hbo:before{content:"\f657"}.ti-brand-headlessui:before{content:"\f32d"}.ti-brand-hexo:before{content:"\fa50"}.ti-brand-hipchat:before{content:"\edcd"}.ti-brand-html5:before{content:"\ed6c"}.ti-brand-inertia:before{content:"\f34a"}.ti-brand-instagram:before{content:"\ec20"}.ti-brand-instagram-filled:before{content:"\10094"}.ti-brand-intercom:before{content:"\f1cf"}.ti-brand-itch:before{content:"\fa22"}.ti-brand-javascript:before{content:"\ef0c"}.ti-brand-juejin:before{content:"\f7b0"}.ti-brand-kako-talk:before{content:"\fd2d"}.ti-brand-kbin:before{content:"\fad0"}.ti-brand-kick:before{content:"\fa23"}.ti-brand-kick-filled:before{content:"\10093"}.ti-brand-kickstarter:before{content:"\edce"}.ti-brand-kotlin:before{content:"\ed6d"}.ti-brand-laravel:before{content:"\f34b"}.ti-brand-lastfm:before{content:"\f001"}.ti-brand-leetcode:before{content:"\fa51"}.ti-brand-letterboxd:before{content:"\fa24"}.ti-brand-line:before{content:"\f7e8"}.ti-brand-linkedin:before{content:"\ec8c"}.ti-brand-linkedin-filled:before{content:"\10092"}.ti-brand-linktree:before{content:"\f1e7"}.ti-brand-linqpad:before{content:"\f562"}.ti-brand-livewire:before{content:"\fd76"}.ti-brand-loom:before{content:"\ef70"}.ti-brand-mailgun:before{content:"\f32e"}.ti-brand-mantine:before{content:"\f32f"}.ti-brand-mastercard:before{content:"\ef49"}.ti-brand-mastodon:before{content:"\f250"}.ti-brand-matrix:before{content:"\f5eb"}.ti-brand-mcdonalds:before{content:"\f251"}.ti-brand-medium:before{content:"\ec70"}.ti-brand-meetup:before{content:"\fc6a"}.ti-brand-mercedes:before{content:"\f072"}.ti-brand-messenger:before{content:"\ec71"}.ti-brand-messenger-filled:before{content:"\100a7"}.ti-brand-meta:before{content:"\efb0"}.ti-brand-metabrainz:before{content:"\ff12"}.ti-brand-minecraft:before{content:"\faef"}.ti-brand-miniprogram:before{content:"\f602"}.ti-brand-mixpanel:before{content:"\f397"}.ti-brand-monday:before{content:"\f219"}.ti-brand-mongodb:before{content:"\f613"}.ti-brand-my-oppo:before{content:"\f4d7"}.ti-brand-mysql:before{content:"\f614"}.ti-brand-national-geographic:before{content:"\f603"}.ti-brand-nem:before{content:"\f5a1"}.ti-brand-netbeans:before{content:"\ef71"}.ti-brand-netease-music:before{content:"\f604"}.ti-brand-netflix:before{content:"\edcf"}.ti-brand-nexo:before{content:"\f5a2"}.ti-brand-nextcloud:before{content:"\f4d8"}.ti-brand-nextjs:before{content:"\f0dd"}.ti-brand-nodejs:before{content:"\fae0"}.ti-brand-nord-vpn:before{content:"\f37f"}.ti-brand-notion:before{content:"\ef7b"}.ti-brand-npm:before{content:"\f569"}.ti-brand-nuxt:before{content:"\f0de"}.ti-brand-nytimes:before{content:"\ef8d"}.ti-brand-oauth:before{content:"\fa52"}.ti-brand-office:before{content:"\f398"}.ti-brand-ok-ru:before{content:"\f399"}.ti-brand-onedrive:before{content:"\f5d7"}.ti-brand-onlyfans:before{content:"\f605"}.ti-brand-open-source:before{content:"\edd0"}.ti-brand-open-source-filled:before{content:"\10091"}.ti-brand-openai:before{content:"\f78e"}.ti-brand-openvpn:before{content:"\f39a"}.ti-brand-opera:before{content:"\ec21"}.ti-brand-opera-filled:before{content:"\10090"}.ti-brand-pagekit:before{content:"\edd1"}.ti-brand-parsinta:before{content:"\fc42"}.ti-brand-patreon:before{content:"\edd2"}.ti-brand-patreon-filled:before{content:"\fcff"}.ti-brand-paypal:before{content:"\ec22"}.ti-brand-paypal-filled:before{content:"\f7e9"}.ti-brand-paypay:before{content:"\f5ec"}.ti-brand-peanut:before{content:"\f39b"}.ti-brand-pepsi:before{content:"\f261"}.ti-brand-php:before{content:"\ef72"}.ti-brand-picsart:before{content:"\f4d9"}.ti-brand-pinterest:before{content:"\ec8d"}.ti-brand-pinterest-filled:before{content:"\1008f"}.ti-brand-planetscale:before{content:"\f78f"}.ti-brand-pnpm:before{content:"\fd77"}.ti-brand-pocket:before{content:"\ed00"}.ti-brand-polymer:before{content:"\f498"}.ti-brand-powershell:before{content:"\f5ed"}.ti-brand-printables:before{content:"\fd1b"}.ti-brand-prisma:before{content:"\f499"}.ti-brand-producthunt:before{content:"\edd3"}.ti-brand-pushbullet:before{content:"\f330"}.ti-brand-pushover:before{content:"\f20e"}.ti-brand-python:before{content:"\ed01"}.ti-brand-qq:before{content:"\f606"}.ti-brand-radix-ui:before{content:"\f790"}.ti-brand-react:before{content:"\f34c"}.ti-brand-react-native:before{content:"\ef73"}.ti-brand-reason:before{content:"\f49a"}.ti-brand-reddit:before{content:"\ec8e"}.ti-brand-redhat:before{content:"\f331"}.ti-brand-redux:before{content:"\f3a8"}.ti-brand-revolut:before{content:"\f4da"}.ti-brand-rumble:before{content:"\fad1"}.ti-brand-rust:before{content:"\fa53"}.ti-brand-safari:before{content:"\ec23"}.ti-brand-samsungpass:before{content:"\f4db"}.ti-brand-sass:before{content:"\edd4"}.ti-brand-sentry:before{content:"\edd5"}.ti-brand-sharik:before{content:"\f4dc"}.ti-brand-shazam:before{content:"\edd6"}.ti-brand-shopee:before{content:"\f252"}.ti-brand-sketch:before{content:"\ec24"}.ti-brand-sketch-filled:before{content:"\1008e"}.ti-brand-skype:before{content:"\ed02"}.ti-brand-slack:before{content:"\ec72"}.ti-brand-snapchat:before{content:"\ec25"}.ti-brand-snapchat-filled:before{content:"\1008d"}.ti-brand-snapseed:before{content:"\f253"}.ti-brand-snowflake:before{content:"\f615"}.ti-brand-socket-io:before{content:"\f49b"}.ti-brand-solidjs:before{content:"\f5ee"}.ti-brand-soundcloud:before{content:"\ed6e"}.ti-brand-spacehey:before{content:"\f4fc"}.ti-brand-speedtest:before{content:"\fa77"}.ti-brand-spotify:before{content:"\ed03"}.ti-brand-spotify-filled:before{content:"\fe86"}.ti-brand-stackoverflow:before{content:"\ef58"}.ti-brand-stackshare:before{content:"\f607"}.ti-brand-steam:before{content:"\ed6f"}.ti-brand-steam-filled:before{content:"\1008c"}.ti-brand-stocktwits:before{content:"\fd78"}.ti-brand-storj:before{content:"\fa54"}.ti-brand-storybook:before{content:"\f332"}.ti-brand-storytel:before{content:"\f608"}.ti-brand-strava:before{content:"\f254"}.ti-brand-stripe:before{content:"\edd7"}.ti-brand-stripe-filled:before{content:"\1008b"}.ti-brand-sublime-text:before{content:"\ef74"}.ti-brand-sugarizer:before{content:"\f7a5"}.ti-brand-supabase:before{content:"\f6d3"}.ti-brand-superhuman:before{content:"\f50c"}.ti-brand-supernova:before{content:"\f49c"}.ti-brand-surfshark:before{content:"\f255"}.ti-brand-svelte:before{content:"\f0df"}.ti-brand-swift:before{content:"\fa55"}.ti-brand-symfony:before{content:"\f616"}.ti-brand-tabler:before{content:"\ec8f"}.ti-brand-tabler-filled:before{content:"\1008a"}.ti-brand-tailwind:before{content:"\eca1"}.ti-brand-taobao:before{content:"\f5ef"}.ti-brand-teams:before{content:"\fadf"}.ti-brand-ted:before{content:"\f658"}.ti-brand-telegram:before{content:"\ec26"}.ti-brand-terraform:before{content:"\fa56"}.ti-brand-tesla:before{content:"\10099"}.ti-brand-tether:before{content:"\f5a3"}.ti-brand-thingiverse:before{content:"\fd1c"}.ti-brand-threads:before{content:"\fb02"}.ti-brand-threejs:before{content:"\f5f0"}.ti-brand-tidal:before{content:"\ed70"}.ti-brand-tiktok:before{content:"\ec73"}.ti-brand-tiktok-filled:before{content:"\f7ea"}.ti-brand-tinder:before{content:"\ed71"}.ti-brand-tinder-filled:before{content:"\10089"}.ti-brand-topbuzz:before{content:"\f50d"}.ti-brand-torchain:before{content:"\f5a4"}.ti-brand-toyota:before{content:"\f262"}.ti-brand-trello:before{content:"\f39d"}.ti-brand-tripadvisor:before{content:"\f002"}.ti-brand-tumblr:before{content:"\ed04"}.ti-brand-tumblr-filled:before{content:"\10088"}.ti-brand-twilio:before{content:"\f617"}.ti-brand-twitch:before{content:"\ed05"}.ti-brand-twitter:before{content:"\ec27"}.ti-brand-twitter-filled:before{content:"\f7eb"}.ti-brand-typescript:before{content:"\f5f1"}.ti-brand-uber:before{content:"\ef75"}.ti-brand-ubuntu:before{content:"\ef59"}.ti-brand-unity:before{content:"\f49d"}.ti-brand-unsplash:before{content:"\edd8"}.ti-brand-upwork:before{content:"\f39e"}.ti-brand-valorant:before{content:"\f39f"}.ti-brand-vercel:before{content:"\ef24"}.ti-brand-vercel-filled:before{content:"\10087"}.ti-brand-vimeo:before{content:"\ed06"}.ti-brand-vimeo-filled:before{content:"\10086"}.ti-brand-vinted:before{content:"\f20f"}.ti-brand-visa:before{content:"\f380"}.ti-brand-visual-studio:before{content:"\ef76"}.ti-brand-vite:before{content:"\f5f2"}.ti-brand-vivaldi:before{content:"\f210"}.ti-brand-vk:before{content:"\ed72"}.ti-brand-vlc:before{content:"\fa78"}.ti-brand-volkswagen:before{content:"\f50e"}.ti-brand-vsco:before{content:"\f334"}.ti-brand-vscode:before{content:"\f3a0"}.ti-brand-vue:before{content:"\f0e0"}.ti-brand-walmart:before{content:"\f211"}.ti-brand-waze:before{content:"\f5d8"}.ti-brand-webflow:before{content:"\f2d2"}.ti-brand-wechat:before{content:"\f5f3"}.ti-brand-weibo:before{content:"\f609"}.ti-brand-weibo-filled:before{content:"\100a6"}.ti-brand-whatsapp:before{content:"\ec74"}.ti-brand-whatsapp-filled:before{content:"\10085"}.ti-brand-wikipedia:before{content:"\fa79"}.ti-brand-windows:before{content:"\ecd8"}.ti-brand-windows-filled:before{content:"\10084"}.ti-brand-windy:before{content:"\f4dd"}.ti-brand-wish:before{content:"\f212"}.ti-brand-wix:before{content:"\f3a1"}.ti-brand-wordpress:before{content:"\f2d3"}.ti-brand-x:before{content:"\fc0f"}.ti-brand-x-filled:before{content:"\fc21"}.ti-brand-xamarin:before{content:"\fa7a"}.ti-brand-xbox:before{content:"\f298"}.ti-brand-xdeep:before{content:"\fc10"}.ti-brand-xing:before{content:"\f21a"}.ti-brand-yahoo:before{content:"\ed73"}.ti-brand-yandex:before{content:"\fae1"}.ti-brand-yarn:before{content:"\fd79"}.ti-brand-yatse:before{content:"\f213"}.ti-brand-ycombinator:before{content:"\edd9"}.ti-brand-youtube:before{content:"\ec90"}.ti-brand-youtube-filled:before{content:"\fc22"}.ti-brand-youtube-kids:before{content:"\f214"}.ti-brand-zalando:before{content:"\f49e"}.ti-brand-zapier:before{content:"\f49f"}.ti-brand-zeit:before{content:"\f335"}.ti-brand-zhihu:before{content:"\f60a"}.ti-brand-zoom:before{content:"\f215"}.ti-brand-zulip:before{content:"\f4de"}.ti-brand-zwift:before{content:"\f216"}.ti-bread:before{content:"\efa3"}.ti-bread-filled:before{content:"\fe85"}.ti-bread-off:before{content:"\f3cb"}.ti-briefcase:before{content:"\ea46"}.ti-briefcase-2:before{content:"\fb03"}.ti-briefcase-2-filled:before{content:"\fe84"}.ti-briefcase-filled:before{content:"\fd00"}.ti-briefcase-off:before{content:"\f3cc"}.ti-brightness:before{content:"\eb7f"}.ti-brightness-2:before{content:"\ee19"}.ti-brightness-auto:before{content:"\fd99"}.ti-brightness-auto-filled:before{content:"\fe83"}.ti-brightness-down:before{content:"\eb7d"}.ti-brightness-down-filled:before{content:"\fb23"}.ti-brightness-filled:before{content:"\fe82"}.ti-brightness-half:before{content:"\ee1a"}.ti-brightness-off:before{content:"\f3cd"}.ti-brightness-up:before{content:"\eb7e"}.ti-brightness-up-filled:before{content:"\fb24"}.ti-broadcast:before{content:"\f1e9"}.ti-broadcast-off:before{content:"\f1e8"}.ti-browser:before{content:"\ebb7"}.ti-browser-check:before{content:"\efd6"}.ti-browser-maximize:before{content:"\100b0"}.ti-browser-minus:before{content:"\100af"}.ti-browser-off:before{content:"\f0c1"}.ti-browser-plus:before{content:"\efd7"}.ti-browser-share:before{content:"\100ae"}.ti-browser-x:before{content:"\efd8"}.ti-brush:before{content:"\ebb8"}.ti-brush-off:before{content:"\f0c2"}.ti-bubble:before{content:"\feba"}.ti-bubble-filled:before{content:"\fec3"}.ti-bubble-minus:before{content:"\febe"}.ti-bubble-plus:before{content:"\febd"}.ti-bubble-tea:before{content:"\ff51"}.ti-bubble-tea-2:before{content:"\ff52"}.ti-bubble-text:before{content:"\febc"}.ti-bubble-text-filled:before{content:"\100a5"}.ti-bubble-x:before{content:"\febb"}.ti-bucket:before{content:"\ea47"}.ti-bucket-droplet:before{content:"\f56a"}.ti-bucket-off:before{content:"\f103"}.ti-bug:before{content:"\ea48"}.ti-bug-filled:before{content:"\fd01"}.ti-bug-off:before{content:"\f0c3"}.ti-building:before{content:"\ea4f"}.ti-building-airport:before{content:"\ffa9"}.ti-building-arch:before{content:"\ea49"}.ti-building-bank:before{content:"\ebe2"}.ti-building-bridge:before{content:"\ea4b"}.ti-building-bridge-2:before{content:"\ea4a"}.ti-building-broadcast-tower:before{content:"\f4be"}.ti-building-broadcast-tower-filled:before{content:"\fe81"}.ti-building-burj-al-arab:before{content:"\ff50"}.ti-building-carousel:before{content:"\ed87"}.ti-building-castle:before{content:"\ed88"}.ti-building-church:before{content:"\ea4c"}.ti-building-circus:before{content:"\f4bf"}.ti-building-cog:before{content:"\10062"}.ti-building-community:before{content:"\ebf6"}.ti-building-cottage:before{content:"\ee1b"}.ti-building-estate:before{content:"\f5a5"}.ti-building-factory:before{content:"\ee1c"}.ti-building-factory-2:before{content:"\f082"}.ti-building-fortress:before{content:"\ed89"}.ti-building-hospital:before{content:"\ea4d"}.ti-building-lighthouse:before{content:"\ed8a"}.ti-building-minus:before{content:"\10061"}.ti-building-monument:before{content:"\ed26"}.ti-building-mosque:before{content:"\fa57"}.ti-building-off:before{content:"\fefd"}.ti-building-pavilion:before{content:"\ebf7"}.ti-building-plus:before{content:"\10060"}.ti-building-skyscraper:before{content:"\ec39"}.ti-building-stadium:before{content:"\f641"}.ti-building-store:before{content:"\ea4e"}.ti-building-tunnel:before{content:"\f5a6"}.ti-building-warehouse:before{content:"\ebe3"}.ti-building-wind-turbine:before{content:"\f4c0"}.ti-buildings:before{content:"\ff40"}.ti-bulb:before{content:"\ea51"}.ti-bulb-filled:before{content:"\f66a"}.ti-bulb-off:before{content:"\ea50"}.ti-bulldozer:before{content:"\ee1d"}.ti-burger:before{content:"\fcb4"}.ti-bus:before{content:"\ebe4"}.ti-bus-off:before{content:"\f3ce"}.ti-bus-stop:before{content:"\f2d4"}.ti-businessplan:before{content:"\ee1e"}.ti-butterfly:before{content:"\efd9"}.ti-butterfly-filled:before{content:"\10016"}.ti-cactus:before{content:"\f21b"}.ti-cactus-filled:before{content:"\fb25"}.ti-cactus-off:before{content:"\f3cf"}.ti-cake:before{content:"\f00f"}.ti-cake-off:before{content:"\f104"}.ti-cake-roll:before{content:"\100bd"}.ti-calculator:before{content:"\eb80"}.ti-calculator-filled:before{content:"\fb26"}.ti-calculator-off:before{content:"\f0c4"}.ti-calendar:before{content:"\ea53"}.ti-calendar-bolt:before{content:"\f822"}.ti-calendar-cancel:before{content:"\f823"}.ti-calendar-check:before{content:"\f824"}.ti-calendar-clock:before{content:"\fd2e"}.ti-calendar-code:before{content:"\f825"}.ti-calendar-cog:before{content:"\f826"}.ti-calendar-dollar:before{content:"\f827"}.ti-calendar-dot:before{content:"\fd3e"}.ti-calendar-down:before{content:"\f828"}.ti-calendar-due:before{content:"\f621"}.ti-calendar-event:before{content:"\ea52"}.ti-calendar-event-filled:before{content:"\100b9"}.ti-calendar-exclamation:before{content:"\f829"}.ti-calendar-filled:before{content:"\fb27"}.ti-calendar-heart:before{content:"\f82a"}.ti-calendar-minus:before{content:"\ebb9"}.ti-calendar-month:before{content:"\fd2f"}.ti-calendar-month-filled:before{content:"\100b8"}.ti-calendar-off:before{content:"\ee1f"}.ti-calendar-pause:before{content:"\f82b"}.ti-calendar-pin:before{content:"\f82c"}.ti-calendar-plus:before{content:"\ebba"}.ti-calendar-question:before{content:"\f82d"}.ti-calendar-repeat:before{content:"\fad2"}.ti-calendar-sad:before{content:"\fd1d"}.ti-calendar-search:before{content:"\f82e"}.ti-calendar-share:before{content:"\f82f"}.ti-calendar-smile:before{content:"\fd1e"}.ti-calendar-star:before{content:"\f830"}.ti-calendar-stats:before{content:"\ee20"}.ti-calendar-time:before{content:"\ee21"}.ti-calendar-up:before{content:"\f831"}.ti-calendar-user:before{content:"\fd1f"}.ti-calendar-week:before{content:"\fd30"}.ti-calendar-week-filled:before{content:"\100b7"}.ti-calendar-x:before{content:"\f832"}.ti-camera:before{content:"\ea54"}.ti-camera-ai:before{content:"\ffa8"}.ti-camera-bitcoin:before{content:"\ffa7"}.ti-camera-bolt:before{content:"\f833"}.ti-camera-cancel:before{content:"\f834"}.ti-camera-check:before{content:"\f835"}.ti-camera-code:before{content:"\f836"}.ti-camera-cog:before{content:"\f837"}.ti-camera-dollar:before{content:"\f838"}.ti-camera-down:before{content:"\f839"}.ti-camera-exclamation:before{content:"\f83a"}.ti-camera-filled:before{content:"\fa37"}.ti-camera-heart:before{content:"\f83b"}.ti-camera-minus:before{content:"\ec3a"}.ti-camera-moon:before{content:"\ffa6"}.ti-camera-off:before{content:"\ecee"}.ti-camera-pause:before{content:"\f83c"}.ti-camera-pin:before{content:"\f83d"}.ti-camera-plus:before{content:"\ec3b"}.ti-camera-question:before{content:"\f83e"}.ti-camera-rotate:before{content:"\ee22"}.ti-camera-search:before{content:"\f83f"}.ti-camera-selfie:before{content:"\ee23"}.ti-camera-share:before{content:"\f840"}.ti-camera-spark:before{content:"\ffbc"}.ti-camera-star:before{content:"\f841"}.ti-camera-up:before{content:"\f842"}.ti-camera-x:before{content:"\f843"}.ti-camper:before{content:"\fa25"}.ti-campfire:before{content:"\f5a7"}.ti-campfire-filled:before{content:"\fb28"}.ti-cancel:before{content:"\ff11"}.ti-candle:before{content:"\efc6"}.ti-candle-filled:before{content:"\fc23"}.ti-candy:before{content:"\ef0d"}.ti-candy-off:before{content:"\f0c5"}.ti-cane:before{content:"\f50f"}.ti-cannabis:before{content:"\f4c1"}.ti-cannabis-filled:before{content:"\10015"}.ti-cap-projecting:before{content:"\ff22"}.ti-cap-rounded:before{content:"\ff21"}.ti-cap-straight:before{content:"\ff20"}.ti-capsule:before{content:"\fae3"}.ti-capsule-filled:before{content:"\fc24"}.ti-capsule-horizontal:before{content:"\fae2"}.ti-capsule-horizontal-filled:before{content:"\fc25"}.ti-capture:before{content:"\ec3c"}.ti-capture-filled:before{content:"\fb29"}.ti-capture-off:before{content:"\f0c6"}.ti-car:before{content:"\ebbb"}.ti-car-4wd:before{content:"\fdb8"}.ti-car-4wd-filled:before{content:"\1001e"}.ti-car-crane:before{content:"\ef25"}.ti-car-crash:before{content:"\efa4"}.ti-car-fan:before{content:"\fdb3"}.ti-car-fan-1:before{content:"\fdb7"}.ti-car-fan-2:before{content:"\fdb6"}.ti-car-fan-3:before{content:"\fdb5"}.ti-car-fan-auto:before{content:"\fdb4"}.ti-car-fan-filled:before{content:"\1001d"}.ti-car-filled:before{content:"\1004c"}.ti-car-garage:before{content:"\fc77"}.ti-car-off:before{content:"\f0c7"}.ti-car-suv:before{content:"\fc8b"}.ti-car-suv-filled:before{content:"\1004d"}.ti-car-turbine:before{content:"\f4fd"}.ti-carambola:before{content:"\feb9"}.ti-carambola-filled:before{content:"\10014"}.ti-caravan:before{content:"\ec7c"}.ti-cardboards:before{content:"\ed74"}.ti-cardboards-filled:before{content:"\1001c"}.ti-cardboards-off:before{content:"\f0c8"}.ti-cards:before{content:"\f510"}.ti-cards-filled:before{content:"\fc26"}.ti-caret-down:before{content:"\eb5d"}.ti-caret-down-filled:before{content:"\fb2a"}.ti-caret-left:before{content:"\eb5e"}.ti-caret-left-filled:before{content:"\fb2b"}.ti-caret-left-right:before{content:"\fc43"}.ti-caret-left-right-filled:before{content:"\fd02"}.ti-caret-right:before{content:"\eb5f"}.ti-caret-right-filled:before{content:"\fb2c"}.ti-caret-up:before{content:"\eb60"}.ti-caret-up-down:before{content:"\fc44"}.ti-caret-up-down-filled:before{content:"\fd03"}.ti-caret-up-filled:before{content:"\fb2d"}.ti-carousel-horizontal:before{content:"\f659"}.ti-carousel-horizontal-filled:before{content:"\fa92"}.ti-carousel-vertical:before{content:"\f65a"}.ti-carousel-vertical-filled:before{content:"\fa93"}.ti-carrot:before{content:"\f21c"}.ti-carrot-off:before{content:"\f3d0"}.ti-cash:before{content:"\ea55"}.ti-cash-banknote:before{content:"\ee25"}.ti-cash-banknote-filled:before{content:"\fe80"}.ti-cash-banknote-off:before{content:"\ee24"}.ti-cash-off:before{content:"\f105"}.ti-cash-register:before{content:"\fee6"}.ti-cast:before{content:"\ea56"}.ti-cast-off:before{content:"\f0c9"}.ti-cat:before{content:"\f65b"}.ti-category:before{content:"\f1f6"}.ti-category-2:before{content:"\f1f5"}.ti-category-filled:before{content:"\fb2e"}.ti-category-minus:before{content:"\fd20"}.ti-category-plus:before{content:"\fd21"}.ti-ce:before{content:"\ed75"}.ti-ce-off:before{content:"\f0ca"}.ti-cell:before{content:"\f05f"}.ti-cell-signal-1:before{content:"\f083"}.ti-cell-signal-2:before{content:"\f084"}.ti-cell-signal-3:before{content:"\f085"}.ti-cell-signal-4:before{content:"\f086"}.ti-cell-signal-5:before{content:"\f087"}.ti-cell-signal-off:before{content:"\f088"}.ti-certificate:before{content:"\ed76"}.ti-certificate-2:before{content:"\f073"}.ti-certificate-2-off:before{content:"\f0cb"}.ti-certificate-off:before{content:"\f0cc"}.ti-chair-director:before{content:"\f2d5"}.ti-chalkboard:before{content:"\f34d"}.ti-chalkboard-off:before{content:"\f3d1"}.ti-charging-pile:before{content:"\ee26"}.ti-charging-pile-filled:before{content:"\1001b"}.ti-chart-arcs:before{content:"\ee28"}.ti-chart-arcs-3:before{content:"\ee27"}.ti-chart-area:before{content:"\ea58"}.ti-chart-area-filled:before{content:"\f66b"}.ti-chart-area-line:before{content:"\ea57"}.ti-chart-area-line-filled:before{content:"\f66c"}.ti-chart-arrows:before{content:"\ee2a"}.ti-chart-arrows-vertical:before{content:"\ee29"}.ti-chart-bar:before{content:"\ea59"}.ti-chart-bar-off:before{content:"\f3d2"}.ti-chart-bar-popular:before{content:"\fef7"}.ti-chart-bubble:before{content:"\ec75"}.ti-chart-bubble-filled:before{content:"\f66d"}.ti-chart-candle:before{content:"\ea5a"}.ti-chart-candle-filled:before{content:"\f66e"}.ti-chart-circles:before{content:"\ee2b"}.ti-chart-cohort:before{content:"\fef6"}.ti-chart-column:before{content:"\ffa5"}.ti-chart-covariate:before{content:"\ffa4"}.ti-chart-donut:before{content:"\ea5b"}.ti-chart-donut-2:before{content:"\ee2c"}.ti-chart-donut-3:before{content:"\ee2d"}.ti-chart-donut-4:before{content:"\ee2e"}.ti-chart-donut-filled:before{content:"\f66f"}.ti-chart-dots:before{content:"\ee2f"}.ti-chart-dots-2:before{content:"\f097"}.ti-chart-dots-3:before{content:"\f098"}.ti-chart-dots-filled:before{content:"\fd04"}.ti-chart-funnel:before{content:"\fef5"}.ti-chart-grid-dots:before{content:"\f4c2"}.ti-chart-grid-dots-filled:before{content:"\fd05"}.ti-chart-histogram:before{content:"\f65c"}.ti-chart-infographic:before{content:"\ee30"}.ti-chart-line:before{content:"\ea5c"}.ti-chart-pie:before{content:"\ea5d"}.ti-chart-pie-2:before{content:"\ee31"}.ti-chart-pie-3:before{content:"\ee32"}.ti-chart-pie-4:before{content:"\ee33"}.ti-chart-pie-filled:before{content:"\f670"}.ti-chart-pie-off:before{content:"\f3d3"}.ti-chart-ppf:before{content:"\f618"}.ti-chart-radar:before{content:"\ed77"}.ti-chart-sankey:before{content:"\f619"}.ti-chart-scatter:before{content:"\fd93"}.ti-chart-scatter-3d:before{content:"\fd92"}.ti-chart-treemap:before{content:"\f381"}.ti-check:before{content:"\ea5e"}.ti-checkbox:before{content:"\eba6"}.ti-checklist:before{content:"\f074"}.ti-checks:before{content:"\ebaa"}.ti-checkup-list:before{content:"\ef5a"}.ti-cheese:before{content:"\ef26"}.ti-chef-hat:before{content:"\f21d"}.ti-chef-hat-off:before{content:"\f3d4"}.ti-cherry:before{content:"\f511"}.ti-cherry-filled:before{content:"\f728"}.ti-chess:before{content:"\f382"}.ti-chess-bishop:before{content:"\f56b"}.ti-chess-bishop-filled:before{content:"\f729"}.ti-chess-filled:before{content:"\f72a"}.ti-chess-king:before{content:"\f56c"}.ti-chess-king-filled:before{content:"\f72b"}.ti-chess-knight:before{content:"\f56d"}.ti-chess-knight-filled:before{content:"\f72c"}.ti-chess-queen:before{content:"\f56e"}.ti-chess-queen-filled:before{content:"\f72d"}.ti-chess-rook:before{content:"\f56f"}.ti-chess-rook-filled:before{content:"\f72e"}.ti-chevron-compact-down:before{content:"\faf0"}.ti-chevron-compact-left:before{content:"\faf1"}.ti-chevron-compact-right:before{content:"\faf2"}.ti-chevron-compact-up:before{content:"\faf3"}.ti-chevron-down:before{content:"\ea5f"}.ti-chevron-down-left:before{content:"\ed09"}.ti-chevron-down-right:before{content:"\ed0a"}.ti-chevron-left:before{content:"\ea60"}.ti-chevron-left-pipe:before{content:"\fae4"}.ti-chevron-right:before{content:"\ea61"}.ti-chevron-right-pipe:before{content:"\fae5"}.ti-chevron-up:before{content:"\ea62"}.ti-chevron-up-left:before{content:"\ed0b"}.ti-chevron-up-right:before{content:"\ed0c"}.ti-chevrons-down:before{content:"\ea63"}.ti-chevrons-down-left:before{content:"\ed0d"}.ti-chevrons-down-right:before{content:"\ed0e"}.ti-chevrons-left:before{content:"\ea64"}.ti-chevrons-right:before{content:"\ea65"}.ti-chevrons-up:before{content:"\ea66"}.ti-chevrons-up-left:before{content:"\ed0f"}.ti-chevrons-up-right:before{content:"\ed10"}.ti-chisel:before{content:"\f383"}.ti-christmas-ball:before{content:"\fd31"}.ti-christmas-tree:before{content:"\ed78"}.ti-christmas-tree-filled:before{content:"\1001a"}.ti-christmas-tree-off:before{content:"\f3d5"}.ti-circle:before{content:"\ea6b"}.ti-circle-arrow-down:before{content:"\f6f9"}.ti-circle-arrow-down-filled:before{content:"\f6f4"}.ti-circle-arrow-down-left:before{content:"\f6f6"}.ti-circle-arrow-down-left-filled:before{content:"\f6f5"}.ti-circle-arrow-down-right:before{content:"\f6f8"}.ti-circle-arrow-down-right-filled:before{content:"\f6f7"}.ti-circle-arrow-left:before{content:"\f6fb"}.ti-circle-arrow-left-filled:before{content:"\f6fa"}.ti-circle-arrow-right:before{content:"\f6fd"}.ti-circle-arrow-right-filled:before{content:"\f6fc"}.ti-circle-arrow-up:before{content:"\f703"}.ti-circle-arrow-up-filled:before{content:"\f6fe"}.ti-circle-arrow-up-left:before{content:"\f700"}.ti-circle-arrow-up-left-filled:before{content:"\f6ff"}.ti-circle-arrow-up-right:before{content:"\f702"}.ti-circle-arrow-up-right-filled:before{content:"\f701"}.ti-circle-caret-down:before{content:"\f4a9"}.ti-circle-caret-left:before{content:"\f4aa"}.ti-circle-caret-right:before{content:"\f4ab"}.ti-circle-caret-up:before{content:"\f4ac"}.ti-circle-check:before{content:"\ea67"}.ti-circle-check-filled:before{content:"\f704"}.ti-circle-chevron-down:before{content:"\f622"}.ti-circle-chevron-left:before{content:"\f623"}.ti-circle-chevron-right:before{content:"\f624"}.ti-circle-chevron-up:before{content:"\f625"}.ti-circle-chevrons-down:before{content:"\f642"}.ti-circle-chevrons-left:before{content:"\f643"}.ti-circle-chevrons-right:before{content:"\f644"}.ti-circle-chevrons-up:before{content:"\f645"}.ti-circle-dashed:before{content:"\ed27"}.ti-circle-dashed-check:before{content:"\feb8"}.ti-circle-dashed-letter-a:before{content:"\ff9a"}.ti-circle-dashed-letter-b:before{content:"\ff99"}.ti-circle-dashed-letter-c:before{content:"\ff98"}.ti-circle-dashed-letter-d:before{content:"\ff97"}.ti-circle-dashed-letter-e:before{content:"\ff96"}.ti-circle-dashed-letter-f:before{content:"\ff95"}.ti-circle-dashed-letter-g:before{content:"\ff94"}.ti-circle-dashed-letter-h:before{content:"\ff93"}.ti-circle-dashed-letter-i:before{content:"\ff92"}.ti-circle-dashed-letter-j:before{content:"\ff91"}.ti-circle-dashed-letter-k:before{content:"\ff90"}.ti-circle-dashed-letter-l:before{content:"\ff8f"}.ti-circle-dashed-letter-m:before{content:"\ff8d"}.ti-circle-dashed-letter-n:before{content:"\ff8c"}.ti-circle-dashed-letter-o:before{content:"\ff8b"}.ti-circle-dashed-letter-p:before{content:"\ff8a"}.ti-circle-dashed-letter-q:before{content:"\ff89"}.ti-circle-dashed-letter-r:before{content:"\ff88"}.ti-circle-dashed-letter-s:before{content:"\ff87"}.ti-circle-dashed-letter-t:before{content:"\ff86"}.ti-circle-dashed-letter-u:before{content:"\ff85"}.ti-circle-dashed-letter-v:before{content:"\ff84"}.ti-circle-dashed-letter-w:before{content:"\ff83"}.ti-circle-dashed-letter-x:before{content:"\ff82"}.ti-circle-dashed-letter-y:before{content:"\ff81"}.ti-circle-dashed-letter-z:before{content:"\ff80"}.ti-circle-dashed-minus:before{content:"\feb7"}.ti-circle-dashed-number-0:before{content:"\fc6b"}.ti-circle-dashed-number-1:before{content:"\fc6c"}.ti-circle-dashed-number-2:before{content:"\fc6d"}.ti-circle-dashed-number-3:before{content:"\fc6e"}.ti-circle-dashed-number-4:before{content:"\fc6f"}.ti-circle-dashed-number-5:before{content:"\fc70"}.ti-circle-dashed-number-6:before{content:"\fc71"}.ti-circle-dashed-number-7:before{content:"\fc72"}.ti-circle-dashed-number-8:before{content:"\fc73"}.ti-circle-dashed-number-9:before{content:"\fc74"}.ti-circle-dashed-percentage:before{content:"\fd7a"}.ti-circle-dashed-plus:before{content:"\feb6"}.ti-circle-dashed-x:before{content:"\fc75"}.ti-circle-dot:before{content:"\efb1"}.ti-circle-dot-filled:before{content:"\f705"}.ti-circle-dotted:before{content:"\ed28"}.ti-circle-dotted-letter-a:before{content:"\ff7f"}.ti-circle-dotted-letter-b:before{content:"\ff7e"}.ti-circle-dotted-letter-c:before{content:"\ff7d"}.ti-circle-dotted-letter-d:before{content:"\ff7c"}.ti-circle-dotted-letter-e:before{content:"\ff7b"}.ti-circle-dotted-letter-f:before{content:"\ff7a"}.ti-circle-dotted-letter-g:before{content:"\ff79"}.ti-circle-dotted-letter-h:before{content:"\ff78"}.ti-circle-dotted-letter-i:before{content:"\ff77"}.ti-circle-dotted-letter-j:before{content:"\ff76"}.ti-circle-dotted-letter-k:before{content:"\ff75"}.ti-circle-dotted-letter-l:before{content:"\ff74"}.ti-circle-dotted-letter-m:before{content:"\ff73"}.ti-circle-dotted-letter-n:before{content:"\ff72"}.ti-circle-dotted-letter-o:before{content:"\ff71"}.ti-circle-dotted-letter-p:before{content:"\ff70"}.ti-circle-dotted-letter-q:before{content:"\ff6f"}.ti-circle-dotted-letter-r:before{content:"\ff6e"}.ti-circle-dotted-letter-s:before{content:"\ff6d"}.ti-circle-dotted-letter-t:before{content:"\ff6c"}.ti-circle-dotted-letter-u:before{content:"\ff6b"}.ti-circle-dotted-letter-v:before{content:"\ff6a"}.ti-circle-dotted-letter-w:before{content:"\ff69"}.ti-circle-dotted-letter-x:before{content:"\ff68"}.ti-circle-dotted-letter-y:before{content:"\ff67"}.ti-circle-dotted-letter-z:before{content:"\ff66"}.ti-circle-filled:before{content:"\f671"}.ti-circle-half:before{content:"\ee3f"}.ti-circle-half-2:before{content:"\eff3"}.ti-circle-half-vertical:before{content:"\ee3e"}.ti-circle-key:before{content:"\f633"}.ti-circle-key-filled:before{content:"\f706"}.ti-circle-letter-a:before{content:"\f441"}.ti-circle-letter-a-filled:before{content:"\fe7f"}.ti-circle-letter-b:before{content:"\f442"}.ti-circle-letter-b-filled:before{content:"\fe7e"}.ti-circle-letter-c:before{content:"\f443"}.ti-circle-letter-c-filled:before{content:"\fe7d"}.ti-circle-letter-d:before{content:"\f444"}.ti-circle-letter-d-filled:before{content:"\fe7c"}.ti-circle-letter-e:before{content:"\f445"}.ti-circle-letter-e-filled:before{content:"\fe7b"}.ti-circle-letter-f:before{content:"\f446"}.ti-circle-letter-f-filled:before{content:"\fe7a"}.ti-circle-letter-g:before{content:"\f447"}.ti-circle-letter-g-filled:before{content:"\fe79"}.ti-circle-letter-h:before{content:"\f448"}.ti-circle-letter-h-filled:before{content:"\fe78"}.ti-circle-letter-i:before{content:"\f449"}.ti-circle-letter-i-filled:before{content:"\fe77"}.ti-circle-letter-j:before{content:"\f44a"}.ti-circle-letter-j-filled:before{content:"\fe76"}.ti-circle-letter-k:before{content:"\f44b"}.ti-circle-letter-k-filled:before{content:"\fe75"}.ti-circle-letter-l:before{content:"\f44c"}.ti-circle-letter-l-filled:before{content:"\fe74"}.ti-circle-letter-m:before{content:"\f44d"}.ti-circle-letter-m-filled:before{content:"\fe73"}.ti-circle-letter-n:before{content:"\f44e"}.ti-circle-letter-n-filled:before{content:"\fe72"}.ti-circle-letter-o:before{content:"\f44f"}.ti-circle-letter-o-filled:before{content:"\fe71"}.ti-circle-letter-p:before{content:"\f450"}.ti-circle-letter-p-filled:before{content:"\fe70"}.ti-circle-letter-q:before{content:"\f451"}.ti-circle-letter-q-filled:before{content:"\fe6f"}.ti-circle-letter-r:before{content:"\f452"}.ti-circle-letter-r-filled:before{content:"\fe6e"}.ti-circle-letter-s:before{content:"\f453"}.ti-circle-letter-s-filled:before{content:"\fe6d"}.ti-circle-letter-t:before{content:"\f454"}.ti-circle-letter-t-filled:before{content:"\fe6c"}.ti-circle-letter-u:before{content:"\f455"}.ti-circle-letter-u-filled:before{content:"\fe6b"}.ti-circle-letter-v:before{content:"\f4ad"}.ti-circle-letter-v-filled:before{content:"\fe6a"}.ti-circle-letter-w:before{content:"\f456"}.ti-circle-letter-w-filled:before{content:"\fe69"}.ti-circle-letter-x:before{content:"\f4ae"}.ti-circle-letter-x-filled:before{content:"\fe68"}.ti-circle-letter-y:before{content:"\f457"}.ti-circle-letter-y-filled:before{content:"\fe67"}.ti-circle-letter-z:before{content:"\f458"}.ti-circle-letter-z-filled:before{content:"\fe66"}.ti-circle-minus:before{content:"\ea68"}.ti-circle-minus-2:before{content:"\fc8c"}.ti-circle-number-0:before{content:"\ee34"}.ti-circle-number-0-filled:before{content:"\f72f"}.ti-circle-number-1:before{content:"\ee35"}.ti-circle-number-1-filled:before{content:"\f730"}.ti-circle-number-2:before{content:"\ee36"}.ti-circle-number-2-filled:before{content:"\f731"}.ti-circle-number-3:before{content:"\ee37"}.ti-circle-number-3-filled:before{content:"\f732"}.ti-circle-number-4:before{content:"\ee38"}.ti-circle-number-4-filled:before{content:"\f733"}.ti-circle-number-5:before{content:"\ee39"}.ti-circle-number-5-filled:before{content:"\f734"}.ti-circle-number-6:before{content:"\ee3a"}.ti-circle-number-6-filled:before{content:"\f735"}.ti-circle-number-7:before{content:"\ee3b"}.ti-circle-number-7-filled:before{content:"\f736"}.ti-circle-number-8:before{content:"\ee3c"}.ti-circle-number-8-filled:before{content:"\f737"}.ti-circle-number-9:before{content:"\ee3d"}.ti-circle-number-9-filled:before{content:"\f738"}.ti-circle-off:before{content:"\ee40"}.ti-circle-percentage:before{content:"\fd7b"}.ti-circle-percentage-filled:before{content:"\fed5"}.ti-circle-plus:before{content:"\ea69"}.ti-circle-plus-2:before{content:"\fc8d"}.ti-circle-plus-filled:before{content:"\fef9"}.ti-circle-rectangle:before{content:"\f010"}.ti-circle-rectangle-filled:before{content:"\ff63"}.ti-circle-rectangle-off:before{content:"\f0cd"}.ti-circle-square:before{content:"\ece4"}.ti-circle-triangle:before{content:"\f011"}.ti-circle-x:before{content:"\ea6a"}.ti-circle-x-filled:before{content:"\f739"}.ti-circles:before{content:"\ece5"}.ti-circles-filled:before{content:"\f672"}.ti-circles-relation:before{content:"\f4c3"}.ti-circuit-ammeter:before{content:"\f271"}.ti-circuit-battery:before{content:"\f272"}.ti-circuit-bulb:before{content:"\f273"}.ti-circuit-capacitor:before{content:"\f275"}.ti-circuit-capacitor-polarized:before{content:"\f274"}.ti-circuit-cell:before{content:"\f277"}.ti-circuit-cell-plus:before{content:"\f276"}.ti-circuit-changeover:before{content:"\f278"}.ti-circuit-diode:before{content:"\f27a"}.ti-circuit-diode-zener:before{content:"\f279"}.ti-circuit-ground:before{content:"\f27c"}.ti-circuit-ground-digital:before{content:"\f27b"}.ti-circuit-inductor:before{content:"\f27d"}.ti-circuit-motor:before{content:"\f27e"}.ti-circuit-pushbutton:before{content:"\f27f"}.ti-circuit-resistor:before{content:"\f280"}.ti-circuit-switch-closed:before{content:"\f281"}.ti-circuit-switch-open:before{content:"\f282"}.ti-circuit-voltmeter:before{content:"\f283"}.ti-clear-all:before{content:"\ee41"}.ti-clear-formatting:before{content:"\ebe5"}.ti-click:before{content:"\ebbc"}.ti-cliff-jumping:before{content:"\fefc"}.ti-clipboard:before{content:"\ea6f"}.ti-clipboard-check:before{content:"\ea6c"}.ti-clipboard-copy:before{content:"\f299"}.ti-clipboard-data:before{content:"\f563"}.ti-clipboard-heart:before{content:"\f34e"}.ti-clipboard-list:before{content:"\ea6d"}.ti-clipboard-off:before{content:"\f0ce"}.ti-clipboard-plus:before{content:"\efb2"}.ti-clipboard-search:before{content:"\10098"}.ti-clipboard-smile:before{content:"\fd9a"}.ti-clipboard-text:before{content:"\f089"}.ti-clipboard-typography:before{content:"\f34f"}.ti-clipboard-x:before{content:"\ea6e"}.ti-clock:before{content:"\ea70"}.ti-clock-12:before{content:"\fc56"}.ti-clock-2:before{content:"\f099"}.ti-clock-24:before{content:"\fc57"}.ti-clock-bitcoin:before{content:"\ff3f"}.ti-clock-bolt:before{content:"\f844"}.ti-clock-cancel:before{content:"\f546"}.ti-clock-check:before{content:"\f7c1"}.ti-clock-code:before{content:"\f845"}.ti-clock-cog:before{content:"\f7c2"}.ti-clock-dollar:before{content:"\f846"}.ti-clock-down:before{content:"\f7c3"}.ti-clock-edit:before{content:"\f547"}.ti-clock-exclamation:before{content:"\f847"}.ti-clock-filled:before{content:"\f73a"}.ti-clock-heart:before{content:"\f7c4"}.ti-clock-hour-1:before{content:"\f313"}.ti-clock-hour-1-filled:before{content:"\fe65"}.ti-clock-hour-10:before{content:"\f314"}.ti-clock-hour-10-filled:before{content:"\fe64"}.ti-clock-hour-11:before{content:"\f315"}.ti-clock-hour-11-filled:before{content:"\fe63"}.ti-clock-hour-12:before{content:"\f316"}.ti-clock-hour-12-filled:before{content:"\fe62"}.ti-clock-hour-2:before{content:"\f317"}.ti-clock-hour-2-filled:before{content:"\fe61"}.ti-clock-hour-3:before{content:"\f318"}.ti-clock-hour-3-filled:before{content:"\fe60"}.ti-clock-hour-4:before{content:"\f319"}.ti-clock-hour-4-filled:before{content:"\fe5f"}.ti-clock-hour-5:before{content:"\f31a"}.ti-clock-hour-5-filled:before{content:"\fe5e"}.ti-clock-hour-6:before{content:"\f31b"}.ti-clock-hour-6-filled:before{content:"\fe5d"}.ti-clock-hour-7:before{content:"\f31c"}.ti-clock-hour-7-filled:before{content:"\fe5c"}.ti-clock-hour-8:before{content:"\f31d"}.ti-clock-hour-8-filled:before{content:"\fe5b"}.ti-clock-hour-9:before{content:"\f31e"}.ti-clock-hour-9-filled:before{content:"\fe5a"}.ti-clock-minus:before{content:"\f848"}.ti-clock-off:before{content:"\f0cf"}.ti-clock-pause:before{content:"\f548"}.ti-clock-pin:before{content:"\f849"}.ti-clock-play:before{content:"\f549"}.ti-clock-plus:before{content:"\f7c5"}.ti-clock-question:before{content:"\f7c6"}.ti-clock-record:before{content:"\f54a"}.ti-clock-search:before{content:"\f7c7"}.ti-clock-share:before{content:"\f84a"}.ti-clock-shield:before{content:"\f7c8"}.ti-clock-star:before{content:"\f7c9"}.ti-clock-stop:before{content:"\f54b"}.ti-clock-up:before{content:"\f7ca"}.ti-clock-x:before{content:"\f7cb"}.ti-clothes-rack:before{content:"\f285"}.ti-clothes-rack-off:before{content:"\f3d6"}.ti-cloud:before{content:"\ea76"}.ti-cloud-bitcoin:before{content:"\ff3e"}.ti-cloud-bolt:before{content:"\f84b"}.ti-cloud-cancel:before{content:"\f84c"}.ti-cloud-check:before{content:"\f84d"}.ti-cloud-code:before{content:"\f84e"}.ti-cloud-cog:before{content:"\f84f"}.ti-cloud-computing:before{content:"\f1d0"}.ti-cloud-data-connection:before{content:"\f1d1"}.ti-cloud-dollar:before{content:"\f850"}.ti-cloud-down:before{content:"\f851"}.ti-cloud-download:before{content:"\ea71"}.ti-cloud-exclamation:before{content:"\f852"}.ti-cloud-filled:before{content:"\f673"}.ti-cloud-fog:before{content:"\ecd9"}.ti-cloud-heart:before{content:"\f853"}.ti-cloud-lock:before{content:"\efdb"}.ti-cloud-lock-open:before{content:"\efda"}.ti-cloud-minus:before{content:"\f854"}.ti-cloud-network:before{content:"\fc78"}.ti-cloud-off:before{content:"\ed3e"}.ti-cloud-pause:before{content:"\f855"}.ti-cloud-pin:before{content:"\f856"}.ti-cloud-plus:before{content:"\f857"}.ti-cloud-question:before{content:"\f858"}.ti-cloud-rain:before{content:"\ea72"}.ti-cloud-search:before{content:"\f859"}.ti-cloud-share:before{content:"\f85a"}.ti-cloud-snow:before{content:"\ea73"}.ti-cloud-star:before{content:"\f85b"}.ti-cloud-storm:before{content:"\ea74"}.ti-cloud-up:before{content:"\f85c"}.ti-cloud-upload:before{content:"\ea75"}.ti-cloud-x:before{content:"\f85d"}.ti-clover:before{content:"\f1ea"}.ti-clover-2:before{content:"\f21e"}.ti-clover-filled:before{content:"\10013"}.ti-clubs:before{content:"\eff4"}.ti-clubs-filled:before{content:"\f674"}.ti-code:before{content:"\ea77"}.ti-code-asterisk:before{content:"\f312"}.ti-code-circle:before{content:"\f4ff"}.ti-code-circle-2:before{content:"\f4fe"}.ti-code-circle-2-filled:before{content:"\fed4"}.ti-code-circle-filled:before{content:"\fed3"}.ti-code-dots:before{content:"\f61a"}.ti-code-minus:before{content:"\ee42"}.ti-code-off:before{content:"\f0d0"}.ti-code-plus:before{content:"\ee43"}.ti-code-variable:before{content:"\100ab"}.ti-code-variable-minus:before{content:"\100ad"}.ti-code-variable-plus:before{content:"\100ac"}.ti-coffee:before{content:"\ef0e"}.ti-coffee-off:before{content:"\f106"}.ti-coffin:before{content:"\f579"}.ti-coin:before{content:"\eb82"}.ti-coin-bitcoin:before{content:"\f2be"}.ti-coin-bitcoin-filled:before{content:"\fd06"}.ti-coin-euro:before{content:"\f2bf"}.ti-coin-euro-filled:before{content:"\fd07"}.ti-coin-filled:before{content:"\fd08"}.ti-coin-monero:before{content:"\f4a0"}.ti-coin-monero-filled:before{content:"\fd09"}.ti-coin-off:before{content:"\f0d1"}.ti-coin-pound:before{content:"\f2c0"}.ti-coin-pound-filled:before{content:"\fd0a"}.ti-coin-rupee:before{content:"\f2c1"}.ti-coin-rupee-filled:before{content:"\fd0b"}.ti-coin-taka:before{content:"\fd0d"}.ti-coin-taka-filled:before{content:"\fd0c"}.ti-coin-yen:before{content:"\f2c2"}.ti-coin-yen-filled:before{content:"\fd0e"}.ti-coin-yuan:before{content:"\f2c3"}.ti-coin-yuan-filled:before{content:"\fd0f"}.ti-coins:before{content:"\f65d"}.ti-color-filter:before{content:"\f5a8"}.ti-color-picker:before{content:"\ebe6"}.ti-color-picker-off:before{content:"\f0d2"}.ti-color-swatch:before{content:"\eb61"}.ti-color-swatch-off:before{content:"\f0d3"}.ti-column-insert-left:before{content:"\ee44"}.ti-column-insert-right:before{content:"\ee45"}.ti-column-remove:before{content:"\faf4"}.ti-columns:before{content:"\eb83"}.ti-columns-1:before{content:"\f6d4"}.ti-columns-2:before{content:"\f6d5"}.ti-columns-3:before{content:"\f6d6"}.ti-columns-off:before{content:"\f0d4"}.ti-comet:before{content:"\ec76"}.ti-command:before{content:"\ea78"}.ti-command-off:before{content:"\f3d7"}.ti-compass:before{content:"\ea79"}.ti-compass-filled:before{content:"\fd10"}.ti-compass-off:before{content:"\f0d5"}.ti-components:before{content:"\efa5"}.ti-components-off:before{content:"\f0d6"}.ti-cone:before{content:"\efdd"}.ti-cone-2:before{content:"\efdc"}.ti-cone-2-filled:before{content:"\fe59"}.ti-cone-filled:before{content:"\fe58"}.ti-cone-off:before{content:"\f3d8"}.ti-cone-plus:before{content:"\fa94"}.ti-confetti:before{content:"\ee46"}.ti-confetti-off:before{content:"\f3d9"}.ti-confucius:before{content:"\f58a"}.ti-congruent-to:before{content:"\ffa3"}.ti-container:before{content:"\ee47"}.ti-container-off:before{content:"\f107"}.ti-contract:before{content:"\fefb"}.ti-contrast:before{content:"\ec4e"}.ti-contrast-2:before{content:"\efc7"}.ti-contrast-2-filled:before{content:"\fe57"}.ti-contrast-2-off:before{content:"\f3da"}.ti-contrast-filled:before{content:"\fe56"}.ti-contrast-off:before{content:"\f3db"}.ti-cooker:before{content:"\f57a"}.ti-cookie:before{content:"\fdb1"}.ti-cookie-filled:before{content:"\fe54"}.ti-cookie-man:before{content:"\fdb2"}.ti-cookie-man-filled:before{content:"\fe55"}.ti-cookie-off:before{content:"\f0d7"}.ti-copy:before{content:"\ea7a"}.ti-copy-check:before{content:"\fdb0"}.ti-copy-check-filled:before{content:"\fe53"}.ti-copy-minus:before{content:"\fdaf"}.ti-copy-minus-filled:before{content:"\fe52"}.ti-copy-off:before{content:"\f0d8"}.ti-copy-plus:before{content:"\fdae"}.ti-copy-plus-filled:before{content:"\fe51"}.ti-copy-x:before{content:"\fdad"}.ti-copy-x-filled:before{content:"\fe50"}.ti-copyleft:before{content:"\ec3d"}.ti-copyleft-filled:before{content:"\f73b"}.ti-copyleft-off:before{content:"\f0d9"}.ti-copyright:before{content:"\ea7b"}.ti-copyright-filled:before{content:"\f73c"}.ti-copyright-off:before{content:"\f0da"}.ti-corner-down-left:before{content:"\ea7c"}.ti-corner-down-left-double:before{content:"\ee48"}.ti-corner-down-right:before{content:"\ea7d"}.ti-corner-down-right-double:before{content:"\ee49"}.ti-corner-left-down:before{content:"\ea7e"}.ti-corner-left-down-double:before{content:"\ee4a"}.ti-corner-left-up:before{content:"\ea7f"}.ti-corner-left-up-double:before{content:"\ee4b"}.ti-corner-right-down:before{content:"\ea80"}.ti-corner-right-down-double:before{content:"\ee4c"}.ti-corner-right-up:before{content:"\ea81"}.ti-corner-right-up-double:before{content:"\ee4d"}.ti-corner-up-left:before{content:"\ea82"}.ti-corner-up-left-double:before{content:"\ee4e"}.ti-corner-up-right:before{content:"\ea83"}.ti-corner-up-right-double:before{content:"\ee4f"}.ti-cpu:before{content:"\ef8e"}.ti-cpu-2:before{content:"\f075"}.ti-cpu-off:before{content:"\f108"}.ti-crane:before{content:"\ef27"}.ti-crane-off:before{content:"\f109"}.ti-creative-commons:before{content:"\efb3"}.ti-creative-commons-by:before{content:"\f21f"}.ti-creative-commons-nc:before{content:"\f220"}.ti-creative-commons-nd:before{content:"\f221"}.ti-creative-commons-off:before{content:"\f10a"}.ti-creative-commons-sa:before{content:"\f222"}.ti-creative-commons-zero:before{content:"\f223"}.ti-credit-card:before{content:"\ea84"}.ti-credit-card-filled:before{content:"\fd11"}.ti-credit-card-off:before{content:"\ed11"}.ti-credit-card-pay:before{content:"\fd32"}.ti-credit-card-refund:before{content:"\fd33"}.ti-cricket:before{content:"\f09a"}.ti-crop:before{content:"\ea85"}.ti-crop-1-1:before{content:"\fd50"}.ti-crop-1-1-filled:before{content:"\fe4f"}.ti-crop-16-9:before{content:"\fd51"}.ti-crop-16-9-filled:before{content:"\fe4e"}.ti-crop-3-2:before{content:"\fd52"}.ti-crop-3-2-filled:before{content:"\fe4d"}.ti-crop-5-4:before{content:"\fd53"}.ti-crop-5-4-filled:before{content:"\fe4c"}.ti-crop-7-5:before{content:"\fd54"}.ti-crop-7-5-filled:before{content:"\fe4b"}.ti-crop-landscape:before{content:"\fd55"}.ti-crop-landscape-filled:before{content:"\fe4a"}.ti-crop-portrait:before{content:"\fd56"}.ti-crop-portrait-filled:before{content:"\fe49"}.ti-cross:before{content:"\ef8f"}.ti-cross-filled:before{content:"\f675"}.ti-cross-off:before{content:"\f10b"}.ti-crosshair:before{content:"\ec3e"}.ti-crown:before{content:"\ed12"}.ti-crown-off:before{content:"\ee50"}.ti-crutches:before{content:"\ef5b"}.ti-crutches-off:before{content:"\f10c"}.ti-crystal-ball:before{content:"\f57b"}.ti-csv:before{content:"\f791"}.ti-cube:before{content:"\fa97"}.ti-cube-3d-sphere:before{content:"\ecd7"}.ti-cube-3d-sphere-off:before{content:"\f3b5"}.ti-cube-off:before{content:"\fa95"}.ti-cube-plus:before{content:"\fa96"}.ti-cube-send:before{content:"\f61b"}.ti-cube-spark:before{content:"\ffbb"}.ti-cube-unfolded:before{content:"\f61c"}.ti-cup:before{content:"\ef28"}.ti-cup-off:before{content:"\f10d"}.ti-curling:before{content:"\efc8"}.ti-curly-loop:before{content:"\ecda"}.ti-currency:before{content:"\efa6"}.ti-currency-afghani:before{content:"\f65e"}.ti-currency-bahraini:before{content:"\ee51"}.ti-currency-baht:before{content:"\f08a"}.ti-currency-bitcoin:before{content:"\ebab"}.ti-currency-cent:before{content:"\ee53"}.ti-currency-dinar:before{content:"\ee54"}.ti-currency-dirham:before{content:"\ee55"}.ti-currency-dogecoin:before{content:"\ef4b"}.ti-currency-dollar:before{content:"\eb84"}.ti-currency-dollar-australian:before{content:"\ee56"}.ti-currency-dollar-brunei:before{content:"\f36c"}.ti-currency-dollar-canadian:before{content:"\ee57"}.ti-currency-dollar-guyanese:before{content:"\f36d"}.ti-currency-dollar-off:before{content:"\f3dc"}.ti-currency-dollar-singapore:before{content:"\ee58"}.ti-currency-dollar-zimbabwean:before{content:"\f36e"}.ti-currency-dong:before{content:"\f36f"}.ti-currency-dram:before{content:"\f370"}.ti-currency-ethereum:before{content:"\ee59"}.ti-currency-euro:before{content:"\eb85"}.ti-currency-euro-off:before{content:"\f3dd"}.ti-currency-florin:before{content:"\faf5"}.ti-currency-forint:before{content:"\ee5a"}.ti-currency-frank:before{content:"\ee5b"}.ti-currency-guarani:before{content:"\f371"}.ti-currency-hryvnia:before{content:"\f372"}.ti-currency-iranian-rial:before{content:"\fa58"}.ti-currency-kip:before{content:"\f373"}.ti-currency-krone-czech:before{content:"\ee5c"}.ti-currency-krone-danish:before{content:"\ee5d"}.ti-currency-krone-swedish:before{content:"\ee5e"}.ti-currency-lari:before{content:"\f374"}.ti-currency-leu:before{content:"\ee5f"}.ti-currency-lira:before{content:"\ee60"}.ti-currency-litecoin:before{content:"\ee61"}.ti-currency-lyd:before{content:"\f375"}.ti-currency-manat:before{content:"\f376"}.ti-currency-monero:before{content:"\f377"}.ti-currency-naira:before{content:"\ee62"}.ti-currency-nano:before{content:"\f7a6"}.ti-currency-off:before{content:"\f3de"}.ti-currency-paanga:before{content:"\f378"}.ti-currency-peso:before{content:"\f65f"}.ti-currency-pound:before{content:"\ebac"}.ti-currency-pound-off:before{content:"\f3df"}.ti-currency-quetzal:before{content:"\f379"}.ti-currency-real:before{content:"\ee63"}.ti-currency-renminbi:before{content:"\ee64"}.ti-currency-ripple:before{content:"\ee65"}.ti-currency-riyal:before{content:"\ee66"}.ti-currency-rubel:before{content:"\ee67"}.ti-currency-rufiyaa:before{content:"\f37a"}.ti-currency-rupee:before{content:"\ebad"}.ti-currency-rupee-nepalese:before{content:"\f37b"}.ti-currency-shekel:before{content:"\ee68"}.ti-currency-solana:before{content:"\f4a1"}.ti-currency-som:before{content:"\f37c"}.ti-currency-taka:before{content:"\ee69"}.ti-currency-tenge:before{content:"\f37d"}.ti-currency-tugrik:before{content:"\ee6a"}.ti-currency-won:before{content:"\ee6b"}.ti-currency-xrp:before{content:"\fd34"}.ti-currency-yen:before{content:"\ebae"}.ti-currency-yen-off:before{content:"\f3e0"}.ti-currency-yuan:before{content:"\f29a"}.ti-currency-zloty:before{content:"\ee6c"}.ti-current-location:before{content:"\ecef"}.ti-current-location-off:before{content:"\f10e"}.ti-cursor-off:before{content:"\f10f"}.ti-cursor-text:before{content:"\ee6d"}.ti-cut:before{content:"\ea86"}.ti-cylinder:before{content:"\f54c"}.ti-cylinder-off:before{content:"\fa98"}.ti-cylinder-plus:before{content:"\fa99"}.ti-dashboard:before{content:"\ea87"}.ti-dashboard-filled:before{content:"\10019"}.ti-dashboard-off:before{content:"\f3e1"}.ti-database:before{content:"\ea88"}.ti-database-cog:before{content:"\fa10"}.ti-database-dollar:before{content:"\fa11"}.ti-database-edit:before{content:"\fa12"}.ti-database-exclamation:before{content:"\fa13"}.ti-database-export:before{content:"\ee6e"}.ti-database-heart:before{content:"\fa14"}.ti-database-import:before{content:"\ee6f"}.ti-database-leak:before{content:"\fa15"}.ti-database-minus:before{content:"\fa16"}.ti-database-off:before{content:"\ee70"}.ti-database-plus:before{content:"\fa17"}.ti-database-search:before{content:"\fa18"}.ti-database-share:before{content:"\fa19"}.ti-database-smile:before{content:"\fd9b"}.ti-database-star:before{content:"\fa1a"}.ti-database-x:before{content:"\fa1b"}.ti-decimal:before{content:"\fa26"}.ti-deer:before{content:"\f4c5"}.ti-delta:before{content:"\f53c"}.ti-dental:before{content:"\f025"}.ti-dental-broken:before{content:"\f286"}.ti-dental-off:before{content:"\f110"}.ti-deselect:before{content:"\f9f3"}.ti-desk:before{content:"\fd35"}.ti-details:before{content:"\ee71"}.ti-details-off:before{content:"\f3e2"}.ti-device-airpods:before{content:"\f5a9"}.ti-device-airpods-case:before{content:"\f646"}.ti-device-airtag:before{content:"\fae6"}.ti-device-analytics:before{content:"\ee72"}.ti-device-audio-tape:before{content:"\ee73"}.ti-device-camera-phone:before{content:"\f233"}.ti-device-cctv:before{content:"\ee74"}.ti-device-cctv-filled:before{content:"\1004b"}.ti-device-cctv-off:before{content:"\f3e3"}.ti-device-computer-camera:before{content:"\ee76"}.ti-device-computer-camera-off:before{content:"\ee75"}.ti-device-desktop:before{content:"\ea89"}.ti-device-desktop-analytics:before{content:"\ee77"}.ti-device-desktop-bolt:before{content:"\f85e"}.ti-device-desktop-cancel:before{content:"\f85f"}.ti-device-desktop-check:before{content:"\f860"}.ti-device-desktop-code:before{content:"\f861"}.ti-device-desktop-cog:before{content:"\f862"}.ti-device-desktop-dollar:before{content:"\f863"}.ti-device-desktop-down:before{content:"\f864"}.ti-device-desktop-exclamation:before{content:"\f865"}.ti-device-desktop-filled:before{content:"\1004a"}.ti-device-desktop-heart:before{content:"\f866"}.ti-device-desktop-minus:before{content:"\f867"}.ti-device-desktop-off:before{content:"\ee78"}.ti-device-desktop-pause:before{content:"\f868"}.ti-device-desktop-pin:before{content:"\f869"}.ti-device-desktop-plus:before{content:"\f86a"}.ti-device-desktop-question:before{content:"\f86b"}.ti-device-desktop-search:before{content:"\f86c"}.ti-device-desktop-share:before{content:"\f86d"}.ti-device-desktop-star:before{content:"\f86e"}.ti-device-desktop-up:before{content:"\f86f"}.ti-device-desktop-x:before{content:"\f870"}.ti-device-floppy:before{content:"\eb62"}.ti-device-gamepad:before{content:"\eb63"}.ti-device-gamepad-2:before{content:"\f1d2"}.ti-device-gamepad-3:before{content:"\fc58"}.ti-device-gamepad-3-filled:before{content:"\10049"}.ti-device-heart-monitor:before{content:"\f060"}.ti-device-heart-monitor-filled:before{content:"\fa38"}.ti-device-imac:before{content:"\f7a7"}.ti-device-imac-bolt:before{content:"\f871"}.ti-device-imac-cancel:before{content:"\f872"}.ti-device-imac-check:before{content:"\f873"}.ti-device-imac-code:before{content:"\f874"}.ti-device-imac-cog:before{content:"\f875"}.ti-device-imac-dollar:before{content:"\f876"}.ti-device-imac-down:before{content:"\f877"}.ti-device-imac-exclamation:before{content:"\f878"}.ti-device-imac-filled:before{content:"\10048"}.ti-device-imac-heart:before{content:"\f879"}.ti-device-imac-minus:before{content:"\f87a"}.ti-device-imac-off:before{content:"\f87b"}.ti-device-imac-pause:before{content:"\f87c"}.ti-device-imac-pin:before{content:"\f87d"}.ti-device-imac-plus:before{content:"\f87e"}.ti-device-imac-question:before{content:"\f87f"}.ti-device-imac-search:before{content:"\f880"}.ti-device-imac-share:before{content:"\f881"}.ti-device-imac-star:before{content:"\f882"}.ti-device-imac-up:before{content:"\f883"}.ti-device-imac-x:before{content:"\f884"}.ti-device-ipad:before{content:"\f648"}.ti-device-ipad-bolt:before{content:"\f885"}.ti-device-ipad-cancel:before{content:"\f886"}.ti-device-ipad-check:before{content:"\f887"}.ti-device-ipad-code:before{content:"\f888"}.ti-device-ipad-cog:before{content:"\f889"}.ti-device-ipad-dollar:before{content:"\f88a"}.ti-device-ipad-down:before{content:"\f88b"}.ti-device-ipad-exclamation:before{content:"\f88c"}.ti-device-ipad-filled:before{content:"\10047"}.ti-device-ipad-heart:before{content:"\f88d"}.ti-device-ipad-horizontal:before{content:"\f647"}.ti-device-ipad-horizontal-bolt:before{content:"\f88e"}.ti-device-ipad-horizontal-cancel:before{content:"\f88f"}.ti-device-ipad-horizontal-check:before{content:"\f890"}.ti-device-ipad-horizontal-code:before{content:"\f891"}.ti-device-ipad-horizontal-cog:before{content:"\f892"}.ti-device-ipad-horizontal-dollar:before{content:"\f893"}.ti-device-ipad-horizontal-down:before{content:"\f894"}.ti-device-ipad-horizontal-exclamation:before{content:"\f895"}.ti-device-ipad-horizontal-heart:before{content:"\f896"}.ti-device-ipad-horizontal-minus:before{content:"\f897"}.ti-device-ipad-horizontal-off:before{content:"\f898"}.ti-device-ipad-horizontal-pause:before{content:"\f899"}.ti-device-ipad-horizontal-pin:before{content:"\f89a"}.ti-device-ipad-horizontal-plus:before{content:"\f89b"}.ti-device-ipad-horizontal-question:before{content:"\f89c"}.ti-device-ipad-horizontal-search:before{content:"\f89d"}.ti-device-ipad-horizontal-share:before{content:"\f89e"}.ti-device-ipad-horizontal-star:before{content:"\f89f"}.ti-device-ipad-horizontal-up:before{content:"\f8a0"}.ti-device-ipad-horizontal-x:before{content:"\f8a1"}.ti-device-ipad-minus:before{content:"\f8a2"}.ti-device-ipad-off:before{content:"\f8a3"}.ti-device-ipad-pause:before{content:"\f8a4"}.ti-device-ipad-pin:before{content:"\f8a5"}.ti-device-ipad-plus:before{content:"\f8a6"}.ti-device-ipad-question:before{content:"\f8a7"}.ti-device-ipad-search:before{content:"\f8a8"}.ti-device-ipad-share:before{content:"\f8a9"}.ti-device-ipad-star:before{content:"\f8aa"}.ti-device-ipad-up:before{content:"\f8ab"}.ti-device-ipad-x:before{content:"\f8ac"}.ti-device-landline-phone:before{content:"\f649"}.ti-device-laptop:before{content:"\eb64"}.ti-device-laptop-off:before{content:"\f061"}.ti-device-mobile:before{content:"\ea8a"}.ti-device-mobile-bolt:before{content:"\f8ad"}.ti-device-mobile-cancel:before{content:"\f8ae"}.ti-device-mobile-charging:before{content:"\f224"}.ti-device-mobile-check:before{content:"\f8af"}.ti-device-mobile-code:before{content:"\f8b0"}.ti-device-mobile-cog:before{content:"\f8b1"}.ti-device-mobile-dollar:before{content:"\f8b2"}.ti-device-mobile-down:before{content:"\f8b3"}.ti-device-mobile-exclamation:before{content:"\f8b4"}.ti-device-mobile-filled:before{content:"\fa39"}.ti-device-mobile-heart:before{content:"\f8b5"}.ti-device-mobile-message:before{content:"\ee79"}.ti-device-mobile-minus:before{content:"\f8b6"}.ti-device-mobile-off:before{content:"\f062"}.ti-device-mobile-pause:before{content:"\f8b7"}.ti-device-mobile-pin:before{content:"\f8b8"}.ti-device-mobile-plus:before{content:"\f8b9"}.ti-device-mobile-question:before{content:"\f8ba"}.ti-device-mobile-rotated:before{content:"\ecdb"}.ti-device-mobile-search:before{content:"\f8bb"}.ti-device-mobile-share:before{content:"\f8bc"}.ti-device-mobile-star:before{content:"\f8bd"}.ti-device-mobile-up:before{content:"\f8be"}.ti-device-mobile-vibration:before{content:"\eb86"}.ti-device-mobile-x:before{content:"\f8bf"}.ti-device-nintendo:before{content:"\f026"}.ti-device-nintendo-off:before{content:"\f111"}.ti-device-projector:before{content:"\fc11"}.ti-device-remote:before{content:"\f792"}.ti-device-remote-filled:before{content:"\10046"}.ti-device-sd-card:before{content:"\f384"}.ti-device-sim:before{content:"\f4b2"}.ti-device-sim-1:before{content:"\f4af"}.ti-device-sim-2:before{content:"\f4b0"}.ti-device-sim-3:before{content:"\f4b1"}.ti-device-speaker:before{content:"\ea8b"}.ti-device-speaker-filled:before{content:"\10045"}.ti-device-speaker-off:before{content:"\f112"}.ti-device-tablet:before{content:"\ea8c"}.ti-device-tablet-bolt:before{content:"\f8c0"}.ti-device-tablet-cancel:before{content:"\f8c1"}.ti-device-tablet-check:before{content:"\f8c2"}.ti-device-tablet-code:before{content:"\f8c3"}.ti-device-tablet-cog:before{content:"\f8c4"}.ti-device-tablet-dollar:before{content:"\f8c5"}.ti-device-tablet-down:before{content:"\f8c6"}.ti-device-tablet-exclamation:before{content:"\f8c7"}.ti-device-tablet-filled:before{content:"\fa3a"}.ti-device-tablet-heart:before{content:"\f8c8"}.ti-device-tablet-minus:before{content:"\f8c9"}.ti-device-tablet-off:before{content:"\f063"}.ti-device-tablet-pause:before{content:"\f8ca"}.ti-device-tablet-pin:before{content:"\f8cb"}.ti-device-tablet-plus:before{content:"\f8cc"}.ti-device-tablet-question:before{content:"\f8cd"}.ti-device-tablet-search:before{content:"\f8ce"}.ti-device-tablet-share:before{content:"\f8cf"}.ti-device-tablet-star:before{content:"\f8d0"}.ti-device-tablet-up:before{content:"\f8d1"}.ti-device-tablet-x:before{content:"\f8d2"}.ti-device-tv:before{content:"\ea8d"}.ti-device-tv-filled:before{content:"\10043"}.ti-device-tv-off:before{content:"\f064"}.ti-device-tv-old:before{content:"\f1d3"}.ti-device-tv-old-filled:before{content:"\10044"}.ti-device-unknown:before{content:"\fef4"}.ti-device-unknown-filled:before{content:"\10018"}.ti-device-usb:before{content:"\fc59"}.ti-device-usb-filled:before{content:"\10042"}.ti-device-vision-pro:before{content:"\fae7"}.ti-device-vision-pro-filled:before{content:"\10041"}.ti-device-watch:before{content:"\ebf9"}.ti-device-watch-bolt:before{content:"\f8d3"}.ti-device-watch-cancel:before{content:"\f8d4"}.ti-device-watch-check:before{content:"\f8d5"}.ti-device-watch-code:before{content:"\f8d6"}.ti-device-watch-cog:before{content:"\f8d7"}.ti-device-watch-dollar:before{content:"\f8d8"}.ti-device-watch-down:before{content:"\f8d9"}.ti-device-watch-exclamation:before{content:"\f8da"}.ti-device-watch-filled:before{content:"\10040"}.ti-device-watch-heart:before{content:"\f8db"}.ti-device-watch-minus:before{content:"\f8dc"}.ti-device-watch-off:before{content:"\f065"}.ti-device-watch-pause:before{content:"\f8dd"}.ti-device-watch-pin:before{content:"\f8de"}.ti-device-watch-plus:before{content:"\f8df"}.ti-device-watch-question:before{content:"\f8e0"}.ti-device-watch-search:before{content:"\f8e1"}.ti-device-watch-share:before{content:"\f8e2"}.ti-device-watch-star:before{content:"\f8e3"}.ti-device-watch-stats:before{content:"\ef7d"}.ti-device-watch-stats-2:before{content:"\ef7c"}.ti-device-watch-up:before{content:"\f8e4"}.ti-device-watch-x:before{content:"\f8e5"}.ti-devices:before{content:"\eb87"}.ti-devices-2:before{content:"\ed29"}.ti-devices-bolt:before{content:"\f8e6"}.ti-devices-cancel:before{content:"\f8e7"}.ti-devices-check:before{content:"\f8e8"}.ti-devices-code:before{content:"\f8e9"}.ti-devices-cog:before{content:"\f8ea"}.ti-devices-dollar:before{content:"\f8eb"}.ti-devices-down:before{content:"\f8ec"}.ti-devices-exclamation:before{content:"\f8ed"}.ti-devices-heart:before{content:"\f8ee"}.ti-devices-minus:before{content:"\f8ef"}.ti-devices-off:before{content:"\f3e4"}.ti-devices-pause:before{content:"\f8f0"}.ti-devices-pc:before{content:"\ee7a"}.ti-devices-pc-off:before{content:"\f113"}.ti-devices-pin:before{content:"\f8f1"}.ti-devices-plus:before{content:"\f8f2"}.ti-devices-question:before{content:"\f8f3"}.ti-devices-search:before{content:"\f8f4"}.ti-devices-share:before{content:"\f8f5"}.ti-devices-star:before{content:"\f8f6"}.ti-devices-up:before{content:"\f8f7"}.ti-devices-x:before{content:"\f8f8"}.ti-diabolo:before{content:"\fa9c"}.ti-diabolo-off:before{content:"\fa9a"}.ti-diabolo-plus:before{content:"\fa9b"}.ti-dialpad:before{content:"\f067"}.ti-dialpad-filled:before{content:"\fa3b"}.ti-dialpad-off:before{content:"\f114"}.ti-diamond:before{content:"\eb65"}.ti-diamond-filled:before{content:"\f73d"}.ti-diamond-off:before{content:"\f115"}.ti-diamonds:before{content:"\eff5"}.ti-diamonds-filled:before{content:"\f676"}.ti-diaper:before{content:"\ffa2"}.ti-dice:before{content:"\eb66"}.ti-dice-1:before{content:"\f08b"}.ti-dice-1-filled:before{content:"\f73e"}.ti-dice-2:before{content:"\f08c"}.ti-dice-2-filled:before{content:"\f73f"}.ti-dice-3:before{content:"\f08d"}.ti-dice-3-filled:before{content:"\f740"}.ti-dice-4:before{content:"\f08e"}.ti-dice-4-filled:before{content:"\f741"}.ti-dice-5:before{content:"\f08f"}.ti-dice-5-filled:before{content:"\f742"}.ti-dice-6:before{content:"\f090"}.ti-dice-6-filled:before{content:"\f743"}.ti-dice-filled:before{content:"\f744"}.ti-dimensions:before{content:"\ee7b"}.ti-direction:before{content:"\ebfb"}.ti-direction-arrows:before{content:"\fd36"}.ti-direction-arrows-filled:before{content:"\100ca"}.ti-direction-horizontal:before{content:"\ebfa"}.ti-direction-sign:before{content:"\f1f7"}.ti-direction-sign-filled:before{content:"\f745"}.ti-direction-sign-off:before{content:"\f3e5"}.ti-directions:before{content:"\ea8e"}.ti-directions-filled:before{content:"\1003f"}.ti-directions-off:before{content:"\f116"}.ti-disabled:before{content:"\ea8f"}.ti-disabled-2:before{content:"\ebaf"}.ti-disabled-off:before{content:"\f117"}.ti-disc:before{content:"\ea90"}.ti-disc-filled:before{content:"\1003e"}.ti-disc-golf:before{content:"\f385"}.ti-disc-off:before{content:"\f118"}.ti-discount:before{content:"\ebbd"}.ti-discount-filled:before{content:"\1003d"}.ti-discount-off:before{content:"\f3e7"}.ti-divide:before{content:"\ed5c"}.ti-dna:before{content:"\ee7d"}.ti-dna-2:before{content:"\ef5c"}.ti-dna-2-off:before{content:"\f119"}.ti-dna-off:before{content:"\f11a"}.ti-dog:before{content:"\f660"}.ti-dog-bowl:before{content:"\ef29"}.ti-door:before{content:"\ef4e"}.ti-door-enter:before{content:"\ef4c"}.ti-door-exit:before{content:"\ef4d"}.ti-door-off:before{content:"\f11b"}.ti-dots:before{content:"\ea95"}.ti-dots-circle-horizontal:before{content:"\ea91"}.ti-dots-diagonal:before{content:"\ea93"}.ti-dots-diagonal-2:before{content:"\ea92"}.ti-dots-vertical:before{content:"\ea94"}.ti-download:before{content:"\ea96"}.ti-download-off:before{content:"\f11c"}.ti-drag-drop:before{content:"\eb89"}.ti-drag-drop-2:before{content:"\eb88"}.ti-drone:before{content:"\ed79"}.ti-drone-off:before{content:"\ee7e"}.ti-drop-circle:before{content:"\efde"}.ti-droplet:before{content:"\ea97"}.ti-droplet-bolt:before{content:"\f8f9"}.ti-droplet-cancel:before{content:"\f8fa"}.ti-droplet-check:before{content:"\f8fb"}.ti-droplet-code:before{content:"\f8fc"}.ti-droplet-cog:before{content:"\f8fd"}.ti-droplet-dollar:before{content:"\f8fe"}.ti-droplet-down:before{content:"\f8ff"}.ti-droplet-exclamation:before{content:"\f900"}.ti-droplet-filled:before{content:"\ee80"}.ti-droplet-half:before{content:"\ee82"}.ti-droplet-half-2:before{content:"\ee81"}.ti-droplet-half-2-filled:before{content:"\fb6c"}.ti-droplet-half-filled:before{content:"\f6c5"}.ti-droplet-heart:before{content:"\f901"}.ti-droplet-minus:before{content:"\f902"}.ti-droplet-off:before{content:"\ee83"}.ti-droplet-pause:before{content:"\f903"}.ti-droplet-pin:before{content:"\f904"}.ti-droplet-plus:before{content:"\f905"}.ti-droplet-question:before{content:"\f906"}.ti-droplet-search:before{content:"\f907"}.ti-droplet-share:before{content:"\f908"}.ti-droplet-star:before{content:"\f909"}.ti-droplet-up:before{content:"\f90a"}.ti-droplet-x:before{content:"\f90b"}.ti-droplets:before{content:"\fc12"}.ti-droplets-filled:before{content:"\100c9"}.ti-dual-screen:before{content:"\fa59"}.ti-dumpling:before{content:"\feb5"}.ti-e-passport:before{content:"\f4df"}.ti-ear:before{content:"\ebce"}.ti-ear-off:before{content:"\ee84"}.ti-ear-scan:before{content:"\fd57"}.ti-ease-in:before{content:"\f573"}.ti-ease-in-control-point:before{content:"\f570"}.ti-ease-in-out:before{content:"\f572"}.ti-ease-in-out-control-points:before{content:"\f571"}.ti-ease-out:before{content:"\f575"}.ti-ease-out-control-point:before{content:"\f574"}.ti-edit:before{content:"\ea98"}.ti-edit-circle:before{content:"\ee85"}.ti-edit-circle-off:before{content:"\f11d"}.ti-edit-off:before{content:"\f11e"}.ti-egg:before{content:"\eb8a"}.ti-egg-cracked:before{content:"\f2d6"}.ti-egg-cracked-filled:before{content:"\10012"}.ti-egg-filled:before{content:"\f678"}.ti-egg-fried:before{content:"\f386"}.ti-egg-off:before{content:"\f11f"}.ti-eggs:before{content:"\f500"}.ti-elevator:before{content:"\efdf"}.ti-elevator-filled:before{content:"\1003c"}.ti-elevator-off:before{content:"\f3e8"}.ti-emergency-bed:before{content:"\ef5d"}.ti-empathize:before{content:"\f29b"}.ti-empathize-off:before{content:"\f3e9"}.ti-emphasis:before{content:"\ebcf"}.ti-engine:before{content:"\ef7e"}.ti-engine-off:before{content:"\f120"}.ti-equal:before{content:"\ee87"}.ti-equal-double:before{content:"\f4e1"}.ti-equal-not:before{content:"\ee86"}.ti-eraser:before{content:"\eb8b"}.ti-eraser-off:before{content:"\f121"}.ti-error-404:before{content:"\f027"}.ti-error-404-off:before{content:"\f122"}.ti-escalator:before{content:"\fb06"}.ti-escalator-down:before{content:"\fb04"}.ti-escalator-up:before{content:"\fb05"}.ti-exchange:before{content:"\ebe7"}.ti-exchange-off:before{content:"\f123"}.ti-exclamation-circle:before{content:"\f634"}.ti-exclamation-circle-filled:before{content:"\ff62"}.ti-exclamation-mark:before{content:"\efb4"}.ti-exclamation-mark-off:before{content:"\f124"}.ti-explicit:before{content:"\f256"}.ti-explicit-off:before{content:"\f3ea"}.ti-exposure:before{content:"\eb8c"}.ti-exposure-0:before{content:"\f29c"}.ti-exposure-minus-1:before{content:"\f29d"}.ti-exposure-minus-2:before{content:"\f29e"}.ti-exposure-off:before{content:"\f3eb"}.ti-exposure-plus-1:before{content:"\f29f"}.ti-exposure-plus-2:before{content:"\f2a0"}.ti-external-link:before{content:"\ea99"}.ti-external-link-off:before{content:"\f125"}.ti-eye:before{content:"\ea9a"}.ti-eye-bitcoin:before{content:"\ff3d"}.ti-eye-bolt:before{content:"\fb6d"}.ti-eye-cancel:before{content:"\fb6e"}.ti-eye-check:before{content:"\ee88"}.ti-eye-closed:before{content:"\f7ec"}.ti-eye-code:before{content:"\fb6f"}.ti-eye-cog:before{content:"\f7ed"}.ti-eye-discount:before{content:"\fb70"}.ti-eye-dollar:before{content:"\fb71"}.ti-eye-dotted:before{content:"\fead"}.ti-eye-down:before{content:"\fb72"}.ti-eye-edit:before{content:"\f7ee"}.ti-eye-exclamation:before{content:"\f7ef"}.ti-eye-filled:before{content:"\f679"}.ti-eye-heart:before{content:"\f7f0"}.ti-eye-minus:before{content:"\fb73"}.ti-eye-off:before{content:"\ecf0"}.ti-eye-pause:before{content:"\fb74"}.ti-eye-pin:before{content:"\fb75"}.ti-eye-plus:before{content:"\fb76"}.ti-eye-question:before{content:"\fb77"}.ti-eye-search:before{content:"\fb78"}.ti-eye-share:before{content:"\fb79"}.ti-eye-spark:before{content:"\ffba"}.ti-eye-star:before{content:"\fb7a"}.ti-eye-table:before{content:"\ef5e"}.ti-eye-up:before{content:"\fb7b"}.ti-eye-x:before{content:"\f7f1"}.ti-eyeglass:before{content:"\ee8a"}.ti-eyeglass-2:before{content:"\ee89"}.ti-eyeglass-filled:before{content:"\100c8"}.ti-eyeglass-off:before{content:"\f126"}.ti-face-id:before{content:"\ea9b"}.ti-face-id-error:before{content:"\efa7"}.ti-face-mask:before{content:"\efb5"}.ti-face-mask-off:before{content:"\f127"}.ti-fall:before{content:"\ecb9"}.ti-favicon:before{content:"\fd65"}.ti-favicon-filled:before{content:"\10071"}.ti-feather:before{content:"\ee8b"}.ti-feather-filled:before{content:"\10011"}.ti-feather-off:before{content:"\f128"}.ti-fence:before{content:"\ef2a"}.ti-fence-off:before{content:"\f129"}.ti-ferry:before{content:"\10074"}.ti-fidget-spinner:before{content:"\f068"}.ti-file:before{content:"\eaa4"}.ti-file-3d:before{content:"\f032"}.ti-file-ai:before{content:"\ffa1"}.ti-file-alert:before{content:"\ede6"}.ti-file-analytics:before{content:"\ede7"}.ti-file-arrow-left:before{content:"\f033"}.ti-file-arrow-right:before{content:"\f034"}.ti-file-barcode:before{content:"\f035"}.ti-file-bitcoin:before{content:"\ffa0"}.ti-file-broken:before{content:"\f501"}.ti-file-certificate:before{content:"\ed4d"}.ti-file-chart:before{content:"\f036"}.ti-file-check:before{content:"\ea9c"}.ti-file-code:before{content:"\ebd0"}.ti-file-code-2:before{content:"\ede8"}.ti-file-cv:before{content:"\fa5a"}.ti-file-database:before{content:"\f037"}.ti-file-delta:before{content:"\f53d"}.ti-file-description:before{content:"\f028"}.ti-file-diff:before{content:"\ecf1"}.ti-file-digit:before{content:"\efa8"}.ti-file-dislike:before{content:"\ed2a"}.ti-file-dollar:before{content:"\efe0"}.ti-file-dots:before{content:"\f038"}.ti-file-download:before{content:"\ea9d"}.ti-file-euro:before{content:"\efe1"}.ti-file-excel:before{content:"\fef3"}.ti-file-export:before{content:"\ede9"}.ti-file-filled:before{content:"\f747"}.ti-file-function:before{content:"\f53e"}.ti-file-horizontal:before{content:"\ebb0"}.ti-file-import:before{content:"\edea"}.ti-file-infinity:before{content:"\f502"}.ti-file-info:before{content:"\edec"}.ti-file-invoice:before{content:"\eb67"}.ti-file-isr:before{content:"\feac"}.ti-file-lambda:before{content:"\f53f"}.ti-file-like:before{content:"\ed2b"}.ti-file-minus:before{content:"\ea9e"}.ti-file-music:before{content:"\ea9f"}.ti-file-neutral:before{content:"\fd22"}.ti-file-off:before{content:"\ecf2"}.ti-file-orientation:before{content:"\f2a1"}.ti-file-pencil:before{content:"\f039"}.ti-file-percent:before{content:"\f540"}.ti-file-phone:before{content:"\ecdc"}.ti-file-plus:before{content:"\eaa0"}.ti-file-power:before{content:"\f03a"}.ti-file-report:before{content:"\eded"}.ti-file-rss:before{content:"\f03b"}.ti-file-sad:before{content:"\fd23"}.ti-file-scissors:before{content:"\f03c"}.ti-file-search:before{content:"\ed5d"}.ti-file-settings:before{content:"\f029"}.ti-file-shredder:before{content:"\eaa1"}.ti-file-signal:before{content:"\f03d"}.ti-file-smile:before{content:"\fd24"}.ti-file-spark:before{content:"\ffb9"}.ti-file-spreadsheet:before{content:"\f03e"}.ti-file-stack:before{content:"\f503"}.ti-file-star:before{content:"\f03f"}.ti-file-symlink:before{content:"\ed53"}.ti-file-text:before{content:"\eaa2"}.ti-file-text-ai:before{content:"\fa27"}.ti-file-text-spark:before{content:"\ffb8"}.ti-file-time:before{content:"\f040"}.ti-file-type-bmp:before{content:"\fb07"}.ti-file-type-css:before{content:"\fb08"}.ti-file-type-csv:before{content:"\fb09"}.ti-file-type-doc:before{content:"\fb0a"}.ti-file-type-docx:before{content:"\fb0b"}.ti-file-type-html:before{content:"\fb0c"}.ti-file-type-jpg:before{content:"\fb0d"}.ti-file-type-js:before{content:"\fb0e"}.ti-file-type-jsx:before{content:"\fb0f"}.ti-file-type-pdf:before{content:"\fb10"}.ti-file-type-php:before{content:"\fb11"}.ti-file-type-png:before{content:"\fb12"}.ti-file-type-ppt:before{content:"\fb13"}.ti-file-type-rs:before{content:"\fb14"}.ti-file-type-sql:before{content:"\fb15"}.ti-file-type-svg:before{content:"\fb16"}.ti-file-type-ts:before{content:"\fb17"}.ti-file-type-tsx:before{content:"\fb18"}.ti-file-type-txt:before{content:"\fb19"}.ti-file-type-vue:before{content:"\fb1a"}.ti-file-type-xls:before{content:"\fb1b"}.ti-file-type-xml:before{content:"\fb1c"}.ti-file-type-zip:before{content:"\fb1d"}.ti-file-typography:before{content:"\f041"}.ti-file-unknown:before{content:"\f042"}.ti-file-upload:before{content:"\ec91"}.ti-file-vector:before{content:"\f043"}.ti-file-word:before{content:"\fef2"}.ti-file-x:before{content:"\eaa3"}.ti-file-x-filled:before{content:"\f748"}.ti-file-zip:before{content:"\ed4e"}.ti-files:before{content:"\edef"}.ti-files-off:before{content:"\edee"}.ti-filter:before{content:"\eaa5"}.ti-filter-bolt:before{content:"\fb7c"}.ti-filter-cancel:before{content:"\fb7d"}.ti-filter-check:before{content:"\fb7e"}.ti-filter-code:before{content:"\fb7f"}.ti-filter-cog:before{content:"\f9fe"}.ti-filter-discount:before{content:"\fb80"}.ti-filter-dollar:before{content:"\f9ff"}.ti-filter-down:before{content:"\fb81"}.ti-filter-edit:before{content:"\fa00"}.ti-filter-exclamation:before{content:"\fb82"}.ti-filter-filled:before{content:"\fc27"}.ti-filter-heart:before{content:"\fb83"}.ti-filter-minus:before{content:"\fa01"}.ti-filter-off:before{content:"\ed2c"}.ti-filter-pause:before{content:"\fb84"}.ti-filter-pin:before{content:"\fb85"}.ti-filter-plus:before{content:"\fa02"}.ti-filter-question:before{content:"\fb86"}.ti-filter-search:before{content:"\fb87"}.ti-filter-share:before{content:"\fb88"}.ti-filter-star:before{content:"\fa03"}.ti-filter-up:before{content:"\fb89"}.ti-filter-x:before{content:"\fa04"}.ti-filters:before{content:"\f793"}.ti-filters-filled:before{content:"\100c7"}.ti-fingerprint:before{content:"\ebd1"}.ti-fingerprint-off:before{content:"\f12a"}.ti-fingerprint-scan:before{content:"\fcb5"}.ti-fire-extinguisher:before{content:"\faf6"}.ti-fire-hydrant:before{content:"\f3a9"}.ti-fire-hydrant-off:before{content:"\f3ec"}.ti-firetruck:before{content:"\ebe8"}.ti-first-aid-kit:before{content:"\ef5f"}.ti-first-aid-kit-off:before{content:"\f3ed"}.ti-fish:before{content:"\ef2b"}.ti-fish-bone:before{content:"\f287"}.ti-fish-christianity:before{content:"\f58b"}.ti-fish-hook:before{content:"\f1f9"}.ti-fish-hook-off:before{content:"\f3ee"}.ti-fish-off:before{content:"\f12b"}.ti-flag:before{content:"\eaa6"}.ti-flag-2:before{content:"\ee8c"}.ti-flag-2-filled:before{content:"\f707"}.ti-flag-2-off:before{content:"\f12c"}.ti-flag-3:before{content:"\ee8d"}.ti-flag-3-filled:before{content:"\f708"}.ti-flag-bitcoin:before{content:"\ff3c"}.ti-flag-bolt:before{content:"\fb8a"}.ti-flag-cancel:before{content:"\fb8b"}.ti-flag-check:before{content:"\fb8c"}.ti-flag-code:before{content:"\fb8d"}.ti-flag-cog:before{content:"\fb8e"}.ti-flag-discount:before{content:"\fb8f"}.ti-flag-dollar:before{content:"\fb90"}.ti-flag-down:before{content:"\fb91"}.ti-flag-exclamation:before{content:"\fb92"}.ti-flag-filled:before{content:"\f67a"}.ti-flag-heart:before{content:"\fb93"}.ti-flag-minus:before{content:"\fb94"}.ti-flag-off:before{content:"\f12d"}.ti-flag-pause:before{content:"\fb95"}.ti-flag-pin:before{content:"\fb96"}.ti-flag-plus:before{content:"\fb97"}.ti-flag-question:before{content:"\fb98"}.ti-flag-search:before{content:"\fb99"}.ti-flag-share:before{content:"\fb9a"}.ti-flag-spark:before{content:"\ffb7"}.ti-flag-star:before{content:"\fb9b"}.ti-flag-up:before{content:"\fb9c"}.ti-flag-x:before{content:"\fb9d"}.ti-flame:before{content:"\ec2c"}.ti-flame-filled:before{content:"\100c6"}.ti-flame-off:before{content:"\f12e"}.ti-flare:before{content:"\ee8e"}.ti-flare-filled:before{content:"\100c5"}.ti-flask:before{content:"\ebd2"}.ti-flask-2:before{content:"\ef60"}.ti-flask-2-filled:before{content:"\fd12"}.ti-flask-2-off:before{content:"\f12f"}.ti-flask-filled:before{content:"\fd13"}.ti-flask-off:before{content:"\f130"}.ti-flip-flops:before{content:"\f564"}.ti-flip-horizontal:before{content:"\eaa7"}.ti-flip-vertical:before{content:"\eaa8"}.ti-float-center:before{content:"\ebb1"}.ti-float-left:before{content:"\ebb2"}.ti-float-none:before{content:"\ed13"}.ti-float-right:before{content:"\ebb3"}.ti-flower:before{content:"\eff6"}.ti-flower-filled:before{content:"\10010"}.ti-flower-off:before{content:"\f131"}.ti-focus:before{content:"\eb8d"}.ti-focus-2:before{content:"\ebd3"}.ti-focus-auto:before{content:"\fa62"}.ti-focus-centered:before{content:"\f02a"}.ti-fold:before{content:"\ed56"}.ti-fold-down:before{content:"\ed54"}.ti-fold-up:before{content:"\ed55"}.ti-folder:before{content:"\eaad"}.ti-folder-bolt:before{content:"\f90c"}.ti-folder-cancel:before{content:"\f90d"}.ti-folder-check:before{content:"\f90e"}.ti-folder-code:before{content:"\f90f"}.ti-folder-cog:before{content:"\f910"}.ti-folder-dollar:before{content:"\f911"}.ti-folder-down:before{content:"\f912"}.ti-folder-exclamation:before{content:"\f913"}.ti-folder-filled:before{content:"\f749"}.ti-folder-heart:before{content:"\f914"}.ti-folder-minus:before{content:"\eaaa"}.ti-folder-off:before{content:"\ed14"}.ti-folder-open:before{content:"\faf7"}.ti-folder-pause:before{content:"\f915"}.ti-folder-pin:before{content:"\f916"}.ti-folder-plus:before{content:"\eaab"}.ti-folder-question:before{content:"\f917"}.ti-folder-root:before{content:"\fd43"}.ti-folder-search:before{content:"\f918"}.ti-folder-share:before{content:"\f919"}.ti-folder-star:before{content:"\f91a"}.ti-folder-symlink:before{content:"\f91b"}.ti-folder-up:before{content:"\f91c"}.ti-folder-x:before{content:"\eaac"}.ti-folders:before{content:"\eaae"}.ti-folders-filled:before{content:"\100c4"}.ti-folders-off:before{content:"\f133"}.ti-forbid:before{content:"\ebd5"}.ti-forbid-2:before{content:"\ebd4"}.ti-forbid-2-filled:before{content:"\fc28"}.ti-forbid-filled:before{content:"\fc29"}.ti-forklift:before{content:"\ebe9"}.ti-forms:before{content:"\ee8f"}.ti-fountain:before{content:"\f09b"}.ti-fountain-filled:before{content:"\fc2a"}.ti-fountain-off:before{content:"\f134"}.ti-frame:before{content:"\eaaf"}.ti-frame-off:before{content:"\f135"}.ti-free-rights:before{content:"\efb6"}.ti-freeze-column:before{content:"\fa63"}.ti-freeze-row:before{content:"\fa65"}.ti-freeze-row-column:before{content:"\fa64"}.ti-fridge:before{content:"\f1fa"}.ti-fridge-off:before{content:"\f3ef"}.ti-friends:before{content:"\eab0"}.ti-friends-off:before{content:"\f136"}.ti-frustum:before{content:"\fa9f"}.ti-frustum-off:before{content:"\fa9d"}.ti-frustum-plus:before{content:"\fa9e"}.ti-function:before{content:"\f225"}.ti-function-filled:before{content:"\fc2b"}.ti-function-off:before{content:"\f3f0"}.ti-galaxy:before{content:"\fcb6"}.ti-garden-cart:before{content:"\f23e"}.ti-garden-cart-filled:before{content:"\100c3"}.ti-garden-cart-off:before{content:"\f3f1"}.ti-gas-station:before{content:"\ec7d"}.ti-gas-station-off:before{content:"\f137"}.ti-gauge:before{content:"\eab1"}.ti-gauge-filled:before{content:"\fc2c"}.ti-gauge-off:before{content:"\f138"}.ti-gavel:before{content:"\ef90"}.ti-gender-agender:before{content:"\f0e1"}.ti-gender-androgyne:before{content:"\f0e2"}.ti-gender-bigender:before{content:"\f0e3"}.ti-gender-demiboy:before{content:"\f0e4"}.ti-gender-demigirl:before{content:"\f0e5"}.ti-gender-epicene:before{content:"\f0e6"}.ti-gender-female:before{content:"\f0e7"}.ti-gender-femme:before{content:"\f0e8"}.ti-gender-genderfluid:before{content:"\f0e9"}.ti-gender-genderless:before{content:"\f0ea"}.ti-gender-genderqueer:before{content:"\f0eb"}.ti-gender-hermaphrodite:before{content:"\f0ec"}.ti-gender-intergender:before{content:"\f0ed"}.ti-gender-male:before{content:"\f0ee"}.ti-gender-neutrois:before{content:"\f0ef"}.ti-gender-third:before{content:"\f0f0"}.ti-gender-transgender:before{content:"\f0f1"}.ti-gender-trasvesti:before{content:"\f0f2"}.ti-geometry:before{content:"\ee90"}.ti-ghost:before{content:"\eb8e"}.ti-ghost-2:before{content:"\f57c"}.ti-ghost-2-filled:before{content:"\f74a"}.ti-ghost-3:before{content:"\fc13"}.ti-ghost-3-filled:before{content:"\100a4"}.ti-ghost-filled:before{content:"\f74b"}.ti-ghost-off:before{content:"\f3f2"}.ti-gif:before{content:"\f257"}.ti-gift:before{content:"\eb68"}.ti-gift-card:before{content:"\f3aa"}.ti-gift-card-filled:before{content:"\fc2d"}.ti-gift-filled:before{content:"\fd14"}.ti-gift-off:before{content:"\f3f3"}.ti-git-branch:before{content:"\eab2"}.ti-git-branch-deleted:before{content:"\f57d"}.ti-git-cherry-pick:before{content:"\f57e"}.ti-git-commit:before{content:"\eab3"}.ti-git-compare:before{content:"\eab4"}.ti-git-fork:before{content:"\eb8f"}.ti-git-merge:before{content:"\eab5"}.ti-git-pull-request:before{content:"\eab6"}.ti-git-pull-request-closed:before{content:"\ef7f"}.ti-git-pull-request-draft:before{content:"\efb7"}.ti-gizmo:before{content:"\f02b"}.ti-glass:before{content:"\eab8"}.ti-glass-champagne:before{content:"\fd9c"}.ti-glass-cocktail:before{content:"\fd9d"}.ti-glass-filled:before{content:"\1000f"}.ti-glass-full:before{content:"\eab7"}.ti-glass-full-filled:before{content:"\fc2e"}.ti-glass-gin:before{content:"\fd9e"}.ti-glass-off:before{content:"\ee91"}.ti-globe:before{content:"\eab9"}.ti-globe-filled:before{content:"\fc2f"}.ti-globe-off:before{content:"\f139"}.ti-go-game:before{content:"\f512"}.ti-golf:before{content:"\ed8c"}.ti-golf-filled:before{content:"\100a3"}.ti-golf-off:before{content:"\f13a"}.ti-gps:before{content:"\ed7a"}.ti-gps-filled:before{content:"\fe48"}.ti-gradienter:before{content:"\f3ab"}.ti-grain:before{content:"\ee92"}.ti-graph:before{content:"\f288"}.ti-graph-filled:before{content:"\fd15"}.ti-graph-off:before{content:"\f3f4"}.ti-grave:before{content:"\f580"}.ti-grave-2:before{content:"\f57f"}.ti-grid-3x3:before{content:"\fca4"}.ti-grid-4x4:before{content:"\fca5"}.ti-grid-dots:before{content:"\eaba"}.ti-grid-goldenratio:before{content:"\fca6"}.ti-grid-pattern:before{content:"\efc9"}.ti-grid-pattern-filled:before{content:"\100c2"}.ti-grid-scan:before{content:"\fca7"}.ti-grill:before{content:"\efa9"}.ti-grill-fork:before{content:"\f35b"}.ti-grill-off:before{content:"\f3f5"}.ti-grill-spatula:before{content:"\f35c"}.ti-grip-horizontal:before{content:"\ec00"}.ti-grip-vertical:before{content:"\ec01"}.ti-growth:before{content:"\ee93"}.ti-guitar-pick:before{content:"\f4c6"}.ti-guitar-pick-filled:before{content:"\f67b"}.ti-gymnastics:before{content:"\fd44"}.ti-h-1:before{content:"\ec94"}.ti-h-2:before{content:"\ec95"}.ti-h-3:before{content:"\ec96"}.ti-h-4:before{content:"\ec97"}.ti-h-5:before{content:"\ec98"}.ti-h-6:before{content:"\ec99"}.ti-hammer:before{content:"\ef91"}.ti-hammer-off:before{content:"\f13c"}.ti-hand-click:before{content:"\ef4f"}.ti-hand-finger:before{content:"\ee94"}.ti-hand-finger-down:before{content:"\ff4f"}.ti-hand-finger-left:before{content:"\ff4e"}.ti-hand-finger-off:before{content:"\f13d"}.ti-hand-finger-right:before{content:"\ff4d"}.ti-hand-grab:before{content:"\f091"}.ti-hand-little-finger:before{content:"\ee95"}.ti-hand-love-you:before{content:"\ee97"}.ti-hand-middle-finger:before{content:"\ec2d"}.ti-hand-move:before{content:"\ef50"}.ti-hand-off:before{content:"\ed15"}.ti-hand-ring-finger:before{content:"\ee96"}.ti-hand-sanitizer:before{content:"\f5f4"}.ti-hand-stop:before{content:"\ec2e"}.ti-hand-three-fingers:before{content:"\ee98"}.ti-hand-two-fingers:before{content:"\ee99"}.ti-hanger:before{content:"\ee9a"}.ti-hanger-2:before{content:"\f09c"}.ti-hanger-2-filled:before{content:"\ff61"}.ti-hanger-off:before{content:"\f13e"}.ti-hash:before{content:"\eabc"}.ti-haze:before{content:"\efaa"}.ti-haze-moon:before{content:"\faf8"}.ti-hdr:before{content:"\fa7b"}.ti-heading:before{content:"\ee9b"}.ti-heading-off:before{content:"\f13f"}.ti-headphones:before{content:"\eabd"}.ti-headphones-filled:before{content:"\fa3c"}.ti-headphones-off:before{content:"\ed1d"}.ti-headset:before{content:"\eb90"}.ti-headset-off:before{content:"\f3f6"}.ti-health-recognition:before{content:"\f1fb"}.ti-heart:before{content:"\eabe"}.ti-heart-bitcoin:before{content:"\ff3b"}.ti-heart-bolt:before{content:"\fb9e"}.ti-heart-broken:before{content:"\ecba"}.ti-heart-cancel:before{content:"\fb9f"}.ti-heart-check:before{content:"\fba0"}.ti-heart-code:before{content:"\fba1"}.ti-heart-cog:before{content:"\fba2"}.ti-heart-discount:before{content:"\fba3"}.ti-heart-dollar:before{content:"\fba4"}.ti-heart-down:before{content:"\fba5"}.ti-heart-exclamation:before{content:"\fba6"}.ti-heart-filled:before{content:"\f67c"}.ti-heart-handshake:before{content:"\f0f3"}.ti-heart-minus:before{content:"\f140"}.ti-heart-off:before{content:"\f141"}.ti-heart-pause:before{content:"\fba7"}.ti-heart-pin:before{content:"\fba8"}.ti-heart-plus:before{content:"\f142"}.ti-heart-question:before{content:"\fba9"}.ti-heart-rate-monitor:before{content:"\ef61"}.ti-heart-search:before{content:"\fbaa"}.ti-heart-share:before{content:"\fbab"}.ti-heart-spark:before{content:"\ffb6"}.ti-heart-star:before{content:"\fbac"}.ti-heart-up:before{content:"\fbad"}.ti-heart-x:before{content:"\fbae"}.ti-heartbeat:before{content:"\ef92"}.ti-hearts:before{content:"\f387"}.ti-hearts-off:before{content:"\f3f7"}.ti-helicopter:before{content:"\ed8e"}.ti-helicopter-landing:before{content:"\ed8d"}.ti-helicopter-landing-filled:before{content:"\100c1"}.ti-helmet:before{content:"\efca"}.ti-helmet-off:before{content:"\f143"}.ti-help:before{content:"\eabf"}.ti-help-circle:before{content:"\f91d"}.ti-help-circle-filled:before{content:"\fa3d"}.ti-help-hexagon:before{content:"\f7a8"}.ti-help-hexagon-filled:before{content:"\fa3e"}.ti-help-octagon:before{content:"\f7a9"}.ti-help-octagon-filled:before{content:"\fa3f"}.ti-help-off:before{content:"\f3f8"}.ti-help-small:before{content:"\f91e"}.ti-help-square:before{content:"\f920"}.ti-help-square-filled:before{content:"\fa40"}.ti-help-square-rounded:before{content:"\f91f"}.ti-help-square-rounded-filled:before{content:"\fa41"}.ti-help-triangle:before{content:"\f921"}.ti-help-triangle-filled:before{content:"\fa42"}.ti-hemisphere:before{content:"\faa2"}.ti-hemisphere-off:before{content:"\faa0"}.ti-hemisphere-plus:before{content:"\faa1"}.ti-hexagon:before{content:"\ec02"}.ti-hexagon-3d:before{content:"\f4c7"}.ti-hexagon-filled:before{content:"\f67d"}.ti-hexagon-letter-a:before{content:"\f463"}.ti-hexagon-letter-a-filled:before{content:"\fe47"}.ti-hexagon-letter-b:before{content:"\f464"}.ti-hexagon-letter-b-filled:before{content:"\fe46"}.ti-hexagon-letter-c:before{content:"\f465"}.ti-hexagon-letter-c-filled:before{content:"\fe45"}.ti-hexagon-letter-d:before{content:"\f466"}.ti-hexagon-letter-d-filled:before{content:"\fe44"}.ti-hexagon-letter-e:before{content:"\f467"}.ti-hexagon-letter-e-filled:before{content:"\fe43"}.ti-hexagon-letter-f:before{content:"\f468"}.ti-hexagon-letter-f-filled:before{content:"\fe42"}.ti-hexagon-letter-g:before{content:"\f469"}.ti-hexagon-letter-g-filled:before{content:"\fe41"}.ti-hexagon-letter-h:before{content:"\f46a"}.ti-hexagon-letter-h-filled:before{content:"\fe40"}.ti-hexagon-letter-i:before{content:"\f46b"}.ti-hexagon-letter-i-filled:before{content:"\fe3f"}.ti-hexagon-letter-j:before{content:"\f46c"}.ti-hexagon-letter-j-filled:before{content:"\fe3e"}.ti-hexagon-letter-k:before{content:"\f46d"}.ti-hexagon-letter-k-filled:before{content:"\fe3d"}.ti-hexagon-letter-l:before{content:"\f46e"}.ti-hexagon-letter-l-filled:before{content:"\fe3c"}.ti-hexagon-letter-m:before{content:"\f46f"}.ti-hexagon-letter-m-filled:before{content:"\fe3b"}.ti-hexagon-letter-n:before{content:"\f470"}.ti-hexagon-letter-n-filled:before{content:"\fe3a"}.ti-hexagon-letter-o:before{content:"\f471"}.ti-hexagon-letter-o-filled:before{content:"\fe39"}.ti-hexagon-letter-p:before{content:"\f472"}.ti-hexagon-letter-p-filled:before{content:"\fe38"}.ti-hexagon-letter-q:before{content:"\f473"}.ti-hexagon-letter-q-filled:before{content:"\fe37"}.ti-hexagon-letter-r:before{content:"\f474"}.ti-hexagon-letter-r-filled:before{content:"\fe36"}.ti-hexagon-letter-s:before{content:"\f475"}.ti-hexagon-letter-s-filled:before{content:"\fe35"}.ti-hexagon-letter-t:before{content:"\f476"}.ti-hexagon-letter-t-filled:before{content:"\fe34"}.ti-hexagon-letter-u:before{content:"\f477"}.ti-hexagon-letter-u-filled:before{content:"\fe33"}.ti-hexagon-letter-v:before{content:"\f4b3"}.ti-hexagon-letter-v-filled:before{content:"\fe32"}.ti-hexagon-letter-w:before{content:"\f478"}.ti-hexagon-letter-w-filled:before{content:"\fe31"}.ti-hexagon-letter-x:before{content:"\f479"}.ti-hexagon-letter-x-filled:before{content:"\fe30"}.ti-hexagon-letter-y:before{content:"\f47a"}.ti-hexagon-letter-y-filled:before{content:"\fe2f"}.ti-hexagon-letter-z:before{content:"\f47b"}.ti-hexagon-letter-z-filled:before{content:"\fe2e"}.ti-hexagon-minus:before{content:"\fc8f"}.ti-hexagon-minus-2:before{content:"\fc8e"}.ti-hexagon-minus-filled:before{content:"\fe2d"}.ti-hexagon-number-0:before{content:"\f459"}.ti-hexagon-number-0-filled:before{content:"\f74c"}.ti-hexagon-number-1:before{content:"\f45a"}.ti-hexagon-number-1-filled:before{content:"\f74d"}.ti-hexagon-number-2:before{content:"\f45b"}.ti-hexagon-number-2-filled:before{content:"\f74e"}.ti-hexagon-number-3:before{content:"\f45c"}.ti-hexagon-number-3-filled:before{content:"\f74f"}.ti-hexagon-number-4:before{content:"\f45d"}.ti-hexagon-number-4-filled:before{content:"\f750"}.ti-hexagon-number-5:before{content:"\f45e"}.ti-hexagon-number-5-filled:before{content:"\f751"}.ti-hexagon-number-6:before{content:"\f45f"}.ti-hexagon-number-6-filled:before{content:"\f752"}.ti-hexagon-number-7:before{content:"\f460"}.ti-hexagon-number-7-filled:before{content:"\f753"}.ti-hexagon-number-8:before{content:"\f461"}.ti-hexagon-number-8-filled:before{content:"\f754"}.ti-hexagon-number-9:before{content:"\f462"}.ti-hexagon-number-9-filled:before{content:"\f755"}.ti-hexagon-off:before{content:"\ee9c"}.ti-hexagon-plus:before{content:"\fc45"}.ti-hexagon-plus-2:before{content:"\fc90"}.ti-hexagon-plus-filled:before{content:"\fe2c"}.ti-hexagonal-prism:before{content:"\faa5"}.ti-hexagonal-prism-off:before{content:"\faa3"}.ti-hexagonal-prism-plus:before{content:"\faa4"}.ti-hexagonal-pyramid:before{content:"\faa8"}.ti-hexagonal-pyramid-off:before{content:"\faa6"}.ti-hexagonal-pyramid-plus:before{content:"\faa7"}.ti-hexagons:before{content:"\f09d"}.ti-hexagons-off:before{content:"\f3f9"}.ti-hierarchy:before{content:"\ee9e"}.ti-hierarchy-2:before{content:"\ee9d"}.ti-hierarchy-3:before{content:"\f289"}.ti-hierarchy-off:before{content:"\f3fa"}.ti-highlight:before{content:"\ef3f"}.ti-highlight-off:before{content:"\f144"}.ti-history:before{content:"\ebea"}.ti-history-off:before{content:"\f3fb"}.ti-history-toggle:before{content:"\f1fc"}.ti-home:before{content:"\eac1"}.ti-home-2:before{content:"\eac0"}.ti-home-bitcoin:before{content:"\ff3a"}.ti-home-bolt:before{content:"\f336"}.ti-home-cancel:before{content:"\f350"}.ti-home-check:before{content:"\f337"}.ti-home-cog:before{content:"\f338"}.ti-home-dollar:before{content:"\f339"}.ti-home-dot:before{content:"\f33a"}.ti-home-down:before{content:"\f33b"}.ti-home-eco:before{content:"\f351"}.ti-home-edit:before{content:"\f352"}.ti-home-exclamation:before{content:"\f33c"}.ti-home-filled:before{content:"\fe2b"}.ti-home-hand:before{content:"\f504"}.ti-home-heart:before{content:"\f353"}.ti-home-infinity:before{content:"\f505"}.ti-home-link:before{content:"\f354"}.ti-home-minus:before{content:"\f33d"}.ti-home-move:before{content:"\f33e"}.ti-home-off:before{content:"\f145"}.ti-home-plus:before{content:"\f33f"}.ti-home-question:before{content:"\f340"}.ti-home-ribbon:before{content:"\f355"}.ti-home-search:before{content:"\f341"}.ti-home-share:before{content:"\f342"}.ti-home-shield:before{content:"\f343"}.ti-home-signal:before{content:"\f356"}.ti-home-spark:before{content:"\ffb5"}.ti-home-star:before{content:"\f344"}.ti-home-stats:before{content:"\f345"}.ti-home-up:before{content:"\f346"}.ti-home-x:before{content:"\f347"}.ti-horse:before{content:"\fc46"}.ti-horse-toy:before{content:"\f28a"}.ti-horseshoe:before{content:"\fcb7"}.ti-hospital:before{content:"\fd59"}.ti-hospital-circle:before{content:"\fd58"}.ti-hospital-circle-filled:before{content:"\fed2"}.ti-hotel-service:before{content:"\ef80"}.ti-hourglass:before{content:"\ef93"}.ti-hourglass-empty:before{content:"\f146"}.ti-hourglass-filled:before{content:"\f756"}.ti-hourglass-high:before{content:"\f092"}.ti-hourglass-low:before{content:"\f093"}.ti-hourglass-off:before{content:"\f147"}.ti-hours-12:before{content:"\fc53"}.ti-hours-24:before{content:"\f5e7"}.ti-html:before{content:"\f7b1"}.ti-http-connect:before{content:"\fa28"}.ti-http-delete:before{content:"\fa29"}.ti-http-get:before{content:"\fa2a"}.ti-http-head:before{content:"\fa2b"}.ti-http-options:before{content:"\fa2c"}.ti-http-patch:before{content:"\fa2d"}.ti-http-post:before{content:"\fa2e"}.ti-http-put:before{content:"\fa2f"}.ti-http-que:before{content:"\fa5b"}.ti-http-trace:before{content:"\fa30"}.ti-ice-cream:before{content:"\eac2"}.ti-ice-cream-2:before{content:"\ee9f"}.ti-ice-cream-off:before{content:"\f148"}.ti-ice-skating:before{content:"\efcb"}.ti-icons:before{content:"\f1d4"}.ti-icons-filled:before{content:"\10070"}.ti-icons-off:before{content:"\f3fc"}.ti-id:before{content:"\eac3"}.ti-id-badge:before{content:"\eff7"}.ti-id-badge-2:before{content:"\f076"}.ti-id-badge-off:before{content:"\f3fd"}.ti-id-off:before{content:"\f149"}.ti-ikosaedr:before{content:"\fec6"}.ti-image-in-picture:before{content:"\fd9f"}.ti-inbox:before{content:"\eac4"}.ti-inbox-off:before{content:"\f14a"}.ti-indent-decrease:before{content:"\eb91"}.ti-indent-increase:before{content:"\eb92"}.ti-infinity:before{content:"\eb69"}.ti-infinity-off:before{content:"\f3fe"}.ti-info-circle:before{content:"\eac5"}.ti-info-circle-filled:before{content:"\f6d8"}.ti-info-hexagon:before{content:"\f7aa"}.ti-info-hexagon-filled:before{content:"\fa43"}.ti-info-octagon:before{content:"\f7ab"}.ti-info-octagon-filled:before{content:"\fa44"}.ti-info-small:before{content:"\f922"}.ti-info-square:before{content:"\eac6"}.ti-info-square-filled:before{content:"\fa45"}.ti-info-square-rounded:before{content:"\f635"}.ti-info-square-rounded-filled:before{content:"\f6d9"}.ti-info-triangle:before{content:"\f923"}.ti-info-triangle-filled:before{content:"\fa46"}.ti-inner-shadow-bottom:before{content:"\f520"}.ti-inner-shadow-bottom-filled:before{content:"\f757"}.ti-inner-shadow-bottom-left:before{content:"\f51e"}.ti-inner-shadow-bottom-left-filled:before{content:"\f758"}.ti-inner-shadow-bottom-right:before{content:"\f51f"}.ti-inner-shadow-bottom-right-filled:before{content:"\f759"}.ti-inner-shadow-left:before{content:"\f521"}.ti-inner-shadow-left-filled:before{content:"\f75a"}.ti-inner-shadow-right:before{content:"\f522"}.ti-inner-shadow-right-filled:before{content:"\f75b"}.ti-inner-shadow-top:before{content:"\f525"}.ti-inner-shadow-top-filled:before{content:"\f75c"}.ti-inner-shadow-top-left:before{content:"\f523"}.ti-inner-shadow-top-left-filled:before{content:"\f75d"}.ti-inner-shadow-top-right:before{content:"\f524"}.ti-inner-shadow-top-right-filled:before{content:"\f75e"}.ti-input-ai:before{content:"\fc5a"}.ti-input-check:before{content:"\fc5b"}.ti-input-search:before{content:"\f2a2"}.ti-input-spark:before{content:"\ffb4"}.ti-input-x:before{content:"\fc5c"}.ti-invoice:before{content:"\feab"}.ti-ironing:before{content:"\fa7c"}.ti-ironing-1:before{content:"\f2f4"}.ti-ironing-1-filled:before{content:"\1006f"}.ti-ironing-2:before{content:"\f2f5"}.ti-ironing-2-filled:before{content:"\1006e"}.ti-ironing-3:before{content:"\f2f6"}.ti-ironing-3-filled:before{content:"\1006d"}.ti-ironing-filled:before{content:"\fe2a"}.ti-ironing-off:before{content:"\f2f7"}.ti-ironing-steam:before{content:"\f2f9"}.ti-ironing-steam-filled:before{content:"\1006c"}.ti-ironing-steam-off:before{content:"\f2f8"}.ti-irregular-polyhedron:before{content:"\faab"}.ti-irregular-polyhedron-off:before{content:"\faa9"}.ti-irregular-polyhedron-plus:before{content:"\faaa"}.ti-italic:before{content:"\eb93"}.ti-jacket:before{content:"\f661"}.ti-jetpack:before{content:"\f581"}.ti-jetpack-filled:before{content:"\fe29"}.ti-jewish-star:before{content:"\f3ff"}.ti-jewish-star-filled:before{content:"\f67e"}.ti-join-bevel:before{content:"\ff4c"}.ti-join-round:before{content:"\ff4b"}.ti-join-straight:before{content:"\ff4a"}.ti-joker:before{content:"\1005f"}.ti-jpg:before{content:"\f3ac"}.ti-json:before{content:"\f7b2"}.ti-jump-rope:before{content:"\ed8f"}.ti-karate:before{content:"\ed32"}.ti-kayak:before{content:"\f1d6"}.ti-kerning:before{content:"\efb8"}.ti-key:before{content:"\eac7"}.ti-key-filled:before{content:"\fe28"}.ti-key-off:before{content:"\f14b"}.ti-keyboard:before{content:"\ebd6"}.ti-keyboard-filled:before{content:"\100a2"}.ti-keyboard-hide:before{content:"\ec7e"}.ti-keyboard-off:before{content:"\eea0"}.ti-keyboard-show:before{content:"\ec7f"}.ti-keyframe:before{content:"\f576"}.ti-keyframe-align-center:before{content:"\f582"}.ti-keyframe-align-center-filled:before{content:"\fc30"}.ti-keyframe-align-horizontal:before{content:"\f583"}.ti-keyframe-align-horizontal-filled:before{content:"\fc31"}.ti-keyframe-align-vertical:before{content:"\f584"}.ti-keyframe-align-vertical-filled:before{content:"\fc32"}.ti-keyframe-filled:before{content:"\fc33"}.ti-keyframes:before{content:"\f585"}.ti-keyframes-filled:before{content:"\fc34"}.ti-label:before{content:"\ff38"}.ti-label-filled:before{content:"\ff41"}.ti-label-important:before{content:"\ff49"}.ti-label-important-filled:before{content:"\ff60"}.ti-label-off:before{content:"\ff39"}.ti-ladder:before{content:"\efe2"}.ti-ladder-off:before{content:"\f14c"}.ti-ladle:before{content:"\fc14"}.ti-lambda:before{content:"\f541"}.ti-lamp:before{content:"\efab"}.ti-lamp-2:before{content:"\f09e"}.ti-lamp-off:before{content:"\f14d"}.ti-lane:before{content:"\faf9"}.ti-language:before{content:"\ebbe"}.ti-language-hiragana:before{content:"\ef77"}.ti-language-katakana:before{content:"\ef78"}.ti-language-off:before{content:"\f14e"}.ti-lasso:before{content:"\efac"}.ti-lasso-off:before{content:"\f14f"}.ti-lasso-polygon:before{content:"\f388"}.ti-lasso-polygon-filled:before{content:"\ff5f"}.ti-laurel-wreath:before{content:"\ff45"}.ti-laurel-wreath-1:before{content:"\ff48"}.ti-laurel-wreath-2:before{content:"\ff47"}.ti-laurel-wreath-3:before{content:"\ff46"}.ti-laurel-wreath-filled:before{content:"\100c0"}.ti-layers-difference:before{content:"\eac8"}.ti-layers-intersect:before{content:"\eac9"}.ti-layers-intersect-2:before{content:"\eff8"}.ti-layers-linked:before{content:"\eea1"}.ti-layers-off:before{content:"\f150"}.ti-layers-selected:before{content:"\fea9"}.ti-layers-selected-bottom:before{content:"\feaa"}.ti-layers-subtract:before{content:"\eaca"}.ti-layers-union:before{content:"\eacb"}.ti-layout:before{content:"\eadb"}.ti-layout-2:before{content:"\eacc"}.ti-layout-2-filled:before{content:"\fe27"}.ti-layout-align-bottom:before{content:"\eacd"}.ti-layout-align-bottom-filled:before{content:"\fe26"}.ti-layout-align-center:before{content:"\eace"}.ti-layout-align-center-filled:before{content:"\fe25"}.ti-layout-align-left:before{content:"\eacf"}.ti-layout-align-left-filled:before{content:"\fe24"}.ti-layout-align-middle:before{content:"\ead0"}.ti-layout-align-middle-filled:before{content:"\fe23"}.ti-layout-align-right:before{content:"\ead1"}.ti-layout-align-right-filled:before{content:"\fe22"}.ti-layout-align-top:before{content:"\ead2"}.ti-layout-align-top-filled:before{content:"\fe21"}.ti-layout-board:before{content:"\ef95"}.ti-layout-board-split:before{content:"\ef94"}.ti-layout-bottombar:before{content:"\ead3"}.ti-layout-bottombar-collapse:before{content:"\f28b"}.ti-layout-bottombar-collapse-filled:before{content:"\fc35"}.ti-layout-bottombar-expand:before{content:"\f28c"}.ti-layout-bottombar-expand-filled:before{content:"\fc36"}.ti-layout-bottombar-filled:before{content:"\fc37"}.ti-layout-bottombar-inactive:before{content:"\fd45"}.ti-layout-cards:before{content:"\ec13"}.ti-layout-cards-filled:before{content:"\fe20"}.ti-layout-collage:before{content:"\f389"}.ti-layout-columns:before{content:"\ead4"}.ti-layout-dashboard:before{content:"\f02c"}.ti-layout-dashboard-filled:before{content:"\fe1f"}.ti-layout-distribute-horizontal:before{content:"\ead5"}.ti-layout-distribute-horizontal-filled:before{content:"\fe1e"}.ti-layout-distribute-vertical:before{content:"\ead6"}.ti-layout-distribute-vertical-filled:before{content:"\fe1d"}.ti-layout-filled:before{content:"\fe17"}.ti-layout-grid:before{content:"\edba"}.ti-layout-grid-add:before{content:"\edb9"}.ti-layout-grid-filled:before{content:"\fe1c"}.ti-layout-grid-remove:before{content:"\fa7d"}.ti-layout-kanban:before{content:"\ec3f"}.ti-layout-kanban-filled:before{content:"\fe1b"}.ti-layout-list:before{content:"\ec14"}.ti-layout-list-filled:before{content:"\fe1a"}.ti-layout-navbar:before{content:"\ead7"}.ti-layout-navbar-collapse:before{content:"\f28d"}.ti-layout-navbar-collapse-filled:before{content:"\fc38"}.ti-layout-navbar-expand:before{content:"\f28e"}.ti-layout-navbar-expand-filled:before{content:"\fc39"}.ti-layout-navbar-filled:before{content:"\fc3a"}.ti-layout-navbar-inactive:before{content:"\fd46"}.ti-layout-off:before{content:"\f151"}.ti-layout-rows:before{content:"\ead8"}.ti-layout-sidebar:before{content:"\eada"}.ti-layout-sidebar-filled:before{content:"\fe18"}.ti-layout-sidebar-inactive:before{content:"\fd47"}.ti-layout-sidebar-left-collapse:before{content:"\f004"}.ti-layout-sidebar-left-collapse-filled:before{content:"\fc3b"}.ti-layout-sidebar-left-expand:before{content:"\f005"}.ti-layout-sidebar-left-expand-filled:before{content:"\fc3c"}.ti-layout-sidebar-right:before{content:"\ead9"}.ti-layout-sidebar-right-collapse:before{content:"\f006"}.ti-layout-sidebar-right-collapse-filled:before{content:"\fc3d"}.ti-layout-sidebar-right-expand:before{content:"\f007"}.ti-layout-sidebar-right-expand-filled:before{content:"\fc3e"}.ti-layout-sidebar-right-filled:before{content:"\fe19"}.ti-layout-sidebar-right-inactive:before{content:"\fd48"}.ti-leaf:before{content:"\ed4f"}.ti-leaf-2:before{content:"\ff44"}.ti-leaf-off:before{content:"\f400"}.ti-lego:before{content:"\eadc"}.ti-lego-filled:before{content:"\fe16"}.ti-lego-off:before{content:"\f401"}.ti-lemon:before{content:"\ef10"}.ti-lemon-2:before{content:"\ef81"}.ti-lemon-2-filled:before{content:"\100bf"}.ti-letter-a:before{content:"\ec50"}.ti-letter-a-small:before{content:"\fcc7"}.ti-letter-b:before{content:"\ec51"}.ti-letter-b-small:before{content:"\fcc8"}.ti-letter-c:before{content:"\ec52"}.ti-letter-c-small:before{content:"\fcc9"}.ti-letter-case:before{content:"\eea5"}.ti-letter-case-lower:before{content:"\eea2"}.ti-letter-case-toggle:before{content:"\eea3"}.ti-letter-case-upper:before{content:"\eea4"}.ti-letter-d:before{content:"\ec53"}.ti-letter-d-small:before{content:"\fcca"}.ti-letter-e:before{content:"\ec54"}.ti-letter-e-small:before{content:"\fccb"}.ti-letter-f:before{content:"\ec55"}.ti-letter-f-small:before{content:"\fccc"}.ti-letter-g:before{content:"\ec56"}.ti-letter-g-small:before{content:"\fccd"}.ti-letter-h:before{content:"\ec57"}.ti-letter-h-small:before{content:"\fcce"}.ti-letter-i:before{content:"\ec58"}.ti-letter-i-small:before{content:"\fccf"}.ti-letter-j:before{content:"\ec59"}.ti-letter-j-small:before{content:"\fcd0"}.ti-letter-k:before{content:"\ec5a"}.ti-letter-k-small:before{content:"\fcd1"}.ti-letter-l:before{content:"\ec5b"}.ti-letter-l-small:before{content:"\fcd2"}.ti-letter-m:before{content:"\ec5c"}.ti-letter-m-small:before{content:"\fcd3"}.ti-letter-n:before{content:"\ec5d"}.ti-letter-n-small:before{content:"\fcd4"}.ti-letter-o:before{content:"\ec5e"}.ti-letter-o-small:before{content:"\fcd5"}.ti-letter-p:before{content:"\ec5f"}.ti-letter-p-small:before{content:"\fcd6"}.ti-letter-q:before{content:"\ec60"}.ti-letter-q-small:before{content:"\fcd7"}.ti-letter-r:before{content:"\ec61"}.ti-letter-r-small:before{content:"\fcd8"}.ti-letter-s:before{content:"\ec62"}.ti-letter-s-small:before{content:"\fcd9"}.ti-letter-spacing:before{content:"\eea6"}.ti-letter-t:before{content:"\ec63"}.ti-letter-t-small:before{content:"\fcda"}.ti-letter-u:before{content:"\ec64"}.ti-letter-u-small:before{content:"\fcdb"}.ti-letter-v:before{content:"\ec65"}.ti-letter-v-small:before{content:"\fcdc"}.ti-letter-w:before{content:"\ec66"}.ti-letter-w-small:before{content:"\fcdd"}.ti-letter-x:before{content:"\ec67"}.ti-letter-x-small:before{content:"\fcde"}.ti-letter-y:before{content:"\ec68"}.ti-letter-y-small:before{content:"\fcdf"}.ti-letter-z:before{content:"\ec69"}.ti-letter-z-small:before{content:"\fce0"}.ti-library:before{content:"\fd4c"}.ti-library-minus:before{content:"\fd49"}.ti-library-photo:before{content:"\fd4a"}.ti-library-plus:before{content:"\fd4b"}.ti-license:before{content:"\ebc0"}.ti-license-off:before{content:"\f153"}.ti-lifebuoy:before{content:"\eadd"}.ti-lifebuoy-filled:before{content:"\100be"}.ti-lifebuoy-off:before{content:"\f154"}.ti-lighter:before{content:"\f794"}.ti-line:before{content:"\ec40"}.ti-line-dashed:before{content:"\eea7"}.ti-line-dotted:before{content:"\eea8"}.ti-line-height:before{content:"\eb94"}.ti-line-scan:before{content:"\fcb8"}.ti-link:before{content:"\eade"}.ti-link-minus:before{content:"\fd16"}.ti-link-off:before{content:"\f402"}.ti-link-plus:before{content:"\fd17"}.ti-list:before{content:"\eb6b"}.ti-list-check:before{content:"\eb6a"}.ti-list-details:before{content:"\ef40"}.ti-list-letters:before{content:"\fc47"}.ti-list-numbers:before{content:"\ef11"}.ti-list-search:before{content:"\eea9"}.ti-list-tree:before{content:"\fafa"}.ti-live-photo:before{content:"\eadf"}.ti-live-photo-filled:before{content:"\fed1"}.ti-live-photo-off:before{content:"\f403"}.ti-live-view:before{content:"\ec6b"}.ti-live-view-filled:before{content:"\100a1"}.ti-load-balancer:before{content:"\fa5c"}.ti-loader:before{content:"\eca3"}.ti-loader-2:before{content:"\f226"}.ti-loader-3:before{content:"\f513"}.ti-loader-quarter:before{content:"\eca2"}.ti-location:before{content:"\eae0"}.ti-location-bolt:before{content:"\fbaf"}.ti-location-broken:before{content:"\f2c4"}.ti-location-cancel:before{content:"\fbb0"}.ti-location-check:before{content:"\fbb1"}.ti-location-code:before{content:"\fbb2"}.ti-location-cog:before{content:"\fbb3"}.ti-location-discount:before{content:"\fbb4"}.ti-location-dollar:before{content:"\fbb5"}.ti-location-down:before{content:"\fbb6"}.ti-location-exclamation:before{content:"\fbb7"}.ti-location-filled:before{content:"\f67f"}.ti-location-heart:before{content:"\fbb8"}.ti-location-minus:before{content:"\fbb9"}.ti-location-off:before{content:"\f155"}.ti-location-pause:before{content:"\fbba"}.ti-location-pin:before{content:"\fbbb"}.ti-location-plus:before{content:"\fbbc"}.ti-location-question:before{content:"\fbbd"}.ti-location-search:before{content:"\fbbe"}.ti-location-share:before{content:"\fbbf"}.ti-location-star:before{content:"\fbc0"}.ti-location-up:before{content:"\fbc1"}.ti-location-x:before{content:"\fbc2"}.ti-lock:before{content:"\eae2"}.ti-lock-access:before{content:"\eeaa"}.ti-lock-access-off:before{content:"\f404"}.ti-lock-bitcoin:before{content:"\ff37"}.ti-lock-bolt:before{content:"\f924"}.ti-lock-cancel:before{content:"\f925"}.ti-lock-check:before{content:"\f926"}.ti-lock-code:before{content:"\f927"}.ti-lock-cog:before{content:"\f928"}.ti-lock-dollar:before{content:"\f929"}.ti-lock-down:before{content:"\f92a"}.ti-lock-exclamation:before{content:"\f92b"}.ti-lock-filled:before{content:"\fe15"}.ti-lock-heart:before{content:"\f92c"}.ti-lock-minus:before{content:"\f92d"}.ti-lock-off:before{content:"\ed1e"}.ti-lock-open:before{content:"\eae1"}.ti-lock-open-2:before{content:"\fea8"}.ti-lock-open-off:before{content:"\f156"}.ti-lock-password:before{content:"\ff9f"}.ti-lock-pause:before{content:"\f92e"}.ti-lock-pin:before{content:"\f92f"}.ti-lock-plus:before{content:"\f930"}.ti-lock-question:before{content:"\f931"}.ti-lock-search:before{content:"\f932"}.ti-lock-share:before{content:"\f933"}.ti-lock-square:before{content:"\ef51"}.ti-lock-square-rounded:before{content:"\f636"}.ti-lock-square-rounded-filled:before{content:"\f6da"}.ti-lock-star:before{content:"\f934"}.ti-lock-up:before{content:"\f935"}.ti-lock-x:before{content:"\f936"}.ti-logic-and:before{content:"\f240"}.ti-logic-buffer:before{content:"\f241"}.ti-logic-nand:before{content:"\f242"}.ti-logic-nor:before{content:"\f243"}.ti-logic-not:before{content:"\f244"}.ti-logic-or:before{content:"\f245"}.ti-logic-xnor:before{content:"\f246"}.ti-logic-xor:before{content:"\f247"}.ti-login:before{content:"\eba7"}.ti-login-2:before{content:"\fc76"}.ti-logout:before{content:"\eba8"}.ti-logout-2:before{content:"\fa7e"}.ti-logs:before{content:"\fea7"}.ti-lollipop:before{content:"\efcc"}.ti-lollipop-off:before{content:"\f157"}.ti-luggage:before{content:"\efad"}.ti-luggage-off:before{content:"\f158"}.ti-lungs:before{content:"\ef62"}.ti-lungs-filled:before{content:"\fe14"}.ti-lungs-off:before{content:"\f405"}.ti-macro:before{content:"\eeab"}.ti-macro-filled:before{content:"\fe13"}.ti-macro-off:before{content:"\f406"}.ti-magnet:before{content:"\eae3"}.ti-magnet-filled:before{content:"\fe12"}.ti-magnet-off:before{content:"\f159"}.ti-magnetic:before{content:"\fcb9"}.ti-mail:before{content:"\eae5"}.ti-mail-ai:before{content:"\fa31"}.ti-mail-bitcoin:before{content:"\ff36"}.ti-mail-bolt:before{content:"\f937"}.ti-mail-cancel:before{content:"\f938"}.ti-mail-check:before{content:"\f939"}.ti-mail-code:before{content:"\f93a"}.ti-mail-cog:before{content:"\f93b"}.ti-mail-dollar:before{content:"\f93c"}.ti-mail-down:before{content:"\f93d"}.ti-mail-exclamation:before{content:"\f93e"}.ti-mail-fast:before{content:"\f069"}.ti-mail-filled:before{content:"\fa47"}.ti-mail-forward:before{content:"\eeac"}.ti-mail-heart:before{content:"\f93f"}.ti-mail-minus:before{content:"\f940"}.ti-mail-off:before{content:"\f15a"}.ti-mail-opened:before{content:"\eae4"}.ti-mail-opened-filled:before{content:"\fa48"}.ti-mail-pause:before{content:"\f941"}.ti-mail-pin:before{content:"\f942"}.ti-mail-plus:before{content:"\f943"}.ti-mail-question:before{content:"\f944"}.ti-mail-search:before{content:"\f945"}.ti-mail-share:before{content:"\f946"}.ti-mail-spark:before{content:"\ffb3"}.ti-mail-star:before{content:"\f947"}.ti-mail-up:before{content:"\f948"}.ti-mail-x:before{content:"\f949"}.ti-mailbox:before{content:"\eead"}.ti-mailbox-off:before{content:"\f15b"}.ti-man:before{content:"\eae6"}.ti-man-filled:before{content:"\fe11"}.ti-manual-gearbox:before{content:"\ed7b"}.ti-manual-gearbox-filled:before{content:"\fe10"}.ti-map:before{content:"\eae9"}.ti-map-2:before{content:"\eae7"}.ti-map-bolt:before{content:"\fbc3"}.ti-map-cancel:before{content:"\fbc4"}.ti-map-check:before{content:"\fbc5"}.ti-map-code:before{content:"\fbc6"}.ti-map-cog:before{content:"\fbc7"}.ti-map-discount:before{content:"\fbc8"}.ti-map-dollar:before{content:"\fbc9"}.ti-map-down:before{content:"\fbca"}.ti-map-east:before{content:"\fc5d"}.ti-map-exclamation:before{content:"\fbcb"}.ti-map-heart:before{content:"\fbcc"}.ti-map-minus:before{content:"\fbcd"}.ti-map-north:before{content:"\fc5e"}.ti-map-off:before{content:"\f15c"}.ti-map-pause:before{content:"\fbce"}.ti-map-pin:before{content:"\eae8"}.ti-map-pin-2:before{content:"\fc48"}.ti-map-pin-bolt:before{content:"\f94a"}.ti-map-pin-cancel:before{content:"\f94b"}.ti-map-pin-check:before{content:"\f94c"}.ti-map-pin-code:before{content:"\f94d"}.ti-map-pin-cog:before{content:"\f94e"}.ti-map-pin-dollar:before{content:"\f94f"}.ti-map-pin-down:before{content:"\f950"}.ti-map-pin-exclamation:before{content:"\f951"}.ti-map-pin-filled:before{content:"\f680"}.ti-map-pin-heart:before{content:"\f952"}.ti-map-pin-minus:before{content:"\f953"}.ti-map-pin-off:before{content:"\ecf3"}.ti-map-pin-pause:before{content:"\f954"}.ti-map-pin-pin:before{content:"\f955"}.ti-map-pin-plus:before{content:"\f956"}.ti-map-pin-question:before{content:"\f957"}.ti-map-pin-search:before{content:"\f958"}.ti-map-pin-share:before{content:"\f795"}.ti-map-pin-star:before{content:"\f959"}.ti-map-pin-up:before{content:"\f95a"}.ti-map-pin-x:before{content:"\f95b"}.ti-map-pins:before{content:"\ed5e"}.ti-map-plus:before{content:"\fbcf"}.ti-map-question:before{content:"\fbd0"}.ti-map-route:before{content:"\fc79"}.ti-map-search:before{content:"\ef82"}.ti-map-share:before{content:"\fbd1"}.ti-map-south:before{content:"\fc5f"}.ti-map-star:before{content:"\fbd2"}.ti-map-up:before{content:"\fbd3"}.ti-map-west:before{content:"\fc60"}.ti-map-x:before{content:"\fbd4"}.ti-markdown:before{content:"\ec41"}.ti-markdown-off:before{content:"\f407"}.ti-marquee:before{content:"\ec77"}.ti-marquee-2:before{content:"\eeae"}.ti-marquee-off:before{content:"\f15d"}.ti-mars:before{content:"\ec80"}.ti-mask:before{content:"\eeb0"}.ti-mask-off:before{content:"\eeaf"}.ti-masks-theater:before{content:"\f263"}.ti-masks-theater-off:before{content:"\f408"}.ti-massage:before{content:"\eeb1"}.ti-matchstick:before{content:"\f577"}.ti-math:before{content:"\ebeb"}.ti-math-1-divide-2:before{content:"\f4e2"}.ti-math-1-divide-3:before{content:"\f4e3"}.ti-math-avg:before{content:"\f0f4"}.ti-math-cos:before{content:"\ff1f"}.ti-math-ctg:before{content:"\ff35"}.ti-math-equal-greater:before{content:"\f4e4"}.ti-math-equal-lower:before{content:"\f4e5"}.ti-math-function:before{content:"\eeb2"}.ti-math-function-off:before{content:"\f15e"}.ti-math-function-y:before{content:"\f4e6"}.ti-math-greater:before{content:"\f4e7"}.ti-math-integral:before{content:"\f4e9"}.ti-math-integral-x:before{content:"\f4e8"}.ti-math-integrals:before{content:"\f4ea"}.ti-math-lower:before{content:"\f4eb"}.ti-math-max:before{content:"\f0f5"}.ti-math-max-min:before{content:"\fda0"}.ti-math-min:before{content:"\f0f6"}.ti-math-not:before{content:"\f4ec"}.ti-math-off:before{content:"\f409"}.ti-math-pi:before{content:"\f4ee"}.ti-math-pi-divide-2:before{content:"\f4ed"}.ti-math-sec:before{content:"\ff34"}.ti-math-sin:before{content:"\ff1e"}.ti-math-symbols:before{content:"\eeb3"}.ti-math-tg:before{content:"\ff33"}.ti-math-x-divide-2:before{content:"\f4ef"}.ti-math-x-divide-y:before{content:"\f4f1"}.ti-math-x-divide-y-2:before{content:"\f4f0"}.ti-math-x-floor-divide-y:before{content:"\10073"}.ti-math-x-minus-x:before{content:"\f4f2"}.ti-math-x-minus-y:before{content:"\f4f3"}.ti-math-x-plus-x:before{content:"\f4f4"}.ti-math-x-plus-y:before{content:"\f4f5"}.ti-math-xy:before{content:"\f4f6"}.ti-math-y-minus-y:before{content:"\f4f7"}.ti-math-y-plus-y:before{content:"\f4f8"}.ti-matrix:before{content:"\100bc"}.ti-maximize:before{content:"\eaea"}.ti-maximize-off:before{content:"\f15f"}.ti-meat:before{content:"\ef12"}.ti-meat-off:before{content:"\f40a"}.ti-medal:before{content:"\ec78"}.ti-medal-2:before{content:"\efcd"}.ti-medical-cross:before{content:"\ec2f"}.ti-medical-cross-circle:before{content:"\fae8"}.ti-medical-cross-filled:before{content:"\f681"}.ti-medical-cross-off:before{content:"\f160"}.ti-medicine-syrup:before{content:"\ef63"}.ti-meeple:before{content:"\f514"}.ti-meeple-filled:before{content:"\100a0"}.ti-melon:before{content:"\fc7a"}.ti-melon-filled:before{content:"\1000e"}.ti-menorah:before{content:"\f58c"}.ti-menu:before{content:"\eaeb"}.ti-menu-2:before{content:"\ec42"}.ti-menu-3:before{content:"\ff43"}.ti-menu-4:before{content:"\ff42"}.ti-menu-deep:before{content:"\fafb"}.ti-menu-order:before{content:"\f5f5"}.ti-message:before{content:"\eaef"}.ti-message-2:before{content:"\eaec"}.ti-message-2-bolt:before{content:"\f95c"}.ti-message-2-cancel:before{content:"\f95d"}.ti-message-2-check:before{content:"\f95e"}.ti-message-2-code:before{content:"\f012"}.ti-message-2-cog:before{content:"\f95f"}.ti-message-2-dollar:before{content:"\f960"}.ti-message-2-down:before{content:"\f961"}.ti-message-2-exclamation:before{content:"\f962"}.ti-message-2-filled:before{content:"\1009f"}.ti-message-2-heart:before{content:"\f963"}.ti-message-2-minus:before{content:"\f964"}.ti-message-2-off:before{content:"\f40b"}.ti-message-2-pause:before{content:"\f965"}.ti-message-2-pin:before{content:"\f966"}.ti-message-2-plus:before{content:"\f967"}.ti-message-2-question:before{content:"\f968"}.ti-message-2-search:before{content:"\f969"}.ti-message-2-share:before{content:"\f077"}.ti-message-2-star:before{content:"\f96a"}.ti-message-2-up:before{content:"\f96b"}.ti-message-2-x:before{content:"\f96c"}.ti-message-bolt:before{content:"\f96d"}.ti-message-cancel:before{content:"\f96e"}.ti-message-chatbot:before{content:"\f38a"}.ti-message-chatbot-filled:before{content:"\fed0"}.ti-message-check:before{content:"\f96f"}.ti-message-circle:before{content:"\eaed"}.ti-message-circle-bolt:before{content:"\f970"}.ti-message-circle-cancel:before{content:"\f971"}.ti-message-circle-check:before{content:"\f972"}.ti-message-circle-code:before{content:"\f973"}.ti-message-circle-cog:before{content:"\f974"}.ti-message-circle-dollar:before{content:"\f975"}.ti-message-circle-down:before{content:"\f976"}.ti-message-circle-exclamation:before{content:"\f977"}.ti-message-circle-filled:before{content:"\fecf"}.ti-message-circle-heart:before{content:"\f978"}.ti-message-circle-minus:before{content:"\f979"}.ti-message-circle-off:before{content:"\ed40"}.ti-message-circle-pause:before{content:"\f97a"}.ti-message-circle-pin:before{content:"\f97b"}.ti-message-circle-plus:before{content:"\f97c"}.ti-message-circle-question:before{content:"\f97d"}.ti-message-circle-search:before{content:"\f97e"}.ti-message-circle-share:before{content:"\f97f"}.ti-message-circle-star:before{content:"\f980"}.ti-message-circle-up:before{content:"\f981"}.ti-message-circle-user:before{content:"\fec5"}.ti-message-circle-x:before{content:"\f982"}.ti-message-code:before{content:"\f013"}.ti-message-cog:before{content:"\f983"}.ti-message-dollar:before{content:"\f984"}.ti-message-dots:before{content:"\eaee"}.ti-message-down:before{content:"\f985"}.ti-message-exclamation:before{content:"\f986"}.ti-message-filled:before{content:"\fecd"}.ti-message-forward:before{content:"\f28f"}.ti-message-heart:before{content:"\f987"}.ti-message-language:before{content:"\efae"}.ti-message-minus:before{content:"\f988"}.ti-message-off:before{content:"\ed41"}.ti-message-pause:before{content:"\f989"}.ti-message-pin:before{content:"\f98a"}.ti-message-plus:before{content:"\ec9a"}.ti-message-question:before{content:"\f98b"}.ti-message-reply:before{content:"\fd4d"}.ti-message-report:before{content:"\ec9b"}.ti-message-report-filled:before{content:"\fece"}.ti-message-search:before{content:"\f98c"}.ti-message-share:before{content:"\f078"}.ti-message-star:before{content:"\f98d"}.ti-message-up:before{content:"\f98e"}.ti-message-user:before{content:"\fec4"}.ti-message-x:before{content:"\f98f"}.ti-messages:before{content:"\eb6c"}.ti-messages-off:before{content:"\ed42"}.ti-meteor:before{content:"\f1fd"}.ti-meteor-filled:before{content:"\1000d"}.ti-meteor-off:before{content:"\f40c"}.ti-meter-cube:before{content:"\fd7c"}.ti-meter-square:before{content:"\fd7d"}.ti-metronome:before{content:"\fd25"}.ti-michelin-bib-gourmand:before{content:"\fae9"}.ti-michelin-star:before{content:"\faeb"}.ti-michelin-star-filled:before{content:"\1000c"}.ti-michelin-star-green:before{content:"\faea"}.ti-mickey:before{content:"\f2a3"}.ti-mickey-filled:before{content:"\f683"}.ti-microphone:before{content:"\eaf0"}.ti-microphone-2:before{content:"\ef2c"}.ti-microphone-2-off:before{content:"\f40d"}.ti-microphone-filled:before{content:"\fe0f"}.ti-microphone-off:before{content:"\ed16"}.ti-microscope:before{content:"\ef64"}.ti-microscope-off:before{content:"\f40e"}.ti-microwave:before{content:"\f248"}.ti-microwave-filled:before{content:"\fe0e"}.ti-microwave-off:before{content:"\f264"}.ti-military-award:before{content:"\f079"}.ti-military-rank:before{content:"\efcf"}.ti-military-rank-filled:before{content:"\ff5e"}.ti-milk:before{content:"\ef13"}.ti-milk-filled:before{content:"\1000b"}.ti-milk-off:before{content:"\f40f"}.ti-milkshake:before{content:"\f4c8"}.ti-minimize:before{content:"\eaf1"}.ti-minus:before{content:"\eaf2"}.ti-minus-vertical:before{content:"\eeb4"}.ti-mist:before{content:"\ec30"}.ti-mist-off:before{content:"\f410"}.ti-mobiledata:before{content:"\f9f5"}.ti-mobiledata-off:before{content:"\f9f4"}.ti-moneybag:before{content:"\f506"}.ti-monkeybar:before{content:"\feb4"}.ti-mood-angry:before{content:"\f2de"}.ti-mood-angry-filled:before{content:"\ff0a"}.ti-mood-annoyed:before{content:"\f2e0"}.ti-mood-annoyed-2:before{content:"\f2df"}.ti-mood-bitcoin:before{content:"\ff32"}.ti-mood-boy:before{content:"\ed2d"}.ti-mood-check:before{content:"\f7b3"}.ti-mood-cog:before{content:"\f7b4"}.ti-mood-confuzed:before{content:"\eaf3"}.ti-mood-confuzed-filled:before{content:"\f7f2"}.ti-mood-crazy-happy:before{content:"\ed90"}.ti-mood-crazy-happy-filled:before{content:"\ff09"}.ti-mood-cry:before{content:"\ecbb"}.ti-mood-dollar:before{content:"\f7b5"}.ti-mood-edit:before{content:"\fa05"}.ti-mood-empty:before{content:"\eeb5"}.ti-mood-empty-filled:before{content:"\f7f3"}.ti-mood-happy:before{content:"\eaf4"}.ti-mood-happy-filled:before{content:"\f7f4"}.ti-mood-heart:before{content:"\f7b6"}.ti-mood-kid:before{content:"\ec03"}.ti-mood-kid-filled:before{content:"\f7f5"}.ti-mood-look-down:before{content:"\fd37"}.ti-mood-look-left:before{content:"\f2c5"}.ti-mood-look-right:before{content:"\f2c6"}.ti-mood-look-up:before{content:"\fd38"}.ti-mood-minus:before{content:"\f7b7"}.ti-mood-nerd:before{content:"\f2e1"}.ti-mood-nervous:before{content:"\ef96"}.ti-mood-neutral:before{content:"\eaf5"}.ti-mood-neutral-filled:before{content:"\f7f6"}.ti-mood-off:before{content:"\f161"}.ti-mood-pin:before{content:"\f7b8"}.ti-mood-plus:before{content:"\f7b9"}.ti-mood-puzzled:before{content:"\fd39"}.ti-mood-sad:before{content:"\eaf6"}.ti-mood-sad-2:before{content:"\f2e2"}.ti-mood-sad-dizzy:before{content:"\f2e3"}.ti-mood-sad-filled:before{content:"\f7f7"}.ti-mood-sad-squint:before{content:"\f2e4"}.ti-mood-search:before{content:"\f7ba"}.ti-mood-share:before{content:"\fa06"}.ti-mood-sick:before{content:"\f2e5"}.ti-mood-silence:before{content:"\f2e6"}.ti-mood-sing:before{content:"\f2c7"}.ti-mood-smile:before{content:"\eaf7"}.ti-mood-smile-beam:before{content:"\f2e7"}.ti-mood-smile-dizzy:before{content:"\f2e8"}.ti-mood-smile-filled:before{content:"\f7f8"}.ti-mood-spark:before{content:"\ffb2"}.ti-mood-surprised:before{content:"\ec04"}.ti-mood-tongue:before{content:"\eb95"}.ti-mood-tongue-wink:before{content:"\f2ea"}.ti-mood-tongue-wink-2:before{content:"\f2e9"}.ti-mood-unamused:before{content:"\f2eb"}.ti-mood-up:before{content:"\f7bb"}.ti-mood-wink:before{content:"\f2ed"}.ti-mood-wink-2:before{content:"\f2ec"}.ti-mood-wrrr:before{content:"\f2ee"}.ti-mood-wrrr-filled:before{content:"\ff08"}.ti-mood-x:before{content:"\f7bc"}.ti-mood-xd:before{content:"\f2ef"}.ti-moon:before{content:"\eaf8"}.ti-moon-2:before{content:"\ece6"}.ti-moon-filled:before{content:"\f684"}.ti-moon-off:before{content:"\f162"}.ti-moon-stars:before{content:"\ece7"}.ti-moped:before{content:"\ecbc"}.ti-motorbike:before{content:"\eeb6"}.ti-mountain:before{content:"\ef97"}.ti-mountain-filled:before{content:"\1000a"}.ti-mountain-off:before{content:"\f411"}.ti-mouse:before{content:"\eaf9"}.ti-mouse-2:before{content:"\f1d7"}.ti-mouse-filled:before{content:"\fb2f"}.ti-mouse-off:before{content:"\f163"}.ti-moustache:before{content:"\f4c9"}.ti-movie:before{content:"\eafa"}.ti-movie-off:before{content:"\f164"}.ti-mug:before{content:"\eafb"}.ti-mug-filled:before{content:"\10009"}.ti-mug-off:before{content:"\f165"}.ti-multiplier-0-5x:before{content:"\ef41"}.ti-multiplier-1-5x:before{content:"\ef42"}.ti-multiplier-1x:before{content:"\ef43"}.ti-multiplier-2x:before{content:"\ef44"}.ti-mushroom:before{content:"\ef14"}.ti-mushroom-filled:before{content:"\f7f9"}.ti-mushroom-off:before{content:"\f412"}.ti-music:before{content:"\eafc"}.ti-music-bolt:before{content:"\fbd5"}.ti-music-cancel:before{content:"\fbd6"}.ti-music-check:before{content:"\fbd7"}.ti-music-code:before{content:"\fbd8"}.ti-music-cog:before{content:"\fbd9"}.ti-music-discount:before{content:"\fbda"}.ti-music-dollar:before{content:"\fbdb"}.ti-music-down:before{content:"\fbdc"}.ti-music-exclamation:before{content:"\fbdd"}.ti-music-heart:before{content:"\fbde"}.ti-music-minus:before{content:"\fbdf"}.ti-music-off:before{content:"\f166"}.ti-music-pause:before{content:"\fbe0"}.ti-music-pin:before{content:"\fbe1"}.ti-music-plus:before{content:"\fbe2"}.ti-music-question:before{content:"\fbe3"}.ti-music-search:before{content:"\fbe4"}.ti-music-share:before{content:"\fbe5"}.ti-music-star:before{content:"\fbe6"}.ti-music-up:before{content:"\fbe7"}.ti-music-x:before{content:"\fbe8"}.ti-navigation:before{content:"\f2c8"}.ti-navigation-bolt:before{content:"\fbe9"}.ti-navigation-cancel:before{content:"\fbea"}.ti-navigation-check:before{content:"\fbeb"}.ti-navigation-code:before{content:"\fbec"}.ti-navigation-cog:before{content:"\fbed"}.ti-navigation-discount:before{content:"\fbee"}.ti-navigation-dollar:before{content:"\fbef"}.ti-navigation-down:before{content:"\fbf0"}.ti-navigation-east:before{content:"\fcba"}.ti-navigation-exclamation:before{content:"\fbf1"}.ti-navigation-filled:before{content:"\f685"}.ti-navigation-heart:before{content:"\fbf2"}.ti-navigation-minus:before{content:"\fbf3"}.ti-navigation-north:before{content:"\fcbb"}.ti-navigation-off:before{content:"\f413"}.ti-navigation-pause:before{content:"\fbf4"}.ti-navigation-pin:before{content:"\fbf5"}.ti-navigation-plus:before{content:"\fbf6"}.ti-navigation-question:before{content:"\fbf7"}.ti-navigation-search:before{content:"\fbf8"}.ti-navigation-share:before{content:"\fbf9"}.ti-navigation-south:before{content:"\fcbc"}.ti-navigation-star:before{content:"\fbfa"}.ti-navigation-top:before{content:"\faec"}.ti-navigation-up:before{content:"\fbfb"}.ti-navigation-west:before{content:"\fcbd"}.ti-navigation-x:before{content:"\fbfc"}.ti-needle:before{content:"\f508"}.ti-needle-thread:before{content:"\f507"}.ti-network:before{content:"\f09f"}.ti-network-off:before{content:"\f414"}.ti-new-section:before{content:"\ebc1"}.ti-news:before{content:"\eafd"}.ti-news-off:before{content:"\f167"}.ti-nfc:before{content:"\eeb7"}.ti-nfc-off:before{content:"\f168"}.ti-no-copyright:before{content:"\efb9"}.ti-no-creative-commons:before{content:"\efba"}.ti-no-derivatives:before{content:"\efbb"}.ti-north-star:before{content:"\f014"}.ti-note:before{content:"\eb6d"}.ti-note-off:before{content:"\f169"}.ti-notebook:before{content:"\eb96"}.ti-notebook-off:before{content:"\f415"}.ti-notes:before{content:"\eb6e"}.ti-notes-off:before{content:"\f16a"}.ti-notification:before{content:"\eafe"}.ti-notification-off:before{content:"\f16b"}.ti-number:before{content:"\f1fe"}.ti-number-0:before{content:"\edf0"}.ti-number-0-small:before{content:"\fce1"}.ti-number-1:before{content:"\edf1"}.ti-number-1-small:before{content:"\fce2"}.ti-number-10:before{content:"\1005e"}.ti-number-10-small:before{content:"\fce3"}.ti-number-100-small:before{content:"\10005"}.ti-number-11:before{content:"\1005d"}.ti-number-11-small:before{content:"\fce4"}.ti-number-12-small:before{content:"\fce5"}.ti-number-123:before{content:"\f554"}.ti-number-13-small:before{content:"\fce6"}.ti-number-14-small:before{content:"\fce7"}.ti-number-15-small:before{content:"\fce8"}.ti-number-16-small:before{content:"\fce9"}.ti-number-17-small:before{content:"\fcea"}.ti-number-18-small:before{content:"\fceb"}.ti-number-19-small:before{content:"\fcec"}.ti-number-2:before{content:"\edf2"}.ti-number-2-small:before{content:"\fced"}.ti-number-20-small:before{content:"\fcee"}.ti-number-21-small:before{content:"\fcef"}.ti-number-22-small:before{content:"\fcf0"}.ti-number-23-small:before{content:"\fcf1"}.ti-number-24-small:before{content:"\fcf2"}.ti-number-25-small:before{content:"\fcf3"}.ti-number-26-small:before{content:"\fcf4"}.ti-number-27-small:before{content:"\fcf5"}.ti-number-28-small:before{content:"\fcf6"}.ti-number-29-small:before{content:"\fcf7"}.ti-number-3:before{content:"\edf3"}.ti-number-3-small:before{content:"\fcf8"}.ti-number-30-small:before{content:"\10004"}.ti-number-31-small:before{content:"\10003"}.ti-number-32-small:before{content:"\10002"}.ti-number-33-small:before{content:"\10001"}.ti-number-34-small:before{content:"\10000"}.ti-number-35-small:before{content:"\ffff"}.ti-number-36-small:before{content:"\fffe"}.ti-number-37-small:before{content:"\fffd"}.ti-number-38-small:before{content:"\fffc"}.ti-number-39-small:before{content:"\fffb"}.ti-number-4:before{content:"\edf4"}.ti-number-4-small:before{content:"\fcf9"}.ti-number-40-small:before{content:"\fffa"}.ti-number-41-small:before{content:"\fff9"}.ti-number-42-small:before{content:"\fff8"}.ti-number-43-small:before{content:"\fff7"}.ti-number-44-small:before{content:"\fff6"}.ti-number-45-small:before{content:"\fff5"}.ti-number-46-small:before{content:"\fff4"}.ti-number-47-small:before{content:"\fff3"}.ti-number-48-small:before{content:"\fff2"}.ti-number-49-small:before{content:"\fff1"}.ti-number-5:before{content:"\edf5"}.ti-number-5-small:before{content:"\fcfa"}.ti-number-50-small:before{content:"\fff0"}.ti-number-51-small:before{content:"\ffef"}.ti-number-52-small:before{content:"\ffee"}.ti-number-53-small:before{content:"\ffed"}.ti-number-54-small:before{content:"\ffec"}.ti-number-55-small:before{content:"\ffeb"}.ti-number-56-small:before{content:"\ffea"}.ti-number-57-small:before{content:"\ffe9"}.ti-number-58-small:before{content:"\ffe8"}.ti-number-59-small:before{content:"\ffe7"}.ti-number-6:before{content:"\edf6"}.ti-number-6-small:before{content:"\fcfb"}.ti-number-60-small:before{content:"\ffe6"}.ti-number-61-small:before{content:"\ffe5"}.ti-number-62-small:before{content:"\ffe4"}.ti-number-63-small:before{content:"\ffe3"}.ti-number-64-small:before{content:"\ffe2"}.ti-number-65-small:before{content:"\ffe1"}.ti-number-66-small:before{content:"\ffe0"}.ti-number-67-small:before{content:"\ffdf"}.ti-number-68-small:before{content:"\ffde"}.ti-number-69-small:before{content:"\ffdd"}.ti-number-7:before{content:"\edf7"}.ti-number-7-small:before{content:"\fcfc"}.ti-number-70-small:before{content:"\ffdc"}.ti-number-71-small:before{content:"\ffdb"}.ti-number-72-small:before{content:"\ffda"}.ti-number-73-small:before{content:"\ffd9"}.ti-number-74-small:before{content:"\ffd8"}.ti-number-75-small:before{content:"\ffd7"}.ti-number-76-small:before{content:"\ffd6"}.ti-number-77-small:before{content:"\ffd5"}.ti-number-78-small:before{content:"\ffd4"}.ti-number-79-small:before{content:"\ffd3"}.ti-number-8:before{content:"\edf8"}.ti-number-8-small:before{content:"\fcfd"}.ti-number-80-small:before{content:"\ffd2"}.ti-number-81-small:before{content:"\ffd1"}.ti-number-82-small:before{content:"\ffd0"}.ti-number-83-small:before{content:"\ffcf"}.ti-number-84-small:before{content:"\ffce"}.ti-number-85-small:before{content:"\ffcd"}.ti-number-86-small:before{content:"\ffcc"}.ti-number-87-small:before{content:"\ffcb"}.ti-number-88-small:before{content:"\ffca"}.ti-number-89-small:before{content:"\ffc9"}.ti-number-9:before{content:"\edf9"}.ti-number-9-small:before{content:"\fcfe"}.ti-number-90-small:before{content:"\ffc8"}.ti-number-91-small:before{content:"\ffc7"}.ti-number-92-small:before{content:"\ffc6"}.ti-number-93-small:before{content:"\ffc5"}.ti-number-94-small:before{content:"\ffc4"}.ti-number-95-small:before{content:"\ffc3"}.ti-number-96-small:before{content:"\ffc2"}.ti-number-97-small:before{content:"\ffc1"}.ti-number-98-small:before{content:"\ffc0"}.ti-number-99-small:before{content:"\ffbf"}.ti-numbers:before{content:"\f015"}.ti-nurse:before{content:"\ef65"}.ti-nurse-filled:before{content:"\1009e"}.ti-nut:before{content:"\fc61"}.ti-object-scan:before{content:"\fef1"}.ti-octagon:before{content:"\ecbd"}.ti-octagon-filled:before{content:"\f686"}.ti-octagon-minus:before{content:"\fc92"}.ti-octagon-minus-2:before{content:"\fc91"}.ti-octagon-off:before{content:"\eeb8"}.ti-octagon-plus:before{content:"\fc94"}.ti-octagon-plus-2:before{content:"\fc93"}.ti-octahedron:before{content:"\faae"}.ti-octahedron-off:before{content:"\faac"}.ti-octahedron-plus:before{content:"\faad"}.ti-old:before{content:"\eeb9"}.ti-olympics:before{content:"\eeba"}.ti-olympics-off:before{content:"\f416"}.ti-om:before{content:"\f58d"}.ti-omega:before{content:"\eb97"}.ti-outbound:before{content:"\f249"}.ti-outlet:before{content:"\ebd7"}.ti-oval:before{content:"\f02e"}.ti-oval-filled:before{content:"\f687"}.ti-oval-vertical:before{content:"\f02d"}.ti-oval-vertical-filled:before{content:"\f688"}.ti-overline:before{content:"\eebb"}.ti-package:before{content:"\eaff"}.ti-package-export:before{content:"\f07a"}.ti-package-import:before{content:"\f07b"}.ti-package-off:before{content:"\f16c"}.ti-packages:before{content:"\f2c9"}.ti-pacman:before{content:"\eebc"}.ti-page-break:before{content:"\ec81"}.ti-paint:before{content:"\eb00"}.ti-paint-filled:before{content:"\f75f"}.ti-paint-off:before{content:"\f16d"}.ti-palette:before{content:"\eb01"}.ti-palette-filled:before{content:"\1009d"}.ti-palette-off:before{content:"\f16e"}.ti-panorama-horizontal:before{content:"\ed33"}.ti-panorama-horizontal-filled:before{content:"\fecc"}.ti-panorama-horizontal-off:before{content:"\f417"}.ti-panorama-vertical:before{content:"\ed34"}.ti-panorama-vertical-filled:before{content:"\fecb"}.ti-panorama-vertical-off:before{content:"\f418"}.ti-paper-bag:before{content:"\f02f"}.ti-paper-bag-off:before{content:"\f16f"}.ti-paperclip:before{content:"\eb02"}.ti-parachute:before{content:"\ed7c"}.ti-parachute-off:before{content:"\f170"}.ti-parentheses:before{content:"\ebd8"}.ti-parentheses-off:before{content:"\f171"}.ti-parking:before{content:"\eb03"}.ti-parking-circle:before{content:"\fd5a"}.ti-parking-circle-filled:before{content:"\feca"}.ti-parking-off:before{content:"\f172"}.ti-password:before{content:"\f4ca"}.ti-password-fingerprint:before{content:"\fc7b"}.ti-password-mobile-phone:before{content:"\fc7c"}.ti-password-user:before{content:"\fc7d"}.ti-paw:before{content:"\eff9"}.ti-paw-filled:before{content:"\f689"}.ti-paw-off:before{content:"\f419"}.ti-paywall:before{content:"\fd7e"}.ti-pdf:before{content:"\f7ac"}.ti-peace:before{content:"\ecbe"}.ti-pencil:before{content:"\eb04"}.ti-pencil-bolt:before{content:"\fbfd"}.ti-pencil-cancel:before{content:"\fbfe"}.ti-pencil-check:before{content:"\fbff"}.ti-pencil-code:before{content:"\fc00"}.ti-pencil-cog:before{content:"\fc01"}.ti-pencil-discount:before{content:"\fc02"}.ti-pencil-dollar:before{content:"\fc03"}.ti-pencil-down:before{content:"\fc04"}.ti-pencil-exclamation:before{content:"\fc05"}.ti-pencil-heart:before{content:"\fc06"}.ti-pencil-minus:before{content:"\f1eb"}.ti-pencil-off:before{content:"\f173"}.ti-pencil-pause:before{content:"\fc07"}.ti-pencil-pin:before{content:"\fc08"}.ti-pencil-plus:before{content:"\f1ec"}.ti-pencil-question:before{content:"\fc09"}.ti-pencil-search:before{content:"\fc0a"}.ti-pencil-share:before{content:"\fc0b"}.ti-pencil-star:before{content:"\fc0c"}.ti-pencil-up:before{content:"\fc0d"}.ti-pencil-x:before{content:"\fc0e"}.ti-pennant:before{content:"\ed7d"}.ti-pennant-2:before{content:"\f06a"}.ti-pennant-2-filled:before{content:"\f68a"}.ti-pennant-filled:before{content:"\f68b"}.ti-pennant-off:before{content:"\f174"}.ti-pentagon:before{content:"\efe3"}.ti-pentagon-filled:before{content:"\f68c"}.ti-pentagon-minus:before{content:"\feb3"}.ti-pentagon-number-0:before{content:"\fc7e"}.ti-pentagon-number-1:before{content:"\fc7f"}.ti-pentagon-number-2:before{content:"\fc80"}.ti-pentagon-number-3:before{content:"\fc81"}.ti-pentagon-number-4:before{content:"\fc82"}.ti-pentagon-number-5:before{content:"\fc83"}.ti-pentagon-number-6:before{content:"\fc84"}.ti-pentagon-number-7:before{content:"\fc85"}.ti-pentagon-number-8:before{content:"\fc86"}.ti-pentagon-number-9:before{content:"\fc87"}.ti-pentagon-off:before{content:"\f41a"}.ti-pentagon-plus:before{content:"\fc49"}.ti-pentagon-x:before{content:"\fc88"}.ti-pentagram:before{content:"\f586"}.ti-pepper:before{content:"\ef15"}.ti-pepper-off:before{content:"\f175"}.ti-percentage:before{content:"\ecf4"}.ti-percentage-0:before{content:"\fee5"}.ti-percentage-10:before{content:"\fee4"}.ti-percentage-100:before{content:"\fee3"}.ti-percentage-20:before{content:"\fee2"}.ti-percentage-25:before{content:"\fee1"}.ti-percentage-30:before{content:"\fee0"}.ti-percentage-33:before{content:"\fedf"}.ti-percentage-40:before{content:"\fede"}.ti-percentage-50:before{content:"\fedd"}.ti-percentage-60:before{content:"\fedc"}.ti-percentage-66:before{content:"\fedb"}.ti-percentage-70:before{content:"\feda"}.ti-percentage-75:before{content:"\fed9"}.ti-percentage-80:before{content:"\fed8"}.ti-percentage-90:before{content:"\fed7"}.ti-perfume:before{content:"\f509"}.ti-perspective:before{content:"\eebd"}.ti-perspective-off:before{content:"\f176"}.ti-phone:before{content:"\eb09"}.ti-phone-call:before{content:"\eb05"}.ti-phone-calling:before{content:"\ec43"}.ti-phone-check:before{content:"\ec05"}.ti-phone-done:before{content:"\ff9e"}.ti-phone-end:before{content:"\ff9d"}.ti-phone-filled:before{content:"\fa49"}.ti-phone-incoming:before{content:"\eb06"}.ti-phone-off:before{content:"\ecf5"}.ti-phone-outgoing:before{content:"\eb07"}.ti-phone-pause:before{content:"\eb08"}.ti-phone-plus:before{content:"\ec06"}.ti-phone-ringing:before{content:"\ff9c"}.ti-phone-spark:before{content:"\ffb1"}.ti-phone-x:before{content:"\ec07"}.ti-photo:before{content:"\eb0a"}.ti-photo-ai:before{content:"\fa32"}.ti-photo-bitcoin:before{content:"\ff31"}.ti-photo-bolt:before{content:"\f990"}.ti-photo-cancel:before{content:"\f35d"}.ti-photo-check:before{content:"\f35e"}.ti-photo-circle:before{content:"\fc4a"}.ti-photo-circle-minus:before{content:"\fc62"}.ti-photo-circle-plus:before{content:"\fc63"}.ti-photo-code:before{content:"\f991"}.ti-photo-cog:before{content:"\f992"}.ti-photo-dollar:before{content:"\f993"}.ti-photo-down:before{content:"\f35f"}.ti-photo-edit:before{content:"\f360"}.ti-photo-exclamation:before{content:"\f994"}.ti-photo-filled:before{content:"\fa4a"}.ti-photo-heart:before{content:"\f361"}.ti-photo-hexagon:before{content:"\fc4b"}.ti-photo-minus:before{content:"\f362"}.ti-photo-off:before{content:"\ecf6"}.ti-photo-pause:before{content:"\f995"}.ti-photo-pentagon:before{content:"\fc4c"}.ti-photo-pin:before{content:"\f996"}.ti-photo-plus:before{content:"\f363"}.ti-photo-question:before{content:"\f997"}.ti-photo-scan:before{content:"\fca8"}.ti-photo-search:before{content:"\f364"}.ti-photo-sensor:before{content:"\f798"}.ti-photo-sensor-2:before{content:"\f796"}.ti-photo-sensor-3:before{content:"\f797"}.ti-photo-share:before{content:"\f998"}.ti-photo-shield:before{content:"\f365"}.ti-photo-spark:before{content:"\ffb0"}.ti-photo-square-rounded:before{content:"\fc4d"}.ti-photo-star:before{content:"\f366"}.ti-photo-up:before{content:"\f38b"}.ti-photo-video:before{content:"\fc95"}.ti-photo-x:before{content:"\f367"}.ti-physotherapist:before{content:"\eebe"}.ti-piano:before{content:"\fad3"}.ti-pick:before{content:"\fafc"}.ti-picnic-table:before{content:"\fed6"}.ti-picture-in-picture:before{content:"\ed35"}.ti-picture-in-picture-filled:before{content:"\fec1"}.ti-picture-in-picture-off:before{content:"\ed43"}.ti-picture-in-picture-on:before{content:"\ed44"}.ti-picture-in-picture-top:before{content:"\efe4"}.ti-picture-in-picture-top-filled:before{content:"\fec2"}.ti-pig:before{content:"\ef52"}.ti-pig-money:before{content:"\f38c"}.ti-pig-off:before{content:"\f177"}.ti-pilcrow:before{content:"\f5f6"}.ti-pilcrow-left:before{content:"\fd7f"}.ti-pilcrow-right:before{content:"\fd80"}.ti-pill:before{content:"\ec44"}.ti-pill-filled:before{content:"\ff07"}.ti-pill-off:before{content:"\f178"}.ti-pills:before{content:"\ef66"}.ti-pin:before{content:"\ec9c"}.ti-pin-end:before{content:"\fd5b"}.ti-pin-filled:before{content:"\f68d"}.ti-pin-invoke:before{content:"\fd5c"}.ti-ping-pong:before{content:"\f38d"}.ti-pinned:before{content:"\ed60"}.ti-pinned-filled:before{content:"\f68e"}.ti-pinned-off:before{content:"\ed5f"}.ti-pizza:before{content:"\edbb"}.ti-pizza-filled:before{content:"\10008"}.ti-pizza-off:before{content:"\f179"}.ti-placeholder:before{content:"\f626"}.ti-plane:before{content:"\eb6f"}.ti-plane-arrival:before{content:"\eb99"}.ti-plane-departure:before{content:"\eb9a"}.ti-plane-inflight:before{content:"\ef98"}.ti-plane-off:before{content:"\f17a"}.ti-plane-tilt:before{content:"\f1ed"}.ti-planet:before{content:"\ec08"}.ti-planet-off:before{content:"\f17b"}.ti-plant:before{content:"\ed50"}.ti-plant-2:before{content:"\ed7e"}.ti-plant-2-off:before{content:"\f17c"}.ti-plant-off:before{content:"\f17d"}.ti-play-basketball:before{content:"\fa66"}.ti-play-card:before{content:"\eebf"}.ti-play-card-1:before{content:"\1005c"}.ti-play-card-1-filled:before{content:"\10083"}.ti-play-card-10:before{content:"\1005b"}.ti-play-card-10-filled:before{content:"\10082"}.ti-play-card-2:before{content:"\1005a"}.ti-play-card-2-filled:before{content:"\10081"}.ti-play-card-3:before{content:"\10059"}.ti-play-card-3-filled:before{content:"\10080"}.ti-play-card-4:before{content:"\10058"}.ti-play-card-4-filled:before{content:"\1007f"}.ti-play-card-5:before{content:"\10057"}.ti-play-card-5-filled:before{content:"\1007e"}.ti-play-card-6:before{content:"\10056"}.ti-play-card-6-filled:before{content:"\1007d"}.ti-play-card-7:before{content:"\10055"}.ti-play-card-7-filled:before{content:"\1007c"}.ti-play-card-8:before{content:"\10054"}.ti-play-card-8-filled:before{content:"\1007b"}.ti-play-card-9:before{content:"\10053"}.ti-play-card-9-filled:before{content:"\1007a"}.ti-play-card-a:before{content:"\10052"}.ti-play-card-a-filled:before{content:"\10079"}.ti-play-card-j:before{content:"\10051"}.ti-play-card-j-filled:before{content:"\10078"}.ti-play-card-k:before{content:"\10050"}.ti-play-card-k-filled:before{content:"\10077"}.ti-play-card-off:before{content:"\f17e"}.ti-play-card-q:before{content:"\1004f"}.ti-play-card-q-filled:before{content:"\10076"}.ti-play-card-star:before{content:"\1004e"}.ti-play-card-star-filled:before{content:"\10075"}.ti-play-football:before{content:"\fa67"}.ti-play-handball:before{content:"\fa68"}.ti-play-volleyball:before{content:"\fa69"}.ti-player-eject:before{content:"\efbc"}.ti-player-eject-filled:before{content:"\f68f"}.ti-player-pause:before{content:"\ed45"}.ti-player-pause-filled:before{content:"\f690"}.ti-player-play:before{content:"\ed46"}.ti-player-play-filled:before{content:"\f691"}.ti-player-record:before{content:"\ed47"}.ti-player-record-filled:before{content:"\f692"}.ti-player-skip-back:before{content:"\ed48"}.ti-player-skip-back-filled:before{content:"\f693"}.ti-player-skip-forward:before{content:"\ed49"}.ti-player-skip-forward-filled:before{content:"\f694"}.ti-player-stop:before{content:"\ed4a"}.ti-player-stop-filled:before{content:"\f695"}.ti-player-track-next:before{content:"\ed4b"}.ti-player-track-next-filled:before{content:"\f696"}.ti-player-track-prev:before{content:"\ed4c"}.ti-player-track-prev-filled:before{content:"\f697"}.ti-playlist:before{content:"\eec0"}.ti-playlist-add:before{content:"\f008"}.ti-playlist-off:before{content:"\f17f"}.ti-playlist-x:before{content:"\f009"}.ti-playstation-circle:before{content:"\f2ad"}.ti-playstation-square:before{content:"\f2ae"}.ti-playstation-triangle:before{content:"\f2af"}.ti-playstation-x:before{content:"\f2b0"}.ti-plug:before{content:"\ebd9"}.ti-plug-connected:before{content:"\f00a"}.ti-plug-connected-x:before{content:"\f0a0"}.ti-plug-off:before{content:"\f180"}.ti-plug-x:before{content:"\f0a1"}.ti-plus:before{content:"\eb0b"}.ti-plus-equal:before{content:"\f7ad"}.ti-plus-minus:before{content:"\f7ae"}.ti-png:before{content:"\f3ad"}.ti-podium:before{content:"\f1d8"}.ti-podium-off:before{content:"\f41b"}.ti-point:before{content:"\eb0c"}.ti-point-filled:before{content:"\f698"}.ti-point-off:before{content:"\f181"}.ti-pointer:before{content:"\f265"}.ti-pointer-bolt:before{content:"\f999"}.ti-pointer-cancel:before{content:"\f99a"}.ti-pointer-check:before{content:"\f99b"}.ti-pointer-code:before{content:"\f99c"}.ti-pointer-cog:before{content:"\f99d"}.ti-pointer-dollar:before{content:"\f99e"}.ti-pointer-down:before{content:"\f99f"}.ti-pointer-exclamation:before{content:"\f9a0"}.ti-pointer-filled:before{content:"\fb30"}.ti-pointer-heart:before{content:"\f9a1"}.ti-pointer-minus:before{content:"\f9a2"}.ti-pointer-off:before{content:"\f9a3"}.ti-pointer-pause:before{content:"\f9a4"}.ti-pointer-pin:before{content:"\f9a5"}.ti-pointer-plus:before{content:"\f9a6"}.ti-pointer-question:before{content:"\f9a7"}.ti-pointer-search:before{content:"\f9a8"}.ti-pointer-share:before{content:"\f9a9"}.ti-pointer-star:before{content:"\f9aa"}.ti-pointer-up:before{content:"\f9ab"}.ti-pointer-x:before{content:"\f9ac"}.ti-pokeball:before{content:"\eec1"}.ti-pokeball-off:before{content:"\f41c"}.ti-poker-chip:before{content:"\f515"}.ti-polaroid:before{content:"\eec2"}.ti-polaroid-filled:before{content:"\fa4b"}.ti-polygon:before{content:"\efd0"}.ti-polygon-off:before{content:"\f182"}.ti-poo:before{content:"\f258"}.ti-poo-filled:before{content:"\fec9"}.ti-pool:before{content:"\ed91"}.ti-pool-off:before{content:"\f41d"}.ti-power:before{content:"\eb0d"}.ti-pray:before{content:"\ecbf"}.ti-premium-rights:before{content:"\efbd"}.ti-prescription:before{content:"\ef99"}.ti-presentation:before{content:"\eb70"}.ti-presentation-analytics:before{content:"\eec3"}.ti-presentation-analytics-filled:before{content:"\ff5d"}.ti-presentation-filled:before{content:"\ff5c"}.ti-presentation-off:before{content:"\f183"}.ti-printer:before{content:"\eb0e"}.ti-printer-off:before{content:"\f184"}.ti-prism:before{content:"\fab1"}.ti-prism-light:before{content:"\fea6"}.ti-prism-off:before{content:"\faaf"}.ti-prism-plus:before{content:"\fab0"}.ti-prison:before{content:"\ef79"}.ti-progress:before{content:"\fa0d"}.ti-progress-alert:before{content:"\fa07"}.ti-progress-bolt:before{content:"\fa08"}.ti-progress-check:before{content:"\fa09"}.ti-progress-down:before{content:"\fa0a"}.ti-progress-help:before{content:"\fa0b"}.ti-progress-x:before{content:"\fa0c"}.ti-prompt:before{content:"\eb0f"}.ti-prong:before{content:"\fda1"}.ti-propeller:before{content:"\eec4"}.ti-propeller-off:before{content:"\f185"}.ti-protocol:before{content:"\fd81"}.ti-pumpkin-scary:before{content:"\f587"}.ti-puzzle:before{content:"\eb10"}.ti-puzzle-2:before{content:"\ef83"}.ti-puzzle-filled:before{content:"\f699"}.ti-puzzle-off:before{content:"\f186"}.ti-pyramid:before{content:"\eec5"}.ti-pyramid-off:before{content:"\f187"}.ti-pyramid-plus:before{content:"\fab2"}.ti-qrcode:before{content:"\eb11"}.ti-qrcode-off:before{content:"\f41e"}.ti-question-mark:before{content:"\ec9d"}.ti-quote:before{content:"\efbe"}.ti-quote-filled:before{content:"\1009c"}.ti-quote-off:before{content:"\f188"}.ti-quotes:before{content:"\fb1e"}.ti-radar:before{content:"\f017"}.ti-radar-2:before{content:"\f016"}.ti-radar-filled:before{content:"\fe0d"}.ti-radar-off:before{content:"\f41f"}.ti-radio:before{content:"\ef2d"}.ti-radio-off:before{content:"\f420"}.ti-radioactive:before{content:"\ecc0"}.ti-radioactive-filled:before{content:"\f760"}.ti-radioactive-off:before{content:"\f189"}.ti-radius-bottom-left:before{content:"\eec6"}.ti-radius-bottom-right:before{content:"\eec7"}.ti-radius-top-left:before{content:"\eec8"}.ti-radius-top-right:before{content:"\eec9"}.ti-rainbow:before{content:"\edbc"}.ti-rainbow-off:before{content:"\f18a"}.ti-rating-12-plus:before{content:"\f266"}.ti-rating-14-plus:before{content:"\f267"}.ti-rating-16-plus:before{content:"\f268"}.ti-rating-18-plus:before{content:"\f269"}.ti-rating-21-plus:before{content:"\f26a"}.ti-razor:before{content:"\f4b5"}.ti-razor-electric:before{content:"\f4b4"}.ti-receipt:before{content:"\edfd"}.ti-receipt-2:before{content:"\edfa"}.ti-receipt-bitcoin:before{content:"\fd66"}.ti-receipt-dollar:before{content:"\fd67"}.ti-receipt-euro:before{content:"\fd68"}.ti-receipt-filled:before{content:"\ff06"}.ti-receipt-off:before{content:"\edfb"}.ti-receipt-pound:before{content:"\fd69"}.ti-receipt-refund:before{content:"\edfc"}.ti-receipt-rupee:before{content:"\fd82"}.ti-receipt-tax:before{content:"\edbd"}.ti-receipt-yen:before{content:"\fd6a"}.ti-receipt-yuan:before{content:"\fd6b"}.ti-recharging:before{content:"\eeca"}.ti-record-mail:before{content:"\eb12"}.ti-record-mail-off:before{content:"\f18b"}.ti-rectangle:before{content:"\ed37"}.ti-rectangle-filled:before{content:"\f69a"}.ti-rectangle-rounded-bottom:before{content:"\faed"}.ti-rectangle-rounded-top:before{content:"\faee"}.ti-rectangle-vertical:before{content:"\ed36"}.ti-rectangle-vertical-filled:before{content:"\f69b"}.ti-rectangular-prism:before{content:"\fab5"}.ti-rectangular-prism-off:before{content:"\fab3"}.ti-rectangular-prism-plus:before{content:"\fab4"}.ti-recycle:before{content:"\eb9b"}.ti-recycle-off:before{content:"\f18c"}.ti-refresh:before{content:"\eb13"}.ti-refresh-alert:before{content:"\ed57"}.ti-refresh-dot:before{content:"\efbf"}.ti-refresh-off:before{content:"\f18d"}.ti-regex:before{content:"\f31f"}.ti-regex-off:before{content:"\f421"}.ti-registered:before{content:"\eb14"}.ti-relation-many-to-many:before{content:"\ed7f"}.ti-relation-many-to-many-filled:before{content:"\fe0c"}.ti-relation-one-to-many:before{content:"\ed80"}.ti-relation-one-to-many-filled:before{content:"\fe0b"}.ti-relation-one-to-one:before{content:"\ed81"}.ti-relation-one-to-one-filled:before{content:"\fe0a"}.ti-reload:before{content:"\f3ae"}.ti-reorder:before{content:"\fc15"}.ti-repeat:before{content:"\eb72"}.ti-repeat-off:before{content:"\f18e"}.ti-repeat-once:before{content:"\eb71"}.ti-replace:before{content:"\ebc7"}.ti-replace-filled:before{content:"\f69c"}.ti-replace-off:before{content:"\f422"}.ti-report:before{content:"\eece"}.ti-report-analytics:before{content:"\eecb"}.ti-report-medical:before{content:"\eecc"}.ti-report-money:before{content:"\eecd"}.ti-report-off:before{content:"\f18f"}.ti-report-search:before{content:"\ef84"}.ti-reserved-line:before{content:"\f9f6"}.ti-resize:before{content:"\eecf"}.ti-restore:before{content:"\fafd"}.ti-rewind-backward-10:before{content:"\faba"}.ti-rewind-backward-15:before{content:"\fabb"}.ti-rewind-backward-20:before{content:"\fabc"}.ti-rewind-backward-30:before{content:"\fabd"}.ti-rewind-backward-40:before{content:"\fabe"}.ti-rewind-backward-5:before{content:"\fabf"}.ti-rewind-backward-50:before{content:"\fac0"}.ti-rewind-backward-60:before{content:"\fac1"}.ti-rewind-forward-10:before{content:"\fac2"}.ti-rewind-forward-15:before{content:"\fac3"}.ti-rewind-forward-20:before{content:"\fac4"}.ti-rewind-forward-30:before{content:"\fac5"}.ti-rewind-forward-40:before{content:"\fac6"}.ti-rewind-forward-5:before{content:"\fac7"}.ti-rewind-forward-50:before{content:"\fac8"}.ti-rewind-forward-60:before{content:"\fac9"}.ti-ribbon-health:before{content:"\f58e"}.ti-rings:before{content:"\fa6a"}.ti-ripple:before{content:"\ed82"}.ti-ripple-off:before{content:"\f190"}.ti-road:before{content:"\f018"}.ti-road-off:before{content:"\f191"}.ti-road-sign:before{content:"\ecdd"}.ti-robot:before{content:"\f00b"}.ti-robot-face:before{content:"\fcbe"}.ti-robot-off:before{content:"\f192"}.ti-rocket:before{content:"\ec45"}.ti-rocket-off:before{content:"\f193"}.ti-roller-skating:before{content:"\efd1"}.ti-rollercoaster:before{content:"\f0a2"}.ti-rollercoaster-off:before{content:"\f423"}.ti-rosette:before{content:"\f599"}.ti-rosette-discount:before{content:"\ee7c"}.ti-rosette-discount-check:before{content:"\f1f8"}.ti-rosette-discount-check-filled:before{content:"\f746"}.ti-rosette-discount-check-off:before{content:"\ff10"}.ti-rosette-discount-filled:before{content:"\ff05"}.ti-rosette-discount-off:before{content:"\f3e6"}.ti-rosette-filled:before{content:"\f69d"}.ti-rosette-number-0:before{content:"\f58f"}.ti-rosette-number-1:before{content:"\f590"}.ti-rosette-number-2:before{content:"\f591"}.ti-rosette-number-3:before{content:"\f592"}.ti-rosette-number-4:before{content:"\f593"}.ti-rosette-number-5:before{content:"\f594"}.ti-rosette-number-6:before{content:"\f595"}.ti-rosette-number-7:before{content:"\f596"}.ti-rosette-number-8:before{content:"\f597"}.ti-rosette-number-9:before{content:"\f598"}.ti-rotate:before{content:"\eb16"}.ti-rotate-2:before{content:"\ebb4"}.ti-rotate-360:before{content:"\ef85"}.ti-rotate-3d:before{content:"\f020"}.ti-rotate-clockwise:before{content:"\eb15"}.ti-rotate-clockwise-2:before{content:"\ebb5"}.ti-rotate-dot:before{content:"\efe5"}.ti-rotate-rectangle:before{content:"\ec15"}.ti-route:before{content:"\eb17"}.ti-route-2:before{content:"\f4b6"}.ti-route-alt-left:before{content:"\fca9"}.ti-route-alt-right:before{content:"\fcaa"}.ti-route-off:before{content:"\f194"}.ti-route-scan:before{content:"\fcbf"}.ti-route-square:before{content:"\fcac"}.ti-route-square-2:before{content:"\fcab"}.ti-route-x:before{content:"\fcae"}.ti-route-x-2:before{content:"\fcad"}.ti-router:before{content:"\eb18"}.ti-router-off:before{content:"\f424"}.ti-row-insert-bottom:before{content:"\eed0"}.ti-row-insert-top:before{content:"\eed1"}.ti-row-remove:before{content:"\fafe"}.ti-rss:before{content:"\eb19"}.ti-rubber-stamp:before{content:"\f5ab"}.ti-rubber-stamp-off:before{content:"\f5aa"}.ti-ruler:before{content:"\eb1a"}.ti-ruler-2:before{content:"\eed2"}.ti-ruler-2-off:before{content:"\f195"}.ti-ruler-3:before{content:"\f290"}.ti-ruler-measure:before{content:"\f291"}.ti-ruler-measure-2:before{content:"\ff0f"}.ti-ruler-off:before{content:"\f196"}.ti-run:before{content:"\ec82"}.ti-rv-truck:before{content:"\fcc0"}.ti-s-turn-down:before{content:"\f516"}.ti-s-turn-left:before{content:"\f517"}.ti-s-turn-right:before{content:"\f518"}.ti-s-turn-up:before{content:"\f519"}.ti-sailboat:before{content:"\ec83"}.ti-sailboat-2:before{content:"\f5f7"}.ti-sailboat-off:before{content:"\f425"}.ti-salad:before{content:"\f50a"}.ti-salad-filled:before{content:"\10007"}.ti-salt:before{content:"\ef16"}.ti-sandbox:before{content:"\fd6c"}.ti-satellite:before{content:"\eed3"}.ti-satellite-off:before{content:"\f197"}.ti-sausage:before{content:"\ef17"}.ti-scale:before{content:"\ebc2"}.ti-scale-off:before{content:"\f198"}.ti-scale-outline:before{content:"\ef53"}.ti-scale-outline-off:before{content:"\f199"}.ti-scan:before{content:"\ebc8"}.ti-scan-eye:before{content:"\f1ff"}.ti-scan-position:before{content:"\fdac"}.ti-schema:before{content:"\f200"}.ti-schema-off:before{content:"\f426"}.ti-school:before{content:"\ecf7"}.ti-school-bell:before{content:"\f64a"}.ti-school-off:before{content:"\f19a"}.ti-scissors:before{content:"\eb1b"}.ti-scissors-off:before{content:"\f19b"}.ti-scooter:before{content:"\ec6c"}.ti-scooter-electric:before{content:"\ecc1"}.ti-scoreboard:before{content:"\fa6b"}.ti-screen-share:before{content:"\ed18"}.ti-screen-share-off:before{content:"\ed17"}.ti-screenshot:before{content:"\f201"}.ti-scribble:before{content:"\f0a3"}.ti-scribble-off:before{content:"\f427"}.ti-script:before{content:"\f2da"}.ti-script-minus:before{content:"\f2d7"}.ti-script-plus:before{content:"\f2d8"}.ti-script-x:before{content:"\f2d9"}.ti-scuba-diving:before{content:"\fd4e"}.ti-scuba-diving-tank:before{content:"\fefa"}.ti-scuba-diving-tank-filled:before{content:"\ff04"}.ti-scuba-mask:before{content:"\eed4"}.ti-scuba-mask-off:before{content:"\f428"}.ti-sdk:before{content:"\f3af"}.ti-search:before{content:"\eb1c"}.ti-search-off:before{content:"\f19c"}.ti-section:before{content:"\eed5"}.ti-section-filled:before{content:"\fe09"}.ti-section-sign:before{content:"\f019"}.ti-seeding:before{content:"\ed51"}.ti-seeding-filled:before{content:"\10006"}.ti-seeding-off:before{content:"\f19d"}.ti-select:before{content:"\ec9e"}.ti-select-all:before{content:"\f9f7"}.ti-selector:before{content:"\eb1d"}.ti-send:before{content:"\eb1e"}.ti-send-2:before{content:"\fd5d"}.ti-send-off:before{content:"\f429"}.ti-seo:before{content:"\f26b"}.ti-separator:before{content:"\ebda"}.ti-separator-horizontal:before{content:"\ec79"}.ti-separator-vertical:before{content:"\ec7a"}.ti-server:before{content:"\eb1f"}.ti-server-2:before{content:"\f07c"}.ti-server-bolt:before{content:"\f320"}.ti-server-cog:before{content:"\f321"}.ti-server-off:before{content:"\f19e"}.ti-server-spark:before{content:"\ffaf"}.ti-servicemark:before{content:"\ec09"}.ti-settings:before{content:"\eb20"}.ti-settings-2:before{content:"\f5ac"}.ti-settings-automation:before{content:"\eed6"}.ti-settings-bolt:before{content:"\f9ad"}.ti-settings-cancel:before{content:"\f9ae"}.ti-settings-check:before{content:"\f9af"}.ti-settings-code:before{content:"\f9b0"}.ti-settings-cog:before{content:"\f9b1"}.ti-settings-dollar:before{content:"\f9b2"}.ti-settings-down:before{content:"\f9b3"}.ti-settings-exclamation:before{content:"\f9b4"}.ti-settings-filled:before{content:"\f69e"}.ti-settings-heart:before{content:"\f9b5"}.ti-settings-minus:before{content:"\f9b6"}.ti-settings-off:before{content:"\f19f"}.ti-settings-pause:before{content:"\f9b7"}.ti-settings-pin:before{content:"\f9b8"}.ti-settings-plus:before{content:"\f9b9"}.ti-settings-question:before{content:"\f9ba"}.ti-settings-search:before{content:"\f9bb"}.ti-settings-share:before{content:"\f9bc"}.ti-settings-spark:before{content:"\ffae"}.ti-settings-star:before{content:"\f9bd"}.ti-settings-up:before{content:"\f9be"}.ti-settings-x:before{content:"\f9bf"}.ti-shadow:before{content:"\eed8"}.ti-shadow-off:before{content:"\eed7"}.ti-shape:before{content:"\eb9c"}.ti-shape-2:before{content:"\eed9"}.ti-shape-3:before{content:"\eeda"}.ti-shape-off:before{content:"\f1a0"}.ti-share:before{content:"\eb21"}.ti-share-2:before{content:"\f799"}.ti-share-3:before{content:"\f7bd"}.ti-share-off:before{content:"\f1a1"}.ti-shareplay:before{content:"\fea5"}.ti-shield:before{content:"\eb24"}.ti-shield-bolt:before{content:"\f9c0"}.ti-shield-cancel:before{content:"\f9c1"}.ti-shield-check:before{content:"\eb22"}.ti-shield-check-filled:before{content:"\f761"}.ti-shield-checkered:before{content:"\ef9a"}.ti-shield-checkered-filled:before{content:"\f762"}.ti-shield-chevron:before{content:"\ef9b"}.ti-shield-code:before{content:"\f9c2"}.ti-shield-cog:before{content:"\f9c3"}.ti-shield-dollar:before{content:"\f9c4"}.ti-shield-down:before{content:"\f9c5"}.ti-shield-exclamation:before{content:"\f9c6"}.ti-shield-filled:before{content:"\f69f"}.ti-shield-half:before{content:"\f358"}.ti-shield-half-filled:before{content:"\f357"}.ti-shield-heart:before{content:"\f9c7"}.ti-shield-lock:before{content:"\ed58"}.ti-shield-lock-filled:before{content:"\f763"}.ti-shield-minus:before{content:"\f9c8"}.ti-shield-off:before{content:"\ecf8"}.ti-shield-pause:before{content:"\f9c9"}.ti-shield-pin:before{content:"\f9ca"}.ti-shield-plus:before{content:"\f9cb"}.ti-shield-question:before{content:"\f9cc"}.ti-shield-search:before{content:"\f9cd"}.ti-shield-share:before{content:"\f9ce"}.ti-shield-star:before{content:"\f9cf"}.ti-shield-up:before{content:"\f9d0"}.ti-shield-x:before{content:"\eb23"}.ti-ship:before{content:"\ec84"}.ti-ship-off:before{content:"\f42a"}.ti-shirt:before{content:"\ec0a"}.ti-shirt-filled:before{content:"\f6a0"}.ti-shirt-off:before{content:"\f1a2"}.ti-shirt-sport:before{content:"\f26c"}.ti-shoe:before{content:"\efd2"}.ti-shoe-off:before{content:"\f1a4"}.ti-shopping-bag:before{content:"\f5f8"}.ti-shopping-bag-check:before{content:"\fc16"}.ti-shopping-bag-discount:before{content:"\fc17"}.ti-shopping-bag-edit:before{content:"\fc18"}.ti-shopping-bag-exclamation:before{content:"\fc19"}.ti-shopping-bag-heart:before{content:"\fda2"}.ti-shopping-bag-minus:before{content:"\fc1a"}.ti-shopping-bag-plus:before{content:"\fc1b"}.ti-shopping-bag-search:before{content:"\fc1c"}.ti-shopping-bag-x:before{content:"\fc1d"}.ti-shopping-cart:before{content:"\eb25"}.ti-shopping-cart-bolt:before{content:"\fb57"}.ti-shopping-cart-cancel:before{content:"\fb58"}.ti-shopping-cart-check:before{content:"\fb59"}.ti-shopping-cart-code:before{content:"\fb5a"}.ti-shopping-cart-cog:before{content:"\fb5b"}.ti-shopping-cart-copy:before{content:"\fb5c"}.ti-shopping-cart-discount:before{content:"\fb5d"}.ti-shopping-cart-dollar:before{content:"\fb5e"}.ti-shopping-cart-down:before{content:"\fb5f"}.ti-shopping-cart-exclamation:before{content:"\fb60"}.ti-shopping-cart-filled:before{content:"\fc3f"}.ti-shopping-cart-heart:before{content:"\fb61"}.ti-shopping-cart-minus:before{content:"\fb62"}.ti-shopping-cart-off:before{content:"\eedc"}.ti-shopping-cart-pause:before{content:"\fb63"}.ti-shopping-cart-pin:before{content:"\fb64"}.ti-shopping-cart-plus:before{content:"\fb65"}.ti-shopping-cart-question:before{content:"\fb66"}.ti-shopping-cart-search:before{content:"\fb67"}.ti-shopping-cart-share:before{content:"\fb68"}.ti-shopping-cart-star:before{content:"\fb69"}.ti-shopping-cart-up:before{content:"\fb6a"}.ti-shopping-cart-x:before{content:"\fb6b"}.ti-shovel:before{content:"\f1d9"}.ti-shovel-pitchforks:before{content:"\fd3a"}.ti-shredder:before{content:"\eedf"}.ti-sign-left:before{content:"\f06b"}.ti-sign-left-filled:before{content:"\f6a1"}.ti-sign-right:before{content:"\f06c"}.ti-sign-right-filled:before{content:"\f6a2"}.ti-signal-2g:before{content:"\f79a"}.ti-signal-3g:before{content:"\f1ee"}.ti-signal-4g:before{content:"\f1ef"}.ti-signal-4g-plus:before{content:"\f259"}.ti-signal-5g:before{content:"\f1f0"}.ti-signal-6g:before{content:"\f9f8"}.ti-signal-e:before{content:"\f9f9"}.ti-signal-g:before{content:"\f9fa"}.ti-signal-h:before{content:"\f9fc"}.ti-signal-h-plus:before{content:"\f9fb"}.ti-signal-lte:before{content:"\f9fd"}.ti-signature:before{content:"\eee0"}.ti-signature-off:before{content:"\f1a5"}.ti-sitemap:before{content:"\eb9d"}.ti-sitemap-filled:before{content:"\1006b"}.ti-sitemap-off:before{content:"\f1a6"}.ti-skateboard:before{content:"\ecc2"}.ti-skateboard-off:before{content:"\f42b"}.ti-skateboarding:before{content:"\faca"}.ti-skew-x:before{content:"\fd3b"}.ti-skew-y:before{content:"\fd3c"}.ti-ski-jumping:before{content:"\fa6c"}.ti-skull:before{content:"\f292"}.ti-slash:before{content:"\f4f9"}.ti-slashes:before{content:"\f588"}.ti-sleigh:before{content:"\ef9c"}.ti-slice:before{content:"\ebdb"}.ti-slideshow:before{content:"\ebc9"}.ti-smart-home:before{content:"\ecde"}.ti-smart-home-off:before{content:"\f1a7"}.ti-smoking:before{content:"\ecc4"}.ti-smoking-no:before{content:"\ecc3"}.ti-snowboarding:before{content:"\fd4f"}.ti-snowflake:before{content:"\ec0b"}.ti-snowflake-off:before{content:"\f1a8"}.ti-snowman:before{content:"\f26d"}.ti-soccer-field:before{content:"\ed92"}.ti-social:before{content:"\ebec"}.ti-social-off:before{content:"\f1a9"}.ti-sock:before{content:"\eee1"}.ti-sofa:before{content:"\efaf"}.ti-sofa-off:before{content:"\f42c"}.ti-solar-electricity:before{content:"\fcc1"}.ti-solar-panel:before{content:"\f7bf"}.ti-solar-panel-2:before{content:"\f7be"}.ti-sort-0-9:before{content:"\f54d"}.ti-sort-9-0:before{content:"\f54e"}.ti-sort-a-z:before{content:"\f54f"}.ti-sort-ascending:before{content:"\eb26"}.ti-sort-ascending-2:before{content:"\eee2"}.ti-sort-ascending-2-filled:before{content:"\ff5b"}.ti-sort-ascending-letters:before{content:"\ef18"}.ti-sort-ascending-numbers:before{content:"\ef19"}.ti-sort-ascending-shapes:before{content:"\fd94"}.ti-sort-ascending-shapes-filled:before{content:"\ff5a"}.ti-sort-ascending-small-big:before{content:"\fd95"}.ti-sort-descending:before{content:"\eb27"}.ti-sort-descending-2:before{content:"\eee3"}.ti-sort-descending-2-filled:before{content:"\ff59"}.ti-sort-descending-letters:before{content:"\ef1a"}.ti-sort-descending-numbers:before{content:"\ef1b"}.ti-sort-descending-shapes:before{content:"\fd97"}.ti-sort-descending-shapes-filled:before{content:"\ff58"}.ti-sort-descending-small-big:before{content:"\fd96"}.ti-sort-z-a:before{content:"\f550"}.ti-sos:before{content:"\f24a"}.ti-soup:before{content:"\ef2e"}.ti-soup-filled:before{content:"\fe08"}.ti-soup-off:before{content:"\f42d"}.ti-source-code:before{content:"\f4a2"}.ti-space:before{content:"\ec0c"}.ti-space-off:before{content:"\f1aa"}.ti-spaces:before{content:"\fea4"}.ti-spacing-horizontal:before{content:"\ef54"}.ti-spacing-vertical:before{content:"\ef55"}.ti-spade:before{content:"\effa"}.ti-spade-filled:before{content:"\f6a3"}.ti-sparkles:before{content:"\f6d7"}.ti-speakerphone:before{content:"\ed61"}.ti-speedboat:before{content:"\ed93"}.ti-sphere:before{content:"\fab8"}.ti-sphere-off:before{content:"\fab6"}.ti-sphere-plus:before{content:"\fab7"}.ti-spider:before{content:"\f293"}.ti-spiral:before{content:"\f294"}.ti-spiral-off:before{content:"\f42e"}.ti-sport-billard:before{content:"\eee4"}.ti-spray:before{content:"\f50b"}.ti-spy:before{content:"\f227"}.ti-spy-off:before{content:"\f42f"}.ti-sql:before{content:"\f7c0"}.ti-square:before{content:"\eb2c"}.ti-square-arrow-down:before{content:"\f4b7"}.ti-square-arrow-down-filled:before{content:"\fb31"}.ti-square-arrow-left:before{content:"\f4b8"}.ti-square-arrow-left-filled:before{content:"\fb32"}.ti-square-arrow-right:before{content:"\f4b9"}.ti-square-arrow-right-filled:before{content:"\fb33"}.ti-square-arrow-up:before{content:"\f4ba"}.ti-square-arrow-up-filled:before{content:"\fb34"}.ti-square-asterisk:before{content:"\f01a"}.ti-square-asterisk-filled:before{content:"\fb35"}.ti-square-check:before{content:"\eb28"}.ti-square-check-filled:before{content:"\f76d"}.ti-square-chevron-down:before{content:"\f627"}.ti-square-chevron-down-filled:before{content:"\fb36"}.ti-square-chevron-left:before{content:"\f628"}.ti-square-chevron-left-filled:before{content:"\fb37"}.ti-square-chevron-right:before{content:"\f629"}.ti-square-chevron-right-filled:before{content:"\fb38"}.ti-square-chevron-up:before{content:"\f62a"}.ti-square-chevron-up-filled:before{content:"\fb39"}.ti-square-chevrons-down:before{content:"\f64b"}.ti-square-chevrons-down-filled:before{content:"\fb3a"}.ti-square-chevrons-left:before{content:"\f64c"}.ti-square-chevrons-left-filled:before{content:"\fb3b"}.ti-square-chevrons-right:before{content:"\f64d"}.ti-square-chevrons-right-filled:before{content:"\fb3c"}.ti-square-chevrons-up:before{content:"\f64e"}.ti-square-chevrons-up-filled:before{content:"\fb3d"}.ti-square-dashed:before{content:"\100bb"}.ti-square-dot:before{content:"\ed59"}.ti-square-dot-filled:before{content:"\fb3e"}.ti-square-f0:before{content:"\f526"}.ti-square-f0-filled:before{content:"\f76e"}.ti-square-f1:before{content:"\f527"}.ti-square-f1-filled:before{content:"\f76f"}.ti-square-f2:before{content:"\f528"}.ti-square-f2-filled:before{content:"\f770"}.ti-square-f3:before{content:"\f529"}.ti-square-f3-filled:before{content:"\f771"}.ti-square-f4:before{content:"\f52a"}.ti-square-f4-filled:before{content:"\f772"}.ti-square-f5:before{content:"\f52b"}.ti-square-f5-filled:before{content:"\f773"}.ti-square-f6:before{content:"\f52c"}.ti-square-f6-filled:before{content:"\f774"}.ti-square-f7:before{content:"\f52d"}.ti-square-f7-filled:before{content:"\f775"}.ti-square-f8:before{content:"\f52e"}.ti-square-f8-filled:before{content:"\f776"}.ti-square-f9:before{content:"\f52f"}.ti-square-f9-filled:before{content:"\f777"}.ti-square-filled:before{content:"\fc40"}.ti-square-forbid:before{content:"\ed5b"}.ti-square-forbid-2:before{content:"\ed5a"}.ti-square-half:before{content:"\effb"}.ti-square-key:before{content:"\f638"}.ti-square-letter-a:before{content:"\f47c"}.ti-square-letter-a-filled:before{content:"\fe07"}.ti-square-letter-b:before{content:"\f47d"}.ti-square-letter-b-filled:before{content:"\fe06"}.ti-square-letter-c:before{content:"\f47e"}.ti-square-letter-c-filled:before{content:"\fe05"}.ti-square-letter-d:before{content:"\f47f"}.ti-square-letter-d-filled:before{content:"\fe04"}.ti-square-letter-e:before{content:"\f480"}.ti-square-letter-e-filled:before{content:"\fe03"}.ti-square-letter-f:before{content:"\f481"}.ti-square-letter-f-filled:before{content:"\fe02"}.ti-square-letter-g:before{content:"\f482"}.ti-square-letter-g-filled:before{content:"\fe01"}.ti-square-letter-h:before{content:"\f483"}.ti-square-letter-h-filled:before{content:"\fe00"}.ti-square-letter-i:before{content:"\f484"}.ti-square-letter-i-filled:before{content:"\fdff"}.ti-square-letter-j:before{content:"\f485"}.ti-square-letter-j-filled:before{content:"\fdfe"}.ti-square-letter-k:before{content:"\f486"}.ti-square-letter-k-filled:before{content:"\fdfd"}.ti-square-letter-l:before{content:"\f487"}.ti-square-letter-l-filled:before{content:"\fdfc"}.ti-square-letter-m:before{content:"\f488"}.ti-square-letter-m-filled:before{content:"\fdfb"}.ti-square-letter-n:before{content:"\f489"}.ti-square-letter-n-filled:before{content:"\fdfa"}.ti-square-letter-o:before{content:"\f48a"}.ti-square-letter-o-filled:before{content:"\fdf9"}.ti-square-letter-p:before{content:"\f48b"}.ti-square-letter-p-filled:before{content:"\fdf8"}.ti-square-letter-q:before{content:"\f48c"}.ti-square-letter-q-filled:before{content:"\fdf7"}.ti-square-letter-r:before{content:"\f48d"}.ti-square-letter-r-filled:before{content:"\fdf6"}.ti-square-letter-s:before{content:"\f48e"}.ti-square-letter-s-filled:before{content:"\fdf5"}.ti-square-letter-t:before{content:"\f48f"}.ti-square-letter-t-filled:before{content:"\fdf4"}.ti-square-letter-u:before{content:"\f490"}.ti-square-letter-u-filled:before{content:"\fdf3"}.ti-square-letter-v:before{content:"\f4bb"}.ti-square-letter-v-filled:before{content:"\fdf2"}.ti-square-letter-w:before{content:"\f491"}.ti-square-letter-w-filled:before{content:"\fdf1"}.ti-square-letter-x:before{content:"\f4bc"}.ti-square-letter-x-filled:before{content:"\fdf0"}.ti-square-letter-y:before{content:"\f492"}.ti-square-letter-y-filled:before{content:"\fdef"}.ti-square-letter-z:before{content:"\f493"}.ti-square-letter-z-filled:before{content:"\fdee"}.ti-square-minus:before{content:"\eb29"}.ti-square-minus-filled:before{content:"\fb3f"}.ti-square-number-0:before{content:"\eee5"}.ti-square-number-0-filled:before{content:"\f764"}.ti-square-number-1:before{content:"\eee6"}.ti-square-number-1-filled:before{content:"\f765"}.ti-square-number-2:before{content:"\eee7"}.ti-square-number-2-filled:before{content:"\f7fa"}.ti-square-number-3:before{content:"\eee8"}.ti-square-number-3-filled:before{content:"\f766"}.ti-square-number-4:before{content:"\eee9"}.ti-square-number-4-filled:before{content:"\f767"}.ti-square-number-5:before{content:"\eeea"}.ti-square-number-5-filled:before{content:"\f768"}.ti-square-number-6:before{content:"\eeeb"}.ti-square-number-6-filled:before{content:"\f769"}.ti-square-number-7:before{content:"\eeec"}.ti-square-number-7-filled:before{content:"\f76a"}.ti-square-number-8:before{content:"\eeed"}.ti-square-number-8-filled:before{content:"\f76b"}.ti-square-number-9:before{content:"\eeee"}.ti-square-number-9-filled:before{content:"\f76c"}.ti-square-off:before{content:"\eeef"}.ti-square-percentage:before{content:"\fd83"}.ti-square-plus:before{content:"\eb2a"}.ti-square-plus-2:before{content:"\fc96"}.ti-square-root:before{content:"\eef1"}.ti-square-root-2:before{content:"\eef0"}.ti-square-rotated:before{content:"\ecdf"}.ti-square-rotated-filled:before{content:"\f6a4"}.ti-square-rotated-forbid:before{content:"\f01c"}.ti-square-rotated-forbid-2:before{content:"\f01b"}.ti-square-rotated-off:before{content:"\eef2"}.ti-square-rounded:before{content:"\f59a"}.ti-square-rounded-arrow-down:before{content:"\f639"}.ti-square-rounded-arrow-down-filled:before{content:"\f6db"}.ti-square-rounded-arrow-left:before{content:"\f63a"}.ti-square-rounded-arrow-left-filled:before{content:"\f6dc"}.ti-square-rounded-arrow-right:before{content:"\f63b"}.ti-square-rounded-arrow-right-filled:before{content:"\f6dd"}.ti-square-rounded-arrow-up:before{content:"\f63c"}.ti-square-rounded-arrow-up-filled:before{content:"\f6de"}.ti-square-rounded-check:before{content:"\f63d"}.ti-square-rounded-check-filled:before{content:"\f6df"}.ti-square-rounded-chevron-down:before{content:"\f62b"}.ti-square-rounded-chevron-down-filled:before{content:"\f6e0"}.ti-square-rounded-chevron-left:before{content:"\f62c"}.ti-square-rounded-chevron-left-filled:before{content:"\f6e1"}.ti-square-rounded-chevron-right:before{content:"\f62d"}.ti-square-rounded-chevron-right-filled:before{content:"\f6e2"}.ti-square-rounded-chevron-up:before{content:"\f62e"}.ti-square-rounded-chevron-up-filled:before{content:"\f6e3"}.ti-square-rounded-chevrons-down:before{content:"\f64f"}.ti-square-rounded-chevrons-down-filled:before{content:"\f6e4"}.ti-square-rounded-chevrons-left:before{content:"\f650"}.ti-square-rounded-chevrons-left-filled:before{content:"\f6e5"}.ti-square-rounded-chevrons-right:before{content:"\f651"}.ti-square-rounded-chevrons-right-filled:before{content:"\f6e6"}.ti-square-rounded-chevrons-up:before{content:"\f652"}.ti-square-rounded-chevrons-up-filled:before{content:"\f6e7"}.ti-square-rounded-filled:before{content:"\f6a5"}.ti-square-rounded-letter-a:before{content:"\f5ae"}.ti-square-rounded-letter-a-filled:before{content:"\fded"}.ti-square-rounded-letter-b:before{content:"\f5af"}.ti-square-rounded-letter-b-filled:before{content:"\fdec"}.ti-square-rounded-letter-c:before{content:"\f5b0"}.ti-square-rounded-letter-c-filled:before{content:"\fdeb"}.ti-square-rounded-letter-d:before{content:"\f5b1"}.ti-square-rounded-letter-d-filled:before{content:"\fdea"}.ti-square-rounded-letter-e:before{content:"\f5b2"}.ti-square-rounded-letter-e-filled:before{content:"\fde9"}.ti-square-rounded-letter-f:before{content:"\f5b3"}.ti-square-rounded-letter-f-filled:before{content:"\fde8"}.ti-square-rounded-letter-g:before{content:"\f5b4"}.ti-square-rounded-letter-g-filled:before{content:"\fde7"}.ti-square-rounded-letter-h:before{content:"\f5b5"}.ti-square-rounded-letter-h-filled:before{content:"\fde6"}.ti-square-rounded-letter-i:before{content:"\f5b6"}.ti-square-rounded-letter-i-filled:before{content:"\fde5"}.ti-square-rounded-letter-j:before{content:"\f5b7"}.ti-square-rounded-letter-j-filled:before{content:"\fde4"}.ti-square-rounded-letter-k:before{content:"\f5b8"}.ti-square-rounded-letter-k-filled:before{content:"\fde3"}.ti-square-rounded-letter-l:before{content:"\f5b9"}.ti-square-rounded-letter-l-filled:before{content:"\fde2"}.ti-square-rounded-letter-m:before{content:"\f5ba"}.ti-square-rounded-letter-m-filled:before{content:"\fde1"}.ti-square-rounded-letter-n:before{content:"\f5bb"}.ti-square-rounded-letter-n-filled:before{content:"\fde0"}.ti-square-rounded-letter-o:before{content:"\f5bc"}.ti-square-rounded-letter-o-filled:before{content:"\fddf"}.ti-square-rounded-letter-p:before{content:"\f5bd"}.ti-square-rounded-letter-p-filled:before{content:"\fdde"}.ti-square-rounded-letter-q:before{content:"\f5be"}.ti-square-rounded-letter-q-filled:before{content:"\fddd"}.ti-square-rounded-letter-r:before{content:"\f5bf"}.ti-square-rounded-letter-r-filled:before{content:"\fddc"}.ti-square-rounded-letter-s:before{content:"\f5c0"}.ti-square-rounded-letter-s-filled:before{content:"\fddb"}.ti-square-rounded-letter-t:before{content:"\f5c1"}.ti-square-rounded-letter-t-filled:before{content:"\fdda"}.ti-square-rounded-letter-u:before{content:"\f5c2"}.ti-square-rounded-letter-u-filled:before{content:"\fdd9"}.ti-square-rounded-letter-v:before{content:"\f5c3"}.ti-square-rounded-letter-v-filled:before{content:"\fdd8"}.ti-square-rounded-letter-w:before{content:"\f5c4"}.ti-square-rounded-letter-w-filled:before{content:"\fdd7"}.ti-square-rounded-letter-x:before{content:"\f5c5"}.ti-square-rounded-letter-x-filled:before{content:"\fdd6"}.ti-square-rounded-letter-y:before{content:"\f5c6"}.ti-square-rounded-letter-y-filled:before{content:"\fdd5"}.ti-square-rounded-letter-z:before{content:"\f5c7"}.ti-square-rounded-letter-z-filled:before{content:"\fdd4"}.ti-square-rounded-minus:before{content:"\f63e"}.ti-square-rounded-minus-2:before{content:"\fc97"}.ti-square-rounded-minus-filled:before{content:"\fb40"}.ti-square-rounded-number-0:before{content:"\f5c8"}.ti-square-rounded-number-0-filled:before{content:"\f778"}.ti-square-rounded-number-1:before{content:"\f5c9"}.ti-square-rounded-number-1-filled:before{content:"\f779"}.ti-square-rounded-number-2:before{content:"\f5ca"}.ti-square-rounded-number-2-filled:before{content:"\f77a"}.ti-square-rounded-number-3:before{content:"\f5cb"}.ti-square-rounded-number-3-filled:before{content:"\f77b"}.ti-square-rounded-number-4:before{content:"\f5cc"}.ti-square-rounded-number-4-filled:before{content:"\f77c"}.ti-square-rounded-number-5:before{content:"\f5cd"}.ti-square-rounded-number-5-filled:before{content:"\f77d"}.ti-square-rounded-number-6:before{content:"\f5ce"}.ti-square-rounded-number-6-filled:before{content:"\f77e"}.ti-square-rounded-number-7:before{content:"\f5cf"}.ti-square-rounded-number-7-filled:before{content:"\f77f"}.ti-square-rounded-number-8:before{content:"\f5d0"}.ti-square-rounded-number-8-filled:before{content:"\f780"}.ti-square-rounded-number-9:before{content:"\f5d1"}.ti-square-rounded-number-9-filled:before{content:"\f781"}.ti-square-rounded-percentage:before{content:"\fd84"}.ti-square-rounded-plus:before{content:"\f63f"}.ti-square-rounded-plus-2:before{content:"\fc98"}.ti-square-rounded-plus-filled:before{content:"\f6e8"}.ti-square-rounded-x:before{content:"\f640"}.ti-square-rounded-x-filled:before{content:"\f6e9"}.ti-square-toggle:before{content:"\eef4"}.ti-square-toggle-horizontal:before{content:"\eef3"}.ti-square-x:before{content:"\eb2b"}.ti-square-x-filled:before{content:"\fb41"}.ti-squares:before{content:"\eef6"}.ti-squares-diagonal:before{content:"\eef5"}.ti-squares-filled:before{content:"\fe9f"}.ti-squares-selected:before{content:"\fea3"}.ti-stack:before{content:"\eb2d"}.ti-stack-2:before{content:"\eef7"}.ti-stack-2-filled:before{content:"\fdd3"}.ti-stack-3:before{content:"\ef9d"}.ti-stack-3-filled:before{content:"\fdd2"}.ti-stack-back:before{content:"\fd26"}.ti-stack-backward:before{content:"\fd27"}.ti-stack-filled:before{content:"\fdd1"}.ti-stack-forward:before{content:"\fd28"}.ti-stack-front:before{content:"\fd29"}.ti-stack-middle:before{content:"\fd2a"}.ti-stack-pop:before{content:"\f234"}.ti-stack-push:before{content:"\f235"}.ti-stairs:before{content:"\eca6"}.ti-stairs-down:before{content:"\eca4"}.ti-stairs-up:before{content:"\eca5"}.ti-star:before{content:"\eb2e"}.ti-star-filled:before{content:"\f6a6"}.ti-star-half:before{content:"\ed19"}.ti-star-half-filled:before{content:"\f6a7"}.ti-star-off:before{content:"\ed62"}.ti-stars:before{content:"\ed38"}.ti-stars-filled:before{content:"\f6a8"}.ti-stars-off:before{content:"\f430"}.ti-status-change:before{content:"\f3b0"}.ti-steam:before{content:"\f24b"}.ti-steering-wheel:before{content:"\ec7b"}.ti-steering-wheel-filled:before{content:"\ff03"}.ti-steering-wheel-off:before{content:"\f431"}.ti-step-into:before{content:"\ece0"}.ti-step-out:before{content:"\ece1"}.ti-stereo-glasses:before{content:"\f4cb"}.ti-stethoscope:before{content:"\edbe"}.ti-stethoscope-off:before{content:"\f432"}.ti-sticker:before{content:"\eb2f"}.ti-sticker-2:before{content:"\fd3d"}.ti-stopwatch:before{content:"\ff9b"}.ti-storm:before{content:"\f24c"}.ti-storm-off:before{content:"\f433"}.ti-stretching:before{content:"\f2db"}.ti-stretching-2:before{content:"\fa6d"}.ti-strikethrough:before{content:"\eb9e"}.ti-submarine:before{content:"\ed94"}.ti-subscript:before{content:"\eb9f"}.ti-subtask:before{content:"\ec9f"}.ti-sum:before{content:"\eb73"}.ti-sum-off:before{content:"\f1ab"}.ti-sun:before{content:"\eb30"}.ti-sun-electricity:before{content:"\fcc2"}.ti-sun-filled:before{content:"\f6a9"}.ti-sun-high:before{content:"\f236"}.ti-sun-low:before{content:"\f237"}.ti-sun-moon:before{content:"\f4a3"}.ti-sun-off:before{content:"\ed63"}.ti-sun-wind:before{content:"\f238"}.ti-sunglasses:before{content:"\f239"}.ti-sunglasses-filled:before{content:"\fec8"}.ti-sunrise:before{content:"\ef1c"}.ti-sunset:before{content:"\ec31"}.ti-sunset-2:before{content:"\f23a"}.ti-superscript:before{content:"\eba0"}.ti-svg:before{content:"\f25a"}.ti-swimming:before{content:"\ec92"}.ti-swipe:before{content:"\f551"}.ti-swipe-down:before{content:"\fd5e"}.ti-swipe-down-filled:before{content:"\ff57"}.ti-swipe-left:before{content:"\fd5f"}.ti-swipe-left-filled:before{content:"\ff56"}.ti-swipe-right:before{content:"\fd60"}.ti-swipe-right-filled:before{content:"\ff55"}.ti-swipe-up:before{content:"\fd61"}.ti-swipe-up-filled:before{content:"\ff54"}.ti-switch:before{content:"\eb33"}.ti-switch-2:before{content:"\edbf"}.ti-switch-3:before{content:"\edc0"}.ti-switch-horizontal:before{content:"\eb31"}.ti-switch-vertical:before{content:"\eb32"}.ti-sword:before{content:"\f030"}.ti-sword-off:before{content:"\f434"}.ti-swords:before{content:"\f132"}.ti-table:before{content:"\eba1"}.ti-table-alias:before{content:"\f25b"}.ti-table-column:before{content:"\faff"}.ti-table-dashed:before{content:"\100ba"}.ti-table-down:before{content:"\fa1c"}.ti-table-export:before{content:"\eef8"}.ti-table-filled:before{content:"\f782"}.ti-table-heart:before{content:"\fa1d"}.ti-table-import:before{content:"\eef9"}.ti-table-minus:before{content:"\fa1e"}.ti-table-off:before{content:"\eefa"}.ti-table-options:before{content:"\f25c"}.ti-table-plus:before{content:"\fa1f"}.ti-table-row:before{content:"\fb00"}.ti-table-share:before{content:"\fa20"}.ti-table-shortcut:before{content:"\f25d"}.ti-table-spark:before{content:"\ffad"}.ti-tag:before{content:"\10096"}.ti-tag-filled:before{content:"\ff02"}.ti-tag-minus:before{content:"\eb34"}.ti-tag-off:before{content:"\efc0"}.ti-tag-plus:before{content:"\10097"}.ti-tag-starred:before{content:"\fc99"}.ti-tags:before{content:"\ef86"}.ti-tags-filled:before{content:"\ff01"}.ti-tags-off:before{content:"\efc1"}.ti-tallymark-1:before{content:"\ec46"}.ti-tallymark-2:before{content:"\ec47"}.ti-tallymark-3:before{content:"\ec48"}.ti-tallymark-4:before{content:"\ec49"}.ti-tallymarks:before{content:"\ec4a"}.ti-tank:before{content:"\ed95"}.ti-target:before{content:"\eb35"}.ti-target-arrow:before{content:"\f51a"}.ti-target-off:before{content:"\f1ad"}.ti-tax:before{content:"\feee"}.ti-tax-euro:before{content:"\fef0"}.ti-tax-pound:before{content:"\feef"}.ti-teapot:before{content:"\f552"}.ti-telescope:before{content:"\f07d"}.ti-telescope-off:before{content:"\f1ae"}.ti-temperature:before{content:"\eb38"}.ti-temperature-celsius:before{content:"\eb36"}.ti-temperature-fahrenheit:before{content:"\eb37"}.ti-temperature-minus:before{content:"\ebed"}.ti-temperature-off:before{content:"\f1af"}.ti-temperature-plus:before{content:"\ebee"}.ti-temperature-snow:before{content:"\fda3"}.ti-temperature-sun:before{content:"\fda4"}.ti-template:before{content:"\eb39"}.ti-template-off:before{content:"\f1b0"}.ti-tent:before{content:"\eefb"}.ti-tent-off:before{content:"\f435"}.ti-terminal:before{content:"\ebdc"}.ti-terminal-2:before{content:"\ebef"}.ti-test-pipe:before{content:"\eb3a"}.ti-test-pipe-2:before{content:"\f0a4"}.ti-test-pipe-2-filled:before{content:"\ff53"}.ti-test-pipe-off:before{content:"\f1b1"}.ti-tex:before{content:"\f4e0"}.ti-text-caption:before{content:"\f4a4"}.ti-text-color:before{content:"\f2dc"}.ti-text-decrease:before{content:"\f202"}.ti-text-direction-ltr:before{content:"\eefc"}.ti-text-direction-rtl:before{content:"\eefd"}.ti-text-grammar:before{content:"\fd6d"}.ti-text-increase:before{content:"\f203"}.ti-text-orientation:before{content:"\f2a4"}.ti-text-plus:before{content:"\f2a5"}.ti-text-recognition:before{content:"\f204"}.ti-text-resize:before{content:"\ef87"}.ti-text-scan-2:before{content:"\fcc3"}.ti-text-size:before{content:"\f2b1"}.ti-text-spellcheck:before{content:"\f2a6"}.ti-text-wrap:before{content:"\ebdd"}.ti-text-wrap-column:before{content:"\feb2"}.ti-text-wrap-disabled:before{content:"\eca7"}.ti-texture:before{content:"\f51b"}.ti-theater:before{content:"\f79b"}.ti-thermometer:before{content:"\ef67"}.ti-thumb-down:before{content:"\eb3b"}.ti-thumb-down-filled:before{content:"\f6aa"}.ti-thumb-down-off:before{content:"\f436"}.ti-thumb-up:before{content:"\eb3c"}.ti-thumb-up-filled:before{content:"\f6ab"}.ti-thumb-up-off:before{content:"\f437"}.ti-tic-tac:before{content:"\f51c"}.ti-ticket:before{content:"\eb3d"}.ti-ticket-off:before{content:"\f1b2"}.ti-tie:before{content:"\f07e"}.ti-tilde:before{content:"\f4a5"}.ti-tilt-shift:before{content:"\eefe"}.ti-tilt-shift-filled:before{content:"\fec7"}.ti-tilt-shift-off:before{content:"\f1b3"}.ti-time-duration-0:before{content:"\fad4"}.ti-time-duration-10:before{content:"\fad5"}.ti-time-duration-15:before{content:"\fad6"}.ti-time-duration-30:before{content:"\fad7"}.ti-time-duration-45:before{content:"\fad8"}.ti-time-duration-5:before{content:"\fad9"}.ti-time-duration-60:before{content:"\fada"}.ti-time-duration-90:before{content:"\fadb"}.ti-time-duration-off:before{content:"\fadc"}.ti-timeline:before{content:"\f031"}.ti-timeline-event:before{content:"\f553"}.ti-timeline-event-exclamation:before{content:"\f662"}.ti-timeline-event-filled:before{content:"\fd18"}.ti-timeline-event-minus:before{content:"\f663"}.ti-timeline-event-plus:before{content:"\f664"}.ti-timeline-event-text:before{content:"\f665"}.ti-timeline-event-x:before{content:"\f666"}.ti-timezone:before{content:"\feed"}.ti-tip-jar:before{content:"\feea"}.ti-tip-jar-euro:before{content:"\feec"}.ti-tip-jar-pound:before{content:"\feeb"}.ti-tir:before{content:"\ebf0"}.ti-toggle-left:before{content:"\eb3e"}.ti-toggle-left-filled:before{content:"\fec0"}.ti-toggle-right:before{content:"\eb3f"}.ti-toggle-right-filled:before{content:"\febf"}.ti-toilet-paper:before{content:"\efd3"}.ti-toilet-paper-off:before{content:"\f1b4"}.ti-toml:before{content:"\fa5d"}.ti-tool:before{content:"\eb40"}.ti-tools:before{content:"\ebca"}.ti-tools-kitchen:before{content:"\ed64"}.ti-tools-kitchen-2:before{content:"\eeff"}.ti-tools-kitchen-2-off:before{content:"\f1b5"}.ti-tools-kitchen-3:before{content:"\fd2b"}.ti-tools-kitchen-off:before{content:"\f1b6"}.ti-tools-off:before{content:"\f1b7"}.ti-tooltip:before{content:"\f2dd"}.ti-topology-bus:before{content:"\f5d9"}.ti-topology-complex:before{content:"\f5da"}.ti-topology-full:before{content:"\f5dc"}.ti-topology-full-hierarchy:before{content:"\f5db"}.ti-topology-ring:before{content:"\f5df"}.ti-topology-ring-2:before{content:"\f5dd"}.ti-topology-ring-3:before{content:"\f5de"}.ti-topology-star:before{content:"\f5e5"}.ti-topology-star-2:before{content:"\f5e0"}.ti-topology-star-3:before{content:"\f5e1"}.ti-topology-star-ring:before{content:"\f5e4"}.ti-topology-star-ring-2:before{content:"\f5e2"}.ti-topology-star-ring-3:before{content:"\f5e3"}.ti-torii:before{content:"\f59b"}.ti-tornado:before{content:"\ece2"}.ti-tournament:before{content:"\ecd0"}.ti-tower:before{content:"\f2cb"}.ti-tower-off:before{content:"\f2ca"}.ti-track:before{content:"\ef00"}.ti-tractor:before{content:"\ec0d"}.ti-trademark:before{content:"\ec0e"}.ti-traffic-cone:before{content:"\ec0f"}.ti-traffic-cone-off:before{content:"\f1b8"}.ti-traffic-lights:before{content:"\ed39"}.ti-traffic-lights-off:before{content:"\f1b9"}.ti-train:before{content:"\ed96"}.ti-transaction-bitcoin:before{content:"\fd6e"}.ti-transaction-dollar:before{content:"\fd6f"}.ti-transaction-euro:before{content:"\fd70"}.ti-transaction-pound:before{content:"\fd71"}.ti-transaction-rupee:before{content:"\fd85"}.ti-transaction-yen:before{content:"\fd72"}.ti-transaction-yuan:before{content:"\fd73"}.ti-transfer:before{content:"\fc1f"}.ti-transfer-in:before{content:"\ef2f"}.ti-transfer-out:before{content:"\ef30"}.ti-transfer-vertical:before{content:"\fc1e"}.ti-transform:before{content:"\f38e"}.ti-transform-filled:before{content:"\f6ac"}.ti-transform-point:before{content:"\fda9"}.ti-transform-point-bottom-left:before{content:"\fda5"}.ti-transform-point-bottom-right:before{content:"\fda6"}.ti-transform-point-top-left:before{content:"\fda7"}.ti-transform-point-top-right:before{content:"\fda8"}.ti-transition-bottom:before{content:"\f2b2"}.ti-transition-bottom-filled:before{content:"\fdd0"}.ti-transition-left:before{content:"\f2b3"}.ti-transition-left-filled:before{content:"\fdcf"}.ti-transition-right:before{content:"\f2b4"}.ti-transition-right-filled:before{content:"\fdce"}.ti-transition-top:before{content:"\f2b5"}.ti-transition-top-filled:before{content:"\fdcd"}.ti-trash:before{content:"\eb41"}.ti-trash-filled:before{content:"\f783"}.ti-trash-off:before{content:"\ed65"}.ti-trash-x:before{content:"\ef88"}.ti-trash-x-filled:before{content:"\f784"}.ti-treadmill:before{content:"\fa6e"}.ti-tree:before{content:"\ef01"}.ti-trees:before{content:"\ec10"}.ti-trekking:before{content:"\f5ad"}.ti-trending-down:before{content:"\eb42"}.ti-trending-down-2:before{content:"\edc1"}.ti-trending-down-3:before{content:"\edc2"}.ti-trending-up:before{content:"\eb43"}.ti-trending-up-2:before{content:"\edc3"}.ti-trending-up-3:before{content:"\edc4"}.ti-triangle:before{content:"\eb44"}.ti-triangle-filled:before{content:"\f6ad"}.ti-triangle-inverted:before{content:"\f01d"}.ti-triangle-inverted-filled:before{content:"\f6ae"}.ti-triangle-minus:before{content:"\fc9b"}.ti-triangle-minus-2:before{content:"\fc9a"}.ti-triangle-off:before{content:"\ef02"}.ti-triangle-plus:before{content:"\fc9d"}.ti-triangle-plus-2:before{content:"\fc9c"}.ti-triangle-square-circle:before{content:"\ece8"}.ti-triangle-square-circle-filled:before{content:"\fb42"}.ti-triangles:before{content:"\f0a5"}.ti-trident:before{content:"\ecc5"}.ti-trolley:before{content:"\f4cc"}.ti-trophy:before{content:"\eb45"}.ti-trophy-filled:before{content:"\f6af"}.ti-trophy-off:before{content:"\f438"}.ti-trowel:before{content:"\f368"}.ti-truck:before{content:"\ebc4"}.ti-truck-delivery:before{content:"\ec4b"}.ti-truck-loading:before{content:"\f1da"}.ti-truck-off:before{content:"\ef03"}.ti-truck-return:before{content:"\ec4c"}.ti-txt:before{content:"\f3b1"}.ti-typeface:before{content:"\fdab"}.ti-typography:before{content:"\ebc5"}.ti-typography-off:before{content:"\f1ba"}.ti-u-turn-left:before{content:"\fea2"}.ti-u-turn-right:before{content:"\fea1"}.ti-ufo:before{content:"\f26f"}.ti-ufo-off:before{content:"\f26e"}.ti-uhd:before{content:"\100aa"}.ti-umbrella:before{content:"\ebf1"}.ti-umbrella-2:before{content:"\ff0e"}.ti-umbrella-closed:before{content:"\ff0c"}.ti-umbrella-closed-2:before{content:"\ff0d"}.ti-umbrella-filled:before{content:"\f6b0"}.ti-umbrella-off:before{content:"\f1bb"}.ti-underline:before{content:"\eba2"}.ti-universe:before{content:"\fcc4"}.ti-unlink:before{content:"\eb46"}.ti-upload:before{content:"\eb47"}.ti-urgent:before{content:"\eb48"}.ti-usb:before{content:"\f00c"}.ti-user:before{content:"\eb4d"}.ti-user-bitcoin:before{content:"\ff30"}.ti-user-bolt:before{content:"\f9d1"}.ti-user-cancel:before{content:"\f9d2"}.ti-user-check:before{content:"\eb49"}.ti-user-circle:before{content:"\ef68"}.ti-user-code:before{content:"\f9d3"}.ti-user-cog:before{content:"\f9d4"}.ti-user-dollar:before{content:"\f9d5"}.ti-user-down:before{content:"\f9d6"}.ti-user-edit:before{content:"\f7cc"}.ti-user-exclamation:before{content:"\ec12"}.ti-user-filled:before{content:"\fd19"}.ti-user-heart:before{content:"\f7cd"}.ti-user-hexagon:before{content:"\fc4e"}.ti-user-minus:before{content:"\eb4a"}.ti-user-off:before{content:"\ecf9"}.ti-user-pause:before{content:"\f9d7"}.ti-user-pentagon:before{content:"\fc4f"}.ti-user-pin:before{content:"\f7ce"}.ti-user-plus:before{content:"\eb4b"}.ti-user-question:before{content:"\f7cf"}.ti-user-scan:before{content:"\fcaf"}.ti-user-screen:before{content:"\fea0"}.ti-user-search:before{content:"\ef89"}.ti-user-share:before{content:"\f9d8"}.ti-user-shield:before{content:"\f7d0"}.ti-user-square:before{content:"\fc51"}.ti-user-square-rounded:before{content:"\fc50"}.ti-user-star:before{content:"\f7d1"}.ti-user-up:before{content:"\f7d2"}.ti-user-x:before{content:"\eb4c"}.ti-users:before{content:"\ebf2"}.ti-users-group:before{content:"\fa21"}.ti-users-minus:before{content:"\fa0e"}.ti-users-plus:before{content:"\fa0f"}.ti-uv-index:before{content:"\f3b2"}.ti-ux-circle:before{content:"\f369"}.ti-vaccine:before{content:"\ef04"}.ti-vaccine-bottle:before{content:"\ef69"}.ti-vaccine-bottle-off:before{content:"\f439"}.ti-vaccine-off:before{content:"\f1bc"}.ti-vacuum-cleaner:before{content:"\f5e6"}.ti-variable:before{content:"\ef05"}.ti-variable-minus:before{content:"\f36a"}.ti-variable-off:before{content:"\f1bd"}.ti-variable-plus:before{content:"\f36b"}.ti-vector:before{content:"\eca9"}.ti-vector-bezier:before{content:"\ef1d"}.ti-vector-bezier-2:before{content:"\f1a3"}.ti-vector-bezier-arc:before{content:"\f4cd"}.ti-vector-bezier-circle:before{content:"\f4ce"}.ti-vector-off:before{content:"\f1be"}.ti-vector-spline:before{content:"\f565"}.ti-vector-triangle:before{content:"\eca8"}.ti-vector-triangle-off:before{content:"\f1bf"}.ti-venus:before{content:"\ec86"}.ti-versions:before{content:"\ed52"}.ti-versions-filled:before{content:"\f6b1"}.ti-versions-off:before{content:"\f1c0"}.ti-video:before{content:"\ed22"}.ti-video-filled:before{content:"\1009b"}.ti-video-minus:before{content:"\ed1f"}.ti-video-off:before{content:"\ed20"}.ti-video-plus:before{content:"\ed21"}.ti-view-360:before{content:"\ed84"}.ti-view-360-arrow:before{content:"\f62f"}.ti-view-360-number:before{content:"\f566"}.ti-view-360-off:before{content:"\f1c1"}.ti-viewfinder:before{content:"\eb4e"}.ti-viewfinder-off:before{content:"\f1c2"}.ti-viewport-narrow:before{content:"\ebf3"}.ti-viewport-short:before{content:"\fee9"}.ti-viewport-tall:before{content:"\fee8"}.ti-viewport-wide:before{content:"\ebf4"}.ti-vinyl:before{content:"\f00d"}.ti-vip:before{content:"\f3b3"}.ti-vip-off:before{content:"\f43a"}.ti-virus:before{content:"\eb74"}.ti-virus-off:before{content:"\ed66"}.ti-virus-search:before{content:"\ed67"}.ti-vocabulary:before{content:"\ef1e"}.ti-vocabulary-off:before{content:"\f43b"}.ti-volcano:before{content:"\f79c"}.ti-volume:before{content:"\eb51"}.ti-volume-2:before{content:"\eb4f"}.ti-volume-3:before{content:"\eb50"}.ti-volume-off:before{content:"\f1c3"}.ti-vs:before{content:"\fc52"}.ti-walk:before{content:"\ec87"}.ti-wall:before{content:"\ef7a"}.ti-wall-off:before{content:"\f43c"}.ti-wallet:before{content:"\eb75"}.ti-wallet-off:before{content:"\f1c4"}.ti-wallpaper:before{content:"\ef56"}.ti-wallpaper-off:before{content:"\f1c5"}.ti-wand:before{content:"\ebcb"}.ti-wand-off:before{content:"\f1c6"}.ti-wash:before{content:"\f311"}.ti-wash-dry:before{content:"\f304"}.ti-wash-dry-1:before{content:"\f2fa"}.ti-wash-dry-2:before{content:"\f2fb"}.ti-wash-dry-3:before{content:"\f2fc"}.ti-wash-dry-a:before{content:"\f2fd"}.ti-wash-dry-dip:before{content:"\f2fe"}.ti-wash-dry-f:before{content:"\f2ff"}.ti-wash-dry-flat:before{content:"\fa7f"}.ti-wash-dry-hang:before{content:"\f300"}.ti-wash-dry-off:before{content:"\f301"}.ti-wash-dry-p:before{content:"\f302"}.ti-wash-dry-shade:before{content:"\f303"}.ti-wash-dry-w:before{content:"\f322"}.ti-wash-dryclean:before{content:"\f305"}.ti-wash-dryclean-off:before{content:"\f323"}.ti-wash-eco:before{content:"\fa80"}.ti-wash-gentle:before{content:"\f306"}.ti-wash-hand:before{content:"\fa81"}.ti-wash-machine:before{content:"\f25e"}.ti-wash-off:before{content:"\f307"}.ti-wash-press:before{content:"\f308"}.ti-wash-temperature-1:before{content:"\f309"}.ti-wash-temperature-2:before{content:"\f30a"}.ti-wash-temperature-3:before{content:"\f30b"}.ti-wash-temperature-4:before{content:"\f30c"}.ti-wash-temperature-5:before{content:"\f30d"}.ti-wash-temperature-6:before{content:"\f30e"}.ti-wash-tumble-dry:before{content:"\f30f"}.ti-wash-tumble-off:before{content:"\f310"}.ti-waterpolo:before{content:"\fa6f"}.ti-wave-saw-tool:before{content:"\ecd3"}.ti-wave-sine:before{content:"\ecd4"}.ti-wave-square:before{content:"\ecd5"}.ti-waves-electricity:before{content:"\fcc5"}.ti-webhook:before{content:"\f01e"}.ti-webhook-off:before{content:"\f43d"}.ti-weight:before{content:"\f589"}.ti-wheat:before{content:"\100a8"}.ti-wheat-off:before{content:"\100a9"}.ti-wheel:before{content:"\fc64"}.ti-wheelchair:before{content:"\f1db"}.ti-wheelchair-off:before{content:"\f43e"}.ti-whirl:before{content:"\f51d"}.ti-wifi:before{content:"\eb52"}.ti-wifi-0:before{content:"\eba3"}.ti-wifi-1:before{content:"\eba4"}.ti-wifi-2:before{content:"\eba5"}.ti-wifi-off:before{content:"\ecfa"}.ti-wind:before{content:"\ec34"}.ti-wind-electricity:before{content:"\fcc6"}.ti-wind-off:before{content:"\f1c7"}.ti-windmill:before{content:"\ed85"}.ti-windmill-filled:before{content:"\f6b2"}.ti-windmill-off:before{content:"\f1c8"}.ti-window:before{content:"\ef06"}.ti-window-maximize:before{content:"\f1f1"}.ti-window-minimize:before{content:"\f1f2"}.ti-window-off:before{content:"\f1c9"}.ti-windsock:before{content:"\f06d"}.ti-windsock-filled:before{content:"\1009a"}.ti-wiper:before{content:"\ecab"}.ti-wiper-wash:before{content:"\ecaa"}.ti-woman:before{content:"\eb53"}.ti-woman-filled:before{content:"\fdcc"}.ti-wood:before{content:"\f359"}.ti-world:before{content:"\eb54"}.ti-world-bolt:before{content:"\f9d9"}.ti-world-cancel:before{content:"\f9da"}.ti-world-check:before{content:"\f9db"}.ti-world-code:before{content:"\f9dc"}.ti-world-cog:before{content:"\f9dd"}.ti-world-dollar:before{content:"\f9de"}.ti-world-down:before{content:"\f9df"}.ti-world-download:before{content:"\ef8a"}.ti-world-exclamation:before{content:"\f9e0"}.ti-world-heart:before{content:"\f9e1"}.ti-world-latitude:before{content:"\ed2e"}.ti-world-longitude:before{content:"\ed2f"}.ti-world-minus:before{content:"\f9e2"}.ti-world-off:before{content:"\f1ca"}.ti-world-pause:before{content:"\f9e3"}.ti-world-pin:before{content:"\f9e4"}.ti-world-plus:before{content:"\f9e5"}.ti-world-question:before{content:"\f9e6"}.ti-world-search:before{content:"\f9e7"}.ti-world-share:before{content:"\f9e8"}.ti-world-star:before{content:"\f9e9"}.ti-world-up:before{content:"\f9ea"}.ti-world-upload:before{content:"\ef8b"}.ti-world-www:before{content:"\f38f"}.ti-world-x:before{content:"\f9eb"}.ti-wrecking-ball:before{content:"\ed97"}.ti-writing:before{content:"\ef08"}.ti-writing-off:before{content:"\f1cb"}.ti-writing-sign:before{content:"\ef07"}.ti-writing-sign-off:before{content:"\f1cc"}.ti-x:before{content:"\eb55"}.ti-x-power-y:before{content:"\10072"}.ti-xbox-a:before{content:"\f2b6"}.ti-xbox-a-filled:before{content:"\fdcb"}.ti-xbox-b:before{content:"\f2b7"}.ti-xbox-b-filled:before{content:"\fdca"}.ti-xbox-x:before{content:"\f2b8"}.ti-xbox-x-filled:before{content:"\fdc9"}.ti-xbox-y:before{content:"\f2b9"}.ti-xbox-y-filled:before{content:"\fdc8"}.ti-xd:before{content:"\fa33"}.ti-xxx:before{content:"\fc20"}.ti-yin-yang:before{content:"\ec35"}.ti-yin-yang-filled:before{content:"\f785"}.ti-yoga:before{content:"\f01f"}.ti-zeppelin:before{content:"\f270"}.ti-zeppelin-filled:before{content:"\fdc7"}.ti-zeppelin-off:before{content:"\f43f"}.ti-zip:before{content:"\f3b4"}.ti-zodiac-aquarius:before{content:"\ecac"}.ti-zodiac-aries:before{content:"\ecad"}.ti-zodiac-cancer:before{content:"\ecae"}.ti-zodiac-capricorn:before{content:"\ecaf"}.ti-zodiac-gemini:before{content:"\ecb0"}.ti-zodiac-leo:before{content:"\ecb1"}.ti-zodiac-libra:before{content:"\ecb2"}.ti-zodiac-pisces:before{content:"\ecb3"}.ti-zodiac-sagittarius:before{content:"\ecb4"}.ti-zodiac-scorpio:before{content:"\ecb5"}.ti-zodiac-taurus:before{content:"\ecb6"}.ti-zodiac-virgo:before{content:"\ecb7"}.ti-zoom:before{content:"\fdaa"}.ti-zoom-cancel:before{content:"\ec4d"}.ti-zoom-cancel-filled:before{content:"\fdc6"}.ti-zoom-check:before{content:"\ef09"}.ti-zoom-check-filled:before{content:"\f786"}.ti-zoom-code:before{content:"\f07f"}.ti-zoom-code-filled:before{content:"\fdc5"}.ti-zoom-exclamation:before{content:"\f080"}.ti-zoom-exclamation-filled:before{content:"\fdc4"}.ti-zoom-filled:before{content:"\f787"}.ti-zoom-in:before{content:"\eb56"}.ti-zoom-in-area:before{content:"\f1dc"}.ti-zoom-in-area-filled:before{content:"\f788"}.ti-zoom-in-filled:before{content:"\f789"}.ti-zoom-money:before{content:"\ef0a"}.ti-zoom-money-filled:before{content:"\fdc3"}.ti-zoom-out:before{content:"\eb57"}.ti-zoom-out-area:before{content:"\f1dd"}.ti-zoom-out-area-filled:before{content:"\fdc2"}.ti-zoom-out-filled:before{content:"\f78a"}.ti-zoom-pan:before{content:"\f1de"}.ti-zoom-pan-filled:before{content:"\fdc1"}.ti-zoom-question:before{content:"\edeb"}.ti-zoom-question-filled:before{content:"\fdc0"}.ti-zoom-replace:before{content:"\f2a7"}.ti-zoom-reset:before{content:"\f295"}.ti-zoom-scan:before{content:"\fcb0"}.ti-zoom-scan-filled:before{content:"\fdbf"}.ti-zzz:before{content:"\f228"}.ti-zzz-off:before{content:"\f440"}.ti-123:before{content:"\f554"}.ti-360:before{content:"\f62f"}.ti-code-asterix:before{content:"\f312"}.ti-discount-2:before{content:"\ee7c"}.ti-discount-2-off:before{content:"\f3e6"}.ti-discount-check:before{content:"\f1f8"}.ti-hand-rock:before{content:"\ee97"}.ti-sort-deacending-small-big:before{content:"\fd96"}.ti-shi-jumping:before{content:"\fa6c"}.ti-box-seam:before{content:"\eaff"}.ti-kering:before{content:"\efb8"}.ti-2fa:before{content:"\eca0"}.ti-3d-cube-sphere:before{content:"\ecd7"}.ti-3d-cube-sphere-off:before{content:"\f3b5"}.ti-3d-rotate:before{content:"\f020"}.ti-12-hours:before{content:"\fc53"}.ti-24-hours:before{content:"\f5e7"}.ti-360-view:before{content:"\f566"}.ti-circle-0:before{content:"\ee34"}.ti-circle-1:before{content:"\ee35"}.ti-circle-2:before{content:"\ee36"}.ti-circle-3:before{content:"\ee37"}.ti-circle-4:before{content:"\ee38"}.ti-circle-5:before{content:"\ee39"}.ti-circle-6:before{content:"\ee3a"}.ti-circle-7:before{content:"\ee3b"}.ti-circle-8:before{content:"\ee3c"}.ti-circle-9:before{content:"\ee3d"}.ti-hexagon-0:before{content:"\f459"}.ti-hexagon-1:before{content:"\f45a"}.ti-hexagon-2:before{content:"\f45b"}.ti-hexagon-3:before{content:"\f45c"}.ti-hexagon-4:before{content:"\f45d"}.ti-hexagon-5:before{content:"\f45e"}.ti-hexagon-6:before{content:"\f45f"}.ti-hexagon-7:before{content:"\f460"}.ti-hexagon-8:before{content:"\f461"}.ti-hexagon-9:before{content:"\f462"}.ti-square-0:before{content:"\eee5"}.ti-square-1:before{content:"\eee6"}.ti-square-2:before{content:"\eee7"}.ti-square-3:before{content:"\eee8"}.ti-square-4:before{content:"\eee9"}.ti-square-5:before{content:"\eeea"}.ti-square-6:before{content:"\eeeb"}.ti-square-7:before{content:"\eeec"}.ti-square-8:before{content:"\eeed"}.ti-square-9:before{content:"\eeee"}.ti-message-circle-2:before{content:"\eaed"}.ti-mood-suprised:before{content:"\ec04"}.ti-circle-dashed-letter-letter-v:before{content:"\ff84"}.ti-discount-check-filled:before{content:"\f746"}.ti-message-circle-2-filled:before{content:"\fecf"}/*# sourceMappingURL=tabler-icons.min.css.map */ diff --git a/adminx/core-permissions.php b/adminx/core-permissions.php new file mode 100644 index 0000000..0e1c8cb --- /dev/null +++ b/adminx/core-permissions.php @@ -0,0 +1,52 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + /** + * Базовые права ядра админки с человекочитаемой расшифровкой. + * Общий источник для точки входа (adminx/index.php) и инициализации RBAC + * (adminx/tools/setup-rbac.php) — чтобы в _permissions попадали название и + * описание, а не голый код. + */ + return array( + array( + 'code' => 'admin_panel', + 'group_code' => 'core', + 'name' => 'Доступ в панель управления', + 'description' => 'Разрешает вход в административную панель /adminx.', + 'sort_order' => 1, + ), + array( + 'code' => 'all_permissions', + 'group_code' => 'core', + 'name' => 'Полный доступ (суперправо)', + 'description' => 'Даёт доступ ко всем разделам и действиям без исключений.', + 'sort_order' => 2, + ), + array( + 'code' => 'view_public_debug', + 'group_code' => 'core', + 'name' => 'Публичная панель отладки', + 'description' => 'Показывает на публичном сайте запросы, ошибки, сессию и служебные данные страницы.', + 'sort_order' => 3, + ), + array( + 'code' => 'view_development_site', + 'group_code' => 'core', + 'name' => 'Просмотр сайта в режиме разработки', + 'description' => 'Разрешает видеть публичный сайт, когда он временно закрыт для посетителей и поисковых систем.', + 'sort_order' => 4, + ), + ); diff --git a/adminx/index.php b/adminx/index.php new file mode 100644 index 0000000..af1eefb --- /dev/null +++ b/adminx/index.php @@ -0,0 +1,322 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + /** + * Точка входа новой админ-панели /adminx. + * + * Поднимает ядро (App::init через system/bootstrap.php), автозагружает модули из + * adminx/modules/*, проверяет доступ, срезает монтажный префикс ADMINX_BASE и + * диспатчит запрос в контроллер модуля. Никакого legacy class/ или Smarty здесь + * нет — только слой App\Common. + * + * Принцип изоляции (ТЗ §1): весь код панели живёт в собственном переименовываемом + * каталоге; удаление этого каталога не ломает сайт и system/App. + */ + + define('START_MICROTIME', microtime()); + define('START_MEMORY', memory_get_usage()); + define('AVE_CMS', true); + define('ACP', true); + + @ini_set('default_charset', 'UTF-8'); + mb_internal_encoding('UTF-8'); + + define('DS', DIRECTORY_SEPARATOR); + define('ADMINX_PATH', str_replace('\\', '/', __DIR__)); + define('BASEPATH', str_replace('\\', '/', dirname(ADMINX_PATH))); + + if (!@filesize(BASEPATH . '/configs/db.config.php')) { + if (!is_file(BASEPATH . '/storage/installed.lock')) { + $script = isset($_SERVER['SCRIPT_NAME']) ? $_SERVER['SCRIPT_NAME'] : '/' . basename(ADMINX_PATH) . '/index.php'; + $publicRoot = rtrim(str_replace('\\', '/', dirname(dirname($script))), '/'); + header('Location: ' . $publicRoot . '/setup/', true, 302); + exit; + } + + http_response_code(503); + header('Content-Type: text/plain; charset=UTF-8'); + echo 'Конфигурация базы данных отсутствует. Восстановите configs/db.config.php.'; + exit; + } + + if (PHP_VERSION_ID < 70300) { + exit('AVE.cms requires PHP 7.3 or higher.'); + } + + header('X-Robots-Tag: noindex, nofollow, noarchive'); + + //-- Adminx и публичный сайт используют одну DB-сессию и один cookie. Это + //-- сохраняет авторизацию и CSRF при переходах между сайтом и панелью. + + //-- Ядро: автозагрузчик, БД, сессии, настройки, Twig, хуки. + include BASEPATH . DS . 'system' . DS . 'bootstrap.php'; + App::init(); + + use App\Common\AdminAssets; + use App\Common\Auth; + use App\Common\Cache; + use App\Common\Dispatcher; + use App\Common\Loader\Load; + use App\Common\ModuleManager; + use App\Common\Navigation; + use App\Common\Permission; + use App\Common\Session; + use App\Common\Settings; + use App\Common\Twig; + use App\Helpers\Request; + use App\Helpers\Response; + + //-- Монтажный префикс админки (обычно /adminx). Модульные роуты «чистые» + //-- (/dashboard, /login), префикс срезается ниже перед матчингом и добавляется + //-- к ссылкам в шаблонах. + define('ADMINX_BASE', rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'])), '/')); + + //-- Базовые права ядра админки (с человекочитаемой расшифровкой). + Permission::add('admin', require ADMINX_PATH . DS . 'core-permissions.php', 'ti ti-shield', 0); + + //-- Shared-классы админки, не привязанные к бизнес-модулям. + Load::regNamespace('App\\Adminx\\Support', ADMINX_PATH . DS . 'Support'); + + //-- Автозагрузка модулей: /modules//module.php, + //-- namespace App\Adminx\. Код ModuleManager не меняется — только путь. + ModuleManager::loadAll(ADMINX_PATH . DS . 'modules', 'admin', 'App\\Adminx'); + + //-- Устанавливаемые пакеты могут владеть одновременно public- и Adminx-частью. + //-- Их admin manifest находится в modules//admin и не связывает public с adminx/. + \App\Common\PackageModuleRuntime::loadAdminModules(); + + //-- Язык панели независим от одноязычного публичного сайта. После загрузки + //-- модулей доступны и их собственные language//*.xml. + \App\Adminx\Support\AdminLocale::boot(ModuleManager::all()); + + //-- Декларативные UI-вклады нативных модулей: меню и синхронизация прав. + //-- Support остается частью adminx, ядро ничего не знает о его layout. + \App\Adminx\Support\ModuleExtensions::boot(); + + //-- Twig: layout-каркас под @adminx, view каждого модуля — под его code. + Twig::twig()->addExtension(new \App\Adminx\Support\Twig\AdminLocaleExtension()); + Twig::addPath(ADMINX_PATH . DS . 'view', 'adminx'); + foreach (ModuleManager::all() as $module) { + $viewPath = rtrim((string) $module['base_path'], '/\\') . DS . 'view'; + if ($module['code'] !== '' && is_dir($viewPath)) { + Twig::addPath($viewPath, $module['code']); + } + } + + //-- Общие ассеты каркаса (грузятся на каждой странице админки). + AdminAssets::addStyle(ADMINX_BASE . '/assets/css/adminkit.css', 1); + AdminAssets::addScript(ADMINX_BASE . '/assets/js/adminx.js', 1); + AdminAssets::addScript(ADMINX_BASE . '/assets/js/media-picker.js', 2); + AdminAssets::addScript(ADMINX_BASE . '/assets/js/saved-views.js', 3); + + //-- Cache-busting ассетов: ?v=. На время разработки браузер не держит + //-- устаревший CSS/JS — при правке файла (или пересборке CSS) меняется mtime и URL. + Twig::twig()->addFunction(new \Twig\TwigFunction('asset_ver', 'adminx_asset_ver')); + + //-- Глобальные переменные шаблонов каркаса. + Twig::addGlobals([ + 'ADMINX_BASE' => ADMINX_BASE, + 'ABS_PATH' => ABS_PATH, + 'current_lang' => \App\Adminx\Support\AdminLocale::current(), + 'admin_locales' => \App\Adminx\Support\AdminLocale::locales(), + 'csrf_token' => Session::csrfToken(), + 'is_ajax' => Request::isAjax(), + 'popup_mode' => Request::getBool('pop', false), + 'lang' => \App\Common\Language::get(), + 'adminx_i18n' => \App\Adminx\Support\AdminLocale::clientDictionary(), + 'adminx_phrases' => \App\Adminx\Support\AdminLocale::clientSourceTranslations(ModuleManager::all()), + 'site_development_mode' => (string) Settings::get('site_access_mode', 'public') === 'development', + ]); + + //-- Текущий путь без монтажного префикса (/adminx/dashboard → /dashboard). + $requestPath = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + $requestPath = rawurldecode((string) $requestPath); + if (ADMINX_BASE !== '' && strpos($requestPath, ADMINX_BASE) === 0) { + $requestPath = substr($requestPath, strlen(ADMINX_BASE)); + } + + $requestPath = '/' . ltrim($requestPath, '/'); + + Twig::addGlobal('current_path', $requestPath); + Twig::addGlobal('section_help', \App\Adminx\Support\SectionHelp::forPath($requestPath)); + + //-- Доступ: публичные роуты (логин) открыты; всё прочее требует admin_panel. + //-- Современный Auth API (SystemTables users: email/password_hash/role); + //-- Auth::user() восстанавливает сессию/cookie и подгружает права роли. + $isPublic = adminx_is_public($requestPath); + $authUser = Auth::user(); + if ($authUser === null) { + $linkedUser = Auth::systemUser(); + if ($linkedUser) { + Auth::login($linkedUser); + $authUser = Auth::user(); + } + } + + $authUser && Auth::ensureBrowserToken(); + $canAdmin = $authUser !== null && Permission::checkAcp('admin_panel'); + + //-- Пункты меню для layout: фильтр по правам + активный пункт + абсолютный href. + $navFlat = []; + $navigationItems = \App\Adminx\Support\InterfaceSettings::applyNavigation(Navigation::forCurrentUser()); + $navigationItems = \App\Adminx\Support\AdminLocale::translateNavigation($navigationItems); + foreach ($navigationItems as $navItem) { + $navItem['active'] = Navigation::isActive($navItem, $requestPath); + $navItem['href'] = ADMINX_BASE . ($navItem['url'] === '#' ? '' : $navItem['url']); + $navItem['children'] = []; + $navFlat[$navItem['code']] = $navItem; + } + + $navItems = []; + foreach (array_keys($navFlat) as $code) { + $parent = isset($navFlat[$code]['parent']) ? (string) $navFlat[$code]['parent'] : ''; + if ($parent !== '' && isset($navFlat[$parent])) { + $navFlat[$parent]['children'][] = &$navFlat[$code]; + if ($navFlat[$code]['active']) { $navFlat[$parent]['active'] = true; } + } else { + $navItems[] = &$navFlat[$code]; + } + } + + $canClearCache = Permission::check('manage_settings'); + $cacheSizes = array(); + if ($canClearCache) { + $cacheSizes = Cache::remember('adminx.cache.sizes.' . md5((string) ADMINX_BASE), 30, function () { + $out = array(); + foreach (\App\Adminx\Settings\Model::cacheRows() as $row) { + $out[$row['code']] = $row['size']; + } + + return $out; + }); + } + + Twig::addGlobals([ + 'nav_items' => $navItems, + 'user_name' => $authUser['name'] ?? '', + 'user_email' => $authUser['email'] ?? '', + 'user_role' => $authUser['role'] ?? '', + 'module_header_actions' => $canAdmin + ? \App\Adminx\Support\ModuleExtensions::headerActions() + : array(), + 'can_clear_cache' => $canClearCache, + 'cache_sizes' => $cacheSizes, + ]); + + //-- Ссылка на публичный сайт (корень установки) для шапки и меню пользователя. + Twig::addGlobal('public_url', adminx_public_url()); + + //-- Сводка уведомлений в шапке (новые заказы/письма) — только для админа. + $notifications = array('total' => 0, 'orders' => 0, 'messages' => 0, 'items' => array()); + if ($canAdmin) { + $notificationKey = 'adminx.notifications.' . (int) Auth::id() . '.' . md5((string) ADMINX_BASE); + $notifications = Cache::remember($notificationKey, 15, function () { + $summary = \App\Adminx\Support\Notifications::summary(ADMINX_BASE); + return \App\Adminx\Support\ModuleExtensions::notifications(ADMINX_BASE, $summary); + }); + } + + Twig::addGlobal('notifications', $notifications); + + if (!$isPublic && !$canAdmin) { + if (Request::isAjax()) { + Response::unauthorized(); + } + + Request::redirect(ADMINX_BASE . '/login'); + } + + //-- Уже вошедшего с экрана логина отправляем на дашборд. + if ($canAdmin && $requestPath === '/login') { + Request::redirect(ADMINX_BASE . '/'); + } + + //-- Диспатч. + $result = Dispatcher::handle($requestPath, Request::method(), function (array $route) use ($isPublic) { + \App\Adminx\Support\RouteGuard::enforce($route, $isPublic); + }); + + if (!$result['matched']) { + Response::setStatus(404); + try { + echo Twig::twig()->render('@adminx/404.twig', ModuleManager::viewGlobals()); + } catch (\Throwable $e) { + echo 'Страница не найдена'; + } + + exit; + } + + echo $result['output']; + + /** + * URL публичного сайта (корень установки над монтажным префиксом админки): + * для ADMINX_BASE «/adminx» вернёт «/», для «/sub/adminx» — «/sub/». + */ + function adminx_public_url() + { + $parent = rtrim(str_replace('\\', '/', dirname(ADMINX_BASE)), '/'); + return ($parent === '' || $parent === '.') ? '/' : $parent . '/'; + } + + /** + * Публичный ли путь (доступен без авторизации): точный роут или префикс, + * объявленные модулями через public_routes / public_prefixes. + */ + function adminx_is_public($path) + { + if (in_array($path, ModuleManager::publicRoutes(), true)) { + return true; + } + + foreach (ModuleManager::publicPrefixes() as $prefix) { + if ($prefix !== '' && strpos($path, $prefix) === 0) { + return true; + } + } + + return false; + } + + /** + * Добавить к URL локального ассета версию по mtime файла (cache-busting). + * Внешние (CDN) и уже версионированные URL не трогаются. + */ + function adminx_asset_ver($url) + { + if (!is_string($url) || $url === '') { + return $url; + } + + //-- Только локальные пути текущей панели (CDN пропускаем). + if (preg_match('#^https?://#i', $url)) { + return $url; + } + + $rel = (string) parse_url($url, PHP_URL_PATH); + if (ADMINX_BASE !== '' && strpos($rel, ADMINX_BASE) === 0) { + $rel = substr($rel, strlen(ADMINX_BASE)); + } + + if (ADMINX_BASE !== '' && strpos((string) parse_url($url, PHP_URL_PATH), ADMINX_BASE) === 0) { + $file = ADMINX_PATH . str_replace('/', DS, '/' . ltrim($rel, '/')); + } else { + $file = BASEPATH . str_replace('/', DS, '/' . ltrim($rel, '/')); + } + + $ver = is_file($file) ? filemtime($file) : time(); + $sep = strpos($url, '?') !== false ? '&' : '?'; + + return $url . $sep . 'v=' . $ver; + } diff --git a/adminx/language/en/client.xml b/adminx/language/en/client.xml new file mode 100644 index 0000000..c3df429 --- /dev/null +++ b/adminx/language/en/client.xml @@ -0,0 +1,43 @@ + + + Delete view + Saved Views + Apply + Cancel + Close + Confirm + Delete + Save + Show + Documents + Confirm + Confirm action + Current user password + Password + Documents + Documents + Content + Media + Could not confirm the password + The action continues automatically after confirmation. Your password may be requested again in a few minutes. + This action changes executable system code. + Protection for sensitive actions + Confirm your password + Content + Specify the name of the view + code + This action changes executable system code. + Confirm your password + Documents + The server is unavailable. Try again. + Theme + Loading... + General + Nothing found + Remove + Search + Select + Size + Source + Title + diff --git a/adminx/language/en/common.xml b/adminx/language/en/common.xml new file mode 100644 index 0000000..25c2d22 --- /dev/null +++ b/adminx/language/en/common.xml @@ -0,0 +1,237 @@ + + + Admin panel + Sign in to the admin panel + Sign in to manage the website + Login or email + Password + Current user password + Remember me + Enter your login or email and password. + Incorrect login or password. + This account is disabled. + Could not sign in. + Your session has expired. Refresh the page and try again. + + Save + Cancel + Delete + Sign in + Sign out + Close + Confirm + Open website + Clear all + Create + Add + Edit + Restore + Reset + Apply + Show + Open + Refresh + Download + Upload + Check + Enable + Disable + Copy + Export + Import + Back to list + Save and stay + Save settings + Delete all + Delete permanently + Create a copy + Full screen + + Home + Breadcrumbs + Name + Title + Description + Text + Value + Code + Type + State + Status + Actions + Settings + Filters + Search + Source + Date + Time + Size + Color + Order + Position + Priority + Group + Groups + Field + Fields + Tag + Tags + Template + Templates + Version + Files + Folder + Path + Role + Profile + Phone + Email + Class + Integration + Structure + Behavior + Metadata + Details + Preview + Views + Total + All + Active + Disabled + Enabled + Disabled + Published + Published + Drafts + Draft + Deleted + In trash + Always + No expiration + Nothing found + Nothing matched the filter. + Loading... + No data + All states + All statuses + All groups + All rubrics + Rows per page + per page + Previous page + Next page + Select + Selected + Remove + Drag + Move up + Move down + Not specified + Deletion unavailable + Copy tag + Expand editor + Check PHP + General information + General + + Menu + Collapse menu + Visit website + Theme + Switch theme + Use light theme + Use dark theme + Interface language + Notifications + No new notifications + Profile + Development mode + The website is hidden from visitors + + Clear cache + Twig templates + Data and SQL + Documents + Cache cleared + Some cache entries could not be cleared + Could not clear the cache + Clear all cache? + This clears Twig, page templates, data and SQL, document snapshots, public modules, and feed caches. + + Help + Section help + Contextual documentation for this section + Full help + Loading… + No help page was found for this section. + Could not load the help page. + + Administrator + Editor + Manager + Author + + Confirm your password + Protection for sensitive actions + The action continues automatically after confirmation. Your password may be requested again in a few minutes. + This action changes executable system code. + Could not confirm the password + The server is unavailable. Try again. + Sign out of the admin panel? + Your current session will be closed. + Tag copied + + Page not found + Your session has expired. Refresh the page. + Network error + Done + Error + Saved + Confirm action + Confirm + + Overview + Content + Catalog + Sales + Analytics + Integrations + System + Help + Fields + Module catalog + Site readiness + Sources + + Dashboard + Documents + Blocks + Catalog + Rubrics and fields + Themes + Templates + Navigation + Requests + Media + Roles and permissions + Users + Website users + Settings + General + Interface + Security + Constants + Pagination + Maintenance + Diagnostics + System files + Database + Events + IP blocks + PHP console + Updates + Modules + Management + Database errors + Check the SQL log in system events. + diff --git a/adminx/language/en/interface.xml b/adminx/language/en/interface.xml new file mode 100644 index 0000000..dbc01ec --- /dev/null +++ b/adminx/language/en/interface.xml @@ -0,0 +1,13 @@ + + + 404 - not found + Login + To the dashboard + For example, without packaging + Main navigation + Page navigation + The section has not yet been moved to the new admin area or the address is incorrect. + Save current filter + Saved Views + Delete view + diff --git a/adminx/language/en/runtime.xml b/adminx/language/en/runtime.xml new file mode 100644 index 0000000..6e8dddf --- /dev/null +++ b/adminx/language/en/runtime.xml @@ -0,0 +1,526 @@ + + + A/B-tests + The API key is stored in a private secret vault. + Callback URL from the settings must exactly match the address of the OAuth application. + Callback accepts status changes from the payment system. + Client secret should not be included in a public template or log. + The Cron expression and activity of each task are changed directly in the table. + Ctrl+S saves changes and leaves the editor open; revisions allow you to return to the previous version. + Email remains the main identifier; additional login methods are provided as separate modules. + Email-registration, restoration of access and creation of an account after ordering are included in the core. + The External/AJAX- flags allow direct calling of a query and require caution. + Gateway is shown only for suitable order contents. + HTML options are controlled by the project, and applications are processed in a separate tab. + IP blocking + JSON-LD includes only published questions from a published collection. + OPTIMIZE applies only to the selected table or to the entire worksheet after confirmation. + Enable PHP only for blocks that actually need server logic. + PHP console + RSS feeds + Redirect URL from the settings must exactly match the address in the VK application. + Runner does the work step by step with lock and displays the progress. + SEO audit + The UTM constructor collects the link in the browser and does not save the entered values. + Uninstall can remove module data if this is specified in its manifest. + The automatic rating is a guide and does not replace checking the content of the page. + Aggregates speed up the public display of counters and ratings. + Administrator + The selection algorithm remains in the directory, and the template is responsible for the presentation. + The algorithm determines the parsing of the string and the weight of the exact match, title, and content. + Analytics + Antispam + The survey archive is available via a custom public URL. + Theme assets and overrides work in both build modes. + Database + The base template is protected from deletion. + Banners + File Security + Blocks + The block may be permanent or have an end date. + Run large tasks in small batches and monitor errors after each step. + Abandoned carts are cleared based on their shelf life and are available for diagnostics. + Fast filling of goods + Quick creation is available from the header if the module contribution is enabled. + There is a syntax error in the PHP code. + Only downloads and temporary attachments can be automatically quarantined; system files are fixed manually. + The weight of options determines the distribution within the selected audience reach. + Weight only affects the By Weight strategy; Even rotation selects material with fewer impressions. + Weight and dimensions are indicated for one piece of cargo; quantity multiplies the totals. + Interactions + Widgets of installed modules appear in the list automatically. + An external profile is associated with a general site account, rather than creating a separate session. + In the “Settings” tab, select the native assembly AVE.cms or Twig theme wrapper. + Questions and answers + The restore completely replaces the tables of the current installation; Additionally, store the working copy off the server. + Time, memory and output size are limited. + Pop-ups + The built-in template is suitable for new markup, the existing request is suitable for ready-made site cards. + Login via SMS + Login via VK ID + Login via Yandex + Select a sheet and create a preview. + The disabled option remains a document, but is not offered to the buyer. + The execution is saved immediately. + Executing Stored PHP Code + Executes administrative PHP snippets in a restricted child process for diagnostics and maintenance. + Galleries + Votes and totals are stored in the Interactions module. + Guestbook + Site readiness + Groups incoming referrals by UTM tags, sources, campaigns and landing pages. + Groups of options + Groups organize long forms of documents and do not affect the public URL. + Groups organize the list and do not influence the public output. + Dashboard + Gives access to all sections and actions without exception. + Provides a controlled editor for permitted PHP configuration files and user functions. + Demo site + System diagnostics + Kernel diagnostics do not replace separate checking of connected modules and external services. + For business analytics, use an external analytics system; This is where internal CMS statistics are stored. + For variants of one product, variant groups are used. + For an external service, the expected HTTP status and limited timeout are set. + For each integration, create a separate profile with a login, password and category. + To continue, confirm the current user's password. + To publish, insert [mod_poll:ID] into a document, block or template. + Create a permanent redirect for the old address of the existing page. + Adds a custom rating scale to documents and other permitted objects. + Apply trusted exceptions only to controlled integrations. + Document #1 and system page 404 are protected from permanent deletion. + Documents + Documents include pages, products, news, and articles built on categories. + Additional fields are independently connected to registration and personal account. + Delivery and packaging + Access to the control panel + Access denied + Always restrict access to functions through roles and rights, and not just through menu visibility. + A unified registry of HTML fragments, text and executable logic for templates and documents. + The log explains which rule stopped the request and helps you adjust the sensitivity. + The title and description can be taken from the category fields. + A task can be created from the drop-down list in the header on any page of the control panel. + Orders and cart + Assignment affects the issuance order. + Notes + Notes are visible only to their owner. + Requests + Runs managed batch jobs on documents and keeps a detailed execution log. + Launch a full profile after changing the site structure, modules or server environment. + The protected URL is added to the hosting scheduler at one minute intervals. + The store ID and secret are only used by the backend. + Changing the alias can automatically create a redirect from the old URL. + Modifying CSS/JavaScript executables and public theme files + Changing the details of the source system + Changing the price with the standard option only affects the presentation and does not change the order amount. + Structure changes affect the document editor and public directory. + The changed cell is saved separately under AJAX. + Measures the speed of PHP, database, file operations and cache on the current server. + Images are selected from the general library and are not deleted along with the banner. + Import prices + Indexes published documents and generates regular results page and JSON for live search. + Integrations + Use the dashboard as a starting point and see the relevant sections for details. + Use the document editor for fields that are not in the Quick Fill table. + Use this section for small project additions; Design large functionality as a module. + Corrections are made in the document editor and appear after a re-audit. + The request history stores sent values ​​and managers' responses. + History allows you to change the status of the request and respond to the visitor. + History helps differentiate a one-time failure from a recurring problem. + The source uses a separate public RSA key of the owner AVE.cms. + Traffic sources + Look for a redirect by the old URL, document title or ID. + Each button works only with its own data source. + Each navigation has its own tag to insert into the template. + Each cargo item stores quantity, weight and three dimensions. + Each option must maintain its own price, balance, URL and article. + Each catalog is created for a specific category field of the catalog type. + Each subsection has its own URL and purpose. + Each user sees only their own tasks. + Each employee works with his own board. + Each old URL must be unique and link to an existing document. + The channel defines the allowed actions and object types. + Kanban + The order card records the products, prices and selected options at the time of checkout. + Cards and order are saved without rebooting. + Product cards + Catalog + Categories define the product selection; exceptions are applied after child sections. + Keys and callback are used only by the server part. + Tag buttons insert available values ​​into the HTML rating template. + Pre/post save code is executed when the document is modified and requires syntax checking. + The code is one-time use, limited in time and number of attempts. + The experiment code is used in the template tag, API, and conversion tracking attributes. + Codes 301/308 are suitable for permanent transfer, 302/307 - for temporary transfer. + The number of elements limits the size of the output tape. + Columns can be customized to suit your own process. + Comments + The console executes code with system access - use it only for verified operations. + Catalog constructor + Form designer + Contexts assign views to the home page, directory, and other listings. + Content + There is no database configuration. Restore configs/db.config.php. + Copying a form creates an independent set of settings. + Red results require correction; warnings must be evaluated in the context of the project. + Red conditions must be resolved before publication. + Media and files + Change only parameters whose purpose is clear and verified on the current site. + Moderator + Modules + Modular widgets appear only for users with the appropriate permission. + You can save no more than + Health monitoring + Monitoring records the working state, but does not replace the acceptance checklist before launching the site. + The set of fields and their groups are specified by the category. + Navigation + The name of the constant is a software contract and should not change without migration. + Reminders + Sensitive route policy violated + Configures HTML page navigation for documents and saved queries. + Configures a separate payment script for goods available for payment with an electronic certificate. + Configures payment gateway YooKassa for processing and monitoring payment for orders. + Configures fields, form template, recipients, email, and post-send behavior. + Configures security profiles for public forms and displays a log of rejected submissions. + Interface setup + The customization applies to blocks, templates, categories, queries, navigation and other operations with code. + System Settings + The settings of installed modules are stored together with the corresponding module. + The heuristic finding is a reason to check the file, not evidence of infection. + Start by selecting a catalog, then configure sections, fields and filters. + Do not block a reverse proxy address without checking the real client IP. + Don't use Kanban as a system error log: that's what events and Todos are for. + Do not change assigned legacy URL from existing partners; edit the associated feed configuration. + Don't weaken the overall profile because of one false positive: create a separate profile for the desired shape. + Don't import if the preview shows unexpected products or empty prices. + Do not accept the changed file as a reference until you have checked the reason for the change and its contents. + Do not place service PHP files in uploads; This section is for user-generated media only. + Failed to write temporary file. + Failed to prepare filters + Failed to create temporary file. + Do not delete storage/updates or copy the full assembly on top of the site while an update is active. + Don't store passwords, tokens, or other secrets in your notes. + An unfinished task continues from the saved stage after reopening the section. + Unknown sensitive operation: + Invalid view area + You cannot disable or delete your own active account. + New dumps use {{prefix}}, so they can be restored with a different dbpref. + Zero limit means no limit; Resetting statistics is irreversible. + Review + Areas allow you to separately search by products, news, articles, and other sets of categories. + Exchange with 1C + Refresh the page and repeat the action. + Updates AVE.cms + Updates product prices from XLSX after mandatory preview of changes. + Processes orders, payment and delivery statuses, cart templates and abandoned customer sessions. + Service + The overall document counter is stored separately and is not cleared along with the daily data. + Combines audit of actions, errors, SQL log, 404 and statistics of external transitions. + Combines individual products with their own URL and SKUs into one public listing card. + Usually this section is not inserted directly into the template: ratings, polls and comments provide the public interface. + One visitor entry to the same page per day is aggregated without IP storage. + Operations with documents + Defines a set of permissions for administrators and limits access to system functions. + Determines the order and visibility of left menu items and dashboard widgets. + Defines document types: set of fields, groups, output templates, rights and life cycle code. + Polls + Organizes personal work cards into columns and displays the board on a dashboard. + The basic option represents the group before choosing a color or execution. + The main and page templates are responsible for presentation only. + The main public call uses the sysblock tag; the old block tag is supported as a synonym. + The main register of pages and product cards of the site, built on headings and their fields. + Basic settings + A stopped job can be analyzed using the log. + The company's response is saved along with the review and displayed in its public template. + The answer is formatted in a rich-text editor, and questions are sorted by drag and drop. + Separate templates control the gallery skin and layout of a single image. + Reviews + Disabling your account prevents you from logging in, but saves the associated data. + Disabling re-password does not override the role and CSRF token permissions check. + A disabled user cannot log in, but is saved in the activity log. + Cancellations and returns may affect the payment gateway - check the final status of the transaction. + Evaluate not only the overall score, but also the specific slow stage. + The assessment of a document consists of several independent checks. + Comment scoring uses a separate channel in the Interactions module. + Clearing the log is irreversible and does not correct the cause of the error. + Clearing history removes analytics data but does not affect the search index. + Clearing the cache does not delete documents or settings. + Clearing startup data and migrating old AVE.cms + Clearing statistics is irreversible and is intended for maintenance. + Clears address caches and accumulated service data AVE.cms. + Error PHP in the main template can break the entire public site - use a check before saving. + An error in such a file can affect both the panel and the public site. + Route Policy Error + 404 errors + Database errors + PSB/SFR + The Tag Palette inserts registered blocks, navigations, and page values. + Before enabling it on the site, send a test SMS and check the login of an existing and new user. + Before recovery, the system checks the file and automatically creates a backup copy. + Before adding, check if there is already a setting with the same purpose. + A syntax check is performed before running. + Before opening the site, switch to public access and check the page in a separate browser without authorization. + Before clearing the selected period, upload a CSV if the data is needed for reporting. + Before clearing data that cannot be recovered from documents, create a backup copy. + Before applying to a site with your own content, make a backup copy of the database and public templates. + Before the production launch, conduct a full test of payment, cancellation and repeated callback. + The PHP syntax is checked before saving. + Before deleting, make sure that the old address is no longer used in external links. + Reindexing updates the product projection from the document fields. + Recalculation restores aggregates from the original activity log. + Drag and drop changes the order for the control panel only. + Transitions are grouped by visitor, login page, and source; open IPs and arbitrary query parameters are not saved. + The period only affects daily statistics. + Period and filters apply equally to summary, tables, and CSV exports. + Scheduler + By default, confirmation is disabled; on a production site it can be enabled as an additional protective layer. + Reapplying uses the package's ledger and should not create duplicates. + Repeat the test after updating PHP, the database or the infrastructure. + Selects related documents based on meaning, manual connections, or stable circular order. + Preparing a patch does not change files until the installation is confirmed separately. + Connects SMSC for registration and passwordless login using a confirmed phone number. + Connects VK ID as an additional way to log into an existing public account. + Connects Yandex ID as an additional way to log into an existing public account. + Connected OAuth gates open the same public session as the regular login form. + Confirm your password + The confirmation is valid for five minutes for the current user and IP address. + Critical Action Confirmation + Allows you to massively clarify basic product data directly in the table without opening each document. + Search the site + Search queries + The “Results Now” metric helps you check whether the zero output has been corrected. + Shows the readiness of product data for delivery calculations and allows you to edit packages without opening a document. + Shows requests, errors, session and page service data on a public site. + Shows non-existent public addresses and helps create a document or redirect. + Shows the general status of AVE.cms, the latest documents and widgets of installed modules. + Shows document traffic by day and helps you find the most popular pages. + Shows AVE.cms worksheets, performs maintenance, applies kernel migrations, and manages full backups. + Shows real search phrases of visitors, frequency of requests and cases of empty results. + Shows product documents in a quick catalog index: prices, balances, images and categories. + Complete replacement of the working diagram with data from a backup copy + Full access (super right) + Recipients are specified one per line. + The resulting profile is associated with the general site account. + Users + Site users + User not defined + XML and characteristics fields are associated with the catalog index or product category fields. + The external ID, article, price and remaining fields are selected from the fields of the assigned category. + Fields are grouped by purpose and the value type is checked before saving. + Fields are separate entities with their own type, settings and template. + The order of the options determines the order of the radio buttons on the public card. + The order of fields and filters can be changed by dragging and dropping and saved immediately. + After changing the alias, update the external subscriptions and check the XML from the list. + After changing the base URL, update the links in the templates and check that the new address is not occupied by the document. + When catalog fields are changed, the index is usually updated automatically. + After changing, check the first, middle and last pages of the list. + Once the role is changed, the rights are applied the next time the user's access is checked. + After changing categories or indexed fields, perform a full re-indexing. + After changing the structure, the cache of the corresponding navigation is cleared. + After changing the template, send a test request and check the letter. + After publishing, review selecting, resetting, and reapplying multiple filters. + After changing the domain, update the callback in the Yandex panel and module settings. + After changing the secret URL, update the task on the hosting: the old address will immediately stop working. + After installation and bulk content changes, rebuild the index in a separate tab. + The latest documents and system indicators provide a quick transition to the profile section. + Similar materials + Rights are grouped into modules and assigned to entire roles. + Previewing does not change the cache, and rebuilding atomically publishes the new file. + The preview shows the contents of the package before data changes. + View not found + The /rss/ prefix is ​​reserved and cannot be used by documents. + When you delete a source, its preview and the associated WebP copy are deleted. + The availability sign and TCP code are specified in the product data. + The indication of a confirmed purchase is established only after checking the order. + The application modifies only confirmed rows and writes the result to history. + Using migrations does not restore data: this is a separate operation of developing the kernel structure. + Receives CommerceML at a protected address and matches 1C products with documents in the product category. + The reason is visible to administrators and recorded in the audit. + Test XML can be downloaded from the profile list and the result can be checked in the exchange log. + The review does not correct anything or change public data. + Checks do not send emails or perform external operations. + Check the SQL log in system events. + Test the campaign on test URL and mobile screens before enabling automatic insertion. + Check your mixed cart and payment refusal before enabling the method on a public site. + Checks PHP, extensions, directory access, mail, module data and readiness of the public part. + Checks basic settings, documents, templates, modules and environment before publishing the site. + Reviews documents for technical and editorial issues affecting search performance. + Checks the availability of the database, disk, background tasks and specified external HTTPS services. + Checks the integrity of sources using SHA-256, searches for suspicious structures and protects downloads. + Please check recipient addresses and system mail settings before publishing the form. + Check your login and binding on a test account after changing your domain or application keys. + Check the request after changing the conditions: it can be used on several pages at once. + Check version compatibility and backup before updating the module. + Sales + Productivity + Viewing the site in development mode + The overdue account is generated separately for the current employee. + The profile specifies categories, attributes, weights, limits and withdrawal methods. + The profile combines limits, hidden field validation, and content analysis for a specific form. + The profile determines the depth and set of subsystems to be tested. + Publishing, copying and deleting can be done from a list or in batches. + Publishing, hiding and marking spam is done from the general log. + Publishes documents of the selected category in the XML feed at a custom address /rss/{alias}. + Public Debug Panel + Public accounts are located in a separate section “Site Users”. + The public site changes only after the view is published and the native mode of the desired context is enabled. + Items can be sorted and transferred between levels. + Inclusion and exclusion paths accept one pattern per line; The * symbol replaces an arbitrary part. + Unlocking returns access immediately. + Sections define the directory structure and the set of available fields. + Allows you to see a public site when it is temporarily closed to visitors and search engines. + Allows login to the administrative panel /adminx. + Allow only those types of objects on which discussions are actually needed. + Distributes visitors between options and collects impressions and conversions without mixing options in a shared cache. + The calculation is included separately and uses only fully filled packaging. + Revisions and viewing statistics are deleted irreversibly. + Revisions maintain a history of drafts and published versions. + The editor is selected separately for each block: PHP, HTML, rich text or plain text. + Document editor + The image editor creates derivative files for crop and resize. + The product editor remains the document editor, but opens from the catalog. + The redirect should lead to a truly relevant document, and not always to the main one. + Document redirects + Development mode returns visitors a temporary 503 page and blocks dynamic routes from indexing. + The results are needed to compare one environment over time, and not different projects. + Ratings + Roles and rights + The role determines the sections and actions available. + A role with assigned users cannot be deleted. + Headings and fields + Russian + Manual launch uses the same handler and ends up in the general log. + The linked document eliminates the need to enter URL manually. + Links a catalog type field to a section tree, documents, fields and public filters. + The application secret is stored only in the gateway settings. + Secrets of installed modules should not be stored in general settings. + Session has expired + The grid and list show the same physical files. + PHP syntax without errors. + System + The admin system role always has full access. + System constants + System events + System files + System features are not shown as installable modules. + Hiding an item does not revoke the role's right and does not block direct URL. + Random crawlers URL can be removed from the log without creating a page. + Changing the category of an existing document is prohibited without separate field migration. + First, download the file or receive it from a configured HTTPS link. + First check the PHP syntax of the job. + First, check the referral source and request frequency. + First, create a standard using a known-tested version of the project. + Snippets can be saved for reuse. + Collects media library images into organized grids and sliders with their own public URL. + Collects general AVE.cms parameters, interface, constants, pagination, maintenance and diagnostics. + Collects ratings and reviews of documents, allows you to moderate publications and respond on behalf of the company. + Collects background tasks of installed modules into a single registry, runs them according to a schedule and stores a log. + Collects frequently asked questions into independent collections with a public page, tag, API and Schema.org. + Creation of unknown items is disabled by default: first test the mapping on existing documents. + Creates and modifies a website page: its address, publication, SEO and field values ​​for the selected category. + Creates a site menu: level templates, items, nesting, display conditions and links to documents. + Creates single and multiple polls with scheduling, access and controlled results. + The composition and order of widgets can be changed in the interface settings. + Status and note allow you to mark resolved requests and schedule new content. + The status determines whether the review will be included in the public list. + The status of the line shows the publication and availability of the product. + An employee with a separate right continues to see documents, modules and API. + Saving runtime PHP code + Saving updates the database, file cache and creates a revision. + The list of files is limited by the system and is not a file manager. + Payment methods are configured in the “Payment” tab, provider details in the “Gateways” tab. + Help + The due date determines the status of the reminder. + The stable code is used in the [mod_faq:CODE] tag and the public URL. + The stable location code is used in the [mod_banner:CODE] tag. + View statistics + Page not found + The script determines the moment of display: delay, scrolling or attempt to escape. + The [mod_gallery:ID] tag displays the collection in a document, block or template. + The [mod_rating] tag uses the current document; the explicit target is specified by type and ID. + The [mod_related:CODE] tag uses the current document, and API takes it ID explicitly. + Field tags are available in the form and email template. + Page, current state, and link tags are inserted during rendering. + Template tags display the filter name, options and calculated quantities of products. + Text files receive revisions; the theme can be exported or imported using the secure ZIP package. + The current snapshot shows the independent result of each infrastructure check. + The phone belongs to a shared account, so a separate profile for SMS is not created. + Public Site Themes + The test creates a short-term load and saves the history of launches. + Test and production modes must use the appropriate keys. + The type determines the valid editor and format of the stored value. + Typed values ​​are checked before being saved. + Product feeds + Catalog products + Products are documents and are not duplicated in a separate main table. + Heavy checks may be cached for a short time. + Your role does not have permission to view this section. + Deleting a gallery does not delete the original files or the previews created for them. + Deleting a task is irreversible; completed items can be returned to work using a regular checkbox. + Removal affects only entities created by this package. + Deleting a reminder is irreversible; The completed item can be returned to work. + Deleting a poll also deletes the answer options and collected votes. + Deleting a review is irreversible; It is safer to first reject controversial material. + Remove a constant only after searching for its use in your code and templates. + Delete categories and fields only after checking the associated documents and templates. + A deleted document first goes to the trash bin and only then can it be permanently deleted. + Specify the name of the view + Manages the Twig markup and CSS of the product card separately for different places on the public site. + Controls the Twig markup, CSS, and client behavior of product catalog facets. + Manages discussion trees, pre-moderation, anti-spam and public templates. + Manages pop-up campaigns, display conditions, A/B-variants and collected applications. + Controls whether a password is required again before changing executable code and installing modules. + Manages public access, site, mail, document and output settings. + Manages public accounts, registration, OAuth gates and profile fields. + Manages public feedback forms and visitor message logs. + Manages ad slots, image rotation, scheduling, and internal conversion statistics. + Manages manual blocking of addresses, and automatic restrictions are performed by RateLimiter. + Manages the employees who can log into the dashboard, their roles, and their activities. + Manages old page addresses and redirects visitors to current documents. + Manages catalog structures, product indexes, filters and links to documents. + Manages files in uploads: uploads, folders, images, previews and conversion WebP. + Controls design files, CSS/JavaScript connections, and how the public page is built. + Conditions determine which documents are included in the result. + The filter conditions define the SQL limits of the public sample. + Installs and updates site extensions, their tables, templates and connection status. + Installs and maintains a demo set of headings, documents, navigation and templates. + Installs signed kernel patches with local change checking, backup and rollback. + Installing or changing module executable code + Installing a signed kernel update + The files remain in the media library; the gallery stores only connections, signatures and order. + Theme files are located in templates/<theme code> and are path and extension checked. + The area filter separates demand by news, products, articles, and other customized areas. + The filter helps you quickly find products without dimensions or with calculations turned off. + Filter documents by category, status and title. + Filters and CSV export work separately for each source. + Filters help you select products without an article number, price, or mandatory features. + Filters help to first sort out critical and widespread problems. + List filters do not change product data. + The form consists of fields, templates and mail settings. + Generates a selection of documents by categories and fields, and then displays them through element and list templates. + Generates controlled XML/YML- unloading of goods for partners and trading platforms. + Forms and appeals + Stores the external HTML/PHP- shell of the site, into which the contents of the document and system tags are substituted. + Stores personal tasks with a date and displays overdue items in a general bell. + Stores personal text notes of the employee and pins important entries on the dashboard. + Stores a personal list of comments and next actions for the current administrator. + Stores a general log of ratings, reactions, and votes used by application modules. + Stores typed configuration values ​​available to the kernel and the public site. + Color and status help distinguish between successes, warnings, and errors. + The checkbox immediately saves the state without rebooting. + The draft can be checked before publication. + The draft can be checked against live product data without publication. + The draft does not participate in the public output; Check each option before starting. + Sensitive route cannot use secure HTTP method + A sensitive route must declare permission and reauth + The template must maintain the accessibility of links and a clear designation of the current page. + Template No. 1 is a system one and cannot be deleted. + The survey, option, result, and archive templates have different sets of tags. + Pagination templates + List, comment, and form templates have their own sets of tags. + Page Templates + Filter templates + The standard HTML can be replaced with module templates or an existing document request. + This action changes executable system code. + code + submissions + diff --git a/adminx/language/en/section-help.xml b/adminx/language/en/section-help.xml new file mode 100644 index 0000000..b28d686 --- /dev/null +++ b/adminx/language/en/section-help.xml @@ -0,0 +1,472 @@ + + + A/B-tests + The API key is stored in a private secret vault. + Callback URL from the settings must exactly match the address of the OAuth application. + Callback accepts status changes from the payment system. + Client secret should not be included in a public template or log. + The Cron expression and activity of each task are changed directly in the table. + Ctrl+S saves changes and leaves the editor open; revisions allow you to return to the previous version. + Email remains the main identifier; additional login methods are provided as separate modules. + Email-registration, restoration of access and creation of an account after ordering are included in the core. + The External/AJAX- flags allow direct calling of a query and require caution. + Gateway is shown only for suitable order contents. + HTML options are controlled by the project, and applications are processed in a separate tab. + IP blocking + JSON-LD includes only published questions from a published collection. + OPTIMIZE applies only to the selected table or to the entire worksheet after confirmation. + Enable PHP only for blocks that actually need server logic. + PHP-console + RSS feeds + Redirect URL from the settings must exactly match the address in the VK application. + Runner does the work step by step with lock and displays the progress. + SEO audit + The UTM constructor collects the link in the browser and does not save the entered values. + Uninstall can remove module data if this is specified in its manifest. + Update applies the update scheme provided by the module. + The automatic rating is a guide and does not replace checking the content of the page. + Aggregates speed up the public display of counters and ratings. + The selection algorithm remains in the directory, and the template is responsible for the presentation. + The algorithm determines the parsing of the string and the weight of the exact match, title, and content. + Antispam + The survey archive is available via a custom public URL. + Theme assets and overrides work in both build modes. + Database + The base template is protected from deletion. + Banners + File Security + Blocks + The block may be permanent or have an end date. + Run large tasks in small batches and monitor errors after each step. + Abandoned carts are cleared based on their shelf life and are available for diagnostics. + Fast filling of goods + Quick creation is available from the header if the module contribution is enabled. + Only downloads and temporary attachments can be automatically quarantined; system files are fixed manually. + The weight of options determines the distribution within the selected audience reach. + Weight only affects the By Weight strategy; Even rotation selects material with fewer impressions. + Weight and dimensions are indicated for one piece of cargo; quantity multiplies the totals. + Interactions + Widgets of installed modules appear in the list automatically. + An external profile is associated with a general site account, rather than creating a separate session. + In the “Settings” tab, select the native assembly AVE.cms or Twig theme wrapper. + Questions and answers + The restore completely replaces the tables of the current installation; Additionally, store the working copy off the server. + Time, memory and output size are limited. + Pop-ups + The built-in template is suitable for new markup, the existing request is suitable for ready-made site cards. + Login by SMS + Login via VK ID + Login via Yandex + Select a sheet and create a preview. + The disabled option remains a document, but is not offered to the buyer. + The execution is saved immediately. + Executes administrative PHP snippets in a restricted child process for diagnostics and maintenance. + Galleries + Votes and totals are stored in the Interactions module. + Site readiness + Groups incoming referrals by UTM tags, sources, campaigns and landing pages. + Groups of options + Groups organize long forms of documents and do not affect the public URL. + Groups organize the list and do not influence the public output. + Dashboard + Provides a controlled editor for permitted PHP configuration files and user functions. + Demo site + System diagnostics + Kernel diagnostics do not replace separate checking of connected modules and external services. + For business analytics, use an external analytics system; This is where internal CMS statistics are stored. + For variants of one product, variant groups are used. + For an external service, the expected HTTP status and limited timeout are set. + For each integration, create a separate profile with a login, password and category. + To publish, insert [mod_poll:ID] into a document, block or template. + Create a permanent redirect for the old address of the existing page. + Adds a custom rating scale to documents and other permitted objects. + Apply trusted exceptions only to controlled integrations. + Document #1 and system page 404 are protected from permanent deletion. + Documents + Documents include pages, products, news, and articles built on categories. + Additional fields are independently connected to registration and personal account. + Delivery and packaging + Always restrict access to functions through roles and rights, and not just through menu visibility. + A unified registry of HTML fragments, text and executable logic for templates and documents. + The log explains which rule stopped the request and helps you adjust the sensitivity. + The title and description can be taken from the category fields. + A task can be created from the drop-down list in the header on any page of the control panel. + Orders and cart + Assignment affects the issuance order. + Notes + Notes are visible only to their owner. + Requests + Runs managed batch jobs on documents and keeps a detailed execution log. + Launch a full profile after changing the site structure, modules or server environment. + The protected URL is added to the hosting scheduler at one minute intervals. + The store ID and secret are only used by the backend. + Changing the alias can automatically create a redirect from the old URL. + Changing the price with the standard option only affects the presentation and does not change the order amount. + Structure changes affect the document editor and public directory. + The changed cell is saved separately under AJAX. + Measures the speed of PHP, database, file operations and cache on the current server. + Images are selected from the general library and are not deleted along with the banner. + Import prices + Indexes published documents and generates regular results page and JSON for live search. + Use the dashboard as a starting point and see the relevant sections for details. + Use the document editor for fields that are not in the Quick Fill table. + Use this section for small project additions; Design large functionality as a module. + Corrections are made in the document editor and appear after a re-audit. + The request history stores sent values ​​and managers' responses. + History allows you to change the status of the request and respond to the visitor. + History helps differentiate a one-time failure from a recurring problem. + The source uses a separate public RSA key of the owner AVE.cms. + Traffic sources + Look for a redirect by the old URL, document title or ID. + Each button works only with its own data source. + Each navigation has its own tag to insert into the template. + Each cargo item stores quantity, weight and three dimensions. + Each option must maintain its own price, balance, URL and article. + Each catalog is created for a specific category field of the catalog type. + Each subsection has its own URL and purpose. + Each user sees only their own tasks. + Each employee works with his own board. + Each old URL must be unique and link to an existing document. + The channel defines the allowed actions and object types. + Kanban + The order card records the products, prices and selected options at the time of checkout. + Cards and order are saved without rebooting. + Product cards + Catalog + Categories define the product selection; exceptions are applied after child sections. + Keys and callback are used only by the server part. + Tag buttons insert available values ​​into the HTML rating template. + Pre/post save code is executed when the document is modified and requires syntax checking. + The code is one-time use, limited in time and number of attempts. + The experiment code is used in the template tag, API, and conversion tracking attributes. + Codes 301/308 are suitable for permanent transfer, 302/307 - for temporary transfer. + The number of elements limits the size of the output tape. + Columns can be customized to suit your own process. + Comments + The console executes code with system access - use it only for verified operations. + Catalog constructor + Form designer + Contexts assign views to the home page, directory, and other listings. + Copying a form creates an independent set of settings. + Red results require correction; warnings must be evaluated in the context of the project. + Red conditions must be resolved before publication. + Media and files + Change only parameters whose purpose is clear and verified on the current site. + Modules + Modular widgets appear only for users with the appropriate permission. + Health monitoring + Monitoring records the working state, but does not replace the acceptance checklist before launching the site. + The set of fields and their groups are specified by the category. + Navigation + The name of the constant is a software contract and should not change without migration. + Reminders + Configures HTML page navigation for documents and saved queries. + Configures a separate payment script for goods available for payment with an electronic certificate. + Configures payment gateway YooKassa for processing and monitoring payment for orders. + Configures fields, form template, recipients, email, and post-send behavior. + Configures security profiles for public forms and displays a log of rejected submissions. + Interface setup + The customization applies to blocks, templates, categories, queries, navigation and other operations with code. + System Settings + The settings of installed modules are stored together with the corresponding module. + The heuristic finding is a reason to check the file, not evidence of infection. + Start by selecting a catalog, then configure sections, fields and filters. + Do not block a reverse proxy address without checking the real client IP. + Don't use Kanban as a system error log: that's what events and Todos are for. + Do not change assigned legacy URL from existing partners; edit the associated feed configuration. + Don't weaken the overall profile because of one false positive: create a separate profile for the desired shape. + Don't import if the preview shows unexpected products or empty prices. + Do not accept the changed file as a reference until you have checked the reason for the change and its contents. + Do not place service PHP files in uploads; This section is for user-generated media only. + Do not delete storage/updates or copy the full assembly on top of the site while an update is active. + Don't store passwords, tokens, or other secrets in your notes. + An unfinished task continues from the saved stage after reopening the section. + You cannot disable or delete your own active account. + New dumps use {{prefix}}, so they can be restored with a different dbpref. + Zero limit means no limit; Resetting statistics is irreversible. + Areas allow you to separately search by products, news, articles, and other sets of categories. + Exchange with 1C + Updates AVE.cms + Updates product prices from XLSX after mandatory preview of changes. + Processes orders, payment and delivery statuses, cart templates and abandoned customer sessions. + Service + The overall document counter is stored separately and is not cleared along with the daily data. + Combines audit of actions, errors, SQL log, 404 and statistics of external transitions. + Combines individual products with their own URL and SKUs into one public listing card. + Usually this section is not inserted directly into the template: ratings, polls and comments provide the public interface. + One visitor entry to the same page per day is aggregated without IP storage. + Operations with documents + Defines a set of permissions for administrators and limits access to system functions. + Determines the order and visibility of left menu items and dashboard widgets. + Defines document types: set of fields, groups, output templates, rights and life cycle code. + Polls + Organizes personal work cards into columns and displays the board on a dashboard. + The basic option represents the group before choosing a color or execution. + The main and page templates are responsible for presentation only. + The main public call uses the sysblock tag; the old block tag is supported as a synonym. + The main register of pages and product cards of the site, built on headings and their fields. + Basic settings + A stopped job can be analyzed using the log. + The company's response is saved along with the review and displayed in its public template. + The answer is formatted in a rich-text editor, and questions are sorted by drag and drop. + Separate templates control the gallery skin and layout of a single image. + Reviews + Disabling your account prevents you from logging in, but saves the associated data. + Disabling re-password does not override the role and CSRF token permissions check. + A disabled user cannot log in, but is saved in the activity log. + Cancellations and returns may affect the payment gateway - check the final status of the transaction. + Evaluate not only the overall score, but also the specific slow stage. + The assessment of a document consists of several independent checks. + Comment scoring uses a separate channel in the Interactions module. + Clearing the log is irreversible and does not correct the cause of the error. + Clearing history removes analytics data but does not affect the search index. + Clearing the cache does not delete documents or settings. + Clearing statistics is irreversible and is intended for maintenance. + Clears address caches and accumulated service data AVE.cms. + Error PHP in the main template can break the entire public site - use a check before saving. + An error in such a file can affect both the panel and the public site. + 404 errors + PSB/SFR + The Tag Palette inserts registered blocks, navigations, and page values. + Before enabling it on the site, send a test SMS and check the login of an existing and new user. + Before recovery, the system checks the file and automatically creates a backup copy. + Before adding, check if there is already a setting with the same purpose. + A syntax check is performed before running. + Before opening the site, switch to public access and check the page in a separate browser without authorization. + Before clearing the selected period, upload a CSV if the data is needed for reporting. + Before clearing data that cannot be recovered from documents, create a backup copy. + Before applying to a site with your own content, make a backup copy of the database and public templates. + Before the production launch, conduct a full test of payment, cancellation and repeated callback. + The PHP syntax is checked before saving. + Before deleting, make sure that the old address is no longer used in external links. + Reindexing updates the product projection from the document fields. + Recalculation restores aggregates from the original activity log. + Drag and drop changes the order for the control panel only. + Transitions are grouped by visitor, login page, and source; open IPs and arbitrary query parameters are not saved. + The period only affects daily statistics. + Period and filters apply equally to summary, tables, and CSV exports. + Scheduler + By default, confirmation is disabled; on a production site it can be enabled as an additional protective layer. + Reapplying uses the package's ledger and should not create duplicates. + Repeat the test after updating PHP, the database or the infrastructure. + Selects related documents based on meaning, manual connections, or stable circular order. + Preparing a patch does not change files until the installation is confirmed separately. + Connects SMSC for registration and passwordless login using a confirmed phone number. + Connects VK ID as an additional way to log into an existing public account. + Connects Yandex ID as an additional way to log into an existing public account. + Connected OAuth gates open the same public session as the regular login form. + The confirmation is valid for five minutes for the current user and IP address. + Critical Action Confirmation + Allows you to massively clarify basic product data directly in the table without opening each document. + Search the site + Search queries + The “Results Now” metric helps you check whether the zero output has been corrected. + Shows the readiness of product data for delivery calculations and allows you to edit packages without opening a document. + Shows non-existent public addresses and helps create a document or redirect. + Shows the general status of AVE.cms, the latest documents and widgets of installed modules. + Shows document traffic by day and helps you find the most popular pages. + Shows AVE.cms worksheets, performs maintenance, applies kernel migrations, and manages full backups. + Shows real search phrases of visitors, frequency of requests and cases of empty results. + Shows product documents in a quick catalog index: prices, balances, images and categories. + Recipients are specified one per line. + The resulting profile is associated with the general site account. + Users + Site users + XML and characteristics fields are associated with the catalog index or product category fields. + The external ID, article, price and remaining fields are selected from the fields of the assigned category. + Fields are grouped by purpose and the value type is checked before saving. + Fields are separate entities with their own type, settings and template. + The order of the options determines the order of the radio buttons on the public card. + The order of fields and filters can be changed by dragging and dropping and saved immediately. + After changing the alias, update the external subscriptions and check the XML from the list. + After changing the base URL, update the links in the templates and check that the new address is not occupied by the document. + When catalog fields are changed, the index is usually updated automatically. + After changing, check the first, middle and last pages of the list. + Once the role is changed, the rights are applied the next time the user's access is checked. + After changing categories or indexed fields, perform a full re-indexing. + After changing the structure, the cache of the corresponding navigation is cleared. + After changing the template, send a test request and check the letter. + After publishing, review selecting, resetting, and reapplying multiple filters. + After changing the domain, update the callback in the Yandex panel and module settings. + After changing the secret URL, update the task on the hosting: the old address will immediately stop working. + After installation and bulk content changes, rebuild the index in a separate tab. + The latest documents and system indicators provide a quick transition to the profile section. + Similar materials + Rights are grouped into modules and assigned to entire roles. + Previewing does not change the cache, and rebuilding atomically publishes the new file. + The preview shows the contents of the package before data changes. + The /rss/ prefix is ​​reserved and cannot be used by documents. + When you delete a source, its preview and the associated WebP copy are deleted. + The availability sign and TCP code are specified in the product data. + The indication of a confirmed purchase is established only after checking the order. + The application modifies only confirmed rows and writes the result to history. + Using migrations does not restore data: this is a separate operation of developing the kernel structure. + Receives CommerceML at a protected address and matches 1C products with documents in the product category. + The reason is visible to administrators and recorded in the audit. + Test XML can be downloaded from the profile list and the result can be checked in the exchange log. + The review does not correct anything or change public data. + Checks do not send emails or perform external operations. + Test the campaign on test URL and mobile screens before enabling automatic insertion. + Check your mixed cart and payment refusal before enabling the method on a public site. + Checks PHP, extensions, directory access, mail, module data and readiness of the public part. + Checks basic settings, documents, templates, modules and environment before publishing the site. + Reviews documents for technical and editorial issues affecting search performance. + Checks the availability of the database, disk, background tasks and specified external HTTPS services. + Checks the integrity of sources using SHA-256, searches for suspicious structures and protects downloads. + Please check recipient addresses and system mail settings before publishing the form. + Check your login and binding on a test account after changing your domain or application keys. + Check the request after changing the conditions: it can be used on several pages at once. + Check version compatibility and backup before updating the module. + Performance + The overdue account is generated separately for the current employee. + The profile specifies categories, attributes, weights, limits and withdrawal methods. + The profile combines limits, hidden field validation, and content analysis for a specific form. + The profile determines the depth and set of subsystems to be tested. + Publishing, copying and deleting can be done from a list or in batches. + Publishing, hiding and marking spam is done from the general log. + Publishes documents of the selected category in the XML feed at a custom address /rss/{alias}. + Public accounts are located in a separate section “Site Users”. + The public site changes only after the view is published and the native mode of the desired context is enabled. + Items can be sorted and transferred between levels. + Inclusion and exclusion paths accept one pattern per line; The * symbol replaces an arbitrary part. + Unlocking returns access immediately. + Sections define the directory structure and the set of available fields. + Allow only those types of objects on which discussions are actually needed. + Distributes visitors between options and collects impressions and conversions without mixing options in a shared cache. + The calculation is included separately and uses only fully filled packaging. + Revisions and viewing statistics are deleted irreversibly. + Revisions maintain a history of drafts and published versions. + The editor is selected separately for each block: PHP, HTML, rich text or plain text. + Document editor + The image editor creates derivative files for crop and resize. + The product editor remains the document editor, but opens from the catalog. + The redirect should lead to a truly relevant document, and not always to the main one. + Document redirects + Development mode returns visitors a temporary 503 page and blocks dynamic routes from indexing. + The results are needed to compare one environment over time, and not different projects. + Ratings + Roles and rights + The role determines the sections and actions available. + A role with assigned users cannot be deleted. + Headings and fields + Manual launch uses the same handler and ends up in the general log. + The linked document eliminates the need to enter URL manually. + Links a catalog type field to a section tree, documents, fields and public filters. + The application secret is stored only in the gateway settings. + Secrets of installed modules should not be stored in general settings. + The grid and list show the same physical files. + The admin system role always has full access. + System constants + System events + System files + System features are not shown as installable modules. + Hiding an item does not revoke the role's right and does not block direct URL. + Random crawlers URL can be removed from the log without creating a page. + Changing the category of an existing document is prohibited without separate field migration. + First, download the file or receive it from a configured HTTPS link. + First check the PHP syntax of the job. + First, check the referral source and request frequency. + First, create a standard using a known-tested version of the project. + Snippets can be saved for reuse. + Collects media library images into organized grids and sliders with their own public URL. + Collects general AVE.cms parameters, interface, constants, pagination, maintenance and diagnostics. + Collects ratings and reviews of documents, allows you to moderate publications and respond on behalf of the company. + Collects background tasks of installed modules into a single registry, runs them according to a schedule and stores a log. + Collects frequently asked questions into independent collections with a public page, tag, API and Schema.org. + Creation of unknown items is disabled by default: first test the mapping on existing documents. + Creates and modifies a website page: its address, publication, SEO and field values ​​for the selected category. + Creates a site menu: level templates, items, nesting, display conditions and links to documents. + Creates single and multiple polls with scheduling, access and controlled results. + The composition and order of widgets can be changed in the interface settings. + Status and note allow you to mark resolved requests and schedule new content. + The status determines whether the review will be included in the public list. + The status of the line shows the publication and availability of the product. + An employee with a separate right continues to see documents, modules and API. + Saving updates the database, file cache and creates a revision. + The list of files is limited by the system and is not a file manager. + Payment methods are configured in the “Payment” tab, provider details in the “Gateways” tab. + The due date determines the status of the reminder. + The stable code is used in the [mod_faq:CODE] tag and the public URL. + The stable location code is used in the [mod_banner:CODE] tag. + View statistics + The script determines the moment of display: delay, scrolling or attempt to escape. + The [mod_gallery:ID] tag displays the collection in a document, block or template. + The [mod_rating] tag uses the current document; the explicit target is specified by type and ID. + The [mod_related:CODE] tag uses the current document, and API takes it ID explicitly. + Field tags are available in the form and email template. + Page, current state, and link tags are inserted during rendering. + Template tags display the filter name, options and calculated quantities of products. + Text files receive revisions; the theme can be exported or imported using the secure ZIP package. + The current snapshot shows the independent result of each infrastructure check. + The phone belongs to a shared account, so a separate profile for SMS is not created. + Public Site Themes + The test creates a short-term load and saves the history of launches. + Test and production modes must use the appropriate keys. + The type determines the valid editor and format of the stored value. + Typed values ​​are checked before being saved. + Product feeds + Catalog products + Products are documents and are not duplicated in a separate main table. + Heavy checks may be cached for a short time. + Deleting a gallery does not delete the original files or the previews created for them. + Deleting a task is irreversible; completed items can be returned to work using a regular checkbox. + Removal affects only entities created by this package. + Deleting a reminder is irreversible; The completed item can be returned to work. + Deleting a poll also deletes the answer options and collected votes. + Deleting a review is irreversible; It is safer to first reject controversial material. + Remove a constant only after searching for its use in your code and templates. + Delete categories and fields only after checking the associated documents and templates. + A deleted document first goes to the trash bin and only then can it be permanently deleted. + Manages the Twig markup and CSS of the product card separately for different places on the public site. + Controls the Twig markup, CSS, and client behavior of product catalog facets. + Manages discussion trees, pre-moderation, anti-spam and public templates. + Manages pop-up campaigns, display conditions, A/B-variants and collected applications. + Controls whether a password is required again before changing executable code and installing modules. + Manages public access, site, mail, document and output settings. + Manages public accounts, registration, OAuth gates and profile fields. + Manages public feedback forms and visitor message logs. + Manages ad slots, image rotation, scheduling, and internal conversion statistics. + Manages manual blocking of addresses, and automatic restrictions are performed by RateLimiter. + Manages the employees who can log into the dashboard, their roles, and their activities. + Manages old page addresses and redirects visitors to current documents. + Manages catalog structures, product indexes, filters and links to documents. + Manages files in uploads: uploads, folders, images, previews and conversion WebP. + Controls design files, CSS/JavaScript connections, and how the public page is built. + Conditions determine which documents are included in the result. + The filter conditions define the SQL limits of the public sample. + Installs and updates site extensions, their tables, templates and connection status. + Installs and maintains a demo set of headings, documents, navigation and templates. + Installs signed kernel patches with local change checking, backup and rollback. + The files remain in the media library; the gallery stores only connections, signatures and order. + Theme files are located in templates/<theme code> and are path and extension checked. + The area filter separates demand by news, products, articles, and other customized areas. + The filter helps you quickly find products without dimensions or with calculations turned off. + Filter documents by category, status and title. + Filters and CSV export work separately for each source. + Filters help you select products without an article number, price, or mandatory features. + Filters help to first sort out critical and widespread problems. + List filters do not change product data. + The form consists of fields, templates and mail settings. + Generates a selection of documents by categories and fields, and then displays them through element and list templates. + Generates controlled XML/YML- unloading of goods for partners and trading platforms. + Forms and appeals + Stores the external HTML/PHP- shell of the site, into which the contents of the document and system tags are substituted. + Stores personal tasks with a date and displays overdue items in a general bell. + Stores personal text notes of the employee and pins important entries on the dashboard. + Stores a personal list of comments and next actions for the current administrator. + Stores a general log of ratings, reactions, and votes used by application modules. + Stores typed configuration values ​​available to the kernel and the public site. + Color and status help distinguish between successes, warnings, and errors. + The checkbox immediately saves the state without rebooting. + The draft can be checked before publication. + The draft can be checked against live product data without publication. + The draft does not participate in the public output; Check each option before starting. + The template must maintain the accessibility of links and a clear designation of the current page. + Template No. 1 is a system one and cannot be deleted. + The survey, option, result, and archive templates have different sets of tags. + Pagination templates + List, comment, and form templates have their own sets of tags. + Page Templates + Filter templates + The standard HTML can be replaced with module templates or an existing document request. + diff --git a/adminx/language/ru/client.xml b/adminx/language/ru/client.xml new file mode 100644 index 0000000..246131c --- /dev/null +++ b/adminx/language/ru/client.xml @@ -0,0 +1,43 @@ + + + Удалить представление + Сохранённые представления + Применить + Отмена + Закрыть + Подтвердить + Удалить + Сохранить + Показать + Документы + Подтвердить + Подтвердите действие + Пароль текущего пользователя + Пароль + Документы + Документы + Контент + Медиа + Не удалось подтвердить пароль + После подтверждения действие продолжится автоматически. Повторный запрос пароля появится через несколько минут. + Это действие изменяет исполняемый код системы. + Защита чувствительной операции + Подтвердите пароль + Контент + Укажите название представления + код + Это действие изменяет исполняемый код системы. + Подтвердите пароль + Документы + Сервер недоступен. Повторите попытку. + Тема + Загрузка... + Основное + Ничего не найдено + Убрать + Поиск + Выбрать + Размер + Источник + Заголовок + diff --git a/adminx/language/ru/common.xml b/adminx/language/ru/common.xml new file mode 100644 index 0000000..4f253c3 --- /dev/null +++ b/adminx/language/ru/common.xml @@ -0,0 +1,237 @@ + + + Админпанель + Вход в админпанель + Вход в панель управления + Логин или email + Пароль + Пароль текущего пользователя + Запомнить меня + Введите логин или email и пароль. + Неверный логин или пароль. + Учётная запись отключена. + Не удалось войти. + Сессия устарела, обновите страницу и попробуйте снова. + + Сохранить + Отмена + Удалить + Войти + Выйти + Закрыть + Подтвердить + Открыть сайт + Очистить всё + Создать + Добавить + Изменить + Восстановить + Сбросить + Применить + Показать + Открыть + Обновить + Скачать + Загрузить + Проверить + Включить + Отключить + Копировать + Экспорт + Импорт + К списку + Сохранить и остаться + Сохранить настройки + Удалить все + Удалить окончательно + Создать копию + На весь экран + + Главная + Хлебные крошки + Название + Заголовок + Описание + Текст + Значение + Код + Тип + Состояние + Статус + Действия + Настройки + Фильтры + Поиск + Источник + Дата + Время + Размер + Цвет + Порядок + Позиция + Приоритет + Группа + Группы + Поле + Поля + Тег + Теги + Шаблон + Шаблоны + Версия + Файлы + Папка + Путь + Роль + Профиль + Телефон + Email + Класс + Интеграция + Структура + Поведение + Метаданные + Детали + Предпросмотр + Просмотры + Всего + Все + Активные + Отключённые + Включено + Выключено + Опубликовано + Опубликованные + Черновики + Черновик + Удалённые + В корзине + Всегда + Бессрочно + Ничего не найдено + Ничего не найдено по фильтру. + Загрузка... + Нет данных + Все состояния + Все статусы + Все группы + Все рубрики + Строк на странице + на странице + Предыдущая страница + Следующая страница + Выбрать + Выбрано + Убрать + Перетащить + Выше + Ниже + Не указан + Удаление недоступно + Скопировать тег + Развернуть редактор + Проверить PHP + Основные данные + Основное + + Меню + Свернуть меню + Перейти на сайт + Тема + Переключить тему + Включить светлую тему + Включить тёмную тему + Язык интерфейса + Уведомления + Нет новых уведомлений + Профиль + Режим разработки + Сайт закрыт для посетителей + + Очистить кеш + Twig-шаблоны + Данные и SQL + Документы + Кеш очищен + Часть кеша не очистилась + Не удалось очистить кеш + Очистить весь кеш? + Будут очищены все кеши: Twig, шаблоны страниц, данные/SQL, снимки документов, публичные модули и фиды. + + Справка + Справка по разделу + Контекстная документация раздела + Полная справка + Загрузка… + Справка для этого раздела не найдена. + Не удалось загрузить справку. + + Администратор + Редактор + Менеджер + Автор + + Подтвердите пароль + Защита чувствительной операции + После подтверждения действие продолжится автоматически. Повторный запрос пароля появится через несколько минут. + Это действие изменяет исполняемый код системы. + Не удалось подтвердить пароль + Сервер недоступен. Повторите попытку. + Выйти из админпанели? + Текущая сессия будет завершена. + Тег скопирован + + Страница не найдена + Сессия устарела, обновите страницу + Ошибка сети + Готово + Ошибка + Сохранено + Подтвердите действие + Подтвердить + + Обзор + Контент + Каталог + Продажи + Аналитика + Интеграции + Система + Справка + Поля + Каталог модулей + Готовность сайта + Источники + + Дашборд + Документы + Блоки + Каталог + Рубрики и поля + Темы + Шаблоны + Навигация + Запросы + Медиа + Роли и права + Пользователи + Пользователи сайта + Настройки + Основные + Интерфейс + Безопасность + Константы + Пагинация + Обслуживание + Диагностика + Системные файлы + База данных + События + IP-блокировки + PHP-консоль + Обновления + Модули + Управление + Ошибки базы данных + Проверьте SQL-журнал системных событий. + diff --git a/adminx/language/ru/interface.xml b/adminx/language/ru/interface.xml new file mode 100644 index 0000000..ebb132a --- /dev/null +++ b/adminx/language/ru/interface.xml @@ -0,0 +1,13 @@ + + + 404 — не найдено + Вход + На дашборд + Например, без упаковки + Основная навигация + Постраничная навигация + Раздел ещё не перенесён в новую админку или адрес указан неверно. + Сохранить текущий фильтр + Сохранённые представления + Удалить представление + diff --git a/adminx/language/ru/runtime.xml b/adminx/language/ru/runtime.xml new file mode 100644 index 0000000..d27df87 --- /dev/null +++ b/adminx/language/ru/runtime.xml @@ -0,0 +1,526 @@ + + + A/B-тесты + API-ключ хранится в закрытом хранилище секретов. + Callback URL из настроек должен точно совпадать с адресом OAuth-приложения. + Callback принимает изменения статуса от платёжной системы. + Client secret не должен попадать в публичный шаблон или журнал. + Cron-выражение и активность каждой задачи меняются прямо в таблице. + Ctrl+S сохраняет изменения и оставляет редактор открытым; ревизии позволяют вернуться к прошлой версии. + Email остаётся основным идентификатором; дополнительные способы входа поставляются отдельными модулями. + Email-регистрация, восстановление доступа и создание аккаунта после заказа входят в ядро. + External/AJAX-флаги разрешают прямой вызов запроса и требуют осторожности. + Gateway показывается только для подходящего состава заказа. + HTML вариантов контролируется проектом, а заявки обрабатываются на отдельной вкладке. + IP-блокировки + JSON-LD включает только опубликованные вопросы опубликованной коллекции. + OPTIMIZE применяется только к выбранной таблице или ко всей рабочей схеме после подтверждения. + PHP включайте только для блоков, которым действительно нужна серверная логика. + PHP-консоль + RSS-каналы + Redirect URL из настроек должен точно совпадать с адресом в приложении VK. + Runner выполняет работу пошагово с lock и отображает прогресс. + SEO-аудит + UTM-конструктор собирает ссылку в браузере и не сохраняет введённые значения. + Uninstall может удалить данные модуля, если это заложено его manifest. + Автоматическая оценка является подсказкой и не заменяет проверку содержания страницы. + Агрегаты ускоряют публичный вывод счётчиков и рейтингов. + Администратор + Алгоритм выборки остаётся в каталоге, а шаблон отвечает за представление. + Алгоритм определяет разбор строки и вес точного совпадения, заголовка и содержимого. + Аналитика + Антиспам + Архив опросов доступен по настраиваемому публичному URL. + Ассеты и переопределения темы работают в обоих режимах сборки. + База данных + Базовый шаблон защищён от удаления. + Баннеры + Безопасность файлов + Блоки + Блокировка может быть постоянной или иметь дату окончания. + Большие задания запускайте небольшими пакетами и контролируйте ошибки после каждого шага. + Брошенные корзины очищаются по сроку хранения и доступны для диагностики. + Быстрое заполнение товаров + Быстрое создание доступно из шапки, если вклад модуля включён. + В PHP-коде есть синтаксическая ошибка. + В карантин автоматически можно переместить только загрузки и временные вложения; системные файлы исправляются вручную. + Вес вариантов задаёт распределение внутри выбранного охвата аудитории. + Вес влияет только на стратегию «По весу»; равномерная ротация выбирает материал с меньшим числом показов. + Вес и размеры указываются для одного грузового места; количество умножает итоговые показатели. + Взаимодействия + Виджеты установленных модулей появляются в списке автоматически. + Внешний профиль связывается с общим аккаунтом сайта, а не создаёт отдельную сессию. + Во вкладке «Настройки» выбирается нативная сборка AVE.cms или Twig-оболочка темы. + Вопросы и ответы + Восстановление полностью заменяет таблицы текущей установки; рабочую копию дополнительно храните вне сервера. + Время, память и размер вывода ограничены. + Всплывающие окна + Встроенный шаблон подходит для новой разметки, существующий запрос — для готовых карточек сайта. + Вход по SMS + Вход через VK ID + Вход через Яндекс + Выберите лист и сформируйте предварительный просмотр. + Выключенный вариант остаётся документом, но не предлагается покупателю. + Выполнение сохраняется сразу. + Выполнение сохранённого PHP-кода + Выполняет административные PHP-сниппеты в ограниченном дочернем процессе для диагностики и обслуживания. + Галереи + Голоса и итоговые значения хранятся в модуле «Взаимодействия». + Гостевая + Готовность сайта + Группирует входящие переходы по UTM-меткам, источникам, кампаниям и посадочным страницам. + Группы вариантов + Группы организуют длинные формы документов и не влияют на публичный URL. + Группы организуют список и не влияют на публичный вывод. + Дашборд + Даёт доступ ко всем разделам и действиям без исключений. + Даёт контролируемый редактор разрешённых PHP-файлов конфигурации и пользовательских функций. + Демо-сайт + Диагностика системы + Диагностика ядра не заменяет отдельную проверку подключённых модулей и внешних сервисов. + Для бизнес-аналитики используйте внешнюю систему аналитики; здесь хранится внутренняя статистика CMS. + Для вариантов одного товара используются группы вариантов. + Для внешнего сервиса задаются ожидаемый HTTP-статус и ограниченный таймаут. + Для каждой интеграции создайте отдельный профиль с логином, паролем и рубрикой. + Для продолжения подтвердите пароль текущего пользователя. + Для публикации вставьте [mod_poll:ID] в документ, блок или шаблон. + Для старого адреса существующей страницы создайте постоянный редирект. + Добавляет на документы и другие разрешённые объекты настраиваемую шкалу оценок. + Доверенные исключения применяйте только к контролируемым интеграциям. + Документ №1 и системная страница 404 защищены от окончательного удаления. + Документы + Документы включают страницы, товары, новости и статьи, построенные на рубриках. + Дополнительные поля независимо подключаются к регистрации и личному кабинету. + Доставка и упаковка + Доступ в панель управления + Доступ запрещён + Доступ к функциям всегда ограничивайте через роли и права, а не только через видимость меню. + Единый реестр HTML-фрагментов, текста и исполняемой логики для шаблонов и документов. + Журнал объясняет, какое правило остановило запрос, и помогает скорректировать чувствительность. + Заголовок и описание можно брать из полей рубрики. + Задачу можно создать из выпадающего списка в шапке на любой странице панели управления. + Заказы и корзина + Закрепление влияет на порядок выдачи. + Заметки + Заметки видны только их владельцу. + Запросы + Запускает управляемые пакетные задания над документами и хранит подробный журнал выполнения. + Запускайте полный профиль после изменения структуры сайта, модулей или серверного окружения. + Защищённый URL добавляется в планировщик хостинга с интервалом в одну минуту. + Идентификатор магазина и секрет применяются только серверной частью. + Изменение alias может автоматически создать редирект со старого URL. + Изменение исполняемых CSS/JavaScript и файлов публичной темы + Изменение реквизитов исходной системы + Изменение цены штатным вариантом влияет только на представление и не меняет сумму заказа. + Изменения структуры влияют на редактор документов и публичный каталог. + Изменённая ячейка сохраняется отдельно по AJAX. + Измеряет скорость PHP, базы данных, файловых операций и кеша на текущем сервере. + Изображения выбираются из общей медиатеки и не удаляются вместе с баннером. + Импорт цен + Индексирует опубликованные документы и формирует обычную страницу результатов и JSON для живого поиска. + Интеграции + Используйте дашборд как отправную точку, а подробные данные смотрите в соответствующих разделах. + Используйте редактор документа для полей, которых нет в таблице быстрого заполнения. + Используйте этот раздел для небольших проектных дополнений; крупную функциональность оформляйте модулем. + Исправления выполняются в редакторе документа и проявляются после повторного аудита. + История обращений хранит отправленные значения и ответы менеджеров. + История позволяет менять статус обращения и отвечать посетителю. + История помогает отличить разовый сбой от повторяющейся проблемы. + Источник использует отдельный публичный RSA-ключ владельца AVE.cms. + Источники трафика + Ищите редирект по старому URL, названию или ID документа. + Каждая кнопка работает только со своим источником данных. + Каждая навигация имеет собственный тег для вставки в шаблон. + Каждое грузовое место хранит количество, вес и три габарита. + Каждый вариант должен сохранять собственные цену, остаток, URL и артикул. + Каждый каталог создаётся для конкретного поля рубрики типа catalog. + Каждый подраздел имеет собственный URL и назначение. + Каждый пользователь видит только собственные задачи. + Каждый сотрудник работает со своей доской. + Каждый старый URL должен быть уникальным и вести на существующий документ. + Канал определяет допустимые действия и типы объектов. + Канбан + Карточка заказа фиксирует товары, цены и выбранные варианты на момент оформления. + Карточки и порядок сохраняются без перезагрузки. + Карточки товаров + Каталог + Категории определяют товарную выборку, исключения применяются после дочерних разделов. + Ключи и callback используются только серверной частью. + Кнопки тегов вставляют доступные значения в HTML-шаблон рейтинга. + Код до/после сохранения выполняется при изменении документа и требует проверки синтаксиса. + Код одноразовый, ограничен по времени и числу попыток. + Код эксперимента используется в шаблонном теге, API и атрибутах отслеживания конверсии. + Коды 301/308 подходят для постоянного переноса, 302/307 — для временного. + Количество элементов ограничивает размер выдаваемой ленты. + Колонки можно настроить под собственный процесс. + Комментарии + Консоль выполняет код с доступом к системе — используйте её только для проверенных операций. + Конструктор каталога + Конструктор формы + Контексты назначают представление главной странице, каталогу и другим листингам. + Контент + Конфигурация базы данных отсутствует. Восстановите configs/db.config.php. + Копирование формы создаёт независимый набор настроек. + Красные результаты требуют исправления, предупреждения нужно оценивать в контексте проекта. + Красные состояния нужно устранить до публикации. + Медиа и файлы + Меняйте только параметры, назначение которых понятно и проверено на текущем сайте. + Модератор + Модули + Модульные виджеты появляются только у пользователей с соответствующим правом. + Можно сохранить не более + Мониторинг здоровья + Мониторинг фиксирует рабочее состояние, но не заменяет приёмочный чек-лист перед запуском сайта. + Набор полей и их группы задаются рубрикой. + Навигация + Название константы является программным контрактом и не должно меняться без миграции. + Напоминания + Нарушена политика чувствительного маршрута + Настраивает HTML постраничной навигации для документов и сохранённых запросов. + Настраивает отдельный платёжный сценарий для товаров, доступных к оплате электронным сертификатом. + Настраивает платёжный gateway YooKassa для оформления и контроля оплаты заказов. + Настраивает поля, шаблон формы, получателей, письмо и поведение после отправки. + Настраивает профили защиты публичных форм и показывает журнал отклонённых отправок. + Настройка интерфейса + Настройка распространяется на блоки, шаблоны, рубрики, запросы, навигацию и другие операции с кодом. + Настройки системы + Настройки устанавливаемых модулей хранятся вместе с соответствующим модулем. + Находка эвристики является причиной проверить файл, а не доказательством заражения. + Начинайте с выбора каталога, затем настраивайте разделы, поля и фильтры. + Не блокируйте адрес reverse proxy без проверки реального клиентского IP. + Не используйте канбан как журнал системных ошибок: для них предназначены события и Todo. + Не меняйте закрепленные legacy URL у действующих партнеров; редактируйте связанную конфигурацию фида. + Не ослабляйте общий профиль из-за одного ложного срабатывания: создайте отдельный профиль для нужной формы. + Не применяйте импорт, если предварительный просмотр показывает неожиданные товары или пустые цены. + Не принимайте изменённый файл в эталон, пока не проверили причину изменения и его содержимое. + Не размещайте служебные PHP-файлы в uploads; раздел предназначен только для пользовательских медиа. + Не удалось записать временный файл. + Не удалось подготовить фильтры + Не удалось создать временный файл. + Не удаляйте storage/updates и не копируйте полную сборку поверх сайта во время активного обновления. + Не храните в заметках пароли, токены и другие секреты. + Незавершённое задание продолжается с сохранённого этапа после повторного открытия раздела. + Неизвестная чувствительная операция: + Некорректная область представления + Нельзя отключить или удалить собственную активную учётную запись. + Новые дампы используют {{prefix}}, поэтому могут восстанавливаться с другим dbpref. + Нулевой лимит означает отсутствие ограничения; сброс статистики необратим. + Обзор + Области позволяют отдельно искать по товарам, новостям, статьям и другим наборам рубрик. + Обмен с 1С + Обновите страницу и повторите действие. + Обновления AVE.cms + Обновляет цены товаров из XLSX после обязательного предварительного просмотра изменений. + Обрабатывает заказы, состояния оплаты и доставки, шаблоны корзины и брошенные сессии покупателей. + Обслуживание + Общий счётчик документа хранится отдельно и не очищается вместе с дневными данными. + Объединяет аудит действий, ошибки, SQL-журнал, 404 и статистику внешних переходов. + Объединяет отдельные товары с собственными URL и артикулами в одну карточку публичного листинга. + Обычно этот раздел не вставляется в шаблон напрямую: публичный интерфейс предоставляют рейтинги, опросы и комментарии. + Один вход посетителя на ту же страницу за день агрегируется без хранения IP. + Операции с документами + Определяет набор разрешений для администраторов и разграничивает доступ к функциям системы. + Определяет порядок и видимость пунктов левого меню и виджетов дашборда. + Определяет типы документов: набор полей, группы, шаблоны вывода, права и код жизненного цикла. + Опросы + Организует личные рабочие карточки по колонкам и показывает доску на дашборде. + Основной вариант представляет группу до выбора цвета или исполнения. + Основной и постраничный шаблоны отвечают только за представление. + Основной публичный вызов использует тег sysblock; старый тег block поддерживается как синоним. + Основной реестр страниц и товарных карточек сайта, построенных на рубриках и их полях. + Основные настройки + Остановленное задание можно проанализировать по журналу. + Ответ компании сохраняется вместе с отзывом и выводится его публичным шаблоном. + Ответ оформляется в rich-text редакторе, а вопросы сортируются перетаскиванием. + Отдельные шаблоны управляют оболочкой галереи и разметкой одного изображения. + Отзывы + Отключение аккаунта запрещает вход, но сохраняет связанные данные. + Отключение повторного пароля не отменяет проверку прав роли и CSRF-токена. + Отключённый пользователь не может войти, но сохраняется в журнале действий. + Отмена и возврат могут влиять на платёжный шлюз — проверяйте итоговый статус операции. + Оценивайте не только общий балл, но и конкретный медленный этап. + Оценка документа складывается из нескольких независимых проверок. + Оценка комментариев использует отдельный канал модуля «Взаимодействия». + Очистка журнала необратима и не исправляет причину ошибки. + Очистка истории удаляет аналитические данные, но не затрагивает поисковый индекс. + Очистка кеша не удаляет документы и настройки. + Очистка стартовых данных и миграция старой AVE.cms + Очистка статистики необратима и предназначена для обслуживания. + Очищает адресные кеши и накопленные служебные данные AVE.cms. + Ошибка PHP в основном шаблоне может нарушить весь публичный сайт — используйте проверку перед сохранением. + Ошибка в таком файле может повлиять и на панель, и на публичный сайт. + Ошибка политики маршрута + Ошибки 404 + Ошибки базы данных + ПСБ / СФР + Палитра тегов вставляет зарегистрированные блоки, навигации и значения страницы. + Перед включением на сайте отправьте тестовое SMS и проверьте вход существующего и нового пользователя. + Перед восстановлением система проверяет файл и автоматически создаёт страховочную копию. + Перед добавлением проверьте, нет ли уже настройки с тем же назначением. + Перед запуском выполняется проверка синтаксиса. + Перед открытием сайта переключите публичный доступ и проверьте страницу в отдельном браузере без авторизации. + Перед очисткой выбранного периода выгрузите CSV, если данные нужны для отчётности. + Перед очисткой данных, которые нельзя восстановить из документов, создайте резервную копию. + Перед применением к сайту с собственным контентом сделайте резервную копию базы и публичных шаблонов. + Перед рабочим запуском проведите полный тест оплаты, отмены и повторного callback. + Перед сохранением выполняется проверка PHP-синтаксиса. + Перед удалением убедитесь, что старый адрес больше не используется во внешних ссылках. + Переиндексация обновляет проекцию товара из полей документа. + Пересчёт восстанавливает агрегаты из исходного журнала действий. + Перетаскивание меняет порядок только для панели управления. + Переходы группируются по посетителю, странице входа и источнику; открытые IP и произвольные query-параметры не сохраняются. + Период влияет только на подневную статистику. + Период и фильтры одинаково применяются к сводке, таблицам и CSV-экспорту. + Планировщик + По умолчанию подтверждение выключено; на рабочем сайте его можно включить как дополнительный защитный слой. + Повторное применение использует ledger пакета и не должно создавать дубли. + Повторяйте тест после обновления PHP, БД или инфраструктуры. + Подбирает связанные документы по смыслу, ручным связям или стабильному кольцевому порядку. + Подготовка патча не меняет файлы до отдельного подтверждения установки. + Подключает SMSC для регистрации и беспарольного входа по подтверждённому номеру телефона. + Подключает VK ID как дополнительный способ входа в существующий публичный аккаунт. + Подключает Яндекс ID как дополнительный способ входа в существующий публичный аккаунт. + Подключённые OAuth-гейты открывают ту же публичную сессию, что и обычная форма входа. + Подтвердите пароль + Подтверждение действует пять минут для текущего пользователя и IP-адреса. + Подтверждение критических действий + Позволяет массово уточнять основные товарные данные прямо в таблице без открытия каждого документа. + Поиск по сайту + Поисковые запросы + Показатель «Результатов сейчас» помогает проверить, исправлена ли нулевая выдача. + Показывает готовность товарных данных к расчёту доставки и позволяет редактировать грузовые места без открытия документа. + Показывает на публичном сайте запросы, ошибки, сессию и служебные данные страницы. + Показывает несуществующие публичные адреса и помогает создать документ или редирект. + Показывает общее состояние AVE.cms, последние документы и виджеты установленных модулей. + Показывает посещаемость документов по дням и помогает находить наиболее востребованные страницы. + Показывает рабочие таблицы AVE.cms, выполняет обслуживание, применяет миграции ядра и управляет полными резервными копиями. + Показывает реальные поисковые фразы посетителей, частоту запросов и случаи пустой выдачи. + Показывает товарные документы в быстром индексе каталога: цены, остатки, изображения и категории. + Полная замена рабочей схемы данными из резервной копии + Полный доступ (суперправо) + Получатели задаются по одному на строку. + Полученный профиль связывается с общим аккаунтом сайта. + Пользователи + Пользователи сайта + Пользователь не определён + Поля XML и характеристики связываются с индексом каталога или полями товарной рубрики. + Поля внешнего ID, артикула, цены и остатка выбираются из полей назначенной рубрики. + Поля сгруппированы по назначению, а тип значения проверяется перед сохранением. + Поля являются отдельными сущностями со своим типом, настройками и шаблоном. + Порядок вариантов определяет порядок переключателей на публичной карточке. + Порядок полей и фильтров меняется перетаскиванием и сохраняется сразу. + После изменения алиаса обновите внешние подписки и проверьте XML из списка. + После изменения базового URL обновите ссылки в шаблонах и проверьте, что новый адрес не занят документом. + После изменения каталоговых полей индекс обычно обновляется автоматически. + После изменения проверьте первую, среднюю и последнюю страницы списка. + После изменения роли права применяются при следующей проверке доступа пользователя. + После изменения рубрик или индексируемых полей выполните полную переиндексацию. + После изменения структуры очищается кеш соответствующей навигации. + После изменения шаблона отправьте тестовое обращение и проверьте письмо. + После публикации проверьте выбор, сброс и повторное применение нескольких фильтров. + После смены домена обновите callback в панели Яндекса и настройках модуля. + После смены секретного URL обновите задание на хостинге: старый адрес сразу перестанет работать. + После установки и массового изменения контента перестройте индекс на отдельной вкладке. + Последние документы и системные показатели дают быстрый переход в профильный раздел. + Похожие материалы + Права сгруппированы по модулям и назначаются роли целиком. + Предпросмотр не меняет кеш, а пересоздание атомарно публикует новый файл. + Предпросмотр показывает состав пакета до изменения данных. + Представление не найдено + Префикс /rss/ зарезервирован и не может использоваться документами. + При удалении исходника удаляются его превью и связанная WebP-копия. + Признак доступности и код ТСР задаются в данных товара. + Признак подтверждённой покупки устанавливается только после проверки заказа. + Применение изменяет только подтверждённые строки и записывает результат в историю. + Применение миграций не восстанавливает данные: это отдельная операция развития структуры ядра. + Принимает CommerceML по защищённому адресу и сопоставляет товары 1С с документами товарной рубрики. + Причина видна администраторам и записывается в аудит. + Пробный XML можно загрузить из списка профилей и проверить результат в журнале обменов. + Проверка ничего не исправляет и не меняет публичные данные. + Проверки не отправляют письма и не выполняют внешние операции. + Проверьте SQL-журнал системных событий. + Проверьте кампанию на тестовом URL и мобильном экране до включения автоматической вставки. + Проверьте смешанную корзину и отказ платежа до включения способа на публичном сайте. + Проверяет PHP, расширения, доступ к каталогам, почту, данные модулей и готовность публичной части. + Проверяет базовые настройки, документы, шаблоны, модули и окружение перед публикацией сайта. + Проверяет документы на технические и редакционные проблемы, влияющие на поисковое представление. + Проверяет доступность базы данных, диска, фоновых задач и заданных внешних HTTPS-сервисов. + Проверяет целостность исходников по SHA-256, ищет подозрительные конструкции и защищает загрузки. + Проверяйте адреса получателей и настройки системной почты перед публикацией формы. + Проверяйте вход и привязку на тестовом аккаунте после смены домена или ключей приложения. + Проверяйте запрос после изменения условий: он может использоваться сразу в нескольких страницах. + Проверяйте совместимость версии и резервную копию перед обновлением модуля. + Продажи + Производительность + Просмотр сайта в режиме разработки + Просроченный счётчик формируется отдельно для текущего сотрудника. + Профиль задаёт рубрики, признаки, веса, лимит и способ вывода. + Профиль объединяет лимиты, проверку скрытого поля и анализ содержимого для конкретной формы. + Профиль определяет глубину и набор проверяемых подсистем. + Публикацию, копирование и удаление можно выполнять из списка или пакетно. + Публикация, скрытие и отметка спама выполняются из общего журнала. + Публикует документы выбранной рубрики в XML-ленте по настраиваемому адресу /rss/{alias}. + Публичная панель отладки + Публичные аккаунты находятся в отдельном разделе «Пользователи сайта». + Публичный сайт меняется только после публикации представления и включения нативного режима нужного контекста. + Пункты можно сортировать и переносить между уровнями. + Пути включения и исключения принимают по одному шаблону на строку; символ * заменяет произвольную часть. + Разблокировка возвращает доступ сразу. + Разделы задают структуру каталога и набор доступных полей. + Разрешает видеть публичный сайт, когда он временно закрыт для посетителей и поисковых систем. + Разрешает вход в административную панель /adminx. + Разрешите только те типы объектов, на которых действительно нужны обсуждения. + Распределяет посетителей между вариантами и собирает показы и конверсии без смешивания вариантов в общем кеше. + Расчёт включается отдельно и использует только полностью заполненную упаковку. + Ревизии и статистика просмотров удаляются необратимо. + Ревизии сохраняют историю черновиков и опубликованных версий. + Редактор выбирается отдельно для каждого блока: PHP, HTML, rich text или обычный текст. + Редактор документа + Редактор изображений создаёт производные файлы для crop и resize. + Редактор товара остаётся редактором документа, но открывается из каталога. + Редирект должен вести на действительно соответствующий документ, а не всегда на главную. + Редиректы документов + Режим разработки возвращает посетителям временную страницу 503 и закрывает динамические маршруты от индексации. + Результаты нужны для сравнения одного окружения во времени, а не разных проектов. + Рейтинги + Роли и права + Роль определяет доступные разделы и действия. + Роль с назначенными пользователями нельзя удалить. + Рубрики и поля + Русский + Ручной запуск использует тот же обработчик и попадает в общий журнал. + Связанный документ позволяет не вводить URL вручную. + Связывает поле типа catalog с деревом разделов, документами, полями и публичными фильтрами. + Секрет приложения хранится только в настройках gateway. + Секреты устанавливаемых модулей не должны храниться в общих настройках. + Сессия устарела + Сетка и список показывают одни и те же физические файлы. + Синтаксис PHP без ошибок. + Система + Системная роль admin всегда имеет полный доступ. + Системные константы + Системные события + Системные файлы + Системные функции не отображаются как устанавливаемые модули. + Скрытие пункта не отменяет право роли и не блокирует прямой URL. + Случайные сканирующие URL можно удалить из журнала без создания страницы. + Смена рубрики существующего документа запрещена без отдельной миграции полей. + Сначала загрузите файл или получите его по настроенной HTTPS-ссылке. + Сначала проверьте PHP-синтаксис задания. + Сначала проверьте источник переходов и частоту запроса. + Сначала сформируйте эталон на заведомо проверенной версии проекта. + Сниппеты можно сохранять для повторного использования. + Собирает изображения медиатеки в упорядоченные сетки и слайдеры с собственными публичными URL. + Собирает общие параметры AVE.cms, интерфейс, константы, пагинацию, обслуживание и диагностику. + Собирает оценки и отзывы к документам, позволяет модерировать публикацию и отвечать от имени компании. + Собирает фоновые задачи установленных модулей в единый реестр, запускает их по расписанию и хранит журнал. + Собирает частые вопросы в независимые коллекции с публичной страницей, тегом, API и Schema.org. + Создание неизвестных товаров выключено по умолчанию: сначала проверьте сопоставление на существующих документах. + Создаёт и изменяет страницу сайта: её адрес, публикацию, SEO и значения полей выбранной рубрики. + Создаёт меню сайта: шаблоны уровней, пункты, вложенность, условия показа и связи с документами. + Создаёт одиночные и множественные голосования с расписанием, доступом и управляемыми результатами. + Состав и порядок виджетов меняются в настройках интерфейса. + Состояние и заметка позволяют отмечать разобранные запросы и планировать новый контент. + Состояние определяет, попадёт ли отзыв в публичный список. + Состояние строки показывает публикацию и доступность товара. + Сотрудник с отдельным правом продолжает видеть документы, модули и API. + Сохранение PHP-кода, исполняемого runtime + Сохранение обновляет БД, файловый кеш и создаёт ревизию. + Список файлов ограничен системой и не является файловым менеджером. + Способы оплаты настраиваются во вкладке «Оплата», реквизиты провайдеров — во вкладке «Gateways». + Справка + Срок определяет состояние напоминания. + Стабильный код используется в теге [mod_faq:КОД] и публичном URL. + Стабильный код места используется в теге [mod_banner:КОД]. + Статистика просмотров + Страница не найдена + Сценарий определяет момент показа: задержку, прокрутку или попытку ухода. + Тег [mod_gallery:ID] выводит коллекцию в документе, блоке или шаблоне. + Тег [mod_rating] использует текущий документ; явная цель задаётся типом и ID. + Тег [mod_related:КОД] использует текущий документ, а API принимает его ID явно. + Теги полей доступны в шаблоне формы и письма. + Теги страниц, текущего состояния и ссылок подставляются во время рендера. + Теги шаблона выводят название фильтра, варианты и рассчитанные количества товаров. + Текстовые файлы получают ревизии; тему можно экспортировать или импортировать безопасным ZIP-пакетом. + Текущий снимок показывает независимый результат каждой инфраструктурной проверки. + Телефон относится к общей учётной записи, поэтому отдельный профиль для SMS не создаётся. + Темы публичного сайта + Тест создаёт кратковременную нагрузку и сохраняет историю запусков. + Тестовый и рабочий режимы должны использовать соответствующие ключи. + Тип определяет допустимый редактор и формат сохранённого значения. + Типизированные значения проверяются перед сохранением. + Товарные фиды + Товары каталога + Товары являются документами и не дублируются в отдельной основной таблице. + Тяжёлые проверки могут кратковременно кешироваться. + У вашей роли нет права на просмотр этого раздела. + Удаление галереи не удаляет исходные файлы и созданные для них превью. + Удаление задачи необратимо; выполненные пункты можно вернуть в работу обычным чекбоксом. + Удаление затрагивает только сущности, созданные этим пакетом. + Удаление напоминания необратимо; выполненный пункт можно вернуть в работу. + Удаление опроса также удаляет варианты ответа и собранные голоса. + Удаление отзыва необратимо; спорный материал безопаснее сначала отклонить. + Удаляйте константу только после поиска её использования в коде и шаблонах. + Удаляйте рубрики и поля только после проверки связанных документов и шаблонов. + Удалённый документ сначала попадает в корзину и только затем может быть удалён окончательно. + Укажите название представления + Управляет Twig-разметкой и CSS товарной карточки отдельно для разных мест публичного сайта. + Управляет Twig-разметкой, CSS и клиентским поведением фасетов товарного каталога. + Управляет древовидными обсуждениями, премодерацией, антиспамом и публичными шаблонами. + Управляет кампаниями поп-апов, условиями показа, A/B-вариантами и собранными заявками. + Управляет повторным запросом пароля перед изменением исполняемого кода и установкой модулей. + Управляет публичным доступом, параметрами сайта, почты, документов и вывода. + Управляет публичными аккаунтами, регистрацией, OAuth-гейтами и полями профиля. + Управляет публичными формами обратной связи и журналом сообщений посетителей. + Управляет рекламными местами, ротацией изображений, расписанием и внутренней статистикой переходов. + Управляет ручными блокировками адресов, а автоматические ограничения выполняет RateLimiter. + Управляет сотрудниками, которые могут входить в панель управления, их ролями и активностью. + Управляет старыми адресами страниц и перенаправляет посетителей на актуальные документы. + Управляет структурами каталогов, товарным индексом, фильтрами и связью с документами. + Управляет файлами в uploads: загрузкой, папками, изображениями, превью и конвертацией WebP. + Управляет файлами оформления, подключениями CSS/JavaScript и способом сборки публичной страницы. + Условия определяют, какие документы попадут в результат. + Условия фильтров определяют SQL-ограничения публичной выборки. + Устанавливает и обновляет расширения сайта, их таблицы, шаблоны и состояние подключения. + Устанавливает и обслуживает демонстрационный набор рубрик, документов, навигации и шаблонов. + Устанавливает подписанные патчи ядра с проверкой локальных изменений, резервной копией и откатом. + Установка или изменение исполняемого кода модуля + Установка подписанного обновления ядра + Файлы остаются в медиатеке; галерея хранит только связи, подписи и порядок. + Файлы темы находятся в templates/<код темы> и проходят проверку пути и расширения. + Фильтр по области разделяет спрос по новостям, товарам, статьям и другим настроенным областям. + Фильтр помогает быстро найти товары без размеров или с выключенным расчётом. + Фильтруйте документы по рубрике, состоянию и названию. + Фильтры и CSV-экспорт работают отдельно для каждого источника. + Фильтры помогают отобрать товары без артикула, цены или обязательных признаков. + Фильтры помогают сначала разобрать критичные и массовые проблемы. + Фильтры списка не меняют данные товара. + Форма состоит из полей, шаблонов и почтовых настроек. + Формирует выборки документов по рубрикам и полям, а затем выводит их через шаблоны элементов и списка. + Формирует управляемые XML/YML-выгрузки товаров для партнеров и торговых площадок. + Формы и обращения + Хранит внешнюю HTML/PHP-оболочку сайта, в которую подставляется содержимое документа и системные теги. + Хранит личные задачи с датой и выводит просроченные пункты в общем колокольчике. + Хранит личные текстовые заметки сотрудника и закрепляет важные записи на дашборде. + Хранит личный список замечаний и следующих действий для текущего администратора. + Хранит общий журнал оценок, реакций и голосов, который используют прикладные модули. + Хранит типизированные значения конфигурации, доступные ядру и публичному сайту. + Цвет и состояние помогают отличать успешные операции, предупреждения и ошибки. + Чекбокс сразу сохраняет состояние без перезагрузки. + Черновик можно проверить до публикации. + Черновик можно проверять на живых данных товара без публикации. + Черновик не участвует в публичном выводе; перед запуском проверьте каждый вариант. + Чувствительный маршрут не может использовать безопасный HTTP-метод + Чувствительный маршрут обязан объявить permission и reauth + Шаблон должен сохранять доступность ссылок и понятное обозначение текущей страницы. + Шаблон №1 является системным и не удаляется. + Шаблоны опроса, варианта, результата и архива имеют разные наборы тегов. + Шаблоны пагинации + Шаблоны списка, комментария и формы имеют собственные наборы тегов. + Шаблоны страниц + Шаблоны фильтров + Штатный HTML можно заменить шаблонами модуля или существующим запросом документов. + Это действие изменяет исполняемый код системы. + код + представлений + diff --git a/adminx/language/ru/section-help.xml b/adminx/language/ru/section-help.xml new file mode 100644 index 0000000..1eab79b --- /dev/null +++ b/adminx/language/ru/section-help.xml @@ -0,0 +1,472 @@ + + + A/B-тесты + API-ключ хранится в закрытом хранилище секретов. + Callback URL из настроек должен точно совпадать с адресом OAuth-приложения. + Callback принимает изменения статуса от платёжной системы. + Client secret не должен попадать в публичный шаблон или журнал. + Cron-выражение и активность каждой задачи меняются прямо в таблице. + Ctrl+S сохраняет изменения и оставляет редактор открытым; ревизии позволяют вернуться к прошлой версии. + Email остаётся основным идентификатором; дополнительные способы входа поставляются отдельными модулями. + Email-регистрация, восстановление доступа и создание аккаунта после заказа входят в ядро. + External/AJAX-флаги разрешают прямой вызов запроса и требуют осторожности. + Gateway показывается только для подходящего состава заказа. + HTML вариантов контролируется проектом, а заявки обрабатываются на отдельной вкладке. + IP-блокировки + JSON-LD включает только опубликованные вопросы опубликованной коллекции. + OPTIMIZE применяется только к выбранной таблице или ко всей рабочей схеме после подтверждения. + PHP включайте только для блоков, которым действительно нужна серверная логика. + PHP-консоль + RSS-каналы + Redirect URL из настроек должен точно совпадать с адресом в приложении VK. + Runner выполняет работу пошагово с lock и отображает прогресс. + SEO-аудит + UTM-конструктор собирает ссылку в браузере и не сохраняет введённые значения. + Uninstall может удалить данные модуля, если это заложено его manifest. + Update применяет предусмотренную модулем схему обновления. + Автоматическая оценка является подсказкой и не заменяет проверку содержания страницы. + Агрегаты ускоряют публичный вывод счётчиков и рейтингов. + Алгоритм выборки остаётся в каталоге, а шаблон отвечает за представление. + Алгоритм определяет разбор строки и вес точного совпадения, заголовка и содержимого. + Антиспам + Архив опросов доступен по настраиваемому публичному URL. + Ассеты и переопределения темы работают в обоих режимах сборки. + База данных + Базовый шаблон защищён от удаления. + Баннеры + Безопасность файлов + Блоки + Блокировка может быть постоянной или иметь дату окончания. + Большие задания запускайте небольшими пакетами и контролируйте ошибки после каждого шага. + Брошенные корзины очищаются по сроку хранения и доступны для диагностики. + Быстрое заполнение товаров + Быстрое создание доступно из шапки, если вклад модуля включён. + В карантин автоматически можно переместить только загрузки и временные вложения; системные файлы исправляются вручную. + Вес вариантов задаёт распределение внутри выбранного охвата аудитории. + Вес влияет только на стратегию «По весу»; равномерная ротация выбирает материал с меньшим числом показов. + Вес и размеры указываются для одного грузового места; количество умножает итоговые показатели. + Взаимодействия + Виджеты установленных модулей появляются в списке автоматически. + Внешний профиль связывается с общим аккаунтом сайта, а не создаёт отдельную сессию. + Во вкладке «Настройки» выбирается нативная сборка AVE.cms или Twig-оболочка темы. + Вопросы и ответы + Восстановление полностью заменяет таблицы текущей установки; рабочую копию дополнительно храните вне сервера. + Время, память и размер вывода ограничены. + Всплывающие окна + Встроенный шаблон подходит для новой разметки, существующий запрос — для готовых карточек сайта. + Вход по SMS + Вход через VK ID + Вход через Яндекс + Выберите лист и сформируйте предварительный просмотр. + Выключенный вариант остаётся документом, но не предлагается покупателю. + Выполнение сохраняется сразу. + Выполняет административные PHP-сниппеты в ограниченном дочернем процессе для диагностики и обслуживания. + Галереи + Голоса и итоговые значения хранятся в модуле «Взаимодействия». + Готовность сайта + Группирует входящие переходы по UTM-меткам, источникам, кампаниям и посадочным страницам. + Группы вариантов + Группы организуют длинные формы документов и не влияют на публичный URL. + Группы организуют список и не влияют на публичный вывод. + Дашборд + Даёт контролируемый редактор разрешённых PHP-файлов конфигурации и пользовательских функций. + Демо-сайт + Диагностика системы + Диагностика ядра не заменяет отдельную проверку подключённых модулей и внешних сервисов. + Для бизнес-аналитики используйте внешнюю систему аналитики; здесь хранится внутренняя статистика CMS. + Для вариантов одного товара используются группы вариантов. + Для внешнего сервиса задаются ожидаемый HTTP-статус и ограниченный таймаут. + Для каждой интеграции создайте отдельный профиль с логином, паролем и рубрикой. + Для публикации вставьте [mod_poll:ID] в документ, блок или шаблон. + Для старого адреса существующей страницы создайте постоянный редирект. + Добавляет на документы и другие разрешённые объекты настраиваемую шкалу оценок. + Доверенные исключения применяйте только к контролируемым интеграциям. + Документ №1 и системная страница 404 защищены от окончательного удаления. + Документы + Документы включают страницы, товары, новости и статьи, построенные на рубриках. + Дополнительные поля независимо подключаются к регистрации и личному кабинету. + Доставка и упаковка + Доступ к функциям всегда ограничивайте через роли и права, а не только через видимость меню. + Единый реестр HTML-фрагментов, текста и исполняемой логики для шаблонов и документов. + Журнал объясняет, какое правило остановило запрос, и помогает скорректировать чувствительность. + Заголовок и описание можно брать из полей рубрики. + Задачу можно создать из выпадающего списка в шапке на любой странице панели управления. + Заказы и корзина + Закрепление влияет на порядок выдачи. + Заметки + Заметки видны только их владельцу. + Запросы + Запускает управляемые пакетные задания над документами и хранит подробный журнал выполнения. + Запускайте полный профиль после изменения структуры сайта, модулей или серверного окружения. + Защищённый URL добавляется в планировщик хостинга с интервалом в одну минуту. + Идентификатор магазина и секрет применяются только серверной частью. + Изменение alias может автоматически создать редирект со старого URL. + Изменение цены штатным вариантом влияет только на представление и не меняет сумму заказа. + Изменения структуры влияют на редактор документов и публичный каталог. + Изменённая ячейка сохраняется отдельно по AJAX. + Измеряет скорость PHP, базы данных, файловых операций и кеша на текущем сервере. + Изображения выбираются из общей медиатеки и не удаляются вместе с баннером. + Импорт цен + Индексирует опубликованные документы и формирует обычную страницу результатов и JSON для живого поиска. + Используйте дашборд как отправную точку, а подробные данные смотрите в соответствующих разделах. + Используйте редактор документа для полей, которых нет в таблице быстрого заполнения. + Используйте этот раздел для небольших проектных дополнений; крупную функциональность оформляйте модулем. + Исправления выполняются в редакторе документа и проявляются после повторного аудита. + История обращений хранит отправленные значения и ответы менеджеров. + История позволяет менять статус обращения и отвечать посетителю. + История помогает отличить разовый сбой от повторяющейся проблемы. + Источник использует отдельный публичный RSA-ключ владельца AVE.cms. + Источники трафика + Ищите редирект по старому URL, названию или ID документа. + Каждая кнопка работает только со своим источником данных. + Каждая навигация имеет собственный тег для вставки в шаблон. + Каждое грузовое место хранит количество, вес и три габарита. + Каждый вариант должен сохранять собственные цену, остаток, URL и артикул. + Каждый каталог создаётся для конкретного поля рубрики типа catalog. + Каждый подраздел имеет собственный URL и назначение. + Каждый пользователь видит только собственные задачи. + Каждый сотрудник работает со своей доской. + Каждый старый URL должен быть уникальным и вести на существующий документ. + Канал определяет допустимые действия и типы объектов. + Канбан + Карточка заказа фиксирует товары, цены и выбранные варианты на момент оформления. + Карточки и порядок сохраняются без перезагрузки. + Карточки товаров + Каталог + Категории определяют товарную выборку, исключения применяются после дочерних разделов. + Ключи и callback используются только серверной частью. + Кнопки тегов вставляют доступные значения в HTML-шаблон рейтинга. + Код до/после сохранения выполняется при изменении документа и требует проверки синтаксиса. + Код одноразовый, ограничен по времени и числу попыток. + Код эксперимента используется в шаблонном теге, API и атрибутах отслеживания конверсии. + Коды 301/308 подходят для постоянного переноса, 302/307 — для временного. + Количество элементов ограничивает размер выдаваемой ленты. + Колонки можно настроить под собственный процесс. + Комментарии + Консоль выполняет код с доступом к системе — используйте её только для проверенных операций. + Конструктор каталога + Конструктор формы + Контексты назначают представление главной странице, каталогу и другим листингам. + Копирование формы создаёт независимый набор настроек. + Красные результаты требуют исправления, предупреждения нужно оценивать в контексте проекта. + Красные состояния нужно устранить до публикации. + Медиа и файлы + Меняйте только параметры, назначение которых понятно и проверено на текущем сайте. + Модули + Модульные виджеты появляются только у пользователей с соответствующим правом. + Мониторинг здоровья + Мониторинг фиксирует рабочее состояние, но не заменяет приёмочный чек-лист перед запуском сайта. + Набор полей и их группы задаются рубрикой. + Навигация + Название константы является программным контрактом и не должно меняться без миграции. + Напоминания + Настраивает HTML постраничной навигации для документов и сохранённых запросов. + Настраивает отдельный платёжный сценарий для товаров, доступных к оплате электронным сертификатом. + Настраивает платёжный gateway YooKassa для оформления и контроля оплаты заказов. + Настраивает поля, шаблон формы, получателей, письмо и поведение после отправки. + Настраивает профили защиты публичных форм и показывает журнал отклонённых отправок. + Настройка интерфейса + Настройка распространяется на блоки, шаблоны, рубрики, запросы, навигацию и другие операции с кодом. + Настройки системы + Настройки устанавливаемых модулей хранятся вместе с соответствующим модулем. + Находка эвристики является причиной проверить файл, а не доказательством заражения. + Начинайте с выбора каталога, затем настраивайте разделы, поля и фильтры. + Не блокируйте адрес reverse proxy без проверки реального клиентского IP. + Не используйте канбан как журнал системных ошибок: для них предназначены события и Todo. + Не меняйте закрепленные legacy URL у действующих партнеров; редактируйте связанную конфигурацию фида. + Не ослабляйте общий профиль из-за одного ложного срабатывания: создайте отдельный профиль для нужной формы. + Не применяйте импорт, если предварительный просмотр показывает неожиданные товары или пустые цены. + Не принимайте изменённый файл в эталон, пока не проверили причину изменения и его содержимое. + Не размещайте служебные PHP-файлы в uploads; раздел предназначен только для пользовательских медиа. + Не удаляйте storage/updates и не копируйте полную сборку поверх сайта во время активного обновления. + Не храните в заметках пароли, токены и другие секреты. + Незавершённое задание продолжается с сохранённого этапа после повторного открытия раздела. + Нельзя отключить или удалить собственную активную учётную запись. + Новые дампы используют {{prefix}}, поэтому могут восстанавливаться с другим dbpref. + Нулевой лимит означает отсутствие ограничения; сброс статистики необратим. + Области позволяют отдельно искать по товарам, новостям, статьям и другим наборам рубрик. + Обмен с 1С + Обновления AVE.cms + Обновляет цены товаров из XLSX после обязательного предварительного просмотра изменений. + Обрабатывает заказы, состояния оплаты и доставки, шаблоны корзины и брошенные сессии покупателей. + Обслуживание + Общий счётчик документа хранится отдельно и не очищается вместе с дневными данными. + Объединяет аудит действий, ошибки, SQL-журнал, 404 и статистику внешних переходов. + Объединяет отдельные товары с собственными URL и артикулами в одну карточку публичного листинга. + Обычно этот раздел не вставляется в шаблон напрямую: публичный интерфейс предоставляют рейтинги, опросы и комментарии. + Один вход посетителя на ту же страницу за день агрегируется без хранения IP. + Операции с документами + Определяет набор разрешений для администраторов и разграничивает доступ к функциям системы. + Определяет порядок и видимость пунктов левого меню и виджетов дашборда. + Определяет типы документов: набор полей, группы, шаблоны вывода, права и код жизненного цикла. + Опросы + Организует личные рабочие карточки по колонкам и показывает доску на дашборде. + Основной вариант представляет группу до выбора цвета или исполнения. + Основной и постраничный шаблоны отвечают только за представление. + Основной публичный вызов использует тег sysblock; старый тег block поддерживается как синоним. + Основной реестр страниц и товарных карточек сайта, построенных на рубриках и их полях. + Основные настройки + Остановленное задание можно проанализировать по журналу. + Ответ компании сохраняется вместе с отзывом и выводится его публичным шаблоном. + Ответ оформляется в rich-text редакторе, а вопросы сортируются перетаскиванием. + Отдельные шаблоны управляют оболочкой галереи и разметкой одного изображения. + Отзывы + Отключение аккаунта запрещает вход, но сохраняет связанные данные. + Отключение повторного пароля не отменяет проверку прав роли и CSRF-токена. + Отключённый пользователь не может войти, но сохраняется в журнале действий. + Отмена и возврат могут влиять на платёжный шлюз — проверяйте итоговый статус операции. + Оценивайте не только общий балл, но и конкретный медленный этап. + Оценка документа складывается из нескольких независимых проверок. + Оценка комментариев использует отдельный канал модуля «Взаимодействия». + Очистка журнала необратима и не исправляет причину ошибки. + Очистка истории удаляет аналитические данные, но не затрагивает поисковый индекс. + Очистка кеша не удаляет документы и настройки. + Очистка статистики необратима и предназначена для обслуживания. + Очищает адресные кеши и накопленные служебные данные AVE.cms. + Ошибка PHP в основном шаблоне может нарушить весь публичный сайт — используйте проверку перед сохранением. + Ошибка в таком файле может повлиять и на панель, и на публичный сайт. + Ошибки 404 + ПСБ / СФР + Палитра тегов вставляет зарегистрированные блоки, навигации и значения страницы. + Перед включением на сайте отправьте тестовое SMS и проверьте вход существующего и нового пользователя. + Перед восстановлением система проверяет файл и автоматически создаёт страховочную копию. + Перед добавлением проверьте, нет ли уже настройки с тем же назначением. + Перед запуском выполняется проверка синтаксиса. + Перед открытием сайта переключите публичный доступ и проверьте страницу в отдельном браузере без авторизации. + Перед очисткой выбранного периода выгрузите CSV, если данные нужны для отчётности. + Перед очисткой данных, которые нельзя восстановить из документов, создайте резервную копию. + Перед применением к сайту с собственным контентом сделайте резервную копию базы и публичных шаблонов. + Перед рабочим запуском проведите полный тест оплаты, отмены и повторного callback. + Перед сохранением выполняется проверка PHP-синтаксиса. + Перед удалением убедитесь, что старый адрес больше не используется во внешних ссылках. + Переиндексация обновляет проекцию товара из полей документа. + Пересчёт восстанавливает агрегаты из исходного журнала действий. + Перетаскивание меняет порядок только для панели управления. + Переходы группируются по посетителю, странице входа и источнику; открытые IP и произвольные query-параметры не сохраняются. + Период влияет только на подневную статистику. + Период и фильтры одинаково применяются к сводке, таблицам и CSV-экспорту. + Планировщик + По умолчанию подтверждение выключено; на рабочем сайте его можно включить как дополнительный защитный слой. + Повторное применение использует ledger пакета и не должно создавать дубли. + Повторяйте тест после обновления PHP, БД или инфраструктуры. + Подбирает связанные документы по смыслу, ручным связям или стабильному кольцевому порядку. + Подготовка патча не меняет файлы до отдельного подтверждения установки. + Подключает SMSC для регистрации и беспарольного входа по подтверждённому номеру телефона. + Подключает VK ID как дополнительный способ входа в существующий публичный аккаунт. + Подключает Яндекс ID как дополнительный способ входа в существующий публичный аккаунт. + Подключённые OAuth-гейты открывают ту же публичную сессию, что и обычная форма входа. + Подтверждение действует пять минут для текущего пользователя и IP-адреса. + Подтверждение критических действий + Позволяет массово уточнять основные товарные данные прямо в таблице без открытия каждого документа. + Поиск по сайту + Поисковые запросы + Показатель «Результатов сейчас» помогает проверить, исправлена ли нулевая выдача. + Показывает готовность товарных данных к расчёту доставки и позволяет редактировать грузовые места без открытия документа. + Показывает несуществующие публичные адреса и помогает создать документ или редирект. + Показывает общее состояние AVE.cms, последние документы и виджеты установленных модулей. + Показывает посещаемость документов по дням и помогает находить наиболее востребованные страницы. + Показывает рабочие таблицы AVE.cms, выполняет обслуживание, применяет миграции ядра и управляет полными резервными копиями. + Показывает реальные поисковые фразы посетителей, частоту запросов и случаи пустой выдачи. + Показывает товарные документы в быстром индексе каталога: цены, остатки, изображения и категории. + Получатели задаются по одному на строку. + Полученный профиль связывается с общим аккаунтом сайта. + Пользователи + Пользователи сайта + Поля XML и характеристики связываются с индексом каталога или полями товарной рубрики. + Поля внешнего ID, артикула, цены и остатка выбираются из полей назначенной рубрики. + Поля сгруппированы по назначению, а тип значения проверяется перед сохранением. + Поля являются отдельными сущностями со своим типом, настройками и шаблоном. + Порядок вариантов определяет порядок переключателей на публичной карточке. + Порядок полей и фильтров меняется перетаскиванием и сохраняется сразу. + После изменения алиаса обновите внешние подписки и проверьте XML из списка. + После изменения базового URL обновите ссылки в шаблонах и проверьте, что новый адрес не занят документом. + После изменения каталоговых полей индекс обычно обновляется автоматически. + После изменения проверьте первую, среднюю и последнюю страницы списка. + После изменения роли права применяются при следующей проверке доступа пользователя. + После изменения рубрик или индексируемых полей выполните полную переиндексацию. + После изменения структуры очищается кеш соответствующей навигации. + После изменения шаблона отправьте тестовое обращение и проверьте письмо. + После публикации проверьте выбор, сброс и повторное применение нескольких фильтров. + После смены домена обновите callback в панели Яндекса и настройках модуля. + После смены секретного URL обновите задание на хостинге: старый адрес сразу перестанет работать. + После установки и массового изменения контента перестройте индекс на отдельной вкладке. + Последние документы и системные показатели дают быстрый переход в профильный раздел. + Похожие материалы + Права сгруппированы по модулям и назначаются роли целиком. + Предпросмотр не меняет кеш, а пересоздание атомарно публикует новый файл. + Предпросмотр показывает состав пакета до изменения данных. + Префикс /rss/ зарезервирован и не может использоваться документами. + При удалении исходника удаляются его превью и связанная WebP-копия. + Признак доступности и код ТСР задаются в данных товара. + Признак подтверждённой покупки устанавливается только после проверки заказа. + Применение изменяет только подтверждённые строки и записывает результат в историю. + Применение миграций не восстанавливает данные: это отдельная операция развития структуры ядра. + Принимает CommerceML по защищённому адресу и сопоставляет товары 1С с документами товарной рубрики. + Причина видна администраторам и записывается в аудит. + Пробный XML можно загрузить из списка профилей и проверить результат в журнале обменов. + Проверка ничего не исправляет и не меняет публичные данные. + Проверки не отправляют письма и не выполняют внешние операции. + Проверьте кампанию на тестовом URL и мобильном экране до включения автоматической вставки. + Проверьте смешанную корзину и отказ платежа до включения способа на публичном сайте. + Проверяет PHP, расширения, доступ к каталогам, почту, данные модулей и готовность публичной части. + Проверяет базовые настройки, документы, шаблоны, модули и окружение перед публикацией сайта. + Проверяет документы на технические и редакционные проблемы, влияющие на поисковое представление. + Проверяет доступность базы данных, диска, фоновых задач и заданных внешних HTTPS-сервисов. + Проверяет целостность исходников по SHA-256, ищет подозрительные конструкции и защищает загрузки. + Проверяйте адреса получателей и настройки системной почты перед публикацией формы. + Проверяйте вход и привязку на тестовом аккаунте после смены домена или ключей приложения. + Проверяйте запрос после изменения условий: он может использоваться сразу в нескольких страницах. + Проверяйте совместимость версии и резервную копию перед обновлением модуля. + Производительность + Просроченный счётчик формируется отдельно для текущего сотрудника. + Профиль задаёт рубрики, признаки, веса, лимит и способ вывода. + Профиль объединяет лимиты, проверку скрытого поля и анализ содержимого для конкретной формы. + Профиль определяет глубину и набор проверяемых подсистем. + Публикацию, копирование и удаление можно выполнять из списка или пакетно. + Публикация, скрытие и отметка спама выполняются из общего журнала. + Публикует документы выбранной рубрики в XML-ленте по настраиваемому адресу /rss/{alias}. + Публичные аккаунты находятся в отдельном разделе «Пользователи сайта». + Публичный сайт меняется только после публикации представления и включения нативного режима нужного контекста. + Пункты можно сортировать и переносить между уровнями. + Пути включения и исключения принимают по одному шаблону на строку; символ * заменяет произвольную часть. + Разблокировка возвращает доступ сразу. + Разделы задают структуру каталога и набор доступных полей. + Разрешите только те типы объектов, на которых действительно нужны обсуждения. + Распределяет посетителей между вариантами и собирает показы и конверсии без смешивания вариантов в общем кеше. + Расчёт включается отдельно и использует только полностью заполненную упаковку. + Ревизии и статистика просмотров удаляются необратимо. + Ревизии сохраняют историю черновиков и опубликованных версий. + Редактор выбирается отдельно для каждого блока: PHP, HTML, rich text или обычный текст. + Редактор документа + Редактор изображений создаёт производные файлы для crop и resize. + Редактор товара остаётся редактором документа, но открывается из каталога. + Редирект должен вести на действительно соответствующий документ, а не всегда на главную. + Редиректы документов + Режим разработки возвращает посетителям временную страницу 503 и закрывает динамические маршруты от индексации. + Результаты нужны для сравнения одного окружения во времени, а не разных проектов. + Рейтинги + Роли и права + Роль определяет доступные разделы и действия. + Роль с назначенными пользователями нельзя удалить. + Рубрики и поля + Ручной запуск использует тот же обработчик и попадает в общий журнал. + Связанный документ позволяет не вводить URL вручную. + Связывает поле типа catalog с деревом разделов, документами, полями и публичными фильтрами. + Секрет приложения хранится только в настройках gateway. + Секреты устанавливаемых модулей не должны храниться в общих настройках. + Сетка и список показывают одни и те же физические файлы. + Системная роль admin всегда имеет полный доступ. + Системные константы + Системные события + Системные файлы + Системные функции не отображаются как устанавливаемые модули. + Скрытие пункта не отменяет право роли и не блокирует прямой URL. + Случайные сканирующие URL можно удалить из журнала без создания страницы. + Смена рубрики существующего документа запрещена без отдельной миграции полей. + Сначала загрузите файл или получите его по настроенной HTTPS-ссылке. + Сначала проверьте PHP-синтаксис задания. + Сначала проверьте источник переходов и частоту запроса. + Сначала сформируйте эталон на заведомо проверенной версии проекта. + Сниппеты можно сохранять для повторного использования. + Собирает изображения медиатеки в упорядоченные сетки и слайдеры с собственными публичными URL. + Собирает общие параметры AVE.cms, интерфейс, константы, пагинацию, обслуживание и диагностику. + Собирает оценки и отзывы к документам, позволяет модерировать публикацию и отвечать от имени компании. + Собирает фоновые задачи установленных модулей в единый реестр, запускает их по расписанию и хранит журнал. + Собирает частые вопросы в независимые коллекции с публичной страницей, тегом, API и Schema.org. + Создание неизвестных товаров выключено по умолчанию: сначала проверьте сопоставление на существующих документах. + Создаёт и изменяет страницу сайта: её адрес, публикацию, SEO и значения полей выбранной рубрики. + Создаёт меню сайта: шаблоны уровней, пункты, вложенность, условия показа и связи с документами. + Создаёт одиночные и множественные голосования с расписанием, доступом и управляемыми результатами. + Состав и порядок виджетов меняются в настройках интерфейса. + Состояние и заметка позволяют отмечать разобранные запросы и планировать новый контент. + Состояние определяет, попадёт ли отзыв в публичный список. + Состояние строки показывает публикацию и доступность товара. + Сотрудник с отдельным правом продолжает видеть документы, модули и API. + Сохранение обновляет БД, файловый кеш и создаёт ревизию. + Список файлов ограничен системой и не является файловым менеджером. + Способы оплаты настраиваются во вкладке «Оплата», реквизиты провайдеров — во вкладке «Gateways». + Срок определяет состояние напоминания. + Стабильный код используется в теге [mod_faq:КОД] и публичном URL. + Стабильный код места используется в теге [mod_banner:КОД]. + Статистика просмотров + Сценарий определяет момент показа: задержку, прокрутку или попытку ухода. + Тег [mod_gallery:ID] выводит коллекцию в документе, блоке или шаблоне. + Тег [mod_rating] использует текущий документ; явная цель задаётся типом и ID. + Тег [mod_related:КОД] использует текущий документ, а API принимает его ID явно. + Теги полей доступны в шаблоне формы и письма. + Теги страниц, текущего состояния и ссылок подставляются во время рендера. + Теги шаблона выводят название фильтра, варианты и рассчитанные количества товаров. + Текстовые файлы получают ревизии; тему можно экспортировать или импортировать безопасным ZIP-пакетом. + Текущий снимок показывает независимый результат каждой инфраструктурной проверки. + Телефон относится к общей учётной записи, поэтому отдельный профиль для SMS не создаётся. + Темы публичного сайта + Тест создаёт кратковременную нагрузку и сохраняет историю запусков. + Тестовый и рабочий режимы должны использовать соответствующие ключи. + Тип определяет допустимый редактор и формат сохранённого значения. + Типизированные значения проверяются перед сохранением. + Товарные фиды + Товары каталога + Товары являются документами и не дублируются в отдельной основной таблице. + Тяжёлые проверки могут кратковременно кешироваться. + Удаление галереи не удаляет исходные файлы и созданные для них превью. + Удаление задачи необратимо; выполненные пункты можно вернуть в работу обычным чекбоксом. + Удаление затрагивает только сущности, созданные этим пакетом. + Удаление напоминания необратимо; выполненный пункт можно вернуть в работу. + Удаление опроса также удаляет варианты ответа и собранные голоса. + Удаление отзыва необратимо; спорный материал безопаснее сначала отклонить. + Удаляйте константу только после поиска её использования в коде и шаблонах. + Удаляйте рубрики и поля только после проверки связанных документов и шаблонов. + Удалённый документ сначала попадает в корзину и только затем может быть удалён окончательно. + Управляет Twig-разметкой и CSS товарной карточки отдельно для разных мест публичного сайта. + Управляет Twig-разметкой, CSS и клиентским поведением фасетов товарного каталога. + Управляет древовидными обсуждениями, премодерацией, антиспамом и публичными шаблонами. + Управляет кампаниями поп-апов, условиями показа, A/B-вариантами и собранными заявками. + Управляет повторным запросом пароля перед изменением исполняемого кода и установкой модулей. + Управляет публичным доступом, параметрами сайта, почты, документов и вывода. + Управляет публичными аккаунтами, регистрацией, OAuth-гейтами и полями профиля. + Управляет публичными формами обратной связи и журналом сообщений посетителей. + Управляет рекламными местами, ротацией изображений, расписанием и внутренней статистикой переходов. + Управляет ручными блокировками адресов, а автоматические ограничения выполняет RateLimiter. + Управляет сотрудниками, которые могут входить в панель управления, их ролями и активностью. + Управляет старыми адресами страниц и перенаправляет посетителей на актуальные документы. + Управляет структурами каталогов, товарным индексом, фильтрами и связью с документами. + Управляет файлами в uploads: загрузкой, папками, изображениями, превью и конвертацией WebP. + Управляет файлами оформления, подключениями CSS/JavaScript и способом сборки публичной страницы. + Условия определяют, какие документы попадут в результат. + Условия фильтров определяют SQL-ограничения публичной выборки. + Устанавливает и обновляет расширения сайта, их таблицы, шаблоны и состояние подключения. + Устанавливает и обслуживает демонстрационный набор рубрик, документов, навигации и шаблонов. + Устанавливает подписанные патчи ядра с проверкой локальных изменений, резервной копией и откатом. + Файлы остаются в медиатеке; галерея хранит только связи, подписи и порядок. + Файлы темы находятся в templates/<код темы> и проходят проверку пути и расширения. + Фильтр по области разделяет спрос по новостям, товарам, статьям и другим настроенным областям. + Фильтр помогает быстро найти товары без размеров или с выключенным расчётом. + Фильтруйте документы по рубрике, состоянию и названию. + Фильтры и CSV-экспорт работают отдельно для каждого источника. + Фильтры помогают отобрать товары без артикула, цены или обязательных признаков. + Фильтры помогают сначала разобрать критичные и массовые проблемы. + Фильтры списка не меняют данные товара. + Форма состоит из полей, шаблонов и почтовых настроек. + Формирует выборки документов по рубрикам и полям, а затем выводит их через шаблоны элементов и списка. + Формирует управляемые XML/YML-выгрузки товаров для партнеров и торговых площадок. + Формы и обращения + Хранит внешнюю HTML/PHP-оболочку сайта, в которую подставляется содержимое документа и системные теги. + Хранит личные задачи с датой и выводит просроченные пункты в общем колокольчике. + Хранит личные текстовые заметки сотрудника и закрепляет важные записи на дашборде. + Хранит личный список замечаний и следующих действий для текущего администратора. + Хранит общий журнал оценок, реакций и голосов, который используют прикладные модули. + Хранит типизированные значения конфигурации, доступные ядру и публичному сайту. + Цвет и состояние помогают отличать успешные операции, предупреждения и ошибки. + Чекбокс сразу сохраняет состояние без перезагрузки. + Черновик можно проверить до публикации. + Черновик можно проверять на живых данных товара без публикации. + Черновик не участвует в публичном выводе; перед запуском проверьте каждый вариант. + Шаблон должен сохранять доступность ссылок и понятное обозначение текущей страницы. + Шаблон №1 является системным и не удаляется. + Шаблоны опроса, варианта, результата и архива имеют разные наборы тегов. + Шаблоны пагинации + Шаблоны списка, комментария и формы имеют собственные наборы тегов. + Шаблоны страниц + Шаблоны фильтров + Штатный HTML можно заменить шаблонами модуля или существующим запросом документов. + diff --git a/adminx/modules/Auth/Controller.php b/adminx/modules/Auth/Controller.php new file mode 100644 index 0000000..b8abdb7 --- /dev/null +++ b/adminx/modules/Auth/Controller.php @@ -0,0 +1,209 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Auth; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\Auth; + use App\Common\AuditLog; + use App\Common\Controller as BaseController; + use App\Common\RateLimiter; + use App\Common\Session; + use App\Adminx\Support\AdminLocale; + use App\Adminx\Support\ReAuth; + use App\Helpers\Request; + use App\Helpers\Url; + + /** + * Логин/логаут новой админки. + * + * Форма логина — обычный POST (работает без JS); logout — ajax POST + CSRF, + * чтобы обкатать единый ajax-контракт (ТЗ §8.5). + */ + class Controller extends BaseController + { + /** GET /login — экран входа. */ + public function form(array $params = []) + { + return $this->render('@auth/login.twig', [ + 'error' => Request::getStr('error'), + ]); + } + + /** POST /login — попытка входа. */ + public function login(array $params = []) + { + ReAuth::forget(); + try { + $this->verifyCsrf(); + } catch (\RuntimeException $e) { + return $this->render('@auth/login.twig', [ + 'error' => AdminLocale::text('auth_err_session', 'Сессия устарела, обновите страницу и попробуйте снова.'), + ]); + } + + $login = Request::postStr('login'); + $password = (string) Request::post('password', ''); + $remember = Request::postBool('remember', false); + + //-- Современный Auth API (SystemTables users: email/password_hash/role). + //-- Вход по email или логину; запоминание — по флажку. + $options = [ + 'identifier_fields' => ['email', 'login'], + 'remember' => $remember, + 'issue_session_token' => true, + ]; + $result = Auth::attempt($login, $password, $options); + + if (is_array($result)) { + Session::regenerateCsrf(); + $this->redirect($this->base() . '/'); + return null; + } + + //-- Legacy-пароль (перенесённые пользователи, md5(md5(pass+salt))): + //-- проверяем и молча перехешируем в bcrypt при первом входе. + if ($this->legacyLogin($login, $password, $options)) { + Session::regenerateCsrf(); + $this->redirect($this->base() . '/'); + return null; + } + + $messages = [ + 1 => AdminLocale::text('auth_err_required', 'Введите логин или email и пароль.'), + 2 => AdminLocale::text('auth_err_invalid', 'Неверный логин или пароль.'), + 3 => AdminLocale::text('auth_err_disabled', 'Учётная запись отключена.'), + ]; + $error = isset($messages[$result]) + ? $messages[$result] + : AdminLocale::text('auth_err_failed', 'Не удалось войти.'); + + return $this->render('@auth/login.twig', [ + 'error' => $error, + 'login_prev' => $login, + ]); + } + + /** POST /locale — смена только языка интерфейса Adminx. */ + public function language(array $params = array()) + { + try { + $this->verifyCsrf(); + } catch (\RuntimeException $e) { + $this->redirect($this->base() . '/login'); + return null; + } + + AdminLocale::set(Request::postStr('locale')); + $return = Url::localReturn(Request::postStr('_return'), $this->base() . '/'); + $this->redirect($return); + return null; + } + + /** POST /reauth — подтверждение пароля перед чувствительным действием. */ + public function reauth(array $params = array()) + { + $user = Auth::user(); + if (!$user) { + return $this->error('Сессия входа завершена', array(), 401); + } + + $key = 'adminx-reauth:' . (int) $user['id'] . ':' . Request::ip(); + if (RateLimiter::tooManyAttempts($key, 5)) { + return $this->error( + 'Слишком много попыток. Повторите через ' . max(1, RateLimiter::availableIn($key)) . ' сек.', + array(), + 429 + ); + } + + if (!ReAuth::confirm((string) Request::post('password', ''))) { + RateLimiter::hit($key, 600); + AuditLog::record('auth.reauth_failed', array( + 'actor_id' => (int) $user['id'], + 'actor_name' => isset($user['name']) ? (string) $user['name'] : '', + )); + return $this->error('Неверный пароль', array('password' => 'Проверьте пароль'), 422); + } + + RateLimiter::clear($key); + AuditLog::record('auth.reauth_confirmed', array( + 'actor_id' => (int) $user['id'], + 'actor_name' => isset($user['name']) ? (string) $user['name'] : '', + )); + + return $this->success('Пароль подтверждён', array( + 'data' => array('ttl' => ReAuth::DEFAULT_TTL), + )); + } + + /** + * Вход перенесённого пользователя по legacy-паролю md5(md5(pass+salt)). + * При успехе перехеширует в bcrypt, очищает legacy-поля и логинит. + */ + protected function legacyLogin($identifier, $password, array $options) + { + $identifier = trim((string) $identifier); + if ($identifier === '' || $password === '') { + return false; + } + + $user = \App\Adminx\Users\Model::findByIdentifier($identifier); + if (!$user || empty($user->legacy_password)) { + return false; + } + + $salt = (string) (isset($user->legacy_salt) ? $user->legacy_salt : ''); + if (!hash_equals((string) $user->legacy_password, md5(md5($password . $salt)))) { + return false; + } + + if ((int) $user->is_active !== 1) { + return false; + } + + $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]); + \App\Adminx\Users\Model::setPassword($user->id, $hash); + + $arr = (array) $user; + $arr['password_hash'] = $hash; + Auth::login($arr, $options); + + return true; + } + + /** POST /logout — выход (ajax + CSRF). */ + public function logout(array $params = []) + { + if (($err = $this->csrfGuard()) !== null) { + return $err; + } + + // Adminx и публичный сайт используют связанную личность. Если оставить + // public-сессию, следующий запрос немедленно восстановит системный вход. + ReAuth::forget(); + Auth::publicLogout(); + Auth::logout(); + + if ($this->wantsJson()) { + return $this->success('Вы вышли из системы', [ + 'redirect' => $this->base() . '/login', + ]); + } + + $this->redirect($this->base() . '/login'); + return null; + } + } diff --git a/adminx/modules/Auth/language/en/interface.xml b/adminx/modules/Auth/language/en/interface.xml new file mode 100644 index 0000000..603190d --- /dev/null +++ b/adminx/modules/Auth/language/en/interface.xml @@ -0,0 +1,16 @@ + + + Enter your login or email and password. + You are logged out + Access + Failed to login. + Invalid login or password. + Invalid password + Password confirmed + Check your password + sec. + Login session ended + The session is out of date, please refresh the page and try again. + Too many tries. Repeat after + The account has been disabled. + diff --git a/adminx/modules/Auth/language/en/runtime.xml b/adminx/modules/Auth/language/en/runtime.xml new file mode 100644 index 0000000..a30478a --- /dev/null +++ b/adminx/modules/Auth/language/en/runtime.xml @@ -0,0 +1,16 @@ + + + Enter your login or email and password. + You are logged out + Access + Failed to login. + Invalid login or password. + Invalid password + Password confirmed + Check your password + Login session ended + The session is out of date, please refresh the page and try again. + Too many tries. Repeat after + The account has been disabled. + sec. + diff --git a/adminx/modules/Auth/language/ru/interface.xml b/adminx/modules/Auth/language/ru/interface.xml new file mode 100644 index 0000000..1fe9bf9 --- /dev/null +++ b/adminx/modules/Auth/language/ru/interface.xml @@ -0,0 +1,16 @@ + + + Введите логин или email и пароль. + Вы вышли из системы + Доступ + Не удалось войти. + Неверный логин или пароль. + Неверный пароль + Пароль подтверждён + Проверьте пароль + сек. + Сессия входа завершена + Сессия устарела, обновите страницу и попробуйте снова. + Слишком много попыток. Повторите через + Учётная запись отключена. + diff --git a/adminx/modules/Auth/language/ru/runtime.xml b/adminx/modules/Auth/language/ru/runtime.xml new file mode 100644 index 0000000..d3ce48c --- /dev/null +++ b/adminx/modules/Auth/language/ru/runtime.xml @@ -0,0 +1,16 @@ + + + Введите логин или email и пароль. + Вы вышли из системы + Доступ + Не удалось войти. + Неверный логин или пароль. + Неверный пароль + Пароль подтверждён + Проверьте пароль + Сессия входа завершена + Сессия устарела, обновите страницу и попробуйте снова. + Слишком много попыток. Повторите через + Учётная запись отключена. + сек. + diff --git a/adminx/modules/Auth/module.php b/adminx/modules/Auth/module.php new file mode 100644 index 0000000..455dd8b --- /dev/null +++ b/adminx/modules/Auth/module.php @@ -0,0 +1,40 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + /** + * Модуль доступа: экран логина на теме AdminKit + logout. + * + * Роут /login публичный (доступен без авторизации); /logout требует входа и + * работает через POST + CSRF (ajax-контракт success/error). + */ + return [ + 'code' => 'auth', + 'name' => 'Доступ', + 'version' => '0.1.0', + + 'public_routes' => [ + '/login', + '/locale', + ], + + 'routes' => array( + array('GET', '/login', array(\App\Adminx\Auth\Controller::class, 'form')), + array('POST', '/login', array(\App\Adminx\Auth\Controller::class, 'login')), + array('POST', '/locale', array(\App\Adminx\Auth\Controller::class, 'language')), + array('POST', '/reauth', array(\App\Adminx\Auth\Controller::class, 'reauth'), array('permission' => 'admin_panel')), + array('POST', '/logout', array(\App\Adminx\Auth\Controller::class, 'logout')), + ), + ]; diff --git a/adminx/modules/Auth/view/login.twig b/adminx/modules/Auth/view/login.twig new file mode 100644 index 0000000..46db72a --- /dev/null +++ b/adminx/modules/Auth/view/login.twig @@ -0,0 +1,43 @@ +{% extends '@adminx/auth.twig' %} +{% block title %}{{ lang.app_login_title|default('Вход в админпанель') }}{% endblock %} +{% block content %} +
+
+
+

+

{{ lang.app_login_subtitle|default('Вход в панель управления') }}

+
+ + {% if error %} + + {% endif %} + +
+ +
+ + +
+
+ + +
+ + +
+
+
+ {% for locale_code, locale_label in admin_locales %} +
+ + + + +
+ {% endfor %} +
+
AVE.cms · /adminx
+
+{% endblock %} diff --git a/adminx/modules/Blocks/Controller.php b/adminx/modules/Blocks/Controller.php new file mode 100644 index 0000000..98ce860 --- /dev/null +++ b/adminx/modules/Blocks/Controller.php @@ -0,0 +1,467 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Blocks; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Adminx\Support\CodeEditor; + use App\Common\AdminAssets; + use App\Common\AuditLog; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Helpers\Request; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + if (!Permission::check('view_blocks')) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); + } + + AdminAssets::addStyle($this->base() . '/modules/Blocks/assets/blocks.css', 50); + AdminAssets::addScript($this->base() . '/modules/Blocks/assets/blocks.js', 50); + CodeEditor::useRichEditor(); + + $q = Request::getStr('q', ''); + $group = Request::getInt('group', 0); + $state = Request::getStr('state', ''); + $state = in_array($state, array('', 'active', 'inactive', 'external', 'ajax', 'visual'), true) ? $state : ''; + $blocks = Model::all($q, $group, $state); + $groups = Model::groups(); + + return $this->render('@blocks/index.twig', array( + 'blocks' => $blocks, + 'block_groups' => Model::groupedBlocks($blocks, $groups), + 'groups' => $groups, + 'group_map' => Model::groupMap(), + 'stats' => Model::stats(), + 'filters' => array('q' => $q, 'group' => $group, 'state' => $state), + 'can_manage' => Permission::check('manage_blocks'), + 'can_manage_php' => Permission::check('manage_php_blocks'), + 'initial_tab' => Request::getStr('tab', '') === 'groups' ? 'groups' : 'blocks', + )); + } + + public function show(array $params = array()) + { + $item = Model::one(isset($params['id']) ? (int) $params['id'] : 0); + if (!$item) { + return $this->error('Системный блок не найден', array(), 404); + } + if (($err = $this->guardPhpItem($item)) !== null) { return $err; } + + return $this->success('', array('data' => $item)); + } + + public function aliasCheck(array $params = array()) + { + if (!Permission::check('manage_blocks')) { + return $this->error('Недостаточно прав', array(), 403); + } + + $alias = trim(Request::getStr('alias', '')); + $id = Request::getInt('id', 0); + $valid = true; + $available = true; + $message = 'Алиас свободен'; + + if ($alias === '') { + $valid = false; + $available = false; + $message = 'Укажите алиас'; + } elseif (preg_match('/^[A-Za-z0-9-_]{1,20}$/i', $alias) !== 1 || is_numeric($alias)) { + $valid = false; + $available = false; + $message = 'Только A-Z, 0-9, дефис и подчёркивание, до 20 символов; не число'; + } elseif (Model::aliasExists($alias, $id)) { + $available = false; + $message = 'Такой алиас уже используется'; + } + + return $this->success($message, array( + 'data' => array( + 'alias' => $alias, + 'id' => $id, + 'valid' => $valid, + 'available' => $available, + ), + )); + } + + public function store(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $input = Request::postAll(); + if (($err = $this->guardPhpInput($input)) !== null) { return $err; } + $errors = $this->validate($input, 0); + if (!empty($errors)) { + return $this->error('Проверьте поля формы', $errors); + } + + $id = Model::save(0, $input, Auth::id()); + $this->audit('block.created', $id, array('editor' => $this->editor($input), 'php' => $this->editor($input) === 'php')); + return $this->success('Системный блок создан', array( + 'data' => array('id' => $id), + 'redirect' => $this->base() . '/blocks', + )); + } + + public function update(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $id = isset($params['id']) ? (int) $params['id'] : 0; + $before = Model::one($id); + if (!$before) { + return $this->error('Системный блок не найден', array(), 404); + } + if (($err = $this->guardPhpItem($before)) !== null) { return $err; } + + $input = Request::postAll(); + if (($err = $this->guardPhpInput($input)) !== null) { return $err; } + $errors = $this->validate($input, $id); + if (!empty($errors)) { + return $this->error('Проверьте поля формы', $errors); + } + + Model::save($id, $input, Auth::id()); + $this->audit('block.updated', $id, array( + 'editor_before' => (string) $before['sysblock_editor'], + 'editor_after' => $this->editor($input), + 'code_changed' => !hash_equals(hash('sha256', (string) $before['sysblock_text']), hash('sha256', isset($input['sysblock_text']) ? (string) $input['sysblock_text'] : '')), + )); + return $this->success('Системный блок сохранён', array('redirect' => $this->base() . '/blocks')); + } + + public function copy(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $source = Model::one(isset($params['id']) ? (int) $params['id'] : 0); + if (!$source) { return $this->error('Системный блок не найден', array(), 404); } + if (($err = $this->guardPhpItem($source)) !== null) { return $err; } + try { + $newId = Model::copy((int) $source['id'], Request::postStr('name', ''), Auth::id()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + $this->audit('block.copied', $newId, array('source_id' => (int) $source['id'], 'editor' => (string) $source['sysblock_editor'])); + + return $this->success('Копия создана', array('redirect' => $this->base() . '/blocks')); + } + + public function revisions(array $params = array()) + { + $id = isset($params['id']) ? (int) $params['id'] : 0; + $item = Model::one($id); + if (!$item) { + return $this->error('Системный блок не найден', array(), 404); + } + if (($err = $this->guardPhpItem($item)) !== null) { return $err; } + + return $this->success('', array( + 'data' => array( + 'block' => array( + 'id' => $item['id'], + 'name' => $item['sysblock_name'], + 'alias' => $item['sysblock_alias'], + ), + 'revisions' => Revisions::listForBlock($id), + ), + )); + } + + public function revision(array $params = array()) + { + $revision = Revisions::one(isset($params['revision']) ? (int) $params['revision'] : 0); + if (!$revision) { + return $this->error('Ревизия не найдена', array(), 404); + } + if (($err = $this->guardPhpSnapshot(isset($revision['snapshot']) ? $revision['snapshot'] : array())) !== null) { return $err; } + + return $this->success('', array('data' => $revision)); + } + + public function restoreRevision(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $revisionId = isset($params['revision']) ? (int) $params['revision'] : 0; + $revision = Revisions::one($revisionId); + if (!$revision) { return $this->error('Ревизия не найдена', array(), 404); } + if (($err = $this->guardPhpSnapshot(isset($revision['snapshot']) ? $revision['snapshot'] : array())) !== null) { return $err; } + try { + $blockId = Revisions::restore($revisionId, Auth::id()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + $this->audit('block.revision_restored', $blockId, array('revision_id' => $revisionId)); + + return $this->success('Блок восстановлен из ревизии', array( + 'data' => array('id' => $blockId), + 'redirect' => $this->base() . '/blocks', + )); + } + + public function deleteRevision(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $revisionId = isset($params['revision']) ? (int) $params['revision'] : 0; + $revision = Revisions::one($revisionId); + if (!$revision) { return $this->error('Ревизия не найдена', array(), 404); } + if (($err = $this->guardPhpSnapshot(isset($revision['snapshot']) ? $revision['snapshot'] : array())) !== null) { return $err; } + $blockId = Revisions::delete($revisionId); + if (!$blockId) { + return $this->error('Ревизия не найдена', array(), 404); + } + + return $this->success('Ревизия удалена', array('data' => array('id' => $blockId))); + } + + public function deleteRevisions(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $id = isset($params['id']) ? (int) $params['id'] : 0; + $item = Model::one($id); + if (!$item) { + return $this->error('Системный блок не найден', array(), 404); + } + if (($err = $this->guardPhpItem($item)) !== null) { return $err; } + + $count = Revisions::deleteForBlock($id); + return $this->success('Ревизии удалены', array('data' => array('id' => $id, 'count' => $count))); + } + + public function lint(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $result = Syntax::check(Request::postStr('sysblock_text', ''), Request::postBool('sysblock_eval', false) ? '1' : '0'); + if (!$result['ok']) { + return $this->error($result['message'], array('sysblock_text' => $result['output']), 422); + } + + return $this->success($result['message'], array('data' => $result)); + } + + public function clearCache(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $item = Model::one(isset($params['id']) ? (int) $params['id'] : 0); + if (!$item) { + return $this->error('Системный блок не найден', array(), 404); + } + + Model::clearCache($item['id'], $item['sysblock_alias']); + return $this->success('Кеш системного блока очищен'); + } + + public function destroy(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $id = isset($params['id']) ? (int) $params['id'] : 0; + $item = Model::one($id); + if (!$item) { return $this->error('Системный блок не найден', array(), 404); } + if (($err = $this->guardPhpItem($item)) !== null) { return $err; } + if (!Model::delete($id, Auth::id())) { + return $this->error('Системный блок не найден', array(), 404); + } + $this->audit('block.deleted', $id, array('editor' => (string) $item['sysblock_editor'])); + + return $this->success('Системный блок удалён'); + } + + public function group(array $params = array()) + { + $item = Model::group(isset($params['id']) ? (int) $params['id'] : 0); + if (!$item) { + return $this->error('Группа не найдена', array(), 404); + } + + return $this->success('', array('data' => $item)); + } + + public function storeGroup(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $id = Model::saveGroup(0, Request::postAll()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Группа создана', array('data' => array('id' => $id), 'redirect' => $this->base() . '/blocks')); + } + + public function updateGroup(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + Model::saveGroup(isset($params['id']) ? (int) $params['id'] : 0, Request::postAll()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Группа сохранена', array('redirect' => $this->base() . '/blocks')); + } + + public function reorderGroups(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $order = Request::post('order', array()); + $ids = is_array($order) ? $order : array(); + $count = Model::reorderGroups($ids); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Порядок групп сохранён', array('data' => array('count' => $count))); + } + + public function destroyGroup(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + if (!Model::deleteGroup(isset($params['id']) ? (int) $params['id'] : 0)) { + return $this->error('Группа не найдена', array(), 404); + } + + return $this->success('Группа удалена', array('redirect' => $this->base() . '/blocks')); + } + + protected function guard() + { + return $this->guardPermission('manage_blocks'); + } + + protected function guardPhpInput(array $input) + { + return $this->editor($input) === 'php' && !Permission::check('manage_php_blocks') + ? $this->error('Недостаточно прав для изменения исполняемого PHP-блока', array(), 403) + : null; + } + + protected function guardPhpItem(array $item) + { + return isset($item['sysblock_editor']) && (string) $item['sysblock_editor'] === 'php' + ? $this->guardPhpInput(array('sysblock_editor' => 'php')) + : null; + } + + protected function guardPhpSnapshot($snapshot) + { + return is_array($snapshot) ? $this->guardPhpItem($snapshot) : null; + } + + protected function editor(array $input) + { + $editor = isset($input['sysblock_editor']) ? (string) $input['sysblock_editor'] : ''; + return in_array($editor, array('php', 'html', 'rich', 'text'), true) ? $editor : 'html'; + } + + protected function audit($action, $targetId, array $meta) + { + $user = Auth::user(); + AuditLog::record($action, array( + 'actor_id' => Auth::id(), + 'actor_name' => $user && isset($user['name']) ? (string) $user['name'] : '', + 'target_type' => 'block', + 'target_id' => (int) $targetId, + 'meta' => $meta, + )); + } + + protected function validate(array $input, $id) + { + $errors = array(); + $name = trim(isset($input['sysblock_name']) ? (string) $input['sysblock_name'] : ''); + $alias = trim(isset($input['sysblock_alias']) ? (string) $input['sysblock_alias'] : ''); + + if ($name === '') { + $errors['sysblock_name'] = 'Укажите название'; + } + + $aliasState = $this->validateAlias($alias, (int) $id); + if (!$aliasState['valid'] || !$aliasState['available']) { + $errors['sysblock_alias'] = $aliasState['message']; + } + + $syntax = Syntax::check( + isset($input['sysblock_text']) ? (string) $input['sysblock_text'] : '', + !empty($input['sysblock_eval']) ? '1' : '0' + ); + if (!empty($syntax['checked']) && empty($syntax['ok'])) { + $errors['sysblock_text'] = $syntax['line'] > 0 + ? 'PHP-синтаксис: ошибка на строке ' . (int) $syntax['line'] + : 'PHP-синтаксис: ошибка в коде блока'; + } + + return $errors; + } + + protected function validateAlias($alias, $id) + { + if ($alias === '') { + return array('valid' => false, 'available' => false, 'message' => 'Укажите алиас'); + } + + if (preg_match('/^[A-Za-z0-9-_]{1,20}$/i', $alias) !== 1 || is_numeric($alias)) { + return array('valid' => false, 'available' => false, 'message' => 'Только A-Z, 0-9, дефис и подчёркивание, до 20 символов; не число'); + } + + if (Model::aliasExists($alias, (int) $id)) { + return array('valid' => true, 'available' => false, 'message' => 'Такой алиас уже используется'); + } + + return array('valid' => true, 'available' => true, 'message' => 'Алиас свободен'); + } + } diff --git a/adminx/modules/Blocks/Model.php b/adminx/modules/Blocks/Model.php new file mode 100644 index 0000000..1bf9f38 --- /dev/null +++ b/adminx/modules/Blocks/Model.php @@ -0,0 +1,525 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Blocks; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Common\FileCacheInvalidator; + use App\Common\SystemTables; + use App\Content\ContentTables; + + class Model + { + public static function table() + { + return ContentTables::table('sysblocks'); + } + + public static function groupsTable() + { + return ContentTables::table('sysblocks_groups'); + } + + public static function all($q = '', $groupId = 0, $state = '') + { + $sql = 'SELECT b.*, g.title AS group_title, u.name AS author_name, u.login AS author_login, u.email AS author_email' + . ' FROM ' . self::table() . ' b' + . ' LEFT JOIN ' . self::groupsTable() . ' g ON g.id = b.sysblock_group_id' + . ' LEFT JOIN ' . SystemTables::table('users') . ' u ON u.id = b.sysblock_author_id' + . ' WHERE 1=1'; + $args = array(); + + $q = trim((string) $q); + if ($q !== '') { + $sql .= ' AND (b.sysblock_name LIKE %ss OR b.sysblock_alias LIKE %ss OR b.sysblock_description LIKE %ss OR b.sysblock_text LIKE %ss)'; + $args[] = $q; + $args[] = $q; + $args[] = $q; + $args[] = $q; + } + + $groupId = (int) $groupId; + if ($groupId > 0) { + $sql .= ' AND b.sysblock_group_id = %i'; + $args[] = $groupId; + } + + if ($state === 'active') { + $sql .= ' AND b.sysblock_active = %s'; + $args[] = '1'; + } elseif ($state === 'inactive') { + $sql .= ' AND b.sysblock_active = %s'; + $args[] = '0'; + } elseif ($state === 'external') { + $sql .= ' AND b.sysblock_external = %s'; + $args[] = '1'; + } elseif ($state === 'ajax') { + $sql .= ' AND b.sysblock_ajax = %s'; + $args[] = '1'; + } elseif ($state === 'visual') { + $sql .= ' AND b.sysblock_visual = %s'; + $args[] = '1'; + } + + $sql .= ' ORDER BY COALESCE(g.position, 9999) ASC, b.id ASC'; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = self::row($row); + } + + return $out; + } + + public static function one($id) + { + $row = DB::query('SELECT * FROM ' . self::table() . ' WHERE id = %i LIMIT 1', (int) $id)->getAssoc(); + return $row ? self::row($row) : null; + } + + public static function raw($id) + { + $row = DB::query('SELECT * FROM ' . self::table() . ' WHERE id = %i LIMIT 1', (int) $id)->getAssoc(); + return $row ? $row : null; + } + + public static function revisionFields() + { + return array( + 'id', + 'sysblock_group_id', + 'sysblock_name', + 'sysblock_description', + 'sysblock_alias', + 'sysblock_text', + 'sysblock_active', + 'sysblock_eval', + 'sysblock_external', + 'sysblock_ajax', + 'sysblock_visual', + 'sysblock_editor', + 'sysblock_author_id', + 'sysblock_created', + ); + } + + public static function snapshot(array $row) + { + $out = array(); + foreach (self::revisionFields() as $field) { + $out[$field] = isset($row[$field]) ? $row[$field] : ''; + } + + $out['id'] = (int) $out['id']; + $out['sysblock_group_id'] = (int) $out['sysblock_group_id']; + $out['sysblock_author_id'] = (int) $out['sysblock_author_id']; + $out['sysblock_created'] = (int) $out['sysblock_created']; + if ($out['sysblock_editor'] === '') { + $out['sysblock_editor'] = self::legacyEditorMode($out); + } + + return $out; + } + + public static function applySnapshot($id, array $snapshot, $authorId) + { + $id = (int) $id; + $current = self::raw($id); + if (!$current) { + throw new \RuntimeException('Системный блок не найден'); + } + + $data = self::snapshot($snapshot); + unset($data['id']); + $data['sysblock_author_id'] = (int) $authorId > 0 ? (int) $authorId : (int) $data['sysblock_author_id']; + DB::Update(self::table(), $data, 'id = %i', $id); + self::clearCache($id, isset($current['sysblock_alias']) ? $current['sysblock_alias'] : ''); + self::clearCache($id, isset($data['sysblock_alias']) ? $data['sysblock_alias'] : ''); + return $id; + } + + public static function groups() + { + $rows = DB::query( + 'SELECT g.*, COUNT(b.id) AS blocks_count' + . ' FROM ' . self::groupsTable() . ' g' + . ' LEFT JOIN ' . self::table() . ' b ON b.sysblock_group_id = g.id' + . ' GROUP BY g.id, g.position, g.title, g.description' + . ' ORDER BY g.position ASC, g.id ASC' + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = array( + 'id' => (int) $row['id'], + 'position' => (int) $row['position'], + 'title' => (string) $row['title'], + 'description' => (string) $row['description'], + 'blocks_count' => (int) $row['blocks_count'], + ); + } + + return $out; + } + + public static function groupMap() + { + $out = array(); + foreach (self::groups() as $group) { + $out[$group['id']] = $group; + } + + return $out; + } + + public static function groupedBlocks(array $blocks, array $groups) + { + $out = array(); + foreach ($groups as $group) { + $out[(int) $group['id']] = array( + 'id' => (int) $group['id'], + 'title' => (string) $group['title'], + 'description' => (string) $group['description'], + 'position' => (int) $group['position'], + 'items' => array(), + ); + } + + $out[0] = array( + 'id' => 0, + 'title' => 'Без группы', + 'description' => 'Блоки без привязки к группе.', + 'position' => 99999, + 'items' => array(), + ); + + foreach ($blocks as $block) { + $id = isset($out[(int) $block['sysblock_group_id']]) ? (int) $block['sysblock_group_id'] : 0; + $out[$id]['items'][] = $block; + } + + $out = array_filter($out, function ($group) { + return !empty($group['items']); + }); + + uasort($out, function ($a, $b) { + if ((int) $a['position'] === (int) $b['position']) { + return strcmp((string) $a['title'], (string) $b['title']); + } + + return (int) $a['position'] < (int) $b['position'] ? -1 : 1; + }); + + return array_values($out); + } + + public static function group($id) + { + $row = DB::query('SELECT * FROM ' . self::groupsTable() . ' WHERE id = %i LIMIT 1', (int) $id)->getAssoc(); + if (!$row) { + return null; + } + + return array( + 'id' => (int) $row['id'], + 'position' => (int) $row['position'], + 'title' => (string) $row['title'], + 'description' => (string) $row['description'], + ); + } + + public static function stats() + { + return array( + 'total' => (int) DB::query('SELECT COUNT(*) FROM ' . self::table())->getValue(), + 'active' => (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE sysblock_active = %s', '1')->getValue(), + 'external' => (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE sysblock_external = %s', '1')->getValue(), + 'ajax' => (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE sysblock_ajax = %s', '1')->getValue(), + 'groups' => (int) DB::query('SELECT COUNT(*) FROM ' . self::groupsTable())->getValue(), + ); + } + + public static function aliasExists($alias, $excludeId = 0) + { + return (bool) DB::query( + 'SELECT id FROM ' . self::table() . ' WHERE sysblock_alias = %s AND id != %i LIMIT 1', + (string) $alias, + (int) $excludeId + )->getValue(); + } + + public static function save($id, array $input, $authorId) + { + $data = self::input($input); + if ((int) $id > 0) { + DB::Update(self::table(), $data, 'id = %i', (int) $id); + self::clearCache((int) $id, $data['sysblock_alias']); + Revisions::capture((int) $id, 'update', (int) $authorId, 'Сохранение блока'); + return (int) $id; + } + + $data['sysblock_author_id'] = (int) $authorId > 0 ? (int) $authorId : 1; + $data['sysblock_created'] = time(); + DB::Insert(self::table(), $data); + $newId = (int) DB::insertId(); + Revisions::capture($newId, 'create', (int) $authorId, 'Создание блока'); + return $newId; + } + + public static function delete($id, $authorId = 0) + { + $raw = self::raw($id); + if (!$raw) { + return false; + } + + Revisions::capture((int) $id, 'delete', (int) $authorId, 'Удаление блока', $raw); + $row = self::row($raw); + if (!$row) { + return false; + } + + DB::Delete(self::table(), 'id = %i', (int) $id); + self::clearCache((int) $id, $row['sysblock_alias']); + return true; + } + + public static function copy($id, $name, $authorId) + { + $row = self::one($id); + if (!$row) { + throw new \RuntimeException('Системный блок не найден'); + } + + $name = trim((string) $name); + if ($name === '') { + $name = $row['sysblock_name'] . ' (копия)'; + } + + $alias = self::uniqueAlias($row['sysblock_alias'] . '-copy'); + $data = array( + 'sysblock_group_id' => (int) $row['sysblock_group_id'], + 'sysblock_name' => $name, + 'sysblock_description' => (string) $row['sysblock_description'], + 'sysblock_alias' => $alias, + 'sysblock_text' => (string) $row['sysblock_text'], + 'sysblock_active' => '0', + 'sysblock_eval' => (string) $row['sysblock_eval'], + 'sysblock_external' => (string) $row['sysblock_external'], + 'sysblock_ajax' => (string) $row['sysblock_ajax'], + 'sysblock_visual' => (string) $row['sysblock_visual'], + 'sysblock_editor' => isset($row['sysblock_editor']) ? (string) $row['sysblock_editor'] : self::legacyEditorMode($row), + 'sysblock_author_id' => (int) $authorId > 0 ? (int) $authorId : 1, + 'sysblock_created' => time(), + ); + DB::Insert(self::table(), $data); + $newId = (int) DB::insertId(); + Revisions::capture($newId, 'copy', (int) $authorId, 'Копия блока #' . (int) $id); + return $newId; + } + + public static function saveGroup($id, array $input) + { + $title = trim(isset($input['title']) ? (string) $input['title'] : ''); + if ($title === '') { + throw new \RuntimeException('Укажите название группы'); + } + + $data = array( + 'title' => $title, + 'description' => trim(isset($input['description']) ? (string) $input['description'] : ''), + ); + if ((int) $id <= 0) { + $data['position'] = (int) DB::query('SELECT COALESCE(MAX(position), 0) + 1 FROM ' . self::groupsTable())->getValue(); + } + + if ((int) $id > 0) { + DB::Update(self::groupsTable(), $data, 'id = %i', (int) $id); + return (int) $id; + } + + DB::Insert(self::groupsTable(), $data); + return (int) DB::insertId(); + } + + public static function reorderGroups(array $ids) + { + $clean = array(); + foreach ($ids as $id) { + $id = (int) $id; + if ($id > 0 && !in_array($id, $clean, true)) { + $clean[] = $id; + } + } + + if (empty($clean)) { + throw new \RuntimeException('Не передан порядок групп'); + } + + $position = 1; + foreach ($clean as $id) { + DB::Update(self::groupsTable(), array('position' => $position), 'id = %i', $id); + $position++; + } + + return count($clean); + } + + public static function deleteGroup($id) + { + $id = (int) $id; + if ($id <= 0) { + return false; + } + + DB::Update(self::table(), array('sysblock_group_id' => 0), 'sysblock_group_id = %i', $id); + DB::Delete(self::groupsTable(), 'id = %i', $id); + return true; + } + + public static function clearCache($id, $alias = '') + { + FileCacheInvalidator::sysblock($id, $alias); + } + + protected static function input(array $input) + { + $editor = isset($input['sysblock_editor']) + ? self::editorMode($input['sysblock_editor']) + : self::legacyEditorMode($input); + + return array( + 'sysblock_group_id' => (int) (isset($input['sysblock_group_id']) ? $input['sysblock_group_id'] : 0), + 'sysblock_name' => trim(isset($input['sysblock_name']) ? (string) $input['sysblock_name'] : ''), + 'sysblock_description' => trim(isset($input['sysblock_description']) ? (string) $input['sysblock_description'] : ''), + 'sysblock_alias' => trim(isset($input['sysblock_alias']) ? (string) $input['sysblock_alias'] : ''), + 'sysblock_text' => isset($input['sysblock_text']) ? (string) $input['sysblock_text'] : '', + 'sysblock_active' => !empty($input['sysblock_active']) ? '1' : '0', + 'sysblock_eval' => $editor === 'php' ? '1' : '0', + 'sysblock_external' => !empty($input['sysblock_external']) ? '1' : '0', + 'sysblock_ajax' => !empty($input['sysblock_ajax']) ? '1' : '0', + 'sysblock_visual' => $editor === 'rich' ? '1' : '0', + 'sysblock_editor' => $editor, + ); + } + + protected static function row(array $row) + { + $author = ''; + if (!empty($row['author_name'])) { + $author = (string) $row['author_name']; + } elseif (!empty($row['author_login'])) { + $author = '@' . (string) $row['author_login']; + } elseif (!empty($row['author_email'])) { + $author = (string) $row['author_email']; + } + + $row['id'] = (int) $row['id']; + $row['sysblock_group_id'] = (int) $row['sysblock_group_id']; + $row['sysblock_author_id'] = (int) $row['sysblock_author_id']; + $row['sysblock_created'] = (int) $row['sysblock_created']; + $row['author'] = $author !== '' ? $author : ('#' . $row['sysblock_author_id']); + $row['group_title'] = isset($row['group_title']) && $row['group_title'] !== null ? $row['group_title'] : 'Без группы'; + $row['created_label'] = $row['sysblock_created'] > 0 ? date('d.m.Y H:i', $row['sysblock_created']) : '-'; + $row['text_size'] = strlen((string) $row['sysblock_text']); + $row['text_size_label'] = self::formatBytes($row['text_size']); + $row['sysblock_editor'] = isset($row['sysblock_editor']) && $row['sysblock_editor'] !== '' + ? self::editorMode($row['sysblock_editor']) + : self::legacyEditorMode($row); + return $row; + } + + protected static function editorMode($mode) + { + $mode = strtolower(trim((string) $mode)); + return in_array($mode, array('php', 'html', 'rich', 'text'), true) ? $mode : 'html'; + } + + protected static function legacyEditorMode(array $row) + { + if (!empty($row['sysblock_eval'])) { + return 'php'; + } + + return !empty($row['sysblock_visual']) ? 'rich' : 'html'; + } + + protected static function uniqueAlias($base) + { + $base = preg_replace('/[^A-Za-z0-9_-]+/', '-', (string) $base); + $base = trim($base, '-_'); + if ($base === '' || is_numeric($base)) { + $base = 'sysblock'; + } + + $base = substr($base, 0, 20); + $alias = $base; + $i = 2; + while (self::aliasExists($alias, 0)) { + $suffix = '-' . $i; + $alias = substr($base, 0, 20 - strlen($suffix)) . $suffix; + $i++; + } + + return $alias; + } + + protected static function formatBytes($bytes) + { + $bytes = (int) $bytes; + if ($bytes < 1024) { + return $bytes . ' Б'; + } + + if ($bytes < 1048576) { + return round($bytes / 1024, 1) . ' KB'; + } + + return round($bytes / 1048576, 1) . ' MB'; + } + + protected static function removeDir($dir) + { + if (!is_dir($dir)) { + return; + } + + $base = realpath(BASEPATH . '/tmp/cache/sql/sysblocks'); + $real = realpath($dir); + if (!$base || !$real || strpos($real, $base) !== 0) { + return; + } + + $items = @scandir($real); + if ($items === false) { + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $real . DIRECTORY_SEPARATOR . $item; + if (is_dir($path)) { + self::removeDir($path); + } elseif (is_file($path)) { + @unlink($path); + } + } + + @rmdir($real); + } + } diff --git a/adminx/modules/Blocks/Revisions.php b/adminx/modules/Blocks/Revisions.php new file mode 100644 index 0000000..7789f0c --- /dev/null +++ b/adminx/modules/Blocks/Revisions.php @@ -0,0 +1,241 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Blocks; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Common\SystemTables; + use App\Content\ContentTables; + use App\Helpers\Json; + + class Revisions + { + public static function table() + { + return ContentTables::table('sysblock_revisions'); + } + + public static function labels() + { + return array( + 'create' => array('label' => 'Создание', 'badge' => 'badge-green'), + 'update' => array('label' => 'Сохранение', 'badge' => 'badge-blue'), + 'copy' => array('label' => 'Копия', 'badge' => 'badge-violet'), + 'import' => array('label' => 'Импорт', 'badge' => 'badge-amber'), + 'restore' => array('label' => 'Восстановление', 'badge' => 'badge-cyan'), + 'restore_backup' => array('label' => 'Перед восстановлением', 'badge' => 'badge-gray'), + 'delete' => array('label' => 'Удаление', 'badge' => 'badge-red'), + ); + } + + public static function listForBlock($blockId, $limit = 50) + { + $rows = DB::query( + 'SELECT * FROM ' . self::table() . ' WHERE block_id = %i ORDER BY created_at DESC, id DESC LIMIT %i', + (int) $blockId, + max(1, min(200, (int) $limit)) + )->getAll(); + + $out = array(); + foreach ($rows as $row) { + $out[] = self::format($row, false); + } + + return $out; + } + + public static function one($id) + { + $row = DB::query('SELECT * FROM ' . self::table() . ' WHERE id = %i LIMIT 1', (int) $id)->getAssoc(); + return $row ? self::format($row, true) : null; + } + + public static function capture($blockId, $action, $authorId = 0, $comment = '', array $snapshot = null, $sourceRevisionId = 0) + { + $blockId = (int) $blockId; + if ($snapshot === null) { + $snapshot = Model::raw($blockId); + } + + if (!$snapshot) { + return 0; + } + + $snapshot = Model::snapshot($snapshot); + $json = Json::encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + $json = '{}'; + } + + $snapshotHash = sha1($json); + $textHash = sha1((string) $snapshot['sysblock_text']); + + if (in_array($action, array('update', 'import', 'restore'), true)) { + $last = DB::query( + 'SELECT snapshot_hash FROM ' . self::table() . ' WHERE block_id = %i ORDER BY created_at DESC, id DESC LIMIT 1', + $blockId + )->getValue(); + if ($last && (string) $last === $snapshotHash) { + return 0; + } + } + + DB::Insert(self::table(), array( + 'block_id' => $blockId, + 'action' => (string) $action, + 'snapshot_hash' => $snapshotHash, + 'text_hash' => $textHash, + 'snapshot_json' => $json, + 'comment' => trim((string) $comment), + 'author_id' => (int) $authorId, + 'author_name' => self::authorName((int) $authorId), + 'source_revision_id' => (int) $sourceRevisionId, + 'created_at' => time(), + )); + + return (int) DB::insertId(); + } + + public static function captureCurrent($action, $authorId = 0, $comment = '') + { + $rows = DB::query('SELECT * FROM ' . Model::table() . ' ORDER BY id ASC')->getAll(); + $count = 0; + foreach ($rows as $row) { + if (self::capture((int) $row['id'], $action, (int) $authorId, $comment, $row) > 0) { + $count++; + } + } + + return $count; + } + + public static function restore($revisionId, $authorId = 0) + { + $revision = self::one($revisionId); + if (!$revision || empty($revision['snapshot'])) { + throw new \RuntimeException('Ревизия не найдена'); + } + + $blockId = (int) $revision['block_id']; + $current = Model::raw($blockId); + if (!$current) { + throw new \RuntimeException('Системный блок не найден'); + } + + self::capture($blockId, 'restore_backup', (int) $authorId, 'Снимок перед восстановлением', $current, (int) $revisionId); + Model::applySnapshot($blockId, $revision['snapshot'], (int) $authorId); + self::capture($blockId, 'restore', (int) $authorId, 'Восстановлено из ревизии #' . (int) $revisionId, Model::raw($blockId), (int) $revisionId); + + return $blockId; + } + + public static function delete($revisionId) + { + $revision = self::one($revisionId); + if (!$revision) { + return false; + } + + DB::Delete(self::table(), 'id = %i', (int) $revisionId); + return (int) $revision['block_id']; + } + + public static function deleteForBlock($blockId) + { + $blockId = (int) $blockId; + if ($blockId <= 0) { + return 0; + } + + $count = (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE block_id = %i', $blockId)->getValue(); + DB::Delete(self::table(), 'block_id = %i', $blockId); + return $count; + } + + protected static function format(array $row, $withSnapshot) + { + $labels = self::labels(); + $action = isset($row['action']) ? (string) $row['action'] : 'update'; + $meta = isset($labels[$action]) ? $labels[$action] : array('label' => $action, 'badge' => 'badge-gray'); + $snapshot = null; + if ($withSnapshot) { + $snapshot = Json::toArray((string) $row['snapshot_json']); + } + + $created = isset($row['created_at']) ? (int) $row['created_at'] : 0; + $text = $withSnapshot && is_array($snapshot) && isset($snapshot['sysblock_text']) ? (string) $snapshot['sysblock_text'] : ''; + + return array( + 'id' => (int) $row['id'], + 'block_id' => (int) $row['block_id'], + 'action' => $action, + 'action_label' => $meta['label'], + 'badge' => $meta['badge'], + 'comment' => (string) $row['comment'], + 'author_id' => (int) $row['author_id'], + 'author_name' => (string) $row['author_name'], + 'created_at' => $created, + 'created_label' => $created > 0 ? date('d.m.Y H:i:s', $created) : '-', + 'source_revision_id' => (int) $row['source_revision_id'], + 'snapshot_hash' => (string) $row['snapshot_hash'], + 'text_hash' => (string) $row['text_hash'], + 'text_size' => strlen($text), + 'text_size_label' => $withSnapshot ? self::formatBytes(strlen($text)) : '', + 'snapshot' => $snapshot, + 'code' => $text, + ); + } + + protected static function authorName($id) + { + if ((int) $id <= 0) { + return ''; + } + + $row = DB::query('SELECT name, login, email FROM ' . SystemTables::table('users') . ' WHERE id = %i LIMIT 1', (int) $id)->getAssoc(); + if (!$row) { + return '#' . (int) $id; + } + + if (!empty($row['name'])) { + return (string) $row['name']; + } + + if (!empty($row['login'])) { + return '@' . (string) $row['login']; + } + + if (!empty($row['email'])) { + return (string) $row['email']; + } + + return '#' . (int) $id; + } + + protected static function formatBytes($bytes) + { + $bytes = (int) $bytes; + if ($bytes < 1024) { + return $bytes . ' Б'; + } + + if ($bytes < 1048576) { + return round($bytes / 1024, 1) . ' KB'; + } + + return round($bytes / 1048576, 1) . ' MB'; + } + } diff --git a/adminx/modules/Blocks/Syntax.php b/adminx/modules/Blocks/Syntax.php new file mode 100644 index 0000000..3389800 --- /dev/null +++ b/adminx/modules/Blocks/Syntax.php @@ -0,0 +1,83 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Blocks; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + class Syntax + { + public static function check($code, $eval) + { + if ((string) $eval !== '1') { + return array( + 'ok' => true, + 'checked' => false, + 'message' => 'Проверка PHP не нужна: Eval отключён.', + 'line' => 0, + 'output' => '', + ); + } + + $tmp = tempnam(sys_get_temp_dir(), 'ave-sysblock-'); + if ($tmp === false) { + return self::unavailable('Не удалось создать временный файл для проверки.'); + } + + $file = $tmp . '.php'; + @rename($tmp, $file); + $written = @file_put_contents($file, '?>' . (string) $code . '&1'; + $output = array(); + $status = 0; + @exec($cmd, $output, $status); + @unlink($file); + + $text = trim(implode("\n", $output)); + if ($text === '') { + return self::unavailable('PHP CLI недоступен для проверки синтаксиса.'); + } + + $clean = str_replace($file, 'код блока', $text); + $line = 0; + if (preg_match('/ on line ([0-9]+)/i', $clean, $m)) { + $line = (int) $m[1]; + } + + return array( + 'ok' => (int) $status === 0, + 'checked' => true, + 'message' => (int) $status === 0 ? 'Синтаксис PHP без ошибок.' : 'В PHP-коде есть синтаксическая ошибка.', + 'line' => $line, + 'output' => $clean, + ); + } + + protected static function unavailable($message) + { + return array( + 'ok' => true, + 'checked' => false, + 'message' => $message, + 'line' => 0, + 'output' => '', + ); + } + } diff --git a/adminx/modules/Blocks/assets/blocks.css b/adminx/modules/Blocks/assets/blocks.css new file mode 100644 index 0000000..4daacb0 --- /dev/null +++ b/adminx/modules/Blocks/assets/blocks.css @@ -0,0 +1,741 @@ +.blocks-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 14px; + margin-bottom: 16px; +} +.blocks-panel a, +.blocks-panel a:hover { + text-decoration: none; +} +.blocks-stat { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; +} +.blocks-stat b { + display: block; + font-size: 20px; + line-height: 1.1; + font-weight: 800; + font-variant-numeric: tabular-nums; +} +.blocks-stat span { + font-size: 13px; + color: var(--text-secondary); +} +.blocks-tabs { + margin-bottom: 18px; +} +.blocks-import-card { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; + padding: 15px 16px; +} +.blocks-import-title { + margin-bottom: 3px; + font-weight: 800; + color: var(--text-primary); +} +.blocks-panel-header { + margin-bottom: 18px; +} +.blocks-panel-header h2 { + font-size: 22px; + line-height: 1.2; + font-weight: 800; + letter-spacing: 0; + text-wrap: balance; +} +.blocks-card { + padding: 0; +} +.blocks-filter-card { + margin-bottom: 14px; +} +.blocks-group-stack { + display: grid; + gap: 14px; +} +.blocks-group-card { + overflow: hidden; +} +.blocks-group-card .table-scroll { + border-top: 0; +} +.blocks-panel .table-scroll { + max-height: none; + overflow-x: hidden; + overflow-y: hidden; +} +.blocks-panel table.table { + min-width: 0; +} +.blocks-panel .table th { + white-space: normal; +} +.blocks-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid var(--border-default); +} +.blocks-section-title { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} +.blocks-section-title h2 { + margin: 0 0 4px; + font-size: 16px; + line-height: 1.25; + font-weight: 700; + text-wrap: balance; +} +.blocks-section-title p { + margin: 0; + font-size: 13px; + line-height: 1.4; + text-wrap: pretty; +} +.blocks-head-icon { + width: 34px; + height: 34px; + border-radius: var(--radius-md); + font-size: 17px; +} +.blocks-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.blocks-filter { + display: flex; + align-items: center; + flex: 1 1 auto; + gap: 10px; + min-width: 0; +} +.blocks-filter .input-wrap { + flex: 1 1 320px; + min-width: 240px; + max-width: 520px; +} +.blocks-filter .select { + flex: 0 0 178px; +} +.blocks-table { + width: 100%; + min-width: 0; + table-layout: fixed; +} +.blocks-col-id { + width: 5.5ch; +} +.blocks-col-name { + width: auto; +} +.blocks-col-alias { + width: 250px; +} +.blocks-col-flags { + width: 190px; +} +.blocks-col-meta { + width: 150px; +} +.blocks-col-actions { + width: 194px; +} +.blocks-name { + display: grid; + gap: 3px; + min-width: 0; +} +.blocks-name b, +.blocks-name small { + overflow-wrap: anywhere; + white-space: normal; +} +.blocks-name small { + color: var(--text-secondary); + font-size: 12px; +} +.blocks-tag { + display: grid; + grid-template-columns: minmax(0, 1fr) 18px; + align-items: center; + gap: 8px; + width: auto; + min-width: 0; + max-width: 100%; + padding: 7px 9px; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: var(--background-muted); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; + text-align: left; + cursor: pointer; + transition: background-color 150ms, border-color 150ms, color 150ms, transform 150ms; +} +.blocks-tag span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.blocks-tag i { + font-size: 16px; +} +.blocks-tag:hover { + border-color: var(--border-strong); + background: var(--background-inset); +} +.blocks-tag:active { + transform: scale(0.98); +} +.blocks-badges { + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.blocks-meta { + display: grid; + gap: 2px; + color: var(--text-secondary); + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.blocks-actions { + gap: 4px; + flex-wrap: nowrap; + justify-content: flex-end; + width: max-content; + margin-left: auto; +} +.blocks-actions .btn { + transition-property: background-color, color, transform; + transition-duration: 150ms; +} +.blocks-actions .btn:active, +.blocks-switch:active { + transform: scale(0.96); +} +.blocks-action-edit { + color: var(--blue-600); +} +.blocks-action-copy { + color: var(--violet-600); +} +.blocks-action-revisions { + color: var(--cyan-600); +} +.blocks-action-cache { + color: var(--green-600); +} +.blocks-action-danger { + color: var(--red-600); +} +.blocks-groups-table { + width: 100%; + min-width: 0; + table-layout: fixed; +} +.blocks-groups-table th, +.blocks-groups-table td { + overflow-wrap: anywhere; +} +.blocks-groups-table th:nth-child(2) { + white-space: nowrap; +} +.blocks-group-drag-handle { + position: relative; + color: var(--text-muted); + cursor: grab; + touch-action: none; +} +.blocks-group-drag-handle:hover { + color: var(--text-primary); + background: var(--background-inset); +} +.blocks-group-drag-handle:active { + cursor: grabbing; +} +.blocks-group-drag-handle[data-tooltip-right]::after { + left: calc(100% + 10px); + top: 50%; + transform: translateY(-50%); +} +.blocks-group-row-dragging { + opacity: 0.48; +} +.blocks-groups-table tr.drag-over-top td { + box-shadow: inset 0 3px 0 0 var(--color-primary); +} +.blocks-groups-table tr.drag-over-bottom td { + box-shadow: inset 0 -3px 0 0 var(--color-primary); +} +.blocks-drawer .drawer-body { + padding-bottom: 0; + overflow-x: hidden; +} +.blocks-drawer { + min-width: 0; + overflow-x: hidden; +} +#blockDrawer .drawer-header, +#blockRevisionsDrawer .drawer-header, +#blockGroupDrawer .drawer-header { + align-items: flex-start; +} +#blockRevisionsDrawer { + width: min(66vw, 1320px); + max-width: calc(100vw - 34px); +} +#blockDrawer { + width: min(1040px, 94vw); + overflow-x: hidden; +} +#blockDrawer[hidden], +#blockRevisionsDrawer[hidden], +#blockGroupDrawer[hidden] { + display: none !important; +} +.blocks-drawer-title { + flex: 1 1 auto; + min-width: 0; +} +#blockDrawer .drawer-header [data-close-drawer], +#blockRevisionsDrawer .drawer-header [data-close-drawer], +#blockGroupDrawer .drawer-header [data-close-drawer] { + flex: 0 0 auto; + margin-left: auto; +} +.blocks-revisions-drawer { + display: flex; + flex-direction: column; + padding-bottom: 0; + overflow: hidden; +} +.blocks-revisions-layout { + display: grid; + grid-template-columns: 1fr; + gap: 14px; + align-items: stretch; + min-height: 0; +} +.blocks-revisions-list, +.blocks-revision-preview { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); + overflow: hidden; +} +.blocks-revisions-list .table-scroll, +.blocks-revisions-list-rows { + flex: 1 1 auto; + min-height: 0; + max-height: 320px; + overflow-y: auto; +} +.blocks-revisions-head { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-default); +} +.blocks-revisions-head h4 { + margin: 0 0 3px; + font-size: 15px; + line-height: 1.25; + font-weight: 800; +} +.blocks-revisions-head p { + margin: 0; + font-size: 12px; + line-height: 1.35; +} +.blocks-revisions-table { + width: 100%; + min-width: 0; + table-layout: fixed; +} +.blocks-revisions-table th:last-child, +.blocks-revisions-table td:last-child { + width: 118px; +} +.blocks-revision-row { + cursor: pointer; +} +.blocks-revision-row.is-active { + background: var(--blue-50); +} +.blocks-revision-icon { + width: 30px; + height: 30px; + border-radius: var(--radius-md); + font-size: 14px; +} +.blocks-revision-row-main { + display: grid; + flex: 1 1 auto; + gap: 3px; + min-width: 0; +} +.blocks-revision-row-main b { + display: inline; + margin-right: 6px; +} +.blocks-revision-row-main .badge { + width: max-content; + font-size: 10px; +} +.blocks-revision-delete { + color: var(--red-600); +} +.blocks-revision-link { + display: grid; + gap: 4px; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.blocks-revision-link b, +.blocks-revision-link small { + overflow-wrap: anywhere; +} +.blocks-revision-link b { + font-size: 12px; + line-height: 1.2; +} +.blocks-revision-link small { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.3; +} +.blocks-revision-fields { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 14px 16px 0; +} +.blocks-revision-fields span { + display: grid; + gap: 3px; + min-width: 0; + padding: 10px 12px; + border-radius: var(--radius-md); + background: var(--background-muted); + box-shadow: inset 0 0 0 1px var(--border-default); +} +.blocks-revision-fields b { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.2; + text-transform: uppercase; +} +.blocks-revision-fields em { + color: var(--text-primary); + font-size: 12px; + font-style: normal; + line-height: 1.35; + overflow-wrap: anywhere; +} +.blocks-revision-code { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + padding: 14px 16px 16px; +} +.blocks-revision-code .CodeMirror { + flex: 1 1 auto; + min-height: 360px; +} +.blocks-options { + margin: 14px 0; + padding: 13px; + border-radius: var(--radius-md); + background: var(--background-muted); + box-shadow: inset 0 0 0 1px var(--border-default); +} +.blocks-options-head { + display: flex; + align-items: center; + gap: 10px; + padding: 1px 2px 12px; +} +.blocks-options-head .icon-tile { + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + font-size: 17px; +} +.blocks-options-head h4 { + margin: 0; + font-size: 13.5px; + line-height: 1.3; + letter-spacing: 0; +} +.blocks-options-head p { + margin: 2px 0 0; + color: var(--text-secondary); + font-size: 11.5px; + line-height: 1.4; +} +.blocks-editor-mode { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) minmax(210px, 280px); + align-items: center; + gap: 10px; + margin-bottom: 10px; + padding: 10px; + border-radius: var(--radius-sm); + background: var(--background-surface); + box-shadow: 0 0 0 1px var(--border-default); +} +.blocks-editor-mode > span:nth-child(2) { + display: grid; + gap: 2px; +} +.blocks-editor-mode small { + color: var(--text-secondary); + font-size: 11.5px; +} +.blocks-switch-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.blocks-switch { + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + min-height: 62px; + padding: 10px; + border-radius: var(--radius-sm); + background: var(--background-surface); + color: var(--text-primary); + cursor: pointer; + box-shadow: 0 0 0 1px var(--border-default); + transition-property: background-color, box-shadow, transform; + transition-duration: 150ms; +} +.blocks-switch:hover { + background: color-mix(in srgb, var(--blue-50) 24%, var(--background-surface)); + box-shadow: 0 0 0 1px var(--blue-300), 0 3px 10px rgba(15, 23, 42, 0.05); +} +.blocks-switch:has(input:checked) { + box-shadow: 0 0 0 1px color-mix(in srgb, var(--blue-500) 45%, var(--border-default)); +} +.blocks-switch-icon { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border-radius: var(--radius-sm); + background: var(--switch-bg, var(--background-muted)); + color: var(--switch-fg, var(--text-secondary)); + font-size: 16px; +} +.blocks-switch-copy { + display: grid; + gap: 2px; + min-width: 0; +} +.blocks-switch-copy b { + font-size: 13px; + line-height: 1.25; +} +.blocks-switch-copy small { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.3; + text-wrap: pretty; +} +.blocks-switch-control { + margin-left: 2px; +} +.blocks-switch-control input { + margin: 0; +} +@media (max-width: 1040px) { + .blocks-switch-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 680px) { + .blocks-switch-grid { + grid-template-columns: 1fr; + } + .blocks-editor-mode { + grid-template-columns: 34px minmax(0, 1fr); + } + .blocks-editor-mode .select { + grid-column: 1 / -1; + } +} +.blocks-code-field { + margin-top: 6px; + min-width: 0; + overflow: visible; +} +.blocks-code-editor[hidden], +.blocks-rich-editor[hidden], +.blocks-plain-editor[hidden] { + display: none !important; +} +.blocks-code-editor { + min-width: 0; +} +.blocks-rich-editor { + min-width: 0; +} +.blocks-plain-editor { + min-height: 320px; + resize: vertical; + line-height: 1.55; +} +.blocks-code-field.is-fullscreen .blocks-code-editor, +.blocks-code-field.is-fullscreen .blocks-rich-editor, +.blocks-code-field.is-fullscreen .blocks-plain-editor { + flex: 1 1 auto; + min-height: 0; +} +.blocks-code-field.is-fullscreen .blocks-rich-editor, +.blocks-code-field.is-fullscreen .blocks-rich-editor .rich-editor, +.blocks-code-field.is-fullscreen .blocks-rich-editor .rich-editor-surface { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; +} +.blocks-code-field.is-fullscreen .blocks-rich-editor .rich-editor-content, +.blocks-code-field.is-fullscreen .blocks-rich-editor .rich-editor-prose { + flex: 1 1 auto; + min-height: 0; +} +.blocks-code-field.is-fullscreen .blocks-plain-editor { + height: 100%; + resize: none; +} +.blocks-code-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.blocks-lint-button[hidden] { + display: none; +} +.blocks-code-tools { + display: inline-flex; + align-items: center; + gap: 6px; +} +.blocks-code-tools [data-tooltip]::after { + top: calc(100% + 7px); + bottom: auto; + z-index: 320; + transform: translateX(-50%) translateY(-3px); +} +.blocks-code-tools [data-tooltip]:hover::after, +.blocks-code-tools [data-tooltip]:focus-visible::after { + transform: translateX(-50%) translateY(0); +} +.blocks-lint-button { + color: var(--amber-600); +} +.blocks-code-fullscreen-button { + color: var(--blue-600); +} +.blocks-code-field [data-block-lint-result].is-ok { + color: var(--green-600); +} +.blocks-code-field [data-block-lint-result].is-error { + color: var(--red-600); +} +.blocks-drawer [data-alias-hint].is-ok { + color: var(--green-600); +} +.blocks-drawer [data-alias-hint].is-checking { + color: var(--text-secondary); +} +.blocks-code-field .CodeMirror { + width: 100%; + max-width: 100%; + min-height: 520px; +} +.blocks-code-field .CodeMirror-scroll, +.blocks-code-field .CodeMirror-sizer { + max-width: 100%; +} +.blocks-code-field.is-fullscreen { + position: fixed; + inset: 16px; + z-index: 260; + display: flex; + flex-direction: column; + margin: 0; + padding: 16px; + border-radius: var(--radius-lg); + background: var(--background-surface); + box-shadow: var(--shadow-modal); +} +.blocks-code-field.is-fullscreen .blocks-code-label { + flex: 0 0 auto; + margin-bottom: 10px; +} +.blocks-code-field.is-fullscreen .CodeMirror { + flex: 1 1 auto; + min-height: 0; + height: auto; +} +.blocks-code-field.is-fullscreen .CodeMirror-wrap { + flex: 1 1 auto; +} +.blocks-code-field.is-fullscreen::before { + content: ""; + position: fixed; + inset: -16px; + z-index: -1; + background: rgba(15, 23, 42, 0.48); +} +@media (max-width: 1200px) { + .blocks-filter { + flex-wrap: wrap; + } + .blocks-filter .input-wrap, + .blocks-filter .select { + flex: 1 1 100%; + max-width: none; + } +} +@media (max-width: 900px) { + .blocks-revisions-layout, + .blocks-revision-fields { + grid-template-columns: 1fr; + } + .blocks-col-actions { + width: 154px; + } +} diff --git a/adminx/modules/Blocks/assets/blocks.js b/adminx/modules/Blocks/assets/blocks.js new file mode 100644 index 0000000..9980cee --- /dev/null +++ b/adminx/modules/Blocks/assets/blocks.js @@ -0,0 +1,1110 @@ +/** + * JS раздела «Системные блоки»: drawer-CRUD, копирование и очистка кеша. + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + function esc(value) { + return String(value == null ? '' : value).replace(/[&<>"']/g, function (ch) { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]; + }); + } + + Adminx.Blocks = { + form: null, + groupForm: null, + currentRevisionId: 0, + currentRevisionBlockId: 0, + filterTimer: null, + aliasTimer: null, + aliasState: 'empty', + dragGroupRow: null, + activeEditorMode: 'php', + + init: function () { + this.form = document.getElementById('blockForm'); + this.groupForm = document.getElementById('blockGroupForm'); + this.bindTabs(); + var self = this; + + document.addEventListener('click', function (e) { + if (e.target.closest('[data-block-new]')) { self.fillNew(); } + if (e.target.closest('[data-blocks-filter-reset]')) { self.resetFilters(); } + if (e.target.closest('[data-block-lint]')) { self.lintCode(); } + if (e.target.closest('[data-block-code-fullscreen]')) { self.toggleCodeFullscreen(); } + if (e.target.closest('[data-block-submit-stay]')) { self.submit(true); } + var edit = e.target.closest('[data-block-edit]'); + if (edit) { self.fillEdit(edit.closest('[data-block-row]')); } + var tagCopy = e.target.closest('[data-block-tag-copy]'); + if (tagCopy) { self.copyTag(tagCopy.getAttribute('data-block-tag-copy')); } + var copy = e.target.closest('[data-block-copy]'); + if (copy) { self.copy(copy.closest('[data-block-row]')); } + var cache = e.target.closest('[data-block-cache]'); + if (cache) { self.clearCache(cache.closest('[data-block-row]')); } + var revisions = e.target.closest('[data-block-revisions]'); + if (revisions) { self.openRevisions(revisions.closest('[data-block-row]')); } + var del = e.target.closest('[data-block-delete]'); + if (del) { self.remove(del.closest('[data-block-row]')); } + + var revisionDelete = e.target.closest('[data-revision-delete]'); + if (revisionDelete) { self.deleteRevision(revisionDelete.getAttribute('data-revision-delete')); return; } + if (e.target.closest('[data-revisions-clear]')) { self.clearRevisions(); return; } + if (e.target.closest('[data-revision-restore]')) { self.restoreRevision(); } + var revisionOpen = e.target.closest('[data-revision-open]'); + if (revisionOpen) { self.loadRevision(revisionOpen.getAttribute('data-revision-open')); } + + if (e.target.closest('[data-block-group-new]')) { self.fillGroupNew(); } + var groupEdit = e.target.closest('[data-block-group-edit]'); + if (groupEdit) { self.fillGroupEdit(groupEdit.closest('[data-block-group-row]')); } + var groupDelete = e.target.closest('[data-block-group-delete]'); + if (groupDelete) { self.removeGroup(groupDelete.closest('[data-block-group-row]')); } + }); + + if (this.form) { + this.form.addEventListener('submit', function (e) { + e.preventDefault(); + self.submit(); + }); + } + document.addEventListener('submit', function (e) { + var filter = e.target.closest('.blocks-filter'); + if (!filter) { return; } + e.preventDefault(); + self.applyFilters(filter, true); + }); + document.addEventListener('change', function (e) { + if (e.target && e.target.matches('[data-block-editor-mode]')) { + self.setEditorMode(e.target.value); + return; + } + var filter = e.target.closest('.blocks-filter'); + if (!filter || !e.target.matches('select')) { return; } + self.applyFilters(filter, true); + }); + document.addEventListener('input', function (e) { + var filter = e.target.closest('.blocks-filter'); + if (!filter || !e.target.matches('input[type="search"]')) { return; } + clearTimeout(self.filterTimer); + self.filterTimer = setTimeout(function () { self.applyFilters(filter, true); }, 350); + }); + document.addEventListener('input', function (e) { + if (!self.form || e.target !== self.field('sysblock_alias')) { return; } + self.scheduleAliasCheck(); + }); + document.addEventListener('blur', function (e) { + if (!self.form || e.target !== self.field('sysblock_alias')) { return; } + self.checkAlias(true); + }, true); + window.addEventListener('popstate', function () { + var form = document.querySelector('.blocks-filter'); + if (!form) { return; } + self.applyFilterUrl(window.location.href, false); + }); + document.addEventListener('keydown', function (e) { + if (e.target && e.target.closest && e.target.closest('.CodeMirror')) { + return; + } + if ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 's' && self.isEditorDrawerOpen()) { + e.preventDefault(); + self.submit(true); + return; + } + if (e.key === 'Escape' && document.querySelector('.blocks-code-field.is-fullscreen')) { + self.toggleCodeFullscreen(false); + } + }); + document.addEventListener('dragstart', function (e) { self.groupDragStart(e); }); + document.addEventListener('dragover', function (e) { self.groupDragOver(e); }); + document.addEventListener('drop', function (e) { self.groupDrop(e); }); + document.addEventListener('dragend', function () { self.groupDragEnd(); }); + if (this.groupForm) { + this.groupForm.addEventListener('submit', function (e) { + e.preventDefault(); + self.submitGroup(); + }); + } + this.openFromLocation(); + }, + + openFromLocation: function () { + var id = parseInt(new URLSearchParams(window.location.search).get('edit'), 10) || 0; + if (!id) { return; } + var row = document.querySelector('[data-block-row][data-id="' + id + '"]'); + if (!row) { return; } + if (Adminx.Drawer) { Adminx.Drawer.open('blockDrawer'); } + this.fillEdit(row); + }, + + base: function () { return (this.form && this.form.getAttribute('data-base')) || Adminx.base(); }, + + filterUrl: function (form) { + var params = new URLSearchParams(new FormData(form)); + Array.from(params.keys()).forEach(function (key) { + var value = String(params.get(key) || ''); + if (value === '' || value === '0') { params.delete(key); } + }); + var query = params.toString(); + return (form.getAttribute('action') || (this.base() + '/blocks')) + (query ? '?' + query : ''); + }, + + applyFilters: function (form, push) { + if (!form) { return; } + var url = this.filterUrl(form); + this.applyFilterUrl(url, push); + }, + + replaceBlocksList: function (html, url, push) { + var doc = new DOMParser().parseFromString(html, 'text/html'); + var active = document.querySelector('[data-blocks-panel].active'); + var activeName = active ? active.getAttribute('data-blocks-panel') : 'blocks'; + var selectors = [ + '.blocks-summary', + '.blocks-import-card', + '.blocks-tabs', + '[data-blocks-panel="blocks"]', + '[data-blocks-panel="groups"]' + ]; + var replaced = 0; + + selectors.forEach(function (selector) { + var next = doc.querySelector(selector); + var current = document.querySelector(selector); + if (next && current) { + current.replaceWith(next); + replaced++; + } + }); + + if (!replaced) { + window.location.href = url; + return; + } + + if (push && window.history && window.history.pushState) { + window.history.pushState({ adminxBlocksFilters: true }, '', url); + } + this.bindTabs(); + this.activateTab(activeName); + }, + + activateTab: function (name) { + name = name || 'blocks'; + document.querySelectorAll('[data-blocks-tab]').forEach(function (tab) { + tab.setAttribute('aria-selected', tab.getAttribute('data-blocks-tab') === name ? 'true' : 'false'); + }); + document.querySelectorAll('[data-blocks-panel]').forEach(function (panel) { + panel.classList.toggle('active', panel.getAttribute('data-blocks-panel') === name); + }); + }, + + isEditorDrawerOpen: function () { + var drawer = document.getElementById('blockDrawer'); + return !!(drawer && !drawer.hidden && this.form); + }, + + ajaxRefresh: function (message, keepOpen) { + if (message) { Adminx.Toast.show(message, 'success'); } + if (!keepOpen && Adminx.Drawer) { Adminx.Drawer.close(); } + this.applyFilterUrl(window.location.href, false); + if (keepOpen && window.Adminx.CodeEditor) { + setTimeout(function () { Adminx.CodeEditor.refreshAll(); }, 120); + } + }, + + resetFilters: function () { + var form = document.querySelector('.blocks-filter'); + if (!form) { return; } + form.reset(); + Array.prototype.forEach.call(form.querySelectorAll('input[type="search"]'), function (input) { input.value = ''; }); + Array.prototype.forEach.call(form.querySelectorAll('select'), function (select) { select.selectedIndex = 0; }); + this.applyFilters(form, true); + }, + + applyFilterUrl: function (url, push) { + var self = this; + Adminx.Loader.show(); + fetch(url, { + method: 'GET', + headers: { 'Accept': 'text/html' }, + credentials: 'same-origin' + }).then(function (res) { + return res.text().then(function (html) { + if (!res.ok) { throw new Error('HTTP ' + res.status); } + self.replaceBlocksList(html, url, push); + }); + }).catch(function () { + Adminx.Toast.show('Не удалось применить фильтры', 'error'); + }).finally(function () { + Adminx.Loader.hide(); + }); + }, + + bindTabs: function () { + document.querySelectorAll('[data-blocks-tab]').forEach(function (tab) { + tab.addEventListener('click', function () { + var name = tab.getAttribute('data-blocks-tab'); + document.querySelectorAll('[data-blocks-tab]').forEach(function (t) { + t.setAttribute('aria-selected', t === tab ? 'true' : 'false'); + }); + document.querySelectorAll('[data-blocks-panel]').forEach(function (panel) { + panel.classList.toggle('active', panel.getAttribute('data-blocks-panel') === name); + }); + if (window.Adminx.CodeEditor) { setTimeout(function () { Adminx.CodeEditor.refreshAll(); }, 40); } + }); + }); + }, + + field: function (name) { return this.form.querySelector('[name="' + name + '"]'); }, + groupField: function (name) { return this.groupForm.querySelector('[name="' + name + '"]'); }, + + clearErrors: function () { + this.form.querySelectorAll('[data-error]').forEach(function (el) { el.textContent = ''; }); + this.form.querySelectorAll('.is-invalid').forEach(function (el) { el.classList.remove('is-invalid'); }); + }, + + setLintResult: function (message, kind) { + var el = document.querySelector('[data-block-lint-result]'); + if (!el) { return; } + el.textContent = message || ''; + el.classList.remove('is-ok', 'is-error'); + if (kind) { el.classList.add(kind === 'error' ? 'is-error' : 'is-ok'); } + }, + + submitButton: function () { + return this.form ? this.form.querySelector('[data-block-submit]') : null; + }, + + submitButtons: function () { + return this.form ? this.form.querySelectorAll('[data-block-submit], [data-block-submit-stay]') : []; + }, + + setAliasState: function (state, message) { + this.aliasState = state; + var input = this.field('sysblock_alias'); + var hint = this.form ? this.form.querySelector('[data-alias-hint]') : null; + var error = this.form ? this.form.querySelector('[data-error="sysblock_alias"]') : null; + if (input) { input.classList.toggle('is-invalid', state === 'invalid' || state === 'taken' || state === 'error'); } + if (hint) { + hint.textContent = message || 'A-Z, 0-9, - и _'; + hint.classList.remove('is-ok', 'is-checking'); + if (state === 'ok') { hint.classList.add('is-ok'); } + if (state === 'checking') { hint.classList.add('is-checking'); } + } + if (error) { + error.textContent = (state === 'invalid' || state === 'taken' || state === 'error') ? (message || '') : ''; + } + this.updateSubmitState(); + }, + + updateSubmitState: function () { + var disabled = !(this.aliasState === 'ok'); + Array.prototype.forEach.call(this.submitButtons(), function (button) { + button.disabled = disabled; + }); + }, + + localAliasError: function (value) { + value = String(value || '').trim(); + if (value === '') { return 'Укажите алиас'; } + if (!/^[A-Za-z0-9-_]{1,20}$/i.test(value) || isFinite(Number(value))) { + return 'Только A-Z, 0-9, дефис и подчёркивание, до 20 символов; не число'; + } + return ''; + }, + + scheduleAliasCheck: function () { + clearTimeout(this.aliasTimer); + var value = this.field('sysblock_alias') ? this.field('sysblock_alias').value : ''; + var local = this.localAliasError(value); + if (local) { + this.setAliasState('invalid', local); + return; + } + this.setAliasState('checking', 'Проверяем уникальность...'); + var self = this; + this.aliasTimer = setTimeout(function () { self.checkAlias(false); }, 300); + }, + + checkAlias: function (immediate) { + clearTimeout(this.aliasTimer); + var input = this.field('sysblock_alias'); + if (!input) { return; } + var value = input.value || ''; + var local = this.localAliasError(value); + if (local) { + this.setAliasState('invalid', local); + return; + } + this.setAliasState('checking', immediate ? 'Проверяем уникальность...' : 'Проверяем уникальность...'); + var id = this.field('id') ? this.field('id').value : ''; + var url = this.base() + '/blocks/alias-check?alias=' + encodeURIComponent(value.trim()) + '&id=' + encodeURIComponent(id || '0'); + var self = this; + Adminx.Ajax.request(url).then(function (payload) { + var d = payload.data || {}; + var data = d.data || {}; + if (!d.success) { + self.setAliasState('error', d.message || 'Не удалось проверить алиас'); + return; + } + if (!data.valid) { + self.setAliasState('invalid', d.message || 'Некорректный алиас'); + return; + } + if (!data.available) { + self.setAliasState('taken', d.message || 'Такой алиас уже используется'); + return; + } + self.setAliasState('ok', d.message || 'Алиас свободен'); + }).catch(function () { + self.setAliasState('error', 'Не удалось проверить алиас'); + }); + }, + + setChecked: function (name, value) { + var el = this.field(name); + if (el) { el.checked = String(value) === '1'; } + }, + + setEditorMode: function (mode) { + mode = ['php', 'html', 'rich', 'text'].indexOf(mode) !== -1 ? mode : 'text'; + var textarea = this.field('sysblock_text'); + var plain = this.form ? this.form.querySelector('[data-block-plain-editor]') : null; + var codeWrap = this.form ? this.form.querySelector('[data-block-code-editor]') : null; + var richTextarea = this.form ? this.form.querySelector('[data-block-rich-editor] textarea') : null; + var richWrap = this.form ? this.form.querySelector('[data-block-rich-editor]') : null; + var currentValue = ''; + var editor = this.editor(); + if (this.activeEditorMode === 'text' && plain) { + currentValue = plain.value || ''; + if (textarea) { textarea.value = currentValue; } + if (editor) { editor.setValue(currentValue); editor.save(); } + } else if (this.activeEditorMode === 'rich' && richTextarea) { + currentValue = this.richValue(); + if (textarea) { textarea.value = currentValue; } + if (editor) { editor.setValue(currentValue); editor.save(); } + } else { + if (editor) { editor.save(); } + currentValue = textarea ? textarea.value : ''; + if (plain) { plain.value = currentValue; } + } + + if (richTextarea && mode === 'rich') { this.setRichValue(currentValue); } + if (plain && mode === 'text') { plain.value = currentValue; } + + this.setChecked('sysblock_eval', mode === 'php' ? '1' : '0'); + this.setChecked('sysblock_visual', mode === 'rich' ? '1' : '0'); + var select = this.form ? this.form.querySelector('[data-block-editor-mode]') : null; + var lint = this.form ? this.form.querySelector('[data-block-lint]') : null; + var title = this.form ? this.form.querySelector('[data-block-editor-title]') : null; + var hint = this.form ? this.form.querySelector('[data-block-editor-mode-hint]') : null; + var labels = { + php: { title: 'PHP-код блока', hint: 'CodeMirror для PHP; код выполняется на сайте' }, + html: { title: 'HTML и теги AVE.cms', hint: 'CodeMirror для HTML; PHP не выполняется, теги AVE.cms обрабатываются' }, + rich: { title: 'Содержимое блока', hint: 'Визуальное редактирование HTML; PHP не выполняется, теги AVE.cms обрабатываются' }, + text: { title: 'Текст блока', hint: 'Обычное текстовое поле без подсветки; PHP не выполняется' } + }; + if (select) { select.value = mode; } + if (lint) { lint.hidden = mode !== 'php'; } + if (title) { title.textContent = labels[mode].title; } + if (hint) { hint.textContent = labels[mode].hint; } + if (codeWrap) { codeWrap.hidden = mode === 'text' || mode === 'rich'; } + if (richWrap) { richWrap.hidden = mode !== 'rich'; } + if (plain) { plain.hidden = mode !== 'text'; } + this.activeEditorMode = mode; + if (editor) { + editor.setOption('mode', mode === 'php' ? 'application/x-httpd-php' : (mode === 'html' ? 'htmlmixed' : 'text/plain')); + if (mode === 'php' || mode === 'html') { setTimeout(function () { editor.refresh(); }, 20); } + } + }, + + syncEditorMode: function () { + var modeField = this.field('sysblock_editor'); + var evalField = this.field('sysblock_eval'); + var visualField = this.field('sysblock_visual'); + var mode = modeField ? modeField.value : ''; + if (['php', 'html', 'rich', 'text'].indexOf(mode) === -1) { + mode = evalField && evalField.checked ? 'php' : (visualField && visualField.checked ? 'rich' : 'html'); + } + this.setEditorMode(mode); + }, + + editor: function () { + var textarea = this.field('sysblock_text'); + return textarea && textarea._adminxCodeMirror ? textarea._adminxCodeMirror : null; + }, + + richValue: function () { + var textarea = this.form ? this.form.querySelector('[data-block-rich-editor] textarea') : null; + if (!textarea) { return ''; } + if (textarea._adminxTiptap) { return textarea._adminxTiptap.getHTML(); } + return textarea.value || ''; + }, + + setRichValue: function (value) { + var textarea = this.form ? this.form.querySelector('[data-block-rich-editor] textarea') : null; + if (!textarea) { return; } + value = value || ''; + textarea.value = value; + textarea.removeAttribute('data-rich-editor-dirty'); + if (textarea._adminxTiptap && textarea._adminxTiptap.getHTML() !== value) { + textarea._adminxTiptap.commands.setContent(value || '

'); + textarea.removeAttribute('data-rich-editor-dirty'); + } + }, + + setCode: function (value) { + var textarea = this.field('sysblock_text'); + var editor = this.editor(); + var plain = this.form ? this.form.querySelector('[data-block-plain-editor]') : null; + if (textarea) { textarea.value = value || ''; } + if (plain) { plain.value = value || ''; } + this.setRichValue(value || ''); + if (editor) { editor.setValue(value || ''); setTimeout(function () { editor.refresh(); }, 40); } + }, + + saveCode: function () { + var editor = this.editor(); + var textarea = this.field('sysblock_text'); + var plain = this.form ? this.form.querySelector('[data-block-plain-editor]') : null; + if (this.activeEditorMode === 'text' && plain) { + if (textarea) { textarea.value = plain.value || ''; } + if (editor) { editor.setValue(plain.value || ''); editor.save(); } + } else if (this.activeEditorMode === 'rich') { + if (textarea) { textarea.value = this.richValue(); } + if (editor) { editor.setValue(textarea ? textarea.value : ''); editor.save(); } + } else if (editor) { + editor.save(); + if (plain) { plain.value = textarea ? textarea.value : ''; } + } + }, + + lintCode: function () { + if (!this.form) { return; } + this.saveCode(); + this.setLintResult('Проверка...', ''); + var fd = new FormData(); + fd.set('_csrf', Adminx.csrf()); + fd.set('sysblock_eval', this.field('sysblock_eval') && this.field('sysblock_eval').checked ? '1' : '0'); + fd.set('sysblock_text', this.field('sysblock_text') ? this.field('sysblock_text').value : ''); + var self = this; + Adminx.Ajax.post(this.base() + '/blocks/lint', fd).then(function (payload) { + var d = payload.data || {}; + if (d.success) { + self.setLintResult(d.message || 'Синтаксис PHP без ошибок.', 'ok'); + Adminx.Toast.show(d.message || 'Синтаксис PHP без ошибок.', 'success'); + return; + } + var detail = d.errors && d.errors.sysblock_text ? d.errors.sysblock_text : (d.message || 'В PHP-коде есть ошибка.'); + self.setLintResult(detail, 'error'); + Adminx.Toast.show(d.message || 'В PHP-коде есть ошибка.', 'error'); + }).catch(function () { + self.setLintResult('Не удалось выполнить проверку.', 'error'); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + toggleCodeFullscreen: function (force) { + var field = document.querySelector('.blocks-code-field'); + var button = document.querySelector('[data-block-code-fullscreen]'); + if (!field) { return; } + var next = typeof force === 'boolean' ? force : !field.classList.contains('is-fullscreen'); + field.classList.toggle('is-fullscreen', next); + document.body.classList.toggle('blocks-code-fullscreen-open', next); + if (button) { + button.setAttribute('data-tooltip', next ? 'Свернуть редактор' : 'Развернуть редактор'); + button.setAttribute('aria-label', next ? 'Свернуть редактор' : 'Развернуть редактор'); + button.innerHTML = next ? '' : ''; + } + var editor = this.editor(); + if (editor) { + setTimeout(function () { + editor.refresh(); + if (next) { editor.setSize('100%', '100%'); } + else { editor.setSize('100%', 520); } + }, 40); + } + var richTextarea = this.form ? this.form.querySelector('[data-block-rich-editor] textarea') : null; + if (richTextarea && richTextarea._adminxTiptap && this.activeEditorMode === 'rich' && next) { + setTimeout(function () { richTextarea._adminxTiptap.commands.focus(); }, 40); + } + }, + + revisionEditor: function () { + var textarea = document.querySelector('[data-revision-code]'); + return textarea && textarea._adminxCodeMirror ? textarea._adminxCodeMirror : null; + }, + + setRevisionCode: function (value) { + var textarea = document.querySelector('[data-revision-code]'); + var editor = this.revisionEditor(); + if (textarea) { textarea.value = value || ''; } + if (editor) { editor.setValue(value || ''); setTimeout(function () { editor.refresh(); }, 40); } + }, + + fillNew: function () { + this.clearErrors(); + this.form.reset(); + this.field('id').value = ''; + this.setAliasState('empty', 'A-Z, 0-9, - и _'); + this.setChecked('sysblock_active', '1'); + this.setChecked('sysblock_eval', '1'); + this.setChecked('sysblock_external', '0'); + this.setChecked('sysblock_ajax', '0'); + this.setChecked('sysblock_visual', '0'); + var editorMode = this.field('sysblock_editor'); + editorMode.value = editorMode.querySelector('option[value="php"]') ? 'php' : 'html'; + this.syncEditorMode(); + this.setCode(''); + this.setLintResult('', ''); + this.toggleCodeFullscreen(false); + document.getElementById('blockDrawerTitle').textContent = 'Новый блок'; + if (window.Adminx.CodeEditor) { setTimeout(function () { Adminx.CodeEditor.refreshAll(); }, 80); } + }, + + fillEdit: function (row) { + if (!row) { return; } + this.clearErrors(); + this.setLintResult('', ''); + this.toggleCodeFullscreen(false); + var self = this; + Adminx.Loader.show(); + Adminx.Ajax.request(this.base() + '/blocks/' + row.dataset.id).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (!d.success || !d.data) { + Adminx.Toast.show(d.message || 'Не удалось загрузить блок', 'error'); + return; + } + var item = d.data; + self.field('id').value = item.id || ''; + self.field('sysblock_name').value = item.sysblock_name || ''; + self.field('sysblock_alias').value = item.sysblock_alias || ''; + self.setAliasState('ok', 'Текущий алиас'); + self.field('sysblock_description').value = item.sysblock_description || ''; + self.field('sysblock_group_id').value = item.sysblock_group_id || '0'; + self.setChecked('sysblock_active', item.sysblock_active); + self.setChecked('sysblock_eval', item.sysblock_eval); + self.setChecked('sysblock_external', item.sysblock_external); + self.setChecked('sysblock_ajax', item.sysblock_ajax); + self.setChecked('sysblock_visual', item.sysblock_visual); + self.field('sysblock_editor').value = item.sysblock_editor || ''; + self.syncEditorMode(); + self.setCode(item.sysblock_text || ''); + document.getElementById('blockDrawerTitle').textContent = 'Блок: ' + (item.sysblock_name || ('#' + item.id)); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + submit: function (stay) { + this.clearErrors(); + this.saveCode(); + if (this.aliasState !== 'ok') { + this.checkAlias(true); + Adminx.Toast.show('Проверьте алиас блока', 'error'); + return; + } + var id = (this.field('id').value || '').trim(); + var url = this.base() + '/blocks' + (id ? '/' + id : ''); + var fd = new FormData(this.form); + var self = this; + Adminx.Loader.show(); + Adminx.Ajax.post(url, fd).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { + if (stay && d.data && d.data.id) { + self.field('id').value = d.data.id; + document.getElementById('blockDrawerTitle').textContent = 'Блок: ' + (self.field('sysblock_name').value || ('#' + d.data.id)); + } + if (stay) { self.setAliasState('ok', 'Текущий алиас'); } + self.ajaxRefresh(d.message || 'Сохранено', !!stay); + return; + } + Object.keys(d.errors || {}).forEach(function (field) { + var span = self.form.querySelector('[data-error="' + field + '"]'); + var input = self.field(field); + if (span) { span.textContent = d.errors[field]; } + if (input) { input.classList.add('is-invalid'); } + if (field === 'sysblock_alias') { self.setAliasState('taken', d.errors[field]); } + }); + Adminx.Toast.show(d.message || 'Не удалось сохранить', 'error'); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + copy: function (row) { + if (!row) { return; } + var base = this.base(); + Adminx.Confirm.open({ + kind: 'info', + title: 'Создать копию блока?', + message: 'Будет создана отключённая копия «' + row.dataset.name + '» с новым алиасом.', + confirmLabel: 'Создать копию', + onConfirm: function () { + var fd = new FormData(); + fd.set('_csrf', Adminx.csrf()); + fd.set('name', row.dataset.name + ' (копия)'); + Adminx.Loader.show(); + Adminx.Ajax.post(base + '/blocks/' + row.dataset.id + '/copy', fd).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { Adminx.Blocks.ajaxRefresh(d.message || 'Копия создана'); } + else { Adminx.Toast.show(d.message || 'Не удалось создать копию', 'error'); } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + }, + + copyTag: function (text) { + text = String(text || ''); + if (!text) { return; } + var ok = function () { Adminx.Toast.show('Тег скопирован', 'success'); }; + var fallback = function () { + var textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'readonly'); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + document.body.appendChild(textarea); + textarea.select(); + try { + if (document.execCommand('copy')) { + ok(); + } else { + Adminx.Toast.show(text, 'info'); + } + } catch (e) { + Adminx.Toast.show(text, 'info'); + } + textarea.remove(); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(ok).catch(fallback); + return; + } + fallback(); + }, + + clearCache: function (row) { + if (!row) { return; } + Adminx.Ajax.post(this.base() + '/blocks/' + row.dataset.id + '/clear-cache').then(function (payload) { + var d = payload.data || {}; + Adminx.Toast.show(d.message || (d.success ? 'Кеш очищен' : 'Не удалось очистить кеш'), d.success ? 'success' : 'error'); + }).catch(function () { + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + openRevisions: function (row) { + if (!row) { return; } + this.currentRevisionId = 0; + this.currentRevisionBlockId = row.dataset.id || 0; + var title = document.getElementById('blockRevisionsDrawerTitle'); + var subtitle = document.getElementById('blockRevisionsDrawerSubtitle'); + var list = document.querySelector('[data-revisions-list]'); + var count = document.querySelector('[data-revisions-count]'); + var revisionTitle = document.querySelector('[data-revision-title]'); + var revisionMeta = document.querySelector('[data-revision-meta]'); + var fields = document.querySelector('[data-revision-fields]'); + var restore = document.querySelector('[data-revision-restore]'); + var remove = document.querySelector('[data-revision-delete]'); + var clear = document.querySelector('[data-revisions-clear]'); + + if (title) { title.textContent = 'Ревизии: ' + (row.dataset.name || ('#' + row.dataset.id)); } + if (subtitle) { subtitle.textContent = '[tag:sysblock:' + (row.dataset.alias || '') + ']'; } + if (list) { list.innerHTML = '
Загрузка...
'; } + if (count) { count.textContent = 'Загрузка...'; } + if (revisionTitle) { revisionTitle.textContent = 'Выберите ревизию'; } + if (revisionMeta) { revisionMeta.textContent = 'Код и метаданные появятся справа после выбора снимка.'; } + if (fields) { fields.innerHTML = ''; } + if (restore) { restore.disabled = true; } + if (remove) { remove.disabled = true; remove.removeAttribute('data-revision-delete'); } + if (clear) { clear.disabled = true; } + this.setRevisionCode(''); + if (Adminx.Drawer) { Adminx.Drawer.open('blockRevisionsDrawer'); } + if (window.Adminx.CodeEditor) { setTimeout(function () { Adminx.CodeEditor.refreshAll(); }, 80); } + + this.refreshRevisions(); + }, + + refreshRevisions: function () { + if (!this.currentRevisionBlockId) { return; } + var self = this; + Adminx.Loader.show(); + Adminx.Ajax.request(this.base() + '/blocks/' + self.currentRevisionBlockId + '/revisions').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (!d.success || !d.data) { + Adminx.Toast.show(d.message || 'Не удалось загрузить ревизии', 'error'); + return; + } + self.renderRevisions(d.data.revisions || []); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + renderRevisions: function (items) { + var list = document.querySelector('[data-revisions-list]'); + var count = document.querySelector('[data-revisions-count]'); + var clear = document.querySelector('[data-revisions-clear]'); + if (count) { count.textContent = items.length ? (items.length + ' снимков') : 'История пока пустая'; } + if (clear) { clear.disabled = !items.length; } + if (!list) { return; } + if (!items.length) { + list.innerHTML = '
Ревизий пока нет. Первый снимок появится после сохранения блока.
'; + this.currentRevisionId = 0; + this.showRevisionEmpty(); + return; + } + list.innerHTML = items.map(function (item) { + return '
' + + '' + + '
' + + 'Ревизия #' + item.id + '' + esc(item.action_label || item.action) + '' + + '
' + esc(item.created_label || '-') + (item.author_name ? ' · ' + esc(item.author_name) : '') + (item.comment ? ' · ' + esc(item.comment) : '') + '
' + + '
' + + '' + + '
'; + }).join(''); + this.loadRevision(items[0].id); + }, + + showRevisionEmpty: function () { + var title = document.querySelector('[data-revision-title]'); + var meta = document.querySelector('[data-revision-meta]'); + var fields = document.querySelector('[data-revision-fields]'); + var restore = document.querySelector('[data-revision-restore]'); + var remove = document.querySelector('[data-revision-delete]'); + if (title) { title.textContent = 'Выберите ревизию'; } + if (meta) { meta.textContent = 'Код и метаданные появятся справа после выбора снимка.'; } + if (fields) { fields.innerHTML = ''; } + if (restore) { restore.disabled = true; } + if (remove) { remove.disabled = true; remove.removeAttribute('data-revision-delete'); } + this.setRevisionCode(''); + }, + + loadRevision: function (id) { + id = parseInt(id, 10) || 0; + if (!id) { return; } + var self = this; + Adminx.Loader.show(); + Adminx.Ajax.request(this.base() + '/blocks/revisions/' + id).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (!d.success || !d.data) { + Adminx.Toast.show(d.message || 'Не удалось загрузить ревизию', 'error'); + return; + } + self.showRevision(d.data); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + showRevision: function (item) { + this.currentRevisionId = parseInt(item.id, 10) || 0; + document.querySelectorAll('[data-revision-open]').forEach(function (row) { + row.classList.toggle('is-active', row.getAttribute('data-revision-open') === String(item.id)); + }); + + var snapshot = item.snapshot || {}; + var title = document.querySelector('[data-revision-title]'); + var meta = document.querySelector('[data-revision-meta]'); + var fields = document.querySelector('[data-revision-fields]'); + var restore = document.querySelector('[data-revision-restore]'); + var remove = document.querySelector('[data-revision-delete]'); + + if (title) { title.textContent = '#' + item.id + ' · ' + (item.action_label || item.action || 'Ревизия'); } + if (meta) { + meta.textContent = (item.created_label || '-') + (item.author_name ? ' · ' + item.author_name : '') + (item.text_size_label ? ' · ' + item.text_size_label : ''); + } + if (fields) { + fields.innerHTML = + 'Название' + esc(snapshot.sysblock_name || '-') + '' + + 'Алиас[tag:sysblock:' + esc(snapshot.sysblock_alias || '-') + ']' + + 'Флаги' + + (String(snapshot.sysblock_active) === '1' ? 'активен' : 'выкл') + + (String(snapshot.sysblock_eval) === '1' ? ', eval' : '') + + (String(snapshot.sysblock_external) === '1' ? ', external' : '') + + (String(snapshot.sysblock_ajax) === '1' ? ', ajax' : '') + + (String(snapshot.sysblock_visual) === '1' ? ', visual' : '') + + ''; + } + if (restore) { restore.disabled = !this.currentRevisionId; } + if (remove) { + remove.disabled = !this.currentRevisionId; + if (this.currentRevisionId) { remove.setAttribute('data-revision-delete', String(this.currentRevisionId)); } + } + this.setRevisionCode(item.code || ''); + }, + + deleteRevision: function (id) { + id = parseInt(id || this.currentRevisionId, 10) || 0; + if (!id) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить ревизию?', + message: 'Снимок будет удалён без восстановления.', + confirmLabel: 'Удалить', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(self.base() + '/blocks/revisions/' + id + '/delete').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { + Adminx.Toast.show(d.message || 'Ревизия удалена', 'success'); + self.currentRevisionId = 0; + self.refreshRevisions(); + } else { + Adminx.Toast.show(d.message || 'Не удалось удалить ревизию', 'error'); + } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + }, + + clearRevisions: function () { + if (!this.currentRevisionBlockId) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить все ревизии?', + message: 'Будет очищена вся история снимков этого блока.', + confirmLabel: 'Удалить все', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(self.base() + '/blocks/' + self.currentRevisionBlockId + '/revisions/delete').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { + Adminx.Toast.show(d.message || 'Ревизии удалены', 'success'); + self.currentRevisionId = 0; + self.refreshRevisions(); + } else { + Adminx.Toast.show(d.message || 'Не удалось удалить ревизии', 'error'); + } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + }, + + restoreRevision: function () { + if (!this.currentRevisionId) { return; } + var base = this.base(); + var id = this.currentRevisionId; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Восстановить ревизию?', + message: 'Текущее состояние блока будет сохранено отдельным снимком перед восстановлением.', + confirmLabel: 'Восстановить', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(base + '/blocks/revisions/' + id + '/restore').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { Adminx.Blocks.ajaxRefresh(d.message || 'Блок восстановлен'); } + else { Adminx.Toast.show(d.message || 'Не удалось восстановить ревизию', 'error'); } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + }, + + remove: function (row) { + if (!row) { return; } + var base = this.base(); + Adminx.Confirm.open({ + kind: 'error', + title: 'Удалить системный блок?', + message: '«' + row.dataset.name + '» будет удалён из таблицы, кеш блока будет очищен.', + confirmLabel: 'Удалить', + confirmClass: 'btn-danger', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(base + '/blocks/' + row.dataset.id + '/delete').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { Adminx.Blocks.ajaxRefresh(d.message || 'Системный блок удалён'); } + else { Adminx.Toast.show(d.message || 'Не удалось удалить', 'error'); } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + }, + + fillGroupNew: function () { + if (!this.groupForm) { return; } + this.groupForm.reset(); + this.groupField('id').value = ''; + document.getElementById('blockGroupDrawerTitle').textContent = 'Новая группа'; + }, + + fillGroupEdit: function (row) { + if (!row || !this.groupForm) { return; } + var self = this; + Adminx.Ajax.request(this.base() + '/blocks/groups/' + row.dataset.id).then(function (payload) { + var d = payload.data || {}; + if (!d.success || !d.data) { Adminx.Toast.show(d.message || 'Группа не найдена', 'error'); return; } + self.groupField('id').value = d.data.id || ''; + self.groupField('title').value = d.data.title || ''; + self.groupField('description').value = d.data.description || ''; + document.getElementById('blockGroupDrawerTitle').textContent = 'Группа: ' + (d.data.title || ''); + }); + }, + + groupDragStart: function (e) { + var handle = e.target.closest('[data-group-drag-handle]'); + var row = handle ? handle.closest('[data-block-group-row]') : null; + if (!handle || !row || !row.closest('[data-block-groups-sortable]')) { + return; + } + this.dragGroupRow = row; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', row.dataset.id || ''); + } + setTimeout(function () { row.classList.add('blocks-group-row-dragging'); }, 0); + }, + + groupDragOver: function (e) { + if (!this.dragGroupRow) { return; } + var list = e.target.closest('[data-block-groups-sortable]'); + if (!list) { return; } + e.preventDefault(); + var row = e.target.closest('[data-block-group-row]'); + this.clearGroupDragMarkers(); + if (!row || row === this.dragGroupRow) { return; } + var rect = row.getBoundingClientRect(); + row.classList.add(e.clientY > rect.top + rect.height / 2 ? 'drag-over-bottom' : 'drag-over-top'); + }, + + groupDrop: function (e) { + if (!this.dragGroupRow) { return; } + var list = e.target.closest('[data-block-groups-sortable]'); + if (!list) { return; } + e.preventDefault(); + var row = e.target.closest('[data-block-group-row]'); + if (row && row !== this.dragGroupRow) { + var rect = row.getBoundingClientRect(); + var after = e.clientY > rect.top + rect.height / 2; + list.insertBefore(this.dragGroupRow, after ? row.nextSibling : row); + } else if (!row) { + list.appendChild(this.dragGroupRow); + } + this.groupDragEnd(); + this.updateGroupPositions(); + this.persistGroupOrder(); + }, + + groupDragEnd: function () { + if (this.dragGroupRow) { this.dragGroupRow.classList.remove('blocks-group-row-dragging'); } + this.dragGroupRow = null; + this.clearGroupDragMarkers(); + }, + + clearGroupDragMarkers: function () { + document.querySelectorAll('.blocks-groups-table .drag-over-top, .blocks-groups-table .drag-over-bottom').forEach(function (row) { + row.classList.remove('drag-over-top', 'drag-over-bottom'); + }); + }, + + updateGroupPositions: function () { + document.querySelectorAll('[data-block-groups-sortable] [data-block-group-row]').forEach(function (row, index) { + var cell = row.querySelector('[data-group-position]'); + row.dataset.position = String(index + 1); + if (cell) { cell.textContent = String(index + 1); } + }); + }, + + persistGroupOrder: function () { + var rows = document.querySelectorAll('[data-block-groups-sortable] [data-block-group-row]'); + if (!rows.length) { return; } + var fd = new FormData(); + fd.set('_csrf', Adminx.csrf()); + rows.forEach(function (row) { fd.append('order[]', row.dataset.id || ''); }); + Adminx.Loader.show(); + Adminx.Ajax.post(this.base() + '/blocks/groups/reorder', fd).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { Adminx.Toast.show(d.message || 'Порядок групп сохранён', 'success'); } + else { + Adminx.Toast.show(d.message || 'Не удалось сохранить порядок групп', 'error'); + Adminx.Blocks.ajaxRefresh(''); + } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + Adminx.Blocks.ajaxRefresh(''); + }); + }, + + submitGroup: function () { + var id = (this.groupField('id').value || '').trim(); + var url = this.base() + '/blocks/groups' + (id ? '/' + id : ''); + Adminx.Loader.show(); + Adminx.Ajax.post(url, new FormData(this.groupForm)).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { Adminx.Blocks.ajaxRefresh(d.message || 'Группа сохранена'); } + else { Adminx.Toast.show(d.message || 'Не удалось сохранить группу', 'error'); } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + removeGroup: function (row) { + if (!row) { return; } + var base = this.base(); + Adminx.Confirm.open({ + kind: 'warning', + title: 'Удалить группу?', + message: 'Блоки из «' + row.dataset.title + '» будут перенесены в «Без группы».', + confirmLabel: 'Удалить группу', + confirmClass: 'btn-danger', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(base + '/blocks/groups/' + row.dataset.id + '/delete').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { Adminx.Blocks.ajaxRefresh(d.message || 'Группа удалена'); } + else { Adminx.Toast.show(d.message || 'Не удалось удалить группу', 'error'); } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.Blocks.init(); }); + } else { + Adminx.Blocks.init(); + } +})(window, document); diff --git a/adminx/modules/Blocks/language/en/interface.xml b/adminx/modules/Blocks/language/en/interface.xml new file mode 100644 index 0000000..b3c8df4 --- /dev/null +++ b/adminx/modules/Blocks/language/en/interface.xml @@ -0,0 +1,141 @@ + + + A-Z, 0-9, - and _ + AJAX mode + CodeMirror for PHP; the code is executed on the site + HTML and tags CodeMirror + HTML fragments and executable blocks for site templates. + PHP block code + active + Active + Alias + Without a group + Block enabled + No blocks found. + Blocks of this group. + Blocks + in the current filter, + Visual + Visual editor + Visual block editor + External call + Total blocks + everything. + Select revision + off + Group + The group only affects the organization of the list. + Block groups + No groups found. + Groups make navigation easier; deleting a group moves the blocks to "No Group". + Availability of the block and how its contents are processed. + Unified block registry + Use block in AJAX requests + History of system unit snapshots. + Block code + The code and metadata will appear on the right after you select a photo. + Content and logic + Title, alias, description or code + Execution Settings + New group + New block + Drag the rows by the handle to change the order of the groups. + The order of groups is set in control mode. + Once a block is saved, its cache is cleared automatically. + View revision + Allow calling outside site templates + Allow use on the site + Audits + Block revisions + Content Mode + Pictures + Create block + States + retains and leaves the shape open. + List of revisions + Text without highlighting + Block text without highlighting + (copy) + >' + + '<b>Revision + '<div class= + >There are no revisions yet. The first snapshot will appear after saving the block.</div>'; + this.currentRevisionId = 0; + this.showRevisionEmpty(); + return; + } + list.innerHTML = items.map(function (item) { + return '<div class= + PHP CLI is not available for syntax checking. + PHP syntax: error in block code + PHP syntax: error on line + Alias is free + Block restored from revision + Blocks + Blocks without reference to a group. + Blocks: PHP executable + Blocks: view + Blocks: control + There is a syntax error in the PHP code. + Recovery + Restored from revision + + return $blockId; + } + + public static function delete($revisionId) + { + $revision = self::one($revisionId); + if (!$revision) { + return false; + } + + DB::Delete(self::table(), + Group not found + Group created + Group saved + Group deleted + Import + System unit cache cleared + block code + Content + Copy + Block copy + return $newId; + } + + public static function saveGroup($id, array $input) + { + $title = trim(isset($input[ + Copy created + Failed to write temporary file for verification. + Failed to create temporary file for verification. + Insufficient rights + Insufficient rights to change the PHP executable block + Before recovery + The order of the groups is preserved + PHP validation is not needed: Eval is disabled. + Check the form fields + View HTML and system blocks, as well as their groups. + Revisions deleted + Revision not found + Revision deleted + PHP syntax without errors. + System unit not found + The system unit has been created + System unit saved + System unit removed + Snapshot before recovery + Creation + Creating a block + Creating, modifying, copying and restoring blocks with PHP code. + Create, modify, copy and delete blocks. + Saving + Saving a block + This alias is already in use + A-Z only, 0-9, hyphen and underscore, up to 20 characters; not a number + Removal + Delete a block + Specify an alias + Please enter a name + diff --git a/adminx/modules/Blocks/language/en/runtime.xml b/adminx/modules/Blocks/language/en/runtime.xml new file mode 100644 index 0000000..764c2b9 --- /dev/null +++ b/adminx/modules/Blocks/language/en/runtime.xml @@ -0,0 +1,64 @@ + + + (copy) + PHP CLI is not available for syntax checking. + PHP syntax: error in block code + PHP syntax: error on line + Alias is free + B + Without a group + Block # + Block restored from revision + Blocks + Blocks without reference to a group. + Blocks: PHP executable + Blocks: view + Blocks: control + There is a syntax error in the PHP code. + Recovery + Restored from revision # + Group not found + Group created + Group saved + Group deleted + Import + System unit cache cleared + Content + Copy + Block copy # + Copy created + Group order not passed + Failed to write temporary file for verification. + Failed to create temporary file for verification. + Insufficient rights + Insufficient rights to change the PHP executable block + Before recovery + Moving a regular block + The order of the groups is preserved + PHP validation is not needed: Eval is disabled. + Check the form fields + View HTML and system blocks, as well as their groups. + Revisions deleted + Revision not found + Revision deleted + PHP syntax without errors. + System unit not found + The system unit has been created + System unit saved + System unit removed + Snapshot before recovery + Creation + Creating a block + Creating, modifying, copying and restoring blocks with PHP code. + Create, modify, copy and delete blocks. + Saving + Saving a block + This alias is already in use + A-Z only, 0-9, hyphen and underscore, up to 20 characters; not a number + Removal + Delete a block + Specify an alias + Please enter a name + Enter the name of the group + block code + diff --git a/adminx/modules/Blocks/language/ru/interface.xml b/adminx/modules/Blocks/language/ru/interface.xml new file mode 100644 index 0000000..f5f7bc5 --- /dev/null +++ b/adminx/modules/Blocks/language/ru/interface.xml @@ -0,0 +1,141 @@ + + + A-Z, 0-9, - и _ + AJAX-режим + CodeMirror для PHP; код выполняется на сайте + HTML и теги · CodeMirror + HTML-фрагменты и исполняемые блоки для шаблонов сайта. + PHP-код блока + активен + Активных + Алиас + Без группы + Блок включён + Блоки не найдены. + Блоки этой группы. + Блоков + в текущем фильтре, + Визуальные + Визуальный редактор + Визуальный редактор блока + Внешний вызов + Всего блоков + всего. + Выберите ревизию + выкл + Групп + Группа влияет только на организацию списка. + Группы блоков + Группы не найдены. + Группы упрощают навигацию; удаление группы переносит блоки в «Без группы». + Доступность блока и способ обработки его содержимого. + Единый реестр блоков + Использовать блок в AJAX-запросах + История снимков системного блока. + Код блока + Код и метаданные появятся справа после выбора снимка. + Контент и логика + Название, алиас, описание или код + Настройки выполнения + Новая группа + Новый блок + Перетащите строки за ручку, чтобы изменить порядок групп. + Порядок групп задаётся в режиме управления. + После сохранения блока его кеш очищается автоматически. + Просмотр ревизии + Разрешить вызов вне шаблонов сайта + Разрешить использование на сайте + Ревизии + Ревизии блока + Режим содержимого + Снимки + Создать блок + Состояния + сохраняет и оставляет форму открытой. + Список ревизий + Текст без подсветки + Текст блока без подсветки + (копия) + >' + + '<b>Ревизия + '<div class= + >Ревизий пока нет. Первый снимок появится после сохранения блока.</div>'; + this.currentRevisionId = 0; + this.showRevisionEmpty(); + return; + } + list.innerHTML = items.map(function (item) { + return '<div class= + PHP CLI недоступен для проверки синтаксиса. + PHP-синтаксис: ошибка в коде блока + PHP-синтаксис: ошибка на строке + Алиас свободен + Блок восстановлен из ревизии + Блоки + Блоки без привязки к группе. + Блоки: исполняемый PHP + Блоки: просмотр + Блоки: управление + В PHP-коде есть синтаксическая ошибка. + Восстановление + Восстановлено из ревизии + + return $blockId; + } + + public static function delete($revisionId) + { + $revision = self::one($revisionId); + if (!$revision) { + return false; + } + + DB::Delete(self::table(), + Группа не найдена + Группа создана + Группа сохранена + Группа удалена + Импорт + Кеш системного блока очищен + код блока + Контент + Копия + Копия блока + return $newId; + } + + public static function saveGroup($id, array $input) + { + $title = trim(isset($input[ + Копия создана + Не удалось записать временный файл для проверки. + Не удалось создать временный файл для проверки. + Недостаточно прав + Недостаточно прав для изменения исполняемого PHP-блока + Перед восстановлением + Порядок групп сохранён + Проверка PHP не нужна: Eval отключён. + Проверьте поля формы + Просмотр HTML- и системных блоков, а также их групп. + Ревизии удалены + Ревизия не найдена + Ревизия удалена + Синтаксис PHP без ошибок. + Системный блок не найден + Системный блок создан + Системный блок сохранён + Системный блок удалён + Снимок перед восстановлением + Создание + Создание блока + Создание, изменение, копирование и восстановление блоков с PHP-кодом. + Создание, изменение, копирование и удаление блоков. + Сохранение + Сохранение блока + Такой алиас уже используется + Только A-Z, 0-9, дефис и подчёркивание, до 20 символов; не число + Удаление + Удаление блока + Укажите алиас + Укажите название + diff --git a/adminx/modules/Blocks/language/ru/runtime.xml b/adminx/modules/Blocks/language/ru/runtime.xml new file mode 100644 index 0000000..e14565a --- /dev/null +++ b/adminx/modules/Blocks/language/ru/runtime.xml @@ -0,0 +1,64 @@ + + + (копия) + PHP CLI недоступен для проверки синтаксиса. + PHP-синтаксис: ошибка в коде блока + PHP-синтаксис: ошибка на строке + Алиас свободен + Б + Без группы + Блок # + Блок восстановлен из ревизии + Блоки + Блоки без привязки к группе. + Блоки: исполняемый PHP + Блоки: просмотр + Блоки: управление + В PHP-коде есть синтаксическая ошибка. + Восстановление + Восстановлено из ревизии # + Группа не найдена + Группа создана + Группа сохранена + Группа удалена + Импорт + Кеш системного блока очищен + Контент + Копия + Копия блока # + Копия создана + Не передан порядок групп + Не удалось записать временный файл для проверки. + Не удалось создать временный файл для проверки. + Недостаточно прав + Недостаточно прав для изменения исполняемого PHP-блока + Перед восстановлением + Перенос обычного блока + Порядок групп сохранён + Проверка PHP не нужна: Eval отключён. + Проверьте поля формы + Просмотр HTML- и системных блоков, а также их групп. + Ревизии удалены + Ревизия не найдена + Ревизия удалена + Синтаксис PHP без ошибок. + Системный блок не найден + Системный блок создан + Системный блок сохранён + Системный блок удалён + Снимок перед восстановлением + Создание + Создание блока + Создание, изменение, копирование и восстановление блоков с PHP-кодом. + Создание, изменение, копирование и удаление блоков. + Сохранение + Сохранение блока + Такой алиас уже используется + Только A-Z, 0-9, дефис и подчёркивание, до 20 символов; не число + Удаление + Удаление блока + Укажите алиас + Укажите название + Укажите название группы + код блока + diff --git a/adminx/modules/Blocks/migrations/001_create_sysblocks_tables.sql b/adminx/modules/Blocks/migrations/001_create_sysblocks_tables.sql new file mode 100644 index 0000000..24e9f25 --- /dev/null +++ b/adminx/modules/Blocks/migrations/001_create_sysblocks_tables.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_sysblocks_groups` ( + `id` MEDIUMINT(5) UNSIGNED NOT NULL AUTO_INCREMENT, + `position` SMALLINT(3) UNSIGNED NOT NULL DEFAULT 0, + `title` VARCHAR(255) NOT NULL DEFAULT '', + `description` TEXT NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_position` (`position`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `{{prefix}}_sysblocks` ( + `id` MEDIUMINT(5) UNSIGNED NOT NULL AUTO_INCREMENT, + `sysblock_group_id` INT(3) NOT NULL DEFAULT 0, + `sysblock_name` VARCHAR(255) NOT NULL DEFAULT '', + `sysblock_description` VARCHAR(255) DEFAULT NULL, + `sysblock_alias` VARCHAR(20) NOT NULL DEFAULT '', + `sysblock_text` LONGTEXT NOT NULL, + `sysblock_active` ENUM('0','1') NOT NULL DEFAULT '1', + `sysblock_eval` ENUM('0','1') NOT NULL DEFAULT '1', + `sysblock_external` ENUM('0','1') NOT NULL DEFAULT '0', + `sysblock_ajax` ENUM('0','1') NOT NULL DEFAULT '0', + `sysblock_visual` ENUM('0','1') NOT NULL DEFAULT '0', + `sysblock_author_id` INT(10) UNSIGNED NOT NULL DEFAULT 1, + `sysblock_created` INT(10) UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_alias` (`sysblock_alias`), + KEY `idx_group` (`sysblock_group_id`), + KEY `idx_active` (`sysblock_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/adminx/modules/Blocks/migrations/002_create_sysblock_revisions.sql b/adminx/modules/Blocks/migrations/002_create_sysblock_revisions.sql new file mode 100644 index 0000000..6ddc769 --- /dev/null +++ b/adminx/modules/Blocks/migrations/002_create_sysblock_revisions.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_sysblock_revisions` ( + `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `block_id` MEDIUMINT(5) UNSIGNED NOT NULL DEFAULT 0, + `action` VARCHAR(32) NOT NULL DEFAULT 'update', + `snapshot_hash` CHAR(40) NOT NULL DEFAULT '', + `text_hash` CHAR(40) NOT NULL DEFAULT '', + `snapshot_json` LONGTEXT NOT NULL, + `comment` VARCHAR(255) NOT NULL DEFAULT '', + `author_id` INT(10) UNSIGNED NOT NULL DEFAULT 0, + `author_name` VARCHAR(255) NOT NULL DEFAULT '', + `source_revision_id` INT(10) UNSIGNED NOT NULL DEFAULT 0, + `created_at` INT(10) UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_block_created` (`block_id`, `created_at`), + KEY `idx_action` (`action`), + KEY `idx_snapshot_hash` (`snapshot_hash`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/adminx/modules/Blocks/migrations/003_native_public_document_context.sql b/adminx/modules/Blocks/migrations/003_native_public_document_context.sql new file mode 100644 index 0000000..598dd51 --- /dev/null +++ b/adminx/modules/Blocks/migrations/003_native_public_document_context.sql @@ -0,0 +1,7 @@ +UPDATE `{{prefix}}_sysblocks` +SET `sysblock_text` = REPLACE( + `sysblock_text`, + '$AVE_Core->curentdoc', + CONCAT(CHAR(92), 'App', CHAR(92), 'Frontend', CHAR(92), 'PublicPageContext::document()') +) +WHERE `sysblock_text` LIKE '%$AVE_Core->curentdoc%'; diff --git a/adminx/modules/Blocks/migrations/004_native_database_calls.sql b/adminx/modules/Blocks/migrations/004_native_database_calls.sql new file mode 100644 index 0000000..9bcedcb --- /dev/null +++ b/adminx/modules/Blocks/migrations/004_native_database_calls.sql @@ -0,0 +1,10 @@ +UPDATE `{{prefix}}_sysblocks` +SET `sysblock_text` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + `sysblock_text`, + '$AVE_DB->Query', 'DB::query'), + '$AVE_DB->EscStr', 'DB::escape'), + '->FetchAssocArray()', '->getAssoc()'), + '->FetchRow()', '->getObject()'), + '->GetCell()', '->getValue()'), + '->getCell()', '->getValue()') +WHERE `sysblock_text` LIKE '%$AVE_DB%'; diff --git a/adminx/modules/Blocks/migrations/005_correct_native_database_escape.sql b/adminx/modules/Blocks/migrations/005_correct_native_database_escape.sql new file mode 100644 index 0000000..80cac0f --- /dev/null +++ b/adminx/modules/Blocks/migrations/005_correct_native_database_escape.sql @@ -0,0 +1,3 @@ +UPDATE `{{prefix}}_sysblocks` +SET `sysblock_text` = REPLACE(`sysblock_text`, 'DB::escape(', 'DB::safe(') +WHERE `sysblock_text` LIKE '%DB::escape(%'; diff --git a/adminx/modules/Blocks/migrations/006_native_public_quick_edit.sql b/adminx/modules/Blocks/migrations/006_native_public_quick_edit.sql new file mode 100644 index 0000000..27630e1 --- /dev/null +++ b/adminx/modules/Blocks/migrations/006_native_public_quick_edit.sql @@ -0,0 +1,3 @@ +UPDATE `{{prefix}}_sysblocks` +SET `sysblock_text` = CONVERT(FROM_BASE64('PD9waHAKCSRkb2NJZCA9IChpbnQpICdbc3lzOnBhcmFtOmRvY2lkXSc7CgkkY2FuRWRpdCA9IFxBcHBcQ29tbW9uXEF1dGg6OnN5c3RlbVVzZXJDYW4oJ21hbmFnZV9kb2N1bWVudHMnKQoJCXx8IChkZWZpbmVkKCdVR1JPVVAnKSAmJiBVR1JPVVAgPT0gMSk7CgoJaWYgKCRkb2NJZCA+IDAgJiYgJGNhbkVkaXQpCgl7CgkJJGVkaXRVcmwgPSBBQlNfUEFUSCAuICdhZG1pbngvZG9jdW1lbnRzLycgLiAkZG9jSWQgLiAnL2VkaXQ/cXVpY2tfZWRpdD0xJzsKCQkkdXJsID0gJzxhIGNsYXNzPSJlZGl0X3RyaWdnZXIgW3N5czpwYXJhbTpjbGFzc10iIGhyZWY9IicgLiBodG1sc3BlY2lhbGNoYXJzKCRlZGl0VXJsLCBFTlRfUVVPVEVTLCAnVVRGLTgnKSAuICciIG9uY2xpY2s9IndpbmRvd09wZW4odGhpcy5ocmVmLCAxMzAwLCA5MDAsIHRydWUsIFwnZWRpdGRvY1wnKTsgcmV0dXJuIGZhbHNlOyIgdGl0bGU9ItCg0LXQtNCw0LrRgtC40YDQvtCy0LDRgtGMIj4nOwoJCSR1cmwgLj0gJzxpIGNsYXNzPSJmYSBmYS1wZW5jaWwiPjwvaT4gPHNwYW4+SUQ6ICcgLiAkZG9jSWQgLiAnPC9zcGFuPjxzcGFuIGNsYXNzPSJlZGl0X3RpdGxlIj4mbmJzcDvQoNC10LTQsNC60YLQuNGA0L7QstCw0YLRjDwvc3Bhbj4nOwoJCSR1cmwgLj0gJzwvYT4nOwoKCQllY2hvICR1cmw7Cgl9Cj8+') USING utf8mb4) +WHERE `sysblock_alias` = 'edit_item'; diff --git a/adminx/modules/Blocks/migrations/007_cache_safe_public_quick_edit.sql b/adminx/modules/Blocks/migrations/007_cache_safe_public_quick_edit.sql new file mode 100644 index 0000000..72f28b5 --- /dev/null +++ b/adminx/modules/Blocks/migrations/007_cache_safe_public_quick_edit.sql @@ -0,0 +1,8 @@ +UPDATE `{{prefix}}_sysblocks` +SET `sysblock_text` = CONVERT(FROM_BASE64('PD9waHAKCSRkb2NJZCA9IChpbnQpICdbc3lzOnBhcmFtOmRvY2lkXSc7CgkkY2xhc3MgPSBwcmVnX3JlcGxhY2UoJy9bXkEtWmEtejAtOSBfLV0vJywgJycsIChzdHJpbmcpICdbc3lzOnBhcmFtOmNsYXNzXScpOwoKCWlmICgkZG9jSWQgPiAwKQoJewoJCWVjaG8gJzxzcGFuIGRhdGEtYWRtaW54LWVkaXQtcGxhY2Vob2xkZXIgZGF0YS1kb2N1bWVudC1pZD0iJyAuICRkb2NJZCAuICciIGRhdGEtZWRpdC1jbGFzcz0iJyAuIGh0bWxzcGVjaWFsY2hhcnMoJGNsYXNzLCBFTlRfUVVPVEVTLCAnVVRGLTgnKSAuICciPjwvc3Bhbj4nOwoJfQo/Pg==') USING utf8mb4) +WHERE `sysblock_alias` = 'edit_item'; + +UPDATE `{{prefix}}_request` +SET `request_changed_elements` = UNIX_TIMESTAMP() +WHERE `request_template_item` LIKE '%sysblock:edit_item%' + OR `request_template_item` LIKE '%sysblock:item_list%'; diff --git a/adminx/modules/Blocks/migrations/008_deferred_public_quick_edit_id.sql b/adminx/modules/Blocks/migrations/008_deferred_public_quick_edit_id.sql new file mode 100644 index 0000000..7a0e0a8 --- /dev/null +++ b/adminx/modules/Blocks/migrations/008_deferred_public_quick_edit_id.sql @@ -0,0 +1,8 @@ +UPDATE `{{prefix}}_sysblocks` +SET `sysblock_text` = CONVERT(FROM_BASE64('PD9waHAKCSRkb2NJZCA9IHRyaW0oKHN0cmluZykgJ1tzeXM6cGFyYW06ZG9jaWRdJyk7CgkkY2xhc3MgPSBwcmVnX3JlcGxhY2UoJy9bXkEtWmEtejAtOSBfLV0vJywgJycsIChzdHJpbmcpICdbc3lzOnBhcmFtOmNsYXNzXScpOwoJJHZhbGlkSWQgPSBwcmVnX21hdGNoKCcvXlxkKyQvJywgJGRvY0lkKQoJCXx8IGluX2FycmF5KCRkb2NJZCwgYXJyYXkoJ1t0YWc6aXRlbWlkXScsICdbdGFnOmRvY2lkXScpLCB0cnVlKTsKCglpZiAoJHZhbGlkSWQpCgl7CgkJZWNobyAnPHNwYW4gZGF0YS1hZG1pbngtZWRpdC1wbGFjZWhvbGRlciBkYXRhLWRvY3VtZW50LWlkPSInIC4gaHRtbHNwZWNpYWxjaGFycygkZG9jSWQsIEVOVF9RVU9URVMsICdVVEYtOCcpIC4gJyIgZGF0YS1lZGl0LWNsYXNzPSInIC4gaHRtbHNwZWNpYWxjaGFycygkY2xhc3MsIEVOVF9RVU9URVMsICdVVEYtOCcpIC4gJyI+PC9zcGFuPic7Cgl9Cj8+') USING utf8mb4) +WHERE `sysblock_alias` = 'edit_item'; + +UPDATE `{{prefix}}_request` +SET `request_changed_elements` = UNIX_TIMESTAMP() +WHERE `request_template_item` LIKE '%sysblock:edit_item%' + OR `request_template_item` LIKE '%sysblock:item_list%'; diff --git a/adminx/modules/Blocks/migrations/009_disable_public_php_endpoint.sql b/adminx/modules/Blocks/migrations/009_disable_public_php_endpoint.sql new file mode 100644 index 0000000..3b05dad --- /dev/null +++ b/adminx/modules/Blocks/migrations/009_disable_public_php_endpoint.sql @@ -0,0 +1,3 @@ +UPDATE `{{content_prefix}}_sysblocks` +SET `sysblock_external` = '0', `sysblock_ajax` = '0' +WHERE `sysblock_alias` = 'php'; diff --git a/adminx/modules/Blocks/migrations/010_disable_retired_catalog_endpoints.sql b/adminx/modules/Blocks/migrations/010_disable_retired_catalog_endpoints.sql new file mode 100644 index 0000000..17c6cbe --- /dev/null +++ b/adminx/modules/Blocks/migrations/010_disable_retired_catalog_endpoints.sql @@ -0,0 +1,4 @@ +UPDATE `{{content_prefix}}_sysblocks` +SET `sysblock_external` = '0', `sysblock_ajax` = '0' +WHERE `sysblock_alias` IN ('request', 'menu_catalog') + OR (`id` = 29 AND `sysblock_alias` = ''); diff --git a/adminx/modules/Blocks/migrations/011_sysblock_editor_mode.php b/adminx/modules/Blocks/migrations/011_sysblock_editor_mode.php new file mode 100644 index 0000000..30ec906 --- /dev/null +++ b/adminx/modules/Blocks/migrations/011_sysblock_editor_mode.php @@ -0,0 +1,36 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\ContentTables; + + $table = ContentTables::table('sysblocks'); + if (DatabaseSchema::tableExists($table) && !DatabaseSchema::columnExists($table, 'sysblock_editor')) { + DatabaseSchema::alter( + 'ALTER TABLE `' . $table . '` ADD COLUMN `sysblock_editor` VARCHAR(16) NOT NULL DEFAULT \'php\' AFTER `sysblock_visual`' + ); + DB::query( + 'UPDATE `' . $table . '` SET `sysblock_editor` = CASE' + . ' WHEN `sysblock_eval` = %s THEN %s' + . ' WHEN `sysblock_visual` = %s THEN %s' + . ' ELSE %s END', + '1', + 'php', + '1', + 'rich', + 'html' + ); + } diff --git a/adminx/modules/Blocks/migrations/012_merge_visual_blocks.php b/adminx/modules/Blocks/migrations/012_merge_visual_blocks.php new file mode 100644 index 0000000..3556bea --- /dev/null +++ b/adminx/modules/Blocks/migrations/012_merge_visual_blocks.php @@ -0,0 +1,138 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Common\SystemTables; + use App\Content\ContentTables; + + $source = ContentTables::table('blocks'); + $target = ContentTables::table('sysblocks'); + if (!DatabaseSchema::tableExists($source) || !DatabaseSchema::tableExists($target)) { + return; + } + + $uniqueAlias = function ($preferred, $legacyId) use ($target) { + $preferred = trim((string) $preferred); + $base = $preferred !== '' ? $preferred : 'block-' . (int) $legacyId; + $base = trim(substr(preg_replace('/[^A-Za-z0-9_-]+/', '-', $base), 0, 20), '-_'); + if ($base === '' || is_numeric($base)) { + $base = 'block-' . (int) $legacyId; + } + + $alias = $base; + $index = 2; + while (DB::query('SELECT id FROM `' . $target . '` WHERE sysblock_alias = %s LIMIT 1', $alias)->getValue()) { + $suffix = '-' . $index++; + $alias = substr($base, 0, 20 - strlen($suffix)) . $suffix; + } + + return $alias; + }; + + $rows = DB::query('SELECT * FROM `' . $source . '` ORDER BY id ASC')->getAll(); + $migrated = array(); + foreach ($rows as $row) { + $alias = $uniqueAlias(isset($row['block_alias']) ? $row['block_alias'] : '', (int) $row['id']); + DB::Insert($target, array( + 'sysblock_group_id' => 0, + 'sysblock_name' => isset($row['block_name']) ? (string) $row['block_name'] : ('Блок #' . (int) $row['id']), + 'sysblock_description' => isset($row['block_description']) ? (string) $row['block_description'] : '', + 'sysblock_alias' => $alias, + 'sysblock_text' => isset($row['block_text']) ? (string) $row['block_text'] : '', + 'sysblock_active' => !empty($row['block_active']) ? '1' : '0', + 'sysblock_eval' => '0', + 'sysblock_external' => '0', + 'sysblock_ajax' => '0', + 'sysblock_visual' => '1', + 'sysblock_editor' => 'rich', + 'sysblock_author_id' => !empty($row['block_author_id']) ? (int) $row['block_author_id'] : 1, + 'sysblock_created' => !empty($row['block_created']) ? (int) $row['block_created'] : time(), + )); + $migrated[(int) $row['id']] = array('id' => (int) DB::insertId(), 'alias' => $alias); + } + + $oldRevisions = ContentTables::table('visual_block_revisions'); + $newRevisions = ContentTables::table('sysblock_revisions'); + if (DatabaseSchema::tableExists($oldRevisions) && DatabaseSchema::tableExists($newRevisions)) { + $revisions = DB::query('SELECT * FROM `' . $oldRevisions . '` ORDER BY id ASC')->getAll(); + foreach ($revisions as $revision) { + $oldId = isset($revision['block_id']) ? (int) $revision['block_id'] : 0; + if (!isset($migrated[$oldId])) { + continue; + } + + $old = json_decode(isset($revision['snapshot_json']) ? (string) $revision['snapshot_json'] : '{}', true); + $old = is_array($old) ? $old : array(); + $snapshot = array( + 'id' => $migrated[$oldId]['id'], + 'sysblock_group_id' => 0, + 'sysblock_name' => isset($old['block_name']) ? (string) $old['block_name'] : '', + 'sysblock_description' => isset($old['block_description']) ? (string) $old['block_description'] : '', + 'sysblock_alias' => $migrated[$oldId]['alias'], + 'sysblock_text' => isset($old['block_text']) ? (string) $old['block_text'] : '', + 'sysblock_active' => !empty($old['block_active']) ? '1' : '0', + 'sysblock_eval' => '0', + 'sysblock_external' => '0', + 'sysblock_ajax' => '0', + 'sysblock_visual' => '1', + 'sysblock_editor' => 'rich', + 'sysblock_author_id' => !empty($old['block_author_id']) ? (int) $old['block_author_id'] : 1, + 'sysblock_created' => !empty($old['block_created']) ? (int) $old['block_created'] : 0, + ); + $json = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + DB::Insert($newRevisions, array( + 'block_id' => $migrated[$oldId]['id'], + 'action' => isset($revision['action']) ? (string) $revision['action'] : 'import', + 'snapshot_hash' => sha1($json), + 'text_hash' => sha1($snapshot['sysblock_text']), + 'snapshot_json' => $json, + 'comment' => isset($revision['comment']) ? (string) $revision['comment'] : 'Перенос обычного блока', + 'author_id' => isset($revision['author_id']) ? (int) $revision['author_id'] : 0, + 'author_name' => isset($revision['author_name']) ? (string) $revision['author_name'] : '', + 'source_revision_id' => 0, + 'created_at' => isset($revision['created_at']) ? (int) $revision['created_at'] : time(), + )); + } + } + + $permissions = SystemTables::table('permissions'); + $rolePermissions = SystemTables::table('role_permissions'); + if (DatabaseSchema::tableExists($rolePermissions)) { + DB::query( + 'DELETE FROM `' . $rolePermissions . '` WHERE permission_code IN (%s, %s)', + 'view_visual_blocks', + 'manage_visual_blocks' + ); + } + + if (DatabaseSchema::tableExists($permissions)) { + DB::query( + 'DELETE FROM `' . $permissions . '` WHERE code IN (%s, %s)', + 'view_visual_blocks', + 'manage_visual_blocks' + ); + } + + $modules = SystemTables::table('modules'); + if (DatabaseSchema::tableExists($modules)) { + DB::Delete($modules, 'code = %s', 'visual_blocks'); + } + + if (DatabaseSchema::tableExists($oldRevisions)) { + DB::query('DROP TABLE `' . $oldRevisions . '`'); + } + + DB::query('DROP TABLE `' . $source . '`'); diff --git a/adminx/modules/Blocks/module.php b/adminx/modules/Blocks/module.php new file mode 100644 index 0000000..f320921 --- /dev/null +++ b/adminx/modules/Blocks/module.php @@ -0,0 +1,110 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + return array( + 'code' => 'blocks', + 'name' => 'Блоки', + 'version' => '0.1.3', + + 'permissions' => array( + 'key' => 'blocks', + 'items' => array( + array( + 'code' => 'view_blocks', + 'group_code' => 'navigation', + 'name' => 'Блоки: просмотр', + 'description' => 'Просмотр HTML- и системных блоков, а также их групп.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_blocks', + 'group_code' => 'content', + 'name' => 'Блоки: управление', + 'description' => 'Создание, изменение, копирование и удаление блоков.', + 'sort_order' => 20, + ), + array( + 'code' => 'manage_php_blocks', + 'group_code' => 'admin', + 'name' => 'Блоки: исполняемый PHP', + 'description' => 'Создание, изменение, копирование и восстановление блоков с PHP-кодом.', + 'sort_order' => 30, + ), + ), + 'icon' => 'ti ti-blockquote', + 'priority' => 32, + ), + + 'navigation' => array( + array( + 'code' => 'blocks', + 'label' => 'Блоки', + 'url' => '/blocks', + 'icon' => 'ti ti-blockquote', + 'permission' => 'view_blocks', + 'group' => 'Контент', + 'sort_order' => 22, + 'match' => array( + '/blocks', + ), + ), + ), + + 'migrations' => array( + array('id' => '001_create_sysblocks_tables', 'file' => 'migrations/001_create_sysblocks_tables.sql'), + array('id' => '002_create_sysblock_revisions', 'file' => 'migrations/002_create_sysblock_revisions.sql'), + array('id' => '003_native_public_document_context', 'file' => 'migrations/003_native_public_document_context.sql'), + array('id' => '004_native_database_calls', 'file' => 'migrations/004_native_database_calls.sql'), + array('id' => '005_correct_native_database_escape', 'file' => 'migrations/005_correct_native_database_escape.sql'), + array('id' => '006_native_public_quick_edit', 'file' => 'migrations/006_native_public_quick_edit.sql'), + array('id' => '007_cache_safe_public_quick_edit', 'file' => 'migrations/007_cache_safe_public_quick_edit.sql'), + array('id' => '008_deferred_public_quick_edit_id', 'file' => 'migrations/008_deferred_public_quick_edit_id.sql'), + array('id' => '009_disable_public_php_endpoint', 'file' => 'migrations/009_disable_public_php_endpoint.sql'), + array('id' => '010_disable_retired_catalog_endpoints', 'file' => 'migrations/010_disable_retired_catalog_endpoints.sql'), + array('id' => '011_sysblock_editor_mode', 'file' => 'migrations/011_sysblock_editor_mode.php'), + array( + 'id' => '012_merge_visual_blocks', + 'file' => 'migrations/012_merge_visual_blocks.php', + 'legacy_checksums' => array('5bf93d4a309e679ed94b2dec4e3a39f9b7d71a8c'), + ), + ), + + 'routes' => array( + array('GET', '/blocks', array(\App\Adminx\Blocks\Controller::class, 'index')), + array('GET', '/blocks/alias-check', array(\App\Adminx\Blocks\Controller::class, 'aliasCheck')), + array('POST', '/blocks/lint', array(\App\Adminx\Blocks\Controller::class, 'lint')), + array('POST', '/blocks/groups', array(\App\Adminx\Blocks\Controller::class, 'storeGroup')), + array('GET', '/blocks/{id}/revisions', array(\App\Adminx\Blocks\Controller::class, 'revisions')), + array('POST', '/blocks/{id}/revisions/delete', array(\App\Adminx\Blocks\Controller::class, 'deleteRevisions')), + array('GET', '/blocks/revisions/{revision}', array(\App\Adminx\Blocks\Controller::class, 'revision')), + array('POST', '/blocks/revisions/{revision}/restore', array(\App\Adminx\Blocks\Controller::class, 'restoreRevision'), array('permission' => 'manage_blocks', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/blocks/revisions/{revision}/delete', array(\App\Adminx\Blocks\Controller::class, 'deleteRevision')), + array('GET', '/blocks/{id}', array(\App\Adminx\Blocks\Controller::class, 'show')), + array('POST', '/blocks', array(\App\Adminx\Blocks\Controller::class, 'store'), array('permission' => 'manage_blocks', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/blocks/{id}', array(\App\Adminx\Blocks\Controller::class, 'update'), array('permission' => 'manage_blocks', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/blocks/{id}/copy', array(\App\Adminx\Blocks\Controller::class, 'copy'), array('permission' => 'manage_blocks', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/blocks/{id}/clear-cache', array(\App\Adminx\Blocks\Controller::class, 'clearCache')), + array('POST', '/blocks/{id}/delete', array(\App\Adminx\Blocks\Controller::class, 'destroy')), + array('GET', '/blocks/groups/{id}', array(\App\Adminx\Blocks\Controller::class, 'group')), + array('POST', '/blocks/groups/reorder', array(\App\Adminx\Blocks\Controller::class, 'reorderGroups')), + array('POST', '/blocks/groups/{id}', array(\App\Adminx\Blocks\Controller::class, 'updateGroup')), + array('POST', '/blocks/groups/{id}/delete', array(\App\Adminx\Blocks\Controller::class, 'destroyGroup')), + ), + + 'view_globals' => array( + 'module_code' => 'blocks', + ), + ); diff --git a/adminx/modules/Blocks/view/index.twig b/adminx/modules/Blocks/view/index.twig new file mode 100644 index 0000000..2c641d3 --- /dev/null +++ b/adminx/modules/Blocks/view/index.twig @@ -0,0 +1,397 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Блоки{% endblock %} +{% block content %} + + + + + +
+
+ +
{{ stats.total }}Всего блоков
+
+
+ +
{{ stats.active }}Активных
+
+
+ +
{{ stats.external }}Внешний вызов
+
+
+ +
{{ stats.groups }}Групп
+
+
+ +
+ + +
+ +
+
+
+
+
Контент и логика
+

Единый реестр блоков

+

После сохранения блока его кеш очищается автоматически.

+
+
+ +
+
+
+ +
+

Блоки

+

{{ blocks|length }} в текущем фильтре, {{ stats.total }} всего.

+
+
+ {{ filters.state ? filters.state : 'all' }} +
+ +
+
+
+ + +
+ + + + {% if filters.q or filters.group or filters.state %} + + {% endif %} +
+
+
+ +
+ {% for group in block_groups %} +
+
+
+ +
+

{{ group.title }}

+

{{ group.description ?: 'Блоки этой группы.' }}

+
+
+ {{ group.items|length }} +
+
+ + + + + + + + {% if can_manage %}{% endif %} + + + + + + + + + {% if can_manage %}{% endif %} + + + + {% for block in group.items %} + + + + + + + {% if can_manage %} + + {% endif %} + + {% endfor %} + +
IDНазваниеТегСостоянияМетаданныеДействия
{{ block.id }} +
+ {{ block.sysblock_name }} + {% if block.sysblock_description %}{{ block.sysblock_description }}{% endif %} +
+
+ + +
+ {% if block.sysblock_active == '1' %}активен{% else %}выкл{% endif %} + {% if block.sysblock_editor == 'php' %}PHP{% endif %} + {% if block.sysblock_editor == 'html' %}HTML{% endif %} + {% if block.sysblock_editor == 'rich' %}Rich text{% endif %} + {% if block.sysblock_editor == 'text' %}Текст{% endif %} + {% if block.sysblock_external == '1' %}external{% endif %} + {% if block.sysblock_ajax == '1' %}ajax{% endif %} +
+
+
+ {{ block.author }} + {{ block.created_label }} + {{ block.text_size_label }} +
+
+
+ {% if block.sysblock_editor != 'php' or can_manage_php %} + + {% endif %} + + +
+
+
+
+ {% else %} +
+
Блоки не найдены.
+
+ {% endfor %} +
+
+ +
+
+
+
+
Контент
+

Группы блоков

+

Группы упрощают навигацию; удаление группы переносит блоки в «Без группы».

+
+
+ +
+
+
+ +
+

Группы

+

{{ can_manage ? 'Перетащите строки за ручку, чтобы изменить порядок групп.' : 'Порядок групп задаётся в режиме управления.' }}

+
+
+ {{ groups|length }} +
+
+ + {% if can_manage %}{% endif %}{% if can_manage %}{% endif %} + + {% for group in groups %} + + {% if can_manage %} + + {% endif %} + + + + + {% if can_manage %} + + {% endif %} + + {% else %} + + {% endfor %} + +
ПорядокНазваниеОписаниеБлоков
{{ loop.index }}{{ group.title }}{{ group.description }}{{ group.blocks_count }} +
+ + +
+
Группы не найдены.
+
+
+
+ +{% if can_manage %} + + + + + +{% endif %} + +{% endblock %} diff --git a/adminx/modules/Catalog/Controller.php b/adminx/modules/Catalog/Controller.php new file mode 100644 index 0000000..53ea3bc --- /dev/null +++ b/adminx/modules/Catalog/Controller.php @@ -0,0 +1,1564 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Catalog; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\AuditLog; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Common\ModuleManager; + use App\Common\Loader\Load; + use App\Common\Twig; + use App\Helpers\Request; + use App\Adminx\Rubrics\FieldAdminEditors; + use App\Adminx\Documents\Model as DocumentsModel; + use App\Adminx\Support\CodeEditor; + use App\Adminx\Support\SavedViews; + use App\Content\PaymentPrograms; + use App\Content\ProductShipping; + use App\Frontend\ThemeAssets; + use App\Adminx\Packages\Products\AttributeNormalization; + use App\Adminx\Packages\Products\Attributes; + use App\Adminx\Packages\Products\CardTemplateRevisions; + use App\Adminx\Packages\Products\CardTemplateSyntax; + use App\Adminx\Packages\Products\CardTemplates; + use App\Adminx\Packages\Products\CardContexts; + use App\Adminx\Packages\Products\FilterTemplateRevisions; + use App\Adminx\Packages\Products\FilterTemplates; + use App\Adminx\Packages\Products\QuickEditor; + use App\Adminx\Packages\Products\ProductRelations; + use App\Adminx\Packages\Products\ProductCollections; + use App\Adminx\Packages\Products\ProductDemands; + use App\Adminx\Packages\Products\ShippingPackageTemplates; + use App\Adminx\Packages\Products\VariantGroups; + use App\Modules\Products\VariantSelectorSettings; + use App\Modules\Products\ComparisonSettings; + use App\Modules\Products\DemandService; + use App\Modules\Products\DemandSettings; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + $this->assets(); + $catalogs = Model::catalogs(); + $items = 0; $active = 0; $filters = 0; $commerce = 0; + foreach ($catalogs as $catalog) { $items += $catalog['items_count']; $active += $catalog['active_count']; $filters += $catalog['filters_use'] ? 1 : 0; $commerce += $catalog['purpose'] === 'commerce' ? 1 : 0; } + $productsAvailable = $this->productsAvailable(); + return $this->render('@catalog/index.twig', array( + 'catalogs' => $catalogs, + 'stats' => array('catalogs' => count($catalogs), 'items' => $items, 'active' => $active, 'filters' => $filters, 'commerce' => $commerce), + 'rubric_options' => Model::rubricOptions(), + 'products_available' => $productsAvailable, + 'filter_templates_available' => $productsAvailable && FilterTemplates::available(), + 'can_manage' => Permission::check('manage_catalog'), + )); + } + + public function products(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } + $this->assets(); + $filters = array( + 'q' => Request::getStr('q', ''), + 'state' => Request::getStr('state', ''), + 'issue' => Request::getStr('issue', ''), + 'payment' => Request::getStr('payment', ''), + 'page' => Request::getInt('page', 1), + 'per_page' => Request::getInt('per_page', 25), + ); + return $this->render('@products/products.twig', array( + 'result' => Model::products($filters), + 'stats' => Model::productStats(), + 'filters' => $filters, + 'saved_views' => SavedViews::all('catalog_products', Auth::id(), $this->savedViewFields('products')), + 'can_manage' => Permission::check('manage_catalog'), + )); + } + + public function productQuality(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } + $this->assets(); + $stats = Model::productStats(); + $issues = Model::productQualityIssues($stats); + $allowed = array(); + foreach ($issues as $item) { $allowed[(string) $item['code']] = true; } + $issue = Request::getStr('issue', ''); + if ($issue !== '' && !isset($allowed[$issue])) { $issue = ''; } + $state = Request::getStr('state', ''); + if (!in_array($state, array('', 'active', 'inactive'), true)) { $state = ''; } + $filters = array( + 'q' => Request::getStr('q', ''), + 'issue' => $issue, + 'state' => $state, + 'page' => Request::getInt('page', 1), + 'per_page' => Request::getInt('per_page', 25), + ); + return $this->render('@products/product-quality.twig', array( + 'result' => Model::products($filters), + 'stats' => $stats, + 'issues' => $issues, + 'filters' => $filters, + 'saved_views' => SavedViews::all('catalog_quality', Auth::id(), $this->savedViewFields('quality')), + 'can_manage' => Permission::check('manage_catalog'), + )); + } + + public function productComparison(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } + $this->assets(); + return $this->render('@products/product-comparison.twig', array( + 'settings' => ComparisonSettings::get(), + 'templates' => \App\Adminx\Rubrics\Model::templateOptions(), + 'can_manage' => Permission::check('manage_catalog'), + )); + } + + public function saveProductComparison(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { + $settings = ComparisonSettings::save(Request::postAll()); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $this->audit('catalog.product_comparison_settings_saved', 0, $settings); + return $this->success('Настройки сравнения сохранены', array( + 'redirect' => $this->base() . '/catalog/product-comparison', + 'data' => $settings, + )); + } + + public function productDemands(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } + $this->assets(); + $type = Request::getStr('type', ''); + if (!in_array($type, array('', 'restock', 'preorder', 'price'), true)) { $type = ''; } + $status = Request::getStr('status', ''); + if (!in_array($status, array('', 'new', 'ready', 'processing', 'notified', 'completed', 'cancelled'), true)) { $status = ''; } + $filters = array( + 'q' => Request::getStr('q', ''), + 'type' => $type, + 'status' => $status, + 'page' => Request::getInt('page', 1), + 'per_page' => Request::getInt('per_page', 25), + ); + return $this->render('@products/product-demands.twig', array( + 'result' => ProductDemands::items($filters), + 'stats' => ProductDemands::stats(), + 'filters' => $filters, + 'settings' => DemandSettings::get(), + 'templates' => \App\Adminx\Rubrics\Model::templateOptions(), + 'can_manage' => Permission::check('manage_catalog'), + )); + } + + public function saveProductDemandSettings(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { + $settings = DemandSettings::save(Request::postAll()); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $this->audit('catalog.product_demand_settings_saved', 0, $settings); + return $this->success('Настройки обращений сохранены', array( + 'redirect' => $this->base() . '/catalog/product-demands', + 'data' => $settings, + )); + } + + public function saveProductDemandStatus(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { + $item = ProductDemands::setStatus( + isset($params['id']) ? (int) $params['id'] : 0, + Request::postStr('status', '') + ); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $this->audit('catalog.product_demand_status_changed', (int) $item['id'], array('status' => $item['status'])); + return $this->success('Состояние обращения изменено', array('data' => $item)); + } + + public function notifyProductDemands(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + $result = DemandService::notifyReady($id > 0 ? 1 : 100, $id); + $this->audit('catalog.product_demand_notifications_sent', $id, $result); + $message = 'Отправлено: ' . (int) $result['sent'] . '.'; + if ($result['skipped'] > 0) { $message .= ' Без email: ' . (int) $result['skipped'] . '.'; } + if ($result['failed'] > 0) { $message .= ' Ошибок: ' . (int) $result['failed'] . '.'; } + return $this->success($message, array('data' => $result)); + } + + public function deleteProductDemand(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { + $item = ProductDemands::delete(isset($params['id']) ? (int) $params['id'] : 0); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), array(), 404); + } + + $this->audit('catalog.product_demand_deleted', (int) $item['id'], array( + 'type' => $item['request_type'], + 'document_id' => (int) $item['document_id'], + )); + return $this->success('Обращение удалено'); + } + + public function saveSavedView(array $params = array()) + { + if (($error = $this->savedViewGuard()) !== null) { return $error; } + $scope = isset($params['scope']) ? (string) $params['scope'] : ''; + $definition = $this->savedViewDefinition($scope); + if (!$definition) { return $this->error('Раздел представления не найден', array(), 404); } + $filters = json_decode(Request::postStr('filters', '{}'), true); + if (!is_array($filters)) { return $this->error('Некорректный набор фильтров'); } + try { + $views = SavedViews::save($definition['scope'], Auth::id(), Request::postStr('title', ''), $filters, $definition['fields']); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage()); + } + + return $this->success('Представление сохранено', array('data' => array('views' => $views))); + } + + public function deleteSavedView(array $params = array()) + { + if (($error = $this->savedViewGuard()) !== null) { return $error; } + $scope = isset($params['scope']) ? (string) $params['scope'] : ''; + $definition = $this->savedViewDefinition($scope); + if (!$definition) { return $this->error('Раздел представления не найден', array(), 404); } + try { + $views = SavedViews::delete($definition['scope'], Auth::id(), isset($params['id']) ? $params['id'] : '', $definition['fields']); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), array(), 404); + } + + return $this->success('Представление удалено', array('data' => array('views' => $views))); + } + + public function shipping(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } + $this->shippingAssets(); + $state = Request::getStr('shipping_state', ''); + $issues = array('ready'=>'shipping_ready','incomplete'=>'shipping','disabled'=>'shipping_disabled'); + if (!isset($issues[$state])) { $state = ''; } + $filters = array( + 'q' => Request::getStr('q', ''), + 'shipping_state' => $state, + 'per_page' => Request::getInt('per_page', 25), + 'page' => Request::getInt('page', 1), + ); + return $this->render('@products/shipping.twig', array( + 'result' => Model::products(array( + 'q' => $filters['q'], 'issue' => $state !== '' ? $issues[$state] : '', + 'per_page' => $filters['per_page'], 'page' => $filters['page'], + )), + 'stats' => Model::productShippingStats(), + 'filters' => $filters, + 'package_templates' => ShippingPackageTemplates::all(), + 'can_manage' => Permission::check('manage_catalog'), + )); + } + + public function productCollections(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } + $this->collectionAssets(); + return $this->render('@products/product-collections.twig', array( + 'collections' => ProductCollections::all(), + 'stats' => ProductCollections::stats(), + 'categories' => ProductCollections::categories(), + 'source_types' => ProductCollections::sourceTypes(), + 'sort_modes' => ProductCollections::sortModes(), + 'card_templates' => CardTemplates::options(), + 'collections_available' => ProductCollections::available(), + 'can_manage' => Permission::check('manage_catalog'), + )); + } + + public function productCollection(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + $item = ProductCollections::one(isset($params['id']) ? (int) $params['id'] : 0); + return $item ? $this->success('', array('data' => array('collection' => $item))) + : $this->error('Подборка не найдена', array(), 404); + } + + public function productCollectionProducts(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + return $this->success('', array('data' => array( + 'items' => ProductCollections::productOptions(Request::getStr('q', ''), 30), + ))); + } + + public function saveProductCollection(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + try { + $item = ProductCollections::save($id, Request::postAll()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $this->audit($id > 0 ? 'catalog.product_collection_saved' : 'catalog.product_collection_created', (int) $item['id'], array( + 'code' => $item['code'], + 'source_type' => $item['source_type'], + 'manual_products' => count($item['manual_ids']), + )); + return $this->success($id > 0 ? 'Подборка сохранена' : 'Подборка создана', array( + 'data' => array('collection' => $item), + 'redirect' => $this->base() . '/catalog/product-collections', + )); + } + + public function deleteProductCollection(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!ProductCollections::delete($id)) { + return $this->error('Подборка не найдена', array(), 404); + } + + $this->audit('catalog.product_collection_deleted', $id); + return $this->success('Подборка удалена', array('redirect' => $this->base() . '/catalog/product-collections')); + } + + public function productRelations(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } + $this->relationAssets(); + $type = Request::getStr('type', ''); + return $this->render('@products/product-relations.twig', array( + 'relations' => ProductRelations::relations($type), 'bundles' => ProductRelations::bundles(), + 'types' => ProductRelations::types(), 'stats' => ProductRelations::stats(), 'selected_type' => $type, + 'can_manage' => Permission::check('manage_catalog'), + )); + } + + public function productRelationProducts(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + return $this->success('', array('data' => array('items' => ProductRelations::productOptions(Request::getStr('q', ''), 40)))); + } + + public function saveProductRelation(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + try { $relation = ProductRelations::saveRelation($id, Request::postAll()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit($id > 0 ? 'catalog.product_relation_saved' : 'catalog.product_relation_created', (int) $relation['id'], array('type' => $relation['relation_type'])); + return $this->success($id > 0 ? 'Связь сохранена' : 'Связь создана', array('data' => array('relation' => $relation), 'redirect' => $this->base() . '/catalog/product-relations')); + } + + public function deleteProductRelation(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!ProductRelations::deleteRelation($id)) { return $this->error('Связь не найдена', array(), 404); } + $this->audit('catalog.product_relation_deleted', $id); + return $this->success('Связь удалена', array('redirect' => $this->base() . '/catalog/product-relations')); + } + + public function productBundle(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + $bundle = ProductRelations::bundle(isset($params['id']) ? (int) $params['id'] : 0); + return $bundle ? $this->success('', array('data' => array('bundle' => $bundle))) : $this->error('Комплект не найден', array(), 404); + } + + public function saveProductBundle(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + $items = Request::postJsonArray('items'); + if (!is_array($items)) { return $this->error('Некорректный состав комплекта', array(), 422); } + try { $bundle = ProductRelations::saveBundle($id, Request::postAll(), $items); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit($id > 0 ? 'catalog.product_bundle_saved' : 'catalog.product_bundle_created', (int) $bundle['id'], array('items' => count($bundle['items']))); + return $this->success($id > 0 ? 'Комплект сохранён' : 'Комплект создан', array('data' => array('bundle' => $bundle), 'redirect' => $this->base() . '/catalog/product-relations#bundles')); + } + + public function deleteProductBundle(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!ProductRelations::deleteBundle($id)) { return $this->error('Комплект не найден', array(), 404); } + $this->audit('catalog.product_bundle_deleted', $id); + return $this->success('Комплект удалён', array('redirect' => $this->base() . '/catalog/product-relations#bundles')); + } + + public function productShipping(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + $productId = isset($params['id']) ? (int) $params['id'] : 0; + $product = Model::product($productId); + if (!$product) { return $this->error('Товар не найден', array(), 404); } + + return $this->success('', array('data' => array( + 'product' => array('id'=>$productId,'title'=>(string)$product['title'],'article'=>(string)$product['article']), + 'profile' => ProductShipping::profile($productId), + ))); + } + + public function quickEdit(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } + $this->assets(); + AdminAssets::addStyle('/modules/products/admin/assets/quick-edit.css', 53); + AdminAssets::addScript('/modules/products/admin/assets/quick-edit.js', 52); + $data = $this->quickEditData(); + if ($this->wantsPartial()) { return $this->partial('@catalog/partials/quick-edit-table.twig', $data); } + return $this->render('@products/quick-edit.twig', $data); + } + + public function cardTemplates(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + $this->cardTemplateAssets(); + return $this->render('@products/card-templates.twig', array( + 'templates' => CardTemplates::all(Request::getStr('q', '')), + 'card_contexts' => CardContexts::all(), + 'card_template_options' => CardTemplates::options(), + 'card_contexts_available' => CardContexts::available(), + 'stats' => CardTemplates::stats(), + 'filters' => array('q' => Request::getStr('q', '')), + 'products' => CardTemplates::productOptions(100), + 'default_markup' => CardTemplates::defaultMarkup(), + 'can_manage' => Permission::check('manage_catalog'), + )); + } + + public function cardTemplate(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + $item = CardTemplates::one(isset($params['id']) ? (int) $params['id'] : 0); + return $item ? $this->success('', array('data' => $item)) : $this->error('Представление не найдено', array(), 404); + } + + public function saveCardContext(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $code = isset($params['code']) ? (string) $params['code'] : ''; + try { $context = CardContexts::save($code, Request::postAll(), Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.card_context_saved', null, array( + 'context' => $context['code'], + 'mode' => $context['mode'], + 'template_id' => $context['template_id'], + )); + return $this->success('Контекст карточки сохранён', array('data' => $context)); + } + + public function filterTemplates(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + $this->cardTemplateAssets(); + return $this->render('@products/filter-templates.twig', array( + 'templates' => FilterTemplates::all(Request::getStr('q', '')), + 'stats' => FilterTemplates::stats(), + 'filters' => array('q' => Request::getStr('q', '')), + 'catalogs' => FilterTemplates::previewOptions(100), + 'default_markup' => FilterTemplates::defaultMarkup(), + 'can_manage' => Permission::check('manage_catalog'), + )); + } + + public function filterTemplate(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + $item = FilterTemplates::one(isset($params['id']) ? (int) $params['id'] : 0); + return $item ? $this->success('', array('data' => $item)) : $this->error('Представление не найдено', array(), 404); + } + + public function createFilterTemplate(array $params = array()) { return $this->persistFilterTemplate(0); } + public function saveFilterTemplate(array $params = array()) { return $this->persistFilterTemplate(isset($params['id']) ? (int) $params['id'] : 0); } + + public function lintFilterTemplate(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $result = CardTemplateSyntax::check(Request::postStr('draft_markup', '')); + return $result['ok'] ? $this->success($result['message'], array('data' => $result)) + : $this->error($result['message'], array('draft_markup' => $result['message']), 422); + } + + public function previewFilterTemplate(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $markup = Request::postStr('draft_markup', ''); $css = Request::postStr('draft_css', ''); + $syntax = CardTemplateSyntax::check($markup); + if (!$syntax['ok']) { return $this->error($syntax['message'], array('draft_markup' => $syntax['message']), 422); } + try { + $this->bootPublicCatalogPreview(); + $catalog = \App\Modules\Products\Repository::fromDocumentId(Request::postInt('preview_catalog_id', 0)); + if (!$catalog) { throw new \RuntimeException('Выберите раздел каталога с настроенными фильтрами'); } + $result = \App\Modules\Products\FilterTemplateRenderer::preview($catalog, $markup, $css); + $html = '' + . ThemeAssets::styles() + . '' + . '' + . ($css !== '' ? '' : '') . '
' + . $result['html'] . '
'; + } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + + return $this->success('Предпросмотр обновлён', array('data' => array('html' => $html, + 'json' => json_encode($result['data'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)))); + } + + public function publishFilterTemplate(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; + try { $item = FilterTemplates::publish($id, Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.filter_template_published', $id, array('version' => $item['version'])); + return $this->success('Фильтры опубликованы', array('data' => $item)); + } + + public function copyFilterTemplate(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { $id = FilterTemplates::copy(isset($params['id']) ? (int) $params['id'] : 0, Auth::id()); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.filter_template_copied', $id); return $this->success('Копия создана', array('data' => array('id' => $id))); + } + + public function deleteFilterTemplate(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } $id = isset($params['id']) ? (int) $params['id'] : 0; + try { $deleted = FilterTemplates::delete($id); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + if (!$deleted) { return $this->error('Представление не найдено', array(), 404); } + $this->audit('catalog.filter_template_deleted', $id); return $this->success('Представление удалено'); + } + + public function filterTemplateRevisions(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } $id = isset($params['id']) ? (int) $params['id'] : 0; + $item = FilterTemplates::one($id); if (!$item) { return $this->error('Представление не найдено', array(), 404); } + return $this->success('', array('data' => array('template' => array('id'=>$id,'title'=>$item['title']), 'revisions'=>FilterTemplateRevisions::listFor($id)))); + } + + public function filterTemplateRevision(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + $item=FilterTemplateRevisions::one(isset($params['revision'])?(int)$params['revision']:0);return $item?$this->success('',array('data'=>$item)):$this->error('Ревизия не найдена',array(),404); + } + + public function restoreFilterTemplateRevision(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try{$id=FilterTemplateRevisions::restore(isset($params['revision'])?(int)$params['revision']:0,Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);} + $this->audit('catalog.filter_template_revision_restored',$id);return $this->success('Ревизия восстановлена в черновик',array('data'=>array('id'=>$id))); + } + + public function deleteFilterTemplateRevision(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; }$id=FilterTemplateRevisions::delete(isset($params['revision'])?(int)$params['revision']:0); + return $id?$this->success('Ревизия удалена',array('data'=>array('id'=>$id))):$this->error('Ревизия не найдена',array(),404); + } + + public function deleteFilterTemplateRevisions(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; }$id=isset($params['id'])?(int)$params['id']:0; + if(!FilterTemplates::one($id)){return $this->error('Представление не найдено',array(),404);} + return $this->success('Ревизии удалены',array('data'=>array('count'=>FilterTemplateRevisions::deleteFor($id)))); + } + + public function createCardTemplate(array $params = array()) + { + return $this->persistCardTemplate(0); + } + + public function saveCardTemplate(array $params = array()) + { + return $this->persistCardTemplate(isset($params['id']) ? (int) $params['id'] : 0); + } + + public function lintCardTemplate(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $result = CardTemplateSyntax::check(Request::postStr('draft_markup', '')); + return $result['ok'] + ? $this->success($result['message'], array('data' => $result)) + : $this->error($result['message'], array('draft_markup' => $result['message']), 422); + } + + public function previewCardTemplate(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $markup = Request::postStr('draft_markup', ''); + $css = Request::postStr('draft_css', ''); + $syntax = CardTemplateSyntax::check($markup); + if (!$syntax['ok']) { return $this->error($syntax['message'], array('draft_markup' => $syntax['message']), 422); } + try { + $this->bootPublicCatalogPreview(); + $item = \App\Modules\Products\ProductCardRepository::find(Request::postInt('preview_product_id', 0)); + if (!$item) { throw new \RuntimeException('Выберите существующий товар'); } + $card = Twig::twig()->createTemplate($markup, 'catalog-card-admin-preview')->render(array('item' => $item)); + $html = '' + . '' + . '' + . '' + . ($css !== '' ? '' : '') + . '
' . $card . '
'; + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Предпросмотр обновлён', array('data' => array( + 'html' => $html, + 'json' => json_encode($item, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), + ))); + } + + public function publishCardTemplate(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + try { $item = CardTemplates::publish($id, Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.card_template_published', $id, array('version' => $item['version'])); + return $this->success('Карточка опубликована', array('data' => $item)); + } + + public function copyCardTemplate(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { $id = CardTemplates::copy(isset($params['id']) ? (int) $params['id'] : 0, Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.card_template_copied', $id); + return $this->success('Копия создана', array('data' => array('id' => $id))); + } + + public function deleteCardTemplate(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + try { $deleted = CardTemplates::delete($id); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + if (!$deleted) { return $this->error('Представление не найдено', array(), 404); } + $this->audit('catalog.card_template_deleted', $id); + return $this->success('Представление удалено'); + } + + public function cardTemplateRevisions(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + $id = isset($params['id']) ? (int) $params['id'] : 0; + $item = CardTemplates::one($id); + if (!$item) { return $this->error('Представление не найдено', array(), 404); } + return $this->success('', array('data' => array('template' => array('id' => $id, 'title' => $item['title']), 'revisions' => CardTemplateRevisions::listFor($id)))); + } + + public function cardTemplateRevision(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + $item = CardTemplateRevisions::one(isset($params['revision']) ? (int) $params['revision'] : 0); + return $item ? $this->success('', array('data' => $item)) : $this->error('Ревизия не найдена', array(), 404); + } + + public function restoreCardTemplateRevision(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { $id = CardTemplateRevisions::restore(isset($params['revision']) ? (int) $params['revision'] : 0, Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.card_template_revision_restored', $id); + return $this->success('Ревизия восстановлена в черновик', array('data' => array('id' => $id))); + } + + public function deleteCardTemplateRevision(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = CardTemplateRevisions::delete(isset($params['revision']) ? (int) $params['revision'] : 0); + return $id ? $this->success('Ревизия удалена', array('data' => array('id' => $id))) : $this->error('Ревизия не найдена', array(), 404); + } + + public function deleteCardTemplateRevisions(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!CardTemplates::one($id)) { return $this->error('Представление не найдено', array(), 404); } + return $this->success('Ревизии удалены', array('data' => array('count' => CardTemplateRevisions::deleteFor($id)))); + } + + public function attributes(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + $this->attributeAssets(); + $view = Request::getStr('view', 'attributes'); + if (!in_array($view, array('normalization', 'attributes', 'sets', 'migration', 'sections'), true)) { $view = 'attributes'; } + return $this->render('@products/attributes.twig', array( + 'view' => $view, 'stats' => Attributes::stats(), 'value_types' => Attributes::valueTypes(), + 'attributes' => Attributes::all(Request::getStr('q', ''), Request::getStr('status', '')), + 'sets' => Attributes::sets(), 'legacy_fields' => $view === 'migration' ? Attributes::legacyFields() : array(), + 'sections' => $view === 'sections' ? Attributes::sections() : array(), + 'normalization' => $view === 'normalization' ? AttributeNormalization::audit() : array(), + 'filters' => array('q'=>Request::getStr('q',''),'status'=>Request::getStr('status','')), + 'can_manage' => Permission::check('manage_catalog'), + )); + } + + public function prepareAttributeNormalization(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { $run = AttributeNormalization::prepare(Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.attribute_normalization_prepared', (int) $run['id'], $run['summary']); + return $this->success('Черновой контур характеристик подготовлен', array( + 'redirect' => $this->base() . '/catalog/attributes?view=normalization', + 'data' => $run, + )); + } + + public function approveAttributeNormalization(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + try { $run = AttributeNormalization::approve($id, Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.attribute_normalization_approved', $id, $run['summary']); + return $this->success('Безопасные значения подтверждены, публичный режим не изменён', array( + 'redirect' => $this->base() . '/catalog/attributes?view=normalization', + 'data' => $run, + )); + } + + public function attributeNormalizationReport(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + $run = AttributeNormalization::run(isset($params['id']) ? (int) $params['id'] : 0); + return $run ? $this->success('', array('data' => $run)) : $this->error('Прогон не найден', array(), 404); + } + + public function saveAttribute(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + try { $id = Attributes::save($id, Request::postAll(), Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.attribute_saved', $id); + return $this->success('Характеристика сохранена', array('data'=>array('id'=>$id))); + } + + public function attribute(array $params = array()) + { + if(!Permission::check('view_catalog')){return $this->error('Недостаточно прав',array(),403);}$item=Attributes::one(isset($params['id'])?(int)$params['id']:0);return $item?$this->success('',array('data'=>$item)):$this->error('Характеристика не найдена',array(),404); + } + + public function deleteAttribute(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { Attributes::delete(isset($params['id']) ? (int) $params['id'] : 0); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.attribute_deleted', (int) $params['id']); + return $this->success('Характеристика удалена'); + } + + public function attributeSet(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + $set = Attributes::set(isset($params['id']) ? (int) $params['id'] : 0); + if (!$set) { return $this->renderStatus('@adminx/404.twig', array('title'=>'Набор не найден'), 404); } + $this->attributeAssets(); + return $this->render('@products/attribute-set.twig', array('set'=>$set,'attributes'=>Attributes::all('', 'active'),'can_manage'=>Permission::check('manage_catalog'))); + } + + public function saveAttributeSet(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id=isset($params['id'])?(int)$params['id']:0; + try{$id=Attributes::saveSet($id,Request::postAll(),Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);} + $this->audit('catalog.attribute_set_saved',$id);return $this->success('Набор сохранён',array('redirect'=>$this->base().'/catalog/attribute-sets/'.$id,'data'=>array('id'=>$id))); + } + + public function deleteAttributeSet(array $params = array()) + { + if(($error=$this->guard())!==null){return $error;}try{Attributes::deleteSet((int)$params['id']);}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.attribute_set_deleted',(int)$params['id']);return $this->success('Набор удалён',array('redirect'=>$this->base().'/catalog/attributes?view=sets')); + } + + public function saveAttributeGroup(array $params = array()) + { + if(($error=$this->guard())!==null){return $error;}try{$id=Attributes::saveGroup((int)$params['id'],Request::postInt('group_id',0),Request::postStr('name',''));}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Группа сохранена',array('data'=>array('id'=>$id))); + } + + public function deleteAttributeGroup(array $params = array()) + { + if(($error=$this->guard())!==null){return $error;}Attributes::deleteGroup((int)$params['id'],(int)$params['group']);return $this->success('Группа удалена, характеристики перенесены в «Без группы»'); + } + + public function saveAttributeSetItem(array $params = array()) + { + if(($error=$this->guard())!==null){return $error;}try{$id=Attributes::saveSetItem((int)$params['id'],Request::postAll());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Характеристика добавлена',array('data'=>array('id'=>$id))); + } + + public function deleteAttributeSetItem(array $params = array()) + { + if(($error=$this->guard())!==null){return $error;}Attributes::deleteSetItem((int)$params['id'],(int)$params['attribute']);return $this->success('Характеристика удалена из набора'); + } + + public function saveAttributeMapping(array $params = array()) + { + if(($error=$this->guard())!==null){return $error;}try{$data=Attributes::saveLegacyMap((int)$params['field'],Request::postAll(),Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Сопоставление сохранено',array('data'=>$data)); + } + + public function attributeMappingPreview(array $params = array()) + { + if(!Permission::check('view_catalog')){return $this->error('Недостаточно прав',array(),403);}try{$data=Attributes::migrationPreview((int)$params['field']);}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('',array('data'=>$data)); + } + + public function migrateAttributeField(array $params = array()) + { + if(($error=$this->guard())!==null){return $error;}try{$data=Attributes::migrate((int)$params['field'],Auth::id(),Request::postBool('verified',false));}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.attribute_field_migrated',(int)$params['field'],$data);return $this->success('Значения перенесены в новый контур',array('data'=>$data)); + } + + public function verifyAttributeField(array $params = array()) + { + if(($error=$this->guard())!==null){return $error;}try{$data=Attributes::verifyMigration((int)$params['field'],Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.attribute_field_verified',(int)$params['field'],$data);return $this->success('Перенесённые значения подтверждены',array('data'=>$data)); + } + + public function saveAttributeSection(array $params = array()) + { + if(($error=$this->guard())!==null){return $error;}try{$data=Attributes::saveSection((int)$params['id'],Request::postAll(),Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Режим раздела сохранён',array('data'=>$data)); + } + + public function rebuildAttributeShadow(array $params = array()) + { + if(($error=$this->guard())!==null){return $error;}try{$data=Attributes::rebuildShadowIndex((int)$params['id']);}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.attribute_shadow_rebuilt',(int)$params['id'],$data);return $this->success('Теневой индекс раздела перестроен',array('data'=>$data)); + } + + public function attributeShadowReport(array $params = array()) + { + if(!Permission::check('view_catalog')){return $this->error('Недостаточно прав',array(),403);}try{$data=Attributes::shadowReport((int)$params['id']);}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('',array('data'=>$data)); + } + + public function saveQuickEdit(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $productId = isset($params['id']) ? (int) $params['id'] : 0; + $key = Request::postStr('key', ''); + try { $saved = QuickEditor::save($productId, $key, Request::postStr('value', ''), Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array('value' => $e->getMessage()), 422); } + $this->audit('catalog.product_quick_updated', $productId, $saved); + return $this->success('Изменение сохранено', array('data' => $saved)); + } + + public function setProductPaymentProgram(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $productId = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::product($productId)) { return $this->error('Товар не найден', array(), 404); } + try { $enabled = PaymentPrograms::setProduct($productId, isset($params['program']) ? $params['program'] : '', Request::postBool('enabled', false)); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.product_payment_program_changed', $productId, array('program' => (string) $params['program'], 'enabled' => $enabled)); + return $this->success($enabled ? 'Оплата через СФР доступна товару' : 'Оплата через СФР отключена для товара', array('data' => array('enabled' => $enabled))); + } + + public function createCatalog(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { $catalog = Model::createCatalog(Request::postAll()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.created', $catalog['field_id'], $catalog); + return $this->success('Каталог создан', array('redirect'=>$this->base().'/catalog/'.$catalog['rubric_id'].'/'.$catalog['field_id'],'data'=>$catalog)); + } + + public function variantGroups(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + if (!Model::hasCommerceCatalogs()) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарные каталоги не настроены'), 404); } + $this->assets(); + return $this->render('@products/variant-groups.twig', array( + 'groups' => VariantGroups::all(), + 'selector_settings' => VariantSelectorSettings::get(), + 'can_manage' => Permission::check('manage_catalog'), + )); + } + + public function saveVariantSelectorSettings(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $settings = VariantSelectorSettings::save(Request::postAll()); + $this->audit('catalog.variant_selector_settings_saved', 0, $settings); + return $this->success('Вид выбора вариантов сохранён', array( + 'redirect' => $this->base() . '/catalog/variant-groups', + 'data' => $settings, + )); + } + + public function variantGroup(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + $group = VariantGroups::one(isset($params['id']) ? (int) $params['id'] : 0); + if (!$group) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Группа не найдена'), 404); } + $this->shippingAssets(); + $search = Request::getStr('q', ''); + return $this->render('@products/variant-group.twig', array( + 'group' => $group, 'search' => $search, + 'variant_fields' => VariantGroups::fields((int) $group['id']), + 'variant_attributes' => VariantGroups::nativeAttributes(), + 'package_templates' => ShippingPackageTemplates::all(), + 'candidates' => $search !== '' ? Model::products(array('q' => $search, 'page' => 1)) : array('items' => array()), + 'can_manage' => Permission::check('manage_catalog'), + )); + } + + public function saveVariantGroup(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + try { $id = VariantGroups::save($id, Request::postStr('title', ''), Request::postBool('status', false), Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.variant_group_saved', $id); + return $this->success('Группа вариантов сохранена', array('redirect' => $this->base() . '/catalog/variant-groups/' . $id)); + } + + public function addVariantProduct(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { VariantGroups::add((int) $params['id'], Request::postInt('product_id', 0)); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.variant_added', (int) $params['id'], array('product_id' => Request::postInt('product_id', 0))); + return $this->success('Товар добавлен в группу'); + } + + public function primaryVariantProduct(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { VariantGroups::primary((int) $params['id'], (int) $params['product']); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.variant_primary_changed', (int) $params['id'], array('product_id' => (int) $params['product'])); + return $this->success('Основной вариант изменён'); + } + + public function removeVariantProduct(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { VariantGroups::remove((int) $params['id'], (int) $params['product']); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.variant_removed', (int) $params['id'], array('product_id' => (int) $params['product'])); + return $this->success('Товар исключён из группы'); + } + + public function statusVariantProduct(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $status = Request::postBool('status', false); + try { VariantGroups::status((int) $params['id'], (int) $params['product'], $status); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.variant_status_changed', (int) $params['id'], array('product_id' => (int) $params['product'], 'status' => $status)); + return $this->success($status ? 'Вариант включён' : 'Вариант выключен'); + } + + public function saveVariantAttribute(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { VariantGroups::saveAttribute((int) $params['id'], (int) $params['product'], Request::postInt('field_id', 0), Request::postStr('label', ''), Request::postStr('swatch', '')); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.variant_attribute_saved', (int) $params['id'], array('product_id' => (int) $params['product'], 'field_id' => Request::postInt('field_id', 0))); + return $this->success('Атрибут варианта сохранён'); + } + + public function deleteVariantAttribute(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + VariantGroups::deleteAttribute((int) $params['id'], (int) $params['product'], (int) $params['field']); + $this->audit('catalog.variant_attribute_deleted', (int) $params['id'], array('product_id' => (int) $params['product'], 'field_id' => (int) $params['field'])); + return $this->success('Атрибут удалён'); + } + + public function saveVariantNativeAttribute(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $attributeId = Request::postInt('attribute_id', 0); + try { + VariantGroups::saveNativeAttribute( + (int) $params['id'], + (int) $params['product'], + $attributeId, + Request::postInt('option_id', 0), + Request::postStr('label', ''), + Request::postStr('swatch', '') + ); + } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.variant_native_attribute_saved', (int) $params['id'], array( + 'product_id' => (int) $params['product'], + 'attribute_id' => $attributeId, + 'option_id' => Request::postInt('option_id', 0), + )); + return $this->success('Характеристика варианта сохранена'); + } + + public function deleteVariantNativeAttribute(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + VariantGroups::deleteNativeAttribute((int) $params['id'], (int) $params['product'], (int) $params['attribute']); + $this->audit('catalog.variant_native_attribute_deleted', (int) $params['id'], array( + 'product_id' => (int) $params['product'], + 'attribute_id' => (int) $params['attribute'], + )); + return $this->success('Характеристика варианта удалена'); + } + + public function reorderVariantProducts(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $order = Request::postJsonArray('order'); + if (!is_array($order)) { return $this->error('Некорректный порядок', array(), 422); } + try { VariantGroups::reorder((int) $params['id'], $order); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.variants_reordered', (int) $params['id'], array('order' => $order)); + return $this->success('Порядок вариантов сохранён'); + } + + public function copyVariantProduct(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $sourceId = (int) $params['product']; + $newId = 0; + try { + $newId = DocumentsModel::copy($sourceId, Auth::id()); + Model::reindexDocument($newId); + VariantGroups::add((int) $params['id'], $newId); + } catch (\Throwable $e) { + if ($newId > 0) { try { DocumentsModel::delete($newId); DocumentsModel::purge($newId); } catch (\Throwable $cleanupError) {} } + return $this->error($e->getMessage(), array(), 422); + } + + $this->audit('catalog.variant_copied', (int) $params['id'], array('source_id' => $sourceId, 'product_id' => $newId)); + return $this->success('Вариант создан копированием', array('redirect' => $this->base() . '/catalog/products/' . $newId . '/edit')); + } + + public function deleteVariantGroup(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { VariantGroups::delete((int) $params['id']); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.variant_group_deleted', (int) $params['id']); + return $this->success('Группа удалена', array('redirect' => $this->base() . '/catalog/variant-groups')); + } + + public function reindexProducts(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { $result = Model::reindexProducts(); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 500); } + $this->audit('catalog.products_reindexed', null, $result); + return $this->success('Индекс товаров перестроен', array('data' => $result)); + } + + public function reindexProduct(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + if ($id <= 0 || !DocumentsModel::one($id)) { return $this->error('Товар не найден', array(), 404); } + try { + $result = Model::reindexDocument($id); + $product = Model::product($id); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 500); + } + + $this->audit('catalog.product_reindexed', $id, $result); + return $this->success('Товар переиндексирован', array('data' => array('product' => $product, 'result' => $result))); + } + + public function createProductVariantGroup(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $productId = isset($params['id']) ? (int) $params['id'] : 0; + $product = Model::product($productId); + if (!$product) { return $this->error('Товар не найден', array(), 404); } + if (VariantGroups::membership($productId)) { return $this->error('Товар уже состоит в группе', array(), 422); } + try { + $groupId = VariantGroups::save(0, Request::postStr('title', (string) $product['title']), true, Auth::id()); + VariantGroups::add($groupId, $productId); + } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.variant_group_created_from_product', $groupId, array('product_id' => $productId)); + return $this->success('Группа вариантов создана', array('redirect' => $this->base() . '/catalog/variant-groups/' . $groupId)); + } + + public function editProduct(array $params = array()) + { + if (!Permission::check('manage_catalog') || !Permission::check('manage_documents')) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); + } + + $id = isset($params['id']) ? (int) $params['id'] : 0; + $product = Model::product($id); + $document = DocumentsModel::one($id); + if (!$product || !$document) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товар не найден'), 404); } + AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); + AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50); + CodeEditor::useRichEditor(); + $this->attributeAssets(); + return $this->render('@documents/edit.twig', array( + 'document' => $document, + 'field_groups' => DocumentsModel::fieldsForDocument($id), + 'rubrics' => DocumentsModel::rubrics(), + 'templates' => DocumentsModel::rubricTemplates((int) $document['rubric_id']), + 'navigation_items' => DocumentsModel::navigationItems(), + 'authors' => DocumentsModel::authors(), + 'is_new' => false, 'can_manage' => true, + 'actor_id' => Auth::id(), + 'catalog_mode' => true, 'product' => $product, 'variant_group' => VariantGroups::membership($id), + 'shipping_profile' => ProductShipping::profile($id), + 'package_templates' => ShippingPackageTemplates::all(), + 'native_attributes' => Attributes::productEditor($id), + 'return_url' => $this->base() . '/catalog/products', + 'submit_url' => $this->base() . '/documents/' . $id, + )); + } + + public function saveProductShipping(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $productId = isset($params['id']) ? (int) $params['id'] : 0; + $packages = Request::postJsonArray('packages'); + if (!is_array($packages)) { return $this->error('Некорректный список грузовых мест', array(), 422); } + try { $profile = ProductShipping::save($productId, Request::postBool('shipping_enabled', false), $packages); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.product_shipping_saved', $productId, array('packages' => count($profile['packages']), 'complete' => $profile['complete'])); + return $this->success('Упаковка товара сохранена', array('data' => array('profile' => $profile))); + } + + public function saveProductAttributes(array $params = array()) + { + if(($error=$this->guard())!==null){return $error;}$productId=isset($params['id'])?(int)$params['id']:0;if(!Model::product($productId)){return $this->error('Товар не найден',array(),404);}try{$data=Attributes::saveProductValues($productId,Request::postAll(),Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.product_attributes_saved',$productId,$data);return $this->success('Характеристики товара сохранены',array('data'=>$data)); + } + + public function copyProductShippingToVariants(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $productId = isset($params['id']) ? (int)$params['id'] : 0; $membership = VariantGroups::membership($productId); + if (!$membership) { return $this->error('Товар не входит в группу вариантов', array(), 422); } + $group = VariantGroups::one((int)$membership['id']); $targets = array(); + foreach (isset($group['variants']) ? $group['variants'] : array() as $variant) { $targets[] = (int)$variant['product_id']; } + try { $done = ProductShipping::copy($productId, $targets); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.product_shipping_copied', $productId, array('group_id'=>(int)$membership['id'],'updated'=>$done)); + return $this->success('Упаковка применена к вариантам: ' . $done, array('data'=>array('updated'=>$done))); + } + + public function bulkProductShipping(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $ids=Request::postJsonArray('ids');if(!is_array($ids)||count($ids)<1||count($ids)>200){return $this->error('Выберите от 1 до 200 товаров',array(),422);} + $enabled=Request::postBool('enabled',false);$done=ProductShipping::setEnabled($ids,$enabled); + $this->audit('catalog.product_shipping_bulk_status',null,array('enabled'=>$enabled,'updated'=>$done)); + return $this->success(($enabled?'Расчёт включён: ':'Расчёт выключен: ').$done,array('data'=>array('updated'=>$done))); + } + + public function shippingPackageTemplates(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + return $this->success('', array('data' => array('templates' => ShippingPackageTemplates::all()))); + } + + public function saveShippingPackageTemplate(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + try { $template = ShippingPackageTemplates::save($id, Request::postAll()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit($id > 0 ? 'catalog.shipping_template_saved' : 'catalog.shipping_template_created', $template['id'], array('title' => $template['title'])); + return $this->success($id > 0 ? 'Шаблон упаковки сохранён' : 'Шаблон упаковки создан', array('data' => array( + 'template' => $template, + 'templates' => ShippingPackageTemplates::all(), + ))); + } + + public function deleteShippingPackageTemplate(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + $template = ShippingPackageTemplates::one($id); + if (!$template) { return $this->error('Шаблон упаковки не найден', array(), 404); } + ShippingPackageTemplates::delete($id); + $this->audit('catalog.shipping_template_deleted', $id, array('title' => $template['title'])); + return $this->success('Шаблон упаковки удалён', array('data' => array('templates' => ShippingPackageTemplates::all()))); + } + + public function createProduct(array $params = array()) + { + if (!Permission::check('manage_catalog') || !Permission::check('manage_documents')) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); + } + + $allowed = Model::productRubrics(); + $rubricId = Request::getInt('rubric_id', 0); + if (!in_array($rubricId, $allowed, true)) { $rubricId = !empty($allowed) ? (int) reset($allowed) : 0; } + if ($rubricId <= 0) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Товарная рубрика не настроена'), 404); } + $document = DocumentsModel::blank($rubricId); + AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); + AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50); + CodeEditor::useRichEditor(); $this->assets(); + return $this->render('@documents/edit.twig', array( + 'document' => $document, 'field_groups' => DocumentsModel::fieldsForRubric($rubricId, 0), + 'rubrics' => array_values(array_filter(DocumentsModel::rubrics(), function ($rubric) use ($allowed) { return in_array((int) $rubric['Id'], $allowed, true); })), + 'templates' => DocumentsModel::rubricTemplates($rubricId), 'navigation_items' => DocumentsModel::navigationItems(), + 'authors' => DocumentsModel::authors(), 'is_new' => true, 'can_manage' => true, + 'actor_id' => Auth::id(), + 'catalog_mode' => true, 'return_url' => $this->base() . '/catalog/products', + 'submit_url' => $this->base() . '/documents', + 'stay_url_template' => $this->base() . '/catalog/products/{id}/edit', + )); + } + + public function edit(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + $rubricId = isset($params['rubric']) ? (int) $params['rubric'] : 0; + $fieldId = isset($params['field']) ? (int) $params['field'] : 0; + $catalog = Model::catalog($rubricId, $fieldId); + if (!$catalog) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Каталог не найден'), 404); } + $this->assets(); + $fields = Model::rubricFields($rubricId); + $fieldGroups = array(); + foreach ($fields as &$field) { + $field['filter_options'] = Model::filterStyleOptions($field['type']); + $editor = FieldAdminEditors::describe($field['type']); + $field['type_title'] = isset($editor['title']) && $editor['title'] !== '' ? (string) $editor['title'] : (string) $field['type']; + $groupId = (int) $field['group_id']; + if (!isset($fieldGroups[$groupId])) { + $fieldGroups[$groupId] = array( + 'id' => $groupId, + 'title' => $field['group'] !== '' ? $field['group'] : 'Без группы', + 'items' => array(), + ); + } + + $fieldGroups[$groupId]['items'][] = &$field; + } + + unset($field); + $settings = Model::settings($rubricId, $fieldId); + $productsAvailable = $this->productsAvailable(); + $filterTemplatesAvailable = $productsAvailable && FilterTemplates::available(); + $settings['fields_default_ids'] = $this->ids(isset($settings['fields_default']) ? $settings['fields_default'] : ''); + $settings['filters_default_ids'] = $this->ids(isset($settings['filters_default']) ? $settings['filters_default'] : ''); + $styles = @unserialize(isset($settings['filters_default_settings']) ? (string) $settings['filters_default_settings'] : '', array('allowed_classes' => false)); + $settings['filter_styles'] = is_array($styles) ? $styles : array(); + return $this->render('@catalog/edit.twig', array( + 'catalog' => $catalog, 'settings' => $settings, + 'tree' => Model::tree($rubricId, $fieldId), 'flat' => Model::items($rubricId, $fieldId), + 'fields' => $fields, + 'field_groups' => array_values($fieldGroups), + 'request_options' => Model::requestOptions(), + 'navigation_options' => Model::navigationOptions(), + 'rubric_options' => Model::rubricOptions(), + 'card_template_options' => $productsAvailable ? CardTemplates::options() : array(), + 'filter_template_options' => $filterTemplatesAvailable ? FilterTemplates::options() : array(), + 'filter_templates_available' => $filterTemplatesAvailable, + 'products_available' => $productsAvailable, + 'can_manage' => Permission::check('manage_catalog'), + )); + } + + public function item(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + $item = Model::item(isset($params['id']) ? (int) $params['id'] : 0); + if ($item) { $item['condition_context'] = Model::conditionContext($item['id']); } + return $item ? $this->success('', array('data' => $item)) : $this->error('Раздел не найден', array(), 404); + } + + public function documents(array $params = array()) + { + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + return $this->success('', array('data' => array( + 'items' => Model::searchDocuments(Request::getStr('q', ''), Request::getInt('limit', 30)), + ))); + } + + public function storeItem(array $params = array()) { return $this->persistItem(0, $params); } + + public function updateItem(array $params = array()) + { + return $this->persistItem(isset($params['id']) ? (int) $params['id'] : 0, $params); + } + + public function deleteItem(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + $item = Model::item($id); + if (!$item || $item['rubric_id'] !== (int) $params['rubric'] || $item['field_id'] !== (int) $params['field']) { + return $this->error('Раздел не найден', array(), 404); + } + + $deleted = Model::deleteItem($id); + $this->audit('catalog.item_deleted', $id, array('ids' => $deleted)); + return $this->success('Раздел и вложенные разделы удалены'); + } + + public function reorder(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $rows = Request::postJsonArray('order'); + if (!is_array($rows)) { return $this->error('Некорректный порядок разделов', array(), 422); } + try { Model::reorder((int) $params['rubric'], (int) $params['field'], $rows); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.reordered', null, array('rubric_id' => (int) $params['rubric'], 'field_id' => (int) $params['field'])); + return $this->success('Порядок разделов сохранён'); + } + + public function setItemStatus(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + try { $status = Model::setItemStatus($id, (int) $params['rubric'], (int) $params['field'], Request::postBool('status', false)); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 404); } + $this->audit('catalog.item_status_changed', $id, array('status' => $status)); + return $this->success($status ? 'Раздел включён' : 'Раздел скрыт', array('data' => array('status' => $status))); + } + + public function recompileItem(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + try { $result = Model::recompileItem((int) $params['rubric'], (int) $params['field'], $id); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.filters_recompiled', $id, array('rubric_id' => (int) $params['rubric'], 'field_id' => (int) $params['field'])); + return $this->success('Фильтры раздела пересобраны', array('data' => $result)); + } + + public function saveSettings(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { $id = Model::saveSettings((int) $params['rubric'], (int) $params['field'], Request::postAll()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.settings_saved', $id, array('rubric_id' => (int) $params['rubric'], 'field_id' => (int) $params['field'])); + return $this->success('Настройки каталога сохранены'); + } + + public function syncCondition(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $itemId = isset($params['id']) ? (int) $params['id'] : 0; + $filterId = isset($params['filter']) ? (int) $params['filter'] : 0; + if (!$this->itemInCatalog($itemId, $params)) { return $this->error('Раздел не найден', array(), 404); } + try { $context = Model::syncCondition($itemId, $filterId, Request::postBool('force', false)); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), strpos($e->getMessage(), 'отличается') !== false ? 409 : 422); } + $this->audit('catalog.condition_synced', $itemId, array('field_id' => $filterId, 'request_id' => (int) $context['request']['id'])); + return $this->success('Условие запроса синхронизировано', array('data' => array('condition_context' => $context))); + } + + public function syncMissingConditions(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $itemId = isset($params['id']) ? (int) $params['id'] : 0; + if (!$this->itemInCatalog($itemId, $params)) { return $this->error('Раздел не найден', array(), 404); } + try { $context = Model::syncMissingConditions($itemId); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.conditions_synced', $itemId, array('created' => (int) $context['created'], 'request_id' => (int) $context['request']['id'])); + $message = (int) $context['created'] > 0 ? 'Недостающие условия созданы: ' . (int) $context['created'] : 'Все доступные условия уже созданы'; + return $this->success($message, array('data' => array('condition_context' => $context))); + } + + protected function persistItem($id, array $params) + { + if (($error = $this->guard()) !== null) { return $error; } + try { $saved = Model::saveItem($id, (int) $params['rubric'], (int) $params['field'], Request::postAll()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array('name' => $e->getMessage()), 422); } + $this->audit($id > 0 ? 'catalog.item_saved' : 'catalog.item_created', $saved, array('rubric_id' => (int) $params['rubric'], 'field_id' => (int) $params['field'])); + return $this->success($id > 0 ? 'Раздел сохранён' : 'Раздел создан', array('data' => array( + 'id' => $saved, + 'condition_context' => Model::conditionContext($saved), + ))); + } + + protected function persistCardTemplate($id) + { + if (($error = $this->guard()) !== null) { return $error; } + try { $saved = CardTemplates::save((int) $id, Request::postAll(), Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array('draft_markup' => $e->getMessage()), 422); } + $this->audit($id > 0 ? 'catalog.card_template_saved' : 'catalog.card_template_created', $saved); + return $this->success($id > 0 ? 'Черновик сохранён' : 'Представление создано', array('data' => array('id' => $saved))); + } + + protected function persistFilterTemplate($id) + { + if (($error = $this->guard()) !== null) { return $error; } + try { $saved = FilterTemplates::save((int) $id, Request::postAll(), Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array('draft_markup' => $e->getMessage()), 422); } + $this->audit($id > 0 ? 'catalog.filter_template_saved' : 'catalog.filter_template_created', $saved); + return $this->success($id > 0 ? 'Черновик сохранён' : 'Представление создано', array('data' => array('id' => $saved))); + } + + protected function bootPublicCatalogPreview() + { + $path = BASEPATH . '/modules/products/app'; + Load::regNamespace('App\\Modules\\Products', $path); + Twig::addPath($path . '/view', 'products_public'); + ThemeAssets::registerViewOverrides(); + } + + protected function itemInCatalog($id, array $params) + { + $item = Model::item((int) $id); + return $item && $item['rubric_id'] === (int) $params['rubric'] && $item['field_id'] === (int) $params['field']; + } + + protected function guard() + { + return $this->guardPermission('manage_catalog'); + } + + protected function savedViewGuard() + { + if (($error = $this->csrfGuard()) !== null) { return $error; } + if (!Permission::check('view_catalog')) { return $this->error('Недостаточно прав', array(), 403); } + return null; + } + + protected function savedViewDefinition($scope) + { + $definitions = array( + 'products' => array('scope' => 'catalog_products', 'fields' => $this->savedViewFields('products')), + 'quality' => array('scope' => 'catalog_quality', 'fields' => $this->savedViewFields('quality')), + ); + + return isset($definitions[$scope]) ? $definitions[$scope] : null; + } + + protected function savedViewFields($scope) + { + return $scope === 'quality' + ? array('q', 'issue', 'state', 'per_page') + : array('q', 'state', 'issue', 'payment', 'per_page'); + } + + protected function quickEditData() + { + $filters = array( + 'q' => Request::getStr('q', ''), 'rubric_id' => Request::getInt('rubric_id', 0), + 'category_id' => Request::getInt('category_id', 0), 'state' => Request::getStr('state', ''), + 'sfr' => Request::getStr('sfr', ''), 'sfr_code' => Request::getStr('sfr_code', ''), + 'incomplete' => Request::getBool('incomplete', false), 'limit' => Request::getInt('limit', 25), + 'page' => Request::getInt('page', 1), + ); + return array('result' => QuickEditor::items($filters), 'filters' => $filters, + 'rubrics' => QuickEditor::rubrics(), 'categories' => QuickEditor::categories(), + 'can_manage' => Permission::check('manage_catalog')); + } + + protected function assets() + { + AdminAssets::addStyle($this->base() . '/modules/Catalog/assets/catalog.css', 50); + AdminAssets::addStyle($this->base() . '/modules/Catalog/assets/catalog-drawer.css', 51); + AdminAssets::addScript($this->base() . '/modules/Catalog/assets/catalog.js', 50); + if ($this->productsAvailable()) { + if (is_file(BASEPATH . '/modules/products/admin/assets/products.css')) { + AdminAssets::addStyle('/modules/products/admin/assets/products.css', 52); + } + + if (is_file(BASEPATH . '/modules/products/admin/assets/products.js')) { + AdminAssets::addScript('/modules/products/admin/assets/products.js', 51); + } + } + } + + protected function productsAvailable() + { + $module = ModuleManager::get('products'); + return is_array($module) + && !empty($module['installed']) + && !empty($module['enabled']) + && is_file(BASEPATH . '/modules/products/app/module.php') + && is_file(BASEPATH . '/modules/products/admin/module.php'); + } + + protected function attributeAssets() + { + $this->assets(); + AdminAssets::addStyle('/modules/products/admin/assets/attributes.css', 54); + AdminAssets::addStyle('/modules/products/admin/assets/attributes-responsive.css', 55); + AdminAssets::addScript('/modules/products/admin/assets/attributes.js', 54); + } + + protected function shippingAssets() + { + $this->assets(); + AdminAssets::addStyle('/modules/products/admin/assets/shipping.css', 54); + AdminAssets::addScript('/modules/products/admin/assets/shipping.js', 54); + } + + protected function relationAssets() + { + $this->assets(); + AdminAssets::addStyle('/modules/products/admin/assets/product-relations.css', 54); + AdminAssets::addScript('/modules/products/admin/assets/product-relations.js', 54); + } + + protected function collectionAssets() + { + $this->assets(); + AdminAssets::addStyle('/modules/products/admin/assets/product-collections.css', 54); + AdminAssets::addScript('/modules/products/admin/assets/product-collections.js', 54); + } + + protected function cardTemplateAssets() + { + $this->assets(); + CodeEditor::useCodeMirror('htmlmixed'); + CodeEditor::useCodeMirror('text/css'); + AdminAssets::addStyle('/modules/products/admin/assets/card-templates.css', 56); + AdminAssets::addScript('/modules/products/admin/assets/card-templates.js', 56); + } + + protected function audit($action, $targetId = null, array $meta = array()) + { + $user = Auth::user(); + AuditLog::record($action, array( + 'actor_id' => Auth::id(), 'actor_name' => $user && isset($user['name']) ? (string) $user['name'] : '', + 'target_type' => 'catalog', 'target_id' => $targetId, 'meta' => $meta, + )); + } + + protected function ids($value) + { + $out = array(); + foreach (preg_split('/[^0-9]+/', (string) $value) ?: array() as $id) { + if ((int) $id > 0) { $out[] = (int) $id; } + } + + return array_values(array_unique($out)); + } + } diff --git a/adminx/modules/Catalog/Model.php b/adminx/modules/Catalog/Model.php new file mode 100644 index 0000000..5826da4 --- /dev/null +++ b/adminx/modules/Catalog/Model.php @@ -0,0 +1,1097 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Catalog; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Common\AdminLocation; + use App\Common\Cache; + use App\Common\CacheKey; + use App\Common\DatabaseSchema; + use App\Common\FileCacheInvalidator; + use App\Common\ModuleManager; + use App\Content\CatalogTables; + use App\Content\ContentTables; + use App\Content\Fields\FieldValueCodec; + use App\Content\Requests\RequestConditionValue; + use App\Helpers\Json; + use App\Adminx\Rubrics\Model as RubricsModel; + use App\Adminx\Documents\Model as DocumentsModel; + use App\Adminx\Packages\Products\ProductIndexer; + + class Model + { + public static function settingsTable() { return CatalogTables::table('module_catalog_settings'); } + public static function itemsTable() { return CatalogTables::table('module_catalog_items'); } + public static function fieldsTable() { return ContentTables::table('rubric_fields'); } + public static function groupsTable() { return ContentTables::table('rubric_fields_group'); } + public static function rubricsTable() { return ContentTables::table('rubrics'); } + public static function documentsTable() { return ContentTables::table('documents'); } + + public static function catalogs() + { + $rows = DB::query( + 'SELECT f.Id AS field_id, f.rubric_id, f.rubric_field_title, f.rubric_field_alias,' + . ' r.rubric_title, s.*, COUNT(i.id) AS items_count,' + . ' SUM(CASE WHEN i.status = 1 THEN 1 ELSE 0 END) AS active_count' + . ' FROM ' . self::fieldsTable() . ' f' + . ' INNER JOIN ' . self::rubricsTable() . ' r ON r.Id = f.rubric_id' + . ' LEFT JOIN ' . self::settingsTable() . ' s ON s.rubric_id = f.rubric_id AND s.field_id = f.Id' + . ' LEFT JOIN ' . self::itemsTable() . ' i ON i.rubric_id = f.rubric_id AND i.field_id = f.Id' + . " WHERE f.rubric_field_type = 'catalog'" + . ' GROUP BY f.Id ORDER BY r.rubric_title ASC, f.rubric_field_position ASC, f.Id ASC' + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = self::catalogRow($row); + } + + return $out; + } + + public static function hasCommerceCatalogs() + { + return (int) DB::query("SELECT COUNT(*) FROM " . self::settingsTable() . " WHERE purpose='commerce'")->getValue() > 0; + } + + public static function createCatalog(array $input) + { + $rubricId = isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0; + $title = trim(isset($input['title']) ? (string) $input['title'] : ''); + $alias = strtolower(trim(isset($input['alias']) ? (string) $input['alias'] : '')); + $alias = trim(preg_replace('/[^a-z0-9_]+/', '_', $alias), '_'); + $purpose = isset($input['purpose']) && $input['purpose'] === 'commerce' ? 'commerce' : 'content'; + if ($purpose === 'commerce' && !self::productsAvailable()) { + throw new \InvalidArgumentException('Сначала установите и включите модуль «Товары»'); + } + + if ($rubricId < 1 || !DB::query('SELECT Id FROM ' . self::rubricsTable() . ' WHERE Id=%i LIMIT 1', $rubricId)->getValue()) { throw new \InvalidArgumentException('Выберите рубрику'); } + if ($title === '') { throw new \InvalidArgumentException('Укажите название каталога'); } + if ($alias === '') { throw new \InvalidArgumentException('Укажите alias латиницей'); } + if (strlen($alias) > 20) { throw new \InvalidArgumentException('Alias каталога должен быть не длиннее 20 символов'); } + if (DB::query('SELECT Id FROM ' . self::fieldsTable() . ' WHERE rubric_id=%i AND rubric_field_alias=%s LIMIT 1', $rubricId, $alias)->getValue()) { throw new \InvalidArgumentException('Такой alias уже используется в рубрике'); } + $fieldId = RubricsModel::saveField(0, $rubricId, array('rubric_field_title'=>$title,'rubric_field_alias'=>$alias,'rubric_field_type'=>'catalog','rubric_field_description'=>'Структура каталога «'.$title.'»')); + try { + self::saveSettings($rubricId, $fieldId, array('purpose'=>$purpose,'recursive'=>1,'doc_parent'=>1,'item_parent'=>1,'sort_parent'=>1)); + } catch (\Throwable $e) { + DB::Delete(self::settingsTable(), 'rubric_id=%i AND field_id=%i', $rubricId, $fieldId); + RubricsModel::deleteField($fieldId); + throw $e; + } + + return array('rubric_id'=>$rubricId,'field_id'=>$fieldId,'purpose'=>$purpose); + } + + public static function productsAvailable() + { + $module = ModuleManager::get('products'); + return is_array($module) + && !empty($module['installed']) + && !empty($module['enabled']) + && is_file(BASEPATH . '/modules/products/app/module.php') + && is_file(BASEPATH . '/modules/products/admin/module.php'); + } + + public static function catalog($rubricId, $fieldId) + { + foreach (self::catalogs() as $catalog) { + if ($catalog['rubric_id'] === (int) $rubricId && $catalog['field_id'] === (int) $fieldId) { + return $catalog; + } + } + + return null; + } + + public static function settings($rubricId, $fieldId) + { + $row = DB::query( + 'SELECT * FROM ' . self::settingsTable() . ' WHERE rubric_id = %i AND field_id = %i LIMIT 1', + (int) $rubricId, + (int) $fieldId + )->getAssoc(); + $defaults = array( + 'id' => 0, 'rubric_id' => (int) $rubricId, 'field_id' => (int) $fieldId, + 'purpose' => 'content', + 'product_title_field_id'=>0,'product_article_field_id'=>0,'product_price_field_id'=>0,'product_old_price_field_id'=>0,'product_stock_field_id'=>0,'product_images_field_id'=>0,'product_card_template_id'=>0, + 'filter_template_id'=>0,'filter_template_mode'=>'legacy','filter_template_settings'=>'', + 'navi_id' => 0, 'request_id' => 0, 'doc_fileds' => 0, 'recursive' => 1, + 'doc_parent' => 1, 'item_parent' => 1, 'sort_parent' => 1, 'save_names' => 0, + 'filters_use' => 0, 'rub_cat_id' => 0, 'fields_default' => '', + 'filters_default' => '', 'filters_default_settings' => '', + ); + $settings = array_merge($defaults, $row ?: array()); + $card = !empty($settings['product_card_settings']) + ? Json::toArray((string) $settings['product_card_settings']) + : array(); + $settings['product_card'] = self::productCardSettings(is_array($card) ? $card : array()); + $filterPresentation = !empty($settings['filter_template_settings']) + ? Json::toArray((string) $settings['filter_template_settings']) + : array(); + $settings['filter_template'] = self::filterTemplateSettings(is_array($filterPresentation) ? $filterPresentation : array()); + return $settings; + } + + public static function saveSettings($rubricId, $fieldId, array $input) + { + self::requireCatalog($rubricId, $fieldId); + $data = array(); + $data['purpose'] = isset($input['purpose']) && $input['purpose'] === 'commerce' ? 'commerce' : 'content'; + if ($data['purpose'] === 'commerce' && !self::productsAvailable()) { + throw new \InvalidArgumentException('Сначала установите и включите модуль «Товары»'); + } + + if ($data['purpose'] === 'commerce' && DB::query( + "SELECT id FROM " . self::settingsTable() . " WHERE rubric_id=%i AND field_id!=%i AND purpose='commerce' LIMIT 1", + (int) $rubricId, + (int) $fieldId + )->getValue()) { + throw new \InvalidArgumentException('У рубрики уже есть товарный каталог'); + } + + foreach(array('product_title_field_id','product_article_field_id','product_price_field_id','product_old_price_field_id','product_stock_field_id','product_images_field_id') as $key){$data[$key]=isset($input[$key])?max(0,(int)$input[$key]):0;if($data[$key]&&!DB::query('SELECT Id FROM '.self::fieldsTable().' WHERE Id=%i AND rubric_id=%i LIMIT 1',$data[$key],(int)$rubricId)->getValue()){throw new \InvalidArgumentException('Коммерческое поле не принадлежит рубрике');}} + $data['product_card_template_id'] = isset($input['product_card_template_id']) ? max(0, (int) $input['product_card_template_id']) : 0; + if ($data['product_card_template_id'] > 0 && !DB::query( + 'SELECT id FROM ' . CatalogTables::table('catalog_card_templates') . ' WHERE id=%i LIMIT 1', + $data['product_card_template_id'] + )->getValue()) { + throw new \InvalidArgumentException('Выбранное представление карточки не найдено'); + } + + if (self::filterTemplateSchemaAvailable()) { + $data['filter_template_id'] = isset($input['filter_template_id']) ? max(0, (int) $input['filter_template_id']) : 0; + $data['filter_template_mode'] = isset($input['filter_template_mode']) ? (string) $input['filter_template_mode'] : 'legacy'; + if (!in_array($data['filter_template_mode'], array('legacy', 'preview', 'native'), true)) { $data['filter_template_mode'] = 'legacy'; } + if ($data['filter_template_id'] > 0 && !DB::query( + 'SELECT id FROM ' . CatalogTables::table('catalog_filter_templates') . ' WHERE id=%i LIMIT 1', + $data['filter_template_id'] + )->getValue()) { throw new \InvalidArgumentException('Выбранный шаблон фильтров не найден'); } + if ($data['filter_template_mode'] !== 'legacy' && $data['filter_template_id'] <= 0) { + throw new \InvalidArgumentException('Для предпросмотра или нового режима выберите шаблон фильтров'); + } + + if ($data['filter_template_mode'] === 'native' && !DB::query( + 'SELECT id FROM ' . CatalogTables::table('catalog_filter_templates') . ' WHERE id=%i AND is_published=1 LIMIT 1', + $data['filter_template_id'] + )->getValue()) { throw new \InvalidArgumentException('Перед включением нового режима опубликуйте шаблон фильтров'); } + } + + foreach (array('navi_id', 'request_id', 'rub_cat_id') as $key) { + $data[$key] = isset($input[$key]) ? max(0, (int) $input[$key]) : 0; + } + + self::assertOptionExists($data['request_id'], self::requestsTable(), 'Id', 'Выбранный запрос не найден'); + self::assertOptionExists($data['navi_id'], self::navigationTable(), 'navigation_id', 'Выбранная навигация не найдена'); + self::assertOptionExists($data['rub_cat_id'], self::rubricsTable(), 'Id', 'Выбранная рубрика не найдена'); + foreach (array('doc_fileds', 'recursive', 'doc_parent', 'item_parent', 'sort_parent', 'save_names', 'filters_use') as $key) { + $data[$key] = !empty($input[$key]) ? '1' : '0'; + } + + $data['fields_default'] = self::idsString(isset($input['fields_default']) ? $input['fields_default'] : array()); + $data['filters_default'] = self::idsString(isset($input['filters_default']) ? $input['filters_default'] : array()); + $styles = self::filterStyles(isset($input['filter_style']) ? $input['filter_style'] : array(), self::ids($data['filters_default'])); + $data['filters_default_settings'] = !empty($styles) ? serialize($styles) : ''; + $data['product_card_settings'] = Json::encode( + self::productCardSettings(isset($input['product_card']) && is_array($input['product_card']) ? $input['product_card'] : array(), true), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + if (self::filterTemplateSchemaAvailable()) { + $data['filter_template_settings'] = Json::encode( + self::filterTemplateSettings(isset($input['filter_template']) && is_array($input['filter_template']) ? $input['filter_template'] : array(), true), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + } + + $existing = self::settings($rubricId, $fieldId); + if ((int) $existing['id'] > 0) { + DB::Update(self::settingsTable(), $data, 'id = %i', (int) $existing['id']); + if ($data['purpose'] !== $existing['purpose'] && self::productsAvailable()) { + try { ProductIndexer::rebuild(); } + catch (\Throwable $e) { DB::Update(self::settingsTable(), array('purpose'=>$existing['purpose']), 'id=%i', (int) $existing['id']); throw $e; } + } + + Cache::forgetTag(CacheKey::tag('catalog')); + FileCacheInvalidator::publicPresentation(); + return (int) $existing['id']; + } + + $data['rubric_id'] = (int) $rubricId; + $data['field_id'] = (int) $fieldId; + DB::Insert(self::settingsTable(), $data); + $settingsId = (int) DB::insertId(); + if ($data['purpose'] === 'commerce') { + try { ProductIndexer::rebuild(); } + catch (\Throwable $e) { DB::Delete(self::settingsTable(), 'id=%i', $settingsId); throw $e; } + } + + Cache::forgetTag(CacheKey::tag('catalog')); + FileCacheInvalidator::publicPresentation(); + return $settingsId; + } + + protected static function productCardSettings(array $input, $submitted = false) + { + $settings = array( + 'max_images' => isset($input['max_images']) ? max(1, min(5, (int) $input['max_images'])) : 5, + 'delivery_threshold' => isset($input['delivery_threshold']) ? max(0, (float) $input['delivery_threshold']) : 50000, + ); + foreach (array('show_badges', 'show_article', 'show_stock', 'show_category', 'show_delivery', 'show_buy_button', 'show_comparison_price') as $key) { + $settings[$key] = $submitted ? !empty($input[$key]) : (!array_key_exists($key, $input) || !empty($input[$key])); + } + + return $settings; + } + + protected static function filterTemplateSettings(array $input, $submitted = false) + { + $zero = isset($input['zero_behavior']) ? (string) $input['zero_behavior'] : 'disable'; + if (!in_array($zero, array('show', 'disable', 'hide'), true)) { $zero = 'disable'; } + return array( + 'show_counts' => $submitted ? !empty($input['show_counts']) : (!array_key_exists('show_counts', $input) || !empty($input['show_counts'])), + 'zero_behavior' => $zero, + ); + } + + protected static function filterTemplateSchemaAvailable() + { + $table = self::settingsTable(); + return DatabaseSchema::columnExists($table, 'filter_template_id') + && DatabaseSchema::columnExists($table, 'filter_template_mode') + && DatabaseSchema::columnExists($table, 'filter_template_settings'); + } + + public static function requestOptions() + { + $rows = DB::query( + 'SELECT Id, request_title, request_alias, rubric_id FROM ' . self::requestsTable() + . ' ORDER BY request_title ASC, request_alias ASC, Id ASC' + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = array( + 'id' => (int) $row['Id'], + 'title' => self::decode(isset($row['request_title']) ? $row['request_title'] : ''), + 'alias' => (string) $row['request_alias'], + 'rubric_id' => (int) $row['rubric_id'], + ); + } + + return $out; + } + + public static function navigationOptions() + { + $rows = DB::query( + 'SELECT navigation_id, title, alias FROM ' . self::navigationTable() + . ' ORDER BY title ASC, navigation_id ASC' + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = array( + 'id' => (int) $row['navigation_id'], + 'title' => self::decode(isset($row['title']) ? $row['title'] : ''), + 'alias' => (string) $row['alias'], + ); + } + + return $out; + } + + public static function rubricOptions() + { + $rows = DB::query( + 'SELECT Id, rubric_title, rubric_alias FROM ' . self::rubricsTable() + . ' ORDER BY rubric_position ASC, rubric_title ASC, Id ASC' + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = array( + 'id' => (int) $row['Id'], + 'title' => self::decode(isset($row['rubric_title']) ? $row['rubric_title'] : ''), + 'alias' => (string) $row['rubric_alias'], + ); + } + + return $out; + } + + public static function items($rubricId, $fieldId, $activeOnly = false) + { + self::requireCatalog($rubricId, $fieldId); + $sql = 'SELECT i.*, d.document_title, d.document_breadcrumb_title' + . ' FROM ' . self::itemsTable() . ' i' + . ' LEFT JOIN ' . self::documentsTable() . ' d ON d.Id = i.document_id' + . ' WHERE i.rubric_id = %i AND i.field_id = %i'; + if ($activeOnly) { $sql .= ' AND i.status = 1'; } + $sql .= ' ORDER BY i.level ASC, i.parent_id ASC, i.position ASC, i.id ASC'; + $rows = DB::query($sql, (int) $rubricId, (int) $fieldId)->getAll(); + $out = array(); + foreach ($rows as $row) { $out[] = self::itemRow($row); } + return $out; + } + + public static function tree($rubricId, $fieldId, $activeOnly = false) + { + $flat = self::items($rubricId, $fieldId, $activeOnly); + $map = array(); + foreach ($flat as $item) { $item['children'] = array(); $map[$item['id']] = $item; } + $tree = array(); + foreach ($map as $id => $item) { + $parentId = $item['parent_id']; + if ($parentId > 0 && isset($map[$parentId])) { $map[$parentId]['children'][] = &$map[$id]; } + else { $tree[] = &$map[$id]; } + } + + return $tree; + } + + public static function item($id) + { + $row = DB::query( + 'SELECT i.*, d.document_title, d.document_breadcrumb_title FROM ' . self::itemsTable() . ' i' + . ' LEFT JOIN ' . self::documentsTable() . ' d ON d.Id = i.document_id WHERE i.id = %i LIMIT 1', + (int) $id + )->getAssoc(); + return $row ? self::itemRow($row) : null; + } + + public static function saveItem($id, $rubricId, $fieldId, array $input) + { + self::requireCatalog($rubricId, $fieldId); + $id = (int) $id; + $current = $id > 0 ? self::item($id) : null; + if ($id > 0 && (!$current || $current['rubric_id'] !== (int) $rubricId || $current['field_id'] !== (int) $fieldId)) { + throw new \RuntimeException('Раздел каталога не найден'); + } + + $name = trim(isset($input['name']) ? (string) $input['name'] : ''); + if ($name === '') { throw new \RuntimeException('Укажите название раздела'); } + $parentId = isset($input['parent_id']) ? (int) $input['parent_id'] : 0; + self::assertParent($id, $parentId, $rubricId, $fieldId); + $documentId = isset($input['document_id']) ? max(0, (int) $input['document_id']) : 0; + if ($documentId > 0 && !self::documentExists($documentId)) { throw new \RuntimeException('Выбранный документ не найден'); } + $filterIds = self::ids(isset($input['filters_use']) ? $input['filters_use'] : array()); + $filterIds = self::orderedIds(isset($input['filters_order']) ? $input['filters_order'] : array(), $filterIds); + $data = array( + 'rubric_id' => (int) $rubricId, 'field_id' => (int) $fieldId, + 'name' => $name, 'parent_id' => $parentId, + 'status' => !empty($input['status']) ? 1 : 0, + 'document_id' => $documentId ?: null, + 'document_alias' => $documentId ? self::documentAlias($documentId) : '', + 'level' => self::parentLevel($parentId) + 1, + 'fields_use' => self::idsString(isset($input['fields_use']) ? $input['fields_use'] : array()), + 'filters_use' => implode(',', $filterIds), + 'filters_settings' => serialize(self::filterStyles(isset($input['filter_style']) ? $input['filter_style'] : array(), $filterIds)), + ); + if ($id > 0) { + DB::Update(self::itemsTable(), $data, 'id = %i', $id); + } else { + $data['position'] = self::nextPosition($rubricId, $fieldId, $parentId); + DB::Insert(self::itemsTable(), $data); + $id = (int) DB::insertId(); + } + + self::recountLevels($rubricId, $fieldId); + self::syncCategoryDocumentParent($id); + $filtersChanged = !$current + || serialize(isset($current['filters_use']) ? $current['filters_use'] : array()) !== serialize($filterIds) + || serialize(isset($current['filter_styles']) ? $current['filter_styles'] : array()) !== serialize(self::filterStyles(isset($input['filter_style']) ? $input['filter_style'] : array(), $filterIds)); + if ($filtersChanged && (int) self::settings($rubricId, $fieldId)['filters_use'] === 1) { self::recompileFilters($rubricId, array($id)); } + self::clearCache(); + return $id; + } + + public static function deleteItem($id) + { + $item = self::item($id); + if (!$item) { return false; } + $ids = self::descendantIds((int) $id); + $ids[] = (int) $id; + DB::query('DELETE FROM ' . self::itemsTable() . ' WHERE id IN (' . implode(',', $ids) . ')'); + self::clearCache(); + return $ids; + } + + public static function reorder($rubricId, $fieldId, array $rows) + { + self::requireCatalog($rubricId, $fieldId); + $changed = array(); + foreach ($rows as $row) { + if (!is_array($row) || empty($row['id'])) { continue; } + $id = (int) $row['id']; + $item = self::item($id); + if (!$item || $item['rubric_id'] !== (int) $rubricId || $item['field_id'] !== (int) $fieldId) { continue; } + $parentId = isset($row['parent_id']) ? (int) $row['parent_id'] : 0; + self::assertParent($id, $parentId, $rubricId, $fieldId); + DB::Update(self::itemsTable(), array( + 'parent_id' => $parentId, + 'position' => isset($row['position']) ? max(0, (int) $row['position']) : 0, + ), 'id = %i', $id); + if ($parentId !== (int) $item['parent_id']) { $changed[] = $id; } + } + + self::recountLevels($rubricId, $fieldId); + foreach (array_unique($changed) as $id) { self::syncCategoryDocumentParent($id); } + self::clearCache(); + } + + public static function setItemStatus($id, $rubricId, $fieldId, $active) + { + $item = self::item((int) $id); + if (!$item || $item['rubric_id'] !== (int) $rubricId || $item['field_id'] !== (int) $fieldId) { + throw new \RuntimeException('Раздел каталога не найден'); + } + + DB::Update(self::itemsTable(), array('status' => $active ? 1 : 0), 'id = %i', (int) $id); + self::clearCache(); + return $active ? 1 : 0; + } + + public static function recompileItem($rubricId, $fieldId, $itemId) + { + $item = self::item((int) $itemId); + if (!$item || $item['rubric_id'] !== (int) $rubricId || $item['field_id'] !== (int) $fieldId) { + throw new \RuntimeException('Раздел каталога не найден'); + } + + return self::runFilterRecompile((int) $rubricId, (int) $itemId); + } + + public static function rubricFields($rubricId) + { + $rows = DB::query( + 'SELECT f.Id, f.rubric_field_title, f.rubric_field_alias, f.rubric_field_type, f.rubric_field_group, g.group_title' + . ' FROM ' . self::fieldsTable() . ' f LEFT JOIN ' . self::groupsTable() . ' g ON g.Id = f.rubric_field_group' + . ' WHERE f.rubric_id = %i ORDER BY COALESCE(g.group_position,999999), f.rubric_field_position, f.Id', + (int) $rubricId + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = array( + 'id' => (int) $row['Id'], 'title' => html_entity_decode((string) $row['rubric_field_title'], ENT_QUOTES, 'UTF-8'), + 'alias' => (string) $row['rubric_field_alias'], 'type' => (string) $row['rubric_field_type'], + 'group_id' => (int) $row['rubric_field_group'], + 'group' => html_entity_decode((string) $row['group_title'], ENT_QUOTES, 'UTF-8'), + ); + } + + return $out; + } + + public static function searchDocuments($query, $limit = 30) + { + $query = trim((string) $query); + $limit = max(1, min(50, (int) $limit)); + $sql = 'SELECT d.Id, d.rubric_id, d.document_title, d.document_alias, r.rubric_title' + . ' FROM ' . self::documentsTable() . ' d' + . ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id' + . " WHERE d.document_deleted != '1'"; + $args = array(); + if ($query !== '') { + $sql .= ' AND (d.document_title LIKE %ss OR d.document_alias LIKE %ss OR d.Id = %i)'; + $args[] = $query; $args[] = $query; $args[] = (int) $query; + } + + $sql .= ' ORDER BY d.document_changed DESC, d.Id DESC LIMIT ' . $limit; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = array( + 'id' => (int) $row['Id'], 'rubric_id' => (int) $row['rubric_id'], + 'title' => self::decode($row['document_title']), 'alias' => (string) $row['document_alias'], + 'rubric_title' => self::decode(isset($row['rubric_title']) ? $row['rubric_title'] : ''), + ); + } + + return $out; + } + + public static function parseValue($value) + { + $data = FieldValueCodec::decodeStructured($value, array()); + return array( + 'catalog_ids' => self::ids(isset($data['catalogs']) ? $data['catalogs'] : array()), + 'document_ids' => self::ids(isset($data['documents']) ? $data['documents'] : array()), + 'names' => isset($data['names']) ? (string) $data['names'] : '', + ); + } + + public static function products(array $filters = array()) + { + return self::productsAvailable() ? ProductIndexer::products($filters) : array(); + } + + public static function productShippingStats() + { + return self::productsAvailable() ? ProductIndexer::shippingStats() : array('total'=>0,'ready'=>0,'incomplete'=>0,'disabled'=>0); + } + + public static function productStats() + { + return self::productsAvailable() + ? ProductIndexer::stats() + : array('total' => 0, 'active' => 0, 'inactive' => 0, 'categories' => 0); + } + + public static function productQualityIssues(array $stats) + { + return self::productsAvailable() ? ProductIndexer::qualityIssues($stats) : array(); + } + + public static function product($id) + { + return self::productsAvailable() ? ProductIndexer::product($id) : null; + } + + public static function productRubrics() + { + return self::productsAvailable() ? ProductIndexer::productRubrics() : array(); + } + + public static function reindexProducts($limit = 0) + { + return self::productsAvailable() + ? ProductIndexer::rebuild($limit) + : array('products' => 0, 'categories' => 0, 'filters' => 0); + } + + public static function reindexDocument($documentId) + { + return self::productsAvailable() + ? ProductIndexer::indexDocument((int) $documentId) + : array('categories' => 0, 'filters' => 0); + } + + public static function selection($rubricId, $fieldId, $input) + { + $ids = self::ids($input); + if (empty($ids)) { return array(); } + $valid = array(); + foreach (self::items($rubricId, $fieldId, false) as $item) { $valid[$item['id']] = true; } + return array_values(array_filter($ids, function ($id) use ($valid) { return isset($valid[$id]); })); + } + + public static function allowedFieldIds($rubricId, $fieldId, array $selected) + { + $settings = self::settings($rubricId, $fieldId); + if ((int) $settings['doc_fileds'] !== 1) { return array(); } + $allowed = array((int) $fieldId); + $selectedMap = array_fill_keys($selected, true); + foreach (self::items($rubricId, $fieldId, false) as $item) { + if (isset($selectedMap[$item['id']])) { $allowed = array_merge($allowed, self::ids($item['fields_use'])); } + } + + return array_values(array_unique(array_map('intval', $allowed))); + } + + public static function serializeSelection($rubricId, $fieldId, array $selected) + { + $selected = self::selection($rubricId, $fieldId, $selected); + $settings = self::settings($rubricId, $fieldId); + $map = array(); + foreach (self::items($rubricId, $fieldId, false) as $item) { $map[$item['id']] = $item; } + $documents = array(); + $names = array(); + foreach ($selected as $id) { + $names[] = isset($map[$id]) ? $map[$id]['name'] : ''; + $chain = array(); + $cursor = $id; + while ($cursor > 0 && isset($map[$cursor])) { + if ($map[$cursor]['document_id'] > 0) { $chain[] = $map[$cursor]['document_id']; } + if ((int) $settings['recursive'] !== 1) { break; } + $cursor = $map[$cursor]['parent_id']; + } + + $documents = array_merge($documents, array_reverse($chain)); + } + + $documents = array_values(array_unique(array_filter(array_map('intval', $documents)))); + $data = array('catalogs' => implode(',', $selected), 'documents' => !empty($documents) ? '|' . implode('|', $documents) . '|' : ''); + if ((int) $settings['save_names'] === 1) { $data['names'] = implode(',', array_filter($names, 'strlen')); } + return serialize($data); + } + + public static function afterDocumentSave($documentId, $rubricId, $fieldId, array $selected) + { + $settings = self::settings($rubricId, $fieldId); + if ((int) $settings['doc_parent'] === 1 && !empty($selected)) { + $item = self::item((int) reset($selected)); + if ($item && $item['document_id'] > 0) { + DB::Update(self::documentsTable(), array('document_parent' => $item['document_id']), 'Id = %i', (int) $documentId); + DocumentsModel::markChanged((int) $documentId); + } + } + + if ((int) $settings['filters_use'] === 1) { self::recompileFilters($rubricId, $selected); } + self::clearCache(); + } + + public static function filterStyleOptions($type) + { + $options = array('default' => 'Автоматически'); + $map = array( + 'single_line' => array('single_line_input' => 'Текстовое поле', 'single_line_minmax' => 'Диапазон от и до'), + 'single_line_numeric' => array('single_line_numeric_input' => 'Числовое поле', 'single_line_numeric_minmax' => 'Диапазон от и до'), + 'number' => array('single_line_numeric_input' => 'Числовое поле', 'single_line_numeric_minmax' => 'Диапазон от и до'), + 'drop_down' => array('drop_down_select' => 'Выпадающий список', 'drop_down_checkbox' => 'Флажки'), + 'drop_down_key' => array('drop_down_key_select' => 'Выпадающий список', 'drop_down_key_checkbox' => 'Флажки'), + 'doc_from_rub' => array('doc_from_rub_select' => 'Выпадающий список', 'doc_from_rub_checkbox' => 'Флажки'), + 'checkbox' => array('checkbox_checkbox' => 'Переключатель'), + 'multi_checkbox' => array('multi_checkbox_select' => 'Выпадающий список', 'multi_checkbox_checkbox' => 'Флажки'), + 'checkbox_multi' => array('checkbox_multi_select' => 'Выпадающий список', 'checkbox_multi_checkbox' => 'Флажки'), + ); + return isset($map[$type]) ? array_merge($options, $map[$type]) : $options; + } + + public static function conditionContext($itemId) + { + $item = self::item((int) $itemId); + if (!$item) { throw new \RuntimeException('Раздел каталога не найден'); } + $settings = self::settings($item['rubric_id'], $item['field_id']); + $requestId = (int) $settings['request_id']; + $requestTitle = ''; + if ($requestId > 0) { + $requestTitle = (string) DB::query( + 'SELECT request_title FROM ' . self::requestsTable() . ' WHERE Id = %i LIMIT 1', + $requestId + )->getValue(); + $requestTitle = self::decode($requestTitle); + } + + $conditions = array(); + if ($requestId > 0) { + foreach (DB::query( + 'SELECT * FROM ' . self::conditionsTable() . ' WHERE request_id = %i ORDER BY condition_position ASC, Id ASC', + $requestId + )->getAll() as $condition) { + $fieldId = (int) $condition['condition_field_id']; + if (!isset($conditions[$fieldId])) { $conditions[$fieldId] = $condition; } + } + } + + $fields = array(); + foreach (self::rubricFields($item['rubric_id']) as $field) { $fields[$field['id']] = $field; } + $states = array(); + $summary = array('synced' => 0, 'staged' => 0, 'missing' => 0, 'different' => 0, 'unsupported' => 0); + foreach ($item['filters_use'] as $filterId) { + if (!isset($fields[$filterId])) { continue; } + $style = isset($item['filter_styles'][$filterId]) ? $item['filter_styles'][$filterId] : 'default'; + $generated = self::generateCondition($fields[$filterId], $style); + $existing = isset($conditions[$filterId]) ? $conditions[$filterId] : null; + $state = 'missing'; + if (!$generated) { $state = 'unsupported'; } + elseif ($existing && self::conditionMatches($existing, $generated)) { $state = 'synced'; } + elseif ($existing && self::conditionDescriptorMatches($existing, $generated)) { $state = 'staged'; } + elseif ($existing) { $state = 'different'; } + $summary[$state]++; + $states[$filterId] = array( + 'field_id' => $filterId, + 'state' => $requestId > 0 ? $state : 'no_request', + 'style' => $style, + 'condition_id' => $existing ? (int) $existing['Id'] : 0, + 'generated' => $generated ?: array( + 'compare' => '', 'value' => '', 'source' => '', 'key' => '', 'config' => '', + ), + 'existing' => $existing ? array( + 'compare' => (string) $existing['condition_compare'], + 'value' => (string) $existing['condition_value'], + 'status' => (int) $existing['condition_status'], + ) : null, + ); + } + + return array( + 'request' => array('id' => $requestId, 'title' => $requestTitle), + 'states' => $states, + 'summary' => $summary, + ); + } + + public static function syncCondition($itemId, $filterId, $force = false, $rebuild = true) + { + $item = self::item((int) $itemId); + if (!$item || !in_array((int) $filterId, $item['filters_use'], true)) { + throw new \RuntimeException('Фильтр не включён в этом разделе'); + } + + $context = self::conditionContext($itemId); + $requestId = (int) $context['request']['id']; + if ($requestId <= 0) { throw new \RuntimeException('Сначала выберите запрос фильтра в настройках каталога'); } + if (!isset($context['states'][(int) $filterId])) { throw new \RuntimeException('Поле фильтра не найдено'); } + $state = $context['states'][(int) $filterId]; + if ($state['state'] === 'unsupported') { throw new \RuntimeException('Для выбранного типа фильтра нельзя создать условие автоматически'); } + if ($state['state'] === 'different' && !$force) { throw new \RuntimeException('Существующее условие отличается от сгенерированного'); } + if ($state['state'] === 'synced' || $state['state'] === 'staged') { return $context; } + + $value = $state['generated']['value']; + if ((int) $state['condition_id'] > 0 && $state['existing']) { + $legacy = RequestConditionValue::legacyDescriptor( + $state['existing']['value'], + $state['existing']['compare'] + ); + if ($legacy !== null && RequestConditionValue::encodeConfig($legacy) === $state['generated']['config']) { + $value = $state['existing']['value']; + } + } + + $data = array( + 'condition_compare' => $state['generated']['compare'], + 'condition_value' => $value, + 'condition_value_source' => $state['generated']['source'], + 'condition_value_key' => $state['generated']['key'], + 'condition_value_config' => $state['generated']['config'], + 'condition_join' => 'AND', + 'condition_status' => '1', + ); + if ((int) $state['condition_id'] > 0) { + DB::Update(self::conditionsTable(), $data, 'Id = %i AND request_id = %i', (int) $state['condition_id'], $requestId); + } else { + $data['request_id'] = $requestId; + $data['condition_field_id'] = (int) $filterId; + $data['condition_position'] = (int) DB::query( + 'SELECT COALESCE(MAX(condition_position), -1) + 1 FROM ' . self::conditionsTable() . ' WHERE request_id = %i', + $requestId + )->getValue(); + DB::Insert(self::conditionsTable(), $data); + } + + if ($rebuild) { self::rebuildRequest($requestId); } + self::clearCache(); + return self::conditionContext($itemId); + } + + public static function syncMissingConditions($itemId) + { + $context = self::conditionContext($itemId); + if ((int) $context['request']['id'] <= 0) { throw new \RuntimeException('Сначала выберите запрос фильтра в настройках каталога'); } + $created = 0; + foreach ($context['states'] as $fieldId => $state) { + if ($state['state'] !== 'missing') { continue; } + self::syncCondition((int) $itemId, (int) $fieldId, false, false); + $created++; + } + + if ($created > 0) { self::rebuildRequest((int) $context['request']['id']); } + $result = self::conditionContext($itemId); + $result['created'] = $created; + return $result; + } + + public static function generateCondition(array $field, $style) + { + $fieldId = (int) $field['id']; + $type = isset($field['type']) ? (string) $field['type'] : ''; + $style = self::resolveFilterStyle($type, (string) $style); + if ($style === '') { return null; } + $alias = isset($field['alias']) ? trim((string) $field['alias']) : ''; + $name = preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $alias) ? $alias : 'field_' . $fieldId; + if ($style === 'checkbox_checkbox' || $style === 'drop_down_key_select') { + return self::conditionDefinition('==', $name, 'integer', 'scalar'); + } + + if ($style === 'single_line_input' || $style === 'single_line_numeric_input') { + return self::conditionDefinition('%%', $name, 'string', 'scalar'); + } + + if ($style === 'drop_down_select' || $style === 'doc_from_rub_select') { + return self::conditionDefinition('==', $name, 'string', 'scalar'); + } + + if ($style === 'single_line_minmax') { + return self::conditionDefinition('FRE', $name, 'integer', 'range', 'plain', array( + 'range_min_default' => '0', + )); + } + + if ($style === 'single_line_numeric_minmax' && $type === 'number') { + return self::conditionDefinition('FRE', $name, 'decimal', 'range'); + } + + if ($style === 'single_line_numeric_minmax') { + return self::conditionDefinition('FRE', $name, 'integer', 'range', 'plain', array( + 'range_min_default' => '0', + 'range_max_positive' => true, + )); + } + + if (in_array($style, array('drop_down_checkbox', 'doc_from_rub_checkbox'), true)) { + return self::conditionDefinition('FRE', $name, 'string', 'list'); + } + + if ($style === 'drop_down_key_checkbox') { + return self::conditionDefinition('FRE', $name, 'integer', 'list'); + } + + if (in_array($style, array('multi_checkbox_select', 'checkbox_multi_select'), true)) { + return self::conditionDefinition('%%', $name, 'integer', 'scalar', 'pipe_member'); + } + + if (in_array($style, array('multi_checkbox_checkbox', 'checkbox_multi_checkbox'), true)) { + return self::conditionDefinition('FRE', $name, 'integer', 'list', 'pipe_member'); + } + + return null; + } + + protected static function conditionDefinition($compare, $key, $cast, $shape, $transform = 'plain', array $options = array()) + { + $descriptor = array( + 'source' => 'input', + 'key' => $key, + 'cast' => $cast, + 'shape' => $shape, + 'transform' => $transform, + 'constant' => '', + ); + $descriptor += $options; + return array( + 'compare' => $compare, + 'value' => RequestConditionValue::tag($key), + 'source' => 'input', + 'key' => $key, + 'config' => RequestConditionValue::encodeConfig($descriptor), + ); + } + + protected static function conditionMatches(array $condition, array $generated) + { + return (string) $condition['condition_compare'] === (string) $generated['compare'] + && trim((string) $condition['condition_value']) === trim((string) $generated['value']) + && (string) (isset($condition['condition_value_source']) ? $condition['condition_value_source'] : '') === (string) $generated['source'] + && (string) (isset($condition['condition_value_key']) ? $condition['condition_value_key'] : '') === (string) $generated['key'] + && RequestConditionValue::decodeConfig(isset($condition['condition_value_config']) ? $condition['condition_value_config'] : '') + === RequestConditionValue::decodeConfig($generated['config']); + } + + protected static function conditionDescriptorMatches(array $condition, array $generated) + { + return strpos((string) $condition['condition_value'], ' 'single_line_input', 'single_line_numeric' => 'single_line_numeric_input', 'number' => 'single_line_numeric_input', + 'drop_down' => 'drop_down_select', 'drop_down_key' => 'drop_down_key_select', + 'doc_from_rub' => 'doc_from_rub_select', 'checkbox' => 'checkbox_checkbox', + 'multi_checkbox' => 'multi_checkbox_select', 'checkbox_multi' => 'checkbox_multi_select', + ); + if ($style === '' || $style === 'default') { return isset($defaults[$type]) ? $defaults[$type] : ''; } + $allowed = self::filterStyleOptions($type); + return isset($allowed[$style]) ? $style : ''; + } + + protected static function requireCatalog($rubricId, $fieldId) + { + $exists = (int) DB::query( + 'SELECT Id FROM ' . self::fieldsTable() . " WHERE Id = %i AND rubric_id = %i AND rubric_field_type = 'catalog' LIMIT 1", + (int) $fieldId, (int) $rubricId + )->getValue(); + if ($exists <= 0) { throw new \RuntimeException('Каталог не найден'); } + } + + protected static function assertOptionExists($id, $table, $column, $message) + { + if ((int) $id <= 0) { return; } + if (!preg_match('/^[A-Za-z0-9_]+$/', (string) $table) || !preg_match('/^[A-Za-z0-9_]+$/', (string) $column)) { + throw new \RuntimeException('Некорректный источник справочника'); + } + + $exists = (int) DB::query('SELECT `' . $column . '` FROM `' . $table . '` WHERE `' . $column . '` = %i LIMIT 1', (int) $id)->getValue(); + if ($exists <= 0) { throw new \RuntimeException((string) $message); } + } + + protected static function assertParent($id, $parentId, $rubricId, $fieldId) + { + if ($parentId <= 0) { return; } + if ($parentId === (int) $id) { throw new \RuntimeException('Раздел не может быть родителем самого себя'); } + $parent = self::item($parentId); + if (!$parent || $parent['rubric_id'] !== (int) $rubricId || $parent['field_id'] !== (int) $fieldId) { + throw new \RuntimeException('Родительский раздел не найден'); + } + + if ($id > 0 && in_array((int) $parentId, self::descendantIds($id), true)) { + throw new \RuntimeException('Нельзя переместить раздел внутрь его потомка'); + } + } + + protected static function descendantIds($id) + { + $out = array(); $queue = array((int) $id); + while (!empty($queue)) { + $parent = array_shift($queue); + $rows = DB::query('SELECT id FROM ' . self::itemsTable() . ' WHERE parent_id = %i', $parent)->getAll(); + foreach ($rows as $row) { $child = (int) $row['id']; if (!in_array($child, $out, true)) { $out[] = $child; $queue[] = $child; } } + } + + return $out; + } + + protected static function recountLevels($rubricId, $fieldId) + { + $items = self::items($rubricId, $fieldId, false); $map = array(); + foreach ($items as $item) { $map[$item['id']] = $item; } + foreach ($map as $id => $item) { + $level = 1; $cursor = $item['parent_id']; $seen = array($id => true); + while ($cursor > 0 && isset($map[$cursor]) && !isset($seen[$cursor])) { $seen[$cursor] = true; $level++; $cursor = $map[$cursor]['parent_id']; } + DB::Update(self::itemsTable(), array('level' => $level), 'id = %i', $id); + } + } + + protected static function syncCategoryDocumentParent($id) + { + $item = self::item($id); if (!$item || $item['document_id'] <= 0) { return; } + $settings = self::settings($item['rubric_id'], $item['field_id']); + if ((int) $settings['item_parent'] !== 1) { return; } + $parent = $item['parent_id'] > 0 ? self::item($item['parent_id']) : null; + DB::Update(self::documentsTable(), array('document_parent' => $parent ? $parent['document_id'] : 0), 'Id = %i', $item['document_id']); + DocumentsModel::markChanged((int) $item['document_id']); + } + + protected static function recompileFilters($rubricId, array $selected) + { + foreach ($selected as $itemId) { + try { self::runFilterRecompile((int) $rubricId, (int) $itemId); } + catch (\Throwable $e) { error_log('Catalog filter recompile failed: ' . $e->getMessage()); } + } + } + + protected static function runFilterRecompile($rubricId, $itemId) + { + $runner = AdminLocation::path('modules/Catalog/bin/recompile.php'); + if (!is_file($runner)) { throw new \RuntimeException('Сценарий пересборки фильтров не найден'); } + if (!function_exists('exec')) { throw new \RuntimeException('Функция exec недоступна'); } + $output = array(); $code = 0; + $command = escapeshellarg(self::phpCliBinary()) . ' ' . escapeshellarg($runner) . ' ' . (int) $rubricId . ' ' . (int) $itemId . ' 2>&1'; + exec($command, $output, $code); + if ($code !== 0) { throw new \RuntimeException(trim(implode("\n", $output)) ?: 'Пересборка завершилась с ошибкой'); } + return array('item_id' => (int) $itemId, 'output' => trim(implode("\n", $output))); + } + + protected static function phpCliBinary() + { + $candidates = array(getenv('PHP_CLI_BINARY')); + if (defined('PHP_BINDIR')) { $candidates[] = PHP_BINDIR . '/php'; } + if (defined('PHP_BINARY') && preg_match('/^php(?:[0-9.]*)?$/i', basename((string) PHP_BINARY))) { $candidates[] = PHP_BINARY; } + $candidates[] = '/usr/local/bin/php'; $candidates[] = '/usr/bin/php'; + foreach (array_unique(array_filter($candidates)) as $candidate) { + if (is_file($candidate) && is_executable($candidate)) { return $candidate; } + } + + throw new \RuntimeException('CLI-интерпретатор PHP не найден'); + } + + protected static function rebuildRequest($requestId) + { + \App\Content\Requests\RequestConditionCompiler::compile((int) $requestId, true); + } + + protected static function clearCache() + { + DB::clearTags(array('documents', 'requests', 'modules', 'catalog')); + Cache::forgetTag(CacheKey::tag('catalog')); + } + + protected static function documentAlias($id) + { + return (string) DB::query('SELECT document_alias FROM ' . self::documentsTable() . ' WHERE Id = %i LIMIT 1', (int) $id)->getValue(); + } + + protected static function documentExists($id) + { + return (bool) DB::query('SELECT Id FROM ' . self::documentsTable() . " WHERE Id = %i AND document_deleted != '1' LIMIT 1", (int) $id)->getValue(); + } + + protected static function requestsTable() { return ContentTables::table('request'); } + protected static function conditionsTable() { return ContentTables::table('request_conditions'); } + protected static function navigationTable() { return ContentTables::table('navigation'); } + protected static function decode($value) + { + return html_entity_decode(stripslashes((string) $value), ENT_QUOTES, 'UTF-8'); + } + + protected static function parentLevel($id) + { + return $id > 0 ? (int) DB::query('SELECT level FROM ' . self::itemsTable() . ' WHERE id = %i LIMIT 1', (int) $id)->getValue() : 0; + } + + protected static function nextPosition($rubricId, $fieldId, $parentId) + { + return (int) DB::query('SELECT COALESCE(MAX(position), -1) + 1 FROM ' . self::itemsTable() . ' WHERE rubric_id = %i AND field_id = %i AND parent_id = %i', (int) $rubricId, (int) $fieldId, (int) $parentId)->getValue(); + } + + protected static function ids($value) + { + if (!is_array($value)) { $value = preg_split('/[^0-9]+/', (string) $value); } + $out = array(); foreach ($value ?: array() as $id) { $id = (int) $id; if ($id > 0) { $out[] = $id; } } + return array_values(array_unique($out)); + } + + protected static function idsString($value) { return implode(',', self::ids($value)); } + + protected static function orderedIds($order, array $selected) + { + $selectedMap = array_fill_keys($selected, true); $out = array(); + foreach (self::ids($order) as $id) { if (isset($selectedMap[$id])) { $out[] = $id; unset($selectedMap[$id]); } } + foreach ($selected as $id) { if (isset($selectedMap[$id])) { $out[] = $id; } } + return $out; + } + + protected static function filterStyles($styles, array $allowed) + { + if (!is_array($styles)) { return array(); } + $out = array(); foreach ($allowed as $id) { $style = isset($styles[$id]) ? preg_replace('/[^a-z0-9_\-]/i', '', (string) $styles[$id]) : ''; if ($style !== '' && $style !== 'default') { $out[$id] = $style; } } + return $out; + } + + protected static function catalogRow(array $row) + { + return array( + 'rubric_id' => (int) $row['rubric_id'], 'field_id' => (int) $row['field_id'], + 'rubric_title' => html_entity_decode((string) $row['rubric_title'], ENT_QUOTES, 'UTF-8'), + 'field_title' => html_entity_decode((string) $row['rubric_field_title'], ENT_QUOTES, 'UTF-8'), + 'field_alias' => (string) $row['rubric_field_alias'], 'items_count' => (int) $row['items_count'], + 'active_count' => (int) $row['active_count'], 'configured' => !empty($row['id']), + 'filters_use' => isset($row['filters_use']) ? (int) $row['filters_use'] : 0, + 'doc_fileds' => isset($row['doc_fileds']) ? (int) $row['doc_fileds'] : 0, + 'purpose' => isset($row['purpose']) && $row['purpose'] === 'commerce' ? 'commerce' : 'content', + ); + } + + protected static function itemRow(array $row) + { + $styles = @unserialize(isset($row['filters_settings']) ? (string) $row['filters_settings'] : '', array('allowed_classes' => false)); + return array( + 'id' => (int) $row['id'], 'rubric_id' => (int) $row['rubric_id'], 'field_id' => (int) $row['field_id'], + 'name' => (string) $row['name'], 'parent_id' => (int) $row['parent_id'], 'status' => (int) $row['status'], + 'document_id' => (int) $row['document_id'], 'document_alias' => (string) $row['document_alias'], + 'document_title' => html_entity_decode((string) (!empty($row['document_breadcrumb_title']) ? $row['document_breadcrumb_title'] : (isset($row['document_title']) ? $row['document_title'] : '')), ENT_QUOTES, 'UTF-8'), + 'level' => (int) $row['level'], 'position' => (int) $row['position'], + 'fields_use' => self::ids(isset($row['fields_use']) ? $row['fields_use'] : ''), + 'filters_use' => self::ids(isset($row['filters_use']) ? $row['filters_use'] : ''), + 'filter_styles' => is_array($styles) ? $styles : array(), + 'attribute_set_id' => isset($row['attribute_set_id']) ? (int) $row['attribute_set_id'] : 0, + 'attributes_runtime' => isset($row['attributes_runtime']) ? (string) $row['attributes_runtime'] : 'legacy', + 'filter_runtime' => isset($row['filter_runtime']) ? (string) $row['filter_runtime'] : 'legacy', + ); + } + + } diff --git a/adminx/modules/Catalog/assets/catalog-drawer.css b/adminx/modules/Catalog/assets/catalog-drawer.css new file mode 100644 index 0000000..248f88c --- /dev/null +++ b/adminx/modules/Catalog/assets/catalog-drawer.css @@ -0,0 +1,783 @@ +.catalog-page-tabs { + flex-wrap: nowrap; + min-height: 41px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} +.catalog-tree > .catalog-tree-item > .catalog-tree-row { + border-top: 1px solid var(--border-strong); +} +.catalog-tree-row { + grid-template-columns: 30px 22px minmax(180px, 1fr) 112px 70px 84px; +} +.catalog-tree-tools { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + min-width: 0; +} +.catalog-tree-status { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} +.catalog-tree-status > span { + color: var(--text-secondary); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; +} +.catalog-tree-status .switch { + flex: 0 0 auto; +} +.catalog-tree-item.is-inactive .catalog-tree-status > span { + color: var(--amber-700); +} +.catalog-tree-item.is-inactive > .catalog-tree-row { + background: var(--amber-50); + box-shadow: inset 3px 0 var(--amber-500); +} +.catalog-tree-item.is-drop-target > .catalog-tree-row { + background: var(--blue-50); + box-shadow: inset 0 2px var(--blue-500); +} +.catalog-tree-item.is-drop-target.is-drop-after > .catalog-tree-row { + box-shadow: inset 0 -2px var(--blue-500); +} +.catalog-tree-item.is-drop-target.is-drop-inside > .catalog-tree-row { + background: var(--blue-50); + box-shadow: inset 0 0 0 2px var(--blue-500); +} +.catalog-tree-item.is-dragging > .catalog-tree-row { + opacity: 0.58; +} +.catalog-item-form { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + padding: 0; + overflow: hidden; +} +.catalog-drawer-tabs { + position: static; + z-index: 2; + flex: 0 0 auto; + margin: 0; + padding: 12px 20px; + border-bottom: 1px solid var(--border-default); + background: var(--background-card); +} +.catalog-item-form [data-catalog-drawer-panel] { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + padding: 18px 20px; + overflow-x: hidden; + overflow-y: auto; +} +.catalog-item-form .catalog-check-list, +.catalog-item-form .catalog-filter-list { + max-height: none; + margin: 0; + gap: 0; + overflow: visible; +} +.catalog-item-form .catalog-choice-row, +.catalog-item-form .catalog-filter-row { + padding: 11px 6px 11px 14px; + border: 0; + border-bottom: 1px solid var(--border-default); + border-radius: 0; + background: transparent; + transition: background-color 140ms ease, box-shadow 140ms ease; +} +.catalog-item-form .catalog-choice-row.is-selected { + background: var(--blue-50); + box-shadow: inset 3px 0 var(--blue-500); +} +.catalog-item-form .catalog-filter-row.is-selected { + background: var(--teal-50); + box-shadow: inset 3px 0 var(--teal-500); +} +.catalog-item-form .catalog-choice-row:first-child, +.catalog-item-form .catalog-filter-row:first-child { + padding-top: 2px; +} +.catalog-item-form .catalog-choice-row:last-child, +.catalog-item-form .catalog-filter-row:last-child { + border-bottom: 0; +} +.catalog-drawer .drawer-header > div { + min-width: 0; +} +.catalog-drawer .drawer-header p { + margin: 3px 0 0; + text-wrap: pretty; +} +.catalog-drawer .drawer-footer { + flex: 0 0 auto; +} +.catalog-choice-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + min-width: 0; + padding: 9px 10px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); +} +.catalog-choice-copy { + display: grid; + min-width: 0; +} +.catalog-choice-copy b, +.catalog-choice-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.catalog-choice-copy b { + font-size: 13px; + font-weight: 600; +} +.catalog-choice-copy small { + color: var(--text-secondary); + font-size: 11px; +} +.catalog-choice-copy small, +.catalog-field-type small { + grid-column: auto; +} +.catalog-choice-head { + display: grid; + align-items: center; + gap: 14px; + padding: 0 4px 9px; + color: var(--text-secondary); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; +} +.catalog-fields-grid { + grid-template-columns: minmax(240px, 1fr) minmax(180px, 220px) 76px; +} +.catalog-filters-grid { + grid-template-columns: minmax(180px, 1fr) minmax(120px, 150px) minmax(150px, 180px) minmax(150px, 170px) 64px; +} +.catalog-condition-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + padding: 12px; + border-radius: var(--radius-md); + background: var(--blue-50); +} +.catalog-condition-toolbar-title, +.catalog-condition-toolbar-title > span:last-child { + display: flex; + min-width: 0; +} +.catalog-condition-toolbar-title { + align-items: center; + gap: 10px; +} +.catalog-condition-toolbar-title > .icon-tile { + width: 34px; + height: 34px; + background: var(--blue-100); + color: var(--blue-600); + font-size: 16px; +} +.catalog-condition-toolbar-title > span:last-child { + flex-direction: column; +} +.catalog-condition-toolbar-title b { + font-size: 13px; +} +.catalog-condition-toolbar-title small { + overflow: hidden; + color: var(--text-secondary); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} +.catalog-condition-toolbar-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + flex: 0 0 auto; + font-size: 11px; + font-variant-numeric: tabular-nums; +} +.catalog-field-groups { + display: grid; + gap: 22px; +} +.catalog-field-group { + min-width: 0; +} +.catalog-field-group-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 56px; + margin-bottom: 8px; + padding: 10px 12px; + border-radius: var(--radius-md); + background: var(--background-inset); +} +.catalog-field-group-title { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.catalog-field-group-icon { + width: 34px; + height: 34px; + font-size: 16px; +} +.catalog-field-group:nth-child(6n + 1) .catalog-field-group-icon { + --tile-bg: var(--blue-100); + --tile-fg: var(--blue-600); +} +.catalog-field-group:nth-child(6n + 2) .catalog-field-group-icon { + --tile-bg: var(--green-100); + --tile-fg: var(--green-600); +} +.catalog-field-group:nth-child(6n + 3) .catalog-field-group-icon { + --tile-bg: var(--violet-100); + --tile-fg: var(--violet-600); +} +.catalog-field-group:nth-child(6n + 4) .catalog-field-group-icon { + --tile-bg: var(--amber-100); + --tile-fg: var(--amber-600); +} +.catalog-field-group:nth-child(6n + 5) .catalog-field-group-icon { + --tile-bg: var(--teal-100); + --tile-fg: var(--teal-600); +} +.catalog-field-group:nth-child(6n + 6) .catalog-field-group-icon { + --tile-bg: var(--cyan-100); + --tile-fg: var(--cyan-600); +} +.catalog-field-group-copy { + display: grid; + min-width: 0; +} +.catalog-field-group-copy small { + color: var(--text-secondary); + font-size: 10px; + font-weight: 700; + line-height: 1.2; + text-transform: uppercase; +} +.catalog-field-group-copy b { + overflow: hidden; + color: var(--text-primary); + font-size: 14px; + font-weight: 700; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} +.catalog-field-group-meta { + display: flex; + align-items: center; + gap: 10px; + flex: 0 0 auto; + color: var(--text-secondary); + font-size: 11px; + font-variant-numeric: tabular-nums; +} +.catalog-field-group-meta .badge { + min-width: 88px; + justify-content: center; +} +.catalog-field-type { + display: grid; + min-width: 0; +} +.catalog-field-type b, +.catalog-field-type small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.catalog-field-type b { + font-size: 12px; + font-weight: 600; +} +.catalog-field-type small { + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 10px; +} +.catalog-item-form .catalog-fields-grid, +.catalog-item-form .catalog-filters-grid { + display: grid; + align-items: center; + gap: 14px; +} +.catalog-item-form .catalog-fields-grid { + grid-template-columns: minmax(240px, 1fr) minmax(180px, 220px) 76px; +} +.catalog-item-form .catalog-filters-grid { + grid-template-columns: minmax(180px, 1fr) minmax(120px, 150px) minmax(150px, 180px) minmax(150px, 170px) 64px; +} +.catalog-item-form .catalog-filters-grid > .select { + width: 100%; + min-width: 0; +} +.catalog-condition-cell { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + min-width: 0; +} +.catalog-condition-cell .badge { + min-width: 72px; + justify-content: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.catalog-condition-cell .btn-icon { + width: 36px; + height: 36px; + flex: 0 0 36px; +} +.catalog-condition-cell [hidden] { + display: none !important; +} +.catalog-condition-view { + color: var(--blue-600); +} +.catalog-condition-sync { + color: var(--amber-600); +} +.catalog-condition-modal { + width: min(760px, 66.666vw); + max-width: none; +} +.catalog-condition-section + .catalog-condition-section { + margin-top: 18px; +} +.catalog-condition-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 7px; +} +.catalog-condition-section-head h4 { + margin: 0; + font-size: 13px; +} +.catalog-condition-code { + max-height: 220px; + margin: 0; + padding: 12px; + overflow: auto; + border-radius: var(--radius-md); + background: var(--background-inset); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.55; + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.catalog-filter-order-modal { + width: min(620px, 66.666vw); + max-width: none; +} +.catalog-filter-order-list { + display: grid; + gap: 6px; +} +.catalog-filter-order-row { + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + align-items: center; + min-height: 52px; + border-radius: var(--radius-md); + background: var(--background-inset); +} +.catalog-filter-order-row.is-dragging { + opacity: 0.55; +} +.catalog-filter-order-handle { + display: grid; + place-items: center; + width: 40px; + height: 40px; + padding: 0; + border: 0; + background: transparent; + color: var(--text-secondary); + cursor: grab; +} +.catalog-filter-order-handle:active { + cursor: grabbing; +} +.catalog-filter-order-row > span { + display: grid; + min-width: 0; + padding: 8px 10px 8px 0; +} +.catalog-filter-order-row b, +.catalog-filter-order-row small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.catalog-filter-order-row b { + font-size: 13px; +} +.catalog-filter-order-row small { + color: var(--text-secondary); + font-size: 11px; +} +.catalog-recompile-modal { + width: min(620px, 66.666vw); + max-width: none; +} +.catalog-recompile-status { + margin-bottom: 10px; + color: var(--text-secondary); + font-size: 13px; + font-variant-numeric: tabular-nums; +} +.catalog-recompile-errors { + margin-top: 14px; +} +.catalog-recompile-modal [hidden] { + display: none !important; +} +.catalog-item-form .catalog-fields-grid > .switch, +.catalog-item-form .catalog-filters-grid > .switch { + justify-self: center; +} +.catalog-check-list .catalog-choice-row > .switch, +.catalog-filter-row > .switch { + display: inline-flex; + width: auto; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; +} +.catalog-filter-row { + grid-template-columns: minmax(0, 1fr) auto minmax(160px, 210px); +} +.catalog-toggle-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 14px; + min-width: 0; +} +.catalog-toggle-row > span { + display: grid; + min-width: 0; +} +.catalog-toggle-row small { + color: var(--text-secondary); + font-size: 11px; +} +.catalog-behavior-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; + padding: 0 24px 24px; +} +.catalog-behavior-group { + min-width: 0; + padding: 18px; + border-radius: 8px; + background: var(--gray-50); +} +.catalog-behavior-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} +.catalog-behavior-head h3 { + margin: 0; + font-size: 14px; +} +.catalog-behavior-icon.is-blue { + --tile-bg: var(--blue-100); + --tile-fg: var(--blue-600); +} +.catalog-behavior-icon.is-violet { + --tile-bg: var(--violet-100); + --tile-fg: var(--violet-600); +} +.catalog-behavior-icon.is-amber { + --tile-bg: var(--amber-100); + --tile-fg: var(--amber-600); +} +.catalog-behavior-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + min-height: 62px; + padding: 10px 0; +} +.catalog-behavior-row + .catalog-behavior-row { + border-top: 1px solid var(--border); +} +.catalog-behavior-row b, +.catalog-behavior-row small { + display: block; +} +.catalog-default-groups { + margin-top: 14px; +} +.catalog-default-groups .catalog-check-list, +.catalog-default-groups .catalog-filter-list { + max-height: none; + margin-top: 0; + overflow: visible; +} +.catalog-default-groups .catalog-field-group-meta > span:first-child::before { + content: 'Всего: '; +} +.catalog-behavior-row b { + font-size: 13px; + line-height: 1.35; +} +.catalog-behavior-row small { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; +} +.catalog-form-footer .catalog-save-state { + margin-right: auto; +} +.catalog-drawer .drawer-footer .catalog-save-state { + margin-left: 0; +} +.catalog-save-state { + align-self: center; + min-width: 90px; + color: var(--text-secondary); + font-size: 12px; +} +.catalog-save-state.is-ok { + color: var(--green-600); +} +.catalog-save-state.is-error { + color: var(--red-600); +} +.catalog-document-picker { + display: grid; + grid-template-columns: minmax(0, 1fr) 40px; + gap: 6px; +} +.catalog-document-value { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; + min-height: 40px; + padding: 8px 11px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); + color: var(--text-primary); + text-align: left; + cursor: pointer; +} +.catalog-document-value:hover { + border-color: var(--blue-300); + background: var(--blue-50); +} +.catalog-document-value i { + flex: 0 0 auto; + color: var(--blue-600); +} +.catalog-document-value span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.catalog-document-modal { + width: min(66.666vw, 1200px); + height: min(66.666vh, 800px); + max-height: calc(100vh - 40px); +} +.catalog-document-modal .modal-body { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.catalog-document-modal .modal-header > div { + flex: 1 1 auto; + min-width: 0; +} +.catalog-document-modal .modal-header p { + margin: 3px 0 0; +} +.catalog-document-status { + padding: 24px 0; + color: var(--text-secondary); + text-align: center; +} +.catalog-document-results { + display: grid; + flex: 1 1 auto; + align-content: start; + gap: 7px; + min-height: 0; + max-height: none; + margin-top: 12px; + overflow-y: auto; +} +.catalog-document-result { + display: grid; + grid-template-columns: 64px minmax(0, 1fr) minmax(130px, 0.35fr); + align-items: center; + gap: 12px; + width: 100%; + min-height: 56px; + padding: 9px 11px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); + color: var(--text-primary); + text-align: left; + cursor: pointer; +} +.catalog-document-result:hover { + border-color: var(--blue-300); + background: var(--blue-50); +} +.catalog-document-result b, +.catalog-document-result small, +.catalog-document-result em { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.catalog-document-result small, +.catalog-document-result em { + color: var(--text-secondary); + font-size: 11px; + font-style: normal; +} +@media (max-width: 640px) { + .catalog-condition-toolbar { + align-items: stretch; + flex-direction: column; + } + .catalog-condition-toolbar-actions { + align-items: stretch; + flex-direction: column; + } + .catalog-condition-toolbar-actions .btn { + width: 100%; + } + .catalog-condition-modal { + width: 100%; + } + .catalog-filter-order-modal { + width: 100%; + } + .catalog-recompile-modal { + width: 100%; + } + .catalog-tree-tools { + align-items: stretch; + flex-direction: column; + } + .catalog-tree-tools .catalog-tree-search, + .catalog-tree-tools .btn { + width: 100%; + } + .catalog-field-group-head { + padding-right: 10px; + padding-left: 10px; + } + .catalog-field-group-meta > span:first-child { + display: none; + } + .catalog-document-modal { + width: 100%; + height: 94vh; + max-height: 94vh; + } + .catalog-filter-row { + grid-template-columns: minmax(0, 1fr) auto; + } + .catalog-filter-row .select { + grid-column: 1 / -1; + } + .catalog-document-result { + grid-template-columns: 54px minmax(0, 1fr); + } + .catalog-document-result em { + display: none; + } +} +@media (max-width: 1100px) { + .catalog-behavior-grid { + grid-template-columns: 1fr; + } + .catalog-tree-row { + grid-template-columns: 30px 18px minmax(140px, 1fr) 60px 70px; + } + .catalog-tree-status > span { + display: none; + } + .catalog-item-form .catalog-choice-head { + display: none; + } + .catalog-item-form .catalog-fields-grid, + .catalog-item-form .catalog-filters-grid { + grid-template-columns: minmax(0, 1fr) auto; + gap: 5px 12px; + } + .catalog-item-form .catalog-field-type { + grid-column: 1; + } + .catalog-item-form .catalog-fields-grid > .switch, + .catalog-item-form .catalog-filters-grid > .switch { + grid-column: 2; + grid-row: 1 / span 2; + } + .catalog-item-form .catalog-filters-grid > .select { + grid-column: 1 / -1; + margin-top: 5px; + } + .catalog-item-form .catalog-filters-grid > .catalog-condition-cell { + grid-column: 1 / -1; + justify-content: flex-start; + margin-top: 3px; + } +} +@media (max-width: 640px) { + .catalog-tree-row { + grid-template-columns: 28px minmax(120px, 1fr) 54px 76px; + } +} diff --git a/adminx/modules/Catalog/assets/catalog.css b/adminx/modules/Catalog/assets/catalog.css new file mode 100644 index 0000000..6f5e739 --- /dev/null +++ b/adminx/modules/Catalog/assets/catalog.css @@ -0,0 +1,43 @@ +.catalog-summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:16px}.catalog-stat{display:flex;align-items:center;gap:14px;padding:16px}.catalog-stat b{display:block;font-size:20px;line-height:1.1;font-weight:800;font-variant-numeric:tabular-nums}.catalog-stat span{font-size:13px;color:var(--text-secondary)}.catalog-panel-header{margin-bottom:18px}.catalog-panel-header h2{font-size:22px;line-height:1.2;font-weight:800;letter-spacing:0}.catalog-card{padding:0;overflow:hidden}.catalog-section-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:18px 20px;border-bottom:1px solid var(--border-default)}.catalog-section-title{display:flex;align-items:flex-start;gap:12px;min-width:0}.catalog-section-title h2{margin:0 0 4px;font-size:16px;line-height:1.25;font-weight:700;letter-spacing:0}.catalog-section-title p{margin:0;font-size:13px}.catalog-head-icon{width:34px;height:34px;border-radius:var(--radius-md);font-size:17px}.catalog-table{width:100%;table-layout:fixed}.catalog-table td b{display:block}.catalog-meta{display:block;margin-top:3px;color:var(--text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.catalog-actions-col{width:140px}.catalog-table a{text-decoration:none}.catalog-page-tabs{margin-bottom:18px;overflow-x:auto}.catalog-page-tabs .tab{text-decoration:none;white-space:nowrap}.catalog-tree-search{width:min(320px,40vw)}.catalog-tree,.catalog-tree-children{margin:0;padding:0;list-style:none}.catalog-tree{padding:8px 0}.catalog-tree-children{margin-left:34px;border-left:1px solid var(--border-default)}.catalog-tree-row{display:grid;grid-template-columns:30px 22px minmax(180px,1fr) 80px 70px 84px;align-items:center;gap:8px;min-height:54px;padding:7px 16px;border-bottom:1px solid var(--border-subtle,var(--border-default))}.catalog-tree-item:last-child>.catalog-tree-row{border-bottom:0}.catalog-tree-item.is-filtered{display:none}.catalog-tree-item.is-dragging>.catalog-tree-row{opacity:.55;background:var(--blue-50)}.catalog-drag{display:grid;place-items:center;width:30px;height:30px;padding:0;border:0;background:transparent;color:var(--text-tertiary);cursor:grab}.catalog-drag:active{cursor:grabbing}.catalog-tree-branch{color:var(--text-tertiary)}.catalog-tree-name{min-width:0}.catalog-tree-name b,.catalog-tree-name small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.catalog-tree-name b{font-size:13px}.catalog-tree-name small{font-size:11px;color:var(--text-secondary)}.catalog-tree-count{text-align:center;font-size:12px;font-variant-numeric:tabular-nums;color:var(--text-secondary)}.catalog-tree-actions{display:flex;justify-content:flex-end;gap:4px}.catalog-action-edit{color:var(--blue-600)}.catalog-action-delete{color:var(--red-600)}.catalog-card-body{padding:18px 20px}.catalog-settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;padding:0 20px 20px}.catalog-settings-grid>section{min-width:0}.catalog-settings-grid h3{margin:0 0 4px;font-size:14px}.catalog-check-list,.catalog-filter-list{display:grid;gap:6px;max-height:440px;margin-top:12px;overflow-y:auto}.catalog-check-list label,.catalog-filter-row{min-width:0;padding:9px 10px;border:1px solid var(--border-default);border-radius:var(--radius-md);background:var(--background-card)}.catalog-check-list label{display:grid;grid-template-columns:auto minmax(0,1fr);column-gap:9px;align-items:center}.catalog-check-list small{grid-column:2;color:var(--text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.catalog-filter-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(160px,210px);align-items:center;gap:10px}.catalog-filter-row label{display:flex;align-items:center;gap:9px;min-width:0}.catalog-filter-row label span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.catalog-form-footer{display:flex;justify-content:flex-end;padding:14px 20px;border-top:1px solid var(--border-default)}.catalog-drawer{width:min(1000px,66.666vw);max-width:none}.catalog-item-form{display:block;overflow-x:hidden}.catalog-drawer-tabs{position:sticky;top:0;z-index:2;margin:-20px -20px 18px;padding:12px 20px;border-bottom:1px solid var(--border-default);background:var(--background-card)}.catalog-drawer-tabs .tab{text-decoration:none}.catalog-item-form [data-catalog-drawer-panel]{min-width:0}@media(max-width:900px){.catalog-settings-grid{grid-template-columns:1fr}.catalog-drawer{width:min(100%,720px)}.catalog-tree-row{grid-template-columns:30px 18px minmax(140px,1fr) 70px 70px}.catalog-tree-count{display:none}}@media(max-width:640px){.catalog-section-head{align-items:stretch;flex-direction:column}.catalog-tree-search{width:100%}.catalog-tree-row{grid-template-columns:28px minmax(120px,1fr) 70px 76px}.catalog-tree-branch{display:none}.catalog-tree-children{margin-left:16px}.catalog-settings-grid{padding:0 14px 14px}.catalog-filter-row{grid-template-columns:1fr}.catalog-table th:nth-child(3),.catalog-table td:nth-child(3){display:none}} +.catalog-panel+.catalog-panel{margin-top:22px} +.catalog-product-card-settings{margin-top:18px;padding-top:18px;border-top:1px solid var(--border-default)} +.catalog-product-card-layout{display:grid;grid-template-columns:minmax(280px,.8fr) minmax(360px,1.2fr);gap:22px;margin-top:14px} +.catalog-product-card-values{align-content:start} +.catalog-product-card-price{display:grid;grid-template-columns:minmax(0,1fr) 36px;align-items:center}.catalog-product-card-price .input{border-top-right-radius:0;border-bottom-right-radius:0}.catalog-product-card-price>span{display:grid;place-items:center;height:var(--control-height,38px);border:1px solid var(--border-default);border-left:0;border-radius:0 var(--radius-md) var(--radius-md) 0;background:var(--background-subtle);color:var(--text-secondary);font-size:13px} +.catalog-product-card-options{display:grid;grid-template-columns:repeat(3,minmax(110px,1fr));gap:8px} +.catalog-product-card-option{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:44px;padding:8px 10px;border:1px solid var(--border-default);border-radius:var(--radius-md);background:var(--background-card);font-size:13px;font-weight:600} +.catalog-product-comparison-option{margin-top:12px;padding:12px 14px;border:1px solid var(--border-default);border-radius:var(--radius-md);background:var(--background-subtle)} +.catalog-product-comparison-option b{font-size:13px}.catalog-product-comparison-option small{margin-top:3px;line-height:1.4} +@media(max-width:900px){.catalog-product-card-layout{grid-template-columns:1fr}.catalog-product-card-options{grid-template-columns:repeat(2,minmax(110px,1fr))}} +@media(max-width:520px){.catalog-product-card-options{grid-template-columns:1fr}} + +.catalog-page-header .between { + align-items: flex-start; + flex-wrap: nowrap; +} +.catalog-page-heading { + min-width: 0; + flex: 1 1 auto; +} +.catalog-page-actions { + display: flex; + flex: 0 0 auto; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: 8px; +} +.catalog-page-actions .btn { + flex: 0 0 auto; + white-space: nowrap; +} +@media (max-width: 860px) { + .catalog-page-header .between { + flex-wrap: wrap; + } + .catalog-page-actions { + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + } +} diff --git a/adminx/modules/Catalog/assets/catalog.js b/adminx/modules/Catalog/assets/catalog.js new file mode 100644 index 0000000..41e017a --- /dev/null +++ b/adminx/modules/Catalog/assets/catalog.js @@ -0,0 +1,195 @@ +(function (window, document) { + 'use strict'; + var Adminx = window.Adminx || (window.Adminx = {}); + Adminx.Catalog = { + itemForm: null, settingsForm: null, dragRow: null, dragParent: null, dragSnapshot: '', dragTarget: null, filterOrder: [], itemSaveTimer: null, settingsSaveTimer: null, conditionContext: null, + init: function () { + this.itemForm = document.getElementById('catalogItemForm'); + this.settingsForm = document.getElementById('catalogSettingsForm'); + var self = this; + document.addEventListener('click', function (e) { + var pageTab = e.target.closest('[data-catalog-page-tab]'); + if (pageTab) { self.tab('[data-catalog-page-tab]', '[data-catalog-page-panel]', pageTab.getAttribute('data-catalog-page-tab')); } + var drawerTab = e.target.closest('[data-catalog-drawer-tab]'); + if (drawerTab) { self.tab('[data-catalog-drawer-tab]', '[data-catalog-drawer-panel]', drawerTab.getAttribute('data-catalog-drawer-tab')); } + if (e.target.closest('[data-catalog-item-new]')) { self.newItem(); } + var edit = e.target.closest('[data-catalog-item-edit]'); + if (edit) { self.editItem(edit.getAttribute('data-catalog-item-edit')); } + var del = e.target.closest('[data-catalog-item-delete]'); + if (del) { self.deleteItem(del.getAttribute('data-catalog-item-delete')); } + if (e.target.closest('[data-catalog-document-pick]')) { self.openDocumentPicker(); } + if (e.target.closest('[data-catalog-document-clear]')) { self.setDocument(null); self.scheduleItemSave(); } + var conditionView = e.target.closest('[data-catalog-condition-view]'); + if (conditionView) { self.showCondition(conditionView.getAttribute('data-catalog-condition-view')); } + var conditionSync = e.target.closest('[data-catalog-condition-sync]'); + if (conditionSync) { self.syncCondition(conditionSync.getAttribute('data-catalog-condition-sync')); } + if (e.target.closest('[data-catalog-conditions-sync]')) { self.syncMissingConditions(); } + if (e.target.closest('[data-catalog-filter-order]')) { self.openFilterOrder(); } + if (e.target.closest('[data-catalog-filter-recompile]')) { self.recompileCurrent(); } + if (e.target.closest('[data-catalog-recompile-all]')) { self.recompileAll(); } + }); + document.addEventListener('input', function (e) { + if (e.target.matches('[data-catalog-tree-search]')) { self.search(e.target.value); } + }); + document.addEventListener('change', function (e) { + if (e.target.matches('[data-catalog-item-status]')) { self.setTreeStatus(e.target); return; } + if (self.itemForm && e.target.closest('#catalogItemForm')) { + if (e.target.matches('[name="filters_use[]"]')) { self.syncFilterOrderSelection(); } + self.updateCounts(); + if (e.target.matches('[name="status"], [name="parent_id"], [name="fields_use[]"], [name="filters_use[]"], [name^="filter_style["]')) { self.scheduleItemSave(); } + return; + } + if (self.settingsForm && e.target.closest('#catalogSettingsForm')) { self.updateSettingsGroups(); self.updateCommerceSettings(); self.scheduleSettingsSave(); } + }); + if (this.itemForm) { this.itemForm.addEventListener('submit', function (e) { e.preventDefault(); self.saveItem(false); }); } + if (this.settingsForm) { this.settingsForm.addEventListener('submit', function (e) { e.preventDefault(); self.saveSettings(false); }); this.updateSettingsGroups(); this.updateCommerceSettings(); } + var createForm = document.querySelector('[data-catalog-create]'); + if (createForm) { createForm.addEventListener('submit', function (e) { e.preventDefault(); self.createCatalog(createForm); }); } + document.addEventListener('dragstart', function (e) { self.dragStart(e); }); + document.addEventListener('dragover', function (e) { self.dragOver(e); }); + document.addEventListener('drop', function (e) { if (self.dragRow) { e.preventDefault(); } }); + document.addEventListener('dragend', function () { self.dragEnd(); }); + this.openRequestedItem(); + }, + base: function () { return (this.itemForm || this.settingsForm).getAttribute('data-base'); }, + rubric: function () { return (this.itemForm || this.settingsForm).getAttribute('data-rubric'); }, + field: function () { return (this.itemForm || this.settingsForm).getAttribute('data-field'); }, + url: function () { return this.base() + '/catalog/' + this.rubric() + '/' + this.field(); }, + csrf: function () { var el = (this.itemForm || this.settingsForm).querySelector('[name="_csrf"]'); return el ? el.value : ''; }, + createCatalog: function (form) { + Adminx.Loader.show(); + fetch(form.getAttribute('data-base') + '/catalog', { method: 'POST', body: new FormData(form), credentials: 'same-origin', headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } }) + .then(this.json).then(function (payload) { Adminx.Toast.show(payload.message || 'Каталог создан', 'success'); window.location.href = payload.redirect || (payload.data && payload.data.redirect); }) + .catch(this.error).finally(function () { Adminx.Loader.hide(); }); + }, + tab: function (tabs, panels, value) { + document.querySelectorAll(tabs).forEach(function (el) { var active = el.getAttribute(tabs.indexOf('drawer') >= 0 ? 'data-catalog-drawer-tab' : 'data-catalog-page-tab') === value; el.classList.toggle('is-active', active); el.setAttribute('aria-selected', active ? 'true' : 'false'); }); + document.querySelectorAll(panels).forEach(function (el) { el.hidden = el.getAttribute(panels.indexOf('drawer') >= 0 ? 'data-catalog-drawer-panel' : 'data-catalog-page-panel') !== value; }); + }, + newItem: function () { + this.itemForm.reset(); this.itemForm.querySelector('[name="id"]').value = ''; this.itemForm.querySelector('[name="status"]').checked = true; + var defaultFields = this.settingsValues('fields_default[]'), defaultFilters = this.settingsValues('filters_default[]'); + this.checkValues('fields_use[]', defaultFields); this.checkValues('filters_use[]', defaultFilters); + defaultFilters.forEach(function (id) { var source = this.settingsForm.querySelector('[name="filter_style[' + id + ']"]'), target = this.itemForm.querySelector('[name="filter_style[' + id + ']"]'); if (source && target) { target.value = source.value; } }, this); + this.filterOrder = defaultFilters.slice(); this.syncFilterOrderInput(); + this.setDocument(null); this.setState('item', ''); this.renderConditionContext(null); + document.querySelector('[data-catalog-drawer-title]').textContent = 'Новый раздел'; this.tab('[data-catalog-drawer-tab]', '[data-catalog-drawer-panel]', 'main'); this.updateCounts(); + Adminx.Drawer.open('catalogItemDrawer'); + }, + settingsValues: function (name) { return this.settingsForm ? Array.prototype.map.call(this.settingsForm.querySelectorAll('[name="' + name + '"]:checked'), function (input) { return String(input.value); }) : []; }, + updateSettingsGroups: function () { if (!this.settingsForm) { return; } this.settingsForm.querySelectorAll('[data-catalog-settings-group]').forEach(function (group) { var name = group.getAttribute('data-catalog-settings-group'), selected = group.querySelectorAll('[name="' + name + '"]:checked').length, badge = group.querySelector('[data-catalog-settings-selected]'); group.querySelectorAll('.catalog-choice-row, .catalog-filter-row').forEach(function (row) { var input = row.querySelector('[name="' + name + '"]'); row.classList.toggle('is-selected', !!input && input.checked); }); if (badge) { badge.textContent = 'Включено: ' + selected; badge.classList.toggle('badge-blue', selected > 0); badge.classList.toggle('badge-gray', selected === 0); } }); }, + updateCommerceSettings: function () { if (!this.settingsForm) { return; } var purpose = this.settingsForm.querySelector('[name="purpose"]'), visible = purpose && purpose.value === 'commerce'; this.settingsForm.querySelectorAll('[data-catalog-commerce-fields]').forEach(function (section) { section.hidden = !visible; }); }, + editItem: function (id, requestedTab) { + var self = this; Adminx.Loader.show(); + fetch(this.base() + '/catalog/items/' + encodeURIComponent(id), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }).then(this.json).then(function (payload) { + self.fill(payload.data || {}); Adminx.Drawer.open('catalogItemDrawer'); + if (['main', 'fields', 'filters'].indexOf(requestedTab) >= 0) { self.tab('[data-catalog-drawer-tab]', '[data-catalog-drawer-panel]', requestedTab); } + }).catch(this.error).finally(function () { Adminx.Loader.hide(); }); + }, + openRequestedItem: function () { + if (!this.itemForm || typeof URLSearchParams === 'undefined') { return; } + var params = new URLSearchParams(window.location.search), itemId = params.get('item'); + if (!itemId || !/^\d+$/.test(itemId)) { return; } + this.editItem(itemId, params.get('tab') || 'main'); + }, + fill: function (item) { + this.itemForm.reset(); this.itemForm.querySelector('[name="id"]').value = item.id || ''; this.itemForm.querySelector('[name="name"]').value = item.name || ''; + this.itemForm.querySelector('[name="parent_id"]').value = item.parent_id || 0; this.itemForm.querySelector('[name="status"]').checked = Number(item.status) === 1; + this.setDocument(item.document_id ? { id: item.document_id, title: item.document_title || '', alias: item.document_alias || '' } : null); + this.filterOrder = (item.filters_use || []).map(function (id) { return String(id); }); this.syncFilterOrderInput(); this.checkValues('fields_use[]', item.fields_use || []); this.checkValues('filters_use[]', item.filters_use || []); + Object.keys(item.filter_styles || {}).forEach(function (id) { var select = this.itemForm.querySelector('[name="filter_style[' + id + ']"]'); if (select) { select.value = item.filter_styles[id]; } }, this); + var own = this.itemForm.querySelector('[name="parent_id"] option[value="' + item.id + '"]'); if (own) { own.disabled = true; } + this.renderConditionContext(item.condition_context || null); + document.querySelector('[data-catalog-drawer-title]').textContent = item.name || 'Раздел каталога'; this.tab('[data-catalog-drawer-tab]', '[data-catalog-drawer-panel]', 'main'); this.updateCounts(); this.setState('item', ''); + }, + checkValues: function (name, values) { var map = {}; values.forEach(function (id) { map[String(id)] = true; }); this.itemForm.querySelectorAll('[name="' + name + '"]').forEach(function (el) { el.checked = !!map[el.value]; }); }, + updateCounts: function () { ['fields', 'filters'].forEach(function (type) { var count = this.itemForm.querySelectorAll('[name="' + type + '_use[]"]:checked').length; var el = document.querySelector('[data-catalog-' + type + '-count]'); if (el) { el.textContent = count; } this.itemForm.querySelectorAll('[name="' + type + '_use[]"]').forEach(function (toggle) { var row = toggle.closest('.catalog-choice-row, .catalog-filter-row'); if (row) { row.classList.toggle('is-selected', toggle.checked); } }); this.itemForm.querySelectorAll('[data-catalog-field-group="' + type + '"]').forEach(function (group) { var selected = group.querySelectorAll('[name="' + type + '_use[]"]:checked').length; var badge = group.querySelector('[data-catalog-group-selected]'); if (badge) { badge.textContent = 'Включено: ' + selected; badge.classList.toggle('badge-blue', selected > 0); badge.classList.toggle('badge-gray', selected === 0); } }); }, this); var orderButton = this.itemForm.querySelector('[data-catalog-filter-order]'); if (orderButton) { orderButton.disabled = this.filterOrder.length < 2; } this.renderConditionContext(this.conditionContext); }, + syncFilterOrderSelection: function () { var selected = {}, next = []; this.itemForm.querySelectorAll('[name="filters_use[]"]:checked').forEach(function (input) { selected[input.value] = true; }); this.filterOrder.forEach(function (id) { if (selected[id]) { next.push(id); delete selected[id]; } }); this.itemForm.querySelectorAll('[name="filters_use[]"]:checked').forEach(function (input) { if (selected[input.value]) { next.push(input.value); delete selected[input.value]; } }); this.filterOrder = next; this.syncFilterOrderInput(); }, + syncFilterOrderInput: function () { var input = this.itemForm && this.itemForm.querySelector('[name="filters_order"]'); if (input) { input.value = this.filterOrder.join(','); } }, + openFilterOrder: function () { var self = this; this.syncFilterOrderSelection(); if (this.filterOrder.length < 2) { return; } var overlay = document.createElement('div'), rows = ''; this.filterOrder.forEach(function (id) { var input = self.itemForm.querySelector('[name="filters_use[]"][value="' + id + '"]'), row = input.closest('.catalog-filter-row'), title = row.querySelector('.catalog-choice-copy b').textContent.trim(), type = row.querySelector('.catalog-field-type b').textContent.trim(); rows += '
' + self.esc(title) + '' + self.esc(type) + ' · #' + self.esc(id) + '
'; }); overlay.className = 'overlay catalog-filter-order-overlay'; overlay.innerHTML = ''; document.body.appendChild(overlay); requestAnimationFrame(function () { overlay.classList.add('show'); }); var dragged = null, close = function () { overlay.classList.remove('show'); setTimeout(function () { overlay.remove(); }, 160); }; overlay.addEventListener('dragstart', function (e) { var handle = e.target.closest('.catalog-filter-order-handle'), row = handle ? handle.closest('[data-filter-order-id]') : null; if (!row) { e.preventDefault(); return; } dragged = row; row.classList.add('is-dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', row.getAttribute('data-filter-order-id')); }); overlay.addEventListener('dragover', function (e) { var target = e.target.closest('[data-filter-order-id]'); if (!dragged || !target || target === dragged) { return; } e.preventDefault(); var rect = target.getBoundingClientRect(), reference = e.clientY < rect.top + rect.height / 2 ? target : target.nextSibling; if (reference !== dragged && dragged.nextSibling !== reference) { target.parentNode.insertBefore(dragged, reference); } }); overlay.addEventListener('dragend', function () { if (dragged) { dragged.classList.remove('is-dragging'); dragged = null; } }); overlay.addEventListener('click', function (e) { if (e.target === overlay || e.target.closest('[data-filter-order-close]')) { close(); return; } if (e.target.closest('[data-filter-order-save]')) { self.filterOrder = Array.prototype.map.call(overlay.querySelectorAll('[data-filter-order-id]'), function (row) { return row.getAttribute('data-filter-order-id'); }); self.syncFilterOrderInput(); self.scheduleItemSave(); close(); } }); }, + renderConditionContext: function (context) { + this.conditionContext = context || { request: { id: 0, title: '' }, states: {}, summary: { synced: 0, staged: 0, missing: 0, different: 0, unsupported: 0 } }; + var request = this.conditionContext.request || {}, summary = this.conditionContext.summary || {}, requestEl = document.querySelector('[data-catalog-condition-request]'), summaryEl = document.querySelector('[data-catalog-condition-summary]'), bulk = document.querySelector('[data-catalog-conditions-sync]'); + if (requestEl) { requestEl.textContent = Number(request.id) > 0 ? ('#' + request.id + ' · ' + (request.title || 'Без названия')) : 'Запрос фильтра не выбран'; } + if (summaryEl) { summaryEl.textContent = Number(request.id) > 0 ? ('Готово: ' + (summary.synced || 0) + ' · ждут проверки: ' + (summary.staged || 0) + ' · нет: ' + (summary.missing || 0) + ' · отличаются: ' + (summary.different || 0)) : 'Выберите запрос в настройках каталога'; } + if (bulk) { bulk.disabled = Number(request.id) <= 0 || Number(summary.missing) <= 0 || !this.itemForm.querySelector('[name="id"]').value; } var recompile = this.itemForm.querySelector('[data-catalog-filter-recompile]'); if (recompile) { recompile.disabled = !this.itemForm.querySelector('[name="id"]').value || this.filterOrder.length === 0; } + var states = this.conditionContext.states || {}, labels = { synced: 'Готово', staged: 'Ждёт Shadow', missing: 'Не создано', different: 'Отличается', unsupported: 'Недоступно', no_request: 'Нет запроса' }, classes = { synced: 'badge-green', staged: 'badge-blue', missing: 'badge-amber', different: 'badge-red', unsupported: 'badge-gray', no_request: 'badge-gray' }; + this.itemForm.querySelectorAll('[data-catalog-condition-field]').forEach(function (cell) { + var id = cell.getAttribute('data-catalog-condition-field'), checked = this.itemForm.querySelector('[name="filters_use[]"][value="' + id + '"]').checked, state = checked ? states[id] : null, badge = cell.querySelector('[data-catalog-condition-status]'), view = cell.querySelector('[data-catalog-condition-view]'), sync = cell.querySelector('[data-catalog-condition-sync]'); + badge.className = 'badge ' + (state ? (classes[state.state] || 'badge-gray') : 'badge-gray'); badge.textContent = state ? (labels[state.state] || state.state) : (checked ? 'Ожидает сохранения' : 'Не включён'); + cell.setAttribute('data-condition-state', state ? state.state : 'disabled'); + view.hidden = !(state && ((state.generated && state.generated.value) || state.existing)); + sync.hidden = !(state && (state.state === 'missing' || state.state === 'different')); + }, this); + }, + showCondition: function (fieldId) { + var state = this.conditionContext && this.conditionContext.states ? this.conditionContext.states[String(fieldId)] : null; if (!state) { return; } + var row = this.itemForm.querySelector('[data-catalog-condition-field="' + fieldId + '"]').closest('.catalog-filter-row'), title = row.querySelector('.catalog-choice-copy b').textContent.trim(), overlay = document.createElement('div'), generated = state.generated || {}, existing = state.existing || null; + overlay.className = 'overlay catalog-condition-overlay'; + overlay.innerHTML = ''; + document.body.appendChild(overlay); requestAnimationFrame(function () { overlay.classList.add('show'); }); + var close = function () { overlay.classList.remove('show'); setTimeout(function () { overlay.remove(); }, 160); }; + overlay.addEventListener('click', function (e) { if (e.target === overlay || e.target.closest('[data-catalog-condition-close]')) { close(); } }); + }, + syncCondition: function (fieldId) { + var state = this.conditionContext && this.conditionContext.states ? this.conditionContext.states[String(fieldId)] : null, self = this; if (!state) { return; } + var run = function () { var data = new FormData(); data.append('_csrf', self.csrf()); if (state.state === 'different') { data.append('force', '1'); } self.ajax(self.url() + '/items/' + encodeURIComponent(self.itemForm.querySelector('[name="id"]').value) + '/conditions/' + encodeURIComponent(fieldId), data, function (payload) { self.renderConditionContext(payload.data.condition_context); Adminx.Toast.show(payload.message, 'success'); }); }; + if (state.state === 'different' && Adminx.Confirm) { Adminx.Confirm.open({ kind: 'warning', title: 'Заменить условие?', message: 'Текущее выражение отличается от автоматически сгенерированного. Оно будет заменено, затем запрос будет пересобран.', confirmLabel: 'Заменить', confirmClass: 'btn-warning', onConfirm: run }); return; } + run(); + }, + syncMissingConditions: function () { + var id = this.itemForm.querySelector('[name="id"]').value, self = this; if (!id) { return; } var data = new FormData(); data.append('_csrf', this.csrf()); this.ajax(this.url() + '/items/' + encodeURIComponent(id) + '/conditions/sync', data, function (payload) { self.renderConditionContext(payload.data.condition_context); Adminx.Toast.show(payload.message, 'success'); }); + }, + recompileCurrent: function () { var id = this.itemForm.querySelector('[name="id"]').value, self = this; if (!id) { return; } var data = new FormData(); data.append('_csrf', this.csrf()); this.ajax(this.url() + '/items/' + encodeURIComponent(id) + '/filters/recompile', data, function (payload) { Adminx.Toast.show(payload.message, 'success'); }); }, + recompileAll: function () { var self = this, ids = Array.prototype.filter.call(document.querySelectorAll('[data-catalog-item]'), function (item) { return Number(item.getAttribute('data-filter-count')) > 0; }).map(function (item) { return item.getAttribute('data-id'); }); if (!ids.length) { Adminx.Toast.show('В каталоге нет настроенных фильтров', 'info'); return; } var overlay = document.createElement('div'), done = 0, failed = [], stopped = false; overlay.className = 'overlay catalog-recompile-overlay'; overlay.innerHTML = ''; document.body.appendChild(overlay); requestAnimationFrame(function () { overlay.classList.add('show'); }); var status = overlay.querySelector('[data-catalog-recompile-status]'), bar = overlay.querySelector('[data-catalog-recompile-progress]'), errors = overlay.querySelector('[data-catalog-recompile-errors]'), close = function () { overlay.classList.remove('show'); setTimeout(function () { overlay.remove(); }, 160); }, finish = function () { status.textContent = stopped ? ('Остановлено: ' + done + ' из ' + ids.length) : ('Готово: ' + done + ' из ' + ids.length); if (failed.length) { errors.innerHTML = '
Ошибки: ' + failed.map(self.esc).join(', ') + '
'; } overlay.querySelectorAll('[data-catalog-recompile-close]').forEach(function (button) { button.disabled = false; }); overlay.querySelector('[data-catalog-recompile-stop]').hidden = true; }, run = function () { if (stopped || done >= ids.length) { finish(); return; } var id = ids[done], data = new FormData(); data.append('_csrf', self.csrf()); status.textContent = 'Раздел #' + id + ' · ' + (done + 1) + ' из ' + ids.length; fetch(self.url() + '/items/' + encodeURIComponent(id) + '/filters/recompile', { method: 'POST', body: data, headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }).then(self.json).catch(function (error) { failed.push('#' + id + ': ' + ((error && error.message) || 'ошибка')); }).finally(function () { done++; bar.style.width = Math.round(done / ids.length * 100) + '%'; run(); }); }; overlay.addEventListener('click', function (e) { if (e.target.closest('[data-catalog-recompile-stop]')) { stopped = true; return; } if (e.target.closest('[data-catalog-recompile-close]') && !e.target.closest('[data-catalog-recompile-close]').disabled) { close(); } }); run(); }, + scheduleItemSave: function () { + if (!this.itemForm || !this.itemForm.querySelector('[name="id"]').value) { return; } + var self = this; clearTimeout(this.itemSaveTimer); this.setState('item', 'Сохранение...'); + this.itemSaveTimer = setTimeout(function () { self.saveItem(true); }, 180); + }, + scheduleSettingsSave: function () { + var self = this; clearTimeout(this.settingsSaveTimer); this.setState('settings', 'Сохранение...'); + this.settingsSaveTimer = setTimeout(function () { self.saveSettings(true); }, 180); + }, + saveItem: function (auto) { + var id = this.itemForm.querySelector('[name="id"]').value; var self = this; + this.ajax(this.url() + '/items' + (id ? '/' + id : ''), new FormData(this.itemForm), function (payload) { + if (auto) { self.setState('item', 'Сохранено', 'ok'); self.syncTreeRow(id); if (payload.data && payload.data.condition_context) { self.renderConditionContext(payload.data.condition_context); } return; } + Adminx.Toast.show(payload.message, 'success'); if (Adminx.Drawer) { Adminx.Drawer.close('catalogItemDrawer'); } window.location.reload(); + }, { quiet: !!auto, fail: function () { self.setState('item', 'Не сохранено', 'error'); } }); + }, + saveSettings: function (auto) { var self = this; this.ajax(this.url() + '/settings', new FormData(this.settingsForm), function (payload) { if (auto) { self.setState('settings', 'Сохранено', 'ok'); } else { self.setState('settings', 'Сохранено', 'ok'); Adminx.Toast.show(payload.message, 'success'); } }, { quiet: !!auto, fail: function () { self.setState('settings', 'Не сохранено', 'error'); } }); }, + setState: function (type, message, state) { var el = document.querySelector('[data-catalog-' + type + '-state]'); if (!el) { return; } el.textContent = message || ''; el.classList.toggle('is-ok', state === 'ok'); el.classList.toggle('is-error', state === 'error'); }, + syncTreeRow: function (id) { var row = document.querySelector('[data-catalog-item][data-id="' + id + '"]'); if (!row) { return; } var toggle = row.querySelector('[data-catalog-item-status]'), active = this.itemForm.querySelector('[name="status"]').checked, filterCount = this.itemForm.querySelectorAll('[name="filters_use[]"]:checked').length; if (toggle) { toggle.checked = active; this.updateTreeStatus(toggle); } row.setAttribute('data-filter-count', String(filterCount)); var count = row.querySelector('.catalog-tree-count'); if (count) { count.textContent = this.itemForm.querySelectorAll('[name="fields_use[]"]:checked').length + ' / ' + filterCount; } }, + updateTreeStatus: function (input) { var item = input.closest('[data-catalog-item]'), active = input.checked, label = item.querySelector('[data-catalog-item-status-label]'), control = input.closest('.switch'); item.classList.toggle('is-inactive', !active); if (label) { label.textContent = active ? 'активен' : 'скрыт'; } input.setAttribute('aria-label', active ? 'Скрыть раздел' : 'Включить раздел'); if (control) { control.setAttribute('data-tooltip', active ? 'Скрыть раздел' : 'Включить раздел'); } }, + setTreeStatus: function (input) { var self = this, id = input.getAttribute('data-catalog-item-status'), previous = !input.checked, data = new FormData(); this.updateTreeStatus(input); input.disabled = true; data.append('_csrf', this.csrf()); data.append('status', input.checked ? '1' : '0'); this.ajax(this.url() + '/items/' + encodeURIComponent(id) + '/status', data, function (payload) { input.checked = Number(payload.data.status) === 1; input.disabled = false; self.updateTreeStatus(input); }, { quiet: true, fail: function () { input.checked = previous; input.disabled = false; self.updateTreeStatus(input); } }); }, + setDocument: function (item) { if (!this.itemForm) { return; } var input = this.itemForm.querySelector('[name="document_id"]'); var label = this.itemForm.querySelector('[data-catalog-document-label]'); var clear = this.itemForm.querySelector('[data-catalog-document-clear]'); var id = item && item.id ? Number(item.id) : 0; input.value = id || ''; if (label) { label.textContent = id ? ('#' + id + ' · ' + (item.title || item.alias || 'Без названия')) : 'Документ не выбран'; } if (clear) { clear.disabled = !id; } }, + openDocumentPicker: function () { + var self = this, overlay = document.createElement('div'); + overlay.className = 'overlay catalog-document-overlay'; + overlay.innerHTML = ''; + overlay.querySelector('.catalog-document-modal').classList.add('picker-modal'); + document.body.appendChild(overlay); requestAnimationFrame(function () { overlay.classList.add('show'); }); + var search = overlay.querySelector('[data-catalog-document-search]'), results = overlay.querySelector('[data-catalog-document-results]'), status = overlay.querySelector('[data-catalog-document-status]'), count = overlay.querySelector('[data-catalog-document-count]'), timer = null; + var close = function () { overlay.classList.remove('show'); setTimeout(function () { overlay.remove(); }, 160); }; + var load = function () { status.hidden = false; status.textContent = 'Загрузка...'; fetch(self.base() + '/catalog/documents?q=' + encodeURIComponent(search.value.trim()) + '&limit=30', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }).then(self.json).then(function (payload) { var items = (payload.data || {}).items || []; results.innerHTML = ''; items.forEach(function (item) { results.insertAdjacentHTML('beforeend', ''); }); status.hidden = items.length > 0; status.textContent = 'Ничего не найдено'; count.textContent = items.length ? 'Документов: ' + items.length : ''; }).catch(function () { status.hidden = false; status.textContent = 'Не удалось загрузить документы'; }); }; + overlay.addEventListener('click', function (e) { if (e.target === overlay || e.target.closest('[data-catalog-document-close]')) { close(); return; } var item = e.target.closest('[data-document-id]'); if (item) { self.setDocument({ id: item.getAttribute('data-document-id'), title: item.getAttribute('data-document-title'), alias: item.getAttribute('data-document-alias') }); self.scheduleItemSave(); close(); } }); + search.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(load, 220); }); load(); search.focus(); + }, + deleteItem: function (id) { + var self = this, run = function () { var data = new FormData(); data.append('_csrf', self.csrf()); self.ajax(self.url() + '/items/' + id + '/delete', data, function (payload) { Adminx.Toast.show(payload.message, 'success'); window.location.reload(); }); }; + if (Adminx.Confirm) { Adminx.Confirm.open({ kind: 'error', title: 'Удалить раздел?', message: 'Будут удалены также все вложенные разделы. Документы останутся.', confirmLabel: 'Удалить', confirmClass: 'btn-danger', onConfirm: run }); } else if (confirm('Удалить раздел и все вложенные?')) { run(); } + }, + search: function (value) { value = String(value || '').trim().toLowerCase(); document.querySelectorAll('[data-catalog-item]').forEach(function (row) { row.classList.toggle('is-filtered', value && row.textContent.toLowerCase().indexOf(value) < 0); }); }, + dragStart: function (e) { var handle = e.target.closest('.catalog-drag[draggable="true"]'), row = handle ? handle.closest('[data-catalog-item]') : null; if (!row) { e.preventDefault(); return; } this.dragRow = row; this.dragParent = row.parentNode; this.dragSnapshot = this.siblingOrder(this.dragParent); row.classList.add('is-dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', row.getAttribute('data-id')); }, + dragOver: function (e) { if (!this.dragRow) { return; } var target = e.target.closest('[data-catalog-item]'); if (!target || target === this.dragRow || this.dragRow.contains(target)) { return; } var row = target.querySelector(':scope > .catalog-tree-row'), rect = row.getBoundingClientRect(), ratio = (e.clientY - rect.top) / rect.height, mode = ratio < .28 ? 'before' : (ratio > .72 ? 'after' : 'inside'), destination = null, reference = null, parentId = 0; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (this.dragTarget && this.dragTarget !== target) { this.dragTarget.classList.remove('is-drop-target', 'is-drop-after', 'is-drop-inside'); } this.dragTarget = target; target.classList.add('is-drop-target'); target.classList.toggle('is-drop-after', mode === 'after'); target.classList.toggle('is-drop-inside', mode === 'inside'); if (mode === 'inside') { destination = target.querySelector(':scope > .catalog-tree-children'); if (!destination) { destination = document.createElement('ol'); destination.className = 'catalog-tree-children'; target.appendChild(destination); } parentId = Number(target.getAttribute('data-id')); } else { destination = target.parentNode; reference = mode === 'before' ? target : target.nextSibling; parentId = Number(target.getAttribute('data-parent-id')); } if (destination !== this.dragRow.parentNode || (reference !== this.dragRow && this.dragRow.nextSibling !== reference)) { destination.insertBefore(this.dragRow, reference); this.dragRow.setAttribute('data-parent-id', String(parentId)); } }, + dragEnd: function () { if (!this.dragRow) { return; } var changed = this.dragSnapshot !== this.siblingOrder(this.dragParent); this.dragRow.classList.remove('is-dragging'); if (this.dragTarget) { this.dragTarget.classList.remove('is-drop-target', 'is-drop-after', 'is-drop-inside'); } document.querySelectorAll('.catalog-tree-children').forEach(function (list) { if (!list.querySelector(':scope > [data-catalog-item]')) { list.remove(); } }); this.dragRow = null; this.dragParent = null; this.dragTarget = null; this.dragSnapshot = ''; if (changed) { this.saveOrder(); } }, + siblingOrder: function (parent) { return Array.prototype.filter.call(parent ? parent.children : [], function (el) { return el.matches('[data-catalog-item]'); }).map(function (el) { return el.getAttribute('data-id'); }).join(','); }, + saveOrder: function () { var rows = []; document.querySelectorAll('[data-catalog-item]').forEach(function (row) { var siblings = Array.prototype.filter.call(row.parentNode.children, function (el) { return el.matches('[data-catalog-item]'); }); rows.push({ id: Number(row.getAttribute('data-id')), parent_id: Number(row.getAttribute('data-parent-id')), position: siblings.indexOf(row) }); }); var data = new FormData(); data.append('_csrf', this.csrf()); data.append('order', JSON.stringify(rows)); this.ajax(this.url() + '/reorder', data, function (payload) { Adminx.Toast.show(payload.message, 'success'); }); }, + ajax: function (url, data, done, options) { options = options || {}; if (!options.quiet) { Adminx.Loader.show(); } fetch(url, { method: 'POST', body: data, headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }).then(this.json).then(function (payload) { if (!payload.success) { throw payload; } done(payload); }).catch(function (payload) { if (options.fail) { options.fail(payload); } Adminx.Catalog.error(payload); }).finally(function () { if (!options.quiet) { Adminx.Loader.hide(); } }); }, + json: function (res) { return res.json().then(function (payload) { if (!res.ok) { throw payload; } return payload; }); }, + esc: function (value) { var node = document.createElement('div'); node.textContent = String(value == null ? '' : value); return node.innerHTML; }, + error: function (payload) { Adminx.Toast.show((payload && payload.message) || 'Ошибка запроса', 'error'); } + }; + document.addEventListener('DOMContentLoaded', function () { Adminx.Catalog.init(); }); +})(window, document); diff --git a/adminx/modules/Catalog/language/en/client.xml b/adminx/modules/Catalog/language/en/client.xml new file mode 100644 index 0000000..b4b82d3 --- /dev/null +++ b/adminx/modules/Catalog/language/en/client.xml @@ -0,0 +1,42 @@ + + + Sections + "><button class="catalog-filter-order-handle" type="button" draggable="true" aria-label="Drag"><i class="ti ti-grip-vertical"></i></button><span><b> + Condition + Enable section + All nested sections will also be deleted. The documents will remain. + Delete a section? + Start typing your name or ID + fields + Catalog + Close + Delete a section and all its sub-sections? + The current expression is different from the automatically generated one. It will be replaced, then the request will be rebuilt. + Stopped: + active + Delete + No filter request selected + There are no configured filters in the catalog + hidden + Request Error + Section + <div class="alert alert-error"><i class="ti ti-alert-triangle"></i><div>Errors: + </p></div><button class="modal-close" type="button" data-catalog-recompile-close aria-label="Close" disabled><i class="ti ti-x"></i></button></div><div class="modal-body"><div class="catalog-recompile-status" data-catalog-recompile-status>Preparing...</div><div class="progress"><div class="progress-bar" data-catalog-recompile-progress style="width:0%;--progress-color:var(--blue-500)"></div></div><div class="catalog-recompile-errors" data-catalog-recompile-errors></div></div><div class="modal-footer"><button class="btn btn-ghost" type="button" data-catalog-recompile-stop>Stop</button><button class="btn btn-primary" type="button" data-catalog-recompile-close disabled>Close</button></div></div> + Hide section + <div class="modal catalog-recompile-modal" role="dialog" aria-modal="true"><div class="modal-header"><span class="dialog-icon info"><i class="ti ti-refresh"></i></span><div><h3>Reassembling filters</h3><p class="text-secondary">Sections: + different + Catalog created + Included: + from + Done: + New section + Catalog section + Replace + Filter request + No document selected + Not included + Replace condition? + Catalog created + different + Catalog + diff --git a/adminx/modules/Catalog/language/en/interface.xml b/adminx/modules/Catalog/language/en/interface.xml new file mode 100644 index 0000000..becda34 --- /dev/null +++ b/adminx/modules/Catalog/language/en/interface.xml @@ -0,0 +1,328 @@ + + + . Only the published version is used on the site. + Alias fields + {{ item.status ? 'Hide section' : 'Enable section' }} + “Preview” does not change the site. + · document # + · draft + active + Active + active + Article + Badges + Free delivery from + In Twig the value is available as + Only the fields assigned to the section are available in the document. + There are no fields like catalog in categories. + Upper level + Enable section + Opportunities + Select a category + Field group + Filter group + Section tree + Public directory filters are compiled for sections. + Add section + No document selected + Delivery + Available in the document field and on the website. + Available: + Available catalogs + A single view for the main page and sections of the product catalog. + If the old price of a product is not filled in, the display will show a price 25% higher than the current one. Product data does not change. + Request # + Filter request + No filter request selected + The query that generates the output and filtering conditions. + The value includes all higher sections. + Hierarchy + Change order + Images + Section names are recorded along with their IDs. + Use {{ field.title ?: field.alias }} field + Use estimated old price + Use filter {{ field.title ?: field.alias }} + Use filters + Each directory is associated with a field of type + Product card + Cards + Catalogs of categories + Catalogs + Buy button + Menu used to export directory structure. + Product name + Assigned to new sections. + Purpose + Directory purpose + Find section + Availability + For example, News Sections + Directory settings + Settings are saved in the native directory storage. + Not included + Not selected + Not selected + Not assigned + Not connected + The new Twig output is enabled separately. The current mode keeps the public HTML unchanged. + New catalog + Null values + regular + Regular catalog + Limit fields by section + Published by Twig + Basic behavior + Remainder + Disable + Moving a section updates the document's parent. + Rebuild + Rebuild filters + Drag the lines by the handle on the left. + Show + Show quantity of products + fields + Fields/filters + Default fields + Product View Fields + Order and nesting are used in the catalog field of the document. + The order is calculated separately within each level. + Presentation + Filters view + Apply to new sections. + View condition + Public release + Section + Section active + The section specifies the parent + Catalog section + Sections + There are no sections yet. + Catalog sections + Estimated old price +25% + Edit Card Views + Edit filter templates + Output mode + Parent + Documents category + Category of documents related to catalog sections. + Category and field + Section heading + With filters + Properties, document fields and filters. + Related document + The linked document becomes the element's parent. + Relationship between the structure and documents, fields and public filters. + Sync parents + Synchronize condition + System structure of sections, document fields and filters. + system bindings. + Hide + hidden + Hide section + Create directory + Create missing ones + Save section names + Maintain chain of parents + Standard file + Old price + Section structure for documents of the selected category. + Current public output + commodity + Product catalog + Product mode includes commercial index, prices, stocks, options and feeds. + Product mode includes prices, stocks, options and feeds. + Product mode is available after installing the “Products” module. + Product mode will appear after installing the “Products” module. + Preview only + Remove linked document + Remove connection + A unique code within the selected category. + Condition + Request conditions + Consider parent when sorting + filters + Default filters + Photos in the slider + Characteristics + Price + "><button class="catalog-filter-order-handle" type="button" draggable="true" aria-label="Drag"><i class="ti ti-grip-vertical"></i></button><span><b> + </p></div><button class="modal-close" type="button" data-catalog-recompile-close aria-label="Close" disabled><i class="ti ti-x"></i></button></div><div class="modal-body"><div class="catalog-recompile-status" data-catalog-recompile-status>Preparing...</div><div class="progress"><div class="progress-bar" data-catalog-recompile-progress style="width:0%;--progress-color:var(--blue-500)"></div></div><div class="catalog-recompile-errors" data-catalog-recompile-errors></div></div><div class="modal-footer"><button class="btn btn-ghost" type="button" data-catalog-recompile-stop>Stop</button><button class="btn btn-primary" type="button" data-catalog-recompile-close disabled>Close</button></div></div> + <div class="alert alert-error"><i class="ti ti-alert-triangle"></i><div>Errors: + <div class="modal catalog-recompile-modal" role="dialog" aria-modal="true"><div class="modal-header"><span class="dialog-icon info"><i class="ti ti-refresh"></i></span><div><h3>Reassembling filters</h3><p class="text-secondary">Sections: + Directory alias must be no longer than 20 characters + PHP CLI interpreter not found + Automatically + Variant attribute saved + Attribute removed + Without email: + Without a group + Safe values confirmed, public mode not changed + All nested sections will also be deleted. The documents will remain. + There are no configured filters in the catalog + Option included + Option disabled + Option created by copying + Option selection view saved + Included: + All available conditions have already been created + Select from 1 to 200 products + Select a directory section with configured filters + Select an existing product + Selected navigation not found + The selected category was not found + The selected card view was not found + The selected document was not found + The selected request was not found + The selected filter template was not found + Dropdown list + Done: + Option group created + Group of options saved + Group not found + Group saved + Group deleted + The group has been deleted, the characteristics have been moved to “Without group” + Range from to + A condition cannot be created automatically for the selected filter type + For preview or new mode, select a filter template + Close + Replace + Replace condition? + The values are transferred to the new contour + from + Changing sections, fields, filters and directory settings. + Change saved + Commodity index rebuilt + Card published + Catalog + Directory not found + Catalog created + Catalog: view + Catalog: management + The commercial field does not belong to the category + Kit not found + Kit created + Set saved + Kit removed + Card context saved + Content + Copy created + Set not found + Set saved + Set deleted + Directory settings saved + Contact settings saved + Comparison settings saved + Start typing your name or ID + Insufficient rights + The missing conditions are created: + Incorrect reference source + Incorrect set of filters + Incorrect order + Incorrect section order + Incorrect composition of the kit + Incorrect list of cargo items + You cannot move a section inside its child + New section + Appeal deleted + Payment via SFR is available for the product + Payment via SFR is disabled for goods + The main option has been changed + Stopped: + different + Sent: + Request Error + Errors: + Publish a filter template before enabling a new mode + Switch + Transferred values are confirmed + Rebuild completed with error + Collection not found + The selection has been created + Collection saved + Collection removed + Filter field not found + The order of options is preserved + The order of the sections is preserved + Preview updated + View not found + View created + View saved + View deleted + Run not found + View the system directory structure. + Section + scheduleItemSave: function() { + if (!this.itemForm || !this.itemForm.querySelector( + Section included + Section and subsections deleted + Directory partition not found + A section cannot be a parent of itself + Section not found + View section not found + Section hidden + Section created + Section saved + Calculation included: + Calculation disabled: + Revisions deleted + Revision restored to draft + Revision not found + Revision deleted + Partition mode saved + Parent partition not found + No connection found + The connection has been created + Connection saved + Communication deleted + First select the filter request in the directory settings + First install and enable the Products module + Mapping saved + Case status changed + Directory structure " + The existing condition is different from the generated one + Filter rebuild script not found + This alias is already used in the category + Text field + The current expression is different from the automatically generated one. It will be replaced, then the request will be rebuilt. + Shadow partition index rebuilt + Product added to group + Product excluded from group + Product is not included in the variant group + Product not found + The product has been re-indexed + The product is already in a group + The product category is not configured + Product catalogs are not configured + The category already has a product catalog + Delete + Delete a section and all its sub-sections? + Delete a section? + Specify alias in Latin + Specify the directory name + Specify the section name + Packaging applied to options: + Product packaging preserved + Request condition is synchronized + The filter is not enabled in this section + Filters published + Section filters have been reassembled + Checkboxes + exec function not available + Variant characteristics saved + Variant characteristic removed + Feature added + Feature not found + Characteristic saved + Feature removed + Characteristic removed from set + Product characteristics saved + Draft saved + The rough outline of the characteristics has been prepared + Numeric field + Packaging template not found + Packaging template created + Packaging template saved + Packaging template removed + diff --git a/adminx/modules/Catalog/language/en/runtime.xml b/adminx/modules/Catalog/language/en/runtime.xml new file mode 100644 index 0000000..aa73314 --- /dev/null +++ b/adminx/modules/Catalog/language/en/runtime.xml @@ -0,0 +1,160 @@ + + + Directory alias must be no longer than 20 characters + PHP CLI interpreter not found + Automatically + Variant attribute saved + Attribute removed + Without email: + Without a group + Safe values confirmed, public mode not changed + Option included + Option disabled + Option created by copying + Option selection view saved + All available conditions have already been created + Select from 1 to 200 products + Select a directory section with configured filters + Select a category + Select an existing product + Selected navigation not found + The selected category was not found + The selected card view was not found + The selected document was not found + The selected request was not found + The selected filter template was not found + Dropdown list + Option group created + Group of options saved + Group not found + Group saved + Group deleted + The group has been deleted, the characteristics have been moved to “Without group” + Range from to + A condition cannot be created automatically for the selected filter type + For preview or new mode, select a filter template + The values are transferred to the new contour + Changing sections, fields, filters and directory settings. + Change saved + Commodity index rebuilt + Card published + Catalog + Directory not found + Catalog created + Catalog: view + Catalog: management + The commercial field does not belong to the category + Kit not found + Kit created + Set saved + Kit removed + Card context saved + Content + Copy created + Set not found + Set saved + Set deleted + Directory settings saved + Contact settings saved + Comparison settings saved + Insufficient rights + The missing conditions are created: + Incorrect reference source + Incorrect set of filters + Incorrect order + Incorrect section order + Incorrect composition of the kit + Incorrect list of cargo items + You cannot move a section inside its child + Appeal deleted + Payment via SFR is available for the product + Payment via SFR is disabled for goods + The main option has been changed + Sent: + Errors: + Publish a filter template before enabling a new mode + Switch + Transferred values are confirmed + Rebuild completed with error + Collection not found + The selection has been created + Collection saved + Collection removed + Filter field not found + The order of options is preserved + The order of the sections is preserved + Preview updated + View not found + View created + View saved + View deleted + Run not found + View the system directory structure. + Section included + Section and subsections deleted + Directory partition not found + A section cannot be a parent of itself + Section not found + View section not found + Section hidden + Section created + Section saved + Calculation included: + Calculation disabled: + Revisions deleted + Revision restored to draft + Revision not found + Revision deleted + Partition mode saved + Parent partition not found + No connection found + The connection has been created + Connection saved + Communication deleted + First select the filter request in the directory settings + First install and enable the Products module + Mapping saved + Case status changed + Directory structure " + The existing condition is different from the generated one + Filter rebuild script not found + This alias is already used in the category + Text field + Shadow partition index rebuilt + Product added to group + Product excluded from group + Product is not included in the variant group + Product not found + The product has been re-indexed + The product is already in a group + The product category is not configured + Product catalogs are not configured + The category already has a product catalog + Specify alias in Latin + Specify the directory name + Specify the section name + Packaging applied to options: + Product packaging preserved + Request condition is synchronized + The filter is not enabled in this section + Filters published + Section filters have been reassembled + Checkboxes + exec function not available + Variant characteristics saved + Variant characteristic removed + Feature added + Feature not found + Characteristic saved + Feature removed + Characteristic removed from set + Product characteristics saved + Draft saved + The rough outline of the characteristics has been prepared + Numeric field + Packaging template not found + Packaging template created + Packaging template saved + Packaging template removed + different + diff --git a/adminx/modules/Catalog/language/ru/client.xml b/adminx/modules/Catalog/language/ru/client.xml new file mode 100644 index 0000000..9f4c36e --- /dev/null +++ b/adminx/modules/Catalog/language/ru/client.xml @@ -0,0 +1,42 @@ + + + Разделов + "><button class="catalog-filter-order-handle" type="button" draggable="true" aria-label="Перетащить"><i class="ti ti-grip-vertical"></i></button><span><b> + Условие + Включить раздел + Будут удалены также все вложенные разделы. Документы останутся. + Удалить раздел? + Начните вводить название или ID + поля + Каталог + Закрыть + Удалить раздел и все вложенные? + Текущее выражение отличается от автоматически сгенерированного. Оно будет заменено, затем запрос будет пересобран. + Остановлено: + активен + Удалить + Запрос фильтра не выбран + В каталоге нет настроенных фильтров + скрыт + Ошибка запроса + Раздел + <div class="alert alert-error"><i class="ti ti-alert-triangle"></i><div>Ошибки: + </p></div><button class="modal-close" type="button" data-catalog-recompile-close aria-label="Закрыть" disabled><i class="ti ti-x"></i></button></div><div class="modal-body"><div class="catalog-recompile-status" data-catalog-recompile-status>Подготовка...</div><div class="progress"><div class="progress-bar" data-catalog-recompile-progress style="width:0%;--progress-color:var(--blue-500)"></div></div><div class="catalog-recompile-errors" data-catalog-recompile-errors></div></div><div class="modal-footer"><button class="btn btn-ghost" type="button" data-catalog-recompile-stop>Остановить</button><button class="btn btn-primary" type="button" data-catalog-recompile-close disabled>Закрыть</button></div></div> + Скрыть раздел + <div class="modal catalog-recompile-modal" role="dialog" aria-modal="true"><div class="modal-header"><span class="dialog-icon info"><i class="ti ti-refresh"></i></span><div><h3>Пересборка фильтров</h3><p class="text-secondary">Разделов: + отличается + Каталог создан + Включено: + из + Готово: + Новый раздел + Раздел каталога + Заменить + Запрос фильтра + Документ не выбран + Не включён + Заменить условие? + Каталог создан + отличается + Каталог + diff --git a/adminx/modules/Catalog/language/ru/interface.xml b/adminx/modules/Catalog/language/ru/interface.xml new file mode 100644 index 0000000..c31bfdb --- /dev/null +++ b/adminx/modules/Catalog/language/ru/interface.xml @@ -0,0 +1,328 @@ + + + . На сайте используется только опубликованная версия. + Alias поля + {{ item.status ? 'Скрыть раздел' : 'Включить раздел' }} + «Предпросмотр» не меняет сайт. + · документ # + · черновик + активен + Активных + активных + Артикул + Бейджи + Бесплатная доставка от + В Twig значение доступно как + В документе доступны только назначенные разделу поля. + В рубриках нет полей типа catalog. + Верхний уровень + Включить раздел + Возможности + Выберите рубрику + Группа полей + Группа фильтров + Дерево разделов + Для разделов компилируются публичные фильтры каталога. + Добавить раздел + Документ не выбран + Доставка + Доступен в поле документа и на сайте. + Доступно: + Доступные каталоги + Единый вид для главной страницы и разделов товарного каталога. + Если старая цена товара не заполнена, витрина покажет цену на 25% выше текущей. Данные товара не изменяются. + Запрос # + Запрос фильтра + Запрос фильтра не выбран + Запрос, который формирует выдачу и условия фильтрации. + Значение включает все вышестоящие разделы. + Иерархия + Изменить порядок + Изображения + Имена разделов записываются вместе с их идентификаторами. + Использовать поле {{ field.title ?: field.alias }} + Использовать расчётную старую цену + Использовать фильтр {{ field.title ?: field.alias }} + Использовать фильтры + Каждый каталог привязан к полю типа + Карточка товара + Карточки + Каталоги рубрик + Каталогов + Кнопка покупки + Меню, используемое для экспорта структуры каталога. + Название товара + Назначаются новым разделам. + Назначение + Назначение каталога + Найти раздел + Наличие + Например, Разделы новостей + Настройки каталога + Настройки сохраняются в нативном хранилище каталога. + Не включён + Не выбран + Не выбрана + Не назначено + Не связана + Новый Twig-вывод включается отдельно. Текущий режим сохраняет публичный HTML без изменений. + Новый каталог + Нулевые значения + обычный + Обычный каталог + Ограничивать поля по разделу + Опубликованный Twig + Основное поведение + Остаток + Отключать + Перемещение раздела обновляет родителя документа. + Пересобрать + Пересобрать фильтры + Перетаскивайте строки за маркер слева. + Показывать + Показывать количество товаров + поля + Поля / фильтры + Поля по умолчанию + Поля товарного представления + Порядок и вложенность используются в каталожном поле документа. + Порядок рассчитывается отдельно внутри каждого уровня. + Представление + Представление фильтров + Применяются к новым разделам. + Просмотреть условие + Публичная выдача + Раздел + Раздел активен + Раздел задаёт родителя + Раздел каталога + Разделов + Разделов пока нет. + Разделы каталога + Расчётная старая цена +25% + Редактировать представления карточек + Редактировать шаблоны фильтров + Режим вывода + Родитель + Рубрика документов + Рубрика документов, связанных с разделами каталога. + Рубрика и поле + Рубрика разделов + С фильтрами + Свойства, поля документа и фильтры. + Связанный документ + Связанный документ становится родителем элемента. + Связь структуры с документами, полями и публичными фильтрами. + Синхронизировать родителей + Синхронизировать условие + Системная структура разделов, полей документов и фильтров. + системных привязок. + Скрывать + скрыт + Скрыть раздел + Создать каталог + Создать недостающие + Сохранять названия разделов + Сохранять цепочку родителей + Стандартное файловое + Старая цена + Структура разделов для документов выбранной рубрики. + Текущий публичный вывод + товарный + Товарный каталог + Товарный режим включает коммерческий индекс, цены, остатки, варианты и фиды. + Товарный режим включает цены, остатки, варианты и фиды. + Товарный режим доступен после установки модуля «Товары». + Товарный режим появится после установки модуля «Товары». + Только предпросмотр + Убрать связанный документ + Убрать связь + Уникальный код внутри выбранной рубрики. + Условие + Условия запроса + Учитывать родителя при сортировке + фильтры + Фильтры по умолчанию + Фотографий в слайдере + Характеристики + Цена + "><button class="catalog-filter-order-handle" type="button" draggable="true" aria-label="Перетащить"><i class="ti ti-grip-vertical"></i></button><span><b> + </p></div><button class="modal-close" type="button" data-catalog-recompile-close aria-label="Закрыть" disabled><i class="ti ti-x"></i></button></div><div class="modal-body"><div class="catalog-recompile-status" data-catalog-recompile-status>Подготовка...</div><div class="progress"><div class="progress-bar" data-catalog-recompile-progress style="width:0%;--progress-color:var(--blue-500)"></div></div><div class="catalog-recompile-errors" data-catalog-recompile-errors></div></div><div class="modal-footer"><button class="btn btn-ghost" type="button" data-catalog-recompile-stop>Остановить</button><button class="btn btn-primary" type="button" data-catalog-recompile-close disabled>Закрыть</button></div></div> + <div class="alert alert-error"><i class="ti ti-alert-triangle"></i><div>Ошибки: + <div class="modal catalog-recompile-modal" role="dialog" aria-modal="true"><div class="modal-header"><span class="dialog-icon info"><i class="ti ti-refresh"></i></span><div><h3>Пересборка фильтров</h3><p class="text-secondary">Разделов: + Alias каталога должен быть не длиннее 20 символов + CLI-интерпретатор PHP не найден + Автоматически + Атрибут варианта сохранён + Атрибут удалён + Без email: + Без группы + Безопасные значения подтверждены, публичный режим не изменён + Будут удалены также все вложенные разделы. Документы останутся. + В каталоге нет настроенных фильтров + Вариант включён + Вариант выключен + Вариант создан копированием + Вид выбора вариантов сохранён + Включено: + Все доступные условия уже созданы + Выберите от 1 до 200 товаров + Выберите раздел каталога с настроенными фильтрами + Выберите существующий товар + Выбранная навигация не найдена + Выбранная рубрика не найдена + Выбранное представление карточки не найдено + Выбранный документ не найден + Выбранный запрос не найден + Выбранный шаблон фильтров не найден + Выпадающий список + Готово: + Группа вариантов создана + Группа вариантов сохранена + Группа не найдена + Группа сохранена + Группа удалена + Группа удалена, характеристики перенесены в «Без группы» + Диапазон от и до + Для выбранного типа фильтра нельзя создать условие автоматически + Для предпросмотра или нового режима выберите шаблон фильтров + Закрыть + Заменить + Заменить условие? + Значения перенесены в новый контур + из + Изменение разделов, полей, фильтров и настроек каталога. + Изменение сохранено + Индекс товаров перестроен + Карточка опубликована + Каталог + Каталог не найден + Каталог создан + Каталог: просмотр + Каталог: управление + Коммерческое поле не принадлежит рубрике + Комплект не найден + Комплект создан + Комплект сохранён + Комплект удалён + Контекст карточки сохранён + Контент + Копия создана + Набор не найден + Набор сохранён + Набор удалён + Настройки каталога сохранены + Настройки обращений сохранены + Настройки сравнения сохранены + Начните вводить название или ID + Недостаточно прав + Недостающие условия созданы: + Некорректный источник справочника + Некорректный набор фильтров + Некорректный порядок + Некорректный порядок разделов + Некорректный состав комплекта + Некорректный список грузовых мест + Нельзя переместить раздел внутрь его потомка + Новый раздел + Обращение удалено + Оплата через СФР доступна товару + Оплата через СФР отключена для товара + Основной вариант изменён + Остановлено: + отличается + Отправлено: + Ошибка запроса + Ошибок: + Перед включением нового режима опубликуйте шаблон фильтров + Переключатель + Перенесённые значения подтверждены + Пересборка завершилась с ошибкой + Подборка не найдена + Подборка создана + Подборка сохранена + Подборка удалена + Поле фильтра не найдено + Порядок вариантов сохранён + Порядок разделов сохранён + Предпросмотр обновлён + Представление не найдено + Представление создано + Представление сохранено + Представление удалено + Прогон не найден + Просмотр структуры системного каталога. + Раздел + scheduleItemSave: function () { + if (!this.itemForm || !this.itemForm.querySelector( + Раздел включён + Раздел и вложенные разделы удалены + Раздел каталога не найден + Раздел не может быть родителем самого себя + Раздел не найден + Раздел представления не найден + Раздел скрыт + Раздел создан + Раздел сохранён + Расчёт включён: + Расчёт выключен: + Ревизии удалены + Ревизия восстановлена в черновик + Ревизия не найдена + Ревизия удалена + Режим раздела сохранён + Родительский раздел не найден + Связь не найдена + Связь создана + Связь сохранена + Связь удалена + Сначала выберите запрос фильтра в настройках каталога + Сначала установите и включите модуль «Товары» + Сопоставление сохранено + Состояние обращения изменено + Структура каталога « + Существующее условие отличается от сгенерированного + Сценарий пересборки фильтров не найден + Такой alias уже используется в рубрике + Текстовое поле + Текущее выражение отличается от автоматически сгенерированного. Оно будет заменено, затем запрос будет пересобран. + Теневой индекс раздела перестроен + Товар добавлен в группу + Товар исключён из группы + Товар не входит в группу вариантов + Товар не найден + Товар переиндексирован + Товар уже состоит в группе + Товарная рубрика не настроена + Товарные каталоги не настроены + У рубрики уже есть товарный каталог + Удалить + Удалить раздел и все вложенные? + Удалить раздел? + Укажите alias латиницей + Укажите название каталога + Укажите название раздела + Упаковка применена к вариантам: + Упаковка товара сохранена + Условие запроса синхронизировано + Фильтр не включён в этом разделе + Фильтры опубликованы + Фильтры раздела пересобраны + Флажки + Функция exec недоступна + Характеристика варианта сохранена + Характеристика варианта удалена + Характеристика добавлена + Характеристика не найдена + Характеристика сохранена + Характеристика удалена + Характеристика удалена из набора + Характеристики товара сохранены + Черновик сохранён + Черновой контур характеристик подготовлен + Числовое поле + Шаблон упаковки не найден + Шаблон упаковки создан + Шаблон упаковки сохранён + Шаблон упаковки удалён + diff --git a/adminx/modules/Catalog/language/ru/runtime.xml b/adminx/modules/Catalog/language/ru/runtime.xml new file mode 100644 index 0000000..c1f4492 --- /dev/null +++ b/adminx/modules/Catalog/language/ru/runtime.xml @@ -0,0 +1,160 @@ + + + Alias каталога должен быть не длиннее 20 символов + CLI-интерпретатор PHP не найден + Автоматически + Атрибут варианта сохранён + Атрибут удалён + Без email: + Без группы + Безопасные значения подтверждены, публичный режим не изменён + Вариант включён + Вариант выключен + Вариант создан копированием + Вид выбора вариантов сохранён + Все доступные условия уже созданы + Выберите от 1 до 200 товаров + Выберите раздел каталога с настроенными фильтрами + Выберите рубрику + Выберите существующий товар + Выбранная навигация не найдена + Выбранная рубрика не найдена + Выбранное представление карточки не найдено + Выбранный документ не найден + Выбранный запрос не найден + Выбранный шаблон фильтров не найден + Выпадающий список + Группа вариантов создана + Группа вариантов сохранена + Группа не найдена + Группа сохранена + Группа удалена + Группа удалена, характеристики перенесены в «Без группы» + Диапазон от и до + Для выбранного типа фильтра нельзя создать условие автоматически + Для предпросмотра или нового режима выберите шаблон фильтров + Значения перенесены в новый контур + Изменение разделов, полей, фильтров и настроек каталога. + Изменение сохранено + Индекс товаров перестроен + Карточка опубликована + Каталог + Каталог не найден + Каталог создан + Каталог: просмотр + Каталог: управление + Коммерческое поле не принадлежит рубрике + Комплект не найден + Комплект создан + Комплект сохранён + Комплект удалён + Контекст карточки сохранён + Контент + Копия создана + Набор не найден + Набор сохранён + Набор удалён + Настройки каталога сохранены + Настройки обращений сохранены + Настройки сравнения сохранены + Недостаточно прав + Недостающие условия созданы: + Некорректный источник справочника + Некорректный набор фильтров + Некорректный порядок + Некорректный порядок разделов + Некорректный состав комплекта + Некорректный список грузовых мест + Нельзя переместить раздел внутрь его потомка + Обращение удалено + Оплата через СФР доступна товару + Оплата через СФР отключена для товара + Основной вариант изменён + Отправлено: + Ошибок: + Перед включением нового режима опубликуйте шаблон фильтров + Переключатель + Перенесённые значения подтверждены + Пересборка завершилась с ошибкой + Подборка не найдена + Подборка создана + Подборка сохранена + Подборка удалена + Поле фильтра не найдено + Порядок вариантов сохранён + Порядок разделов сохранён + Предпросмотр обновлён + Представление не найдено + Представление создано + Представление сохранено + Представление удалено + Прогон не найден + Просмотр структуры системного каталога. + Раздел включён + Раздел и вложенные разделы удалены + Раздел каталога не найден + Раздел не может быть родителем самого себя + Раздел не найден + Раздел представления не найден + Раздел скрыт + Раздел создан + Раздел сохранён + Расчёт включён: + Расчёт выключен: + Ревизии удалены + Ревизия восстановлена в черновик + Ревизия не найдена + Ревизия удалена + Режим раздела сохранён + Родительский раздел не найден + Связь не найдена + Связь создана + Связь сохранена + Связь удалена + Сначала выберите запрос фильтра в настройках каталога + Сначала установите и включите модуль «Товары» + Сопоставление сохранено + Состояние обращения изменено + Структура каталога « + Существующее условие отличается от сгенерированного + Сценарий пересборки фильтров не найден + Такой alias уже используется в рубрике + Текстовое поле + Теневой индекс раздела перестроен + Товар добавлен в группу + Товар исключён из группы + Товар не входит в группу вариантов + Товар не найден + Товар переиндексирован + Товар уже состоит в группе + Товарная рубрика не настроена + Товарные каталоги не настроены + У рубрики уже есть товарный каталог + Укажите alias латиницей + Укажите название каталога + Укажите название раздела + Упаковка применена к вариантам: + Упаковка товара сохранена + Условие запроса синхронизировано + Фильтр не включён в этом разделе + Фильтры опубликованы + Фильтры раздела пересобраны + Флажки + Функция exec недоступна + Характеристика варианта сохранена + Характеристика варианта удалена + Характеристика добавлена + Характеристика не найдена + Характеристика сохранена + Характеристика удалена + Характеристика удалена из набора + Характеристики товара сохранены + Черновик сохранён + Черновой контур характеристик подготовлен + Числовое поле + Шаблон упаковки не найден + Шаблон упаковки создан + Шаблон упаковки сохранён + Шаблон упаковки удалён + отличается + diff --git a/adminx/modules/Catalog/migrations/005_add_catalog_purpose.sql b/adminx/modules/Catalog/migrations/005_add_catalog_purpose.sql new file mode 100644 index 0000000..3945fbe --- /dev/null +++ b/adminx/modules/Catalog/migrations/005_add_catalog_purpose.sql @@ -0,0 +1,9 @@ +ALTER TABLE `{{prefix}}_module_catalog_settings` + ADD COLUMN IF NOT EXISTS `purpose` VARCHAR(20) NOT NULL DEFAULT 'commerce' AFTER `field_id`; + +UPDATE `{{prefix}}_module_catalog_settings` +SET `purpose` = 'commerce' +WHERE `purpose` = '' OR `purpose` IS NULL; + +ALTER TABLE `{{prefix}}_module_catalog_settings` + MODIFY COLUMN `purpose` VARCHAR(20) NOT NULL DEFAULT 'content'; diff --git a/adminx/modules/Catalog/module.php b/adminx/modules/Catalog/module.php new file mode 100644 index 0000000..5937874 --- /dev/null +++ b/adminx/modules/Catalog/module.php @@ -0,0 +1,68 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + return array( + 'code' => 'catalog', 'name' => 'Каталог', 'version' => '0.2.0', + 'permissions' => array('key' => 'catalog', 'items' => array( + array( + 'code' => 'view_catalog', + 'group_code' => 'navigation', + 'name' => 'Каталог: просмотр', + 'description' => 'Просмотр структуры системного каталога.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_catalog', + 'group_code' => 'content', + 'name' => 'Каталог: управление', + 'description' => 'Изменение разделов, полей, фильтров и настроек каталога.', + 'sort_order' => 20, + ), + ), 'icon' => 'ti ti-category-2', 'priority' => 22), + 'navigation' => array( + array( + 'code' => 'catalog', + 'label' => 'Каталог', + 'url' => '/catalog', + 'icon' => 'ti ti-category-2', + 'permission' => 'view_catalog', + 'group' => 'Контент', + 'sort_order' => 22, + 'match_regex' => array( + '#^/catalog/\d+/\d+(?:/.*)?$#', + ), + ), + ), + 'routes' => array( + array('GET', '/catalog', array(\App\Adminx\Catalog\Controller::class, 'index')), + array('POST', '/catalog', array(\App\Adminx\Catalog\Controller::class, 'createCatalog')), + array('GET', '/catalog/documents', array(\App\Adminx\Catalog\Controller::class, 'documents')), + array('GET', '/catalog/items/{id}', array(\App\Adminx\Catalog\Controller::class, 'item')), + array('GET', '/catalog/{rubric}/{field}', array(\App\Adminx\Catalog\Controller::class, 'edit')), + array('POST', '/catalog/{rubric}/{field}/items', array(\App\Adminx\Catalog\Controller::class, 'storeItem')), + array('POST', '/catalog/{rubric}/{field}/items/{id}', array(\App\Adminx\Catalog\Controller::class, 'updateItem')), + array('POST', '/catalog/{rubric}/{field}/items/{id}/conditions/sync', array(\App\Adminx\Catalog\Controller::class, 'syncMissingConditions')), + array('POST', '/catalog/{rubric}/{field}/items/{id}/conditions/{filter}', array(\App\Adminx\Catalog\Controller::class, 'syncCondition')), + array('POST', '/catalog/{rubric}/{field}/items/{id}/status', array(\App\Adminx\Catalog\Controller::class, 'setItemStatus')), + array('POST', '/catalog/{rubric}/{field}/items/{id}/filters/recompile', array(\App\Adminx\Catalog\Controller::class, 'recompileItem')), + array('POST', '/catalog/{rubric}/{field}/items/{id}/delete', array(\App\Adminx\Catalog\Controller::class, 'deleteItem')), + array('POST', '/catalog/{rubric}/{field}/reorder', array(\App\Adminx\Catalog\Controller::class, 'reorder')), + array('POST', '/catalog/{rubric}/{field}/settings', array(\App\Adminx\Catalog\Controller::class, 'saveSettings')), + ), + 'migrations' => array( + array('id' => '005_add_catalog_purpose', 'file' => 'migrations/005_add_catalog_purpose.sql'), + ), + 'view_globals' => array('module_code' => 'catalog'), + ); diff --git a/adminx/modules/Catalog/view/edit.twig b/adminx/modules/Catalog/view/edit.twig new file mode 100644 index 0000000..24cd541 --- /dev/null +++ b/adminx/modules/Catalog/view/edit.twig @@ -0,0 +1,117 @@ +{% extends '@adminx/main.twig' %} +{% block title %}{{ catalog.rubric_title }} · Каталог{% endblock %} +{% macro rows(items, can_manage) %} + {% for item in items %} +
  • +
    + + +
    {{ item.name }}#{{ item.id }}{% if item.document_id %} · документ #{{ item.document_id }}{% endif %}
    +
    {{ item.status ? 'активен' : 'скрыт' }}
    + {{ item.fields_use|length }} / {{ item.filters_use|length }} + {% if can_manage %}
    {% endif %} +
    + {% if item.children %}
      {{ _self.rows(item.children, can_manage) }}
    {% endif %} +
  • + {% endfor %} +{% endmacro %} +{% block content %} + + + +
    + +
    +
    Структура

    Разделы каталога

    Порядок и вложенность используются в каталожном поле документа.

    +
    +

    Дерево разделов

    Перетаскивайте строки за маркер слева.

    {% if can_manage %}{% endif %}
    +
      {{ _self.rows(tree, can_manage) }}
    + {% if not tree %}
    Разделов пока нет.
    {% endif %} +
    +
    + + + +{% if can_manage %}{% endif %} +{% endblock %} diff --git a/adminx/modules/Catalog/view/index.twig b/adminx/modules/Catalog/view/index.twig new file mode 100644 index 0000000..d876f5f --- /dev/null +++ b/adminx/modules/Catalog/view/index.twig @@ -0,0 +1,40 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Каталог{% endblock %} +{% block content %} + + + +
    +
    {{ stats.catalogs }}Каталогов
    +
    {{ stats.items }}Разделов
    +
    {{ stats.active }}Активных
    +
    {{ stats.filters }}С фильтрами
    +
    + +
    +
    Контент

    Каталоги рубрик

    Каждый каталог привязан к полю типа catalog.

    +
    +

    Доступные каталоги

    {{ catalogs|length }} системных привязок.

    +
    + {% for catalog in catalogs %} + {% else %}{% endfor %} +
    Рубрика и полеСтруктураВозможностиДействия
    {{ catalog.rubric_title }}{{ catalog.field_title }} · #{{ catalog.field_id }} · {{ catalog.field_alias }}{{ catalog.items_count }}{{ catalog.active_count }} активных
    {{ catalog.purpose == 'commerce' ? 'товарный' : 'обычный' }}поляфильтры
    Открыть
    В рубриках нет полей типа catalog.
    +
    +
    +{% if can_manage %}{% endif %} +{% endblock %} diff --git a/adminx/modules/Console/Controller.php b/adminx/modules/Console/Controller.php new file mode 100644 index 0000000..1fb87c7 --- /dev/null +++ b/adminx/modules/Console/Controller.php @@ -0,0 +1,150 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Console; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Adminx\Support\CodeEditor; + use App\Adminx\Support\PhpCode; + use App\Common\AdminAssets; + use App\Common\AuditLog; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Helpers\Request; + use App\Helpers\Response; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + if (!Permission::check('view_console')) { + Response::forbidden(); + return ''; + } + + AdminAssets::addStyle($this->base() . '/modules/Console/assets/console.css', 50); + AdminAssets::addScript($this->base() . '/modules/Console/assets/console.js', 50); + CodeEditor::useCodeMirror('application/x-httpd-php'); + return $this->render('@console/index.twig', array( + 'snippets' => Model::all(), + 'can_manage' => Permission::check('manage_console'), + 'can_execute' => Permission::check('execute_console'), + 'runner_enabled' => Runner::enabled(), + )); + } + + public function show(array $params = array()) + { + if (!Permission::check('view_console')) { + return $this->error('Недостаточно прав', array(), 403); + } + + $item = Model::one(isset($params['id']) ? (int) $params['id'] : 0); + return $item ? $this->success('', array('data' => $item)) : $this->error('Сниппет не найден', array(), 404); + } + + public function save(array $params = array()) + { + if (($error = $this->guard('manage_console')) !== null) { return $error; } + $id = Request::postInt('id', 0); + $name = trim(Request::postStr('name', '')); + $code = Request::postStr('code', ''); + if ($name === '' || trim($code) === '') { + return $this->error('Укажите название и код', array('name' => $name === '' ? 'Обязательное поле' : '', 'code' => trim($code) === '' ? 'Обязательное поле' : '')); + } + + if ($id > 0 && !Model::one($id)) { + return $this->error('Сниппет не найден', array(), 404); + } + + $id = Model::save($id, $name, $code, Auth::id()); + $this->audit('console.snippet_saved', $id, array('name' => $name)); + return $this->success('Сниппет сохранён', array('data' => array('id' => $id, 'snippets' => Model::all()))); + } + + public function delete(array $params = array()) + { + if (($error = $this->guard('manage_console')) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($id) || !Model::delete($id)) { + return $this->error('Сниппет не найден', array(), 404); + } + + $this->audit('console.snippet_deleted', $id); + return $this->success('Сниппет удалён', array('data' => array('snippets' => Model::all()))); + } + + public function lint(array $params = array()) + { + if (($error = $this->guard('manage_console')) !== null) { return $error; } + $result = PhpCode::lint(Request::postStr('code', '')); + return $result['ok'] + ? $this->success($result['message'], array('data' => $result)) + : $this->error($result['message'], array('code' => $result['output']), 422); + } + + public function execute(array $params = array()) + { + if (($error = $this->guard('execute_console')) !== null) { return $error; } + $code = Request::postStr('code', ''); + if (trim($code) === '') { + return $this->error('Введите PHP-код', array('code' => 'Обязательное поле')); + } + + try { + $result = Runner::execute($code); + } catch (\Throwable $e) { + $this->audit('console.execution_failed', null, array('error' => $e->getMessage(), 'code_hash' => sha1($code))); + return $this->error($e->getMessage(), array(), 422); + } + + $this->audit('console.executed', null, array( + 'code_hash' => sha1($code), + 'duration_ms' => $result['duration_ms'], + 'exit_code' => $result['exit_code'], + 'timed_out' => $result['timed_out'], + )); + if ($result['ok']) { + return $this->success('Код выполнен', array('data' => $result)); + } + + return $this->json(array( + 'success' => false, + 'message' => $result['timed_out'] ? 'Превышен лимит времени' : 'Процесс завершился с ошибкой', + 'data' => $result, + 'html' => new \stdClass(), + 'redirect' => null, + 'errors' => new \stdClass(), + ), 422); + } + + protected function guard($permission) + { + return $this->guardPermission($permission); + } + + protected function audit($action, $targetId = null, array $meta = array()) + { + $user = Auth::user(); + AuditLog::record($action, array( + 'actor_id' => Auth::id(), + 'actor_name' => $user && isset($user['name']) ? (string) $user['name'] : '', + 'target_type' => 'system_console', + 'target_id' => $targetId, + 'meta' => $meta, + )); + } + } diff --git a/adminx/modules/Console/Model.php b/adminx/modules/Console/Model.php new file mode 100644 index 0000000..dcd171f --- /dev/null +++ b/adminx/modules/Console/Model.php @@ -0,0 +1,66 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Console; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Common\SystemTables; + + class Model + { + protected static function table() + { + return SystemTables::table('console_snippets'); + } + + public static function all() + { + $rows = DB::query( + 'SELECT id, name, author_id, created_at, updated_at FROM `' . self::table() . '` ORDER BY updated_at DESC, id DESC' + )->getAll(); + return $rows ?: array(); + } + + public static function one($id) + { + $row = DB::query('SELECT * FROM `' . self::table() . '` WHERE id = %i LIMIT 1', (int) $id)->getAssoc(); + return $row ? (array) $row : null; + } + + public static function save($id, $name, $code, $authorId) + { + $now = time(); + $data = array( + 'name' => trim((string) $name), + 'code' => (string) $code, + 'author_id' => (int) $authorId, + 'updated_at' => $now, + ); + if ((int) $id > 0) { + DB::Update(self::table(), $data, 'id = %i', (int) $id); + return (int) $id; + } + + $data['created_at'] = $now; + DB::Insert(self::table(), $data); + return (int) DB::$insert_id; + } + + public static function delete($id) + { + return DB::Delete(self::table(), 'id = %i', (int) $id); + } + } diff --git a/adminx/modules/Console/Runner.php b/adminx/modules/Console/Runner.php new file mode 100644 index 0000000..0a29553 --- /dev/null +++ b/adminx/modules/Console/Runner.php @@ -0,0 +1,231 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Console; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Adminx\Support\PhpCode; + use App\Common\TemporaryDirectory; + + class Runner + { + const TIMEOUT = 5; + const OUTPUT_LIMIT = 262144; + + public static function enabled() + { + $value = getenv('ADMINX_CONSOLE_ENABLED'); + if ($value !== false && $value !== '') { + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + + return !defined('ENV_CMS') || strtolower((string) ENV_CMS) !== 'production'; + } + + public static function execute($code) + { + if (!self::enabled()) { + throw new \RuntimeException('Выполнение PHP-консоли отключено окружением.'); + } + + if (!function_exists('proc_open')) { + throw new \RuntimeException('proc_open недоступен: изолированный запуск невозможен.'); + } + + $lint = PhpCode::lint($code); + if (!$lint['ok']) { + throw new \RuntimeException($lint['message'] . ($lint['line'] ? ' Строка ' . $lint['line'] . '.' : '')); + } + + $dir = TemporaryDirectory::create('console'); + + $codeFile = $dir . '/snippet.php'; + $runnerFile = $dir . '/runner.php'; + $runner = " array('pipe', 'r'), + 1 => array('pipe', 'w'), + 2 => array('pipe', 'w'), + ), $pipes, $dir); + if (!is_resource($process)) { + self::cleanup($dir); + throw new \RuntimeException('Не удалось запустить PHP-процесс.'); + } + + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $started = microtime(true); + $stdout = ''; + $stderr = ''; + $timedOut = false; + $knownExitCode = null; + do { + $stdout .= (string) stream_get_contents($pipes[1]); + $stderr .= (string) stream_get_contents($pipes[2]); + $status = proc_get_status($process); + if (!$status['running']) { + $knownExitCode = isset($status['exitcode']) ? (int) $status['exitcode'] : null; + break; + } + + if (microtime(true) - $started >= self::TIMEOUT) { + $timedOut = true; + proc_terminate($process, 9); + break; + } + + usleep(20000); + } while (true); + $stdout .= (string) stream_get_contents($pipes[1]); + $stderr .= (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $closedExitCode = proc_close($process); + $exitCode = $knownExitCode !== null && $knownExitCode >= 0 ? $knownExitCode : $closedExitCode; + self::cleanup($dir); + + $output = $stdout . ($stderr !== '' ? ($stdout !== '' ? "\n" : '') . $stderr : ''); + $truncated = strlen($output) > self::OUTPUT_LIMIT; + if ($truncated) { + $output = substr($output, 0, self::OUTPUT_LIMIT) . "\n[вывод сокращён]"; + } + + return array( + 'ok' => !$timedOut && $exitCode === 0, + 'exit_code' => $exitCode, + 'timed_out' => $timedOut, + 'truncated' => $truncated, + 'duration_ms' => (int) round((microtime(true) - $started) * 1000), + 'output' => $output, + ); + } + + /** Find a CLI binary compatible with the application runtime. */ + protected static function resolvePhpBinary() + { + $candidates = array(); + $configured = trim((string) getenv('ADMINX_PHP_BINARY')); + if ($configured !== '') { + $candidates[] = $configured; + } + + if (defined('PHP_BINDIR') && PHP_BINDIR !== '') { + $candidates[] = rtrim(PHP_BINDIR, '/\\') . DIRECTORY_SEPARATOR . 'php'; + } + + if (defined('PHP_BINARY') && PHP_BINARY !== '') { + $candidates[] = dirname(PHP_BINARY) . DIRECTORY_SEPARATOR . 'php'; + $candidates[] = PHP_BINARY; + } + + $candidates[] = 'php'; + $candidates = array_values(array_unique($candidates)); + $detected = array(); + foreach ($candidates as $candidate) { + $version = self::probePhpBinary($candidate); + if ($version >= 70300) { + return $candidate; + } + + if ($version > 0) { + $detected[] = $candidate . ' (PHP ' . self::formatVersionId($version) . ')'; + } + } + + $message = 'Для PHP-консоли нужен CLI-интерпретатор PHP 7.3 или новее.'; + if ($detected) { + $message .= ' Найдены несовместимые: ' . implode(', ', $detected) . '.'; + } + + throw new \RuntimeException($message . ' Укажите путь в ADMINX_PHP_BINARY.'); + } + + protected static function probePhpBinary($binary) + { + $command = escapeshellarg((string) $binary) . ' -r ' . escapeshellarg('echo PHP_VERSION_ID;'); + $pipes = array(); + $process = @proc_open($command, array( + 0 => array('pipe', 'r'), + 1 => array('pipe', 'w'), + 2 => array('pipe', 'w'), + ), $pipes); + if (!is_resource($process)) { + return 0; + } + + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $started = microtime(true); + $output = ''; + do { + $output .= (string) stream_get_contents($pipes[1]); + $status = proc_get_status($process); + if (!$status['running']) { + break; + } + + if (microtime(true) - $started >= 2) { + proc_terminate($process, 9); + break; + } + + usleep(10000); + } while (true); + $output .= (string) stream_get_contents($pipes[1]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + + $output = trim($output); + return preg_match('/^\d{5,6}$/', $output) === 1 ? (int) $output : 0; + } + + protected static function formatVersionId($version) + { + $version = (int) $version; + return (int) floor($version / 10000) + . '.' . (int) floor(($version % 10000) / 100) + . '.' . (int) ($version % 100); + } + + protected static function cleanup($dir) + { + TemporaryDirectory::remove($dir); + } + } diff --git a/adminx/modules/Console/assets/console.css b/adminx/modules/Console/assets/console.css new file mode 100644 index 0000000..45495ec --- /dev/null +++ b/adminx/modules/Console/assets/console.css @@ -0,0 +1,62 @@ +.console-warning { + margin-bottom: 20px; +} +.console-toolbar { + display: grid; + grid-template-columns: minmax(190px, 280px) minmax(220px, 1fr) auto; + gap: 10px; + align-items: center; + padding: 14px; + border-bottom: 1px solid var(--border-default); +} +.console-toolbar-actions { + flex-wrap: nowrap; +} +.console-editor-card { + overflow: hidden; +} +.console-editor-card .CodeMirror { + border: 0; + border-radius: 0; +} +.console-actions { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + padding: 12px 14px; + border-top: 1px solid var(--border-default); + background: var(--background-inset); +} +.console-output-header { + margin-top: 24px; +} +.console-output-card { + overflow: hidden; +} +.console-output-card pre { + min-height: 180px; + max-height: 440px; + margin: 0; + padding: 18px; + overflow: auto; + background: #10151c; + color: #d8e2ee; + font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +@media (max-width: 760px) { + .console-toolbar { + grid-template-columns: 1fr; + } + .console-toolbar-actions, + .console-actions, + .console-actions .cluster { + width: 100%; + } + .console-actions { + align-items: stretch; + flex-direction: column; + } +} diff --git a/adminx/modules/Console/assets/console.js b/adminx/modules/Console/assets/console.js new file mode 100644 index 0000000..51b7930 --- /dev/null +++ b/adminx/modules/Console/assets/console.js @@ -0,0 +1,152 @@ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.Console = { + root: null, + currentId: 0, + + init: function () { + this.root = document.querySelector('[data-console]'); + if (!this.root) { return; } + var self = this; + this.select().addEventListener('change', function () { self.load(this.value); }); + document.addEventListener('click', function (event) { + if (event.target.closest('[data-console-new]')) { self.reset(); } + if (event.target.closest('[data-console-save]')) { self.save(); } + if (event.target.closest('[data-console-delete]')) { self.remove(); } + if (event.target.closest('[data-console-lint]')) { self.lint(); } + if (event.target.closest('[data-console-execute]')) { self.execute(); } + if (event.target.closest('[data-console-clear]')) { self.setCode(''); } + }); + }, + + base: function () { return this.root.getAttribute('data-base') || Adminx.base(); }, + select: function () { return this.root.querySelector('[data-console-snippets]'); }, + name: function () { return this.root.querySelector('[data-console-name]'); }, + textarea: function () { return this.root.querySelector('[data-console-code]'); }, + code: function () { + var textarea = this.textarea(); + if (textarea._adminxCodeMirror) { textarea._adminxCodeMirror.save(); } + return textarea.value; + }, + setCode: function (value) { + var textarea = this.textarea(); + textarea.value = value || ''; + if (textarea._adminxCodeMirror) { textarea._adminxCodeMirror.setValue(textarea.value); } + }, + + request: function (url, fd, method) { + Adminx.Loader.show(); + return Adminx.Ajax.request(url, { method: method || 'POST', body: fd || null }).then(function (payload) { + Adminx.Loader.hide(); + return payload; + }).catch(function () { + Adminx.Loader.hide(); + return { ok: false, data: { success: false, message: 'Ошибка сети' } }; + }); + }, + + load: function (id) { + if (!id) { this.reset(); return; } + var self = this; + this.request(this.base() + '/system/console/snippets/' + encodeURIComponent(id), null, 'GET').then(function (payload) { + var data = payload.data && payload.data.data; + if (!payload.ok || !data) { Adminx.Toast.show((payload.data && payload.data.message) || 'Сниппет не найден', 'error'); return; } + self.currentId = parseInt(data.id, 10) || 0; + self.name().value = data.name || ''; + self.setCode(data.code || ''); + self.toggleDelete(); + }); + }, + + reset: function () { + this.currentId = 0; + this.select().value = ''; + this.name().value = ''; + this.setCode('echo "AVE.cms " . APP_VERSION . PHP_EOL;'); + this.toggleDelete(); + this.name().focus(); + }, + + toggleDelete: function () { + var button = this.root.querySelector('[data-console-delete]'); + if (button) { button.disabled = !this.currentId; } + }, + + save: function () { + var self = this; + var fd = new FormData(); + fd.append('id', this.currentId || 0); + fd.append('name', this.name().value.trim()); + fd.append('code', this.code()); + this.request(this.base() + '/system/console/snippets', fd).then(function (payload) { + var body = payload.data || {}; + Adminx.Toast.show(body.message || (payload.ok ? 'Сохранено' : 'Ошибка'), payload.ok ? 'success' : 'error'); + if (!payload.ok) { return; } + self.currentId = parseInt(body.data.id, 10) || 0; + self.renderOptions(body.data.snippets || []); + self.select().value = String(self.currentId); + self.toggleDelete(); + }); + }, + + remove: function () { + if (!this.currentId) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'error', title: 'Удалить сниппет?', message: this.name().value || 'Сниппет будет удалён.', confirmLabel: 'Удалить', confirmClass: 'btn-danger', + onConfirm: function () { + self.request(self.base() + '/system/console/snippets/' + self.currentId + '/delete', new FormData()).then(function (payload) { + var body = payload.data || {}; + Adminx.Toast.show(body.message || (payload.ok ? 'Удалено' : 'Ошибка'), payload.ok ? 'success' : 'error'); + if (payload.ok) { self.renderOptions(body.data.snippets || []); self.reset(); } + }); + } + }); + }, + + lint: function () { this.runAction('lint'); }, + execute: function () { this.runAction('execute'); }, + + runAction: function (action) { + var self = this; + var fd = new FormData(); + fd.append('code', this.code()); + this.setOutput(action === 'execute' ? 'Выполнение...' : 'Проверка...', ''); + this.request(this.base() + '/system/console/' + action, fd).then(function (payload) { + var body = payload.data || {}; + var data = body.data || {}; + if (action === 'execute') { + self.setOutput(data.output || body.message || '', data.duration_ms != null ? data.duration_ms + ' мс · код ' + data.exit_code : 'Ошибка'); + } else { + self.setOutput(data.output || (body.errors && body.errors.code) || body.message || '', body.success ? 'Синтаксис корректен' : 'Ошибка синтаксиса'); + } + Adminx.Toast.show(body.message || (payload.ok ? 'Готово' : 'Ошибка'), payload.ok ? 'success' : 'error'); + }); + }, + + setOutput: function (text, meta) { + var output = document.querySelector('[data-console-output]'); + var info = document.querySelector('[data-console-meta]'); + if (output) { output.textContent = text || '(вывод пуст)'; } + if (info) { info.textContent = meta || ''; } + }, + + renderOptions: function (items) { + var select = this.select(); + select.innerHTML = ''; + items.forEach(function (item) { + var option = document.createElement('option'); + option.value = item.id; + option.textContent = item.name; + select.appendChild(option); + }); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.Console.init(); }); + } else { Adminx.Console.init(); } +})(window, document); diff --git a/adminx/modules/Console/language/en/client.xml b/adminx/modules/Console/language/en/client.xml new file mode 100644 index 0000000..11547f8 --- /dev/null +++ b/adminx/modules/Console/language/en/client.xml @@ -0,0 +1,22 @@ + + + Snippet not found + New + Syntax error + The snippet will be deleted. + Done + (output is empty) + Error + New snippet + Delete + ms code + The syntax is correct + Deleted + Delete snippet? + Network error + Checking... + <option value="">New snippet</option> + Saved + Execution... + Snippet not found + diff --git a/adminx/modules/Console/language/en/interface.xml b/adminx/modules/Console/language/en/interface.xml new file mode 100644 index 0000000..d4410a7 --- /dev/null +++ b/adminx/modules/Console/language/en/interface.xml @@ -0,0 +1,57 @@ + + + Process output + Execution disabled + Execute + Ready to run. + The code runs with access to the application and database. + Local execution + Snippet title + Didn't start + New + New snippet + Clear + Result + System tool · isolated PHP process + Saved snippets + Old fragments are not imported automatically. + (output is empty) + <option value="">New snippet</option> + \n[output truncated] + PHP console + The PHP console will execute the entered code on the server. + PHP console: execution + PHP Console: View + PHP console: snippets + proc_open is not available: isolated startup is not possible. + Enter PHP code + Execute PHP code in an isolated process. + PHP console execution is disabled by the environment. + Execution... + Done + Code completed + ms code + The PHP process failed to start. + Insufficient rights + Required field + Error + Network error + Syntax error + Time limit exceeded + Checking... + View the system console and saved snippets. + The process ended with an error + The syntax is correct + System + The snippet will be deleted. + Snippet not found + Snippet saved + Snippet removed + Creating, changing and deleting snippets. + Saved + String + Deleted + Delete + Delete snippet? + Enter the name and code + diff --git a/adminx/modules/Console/language/en/runtime.xml b/adminx/modules/Console/language/en/runtime.xml new file mode 100644 index 0000000..7d84ca9 --- /dev/null +++ b/adminx/modules/Console/language/en/runtime.xml @@ -0,0 +1,30 @@ + + + PHP console + The PHP console will execute the entered code on the server. + PHP console: execution + PHP Console: View + PHP console: snippets + [output shortened] + proc_open is not available: isolated startup is not possible. + Enter PHP code + Execute PHP code in an isolated process. + PHP console execution is disabled by the environment. + The PHP console requires a PHP 7.3 or later CLI interpreter. + Code completed + Found incompatible: + The PHP process failed to start. + Insufficient rights + Required field + Time limit exceeded + View the system console and saved snippets. + The process ended with an error + System + Snippet not found + Snippet saved + Snippet removed + Creating, changing and deleting snippets. + String + Enter the name and code + Set the path to ADMINX_PHP_BINARY. + diff --git a/adminx/modules/Console/language/ru/client.xml b/adminx/modules/Console/language/ru/client.xml new file mode 100644 index 0000000..a8dd32b --- /dev/null +++ b/adminx/modules/Console/language/ru/client.xml @@ -0,0 +1,22 @@ + + + Сниппет не найден + Новый + Ошибка синтаксиса + Сниппет будет удалён. + Готово + (вывод пуст) + Ошибка + Новый сниппет + Удалить + мс · код + Синтаксис корректен + Удалено + Удалить сниппет? + Ошибка сети + Проверка... + <option value="">Новый сниппет</option> + Сохранено + Выполнение... + Сниппет не найден + diff --git a/adminx/modules/Console/language/ru/interface.xml b/adminx/modules/Console/language/ru/interface.xml new file mode 100644 index 0000000..76661c7 --- /dev/null +++ b/adminx/modules/Console/language/ru/interface.xml @@ -0,0 +1,57 @@ + + + Вывод процесса + Выполнение отключено + Выполнить + Готово к выполнению. + Код выполняется с доступом к приложению и базе данных. + Локальное выполнение + Название сниппета + Не запускалось + Новый + Новый сниппет + Очистить + Результат + Системный инструмент · изолированный PHP-процесс + Сохранённые сниппеты + Старые фрагменты не импортируются автоматически. + (вывод пуст) + <option value="">Новый сниппет</option> + \n[вывод сокращён] + PHP-консоль + PHP-консоль выполнит введённый код на сервере. + PHP-консоль: выполнение + PHP-консоль: просмотр + PHP-консоль: сниппеты + proc_open недоступен: изолированный запуск невозможен. + Введите PHP-код + Выполнение PHP-кода в изолированном процессе. + Выполнение PHP-консоли отключено окружением. + Выполнение... + Готово + Код выполнен + мс · код + Не удалось запустить PHP-процесс. + Недостаточно прав + Обязательное поле + Ошибка + Ошибка сети + Ошибка синтаксиса + Превышен лимит времени + Проверка... + Просмотр системной консоли и сохранённых сниппетов. + Процесс завершился с ошибкой + Синтаксис корректен + Система + Сниппет будет удалён. + Сниппет не найден + Сниппет сохранён + Сниппет удалён + Создание, изменение и удаление сниппетов. + Сохранено + Строка + Удалено + Удалить + Удалить сниппет? + Укажите название и код + diff --git a/adminx/modules/Console/language/ru/runtime.xml b/adminx/modules/Console/language/ru/runtime.xml new file mode 100644 index 0000000..8f27c6f --- /dev/null +++ b/adminx/modules/Console/language/ru/runtime.xml @@ -0,0 +1,30 @@ + + + PHP-консоль + PHP-консоль выполнит введённый код на сервере. + PHP-консоль: выполнение + PHP-консоль: просмотр + PHP-консоль: сниппеты + [вывод сокращён] + proc_open недоступен: изолированный запуск невозможен. + Введите PHP-код + Выполнение PHP-кода в изолированном процессе. + Выполнение PHP-консоли отключено окружением. + Для PHP-консоли нужен CLI-интерпретатор PHP 7.3 или новее. + Код выполнен + Найдены несовместимые: + Не удалось запустить PHP-процесс. + Недостаточно прав + Обязательное поле + Превышен лимит времени + Просмотр системной консоли и сохранённых сниппетов. + Процесс завершился с ошибкой + Система + Сниппет не найден + Сниппет сохранён + Сниппет удалён + Создание, изменение и удаление сниппетов. + Строка + Укажите название и код + Укажите путь в ADMINX_PHP_BINARY. + diff --git a/adminx/modules/Console/migrations/001_create_console_snippets.sql b/adminx/modules/Console/migrations/001_create_console_snippets.sql new file mode 100644 index 0000000..461f12f --- /dev/null +++ b/adminx/modules/Console/migrations/001_create_console_snippets.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_console_snippets` ( + `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(190) NOT NULL, + `code` MEDIUMTEXT NOT NULL, + `author_id` INT(10) UNSIGNED NOT NULL DEFAULT 0, + `created_at` INT(10) UNSIGNED NOT NULL DEFAULT 0, + `updated_at` INT(10) UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_updated` (`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Console/module.php b/adminx/modules/Console/module.php new file mode 100644 index 0000000..cfee887 --- /dev/null +++ b/adminx/modules/Console/module.php @@ -0,0 +1,92 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + use App\Adminx\Support\SystemFeatures; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + if (!SystemFeatures::enabled('console')) { + return array( + 'code' => 'console', + 'name' => 'PHP-консоль', + 'version' => '0.1.0', + 'registry' => array('dynamic' => true), + ); + } + + return array( + 'code' => 'console', + 'name' => 'PHP-консоль', + 'version' => '0.1.0', + 'registry' => array('dynamic' => true), + 'permissions' => array( + 'key' => 'console', + 'items' => array( + array( + 'code' => 'view_console', + 'group_code' => 'navigation', + 'name' => 'PHP-консоль: просмотр', + 'description' => 'Просмотр системной консоли и сохранённых сниппетов.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_console', + 'group_code' => 'admin', + 'name' => 'PHP-консоль: сниппеты', + 'description' => 'Создание, изменение и удаление сниппетов.', + 'sort_order' => 20, + ), + array( + 'code' => 'execute_console', + 'group_code' => 'admin', + 'name' => 'PHP-консоль: выполнение', + 'description' => 'Выполнение PHP-кода в изолированном процессе.', + 'sort_order' => 30, + ), + ), + 'icon' => 'ti ti-terminal-2', + 'priority' => 47, + ), + 'navigation' => array( + array( + 'code' => 'console', + 'label' => 'PHP-консоль', + 'url' => '/system/console', + 'icon' => 'ti ti-terminal-2', + 'permission' => 'view_console', + 'group' => 'Система', + 'sort_order' => 38, + 'parent' => 'settings', + 'match' => array( + '/system/console', + ), + ), + ), + 'migrations' => array( + array('id' => '001_create_console_snippets', 'file' => 'migrations/001_create_console_snippets.sql'), + ), + 'routes' => array( + array('GET', '/system/console', array(\App\Adminx\Console\Controller::class, 'index')), + array('GET', '/system/console/snippets/{id}', array(\App\Adminx\Console\Controller::class, 'show')), + array('POST', '/system/console/snippets', array(\App\Adminx\Console\Controller::class, 'save')), + array('POST', '/system/console/snippets/{id}/delete', array(\App\Adminx\Console\Controller::class, 'delete')), + array('POST', '/system/console/lint', array(\App\Adminx\Console\Controller::class, 'lint')), + array('POST', '/system/console/execute', array(\App\Adminx\Console\Controller::class, 'execute'), array( + 'permission' => 'execute_console', + 'sensitive' => 'stored_php.execute', + 'reauth' => array('reason' => 'PHP-консоль выполнит введённый код на сервере.'), + )), + ), + 'view_globals' => array('module_code' => 'console'), + ); diff --git a/adminx/modules/Console/view/index.twig b/adminx/modules/Console/view/index.twig new file mode 100644 index 0000000..66020de --- /dev/null +++ b/adminx/modules/Console/view/index.twig @@ -0,0 +1,72 @@ +{% extends '@adminx/main.twig' %} +{% block title %}PHP-консоль{% endblock %} +{% block content %} + + + + + +
    + +
    Код выполняется с доступом к приложению и базе данных. Старые фрагменты не импортируются автоматически.
    +
    + +
    +
    +
    +
    Система
    +

    Редактор

    +
    +
    + +
    +
    + + +
    + + {% if can_manage %} + + + {% endif %} +
    +
    + + + +
    +
    + {% if can_manage %}{% endif %} + +
    + {% if can_execute %}{% endif %} +
    +
    + +
    +
    +
    +
    Результат
    +

    Вывод процесса

    +
    +
    Не запускалось
    +
    + +
    +
    Готово к выполнению.
    +
    + +{% endblock %} diff --git a/adminx/modules/Customers/Controller.php b/adminx/modules/Customers/Controller.php new file mode 100644 index 0000000..47d609c --- /dev/null +++ b/adminx/modules/Customers/Controller.php @@ -0,0 +1,64 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Customers; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Adminx\Support\Roles; + use App\Adminx\Support\CodeEditor; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Helpers\Request; + use App\Helpers\Response; + + class Controller extends BaseController + { + public function index(array $params=array()) + { + if(!Permission::check('view_customers')){Response::forbidden();return '';}AdminAssets::addStyle($this->base().'/modules/Customers/assets/customers.css',50);AdminAssets::addScript($this->base().'/modules/Customers/assets/customers.js',50);CodeEditor::useCodeMirror('htmlmixed'); + $oauthModules=Model::oauthModules();foreach($oauthModules as &$oauthModule){$oauthModule['can_open']=Permission::check($oauthModule['permission']);}unset($oauthModule); + $tab=Request::getStr('tab','customers');if(!in_array($tab,array('customers','fields','auth','pages'),true)){$tab='customers';}return $this->render('@customers/index.twig',array('tab'=>$tab,'customers'=>Model::customers(Request::getStr('q','')),'fields'=>Model::fields(),'stats'=>Model::stats(),'auth_settings'=>Model::authSettings(),'checkout_access_template_default'=>\App\Common\PublicAuthSettings::defaultCheckoutAccessTemplate(),'oauth_modules'=>$oauthModules,'customer_groups'=>Model::groups(),'admin_roles'=>Roles::map(),'page_templates'=>Model::pageTemplates(),'auth_forms'=>Model::authFormDefinitions(),'q'=>Request::getStr('q',''),'can_manage'=>Permission::check('manage_customers'),'can_manage_admin_access'=>Permission::check('manage_users'))); + } + + public function toggle(array $params=array()){if(($e=$this->guard())!==null){return $e;}return $this->success(Model::toggle(isset($params['id'])?$params['id']:0)?'Пользователь включён':'Пользователь отключён');} + public function customer(array $params=array()) + { + if(!Permission::check('view_customers')){return $this->error('Недостаточно прав',array(),403);} + $customer=Model::customer(isset($params['id'])?$params['id']:0); + return $customer?$this->success('',array('data'=>$customer)):$this->error('Пользователь не найден',array(),404); + } + + public function updateCustomer(array $params=array()) + { + if(($e=$this->guard())!==null){return $e;} + $id=isset($params['id'])?(int)$params['id']:0;$input=Request::postAll(); + if(!Permission::check('manage_users')){$current=Model::customer($id);$input['admin_access']=!empty($current['system']['is_active'])?'1':'';$input['admin_role']=!empty($current['system']['role'])?(string)$current['system']['role']:'manager';} + try{$customer=Model::updateCustomer($id,$input,Auth::id());}catch(\InvalidArgumentException $e){return $this->error($e->getMessage(),array(),422);}catch(\Throwable $e){return $this->error('Не удалось сохранить пользователя',array(),500);} + return $this->success('Профиль пользователя сохранён',array('data'=>$customer)); + } + + public function saveField(array $params=array()){if(($e=$this->guard())!==null){return $e;}try{$id=Model::saveField(isset($params['id'])?$params['id']:0,Request::postAll());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Поле сохранено',array('data'=>array('id'=>$id),'reload'=>true));} + public function deleteField(array $params=array()){if(($e=$this->guard())!==null){return $e;}Model::deleteField(isset($params['id'])?$params['id']:0);return $this->success('Поле удалено',array('reload'=>true));} + public function toggleField(array $params=array()){if(($e=$this->guard())!==null){return $e;}$active=Model::toggleField(isset($params['id'])?$params['id']:0);return $this->success($active?'Поле включено':'Поле скрыто',array('data'=>array('is_active'=>$active?1:0)));} + public function reorderFields(array $params=array()){if(($e=$this->guard())!==null){return $e;}$ids=json_decode(Request::postStr('order','[]'),true);if(!is_array($ids)){return $this->error('Некорректный порядок полей',array(),422);}$count=Model::reorderFields($ids);return $this->success('Порядок полей сохранён',array('data'=>array('count'=>$count)));} + public function saveAuthSettings(array $params=array()){if(($e=$this->guard())!==null){return $e;}try{$settings=Model::saveAuthSettings(Request::postAll());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Настройки регистрации сохранены',array('data'=>array('settings'=>$settings)));} + public function saveAuthPages(array $params=array()){if(($e=$this->guard())!==null){return $e;}try{$settings=Model::saveAuthPages(Request::postAll());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Страницы входа сохранены',array('data'=>array('settings'=>$settings)));} + public function authForm(array $params=array()){if(!Permission::check('view_customers')){return $this->error('Недостаточно прав',array(),403);}try{$form=Model::authForm(isset($params['key'])?(string)$params['key']:'');}catch(\Throwable $e){return $this->error($e->getMessage(),array(),404);}return $this->success('',array('data'=>$form));} + public function saveAuthForm(array $params=array()){if(($e=$this->guard())!==null){return $e;}try{$template=Model::saveAuthForm(isset($params['key'])?(string)$params['key']:'',Request::postStr('template',''),Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array('template'=>$e->getMessage()),422);}return $this->success('Шаблон формы сохранён',array('data'=>array('template'=>$template,'customized'=>1)));} + public function resetAuthForm(array $params=array()){if(($e=$this->guard())!==null){return $e;}try{$template=Model::resetAuthForm(isset($params['key'])?(string)$params['key']:'');}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Восстановлен штатный шаблон формы',array('data'=>array('template'=>$template,'customized'=>0)));} + protected function guard(){return $this->guardPermission('manage_customers');} + } diff --git a/adminx/modules/Customers/Model.php b/adminx/modules/Customers/Model.php new file mode 100644 index 0000000..ab18c5e --- /dev/null +++ b/adminx/modules/Customers/Model.php @@ -0,0 +1,413 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Customers; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Adminx\Support\Roles; + use App\Common\Auth\IdentityLinker; + use App\Common\ModuleManager; + use App\Content\PublicUserTables; + use App\Content\ContentTables; + use App\Common\PublicAuthSettings; + use App\Common\Twig; + use App\Frontend\Auth\FormTemplateRepository; + use App\Frontend\Auth\OAuth\ProviderRegistry; + use App\Frontend\Auth\Phone\ProviderRegistry as PhoneProviderRegistry; + use App\Frontend\Auth\UserRepository; + use App\Helpers\Phone; + + class Model + { + public static function customers($q='') + { + $sql='SELECT Id AS id,email,firstname,lastname,user_name,phone,company,status,reg_time,last_visit FROM '.self::table('users').' WHERE deleted!=%s';$args=array('1');$q=trim((string)$q); + if($q!==''){$sql.=' AND (email LIKE %ss OR firstname LIKE %ss OR lastname LIKE %ss OR phone LIKE %ss OR company LIKE %ss)';for($i=0;$i<5;$i++){$args[]=$q;}}$sql.=' ORDER BY Id DESC LIMIT 500';return call_user_func_array(array('DB','query'),array_merge(array($sql),$args))->getAll()?:array(); + } + + public static function stats() + { + $table=self::table('users');return array('total'=>(int)DB::query('SELECT COUNT(*) FROM '.$table.' WHERE deleted!=%s','1')->getValue(),'active'=>(int)DB::query('SELECT COUNT(*) FROM '.$table.' WHERE deleted!=%s AND status=%s','1','1')->getValue(),'verified'=>(int)DB::query('SELECT COUNT(*) FROM '.$table.' WHERE deleted!=%s AND (email_verified_at>0 OR phone_verified_at>0)','1')->getValue(),'fields'=>count(self::fields())); + } + + public static function toggle($id) + { + DB::query('UPDATE '.self::table('users')." SET status=IF(status='1','0','1') WHERE Id=%i AND deleted!=%s",(int)$id,'1'); + $active=(string)DB::query('SELECT status FROM '.self::table('users').' WHERE Id=%i',(int)$id)->getValue()==='1'; + if(!$active){self::invalidateCustomerSessions((int)$id);} + return $active; + } + + public static function customer($id) + { + $row=DB::query('SELECT Id AS id,email,email_verified_at,firstname,lastname,user_name,phone,phone_normalized,phone_verified_at,company,city,street,street_nr,zipcode,birthday,description,user_group,status,reg_time,last_visit FROM '.self::table('users').' WHERE Id=%i AND deleted!=%s LIMIT 1',(int)$id,'1')->getAssoc(); + if(!$row){return null;} + $row=(array)$row; + $row['birthday']=(int)$row['birthday']>0?date('Y-m-d',(int)$row['birthday']):''; + $values=DB::query('SELECT field_id,value FROM '.self::table('user_profile_values').' WHERE user_id=%i',(int)$id)->getAll()?:array(); + $extra=array(); + foreach($values as $value){$extra[(string)(int)$value['field_id']]=(string)$value['value'];} + $system=IdentityLinker::systemForPublic((int)$id,(string)$row['email']); + return array('user'=>$row,'extra'=>$extra,'system'=>$system?array( + 'id'=>(int)$system['id'],'role'=>(string)$system['role'],'is_active'=>(int)$system['is_active'], + ):null); + } + + public static function updateCustomer($id,array $input,$currentSystemId=0) + { + $id=(int)$id; + $current=self::customer($id); + if(!$current){throw new \InvalidArgumentException('Пользователь не найден');} + + $email=mb_strtolower(trim((string)(isset($input['email'])?$input['email']:''))); + $phoneInput=trim((string)(isset($input['phone'])?$input['phone']:'')); + $phone=$phoneInput!==''?Phone::normalize($phoneInput):''; + $firstName=trim((string)(isset($input['firstname'])?$input['firstname']:'')); + $lastName=trim((string)(isset($input['lastname'])?$input['lastname']:'')); + $userName=trim((string)(isset($input['user_name'])?$input['user_name']:'')); + $userName=$userName!==''?$userName:($email!==''?$email:$phone); + $group=(int)(isset($input['user_group'])?$input['user_group']:0); + $password=(string)(isset($input['password'])?$input['password']:''); + $adminAccess=!empty($input['admin_access']); + $adminRole=trim((string)(isset($input['admin_role'])?$input['admin_role']:'manager')); + if($email!==''&&!filter_var($email,FILTER_VALIDATE_EMAIL)){throw new \InvalidArgumentException('Укажите корректный email');} + if($phoneInput!==''&&$phone===''){throw new \InvalidArgumentException('Укажите корректный телефон');} + if($email===''&&$phone===''){throw new \InvalidArgumentException('Укажите email или телефон');} + if($adminAccess&&$email===''){throw new \InvalidArgumentException('Для доступа в панель управления укажите email');} + if(mb_strlen($email)>100||mb_strlen($userName)>50||mb_strlen($firstName)>50||mb_strlen($lastName)>50){throw new \InvalidArgumentException('Имя, email или логин превышают допустимую длину');} + if($email!==''&&DB::query('SELECT Id FROM '.self::table('users').' WHERE LOWER(email)=LOWER(%s) AND Id!=%i AND deleted!=%s LIMIT 1',$email,$id,'1')->getValue()){throw new \InvalidArgumentException('Этот email уже используется');} + if($phone!==''&&DB::query('SELECT Id FROM '.self::table('users').' WHERE phone_normalized=%s AND Id!=%i AND deleted!=%s LIMIT 1',$phone,$id,'1')->getValue()){throw new \InvalidArgumentException('Этот телефон уже используется');} + if(DB::query('SELECT Id FROM '.self::table('users').' WHERE LOWER(user_name)=LOWER(%s) AND Id!=%i AND deleted!=%s LIMIT 1',$userName,$id,'1')->getValue()){throw new \InvalidArgumentException('Этот логин уже используется');} + $groupIds=array_map(function($item){return (int)$item['id'];},self::groups()); + if(!in_array($group,$groupIds,true)){throw new \InvalidArgumentException('Выберите активную группу публичных пользователей');} + $authSettings=PublicAuthSettings::all(); + $minimum=max(6,(int)(isset($authSettings['password_min_length'])?$authSettings['password_min_length']:8)); + if($password!==''&&mb_strlen($password)<$minimum){throw new \InvalidArgumentException('Новый пароль: минимум '.$minimum.' символов');} + if($adminAccess&&!in_array($adminRole,Roles::codes(),true)){throw new \InvalidArgumentException('Выберите роль для доступа в панель управления');} + + $extra=isset($input['extra'])&&is_array($input['extra'])?$input['extra']:array(); + self::validateExtraValues($extra); + $birthday=trim((string)(isset($input['birthday'])?$input['birthday']:'')); + $birthdayValue=$birthday!==''?strtotime($birthday.' 00:00:00'):0; + if($birthday!==''&&$birthdayValue===false){throw new \InvalidArgumentException('Укажите корректную дату рождения');} + $active=!empty($input['status'])?'1':'0'; + $now=time(); + $data=array( + 'email'=>$email!==''?$email:null,'firstname'=>$firstName,'lastname'=>$lastName,'user_name'=>$userName, + 'phone'=>$phone,'phone_normalized'=>$phone!==''?$phone:null, + 'company'=>trim((string)(isset($input['company'])?$input['company']:'')), + 'city'=>trim((string)(isset($input['city'])?$input['city']:'')), + 'street'=>trim((string)(isset($input['street'])?$input['street']:'')), + 'street_nr'=>trim((string)(isset($input['street_nr'])?$input['street_nr']:'')), + 'zipcode'=>trim((string)(isset($input['zipcode'])?$input['zipcode']:'')), + 'birthday'=>$birthdayValue?(int)$birthdayValue:0, + 'description'=>trim((string)(isset($input['description'])?$input['description']:'')), + 'user_group'=>$group,'status'=>$active, + 'email_verified_at'=>$email!==''&&!empty($input['email_verified'])?((int)$current['user']['email_verified_at']?:$now):0, + 'phone_verified_at'=>$phone!==''&&!empty($input['phone_verified'])&&$phone===(string)$current['user']['phone_normalized']?((int)$current['user']['phone_verified_at']?:$now):($phone!==''&&!empty($input['phone_verified'])?$now:0), + ); + + DB::startTransaction(); + try{ + DB::Update(self::table('users'),$data,'Id=%i',$id); + self::saveExtraValues($id,$extra); + if($password!==''){(new UserRepository())->setPassword($id,$password);} + IdentityLinker::setAdminAccess($id,$adminAccess,$adminRole,(int)$currentSystemId); + if($password!==''||$active!=='1'){self::invalidateCustomerSessions($id);} + DB::commit(); + }catch(\Throwable $e){DB::rollback();throw $e;} + return self::customer($id); + } + + public static function fields() + { + $rows = DB::query('SELECT * FROM '.self::table('user_profile_fields').' ORDER BY position,id')->getAll() ?: array(); + $types = self::fieldTypes(); + foreach ($rows as &$row) { + $type = isset($types[$row['type']]) ? $types[$row['type']] : $types['text']; + $row['type_label'] = $type['label']; + $row['type_icon'] = $type['icon']; + $row['choices'] = self::fieldChoices(isset($row['options']) ? $row['options'] : ''); + } + + unset($row); + return $rows; + } + + public static function saveField($id,array $input) + { + $id=(int)$id;$code=strtolower(trim((string)(isset($input['code'])?$input['code']:'')));$name=trim((string)(isset($input['name'])?$input['name']:''));$type=(string)(isset($input['type'])?$input['type']:'text'); + if(!preg_match('/^[a-z][a-z0-9_]{1,63}$/',$code)){throw new \InvalidArgumentException('Код: латиница, цифры и подчёркивание');}if($name===''){throw new \InvalidArgumentException('Укажите название поля');}if(!in_array($type,array('text','textarea','select','checkbox','date','number','email','tel'),true)){$type='text';} + if(DB::query('SELECT id FROM '.self::table('user_profile_fields').' WHERE code=%s AND id!=%i LIMIT 1',$code,$id)->getValue()){throw new \InvalidArgumentException('Поле с таким кодом уже существует');} + $data=array('code'=>$code,'name'=>$name,'type'=>$type,'options'=>trim((string)(isset($input['options'])?$input['options']:'')),'is_required'=>!empty($input['is_required'])?1:0,'is_active'=>!empty($input['is_active'])?1:0,'show_registration'=>!empty($input['show_registration'])?1:0,'show_profile'=>!empty($input['show_profile'])?1:0,'position'=>max(0,(int)(isset($input['position'])?$input['position']:0))); + if($id>0){DB::Update(self::table('user_profile_fields'),$data,'id=%i',$id);}else{$data['created_at']=time();DB::Insert(self::table('user_profile_fields'),$data);$id=(int)DB::insertId();}return $id; + } + + public static function deleteField($id) + { + $id=(int)$id;DB::Delete(self::table('user_profile_values'),'field_id=%i',$id);return DB::Delete(self::table('user_profile_fields'),'id=%i',$id); + } + + public static function toggleField($id) + { + $id = (int) $id; + DB::query('UPDATE '.self::table('user_profile_fields').' SET is_active=IF(is_active=1,0,1) WHERE id=%i', $id); + return (int) DB::query('SELECT is_active FROM '.self::table('user_profile_fields').' WHERE id=%i', $id)->getValue() === 1; + } + + public static function reorderFields(array $ids) + { + $valid = array(); + foreach ($ids as $id) { + $id = (int) $id; + if ($id > 0 && !in_array($id, $valid, true)) { $valid[] = $id; } + } + + foreach ($valid as $position => $id) { + DB::Update(self::table('user_profile_fields'), array('position' => $position + 1), 'id=%i', $id); + } + + return count($valid); + } + + public static function authSettings() + { + $settings = PublicAuthSettings::all(); + $settings['verification_hours'] = max(1, (int) round($settings['verification_ttl'] / 3600)); + $settings['reset_hours'] = max(1, (int) round($settings['reset_ttl'] / 3600)); + return $settings; + } + + public static function oauthModules() + { + $definitions = array( + array( + 'module' => 'yandex_oauth', 'provider' => 'yandex', 'label' => 'Яндекс', + 'description' => 'Вход и регистрация через аккаунт Яндекса.', + 'url' => '/modules/yandex-oauth/settings', 'permission' => 'view_yandex_oauth', + 'brand' => 'is-yandex', 'mark' => 'Я', + ), + array( + 'module' => 'vk_oauth', 'provider' => 'vk', 'label' => 'VK ID', + 'description' => 'Вход и регистрация через единую учётную запись VK.', + 'url' => '/modules/vk-oauth/settings', 'permission' => 'view_vk_oauth', + 'brand' => 'is-vk', 'mark' => 'VK', + ), + array( + 'module' => 'smsc_auth', 'provider' => 'smsc', 'label' => 'SMS', + 'description' => 'Вход и регистрация по одноразовому коду SMSC.', + 'url' => '/modules/smsc-auth/settings', 'permission' => 'view_smsc_auth', + 'brand' => 'is-smsc', 'mark' => 'SMS', 'kind' => 'phone', + ), + ); + $registered = ProviderRegistry::codes(); + $items = array(); + foreach ($definitions as $definition) { + $module = ModuleManager::get($definition['module']); + if (!$module || empty($module['installed'])) { continue; } + + $isPhone = isset($definition['kind']) && $definition['kind'] === 'phone'; + if ($isPhone) { + $phoneProvider = PhoneProviderRegistry::get($definition['provider']); + $isRegistered = $phoneProvider !== null; + $config = class_exists('\\App\\Modules\\SmscAuth\\SecretStore') + ? \App\Modules\SmscAuth\SecretStore::config() + : array(); + $configured = $isRegistered && $phoneProvider->configured(); + } else { + $isRegistered = in_array($definition['provider'], $registered, true); + $config = $isRegistered ? ProviderRegistry::config($definition['provider']) : array(); + $configured = $isRegistered && ProviderRegistry::configured($definition['provider'], $config); + } + + $active = !empty($module['enabled']) && $configured && !empty($config['enabled']); + if (empty($module['enabled'])) { + $state = array('label' => 'Модуль выключен', 'badge' => 'badge-gray'); + } elseif (!$configured) { + $state = array('label' => 'Нужны ключи', 'badge' => 'badge-amber'); + } elseif (!$active) { + $state = array('label' => 'Вход выключен', 'badge' => 'badge-gray'); + } else { + $state = array('label' => 'Вход активен', 'badge' => 'badge-green'); + } + + $items[] = array_merge($definition, array( + 'module_enabled' => !empty($module['enabled']), + 'configured' => $configured, + 'active' => $active, + 'allow_registration' => !empty($config['allow_registration']), + 'link_by_email' => !empty($config['link_by_email']), + 'state' => $state, + )); + } + + return $items; + } + + public static function saveAuthSettings(array $input) + { + foreach (array('registration_enabled','password_reset_enabled','require_firstname','show_lastname','require_lastname','show_phone','require_phone','show_company','require_company','checkout_registration_enabled') as $key) { + $input[$key] = !empty($input[$key]) ? 1 : 0; + } + + $groups = self::groups(); + $groupIds = array_map(function ($group) { return (int) $group['id']; }, $groups); + $group = isset($input['default_group']) ? (int) $input['default_group'] : 0; + if (!in_array($group, $groupIds, true)) { + throw new \InvalidArgumentException('Выберите существующую активную публичную группу'); + } + + if (isset($input['verification_hours'])) { $input['verification_ttl'] = max(1, (int) $input['verification_hours']) * 3600; } + if (isset($input['reset_hours'])) { $input['reset_ttl'] = max(1, (int) $input['reset_hours']) * 3600; } + $template = trim((string) (isset($input['checkout_access_template']) ? $input['checkout_access_template'] : '')); + if ($template === '') { $template = PublicAuthSettings::defaultCheckoutAccessTemplate(); } + try { + Twig::twig()->createTemplate($template, 'checkout_access_validate'); + } catch (\Throwable $e) { + throw new \InvalidArgumentException('Ошибка Twig в письме после заказа: ' . $e->getMessage()); + } + + $input['checkout_access_template'] = $template; + return PublicAuthSettings::save($input); + } + + public static function saveAuthPages(array $input) + { + $pages = isset($input['pages']) && is_array($input['pages']) ? $input['pages'] : array(); + if (empty($pages)) { throw new \InvalidArgumentException('Настройки страниц не переданы'); } + $templateIds = array_map(function ($row) { return (int) $row['id']; }, self::pageTemplates()); + foreach (array('login','register','remember','reset','profile','password') as $key) { + $templateId = isset($pages[$key]['template_id']) ? (int) $pages[$key]['template_id'] : 0; + if (!in_array($templateId, $templateIds, true)) { + throw new \InvalidArgumentException('Выберите существующий шаблон сайта для каждой формы.'); + } + } + + return PublicAuthSettings::save(array('pages' => $pages)); + } + + public static function pageTemplates() + { + $rows = DB::query('SELECT Id AS id, template_title AS title FROM ' . ContentTables::table('templates') . ' ORDER BY Id ASC')->getAll(); + return $rows ?: array(); + } + + public static function authFormDefinitions() + { + return array( + 'panel' => array('label' => 'Форма в шапке', 'description' => 'Выпадающая авторизация и меню вошедшего пользователя.', 'icon' => 'ti-layout-navbar', 'tile' => 'blue'), + 'login' => array('label' => 'Вход', 'description' => 'Основная страница входа в личный кабинет.', 'icon' => 'ti-login', 'tile' => 'blue'), + 'register' => array('label' => 'Регистрация', 'description' => 'Базовые и дополнительные поля регистрации.', 'icon' => 'ti-user-plus', 'tile' => 'green'), + 'remember' => array('label' => 'Запрос восстановления', 'description' => 'Форма отправки ссылки на email.', 'icon' => 'ti-mail-forward', 'tile' => 'amber'), + 'reset' => array('label' => 'Новый пароль', 'description' => 'Форма из одноразовой ссылки.', 'icon' => 'ti-key', 'tile' => 'red'), + 'overview' => array('label' => 'Обзор кабинета', 'description' => 'Сводка заказов и быстрые переходы пользователя.', 'icon' => 'ti-layout-dashboard', 'tile' => 'blue'), + 'profile' => array('label' => 'Профиль', 'description' => 'Контактные и дополнительные поля пользователя.', 'icon' => 'ti-user-circle', 'tile' => 'violet'), + 'password' => array('label' => 'Смена пароля', 'description' => 'Форма для авторизованного пользователя.', 'icon' => 'ti-lock-cog', 'tile' => 'cyan'), + 'message' => array('label' => 'Системное сообщение', 'description' => 'Результат регистрации, подтверждения и восстановления.', 'icon' => 'ti-message-circle-check', 'tile' => 'green'), + ); + } + + public static function authForm($key) + { + $definitions = self::authFormDefinitions(); + if (!isset($definitions[$key])) { throw new \InvalidArgumentException('Неизвестная форма'); } + $repository = new FormTemplateRepository(); + return array_merge($definitions[$key], array( + 'key' => $key, + 'template' => $repository->get($key), + 'customized' => $repository->customized($key) ? 1 : 0, + )); + } + + public static function saveAuthForm($key, $template, $authorId) + { + self::authForm($key); + return (new FormTemplateRepository())->save($key, $template, $authorId); + } + + public static function resetAuthForm($key) + { + self::authForm($key); + return (new FormTemplateRepository())->reset($key); + } + + public static function groups() + { + $rows = DB::query('SELECT user_group AS id,user_group_name AS name,status,user_group_permission AS permissions FROM '.self::table('user_groups').' ORDER BY user_group ASC')->getAll() ?: array(); + foreach ($rows as &$row) { + $items = array_filter(array_map('trim', explode('|', (string) $row['permissions']))); + $row['permissions_count'] = count($items); + } + + unset($row); + return array_values(array_filter($rows, function ($row) { return (string) $row['status'] === '1'; })); + } + + protected static function fieldTypes() + { + return array( + 'text' => array('label' => 'Однострочный текст', 'icon' => 'ti-letter-case'), + 'textarea' => array('label' => 'Многострочный текст', 'icon' => 'ti-align-left'), + 'select' => array('label' => 'Список значений', 'icon' => 'ti-list'), + 'checkbox' => array('label' => 'Переключатель', 'icon' => 'ti-toggle-right'), + 'date' => array('label' => 'Дата', 'icon' => 'ti-calendar'), + 'number' => array('label' => 'Число', 'icon' => 'ti-number'), + 'email' => array('label' => 'Email', 'icon' => 'ti-mail'), + 'tel' => array('label' => 'Телефон', 'icon' => 'ti-phone'), + ); + } + + protected static function validateExtraValues(array $values) + { + foreach(self::fields() as $field){ + if(empty($field['is_active'])){continue;} + $id=(int)$field['id']; + $value=isset($values[$id])?$values[$id]:''; + $normalized=(string)$field['type']==='checkbox'?(!empty($value)?'1':'0'):trim(is_array($value)?implode(',',array_map('trim',$value)):(string)$value); + if(!empty($field['is_required'])&&($normalized===''||((string)$field['type']==='checkbox'&&$normalized!=='1'))){throw new \InvalidArgumentException('Заполните поле «'.(string)$field['name'].'»');} + if($normalized!==''&&(string)$field['type']==='email'&&!filter_var($normalized,FILTER_VALIDATE_EMAIL)){throw new \InvalidArgumentException('Поле «'.(string)$field['name'].'» должно содержать email');} + if($normalized!==''&&(string)$field['type']==='number'&&!is_numeric($normalized)){throw new \InvalidArgumentException('Поле «'.(string)$field['name'].'» должно содержать число');} + if($normalized!==''&&(string)$field['type']==='select'&&!in_array($normalized,$field['choices'],true)){throw new \InvalidArgumentException('Выберите значение поля «'.(string)$field['name'].'» из списка');} + } + } + + protected static function saveExtraValues($userId,array $values) + { + foreach(self::fields() as $field){ + if(empty($field['is_active'])){continue;} + $id=(int)$field['id']; + $value=isset($values[$id])?$values[$id]:''; + if((string)$field['type']==='checkbox'){$value=!empty($value)?'1':'0';} + elseif(is_array($value)){$value=implode(',',array_map('trim',$value));} + else{$value=trim((string)$value);} + DB::query('INSERT INTO '.self::table('user_profile_values').' (user_id,field_id,value,updated_at) VALUES (%i,%i,%s,%i) ON DUPLICATE KEY UPDATE value=VALUES(value),updated_at=VALUES(updated_at)',(int)$userId,$id,$value,time()); + } + } + + protected static function fieldChoices($value) + { + $json=json_decode((string)$value,true); + return is_array($json)?array_values($json):array_values(array_filter(array_map('trim',preg_split('/[\r\n,]+/',(string)$value)))); + } + + protected static function invalidateCustomerSessions($id) + { + DB::Delete(self::table('users_session'),'user_id=%i',(int)$id); + DB::Delete(self::table('auth_tokens'),'user_id=%i',(int)$id); + } + + protected static function table($suffix){return PublicUserTables::table($suffix);} + } diff --git a/adminx/modules/Customers/assets/customers.css b/adminx/modules/Customers/assets/customers.css new file mode 100644 index 0000000..38c64dd --- /dev/null +++ b/adminx/modules/Customers/assets/customers.css @@ -0,0 +1,880 @@ +.customers-tabs { + margin-bottom: 18px; +} +.customers-tabs a, +.customers-tabs a:hover { + text-decoration: none; +} +.customers-filter { + display: flex; + gap: 10px; +} +.customers-filter .input-wrap { + min-width: 280px; +} +.customers-filter .input-wrap > i { + position: absolute; + top: 50%; + left: 12px; + transform: translateY(-50%); +} +.customers-filter .input { + padding-left: 36px; +} +.customers-summary ~ .card td small { + display: block; + margin-top: 4px; + color: var(--text-secondary); +} +.customers-table .customers-actions-col { + width: 104px; + text-align: right; +} +.customers-table td:last-child { + text-align: right; +} +.customers-table td:first-child { + font-variant-numeric: tabular-nums; +} +.customer-editor-drawer { + width: min(66.666vw, 1240px); + max-width: none; +} +.customer-editor-drawer > form { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} +.customer-editor-drawer .drawer-body { + flex: 1 1 auto; + background: var(--background-inset); +} +.customers-editor-meta { + display: grid; + grid-template-columns: 110px repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} +.customers-editor-meta > span { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + grid-template-rows: auto auto; + column-gap: 9px; + align-items: center; + min-height: 58px; + padding: 10px 12px; + border-radius: var(--radius-md); + background: var(--background-surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} +.customers-editor-meta i { + display: grid; + grid-row: 1 / 3; + place-items: center; + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + background: var(--blue-100); + color: var(--blue-600); + font-size: 18px; +} +.customers-editor-meta b { + min-width: 0; + overflow: hidden; + font-size: 13px; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} +.customers-editor-meta small { + color: var(--text-secondary); + font-size: 11.5px; +} +.customers-editor-sections { + display: grid; + gap: 14px; +} +.customers-editor-access { + margin-top: 14px; +} +.customers-editor-access .customers-switch-card:last-child { + grid-column: 1 / -1; +} +.customers-editor-checkbox { + display: flex; + align-items: center; + gap: 10px; + min-height: 42px; +} +.customers-editor-checkbox > span:last-child { + color: var(--text-secondary); + font-size: 13px; +} +.customers-editor-password { + max-width: 560px; +} +.customers-field-builder { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(300px, 0.75fr); + gap: 16px; + align-items: start; +} +.customers-field-structure, +.customers-profile-preview { + min-width: 0; +} +.customers-profile-preview { + padding: 0; + overflow: hidden; + position: sticky; + top: 80px; +} +.customers-field-list { + display: grid; + gap: 8px; + padding: 12px; + background: var(--background-inset); +} +.customers-field-item { + display: flex; + align-items: center; + gap: 12px; + min-height: 66px; + padding: 11px 12px 11px 4px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); + transition: border-color 0.16s ease, opacity 0.16s ease, transform 0.16s ease; +} +.customers-field-item:hover { + border-color: var(--border-strong); +} +.customers-field-item.is-disabled { + opacity: 0.58; +} +.customers-field-item.is-dragging { + opacity: 0.65; + transform: scale(0.995); +} +.customers-field-handle { + display: grid; + place-items: center; + align-self: stretch; + width: 34px; + border: 0; + background: transparent; + color: var(--text-muted); + cursor: grab; +} +.customers-field-handle:active { + cursor: grabbing; +} +.customers-field-icon { + --tile-bg: var(--blue-100); + --tile-fg: var(--blue-600); +} +.customers-field-copy { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.customers-field-copy b { + overflow: hidden; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} +.customers-field-copy > span { + color: var(--text-secondary); + font-size: 12.5px; +} +.customers-field-copy .customers-field-places { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 2px; +} +.customers-field-places .badge { + font-size: 10.5px; +} +.customers-field-toggle { + flex: 0 0 auto; +} +.customers-field-actions { + display: flex; + gap: 4px; + flex: 0 0 auto; +} +.customers-preview-form { + display: grid; + gap: 14px; + padding: 18px; +} +.customers-preview-form .field[hidden] { + display: none; +} +.customers-preview-form :disabled { + opacity: 1; + cursor: default; +} +.customer-field-drawer { + width: min(66.666vw, 1180px); + max-width: none; +} +.customer-field-drawer > form { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; +} +.customer-field-drawer .drawer-body { + flex: 1 1 auto; + background: var(--background-inset); +} +.customers-field-form { + display: grid; + gap: 14px; +} +.customers-form-section { + padding: 16px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); +} +.customers-form-section-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-default); +} +.customers-form-section-head > div { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 3px; + min-width: 0; +} +.customers-form-section-head > div > span { + color: var(--text-secondary); + font-size: 12.5px; +} +.customers-options { + display: grid; + gap: 8px; +} +.customers-option-row { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) 36px; + gap: 8px; + align-items: center; +} +.customers-option-row > i { + text-align: center; +} +.customers-switch-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.customers-switch-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + min-height: 72px; + padding: 12px 14px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + cursor: pointer; +} +.customers-switch-card > span:first-child { + display: flex; + flex-direction: column; + gap: 4px; +} +.customers-switch-card small { + color: var(--text-secondary); + line-height: 1.35; +} +.customers-auth-note { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; +} +.customers-auth-note .icon-tile { + flex: 0 0 auto; +} +.customers-auth-note b { + display: block; + margin-bottom: 3px; +} +.customers-auth-note p { + margin: 0; + color: var(--text-secondary); + line-height: 1.5; + text-wrap: pretty; +} +.customers-auth-form { + display: grid; + gap: 16px; +} +.customers-auth-section { + padding: 0; + overflow: hidden; +} +.customers-auth-section > .ax-list-head { + margin: 0; +} +.customers-checkout-body { + display: grid; + gap: 16px; + padding: 18px; +} +.customers-checkout-note { + display: flex; + align-items: flex-start; + gap: 9px; + margin: 0; +} +.customers-checkout-note p { + margin: 0; +} +.customers-checkout-template-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.customers-checkout-template-head > div { + display: flex; + flex-direction: column; + gap: 3px; +} +.customers-checkout-template-head small, +.customers-checkout-template-tags button span { + color: var(--text-secondary); +} +.customers-checkout-template-tags button { + align-items: flex-start; + text-align: left; +} +.customers-auth-settings-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + padding: 18px; +} +.customers-auth-fixed { + min-width: 0; +} +.customers-auth-fixed-value { + display: flex; + align-items: center; + gap: 10px; + min-height: 42px; + padding: 8px 11px; + border-radius: var(--radius-sm); + background: var(--background-inset); +} +.customers-auth-fixed-value > i { + color: var(--blue-600); + font-size: 20px; +} +.customers-auth-fixed-value > span { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.customers-auth-fixed-value small { + color: var(--text-secondary); + line-height: 1.3; +} +.customers-auth-fields { + padding: 0 18px 18px; +} +.customers-auth-fields-head, +.customers-auth-field-row { + display: grid; + grid-template-columns: minmax(240px, 1fr) 130px 130px; + align-items: center; + gap: 16px; +} +.customers-auth-fields-head { + min-height: 38px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} +.customers-auth-fields-head > span:not(:first-child) { + text-align: center; +} +.customers-auth-field-row { + min-height: 62px; + padding: 9px 12px; + border-radius: var(--radius-sm); +} +.customers-auth-field-row:nth-child(even) { + background: var(--background-inset); +} +.customers-auth-field-row > span:first-child { + display: flex; + align-items: center; + gap: 11px; + min-width: 0; +} +.customers-auth-field-row > span:first-child > i { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + background: var(--blue-100); + color: var(--blue-600); + font-size: 18px; +} +.customers-auth-field-row > span:first-child > span { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.customers-auth-field-row small { + color: var(--text-secondary); + line-height: 1.3; +} +.customers-auth-field-row > .switch, +.customers-auth-field-row > .badge { + justify-self: center; +} +.customers-auth-field-row.is-disabled > span:first-child { + opacity: 0.55; +} +.customers-auth-restrictions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + padding: 18px; +} +.customers-auth-reset { + min-height: 82px; +} +.customers-auth-actions { + display: flex; + justify-content: flex-end; + padding: 2px 0 8px; +} +.customers-auth-settings-grid [hidden] { + display: none; +} +.customers-social-head { + margin-top: 26px; +} +.customers-social-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} +.customers-social-card { + min-width: 0; + padding: 0; + overflow: hidden; +} +.customers-social-card > .ax-list-head { + margin: 0; +} +.customers-social-card .ax-list-title { + min-width: 0; +} +.customers-social-card .ax-list-title > div { + min-width: 0; +} +.customers-social-card .ax-list-title h3 { + margin: 0 0 3px; + font-size: 15px; +} +.customers-social-card .ax-list-title p { + margin: 0; + line-height: 1.4; + text-wrap: pretty; +} +.customers-social-mark { + display: grid; + place-items: center; + width: 40px; + height: 40px; + flex: 0 0 auto; + border-radius: var(--radius-sm); + color: #fff; + font-size: 15px; + font-weight: 800; +} +.customers-social-mark.is-yandex { + background: #fc3f1d; +} +.customers-social-mark.is-vk { + background: #07f; + font-size: 12px; +} +.customers-social-mark.is-smsc { + background: var(--cyan-600); + font-size: 10px; +} +.customers-social-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px; +} +.customers-social-checks { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 8px 16px; +} +.customers-social-checks span { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-muted); + font-size: 12px; +} +.customers-social-checks span.is-ready { + color: var(--green-700); +} +.customers-social-status > .btn { + flex: 0 0 auto; +} +.customers-social-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + padding: 16px; +} +.customers-social-fields small { + color: var(--text-muted); + font-weight: 500; +} +.customers-social-callback { + grid-column: 1 / -1; +} +.customers-social-callback .input-copy { + display: grid; + grid-template-columns: minmax(0, 1fr) 40px; + gap: 6px; +} +.customers-social-rules { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 0 16px 16px; +} +.customers-social-rules .customers-switch-card { + min-height: 78px; +} +.customers-pages-form { + display: grid; + gap: 16px; +} +.customers-pages-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} +.customers-page-card { + min-width: 0; + padding: 0; + overflow: hidden; +} +.customers-page-card-head { + display: flex; + align-items: center; + gap: 12px; + min-height: 76px; + padding: 14px 16px; + background: transparent; +} +.customers-page-card-head .icon-tile { + flex: 0 0 auto; +} +.customers-page-card-head > div { + min-width: 0; +} +.customers-page-card-copy { + flex: 1 1 auto; +} +.customers-page-card-head > .btn { + flex: 0 0 auto; +} +.customers-page-card-head a, +.customers-page-card-head a:hover { + text-decoration: none; +} +.customers-page-card-head h3 { + margin: 0 0 3px; + font-size: 15px; + text-wrap: balance; +} +.customers-page-card-head span { + color: var(--text-secondary); + font-size: 12.5px; + line-height: 1.35; + text-wrap: pretty; +} +.customers-page-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + padding: 16px; +} +.customers-page-fields .field:nth-child(3) { + grid-column: auto; +} +.customers-page-card-actions { + display: flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; + margin-left: auto; +} +.customers-page-card-actions a, +.customers-page-card-actions a:hover { + text-decoration: none; +} +.customers-page-fields .field:nth-child(5) { + grid-column: 1 / -1; +} +.customers-page-fields textarea { + resize: vertical; +} +.customers-service-routes { + margin-top: 0; +} +.customers-service-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} +.customer-auth-form-drawer { + width: min(66.666vw, 1280px); + max-width: none; +} +.customer-auth-form-drawer > form { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; +} +.customers-auth-template-body { + flex: 1 1 auto; + padding: 0; + overflow: hidden; + background: var(--background-inset); +} +.customers-auth-template-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + height: 100%; + min-height: 0; +} +.customers-auth-template-editor { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + padding: 16px; +} +.customers-auth-template-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +.customers-auth-template-toolbar > div { + display: flex; + align-items: center; + gap: 6px; +} +.customers-auth-template-code { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} +.customers-auth-template-code .CodeMirror { + flex: 1 1 auto; + height: auto; + min-height: 0; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + font-size: 13px; +} +.customers-auth-template-code .field-error { + display: block; + min-height: 18px; + padding-top: 4px; + color: var(--red-600); + font-size: 12px; +} +.customers-auth-template-help { + min-width: 0; + overflow-y: auto; + padding: 16px; + border-left: 1px solid var(--border-default); + background: var(--background-surface); +} +.customers-auth-template-help > div:first-child { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; + padding-bottom: 14px; + border-bottom: 1px solid var(--border-default); +} +.customers-auth-template-help > div:first-child > div { + display: flex; + flex-direction: column; + gap: 3px; +} +.customers-auth-template-help > div:first-child span { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.35; +} +.customers-auth-template-tags { + display: grid; + gap: 7px; +} +.customers-auth-template-tags button { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; + padding: 9px 10px; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: var(--background-surface); + color: var(--text-primary); + text-align: left; + cursor: pointer; + transition: border-color 0.16s ease, background-color 0.16s ease; +} +.customers-auth-template-tags button:hover { + border-color: var(--blue-400); + background: var(--blue-50); +} +.customers-auth-template-tags code { + overflow: hidden; + color: var(--blue-700); + font-size: 11.5px; + text-overflow: ellipsis; + white-space: nowrap; +} +.customers-auth-template-tags span { + color: var(--text-secondary); + font-size: 11.5px; +} +.customers-auth-template-note { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 14px 0 0; +} +.customers-auth-template-note i { + flex: 0 0 auto; + margin-top: 2px; +} +.customers-auth-template-note p { + margin: 0; + font-size: 12px; + line-height: 1.45; +} +@media (max-width: 980px) { + .customers-field-builder, + .customers-pages-grid, + .customers-social-grid { + grid-template-columns: 1fr; + } + .customers-profile-preview { + position: static; + } + .customers-auth-settings-grid { + grid-template-columns: 1fr 1fr; + } +} +@media (max-width: 980px) { + .customers-auth-template-layout { + grid-template-columns: 1fr; + overflow-y: auto; + } + .customers-auth-template-help { + border-top: 1px solid var(--border-default); + border-left: 0; + } +} +@media (max-width: 760px) { + .customers-filter { + align-items: stretch; + flex-direction: column; + } + .customers-filter .input-wrap { + min-width: 0; + } + .customer-editor-drawer, + .customer-field-drawer, + .customer-auth-form-drawer { + width: 100%; + max-width: 100%; + } + .customers-editor-meta { + grid-template-columns: 1fr; + } + .customers-switch-grid, + .customers-auth-settings-grid, + .customers-auth-restrictions, + .customers-page-fields, + .customers-social-fields, + .customers-social-rules { + grid-template-columns: 1fr; + } + .customers-editor-access .customers-switch-card:last-child { + grid-column: auto; + } + .customers-social-callback { + grid-column: auto; + } + .customers-social-status { + align-items: stretch; + flex-direction: column; + } + .customers-social-status > .btn { + align-self: flex-end; + } + .customers-page-fields .field:nth-child(3), + .customers-page-fields .field:nth-child(5) { + grid-column: auto; + } + .customers-field-icon { + display: none; + } + .customers-auth-fields { + overflow-x: auto; + } + .customers-auth-fields-head, + .customers-auth-field-row { + min-width: 560px; + } + .customers-service-actions { + align-items: stretch; + flex-direction: column; + } +} diff --git a/adminx/modules/Customers/assets/customers.js b/adminx/modules/Customers/assets/customers.js new file mode 100644 index 0000000..23cbdce --- /dev/null +++ b/adminx/modules/Customers/assets/customers.js @@ -0,0 +1,444 @@ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.Customers = { + draggedField: null, + authTemplateKey: '', + + init: function () { + var self = this; + + document.addEventListener('change', function (event) { + var customer = event.target.closest('[data-customer-toggle]'); + if (customer) { + self.request(customer.getAttribute('data-url'), new FormData()).catch(function () { customer.checked = !customer.checked; }); + return; + } + var fieldToggle = event.target.closest('[data-field-toggle]'); + if (fieldToggle) { self.toggleField(fieldToggle); return; } + if (event.target.matches('[data-customer-field] [name="type"]')) { self.updateOptionsVisibility(); } + if (event.target.matches('[data-auth-show]')) { self.syncAuthFieldRows(); } + if (event.target.matches('[data-registration-mode]')) { self.syncAuthMode(); } + if (event.target.matches('[data-customer-admin-access]')) { self.syncAdminAccess(); } + }); + + document.addEventListener('input', function (event) { + if (event.target.closest('[data-field-options]')) { self.syncOptions(); } + }); + + document.addEventListener('click', function (event) { + var customerEdit = event.target.closest('[data-customer-edit]'); + if (customerEdit) { event.preventDefault(); self.openCustomer(customerEdit); return; } + if (event.target.closest('[data-field-new]')) { self.fieldForm(null); } + var edit = event.target.closest('[data-field-edit]'); + if (edit) { self.fieldForm(JSON.parse(edit.closest('[data-field]').getAttribute('data-field'))); } + var remove = event.target.closest('[data-field-delete]'); + if (remove) { event.preventDefault(); self.deleteField(remove); } + if (event.target.closest('[data-field-option-add]')) { self.addOption(''); } + var optionRemove = event.target.closest('[data-field-option-remove]'); + if (optionRemove) { optionRemove.closest('[data-field-option]').remove(); self.syncOptions(); } + var formEdit = event.target.closest('[data-auth-form-edit]'); + if (formEdit) { event.preventDefault(); self.openAuthTemplate(formEdit.getAttribute('data-auth-form-edit')); } + if (event.target.closest('[data-auth-form-reset]')) { self.resetAuthTemplate(); } + var templateTag = event.target.closest('[data-auth-template-tag]'); + if (templateTag) { self.insertAuthTemplateTag(templateTag.getAttribute('data-auth-template-tag')); } + if (event.target.closest('[data-auth-template-fullscreen]')) { self.toggleAuthTemplateFullscreen(); } + var checkoutDefault = event.target.closest('[data-checkout-template-default]'); + if (checkoutDefault) { self.setCheckoutTemplate(checkoutDefault.getAttribute('data-template') || ''); } + }); + + var form = document.querySelector('[data-customer-field]'); + if (form) { + form.addEventListener('submit', function (event) { + event.preventDefault(); + self.syncOptions(); + var id = form.dataset.id || '0'; + self.request(form.dataset.base + '/system/customers/fields/' + id, new FormData(form)).then(function () { window.location.reload(); }); + }); + } + var customerForm = document.querySelector('[data-customer-editor]'); + if (customerForm) { + customerForm.addEventListener('submit', function (event) { + event.preventDefault(); + var id = customerForm.dataset.id || '0'; + self.request(customerForm.dataset.base + '/system/customers/users/' + id, new FormData(customerForm)).then(function (response) { + if (response.data && response.data.user) { self.updateCustomerRow(response.data.user); } + if (Adminx.Drawer) { Adminx.Drawer.close('customerEditorDrawer'); } + }); + }); + } + var authForm = document.querySelector('[data-customer-auth]'); + if (authForm) { + authForm.addEventListener('submit', function (event) { + event.preventDefault(); + var checkoutTemplate = authForm.elements.checkout_access_template; + if (checkoutTemplate && checkoutTemplate._adminxCodeMirror) { checkoutTemplate._adminxCodeMirror.save(); } + self.request(authForm.dataset.base + '/system/customers/auth', new FormData(authForm)).then(function () { window.location.reload(); }); + }); + } + var pagesForm = document.querySelector('[data-customer-pages]'); + if (pagesForm) { + pagesForm.addEventListener('submit', function (event) { + event.preventDefault(); + self.request(pagesForm.dataset.base + '/system/customers/pages', new FormData(pagesForm)).then(function () { window.location.reload(); }); + }); + } + var authTemplateForm = document.querySelector('[data-auth-form-template]'); + if (authTemplateForm) { + authTemplateForm.addEventListener('submit', function (event) { + event.preventDefault(); + self.saveAuthTemplate(); + }); + } + this.syncAuthFieldRows(); + this.syncAuthMode(); + this.initSortable(); + }, + + request: function (url, body) { + Adminx.Loader.show(); + return Adminx.Ajax.request(url, { method: 'POST', body: body }).then(function (payload) { + Adminx.Loader.hide(); + if (!payload.ok || !payload.data.success) { throw new Error(payload.data.message || 'Не удалось выполнить действие'); } + Adminx.Toast.show(payload.data.message || 'Сохранено', 'success'); + return payload.data; + }).catch(function (error) { + Adminx.Loader.hide(); + Adminx.Toast.show(error.message, 'error'); + throw error; + }); + }, + + openCustomer: function (button) { + var form = document.querySelector('[data-customer-editor]'); + var self = this; + if (!form) { return; } + Adminx.Loader.show(); + Adminx.Ajax.request(button.getAttribute('data-url'), { method: 'GET' }).then(function (payload) { + Adminx.Loader.hide(); + var response = payload.data || {}; + if (!payload.ok || !response.success || !response.data || !response.data.user) { throw new Error(response.message || 'Не удалось загрузить профиль'); } + self.fillCustomerForm(response.data); + if (Adminx.Drawer) { Adminx.Drawer.open('customerEditorDrawer'); } + }).catch(function (error) { + Adminx.Loader.hide(); + Adminx.Toast.show(error.message || 'Не удалось загрузить профиль', 'error'); + }); + }, + + fillCustomerForm: function (data) { + var form = document.querySelector('[data-customer-editor]'); + var user = data.user || {}; + var extra = data.extra || {}; + if (!form) { return; } + form.reset(); + form.dataset.id = user.id || '0'; + ['firstname', 'lastname', 'email', 'user_name', 'phone', 'company', 'birthday', 'description', 'city', 'street', 'street_nr', 'zipcode', 'user_group'].forEach(function (name) { + if (form.elements[name]) { form.elements[name].value = user[name] == null ? '' : user[name]; } + }); + if (!form.elements.user_name.value) { form.elements.user_name.value = user.email || user.phone || ''; } + form.elements.password.value = ''; + form.elements.status.checked = String(user.status) === '1'; + form.elements.email_verified.checked = Number(user.email_verified_at) > 0; + form.elements.phone_verified.checked = Number(user.phone_verified_at) > 0; + form.elements.admin_access.checked = !!(data.system && Number(data.system.is_active) === 1); + form.elements.admin_role.value = data.system && data.system.role ? data.system.role : 'manager'; + this.syncAdminAccess(); + Object.keys(extra).forEach(function (id) { + var input = form.elements['extra[' + id + ']']; + if (!input) { return; } + if (input.type === 'checkbox') { input.checked = String(extra[id]) === '1'; } + else { input.value = extra[id] == null ? '' : extra[id]; } + }); + document.querySelector('[data-customer-editor-title]').textContent = (user.firstname || user.lastname) ? [user.firstname, user.lastname].filter(Boolean).join(' ') : (user.user_name || 'Пользователь сайта'); + document.querySelector('[data-customer-meta-id]').textContent = '#' + user.id; + document.querySelector('[data-customer-meta-created]').textContent = this.formatTimestamp(user.reg_time); + document.querySelector('[data-customer-meta-visit]').textContent = this.formatTimestamp(user.last_visit); + }, + + updateCustomerRow: function (user) { + var row = document.querySelector('[data-customer-row][data-id="' + Number(user.id) + '"]'); + var fullName = [user.firstname, user.lastname].filter(Boolean).join(' ') || user.user_name; + var name; + var contacts; + if (!row) { return; } + name = row.querySelector('[data-customer-name]'); + contacts = row.querySelector('[data-customer-contacts]'); + if (name) { name.innerHTML = '' + this.escape(fullName) + '' + this.escape(user.user_name) + ''; } + if (contacts) { + contacts.innerHTML = '' + this.escape(user.email || user.phone) + '' + + (user.email && user.phone ? '' + this.escape(user.phone) + '' : ''); + } + if (row.querySelector('[data-customer-company]')) { row.querySelector('[data-customer-company]').textContent = user.company || '—'; } + if (row.querySelector('[data-customer-toggle]')) { row.querySelector('[data-customer-toggle]').checked = String(user.status) === '1'; } + }, + + syncAdminAccess: function () { + var form = document.querySelector('[data-customer-editor]'); + var field = form ? form.querySelector('[data-customer-admin-role]') : null; + var toggle = form && form.elements.admin_access ? form.elements.admin_access : null; + if (!field || !toggle) { return; } + field.hidden = !toggle.checked; + if (form.elements.admin_role) { form.elements.admin_role.disabled = !toggle.checked; } + }, + + formatTimestamp: function (value) { + var timestamp = Number(value) || 0; + if (timestamp < 946684800) { return '—'; } + return new Date(timestamp * 1000).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + }, + + escape: function (value) { + return String(value == null ? '' : value).replace(/[&<>"']/g, function (character) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character]; + }); + }, + + deleteField: function (button) { + var self = this; + Adminx.Confirm.open({ + kind: 'error', title: 'Удалить поле?', + message: 'Значения этого поля у пользователей сайта также будут удалены.', + confirmLabel: 'Удалить', confirmClass: 'btn-danger', + onConfirm: function () { self.request(button.getAttribute('data-url'), new FormData()).then(function () { window.location.reload(); }); } + }); + }, + + toggleField: function (input) { + var self = this; + var item = input.closest('[data-field-id]'); + this.request(input.getAttribute('data-url'), new FormData()).then(function (response) { + var active = !!Number(response.data && response.data.is_active); + input.checked = active; + item.classList.toggle('is-disabled', !active); + var preview = document.querySelector('[data-preview-field="' + item.getAttribute('data-field-id') + '"]'); + var field = JSON.parse(item.getAttribute('data-field')); + if (preview) { preview.hidden = !active || !Number(field.show_profile); } + }).catch(function () { input.checked = !input.checked; }); + }, + + fieldForm: function (data) { + var form = document.querySelector('[data-customer-field]'); + if (!form) { return; } + form.reset(); + form.dataset.id = data ? data.id : '0'; + document.querySelector('[data-field-title]').textContent = data ? 'Редактирование поля' : 'Новое поле'; + ['name', 'code', 'type', 'position'].forEach(function (key) { + if (data && form.elements[key]) { form.elements[key].value = data[key] || ''; } + }); + form.elements.is_required.checked = !!(data && Number(data.is_required)); + form.elements.is_active.checked = !data || !!Number(data.is_active); + form.elements.show_registration.checked = !!(data && Number(data.show_registration)); + form.elements.show_profile.checked = !data || !!Number(data.show_profile); + this.renderOptions(data ? data.options : ''); + this.updateOptionsVisibility(); + }, + + renderOptions: function (value) { + var list = document.querySelector('[data-field-options]'); + if (!list) { return; } + list.innerHTML = ''; + var values = String(value || '').split(/\r?\n/).filter(function (item) { return item.trim() !== ''; }); + if (values.length === 0) { values.push(''); } + var self = this; + values.forEach(function (item) { self.addOption(item); }); + this.syncOptions(); + }, + + addOption: function (value) { + var list = document.querySelector('[data-field-options]'); + if (!list) { return; } + var row = document.createElement('div'); + row.className = 'customers-option-row'; + row.setAttribute('data-field-option', ''); + row.innerHTML = ''; + row.querySelector('input').value = value || ''; + list.appendChild(row); + }, + + syncOptions: function () { + var hidden = document.querySelector('[data-customer-field] [name="options"]'); + if (!hidden) { return; } + var values = []; + document.querySelectorAll('[data-field-options] input').forEach(function (input) { + if (input.value.trim() !== '') { values.push(input.value.trim()); } + }); + hidden.value = values.join('\n'); + }, + + updateOptionsVisibility: function () { + var type = document.querySelector('[data-customer-field] [name="type"]'); + var section = document.querySelector('[data-field-options-section]'); + if (type && section) { section.hidden = type.value !== 'select'; } + }, + + syncAuthFieldRows: function () { + document.querySelectorAll('[data-auth-field-row]').forEach(function (row) { + var show = row.querySelector('[data-auth-show]'); + var required = row.querySelector('[data-auth-required]'); + if (!show || !required) { return; } + if (!show.checked) { required.checked = false; } + required.disabled = !show.checked || show.disabled; + row.classList.toggle('is-disabled', !show.checked); + }); + }, + + syncAuthMode: function () { + var mode = document.querySelector('[data-registration-mode]'); + var emailOnly = document.querySelector('[data-auth-email-only]'); + if (!mode || !emailOnly) { return; } + emailOnly.hidden = mode.value !== 'email'; + }, + + setCheckoutTemplate: function (value) { + var form = document.querySelector('[data-customer-auth]'); + var textarea = form ? form.elements.checkout_access_template : null; + if (!textarea) { return; } + textarea.value = value; + if (textarea._adminxCodeMirror) { textarea._adminxCodeMirror.setValue(value); textarea._adminxCodeMirror.focus(); } + }, + + authTemplateForm: function () { + return document.querySelector('[data-auth-form-template]'); + }, + + authTemplateEditor: function () { + var form = this.authTemplateForm(); + var textarea = form ? form.elements.template : null; + return textarea && textarea._adminxCodeMirror ? textarea._adminxCodeMirror : null; + }, + + setAuthTemplate: function (value) { + var form = this.authTemplateForm(); + if (!form) { return; } + form.elements.template.value = value || ''; + var editor = this.authTemplateEditor(); + if (editor) { editor.setValue(value || ''); editor.clearHistory(); } + }, + + setAuthTemplateState: function (customized) { + var state = document.querySelector('[data-auth-form-state]'); + if (!state) { return; } + state.textContent = customized ? 'Изменён' : 'Штатный шаблон'; + state.className = 'badge ' + (customized ? 'badge-info' : 'badge-neutral'); + }, + + openAuthTemplate: function (key) { + var form = this.authTemplateForm(); + if (!form || !key) { return; } + var self = this; + this.authTemplateKey = key; + form.elements.key.value = key; + var error = form.querySelector('[data-auth-form-error]'); + if (error) { error.textContent = ''; } + Adminx.Loader.show(); + Adminx.Ajax.request(form.dataset.base + '/system/customers/forms/' + encodeURIComponent(key)).then(function (payload) { + Adminx.Loader.hide(); + var response = payload.data || {}; + if (!response.success || !response.data) { throw new Error(response.message || 'Не удалось загрузить форму'); } + document.querySelector('[data-auth-form-title]').textContent = response.data.label || 'Шаблон формы'; + document.querySelector('[data-auth-form-description]').textContent = response.data.description || 'Публичная Twig-разметка'; + self.setAuthTemplate(response.data.template || ''); + self.setAuthTemplateState(!!Number(response.data.customized)); + Adminx.Drawer.open('customerAuthFormDrawer'); + window.setTimeout(function () { + var editor = self.authTemplateEditor(); + if (editor) { + editor.getWrapperElement().style.height = ''; + editor.refresh(); + editor.focus(); + } + }, 80); + }).catch(function (error) { + Adminx.Loader.hide(); + Adminx.Toast.show(error.message || 'Не удалось загрузить форму', 'error'); + }); + }, + + saveAuthTemplate: function () { + var form = this.authTemplateForm(); + if (!form || !this.authTemplateKey) { return; } + var editor = this.authTemplateEditor(); + if (editor) { editor.save(); } + var self = this; + var error = form.querySelector('[data-auth-form-error]'); + if (error) { error.textContent = ''; } + this.request(form.dataset.base + '/system/customers/forms/' + encodeURIComponent(this.authTemplateKey), new FormData(form)).then(function () { + self.setAuthTemplateState(true); + }).catch(function (failure) { + if (error) { error.textContent = failure.message || 'Проверьте Twig-синтаксис'; } + }); + }, + + resetAuthTemplate: function () { + var form = this.authTemplateForm(); + if (!form || !this.authTemplateKey) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Восстановить штатную форму?', + message: 'Текущая разметка будет заменена версией из системы.', + confirmLabel: 'Восстановить', + onConfirm: function () { + self.request(form.dataset.base + '/system/customers/forms/' + encodeURIComponent(self.authTemplateKey) + '/reset', new FormData()).then(function (response) { + self.setAuthTemplate(response.data && response.data.template ? response.data.template : ''); + self.setAuthTemplateState(false); + }); + } + }); + }, + + insertAuthTemplateTag: function (value) { + if (!value) { return; } + var editor = this.authTemplateEditor(); + if (editor) { editor.replaceSelection(value, 'around'); editor.focus(); editor.save(); return; } + var form = this.authTemplateForm(); + var textarea = form ? form.elements.template : null; + if (!textarea) { return; } + var start = textarea.selectionStart || 0; + textarea.value = textarea.value.slice(0, start) + value + textarea.value.slice(textarea.selectionEnd || start); + textarea.selectionStart = textarea.selectionEnd = start + value.length; + textarea.focus(); + }, + + toggleAuthTemplateFullscreen: function () { + var editor = this.authTemplateEditor(); + if (editor && Adminx.CodeEditor) { Adminx.CodeEditor.toggleFullscreen(editor); } + }, + + initSortable: function () { + var list = document.querySelector('[data-fields-sortable]'); + if (!list) { return; } + var self = this; + list.addEventListener('dragstart', function (event) { + var item = event.target.closest('[data-field-id]'); + if (!item || !event.target.closest('.customers-field-handle')) { event.preventDefault(); return; } + self.draggedField = item; + item.classList.add('is-dragging'); + event.dataTransfer.effectAllowed = 'move'; + }); + list.addEventListener('dragover', function (event) { + var target = event.target.closest('[data-field-id]'); + if (!self.draggedField || !target || target === self.draggedField) { return; } + event.preventDefault(); + var rect = target.getBoundingClientRect(); + list.insertBefore(self.draggedField, event.clientY < rect.top + rect.height / 2 ? target : target.nextSibling); + }); + list.addEventListener('dragend', function () { + if (!self.draggedField) { return; } + self.draggedField.classList.remove('is-dragging'); + self.draggedField = null; + var order = []; + list.querySelectorAll('[data-field-id]').forEach(function (item) { order.push(Number(item.getAttribute('data-field-id'))); }); + var data = new FormData(); + data.append('order', JSON.stringify(order)); + self.request(list.getAttribute('data-reorder-url'), data).catch(function () { window.location.reload(); }); + }); + } + }; + + if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { Adminx.Customers.init(); }); } + else { Adminx.Customers.init(); } +})(window, document); diff --git a/adminx/modules/Customers/language/en/client.xml b/adminx/modules/Customers/language/en/client.xml new file mode 100644 index 0000000..1693af2 --- /dev/null +++ b/adminx/modules/Customers/language/en/client.xml @@ -0,0 +1,31 @@ + + + Failed to load profile + New field + User + Site user + : '&#039;' }[character]; + }); + }, + + deleteField: function (button) { + var self = this; + Adminx.Confirm.open({ + kind: 'error', title: 'Delete field?', + message: 'Values of this field for site users will also be deleted.', + confirmLabel: 'Delete', confirmClass: 'btn-danger', + onConfirm: function () { self.request(button.getAttribute('data-url'), new FormData()).then(function () { window.location.reload(); }); } + }); + }, + + toggleField: function (input) { + var self = this; + var item = input.closest('[data-field-id]'); + this.request(input.getAttribute('data-url'), new FormData()).then(function (response) { + var active = !!Number(response.data && response.data.is_active); + input.checked = active; + item.classList.toggle('is-disabled', !active); + var preview = document.querySelector('[data-preview-field= + Action failed + Saved + diff --git a/adminx/modules/Customers/language/en/interface.xml b/adminx/modules/Customers/language/en/interface.xml new file mode 100644 index 0000000..806541c --- /dev/null +++ b/adminx/modules/Customers/language/en/interface.xml @@ -0,0 +1,297 @@ + + + Email and login + Order amount + One-time password setup link + Link lifetime in hours + CSRF token + Email confirmed + HTML and Twig. Click a tag to insert it at the cursor position. + OAuth and one-time code login use the same site account. + Twig page template + Login URL + Policy URL + · mandatory + Authorization + The administrator can change the status manually. + Address + Account enabled + Account after order + Site accounts + Account activation + Active + Active gates Yandex/VK + Active fields from the public profile constructor. + Basic account details. Additional fields are included in the adjacent tab. + Base URL + Favorites · review + Option + List options + Return standard template + Restore normal + Password recovery + Total orders · overview + You choose the group that will be assigned to new site users. + Select value + off + Exit + City + Group after registration + Public User Group + Yes/no + Data for profile and pre-filling orders. + Date of birth + For corporate profiles + For approval mode, enable the user in the first tab. + Add a field + Add the first profile field. + Add fields to see the form + House + Add. fields + Additional fields + Additional login methods are set by modules + There are no additional fields yet + Complements display name + Access + Access to the control panel + Available data + Unified rules for creating a user account, activating it and restoring access. + Page title + Prohibited addresses + Banned domains + The value will be required in all selected forms. + The change requires the Manage System Users right. + Change activity + Change order + Name + Username + Name, email, phone + Name, contacts and details of the organization. + Index + Used in APIs and templates. + Each page has an editable Twig template and is rendered via `[tag:maincontent]` within the selected site template. + Each option is displayed on a separate line. + Registration channel + Keys are configured + Button + Company + Additional fields constructor + Contact for orders and delivery + Contacts + Who can register and when entry will be available. + Login + Login and confirmation channel + Minimum password length + Multiline text + Module enabled + Can log into the site and personal account. + Can be used for login just like email. + Click to paste the expression into the editor. + Name, system code and input method. + Find + Customize + Setting up a public profile field + Not posted + New field + New password + About the user + Mandatory + Required field + Registration restrictions + One email or domain per line. The check is not case sensitive. + One account for the site and panel + Single line text + Paid · review + Primary username + Basic parameters + Leave blank so as not to change + Disabled + A disabled field is hidden in all forms. + Open modules + Open preview + Form error + Password and confirmation + The password will only change if you fill in the field. + The password is not sent by email. After ordering, the user immediately logs into his account and receives a one-time link to set a password himself. + Switch + Drag the fields by the handle to change the order. + Letter with access + Prepared for future SMS gate. + connected + Connected login methods + Button signature + Confirmation email + Confirmation email, exit and general system results template. + Show + Show on profile + Show field + Show during registration + Show the “Forgot your password” form and send a link by email. + The buyer can create the same personal account directly during checkout. + Field enabled + No users found + User + Site user + Profile fields + Registration form fields + The order and state are preserved without rebooting; on the right is the final user form. + Visitors registered on the site, their contacts and access to their personal account. + After administrator approval + After confirmation email + After changing the URL, the system `/login`, `/register`, `/remember`, `/password/reset` and `/personal` will remain compatible aliases. New links in the interface and emails will immediately start using the specified addresses. + Latest orders · review + Last entry + Role rights are configured in the “Roles and Rights” section. + Rules for filling and display areas of the field. + Offer registration in cart + Form preview + When you change your password, remember sessions and recovery links will be revoked. + Profile, access and personal account data + Public Twig markup + The public group defines the features on the site. The same user can be allowed to work in the control panel; separate registration and a second login will not be required. + Public group, account status and role in the control panel. + Public accounts + Public accounts, profiles and login methods + Public URL + Public account + Public site + Markup and CSS classes are edited here. Routes, data validation, CSRF, and user persistence remain in the system handler. + allowed + permits + Allow filling and modification in your personal account. + Allow registration + Registration + Registration and personal account + Edit form + Role in Control Panel + Hidden + Service routes + Collect the value during account creation. + Account creation + Messages + Save pages + Save template + List of values + Immediately after registration + Expiration date for one-time links and password requirements. + Recovery link, hours + Confirmation link, hours + Old addresses will continue to work + Login pages + Personal account pages + Profile structure + The same account will be able to work in the administrative interface. + This way the user will see the active fields. + Consent text + Phone and SMS are connected via a separate gate. + Phone confirmed + Subject of the email after ordering + Field type + Only the internal site address. + street + Last name + Profile form + Number + Website template + Page template + Standard template + Yandex, VK ID and SMSC are configured separately in the “Modules” section. Removing a package physically removes its code and settings form. + : '&#039;' }[character]; + }); + }, + + deleteField: function (button) { + var self = this; + Adminx.Confirm.open({ + kind: 'error', title: 'Delete field?', + message: 'Values of this field for site users will also be deleted.', + confirmLabel: 'Delete', confirmClass: 'btn-danger', + onConfirm: function () { self.request(button.getAttribute('data-url'), new FormData()).then(function () { window.location.reload(); }); } + }); + }, + + toggleField: function (input) { + var self = this; + var item = input.closest('[data-field-id]'); + this.request(input.getAttribute('data-url'), new FormData()).then(function (response) { + var active = !!Number(response.data && response.data.is_active); + input.checked = active; + item.classList.toggle('is-disabled', !active); + var preview = document.querySelector('[data-preview-field= + » must contain email + » must contain a number + » from the list + Basic and additional registration fields. + The standard form template has been restored + Login + Input active + Input disabled + Login and registration using one-time code SMSC. + Login and registration via Yandex account. + Login and registration through a single VK account. + Select an active public user group + Select the field value " + Select a role to access the control panel + Select an existing site template for each form. + Select an existing active public group + Drop-down authorization and logged-in user menu. + Date + To access the control panel, enter your email + Fill in the field " + Request for recovery + Name, email or login exceeds the allowed length + Code: Latin, numbers and underscore + Contact and additional user fields. + Module disabled + Registration settings saved + Page settings not transferred + Action failed + Failed to load profile + Failed to save user + Insufficient rights + Unknown form + Incorrect field order + New password: minimum + Need keys + Cabinet overview + The main login page for your personal account. + Twig error in email after ordering: + Field " + A field with this code already exists + Field hidden + Field saved + Field removed + Site users + Site users: view + Site users: management + User enabled + User not found + User disabled + The order of the fields is preserved + Profile + User profile saved + Result of registration, confirmation and recovery. + Summary of orders and quick user transitions. + characters + System + System message + Change password + Saved + Login pages saved + Phone + Enter your email or phone number + Please enter the correct date of birth + Please enter a correct email + Please enter a correct phone number + Enter the field name + Form in the cap + Form for authorized user. + Form from a one-time link. + Form for sending a link by email. + Form template saved + This email is already in use + This login is already in use + This phone is already in use + I + Yandex + diff --git a/adminx/modules/Customers/language/en/runtime.xml b/adminx/modules/Customers/language/en/runtime.xml new file mode 100644 index 0000000..1e519bb --- /dev/null +++ b/adminx/modules/Customers/language/en/runtime.xml @@ -0,0 +1,84 @@ + + + » must contain email + » must contain a number + » from the list + Basic and additional registration fields. + The standard form template has been restored + Login + Input active + Input disabled + Login and registration using one-time code SMSC. + Login and registration via Yandex account. + Login and registration through a single VK account. + Select an active public user group + Select the field value " + Select a role to access the control panel + Select an existing site template for each form. + Select an existing active public group + Drop-down authorization and logged-in user menu. + Date + To access the control panel, enter your email + Fill in the field " + Request for recovery + Name, email or login exceeds the allowed length + Code: Latin, numbers and underscore + Contact and additional user fields. + Multiline text + Module disabled + Registration settings saved + Page settings not transferred + Failed to save user + Insufficient rights + Unknown form + Incorrect field order + New password + New password: minimum + Need keys + Cabinet overview + Single line text + The main login page for your personal account. + Twig error in email after ordering: + Switch + Field " + Field enabled + A field with this code already exists + Field hidden + Field saved + Field removed + Site users + Site users: view + Site users: management + User enabled + User not found + User disabled + The order of the fields is preserved + Profile + User profile saved + Registration + Result of registration, confirmation and recovery. + Summary of orders and quick user transitions. + System + System message + Change password + List of values + Login pages saved + Phone + Enter your email or phone number + Please enter the correct date of birth + Please enter a correct email + Please enter a correct phone number + Enter the field name + Form in the cap + Form for authorized user. + Form from a one-time link. + Form for sending a link by email. + Number + Form template saved + This email is already in use + This login is already in use + This phone is already in use + I + Yandex + characters + diff --git a/adminx/modules/Customers/language/ru/client.xml b/adminx/modules/Customers/language/ru/client.xml new file mode 100644 index 0000000..507d2d0 --- /dev/null +++ b/adminx/modules/Customers/language/ru/client.xml @@ -0,0 +1,31 @@ + + + Не удалось загрузить профиль + Новое поле + Пользователь + Пользователь сайта + : '&#039;' }[character]; + }); + }, + + deleteField: function (button) { + var self = this; + Adminx.Confirm.open({ + kind: 'error', title: 'Удалить поле?', + message: 'Значения этого поля у пользователей сайта также будут удалены.', + confirmLabel: 'Удалить', confirmClass: 'btn-danger', + onConfirm: function () { self.request(button.getAttribute('data-url'), new FormData()).then(function () { window.location.reload(); }); } + }); + }, + + toggleField: function (input) { + var self = this; + var item = input.closest('[data-field-id]'); + this.request(input.getAttribute('data-url'), new FormData()).then(function (response) { + var active = !!Number(response.data && response.data.is_active); + input.checked = active; + item.classList.toggle('is-disabled', !active); + var preview = document.querySelector('[data-preview-field= + Не удалось выполнить действие + Сохранено + diff --git a/adminx/modules/Customers/language/ru/interface.xml b/adminx/modules/Customers/language/ru/interface.xml new file mode 100644 index 0000000..beaeb37 --- /dev/null +++ b/adminx/modules/Customers/language/ru/interface.xml @@ -0,0 +1,297 @@ + + + Email и логин + Сумма заказа + Одноразовая ссылка задания пароля + Срок ссылки в часах + CSRF-токен + Email подтверждён + HTML и Twig. Нажмите тег, чтобы вставить его в позицию курсора. + OAuth и вход по одноразовому коду используют одну учётную запись сайта. + Twig-шаблон страницы + URL входа + URL политики + · обязательное + Авторизация + Администратор может изменить статус вручную. + Адрес + Аккаунт включён + Аккаунт после заказа + Аккаунты сайта + Активация аккаунта + Активен + Активные гейты Яндекс/VK + Активные поля из конструктора публичного профиля. + Базовые данные аккаунта. Дополнительные поля подключаются в соседнем табе. + Базовый URL + В избранном · обзор + Вариант + Варианты списка + Вернуть штатный шаблон + Восстановить штатный + Восстановление пароля + Всего заказов · обзор + Вы сами выбираете группу, которая будет назначаться новым пользователям сайта. + Выберите значение + выключена + Выход + Город + Группа после регистрации + Группа публичных пользователей + Да / нет + Данные для профиля и предзаполнения заказов. + Дата рождения + Для корпоративных профилей + Для режима одобрения включите пользователя в первом табе. + Добавить поле + Добавьте первое поле профиля. + Добавьте поля, чтобы увидеть форму + Дом + Доп. полей + Дополнительные поля + Дополнительные способы входа устанавливаются модулями + Дополнительных полей пока нет + Дополняет отображаемое имя + Доступ + Доступ в панель управления + Доступные данные + Единые правила создания аккаунта пользователя, его активации и восстановления доступа. + Заголовок страницы + Запрещённые адреса + Запрещённые домены + Значение потребуется во всех выбранных формах. + Изменение требует права управления системными пользователями. + Изменить активность + Изменить порядок + Имя + Имя пользователя + Имя, email, телефон + Имя, контакты и данные организации. + Индекс + Используется в API и шаблонах. + Каждая страница имеет редактируемый Twig-шаблон и выводится через `[tag:maincontent]` внутри выбранного шаблона сайта. + Каждый вариант отображается отдельной строкой. + Канал регистрации + Ключи настроены + Кнопка + Компания + Конструктор дополнительных полей + Контакт для заказов и доставки + Контакты + Кто может зарегистрироваться и когда станет доступен вход. + Логин + Логин и канал подтверждения + Минимальная длина пароля + Многострочный текст + Модуль включён + Может входить на сайт и в личный кабинет. + Можно использовать для входа наравне с email. + Нажмите, чтобы вставить выражение в редактор. + Название, системный код и способ ввода. + Найти + Настроить + Настройка поля публичного профиля + Не размещено + Новое поле + Новый пароль + О пользователе + Обязательное + Обязательное поле + Ограничения регистрации + Один email или домен на строку. Проверка не зависит от регистра букв. + Одна учётная запись для сайта и панели + Однострочный текст + Оплачено · обзор + Основное имя пользователя + Основные параметры + Оставьте пустым, чтобы не менять + Отключён + Отключённое поле скрывается во всех формах. + Открыть модули + Открыть предпросмотр + Ошибка формы + Пароль и подтверждение + Пароль изменится, только если заполнить поле. + Пароль по почте не отправляется. После заказа пользователь сразу входит в аккаунт и получает одноразовую ссылку, чтобы задать пароль самостоятельно. + Переключатель + Перетаскивайте поля за маркер, чтобы изменить порядок. + Письмо с доступом + Подготовлено для будущего SMS-гейта. + подключено + Подключённые способы входа + Подпись кнопки + Подтверждение email + Подтверждение email, выход и общий шаблон системных результатов. + Показывать + Показывать в профиле + Показывать поле + Показывать при регистрации + Показывать форму «Забыли пароль» и отправлять ссылку на email. + Покупатель может создать тот же личный кабинет прямо при оформлении. + Поле включено + Пользователи не найдены + Пользователь + Пользователь сайта + Поля профиля + Поля формы регистрации + Порядок и состояние сохраняются без перезагрузки; справа показана итоговая форма пользователя. + Посетители, зарегистрированные на сайте, их контакты и доступ к личному кабинету. + После одобрения администратором + После подтверждения email + После смены URL системные `/login`, `/register`, `/remember`, `/password/reset` и `/personal` останутся совместимыми алиасами. Новые ссылки в интерфейсе и письмах сразу начнут использовать заданные адреса. + Последние заказы · обзор + Последний вход + Права роли настраиваются в разделе «Роли и права». + Правила заполнения и области показа поля. + Предлагать регистрацию в корзине + Предпросмотр формы + При смене пароля будут отозваны remember-сессии и ссылки восстановления. + Профиль, доступ и данные личного кабинета + Публичная Twig-разметка + Публичная группа определяет возможности на сайте. Этому же пользователю можно разрешить работу в панели управления; отдельная регистрация и второй вход не потребуются. + Публичная группа, состояние аккаунта и роль в панели управления. + Публичные аккаунты + Публичные аккаунты, профили и способы входа + Публичный URL + Публичный аккаунт + Публичный сайт + Разметка и CSS-классы редактируются здесь. Маршруты, проверка данных, CSRF и сохранение пользователей остаются в системном обработчике. + разрешена + разрешений + Разрешить заполнение и изменение в личном кабинете. + Разрешить регистрацию + Регистрация + Регистрация и личный кабинет + Редактировать форму + Роль в панели управления + Скрыто + Служебные маршруты + Собрать значение во время создания аккаунта. + Создание аккаунта + Сообщения + Сохранить страницы + Сохранить шаблон + Список значений + Сразу после регистрации + Срок действия одноразовых ссылок и требования к паролю. + Ссылка восстановления, часов + Ссылка подтверждения, часов + Старые адреса продолжат работать + Страницы входа + Страницы личного кабинета + Структура профиля + Та же учётная запись сможет работать в административном интерфейсе. + Так активные поля увидит пользователь. + Текст согласия + Телефон и SMS подключаются отдельным гейтом. + Телефон подтверждён + Тема письма после заказа + Тип поля + Только внутренний адрес сайта. + Улица + Фамилия + Форма профиля + Число + Шаблон сайта + Шаблон страницы + Штатный шаблон + Яндекс, VK ID и SMSC настраиваются отдельно в разделе «Модули». Удаление пакета физически убирает его код и форму настроек. + : '&#039;' }[character]; + }); + }, + + deleteField: function (button) { + var self = this; + Adminx.Confirm.open({ + kind: 'error', title: 'Удалить поле?', + message: 'Значения этого поля у пользователей сайта также будут удалены.', + confirmLabel: 'Удалить', confirmClass: 'btn-danger', + onConfirm: function () { self.request(button.getAttribute('data-url'), new FormData()).then(function () { window.location.reload(); }); } + }); + }, + + toggleField: function (input) { + var self = this; + var item = input.closest('[data-field-id]'); + this.request(input.getAttribute('data-url'), new FormData()).then(function (response) { + var active = !!Number(response.data && response.data.is_active); + input.checked = active; + item.classList.toggle('is-disabled', !active); + var preview = document.querySelector('[data-preview-field= + » должно содержать email + » должно содержать число + » из списка + Базовые и дополнительные поля регистрации. + Восстановлен штатный шаблон формы + Вход + Вход активен + Вход выключен + Вход и регистрация по одноразовому коду SMSC. + Вход и регистрация через аккаунт Яндекса. + Вход и регистрация через единую учётную запись VK. + Выберите активную группу публичных пользователей + Выберите значение поля « + Выберите роль для доступа в панель управления + Выберите существующий шаблон сайта для каждой формы. + Выберите существующую активную публичную группу + Выпадающая авторизация и меню вошедшего пользователя. + Дата + Для доступа в панель управления укажите email + Заполните поле « + Запрос восстановления + Имя, email или логин превышают допустимую длину + Код: латиница, цифры и подчёркивание + Контактные и дополнительные поля пользователя. + Модуль выключен + Настройки регистрации сохранены + Настройки страниц не переданы + Не удалось выполнить действие + Не удалось загрузить профиль + Не удалось сохранить пользователя + Недостаточно прав + Неизвестная форма + Некорректный порядок полей + Новый пароль: минимум + Нужны ключи + Обзор кабинета + Основная страница входа в личный кабинет. + Ошибка Twig в письме после заказа: + Поле « + Поле с таким кодом уже существует + Поле скрыто + Поле сохранено + Поле удалено + Пользователи сайта + Пользователи сайта: просмотр + Пользователи сайта: управление + Пользователь включён + Пользователь не найден + Пользователь отключён + Порядок полей сохранён + Профиль + Профиль пользователя сохранён + Результат регистрации, подтверждения и восстановления. + Сводка заказов и быстрые переходы пользователя. + символов + Система + Системное сообщение + Смена пароля + Сохранено + Страницы входа сохранены + Телефон + Укажите email или телефон + Укажите корректную дату рождения + Укажите корректный email + Укажите корректный телефон + Укажите название поля + Форма в шапке + Форма для авторизованного пользователя. + Форма из одноразовой ссылки. + Форма отправки ссылки на email. + Шаблон формы сохранён + Этот email уже используется + Этот логин уже используется + Этот телефон уже используется + Я + Яндекс + diff --git a/adminx/modules/Customers/language/ru/runtime.xml b/adminx/modules/Customers/language/ru/runtime.xml new file mode 100644 index 0000000..c3a9218 --- /dev/null +++ b/adminx/modules/Customers/language/ru/runtime.xml @@ -0,0 +1,84 @@ + + + » должно содержать email + » должно содержать число + » из списка + Базовые и дополнительные поля регистрации. + Восстановлен штатный шаблон формы + Вход + Вход активен + Вход выключен + Вход и регистрация по одноразовому коду SMSC. + Вход и регистрация через аккаунт Яндекса. + Вход и регистрация через единую учётную запись VK. + Выберите активную группу публичных пользователей + Выберите значение поля « + Выберите роль для доступа в панель управления + Выберите существующий шаблон сайта для каждой формы. + Выберите существующую активную публичную группу + Выпадающая авторизация и меню вошедшего пользователя. + Дата + Для доступа в панель управления укажите email + Заполните поле « + Запрос восстановления + Имя, email или логин превышают допустимую длину + Код: латиница, цифры и подчёркивание + Контактные и дополнительные поля пользователя. + Многострочный текст + Модуль выключен + Настройки регистрации сохранены + Настройки страниц не переданы + Не удалось сохранить пользователя + Недостаточно прав + Неизвестная форма + Некорректный порядок полей + Новый пароль + Новый пароль: минимум + Нужны ключи + Обзор кабинета + Однострочный текст + Основная страница входа в личный кабинет. + Ошибка Twig в письме после заказа: + Переключатель + Поле « + Поле включено + Поле с таким кодом уже существует + Поле скрыто + Поле сохранено + Поле удалено + Пользователи сайта + Пользователи сайта: просмотр + Пользователи сайта: управление + Пользователь включён + Пользователь не найден + Пользователь отключён + Порядок полей сохранён + Профиль + Профиль пользователя сохранён + Регистрация + Результат регистрации, подтверждения и восстановления. + Сводка заказов и быстрые переходы пользователя. + Система + Системное сообщение + Смена пароля + Список значений + Страницы входа сохранены + Телефон + Укажите email или телефон + Укажите корректную дату рождения + Укажите корректный email + Укажите корректный телефон + Укажите название поля + Форма в шапке + Форма для авторизованного пользователя. + Форма из одноразовой ссылки. + Форма отправки ссылки на email. + Число + Шаблон формы сохранён + Этот email уже используется + Этот логин уже используется + Этот телефон уже используется + Я + Яндекс + символов + diff --git a/adminx/modules/Customers/migrations/001_normalize_public_groups.sql b/adminx/modules/Customers/migrations/001_normalize_public_groups.sql new file mode 100644 index 0000000..5310c4e --- /dev/null +++ b/adminx/modules/Customers/migrations/001_normalize_public_groups.sql @@ -0,0 +1,10 @@ +INSERT INTO `{{prefix}}_public_user_groups` + (`user_group`, `user_group_name`, `status`, `set_default_avatar`, `default_avatar`, `user_group_permission`) +VALUES + (1, 'Администраторы', '1', '0', '', ''), + (2, 'Гости', '1', '0', '', ''), + (3, 'Модераторы', '1', '0', '', ''), + (4, 'Пользователи', '1', '0', '', '') +ON DUPLICATE KEY UPDATE + `user_group_name` = VALUES(`user_group_name`), + `status` = '1'; diff --git a/adminx/modules/Customers/migrations/002_public_auth_schema.sql b/adminx/modules/Customers/migrations/002_public_auth_schema.sql new file mode 100644 index 0000000..6312d88 --- /dev/null +++ b/adminx/modules/Customers/migrations/002_public_auth_schema.sql @@ -0,0 +1,66 @@ +ALTER TABLE `{{public_user_prefix}}_users` + MODIFY `password` VARCHAR(255) NOT NULL, + MODIFY `salt` VARCHAR(64) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS `email_verified_at` INT UNSIGNED NOT NULL DEFAULT 0 AFTER `email`, + ADD COLUMN IF NOT EXISTS `phone_verified_at` INT UNSIGNED NOT NULL DEFAULT 0 AFTER `phone`; + +CREATE TABLE IF NOT EXISTS `{{public_user_prefix}}_auth_tokens` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `purpose` VARCHAR(32) NOT NULL, + `channel` VARCHAR(24) NOT NULL, + `token_hash` CHAR(64) NOT NULL, + `expires_at` INT UNSIGNED NOT NULL, + `used_at` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` INT UNSIGNED NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_token_hash` (`token_hash`), + KEY `idx_user_purpose` (`user_id`,`purpose`), + KEY `idx_expiry` (`expires_at`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `{{public_user_prefix}}_user_profile_fields` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `code` VARCHAR(64) NOT NULL, + `name` VARCHAR(160) NOT NULL, + `type` VARCHAR(24) NOT NULL DEFAULT 'text', + `options` TEXT NULL, + `is_required` TINYINT(1) NOT NULL DEFAULT 0, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `show_registration` TINYINT(1) NOT NULL DEFAULT 0, + `show_profile` TINYINT(1) NOT NULL DEFAULT 1, + `position` INT NOT NULL DEFAULT 0, + `created_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_code` (`code`), + KEY `idx_active_position` (`is_active`,`position`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; + +ALTER TABLE `{{public_user_prefix}}_user_profile_fields` + ADD COLUMN IF NOT EXISTS `show_registration` TINYINT(1) NOT NULL DEFAULT 0 AFTER `is_active`, + ADD COLUMN IF NOT EXISTS `show_profile` TINYINT(1) NOT NULL DEFAULT 1 AFTER `show_registration`; + +CREATE TABLE IF NOT EXISTS `{{public_user_prefix}}_user_profile_values` ( + `user_id` INT UNSIGNED NOT NULL, + `field_id` INT UNSIGNED NOT NULL, + `value` TEXT NULL, + `updated_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`user_id`,`field_id`), + KEY `idx_field` (`field_id`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `{{public_user_prefix}}_user_identities` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `provider` VARCHAR(24) CHARACTER SET ascii NOT NULL, + `provider_user_id` VARCHAR(190) CHARACTER SET ascii NOT NULL, + `email` VARCHAR(190) NOT NULL DEFAULT '', + `profile_json` TEXT NULL, + `created_at` INT UNSIGNED NOT NULL, + `updated_at` INT UNSIGNED NOT NULL, + `last_login_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_provider_identity` (`provider`,`provider_user_id`), + KEY `idx_identity_user` (`user_id`), + KEY `idx_identity_email` (`email`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Customers/migrations/003_normalize_last_visit.sql b/adminx/modules/Customers/migrations/003_normalize_last_visit.sql new file mode 100644 index 0000000..282ffff --- /dev/null +++ b/adminx/modules/Customers/migrations/003_normalize_last_visit.sql @@ -0,0 +1,4 @@ +UPDATE `{{public_user_prefix}}_users` +SET `last_visit` = 0 +WHERE `last_visit` > 0 + AND `last_visit` < 946684800; diff --git a/adminx/modules/Customers/migrations/004_materialize_public_auth_schema.sql b/adminx/modules/Customers/migrations/004_materialize_public_auth_schema.sql new file mode 100644 index 0000000..c6bf703 --- /dev/null +++ b/adminx/modules/Customers/migrations/004_materialize_public_auth_schema.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS `{{public_user_prefix}}_auth_settings` ( + `id` TINYINT UNSIGNED NOT NULL DEFAULT 1, + `registration_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `registration_mode` VARCHAR(16) NOT NULL DEFAULT 'email', + `registration_gate` VARCHAR(24) NOT NULL DEFAULT 'email', + `default_group` SMALLINT UNSIGNED NOT NULL DEFAULT 4, + `password_min_length` TINYINT UNSIGNED NOT NULL DEFAULT 8, + `verification_ttl` INT UNSIGNED NOT NULL DEFAULT 86400, + `reset_ttl` INT UNSIGNED NOT NULL DEFAULT 3600, + `password_reset_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `require_firstname` TINYINT(1) NOT NULL DEFAULT 1, + `show_lastname` TINYINT(1) NOT NULL DEFAULT 1, + `require_lastname` TINYINT(1) NOT NULL DEFAULT 0, + `show_phone` TINYINT(1) NOT NULL DEFAULT 0, + `require_phone` TINYINT(1) NOT NULL DEFAULT 0, + `show_company` TINYINT(1) NOT NULL DEFAULT 0, + `require_company` TINYINT(1) NOT NULL DEFAULT 0, + `deny_domains` TEXT NULL, + `deny_emails` TEXT NULL, + `pages_json` MEDIUMTEXT NULL, + `updated_at` DATETIME NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; + +ALTER TABLE `{{public_user_prefix}}_auth_settings` + ADD COLUMN IF NOT EXISTS `pages_json` MEDIUMTEXT NULL AFTER `deny_emails`; + +INSERT INTO `{{public_user_prefix}}_auth_settings` + (`id`, `registration_enabled`, `registration_mode`, `registration_gate`, `default_group`, + `password_min_length`, `verification_ttl`, `reset_ttl`, `password_reset_enabled`, + `require_firstname`, `show_lastname`, `require_lastname`, `show_phone`, `require_phone`, + `show_company`, `require_company`, `deny_domains`, `deny_emails`, `pages_json`, `updated_at`) +VALUES + (1, 0, 'email', 'email', 4, 8, 86400, 3600, 0, 1, 1, 0, 0, 0, 0, 0, '', '', NULL, NOW()) +ON DUPLICATE KEY UPDATE `id` = VALUES(`id`); + +CREATE TABLE IF NOT EXISTS `{{public_user_prefix}}_auth_templates` ( + `page_key` VARCHAR(32) NOT NULL, + `template_text` MEDIUMTEXT NOT NULL, + `updated_by` INT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` DATETIME NULL, + PRIMARY KEY (`page_key`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Customers/migrations/005_checkout_registration.sql b/adminx/modules/Customers/migrations/005_checkout_registration.sql new file mode 100644 index 0000000..dbe59b4 --- /dev/null +++ b/adminx/modules/Customers/migrations/005_checkout_registration.sql @@ -0,0 +1,40 @@ +ALTER TABLE `{{public_user_prefix}}_auth_settings` + ADD COLUMN IF NOT EXISTS `checkout_registration_enabled` TINYINT(1) NOT NULL DEFAULT 1 AFTER `deny_emails`, + ADD COLUMN IF NOT EXISTS `checkout_policy_url` VARCHAR(255) NOT NULL DEFAULT '/privacy-policy' AFTER `checkout_registration_enabled`, + ADD COLUMN IF NOT EXISTS `checkout_policy_label` VARCHAR(300) NOT NULL DEFAULT '' AFTER `checkout_policy_url`, + ADD COLUMN IF NOT EXISTS `checkout_access_subject` VARCHAR(190) NOT NULL DEFAULT 'Доступ к личному кабинету' AFTER `checkout_policy_label`, + ADD COLUMN IF NOT EXISTS `checkout_access_template` MEDIUMTEXT NULL AFTER `checkout_access_subject`; + +UPDATE `{{public_user_prefix}}_auth_settings` +SET `checkout_policy_label` = 'Я согласен с политикой конфиденциальности и обработкой персональных данных', + `checkout_access_subject` = 'Доступ к личному кабинету', + `checkout_access_template` = '

    Личный кабинет создан

    {% if user.firstname %}{{ user.firstname }}, {% endif %}ваш заказ №{{ order.id }} оформлен, а личный кабинет уже доступен.

    Логин: {{ user.email }}

    Задать пароль

    Ссылка действует {{ expires_hours }} ч. Если она истечёт, запросите новую на странице восстановления пароля.

    ' +WHERE `id` = 1 + AND (`checkout_access_template` IS NULL OR `checkout_access_template` = ''); + +UPDATE `{{public_user_prefix}}_auth_settings` +SET `checkout_registration_enabled` = COALESCE((SELECT `value` FROM `{{prefix}}_settings` WHERE `param` = 'email_registration.checkout_enabled' LIMIT 1), `checkout_registration_enabled`), + `checkout_policy_url` = COALESCE(NULLIF((SELECT `value` FROM `{{prefix}}_settings` WHERE `param` = 'email_registration.policy_url' LIMIT 1), ''), `checkout_policy_url`), + `checkout_policy_label` = COALESCE(NULLIF((SELECT `value` FROM `{{prefix}}_settings` WHERE `param` = 'email_registration.policy_label' LIMIT 1), ''), `checkout_policy_label`), + `checkout_access_subject` = COALESCE(NULLIF((SELECT `value` FROM `{{prefix}}_settings` WHERE `param` = 'email_registration.access_subject' LIMIT 1), ''), `checkout_access_subject`), + `checkout_access_template` = COALESCE(NULLIF((SELECT `value` FROM `{{prefix}}_settings` WHERE `param` = 'email_registration.access_template' LIMIT 1), ''), `checkout_access_template`) +WHERE `id` = 1; + +DELETE FROM `{{prefix}}_settings` +WHERE `param` LIKE 'email_registration.%'; + +DELETE rp FROM `{{prefix}}_role_permissions` rp +INNER JOIN `{{prefix}}_permissions` p ON p.`code` = rp.`permission_code` +WHERE p.`module` = 'email_registration'; + +DELETE FROM `{{prefix}}_permissions` +WHERE `module` = 'email_registration'; + +DELETE FROM `{{prefix}}_module_events` +WHERE `module_code` = 'email_registration'; + +DELETE FROM `{{prefix}}_module_migrations` +WHERE `module` = 'email_registration'; + +DELETE FROM `{{prefix}}_modules` +WHERE `code` = 'email_registration'; diff --git a/adminx/modules/Customers/migrations/006_phone_identity.php b/adminx/modules/Customers/migrations/006_phone_identity.php new file mode 100644 index 0000000..cd72d83 --- /dev/null +++ b/adminx/modules/Customers/migrations/006_phone_identity.php @@ -0,0 +1,83 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\PublicUserTables; + use App\Helpers\Phone; + + return function (array $context) { + $table = PublicUserTables::table('users'); + $changed = 0; + if (!DatabaseSchema::tableExists($table)) { + return $changed; + } + + DB::query('ALTER TABLE `' . $table . '` MODIFY `email` VARCHAR(190) NULL DEFAULT NULL'); + $changed++; + if (!DatabaseSchema::columnExists($table, 'phone_normalized')) { + DB::query( + 'ALTER TABLE `' . $table . '`' + . ' ADD COLUMN `phone_normalized` VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NULL DEFAULT NULL AFTER `phone`' + ); + $changed++; + } + + if (!DatabaseSchema::indexExists($table, 'uniq_phone_normalized')) { + DB::query('UPDATE `' . $table . '` SET phone_normalized=NULL'); + $cursor = 0; + do { + $rows = DB::query( + "SELECT Id,phone FROM `" . $table . "`" + . " WHERE Id>%i AND deleted!='1' AND phone!='' ORDER BY Id LIMIT 500", + $cursor + )->getAll() ?: array(); + if (!$rows) { + break; + } + + $sql = 'UPDATE `' . $table . '` SET phone_normalized=CASE Id'; + $args = array(); + $ids = array(); + foreach ($rows as $row) { + $id = (int) $row['Id']; + $sql .= ' WHEN %i THEN %s'; + $args[] = $id; + $args[] = Phone::normalize(isset($row['phone']) ? $row['phone'] : '') ?: null; + $ids[] = $id; + $cursor = $id; + } + + $sql .= ' ELSE phone_normalized END WHERE Id IN %li'; + $args[] = $ids; + call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args)); + } while (count($rows) === 500); + + DB::query( + 'UPDATE `' . $table . '` AS target' + . ' JOIN (SELECT phone_normalized FROM (' + . ' SELECT phone_normalized FROM `' . $table . '`' + . ' WHERE phone_normalized IS NOT NULL' + . ' GROUP BY phone_normalized HAVING COUNT(*)>1' + . ' ) AS grouped) AS duplicates' + . ' ON duplicates.phone_normalized=target.phone_normalized' + . ' SET target.phone_normalized=NULL' + ); + DB::query('ALTER TABLE `' . $table . '` ADD UNIQUE KEY `uniq_phone_normalized` (`phone_normalized`)'); + $changed++; + } + + return $changed; + }; diff --git a/adminx/modules/Customers/module.php b/adminx/modules/Customers/module.php new file mode 100644 index 0000000..7deb334 --- /dev/null +++ b/adminx/modules/Customers/module.php @@ -0,0 +1,71 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + return array( + 'code' => 'customers', 'name' => 'Пользователи сайта', 'version' => '0.3.0', + 'permissions' => array('key' => 'customers', 'items' => array( + array( + 'code' => 'view_customers', + 'group_code' => 'navigation', + 'name' => 'Пользователи сайта: просмотр', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_customers', + 'group_code' => 'admin', + 'name' => 'Пользователи сайта: управление', + 'sort_order' => 20, + ), + ), 'icon' => 'ti ti-user-heart', 'priority' => 31), + 'navigation' => array( + array( + 'code' => 'customers', + 'label' => 'Пользователи сайта', + 'url' => '/system/customers', + 'icon' => 'ti ti-user-heart', + 'permission' => 'view_customers', + 'group' => 'Система', + 'sort_order' => 32, + 'match' => array( + '/system/customers', + ), + ), + ), + 'routes' => array( + array('GET', '/system/customers', array(\App\Adminx\Customers\Controller::class, 'index')), + array('GET', '/system/customers/users/{id}', array(\App\Adminx\Customers\Controller::class, 'customer')), + array('POST', '/system/customers/users/{id}', array(\App\Adminx\Customers\Controller::class, 'updateCustomer')), + array('POST', '/system/customers/{id}/toggle', array(\App\Adminx\Customers\Controller::class, 'toggle')), + array('POST', '/system/customers/fields/reorder', array(\App\Adminx\Customers\Controller::class, 'reorderFields')), + array('POST', '/system/customers/fields/{id}', array(\App\Adminx\Customers\Controller::class, 'saveField')), + array('POST', '/system/customers/fields/{id}/delete', array(\App\Adminx\Customers\Controller::class, 'deleteField')), + array('POST', '/system/customers/fields/{id}/toggle', array(\App\Adminx\Customers\Controller::class, 'toggleField')), + array('POST', '/system/customers/auth', array(\App\Adminx\Customers\Controller::class, 'saveAuthSettings')), + array('POST', '/system/customers/pages', array(\App\Adminx\Customers\Controller::class, 'saveAuthPages')), + array('GET', '/system/customers/forms/{key}', array(\App\Adminx\Customers\Controller::class, 'authForm')), + array('POST', '/system/customers/forms/{key}', array(\App\Adminx\Customers\Controller::class, 'saveAuthForm')), + array('POST', '/system/customers/forms/{key}/reset', array(\App\Adminx\Customers\Controller::class, 'resetAuthForm')), + ), + 'migrations' => array( + array('id' => '001_normalize_public_groups', 'file' => 'migrations/001_normalize_public_groups.sql'), + array('id' => '002_public_auth_schema', 'file' => 'migrations/002_public_auth_schema.sql'), + array('id' => '003_normalize_last_visit', 'file' => 'migrations/003_normalize_last_visit.sql'), + array('id' => '004_materialize_public_auth_schema', 'file' => 'migrations/004_materialize_public_auth_schema.sql'), + array('id' => '005_checkout_registration', 'file' => 'migrations/005_checkout_registration.sql'), + array('id' => '006_phone_identity', 'file' => 'migrations/006_phone_identity.php'), + ), + 'view_globals' => array('module_code' => 'customers'), + ); diff --git a/adminx/modules/Customers/view/index.twig b/adminx/modules/Customers/view/index.twig new file mode 100644 index 0000000..13a648a --- /dev/null +++ b/adminx/modules/Customers/view/index.twig @@ -0,0 +1,251 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Пользователи сайта{% endblock %} +{% block content %} + + + + +
    +
    {{ stats.total }}
    Всего
    +
    {{ stats.active }}
    Активные
    +
    {{ stats.verified }}
    Email подтверждён
    +
    {{ stats.fields }}
    Доп. полей
    +
    + + + +{% if tab=='customers' %} +
    Аккаунты сайта

    Пользователи

    +
    +

    Публичные аккаунты

    Посетители, зарегистрированные на сайте, их контакты и доступ к личному кабинету.

    {{ customers|length }}
    +
    {% if can_manage %}{% endif %} + {% for customer in customers %}{% if can_manage %}{% endif %} + {% else %}{% endfor %} +
    IDПользовательКонтактыКомпанияРегистрацияСтатусДействия
    {{ customer.id }}{{ (customer.firstname ~ ' ' ~ customer.lastname)|trim ?: customer.user_name }}{{ customer.user_name }}{{ customer.email ?: customer.phone }}{% if customer.email and customer.phone %}{{ customer.phone }}{% endif %}{{ customer.company ?: '—' }}{{ customer.reg_time ? customer.reg_time|date('d.m.Y') : '—' }}{% if can_manage %}{% else %}{{ customer.status=='1'?'Активен':'Отключён' }}{% endif %}
    Пользователи не найдены
    +
    +{% elseif tab=='fields' %} +
    Форма профиля

    Конструктор дополнительных полей

    Порядок и состояние сохраняются без перезагрузки; справа показана итоговая форма пользователя.

    {% if can_manage %}
    {% endif %}
    + +
    +
    +

    Структура профиля

    Перетаскивайте поля за маркер, чтобы изменить порядок.

    {{ fields|length }}
    +
    + {% for field in fields %} +
    + {% if can_manage %}{% endif %} + +
    {{ field.name }}{{ field.code }} · {{ field.type_label }}{% if field.is_required %} · обязательное{% endif %}{% if field.show_registration %}Регистрация{% endif %}{% if field.show_profile %}Профиль{% endif %}{% if not field.show_registration and not field.show_profile %}Не размещено{% endif %}
    + {% if can_manage %}{% else %}{{ field.is_active ? 'Включено' : 'Скрыто' }}{% endif %} + {% if can_manage %}
    {% endif %} +
    + {% else %}
    Дополнительных полей пока нетДобавьте первое поле профиля.
    {% endfor %} +
    +
    + + +
    +{% elseif tab=='auth' %} +
    Публичный аккаунт

    Регистрация и личный кабинет

    Единые правила создания аккаунта пользователя, его активации и восстановления доступа.

    {% if can_manage %}
    {% endif %}
    + +
    Одна учётная запись для сайта и панели

    Публичная группа определяет возможности на сайте. Этому же пользователю можно разрешить работу в панели управления; отдельная регистрация и второй вход не потребуются.

    + +
    +
    +

    Создание аккаунта

    Кто может зарегистрироваться и когда станет доступен вход.

    +
    + + +
    Канал регистрации
    EmailТелефон и SMS подключаются отдельным гейтом.
    +
    +
    + +
    +

    Поля формы регистрации

    Базовые данные аккаунта. Дополнительные поля подключаются в соседнем табе.

    +
    +
    ПолеПоказыватьОбязательное
    +
    EmailЛогин и канал подтвержденияВсегдаВсегда
    +
    ИмяОсновное имя пользователяВсегда
    +
    ФамилияДополняет отображаемое имя
    +
    ТелефонКонтакт для заказов и доставки
    +
    КомпанияДля корпоративных профилей
    +
    +
    + +
    +

    Пароль и подтверждение

    Срок действия одноразовых ссылок и требования к паролю.

    +
    + + + + +
    +
    + +
    +

    Аккаунт после заказа

    Покупатель может создать тот же личный кабинет прямо при оформлении.

    +
    +

    Пароль по почте не отправляется. После заказа пользователь сразу входит в аккаунт и получает одноразовую ссылку, чтобы задать пароль самостоятельно.

    +
    + + +
    + +
    Письмо с доступомHTML и Twig. Нажмите тег, чтобы вставить его в позицию курсора.
    {% if can_manage %}{% endif %}
    + + + {% for value,description in {'{{ user.firstname }}':'Имя пользователя','{{ user.lastname }}':'Фамилия','{{ user.email }}':'Email и логин','{{ order.id }}':'Номер заказа','{{ order.total }}':'Сумма заказа','{{ access_url }}':'Одноразовая ссылка задания пароля','{{ expires_hours }}':'Срок ссылки в часах'} %}{% endfor %} + +
    +
    + +
    +

    Ограничения регистрации

    Один email или домен на строку. Проверка не зависит от регистра букв.

    +
    +
    + + {% if can_manage %}
    {% endif %} +
    + +{% if oauth_modules %} +
    +
    +
    Авторизация

    Подключённые способы входа

    OAuth и вход по одноразовому коду используют одну учётную запись сайта.

    +
    {{ oauth_modules|length }} подключено
    +
    +
    + {% for provider in oauth_modules %} +
    +
    +
    {{ provider.mark }}

    {{ provider.label }}

    {{ provider.description }}

    + {{ provider.state.label }} +
    +
    +
    + Модуль включён + Ключи настроены + Регистрация {{ provider.allow_registration ? 'разрешена' : 'выключена' }} +
    + {% if provider.can_open %}Настроить{% endif %} +
    +
    + {% endfor %} +
    +{% else %} +
    Дополнительные способы входа устанавливаются модулями

    Яндекс, VK ID и SMSC настраиваются отдельно в разделе «Модули». Удаление пакета физически убирает его код и форму настроек.

    Открыть модули
    +{% endif %} +{% else %} +
    Публичный сайт

    Страницы личного кабинета

    Каждая страница имеет редактируемый Twig-шаблон и выводится через `[tag:maincontent]` внутри выбранного шаблона сайта.

    {% if can_manage %}
    {% endif %}
    + +
    Старые адреса продолжат работать

    После смены URL системные `/login`, `/register`, `/remember`, `/password/reset` и `/personal` останутся совместимыми алиасами. Новые ссылки в интерфейсе и письмах сразу начнут использовать заданные адреса.

    + +
    +
    + {% for key in ['login','register','remember','reset','overview','profile','password'] %} + {% set settings = auth_settings.pages[key] %}{% set form_meta = auth_forms[key] %} +
    +

    {{ form_meta.label }}

    {{ form_meta.description }}
    {% if can_manage %}{% endif %}
    +
    +
    + {% endfor %} +
    +

    Служебные маршруты

    Подтверждение email, выход и общий шаблон системных результатов.

    {% if can_manage %}
    {% endif %}
    + {% if can_manage %}
    {% endif %} +
    +{% endif %} + +{% if can_manage %} + + + + + +{% endif %} +{% endblock %} diff --git a/adminx/modules/Dashboard/Controller.php b/adminx/modules/Dashboard/Controller.php new file mode 100644 index 0000000..06b9136 --- /dev/null +++ b/adminx/modules/Dashboard/Controller.php @@ -0,0 +1,122 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Dashboard; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\Controller as BaseController; + use App\Common\ModuleManager; + use App\Common\SystemTables; + use App\Content\ContentTables; + use App\Adminx\Support\ModuleExtensions; + use App\Adminx\Support\AdminLocale; + use App\Adminx\Support\SystemHealth; + use DB; + + /** + * Главная страница ядра. Бизнес-показатели добавляют владельцы модулей. + */ + class Controller extends BaseController + { + /** GET / и /dashboard */ + public function index(array $params = []) + { + AdminAssets::addStyle($this->base() . '/modules/Dashboard/assets/dashboard.css', 50); + AdminAssets::addScript($this->base() . '/modules/Dashboard/assets/dashboard.js', 50); + + $catalog = Widgets::catalog(ModuleExtensions::dashboardDefinitions()); + $visibleCodes = array(); + foreach ($catalog as $widget) { + if (!empty($widget['layout_visible'])) { + $visibleCodes[] = (string) $widget['layout_code']; + } + } + + $stats = in_array('core.system_kpis', $visibleCodes, true) ? $this->stats() : array(); + $recentDocuments = in_array('core.recent_documents', $visibleCodes, true) ? $this->recentDocuments() : array(); + + return $this->render('@dashboard/index.twig', [ + 'greeting' => $this->greeting(), + 'dashboard_widgets' => Widgets::compose($stats, $recentDocuments, ModuleExtensions::dashboardWidgets($visibleCodes)), + ]); + } + + /** Приветствие по времени суток. */ + protected function greeting() + { + $h = (int) date('G'); + if ($h < 6) { + return AdminLocale::text('dashboard_greeting_night', 'Доброй ночи'); + } + + if ($h < 12) { + return AdminLocale::text('dashboard_greeting_morning', 'Доброе утро'); + } + + if ($h < 18) { + return AdminLocale::text('dashboard_greeting_day', 'Добрый день'); + } + + return AdminLocale::text('dashboard_greeting_evening', 'Добрый вечер'); + } + + /** Сводка для KPI-карточек с динамикой за 30 дней (безопасно к отсутствию таблиц). */ + protected function stats() + { + $notFound = SystemHealth::notFoundStats(); + $notFoundModule = ModuleManager::get('notfound'); + return [ + 'users' => $this->countTable(SystemTables::table('users'), 'is_active = 1'), + 'documents' => $this->countTable($this->table('documents'), "document_status = '1' AND document_deleted != '1'"), + 'not_found' => isset($notFound['unresolved']) ? (int) $notFound['unresolved'] : 0, + 'not_found_url' => $notFoundModule && !empty($notFoundModule['enabled']) + ? '/notfound?unresolved=1' + : '/events?source=404', + 'sql_errors' => SystemHealth::sqlErrorCount(), + ]; + } + + protected function recentDocuments() + { + try { + return DB::query('SELECT d.Id,d.document_title,d.document_alias,d.document_changed,d.document_status,' + . 'r.rubric_title FROM ' . $this->table('documents') . ' d LEFT JOIN ' . $this->table('rubrics') + . " r ON r.Id=d.rubric_id WHERE d.document_deleted != '1'" + . ' ORDER BY d.document_changed DESC,d.Id DESC LIMIT 8')->getAll() ?: []; + } catch (\Throwable $e) { + return []; + } + } + + protected function countTable($table, $where = '') + { + try { + $sql = 'SELECT COUNT(*) FROM ' . $table; + if ($where !== '') { + $sql .= ' WHERE ' . $where; + } + + return (int) \DB::query($sql)->getValue(); + } catch (\Throwable $e) { + return null; + } + } + + protected function table($suffix) + { + return ContentTables::table($suffix); + } + } diff --git a/adminx/modules/Dashboard/Widgets.php b/adminx/modules/Dashboard/Widgets.php new file mode 100644 index 0000000..35be5d6 --- /dev/null +++ b/adminx/modules/Dashboard/Widgets.php @@ -0,0 +1,67 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Dashboard; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Adminx\Support\InterfaceSettings; + + class Widgets + { + public static function catalog(array $moduleDefinitions = array()) + { + return InterfaceSettings::dashboardCatalog(array_merge(self::coreCatalog(), $moduleDefinitions)); + } + + public static function compose(array $stats, array $documents, array $moduleWidgets) + { + $items = array( + array( + 'layout_code' => 'core.system_kpis', 'label' => 'Состояние системы', + 'description' => 'Документы, пользователи, 404 и ошибки базы данных.', + 'icon' => 'ti ti-chart-dots-3', 'sort_order' => 10, + 'template' => '@dashboard/widgets/system-kpis.twig', 'data' => array('stats' => $stats), + ), + array( + 'layout_code' => 'core.recent_documents', 'label' => 'Недавние документы', + 'description' => 'Последние измененные документы в виде таблицы.', + 'icon' => 'ti ti-file-pencil', 'sort_order' => 20, + 'template' => '@dashboard/widgets/recent-documents.twig', 'data' => array('documents' => $documents), + ), + array( + 'layout_code' => 'core.shortcuts', 'label' => 'Быстрый доступ', + 'description' => 'Ссылки на основные рабочие разделы системы.', + 'icon' => 'ti ti-bolt', 'sort_order' => 1000, + 'template' => '@dashboard/widgets/shortcuts.twig', 'data' => array(), + ), + ); + + foreach ($moduleWidgets as $widget) { + $widget['sort_order'] = 100 + (isset($widget['sort_order']) ? (int) $widget['sort_order'] : 100); + $items[] = $widget; + } + + return InterfaceSettings::applyDashboard($items); + } + + protected static function coreCatalog() + { + return array( + array('layout_code' => 'core.system_kpis', 'label' => 'Состояние системы', 'description' => 'Документы, пользователи, 404 и SQL ошибки.', 'icon' => 'ti ti-chart-dots-3', 'sort_order' => 10), + array('layout_code' => 'core.recent_documents', 'label' => 'Недавние документы', 'description' => 'Таблица последних измененных документов.', 'icon' => 'ti ti-file-pencil', 'sort_order' => 20), + array('layout_code' => 'core.shortcuts', 'label' => 'Быстрый доступ', 'description' => 'Переходы к основным разделам.', 'icon' => 'ti ti-bolt', 'sort_order' => 1000), + ); + } + } diff --git a/adminx/modules/Dashboard/assets/dashboard.css b/adminx/modules/Dashboard/assets/dashboard.css new file mode 100644 index 0000000..dc79a97 --- /dev/null +++ b/adminx/modules/Dashboard/assets/dashboard.css @@ -0,0 +1,559 @@ +.dashboard-page-header { + align-items: flex-end; +} +.dashboard-page-header h1 { + text-wrap: balance; +} +.dashboard-date { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-secondary); + font-size: 13px; + font-variant-numeric: tabular-nums; +} +/* ── KPI ───────────────────────────────────────────── */ +.dashboard-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 20px; +} +.dashboard-kpi { + display: flex; + align-items: flex-start; + gap: 14px; + min-width: 0; + padding: 18px 20px; + color: inherit; + text-decoration: none; + background: var(--surface-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04); + transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s; +} +.dashboard-kpi:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--color-primary) 40%, var(--border-default)); + box-shadow: 0 6px 18px rgba(16, 24, 40, 0.08); + text-decoration: none; +} +.dashboard-kpi:active { + transform: translateY(0) scale(0.99); +} +.dashboard-kpi.is-danger { + border-color: color-mix(in srgb, var(--color-danger) 34%, var(--border-default)); +} +.dashboard-kpi .icon-tile { + flex: 0 0 auto; +} +.dashboard-kpi-body { + display: flex; + flex-direction: column; + min-width: 0; +} +.dashboard-kpi-body small { + color: var(--text-secondary); + font-size: 12.5px; +} +.dashboard-kpi-body strong { + margin-top: 4px; + color: var(--text-primary); + font-size: 24px; + line-height: 1.1; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} +.dashboard-delta { + display: inline-flex; + align-items: center; + gap: 3px; + align-self: flex-start; + margin-top: 8px; + padding: 2px 8px 2px 6px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.dashboard-delta .ti { + font-size: 14px; +} +.dashboard-delta.is-up { + background: var(--color-success-soft); + color: var(--color-success); +} +.dashboard-delta.is-down { + background: var(--color-danger-soft); + color: var(--color-danger); +} +.dashboard-delta.is-flat { + background: var(--surface-muted); + color: var(--text-secondary); +} +/* ── Grids ─────────────────────────────────────────── */ +.dashboard-grid { + display: grid; + gap: 20px; + margin-bottom: 20px; +} +.dashboard-grid-top { + grid-template-columns: minmax(0, 1.7fr) minmax(300px, 0.75fr); +} +.dashboard-grid-bottom { + grid-template-columns: minmax(0, 1.65fr) minmax(310px, 0.75fr); +} +.dashboard-section { + min-width: 0; + padding: 18px 20px; + background: var(--surface-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04); +} +.dashboard-orders, +.dashboard-documents { + overflow: hidden; +} +.dashboard-section .section-header { + margin-bottom: 16px; +} +.dashboard-section h2 { + text-wrap: balance; +} +/* Каждому разделу — свой мягкий акцент (иконка, эйбрау, полоска шапки), + чтобы дашборд не был монотонно-белым. */ +.dashboard-chart-card { + --section-accent: var(--color-primary); + --section-accent-soft: var(--color-primary-soft); +} +.dashboard-status-card { + --section-accent: var(--amber-500); + --section-accent-soft: var(--amber-100); +} +.dashboard-orders { + --section-accent: var(--green-500); + --section-accent-soft: var(--green-100); +} +.dashboard-documents { + --section-accent: var(--violet-500); + --section-accent-soft: var(--violet-100); +} +.dashboard-shortcuts { + --section-accent: var(--cyan-500); + --section-accent-soft: var(--cyan-100); +} +/* ── Sales chart ───────────────────────────────────── */ +.dashboard-chart-legend { + text-align: right; + line-height: 1.25; +} +.dashboard-chart-total { + display: block; + color: var(--text-primary); + font-size: 18px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.dashboard-chart-legend small { + color: var(--text-secondary); + font-size: 12px; +} +.dashboard-chart { + position: relative; + height: 190px; +} +.dashboard-chart-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; +} +.dashboard-chart-cols { + position: absolute; + inset: 0; +} +.dashboard-chart-col { + position: absolute; + top: 0; + bottom: 0; + transform: translateX(-50%); + cursor: default; +} +.dashboard-chart-col.is-weekend::before { + content: ""; + position: absolute; + inset: 0; + background: color-mix(in srgb, var(--text-secondary) 6%, transparent); + opacity: 0; + transition: opacity 0.12s; +} +.dashboard-chart-col:hover.is-weekend::before { + opacity: 1; +} +.dashboard-chart-dot { + position: absolute; + left: 50%; + width: 9px; + height: 9px; + transform: translate(-50%, -50%); + border-radius: 50%; + background: var(--surface-card); + border: 2px solid var(--color-primary); + opacity: 0; + transition: opacity 0.12s; +} +.dashboard-chart-col:hover .dashboard-chart-dot { + opacity: 1; +} +.dashboard-chart-axis { + display: flex; + justify-content: space-between; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-default); + color: var(--text-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} +.dashboard-chart-axis .is-weekend { + color: var(--text-secondary); + font-weight: 600; +} +/* ── Status breakdown ──────────────────────────────── */ +.dashboard-status-list { + display: grid; + gap: 14px; +} +.dashboard-status-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 6px 10px; +} +.dashboard-status-name { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dashboard-status-name i { + width: 9px; + height: 9px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--status-color); +} +.dashboard-status-count { + font-weight: 700; + font-size: 13px; + font-variant-numeric: tabular-nums; +} +.dashboard-status-bar { + grid-column: 1 / -1; + height: 7px; + border-radius: 999px; + background: var(--surface-muted); + overflow: hidden; +} +.dashboard-status-bar > span { + display: block; + height: 100%; + min-width: 3px; + border-radius: inherit; + background: var(--status-color); +} +/* ── Recent orders table ───────────────────────────── */ +.dashboard-table-wrap { + width: 100%; + max-width: 100%; + min-width: 0; + overflow-x: auto; +} +.dashboard-orders-table { + min-width: 620px; + table-layout: fixed; +} +.dashboard-orders-table th:nth-child(1) { + width: 18%; +} +.dashboard-orders-table th:nth-child(2) { + width: 34%; +} +.dashboard-orders-table th:nth-child(3) { + width: 20%; +} +.dashboard-orders-table th:nth-child(4) { + width: 28%; +} +.dashboard-orders-table td { + vertical-align: middle; +} +.dashboard-orders-table td small { + display: block; + margin-top: 3px; + color: var(--text-secondary); +} +.dashboard-documents-table { + min-width: 860px; + table-layout: fixed; +} +.dashboard-documents-table th, +.dashboard-documents-table td { + vertical-align: middle; +} +.dashboard-documents-table .dashboard-doc-id { + width: 76px; +} +.dashboard-documents-table .dashboard-doc-rubric { + width: 210px; +} +.dashboard-documents-table .dashboard-doc-state { + width: 132px; +} +.dashboard-documents-table .dashboard-doc-date { + width: 154px; +} +.dashboard-documents-table .dashboard-doc-action { + width: 52px; +} +.dashboard-document-title { + display: block; + min-width: 0; + color: inherit; + text-decoration: none; +} +.dashboard-document-title:hover { + text-decoration: none; +} +.dashboard-document-title b { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dashboard-document-title small { + display: block; + margin-top: 3px; + overflow: hidden; + color: var(--text-secondary); + font-size: 11.5px; + text-overflow: ellipsis; + white-space: nowrap; +} +.dashboard-money { + font-weight: 700; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.dashboard-status-tag { + display: flex; + align-items: center; + gap: 7px; +} +.dashboard-status-tag i { + width: 8px; + height: 8px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--status-color); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--status-color) 16%, transparent); +} +/* ── Recent documents ──────────────────────────────── */ +.dashboard-document-list { + display: grid; +} +.dashboard-document-list > a { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) 18px; + align-items: center; + gap: 11px; + min-height: 61px; + padding: 9px 4px; + color: inherit; + border-bottom: 1px solid var(--border-default); + text-decoration: none; + transition-property: background-color; + transition-duration: 0.15s; +} +.dashboard-document-list > a:last-child { + border-bottom: 0; +} +.dashboard-document-list > a:hover { + background: var(--surface-muted); + text-decoration: none; +} +.dashboard-document-icon { + display: grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + background: var(--surface-muted); + color: var(--text-secondary); +} +.dashboard-document-list b, +.dashboard-document-list small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dashboard-document-list b { + font-size: 13.5px; +} +.dashboard-document-list small { + margin-top: 4px; + color: var(--text-secondary); + font-size: 11.5px; + font-variant-numeric: tabular-nums; +} +.dashboard-document-list > a > .ti { + color: var(--text-muted); +} +/* ── Quick access ──────────────────────────────────── */ +.dashboard-shortcuts { + padding-bottom: 18px; +} +.dashboard-shortcut-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; +} +.dashboard-shortcut-grid > a { + display: grid; + grid-template-columns: 40px minmax(0, 1fr) 18px; + align-items: center; + gap: 12px; + min-height: 70px; + padding: 10px 16px; + color: inherit; + text-decoration: none; + border-right: 1px solid var(--border-default); + transition-property: background-color; + transition-duration: 0.15s; +} +.dashboard-shortcut-grid > a:last-child { + border-right: 0; +} +.dashboard-shortcut-grid > a:hover { + background: var(--surface-muted); + text-decoration: none; +} +.dashboard-shortcut-grid b, +.dashboard-shortcut-grid small { + display: block; +} +.dashboard-shortcut-grid b { + font-size: 13.5px; +} +.dashboard-shortcut-grid small { + margin-top: 3px; + color: var(--text-secondary); + font-size: 11.5px; + text-wrap: pretty; +} +.dashboard-shortcut-grid > a > .ti { + color: var(--text-muted); +} +.dashboard-shortcut-grid > a:active { + transform: scale(0.98); +} +/* ── Responsive ────────────────────────────────────── */ +@media (max-width: 1100px) { + .dashboard-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .dashboard-grid-top, + .dashboard-grid-bottom { + grid-template-columns: 1fr; + } + .dashboard-shortcut-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .dashboard-shortcut-grid > a:nth-child(2) { + border-right: 0; + } + .dashboard-shortcut-grid > a:nth-child(n+3) { + border-top: 1px solid var(--border-default); + } +} +@media (max-width: 680px) { + .dashboard-page-header { + align-items: flex-start; + } + .dashboard-kpis { + grid-template-columns: 1fr; + } + .dashboard-section { + padding: 16px 14px; + } + .dashboard-shortcut-grid { + grid-template-columns: 1fr; + } + .dashboard-shortcut-grid > a { + border-right: 0; + border-bottom: 1px solid var(--border-default); + } + .dashboard-shortcut-grid > a:last-child { + border-bottom: 0; + } + .dashboard-section .section-header-right { + width: auto; + margin-left: auto; + } +} +.dashboard-module-widgets { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + align-items: stretch; + gap: var(--space-4); + margin-bottom: var(--space-6); +} +.dashboard-module-widgets > * { + min-width: 0; +} +.dashboard-module-widgets > .dashboard-core-kpis, +.dashboard-module-widgets > .dashboard-section.dashboard-core-wide { + grid-column: 1 / -1; +} +/* Компактные виджеты занимают 4/12, крупные сводки — 6/12. */ +.dashboard-module-widgets > .dashboard-section { + grid-column: span 4; +} +.dashboard-module-widgets > .dashboard-section.dashboard-half { + grid-column: span 6; +} +.dashboard-module-widgets > .todo-dashboard-widget, +.dashboard-module-widgets > .reminders-dashboard, +.dashboard-module-widgets > .notes-dashboard, +.dashboard-module-widgets > .kanban-dashboard { + grid-column: 1 / -1; +} +.dashboard-module-widgets > .benchmark-dashboard, +.dashboard-module-widgets > .seo-dashboard { + grid-column: span 6; +} +@media (max-width: 1100px) { + .dashboard-module-widgets > .dashboard-section { + grid-column: span 6; + } + .dashboard-module-widgets > .todo-dashboard-widget, + .dashboard-module-widgets > .reminders-dashboard, + .dashboard-module-widgets > .notes-dashboard, + .dashboard-module-widgets > .kanban-dashboard { + grid-column: 1 / -1; + } +} +@media (max-width: 760px) { + .dashboard-module-widgets > .dashboard-section, + .dashboard-module-widgets > .dashboard-section.dashboard-half { + grid-column: 1 / -1; + } +} diff --git a/adminx/modules/Dashboard/assets/dashboard.js b/adminx/modules/Dashboard/assets/dashboard.js new file mode 100644 index 0000000..008a733 --- /dev/null +++ b/adminx/modules/Dashboard/assets/dashboard.js @@ -0,0 +1,34 @@ +/** + * JS раздела Dashboard. Подключается точечно (только на страницах модуля) + * через AdminAssets::addScript() из контроллера. Объявляет свой объект в + * неймспейсе Adminx (ТЗ §4). + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.Dashboard = { + init: function () { + // Лёгкая анимация счётчиков stat-карточек. + var nodes = document.querySelectorAll('[data-count]'); + nodes.forEach(function (node) { + var target = parseInt((node.textContent || '').replace(/\D/g, ''), 10); + if (!target || target < 1) { return; } + var start = 0; + var step = Math.max(1, Math.round(target / 24)); + var timer = setInterval(function () { + start += step; + if (start >= target) { start = target; clearInterval(timer); } + node.textContent = start.toLocaleString('ru-RU'); + }, 24); + }); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', Adminx.Dashboard.init); + } else { + Adminx.Dashboard.init(); + } +})(window, document); diff --git a/adminx/modules/Dashboard/language/en/common.xml b/adminx/modules/Dashboard/language/en/common.xml new file mode 100644 index 0000000..45ea01e --- /dev/null +++ b/adminx/modules/Dashboard/language/en/common.xml @@ -0,0 +1,36 @@ + + + Overview + Breadcrumbs + Home + System summary and recent changes + Good night + Good morning + Good afternoon + Good evening + System health + Active documents + Active users + Unresolved 404s + Database errors + Recent documents + Recently changed entries from all rubrics + All documents + Document + Rubric + State + Changed + No rubric + Published + Disabled + Edit + No documents yet + Workspace + Quick access + New document + Create a document and fill its fields + Files and images + Sections and filters + System events + Audit and errors + diff --git a/adminx/modules/Dashboard/language/en/interface.xml b/adminx/modules/Dashboard/language/en/interface.xml new file mode 100644 index 0000000..485b752 --- /dev/null +++ b/adminx/modules/Dashboard/language/en/interface.xml @@ -0,0 +1,18 @@ + + + Quick access + Dashboard + Good morning + Good night + Good evening + Good afternoon + Documents, users, 404 and SQL errors. + Documents, users, 404 and database errors. + Recent Documents + Review + Transitions to main sections. + Last modified documents in table form. + System Status + Links to the main working sections of the system. + Table of last modified documents. + diff --git a/adminx/modules/Dashboard/language/en/runtime.xml b/adminx/modules/Dashboard/language/en/runtime.xml new file mode 100644 index 0000000..5246874 --- /dev/null +++ b/adminx/modules/Dashboard/language/en/runtime.xml @@ -0,0 +1,18 @@ + + + Quick access + Dashboard + Good morning + Good night + Good evening + Good afternoon + Documents, users, 404 and SQL errors. + Documents, users, 404 and database errors. + Recent Documents + Review + Transitions to main sections. + Last modified documents in table form. + System Status + Links to the main working sections of the system. + Table of last modified documents. + diff --git a/adminx/modules/Dashboard/language/ru/common.xml b/adminx/modules/Dashboard/language/ru/common.xml new file mode 100644 index 0000000..9043de0 --- /dev/null +++ b/adminx/modules/Dashboard/language/ru/common.xml @@ -0,0 +1,36 @@ + + + Обзор + Хлебные крошки + Главная + Сводка системы и последние изменения + Доброй ночи + Доброе утро + Добрый день + Добрый вечер + Состояние системы + Активных документов + Активных пользователей + Нерешенных 404 + Ошибок базы данных + Недавние документы + Последние измененные записи всех рубрик + Все документы + Документ + Рубрика + Состояние + Изменен + Без рубрики + Опубликован + Выключен + Редактировать + Документов пока нет + Работа + Быстрый доступ + Новый документ + Создать и заполнить поля + Файлы и изображения + Разделы и фильтры + Системные события + Аудит и ошибки + diff --git a/adminx/modules/Dashboard/language/ru/interface.xml b/adminx/modules/Dashboard/language/ru/interface.xml new file mode 100644 index 0000000..27af849 --- /dev/null +++ b/adminx/modules/Dashboard/language/ru/interface.xml @@ -0,0 +1,18 @@ + + + Быстрый доступ + Дашборд + Доброе утро + Доброй ночи + Добрый вечер + Добрый день + Документы, пользователи, 404 и SQL ошибки. + Документы, пользователи, 404 и ошибки базы данных. + Недавние документы + Обзор + Переходы к основным разделам. + Последние измененные документы в виде таблицы. + Состояние системы + Ссылки на основные рабочие разделы системы. + Таблица последних измененных документов. + diff --git a/adminx/modules/Dashboard/language/ru/runtime.xml b/adminx/modules/Dashboard/language/ru/runtime.xml new file mode 100644 index 0000000..8c32cbb --- /dev/null +++ b/adminx/modules/Dashboard/language/ru/runtime.xml @@ -0,0 +1,18 @@ + + + Быстрый доступ + Дашборд + Доброе утро + Доброй ночи + Добрый вечер + Добрый день + Документы, пользователи, 404 и SQL ошибки. + Документы, пользователи, 404 и ошибки базы данных. + Недавние документы + Обзор + Переходы к основным разделам. + Последние измененные документы в виде таблицы. + Состояние системы + Ссылки на основные рабочие разделы системы. + Таблица последних измененных документов. + diff --git a/adminx/modules/Dashboard/module.php b/adminx/modules/Dashboard/module.php new file mode 100644 index 0000000..d5a13d7 --- /dev/null +++ b/adminx/modules/Dashboard/module.php @@ -0,0 +1,49 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + /** + * Модуль Dashboard — пилотный CRUD-каркас: главная страница + пункт меню. + * Декларативный дескриптор (modules.md §1); регистрацию делает ModuleManager. + */ + return [ + 'code' => 'dashboard', + 'name' => 'Дашборд', + 'version' => '0.1.0', + + 'navigation' => array( + array( + 'code' => 'dashboard', + 'label' => 'Дашборд', + 'url' => '/dashboard', + 'icon' => 'ti ti-layout-dashboard', + 'permission' => '', + 'group' => 'Обзор', + 'sort_order' => 10, + 'match' => array( + '/dashboard', + ), + ), + ), + + 'routes' => array( + array('GET', '/', array(\App\Adminx\Dashboard\Controller::class, 'index')), + array('GET', '/dashboard', array(\App\Adminx\Dashboard\Controller::class, 'index')), + ), + + 'view_globals' => [ + 'module_code' => 'dashboard', + ], + ]; diff --git a/adminx/modules/Dashboard/view/index.twig b/adminx/modules/Dashboard/view/index.twig new file mode 100644 index 0000000..a82232b --- /dev/null +++ b/adminx/modules/Dashboard/view/index.twig @@ -0,0 +1,15 @@ +{% extends '@adminx/main.twig' %} +{% block title %}{{ lang.dashboard_title|default('Обзор') }}{% endblock %} +{% block content %} + + + + +{% if dashboard_widgets %} +
    + {% for dashboard_widget in dashboard_widgets %} + {% if dashboard_widget.template %}{% include dashboard_widget.template with {module_data: dashboard_widget.data, module_widget: dashboard_widget} %}{% endif %} + {% endfor %} +
    +{% endif %} +{% endblock %} diff --git a/adminx/modules/Dashboard/view/widgets/recent-documents.twig b/adminx/modules/Dashboard/view/widgets/recent-documents.twig new file mode 100644 index 0000000..ec55d85 --- /dev/null +++ b/adminx/modules/Dashboard/view/widgets/recent-documents.twig @@ -0,0 +1,24 @@ +{% set documents = module_data.documents %} +
    +
    {{ lang.nav_group_content|default('Контент') }}

    {{ lang.dashboard_recent_documents|default('Недавние документы') }}

    {{ lang.dashboard_recent_documents_desc|default('Последние измененные записи всех рубрик') }}

    +
    + + + + + {% for document in documents %} + + + + + + + + + {% else %} + + {% endfor %} + +
    ID{{ lang.dashboard_document|default('Документ') }}{{ lang.dashboard_rubric|default('Рубрика') }}{{ lang.dashboard_state|default('Состояние') }}{{ lang.dashboard_changed|default('Изменен') }}
    #{{ document.Id }}{{ document.document_title }}/{{ document.document_alias|trim('/') }}{{ document.rubric_title ?: lang.dashboard_no_rubric|default('Без рубрики') }}{{ document.document_status == '1' ? lang.dashboard_published|default('Опубликован') : lang.dashboard_disabled|default('Выключен') }}{{ document.document_changed|date('d.m.Y H:i') }}
    {{ lang.dashboard_no_documents|default('Документов пока нет') }}
    +
    +
    diff --git a/adminx/modules/Dashboard/view/widgets/shortcuts.twig b/adminx/modules/Dashboard/view/widgets/shortcuts.twig new file mode 100644 index 0000000..312efbe --- /dev/null +++ b/adminx/modules/Dashboard/view/widgets/shortcuts.twig @@ -0,0 +1,4 @@ +
    +
    {{ lang.dashboard_work|default('Работа') }}

    {{ lang.dashboard_shortcuts|default('Быстрый доступ') }}

    + +
    diff --git a/adminx/modules/Dashboard/view/widgets/system-kpis.twig b/adminx/modules/Dashboard/view/widgets/system-kpis.twig new file mode 100644 index 0000000..ae8570b --- /dev/null +++ b/adminx/modules/Dashboard/view/widgets/system-kpis.twig @@ -0,0 +1,19 @@ +{% set stats = module_data.stats %} +
    + + + {{ lang.dashboard_active_documents|default('Активных документов') }}{{ stats.documents is null ? '—' : stats.documents }} + + + + {{ lang.dashboard_active_users|default('Активных пользователей') }}{{ stats.users is null ? '—' : stats.users }} + + + + {{ lang.dashboard_unresolved_404|default('Нерешенных 404') }}{{ stats.not_found }} + + + + {{ lang.dashboard_database_errors|default('Ошибок базы данных') }}{{ stats.sql_errors }} + +
    diff --git a/adminx/modules/Database/Backup.php b/adminx/modules/Database/Backup.php new file mode 100644 index 0000000..e6c9275 --- /dev/null +++ b/adminx/modules/Database/Backup.php @@ -0,0 +1,303 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Database; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + + /** + * Резервные копии БД: создание gzip-дампа единой рабочей схемы, + * путь и удаление. Дампы лежат в tmp/backup. Имена файлов строго валидируются, + * выход за пределы каталога исключён. + */ + class Backup + { + public static function dir() + { + return BASEPATH . DS . 'tmp' . DS . 'backup'; + } + + /** Список файлов резервных копий (name/size/mtime), новые сверху. */ + public static function all() + { + $dir = self::dir(); + if (!is_dir($dir)) { + return array(); + } + + $out = array(); + foreach (scandir($dir) as $f) { + if ($f === '.' || $f === '..' || $f[0] === '.') { + continue; + } + + if (!preg_match('/\.(sql|sql\.gz)$/i', $f)) { + continue; + } + + $path = $dir . DS . $f; + if (!is_file($path)) { + continue; + } + + $out[] = array( + 'name' => $f, + 'size' => filesize($path), + 'size_h' => Model::human(filesize($path)), + 'mtime' => filemtime($path), + 'own' => strpos($f, 'adminx_') === 0, + ); + } + + usort($out, function ($a, $b) { return $b['mtime'] - $a['mtime']; }); + return $out; + } + + /** Полный путь к файлу бэкапа по имени (или null, если имя небезопасно/нет файла). */ + public static function path($name) + { + $name = basename((string) $name); + if (!preg_match('/^[A-Za-z0-9._-]+\.(sql|sql\.gz)$/', $name)) { + return null; + } + + $path = self::dir() . DS . $name; + return is_file($path) ? $path : null; + } + + public static function delete($name) + { + $path = self::path($name); + if ($path === null) { + return false; + } + + return @unlink($path); + } + + /** Принять скачанный ранее дамп и добавить его в список после полной проверки. */ + public static function upload(array $file) + { + if (!isset($file['error']) || (int) $file['error'] !== UPLOAD_ERR_OK + || empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) { + throw new \RuntimeException('Файл резервной копии не загружен'); + } + + $size = isset($file['size']) ? (int) $file['size'] : 0; + if ($size <= 0 || $size > BackupRestore::MAX_ARCHIVE_BYTES) { + throw new \RuntimeException('Размер резервной копии недопустим'); + } + + $original = isset($file['name']) ? (string) $file['name'] : ''; + if (preg_match('/\.sql\.gz$/i', $original)) { + $extension = '.sql.gz'; + } elseif (preg_match('/\.sql$/i', $original)) { + $extension = '.sql'; + } else { + throw new \RuntimeException('Поддерживаются только файлы .sql и .sql.gz'); + } + + $dir = self::ensureDir(); + $name = self::uniqueName('adminx_uploaded_' . date('Ymd_His'), $extension); + $temporary = $dir . DS . '.upload-' . bin2hex(random_bytes(6)) . $extension; + if (!move_uploaded_file($file['tmp_name'], $temporary)) { + throw new \RuntimeException('Не удалось сохранить загруженный файл'); + } + + try { + $summary = SqlDumpReader::scan($temporary); + $path = $dir . DS . $name; + if (!@rename($temporary, $path)) { + throw new \RuntimeException('Не удалось завершить загрузку резервной копии'); + } + } catch (\Throwable $e) { + @unlink($temporary); + throw $e; + } + + return array( + 'name' => $name, + 'size' => filesize($path), + 'tables' => $summary['table_count'], + 'rows' => $summary['inserts'], + ); + } + + /** + * Создать gzip-дамп всех таблиц текущего префикса. + * @return array{name:string, size:int, tables:int} + */ + public static function create($purpose = '', callable $progress = null) + { + $dir = self::ensureDir(); + + $tables = Model::names(); + if (!$tables) { + throw new \RuntimeException('Нет таблиц для резервного копирования'); + } + + $prefixes = Model::prefixes(); + $purpose = preg_replace('/[^a-z0-9_-]+/', '', strtolower((string) $purpose)); + $name = self::uniqueName('adminx_' . ($purpose !== '' ? $purpose . '_' : '') . implode('-', $prefixes) . '_' . date('Ymd_His'), '.sql.gz'); + $path = $dir . DS . $name; + + $temporary = $path . '.part'; + $lockPath = $dir . DS . '.adminx-backup.lock'; + $lock = fopen($lockPath, 'c'); + if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) { + if ($lock) { fclose($lock); } + throw new \RuntimeException('Другая резервная копия уже создаётся'); + } + + $fp = gzopen($temporary, 'wb6'); + if (!$fp) { + flock($lock, LOCK_UN); + fclose($lock); + throw new \RuntimeException('Не удалось создать файл дампа'); + } + + gzwrite($fp, "-- adminx full dump\n-- database: " . Model::databaseName() . "\n-- prefixes: {{prefix}}\n-- source-prefix: " . Model::basePrefix() . + "\n-- date: " . date('Y-m-d H:i:s') . "\n\nSET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\n\n"); + + $mysqli = DB::mysqli(); + try { + $total = count($tables); + foreach ($tables as $index => $table) { + self::notify($progress, array( + 'table' => $table, + 'current' => $index + 1, + 'total' => $total, + 'rows' => 0, + )); + self::dumpTable($fp, $table, $mysqli, $progress, $index + 1, $total); + } + + gzwrite($fp, "\nSET FOREIGN_KEY_CHECKS=1;\n"); + gzclose($fp); + $fp = null; + if (!@rename($temporary, $path)) { + throw new \RuntimeException('Не удалось завершить файл дампа'); + } + } catch (\Throwable $e) { + if ($fp) { gzclose($fp); } + @unlink($temporary); + flock($lock, LOCK_UN); + fclose($lock); + throw $e; + } + + flock($lock, LOCK_UN); + fclose($lock); + + return array('name' => $name, 'size' => filesize($path), 'tables' => count($tables), 'prefixes' => $prefixes); + } + + protected static function dumpTable($fp, $table, $mysqli, $progress = null, $current = 0, $total = 0) + { + $portableTable = Model::portableTableName($table); + gzwrite($fp, "\n-- ----- Table `{$portableTable}` -----\nDROP TABLE IF EXISTS `{$portableTable}`;\n"); + + $create = (array) DB::query('SHOW CREATE TABLE `' . $table . '`')->getObject(); + $ddl = isset($create['Create Table']) ? $create['Create Table'] : ''; + $enumFallbacks = SqlDumpReader::enumFallbacks($ddl); + if ($ddl !== '') { + gzwrite($fp, self::portableDdl($ddl) . ";\n\n"); + } + + //-- Данные пакетами, чтобы не держать всё в памяти. + $offset = 0; + $batch = 500; + do { + $rows = DB::query('SELECT * FROM `' . $table . '` LIMIT ' . $batch . ' OFFSET ' . $offset)->getAll(); + if (!$rows) { + break; + } + + foreach ($rows as $row) { + $row = (array) $row; + $cols = array(); + $vals = array(); + foreach ($row as $col => $val) { + $cols[] = '`' . $col . '`'; + if ($val === '' && isset($enumFallbacks[$col])) { + $val = $enumFallbacks[$col]; + } + + if ($val === null) { + $vals[] = 'NULL'; + } else { + $vals[] = "'" . $mysqli->real_escape_string((string) $val) . "'"; + } + } + + gzwrite($fp, 'INSERT INTO `' . $portableTable . '` (' . implode(',', $cols) . ') VALUES (' . implode(',', $vals) . ");\n"); + } + + $offset += $batch; + if ($offset === $batch || $offset % 5000 === 0 || count($rows) < $batch) { + self::notify($progress, array( + 'table' => $table, + 'current' => (int) $current, + 'total' => (int) $total, + 'rows' => $offset, + )); + } + } while (count($rows) === $batch); + + gzwrite($fp, "\n"); + } + + /** Заменяет префикс только внутри SQL-идентификаторов, не затрагивая данные строк. */ + protected static function portableDdl($ddl) + { + $ddl = preg_replace_callback('/`([^`]+)`/', function ($match) { + return '`' . Model::portableTableName($match[1]) . '`'; + }, (string) $ddl); + + //-- MariaDB выводит DEFAULT NULL у TEXT/BLOB, тогда как MySQL 5.7 + //-- запрещает default для этих типов. NULL и без clause остаётся default. + return preg_replace_callback( + '/^(\s*`[^`]+`\s+(?:tiny|medium|long)?(?:text|blob)\b[^,\r\n]*?)\s+DEFAULT\s+NULL(\s*,?)$/mi', + function ($match) { return $match[1] . $match[2]; }, + $ddl + ); + } + + protected static function ensureDir() + { + $dir = self::dir(); + if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { + throw new \RuntimeException('Каталог бэкапов недоступен для записи'); + } + + return $dir; + } + + protected static function uniqueName($base, $extension) + { + $name = (string) $base . (string) $extension; + if (is_file(self::dir() . DS . $name)) { + $name = (string) $base . '_' . bin2hex(random_bytes(3)) . (string) $extension; + } + + return $name; + } + + protected static function notify($progress, array $payload) + { + if ($progress) { call_user_func($progress, $payload); } + } + } diff --git a/adminx/modules/Database/BackupRestore.php b/adminx/modules/Database/BackupRestore.php new file mode 100644 index 0000000..bd151ef --- /dev/null +++ b/adminx/modules/Database/BackupRestore.php @@ -0,0 +1,426 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Database; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\Cache; + use App\Common\CompiledModuleRegistry; + use App\Common\Session; + use App\Common\Twig; + use DB; + + /** Проверяет и восстанавливает полный дамп с обязательной страховочной копией. */ + class BackupRestore + { + const MAX_ARCHIVE_BYTES = 536870912; + const INSPECTION_SESSION_KEY = 'database_backup_inspection'; + const RUN_SESSION_KEY = 'database_backup_restore_run'; + + public static function inspect($name, $includeToken = true) + { + $path = Backup::path($name); + if ($path === null || strpos(basename($path), 'adminx_') !== 0) { + throw new \RuntimeException('Для восстановления выберите полный дамп, созданный AVE.cms'); + } + + $size = (int) filesize($path); + if ($size <= 0 || $size > self::MAX_ARCHIVE_BYTES) { + throw new \RuntimeException('Размер резервной копии недопустим'); + } + + $summary = SqlDumpReader::scan($path); + $summary['name'] = basename($path); + $summary['size'] = $size; + $summary['size_h'] = Model::human($size); + $summary['hash'] = hash_file('sha256', $path); + $summary['mtime'] = (int) filemtime($path); + $summary['current_database'] = Model::databaseName(); + $summary['current_prefixes'] = implode(', ', Model::prefixes()); + $summary['warnings'] = self::warnings($summary); + if ($includeToken) { + $summary['token'] = self::token($summary); + Session::set(self::INSPECTION_SESSION_KEY, $summary); + } + + return $summary; + } + + public static function prepare($name, $token, $actorId) + { + $summary = Session::get(self::INSPECTION_SESSION_KEY); + if (!is_array($summary) || !isset($summary['name']) || !hash_equals((string) $summary['name'], basename((string) $name))) { + throw new \RuntimeException('Проверка резервной копии устарела. Выполните её повторно'); + } + + if (!hash_equals(self::token($summary), (string) $token)) { + throw new \RuntimeException('Проверка резервной копии устарела. Выполните её повторно'); + } + + $path = Backup::path($summary['name']); + if ($path === null || (int) filesize($path) !== (int) $summary['size'] + || (int) filemtime($path) !== (int) $summary['mtime'] + || !hash_equals((string) $summary['hash'], (string) hash_file('sha256', $path))) { + throw new \RuntimeException('Файл изменился после проверки. Выполните проверку повторно'); + } + + $id = date('YmdHis') . '-' . bin2hex(random_bytes(6)); + $runToken = bin2hex(random_bytes(24)); + $job = array( + 'id' => $id, + 'actor_id' => (int) $actorId, + 'status' => 'prepared', + 'stage' => 'prepare', + 'progress' => 2, + 'title' => 'Восстановление подготовлено', + 'detail' => 'Ожидание запуска защищённого процесса', + 'table' => '', + 'current' => 0, + 'total' => (int) $summary['table_count'], + 'error' => '', + 'result' => array(), + 'created_at' => date(DATE_ATOM), + 'updated_at' => date(DATE_ATOM), + ); + self::saveJob($job); + Session::set(self::RUN_SESSION_KEY, array( + 'job_id' => $id, + 'token_hash' => hash('sha256', $runToken), + 'summary' => $summary, + )); + Session::del(self::INSPECTION_SESSION_KEY); + + return array('job' => $job, 'run_token' => $runToken); + } + + public static function status($id, $actorId) + { + $job = self::loadJob($id); + if ((int) $job['actor_id'] !== (int) $actorId) { + throw new \RuntimeException('Задание восстановления принадлежит другому пользователю'); + } + + return $job; + } + + public static function restore($jobId, $token, callable $progress = null) + { + $run = Session::get(self::RUN_SESSION_KEY); + if (!is_array($run) || empty($run['job_id']) || !hash_equals((string) $run['job_id'], (string) $jobId) + || empty($run['token_hash']) || !hash_equals((string) $run['token_hash'], hash('sha256', (string) $token)) + || empty($run['summary']) || !is_array($run['summary'])) { + throw new \RuntimeException('Запуск восстановления устарел. Проверьте копию повторно'); + } + + $summary = $run['summary']; + $job = self::loadJob($jobId); + Session::del(self::RUN_SESSION_KEY); + $emit = function (array $payload) use (&$job, $progress) { + $job = array_merge($job, $payload); + $job['updated_at'] = date(DATE_ATOM); + self::saveJob($job); + if ($progress) { call_user_func($progress, $job); } + }; + $emit(array('status' => 'running', 'stage' => 'verify', 'progress' => 5, 'title' => 'Проверяем исходный файл', 'detail' => $summary['name'])); + + $lockPath = Backup::dir() . DS . '.adminx-restore.lock'; + $lock = fopen($lockPath, 'c'); + if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) { + if ($lock) { fclose($lock); } + $message = 'Другое восстановление уже выполняется'; + $emit(array('status' => 'failed', 'stage' => 'failed', 'progress' => 100, 'title' => 'Восстановление не запущено', 'detail' => $message, 'error' => $message)); + throw new \RuntimeException($message); + } + + $safety = null; + try { + $emit(array('stage' => 'backup', 'progress' => 10, 'title' => 'Создаём страховочную копию', 'detail' => 'Рабочая база пока не изменяется')); + $safety = Backup::create('before_restore', function (array $state) use ($emit) { + $total = max(1, (int) $state['total']); + $percent = 10 + (int) floor(((max(1, (int) $state['current']) - 1) / $total) * 25); + $emit(array( + 'stage' => 'backup', + 'progress' => $percent, + 'title' => 'Страховочная копия', + 'detail' => (string) $state['table'] . (!empty($state['rows']) ? ', строк: ' . (int) $state['rows'] : ''), + 'table' => (string) $state['table'], + 'current' => (int) $state['current'], + 'total' => (int) $state['total'], + )); + }); + $path = Backup::path($summary['name']); + if ($path === null || !hash_equals($summary['hash'], hash_file('sha256', $path))) { + throw new \RuntimeException('Файл изменился после проверки. Восстановление отменено'); + } + + $emit(array('stage' => 'clean', 'progress' => 37, 'title' => 'Подготавливаем рабочую схему', 'detail' => 'Удаляем таблицы текущей установки', 'table' => '', 'current' => 0)); + self::dropCurrentTables(function ($table, $current, $total) use ($emit) { + $emit(array( + 'stage' => 'clean', + 'progress' => 37 + (int) floor(($current / max(1, $total)) * 6), + 'title' => 'Подготавливаем рабочую схему', + 'detail' => $table, + 'table' => $table, + 'current' => $current, + 'total' => $total, + )); + }); + $emit(array('stage' => 'restore', 'progress' => 44, 'title' => 'Восстанавливаем таблицы', 'detail' => 'Начинаем импорт резервной копии', 'current' => 0, 'total' => (int) $summary['table_count'])); + self::apply($path, (int) $summary['table_count'], function ($table, $current, $total, $statements) use ($emit) { + $emit(array( + 'stage' => 'restore', + 'progress' => 44 + (int) floor(($current / max(1, $total)) * 45), + 'title' => 'Восстанавливаем таблицы', + 'detail' => $table . ', SQL-команд: ' . $statements, + 'table' => $table, + 'current' => $current, + 'total' => $total, + )); + }); + $emit(array('stage' => 'cache', 'progress' => 92, 'title' => 'Обновляем runtime', 'detail' => 'Очищаем кеши и реестр модулей', 'table' => '', 'current' => 0)); + self::clearRuntimeCaches(); + } catch (\Throwable $e) { + $originalMessage = $e->getMessage(); + $rollbackOk = false; + if ($safety && !empty($safety['name'])) { + try { + $emit(array('stage' => 'rollback', 'progress' => 94, 'title' => 'Возвращаем исходную базу', 'detail' => $originalMessage, 'table' => '', 'current' => 0)); + self::dropCurrentTables(); + self::apply(Backup::path($safety['name']), 0, function ($table) use ($emit) { + $emit(array('stage' => 'rollback', 'progress' => 96, 'title' => 'Возвращаем исходную базу', 'detail' => $table, 'table' => $table)); + }); + self::clearRuntimeCaches(); + $rollbackOk = true; + } catch (\Throwable $rollbackError) { + error_log('Database backup rollback failed: ' . $rollbackError->getMessage()); + } + } + + error_log('Database backup restore failed: ' . $e->getMessage()); + $message = $rollbackOk + ? 'Восстановление не выполнено: ' . $originalMessage . '. Исходная база возвращена из страховочной копии' + : 'Восстановление прервано: ' . $originalMessage . '. Автоматический откат не выполнен; используйте страховочный бэкап'; + $emit(array('status' => 'failed', 'stage' => 'failed', 'progress' => 100, 'title' => 'Восстановление остановлено', 'detail' => $message, 'error' => $message)); + throw new \RuntimeException($message); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + + $result = array( + 'name' => $summary['name'], + 'tables' => $summary['table_count'], + 'rows' => $summary['inserts'], + 'safety_backup' => isset($safety['name']) ? $safety['name'] : '', + ); + $emit(array('status' => 'completed', 'stage' => 'complete', 'progress' => 100, 'title' => 'База восстановлена', 'detail' => 'Все таблицы импортированы и кеш очищен', 'result' => $result)); + + return $result; + } + + protected static function apply($path, $expectedTables = 0, callable $progress = null) + { + if ($path === null || !is_file($path)) { + throw new \RuntimeException('Файл резервной копии не найден'); + } + + $mysqli = DB::mysqli(); + $insertBatch = array(); + $insertBytes = 0; + $insertTable = ''; + $currentTable = ''; + $enumFallbacks = array(); + $tableIndex = 0; + $statements = 0; + $flush = function () use ($mysqli, &$insertBatch, &$insertBytes, &$insertTable) { + if ($insertBatch) { self::executeInsertBatch($mysqli, $insertBatch, $insertTable); } + $insertBatch = array(); + $insertBytes = 0; + $insertTable = ''; + }; + SqlDumpReader::scan($path, function ($statement, $info) use ($mysqli, &$insertBatch, &$insertBytes, &$insertTable, &$currentTable, &$enumFallbacks, &$tableIndex, &$statements, $expectedTables, $progress, $flush) { + $table = isset($info['table']) ? (string) $info['table'] : ''; + if ($table !== '' && $table !== $currentTable) { + $flush(); + $currentTable = $table; + $enumFallbacks = array(); + $tableIndex++; + $statements = 0; + } + + $statements++; + if ($progress && $table !== '' && ($statements === 1 || $statements % 500 === 0)) { + call_user_func($progress, $table, $tableIndex, max($tableIndex, (int) $expectedTables), $statements); + } + + if ($info['type'] === 'create') { + $enumFallbacks = SqlDumpReader::enumFallbacks($statement); + } elseif ($info['type'] === 'insert') { + $statement = SqlDumpReader::normalizeEnumInsert($statement, $enumFallbacks); + $insertTable = $table; + $insertBatch[] = $statement; + $insertBytes += strlen($statement); + if (count($insertBatch) >= 200 || $insertBytes >= 2097152) { $flush(); } + return; + } + + $flush(); + if (!$mysqli->query($statement)) { + throw new \RuntimeException(($table !== '' ? 'таблица ' . $table . ': ' : '') . $mysqli->error); + } + }); + $flush(); + } + + protected static function executeInsertBatch($mysqli, array $statements, $table = '') + { + if (count($statements) === 1) { + if (!$mysqli->query($statements[0])) { + throw new \RuntimeException(($table !== '' ? 'таблица ' . $table . ': ' : '') . $mysqli->error); + } + + return; + } + + if (!$mysqli->multi_query(implode(";\n", $statements))) { + throw new \RuntimeException(($table !== '' ? 'таблица ' . $table . ': ' : '') . $mysqli->error); + } + + do { + $result = $mysqli->store_result(); + if ($result) { $result->free(); } + if (!$mysqli->more_results()) { break; } + } while ($mysqli->next_result()); + + if ($mysqli->errno) { + throw new \RuntimeException(($table !== '' ? 'таблица ' . $table . ': ' : '') . $mysqli->error); + } + } + + /** Удаляет текущий набор, чтобы восстановление не оставляло таблицы из другой версии. */ + protected static function dropCurrentTables(callable $progress = null) + { + $mysqli = DB::mysqli(); + if (!$mysqli->query('SET FOREIGN_KEY_CHECKS=0')) { + throw new \RuntimeException('Не удалось отключить проверку внешних ключей'); + } + + try { + $tables = Model::names(); + $total = count($tables); + foreach ($tables as $index => $table) { + if (!$mysqli->query('DROP TABLE IF EXISTS `' . $table . '`')) { + throw new \RuntimeException('Не удалось подготовить текущую схему к восстановлению'); + } + + if ($progress) { call_user_func($progress, $table, $index + 1, $total); } + } + } finally { + $mysqli->query('SET FOREIGN_KEY_CHECKS=1'); + } + } + + protected static function token(array $summary) + { + $payload = implode('|', array( + $summary['name'], + $summary['hash'], + $summary['size'], + isset($summary['mtime']) ? $summary['mtime'] : 0, + $summary['current_database'], + $summary['current_prefixes'], + )); + + return hash_hmac('sha256', $payload, (string) Session::csrfToken()); + } + + protected static function warnings(array $summary) + { + $warnings = array(); + if ($summary['database'] !== '' && $summary['database'] !== $summary['current_database']) { + $warnings[] = 'Дамп создан в базе «' . $summary['database'] . '», текущая база — «' . $summary['current_database'] . '».'; + } + + if ($summary['prefixes'] !== '' && $summary['prefixes'] !== '{{prefix}}' + && $summary['prefixes'] !== $summary['current_prefixes']) { + $warnings[] = 'Префикс в заголовке отличается от текущего; фактические имена таблиц уже проверены.'; + } + + return $warnings; + } + + protected static function clearRuntimeCaches() + { + Cache::flush(); + CompiledModuleRegistry::invalidate(); + Twig::resetInstance(); + foreach (array('sql', 'twig', 'tpl', 'content-snapshots', 'module', 'feeds') as $directory) { + self::removeContents(BASEPATH . DS . 'tmp' . DS . 'cache' . DS . $directory); + } + } + + protected static function jobRoot() + { + $root = BASEPATH . DS . 'storage' . DS . 'database-restore' . DS . 'jobs'; + if (!is_dir($root) && !@mkdir($root, 0750, true) && !is_dir($root)) { + throw new \RuntimeException('Не удалось создать каталог заданий восстановления'); + } + + return $root; + } + + protected static function jobFile($id) + { + $id = trim((string) $id); + if (!preg_match('/^[0-9]{14}-[a-f0-9]{12}$/', $id)) { + throw new \InvalidArgumentException('Некорректный идентификатор восстановления'); + } + + return self::jobRoot() . DS . $id . '.json'; + } + + protected static function loadJob($id) + { + $file = self::jobFile($id); + $job = is_file($file) ? json_decode((string) file_get_contents($file), true) : null; + if (!is_array($job) || empty($job['id'])) { throw new \RuntimeException('Задание восстановления не найдено'); } + return $job; + } + + protected static function saveJob(array $job) + { + $file = self::jobFile($job['id']); + $temporary = $file . '.tmp-' . bin2hex(random_bytes(4)); + $content = json_encode($job, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + if (file_put_contents($temporary, $content, LOCK_EX) === false || !@rename($temporary, $file)) { + @unlink($temporary); + throw new \RuntimeException('Не удалось сохранить состояние восстановления'); + } + + @chmod($file, 0600); + } + + protected static function removeContents($directory) + { + if (!is_dir($directory)) { return; } + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + $item->isDir() && !$item->isLink() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + } + } diff --git a/adminx/modules/Database/Controller.php b/adminx/modules/Database/Controller.php new file mode 100644 index 0000000..6573d1d --- /dev/null +++ b/adminx/modules/Database/Controller.php @@ -0,0 +1,299 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Database; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\AuditLog; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\ModuleManager; + use App\Common\Permission; + use App\Helpers\Request; + use App\Helpers\Response; + + /** + * База данных: таблицы + обслуживание + резервные копии. Мутации — ajax по + * контракту success/error с CSRF и правом manage_database. Операции только над + * таблицами текущего префикса. + */ + class Controller extends BaseController + { + /** GET /database */ + public function index(array $params = []) + { + AdminAssets::addStyle($this->base() . '/modules/Database/assets/database.css', 50); + AdminAssets::addScript($this->base() . '/modules/Database/assets/database.js', 50); + + return $this->render('@database/index.twig', [ + 'tables' => Model::tables(), + 'stats' => Model::stats(), + 'backups' => Backup::all(), + 'schema' => ModuleManager::coreSchemaStatus(), + 'can_manage' => Permission::check('manage_database'), + ]); + } + + /** POST /database/maintenance — OPTIMIZE/REPAIR/ANALYZE (table='*' = все). */ + public function maintenance(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $op = Request::postStr('op'); + $table = Request::postStr('table'); + + if ($table === '*') { + $ok = 0; $fail = 0; + foreach (Model::names() as $name) { + $r = Model::maintenance($op, $name); + $r['ok'] ? $ok++ : $fail++; + } + + if ($ok === 0 && $fail === 0) { + return $this->error('Нет таблиц для обработки', [], 422); + } + + return $this->success('Готово: ' . $ok . ' таблиц' . ($fail ? ', ошибок: ' . $fail : '')); + } + + $result = Model::maintenance($op, $table); + if (!$result['ok']) { + return $this->error($result['table'] . ': ' . $result['message'], [], 422); + } + + return $this->success($result['table'] . ': ' . $result['message'], [ + 'data' => ['table' => $result['table']], + ]); + } + + /** POST /database/backup — создать дамп. */ + public function backupCreate(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + try { + $info = Backup::create(); + } catch (\Throwable $e) { + return $this->error('Не удалось создать бэкап: ' . $e->getMessage(), [], 500); + } + + return $this->success('Бэкап создан: ' . $info['name'] . ' (' . $info['tables'] . ' таблиц)', [ + 'redirect' => $this->base() . '/database?tab=backups', + ]); + } + + /** POST /database/backup/upload — загрузить и проверить внешний файл дампа. */ + public function backupUpload(array $params = array()) + { + if (($resp = $this->guard()) !== null) { return $resp; } + try { + $info = Backup::upload(isset($_FILES['backup']) ? (array) $_FILES['backup'] : array()); + return $this->success('Копия загружена и проверена: ' . $info['name'], array( + 'data' => $info, + 'redirect' => $this->base() . '/database?tab=backups', + )); + } catch (\Throwable $e) { + return $this->error('Не удалось загрузить копию: ' . $e->getMessage(), array(), 422); + } + } + + /** GET /database/backup/download?file=... — скачать дамп. */ + public function backupDownload(array $params = []) + { + if (!Permission::check('view_database')) { + Response::forbidden(); + return null; + } + + $path = Backup::path(Request::getStr('file')); + if ($path === null) { + Response::notFound('Файл не найден'); + return null; + } + + $contentType = substr($path, -3) === '.gz' ? 'application/gzip' : 'application/sql'; + Response::download($path, basename($path), $contentType); + return null; + } + + /** POST /database/backup/delete — удалить дамп. */ + public function backupDelete(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $file = Request::postStr('file'); + if (!Backup::delete($file)) { + return $this->error('Не удалось удалить файл', [], 422); + } + + return $this->success('Бэкап удалён'); + } + + /** POST /database/backup/inspect — проверить состав дампа до подтверждения. */ + public function backupInspect(array $params = array()) + { + if (($resp = $this->guard()) !== null) { return $resp; } + try { + $summary = BackupRestore::inspect(Request::postStr('file')); + return $this->success('Резервная копия проверена', array('data' => $summary)); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + } + + /** POST /database/backup/restore/start — подтвердить и подготовить потоковое восстановление. */ + public function backupRestoreStart(array $params = array()) + { + if (($resp = $this->guard()) !== null) { return $resp; } + $file = Request::postStr('file'); + try { + $prepared = BackupRestore::prepare($file, Request::postStr('restore_token'), Auth::id()); + return $this->success('Восстановление подготовлено', array('data' => $prepared)); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + } + + /** POST /database/backup/restore/run — NDJSON-поток фактического восстановления. */ + public function backupRestoreRun(array $params = array()) + { + if (($resp = $this->guard()) !== null) { return $resp; } + $jobId = Request::postStr('job_id'); + $runToken = Request::postStr('run_token'); + @set_time_limit(0); + ignore_user_abort(true); + header('Content-Type: application/x-ndjson; charset=UTF-8'); + header('Cache-Control: no-cache, no-store, must-revalidate'); + header('X-Accel-Buffering: no'); + @ini_set('output_buffering', '0'); + @ini_set('zlib.output_compression', '0'); + while (ob_get_level() > 0) { @ob_end_flush(); } + ob_implicit_flush(true); + $emit = function (array $payload) { + echo json_encode(array('type' => 'progress', 'job' => $payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + @ob_flush(); + flush(); + }; + + try { + $result = BackupRestore::restore($jobId, $runToken, $emit); + try { + $user = Auth::user(); + AuditLog::record('database.backup_restored', array( + 'actor_id' => Auth::id(), + 'actor_name' => $user && isset($user['name']) ? $user['name'] : '', + 'target_type' => 'database_backup', + 'target_id' => null, + 'meta' => $result, + )); + } catch (\Throwable $auditError) { + error_log('Database restore audit failed: ' . $auditError->getMessage()); + } + + echo json_encode(array( + 'type' => 'complete', + 'message' => 'База восстановлена из ' . $result['name'], + 'result' => $result, + 'redirect' => $this->base() . '/database?tab=backups', + ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + } catch (\Throwable $e) { + try { + AuditLog::record('database.backup_restore_failed', array( + 'actor_id' => Auth::id(), + 'target_type' => 'database_backup', + 'target_id' => null, + 'meta' => array('job_id' => $jobId, 'reason' => $e->getMessage()), + )); + } catch (\Throwable $auditError) { + error_log('Database restore failure audit failed: ' . $auditError->getMessage()); + } + + echo json_encode(array('type' => 'error', 'message' => $e->getMessage()), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + } + + return null; + } + + /** POST /database/backup/restore/status — состояние на случай обрыва потока. */ + public function backupRestoreStatus(array $params = array()) + { + if (($resp = $this->guard()) !== null) { return $resp; } + try { + return $this->success('Состояние восстановления', array( + 'data' => array('job' => BackupRestore::status(Request::postStr('job_id'), Auth::id())), + )); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 404); + } + } + + /** POST /database/engines/innodb — завершить перевод рабочей схемы на InnoDB. */ + public function convertToInnoDb(array $params = array()) + { + if (($resp = $this->guard()) !== null) { return $resp; } + try { + $result = Model::convertToInnoDb(); + $converted = count($result['converted']); + $failed = count($result['failed']); + if ($failed > 0) { + return $this->error('Переведено: ' . $converted . ', ошибок: ' . $failed, array('data' => $result), 422); + } + + return $this->success($converted > 0 ? 'Таблицы переведены в InnoDB: ' . $converted : 'Таблиц MyISAM не осталось', array('data' => $result)); + } catch (\Throwable $e) { + return $this->error('Не удалось изменить движок таблиц: ' . $e->getMessage(), array(), 422); + } + } + + /** POST /database/schema/update — применить новые миграции ядровых разделов. */ + public function updateSchema(array $params = array()) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + try { + $results = ModuleManager::updateCoreSchema(); + $applied = 0; + foreach ($results as $result) { + if (isset($result['status']) && $result['status'] === 'applied') { + $applied++; + } + } + + $permissions = Permission::syncRegistryToDb(); + + return $this->success($applied > 0 + ? 'Применено миграций ядра: ' . $applied + : 'Все миграции ядра уже применены', array('data' => array('applied' => $applied, 'permissions' => $permissions))); + } catch (\Throwable $e) { + return $this->error('Не удалось применить миграции ядра: ' . $e->getMessage(), array(), 422); + } + } + + // ------------------------------------------------------------------ // + + protected function guard() + { + return $this->guardPermission('manage_database'); + } + } diff --git a/adminx/modules/Database/Model.php b/adminx/modules/Database/Model.php new file mode 100644 index 0000000..c95596d --- /dev/null +++ b/adminx/modules/Database/Model.php @@ -0,0 +1,247 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Database; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Common\DatabaseConfiguration; + use App\Common\PublicConfiguration; + use App\Common\SystemTables; + + /** + * Обслуживание БД: список таблиц текущего префикса, их размеры и операции + * OPTIMIZE/REPAIR/ANALYZE. Все операции — только над таблицами native-доменов + * (имя валидируется по фактическому списку, чужие/legacy-таблицы недоступны). + */ + class Model + { + /** Таблицы текущего префикса со статусом (размер/строки/движок). */ + public static function tables() + { + $out = array(); + $seen = array(); + foreach (self::prefixes() as $prefix) { + $rows = DB::query('SHOW TABLE STATUS LIKE %s', $prefix . '\_%')->getAll(); + foreach ($rows ?: array() as $row) { + $row = (array) $row; + $name = (string) $row['Name']; + if (strpos($name, self::stagePrefix() . '_stage_') === 0 || isset($seen[$name])) { continue; } + $seen[$name] = true; + $data = (int) $row['Data_length']; + $index = (int) $row['Index_length']; + $out[] = array( + 'name' => $name, + 'prefix' => $prefix, + 'engine' => (string) $row['Engine'], + 'is_myisam' => strcasecmp((string) $row['Engine'], 'MyISAM') === 0, + 'rows' => (int) $row['Rows'], + 'size' => $data + $index, + 'size_h' => self::human($data + $index), + 'data' => $data, + 'index' => $index, + 'collation' => (string) (isset($row['Collation']) ? $row['Collation'] : ''), + ); + } + } + + return $out; + } + + /** Множество валидных имён таблиц текущего префикса. */ + public static function names() + { + $out = array(); + foreach (self::prefixes() as $prefix) { + $rows = DB::query('SHOW TABLES LIKE %s', $prefix . '\_%')->getAll(); + foreach ($rows ?: array() as $row) { + $name = (string) array_values((array) $row)[0]; + if (strpos($name, self::stagePrefix() . '_stage_') === 0) { continue; } + $out[] = $name; + } + } + + return array_values(array_unique($out)); + } + + public static function prefixes() + { + return self::nativePrefixes(); + } + + /** Базовый префикс из configs/db.config.php, совместимый с {{prefix}} installer. */ + public static function basePrefix() + { + return DatabaseConfiguration::prefix(); + } + + public static function portableTableName($name) + { + $name = (string) $name; + $prefix = self::basePrefix() . '_'; + return strpos($name, $prefix) === 0 ? '{{prefix}}_' . substr($name, strlen($prefix)) : $name; + } + + public static function isValidTable($name) + { + return in_array((string) $name, self::names(), true); + } + + /** Разрешённое имя таблицы, включая ещё не созданную таблицу из дампа. */ + public static function isManagedTableName($name) + { + $name = (string) $name; + if (!preg_match('/^[A-Za-z0-9_]+$/', $name) + || strpos($name, self::stagePrefix() . '_stage_') === 0) { + return false; + } + + foreach (self::prefixes() as $prefix) { + if (strpos($name, $prefix . '_') === 0) { return true; } + } + + return false; + } + + /** Сводка по префиксу: таблиц / размер / строк / имя БД. */ + public static function stats() + { + $tables = self::tables(); + $size = 0; + $rows = 0; + $innodb = 0; + $myisam = 0; + foreach ($tables as $t) { + $size += $t['size']; + $rows += $t['rows']; + if (strcasecmp($t['engine'], 'InnoDB') === 0) { $innodb++; } + if (strcasecmp($t['engine'], 'MyISAM') === 0) { $myisam++; } + } + + return array( + 'tables' => count($tables), + 'size' => $size, + 'size_h' => self::human($size), + 'rows' => $rows, + 'innodb' => $innodb, + 'myisam' => $myisam, + 'database' => self::databaseName(), + 'prefix' => implode(', ', self::prefixes()), + ); + } + + /** Человекочитаемый размер. */ + public static function human($bytes) + { + $bytes = (int) $bytes; + $units = array('Б', 'КБ', 'МБ', 'ГБ', 'ТБ'); + $i = 0; + $val = $bytes; + while ($val >= 1024 && $i < count($units) - 1) { + $val /= 1024; + $i++; + } + + return ($i === 0 ? $val : number_format($val, 1, ',', ' ')) . ' ' . $units[$i]; + } + + public static function databaseName() + { + try { + return (string) DB::query('SELECT DATABASE()')->getValue(); + } catch (\Throwable $e) { + return ''; + } + } + + /** Выполнить maintenance-операцию над одной таблицей. */ + public static function maintenance($op, $table) + { + if (!self::isValidTable($table)) { + return array('ok' => false, 'table' => $table, 'message' => 'Неизвестная таблица'); + } + + $keyword = self::opKeyword($op); + if ($keyword === null) { + return array('ok' => false, 'table' => $table, 'message' => 'Неизвестная операция'); + } + + try { + $res = DB::query($keyword . ' TABLE `' . $table . '`')->getAll(); + $last = $res ? (array) end($res) : array(); + $msg = isset($last['Msg_text']) ? (string) $last['Msg_text'] : 'OK'; + return array('ok' => true, 'table' => $table, 'message' => $msg); + } catch (\Throwable $e) { + return array('ok' => false, 'table' => $table, 'message' => $e->getMessage()); + } + } + + /** Перевести таблицы текущей схемы из MyISAM в InnoDB. */ + public static function convertToInnoDb() + { + $converted = array(); + $failed = array(); + foreach (self::tables() as $table) { + if (!$table['is_myisam']) { continue; } + try { + DB::query('ALTER TABLE `' . $table['name'] . '` ENGINE=InnoDB'); + $converted[] = $table['name']; + } catch (\Throwable $e) { + $failed[$table['name']] = $e->getMessage(); + } + } + + return array('converted' => $converted, 'failed' => $failed); + } + + protected static function opKeyword($op) + { + $map = array('optimize' => 'OPTIMIZE', 'repair' => 'REPAIR', 'analyze' => 'ANALYZE', 'check' => 'CHECK'); + return isset($map[$op]) ? $map[$op] : null; + } + + public static function stagePrefix() + { + return SystemTables::prefix(); + } + + protected static function nativePrefixes() + { + $prefixes = array(); + foreach (array('system', 'content', 'catalog', 'module', 'basket', 'contacts', 'public_user', 'public_shell') as $domain) { + $prefixes[] = PublicConfiguration::prefix($domain); + } + + return self::compactPrefixes($prefixes); + } + + /** Remove duplicate/nested prefixes because SHOW LIKE parent_ already includes them. */ + protected static function compactPrefixes(array $prefixes) + { + $prefixes = array_values(array_unique(array_map('strval', $prefixes))); + usort($prefixes, function ($left, $right) { return strlen($left) - strlen($right); }); + $out = array(); + foreach ($prefixes as $prefix) { + $covered = false; + foreach ($out as $parent) { + if ($prefix === $parent || strpos($prefix, $parent . '_') === 0) { $covered = true; break; } + } + + if (!$covered) { $out[] = $prefix; } + } + + return $out; + } + } diff --git a/adminx/modules/Database/SqlDumpReader.php b/adminx/modules/Database/SqlDumpReader.php new file mode 100644 index 0000000..fe861ca --- /dev/null +++ b/adminx/modules/Database/SqlDumpReader.php @@ -0,0 +1,324 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Database; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + /** Потоково разбирает собственный SQL-дамп без загрузки файла целиком в память. */ + class SqlDumpReader + { + const MAX_UNCOMPRESSED_BYTES = 1073741824; + const MAX_STATEMENT_BYTES = 67108864; + const MAX_STATEMENTS = 5000000; + + public static function scan($path, callable $statementHandler = null) + { + $gzip = substr((string) $path, -3) === '.gz'; + $handle = $gzip ? @gzopen($path, 'rb') : @fopen($path, 'rb'); + if (!$handle) { + throw new \RuntimeException('Не удалось прочитать файл резервной копии'); + } + + $meta = array( + 'format' => '', + 'database' => '', + 'prefixes' => '', + 'date' => '', + ); + $summary = array( + 'bytes' => 0, + 'statements' => 0, + 'inserts' => 0, + 'tables' => array(), + ); + $buffer = ''; + $quote = ''; + $escaped = false; + + try { + do { + $line = $gzip ? gzgets($handle, 1048576) : fgets($handle, 1048576); + if ($line === false) { break; } + $summary['bytes'] += strlen($line); + if ($summary['bytes'] > self::MAX_UNCOMPRESSED_BYTES) { + throw new \RuntimeException('Распакованный дамп превышает допустимый размер'); + } + + if ($quote === '' && trim($buffer) === '' && strpos(ltrim($line), '--') === 0) { + self::readMetadata($line, $meta); + continue; + } + + $length = strlen($line); + for ($i = 0; $i < $length; $i++) { + $character = $line[$i]; + if ($quote !== '') { + $buffer .= $character; + if ($escaped) { + $escaped = false; + } elseif ($character === '\\') { + $escaped = true; + } elseif ($character === $quote) { + $quote = ''; + } + } elseif ($character === "'" || $character === '"' || $character === '`') { + $quote = $character; + $buffer .= $character; + } elseif ($character === ';') { + self::acceptStatement($buffer, $summary, $statementHandler); + $buffer = ''; + } else { + $buffer .= $character; + } + + if (strlen($buffer) > self::MAX_STATEMENT_BYTES) { + throw new \RuntimeException('Одна команда дампа превышает допустимый размер'); + } + } + } while (true); + } finally { + $gzip ? gzclose($handle) : fclose($handle); + } + + if ($quote !== '' || trim($buffer) !== '') { + throw new \RuntimeException('SQL-дамп оборван или содержит незавершённую команду'); + } + + if ($meta['format'] !== 'adminx full dump') { + throw new \RuntimeException('Файл не является полным дампом AVE.cms'); + } + + if (!$summary['tables']) { + throw new \RuntimeException('В резервной копии нет таблиц'); + } + + foreach ($summary['tables'] as $table => $state) { + if (empty($state['drop']) || empty($state['create'])) { + throw new \RuntimeException('Неполное описание таблицы в дампе: ' . $table); + } + } + + $summary['table_names'] = array_keys($summary['tables']); + $summary['table_count'] = count($summary['tables']); + unset($summary['tables']); + + return array_merge($meta, $summary); + } + + public static function statementInfo($statement) + { + $statement = trim((string) $statement); + if (preg_match('/^SET\s+NAMES\s+utf8mb4$/i', $statement)) { + return array('type' => 'set', 'table' => ''); + } + + if (preg_match('/^SET\s+FOREIGN_KEY_CHECKS\s*=\s*[01]$/i', $statement)) { + return array('type' => 'set', 'table' => ''); + } + + $patterns = array( + 'drop' => '/^DROP\s+TABLE\s+IF\s+EXISTS\s+`([A-Za-z0-9_]+)`$/i', + 'create' => '/^CREATE\s+TABLE\s+`([A-Za-z0-9_]+)`\s*\(/is', + 'insert' => '/^INSERT\s+INTO\s+`([A-Za-z0-9_]+)`\s*\(/is', + ); + foreach ($patterns as $type => $pattern) { + if (preg_match($pattern, $statement, $match)) { + return array('type' => $type, 'table' => (string) $match[1]); + } + } + + throw new \RuntimeException('Дамп содержит неподдерживаемую SQL-команду'); + } + + /** Defaults for legacy ENUM columns where MySQL stored the implicit empty value. */ + public static function enumFallbacks($ddl) + { + $fallbacks = array(); + foreach (preg_split('/\r\n|\r|\n/', (string) $ddl) as $line) { + if (!preg_match('/^\s*`([^`]+)`\s+enum\((.*)\)(.*?)(?:,)?\s*$/i', $line, $match)) { + continue; + } + + $options = array(); + foreach (self::splitSqlList($match[2]) as $token) { + $options[] = self::decodeSqlString($token); + } + + if (!$options || in_array('', $options, true)) { + continue; + } + + $fallback = ''; + if (preg_match('/\bDEFAULT\s+(\'(?:\\\\.|[^\'])*\')/i', $match[3], $defaultMatch)) { + $fallback = self::decodeSqlString($defaultMatch[1]); + } + + if ($fallback === '' || !in_array($fallback, $options, true)) { + $fallback = (string) reset($options); + } + + $fallbacks[(string) $match[1]] = $fallback; + } + + return $fallbacks; + } + + /** Normalize only the implicit empty ENUM value; all other SQL remains untouched. */ + public static function normalizeEnumInsert($statement, array $fallbacks) + { + if (!$fallbacks) { + return (string) $statement; + } + + if (!preg_match('/^(INSERT\s+INTO\s+`[^`]+`\s*\()(.*?)(\)\s+VALUES\s*\()(.*)(\)\s*)$/is', (string) $statement, $match)) { + return (string) $statement; + } + + preg_match_all('/`([^`]+)`/', $match[2], $columnMatches); + $columns = isset($columnMatches[1]) ? $columnMatches[1] : array(); + $values = self::splitSqlList($match[4]); + if (count($columns) !== count($values)) { + throw new \RuntimeException('Не удалось проверить ENUM-значения в команде дампа'); + } + + foreach ($columns as $index => $column) { + if (isset($fallbacks[$column]) && trim((string) $values[$index]) === "''") { + $values[$index] = self::encodeSqlString($fallbacks[$column]); + } + } + + return $match[1] . $match[2] . $match[3] . implode(',', $values) . $match[5]; + } + + protected static function acceptStatement($buffer, array &$summary, $statementHandler) + { + $statement = self::resolvePortablePrefix(trim((string) $buffer)); + if ($statement === '') { return; } + + $summary['statements']++; + if ($summary['statements'] > self::MAX_STATEMENTS) { + throw new \RuntimeException('В дампе слишком много SQL-команд'); + } + + $info = self::statementInfo($statement); + $table = $info['table']; + if ($table !== '') { + if (!Model::isManagedTableName($table)) { + throw new \RuntimeException('Таблица не относится к текущей установке: ' . $table); + } + + if (!isset($summary['tables'][$table])) { + $summary['tables'][$table] = array('drop' => false, 'create' => false, 'inserts' => 0); + } + + if ($info['type'] === 'drop' || $info['type'] === 'create') { + if ($summary['tables'][$table][$info['type']]) { + throw new \RuntimeException('Команда таблицы продублирована в дампе: ' . $table); + } + + $summary['tables'][$table][$info['type']] = true; + } elseif ($info['type'] === 'insert') { + $summary['tables'][$table]['inserts']++; + $summary['inserts']++; + } + } + + if ($statementHandler) { + call_user_func($statementHandler, $statement, $info); + } + } + + protected static function resolvePortablePrefix($statement) + { + $statement = (string) $statement; + $replacement = '`' . Model::basePrefix() . '_'; + if (preg_match('/^CREATE\s+TABLE\s+/i', $statement)) { + //-- В DDL placeholder может встречаться в имени таблицы, constraint и REFERENCES. + return str_replace('`{{prefix}}_', $replacement, $statement); + } + + //-- В INSERT заменяем только имя целевой таблицы: значения строк неприкосновенны. + return preg_replace( + '/^(DROP\s+TABLE\s+IF\s+EXISTS\s+|INSERT\s+INTO\s+)`\{\{prefix\}\}_/i', + '$1' . $replacement, + $statement, + 1 + ); + } + + protected static function splitSqlList($value) + { + $items = array(); + $buffer = ''; + $quote = ''; + $escaped = false; + $length = strlen((string) $value); + for ($index = 0; $index < $length; $index++) { + $character = $value[$index]; + if ($quote !== '') { + $buffer .= $character; + if ($escaped) { + $escaped = false; + } elseif ($character === '\\') { + $escaped = true; + } elseif ($character === $quote) { + $quote = ''; + } + } elseif ($character === "'" || $character === '"' || $character === '`') { + $quote = $character; + $buffer .= $character; + } elseif ($character === ',') { + $items[] = trim($buffer); + $buffer = ''; + } else { + $buffer .= $character; + } + } + + $items[] = trim($buffer); + return $items; + } + + protected static function decodeSqlString($token) + { + $token = trim((string) $token); + if (strlen($token) < 2 || $token[0] !== "'" || substr($token, -1) !== "'") { + return $token; + } + + return stripcslashes(substr($token, 1, -1)); + } + + protected static function encodeSqlString($value) + { + return "'" . str_replace(array('\\', "'"), array('\\\\', "\\'"), (string) $value) . "'"; + } + + protected static function readMetadata($line, array &$meta) + { + $value = trim(substr(ltrim((string) $line), 2)); + if (strcasecmp($value, 'adminx full dump') === 0) { + $meta['format'] = 'adminx full dump'; + return; + } + + foreach (array('database', 'prefixes', 'date') as $key) { + if (stripos($value, $key . ':') === 0) { + $meta[$key] = trim(substr($value, strlen($key) + 1)); + return; + } + } + } + } diff --git a/adminx/modules/Database/assets/database.css b/adminx/modules/Database/assets/database.css new file mode 100644 index 0000000..3ed41ed --- /dev/null +++ b/adminx/modules/Database/assets/database.css @@ -0,0 +1,319 @@ +/* Стили раздела «База данных». */ +.db-tabs { + margin-bottom: 18px; +} +/* Длинный список таблиц — прокрутка внутри карточки с залипающей шапкой, + чтобы колонки не терялись и страница не растягивалась на тысячи пикселей. */ +.db-tablewrap { + max-height: 68vh; + overflow: auto; +} +.db-tablewrap thead th { + position: sticky; + top: 0; + z-index: 2; + background: var(--background-surface); +} +/* Тулбар фильтра по имени таблицы */ +.db-tables-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; + border-bottom: 1px solid var(--border-default); +} +.db-tables-toolbar .input-wrap { + flex: 1 1 320px; + max-width: 440px; +} +.db-tables-count { + flex: 0 0 auto; + font-variant-numeric: tabular-nums; +} +/* Размеры/отступы заголовка/сводки — общие (.ax-panel-header/.ax-summary). Здесь только акцент. */ +.database-panel-header { + --section-accent: var(--teal-500); + --section-accent-soft: var(--teal-100); +} +.database-engine-note { + align-items: center; + margin-bottom: 16px; +} +.database-engine-note > div { + flex: 1 1 auto; + min-width: 0; +} +.database-engine-note p { + margin: 3px 0 0; +} +.database-engine-note .btn { + flex: 0 0 auto; +} +.database-backup-note { + align-items: flex-start; + margin-bottom: 16px; +} +.database-backup-note p { + margin: 3px 0 0; +} +.database-backup-head-actions { + flex: 0 0 auto; + justify-content: flex-end; +} +.database-restore-active { + overflow: hidden; +} +.database-restore-overlay[hidden], +.database-restore-error[hidden], +[data-db-restore-close][hidden] { + display: none; +} +.database-restore-overlay { + position: fixed; + inset: 0; + z-index: 260; + display: grid; + place-items: center; + padding: 24px; + background: rgba(15, 23, 42, 0.58); + backdrop-filter: blur(3px); +} +.database-restore-dialog { + width: min(680px, 100%); + overflow: hidden; + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.24); +} +.database-restore-head { + display: grid; + grid-template-columns: 46px minmax(0, 1fr) auto; + align-items: center; + gap: 13px; + padding: 20px 22px 16px; +} +.database-restore-head h2 { + margin: 2px 0 0; + font-size: 19px; + text-wrap: balance; +} +.database-restore-head strong { + color: var(--blue-600); + font-size: 22px; + font-variant-numeric: tabular-nums; +} +.database-restore-track { + height: 6px; + overflow: hidden; + background: var(--background-inset); +} +.database-restore-track span { + display: block; + width: 0; + height: 100%; + background: var(--blue-500); + transition-property: width, background-color; + transition-duration: 180ms; +} +.database-restore-current { + display: flex; + align-items: center; + gap: 12px; + margin: 20px 22px 16px; + padding: 13px 14px; + border-radius: var(--radius-md); + background: var(--blue-50); +} +.database-restore-current b, +.database-restore-current small { + display: block; +} +.database-restore-current b { + overflow-wrap: anywhere; +} +.database-restore-current small { + margin-top: 3px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} +.database-restore-pulse { + width: 10px; + height: 10px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--blue-500); + box-shadow: 0 0 0 5px var(--blue-100); + animation: database-restore-pulse 1.4s ease-in-out infinite; +} +.database-restore-steps { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 7px; + padding: 0 22px 20px; +} +.database-restore-steps > div { + display: grid; + justify-items: center; + gap: 5px; + min-width: 0; + padding: 10px 5px; + border-radius: var(--radius-sm); + color: var(--text-tertiary); + font-size: 11px; + text-align: center; +} +.database-restore-steps i { + font-size: 19px; +} +.database-restore-steps .is-active { + background: var(--blue-50); + color: var(--blue-700); +} +.database-restore-steps .is-complete { + color: var(--green-600); +} +.database-restore-error { + display: flex; + align-items: flex-start; + gap: 9px; + margin: 0 22px 18px; + padding: 12px 14px; + border-radius: var(--radius-md); + background: var(--red-50); + color: var(--red-700); + line-height: 1.45; +} +.database-restore-error i { + margin-top: 2px; + flex: 0 0 auto; +} +.database-restore-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 22px; + background: var(--background-subtle); +} +.database-restore-footer p { + margin: 0; + color: var(--text-secondary); + font-size: 12px; +} +.database-restore-footer .btn { + flex: 0 0 auto; +} +.database-restore-overlay.is-complete .database-restore-track span { + background: var(--green-500); +} +.database-restore-overlay.is-complete .database-restore-pulse { + animation: none; + background: var(--green-500); + box-shadow: 0 0 0 5px var(--green-100); +} +.database-restore-overlay.is-error .database-restore-track span { + background: var(--red-500); +} +.database-restore-overlay.is-error .database-restore-pulse { + animation: none; + background: var(--red-500); + box-shadow: 0 0 0 5px var(--red-100); +} +@keyframes database-restore-pulse { + 50% { + opacity: 0.45; + transform: scale(0.82); + } +} +@media (max-width: 720px) { + .database-backups-card .ax-list-head { + flex-wrap: wrap; + } + .database-backup-head-actions { + width: 100%; + justify-content: flex-end; + padding-left: 46px; + } + .database-backups-table, + .database-backups-table tbody, + .database-backups-table tr, + .database-backups-table td { + display: block; + width: 100%; + } + .db-tablewrap .database-backups-table { + min-width: 0; + } + .database-backups-table thead { + display: none; + } + .database-backups-table tr { + padding: 10px 0; + border-bottom: 1px solid var(--border-default); + } + .database-backups-table tr:last-child { + border-bottom: 0; + } + .database-backups-table td { + padding: 3px 14px; + border: 0; + } + .database-backups-table td:first-child { + padding-top: 4px; + } + .database-backups-table td:first-child .cluster { + align-items: flex-start; + } + .database-backups-table td:first-child .mono { + min-width: 0; + overflow-wrap: anywhere; + } + .database-backups-table td:nth-child(2)::before { + content: 'Размер: '; + color: var(--text-muted); + } + .database-backups-table td:nth-child(3)::before { + content: 'Создан: '; + color: var(--text-muted); + } + .database-backups-table td.table-actions { + width: 100% !important; + padding-top: 8px; + } + .database-backups-table td.table-actions .cluster { + justify-content: flex-end; + } + .database-engine-note { + align-items: flex-start; + flex-wrap: wrap; + } + .database-engine-note .btn { + margin-left: 30px; + } + .database-restore-overlay { + align-items: end; + padding: 12px; + } + .database-restore-head { + padding: 17px 16px 14px; + } + .database-restore-current { + margin: 16px; + } + .database-restore-steps { + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding: 0 16px 16px; + } + .database-restore-footer { + align-items: flex-start; + padding: 13px 16px; + } +} +.database-schema-current { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 36px; + padding: 0 10px; + color: var(--color-success); + font-size: 13px; + font-weight: 600; +} diff --git a/adminx/modules/Database/assets/database.js b/adminx/modules/Database/assets/database.js new file mode 100644 index 0000000..c34b731 --- /dev/null +++ b/adminx/modules/Database/assets/database.js @@ -0,0 +1,417 @@ +/** + * JS раздела «База данных»: вкладки, обслуживание таблиц (OPTIMIZE/REPAIR), + * создание, проверка, восстановление и удаление резервных копий. Ajax по контракту success/error, + * подтверждения — Adminx.Confirm. + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.Database = { + init: function () { + if (!document.querySelector('[data-db-panel]')) { return; } + var self = this; + if (new URLSearchParams(window.location.search).get('tab') === 'backups') { self.switchTab('backups'); } + + document.addEventListener('click', function (e) { + var tab = e.target.closest('[data-db-tab]'); + if (tab) { self.switchTab(tab.getAttribute('data-db-tab')); return; } + + var opBtn = e.target.closest('[data-db-op]'); + if (opBtn) { self.maintain(opBtn.getAttribute('data-db-op'), opBtn.closest('tr').dataset.table, opBtn); return; } + + if (e.target.closest('[data-db-optimize-all]')) { self.optimizeAll(); return; } + if (e.target.closest('[data-db-schema-update]')) { self.updateSchema(); return; } + var backup = e.target.closest('[data-db-backup]'); + if (backup) { self.backup(); return; } + if (e.target.closest('[data-db-backup-upload]')) { + var fileInput = document.querySelector('[data-db-backup-file]'); + if (fileInput) { fileInput.click(); } + return; + } + + if (e.target.closest('[data-db-convert-innodb]')) { self.convertToInnoDb(); return; } + + var del = e.target.closest('[data-db-backup-delete]'); + if (del) { self.deleteBackup(del.closest('tr')); return; } + + var restore = e.target.closest('[data-db-backup-restore]'); + if (restore) { self.inspectBackup(restore.closest('tr'), restore); return; } + + if (e.target.closest('[data-db-restore-close]')) { self.closeRestoreProgress(); return; } + }); + + document.addEventListener('input', function (e) { + var filter = e.target.closest('[data-db-table-filter]'); + if (filter) { self.filterTables(filter.value); } + }); + + document.addEventListener('change', function (e) { + var input = e.target.closest('[data-db-backup-file]'); + if (input && input.files && input.files[0]) { self.uploadBackup(input); } + }); + }, + + filterTables: function (query) { + query = (query || '').trim().toLowerCase(); + var rows = document.querySelectorAll('[data-db-panel="tables"] tbody tr[data-table]'); + var shown = 0; + rows.forEach(function (row) { + var match = query === '' || (row.getAttribute('data-table') || '').toLowerCase().indexOf(query) !== -1; + row.hidden = !match; + if (match) { shown++; } + }); + var label = document.querySelector('[data-db-table-visible]'); + if (label) { label.textContent = shown + ' из ' + rows.length; } + }, + + base: function () { return Adminx.base(); }, + + switchTab: function (name) { + document.querySelectorAll('[data-db-tab]').forEach(function (t) { + t.setAttribute('aria-selected', t.getAttribute('data-db-tab') === name ? 'true' : 'false'); + }); + document.querySelectorAll('[data-db-panel]').forEach(function (p) { + p.hidden = p.getAttribute('data-db-panel') !== name; + }); + }, + + post: function (url, fd) { + Adminx.Loader.show(); + return Adminx.Ajax.post(url, fd).then(function (payload) { + Adminx.Loader.hide(); + return payload.data || {}; + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + return { success: false }; + }); + }, + + maintain: function (op, table, btn) { + var fd = new FormData(); + fd.append('op', op); + fd.append('table', table); + if (btn) { btn.disabled = true; } + this.post(this.base() + '/database/maintenance', fd).then(function (d) { + if (btn) { btn.disabled = false; } + Adminx.Toast.show(d.message || (d.success ? 'Готово' : 'Ошибка'), d.success ? 'success' : 'error'); + }); + }, + + optimizeAll: function () { + var self = this; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Оптимизировать все таблицы?', + message: 'Будет выполнен OPTIMIZE для всех таблиц текущего префикса.', + confirmLabel: 'Оптимизировать', + onConfirm: function () { + var fd = new FormData(); + fd.append('op', 'optimize'); + fd.append('table', '*'); + self.post(self.base() + '/database/maintenance', fd).then(function (d) { + Adminx.Toast.show(d.message || 'Готово', d.success ? 'success' : 'error'); + }); + } + }); + }, + + updateSchema: function () { + var self = this; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Применить миграции ядра?', + message: 'Будут выполнены ещё не применённые миграции встроенных разделов и синхронизированы права. Данные из резервных копий эта операция не читает.', + confirmLabel: 'Применить', + onConfirm: function () { + self.post(self.base() + '/database/schema/update', new FormData()).then(function (d) { + Adminx.Toast.show(d.message || (d.success ? 'Готово' : 'Ошибка'), d.success ? 'success' : 'error'); + if (d.success) { window.setTimeout(function () { window.location.reload(); }, 500); } + }); + } + }); + }, + + inspectBackup: function (row, button) { + var self = this; + var name = row.dataset.backup; + var fd = new FormData(); + fd.append('file', name); + button.disabled = true; + this.post(this.base() + '/database/backup/inspect', fd).then(function (d) { + button.disabled = false; + if (!d.success) { + Adminx.Toast.show(d.message || 'Не удалось проверить резервную копию', 'error'); + return; + } + + var info = d.data || {}; + var message = 'Файл: ' + name + '. Таблиц: ' + (info.table_count || 0) + + ', записей: ' + (info.inserts || 0) + ', дата копии: ' + (info.date || 'не указана') + '.'; + if (info.warnings && info.warnings.length) { message += ' Внимание: ' + info.warnings.join(' '); } + message += ' Текущие данные будут заменены, но перед этим автоматически создастся страховочный бэкап.'; + + Adminx.Confirm.open({ + kind: 'error', + title: 'Восстановить базу из копии?', + message: message, + confirmLabel: 'Восстановить', + confirmClass: 'btn-danger', + onConfirm: function () { self.restoreBackup(name, info.token); } + }); + }); + }, + + restoreBackup: function (name, token) { + var self = this; + var fd = new FormData(); + fd.append('file', name); + fd.append('restore_token', token || ''); + this.post(this.base() + '/database/backup/restore/start', fd).then(function (d) { + if (!d.success || !d.data || !d.data.job || !d.data.run_token) { + Adminx.Toast.show(d.message || 'Не удалось подготовить восстановление', 'error'); + return; + } + + self.openRestoreProgress(d.data.job); + self.runRestoreStream(d.data.job.id, d.data.run_token); + }); + }, + + openRestoreProgress: function (job) { + var overlay = document.querySelector('[data-db-restore-progress]'); + if (!overlay) { return; } + overlay.hidden = false; + document.body.classList.add('database-restore-active'); + var close = overlay.querySelector('[data-db-restore-close]'); + var error = overlay.querySelector('[data-db-restore-error]'); + if (close) { close.hidden = true; } + if (error) { error.hidden = true; } + this.updateRestoreProgress(job || {}); + }, + + closeRestoreProgress: function () { + var overlay = document.querySelector('[data-db-restore-progress]'); + if (overlay) { overlay.hidden = true; } + document.body.classList.remove('database-restore-active'); + }, + + updateRestoreProgress: function (job) { + var root = document.querySelector('[data-db-restore-progress]'); + if (!root || !job) { return; } + var percent = Math.max(0, Math.min(100, parseInt(job.progress, 10) || 0)); + var title = root.querySelector('[data-db-restore-title]'); + var detail = root.querySelector('[data-db-restore-detail]'); + var counter = root.querySelector('[data-db-restore-counter]'); + var percentLabel = root.querySelector('[data-db-restore-percent]'); + var track = root.querySelector('[data-db-restore-track]'); + var bar = root.querySelector('[data-db-restore-bar]'); + if (title) { title.textContent = job.title || 'Восстановление базы'; } + if (detail) { detail.textContent = job.detail || job.table || 'Выполняется операция'; } + if (percentLabel) { percentLabel.textContent = percent + '%'; } + if (track) { track.setAttribute('aria-valuenow', String(percent)); } + if (bar) { bar.style.width = percent + '%'; } + if (counter) { + counter.textContent = job.total && job.current + ? 'Таблица ' + job.current + ' из ' + job.total + : (job.table ? job.table : 'Состояние сохраняется автоматически'); + } + + var order = ['verify', 'backup', 'clean', 'restore', 'cache']; + var active = order.indexOf(job.stage); + root.querySelectorAll('[data-restore-stage]').forEach(function (step) { + var index = order.indexOf(step.getAttribute('data-restore-stage')); + step.classList.toggle('is-active', index === active); + step.classList.toggle('is-complete', active > index || job.status === 'completed'); + }); + + root.classList.toggle('is-complete', job.status === 'completed'); + root.classList.toggle('is-error', job.status === 'failed'); + if (job.status === 'failed') { this.showRestoreError(job.error || job.detail || 'Восстановление остановлено'); } + }, + + showRestoreError: function (message) { + var root = document.querySelector('[data-db-restore-progress]'); + if (!root) { return; } + root.classList.add('is-error'); + var error = root.querySelector('[data-db-restore-error]'); + var close = root.querySelector('[data-db-restore-close]'); + if (error) { error.hidden = false; error.querySelector('span').textContent = message; } + if (close) { close.hidden = false; } + }, + + finishRestore: function (payload) { + var root = document.querySelector('[data-db-restore-progress]'); + if (root) { + root.classList.add('is-complete'); + var close = root.querySelector('[data-db-restore-close]'); + if (close) { close.hidden = false; } + } + Adminx.Toast.show(payload.message || 'База восстановлена', 'success'); + window.setTimeout(function () { + window.location.href = payload.redirect || Adminx.Database.base() + '/database?tab=backups'; + }, 900); + }, + + runRestoreStream: function (jobId, runToken) { + var self = this; + var terminal = false; + var fd = new FormData(); + fd.append('job_id', jobId); + fd.append('run_token', runToken); + fd.append('_csrf', Adminx.csrf()); + fetch(this.base() + '/database/backup/restore/run', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Accept': 'application/x-ndjson', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': Adminx.csrf() + }, + body: fd + }).then(function (response) { + if (!response.ok || !response.body || !response.body.getReader) { + throw new Error('Сервер не открыл поток восстановления'); + } + + var reader = response.body.getReader(); + var decoder = new TextDecoder('utf-8'); + var buffer = ''; + var consume = function () { + return reader.read().then(function (chunk) { + buffer += decoder.decode(chunk.value || new Uint8Array(), { stream: !chunk.done }); + var lines = buffer.split('\n'); + buffer = lines.pop(); + lines.forEach(function (line) { + var type = self.handleRestoreEvent(line); + if (type === 'complete' || type === 'error') { terminal = true; } + }); + if (!chunk.done) { return consume(); } + if (buffer.trim()) { + var type = self.handleRestoreEvent(buffer); + if (type === 'complete' || type === 'error') { terminal = true; } + } + if (!terminal) { self.pollRestoreStatus(jobId, 0); } + }); + }; + return consume(); + }).catch(function () { + self.pollRestoreStatus(jobId, 0); + }); + }, + + handleRestoreEvent: function (line) { + if (!String(line || '').trim()) { return ''; } + var payload; + try { payload = JSON.parse(line); } catch (e) { return ''; } + if (payload.type === 'progress' && payload.job) { this.updateRestoreProgress(payload.job); } + if (payload.type === 'complete') { this.finishRestore(payload); } + if (payload.type === 'error') { this.showRestoreError(payload.message || 'Восстановление остановлено'); } + return payload.type || ''; + }, + + pollRestoreStatus: function (jobId, attempt) { + var self = this; + var fd = new FormData(); + fd.append('job_id', jobId); + Adminx.Ajax.post(this.base() + '/database/backup/restore/status', fd).then(function (payload) { + var response = payload.data || {}; + var job = response.data ? response.data.job : null; + if (!response.success || !job) { + self.showRestoreError('Соединение потеряно. Проверьте список резервных копий после обновления страницы.'); + return; + } + + self.updateRestoreProgress(job); + if (job.status === 'completed') { + self.finishRestore({ message: 'База восстановлена', result: job.result }); + } else if (job.status === 'failed') { + self.showRestoreError(job.error || 'Восстановление остановлено'); + } else if (attempt < 180) { + window.setTimeout(function () { self.pollRestoreStatus(jobId, attempt + 1); }, 2000); + } else { + self.showRestoreError('Восстановление выполняется дольше ожидаемого. Обновите страницу и проверьте состояние базы.'); + } + }).catch(function () { + if (attempt < 10) { window.setTimeout(function () { self.pollRestoreStatus(jobId, attempt + 1); }, 2000); } + else { self.showRestoreError('Не удалось получить состояние восстановления.'); } + }); + }, + + backup: function () { + var self = this; + Adminx.Confirm.open({ + kind: 'info', + title: 'Создать резервную копию?', + message: 'Будет создан gzip-дамп единой рабочей схемы.', + confirmLabel: 'Создать', + onConfirm: function () { + var fd = new FormData(); + self.post(self.base() + '/database/backup', fd).then(function (d) { + if (d.success) { + Adminx.Toast.show(d.message, 'success'); + if (d.redirect) { window.location.href = d.redirect; } else { window.location.reload(); } + } else { + Adminx.Toast.show(d.message || 'Ошибка', 'error'); + } + }); + } + }); + }, + + uploadBackup: function (input) { + var self = this; + var fd = new FormData(); + fd.append('backup', input.files[0]); + this.post(this.base() + '/database/backup/upload', fd).then(function (d) { + input.value = ''; + Adminx.Toast.show(d.message || (d.success ? 'Копия загружена' : 'Ошибка загрузки'), d.success ? 'success' : 'error'); + if (d.success) { window.location.href = d.redirect || self.base() + '/database'; } + }); + }, + + convertToInnoDb: function () { + var self = this; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Перевести MyISAM в InnoDB?', + message: 'Сначала создайте полный бэкап. ALTER TABLE может временно заблокировать запись, поэтому на production выполняйте операцию в окно обслуживания.', + confirmLabel: 'Перевести', + onConfirm: function () { + self.post(self.base() + '/database/engines/innodb', new FormData()).then(function (d) { + Adminx.Toast.show(d.message || (d.success ? 'Готово' : 'Ошибка'), d.success ? 'success' : 'error'); + if (d.success) { window.location.reload(); } + }); + } + }); + }, + + deleteBackup: function (row) { + var self = this; + var name = row.dataset.backup; + Adminx.Confirm.open({ + kind: 'error', + title: 'Удалить резервную копию?', + message: name + ' будет удалён без возможности восстановления.', + confirmLabel: 'Удалить', + confirmClass: 'btn-danger', + onConfirm: function () { + var fd = new FormData(); + fd.append('file', name); + self.post(self.base() + '/database/backup/delete', fd).then(function (d) { + if (d.success) { row.remove(); Adminx.Toast.show(d.message, 'success'); } + else { Adminx.Toast.show(d.message || 'Ошибка', 'error'); } + }); + } + }); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.Database.init(); }); + } else { + Adminx.Database.init(); + } +})(window, document); diff --git a/adminx/modules/Database/language/en/client.xml b/adminx/modules/Database/language/en/client.xml new file mode 100644 index 0000000..ca4d33d --- /dev/null +++ b/adminx/modules/Database/language/en/client.xml @@ -0,0 +1,52 @@ + + + Convert MyISAM to InnoDB? + Apply kernel migrations? + , records: + OPTIMIZE will be performed on all tables of the current prefix. + Attention: + Copy uploaded + Restore + Create a backup? + Done + , copy date: + Optimize all tables? + Restore the database from a copy? + A gzip dump of a single working schema will be created. + Recovery stopped + Delete backup? + Error + Apply + Unapplied built-in partition migrations will be performed and rights will be synchronized. This operation does not read data from backups. + The restore takes longer than expected. Refresh the page and check the status of the database. + The base has been restored + Create + Delete + File + Connection lost. Check the list of backups after refreshing the page. + File: + Translate + Failed to verify backup + Failed to prepare recovery + Restoring the database + The current data will be replaced, but before that a safety backup will be automatically created. + The state is saved automatically + First, create a full backup. ALTER TABLE may temporarily block the record, so in production, perform the operation in the maintenance window. + Loading error + from + Network error + will be deleted without the possibility of recovery. + not specified + Failed to get recovery status. + tables + . Tables: + The server did not open the recovery stream + Operation in progress + Optimize + Tables + Table + Optimize everything + The base has been restored + tables + Recovery stopped + diff --git a/adminx/modules/Database/language/en/interface.xml b/adminx/modules/Database/language/en/interface.xml new file mode 100644 index 0000000..4bafdf1 --- /dev/null +++ b/adminx/modules/Database/language/en/interface.xml @@ -0,0 +1,196 @@ + + + _ no + SQL dumps of a single working schema + · prefix + Backups + Remaining in the working scheme + Restore completely replaces the current schema data + Dumps + Engine + Engine, number of entries, size and maintenance. + Engine, volume and approximate number of rows of each table of the working scheme. + Download a copy + Posts + from + Importing tables + Migrations + Don't close the tab. If the connection is lost, AVE.cms will check the saved state of the task. + The operation has not yet started + Optimize + Optimize everything + Convert to InnoDB + Before launching, AVE.cms will automatically create a backup copy. Do not close the page until the operation is completed. + Preparation + Preparing for recovery + Search by table name + Full backup + Check + Checking the selected file + The working scheme uses InnoDB + Backup + Backups + No backups yet + Own and downloaded SQL dumps of the database. + Created + Safety copy + Scheme + The scheme is current + Tables + MyISAM tables + tables support transactions and failover. + Tables with prefix + Table + Tables + Current schema tables + File + Backup files + stored in + These are tables transferred from Legacy with the old engine. Before transferring to InnoDB, make a full backup: the operation may temporarily block writing to large tables. + SQL commands: + , copy date: + , records: + , errors: + , lines: + . Automatic rollback failed; use a safety backup + . The original base is returned from the safety copy + . Tables: + \s*\(/is', + ); + foreach ($patterns as $type => $pattern) { + if (preg_match($pattern, $statement, $match)) { + return array('type' => $type, 'table' => (string) $match[1]); + } + } + + throw new \RuntimeException('Dump contains an unsupported SQL command'); + } + + + public static function enumFallbacks($ddl) + { + $fallbacks = array(); + foreach (preg_split('/\r\n|\r|\n/', (string) $ddl) as $line) { + if (!preg_match('/^\s* + ", current base - " + B + The base has been restored + The database was restored from + Database + Database: service + Database: view + OPTIMIZE will be performed on all tables of the current prefix. + A gzip dump of a single working schema will be created. + will be deleted without the possibility of recovery. + Unapplied built-in partition migrations will be performed and rights will be synchronized. This operation does not read data from backups. + Backup created: + Backup deleted + There are too many SQL commands in the dump + Attention: + Returning the original database + Restoring tables + Restore + Restore the database from a copy? + Restoring the database + The restore takes longer than expected. Refresh the page and check the status of the database. + The restore will replace all tables in the current installation with the data from the backup. + Restore failed: + Recovery not started + Recovery stopped + Recovery prepared + Restore aborted: + All kernel migrations have already been applied + All tables imported and cache cleared + Operation in progress + GB + Done + Done: + The dump was created in the database " + To restore, select the full dump created by AVE.cms + Another backup is already being created + Another restore is already in progress + Restore job not found + The restore job belongs to another user + Running recovery is deprecated. Check your copy again + The backup directory is not writable + KB + The table command is duplicated in the dump: + Copy uploaded + Copy uploaded and verified: + MB + Let's start importing the backup copy + Failed to complete backup download + Failed to complete dump file + Failed to download copy: + Failed to change table engine: + Failed to disable foreign key checking + Failed to prepare recovery + Failed to prepare the current schema for recovery + Failed to get recovery status. + Failed to apply kernel migrations: + Failed to check ENUM values in dump command + Failed to verify backup + Failed to read backup file + Failed to create backup: + Failed to create recovery job directory + Failed to create dump file + Failed to save downloaded file + Failed to save recovery state + Failed to delete file + not specified + Unknown operation + Unknown table + Incorrect recovery ID + No tables to process + No tables to back up + Update runtime + Maintaining tables, kernel migrations, creating, restoring and deleting backups. + Waiting for a protected process to start + Optimize all tables? + Clear caches and module registry + Error + Loading error + Network error + Translated: + Translate + Convert MyISAM to InnoDB? + Preparing a working diagram + Only .sql and .sql.gz files are supported + The prefix in the header is different from the current one; the actual table names have already been checked. + Applied kernel migrations: + Apply + Apply kernel migrations? + Backup verification is outdated. Run it again + Checking the source file + View tables and backups. + The working base has not changed yet + Backup size is invalid + The unpacked dump exceeds the allowed size + Backup verified + The server did not open the recovery stream + System + First, create a full backup. ALTER TABLE may temporarily block the record, so in production, perform the operation in the maintenance window. + Connection lost. Check the list of backups after refreshing the page. + Create + Create a backup? + Creating a safety copy + Recovery state + The state is saved automatically + tables + There are no MyISAM tables left + tables) + table + The table does not apply to the current installation: + Tables transferred to InnoDB: + TB + The current data will be replaced, but before that a safety backup will be automatically created. + Delete + Delete backup? + Deleting the tables of the current installation + The file has changed since verification. Restore canceled + The file has changed since verification. Check again + File not found + Backup file not loaded + Backup file not found + File: + diff --git a/adminx/modules/Database/language/en/runtime.xml b/adminx/modules/Database/language/en/runtime.xml new file mode 100644 index 0000000..66a8725 --- /dev/null +++ b/adminx/modules/Database/language/en/runtime.xml @@ -0,0 +1,102 @@ + + + SQL commands: + , errors: + , lines: + . Automatic rollback failed; use a safety backup + . The original base is returned from the safety copy + SQL dump is broken or contains an incomplete command + ", current base - " + B + The base has been restored + The database was restored from + Database + Database: service + Database: view + Backup created: + Backup deleted + There are too many SQL commands in the dump + There are no tables in the backup + Returning the original database + Restoring tables + The restore will replace all tables in the current installation with the data from the backup. + Restore failed: + Recovery not started + Recovery stopped + Recovery prepared + Restore aborted: + All kernel migrations have already been applied + All tables imported and cache cleared + GB + Done: + The dump contains an unsupported SQL command + The dump was created in the database " + To restore, select the full dump created by AVE.cms + Another backup is already being created + Another restore is already in progress + Restore job not found + The restore job belongs to another user + Running recovery is deprecated. Check your copy again + KB + The backup directory is not writable + The table command is duplicated in the dump: + Copy uploaded and verified: + MB + Let's start importing the backup copy + Failed to complete backup download + Failed to complete dump file + Failed to download copy: + Failed to change table engine: + Failed to disable foreign key checking + Failed to prepare the current schema for recovery + Failed to apply kernel migrations: + Failed to check ENUM values in dump command + Failed to read backup file + Failed to create backup: + Failed to create recovery job directory + Failed to create dump file + Failed to save downloaded file + Failed to save recovery state + Failed to delete file + Unknown operation + Unknown table + Incorrect recovery ID + Incomplete description of the table in the dump: + No tables to process + No tables to back up + Update runtime + Maintaining tables, kernel migrations, creating, restoring and deleting backups. + One dump command exceeds the allowed size + Waiting for a protected process to start + Clear caches and module registry + Translated: + Preparing a working diagram + Only .sql and .sql.gz files are supported + The prefix in the header is different from the current one; the actual table names have already been checked. + Applied kernel migrations: + Backup verification is outdated. Run it again + Checking the source file + View tables and backups. + The working base has not changed yet + Backup size is invalid + The unpacked dump exceeds the allowed size + Backup verified + System + Creating a safety copy + Recovery state + Safety copy + TB + There are no MyISAM tables left + The table does not apply to the current installation: + Tables transferred to InnoDB: + Deleting the tables of the current installation + The file has changed since verification. Restore canceled + The file has changed since verification. Check again + File not found + The file is not a complete dump of AVE.cms + Backup file not loaded + Backup file not found + tables + tables) + table + diff --git a/adminx/modules/Database/language/ru/client.xml b/adminx/modules/Database/language/ru/client.xml new file mode 100644 index 0000000..9d2cd41 --- /dev/null +++ b/adminx/modules/Database/language/ru/client.xml @@ -0,0 +1,52 @@ + + + Перевести MyISAM в InnoDB? + Применить миграции ядра? + , записей: + Будет выполнен OPTIMIZE для всех таблиц текущего префикса. + Внимание: + Копия загружена + Восстановить + Создать резервную копию? + Готово + , дата копии: + Оптимизировать все таблицы? + Восстановить базу из копии? + Будет создан gzip-дамп единой рабочей схемы. + Восстановление остановлено + Удалить резервную копию? + Ошибка + Применить + Будут выполнены ещё не применённые миграции встроенных разделов и синхронизированы права. Данные из резервных копий эта операция не читает. + Восстановление выполняется дольше ожидаемого. Обновите страницу и проверьте состояние базы. + База восстановлена + Создать + Удалить + Файл + Соединение потеряно. Проверьте список резервных копий после обновления страницы. + Файл: + Перевести + Не удалось проверить резервную копию + Не удалось подготовить восстановление + Восстановление базы + Текущие данные будут заменены, но перед этим автоматически создастся страховочный бэкап. + Состояние сохраняется автоматически + Сначала создайте полный бэкап. ALTER TABLE может временно заблокировать запись, поэтому на production выполняйте операцию в окно обслуживания. + Ошибка загрузки + из + Ошибка сети + будет удалён без возможности восстановления. + не указана + Не удалось получить состояние восстановления. + таблиц + . Таблиц: + Сервер не открыл поток восстановления + Выполняется операция + Оптимизировать + Таблиц + Таблица + Оптимизировать все + База восстановлена + таблиц + Восстановление остановлено + diff --git a/adminx/modules/Database/language/ru/interface.xml b/adminx/modules/Database/language/ru/interface.xml new file mode 100644 index 0000000..cf90828 --- /dev/null +++ b/adminx/modules/Database/language/ru/interface.xml @@ -0,0 +1,196 @@ + + + _ нет + SQL-дампы единой рабочей схемы + · префикс + Бэкапов + В рабочей схеме осталось + Восстановление полностью заменяет данные текущей схемы + Дампы + Движок + Движок, количество записей, размер и обслуживание. + Движок, объём и ориентировочное число строк каждой таблицы рабочей схемы. + Загрузить копию + Записей + из + Импорт таблиц + Миграции + Не закрывайте вкладку. При разрыве соединения AVE.cms проверит сохранённое состояние задания. + Операция ещё не началась + Оптимизировать + Оптимизировать все + Перевести в InnoDB + Перед запуском AVE.cms автоматически создаст страховочную копию. Не закрывайте страницу до завершения операции. + Подготовка + Подготовка восстановления + Поиск по имени таблицы + Полный бэкап + Проверка + Проверяем выбранный файл + Рабочая схема использует InnoDB + Резервная копия + Резервные копии + Резервных копий пока нет + Собственные и загруженные SQL-дампы базы данных. + Создан + Страховочная копия + Схема + Схема актуальна + Таблиц + таблиц MyISAM + таблиц поддерживают транзакции и восстановление после сбоя. + Таблиц с префиксом + Таблица + Таблицы + Таблицы текущей схемы + Файл + Файлы резервных копий + хранятся в + Это таблицы, перенесённые из легаси со старым движком. Перед переводом в InnoDB сделайте полный бэкап: операция может временно заблокировать запись в крупные таблицы. + , SQL-команд: + , дата копии: + , записей: + , ошибок: + , строк: + . Автоматический откат не выполнен; используйте страховочный бэкап + . Исходная база возвращена из страховочной копии + . Таблиц: + \s*\(/is', + ); + foreach ($patterns as $type => $pattern) { + if (preg_match($pattern, $statement, $match)) { + return array('type' => $type, 'table' => (string) $match[1]); + } + } + + throw new \RuntimeException('Дамп содержит неподдерживаемую SQL-команду'); + } + + + public static function enumFallbacks($ddl) + { + $fallbacks = array(); + foreach (preg_split('/\r\n|\r|\n/', (string) $ddl) as $line) { + if (!preg_match('/^\s* + », текущая база — « + Б + База восстановлена + База восстановлена из + База данных + База данных: обслуживание + База данных: просмотр + Будет выполнен OPTIMIZE для всех таблиц текущего префикса. + Будет создан gzip-дамп единой рабочей схемы. + будет удалён без возможности восстановления. + Будут выполнены ещё не применённые миграции встроенных разделов и синхронизированы права. Данные из резервных копий эта операция не читает. + Бэкап создан: + Бэкап удалён + В дампе слишком много SQL-команд + Внимание: + Возвращаем исходную базу + Восстанавливаем таблицы + Восстановить + Восстановить базу из копии? + Восстановление базы + Восстановление выполняется дольше ожидаемого. Обновите страницу и проверьте состояние базы. + Восстановление заменит все таблицы текущей установки данными из резервной копии. + Восстановление не выполнено: + Восстановление не запущено + Восстановление остановлено + Восстановление подготовлено + Восстановление прервано: + Все миграции ядра уже применены + Все таблицы импортированы и кеш очищен + Выполняется операция + ГБ + Готово + Готово: + Дамп создан в базе « + Для восстановления выберите полный дамп, созданный AVE.cms + Другая резервная копия уже создаётся + Другое восстановление уже выполняется + Задание восстановления не найдено + Задание восстановления принадлежит другому пользователю + Запуск восстановления устарел. Проверьте копию повторно + Каталог бэкапов недоступен для записи + КБ + Команда таблицы продублирована в дампе: + Копия загружена + Копия загружена и проверена: + МБ + Начинаем импорт резервной копии + Не удалось завершить загрузку резервной копии + Не удалось завершить файл дампа + Не удалось загрузить копию: + Не удалось изменить движок таблиц: + Не удалось отключить проверку внешних ключей + Не удалось подготовить восстановление + Не удалось подготовить текущую схему к восстановлению + Не удалось получить состояние восстановления. + Не удалось применить миграции ядра: + Не удалось проверить ENUM-значения в команде дампа + Не удалось проверить резервную копию + Не удалось прочитать файл резервной копии + Не удалось создать бэкап: + Не удалось создать каталог заданий восстановления + Не удалось создать файл дампа + Не удалось сохранить загруженный файл + Не удалось сохранить состояние восстановления + Не удалось удалить файл + не указана + Неизвестная операция + Неизвестная таблица + Некорректный идентификатор восстановления + Нет таблиц для обработки + Нет таблиц для резервного копирования + Обновляем runtime + Обслуживание таблиц, миграции ядра, создание, восстановление и удаление бэкапов. + Ожидание запуска защищённого процесса + Оптимизировать все таблицы? + Очищаем кеши и реестр модулей + Ошибка + Ошибка загрузки + Ошибка сети + Переведено: + Перевести + Перевести MyISAM в InnoDB? + Подготавливаем рабочую схему + Поддерживаются только файлы .sql и .sql.gz + Префикс в заголовке отличается от текущего; фактические имена таблиц уже проверены. + Применено миграций ядра: + Применить + Применить миграции ядра? + Проверка резервной копии устарела. Выполните её повторно + Проверяем исходный файл + Просмотр таблиц и резервных копий. + Рабочая база пока не изменяется + Размер резервной копии недопустим + Распакованный дамп превышает допустимый размер + Резервная копия проверена + Сервер не открыл поток восстановления + Система + Сначала создайте полный бэкап. ALTER TABLE может временно заблокировать запись, поэтому на production выполняйте операцию в окно обслуживания. + Соединение потеряно. Проверьте список резервных копий после обновления страницы. + Создать + Создать резервную копию? + Создаём страховочную копию + Состояние восстановления + Состояние сохраняется автоматически + таблиц + Таблиц MyISAM не осталось + таблиц) + таблица + Таблица не относится к текущей установке: + Таблицы переведены в InnoDB: + ТБ + Текущие данные будут заменены, но перед этим автоматически создастся страховочный бэкап. + Удалить + Удалить резервную копию? + Удаляем таблицы текущей установки + Файл изменился после проверки. Восстановление отменено + Файл изменился после проверки. Выполните проверку повторно + Файл не найден + Файл резервной копии не загружен + Файл резервной копии не найден + Файл: + diff --git a/adminx/modules/Database/language/ru/runtime.xml b/adminx/modules/Database/language/ru/runtime.xml new file mode 100644 index 0000000..5cf8a6a --- /dev/null +++ b/adminx/modules/Database/language/ru/runtime.xml @@ -0,0 +1,102 @@ + + + , SQL-команд: + , ошибок: + , строк: + . Автоматический откат не выполнен; используйте страховочный бэкап + . Исходная база возвращена из страховочной копии + SQL-дамп оборван или содержит незавершённую команду + », текущая база — « + Б + База восстановлена + База восстановлена из + База данных + База данных: обслуживание + База данных: просмотр + Бэкап создан: + Бэкап удалён + В дампе слишком много SQL-команд + В резервной копии нет таблиц + Возвращаем исходную базу + Восстанавливаем таблицы + Восстановление заменит все таблицы текущей установки данными из резервной копии. + Восстановление не выполнено: + Восстановление не запущено + Восстановление остановлено + Восстановление подготовлено + Восстановление прервано: + Все миграции ядра уже применены + Все таблицы импортированы и кеш очищен + ГБ + Готово: + Дамп содержит неподдерживаемую SQL-команду + Дамп создан в базе « + Для восстановления выберите полный дамп, созданный AVE.cms + Другая резервная копия уже создаётся + Другое восстановление уже выполняется + Задание восстановления не найдено + Задание восстановления принадлежит другому пользователю + Запуск восстановления устарел. Проверьте копию повторно + КБ + Каталог бэкапов недоступен для записи + Команда таблицы продублирована в дампе: + Копия загружена и проверена: + МБ + Начинаем импорт резервной копии + Не удалось завершить загрузку резервной копии + Не удалось завершить файл дампа + Не удалось загрузить копию: + Не удалось изменить движок таблиц: + Не удалось отключить проверку внешних ключей + Не удалось подготовить текущую схему к восстановлению + Не удалось применить миграции ядра: + Не удалось проверить ENUM-значения в команде дампа + Не удалось прочитать файл резервной копии + Не удалось создать бэкап: + Не удалось создать каталог заданий восстановления + Не удалось создать файл дампа + Не удалось сохранить загруженный файл + Не удалось сохранить состояние восстановления + Не удалось удалить файл + Неизвестная операция + Неизвестная таблица + Некорректный идентификатор восстановления + Неполное описание таблицы в дампе: + Нет таблиц для обработки + Нет таблиц для резервного копирования + Обновляем runtime + Обслуживание таблиц, миграции ядра, создание, восстановление и удаление бэкапов. + Одна команда дампа превышает допустимый размер + Ожидание запуска защищённого процесса + Очищаем кеши и реестр модулей + Переведено: + Подготавливаем рабочую схему + Поддерживаются только файлы .sql и .sql.gz + Префикс в заголовке отличается от текущего; фактические имена таблиц уже проверены. + Применено миграций ядра: + Проверка резервной копии устарела. Выполните её повторно + Проверяем исходный файл + Просмотр таблиц и резервных копий. + Рабочая база пока не изменяется + Размер резервной копии недопустим + Распакованный дамп превышает допустимый размер + Резервная копия проверена + Система + Создаём страховочную копию + Состояние восстановления + Страховочная копия + ТБ + Таблиц MyISAM не осталось + Таблица не относится к текущей установке: + Таблицы переведены в InnoDB: + Удаляем таблицы текущей установки + Файл изменился после проверки. Восстановление отменено + Файл изменился после проверки. Выполните проверку повторно + Файл не найден + Файл не является полным дампом AVE.cms + Файл резервной копии не загружен + Файл резервной копии не найден + таблиц + таблиц) + таблица + diff --git a/adminx/modules/Database/migrations/001_mysql_legacy_index_compatibility.php b/adminx/modules/Database/migrations/001_mysql_legacy_index_compatibility.php new file mode 100644 index 0000000..53fdda9 --- /dev/null +++ b/adminx/modules/Database/migrations/001_mysql_legacy_index_compatibility.php @@ -0,0 +1,85 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\SystemTables; + use App\Content\ContentTables; + use App\Content\PublicUserTables; + + return function (array $context) { + $tableExists = function ($table) { + return in_array((string) $table, DB::getTables((string) $table), true); + }; + $indexExists = function ($table, $index) { + return (bool) DB::query( + 'SELECT 1 FROM information_schema.STATISTICS' + . ' WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s AND INDEX_NAME=%s LIMIT 1', + (string) $table, + (string) $index + )->getValue(); + }; + + $operations = 0; + $migrations = SystemTables::table('module_migrations'); + if ($tableExists($migrations)) { + DB::query( + 'ALTER TABLE ' . $migrations + . ' MODIFY module VARCHAR(80) CHARACTER SET ascii NOT NULL,' + . ' MODIFY migration VARCHAR(190) CHARACTER SET ascii NOT NULL' + ); + $operations++; + } + + $users = SystemTables::table('users'); + if ($tableExists($users)) { + DB::query('ALTER TABLE ' . $users . ' MODIFY email VARCHAR(255) CHARACTER SET ascii NOT NULL'); + $operations++; + } + + $identities = PublicUserTables::table('user_identities'); + if ($tableExists($identities)) { + DB::query( + 'ALTER TABLE ' . $identities + . ' MODIFY provider VARCHAR(24) CHARACTER SET ascii NOT NULL,' + . ' MODIFY provider_user_id VARCHAR(190) CHARACTER SET ascii NOT NULL' + ); + $operations++; + } + + $referrers = SystemTables::table('referrer_log'); + if ($tableExists($referrers) && $indexExists($referrers, 'idx_source')) { + DB::query( + 'ALTER TABLE ' . $referrers + . ' DROP INDEX idx_source, ADD INDEX idx_source (source_type,source_name(167))' + ); + $operations++; + } + + $documentFields = ContentTables::table('document_fields'); + if ($tableExists($documentFields)) { + $legacyIndex = $indexExists($documentFields, 'field_value') ? 'field_value' : ''; + $currentIndex = $indexExists($documentFields, 'idx_field_value') ? 'idx_field_value' : ''; + if ($legacyIndex !== '' || $currentIndex !== '') { + $drop = array(); + if ($legacyIndex !== '') { $drop[] = 'DROP INDEX field_value'; } + if ($currentIndex !== '') { $drop[] = 'DROP INDEX idx_field_value'; } + $drop[] = 'ADD INDEX idx_field_value (field_value(190))'; + DB::query('ALTER TABLE ' . $documentFields . ' ' . implode(',', $drop)); + $operations++; + } + } + + return $operations; + }; diff --git a/adminx/modules/Database/migrations/002_module_lifecycle_recovery.php b/adminx/modules/Database/migrations/002_module_lifecycle_recovery.php new file mode 100644 index 0000000..d1b39b1 --- /dev/null +++ b/adminx/modules/Database/migrations/002_module_lifecycle_recovery.php @@ -0,0 +1,63 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\SystemTables; + + return function (array $context) { + $attempts = SystemTables::table('module_migration_attempts'); + $modules = SystemTables::table('modules'); + $queries = 0; + \DB::query( + 'CREATE TABLE IF NOT EXISTS ' . $attempts . " ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + module VARCHAR(80) CHARACTER SET ascii NOT NULL, + migration VARCHAR(190) CHARACTER SET ascii NOT NULL, + checksum CHAR(40) NOT NULL, + operation VARCHAR(20) CHARACTER SET ascii NOT NULL DEFAULT '', + status VARCHAR(20) CHARACTER SET ascii NOT NULL, + queries_count INT UNSIGNED NOT NULL DEFAULT 0, + error_message TEXT NULL, + actor_id INT UNSIGNED NULL, + started_at DATETIME NOT NULL, + completed_at DATETIME NULL, + PRIMARY KEY (id), + INDEX idx_module_started (module, started_at), + INDEX idx_status (status) + ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4" + ); + $queries++; + + $columns = array( + 'previous_status' => "VARCHAR(30) NOT NULL DEFAULT '' AFTER disabled_at", + 'operation' => "VARCHAR(20) NOT NULL DEFAULT '' AFTER previous_status", + 'operation_started_at' => 'DATETIME NULL AFTER operation', + 'failed_at' => 'DATETIME NULL AFTER operation_started_at', + 'last_error' => 'TEXT NULL AFTER failed_at', + ); + foreach ($columns as $name => $definition) { + $exists = \DB::query( + 'SELECT 1 FROM information_schema.COLUMNS' + . ' WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s AND COLUMN_NAME=%s LIMIT 1', + $modules, + $name + )->getValue(); + if ($exists) { continue; } + \DB::query('ALTER TABLE ' . $modules . ' ADD COLUMN ' . $name . ' ' . $definition); + $queries++; + } + + return $queries; + }; diff --git a/adminx/modules/Database/migrations/003_document_fields_text_composite_index.php b/adminx/modules/Database/migrations/003_document_fields_text_composite_index.php new file mode 100644 index 0000000..7c29c80 --- /dev/null +++ b/adminx/modules/Database/migrations/003_document_fields_text_composite_index.php @@ -0,0 +1,37 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Content\ContentTables; + + return function (array $context) { + $table = ContentTables::table('document_fields_text'); + if (!in_array($table, DB::getTables($table), true)) { + return 0; + } + + $exists = (bool) DB::query( + 'SELECT 1 FROM information_schema.STATISTICS' + . ' WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s AND INDEX_NAME=%s LIMIT 1', + $table, + 'idx_document_field' + )->getValue(); + if ($exists) { + return 0; + } + + DB::query('ALTER TABLE ' . $table . ' ADD INDEX idx_document_field (document_id,rubric_field_id)'); + return 1; + }; diff --git a/adminx/modules/Database/module.php b/adminx/modules/Database/module.php new file mode 100644 index 0000000..604baf5 --- /dev/null +++ b/adminx/modules/Database/module.php @@ -0,0 +1,100 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + /** + * Раздел «База данных»: таблицы текущего префикса, обслуживание (OPTIMIZE/REPAIR) + * и резервные копии (gzip-дамп в tmp/backup). + */ + return [ + 'code' => 'database', + 'name' => 'База данных', + 'version' => '0.1.5', + + 'permissions' => [ + 'key' => 'database', + 'items' => array( + array( + 'code' => 'view_database', + 'group_code' => 'navigation', + 'name' => 'База данных: просмотр', + 'description' => 'Просмотр таблиц и резервных копий.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_database', + 'group_code' => 'admin', + 'name' => 'База данных: обслуживание', + 'description' => 'Обслуживание таблиц, миграции ядра, создание, восстановление и удаление бэкапов.', + 'sort_order' => 20, + ), + ), + 'icon' => 'ti ti-database', + 'priority' => 40, + ], + + 'navigation' => array( + array( + 'code' => 'database', + 'label' => 'База данных', + 'url' => '/database', + 'icon' => 'ti ti-database', + 'permission' => 'view_database', + 'group' => 'Система', + 'sort_order' => 34, + 'match' => array( + '/database', + ), + ), + ), + + 'migrations' => array( + array( + 'id' => '001_mysql_legacy_index_compatibility', + 'file' => 'migrations/001_mysql_legacy_index_compatibility.php', + ), + array( + 'id' => '002_module_lifecycle_recovery', + 'file' => 'migrations/002_module_lifecycle_recovery.php', + ), + array( + 'id' => '003_document_fields_text_composite_index', + 'file' => 'migrations/003_document_fields_text_composite_index.php', + ), + ), + + 'routes' => array( + array('GET', '/database', array(\App\Adminx\Database\Controller::class, 'index')), + array('POST', '/database/maintenance', array(\App\Adminx\Database\Controller::class, 'maintenance')), + array('POST', '/database/backup', array(\App\Adminx\Database\Controller::class, 'backupCreate')), + array('POST', '/database/backup/upload', array(\App\Adminx\Database\Controller::class, 'backupUpload'), array('permission' => 'manage_database')), + array('GET', '/database/backup/download', array(\App\Adminx\Database\Controller::class, 'backupDownload')), + array('POST', '/database/backup/delete', array(\App\Adminx\Database\Controller::class, 'backupDelete')), + array('POST', '/database/backup/inspect', array(\App\Adminx\Database\Controller::class, 'backupInspect'), array('permission' => 'manage_database')), + array('POST', '/database/backup/restore/start', array(\App\Adminx\Database\Controller::class, 'backupRestoreStart'), array( + 'permission' => 'manage_database', + 'sensitive' => 'database.restore', + 'reauth' => array('reason' => 'Восстановление заменит все таблицы текущей установки данными из резервной копии.'), + )), + array('POST', '/database/backup/restore/run', array(\App\Adminx\Database\Controller::class, 'backupRestoreRun'), array('permission' => 'manage_database')), + array('POST', '/database/backup/restore/status', array(\App\Adminx\Database\Controller::class, 'backupRestoreStatus'), array('permission' => 'manage_database')), + array('POST', '/database/engines/innodb', array(\App\Adminx\Database\Controller::class, 'convertToInnoDb')), + array('POST', '/database/schema/update', array(\App\Adminx\Database\Controller::class, 'updateSchema')), + ), + + 'view_globals' => [ + 'module_code' => 'database', + ], + ]; diff --git a/adminx/modules/Database/view/index.twig b/adminx/modules/Database/view/index.twig new file mode 100644 index 0000000..0479490 --- /dev/null +++ b/adminx/modules/Database/view/index.twig @@ -0,0 +1,205 @@ +{% extends '@adminx/main.twig' %} +{% block title %}База данных{% endblock %} +{% block content %} + + + + + +
    +
    {{ stats.tables }}
    Таблиц
    +
    {{ stats.size_h }}
    Размер
    +
    {{ stats.rows|number_format(0, '.', ' ') }}
    Записей
    +
    {{ backups|length }}
    Бэкапов
    +
    + +{% if stats.myisam > 0 %} +
    + +
    + В рабочей схеме осталось {{ stats.myisam }} таблиц MyISAM +

    Это таблицы, перенесённые из легаси со старым движком. Перед переводом в InnoDB сделайте полный бэкап: операция может временно заблокировать запись в крупные таблицы.

    +
    + {% if can_manage %}{% endif %} +
    +{% else %} +
    Рабочая схема использует InnoDB

    Все {{ stats.innodb }} таблиц поддерживают транзакции и восстановление после сбоя.

    +{% endif %} + +
    + + +
    + +{# ---------------- Таблицы ---------------- #} +
    +
    +
    +
    +
    Схема
    +

    Таблицы

    +

    Движок, объём и ориентировочное число строк каждой таблицы рабочей схемы.

    +
    +
    + +
    +

    Таблицы текущей схемы

    Движок, количество записей, размер и обслуживание.

    {{ tables|length }}
    +
    +
    + {{ tables|length }} из {{ tables|length }} +
    +
    + + + + + + + + {% if can_manage %}{% endif %} + + + + {% for t in tables %} + + + + + + {% if can_manage %} + + {% endif %} + + {% else %} + + {% endfor %} + +
    ТаблицаДвижокЗаписейРазмерДействия
    {{ t.name }}{{ t.engine }}{{ t.rows|number_format(0, '.', ' ') }}{{ t.size_h }} +
    + + {% if t.is_myisam %} + + {% endif %} +
    +
    Таблиц с префиксом {{ stats.prefix }}_ нет
    +
    +
    +
    + +{# ---------------- Резервные копии ---------------- #} + + + + +{% endblock %} diff --git a/adminx/modules/Documents/Controller.php b/adminx/modules/Documents/Controller.php new file mode 100644 index 0000000..4534f03 --- /dev/null +++ b/adminx/modules/Documents/Controller.php @@ -0,0 +1,1124 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Documents; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\ApiTokenRepository; + use App\Common\AuditLog; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\ErrorReport; + use App\Common\Permission; + use App\Adminx\Support\CodeEditor; + use App\Adminx\Rubrics\AdminView; + use App\Content\Documents\DocumentHookException; + use App\Content\Documents\ContentCacheInvalidator; + use App\Content\Documents\DocumentCreationPresetRepository; + use App\Content\Documents\DocumentMediaDraft; + use App\Content\Documents\DocumentMediaFinalizer; + use App\Content\Documents\DocumentSaveEvents; + use App\Content\Documents\DocumentSaveRejected; + use App\Content\Documents\DocumentSnapshotRepository; + use App\Content\Documents\DocumentSnapshotStore; + use App\Content\Documents\FieldTemplateManifest; + use App\Content\Documents\RubricSchemaBuilder; + use App\Frontend\DocumentRevisionPreview; + use App\Helpers\Request; + use DB; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); + AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50); + CodeEditor::useCodeMirror('application/x-httpd-php'); + + $filters = array( + 'q' => Request::getStr('q', ''), + 'field' => Request::getStr('field', ''), + 'rubric_id' => Request::getInt('rubric_id', 0), + 'state' => Request::getStr('state', ''), + 'page' => max(1, Request::getInt('page', 1)), + 'per_page' => Request::getInt('per_page', 25), + ); + //-- Поля поиска зависят от выбранной рубрики; сбрасываем поле, если + // оно не входит в её набор. + $searchableFields = Model::searchableFields($filters['rubric_id']); + $filters['field'] = Model::normalizeSearchField($filters['field'], $filters['rubric_id']); + $result = Model::items($filters); + $adminView = AdminView::forDocuments($filters['rubric_id'], $result['items']); + $result['items'] = $adminView['documents']; + $stateLabels = array( + '' => 'Рабочие', + 'active' => 'Опубликованные', + 'draft' => 'Черновики', + 'deleted' => 'Удалённые', + ); + + return $this->render('@documents/index.twig', array( + 'documents' => $result['items'], + 'pagination' => $result['pagination'], + 'stats' => Model::stats(), + 'rubrics' => $this->creationRubrics(), + 'searchable_fields' => $searchableFields, + 'filters' => $filters, + 'filter_state_label' => isset($stateLabels[$filters['state']]) ? $stateLabels[$filters['state']] : $stateLabels[''], + 'admin_view' => $adminView, + 'table_name' => Model::documentsTable(), + 'can_manage' => Permission::check('manage_documents'), + 'open_create' => Request::getBool('create', false), + )); + } + + public function create(array $params = array()) + { + if (!Permission::check('manage_documents')) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); + } + + AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); + AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50); + CodeEditor::useRichEditor(); + + $rubricId = Request::getInt('rubric_id', 0); + if ($rubricId <= 0 || !Model::rubric($rubricId)) { + $this->redirect($this->base() . '/documents?create=1'); + return ''; + } + + $document = Model::blank($rubricId); + $presetRepository = new DocumentCreationPresetRepository(); + $preset = null; + $presetId = Request::getInt('preset', 0); + if ($presetId > 0) { + $preset = $presetRepository->find($presetId); + if (!$preset || (int) $preset['rubric_id'] !== $rubricId) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Пресет документа не найден'), 404); + } + + $document = $presetRepository->applyToDocument($document, $preset); + } + + $mediaDraftToken = DocumentMediaDraft::issue(Auth::id(), 0); + + return $this->render('@documents/edit.twig', array( + 'document' => $document, + 'field_groups' => Model::fieldsForRubric( + (int) $document['rubric_id'], + 0, + $mediaDraftToken, + $preset ? $presetRepository->fieldDefaults($preset) : array() + ), + 'media_draft_token' => $mediaDraftToken, + 'rubrics' => Model::rubrics(), + 'templates' => Model::rubricTemplates((int) $document['rubric_id']), + 'navigation_items' => Model::navigationItems(), + 'authors' => Model::authors(), + 'is_new' => true, + 'creation_preset' => $preset, + 'can_manage' => true, + 'actor_id' => Auth::id(), + )); + } + + public function views(array $params = array()) + { + AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); + AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50); + $days = Request::getInt('days', 30); + return $this->render('@documents/views.twig', array( + 'view_stats' => Model::viewStats($days), + 'can_manage' => Permission::check('manage_documents'), + )); + } + + public function redirects(array $params = array()) + { + AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); + AdminAssets::addScript($this->base() . '/modules/Documents/assets/redirects.js', 50); + $filters = array( + 'q' => Request::getStr('q', ''), + 'header' => Request::getInt('header', 0), + 'page' => max(1, Request::getInt('page', 1)), + ); + $result = Model::allAliasHistory($filters); + return $this->render('@documents/redirects.twig', array( + 'redirects' => $result['items'], + 'pagination' => $result['pagination'], + 'stats' => Model::aliasHistoryStats(), + 'filters' => $filters, + 'can_manage' => Permission::check('manage_documents'), + )); + } + + public function apiTokens(array $params = array()) + { + if (!Permission::check('manage_document_api')) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); + } + + AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); + AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50); + return $this->render('@documents/api.twig', array('tokens' => ApiTokenRepository::all(), 'can_manage_api' => true)); + } + + public function issueApiToken(array $params = array()) + { + if (($err = $this->csrfGuard()) !== null) { return $err; } + if (!Permission::check('manage_document_api')) { return $this->error('Недостаточно прав', array(), 403); } + try { + $scopes = Request::post('scopes', array()); + $scopes = is_array($scopes) ? $scopes : array(); + $result = ApiTokenRepository::issue(Auth::id(), Request::postStr('name', ''), $scopes, Request::postStr('expires_at', '')); + AuditLog::record('document.api_token_created', array('actor_id'=>Auth::id(),'target_type'=>'api_token','target_id'=>$result['id'],'meta'=>array('scopes'=>$result['scopes']))); + return $this->success('API-токен создан', array('data' => $result)); + } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + } + + public function revokeApiToken(array $params = array()) + { + if (($err = $this->csrfGuard()) !== null) { return $err; } + if (!Permission::check('manage_document_api')) { return $this->error('Недостаточно прав', array(), 403); } + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!ApiTokenRepository::revoke($id)) { return $this->error('Токен не найден или уже отозван', array(), 404); } + AuditLog::record('document.api_token_revoked', array('actor_id'=>Auth::id(),'target_type'=>'api_token','target_id'=>$id)); + return $this->success('API-токен отозван'); + } + + public function clearViews(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $count = Model::clearViewStats(); + return $this->success('Подневная статистика очищена', array('data' => array('count' => $count))); + } + + public function edit(array $params = array()) + { + if (!Permission::check('manage_documents')) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); + } + + AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); + AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50); + CodeEditor::useRichEditor(); + + $item = Model::one(isset($params['id']) ? (int) $params['id'] : 0); + if (!$item) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Документ не найден'), 404); + } + + $mediaDraftToken = DocumentMediaDraft::issue(Auth::id(), (int) $item['Id']); + + return $this->render('@documents/edit.twig', array( + 'document' => $item, + 'field_groups' => Model::fieldsForDocument((int) $item['Id'], $mediaDraftToken), + 'media_draft_token' => $mediaDraftToken, + 'rubrics' => Model::rubrics(), + 'templates' => Model::rubricTemplates((int) $item['rubric_id']), + 'navigation_items' => Model::navigationItems(), + 'authors' => Model::authors(), + 'is_new' => false, + 'is_homepage' => (int) $item['Id'] === 1, + 'can_manage' => true, + 'quick_edit' => Request::getBool('quick_edit', false), + 'actor_id' => Auth::id(), + )); + } + + public function show(array $params = array()) + { + $item = Model::one(isset($params['id']) ? (int) $params['id'] : 0); + if (!$item) { + return $this->error('Документ не найден', array(), 404); + } + + return $this->success('', array('data' => $item)); + } + + public function aliasCheck(array $params = array()) + { + if (!Permission::check('manage_documents')) { + return $this->error('Недостаточно прав', array(), 403); + } + + $alias = trim(Request::getStr('alias', '')); + $kind = Request::getStr('kind', 'alias') === 'short' ? 'short' : 'alias'; + $id = Request::getInt('id', 0); + if ($alias === '') { + $message = ($kind === 'short' ? 'Короткий alias' : 'Alias') . ' можно оставить пустым'; + return $this->success('Alias не указан', array('data' => array('ok' => true, 'state' => 'empty', 'message' => $message))); + } + + $pattern = $kind === 'short' ? '/^[a-zA-Z0-9_\-]+$/' : '/^[a-zA-Z0-9_\\-\\/\\.]+$/'; + if (!preg_match($pattern, $alias)) { + $message = $kind === 'short' + ? 'Допустимы латиница, цифры, дефис и подчёркивание' + : 'Допустимы латиница, цифры, точка, дефис, подчёркивание и слэш'; + return $this->success('Некорректный alias', array('data' => array('ok' => false, 'state' => 'error', 'message' => $message))); + } + + $conflict = Model::aliasConflict($alias, $id); + if ($conflict) { + $label = isset($conflict['label']) ? (string) $conflict['label'] : 'другой раздел'; + return $this->success('Alias занят', array('data' => array('ok' => false, 'state' => 'error', 'message' => 'URL уже занят: ' . $label))); + } + + return $this->success('Alias свободен', array('data' => array('ok' => true, 'state' => 'ok', 'message' => 'Alias свободен'))); + } + + public function slug(array $params = array()) + { + if (!Permission::check('manage_documents')) { + return $this->error('Недостаточно прав', array(), 403); + } + + $title = trim(Request::postStr('title', '')); + $id = Request::postInt('id', 0); + $parent = Request::postInt('parent', 0); + $rubricId = Request::postInt('rubric_id', 0); + $published = Request::postStr('published', ''); + $publishedAt = $published !== '' ? strtotime($published) : 0; + if ($title === '') { + return $this->error('Укажите название документа', array(), 422); + } + + return $this->success('', array('data' => array( + 'ok' => true, + 'alias' => Model::generateAlias($title, $id, $parent, $rubricId, $publishedAt), + ))); + } + + public function shortAlias(array $params = array()) + { + if (!Permission::check('manage_documents')) { + return $this->error('Недостаточно прав', array(), 403); + } + + $id = Request::postInt('id', 0); + return $this->success('', array('data' => array('ok' => true, 'alias' => Model::generateShortAlias($id)))); + } + + public function documentPicker(array $params = array()) + { + if (!Permission::check('manage_documents')) { + return $this->error('Недостаточно прав', array(), 403); + } + + return $this->success('', array( + 'data' => array( + 'items' => Model::documentPicker( + Request::getStr('q', ''), + Request::getStr('rubric_id', ''), + Request::getInt('limit', 20) + ), + ), + )); + } + + public function termSuggestions(array $params = array()) + { + if (!Permission::check('manage_documents')) { + return $this->error('Недостаточно прав', array(), 403); + } + + $kind = Request::getStr('kind', 'tags'); + if (!in_array($kind, array('keywords', 'tags'), true)) { + return $this->error('Неизвестный тип значений', array(), 422); + } + + return $this->success('', array('data' => array( + 'items' => Model::termSuggestions( + $kind, + Request::getStr('q', ''), + Request::getInt('rubric_id', 0), + Request::getInt('limit', 12) + ), + ))); + } + + public function bulk(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + $postedIds = Request::post('ids', array()); + $ids = is_array($postedIds) ? array_values(array_unique(array_map('intval', $postedIds))) : array(); + $ids = array_values(array_filter($ids, function ($id) { return $id > 0; })); + if (empty($ids) || count($ids) > 200) { return $this->error('Выберите от 1 до 200 документов', array(), 422); } + $action = Request::postStr('action', ''); + if (!in_array($action, array('publish', 'unpublish', 'delete', 'restore', 'purge'), true)) { return $this->error('Неизвестное пакетное действие', array(), 422); } + $done = 0; $errors = array(); + foreach ($ids as $id) { + try { + $doc = Model::one($id); + if (!$doc) { continue; } + if ($action === 'publish' && empty($doc['document_status'])) { Model::toggleStatus($id); } + elseif ($action === 'unpublish' && !empty($doc['document_status'])) { Model::toggleStatus($id); } + elseif ($action === 'delete') { Model::delete($id); } + elseif ($action === 'restore') { Model::restore($id); } + elseif ($action === 'purge') { Model::purge($id); } + $done++; + } catch (\Throwable $e) { $errors[] = '#' . $id . ': ' . $e->getMessage(); } + } + + return $this->success('Пакетное действие выполнено', array('data' => array('done' => $done, 'errors' => $errors))); + } + + public function store(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $input = Model::prepareAliasInput(Request::postAll(), 0); + $fieldValues = isset($input['fields']) && is_array($input['fields']) ? $input['fields'] : array(); + $rubricId = isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0; + $fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, 0); + $input['fields'] = $fieldValues; + try { + DocumentSaveEvents::before('create', 'adminx', 0, $rubricId, Auth::id(), $input, $fieldValues); + DocumentHookRunner::before($rubricId, $input, $fieldValues, 0, Auth::id(), true, 'adminx'); + } catch (DocumentSaveRejected $e) { + return $this->error($e->getMessage(), $e->errors(), 422); + } catch (DocumentHookException $e) { + return $this->error('Код рубрики остановил создание документа', array('rubric_code_start' => $e->getMessage()), 422); + } + + $fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, 0); + $input['fields'] = $fieldValues; + $input = Model::prepareAliasInput($input, 0); + $errors = $this->validate($input, 0); + $errors = array_merge($errors, Model::validateFieldValues($rubricId, $fieldValues, 0)); + if (!empty($errors)) { + return $this->error('Проверьте поля документа', $errors, 422); + } + + $id = 0; + $mediaFinalization = null; + $previousDatabaseExceptionMode = DB::$throw_exception_on_error; + DB::$throw_exception_on_error = true; + DB::startTransaction(); + try { + $id = Model::save(0, $input, Auth::id()); + $mediaFinalization = DocumentMediaFinalizer::finalize( + Request::postStr('media_draft_token', ''), + Auth::id(), + $id, + $rubricId, + $fieldValues + ); + $afterHook = DocumentHookRunner::after($rubricId, $input, $fieldValues, $id, Auth::id(), true, 'adminx'); + if ($afterHook['document_changed'] || $afterHook['save_requested']) { + Model::save($id, $input, Auth::id()); + } + + Model::saveFields($id, $fieldValues); + DB::commit(); + $mediaFinalization->commit(); + } catch (DocumentHookException $e) { + DB::rollback(); + if ($mediaFinalization) { $mediaFinalization->rollback(); } + if ($id > 0) { ContentCacheInvalidator::document($id, false); } + if (ErrorReport::isDatabase($e)) { + return $this->error(ErrorReport::publicMessage( + 'Код рубрики после сохранения не выполнен из-за ошибки базы данных', + $e, + 'DOCREATE', + array('rubric_id' => $rubricId, 'document_id' => $id) + ), array(), 500); + } + + return $this->error( + 'Код рубрики после сохранения остановил создание документа', + array('rubric_code_end' => $e->getMessage()), + 422 + ); + } catch (\Throwable $e) { + DB::rollback(); + if ($mediaFinalization) { $mediaFinalization->rollback(); } + if ($id > 0) { ContentCacheInvalidator::document($id, false); } + return $this->error(ErrorReport::publicMessage( + 'Не удалось создать документ', + $e, + 'DOCREATE', + array('rubric_id' => $rubricId, 'document_id' => $id) + ), array(), 500); + } finally { + DB::$throw_exception_on_error = $previousDatabaseExceptionMode; + } + + $snapshotError = ContentCacheInvalidator::consumeError($id); + $snapshot = (new DocumentSnapshotRepository())->find($id); + DocumentSaveEvents::after('create', 'adminx', $id, $rubricId, Auth::id(), $input, $fieldValues, array(), is_array($snapshot) ? $snapshot : array()); + $nextMediaDraftToken = DocumentMediaDraft::issue(Auth::id(), $id); + return $this->success('Документ создан', array('data' => array( + 'id' => $id, + 'document_version' => Model::documentVersion($id), + 'snapshot_warning' => $snapshotError, + 'media_draft_token' => $nextMediaDraftToken, + ), 'redirect' => $this->base() . '/documents/' . $id . '/edit')); + } + + public function previewPayload(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $id = Request::postInt('id', 0); + $document = $id > 0 ? Model::one($id) : null; + if ($id > 0 && !$document) { + return $this->error('Документ не найден', array(), 404); + } + + $input = Model::prepareAliasInput(Request::postAll(), $id); + $rubricId = $document ? (int) $document['rubric_id'] : (isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0); + $fieldValues = isset($input['fields']) && is_array($input['fields']) ? $input['fields'] : array(); + $fieldValues = Model::conditionalFieldValues($rubricId, $fieldValues, $id); + $input['fields'] = $fieldValues; + $errors = $this->validate($input, $id); + $errors = array_merge($errors, Model::validateFieldValues($rubricId, $fieldValues, $id)); + + return $this->success('Данные сформированы без сохранения', array('data' => array( + 'payload' => Model::previewPayload($id, $input, $fieldValues, Auth::id()), + 'validation_errors' => $errors, + ))); + } + + public function update(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $id = isset($params['id']) ? (int) $params['id'] : 0; + $doc = Model::one($id); + if (!$doc) { + return $this->error('Документ не найден', array(), 404); + } + + $input = Request::postAll(); + $expectedVersion = isset($input['document_version']) ? (int) $input['document_version'] : 0; + if ($expectedVersion <= 0 || $expectedVersion !== (int) $doc['document_version']) { + return $this->editConflict($id, (int) $doc['document_version']); + } + + $fieldValues = isset($input['fields']) && is_array($input['fields']) ? $input['fields'] : array(); + $fieldValues = Model::conditionalFieldValues((int) $doc['rubric_id'], $fieldValues, $id); + $input['fields'] = $fieldValues; + try { + DocumentSaveEvents::before('update', 'adminx', $id, (int) $doc['rubric_id'], Auth::id(), $input, $fieldValues, $doc); + DocumentHookRunner::before((int) $doc['rubric_id'], $input, $fieldValues, $id, Auth::id(), false, 'adminx'); + } catch (DocumentSaveRejected $e) { + return $this->error($e->getMessage(), $e->errors(), 422); + } catch (DocumentHookException $e) { + return $this->error('Код рубрики остановил сохранение документа', array('rubric_code_start' => $e->getMessage()), 422); + } + + $fieldValues = Model::conditionalFieldValues((int) $doc['rubric_id'], $fieldValues, $id); + $input['fields'] = $fieldValues; + $errors = $this->validate($input, $id); + $errors = array_merge($errors, Model::validateFieldValues((int) $doc['rubric_id'], $fieldValues, $id)); + if (!empty($errors)) { + return $this->error('Проверьте поля документа', $errors, 422); + } + + $mediaFinalization = null; + $previousDatabaseExceptionMode = DB::$throw_exception_on_error; + DB::$throw_exception_on_error = true; + DB::startTransaction(); + try { + Model::assertEditVersion($id, $expectedVersion); + Revisions::capture($id, Auth::id()); + Model::save($id, $input, Auth::id()); + $mediaFinalization = DocumentMediaFinalizer::finalize( + Request::postStr('media_draft_token', ''), + Auth::id(), + $id, + (int) $doc['rubric_id'], + $fieldValues + ); + $afterHook = DocumentHookRunner::after((int) $doc['rubric_id'], $input, $fieldValues, $id, Auth::id(), false, 'adminx'); + if ($afterHook['document_changed'] || $afterHook['save_requested']) { + Model::save($id, $input, Auth::id()); + } + + Model::saveFields($id, $fieldValues); + DB::commit(); + $mediaFinalization->commit(); + } catch (EditConflict $e) { + DB::rollback(); + if ($mediaFinalization) { $mediaFinalization->rollback(); } + return $this->editConflict($id, $e->currentVersion()); + } catch (DocumentHookException $e) { + DB::rollback(); + if ($mediaFinalization) { $mediaFinalization->rollback(); } + ContentCacheInvalidator::document($id, false); + if (ErrorReport::isDatabase($e)) { + return $this->error(ErrorReport::publicMessage( + 'Код рубрики после сохранения не выполнен из-за ошибки базы данных', + $e, + 'DOCUPDATE', + array('rubric_id' => (int) $doc['rubric_id'], 'document_id' => $id) + ), array(), 500); + } + + return $this->error( + 'Код рубрики после сохранения остановил сохранение документа', + array('rubric_code_end' => $e->getMessage()), + 422 + ); + } catch (\Throwable $e) { + DB::rollback(); + if ($mediaFinalization) { $mediaFinalization->rollback(); } + ContentCacheInvalidator::document($id, false); + return $this->error(ErrorReport::publicMessage( + 'Не удалось сохранить документ', + $e, + 'DOCUPDATE', + array('rubric_id' => (int) $doc['rubric_id'], 'document_id' => $id) + ), array(), 500); + } finally { + DB::$throw_exception_on_error = $previousDatabaseExceptionMode; + } + + $snapshotError = ContentCacheInvalidator::consumeError($id); + $snapshot = (new DocumentSnapshotRepository())->find($id); + DocumentSaveEvents::after('update', 'adminx', $id, (int) $doc['rubric_id'], Auth::id(), $input, $fieldValues, $doc, is_array($snapshot) ? $snapshot : array()); + return $this->success('Документ сохранён', array('data' => array( + 'id' => $id, + 'document_version' => Model::documentVersion($id), + 'snapshot_warning' => $snapshotError, + 'media_draft_token' => DocumentMediaDraft::issue(Auth::id(), $id), + ), 'redirect' => $this->base() . '/documents/' . $id . '/edit')); + } + + public function destroy(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + if (!Model::delete(isset($params['id']) ? (int) $params['id'] : 0)) { + return $this->error('Документ не найден', array(), 404); + } + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Документ помечен удалённым'); + } + + public function revisions(array $params = array()) + { + $id = isset($params['id']) ? (int) $params['id'] : 0; + $item = Model::one($id); + if (!$item) { + return $this->error('Документ не найден', array(), 404); + } + + return $this->success('', array( + 'data' => array( + 'document' => array('id' => $item['Id'], 'title' => $item['document_title']), + 'revisions' => Revisions::listForDocument($id), + ), + )); + } + + public function snapshot(array $params = array()) + { + if (!Permission::check('manage_documents')) { + return $this->error('Недостаточно прав', array(), 403); + } + + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($id)) { + return $this->error('Документ не найден', array(), 404); + } + + return $this->success('', array('data' => $this->snapshotInfo($id))); + } + + public function rebuildSnapshot(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $id = isset($params['id']) ? (int) $params['id'] : 0; + $document = Model::one($id); + if (!$document) { + return $this->error('Документ не найден', array(), 404); + } + + if (!empty($document['document_deleted'])) { + ContentCacheInvalidator::document($id, false); + return $this->error('Для удалённого документа JSON-снимок не создаётся', array(), 422); + } + + if (!ContentCacheInvalidator::document($id, true)) { + return $this->error(ContentCacheInvalidator::consumeError($id) ?: 'Не удалось пересобрать JSON-снимок', array(), 500); + } + + return $this->success('JSON-снимок пересобран', array('data' => $this->snapshotInfo($id))); + } + + public function rebuildSnapshots(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $after = max(0, Request::postInt('after', 0)); + $rubricId = max(0, Request::postInt('rubric_id', 0)); + $limit = max(1, min(100, Request::postInt('limit', 50))); + $sql = 'SELECT Id FROM ' . Model::documentsTable() . " WHERE Id > %i AND document_deleted != '1'"; + $args = array($after); + if ($rubricId > 0) { + $sql .= ' AND rubric_id = %i'; + $args[] = $rubricId; + } + + $sql .= ' ORDER BY Id ASC LIMIT %i'; + $args[] = $limit + 1; + array_unshift($args, $sql); + $rows = call_user_func_array(array('DB', 'query'), $args)->getAll(); + $hasMore = count($rows) > $limit; + if ($hasMore) { + array_pop($rows); + } + + $done = 0; + $failed = array(); + $next = $after; + foreach ($rows as $row) { + $id = (int) $row['Id']; + $next = $id; + if (ContentCacheInvalidator::document($id, true)) { + $done++; + } else { + $failed[] = array('id' => $id, 'error' => ContentCacheInvalidator::consumeError($id) ?: 'Неизвестная ошибка'); + } + } + + return $this->success('Пакет JSON-снимков обработан', array('data' => array( + 'done' => $done, + 'failed' => $failed, + 'next_after' => $next, + 'has_more' => $hasMore, + ))); + } + + public function aliases(array $params = array()) + { + $id = isset($params['id']) ? (int) $params['id'] : 0; + $item = Model::one($id); + if (!$item) { + return $this->error('Документ не найден', array(), 404); + } + + return $this->success('', array('data' => array( + 'document' => array('id' => $id, 'title' => $item['document_title'], 'alias' => $item['document_alias']), + 'aliases' => Model::aliasHistory($id), + ))); + } + + public function saveAlias(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $id = Model::saveAliasHistory( + isset($params['id']) ? (int) $params['id'] : 0, + isset($params['alias']) ? (int) $params['alias'] : 0, + Request::postAll(), + Auth::id() + ); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Редирект сохранён', array('data' => array('id' => $id))); + } + + public function deleteAlias(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + if (!Model::deleteAliasHistory( + isset($params['id']) ? (int) $params['id'] : 0, + isset($params['alias']) ? (int) $params['alias'] : 0 + )) { + return $this->error('Редирект не найден', array(), 404); + } + + return $this->success('Редирект удалён'); + } + + public function remarks(array $params = array()) + { + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($id)) { + return $this->error('Документ не найден', array(), 404); + } + + return $this->success('', array('data' => array('remarks' => Model::remarks($id)))); + } + + public function addRemark(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $id = Model::addRemark(isset($params['id']) ? (int) $params['id'] : 0, Request::postAll(), Auth::id()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Заметка добавлена', array('data' => array('id' => $id))); + } + + public function deleteRemark(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + if (!Model::deleteRemark( + isset($params['id']) ? (int) $params['id'] : 0, + isset($params['remark']) ? (int) $params['remark'] : 0 + )) { + return $this->error('Заметка не найдена', array(), 404); + } + + return $this->success('Заметка удалена'); + } + + public function revision(array $params = array()) + { + if (!Permission::check('manage_documents')) { + return $this->error('Недостаточно прав', array(), 403); + } + + $revision = Revisions::one(isset($params['revision']) ? (int) $params['revision'] : 0); + if (!$revision) { + return $this->error('Ревизия не найдена', array(), 404); + } + + $document = Model::one((int) $revision['doc_id']); + if (!$document) { + return $this->error('Документ не найден', array(), 404); + } + + $alias = trim(isset($document['document_alias']) ? (string) $document['document_alias'] : '', '/ '); + $publicUrl = $alias === '' + ? (defined('ABS_PATH') ? (string) ABS_PATH : '/') + : (defined('ABS_PATH') ? (string) ABS_PATH : '/') . $alias . (defined('URL_SUFF') ? (string) URL_SUFF : ''); + $publicUrl .= (strpos($publicUrl, '?') === false ? '?' : '&') . http_build_query(array( + 'revision_preview' => (int) $revision['id'], + 'preview_token' => DocumentRevisionPreview::issue((int) $revision['doc_id'], (int) $revision['id'], Auth::id()), + )); + $revision['preview_url'] = $publicUrl; + + return $this->success('', array('data' => $revision)); + } + + public function restoreRevision(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $selected = Request::postStr('selection_mode', '') === 'selected'; + $documentKeys = Request::post('document_keys', array()); + $fieldIds = Request::post('field_ids', array()); + $documentId = $selected + ? Revisions::restoreSelected( + isset($params['revision']) ? (int) $params['revision'] : 0, + is_array($documentKeys) ? $documentKeys : array(), + is_array($fieldIds) ? $fieldIds : array(), + Auth::id() + ) + : Revisions::restore(isset($params['revision']) ? (int) $params['revision'] : 0, Auth::id()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success($selected ? 'Выбранные данные восстановлены из ревизии' : 'Документ восстановлен из ревизии', array( + 'data' => array('id' => $documentId), + 'redirect' => $this->base() . '/documents/' . $documentId . '/edit', + )); + } + + public function deleteRevision(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $documentId = Revisions::delete(isset($params['revision']) ? (int) $params['revision'] : 0); + if (!$documentId) { + return $this->error('Ревизия не найдена', array(), 404); + } + + return $this->success('Ревизия удалена', array('data' => array('id' => $documentId))); + } + + public function deleteRevisions(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($id)) { + return $this->error('Документ не найден', array(), 404); + } + + $count = Revisions::deleteForDocument($id); + return $this->success('Ревизии удалены', array('data' => array('id' => $id, 'count' => $count))); + } + + public function restore(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + if (!Model::restore(isset($params['id']) ? (int) $params['id'] : 0)) { + return $this->error('Документ не найден', array(), 404); + } + + return $this->success('Документ восстановлен'); + } + + public function toggle(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $status = Model::toggleStatus(isset($params['id']) ? (int) $params['id'] : 0); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + if ($status === false) { + return $this->error('Документ не найден', array(), 404); + } + + return $this->success($status ? 'Документ опубликован' : 'Документ снят с публикации', array('data' => array('status' => $status))); + } + + public function copy(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $id = Model::copy(isset($params['id']) ? (int) $params['id'] : 0, Auth::id()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Копия документа создана', array( + 'data' => array('id' => $id), + 'redirect' => $this->base() . '/documents/' . $id . '/edit', + )); + } + + public function saveCreationPreset(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + $documentId = isset($params['id']) ? (int) $params['id'] : 0; + try { + $preset = (new DocumentCreationPresetRepository())->createFromDocument( + $documentId, + Request::postAll(), + Auth::id() + ); + } catch (\InvalidArgumentException $e) { + return $this->error('Проверьте настройки пресета', array('title' => $e->getMessage()), 422); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + AuditLog::record('document.creation_preset_created', array( + 'actor_id' => Auth::id(), + 'target_type' => 'document_creation_preset', + 'target_id' => (int) $preset['id'], + 'meta' => array( + 'rubric_id' => (int) $preset['rubric_id'], + 'source_document_id' => $documentId, + 'fields_count' => (int) $preset['fields_count'], + ), + )); + return $this->success('Пресет создания сохранён', array('data' => array('preset' => $preset))); + } + + public function deleteCreationPreset(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + $preset = (new DocumentCreationPresetRepository())->delete( + isset($params['preset']) ? (int) $params['preset'] : 0 + ); + if (!$preset) { return $this->error('Пресет не найден', array(), 404); } + + AuditLog::record('document.creation_preset_deleted', array( + 'actor_id' => Auth::id(), + 'target_type' => 'document_creation_preset', + 'target_id' => (int) $preset['id'], + 'meta' => array('rubric_id' => (int) $preset['rubric_id'], 'title' => (string) $preset['title']), + )); + return $this->success('Пресет удалён'); + } + + public function purge(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + if (!Model::purge(isset($params['id']) ? (int) $params['id'] : 0)) { + return $this->error('Документ не найден', array(), 404); + } + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Документ и связанные данные удалены окончательно'); + } + + protected function guard() + { + return $this->guardPermission('manage_documents'); + } + + protected function creationRubrics() + { + $rubrics = Model::rubrics(); + $presets = (new DocumentCreationPresetRepository())->all(); + $byRubric = array(); + foreach ($presets as $preset) { + $byRubric[(int) $preset['rubric_id']][] = $preset; + } + + foreach ($rubrics as &$rubric) { + $rubric['creation_presets'] = isset($byRubric[(int) $rubric['Id']]) + ? $byRubric[(int) $rubric['Id']] + : array(); + } + + unset($rubric); + return $rubrics; + } + + protected function editConflict($documentId, $currentVersion) + { + return $this->json(array( + 'success' => false, + 'message' => 'Документ уже изменён в другой вкладке или другим пользователем.', + 'data' => array( + 'conflict' => true, + 'current_version' => max(1, (int) $currentVersion), + 'reload_url' => $this->base() . '/documents/' . (int) $documentId . '/edit', + ), + 'html' => new \stdClass(), + 'redirect' => null, + 'errors' => new \stdClass(), + ), 409); + } + + protected function snapshotInfo($documentId) + { + $store = new DocumentSnapshotStore(); + $status = $store->status($documentId); + $snapshot = $store->read($documentId); + $document = Model::one($documentId); + $current = false; + if (is_array($snapshot) && $document) { + try { + $schema = (new RubricSchemaBuilder($store))->get((int) $document['rubric_id']); + $current = isset($snapshot['format'], $snapshot['schema_version'], $snapshot['rubric_schema_version'], $snapshot['template_set_version']) + && (string) $snapshot['format'] === DocumentSnapshotStore::FORMAT + && (int) $snapshot['schema_version'] === DocumentSnapshotStore::VERSION + && (string) $snapshot['rubric_schema_version'] === (string) $schema['version'] + && (string) $snapshot['template_set_version'] === FieldTemplateManifest::version(); + } catch (\Throwable $e) { + $current = false; + } + } + + $status['current'] = $current; + $status['fields_count'] = is_array($snapshot) && isset($snapshot['fields']) ? count($snapshot['fields']) : 0; + $status['generated_label'] = !empty($status['generated_at']) ? date('d.m.Y H:i:s', $status['generated_at']) : 'ещё не создан'; + $status['size_label'] = $this->bytes(isset($status['size']) ? (int) $status['size'] : 0); + $status['path'] = str_replace(rtrim(BASEPATH, '/\\') . '/', '', (string) $status['path']); + return $status; + } + + protected function bytes($bytes) + { + if ($bytes < 1024) { return $bytes . ' Б'; } + if ($bytes < 1048576) { return number_format($bytes / 1024, 1, ',', ' ') . ' КБ'; } + return number_format($bytes / 1048576, 1, ',', ' ') . ' МБ'; + } + + protected function validate(array $input, $id) + { + $errors = array(); + $title = trim(isset($input['document_title']) ? (string) $input['document_title'] : ''); + $rubricId = isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0; + $alias = trim(isset($input['document_alias']) ? (string) $input['document_alias'] : ''); + $shortAlias = trim(isset($input['document_short_alias']) ? (string) $input['document_short_alias'] : ''); + + if ($title === '') { + $errors['document_title'] = 'Укажите название документа'; + } + + if ($rubricId <= 0 || !Model::rubric($rubricId)) { + $errors['rubric_id'] = 'Выберите рубрику'; + } + + if ($alias !== '' && !preg_match('/^[a-zA-Z0-9_\\-\\/\\.]+$/', $alias)) { + $errors['document_alias'] = 'Допустимы латиница, цифры, точка, дефис, подчёркивание и слэш'; + } elseif ($alias !== '' && ($conflict = Model::aliasConflict($alias, (int) $id))) { + $errors['document_alias'] = 'URL уже занят: ' . (isset($conflict['label']) ? $conflict['label'] : 'другой раздел'); + } + + if ($shortAlias !== '' && !preg_match('/^[a-zA-Z0-9_\-]+$/', $shortAlias)) { + $errors['document_short_alias'] = 'Допустимы латиница, цифры, дефис и подчёркивание'; + } elseif ($shortAlias !== '' && ($conflict = Model::aliasConflict($shortAlias, (int) $id))) { + $errors['document_short_alias'] = 'URL уже занят: ' . (isset($conflict['label']) ? $conflict['label'] : 'другой раздел'); + } + + return $errors; + } + } diff --git a/adminx/modules/Documents/DocumentHookRunner.php b/adminx/modules/Documents/DocumentHookRunner.php new file mode 100644 index 0000000..f12fce0 --- /dev/null +++ b/adminx/modules/Documents/DocumentHookRunner.php @@ -0,0 +1,263 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Documents; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Content\Documents\DocumentHookContext; + use App\Content\Documents\DocumentHookRuntime; + + class DocumentHookRunner + { + protected $documentId; + protected $runtimeData; + protected $runtimeFields; + protected $saveRequested = false; + + public function __construct($documentId, &$runtimeData = null, &$runtimeFields = null) + { + $this->documentId = (int) $documentId; + $this->runtimeData =& $runtimeData; + $this->runtimeFields =& $runtimeFields; + } + + public static function before($rubricId, array &$input, array &$fields, $documentId = 0, $actorId = 0, $isNew = null, $source = 'adminx') + { + return self::run( + 'rubric_code_start', + DocumentHookContext::BEFORE_SAVE, + $rubricId, + $input, + $fields, + $documentId, + $actorId, + $isNew === null ? (int) $documentId <= 0 : (bool) $isNew, + $source + ); + } + + public static function after($rubricId, array &$input, array &$fields, $documentId, $actorId = 0, $isNew = false, $source = 'adminx') + { + return self::run( + 'rubric_code_end', + DocumentHookContext::AFTER_SAVE, + $rubricId, + $input, + $fields, + $documentId, + $actorId, + (bool) $isNew, + $source + ); + } + + public function documentSave($rubricId, $documentId, array $data) + { + // Historical hooks recursively called documentSave() after changing + // $data. Native persistence is performed once by the caller, but the + // supplied values must still be returned to that save pipeline. + if (is_array($this->runtimeData)) { + $this->runtimeData = $data; + if (isset($data['field']) && is_array($data['field'])) { + $this->runtimeFields = $data['field']; + } elseif (isset($data['feld']) && is_array($data['feld'])) { + $this->runtimeFields = $data['feld']; + } + + $this->runtimeData['field'] =& $this->runtimeFields; + $this->runtimeData['feld'] =& $this->runtimeFields; + } + + $this->saveRequested = true; + return $this->documentId > 0 ? $this->documentId : (int) $documentId; + } + + protected static function run($column, $phase, $rubricId, array &$input, array &$fields, $documentId, $actorId, $isNew, $source) + { + $code = (string) DB::query( + 'SELECT `' . $column . '` FROM ' . Model::rubricsTable() . ' WHERE Id = %i LIMIT 1', + (int) $rubricId + )->getValue(); + if (trim($code) === '') { + return self::emptyResult(); + } + + $inputBefore = $input; + $fieldsBefore = $fields; + $field = self::legacyFields($fields); + $data = $input; + $originalTitle = isset($input['document_title']) ? (string) $input['document_title'] : ''; + $data['doc_title'] = $originalTitle; + $data['field'] =& $field; + $data['feld'] =& $field; + $logs = array(); + $hook = new DocumentHookContext( + $phase, + (int) $rubricId, + (int) $documentId, + $data, + $field, + $logs, + array( + 'actor_id' => (int) $actorId, + 'is_new' => (bool) $isNew, + 'source' => (string) $source, + 'field_aliases' => self::fieldAliases((int) $rubricId), + ) + ); + $context = array( + 'data' => &$data, + 'fields' => &$field, + 'document_logs' => &$logs, + 'document_id' => (int) $documentId, + 'rubric_id' => (int) $rubricId, + ); + $runner = new self($documentId, $data, $field); + $runtime = DocumentHookRuntime::execute($code, $hook, $context, $runner); + self::syncTitleAliases($data, $originalTitle); + self::syncInput($input, $data); + self::syncFields($fields, $field); + foreach ($hook->skippedFieldIds() as $skippedFieldId) { + unset($fields[(int) $skippedFieldId]); + } + + return array_merge($runtime, array( + 'document_changed' => self::fingerprint($inputBefore) !== self::fingerprint($input), + 'fields_changed' => self::fingerprint($fieldsBefore) !== self::fingerprint($fields), + 'save_requested' => $runner->saveRequested, + 'logs' => $logs, + )); + } + + protected static function emptyResult() + { + return array( + 'executed' => false, + 'duration' => 0.0, + 'output' => '', + 'document_changed' => false, + 'fields_changed' => false, + 'save_requested' => false, + 'logs' => array(), + ); + } + + protected static function fieldAliases($rubricId) + { + $rows = DB::query( + 'SELECT Id, rubric_field_alias FROM ' . Model::fieldsTable() . ' WHERE rubric_id = %i', + (int) $rubricId + )->getAll(); + $aliases = array(); + foreach ($rows as $row) { + $alias = trim(isset($row['rubric_field_alias']) ? (string) $row['rubric_field_alias'] : ''); + if ($alias !== '') { + $aliases[$alias] = (int) $row['Id']; + } + } + + return $aliases; + } + + protected static function fingerprint(array $value) + { + return md5(serialize($value)); + } + + protected static function syncTitleAliases(array &$data, $originalTitle) + { + $documentTitle = isset($data['document_title']) ? (string) $data['document_title'] : ''; + $legacyTitle = isset($data['doc_title']) ? (string) $data['doc_title'] : ''; + if ($documentTitle !== (string) $originalTitle) { + $data['doc_title'] = $documentTitle; + } elseif ($legacyTitle !== (string) $originalTitle) { + $data['document_title'] = $legacyTitle; + } + } + + protected static function legacyFields(array $fields) + { + $legacy = array(); + foreach ($fields as $id => $value) { + if (!is_array($value)) { + $legacy[$id] = $value; + } elseif (array_key_exists('items', $value)) { + $legacy[$id] = $value['items']; + } elseif (array_key_exists('raw', $value)) { + $legacy[$id] = $value['raw']; + } elseif (array_key_exists('url', $value)) { + $legacy[$id] = array( + 'img' => $value['url'], 'url' => $value['url'], + 'descr' => isset($value['description']) ? $value['description'] : '', + 'description' => isset($value['description']) ? $value['description'] : '', + ); + } else { + $legacy[$id] = $value; + } + } + + return $legacy; + } + + protected static function syncInput(array &$input, array $data) + { + if (!array_key_exists('document_breadcrumb_title', $data) && array_key_exists('document_breadcrum_title', $data)) { + $data['document_breadcrumb_title'] = $data['document_breadcrum_title']; + } + + if (!array_key_exists('document_excerpt', $data) && array_key_exists('document_teaser', $data)) { + $data['document_excerpt'] = $data['document_teaser']; + } + + foreach (array( + 'document_title', 'document_alias', 'document_alias_header', 'document_alias_history', + 'document_short_alias', 'document_breadcrumb_title', 'document_excerpt', + 'document_meta_keywords', 'document_meta_description', 'document_meta_robots', + 'document_sitemap_freq', 'document_sitemap_pr', 'document_tags', 'document_property', + 'guid', 'document_status', 'document_in_search', 'document_parent', 'rubric_tmpl_id', + 'document_linked_navi_id', 'document_position', 'document_published', 'document_expire', + 'document_author_id', + ) as $key) { + if (array_key_exists($key, $data)) { + $input[$key] = $data[$key]; + } + } + + if (isset($data['doc_title'])) { + $input['document_title'] = $data['doc_title']; + } + } + + protected static function syncFields(array &$fields, array $legacy) + { + foreach ($legacy as $id => $value) { + if (!array_key_exists($id, $fields)) { + continue; + } + + if (is_array($fields[$id]) && array_key_exists('items', $fields[$id])) { + $fields[$id]['items'] = is_array($value) ? $value : array(); + } elseif (is_array($fields[$id]) && array_key_exists('raw', $fields[$id])) { + $fields[$id]['raw'] = is_array($value) ? '' : (string) $value; + } elseif (is_array($fields[$id]) && array_key_exists('url', $fields[$id]) && is_array($value)) { + $fields[$id]['url'] = isset($value['url']) ? $value['url'] : (isset($value['img']) ? $value['img'] : ''); + $fields[$id]['description'] = isset($value['description']) ? $value['description'] : (isset($value['descr']) ? $value['descr'] : ''); + } else { + $fields[$id] = $value; + } + } + } + } diff --git a/adminx/modules/Documents/EditConflict.php b/adminx/modules/Documents/EditConflict.php new file mode 100644 index 0000000..93859ee --- /dev/null +++ b/adminx/modules/Documents/EditConflict.php @@ -0,0 +1,33 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Documents; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + class EditConflict extends \RuntimeException + { + protected $currentVersion; + + public function __construct($currentVersion) + { + parent::__construct('Документ уже изменён в другой вкладке или другим пользователем'); + $this->currentVersion = max(1, (int) $currentVersion); + } + + public function currentVersion() + { + return $this->currentVersion; + } + } diff --git a/adminx/modules/Documents/Model.php b/adminx/modules/Documents/Model.php new file mode 100644 index 0000000..9cb7336 --- /dev/null +++ b/adminx/modules/Documents/Model.php @@ -0,0 +1,2579 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Documents; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Helpers\Str; + use App\Content\Fields\FieldValidator; + use App\Content\Fields\FieldConditionEvaluator; + use App\Content\Fields\FieldValueCodec; + use App\Content\Fields\FieldContext; + use App\Content\Fields\FieldRegistry; + use App\Content\Fields\FieldLifecycle; + use App\Content\Fields\FieldSettings; + use App\Content\ContentTables; + use App\Content\DocumentTerms; + use App\Content\Documents\ContentCacheInvalidator; + use App\Content\Documents\DocumentAliasRegistry; + use App\Content\Documents\DocumentAliasTemplate; + use App\Content\Documents\DocumentMediaDraft; + use App\Content\Documents\DocumentMediaPath; + use App\Common\Settings; + use App\Common\SystemTables; + use App\Common\Lifecycle; + use App\Common\DatabaseSchema; + use App\Adminx\Rubrics\FieldAdminEditors; + use App\Adminx\Catalog\Model as CatalogModel; + + class Model + { + public static function documentsTable() { return ContentTables::table('documents'); } + public static function rubricsTable() { return ContentTables::table('rubrics'); } + public static function fieldsTable() { return ContentTables::table('rubric_fields'); } + public static function groupsTable() { return ContentTables::table('rubric_fields_group'); } + public static function docFieldsTable() { return ContentTables::table('document_fields'); } + public static function docFieldsTextTable() { return ContentTables::table('document_fields_text'); } + public static function revisionsTable() { return ContentTables::table('document_rev'); } + public static function aliasHistoryTable() { return ContentTables::table('document_alias_history'); } + public static function keywordsTable() { return ContentTables::table('document_keywords'); } + public static function tagsTable() { return ContentTables::table('document_tags'); } + public static function remarksTable() { return ContentTables::table('document_remarks'); } + public static function viewCountTable() { return ContentTables::table('view_count'); } + + public static function items(array $filters) + { + $page = max(1, isset($filters['page']) ? (int) $filters['page'] : 1); + $allowedPerPage = array(25, 50, 100, 300); + $perPage = isset($filters['per_page']) ? (int) $filters['per_page'] : 25; + $limit = in_array($perPage, $allowedPerPage, true) ? $perPage : 25; + $offset = ($page - 1) * $limit; + $where = ' WHERE 1=1'; + $args = array(); + + $rubricId = isset($filters['rubric_id']) ? (int) $filters['rubric_id'] : 0; + $q = trim(isset($filters['q']) ? (string) $filters['q'] : ''); + $field = self::normalizeSearchField(isset($filters['field']) ? (string) $filters['field'] : '', $rubricId); + if ($q !== '') { + if ($field !== '') { + // Поиск по значению выбранного поля рубрики (напр. «Артикул»). + $where .= ' AND EXISTS (SELECT 1 FROM ' . self::docFieldsTable() . ' df' + . ' INNER JOIN ' . self::fieldsTable() . ' rf ON rf.Id = df.rubric_field_id' + . ' LEFT JOIN ' . self::docFieldsTextTable() . ' dft ON dft.document_id = df.document_id AND dft.rubric_field_id = df.rubric_field_id' + . ' WHERE df.document_id = d.Id AND rf.rubric_field_title = %s' + . ' AND (df.field_value LIKE %ss OR dft.field_value LIKE %ss))'; + $args[] = $field; + $args[] = $q; + $args[] = $q; + } else { + $where .= ' AND (d.document_title LIKE %ss OR d.document_alias LIKE %ss OR d.Id = %i)'; + $args[] = $q; + $args[] = $q; + $args[] = (int) $q; + } + } + + if ($rubricId > 0) { + $where .= ' AND d.rubric_id = %i'; + $args[] = $rubricId; + } + + $state = isset($filters['state']) ? (string) $filters['state'] : ''; + if ($state === 'active') { + $where .= " AND d.document_status = '1' AND d.document_deleted != '1'"; + } elseif ($state === 'draft') { + $where .= " AND d.document_status = '0' AND d.document_deleted != '1'"; + } elseif ($state === 'deleted') { + $where .= " AND d.document_deleted = '1'"; + } else { + $where .= " AND d.document_deleted != '1'"; + } + + $countArgs = array_merge(array('SELECT COUNT(*) FROM ' . self::documentsTable() . ' d' . $where), $args); + $total = (int) call_user_func_array(array('DB', 'query'), $countArgs)->getValue(); + + $sql = 'SELECT d.*, r.rubric_title, r.rubric_alias' + . ' FROM ' . self::documentsTable() . ' d' + . ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id' + . $where + . ' ORDER BY d.document_changed DESC, d.Id DESC LIMIT ' . (int) $limit . ' OFFSET ' . (int) $offset; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + + $items = array(); + foreach ($rows as $row) { + $items[] = self::row($row); + } + + return array( + 'items' => $items, + 'pagination' => array( + 'page' => $page, + 'limit' => $limit, + 'total' => $total, + 'pages' => max(1, (int) ceil($total / $limit)), + 'from' => $total > 0 ? $offset + 1 : 0, + 'to' => min($total, $offset + count($items)), + ), + ); + } + + /** + * Поля рубрик (по названию) для выбора «искать по полю». Если задана + * рубрика — только её поля (у каждой рубрики свой набор), иначе все. + * Идентифицируем по названию, т.к. alias есть не у всех полей. + */ + public static function searchableFields($rubricId = 0) + { + $rubricId = (int) $rubricId; + $sql = 'SELECT rubric_field_title AS title' + . ' FROM ' . self::fieldsTable() + . " WHERE TRIM(rubric_field_title) <> ''"; + $args = array(); + if ($rubricId > 0) { + $sql .= ' AND rubric_id = %i'; + $args[] = $rubricId; + } + + $sql .= ' GROUP BY rubric_field_title ORDER BY rubric_field_title ASC'; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + + $fields = array(); + foreach ($rows as $row) { + $title = trim((string) $row['title']); + if ($title === '') { continue; } + $fields[] = array('value' => $title, 'title' => $title); + } + + return $fields; + } + + /** Валидирует выбранное поле поиска по списку доступных полей рубрики. */ + public static function normalizeSearchField($field, $rubricId = 0) + { + $field = trim((string) $field); + if ($field === '') { + return ''; + } + + foreach (self::searchableFields($rubricId) as $item) { + if ($item['value'] === $field) { + return $field; + } + } + + return ''; + } + + public static function one($id) + { + $row = DB::query( + 'SELECT d.*, r.rubric_title, r.rubric_alias FROM ' . self::documentsTable() . ' d' + . ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id' + . ' WHERE d.Id = %i LIMIT 1', + (int) $id + )->getAssoc(); + return $row ? self::row($row, true) : null; + } + + public static function blank($rubricId = 0) + { + $rubricId = self::blankRubricId((int) $rubricId); + $rubric = $rubricId > 0 ? self::rubric($rubricId) : null; + return array( + 'Id' => 0, + 'rubric_id' => $rubricId, + 'document_title' => '', + 'document_alias' => '', + 'document_alias_header' => 301, + 'document_alias_history' => 0, + 'document_short_alias' => '', + 'document_breadcrumb_title' => '', + 'document_excerpt' => '', + 'document_meta_keywords' => '', + 'document_meta_description' => '', + 'document_meta_robots' => '', + 'document_sitemap_freq' => 3, + 'document_sitemap_pr' => 0.5, + 'document_tags' => '', + 'document_property' => '', + 'guid' => '', + 'document_status' => 1, + 'document_in_search' => 1, + 'document_author_id' => 1, + 'document_parent' => 0, + 'rubric_tmpl_id' => 0, + 'document_linked_navi_id' => 0, + 'document_position' => 0, + 'document_deleted' => 0, + 'document_version' => 1, + 'parent_info' => null, + 'published_input' => date('Y-m-d\\TH:i'), + 'expire_input' => '', + 'changed_label' => 'ещё не сохранён', + 'published_label' => 'после сохранения', + 'state_label' => 'Новый', + 'state_badge' => 'blue', + 'rubric_title' => $rubric ? self::decode($rubric['rubric_title']) : '', + 'fields_count' => 0, + ); + } + + public static function blankRubricId($preferred = 0) + { + if ((int) $preferred > 0 && self::rubric((int) $preferred)) { + return (int) $preferred; + } + + $rubrics = self::rubrics(); + return !empty($rubrics) ? (int) $rubrics[0]['Id'] : 0; + } + + public static function fieldsForDocument($documentId, $mediaDraftToken = '') + { + $doc = self::one((int) $documentId); + if (!$doc) { + return array(); + } + + return self::fieldsForRubric((int) $doc['rubric_id'], (int) $documentId, $mediaDraftToken); + } + + public static function fieldsForRubric($rubricId, $documentId = 0, $mediaDraftToken = '', array $initialValues = array()) + { + $conditionsEnabled = self::rubricConditionsEnabled((int) $rubricId); + $groupSettings = $conditionsEnabled && self::hasGroupSettingsColumn() ? ', g.group_settings' : ', NULL AS group_settings'; + $rows = DB::query( + 'SELECT f.*, g.group_title, g.group_description, g.group_position' . $groupSettings . ',' + . ' df.Id AS document_field_id, df.field_value, df.field_number_value, df.document_in_search AS field_in_search,' + . ' dft.field_value AS field_value_more' + . ' FROM ' . self::fieldsTable() . ' f' + . ' LEFT JOIN ' . self::groupsTable() . ' g ON g.Id = f.rubric_field_group' + . ' LEFT JOIN ' . self::docFieldsTable() . ' df ON df.rubric_field_id = f.Id AND df.document_id = %i' + . ' LEFT JOIN ' . self::docFieldsTextTable() . ' dft ON dft.rubric_field_id = f.Id AND dft.document_id = %i' + . ' WHERE f.rubric_id = %i' + . ' ORDER BY COALESCE(g.group_position, 999999) ASC, f.rubric_field_position ASC, f.Id ASC', + (int) $documentId, + (int) $documentId, + (int) $rubricId + )->getAll(); + + if ((int) $documentId <= 0 && $initialValues) { + foreach ($rows as &$initialRow) { + $alias = (string) $initialRow['rubric_field_alias']; + if (array_key_exists($alias, $initialValues) && (is_scalar($initialValues[$alias]) || $initialValues[$alias] === null)) { + $initialRow['__initial_value'] = (string) $initialValues[$alias]; + } + } + + unset($initialRow); + } + + $catalogAllowed = array(); + foreach ($rows as $catalogRow) { + $catalogSettings = FieldSettings::effective($catalogRow); + if (array_key_exists('placed', $catalogSettings) && empty($catalogSettings['placed'])) { continue; } + if ((string) $catalogRow['rubric_field_type'] !== 'catalog') { continue; } + $catalogRaw = array_key_exists('__initial_value', $catalogRow) + ? (string) $catalogRow['__initial_value'] + : ($catalogRow['document_field_id'] ? (string) $catalogRow['field_value'] . (string) $catalogRow['field_value_more'] : (string) $catalogRow['rubric_field_default']); + $selected = CatalogModel::parseValue($catalogRaw); + $allowed = CatalogModel::allowedFieldIds((int) $rubricId, (int) $catalogRow['Id'], $selected['catalog_ids']); + if (!empty($allowed)) { $catalogAllowed = array_values(array_unique(array_merge($catalogAllowed, $allowed))); } + } + + $groups = array(); + foreach ($rows as $row) { + $settings = FieldSettings::effective($row); + if (array_key_exists('placed', $settings) && empty($settings['placed'])) { continue; } + $groupId = (int) $row['rubric_field_group']; + if (!isset($groups[$groupId])) { + $groups[$groupId] = array( + 'id' => $groupId, + 'title' => $groupId > 0 && !empty($row['group_title']) ? self::decode($row['group_title']) : 'Без группы', + 'description' => $groupId > 0 && !empty($row['group_description']) ? self::decode($row['group_description']) : '', + 'condition' => $conditionsEnabled && $groupId > 0 ? FieldConditionEvaluator::groupCondition($row) : array(), + 'items' => array(), + ); + } + + $field = self::fieldRow($row, (int) $documentId, $mediaDraftToken, $conditionsEnabled); + $field['catalog_hidden'] = !empty($catalogAllowed) && !in_array((int) $row['Id'], $catalogAllowed, true); + $groups[$groupId]['items'][] = $field; + } + + return array_values($groups); + } + + /** + * Валидация значений полей документа по JSON-настройкам (rubric_field_settings.rules). + * Возвращает ошибки в формате ['fields[]' => сообщение]. Пусто, если правил нет. + */ + public static function validateFieldValues($rubricId, array $values, $documentId = 0) + { + $rubricId = (int) $rubricId; + if ($rubricId <= 0) { + return array(); + } + + $fields = array(); + $effectiveValues = array(); + foreach (self::fieldsForRubric($rubricId, (int) $documentId) as $group) { + foreach ($group['items'] as $field) { + $fields[] = $field; + $effectiveValues[(int) $field['Id']] = isset($field['field_value']) ? $field['field_value'] : ''; + } + } + + foreach ($values as $id => $value) { + $effectiveValues[(int) $id] = $value; + } + + return FieldValidator::validateValues($fields, $effectiveValues, self::rubricConditionsEnabled($rubricId)); + } + + /** Remove values of fields hidden or locked by rubric form conditions. */ + public static function conditionalFieldValues($rubricId, array $values, $documentId = 0) + { + if (!self::rubricConditionsEnabled((int) $rubricId)) { + return $values; + } + + $fields = array(); + $fieldMap = array(); + $storedValues = array(); + $effectiveValues = array(); + foreach (self::fieldsForRubric((int) $rubricId, (int) $documentId) as $group) { + foreach ($group['items'] as $field) { + $fields[] = $field; + $fieldId = (int) $field['Id']; + $fieldMap[$fieldId] = $field; + $storedValues[$fieldId] = (int) $documentId > 0 && isset($field['field_value']) ? $field['field_value'] : ''; + $effectiveValues[$fieldId] = isset($field['field_value']) ? $field['field_value'] : ''; + } + } + + foreach ($values as $id => $value) { + $effectiveValues[(int) $id] = $value; + } + + $actions = FieldConditionEvaluator::valueActions($fields, $storedValues, $effectiveValues, (int) $documentId <= 0); + foreach ($values as $fieldId => $value) { + if (is_array($value) && !empty($value['_condition_action_override'])) { + unset($actions[(int) $fieldId]); + } + } + + foreach ($actions as $fieldId => $action) { + if (!isset($fieldMap[$fieldId])) { continue; } + $values[$fieldId] = self::conditionActionInput($fieldMap[$fieldId], $action); + $effectiveValues[$fieldId] = $values[$fieldId]; + } + + $states = FieldConditionEvaluator::states($fields, $effectiveValues); + foreach ($states as $id => $state) { + if ((empty($state['visible']) || !empty($state['locked'])) && !isset($actions[$id])) { + unset($values[$id], $values[(string) $id]); + } + } + + return $values; + } + + protected static function conditionActionInput(array $field, array $action) + { + $value = isset($action['action']) && (string) $action['action'] === 'set' + ? (string) (isset($action['value']) ? $action['value'] : '') : ''; + $kind = isset($field['editor_kind']) ? (string) $field['editor_kind'] : 'text'; + if ($kind === 'boolean') { return array('checked' => in_array(strtolower($value), array('1', 'true', 'yes', 'on'), true)); } + if ($kind === 'date') { return array('date' => $value); } + if ($kind === 'datetime') { return array('datetime' => $value); } + if ($kind === 'number') { return array('number' => $value); } + if ($kind === 'choice') { return array('selected' => $value); } + + return array('raw' => $value); + } + + public static function saveFields($documentId, array $values) + { + $documentId = (int) $documentId; + $doc = self::one($documentId); + if (!$doc) { + throw new \RuntimeException('Документ не найден'); + } + + $values = self::conditionalFieldValues((int) $doc['rubric_id'], $values, $documentId); + $fields = self::fieldsForRubric((int) $doc['rubric_id'], $documentId); + $catalogAllowed = array(); + $catalogFields = array(); + foreach ($fields as $group) { + foreach ($group['items'] as $field) { + $fieldId = (int) $field['Id']; + if ((string) $field['rubric_field_type'] !== 'catalog' || !array_key_exists($fieldId, $values)) { continue; } + // A partial/legacy payload may contain the field container without + // catalog_ids. Treat it as absent instead of erasing existing links. + if (!is_array($values[$fieldId]) || !array_key_exists('catalog_ids', $values[$fieldId])) { continue; } + $selected = CatalogModel::selection((int) $doc['rubric_id'], $fieldId, isset($values[$fieldId]['catalog_ids']) ? $values[$fieldId]['catalog_ids'] : array()); + $raw = CatalogModel::serializeSelection((int) $doc['rubric_id'], $fieldId, $selected); + self::saveFieldValue($documentId, $field, $raw, (int) $doc['document_in_search'] && (int) $field['rubric_field_search']); + CatalogModel::afterDocumentSave($documentId, (int) $doc['rubric_id'], $fieldId, $selected); + $allowed = CatalogModel::allowedFieldIds((int) $doc['rubric_id'], $fieldId, $selected); + if (!empty($allowed)) { $catalogAllowed = array_values(array_unique(array_merge($catalogAllowed, $allowed))); } + $catalogFields[$fieldId] = true; + } + } + + foreach ($fields as $group) { + foreach ($group['items'] as $field) { + $fieldId = (int) $field['Id']; + if (isset($catalogFields[$fieldId])) { continue; } + if (!empty($catalogAllowed) && !in_array($fieldId, $catalogAllowed, true)) { continue; } + if (!array_key_exists($fieldId, $values)) { + continue; + } + + $raw = self::fieldInputValue($field, $values[$fieldId]); + self::saveFieldValue($documentId, $field, $raw, (int) $doc['document_in_search'] && (int) $field['rubric_field_search']); + } + } + + self::clearDocumentCache($documentId); + CatalogModel::reindexDocument($documentId); + self::buildDocumentSnapshot($documentId); + } + + public static function currentFieldValues($documentId) + { + $rows = DB::query( + 'SELECT df.rubric_field_id, df.field_value, dft.field_value AS field_value_more' + . ' FROM ' . self::docFieldsTable() . ' df' + . ' LEFT JOIN ' . self::docFieldsTextTable() . ' dft ON dft.rubric_field_id = df.rubric_field_id AND dft.document_id = df.document_id' + . ' WHERE df.document_id = %i ORDER BY df.rubric_field_id ASC', + (int) $documentId + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[(int) $row['rubric_field_id']] = (string) $row['field_value'] . (string) $row['field_value_more']; + } + + return $out; + } + + /** Значения полей для управляемых пакетных операций: JSON уже декодирован. */ + public static function runtimeFieldValues($documentId) + { + $values = self::currentFieldValues((int) $documentId); + foreach ($values as $fieldId => $value) { + $decoded = FieldValueCodec::decodeStructured($value, null); + $values[$fieldId] = is_array($decoded) ? $decoded : $value; + } + + return $values; + } + + /** Сохранить runtime-значения через те же native field-типы, что и редактор. */ + public static function saveRuntimeFieldValues($documentId, array $values) + { + $documentId = (int) $documentId; + $doc = self::one($documentId); + if (!$doc) { + throw new \RuntimeException('Документ не найден'); + } + + $values = self::conditionalFieldValues((int) $doc['rubric_id'], $values, $documentId); + $errors = self::validateFieldValues((int) $doc['rubric_id'], $values, $documentId); + if ($errors) { + throw new \RuntimeException('Поля документа не прошли проверку: ' . implode('; ', array_values($errors))); + } + + $fieldMap = array(); + foreach (self::fieldsForRubric((int) $doc['rubric_id'], $documentId) as $group) { + foreach ($group['items'] as $field) { + $fieldMap[(int) $field['Id']] = $field; + } + } + + foreach ($values as $fieldId => $value) { + $fieldId = (int) $fieldId; + if (!isset($fieldMap[$fieldId])) { + continue; + } + + $field = $fieldMap[$fieldId]; + self::saveFieldValue( + $documentId, + $field, + $value, + (int) $doc['document_in_search'] && (int) $field['rubric_field_search'] + ); + } + + self::touchDocument($documentId); + self::clearDocumentCache($documentId); + CatalogModel::reindexDocument($documentId); + self::buildDocumentSnapshot($documentId); + } + + public static function restoreFieldValues($documentId, array $values) + { + $documentId = (int) $documentId; + $doc = self::one($documentId); + if (!$doc) { + throw new \RuntimeException('Документ не найден'); + } + + $fieldMap = array(); + foreach (self::fieldsForRubric((int) $doc['rubric_id'], $documentId) as $group) { + foreach ($group['items'] as $field) { + $fieldMap[(int) $field['Id']] = $field; + } + } + + foreach ($values as $fieldId => $value) { + $fieldId = (int) $fieldId; + if (!isset($fieldMap[$fieldId])) { + continue; + } + + self::saveFieldValue( + $documentId, + $fieldMap[$fieldId], + (string) $value, + (int) $doc['document_in_search'] && (int) $fieldMap[$fieldId]['rubric_field_search'] + ); + } + + self::touchDocument($documentId); + self::clearDocumentCache($documentId); + CatalogModel::reindexDocument($documentId); + self::buildDocumentSnapshot($documentId); + } + + public static function restoreDocumentState($documentId, array $state, $authorId) + { + $documentId = (int) $documentId; + $current = self::one($documentId); + if (!$current) { throw new \RuntimeException('Документ не найден'); } + $input = $current; + foreach (array( + 'document_title', 'document_alias', 'document_alias_header', 'document_alias_history', + 'document_short_alias', 'document_breadcrumb_title', 'document_excerpt', 'document_meta_keywords', + 'document_meta_description', 'document_meta_robots', 'document_sitemap_freq', 'document_sitemap_pr', + 'document_tags', 'document_property', 'guid', 'document_status', 'document_in_search', + 'document_parent', 'rubric_tmpl_id', 'document_linked_navi_id', 'document_position', + 'document_author_id', + ) as $key) { + if (array_key_exists($key, $state)) { $input[$key] = $state[$key]; } + } + + $input['document_published'] = !empty($state['document_published']) ? date('Y-m-d H:i:s', (int) $state['document_published']) : ''; + $input['document_expire'] = !empty($state['document_expire']) ? date('Y-m-d H:i:s', (int) $state['document_expire']) : ''; + $input['rubric_id'] = (int) $current['rubric_id']; + return self::save($documentId, $input, (int) $authorId); + } + + public static function revisionCount($documentId) + { + return (int) DB::query('SELECT COUNT(*) FROM ' . self::revisionsTable() . ' WHERE doc_id = %i', (int) $documentId)->getValue(); + } + + public static function save($id, array $input, $authorId) + { + $id = (int) $id; + $existing = $id > 0 ? self::one($id) : null; + $rubricId = isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0; + $rubric = self::rubric($rubricId); + $now = time(); + $data = self::input($input); + if ($id === 1) { + $data['document_alias'] = '/'; + $data['document_short_alias'] = ''; + $data['document_alias_history'] = '0'; + $data['document_alias_header'] = 301; + } + + $data['rubric_id'] = $rubricId; + $data['document_changed'] = $now; + $data['document_version'] = $existing ? max(1, (int) $existing['document_version']) + 1 : 1; + // Language is a site-wide setting. Keep legacy columns normalized only + // because the public engine still expects them to exist. + if (isset($input['document_author_id'])) { + $data['document_author_id'] = self::validLegacyAuthorId((int) $input['document_author_id']); + } + + if ($id > 0) { + DB::Update(self::documentsTable(), $data, 'Id = %i', $id); + self::syncAliasHistory($id, $existing, $data, (int) $authorId, $rubric); + self::syncSearchTerms($id, $rubricId, $data['document_meta_keywords'], $data['document_tags']); + self::clearDocumentCache($id); + return $id; + } + + if ((int) $data['rubric_tmpl_id'] <= 0) { + $data['rubric_tmpl_id'] = 0; + } + + if ((int) $data['document_position'] <= 0) { + $data['document_position'] = self::nextPosition(); + } + + if (!isset($data['document_author_id'])) { + $data['document_author_id'] = self::legacyAuthorId($authorId); + } + + $data['document_deleted'] = '0'; + $data['document_count_print'] = 0; + $data['document_count_view'] = 0; + $data['module_catalog'] = ''; + DB::Insert(self::documentsTable(), $data); + $newId = (int) DB::insertId(); + self::removeOwnedHistoryAlias($newId, $data['document_alias']); + self::syncSearchTerms($newId, $rubricId, $data['document_meta_keywords'], $data['document_tags']); + self::seedFields($newId, $rubricId); + self::clearDocumentCache($newId); + return $newId; + } + + public static function assertEditVersion($id, $expectedVersion) + { + $current = DB::query( + 'SELECT document_version FROM ' . self::documentsTable() . ' WHERE Id=%i LIMIT 1 FOR UPDATE', + (int) $id + )->getValue(); + if ($current === null || $current === false) { throw new \RuntimeException('Документ не найден'); } + $current = max(1, (int) $current); + if ($current !== max(1, (int) $expectedVersion)) { throw new EditConflict($current); } + + return $current; + } + + public static function documentVersion($id) + { + return max(1, (int) DB::query( + 'SELECT document_version FROM ' . self::documentsTable() . ' WHERE Id=%i LIMIT 1', + (int) $id + )->getValue()); + } + + public static function markChanged($id) + { + self::touchDocument((int) $id); + return self::documentVersion((int) $id); + } + + protected static function touchDocument($id) + { + DB::query( + 'UPDATE ' . self::documentsTable() + . ' SET document_changed=%i, document_version=GREATEST(1, document_version)+1 WHERE Id=%i', + time(), + (int) $id + ); + } + + public static function previewPayload($id, array $input, array $values, $authorId) + { + $id = (int) $id; + $existing = $id > 0 ? self::one($id) : null; + $rubricId = $existing ? (int) $existing['rubric_id'] : (isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0); + $rubric = self::rubric($rubricId); + $data = self::input($input); + $data['rubric_id'] = $rubricId; + $data['document_changed'] = time(); + if (isset($input['document_author_id'])) { + $data['document_author_id'] = self::validLegacyAuthorId((int) $input['document_author_id']); + } + + if ($id <= 0) { + if ((int) $data['rubric_tmpl_id'] <= 0) { + $data['rubric_tmpl_id'] = 0; + } + + if ((int) $data['document_position'] <= 0) { + $data['document_position'] = self::nextPosition(); + } + + if (!isset($data['document_author_id'])) { + $data['document_author_id'] = self::legacyAuthorId($authorId); + } + + $data['document_deleted'] = 0; + $data['document_count_print'] = 0; + $data['document_count_view'] = 0; + } + + $fields = array(); + $skippedFields = array(); + $fieldRows = self::fieldsForRubric($rubricId, $id); + $flatFields = array(); + foreach ($fieldRows as $group) { + foreach ($group['items'] as $field) { + $flatFields[(int) $field['Id']] = $field; + } + } + + $append = function (array $field, $storageValue) use (&$fields, $values) { + $fieldId = (int) $field['Id']; + $storageValue = FieldLifecycle::normalizeForStorage($storageValue, $field, array(), 'adminx_preview'); + $fields[] = array( + 'id' => $fieldId, + 'alias' => (string) $field['rubric_field_alias'], + 'title' => self::decode($field['rubric_field_title']), + 'type' => (string) $field['rubric_field_type'], + 'input' => $values[$fieldId], + 'storage_value' => $storageValue, + 'storage_length' => mb_strlen($storageValue), + ); + }; + + $catalogAllowed = array(); + $catalogFields = array(); + foreach ($flatFields as $fieldId => $field) { + if ((string) $field['rubric_field_type'] !== 'catalog' || !array_key_exists($fieldId, $values)) { continue; } + if (!is_array($values[$fieldId]) || !array_key_exists('catalog_ids', $values[$fieldId])) { + $skippedFields[] = array('id' => $fieldId, 'reason' => 'catalog_ids не переданы'); + continue; + } + + $selected = CatalogModel::selection($rubricId, $fieldId, $values[$fieldId]['catalog_ids']); + $append($field, CatalogModel::serializeSelection($rubricId, $fieldId, $selected)); + $allowed = CatalogModel::allowedFieldIds($rubricId, $fieldId, $selected); + if (!empty($allowed)) { $catalogAllowed = array_values(array_unique(array_merge($catalogAllowed, $allowed))); } + $catalogFields[$fieldId] = true; + } + + foreach ($flatFields as $fieldId => $field) { + if (isset($catalogFields[$fieldId]) || (string) $field['rubric_field_type'] === 'catalog') { continue; } + if (!array_key_exists($fieldId, $values)) { continue; } + if (!empty($catalogAllowed) && !in_array($fieldId, $catalogAllowed, true)) { + $skippedFields[] = array('id' => $fieldId, 'reason' => 'поле не включено в выбранных разделах каталога'); + continue; + } + + $append($field, self::fieldInputValue($field, $values[$fieldId])); + } + + return array( + 'mode' => $id > 0 ? 'update' : 'create', + 'document_id' => $id ?: null, + 'document' => $data, + 'fields' => $fields, + 'skipped_fields' => $skippedFields, + 'lifecycle_hooks_executed' => false, + ); + } + + public static function delete($id) + { + $id = (int) $id; + if (self::isProtectedDocument($id)) { + throw new \RuntimeException(self::protectedDocumentMessage($id)); + } + + if (!self::one($id)) { + return false; + } + + $deleting = Lifecycle::event('content.document.deleting', 'document', 'deleting', $id, array( + 'document' => self::one($id), + ), null, array(), 'adminx_documents'); + if ($deleting->cancelled()) { + throw new \RuntimeException($deleting->message() !== '' ? $deleting->message() : 'Удаление документа отменено модулем'); + } + + DB::query('UPDATE ' . self::documentsTable() . " SET document_deleted='1', document_changed=%i, document_version=GREATEST(1, document_version)+1 WHERE Id=%i", time(), $id); + self::clearDocumentCache($id); + CatalogModel::reindexDocument($id); + Lifecycle::event('content.document.deleted', 'document', 'deleted', $id, array(), true, array('soft_delete' => true), 'adminx_documents'); + return true; + } + + public static function restore($id) + { + $id = (int) $id; + if (!self::one($id)) { + return false; + } + + DB::query('UPDATE ' . self::documentsTable() . " SET document_deleted='0', document_changed=%i, document_version=GREATEST(1, document_version)+1 WHERE Id=%i", time(), $id); + self::clearDocumentCache($id); + CatalogModel::reindexDocument($id); + self::buildDocumentSnapshot($id); + return true; + } + + public static function toggleStatus($id) + { + $id = (int) $id; + $document = DB::query('SELECT * FROM ' . self::documentsTable() . ' WHERE Id=%i LIMIT 1', $id)->getAssoc(); + if (!$document) { + return false; + } + + if (self::isProtectedDocument($id)) { + throw new \RuntimeException('Статус системного документа менять нельзя'); + } + + $status = !empty($document['document_status']) ? '0' : '1'; + DB::query('UPDATE ' . self::documentsTable() . ' SET document_status=%s, document_changed=%i, document_version=GREATEST(1, document_version)+1 WHERE Id=%i', $status, time(), $id); + self::clearDocumentCache($id); + CatalogModel::reindexDocument($id); + self::buildDocumentSnapshot($id); + return $status; + } + + public static function copy($id, $authorId) + { + $id = (int) $id; + if (self::isProtectedDocument($id)) { + throw new \RuntimeException('Системный документ копировать нельзя'); + } + + $source = DB::query('SELECT * FROM ' . self::documentsTable() . ' WHERE Id = %i LIMIT 1', $id)->getAssoc(); + if (!$source) { + throw new \RuntimeException('Документ не найден'); + } + + unset($source['Id']); + $source['document_title'] = htmlspecialchars('Копия: ' . self::decode($source['document_title']), ENT_QUOTES); + $source['document_alias'] = self::generateAlias( + self::decode($source['document_title']), + 0, + 0, + (int) $source['rubric_id'], + time() + ); + $source['document_short_alias'] = ''; + $source['document_status'] = '0'; + $source['document_deleted'] = '0'; + $source['document_count_print'] = 0; + $source['document_count_view'] = 0; + $source['document_author_id'] = self::legacyAuthorId($authorId); + $source['document_changed'] = time(); + $source['document_version'] = 1; + $source['document_published'] = 0; + $source['document_expire'] = 0; + DB::Insert(self::documentsTable(), $source); + $newId = (int) DB::insertId(); + + foreach (DB::query('SELECT * FROM ' . self::docFieldsTable() . ' WHERE document_id = %i', $id)->getAll() as $field) { + unset($field['Id']); + $field['document_id'] = $newId; + DB::Insert(self::docFieldsTable(), $field); + } + + foreach (DB::query('SELECT * FROM ' . self::docFieldsTextTable() . ' WHERE document_id = %i', $id)->getAll() as $field) { + unset($field['Id']); + $field['document_id'] = $newId; + DB::Insert(self::docFieldsTextTable(), $field); + } + + self::syncSearchTerms($newId, (int) $source['rubric_id'], $source['document_meta_keywords'], $source['document_tags']); + self::clearDocumentCache($newId); + CatalogModel::reindexDocument($newId); + self::buildDocumentSnapshot($newId); + return $newId; + } + + public static function purge($id) + { + $id = (int) $id; + if (self::isProtectedDocument($id)) { + throw new \RuntimeException(self::protectedDocumentMessage($id)); + } + + $document = DB::query('SELECT * FROM ' . self::documentsTable() . ' WHERE Id=%i LIMIT 1', $id)->getAssoc(); + if (!$document) { + return false; + } + + if (empty($document['document_deleted'])) { + throw new \RuntimeException('Сначала переместите документ в корзину'); + } + + DB::Delete(self::docFieldsTextTable(), 'document_id = %i', $id); + DB::Delete(self::docFieldsTable(), 'document_id = %i', $id); + DB::Delete(self::revisionsTable(), 'doc_id = %i', $id); + DB::Delete(self::aliasHistoryTable(), 'document_id = %i', $id); + DB::Delete(self::keywordsTable(), 'document_id = %i', $id); + DB::Delete(self::tagsTable(), 'document_id = %i', $id); + DB::Delete(self::remarksTable(), 'document_id = %i', $id); + DB::Delete(self::viewCountTable(), 'document_id = %i', $id); + DB::Delete(self::documentsTable(), 'Id = %i', $id); + self::clearDocumentCache($id); + CatalogModel::reindexDocument($id); + return true; + } + + public static function stats() + { + $table = self::documentsTable(); + return array( + 'total' => (int) DB::query('SELECT COUNT(*) FROM ' . $table)->getValue(), + 'active' => (int) DB::query("SELECT COUNT(*) FROM " . $table . " WHERE document_status = '1' AND document_deleted != '1'")->getValue(), + 'draft' => (int) DB::query("SELECT COUNT(*) FROM " . $table . " WHERE document_status = '0' AND document_deleted != '1'")->getValue(), + 'deleted' => (int) DB::query("SELECT COUNT(*) FROM " . $table . " WHERE document_deleted = '1'")->getValue(), + ); + } + + public static function viewStats($days = 30) + { + $days = max(1, min(365, (int) $days)); + $from = strtotime('-' . ($days - 1) . ' days', mktime(0, 0, 0)); + $dailyRows = DB::query( + 'SELECT day_id, SUM(count) AS views, COUNT(DISTINCT document_id) AS documents' + . ' FROM ' . self::viewCountTable() . ' WHERE day_id >= %i GROUP BY day_id ORDER BY day_id ASC', + (int) $from + )->getAll(); + $daily = array(); + $total = 0; + foreach ($dailyRows as $row) { + $views = (int) $row['views']; + $total += $views; + $daily[] = array( + 'day' => (int) $row['day_id'], + 'date_label' => date('d.m.Y', (int) $row['day_id']), + 'views' => $views, + 'documents' => (int) $row['documents'], + ); + } + + $topRows = DB::query( + 'SELECT v.document_id, SUM(v.count) AS views, d.document_title, d.document_alias' + . ' FROM ' . self::viewCountTable() . ' v' + . ' LEFT JOIN ' . self::documentsTable() . ' d ON d.Id = v.document_id' + . ' WHERE v.day_id >= %i GROUP BY v.document_id, d.document_title, d.document_alias' + . ' ORDER BY views DESC, v.document_id ASC LIMIT 25', + (int) $from + )->getAll(); + $top = array(); + foreach ($topRows as $row) { + $top[] = array( + 'id' => (int) $row['document_id'], + 'title' => !empty($row['document_title']) ? self::decode($row['document_title']) : 'Удалённый документ', + 'alias' => isset($row['document_alias']) ? (string) $row['document_alias'] : '', + 'views' => (int) $row['views'], + ); + } + + return array( + 'days' => $days, + 'from' => (int) $from, + 'total' => $total, + 'average' => !empty($daily) ? round($total / count($daily), 1) : 0, + 'tracked_documents' => (int) DB::query('SELECT COUNT(DISTINCT document_id) FROM ' . self::viewCountTable() . ' WHERE day_id >= %i', (int) $from)->getValue(), + 'rows' => (int) DB::query('SELECT COUNT(*) FROM ' . self::viewCountTable())->getValue(), + 'daily' => $daily, + 'top' => $top, + ); + } + + public static function clearViewStats() + { + $count = (int) DB::query('SELECT COUNT(*) FROM ' . self::viewCountTable())->getValue(); + DB::query('TRUNCATE TABLE ' . self::viewCountTable()); + return $count; + } + + public static function rubrics() + { + $rows = DB::query( + 'SELECT r.Id, r.rubric_title, r.rubric_alias, r.rubric_template_id, COUNT(f.Id) AS fields_count' + . ' FROM ' . self::rubricsTable() . ' r' + . ' LEFT JOIN ' . self::fieldsTable() . ' f ON f.rubric_id = r.Id' + . ' GROUP BY r.Id, r.rubric_title, r.rubric_alias, r.rubric_template_id, r.rubric_position' + . ' ORDER BY r.rubric_position ASC, r.Id ASC' + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = array( + 'Id' => (int) $row['Id'], + 'rubric_title' => self::decode($row['rubric_title']), + 'rubric_alias' => (string) $row['rubric_alias'], + 'rubric_template_id' => (int) $row['rubric_template_id'], + 'fields_count' => (int) $row['fields_count'], + ); + } + + return $out; + } + + public static function authors() + { + $rows = DB::query('SELECT id, legacy_id, name, email, is_active FROM ' . SystemTables::table('users') . ' ORDER BY name ASC, id ASC')->getAll(); + $out = array(); + $seen = array(); + foreach ($rows as $row) { + $id = !empty($row['legacy_id']) ? (int) $row['legacy_id'] : (int) $row['id']; + if ($id <= 0 || isset($seen[$id])) { + continue; + } + + $seen[$id] = true; + $out[] = array( + 'id' => $id, + 'name' => trim((string) $row['name']) !== '' ? (string) $row['name'] : (string) $row['email'], + 'email' => (string) $row['email'], + 'active' => !empty($row['is_active']), + ); + } + + return $out; + } + + public static function remarks($documentId) + { + $rows = DB::query( + 'SELECT * FROM ' . self::remarksTable() . ' WHERE document_id = %i ORDER BY remark_first DESC, remark_published ASC, Id ASC', + (int) $documentId + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = array( + 'id' => (int) $row['Id'], + 'title' => self::decode($row['remark_title']), + 'text' => self::decode($row['remark_text']), + 'author_id' => (int) $row['remark_author_id'], + 'email' => (string) $row['remark_author_email'], + 'published' => (int) $row['remark_published'], + 'published_label' => !empty($row['remark_published']) ? date('d.m.Y H:i', (int) $row['remark_published']) : '-', + 'first' => (int) $row['remark_first'], + 'status' => (int) $row['remark_status'], + ); + } + + return $out; + } + + public static function addRemark($documentId, array $input, $authorId) + { + $documentId = (int) $documentId; + if (!self::one($documentId)) { + throw new \RuntimeException('Документ не найден'); + } + + $title = trim(isset($input['title']) ? (string) $input['title'] : ''); + $text = trim(isset($input['text']) ? (string) $input['text'] : ''); + if ($text === '') { + throw new \RuntimeException('Введите текст заметки'); + } + + $first = (int) DB::query('SELECT COUNT(*) FROM ' . self::remarksTable() . ' WHERE document_id = %i', $documentId)->getValue() === 0 ? 1 : 0; + DB::Insert(self::remarksTable(), array( + 'document_id' => $documentId, + 'remark_first' => $first, + 'remark_title' => htmlspecialchars($title, ENT_QUOTES), + 'remark_text' => htmlspecialchars(function_exists('mb_substr') ? mb_substr($text, 0, 500, 'UTF-8') : substr($text, 0, 500), ENT_QUOTES), + 'remark_author_id' => self::legacyAuthorId($authorId), + 'remark_published' => time(), + 'remark_status' => 1, + 'remark_author_email' => self::adminEmail($authorId), + )); + return (int) DB::insertId(); + } + + public static function deleteRemark($documentId, $remarkId) + { + $exists = (int) DB::query('SELECT Id FROM ' . self::remarksTable() . ' WHERE Id = %i AND document_id = %i LIMIT 1', (int) $remarkId, (int) $documentId)->getValue(); + if ($exists <= 0) { + return false; + } + + DB::Delete(self::remarksTable(), 'Id = %i AND document_id = %i', (int) $remarkId, (int) $documentId); + return true; + } + + public static function rubric($id) + { + return DB::query('SELECT * FROM ' . self::rubricsTable() . ' WHERE Id = %i LIMIT 1', (int) $id)->getAssoc(); + } + + protected static function rubricConditionsEnabled($id) + { + static $cache = array(); + $id = (int) $id; + if (!array_key_exists($id, $cache)) { + $rubric = self::rubric($id); + $cache[$id] = $rubric && !empty($rubric['rubric_form_conditions']); + } + + return $cache[$id]; + } + + public static function aliasAvailable($alias, $exceptId) + { + return self::aliasConflict($alias, $exceptId) === null; + } + + public static function aliasConflict($alias, $exceptId = 0) + { + return DocumentAliasRegistry::conflict($alias, $exceptId); + } + + public static function aliasHistory($documentId) + { + $rows = DB::query( + 'SELECT h.*, u.name AS author_name FROM ' . self::aliasHistoryTable() . ' h' + . ' LEFT JOIN ' . SystemTables::table('users') . ' u ON u.id = h.document_alias_author' + . ' WHERE h.document_id = %i ORDER BY h.document_alias_changed DESC, h.id DESC', + (int) $documentId + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = array( + 'id' => (int) $row['id'], + 'document_id' => (int) $row['document_id'], + 'alias' => (string) $row['document_alias'], + 'header' => (int) $row['document_alias_header'], + 'author_id' => (int) $row['document_alias_author'], + 'author_name' => isset($row['author_name']) ? (string) $row['author_name'] : '', + 'changed' => (int) $row['document_alias_changed'], + 'changed_label' => !empty($row['document_alias_changed']) ? date('d.m.Y H:i', (int) $row['document_alias_changed']) : '-', + ); + } + + return $out; + } + + public static function allAliasHistory(array $filters) + { + $page = max(1, isset($filters['page']) ? (int) $filters['page'] : 1); + $limit = 50; + $where = ' WHERE 1=1'; + $args = array(); + $q = trim(isset($filters['q']) ? (string) $filters['q'] : ''); + if ($q !== '') { + $where .= ' AND (h.document_alias LIKE %ss OR d.document_alias LIKE %ss OR d.document_title LIKE %ss OR d.Id = %i)'; + $args[] = $q; $args[] = $q; $args[] = $q; $args[] = (int) $q; + } + + $header = isset($filters['header']) ? (int) $filters['header'] : 0; + if (in_array($header, array(301, 302, 307, 308), true)) { + $where .= ' AND h.document_alias_header = %i'; + $args[] = $header; + } + + $from = ' FROM ' . self::aliasHistoryTable() . ' h' + . ' INNER JOIN ' . self::documentsTable() . ' d ON d.Id = h.document_id' + . ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id' + . ' LEFT JOIN ' . SystemTables::table('users') . ' u ON u.id = h.document_alias_author'; + $count = (int) call_user_func_array(array('DB', 'query'), array_merge(array('SELECT COUNT(*)' . $from . $where), $args))->getValue(); + $pages = max(1, (int) ceil($count / $limit)); + $page = min($page, $pages); + $offset = ($page - 1) * $limit; + $sql = 'SELECT h.*, d.document_title, d.document_alias AS target_alias, d.document_deleted,' + . ' r.rubric_title, u.name AS author_name' . $from . $where + . ' ORDER BY h.document_alias_changed DESC, h.id DESC LIMIT ' . $offset . ', ' . $limit; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + $items = array(); + foreach ($rows as $row) { $items[] = self::aliasHistoryRow($row); } + return array('items' => $items, 'pagination' => array( + 'page' => $page, 'pages' => $pages, 'total' => $count, + 'from' => $count ? $offset + 1 : 0, 'to' => min($offset + $limit, $count), + )); + } + + public static function aliasHistoryStats() + { + $table = self::aliasHistoryTable(); + return array( + 'total' => (int) DB::query('SELECT COUNT(*) FROM ' . $table)->getValue(), + 'documents' => (int) DB::query('SELECT COUNT(DISTINCT document_id) FROM ' . $table)->getValue(), + 'permanent' => (int) DB::query('SELECT COUNT(*) FROM ' . $table . ' WHERE document_alias_header IN (301, 308)')->getValue(), + 'temporary' => (int) DB::query('SELECT COUNT(*) FROM ' . $table . ' WHERE document_alias_header IN (302, 307)')->getValue(), + ); + } + + protected static function aliasHistoryRow(array $row) + { + return array( + 'id' => (int) $row['id'], 'document_id' => (int) $row['document_id'], + 'alias' => (string) $row['document_alias'], 'header' => self::redirectHeader($row['document_alias_header']), + 'author_id' => (int) $row['document_alias_author'], + 'author_name' => isset($row['author_name']) ? (string) $row['author_name'] : '', + 'changed' => (int) $row['document_alias_changed'], + 'changed_label' => !empty($row['document_alias_changed']) ? date('d.m.Y H:i', (int) $row['document_alias_changed']) : '-', + 'document_title' => isset($row['document_title']) ? (string) $row['document_title'] : '', + 'target_alias' => isset($row['target_alias']) ? (string) $row['target_alias'] : '', + 'rubric_title' => isset($row['rubric_title']) ? (string) $row['rubric_title'] : '', + 'document_deleted' => !empty($row['document_deleted']), + ); + } + + public static function saveAliasHistory($documentId, $historyId, array $input, $authorId) + { + $documentId = (int) $documentId; + $historyId = (int) $historyId; + if (!self::one($documentId)) { + throw new \RuntimeException('Документ не найден'); + } + + $alias = trim(isset($input['alias']) ? (string) $input['alias'] : '', '/ '); + if ($alias === '' || !preg_match('/^[a-zA-Z0-9_\-\/\.]+$/', $alias)) { + throw new \RuntimeException('Укажите корректный alias'); + } + + if (!self::historyAliasAvailable($alias, $documentId, $historyId)) { + throw new \RuntimeException('Такой alias уже используется документом или другим редиректом'); + } + + $data = array( + 'document_id' => $documentId, + 'document_alias' => $alias, + 'document_alias_header' => self::redirectHeader(isset($input['header']) ? $input['header'] : 301), + 'document_alias_author' => (int) $authorId > 0 ? (int) $authorId : 1, + 'document_alias_changed' => time(), + ); + if ($historyId > 0) { + DB::Update(self::aliasHistoryTable(), $data, 'id = %i AND document_id = %i', $historyId, $documentId); + return $historyId; + } + + DB::Insert(self::aliasHistoryTable(), $data); + return (int) DB::insertId(); + } + + public static function deleteAliasHistory($documentId, $historyId) + { + $exists = (int) DB::query( + 'SELECT id FROM ' . self::aliasHistoryTable() . ' WHERE id = %i AND document_id = %i LIMIT 1', + (int) $historyId, + (int) $documentId + )->getValue(); + if ($exists <= 0) { + return false; + } + + DB::Delete(self::aliasHistoryTable(), 'id = %i AND document_id = %i', (int) $historyId, (int) $documentId); + return true; + } + + public static function documentPicker($q, $rubricId = 0, $limit = 20) + { + $limit = max(1, min(50, (int) $limit)); + $sql = 'SELECT d.Id, d.rubric_id, d.document_title, d.document_alias, r.rubric_title' + . ' FROM ' . self::documentsTable() . ' d' + . ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id' + . " WHERE d.document_deleted != '1'"; + $args = array(); + $q = trim((string) $q); + if ($q !== '') { + $sql .= ' AND (d.document_title LIKE %ss OR d.document_alias LIKE %ss OR d.Id = %i)'; + $args[] = $q; + $args[] = $q; + $args[] = (int) $q; + } + + $rubricIds = array(); + foreach (explode(',', (string) $rubricId) as $r) { + $r = (int) trim($r); + if ($r > 0) { $rubricIds[] = $r; } + } + + if (!empty($rubricIds)) { + $rubricIds = array_values(array_unique($rubricIds)); + $sql .= ' AND d.rubric_id IN (' . implode(',', array_map('intval', $rubricIds)) . ')'; + } + + $sql .= ' ORDER BY d.document_changed DESC, d.Id DESC LIMIT ' . (int) $limit; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = array( + 'id' => (int) $row['Id'], + 'rubric_id' => (int) $row['rubric_id'], + 'title' => self::decode($row['document_title']), + 'alias' => (string) $row['document_alias'], + 'rubric_title' => self::decode(isset($row['rubric_title']) ? $row['rubric_title'] : ''), + ); + } + + return $out; + } + + /** Existing keywords/tags for the searchable tag inputs in document editor. */ + public static function termSuggestions($kind, $q = '', $rubricId = 0, $limit = 12) + { + $kind = $kind === 'keywords' ? 'keywords' : 'tags'; + $q = trim((string) $q); + $rubricId = max(0, (int) $rubricId); + $limit = max(5, min(30, (int) $limit)); + $termTable = $kind === 'keywords' ? self::keywordsTable() : self::tagsTable(); + $termColumn = $kind === 'keywords' ? 'keyword' : 'tag'; + + $selectRubric = $rubricId > 0 + ? 'SUM(CASE WHEN d.rubric_id = %i THEN 1 ELSE 0 END) AS rubric_count' + : '0 AS rubric_count'; + $sql = 'SELECT x.' . $termColumn . ' AS term, COUNT(*) AS usage_count, ' . $selectRubric + . ' FROM ' . $termTable . ' x' + . ' INNER JOIN ' . self::documentsTable() . ' d ON d.Id = x.document_id' + . " WHERE d.document_deleted != '1' AND TRIM(x." . $termColumn . ") <> ''"; + $args = array(); + if ($rubricId > 0) { + $args[] = $rubricId; + } + + if ($q !== '') { + $sql .= ' AND x.' . $termColumn . ' LIKE %ss'; + $args[] = $q; + } + + $sql .= ' GROUP BY x.' . $termColumn + . ' ORDER BY rubric_count DESC, usage_count DESC, x.' . $termColumn . ' ASC' + . ' LIMIT ' . $limit; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + + $items = array(); + foreach ($rows ?: array() as $row) { + $term = trim((string) $row['term']); + if ($term === '') { continue; } + $items[] = array( + 'value' => $term, + 'count' => (int) $row['usage_count'], + 'rubric_count' => (int) $row['rubric_count'], + ); + } + + return $items; + } + + protected static function input(array $input) + { + $get = function ($key) use ($input) { + return isset($input[$key]) ? (string) $input[$key] : ''; + }; + return array( + 'document_title' => htmlspecialchars(trim($get('document_title')), ENT_QUOTES), + 'document_alias' => DocumentAliasRegistry::normalize($get('document_alias')), + 'document_alias_header' => self::redirectHeader($get('document_alias_header')), + 'document_alias_history' => (string) self::aliasHistoryMode($get('document_alias_history')), + 'document_short_alias' => substr(trim($get('document_short_alias')), 0, 10), + 'document_breadcrumb_title' => htmlspecialchars(trim($get('document_breadcrumb_title')), ENT_QUOTES), + 'document_excerpt' => trim($get('document_excerpt')), + 'document_meta_keywords' => trim($get('document_meta_keywords')), + 'document_meta_description' => trim($get('document_meta_description')), + 'document_meta_robots' => self::robotsValue($get('document_meta_robots')), + 'document_sitemap_freq' => self::sitemapFreqValue($get('document_sitemap_freq')), + 'document_sitemap_pr' => self::sitemapPrValue($get('document_sitemap_pr')), + 'document_tags' => trim($get('document_tags')), + 'document_property' => trim($get('document_property')), + 'guid' => substr(trim($get('guid')), 0, 100), + 'document_status' => (int) $get('document_status') === 1 ? '1' : '0', + 'document_in_search' => (int) $get('document_in_search') === 1 ? '1' : '0', + 'document_parent' => (int) $get('document_parent'), + 'rubric_tmpl_id' => (int) $get('rubric_tmpl_id'), + 'document_linked_navi_id' => (int) $get('document_linked_navi_id'), + 'document_position' => (int) $get('document_position'), + 'document_published' => self::parseDate($get('document_published')), + 'document_expire' => self::parseDate($get('document_expire')), + ); + } + + protected static function sitemapFreqValue($value) + { + $v = (int) $value; + return ($v >= 0 && $v <= 6) ? $v : 3; + } + + protected static function sitemapPrValue($value) + { + $v = round((float) $value, 1); + if ($v < 0) { $v = 0.0; } + if ($v > 1) { $v = 1.0; } + return $v; + } + + /** Транслитерация заголовка в ЧПУ-алиас (как в sample/other). */ + public static function slugify($value) + { + $map = array( + 'а' => 'a', 'б' => 'b', 'в' => 'v', 'г' => 'g', 'д' => 'd', 'е' => 'e', 'ё' => 'e', + 'ж' => 'zh', 'з' => 'z', 'и' => 'i', 'й' => 'y', 'к' => 'k', 'л' => 'l', 'м' => 'm', + 'н' => 'n', 'о' => 'o', 'п' => 'p', 'р' => 'r', 'с' => 's', 'т' => 't', 'у' => 'u', + 'ф' => 'f', 'х' => 'h', 'ц' => 'c', 'ч' => 'ch', 'ш' => 'sh', 'щ' => 'sch', + 'ъ' => '', 'ы' => 'y', 'ь' => '', 'э' => 'e', 'ю' => 'yu', 'я' => 'ya', + ); + $value = function_exists('mb_strtolower') ? mb_strtolower((string) $value, 'UTF-8') : strtolower((string) $value); + $value = strtr($value, $map); + $value = preg_replace('/[^a-z0-9]+/u', '-', $value); + $value = preg_replace('/-{2,}/', '-', $value); + return trim((string) $value, '-'); + } + + /** Уникальный алиас из заголовка (добавляет -2, -3… при конфликте). */ + /** Короткий алиас свободен (не занят ни коротким, ни обычным алиасом). */ + public static function shortAliasAvailable($code, $exceptId) + { + return DocumentAliasRegistry::available($code, $exceptId); + } + + /** Случайный уникальный короткий алиас (URL-shortener, как в legacy gen_short_link). */ + public static function generateShortAlias($excludeId = 0, $length = 6) + { + $length = max(4, min(10, (int) $length)); + for ($i = 0; $i < 50; $i++) { + $code = Str::random($length); + if (self::shortAliasAvailable($code, (int) $excludeId)) { + return $code; + } + } + + return Str::random($length); + } + + /** Путь алиаса родителя без крайних слэшей (иерархия ЧПУ). */ + public static function parentAliasPath($parentId) + { + $parentId = (int) $parentId; + if ($parentId <= 0) { + return ''; + } + + $alias = (string) DB::query( + 'SELECT document_alias FROM ' . self::documentsTable() . ' WHERE Id = %i LIMIT 1', + $parentId + )->getValue(); + return trim($alias, '/'); + } + + /** Уникальный иерархический алиас: <алиас-родителя>/, с -2/-3 при конфликте. */ + public static function generateAlias($title, $excludeId = 0, $parentId = 0, $rubricId = 0, $publishedAt = 0) + { + $base = self::slugify($title); + if ($base === '') { + $base = 'document'; + } + + $parentPath = self::parentAliasPath($parentId); + $rubric = (int) $rubricId > 0 ? self::rubric((int) $rubricId) : null; + $pattern = $rubric && isset($rubric['rubric_alias']) ? (string) $rubric['rubric_alias'] : ''; + $build = function ($leaf) use ($parentPath, $pattern, $publishedAt) { + return DocumentAliasTemplate::compose($pattern, $leaf, (int) $publishedAt, $parentPath); + }; + $alias = $build($base); + $n = 2; + while (!self::aliasAvailable($alias, (int) $excludeId)) { + $alias = $build($base . '-' . $n); + $n++; + if ($n > 200) { + break; + } + } + + return $alias; + } + + /** Дополняет короткий alias шаблоном рубрики при создании документа. */ + public static function prepareAliasInput(array $input, $id = 0) + { + if ((int) $id > 0) { + return $input; + } + + $rubricId = isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0; + $rubric = self::rubric($rubricId); + $pattern = $rubric && isset($rubric['rubric_alias']) ? (string) $rubric['rubric_alias'] : ''; + $publishedAt = self::parseDate(isset($input['document_published']) ? $input['document_published'] : ''); + $parentId = isset($input['document_parent']) ? (int) $input['document_parent'] : 0; + $alias = trim(isset($input['document_alias']) ? (string) $input['document_alias'] : '', '/ '); + if ($alias === '') { + $title = isset($input['document_title']) ? self::decode($input['document_title']) : ''; + $input['document_alias'] = self::generateAlias($title, 0, $parentId, $rubricId, $publishedAt); + return $input; + } + + $input['document_alias'] = DocumentAliasTemplate::compose( + $pattern, + $alias, + $publishedAt, + self::parentAliasPath($parentId) + ); + return $input; + } + + /** Шаблоны рубрики для выбора шаблона документа. */ + public static function rubricTemplates($rubricId) + { + $rows = DB::query( + 'SELECT id, title FROM ' . self::rubricTemplatesTable() . ' WHERE rubric_id = %i ORDER BY title ASC, id ASC', + (int) $rubricId + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = array('id' => (int) $row['id'], 'title' => self::decode($row['title'])); + } + + return $out; + } + + /** Пункты навигации для привязки документа. */ + public static function navigationItems() + { + $rows = DB::query( + 'SELECT navigation_item_id AS id, title, navigation_id FROM ' . self::navigationItemsTable() + . " WHERE status = '1' ORDER BY navigation_id ASC, position ASC, title ASC LIMIT 500" + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = array('id' => (int) $row['id'], 'title' => self::decode($row['title'])); + } + + return $out; + } + + /** Короткая карточка документа-родителя. */ + public static function parentInfo($id) + { + $id = (int) $id; + if ($id <= 0) { + return null; + } + + $row = DB::query( + 'SELECT Id, document_title, document_alias FROM ' . self::documentsTable() . ' WHERE Id = %i LIMIT 1', + $id + )->getAssoc(); + if (!$row) { + return null; + } + + return array( + 'id' => (int) $row['Id'], + 'title' => self::decode($row['document_title']), + 'alias' => (string) $row['document_alias'], + ); + } + + public static function rubricTemplatesTable() { return ContentTables::table('rubric_templates'); } + + public static function navigationItemsTable() { return ContentTables::table('navigation_items'); } + + protected static function row(array $row, $full = false) + { + $row['Id'] = (int) $row['Id']; + $row['rubric_id'] = (int) $row['rubric_id']; + $row['document_title'] = self::decode($row['document_title']); + $row['document_breadcrumb_title'] = self::decode($row['document_breadcrumb_title']); + $row['document_status'] = (int) $row['document_status']; + $row['document_deleted'] = (int) $row['document_deleted']; + $row['document_in_search'] = (int) $row['document_in_search']; + $row['document_published'] = (int) $row['document_published']; + $row['document_expire'] = (int) $row['document_expire']; + $row['document_changed'] = (int) $row['document_changed']; + $row['document_version'] = isset($row['document_version']) ? max(1, (int) $row['document_version']) : 1; + $row['document_author_id'] = isset($row['document_author_id']) ? (int) $row['document_author_id'] : 1; + $row['document_count_view'] = isset($row['document_count_view']) ? (int) $row['document_count_view'] : 0; + $row['document_count_print'] = isset($row['document_count_print']) ? (int) $row['document_count_print'] : 0; + $row['document_short_alias'] = isset($row['document_short_alias']) ? (string) $row['document_short_alias'] : ''; + $row['document_alias_header'] = isset($row['document_alias_header']) ? (int) $row['document_alias_header'] : 301; + $row['document_alias_history'] = isset($row['document_alias_history']) ? (int) $row['document_alias_history'] : 0; + $row['document_excerpt'] = isset($row['document_excerpt']) ? self::decode($row['document_excerpt']) : ''; + $row['document_property'] = isset($row['document_property']) ? self::decode($row['document_property']) : ''; + $row['guid'] = isset($row['guid']) ? (string) $row['guid'] : ''; + $row['document_parent'] = isset($row['document_parent']) ? (int) $row['document_parent'] : 0; + $row['rubric_tmpl_id'] = isset($row['rubric_tmpl_id']) ? (int) $row['rubric_tmpl_id'] : 0; + $row['document_linked_navi_id'] = isset($row['document_linked_navi_id']) ? (int) $row['document_linked_navi_id'] : 0; + $row['document_sitemap_freq'] = isset($row['document_sitemap_freq']) ? (int) $row['document_sitemap_freq'] : 3; + $row['document_sitemap_pr'] = isset($row['document_sitemap_pr']) ? (float) $row['document_sitemap_pr'] : 0.5; + $row['document_position'] = isset($row['document_position']) ? (int) $row['document_position'] : 0; + $row['parent_info'] = $full && $row['document_parent'] > 0 ? self::parentInfo($row['document_parent']) : null; + $row['published_input'] = self::dateInput($row['document_published']); + $row['expire_input'] = self::dateInput($row['document_expire']); + $row['published_label'] = $row['document_published'] > 0 ? date('d.m.Y H:i', $row['document_published']) : 'не задано'; + $row['changed_label'] = $row['document_changed'] > 0 ? date('d.m.Y H:i', $row['document_changed']) : 'не задано'; + $row['state_label'] = $row['document_deleted'] ? 'Удалён' : ($row['document_status'] ? 'Опубликован' : 'Черновик'); + $row['state_badge'] = $row['document_deleted'] ? 'gray' : ($row['document_status'] ? 'green' : 'amber'); + $row['rubric_title'] = isset($row['rubric_title']) ? self::decode($row['rubric_title']) : ''; + $row['rubric_alias'] = isset($row['rubric_alias']) ? (string) $row['rubric_alias'] : ''; + $row['is_protected'] = self::isProtectedDocument($row['Id']); + $row['can_delete'] = !$row['is_protected'] && !$row['document_deleted']; + $row['can_restore'] = !$row['is_protected'] && $row['document_deleted'] === 1; + $row['can_toggle'] = !$row['is_protected'] && !$row['document_deleted']; + $row['can_copy'] = !$row['is_protected'] && !$row['document_deleted']; + $row['can_purge'] = !$row['is_protected'] && $row['document_deleted'] === 1; + if ($full) { + $row['fields_count'] = (int) DB::query('SELECT COUNT(*) FROM ' . self::docFieldsTable() . ' WHERE document_id = %i', $row['Id'])->getValue(); + $row['revisions_count'] = self::revisionCount($row['Id']); + $row['aliases_count'] = (int) DB::query('SELECT COUNT(*) FROM ' . self::aliasHistoryTable() . ' WHERE document_id = %i', $row['Id'])->getValue(); + $row['remarks_count'] = (int) DB::query('SELECT COUNT(*) FROM ' . self::remarksTable() . ' WHERE document_id = %i', $row['Id'])->getValue(); + } + + return $row; + } + + public static function isProtectedDocument($id) + { + $id = (int) $id; + if ($id === 1) { + return true; + } + + return $id > 0 && $id === (int) Settings::get('page_not_found_id', 0); + } + + protected static function protectedDocumentMessage($id) + { + return (int) $id === 1 + ? 'Главная страница является системной и не может быть удалена' + : 'Документ назначен страницей 404 и не может быть удалён'; + } + + protected static function seedFields($documentId, $rubricId) + { + $fields = DB::query('SELECT Id FROM ' . self::fieldsTable() . ' WHERE rubric_id = %i', (int) $rubricId)->getAll(); + foreach ($fields as $field) { + DB::Insert(self::docFieldsTable(), array( + 'rubric_field_id' => (int) $field['Id'], + 'document_id' => (int) $documentId, + )); + } + } + + protected static function fieldRow(array $row, $documentId, $mediaDraftToken = '', $conditionsEnabled = false) + { + $type = (string) $row['rubric_field_type']; + $hasInitialValue = array_key_exists('__initial_value', $row); + $raw = $hasInitialValue + ? (string) $row['__initial_value'] + : ($row['document_field_id'] + ? (string) $row['field_value'] . (string) $row['field_value_more'] + : self::initialFieldValue($type, (string) $row['rubric_field_default'])); + $editor = FieldAdminEditors::describe($type); + $kind = self::documentEditorKind($editor); + $settings = FieldSettings::effective($row); + if ($type === 'choice') { + $kind = isset($settings['mode']) && (string) $settings['mode'] === 'multiple' ? 'choice_multi' : 'choice'; + } + + $parsed = FieldAdminEditors::parseValue($type, $raw); + $plugin = FieldRegistry::get($type); + $isDocumentMedia = $plugin instanceof \App\Content\Fields\DocumentMediaFieldType; + $mediaTargetDir = $isDocumentMedia ? self::mediaTargetDir($row, $documentId) : ''; + $field = array( + 'Id' => (int) $row['Id'], + 'rubric_id' => (int) $row['rubric_id'], + 'rubric_field_group' => (int) $row['rubric_field_group'], + 'rubric_field_title' => self::decode($row['rubric_field_title']), + 'rubric_field_alias' => (string) $row['rubric_field_alias'], + 'rubric_field_type' => $type, + 'rubric_field_numeric' => (int) $row['rubric_field_numeric'], + 'rubric_field_search' => (int) $row['rubric_field_search'], + 'rubric_field_description' => self::decode($row['rubric_field_description']), + 'rubric_field_default' => (string) $row['rubric_field_default'], + 'rubric_field_settings' => isset($row['rubric_field_settings']) ? (string) $row['rubric_field_settings'] : '', + 'group_settings' => isset($row['group_settings']) ? (string) $row['group_settings'] : '', + 'settings' => $settings, + 'condition' => $conditionsEnabled ? FieldConditionEvaluator::condition($row) : array(), + 'document_field_id' => (int) $row['document_field_id'], + 'field_value' => $raw, + 'field_number_value' => isset($row['field_number_value']) ? (string) $row['field_number_value'] : '0', + 'field_in_search' => isset($row['field_in_search']) ? (int) $row['field_in_search'] : 1, + 'editor' => $editor, + 'editor_kind' => $kind, + 'parsed' => $type === 'youtube' ? $parsed : self::viewValue($kind, $parsed, $raw), + 'options' => self::fieldOptions($row['rubric_field_default'], $type, $settings), + 'media_upload_dir' => $isDocumentMedia ? self::mediaUploadDir($row, $documentId, $mediaDraftToken) : '', + 'media_target_dir' => $mediaTargetDir, + 'media_picker_dir' => $isDocumentMedia && (int) $documentId > 0 ? $mediaTargetDir : '/uploads', + 'relation_rubric_ids' => isset($settings['rubric']) && is_array($settings['rubric']) ? array_values($settings['rubric']) : array(), + 'tag' => '[tag:fld:' . (int) $row['Id'] . ']', + ); + if ($kind === 'relation_list') { + $field['relation_items'] = self::relationDocuments( + isset($parsed['document_ids']) && is_array($parsed['document_ids']) ? $parsed['document_ids'] : array() + ); + } + + // Одиночный «Документ из рубрики»: резолвим заголовок выбранного документа, + // чтобы в поле показывать наименование, а не только ID. + if ($kind === 'relation' && $type === 'doc_from_rub') { + $selectedId = (((int) $row['document_field_id'] > 0 || $hasInitialValue) && is_array($parsed)) + ? (int) (isset($parsed['document_id']) ? $parsed['document_id'] : 0) + : 0; + if ($selectedId > 0) { + $docs = self::relationDocuments(array($selectedId)); + $field['relation_selected'] = !empty($docs) + ? $docs[0] + : array('id' => $selectedId, 'title' => 'Документ #' . $selectedId, 'alias' => '', 'rubric_title' => ''); + } + } + + // Оформление поля в документе из rubric_field_settings (adminx): ширина, + // обязательность и приставка/суффикс (составное поле, напр. цена + ₽). + $field['required'] = !empty($settings['required']); + // Приставка/суффикс имеют смысл только для простых однострочных полей + // (одиночный ), иначе аддон не встроить в контрол плагина. + $affixTypes = array('single_line', 'single_line_numeric', 'number'); + $affixOk = in_array($type, $affixTypes, true); + $field['prefix'] = ($affixOk && !empty($settings['prefix'])) ? (string) $settings['prefix'] : ''; + $field['suffix'] = ($affixOk && !empty($settings['suffix'])) ? (string) $settings['suffix'] : ''; + // Числовому полю без явного суффикса подставляем единицу по названию/алиасу + // (Цена → ₽, Скидка → %, Вес → кг и т.п.), как в референсе sample/other. + if ($field['suffix'] === '' && $type === 'single_line_numeric') { + $field['suffix'] = self::autoUnit($field['rubric_field_title'], $field['rubric_field_alias']); + } + + if ($field['suffix'] === '' && $type === 'number') { + $format = isset($settings['format']) ? (string) $settings['format'] : 'decimal'; + if ($format === 'money') { + $currencies = array('RUB' => '₽', 'USD' => '$', 'EUR' => '€'); + $currency = isset($settings['currency']) ? (string) $settings['currency'] : 'RUB'; + $field['suffix'] = isset($currencies[$currency]) ? $currencies[$currency] : ''; + } elseif ($format === 'percent') { + $field['suffix'] = '%'; + } elseif ($format === 'measurement') { + $field['suffix'] = isset($settings['unit']) ? trim((string) $settings['unit']) : ''; + } + } + + // Плагин типа поля сам рисует контрол (см. Rubrics\FieldEditors\*::renderEdit). + $field['has_plugin'] = isset($editor['status']) && $editor['status'] === 'native'; + $field['width_class'] = self::fieldWidthClass($kind, $type, isset($settings['width']) ? (string) $settings['width'] : ''); + $field['html'] = $type === 'catalog' + ? self::renderCatalogField($field, CatalogModel::parseValue($raw)) + : FieldAdminEditors::renderEdit($type, $field); + return $field; + } + + protected static function hasGroupSettingsColumn() + { + static $has = null; + if ($has === null) { + $has = DatabaseSchema::columnExists(self::groupsTable(), 'group_settings'); + } + + return $has; + } + + /** Ширина поля в 12-колоночной сетке документа. $pref — явный пресет из настроек. */ + protected static function fieldWidthClass($kind, $type, $pref = '') + { + switch ($pref) { + case 'full': return 'is-wide'; + case 'half': return 'is-half'; + case 'third': return 'is-third'; + case 'quarter': return 'is-quarter'; + } + + // Авто: узкие типы — половина, остальные — на всю ширину. + $half = array('boolean', 'date', 'datetime', 'number', 'range', 'numeric_parts', 'choice', 'relation', 'url'); + if (in_array($type, array('contact', 'color'), true)) { + return 'is-half'; + } + + if (in_array($kind, $half, true)) { + return 'is-half'; + } + + return 'is-wide'; + } + + /** Единица измерения числового поля по названию/алиасу (₽/%/кг/см/°/мес). */ + protected static function autoUnit($title, $alias) + { + $text = $title . ' ' . $alias; + $text = function_exists('mb_strtolower') ? mb_strtolower($text, 'UTF-8') : strtolower($text); + if (preg_match('/(^|[^a-zа-я])(price|old|цена|стоим)/u', $text)) { return '₽'; } + if (preg_match('/(discount|скидк)/u', $text)) { return '%'; } + if (preg_match('/(вес|нагруз|weight|load)/u', $text)) { return 'кг'; } + if (preg_match('/(ширин|высот|глубин|длин|размер|колес|поролон|width|height|depth|length|diameter)/u', $text)) { return 'см'; } + if (preg_match('/(угол|наклон|поворот|тренделенбург|angle|turn)/u', $text)) { return '°'; } + if (preg_match('/(гарант|warranty)/u', $text)) { return 'мес'; } + return ''; + } + + protected static function documentEditorKind(array $editor) + { + $type = isset($editor['type']) ? (string) $editor['type'] : ''; + if (in_array($type, array('dropdown', 'drop_down', 'drop_down_key'), true)) { + return 'choice'; + } + + if (in_array($type, array('multi_select', 'checkbox_multi', 'multi_checkbox'), true)) { + return 'choice_multi'; + } + + if ($type === 'multi_list_single') { + return 'list_single'; + } + + if ($type === 'multi_list_triple') { + return 'list_triple'; + } + + if (in_array($type, array('multi_list', 'multi_links'), true)) { + return 'list_pair'; + } + + if (in_array($type, array('single_line_numeric_two', 'single_line_numeric_three'), true)) { + return 'numeric_parts'; + } + + // В describe() default_kind описывает редактор значения документа. Для + // типов, чьи legacy defaults были конфигурацией (медиа, связи), control + // значения по умолчанию намеренно удалён, но сам editor kind не меняется. + if (isset($editor['default_kind']) && (string) $editor['default_kind'] !== '') { + return (string) $editor['default_kind']; + } + + $controls = isset($editor['controls']) && is_array($editor['controls']) ? $editor['controls'] : array(); + foreach ($controls as $control) { + if (isset($control['name']) && $control['name'] === 'rubric_field_default') { + return isset($control['kind']) ? (string) $control['kind'] : 'textarea'; + } + } + + return 'textarea'; + } + + protected static function viewValue($kind, array $parsed, $raw) + { + if ($kind === 'boolean') { + return !empty($parsed['checked']) ? '1' : '0'; + } + + if ($kind === 'date') { + return isset($parsed['date']) ? (string) $parsed['date'] : ''; + } + + if ($kind === 'datetime') { + return isset($parsed['datetime']) ? (string) $parsed['datetime'] : ''; + } + + if ($kind === 'period') { + return array( + 'start' => isset($parsed['start']) ? (string) $parsed['start'] : '', + 'end' => isset($parsed['end']) ? (string) $parsed['end'] : '', + ); + } + + if ($kind === 'number') { + return isset($parsed['number']) ? (string) $parsed['number'] : (string) $raw; + } + + if ($kind === 'range') { + return array( + 'min' => isset($parsed['min']) ? (string) $parsed['min'] : '', + 'max' => isset($parsed['max']) ? (string) $parsed['max'] : '', + ); + } + + if ($kind === 'dimensions') { + return array( + 'length' => isset($parsed['length']) ? (string) $parsed['length'] : '', + 'width' => isset($parsed['width']) ? (string) $parsed['width'] : '', + 'height' => isset($parsed['height']) ? (string) $parsed['height'] : '', + ); + } + + if ($kind === 'address') { + return array( + 'postal_code' => isset($parsed['postal_code']) ? (string) $parsed['postal_code'] : '', + 'region' => isset($parsed['region']) ? (string) $parsed['region'] : '', + 'city' => isset($parsed['city']) ? (string) $parsed['city'] : '', + 'street' => isset($parsed['street']) ? (string) $parsed['street'] : '', + 'building' => isset($parsed['building']) ? (string) $parsed['building'] : '', + 'unit' => isset($parsed['unit']) ? (string) $parsed['unit'] : '', + 'latitude' => isset($parsed['latitude']) ? (string) $parsed['latitude'] : '', + 'longitude' => isset($parsed['longitude']) ? (string) $parsed['longitude'] : '', + ); + } + + if ($kind === 'numeric_parts') { + return isset($parsed['parts']) && is_array($parsed['parts']) ? $parsed['parts'] : array(); + } + + if ($kind === 'choice') { + return isset($parsed['selected']) ? (string) $parsed['selected'] : (string) $raw; + } + + if ($kind === 'choice_multi') { + return isset($parsed['selected']) && is_array($parsed['selected']) ? $parsed['selected'] : array(); + } + + if ($kind === 'media') { + return array( + 'url' => isset($parsed['url']) ? (string) $parsed['url'] : '', + 'description' => isset($parsed['description']) ? (string) $parsed['description'] : '', + ); + } + + if ($kind === 'relation') { + return isset($parsed['document_id']) ? (string) $parsed['document_id'] : (string) $raw; + } + + if (in_array($kind, array('lines', 'relation_list'), true)) { + $items = isset($parsed['document_ids']) ? $parsed['document_ids'] : (isset($parsed['tags']) ? $parsed['tags'] : array()); + return is_array($items) ? implode("\n", $items) : (string) $raw; + } + + if (in_array($kind, array('key_value', 'pipe_list'), true)) { + return self::itemsToLines(isset($parsed['items']) ? $parsed['items'] : array(), $raw); + } + + if ($kind === 'media_list') { + return array( + 'raw' => (string) $raw, + 'items' => isset($parsed['items']) && is_array($parsed['items']) ? $parsed['items'] : array(), + ); + } + + if (in_array($kind, array('list_single', 'list_pair', 'list_triple', 'packages'), true)) { + return array( + 'raw' => (string) $raw, + 'items' => isset($parsed['items']) && is_array($parsed['items']) ? $parsed['items'] : array(), + ); + } + + if ($kind === 'pipe') { + return isset($parsed['parts']) && is_array($parsed['parts']) ? implode('|', $parsed['parts']) : (string) $raw; + } + + return (string) $raw; + } + + protected static function fieldInputValue(array $field, $value) + { + $kind = (string) $field['editor_kind']; + $type = (string) $field['rubric_field_type']; + if (!is_array($value)) { + $value = array('raw' => (string) $value); + } + + if ($type === 'catalog') { + $selected = CatalogModel::selection((int) $field['rubric_id'], (int) $field['Id'], isset($value['catalog_ids']) ? $value['catalog_ids'] : array()); + return CatalogModel::serializeSelection((int) $field['rubric_id'], (int) $field['Id'], $selected); + } + + if ($type === 'youtube') { + return FieldAdminEditors::serializeValue($type, array( + 'url' => isset($value['url']) ? (string) $value['url'] : '', + 'width' => isset($value['width']) ? (string) $value['width'] : '', + 'height' => isset($value['height']) ? (string) $value['height'] : '', + 'fullscreen' => isset($value['fullscreen']) ? (string) $value['fullscreen'] : '', + 'source' => isset($value['source']) ? (string) $value['source'] : '', + )); + } + + if ($kind === 'boolean') { + return FieldAdminEditors::serializeValue($type, array('checked' => !empty($value['checked']))); + } + + if ($kind === 'date') { + return FieldAdminEditors::serializeValue($type, array('date' => isset($value['date']) ? (string) $value['date'] : '')); + } + + if ($kind === 'datetime') { + return FieldAdminEditors::serializeValue($type, array('datetime' => isset($value['datetime']) ? (string) $value['datetime'] : '')); + } + + if ($kind === 'period') { + return FieldAdminEditors::serializeValue($type, array( + 'start' => isset($value['start']) ? (string) $value['start'] : '', + 'end' => isset($value['end']) ? (string) $value['end'] : '', + )); + } + + if ($kind === 'number') { + return FieldAdminEditors::serializeValue($type, array('number' => isset($value['number']) ? (string) $value['number'] : '')); + } + + if ($kind === 'range') { + return FieldAdminEditors::serializeValue($type, array( + 'min' => isset($value['min']) ? (string) $value['min'] : '', + 'max' => isset($value['max']) ? (string) $value['max'] : '', + )); + } + + if ($kind === 'dimensions') { + return FieldAdminEditors::serializeValue($type, array( + 'length' => isset($value['length']) ? (string) $value['length'] : '', + 'width' => isset($value['width']) ? (string) $value['width'] : '', + 'height' => isset($value['height']) ? (string) $value['height'] : '', + )); + } + + if ($kind === 'address') { + return FieldAdminEditors::serializeValue($type, array( + 'postal_code' => isset($value['postal_code']) ? (string) $value['postal_code'] : '', + 'region' => isset($value['region']) ? (string) $value['region'] : '', + 'city' => isset($value['city']) ? (string) $value['city'] : '', + 'street' => isset($value['street']) ? (string) $value['street'] : '', + 'building' => isset($value['building']) ? (string) $value['building'] : '', + 'unit' => isset($value['unit']) ? (string) $value['unit'] : '', + 'latitude' => isset($value['latitude']) ? (string) $value['latitude'] : '', + 'longitude' => isset($value['longitude']) ? (string) $value['longitude'] : '', + )); + } + + if ($kind === 'numeric_parts') { + return FieldAdminEditors::serializeValue($type, array('parts' => isset($value['parts']) && is_array($value['parts']) ? $value['parts'] : array())); + } + + if ($kind === 'choice') { + return FieldAdminEditors::serializeValue($type, array('selected' => isset($value['selected']) ? (string) $value['selected'] : '')); + } + + if ($kind === 'choice_multi') { + return FieldAdminEditors::serializeValue($type, array('selected' => isset($value['selected']) && is_array($value['selected']) ? $value['selected'] : array())); + } + + if ($kind === 'media') { + return FieldAdminEditors::serializeValue($type, array( + 'url' => isset($value['url']) ? (string) $value['url'] : '', + 'description' => isset($value['description']) ? (string) $value['description'] : '', + )); + } + + if ($kind === 'relation') { + return FieldAdminEditors::serializeValue($type, array('document_id' => isset($value['document_id']) ? (int) $value['document_id'] : 0)); + } + + if ($kind === 'relation_list') { + $ids = isset($value['document_ids']) && is_array($value['document_ids']) + ? $value['document_ids'] + : self::linesArray(isset($value['lines']) ? $value['lines'] : ''); + return FieldAdminEditors::serializeValue($type, array('document_ids' => $ids)); + } + + if ($kind === 'lines') { + if ($type === 'tags') { + return FieldAdminEditors::serializeValue($type, array('tags' => self::linesArray(isset($value['lines']) ? $value['lines'] : ''))); + } + + return isset($value['raw']) ? (string) $value['raw'] : (isset($value['lines']) ? (string) $value['lines'] : ''); + } + + if ($kind === 'media_list') { + return FieldAdminEditors::serializeValue($type, array( + 'items' => isset($value['items']) && is_array($value['items']) ? $value['items'] : array(), + )); + } + + if (in_array($kind, array('list_single', 'list_pair', 'list_triple', 'packages'), true)) { + return FieldAdminEditors::serializeValue($type, array( + 'items' => isset($value['items']) && is_array($value['items']) ? $value['items'] : array(), + )); + } + + if (in_array($kind, array('key_value', 'pipe_list'), true)) { + return self::serializeLinesByKind($type, $kind, isset($value['lines']) ? (string) $value['lines'] : ''); + } + + if ($kind === 'pipe') { + return FieldAdminEditors::serializeValue($type, array('parts' => preg_split('/[|,\r\n]+/', isset($value['raw']) ? (string) $value['raw'] : ''))); + } + + return isset($value['raw']) ? (string) $value['raw'] : ''; + } + + protected static function saveFieldValue($documentId, array $field, $value, $inSearch) + { + $documentId = (int) $documentId; + $fieldId = (int) $field['Id']; + $type = isset($field['rubric_field_type']) ? (string) $field['rubric_field_type'] : ''; + $value = FieldLifecycle::normalizeForStorage($value, $field, array('Id' => $documentId), 'adminx_document'); + $saving = FieldLifecycle::saving($value, $field, $documentId, 'adminx_document'); + if ($saving->cancelled()) { return; } + $value = $saving->result(); + $substr = mb_substr($value, 501, 1) ? 499 : 500; + $first = mb_substr($value, 0, $substr); + $slash = mb_substr($first, 498, 1) === '\\'; + if ($slash) { + $first = rtrim($first, '\\'); + } + + $data = array( + 'field_value' => $first, + 'field_number_value' => ((int) $field['rubric_field_numeric'] === 1) + ? ($type === 'period' ? \App\Content\Fields\Types\PeriodValue::indexValue($value) : self::numericValue($value)) + : 0, + 'document_in_search' => (int) $inSearch ? '1' : '0', + ); + $exists = (int) DB::query('SELECT Id FROM ' . self::docFieldsTable() . ' WHERE document_id = %i AND rubric_field_id = %i LIMIT 1', $documentId, $fieldId)->getValue(); + if ($exists > 0) { + DB::Update(self::docFieldsTable(), $data, 'Id = %i', $exists); + } else { + $data['document_id'] = $documentId; + $data['rubric_field_id'] = $fieldId; + DB::Insert(self::docFieldsTable(), $data); + } + + if (mb_strlen($value) > $substr) { + $more = mb_substr($value, $substr); + if ($slash) { + $more = '\\' . $more; + } + + $textId = (int) DB::query('SELECT Id FROM ' . self::docFieldsTextTable() . ' WHERE document_id = %i AND rubric_field_id = %i LIMIT 1', $documentId, $fieldId)->getValue(); + if ($textId > 0) { + DB::Update(self::docFieldsTextTable(), array('field_value' => $more), 'Id = %i', $textId); + } else { + DB::Insert(self::docFieldsTextTable(), array('document_id' => $documentId, 'rubric_field_id' => $fieldId, 'field_value' => $more)); + } + } else { + DB::Delete(self::docFieldsTextTable(), 'document_id = %i AND rubric_field_id = %i', $documentId, $fieldId); + } + + FieldLifecycle::saved($value, $field, $documentId, 'adminx_document'); + } + + protected static function relationDocuments(array $ids) + { + $ordered = array(); + foreach ($ids as $id) { + $id = (int) $id; + if ($id > 0 && !isset($ordered[$id])) { $ordered[$id] = null; } + } + + if (empty($ordered)) { return array(); } + + $rows = DB::query( + 'SELECT d.Id, d.document_title, d.document_alias, r.rubric_title' + . ' FROM ' . self::documentsTable() . ' d' + . ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id' + . ' WHERE d.Id IN (' . implode(',', array_keys($ordered)) . ')' + )->getAll(); + foreach ($rows as $row) { + $id = (int) $row['Id']; + $ordered[$id] = array( + 'id' => $id, + 'title' => self::decode($row['document_title']), + 'alias' => (string) $row['document_alias'], + 'rubric_title' => self::decode(isset($row['rubric_title']) ? $row['rubric_title'] : ''), + ); + } + + $out = array(); + foreach ($ordered as $id => $item) { + $out[] = $item ?: array('id' => (int) $id, 'title' => 'Документ недоступен', 'alias' => '', 'rubric_title' => ''); + } + + return $out; + } + + protected static function renderCatalogField(array $field, array $parsed) + { + $selectedIds = array(); + foreach (isset($parsed['catalog_ids']) ? $parsed['catalog_ids'] : array() as $cid) { + $selectedIds[] = (int) $cid; + } + + $selected = array_fill_keys($selectedIds, true); + $tree = CatalogModel::tree((int) $field['rubric_id'], (int) $field['Id'], false); + $settings = CatalogModel::settings((int) $field['rubric_id'], (int) $field['Id']); + $id = (int) $field['Id']; + + $flat = array(); + self::flattenCatalog($tree, $flat); + $tokens = ''; + foreach ($selectedIds as $cid) { + if (isset($flat[$cid])) { + $tokens .= self::catalogTokenHtml($id, $cid, $flat[$cid]['name'], $flat[$cid]['fields']); + } + } + + $html = '
    ' + . '' + . '
    ' . $tokens . '
    ' + . '

    Разделы не выбраны.

    ' + . '
    '; + return $html; + } + + protected static function flattenCatalog(array $items, array &$flat) + { + foreach ($items as $item) { + $flat[(int) $item['id']] = array( + 'name' => (string) $item['name'], + 'fields' => implode(',', isset($item['fields_use']) ? $item['fields_use'] : array()), + ); + if (!empty($item['children'])) { + self::flattenCatalog($item['children'], $flat); + } + } + } + + protected static function catalogTokenHtml($fieldId, $catalogId, $name, $fields) + { + return '' + . '' + . '' . self::e($name) . '' + . '' + . '' + . ''; + } + + protected static function renderCatalogNodes(array $items, $fieldId, array $selected) + { + $html = ''; + foreach ($items as $item) { + $fields = implode(',', isset($item['fields_use']) ? $item['fields_use'] : array()); + $isSelected = isset($selected[$item['id']]); + $html .= '
  • ' + . ''; + if (!empty($item['children'])) { $html .= '
      ' . self::renderCatalogNodes($item['children'], $fieldId, $selected) . '
    '; } + $html .= '
  • '; + } + + return $html; + } + + protected static function e($value) + { + return Str::escape((string) $value); + } + + /** + * Legacy field default is usually type configuration, not a document value. + * Only scalar/content fields use it as the initial value of a new document. + */ + protected static function initialFieldValue($type, $default) + { + $type = (string) $type; + $default = (string) $default; + if (in_array($type, array( + 'single_line', 'single_line_numeric', 'single_line_numeric_two', 'single_line_numeric_three', 'number', 'range', 'dimensions', 'packages', 'address', 'date_time', 'period', 'choice', + 'multi_line', 'multi_line_simple', 'multi_line_slim', 'content', 'text_to_html', 'text_to_image', 'checkbox', 'date', 'link', 'contact', 'color', 'code' + ), true)) { + return $default; + } + + if (in_array($type, array('dropdown', 'drop_down'), true)) { + foreach (preg_split('/\r\n|\r|\n|,/', $default) ?: array() as $option) { + $option = trim((string) $option); + if ($option !== '') { + return $option; + } + } + } + + return ''; + } + + protected static function fieldOptions($default, $type = '', array $settings = array()) + { + $default = (string) $default; + $type = (string) $type; + if (!empty($settings['options']) && is_array($settings['options'])) { + $out = array(); + foreach ($settings['options'] as $key => $option) { + if (is_array($option)) { + $value = isset($option['value']) ? (string) $option['value'] : (isset($option['key']) ? (string) $option['key'] : (string) $key); + $label = isset($option['label']) ? (string) $option['label'] : (isset($option['title']) ? (string) $option['title'] : $value); + } else { + $value = is_int($key) ? (string) $option : (string) $key; + $label = (string) $option; + } + + if ($value !== '' && $label !== '') { + $out[] = array('value' => $value, 'label' => $label); + } + } + + return $out; + } + + $lines = preg_split('/\r\n|\r|\n/', $default); + if (count($lines) <= 1) { $lines = explode(',', $default); } + $indexed = in_array($type, array('drop_down_key', 'checkbox_multi', 'multi_checkbox'), true); + $out = array(); + foreach ($lines ?: array() as $index => $line) { + $line = trim((string) $line); + if ($line === '') { + continue; + } + + $parts = explode('|', $line, 2); + $label = isset($parts[1]) && trim($parts[1]) !== '' ? trim($parts[1]) : trim($parts[0]); + $value = trim($parts[0]); + if ($indexed) { + $value = (string) ((int) $index + ($type === 'drop_down_key' ? 0 : 1)); + $label = $line; + } + + $out[] = array('value' => $value, 'label' => $label); + } + + return $out; + } + + protected static function mediaUploadDir(array $field, $documentId, $mediaDraftToken = '') + { + $type = isset($field['rubric_field_type']) ? (string) $field['rubric_field_type'] : ''; + $plugin = FieldRegistry::get($type); + if (!$plugin instanceof \App\Content\Fields\DocumentMediaFieldType) { + return '/uploads'; + } + + if ($mediaDraftToken !== '') { + return DocumentMediaDraft::directory($mediaDraftToken, (int) $field['Id']); + } + + return (int) $documentId > 0 ? self::mediaTargetDir($field, $documentId) : '/uploads'; + } + + protected static function mediaTargetDir(array $field, $documentId) + { + $rubric = self::rubric(isset($field['rubric_id']) ? (int) $field['rubric_id'] : 0); + return (int) $documentId > 0 + ? DocumentMediaPath::resolve($field, (int) $documentId, is_array($rubric) ? $rubric : array()) + : DocumentMediaPath::describe($field, is_array($rubric) ? $rubric : array()); + } + + protected static function linesArray($value) + { + $items = preg_split('/\r\n|\r|\n|,/', (string) $value); + $out = array(); + foreach ($items ?: array() as $item) { + $item = trim((string) $item); + if ($item !== '') { + $out[] = $item; + } + } + + return $out; + } + + protected static function serializeLinesByKind($type, $kind, $lines) + { + $items = array(); + foreach (preg_split('/\r\n|\r|\n/', (string) $lines) ?: array() as $line) { + $line = trim((string) $line); + if ($line === '') { + continue; + } + + $parts = explode('|', $line); + if ($kind === 'media_list') { + $items[] = array('url' => isset($parts[0]) ? $parts[0] : '', 'description' => isset($parts[1]) ? implode('|', array_slice($parts, 1)) : ''); + } elseif ($type === 'multi_list_single') { + $items[] = array('value' => $line); + } elseif ($type === 'multi_list_triple') { + $items[] = array('param' => isset($parts[0]) ? $parts[0] : '', 'value' => isset($parts[1]) ? $parts[1] : '', 'value2' => isset($parts[2]) ? $parts[2] : ''); + } else { + $items[] = array('param' => isset($parts[0]) ? $parts[0] : '', 'value' => isset($parts[1]) ? implode('|', array_slice($parts, 1)) : ''); + } + } + + return FieldAdminEditors::serializeValue($type, array('items' => $items)); + } + + protected static function itemsToLines(array $items, $raw) + { + if (empty($items)) { + return (string) $raw; + } + + $out = array(); + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + if (isset($item['url'])) { + $out[] = (string) $item['url'] . (!empty($item['description']) ? '|' . $item['description'] : ''); + } elseif (isset($item['value2'])) { + $out[] = (string) $item['param'] . '|' . (string) $item['value'] . '|' . (string) $item['value2']; + } elseif (isset($item['param'])) { + $out[] = (string) $item['param'] . (isset($item['value']) && $item['value'] !== '' ? '|' . $item['value'] : ''); + } elseif (isset($item['value'])) { + $out[] = (string) $item['value']; + } + } + + return implode("\n", $out); + } + + protected static function nextPosition() + { + return (int) DB::query('SELECT COALESCE(MAX(document_position), 0) + 1 FROM ' . self::documentsTable())->getValue(); + } + + protected static function systemLanguage() + { + if (defined('DEFAULT_LANGUAGE') && trim((string) DEFAULT_LANGUAGE) !== '') { + return trim((string) DEFAULT_LANGUAGE); + } + + return 'ru'; + } + + protected static function legacyAuthorId($adminId) + { + $adminId = (int) $adminId; + if ($adminId <= 0) { + return 1; + } + + $legacyId = 0; + try { + $hasLegacyId = DB::query('SHOW COLUMNS FROM ' . SystemTables::table('users') . ' LIKE %s', 'legacy_id')->numRows() > 0; + if ($hasLegacyId) { $legacyId = (int) DB::query('SELECT legacy_id FROM ' . SystemTables::table('users') . ' WHERE id = %i LIMIT 1', $adminId)->getValue(); } + } catch (\Throwable $e) { $legacyId = 0; } + return $legacyId > 0 ? $legacyId : $adminId; + } + + protected static function validLegacyAuthorId($id) + { + $id = (int) $id; + foreach (self::authors() as $author) { + if ((int) $author['id'] === $id) { + return $id; + } + } + + return 1; + } + + protected static function adminEmail($adminId) + { + return (string) DB::query('SELECT email FROM ' . SystemTables::table('users') . ' WHERE id = %i LIMIT 1', (int) $adminId)->getValue(); + } + + protected static function aliasHistoryMode($value) + { + $value = (int) $value; + return in_array($value, array(0, 1, 2), true) ? $value : 0; + } + + protected static function redirectHeader($value) + { + $value = (int) $value; + return in_array($value, array(301, 302, 307, 308), true) ? $value : 301; + } + + protected static function syncAliasHistory($documentId, $existing, array $data, $authorId, $rubric) + { + if (!$existing) { + return; + } + + $oldAlias = trim(isset($existing['document_alias']) ? (string) $existing['document_alias'] : '', '/ '); + $newAlias = trim(isset($data['document_alias']) ? (string) $data['document_alias'] : '', '/ '); + self::removeOwnedHistoryAlias($documentId, $newAlias); + if ($oldAlias === '' || $oldAlias === $newAlias) { + return; + } + + $mode = isset($data['document_alias_history']) ? (int) $data['document_alias_history'] : 0; + $rubricEnabled = $rubric && !empty($rubric['rubric_alias_history']); + if ($mode === 2 || ($mode === 0 && !$rubricEnabled)) { + return; + } + + if (!self::historyAliasAvailable($oldAlias, $documentId, 0)) { + return; + } + + DB::Insert(self::aliasHistoryTable(), array( + 'document_id' => (int) $documentId, + 'document_alias' => $oldAlias, + 'document_alias_header' => self::redirectHeader(isset($existing['document_alias_header']) ? $existing['document_alias_header'] : 301), + 'document_alias_author' => (int) $authorId > 0 ? (int) $authorId : 1, + 'document_alias_changed' => time(), + )); + } + + protected static function historyAliasAvailable($alias, $documentId, $exceptHistoryId) + { + $document = (int) DB::query( + 'SELECT Id FROM ' . self::documentsTable() . ' WHERE document_alias = %s AND Id != %i LIMIT 1', + (string) $alias, + (int) $documentId + )->getValue(); + if ($document > 0) { + return false; + } + + $history = (int) DB::query( + 'SELECT id FROM ' . self::aliasHistoryTable() . ' WHERE document_alias = %s AND id != %i LIMIT 1', + (string) $alias, + (int) $exceptHistoryId + )->getValue(); + return $history <= 0; + } + + protected static function removeOwnedHistoryAlias($documentId, $alias) + { + $alias = trim((string) $alias, '/ '); + if ($alias !== '') { + DB::Delete(self::aliasHistoryTable(), 'document_id = %i AND document_alias = %s', (int) $documentId, $alias); + } + } + + protected static function syncSearchTerms($documentId, $rubricId, $keywords, $tags) + { + DocumentTerms::sync($documentId, $rubricId, $keywords, $tags); + } + + protected static function parseDate($value) + { + $value = trim((string) $value); + if ($value === '') { + return 0; + } + + $time = strtotime($value); + return $time ? (int) $time : 0; + } + + protected static function robotsValue($value) + { + $value = trim((string) $value); + return in_array($value, array('index,follow', 'index,nofollow', 'noindex,nofollow'), true) ? $value : 'index,follow'; + } + + protected static function numericValue($value) + { + $value = preg_replace('/[^\d.]/', '', (string) $value); + return $value === '' ? 0 : $value; + } + + protected static function dateInput($time) + { + return (int) $time > 0 ? date('Y-m-d\\TH:i', (int) $time) : ''; + } + + protected static function decode($value) + { + return htmlspecialchars_decode(stripslashes((string) $value), ENT_QUOTES); + } + + protected static function clearDocumentCache($documentId) + { + ContentCacheInvalidator::document($documentId, false); + } + + protected static function buildDocumentSnapshot($documentId) + { + return ContentCacheInvalidator::document($documentId, true); + } + + protected static function tableExists($table) + { + return (bool) DB::query('SHOW TABLES LIKE %s', (string) $table)->getValue(); + } + } diff --git a/adminx/modules/Documents/Revisions.php b/adminx/modules/Documents/Revisions.php new file mode 100644 index 0000000..ef14051 --- /dev/null +++ b/adminx/modules/Documents/Revisions.php @@ -0,0 +1,360 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Documents; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Common\SystemTables; + use App\Content\Documents\DocumentRevisionPayload; + + class Revisions + { + const FORMAT = 'ave.document-revision'; + const VERSION = 2; + + public static function listForDocument($documentId, $limit = 50) + { + $rows = DB::query( + 'SELECT * FROM ' . Model::revisionsTable() . ' WHERE doc_id = %i ORDER BY doc_revision DESC, Id DESC LIMIT %i', + (int) $documentId, + max(1, min(200, (int) $limit)) + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = self::format($row, false); + } + + return $out; + } + + public static function one($id) + { + $row = DB::query('SELECT * FROM ' . Model::revisionsTable() . ' WHERE Id = %i LIMIT 1', (int) $id)->getAssoc(); + return $row ? self::format($row, true) : null; + } + + public static function capture($documentId, $authorId = 0) + { + $documentId = (int) $documentId; + $document = Model::one($documentId); + if (!$document) { return 0; } + $values = Model::currentFieldValues($documentId); + $payload = self::payload($document, $values); + + $last = DB::query( + 'SELECT doc_data FROM ' . Model::revisionsTable() . ' WHERE doc_id = %i ORDER BY doc_revision DESC, Id DESC LIMIT 1', + $documentId + )->getValue(); + $lastPayload = self::decodePayload((string) $last); + if (!empty($lastPayload['full']) && self::sameValues($payload, $lastPayload['raw'])) { + return 0; + } + + $revisionTime = !empty($document['document_changed']) ? (int) $document['document_changed'] : time(); + DB::Insert(Model::revisionsTable(), array( + 'doc_id' => $documentId, + 'doc_revision' => $revisionTime, + 'doc_data' => serialize($payload), + 'user_id' => (int) $authorId, + )); + return (int) DB::insertId(); + } + + public static function restore($revisionId, $authorId = 0) + { + return self::restoreData($revisionId, (int) $authorId, null, null); + } + + public static function restoreSelected($revisionId, array $documentKeys, array $fieldIds, $authorId = 0) + { + $documentKeys = array_values(array_unique(array_filter(array_map('strval', $documentKeys), 'strlen'))); + $fieldIds = array_values(array_unique(array_filter(array_map('intval', $fieldIds), function ($id) { return $id > 0; }))); + if (count($documentKeys) > 64 || count($fieldIds) > 1000) { + throw new \RuntimeException('В ревизии выбрано слишком много элементов'); + } + + if (empty($documentKeys) && empty($fieldIds)) { + throw new \RuntimeException('Выберите хотя бы один параметр или поле'); + } + + return self::restoreData($revisionId, (int) $authorId, $documentKeys, $fieldIds); + } + + protected static function restoreData($revisionId, $authorId, $documentKeys, $fieldIds) + { + $revision = self::one($revisionId); + if (!$revision || !is_array($revision['values'])) { + throw new \RuntimeException('Ревизия не найдена'); + } + + $partial = is_array($documentKeys) || is_array($fieldIds); + $documentState = !empty($revision['document']) ? $revision['document'] : array(); + $fieldValues = is_array($revision['values']) ? $revision['values'] : array(); + if ($partial) { + $selection = DocumentRevisionPayload::select( + $documentState, + $fieldValues, + is_array($documentKeys) ? $documentKeys : array(), + is_array($fieldIds) ? $fieldIds : array() + ); + $documentState = $selection['document']; + $fieldValues = $selection['fields']; + if (empty($documentState) && empty($fieldValues)) { + throw new \RuntimeException('Выбранных данных нет в этой ревизии'); + } + } + + $documentId = (int) $revision['doc_id']; + $ownsTransaction = !DB::$transaction_in_progress; + if ($ownsTransaction) { DB::startTransaction(); } + try { + self::capture($documentId, (int) $authorId); + if (!empty($documentState)) { + Model::restoreDocumentState($documentId, $documentState, (int) $authorId); + } + + if (!empty($fieldValues)) { + Model::restoreFieldValues($documentId, $fieldValues); + } + + if ($ownsTransaction) { DB::commit(); } + } catch (\Throwable $e) { + if ($ownsTransaction) { DB::rollback(); } + throw $e; + } + + return $documentId; + } + + public static function delete($revisionId) + { + $revision = self::one($revisionId); + if (!$revision) { + return false; + } + + DB::Delete(Model::revisionsTable(), 'Id = %i', (int) $revisionId); + return (int) $revision['doc_id']; + } + + public static function deleteForDocument($documentId) + { + $documentId = (int) $documentId; + if ($documentId <= 0) { + return 0; + } + + $count = (int) DB::query('SELECT COUNT(*) FROM ' . Model::revisionsTable() . ' WHERE doc_id = %i', $documentId)->getValue(); + DB::Delete(Model::revisionsTable(), 'doc_id = %i', $documentId); + return $count; + } + + protected static function format(array $row, $withData) + { + $created = isset($row['doc_revision']) ? (int) $row['doc_revision'] : 0; + $payload = self::decodePayload((string) $row['doc_data']); + $values = $withData ? $payload['fields'] : null; + $document = $withData ? $payload['document'] : null; + return array( + 'id' => (int) $row['Id'], + 'doc_id' => (int) $row['doc_id'], + 'doc_revision' => $created, + 'created_label' => $created > 0 ? date('d.m.Y H:i:s', $created) : '-', + 'user_id' => (int) $row['user_id'], + 'author_name' => self::authorName((int) $row['user_id']), + 'fields_count' => count($payload['fields']), + 'format_version' => $payload['full'] ? self::VERSION : 1, + 'format_label' => $payload['full'] ? 'Полный снимок' : 'Только поля', + 'size_label' => self::formatBytes(strlen((string) $row['doc_data'])), + 'values' => $values, + 'document' => $document, + 'document_preview' => $withData ? self::documentPreview($payload['document']) : array(), + 'preview' => $withData && is_array($values) ? self::preview($values) : array(), + ); + } + + protected static function payload(array $document, array $fields) + { + return DocumentRevisionPayload::create($document, $fields); + } + + protected static function decodePayload($serialized) + { + return DocumentRevisionPayload::decodeSerialized($serialized); + } + + protected static function documentPreview(array $document) + { + $labels = array( + 'document_title' => 'Название', 'document_alias' => 'Alias (URL)', 'document_short_alias' => 'Короткий alias', + 'document_breadcrumb_title' => 'Хлебная крошка', 'document_excerpt' => 'Краткое описание', + 'document_meta_keywords' => 'Meta keywords', 'document_meta_description' => 'Meta description', + 'document_meta_robots' => 'Meta robots', 'document_tags' => 'Теги', 'document_property' => 'Артикул / свойство', + 'guid' => 'GUID', 'document_status' => 'Статус', 'document_in_search' => 'Поиск', + 'document_parent' => 'Родитель', 'rubric_tmpl_id' => 'Шаблон', 'document_linked_navi_id' => 'Навигация', + 'document_position' => 'Позиция', 'document_published' => 'Дата публикации', 'document_expire' => 'Дата окончания', + 'document_author_id' => 'Автор', 'document_sitemap_freq' => 'Sitemap: частота', 'document_sitemap_pr' => 'Sitemap: приоритет', + ); + $out = array(); + foreach ($document as $key => $value) { + if (!isset($labels[$key])) { continue; } + if (in_array($key, array('document_published', 'document_expire'), true)) { + $value = (int) $value > 0 ? date('d.m.Y H:i:s', (int) $value) : 'не задано'; + } elseif ($key === 'document_status') { + $value = (int) $value === 1 ? 'Опубликован' : 'Черновик'; + } elseif ($key === 'document_in_search') { + $value = (int) $value === 1 ? 'В поиске' : 'Скрыт'; + } + + $out[] = array('key' => $key, 'title' => $labels[$key], 'value' => (string) $value, 'value_preview' => self::shorten((string) $value, 420)); + } + + return $out; + } + + protected static function preview(array $values) + { + $fieldIds = array(); + foreach (array_keys($values) as $fieldId) { + $fieldId = (int) $fieldId; + if ($fieldId > 0) { $fieldIds[] = $fieldId; } + } + + $fields = array(); + if (!empty($fieldIds)) { + $rows = DB::query( + 'SELECT Id, rubric_field_title, rubric_field_alias, rubric_field_type FROM ' . Model::fieldsTable() + . ' WHERE Id IN (' . implode(',', $fieldIds) . ')' + )->getAll(); + foreach ($rows as $row) { + $fields[(int) $row['Id']] = array( + 'title' => self::decode(isset($row['rubric_field_title']) ? $row['rubric_field_title'] : ''), + 'alias' => isset($row['rubric_field_alias']) ? (string) $row['rubric_field_alias'] : '', + 'type' => isset($row['rubric_field_type']) ? (string) $row['rubric_field_type'] : '', + ); + } + } + + $out = array(); + foreach ($values as $fieldId => $value) { + $meta = isset($fields[(int) $fieldId]) ? $fields[(int) $fieldId] : array(); + $out[] = array( + 'field_id' => (int) $fieldId, + 'title' => !empty($meta['title']) ? $meta['title'] : (!empty($meta['alias']) ? $meta['alias'] : 'Поле #' . (int) $fieldId), + 'alias' => isset($meta['alias']) ? $meta['alias'] : '', + 'type' => isset($meta['type']) ? $meta['type'] : '', + 'value' => (string) $value, + 'value_preview' => self::shorten((string) $value, 420), + 'size_label' => self::formatBytes(strlen((string) $value)), + ); + } + + return $out; + } + + protected static function decode($value) + { + return htmlspecialchars_decode(stripcslashes((string) $value), ENT_QUOTES); + } + + protected static function sameValues(array $a, array $b) + { + ksort($a); + ksort($b); + return serialize($a) === serialize($b); + } + + protected static function authorName($id) + { + if ((int) $id <= 0) { + return ''; + } + + $columns = self::userColumns(); + $idField = in_array('id', $columns, true) ? 'id' : (in_array('Id', $columns, true) ? 'Id' : 'id'); + $row = DB::query('SELECT * FROM ' . SystemTables::table('users') . ' WHERE ' . $idField . ' = %i LIMIT 1', (int) $id)->getAssoc(); + if (!$row) { + return '#' . (int) $id; + } + + $legacyName = trim((isset($row['firstname']) ? (string) $row['firstname'] : '') . ' ' . (isset($row['lastname']) ? (string) $row['lastname'] : '')); + if ($legacyName !== '') { + return $legacyName; + } + + if (!empty($row['name'])) { + return (string) $row['name']; + } + + if (!empty($row['login'])) { + return '@' . (string) $row['login']; + } + + if (!empty($row['user_name'])) { + return '@' . (string) $row['user_name']; + } + + if (!empty($row['email'])) { + return (string) $row['email']; + } + + return '#' . (int) $id; + } + + protected static function userColumns() + { + static $columns = null; + if ($columns !== null) { + return $columns; + } + + $columns = array(); + $rows = DB::query('SHOW COLUMNS FROM ' . SystemTables::table('users'))->getAll(); + foreach ($rows as $row) { + $columns[] = (string) $row['Field']; + } + + return $columns; + } + + protected static function shorten($value, $limit) + { + $value = trim((string) $value); + if (function_exists('mb_strlen') && mb_strlen($value, 'UTF-8') > $limit) { + return mb_substr($value, 0, $limit, 'UTF-8') . '...'; + } + + if (strlen($value) > $limit) { + return substr($value, 0, $limit) . '...'; + } + + return $value; + } + + protected static function formatBytes($bytes) + { + $bytes = (int) $bytes; + if ($bytes < 1024) { + return $bytes . ' Б'; + } + + if ($bytes < 1048576) { + return round($bytes / 1024, 1) . ' KB'; + } + + return round($bytes / 1048576, 1) . ' MB'; + } + } diff --git a/adminx/modules/Documents/assets/documents.css b/adminx/modules/Documents/assets/documents.css new file mode 100644 index 0000000..f937d6c --- /dev/null +++ b/adminx/modules/Documents/assets/documents.css @@ -0,0 +1,3529 @@ +.documents-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 14px; + margin-bottom: 16px; +} +.documents-panel a, +.documents-panel a:hover { + text-decoration: none; +} +.documents-stat { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; +} +.documents-stat b { + display: block; + font-size: 20px; + line-height: 1.1; + font-weight: 800; + font-variant-numeric: tabular-nums; +} +.documents-stat span { + font-size: 13px; + color: var(--text-secondary); +} +.documents-panel-header { + margin-bottom: 18px; +} +.documents-panel-header h2 { + font-size: 22px; + line-height: 1.2; + font-weight: 800; + letter-spacing: 0; +} +.documents-card { + padding: 0; + overflow: hidden; +} +.documents-term-combobox { + width: 100%; +} +.documents-term-input { + min-height: 42px; + padding: 5px 7px; + gap: 6px; + align-content: center; +} +.documents-term-input > input[data-document-term-query] { + min-width: 180px; + height: 28px; + padding: 0 3px; + border: 0 !important; + border-radius: 0; + outline: 0 !important; + background: transparent; + box-shadow: none !important; +} +.documents-term-input > input[data-document-term-query]:focus, +.documents-term-input > input[data-document-term-query]:focus-visible { + border: 0 !important; + outline: 0 !important; + box-shadow: none !important; +} +.documents-term-chip { + max-width: min(100%, 360px); + min-height: 28px; + padding: 3px 4px 3px 9px; +} +.documents-term-chip > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-term-chip > button { + position: relative; + flex: 0 0 auto; + width: 22px; + height: 22px; + padding: 0; + transition-property: background-color, opacity; + transition-duration: 120ms; +} +.documents-term-chip > button:active { + transform: scale(0.96); +} +.documents-term-chip > button::after { + position: absolute; + inset: -6px; + content: ''; +} +.documents-term-options { + z-index: 90; + max-height: 260px; + padding: 5px; +} +.documents-term-option { + width: 100%; + min-height: 40px; + border: 0; + background: var(--background-surface); + text-align: left; + color: var(--text-primary); +} +.documents-term-option:hover, +.documents-term-option[aria-selected="true"] { + background: var(--background-muted); +} +.documents-term-option > i { + flex: 0 0 auto; + color: var(--text-tertiary); + font-size: 16px; +} +.documents-term-option.is-new > i, +.documents-term-option.is-new .documents-term-option-label { + color: var(--color-primary); +} +.documents-term-option-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-term-option .co-sub { + flex: 0 0 auto; + font-variant-numeric: tabular-nums; +} +.documents-term-status { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 54px; + padding: 10px; + color: var(--text-muted); + font-size: 12.5px; +} +.documents-term-status.is-error { + color: var(--red-600); +} +.documents-term-status .ti-loader-2 { + animation: documents-term-spin 0.7s linear infinite; +} +@keyframes documents-term-spin { + to { + transform: rotate(360deg); + } +} +.documents-filter-card { + margin-bottom: 14px; +} +.documents-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border: 0; + border-bottom: 1px solid var(--border-default); +} +.documents-section-title { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} +.documents-section-title h2 { + margin: 0 0 4px; + font-size: 16px; + line-height: 1.25; + font-weight: 700; + letter-spacing: 0; +} +.documents-section-title p { + margin: 0; + font-size: 13px; + line-height: 1.4; +} +.documents-head-icon { + width: 34px; + height: 34px; + border-radius: var(--radius-md); + font-size: 17px; +} +/* Лёгкие карточки редактора: однострочные негромкие заголовки секций */ +.documents-edit-card .documents-section-head { + align-items: center; + padding: 11px 16px; +} +.documents-edit-card .documents-section-head h2 { + margin: 0; + font-size: 14px; + line-height: 1.3; + font-weight: 700; + letter-spacing: 0; + color: var(--text-primary); + min-width: 0; +} +/* Цветной акцент и тонированная шапка карточек редактора */ +.documents-edit-card .documents-section-head { + gap: 10px; + background: transparent; +} +.documents-head-left { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.documents-edit-card .documents-head-icon { + flex: 0 0 auto; + width: 32px; + height: 32px; + display: grid; + place-items: center; + font-size: 16px; +} +/* Компактные инструменты документа (заметки/редиректы/ревизии) */ +.documents-tools { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px; +} +.documents-tool { + display: flex; + align-items: center; + gap: 11px; + width: 100%; + padding: 8px 10px; + border: 0; + border-radius: var(--radius-md); + background: none; + text-align: left; + color: inherit; + cursor: pointer; + transition: background-color 0.12s; +} +.documents-tool:hover:not(:disabled) { + background: var(--surface-muted); +} +.documents-tool:disabled { + opacity: 0.55; + cursor: not-allowed; +} +.documents-tool .icon-tile { + flex: 0 0 auto; + width: 34px; + height: 34px; +} +.documents-tool-body { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1 1 auto; +} +.documents-tool-body b { + font-size: 13.5px; + line-height: 1.2; +} +.documents-tool-body small { + margin-top: 2px; + color: var(--text-secondary); + font-size: 11.5px; +} +.documents-tool-arrow { + color: var(--text-muted); + font-size: 16px; +} +.documents-toolbar, +.documents-filter { + display: flex; + align-items: center; + gap: 10px; +} +.documents-filter { + flex: 1 1 auto; + min-width: 0; + flex-wrap: wrap; +} +.documents-filter .input-wrap { + flex: 1 1 300px; + min-width: 240px; + max-width: 520px; +} +.documents-filter .select { + width: auto; + min-width: 190px; +} +.documents-panel .table-scroll { + max-height: none; + overflow-x: hidden; + overflow-y: hidden; +} +.documents-table { + width: 100%; + min-width: 0; + table-layout: fixed; +} +/* Представления списка, настроенные на уровне рубрики */ +.documents-admin-view-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 12px; + padding: 14px; + background: var(--background-muted); +} +.documents-admin-card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--surface-card); + transition-property: border-color, box-shadow, transform; + transition-duration: 150ms; +} +.documents-admin-card:hover { + border-color: var(--blue-300); + box-shadow: var(--shadow-sm); +} +.documents-admin-card.is-draft { + border-left: 3px solid var(--amber-400); +} +.documents-admin-card.is-deleted { + border-left: 3px solid var(--gray-400); + background: color-mix(in srgb, var(--gray-100) 35%, var(--surface-card)); +} +.documents-admin-card > header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: start; + min-height: 66px; + padding: 12px 14px; + border-bottom: 1px solid var(--border-subtle); + background: var(--background-muted); +} +.documents-admin-card-title { + min-width: 0; +} +.documents-admin-card-title a, +.documents-admin-card-title small { + display: block; + letter-spacing: 0; +} +.documents-admin-card-title a { + display: -webkit-box; + overflow: hidden; + color: var(--text-primary); + font-size: 13.5px; + font-weight: 700; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + white-space: normal; +} +.documents-admin-card-title small { + margin-top: 3px; + overflow: hidden; + color: var(--text-muted); + font-size: 10.5px; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-admin-card-body { + min-height: 92px; + padding: 12px 14px; +} +.documents-admin-fields { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; +} +.documents-admin-fields > div { + grid-column: span 3; + min-width: 0; +} +.documents-admin-fields > .is-small { + grid-column: span 2; +} +.documents-admin-fields > .is-wide { + grid-column: span 6; +} +.documents-admin-fields small { + display: block; + margin-bottom: 4px; + color: var(--text-muted); + font-size: 10.5px; +} +.documents-admin-fields > div > span:not(.badge) { + display: block; + overflow: hidden; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-admin-fields img, +.documents-admin-value img { + display: block; + width: 48px; + height: 48px; + border-radius: var(--radius-md); + object-fit: cover; + outline: 1px solid rgba(0, 0, 0, 0.1); +} +[data-theme="dark"] .documents-admin-fields img, +[data-theme="dark"] .documents-admin-value img { + outline-color: rgba(255, 255, 255, 0.1); +} +.documents-admin-fields-empty { + grid-column: 1 / -1 !important; + color: var(--text-muted); + font-size: 12px; + text-align: center; +} +.documents-admin-custom { + min-width: 0; + color: var(--text-secondary); + font-size: 12.5px; + line-height: 1.5; +} +.documents-admin-custom > :first-child { + margin-top: 0; +} +.documents-admin-custom > :last-child { + margin-bottom: 0; +} +.documents-admin-card > footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 50px; + padding: 8px 10px 8px 14px; + border-top: 1px solid var(--border-subtle); +} +.documents-admin-card > footer > span { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 10.5px; +} +.documents-admin-table-scroll { + overflow-x: hidden; +} +.documents-admin-table { + width: 100%; + min-width: 0; + table-layout: fixed; +} +.documents-admin-table th, +.documents-admin-table td { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.documents-admin-table th { + white-space: normal; +} +.documents-admin-table .documents-admin-check { + width: 44px; +} +.documents-admin-table .documents-admin-id { + width: 78px; +} +.documents-admin-table .documents-admin-actions { + width: 176px; +} +.documents-admin-table th.is-small { + width: 9%; +} +.documents-admin-table th.is-medium { + width: 13%; +} +.documents-admin-table th.is-wide { + width: 19%; +} +.documents-admin-value > span:not(.badge) { + display: block; + overflow: hidden; + color: var(--text-secondary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-admin-table tbody tr:nth-child(even):not(.is-draft):not(.is-deleted) { + background: color-mix(in srgb, var(--gray-100) 35%, transparent); +} +@media (max-width: 760px) { + .documents-admin-view-grid { + grid-template-columns: 1fr; + padding: 10px; + } + .documents-admin-card > footer { + align-items: flex-start; + flex-direction: column; + } + .documents-admin-table-scroll { + overflow-x: auto; + } + .documents-admin-table { + min-width: 760px; + } +} +.documents-table th { + white-space: normal; +} +.documents-col-id { + width: 7ch; +} +.documents-col-title { + width: 32%; +} +.documents-col-check { + width: 44px; +} +.documents-col-rubric { + width: 22%; +} +.documents-col-state { + width: 18%; +} +.documents-col-date { + width: 18%; +} +.documents-col-actions { + width: 184px; +} +.documents-name, +.documents-meta { + display: grid; + gap: 3px; + min-width: 0; +} +.documents-name b, +.documents-name small, +.documents-meta span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-title-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + margin-top: 5px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.35; +} +.documents-title-meta > span { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; +} +.documents-title-meta .mono { + color: var(--text-muted); +} +.documents-search-flag { + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} +.documents-mobile-label { + display: none; +} +.documents-row.is-draft td:first-child { + box-shadow: inset 3px 0 0 var(--amber-500); +} +.documents-row.is-deleted td { + color: var(--text-secondary); + background: color-mix(in srgb, var(--red-50) 48%, var(--background-surface)); +} +.documents-row.is-deleted td:first-child { + box-shadow: inset 3px 0 0 var(--red-500); +} +.documents-name small, +.documents-meta span { + font-size: 12px; + color: var(--text-secondary); +} +.documents-badges, +.documents-actions { + flex-wrap: nowrap; +} +.documents-actions .btn { + text-decoration: none; +} +.documents-action-edit { + color: var(--blue-600); +} +.documents-action-restore { + color: var(--green-600); +} +.documents-action-toggle { + color: var(--amber-600); +} +.documents-action-copy { + color: var(--violet-600); +} +.redirects-table { + width: 100%; + table-layout: fixed; +} +.redirects-table th:nth-child(1) { + width: 28%; +} +.redirects-table th:nth-child(2) { + width: 76px; +} +.redirects-table th:nth-child(3) { + width: 34%; +} +.redirects-table th:nth-child(4) { + width: 18%; +} +.redirects-table th:last-child { + width: 104px; +} +.redirects-url { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.redirects-url > a { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); + text-decoration: none; +} +.redirects-url > a:hover { + color: var(--blue-600); +} +.documents-side-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) 36px; + gap: 8px; +} +.documents-alias-target { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + margin-bottom: 12px; + border: 1px solid var(--border-default); + background: var(--surface-subtle); +} +.documents-alias-target > div { + display: grid; + gap: 3px; + min-width: 0; +} +.documents-alias-target small { + color: var(--text-secondary); +} +.documents-alias-target a, +.documents-alias-main a { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); + text-decoration: none; +} +.redirects-panel .table-scroll { + overflow-x: hidden; +} +.redirects-panel .documents-name a { + display: block; + min-width: 0; + color: inherit; + text-decoration: none; +} +.redirects-panel .documents-name a b { + display: block; +} +#redirectDrawer .drawer-form { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} +#redirectDrawer { + width: min(66.666vw, 1040px); + max-width: calc(100vw - 24px); +} +#redirectDrawer .drawer-body { + flex: 1 1 auto; + overflow-y: auto; +} +.documents-relation-choice:disabled { + opacity: 0.7; + cursor: default; +} +@media (max-width: 820px) { + #redirectDrawer { + width: calc(100vw - 12px); + max-width: calc(100vw - 12px); + } + #redirectDrawer .field.col-9, + #redirectDrawer .field.col-3 { + grid-column: span 12; + } + .redirects-panel .table-scroll { + overflow: visible; + } + .redirects-table, + .redirects-table tbody, + .redirects-table tr, + .redirects-table td { + display: block; + width: 100%; + } + .redirects-panel table.redirects-table { + min-width: 0; + } + .redirects-table colgroup, + .redirects-table thead { + display: none; + } + .redirects-table .redirects-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: "url code" "target target" "date actions"; + gap: 10px 12px; + padding: 13px 14px; + border-bottom: 1px solid var(--border-default); + } + .redirects-table .redirects-row:nth-child(even) { + background: color-mix(in srgb, var(--background-muted) 55%, var(--background-surface)); + } + .redirects-table .redirects-row td { + min-width: 0; + padding: 0; + border: 0; + background: transparent; + } + .redirects-cell-url { + grid-area: url; + } + .redirects-cell-code { + grid-area: code; + justify-self: end; + } + .redirects-cell-target { + grid-area: target; + padding-top: 9px !important; + border-top: 1px solid var(--border-subtle) !important; + } + .redirects-cell-date { + grid-area: date; + } + .redirects-cell-actions { + grid-area: actions; + align-self: end; + } + .redirects-cell-date .documents-meta { + display: flex; + flex-wrap: wrap; + gap: 5px 12px; + } + .redirects-cell-actions .documents-actions { + justify-content: flex-end; + } + .redirects-empty-row, + .redirects-empty-row td { + display: block; + } +} +.documents-action-danger { + color: var(--red-600); +} +.documents-action-locked { + color: var(--text-tertiary); +} +.documents-filter .documents-per-page { + min-width: 150px; +} +.documents-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 20px; + border-top: 1px solid var(--border-default); +} +.documents-pagination .pagination { + padding-top: 0; + justify-content: flex-end; +} +.documents-bulk { + position: sticky; + z-index: 4; + bottom: 12px; + margin: 12px; +} +.documents-bulk .select { + width: min(260px, 40vw); +} +.documents-page-tabs { + margin-bottom: 16px; +} +.documents-api-layout { + display: grid; + gap: 16px; +} +.documents-api-info { + margin: 0; +} +.documents-api-info .alert-title, +.documents-api-info .alert-title + span, +.documents-api-secret .alert-title, +.documents-api-secret .alert-title + span { + display: block; +} +.documents-api-card .table-scroll { + max-height: none; + overflow-y: visible; +} +.documents-api-table { + min-width: 940px; +} +.documents-api-table th:last-child, +.documents-api-table td:last-child { + width: 54px; + text-align: right; +} +.documents-api-table tbody tr.is-revoked td { + color: var(--text-muted); +} +.documents-api-name { + display: grid; + gap: 3px; + min-width: 170px; +} +.documents-api-name b { + color: var(--text-primary); + font-size: 13.5px; + line-height: 1.35; +} +.documents-api-name small, +.documents-api-date { + color: var(--text-secondary); + font-size: 11.5px; + white-space: nowrap; +} +.documents-api-scopes { + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.documents-api-actions { + text-align: right; +} +.documents-api-empty { + display: grid; + grid-template-columns: 1fr; + align-content: center; + justify-items: center; + gap: 10px; + min-height: 150px; + padding: 20px; + text-align: center; +} +.documents-api-empty .icon-tile { + width: 42px; + height: 42px; + margin: 0; + font-size: 19px; +} +.documents-api-empty > div { + display: grid; + justify-items: center; + gap: 3px; + max-width: 360px; +} +.documents-api-empty b, +.documents-api-empty > div > span { + display: block; + margin: 0; +} +.documents-api-empty > div > span { + color: var(--text-secondary); + font-size: 12.5px; + line-height: 1.45; +} +.documents-api-token-drawer.drawer-lg { + width: min(720px, 94vw); +} +.documents-api-token-drawer form { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; +} +.documents-api-drawer-body { + background: var(--background-muted); +} +.documents-api-token-fields { + display: grid; + gap: 14px; +} +.documents-api-token-fields[hidden], +.documents-api-secret[hidden], +[data-api-token-submit][hidden] { + display: none !important; +} +.documents-api-form-section { + display: grid; + gap: 16px; + padding: 18px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} +.documents-api-form-heading { + display: flex; + align-items: center; + gap: 12px; +} +.documents-api-form-heading .icon-tile { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + font-size: 17px; +} +.documents-api-form-heading > div > b, +.documents-api-form-heading > div > span { + display: block; +} +.documents-api-form-heading > div > b { + font-size: 14px; + line-height: 1.3; +} +.documents-api-form-heading > div > span { + margin-top: 2px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; +} +.documents-api-scope-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.documents-api-scope { + position: relative; + display: grid; + grid-template-columns: 36px minmax(0, 1fr) 22px; + align-items: center; + gap: 11px; + min-height: 86px; + padding: 13px; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: var(--background-surface); + cursor: pointer; + transition: border-color 0.14s, box-shadow 0.14s, background-color 0.14s; +} +.documents-api-scope:hover { + border-color: var(--blue-300); + background: color-mix(in srgb, var(--blue-50) 34%, var(--background-surface)); +} +.documents-api-scope:has(input:checked) { + border-color: var(--blue-400); + background: color-mix(in srgb, var(--blue-50) 56%, var(--background-surface)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--blue-500) 12%, transparent); +} +.documents-api-scope input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} +.documents-api-scope:has(input:focus-visible) { + box-shadow: var(--focus-ring); +} +.documents-api-scope-icon { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: var(--radius-sm); + background: var(--background-muted); + color: var(--text-secondary); + font-size: 17px; +} +.documents-api-scope:has(input:checked) .documents-api-scope-icon { + background: var(--blue-100); + color: var(--blue-600); +} +.documents-api-scope b, +.documents-api-scope small { + display: block; +} +.documents-api-scope b { + font-size: 13px; + line-height: 1.3; +} +.documents-api-scope small { + margin-top: 3px; + color: var(--text-secondary); + font-size: 11.5px; + line-height: 1.35; +} +.documents-api-scope-check { + display: grid; + width: 20px; + height: 20px; + place-items: center; + border: 1px solid var(--border-strong); + border-radius: 50%; + color: transparent; + font-size: 13px; +} +.documents-api-scope:has(input:checked) .documents-api-scope-check { + border-color: var(--blue-600); + background: var(--blue-600); + color: #fff; +} +.documents-api-secret { + padding: 28px; + border-radius: var(--radius-md); + background: var(--background-surface); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 0 0 1px var(--border-default); + text-align: center; +} +.documents-api-secret-status { + display: grid; + width: 50px; + height: 50px; + margin: 0 auto 13px; + place-items: center; + border-radius: 50%; + background: var(--green-100); + color: var(--green-600); + font-size: 25px; +} +.documents-api-secret h4 { + margin: 0; + font-size: 18px; + letter-spacing: 0; +} +.documents-api-secret > p { + max-width: 470px; + margin: 7px auto 22px; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.5; +} +.documents-api-secret .field { + text-align: left; +} +.documents-api-secret-control { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; +} +.documents-api-secret-control .input { + font-size: 12px; +} +.documents-api-secret .alert { + margin-top: 16px; + text-align: left; +} +@media (max-width: 720px) { + .documents-api-table-scroll { + overflow: visible; + } + .documents-api-table, + .documents-api-table tbody, + .documents-api-table tr, + .documents-api-table td { + display: block; + width: 100%; + } + table.documents-api-table { + min-width: 0; + } + .documents-api-table thead { + display: none; + } + .documents-api-table tr[data-api-token-row] { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 18px; + padding: 15px 14px; + border-bottom: 1px solid var(--border-default); + } + .documents-api-table tr[data-api-token-row]:last-child { + border-bottom: 0; + } + .documents-api-table tr[data-api-token-row] td { + min-width: 0; + padding: 0; + border: 0; + background: transparent; + } + .documents-api-table tr[data-api-token-row] td::before { + display: block; + margin-bottom: 5px; + color: var(--text-muted); + content: attr(data-label); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + .documents-api-table tr[data-api-token-row] td:nth-child(1), + .documents-api-table tr[data-api-token-row] td:nth-child(3) { + grid-column: 1 / -1; + } + .documents-api-table tr[data-api-token-row] td:last-child { + grid-column: 1 / -1; + width: 100%; + text-align: right; + } + .documents-api-table tr[data-api-token-row] td:last-child:empty { + display: none; + } + .documents-api-table tr[data-api-token-row] td:last-child::before { + display: none; + } + .documents-api-name { + min-width: 0; + } + .documents-api-table tr[data-api-token-empty], + .documents-api-table tr[data-api-token-empty] td { + display: block; + width: 100%; + } + .documents-api-empty { + min-height: 132px; + padding: 18px; + } + .documents-api-scope-grid { + grid-template-columns: 1fr; + } + .documents-api-form-section, + .documents-api-secret { + padding: 15px; + } + .documents-api-secret-control { + grid-template-columns: 1fr; + } + .documents-api-secret-control .btn { + width: 100%; + } +} +.documents-views-panel { + display: grid; + gap: 16px; +} +.documents-views-grid { + display: grid; + grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); + gap: 16px; + align-items: start; +} +.documents-view-link { + display: grid; + gap: 3px; + min-width: 0; + color: inherit; + text-decoration: none; +} +.documents-view-link b, +.documents-view-link small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-view-link small { + color: var(--text-secondary); + font-size: 12px; +} +.documents-edit-header h1 { + text-wrap: balance; +} +.documents-editor-modes { + flex: 0 0 auto; +} +.documents-editor-modes .segmented-item { + min-height: 36px; + gap: 6px; +} +.documents-edit-form[data-editor-mode="quick"] [data-editor-level="normal"], +.documents-edit-form[data-editor-mode="quick"] [data-editor-level="advanced"], +.documents-edit-form[data-editor-mode="normal"] [data-editor-level="advanced"] { + display: none; +} +.documents-edit-form[data-editor-mode="quick"] .documents-edit-layout { + grid-template-columns: minmax(0, 1fr); +} +.documents-draft-recovery, +.documents-error-summary { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; + padding: 13px 14px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); +} +.documents-draft-recovery[hidden], +.documents-error-summary[hidden] { + display: none; +} +.documents-draft-recovery { + border-color: var(--amber-300); + background: var(--amber-50); +} +.documents-draft-recovery-copy, +.documents-error-summary-copy { + min-width: 0; + flex: 1; +} +.documents-draft-recovery-copy b, +.documents-draft-recovery-copy span { + display: block; +} +.documents-draft-recovery-copy span { + margin-top: 3px; + color: var(--text-secondary); + font-size: 13px; +} +.documents-error-summary { + border-color: var(--red-300); + background: var(--red-50); +} +.documents-error-summary-copy > b { + display: block; + margin-bottom: 8px; + color: var(--red-800); +} +.documents-error-summary-copy > div { + display: grid; + gap: 5px; +} +.documents-error-jump { + display: grid; + grid-template-columns: minmax(120px, 0.35fr) minmax(0, 1fr) 18px; + align-items: center; + gap: 10px; + width: 100%; + padding: 7px 9px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-primary); + text-align: left; + cursor: pointer; +} +.documents-error-jump:hover { + background: color-mix(in srgb, var(--red-100) 68%, var(--background-card)); +} +.documents-error-jump span { + color: var(--text-secondary); +} +.documents-edit-top { + display: grid; + grid-template-columns: minmax(0, 1.15fr) repeat(2, minmax(0, 0.925fr)); + gap: 16px; + align-items: stretch; + margin-bottom: 16px; +} +@media (max-width: 1100px) { + .documents-edit-top { + grid-template-columns: 1fr; + } +} +.documents-edit-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 16px; + align-items: start; +} +.documents-fields-section { + width: 100%; + margin-top: 24px; + background: transparent; +} +.documents-fields-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; + padding: 0 0 12px; + border-bottom: 1px solid var(--border-default); + background: transparent; +} +.documents-fields-heading h2 { + margin: 0; + font-size: 16px; + line-height: 1.3; + font-weight: 700; + letter-spacing: 0; +} +.documents-edit-card { + margin: 0; +} +.documents-card-body { + padding: 16px; +} +.documents-edit-side { + position: sticky; + top: 82px; + align-self: start; +} +.documents-edit-form .textarea { + resize: vertical; +} +.documents-placeholder { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 16px; + border-radius: var(--radius-lg); + background: var(--bg-soft); +} +.documents-placeholder b { + display: block; + margin-bottom: 4px; + font-size: 14px; + color: var(--text-primary); +} +.documents-placeholder p { + margin: 0; + font-size: 13px; + line-height: 1.45; + text-wrap: pretty; +} +.documents-field-tabs { + display: flex; + gap: 8px; + margin-bottom: 16px; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 2px; + scrollbar-width: thin; +} +.documents-field-tabs .tab { + flex: 0 0 auto; + min-height: 40px; + text-decoration: none; + white-space: nowrap; +} +/* Пустая группа реально скрыта: класс .tab с display перебивал атрибут [hidden]. */ +.documents-field-tabs .tab[hidden] { + display: none; +} +.documents-field-tabs .tab-count { + font-variant-numeric: tabular-nums; +} +.documents-field-tabs .tab-hint { + margin-left: 2px; + font-size: 14px; + color: var(--text-tertiary); +} +.documents-field-tabs .tab.is-active .tab-hint { + color: var(--text-secondary); +} +.documents-field-tabs .tab.is-condition-locked { + box-shadow: inset 0 0 0 1px var(--amber-300); +} +.documents-group-lock { + margin-left: 2px; + color: var(--amber-600); + font-size: 13px; +} +.documents-fields-groups { + display: grid; + gap: 16px; +} +.documents-field-group { + display: grid; + gap: 10px; +} +/* Неактивная группа реально скрыта: класс-селектор перебивал [hidden] с display:none. */ +.documents-field-group[hidden] { + display: none; +} +/* Поля документа: плагин типа рисует контрол, вид как в референсе — + компактная рамка (шапка-полоска + soft-body) в 12-колоночной сетке. */ +.documents-fields, +.ax-document-fields { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 12px; +} +.ax-document-field { + grid-column: span 12; + display: grid; + grid-template-rows: auto minmax(0, auto); + overflow: hidden; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: #fff; + transition: border-color 0.14s, box-shadow 0.14s; +} +.ax-document-field:hover { + border-color: color-mix(in srgb, var(--color-primary) 35%, var(--border-default)); +} +.ax-document-field:focus-within { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-soft); +} +.ax-document-field.is-required .ax-attr-label::before { + content: ""; + width: 6px; + height: 6px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--amber-500); +} +.ax-document-field.is-invalid { + border-color: var(--red-500); + box-shadow: 0 0 0 1px var(--red-500); +} +.ax-document-field.is-condition-hidden { + display: none; +} +.ax-document-field.is-condition-locked .ax-document-field-body { + opacity: 0.72; + pointer-events: none; +} +.ax-condition-lock { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 22px; + padding: 2px 7px; + border-radius: var(--radius-full); + background: var(--amber-100); + color: var(--amber-700); + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} +.ax-condition-lock i { + font-size: 13px; +} +.ax-condition-options { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 22px; + padding: 2px 7px; + border-radius: var(--radius-full); + background: var(--blue-100); + color: var(--blue-700); + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} +.ax-condition-options i { + font-size: 13px; +} +.ax-condition-options[hidden] { + display: none; +} +.ax-condition-options.is-warning { + background: var(--amber-100); + color: var(--amber-700); +} +.ax-condition-action { + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: auto; + padding: 4px 5px 4px 7px; + border-radius: var(--radius-sm); + background: var(--blue-100); + color: var(--blue-700); + font-size: 10.5px; + font-weight: 600; +} +.ax-condition-action > i { + font-size: 13px; +} +.ax-condition-action button { + display: inline-grid; + place-items: center; + width: 28px; + height: 28px; + margin: -3px -3px -3px 1px; + padding: 0; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + cursor: pointer; +} +.ax-condition-action button:hover { + background: rgba(255, 255, 255, 0.72); +} +.documents-choice-option.is-condition-unavailable { + opacity: 0.58; +} +[data-theme="dark"] .ax-condition-lock { + background: color-mix(in srgb, var(--amber-500) 18%, var(--background-card)); + color: var(--amber-300); +} +[data-theme="dark"] .ax-condition-options { + background: color-mix(in srgb, var(--blue-500) 18%, var(--background-card)); + color: var(--blue-300); +} +[data-theme="dark"] .ax-condition-options.is-warning { + background: color-mix(in srgb, var(--amber-500) 18%, var(--background-card)); + color: var(--amber-300); +} +[data-theme="dark"] .ax-condition-action { + background: color-mix(in srgb, var(--blue-700) 42%, var(--surface-card)); + color: var(--blue-100); +} +.ax-document-field .field-error[data-field-value-error] { + margin: 0; + padding: 6px 12px 8px; + color: var(--red-600); +} +.ax-document-field.is-half { + grid-column: span 6; +} +.ax-document-field.is-third { + grid-column: span 4; +} +.ax-document-field.is-quarter { + grid-column: span 3; +} +.ax-document-field.is-two-thirds { + grid-column: span 8; +} +.ax-document-field.is-wide { + grid-column: span 12; +} +.ax-document-field.is-catalog-hidden { + display: none; +} +@media (max-width: 720px) { + .ax-document-field.is-half, + .ax-document-field.is-third, + .ax-document-field.is-quarter, + .ax-document-field.is-two-thirds { + grid-column: span 12; + } +} +/* Обязательное поле */ +.ax-required { + margin-left: 3px; + color: var(--red-600); + font-weight: 700; +} +/* Составное поле: приставка/суффикс (напр. цена + ₽) */ +.ax-document-field-body.ax-field-affix { + display: flex; + align-items: stretch; + gap: 0; +} +.ax-document-field-body.ax-field-affix > .input, +.ax-document-field-body.ax-field-affix .input { + flex: 1 1 auto; + min-width: 0; +} +.ax-document-field-body.ax-field-affix .input-addon { + flex: 0 0 auto; +} +.ax-attr-label { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 7px; + min-height: 34px; + margin: 0; + padding: 8px 12px; + border: 0; + background: transparent; +} +.ax-attr-label .ax-label { + margin: 0; + margin-right: auto; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} +.ax-attr-id { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: var(--radius-full); + background: var(--background-card); + border: 1px solid var(--border-subtle); + color: var(--text-tertiary); + font-family: var(--font-mono, ui-monospace, "SFMono-Regular", monospace); + font-size: 11px; + font-weight: 500; + font-variant-numeric: tabular-nums; +} +.ax-attr-type { + display: none; +} +.ax-attr-noplugin { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: var(--radius-full); + background: var(--background-inset); + color: var(--text-tertiary); + font-size: 11px; + font-weight: 600; +} +.ax-document-field-body { + min-width: 0; + padding: 12px 13px; + background: transparent; +} +[data-theme="dark"] .ax-document-field { + background: var(--background-card); +} +.ax-document-field-body > .input, +.ax-document-field-body > .select, +.ax-document-field-body > .textarea { + width: 100%; +} +/* body-обёртка также несёт .documents-field-control (JS-хук пикеров) */ +.documents-field-control { + display: grid; + gap: 7px; +} +.documents-boolean-control { + display: flex; + align-items: center; + min-height: 38px; +} +.documents-boolean-control .switch { + color: var(--text-secondary); + font-weight: 600; +} +.documents-color-control { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + align-items: center; + gap: 8px; +} +.documents-color-control input[type="color"] { + width: 42px; + height: 38px; + padding: 3px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); + cursor: pointer; +} +.documents-color-control input[type="color"]::-webkit-color-swatch-wrapper { + padding: 0; +} +.documents-color-control input[type="color"]::-webkit-color-swatch { + border: 0; + border-radius: calc(var(--radius-md) - 3px); +} +.documents-range-control, +.documents-period-control { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.documents-dimensions-control { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.documents-address-control { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 10px; + min-width: 0; +} +.documents-address-control > .field { + min-width: 0; + margin: 0; +} +.documents-address-postal_code { + grid-column: span 2; +} +.documents-address-region, +.documents-address-city { + grid-column: span 5; +} +.documents-address-street { + grid-column: span 7; +} +.documents-address-building { + grid-column: span 2; +} +.documents-address-unit { + grid-column: span 3; +} +.documents-address-latitude, +.documents-address-longitude { + grid-column: span 6; +} +@media (max-width: 980px) { + .documents-address-postal_code { + grid-column: span 4; + } + .documents-address-region { + grid-column: span 8; + } + .documents-address-city { + grid-column: span 12; + } + .documents-address-street { + grid-column: span 8; + } + .documents-address-building, + .documents-address-unit { + grid-column: span 2; + } +} +.documents-dimensions-control .field, +.documents-dimensions-control .input-group { + min-width: 0; +} +.documents-range-control .field, +.documents-range-control .input-group, +.documents-period-control .field { + min-width: 0; +} +@media (max-width: 640px) { + .documents-range-control, + .documents-period-control, + .documents-dimensions-control { + grid-template-columns: 1fr; + } + .documents-address-control > .field { + grid-column: span 12; + } +} +.documents-choice-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px; +} +.documents-choice-option { + display: flex; + align-items: flex-start; + gap: 9px; + min-width: 0; + padding: 9px 10px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); + color: var(--text-secondary); + cursor: pointer; +} +.documents-choice-option:has(input:checked) { + border-color: var(--blue-300); + background: var(--blue-50); + color: var(--text-primary); +} +.documents-choice-option input { + flex: 0 0 auto; + margin-top: 2px; + accent-color: var(--color-primary); +} +.documents-choice-option span { + min-width: 0; + overflow-wrap: anywhere; +} +.documents-field-description { + margin: -4px 13px 12px; + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.45; +} +.documents-catalog-field { + display: grid; + gap: 10px; + min-width: 0; +} +/* Выбранные разделы — токены-пилюли; «+» открывает выпадашку с поиском */ +.documents-catalog-tokens { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.documents-catalog-tokens:not(:empty) { + margin-bottom: 10px; +} +.documents-catalog-token { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 100%; + padding: 5px 6px 5px 11px; + border: 1px solid var(--border-default); + border-radius: var(--radius-full); + background: var(--background-card); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); + font-size: 12.5px; + font-weight: 500; + line-height: 1.2; + color: var(--text-primary); +} +.documents-catalog-token-icon { + flex: none; + font-size: 15px; + color: var(--color-primary); +} +.documents-catalog-token-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-catalog-token-remove { + display: grid; + place-items: center; + flex: none; + width: 19px; + height: 19px; + padding: 0; + border: none; + border-radius: var(--radius-full); + background: transparent; + color: var(--text-muted); + font-size: 14px; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} +.documents-catalog-token-remove:hover { + background: var(--red-100); + color: var(--red-600); +} +.documents-catalog-empty { + margin: 0; + font-size: 12px; + color: var(--text-secondary); +} +.documents-catalog-dropdown { + display: block; + position: relative; +} +/* В потоке (не absolute), чтобы не обрезалось overflow:hidden карточки поля. */ +.documents-catalog-menu { + position: static; + width: 100%; + max-height: 360px; + margin-top: 4px; + overflow-y: auto; + padding: 6px; +} +.documents-catalog-search { + width: 100%; + margin-bottom: 6px; +} +.documents-catalog-tree, +.documents-catalog-tree ol { + margin: 0; + padding: 0; + list-style: none; +} +.documents-catalog-tree ol { + margin-left: 20px; + border-left: 1px solid var(--border-default); +} +.documents-catalog-tree li.is-filtered { + display: none; +} +.documents-catalog-option { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 36px; + padding: 6px 8px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.documents-catalog-option.is-picked { + display: none; +} +.documents-catalog-option:hover { + background: var(--background-muted); +} +.documents-catalog-option-main { + display: grid; + min-width: 0; + margin-right: auto; +} +.documents-catalog-option-main b, +.documents-catalog-option-main small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-catalog-option-main b { + font-size: 13px; +} +.documents-catalog-option-main small { + font-size: 11px; + color: var(--text-secondary); +} +/* Заголовок поля со счётчиком символов справа */ +.field-label.between-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.field-count { + font-size: 11px; + font-weight: 600; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} +.field-count.is-over { + color: var(--red-600); +} +/* Alias-группа (input-group ките): «/» слева, кнопка генерации справа */ +.documents-alias-group .input { + flex: 1 1 auto; + min-width: 0; +} +.documents-alias-gen { + cursor: pointer; + gap: 0; + color: var(--text-secondary); + transition: background 0.12s ease, color 0.12s ease; +} +.documents-alias-gen:hover { + background: var(--background-inset); + color: var(--text-primary); +} +.documents-alias-gen i { + font-size: 15px; +} +/* SEO-сниппет (превью выдачи) */ +.documents-seo-snippet { + padding: 12px 14px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); +} +.documents-seo-url { + font-size: 12px; + color: var(--green-600); + word-break: break-all; +} +.documents-seo-title { + margin: 2px 0 3px; + font-size: 16px; + line-height: 1.3; + color: var(--text-link, var(--blue-600)); + font-weight: 600; +} +.documents-seo-desc { + font-size: 13px; + line-height: 1.45; + color: var(--text-secondary); +} +.documents-field-control .textarea { + min-height: 118px; +} +.documents-field-control .rich-editor { + min-width: 0; +} +.documents-field-control .rich-editor-content, +.documents-field-control .rich-editor-prose { + min-height: 360px; +} +.documents-field-control .rich-editor-simple .rich-editor-content, +.documents-field-control .rich-editor-simple .rich-editor-prose { + min-height: 260px; +} +.documents-field-control .rich-editor-slim .rich-editor-content, +.documents-field-control .rich-editor-slim .rich-editor-prose { + min-height: 180px; +} +.documents-relation-field { + display: grid; + justify-items: start; + gap: 10px; + min-width: 0; +} +.documents-relation-tokens { + display: grid; + width: 100%; + gap: 7px; +} +.documents-relation-token { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + width: 100%; + min-width: 0; + padding: 7px 7px 7px 11px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); +} +.documents-relation-token > span { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} +.documents-relation-token b { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; +} +.documents-relation-token small, +.documents-relation-empty { + color: var(--text-tertiary); + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.documents-relation-empty { + margin: 0; +} +.documents-relation-item.is-selected { + background: var(--color-primary-soft); + box-shadow: inset 3px 0 0 var(--color-primary); +} +/* Одиночная связь «Документ из рубрики»: выпадашка с заголовком выбранного документа */ +.documents-relation-single { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; +} +.documents-relation-clear[hidden] { + display: none; +} +.documents-relation-choice { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-width: 0; + padding: 8px 12px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; +} +.documents-relation-choice:hover { + border-color: var(--border-strong); +} +.documents-relation-choice:focus-visible { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-soft); +} +.documents-relation-choice-ic { + flex: 0 0 auto; + font-size: 18px; + color: var(--color-primary); +} +.documents-relation-single.is-empty .documents-relation-choice-ic { + color: var(--text-tertiary); +} +.documents-relation-choice-text { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + flex: 1; +} +.documents-relation-choice-text b { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 600; +} +.documents-relation-single.is-empty .documents-relation-choice-text b { + color: var(--text-secondary); + font-weight: 500; +} +.documents-relation-choice-text small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-tertiary); + font-size: 11.5px; + font-variant-numeric: tabular-nums; +} +.documents-relation-choice-caret { + flex: 0 0 auto; + margin-left: auto; + font-size: 16px; + color: var(--text-tertiary); +} +.documents-media-field { + display: grid; + gap: 8px; +} +/* Медиа-поле = карточка: сверху квадратное превью, ниже подпись пути и поля */ +.documents-media-card { + display: grid; + gap: 10px; + align-content: start; + padding: 10px; + border-radius: var(--radius-lg); + background: var(--background-card); + box-shadow: inset 0 0 0 1px var(--border-default); +} +.documents-single-media-field.documents-media-card { + grid-template-columns: 132px minmax(0, 1fr); + max-width: none; + align-items: start; +} +.documents-single-media-field .documents-media-thumb { + width: 132px; +} +.documents-media-card-body { + display: grid; + gap: 8px; + min-width: 0; +} +.documents-media-card-actions { + display: flex; + gap: 6px; +} +.documents-media-preview, +.documents-media-thumb { + display: grid; + place-items: center; + width: 100%; + aspect-ratio: 1; + padding: 0; + border: 0; + border-radius: var(--radius-md); + background: linear-gradient(45deg, rgba(15, 23, 42, 0.04) 25%, transparent 25%), linear-gradient(-45deg, rgba(15, 23, 42, 0.04) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, rgba(15, 23, 42, 0.04) 75%), linear-gradient(-45deg, transparent 75%, rgba(15, 23, 42, 0.04) 75%), var(--background-inset); + background-position: 0 0, 0 8px, 8px -8px, -8px 0; + background-size: 16px 16px; + color: var(--text-tertiary); + cursor: pointer; + overflow: hidden; + box-shadow: inset 0 0 0 1px var(--border-default); + transition: box-shadow 0.16s cubic-bezier(0.2, 0, 0, 1), transform 0.16s cubic-bezier(0.2, 0, 0, 1); +} +.documents-media-preview:hover, +.documents-media-thumb:hover { + box-shadow: inset 0 0 0 1px var(--blue-300); +} +.documents-media-preview:active, +.documents-media-thumb:active { + transform: scale(0.98); +} +.documents-media-preview img, +.documents-media-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} +.documents-media-preview span, +.documents-media-thumb span { + display: grid; + place-items: center; + width: 100%; + height: 100%; + font-size: 30px; +} +/* Подпись «куда сохранится» */ +.documents-media-path { + min-width: 0; + font-size: 12px; + color: var(--text-tertiary); +} +.documents-media-path code { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); +} +.documents-media-path-empty { + color: var(--text-tertiary); +} +.documents-youtube-field, +.documents-media-list { + display: grid; + gap: 12px; + min-width: 0; +} +.documents-media-list-items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 12px; + align-items: start; + min-width: 0; +} +.documents-inline-grid { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 10px; +} +.documents-numeric-parts { + grid-template-columns: repeat(var(--parts-count, 2), minmax(120px, 1fr)); +} +.documents-media-list-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.documents-media-list-actions input[type="file"], +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.documents-media-dropzone { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 54px; + padding: 12px; + border: 1px dashed var(--border-default); + border-radius: var(--radius-lg); + background: var(--background-card); + color: var(--text-secondary); + font-size: 13px; + transition: border-color 0.16s cubic-bezier(0.2, 0, 0, 1), background 0.16s cubic-bezier(0.2, 0, 0, 1), color 0.16s cubic-bezier(0.2, 0, 0, 1); +} +.documents-media-dropzone i { + font-size: 18px; + color: var(--blue-600); +} +.documents-media-dropzone code { + padding: 1px 6px; + border-radius: var(--radius-sm); + background: var(--background-inset); + font-family: var(--font-mono, ui-monospace, "SFMono-Regular", monospace); + font-size: 12px; +} +.documents-media-dropzone.is-dragover { + border-color: var(--blue-400); + background: var(--blue-50); + color: var(--blue-700); +} +.documents-media-row-fields { + display: grid; + gap: 8px; + min-width: 0; +} +.documents-media-card-actions .btn-icon { + min-width: 32px; + width: 32px; + height: 32px; +} +.documents-single-media-field .documents-media-card-actions .btn { + width: auto; + min-width: 0; + padding-inline: 10px; +} +.documents-media-card-actions .documents-media-remove { + margin-left: auto; +} +.documents-media-row-fields .textarea { + min-height: 64px; +} +.documents-media-empty { + padding: 14px; + border-radius: var(--radius-lg); + background: var(--bg-soft); + color: var(--text-secondary); + font-size: 13px; + text-align: center; +} +.documents-value-list, +.documents-value-list-items { + display: grid; + gap: 10px; + min-width: 0; +} +.documents-value-list-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.documents-value-row { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 34px; + gap: 10px; + align-items: start; + min-width: 0; + padding: 12px; + border-radius: var(--radius-lg); + background: var(--bg-soft); + box-shadow: inset 0 0 0 1px var(--border-default); +} +.documents-value-row-tools, +.documents-value-row-fields { + display: grid; + gap: 8px; + min-width: 0; +} +.documents-value-row-fields { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.documents-value-row-list_single .documents-value-row-fields { + grid-template-columns: minmax(0, 1fr); +} +.documents-value-row-list_triple .documents-value-row-fields { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.documents-value-row-packages .documents-value-row-fields { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} +@media (max-width: 1100px) { + .documents-value-row-packages .documents-value-row-fields { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 640px) { + .documents-value-row-packages .documents-value-row-fields { + grid-template-columns: minmax(0, 1fr); + } +} +.documents-value-row-tools .btn, +.documents-value-remove { + min-width: 34px; + width: 34px; + height: 34px; +} +.documents-value-empty { + padding: 14px; + border-radius: var(--radius-lg); + background: var(--bg-soft); + color: var(--text-secondary); + font-size: 13px; + text-align: center; +} +.documents-picker-input { + display: grid; + grid-template-columns: minmax(0, 1fr) 38px; + gap: 8px; + align-items: center; +} +.documents-picker-input .btn { + min-width: 38px; +} +.documents-picker-input-link { + grid-template-columns: minmax(0, 1fr) 38px 38px; +} +/* Ручка перетаскивания строк (медиа/значения) */ +.documents-drag-handle { + cursor: grab; +} +.documents-drag-handle:active { + cursor: grabbing; +} +.documents-row-dragging { + opacity: 0.5; +} +[data-document-alias-state].is-ok { + color: var(--green-600); +} +[data-document-alias-state].is-error { + color: var(--red-600); +} +.documents-picker-overlay.show { + display: flex; +} +.documents-media-picker, +.documents-relation-picker { + width: min(66.666vw, 1200px); + height: min(66.666vh, 800px); + max-height: calc(100vh - 40px); +} +.documents-picker-overlay .modal-body { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.documents-picker-tools { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} +.documents-picker-tools .input-wrap, +.documents-relation-search { + flex: 1 1 auto; + min-width: 0; +} +.documents-picker-crumbs { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 12px; + min-width: 0; + overflow-x: auto; +} +.documents-picker-crumb, +.documents-picker-sep { + flex: 0 0 auto; +} +.documents-picker-crumb { + border: 0; + background: transparent; + color: var(--blue-600); + font-size: 12px; + cursor: pointer; + text-decoration: none; +} +.documents-picker-status { + padding: 18px; + color: var(--text-secondary); + text-align: center; +} +.documents-media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); + gap: 10px; + flex: 1 1 auto; + align-content: start; + min-height: 0; + max-height: none; + overflow: auto; +} +.documents-media-item { + display: grid; + gap: 7px; + min-width: 0; + padding: 8px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); + color: var(--text-primary); + text-align: left; + cursor: pointer; +} +.documents-media-item span:first-child { + display: grid; + place-items: center; + aspect-ratio: 1; + overflow: hidden; + border-radius: var(--radius-sm); + background: var(--bg-soft); + color: var(--text-secondary); +} +.documents-media-item img { + width: 100%; + height: 100%; + object-fit: cover; + outline: 1px solid rgba(0, 0, 0, 0.1); +} +.documents-media-item b, +.documents-media-item small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-media-item b { + font-size: 12px; + line-height: 1.25; +} +.documents-media-item small { + font-size: 11px; + color: var(--text-secondary); +} +.documents-relation-list { + display: grid; + gap: 8px; + flex: 1 1 auto; + align-content: start; + min-height: 0; + max-height: none; + overflow: auto; +} +.documents-relation-item { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) minmax(120px, 0.35fr); + gap: 12px; + align-items: center; + padding: 10px 12px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); + color: var(--text-primary); + text-align: left; + cursor: pointer; +} +.documents-relation-id { + font-family: var(--font-mono); + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} +.documents-relation-item b, +.documents-relation-item small, +.documents-relation-item em { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-relation-item small, +.documents-relation-item em { + font-size: 12px; + color: var(--text-secondary); + font-style: normal; +} +.documents-facts { + display: grid; + gap: 10px; + margin: 0; +} +.documents-snapshot-facts dd { + min-width: 0; + overflow-wrap: anywhere; +} +.documents-facts div { + display: grid; + grid-template-columns: 94px minmax(0, 1fr); + gap: 10px; +} +.documents-facts dt { + color: var(--text-secondary); + font-size: 12px; +} +.documents-facts dd { + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + color: var(--text-primary); +} +.documents-sticky-actions { + justify-content: space-between; +} +.documents-save-state { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; +} +.documents-save-state-icon { + display: grid; + place-items: center; + width: 28px; + height: 28px; + flex: 0 0 auto; + border-radius: var(--radius-full); + background: var(--green-100); + color: var(--green-600); +} +.documents-save-state[data-state="dirty"] .documents-save-state-icon { + background: var(--amber-100); + color: var(--amber-600); +} +.documents-save-state[data-state="saving"] .documents-save-state-icon { + background: var(--blue-100); + color: var(--blue-600); +} +.documents-save-state[data-state="draft"] .documents-save-state-icon { + background: var(--amber-100); + color: var(--amber-700); +} +.documents-save-state[data-state="saving"] .documents-save-state-icon i { + animation: documents-spin 0.8s linear infinite; +} +@keyframes documents-spin { + to { + transform: rotate(360deg); + } +} +.documents-label-mobile { + display: none; +} +.documents-side-note { + margin: 10px 0 0; + line-height: 1.4; +} +.documents-revisions-panel { + width: min(1120px, calc(100vw - 36px)); +} +.documents-payload-panel { + width: min(1120px, 66.666vw); + min-width: min(720px, calc(100vw - 24px)); +} +.documents-payload-body { + display: flex; + flex-direction: column; + gap: 14px; + min-height: 0; +} +.documents-payload-body .alert > div { + display: grid; + gap: 3px; +} +.documents-payload-code { + flex: 1 1 auto; + min-height: 420px; + max-height: calc(100vh - 270px); + margin: 0; + padding: 16px; + overflow: auto; + border-radius: var(--radius-md); + background: #111827; + color: #e5e7eb; + font: 13px/1.55 var(--font-mono, monospace); + tab-size: 2; + white-space: pre; +} +@media (max-width: 760px) { + .documents-payload-panel { + width: calc(100vw - 12px); + min-width: 0; + } + .documents-payload-code { + min-height: 320px; + max-height: calc(100vh - 300px); + } +} +.documents-drawer-title h3 { + margin: 0 0 4px; + font-size: 18px; + line-height: 1.25; + font-weight: 800; +} +.documents-drawer-title p { + margin: 0; +} +.documents-revisions-layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + min-width: 0; +} +.documents-revisions-list, +.documents-revision-preview { + min-width: 0; + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--background-card); + overflow: hidden; +} +.documents-revisions-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-default); +} +.documents-revisions-head h4 { + margin: 0 0 3px; + font-size: 15px; + line-height: 1.25; + font-weight: 800; +} +.documents-revisions-head p { + margin: 0; + font-size: 12px; + line-height: 1.4; +} +.documents-revisions-list-rows { + display: grid; + gap: 8px; + max-height: min(320px, 38vh); + overflow: auto; + padding: 12px; +} +.documents-revision-fields { + display: grid; + gap: 8px; + padding: 12px; +} +.documents-revision-system, +.documents-revision-content { + display: grid; + gap: 8px; +} +.documents-revision-content { + margin-top: 8px; +} +.documents-revision-subhead { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + padding: 7px 2px; + color: var(--text-secondary); + font-size: 12px; +} +.documents-revision-subhead b { + color: var(--text-primary); + font-size: 13px; +} +.documents-revision-system-field { + background: var(--background-subtle); +} +.documents-revision-row { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 34px; + gap: 10px; + align-items: center; + padding: 10px; + border: 1px solid transparent; + border-radius: var(--radius-md); + cursor: pointer; +} +.documents-revision-row.is-active { + border-color: var(--blue-200); + background: var(--blue-50); +} +.documents-revision-icon { + width: 34px; + height: 34px; + border-radius: var(--radius-md); + font-size: 16px; + --tile-bg: var(--violet-100); + --tile-fg: var(--violet-600); +} +.documents-revision-row-main { + min-width: 0; +} +.documents-revision-row-main b, +.documents-revision-row-main div { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-revision-row-main .badge { + margin-left: 6px; +} +.documents-revision-field { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); +} +.documents-revision-field div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.documents-revision-field b { + font-size: 13px; +} +.documents-revision-field span { + color: var(--text-secondary); + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.documents-revision-field-name { + display: grid; + flex: 1 1 auto; + gap: 2px; + min-width: 0; +} +.documents-revision-check, +.documents-revision-group-check { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 40px; + min-height: 40px; + color: var(--text-secondary); + cursor: pointer; +} +.documents-revision-check input, +.documents-revision-group-check input { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--color-primary); +} +.documents-revision-group-check { + gap: 6px; + min-width: auto; + padding: 0 4px; + font-size: 12px; + font-weight: 600; +} +.documents-revision-group-check span { + color: inherit; +} +.documents-revision-field-name b, +.documents-revision-field-name small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-revision-field-name small { + color: var(--text-tertiary); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; +} +.documents-revision-field pre { + margin: 0; + max-height: 220px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.45; + color: var(--text-primary); +} +.documents-aliases-panel { + width: min(760px, 66.666vw); +} +.documents-remarks-panel { + width: min(760px, 66.666vw); +} +.documents-remarks-drawer, +.documents-remark-list { + display: grid; + align-content: start; + gap: 12px; + overflow-x: hidden; +} +.documents-remark-form { + overflow: hidden; +} +.documents-remark { + padding: 14px; +} +.documents-remark-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.documents-remark-head b, +.documents-remark-head small { + display: block; +} +.documents-remark-head small { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12px; +} +.documents-remark p { + margin: 10px 0 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.5; +} +.documents-aliases-drawer { + display: grid; + align-content: start; + gap: 16px; + overflow-x: hidden; +} +.documents-alias-form { + overflow: hidden; +} +/* Заголовки форм в дроверах инструментов */ +.documents-drawer-form .documents-section-head { + padding: 12px 16px; +} +.documents-drawer-form .documents-section-head h4 { + margin: 0; + font-size: 14px; + font-weight: 700; + color: var(--text-primary); +} +.documents-drawer-form .documents-head-icon { + width: 32px; + height: 32px; + display: grid; + place-items: center; + font-size: 16px; +} +.documents-alias-form-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 0 16px 16px; +} +.documents-alias-list { + display: grid; + gap: 8px; +} +.documents-alias-row { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto auto; + align-items: center; + gap: 12px; +} +.documents-alias-icon { + width: 34px; + height: 34px; + --tile-bg: var(--cyan-100); + --tile-fg: var(--cyan-600); +} +.documents-alias-main { + min-width: 0; +} +.documents-alias-main b, +.documents-alias-main small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-alias-main small { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12px; +} +.documents-alias-actions { + flex-wrap: nowrap; +} +@media (max-width: 860px) { + .documents-media-picker, + .documents-relation-picker { + width: 100%; + height: 94vh; + max-height: 94vh; + } + .documents-filter .input-wrap, + .documents-filter .select, + .documents-filter .btn { + flex: 1 1 100%; + max-width: none; + width: 100%; + } + .documents-pagination { + align-items: flex-start; + flex-direction: column; + } + .documents-edit-layout { + display: flex; + flex-direction: column; + } + .documents-edit-main, + .documents-edit-side { + display: contents; + } + .documents-section-main { + order: 1; + } + .documents-section-publish { + order: 2; + } + .documents-section-dates { + order: 3; + } + .documents-section-seo { + order: 4; + } + .documents-views-grid { + grid-template-columns: 1fr; + } + .documents-edit-side { + position: static; + } + .documents-revisions-layout { + grid-template-columns: 1fr; + } + .documents-aliases-panel { + width: 100%; + } + .documents-remarks-panel { + width: 100%; + } + .documents-alias-row { + grid-template-columns: 36px minmax(0, 1fr) auto; + } + .documents-alias-actions { + grid-column: 2 / -1; + justify-content: flex-end; + } + .documents-media-field, + .documents-inline-grid, + .documents-field-head, + .documents-relation-item { + grid-template-columns: 1fr; + } + .ax-document-field.is-half, + .ax-document-field.is-third, + .ax-document-field.is-two-thirds { + grid-column: span 12; + } +} +@media (max-width: 700px) { + .documents-single-media-field.documents-media-card { + grid-template-columns: 1fr; + } + .documents-single-media-field .documents-media-thumb { + width: min(180px, 100%); + } + .documents-edit-header .between { + align-items: flex-start; + } + .documents-edit-header .cluster { + flex: 0 0 auto; + } + .documents-edit-header .btn { + width: 40px; + min-width: 40px; + height: 40px; + padding: 0; + font-size: 0; + } + .documents-edit-header .btn i { + margin: 0; + font-size: 17px; + } + .documents-sticky-actions { + bottom: 6px; + align-items: stretch; + flex-direction: column; + gap: 8px; + padding: 8px; + } + .documents-sticky-actions .cluster { + display: grid; + grid-template-columns: 40px minmax(0, 1fr) minmax(0, 1fr); + width: 100%; + gap: 7px; + } + .documents-sticky-actions .btn { + width: 100%; + min-width: 0; + padding-inline: 9px; + } + .documents-json-button span, + .documents-label-desktop { + display: none; + } + .documents-label-mobile { + display: inline; + } + .documents-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + .documents-stat { + gap: 10px; + min-width: 0; + padding: 13px; + } + .documents-stat .icon-tile { + width: 34px; + height: 34px; + } + .documents-stat b { + font-size: 18px; + } + .documents-section-head { + padding: 14px 12px; + } + .documents-panel .table-scroll { + overflow: visible; + } + .documents-table, + .documents-table tbody, + .documents-table tr, + .documents-table td { + display: block; + width: 100%; + } + .documents-panel table.documents-table { + min-width: 0; + max-width: 100%; + } + .documents-table colgroup, + .documents-table thead { + display: none; + } + .documents-table tbody { + background: var(--background-surface); + } + .documents-table .documents-row { + position: relative; + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + grid-template-areas: "check title id" "check rubric state" "date date date" "actions actions actions"; + gap: 9px 10px; + padding: 13px 12px 11px; + border-bottom: 1px solid var(--border-default); + } + .documents-table .documents-row:last-child { + border-bottom: 0; + } + .documents-table .documents-row:nth-child(even) { + background: color-mix(in srgb, var(--background-muted) 55%, var(--background-surface)); + } + .documents-table .documents-row.is-draft { + box-shadow: inset 3px 0 0 var(--amber-500); + } + .documents-table .documents-row.is-deleted { + background: color-mix(in srgb, var(--red-50) 58%, var(--background-surface)); + box-shadow: inset 3px 0 0 var(--red-500); + } + .documents-table .documents-row td { + min-width: 0; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + } + .documents-cell-check { + grid-area: check; + padding-top: 2px !important; + } + .documents-cell-id { + grid-area: id; + justify-self: end; + font-size: 12px; + } + .documents-cell-title { + grid-area: title; + } + .documents-cell-rubric { + grid-area: rubric; + } + .documents-cell-state { + grid-area: state; + justify-self: end; + } + .documents-cell-date { + grid-area: date; + } + .documents-cell-actions { + grid-area: actions; + } + .documents-name b, + .documents-meta span { + white-space: normal; + } + .documents-name b { + font-size: 13.5px; + line-height: 1.35; + } + .documents-name small, + .documents-cell-rubric .documents-meta span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .documents-cell-rubric .documents-meta { + display: flex; + gap: 5px; + } + .documents-cell-date .documents-meta { + display: flex; + flex-wrap: wrap; + gap: 5px 14px; + padding-top: 8px; + border-top: 1px solid var(--border-subtle); + } + .documents-cell-date .documents-meta span { + display: flex; + gap: 5px; + font-size: 11.5px; + } + .documents-mobile-label { + display: inline; + color: var(--text-tertiary); + } + .documents-cell-actions { + padding-top: 1px !important; + } + .documents-actions { + justify-content: flex-end; + gap: 4px; + } + .documents-actions .btn { + width: 36px; + height: 36px; + min-width: 36px; + } + .documents-pagination { + padding: 13px 12px; + } +} +.documents-create-drawer { + width: min(66.666vw, 980px); + max-width: none; +} +.documents-create-body { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + background: var(--background-subtle); +} +.documents-create-search { + width: 100%; + margin-bottom: 14px; +} +.documents-create-layout { + display: grid; + grid-template-columns: minmax(250px, 32%) minmax(0, 1fr); + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); +} +.documents-create-list { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; + padding: 10px; + overflow-y: auto; + border-right: 1px solid var(--border-default); + background: var(--background-muted); +} +.documents-create-item { + display: grid; + grid-template-columns: 40px minmax(0, 1fr) 20px; + align-items: center; + gap: 10px; + width: 100%; + min-height: 62px; + padding: 9px 10px; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-primary); + font: inherit; + text-decoration: none; + text-align: left; + cursor: pointer; + transition-property: border-color, box-shadow, transform; + transition-duration: 140ms; +} +.documents-create-item:hover { + border-color: var(--blue-200); + background: var(--background-card); + box-shadow: var(--shadow-xs); +} +.documents-create-item[hidden] { + display: none; +} +.documents-create-item:active { + transform: scale(0.96); +} +.documents-create-item.is-current { + border-color: var(--blue-300); + background: var(--blue-50); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--blue-300) 25%, transparent); +} +.documents-create-name { + min-width: 0; +} +.documents-create-name b, +.documents-create-name small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-create-name small { + margin-top: 4px; + color: var(--text-secondary); +} +.documents-create-arrow { + color: var(--text-tertiary); +} +.documents-create-empty { + margin-top: 12px; +} +.documents-create-details { + min-width: 0; + overflow-y: auto; +} +.documents-create-detail { + min-height: 100%; + padding: 20px; +} +.documents-create-detail-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 18px; +} +.documents-create-detail-head h4 { + margin: 0; + font-size: 17px; +} +.documents-create-detail-head p { + margin: 4px 0 0; + color: var(--text-secondary); + font-size: 12px; +} +.documents-create-blank { + display: grid; + grid-template-columns: 42px minmax(0, 1fr) 20px; + align-items: center; + gap: 12px; + padding: 13px 14px; + border: 1px solid var(--green-200); + border-radius: var(--radius-md); + background: var(--green-50); + color: var(--text-primary); + text-decoration: none; +} +.documents-create-blank:hover { + border-color: var(--green-400); + box-shadow: var(--shadow-sm); + text-decoration: none; +} +.documents-create-blank b, +.documents-create-blank small { + display: block; +} +.documents-create-blank small { + margin-top: 3px; + color: var(--text-secondary); +} +.documents-create-blank > .ti { + color: var(--green-600); +} +.documents-create-presets-head { + display: flex; + align-items: center; + justify-content: space-between; + margin: 22px 0 9px; +} +.documents-create-presets-head h5 { + margin: 0; + font-size: 13px; +} +.documents-create-presets { + display: grid; + gap: 7px; +} +.documents-create-preset { + display: grid; + grid-template-columns: minmax(0, 1fr) 34px; + align-items: center; + gap: 5px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); +} +.documents-create-preset:hover { + border-color: var(--cyan-300); +} +.documents-create-preset > a { + display: grid; + grid-template-columns: 38px minmax(0, 1fr); + align-items: center; + gap: 11px; + min-width: 0; + padding: 10px 5px 10px 11px; + color: var(--text-primary); + text-decoration: none; +} +.documents-create-preset > a:hover { + text-decoration: none; +} +.documents-create-preset b, +.documents-create-preset small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.documents-create-preset small { + margin-top: 3px; + color: var(--text-secondary); +} +.documents-create-preset > .btn { + margin-right: 5px; +} +.documents-create-presets-empty { + display: flex; + align-items: center; + gap: 10px; + min-height: 70px; + padding: 13px; + border: 1px dashed var(--border-default); + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; +} +.documents-create-presets-empty > .ti { + flex: 0 0 auto; + color: var(--text-tertiary); + font-size: 22px; +} +.documents-preset-notice { + display: grid; + grid-template-columns: 40px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 11px 14px; + border: 1px solid var(--cyan-200); + border-radius: var(--radius-md); + background: var(--cyan-50); +} +.documents-preset-notice b, +.documents-preset-notice span { + display: block; +} +.documents-preset-notice span { + margin-top: 2px; + color: var(--text-secondary); + font-size: 12px; +} +.documents-preset-panel > form { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} +.documents-preset-body { + display: flex; + flex-direction: column; + gap: 18px; + overflow-y: auto; + background: var(--background-subtle); +} +.documents-preset-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px; +} +.documents-preset-options .switch { + min-height: 48px; + padding: 10px 12px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); +} +.documents-preset-options .switch b { + min-width: 0; + font-size: 12.5px; +} +.documents-preset-warning { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0; + padding: 10px 12px; + border-radius: var(--radius-md); + background: var(--amber-50); + color: var(--amber-800); + font-size: 12px; + line-height: 1.45; +} +.documents-preset-warning .ti { + margin-top: 1px; +} +@media (max-width: 760px) { + .documents-create-drawer { + width: 100%; + max-width: 100%; + } + .documents-create-body { + overflow-y: auto; + } + .documents-create-layout { + display: block; + flex: 0 0 auto; + overflow: visible; + } + .documents-create-list { + max-height: 250px; + overflow-y: auto; + border-right: 0; + border-bottom: 1px solid var(--border-default); + } + .documents-create-details { + overflow: visible; + } + .documents-create-detail { + min-height: 0; + padding: 15px; + } + .documents-preset-notice { + grid-template-columns: 36px minmax(0, 1fr); + } + .documents-preset-notice > .btn { + grid-column: 2; + justify-self: start; + } + .documents-preset-options { + grid-template-columns: 1fr; + } +} diff --git a/adminx/modules/Documents/assets/documents.js b/adminx/modules/Documents/assets/documents.js new file mode 100644 index 0000000..15647b5 --- /dev/null +++ b/adminx/modules/Documents/assets/documents.js @@ -0,0 +1,3398 @@ +/** + * JS раздела «Документы»: AJAX-фильтры, быстрые действия списка и сохранение страницы редактирования. + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.Documents = { + form: null, + filterTimer: null, + filterAbort: null, + filterRequest: 0, + aliasTimer: null, + shortAliasTimer: null, + dirty: false, + submitting: false, + aliasState: 'empty', + currentRevisionId: 0, + currentRevisionDocumentId: 0, + currentAliasDocumentId: 0, + currentRemarkDocumentId: 0, + draftTimer: null, + draftCandidate: null, + editorMode: 'normal', + errorTargets: [], + conditionsInitialized: false, + + init: function () { + this.form = document.getElementById('documentForm'); + var self = this; + + document.addEventListener('click', function (e) { + if (e.target.closest('[data-documents-filter-reset]')) { self.resetFilters(); } + + var page = e.target.closest('[data-documents-page]'); + if (page) { + e.preventDefault(); + self.applyFilterUrl(page.href, true); + } + + var del = e.target.closest('[data-document-delete]'); + if (del) { self.remove(del.closest('[data-document-row]')); } + + var restore = e.target.closest('[data-document-restore]'); + if (restore) { self.restore(restore.closest('[data-document-row]')); } + + var toggle = e.target.closest('[data-document-toggle]'); + if (toggle) { self.action(toggle.closest('[data-document-row]'), '/toggle'); } + + var copy = e.target.closest('[data-document-copy]'); + if (copy) { self.copy(copy.closest('[data-document-row]')); } + + var purge = e.target.closest('[data-document-purge]'); + if (purge) { self.purge(purge.closest('[data-document-row]')); } + + if (e.target.closest('[data-document-views-clear]')) { self.clearViews(); } + if (e.target.closest('[data-documents-bulk-apply]')) { self.applyBulk(); } + if (e.target.closest('[data-documents-bulk-clear]')) { self.clearBulk(); } + if (e.target.closest('[data-documents-snapshots-rebuild]')) { self.rebuildSnapshots(); } + + var createItem = e.target.closest('[data-document-create-item]'); + if (createItem) { self.selectCreateRubric(createItem.getAttribute('data-document-create-item')); } + var presetDelete = e.target.closest('[data-document-preset-delete]'); + if (presetDelete) { e.preventDefault(); self.deleteCreationPreset(presetDelete); } + + var fieldTab = e.target.closest('[data-document-field-tab]'); + if (fieldTab) { self.activateFieldGroup(fieldTab.getAttribute('data-document-field-tab')); } + + var editorMode = e.target.closest('[data-document-editor-mode]'); + if (editorMode) { self.setEditorMode(editorMode.getAttribute('data-document-editor-mode'), true); } + if (e.target.closest('[data-document-draft-restore]')) { self.restoreLocalDraft(); } + if (e.target.closest('[data-document-draft-discard]')) { self.discardLocalDraft(); } + var errorJump = e.target.closest('[data-document-error-jump]'); + if (errorJump) { self.jumpToError(errorJump.getAttribute('data-document-error-jump')); } + + var mediaPick = e.target.closest('[data-document-media-pick]'); + if (mediaPick) { + self.openMediaPicker(mediaPick.closest('.documents-picker-input') || mediaPick.closest('[data-document-media-row]') || mediaPick.closest('.documents-single-media-field') || mediaPick.closest('.documents-field-control')); + } + + var mediaAdd = e.target.closest('[data-document-media-add]'); + if (mediaAdd) { self.addMediaRow(mediaAdd.closest('[data-document-media-list]')); } + + var mediaRemove = e.target.closest('[data-document-media-remove]'); + if (mediaRemove) { self.removeMediaRow(mediaRemove.closest('[data-document-media-row]')); } + + var mediaUp = e.target.closest('[data-document-media-up]'); + if (mediaUp) { self.moveMediaRow(mediaUp.closest('[data-document-media-row]'), -1); } + + var mediaDown = e.target.closest('[data-document-media-down]'); + if (mediaDown) { self.moveMediaRow(mediaDown.closest('[data-document-media-row]'), 1); } + + var mediaClear = e.target.closest('[data-document-media-clear]'); + if (mediaClear) { self.clearMediaRows(mediaClear.closest('[data-document-media-list]')); } + + var mediaUpload = e.target.closest('[data-document-media-upload]'); + if (mediaUpload) { self.openMediaUpload(mediaUpload.closest('[data-document-media-list], [data-document-media-single]')); } + + var mediaImportFolder = e.target.closest('[data-document-media-import-folder]'); + if (mediaImportFolder) { self.importMediaFolder(mediaImportFolder.closest('[data-document-media-list]')); } + + var valueAdd = e.target.closest('[data-document-value-add]'); + if (valueAdd) { self.addValueRow(valueAdd.closest('[data-document-value-list]')); } + + var valueRemove = e.target.closest('[data-document-value-remove]'); + if (valueRemove) { self.removeValueRow(valueRemove.closest('[data-document-value-row]')); } + + var valueUp = e.target.closest('[data-document-value-up]'); + if (valueUp) { self.moveValueRow(valueUp.closest('[data-document-value-row]'), -1); } + + var valueDown = e.target.closest('[data-document-value-down]'); + if (valueDown) { self.moveValueRow(valueDown.closest('[data-document-value-row]'), 1); } + + var valueClear = e.target.closest('[data-document-value-clear]'); + if (valueClear) { self.clearValueRows(valueClear.closest('[data-document-value-list]')); } + + var relationPick = e.target.closest('[data-document-relation-pick]'); + if (relationPick) { self.openRelationPicker(relationPick.closest('.documents-field-control')); } + + var relationRemove = e.target.closest('[data-document-relation-remove]'); + if (relationRemove) { self.removePickedRelation(relationRemove.closest('[data-document-relation-token]')); } + + var relationClear = e.target.closest('[data-document-relation-clear]'); + if (relationClear) { + var clearSingle = relationClear.closest('[data-document-relation-single]'); + if (clearSingle) { + var clearInput = clearSingle.querySelector('[data-document-relation-id]'); + if (clearInput) { clearInput.value = ''; clearInput.dispatchEvent(new Event('input', { bubbles: true })); } + self.fillRelationSingle(clearSingle, '', ''); + } + } + + var parentPick = e.target.closest('[data-document-parent-pick]'); + if (parentPick) { self.openRelationPicker(parentPick.closest('.documents-picker-input')); } + + var docPick = e.target.closest('[data-document-media-doc-pick]'); + if (docPick) { + var wrap = docPick.closest('.documents-picker-input'); + var linkInput = wrap ? wrap.querySelector('[data-document-media-url]') : null; + if (linkInput) { self.openRelationPicker(null, { input: linkInput, insert: 'linkAlias' }); } + } + + var slugGen = e.target.closest('[data-slug-generate]'); + if (slugGen) { e.preventDefault(); self.generateSlug(slugGen); } + + var shortGen = e.target.closest('[data-short-generate]'); + if (shortGen) { e.preventDefault(); self.generateShortAlias(shortGen); } + + var catOption = e.target.closest('[data-document-catalog-option]'); + if (catOption) { e.preventDefault(); self.addCatalogToken(catOption); return; } + var catRemove = e.target.closest('[data-document-catalog-remove]'); + if (catRemove) { e.preventDefault(); self.removeCatalogToken(catRemove.closest('[data-document-catalog-token]')); return; } + + if (e.target.closest('[data-document-revisions]')) { self.openRevisions(); } + if (e.target.closest('[data-document-aliases]')) { self.openAliases(); } + if (e.target.closest('[data-document-remarks]')) { self.openRemarks(); } + if (e.target.closest('[data-document-snapshot]')) { self.openSnapshot(); } + if (e.target.closest('[data-document-snapshot-rebuild]')) { self.rebuildSnapshot(); } + if (e.target.closest('[data-document-payload-preview]')) { self.previewPayload(); } + if (e.target.closest('[data-document-payload-copy]')) { self.copyPayload(); } + var aliasEdit = e.target.closest('[data-document-alias-edit]'); + if (aliasEdit) { self.editAlias(aliasEdit); } + var aliasDelete = e.target.closest('[data-document-alias-delete]'); + if (aliasDelete) { self.deleteAlias(aliasDelete.getAttribute('data-document-alias-delete')); } + if (e.target.closest('[data-document-alias-reset]')) { self.resetAliasForm(); } + var remarkDelete = e.target.closest('[data-document-remark-delete]'); + if (remarkDelete) { self.deleteRemark(remarkDelete.getAttribute('data-document-remark-delete')); } + var revisionDelete = e.target.closest('[data-document-revision-delete]'); + if (revisionDelete) { self.deleteRevision(revisionDelete.getAttribute('data-document-revision-delete')); return; } + var revisionOpen = e.target.closest('[data-document-revision-open]'); + if (revisionOpen) { self.loadRevision(revisionOpen.getAttribute('data-document-revision-open')); } + if (e.target.closest('[data-document-revisions-clear]')) { self.clearRevisions(); } + if (e.target.closest('[data-document-revision-restore]')) { self.restoreRevision(); } + + var apiTokenCopy = e.target.closest('[data-api-token-copy]'); + if (apiTokenCopy) { e.preventDefault(); self.copyApiToken(); return; } + var apiTokenRevoke = e.target.closest('[data-api-token-revoke]'); + if (apiTokenRevoke) { e.preventDefault(); self.revokeApiToken(apiTokenRevoke.closest('[data-api-token-row]')); return; } + + if (e.target.closest('[data-document-submit-stay]')) { self.submit(true); } + var conditionUndo = e.target.closest('[data-condition-action-undo]'); + if (conditionUndo) { e.preventDefault(); self.undoDocumentFieldValueAction(conditionUndo.closest('.ax-document-field')); } + }); + + if (this.form) { + this.form.addEventListener('submit', function (e) { + e.preventDefault(); + self.submit(false); + }); + this.form.addEventListener('input', function (e) { + if (!e.target.matches('[data-document-term-query]')) { self.setDirty(true); } + self.clearDocumentFieldValueAction(e.target.closest('.ax-document-field'), true); + self.applyFieldConditions(); + }); + this.form.addEventListener('change', function (e) { self.setDirty(true); self.clearDocumentFieldValueAction(e.target.closest('.ax-document-field'), true); self.applyFieldConditions(); }); + var aliasInput = this.field('document_alias'); + this.aliasTouched = !!(aliasInput && aliasInput.value.trim()); + this.form.querySelectorAll('[data-document-boolean]').forEach(function (input) { self.updateBoolean(input); }); + this.updateSeo(); + this.updateAliasTemplateHint(); + this.initTermInputs(); + this.applyFieldConditions(); + this.initEditorMode(); + this.setDirty(false); + window.setTimeout(function () { self.checkLocalDraft(); }, 180); + window.addEventListener('beforeunload', function (e) { + if (!self.dirty || self.submitting) { return; } + e.preventDefault(); + e.returnValue = ''; + }); + } + + document.addEventListener('submit', function (e) { + var presetForm = e.target.closest('[data-document-preset-form]'); + if (presetForm) { + e.preventDefault(); + self.saveCreationPreset(presetForm); + return; + } + var aliasForm = e.target.closest('[data-document-alias-form]'); + if (aliasForm) { + e.preventDefault(); + self.submitAlias(aliasForm); + return; + } + var remarkForm = e.target.closest('[data-document-remark-form]'); + if (remarkForm) { + e.preventDefault(); + self.submitRemark(remarkForm); + return; + } + var apiTokenForm = e.target.closest('[data-api-token-form]'); + if (apiTokenForm) { + e.preventDefault(); + self.issueApiToken(apiTokenForm); + return; + } + var filter = e.target.closest('.documents-filter'); + if (!filter) { return; } + e.preventDefault(); + self.applyFilters(filter, true); + }); + + document.addEventListener('input', function (e) { + if (e.target.matches('[data-document-create-search]')) { + self.filterCreateRubrics(e.target.value); + return; + } + if (e.target.matches('[data-document-color-picker]')) { + var pickerControl = e.target.closest('[data-document-color-control]'); + var colorValue = pickerControl ? pickerControl.querySelector('[data-document-color-value]') : null; + if (colorValue) { colorValue.value = String(e.target.value || '').toLowerCase(); } + } + if (e.target.matches('[data-document-color-value]')) { + var valueControl = e.target.closest('[data-document-color-control]'); + var colorPicker = valueControl ? valueControl.querySelector('[data-document-color-picker]') : null; + var color = String(e.target.value || '').trim().toLowerCase(); + if (/^#?[0-9a-f]{3}$/.test(color)) { + color = color.replace(/^#/, ''); + color = '#' + color[0] + color[0] + color[1] + color[1] + color[2] + color[2]; + } else if (/^#?[0-9a-f]{6}$/.test(color)) { + color = '#' + color.replace(/^#/, ''); + } else { + color = ''; + } + if (colorPicker && color) { colorPicker.value = color; } + } + if (self.form && e.target === self.field('document_alias')) { + self.aliasTouched = true; + self.scheduleAliasCheck(); + self.updateSeo(); + return; + } + if (self.form && e.target === self.field('document_short_alias')) { + self.scheduleShortAliasCheck(); + return; + } + if (self.form && e.target.matches('[data-slug-source]')) { + self.syncSlugFromTitle(); + self.updateSeo(); + } + if (self.form && e.target === self.field('document_meta_description')) { + self.updateSeo(); + } + var filter = e.target.closest('.documents-filter'); + if (!filter || !e.target.matches('input[type="search"]')) { return; } + clearTimeout(self.filterTimer); + self.filterTimer = setTimeout(function () { self.applyFilters(filter, true); }, 350); + }); + + document.addEventListener('change', function (e) { + if (e.target.matches('[data-document-revision-group]')) { + self.toggleRevisionGroup(e.target.getAttribute('data-document-revision-group'), e.target.checked); + return; + } + if (e.target.matches('[data-document-revision-select]')) { + self.updateRevisionSelection(); + return; + } + if (e.target.matches('[data-document-preset-form] [name="include_fields"]')) { + self.updatePresetAssetsState(e.target.closest('[data-document-preset-form]')); + } + if (e.target.matches('[data-document-boolean]')) { + self.updateBoolean(e.target); + } + if (e.target.matches('[data-documents-check-all]')) { + document.querySelectorAll('[data-document-check]').forEach(function (box) { box.checked = e.target.checked; }); + self.updateBulk(); + return; + } + if (e.target.matches('[data-document-check]')) { self.updateBulk(); return; } + if (e.target.matches('[data-document-view-days]')) { + self.loadViews(e.target.value, true); + return; + } + if (e.target.matches('[data-document-rubric-select]')) { + self.changeCreateRubric(e.target.value); + return; + } + if (self.form && e.target === self.field('document_published') && !self.aliasTouched) { + self.syncSlugFromTitle(); + } + var filter = e.target.closest('.documents-filter'); + if (!filter || !e.target.matches('select')) { return; } + self.applyFilters(filter, true); + }); + + document.addEventListener('change', function (e) { + if (!e.target.matches('[data-document-media-files]')) { return; } + self.uploadMediaFiles(e.target.closest('[data-document-media-list], [data-document-media-single]'), e.target.files); + e.target.value = ''; + }); + + document.addEventListener('dragover', function (e) { + var drop = e.target.closest('[data-document-media-drop]'); + if (!drop) { return; } + e.preventDefault(); + drop.classList.add('is-dragover'); + }); + + document.addEventListener('dragleave', function (e) { + var drop = e.target.closest('[data-document-media-drop]'); + if (drop) { drop.classList.remove('is-dragover'); } + }); + + document.addEventListener('drop', function (e) { + var drop = e.target.closest('[data-document-media-drop]'); + if (!drop) { return; } + e.preventDefault(); + drop.classList.remove('is-dragover'); + self.uploadMediaFiles(drop.closest('[data-document-media-list], [data-document-media-single]'), e.dataTransfer ? e.dataTransfer.files : null); + }); + + // drag-and-drop сортировка строк медиа/значений (по ручке-грипу) + document.addEventListener('dragstart', function (e) { self.rowDragStart(e); }); + document.addEventListener('dragover', function (e) { self.rowDragOver(e); }); + document.addEventListener('drop', function (e) { if (self.dragRow) { e.preventDefault(); } }); + document.addEventListener('dragend', function () { self.rowDragEnd(); }); + + document.addEventListener('blur', function (e) { + if (!self.form) { return; } + if (e.target === self.field('document_alias')) { self.checkAlias(false); } + if (e.target === self.field('document_short_alias')) { self.checkShortAlias(false); } + }, true); + + window.addEventListener('popstate', function () { + if (document.querySelector('.documents-views-panel')) { + var params = new URLSearchParams(window.location.search); + self.loadViews(params.get('days') || 30, false); + return; + } + self.applyFilterUrl(window.location.href, false); + }); + + document.addEventListener('keydown', function (e) { + if ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 's' && self.form) { + e.preventDefault(); + self.submit(true); + } + }); + + document.addEventListener('input', function (e) { + if (e.target.matches('[data-document-catalog-search]')) { + self.searchCatalog(e.target.closest('[data-document-catalog-field]'), e.target.value); + return; + } + if (e.target.matches('[data-document-media-url]')) { self.updateMediaPreview(e.target); } + }); + + document.querySelectorAll('[data-document-catalog-field]').forEach(function (field) { self.updateCatalogFields(field); }); + + var createDrawer = document.querySelector('[data-documents-create-auto]'); + if (createDrawer && Adminx.Drawer) { Adminx.Drawer.open(createDrawer.id); } + document.querySelectorAll('[data-document-preset-form]').forEach(function (form) { self.updatePresetAssetsState(form); }); + + document.addEventListener('adminx:drawer:closed', function (e) { + var drawer = e.detail ? e.detail.drawer : null; + if (!drawer || drawer.id !== 'documentApiTokenDrawer') { return; } + if (drawer.getAttribute('data-token-created') === '1') { + window.location.reload(); + return; + } + self.resetApiTokenForm(); + }); + }, + + base: function () { return (this.form && this.form.getAttribute('data-base')) || Adminx.base(); }, + field: function (name) { return this.form.querySelector('[name="' + name + '"]'); }, + + initTermInputs: function () { + var self = this; + if (!this.form) { return; } + this.form.querySelectorAll('[data-document-term-input]').forEach(function (field) { + if (field._documentTermReady) { return; } + field._documentTermReady = true; + field._termItems = self.normalizeTerms((field.querySelector('[data-document-term-value]') || {}).value || ''); + field._termActive = -1; + field._termOpen = false; + self.renderTermChips(field); + + var box = field.querySelector('[data-document-term-box]'); + var input = field.querySelector('[data-document-term-query]'); + var list = field.querySelector('[data-document-term-options]'); + if (!box || !input || !list) { return; } + + box.addEventListener('click', function (e) { + if (e.target === box) { input.focus(); } + }); + input.addEventListener('focus', function () { + field._termOpen = true; + if (String(input.value || '').trim()) { + self.searchTermSuggestions(field, input.value, true); + } + }); + input.addEventListener('input', function () { + if (!String(input.value || '').trim()) { + self.closeTermSuggestions(field); + return; + } + field._termOpen = true; + clearTimeout(field._termTimer); + field._termTimer = setTimeout(function () { self.searchTermSuggestions(field, input.value, true); }, 180); + }); + input.addEventListener('keydown', function (e) { self.termKeydown(field, e); }); + input.addEventListener('paste', function () { + setTimeout(function () { + if (!/[,;\n\r]/.test(input.value)) { return; } + self.addTerms(field, input.value); + input.value = ''; + self.closeTermSuggestions(field); + }, 0); + }); + field.addEventListener('click', function (e) { + var remove = e.target.closest('[data-document-term-remove]'); + if (remove) { + e.preventDefault(); + self.removeTerm(field, remove.getAttribute('data-document-term-remove')); + input.focus(); + return; + } + var option = e.target.closest('[data-document-term-option]'); + if (option) { + e.preventDefault(); + self.addTerms(field, option.getAttribute('data-value') || ''); + input.value = ''; + self.closeTermSuggestions(field); + } + }); + list.addEventListener('mousedown', function (e) { e.preventDefault(); }); + field.addEventListener('focusout', function () { + setTimeout(function () { + if (!field.contains(document.activeElement)) { self.closeTermSuggestions(field); } + }, 0); + }); + document.addEventListener('click', function (e) { + if (!field.contains(e.target)) { self.closeTermSuggestions(field); } + }); + }); + }, + + normalizeTerms: function (value) { + var terms = Array.isArray(value) ? value : String(value || '').split(/[,;\n\r]+/); + var seen = {}; + return terms.reduce(function (result, term) { + term = String(term || '').replace(/[\x00-\x1f\x7f]/g, '').trim(); + var key = term.toLowerCase(); + if (term && !seen[key]) { seen[key] = true; result.push(term.slice(0, 254)); } + return result; + }, []); + }, + + renderTermChips: function (field) { + var self = this; + var box = field.querySelector('[data-document-term-box]'); + var input = field.querySelector('[data-document-term-query]'); + var hidden = field.querySelector('[data-document-term-value]'); + if (!box || !input || !hidden) { return; } + box.querySelectorAll('[data-document-term-chip]').forEach(function (chip) { chip.remove(); }); + (field._termItems || []).forEach(function (term) { + var chip = document.createElement('span'); + chip.className = 'chip documents-term-chip' + (field.getAttribute('data-term-kind') === 'tags' ? ' chip-teal' : ''); + chip.setAttribute('data-document-term-chip', ''); + var label = document.createElement('span'); + label.textContent = term; + var remove = document.createElement('button'); + remove.type = 'button'; + remove.setAttribute('data-document-term-remove', term); + remove.setAttribute('aria-label', 'Удалить «' + term + '»'); + remove.innerHTML = ''; + chip.appendChild(label); + chip.appendChild(remove); + box.insertBefore(chip, input); + }); + hidden.value = (field._termItems || []).join(', '); + box.classList.toggle('is-empty', !(field._termItems || []).length); + input.setAttribute('aria-label', field.getAttribute('data-term-kind') === 'tags' ? 'Добавить тег' : 'Добавить ключевое слово'); + self.positionTermInput(field); + }, + + positionTermInput: function (field) { + var input = field.querySelector('[data-document-term-query]'); + if (input) { input.placeholder = (field._termItems || []).length ? 'Добавить ещё…' : (field.getAttribute('data-term-kind') === 'tags' ? 'Найти или добавить тег' : 'Найти или добавить ключевое слово'); } + }, + + addTerms: function (field, value) { + var additions = this.normalizeTerms(value); + if (!additions.length) { return false; } + var items = field._termItems || []; + var seen = {}; + items.forEach(function (item) { seen[String(item).toLowerCase()] = true; }); + var changed = false; + additions.forEach(function (item) { + var key = item.toLowerCase(); + if (!seen[key]) { items.push(item); seen[key] = true; changed = true; } + }); + if (!changed) { return false; } + field._termItems = items; + this.renderTermChips(field); + this.setDirty(true); + return true; + }, + + removeTerm: function (field, value) { + var key = String(value || '').toLowerCase(); + var next = (field._termItems || []).filter(function (item) { return String(item).toLowerCase() !== key; }); + if (next.length === (field._termItems || []).length) { return; } + field._termItems = next; + this.renderTermChips(field); + this.setDirty(true); + }, + + termKeydown: function (field, e) { + var input = field.querySelector('[data-document-term-query]'); + var options = Array.prototype.slice.call(field.querySelectorAll('[data-document-term-option]')); + if (!input) { return; } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + if (!String(input.value || '').trim()) { return; } + e.preventDefault(); + if (!field._termOpen) { field._termOpen = true; this.searchTermSuggestions(field, input.value, true); } + if (!options.length) { return; } + var step = e.key === 'ArrowDown' ? 1 : -1; + field._termActive = Math.max(0, Math.min(options.length - 1, (field._termActive < 0 ? (step > 0 ? -1 : options.length) : field._termActive) + step)); + options.forEach(function (option, index) { option.setAttribute('aria-selected', index === field._termActive ? 'true' : 'false'); }); + options[field._termActive].scrollIntoView({ block: 'nearest' }); + return; + } + if (e.key === 'Enter' || e.key === ',') { + var option = options[field._termActive]; + var value = option ? option.getAttribute('data-value') : input.value; + if (!String(value || '').trim()) { return; } + e.preventDefault(); + this.addTerms(field, value); + input.value = ''; + this.closeTermSuggestions(field); + return; + } + if (e.key === 'Backspace' && !input.value && (field._termItems || []).length) { + this.removeTerm(field, field._termItems[field._termItems.length - 1]); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + this.closeTermSuggestions(field); + } + }, + + searchTermSuggestions: function (field, query, immediate) { + var self = this; + var list = field.querySelector('[data-document-term-options]'); + var input = field.querySelector('[data-document-term-query]'); + if (!list || !input || !field._termOpen) { return; } + if (!String(query || '').trim()) { + this.closeTermSuggestions(field); + return; + } + clearTimeout(field._termTimer); + if (!immediate) { + field._termTimer = setTimeout(function () { self.searchTermSuggestions(field, query, true); }, 180); + return; + } + if (field._termAbort && typeof field._termAbort.abort === 'function') { field._termAbort.abort(); } + field._termAbort = typeof AbortController !== 'undefined' ? new AbortController() : null; + field._termRequest = (field._termRequest || 0) + 1; + var requestId = field._termRequest; + this.renderTermLoading(field); + var params = new URLSearchParams({ + kind: field.getAttribute('data-term-kind') || 'tags', + q: String(query || '').trim(), + rubric_id: field.getAttribute('data-term-rubric-id') || '0', + limit: '12' + }); + fetch((field.getAttribute('data-term-url') || (this.base() + '/documents/terms')) + '?' + params.toString(), { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin', + signal: field._termAbort ? field._termAbort.signal : undefined + }) + .then(this.json) + .then(function (payload) { + if (requestId !== field._termRequest || !field._termOpen) { return; } + self.renderTermSuggestions(field, ((payload.data || {}).items || []), query); + }) + .catch(function (error) { + if (!error || error.name !== 'AbortError') { self.renderTermError(field); } + }); + }, + + renderTermLoading: function (field) { + var list = field.querySelector('[data-document-term-options]'); + if (!list) { return; } + list.innerHTML = '
    Ищем совпадения…
    '; + this.openTermSuggestions(field); + }, + + renderTermError: function (field) { + var list = field.querySelector('[data-document-term-options]'); + if (!list || !field._termOpen) { return; } + list.innerHTML = '
    Не удалось загрузить варианты
    '; + this.openTermSuggestions(field); + }, + + renderTermSuggestions: function (field, items, query) { + var self = this; + var list = field.querySelector('[data-document-term-options]'); + if (!list) { return; } + list.innerHTML = ''; + field._termActive = -1; + var selected = {}; + (field._termItems || []).forEach(function (item) { selected[String(item).toLowerCase()] = true; }); + var exact = false; + (items || []).forEach(function (item) { + var value = String(item.value || '').trim(); + if (!value) { return; } + if (value.toLowerCase() === String(query || '').trim().toLowerCase()) { exact = true; } + if (selected[value.toLowerCase()]) { return; } + list.appendChild(self.termOption(value, query, item, false)); + }); + query = String(query || '').trim(); + if (query && !exact && !selected[query.toLowerCase()]) { + list.appendChild(this.termOption(query, query, {}, true)); + } + if (!list.querySelector('[data-document-term-option]')) { + var empty = document.createElement('div'); + empty.className = 'documents-term-status'; + empty.innerHTML = '' + (query ? 'Совпадений нет' : 'Сохранённых значений пока нет') + ''; + list.appendChild(empty); + } + this.openTermSuggestions(field); + }, + + termOption: function (value, query, item, isNew) { + var option = document.createElement('button'); + option.type = 'button'; + option.className = 'combo-option documents-term-option' + (isNew ? ' is-new' : ''); + option.setAttribute('role', 'option'); + option.setAttribute('aria-selected', 'false'); + option.setAttribute('data-document-term-option', ''); + option.setAttribute('data-value', value); + var icon = document.createElement('i'); + icon.className = isNew ? 'ti ti-plus' : 'ti ti-tag'; + icon.setAttribute('aria-hidden', 'true'); + var label = document.createElement('span'); + label.className = 'documents-term-option-label'; + if (isNew) { + label.appendChild(document.createTextNode('Добавить «' + value + '»')); + } else { + this.highlightTerm(label, value, query); + } + option.appendChild(icon); + option.appendChild(label); + var meta = document.createElement('span'); + meta.className = 'co-sub'; + meta.textContent = isNew ? 'новое' : String(item.rubric_count > 0 ? item.rubric_count + ' в рубрике' : item.count + ' док.'); + option.appendChild(meta); + return option; + }, + + highlightTerm: function (target, value, query) { + var index = String(value).toLowerCase().indexOf(String(query || '').trim().toLowerCase()); + if (index < 0 || !String(query || '').trim()) { target.textContent = value; return; } + var length = String(query).trim().length; + target.appendChild(document.createTextNode(value.slice(0, index))); + var mark = document.createElement('mark'); + mark.textContent = value.slice(index, index + length); + target.appendChild(mark); + target.appendChild(document.createTextNode(value.slice(index + length))); + }, + + openTermSuggestions: function (field) { + var list = field.querySelector('[data-document-term-options]'); + var input = field.querySelector('[data-document-term-query]'); + if (!list || !input || !field._termOpen) { return; } + list.hidden = false; + list.classList.add('open'); + input.setAttribute('aria-expanded', 'true'); + }, + + closeTermSuggestions: function (field) { + var list = field.querySelector('[data-document-term-options]'); + var input = field.querySelector('[data-document-term-query]'); + field._termOpen = false; + field._termActive = -1; + if (list) { list.hidden = true; list.classList.remove('open'); } + if (input) { input.setAttribute('aria-expanded', 'false'); } + }, + + filterUrl: function (form) { + var params = new URLSearchParams(new FormData(form)); + params.delete('page'); + Array.from(params.keys()).forEach(function (key) { + if (String(params.get(key) || '') === '' || String(params.get(key)) === '0') { params.delete(key); } + }); + var query = params.toString(); + return (form.getAttribute('action') || (this.base() + '/documents')) + (query ? '?' + query : ''); + }, + + applyFilters: function (form, push) { + if (!form) { return; } + this.applyFilterUrl(this.filterUrl(form), push); + }, + + applyFilterUrl: function (url, push) { + var self = this; + var requestId = ++this.filterRequest; + var active = document.activeElement; + var focus = active && active.closest && active.closest('.documents-filter') ? { + name: active.getAttribute('name') || '', + start: typeof active.selectionStart === 'number' ? active.selectionStart : null, + end: typeof active.selectionEnd === 'number' ? active.selectionEnd : null + } : null; + if (this.filterAbort && typeof this.filterAbort.abort === 'function') { this.filterAbort.abort(); } + this.filterAbort = typeof AbortController !== 'undefined' ? new AbortController() : null; + Adminx.Loader.show(); + fetch(url, { method: 'GET', headers: { 'Accept': 'text/html' }, credentials: 'same-origin', signal: this.filterAbort ? this.filterAbort.signal : undefined }) + .then(function (res) { + return res.text().then(function (html) { + if (!res.ok) { throw new Error('HTTP ' + res.status); } + if (requestId === self.filterRequest) { self.replaceList(html, url, push, focus); } + }); + }) + .catch(function (error) { + if (!error || error.name !== 'AbortError') { Adminx.Toast.show('Не удалось применить фильтры', 'error'); } + }) + .finally(function () { + if (requestId === self.filterRequest) { self.filterAbort = null; Adminx.Loader.hide(); } + }); + }, + + loadViews: function (days, push) { + var self = this; + var url = this.base() + '/documents/views?days=' + encodeURIComponent(days || 30); + Adminx.Loader.show(); + fetch(url, { method: 'GET', headers: { 'Accept': 'text/html' }, credentials: 'same-origin' }) + .then(function (res) { return res.text().then(function (html) { if (!res.ok) { throw new Error('HTTP ' + res.status); } return html; }); }) + .then(function (html) { + var doc = new DOMParser().parseFromString(html, 'text/html'); + var next = doc.querySelector('.documents-views-panel'); + var current = document.querySelector('.documents-views-panel'); + if (!next || !current) { throw new Error('Views panel not found'); } + current.replaceWith(next); + if (push) { window.history.pushState({ adminxDocumentViews: true }, '', url); } + }) + .catch(function () { Adminx.Toast.show('Не удалось загрузить статистику', 'error'); }) + .finally(function () { Adminx.Loader.hide(); }); + }, + + clearViews: function () { + var self = this; + Adminx.Confirm.open({ + kind: 'danger', + title: 'Очистить подневную статистику?', + message: 'Все строки view_count будут удалены. Общие счётчики документов останутся без изменений.', + confirmLabel: 'Очистить', + onConfirm: function () { + var data = new FormData(); + data.append('_csrf', self.csrf()); + self.ajax(self.base() + '/documents/views/clear', data, function (payload) { + Adminx.Toast.show(payload.message || 'Статистика очищена', 'success'); + var days = document.querySelector('[data-document-view-days]'); + self.loadViews(days ? days.value : 30, false); + }); + } + }); + }, + + replaceList: function (html, url, push, focus) { + var doc = new DOMParser().parseFromString(html, 'text/html'); + ['.documents-summary', '.documents-panel'].forEach(function (selector) { + var next = doc.querySelector(selector); + var current = document.querySelector(selector); + if (next && current) { current.replaceWith(next); } + }); + if (push && window.history && window.history.pushState) { + window.history.pushState({ adminxDocumentsFilters: true }, '', url); + } + if (focus && focus.name) { + var nextFocus = document.querySelector('.documents-filter [name="' + focus.name.replace(/"/g, '\\"') + '"]'); + if (nextFocus) { + nextFocus.focus(); + if (focus.start !== null && typeof nextFocus.setSelectionRange === 'function') { + nextFocus.setSelectionRange(focus.start, focus.end); + } + } + } + }, + + resetFilters: function () { + var form = document.querySelector('.documents-filter'); + if (!form) { return; } + this.applyFilterUrl(form.getAttribute('action') || (this.base() + '/documents'), true); + }, + + changeCreateRubric: function (rubricId) { + if (!this.form || this.field('id').value !== '') { return; } + this.dirty = false; + this.submitting = true; + window.location.href = this.base() + '/documents/create?rubric_id=' + encodeURIComponent(rubricId || ''); + }, + + filterCreateRubrics: function (value) { + var query = String(value || '').trim().toLowerCase(); + var visible = 0; + document.querySelectorAll('[data-document-create-item]').forEach(function (item) { + var match = !query || String(item.getAttribute('data-search') || '').indexOf(query) !== -1; + item.hidden = !match; + if (match) { visible++; } + }); + var empty = document.querySelector('[data-document-create-empty]'); + if (empty) { empty.hidden = visible > 0; } + var current = document.querySelector('[data-document-create-item].is-current:not([hidden])'); + if (!current) { + var first = document.querySelector('[data-document-create-item]:not([hidden])'); + if (first) { this.selectCreateRubric(first.getAttribute('data-document-create-item')); } + } + }, + + selectCreateRubric: function (rubricId) { + rubricId = String(rubricId || ''); + document.querySelectorAll('[data-document-create-item]').forEach(function (item) { + var selected = item.getAttribute('data-document-create-item') === rubricId; + item.classList.toggle('is-current', selected); + item.setAttribute('aria-selected', selected ? 'true' : 'false'); + }); + document.querySelectorAll('[data-document-create-detail]').forEach(function (detail) { + detail.hidden = detail.getAttribute('data-document-create-detail') !== rubricId; + }); + }, + + deleteCreationPreset: function (button) { + var id = parseInt(button.getAttribute('data-document-preset-delete'), 10) || 0; + if (!id) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'error', + title: 'Удалить пресет создания?', + message: 'Сохранённые документы не изменятся. Заготовка исчезнет из выбора новых документов.', + confirmLabel: 'Удалить', + confirmClass: 'btn-danger', + onConfirm: function () { + var data = new FormData(); + data.append('_csrf', self.csrf()); + self.ajax(self.base() + '/documents/presets/' + id + '/delete', data, function (payload) { + Adminx.Toast.show(payload.message || 'Пресет удалён', 'success'); + var url = new URL(window.location.href); + url.searchParams.set('create', '1'); + window.location.href = url.toString(); + }); + } + }); + }, + + saveCreationPreset: function (form) { + if (!form || form.getAttribute('aria-busy') === 'true') { return; } + form.querySelectorAll('.field-error').forEach(function (error) { error.textContent = ''; }); + form.setAttribute('aria-busy', 'true'); + var submit = form.querySelector('button[type="submit"]'); + if (submit) { submit.disabled = true; } + var self = this; + this.ajax(form.getAttribute('action'), new FormData(form), function (payload) { + form.removeAttribute('aria-busy'); + if (submit) { submit.disabled = false; } + form.reset(); + self.updatePresetAssetsState(form); + if (Adminx.Drawer) { Adminx.Drawer.close('documentPresetDrawer'); } + Adminx.Toast.show(payload.message || 'Пресет создания сохранён', 'success'); + }, function (payload) { + form.removeAttribute('aria-busy'); + if (submit) { submit.disabled = false; } + var errors = payload && payload.errors ? payload.errors : {}; + Object.keys(errors).forEach(function (key) { + var target = form.querySelector('[data-error="' + key + '"]'); + if (target) { target.textContent = errors[key]; } + }); + Adminx.Toast.show((payload && payload.message) || 'Не удалось сохранить пресет', 'error'); + return false; + }); + }, + + updatePresetAssetsState: function (form) { + if (!form) { return; } + var fields = form.querySelector('[name="include_fields"]'); + var assets = form.querySelector('[name="include_assets"]'); + if (!assets) { return; } + assets.disabled = !fields || !fields.checked; + if (assets.disabled) { assets.checked = false; } + }, + + activateFieldGroup: function (index) { + document.querySelectorAll('[data-document-field-tab]').forEach(function (tab) { + tab.classList.toggle('is-active', tab.getAttribute('data-document-field-tab') === String(index)); + tab.setAttribute('aria-selected', tab.getAttribute('data-document-field-tab') === String(index) ? 'true' : 'false'); + }); + document.querySelectorAll('[data-document-field-panel]').forEach(function (panel) { + panel.hidden = panel.getAttribute('data-document-field-panel') !== String(index); + }); + setTimeout(function () { + if (Adminx.CodeEditor) { Adminx.CodeEditor.refreshAll(); } + }, 80); + }, + + applyFieldConditions: function () { + if (!this.form) { return; } + var context = this.documentFieldConditionContext(); + var self = this; + this.form.querySelectorAll('[data-document-field-panel]').forEach(function (panel) { + var raw = panel.getAttribute('data-group-condition') || ''; + var condition = panel._documentGroupCondition; + if (condition === undefined) { + try { condition = raw ? JSON.parse(raw) : null; } + catch (e) { condition = null; } + panel._documentGroupCondition = condition; + } + var matched = condition && condition.tree ? self.documentConditionMatches(condition.tree, context) : true; + var mode = condition && condition.mode ? condition.mode : 'show'; + panel._documentGroupState = { + visible: !condition || !condition.tree || mode === 'lock' ? true : (mode === 'hide' ? !matched : matched), + locked: !!(condition && condition.tree && matched && mode === 'lock') + }; + var tab = self.form.querySelector('[data-document-field-tab="' + panel.getAttribute('data-document-field-panel') + '"]'); + if (tab) { + tab.classList.toggle('is-condition-locked', panel._documentGroupState.locked); + var marker = tab.querySelector('.documents-group-lock'); + if (panel._documentGroupState.locked && !marker) { tab.insertAdjacentHTML('beforeend', ''); } + else if (!panel._documentGroupState.locked && marker) { marker.remove(); } + } + }); + this.form.querySelectorAll('.ax-document-field[data-rubric-field-id]').forEach(function (field) { + var raw = field.getAttribute('data-field-condition') || ''; + var condition = field._documentCondition; + if (condition === undefined) { + try { condition = raw ? JSON.parse(raw) : null; } + catch (e) { condition = null; } + field._documentCondition = condition; + } + var matched = condition && condition.tree ? self.documentConditionMatches(condition.tree, context) : true; + var matchedBefore = field._documentConditionMatched; + field._documentConditionMatched = matched; + if (self.conditionsInitialized && matchedBefore === false && matched && condition && ['set', 'clear'].indexOf(String(condition.value_action || '')) !== -1) { + self.applyDocumentFieldValueAction(field, String(condition.value_action), Object.prototype.hasOwnProperty.call(condition, 'action_value') ? String(condition.action_value) : ''); + } + var mode = condition && condition.mode ? condition.mode : 'show'; + var fieldVisible = !condition || !condition.tree || mode === 'lock' ? true : (mode === 'hide' ? !matched : matched); + var group = field.closest('[data-document-field-panel]'); + var groupState = group && group._documentGroupState ? group._documentGroupState : { visible: true, locked: false }; + var visible = groupState.visible && fieldVisible; + var required = visible && (field.getAttribute('data-base-required') === '1' || !!(condition && condition.required)); + var locked = visible && (groupState.locked || (matched && mode === 'lock')); + var allowedValues = visible && matched && condition && Array.isArray(condition.allowed_values) ? condition.allowed_values.map(String) : null; + self.setDocumentFieldConditionState(field, visible, required, locked, allowedValues); + }); + this.conditionsInitialized = true; + this.updateCatalogGroups(); + }, + + fieldValueControls: function (field) { + if (!field) { return []; } + return Array.prototype.slice.call(field.querySelectorAll('input[name], select[name], textarea[name]')).filter(function (control) { + return control.type !== 'hidden' && !control.hasAttribute('data-document-term-query'); + }); + }, + + applyDocumentFieldValueAction: function (field, action, value) { + var controls = this.fieldValueControls(field); + if (!field || !controls.length) { return; } + this.setDocumentFieldValueActionOverride(field, false); + field._conditionActionUndo = controls.map(function (control) { + return { + control: control, + value: control.value, + checked: !!control.checked, + selected: control.tagName === 'SELECT' && control.multiple + ? Array.prototype.map.call(control.selectedOptions, function (option) { return option.value; }) : null + }; + }); + field._conditionActionApplying = true; + controls.forEach(function (control) { + if (control.tagName === 'SELECT') { + if (control.multiple) { + Array.prototype.forEach.call(control.options, function (option) { option.selected = action === 'set' && String(option.value) === value; }); + } else { control.value = action === 'set' ? value : ''; } + } else if (control.type === 'radio') { + control.checked = action === 'set' && String(control.value) === value; + } else if (control.type === 'checkbox') { + control.checked = action === 'set' && ['1', 'true', 'yes', 'on'].indexOf(value.toLowerCase()) !== -1; + } else { + control.value = action === 'set' ? value : ''; + } + }); + field._conditionActionApplying = false; + this.renderDocumentFieldValueAction(field, action); + this.setDirty(true); + }, + + renderDocumentFieldValueAction: function (field, action) { + var header = field ? field.querySelector('.ax-attr-label') : null; + if (!header) { return; } + var marker = header.querySelector('.ax-condition-action'); + if (!marker) { + header.insertAdjacentHTML('beforeend', ''); + marker = header.querySelector('.ax-condition-action'); + } + var copy = marker.querySelector('span'); + if (copy) { copy.textContent = action === 'clear' ? 'Очищено условием' : 'Установлено условием'; } + }, + + clearDocumentFieldValueAction: function (field, markOverride) { + if (!field || field._conditionActionApplying) { return; } + var hadAction = Array.isArray(field._conditionActionUndo) || !!field.querySelector('.ax-condition-action'); + if (markOverride && hadAction) { this.setDocumentFieldValueActionOverride(field, true); } + field._conditionActionUndo = null; + var marker = field.querySelector('.ax-condition-action'); + if (marker) { marker.remove(); } + }, + + setDocumentFieldValueActionOverride: function (field, enabled) { + if (!field) { return; } + var control = field.querySelector('[data-condition-action-override]'); + if (!enabled) { + if (control) { control.remove(); } + return; + } + if (control) { return; } + var fieldId = parseInt(field.getAttribute('data-rubric-field-id'), 10) || 0; + if (fieldId <= 0) { return; } + control = document.createElement('input'); + control.type = 'hidden'; + control.name = 'fields[' + fieldId + '][_condition_action_override]'; + control.value = '1'; + control.setAttribute('data-condition-action-override', ''); + field.appendChild(control); + }, + + undoDocumentFieldValueAction: function (field) { + if (!field || !Array.isArray(field._conditionActionUndo)) { return; } + field._conditionActionApplying = true; + field._conditionActionUndo.forEach(function (state) { + var control = state.control; + if (!control || !control.isConnected) { return; } + control.value = state.value; + if (control.type === 'checkbox' || control.type === 'radio') { control.checked = state.checked; } + if (control.tagName === 'SELECT' && control.multiple && Array.isArray(state.selected)) { + Array.prototype.forEach.call(control.options, function (option) { option.selected = state.selected.indexOf(option.value) !== -1; }); + } + }); + field._conditionActionApplying = false; + this.clearDocumentFieldValueAction(field, true); + this.setDirty(true); + this.applyFieldConditions(); + }, + + documentFieldConditionContext: function () { + var context = {}; + this.form.querySelectorAll('.ax-document-field[data-rubric-field-id]').forEach(function (field) { + var values = []; + var controls = Array.prototype.slice.call(field.querySelectorAll('input[name], select[name], textarea[name]')); + controls.forEach(function (control) { + if ((control.type === 'checkbox' || control.type === 'radio') && !control.checked) { return; } + if (control.type === 'hidden') { + var choices = controls.filter(function (other) { return other.name === control.name && (other.type === 'checkbox' || other.type === 'radio'); }); + if (choices.some(function (choice) { return choice.checked; })) { return; } + } + if (control.tagName === 'SELECT' && control.multiple) { + Array.prototype.forEach.call(control.selectedOptions, function (option) { values.push(String(option.value)); }); + } else { + values.push(String(control.value == null ? '' : control.value)); + } + }); + var id = String(parseInt(field.getAttribute('data-rubric-field-id'), 10) || 0); + var alias = String(field.getAttribute('data-field-alias') || '').trim().toLowerCase(); + context['id:' + id] = values; + if (alias) { context['alias:' + alias] = values; } + }); + return context; + }, + + documentConditionMatches: function (node, context) { + if (node && Array.isArray(node.items)) { + var join = node.operator === 'or' ? 'or' : 'and'; + if (!node.items.length) { return true; } + for (var i = 0; i < node.items.length; i++) { + var result = this.documentConditionMatches(node.items[i], context); + if (join === 'and' && !result) { return false; } + if (join === 'or' && result) { return true; } + } + return join === 'and'; + } + var values = context[String((node || {}).field || '')] || ['']; + return this.documentConditionCompare(values, String((node || {}).operator || 'equals'), String((node || {}).value || '')); + }, + + documentConditionCompare: function (values, operator, expected) { + values = Array.isArray(values) ? values.map(String) : [String(values == null ? '' : values)]; + var nonEmpty = values.filter(function (value) { return value.trim() !== ''; }); + if (operator === 'empty') { return nonEmpty.length === 0; } + if (operator === 'not_empty') { return nonEmpty.length > 0; } + if (operator === 'equals') { return values.indexOf(expected) !== -1; } + if (operator === 'not_equals') { return values.indexOf(expected) === -1; } + if (operator === 'contains' || operator === 'not_contains') { + var found = expected !== '' && values.some(function (value) { return value.toLowerCase().indexOf(expected.toLowerCase()) !== -1; }); + return operator === 'contains' ? found : !found; + } + if (operator === 'in' || operator === 'not_in') { + var options = expected.split(/[\r\n,]+/).map(function (value) { return value.trim(); }).filter(Boolean); + var included = values.some(function (value) { return options.indexOf(value) !== -1; }); + return operator === 'in' ? included : !included; + } + var number = nonEmpty.length ? Number(nonEmpty[0].replace(',', '.')) : NaN; + var target = Number(expected.replace(',', '.')); + if (!Number.isFinite(number) || !Number.isFinite(target)) { return false; } + if (operator === 'greater') { return number > target; } + if (operator === 'greater_or_equal') { return number >= target; } + if (operator === 'less') { return number < target; } + if (operator === 'less_or_equal') { return number <= target; } + return false; + }, + + setDocumentFieldConditionState: function (field, visible, required, locked, allowedValues) { + field.hidden = !visible; + field.classList.toggle('is-condition-hidden', !visible); + field.classList.toggle('is-condition-locked', !!locked); + field.classList.toggle('is-required', required); + field.setAttribute('aria-hidden', visible ? 'false' : 'true'); + field.setAttribute('aria-disabled', locked ? 'true' : 'false'); + field.querySelectorAll('input, select, textarea, button').forEach(function (control) { + if (control.hasAttribute('data-condition-action-undo')) { return; } + if ((!visible || locked) && !control.disabled) { + control.disabled = true; + control.setAttribute('data-condition-disabled', '1'); + } else if (visible && !locked && control.getAttribute('data-condition-disabled') === '1') { + control.disabled = false; + control.removeAttribute('data-condition-disabled'); + } + }); + this.setDocumentFieldAllowedValues(field, allowedValues); + field.querySelectorAll('[contenteditable]').forEach(function (control) { + if ((!visible || locked) && control.getAttribute('contenteditable') !== 'false') { + control.setAttribute('data-condition-contenteditable', control.getAttribute('contenteditable') || 'true'); + control.setAttribute('contenteditable', 'false'); + } else if (visible && !locked && control.hasAttribute('data-condition-contenteditable')) { + control.setAttribute('contenteditable', control.getAttribute('data-condition-contenteditable')); + control.removeAttribute('data-condition-contenteditable'); + } + }); + var label = field.querySelector('.ax-label'); + var marker = label ? label.querySelector('.ax-required') : null; + if (required && label && !marker) { + label.insertAdjacentHTML('beforeend', ''); + } else if (!required && marker) { + marker.remove(); + } + var header = field.querySelector('.ax-attr-label'); + var lockMarker = header ? header.querySelector('.ax-condition-lock') : null; + if (locked && header && !lockMarker) { + header.insertAdjacentHTML('beforeend', 'Только чтение'); + } else if (!locked && lockMarker) { + lockMarker.remove(); + } + }, + + setDocumentFieldAllowedValues: function (field, allowedValues) { + var limited = Array.isArray(allowedValues) && allowedValues.length > 0; + var allowed = limited ? allowedValues.map(String) : []; + var invalidSelected = false; + field.querySelectorAll('select option').forEach(function (option) { + var unavailable = limited && option.value !== '' && allowed.indexOf(String(option.value)) === -1; + if (unavailable && option.selected) { invalidSelected = true; } + option.disabled = unavailable && !option.selected; + option.classList.toggle('is-condition-unavailable', unavailable); + }); + field.querySelectorAll('input[type="radio"], input[type="checkbox"]').forEach(function (control) { + var unavailable = limited && allowed.indexOf(String(control.value)) === -1; + if (unavailable && control.checked) { invalidSelected = true; } + if (unavailable && !control.checked) { + control.disabled = true; + control.setAttribute('data-condition-option-disabled', '1'); + } else if ((!unavailable || control.checked) && control.getAttribute('data-condition-option-disabled') === '1' + && !field.hidden && !field.classList.contains('is-condition-locked')) { + control.disabled = false; + control.removeAttribute('data-condition-option-disabled'); + } + var label = control.closest('label'); + if (label) { label.classList.toggle('is-condition-unavailable', unavailable); } + }); + var header = field.querySelector('.ax-attr-label'); + var marker = header ? header.querySelector('.ax-condition-options') : null; + if (limited && header && !marker) { + header.insertAdjacentHTML('beforeend', ''); + marker = header.querySelector('.ax-condition-options'); + } + if (marker) { + marker.hidden = !limited; + marker.classList.toggle('is-warning', invalidSelected); + var copy = marker.querySelector('span'); + if (copy) { copy.textContent = invalidSelected ? 'Выберите доступный вариант' : ('Доступно: ' + allowed.length); } + } + }, + + updateCatalogFields: function (catalog) { + if (!catalog) { return; } + var tokens = catalog.querySelectorAll('[data-document-catalog-token]'); + var limited = catalog.getAttribute('data-catalog-limits-fields') === '1'; + var allowed = {}; + tokens.forEach(function (token) { + String(token.getAttribute('data-catalog-fields') || '').split(',').forEach(function (id) { if (id) { allowed[String(Number(id))] = true; } }); + }); + document.querySelectorAll('[data-document-catalog-field]').forEach(function (other) { + var host = other.closest('[data-rubric-field-id]'); + if (host) { allowed[String(host.getAttribute('data-rubric-field-id'))] = true; } + }); + document.querySelectorAll('.ax-document-field[data-rubric-field-id]').forEach(function (field) { + var hide = (limited || tokens.length > 0) && !allowed[String(field.getAttribute('data-rubric-field-id'))]; + field.classList.toggle('is-catalog-hidden', hide); + }); + var empty = catalog.querySelector('[data-document-catalog-empty]'); + if (empty) { empty.hidden = tokens.length > 0; } + this.updateCatalogGroups(); + }, + + addCatalogToken: function (option) { + var catalog = option.closest('[data-document-catalog-field]'); + if (!catalog) { return; } + var tokens = catalog.querySelector('[data-document-catalog-tokens]'); + var fieldId = catalog.getAttribute('data-document-catalog-field'); + var id = option.getAttribute('data-catalog-id'); + if (!tokens || catalog.querySelector('[data-document-catalog-token][data-catalog-id="' + id + '"]')) { return; } + var name = option.getAttribute('data-catalog-name') || ('#' + id); + var fields = option.getAttribute('data-catalog-fields') || ''; + var chip = document.createElement('span'); + chip.className = 'documents-catalog-token'; + chip.setAttribute('data-document-catalog-token', ''); + chip.setAttribute('data-catalog-id', id); + chip.setAttribute('data-catalog-fields', fields); + chip.innerHTML = '' + + '' + this.esc(name) + '' + + '' + + ''; + tokens.appendChild(chip); + option.classList.add('is-picked'); + var dd = catalog.querySelector('.dropdown'); + if (dd) { dd.classList.remove('open'); } + var search = catalog.querySelector('[data-document-catalog-search]'); + if (search) { search.value = ''; this.searchCatalog(catalog, ''); } + this.updateCatalogFields(catalog); + this.setDirty(true); + }, + + removeCatalogToken: function (token) { + if (!token) { return; } + var catalog = token.closest('[data-document-catalog-field]'); + var id = token.getAttribute('data-catalog-id'); + if (token.parentNode) { token.parentNode.removeChild(token); } + if (!catalog) { return; } + var option = catalog.querySelector('[data-document-catalog-option][data-catalog-id="' + id + '"]'); + if (option) { option.classList.remove('is-picked'); } + this.updateCatalogFields(catalog); + this.setDirty(true); + }, + + updateCatalogGroups: function () { + var firstVisible = null; + document.querySelectorAll('[data-document-field-panel]').forEach(function (panel) { + var count = panel.querySelectorAll('.ax-document-field:not(.is-catalog-hidden):not(.is-condition-hidden)').length; + var index = panel.getAttribute('data-document-field-panel'); + var tab = document.querySelector('[data-document-field-tab="' + index + '"]'); + if (tab) { + tab.hidden = count === 0; + var badge = tab.querySelector('.tab-count'); + if (badge) { badge.textContent = count; } + } + if (count > 0 && firstVisible === null) { firstVisible = index; } + }); + var active = document.querySelector('[data-document-field-tab].is-active:not([hidden])'); + if (!active && firstVisible !== null) { this.activateFieldGroup(firstVisible); } + }, + + searchCatalog: function (catalog, value) { + if (!catalog) { return; } + value = String(value || '').trim().toLowerCase(); + catalog.querySelectorAll('[data-document-catalog-node]').forEach(function (node) { + node.classList.toggle('is-filtered', value && String(node.getAttribute('data-search') || '').toLowerCase().indexOf(value) < 0); + }); + }, + + submit: function (stay) { + if (!this.form || this.submitting) { return; } + var self = this; + var id = parseInt(this.field('id').value, 10) || 0; + this.clearErrors(); + this.syncEditors(); + this.reindexMediaLists(); + this.reindexValueLists(); + if (!this.form.checkValidity()) { + var nativeErrors = {}; + this.form.querySelectorAll(':invalid').forEach(function (field) { + if (field.name && !nativeErrors[field.name]) { nativeErrors[field.name] = field.validationMessage || 'Проверьте значение'; } + }); + this.showErrors(nativeErrors); + return; + } + this.checkAlias(false, function (ok) { + if (!ok) { return; } + self.checkShortAlias(false, function (shortOk) { + if (!shortOk) { return; } + self.setSaving(true); + var submitUrl = self.form.getAttribute('data-submit-url') || (self.base() + '/documents' + (id ? '/' + id : '')); + var returnUrl = self.form.getAttribute('data-return-url') || (self.base() + '/documents'); + self.ajax(submitUrl, new FormData(self.form), function (payload) { + self.clearLocalDraft(); + self.setDirty(false); + self.setSaving(false); + Adminx.Toast.show(payload.message || 'Документ сохранён', 'success'); + if (payload.data && payload.data.snapshot_warning) { + Adminx.Toast.show('Документ сохранён, но JSON-снимок не записан: ' + payload.data.snapshot_warning, 'warning'); + } + if (payload.data && payload.data.id) { + self.field('id').value = payload.data.id; + self.form.setAttribute('data-id', payload.data.id); + if (!id && self.form.getAttribute('data-submit-url')) { + self.form.setAttribute('data-submit-url', self.base() + '/documents/' + payload.data.id); + } + } + if (payload.data && payload.data.document_version) { + var versionField = self.field('document_version'); + if (versionField) { versionField.value = String(payload.data.document_version); } + } + if (payload.data && payload.data.media_draft_token) { + self.refreshMediaDraft(payload.data.media_draft_token, payload.data.id || id); + } + if (self.form.getAttribute('data-quick-edit') === '1') { + self.refreshQuickEditOpener(!stay); + return; + } + if (!stay) { + window.location.href = returnUrl; + return; + } + if (payload.redirect && !id) { + var stayTemplate = self.form.getAttribute('data-stay-url-template'); + var stayUrl = stayTemplate && payload.data && payload.data.id ? stayTemplate.replace('{id}', payload.data.id) : payload.redirect; + self.promoteCreatedDocument(payload.data.id, stayUrl); + } + }, function (payload) { + self.setSaving(false); + if (payload.data && payload.data.conflict) { + self.saveLocalDraft(true); + self.showEditConflict(payload); + return false; + } + self.showErrors(payload.errors || {}); + }); + }); + }); + }, + + setDirty: function (dirty) { + if (!this.form) { return; } + this.dirty = !!dirty; + if (this.dirty) { this.scheduleLocalDraft(); } + this.setSaveState(this.submitting ? 'saving' : (this.dirty ? 'dirty' : 'saved')); + }, + + initEditorMode: function () { + if (!this.form) { return; } + var mode = this.form.getAttribute('data-quick-edit') === '1' ? 'quick' : 'normal'; + try { mode = window.localStorage.getItem(this.editorModeKey()) || mode; } catch (e) {} + this.setEditorMode(mode, false); + }, + + editorModeKey: function () { + var actor = this.form ? this.form.getAttribute('data-actor-id') || '0' : '0'; + var rubric = this.form ? this.form.getAttribute('data-rubric-id') || '0' : '0'; + return 'ave.adminx.document-editor-mode:' + actor + ':' + rubric; + }, + + setEditorMode: function (mode, persist) { + if (!this.form) { return; } + if (['quick', 'normal', 'advanced'].indexOf(mode) === -1) { mode = 'normal'; } + this.editorMode = mode; + this.form.setAttribute('data-editor-mode', mode); + document.querySelectorAll('[data-document-editor-mode]').forEach(function (button) { + var active = button.getAttribute('data-document-editor-mode') === mode; + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + if (persist) { + try { window.localStorage.setItem(this.editorModeKey(), mode); } catch (e) {} + } + window.setTimeout(function () { if (Adminx.CodeEditor) { Adminx.CodeEditor.refreshAll(); } }, 40); + }, + + draftKey: function () { + if (!this.form) { return ''; } + var actor = this.form.getAttribute('data-actor-id') || '0'; + var id = parseInt((this.field('id') || {}).value, 10) || 0; + var rubric = this.form.getAttribute('data-rubric-id') || '0'; + if (id > 0) { return 'ave.adminx.document-draft:' + actor + ':document-' + id; } + var instanceKey = 'ave.adminx.document-draft-instance:' + actor + ':' + rubric + ':' + window.location.pathname; + var instance = ''; + try { + instance = window.sessionStorage.getItem(instanceKey) || ''; + if (!instance) { + instance = Date.now().toString(36) + Math.random().toString(36).slice(2, 10); + window.sessionStorage.setItem(instanceKey, instance); + } + } catch (e) { instance = 'default'; } + return 'ave.adminx.document-draft:' + actor + ':new-' + rubric + '-' + instance; + }, + + scheduleLocalDraft: function () { + var self = this; + window.clearTimeout(this.draftTimer); + this.draftTimer = window.setTimeout(function () { self.saveLocalDraft(false); }, 700); + }, + + formValues: function () { + if (!this.form) { return {}; } + this.syncEditors(); + this.reindexMediaLists(); + this.reindexValueLists(); + var values = {}; + var excluded = { _csrf: true, id: true, document_version: true, media_draft_token: true }; + new FormData(this.form).forEach(function (value, name) { + if (excluded[name] || typeof value !== 'string') { return; } + if (!Object.prototype.hasOwnProperty.call(values, name)) { values[name] = []; } + values[name].push(value); + }); + return values; + }, + + saveLocalDraft: function (immediate) { + if (!this.form || (!this.dirty && !immediate)) { return; } + window.clearTimeout(this.draftTimer); + var version = parseInt((this.field('document_version') || {}).value, 10) || 1; + var draft = { + format: 'ave.adminx.document-draft', + version: 1, + document_id: parseInt((this.field('id') || {}).value, 10) || 0, + rubric_id: parseInt(this.form.getAttribute('data-rubric-id'), 10) || 0, + server_version: version, + saved_at: Date.now(), + values: this.formValues() + }; + try { + window.localStorage.setItem(this.draftKey(), JSON.stringify(draft)); + this.setSaveState('draft'); + } catch (e) {} + }, + + checkLocalDraft: function () { + if (!this.form) { return; } + var raw = ''; + try { raw = window.localStorage.getItem(this.draftKey()) || ''; } catch (e) { return; } + if (!raw) { return; } + try { this.draftCandidate = JSON.parse(raw); } catch (e) { this.clearLocalDraft(); return; } + if (!this.draftCandidate || this.draftCandidate.format !== 'ave.adminx.document-draft' || !this.draftCandidate.values) { this.clearLocalDraft(); return; } + if (JSON.stringify(this.draftCandidate.values) === JSON.stringify(this.formValues())) { this.clearLocalDraft(); return; } + var root = this.form.querySelector('[data-document-draft-recovery]'); + if (!root) { return; } + var when = new Date(this.draftCandidate.saved_at || Date.now()); + var text = root.querySelector('[data-document-draft-recovery-text]'); + var currentVersion = parseInt((this.field('document_version') || {}).value, 10) || 1; + var suffix = this.draftCandidate.server_version !== currentVersion ? ' Серверная версия документа уже изменилась.' : ''; + if (text) { text.textContent = 'Сохранён в этом браузере ' + when.toLocaleString('ru-RU') + '.' + suffix; } + root.hidden = false; + }, + + restoreLocalDraft: function () { + if (!this.form || !this.draftCandidate) { return; } + var values = this.draftCandidate.values || {}; + var self = this; + Object.keys(values).forEach(function (name) { + var controls = Array.prototype.slice.call(self.form.querySelectorAll('[name="' + self.escapeSelector(name) + '"]')); + var saved = Array.isArray(values[name]) ? values[name].map(String) : [String(values[name])]; + controls.forEach(function (control, index) { + if (control.type === 'checkbox' || control.type === 'radio') { control.checked = saved.indexOf(String(control.value)) !== -1; return; } + if (control.tagName === 'SELECT' && control.multiple) { + Array.prototype.forEach.call(control.options, function (option) { option.selected = saved.indexOf(String(option.value)) !== -1; }); + return; + } + control.value = saved[Math.min(index, saved.length - 1)] || ''; + if (control._adminxCodeMirror) { control._adminxCodeMirror.setValue(control.value); } + if (control._adminxTiptap) { control._adminxTiptap.commands.setContent(control.value || '

    '); } + }); + }); + this.form.querySelectorAll('[data-document-term-input]').forEach(function (field) { + var hidden = field.querySelector('[data-document-term-value]'); + field._termItems = self.normalizeTerms(hidden ? hidden.value : ''); + self.renderTermChips(field); + }); + this.form.querySelectorAll('[data-document-media-url]').forEach(function (input) { self.updateMediaPreview(input); }); + this.applyFieldConditions(); + this.updateSeo(); + this.setDirty(true); + var root = this.form.querySelector('[data-document-draft-recovery]'); + if (root) { root.hidden = true; } + Adminx.Toast.show('Локальный черновик восстановлен. Проверьте данные и сохраните документ.', 'success'); + }, + + discardLocalDraft: function () { + this.clearLocalDraft(); + this.draftCandidate = null; + var root = this.form ? this.form.querySelector('[data-document-draft-recovery]') : null; + if (root) { root.hidden = true; } + }, + + clearLocalDraft: function () { + window.clearTimeout(this.draftTimer); + try { window.localStorage.removeItem(this.draftKey()); } catch (e) {} + }, + + escapeSelector: function (value) { + if (window.CSS && typeof window.CSS.escape === 'function') { return window.CSS.escape(String(value)); } + return String(value).replace(/(["\\])/g, '\\$1'); + }, + + promoteCreatedDocument: function (id, stayUrl) { + id = parseInt(id, 10) || 0; + if (!id) { return; } + window.history.replaceState({ adminxDocumentEdit: true }, '', stayUrl); + document.querySelectorAll('[data-document-remarks], [data-document-aliases], [data-document-revisions], [data-document-snapshot], [data-open-drawer="documentPresetDrawer"]').forEach(function (button) { + button.disabled = false; + }); + var presetForm = document.querySelector('[data-document-preset-form]'); + if (presetForm) { presetForm.setAttribute('action', this.base() + '/documents/' + id + '/presets'); } + document.querySelectorAll('[data-document-id]').forEach(function (element) { + element.setAttribute('data-document-id', String(id)); + }); + var note = document.querySelector('[data-document-new-note]'); + if (note) { note.remove(); } + var factId = document.querySelector('[data-document-fact-id]'); + if (factId) { factId.textContent = String(id); } + var titleField = this.field('document_title'); + var heading = document.querySelector('[data-document-edit-heading]'); + var title = titleField ? titleField.value.trim() : ''; + if (heading && title) { heading.textContent = title; } + var status = this.field('document_status'); + var meta = document.querySelector('[data-document-edit-meta]'); + if (meta) { meta.textContent = 'ID ' + id + ', ' + (status && status.value === '1' ? 'опубликован' : 'черновик') + ', сохранён только что'; } + if (title) { document.title = title + ' · AVE.cms'; } + }, + + setSaving: function (saving) { + if (!this.form) { return; } + this.submitting = !!saving; + this.form.setAttribute('aria-busy', this.submitting ? 'true' : 'false'); + this.form.querySelectorAll('[data-document-submit-stay], button[type="submit"]').forEach(function (button) { + button.disabled = !!saving; + }); + this.setSaveState(this.submitting ? 'saving' : (this.dirty ? 'dirty' : 'saved')); + }, + + setSaveState: function (state) { + var root = document.querySelector('[data-document-save-state]'); + if (!root) { return; } + var states = { + saved: { text: 'Изменения сохранены', icon: 'ti ti-circle-check' }, + dirty: { text: 'Есть несохранённые изменения', icon: 'ti ti-point-filled' }, + saving: { text: 'Сохраняем...', icon: 'ti ti-loader-2' }, + draft: { text: 'Черновик сохранён в браузере', icon: 'ti ti-device-floppy' } + }; + var current = states[state] || states.saved; + root.setAttribute('data-state', state); + var text = root.querySelector('[data-document-save-state-text]'); + var icon = root.querySelector('[data-document-save-state-icon]'); + if (text) { text.textContent = current.text; } + if (icon) { icon.className = current.icon; } + }, + + updateBoolean: function (input) { + if (!input) { return; } + var label = input.closest('.switch'); + label = label ? label.querySelector('[data-document-boolean-label]') : null; + if (label) { label.textContent = input.checked ? 'Включено' : 'Выключено'; } + }, + + refreshQuickEditOpener: function (closeAfter) { + if (window.opener && !window.opener.closed) { + try { + window.opener.location.reload(); + } catch (e) { + window.opener.postMessage({ type: 'adminx:document-saved' }, window.location.origin); + } + } + if (closeAfter) { + window.close(); + } + }, + + previewPayload: function () { + if (!this.form) { return; } + this.syncEditors(); + this.reindexMediaLists(); + this.reindexValueLists(); + var code = document.querySelector('[data-document-payload-code]'); + var summary = document.querySelector('[data-document-payload-summary]'); + var validation = document.querySelector('[data-document-payload-validation]'); + if (code) { code.textContent = 'Формируем данные...'; } + if (validation) { validation.hidden = true; validation.innerHTML = ''; } + if (Adminx.Drawer) { Adminx.Drawer.open('documentPayloadDrawer'); } + var self = this; + this.ajax(this.base() + '/documents/preview', new FormData(this.form), function (payload) { + var data = payload.data || {}; + var preview = data.payload || {}; + if (code) { code.textContent = JSON.stringify(preview, null, 2); } + var fields = Array.isArray(preview.fields) ? preview.fields.length : 0; + if (summary) { summary.textContent = (preview.mode === 'create' ? 'Создание' : 'Обновление') + ' · полей: ' + fields + ' · без сохранения'; } + self.renderPayloadValidation(data.validation_errors || {}); + }); + }, + + renderPayloadValidation: function (errors) { + var target = document.querySelector('[data-document-payload-validation]'); + if (!target) { return; } + var keys = Object.keys(errors || {}); + if (!keys.length) { target.hidden = true; target.innerHTML = ''; return; } + target.innerHTML = '
    Валидация: ' + keys.length + '' + keys.map(function (key) { return selfEsc(key + ': ' + errors[key]); }).join('
    ') + '
    '; + target.hidden = false; + function selfEsc(value) { + var node = document.createElement('span'); + node.textContent = String(value || ''); + return node.innerHTML; + } + }, + + copyPayload: function () { + var code = document.querySelector('[data-document-payload-code]'); + var value = code ? code.textContent : ''; + if (!value) { return; } + var done = function () { Adminx.Toast.show('JSON скопирован', 'success'); }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(value).then(done); + return; + } + var textarea = document.createElement('textarea'); + textarea.value = value; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + textarea.remove(); + done(); + }, + + syncEditors: function () { + if (!this.form) { return; } + this.form.querySelectorAll('textarea[data-code-editor]').forEach(function (textarea) { + if (textarea._adminxCodeMirror) { textarea._adminxCodeMirror.save(); } + }); + this.form.querySelectorAll('textarea[data-rich-editor]').forEach(function (textarea) { + if (textarea._adminxTiptap && textarea.getAttribute('data-rich-editor-dirty') === '1') { + textarea.value = textarea._adminxTiptap.getHTML(); + } + }); + }, + + remove: function (row) { + var self = this; + if (!row) { return; } + if (Adminx.Confirm) { + Adminx.Confirm.open({ + kind: 'error', + title: 'Удалить документ?', + message: 'Документ будет помечен удалённым и останется доступен для восстановления.', + confirmLabel: 'Удалить', + confirmClass: 'btn-danger', + onConfirm: function () { self.action(row, '/delete'); } + }); + return; + } + if (confirm('Пометить документ удалённым?')) { this.action(row, '/delete'); } + }, + + restore: function (row) { + if (!row) { return; } + this.action(row, '/restore'); + }, + + copy: function (row) { + if (!row) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'info', + title: 'Создать копию документа?', + message: 'Будет создан новый черновик со всеми значениями полей.', + confirmLabel: 'Создать копию', + onConfirm: function () { + var id = row.getAttribute('data-id'); + var data = new FormData(); + data.append('_csrf', self.csrf()); + self.ajax(self.base() + '/documents/' + encodeURIComponent(id) + '/copy', data, function (payload) { + Adminx.Toast.show(payload.message || 'Копия создана', 'success'); + window.location.href = payload.redirect || (self.base() + '/documents/' + ((payload.data || {}).id || '') + '/edit'); + }); + } + }); + }, + + purge: function (row) { + if (!row) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить документ окончательно?', + message: 'Будут удалены документ, значения полей, ревизии, редиректы, заметки и статистика. Действие необратимо.', + confirmLabel: 'Удалить окончательно', + onConfirm: function () { self.action(row, '/purge'); } + }); + }, + + action: function (row, suffix) { + var id = row.getAttribute('data-id'); + var data = new FormData(); + data.append('_csrf', this.csrf()); + var self = this; + this.ajax(this.base() + '/documents/' + encodeURIComponent(id) + suffix, data, function (payload) { + Adminx.Toast.show(payload.message || 'Готово', 'success'); + self.applyFilterUrl(window.location.href, false); + }); + }, + + updateBulk: function () { + var selected = document.querySelectorAll('[data-document-check]:checked'); + var bar = document.querySelector('[data-documents-bulk]'); + var count = document.querySelector('[data-documents-selected]'); + var all = document.querySelector('[data-documents-check-all]'); + var total = document.querySelectorAll('[data-document-check]').length; + if (bar) { + bar.hidden = selected.length === 0; + bar.classList.toggle('visible', selected.length > 0); + } + if (count) { count.textContent = selected.length; } + if (all) { all.checked = total > 0 && selected.length === total; all.indeterminate = selected.length > 0 && selected.length < total; } + }, + + clearBulk: function () { + document.querySelectorAll('[data-document-check], [data-documents-check-all]').forEach(function (box) { box.checked = false; box.indeterminate = false; }); + this.updateBulk(); + }, + + applyBulk: function () { + var action = document.querySelector('[data-documents-bulk-action]'); + var ids = Array.prototype.map.call(document.querySelectorAll('[data-document-check]:checked'), function (box) { return box.value; }); + if (!action || !action.value || !ids.length) { Adminx.Toast.show('Выберите документы и действие', 'warning'); return; } + var self = this; + var labels = {publish:'опубликовать',unpublish:'снять с публикации',delete:'переместить в корзину',restore:'восстановить',purge:'удалить окончательно'}; + Adminx.Confirm.open({kind:action.value==='purge'?'danger':'warning',title:'Выполнить пакетное действие?',message:'Документов: '+ids.length+'. Действие: '+labels[action.value]+'.',confirmLabel:'Применить',onConfirm:function(){var data=new FormData();data.append('_csrf',self.csrf());data.append('action',action.value);ids.forEach(function(id){data.append('ids[]',id);});self.ajax(self.base()+'/documents/bulk',data,function(payload){var result=payload.data||{};Adminx.Toast.show((payload.message||'Готово')+' · обработано '+(result.done||0),result.errors&&result.errors.length?'warning':'success');self.applyFilterUrl(window.location.href,false);});}}); + }, + + rebuildSnapshots: function () { + var self = this; + var rubric = document.querySelector('.documents-filter [name="rubric_id"]'); + var rubricId = rubric ? (parseInt(rubric.value, 10) || 0) : 0; + var scope = rubricId ? ('выбранной рубрики #' + rubricId) : 'всех рабочих документов'; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Пересобрать JSON-снимки?', + message: 'Снимки будут последовательно пересобраны для ' + scope + '. Окно нужно оставить открытым до завершения.', + confirmLabel: 'Пересобрать', + onConfirm: function () { self.runSnapshotBatch(rubricId, 0, 0, []); } + }); + }, + + runSnapshotBatch: function (rubricId, after, total, failures) { + var self = this; + var data = new FormData(); + data.append('_csrf', this.csrf()); + data.append('rubric_id', rubricId || 0); + data.append('after', after || 0); + data.append('limit', 50); + Adminx.Loader.show(); + fetch(this.base() + '/documents/snapshots/rebuild', { method: 'POST', body: data, headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(this.json) + .then(function (payload) { + if (!payload.success) { throw payload; } + var result = payload.data || {}; + total += parseInt(result.done, 10) || 0; + failures = failures.concat(result.failed || []); + if (result.has_more) { + self.runSnapshotBatch(rubricId, parseInt(result.next_after, 10) || after, total, failures); + return; + } + Adminx.Loader.hide(); + Adminx.Toast.show('JSON-снимки пересобраны: ' + total + (failures.length ? ', ошибок: ' + failures.length : ''), failures.length ? 'warning' : 'success'); + }) + .catch(function (payload) { + Adminx.Loader.hide(); + Adminx.Toast.show((payload && payload.message) || 'Пакетная пересборка остановлена', 'error'); + }); + }, + + openSnapshot: function () { + if (!this.form) { return; } + var idField = this.field('id'); + var id = idField ? (parseInt(idField.value, 10) || 0) : 0; + if (!id) { return; } + if (Adminx.Drawer) { Adminx.Drawer.open('documentSnapshotDrawer'); } + this.loadSnapshot(id); + }, + + loadSnapshot: function (id) { + var self = this; + var target = document.querySelector('[data-document-snapshot-status]'); + if (target) { target.innerHTML = '
    Состояние
    Загрузка...
    '; } + fetch(this.base() + '/documents/' + id + '/snapshot', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(this.json) + .then(function (payload) { self.renderSnapshot(payload.data || {}); }) + .catch(function (payload) { Adminx.Toast.show((payload && payload.message) || 'Не удалось получить статус снимка', 'error'); }); + }, + + renderSnapshot: function (status) { + var target = document.querySelector('[data-document-snapshot-status]'); + if (!target) { return; } + var state = status.current ? 'актуален' : (status.exists ? 'нужна пересборка' : 'не создан'); + target.innerHTML = '
    Состояние
    ' + state + '
    ' + + '
    Сформирован
    ' + this.esc(status.generated_label || 'ещё не создан') + '
    ' + + '
    Поля
    ' + this.esc(status.fields_count || 0) + '
    ' + + '
    Размер
    ' + this.esc(status.size_label || '0 Б') + '
    ' + + '
    Файл
    ' + this.esc(status.path || '') + '
    '; + }, + + rebuildSnapshot: function () { + if (!this.form) { return; } + var idField = this.field('id'); + var id = idField ? (parseInt(idField.value, 10) || 0) : 0; + if (!id) { return; } + var data = new FormData(); + data.append('_csrf', this.csrf()); + var self = this; + this.ajax(this.base() + '/documents/' + id + '/snapshot/rebuild', data, function (payload) { + self.renderSnapshot(payload.data || {}); + Adminx.Toast.show(payload.message || 'JSON-снимок пересобран', 'success'); + }); + }, + + ajax: function (url, data, done, fail) { + Adminx.Loader.show(); + fetch(url, { method: 'POST', body: data, headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin' }) + .then(this.json) + .then(function (payload) { + if (!payload.success) { throw payload; } + if (done) { done(payload); } + }) + .catch(function (payload) { + var handled = fail ? fail(payload || {}) === false : false; + if (handled) { return; } + Adminx.Toast.show((payload && payload.message) || 'Ошибка запроса', 'error'); + }) + .finally(function () { Adminx.Loader.hide(); }); + }, + + showEditConflict: function (payload) { + var data = payload.data || {}; + var reload = function () { window.location.href = data.reload_url || window.location.href; }; + if (window.Adminx && Adminx.Confirm && typeof Adminx.Confirm.open === 'function') { + Adminx.Confirm.open({ + kind: 'warning', + title: 'Документ изменился', + message: 'На сервере уже сохранена более новая версия. Обновите страницу, проверьте изменения и сохраните документ повторно.', + confirmLabel: 'Обновить страницу', + cancelLabel: 'Остаться', + onConfirm: reload + }); + return; + } + Adminx.Toast.show(payload.message || 'Документ уже изменён. Обновите страницу.', 'warning'); + }, + + json: function (res) { + return res.text().then(function (raw) { + var payload = {}; + try { + payload = raw ? JSON.parse(raw) : {}; + } catch (e) { + payload = { + success: false, + message: res.status >= 500 + ? 'Внутренняя ошибка сервера. Обновите страницу и повторите попытку.' + : 'Сервер вернул некорректный ответ.' + }; + } + if (!res.ok || payload.success === false) { throw payload; } + return payload; + }); + }, + + csrf: function () { + var field = this.form ? this.field('_csrf') : document.querySelector('[data-documents-csrf]'); + return field ? field.value : ''; + }, + + issueApiToken: function (form) { + if (!form || form.getAttribute('aria-busy') === 'true') { return; } + var scopes = form.querySelectorAll('[name="scopes[]"]:checked'); + if (!scopes.length) { + Adminx.Toast.show('Выберите хотя бы одно разрешение', 'warning'); + return; + } + var self = this; + var submit = form.querySelector('[data-api-token-submit]'); + form.setAttribute('aria-busy', 'true'); + if (submit) { submit.disabled = true; } + var data = new FormData(form); + data.append('_csrf', this.csrf()); + this.ajax((form.getAttribute('data-base') || this.base()) + '/documents/api/tokens', data, function (payload) { + var secret = form.querySelector('[data-api-token-secret]'); + var fields = form.querySelector('[data-api-token-fields]'); + var value = form.querySelector('[data-api-token-value]'); + var subtitle = document.querySelector('[data-api-token-subtitle]'); + if (value) { value.value = payload.data && payload.data.token ? payload.data.token : ''; } + if (fields) { fields.hidden = true; } + if (secret) { secret.hidden = false; } + if (submit) { submit.hidden = true; } + if (subtitle) { subtitle.textContent = 'Секрет готов к копированию.'; } + form.removeAttribute('aria-busy'); + form.closest('.drawer').setAttribute('data-token-created', '1'); + Adminx.Toast.show(payload.message || 'API-токен создан', 'success'); + window.setTimeout(function () { if (value) { value.focus(); value.select(); } }, 30); + }, function () { + form.removeAttribute('aria-busy'); + if (submit) { submit.disabled = false; } + }); + }, + + copyApiToken: function () { + var value = document.querySelector('[data-api-token-value]'); + var token = value ? value.value : ''; + if (!token) { return; } + var label = document.querySelector('[data-api-token-copy-label]'); + var done = function () { + if (label) { label.textContent = 'Скопировано'; } + Adminx.Toast.show('Токен скопирован', 'success'); + window.setTimeout(function () { if (label) { label.textContent = 'Копировать'; } }, 1600); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(token).then(done).catch(function () { + value.focus(); value.select(); document.execCommand('copy'); done(); + }); + return; + } + value.focus(); value.select(); document.execCommand('copy'); done(); + }, + + revokeApiToken: function (row) { + if (!row) { return; } + var self = this; + var id = parseInt(row.getAttribute('data-id'), 10) || 0; + if (!id) { return; } + Adminx.Confirm.open({ + kind: 'danger', + title: 'Отозвать API-токен?', + message: 'Интеграция сразу потеряет доступ. Вернуть этот токен будет невозможно.', + confirmLabel: 'Отозвать', + onConfirm: function () { + var data = new FormData(); + data.append('_csrf', self.csrf()); + self.ajax(self.base() + '/documents/api/tokens/' + id + '/revoke', data, function (payload) { + var state = row.querySelector('[data-api-token-state]'); + var button = row.querySelector('[data-api-token-revoke]'); + if (state) { state.className = 'badge badge-gray'; state.textContent = 'Отозван'; } + if (button) { button.remove(); } + row.classList.add('is-revoked'); + Adminx.Toast.show(payload.message || 'API-токен отозван', 'success'); + }); + } + }); + }, + + resetApiTokenForm: function () { + var form = document.querySelector('[data-api-token-form]'); + if (!form) { return; } + form.reset(); + form.removeAttribute('aria-busy'); + var fields = form.querySelector('[data-api-token-fields]'); + var secret = form.querySelector('[data-api-token-secret]'); + var value = form.querySelector('[data-api-token-value]'); + var submit = form.querySelector('[data-api-token-submit]'); + var subtitle = document.querySelector('[data-api-token-subtitle]'); + if (fields) { fields.hidden = false; } + if (secret) { secret.hidden = true; } + if (value) { value.value = ''; } + if (submit) { submit.hidden = false; submit.disabled = false; } + if (subtitle) { subtitle.textContent = 'Права доступа для одной внешней интеграции.'; } + }, + + clearErrors: function () { + if (!this.form) { return; } + this.form.querySelectorAll('.field-error').forEach(function (el) { el.textContent = ''; }); + this.form.querySelectorAll('.is-invalid').forEach(function (el) { el.classList.remove('is-invalid'); }); + this.errorTargets = []; + var summary = this.form.querySelector('[data-document-error-summary]'); + var list = summary ? summary.querySelector('[data-document-error-list]') : null; + if (list) { list.innerHTML = ''; } + if (summary) { summary.hidden = true; } + }, + + showErrors: function (errors) { + var self = this; + var jumpPanel = null; + var summaryItems = []; + Object.keys(errors || {}).forEach(function (key) { + var fieldMatch = key.match(/^fields(?:\[(\d+)\]|\.(\d+))$/); + var target = null; + var label = key; + if (fieldMatch) { + var fieldId = fieldMatch[1] || fieldMatch[2]; + var container = self.form.querySelector('.ax-document-field[data-rubric-field-id="' + fieldId + '"]'); + if (container) { + container.classList.add('is-invalid'); + target = container; + var fieldLabel = container.querySelector('.ax-label'); + if (fieldLabel) { label = fieldLabel.textContent.replace('*', '').trim(); } + var err = container.querySelector('[data-field-value-error]'); + if (!err) { + err = document.createElement('div'); + err.className = 'field-error'; + err.setAttribute('data-field-value-error', ''); + (container.querySelector('.ax-document-field-body') || container).appendChild(err); + } + err.textContent = errors[key]; + var panel = container.closest('[data-document-field-panel]'); + if (panel && jumpPanel === null) { jumpPanel = panel.getAttribute('data-document-field-panel'); } + } + if (target) { summaryItems.push({ key: key, label: label, message: String(errors[key]), target: target }); } + return; + } + var error = self.form.querySelector('[data-error="' + key + '"]'); + var field = self.field(key); + if (error) { error.textContent = errors[key]; } + if (field) { field.classList.add('is-invalid'); } + target = field || error; + if (field) { + var fieldRoot = field.closest('.field'); + var directLabel = fieldRoot ? fieldRoot.querySelector('.field-label') : null; + if (directLabel) { label = directLabel.textContent.replace(/\d+\s*\/\s*\d+$/, '').trim(); } + } + if (target) { summaryItems.push({ key: key, label: label, message: String(errors[key]), target: target }); } + }); + this.renderErrorSummary(summaryItems); + // если ошибка в поле скрытой группы — переключаемся на её вкладку + if (jumpPanel !== null) { self.activateFieldGroup(jumpPanel); } + var firstInvalid = self.form.querySelector('.is-invalid'); + if (firstInvalid) { + window.setTimeout(function () { + firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' }); + var input = firstInvalid.matches('input,select,textarea') ? firstInvalid : firstInvalid.querySelector('input,select,textarea,[contenteditable="true"]'); + if (input && typeof input.focus === 'function') { input.focus({ preventScroll: true }); } + }, 80); + } + }, + + renderErrorSummary: function (items) { + var summary = this.form ? this.form.querySelector('[data-document-error-summary]') : null; + var list = summary ? summary.querySelector('[data-document-error-list]') : null; + var title = summary ? summary.querySelector('[data-document-error-title]') : null; + if (!summary || !list || !items.length) { return; } + this.errorTargets = items.map(function (item) { return item.target; }); + list.innerHTML = ''; + items.forEach(function (item, index) { + var button = document.createElement('button'); + button.type = 'button'; + button.className = 'documents-error-jump'; + button.setAttribute('data-document-error-jump', String(index)); + var name = document.createElement('b'); + name.textContent = item.label; + var message = document.createElement('span'); + message.textContent = item.message; + var icon = document.createElement('i'); + icon.className = 'ti ti-arrow-up-right'; + button.appendChild(name); + button.appendChild(message); + button.appendChild(icon); + list.appendChild(button); + }); + if (title) { title.textContent = 'Найдено ошибок: ' + items.length; } + summary.hidden = false; + summary.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, + + jumpToError: function (index) { + var target = this.errorTargets[parseInt(index, 10) || 0]; + if (!target) { return; } + var level = target.closest('[data-editor-level]'); + if (level) { + var required = level.getAttribute('data-editor-level'); + if (required === 'advanced') { this.setEditorMode('advanced', true); } + else if (required === 'normal' && this.editorMode === 'quick') { this.setEditorMode('normal', true); } + } + var panel = target.closest('[data-document-field-panel]'); + if (panel) { this.activateFieldGroup(panel.getAttribute('data-document-field-panel')); } + window.setTimeout(function () { + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + var input = target.matches('input,select,textarea') ? target : target.querySelector('input,select,textarea,[contenteditable="true"]'); + if (input && typeof input.focus === 'function') { input.focus({ preventScroll: true }); } + }, 90); + }, + + scheduleAliasCheck: function () { + var self = this; + clearTimeout(this.aliasTimer); + this.setAliasState('', 'Проверка...'); + this.aliasTimer = setTimeout(function () { self.checkAlias(false); }, 350); + }, + + checkAlias: function (required, done) { + if (!this.form) { if (done) { done(true); } return; } + var alias = this.field('document_alias').value.trim(); + var id = this.field('id').value || 0; + var self = this; + if (!alias) { + this.setAliasState('empty', 'Alias можно оставить пустым'); + if (done) { done(true); } + return; + } + fetch(this.base() + '/documents/alias-check?alias=' + encodeURIComponent(alias) + '&id=' + encodeURIComponent(id), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(this.json) + .then(function (payload) { + var data = payload.data || {}; + self.setAliasState(data.state || (data.ok ? 'ok' : 'error'), data.message || payload.message || ''); + if (done) { done(data.ok !== false); } + }) + .catch(function () { + self.setAliasState('error', 'Не удалось проверить alias'); + if (done) { done(!required); } + }); + }, + + setAliasState: function (state, message) { + var el = document.querySelector('[data-document-alias-state]'); + this.aliasState = state || ''; + if (!el) { return; } + el.textContent = message || ''; + el.classList.toggle('is-ok', state === 'ok'); + el.classList.toggle('is-error', state === 'error'); + }, + + scheduleShortAliasCheck: function () { + var self = this; + clearTimeout(this.shortAliasTimer); + this.setShortAliasState('', 'Проверка...'); + this.shortAliasTimer = setTimeout(function () { self.checkShortAlias(false); }, 350); + }, + + checkShortAlias: function (required, done) { + if (!this.form) { if (done) { done(true); } return; } + var input = this.field('document_short_alias'); + var alias = input ? input.value.trim() : ''; + var id = this.field('id').value || 0; + var self = this; + if (!alias) { + this.setShortAliasState('empty', 'До 10 символов, необязательно.'); + if (done) { done(true); } + return; + } + + fetch(this.base() + '/documents/alias-check?kind=short&alias=' + encodeURIComponent(alias) + '&id=' + encodeURIComponent(id), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(this.json) + .then(function (payload) { + var data = payload.data || {}; + self.setShortAliasState(data.state || (data.ok ? 'ok' : 'error'), data.message || payload.message || ''); + if (done) { done(data.ok !== false); } + }) + .catch(function () { + self.setShortAliasState('error', 'Не удалось проверить короткий alias'); + if (done) { done(!required); } + }); + }, + + setShortAliasState: function (state, message) { + var el = document.querySelector('[data-document-short-alias-state]'); + if (!el) { return; } + el.textContent = message || ''; + el.classList.toggle('is-ok', state === 'ok'); + el.classList.toggle('is-error', state === 'error'); + }, + + slugify: function (value) { + var map = { + 'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'e','ж':'zh','з':'z','и':'i', + 'й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r','с':'s','т':'t', + 'у':'u','ф':'f','х':'h','ц':'c','ч':'ch','ш':'sh','щ':'sch','ъ':'','ы':'y','ь':'', + 'э':'e','ю':'yu','я':'ya' + }; + return String(value || '').toLowerCase().replace(/[а-яё]/g, function (ch) { return map[ch] !== undefined ? map[ch] : ch; }) + .replace(/[^a-z0-9]+/g, '-').replace(/-{2,}/g, '-').replace(/^-+|-+$/g, ''); + }, + + rubricAliasTemplate: function () { + var select = this.field('rubric_id'); + if (!select || !select.options || select.selectedIndex < 0) { return ''; } + return String(select.options[select.selectedIndex].getAttribute('data-alias-template') || '').replace(/^\/+|\/+$/g, ''); + }, + + expandAliasTemplate: function (pattern) { + var published = this.field('document_published'); + var value = published && published.value ? new Date(published.value) : new Date(); + if (isNaN(value.getTime())) { value = new Date(); } + var pad = function (part) { return String(part).padStart(2, '0'); }; + var replacements = { + '%d': pad(value.getDate()), '%m': pad(value.getMonth() + 1), '%Y': String(value.getFullYear()), + '%y': String(value.getFullYear()).slice(-2), '%H': pad(value.getHours()), '%M': pad(value.getMinutes()), '%S': pad(value.getSeconds()) + }; + pattern = String(pattern || ''); + Object.keys(replacements).forEach(function (token) { pattern = pattern.split(token).join(replacements[token]); }); + return pattern.replace(/^\/+|\/+$/g, ''); + }, + + composeRubricAlias: function (leaf) { + leaf = String(leaf || '').replace(/^\/+|\/+$/g, ''); + var prefix = this.expandAliasTemplate(this.rubricAliasTemplate()); + return prefix ? prefix + '/' + leaf : leaf; + }, + + updateAliasTemplateHint: function () { + var hint = document.querySelector('[data-document-alias-template-hint]'); + if (!hint) { return; } + var pattern = this.rubricAliasTemplate(); + hint.textContent = pattern + ? 'Шаблон рубрики: /' + pattern + '/ + alias документа. Дата берётся из публикации.' + : 'У рубрики нет шаблона пути: alias документа используется от корня сайта.'; + }, + + syncSlugFromTitle: function () { + if (this.aliasTouched) { return; } + var title = this.field('document_title'); + var alias = this.field('document_alias'); + if (!title || !alias) { return; } + var leaf = this.slugify(title.value); + alias.value = this.composeRubricAlias(leaf); + this.updateAliasTemplateHint(); + this.scheduleAliasCheck(); + }, + + generateSlug: function (button) { + var self = this; + var title = this.field('document_title'); + var alias = this.field('document_alias'); + if (!title || !alias) { return; } + var parent = this.field('document_parent'); + var body = new FormData(); + body.append('title', title.value || ''); + body.append('id', button.getAttribute('data-document-id') || '0'); + body.append('parent', parent ? (parent.value || '0') : '0'); + var rubric = this.field('rubric_id'); + var published = this.field('document_published'); + body.append('rubric_id', rubric ? (rubric.value || '0') : '0'); + body.append('published', published ? (published.value || '') : ''); + body.append('_csrf', this.csrf()); + fetch(button.getAttribute('data-slug-generate'), { + method: 'POST', body: body, + headers: { 'Accept': 'application/json', 'X-CSRF-Token': this.csrf() }, + credentials: 'same-origin' + }) + .then(this.json) + .then(function (payload) { + var data = payload.data || {}; + if (data.alias) { + alias.value = data.alias; + self.aliasTouched = true; + self.checkAlias(false); + self.updateSeo(); + } else if (payload.message && window.Adminx && Adminx.toast) { + Adminx.toast(payload.message, 'error'); + } + }) + .catch(function () { + if (window.Adminx && Adminx.toast) { Adminx.toast('Не удалось сгенерировать alias', 'error'); } + }); + }, + + generateShortAlias: function (button) { + var self = this; + var input = this.field('document_short_alias'); + if (!input) { return; } + var body = new FormData(); + body.append('id', button.getAttribute('data-document-id') || '0'); + body.append('_csrf', this.csrf()); + fetch(button.getAttribute('data-short-generate'), { + method: 'POST', body: body, + headers: { 'Accept': 'application/json', 'X-CSRF-Token': this.csrf() }, + credentials: 'same-origin' + }) + .then(this.json) + .then(function (payload) { + var data = payload.data || {}; + if (data.alias) { + input.value = data.alias; + input.dispatchEvent(new Event('input', { bubbles: true })); + } else if (payload.message && window.Adminx && Adminx.toast) { + Adminx.toast(payload.message, 'error'); + } + }) + .catch(function () { + if (window.Adminx && Adminx.toast) { Adminx.toast('Не удалось сгенерировать короткий алиас', 'error'); } + }); + }, + + updateSeo: function () { + if (!this.form) { return; } + var title = (this.field('document_title') || {}).value || ''; + var desc = (this.field('document_meta_description') || {}).value || ''; + var alias = (this.field('document_alias') || {}).value || ''; + this.setSeoCount('title', title.length); + this.setSeoCount('description', desc.length); + var u = document.querySelector('[data-seo-snippet-url]'); if (u) { u.textContent = '/' + alias; } + var t = document.querySelector('[data-seo-snippet-title]'); if (t) { t.textContent = title || 'Заголовок документа'; } + var d = document.querySelector('[data-seo-snippet-desc]'); if (d) { d.textContent = desc || 'Описание появится после заполнения meta description.'; } + }, + + setSeoCount: function (key, len) { + var el = document.querySelector('[data-seo-count="' + key + '"]'); + if (!el) { return; } + var max = parseInt(el.getAttribute('data-seo-max'), 10) || 0; + el.textContent = len + '/' + max; + el.classList.toggle('is-over', max > 0 && len > max); + }, + + openMediaPicker: function (target) { + var self = this; + var input = target ? target.querySelector('[data-document-media-url]') : null; + var list = target ? target.closest('[data-document-media-list], [data-document-media-single]') : null; + var fallbackDir = list ? (list.getAttribute('data-picker-dir') || '/uploads') : '/uploads'; + Adminx.MediaPicker.open({ + type: this.mediaPickerType(target), + dir: this.mediaDirectory(input ? input.value : '', fallbackDir), + title: 'Выбрать файл', + description: 'Путь будет записан в поле документа.', + onPick: function (file) { self.applyPickedMedia(target, file); } + }); + }, + + mediaDirectory: function (value, fallback) { + var path = String(value || '').trim(); + var safeFallback = String(fallback || '/uploads').trim() || '/uploads'; + if (!path) { return safeFallback; } + try { + path = new URL(path, window.location.origin).pathname; + path = decodeURIComponent(path); + } catch (e) { + path = path.split(/[?#]/, 1)[0]; + } + path = path.replace(/\\/g, '/').replace(/\/+/g, '/'); + if (path !== '/uploads' && path.indexOf('/uploads/') !== 0) { return safeFallback; } + var slash = path.lastIndexOf('/'); + return slash > 0 ? (path.slice(0, slash) || '/uploads') : safeFallback; + }, + + applyPickedMedia: function (target, file) { + var input = target ? target.querySelector('[data-document-media-url]') : null; + if (input && file && file.url) { input.value = file.url; input.dispatchEvent(new Event('input', { bubbles: true })); } + }, + + mediaPickerType: function (target) { + var input = target ? target.querySelector('[data-document-picker-type]') : null; + var type = input ? input.getAttribute('data-document-picker-type') : ''; + if (!type && target && target.closest('[data-media-accept]')) { + type = target.closest('[data-media-accept]').getAttribute('data-media-accept') || ''; + } + return type === 'file' || type === 'all' ? type : 'image'; + }, + + addMediaRow: function (list) { + if (!list) { return; } + var fieldId = list.getAttribute('data-field-id') || ''; + var type = list.getAttribute('data-field-type') || 'image_multi'; + var box = list.querySelector('[data-document-media-items]'); + if (!box) { return; } + box.insertAdjacentHTML('beforeend', this.mediaRowHtml(fieldId, type, box.querySelectorAll('[data-document-media-row]').length)); + this.updateMediaListEmpty(list); + this.setDirty(true); + }, + + openMediaUpload: function (list) { + var input = list ? list.querySelector('[data-document-media-files]') : null; + if (input) { input.click(); } + }, + + uploadMediaFiles: function (container, files) { + if (!container || !files || !files.length) { return; } + var data = new FormData(); + data.append('_csrf', this.csrf()); + data.append('dir', container.getAttribute('data-upload-dir') || '/uploads'); + data.append('field_id', container.getAttribute('data-field-id') || '0'); + data.append('document_id', (this.field('id') || {}).value || '0'); + data.append('media_draft_token', (this.field('media_draft_token') || {}).value || ''); + Array.prototype.forEach.call(files, function (file) { data.append('files[]', file); }); + var self = this; + this.ajax(container.getAttribute('data-upload-url') || (this.base() + '/media/upload'), data, function (payload) { + var uploaded = ((payload.data || {}).files) || []; + if (container.matches('[data-document-media-single]')) { + self.applyUploadedSingle(container, uploaded[0] || null); + } else { + self.appendMediaFiles(container, uploaded); + } + Adminx.Toast.show(payload.message || 'Файлы загружены', 'success'); + }); + }, + + applyUploadedSingle: function (container, file) { + var input = container ? container.querySelector('[data-document-media-url]') : null; + var url = this.mediaFileUrl(file); + if (!input || !url) { return; } + input.value = url; + input.dispatchEvent(new Event('input', { bubbles: true })); + }, + + refreshMediaDraft: function (token, documentId) { + var tokenInput = this.field('media_draft_token'); + if (tokenInput) { tokenInput.value = token; } + documentId = parseInt(documentId, 10) || 0; + document.querySelectorAll('[data-document-media-list], [data-document-media-single]').forEach(function (container) { + var fieldId = container.getAttribute('data-field-id') || '0'; + container.setAttribute('data-upload-dir', '/uploads/.drafts/' + token + '/' + fieldId); + var target = container.getAttribute('data-target-dir') || ''; + if (documentId && target.indexOf('{ID}') !== -1) { + target = target.replace(/\{ID\}/g, String(documentId)); + container.setAttribute('data-target-dir', target); + container.setAttribute('data-picker-dir', target); + var targetLabel = container.querySelector('[data-document-media-target]'); + if (targetLabel) { targetLabel.textContent = target; } + } + }); + }, + + importMediaFolder: function (list) { + if (!list) { return; } + var self = this; + Adminx.MediaPicker.open({ + type: list.getAttribute('data-media-accept') || 'image', + dir: list.getAttribute('data-picker-dir') || '/uploads', + title: 'Добавить файлы из папки', + description: 'Откройте нужную папку и подтвердите выбор. В поле добавятся все подходящие файлы из неё.', + folderLabel: 'Добавить из этой папки', + onFolder: function (dir) { self.loadMediaFolder(list, dir); } + }); + }, + + loadMediaFolder: function (list, dir) { + var self = this; + var params = new URLSearchParams(); + params.set('dir', dir || list.getAttribute('data-picker-dir') || '/uploads'); + params.set('type', list.getAttribute('data-media-accept') || 'image'); + params.set('limit', 1000); + fetch(this.base() + '/media/folder-files?' + params.toString(), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(this.json) + .then(function (payload) { + var files = ((payload.data || {}).files) || []; + self.appendMediaFiles(list, files); + Adminx.Toast.show(files.length ? ('Добавлено из папки: ' + files.length) : 'В папке нет подходящих файлов', files.length ? 'success' : 'info'); + }) + .catch(function (payload) { Adminx.Toast.show((payload && payload.message) || 'Не удалось прочитать папку', 'error'); }); + }, + + appendMediaFiles: function (list, files) { + if (!list || !files || !files.length) { return; } + var seen = {}; + list.querySelectorAll('[data-document-media-url]').forEach(function (input) { + var value = String(input.value || '').trim(); + if (value) { seen[value] = true; } + }); + var added = 0; + var self = this; + Array.prototype.forEach.call(files, function (file) { + var url = self.mediaFileUrl(file); + if (!url || seen[url]) { return; } + var row = self.createMediaRow(list); + var input = row ? row.querySelector('[data-media-key="url"]') : null; + if (!input) { return; } + input.value = url; + input.dispatchEvent(new Event('input', { bubbles: true })); + var name = row.querySelector('[data-media-key="name"]'); + if (name && !name.value) { name.value = file.name || url.split('/').pop(); } + seen[url] = true; + added++; + }); + this.updateMediaListEmpty(list); + if (!added && files.length) { Adminx.Toast.show('Эти файлы уже есть в поле', 'info'); } + }, + + createMediaRow: function (list) { + this.addMediaRow(list); + var rows = list.querySelectorAll('[data-document-media-row]'); + return rows.length ? rows[rows.length - 1] : null; + }, + + mediaFileUrl: function (file) { + if (!file) { return ''; } + return String(file.url || file.path || '').trim(); + }, + + removeMediaRow: function (row) { + if (!row) { return; } + var list = row.closest('[data-document-media-list]'); + row.remove(); + this.reindexMediaList(list); + this.updateMediaListEmpty(list); + this.setDirty(true); + }, + + moveMediaRow: function (row, direction) { + if (!row) { return; } + var list = row.closest('[data-document-media-list]'); + var moved = false; + if (direction < 0 && row.previousElementSibling) { + row.parentNode.insertBefore(row, row.previousElementSibling); + moved = true; + } + if (direction > 0 && row.nextElementSibling) { + row.parentNode.insertBefore(row.nextElementSibling, row); + moved = true; + } + this.reindexMediaList(list); + if (moved) { this.setDirty(true); } + }, + + clearMediaRows: function (list) { + if (!list) { return; } + var self = this; + var clear = function () { + var box = list.querySelector('[data-document-media-items]'); + if (box) { box.innerHTML = ''; } + self.updateMediaListEmpty(list); + self.setDirty(true); + }; + if (Adminx.Confirm) { + Adminx.Confirm.open({ + kind: 'danger', + title: 'Очистить поле?', + message: 'Все элементы этого поля будут удалены из документа после сохранения.', + confirmLabel: 'Очистить', + onConfirm: clear + }); + return; + } + if (confirm('Очистить все элементы поля?')) { clear(); } + }, + + updateMediaPreview: function (input) { + var key = input.getAttribute('data-media-key'); + if (key && key !== 'url') { return; } + var scope = input.closest('[data-document-media-row]') || input.closest('.documents-single-media-field'); + if (!scope) { return; } + var url = String(input.value || '').trim(); + var isDoc = scope.classList.contains('documents-media-row-doc_files') || (scope.getAttribute('data-media-accept') === 'all'); + + var preview = scope.querySelector('.documents-media-thumb, .documents-media-preview'); + if (preview) { + if (url && /\.(jpe?g|png|gif|webp|bmp|svg)(\?.*)?$/i.test(url)) { + preview.innerHTML = ''; + } else { + preview.innerHTML = ''; + } + } + + var path = scope.querySelector('[data-media-path]'); + if (path) { + path.innerHTML = url + ? '' + this.esc(url) + '' + : '' + (isDoc ? 'Файл не выбран' : 'Изображение не выбрано') + ''; + } + }, + + updateMediaListEmpty: function (list) { + if (!list) { return; } + var empty = list.querySelector('[data-document-media-empty]'); + var count = list.querySelectorAll('[data-document-media-row]').length; + if (empty) { empty.hidden = count > 0; } + this.reindexMediaList(list); + }, + + reindexMediaLists: function () { + var self = this; + if (!this.form) { return; } + this.form.querySelectorAll('[data-document-media-list]').forEach(function (list) { self.reindexMediaList(list); }); + }, + + reindexMediaList: function (list) { + if (!list) { return; } + var fieldId = list.getAttribute('data-field-id') || ''; + list.querySelectorAll('[data-document-media-row]').forEach(function (row, index) { + row.querySelectorAll('[data-media-key]').forEach(function (input) { + input.name = 'fields[' + fieldId + '][items][' + index + '][' + input.getAttribute('data-media-key') + ']'; + }); + }); + }, + + // ---- drag-and-drop сортировка строк медиа/значений ---- + rowDragStart: function (e) { + var handle = e.target.closest('[data-doc-drag]'); + if (!handle) { return; } + var row = handle.closest('[data-document-media-row], [data-document-value-row]'); + if (!row) { return; } + this.dragRow = row; + this.dragContainer = row.parentNode; + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', ''); } catch (err) {} + setTimeout(function () { row.classList.add('documents-row-dragging'); }, 0); + }, + + rowDragOver: function (e) { + if (!this.dragRow) { return; } + var over = e.target.closest('[data-document-media-row], [data-document-value-row]'); + if (!over || over === this.dragRow || over.parentNode !== this.dragContainer) { return; } + e.preventDefault(); + var rect = over.getBoundingClientRect(); + var after; + if (Math.abs(e.clientY - (rect.top + rect.height / 2)) > rect.height / 2) { + after = e.clientY > rect.top + rect.height / 2; + } else { + after = e.clientX > rect.left + rect.width / 2; + } + this.dragContainer.insertBefore(this.dragRow, after ? over.nextSibling : over); + }, + + rowDragEnd: function () { + if (!this.dragRow) { return; } + this.dragRow.classList.remove('documents-row-dragging'); + var mediaList = this.dragRow.closest('[data-document-media-list]'); + var valueList = this.dragRow.closest('[data-document-value-list]'); + this.dragRow = null; + this.dragContainer = null; + if (mediaList) { this.reindexMediaList(mediaList); } + if (valueList) { this.reindexValueList(valueList); } + this.setDirty(true); + }, + + mediaRowHtml: function (fieldId, type, index) { + var prefix = 'fields[' + this.esc(fieldId) + '][items][' + this.esc(index) + ']'; + var isImage = type !== 'doc_files'; + var thumb = ''; + var path = '
    ' + (isImage ? 'Изображение не выбрано' : 'Файл не выбран') + '
    '; + var actions = '
    ' + + '' + + '' + + '' + + '' + + '
    '; + var picker = function (name, placeholder, icon) { + var pickerType = name === 'link' || type === 'doc_files' ? 'all' : 'image'; + return '
    '; + }; + var linkField = function () { + return ''; + }; + var fields; + if (type === 'doc_files') { + fields = '' + + '' + + picker('url', '/uploads/file.pdf', 'paperclip'); + } else if (type === 'image_mega') { + fields = picker('url', '/uploads/image.jpg', 'photo-plus') + + '' + + '' + + linkField(); + } else { + fields = picker('url', '/uploads/image.jpg', 'photo-plus') + + ''; + } + return '
    ' + + thumb + + '
    ' + + path + + '
    ' + fields + '
    ' + + actions + + '
    '; + }, + + addValueRow: function (list) { + if (!list) { return; } + var fieldId = list.getAttribute('data-field-id') || ''; + var kind = list.getAttribute('data-list-kind') || 'list_pair'; + var type = list.getAttribute('data-field-type') || ''; + var dimensionUnit = list.getAttribute('data-dimension-unit') || 'см'; + var weightUnit = list.getAttribute('data-weight-unit') || 'кг'; + var box = list.querySelector('[data-document-value-items]'); + if (!box) { return; } + box.insertAdjacentHTML('beforeend', this.valueRowHtml(fieldId, kind, type, box.querySelectorAll('[data-document-value-row]').length, dimensionUnit, weightUnit)); + this.updateValueListEmpty(list); + this.setDirty(true); + }, + + removeValueRow: function (row) { + if (!row) { return; } + var list = row.closest('[data-document-value-list]'); + row.remove(); + this.reindexValueList(list); + this.updateValueListEmpty(list); + this.setDirty(true); + }, + + moveValueRow: function (row, direction) { + if (!row) { return; } + var list = row.closest('[data-document-value-list]'); + var moved = false; + if (direction < 0 && row.previousElementSibling) { + row.parentNode.insertBefore(row, row.previousElementSibling); + moved = true; + } + if (direction > 0 && row.nextElementSibling) { + row.parentNode.insertBefore(row.nextElementSibling, row); + moved = true; + } + this.reindexValueList(list); + if (moved) { this.setDirty(true); } + }, + + clearValueRows: function (list) { + if (!list) { return; } + var self = this; + var clear = function () { + var box = list.querySelector('[data-document-value-items]'); + if (box) { box.innerHTML = ''; } + self.updateValueListEmpty(list); + self.setDirty(true); + }; + if (Adminx.Confirm) { + Adminx.Confirm.open({ + kind: 'danger', + title: 'Очистить поле?', + message: 'Все строки этого поля будут удалены из документа после сохранения.', + confirmLabel: 'Очистить', + onConfirm: clear + }); + return; + } + if (confirm('Очистить все строки поля?')) { clear(); } + }, + + updateValueListEmpty: function (list) { + if (!list) { return; } + var empty = list.querySelector('[data-document-value-empty]'); + var count = list.querySelectorAll('[data-document-value-row]').length; + if (empty) { empty.hidden = count > 0; } + this.reindexValueList(list); + }, + + reindexValueLists: function () { + var self = this; + if (!this.form) { return; } + this.form.querySelectorAll('[data-document-value-list]').forEach(function (list) { self.reindexValueList(list); }); + }, + + reindexValueList: function (list) { + if (!list) { return; } + var fieldId = list.getAttribute('data-field-id') || ''; + list.querySelectorAll('[data-document-value-row]').forEach(function (row, index) { + row.querySelectorAll('[data-value-key]').forEach(function (input) { + input.name = 'fields[' + fieldId + '][items][' + index + '][' + input.getAttribute('data-value-key') + ']'; + }); + }); + }, + + valueRowHtml: function (fieldId, kind, type, index, dimensionUnit, weightUnit) { + var prefix = 'fields[' + this.esc(fieldId) + '][items][' + this.esc(index) + ']'; + var tools = '
    ' + + '' + + '' + + '' + + '
    '; + var remove = ''; + var input = function (name, placeholder) { + return ''; + }; + var fields = ''; + if (kind === 'list_single') { + fields = input('value', 'Значение'); + } else if (kind === 'packages') { + fields = input('length', 'Длина, ' + (dimensionUnit || 'см')) + + input('width', 'Ширина, ' + (dimensionUnit || 'см')) + + input('height', 'Высота, ' + (dimensionUnit || 'см')) + + input('weight', 'Вес, ' + (weightUnit || 'кг')); + } else if (kind === 'list_triple') { + fields = input('param', type === 'multi_list_triple' ? 'Колонка 1' : 'Заголовок') + + input('value', type === 'multi_list_triple' ? 'Колонка 2' : 'Значение') + + input('value2', type === 'multi_list_triple' ? 'Колонка 3' : 'Дополнительно'); + } else { + fields = input('param', type === 'multi_links' ? 'Название' : (type === 'teasers' ? 'Заголовок' : 'Параметр')) + + input('value', type === 'multi_links' ? 'URL' : (type === 'teasers' ? 'ID тизера / ссылка' : 'Значение')); + } + return '
    ' + + tools + '
    ' + fields + '
    ' + remove + '
    '; + }, + + openRelationPicker: function (target, opts) { + var self = this; + opts = opts || {}; + var subtitle = opts.insert === 'linkAlias' + ? 'В поле-ссылку будет записан адрес документа.' + : 'В поле будет записан ID документа.'; + var overlay = document.createElement('div'); + overlay.className = 'overlay documents-picker-overlay'; + overlay.innerHTML = ''; + document.body.appendChild(overlay); + requestAnimationFrame(function () { overlay.classList.add('show'); }); + this.bindRelationPicker(overlay, target, opts); + }, + + bindRelationPicker: function (overlay, target, opts) { + var self = this; + opts = opts || {}; + var list = overlay.querySelector('[data-relation-list]'); + var status = overlay.querySelector('[data-relation-status]'); + var search = overlay.querySelector('[data-relation-search]'); + var count = overlay.querySelector('[data-relation-count]'); + var close = function () { + overlay.classList.remove('show'); + setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 160); + }; + var render = function (items) { + list.innerHTML = ''; + (items || []).forEach(function (item) { + list.insertAdjacentHTML('beforeend', ''); + }); + status.hidden = items && items.length > 0; + status.textContent = search.value.trim() ? 'Ничего не найдено' : 'Начните вводить название или ID'; + count.textContent = items && items.length ? 'Документов: ' + items.length : ''; + }; + var rubricEl = target ? (target.querySelector && target.querySelector('[data-document-picker-rubric]')) : null; + if (!rubricEl && target && target.matches && target.matches('[data-document-picker-rubric]')) { rubricEl = target; } + var rubric = rubricEl ? (rubricEl.getAttribute('data-document-picker-rubric') || '') : ''; + var load = function () { + var params = new URLSearchParams(); + params.set('q', search.value.trim()); + params.set('limit', 20); + if (rubric) { params.set('rubric_id', rubric); } + status.textContent = 'Загрузка...'; + status.hidden = false; + fetch(self.base() + '/documents/picker?' + params.toString(), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(self.json) + .then(function (payload) { render((payload.data || {}).items || []); }) + .catch(function () { list.innerHTML = ''; status.textContent = 'Не удалось загрузить документы'; status.hidden = false; }); + }; + var timer = null; + var onKey = function (e) { if (e.key === 'Escape') { close(); } }; + overlay.addEventListener('click', function (e) { + if (e.target === overlay || e.target.closest('[data-relation-close]')) { close(); return; } + var item = e.target.closest('[data-relation-id]'); + if (item) { + if (opts.insert === 'linkAlias' && opts.input) { + var alias = (item.getAttribute('data-relation-alias') || '').replace(/^\/+/, ''); + opts.input.value = alias ? '/' + alias : ''; + opts.input.dispatchEvent(new Event('input', { bubbles: true })); + } else { + var isMultiple = !!(target && target.querySelector('[data-document-relation-list]')); + self.applyPickedRelation( + target, + item.getAttribute('data-relation-id'), + item.getAttribute('data-relation-alias'), + item.querySelector('b') ? item.querySelector('b').textContent : '' + ); + if (isMultiple) { item.classList.add('is-selected'); return; } + } + close(); + } + }); + search.addEventListener('input', function () { + clearTimeout(timer); + timer = setTimeout(load, 220); + }); + document.addEventListener('keydown', onKey); + load(); + search.focus(); + }, + + applyPickedRelation: function (target, id, alias, title) { + var multiple = target ? target.querySelector('[data-document-relation-list]') : null; + if (multiple) { + this.appendPickedRelation(multiple, id, title); + return; + } + var input = target ? (target.querySelector('[data-document-relation-id]') || target.querySelector('[data-document-parent-id]')) : null; + if (input) { input.value = id || ''; input.dispatchEvent(new Event('input', { bubbles: true })); } + // Одиночная связь «Документ из рубрики»: показываем заголовок выбранного документа, а не ID. + var single = target ? target.querySelector('[data-document-relation-single]') : null; + if (single) { this.fillRelationSingle(single, id, title); } + // Выбор родительского документа перестраивает ЧПУ по иерархии: <алиас-родителя>/. + if (input && input.hasAttribute('data-document-parent-id')) { + this.rebuildAliasFromParent(alias); + } + }, + + fillRelationSingle: function (single, id, title) { + if (!single) { return; } + id = String(id || '').replace(/[^0-9]/g, ''); + var titleEl = single.querySelector('[data-relation-title]'); + var idEl = single.querySelector('[data-relation-idlabel]'); + var clear = single.querySelector('[data-document-relation-clear]'); + if (titleEl) { titleEl.textContent = id ? (title || ('Документ #' + id)) : 'Документ не выбран'; } + if (idEl) { idEl.textContent = id ? '#' + id : 'нажмите, чтобы выбрать'; } + single.classList.toggle('is-empty', !id); + if (clear) { clear.hidden = !id; } + }, + + appendPickedRelation: function (list, id, title) { + id = String(id || '').replace(/[^0-9]/g, ''); + if (!list || !id || list.querySelector('[data-document-relation-token][data-relation-id="' + id + '"]')) { return; } + var fieldId = list.getAttribute('data-field-id') || ''; + var tokens = list.querySelector('[data-document-relation-tokens]'); + if (!tokens) { return; } + tokens.insertAdjacentHTML('beforeend', '' + + '' + this.esc(title || ('Документ #' + id)) + '#' + this.esc(id) + '' + + '' + + ''); + var empty = list.querySelector('[data-document-relation-empty]'); + if (empty) { empty.hidden = true; } + this.setDirty(true); + }, + + removePickedRelation: function (token) { + if (!token) { return; } + var list = token.closest('[data-document-relation-list]'); + token.remove(); + var empty = list ? list.querySelector('[data-document-relation-empty]') : null; + if (empty) { empty.hidden = !!list.querySelector('[data-document-relation-token]'); } + this.setDirty(true); + }, + + rebuildAliasFromParent: function (parentAlias) { + var aliasInput = this.field('document_alias'); + if (!aliasInput) { return; } + var current = String(aliasInput.value || '').replace(/^\/+|\/+$/g, ''); + var leaf = current.indexOf('/') >= 0 ? current.split('/').pop() : current; + if (!leaf) { + var title = this.field('document_title'); + leaf = title ? this.slugify(title.value) : ''; + } + var parent = String(parentAlias || '').replace(/^\/+|\/+$/g, ''); + aliasInput.value = parent ? (parent + '/' + leaf) : leaf; + this.aliasTouched = true; + this.checkAlias(false); + this.updateSeo(); + }, + + openRemarks: function () { + if (!this.form) { return; } + var id = parseInt(this.field('id').value, 10) || 0; + if (!id) { return; } + this.currentRemarkDocumentId = id; + var subtitle = document.querySelector('[data-document-remarks-subtitle]'); + if (subtitle) { subtitle.textContent = '#' + id + ' · ' + (this.field('document_title') ? this.field('document_title').value : 'Документ'); } + if (Adminx.Drawer) { Adminx.Drawer.open('documentRemarksDrawer'); } + this.refreshRemarks(); + }, + + refreshRemarks: function () { + if (!this.currentRemarkDocumentId) { return; } + var self = this; + var list = document.querySelector('[data-document-remark-list]'); + if (list) { list.innerHTML = '
    Загрузка...
    '; } + fetch(this.base() + '/documents/' + this.currentRemarkDocumentId + '/remarks', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(this.json) + .then(function (payload) { self.renderRemarks(((payload.data || {}).remarks) || []); }) + .catch(function () { Adminx.Toast.show('Не удалось загрузить заметки', 'error'); }); + }, + + renderRemarks: function (items) { + var list = document.querySelector('[data-document-remark-list]'); + var self = this; + if (!list) { return; } + var badge = document.querySelector('[data-document-remarks-badge]'); + if (badge) { badge.textContent = items.length; } + if (!items.length) { list.innerHTML = '
    Заметок пока нет.
    '; return; } + list.innerHTML = items.map(function (item) { + return '
    ' + + '
    ' + self.esc(item.title || 'Без заголовка') + '' + self.esc(item.published_label || '-') + (item.email ? ' · ' + self.esc(item.email) : '') + '
    ' + + '

    ' + self.esc(item.text || '') + '

    '; + }).join(''); + }, + + submitRemark: function (form) { + if (!this.currentRemarkDocumentId || !form) { return; } + var data = new FormData(form); + data.append('_csrf', this.csrf()); + var self = this; + this.ajax(this.base() + '/documents/' + this.currentRemarkDocumentId + '/remarks', data, function (payload) { + Adminx.Toast.show(payload.message || 'Заметка добавлена', 'success'); + form.reset(); + self.refreshRemarks(); + }); + }, + + deleteRemark: function (id) { + id = parseInt(id, 10) || 0; + if (!id || !this.currentRemarkDocumentId) { return; } + var self = this; + Adminx.Confirm.open({kind: 'danger', title: 'Удалить заметку?', message: 'Заметка будет удалена без восстановления.', confirmLabel: 'Удалить', onConfirm: function () { + var data = new FormData(); data.append('_csrf', self.csrf()); + self.ajax(self.base() + '/documents/' + self.currentRemarkDocumentId + '/remarks/' + id + '/delete', data, function (payload) { Adminx.Toast.show(payload.message || 'Заметка удалена', 'success'); self.refreshRemarks(); }); + }}); + }, + + openAliases: function () { + if (!this.form) { return; } + var id = parseInt(this.field('id').value, 10) || 0; + if (!id) { return; } + this.currentAliasDocumentId = id; + this.resetAliasForm(); + var subtitle = document.querySelector('[data-document-aliases-subtitle]'); + if (subtitle) { subtitle.textContent = '#' + id + ' · ' + (this.field('document_title') ? this.field('document_title').value : 'Документ'); } + if (Adminx.Drawer) { Adminx.Drawer.open('documentAliasesDrawer'); } + this.refreshAliases(); + }, + + refreshAliases: function () { + if (!this.currentAliasDocumentId) { return; } + var self = this; + var list = document.querySelector('[data-document-alias-list]'); + if (list) { list.innerHTML = '
    Загрузка...
    '; } + Adminx.Loader.show(); + fetch(this.base() + '/documents/' + this.currentAliasDocumentId + '/aliases', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(this.json) + .then(function (payload) { + var data = payload.data || {}; + var target = document.querySelector('[data-document-alias-target]'); + var targetAlias = ((data.document || {}).alias) || ''; + if (target) { target.textContent = '/' + targetAlias; target.href = '/' + targetAlias; } + self.renderAliases(data.aliases || []); + }) + .catch(function () { Adminx.Toast.show('Не удалось загрузить историю URL', 'error'); }) + .finally(function () { Adminx.Loader.hide(); }); + }, + + renderAliases: function (items) { + var list = document.querySelector('[data-document-alias-list]'); + var self = this; + if (!list) { return; } + var count = document.querySelector('[data-document-aliases-count]'); + if (count) { count.textContent = items.length; } + if (!items.length) { + list.innerHTML = '
    Старых URL пока нет.
    '; + return; + } + list.innerHTML = items.map(function (item) { + return '
    ' + + '' + + '
    /' + self.esc(item.alias) + '' + self.esc(item.changed_label || '-') + (item.author_name ? ' · ' + self.esc(item.author_name) : '') + '
    ' + + '' + self.esc(item.header || 301) + '' + + '
    ' + + '' + + '' + + '
    '; + }).join(''); + }, + + editAlias: function (button) { + var row = button ? button.closest('.documents-alias-row') : null; + var form = document.querySelector('[data-document-alias-form]'); + if (!row || !form) { return; } + form.elements.id.value = button.getAttribute('data-document-alias-edit') || '0'; + form.elements.alias.value = row.getAttribute('data-alias') || ''; + form.elements.header.value = row.getAttribute('data-header') || '301'; + var title = form.querySelector('[data-document-alias-form-title]'); + if (title) { title.textContent = 'Редактирование редиректа'; } + form.elements.alias.focus(); + }, + + resetAliasForm: function () { + var form = document.querySelector('[data-document-alias-form]'); + if (!form) { return; } + form.reset(); + form.elements.id.value = '0'; + var title = form.querySelector('[data-document-alias-form-title]'); + if (title) { title.textContent = 'Новый редирект'; } + }, + + submitAlias: function (form) { + if (!this.currentAliasDocumentId || !form) { return; } + var historyId = parseInt(form.elements.id.value, 10) || 0; + var data = new FormData(form); + data.append('_csrf', this.csrf()); + var self = this; + this.ajax(this.base() + '/documents/' + this.currentAliasDocumentId + '/aliases' + (historyId ? '/' + historyId : ''), data, function (payload) { + Adminx.Toast.show(payload.message || 'Редирект сохранён', 'success'); + self.resetAliasForm(); + self.refreshAliases(); + }); + }, + + deleteAlias: function (id) { + id = parseInt(id, 10) || 0; + if (!id || !this.currentAliasDocumentId) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить редирект?', + message: 'Старый URL перестанет перенаправлять на документ.', + confirmLabel: 'Удалить', + onConfirm: function () { + var data = new FormData(); + data.append('_csrf', self.csrf()); + self.ajax(self.base() + '/documents/' + self.currentAliasDocumentId + '/aliases/' + id + '/delete', data, function (payload) { + Adminx.Toast.show(payload.message || 'Редирект удалён', 'success'); + self.resetAliasForm(); + self.refreshAliases(); + }); + } + }); + }, + + openRevisions: function () { + if (!this.form) { return; } + var id = parseInt(this.field('id').value, 10) || 0; + if (!id) { return; } + this.currentRevisionDocumentId = id; + this.currentRevisionId = 0; + var title = document.getElementById('documentRevisionsDrawerTitle'); + var subtitle = document.getElementById('documentRevisionsDrawerSubtitle'); + var list = document.querySelector('[data-document-revisions-list]'); + var count = document.querySelector('[data-document-revisions-count]'); + var revisionTitle = document.querySelector('[data-document-revision-title]'); + var revisionMeta = document.querySelector('[data-document-revision-meta]'); + var fields = document.querySelector('[data-document-revision-fields]'); + var restore = document.querySelector('[data-document-revision-restore]'); + var restoreLabel = document.querySelector('[data-document-revision-restore-label]'); + var remove = document.querySelector('[data-document-revision-delete]'); + var publicPreview = document.querySelector('[data-document-revision-preview]'); + var clear = document.querySelector('[data-document-revisions-clear]'); + if (title) { title.textContent = 'Ревизии документа #' + id; } + if (subtitle) { subtitle.textContent = this.field('document_title') ? this.field('document_title').value : 'История значений полей'; } + if (list) { list.innerHTML = '
    Загрузка...
    '; } + if (count) { count.textContent = 'Загрузка...'; } + if (revisionTitle) { revisionTitle.textContent = 'Выберите ревизию'; } + if (revisionMeta) { revisionMeta.textContent = 'Содержимое снимка появится после выбора ревизии.'; } + if (fields) { fields.innerHTML = ''; } + if (restore) { restore.disabled = true; } + if (restoreLabel) { restoreLabel.textContent = 'Восстановить'; } + if (remove) { remove.disabled = true; remove.removeAttribute('data-document-revision-delete'); } + if (publicPreview) { publicPreview.hidden = true; publicPreview.href = '#'; } + if (clear) { clear.disabled = true; } + if (Adminx.Drawer) { Adminx.Drawer.open('documentRevisionsDrawer'); } + this.refreshRevisions(); + }, + + refreshRevisions: function () { + if (!this.currentRevisionDocumentId) { return; } + var self = this; + Adminx.Loader.show(); + fetch(this.base() + '/documents/' + this.currentRevisionDocumentId + '/revisions', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(this.json) + .then(function (payload) { self.renderRevisions(((payload.data || {}).revisions) || []); }) + .catch(function () { Adminx.Toast.show('Не удалось загрузить ревизии', 'error'); }) + .finally(function () { Adminx.Loader.hide(); }); + }, + + renderRevisions: function (items) { + var list = document.querySelector('[data-document-revisions-list]'); + var count = document.querySelector('[data-document-revisions-count]'); + var clear = document.querySelector('[data-document-revisions-clear]'); + var badge = document.querySelector('[data-document-revisions-badge]'); + var self = this; + if (count) { count.textContent = items.length ? (items.length + ' снимков') : 'История пока пустая'; } + if (badge) { badge.textContent = items.length; } + if (clear) { clear.disabled = !items.length; } + if (!list) { return; } + if (!items.length) { + list.innerHTML = '
    Ревизий пока нет. Первый снимок появится после сохранения документа.
    '; + this.showRevisionEmpty(); + return; + } + list.innerHTML = items.map(function (item) { + return '
    ' + + '' + + '
    ' + + 'Ревизия #' + self.esc(item.id) + '' + self.esc(item.format_label || 'Только поля') + '' + self.esc(item.fields_count || 0) + ' полей' + + '
    ' + self.esc(item.created_label || '-') + (item.author_name ? ' · ' + self.esc(item.author_name) : '') + (item.size_label ? ' · ' + self.esc(item.size_label) : '') + '
    ' + + '
    ' + + '' + + '
    '; + }).join(''); + this.loadRevision(items[0].id); + }, + + showRevisionEmpty: function () { + var title = document.querySelector('[data-document-revision-title]'); + var meta = document.querySelector('[data-document-revision-meta]'); + var fields = document.querySelector('[data-document-revision-fields]'); + var restore = document.querySelector('[data-document-revision-restore]'); + var restoreLabel = document.querySelector('[data-document-revision-restore-label]'); + var remove = document.querySelector('[data-document-revision-delete]'); + var publicPreview = document.querySelector('[data-document-revision-preview]'); + if (title) { title.textContent = 'Выберите ревизию'; } + if (meta) { meta.textContent = 'Содержимое снимка появится после выбора ревизии.'; } + if (fields) { fields.innerHTML = ''; } + if (restore) { restore.disabled = true; } + if (restoreLabel) { restoreLabel.textContent = 'Восстановить'; } + if (remove) { remove.disabled = true; remove.removeAttribute('data-document-revision-delete'); } + if (publicPreview) { publicPreview.hidden = true; publicPreview.href = '#'; } + }, + + loadRevision: function (id) { + id = parseInt(id, 10) || 0; + if (!id) { return; } + var self = this; + Adminx.Loader.show(); + fetch(this.base() + '/documents/revisions/' + id, { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(this.json) + .then(function (payload) { self.showRevision(payload.data || {}); }) + .catch(function () { Adminx.Toast.show('Не удалось загрузить ревизию', 'error'); }) + .finally(function () { Adminx.Loader.hide(); }); + }, + + showRevision: function (item) { + var self = this; + this.currentRevisionId = parseInt(item.id, 10) || 0; + document.querySelectorAll('[data-document-revision-open]').forEach(function (row) { + row.classList.toggle('is-active', row.getAttribute('data-document-revision-open') === String(item.id)); + }); + var title = document.querySelector('[data-document-revision-title]'); + var meta = document.querySelector('[data-document-revision-meta]'); + var fields = document.querySelector('[data-document-revision-fields]'); + var restore = document.querySelector('[data-document-revision-restore]'); + var remove = document.querySelector('[data-document-revision-delete]'); + var publicPreview = document.querySelector('[data-document-revision-preview]'); + if (title) { title.textContent = 'Ревизия #' + (item.id || ''); } + if (meta) { meta.textContent = (item.created_label || '-') + (item.author_name ? ' · ' + item.author_name : '') + (item.size_label ? ' · ' + item.size_label : ''); } + if (fields) { + var preview = item.preview || []; + var documentPreview = item.document_preview || []; + var systemHtml = documentPreview.length ? '
    Основные настройки' + self.esc(documentPreview.length) + '
    ' + documentPreview.map(function (field) { + return '
    ' + self.esc(field.title || field.key) + '' + self.esc(field.key || '') + '
    ' + self.esc(field.value_preview || '') + '
    '; + }).join('') + '
    ' : ''; + var fieldsHtml = preview.length ? '
    Поля рубрики' + self.esc(preview.length) + '
    ' + preview.map(function (field) { + return '
    ' + + '
    ' + self.esc(field.title || ('Поле #' + field.field_id)) + '#' + self.esc(field.field_id) + (field.type ? ' · ' + self.esc(field.type) : '') + '' + self.esc(field.size_label || '') + '
    ' + + '
    ' + self.esc(field.value_preview || '') + '
    ' + + '
    '; + }).join('') + '
    ' : '
    В снимке нет значений полей.
    '; + fields.innerHTML = systemHtml + fieldsHtml; + } + this.updateRevisionSelection(); + if (publicPreview) { + publicPreview.hidden = !item.preview_url; + publicPreview.href = item.preview_url || '#'; + } + if (remove) { + remove.disabled = !this.currentRevisionId; + if (this.currentRevisionId) { remove.setAttribute('data-document-revision-delete', String(this.currentRevisionId)); } + } + }, + + toggleRevisionGroup: function (group, checked) { + document.querySelectorAll('[data-document-revision-select="' + group + '"]').forEach(function (input) { + input.checked = !!checked; + }); + this.updateRevisionSelection(); + }, + + revisionSelection: function () { + var all = Array.prototype.slice.call(document.querySelectorAll('[data-document-revision-select]')); + var selected = all.filter(function (input) { return input.checked; }); + return { + all: all.length > 0 && selected.length === all.length, + count: selected.length, + total: all.length, + documentKeys: selected.filter(function (input) { return input.getAttribute('data-document-revision-select') === 'document'; }).map(function (input) { return input.value; }), + fieldIds: selected.filter(function (input) { return input.getAttribute('data-document-revision-select') === 'field'; }).map(function (input) { return parseInt(input.value, 10) || 0; }).filter(Boolean) + }; + }, + + updateRevisionSelection: function () { + var selection = this.revisionSelection(); + document.querySelectorAll('[data-document-revision-group]').forEach(function (group) { + var type = group.getAttribute('data-document-revision-group'); + var items = Array.prototype.slice.call(document.querySelectorAll('[data-document-revision-select="' + type + '"]')); + var checked = items.filter(function (input) { return input.checked; }).length; + group.checked = items.length > 0 && checked === items.length; + group.indeterminate = checked > 0 && checked < items.length; + }); + var restore = document.querySelector('[data-document-revision-restore]'); + var label = document.querySelector('[data-document-revision-restore-label]'); + if (restore) { restore.disabled = !this.currentRevisionId || selection.count === 0; } + if (label) { label.textContent = selection.all ? 'Восстановить всё' : 'Восстановить выбранное · ' + selection.count; } + }, + + deleteRevision: function (id) { + id = parseInt(id || this.currentRevisionId, 10) || 0; + if (!id) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить ревизию?', + message: 'Снимок будет удалён без восстановления.', + confirmLabel: 'Удалить', + onConfirm: function () { + var data = new FormData(); + data.append('_csrf', self.csrf()); + self.ajax(self.base() + '/documents/revisions/' + id + '/delete', data, function (payload) { + Adminx.Toast.show(payload.message || 'Ревизия удалена', 'success'); + self.currentRevisionId = 0; + self.refreshRevisions(); + }); + } + }); + }, + + clearRevisions: function () { + if (!this.currentRevisionDocumentId) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить все ревизии?', + message: 'Будет очищена вся история снимков этого документа.', + confirmLabel: 'Удалить все', + onConfirm: function () { + var data = new FormData(); + data.append('_csrf', self.csrf()); + self.ajax(self.base() + '/documents/' + self.currentRevisionDocumentId + '/revisions/delete', data, function (payload) { + Adminx.Toast.show(payload.message || 'Ревизии удалены', 'success'); + self.currentRevisionId = 0; + self.refreshRevisions(); + }); + } + }); + }, + + restoreRevision: function () { + if (!this.currentRevisionId) { return; } + var self = this; + var id = this.currentRevisionId; + var selection = this.revisionSelection(); + if (!selection.count) { Adminx.Toast.show('Выберите данные для восстановления', 'warning'); return; } + Adminx.Confirm.open({ + kind: 'warning', + title: selection.all ? 'Восстановить ревизию?' : 'Восстановить выбранные данные?', + message: 'Текущее состояние документа будет сохранено отдельной ревизией. Затем восстановится ' + (selection.all ? 'весь выбранный снимок.' : selection.count + ' отмеченных элементов.'), + confirmLabel: 'Восстановить', + onConfirm: function () { + var data = new FormData(); + data.append('_csrf', self.csrf()); + if (!selection.all) { + data.append('selection_mode', 'selected'); + selection.documentKeys.forEach(function (key) { data.append('document_keys[]', key); }); + selection.fieldIds.forEach(function (fieldId) { data.append('field_ids[]', String(fieldId)); }); + } + self.ajax(self.base() + '/documents/revisions/' + id + '/restore', data, function (payload) { + Adminx.Toast.show(payload.message || 'Документ восстановлен', 'success'); + var redirect = payload.redirect || (self.base() + '/documents/' + self.currentRevisionDocumentId + '/edit'); + window.location.href = redirect; + }); + } + }); + }, + + esc: function (value) { + return String(value == null ? '' : value).replace(/[&<>"']/g, function (ch) { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]; + }); + }, + + setSubtitle: function () {} + }; + + document.addEventListener('DOMContentLoaded', function () { Adminx.Documents.init(); }); +})(window, document); diff --git a/adminx/modules/Documents/assets/redirects.js b/adminx/modules/Documents/assets/redirects.js new file mode 100644 index 0000000..45654d4 --- /dev/null +++ b/adminx/modules/Documents/assets/redirects.js @@ -0,0 +1,188 @@ +(function () { + 'use strict'; + + window.Adminx = window.Adminx || {}; + Adminx.DocumentRedirects = { + filterTimer: null, + filterAbort: null, + filterRequest: 0, + base: function () { return window.ADMINX_BASE || '/adminx'; }, + csrf: function () { var el = document.querySelector('[data-redirect-csrf]'); return el ? el.value : ''; }, + json: function (response) { return response.json().then(function (data) { if (!response.ok || data.success === false) { throw new Error(data.message || 'Ошибка запроса'); } return data; }); }, + esc: function (value) { var div = document.createElement('div'); div.textContent = value == null ? '' : String(value); return div.innerHTML; }, + + init: function () { + var self = this; + document.addEventListener('submit', function (event) { + var filter = event.target.closest('[data-redirect-filter]'); + if (filter) { event.preventDefault(); self.load(filter.action + '?' + new URLSearchParams(new FormData(filter)).toString()); return; } + var form = event.target.closest('[data-redirect-form]'); + if (form) { event.preventDefault(); self.save(form); } + }); + document.addEventListener('change', function (event) { + if (event.target.matches('[data-redirect-filter] select')) { event.target.form.requestSubmit(); } + }); + document.addEventListener('input', function (event) { + if (!event.target.matches('[data-redirect-filter] input[type="search"]')) { return; } + var searchInput = event.target; + clearTimeout(self.filterTimer); + self.filterTimer = setTimeout(function () { if (searchInput.form) { searchInput.form.requestSubmit(); } }, 350); + }); + document.addEventListener('click', function (event) { + var page = event.target.closest('[data-redirect-page]'); + if (page) { event.preventDefault(); self.load(page.href); return; } + if (event.target.closest('[data-redirect-filter-reset]')) { self.load(self.base() + '/documents/redirects'); return; } + var pick = event.target.closest('[data-redirect-document-pick]'); + if (pick) { self.pickDocument(pick.closest('[data-redirect-form]')); return; } + if (event.target.closest('[data-redirect-create]')) { self.open(); return; } + var edit = event.target.closest('[data-redirect-edit]'); + if (edit) { self.open(edit.closest('[data-redirect-row]')); return; } + var remove = event.target.closest('[data-redirect-delete]'); + if (remove) { self.remove(remove.closest('[data-redirect-row]')); return; } + var copy = event.target.closest('[data-copy]'); + if (copy) { navigator.clipboard.writeText(copy.getAttribute('data-copy') || '').then(function () { Adminx.Toast.show('URL скопирован', 'success'); }); } + }); + window.addEventListener('popstate', function () { self.load(location.href, false); }); + }, + + load: function (url, push) { + var self = this; + var requestId = ++this.filterRequest; + var active = document.activeElement; + var focusName = active && active.closest && active.closest('[data-redirect-filter]') ? active.getAttribute('name') : ''; + if (push !== false) { push = true; } + if (this.filterAbort && typeof this.filterAbort.abort === 'function') { this.filterAbort.abort(); } + this.filterAbort = typeof AbortController !== 'undefined' ? new AbortController() : null; + Adminx.Loader.show(); + fetch(url, { headers: { 'Accept': 'text/html' }, credentials: 'same-origin', signal: this.filterAbort ? this.filterAbort.signal : undefined }) + .then(function (response) { if (!response.ok) { throw new Error('Не удалось загрузить список'); } return response.text(); }) + .then(function (html) { + if (requestId !== self.filterRequest) { return; } + var doc = new DOMParser().parseFromString(html, 'text/html'); + var next = doc.querySelector('[data-redirects-content]'); + var current = document.querySelector('[data-redirects-content]'); + if (!next || !current) { throw new Error('Некорректный ответ'); } + current.replaceWith(next); + if (push) { history.pushState({}, '', url); } + if (focusName) { + var nextFocus = document.querySelector('[data-redirect-filter] [name="' + focusName.replace(/"/g, '\\"') + '"]'); + if (nextFocus) { nextFocus.focus(); } + } + }) + .catch(function (error) { if (!error || error.name !== 'AbortError') { Adminx.Toast.show(error.message, 'error'); } }) + .finally(function () { if (requestId === self.filterRequest) { self.filterAbort = null; Adminx.Loader.hide(); } }); + }, + + open: function (row) { + var form = document.querySelector('[data-redirect-form]'); + if (!form) { return; } + form.reset(); + form.elements.id.value = row ? row.getAttribute('data-id') : '0'; + this.setDocument(form, row ? row.getAttribute('data-document-id') : '', row ? row.getAttribute('data-document-title') : ''); + var picker = form.querySelector('[data-redirect-document-pick]'); + if (picker) { picker.disabled = !!row; } + form.elements.alias.value = row ? row.getAttribute('data-alias') : ''; + form.elements.header.value = row ? row.getAttribute('data-header') : '301'; + var title = document.querySelector('[data-redirect-title]'); + var subtitle = document.querySelector('[data-redirect-subtitle]'); + if (title) { title.textContent = row ? 'Редактирование редиректа' : 'Новый редирект'; } + if (subtitle) { subtitle.textContent = row ? ('#' + row.getAttribute('data-document-id') + ' · ' + row.getAttribute('data-document-title')) : 'Старый адрес существующего документа.'; } + Adminx.Drawer.open('redirectDrawer'); + setTimeout(function () { (row ? form.elements.alias : picker).focus(); }, 100); + }, + + setDocument: function (form, id, title, alias) { + if (!form) { return; } + id = parseInt(id, 10) || 0; + form.elements.document_id.value = id || ''; + var titleEl = form.querySelector('[data-redirect-document-title]'); + var metaEl = form.querySelector('[data-redirect-document-meta]'); + if (titleEl) { titleEl.textContent = id ? (title || ('Документ #' + id)) : 'Документ не выбран'; } + if (metaEl) { metaEl.textContent = id ? ('#' + id + (alias ? ' · /' + String(alias).replace(/^\/+/, '') : '')) : 'Нажмите, чтобы найти документ'; } + }, + + pickDocument: function (form) { + if (!form) { return; } + var self = this; + var overlay = document.createElement('div'); + overlay.className = 'overlay documents-picker-overlay'; + overlay.innerHTML = ''; + document.body.appendChild(overlay); + requestAnimationFrame(function () { overlay.classList.add('show'); }); + var search = overlay.querySelector('[data-redirect-picker-search]'); + var list = overlay.querySelector('[data-redirect-picker-list]'); + var status = overlay.querySelector('[data-redirect-picker-status]'); + var count = overlay.querySelector('[data-redirect-picker-count]'); + var timer = null; + var close = function () { + overlay.classList.remove('show'); + setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 160); + }; + var onKey = function (event) { if (event.key === 'Escape') { close(); } }; + var load = function () { + var params = new URLSearchParams(); + params.set('q', search.value.trim()); + params.set('limit', 30); + status.hidden = false; + status.textContent = 'Загрузка...'; + fetch(self.base() + '/documents/picker?' + params.toString(), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(self.json) + .then(function (payload) { + var items = ((payload.data || {}).items) || []; + list.innerHTML = items.map(function (item) { + return ''; + }).join(''); + status.hidden = items.length > 0; + status.textContent = search.value.trim() ? 'Ничего не найдено' : 'Документы не найдены'; + count.textContent = items.length ? 'Показано: ' + items.length : ''; + }) + .catch(function () { list.innerHTML = ''; status.hidden = false; status.textContent = 'Не удалось загрузить документы'; }); + }; + overlay.addEventListener('click', function (event) { + if (event.target === overlay || event.target.closest('[data-redirect-picker-close]')) { close(); return; } + var item = event.target.closest('[data-redirect-picker-id]'); + if (!item) { return; } + self.setDocument(form, item.getAttribute('data-redirect-picker-id'), item.getAttribute('data-title'), item.getAttribute('data-alias')); + close(); + form.elements.alias.focus(); + }); + search.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(load, 220); }); + document.addEventListener('keydown', onKey); + load(); + search.focus(); + }, + + save: function (form) { + var self = this; + var documentId = parseInt(form.elements.document_id.value, 10) || 0; + var id = parseInt(form.elements.id.value, 10) || 0; + if (!documentId) { Adminx.Toast.show('Выберите документ назначения', 'error'); var picker = form.querySelector('[data-redirect-document-pick]'); if (picker) { picker.focus(); } return; } + var data = new FormData(form); data.append('_csrf', this.csrf()); + Adminx.Loader.show(); + fetch(this.base() + '/documents/' + documentId + '/aliases' + (id ? '/' + id : ''), { method: 'POST', body: data, headers: { 'Accept': 'application/json', 'X-CSRF-Token': this.csrf() }, credentials: 'same-origin' }) + .then(this.json) + .then(function (payload) { Adminx.Toast.show(payload.message || 'Редирект сохранён', 'success'); Adminx.Drawer.close('redirectDrawer'); self.load(location.href, false); }) + .catch(function (error) { Adminx.Toast.show(error.message, 'error'); }) + .finally(function () { Adminx.Loader.hide(); }); + }, + + remove: function (row) { + if (!row) { return; } + var self = this; + Adminx.Confirm.open({ kind: 'danger', title: 'Удалить редирект?', message: 'Старый URL перестанет перенаправлять на документ.', confirmLabel: 'Удалить', onConfirm: function () { + var data = new FormData(); data.append('_csrf', self.csrf()); + Adminx.Loader.show(); + fetch(self.base() + '/documents/' + row.getAttribute('data-document-id') + '/aliases/' + row.getAttribute('data-id') + '/delete', { method: 'POST', body: data, headers: { 'Accept': 'application/json', 'X-CSRF-Token': self.csrf() }, credentials: 'same-origin' }) + .then(self.json) + .then(function (payload) { Adminx.Toast.show(payload.message || 'Редирект удалён', 'success'); self.load(location.href, false); }) + .catch(function (error) { Adminx.Toast.show(error.message, 'error'); }) + .finally(function () { Adminx.Loader.hide(); }); + }}); + } + }; + + Adminx.DocumentRedirects.init(); +}()); diff --git a/adminx/modules/Documents/language/en/client.xml b/adminx/modules/Documents/language/en/client.xml new file mode 100644 index 0000000..c0d8925 --- /dev/null +++ b/adminx/modules/Documents/language/en/client.xml @@ -0,0 +1,47 @@ + + + Add more... + New + Add a keyword + Creation preset + Creation preset saved + Failed to load list + new + Clear daily statistics? + Statistics cleared + in the section + All view_count lines will be removed. General document counters will remain unchanged. + doc. + <div class="documents-term-status is-error"><i class="ti ti-alert-circle"></i><span>Failed to load options</span></div> + Delete " + URL copied + There are no saved values yet + Failed to load statistics + Find or add a tag + Delete + No matches + Request Error + Clear + Loading... + Preset deleted + from + Failed to apply filters + Read only + Click to find document + Add " + <div class="documents-term-status"><i class="ti ti-loader-2"></i><span>Looking for matches...</span></div> + preset + Delete preset + Assignment document + Incorrect answer + Find or add a keyword + in + No document selected + New redirect + Add a tag + in + Creation preset saved + Preset deleted + Document # + New + diff --git a/adminx/modules/Documents/language/en/interface.xml b/adminx/modules/Documents/language/en/interface.xml new file mode 100644 index 0000000..f055a43 --- /dev/null +++ b/adminx/modules/Documents/language/en/interface.xml @@ -0,0 +1,600 @@ + + + , changed: + . Add cargo space to get an accurate estimate. + Document ID + ID, name or alias + JSON API Documents + JSON snapshot + JSON document snapshot + Read-through model of the public part. + SEO and search + SEO and tags + Sitemap: priority + Sitemap: frequency + URL and service data are not copied + {{ filters.field ? 'Search by field value' : 'ID, title or alias' }} + {{ item.document_status ? 'Unpublish' : 'Publish' }} + · no URL template + · disabled + · previous value + · deleted + The home page address is assigned to / and does not change. + Active + Article/property + The database remains the source of truth + without alias + Without DB entry + No deadline + will be selected when saving + Fast + Only its SHA-256 hash is stored in the database. The lost key must be revoked and reissued. + Looking for + Average per day + in the current set. + There are no fields in this group + Group option + Options + Product options + Weight in kilograms + Weight, kg + Owner + Temporary + All codes + All document settings + Total redirects + Select action + Select revision + Select everything on the page + Select document + Select document #{{ item.Id }} + Grant only the access that the service needs. + Height in centimeters + Height, cm + Cargo space + No cargo spaces have been added yet + Document field groups + Yes + JSON data + Data to save + data is filled in + No data yet. + End date + Publication date + Dates + Valid until + Length in centimeters + Length, cm + For permanent server integration there are no restrictions. + For this type a universal editor is used + There are no presets for this section yet. They can be created from a saved document. + days + Up to 10 characters, optional. + Add ready-made packaging + Add a note + Add a place + Add redirect + Add fields in the category view settings. + Add fields in the “Categories and Fields” section. + Assignment document + No document selected + Documents + No documents found. + Delivery and packaging + If empty, the document title is used. + Live JSON of the current form. + For the period + There is no data for the period. + Document title + The blank will be available only for the “ + Replace packaging for all options + Notes + Document notes + Record + Writing documents via POST, PUT and PATCH. + Field values + from + Changes saved + Changed + Tools + Tools are available after the first save. + Use a separate token for each integration. + URL History + Address history + URL history + History of basic settings and field values. + Expired + Each old URL should point to an existing document. + As in the section + kg + Access keys for external services and integrations. + When to use this blank + Redirect code + The category code before/after saving is not run so that the preview does not have side effects. + Qty + Quantity + Number of views and documents covered. + Comments on the document + Box 1 + Short alias + Brief description + + Routing + Media and relationships are excluded by default to prevent new documents from accidentally using the files and relationships of the source material. + Media, communications and catalog + places + You can revert changes that were not sent to the server. + On the website + Click "JSON Data" to generate a preview. + Click to find document + Name of the package + The document title, alias, dates, author, GUID and navigation links will always remain blank or default. + The name will help you find and revoke the required key. + Preset name + Title, alias or ID of the category + Local draft found + Old data found: + Find or add a keyword + Find or add a tag + For example, publishing in Zen + For example: News with a source + Set up sets + Search settings + Native characteristics + Not specified + Don't send the token in the URL + Not connected + Don't save + optional + No + no plugin + Never + New note + new + New API token + New test circuit. The public card currently uses the old fields. + New creation preset + New redirect + dimensions needed + Usually + Required field + The description will appear after filling out the meta description. + Publish + Basic, fields and publication + Basic group option + Stay + Withdrawn + Revoke + Revoke token + Clear + Clear statistics + Pass it only in the header + Move to cart + Rebuild + Rebuild JSON + Period + By day + The public part will automatically rebuild a damaged or outdated image. + Daily statistics from the current front counter. + No matching categories found. + Search by field value + field + Search field + Search field: everywhere + fields + fields from document # + Full access + Retrieving a JSON document by ID or alias. + fields + Document fields + Popular documents + After selecting a category and saving, its fields will appear here. + Last request + Permanent + Permissions for one external integration. + preset + Creation preset + presets + Fill Presets + Preset applied " + verified + Check document fields + View revision + views + Document Views + Publication + Public Document Model + Navigation point + Empty - top level. + Blank document + Workers + Document Sections + Permissions + Calculate delivery + Extended + Audits + Document revisions + The redirect will lead to the current URL of the selected document. + Redirects + Document redirects + No redirects found. + Register of pages and product cards of the site. + Editor mode + Parent document + Category not found + Self-service announcement for lists, RSS and integrations. If Meta description is empty, it is used as its reserve. + Summary + Generate alias + Generate from title + Generate short alias + Secret key + The secret key is shown only once + Copy JSON + Copy URL + Copy the secret now. After closing the panel it will be impossible to see it again. + Hidden + Changing the category of an existing document will be a separate operation with migration of field values. + First, select a content type, then a blank form or a ready-made preset. + First, create a category. + Pictures + Snapshots of field values + Snippet in search results + Deselect + Remove from publication + The contents of the snapshot will appear after selecting a revision. + Create published + Create a group based on the current product. + Create a separate key for the first integration. + Creating a basic document card. + Creation and update + Create a group of options + Create document + Create a token + Save the filling as a blank + Save preset + Save packaging + Save characteristics + Keep old URLs + List of site documents with quick access to editing. + List of revisions + Validity period + Standard values and category fields + Old document addresses. + Old addresses and documents to which they redirect. + Old URL + Old URL, document or ID + Old document address. + Old address, response code and current destination. + Statistics are not copied and read from the worksheet. + Document pages + Redirect pages + Counter lines + Current destination address + Content Types + Item # + The product is not yet combined with variants + Products + Token created + No tokens yet + Access tokens + Only main and fields + Top for the selected period. + The selected category has no fields + Remove cargo item + Delete preset + Variant management + Template management + Manage Packaging Templates + Physical cargo items for payment by carriers. + The form is filled with saved values. Check them before saving. + Bread crumb + draft + Chernovikov + Reading + Document template + Default category template + Width in centimeters + Width, cm + "><button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Select file" aria-label="Select file"><i class="ti ti- + , errors: + . The window must be left open until completion. + / + document alias. The date is taken from the publication. + 0 B + </p></div><button class="modal-close" type="button" data-relation-close aria-label="Close"><i class="ti ti-x"></i></button></div> + </span><label class="documents-revision-group-check"><input type="checkbox" data-document-revision-group="document" checked><span>All</span></label></div> + </span><label class="documents-revision-group-check"><input type="checkbox" data-document-revision-group="field" checked><span>All</span></label></div> + <article class="documents-revision-field documents-revision-system-field"><div><label class="documents-revision-check" aria-label="Restore + <b>Revision + + + <button class="btn btn-ghost btn-icon btn-sm documents-drag-handle" type="button" data-doc-drag draggable="true" data-tooltip="Drag" aria-label="Drag"><i class="ti ti-grip-vertical"></i></button> + <button class="btn btn-ghost btn-icon btn-sm documents-media-remove" type="button" data-document-media-remove data-tooltip="Delete" aria-label="Delete"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm documents-value-remove" type="button" data-document-value-remove data-tooltip="Delete" aria-label="Delete"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-down data-tooltip="Below" aria-label="Below"><i class="ti ti-arrow-down"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-up data-tooltip="Above" aria-label="Above"><i class="ti ti-arrow-up"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-down data-tooltip="Below" aria-label="Below"><i class="ti ti-arrow-down"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-up data-tooltip="Above" aria-label="Above"><i class="ti ti-arrow-up"></i></button> + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-doc-pick data-tooltip="Select document" aria-label="Select document"><i class="ti ti-file-search"></i></button></div> + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Select file" aria-label="Select file"><i class="ti ti-paperclip"></i></button> + <button class="documents-media-thumb" type="button" data-document-media-pick aria-label="Select file"><span><i class="ti ti- + <div class="documents-term-status is-error"><i class="ti ti-alert-circle"></i><span>Failed to load options</span></div> + <div class="documents-term-status"><i class="ti ti-loader-2"></i><span>Looking for matches...</span></div> + <div class="empty-state">Loading...</div> + <div class="empty-state">There are no revisions yet. The first snapshot will appear after saving the document.</div> + <div class="modal-body"><div class="input-wrap documents-relation-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="ID, title or alias" data-redirect-picker-search></div><div class="documents-picker-status" data-redirect-picker-status>Loading...</div><div class="documents-relation-list" data-redirect-picker-list></div></div> + <div class="modal-body"><div class="input-wrap documents-relation-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="ID, title or alias" data-relation-search></div><div class="documents-picker-status" data-relation-status>Loading...</div><div class="documents-relation-list" data-relation-list></div></div> + <div class="modal-footer"><div class="mf-left documents-picker-count" data-relation-count></div><button class="btn btn-ghost" type="button" data-relation-close>Close</button></div> + <div class="modal-footer"><div class="mf-left" data-redirect-picker-count></div><button class="btn btn-ghost" type="button" data-redirect-picker-close>Close</button></div></div> + <div class="modal-header"><span class="dialog-icon info"><i class="ti ti-file-search"></i></span><div style="flex:1"><h3 id="redirectDocumentPickerTitle">Destination document</h3><p class="text-secondary" style="margin-top:4px">Find document by ID, name or alias.</p></div><button class="modal-close" type="button" data-redirect-picker-close aria-label="Close"><i class="ti ti-x"></i></button></div> + <div class="modal-header"><span class="dialog-icon info"><i class="ti ti-file-search"></i></span><div style="flex:1"><h3>Select document</h3><p class="text-secondary" style="margin-top:4px"> + <div><dt>Fields</dt><dd class="mono"> + <div><dt>Size</dt><dd> + <div><dt>State</dt><dd> + <div><dt>Status</dt><dd>Loading...</dd></div> + <div><dt>Formed by</dt><dd> + <div><dt>File</dt><dd class="mono"> + <div><label class="documents-revision-check" aria-label="Restore + <section class="documents-revision-content"><div class="documents-revision-subhead"><i class="ti ti-forms"></i><b>Category fields</b><span> + <section class="documents-revision-system"><div class="documents-revision-subhead"><i class="ti ti-settings"></i><b>Basic settings</b><span> + <span class="badge badge-amber">needs reassembly</span> + <span class="badge badge-gray">not created</span> + <span class="badge badge-gray">hidden</span> + <span class="badge badge-green">relevant</span> + [description]" data-media-key="description" rows="2" placeholder="Description"></textarea> + [link]" data-media-key="link" value="" placeholder="Link or document" data-document-media-url data-document-picker-type="all"> + [name]" data-media-key="name" value="" placeholder="File name"> + [title]" data-media-key="title" value="" placeholder="Image title"> + Alias is busy + Alias can be left empty + Alias not specified + Alias is free + API token revoked + API token created + catalog_ids not sent + Teaser ID/link + JSON snapshots reassembled: + JSON snapshot rebuilt + URL copied + URL is already taken: + a + Author + b + Without a group + Untitled + in + There are no matching files in the folder + The document ID will be written in the field. + The address of the document will be written into the link field. + Too many items selected in revision + in the section + Enter note text + Weight, + Internal server error. Please refresh the page and try again. + Restore + All view_count lines will be removed. General document counters will remain unchanged. + All lines in this field will be removed from the document after saving. + All elements of this field will be removed from the document after saving. + Select from 1 to 200 documents + Select at least one option or field + Select at least one resolution + Selected data restored from revision + The selected data is not in this revision + Select file + Release and revocation of JSON API document keys. + Height, + g + The main page is a system page and cannot be deleted + d + Data generated without saving + Length + A JSON snapshot is not created for a deleted document + Add " + Add more... + Add from this folder + Add a keyword + Add a tag + Add files from a folder + Added from folder: + doc. + Document + } + } + + + + $field[ + Document + + + The document has been restored + Document restored from revision + The document and associated data are permanently deleted + The document has changed + The document is designated as a 404 page and cannot be deleted + Document not found + Document published + Document marked deleted + The document has been withdrawn from publication + Document created + Document saved + The document has already been modified in another tab or by another user + The document has already been modified in another tab or by another user. + The document has already been changed. Refresh the page. + Documents + Docs: API Tokens + Documents: view + Documents: management + Additionally + Latin characters, numbers, hyphens and underscores are acceptable + Latin, numbers, dot, hyphen, underscore and slash are acceptable + another section + e + not created yet + not saved yet + f + h + Heading + Loading... + Note added + Note not found + Note deleted + Meaning + and + Edit + No image selected + The integration will immediately lose access. It will be impossible to return this token. + History of field values + The story is still empty + th + to + Category code stopped document creation + The category code stopped saving the document + Category code failed after saving due to database error + The category code stopped document creation after saving + The category code stopped saving the document after saving + Column 1 + Column 2 + Column 3 + Content + Copy + A copy of the document has been created + Copy: + l + m + can be left blank + n + A newer version is already saved on the server. Refresh the page, check the changes, and save the document again. + Navigation + click to select + Title + Errors found: + Find section + not specified + Failed to load revisions + Failed to load list + Failed to load statistics + Failed to rebuild JSON snapshot + Failed to get snapshot status + Failed to apply filters + Failed to check alias + Failed to check short alias + Failed to read folder + Failed to generate alias + Failed to generate short alias + Failed to create document + Failed to save document + Insufficient rights + Unknown error + Unknown batch action + Unknown value type + Incorrect alias + Incorrect answer + new + New + o + Refresh page + Published + Published + Open the desired folder and confirm your selection. All suitable files from it will be added to the field. + Cancel + Cancel change + Revoke an API token? + Clear all field lines? + Clear all field elements? + Clear daily statistics? + Clear the field? + Request Error + n + JSON snapshot package processed + Batch rebuild stopped + Batch action completed + Parameter + Rebuild JSON snapshots? + Daily statistics cleared + Position + Search + Field + Field + + + field is not enabled in selected catalog sections + Full shot + Document fields failed validation: + after saving + Document preset not found + Preset not found + Creation preset saved + Preset deleted + Checking... + Check your preset settings + View a list of documents and basic metadata. + The path will be written in the document field. + r + Revisions deleted + Revision not found + Revision deleted + Redirect not found + Redirect saved + Redirect removed + Parent + Category + }).join( + Category + }); + status.hidden = items && items.length > 0; + status.textContent = search.value.trim() ? + with + The secret is ready to be copied. + The server returned an incorrect response. + The system document cannot be copied + Copied + cm + First, move the document to the trash + The pictures will be sequentially reassembled to + pictures + No matches + Creating, editing, deleting and restoring documents. + There are no saved values yet + Statistics cleared + Status + The status of a system document cannot be changed + t + This alias is already used by the document or another redirect + Tags + Token not found or already revoked + Token copied + Fields only + Read only + at + The category does not have a path template: the document alias is used from the root of the site. + Remove + Remove document + Remove section + Document deletion canceled by module + Delete + Delete " + Delete revision + Deleted + Deleted + Deleted document + Please enter a correct alias + Enter the title of the document + f + File not selected + Files uploaded + x + ts + h + Draft + Drafts + w + Template + Category template: / + Width, + sch + ъ + s + b + uh + These files are already in the field + yu + I + e + diff --git a/adminx/modules/Documents/language/en/runtime.xml b/adminx/modules/Documents/language/en/runtime.xml new file mode 100644 index 0000000..265b424 --- /dev/null +++ b/adminx/modules/Documents/language/en/runtime.xml @@ -0,0 +1,175 @@ + + + <button class="btn btn-secondary btn-sm" type="button" data-dropdown data-document-catalog-add><i class="ti ti-plus"></i>Add section</button> + <button class="documents-catalog-token-remove" type="button" data-document-catalog-remove aria-label="Убрать раздел"><i class="ti ti-x"></i></button> + <label class="input-wrap documents-catalog-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="Найти раздел" data-document-catalog-search></label> + <main><h1>Page not found</h1><p>The requested page does not exist or has been moved.</p><p><a href="/">Go to home</a></p></main> + <span class="badge badge-gray">hidden</span> + API token revoked + API token created + Alias is busy + Alias not specified + Alias is free + JSON snapshot rebuilt + Sitemap: priority + Sitemap: frequency + URL is already taken: + catalog_ids not sent + data-document-catalog-empty>No sections selected.</p> + · document # + Author + Article/property + B + Without a group + Looking for + Too many items selected in revision + Enter note text + Select from 1 to 200 documents + Select a category + Select at least one option or field + Selected data restored from revision + The selected data is not in this revision + Release and revocation of JSON API document keys. + The main page is a system page and cannot be deleted + Data generated without saving + End date + Publication date + A JSON snapshot is not created for a deleted document + Document # + The document has been restored + Document restored from revision + The document and associated data are permanently deleted + The document is designated as a 404 page and cannot be deleted + Document not found + Document unavailable + Document published + Document marked deleted + The document has been withdrawn from publication + Document created + Document saved + The document has already been modified in another tab or by another user + The document has already been modified in another tab or by another user. + Documents + Docs: API Tokens + Documents: view + Documents: management + Latin characters, numbers, hyphens and underscores are acceptable + Latin, numbers, dot, hyphen, underscore and slash are acceptable + Note added + Note not found + Note deleted + The requested page was not found. + KB + Category code stopped document creation + The category code stopped saving the document + Category code failed after saving due to database error + The category code stopped document creation after saving + The category code stopped saving the document after saving + Content + A copy of the document has been created + Copy: + Short alias + Brief description + MB + Navigation + Title + Failed to rebuild JSON snapshot + Failed to create document + Failed to save document + Insufficient rights + Unknown error + Unknown batch action + Unknown value type + Incorrect alias + New + Published + Published + Error 404 + JSON snapshot package processed + Batch action completed + Daily statistics cleared + Position + Search + Field # + Full shot + Document fields failed validation: + Document preset not found + Preset not found + Creation preset saved + Preset deleted + Check your preset settings + Check document fields + View a list of documents and basic metadata. + Workers + Revisions deleted + Revision not found + Revision deleted + Redirect not found + Redirect saved + Redirect removed + Parent + The system document cannot be copied + Hidden + First, move the document to the trash + Creating, editing, deleting and restoring documents. + Status + The status of a system document cannot be changed + Page not found + This alias is already used by the document or another redirect + Tags + Token not found or already revoked + Fields only + Document deletion canceled by module + Deleted + Deleted + Deleted document + Please enter a correct alias + Enter the title of the document + Bread crumb + Draft + Drafts + Template + a + b + in + g + d + another section + e + not created yet + not saved yet + f + h + and + th + to + kg + l + m + months + can be left blank + n + not specified + o + n + field is not enabled in selected catalog sections + after saving + r + with + cm + t + at + f + x + ts + h + w + sch + ъ + s + b + uh + yu + I + e + diff --git a/adminx/modules/Documents/language/ru/client.xml b/adminx/modules/Documents/language/ru/client.xml new file mode 100644 index 0000000..9f4a554 --- /dev/null +++ b/adminx/modules/Documents/language/ru/client.xml @@ -0,0 +1,47 @@ + + + Добавить ещё… + Новый + Добавить ключевое слово + Пресет создания + Пресет создания сохранён + Не удалось загрузить список + новое + Очистить подневную статистику? + Статистика очищена + в рубрике + Все строки view_count будут удалены. Общие счётчики документов останутся без изменений. + док. + <div class="documents-term-status is-error"><i class="ti ti-alert-circle"></i><span>Не удалось загрузить варианты</span></div> + Удалить « + URL скопирован + Сохранённых значений пока нет + Не удалось загрузить статистику + Найти или добавить тег + Удалить + Совпадений нет + Ошибка запроса + Очистить + Загрузка... + Пресет удалён + из + Не удалось применить фильтры + Только чтение + Нажмите, чтобы найти документ + Добавить « + <div class="documents-term-status"><i class="ti ti-loader-2"></i><span>Ищем совпадения…</span></div> + пресет + Удалить пресет + Документ назначения + Некорректный ответ + Найти или добавить ключевое слово + в + Документ не выбран + Новый редирект + Добавить тег + в + Пресет создания сохранён + Пресет удалён + Документ # + Новый + diff --git a/adminx/modules/Documents/language/ru/interface.xml b/adminx/modules/Documents/language/ru/interface.xml new file mode 100644 index 0000000..003f43d --- /dev/null +++ b/adminx/modules/Documents/language/ru/interface.xml @@ -0,0 +1,600 @@ + + + , изменён: + . Добавьте грузовое место, чтобы перейти на точный расчёт. + ID документа + ID, название или alias + JSON API документов + JSON-снимок + JSON-снимок документа + Read-through модель публичной части. + SEO и поиск + SEO и теги + Sitemap: приоритет + Sitemap: частота + URL и служебные данные не копируются + {{ filters.field ? 'Поиск по значению поля' : 'ID, название или alias' }} + {{ item.document_status ? 'Снять с публикации' : 'Опубликовать' }} + · без шаблона URL + · отключён + · прежнее значение + · удалён + Адрес главной страницы закреплён за / и не изменяется. + Активен + Артикул / свойство + База данных остаётся источником истины + без alias + Без записи в БД + Без срока + будет выбрана при сохранении + Быстро + В базе хранится только его SHA-256-хеш. Утраченный ключ нужно отозвать и выпустить заново. + В поиске + В среднем за день + в текущем наборе. + В этой группе нет полей + Вариант группы + Вариантам + Варианты товара + Вес в килограммах + Вес, кг + Владелец + Временных + Все коды + Все настройки документа + Всего редиректов + Выберите действие + Выберите ревизию + Выбрать все на странице + Выбрать документ + Выбрать документ #{{ item.Id }} + Выдайте только тот доступ, который нужен сервису. + Высота в сантиметрах + Высота, см + Грузовое место + Грузовые места ещё не добавлены + Группы полей документа + Да + Данные JSON + Данные для сохранения + данные заполнены + Данных пока нет. + Дата окончания + Дата публикации + Даты + Действует до + Длина в сантиметрах + Длина, см + Для постоянной серверной интеграции можно не ограничивать. + Для этого типа используется универсальный редактор + Для этой рубрики пресетов пока нет. Их можно создать из сохранённого документа. + дней + До 10 символов, необязательно. + Добавить готовую упаковку + Добавить заметку + Добавить место + Добавить редирект + Добавьте поля в настройках вида рубрики. + Добавьте поля в разделе «Рубрики и поля». + Документ назначения + Документ не выбран + Документов + Документы не найдены. + Доставка и упаковка + Если пусто, используется название документа. + Живой JSON текущей формы. + За период + За период данных нет. + Заголовок документа + Заготовка будет доступна только для рубрики « + Заменить упаковку у всех вариантов + Заметки + Заметки документа + Запись + Запись документов через POST, PUT и PATCH. + Значения полей + из + Изменения сохранены + Изменён + Инструменты + Инструменты доступны после первого сохранения. + Используйте отдельный токен для каждой интеграции. + История URL + История адресов + История адресов URL + История основных настроек и значений полей. + Истёк + Каждый старый URL должен вести на существующий документ. + Как в рубрике + кг + Ключи доступа для внешних сервисов и интеграций. + Когда использовать эту заготовку + Код редиректа + Код рубрики до/после сохранения не запускается, чтобы preview не имел побочных эффектов. + Кол-во + Количество + Количество просмотров и охваченных документов. + Комментарии к документу + Коробка 1 + Короткий alias + Краткое описание + м³ + Маршрутизация + Медиа и связи по умолчанию исключены, чтобы новые документы случайно не использовали файлы и отношения исходного материала. + Медиа, связи и каталог + мест + Можно вернуть изменения, которые не были отправлены на сервер. + На сайте + Нажмите «Данные JSON», чтобы сформировать preview. + Нажмите, чтобы найти документ + Название грузового места + Название документа, alias, даты, автор, GUID и связи с навигацией всегда останутся пустыми или стандартными. + Название поможет найти и отозвать нужный ключ. + Название пресета + Название, alias или ID рубрики + Найден локальный черновик + Найдены старые данные: + Найти или добавить ключевое слово + Найти или добавить тег + Например, публикация в Дзен + Например: Новость с источником + Настроить наборы + Настройка поиска + Нативные характеристики + Не задано + Не отправляйте токен в URL + Не связан + Не сохранять + необязательно + Нет + нет плагина + Ни разу + Новая заметка + новый + Новый API-токен + Новый контур проверки. Публичная карточка пока использует старые поля. + Новый пресет создания + Новый редирект + нужны габариты + Обычно + Обязательное поле + Описание появится после заполнения meta description. + Опубликовать + Основное, поля и публикация + Основной вариант группы + Остаться + Отозван + Отозвать + Отозвать токен + Очистить + Очистить статистику + Передавайте его только в заголовке + Переместить в корзину + Пересобрать + Пересобрать JSON + Период + По дням + Повреждённый или устаревший снимок публичная часть пересоберёт автоматически. + Подневная статистика из действующего счётчика фронта. + Подходящие рубрики не найдены. + Поиск по значению поля + поле + Поле поиска + Поле поиска: везде + полей + полей из документа # + Полный доступ + Получение JSON документа по ID или alias. + поля + Поля документа + Популярные документы + После выбора рубрики и сохранения здесь появятся её поля. + Последний запрос + Постоянных + Права доступа для одной внешней интеграции. + пресет + Пресет создания + пресетов + Пресеты заполнения + Применён пресет « + проверено + Проверьте поля документа + Просмотр ревизии + просмотров + Просмотры документов + Публикация + Публичная модель документа + Пункт навигации + Пусто — верхний уровень. + Пустой документ + Рабочие + Разделы документов + Разрешения + Рассчитывать доставку + Расширенно + Ревизии + Ревизии документа + Редирект будет вести на текущий URL выбранного документа. + Редиректы + Редиректы документов + Редиректы не найдены. + Реестр страниц и товарных карточек сайта. + Режим редактора + Родительский документ + Рубрика не найдена + Самостоятельный анонс для списков, RSS и интеграций. Если Meta description пуст, используется как его резерв. + Сводка + Сгенерировать alias + Сгенерировать из названия + Сгенерировать короткий алиас + Секретный ключ + Секретный ключ показывается один раз + Скопировать JSON + Скопировать URL + Скопируйте секрет сейчас. После закрытия панели увидеть его снова будет невозможно. + Скрыт + Смена рубрики существующего документа будет отдельной операцией с миграцией значений полей. + Сначала выберите тип контента, затем пустую форму или готовый пресет. + Сначала создайте рубрику. + Снимки + Снимки значений полей + Сниппет в выдаче + Снять выделение + Снять с публикации + Содержимое снимка появится после выбора ревизии. + Создавать опубликованным + Создайте группу на основе текущего товара. + Создайте отдельный ключ для первой интеграции. + Создание базовой карточки документа. + Создание и обновление + Создать группу вариантов + Создать документ + Создать токен + Сохранить заполнение как заготовку + Сохранить пресет + Сохранить упаковку + Сохранить характеристики + Сохранять старые URL + Список документов сайта с быстрым переходом к редактированию. + Список ревизий + Срок действия + Стандартные значения и поля рубрики + Старые адреса документа. + Старые адреса и документы, на которые они перенаправляют. + Старый URL + Старый URL, документ или ID + Старый адрес документа. + Старый адрес, код ответа и текущая цель. + Статистика не копируется и читается из рабочей таблицы. + Страницы документов + Страницы редиректов + Строк счётчика + Текущий адрес назначения + Типы контента + Товар # + Товар пока не объединён с вариантами + Товары + Токен создан + Токенов пока нет + Токены доступа + Только основное и поля + Топ за выбранный период. + У выбранной рубрики нет полей + Удалить грузовое место + Удалить пресет + Управление вариантами + Управление шаблонами + Управление шаблонами упаковки + Физические грузовые места для расчёта перевозчиками. + Форма заполнена сохранёнными значениями. Проверьте их перед сохранением. + Хлебная крошка + черновик + Черновиков + Чтение + Шаблон документа + Шаблон рубрики по умолчанию + Ширина в сантиметрах + Ширина, см + "><button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Выбрать файл" aria-label="Выбрать файл"><i class="ti ti- + , ошибок: + . Окно нужно оставить открытым до завершения. + / + alias документа. Дата берётся из публикации. + 0 Б + </p></div><button class="modal-close" type="button" data-relation-close aria-label="Закрыть"><i class="ti ti-x"></i></button></div> + </span><label class="documents-revision-group-check"><input type="checkbox" data-document-revision-group="document" checked><span>Все</span></label></div> + </span><label class="documents-revision-group-check"><input type="checkbox" data-document-revision-group="field" checked><span>Все</span></label></div> + <article class="documents-revision-field documents-revision-system-field"><div><label class="documents-revision-check" aria-label="Восстановить + <b>Ревизия + + + <button class="btn btn-ghost btn-icon btn-sm documents-drag-handle" type="button" data-doc-drag draggable="true" data-tooltip="Перетащить" aria-label="Перетащить"><i class="ti ti-grip-vertical"></i></button> + <button class="btn btn-ghost btn-icon btn-sm documents-media-remove" type="button" data-document-media-remove data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm documents-value-remove" type="button" data-document-value-remove data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-down data-tooltip="Ниже" aria-label="Ниже"><i class="ti ti-arrow-down"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-up data-tooltip="Выше" aria-label="Выше"><i class="ti ti-arrow-up"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-down data-tooltip="Ниже" aria-label="Ниже"><i class="ti ti-arrow-down"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-up data-tooltip="Выше" aria-label="Выше"><i class="ti ti-arrow-up"></i></button> + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-doc-pick data-tooltip="Выбрать документ" aria-label="Выбрать документ"><i class="ti ti-file-search"></i></button></div> + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Выбрать файл" aria-label="Выбрать файл"><i class="ti ti-paperclip"></i></button> + <button class="documents-media-thumb" type="button" data-document-media-pick aria-label="Выбрать файл"><span><i class="ti ti- + <div class="documents-term-status is-error"><i class="ti ti-alert-circle"></i><span>Не удалось загрузить варианты</span></div> + <div class="documents-term-status"><i class="ti ti-loader-2"></i><span>Ищем совпадения…</span></div> + <div class="empty-state">Загрузка...</div> + <div class="empty-state">Ревизий пока нет. Первый снимок появится после сохранения документа.</div> + <div class="modal-body"><div class="input-wrap documents-relation-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="ID, название или alias" data-redirect-picker-search></div><div class="documents-picker-status" data-redirect-picker-status>Загрузка...</div><div class="documents-relation-list" data-redirect-picker-list></div></div> + <div class="modal-body"><div class="input-wrap documents-relation-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="ID, название или alias" data-relation-search></div><div class="documents-picker-status" data-relation-status>Загрузка...</div><div class="documents-relation-list" data-relation-list></div></div> + <div class="modal-footer"><div class="mf-left documents-picker-count" data-relation-count></div><button class="btn btn-ghost" type="button" data-relation-close>Закрыть</button></div> + <div class="modal-footer"><div class="mf-left" data-redirect-picker-count></div><button class="btn btn-ghost" type="button" data-redirect-picker-close>Закрыть</button></div></div> + <div class="modal-header"><span class="dialog-icon info"><i class="ti ti-file-search"></i></span><div style="flex:1"><h3 id="redirectDocumentPickerTitle">Документ назначения</h3><p class="text-secondary" style="margin-top:4px">Найдите документ по ID, названию или alias.</p></div><button class="modal-close" type="button" data-redirect-picker-close aria-label="Закрыть"><i class="ti ti-x"></i></button></div> + <div class="modal-header"><span class="dialog-icon info"><i class="ti ti-file-search"></i></span><div style="flex:1"><h3>Выбрать документ</h3><p class="text-secondary" style="margin-top:4px"> + <div><dt>Поля</dt><dd class="mono"> + <div><dt>Размер</dt><dd> + <div><dt>Состояние</dt><dd> + <div><dt>Состояние</dt><dd>Загрузка...</dd></div> + <div><dt>Сформирован</dt><dd> + <div><dt>Файл</dt><dd class="mono"> + <div><label class="documents-revision-check" aria-label="Восстановить + <section class="documents-revision-content"><div class="documents-revision-subhead"><i class="ti ti-forms"></i><b>Поля рубрики</b><span> + <section class="documents-revision-system"><div class="documents-revision-subhead"><i class="ti ti-settings"></i><b>Основные настройки</b><span> + <span class="badge badge-amber">нужна пересборка</span> + <span class="badge badge-gray">не создан</span> + <span class="badge badge-gray">скрыт</span> + <span class="badge badge-green">актуален</span> + [description]" data-media-key="description" rows="2" placeholder="Описание"></textarea> + [link]" data-media-key="link" value="" placeholder="Ссылка или документ" data-document-media-url data-document-picker-type="all"> + [name]" data-media-key="name" value="" placeholder="Название файла"> + [title]" data-media-key="title" value="" placeholder="Заголовок изображения"> + Alias занят + Alias можно оставить пустым + Alias не указан + Alias свободен + API-токен отозван + API-токен создан + catalog_ids не переданы + ID тизера / ссылка + JSON-снимки пересобраны: + JSON-снимок пересобран + URL скопирован + URL уже занят: + а + Автор + б + Без группы + Без названия + в + В папке нет подходящих файлов + В поле будет записан ID документа. + В поле-ссылку будет записан адрес документа. + В ревизии выбрано слишком много элементов + в рубрике + Введите текст заметки + Вес, + Внутренняя ошибка сервера. Обновите страницу и повторите попытку. + Восстановить + Все строки view_count будут удалены. Общие счётчики документов останутся без изменений. + Все строки этого поля будут удалены из документа после сохранения. + Все элементы этого поля будут удалены из документа после сохранения. + Выберите от 1 до 200 документов + Выберите хотя бы один параметр или поле + Выберите хотя бы одно разрешение + Выбранные данные восстановлены из ревизии + Выбранных данных нет в этой ревизии + Выбрать файл + Выпуск и отзыв ключей JSON API документов. + Высота, + г + Главная страница является системной и не может быть удалена + д + Данные сформированы без сохранения + Длина, + Для удалённого документа JSON-снимок не создаётся + Добавить « + Добавить ещё… + Добавить из этой папки + Добавить ключевое слово + Добавить тег + Добавить файлы из папки + Добавлено из папки: + док. + Документ + } + } + + + + $field[ + Документ + + + Документ восстановлен + Документ восстановлен из ревизии + Документ и связанные данные удалены окончательно + Документ изменился + Документ назначен страницей 404 и не может быть удалён + Документ не найден + Документ опубликован + Документ помечен удалённым + Документ снят с публикации + Документ создан + Документ сохранён + Документ уже изменён в другой вкладке или другим пользователем + Документ уже изменён в другой вкладке или другим пользователем. + Документ уже изменён. Обновите страницу. + Документы + Документы: API-токены + Документы: просмотр + Документы: управление + Дополнительно + Допустимы латиница, цифры, дефис и подчёркивание + Допустимы латиница, цифры, точка, дефис, подчёркивание и слэш + другой раздел + е + ещё не создан + ещё не сохранён + ж + з + Заголовок + Загрузка... + Заметка добавлена + Заметка не найдена + Заметка удалена + Значение + и + Изменить + Изображение не выбрано + Интеграция сразу потеряет доступ. Вернуть этот токен будет невозможно. + История значений полей + История пока пустая + й + к + Код рубрики остановил создание документа + Код рубрики остановил сохранение документа + Код рубрики после сохранения не выполнен из-за ошибки базы данных + Код рубрики после сохранения остановил создание документа + Код рубрики после сохранения остановил сохранение документа + Колонка 1 + Колонка 2 + Колонка 3 + Контент + Копировать + Копия документа создана + Копия: + л + м + можно оставить пустым + н + На сервере уже сохранена более новая версия. Обновите страницу, проверьте изменения и сохраните документ повторно. + Навигация + нажмите, чтобы выбрать + Название + Найдено ошибок: + Найти раздел + не задано + Не удалось загрузить ревизии + Не удалось загрузить список + Не удалось загрузить статистику + Не удалось пересобрать JSON-снимок + Не удалось получить статус снимка + Не удалось применить фильтры + Не удалось проверить alias + Не удалось проверить короткий alias + Не удалось прочитать папку + Не удалось сгенерировать alias + Не удалось сгенерировать короткий алиас + Не удалось создать документ + Не удалось сохранить документ + Недостаточно прав + Неизвестная ошибка + Неизвестное пакетное действие + Неизвестный тип значений + Некорректный alias + Некорректный ответ + новое + Новый + о + Обновить страницу + Опубликован + Опубликованные + Откройте нужную папку и подтвердите выбор. В поле добавятся все подходящие файлы из неё. + Отменить + Отменить изменение + Отозвать API-токен? + Очистить все строки поля? + Очистить все элементы поля? + Очистить подневную статистику? + Очистить поле? + Ошибка запроса + п + Пакет JSON-снимков обработан + Пакетная пересборка остановлена + Пакетное действие выполнено + Параметр + Пересобрать JSON-снимки? + Подневная статистика очищена + Позиция + Поиск + Поле + Поле + + + поле не включено в выбранных разделах каталога + Полный снимок + Поля документа не прошли проверку: + после сохранения + Пресет документа не найден + Пресет не найден + Пресет создания сохранён + Пресет удалён + Проверка... + Проверьте настройки пресета + Просмотр списка документов и базовых метаданных. + Путь будет записан в поле документа. + р + Ревизии удалены + Ревизия не найдена + Ревизия удалена + Редирект не найден + Редирект сохранён + Редирект удалён + Родитель + Рубрика + }).join( + Рубрика + }); + status.hidden = items && items.length > 0; + status.textContent = search.value.trim() ? + с + Секрет готов к копированию. + Сервер вернул некорректный ответ. + Системный документ копировать нельзя + Скопировано + см + Сначала переместите документ в корзину + Снимки будут последовательно пересобраны для + снимков + Совпадений нет + Создание, изменение, удаление и восстановление документов. + Сохранённых значений пока нет + Статистика очищена + Статус + Статус системного документа менять нельзя + т + Такой alias уже используется документом или другим редиректом + Теги + Токен не найден или уже отозван + Токен скопирован + Только поля + Только чтение + у + У рубрики нет шаблона пути: alias документа используется от корня сайта. + Убрать + Убрать документ + Убрать раздел + Удаление документа отменено модулем + Удалить + Удалить « + Удалить ревизию + Удалён + Удалённые + Удалённый документ + Укажите корректный alias + Укажите название документа + ф + Файл не выбран + Файлы загружены + х + ц + ч + Черновик + Черновики + ш + Шаблон + Шаблон рубрики: / + Ширина, + щ + ъ + ы + ь + э + Эти файлы уже есть в поле + ю + я + ё + diff --git a/adminx/modules/Documents/language/ru/runtime.xml b/adminx/modules/Documents/language/ru/runtime.xml new file mode 100644 index 0000000..6fb02d6 --- /dev/null +++ b/adminx/modules/Documents/language/ru/runtime.xml @@ -0,0 +1,175 @@ + + + <button class="btn btn-secondary btn-sm" type="button" data-dropdown data-document-catalog-add><i class="ti ti-plus"></i>Добавить раздел</button> + <button class="documents-catalog-token-remove" type="button" data-document-catalog-remove aria-label="Убрать раздел"><i class="ti ti-x"></i></button> + <label class="input-wrap documents-catalog-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="Найти раздел" data-document-catalog-search></label> + <main><h1>Страница не найдена</h1><p>Запрошенная страница не существует или была перемещена.</p><p><a href="/">Перейти на главную</a></p></main> + <span class="badge badge-gray">скрыт</span> + API-токен отозван + API-токен создан + Alias занят + Alias не указан + Alias свободен + JSON-снимок пересобран + Sitemap: приоритет + Sitemap: частота + URL уже занят: + catalog_ids не переданы + data-document-catalog-empty>Разделы не выбраны.</p> + · документ # + Автор + Артикул / свойство + Б + Без группы + В поиске + В ревизии выбрано слишком много элементов + Введите текст заметки + Выберите от 1 до 200 документов + Выберите рубрику + Выберите хотя бы один параметр или поле + Выбранные данные восстановлены из ревизии + Выбранных данных нет в этой ревизии + Выпуск и отзыв ключей JSON API документов. + Главная страница является системной и не может быть удалена + Данные сформированы без сохранения + Дата окончания + Дата публикации + Для удалённого документа JSON-снимок не создаётся + Документ # + Документ восстановлен + Документ восстановлен из ревизии + Документ и связанные данные удалены окончательно + Документ назначен страницей 404 и не может быть удалён + Документ не найден + Документ недоступен + Документ опубликован + Документ помечен удалённым + Документ снят с публикации + Документ создан + Документ сохранён + Документ уже изменён в другой вкладке или другим пользователем + Документ уже изменён в другой вкладке или другим пользователем. + Документы + Документы: API-токены + Документы: просмотр + Документы: управление + Допустимы латиница, цифры, дефис и подчёркивание + Допустимы латиница, цифры, точка, дефис, подчёркивание и слэш + Заметка добавлена + Заметка не найдена + Заметка удалена + Запрошенная страница не найдена. + КБ + Код рубрики остановил создание документа + Код рубрики остановил сохранение документа + Код рубрики после сохранения не выполнен из-за ошибки базы данных + Код рубрики после сохранения остановил создание документа + Код рубрики после сохранения остановил сохранение документа + Контент + Копия документа создана + Копия: + Короткий alias + Краткое описание + МБ + Навигация + Название + Не удалось пересобрать JSON-снимок + Не удалось создать документ + Не удалось сохранить документ + Недостаточно прав + Неизвестная ошибка + Неизвестное пакетное действие + Неизвестный тип значений + Некорректный alias + Новый + Опубликован + Опубликованные + Ошибка 404 + Пакет JSON-снимков обработан + Пакетное действие выполнено + Подневная статистика очищена + Позиция + Поиск + Поле # + Полный снимок + Поля документа не прошли проверку: + Пресет документа не найден + Пресет не найден + Пресет создания сохранён + Пресет удалён + Проверьте настройки пресета + Проверьте поля документа + Просмотр списка документов и базовых метаданных. + Рабочие + Ревизии удалены + Ревизия не найдена + Ревизия удалена + Редирект не найден + Редирект сохранён + Редирект удалён + Родитель + Системный документ копировать нельзя + Скрыт + Сначала переместите документ в корзину + Создание, изменение, удаление и восстановление документов. + Статус + Статус системного документа менять нельзя + Страница не найдена + Такой alias уже используется документом или другим редиректом + Теги + Токен не найден или уже отозван + Только поля + Удаление документа отменено модулем + Удалён + Удалённые + Удалённый документ + Укажите корректный alias + Укажите название документа + Хлебная крошка + Черновик + Черновики + Шаблон + а + б + в + г + д + другой раздел + е + ещё не создан + ещё не сохранён + ж + з + и + й + к + кг + л + м + мес + можно оставить пустым + н + не задано + о + п + поле не включено в выбранных разделах каталога + после сохранения + р + с + см + т + у + ф + х + ц + ч + ш + щ + ъ + ы + ь + э + ю + я + ё + diff --git a/adminx/modules/Documents/migrations/001_correct_breadcrumb_title.sql b/adminx/modules/Documents/migrations/001_correct_breadcrumb_title.sql new file mode 100644 index 0000000..82f1a26 --- /dev/null +++ b/adminx/modules/Documents/migrations/001_correct_breadcrumb_title.sql @@ -0,0 +1,131 @@ +-- Canonicalize the historical document_breadcrum_title typo without losing data. +-- Both the active content schema and the dbpref legacy schema are handled; when +-- they point to the same table, the second pass is an idempotent no-op. + +SET @ave_breadcrumb_table = '{{content_prefix}}_documents'; +SET @ave_breadcrumb_has_old = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_breadcrumb_table + AND COLUMN_NAME = 'document_breadcrum_title' +); +SET @ave_breadcrumb_has_new = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_breadcrumb_table + AND COLUMN_NAME = 'document_breadcrumb_title' +); +SET @ave_breadcrumb_sql = IF( + @ave_breadcrumb_has_new = 0 AND @ave_breadcrumb_has_old = 1, + CONCAT('ALTER TABLE `', @ave_breadcrumb_table, '` CHANGE COLUMN `document_breadcrum_title` `document_breadcrumb_title` VARCHAR(255) NOT NULL DEFAULT \'\''), + IF( + @ave_breadcrumb_has_new = 0, + CONCAT('ALTER TABLE `', @ave_breadcrumb_table, '` ADD COLUMN `document_breadcrumb_title` VARCHAR(255) NOT NULL DEFAULT \'\' AFTER `document_title`'), + 'SELECT 1' + ) +); +PREPARE ave_breadcrumb_stmt FROM @ave_breadcrumb_sql; +EXECUTE ave_breadcrumb_stmt; +DEALLOCATE PREPARE ave_breadcrumb_stmt; + +SET @ave_breadcrumb_has_old = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_breadcrumb_table + AND COLUMN_NAME = 'document_breadcrum_title' +); +SET @ave_breadcrumb_has_new = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_breadcrumb_table + AND COLUMN_NAME = 'document_breadcrumb_title' +); +SET @ave_breadcrumb_sql = IF( + @ave_breadcrumb_has_old = 1 AND @ave_breadcrumb_has_new = 1, + CONCAT('UPDATE `', @ave_breadcrumb_table, '` SET `document_breadcrumb_title` = `document_breadcrum_title` WHERE `document_breadcrumb_title` = \'\' AND `document_breadcrum_title` <> \'\''), + 'SELECT 1' +); +PREPARE ave_breadcrumb_stmt FROM @ave_breadcrumb_sql; +EXECUTE ave_breadcrumb_stmt; +DEALLOCATE PREPARE ave_breadcrumb_stmt; + +SET @ave_breadcrumb_sql = IF( + @ave_breadcrumb_has_old = 1 AND @ave_breadcrumb_has_new = 1, + CONCAT('ALTER TABLE `', @ave_breadcrumb_table, '` DROP COLUMN `document_breadcrum_title`'), + 'SELECT 1' +); +PREPARE ave_breadcrumb_stmt FROM @ave_breadcrumb_sql; +EXECUTE ave_breadcrumb_stmt; +DEALLOCATE PREPARE ave_breadcrumb_stmt; + +SET @ave_breadcrumb_table = '{{database_prefix}}_documents'; +SET @ave_breadcrumb_has_old = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_breadcrumb_table + AND COLUMN_NAME = 'document_breadcrum_title' +); +SET @ave_breadcrumb_has_new = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_breadcrumb_table + AND COLUMN_NAME = 'document_breadcrumb_title' +); +SET @ave_breadcrumb_sql = IF( + @ave_breadcrumb_has_new = 0 AND @ave_breadcrumb_has_old = 1, + CONCAT('ALTER TABLE `', @ave_breadcrumb_table, '` CHANGE COLUMN `document_breadcrum_title` `document_breadcrumb_title` VARCHAR(255) NOT NULL DEFAULT \'\''), + IF( + @ave_breadcrumb_has_new = 0, + CONCAT('ALTER TABLE `', @ave_breadcrumb_table, '` ADD COLUMN `document_breadcrumb_title` VARCHAR(255) NOT NULL DEFAULT \'\' AFTER `document_title`'), + 'SELECT 1' + ) +); +PREPARE ave_breadcrumb_stmt FROM @ave_breadcrumb_sql; +EXECUTE ave_breadcrumb_stmt; +DEALLOCATE PREPARE ave_breadcrumb_stmt; + +SET @ave_breadcrumb_has_old = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_breadcrumb_table + AND COLUMN_NAME = 'document_breadcrum_title' +); +SET @ave_breadcrumb_has_new = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_breadcrumb_table + AND COLUMN_NAME = 'document_breadcrumb_title' +); +SET @ave_breadcrumb_sql = IF( + @ave_breadcrumb_has_old = 1 AND @ave_breadcrumb_has_new = 1, + CONCAT('UPDATE `', @ave_breadcrumb_table, '` SET `document_breadcrumb_title` = `document_breadcrum_title` WHERE `document_breadcrumb_title` = \'\' AND `document_breadcrum_title` <> \'\''), + 'SELECT 1' +); +PREPARE ave_breadcrumb_stmt FROM @ave_breadcrumb_sql; +EXECUTE ave_breadcrumb_stmt; +DEALLOCATE PREPARE ave_breadcrumb_stmt; + +SET @ave_breadcrumb_sql = IF( + @ave_breadcrumb_has_old = 1 AND @ave_breadcrumb_has_new = 1, + CONCAT('ALTER TABLE `', @ave_breadcrumb_table, '` DROP COLUMN `document_breadcrum_title`'), + 'SELECT 1' +); +PREPARE ave_breadcrumb_stmt FROM @ave_breadcrumb_sql; +EXECUTE ave_breadcrumb_stmt; +DEALLOCATE PREPARE ave_breadcrumb_stmt; + +UPDATE `{{content_prefix}}_sysblocks` +SET `sysblock_text` = REPLACE( + `sysblock_text`, + 'document_breadcrum_title', + 'document_breadcrumb_title' +) +WHERE `sysblock_text` LIKE '%document_breadcrum_title%'; + +UPDATE `{{database_prefix}}_sysblocks` +SET `sysblock_text` = REPLACE( + `sysblock_text`, + 'document_breadcrum_title', + 'document_breadcrumb_title' +) +WHERE `sysblock_text` LIKE '%document_breadcrum_title%'; diff --git a/adminx/modules/Documents/migrations/002_correct_legacy_breadcrumb_title.sql b/adminx/modules/Documents/migrations/002_correct_legacy_breadcrumb_title.sql new file mode 100644 index 0000000..173f41a --- /dev/null +++ b/adminx/modules/Documents/migrations/002_correct_legacy_breadcrumb_title.sql @@ -0,0 +1,66 @@ +-- Follow-up for installations where {{prefix}} already resolves to the native +-- system schema. The legacy source prefix comes only from db.config.php. + +SET @ave_breadcrumb_table = '{{database_prefix}}_documents'; +SET @ave_breadcrumb_has_old = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_breadcrumb_table + AND COLUMN_NAME = 'document_breadcrum_title' +); +SET @ave_breadcrumb_has_new = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_breadcrumb_table + AND COLUMN_NAME = 'document_breadcrumb_title' +); +SET @ave_breadcrumb_sql = IF( + @ave_breadcrumb_has_new = 0 AND @ave_breadcrumb_has_old = 1, + CONCAT('ALTER TABLE `', @ave_breadcrumb_table, '` CHANGE COLUMN `document_breadcrum_title` `document_breadcrumb_title` VARCHAR(255) NOT NULL DEFAULT \'\''), + IF( + @ave_breadcrumb_has_new = 0, + CONCAT('ALTER TABLE `', @ave_breadcrumb_table, '` ADD COLUMN `document_breadcrumb_title` VARCHAR(255) NOT NULL DEFAULT \'\' AFTER `document_title`'), + 'SELECT 1' + ) +); +PREPARE ave_breadcrumb_stmt FROM @ave_breadcrumb_sql; +EXECUTE ave_breadcrumb_stmt; +DEALLOCATE PREPARE ave_breadcrumb_stmt; + +SET @ave_breadcrumb_has_old = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_breadcrumb_table + AND COLUMN_NAME = 'document_breadcrum_title' +); +SET @ave_breadcrumb_has_new = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_breadcrumb_table + AND COLUMN_NAME = 'document_breadcrumb_title' +); +SET @ave_breadcrumb_sql = IF( + @ave_breadcrumb_has_old = 1 AND @ave_breadcrumb_has_new = 1, + CONCAT('UPDATE `', @ave_breadcrumb_table, '` SET `document_breadcrumb_title` = `document_breadcrum_title` WHERE `document_breadcrumb_title` = \'\' AND `document_breadcrum_title` <> \'\''), + 'SELECT 1' +); +PREPARE ave_breadcrumb_stmt FROM @ave_breadcrumb_sql; +EXECUTE ave_breadcrumb_stmt; +DEALLOCATE PREPARE ave_breadcrumb_stmt; + +SET @ave_breadcrumb_sql = IF( + @ave_breadcrumb_has_old = 1 AND @ave_breadcrumb_has_new = 1, + CONCAT('ALTER TABLE `', @ave_breadcrumb_table, '` DROP COLUMN `document_breadcrum_title`'), + 'SELECT 1' +); +PREPARE ave_breadcrumb_stmt FROM @ave_breadcrumb_sql; +EXECUTE ave_breadcrumb_stmt; +DEALLOCATE PREPARE ave_breadcrumb_stmt; + +UPDATE `{{database_prefix}}_sysblocks` +SET `sysblock_text` = REPLACE( + `sysblock_text`, + 'document_breadcrum_title', + 'document_breadcrumb_title' +) +WHERE `sysblock_text` LIKE '%document_breadcrum_title%'; diff --git a/adminx/modules/Documents/migrations/003_create_document_api_tokens.sql b/adminx/modules/Documents/migrations/003_create_document_api_tokens.sql new file mode 100644 index 0000000..9c2f575 --- /dev/null +++ b/adminx/modules/Documents/migrations/003_create_document_api_tokens.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS `{{system_prefix}}_api_tokens` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(190) NOT NULL, + `token_hash` CHAR(64) NOT NULL, + `token_prefix` VARCHAR(20) NOT NULL, + `scopes` VARCHAR(255) NOT NULL, + `last_used_at` DATETIME NULL, + `expires_at` DATETIME NULL, + `revoked_at` DATETIME NULL, + `created_at` DATETIME NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_token_hash` (`token_hash`), + KEY `idx_user` (`user_id`), + KEY `idx_active` (`revoked_at`, `expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Documents/migrations/004_optimize_public_document_indexes.sql b/adminx/modules/Documents/migrations/004_optimize_public_document_indexes.sql new file mode 100644 index 0000000..a17381f --- /dev/null +++ b/adminx/modules/Documents/migrations/004_optimize_public_document_indexes.sql @@ -0,0 +1,7 @@ +ALTER TABLE `{{prefix}}_documents` + ADD INDEX IF NOT EXISTS `idx_public_listing` + (`rubric_id`, `document_status`, `document_deleted`, `document_published`, `Id`); + +ALTER TABLE `{{prefix}}_document_fields` + ADD INDEX IF NOT EXISTS `idx_public_field_text` + (`rubric_field_id`, `field_value`(100), `document_id`); diff --git a/adminx/modules/Documents/migrations/005_rename_document_teaser_to_excerpt.sql b/adminx/modules/Documents/migrations/005_rename_document_teaser_to_excerpt.sql new file mode 100644 index 0000000..2a1dd26 --- /dev/null +++ b/adminx/modules/Documents/migrations/005_rename_document_teaser_to_excerpt.sql @@ -0,0 +1,58 @@ +-- Replace the ambiguous legacy teaser property with the canonical document +-- excerpt while preserving any existing content. + +SET @ave_excerpt_table = '{{content_prefix}}_documents'; +SET @ave_excerpt_has_old = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_excerpt_table + AND COLUMN_NAME = 'document_teaser' +); +SET @ave_excerpt_has_new = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_excerpt_table + AND COLUMN_NAME = 'document_excerpt' +); +SET @ave_excerpt_sql = IF( + @ave_excerpt_has_new = 0 AND @ave_excerpt_has_old = 1, + CONCAT('ALTER TABLE `', @ave_excerpt_table, '` CHANGE COLUMN `document_teaser` `document_excerpt` TEXT NOT NULL'), + IF( + @ave_excerpt_has_new = 0, + CONCAT('ALTER TABLE `', @ave_excerpt_table, '` ADD COLUMN `document_excerpt` TEXT NOT NULL AFTER `document_breadcrumb_title`'), + 'SELECT 1' + ) +); +PREPARE ave_excerpt_stmt FROM @ave_excerpt_sql; +EXECUTE ave_excerpt_stmt; +DEALLOCATE PREPARE ave_excerpt_stmt; + +SET @ave_excerpt_has_old = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_excerpt_table + AND COLUMN_NAME = 'document_teaser' +); +SET @ave_excerpt_has_new = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_excerpt_table + AND COLUMN_NAME = 'document_excerpt' +); +SET @ave_excerpt_sql = IF( + @ave_excerpt_has_old = 1 AND @ave_excerpt_has_new = 1, + CONCAT('UPDATE `', @ave_excerpt_table, '` SET `document_excerpt` = `document_teaser` WHERE `document_excerpt` = \'\' AND `document_teaser` <> \'\''), + 'SELECT 1' +); +PREPARE ave_excerpt_stmt FROM @ave_excerpt_sql; +EXECUTE ave_excerpt_stmt; +DEALLOCATE PREPARE ave_excerpt_stmt; + +SET @ave_excerpt_sql = IF( + @ave_excerpt_has_old = 1 AND @ave_excerpt_has_new = 1, + CONCAT('ALTER TABLE `', @ave_excerpt_table, '` DROP COLUMN `document_teaser`'), + 'SELECT 1' +); +PREPARE ave_excerpt_stmt FROM @ave_excerpt_sql; +EXECUTE ave_excerpt_stmt; +DEALLOCATE PREPARE ave_excerpt_stmt; diff --git a/adminx/modules/Documents/migrations/006_configure_product_media_paths.php b/adminx/modules/Documents/migrations/006_configure_product_media_paths.php new file mode 100644 index 0000000..8683212 --- /dev/null +++ b/adminx/modules/Documents/migrations/006_configure_product_media_paths.php @@ -0,0 +1,52 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Content\ContentTables; + use App\Helpers\Json; + + return function (array $context) { + $fieldsTable = ContentTables::table('rubric_fields'); + $rubricsTable = ContentTables::table('rubrics'); + $rows = \DB::query( + 'SELECT Id, rubric_field_settings FROM `' . $fieldsTable . '`' + . ' WHERE rubric_id = %i AND Id IN (22, 215, 217)' + . " AND rubric_field_type IN ('image_single', 'image_multi', 'image_mega')", + 6 + )->getAll() ?: array(); + $queries = 1; + + foreach ($rows as $row) { + $settings = Json::toArray(isset($row['rubric_field_settings']) ? $row['rubric_field_settings'] : ''); + $settings['upload_dir'] = '/articles/doc_%id'; + \DB::Update( + $fieldsTable, + array('rubric_field_settings' => Json::encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)), + 'Id = %i', + (int) $row['Id'] + ); + $queries++; + } + + \DB::query( + 'UPDATE `' . $rubricsTable . '` SET rubric_code_end = %s' + . ' WHERE Id = %i AND SHA1(rubric_code_end) = %s', + '', + 6, + 'e2fc38f19784135911dbb7e4fb78862da21e0977' + ); + + return $queries + 1; + }; diff --git a/adminx/modules/Documents/migrations/007_index_document_short_alias.sql b/adminx/modules/Documents/migrations/007_index_document_short_alias.sql new file mode 100644 index 0000000..5c78d3b --- /dev/null +++ b/adminx/modules/Documents/migrations/007_index_document_short_alias.sql @@ -0,0 +1,2 @@ +ALTER TABLE `{{prefix}}_documents` + ADD INDEX IF NOT EXISTS `idx_short_alias` (`document_short_alias`); diff --git a/adminx/modules/Documents/migrations/008_seed_clean_not_found_document.php b/adminx/modules/Documents/migrations/008_seed_clean_not_found_document.php new file mode 100644 index 0000000..33616b6 --- /dev/null +++ b/adminx/modules/Documents/migrations/008_seed_clean_not_found_document.php @@ -0,0 +1,69 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\SystemTables; + use App\Content\ContentTables; + use App\Content\Documents\DocumentAliasRegistry; + use App\Content\PublicShellTables; + + return function (array $context) { + $documents = ContentTables::table('documents'); + $starter = \DB::query('SELECT Id, rubric_id, document_title FROM ' . $documents . ' WHERE Id = 1 LIMIT 1')->getAssoc(); + $queries = 1; + if (!is_array($starter) || (int) $starter['rubric_id'] !== 1 || strpos((string) $starter['document_title'], 'AVE.cms') !== 0) { + return $queries; + } + + $notFound = \DB::query('SELECT Id, document_alias, document_title FROM ' . $documents . ' WHERE Id = 2 LIMIT 1')->getAssoc(); + $queries++; + if (is_array($notFound)) { + $isExpected = DocumentAliasRegistry::normalize($notFound['document_alias']) === '404' + || (string) $notFound['document_title'] === 'Страница не найдена'; + if (!$isExpected) { return $queries; } + } else { + if (DocumentAliasRegistry::conflict('404', 2)) { return $queries + 1; } + + $now = time(); + \DB::Insert($documents, array( + 'Id' => 2, 'rubric_id' => 1, 'rubric_tmpl_id' => 0, 'document_parent' => 0, + 'document_alias' => '404', 'document_alias_header' => 301, 'document_alias_history' => 0, + 'document_short_alias' => '', 'document_title' => 'Страница не найдена', + 'document_breadcrumb_title' => 'Ошибка 404', 'document_published' => $now, + 'document_expire' => 0, 'document_changed' => $now, 'document_author_id' => 1, + 'document_in_search' => 0, 'document_meta_keywords' => '', + 'document_meta_description' => 'Запрошенная страница не найдена.', + 'document_meta_robots' => 'noindex,nofollow', 'document_sitemap_freq' => 3, + 'document_sitemap_pr' => 0, 'document_status' => '1', 'document_deleted' => '0', + 'document_count_print' => 0, 'document_count_view' => 0, 'document_linked_navi_id' => 0, + 'document_excerpt' => '', 'document_tags' => '', 'document_property' => '', + 'document_position' => 2, 'module_catalog' => '', 'guid' => '', + )); + \DB::Insert(ContentTables::table('document_fields'), array( + 'rubric_field_id' => 1, 'document_id' => 2, 'field_number_value' => 0, + 'field_value' => '', 'document_in_search' => 0, + )); + $fieldId = (int) \DB::insertId(); + \DB::Insert(ContentTables::table('document_fields_text'), array( + 'Id' => $fieldId, 'rubric_field_id' => 1, 'document_id' => 2, + 'field_value' => '

    Страница не найдена

    Запрошенная страница не существует или была перемещена.

    Перейти на главную

    ', + )); + $queries += 3; + } + + \DB::Update(SystemTables::table('settings'), array('value' => '2', 'type' => 'int'), 'param = %s', 'page_not_found_id'); + \DB::Update(PublicShellTables::table('settings'), array('page_not_found_id' => 2), 'Id = %i', 1); + return $queries + 2; + }; diff --git a/adminx/modules/Documents/migrations/009_deduplicate_document_short_aliases.php b/adminx/modules/Documents/migrations/009_deduplicate_document_short_aliases.php new file mode 100644 index 0000000..26e5961 --- /dev/null +++ b/adminx/modules/Documents/migrations/009_deduplicate_document_short_aliases.php @@ -0,0 +1,53 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Content\ContentTables; + use App\Content\Documents\DocumentAliasRegistry; + + return function (array $context) { + $table = ContentTables::table('documents'); + $keyOf = function ($value) { + $value = trim((string) $value); + return function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value); + }; + $rows = \DB::query( + 'SELECT Id, document_alias, document_short_alias FROM ' . $table . ' ORDER BY Id ASC' + )->getAll() ?: array(); + $mainAliases = array(); + foreach ($rows as $row) { + $alias = DocumentAliasRegistry::normalize($row['document_alias']); + if ($alias !== '') { $mainAliases[$keyOf($alias)] = (int) $row['Id']; } + } + + $seen = array(); + $queries = 1; + foreach ($rows as $row) { + $alias = trim((string) $row['document_short_alias']); + if ($alias === '') { continue; } + $key = $keyOf($alias); + $id = (int) $row['Id']; + $conflictsMain = isset($mainAliases[$key]) && (int) $mainAliases[$key] !== $id; + if (isset($seen[$key]) || $conflictsMain) { + \DB::Update($table, array('document_short_alias' => ''), 'Id = %i', $id); + $queries++; + continue; + } + + $seen[$key] = $id; + } + + return $queries; + }; diff --git a/adminx/modules/Documents/migrations/010_reset_dangling_rubric_templates.sql b/adminx/modules/Documents/migrations/010_reset_dangling_rubric_templates.sql new file mode 100644 index 0000000..439e25b --- /dev/null +++ b/adminx/modules/Documents/migrations/010_reset_dangling_rubric_templates.sql @@ -0,0 +1,7 @@ +UPDATE `{{prefix}}_documents` AS d +LEFT JOIN `{{prefix}}_rubric_templates` AS t + ON t.id = d.rubric_tmpl_id + AND t.rubric_id = d.rubric_id +SET d.rubric_tmpl_id = 0 +WHERE d.rubric_tmpl_id > 0 + AND t.id IS NULL; diff --git a/adminx/modules/Documents/migrations/011_normalize_document_enum_values.sql b/adminx/modules/Documents/migrations/011_normalize_document_enum_values.sql new file mode 100644 index 0000000..2704b7d --- /dev/null +++ b/adminx/modules/Documents/migrations/011_normalize_document_enum_values.sql @@ -0,0 +1,19 @@ +UPDATE `{{content_prefix}}_documents` +SET `document_alias_history` = '0' +WHERE `document_alias_history` = ''; + +UPDATE `{{content_prefix}}_documents` +SET `document_in_search` = '0' +WHERE `document_in_search` = ''; + +UPDATE `{{content_prefix}}_documents` +SET `document_status` = '0' +WHERE `document_status` = ''; + +UPDATE `{{content_prefix}}_documents` +SET `document_deleted` = '0' +WHERE `document_deleted` = ''; + +UPDATE `{{content_prefix}}_document_fields` +SET `document_in_search` = '0' +WHERE `document_in_search` = ''; diff --git a/adminx/modules/Documents/migrations/012_default_legacy_language_columns.php b/adminx/modules/Documents/migrations/012_default_legacy_language_columns.php new file mode 100644 index 0000000..f4fd017 --- /dev/null +++ b/adminx/modules/Documents/migrations/012_default_legacy_language_columns.php @@ -0,0 +1,35 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\ContentTables; + + return function (array $context) { + $table = ContentTables::table('documents'); + $queries = 0; + + if (DatabaseSchema::columnExists($table, 'document_lang')) { + \DB::query('ALTER TABLE %b MODIFY COLUMN `document_lang` VARCHAR(5) NOT NULL DEFAULT %s', $table, ''); + $queries++; + } + + if (DatabaseSchema::columnExists($table, 'document_lang_group')) { + \DB::query('ALTER TABLE %b MODIFY COLUMN `document_lang_group` INT NOT NULL DEFAULT 0', $table); + $queries++; + } + + return $queries; + }; diff --git a/adminx/modules/Documents/migrations/013_document_edit_version.php b/adminx/modules/Documents/migrations/013_document_edit_version.php new file mode 100644 index 0000000..4052c24 --- /dev/null +++ b/adminx/modules/Documents/migrations/013_document_edit_version.php @@ -0,0 +1,30 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\ContentTables; + + return function (array $context) { + $table = ContentTables::table('documents'); + if (DatabaseSchema::columnExists($table, 'document_version')) { return 0; } + + \DB::query( + 'ALTER TABLE %b ADD COLUMN `document_version` INT UNSIGNED NOT NULL DEFAULT 1 AFTER `document_changed`', + $table + ); + + return 1; + }; diff --git a/adminx/modules/Documents/migrations/014_document_relation_edges.sql b/adminx/modules/Documents/migrations/014_document_relation_edges.sql new file mode 100644 index 0000000..07bbd4f --- /dev/null +++ b/adminx/modules/Documents/migrations/014_document_relation_edges.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_document_relation_edges` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `source_document_id` INT UNSIGNED NOT NULL, + `source_field_id` MEDIUMINT UNSIGNED NOT NULL, + `target_document_id` INT UNSIGNED NOT NULL, + `relation_type` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `position` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_relation_edge` (`source_document_id`,`source_field_id`,`target_document_id`), + KEY `idx_relation_outgoing` (`source_document_id`,`source_field_id`,`position`), + KEY `idx_relation_incoming` (`target_document_id`,`relation_type`,`source_document_id`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Documents/migrations/015_document_creation_presets.sql b/adminx/modules/Documents/migrations/015_document_creation_presets.sql new file mode 100644 index 0000000..edbdd57 --- /dev/null +++ b/adminx/modules/Documents/migrations/015_document_creation_presets.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_document_creation_presets` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `rubric_id` MEDIUMINT UNSIGNED NOT NULL, + `title` VARCHAR(191) NOT NULL, + `description` TEXT NOT NULL, + `document_json` MEDIUMTEXT NOT NULL, + `fields_json` MEDIUMTEXT NOT NULL, + `source_document_id` INT UNSIGNED NOT NULL DEFAULT 0, + `is_active` TINYINT UNSIGNED NOT NULL DEFAULT 1, + `sort_order` INT NOT NULL DEFAULT 0, + `created_by` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` INT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_preset_rubric` (`rubric_id`,`is_active`,`sort_order`,`id`), + KEY `idx_preset_source` (`source_document_id`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Documents/migrations/016_reconcile_document_content_columns.php b/adminx/modules/Documents/migrations/016_reconcile_document_content_columns.php new file mode 100644 index 0000000..4638f68 --- /dev/null +++ b/adminx/modules/Documents/migrations/016_reconcile_document_content_columns.php @@ -0,0 +1,96 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\ContentTables; + + return function (array $context) { + $table = ContentTables::table('documents'); + $queries = 0; + + if (!DatabaseSchema::columnExists($table, 'document_breadcrumb_title')) { + if (DatabaseSchema::columnExists($table, 'document_breadcrum_title')) { + \DB::query( + 'ALTER TABLE %b CHANGE COLUMN `document_breadcrum_title` `document_breadcrumb_title` VARCHAR(255) NOT NULL DEFAULT %s', + $table, + '' + ); + } else { + \DB::query( + 'ALTER TABLE %b ADD COLUMN `document_breadcrumb_title` VARCHAR(255) NOT NULL DEFAULT %s AFTER `document_title`', + $table, + '' + ); + } + + $queries++; + } + + if (DatabaseSchema::columnExists($table, 'document_breadcrum_title')) { + \DB::query( + 'UPDATE %b SET `document_breadcrumb_title`=`document_breadcrum_title`' + . ' WHERE `document_breadcrumb_title`=%s AND `document_breadcrum_title`!=%s', + $table, + '', + '' + ); + \DB::query('ALTER TABLE %b DROP COLUMN `document_breadcrum_title`', $table); + $queries += 2; + } + + if (!DatabaseSchema::columnExists($table, 'document_excerpt')) { + if (DatabaseSchema::columnExists($table, 'document_teaser')) { + \DB::query( + 'ALTER TABLE %b CHANGE COLUMN `document_teaser` `document_excerpt` TEXT NOT NULL', + $table + ); + } else { + \DB::query( + 'ALTER TABLE %b ADD COLUMN `document_excerpt` TEXT NOT NULL AFTER `document_linked_navi_id`', + $table + ); + } + + $queries++; + } + + if (DatabaseSchema::columnExists($table, 'document_teaser')) { + \DB::query( + 'UPDATE %b SET `document_excerpt`=`document_teaser`' + . ' WHERE `document_excerpt`=%s AND `document_teaser`!=%s', + $table, + '', + '' + ); + \DB::query('ALTER TABLE %b DROP COLUMN `document_teaser`', $table); + $queries += 2; + } + + $columns = array( + 'document_short_alias' => 'VARCHAR(10) NOT NULL DEFAULT \'\' AFTER `document_alias_history`', + 'document_tags' => 'TEXT NOT NULL AFTER `document_excerpt`', + 'document_property' => 'TEXT NOT NULL AFTER `document_tags`', + 'module_catalog' => 'LONGTEXT NOT NULL AFTER `document_position`', + 'guid' => 'VARCHAR(100) NOT NULL DEFAULT \'\' AFTER `module_catalog`', + ); + foreach ($columns as $column => $definition) { + if (DatabaseSchema::columnExists($table, $column)) { continue; } + \DB::query('ALTER TABLE %b ADD COLUMN %b ' . $definition, $table, $column); + $queries++; + } + + return $queries; + }; diff --git a/adminx/modules/Documents/module.php b/adminx/modules/Documents/module.php new file mode 100644 index 0000000..845a9a4 --- /dev/null +++ b/adminx/modules/Documents/module.php @@ -0,0 +1,132 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + return array( + 'code' => 'documents', + 'name' => 'Документы', + 'version' => '0.1.7', + + 'permissions' => array( + 'key' => 'documents', + 'items' => array( + array( + 'code' => 'view_documents', + 'group_code' => 'navigation', + 'name' => 'Документы: просмотр', + 'description' => 'Просмотр списка документов и базовых метаданных.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_documents', + 'group_code' => 'content', + 'name' => 'Документы: управление', + 'description' => 'Создание, изменение, удаление и восстановление документов.', + 'sort_order' => 20, + ), + array( + 'code' => 'manage_document_api', + 'group_code' => 'content', + 'name' => 'Документы: API-токены', + 'description' => 'Выпуск и отзыв ключей JSON API документов.', + 'sort_order' => 30, + ), + ), + 'icon' => 'ti ti-files', + 'priority' => 21, + ), + + 'navigation' => array( + array( + 'code' => 'documents', + 'label' => 'Документы', + 'url' => '/documents', + 'icon' => 'ti ti-files', + 'permission' => 'view_documents', + 'group' => 'Контент', + 'sort_order' => 21, + 'match' => array( + '/documents', + ), + ), + ), + + 'routes' => array( + array('GET', '/documents', array(\App\Adminx\Documents\Controller::class, 'index')), + array('GET', '/documents/create', array(\App\Adminx\Documents\Controller::class, 'create')), + array('GET', '/documents/alias-check', array(\App\Adminx\Documents\Controller::class, 'aliasCheck')), + array('POST', '/documents/slug', array(\App\Adminx\Documents\Controller::class, 'slug')), + array('POST', '/documents/short-alias', array(\App\Adminx\Documents\Controller::class, 'shortAlias')), + array('POST', '/documents/preview', array(\App\Adminx\Documents\Controller::class, 'previewPayload')), + array('POST', '/documents/bulk', array(\App\Adminx\Documents\Controller::class, 'bulk')), + array('POST', '/documents/snapshots/rebuild', array(\App\Adminx\Documents\Controller::class, 'rebuildSnapshots')), + array('GET', '/documents/picker', array(\App\Adminx\Documents\Controller::class, 'documentPicker')), + array('GET', '/documents/terms', array(\App\Adminx\Documents\Controller::class, 'termSuggestions')), + array('GET', '/documents/views', array(\App\Adminx\Documents\Controller::class, 'views')), + array('POST', '/documents/views/clear', array(\App\Adminx\Documents\Controller::class, 'clearViews')), + array('GET', '/documents/redirects', array(\App\Adminx\Documents\Controller::class, 'redirects')), + array('GET', '/documents/api', array(\App\Adminx\Documents\Controller::class, 'apiTokens')), + array('POST', '/documents/api/tokens', array(\App\Adminx\Documents\Controller::class, 'issueApiToken')), + array('POST', '/documents/api/tokens/{id}/revoke', array(\App\Adminx\Documents\Controller::class, 'revokeApiToken')), + array('GET', '/documents/{id}/revisions', array(\App\Adminx\Documents\Controller::class, 'revisions')), + array('GET', '/documents/{id}/snapshot', array(\App\Adminx\Documents\Controller::class, 'snapshot')), + array('POST', '/documents/{id}/snapshot/rebuild', array(\App\Adminx\Documents\Controller::class, 'rebuildSnapshot')), + array('POST', '/documents/{id}/revisions/delete', array(\App\Adminx\Documents\Controller::class, 'deleteRevisions')), + array('GET', '/documents/{id}/aliases', array(\App\Adminx\Documents\Controller::class, 'aliases')), + array('POST', '/documents/{id}/aliases', array(\App\Adminx\Documents\Controller::class, 'saveAlias')), + array('POST', '/documents/{id}/aliases/{alias}', array(\App\Adminx\Documents\Controller::class, 'saveAlias')), + array('POST', '/documents/{id}/aliases/{alias}/delete', array(\App\Adminx\Documents\Controller::class, 'deleteAlias')), + array('GET', '/documents/{id}/remarks', array(\App\Adminx\Documents\Controller::class, 'remarks')), + array('POST', '/documents/{id}/remarks', array(\App\Adminx\Documents\Controller::class, 'addRemark')), + array('POST', '/documents/{id}/remarks/{remark}/delete', array(\App\Adminx\Documents\Controller::class, 'deleteRemark')), + array('GET', '/documents/revisions/{revision}', array(\App\Adminx\Documents\Controller::class, 'revision')), + array('POST', '/documents/revisions/{revision}/restore', array(\App\Adminx\Documents\Controller::class, 'restoreRevision')), + array('POST', '/documents/revisions/{revision}/delete', array(\App\Adminx\Documents\Controller::class, 'deleteRevision')), + array('GET', '/documents/{id}/edit', array(\App\Adminx\Documents\Controller::class, 'edit')), + array('GET', '/documents/{id}', array(\App\Adminx\Documents\Controller::class, 'show')), + array('POST', '/documents', array(\App\Adminx\Documents\Controller::class, 'store')), + array('POST', '/documents/{id}', array(\App\Adminx\Documents\Controller::class, 'update')), + array('POST', '/documents/{id}/delete', array(\App\Adminx\Documents\Controller::class, 'destroy')), + array('POST', '/documents/{id}/restore', array(\App\Adminx\Documents\Controller::class, 'restore')), + array('POST', '/documents/{id}/toggle', array(\App\Adminx\Documents\Controller::class, 'toggle')), + array('POST', '/documents/{id}/copy', array(\App\Adminx\Documents\Controller::class, 'copy')), + array('POST', '/documents/{id}/presets', array(\App\Adminx\Documents\Controller::class, 'saveCreationPreset')), + array('POST', '/documents/presets/{preset}/delete', array(\App\Adminx\Documents\Controller::class, 'deleteCreationPreset')), + array('POST', '/documents/{id}/purge', array(\App\Adminx\Documents\Controller::class, 'purge')), + ), + + 'migrations' => array( + array('id' => '001_correct_breadcrumb_title', 'file' => 'migrations/001_correct_breadcrumb_title.sql', 'legacy_checksums' => array('0ee53933c726cae4f1f9cac350259c4559471fb4')), + array('id' => '002_correct_legacy_breadcrumb_title', 'file' => 'migrations/002_correct_legacy_breadcrumb_title.sql', 'legacy_checksums' => array('7384b0522cf8baa2029949517b8215caacaa8ba3')), + array('id' => '003_create_document_api_tokens', 'file' => 'migrations/003_create_document_api_tokens.sql'), + array('id' => '004_optimize_public_document_indexes', 'file' => 'migrations/004_optimize_public_document_indexes.sql'), + array('id' => '005_rename_document_teaser_to_excerpt', 'file' => 'migrations/005_rename_document_teaser_to_excerpt.sql'), + array('id' => '006_configure_product_media_paths', 'file' => 'migrations/006_configure_product_media_paths.php', 'legacy_checksums' => array('1208cc44ddc47b655159acfb411e6a854a365c49')), + array('id' => '007_index_document_short_alias', 'file' => 'migrations/007_index_document_short_alias.sql'), + array('id' => '008_seed_clean_not_found_document', 'file' => 'migrations/008_seed_clean_not_found_document.php', 'legacy_checksums' => array('dd3a795de2b6b152c148c9ee65089e42d83acdfe')), + array('id' => '009_deduplicate_document_short_aliases', 'file' => 'migrations/009_deduplicate_document_short_aliases.php'), + array('id' => '010_reset_dangling_rubric_templates', 'file' => 'migrations/010_reset_dangling_rubric_templates.sql'), + array('id' => '011_normalize_document_enum_values', 'file' => 'migrations/011_normalize_document_enum_values.sql'), + array('id' => '012_default_legacy_language_columns', 'file' => 'migrations/012_default_legacy_language_columns.php'), + array('id' => '013_document_edit_version', 'file' => 'migrations/013_document_edit_version.php'), + array('id' => '014_document_relation_edges', 'file' => 'migrations/014_document_relation_edges.sql'), + array('id' => '015_document_creation_presets', 'file' => 'migrations/015_document_creation_presets.sql'), + array('id' => '016_reconcile_document_content_columns', 'file' => 'migrations/016_reconcile_document_content_columns.php'), + ), + + 'view_globals' => array( + 'module_code' => 'documents', + ), + ); diff --git a/adminx/modules/Documents/view/api.twig b/adminx/modules/Documents/view/api.twig new file mode 100644 index 0000000..800431e --- /dev/null +++ b/adminx/modules/Documents/view/api.twig @@ -0,0 +1,199 @@ +{% extends '@adminx/main.twig' %} + +{% block title %}JSON API документов{% endblock %} + +{% block content %} + + + + + + + + +
    +
    + +
    + Секретный ключ показывается один раз + В базе хранится только его SHA-256-хеш. Утраченный ключ нужно отозвать и выпустить заново. +
    +
    + +
    +
    +
    + +
    +

    Токены доступа

    +

    Используйте отдельный токен для каждой интеграции.

    +
    +
    + {{ tokens|length }} +
    + +
    + + + + + + + + + + + + + + {% for token in tokens %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
    ИнтеграцияВладелецРазрешенияПоследний запросДействует доСостояние
    +
    + {{ token.name }} + {{ token.token_prefix }}… +
    +
    {{ token.user_name ?: token.user_email }} +
    + {% for scope in token.scopes_list %} + {{ scope == 'documents:read' ? 'Чтение' : (scope == 'documents:write' ? 'Запись' : 'Полный доступ') }} + {% endfor %} +
    +
    {{ token.last_used_at ?: 'Ни разу' }}{{ token.expires_at ?: 'Без срока' }} + + {{ token.state == 'active' ? 'Активен' : (token.state == 'expired' ? 'Истёк' : 'Отозван') }} + + + {% if token.state == 'active' %} + + {% endif %} +
    +
    + +
    Токенов пока нетСоздайте отдельный ключ для первой интеграции.
    +
    +
    +
    +
    +
    + + +{% endblock %} diff --git a/adminx/modules/Documents/view/edit.twig b/adminx/modules/Documents/view/edit.twig new file mode 100644 index 0000000..d3ad6a2 --- /dev/null +++ b/adminx/modules/Documents/view/edit.twig @@ -0,0 +1,539 @@ +{% extends '@adminx/main.twig' %} +{% block title %}{{ catalog_mode ? 'Товар #' ~ document.Id : (is_new ? 'Новый документ' : 'Документ #' ~ document.Id) }}{% endblock %} +{% block content %} + + + + + +{% if catalog_mode and not is_new %}

    Варианты товара

    {% if variant_group %}Группа #{{ variant_group.id }}{% endif %}
    {% if variant_group %}
    {{ variant_group.title }}

    {{ variant_group.is_primary ? 'Основной вариант группы' : 'Вариант группы' }}

    Управление вариантами{% else %}
    Товар пока не объединён с вариантами

    Создайте группу на основе текущего товара.

    {% endif %}
    {% endif %} + +{% if catalog_mode and not is_new and native_attributes.attributes_count %} +
    +

    Нативные характеристики

    Новый контур проверки. Публичная карточка пока использует старые поля.

    {{ native_attributes.attributes_count }}
    +
    {% for group in native_attributes.groups %}
    {{ group.name }}{{ group.set_name }}
    {{ group.items|length }}
    {% for item in group.items %}{% endfor %}
    {% endfor %}
    +
    +{% endif %} + +{% if catalog_mode and not is_new %} +
    +
    + + +
    +

    Доставка и упаковка

    Физические грузовые места для расчёта перевозчиками.

    + +
    +
    + {% if shipping_profile.legacy %}
    Найдены старые данные: {% for alias,value in shipping_profile.legacy %}{{ alias }} {{ value }}{% if not loop.last %}, {% endif %}{% endfor %}. Добавьте грузовое место, чтобы перейти на точный расчёт.
    {% endif %} +
    + +
    + {% for package in shipping_profile.packages %} +
    + + + + + + + +
    + {% endfor %} +
    +
    Грузовые места ещё не добавлены
    + +
    +
    + +
    +{% endif %} + +
    + + + + + + + + + + {% if creation_preset %} +
    + +
    Применён пресет «{{ creation_preset.title }}»{{ creation_preset.description ?: 'Форма заполнена сохранёнными значениями. Проверьте их перед сохранением.' }}
    + Сбросить +
    + {% endif %} + +
    +
    +
    +
    + +

    Инструменты

    +
    +
    +
    + + + + + + {% if is_new %}

    Инструменты доступны после первого сохранения.

    {% endif %} +
    +
    + +
    +
    +
    + +

    Сводка

    +
    +
    +
    +
    +
    ID
    {{ document.Id ?: 'новый' }}
    +
    Рубрика
    {{ document.rubric_title ?: 'будет выбрана при сохранении' }}
    +
    Изменён
    {{ document.changed_label }}
    +
    Опубликован
    {{ document.published_label }}
    +
    +
    +
    + +
    +
    +
    + +

    Интеграция

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +

    Основное

    +
    + {{ document.state_label }} +
    +
    +
    + + + +
    + Alias (URL) +
    + / + + +
    + {{ is_homepage ? 'Адрес главной страницы закреплён за / и не изменяется.' : '' }} + {% if is_new %}{% endif %} + +
    + + + + +
    +
    +
    + +
    +
    +
    + +

    SEO и поиск

    +
    +
    +
    +
    +
    + Meta keywords +
    +
    + + +
    + +
    +
    + + +
    + Теги +
    +
    + + +
    + +
    +
    + + +
    + Сниппет в выдаче +
    +
    /{{ document.document_alias }}
    +
    {{ document.document_title ?: 'Заголовок документа' }}
    +
    {{ document.document_meta_description ?: 'Описание появится после заполнения meta description.' }}
    +
    +
    +
    +
    +
    + +
    + + +
    + +
    +
    +
    + +

    Поля документа

    +
    + {{ document.fields_count|default(0) }} +
    + {% if field_groups %} +
    + {% for group in field_groups %} + + {% endfor %} +
    + {% endif %} +
    + {% for group in field_groups %} +
    + {% if group.items %} +
    + {% for field in group.items %} +
    +
    + + #{{ field.Id }} + {{ field.rubric_field_type }} + {% if not field.has_plugin %}нет плагина{% endif %} +
    + {% if field.prefix or field.suffix %} +
    {% if field.prefix %}{{ field.prefix }}{% endif %}{{ field.html|raw }}{% if field.suffix %}{{ field.suffix }}{% endif %}
    + {% else %} +
    {{ field.html|raw }}
    + {% endif %} + {% if field.rubric_field_description %}

    {{ field.rubric_field_description }}

    {% endif %} +
    + {% endfor %} +
    + {% else %} +
    + +
    В этой группе нет полей

    Добавьте поля в разделе «Рубрики и поля».

    +
    + {% endif %} +
    + {% else %} +
    + +
    У выбранной рубрики нет полей

    После выбора рубрики и сохранения здесь появятся её поля.

    +
    + {% endfor %} +
    +
    + +
    +
    + + Изменения сохранены +
    +
    + + + +
    +
    +
    + +{% if can_manage %} + + + + + + + + + + + +{% endif %} + +{% endblock %} diff --git a/adminx/modules/Documents/view/index.twig b/adminx/modules/Documents/view/index.twig new file mode 100644 index 0000000..9192cfc --- /dev/null +++ b/adminx/modules/Documents/view/index.twig @@ -0,0 +1,344 @@ +{% extends '@adminx/main.twig' %} +{% macro row_actions(item, base) %} +
    + + {% if item.can_toggle %} + + {% if item.can_copy %}{% endif %} + {% endif %} + {% if item.can_restore %} + + {% if item.can_purge %}{% endif %} + {% elseif item.can_delete %} + + {% else %} + + {% endif %} +
    +{% endmacro %} +{% import _self as document_ui %} +{% block title %}Документы{% endblock %} +{% block content %} + + + + + + + +
    +
    + +
    {{ stats.total }}Всего
    +
    +
    + +
    {{ stats.active }}Опубликовано
    +
    +
    + +
    {{ stats.draft }}Черновиков
    +
    +
    + +
    {{ stats.deleted }}В корзине
    +
    +
    + +
    + + +
    +
    +
    + +
    +

    Фильтры

    +

    {{ pagination.from }}-{{ pagination.to }} из {{ pagination.total }} в текущем наборе.

    +
    +
    + {{ filter_state_label }} +
    +
    +
    + + +
    + + +
    + + + + {% if filters.q or filters.field or filters.rubric_id or filters.state %} + + {% endif %} +
    +
    +
    + +
    +
    +
    + +
    +

    Документы

    +

    Список документов сайта с быстрым переходом к редактированию.

    +
    +
    + {{ documents|length }} +
    + + {% if admin_view.mode == 'cards' or admin_view.mode == 'custom' %} +
    + {% for item in documents %} +
    +
    + {% if can_manage %}{% endif %} +
    {{ item.document_title }}#{{ item.Id }} · {{ item.document_alias ? item.document_alias : 'без alias' }}
    + {{ item.state_label }} +
    +
    + {% if admin_view.mode == 'custom' %} +
    {{ item.admin_view_html|raw }}
    + {% else %} +
    + {% for display in item.admin_view_values %} +
    {{ display.label }} + {% if display.format == 'image' %}{% if display.value.image %}{% else %}{% endif %} + {% elseif display.format == 'badge' %}{{ display.value.text ? display.value.text : '—' }} + {% else %}{{ display.value.text ? display.value.text : '—' }}{% endif %} +
    + {% else %}
    Добавьте поля в настройках вида рубрики.
    {% endfor %} +
    + {% endif %} +
    +
    {{ item.changed_label }}{% if can_manage %}{{ document_ui.row_actions(item, ADMINX_BASE) }}{% endif %}
    +
    + {% else %}
    Документы не найдены.
    {% endfor %} +
    + {% elseif admin_view.mode == 'table' %} +
    + + + {% if can_manage %}{% endif %} + + {% for column in admin_view.items %}{% endfor %} + {% if can_manage %}{% endif %} + + + {% for item in documents %} + + {% if can_manage %}{% endif %} + + + {% for display in item.admin_view_values %} + + {% endfor %} + + {% if can_manage %}{% endif %} + + {% else %}{% endfor %} + +
    IDДокумент{{ column.label }}Состояние
    {{ item.Id }}
    {{ item.document_title }}{{ item.document_alias ? item.document_alias : 'без alias' }}
    + {% if display.format == 'image' %}{% if display.value.image %}{% else %}{% endif %} + {% elseif display.format == 'badge' %}{{ display.value.text ? display.value.text : '—' }} + {% else %}{{ display.value.text ? display.value.text : '—' }}{% endif %} + {{ item.state_label }}{{ document_ui.row_actions(item, ADMINX_BASE) }}
    Документы не найдены.
    +
    + {% else %} +
    + + + {% if can_manage %}{% endif %} + + + + + + {% if can_manage %}{% endif %} + + + + {% if can_manage %}{% endif %} + + + + + + {% if can_manage %}{% endif %} + + + + {% for item in documents %} + + {% if can_manage %}{% endif %} + + + + + + {% if can_manage %} + + {% endif %} + + {% else %} + + {% endfor %} + +
    IDНазваниеРубрикаСостояниеИзменён
    ID{{ item.Id }} +
    + {{ item.document_title }} + {{ item.document_count_view|number_format(0, '.', ' ') }} просмотров{{ item.document_alias ? item.document_alias : 'без alias' }} +
    +
    +
    + {{ item.rubric_title ? item.rubric_title : 'Рубрика не найдена' }} + #{{ item.rubric_id }} +
    +
    +
    + {{ item.state_label }} + {% if item.document_in_search %}В поиске{% endif %} + {% if item.Id == 1 %}system{% endif %} +
    +
    +
    + Изменён{{ item.changed_label }} + Публикация{{ item.published_label }} +
    +
    +
    + + {% if item.Id > 2 and not item.document_deleted %} + + + {% endif %} + {% if item.can_restore %} + + {% if item.Id > 2 %}{% endif %} + {% elseif item.can_delete %} + + {% else %} + + {% endif %} +
    +
    Документы не найдены.
    +
    + {% endif %} + + {% if can_manage %}{% endif %} + +
    + {{ pagination.from }}-{{ pagination.to }} из {{ pagination.total }} + {% if pagination.pages > 1 %} + {% set current = pagination.page %}{% set pages = pagination.pages %} + {% set page_url = ADMINX_BASE ~ '/documents?q=' ~ filters.q|url_encode ~ '&field=' ~ filters.field|url_encode ~ '&rubric_id=' ~ filters.rubric_id ~ '&state=' ~ filters.state|url_encode ~ '&per_page=' ~ pagination.limit ~ '&page=' %} + + {% endif %} +
    +
    +
    +{% if can_manage %} + +{% endif %} +{% endblock %} diff --git a/adminx/modules/Documents/view/redirects.twig b/adminx/modules/Documents/view/redirects.twig new file mode 100644 index 0000000..fb70b44 --- /dev/null +++ b/adminx/modules/Documents/view/redirects.twig @@ -0,0 +1,38 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Редиректы документов{% endblock %} +{% block content %} +
    + + + + + +
    +
    {{ stats.total }}Всего редиректов
    +
    {{ stats.documents }}Документов
    +
    {{ stats.permanent }}Постоянных
    +
    {{ stats.temporary }}Временных
    +
    + +
    + +
    Маршрутизация

    История адресов

    Каждый старый URL должен вести на существующий документ.

    +
    +

    Фильтры

    {{ pagination.from }}-{{ pagination.to }} из {{ pagination.total }}

    +
    {% if filters.q or filters.header %}{% endif %}
    +
    +
    +

    Редиректы

    Старый адрес, код ответа и текущая цель.

    {{ redirects|length }}
    +
    {% if can_manage %}{% endif %}{% for item in redirects %}{% if can_manage %}{% endif %}{% else %}{% endfor %}
    Старый URLКодДокумент назначенияИзменён
    {{ item.header }}
    #{{ item.document_id }} · {{ item.document_title }}/{{ item.target_alias ?: 'без alias' }}{% if item.document_deleted %} · удалён{% endif %}
    {{ item.changed_label }}{{ item.author_name ?: '#' ~ item.author_id }}
    Редиректы не найдены.
    +
    {{ pagination.from }}-{{ pagination.to }} из {{ pagination.total }}{% if pagination.pages > 1 %}{% set current = pagination.page %}{% set pages = pagination.pages %}{% set page_url = ADMINX_BASE ~ '/documents/redirects?q=' ~ filters.q|url_encode ~ '&header=' ~ filters.header ~ '&page=' %}{% endif %}
    +
    +
    + + {% if can_manage %}{% endif %} +
    +{% endblock %} diff --git a/adminx/modules/Documents/view/views.twig b/adminx/modules/Documents/view/views.twig new file mode 100644 index 0000000..01562cb --- /dev/null +++ b/adminx/modules/Documents/view/views.twig @@ -0,0 +1,42 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Просмотры документов{% endblock %} +{% block content %} + + + + + + +
    + +
    +
    {{ view_stats.total }}За период
    +
    {{ view_stats.average }}В среднем за день
    +
    {{ view_stats.tracked_documents }}Документов
    +
    {{ view_stats.rows }}Строк счётчика
    +
    + +
    +
    +

    Период

    Статистика не копируется и читается из рабочей таблицы.

    +
    {% if can_manage %}{% endif %}
    +
    +
    + +
    +
    +

    По дням

    Количество просмотров и охваченных документов.

    +
    {% for row in view_stats.daily %}{% else %}{% endfor %}
    ДатаПросмотрыДокументы
    {{ row.date_label }}{{ row.views }}{{ row.documents }}
    За период данных нет.
    +
    +
    +

    Популярные документы

    Топ за выбранный период.

    +
    {% for row in view_stats.top %}{% else %}{% endfor %}
    ДокументПросмотры
    {{ row.title }}#{{ row.id }} · {{ row.alias }}{{ row.views }}
    Данных пока нет.
    +
    +
    +
    +{% endblock %} diff --git a/adminx/modules/Events/Controller.php b/adminx/modules/Events/Controller.php new file mode 100644 index 0000000..c4a8738 --- /dev/null +++ b/adminx/modules/Events/Controller.php @@ -0,0 +1,86 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Events; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Helpers\Request; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + AdminAssets::addStyle($this->base() . '/modules/Events/assets/events.css', 50); + AdminAssets::addScript($this->base() . '/modules/Events/assets/events.js', 50); + + $source = Request::getStr('source', Model::defaultSource()); + $q = Request::getStr('q', ''); + $limit = Request::getInt('limit', 300); + $sourceDef = Model::source($source); + + return $this->render('@events/index.twig', array( + 'sources' => Model::summaries(), + 'active_source' => $sourceDef['code'], + 'active_source_def' => $sourceDef, + 'rows' => Model::rows($sourceDef['code'], $q, $limit), + 'q' => $q, + 'limit' => $limit, + 'can_manage' => Permission::check('manage_events'), + )); + } + + public function clear(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $source = isset($params['source']) ? $params['source'] : ''; + if (!Model::clear($source, Auth::id())) { + return $this->error('Этот источник нельзя очистить', array(), 422); + } + + return $this->success('Журнал очищен', array( + 'redirect' => $this->base() . '/events?source=' . rawurlencode($source), + )); + } + + public function export(array $params = array()) + { + if (!Permission::check('view_events')) { + return $this->error('Недостаточно прав', array(), 403); + } + + $source = Model::source(isset($params['source']) ? $params['source'] : ''); + $csv = Model::csv($source['code'], Request::getStr('q', ''), 1000); + $file = 'events_' . $source['code'] . '_' . date('Ymd_His') . '.csv'; + + Request::setHeader('Content-Type: text/csv; charset=UTF-8', true); + Request::setHeader('Content-Disposition: attachment; filename="' . addslashes($file) . '"', true); + Request::setHeader('Cache-Control: must-revalidate', true); + Request::setHeader('Pragma: public', true); + echo "\xEF\xBB\xBF" . $csv; + Request::shutDown(); + } + + protected function guard() + { + return $this->guardPermission('manage_events'); + } + } diff --git a/adminx/modules/Events/Model.php b/adminx/modules/Events/Model.php new file mode 100644 index 0000000..796c67d --- /dev/null +++ b/adminx/modules/Events/Model.php @@ -0,0 +1,528 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Events; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AuditLog; + use App\Common\ReferrerLog; + use App\Helpers\Json; + use DB; + + class Model + { + public static function sources() + { + return array( + 'audit' => array( + 'code' => 'audit', + 'label' => 'Аудит', + 'description' => 'Административные действия в панели управления.', + 'icon' => 'ti ti-shield-check', + 'tile_bg' => 'var(--blue-100)', + 'tile_fg' => 'var(--blue-600)', + 'clearable' => true, + ), + 'legacy' => array( + 'code' => 'legacy', + 'label' => 'Журнал приложения', + 'description' => 'Сообщения runtime, которые код сайта записывает в системный журнал.', + 'icon' => 'ti ti-history', + 'tile_bg' => 'var(--green-100)', + 'tile_fg' => 'var(--green-600)', + 'clearable' => true, + ), + '404' => array( + 'code' => '404', + 'label' => 'Ошибки 404', + 'description' => 'Запросы к несуществующим страницам.', + 'icon' => 'ti ti-alert-circle', + 'tile_bg' => 'var(--amber-100)', + 'tile_fg' => 'var(--amber-600)', + 'clearable' => true, + ), + 'sql' => array( + 'code' => 'sql', + 'label' => 'SQL ошибки', + 'description' => 'Ошибки SQL-запросов сайта.', + 'icon' => 'ti ti-database-exclamation', + 'tile_bg' => 'var(--red-100)', + 'tile_fg' => 'var(--red-600)', + 'clearable' => true, + ), + 'referrers' => array( + 'code' => 'referrers', + 'label' => 'Переходы', + 'description' => 'Внешние источники, рекламные метки и страницы входа.', + 'icon' => 'ti ti-route', + 'tile_bg' => 'var(--cyan-100)', + 'tile_fg' => 'var(--cyan-700)', + 'clearable' => true, + ), + ); + } + + public static function defaultSource() + { + return 'audit'; + } + + public static function states() + { + return array( + 'success' => array('label' => 'Успех', 'badge' => 'badge-green', 'icon' => 'ti ti-circle-check', 'class' => 'state-success'), + 'change' => array('label' => 'Изменение', 'badge' => 'badge-blue', 'icon' => 'ti ti-pencil', 'class' => 'state-change'), + 'warning' => array('label' => 'Внимание', 'badge' => 'badge-amber', 'icon' => 'ti ti-alert-triangle', 'class' => 'state-warning'), + 'error' => array('label' => 'Ошибка', 'badge' => 'badge-red', 'icon' => 'ti ti-alert-circle', 'class' => 'state-error'), + 'danger' => array('label' => 'Опасное', 'badge' => 'badge-red', 'icon' => 'ti ti-trash', 'class' => 'state-danger'), + 'info' => array('label' => 'Инфо', 'badge' => 'badge-gray', 'icon' => 'ti ti-info-circle', 'class' => 'state-info'), + ); + } + + public static function source($code) + { + $sources = self::sources(); + $code = (string) $code; + return isset($sources[$code]) ? $sources[$code] : $sources[self::defaultSource()]; + } + + public static function summaries() + { + $out = array(); + foreach (self::sources() as $code => $source) { + $source['count'] = self::count($code); + $source['size'] = self::sourceSize($code); + $source['exists'] = in_array($code, array('audit', 'referrers'), true) ? true : is_file(self::path($code)); + $out[$code] = $source; + } + + return $out; + } + + public static function rows($source, $q = '', $limit = 300) + { + $source = self::source($source); + $code = $source['code']; + $limit = max(1, min(1000, (int) $limit)); + $q = trim((string) $q); + + if ($code === 'audit') { + $rows = self::auditRows($limit); + } elseif ($code === 'referrers') { + $rows = self::referrerRows($limit, $q); + } else { + $rows = self::legacyRows($code, $limit); + } + + if ($q !== '') { + $rows = array_values(array_filter($rows, function ($row) use ($q) { + $haystack = implode(' ', array( + isset($row['message']) ? $row['message'] : '', + isset($row['actor']) ? $row['actor'] : '', + isset($row['ip']) ? $row['ip'] : '', + isset($row['url']) ? $row['url'] : '', + isset($row['details_text']) ? $row['details_text'] : '', + )); + return stripos($haystack, $q) !== false; + })); + } + + return $rows; + } + + public static function clear($source, $actorId = 0) + { + $source = self::source($source); + if (empty($source['clearable'])) { + return false; + } + + if ($source['code'] === 'audit') { + $count = (int) DB::query('SELECT COUNT(*) FROM ' . AuditLog::table())->getValue(); + DB::query('DELETE FROM ' . AuditLog::table()); + AuditLog::record('events.audit_cleared', array( + 'actor_id' => (int) $actorId > 0 ? (int) $actorId : null, + 'target_type' => 'audit_log', + 'meta' => array('deleted_rows' => $count), + )); + return true; + } + + if ($source['code'] === 'referrers') { + return ReferrerLog::clear(); + } + + $path = self::path($source['code']); + if (is_file($path)) { + unlink($path); + } + + return true; + } + + public static function csv($source, $q = '', $limit = 1000) + { + if (self::source($source)['code'] === 'referrers') { + return self::referrerCsv($q, $limit); + } + + $rows = self::rows($source, $q, $limit); + $fh = fopen('php://temp', 'r+'); + fputcsv($fh, array('time', 'state', 'ip', 'actor', 'action', 'url', 'details')); + foreach ($rows as $row) { + fputcsv($fh, array( + $row['time_display'], + $row['state_label'], + $row['ip'], + $row['actor'], + $row['message'], + $row['url'], + $row['details_text'], + )); + } + + rewind($fh); + $csv = stream_get_contents($fh); + fclose($fh); + return $csv; + } + + protected static function count($source) + { + if ($source === 'audit') { + return (int) DB::query('SELECT COUNT(*) FROM ' . AuditLog::table())->getValue(); + } + + if ($source === 'referrers') { + return ReferrerLog::count(); + } + + return count(self::readCsv($source, 100000)); + } + + protected static function sourceSize($source) + { + if (in_array($source, array('audit', 'referrers'), true)) { + return 'БД'; + } + + $path = self::path($source); + return is_file($path) ? self::formatBytes(filesize($path)) : '0 Б'; + } + + protected static function auditRows($limit) + { + $rows = DB::query( + 'SELECT * FROM ' . AuditLog::table() . ' ORDER BY created_at DESC, id DESC LIMIT ' . (int) $limit + )->getAll(); + + $out = array(); + foreach ($rows as $row) { + $meta = self::decodeMeta(isset($row['meta']) ? $row['meta'] : ''); + $state = self::auditState((string) $row['action']); + $out[] = array( + 'id' => (int) $row['id'], + 'source' => 'audit', + 'level' => $state, + 'time' => strtotime($row['created_at']), + 'time_display' => self::formatTime(strtotime($row['created_at'])), + 'ip' => (string) $row['ip'], + 'actor' => $row['actor_name'] !== '' ? $row['actor_name'] : ('#' . (int) $row['actor_id']), + 'message' => AuditLog::actionLabel($row['action']), + 'url' => trim((string) $row['target_type'] . ($row['target_id'] ? ' #' . $row['target_id'] : '')), + 'details' => $meta, + 'details_text' => self::detailsText($meta), + ) + self::state($state); + } + + return $out; + } + + protected static function legacyRows($source, $limit) + { + $rows = self::readCsv($source, $limit); + $out = array(); + foreach ($rows as $i => $event) { + if ($source === 'legacy') { + $out[] = self::legacyEventRow($event, $i); + } elseif ($source === '404') { + $out[] = self::notFoundRow($event, $i); + } elseif ($source === 'sql') { + $out[] = self::sqlRow($event, $i); + } + } + + return array_reverse($out); + } + + protected static function legacyEventRow(array $event, $i) + { + $state = self::legacyState(self::at($event, 6)); + return array( + 'id' => $i, + 'source' => 'legacy', + 'level' => $state, + 'time' => (int) self::at($event, 0), + 'time_display' => self::formatTime(self::at($event, 0)), + 'ip' => (string) self::at($event, 1), + 'actor' => (string) self::at($event, 4, 'Anonymous'), + 'message' => (string) self::at($event, 5), + 'url' => (string) self::at($event, 2), + 'details' => array('type' => self::at($event, 6), 'rubric' => self::at($event, 7)), + 'details_text' => 'type: ' . self::at($event, 6) . ', rubric: ' . self::at($event, 7), + ) + self::state($state); + } + + protected static function notFoundRow(array $event, $i) + { + $details = array( + 'user_agent' => self::at($event, 3), + 'referer' => self::at($event, 4), + 'query' => self::at($event, 2), + ); + return array( + 'id' => $i, + 'source' => '404', + 'level' => 'warning', + 'time' => (int) self::at($event, 0), + 'time_display' => self::formatTime(self::at($event, 0)), + 'ip' => (string) self::at($event, 1), + 'actor' => 'Гость', + 'message' => '404: ' . (string) self::at($event, 5), + 'url' => (string) self::at($event, 5), + 'details' => $details, + 'details_text' => self::detailsText($details), + ) + self::state('warning'); + } + + protected static function sqlRow(array $event, $i) + { + $raw = base64_decode((string) self::at($event, 5), true); + $data = $raw !== false ? @unserialize($raw, array('allowed_classes' => false)) : array(); + if (!is_array($data)) { + $data = array('raw' => (string) self::at($event, 5)); + } + + return array( + 'id' => $i, + 'source' => 'sql', + 'level' => 'error', + 'time' => (int) self::at($event, 0), + 'time_display' => self::formatTime(self::at($event, 0)), + 'ip' => (string) self::at($event, 1), + 'actor' => (string) self::at($event, 4, 'Anonymous'), + 'message' => isset($data['sql_error']) ? (string) $data['sql_error'] : 'SQL ошибка', + 'url' => (string) self::at($event, 2), + 'details' => $data, + 'details_text' => self::detailsText($data), + ) + self::state('error'); + } + + protected static function referrerRows($limit, $query) + { + $types = array( + 'campaign' => array('label' => 'Кампания', 'badge' => 'badge-blue', 'state' => 'change'), + 'search' => array('label' => 'Поиск', 'badge' => 'badge-green', 'state' => 'success'), + 'social' => array('label' => 'Соцсеть', 'badge' => 'badge-cyan', 'state' => 'info'), + 'referral' => array('label' => 'Ссылка', 'badge' => 'badge-amber', 'state' => 'warning'), + ); + $out = array(); + foreach (ReferrerLog::rows($limit, $query) as $row) { + $type = isset($types[$row['source_type']]) ? $types[$row['source_type']] : $types['referral']; + $tracking = Json::toArray(isset($row['tracking_json']) ? $row['tracking_json'] : '', array()); + $details = array( + 'referer' => isset($row['referer_url']) ? $row['referer_url'] : '', + 'first_seen' => self::formatTime(isset($row['first_seen_at']) ? $row['first_seen_at'] : 0), + 'last_seen' => self::formatTime(isset($row['last_seen_at']) ? $row['last_seen_at'] : 0), + 'tracking' => $tracking, + 'user_agent' => isset($row['user_agent']) ? $row['user_agent'] : '', + ); + $out[] = array( + 'id' => (int) $row['id'], + 'source' => 'referrers', + 'level' => $type['state'], + 'time' => (int) $row['last_seen_at'], + 'time_display' => self::formatTime($row['last_seen_at']), + 'ip' => '', + 'actor' => (string) $row['source_name'], + 'message' => (string) $row['landing_path'], + 'url' => (string) $row['referer_url'], + 'details' => $details, + 'details_text' => self::detailsText($details), + 'source_name' => (string) $row['source_name'], + 'source_type_label' => $type['label'], + 'source_type_badge' => $type['badge'], + 'hits' => (int) $row['hits'], + ) + self::state($type['state']); + } + + return $out; + } + + protected static function referrerCsv($query, $limit) + { + $rows = self::referrerRows($limit, $query); + $fh = fopen('php://temp', 'r+'); + fputcsv($fh, array('last_seen', 'type', 'source', 'landing_page', 'referer', 'hits', 'details')); + foreach ($rows as $row) { + fputcsv($fh, array( + $row['time_display'], + $row['source_type_label'], + $row['source_name'], + $row['message'], + $row['url'], + $row['hits'], + $row['details_text'], + )); + } + + rewind($fh); + $csv = stream_get_contents($fh); + fclose($fh); + return $csv; + } + + protected static function state($state) + { + $states = self::states(); + $state = isset($states[$state]) ? $state : 'info'; + return array( + 'state' => $state, + 'state_label' => $states[$state]['label'], + 'state_badge' => $states[$state]['badge'], + 'state_icon' => $states[$state]['icon'], + 'state_class' => $states[$state]['class'], + 'badge' => $states[$state]['badge'], + ); + } + + protected static function auditState($action) + { + $action = strtolower((string) $action); + + if (preg_match('/(error|failed|failure|exception|denied|blocked)/', $action)) { + return 'error'; + } + + if (preg_match('/(deleted|purged|clear|cleared|reset|revoked|disabled)/', $action)) { + return 'danger'; + } + + if (preg_match('/(warning|expired|timeout|missing|invalid)/', $action)) { + return 'warning'; + } + + if (preg_match('/(created|restored|enabled|login|logged)/', $action)) { + return 'success'; + } + + if (preg_match('/(updated|changed|copied|saved|settings|access)/', $action)) { + return 'change'; + } + + return 'info'; + } + + protected static function legacyState($type) + { + $type = (int) $type; + if ($type < 0) { + return 'error'; + } + + if ($type > 1) { + return 'warning'; + } + + if ($type === 1) { + return 'change'; + } + + return 'success'; + } + + protected static function readCsv($source, $limit) + { + $path = self::path($source); + if (!is_file($path)) { + return array(); + } + + $rows = array(); + if (($fh = fopen($path, 'rb')) !== false) { + while (($row = fgetcsv($fh, 0, ',')) !== false) { + if (!isset($row[0]) || $row[0] === '') { + continue; + } + + $rows[] = $row; + if (count($rows) > $limit) { + array_shift($rows); + } + } + + fclose($fh); + } + + return $rows; + } + + protected static function path($source) + { + $map = array( + 'legacy' => BASEPATH . '/tmp/logs/log.csv', + '404' => BASEPATH . '/tmp/logs/404.csv', + 'sql' => BASEPATH . '/tmp/logs/sql.csv', + ); + return isset($map[$source]) ? $map[$source] : ''; + } + + protected static function at(array $row, $index, $default = '') + { + return isset($row[$index]) ? $row[$index] : $default; + } + + protected static function decodeMeta($json) + { + return Json::toArray($json, array()); + } + + protected static function detailsText($data) + { + if (!is_array($data) || empty($data)) { + return ''; + } + + return Json::encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + protected static function formatTime($value) + { + $ts = is_numeric($value) ? (int) $value : strtotime((string) $value); + if ($ts <= 0) { + return ''; + } + + return date('d.m.Y H:i:s', $ts); + } + + protected static function formatBytes($bytes) + { + $bytes = (int) $bytes; + if ($bytes < 1024) return $bytes . ' Б'; + if ($bytes < 1048576) return round($bytes / 1024, 1) . ' КБ'; + return round($bytes / 1048576, 1) . ' МБ'; + } + } diff --git a/adminx/modules/Events/assets/events.css b/adminx/modules/Events/assets/events.css new file mode 100644 index 0000000..a963b70 --- /dev/null +++ b/adminx/modules/Events/assets/events.css @@ -0,0 +1,257 @@ +.events-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; + margin-bottom: 16px; +} +.events-source { + display: flex; + gap: 12px; + color: inherit; + text-decoration: none; + min-height: 124px; +} +.events-source:hover, +.events-source:focus, +.events-source:focus-visible, +.events-source * { + text-decoration: none; +} +.events-source.active { + box-shadow: inset 0 0 0 2px var(--color-primary); +} +.events-source-title { + font-weight: 700; + font-size: 14px; + margin-bottom: 4px; +} +.events-source-meta { + margin-top: 10px; +} +.events-tabs { + margin-bottom: 16px; +} +.events-source-help { + margin-bottom: 16px; +} +.events-source-help p { + margin: 3px 0 0; +} +.events-tabs .tab, +.events-tabs .tab:hover, +.events-tabs .tab:focus, +.events-tabs .tab:focus-visible { + text-decoration: none; +} +.events-panel-header { + margin-bottom: 18px; +} +.events-panel-header h2 { + font-size: 22px; + line-height: 1.2; + font-weight: 800; + letter-spacing: 0; +} +.events-panel-header .section-desc { + font-size: 13.5px; + line-height: 1.45; + text-wrap: pretty; +} +.events-filter { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.events-filter .input-wrap { + width: 260px; +} +.events-filter .select { + width: 96px; +} +.events-card { + padding: 0; +} +.events-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid var(--border-default); +} +.events-section-title { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} +.events-head-icon { + width: 34px; + height: 34px; + border-radius: var(--radius-md); + font-size: 17px; +} +.events-section-head h2 { + font-size: 16px; + line-height: 1.25; + font-weight: 700; + margin: 0 0 4px; + text-wrap: balance; +} +.events-section-head p { + margin: 0; + font-size: 13px; + line-height: 1.4; + font-weight: 400; + text-wrap: pretty; +} +.events-section-meta { + flex: 0 0 auto; + padding-top: 3px; +} +.events-section-meta .badge { + font-size: 11px; + font-weight: 600; +} +.events-table { + table-layout: fixed; + min-width: 1080px; +} +.events-table th, +.events-table td { + vertical-align: middle; + font-size: 13px; + line-height: 1.35; +} +.events-table th { + font-size: 11.5px; + font-weight: 700; +} +.events-col-time { + width: 154px; +} +.events-col-level { + width: 126px; +} +.events-col-actor { + width: 160px; +} +.events-col-ip { + width: 132px; +} +.events-col-actions { + width: 56px; +} +.events-col-ref-type { + width: 116px; +} +.events-col-ref-source { + width: 190px; +} +.events-col-hits { + width: 96px; +} +.events-referrer-source { + font-weight: 700; + overflow-wrap: anywhere; +} +.events-referrer-url { + margin-top: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.events-source-kind { + min-width: 76px; + justify-content: center; +} +.events-hit-count { + min-width: 40px; + justify-content: center; + font-variant-numeric: tabular-nums; +} +.events-message { + font-weight: 600; + overflow-wrap: anywhere; +} +.events-row td:first-child { + position: relative; +} +.events-row td:first-child::before { + content: ""; + position: absolute; + left: 0; + top: 9px; + bottom: 9px; + width: 3px; + border-radius: 3px; + background: var(--event-state-color, var(--gray-300)); +} +.events-row.state-success { + --event-state-color: var(--green-500); +} +.events-row.state-change { + --event-state-color: var(--blue-500); +} +.events-row.state-warning { + --event-state-color: var(--amber-500); +} +.events-row.state-error, +.events-row.state-danger { + --event-state-color: var(--red-500); +} +.events-row.state-info { + --event-state-color: var(--gray-400); +} +.events-state { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 86px; + justify-content: center; + font-size: 11px; + font-weight: 600; +} +.events-state i { + font-size: 13px; +} +.events-details { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 1.45; + color: var(--text-secondary); + background: var(--background-muted); + border-radius: var(--radius-sm); + padding: 12px; +} +.events-details-row td { + background: var(--background-inset); +} +.events-empty { + border: none; + min-height: 260px; +} +@media (max-width: 760px) { + .events-table-wrap { + max-height: none; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + } + .events-table, + .events-table tbody, + .events-table tr, + .events-table th, + .events-table td { + height: auto; + } + .events-filter, + .events-filter .input-wrap, + .events-filter .select, + .events-filter .btn { + width: 100%; + } +} diff --git a/adminx/modules/Events/assets/events.js b/adminx/modules/Events/assets/events.js new file mode 100644 index 0000000..455fc61 --- /dev/null +++ b/adminx/modules/Events/assets/events.js @@ -0,0 +1,71 @@ +/** + * JS раздела «Системные события»: раскрытие деталей и очистка legacy-логов. + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.Events = { + init: function () { + var self = this; + document.addEventListener('click', function (e) { + var details = e.target.closest('[data-events-details]'); + if (details) { + self.toggleDetails(details); + return; + } + + var clear = e.target.closest('[data-events-clear]'); + if (clear) { + self.clear(clear.getAttribute('data-events-clear')); + } + }); + }, + + base: function () { + return Adminx.base(); + }, + + toggleDetails: function (button) { + var row = button.closest('tr'); + var details = row ? row.nextElementSibling : null; + if (!details || !details.classList.contains('events-details-row')) { return; } + var hidden = details.hasAttribute('hidden'); + if (hidden) { + details.removeAttribute('hidden'); + button.classList.add('active'); + } else { + details.setAttribute('hidden', 'hidden'); + button.classList.remove('active'); + } + }, + + clear: function (source) { + var self = this; + Adminx.Confirm.open({ + kind: 'error', + title: 'Очистить журнал?', + message: 'Все записи выбранного журнала будут удалены. В аудите останется только запись о самой очистке.', + confirmLabel: 'Очистить', + confirmClass: 'btn-danger', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(self.base() + '/events/' + encodeURIComponent(source) + '/clear').then(function (payload) { + Adminx.Loader.hide(); + Adminx.Ajax.handle(payload); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.Events.init(); }); + } else { + Adminx.Events.init(); + } +})(window, document); diff --git a/adminx/modules/Events/language/en/client.xml b/adminx/modules/Events/language/en/client.xml new file mode 100644 index 0000000..7ea7d96 --- /dev/null +++ b/adminx/modules/Events/language/en/client.xml @@ -0,0 +1,9 @@ + + + Error + Clear log? + Clear + All entries in the selected journal will be deleted. The audit will only contain a record of the cleaning itself. + Network error + Error + diff --git a/adminx/modules/Events/language/en/interface.xml b/adminx/modules/Events/language/en/interface.xml new file mode 100644 index 0000000..3678087 --- /dev/null +++ b/adminx/modules/Events/language/en/interface.xml @@ -0,0 +1,53 @@ + + + Audit of actions, errors and system logs of the site + For the selected source, the log is empty or the file has not yet been created. + Magazine + Transition log + No entries + External entrances and transitions with advertising labels will appear here. + Event Sources + Clear + No transitions yet + Transitions + Search by magazine + User + Latest posts of the selected source with filter and details. + Last entry + Grouped external inputs, sources and advertising labels. + Event + List of events + Login page + Service messages of the public runtime are displayed here: results of background operations, warnings and records of old integrations. Administrators' actions are located in the "Audit" tab; SQL and 404 are displayed separately. + What goes into the application log + 0 B + SQL errors + Administrative actions in the control panel. + Audit + DB + External sources, advertising tags and login pages. + Attention + All entries in the selected journal will be deleted. The audit will only contain a record of the cleaning itself. + Access to the system event log and logs. + Log cleared + Application log + Requests to non-existent pages. + Change + Info + Insufficient rights + Dangerous + Clear log? + Error + Network error + 404 errors + Errors in website SQL queries. + System + System events + Events + Events: viewing + Events: management + Runtime messages that the site code writes to the system log. + Success + Export and clear event logs. + This source cannot be purified + diff --git a/adminx/modules/Events/language/en/runtime.xml b/adminx/modules/Events/language/en/runtime.xml new file mode 100644 index 0000000..2be5f5f --- /dev/null +++ b/adminx/modules/Events/language/en/runtime.xml @@ -0,0 +1,40 @@ + + + 0 B + SQL error + SQL errors + Administrative actions in the control panel. + Audit + B + DB + External sources, advertising tags and login pages. + Attention + Guest + Access to the system event log and logs. + Log cleared + Application log + Requests to non-existent pages. + Change + Info + KB + Campaign + MB + Insufficient rights + Dangerous + Error + 404 errors + Errors in website SQL queries. + Transitions + Search + System + System events + Events + Events: viewing + Events: management + Runtime messages that the site code writes to the system log. + Social network + Link + Success + Export and clear event logs. + This source cannot be purified + diff --git a/adminx/modules/Events/language/ru/client.xml b/adminx/modules/Events/language/ru/client.xml new file mode 100644 index 0000000..11b11a9 --- /dev/null +++ b/adminx/modules/Events/language/ru/client.xml @@ -0,0 +1,9 @@ + + + Ошибка + Очистить журнал? + Очистить + Все записи выбранного журнала будут удалены. В аудите останется только запись о самой очистке. + Ошибка сети + Ошибка + diff --git a/adminx/modules/Events/language/ru/interface.xml b/adminx/modules/Events/language/ru/interface.xml new file mode 100644 index 0000000..a182447 --- /dev/null +++ b/adminx/modules/Events/language/ru/interface.xml @@ -0,0 +1,53 @@ + + + Аудит действий, ошибки и системные журналы сайта + Для выбранного источника журнал пуст или файл ещё не создан. + Журнал + Журнал переходов + Записей нет + Здесь появятся внешние входы и переходы с рекламными метками. + Источники событий + Очистить + Переходов пока нет + Переходы + Поиск по журналу + Пользователь + Последние записи выбранного источника с фильтром и деталями. + Последний вход + Сгруппированные внешние входы, источники и рекламные метки. + Событие + Список событий + Страница входа + Сюда выводятся служебные сообщения публичного runtime: результаты фоновых операций, предупреждения и записи старых интеграций. Действия администраторов находятся во вкладке «Аудит», SQL и 404 вынесены отдельно. + Что попадает в журнал приложения + 0 Б + SQL ошибки + Административные действия в панели управления. + Аудит + БД + Внешние источники, рекламные метки и страницы входа. + Внимание + Все записи выбранного журнала будут удалены. В аудите останется только запись о самой очистке. + Доступ к журналу системных событий и логам. + Журнал очищен + Журнал приложения + Запросы к несуществующим страницам. + Изменение + Инфо + Недостаточно прав + Опасное + Очистить журнал? + Ошибка + Ошибка сети + Ошибки 404 + Ошибки SQL-запросов сайта. + Система + Системные события + События + События: просмотр + События: управление + Сообщения runtime, которые код сайта записывает в системный журнал. + Успех + Экспорт и очистка журналов событий. + Этот источник нельзя очистить + diff --git a/adminx/modules/Events/language/ru/runtime.xml b/adminx/modules/Events/language/ru/runtime.xml new file mode 100644 index 0000000..31e748f --- /dev/null +++ b/adminx/modules/Events/language/ru/runtime.xml @@ -0,0 +1,40 @@ + + + 0 Б + SQL ошибка + SQL ошибки + Административные действия в панели управления. + Аудит + Б + БД + Внешние источники, рекламные метки и страницы входа. + Внимание + Гость + Доступ к журналу системных событий и логам. + Журнал очищен + Журнал приложения + Запросы к несуществующим страницам. + Изменение + Инфо + КБ + Кампания + МБ + Недостаточно прав + Опасное + Ошибка + Ошибки 404 + Ошибки SQL-запросов сайта. + Переходы + Поиск + Система + Системные события + События + События: просмотр + События: управление + Сообщения runtime, которые код сайта записывает в системный журнал. + Соцсеть + Ссылка + Успех + Экспорт и очистка журналов событий. + Этот источник нельзя очистить + diff --git a/adminx/modules/Events/migrations/001_materialize_event_logs.sql b/adminx/modules/Events/migrations/001_materialize_event_logs.sql new file mode 100644 index 0000000..77f217e --- /dev/null +++ b/adminx/modules/Events/migrations/001_materialize_event_logs.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS `{{system_prefix}}_audit_log` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `actor_id` INT UNSIGNED NULL, + `actor_name` VARCHAR(190) NOT NULL DEFAULT '', + `action` VARCHAR(100) NOT NULL, + `target_type` VARCHAR(50) NOT NULL DEFAULT '', + `target_id` INT UNSIGNED NULL, + `meta` MEDIUMTEXT NULL, + `ip` VARCHAR(45) NOT NULL DEFAULT '', + `user_agent` VARCHAR(255) NOT NULL DEFAULT '', + `created_at` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_created` (`created_at`), + KEY `idx_action` (`action`), + KEY `idx_actor` (`actor_id`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `{{system_prefix}}_referrer_log` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `log_date` DATE NOT NULL, + `visitor_hash` CHAR(32) NOT NULL, + `source_type` VARCHAR(24) NOT NULL, + `source_name` VARCHAR(190) NOT NULL, + `referer_host` VARCHAR(190) NOT NULL DEFAULT '', + `referer_url` VARCHAR(1000) NOT NULL DEFAULT '', + `landing_path` VARCHAR(1000) NOT NULL, + `utm_source` VARCHAR(255) NOT NULL DEFAULT '', + `utm_medium` VARCHAR(255) NOT NULL DEFAULT '', + `utm_campaign` VARCHAR(255) NOT NULL DEFAULT '', + `utm_term` VARCHAR(255) NOT NULL DEFAULT '', + `utm_content` VARCHAR(255) NOT NULL DEFAULT '', + `tracking_json` TEXT NULL, + `user_agent` VARCHAR(500) NOT NULL DEFAULT '', + `first_seen_at` INT UNSIGNED NOT NULL, + `last_seen_at` INT UNSIGNED NOT NULL, + `hits` INT UNSIGNED NOT NULL DEFAULT 1, + `dedupe_hash` CHAR(64) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_dedupe` (`dedupe_hash`), + KEY `idx_last_seen` (`last_seen_at`), + KEY `idx_log_date` (`log_date`), + KEY `idx_source` (`source_type`, `source_name`(167)) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Events/module.php b/adminx/modules/Events/module.php new file mode 100644 index 0000000..e5b0704 --- /dev/null +++ b/adminx/modules/Events/module.php @@ -0,0 +1,71 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + return array( + 'code' => 'events', + 'name' => 'Системные события', + 'version' => '0.1.1', + + 'permissions' => array( + 'key' => 'events', + 'items' => array( + array( + 'code' => 'view_events', + 'group_code' => 'navigation', + 'name' => 'События: просмотр', + 'description' => 'Доступ к журналу системных событий и логам.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_events', + 'group_code' => 'admin', + 'name' => 'События: управление', + 'description' => 'Экспорт и очистка журналов событий.', + 'sort_order' => 20, + ), + ), + 'icon' => 'ti ti-history', + 'priority' => 40, + ), + + 'navigation' => array( + array( + 'code' => 'events', + 'label' => 'События', + 'url' => '/events', + 'icon' => 'ti ti-history', + 'permission' => 'view_events', + 'group' => 'Система', + 'sort_order' => 35, + 'match' => array( + '/events', + ), + ), + ), + + 'routes' => array( + array('GET', '/events', array(\App\Adminx\Events\Controller::class, 'index')), + array('GET', '/events/export/{source}', array(\App\Adminx\Events\Controller::class, 'export')), + array('POST', '/events/{source}/clear', array(\App\Adminx\Events\Controller::class, 'clear')), + ), + 'migrations' => array( + array('id' => '001_materialize_event_logs', 'file' => 'migrations/001_materialize_event_logs.sql'), + ), + + 'view_globals' => array( + 'module_code' => 'events', + ), + ); diff --git a/adminx/modules/Events/view/index.twig b/adminx/modules/Events/view/index.twig new file mode 100644 index 0000000..2cfbd8e --- /dev/null +++ b/adminx/modules/Events/view/index.twig @@ -0,0 +1,203 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Системные события{% endblock %} +{% block content %} + + + + + + + +
    + {% for source in sources %} + {{ source.label }}{{ source.count }} + {% endfor %} +
    + +{% if active_source == 'legacy' %} +
    Что попадает в журнал приложения

    Сюда выводятся служебные сообщения публичного runtime: результаты фоновых операций, предупреждения и записи старых интеграций. Действия администраторов находятся во вкладке «Аудит», SQL и 404 вынесены отдельно.

    +{% endif %} + +
    +
    +
    +
    +
    Журнал
    +

    {{ active_source_def.label }}

    +

    {{ active_source_def.description }}

    +
    +
    +
    + +
    + + +
    + + +
    +
    +
    + +
    +
    +
    + +
    +

    {{ active_source == 'referrers' ? 'Журнал переходов' : 'Список событий' }}

    +

    {{ active_source == 'referrers' ? 'Сгруппированные внешние входы, источники и рекламные метки.' : 'Последние записи выбранного источника с фильтром и деталями.' }}

    +
    +
    + +
    +
    + + {% if active_source == 'referrers' %} + + + + + + + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + {% if row.details_text %} + + + + {% endif %} + {% else %} + + + + {% endfor %} + + {% else %} + + + + + + + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + {% if row.details_text %} + + + + {% endif %} + {% else %} + + + + {% endfor %} + + {% endif %} +
    Последний входТипИсточникСтраница входаПереходы
    {{ row.time_display }}{{ row.source_type_label }}
    {{ row.source_name }}
    +
    {{ row.message }}
    + {% if row.url %}
    {{ row.url }}
    {% endif %} +
    {{ row.hits }} + {% if row.details_text %} + + {% endif %} +
    +
    +
    +

    Переходов пока нет

    +

    Здесь появятся внешние входы и переходы с рекламными метками.

    +
    +
    ВремяСостояниеПользовательСобытиеIP
    {{ row.time_display }}{{ row.state_label }}{{ row.actor }} +
    {{ row.message }}
    + {% if row.url %}
    {{ row.url }}
    {% endif %} +
    {{ row.ip }} + {% if row.details_text %} + + {% endif %} +
    +
    +
    +

    Записей нет

    +

    Для выбранного источника журнал пуст или файл ещё не создан.

    +
    +
    +
    +
    +
    + +{% endblock %} diff --git a/adminx/modules/Groups/Controller.php b/adminx/modules/Groups/Controller.php new file mode 100644 index 0000000..196c92f --- /dev/null +++ b/adminx/modules/Groups/Controller.php @@ -0,0 +1,159 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Groups; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Helpers\Request; + + /** + * Роли и права (RBAC). Список ролей — server-render; создание/правка и назначение + * прав — ajax из drawer по контракту success/error. Роль `admin` защищена + * (полный доступ, не редактируется/не удаляется). + */ + class Controller extends BaseController + { + /** GET /roles */ + public function index(array $params = []) + { + AdminAssets::addStyle($this->base() . '/modules/Groups/assets/groups.css', 50); + AdminAssets::addScript($this->base() . '/modules/Groups/assets/groups.js', 50); + + return $this->render('@groups/list.twig', [ + 'roles' => Model::all(), + 'permissions' => Model::allPermissionsGrouped(), + 'permission_module_labels' => Model::permissionModuleLabels(), + 'stats' => Model::stats(), + 'can_manage' => Permission::check('manage_roles'), + ]); + } + + /** GET /roles/{id} — роль + её права (JSON, для формы). */ + public function show(array $params = []) + { + $role = Model::find($params['id'] ?? 0); + if (!$role) { + return $this->error('Роль не найдена', [], 404); + } + + return $this->success('', ['data' => [ + 'id' => (int) $role->id, + 'code' => $role->code, + 'name' => $role->name, + 'is_system' => (int) $role->is_system, + 'permissions' => Model::permissionCodes($role->id), + ]]); + } + + /** POST /roles — создать роль. */ + public function store(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $code = strtolower(trim(Request::postStr('code'))); + $name = trim(Request::postStr('name')); + + $errors = []; + if (!preg_match('/^[a-z][a-z0-9_]{1,49}$/', $code)) { + $errors['code'] = 'Код: латиница/цифры/подчёркивание, с буквы.'; + } elseif (Model::codeExists($code)) { + $errors['code'] = 'Роль с таким кодом уже есть.'; + } + + if ($name === '') { + $errors['name'] = 'Укажите название.'; + } + + if ($errors) { + return $this->error('Проверьте поля формы', $errors); + } + + $id = Model::create($code, $name); + Model::setPermissions($id, $this->postedPerms()); + + return $this->success('Роль создана', ['redirect' => $this->base() . '/roles']); + } + + /** POST /roles/{id} — обновить название и права. */ + public function update(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $role = Model::find($params['id'] ?? 0); + if (!$role) { + return $this->error('Роль не найдена', [], 404); + } + + $name = trim(Request::postStr('name')); + if ($name === '') { + return $this->error('Проверьте поля формы', ['name' => 'Укажите название.']); + } + + Model::update($role->id, $name); + + //-- admin — всегда полный доступ (fallback ядра), права не редактируем. + if ($role->code !== 'admin') { + Model::setPermissions($role->id, $this->postedPerms()); + } + + return $this->success('Изменения сохранены', ['redirect' => $this->base() . '/roles']); + } + + /** POST /roles/{id}/delete — удалить роль. */ + public function destroy(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $role = Model::find($params['id'] ?? 0); + if (!$role) { + return $this->error('Роль не найдена', [], 404); + } + + if ((int) $role->is_system === 1) { + return $this->error('Системную роль нельзя удалить', [], 422); + } + + $used = Model::usersCount($role->code); + if ($used > 0) { + return $this->error('Роль назначена пользователям (' . $used . '), сначала переназначьте их', [], 422); + } + + Model::delete($role->id); + return $this->success('Роль удалена', ['redirect' => $this->base() . '/roles']); + } + + // ------------------------------------------------------------------ // + + protected function guard() + { + return $this->guardPermission('manage_roles'); + } + + /** Отмеченные права из формы (perms[]). */ + protected function postedPerms() + { + $perms = Request::post('perms', []); + return is_array($perms) ? $perms : []; + } + } diff --git a/adminx/modules/Groups/Model.php b/adminx/modules/Groups/Model.php new file mode 100644 index 0000000..28c706d --- /dev/null +++ b/adminx/modules/Groups/Model.php @@ -0,0 +1,232 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Groups; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Common\Language; + use App\Common\ModuleManager; + use App\Common\SystemTables; + + /** Роли и их права (RBAC) поверх {prefix}_roles / _role_permissions / _permissions. */ + class Model + { + protected static function roles() { return SystemTables::table('roles'); } + protected static function rolePerms() { return SystemTables::table('role_permissions'); } + protected static function permissions() { return SystemTables::table('permissions'); } + protected static function users() { return SystemTables::table('users'); } + + /** Список ролей со счётчиками пользователей и прав. */ + public static function all() + { + return DB::query( + 'SELECT r.id, r.code, r.name, r.is_system, + (SELECT COUNT(*) FROM ' . self::users() . ' u WHERE u.role = r.code) AS user_count, + (SELECT COUNT(*) FROM ' . self::rolePerms() . ' rp WHERE rp.role_id = r.id) AS perm_count + FROM ' . self::roles() . ' r + ORDER BY r.is_system DESC, r.name ASC' + )->getAll(); + } + + public static function find($id) + { + return DB::query('SELECT * FROM ' . self::roles() . ' WHERE id = %i LIMIT 1', (int) $id)->getObject(); + } + + /** Сводка для карточек: всего ролей / системных / всего прав. */ + public static function stats() + { + return [ + 'roles' => (int) DB::query('SELECT COUNT(*) FROM ' . self::roles())->getValue(), + 'system' => (int) DB::query('SELECT COUNT(*) FROM ' . self::roles() . ' WHERE is_system = 1')->getValue(), + 'permissions' => (int) DB::query('SELECT COUNT(*) FROM ' . self::permissions())->getValue(), + ]; + } + + public static function codeExists($code, $exceptId = 0) + { + return (bool) DB::query( + 'SELECT id FROM ' . self::roles() . ' WHERE code = %s AND id != %i LIMIT 1', + (string) $code, (int) $exceptId + )->getValue(); + } + + public static function usersCount($code) + { + return (int) DB::query('SELECT COUNT(*) FROM ' . self::users() . ' WHERE role = %s', (string) $code)->getValue(); + } + + /** Коды прав, назначенных роли. */ + public static function permissionCodes($roleId) + { + $rows = DB::query('SELECT permission_code FROM ' . self::rolePerms() . ' WHERE role_id = %i', (int) $roleId)->getAll(); + $out = []; + foreach ($rows ?: [] as $row) { + $out[] = (string) ((array) $row)['permission_code']; + } + + return $out; + } + + /** Все зарегистрированные права, сгруппированные по модулю. */ + public static function allPermissionsGrouped() + { + $rows = DB::query( + 'SELECT code, module, group_code, name, description FROM ' . self::permissions() . ' ORDER BY module ASC, sort_order ASC, code ASC' + )->getAll(); + + $groups = []; + foreach ($rows ?: [] as $row) { + $row = (array) $row; + $row['name'] = Language::translateSource(isset($row['name']) ? $row['name'] : ''); + $row['description'] = Language::translateSource(isset($row['description']) ? $row['description'] : ''); + $mod = $row['module'] !== '' ? $row['module'] : 'other'; + $groups[$mod][] = $row; + } + + $labels = self::permissionModuleLabels(); + uksort($groups, function ($left, $right) use ($labels) { + $leftLabel = isset($labels[$left]) ? $labels[$left] : $left; + $rightLabel = isset($labels[$right]) ? $labels[$right] : $right; + return strnatcasecmp($leftLabel, $rightLabel); + }); + + return $groups; + } + + public static function permissionModuleLabels() + { + $labels = array( + 'admin' => 'Ядро', + 'antispam' => 'Антиспам', + 'auth' => 'Доступ', + 'benchmark' => 'Производительность', + 'blocks' => 'Блоки', + 'catalog' => 'Каталог', + 'commerce' => 'Корзина и заказы', + 'console' => 'PHP-консоль', + 'contacts' => 'Контактные формы', + 'customers' => 'Пользователи сайта', + 'dashboard' => 'Дашборд', + 'database' => 'База данных', + 'document_jobs' => 'Операции с документами', + 'documents' => 'Документы', + 'events' => 'Системные события', + 'feeds' => 'Товарные фиды', + 'file_security' => 'Безопасность файлов', + 'groups' => 'Роли и права', + 'help' => 'Справка', + 'interactions' => 'Взаимодействия', + 'media' => 'Медиа', + 'modules' => 'Модули', + 'navigation' => 'Навигация', + 'notfound' => 'Ошибки 404', + 'other' => 'Прочее', + 'products' => 'Товары', + 'psb' => 'ПСБ / СФР', + 'ratings' => 'Рейтинги', + 'related' => 'Похожие документы', + 'requests' => 'Запросы', + 'rss' => 'RSS-каналы', + 'rubrics' => 'Рубрики и поля', + 'search' => 'Поиск по сайту', + 'security' => 'Безопасность', + 'seoaudit' => 'SEO-аудит', + 'settings' => 'Настройки', + 'site_readiness' => 'Готовность сайта', + 'system_health' => 'Мониторинг здоровья', + 'templates' => 'Шаблоны', + 'todo' => 'Задачи Todo', + 'updates' => 'Обновления', + 'users' => 'Пользователи', + 'vk_oauth' => 'Вход через VK ID', + 'yandex_oauth' => 'Вход через Яндекс', + 'yookassa' => 'YooKassa', + ); + foreach (ModuleManager::all() as $module) { + $code = isset($module['code']) ? trim((string) $module['code']) : ''; + $name = isset($module['name']) ? trim((string) $module['name']) : ''; + if ($code !== '' && $name !== '' && !isset($labels[$code])) { $labels[$code] = $name; } + } + + foreach ($labels as $code => $label) { + $labels[$code] = Language::translateSource($label); + } + + return $labels; + } + + /** Все валидные коды прав (для фильтрации входящего набора). */ + public static function validCodes() + { + $rows = DB::query('SELECT code FROM ' . self::permissions())->getAll(); + $out = []; + foreach ($rows ?: [] as $row) { + $out[] = (string) ((array) $row)['code']; + } + + return $out; + } + + public static function create($code, $name) + { + $now = date('Y-m-d H:i:s'); + DB::Insert(self::roles(), [ + 'code' => (string) $code, + 'name' => (string) $name, + 'is_system' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + return DB::insertId(); + } + + public static function update($id, $name) + { + DB::Update(self::roles(), [ + 'name' => (string) $name, + 'updated_at' => date('Y-m-d H:i:s'), + ], 'id = %i', (int) $id); + } + + /** Переписать набор прав роли (только валидные коды). */ + public static function setPermissions($roleId, array $codes) + { + $roleId = (int) $roleId; + $valid = array_flip(self::validCodes()); + + DB::Delete(self::rolePerms(), 'role_id = %i', $roleId); + + foreach (array_unique($codes) as $code) { + $code = trim((string) $code); + if ($code === '' || !isset($valid[$code])) { + continue; + } + + DB::query( + 'INSERT IGNORE INTO ' . self::rolePerms() . ' (role_id, permission_code) VALUES (%i, %s)', + $roleId, $code + ); + } + } + + public static function delete($id) + { + $id = (int) $id; + DB::Delete(self::rolePerms(), 'role_id = %i', $id); + DB::Delete(self::roles(), 'id = %i', $id); + } + } diff --git a/adminx/modules/Groups/Setup.php b/adminx/modules/Groups/Setup.php new file mode 100644 index 0000000..c14644f --- /dev/null +++ b/adminx/modules/Groups/Setup.php @@ -0,0 +1,126 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Groups; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Common\Permission; + use App\Common\SystemTables; + + /** + * Инициализация RBAC-схемы новой админки: таблицы ролей/прав, seed канонических + * ролей и синхронизация прав, объявленных модулями, в {prefix}_permissions. + * + * Схема совпадает с контрактом App\Common\Permission (forRole/syncRegistryToDb): + * _roles(id, code, name, is_system, ...) + * _role_permissions(role_id, permission_code) + * _permissions(code, module, group_code, name, description, sort_order) + * + * Роль `admin` при пустом наборе прав получает all_permissions (fallback ядра), + * поэтому явных строк ей не требуется. + */ + class Setup + { + /** Канонические роли новой админки (code => отображаемое имя). */ + public static function roleNames() + { + return array( + 'admin' => 'Администратор', + 'guest' => 'Гостевая', + 'moderator' => 'Модератор', + 'user' => 'Пользователи', + ); + } + + public static function ensureSchema() + { + DB::query( + 'CREATE TABLE IF NOT EXISTS ' . SystemTables::table('roles') . ' ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(50) NOT NULL, + name VARCHAR(150) NOT NULL DEFAULT "", + is_system TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NULL, + updated_at DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_code (code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + + DB::query( + 'CREATE TABLE IF NOT EXISTS ' . SystemTables::table('role_permissions') . ' ( + role_id INT UNSIGNED NOT NULL, + permission_code VARCHAR(100) NOT NULL, + PRIMARY KEY (role_id, permission_code), + KEY idx_role (role_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + + DB::query( + 'CREATE TABLE IF NOT EXISTS ' . SystemTables::table('permissions') . ' ( + code VARCHAR(100) NOT NULL, + module VARCHAR(50) NOT NULL DEFAULT "", + group_code VARCHAR(50) NOT NULL DEFAULT "", + name VARCHAR(200) NOT NULL DEFAULT "", + description VARCHAR(255) NOT NULL DEFAULT "", + sort_order INT NOT NULL DEFAULT 100, + PRIMARY KEY (code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + } + + /** Завести канонические роли + роли, встречающиеся у пользователей. */ + public static function seedRoles() + { + $names = self::roleNames(); + + //-- роли, реально используемые в users + $used = DB::query('SELECT DISTINCT role FROM ' . SystemTables::table('users') . ' WHERE role != ""')->getAll(); + foreach ($used ?: array() as $row) { + $code = trim((string) ((array) $row)['role']); + if ($code !== '' && !isset($names[$code])) { + $names[$code] = $code; + } + } + + $now = date('Y-m-d H:i:s'); + $seeded = 0; + foreach ($names as $code => $name) { + DB::query( + 'INSERT INTO ' . SystemTables::table('roles') . ' (code, name, is_system, created_at, updated_at) + VALUES (%s, %s, %i, %s, %s) + ON DUPLICATE KEY UPDATE name = IF(name = "", VALUES(name), name)', + $code, $name, (in_array($code, array('admin', 'guest', 'moderator', 'user'), true) ? 1 : 0), $now, $now + ); + $seeded++; + } + + return $seeded; + } + + /** Прогнать всё: схема + роли + синхронизация прав модулей. */ + public static function run() + { + self::ensureSchema(); + $roles = self::seedRoles(); + $perms = Permission::syncRegistryToDb(); + + return array( + 'roles' => $roles, + 'permissions' => $perms, + ); + } + } diff --git a/adminx/modules/Groups/assets/groups.css b/adminx/modules/Groups/assets/groups.css new file mode 100644 index 0000000..3fb932f --- /dev/null +++ b/adminx/modules/Groups/assets/groups.css @@ -0,0 +1,6 @@ +/* Стили раздела «Роли и права» (то, чего нет в общем UI-ките). */ +/* Размеры/отступы — общие (.ax-panel-header/.ax-summary). Здесь только акцент. */ +.groups-panel-header { + --section-accent: var(--violet-500); + --section-accent-soft: var(--violet-100); +} diff --git a/adminx/modules/Groups/assets/groups.js b/adminx/modules/Groups/assets/groups.js new file mode 100644 index 0000000..84696a4 --- /dev/null +++ b/adminx/modules/Groups/assets/groups.js @@ -0,0 +1,155 @@ +/** + * JS раздела «Роли и права». Drawer создания/правки роли + назначение прав + * (чекбоксы по модулям). Ajax по контракту success/error, подтверждение — + * Adminx.Confirm (без нативных промтов). + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.Groups = { + form: null, + + init: function () { + this.form = document.getElementById('roleForm'); + if (!this.form) { return; } + var self = this; + + document.addEventListener('click', function (e) { + if (e.target.closest('[data-role-new]')) { self.fillNew(); } + var edit = e.target.closest('[data-role-edit]'); + if (edit) { self.fillEdit(edit.closest('tr')); } + var del = e.target.closest('[data-role-delete]'); + if (del) { self.remove(del.closest('tr')); } + if (e.target.closest('[data-perms-all]')) { self.setAll(true); } + if (e.target.closest('[data-perms-none]')) { self.setAll(false); } + }); + + this.form.addEventListener('submit', function (e) { + e.preventDefault(); + self.submit(); + }); + }, + + base: function () { return this.form.getAttribute('data-base') || Adminx.base(); }, + + clearErrors: function () { + this.form.querySelectorAll('[data-error]').forEach(function (s) { s.textContent = ''; }); + this.form.querySelectorAll('.is-invalid').forEach(function (i) { i.classList.remove('is-invalid'); }); + }, + + field: function (name) { return this.form.querySelector('[name="' + name + '"]'); }, + + checks: function () { return this.form.querySelectorAll('input[name="perms[]"]'); }, + + setAll: function (on) { + this.checks().forEach(function (c) { if (!c.disabled) { c.checked = on; } }); + }, + + lockPerms: function (locked) { + var wrap = this.form.querySelector('[data-perms-wrap]'); + var note = this.form.querySelector('[data-role-admin-note]'); + this.checks().forEach(function (c) { c.disabled = locked; }); + if (wrap) { wrap.style.opacity = locked ? '0.5' : ''; } + // .alert{display:flex} перебивает атрибут hidden — переключаем через style.display + if (note) { note.style.display = locked ? '' : 'none'; } + }, + + fillNew: function () { + this.clearErrors(); + this.form.reset(); + this.field('id').value = ''; + var code = this.field('code'); + code.disabled = false; + this.setAll(false); + this.lockPerms(false); + document.getElementById('roleDrawerTitle').textContent = 'Новая роль'; + }, + + fillEdit: function (row) { + if (!row) { return; } + this.clearErrors(); + var self = this; + this.field('id').value = row.dataset.id; + this.field('name').value = row.dataset.name; + var code = this.field('code'); + code.value = row.dataset.code; + code.disabled = true; // код не меняем после создания + document.getElementById('roleDrawerTitle').textContent = 'Роль: ' + row.dataset.name; + this.setAll(false); + this.lockPerms(false); + + // подтягиваем назначенные права + Adminx.Ajax.request(this.base() + '/roles/' + row.dataset.id).then(function (payload) { + var d = payload.data || {}; + var codes = (d.data && d.data.permissions) || []; + var map = {}; + codes.forEach(function (c) { map[c] = true; }); + self.checks().forEach(function (c) { c.checked = !!map[c.value]; }); + if ((d.data && d.data.code) === 'admin') { self.lockPerms(true); } + }); + }, + + submit: function () { + this.clearErrors(); + var id = (this.field('id').value || '').trim(); + var url = this.base() + '/roles' + (id ? '/' + id : ''); + var fd = new FormData(this.form); + // отключённый code (при правке) не попадает в FormData — это ок, он неизменяем + var self = this; + + Adminx.Loader.show(); + Adminx.Ajax.post(url, fd).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { + Adminx.Ajax.handle(payload); + if (!d.redirect) { window.location.reload(); } + return; + } + var errors = d.errors || {}; + Object.keys(errors).forEach(function (f) { + var span = self.form.querySelector('[data-error="' + f + '"]'); + if (span) { span.textContent = errors[f]; } + var input = self.field(f); + if (input) { input.classList.add('is-invalid'); } + }); + if (d.message) { Adminx.Toast.show(d.message, 'error'); } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + remove: function (row) { + var id = row.dataset.id; + var base = this.base(); + Adminx.Confirm.open({ + kind: 'error', + title: 'Удалить роль?', + message: 'Роль «' + row.dataset.name + '» будет удалена вместе с назначением прав.', + confirmLabel: 'Удалить', + confirmClass: 'btn-danger', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(base + '/roles/' + id + '/delete').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { row.remove(); Adminx.Toast.show(d.message, 'success'); } + else { Adminx.Toast.show(d.message || 'Не удалось удалить', 'error'); } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.Groups.init(); }); + } else { + Adminx.Groups.init(); + } +})(window, document); diff --git a/adminx/modules/Groups/language/en/client.xml b/adminx/modules/Groups/language/en/client.xml new file mode 100644 index 0000000..e878309 --- /dev/null +++ b/adminx/modules/Groups/language/en/client.xml @@ -0,0 +1,11 @@ + + + " will be deleted along with the assignment of rights. + Role " + Failed to delete + Role: + Delete + Delete role? + Network error + New role + diff --git a/adminx/modules/Groups/language/en/interface.xml b/adminx/modules/Groups/language/en/interface.xml new file mode 100644 index 0000000..34ac8c3 --- /dev/null +++ b/adminx/modules/Groups/language/en/interface.xml @@ -0,0 +1,96 @@ + + + (Latin) + everything + always has full access - the set of rights cannot be edited. + Select all + For example, Content Manager + New role + Users + Right + Everything is right + Rights + Rights are not synchronized. Run + Raleigh + roles · assignment of access rights to sections + There are no roles - run setup-rbac + Roles + Access Roles + Roles and number of assigned rights; editing a set of rights by section. + systemic + System and user roles, employees and assigned rights. + System + Remove everything + Create a role + List + ), reassign them first + PHP console + RSS feeds + SEO audit + " will be deleted along with the assignment of rights. + Administrator + Antispam + Database + Security + File Security + Blocks + Interactions + Login via VK ID + Login via Yandex + Guestbook + Site readiness + Dashboard + Documents + Access + Access to the “Roles and Rights” section. + Todo tasks + Requests + Changes saved + Catalog + Code: Latin/numbers/underscore, starting with a letter. + Contact forms + Cart and orders + Media + Moderator + Modules + Health monitoring + Navigation + Settings + Failed to delete + Updates + Operations with documents + Network error + 404 errors + Search the site + Users + Site users + Related documents + Check the form fields + Productivity + View roles + Other + PSB/SFR + Ratings + Roles and rights + Role " + Role assigned to users ( + Role not found + There is already a role with this code. + Role created + Role deleted + Role: + Headings and fields + System + System role cannot be deleted + System events + Creating/modifying roles and assigning rights. + Help + Product feeds + Products + Delete + Delete role? + Please provide a name. + Role management + Templates + Core + diff --git a/adminx/modules/Groups/language/en/runtime.xml b/adminx/modules/Groups/language/en/runtime.xml new file mode 100644 index 0000000..7a62666 --- /dev/null +++ b/adminx/modules/Groups/language/en/runtime.xml @@ -0,0 +1,66 @@ + + + ), reassign them first + PHP console + RSS feeds + SEO audit + Administrator + Antispam + Database + Security + File Security + Blocks + Interactions + Login via VK ID + Login via Yandex + Guestbook + Site readiness + Dashboard + Documents + Access + Access to the “Roles and Rights” section. + Todo tasks + Requests + Changes saved + Catalog + Code: Latin/numbers/underscore, starting with a letter. + Contact forms + Cart and orders + Media + Moderator + Modules + Health monitoring + Navigation + Settings + Updates + Operations with documents + 404 errors + PSB/SFR + Search the site + Users + Site users + Related documents + Check the form fields + Productivity + View roles + Other + Ratings + Roles and rights + Role assigned to users ( + Role not found + There is already a role with this code. + Role created + Role deleted + Headings and fields + System + System role cannot be deleted + System events + Creating/modifying roles and assigning rights. + Help + Product feeds + Products + Please provide a name. + Role management + Templates + Core + diff --git a/adminx/modules/Groups/language/ru/client.xml b/adminx/modules/Groups/language/ru/client.xml new file mode 100644 index 0000000..5b7b670 --- /dev/null +++ b/adminx/modules/Groups/language/ru/client.xml @@ -0,0 +1,11 @@ + + + » будет удалена вместе с назначением прав. + Роль « + Не удалось удалить + Роль: + Удалить + Удалить роль? + Ошибка сети + Новая роль + diff --git a/adminx/modules/Groups/language/ru/interface.xml b/adminx/modules/Groups/language/ru/interface.xml new file mode 100644 index 0000000..21816d2 --- /dev/null +++ b/adminx/modules/Groups/language/ru/interface.xml @@ -0,0 +1,96 @@ + + + (латиницей) + все + всегда имеет полный доступ — набор прав не редактируется. + Выбрать все + Например, Контент-менеджер + Новая роль + Пользователей + Прав + Прав всего + Права + Права не синхронизированы. Запустите + Ролей + ролей · назначение прав доступа по разделам + Ролей нет — запустите setup-rbac + Роли + Роли доступа + Роли и число назначенных прав; редактирование набора прав по разделам. + системная + Системные и пользовательские роли, сотрудники и назначенные права. + Системных + Снять все + Создать роль + Список + ), сначала переназначьте их + PHP-консоль + RSS-каналы + SEO-аудит + » будет удалена вместе с назначением прав. + Администратор + Антиспам + База данных + Безопасность + Безопасность файлов + Блоки + Взаимодействия + Вход через VK ID + Вход через Яндекс + Гостевая + Готовность сайта + Дашборд + Документы + Доступ + Доступ к разделу «Роли и права». + Задачи Todo + Запросы + Изменения сохранены + Каталог + Код: латиница/цифры/подчёркивание, с буквы. + Контактные формы + Корзина и заказы + Медиа + Модератор + Модули + Мониторинг здоровья + Навигация + Настройки + Не удалось удалить + Обновления + Операции с документами + Ошибка сети + Ошибки 404 + Поиск по сайту + Пользователи + Пользователи сайта + Похожие документы + Проверьте поля формы + Производительность + Просмотр ролей + Прочее + ПСБ / СФР + Рейтинги + Роли и права + Роль « + Роль назначена пользователям ( + Роль не найдена + Роль с таким кодом уже есть. + Роль создана + Роль удалена + Роль: + Рубрики и поля + Система + Системную роль нельзя удалить + Системные события + Создание/изменение ролей и назначение прав. + Справка + Товарные фиды + Товары + Удалить + Удалить роль? + Укажите название. + Управление ролями + Шаблоны + Ядро + diff --git a/adminx/modules/Groups/language/ru/runtime.xml b/adminx/modules/Groups/language/ru/runtime.xml new file mode 100644 index 0000000..9611e09 --- /dev/null +++ b/adminx/modules/Groups/language/ru/runtime.xml @@ -0,0 +1,66 @@ + + + ), сначала переназначьте их + PHP-консоль + RSS-каналы + SEO-аудит + Администратор + Антиспам + База данных + Безопасность + Безопасность файлов + Блоки + Взаимодействия + Вход через VK ID + Вход через Яндекс + Гостевая + Готовность сайта + Дашборд + Документы + Доступ + Доступ к разделу «Роли и права». + Задачи Todo + Запросы + Изменения сохранены + Каталог + Код: латиница/цифры/подчёркивание, с буквы. + Контактные формы + Корзина и заказы + Медиа + Модератор + Модули + Мониторинг здоровья + Навигация + Настройки + Обновления + Операции с документами + Ошибки 404 + ПСБ / СФР + Поиск по сайту + Пользователи + Пользователи сайта + Похожие документы + Проверьте поля формы + Производительность + Просмотр ролей + Прочее + Рейтинги + Роли и права + Роль назначена пользователям ( + Роль не найдена + Роль с таким кодом уже есть. + Роль создана + Роль удалена + Рубрики и поля + Система + Системную роль нельзя удалить + Системные события + Создание/изменение ролей и назначение прав. + Справка + Товарные фиды + Товары + Укажите название. + Управление ролями + Шаблоны + Ядро + diff --git a/adminx/modules/Groups/migrations/001_register_public_debug_permission.sql b/adminx/modules/Groups/migrations/001_register_public_debug_permission.sql new file mode 100644 index 0000000..9d84a9b --- /dev/null +++ b/adminx/modules/Groups/migrations/001_register_public_debug_permission.sql @@ -0,0 +1,11 @@ +INSERT INTO `{{prefix}}_permissions` + (`code`, `module`, `group_code`, `name`, `description`, `sort_order`) +VALUES + ('view_public_debug', 'admin', 'core', 'Публичная панель отладки', + 'Показывает на публичном сайте запросы, ошибки, сессию и служебные данные страницы.', 3) +ON DUPLICATE KEY UPDATE + `module` = VALUES(`module`), + `group_code` = VALUES(`group_code`), + `name` = VALUES(`name`), + `description` = VALUES(`description`), + `sort_order` = VALUES(`sort_order`); diff --git a/adminx/modules/Groups/migrations/002_remove_observer_role.sql b/adminx/modules/Groups/migrations/002_remove_observer_role.sql new file mode 100644 index 0000000..41e4c3a --- /dev/null +++ b/adminx/modules/Groups/migrations/002_remove_observer_role.sql @@ -0,0 +1,11 @@ +UPDATE `{{prefix}}_users` +SET `role` = 'user' +WHERE `role` = 'observer'; + +DELETE rp +FROM `{{prefix}}_role_permissions` rp +INNER JOIN `{{prefix}}_roles` r ON r.`id` = rp.`role_id` +WHERE r.`code` = 'observer'; + +DELETE FROM `{{prefix}}_roles` +WHERE `code` = 'observer'; diff --git a/adminx/modules/Groups/migrations/003_seed_default_roles.sql b/adminx/modules/Groups/migrations/003_seed_default_roles.sql new file mode 100644 index 0000000..c111ba1 --- /dev/null +++ b/adminx/modules/Groups/migrations/003_seed_default_roles.sql @@ -0,0 +1,9 @@ +INSERT INTO `{{prefix}}_roles` (`code`,`name`,`is_system`,`created_at`,`updated_at`) VALUES + ('admin','Администратор',1,NOW(),NOW()), + ('guest','Гостевая',1,NOW(),NOW()), + ('moderator','Модератор',1,NOW(),NOW()), + ('user','Пользователи',1,NOW(),NOW()) +ON DUPLICATE KEY UPDATE + `name`=VALUES(`name`), + `is_system`=1, + `updated_at`=VALUES(`updated_at`); diff --git a/adminx/modules/Groups/migrations/004_merge_legacy_roles.sql b/adminx/modules/Groups/migrations/004_merge_legacy_roles.sql new file mode 100644 index 0000000..8cbc0b6 --- /dev/null +++ b/adminx/modules/Groups/migrations/004_merge_legacy_roles.sql @@ -0,0 +1,18 @@ +UPDATE `{{prefix}}_users` +SET `role` = 'moderator' +WHERE `role` IN ('manager','designer'); + +INSERT IGNORE INTO `{{prefix}}_role_permissions` (`role_id`,`permission_code`) +SELECT target.`id`, source_permissions.`permission_code` +FROM `{{prefix}}_roles` target +INNER JOIN `{{prefix}}_roles` source_roles ON source_roles.`code` IN ('manager','designer') +INNER JOIN `{{prefix}}_role_permissions` source_permissions ON source_permissions.`role_id` = source_roles.`id` +WHERE target.`code` = 'moderator'; + +DELETE source_permissions +FROM `{{prefix}}_role_permissions` source_permissions +INNER JOIN `{{prefix}}_roles` source_roles ON source_roles.`id` = source_permissions.`role_id` +WHERE source_roles.`code` IN ('manager','designer'); + +DELETE FROM `{{prefix}}_roles` +WHERE `code` IN ('manager','designer'); diff --git a/adminx/modules/Groups/migrations/005_register_development_site_permission.sql b/adminx/modules/Groups/migrations/005_register_development_site_permission.sql new file mode 100644 index 0000000..5f22601 --- /dev/null +++ b/adminx/modules/Groups/migrations/005_register_development_site_permission.sql @@ -0,0 +1,11 @@ +INSERT INTO `{{prefix}}_permissions` + (`code`, `module`, `group_code`, `name`, `description`, `sort_order`) +VALUES + ('view_development_site', 'admin', 'core', 'Просмотр сайта в режиме разработки', + 'Разрешает видеть публичный сайт, когда он временно закрыт для посетителей и поисковых систем.', 4) +ON DUPLICATE KEY UPDATE + `module` = VALUES(`module`), + `group_code` = VALUES(`group_code`), + `name` = VALUES(`name`), + `description` = VALUES(`description`), + `sort_order` = VALUES(`sort_order`); diff --git a/adminx/modules/Groups/module.php b/adminx/modules/Groups/module.php new file mode 100644 index 0000000..6e349aa --- /dev/null +++ b/adminx/modules/Groups/module.php @@ -0,0 +1,97 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + /** + * Раздел «Роли и права» (RBAC): роли + назначение прав, объявленных модулями. + * Закрывает Фазу 1 плана миграции (идентичность и доступ). + */ + return [ + 'code' => 'groups', + 'name' => 'Роли и права', + 'version' => '0.1.0', + + 'permissions' => [ + 'key' => 'groups', + 'items' => array( + array( + 'code' => 'view_roles', + 'group_code' => 'navigation', + 'name' => 'Просмотр ролей', + 'description' => 'Доступ к разделу «Роли и права».', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_roles', + 'group_code' => 'admin', + 'name' => 'Управление ролями', + 'description' => 'Создание/изменение ролей и назначение прав.', + 'sort_order' => 20, + ), + ), + 'icon' => 'ti ti-shield-lock', + 'priority' => 30, + ], + + 'navigation' => array( + array( + 'code' => 'roles', + 'label' => 'Роли и права', + 'url' => '/roles', + 'icon' => 'ti ti-shield-lock', + 'permission' => 'view_roles', + 'group' => 'Система', + 'sort_order' => 30, + 'match' => array( + '/roles', + ), + ), + ), + + 'routes' => array( + array('GET', '/roles', array(\App\Adminx\Groups\Controller::class, 'index')), + array('GET', '/roles/{id}', array(\App\Adminx\Groups\Controller::class, 'show')), + array('POST', '/roles', array(\App\Adminx\Groups\Controller::class, 'store')), + array('POST', '/roles/{id}', array(\App\Adminx\Groups\Controller::class, 'update')), + array('POST', '/roles/{id}/delete', array(\App\Adminx\Groups\Controller::class, 'destroy')), + ), + + 'migrations' => [ + [ + 'id' => '001_register_public_debug_permission', + 'file' => 'migrations/001_register_public_debug_permission.sql', + ], + [ + 'id' => '002_remove_observer_role', + 'file' => 'migrations/002_remove_observer_role.sql', + ], + [ + 'id' => '003_seed_default_roles', + 'file' => 'migrations/003_seed_default_roles.sql', + ], + [ + 'id' => '004_merge_legacy_roles', + 'file' => 'migrations/004_merge_legacy_roles.sql', + ], + [ + 'id' => '005_register_development_site_permission', + 'file' => 'migrations/005_register_development_site_permission.sql', + ], + ], + + 'view_globals' => [ + 'module_code' => 'groups', + ], + ]; diff --git a/adminx/modules/Groups/view/list.twig b/adminx/modules/Groups/view/list.twig new file mode 100644 index 0000000..b118fc7 --- /dev/null +++ b/adminx/modules/Groups/view/list.twig @@ -0,0 +1,153 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Роли и права{% endblock %} +{% block content %} + + + + + +
    +
    {{ stats.roles }}
    Ролей
    +
    {{ stats.system }}
    Системных
    +
    {{ stats.permissions }}
    Прав всего
    +
    + +
    +
    +
    +
    Список
    +

    Роли

    +

    Роли и число назначенных прав; редактирование набора прав по разделам.

    +
    +
    + +
    +
    +
    + +

    Роли доступа

    Системные и пользовательские роли, сотрудники и назначенные права.

    +
    + {{ roles|length }} +
    +
    + + + + + + + + + {% if can_manage %}{% endif %} + + + + {% for r in roles %} + + + + + + + {% if can_manage %} + + {% endif %} + + {% else %} + + {% endfor %} + +
    IDРольКодПользователейПрав
    {{ r.id }} + + + {{ r.name }} + {% if r.is_system %}системная{% endif %} + + {{ r.code }}{{ r.user_count }}{{ r.code == 'admin' ? 'все' : r.perm_count }} +
    + + {% if not r.is_system %} + + {% endif %} +
    +
    Ролей нет — запустите setup-rbac
    +
    +
    + +{% if can_manage %} + +{% endif %} + +{% endblock %} diff --git a/adminx/modules/Media/Controller.php b/adminx/modules/Media/Controller.php new file mode 100644 index 0000000..3a8d71c --- /dev/null +++ b/adminx/modules/Media/Controller.php @@ -0,0 +1,382 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Media; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Content\Documents\DocumentMediaDraft; + use App\Helpers\Request; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + AdminAssets::addStyle($this->base() . '/modules/Media/assets/media.css', 50); + AdminAssets::addScript($this->base() . '/modules/Media/assets/media.js', 50); + + $dir = Model::normalize(Request::getStr('dir', Model::ROOT), Model::ROOT); + $q = trim(Request::getStr('q', '')); + $type = Request::getStr('type', ''); + $type = in_array($type, array('image', 'file'), true) ? $type : ''; + $view = Request::getStr('view', 'grid') === 'table' ? 'table' : 'grid'; + $page = max(1, Request::getInt('page', 1)); + $perPage = 48; + + $listing = Model::listing($dir, $q, $type, $page, $perPage); + + return $this->render('@media/index.twig', array( + 'title' => 'Медиа', + 'dir' => $listing['dir'], + 'breadcrumbs' => Model::breadcrumbs($listing['dir']), + 'parent_dir' => Model::parentDir($listing['dir']), + 'folders' => $listing['folders'], + 'files' => $listing['files'], + 'stats' => $listing['stats'], + 'filters' => array('q' => $q, 'type' => $type, 'view' => $view), + 'page' => $listing['page'], + 'pages' => $listing['pages'], + 'total' => $listing['total'], + 'per_page' => $perPage, + 'can_manage' => Permission::check('manage_media'), + )); + } + + public function file(array $params = array()) + { + AdminAssets::addStyle($this->base() . '/modules/Media/assets/media.css', 50); + AdminAssets::addScript($this->base() . '/modules/Media/assets/media.js', 50); + + $file = Model::file(Request::getStr('path', '')); + if (!$file) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Файл не найден'), 404); + } + + return $this->render('@media/file.twig', array( + 'title' => 'Медиа: ' . $file['name'], + 'file' => $file, + 'parent_dir' => Model::parentDir($file['path']), + 'can_manage' => Permission::check('manage_media'), + 'supports_webp' => Model::supportsWebp(), + )); + } + + public function picker(array $params = array()) + { + $dir = Model::normalize(Request::getStr('dir', Model::ROOT), Model::ROOT); + $q = trim(Request::getStr('q', '')); + $type = Request::getStr('type', 'image'); + $type = in_array($type, array('image', 'file', 'all'), true) ? $type : 'image'; + $listingType = $type === 'all' ? '' : $type; + $page = max(1, Request::getInt('page', 1)); + $perPage = Request::getInt('per_page', 36); + $perPage = max(12, min(60, $perPage)); + + $listing = Model::listing($dir, $q, $listingType, $page, $perPage); + $folderListing = Model::listing($dir, $q, '', 1, 1); + + return $this->success('', array( + 'data' => array( + 'dir' => $listing['dir'], + 'type' => $type, + 'parent_dir' => Model::parentDir($listing['dir']), + 'breadcrumbs' => Model::breadcrumbs($listing['dir']), + 'folders' => $this->pickerFolders($folderListing['folders']), + 'files' => $this->pickerFiles($listing['files']), + 'page' => $listing['page'], + 'pages' => $listing['pages'], + 'total' => $listing['total'], + 'per_page' => $perPage, + ), + )); + } + + public function folderFiles(array $params = array()) + { + $dir = Model::normalize(Request::getStr('dir', Model::ROOT), Model::ROOT); + $type = Request::getStr('type', 'image'); + $type = in_array($type, array('image', 'file', 'all'), true) ? $type : 'image'; + $limit = Request::getInt('limit', 500); + $limit = max(1, min(1000, $limit)); + + try { + $files = Model::folderFiles($dir, $type === 'all' ? '' : $type, $limit); + } catch (\RuntimeException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('', array( + 'data' => array( + 'dir' => $dir, + 'type' => $type, + 'files' => $this->pickerFiles($files), + 'count' => count($files), + ), + )); + } + + public function upload(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + if (empty($_FILES['files']) || empty($_FILES['files']['name'])) { + return $this->error('Выберите файлы для загрузки'); + } + + try { + $draftToken = Request::postStr('media_draft_token', ''); + $dir = $draftToken !== '' + ? DocumentMediaDraft::uploadDirectory( + $draftToken, + Request::postInt('field_id', 0), + Auth::id(), + Request::postInt('document_id', 0) + ) + : Request::postStr('dir', Model::ROOT); + $result = Model::upload($dir, $_FILES['files']); + } catch (\RuntimeException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + if (empty($result['uploaded'])) { + return $this->error('Файлы не загружены', array('files' => implode(', ', $result['errors']))); + } + + $message = 'Загружено файлов: ' . count($result['uploaded']); + if (!empty($result['errors'])) { + $message .= '. Пропущено: ' . count($result['errors']); + } + + return $this->success($message, array( + 'data' => array('files' => $result['uploaded']), + 'redirect' => $this->base() . '/media?dir=' . rawurlencode($result['dir']), + )); + } + + public function createFolder(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $path = Model::createFolder(Request::postStr('dir', Model::ROOT), Request::postStr('name', '')); + } catch (\RuntimeException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Папка создана', array( + 'redirect' => $this->base() . '/media?dir=' . rawurlencode($path), + )); + } + + public function rename(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $target = Model::rename(Request::postStr('path', ''), Request::postStr('name', '')); + } catch (\RuntimeException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $redirect = is_file(Model::abs($target)) + ? $this->base() . '/media/file?path=' . rawurlencode($target) + : $this->base() . '/media?dir=' . rawurlencode($target); + + return $this->success('Переименовано', array('redirect' => $redirect)); + } + + public function delete(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $path = Request::postStr('path', ''); + $parent = Model::parentDir($path); + try { + Model::delete($path); + } catch (\RuntimeException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Удалено', array( + 'redirect' => $this->base() . '/media?dir=' . rawurlencode($parent ?: Model::ROOT), + )); + } + + public function clearThumbnails(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $dir = Request::postStr('path', Model::ROOT); + try { + $result = Model::clearThumbnails($dir); + } catch (\RuntimeException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $message = $result['files'] > 0 + ? 'Удалено превью: ' . $result['files'] + : 'В этой папке превью не найдены'; + + return $this->success($message, array( + 'data' => $result, + 'redirect' => $this->base() . '/media?dir=' . rawurlencode($dir), + )); + } + + public function transform(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $path = Request::postStr('path', ''); + try { + $out = Model::transform($path, array( + 'mode' => Request::postStr('mode', 'fit'), + 'width' => Request::postInt('width', 320), + 'height' => Request::postInt('height', 240), + 'format' => Request::postStr('format', 'original'), + 'quality' => Request::postInt('quality', 82), + 'crop_enabled' => Request::postStr('crop_enabled', ''), + 'crop_x' => Request::postInt('crop_x', 0), + 'crop_y' => Request::postInt('crop_y', 0), + 'crop_w' => Request::postInt('crop_w', 0), + 'crop_h' => Request::postInt('crop_h', 0), + )); + } catch (\RuntimeException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Изображение создано', array( + 'data' => array('path' => $out), + 'redirect' => $this->base() . '/media?dir=' . rawurlencode(Model::parentDir($out)), + )); + } + + public function preview(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $path = Request::postStr('path', ''); + try { + $preview = Model::previewTransform($path, array( + 'mode' => Request::postStr('mode', 'fit'), + 'width' => Request::postInt('width', 320), + 'height' => Request::postInt('height', 240), + 'format' => Request::postStr('format', 'original'), + 'quality' => Request::postInt('quality', 82), + 'crop_enabled' => Request::postStr('crop_enabled', ''), + 'crop_x' => Request::postInt('crop_x', 0), + 'crop_y' => Request::postInt('crop_y', 0), + 'crop_w' => Request::postInt('crop_w', 0), + 'crop_h' => Request::postInt('crop_h', 0), + )); + } catch (\RuntimeException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $meta = trim(($preview['width'] && $preview['height'] ? $preview['width'] . 'x' . $preview['height'] : '') + . ' · ' . strtoupper($preview['format']) + . ($preview['size_label'] !== '' ? ' · ' . $preview['size_label'] : ''), ' ·'); + return $this->success('', array('data' => array( + 'path' => $preview['path'], + 'url' => $preview['path'], + 'meta' => $meta, + ))); + } + + public function previewClear(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + Model::deletePreview(Request::postStr('path', '')); + return $this->success(''); + } + + public function convertWebp(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $out = Model::convertWebp(Request::postStr('path', ''), Request::postInt('quality', 82)); + } catch (\RuntimeException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('WebP-копия создана', array( + 'data' => array('path' => $out), + 'redirect' => $this->base() . '/media/file?path=' . rawurlencode($out), + )); + } + + protected function guard() + { + return $this->guardPermission('manage_media'); + } + + protected function pickerFolders(array $folders) + { + $out = array(); + foreach ($folders as $folder) { + $out[] = array( + 'name' => isset($folder['name']) ? $folder['name'] : '', + 'path' => isset($folder['path']) ? $folder['path'] : '', + 'count' => isset($folder['count']) ? (int) $folder['count'] : 0, + ); + } + + return $out; + } + + protected function pickerFiles(array $files) + { + $out = array(); + foreach ($files as $file) { + $out[] = array( + 'name' => isset($file['name']) ? $file['name'] : '', + 'path' => isset($file['path']) ? $file['path'] : '', + 'url' => isset($file['url']) ? $file['url'] : '', + 'thumb_url' => isset($file['thumb_url']) ? $file['thumb_url'] : '', + 'preview_url' => isset($file['preview_url']) ? $file['preview_url'] : '', + 'extension' => isset($file['extension']) ? $file['extension'] : '', + 'size_label' => isset($file['size_label']) ? $file['size_label'] : '', + 'width' => isset($file['width']) ? (int) $file['width'] : 0, + 'height' => isset($file['height']) ? (int) $file['height'] : 0, + 'webp_url' => !empty($file['has_webp']) && !empty($file['webp_path']) ? $file['webp_path'] : '', + ); + } + + return $out; + } + } diff --git a/adminx/modules/Media/Model.php b/adminx/modules/Media/Model.php new file mode 100644 index 0000000..3ce9d4f --- /dev/null +++ b/adminx/modules/Media/Model.php @@ -0,0 +1,1128 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Media; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\Cache; + use App\Common\CacheKey; + use App\Frontend\Media\ThumbnailUrl; + use App\Frontend\Media\ThumbnailStorage; + use App\Common\UploadPolicy; + use App\Helpers\Dir; + use App\Helpers\File; + + class Model + { + const ROOT = '/uploads'; + const MAX_UPLOAD_SIZE = 52428800; + const MAX_IMAGE_DIM = 2400; + + protected static $imageExt = array('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'); + protected static $allowedExt = array( + 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'csv', 'txt', 'rtf', 'zip', 'rar', '7z' + ); + protected static $hiddenEntries = array( + 'index.php', '.htaccess', 'th', '_derivatives', 'recycled', + '.tmb', '.quarantine', '.temp', '.uploader', '.drafts' + ); + + public static function normalize($path, $fallback) + { + $path = html_entity_decode(trim((string) $path), ENT_QUOTES, 'UTF-8'); + $path = preg_replace('/[?#].*$/', '', $path); + $path = str_replace('\\', '/', $path); + $path = preg_replace('#/+#', '/', $path); + if ($path === '') { + $path = $fallback; + } + + if ($path === '') { + return ''; + } + + if ($path[0] !== '/') { + $path = '/' . $path; + } + + $path = rtrim($path, '/'); + if ($path === '') { + $path = self::ROOT; + } + + if (strpos($path, '..') !== false || !self::isAllowedPath($path)) { + return $fallback; + } + + return $path; + } + + public static function isAllowedPath($path) + { + $path = (string) $path; + return $path === self::ROOT || strpos($path, self::ROOT . '/') === 0; + } + + public static function isProtectedPath($path) + { + $path = self::normalize($path, ''); + if ($path === '' || $path === self::ROOT) { + return true; + } + + $parts = explode('/', trim($path, '/')); + foreach ($parts as $part) { + if ($part === 'uploads') { + continue; + } + + if (!self::isVisibleEntry($part)) { + return true; + } + } + + return false; + } + + public static function supportsWebp() + { + return function_exists('imagewebp') && function_exists('imagecreatefromwebp'); + } + + public static function abs($path) + { + $path = self::normalize($path, self::ROOT); + return rtrim(BASEPATH, '/') . $path; + } + + public static function listing($dir, $q, $type, $page, $perPage) + { + $dir = self::normalize($dir, self::ROOT); + if (!self::isBrowseablePath($dir)) { + $dir = self::ROOT; + } + + $abs = self::abs($dir); + if (!is_dir($abs)) { + $dir = self::ROOT; + $abs = self::abs($dir); + } + + $folders = array(); + $files = array(); + $items = @scandir($abs); + if ($items === false) { + $items = array(); + } + + foreach ($items as $name) { + if (!self::isVisibleEntry($name)) { + continue; + } + + $path = rtrim($dir, '/') . '/' . $name; + $itemAbs = $abs . DIRECTORY_SEPARATOR . $name; + if (is_dir($itemAbs)) { + if (self::matches($name, $q, $type, true)) { + $folders[] = self::folderRow($path, $itemAbs); + } + + continue; + } + + if (is_file($itemAbs) && self::matches($name, $q, $type, false)) { + $files[] = self::fileRow($path, $itemAbs); + } + } + + usort($folders, array(__CLASS__, 'sortByName')); + usort($files, array(__CLASS__, 'sortByName')); + + $total = count($files); + $pages = max(1, (int) ceil($total / $perPage)); + $page = max(1, min($page, $pages)); + $files = array_slice($files, ($page - 1) * $perPage, $perPage); + + return array( + 'dir' => $dir, + 'folders' => $folders, + 'files' => $files, + 'total' => $total, + 'pages' => $pages, + 'page' => $page, + 'stats' => self::stats(self::abs(self::ROOT)), + ); + } + + public static function file($path) + { + $path = self::normalize($path, ''); + $abs = self::abs($path); + if ($path === '' || !self::isBrowseablePath($path) || !is_file($abs)) { + return null; + } + + return self::fileRow($path, $abs); + } + + public static function folderFiles($dir, $type, $limit) + { + $dir = self::normalize($dir, self::ROOT); + if (!self::isBrowseablePath($dir)) { + throw new \RuntimeException('Некорректная папка'); + } + + $abs = self::abs($dir); + if (!is_dir($abs)) { + return array(); + } + + $out = array(); + $items = @scandir($abs); + if ($items === false) { + return $out; + } + + foreach ($items as $name) { + if (!self::isVisibleEntry($name)) { + continue; + } + + $path = rtrim($dir, '/') . '/' . $name; + $fileAbs = $abs . DIRECTORY_SEPARATOR . $name; + if (!is_file($fileAbs) || !self::matches($name, '', (string) $type, false)) { + continue; + } + + $out[] = self::fileRow($path, $fileAbs); + if (count($out) >= (int) $limit) { + break; + } + } + + usort($out, array(__CLASS__, 'sortByName')); + return $out; + } + + public static function breadcrumbs($dir) + { + $dir = self::normalize($dir, self::ROOT); + $out = array(array('title' => 'uploads', 'path' => self::ROOT)); + $parts = explode('/', trim($dir, '/')); + $path = self::ROOT; + foreach ($parts as $i => $part) { + if ($part === '' || ($i === 0 && $part === 'uploads')) { + continue; + } + + $path = rtrim($path, '/') . '/' . $part; + $out[] = array('title' => $part, 'path' => $path); + } + + return $out; + } + + public static function parentDir($path) + { + $path = self::normalize($path, self::ROOT); + if ($path === self::ROOT) { + return ''; + } + + $parent = dirname($path); + return ($parent === '/' || $parent === '.' || $parent === '\\') ? self::ROOT : $parent; + } + + public static function createFolder($parent, $name) + { + $parent = self::normalize($parent, self::ROOT); + $name = self::safeName($name, false); + if ($name === '') { + throw new \RuntimeException('Укажите название папки'); + } + + $path = rtrim($parent, '/') . '/' . $name; + $abs = self::abs($path); + if (file_exists($abs)) { + throw new \RuntimeException('Папка или файл с таким именем уже существует'); + } + + if (!Dir::create($abs)) { + throw new \RuntimeException('Не удалось создать папку'); + } + + self::invalidateStats(); + return $path; + } + + public static function upload($dir, array $files) + { + $dir = self::normalize($dir, self::ROOT); + $absDir = self::abs($dir); + if (!Dir::create($absDir)) { + throw new \RuntimeException('Не удалось создать каталог загрузки'); + } + + $uploaded = array(); + $errors = array(); + $names = isset($files['name']) ? (array) $files['name'] : array(); + $tmpNames = isset($files['tmp_name']) ? (array) $files['tmp_name'] : array(); + $uploadErrors = isset($files['error']) ? (array) $files['error'] : array(); + $sizes = isset($files['size']) ? (array) $files['size'] : array(); + + foreach ($names as $i => $name) { + $name = (string) $name; + $tmp = isset($tmpNames[$i]) ? (string) $tmpNames[$i] : ''; + $err = isset($uploadErrors[$i]) ? (int) $uploadErrors[$i] : UPLOAD_ERR_NO_FILE; + $size = isset($sizes[$i]) ? (int) $sizes[$i] : 0; + if ($err !== UPLOAD_ERR_OK || $tmp === '') { + $errors[] = $name !== '' ? $name : ('file #' . ($i + 1)); + continue; + } + + if ($size > self::MAX_UPLOAD_SIZE) { + $errors[] = $name . ' (слишком большой файл)'; + continue; + } + + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + if (!in_array($ext, self::$allowedExt, true)) { + $errors[] = $name . ' (тип не разрешён)'; + continue; + } + + $base = self::safeName(pathinfo($name, PATHINFO_FILENAME), false); + if ($base === '') { + $base = 'file'; + } + + $filename = self::uniqueName($absDir, $base, $ext); + $target = $absDir . DIRECTORY_SEPARATOR . $filename; + try { + UploadPolicy::assertAllowed($tmp, $name, $size, $target, 'admin_media'); + } catch (\RuntimeException $e) { + $errors[] = $name . ' (' . $e->getMessage() . ')'; + continue; + } + + if (!@move_uploaded_file($tmp, $target)) { + $errors[] = $name . ' (не удалось сохранить)'; + continue; + } + + UploadPolicy::stored($target, $name, $size, 'admin_media'); + $uploaded[] = self::fileRow(rtrim($dir, '/') . '/' . $filename, $target); + } + + if ($uploaded) { + self::invalidateStats(); + } + + return array('uploaded' => $uploaded, 'errors' => $errors, 'dir' => $dir); + } + + public static function rename($path, $name) + { + $path = self::normalize($path, ''); + $abs = self::abs($path); + if (self::isProtectedPath($path) || !file_exists($abs)) { + throw new \RuntimeException('Некорректный путь'); + } + + $parent = self::parentDir($path); + $isDir = is_dir($abs); + $safe = self::safeName($name, !$isDir); + if ($safe === '') { + throw new \RuntimeException('Укажите новое имя'); + } + + if (!$isDir && pathinfo($safe, PATHINFO_EXTENSION) === '') { + $safe .= '.' . strtolower(pathinfo($path, PATHINFO_EXTENSION)); + } + + if (!$isDir && strtolower(pathinfo($safe, PATHINFO_EXTENSION)) !== strtolower(pathinfo($path, PATHINFO_EXTENSION))) { + throw new \RuntimeException('Расширение файла нельзя менять переименованием'); + } + + $target = rtrim($parent, '/') . '/' . $safe; + $targetAbs = self::abs($target); + if (file_exists($targetAbs)) { + throw new \RuntimeException('По этому пути уже есть файл или папка'); + } + + if (!$isDir) { + self::assertCompanionRenameAvailable($path, $target); + self::clearGeneratedForFile($path, $abs); + } + + if (!@rename($abs, $targetAbs)) { + throw new \RuntimeException('Не удалось переименовать'); + } + + if (!$isDir) { + self::renameWebpCompanion($path, $target); + } + + self::invalidateStats(); + return $target; + } + + public static function delete($path) + { + $path = self::normalize($path, ''); + if (self::isProtectedPath($path)) { + throw new \RuntimeException('Этот путь нельзя удалить'); + } + + $abs = self::abs($path); + if (is_dir($abs)) { + if (!self::removeDir($abs)) { + throw new \RuntimeException('Не удалось удалить папку'); + } + + self::invalidateStats(); + return true; + } + + if (is_file($abs)) { + self::deleteFileCompanions($path, $abs); + } + + if (is_file($abs) && !File::delete($abs)) { + throw new \RuntimeException('Не удалось удалить файл'); + } + + self::invalidateStats(); + return true; + } + + public static function clearThumbnails($dir) + { + $dir = self::normalize($dir, self::ROOT); + if (!self::isBrowseablePath($dir)) { + throw new \RuntimeException('Некорректная папка'); + } + + $abs = self::abs($dir); + if (!is_dir($abs)) { + throw new \RuntimeException('Папка не найдена'); + } + + $thumbDir = self::thumbnailDirName(); + $target = rtrim($abs, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $thumbDir; + if (!is_dir($target)) { + return array('files' => 0, 'dirs' => 0, 'path' => rtrim($dir, '/') . '/' . $thumbDir); + } + + $base = realpath($abs); + $real = realpath($target); + if (!$base || !$real || basename($real) !== $thumbDir || strpos($real, $base . DIRECTORY_SEPARATOR) !== 0) { + throw new \RuntimeException('Некорректная папка превью'); + } + + $files = 0; + $dirs = 0; + if (!self::removeDirWithCount($real, $files, $dirs)) { + throw new \RuntimeException('Не удалось удалить превью'); + } + + return array('files' => $files, 'dirs' => $dirs, 'path' => rtrim($dir, '/') . '/' . $thumbDir); + } + + /** + * Общий рендер по настройкам редактора (crop + resample). Возвращает + * GD-ресурс и выбранные format/quality/размеры; сохранение — на вызывающем. + */ + protected static function prepareRender($path, array $input) + { + $file = self::file($path); + if (!$file || !$file['is_image'] || $file['extension'] === 'svg') { + throw new \RuntimeException('Файл не является редактируемым изображением'); + } + + if (!function_exists('imagecreatetruecolor')) { + throw new \RuntimeException('GD недоступен для обработки изображений'); + } + + $srcExt = strtolower($file['extension']); + $format = self::format(isset($input['format']) ? $input['format'] : 'original', $srcExt); + if ($format === 'webp' && !function_exists('imagewebp')) { + throw new \RuntimeException('PHP GD не поддерживает сохранение WebP'); + } + + $width = self::clamp(isset($input['width']) ? $input['width'] : $file['width'], 1, self::MAX_IMAGE_DIM); + $height = self::clamp(isset($input['height']) ? $input['height'] : $file['height'], 1, self::MAX_IMAGE_DIM); + $quality = self::clamp(isset($input['quality']) ? $input['quality'] : 82, 1, 100); + $mode = in_array(isset($input['mode']) ? $input['mode'] : '', array('fit', 'cover', 'contain', 'stretch'), true) ? $input['mode'] : 'fit'; + + $src = self::loadImage($file['abs'], $srcExt); + if (!$src) { + throw new \RuntimeException('Не удалось открыть исходное изображение'); + } + + $sw = imagesx($src); + $sh = imagesy($src); + if (!empty($input['crop_enabled'])) { + $crop = self::cropImage($src, $sw, $sh, array( + 'x' => isset($input['crop_x']) ? $input['crop_x'] : 0, + 'y' => isset($input['crop_y']) ? $input['crop_y'] : 0, + 'w' => isset($input['crop_w']) ? $input['crop_w'] : $sw, + 'h' => isset($input['crop_h']) ? $input['crop_h'] : $sh, + )); + imagedestroy($src); + $src = $crop; + $sw = imagesx($src); + $sh = imagesy($src); + } + + $dst = self::resample($src, $sw, $sh, $width, $height, $mode); + imagedestroy($src); + + return array( + 'file' => $file, + 'dst' => $dst, + 'format' => $format, + 'quality' => $quality, + 'width' => $width, + 'height' => $height, + 'mode' => $mode, + ); + } + + public static function transform($path, array $input) + { + $r = self::prepareRender($path, $input); + $dst = $r['dst']; + + $dir = self::parentDir($r['file']['path']); + $outDir = rtrim($dir, '/') . '/_derivatives'; + $outAbsDir = self::abs($outDir); + if (!Dir::create($outAbsDir)) { + imagedestroy($dst); + throw new \RuntimeException('Не удалось создать каталог производных изображений'); + } + + $base = pathinfo($r['file']['name'], PATHINFO_FILENAME) . '-' . $r['mode'] . '-' . $r['width'] . 'x' . $r['height']; + $name = self::uniqueName($outAbsDir, self::safeName($base, false), $r['format']); + $out = $outAbsDir . DIRECTORY_SEPARATOR . $name; + self::saveImage($dst, $out, $r['format'], $r['quality']); + imagedestroy($dst); + return rtrim($outDir, '/') . '/' . $name; + } + + /** + * Временный превью по текущим настройкам редактора. Кладётся в + * `_derivatives/_preview` (скрыт от листинга), имя `preview-` — + * удаляется через deletePreview() при закрытии окна предпросмотра. + */ + public static function previewTransform($path, array $input) + { + $r = self::prepareRender($path, $input); + $dst = $r['dst']; + + $dir = self::parentDir($r['file']['path']); + $outDir = rtrim($dir, '/') . '/_derivatives/_preview'; + $outAbsDir = self::abs($outDir); + if (!Dir::create($outAbsDir)) { + imagedestroy($dst); + throw new \RuntimeException('Не удалось создать каталог предпросмотра'); + } + + self::sweepPreviews($outAbsDir); + $token = function_exists('random_bytes') ? bin2hex(random_bytes(8)) : md5(uniqid('', true)); + $name = 'preview-' . $token . '.' . $r['format']; + $out = $outAbsDir . DIRECTORY_SEPARATOR . $name; + self::saveImage($dst, $out, $r['format'], $r['quality']); + imagedestroy($dst); + $info = @getimagesize($out); + $w = $info ? (int) $info[0] : (int) $r['width']; + $h = $info ? (int) $info[1] : (int) $r['height']; + return array( + 'path' => rtrim($outDir, '/') . '/' . $name, + 'width' => $w, + 'height' => $h, + 'format' => $r['format'], + 'size' => is_file($out) ? (int) @filesize($out) : 0, + 'size_label' => self::formatBytes(is_file($out) ? (int) @filesize($out) : 0), + ); + } + + /** Удаление временного превью (только файлы preview-* внутри _preview). */ + public static function deletePreview($path) + { + $path = self::normalize($path, ''); + if ($path === '' || strpos($path, '/_derivatives/_preview/') === false) { + return false; + } + + if (strpos(basename($path), 'preview-') !== 0) { + return false; + } + + $abs = self::abs($path); + if (is_file($abs)) { + File::delete($abs); + return true; + } + + return false; + } + + /** Подчистка «протухших» превью (старше 1 часа), чтобы не копились. */ + protected static function sweepPreviews($absDir) + { + $files = @glob(rtrim($absDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'preview-*'); + if (!is_array($files)) { + return; + } + + $limit = time() - 3600; + foreach ($files as $f) { + if (is_file($f) && @filemtime($f) < $limit) { + File::delete($f); + } + } + } + + public static function convertWebp($path, $quality) + { + $file = self::file($path); + if (!$file || !$file['is_image'] || $file['extension'] === 'svg') { + throw new \RuntimeException('Файл не является редактируемым изображением'); + } + + if ($file['extension'] === 'webp') { + throw new \RuntimeException('Исходник уже в формате WebP'); + } + + if (!self::supportsWebp()) { + throw new \RuntimeException('PHP GD не поддерживает сохранение WebP'); + } + + if ($file['has_webp']) { + throw new \RuntimeException('WebP-копия уже существует'); + } + + $src = self::loadImage($file['abs'], $file['extension']); + if (!$src) { + throw new \RuntimeException('Не удалось открыть исходное изображение'); + } + + $quality = self::clamp($quality, 1, 100); + $outPath = $file['webp_path']; + $outAbs = self::abs($outPath); + self::saveImage($src, $outAbs, 'webp', $quality); + imagedestroy($src); + if (!is_file($outAbs)) { + throw new \RuntimeException('WebP-файл не был сохранён'); + } + + self::invalidateStats(); + return $outPath; + } + + protected static function fileRow($path, $abs) + { + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $isImage = in_array($ext, self::$imageExt, true); + $width = 0; + $height = 0; + $mime = ''; + if ($isImage && $ext !== 'svg') { + $info = @getimagesize($abs); + if ($info) { + $width = (int) $info[0]; + $height = (int) $info[1]; + $mime = isset($info['mime']) ? (string) $info['mime'] : ''; + } + } + + return array( + 'name' => basename($path), + 'path' => $path, + 'abs' => $abs, + 'url' => $path, + 'thumb_url' => ($isImage && $ext !== 'svg') ? self::thumbnailUrl($path, 'c320x320') : '', + 'preview_url' => ($isImage && $ext !== 'svg') ? self::thumbnailUrl($path, 'f480x360') : $path, + 'extension' => $ext, + 'is_image' => $isImage, + 'type' => $isImage ? 'image' : 'file', + 'mime' => $mime, + 'size' => is_file($abs) ? (int) @filesize($abs) : 0, + 'size_label' => self::formatBytes(is_file($abs) ? (int) @filesize($abs) : 0), + 'width' => $width, + 'height' => $height, + 'mtime' => is_file($abs) ? (int) @filemtime($abs) : 0, + 'mtime_label' => is_file($abs) ? date('d.m.Y H:i', (int) @filemtime($abs)) : '', + 'meta' => trim(($width && $height ? $width . 'x' . $height : '') . (is_file($abs) ? ' · ' . self::formatBytes((int) @filesize($abs)) : ''), ' ·'), + 'webp_path' => self::webpPath($path), + 'has_webp' => is_file(self::abs(self::webpPath($path))), + ); + } + + protected static function webpPath($path) + { + $dir = dirname($path); + $base = pathinfo($path, PATHINFO_FILENAME); + if ($dir === '.' || $dir === '/' || $dir === '\\') { + $dir = self::ROOT; + } + + return rtrim($dir, '/') . '/' . $base . '.webp'; + } + + protected static function thumbnailUrl($path, $size) + { + $url = ThumbnailUrl::make(array('link' => $path, 'size' => $size)); + return $url === false ? $path : (string) $url; + } + + protected static function thumbnailDirName() + { + return ThumbnailUrl::directoryName(); + } + + protected static function folderRow($path, $abs) + { + $count = 0; + $items = @scandir($abs); + if ($items !== false) { + foreach ($items as $item) { + if (self::isVisibleEntry($item)) { + $count++; + } + } + } + + return array('name' => basename($path), 'path' => $path, 'count' => $count); + } + + protected static function stats($absRoot) + { + return Cache::rememberTagged( + CacheKey::make('media', array('stats')), + 300, + CacheKey::tags('media'), + function () use ($absRoot) { + return self::calculateStats($absRoot); + } + ); + } + + protected static function calculateStats($absRoot) + { + $out = array('files' => 0, 'folders' => 0, 'images' => 0, 'size' => 0, 'size_label' => '0 B'); + if (!is_dir($absRoot)) { + return $out; + } + + $directory = new \RecursiveDirectoryIterator($absRoot, \FilesystemIterator::SKIP_DOTS); + $visible = new \RecursiveCallbackFilterIterator($directory, function ($item) { + return self::isVisibleEntry($item->getFilename()); + }); + $it = new \RecursiveIteratorIterator( + $visible, + \RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($it as $item) { + if ($item->isDir()) { + $out['folders']++; + } elseif ($item->isFile()) { + $out['files']++; + $out['size'] += (int) $item->getSize(); + if (in_array(strtolower(pathinfo($item->getFilename(), PATHINFO_EXTENSION)), self::$imageExt, true)) { + $out['images']++; + } + } + } + + $out['size_label'] = self::formatBytes($out['size']); + return $out; + } + + protected static function invalidateStats() + { + Cache::forgetTag(CacheKey::tag('media')); + } + + protected static function isVisibleEntry($name) + { + $name = (string) $name; + if ($name === '' || $name === '.' || $name === '..') { + return false; + } + + if ($name[0] === '.') { + return false; + } + + return !in_array(strtolower($name), self::$hiddenEntries, true); + } + + protected static function isBrowseablePath($path) + { + $path = self::normalize($path, ''); + if ($path === '' || !self::isAllowedPath($path)) { + return false; + } + + if ($path === self::ROOT) { + return true; + } + + $parts = explode('/', trim($path, '/')); + foreach ($parts as $part) { + if ($part === 'uploads') { + continue; + } + + if (!self::isVisibleEntry($part)) { + return false; + } + } + + return true; + } + + protected static function matches($name, $q, $type, $isDir) + { + if ($q !== '' && mb_stripos($name, $q, 0, 'UTF-8') === false) { + return false; + } + + if ($type === 'image') { + return !$isDir && in_array(strtolower(pathinfo($name, PATHINFO_EXTENSION)), self::$imageExt, true); + } + + if ($type === 'file') { + return !$isDir && !in_array(strtolower(pathinfo($name, PATHINFO_EXTENSION)), self::$imageExt, true); + } + + return true; + } + + protected static function safeName($name, $keepExt) + { + $name = trim((string) $name); + $ext = $keepExt ? strtolower(pathinfo($name, PATHINFO_EXTENSION)) : ''; + $base = $keepExt && $ext !== '' ? pathinfo($name, PATHINFO_FILENAME) : $name; + $base = preg_replace('/[^a-zA-Z0-9а-яА-ЯёЁ._-]+/u', '-', $base); + $base = trim($base, '.-_'); + if ($base === '') { + return ''; + } + + return $ext !== '' ? $base . '.' . $ext : $base; + } + + protected static function uniqueName($dir, $base, $ext) + { + $name = $base . '.' . $ext; + $n = 2; + while (file_exists(rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name)) { + $name = $base . '-' . $n . '.' . $ext; + $n++; + } + + return $name; + } + + protected static function removeDir($dir) + { + $base = realpath(self::abs(self::ROOT)); + $real = realpath($dir); + if (!$base || !$real || strpos($real, $base . DIRECTORY_SEPARATOR) !== 0) { + return false; + } + + $items = @scandir($real); + if ($items === false) { + return false; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $real . DIRECTORY_SEPARATOR . $item; + if (is_dir($path)) { + if (!self::removeDir($path)) { + return false; + } + } elseif (is_file($path) && !File::delete($path)) { + return false; + } + } + + return @rmdir($real); + } + + protected static function removeDirWithCount($dir, &$files, &$dirs) + { + $base = realpath(self::abs(self::ROOT)); + $real = realpath($dir); + if (!$base || !$real || strpos($real, $base . DIRECTORY_SEPARATOR) !== 0) { + return false; + } + + $items = @scandir($real); + if ($items === false) { + return false; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $real . DIRECTORY_SEPARATOR . $item; + if (is_dir($path)) { + if (!self::removeDirWithCount($path, $files, $dirs)) { + return false; + } + } elseif (is_file($path)) { + if (!File::delete($path)) { + return false; + } + + if ($item !== ThumbnailStorage::MARKER) { + $files++; + } + } + } + + if (!@rmdir($real)) { + return false; + } + + $dirs++; + return true; + } + + protected static function deleteFileCompanions($path, $abs) + { + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $base = pathinfo($path, PATHINFO_FILENAME); + $dir = dirname($abs); + + self::clearGeneratedForFile($path, $abs); + + if (in_array($ext, array('jpg', 'jpeg', 'png'), true)) { + $webp = $dir . DIRECTORY_SEPARATOR . $base . '.webp'; + if (is_file($webp)) { + self::deleteThumbnailsFor($webp); + File::delete($webp); + } + } + } + + protected static function clearGeneratedForFile($path, $abs) + { + self::deleteThumbnailsFor($abs); + self::deleteDerivativesFor($abs); + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if (in_array($ext, array('jpg', 'jpeg', 'png'), true)) { + $webp = dirname($abs) . DIRECTORY_SEPARATOR . pathinfo($path, PATHINFO_FILENAME) . '.webp'; + if (is_file($webp)) { + self::deleteThumbnailsFor($webp); + } + } + } + + protected static function deleteThumbnailsFor($source) + { + ThumbnailStorage::removeForSource($source); + } + + protected static function deleteDerivativesFor($source) + { + $dir = dirname($source) . DIRECTORY_SEPARATOR . '_derivatives'; + $base = preg_quote(pathinfo($source, PATHINFO_FILENAME), '/'); + self::deleteMatchingFiles($dir, '/^' . $base . '-(?:fit|cover|contain|stretch)-\d+x\d+\.(?:jpe?g|png|webp)$/i'); + } + + protected static function deleteMatchingFiles($dir, $pattern) + { + $base = realpath(self::abs(self::ROOT)); + $real = realpath($dir); + if (!$base || !$real || strpos($real, $base . DIRECTORY_SEPARATOR) !== 0 || !is_dir($real)) { + return; + } + + $items = @scandir($real); + if ($items === false) { + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..' || !preg_match($pattern, $item)) { + continue; + } + + $file = $real . DIRECTORY_SEPARATOR . $item; + if (is_file($file)) { + File::delete($file); + } + } + } + + protected static function assertCompanionRenameAvailable($sourcePath, $targetPath) + { + $ext = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION)); + if (!in_array($ext, array('jpg', 'jpeg', 'png'), true)) { + return; + } + + $sourceWebp = self::abs(self::webpPath($sourcePath)); + $targetWebp = self::abs(self::webpPath($targetPath)); + if (is_file($sourceWebp) && $sourceWebp !== $targetWebp && file_exists($targetWebp)) { + throw new \RuntimeException('WebP-копия с новым именем уже существует'); + } + } + + protected static function renameWebpCompanion($sourcePath, $targetPath) + { + $ext = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION)); + if (!in_array($ext, array('jpg', 'jpeg', 'png'), true)) { + return; + } + + $sourceWebp = self::abs(self::webpPath($sourcePath)); + $targetWebp = self::abs(self::webpPath($targetPath)); + if (is_file($sourceWebp) && $sourceWebp !== $targetWebp) { + @rename($sourceWebp, $targetWebp); + } + } + + protected static function sortByName($a, $b) + { + return strnatcasecmp($a['name'], $b['name']); + } + + protected static function formatBytes($bytes) + { + $bytes = (int) $bytes; + if ($bytes < 1024) { + return $bytes . ' B'; + } + + if ($bytes >= 1048576) { + return round($bytes / 1048576, 1) . ' MB'; + } + + return round($bytes / 1024, 1) . ' KB'; + } + + protected static function clamp($value, $min, $max) + { + $value = (int) $value; + return max($min, min($max, $value)); + } + + protected static function format($format, $sourceExt) + { + $format = strtolower((string) $format); + if ($format === '' || $format === 'original') { + $format = $sourceExt === 'jpeg' ? 'jpg' : $sourceExt; + } + + if ($format === 'jpeg') { + $format = 'jpg'; + } + + if (!in_array($format, array('jpg', 'png', 'webp'), true)) { + throw new \RuntimeException('Формат результата не поддерживается'); + } + + return $format; + } + + protected static function loadImage($abs, $ext) + { + switch ($ext) { + case 'jpg': + case 'jpeg': + return @imagecreatefromjpeg($abs); + case 'png': + return @imagecreatefrompng($abs); + case 'gif': + return @imagecreatefromgif($abs); + case 'webp': + return function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($abs) : null; + } + + return null; + } + + protected static function cropImage($src, $sw, $sh, array $crop) + { + $x = min(max(0, (int) $crop['x']), max(0, $sw - 1)); + $y = min(max(0, (int) $crop['y']), max(0, $sh - 1)); + $w = min(max(1, (int) $crop['w']), $sw - $x); + $h = min(max(1, (int) $crop['h']), $sh - $y); + $dst = imagecreatetruecolor($w, $h); + self::prepareCanvas($dst); + imagecopyresampled($dst, $src, 0, 0, $x, $y, $w, $h, $w, $h); + return $dst; + } + + protected static function resample($src, $sw, $sh, $tw, $th, $mode) + { + if ($mode === 'stretch') { + $sx = 0; $sy = 0; $cw = $sw; $ch = $sh; $dw = $tw; $dh = $th; + } elseif ($mode === 'cover') { + $scale = max($tw / $sw, $th / $sh); + $cw = (int) round($tw / $scale); $ch = (int) round($th / $scale); + $sx = (int) round(($sw - $cw) / 2); $sy = (int) round(($sh - $ch) / 2); + $dw = $tw; $dh = $th; + } else { + $scale = min($tw / $sw, $th / $sh, 1); + $dw = max(1, (int) round($sw * $scale)); $dh = max(1, (int) round($sh * $scale)); + $sx = 0; $sy = 0; $cw = $sw; $ch = $sh; + if ($mode === 'fit') { + $tw = $dw; $th = $dh; + } + } + + $dst = imagecreatetruecolor($tw, $th); + self::prepareCanvas($dst); + $dx = $mode === 'contain' ? (int) round(($tw - $dw) / 2) : 0; + $dy = $mode === 'contain' ? (int) round(($th - $dh) / 2) : 0; + imagecopyresampled($dst, $src, $dx, $dy, $sx, $sy, $dw, $dh, $cw, $ch); + return $dst; + } + + protected static function prepareCanvas($img) + { + imagealphablending($img, false); + imagesavealpha($img, true); + $transparent = imagecolorallocatealpha($img, 0, 0, 0, 127); + imagefilledrectangle($img, 0, 0, imagesx($img), imagesy($img), $transparent); + } + + protected static function saveImage($img, $out, $format, $quality) + { + if ($format === 'png') { + imagepng($img, $out, 6); + } elseif ($format === 'webp') { + imagewebp($img, $out, $quality); + } else { + imagejpeg($img, $out, $quality); + } + } + } diff --git a/adminx/modules/Media/assets/media.css b/adminx/modules/Media/assets/media.css new file mode 100644 index 0000000..28f2acf --- /dev/null +++ b/adminx/modules/Media/assets/media.css @@ -0,0 +1,732 @@ +.media-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 14px; + margin-bottom: 16px; +} +.media-browser a, +.media-file-layout a, +.media-lightbox a, +.media-browser a:hover, +.media-file-layout a:hover, +.media-lightbox a:hover { + text-decoration: none; +} +.media-stat { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; +} +.media-stat b { + display: block; + font-size: 20px; + line-height: 1.1; + font-weight: 800; + font-variant-numeric: tabular-nums; +} +.media-stat span { + font-size: 13px; + color: var(--text-secondary); +} +.media-panel-header { + margin-bottom: 18px; +} +.media-panel-header h2 { + font-size: 22px; + line-height: 1.2; + font-weight: 800; + letter-spacing: 0; + text-wrap: balance; +} +.media-card, +.media-preview-card, +.media-editor-card { + padding: 0; +} +.media-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid var(--border-default); +} +.media-section-title { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} +.media-section-title h2 { + margin: 0 0 4px; + font-size: 16px; + line-height: 1.25; + font-weight: 700; + text-wrap: balance; +} +.media-section-title p { + margin: 0; + font-size: 13px; + line-height: 1.4; + text-wrap: pretty; +} +.media-head-icon { + width: 34px; + height: 34px; + border-radius: var(--radius-md); + font-size: 17px; +} +.media-crumbs { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + max-width: 520px; + font-size: 13px; + color: var(--text-secondary); +} +.media-crumbs a { + color: var(--text-primary); + text-decoration: none; +} +.media-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.media-filter { + display: flex; + align-items: center; + flex: 1 1 auto; + gap: 10px; + min-width: 0; +} +.media-filter .input-wrap { + flex: 1 1 280px; + min-width: 220px; + max-width: 420px; +} +.media-filter .select { + flex: 0 0 170px; +} +.media-view { + flex: 0 0 auto; + min-height: 40px; +} +.media-view a { + min-height: 34px; + width: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-xs); + color: var(--text-secondary); + cursor: pointer; + text-decoration: none; +} +.media-view a[aria-pressed="true"] { + background: var(--background-surface); + color: var(--text-primary); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.1); +} +.media-view a:hover { + color: var(--text-primary); +} +.media-upload { + padding: 16px 20px 0; +} +.media-dropzone { + width: 100%; + min-height: 118px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: var(--space-7) var(--space-5); + border: 2px dashed var(--border-strong); + border-radius: var(--radius-lg); + background: var(--background-muted); + color: var(--text-secondary); + cursor: pointer; + text-align: center; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35); + transition-property: background-color, border-color, box-shadow, transform; + transition-duration: 150ms; + transition-timing-function: ease-out; +} +.media-dropzone:hover, +.media-dropzone:focus-visible { + border-color: var(--color-primary); + background: var(--background-surface); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35), 0 0 0 3px color-mix(in srgb, var(--color-primary) 14%, transparent); +} +.media-dropzone:active { + transform: scale(0.96); +} +.media-dropzone i { + width: 44px; + height: 44px; + display: grid; + place-items: center; + margin: 0 0 2px; + border-radius: var(--radius-md); + background: var(--color-primary-soft); + color: var(--color-primary); + font-size: 26px; +} +.media-dropzone span { + display: grid; + gap: 3px; + font-size: 13px; + line-height: 1.35; +} +.media-dropzone span b { + color: var(--text-primary); + font-size: 14px; + font-weight: 700; +} +.media-dropzone span small { + color: var(--text-muted); + font-size: 12.5px; +} +.media-dropzone > small { + text-align: center; + min-height: 16px; + font-size: 12.5px; +} +.media-dropzone > small:empty { + display: none; +} +.media-dropzone.is-dragover, +.media-dropzone.drag-over, +.media-upload.is-loading .media-dropzone { + border-color: var(--color-primary); + background: var(--color-primary-soft); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent), 0 8px 22px rgba(37, 99, 235, 0.08); +} +.media-dropzone .is-error { + color: var(--color-danger); +} +.media-dropzone .is-success { + color: var(--color-success); +} +.media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(154px, 1fr)); + gap: 14px; + padding: 20px; +} +.media-tile { + position: relative; + min-width: 0; + padding: 10px; + border-radius: var(--radius-md); + background: var(--background-surface); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06), 0 2px 4px 0 rgba(0, 0, 0, 0.04); + transition-property: box-shadow, transform; + transition-duration: 150ms; + transition-timing-function: ease-out; +} +.media-tile:hover { + transform: translateY(-3px); + background: color-mix(in srgb, var(--accent-primary) 10%, var(--background-surface)); + box-shadow: 0 0 0 2px var(--accent-primary), 0 12px 24px -10px rgba(2, 6, 23, 0.32), 0 4px 10px -4px rgba(2, 6, 23, 0.18); +} +.media-tile:hover .media-name, +.media-tile:hover b { + color: var(--accent-primary); +} +.media-tile:hover .media-thumb { + background: color-mix(in srgb, var(--accent-primary) 14%, var(--background-subtle)); +} +.media-thumb { + border: 0; + width: 100%; + aspect-ratio: 1 / 1; + display: grid; + place-items: center; + border-radius: calc(var(--radius-md) - 2px); + overflow: hidden; + background: var(--background-subtle); + color: var(--accent-primary); + text-decoration: none; + cursor: pointer; + padding: 0; +} +.media-thumb img, +.media-crop-frame img { + width: 100%; + height: 100%; + object-fit: cover; + outline: 1px solid rgba(0, 0, 0, 0.1); + outline-offset: -1px; +} +.media-thumb i { + font-size: 34px; +} +button.media-thumb { + font: inherit; +} +.media-tile.is-folder .media-thumb { + background: color-mix(in srgb, #f59e0b 14%, var(--background-subtle)); + color: #d97706; +} +.media-tile.is-folder .media-thumb i { + font-size: 40px; +} +.media-tile.is-folder:hover .media-thumb { + background: color-mix(in srgb, #f59e0b 22%, var(--background-subtle)); + color: #b45309; +} +.media-name, +.media-tile b { + display: block; + margin-top: 10px; + color: var(--text-primary); + font-size: 13px; + line-height: 1.25; + font-weight: 700; + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.media-tile small { + display: block; + min-height: 18px; + margin-top: 4px; + color: var(--text-secondary); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.media-actions { + display: flex; + align-items: center; + gap: 4px; + min-height: 32px; + margin-top: 10px; +} +.media-action { + --media-action-color: var(--text-secondary); + --media-action-bg: var(--background-inset); + color: var(--media-action-color); +} +.media-action:hover, +.media-action:focus-visible { + background: var(--media-action-bg); + color: var(--media-action-color); +} +.media-action-info { + --media-action-color: var(--color-primary); + --media-action-bg: var(--color-primary-soft); +} +.media-action-copy { + --media-action-color: var(--color-cyan); + --media-action-bg: var(--color-cyan-soft); +} +.media-action-edit { + --media-action-color: var(--color-violet); + --media-action-bg: var(--color-violet-soft); +} +.media-action-thumbs { + --media-action-color: var(--color-warning); + --media-action-bg: var(--color-warning-soft); +} +.media-action-danger { + --media-action-color: var(--color-danger); + --media-action-bg: var(--color-danger-soft); +} +.media-table { + table-layout: fixed; + min-width: 880px; +} +.media-table th, +.media-table td { + vertical-align: middle; + font-size: 13px; +} +.media-col-name { + width: 44%; +} +.media-col-type { + width: 18%; +} +.media-col-size { + width: 120px; +} +.media-col-date { + width: 150px; +} +.media-col-actions { + width: 132px; +} +.media-row-name { + display: inline-flex; + align-items: center; + gap: 10px; + max-width: 100%; + color: var(--text-primary); + text-decoration: none; +} +.media-row-name b { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.media-row-icon, +.media-row-preview { + width: 38px; + height: 38px; + aspect-ratio: 1 / 1; + flex: 0 0 38px; + display: grid; + place-items: center; + border-radius: var(--radius-sm); + background: var(--background-subtle); + color: var(--accent-primary); + overflow: hidden; +} +.media-row-preview img { + width: 100%; + height: 100%; + aspect-ratio: 1 / 1; + display: block; + object-fit: cover; + outline: 1px solid rgba(0, 0, 0, 0.1); + outline-offset: -1px; +} +.media-row-icon.is-folder { + color: #f59e0b; +} +.media-row-actions { + justify-content: flex-end; +} +.media-pagination { + padding: 16px 20px 20px; + border-top: 1px solid var(--border-default); +} +.media-empty { + padding: 44px 20px; +} +.media-file-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 380px; + gap: 18px; + align-items: start; +} +.media-preview-stage { + min-height: 420px; + display: grid; + place-items: center; + padding: 20px; + background-color: color-mix(in srgb, var(--text-secondary) 34%, var(--background-surface)); + background-image: linear-gradient(45deg, color-mix(in srgb, var(--text-secondary) 64%, transparent) 25%, transparent 25%), linear-gradient(-45deg, color-mix(in srgb, var(--text-secondary) 64%, transparent) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--text-secondary) 64%, transparent) 75%), linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--text-secondary) 64%, transparent) 75%); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0; +} +.media-crop-frame { + position: relative; + max-width: min(100%, 860px); + max-height: 68vh; + display: inline-block; + overflow: hidden; + border-radius: var(--radius-sm); +} +.media-crop-frame img { + display: block; + max-width: 100%; + max-height: 68vh; + width: auto; + height: auto; + object-fit: contain; + border-radius: var(--radius-sm); +} +.media-crop-box { + position: absolute; + box-sizing: border-box; + border: 2px solid #fff; + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.38), 0 0 0 1px rgba(0, 0, 0, 0.25); + cursor: move; +} +.media-crop-box [data-crop-handle] { + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18); +} +.media-crop-size { + position: absolute; + left: 50%; + top: 6px; + transform: translateX(-50%); + max-width: calc(100% - 8px); + padding: 3px 8px; + border-radius: var(--radius-sm); + background: rgba(15, 23, 42, 0.86); + color: #fff; + font-size: 11.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; +} +.media-crop-box [data-crop-handle="nw"] { + left: -8px; + top: -8px; + cursor: nwse-resize; +} +.media-crop-box [data-crop-handle="ne"] { + right: -8px; + top: -8px; + cursor: nesw-resize; +} +.media-crop-box [data-crop-handle="sw"] { + left: -8px; + bottom: -8px; + cursor: nesw-resize; +} +.media-crop-box [data-crop-handle="se"] { + right: -8px; + bottom: -8px; + cursor: nwse-resize; +} +.media-file-placeholder { + display: grid; + place-items: center; + gap: 10px; + width: 180px; + height: 180px; + border-radius: var(--radius-md); + background: var(--background-subtle); + color: var(--text-secondary); +} +.media-file-placeholder i { + font-size: 54px; +} +.media-file-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + padding: 14px 20px 18px; + border-top: 1px solid var(--border-default); + color: var(--text-secondary); + font-size: 13px; +} +.media-editor-card { + position: sticky; + top: calc(var(--topbar-h) + 18px); +} +.media-tool-group { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 18px 20px; +} +.media-transform-form { + border-top: 1px solid var(--border-default); +} +.media-transform-form > p { + padding: 0 20px 20px; + margin: 0; +} +.media-editor-card .alert { + margin: 12px 20px 0; +} +.media-editor-card .alert:last-child { + margin-bottom: 18px; +} +.media-result { + padding: 16px 20px 4px; +} +.media-result-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} +.media-result-head > span { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + font-weight: 700; + color: var(--text-primary); +} +.media-result-meta { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} +.media-result-stage { + display: grid; + place-items: center; + width: 100%; + min-height: 150px; + padding: 14px; + border: 0; + cursor: zoom-in; + border-radius: var(--radius-md); + background-color: color-mix(in srgb, var(--text-secondary) 34%, var(--background-surface)); + background-image: linear-gradient(45deg, color-mix(in srgb, var(--text-secondary) 64%, transparent) 25%, transparent 25%), linear-gradient(-45deg, color-mix(in srgb, var(--text-secondary) 64%, transparent) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--text-secondary) 64%, transparent) 75%), linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--text-secondary) 64%, transparent) 75%); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0; +} +.media-result-stage img { + display: block; + max-width: 100%; + max-height: 280px; + border-radius: var(--radius-sm); + outline: 1px solid rgba(0, 0, 0, 0.1); + outline-offset: -1px; +} +.media-result-stage img:not([src]) { + display: none; +} +.media-result-empty { + max-width: 220px; + color: var(--text-secondary); + font-size: 12.5px; + line-height: 1.4; + text-align: center; +} +.media-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + padding: 18px 20px 0; +} +.media-form-grid label { + display: grid; + gap: 6px; + min-width: 0; + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; +} +.media-prompt .modal-body { + padding: 0 20px 18px; +} +.media-lightbox[hidden] { + display: none; +} +.media-lightbox { + position: fixed; + inset: 0; + z-index: 1400; + display: grid; + place-items: center; + padding: 28px; + background: rgba(15, 23, 42, 0.62); +} +.media-lightbox-panel { + width: min(1120px, 100%); + max-height: calc(100vh - 56px); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + border-radius: var(--radius-lg); + background: var(--background-surface); + box-shadow: var(--shadow-xl); + overflow: hidden; +} +.media-lightbox-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding: 16px 18px; + border-bottom: 1px solid var(--border-default); +} +.media-lightbox-head h2 { + margin: 0 0 3px; + font-size: 16px; + line-height: 1.25; + font-weight: 700; +} +.media-lightbox-head p { + margin: 0; + font-size: 13px; +} +.media-lightbox-stage { + min-height: 320px; + display: grid; + place-items: center; + padding: 18px; + overflow: auto; + background-color: color-mix(in srgb, var(--text-secondary) 34%, var(--background-surface)); + background-image: linear-gradient(45deg, color-mix(in srgb, var(--text-secondary) 64%, transparent) 25%, transparent 25%), linear-gradient(-45deg, color-mix(in srgb, var(--text-secondary) 64%, transparent) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--text-secondary) 64%, transparent) 75%), linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--text-secondary) 64%, transparent) 75%); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0; +} +.media-lightbox-stage img { + display: block; + max-width: 100%; + max-height: calc(100vh - 170px); + width: auto; + height: auto; + border-radius: var(--radius-sm); + outline: 1px solid rgba(0, 0, 0, 0.1); + outline-offset: -1px; +} +.media-lightbox-open { + overflow: hidden; +} +@media (max-width: 1100px) { + .media-file-layout { + grid-template-columns: 1fr; + } + .media-editor-card { + position: static; + } +} +@media (max-width: 720px) { + .media-section-head, + .media-toolbar, + .media-filter { + align-items: stretch; + flex-direction: column; + } + .media-filter .input-wrap, + .media-filter .select, + .media-filter .btn { + flex: 1 1 auto; + width: 100%; + max-width: none; + } + .media-view { + width: 100%; + } + .media-view a { + flex: 1 1 0; + width: auto; + } + .media-grid { + grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); + padding: 14px; + } + .media-preview-stage { + min-height: 280px; + padding: 14px; + } + .media-lightbox { + padding: 12px; + } + .media-lightbox-stage { + min-height: 260px; + padding: 12px; + } + .media-form-grid { + grid-template-columns: 1fr; + } +} diff --git a/adminx/modules/Media/assets/media.js b/adminx/modules/Media/assets/media.js new file mode 100644 index 0000000..f9856a9 --- /dev/null +++ b/adminx/modules/Media/assets/media.js @@ -0,0 +1,575 @@ +/** + * JS раздела «Медиа»: файловый браузер, upload и простой crop-редактор. + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; + }); + } + + function submitAction(url, data, onDone) { + var form = document.getElementById('mediaActionForm'); + var fd; + if (!form) { return; } + fd = new FormData(form); + Object.keys(data || {}).forEach(function (key) { + fd.set(key, data[key]); + }); + Adminx.Loader.show(); + Adminx.Ajax.post(url, fd).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success && typeof onDone === 'function') { + onDone(d); + } + Adminx.Ajax.handle(payload); + if (d.success && !d.redirect) { + window.location.reload(); + } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + + Adminx.Media = { + pendingPreview: '', + + init: function () { + var self = this; + this.bindUpload(); + this.bindCrop(); + this.bindLightbox(); + this.bindLivePreview(); + + document.addEventListener('click', function (e) { + var folder = e.target.closest('[data-media-folder]'); + var rename = e.target.closest('[data-media-rename]'); + var del = e.target.closest('[data-media-delete]'); + var clearThumbs = e.target.closest('[data-media-clear-thumbs]'); + var copy = e.target.closest('[data-copy]'); + var webp = e.target.closest('[data-media-webp]'); + var zoom = e.target.closest('[data-media-result-zoom]'); + + if (folder) { self.createFolder(); return; } + if (rename) { self.rename(rename); return; } + if (del) { self.remove(del); return; } + if (clearThumbs) { self.clearThumbnails(clearThumbs); return; } + if (webp) { self.convertWebp(webp); return; } + if (zoom) { e.preventDefault(); self.openResultLarge(); return; } + if (copy) { self.copy(copy.getAttribute('data-copy')); } + }); + }, + + base: function () { + return Adminx.base(); + }, + + modalInput: function (cfg, done) { + var overlay = document.createElement('div'); + overlay.className = 'overlay'; + overlay.innerHTML = + ''; + document.body.appendChild(overlay); + requestAnimationFrame(function () { overlay.classList.add('show'); }); + var input = overlay.querySelector('[data-input]'); + var close = function () { + overlay.classList.remove('show'); + setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 180); + }; + var apply = function () { + var value = (input.value || '').trim(); + close(); + if (value) { done(value); } + }; + var onKey = function (e) { + if (e.key === 'Escape') { close(); } + if (e.key === 'Enter') { apply(); } + }; + overlay.addEventListener('click', function (e) { + if (e.target === overlay || e.target.closest('[data-cancel]')) { close(); } + if (e.target.closest('[data-ok]')) { apply(); } + }); + document.addEventListener('keydown', onKey); + input.focus(); + input.select(); + }, + + createFolder: function () { + var dir = document.querySelector('input[name="dir"]'); + var self = this; + this.modalInput({ + title: 'Создать папку', + message: 'Папка будет создана в текущем каталоге.', + value: '', + ok: 'Создать' + }, function (name) { + submitAction(self.base() + '/media/folders', { + dir: dir ? dir.value : '/uploads', + name: name + }); + }); + }, + + rename: function (button) { + var self = this; + var kind = button.getAttribute('data-kind') === 'folder' ? 'папку' : 'файл'; + this.modalInput({ + title: 'Переименовать ' + kind, + message: button.getAttribute('data-path') || '', + value: button.getAttribute('data-name') || '', + ok: 'Переименовать' + }, function (name) { + submitAction(self.base() + '/media/rename', { + path: button.getAttribute('data-path') || '', + name: name + }); + }); + }, + + remove: function (button) { + var self = this; + var name = button.getAttribute('data-name') || button.getAttribute('data-path') || ''; + var kind = button.getAttribute('data-kind') === 'folder' ? 'папку и всё содержимое' : 'файл'; + Adminx.Confirm.open({ + kind: 'error', + title: 'Удалить ' + kind + '?', + message: '«' + name + '» будет удалён с диска.', + confirmLabel: 'Удалить', + confirmClass: 'btn-danger', + onConfirm: function () { + submitAction(self.base() + '/media/delete', { + path: button.getAttribute('data-path') || '' + }); + } + }); + }, + + clearThumbnails: function (button) { + var self = this; + var name = button.getAttribute('data-name') || button.getAttribute('data-path') || ''; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Удалить превью в папке?', + message: 'Будет удалена только служебная папка превью для «' + name + '». Исходные файлы останутся на месте.', + confirmLabel: 'Удалить превью', + confirmClass: 'btn-danger', + onConfirm: function () { + submitAction(self.base() + '/media/clear-thumbnails', { + path: button.getAttribute('data-path') || '' + }); + } + }); + }, + + copy: function (text) { + if (!text) { return; } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(function () { + Adminx.Toast.show('Путь скопирован', 'success'); + }); + } else { + Adminx.Toast.show(text, 'info'); + } + }, + + bindUpload: function () { + var form = document.getElementById('mediaUploadForm'); + if (!form) { return; } + var input = form.querySelector('input[type="file"]'); + var drop = form.querySelector('[data-media-drop]'); + var trigger = document.querySelector('[data-media-upload-trigger]'); + var status = form.querySelector('[data-media-upload-status]'); + var self = this; + var depth = 0; + + function setStatus(text, cls) { + if (!status) { return; } + status.textContent = text || ''; + status.className = cls ? 'is-' + cls : ''; + } + + function upload(files) { + var fd; + var i; + if (!files || !files.length) { + setStatus('Файлы не выбраны', 'error'); + return; + } + fd = new FormData(form); + fd.delete('files[]'); + for (i = 0; i < files.length; i++) { + fd.append('files[]', files[i]); + } + form.classList.add('is-loading'); + setStatus('Загрузка: ' + files.length, 'progress'); + Adminx.Ajax.post(self.base() + '/media/upload', fd).then(function (payload) { + form.classList.remove('is-loading'); + var d = payload.data || {}; + if (d.success) { + setStatus(d.message || 'Готово', 'success'); + Adminx.Ajax.handle(payload); + } else { + setStatus(d.message || 'Ошибка загрузки', 'error'); + Adminx.Toast.show(d.message || 'Ошибка загрузки', 'error'); + } + }).catch(function () { + form.classList.remove('is-loading'); + setStatus('Ошибка сети', 'error'); + }); + } + + if (trigger) { + trigger.addEventListener('click', function () { input.click(); }); + } + drop.addEventListener('click', function () { input.click(); }); + input.addEventListener('change', function () { upload(input.files); }); + drop.addEventListener('dragenter', function (e) { + e.preventDefault(); depth++; drop.classList.add('is-dragover'); setStatus('Отпустите файлы', 'progress'); + }); + drop.addEventListener('dragover', function (e) { + e.preventDefault(); drop.classList.add('is-dragover'); + }); + drop.addEventListener('dragleave', function (e) { + e.preventDefault(); depth = Math.max(0, depth - 1); if (depth === 0) { drop.classList.remove('is-dragover'); } + }); + drop.addEventListener('drop', function (e) { + e.preventDefault(); depth = 0; drop.classList.remove('is-dragover'); upload(e.dataTransfer ? e.dataTransfer.files : null); + }); + }, + + bindLightbox: function () { + var self = this; + var modal = document.querySelector('[data-media-lightbox]'); + if (!modal) { return; } + this.lightbox = { + modal: modal, + image: modal.querySelector('[data-media-lightbox-image]'), + title: modal.querySelector('[data-media-lightbox-title]'), + meta: modal.querySelector('[data-media-lightbox-meta]') + }; + + document.addEventListener('click', function (e) { + var trigger = e.target.closest('[data-media-preview]'); + if (trigger) { + e.preventDefault(); + e.stopPropagation(); + self.openLightbox( + trigger.getAttribute('data-media-preview') || '', + trigger.getAttribute('data-media-preview-name') || 'Предпросмотр', + trigger.getAttribute('data-media-preview-meta') || '' + ); + return; + } + if (e.target === modal || e.target.closest('[data-media-lightbox-close]')) { + self.closeLightbox(); + } + }); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && !modal.hasAttribute('hidden')) { + self.closeLightbox(); + } + }); + // Уходя со страницы с открытым превью — подчистить временный файл. + window.addEventListener('pagehide', function () { self.clearPreview(true); }); + }, + + openLightbox: function (url, title, meta) { + var lb = this.lightbox; + if (!lb) { return; } + if (lb.image) { lb.image.src = url || ''; lb.image.alt = title || ''; } + if (lb.title) { lb.title.textContent = title || 'Предпросмотр'; } + if (lb.meta) { lb.meta.textContent = meta || ''; } + lb.modal.removeAttribute('hidden'); + document.documentElement.classList.add('media-lightbox-open'); + }, + + closeLightbox: function () { + var lb = this.lightbox; + if (!lb) { return; } + lb.modal.setAttribute('hidden', 'hidden'); + if (lb.image) { lb.image.src = ''; } + document.documentElement.classList.remove('media-lightbox-open'); + }, + + // Встроенный «Результат»: живой серверный рендер по настройкам + выделению. + bindLivePreview: function () { + var self = this; + var form = document.querySelector('.media-transform-form'); + var result = document.querySelector('[data-media-result]'); + if (!form || !result) { return; } + this.result = { + img: result.querySelector('[data-media-result-img]'), + meta: result.querySelector('[data-media-result-meta]'), + empty: result.querySelector('[data-media-result-empty]') + }; + var timer = null; + var onChange = function () { + clearTimeout(timer); + timer = setTimeout(function () { self.refreshResult(); }, 300); + }; + form.addEventListener('change', onChange); + form.addEventListener('input', onChange); + // Первый рендер результата при открытии страницы. + setTimeout(function () { self.refreshResult(); }, 60); + }, + + // Сгенерировать серверный результат по текущим настройкам и показать во встроенном блоке. + refreshResult: function () { + var self = this; + var form = document.querySelector('.media-transform-form'); + if (!form || !this.result) { return; } + var reqId = (this.resultReq = (this.resultReq || 0) + 1); + Adminx.Ajax.post(this.base() + '/media/preview', new FormData(form)).then(function (payload) { + if (reqId !== self.resultReq) { return; } // пришёл ответ на устаревший запрос + var d = payload.data || {}; + if (!d.success) { return; } + var data = d.data || {}; + self.clearPreview(false); + self.pendingPreview = data.path || ''; + self.resultUrl = (data.url || '') + '?t=' + Date.now(); + self.resultMeta = data.meta || ''; + if (self.result.img) { self.result.img.src = self.resultUrl; self.result.img.style.display = ''; } + if (self.result.empty) { self.result.empty.style.display = 'none'; } + if (self.result.meta) { self.result.meta.textContent = data.meta || ''; } + // Если открыт крупный просмотр — обновить и его. + if (self.lightbox && !self.lightbox.modal.hasAttribute('hidden') && self.lightbox.image) { + self.lightbox.image.src = self.resultUrl; + if (self.lightbox.meta) { self.lightbox.meta.textContent = data.meta || ''; } + } + }).catch(function () {}); + }, + + // Открыть текущий результат крупно (лупа/кнопка «Крупно»). + openResultLarge: function () { + if (this.resultUrl) { + this.openLightbox(this.resultUrl, 'Результат по настройкам', this.resultMeta || ''); + } else { + this.refreshResult(); + Adminx.Toast.show('Готовлю результат…', 'info'); + } + }, + + // Удалить временный превью-файл (при закрытии окна или уходе со страницы). + clearPreview: function (useBeacon) { + var path = this.pendingPreview; + if (!path) { return; } + this.pendingPreview = ''; + var url = this.base() + '/media/preview-clear'; + var fd = new FormData(); + fd.append('_csrf', Adminx.csrf()); + fd.append('path', path); + if (useBeacon && navigator.sendBeacon) { + navigator.sendBeacon(url, fd); + return; + } + Adminx.Ajax.post(url, fd).catch(function () {}); + }, + + convertWebp: function (button) { + var form = button.closest('form'); + var fd; + if (!form) { return; } + Adminx.Confirm.open({ + kind: 'info', + title: 'Создать WebP-копию?', + message: 'Будет создан соседний WebP-файл с тем же именем исходника.', + confirmLabel: 'Создать WebP', + confirmClass: 'btn-primary', + onConfirm: function () { + fd = new FormData(); + fd.append('_csrf', Adminx.csrf()); + fd.append('path', button.getAttribute('data-path') || ''); + fd.append('quality', form.querySelector('[name="quality"]') ? form.querySelector('[name="quality"]').value : '82'); + Adminx.Loader.show(); + Adminx.Ajax.post(Adminx.Media.base() + '/media/convert-webp', fd).then(function (payload) { + Adminx.Loader.hide(); + Adminx.Ajax.handle(payload); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + }, + + bindCrop: function () { + var root = document.querySelector('[data-media-editor]'); + if (!root) { return; } + var img = root.querySelector('[data-crop-image]'); + var frame = root.querySelector('[data-crop-frame]'); + var box = root.querySelector('[data-crop-box]'); + var form = root.querySelector('.media-transform-form'); + if (!img || !frame || !box || !form) { return; } + + var state = { x: 12, y: 12, w: 76, h: 76, drag: null, sx: 0, sy: 0 }; + var sizeBadge = box.querySelector('[data-crop-size]'); + var ratioSelect = form.querySelector('[data-crop-ratio]'); + var inputs = { + enabled: form.querySelector('[data-crop-enabled]'), + x: form.querySelector('[data-crop-input="x"]'), + y: form.querySelector('[data-crop-input="y"]'), + w: form.querySelector('[data-crop-input="w"]'), + h: form.querySelector('[data-crop-input="h"]'), + outputW: form.querySelector('[name="width"]'), + outputH: form.querySelector('[name="height"]') + }; + + function aspectLabel(w, h) { + function gcd(a, b) { return b ? gcd(b, a % b) : a; } + var g = gcd(w, h) || 1; + var rw = Math.round(w / g); + var rh = Math.round(h / g); + if (rw <= 40 && rh <= 40) { return rw + ':' + rh; } + return (w / Math.max(1, h)).toFixed(2) + ':1'; + } + + function sync(syncOutputSize) { + var rect = img.getBoundingClientRect(); + var naturalW = img.naturalWidth || 1; + var naturalH = img.naturalHeight || 1; + var full = state.x === 0 && state.y === 0 && state.w === 100 && state.h === 100; + var px = Math.round((state.x / 100) * naturalW); + var py = Math.round((state.y / 100) * naturalH); + var pw = Math.max(1, Math.round((state.w / 100) * naturalW)); + var ph = Math.max(1, Math.round((state.h / 100) * naturalH)); + box.style.left = state.x + '%'; + box.style.top = state.y + '%'; + box.style.width = state.w + '%'; + box.style.height = state.h + '%'; + if (inputs.enabled) { inputs.enabled.value = full ? '0' : '1'; } + if (inputs.x) { inputs.x.value = px; } + if (inputs.y) { inputs.y.value = py; } + if (inputs.w) { inputs.w.value = pw; } + if (inputs.h) { inputs.h.value = ph; } + if (syncOutputSize && inputs.outputW && inputs.outputH) { + var outputScale = Math.min(1, 2400 / pw, 2400 / ph); + inputs.outputW.value = Math.max(1, Math.round(pw * outputScale)); + inputs.outputH.value = Math.max(1, Math.round(ph * outputScale)); + } + if (sizeBadge) { + sizeBadge.textContent = (full ? 'Весь кадр · ' : 'Выделение · ') + pw + '×' + ph + ' px · ' + aspectLabel(pw, ph); + } + // Кроп-инпуты меняются программно — уведомим живое превью вручную. + if (inputs.w) { inputs.w.dispatchEvent(new Event('input', { bubbles: true })); } + return rect; + } + + function clamp(preserveSize) { + if (preserveSize) { + state.w = Math.max(1, Math.min(100, state.w)); + state.h = Math.max(1, Math.min(100, state.h)); + state.x = Math.max(0, Math.min(100 - state.w, state.x)); + state.y = Math.max(0, Math.min(100 - state.h, state.y)); + return; + } + state.x = Math.max(0, Math.min(99, state.x)); + state.y = Math.max(0, Math.min(99, state.y)); + state.w = Math.max(1, Math.min(100 - state.x, state.w)); + state.h = Math.max(1, Math.min(100 - state.y, state.h)); + } + + function ratio(value) { + if (!value || value === 'free' || value === 'original') { return 0; } + var p = value.split(':'); + return p.length === 2 ? (parseFloat(p[0]) / parseFloat(p[1])) : 0; + } + + function setRatio(value) { + var r = ratio(value); + var rect = img.getBoundingClientRect(); + var imageRatio = rect.height > 0 ? rect.width / rect.height : ((img.naturalWidth || 1) / (img.naturalHeight || 1)); + if (!r || !imageRatio) { + state = { x: 0, y: 0, w: 100, h: 100, drag: null, sx: 0, sy: 0 }; + } else if (r >= imageRatio) { + state.w = 76; + state.h = (state.w * imageRatio) / r; + state.x = 12; + state.y = (100 - state.h) / 2; + } else { + state.h = 76; + state.w = (state.h * r) / imageRatio; + state.y = 12; + state.x = (100 - state.w) / 2; + } + clamp(); + sync(true); + } + + box.addEventListener('mousedown', function (e) { + var handle = e.target.getAttribute('data-crop-handle'); + state.drag = handle || 'move'; + state.sx = e.clientX; + state.sy = e.clientY; + e.preventDefault(); + }); + document.addEventListener('mousemove', function (e) { + var rect; + var dx; + var dy; + if (!state.drag) { return; } + rect = img.getBoundingClientRect(); + dx = ((e.clientX - state.sx) / rect.width) * 100; + dy = ((e.clientY - state.sy) / rect.height) * 100; + state.sx = e.clientX; + state.sy = e.clientY; + if (state.drag === 'move') { + state.x += dx; state.y += dy; + clamp(true); + sync(false); + } else { + if (state.drag.indexOf('e') !== -1) { state.w += dx; } + if (state.drag.indexOf('s') !== -1) { state.h += dy; } + if (state.drag.indexOf('w') !== -1) { state.x += dx; state.w -= dx; } + if (state.drag.indexOf('n') !== -1) { state.y += dy; state.h -= dy; } + if (ratioSelect) { ratioSelect.value = 'free'; } + clamp(false); + sync(true); + } + }); + document.addEventListener('mouseup', function () { state.drag = null; }); + + if (ratioSelect) { + ratioSelect.addEventListener('change', function (e) { setRatio(e.target.value); }); + } + form.querySelector('[data-crop-reset]').addEventListener('click', function () { + state = { x: 0, y: 0, w: 100, h: 100, drag: null, sx: 0, sy: 0 }; + sync(true); + }); + form.addEventListener('submit', function (e) { + e.preventDefault(); + Adminx.Loader.show(); + Adminx.Ajax.post(form.action, new FormData(form)).then(function (payload) { + Adminx.Loader.hide(); + Adminx.Ajax.handle(payload); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }); + window.addEventListener('resize', sync); + if (img.complete) { sync(); } else { img.addEventListener('load', sync); } + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.Media.init(); }); + } else { + Adminx.Media.init(); + } +})(window, document); diff --git a/adminx/modules/Media/language/en/client.xml b/adminx/modules/Media/language/en/client.xml new file mode 100644 index 0000000..1a78ea0 --- /dev/null +++ b/adminx/modules/Media/language/en/client.xml @@ -0,0 +1,7 @@ + + + Close + Large + Result + and + diff --git a/adminx/modules/Media/language/en/interface.xml b/adminx/modules/Media/language/en/interface.xml new file mode 100644 index 0000000..52b08fe --- /dev/null +++ b/adminx/modules/Media/language/en/interface.xml @@ -0,0 +1,111 @@ + + + , WebP conversion creates an adjacent file + PHP GD built without WebP + WebP conversion requires PHP GD with WebP support: features + There is already a WebP copy: + The “Result” block is updated live based on the current settings and selection. The mode affects when the width and height change the proportions of the original: + uploads is empty + In WebP + Up + View + Enter + All types + Height + Dimensions: + To change files, you need media control rights. + and + Changed + Changed: + Image + Images + Images + or press to select on your computer + Frame + Quality + Copy path + A copy will be saved in a subfolder + Large + Change the settings - the result will be updated here + Trim + objects + Volume + Original + Open large + Open result large + Clear preview + Folders and files are physically read from the uploads directory. + Folders + folders, + Rename, delete and image editor. + Rename + Drag files here + Search by name + Image preview + Preview the result + Stretch + Mode + Result + Settings result + Free + Grid + Folder Contents + Create a folder or upload files. + List + Delete all previews in the current folder + Decrease + File + Files + files in the current filter. + File browser for the uploads directory + Format + Width + - fill and cut off excess, + - just reduce without trimming, + - exactly the size. + - entirely with margins, + (failed to save) + (file too big) + (type not allowed) + . Missed: + GD is not available for image processing + PHP GD does not support WebP saving + WebP copy created + A WebP copy already exists + WebP file was not saved + No previews found in this folder + Select files to upload + Files uploaded: + Uploading, renaming, deleting files and folders, image editor. + Close + Image created + The source is already in WebP format + Content + Media + Media: + Media: viewing + Media: File Management + Could not open source image + Failed to rename + Failed to create preview directory + Failed to create derived image directory + Failed to delete folder + Failed to delete preview + Failed to delete file + Invalid folder + Incorrect preview folder + Incorrect path + Folder not found + Folder created + Renamed + There is already a file or folder at this path + View the file manager and the /uploads. folder + The file extension cannot be changed by renaming + Deleted + Removed preview: + Enter a new name + File not found + The file is not an editable image + Files not uploaded + This path cannot be deleted + diff --git a/adminx/modules/Media/language/en/runtime.xml b/adminx/modules/Media/language/en/runtime.xml new file mode 100644 index 0000000..1327448 --- /dev/null +++ b/adminx/modules/Media/language/en/runtime.xml @@ -0,0 +1,52 @@ + + + (failed to save) + (file too big) + (type not allowed) + . Missed: + GD is not available for image processing + PHP GD does not support WebP saving + A WebP copy with a new name already exists + WebP copy created + A WebP copy already exists + WebP file was not saved + No previews found in this folder + Select files to upload + Files uploaded: + Uploading, renaming, deleting files and folders, image editor. + Image created + The source is already in WebP format + Content + Media + Media: + Media: viewing + Media: File Management + Could not open source image + Failed to rename + Failed to create download directory + Failed to create preview directory + Failed to create derived image directory + Failed to create folder + Failed to delete folder + Failed to delete preview + Failed to delete file + Invalid folder + Incorrect preview folder + Incorrect path + A folder or file with the same name already exists + Folder not found + Folder created + Renamed + There is already a file or folder at this path + View the file manager and the /uploads. folder + The file extension cannot be changed by renaming + Deleted + Removed preview: + Specify the folder name + Enter a new name + File not found + The file is not an editable image + Files not uploaded + Result format not supported + This path cannot be deleted + diff --git a/adminx/modules/Media/language/ru/client.xml b/adminx/modules/Media/language/ru/client.xml new file mode 100644 index 0000000..6bc6b53 --- /dev/null +++ b/adminx/modules/Media/language/ru/client.xml @@ -0,0 +1,7 @@ + + + Закрыть + Крупно + Результат + и + diff --git a/adminx/modules/Media/language/ru/interface.xml b/adminx/modules/Media/language/ru/interface.xml new file mode 100644 index 0000000..107916d --- /dev/null +++ b/adminx/modules/Media/language/ru/interface.xml @@ -0,0 +1,111 @@ + + + , WebP-конвертация создаёт соседний файл + PHP GD собран без WebP + WebP-конвертация требует PHP GD с поддержкой WebP: функций + WebP-копия уже есть: + Блок «Результат» обновляется вживую по текущим настройкам и выделению. Режим влияет, когда ширина и высота меняют пропорции оригинала: + В uploads пусто + В WebP + Вверх + Вид + Вписать + Все типы + Высота + Габариты: + Для изменения файлов нужны права управления медиа. + и + Изменён + Изменён: + Изображение + Изображений + Изображения + или нажмите для выбора на компьютере + Кадр + Качество + Копировать путь + Копия будет сохранена в подпапку + Крупно + Меняйте настройки — результат обновится здесь + Обрезать + объектов + Объём + Оригинал + Открыть крупно + Открыть результат крупно + Очистить превью + Папки и файлы физически читаются из каталога uploads. + Папок + папок, + Переименование, удаление и редактор изображения. + Переименовать + Перетащите файлы сюда + Поиск по имени + Предпросмотр изображения + Предпросмотр результата + Растянуть + Режим + Результат + Результат по настройкам + Свободный + Сетка + Содержимое папки + Создайте папку или загрузите файлы. + Список + Удалить все превью в текущей папке + Уменьшить + Файл + Файлов + файлов в текущем фильтре. + Файловый браузер каталога uploads + Формат + Ширина + — заполнить и срезать лишнее, + — только уменьшить без обрезки, + — точно в размер. + — целиком с полями, + (не удалось сохранить) + (слишком большой файл) + (тип не разрешён) + . Пропущено: + GD недоступен для обработки изображений + PHP GD не поддерживает сохранение WebP + WebP-копия создана + WebP-копия уже существует + WebP-файл не был сохранён + В этой папке превью не найдены + Выберите файлы для загрузки + Загружено файлов: + Загрузка, переименование, удаление файлов и папок, редактор изображений. + Закрыть + Изображение создано + Исходник уже в формате WebP + Контент + Медиа + Медиа: + Медиа: просмотр + Медиа: управление файлами + Не удалось открыть исходное изображение + Не удалось переименовать + Не удалось создать каталог предпросмотра + Не удалось создать каталог производных изображений + Не удалось удалить папку + Не удалось удалить превью + Не удалось удалить файл + Некорректная папка + Некорректная папка превью + Некорректный путь + Папка не найдена + Папка создана + Переименовано + По этому пути уже есть файл или папка + Просмотр файлового менеджера и папки /uploads. + Расширение файла нельзя менять переименованием + Удалено + Удалено превью: + Укажите новое имя + Файл не найден + Файл не является редактируемым изображением + Файлы не загружены + Этот путь нельзя удалить + diff --git a/adminx/modules/Media/language/ru/runtime.xml b/adminx/modules/Media/language/ru/runtime.xml new file mode 100644 index 0000000..fa2c3ec --- /dev/null +++ b/adminx/modules/Media/language/ru/runtime.xml @@ -0,0 +1,52 @@ + + + (не удалось сохранить) + (слишком большой файл) + (тип не разрешён) + . Пропущено: + GD недоступен для обработки изображений + PHP GD не поддерживает сохранение WebP + WebP-копия с новым именем уже существует + WebP-копия создана + WebP-копия уже существует + WebP-файл не был сохранён + В этой папке превью не найдены + Выберите файлы для загрузки + Загружено файлов: + Загрузка, переименование, удаление файлов и папок, редактор изображений. + Изображение создано + Исходник уже в формате WebP + Контент + Медиа + Медиа: + Медиа: просмотр + Медиа: управление файлами + Не удалось открыть исходное изображение + Не удалось переименовать + Не удалось создать каталог загрузки + Не удалось создать каталог предпросмотра + Не удалось создать каталог производных изображений + Не удалось создать папку + Не удалось удалить папку + Не удалось удалить превью + Не удалось удалить файл + Некорректная папка + Некорректная папка превью + Некорректный путь + Папка или файл с таким именем уже существует + Папка не найдена + Папка создана + Переименовано + По этому пути уже есть файл или папка + Просмотр файлового менеджера и папки /uploads. + Расширение файла нельзя менять переименованием + Удалено + Удалено превью: + Укажите название папки + Укажите новое имя + Файл не найден + Файл не является редактируемым изображением + Файлы не загружены + Формат результата не поддерживается + Этот путь нельзя удалить + diff --git a/adminx/modules/Media/module.php b/adminx/modules/Media/module.php new file mode 100644 index 0000000..ec14007 --- /dev/null +++ b/adminx/modules/Media/module.php @@ -0,0 +1,78 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + return array( + 'code' => 'media', + 'name' => 'Медиа', + 'version' => '0.1.0', + + 'permissions' => array( + 'key' => 'media', + 'items' => array( + array( + 'code' => 'view_media', + 'group_code' => 'navigation', + 'name' => 'Медиа: просмотр', + 'description' => 'Просмотр файлового менеджера и папки /uploads.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_media', + 'group_code' => 'content', + 'name' => 'Медиа: управление файлами', + 'description' => 'Загрузка, переименование, удаление файлов и папок, редактор изображений.', + 'sort_order' => 20, + ), + ), + 'icon' => 'ti ti-photo', + 'priority' => 30, + ), + + 'navigation' => array( + array( + 'code' => 'media', + 'label' => 'Медиа', + 'url' => '/media', + 'icon' => 'ti ti-photo', + 'permission' => 'view_media', + 'group' => 'Контент', + 'sort_order' => 30, + 'match' => array( + '/media', + ), + ), + ), + + 'routes' => array( + array('GET', '/media', array(\App\Adminx\Media\Controller::class, 'index')), + array('GET', '/media/picker', array(\App\Adminx\Media\Controller::class, 'picker')), + array('GET', '/media/folder-files', array(\App\Adminx\Media\Controller::class, 'folderFiles')), + array('GET', '/media/file', array(\App\Adminx\Media\Controller::class, 'file')), + array('POST', '/media/upload', array(\App\Adminx\Media\Controller::class, 'upload')), + array('POST', '/media/folders', array(\App\Adminx\Media\Controller::class, 'createFolder')), + array('POST', '/media/rename', array(\App\Adminx\Media\Controller::class, 'rename')), + array('POST', '/media/delete', array(\App\Adminx\Media\Controller::class, 'delete')), + array('POST', '/media/clear-thumbnails', array(\App\Adminx\Media\Controller::class, 'clearThumbnails')), + array('POST', '/media/transform', array(\App\Adminx\Media\Controller::class, 'transform')), + array('POST', '/media/preview', array(\App\Adminx\Media\Controller::class, 'preview')), + array('POST', '/media/preview-clear', array(\App\Adminx\Media\Controller::class, 'previewClear')), + array('POST', '/media/convert-webp', array(\App\Adminx\Media\Controller::class, 'convertWebp')), + ), + + 'view_globals' => array( + 'module_code' => 'media', + ), + ); diff --git a/adminx/modules/Media/view/file.twig b/adminx/modules/Media/view/file.twig new file mode 100644 index 0000000..abf747c --- /dev/null +++ b/adminx/modules/Media/view/file.twig @@ -0,0 +1,149 @@ +{% extends '@adminx/main.twig' %} +{% block title %}{{ file.name }}{% endblock %} +{% block content %} + + + + + +
    +
    +
    +
    + +
    +

    Предпросмотр

    +

    {{ file.meta ?: file.extension }}

    +
    +
    + {{ file.is_image ? 'Изображение' : 'Файл' }} +
    +
    + {% if file.is_image %} +
    + + {% if file.extension != 'svg' and can_manage %} +
    + + + +
    + {% endif %} +
    + {% else %} +
    {{ file.extension ?: 'file' }}
    + {% endif %} +
    +
    + Размер: {{ file.size_label }} + Габариты: {{ file.width and file.height ? file.width ~ 'x' ~ file.height : '-' }} + Изменён: {{ file.mtime_label }} +
    +
    + + +
    + +{% if can_manage %} +
    + + + +
    + + +{% endif %} + +{% endblock %} diff --git a/adminx/modules/Media/view/index.twig b/adminx/modules/Media/view/index.twig new file mode 100644 index 0000000..df594dc --- /dev/null +++ b/adminx/modules/Media/view/index.twig @@ -0,0 +1,240 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Медиа{% endblock %} +{% block content %} + + + + + +
    +
    + +
    {{ stats.files }}Файлов
    +
    +
    + +
    {{ stats.images }}Изображений
    +
    +
    + +
    {{ stats.folders }}Папок
    +
    +
    + +
    {{ stats.size_label }}Объём
    +
    +
    + +
    +
    +
    +
    +
    Файлы
    +

    {{ dir }}

    +

    Папки и файлы физически читаются из каталога uploads.

    +
    +
    +
    + {% for crumb in breadcrumbs %} + {% if loop.last %} + {{ crumb.title }} + {% else %} + {{ crumb.title }} + {% endif %} + {% endfor %} +
    +
    +
    + +
    +
    +
    + +
    +

    Содержимое папки

    +

    {{ folders|length }} папок, {{ total }} файлов в текущем фильтре.

    +
    +
    + {{ filters.view == 'grid' ? 'Сетка' : 'Список' }} +
    + +
    +
    + + +
    + + +
    + + + {% if filters.q or filters.type %} + Сбросить + {% endif %} +
    + +
    + + +
    +
    + + {% if can_manage %} +
    + + + + +
    + {% endif %} + + {% if folders is empty and files is empty and not parent_dir %} +
    +
    +

    В uploads пусто

    +

    Создайте папку или загрузите файлы.

    +
    + {% elseif filters.view == 'grid' %} +
    + {% if parent_dir %} + + + ..Вверх + + {% endif %} + {% for folder in folders %} +
    + + {{ folder.name }} + {{ folder.count }} объектов + {% if can_manage %} +
    + + + +
    + {% endif %} +
    + {% endfor %} + {% for f in files %} +
    + {% if f.is_image %} + + {% else %} + + + + {% endif %} + {{ f.name }} + {{ f.meta ?: f.extension }} +
    + + + {% if can_manage %} + + {% endif %} +
    +
    + {% endfor %} +
    + {% else %} +
    + + + + + {% if parent_dir %} + + + + + {% endif %} + {% for folder in folders %} + + + + + + {% endfor %} + {% for f in files %} + + + + + + + + {% endfor %} + +
    НазваниеТипРазмерИзменён
    ..Папка--
    {{ folder.name }}Папка{{ folder.count }} объектов-{% if can_manage %}
    {% endif %}
    {% if f.is_image %}{% else %}{% endif %}{{ f.name }}{{ f.is_image ? 'Изображение' : 'Файл' }}{% if f.extension %} / {{ f.extension }}{% endif %}{{ f.size_label }}{{ f.mtime_label }}
    {% if can_manage %}{% endif %}
    +
    + {% endif %} + + {% if pages > 1 %} + + {% endif %} +
    +
    + +{% if can_manage %} +
    + + + + +
    +{% endif %} + + + +{% endblock %} diff --git a/adminx/modules/Modules/Controller.php b/adminx/modules/Modules/Controller.php new file mode 100644 index 0000000..48c2f58 --- /dev/null +++ b/adminx/modules/Modules/Controller.php @@ -0,0 +1,293 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Modules; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\AuditLog; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Helpers\Request; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + AdminAssets::addStyle($this->base() . '/modules/Modules/assets/modules.css', 50); + AdminAssets::addScript($this->base() . '/modules/Modules/assets/modules.js', 50); + + try { + $result = array( + 'items' => NativeModuleAdapter::all(), + 'errors' => NativeModuleAdapter::errors(), + ); + $fatal = ''; + } catch (\Throwable $e) { + $result = array('items' => array(), 'errors' => array()); + $fatal = $e->getMessage(); + } + + $repository = ModuleRepository::catalog(false); + return $this->render('@modules/index.twig', array( + 'modules' => $result['items'], + 'errors' => $result['errors'], + 'fatal' => $fatal, + 'stats' => NativeModuleAdapter::stats($result['items']), + 'can_manage' => Permission::check('manage_modules'), + 'can_install_code' => Permission::check('install_module_code'), + 'repository' => $repository, + 'repository_settings' => ModuleRepository::settings(), + 'official_repository' => ModuleRepository::officialSettings(), + )); + } + + public function toggle(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + $enabled = Request::postBool('enabled', false); + return $this->perform($params, $enabled ? 'module.enabled' : 'module.disabled', function ($code) use ($enabled) { + return $this->moduleAction($code, 'toggle', array($enabled)); + }, $enabled ? 'Модуль включён' : 'Модуль отключён'); + } + + public function install(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + return $this->perform($params, 'module.installed', function ($code) { return $this->moduleAction($code, 'install'); }, 'Модуль установлен'); + } + + public function update(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + return $this->perform($params, 'module.updated', function ($code) { return $this->moduleAction($code, 'update'); }, 'Миграции модуля применены'); + } + + public function reinstall(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + $token = Request::postStr('preflight_token', ''); + return $this->perform($params, 'module.reinstalled', function ($code) use ($token) { return $this->moduleAction($code, 'reinstall', array($token)); }, 'Модуль переустановлен'); + } + + public function repair(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + return $this->perform($params, 'module.repaired', function ($code) { return $this->moduleAction($code, 'repair'); }, 'Операция модуля восстановлена'); + } + + public function uninstall(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + $token = Request::postStr('preflight_token', ''); + return $this->perform($params, 'module.uninstalled', function ($code) use ($token) { return $this->moduleAction($code, 'uninstall', array($token)); }, 'Модуль деинсталлирован'); + } + + public function preflight(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + $code = isset($params['code']) ? (string) $params['code'] : ''; + $operation = isset($params['operation']) ? (string) $params['operation'] : ''; + try { + $result = NativeModuleAdapter::preflight($code, $operation); + return $this->success('Предварительная проверка выполнена', array('data' => $result)); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + } + + public function remove(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + return $this->perform($params, 'module.files_removed', function ($code) { + return NativeModuleAdapter::remove($code); + }, 'Файлы модуля удалены'); + } + + public function presentation(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + $code = isset($params['code']) ? (string) $params['code'] : ''; + try { + $item = NativeModuleAdapter::savePresentation( + $code, + Request::postBool('header_enabled', false), + Request::postBool('dashboard_enabled', false) + ); + $this->auditPresentation($item); + return $this->success('Размещение модуля сохранено', array('data' => array('module' => $item))); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + } + + public function installArchive(array $params = array()) + { + if (($guard = $this->guard('manage_modules', 'install_module_code')) !== null) { return $guard; } + try { + $file = isset($_FILES['module_archive']) && is_array($_FILES['module_archive']) + ? $_FILES['module_archive'] + : array(); + $item = ModuleArchiveInstaller::install($file); + $user = Auth::user(); + AuditLog::record('module.archive_installed', array( + 'actor_id' => Auth::id(), + 'actor_name' => $user && isset($user['name']) ? (string) $user['name'] : '', + 'target_type' => 'native_module', + 'target_id' => null, + 'meta' => array( + 'code' => $item['code'], + 'version' => $item['version'], + 'folder' => $item['folder'], + 'manifest_sha256' => isset($item['manifest_sha256']) ? $item['manifest_sha256'] : '', + ), + )); + $adminUrl = isset($item['admin_url']) ? trim((string) $item['admin_url']) : ''; + $redirect = $adminUrl !== '' && strpos($adminUrl, '/') === 0 + ? $this->base() . $adminUrl + : $this->base() . '/modules'; + return $this->success('Модуль «' . $item['name'] . '» установлен', array( + 'data' => array('module' => $item), + 'redirect' => $redirect, + )); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + } + + public function saveRepositorySettings(array $params = array()) + { + if (($guard = $this->guard('manage_modules', 'install_module_code')) !== null) { return $guard; } + try { + $settings = ModuleRepository::saveSettings(array( + 'enabled' => Request::postBool('enabled', false), + 'url' => Request::postStr('url', ''), + 'public_key' => Request::postStr('public_key', ''), + )); + $this->auditRepository('module.repository_settings_updated', array( + 'enabled' => !empty($settings['enabled']), + 'url' => isset($settings['url']) ? $settings['url'] : '', + )); + return $this->success('Настройки каталога сохранены', array('data' => array('settings' => $settings))); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + } + + public function restoreOfficialRepository(array $params = array()) + { + if (($guard = $this->guard('manage_modules', 'install_module_code')) !== null) { return $guard; } + try { + $settings = ModuleRepository::restoreOfficial(); + $this->auditRepository('module.repository_official_restored', array('url' => $settings['url'])); + return $this->success('Официальный каталог модулей восстановлен', array('data' => array('settings' => $settings))); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + } + + public function refreshRepository(array $params = array()) + { + if (($guard = $this->guard('view_modules')) !== null) { return $guard; } + $catalog = ModuleRepository::catalog(true); + if (!empty($catalog['error'])) { return $this->error($catalog['error'], array(), 422); } + + return $this->success('Каталог модулей обновлён', array('data' => array('catalog' => $catalog))); + } + + public function installRepository(array $params = array()) + { + if (($guard = $this->guard('manage_modules', 'install_module_code')) !== null) { return $guard; } + $code = isset($params['code']) ? (string) $params['code'] : ''; + try { + $item = ModuleRepository::install($code); + $updated = isset($item['repository_operation']) && $item['repository_operation'] === 'update'; + $this->auditRepository($updated ? 'module.repository_updated' : 'module.repository_installed', array( + 'code' => $code, + 'version' => isset($item['version']) ? $item['version'] : (isset($item['file_version']) ? $item['file_version'] : ''), + 'manifest_sha256' => isset($item['manifest_sha256']) ? $item['manifest_sha256'] : '', + )); + $adminUrl = isset($item['admin_url']) ? trim((string) $item['admin_url']) : ''; + $redirect = $adminUrl !== '' && strpos($adminUrl, '/') === 0 + ? $this->base() . $adminUrl + : $this->base() . '/modules'; + return $this->success('Модуль «' . (isset($item['name']) ? $item['name'] : $code) . '» ' + . ($updated ? 'обновлён из каталога' : 'установлен из каталога'), array( + 'data' => array('module' => $item), + 'redirect' => $updated ? $this->base() . '/modules?tab=catalog' : $redirect, + )); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + } + + protected function perform(array $params, $action, $callback, $message) + { + $code = isset($params['code']) ? (string) $params['code'] : ''; + try { + $item = call_user_func($callback, $code); + $user = Auth::user(); + AuditLog::record($action, array( + 'actor_id' => Auth::id(), + 'actor_name' => $user && isset($user['name']) ? (string) $user['name'] : '', + 'target_type' => 'native_module', + 'target_id' => !empty($item['id']) ? (int) $item['id'] : null, + 'meta' => array('code' => $code, 'version' => isset($item['file_version']) ? $item['file_version'] : ''), + )); + return $this->success($message, array('data' => array('module' => $item))); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + } + + protected function moduleAction($code, $method, array $arguments = array()) + { + return call_user_func_array(array(NativeModuleAdapter::class, $method), array_merge(array($code), $arguments)); + } + + protected function auditPresentation(array $item) + { + $user = Auth::user(); + AuditLog::record('module.presentation_updated', array( + 'actor_id' => Auth::id(), + 'actor_name' => $user && isset($user['name']) ? (string) $user['name'] : '', + 'target_type' => 'native_module', + 'target_id' => null, + 'meta' => array( + 'code' => $item['code'], + 'header_enabled' => !empty($item['header_enabled']), + 'dashboard_enabled' => !empty($item['dashboard_enabled']), + ), + )); + } + + protected function auditRepository($action, array $meta) + { + $user = Auth::user(); + AuditLog::record($action, array( + 'actor_id' => Auth::id(), + 'actor_name' => $user && isset($user['name']) ? (string) $user['name'] : '', + 'target_type' => 'module_repository', + 'target_id' => null, + 'meta' => $meta, + )); + } + + protected function guard($permission = 'manage_modules', $additionalPermission = '') + { + return $this->guardPermission($permission, $additionalPermission); + } + } diff --git a/adminx/modules/Modules/ModuleArchiveInstaller.php b/adminx/modules/Modules/ModuleArchiveInstaller.php new file mode 100644 index 0000000..b17e2bd --- /dev/null +++ b/adminx/modules/Modules/ModuleArchiveInstaller.php @@ -0,0 +1,862 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Modules; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\UploadPolicy; + + use App\Common\CompiledModuleRegistry; + use App\Common\Loader\Load; + use App\Common\ModuleManager; + use App\Content\Packages\PackageDependencyContext; + use App\Helpers\Dir; + + /** Безопасная доставка Adminx-модуля или составного public/admin-пакета из ZIP. */ + class ModuleArchiveInstaller + { + const MAX_ARCHIVE_BYTES = 26214400; + const MAX_FILES = 1500; + const MAX_FILE_BYTES = 26214400; + const MAX_UNPACKED_BYTES = 104857600; + + protected static $lockDepth = 0; + protected static $dependencyStack = array(); + + public static function install(array $file) + { + self::assertUpload($file); + return self::installStoredArchive(self::storeUploadedArchive($file)); + } + + public static function installDownloaded($archive, $expectedCode, $expectedVersion) + { + $archive = self::assertStoredArchive($archive); + self::assertExpectedModule($expectedCode, $expectedVersion); + return self::installStoredArchive($archive, $expectedCode, $expectedVersion); + } + + public static function updateDownloaded($archive, $expectedCode, $expectedVersion) + { + $archive = self::assertStoredArchive($archive); + self::assertExpectedModule($expectedCode, $expectedVersion); + return self::installStoredArchive($archive, $expectedCode, $expectedVersion, true); + } + + protected static function installStoredArchive($archive, $expectedCode = '', $expectedVersion = '', $replace = false) + { + + $adminModulesRoot = ADMINX_PATH . DS . 'modules'; + $packagesRoot = BASEPATH . DS . 'modules'; + self::assertWritableRoot($adminModulesRoot); + self::assertWritableRoot($packagesRoot); + + $lock = self::acquireInstallLock(); + + $stage = ''; + $content = ''; + $target = ''; + $backup = ''; + $replacing = false; + $validated = false; + $wasInstalled = false; + $dependencyResults = array(); + $packageCode = ''; + + try { + $stage = BASEPATH . DS . 'tmp' . DS . '.module-install-' . bin2hex(random_bytes(8)); + $content = $stage . DS . 'content'; + if (!@mkdir($content, 0755, true) && !is_dir($content)) { + throw new \RuntimeException('Не удалось создать временный каталог установки'); + } + + self::extract($archive, $content); + $sourceInfo = self::moduleRoot($content); + $source = $sourceInfo['path']; + $isPackage = $sourceInfo['kind'] === 'package'; + $packageManifest = self::packageManifest($source, $sourceInfo['kind']); + $packageCode = (string) $packageManifest['code']; + if ($expectedCode !== '' && !hash_equals($expectedCode, (string) $packageManifest['code'])) { + throw new \RuntimeException('Код скачанного пакета не совпадает с записью удалённого каталога'); + } + + if ($expectedVersion !== '' && !hash_equals($expectedVersion, (string) $packageManifest['version'])) { + throw new \RuntimeException('Версия скачанного пакета не совпадает с записью удалённого каталога'); + } + + $dependencyResults = self::installEmbeddedDependencies($source, $packageManifest); + if (is_dir($source . DS . 'packages')) { + Dir::delete($source . DS . 'packages'); + } + + $folder = (string) $packageManifest['folder']; + $modulesRoot = $isPackage ? $packagesRoot : $adminModulesRoot; + $target = $modulesRoot . DS . $folder; + if ($replace) { + $previousModule = ModuleManager::get((string) $packageManifest['code']); + $wasInstalled = $previousModule && !empty($previousModule['installed']); + self::assertReplacementTarget($target, $packageManifest['code'], $isPackage); + $backup = $stage . DS . 'previous'; + if (!@rename($target, $backup)) { + throw new \RuntimeException('Не удалось подготовить резервную копию файлов модуля'); + } + + $replacing = true; + } else { + self::assertFolderAvailable($modulesRoot, $folder); + } + + if (!@rename($source, $target)) { + if ($replacing && is_dir($backup)) { + @rename($backup, $target); + self::refreshCodeCache($target); + $replacing = false; + } + + throw new \RuntimeException('Не удалось перенести файлы модуля'); + } + + if ($replace) { self::refreshCodeCache($target); } + + $existingCodes = array(); + foreach (ModuleManager::all() as $module) { + $existingCodes[(string) $module['code']] = true; + } + + if ($isPackage) { + $class = \App\Common\PackageModuleRuntime::classify($folder); + if (is_dir($target . DS . 'app')) { + Load::regNamespace('App\\Modules\\' . $class, $target . DS . 'app'); + } + + Load::regNamespace('App\\Adminx\\Packages\\' . $class, $target . DS . 'admin'); + } else { + Load::regNamespace('App\\Adminx\\' . $folder, $target); + } + + $manifest = $target . DS . ($isPackage ? 'admin' . DS : '') . 'module.php'; + $descriptor = include $manifest; + if (!is_array($descriptor) || empty($descriptor['code'])) { + throw new \RuntimeException('module.php должен возвращать descriptor с кодом модуля'); + } + + $code = (string) $descriptor['code']; + if (!preg_match('/^[a-z][a-z0-9_]{0,63}$/', $code)) { + throw new \RuntimeException('Некорректный code в module.php'); + } + + if ($isPackage && $folder !== $code) { + throw new \RuntimeException('Имя папки составного пакета должно совпадать с его code'); + } + + if ($code !== (string) $packageManifest['code']) { + throw new \RuntimeException('Code в module.php не совпадает с официальным manifest пакета'); + } + + $version = isset($descriptor['version']) ? (string) $descriptor['version'] : ''; + if ($version !== (string) $packageManifest['version']) { + throw new \RuntimeException('Версия module.php не совпадает с официальным manifest пакета'); + } + + if (isset($existingCodes[$code]) && !$replace) { + throw new \RuntimeException('Модуль с кодом «' . $code . '» уже существует'); + } + + if (empty($descriptor['lifecycle']['managed'])) { + throw new \RuntimeException('Архивный модуль должен объявлять lifecycle.managed = true'); + } + + if (!empty($descriptor['lifecycle']['adopt_existing'])) { + throw new \RuntimeException('adopt_existing запрещён для установки нового модуля из ZIP'); + } + + if (empty($descriptor['admin_extension']) || !is_array($descriptor['admin_extension'])) { + throw new \RuntimeException('Архив не содержит admin_extension для новой админки'); + } + + $descriptor['_base_path'] = $isPackage ? $target . DS . 'admin' : $target; + ModuleManager::register($descriptor); + if ($replace) { + self::invalidatePackageRegistry($isPackage); + $validated = true; + if ($wasInstalled) { ModuleManager::update($code); } + if ($backup !== '' && is_dir($backup)) { Dir::delete($backup); } + $result = self::result($code, $folder, $isPackage, $descriptor, $packageManifest, $dependencyResults); + PackageDependencyContext::clear($packageCode); + return $result; + } + + $validated = true; + ModuleManager::install($code); + self::invalidatePackageRegistry($isPackage); + $result = self::result($code, $folder, $isPackage, $descriptor, $packageManifest, $dependencyResults); + PackageDependencyContext::clear($packageCode); + return $result; + } catch (\Throwable $e) { + // После успешной проверки manifest папку оставляем: миграции могли + // примениться частично, и штатный повтор установки должен их продолжить. + if (!$validated && $target !== '' && is_dir($target)) { + Dir::delete($target); + } + + if (!$validated && $replacing && $backup !== '' && is_dir($backup)) { + @rename($backup, $target); + self::refreshCodeCache($target); + self::invalidatePackageRegistry($isPackage); + } + + if ($packageCode !== '') { + PackageDependencyContext::clear($packageCode); + } + + throw $e; + } finally { + if ($stage !== '' && is_dir($stage)) { Dir::delete($stage); } + if ($archive !== '' && is_file($archive)) { @unlink($archive); } + self::releaseInstallLock($lock); + } + } + + protected static function assertExpectedModule(&$code, &$version) + { + $code = strtolower(trim((string) $code)); + $version = trim((string) $version); + if (!preg_match('/^[a-z][a-z0-9_]{0,63}$/', $code) + || !preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9._-]+)?$/', $version)) { + throw new \InvalidArgumentException('Удалённый каталог передал некорректный модуль'); + } + } + + protected static function result( + $code, + $folder, + $isPackage, + array $descriptor, + array $packageManifest, + array $dependencies = array() + ) + { + $meta = ModuleManager::get($code); + return array( + 'code' => $code, + 'name' => isset($meta['name']) ? (string) $meta['name'] : $code, + 'version' => isset($meta['version']) ? (string) $meta['version'] : '', + 'folder' => $folder, + 'package' => (bool) $isPackage, + 'installed' => !empty($meta['installed']), + 'admin_url' => isset($descriptor['admin_extension']['url']) ? (string) $descriptor['admin_extension']['url'] : '', + 'manifest_sha256' => (string) $packageManifest['manifest_sha256'], + 'dependencies' => $dependencies, + ); + } + + protected static function assertStoredArchive($archive) + { + $archive = realpath((string) $archive); + $tmpRoot = realpath(BASEPATH . DS . 'tmp'); + if ($archive === false || $tmpRoot === false || !is_file($archive) + || strpos(str_replace('\\', '/', $archive), rtrim(str_replace('\\', '/', $tmpRoot), '/') . '/') !== 0) { + throw new \RuntimeException('Удалённый ZIP находится вне защищённого временного каталога'); + } + + $size = (int) filesize($archive); + if (strtolower(pathinfo($archive, PATHINFO_EXTENSION)) !== 'zip' + || $size < 1 || $size > self::MAX_ARCHIVE_BYTES) { + throw new \RuntimeException('Скачанный ZIP имеет недопустимый размер или формат'); + } + + return $archive; + } + + protected static function storeUploadedArchive(array $file) + { + $directory = BASEPATH . DS . 'tmp'; + if (!is_dir($directory) && !@mkdir($directory, 0755, true)) { + throw new \RuntimeException('Не удалось создать локальный каталог загрузки: ' . $directory); + } + + if (!is_writable($directory)) { + throw new \RuntimeException('Локальный каталог загрузки недоступен для записи: ' . $directory); + } + + $target = $directory . DS . '.module-upload-' . bin2hex(random_bytes(8)) . '.zip'; + if (!@move_uploaded_file((string) $file['tmp_name'], $target)) { + throw new \RuntimeException('Не удалось перенести ZIP из временного каталога сервера'); + } + + @chmod($target, 0600); + if (!is_file($target) || (int) filesize($target) !== (int) $file['size']) { + @unlink($target); + throw new \RuntimeException('Локальная копия ZIP сохранена не полностью'); + } + + return $target; + } + + /** MySQL-lock works across PHP workers and does not depend on hosting /tmp. */ + protected static function acquireInstallLock() + { + if (self::$lockDepth > 0) { + self::$lockDepth++; + return array('type' => 'nested', 'name' => '', 'handle' => null); + } + + $name = 'ave_module_install_' . substr(hash('sha256', BASEPATH), 0, 32); + try { + $acquired = (int) \DB::query('SELECT GET_LOCK(%s,0)', $name)->getValue(); + if ($acquired === 1) { + self::$lockDepth = 1; + return array('type' => 'database', 'name' => $name, 'handle' => null); + } + + if ($acquired === 0) { + throw new \RuntimeException('Установка другого модуля уже выполняется. Повторите попытку позже'); + } + } catch (\RuntimeException $e) { + if (strpos($e->getMessage(), 'уже выполняется') !== false) { throw $e; } + } catch (\Throwable $e) { + // The file lock below keeps installations safe if advisory DB locks are unavailable. + } + + $directory = BASEPATH . DS . 'tmp'; + if (!is_dir($directory) && !@mkdir($directory, 0755, true)) { + throw new \RuntimeException('Не удалось создать каталог блокировки установки: ' . $directory); + } + + $handle = @fopen($directory . DS . '.module-install.lock', 'c'); + if (!$handle) { + throw new \RuntimeException('Каталог временных файлов недоступен для записи: ' . $directory); + } + + if (!@flock($handle, LOCK_EX | LOCK_NB)) { + @fclose($handle); + throw new \RuntimeException('Установка другого модуля уже выполняется. Повторите попытку позже'); + } + + self::$lockDepth = 1; + return array('type' => 'file', 'name' => '', 'handle' => $handle); + } + + protected static function releaseInstallLock(array $lock) + { + if (self::$lockDepth > 0) { + self::$lockDepth--; + } + + if (isset($lock['type']) && $lock['type'] === 'nested') { + return; + } + + if (self::$lockDepth > 0) { + return; + } + + if (isset($lock['type']) && $lock['type'] === 'database' && !empty($lock['name'])) { + try { + \DB::query('SELECT RELEASE_LOCK(%s)', (string) $lock['name']); + } catch (\Throwable $e) { + // MySQL releases named locks automatically when the request connection closes. + } + + return; + } + + $handle = isset($lock['handle']) ? $lock['handle'] : null; + if (is_resource($handle)) { + @flock($handle, LOCK_UN); + @fclose($handle); + } + } + + protected static function assertWritableRoot($root) + { + if (!is_dir($root) || !is_writable($root)) { + throw new \RuntimeException('Каталог модулей недоступен для записи: ' . $root); + } + } + + protected static function assertUpload(array $file) + { + $error = isset($file['error']) ? (int) $file['error'] : UPLOAD_ERR_NO_FILE; + if ($error !== UPLOAD_ERR_OK) { + $messages = array( + UPLOAD_ERR_INI_SIZE => 'Архив превышает серверный лимит загрузки', + UPLOAD_ERR_FORM_SIZE => 'Архив превышает допустимый размер', + UPLOAD_ERR_PARTIAL => 'Архив загрузился не полностью', + UPLOAD_ERR_NO_FILE => 'Выберите ZIP-архив модуля', + ); + throw new \RuntimeException(isset($messages[$error]) ? $messages[$error] : 'Ошибка загрузки архива'); + } + + $name = isset($file['name']) ? (string) $file['name'] : ''; + $path = isset($file['tmp_name']) ? (string) $file['tmp_name'] : ''; + $size = isset($file['size']) ? (int) $file['size'] : 0; + if ($path === '' || !is_uploaded_file($path)) { + throw new \RuntimeException('Загруженный файл не найден'); + } + + if (strtolower(pathinfo($name, PATHINFO_EXTENSION)) !== 'zip') { + throw new \RuntimeException('Поддерживаются только ZIP-архивы'); + } + + if ($size < 1 || $size > self::MAX_ARCHIVE_BYTES) { + throw new \RuntimeException('Размер ZIP должен быть не больше 25 МБ'); + } + + UploadPolicy::assertAllowed($path, $name, $size, '', 'module_archive'); + } + + protected static function extract($archive, $target) + { + if (!class_exists('ZipArchive')) { + throw new \RuntimeException('Для установки модулей требуется PHP-расширение zip'); + } + + $zip = new \ZipArchive(); + $opened = $zip->open($archive); + if ($opened !== true) { + throw new \RuntimeException(self::zipOpenError((int) $opened)); + } + + $files = 0; + $total = 0; + $seen = array(); + try { + if ($zip->numFiles > self::MAX_FILES) { + throw new \RuntimeException('ZIP содержит слишком много файлов и каталогов'); + } + + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + if (!is_array($stat) || !isset($stat['name'])) { + throw new \RuntimeException('Не удалось прочитать структуру ZIP'); + } + + $name = self::safeEntryName((string) $stat['name']); + if ($name === null) { continue; } + $entryKey = strtolower(rtrim($name, '/')); + if (isset($seen[$entryKey])) { + throw new \RuntimeException('ZIP содержит повторяющийся путь «' . $name . '»'); + } + + $seen[$entryKey] = true; + self::assertNotSymlink($zip, $i); + + $isDir = substr($name, -1) === '/'; + $path = $target . DS . str_replace('/', DS, rtrim($name, '/')); + if ($isDir) { + if (!is_dir($path) && !@mkdir($path, 0755, true)) { + throw new \RuntimeException('Не удалось создать каталог из ZIP'); + } + + continue; + } + + $files++; + $size = isset($stat['size']) ? (int) $stat['size'] : 0; + $compressed = isset($stat['comp_size']) ? (int) $stat['comp_size'] : 0; + $total += $size; + if ($files > self::MAX_FILES || $size > self::MAX_FILE_BYTES || $total > self::MAX_UNPACKED_BYTES) { + throw new \RuntimeException('Распакованный модуль превышает безопасные лимиты'); + } + + if ($size > 1048576 && $compressed > 0 && $size > $compressed * 200) { + throw new \RuntimeException('ZIP содержит файл с подозрительным коэффициентом сжатия'); + } + + $parent = dirname($path); + if (!is_dir($parent) && !@mkdir($parent, 0755, true)) { + throw new \RuntimeException('Не удалось создать структуру модуля'); + } + + $input = $zip->getStream((string) $stat['name']); + $output = @fopen($path, 'wb'); + if (!$input || !$output) { + if ($input) { @fclose($input); } + if ($output) { @fclose($output); } + throw new \RuntimeException('Не удалось распаковать файл «' . basename($name) . '»'); + } + + $copied = stream_copy_to_stream($input, $output); + fclose($input); + fclose($output); + if ($copied === false || (int) $copied !== $size) { + throw new \RuntimeException('Файл «' . basename($name) . '» распакован не полностью'); + } + + @chmod($path, 0644); + } + } finally { + $zip->close(); + } + + if ($files < 1) { + throw new \RuntimeException('ZIP-архив пуст'); + } + } + + protected static function zipOpenError($code) + { + $errors = array( + \ZipArchive::ER_EXISTS => 'файл уже существует', + \ZipArchive::ER_INCONS => 'повреждена структура архива', + \ZipArchive::ER_INVAL => 'передан некорректный аргумент', + \ZipArchive::ER_MEMORY => 'серверу не хватило памяти', + \ZipArchive::ER_NOENT => 'локальная копия архива не найдена', + \ZipArchive::ER_NOZIP => 'файл не является ZIP-архивом', + \ZipArchive::ER_OPEN => 'сервер не смог открыть локальную копию архива', + \ZipArchive::ER_READ => 'сервер не смог прочитать архив', + \ZipArchive::ER_SEEK => 'сервер не поддерживает позиционирование в файле архива', + ); + $reason = isset($errors[$code]) ? $errors[$code] : 'неподдерживаемый формат или ошибка ZIP'; + return 'Не удалось открыть ZIP: ' . $reason . ' (код ' . (int) $code . ')'; + } + + protected static function safeEntryName($name) + { + $name = str_replace('\\', '/', $name); + if ($name === '' || strlen($name) > 512 || strpos($name, "\0") !== false || preg_match('/[\x00-\x1F\x7F]/', $name)) { + throw new \RuntimeException('ZIP содержит некорректное имя файла'); + } + + if ($name[0] === '/' || preg_match('/^[A-Za-z]:\//', $name)) { + throw new \RuntimeException('ZIP содержит абсолютный путь'); + } + + $segments = explode('/', rtrim($name, '/')); + foreach ($segments as $segment) { + if ($segment === '' || $segment === '.' || $segment === '..') { + throw new \RuntimeException('ZIP содержит небезопасный путь'); + } + + if ($segment === '__MACOSX' || $segment === '.DS_Store') { + return null; + } + } + + return $name; + } + + protected static function assertNotSymlink(\ZipArchive $zip, $index) + { + $opsys = 0; + $attributes = 0; + if ($zip->getExternalAttributesIndex($index, $opsys, $attributes)) { + $type = ($attributes >> 16) & 0170000; + if ($type === 0120000) { + throw new \RuntimeException('Символические ссылки в архиве запрещены'); + } + } + } + + protected static function installEmbeddedDependencies($source, array $manifest) + { + $dependencies = isset($manifest['dependencies']) && is_array($manifest['dependencies']) + ? $manifest['dependencies'] + : array(); + if (!$dependencies) { + return array(); + } + + $packageCode = (string) $manifest['code']; + if (in_array($packageCode, self::$dependencyStack, true)) { + throw new \RuntimeException('Обнаружена циклическая зависимость пакета «' . $packageCode . '»'); + } + + self::$dependencyStack[] = $packageCode; + $results = array(); + try { + foreach ($dependencies as $dependency) { + $code = (string) $dependency['code']; + $version = (string) $dependency['version']; + $archive = $source . DS . str_replace('/', DS, (string) $dependency['archive']); + $module = ModuleManager::get($code); + $state = self::moduleState($module); + PackageDependencyContext::remember($packageCode, $code, $state); + + $currentVersion = $module && isset($module['version']) ? (string) $module['version'] : ''; + if ($module && $currentVersion !== '' && version_compare($currentVersion, $version, '>=')) { + $results[] = array( + 'code' => $code, + 'version' => $currentVersion, + 'required_version' => $version, + 'operation' => 'reused', + 'original_state' => $state, + ); + continue; + } + + $replace = is_array($module); + $installed = self::installStoredArchive($archive, $code, $version, $replace); + $results[] = array( + 'code' => $code, + 'version' => isset($installed['version']) ? (string) $installed['version'] : $version, + 'required_version' => $version, + 'operation' => $replace ? 'updated' : 'installed', + 'original_state' => $state, + ); + } + } finally { + array_pop(self::$dependencyStack); + } + + return $results; + } + + protected static function moduleState($module) + { + if (!is_array($module) || empty($module['installed'])) { + return 'available'; + } + + if (isset($module['status']) && $module['status'] === 'dirty') { + $previous = isset($module['previous_status']) + ? strtolower(trim((string) $module['previous_status'])) + : ''; + if (in_array($previous, array('available', 'disabled', 'enabled'), true)) { + return $previous; + } + } + + return !empty($module['enabled']) ? 'enabled' : 'disabled'; + } + + protected static function packageManifest($source, $kind) + { + $path = rtrim((string) $source, DS) . DS . 'AVE-MODULE.json'; + if (!is_file($path) || filesize($path) > 1048576) { + throw new \RuntimeException('Архив не содержит допустимый AVE-MODULE.json'); + } + + $json = file_get_contents($path); + $manifest = json_decode((string) $json, true); + if (!is_array($manifest) || json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException('AVE-MODULE.json содержит некорректный JSON'); + } + + $formats = array('ave-module-package-v1', 'ave-module-package-v2'); + if (!isset($manifest['format']) || !in_array($manifest['format'], $formats, true) + || !isset($manifest['owner']) || $manifest['owner'] !== 'AVE.cms') { + throw new \RuntimeException('Поддерживаются только официальные пакеты AVE.cms форматов v1 и v2'); + } + + if (!isset($manifest['kind']) || $manifest['kind'] !== (string) $kind) { + throw new \RuntimeException('Тип модуля не совпадает с AVE-MODULE.json'); + } + + $code = isset($manifest['code']) ? strtolower(trim((string) $manifest['code'])) : ''; + $folder = isset($manifest['folder']) ? trim((string) $manifest['folder']) : ''; + $version = isset($manifest['version']) ? trim((string) $manifest['version']) : ''; + if (!preg_match('/^[a-z][a-z0-9_]{0,63}$/', $code) + || !preg_match('/^[A-Za-z][A-Za-z0-9_]{0,63}$/', $folder) + || !preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9._-]+)?$/', $version)) { + throw new \RuntimeException('AVE-MODULE.json содержит некорректные code, folder или version'); + } + + if ($kind === 'package' && $folder !== $code) { + throw new \RuntimeException('Папка составного пакета в AVE-MODULE.json должна совпадать с code'); + } + + $files = isset($manifest['files']) && is_array($manifest['files']) ? $manifest['files'] : array(); + if (!$files || count($files) > self::MAX_FILES) { + throw new \RuntimeException('AVE-MODULE.json не содержит допустимый список файлов'); + } + + $expected = array(); + foreach ($files as $relative => $checksum) { + $relative = str_replace('\\', '/', trim((string) $relative)); + $safe = self::safeEntryName($relative); + $checksum = strtolower(trim((string) $checksum)); + if ($safe === null || $safe !== $relative || $relative === 'AVE-MODULE.json' + || !preg_match('/^[a-f0-9]{64}$/', $checksum)) { + throw new \RuntimeException('AVE-MODULE.json содержит некорректный файл или checksum'); + } + + $expected[$relative] = $checksum; + } + + $entry = $kind === 'package' ? 'admin/module.php' : 'module.php'; + if (!isset($expected[$entry])) { + throw new \RuntimeException('Manifest пакета не включает основной module.php'); + } + + $actual = array(); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($source, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + foreach ($iterator as $file) { + if (!$file->isFile() || $file->isLink()) { + throw new \RuntimeException('Пакет содержит недопустимый тип файла'); + } + + $relative = ltrim(str_replace('\\', '/', substr($file->getPathname(), strlen($source))), '/'); + if ($relative === 'AVE-MODULE.json') { + continue; + } + + if (!isset($expected[$relative])) { + throw new \RuntimeException('Файл «' . $relative . '» отсутствует в официальном manifest'); + } + + $actual[$relative] = hash_file('sha256', $file->getPathname()); + if (!hash_equals($expected[$relative], $actual[$relative])) { + throw new \RuntimeException('Checksum файла «' . $relative . '» не совпадает'); + } + } + + if (count($actual) !== count($expected)) { + throw new \RuntimeException('В пакете отсутствует часть файлов из официального manifest'); + } + + $manifest['dependencies'] = self::manifestDependencies($manifest, $expected); + $manifest['code'] = $code; + $manifest['folder'] = $folder; + $manifest['version'] = $version; + $manifest['manifest_sha256'] = hash('sha256', (string) $json); + return $manifest; + } + + protected static function manifestDependencies(array $manifest, array $files) + { + $dependencies = isset($manifest['dependencies']) && is_array($manifest['dependencies']) + ? $manifest['dependencies'] + : array(); + if ($manifest['format'] === 'ave-module-package-v1') { + if ($dependencies) { + throw new \RuntimeException('Вложенные зависимости требуют format ave-module-package-v2'); + } + + return array(); + } + + if (!$dependencies || count($dependencies) > 64) { + throw new \RuntimeException('Manifest v2 не содержит допустимый список зависимостей'); + } + + $result = array(); + $codes = array(); + $archives = array(); + foreach ($dependencies as $dependency) { + $code = isset($dependency['code']) ? strtolower(trim((string) $dependency['code'])) : ''; + $version = isset($dependency['version']) ? trim((string) $dependency['version']) : ''; + $archive = isset($dependency['archive']) + ? str_replace('\\', '/', trim((string) $dependency['archive'])) + : ''; + $checksum = isset($dependency['sha256']) ? strtolower(trim((string) $dependency['sha256'])) : ''; + $safeArchive = $archive !== '' ? self::safeEntryName($archive) : null; + if (!preg_match('/^[a-z][a-z0-9_]{0,63}$/', $code) + || !preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9._-]+)?$/', $version) + || $safeArchive === null || $safeArchive !== $archive + || strpos($archive, 'packages/modules/') !== 0 + || strtolower(pathinfo($archive, PATHINFO_EXTENSION)) !== 'zip' + || !preg_match('/^[a-f0-9]{64}$/', $checksum) + || !isset($files[$archive]) || !hash_equals((string) $files[$archive], $checksum) + || isset($codes[$code]) || isset($archives[$archive])) { + throw new \RuntimeException('AVE-MODULE.json содержит некорректную вложенную зависимость'); + } + + $codes[$code] = true; + $archives[$archive] = true; + $result[] = array( + 'code' => $code, + 'version' => $version, + 'archive' => $archive, + 'sha256' => $checksum, + ); + } + + return $result; + } + + protected static function moduleRoot($content) + { + if (is_file($content . DS . 'module.php')) { + return array('path' => $content, 'kind' => 'admin'); + } + + if (is_file($content . DS . 'admin' . DS . 'module.php')) { + return array('path' => $content, 'kind' => 'package'); + } + + $roots = array(); + foreach ((array) scandir($content) as $entry) { + if ($entry === '.' || $entry === '..' || strpos($entry, '.') === 0) { continue; } + $path = $content . DS . $entry; + if (!is_dir($path)) { continue; } + if (is_file($path . DS . 'module.php')) { + $roots[] = array('path' => $path, 'kind' => 'admin'); + } elseif (is_file($path . DS . 'admin' . DS . 'module.php')) { + $roots[] = array('path' => $path, 'kind' => 'package'); + } + } + + if (count($roots) !== 1) { + throw new \RuntimeException('ZIP должен содержать один module.php или пакет с admin/module.php'); + } + + return $roots[0]; + } + + protected static function assertFolderAvailable($modulesRoot, $folder) + { + foreach ((array) scandir($modulesRoot) as $entry) { + if (strcasecmp((string) $entry, $folder) === 0) { + throw new \RuntimeException('Папка модуля «' . $folder . '» уже существует'); + } + } + } + + protected static function assertReplacementTarget($target, $code, $isPackage) + { + $module = ModuleManager::get((string) $code); + $expected = realpath($isPackage ? $target . DS . 'admin' : $target); + $actual = $module && !empty($module['base_path']) ? realpath((string) $module['base_path']) : false; + if (!$module || !$expected || !$actual || !hash_equals(str_replace('\\', '/', $expected), str_replace('\\', '/', $actual))) { + throw new \RuntimeException('Локальный модуль не совпадает с обновляемым пакетом'); + } + } + + protected static function invalidatePackageRegistry($isPackage) + { + if ($isPackage) { + CompiledModuleRegistry::invalidate('package_admin'); + CompiledModuleRegistry::invalidate('public'); + return; + } + + CompiledModuleRegistry::invalidate('adminx'); + } + + protected static function invalidateOpcache($root) + { + if (!function_exists('opcache_invalidate') || !is_dir($root)) { return; } + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + foreach ($iterator as $file) { + if ($file->isFile() && strtolower($file->getExtension()) === 'php') { + @opcache_invalidate($file->getPathname(), true); + } + } + } + + protected static function refreshCodeCache($root) + { + clearstatcache(true); + self::invalidateOpcache($root); + } + } diff --git a/adminx/modules/Modules/ModuleRepository.php b/adminx/modules/Modules/ModuleRepository.php new file mode 100644 index 0000000..49907be --- /dev/null +++ b/adminx/modules/Modules/ModuleRepository.php @@ -0,0 +1,447 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Modules; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\Cache; + use App\Common\DistributionProfile; + use App\Common\ModuleSettings; + + /** Подписанный удалённый каталог официальных ZIP-пакетов AVE.cms. */ + class ModuleRepository + { + const MODULE = 'modules'; + const ENVELOPE_FORMAT = 'ave-module-repository-v1'; + const PAYLOAD_FORMAT = 'ave-module-index-v1'; + const CACHE_TTL = 900; + const MAX_CATALOG_BYTES = 2097152; + + public static function settings() + { + return array( + 'enabled' => (bool) ModuleSettings::value('repository_enabled', false, self::MODULE), + 'url' => trim((string) ModuleSettings::value('repository_url', '', self::MODULE)), + 'public_key' => trim((string) ModuleSettings::value('repository_public_key', '', self::MODULE)), + ); + } + + public static function saveSettings(array $input) + { + $enabled = !empty($input['enabled']); + $url = self::catalogUrl(isset($input['url']) ? $input['url'] : '', $enabled); + $key = self::publicKey(isset($input['public_key']) ? $input['public_key'] : '', $enabled); + ModuleSettings::set('repository_enabled', $enabled, self::MODULE, 'bool'); + ModuleSettings::set('repository_url', $url, self::MODULE, 'string'); + ModuleSettings::set('repository_public_key', $key, self::MODULE, 'string'); + Cache::forgetTag('module-repository'); + + return self::settings(); + } + + public static function officialSettings() + { + try { + $profile = DistributionProfile::official(); + $repository = DistributionProfile::repository($profile, 'modules'); + return array('available' => true, 'name' => $profile['name'], 'error' => '') + $repository; + } catch (\Throwable $e) { + return array('available' => false, 'name' => '', 'url' => '', 'public_key' => '', 'fingerprint' => '', 'error' => $e->getMessage()); + } + } + + public static function restoreOfficial() + { + $official = self::officialSettings(); + if (empty($official['available'])) { throw new \RuntimeException($official['error']); } + return self::saveSettings(array('enabled' => true, 'url' => $official['url'], 'public_key' => $official['public_key'])); + } + + public static function catalog($force = false) + { + $settings = self::settings(); + if (!$settings['enabled']) { + return self::emptyCatalog('disabled', 'Удалённый каталог выключен'); + } + + if ($settings['url'] === '' || $settings['public_key'] === '') { + return self::emptyCatalog('configuration', 'Укажите URL каталога и публичный ключ'); + } + + $key = 'module-repository:' . hash('sha256', $settings['url'] . "\0" . $settings['public_key']); + if ($force) { Cache::forget($key); } + try { + $catalog = Cache::rememberTagged($key, self::CACHE_TTL, array('module-repository'), function () use ($settings) { + $raw = self::request($settings['url'], self::MAX_CATALOG_BYTES); + return self::verifyEnvelope($raw, $settings['public_key'], $settings['url']); + }); + $catalog['enabled'] = true; + $catalog['configured'] = true; + $catalog['error'] = ''; + return self::withLocalState($catalog); + } catch (\Throwable $e) { + $catalog = self::emptyCatalog('fetch', $e->getMessage()); + $catalog['enabled'] = true; + $catalog['configured'] = true; + return $catalog; + } + } + + public static function install($code) + { + $code = strtolower(trim((string) $code)); + $catalog = self::catalog(false); + if (!empty($catalog['error'])) { throw new \RuntimeException($catalog['error']); } + $item = null; + foreach ($catalog['items'] as $candidate) { + if ($candidate['code'] === $code) { $item = $candidate; break; } + } + + if (!$item) { throw new \InvalidArgumentException('Модуль отсутствует в проверенном каталоге'); } + if (empty($item['compatible'])) { throw new \RuntimeException((string) $item['compatibility_message']); } + if (!empty($item['local'])) { + if ($item['status'] === 'update') { + $archive = self::download($item); + $result = ModuleArchiveInstaller::updateDownloaded($archive, $item['code'], $item['version']); + $result['repository_operation'] = 'update'; + return $result; + } + + if (empty($item['installed']) && version_compare($item['local_version'], $item['version'], '=')) { + $result = NativeModuleAdapter::install($code); + $result['repository_operation'] = 'install'; + return $result; + } + + throw new \RuntimeException('Эта версия модуля уже находится на сервере'); + } + + $archive = self::download($item); + $result = ModuleArchiveInstaller::installDownloaded($archive, $item['code'], $item['version']); + $result['repository_operation'] = 'install'; + return $result; + } + + public static function verifyEnvelope($raw, $publicKey, $catalogUrl = 'https://repository.invalid/index.json') + { + if (!function_exists('openssl_pkey_get_public') || !function_exists('openssl_verify')) { + throw new \RuntimeException('Для проверки каталога требуется PHP-расширение OpenSSL'); + } + + $envelope = json_decode((string) $raw, true); + if (!is_array($envelope) || json_last_error() !== JSON_ERROR_NONE + || !isset($envelope['format'], $envelope['payload'], $envelope['signature']) + || $envelope['format'] !== self::ENVELOPE_FORMAT) { + throw new \RuntimeException('Удалённый каталог имеет неподдерживаемый формат'); + } + + $payloadRaw = base64_decode((string) $envelope['payload'], true); + $signature = base64_decode((string) $envelope['signature'], true); + if ($payloadRaw === false || $signature === false || strlen($payloadRaw) > self::MAX_CATALOG_BYTES) { + throw new \RuntimeException('Подпись удалённого каталога повреждена'); + } + + $key = openssl_pkey_get_public(self::publicKey($publicKey, true)); + if (!$key) { throw new \RuntimeException('Публичный ключ каталога не читается OpenSSL'); } + $keyDetails = openssl_pkey_get_details($key); + if (!is_array($keyDetails) || !isset($keyDetails['type']) || $keyDetails['type'] !== OPENSSL_KEYTYPE_RSA) { + if (is_resource($key)) { openssl_free_key($key); } + throw new \RuntimeException('Каталог должен использовать публичный RSA-ключ'); + } + + $verified = openssl_verify($payloadRaw, $signature, $key, OPENSSL_ALGO_SHA256); + if (is_resource($key)) { openssl_free_key($key); } + if ($verified !== 1) { throw new \RuntimeException('Цифровая подпись каталога не прошла проверку'); } + + $payload = json_decode($payloadRaw, true); + if (!is_array($payload) || json_last_error() !== JSON_ERROR_NONE + || !isset($payload['format'], $payload['modules']) || $payload['format'] !== self::PAYLOAD_FORMAT + || !is_array($payload['modules'])) { + throw new \RuntimeException('Подписанные данные каталога повреждены'); + } + + $items = array(); + foreach ($payload['modules'] as $module) { + $item = self::normalizeItem($module, $catalogUrl); + if (isset($items[$item['code']])) { throw new \RuntimeException('Каталог содержит повторяющийся code: ' . $item['code']); } + $items[$item['code']] = $item; + } + + uasort($items, array(self::class, 'compareByName')); + + return array( + 'enabled' => true, 'configured' => true, 'verified' => true, 'error' => '', + 'name' => trim(isset($payload['name']) ? (string) $payload['name'] : 'AVE.cms'), + 'channel' => trim(isset($payload['channel']) ? (string) $payload['channel'] : 'stable'), + 'generated_at' => trim(isset($payload['generated_at']) ? (string) $payload['generated_at'] : ''), + 'items' => array_values($items), 'available' => 0, 'updates' => 0, + ); + } + + protected static function withLocalState(array $catalog) + { + $locals = array(); + foreach (NativeModuleAdapter::all() as $local) { $locals[$local['code']] = $local; } + $available = 0; + $updates = 0; + foreach ($catalog['items'] as &$item) { + $local = isset($locals[$item['code']]) ? $locals[$item['code']] : null; + $item['local'] = $local !== null; + $item['installed'] = $local && !empty($local['installed']); + $item['local_version'] = $local ? (string) $local['file_version'] : ''; + $item['database_version'] = $local ? (string) $local['db_version'] : ''; + $item['migration_pending'] = $local && !empty($local['migration_pending']); + $item['status'] = self::localStatus((string) $item['version'], $local); + if ($item['status'] === 'available') { + $available++; + } elseif ($item['status'] === 'update') { + $updates++; + } elseif ($item['status'] === 'local') { + $available++; + } + } + + unset($item); + usort($catalog['items'], array(self::class, 'compareByName')); + $catalog['available'] = $available; + $catalog['updates'] = $updates; + return $catalog; + } + + protected static function compareByName(array $left, array $right) + { + $name = strnatcasecmp((string) $left['name'], (string) $right['name']); + return $name !== 0 ? $name : strnatcasecmp((string) $left['code'], (string) $right['code']); + } + + protected static function localStatus($remoteVersion, array $local = null) + { + if ($local === null) { return 'available'; } + $fileVersion = isset($local['file_version']) ? (string) $local['file_version'] : ''; + if ($fileVersion !== '' && version_compare((string) $remoteVersion, $fileVersion, '>')) { return 'update'; } + if (empty($local['installed'])) { return 'local'; } + if (!empty($local['migration_pending'])) { return 'migration'; } + return 'current'; + } + + protected static function normalizeItem($module, $catalogUrl) + { + if (!is_array($module)) { throw new \RuntimeException('Каталог содержит некорректную запись модуля'); } + $code = strtolower(trim(isset($module['code']) ? (string) $module['code'] : '')); + $version = trim(isset($module['version']) ? (string) $module['version'] : ''); + $checksum = strtolower(trim(isset($module['sha256']) ? (string) $module['sha256'] : '')); + $size = isset($module['size']) ? (int) $module['size'] : 0; + $kind = isset($module['kind']) && $module['kind'] === 'admin' ? 'admin' : 'package'; + if (!preg_match('/^[a-z][a-z0-9_]{0,63}$/', $code) + || !preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9._-]+)?$/', $version) + || !preg_match('/^[a-f0-9]{64}$/', $checksum) + || $size < 1 || $size > ModuleArchiveInstaller::MAX_ARCHIVE_BYTES) { + throw new \RuntimeException('Каталог содержит некорректные метаданные модуля'); + } + + $phpMin = trim(isset($module['php_min']) ? (string) $module['php_min'] : '7.3.0'); + $phpMax = trim(isset($module['php_max']) ? (string) $module['php_max'] : ''); + $aveMin = trim(isset($module['ave_min']) ? (string) $module['ave_min'] : '3.3.0'); + $aveMax = trim(isset($module['ave_max']) ? (string) $module['ave_max'] : ''); + $engineVersion = self::normalizedVersion(defined('APP_VERSION') ? APP_VERSION : '3.3'); + $compatible = version_compare(PHP_VERSION, $phpMin, '>=') + && ($phpMax === '' || version_compare(PHP_VERSION, $phpMax, '<=')) + && version_compare($engineVersion, self::normalizedVersion($aveMin), '>=') + && ($aveMax === '' || version_compare($engineVersion, self::normalizedVersion($aveMax), '<=')); + return array( + 'code' => $code, 'version' => $version, 'kind' => $kind, + 'name' => trim(isset($module['name']) ? (string) $module['name'] : $code) ?: $code, + 'description' => trim(isset($module['description']) ? (string) $module['description'] : ''), + 'author' => trim(isset($module['author']) ? (string) $module['author'] : 'AVE.cms'), + 'archive' => self::archiveUrl(isset($module['archive']) ? $module['archive'] : '', $catalogUrl), + 'sha256' => $checksum, 'size' => $size, + 'php_min' => $phpMin, 'php_max' => $phpMax, 'ave_min' => $aveMin, 'ave_max' => $aveMax, + 'compatible' => $compatible, + 'compatibility_message' => $compatible ? 'Совместим с этой установкой' : 'Модуль несовместим с текущими версиями PHP или AVE.cms', + ); + } + + protected static function download(array $item) + { + $directory = BASEPATH . DS . 'tmp'; + if (!is_dir($directory) && !@mkdir($directory, 0755, true)) { throw new \RuntimeException('Не удалось создать временный каталог'); } + $target = $directory . DS . '.module-remote-' . bin2hex(random_bytes(8)) . '.zip'; + try { + self::request($item['archive'], min(ModuleArchiveInstaller::MAX_ARCHIVE_BYTES, $item['size'] + 1), $target); + if (!is_file($target)) { + throw new \RuntimeException('Сервер каталога не вернул ZIP-пакет модуля «' . $item['name'] . '».'); + } + + $actualSize = (int) filesize($target); + if ($actualSize !== (int) $item['size']) { + throw new \RuntimeException( + 'ZIP модуля «' . $item['name'] . '» загружен на сервер не полностью: ожидалось ' + . (int) $item['size'] . ' байт, получено ' . $actualSize + . '. Повторно загрузите файл пакета и только после проверки обновляйте index.json.' + ); + } + + if (!hash_equals($item['sha256'], hash_file('sha256', $target))) { + throw new \RuntimeException( + 'Контрольная сумма ZIP модуля «' . $item['name'] + . '» не совпадает с подписанным каталогом. Повторно загрузите файл пакета и очистите кеш CDN.' + ); + } + + @chmod($target, 0600); + return $target; + } catch (\Throwable $e) { + if (is_file($target)) { @unlink($target); } + throw $e; + } + } + + protected static function request($url, $maxBytes, $target = '') + { + if (!function_exists('curl_init')) { throw new \RuntimeException('Для удалённого каталога требуется PHP-расширение cURL'); } + $url = self::httpsUrl($url); + $parts = parse_url($url); + $ips = gethostbynamel((string) $parts['host']) ?: array(); + $publicIps = array_values(array_filter($ips, function ($ip) { + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false; + })); + if (!$publicIps) { throw new \RuntimeException('Хост каталога не имеет доступного публичного IPv4-адреса'); } + + $port = isset($parts['port']) ? (int) $parts['port'] : 443; + $handle = curl_init($url); + $options = array( + CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => $target === '' ? 20 : 120, + CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_FAILONERROR => false, + CURLOPT_USERAGENT => 'AVE.cms/' . (defined('APP_VERSION') ? APP_VERSION : '3.3') . ' ModuleRepository', + CURLOPT_HTTPHEADER => array('Accept: ' . ($target === '' ? 'application/json' : 'application/zip')), + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_RESOLVE => array($parts['host'] . ':' . $port . ':' . $publicIps[0]), + ); + $buffer = ''; + $output = null; + $bytes = 0; + if ($target === '') { + $options[CURLOPT_WRITEFUNCTION] = function ($curl, $chunk) use (&$buffer, &$bytes, $maxBytes) { + $length = strlen($chunk); + $bytes += $length; + if ($bytes > $maxBytes) { return 0; } + $buffer .= $chunk; + return $length; + }; + } else { + $output = @fopen($target, 'wb'); + if (!$output) { throw new \RuntimeException('Не удалось создать временный ZIP'); } + $options[CURLOPT_WRITEFUNCTION] = function ($curl, $chunk) use (&$bytes, $maxBytes, $output) { + $length = strlen($chunk); + $bytes += $length; + if ($bytes > $maxBytes) { return 0; } + return fwrite($output, $chunk); + }; + } + + curl_setopt_array($handle, $options); + $result = curl_exec($handle); + $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); + $error = curl_error($handle); + curl_close($handle); + if (is_resource($output)) { fclose($output); } + if ($result === false || $status !== 200) { + if ($target !== '' && is_file($target)) { @unlink($target); } + throw new \RuntimeException($bytes > $maxBytes ? 'Удалённый файл превышает допустимый размер' : ('Сервер каталога недоступен' . ($error !== '' ? ': ' . $error : ''))); + } + + if ($target !== '') { return $target; } + if ($buffer === '' || strlen($buffer) > $maxBytes) { throw new \RuntimeException('Ответ каталога пуст или слишком велик'); } + return $buffer; + } + + protected static function emptyCatalog($reason, $message) + { + return array( + 'enabled' => false, 'configured' => false, 'verified' => false, + 'error' => $reason === 'disabled' ? '' : (string) $message, + 'message' => (string) $message, 'name' => '', 'channel' => '', 'generated_at' => '', + 'items' => array(), 'available' => 0, 'updates' => 0, + ); + } + + protected static function catalogUrl($value, $required) + { + $value = trim((string) $value); + if ($value === '' && !$required) { return ''; } + return self::httpsUrl($value); + } + + protected static function publicKey($value, $required) + { + $value = str_replace("\r\n", "\n", trim((string) $value)); + if ($value === '' && !$required) { return ''; } + if (strlen($value) < 200 || strlen($value) > 10000 || strpos($value, 'BEGIN PUBLIC KEY') === false) { + throw new \InvalidArgumentException('Укажите публичный RSA-ключ каталога в формате PEM'); + } + + return $value . "\n"; + } + + protected static function archiveUrl($value, $catalogUrl) + { + $value = trim((string) $value); + $base = parse_url(self::httpsUrl($catalogUrl)); + if (strpos($value, 'https://') === 0) { + $url = self::httpsUrl($value); + } else { + $path = $value !== '' && $value[0] === '/' ? $value : rtrim(dirname((string) $base['path']), '/') . '/' . ltrim($value, '/'); + $url = 'https://' . $base['host'] . (isset($base['port']) ? ':' . (int) $base['port'] : '') . $path; + $url = self::httpsUrl($url); + } + + $parts = parse_url($url); + $path = isset($parts['path']) ? rawurldecode((string) $parts['path']) : ''; + if (strcasecmp((string) $parts['host'], (string) $base['host']) !== 0 + || (isset($parts['port']) ? (int) $parts['port'] : 443) !== (isset($base['port']) ? (int) $base['port'] : 443) + || preg_match('#(?:^|/)\.\.(?:/|$)#', str_replace('\\', '/', $path))) { + throw new \RuntimeException('ZIP должен находиться на том же хосте, что и каталог'); + } + + return $url; + } + + protected static function httpsUrl($value) + { + $value = trim((string) $value); + $parts = parse_url($value); + if (!is_array($parts) || !isset($parts['scheme'], $parts['host']) || strtolower($parts['scheme']) !== 'https' + || isset($parts['user']) || isset($parts['pass']) || isset($parts['fragment']) + || (isset($parts['port']) && ((int) $parts['port'] < 1 || (int) $parts['port'] > 65535)) + || filter_var($parts['host'], FILTER_VALIDATE_IP) !== false + || in_array(strtolower((string) $parts['host']), array('localhost', 'localhost.localdomain'), true)) { + throw new \InvalidArgumentException('Каталог должен иметь публичный HTTPS URL без логина и фрагмента'); + } + + return $value; + } + + /** Дополняет короткие версии ядра до semver без изменения prerelease-части. */ + protected static function normalizedVersion($value) + { + $value = trim((string) $value); + if (!preg_match('/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(.*)$/', $value, $matches)) { return $value; } + + return $matches[1] . '.' + . (isset($matches[2]) && $matches[2] !== '' ? $matches[2] : '0') . '.' + . (isset($matches[3]) && $matches[3] !== '' ? $matches[3] : '0') + . (isset($matches[4]) ? $matches[4] : ''); + } + } diff --git a/adminx/modules/Modules/NativeModuleAdapter.php b/adminx/modules/Modules/NativeModuleAdapter.php new file mode 100644 index 0000000..e707be2 --- /dev/null +++ b/adminx/modules/Modules/NativeModuleAdapter.php @@ -0,0 +1,213 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Modules; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Adminx\Support\ModuleExtensions; + use App\Common\AdminLocation; + use App\Common\ModuleManager; + use App\Common\ModuleMigrator; + use App\Common\ModuleSettings; + + /** Нативный lifecycle-адаптер manifest-файлов adminx-модулей. */ + class NativeModuleAdapter + { + public static function all() + { + $items = array(); + foreach (ModuleExtensions::catalog() as $extension) { + $items[] = self::normalize($extension); + } + + usort($items, array(self::class, 'compareByName')); + + return $items; + } + + public static function compareByName(array $left, array $right) + { + $name = strnatcasecmp((string) $left['name'], (string) $right['name']); + return $name !== 0 ? $name : strnatcasecmp((string) $left['code'], (string) $right['code']); + } + + public static function errors() + { + $errors = array(); + foreach (array_merge(ModuleManager::errors(), \App\Common\PackageModuleRuntime::errors()) as $message) { + $errors[] = array('code' => 'manifest', 'message' => (string) $message); + } + + return $errors; + } + + public static function stats(array $items) + { + $stats = array('total' => count($items), 'active' => 0, 'inactive' => 0, 'available' => 0, 'updates' => 0, 'errors' => 0); + foreach ($items as $item) { + if (!empty($item['error']) || !empty($item['recoverable'])) { $stats['errors']++; } + if (empty($item['installed'])) { $stats['available']++; } + elseif (!empty($item['enabled'])) { $stats['active']++; } + else { $stats['inactive']++; } + if (!empty($item['needs_update'])) { $stats['updates']++; } + } + + return $stats; + } + + public static function handles($code) + { + foreach (ModuleExtensions::catalog() as $extension) { + if ($extension['code'] === (string) $code && !empty($extension['module']['managed'])) { + return true; + } + } + + return false; + } + + public static function one($code) + { + foreach (ModuleExtensions::catalog() as $extension) { + if ($extension['code'] === (string) $code) { + return self::normalize($extension); + } + } + + throw new \RuntimeException('Нативный модуль не найден'); + } + + public static function install($code) { ModuleManager::install($code); return self::one($code); } + public static function update($code) { ModuleManager::update($code); return self::one($code); } + public static function preflight($code, $operation) { return ModuleManager::preflight($code, $operation); } + public static function reinstall($code, $token = '') { ModuleManager::reinstall($code, $token); return self::one($code); } + public static function repair($code) { ModuleManager::repair($code); return self::one($code); } + public static function uninstall($code, $token = '') { ModuleManager::uninstall($code, $token); return self::one($code); } + public static function remove($code) { return ModuleManager::removePackageFiles($code); } + public static function toggle($code, $enabled) { ModuleManager::toggle($code, $enabled); return self::one($code); } + + public static function savePresentation($code, $headerEnabled, $dashboardEnabled) + { + $item = self::one($code); + if (empty($item['installed'])) { + throw new \RuntimeException('Сначала установите модуль'); + } + + if (!empty($item['has_header'])) { + ModuleSettings::set('adminx_header_enabled', (bool) $headerEnabled, $code, 'bool'); + } + + if (!empty($item['has_dashboard'])) { + ModuleSettings::set('adminx_dashboard_enabled', (bool) $dashboardEnabled, $code, 'bool'); + } + + return self::one($code); + } + + protected static function normalize(array $extension) + { + $module = $extension['module']; + $descriptor = ModuleManager::descriptor($extension['code']); + $installed = !empty($module['installed']); + $fileVersion = isset($module['version']) ? (string) $module['version'] : ''; + $dbVersion = isset($module['db_version']) ? (string) $module['db_version'] : ''; + $extensionConfig = isset($module['admin_extension']) && is_array($module['admin_extension']) + ? $module['admin_extension'] + : array(); + $location = self::location(isset($module['base_path']) ? $module['base_path'] : ''); + $scope = self::scope($location, $extension['code']); + $hasHeader = !empty($extensionConfig['header']); + $hasDashboard = !empty($extensionConfig['dashboard']); + $migrationState = array('pending' => 0); + if ($installed && is_array($descriptor) && !empty($descriptor['migrations'])) { + $migrationState = ModuleMigrator::inspect( + $extension['code'], + $descriptor['migrations'], + isset($descriptor['_base_path']) ? $descriptor['_base_path'] : '' + ); + } + + $migrationPending = !empty($migrationState['pending']); + $needsUpdate = $installed && ( + ($fileVersion !== '' && $fileVersion !== $dbVersion) + || $migrationPending + ); + return array( + 'id' => 0, + 'code' => $extension['code'], + 'folder' => $location['folder'], + 'name' => $extension['name'], + 'description' => $extension['description'], + 'author' => $extension['author'], + 'copyright' => '', + 'installed' => $installed, + 'enabled' => $installed && !empty($module['enabled']), + 'status' => isset($module['status']) ? (string) $module['status'] : 'available', + 'recoverable' => !empty($module['recoverable']), + 'operation' => isset($module['operation']) ? (string) $module['operation'] : '', + 'last_error' => isset($module['last_error']) ? (string) $module['last_error'] : '', + 'missing' => false, + 'error' => '', + 'file_version' => $fileVersion, + 'db_version' => $dbVersion, + 'needs_update' => $needsUpdate, + 'migration_pending' => $migrationPending, + 'template_id' => 0, + 'supports_template' => false, + 'admin_edit' => $extension['admin_url'] !== '', + 'admin_url' => $extension['admin_url'], + 'tag' => '', + 'function' => $extension['feature'], + 'has_sql' => !empty($module['migrations_count']), + 'has_admin' => $extension['admin_url'] !== '', + 'modern_available' => true, + 'managed' => !empty($module['managed']), + 'removable' => is_array($descriptor) && !empty($descriptor['package']['removable']), + 'toggleable' => $installed, + 'native_admin' => true, + 'icon' => $extension['icon'], + 'path' => $location['path'], + 'scope' => $scope, + 'has_header' => $hasHeader, + 'header_enabled' => $hasHeader && (bool) ModuleSettings::get('adminx_header_enabled', true, $extension['code']), + 'has_dashboard' => $hasDashboard, + 'dashboard_enabled' => $hasDashboard && (bool) ModuleSettings::get('adminx_dashboard_enabled', true, $extension['code']), + ); + } + + protected static function location($basePath) + { + $basePath = rtrim(str_replace('\\', '/', (string) $basePath), '/'); + $packageAdmin = basename($basePath) === 'admin' ? dirname($basePath) : ''; + $packagesRoot = str_replace('\\', '/', BASEPATH . DS . 'modules'); + if ($packageAdmin !== '' && dirname($packageAdmin) === $packagesRoot) { + $folder = basename($packageAdmin); + return array('folder' => $folder, 'path' => 'modules/' . $folder, 'package' => true); + } + + $folder = basename($basePath); + return array('folder' => $folder, 'path' => AdminLocation::directory() . '/modules/' . $folder, 'package' => false); + } + + protected static function scope(array $location, $code) + { + $root = rtrim(BASEPATH, '/\\') . DS . 'modules' . DS + . (empty($location['package']) ? (string) $code : $location['folder']); + $hasPublic = is_file($root . DS . 'app' . DS . 'module.php'); + $hasAdmin = empty($location['package']) || is_file($root . DS . 'admin' . DS . 'module.php'); + if ($hasPublic && $hasAdmin) { return 'mixed'; } + return $hasPublic ? 'public' : 'admin'; + } + } diff --git a/adminx/modules/Modules/assets/modules.css b/adminx/modules/Modules/assets/modules.css new file mode 100644 index 0000000..9b9cd35 --- /dev/null +++ b/adminx/modules/Modules/assets/modules.css @@ -0,0 +1,686 @@ +/* Раздел «Модули»: только локальная компоновка поверх AdminKit-примитивов. */ +.modules-panel-header { + --section-accent: var(--violet-500); + --section-accent-soft: var(--violet-100); +} +.modules-stat-active { + background: var(--color-success-soft); + color: var(--color-success); +} +.modules-stat-off { + background: var(--background-inset); + color: var(--text-muted); +} +.modules-stat-update { + background: var(--orange-100); + color: var(--orange-600); +} +.modules-prefix-note { + margin-bottom: 20px; +} +.modules-page-tabs { + margin-bottom: 0; +} +.modules-tab-panel { + display: grid; + gap: 18px; + min-width: 0; + padding-top: 20px; +} +.modules-tab-panel[hidden] { + display: none; +} +.modules-filter { + display: flex; + align-items: center; + gap: 8px; +} +.modules-filter .input-wrap { + width: 250px; +} +.modules-filter .select { + width: 170px; +} +.modules-filter .modules-sort { + width: 156px; +} +.modules-list-tools { + display: flex; + align-items: center; + gap: 10px; +} +.modules-list-tools .badge { + flex: 0 0 auto; +} +.modules-sort { + min-width: 156px; +} +.modules-table-scroll { + max-height: none; +} +.modules-table { + table-layout: fixed; + min-width: 852px; +} +.modules-table td { + vertical-align: middle; +} +.modules-identity { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} +.modules-icon { + width: 40px; + height: 40px; + flex: 0 0 40px; + display: grid; + place-items: center; + color: var(--violet-700); + background: var(--violet-100); + border-radius: var(--radius-sm); + font-size: 19px; +} +.modules-scope-admin .modules-icon { + color: var(--blue-700); + background: var(--blue-100); +} +.modules-scope-public .modules-icon { + color: var(--green-700); + background: var(--green-100); +} +.modules-scope-mixed .modules-icon { + color: var(--violet-700); + background: var(--violet-100); +} +.modules-scope-badge { + min-width: 112px; + justify-content: center; + gap: 5px; + white-space: nowrap; +} +.modules-scope-badge.is-admin { + color: var(--blue-700); + background: var(--blue-100); +} +.modules-scope-badge.is-public { + color: var(--green-700); + background: var(--green-100); +} +.modules-scope-badge.is-mixed { + color: var(--violet-700); + background: var(--violet-100); +} +.modules-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} +.modules-title { + color: var(--text-primary); + font-weight: 650; + line-height: 1.25; + text-wrap: balance; +} +.modules-meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + color: var(--text-muted); + font-size: 12px; +} +.modules-description { + color: var(--text-secondary); + font-size: 12.5px; + line-height: 1.4; + text-wrap: pretty; + max-width: 680px; +} +.modules-error { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--color-danger); + font-size: 12px; +} +.modules-version { + font-variant-numeric: tabular-nums; +} +.modules-version > * { + display: block; +} +.modules-actions { + min-height: 40px; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 4px; + white-space: nowrap; +} +.modules-switch { + margin-right: 4px; + min-height: 40px; + display: inline-flex; + align-items: center; +} +.modules-repository-header { + --section-accent: var(--green-500); + --section-accent-soft: var(--green-100); +} +.section-header-right.modules-repository-tools { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: 8px; + white-space: nowrap; +} +.modules-repository-tools > * { + flex: 0 0 auto; +} +.modules-repository-tools .badge { + min-height: 28px; + gap: 5px; +} +.modules-repository-card { + min-height: 180px; +} +.modules-repository-table { + min-width: 780px; + table-layout: fixed; +} +.modules-repository-table td { + vertical-align: middle; +} +.modules-repository-table .modules-icon { + color: var(--green-700); + background: var(--green-100); +} +.modules-repository-empty { + min-height: 180px; + padding: 28px 20px; +} +.modules-repository-empty p { + max-width: 620px; + margin: 6px auto 14px; + color: var(--text-secondary); + line-height: 1.5; + text-wrap: pretty; +} +.modules-repository-message { + min-height: 180px; + padding: 24px; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 14px; +} +.modules-repository-message .alert { + width: 100%; + margin: 0; +} +.modules-diagnostics { + margin-top: 18px; + background: var(--background-surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); +} +.modules-diagnostics summary { + min-height: 48px; + padding: 0 16px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-weight: 600; +} +.modules-diagnostics-body { + padding: 0 16px 14px; +} +.modules-diagnostics-body > div { + display: grid; + grid-template-columns: 160px 1fr; + gap: 12px; + padding: 8px 0; + color: var(--text-secondary); + font-size: 12.5px; +} +.modules-presentation-drawer, +.modules-archive-drawer, +.modules-repository-drawer { + width: min(620px, 100vw); +} +.modules-presentation-drawer form, +.modules-archive-drawer form, +.modules-repository-drawer form { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} +.modules-presentation-body, +.modules-archive-body, +.modules-repository-body { + display: flex; + flex-direction: column; + gap: 18px; +} +.modules-repository-key { + min-height: 210px; + resize: vertical; + line-height: 1.45; +} +.modules-placement-option { + min-height: 82px; + padding: 16px; + display: grid; + grid-template-columns: 40px minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + background: var(--background-surface); + border-radius: var(--radius-md); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(15, 23, 42, 0.05); +} +.modules-placement-option[hidden] { + display: none; +} +.modules-placement-option > div { + min-width: 0; +} +.modules-placement-option b { + display: block; + color: var(--text-primary); + font-size: 14px; + text-wrap: balance; +} +.modules-placement-option p { + margin: 3px 0 0; + color: var(--text-secondary); + font-size: 12.5px; + line-height: 1.45; + text-wrap: pretty; +} +.modules-placement-option .switch { + min-width: 44px; + min-height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.modules-repository-note { + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + align-items: center; + gap: 12px; + padding: 13px 14px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-muted); +} +.modules-repository-note > div { + min-width: 0; +} +.modules-repository-note b, +.modules-repository-note small { + display: block; +} +.modules-repository-note b { + color: var(--text-primary); + font-size: 13.5px; +} +.modules-repository-note small { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; +} +.modules-repository-official { + grid-template-columns: 40px minmax(0, 1fr) auto; +} +.modules-archive-drop { + min-height: 230px; + padding: 28px 20px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + text-align: center; + cursor: pointer; + color: var(--text-secondary); + background: var(--background-surface); + border: 1px dashed var(--border-strong); + border-radius: var(--radius-md); + transition-property: background-color, border-color, box-shadow, transform; + transition-duration: 160ms; + transition-timing-function: cubic-bezier(0.2, 0, 0, 1); +} +.modules-archive-drop:hover, +.modules-archive-drop.is-dragging { + color: var(--blue-700); + background: var(--blue-50); + border-color: var(--blue-500); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} +.modules-archive-drop.is-dragging { + transform: translateY(-2px); +} +.modules-archive-drop:active { + transform: scale(0.96); +} +.modules-archive-icon { + width: 58px; + height: 58px; + margin-bottom: 4px; + display: grid; + place-items: center; + color: var(--blue-700); + background: var(--blue-100); + border-radius: 14px; + font-size: 27px; +} +.modules-archive-drop strong { + max-width: 100%; + color: var(--text-primary); + font-size: 16px; + overflow-wrap: anywhere; + text-wrap: balance; +} +.modules-archive-drop > span:last-child { + max-width: 360px; + font-size: 12.5px; + line-height: 1.45; + text-wrap: pretty; +} +.modules-archive-format { + display: grid; + gap: 8px; +} +.modules-archive-format > div { + min-height: 64px; + padding: 12px 14px; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + gap: 10px; + align-items: start; + background: var(--background-surface); + border-radius: var(--radius-sm); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05), 0 0 0 1px rgba(15, 23, 42, 0.045); +} +.modules-archive-format > div > i { + margin-top: 1px; + color: var(--blue-600); + font-size: 20px; +} +.modules-archive-format b, +.modules-archive-format small { + display: block; +} +.modules-archive-format b { + color: var(--text-primary); + font-size: 13px; +} +.modules-archive-format small { + margin-top: 2px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; + text-wrap: pretty; +} +.modules-archive-progress { + min-height: 68px; + padding: 14px 16px; + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + gap: 12px; + align-items: center; + color: var(--blue-700); + background: var(--blue-50); + border-radius: var(--radius-sm); + box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.14); +} +.modules-archive-progress[hidden] { + display: none; +} +.modules-archive-progress .spinner { + width: 22px; + height: 22px; + border-width: 3px; +} +.modules-archive-progress b, +.modules-archive-progress small { + display: block; +} +.modules-archive-progress b { + font-size: 13.5px; +} +.modules-archive-progress small { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; +} +.modules-archive-drawer form.is-installing .modules-archive-drop, +.modules-archive-drawer form.is-installing .modules-archive-format { + opacity: 0.48; + pointer-events: none; +} +.modules-archive-drawer form.is-installing [data-close-drawer] { + pointer-events: none; + opacity: 0.5; +} +.modules-actions .btn:active { + transform: scale(0.96); +} +.modules-actions .btn { + transition-property: transform, background-color, color, box-shadow; +} +@media (max-width: 680px) { + .modules-repository-official { + grid-template-columns: 36px minmax(0, 1fr); + } + .modules-repository-official .btn { + grid-column: 1 / -1; + width: 100%; + } + .modules-filter { + width: 100%; + flex-wrap: wrap; + } + .modules-filter .input-wrap { + flex: 1 1 220px; + width: auto; + } + .modules-filter .select { + flex: 1 1 160px; + width: auto; + } + .modules-list-tools { + width: 100%; + justify-content: space-between; + } + .modules-list-tools .modules-sort { + flex: 1 1 auto; + min-width: 0; + } + .modules-table-scroll { + overflow: visible; + } + .table.modules-table { + display: block; + width: 100%; + min-width: 0; + table-layout: auto; + } + .modules-table thead { + display: none; + } + .modules-table tbody { + display: block; + } + .modules-table tr[data-module-row] { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px 12px; + padding: 13px 12px; + border-bottom: 1px solid var(--border-default); + } + .modules-table tr[data-module-row]:last-of-type { + border-bottom: 0; + } + .modules-table tr[data-module-row] > td { + display: block; + width: auto; + padding: 0; + border: 0; + } + .modules-table tr[data-module-row] > td:first-child { + grid-column: 1 / -1; + } + .modules-table tr[data-module-row] > td:nth-child(2) { + grid-column: 1; + align-self: center; + } + .modules-table tr[data-module-row] > td:nth-child(3) { + display: block; + grid-column: 1 / -1; + } + .modules-table tr[data-module-row] > td:nth-child(4) { + grid-column: 2; + align-self: center; + text-align: right; + } + .modules-table tr[data-module-row] > td:nth-child(5) { + grid-column: 1 / -1; + padding-top: 2px; + } + .modules-identity { + gap: 10px; + } + .modules-icon { + width: 36px; + height: 36px; + flex-basis: 36px; + font-size: 17px; + } + .modules-title { + font-size: 14px; + } + .modules-description { + font-size: 12px; + } + .modules-actions { + min-height: 36px; + justify-content: flex-end; + } + .modules-switch { + min-height: 36px; + } + .modules-table tr[data-module-empty] { + display: block; + } + .modules-table tr[data-module-empty] td { + display: block; + } + .modules-repository-header .section-header-right { + width: auto; + } + .modules-repository-tools .badge { + display: none; + } + .modules-repository-card .modules-table-scroll { + overflow: visible; + } + .table.modules-repository-table { + display: block; + width: 100%; + min-width: 0; + table-layout: auto; + } + .modules-repository-table thead { + display: none; + } + .modules-repository-table tbody { + display: block; + } + .modules-repository-table tr[data-repository-row] { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px 12px; + padding: 13px 12px; + border-bottom: 1px solid var(--border-default); + } + .modules-repository-table tr[data-repository-row]:last-child { + border-bottom: 0; + } + .modules-repository-table tr[data-repository-row] > td { + display: block; + width: auto; + padding: 0; + border: 0; + } + .modules-repository-table tr[data-repository-row] > td:first-child { + grid-column: 1 / -1; + } + .modules-repository-table tr[data-repository-row] > td:nth-child(2) { + grid-column: 1 / -1; + } + .modules-repository-table tr[data-repository-row] > td:nth-child(3) { + grid-column: 1; + } + .modules-repository-table tr[data-repository-row] > td:nth-child(4) { + grid-column: 1; + } + .modules-repository-table tr[data-repository-row] > td:nth-child(5) { + grid-column: 2; + grid-row: 3 / 5; + align-self: center; + } +} +[data-theme="dark"] .modules-icon { + background: rgba(139, 92, 246, 0.18); + color: #c4b5fd; +} +[data-theme="dark"] .modules-scope-admin .modules-icon, +[data-theme="dark"] .modules-scope-badge.is-admin { + color: #93c5fd; + background: rgba(37, 99, 235, 0.18); +} +[data-theme="dark"] .modules-scope-public .modules-icon, +[data-theme="dark"] .modules-scope-badge.is-public { + color: #86efac; + background: rgba(22, 163, 74, 0.18); +} +[data-theme="dark"] .modules-scope-mixed .modules-icon, +[data-theme="dark"] .modules-scope-badge.is-mixed { + color: #c4b5fd; + background: rgba(139, 92, 246, 0.18); +} +[data-theme="dark"] .modules-stat-update { + background: rgba(249, 115, 22, 0.18); + color: #fdba74; +} +[data-theme="dark"] .modules-placement-option, +[data-theme="dark"] .modules-archive-format > div { + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.06); +} +[data-theme="dark"] .modules-repository-table .modules-icon { + color: #86efac; + background: rgba(22, 163, 74, 0.18); +} +[data-theme="dark"] .modules-archive-drop:hover, +[data-theme="dark"] .modules-archive-drop.is-dragging { + color: #bfdbfe; + background: rgba(37, 99, 235, 0.13); + border-color: #60a5fa; +} +[data-theme="dark"] .modules-archive-progress { + color: #bfdbfe; + background: rgba(37, 99, 235, 0.13); + box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.24); +} diff --git a/adminx/modules/Modules/assets/modules.js b/adminx/modules/Modules/assets/modules.js new file mode 100644 index 0000000..569ce24 --- /dev/null +++ b/adminx/modules/Modules/assets/modules.js @@ -0,0 +1,435 @@ +/** Реестр модулей: клиентские фильтры и Ajax lifecycle-действия. */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.Modules = { + init: function () { + if (!document.querySelector('[data-modules-page]')) { return; } + var self = this; + var page = document.querySelector('[data-modules-page]'); + var search = document.querySelector('[data-module-search]'); + var state = document.querySelector('[data-module-state]'); + var sort = document.querySelector('[data-module-sort]'); + var repositorySort = document.querySelector('[data-repository-sort]'); + if (search) { search.addEventListener('input', function () { self.filter(); }); } + if (state) { state.addEventListener('change', function () { self.filter(); }); } + if (sort) { sort.addEventListener('change', function () { self.sortRows('[data-module-row]', sort.value); }); } + if (repositorySort) { repositorySort.addEventListener('change', function () { self.sortRows('[data-repository-row]', repositorySort.value); }); } + + document.addEventListener('change', function (event) { + var toggle = event.target.closest('[data-module-toggle]'); + if (toggle) { self.toggle(toggle); return; } + }); + document.addEventListener('click', function (event) { + var refresh = event.target.closest('[data-module-repository-refresh]'); + if (refresh) { self.refreshRepository(refresh); return; } + var official = event.target.closest('[data-module-repository-official]'); + if (official) { self.restoreOfficialRepository(official); return; } + var remoteInstall = event.target.closest('[data-module-repository-install]'); + if (remoteInstall) { self.installRepository(remoteInstall); return; } + var repositoryMigration = event.target.closest('[data-module-repository-migrate]'); + if (repositoryMigration) { self.updateRepositoryMigration(repositoryMigration); return; } + var presentation = event.target.closest('[data-module-presentation]'); + if (presentation) { self.openPresentation(presentation); return; } + var button = event.target.closest('[data-module-action]'); + if (button) { self.action(button); } + }); + var presentationForm = document.querySelector('[data-module-presentation-form]'); + if (presentationForm) { + presentationForm.addEventListener('submit', function (event) { self.savePresentation(event); }); + } + var repositoryForm = document.querySelector('[data-module-repository-form]'); + if (repositoryForm) { + repositoryForm.addEventListener('submit', function (event) { self.saveRepository(event); }); + } + if (new URLSearchParams(window.location.search).get('tab') === 'catalog' && Adminx.Tabs) { + Adminx.Tabs.activate(page, 'catalog'); + } + this.initArchive(); + }, + + base: function () { return Adminx.base(); }, + + filter: function () { + var query = (document.querySelector('[data-module-search]').value || '').trim().toLowerCase(); + var state = document.querySelector('[data-module-state]').value; + var shown = 0; + document.querySelectorAll('[data-module-row]').forEach(function (row) { + var matchQuery = !query || row.dataset.search.indexOf(query) !== -1; + var matchState = state === 'all' || row.dataset.state === state; + row.hidden = !(matchQuery && matchState); + if (!row.hidden) { shown++; } + }); + var empty = document.querySelector('[data-module-empty]'); + if (empty) { empty.hidden = shown !== 0; } + }, + + sortRows: function (selector, direction) { + var rows = Array.prototype.slice.call(document.querySelectorAll(selector)); + if (rows.length < 2) { return; } + var body = rows[0].parentNode; + var empty = body.querySelector('[data-module-empty]'); + rows.sort(function (left, right) { + var result = (left.dataset.name || '').localeCompare(right.dataset.name || '', 'ru', { numeric: true, sensitivity: 'base' }); + return direction === 'desc' ? -result : result; + }); + rows.forEach(function (row) { empty ? body.insertBefore(row, empty) : body.appendChild(row); }); + }, + + post: function (url, formData) { + Adminx.Loader.show(); + return Adminx.Ajax.post(url, formData || new FormData()).then(function (payload) { + Adminx.Loader.hide(); + return payload.data || {}; + }).catch(function (error) { + Adminx.Loader.hide(); + Adminx.Toast.show(error && error.message ? error.message : 'Ошибка запроса', 'error'); + return { success: false }; + }); + }, + + toggle: function (input) { + var self = this; + var row = input.closest('[data-module-row]'); + var enabled = input.checked; + input.disabled = true; + var data = new FormData(); + data.append('enabled', enabled ? '1' : '0'); + this.post(this.base() + '/modules/lifecycle/' + encodeURIComponent(row.dataset.code) + '/toggle', data).then(function (result) { + input.disabled = false; + if (!result.success) { + input.checked = !enabled; + Adminx.Toast.show(result.message || 'Не удалось изменить состояние модуля', 'error'); + return; + } + Adminx.Toast.show(result.message || (enabled ? 'Модуль включён' : 'Модуль отключён'), 'success'); + self.reloadSoon(); + }); + }, + + action: function (button) { + var self = this; + var row = button.closest('[data-module-row]'); + var code = row.dataset.code; + var action = button.dataset.moduleAction; + var config = { + install: { title: 'Установить модуль?', message: 'Будут выполнены SQL-команды установки и модуль будет включён.', label: 'Установить', kind: 'info' }, + update: { title: 'Обновить модуль?', message: 'Будут выполнены SQL-команды обновления до версии из файлов.', label: 'Обновить', kind: 'warning' }, + repair: { title: 'Восстановить операцию модуля?', message: 'Успешные миграции будут пропущены, а оборванный этап выполнится повторно. Перед продолжением проверьте указанную ошибку.', label: 'Восстановить', kind: 'warning' }, + reinstall: { title: 'Переустановить модуль?', message: 'Данные и настройки будут удалены, затем модуль установится заново. Это действие необратимо.', label: 'Переустановить', kind: 'error' }, + uninstall: { title: 'Деинсталлировать модуль?', message: 'Модуль, его данные, настройки, меню и права будут удалены. Это действие необратимо.', label: 'Деинсталлировать', kind: 'error' }, + remove: { title: 'Удалить файлы модуля?', message: 'Каталог пакета будет физически удалён. Для повторной установки понадобится ZIP-архив.', label: 'Удалить файлы', kind: 'error' } + }[action]; + if (!config) { return; } + + if (action === 'uninstall' || action === 'reinstall') { + button.disabled = true; + this.post(this.base() + '/modules/lifecycle/' + encodeURIComponent(code) + '/preflight/' + action, new FormData()).then(function (result) { + button.disabled = false; + if (!result.success) { return; } + var preflight = result.data || {}; + self.confirmAction(button, code, action, config, preflight); + }); + return; + } + + this.confirmAction(button, code, action, config, null); + }, + + confirmAction: function (button, code, action, config, preflight) { + var self = this; + var message = this.preflightMessage(config.message, preflight && preflight.preview ? preflight.preview : {}); + Adminx.Confirm.open({ + kind: config.kind, + title: config.title, + message: message, + confirmLabel: config.label, + confirmClass: config.kind === 'error' ? 'btn-danger' : 'btn-primary', + onConfirm: function () { + button.disabled = true; + var data = new FormData(); + if (preflight && preflight.required && preflight.token) { + data.append('preflight_token', preflight.token); + } + self.post(self.base() + '/modules/lifecycle/' + encodeURIComponent(code) + '/' + action, data).then(function (result) { + button.disabled = false; + Adminx.Toast.show(result.message || (result.success ? 'Готово' : 'Операция не выполнена'), result.success ? 'success' : 'error'); + if (result.success) { self.reloadSoon(); } + }); + } + }); + }, + + preflightMessage: function (fallback, preview) { + var parts = []; + if (preview && preview.message) { parts.push(String(preview.message)); } + var summary = preview && Array.isArray(preview.summary) ? preview.summary : []; + summary.forEach(function (item) { + if (!item || !item.label) { return; } + parts.push(String(item.label) + ': ' + String(item.count == null ? 0 : item.count)); + }); + return parts.length ? parts.join('. ') + '.' : fallback; + }, + + updateRepositoryMigration: function (button) { + var self = this; + var row = button.closest('[data-repository-row]'); + if (!row || button.disabled) { return; } + var code = row.dataset.code || ''; + var name = button.dataset.moduleName || code; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Применить миграции «' + name + '»?', + message: 'Файлы модуля уже актуальны. AVE.cms выполнит ещё не применённые миграции и синхронизирует версию БД.', + confirmLabel: 'Применить', + confirmClass: 'btn-primary', + onConfirm: function () { + button.disabled = true; + button.classList.add('is-loading'); + self.post(self.base() + '/modules/lifecycle/' + encodeURIComponent(code) + '/update', new FormData()).then(function (result) { + button.disabled = false; + button.classList.remove('is-loading'); + Adminx.Toast.show(result.message || (result.success ? 'Миграции применены' : 'Не удалось применить миграции'), result.success ? 'success' : 'error'); + if (result.success) { window.setTimeout(function () { window.location.href = self.base() + '/modules?tab=catalog'; }, 350); } + }); + } + }); + }, + + openPresentation: function (button) { + var row = button.closest('[data-module-row]'); + var drawer = document.getElementById('modulePresentationDrawer'); + if (!row || !drawer) { return; } + var form = drawer.querySelector('[data-module-presentation-form]'); + form.dataset.code = row.dataset.code || ''; + drawer.querySelector('[data-module-presentation-title]').textContent = button.dataset.moduleName || 'Размещение модуля'; + drawer.querySelector('[data-module-presentation-code]').textContent = row.dataset.code || ''; + + var headerOption = drawer.querySelector('[data-module-header-option]'); + var dashboardOption = drawer.querySelector('[data-module-dashboard-option]'); + headerOption.hidden = button.dataset.hasHeader !== '1'; + dashboardOption.hidden = button.dataset.hasDashboard !== '1'; + headerOption.querySelector('input').checked = button.dataset.headerEnabled === '1'; + dashboardOption.querySelector('input').checked = button.dataset.dashboardEnabled === '1'; + Adminx.Drawer.open('modulePresentationDrawer'); + }, + + savePresentation: function (event) { + event.preventDefault(); + var self = this; + var form = event.currentTarget; + var code = form.dataset.code || ''; + if (!code) { return; } + var submit = form.querySelector('[type="submit"]'); + var data = new FormData(form); + data.set('header_enabled', form.elements.header_enabled.checked ? '1' : '0'); + data.set('dashboard_enabled', form.elements.dashboard_enabled.checked ? '1' : '0'); + submit.disabled = true; + this.post(this.base() + '/modules/lifecycle/' + encodeURIComponent(code) + '/presentation', data).then(function (result) { + submit.disabled = false; + if (!result.success) { + Adminx.Toast.show(result.message || 'Не удалось сохранить размещение', 'error'); + return; + } + Adminx.Toast.show(result.message || 'Размещение сохранено', 'success'); + Adminx.Drawer.close('modulePresentationDrawer'); + self.reloadSoon(); + }); + }, + + saveRepository: function (event) { + event.preventDefault(); + var self = this; + var form = event.currentTarget; + var submit = form.querySelector('[type="submit"]'); + var data = new FormData(form); + data.set('enabled', form.elements.enabled.checked ? '1' : '0'); + submit.disabled = true; + this.post(this.base() + '/modules/repository/settings', data).then(function (result) { + submit.disabled = false; + if (!result.success) { + Adminx.Toast.show(result.message || 'Не удалось сохранить источник', 'error'); + return; + } + Adminx.Toast.show(result.message || 'Источник модулей сохранён', 'success'); + Adminx.Drawer.close('moduleRepositoryDrawer'); + self.reloadSoon(); + }); + }, + + restoreOfficialRepository: function (button) { + var self = this; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Восстановить официальный каталог?', + message: 'Текущий URL и публичный ключ модулей будут заменены данными из доверенного профиля сборки.', + confirmLabel: 'Восстановить', + confirmClass: 'btn-primary', + onConfirm: function () { + button.disabled = true; + self.post(self.base() + '/modules/repository/settings/official', new FormData()).then(function (result) { + button.disabled = false; + Adminx.Toast.show(result.message || (result.success ? 'Официальный каталог восстановлен' : 'Не удалось восстановить каталог'), result.success ? 'success' : 'error'); + if (result.success) { self.reloadSoon(); } + }); + } + }); + }, + + refreshRepository: function (button) { + var self = this; + if (button.disabled) { return; } + button.disabled = true; + button.classList.add('is-loading'); + this.post(this.base() + '/modules/repository/refresh', new FormData()).then(function (result) { + button.disabled = false; + button.classList.remove('is-loading'); + if (!result.success) { + Adminx.Toast.show(result.message || 'Не удалось обновить каталог', 'error'); + return; + } + Adminx.Toast.show(result.message || 'Каталог обновлён', 'success'); + self.reloadSoon(); + }); + }, + + installRepository: function (button) { + var self = this; + var row = button.closest('[data-repository-row]'); + if (!row || button.disabled) { return; } + var code = row.dataset.code || ''; + var name = button.dataset.moduleName || code; + var updating = button.dataset.moduleOperation === 'update'; + Adminx.Confirm.open({ + kind: updating ? 'warning' : 'info', + title: (updating ? 'Обновить «' : 'Установить «') + name + '»?', + message: updating + ? 'AVE.cms скачает подписанный ZIP, проверит контрольные суммы и безопасно заменит файлы модуля. Если новой версии нужны миграции, они появятся отдельным следующим действием.' + : 'AVE.cms скачает подписанный ZIP, проверит контрольные суммы, добавит файлы и выполнит миграции модуля.', + confirmLabel: updating ? 'Обновить файлы' : 'Установить', + confirmClass: 'btn-primary', + onConfirm: function () { + button.disabled = true; + button.classList.add('is-loading'); + self.post(self.base() + '/modules/repository/' + encodeURIComponent(code) + '/install', new FormData()).then(function (result) { + button.disabled = false; + button.classList.remove('is-loading'); + if (!result.success) { + Adminx.Toast.show(result.message || (updating ? 'Не удалось обновить модуль' : 'Не удалось установить модуль'), 'error'); + return; + } + Adminx.Toast.show(result.message || (updating ? 'Файлы модуля обновлены' : 'Модуль установлен'), 'success'); + window.setTimeout(function () { window.location.href = result.redirect || (Adminx.base() + '/modules'); }, 350); + }); + } + }); + }, + + initArchive: function () { + var self = this; + var form = document.querySelector('[data-module-archive-form]'); + if (!form) { return; } + var drop = form.querySelector('[data-module-archive-drop]'); + var input = form.querySelector('[data-module-archive-input]'); + var dragDepth = 0; + + input.addEventListener('change', function () { self.selectArchive(form, input.files && input.files[0]); }); + drop.addEventListener('dragenter', function (event) { + event.preventDefault(); + dragDepth++; + drop.classList.add('is-dragging'); + }); + drop.addEventListener('dragover', function (event) { event.preventDefault(); }); + drop.addEventListener('dragleave', function (event) { + event.preventDefault(); + dragDepth = Math.max(0, dragDepth - 1); + if (!dragDepth) { drop.classList.remove('is-dragging'); } + }); + drop.addEventListener('drop', function (event) { + event.preventDefault(); + dragDepth = 0; + drop.classList.remove('is-dragging'); + self.selectArchive(form, event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files[0] : null); + }); + form.addEventListener('submit', function (event) { self.installArchive(event); }); + }, + + selectArchive: function (form, file) { + var title = form.querySelector('[data-module-archive-title]'); + var meta = form.querySelector('[data-module-archive-meta]'); + var submit = form.querySelector('[data-module-archive-submit]'); + form._moduleArchive = null; + if (!file) { + title.textContent = 'Перетащите ZIP сюда'; + meta.textContent = 'или нажмите, чтобы выбрать файл до 25 МБ'; + submit.disabled = true; + return; + } + if (!/\.zip$/i.test(file.name || '')) { + Adminx.Toast.show('Выберите ZIP-архив модуля', 'error'); + this.selectArchive(form, null); + return; + } + if (file.size > 25 * 1024 * 1024) { + Adminx.Toast.show('ZIP-архив превышает 25 МБ', 'error'); + this.selectArchive(form, null); + return; + } + form._moduleArchive = file; + title.textContent = file.name; + meta.textContent = this.fileSize(file.size) + ' · готов к проверке и установке'; + submit.disabled = false; + }, + + installArchive: function (event) { + event.preventDefault(); + var form = event.currentTarget; + if (form.getAttribute('aria-busy') === 'true') { return; } + var file = form._moduleArchive || (form.elements.module_archive.files && form.elements.module_archive.files[0]); + if (!file) { this.selectArchive(form, null); return; } + var submit = form.querySelector('[data-module-archive-submit]'); + var progress = form.querySelector('[data-module-archive-progress]'); + var progressTitle = form.querySelector('[data-module-archive-progress-title]'); + var data = new FormData(); + data.append('module_archive', file, file.name); + form.setAttribute('aria-busy', 'true'); + form.classList.add('is-installing'); + submit.disabled = true; + submit.classList.add('is-loading'); + if (progress) { progress.hidden = false; } + if (progressTitle) { progressTitle.textContent = 'Устанавливаем ' + file.name; } + this.post(this.base() + '/modules/archive/install', data).then(function (result) { + if (!result.success) { + form.removeAttribute('aria-busy'); + form.classList.remove('is-installing'); + submit.disabled = false; + submit.classList.remove('is-loading'); + if (progress) { progress.hidden = true; } + Adminx.Toast.show(result.message || 'Не удалось установить модуль', 'error'); + return; + } + if (progressTitle) { progressTitle.textContent = 'Модуль установлен'; } + Adminx.Toast.show(result.message || 'Модуль установлен', 'success'); + window.setTimeout(function () { window.location.href = result.redirect || (Adminx.base() + '/modules'); }, 350); + }); + }, + + fileSize: function (bytes) { + if (bytes < 1024) { return bytes + ' Б'; } + if (bytes < 1024 * 1024) { return Math.round(bytes / 1024) + ' КБ'; } + return (bytes / 1024 / 1024).toFixed(1).replace('.0', '') + ' МБ'; + }, + + reloadSoon: function () { + window.setTimeout(function () { window.location.reload(); }, 350); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.Modules.init(); }); + } else { + Adminx.Modules.init(); + } +})(window, document); diff --git a/adminx/modules/Modules/language/en/client.xml b/adminx/modules/Modules/language/en/client.xml new file mode 100644 index 0000000..385661a --- /dev/null +++ b/adminx/modules/Modules/language/en/client.xml @@ -0,0 +1,70 @@ + + + The module, its data, settings, menus and rights will be deleted. This action is irreversible. + Restore module operation? + Successful migrations will be skipped and the aborted step will be re-executed. Please check the given error before proceeding. + Install + Apply migrations " + Module source saved + Source of modules + Module disabled + or click to select file up to 25 MB + Migrations applied + Catalog updated + Failed to apply migrations + Ready for testing and installation + Restore + Operation failed + ZIP archive exceeds 25 MB + Done + settings + The installation SQL commands will be executed and the module will be enabled. + AVE.cms will download the signed ZIP, check the checksums and safely replace the module files. If the new version requires migrations, they will appear as a separate next step. + Module files updated + Module installed + Update files + Delete files + Module placement + Failed to restore directory + Placement saved + Reinstall the module? + The package directory will be physically deleted. To reinstall, you will need a ZIP archive. + Data and settings will be deleted, then the module will be installed again. This action is irreversible. + KB + MB + Apply + Update " + AVE.cms will download the signed ZIP, check checksums, add files and perform module migrations. + The official catalog has been restored + Install a module? + The modules' current URL and public key will be replaced with data from the trusted build profile. + Failed to change module state + B + Request Error + The module files are already up to date. AVE.cms will perform migrations that have not yet been applied and synchronize the database version. + Module enabled + Uninstall + Migrations + Failed to install module + Update the module? + Module update failed + Restore the official catalog? + Update + Apply migrations + Reinstall + Install + Module + Select module ZIP archive + Failed to save source + The SQL commands for updating to the version from the files will be executed. + Failed to save placement + Drag the ZIP here + Failed to update directory + Install " + Delete module files? + Uninstall the module? + Module installed + Select module ZIP archive + Module disabled + Module enabled + diff --git a/adminx/modules/Modules/language/en/interface.xml b/adminx/modules/Modules/language/en/interface.xml new file mode 100644 index 0000000..df59794 --- /dev/null +++ b/adminx/modules/Modules/language/en/interface.xml @@ -0,0 +1,233 @@ + + + Directory URL + {{ module.enabled ? 'Disable' : 'Enable' }} + · updated + Admin + Active + Active + DB: + No rewrite + Official is fundamentally required + There are no modules in the published catalog yet + Versions, connection status and available system actions. + File version + Widget on the main page + Visibility for users + Enable + Include directory + Recovery + Display the module block on the Dashboard after the system summary. + Double Packet Inspection + Uninstall + Action in the header + File diagnostics + Available + Update available + Loading the archive + or click to select file up to 25 MB + Use remote directory + Source of modules + Channel + Catalog unavailable + The directory is signed with RSA-SHA256, then the downloaded ZIP is checked for size and SHA-256, and the standard installer checks the manifest of each file. + Migrations + Update + Update module + Updates required + No modules found + Module + Name, code, function + Title: A–Z + Title: I–A + Found + Set up source + settings + Migrations not applied + Not installed + Not installed + Incompatible + New native module from ZIP archive + Only needed to verify the signature. The private key is stored by the publisher and is never loaded into AVE.cms. + Need migrations + Region + Update catalog + Disabled + Disable + Disabled + Public key: + Open settings + Official source + Public + admin panel + Drag the ZIP here + Reinstall + No results found by filter + Repeat the interrupted operation + Connect your HTTPS source of signed modules. Installation from a local ZIP continues to work regardless. + Signed packages available for this AVE.cms installation. + Signed system owner directory + Signature verified + Show in header + Show a button or dropdown menu declared by a module. + Show on main page + Show published packages and allow installation after signature verification. + The module's rights remain in effect. These settings only remove quick elements from the header and home page. + Previous operation not completed + Apply migrations + Apply migrations from local files + Check settings + We check the composition, install files and apply migrations. Do not cover the panel. + Public + Public RSA key + Placement in the interface + Module placement + Site extensions + Module registry + The module registry is not available + with SHA-256 of all files. + With errors + Download and update files + Download, check and install + Sorting the catalog by name + Sort by name + manifest status, versions and available lifecycle actions. + Archive structure + The existing folder or already registered code is not replaced. + Only trusted code + Public HTTPS address only. ZIP packages must be on the same host. + Requires restoration + Delete files + Remote directory disabled + Install + Install ZIP + The installation runs PHP manifest, migrations and module lifecycle handlers. + Installing the module + Installed + Installed + Installed and available modules + Installed site modules. + . Re-upload the package file and only update index.json after checking. + AVE.cms will download the signed ZIP, check the checksums and safely replace the module files. If the new version requires migrations, they will appear as a separate next step. + AVE.cms will download the signed ZIP, check checksums, add files and perform module migrations. + Subscribed Directory URL + ZIP module " + ZIP archive exceeds 25 MB + The ZIP package will add new executable code to the system. + Ready for testing and installation + » not fully loaded to the server: expected + " does not match the signed directory. Re-download the package file and clear the CDN cache. + » installed + B + byte received + The SQL commands for updating to the version from the files will be executed. + The installation SQL commands will be executed and the module will be enabled. + The version of the downloaded package does not match the remote directory entry + Restore + Restore module operation? + Restore the official catalog? + Recovery will re-execute the unfinished installation step. + Select module ZIP archive + Done + Data and settings will be deleted, then the module will be installed again. This action is irreversible. + Uninstall the module? + Uninstallation will execute the uninstall code and destroy the module data. + The OpenSSL PHP extension is required to check the directory + The remote directory requires the cURL PHP extension + ZIP download, installation, placement configuration, updating, enabling and uninstalling modules. + Download and execute official AVE.cms ZIP packages. + Module source saved + The directory must use a public RSA key + The modules catalog has been updated + Catalog updated + The package directory will be physically deleted. To reinstall, you will need a ZIP archive. + The directory contains an incorrect module entry + The directory contains incorrect module metadata + The directory contains duplicate code: + KB + The downloaded package code does not match the remote directory entry + ZIP module checksum " + MB + Module migrations applied + Migrations applied + Modules + Modules: view + Modules: control + Modules: setting code + Module " + Module enabled + Module uninstalled + The module is not compatible with current versions of PHP or AVE.cms + Module disabled + The module is not in the verified directory + Module reinstalled + Module installed + The module, its data, settings, menus and rights will be deleted. This action is irreversible. + The source setting determines where the system gets executable code from. + Directory settings saved + Native module not found + Failed to restore directory + Failed to change module state + Failed to update directory + Module update failed + Failed to transfer module files + Failed to back up module files + Failed to apply migrations + Failed to create temporary ZIP + Failed to create temporary directory + Failed to create temporary installation directory + Failed to save source + Failed to save placement + Failed to install module + Update + Update " + Update the module? + Update files + The update will perform migrations and package code. + updated from catalog + Module operation restored + Operation failed + Directory response is empty or too large + The official catalog has been restored + The official module catalog has been restored + The official profile will replace the current URL and public key of the module directory. + Request Error + Reinstall the module? + Reinstalling will remove the data and re-execute the package code. + Signed directory data is corrupted + The signed ZIP will be downloaded, verified and installed along with the migrations. + The remote directory signature is corrupted + Preliminary check completed + Apply + Apply migrations " + View installed and available legacy modules. + Directory public key + Directory public key is not readable by OpenSSL + Module placement saved + Placement saved + The directory server did not return the module zip package " + Directory server unavailable + System + Install the module first + Compatible with this installation + The modules' current URL and public key will be replaced with data from the trusted build profile. + Delete module files? + The remote directory is in an unsupported format + Remote module directory + The deleted file exceeds the allowed size + Specify the directory URL and public key + Specify the directory's public RSA key in PEM format + Management + Successful migrations will be skipped and the aborted step will be re-executed. Please check the given error before proceeding. + Install + Install " + Install a module? + The installation will perform migrations and package code. + installed from directory + The module's executable code files will be physically deleted. + Module files updated + Module files deleted + The module files are already up to date. AVE.cms will perform migrations that have not yet been applied and synchronize the database version. + The directory host does not have an available public IPv4 address + The digital signature of the catalog has not been verified + This version of the module is already on the server + diff --git a/adminx/modules/Modules/language/en/runtime.xml b/adminx/modules/Modules/language/en/runtime.xml new file mode 100644 index 0000000..c9d8c15 --- /dev/null +++ b/adminx/modules/Modules/language/en/runtime.xml @@ -0,0 +1,161 @@ + + + (code + . Re-upload the package file and only update index.json after checking. + AVE-MODULE.json does not contain a valid file list + AVE-MODULE.json contains an incorrect nested dependency + AVE-MODULE.json contains incorrect code, folder or version + AVE-MODULE.json contains incorrect JSON + AVE-MODULE.json contains an incorrect file or checksum + Checksum file " + The code in module.php does not match the official manifest of the package + Manifest v2 does not contain a valid dependency list + The package manifest does not include the main module.php + Subscribed Directory URL + The ZIP must be on the same host as the directory + The ZIP must contain one module.php or a package with admin/module.php + ZIP module " + ZIP contains absolute path + ZIP contains an unsafe path + ZIP contains an invalid file name + ZIP contains duplicate path " + ZIP contains too many files and directories + ZIP contains a file with a suspicious compression ratio + ZIP archive is empty + The ZIP package will add new executable code to the system. + adopt_existing is prohibited for installing a new module from a ZIP + module.php should return a descriptor with the module code + » not fully loaded to the server: expected + » does not match + " does not match the signed directory. Re-download the package file and clear the CDN cache. + » not in the official manifest + » not fully unpacked + » already exists + » installed + The archive was not fully loaded + The archive does not contain admin_extension for the new admin panel + The archive does not contain a valid AVE-MODULE.json + The archive exceeds the permissible size + The archive exceeds the server download limit + The archive module must declare lifecycle.managed = true + The package is missing some files from the official manifest + Module.php version does not match the official package manifest + The version of the downloaded package does not match the remote directory entry + Nested dependencies require format ave-module-package-v2 + Recovery will re-execute the unfinished installation step. + Select module ZIP archive + Uninstallation will execute the uninstall code and destroy the module data. + The OpenSSL PHP extension is required to check the directory + The remote directory requires the cURL PHP extension + Installation of modules requires PHP zip extension + Uploaded file not found + ZIP download, installation, placement configuration, updating, enabling and uninstalling modules. + Download and execute official AVE.cms ZIP packages. + The folder name of the composite package must match its code + The temporary files directory is not writable: + The directory must have a public HTTPS URL without login and fragment + The directory must use a public RSA key + The modules directory is not writable: + The modules catalog has been updated + The directory contains an incorrect module entry + The directory contains incorrect module metadata + The directory contains duplicate code: + The downloaded package code does not match the remote directory entry + ZIP module checksum " + The local ZIP copy was not completely saved + The local download directory is not writable: + The local module does not match the package being updated + Module migrations applied + Modules + Modules: view + Modules: control + Modules: setting code + Module " + Module enabled + Module uninstalled + The module is not compatible with current versions of PHP or AVE.cms + Module disabled + The module is not in the verified directory + Module reinstalled + Module with code " + Module installed + The source setting determines where the system gets executable code from. + Directory settings saved + Native module not found + Failed to open ZIP: + Failed to transfer ZIP from server temporary directory + Failed to transfer module files + Failed to back up module files + Failed to read ZIP structure + Failed to unpack file " + Failed to create temporary ZIP + Failed to create temporary directory + Failed to create temporary installation directory + Failed to create installation lock directory: + Failed to create directory from ZIP + Failed to create local download directory: + Failed to create module structure + Incorrect code in module.php + Package circular dependency detected " + The update will perform migrations and package code. + Module operation restored + Directory response is empty or too large + The official module catalog has been restored + The official profile will replace the current URL and public key of the module directory. + Error loading archive + The package contains an invalid file type + Module folder " + The composite package folder in AVE-MODULE.json must match code + Reinstalling will remove the data and re-execute the package code. + Only ZIP archives are supported + Only official AVE.cms packages of v1 and v2 formats are supported + Signed directory data is corrupted + The signed ZIP will be downloaded, verified and installed along with the migrations. + The remote directory signature is corrupted + Preliminary check completed + View installed and available legacy modules. + Directory public key + Directory public key is not readable by OpenSSL + ZIP size must be no more than 25 MB + Module placement saved + Unpacked module exceeds safe limits + The directory server did not return the module zip package " + Directory server unavailable + Symbolic links in the archive are prohibited + System + The downloaded ZIP is not a valid size or format + Install the module first + Compatible with this installation + Module type does not match AVE-MODULE.json + The remote ZIP is located outside the protected temporary directory + Remote directory disabled + The remote directory is in an unsupported format + Remote module directory + The remote directory passed an incorrect module + The deleted file exceeds the allowed size + Specify the directory URL and public key + Specify the directory's public RSA key in PEM format + Management + The installation will perform migrations and package code. + Another module is already being installed. Please try again later + File " + The module's executable code files will be physically deleted. + Module files deleted + The directory host does not have an available public IPv4 address + The digital signature of the catalog has not been verified + This version of the module is already on the server + byte received + local copy of archive not found + unsupported format or ZIP error + updated from catalog + invalid argument passed + archive structure damaged + the server does not support positioning in the archive file + the server could not open the local copy of the archive + the server could not read the archive + the server ran out of memory + already running + installed from directory + the file is not a ZIP archive + the file already exists + diff --git a/adminx/modules/Modules/language/ru/client.xml b/adminx/modules/Modules/language/ru/client.xml new file mode 100644 index 0000000..b925dc0 --- /dev/null +++ b/adminx/modules/Modules/language/ru/client.xml @@ -0,0 +1,70 @@ + + + Модуль, его данные, настройки, меню и права будут удалены. Это действие необратимо. + Восстановить операцию модуля? + Успешные миграции будут пропущены, а оборванный этап выполнится повторно. Перед продолжением проверьте указанную ошибку. + Устанавливаем + Применить миграции « + Источник модулей сохранён + Источник модулей + Модуль отключён + или нажмите, чтобы выбрать файл до 25 МБ + Миграции применены + Каталог обновлён + Не удалось применить миграции + · готов к проверке и установке + Восстановить + Операция не выполнена + ZIP-архив превышает 25 МБ + Готово + настройки + Будут выполнены SQL-команды установки и модуль будет включён. + AVE.cms скачает подписанный ZIP, проверит контрольные суммы и безопасно заменит файлы модуля. Если новой версии нужны миграции, они появятся отдельным следующим действием. + Файлы модуля обновлены + Модуль установлен + Обновить файлы + Удалить файлы + Размещение модуля + Не удалось восстановить каталог + Размещение сохранено + Переустановить модуль? + Каталог пакета будет физически удалён. Для повторной установки понадобится ZIP-архив. + Данные и настройки будут удалены, затем модуль установится заново. Это действие необратимо. + КБ + МБ + Применить + Обновить « + AVE.cms скачает подписанный ZIP, проверит контрольные суммы, добавит файлы и выполнит миграции модуля. + Официальный каталог восстановлен + Установить модуль? + Текущий URL и публичный ключ модулей будут заменены данными из доверенного профиля сборки. + Не удалось изменить состояние модуля + Б + Ошибка запроса + Файлы модуля уже актуальны. AVE.cms выполнит ещё не применённые миграции и синхронизирует версию БД. + Модуль включён + Деинсталлировать + Миграции + Не удалось установить модуль + Обновить модуль? + Не удалось обновить модуль + Восстановить официальный каталог? + Обновить + Применить миграции + Переустановить + Установить + Модуль + Выберите ZIP-архив модуля + Не удалось сохранить источник + Будут выполнены SQL-команды обновления до версии из файлов. + Не удалось сохранить размещение + Перетащите ZIP сюда + Не удалось обновить каталог + Установить « + Удалить файлы модуля? + Деинсталлировать модуль? + Модуль установлен + Выберите ZIP-архив модуля + Модуль отключён + Модуль включён + diff --git a/adminx/modules/Modules/language/ru/interface.xml b/adminx/modules/Modules/language/ru/interface.xml new file mode 100644 index 0000000..445f22f --- /dev/null +++ b/adminx/modules/Modules/language/ru/interface.xml @@ -0,0 +1,233 @@ + + + URL каталога + {{ module.enabled ? 'Отключить' : 'Включить' }} + · обновлён + Админка + Активен + Активны + БД: + Без перезаписи + В корне обязателен официальный + В опубликованном каталоге пока нет модулей + Версии, состояние подключения и доступные системные действия. + Версия файлов + Виджет на главной + Видимость для пользователей + Включить + Включить каталог + Восстановление + Выводить блок модуля на Dashboard после системной сводки. + Двойная проверка пакета + Деинсталлировать + Действие в шапке + Диагностика файлов + Доступен + Доступно обновление + Загружаем архив + или нажмите, чтобы выбрать файл до 25 МБ + Использовать удалённый каталог + Источник модулей + Канал + Каталог недоступен + Каталог подписан RSA-SHA256, затем скачанный ZIP сверяется по размеру и SHA-256, а штатный установщик проверяет manifest каждого файла. + Миграции + Обновление + Обновить модуль + Требуют обновления + Модули не найдены + Модуль + Название, код, функция + Название: А–Я + Название: Я–А + Найдено + Настроить источник + настройки + Не применены миграции + Не установлен + Не установлены + Несовместим + Новый нативный модуль из ZIP-архива + Нужен только для проверки подписи. Закрытый ключ хранится у издателя и никогда не загружается в AVE.cms. + Нужны миграции + Область + Обновить каталог + Отключены + Отключить + Отключён + Открытый ключ: + Открыть настройки + Официальный источник + Паблик + админка + Перетащите ZIP сюда + Переустановить + По фильтру ничего не найдено + Повторить оборванную операцию + Подключите свой HTTPS-источник подписанных модулей. Установка из локального ZIP продолжает работать независимо. + Подписанные пакеты, доступные для этой установки AVE.cms. + Подписанный каталог владельца системы + Подпись проверена + Показывать в шапке + Показывать кнопку или выпадающее меню, объявленное модулем. + Показывать на главной + Показывать опубликованные пакеты и разрешить их установку после проверки подписи. + Права модуля продолжают действовать. Эти настройки только убирают быстрые элементы из шапки и главной страницы. + Предыдущая операция не завершена + Применить миграции + Применить миграции из локальных файлов + Проверить настройки + Проверяем состав, устанавливаем файлы и применяем миграции. Не закрывайте панель. + Публичный + Публичный RSA-ключ + Размещение в интерфейсе + Размещение модуля + Расширения сайта + Реестр модулей + Реестр модулей недоступен + с SHA-256 всех файлов. + С ошибками + Скачать и обновить файлы + Скачать, проверить и установить + Сортировка каталога по названию + Сортировка по названию + Состояние manifest, версии и доступные lifecycle-действия. + Структура архива + Существующая папка или уже зарегистрированный code не заменяются. + Только доверенный код + Только публичный HTTPS-адрес. ZIP-пакеты должны находиться на том же хосте. + Требуют восстановления + Удалить файлы + Удалённый каталог выключен + Установить + Установить ZIP + Установка выполняет PHP manifest, миграции и lifecycle-обработчики модуля. + Установка модуля + Установлен + Установленные + Установленные и доступные модули + Установленные модули сайта. + . Повторно загрузите файл пакета и только после проверки обновляйте index.json. + AVE.cms скачает подписанный ZIP, проверит контрольные суммы и безопасно заменит файлы модуля. Если новой версии нужны миграции, они появятся отдельным следующим действием. + AVE.cms скачает подписанный ZIP, проверит контрольные суммы, добавит файлы и выполнит миграции модуля. + URL подписанного каталога + ZIP модуля « + ZIP-архив превышает 25 МБ + ZIP-пакет добавит в систему новый исполняемый код. + · готов к проверке и установке + » загружен на сервер не полностью: ожидалось + » не совпадает с подписанным каталогом. Повторно загрузите файл пакета и очистите кеш CDN. + » установлен + Б + байт, получено + Будут выполнены SQL-команды обновления до версии из файлов. + Будут выполнены SQL-команды установки и модуль будет включён. + Версия скачанного пакета не совпадает с записью удалённого каталога + Восстановить + Восстановить операцию модуля? + Восстановить официальный каталог? + Восстановление повторно выполнит незавершённый этап установки. + Выберите ZIP-архив модуля + Готово + Данные и настройки будут удалены, затем модуль установится заново. Это действие необратимо. + Деинсталлировать модуль? + Деинсталляция выполнит код удаления и уничтожит данные модуля. + Для проверки каталога требуется PHP-расширение OpenSSL + Для удалённого каталога требуется PHP-расширение cURL + Загрузка ZIP, установка, настройка размещения, обновление, включение и деинсталляция модулей. + Загрузка и исполнение официальных ZIP-пакетов AVE.cms. + Источник модулей сохранён + Каталог должен использовать публичный RSA-ключ + Каталог модулей обновлён + Каталог обновлён + Каталог пакета будет физически удалён. Для повторной установки понадобится ZIP-архив. + Каталог содержит некорректную запись модуля + Каталог содержит некорректные метаданные модуля + Каталог содержит повторяющийся code: + КБ + Код скачанного пакета не совпадает с записью удалённого каталога + Контрольная сумма ZIP модуля « + МБ + Миграции модуля применены + Миграции применены + Модули + Модули: просмотр + Модули: управление + Модули: установка кода + Модуль « + Модуль включён + Модуль деинсталлирован + Модуль несовместим с текущими версиями PHP или AVE.cms + Модуль отключён + Модуль отсутствует в проверенном каталоге + Модуль переустановлен + Модуль установлен + Модуль, его данные, настройки, меню и права будут удалены. Это действие необратимо. + Настройка источника определяет, откуда система получает исполняемый код. + Настройки каталога сохранены + Нативный модуль не найден + Не удалось восстановить каталог + Не удалось изменить состояние модуля + Не удалось обновить каталог + Не удалось обновить модуль + Не удалось перенести файлы модуля + Не удалось подготовить резервную копию файлов модуля + Не удалось применить миграции + Не удалось создать временный ZIP + Не удалось создать временный каталог + Не удалось создать временный каталог установки + Не удалось сохранить источник + Не удалось сохранить размещение + Не удалось установить модуль + Обновить + Обновить « + Обновить модуль? + Обновить файлы + Обновление выполнит миграции и код пакета. + обновлён из каталога + Операция модуля восстановлена + Операция не выполнена + Ответ каталога пуст или слишком велик + Официальный каталог восстановлен + Официальный каталог модулей восстановлен + Официальный профиль заменит текущий URL и открытый ключ каталога модулей. + Ошибка запроса + Переустановить модуль? + Переустановка удалит данные и повторно выполнит код пакета. + Подписанные данные каталога повреждены + Подписанный ZIP будет загружен, проверен и установлен вместе с миграциями. + Подпись удалённого каталога повреждена + Предварительная проверка выполнена + Применить + Применить миграции « + Просмотр установленных и доступных легаси-модулей. + Публичный ключ каталога + Публичный ключ каталога не читается OpenSSL + Размещение модуля сохранено + Размещение сохранено + Сервер каталога не вернул ZIP-пакет модуля « + Сервер каталога недоступен + Система + Сначала установите модуль + Совместим с этой установкой + Текущий URL и публичный ключ модулей будут заменены данными из доверенного профиля сборки. + Удалить файлы модуля? + Удалённый каталог имеет неподдерживаемый формат + Удалённый каталог модулей + Удалённый файл превышает допустимый размер + Укажите URL каталога и публичный ключ + Укажите публичный RSA-ключ каталога в формате PEM + Управление + Успешные миграции будут пропущены, а оборванный этап выполнится повторно. Перед продолжением проверьте указанную ошибку. + Устанавливаем + Установить « + Установить модуль? + Установка выполнит миграции и код пакета. + установлен из каталога + Файлы исполняемого кода модуля будут физически удалены. + Файлы модуля обновлены + Файлы модуля удалены + Файлы модуля уже актуальны. AVE.cms выполнит ещё не применённые миграции и синхронизирует версию БД. + Хост каталога не имеет доступного публичного IPv4-адреса + Цифровая подпись каталога не прошла проверку + Эта версия модуля уже находится на сервере + diff --git a/adminx/modules/Modules/language/ru/runtime.xml b/adminx/modules/Modules/language/ru/runtime.xml new file mode 100644 index 0000000..4f1ef4a --- /dev/null +++ b/adminx/modules/Modules/language/ru/runtime.xml @@ -0,0 +1,161 @@ + + + (код + . Повторно загрузите файл пакета и только после проверки обновляйте index.json. + AVE-MODULE.json не содержит допустимый список файлов + AVE-MODULE.json содержит некорректную вложенную зависимость + AVE-MODULE.json содержит некорректные code, folder или version + AVE-MODULE.json содержит некорректный JSON + AVE-MODULE.json содержит некорректный файл или checksum + Checksum файла « + Code в module.php не совпадает с официальным manifest пакета + Manifest v2 не содержит допустимый список зависимостей + Manifest пакета не включает основной module.php + URL подписанного каталога + ZIP должен находиться на том же хосте, что и каталог + ZIP должен содержать один module.php или пакет с admin/module.php + ZIP модуля « + ZIP содержит абсолютный путь + ZIP содержит небезопасный путь + ZIP содержит некорректное имя файла + ZIP содержит повторяющийся путь « + ZIP содержит слишком много файлов и каталогов + ZIP содержит файл с подозрительным коэффициентом сжатия + ZIP-архив пуст + ZIP-пакет добавит в систему новый исполняемый код. + adopt_existing запрещён для установки нового модуля из ZIP + module.php должен возвращать descriptor с кодом модуля + » загружен на сервер не полностью: ожидалось + » не совпадает + » не совпадает с подписанным каталогом. Повторно загрузите файл пакета и очистите кеш CDN. + » отсутствует в официальном manifest + » распакован не полностью + » уже существует + » установлен + Архив загрузился не полностью + Архив не содержит admin_extension для новой админки + Архив не содержит допустимый AVE-MODULE.json + Архив превышает допустимый размер + Архив превышает серверный лимит загрузки + Архивный модуль должен объявлять lifecycle.managed = true + В пакете отсутствует часть файлов из официального manifest + Версия module.php не совпадает с официальным manifest пакета + Версия скачанного пакета не совпадает с записью удалённого каталога + Вложенные зависимости требуют format ave-module-package-v2 + Восстановление повторно выполнит незавершённый этап установки. + Выберите ZIP-архив модуля + Деинсталляция выполнит код удаления и уничтожит данные модуля. + Для проверки каталога требуется PHP-расширение OpenSSL + Для удалённого каталога требуется PHP-расширение cURL + Для установки модулей требуется PHP-расширение zip + Загруженный файл не найден + Загрузка ZIP, установка, настройка размещения, обновление, включение и деинсталляция модулей. + Загрузка и исполнение официальных ZIP-пакетов AVE.cms. + Имя папки составного пакета должно совпадать с его code + Каталог временных файлов недоступен для записи: + Каталог должен иметь публичный HTTPS URL без логина и фрагмента + Каталог должен использовать публичный RSA-ключ + Каталог модулей недоступен для записи: + Каталог модулей обновлён + Каталог содержит некорректную запись модуля + Каталог содержит некорректные метаданные модуля + Каталог содержит повторяющийся code: + Код скачанного пакета не совпадает с записью удалённого каталога + Контрольная сумма ZIP модуля « + Локальная копия ZIP сохранена не полностью + Локальный каталог загрузки недоступен для записи: + Локальный модуль не совпадает с обновляемым пакетом + Миграции модуля применены + Модули + Модули: просмотр + Модули: управление + Модули: установка кода + Модуль « + Модуль включён + Модуль деинсталлирован + Модуль несовместим с текущими версиями PHP или AVE.cms + Модуль отключён + Модуль отсутствует в проверенном каталоге + Модуль переустановлен + Модуль с кодом « + Модуль установлен + Настройка источника определяет, откуда система получает исполняемый код. + Настройки каталога сохранены + Нативный модуль не найден + Не удалось открыть ZIP: + Не удалось перенести ZIP из временного каталога сервера + Не удалось перенести файлы модуля + Не удалось подготовить резервную копию файлов модуля + Не удалось прочитать структуру ZIP + Не удалось распаковать файл « + Не удалось создать временный ZIP + Не удалось создать временный каталог + Не удалось создать временный каталог установки + Не удалось создать каталог блокировки установки: + Не удалось создать каталог из ZIP + Не удалось создать локальный каталог загрузки: + Не удалось создать структуру модуля + Некорректный code в module.php + Обнаружена циклическая зависимость пакета « + Обновление выполнит миграции и код пакета. + Операция модуля восстановлена + Ответ каталога пуст или слишком велик + Официальный каталог модулей восстановлен + Официальный профиль заменит текущий URL и открытый ключ каталога модулей. + Ошибка загрузки архива + Пакет содержит недопустимый тип файла + Папка модуля « + Папка составного пакета в AVE-MODULE.json должна совпадать с code + Переустановка удалит данные и повторно выполнит код пакета. + Поддерживаются только ZIP-архивы + Поддерживаются только официальные пакеты AVE.cms форматов v1 и v2 + Подписанные данные каталога повреждены + Подписанный ZIP будет загружен, проверен и установлен вместе с миграциями. + Подпись удалённого каталога повреждена + Предварительная проверка выполнена + Просмотр установленных и доступных легаси-модулей. + Публичный ключ каталога + Публичный ключ каталога не читается OpenSSL + Размер ZIP должен быть не больше 25 МБ + Размещение модуля сохранено + Распакованный модуль превышает безопасные лимиты + Сервер каталога не вернул ZIP-пакет модуля « + Сервер каталога недоступен + Символические ссылки в архиве запрещены + Система + Скачанный ZIP имеет недопустимый размер или формат + Сначала установите модуль + Совместим с этой установкой + Тип модуля не совпадает с AVE-MODULE.json + Удалённый ZIP находится вне защищённого временного каталога + Удалённый каталог выключен + Удалённый каталог имеет неподдерживаемый формат + Удалённый каталог модулей + Удалённый каталог передал некорректный модуль + Удалённый файл превышает допустимый размер + Укажите URL каталога и публичный ключ + Укажите публичный RSA-ключ каталога в формате PEM + Управление + Установка выполнит миграции и код пакета. + Установка другого модуля уже выполняется. Повторите попытку позже + Файл « + Файлы исполняемого кода модуля будут физически удалены. + Файлы модуля удалены + Хост каталога не имеет доступного публичного IPv4-адреса + Цифровая подпись каталога не прошла проверку + Эта версия модуля уже находится на сервере + байт, получено + локальная копия архива не найдена + неподдерживаемый формат или ошибка ZIP + обновлён из каталога + передан некорректный аргумент + повреждена структура архива + сервер не поддерживает позиционирование в файле архива + сервер не смог открыть локальную копию архива + сервер не смог прочитать архив + серверу не хватило памяти + уже выполняется + установлен из каталога + файл не является ZIP-архивом + файл уже существует + diff --git a/adminx/modules/Modules/module.php b/adminx/modules/Modules/module.php new file mode 100644 index 0000000..670f343 --- /dev/null +++ b/adminx/modules/Modules/module.php @@ -0,0 +1,118 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + $codeRoute = function ($reason) { + return array( + 'permission' => 'install_module_code', + 'sensitive' => 'module.code', + 'reauth' => array('reason' => (string) $reason), + ); + }; + + return array( + 'code' => 'modules', + 'name' => 'Модули', + 'version' => '0.1.3', + + 'settings' => array( + 'repository_enabled' => array('label' => 'Удалённый каталог модулей', 'type' => 'bool', 'default' => false, 'group' => 'modules_repository'), + 'repository_url' => array('label' => 'URL подписанного каталога', 'type' => 'string', 'default' => '', 'group' => 'modules_repository'), + 'repository_public_key' => array('label' => 'Публичный ключ каталога', 'type' => 'code', 'default' => '', 'group' => 'modules_repository', 'sensitive' => true), + ), + + 'permissions' => array( + 'key' => 'modules', + 'items' => array( + array( + 'code' => 'view_modules', + 'group_code' => 'navigation', + 'name' => 'Модули: просмотр', + 'description' => 'Просмотр установленных и доступных легаси-модулей.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_modules', + 'group_code' => 'admin', + 'name' => 'Модули: управление', + 'description' => 'Загрузка ZIP, установка, настройка размещения, обновление, включение и деинсталляция модулей.', + 'sort_order' => 20, + ), + array( + 'code' => 'install_module_code', + 'group_code' => 'admin', + 'name' => 'Модули: установка кода', + 'description' => 'Загрузка и исполнение официальных ZIP-пакетов AVE.cms.', + 'sort_order' => 30, + ), + ), + 'icon' => 'ti ti-puzzle', + 'priority' => 45, + ), + + 'navigation' => array( + array( + 'code' => 'modules', + 'label' => 'Модули', + 'url' => '#', + 'icon' => 'ti ti-puzzle', + 'permission' => '', + 'group' => 'Система', + 'sort_order' => 45, + 'match' => array( + '/modules', + '/system/document-jobs', + ), + ), + array( + 'code' => 'modules_registry', + 'label' => 'Управление', + 'url' => '/modules/', + 'permission' => 'view_modules', + 'group' => 'Система', + 'parent' => 'modules', + 'sort_order' => 1, + 'match' => array( + '/modules', + ), + ), + ), + 'routes' => array( + array('GET', '/modules', array(\App\Adminx\Modules\Controller::class, 'index')), + array('GET', '/modules/', array(\App\Adminx\Modules\Controller::class, 'index')), + array('POST', '/modules/archive/install', array(\App\Adminx\Modules\Controller::class, 'installArchive'), array( + 'permission' => 'install_module_code', + 'sensitive' => 'module.code', + 'reauth' => array('reason' => 'ZIP-пакет добавит в систему новый исполняемый код.'), + )), + array('POST', '/modules/repository/settings', array(\App\Adminx\Modules\Controller::class, 'saveRepositorySettings'), $codeRoute('Настройка источника определяет, откуда система получает исполняемый код.')), + array('POST', '/modules/repository/settings/official', array(\App\Adminx\Modules\Controller::class, 'restoreOfficialRepository'), $codeRoute('Официальный профиль заменит текущий URL и открытый ключ каталога модулей.')), + array('POST', '/modules/repository/refresh', array(\App\Adminx\Modules\Controller::class, 'refreshRepository')), + array('POST', '/modules/repository/{code}/install', array(\App\Adminx\Modules\Controller::class, 'installRepository'), $codeRoute('Подписанный ZIP будет загружен, проверен и установлен вместе с миграциями.')), + array('POST', '/modules/lifecycle/{code}/presentation', array(\App\Adminx\Modules\Controller::class, 'presentation')), + array('POST', '/modules/lifecycle/{code}/toggle', array(\App\Adminx\Modules\Controller::class, 'toggle')), + array('POST', '/modules/lifecycle/{code}/preflight/{operation}', array(\App\Adminx\Modules\Controller::class, 'preflight')), + array('POST', '/modules/lifecycle/{code}/install', array(\App\Adminx\Modules\Controller::class, 'install'), $codeRoute('Установка выполнит миграции и код пакета.')), + array('POST', '/modules/lifecycle/{code}/update', array(\App\Adminx\Modules\Controller::class, 'update'), $codeRoute('Обновление выполнит миграции и код пакета.')), + array('POST', '/modules/lifecycle/{code}/reinstall', array(\App\Adminx\Modules\Controller::class, 'reinstall'), $codeRoute('Переустановка удалит данные и повторно выполнит код пакета.')), + array('POST', '/modules/lifecycle/{code}/repair', array(\App\Adminx\Modules\Controller::class, 'repair'), $codeRoute('Восстановление повторно выполнит незавершённый этап установки.')), + array('POST', '/modules/lifecycle/{code}/uninstall', array(\App\Adminx\Modules\Controller::class, 'uninstall'), $codeRoute('Деинсталляция выполнит код удаления и уничтожит данные модуля.')), + array('POST', '/modules/lifecycle/{code}/remove', array(\App\Adminx\Modules\Controller::class, 'remove'), $codeRoute('Файлы исполняемого кода модуля будут физически удалены.')), + ), + + 'view_globals' => array( + 'module_code' => 'modules', + ), + ); diff --git a/adminx/modules/Modules/view/index.twig b/adminx/modules/Modules/view/index.twig new file mode 100644 index 0000000..34bcb55 --- /dev/null +++ b/adminx/modules/Modules/view/index.twig @@ -0,0 +1,299 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Модули{% endblock %} +{% block content %} + +
    + + + + +
    +
    {{ stats.total }}
    Найдено
    +
    {{ stats.active }}
    Активны
    +
    {{ stats.inactive }}
    Отключены
    +
    {{ stats.updates }}
    Обновления
    +
    + +{% if fatal %} +
    Реестр модулей недоступен
    {{ fatal }}
    +{% endif %} + +
    + + +
    + +
    + +
    +
    +
    +
    Расширения сайта
    +

    Реестр модулей

    +

    Состояние manifest, версии и доступные lifecycle-действия.

    +
    +
    +
    +
    + + +
    +
    +
    + +
    +

    Установленные и доступные модули

    Версии, состояние подключения и доступные системные действия.

    {{ modules|length }}
    +
    + + + + + + + + + + {% for module in modules %} + {% set state = module.error ? 'error' : (module.recoverable ? 'recovery' : (not module.installed ? 'available' : (module.needs_update ? 'update' : (module.enabled ? 'active' : 'inactive')))) %} + + + + + + + + {% else %} + + {% endfor %} + + +
    МодульСостояниеОбластьВерсия
    +
    + + + {{ module.name }} + {{ module.code }}{% if module.function %}{{ module.function }}{% endif %}{% if module.has_admin or module.admin_url %}настройки{% endif %} + {% if module.description %}{{ module.description }}{% endif %} + {% if module.error %}{{ module.error }}{% endif %} + {% if module.recoverable %}{{ module.last_error ?: 'Предыдущая операция не завершена' }}{% endif %} + +
    +
    + {% if module.error %}Ошибка + {% elseif module.recoverable %}Восстановление + {% elseif not module.installed %}Не установлен + {% elseif module.needs_update %}{{ module.migration_pending ? 'Миграции' : 'Обновление' }} + {% elseif module.enabled %}Активен + {% else %}Отключён{% endif %} + + {% if module.scope == 'mixed' %}Паблик + админка + {% elseif module.scope == 'public' %}Публичный + {% else %}Админка{% endif %} + + Файлы: {{ module.file_version ?: '—' }} + {% if module.installed and module.db_version != module.file_version %}БД: {{ module.db_version ?: '—' }}{% endif %} + + {% if module.admin_url or can_manage %} +
    + {% if module.installed and not module.missing and module.admin_url %} + + {% endif %} + {% if can_manage and module.installed and (module.has_header or module.has_dashboard) %} + + {% endif %} + {% if can_manage and module.managed %} + {% if module.recoverable %} + + {% elseif not module.installed and not module.error %} + + {% if module.removable %}{% endif %} + {% elseif module.installed %} + + {% if module.needs_update %}{% endif %} + + + {% endif %} + {% endif %} +
    + {% endif %} +
    Модули не найдены
    +
    +
    + +{% if errors %} +
    + Диагностика файлов {{ errors|length }} +
    {% for error in errors %}
    {{ error.code }}{{ error.message }}
    {% endfor %}
    +
    +{% endif %} +
    + + + +{% if can_manage %} + + + {% if can_install_code %} + + + + {% endif %} + {% endif %} +
    + +{% endblock %} diff --git a/adminx/modules/Navigation/Controller.php b/adminx/modules/Navigation/Controller.php new file mode 100644 index 0000000..2867b5f --- /dev/null +++ b/adminx/modules/Navigation/Controller.php @@ -0,0 +1,369 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Navigation; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Adminx\Support\CodeEditor; + use App\Common\AdminAssets; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Helpers\Request; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + AdminAssets::addStyle($this->base() . '/modules/Navigation/assets/navigation.css', 50); + AdminAssets::addScript($this->base() . '/modules/Navigation/assets/navigation.js', 50); + CodeEditor::useCodeMirror('htmlmixed'); + + $q = Request::getStr('q', ''); + + return $this->render('@navigation/index.twig', array( + 'navigation_items' => Model::all($q), + 'stats' => Model::stats(), + 'filters' => array('q' => $q), + 'access_groups' => Model::accessGroups(), + 'can_manage' => Permission::check('manage_navigation'), + )); + } + + public function show(array $params = array()) + { + $item = Model::one(isset($params['id']) ? (int) $params['id'] : 0); + if (!$item) { + return $this->error('Навигация не найдена', array(), 404); + } + + return $this->success('', array('data' => $item)); + } + + public function aliasCheck(array $params = array()) + { + if (!Permission::check('manage_navigation')) { + return $this->error('Недостаточно прав', array(), 403); + } + + $alias = trim(Request::getStr('alias', '')); + $id = Request::getInt('id', 0); + $valid = true; + $available = true; + $message = 'Алиас свободен'; + + if ($alias === '') { + $valid = false; + $available = false; + $message = 'Укажите алиас'; + } elseif (preg_match('/^[A-Za-z0-9-_]{1,20}$/i', $alias) !== 1 || is_numeric($alias)) { + $valid = false; + $available = false; + $message = 'Только A-Z, 0-9, дефис и подчёркивание, до 20 символов; не число'; + } elseif (Model::aliasExists($alias, $id)) { + $available = false; + $message = 'Такой алиас уже используется'; + } + + return $this->success($message, array( + 'data' => array( + 'alias' => $alias, + 'id' => $id, + 'valid' => $valid, + 'available' => $available, + ), + )); + } + + public function documentPicker(array $params = array()) + { + if (!Permission::check('manage_navigation')) { + return $this->error('Недостаточно прав', array(), 403); + } + + return $this->success('', array( + 'data' => array( + 'items' => Model::documentPicker( + Request::getStr('q', ''), + Request::getInt('limit', 30) + ), + ), + )); + } + + public function store(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $errors = $this->validate(Request::postAll(), 0); + if (!empty($errors)) { + return $this->error('Проверьте поля формы', $errors); + } + + $id = Model::save(0, Request::postAll()); + return $this->success('Навигация создана', array('data' => array('id' => $id), 'redirect' => $this->base() . '/navigation')); + } + + public function update(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($id)) { + return $this->error('Навигация не найдена', array(), 404); + } + + $errors = $this->validate(Request::postAll(), $id); + if (!empty($errors)) { + return $this->error('Проверьте поля формы', $errors); + } + + Model::save($id, Request::postAll()); + return $this->success('Навигация сохранена', array('redirect' => $this->base() . '/navigation')); + } + + public function copy(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + Model::copy(isset($params['id']) ? (int) $params['id'] : 0, Request::postStr('title', '')); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Копия создана', array('redirect' => $this->base() . '/navigation')); + } + + public function clearCache(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $item = Model::one(isset($params['id']) ? (int) $params['id'] : 0); + if (!$item) { + return $this->error('Навигация не найдена', array(), 404); + } + + Model::clearCache($item['navigation_id'], $item['alias']); + return $this->success('Кеш навигации очищен'); + } + + public function items(array $params = array()) + { + $id = isset($params['id']) ? (int) $params['id'] : 0; + $nav = Model::one($id); + if (!$nav) { + return $this->error('Навигация не найдена', array(), 404); + } + + return $this->success('', array( + 'data' => array( + 'navigation' => array( + 'id' => $nav['navigation_id'], + 'title' => $nav['title'], + 'alias' => $nav['alias'], + 'tag' => $nav['tag'], + ), + 'items' => Model::itemsTree($id), + 'flat' => Model::flatItems($id), + ), + )); + } + + public function item(array $params = array()) + { + $item = Model::item(isset($params['item']) ? (int) $params['item'] : 0); + if (!$item) { + return $this->error('Пункт навигации не найден', array(), 404); + } + + return $this->success('', array('data' => $item)); + } + + public function storeItem(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $navigationId = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($navigationId)) { + return $this->error('Навигация не найдена', array(), 404); + } + + $errors = $this->validateItem(Request::postAll()); + if (!empty($errors)) { + return $this->error('Проверьте поля пункта', $errors); + } + + $id = Model::saveItem(0, $navigationId, Request::postAll()); + return $this->success('Пункт навигации создан', array('data' => array('id' => $id))); + } + + public function updateItem(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $id = isset($params['item']) ? (int) $params['item'] : 0; + $item = Model::item($id); + if (!$item) { + return $this->error('Пункт навигации не найден', array(), 404); + } + + $errors = $this->validateItem(Request::postAll()); + if (!empty($errors)) { + return $this->error('Проверьте поля пункта', $errors); + } + + Model::saveItem($id, (int) $item['navigation_id'], Request::postAll()); + return $this->success('Пункт навигации сохранён'); + } + + public function reorderItems(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $navigationId = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($navigationId)) { + return $this->error('Навигация не найдена', array(), 404); + } + + $order = Request::postJsonArray('order'); + if (!is_array($order)) { + return $this->error('Некорректный порядок пунктов', array(), 422); + } + + try { + Model::reorderItems($navigationId, $order); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Порядок пунктов сохранён'); + } + + public function toggleItem(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $status = Model::toggleItem(isset($params['item']) ? (int) $params['item'] : 0); + if ($status === null) { + return $this->error('Пункт навигации не найден', array(), 404); + } + + return $this->success($status === '1' ? 'Пункт включён' : 'Пункт выключен', array('data' => array('status' => $status))); + } + + public function destroyItem(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + if (!Model::deleteItem(isset($params['item']) ? (int) $params['item'] : 0)) { + return $this->error('Пункт навигации не найден', array(), 404); + } + + return $this->success('Пункт навигации удалён или выключен'); + } + + public function destroy(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + if (!Model::delete(isset($params['id']) ? (int) $params['id'] : 0)) { + return $this->error('Навигация не найдена', array(), 404); + } + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Навигация удалена'); + } + + protected function guard() + { + return $this->guardPermission('manage_navigation'); + } + + protected function validate(array $input, $id) + { + $errors = array(); + $title = trim(isset($input['title']) ? (string) $input['title'] : ''); + $alias = trim(isset($input['alias']) ? (string) $input['alias'] : ''); + $level1 = isset($input['level1']) ? trim((string) $input['level1']) : ''; + $level1Active = isset($input['level1_active']) ? trim((string) $input['level1_active']) : ''; + + if ($title === '') { + $errors['title'] = 'Укажите название'; + } + + if ($alias === '') { + $errors['alias'] = 'Укажите алиас'; + } elseif (preg_match('/^[A-Za-z0-9-_]{1,20}$/i', $alias) !== 1 || is_numeric($alias)) { + $errors['alias'] = 'Только A-Z, 0-9, дефис и подчёркивание, до 20 символов; не число'; + } elseif (Model::aliasExists($alias, (int) $id)) { + $errors['alias'] = 'Такой алиас уже используется'; + } + + if ($level1 === '') { + $errors['level1'] = 'Укажите шаблон ссылки первого уровня'; + } + + if ($level1Active === '') { + $errors['level1_active'] = 'Укажите активный шаблон первого уровня'; + } + + return $errors; + } + + protected function validateItem(array $input) + { + $errors = array(); + $title = trim(isset($input['title']) ? (string) $input['title'] : ''); + $alias = trim(isset($input['alias']) ? (string) $input['alias'] : ''); + $parentId = isset($input['parent_id']) ? (int) $input['parent_id'] : 0; + + if ($title === '') { + $errors['item_title'] = 'Укажите название пункта'; + } + + if ($alias === '') { + $errors['item_alias'] = 'Укажите ссылку или alias'; + } + + if ($parentId < 0) { + $errors['item_parent_id'] = 'Некорректный родитель'; + } + + return $errors; + } + } diff --git a/adminx/modules/Navigation/Model.php b/adminx/modules/Navigation/Model.php new file mode 100644 index 0000000..380be11 --- /dev/null +++ b/adminx/modules/Navigation/Model.php @@ -0,0 +1,734 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Navigation; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Content\ContentTables; + use App\Content\PublicUserTables; + use App\Helpers\Dir; + use App\Helpers\File; + use DB; + + class Model + { + public static function table() + { + return ContentTables::table('navigation'); + } + + public static function itemsTable() + { + return ContentTables::table('navigation_items'); + } + + public static function all($q = '') + { + $sql = 'SELECT n.*, COUNT(i.navigation_item_id) AS items_count' + . ' FROM ' . self::table() . ' n' + . ' LEFT JOIN ' . self::itemsTable() . ' i ON i.navigation_id = n.navigation_id' + . ' WHERE 1=1'; + $args = array(); + $q = trim((string) $q); + if ($q !== '') { + $sql .= ' AND (n.title LIKE %ss OR n.alias LIKE %ss OR n.navigation_id = %i)'; + $args[] = $q; + $args[] = $q; + $args[] = (int) $q; + } + + $sql .= ' GROUP BY n.navigation_id ORDER BY n.navigation_id ASC'; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = self::row($row); + } + + return $out; + } + + public static function one($id) + { + $row = DB::query( + 'SELECT n.*, COUNT(i.navigation_item_id) AS items_count' + . ' FROM ' . self::table() . ' n' + . ' LEFT JOIN ' . self::itemsTable() . ' i ON i.navigation_id = n.navigation_id' + . ' WHERE n.navigation_id = %i' + . ' GROUP BY n.navigation_id LIMIT 1', + (int) $id + )->getAssoc(); + return $row ? self::row($row, true) : null; + } + + public static function raw($id) + { + $row = DB::query('SELECT * FROM ' . self::table() . ' WHERE navigation_id = %i LIMIT 1', (int) $id)->getAssoc(); + return $row ? $row : null; + } + + public static function itemsTree($navigationId) + { + $rows = DB::query( + 'SELECT * FROM ' . self::itemsTable() + . ' WHERE navigation_id = %i' + . ' ORDER BY level ASC, parent_id ASC, position ASC, navigation_item_id ASC', + (int) $navigationId + )->getAll(); + + $items = array(); + foreach ($rows as $row) { + $item = self::itemRow($row); + $item['children'] = array(); + $items[$item['navigation_item_id']] = $item; + } + + $tree = array(); + foreach ($items as $id => $item) { + $parentId = (int) $item['parent_id']; + if ($parentId > 0 && isset($items[$parentId])) { + $items[$parentId]['children'][] = &$items[$id]; + } else { + $tree[] = &$items[$id]; + } + } + + unset($item); + return $tree; + } + + public static function flatItems($navigationId) + { + $rows = DB::query( + 'SELECT navigation_item_id, parent_id, level, title, alias, position, status' + . ' FROM ' . self::itemsTable() + . ' WHERE navigation_id = %i' + . ' ORDER BY level ASC, parent_id ASC, position ASC, navigation_item_id ASC', + (int) $navigationId + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = self::itemRow($row); + } + + return $out; + } + + public static function item($id) + { + $row = DB::query( + 'SELECT i.*, d.document_title, d.document_alias' + . ' FROM ' . self::itemsTable() . ' i' + . ' LEFT JOIN ' . ContentTables::table('documents') . ' d ON d.Id = i.document_id' + . ' WHERE i.navigation_item_id = %i LIMIT 1', + (int) $id + )->getAssoc(); + return $row ? self::itemRow($row) : null; + } + + public static function documentPicker($q = '', $limit = 30) + { + $limit = max(5, min(50, (int) $limit)); + $sql = 'SELECT d.Id, d.rubric_id, d.document_title, d.document_alias, d.document_status, r.rubric_title' + . ' FROM ' . ContentTables::table('documents') . ' d' + . ' LEFT JOIN ' . ContentTables::table('rubrics') . ' r ON r.Id = d.rubric_id' + . " WHERE d.document_deleted != '1'"; + $args = array(); + $q = trim((string) $q); + if ($q !== '') { + $sql .= ' AND (d.document_title LIKE %ss OR d.document_alias LIKE %ss OR d.Id = %i)'; + $args[] = $q; + $args[] = $q; + $args[] = (int) $q; + } + + $sql .= ' ORDER BY d.document_changed DESC, d.Id DESC LIMIT ' . (int) $limit; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = array( + 'id' => (int) $row['Id'], + 'rubric_id' => (int) $row['rubric_id'], + 'title' => self::decode(isset($row['document_title']) ? $row['document_title'] : ''), + 'alias' => (string) $row['document_alias'], + 'status' => (int) $row['document_status'], + 'rubric_title' => self::decode(isset($row['rubric_title']) ? $row['rubric_title'] : ''), + ); + } + + return $out; + } + + public static function stats() + { + return array( + 'total' => (int) DB::query('SELECT COUNT(*) FROM ' . self::table())->getValue(), + 'items' => (int) DB::query('SELECT COUNT(*) FROM ' . self::itemsTable())->getValue(), + 'aliases' => (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE alias != %s', '')->getValue(), + 'restricted' => (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE user_group != %s', '')->getValue(), + ); + } + + public static function accessGroups() + { + $rows = DB::query( + 'SELECT user_group AS id, user_group_name AS name, status' + . ' FROM ' . PublicUserTables::table('user_groups') + . ' ORDER BY user_group ASC' + )->getAll() ?: array(); + + foreach ($rows as &$row) { + $row['id'] = (int) $row['id']; + $row['name'] = self::decode(isset($row['name']) ? $row['name'] : ''); + $row['active'] = isset($row['status']) && (string) $row['status'] === '1'; + } + + unset($row); + return $rows; + } + + public static function aliasExists($alias, $excludeId = 0) + { + return (bool) DB::query( + 'SELECT navigation_id FROM ' . self::table() . ' WHERE alias = %s AND navigation_id != %i LIMIT 1', + (string) $alias, + (int) $excludeId + )->getValue(); + } + + public static function save($id, array $input) + { + $data = self::input($input); + if ((int) $id > 0) { + $current = self::raw($id); + DB::Update(self::table(), $data, 'navigation_id = %i', (int) $id); + self::clearCache((int) $id, isset($current['alias']) ? $current['alias'] : ''); + self::clearCache((int) $id, $data['alias']); + return (int) $id; + } + + DB::Insert(self::table(), $data); + $newId = (int) DB::insertId(); + self::clearCache($newId, $data['alias']); + return $newId; + } + + public static function saveItem($id, $navigationId, array $input) + { + $data = self::itemInput($input); + if ((int) $id > 0) { + $current = self::item($id); + if (!$current) { + throw new \RuntimeException('Пункт навигации не найден'); + } + + $navigationId = (int) $current['navigation_id']; + self::assertItemParent($navigationId, (int) $id, (int) $data['parent_id']); + $data['navigation_id'] = $navigationId; + DB::startTransaction(); + try { + DB::Update(self::itemsTable(), $data, 'navigation_item_id = %i', (int) $id); + self::synchronizeItemLevels($navigationId); + DB::commit(); + } catch (\Throwable $e) { + DB::rollback(); + throw $e; + } + + self::clearCache($navigationId, self::navigationAlias($navigationId)); + return (int) $id; + } + + $navigationId = (int) $navigationId; + self::assertItemParent($navigationId, 0, (int) $data['parent_id']); + $data['navigation_id'] = $navigationId; + $data['position'] = self::nextItemPosition($navigationId, (int) $data['parent_id']); + DB::startTransaction(); + try { + DB::Insert(self::itemsTable(), $data); + $newId = (int) DB::insertId(); + self::synchronizeItemLevels($navigationId); + DB::commit(); + } catch (\Throwable $e) { + DB::rollback(); + throw $e; + } + + self::clearCache($navigationId, self::navigationAlias($navigationId)); + return $newId; + } + + public static function toggleItem($id) + { + $item = self::item($id); + if (!$item) { + return null; + } + + $status = (string) $item['status'] === '1' ? '0' : '1'; + DB::Update(self::itemsTable(), array('status' => $status), 'navigation_item_id = %i', (int) $id); + self::clearCache((int) $item['navigation_id'], self::navigationAlias((int) $item['navigation_id'])); + return $status; + } + + public static function deleteItem($id) + { + $item = self::item($id); + if (!$item) { + return false; + } + + $children = (int) DB::query('SELECT COUNT(*) FROM ' . self::itemsTable() . ' WHERE parent_id = %i', (int) $id)->getValue(); + if ($children > 0) { + DB::Update(self::itemsTable(), array('status' => '0'), 'navigation_item_id = %i', (int) $id); + } else { + DB::Delete(self::itemsTable(), 'navigation_item_id = %i', (int) $id); + } + + self::clearCache((int) $item['navigation_id'], self::navigationAlias((int) $item['navigation_id'])); + return true; + } + + public static function reorderItems($navigationId, array $order) + { + $navigationId = (int) $navigationId; + $rows = DB::query( + 'SELECT navigation_item_id FROM ' . self::itemsTable() . ' WHERE navigation_id=%i', + $navigationId + )->getAll(); + $known = array(); + foreach ($rows ?: array() as $row) { + $known[(int) $row['navigation_item_id']] = true; + } + + $submitted = array(); + $positions = array(); + foreach ($order as $row) { + if (!is_array($row) || empty($row['id'])) { continue; } + $id = (int) $row['id']; + $parentId = isset($row['parent_id']) ? (int) $row['parent_id'] : 0; + if (!isset($known[$id]) || isset($submitted[$id])) { + throw new \InvalidArgumentException('Порядок содержит неизвестный или повторяющийся пункт'); + } + + if ($parentId > 0 && !isset($known[$parentId])) { + throw new \InvalidArgumentException('Родитель пункта не принадлежит этой навигации'); + } + + $positionKey = (string) $parentId; + $positions[$positionKey] = isset($positions[$positionKey]) ? $positions[$positionKey] + 1 : 1; + $submitted[$id] = array('parent_id' => $parentId, 'position' => $positions[$positionKey]); + } + + if (count($submitted) !== count($known)) { + throw new \InvalidArgumentException('Передан неполный порядок пунктов'); + } + + $levels = array(); + $visiting = array(); + foreach (array_keys($submitted) as $id) { + self::submittedItemLevel($id, $submitted, $levels, $visiting); + } + + DB::startTransaction(); + try { + foreach ($submitted as $id => $row) { + DB::Update(self::itemsTable(), array( + 'parent_id' => $row['parent_id'], + 'level' => (string) $levels[$id], + 'position' => $row['position'], + ), 'navigation_item_id = %i AND navigation_id = %i', $id, $navigationId); + } + + DB::commit(); + } catch (\Throwable $e) { + DB::rollback(); + throw $e; + } + + self::clearCache($navigationId, self::navigationAlias($navigationId)); + return true; + } + + public static function copy($id, $title) + { + $row = self::raw($id); + if (!$row) { + throw new \RuntimeException('Навигация не найдена'); + } + + unset($row['navigation_id']); + $title = trim((string) $title); + $row['title'] = $title !== '' ? $title : self::uniqueTitle($row['title'] . ' (копия)'); + $row['alias'] = ''; + DB::Insert(self::table(), $row); + return (int) DB::insertId(); + } + + public static function delete($id) + { + $id = (int) $id; + if ($id === 1) { + throw new \RuntimeException('Навигация #1 является системной и не может быть удалена'); + } + + $row = self::raw($id); + if (!$row) { + return false; + } + + self::clearCache($id, isset($row['alias']) ? $row['alias'] : ''); + DB::Delete(self::itemsTable(), 'navigation_id = %i', $id); + DB::Delete(self::table(), 'navigation_id = %i', $id); + return true; + } + + public static function clearCache($id, $alias = '') + { + $keys = array((string) (int) $id); + $alias = trim((string) $alias); + if ($alias !== '') { + $keys[] = $alias; + } + + foreach (array_unique($keys) as $key) { + self::removeFile(self::cacheDir($key) . '/template.cache'); + self::removeFile(self::cacheDir($key) . '/items.cache'); + self::removeDir(self::cacheDir($key)); + } + } + + protected static function input(array $input) + { + $title = trim(isset($input['title']) ? (string) $input['title'] : ''); + $expand = isset($input['expand_ext']) ? (string) $input['expand_ext'] : '1'; + if (!in_array($expand, array('0', '1', '2'), true)) { + $expand = '1'; + } + + $defaults = self::defaultTemplates(); + if ($title === '') { + $title = 'title'; + } + + foreach ($defaults as $key => $value) { + if (!isset($input[$key]) || trim((string) $input[$key]) === '') { + $input[$key] = $value; + } + } + + return array( + 'alias' => trim(isset($input['alias']) ? (string) $input['alias'] : ''), + 'title' => $title, + 'level1' => (string) $input['level1'], + 'level2' => isset($input['level2']) ? (string) $input['level2'] : '', + 'level3' => isset($input['level3']) ? (string) $input['level3'] : '', + 'level1_active' => (string) $input['level1_active'], + 'level2_active' => isset($input['level2_active']) ? (string) $input['level2_active'] : '', + 'level3_active' => isset($input['level3_active']) ? (string) $input['level3_active'] : '', + 'level1_begin' => isset($input['level1_begin']) ? (string) $input['level1_begin'] : '', + 'level1_end' => isset($input['level1_end']) ? (string) $input['level1_end'] : '', + 'level2_begin' => isset($input['level2_begin']) ? (string) $input['level2_begin'] : '', + 'level2_end' => isset($input['level2_end']) ? (string) $input['level2_end'] : '', + 'level3_begin' => isset($input['level3_begin']) ? (string) $input['level3_begin'] : '', + 'level3_end' => isset($input['level3_end']) ? (string) $input['level3_end'] : '', + 'begin' => isset($input['begin']) ? (string) $input['begin'] : '', + 'end' => isset($input['end']) ? (string) $input['end'] : '', + 'user_group' => self::normalizeUserGroups(isset($input['user_group']) ? $input['user_group'] : ''), + 'expand_ext' => $expand, + ); + } + + protected static function defaultTemplates() + { + return array( + 'begin' => '', + 'level1_begin' => '', + ); + } + + protected static function itemInput(array $input) + { + $alias = isset($input['alias']) ? (string) $input['alias'] : ''; + if (strpos($alias, 'javascript') !== false) { + $alias = str_replace(array(' ', '%'), '-', $alias); + } + + $target = isset($input['target']) ? (string) $input['target'] : '_self'; + if (!in_array($target, array('_self', '_blank', '_parent', '_top'), true)) { + $target = '_self'; + } + + $parentId = isset($input['parent_id']) ? (int) $input['parent_id'] : 0; + return array( + 'document_id' => isset($input['document_id']) && (int) $input['document_id'] > 0 ? (int) $input['document_id'] : null, + 'alias' => $alias, + 'title' => trim(isset($input['title']) ? (string) $input['title'] : ''), + 'description' => isset($input['description']) ? (string) $input['description'] : '', + 'target' => $target, + 'image' => isset($input['image']) ? (string) $input['image'] : '', + 'css_style' => isset($input['css_style']) ? (string) $input['css_style'] : '', + 'css_id' => isset($input['css_id']) ? (string) $input['css_id'] : '', + 'css_class' => isset($input['css_class']) ? (string) $input['css_class'] : '', + 'parent_id' => $parentId, + 'level' => '1', + 'position' => isset($input['position']) ? (int) $input['position'] : 1, + 'status' => trim($alias) === '' ? '0' : '1', + ); + } + + protected static function row(array $row, $withCode = false) + { + $id = (int) $row['navigation_id']; + $alias = trim((string) $row['alias']); + $key = $alias !== '' ? $alias : (string) $id; + $row['navigation_id'] = $id; + $row['id'] = $id; + $row['alias'] = $alias; + $row['items_count'] = isset($row['items_count']) ? (int) $row['items_count'] : self::itemsCount($id); + $row['tag'] = '[tag:navigation:' . $key . ']'; + $row['expand_label'] = self::expandLabel(isset($row['expand_ext']) ? (string) $row['expand_ext'] : '1'); + $row['has_groups'] = trim((string) $row['user_group']) !== ''; + $row['can_delete'] = $id !== 1; + if (!$withCode) { + foreach (self::templateFields() as $field) { + unset($row[$field]); + } + } + + return $row; + } + + protected static function itemRow(array $row) + { + $row['navigation_item_id'] = (int) $row['navigation_item_id']; + $row['id'] = $row['navigation_item_id']; + $row['navigation_id'] = isset($row['navigation_id']) ? (int) $row['navigation_id'] : 0; + $row['document_id'] = isset($row['document_id']) ? (int) $row['document_id'] : 0; + $row['parent_id'] = isset($row['parent_id']) ? (int) $row['parent_id'] : 0; + $row['level'] = isset($row['level']) ? (string) $row['level'] : '1'; + $row['position'] = isset($row['position']) ? (int) $row['position'] : 1; + $row['status'] = isset($row['status']) ? (string) $row['status'] : '1'; + if (array_key_exists('document_title', $row)) { + $row['document_title'] = self::decode($row['document_title']); + } + + return $row; + } + + protected static function decode($value) + { + return html_entity_decode((string) $value, ENT_QUOTES, 'UTF-8'); + } + + protected static function normalizeUserGroups($value) + { + $values = is_array($value) ? $value : explode(',', (string) $value); + $selected = array(); + foreach ($values as $groupId) { + $groupId = (int) trim((string) $groupId); + if ($groupId > 0) { + $selected[$groupId] = true; + } + } + + $valid = array(); + foreach (self::accessGroups() as $group) { + if (isset($selected[(int) $group['id']])) { + $valid[] = (int) $group['id']; + } + } + + return implode(',', $valid); + } + + protected static function resolveLevel($parentId) + { + $parentId = (int) $parentId; + if ($parentId <= 0) { + return '1'; + } + + $parent = self::item($parentId); + if (!$parent) { + return '1'; + } + + $level = min(3, max(1, (int) $parent['level'] + 1)); + return (string) $level; + } + + protected static function assertItemParent($navigationId, $itemId, $parentId) + { + if ($parentId <= 0) { return; } + $rows = DB::query( + 'SELECT navigation_item_id,parent_id FROM ' . self::itemsTable() . ' WHERE navigation_id=%i', + (int) $navigationId + )->getAll(); + $parents = array(); + foreach ($rows ?: array() as $row) { + $parents[(int) $row['navigation_item_id']] = (int) $row['parent_id']; + } + + if (!isset($parents[$parentId])) { + throw new \InvalidArgumentException('Выбранный родитель не принадлежит этой навигации'); + } + + $seen = array(); + $cursor = $parentId; + $level = 1; + while ($cursor > 0) { + if ($cursor === (int) $itemId || isset($seen[$cursor])) { + throw new \InvalidArgumentException('Нельзя переместить пункт внутрь собственной ветки'); + } + + $seen[$cursor] = true; + $level++; + if ($level > 3) { + throw new \InvalidArgumentException('Меню поддерживает не более трёх уровней'); + } + + $cursor = isset($parents[$cursor]) ? (int) $parents[$cursor] : 0; + } + } + + protected static function synchronizeItemLevels($navigationId) + { + $rows = DB::query( + 'SELECT navigation_item_id,parent_id FROM ' . self::itemsTable() . ' WHERE navigation_id=%i', + (int) $navigationId + )->getAll(); + $submitted = array(); + foreach ($rows ?: array() as $row) { + $submitted[(int) $row['navigation_item_id']] = array('parent_id' => (int) $row['parent_id']); + } + + $levels = array(); + $visiting = array(); + foreach (array_keys($submitted) as $id) { + self::submittedItemLevel($id, $submitted, $levels, $visiting); + } + + foreach ($levels as $id => $level) { + DB::Update(self::itemsTable(), array('level' => (string) $level), 'navigation_item_id=%i', (int) $id); + } + } + + protected static function submittedItemLevel($id, array $items, array &$levels, array &$visiting) + { + $id = (int) $id; + if (isset($levels[$id])) { return $levels[$id]; } + if (isset($visiting[$id])) { + throw new \InvalidArgumentException('В структуре меню обнаружен цикл'); + } + + if (!isset($items[$id])) { + throw new \InvalidArgumentException('Пункт меню не найден'); + } + + $visiting[$id] = true; + $parentId = isset($items[$id]['parent_id']) ? (int) $items[$id]['parent_id'] : 0; + if ($parentId > 0 && !isset($items[$parentId])) { + throw new \InvalidArgumentException('Родитель пункта меню не найден'); + } + + $level = $parentId > 0 ? self::submittedItemLevel($parentId, $items, $levels, $visiting) + 1 : 1; + unset($visiting[$id]); + if ($level > 3) { + throw new \InvalidArgumentException('Меню поддерживает не более трёх уровней'); + } + + $levels[$id] = $level; + return $level; + } + + protected static function nextItemPosition($navigationId, $parentId) + { + return (int) DB::query( + 'SELECT COALESCE(MAX(position), 0) + 1 FROM ' . self::itemsTable() . ' WHERE navigation_id = %i AND parent_id = %i', + (int) $navigationId, + (int) $parentId + )->getValue(); + } + + protected static function navigationAlias($navigationId) + { + return (string) DB::query('SELECT alias FROM ' . self::table() . ' WHERE navigation_id = %i LIMIT 1', (int) $navigationId)->getValue(); + } + + protected static function itemsCount($id) + { + return (int) DB::query('SELECT COUNT(*) FROM ' . self::itemsTable() . ' WHERE navigation_id = %i', (int) $id)->getValue(); + } + + protected static function templateFields() + { + return array('level1', 'level2', 'level3', 'level1_active', 'level2_active', 'level3_active', 'level1_begin', 'level1_end', 'level2_begin', 'level2_end', 'level3_begin', 'level3_end', 'begin', 'end'); + } + + protected static function expandLabel($value) + { + if ((string) $value === '0') { + return 'Активная ветка'; + } + + if ((string) $value === '2') { + return 'Текущий уровень'; + } + + return 'Все уровни'; + } + + protected static function uniqueTitle($base) + { + $base = trim((string) $base); + if ($base === '') { + $base = 'Новая навигация'; + } + + $title = $base; + $i = 2; + while (DB::query('SELECT navigation_id FROM ' . self::table() . ' WHERE title = %s LIMIT 1', $title)->getValue()) { + $title = $base . ' #' . $i; + $i++; + } + + return $title; + } + + protected static function cacheDir($key) + { + return BASEPATH . '/tmp/cache/sql/navigations/' . trim((string) $key, '/'); + } + + protected static function removeFile($file) + { + if (is_file($file)) { + File::delete($file); + } + } + + protected static function removeDir($dir) + { + if (!is_dir($dir)) { + return; + } + + Dir::delete($dir); + } + } diff --git a/adminx/modules/Navigation/assets/navigation.css b/adminx/modules/Navigation/assets/navigation.css new file mode 100644 index 0000000..6adb58c --- /dev/null +++ b/adminx/modules/Navigation/assets/navigation.css @@ -0,0 +1,1213 @@ +.navigation-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 14px; + margin-bottom: 16px; +} +.navigation-panel a, +.navigation-panel a:hover { + text-decoration: none; +} +.navigation-stat { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; +} +.navigation-stat b { + display: block; + font-size: 20px; + line-height: 1.1; + font-weight: 800; + font-variant-numeric: tabular-nums; +} +.navigation-stat span { + font-size: 13px; + color: var(--text-secondary); +} +.navigation-import-card { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; + padding: 15px 16px; +} +.navigation-import-title { + margin-bottom: 3px; + font-weight: 800; + color: var(--text-primary); +} +.navigation-panel-header { + margin-bottom: 18px; +} +.navigation-panel-header h2 { + font-size: 22px; + line-height: 1.2; + font-weight: 800; + letter-spacing: 0; + text-wrap: balance; +} +.navigation-card { + padding: 0; + overflow: hidden; +} +.navigation-filter-card { + margin-bottom: 14px; +} +.navigation-panel .table-scroll { + max-height: none; + overflow-x: hidden; + overflow-y: hidden; +} +.navigation-panel table.table { + min-width: 0; +} +.navigation-panel .table th { + white-space: normal; +} +.navigation-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid var(--border-default); +} +.navigation-section-title { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} +.navigation-section-title h2 { + margin: 0 0 4px; + font-size: 16px; + line-height: 1.25; + font-weight: 700; + text-wrap: balance; +} +.navigation-section-title p { + margin: 0; + font-size: 13px; + line-height: 1.4; + text-wrap: pretty; +} +.navigation-head-icon { + width: 34px; + height: 34px; + border-radius: var(--radius-md); + font-size: 17px; +} +.navigation-toolbar, +.navigation-filter { + display: flex; + align-items: center; + gap: 10px; +} +.navigation-filter { + flex: 1 1 auto; + min-width: 0; +} +.navigation-filter .input-wrap { + flex: 1 1 320px; + min-width: 240px; + max-width: 560px; +} +.navigation-table { + width: 100%; + table-layout: fixed; +} +.navigation-col-id { + width: 6ch; +} +.navigation-col-title { + width: auto; +} +.navigation-col-tag { + width: 250px; +} +.navigation-col-state { + width: 180px; +} +.navigation-col-meta { + width: 110px; +} +.navigation-col-actions { + width: 194px; +} +.navigation-name, +.navigation-meta { + display: grid; + gap: 3px; + min-width: 0; +} +.navigation-name b, +.navigation-name small, +.navigation-meta span { + overflow-wrap: anywhere; +} +.navigation-name small, +.navigation-meta { + color: var(--text-secondary); + font-size: 12px; +} +.navigation-badges { + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.navigation-actions { + gap: 4px; + flex-wrap: nowrap; + justify-content: flex-end; +} +.navigation-actions .btn-icon { + width: 34px; + height: 34px; + min-width: 34px; +} +.navigation-action-edit i, +.navigation-action-items i { + color: var(--blue-600); +} +.navigation-action-copy i { + color: var(--violet-600); +} +.navigation-action-cache i { + color: var(--cyan-600); +} +.navigation-action-danger i { + color: var(--red-600); +} +.navigation-action-locked i { + color: var(--text-tertiary); +} +.navigation-drawer-panel.drawer-lg { + width: 66.6667vw; + max-width: calc(100vw - 34px); +} +.navigation-builder-drawer-panel.drawer-lg { + width: min(78vw, 1480px); + max-width: calc(100vw - 34px); + overflow-x: hidden; +} +.navigation-builder-drawer-head { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.navigation-drawer-title h3 { + margin: 0 0 4px; +} +.navigation-template-layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + align-items: start; + margin-top: 18px; +} +.navigation-groups-field { + min-width: 0; +} +.navigation-groups-dropdown { + display: block; + width: 100%; +} +.navigation-groups-dropdown.open { + z-index: 100; +} +.navigation-groups-trigger { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 42px; + padding: 6px 10px; + color: var(--text-primary); + text-align: left; + background: var(--background-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + cursor: pointer; + transition-property: border-color, box-shadow; + transition-duration: 0.15s; +} +.navigation-groups-trigger:hover, +.navigation-groups-dropdown.open .navigation-groups-trigger { + border-color: var(--blue-400); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--blue-500) 12%, transparent); +} +.navigation-groups-trigger-icon { + display: inline-grid; + place-items: center; + flex: 0 0 30px; + width: 30px; + height: 30px; + color: var(--blue-600); + background: var(--blue-100); + border-radius: var(--radius-xs); +} +.navigation-groups-trigger-copy { + display: grid; + gap: 1px; + min-width: 0; + flex: 1 1 auto; +} +.navigation-groups-trigger-copy b, +.navigation-groups-trigger-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.navigation-groups-trigger-copy b { + font-size: 13px; + font-weight: 700; +} +.navigation-groups-trigger-copy small { + color: var(--text-secondary); + font-size: 11px; +} +.navigation-groups-chevron { + color: var(--text-muted); + transition-property: transform; + transition-duration: 0.15s; +} +.navigation-groups-dropdown.open .navigation-groups-chevron { + transform: rotate(180deg); +} +.navigation-groups-menu { + width: 100%; + min-width: 300px; + max-height: min(420px, 60vh); + padding: 0; + overflow: hidden; +} +.navigation-groups-menu-head, +.navigation-groups-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.navigation-groups-menu-head { + padding: 12px 13px 9px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} +.navigation-groups-actions { + padding: 0 8px 8px; +} +.navigation-groups-actions button { + min-height: 32px; + padding: 5px 8px; + color: var(--blue-600); + font: inherit; + font-size: 12px; + font-weight: 600; + background: transparent; + border: 0; + border-radius: var(--radius-xs); + cursor: pointer; +} +.navigation-groups-actions button:hover { + background: var(--blue-50); +} +.navigation-groups-options { + display: grid; + gap: 2px; + max-height: min(320px, 48vh); + padding: 5px; + overflow-y: auto; + border-top: 1px solid var(--border-default); +} +.navigation-group-option { + position: relative; + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 6px 8px; + border-radius: var(--radius-xs); + cursor: pointer; +} +.navigation-group-option:hover { + background: var(--background-muted); +} +.navigation-group-option input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} +.navigation-group-check { + display: inline-grid; + place-items: center; + flex: 0 0 20px; + width: 20px; + height: 20px; + color: transparent; + border: 1px solid var(--border-strong); + border-radius: 5px; +} +.navigation-group-option input:checked + .navigation-group-check { + color: #fff; + background: var(--blue-600); + border-color: var(--blue-600); +} +.navigation-group-option input:focus-visible + .navigation-group-check { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--blue-500) 18%, transparent); +} +.navigation-group-check i { + font-size: 14px; +} +.navigation-group-copy { + display: grid; + gap: 1px; + min-width: 0; + flex: 1 1 auto; +} +.navigation-group-copy b { + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} +.navigation-group-copy small { + color: var(--text-secondary); + font-size: 11px; +} +.navigation-group-option.is-inactive .navigation-group-copy { + opacity: 0.72; +} +.navigation-groups-empty { + padding: 18px 12px; + color: var(--text-secondary); + font-size: 13px; + text-align: center; +} +.navigation-template-tabs { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 4px; + padding: 4px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-muted); +} +.navigation-template-tab { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + min-width: 0; + min-height: 54px; + padding: 7px 10px; + color: var(--text-secondary); + text-align: left; + border: 0; + border-radius: calc(var(--radius-md) - 3px); + background: transparent; + cursor: pointer; + transition-property: color, background-color, box-shadow; + transition-duration: 0.15s; +} +.navigation-template-tab > i { + display: grid; + place-items: center; + width: 30px; + height: 30px; + color: var(--text-muted); + border-radius: var(--radius-sm); + background: var(--background-surface); + font-size: 15px; +} +.navigation-template-tab > span { + display: grid; + gap: 1px; + min-width: 0; +} +.navigation-template-tab b, +.navigation-template-tab small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.navigation-template-tab b { + color: inherit; + font-size: 13px; +} +.navigation-template-tab small { + color: var(--text-muted); + font-size: 10.5px; +} +.navigation-template-tab em { + display: grid; + place-items: center; + min-width: 21px; + height: 21px; + padding-inline: 5px; + color: var(--text-muted); + border-radius: var(--radius-full); + background: var(--background-surface); + font-size: 10.5px; + font-style: normal; + font-variant-numeric: tabular-nums; +} +.navigation-template-tab:hover { + color: var(--text-primary); + background: color-mix(in srgb, var(--background-surface) 72%, transparent); +} +.navigation-template-tab.is-active { + color: var(--blue-700); + background: var(--background-surface); + box-shadow: var(--shadow-sm); +} +.navigation-template-tab.is-active > i { + color: var(--blue-600); + background: var(--blue-100); +} +.navigation-template-tab.is-active em { + color: var(--blue-700); + background: var(--blue-100); +} +.navigation-template-panels { + min-width: 0; +} +.navigation-template-panel[hidden] { + display: none !important; +} +.navigation-template-panel-head { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 18px 2px 13px; + border-bottom: 1px solid var(--border-default); +} +.navigation-template-panel-head .icon-tile { + flex: 0 0 34px; + width: 34px; + height: 34px; + color: var(--blue-600); + background: var(--blue-100); + font-size: 16px; +} +.navigation-template-panel-head h3, +.navigation-template-panel-head p { + margin: 0; +} +.navigation-template-panel-head h3 { + font-size: 15px; +} +.navigation-template-panel-head p { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; + text-wrap: pretty; +} +.navigation-code-sequence { + display: grid; + gap: 12px; + padding-top: 14px; +} +.navigation-code-field { + position: relative; + min-width: 0; + padding: 14px; + border-radius: var(--radius-md); + background: var(--background-surface); + box-shadow: inset 0 0 0 1px var(--border-default); +} +.navigation-code-field.is-tags-open { + z-index: 12; +} +.navigation-code-field-head { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) 32px; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} +.navigation-code-field-head .field-label, +.navigation-code-field-head p { + margin: 0; +} +.navigation-code-field-head .field-label { + color: var(--text-primary); + font-size: 13px; + font-weight: 700; +} +.navigation-code-field-head p { + margin-top: 2px; + color: var(--text-secondary); + font-size: 11.5px; + line-height: 1.4; + text-wrap: pretty; +} +.navigation-template-step { + display: grid; + place-items: center; + width: 28px; + height: 28px; + color: var(--blue-700); + border-radius: var(--radius-full); + background: var(--blue-100); + font-size: 11px; + font-weight: 800; + font-variant-numeric: tabular-nums; +} +.navigation-tags-button { + color: var(--cyan-600); +} +.navigation-code-field .CodeMirror { + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + font-size: 12.5px; +} +.navigation-tag-palette { + position: absolute; + top: 32px; + right: 0; + z-index: 20; + width: min(620px, calc(200% + 14px)); + margin: 0; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); + box-shadow: var(--shadow-lg); + overflow: hidden; +} +.navigation-template-help { + margin-bottom: 16px; +} +.navigation-template-help > div { + display: grid; + gap: 3px; +} +.navigation-tag-palette[hidden] { + display: none !important; +} +.navigation-tag-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border-bottom: 1px solid var(--border-default); +} +.navigation-tag-toolbar .input-wrap { + flex: 1; +} +.navigation-tag-nav { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 8px; + border-bottom: 1px solid var(--border-default); +} +.navigation-tag-nav button { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 28px; + padding: 3px 10px; + border: 1px solid var(--border-default); + border-radius: var(--radius-full); + background: var(--background-muted); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition-property: background-color, border-color, color, transform; + transition-duration: 150ms; +} +.navigation-tag-nav button:hover, +.navigation-tag-nav button.is-active { + border-color: var(--color-primary); + color: var(--color-primary); +} +.navigation-tag-nav button.is-active { + background: var(--color-primary-soft); +} +.navigation-tag-nav button:active, +.navigation-tag-list button:active { + transform: scale(0.96); +} +.navigation-tag-nav b { + color: var(--text-muted); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} +.navigation-tag-nav button.is-active b { + color: var(--color-primary); +} +.navigation-tag-group { + display: none; + max-height: 260px; + padding: 8px; + overflow-y: auto; +} +.navigation-tag-group.is-active, +.navigation-tag-palette.is-searching .navigation-tag-group { + display: block; +} +.navigation-tag-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 6px; +} +.navigation-tag-list button { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + padding: 6px 8px; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: var(--background-surface); + color: var(--text-primary); + text-align: left; + cursor: pointer; + transition-property: background-color, border-color, color, transform; + transition-duration: 150ms; +} +.navigation-tag-list button:hover { + border-color: var(--color-primary); + background: var(--color-primary-soft); +} +.navigation-tag-list code, +.navigation-tag-list span { + overflow: hidden; + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; +} +.navigation-tag-list code { + color: var(--color-primary); + font-size: 12px; +} +.navigation-tag-list span { + color: var(--text-muted); + font-size: 11px; +} +.navigation-tag-empty { + padding: 14px; + color: var(--text-muted); + font-size: 13px; + text-align: center; +} +.navigation-tag-empty[hidden] { + display: none !important; +} +.navigation-builder { + display: flex; + flex-direction: column; + min-height: 0; + padding: 16px; + overflow-x: hidden; + overflow-y: hidden; + background: var(--background-muted); +} +.navigation-builder-layout { + display: grid; + flex: 1 1 auto; + grid-template-columns: minmax(0, 1.05fr) minmax(380px, 0.95fr); + gap: 16px; + align-items: stretch; + min-height: 0; +} +.navigation-builder .dual-panel { + min-height: 0; +} +.navigation-builder-tree-panel, +.navigation-builder-props { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--surface-card); + box-shadow: none; + overflow: hidden; +} +.navigation-builder-panel-head { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + min-height: 64px; + padding: 12px 14px; + border-bottom: 1px solid var(--border-subtle); +} +.navigation-builder-panel-head h2, +.navigation-builder-panel-head p { + margin: 0; + letter-spacing: 0; +} +.navigation-builder-panel-head h2 { + font-size: 14px; + line-height: 1.25; + font-weight: 700; + text-wrap: balance; +} +.navigation-builder-panel-head p { + margin-top: 2px; + color: var(--text-secondary); + font-size: 11.5px; + line-height: 1.3; + text-wrap: pretty; +} +.navigation-mini-icon { + width: 30px; + height: 30px; + border-radius: var(--radius-sm); + font-size: 15px; +} +.navigation-items-tree { + position: relative; + flex: 1; + overflow-x: hidden; + overflow-y: auto; + min-height: 0; + padding: 12px; +} +.navigation-items-tree.is-drag-active { + user-select: none; +} +.navigation-items-tree.is-order-saving::after { + position: absolute; + inset: 0; + z-index: 4; + content: ''; + cursor: wait; + background: rgba(248, 250, 252, 0.36); +} +.navigation-items-tree ol { + display: flex; + flex-direction: column; + gap: 7px; + width: 100%; + min-width: 0; + margin: 0; + padding: 0; + list-style: none; +} +.navigation-items-tree li { + width: 100%; + max-width: 100%; + min-width: 0; +} +.navigation-tree-item { + min-height: 48px; + cursor: pointer; + transition-property: border-color, box-shadow, background, opacity; + transition-duration: 0.15s; +} +.navigation-tree-item.is-selected { + border-color: var(--blue-400); + background: var(--blue-50); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12); +} +.navigation-items-tree li.is-dragging, +.navigation-items-tree li.is-dragging-child { + display: none; +} +.navigation-drag-placeholder { + min-height: 48px; + border: 1px dashed var(--blue-500); + border-radius: var(--radius-md); + background: var(--blue-50); + box-shadow: inset 0 0 0 3px rgba(59, 130, 246, 0.08); +} +.navigation-tree-item.drag-over-top { + box-shadow: inset 0 2px 0 var(--blue-500); +} +.navigation-tree-item.drag-over-bottom { + box-shadow: inset 0 -2px 0 var(--blue-500); +} +.navigation-tree-item.mb-level-0 { + margin-left: 0; +} +.navigation-tree-item.mb-level-1 { + margin-left: 28px; +} +.navigation-tree-item.mb-level-2 { + margin-left: 56px; +} +.navigation-drag-handle { + display: grid; + flex: 0 0 28px; + place-items: center; + width: 28px; + height: 30px; + padding: 0; + border: 0; + border-radius: var(--radius-sm); + outline: 0; + background: transparent; + color: var(--text-muted); + cursor: grab; + touch-action: none; + user-select: none; + -webkit-user-select: none; +} +.navigation-drag-handle:hover, +.navigation-drag-handle:focus-visible { + background: var(--background-muted); + color: var(--blue-600); +} +.navigation-drag-handle:active { + cursor: grabbing; +} +.navigation-item-dragging, +.navigation-item-dragging * { + cursor: grabbing !important; +} +.navigation-drag-ghost { + position: fixed; + top: 0; + left: 0; + z-index: 3000; + min-height: 46px; + max-width: calc(100vw - 24px); + margin: 0; + box-sizing: border-box; + pointer-events: none; + opacity: 0.96; + box-shadow: 0 14px 34px rgba(15, 23, 42, 0.2); + will-change: transform; +} +.navigation-drag-ghost .navigation-tree-meta, +.navigation-drag-ghost .navigation-tree-actions { + display: none; +} +.navigation-drag-ghost .navigation-tree-main span { + max-width: 220px; +} +.navigation-tree-main { + display: grid; + gap: 2px; + min-width: 0; + flex: 1 1 auto; +} +.navigation-tree-main b, +.navigation-tree-main span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.navigation-tree-main b { + font-weight: 700; +} +.navigation-tree-main span { + color: var(--text-secondary); + font-size: 12px; +} +.navigation-tree-meta, +.navigation-tree-actions { + display: flex; + align-items: center; + gap: 5px; + flex-wrap: nowrap; +} +.navigation-tree-actions { + margin-left: auto; +} +.navigation-tree-actions .btn-icon { + width: 32px; + height: 32px; + min-width: 32px; +} +.navigation-tree-actions .btn-icon:disabled { + opacity: 0.28; + cursor: default; +} +.navigation-action-level i { + color: var(--blue-600); +} +.navigation-builder-props .navigation-item-form { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; +} +.navigation-item-fields { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 14px; +} +.navigation-picker-input { + display: grid; + grid-template-columns: minmax(0, 1fr) 40px 40px; + gap: 6px; + align-items: center; +} +.navigation-picker-input > .btn-icon { + width: 40px; + min-width: 40px; + height: 40px; +} +.navigation-picker-input > [hidden] { + display: none !important; +} +.navigation-image-selection { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; + margin-top: 8px; + padding: 7px; + border-radius: var(--radius-md); + background: var(--background-muted); +} +.navigation-image-selection[hidden] { + display: none; +} +.navigation-image-selection img { + width: 42px; + height: 42px; + flex: 0 0 42px; + border-radius: var(--radius-sm); + object-fit: cover; + outline: 1px solid rgba(0, 0, 0, 0.1); + outline-offset: -1px; +} +.navigation-image-selection span { + overflow: hidden; + min-width: 0; + color: var(--text-secondary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} +.navigation-document-picker .modal-body, +.navigation-image-picker .modal-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 12px; + min-height: 0; + overflow: hidden; +} +.navigation-document-search { + flex: 0 0 auto; +} +.navigation-picker-status { + padding: 26px 14px; + border-radius: var(--radius-md); + background: var(--background-muted); + color: var(--text-secondary); + text-align: center; +} +.navigation-document-list { + display: grid; + align-content: start; + gap: 7px; + min-height: 0; + overflow-y: auto; + padding: 1px 2px 4px; +} +.navigation-document-item { + display: grid; + grid-template-columns: 64px minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + min-height: 54px; + padding: 8px 12px; + border: 0; + border-radius: var(--radius-md); + background: var(--surface-card); + color: var(--text-primary); + box-shadow: inset 0 0 0 1px var(--border-default); + cursor: pointer; + text-align: left; + transition-property: background-color, box-shadow, transform; + transition-duration: 0.15s; +} +.navigation-document-item:hover { + background: var(--blue-50); + box-shadow: inset 0 0 0 1px var(--blue-400); +} +.navigation-document-item:active { + transform: scale(0.96); +} +.navigation-document-id, +.navigation-picker-count { + color: var(--text-secondary); + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.navigation-document-main { + display: grid; + gap: 2px; + min-width: 0; +} +.navigation-document-main b, +.navigation-document-main small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.navigation-document-main small { + color: var(--text-secondary); +} +.navigation-builder-empty { + display: grid; + place-items: center; + align-content: center; + gap: 10px; + min-height: 260px; + color: var(--text-secondary); + text-align: center; +} +.navigation-builder-empty[hidden], +.navigation-item-fields[hidden] { + display: none !important; +} +.navigation-builder-footer { + align-items: center; + background: var(--background-muted); +} +.navigation-alias-state.is-ok { + color: var(--green-600); +} +.navigation-alias-state.is-error { + color: var(--red-600); +} +.navigation-alias-state.is-pending { + color: var(--amber-600); +} +@media (max-width: 980px) { + .navigation-template-layout { + grid-template-columns: 1fr; + } + .navigation-tag-palette { + position: static; + width: 100%; + max-height: none; + } + .navigation-tag-groups { + max-height: none; + } + .navigation-tag-groups section > div { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + } + .navigation-template-tabs { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .navigation-builder-layout { + flex: 0 0 auto; + grid-template-columns: 1fr; + } + .navigation-builder { + overflow-y: auto; + } + .navigation-col-state, + .navigation-col-meta, + .navigation-table th:nth-child(4), + .navigation-table th:nth-child(5), + .navigation-table td:nth-child(4), + .navigation-table td:nth-child(5) { + display: none; + } + .navigation-col-title { + width: auto; + } + .navigation-col-tag { + width: 250px; + } + .navigation-col-actions { + width: 136px; + } +} +@media (max-width: 760px) { + .navigation-drawer-panel.drawer-lg, + .navigation-builder-drawer-panel.drawer-lg { + width: 100%; + max-width: 100%; + } + .navigation-template-tabs { + grid-template-columns: 1fr 1fr; + } + .navigation-template-tab { + grid-template-columns: 28px minmax(0, 1fr) auto; + padding: 6px 8px; + } + .navigation-template-tab small { + display: none; + } + .navigation-code-field { + padding: 11px; + } + .navigation-code-field-head { + grid-template-columns: 26px minmax(0, 1fr) 32px; + gap: 8px; + } + .navigation-builder-footer > [data-close-drawer], + .navigation-builder-footer > [data-navigation-item-reset] { + flex: 0 0 40px; + width: 40px; + min-width: 40px; + padding-inline: 0; + font-size: 0; + } + .navigation-builder-footer > [data-close-drawer] i, + .navigation-builder-footer > [data-navigation-item-reset] i { + margin: 0; + font-size: 16px; + } + .navigation-document-item { + grid-template-columns: 54px minmax(0, 1fr); + } + .navigation-document-item > .badge { + display: none; + } + .navigation-filter, + .navigation-toolbar { + align-items: stretch; + flex-direction: column; + } + .navigation-filter .input-wrap { + flex: 0 0 auto; + max-width: none; + min-width: 0; + width: 100%; + } + .navigation-section-head { + align-items: flex-start; + flex-direction: column; + } + .navigation-col-id, + .navigation-table th:first-child, + .navigation-table td:first-child { + display: none; + } + .navigation-col-tag, + .navigation-table th:nth-child(3), + .navigation-table td:nth-child(3) { + display: none; + } + .navigation-col-title { + width: auto; + } + .navigation-col-actions { + width: 120px; + } + .navigation-actions { + display: grid; + grid-template-columns: repeat(3, 30px); + justify-content: end; + gap: 5px; + } + .navigation-actions .btn-icon { + width: 30px; + height: 30px; + min-width: 30px; + } + .navigation-tree-actions { + justify-content: flex-end; + } + .navigation-tree-meta { + display: none; + } +} diff --git a/adminx/modules/Navigation/assets/navigation.js b/adminx/modules/Navigation/assets/navigation.js new file mode 100644 index 0000000..e0e05b7 --- /dev/null +++ b/adminx/modules/Navigation/assets/navigation.js @@ -0,0 +1,1255 @@ +/** + * JS раздела «Навигация»: drawer-CRUD, импорт legacy, фильтры и копирование тегов. + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.Navigation = { + form: null, + itemForm: null, + filterTimer: null, + aliasTimer: null, + aliasState: 'empty', + currentNavigationId: 0, + currentNavigationFlat: [], + currentItemId: 0, + dragItem: null, + dragGroup: [], + dragPlaceholder: null, + dragGhost: null, + dragPointerId: null, + dragStartX: 0, + dragStartY: 0, + dragStarted: false, + dragOrderSnapshot: '', + orderSaving: false, + activeTemplateEditor: null, + + init: function () { + this.form = document.getElementById('navigationForm'); + this.itemForm = document.getElementById('navigationItemForm'); + var self = this; + + document.addEventListener('click', function (e) { + if (e.target.closest('[data-navigation-new]')) { self.fillNew(); } + if (e.target.closest('[data-navigation-filter-reset]')) { self.resetFilters(); } + + var edit = e.target.closest('[data-navigation-edit]'); + if (edit) { self.fillEdit(edit.closest('[data-navigation-row]')); } + + var items = e.target.closest('[data-navigation-items]'); + if (items) { self.openItems(items.closest('[data-navigation-row]')); } + + if (e.target.closest('[data-navigation-item-new]')) { self.fillItemNew(); } + if (e.target.closest('[data-navigation-item-reset]')) { self.reloadCurrentItem(); } + var itemEdit = e.target.closest('[data-navigation-item-edit]'); + if (itemEdit) { self.fillItemEdit(itemEdit.getAttribute('data-navigation-item-edit')); } + var itemPick = e.target.closest('[data-navigation-builder-item]'); + if (itemPick && !e.target.closest('button, .navigation-drag-handle')) { self.fillItemEdit(itemPick.getAttribute('data-navigation-builder-item')); } + var itemToggle = e.target.closest('[data-navigation-item-toggle]'); + if (itemToggle) { self.toggleItem(itemToggle.getAttribute('data-navigation-item-toggle')); } + var itemDelete = e.target.closest('[data-navigation-item-delete]'); + if (itemDelete) { self.deleteItem(itemDelete.getAttribute('data-navigation-item-delete')); } + var itemIndent = e.target.closest('[data-navigation-item-indent]'); + if (itemIndent) { self.changeItemLevel(itemIndent.closest('[data-navigation-item-node]'), parseInt(itemIndent.getAttribute('data-navigation-item-indent'), 10) || 0); } + if (e.target.closest('[data-navigation-document-pick]')) { self.openDocumentPicker(); } + if (e.target.closest('[data-navigation-document-clear]')) { self.clearPickedDocument(); } + if (e.target.closest('[data-navigation-image-pick]')) { self.openImagePicker(); } + if (e.target.closest('[data-navigation-image-clear]')) { self.applyPickedImage(''); } + + var tagsToggle = e.target.closest('[data-navigation-tags-toggle]'); + if (tagsToggle) { e.preventDefault(); self.openTagPalette(tagsToggle.closest('.navigation-code-field')); } + var templateTab = e.target.closest('[data-navigation-template-tab]'); + if (templateTab) { self.activateTemplateSection(templateTab.getAttribute('data-navigation-template-tab')); } + if (e.target.closest('[data-navigation-tags-close]')) { self.closeTagPalette(); } + var tagTab = e.target.closest('[data-navigation-tag-tab]'); + if (tagTab) { self.activateTagGroup(tagTab.getAttribute('data-navigation-tag-tab')); } + var templateTag = e.target.closest('[data-navigation-template-tag]'); + if (templateTag) { self.insertTemplateTag(templateTag); } + + var copy = e.target.closest('[data-navigation-copy]'); + if (copy) { self.copy(copy.closest('[data-navigation-row]')); } + + var cache = e.target.closest('[data-navigation-cache]'); + if (cache) { self.clearCache(cache.closest('[data-navigation-row]')); } + + var del = e.target.closest('[data-navigation-delete]'); + if (del) { self.remove(del.closest('[data-navigation-row]')); } + + if (e.target.closest('[data-navigation-submit-stay]')) { self.submit(true); } + }); + + if (this.form) { + this.form.addEventListener('submit', function (e) { + e.preventDefault(); + self.submit(false); + }); + } + if (this.itemForm) { + this.itemForm.addEventListener('submit', function (e) { + e.preventDefault(); + self.submitItem(); + }); + this.itemForm.addEventListener('input', function (e) { + if (e.target && e.target.matches('[data-navigation-image-input]')) { self.renderPickedImage(e.target.value); } + }); + } + + document.addEventListener('submit', function (e) { + var filter = e.target.closest('.navigation-filter'); + if (!filter) { return; } + e.preventDefault(); + self.applyFilters(filter, true); + }); + + document.addEventListener('input', function (e) { + if (e.target.matches('[data-navigation-tags-search]')) { + self.filterTags(e.target.value); + return; + } + var filter = e.target.closest('.navigation-filter'); + if (!filter || !e.target.matches('input[type="search"]')) { return; } + clearTimeout(self.filterTimer); + self.filterTimer = setTimeout(function () { self.applyFilters(filter, true); }, 350); + }); + + document.addEventListener('focusin', function (e) { + if (e.target && e.target.matches && e.target.matches('#navigationForm textarea[data-code-editor]')) { + self.activeTemplateEditor = e.target._adminxCodeMirror || null; + } + }); + + document.addEventListener('input', function (e) { + if (!self.form || e.target !== self.field('alias')) { return; } + self.scheduleAliasCheck(); + }); + + document.addEventListener('change', function (e) { + if (e.target && e.target.matches('[data-navigation-group-option]')) { + self.syncGroupAccess(); + } + }); + + document.addEventListener('click', function (e) { + if (e.target.closest('[data-navigation-groups-all]')) { + self.selectAllGroups(true); + } else if (e.target.closest('[data-navigation-groups-none]')) { + self.selectAllGroups(false); + } + }); + + document.addEventListener('blur', function (e) { + if (!self.form || e.target !== self.field('alias')) { return; } + self.checkAlias(true); + }, true); + + window.addEventListener('popstate', function () { + self.applyFilterUrl(window.location.href, false); + }); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && self.isTagPaletteOpen()) { + self.closeTagPalette(); + return; + } + if (e.target && e.target.closest && e.target.closest('.CodeMirror')) { + return; + } + if ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 's' && self.isDrawerOpen()) { + e.preventDefault(); + self.submit(true); + } + }); + document.addEventListener('pointerdown', function (e) { self.itemDragStart(e); }); + document.addEventListener('pointermove', function (e) { self.itemDragOver(e); }); + document.addEventListener('pointerup', function (e) { self.itemDrop(e); }); + document.addEventListener('pointercancel', function () { self.itemDragEnd(); }); + this.openFromLocation(); + }, + + openFromLocation: function () { + var id = parseInt(new URLSearchParams(window.location.search).get('edit'), 10) || 0; + if (!id) { return; } + var row = document.querySelector('[data-navigation-row][data-id="' + id + '"]'); + if (!row) { return; } + if (Adminx.Drawer) { Adminx.Drawer.open('navigationDrawer'); } + this.fillEdit(row); + }, + + base: function () { return (this.form && this.form.getAttribute('data-base')) || Adminx.base(); }, + field: function (name) { return this.form.querySelector('[name="' + name + '"]'); }, + itemField: function (name) { return this.itemForm.querySelector('[name="' + name + '"]'); }, + + filterUrl: function (form) { + var params = new URLSearchParams(new FormData(form)); + Array.from(params.keys()).forEach(function (key) { + if (String(params.get(key) || '') === '') { params.delete(key); } + }); + var query = params.toString(); + return (form.getAttribute('action') || (this.base() + '/navigation')) + (query ? '?' + query : ''); + }, + + applyFilters: function (form, push) { + if (!form) { return; } + this.applyFilterUrl(this.filterUrl(form), push); + }, + + applyFilterUrl: function (url, push) { + var self = this; + Adminx.Loader.show(); + fetch(url, { method: 'GET', headers: { 'Accept': 'text/html' }, credentials: 'same-origin' }) + .then(function (res) { + return res.text().then(function (html) { + if (!res.ok) { throw new Error('HTTP ' + res.status); } + self.replaceList(html, url, push); + }); + }) + .catch(function () { Adminx.Toast.show('Не удалось применить фильтры', 'error'); }) + .finally(function () { Adminx.Loader.hide(); }); + }, + + replaceList: function (html, url, push) { + var doc = new DOMParser().parseFromString(html, 'text/html'); + ['.navigation-summary', '.navigation-import-card', '.navigation-panel'].forEach(function (selector) { + var next = doc.querySelector(selector); + var current = document.querySelector(selector); + if (next && current) { current.replaceWith(next); } + }); + if (push && window.history && window.history.pushState) { + window.history.pushState({ adminxNavigationFilters: true }, '', url); + } + }, + + resetFilters: function () { + var form = document.querySelector('.navigation-filter'); + if (!form) { return; } + form.reset(); + this.applyFilters(form, true); + }, + + isDrawerOpen: function () { + var drawer = document.getElementById('navigationDrawer'); + return !!(drawer && !drawer.hidden && this.form); + }, + + fillNew: function () { + if (!this.form) { return; } + this.closeTagPalette(); + this.form.reset(); + this.field('id').value = ''; + this.field('title').value = ''; + this.field('alias').value = ''; + this.field('expand_ext').value = '1'; + this.selectAllGroups(true); + var templates = { + begin: '', + level1_begin: '' + }; + Object.keys(templates).forEach(function (key) { this.field(key).value = templates[key]; }, this); + this.activateTemplateSection('base'); + this.syncEditors(); + this.clearErrors(); + this.setAliasState('', ''); + document.getElementById('navigationDrawerTitle').textContent = 'Новая навигация'; + setTimeout(function () { if (Adminx.CodeEditor) { Adminx.CodeEditor.refreshAll(); } }, 120); + }, + + fillEdit: function (row) { + if (!row) { return; } + this.closeTagPalette(); + var self = this; + var id = row.getAttribute('data-id'); + Adminx.Loader.show(); + fetch(this.base() + '/navigation/' + encodeURIComponent(id), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(this.json) + .then(function (payload) { + var item = payload.data || {}; + self.form.reset(); + Object.keys(item).forEach(function (key) { + var field = self.field(key); + if (field) { field.value = item[key] == null ? '' : item[key]; } + }); + self.field('id').value = item.navigation_id || ''; + self.setGroupAccess(item.user_group || ''); + document.getElementById('navigationDrawerTitle').textContent = item.title || 'Навигация'; + self.clearErrors(); + self.setAliasState('ok', item.alias ? 'Алиас сохранён' : ''); + self.activateTemplateSection('base'); + self.syncEditors(); + setTimeout(function () { if (Adminx.CodeEditor) { Adminx.CodeEditor.refreshAll(); } }, 120); + }) + .catch(function (err) { Adminx.Toast.show(err.message || 'Не удалось загрузить навигацию', 'error'); }) + .finally(function () { Adminx.Loader.hide(); }); + }, + + setGroupAccess: function (value) { + var selected = String(value || '').split(',').map(function (id) { return String(parseInt(id, 10) || ''); }); + document.querySelectorAll('[data-navigation-group-option]').forEach(function (option) { + option.checked = selected.indexOf(String(option.value)) !== -1; + }); + this.syncGroupAccess(); + }, + + selectAllGroups: function (checked) { + document.querySelectorAll('[data-navigation-group-option]').forEach(function (option) { + option.checked = !!checked; + }); + this.syncGroupAccess(); + }, + + syncGroupAccess: function () { + if (!this.form) { return; } + var options = Array.prototype.slice.call(document.querySelectorAll('[data-navigation-group-option]')); + var selected = options.filter(function (option) { return option.checked; }); + var input = this.field('user_group'); + var summary = document.querySelector('[data-navigation-groups-summary]'); + var detail = document.querySelector('[data-navigation-groups-detail]'); + var count = document.querySelector('[data-navigation-groups-count]'); + if (input) { input.value = selected.map(function (option) { return option.value; }).join(','); } + if (count) { count.textContent = selected.length + ' из ' + options.length; } + if (!summary || !detail) { return; } + if (!selected.length) { + summary.textContent = 'Никому не доступна'; + detail.textContent = 'Выберите хотя бы одну группу'; + } else if (selected.length === options.length) { + summary.textContent = 'Все группы'; + detail.textContent = selected.map(function (option) { return option.getAttribute('data-group-name'); }).join(', '); + } else if (selected.length === 1) { + summary.textContent = selected[0].getAttribute('data-group-name') || ('Группа #' + selected[0].value); + detail.textContent = 'Выбрана 1 группа'; + } else { + summary.textContent = 'Выбрано групп: ' + selected.length; + detail.textContent = selected.map(function (option) { return option.getAttribute('data-group-name'); }).join(', '); + } + }, + + syncEditors: function () { + var self = this; + Array.prototype.forEach.call(this.form.querySelectorAll('textarea[data-code-editor]'), function (textarea) { + if (textarea._adminxCodeMirror) { + textarea._adminxCodeMirror.setValue(textarea.value || ''); + textarea._adminxCodeMirror.on('focus', function (cm) { self.activeTemplateEditor = cm; }); + setTimeout(function () { textarea._adminxCodeMirror.refresh(); }, 40); + } + }); + }, + + activateTemplateSection: function (name) { + var panel = null; + name = String(name || 'base'); + this.closeTagPalette(); + document.querySelectorAll('[data-navigation-template-tab]').forEach(function (tab) { + var active = tab.getAttribute('data-navigation-template-tab') === name; + tab.classList.toggle('is-active', active); + tab.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + document.querySelectorAll('[data-navigation-template-panel]').forEach(function (item) { + var active = item.getAttribute('data-navigation-template-panel') === name; + item.hidden = !active; + if (active) { panel = item; } + }); + if (panel) { + setTimeout(function () { + panel.querySelectorAll('textarea[data-code-editor]').forEach(function (textarea) { + if (textarea._adminxCodeMirror) { textarea._adminxCodeMirror.refresh(); } + }); + }, 30); + } + }, + + isTagPaletteOpen: function () { + var palette = document.querySelector('[data-navigation-tags]'); + return !!(palette && !palette.hidden); + }, + + openTagPalette: function (field) { + var palette = document.querySelector('[data-navigation-tags]'); + var textarea = field ? field.querySelector('textarea[data-code-editor]') : null; + var anchor = textarea && textarea._adminxCodeMirror ? textarea._adminxCodeMirror.getWrapperElement() : textarea; + var search; + if (!palette || !field || !textarea) { return; } + document.querySelectorAll('.navigation-code-field.is-tags-open').forEach(function (item) { item.classList.remove('is-tags-open'); }); + field.classList.add('is-tags-open'); + this.activeTemplateEditor = textarea._adminxCodeMirror || this.activeTemplateEditor; + field.insertBefore(palette, anchor || field.firstChild); + palette.hidden = false; + search = palette.querySelector('[data-navigation-tags-search]'); + if (search) { + search.value = ''; + this.filterTags(''); + search.focus(); + } + }, + + closeTagPalette: function () { + var palette = document.querySelector('[data-navigation-tags]'); + if (palette) { palette.hidden = true; } + document.querySelectorAll('.navigation-code-field.is-tags-open').forEach(function (item) { item.classList.remove('is-tags-open'); }); + }, + + activateTagGroup: function (name) { + var palette = document.querySelector('[data-navigation-tags]'); + if (!palette) { return; } + palette.querySelectorAll('[data-navigation-tag-tab]').forEach(function (tab) { + tab.classList.toggle('is-active', tab.getAttribute('data-navigation-tag-tab') === String(name)); + }); + palette.querySelectorAll('[data-navigation-tag-panel]').forEach(function (group) { + group.classList.toggle('is-active', group.getAttribute('data-navigation-tag-panel') === String(name)); + }); + }, + + filterTags: function (query) { + var palette = document.querySelector('[data-navigation-tags]'); + var shown = 0; + if (!palette) { return; } + query = String(query || '').trim().toLowerCase(); + palette.classList.toggle('is-searching', query !== ''); + palette.querySelectorAll('[data-navigation-template-tag]').forEach(function (button) { + var visible = query === '' || button.textContent.toLowerCase().indexOf(query) !== -1; + button.hidden = !visible; + if (visible) { shown++; } + }); + palette.querySelectorAll('[data-navigation-tag-group]').forEach(function (group) { + group.hidden = query !== '' && !group.querySelector('[data-navigation-template-tag]:not([hidden])'); + }); + var empty = palette.querySelector('[data-navigation-tags-empty]'); + if (empty) { empty.hidden = shown > 0; } + }, + + insertTemplateTag: function (button) { + var value = button.getAttribute('data-navigation-template-tag') || ''; + var select = button.getAttribute('data-navigation-template-select') || value; + var editor = this.activeTemplateEditor || this.editorFallback(); + if (!value || !editor) { return; } + editor.replaceSelection(value); + var cursor = editor.getCursor(); + if (select && value.indexOf(select) !== -1) { + var from = editor.indexFromPos(cursor) - value.length + value.indexOf(select); + editor.setSelection(editor.posFromIndex(from), editor.posFromIndex(from + select.length)); + } + editor.focus(); + editor.save(); + this.closeTagPalette(); + }, + + editorFallback: function () { + var textarea = this.form ? this.form.querySelector('textarea[data-code-editor]') : null; + return textarea && textarea._adminxCodeMirror ? textarea._adminxCodeMirror : null; + }, + + saveEditors: function () { + Array.prototype.forEach.call(this.form.querySelectorAll('textarea[data-code-editor]'), function (textarea) { + if (textarea._adminxCodeMirror) { textarea._adminxCodeMirror.save(); } + }); + }, + + scheduleAliasCheck: function () { + var self = this; + clearTimeout(this.aliasTimer); + this.setAliasState('pending', 'Проверяем алиас...'); + this.aliasTimer = setTimeout(function () { self.checkAlias(false); }, 350); + }, + + checkAlias: function (force) { + if (!this.form) { return Promise.resolve(false); } + var alias = this.field('alias').value || ''; + var id = this.field('id').value || '0'; + if (!alias && !force) { + this.setAliasState('empty', ''); + return Promise.resolve(false); + } + var self = this; + return fetch(this.base() + '/navigation/alias-check?alias=' + encodeURIComponent(alias) + '&id=' + encodeURIComponent(id), { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }).then(this.json).then(function (payload) { + var data = payload.data || {}; + self.setAliasState(data.valid && data.available ? 'ok' : 'error', payload.message || ''); + return !!(data.valid && data.available); + }).catch(function () { + self.setAliasState('error', 'Не удалось проверить алиас'); + return false; + }); + }, + + setAliasState: function (state, message) { + this.aliasState = state || 'empty'; + var node = document.querySelector('[data-navigation-alias-state]'); + var field = this.form ? this.field('alias') : null; + if (node) { + node.textContent = message || ''; + node.className = 'field-hint navigation-alias-state is-' + this.aliasState; + } + if (field) { + field.classList.toggle('is-invalid', this.aliasState === 'error'); + } + }, + + submit: function (stay) { + if (!this.form) { return; } + var self = this; + this.saveEditors(); + this.clearErrors(); + this.checkAlias(true).then(function (ok) { + if (!ok) { + Adminx.Toast.show('Проверьте алиас перед сохранением', 'error'); + return; + } + var id = self.field('id').value; + var url = id ? self.base() + '/navigation/' + encodeURIComponent(id) : self.base() + '/navigation'; + self.post(url, new FormData(self.form)) + .then(function (payload) { + if (stay && payload.data && payload.data.id) { + self.field('id').value = payload.data.id; + } + self.ajaxRefresh(payload.message || 'Сохранено', stay); + }) + .catch(function (err) { self.showErrors(err); }); + }); + }, + + copy: function (row) { + if (!row) { return; } + var id = row.getAttribute('data-id'); + var data = new FormData(); + data.append('_csrf', this.csrf()); + data.append('title', (row.getAttribute('data-title') || 'Навигация') + ' (копия)'); + var self = this; + this.post(this.base() + '/navigation/' + encodeURIComponent(id) + '/copy', data) + .then(function (payload) { self.ajaxRefresh(payload.message || 'Копия создана'); }) + .catch(function (err) { Adminx.Toast.show(err.message || 'Не удалось создать копию', 'error'); }); + }, + + clearCache: function (row) { + if (!row) { return; } + var data = new FormData(); + data.append('_csrf', this.csrf()); + var self = this; + this.post(this.base() + '/navigation/' + encodeURIComponent(row.getAttribute('data-id')) + '/clear-cache', data) + .then(function (payload) { self.ajaxRefresh(payload.message || 'Кеш очищен', true); }) + .catch(function (err) { Adminx.Toast.show(err.message || 'Не удалось очистить кеш', 'error'); }); + }, + + remove: function (row) { + if (!row) { return; } + var data = new FormData(); + data.append('_csrf', this.csrf()); + var self = this; + var run = function () { + self.post(self.base() + '/navigation/' + encodeURIComponent(row.getAttribute('data-id')) + '/delete', data) + .then(function (payload) { self.ajaxRefresh(payload.message || 'Удалено'); }) + .catch(function (err) { Adminx.Toast.show(err.message || 'Не удалось удалить', 'error'); }); + }; + if (Adminx.Confirm) { + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить навигацию', + message: 'Будут удалены шаблон меню и все его пункты.', + confirmLabel: 'Удалить', + onConfirm: run + }); + } else { + run(); + } + }, + + openItems: function (row) { + if (!row) { return; } + this.currentNavigationId = parseInt(row.getAttribute('data-id'), 10) || 0; + this.currentItemId = 0; + this.setItemFormMode('empty'); + this.loadItems(); + }, + + loadItems: function () { + var self = this; + if (!this.currentNavigationId) { return; } + Adminx.Loader.show(); + fetch(this.base() + '/navigation/' + encodeURIComponent(this.currentNavigationId) + '/items', { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }).then(this.json).then(function (payload) { + var data = payload.data || {}; + self.currentNavigationFlat = data.flat || []; + var title = document.getElementById('navigationItemsDrawerTitle'); + var subtitle = document.querySelector('[data-navigation-items-subtitle]'); + var count = document.querySelector('[data-navigation-items-count]'); + if (title) { title.textContent = data.navigation ? data.navigation.title : 'Пункты навигации'; } + if (subtitle) { subtitle.textContent = data.navigation ? data.navigation.tag : 'Структура меню'; } + if (count) { count.textContent = String(self.currentNavigationFlat.length); } + self.renderItems(data.items || []); + if (self.currentItemId) { + self.markSelectedItem(self.currentItemId); + } + }).catch(function (err) { + Adminx.Toast.show(err.message || 'Не удалось загрузить пункты', 'error'); + }).finally(function () { Adminx.Loader.hide(); }); + }, + + renderItems: function (items) { + var root = document.querySelector('[data-navigation-items-tree]'); + if (!root) { return; } + if (!items.length) { + root.innerHTML = '
    Пункты не найдены.
    '; + return; + } + var flat = []; + this.flattenItems(items, flat); + root.innerHTML = '
      ' + flat.map(this.itemHtml.bind(this)).join('') + '
    '; + this.normalizeItemHierarchy(); + }, + + flattenItems: function (items, out) { + items.forEach(function (item) { + out.push(item); + if (item.children && item.children.length) { this.flattenItems(item.children, out); } + }, this); + }, + + itemHtml: function (item) { + var level = Math.max(0, Math.min(2, (parseInt(item.level, 10) || 1) - 1)); + var status = String(item.status) === '1' ? 'активен' : 'выкл'; + return '
  • '; + }, + + fillItemNew: function () { + if (!this.itemForm) { return; } + this.itemForm.reset(); + this.currentItemId = 0; + this.itemField('navigation_id').value = this.currentNavigationId || ''; + this.itemField('navigation_item_id').value = ''; + this.itemField('target').value = '_self'; + this.renderPickedDocument(null); + this.applyPickedImage(''); + this.populateParents(0); + this.clearItemErrors(); + this.setItemFormMode('new', 'Новый пункт меню'); + this.markSelectedItem(0); + var title = this.itemField('title'); + if (title) { setTimeout(function () { title.focus(); }, 40); } + }, + + fillItemEdit: function (id) { + var self = this; + fetch(this.base() + '/navigation/items/' + encodeURIComponent(id), { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }).then(this.json).then(function (payload) { + var item = payload.data || {}; + self.itemForm.reset(); + Object.keys(item).forEach(function (key) { + var field = self.itemField(key); + if (field) { field.value = item[key] == null ? '' : item[key]; } + }); + self.currentItemId = parseInt(item.navigation_item_id, 10) || 0; + self.itemField('navigation_item_id').value = item.navigation_item_id || ''; + self.itemField('navigation_id').value = item.navigation_id || self.currentNavigationId || ''; + self.populateParents(item.parent_id || 0, item.navigation_item_id || 0); + self.renderPickedDocument(item); + self.applyPickedImage(item.image || ''); + self.clearItemErrors(); + self.setItemFormMode('edit', item.title || 'Пункт меню'); + self.markSelectedItem(self.currentItemId); + }).catch(function (err) { + Adminx.Toast.show(err.message || 'Не удалось загрузить пункт', 'error'); + }); + }, + + reloadCurrentItem: function () { + if (this.currentItemId) { + this.fillItemEdit(this.currentItemId); + } else { + this.fillItemNew(); + } + }, + + setItemFormMode: function (mode, title) { + var empty = document.querySelector('[data-navigation-item-empty]'); + var fields = document.querySelector('[data-navigation-item-fields]'); + var state = document.querySelector('[data-navigation-item-form-state]'); + var titleNode = document.querySelector('[data-navigation-item-form-title]'); + var save = document.querySelector('[data-navigation-item-save]'); + var reset = document.querySelector('[data-navigation-item-reset]'); + var active = mode === 'new' || mode === 'edit'; + if (empty) { empty.hidden = active; } + if (fields) { fields.hidden = !active; } + if (titleNode) { titleNode.textContent = title || 'Свойства пункта'; } + if (state) { + state.textContent = mode === 'new' ? 'новый' : (mode === 'edit' ? 'редактирование' : 'не выбран'); + state.className = 'badge ' + (mode === 'new' ? 'badge-green' : (mode === 'edit' ? 'badge-blue' : 'badge-gray')); + } + if (save) { save.disabled = !active; } + if (reset) { reset.disabled = !active; } + }, + + markSelectedItem: function (id) { + document.querySelectorAll('[data-navigation-builder-item]').forEach(function (node) { + node.classList.toggle('is-selected', String(node.getAttribute('data-navigation-builder-item')) === String(id)); + }); + }, + + populateParents: function (selected, exclude) { + var select = this.itemField('parent_id'); + if (!select) { return; } + var html = ''; + this.currentNavigationFlat.forEach(function (item) { + if (String(item.navigation_item_id) === String(exclude) || parseInt(item.level, 10) >= 3 || this.itemIsDescendant(item.navigation_item_id, exclude)) { return; } + html += ''; + }, this); + select.innerHTML = html; + select.value = String(selected || 0); + }, + + itemIsDescendant: function (candidateId, ancestorId) { + candidateId = parseInt(candidateId, 10) || 0; + ancestorId = parseInt(ancestorId, 10) || 0; + if (!candidateId || !ancestorId) { return false; } + var byId = {}; + this.currentNavigationFlat.forEach(function (item) { byId[parseInt(item.navigation_item_id, 10) || 0] = item; }); + var cursor = candidateId; + var seen = {}; + while (cursor && byId[cursor] && !seen[cursor]) { + if (parseInt(byId[cursor].parent_id, 10) === ancestorId) { return true; } + seen[cursor] = true; + cursor = parseInt(byId[cursor].parent_id, 10) || 0; + } + return false; + }, + + openDocumentPicker: function () { + var self = this; + var overlay = document.createElement('div'); + overlay.className = 'overlay navigation-picker-overlay'; + overlay.innerHTML = ''; + document.body.appendChild(overlay); + requestAnimationFrame(function () { overlay.classList.add('show'); }); + this.bindDocumentPicker(overlay); + }, + + bindDocumentPicker: function (overlay) { + var self = this; + var list = overlay.querySelector('[data-navigation-document-list]'); + var status = overlay.querySelector('[data-navigation-document-status]'); + var search = overlay.querySelector('[data-navigation-document-search]'); + var count = overlay.querySelector('[data-navigation-document-count]'); + var close = function () { + overlay.classList.remove('show'); + setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 160); + }; + var render = function (items) { + list.innerHTML = ''; + (items || []).forEach(function (item) { + list.insertAdjacentHTML('beforeend', ''); + }); + status.hidden = items && items.length > 0; + status.textContent = search.value.trim() ? 'Ничего не найдено' : 'Документы не найдены'; + count.textContent = items && items.length ? 'Документов: ' + items.length : ''; + }; + var load = function () { + var params = new URLSearchParams(); + params.set('q', search.value.trim()); + params.set('limit', 30); + status.textContent = 'Загрузка...'; + status.hidden = false; + fetch(self.base() + '/navigation/documents/picker?' + params.toString(), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(self.json) + .then(function (payload) { render((payload.data || {}).items || []); }) + .catch(function () { list.innerHTML = ''; status.textContent = 'Не удалось загрузить документы'; status.hidden = false; }); + }; + var timer = null; + var onKey = function (e) { if (e.key === 'Escape') { close(); } }; + overlay.addEventListener('click', function (e) { + if (e.target === overlay || e.target.closest('[data-navigation-picker-close]')) { close(); return; } + var item = e.target.closest('[data-navigation-document-option]'); + if (item) { + self.applyPickedDocument({ id: item.getAttribute('data-id'), title: item.getAttribute('data-title'), alias: item.getAttribute('data-alias') }); + close(); + } + }); + search.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(load, 220); }); + document.addEventListener('keydown', onKey); + load(); + search.focus(); + }, + + applyPickedDocument: function (item) { + var id = item ? parseInt(item.id || item.document_id, 10) || 0 : 0; + var title = item ? String(item.title || item.document_title || '') : ''; + var alias = item ? String(item.alias || item.document_alias || '').replace(/^\/+/, '') : ''; + var input = this.itemField('document_id'); + if (input) { input.value = id || ''; } + this.renderPickedDocument(id ? { document_id: id, document_title: title, document_alias: alias } : null); + if (!id) { return; } + var titleInput = this.itemField('title'); + var aliasInput = this.itemField('alias'); + if (titleInput) { titleInput.value = title; titleInput.dispatchEvent(new Event('input', { bubbles: true })); } + if (aliasInput) { aliasInput.value = alias; aliasInput.dispatchEvent(new Event('input', { bubbles: true })); } + }, + + renderPickedDocument: function (item) { + var id = item ? parseInt(item.document_id || item.id, 10) || 0 : 0; + var title = item ? String(item.document_title || item.title || '') : ''; + var display = document.querySelector('[data-navigation-document-display]'); + var clear = document.querySelector('[data-navigation-document-clear]'); + if (display) { display.value = id ? ('#' + id + (title ? ' · ' + title : '')) : ''; } + if (clear) { clear.hidden = !id; } + }, + + clearPickedDocument: function () { + var input = this.itemField('document_id'); + if (input) { input.value = ''; } + this.renderPickedDocument(null); + }, + + openImagePicker: function () { + var self = this; + Adminx.MediaPicker.open({ + type: 'image', + title: 'Выбрать изображение', + description: 'Файл из медиабраузера будет привязан к пункту меню.', + onPick: function (file) { self.applyPickedImage((file && file.url) || ''); } + }); + }, + + applyPickedImage: function (url) { + var input = this.itemField('image'); + url = String(url || ''); + if (input) { input.value = url; input.dispatchEvent(new Event('input', { bubbles: true })); } + this.renderPickedImage(url); + }, + + renderPickedImage: function (url) { + var selection = document.querySelector('[data-navigation-image-selection]'); + var image = selection ? selection.querySelector('img') : null; + var name = document.querySelector('[data-navigation-image-name]'); + var clear = document.querySelector('[data-navigation-image-clear]'); + url = String(url || ''); + if (selection) { selection.hidden = !url; } + if (image) { image.src = url; } + if (name) { name.textContent = url ? url.split('/').pop() : ''; } + if (clear) { clear.hidden = !url; } + }, + + submitItem: function () { + var id = this.itemField('navigation_item_id').value; + var navId = this.itemField('navigation_id').value || this.currentNavigationId; + var url = id ? this.base() + '/navigation/items/' + encodeURIComponent(id) : this.base() + '/navigation/' + encodeURIComponent(navId) + '/items'; + var self = this; + this.clearItemErrors(); + this.post(url, new FormData(this.itemForm)).then(function (payload) { + Adminx.Toast.show(payload.message || 'Пункт сохранён', 'success'); + if (payload.data && payload.data.id) { + self.currentItemId = parseInt(payload.data.id, 10) || self.currentItemId; + self.itemField('navigation_item_id').value = payload.data.id; + } + self.loadItems(); + self.applyFilterUrl(window.location.href, false); + }).catch(function (err) { + self.showItemErrors(err); + }); + }, + + toggleItem: function (id) { + var data = new FormData(); + data.append('_csrf', this.csrf()); + var self = this; + this.post(this.base() + '/navigation/items/' + encodeURIComponent(id) + '/toggle', data) + .then(function (payload) { Adminx.Toast.show(payload.message || 'Статус изменён', 'success'); self.loadItems(); }) + .catch(function (err) { Adminx.Toast.show(err.message || 'Не удалось изменить статус', 'error'); }); + }, + + deleteItem: function (id) { + var data = new FormData(); + data.append('_csrf', this.csrf()); + var self = this; + var run = function () { + self.post(self.base() + '/navigation/items/' + encodeURIComponent(id) + '/delete', data) + .then(function (payload) { + Adminx.Toast.show(payload.message || 'Пункт удалён', 'success'); + if (String(self.currentItemId) === String(id)) { + self.currentItemId = 0; + self.setItemFormMode('empty'); + } + self.loadItems(); + self.applyFilterUrl(window.location.href, false); + }) + .catch(function (err) { Adminx.Toast.show(err.message || 'Не удалось удалить пункт', 'error'); }); + }; + if (Adminx.Confirm) { + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить пункт меню', + message: 'Если у пункта есть дочерние элементы, он будет выключен.', + confirmLabel: 'Удалить', + onConfirm: run + }); + } else { + run(); + } + }, + + itemDragStart: function (e) { + var handle = e.target.closest('[data-navigation-drag-handle]'); + var root = handle ? handle.closest('[data-navigation-items-tree]') : null; + if (!handle || !root || this.orderSaving || (typeof e.button === 'number' && e.button !== 0)) { return; } + this.dragItem = handle.closest('[data-navigation-item-node]'); + if (!this.dragItem) { return; } + this.dragPointerId = e.pointerId; + this.dragStartX = e.clientX; + this.dragStartY = e.clientY; + this.dragStarted = false; + this.dragOrderSnapshot = this.itemOrderSignature(); + if (handle.setPointerCapture) { handle.setPointerCapture(e.pointerId); } + e.preventDefault(); + }, + + beginItemDrag: function (e) { + if (!this.dragItem || this.dragStarted) { return; } + var row = this.dragItem.querySelector('[data-navigation-builder-item]'); + var firstRect = this.dragItem.getBoundingClientRect(); + var rowRect = row.getBoundingClientRect(); + this.dragStarted = true; + this.dragGroup = this.itemBranch(this.dragItem); + this.dragPlaceholder = document.createElement('li'); + this.dragPlaceholder.className = 'navigation-drag-placeholder'; + this.dragPlaceholder.setAttribute('aria-hidden', 'true'); + var lastRect = this.dragGroup[this.dragGroup.length - 1].getBoundingClientRect(); + this.dragPlaceholder.style.height = Math.max(48, lastRect.bottom - firstRect.top) + 'px'; + this.dragItem.parentNode.insertBefore(this.dragPlaceholder, this.dragItem); + this.dragGhost = row.cloneNode(true); + this.dragGhost.classList.remove('is-selected', 'mb-level-0', 'mb-level-1', 'mb-level-2'); + this.dragGhost.classList.add('navigation-drag-ghost'); + this.dragGhost.style.width = Math.min(rowRect.width, 440, Math.max(1, window.innerWidth - 24)) + 'px'; + document.body.appendChild(this.dragGhost); + this.positionItemDragGhost(e); + document.body.classList.add('navigation-item-dragging'); + this.dragItem.closest('[data-navigation-items-tree]').classList.add('is-drag-active'); + this.dragGroup.forEach(function (node, index) { + node.classList.add(index === 0 ? 'is-dragging' : 'is-dragging-child'); + }); + }, + + itemDragOver: function (e) { + if (!this.dragItem || e.pointerId !== this.dragPointerId) { return; } + if (!this.dragStarted) { + if (Math.abs(e.clientX - this.dragStartX) < 5 && Math.abs(e.clientY - this.dragStartY) < 5) { return; } + this.beginItemDrag(e); + } + e.preventDefault(); + this.positionItemDragGhost(e); + var root = this.dragItem.closest('[data-navigation-items-tree]'); + if (!root) { return; } + this.scrollItemTree(root, e.clientY); + + var target = null; + var bottom = false; + var self = this; + Array.prototype.some.call(root.querySelectorAll(':scope > ol > [data-navigation-item-node]'), function (node) { + if (self.dragGroup.indexOf(node) !== -1) { return false; } + var candidate = node.querySelector('[data-navigation-builder-item]'); + var candidateRect = candidate ? candidate.getBoundingClientRect() : null; + if (!candidateRect || e.clientY >= candidateRect.bottom) { return false; } + target = node; + bottom = e.clientY > candidateRect.top + candidateRect.height / 2; + return true; + }); + + document.querySelectorAll('.navigation-tree-item.drag-over-top, .navigation-tree-item.drag-over-bottom').forEach(function (node) { + node.classList.remove('drag-over-top', 'drag-over-bottom'); + }); + if (!target) { + root.querySelector('ol').appendChild(this.dragPlaceholder); + return; + } + + var row = target.querySelector('[data-navigation-builder-item]'); + var anchor = bottom ? this.itemBranch(target).slice(-1)[0].nextSibling : target; + if (anchor !== this.dragPlaceholder) { target.parentNode.insertBefore(this.dragPlaceholder, anchor); } + row.classList.toggle('drag-over-top', !bottom); + row.classList.toggle('drag-over-bottom', bottom); + }, + + itemDrop: function (e) { + if (!this.dragItem || e.pointerId !== this.dragPointerId) { return; } + e.preventDefault(); + if (!this.dragStarted || !this.dragPlaceholder || !this.dragPlaceholder.parentNode) { + this.itemDragEnd(); + return; + } + var placeholder = this.dragPlaceholder; + this.dragGroup.forEach(function (node) { + placeholder.parentNode.insertBefore(node, placeholder); + }); + placeholder.parentNode.removeChild(placeholder); + this.dragPlaceholder = null; + this.normalizeItemHierarchy(); + var changed = this.itemOrderSignature() !== this.dragOrderSnapshot; + this.itemDragEnd(); + if (changed) { this.persistItemOrder(); } + }, + + itemDragEnd: function () { + this.dragGroup.forEach(function (node) { + node.classList.remove('is-dragging', 'is-dragging-child'); + }); + if (this.dragPlaceholder && this.dragPlaceholder.parentNode) { + this.dragPlaceholder.parentNode.removeChild(this.dragPlaceholder); + } + document.querySelectorAll('.navigation-tree-item.drag-over-top, .navigation-tree-item.drag-over-bottom').forEach(function (node) { + node.classList.remove('drag-over-top', 'drag-over-bottom'); + }); + document.querySelectorAll('[data-navigation-items-tree].is-drag-active').forEach(function (node) { + node.classList.remove('is-drag-active'); + }); + if (this.dragGhost && this.dragGhost.parentNode) { this.dragGhost.parentNode.removeChild(this.dragGhost); } + document.body.classList.remove('navigation-item-dragging'); + this.dragItem = null; + this.dragGroup = []; + this.dragPlaceholder = null; + this.dragGhost = null; + this.dragPointerId = null; + this.dragStarted = false; + this.dragOrderSnapshot = ''; + }, + + positionItemDragGhost: function (e) { + if (!this.dragGhost) { return; } + var left = Math.min(e.clientX + 14, window.innerWidth - this.dragGhost.offsetWidth - 12); + var top = Math.min(e.clientY + 12, window.innerHeight - this.dragGhost.offsetHeight - 12); + this.dragGhost.style.transform = 'translate3d(' + Math.max(12, left) + 'px,' + Math.max(12, top) + 'px,0)'; + }, + + scrollItemTree: function (root, clientY) { + var rect = root.getBoundingClientRect(); + var edge = 54; + if (clientY < rect.top + edge) { root.scrollTop -= Math.ceil((rect.top + edge - clientY) / 5); } + if (clientY > rect.bottom - edge) { root.scrollTop += Math.ceil((clientY - rect.bottom + edge) / 5); } + }, + + itemOrderSignature: function () { + return Array.prototype.map.call(document.querySelectorAll('[data-navigation-items-tree] > ol > [data-navigation-item-node]'), function (node) { + return node.getAttribute('data-navigation-item-node') + ':' + node.getAttribute('data-level'); + }).join('|'); + }, + + itemBranch: function (node) { + if (!node) { return []; } + var branch = [node]; + var level = parseInt(node.getAttribute('data-level'), 10) || 0; + var next = node.nextElementSibling; + while (next) { + if (next === this.dragPlaceholder) { + next = next.nextElementSibling; + continue; + } + if (parseInt(next.getAttribute('data-level'), 10) <= level) { break; } + branch.push(next); + next = next.nextElementSibling; + } + return branch; + }, + + changeItemLevel: function (node, delta) { + if (!node || !delta) { return; } + var level = parseInt(node.getAttribute('data-level'), 10) || 0; + var nextLevel = Math.max(0, Math.min(2, level + delta)); + if (delta > 0 && !this.canIndentItem(node)) { return; } + if (nextLevel === level) { return; } + node.setAttribute('data-level', String(nextLevel)); + this.normalizeItemHierarchy(); + this.persistItemOrder(); + }, + + canIndentItem: function (node) { + var level = parseInt(node.getAttribute('data-level'), 10) || 0; + if (level >= 2) { return false; } + var previous = node.previousElementSibling; + while (previous) { + var previousLevel = parseInt(previous.getAttribute('data-level'), 10) || 0; + if (previousLevel < level) { return false; } + if (previousLevel === level) { return true; } + previous = previous.previousElementSibling; + } + return false; + }, + + normalizeItemHierarchy: function () { + var nodes = document.querySelectorAll('[data-navigation-items-tree] > ol > [data-navigation-item-node]'); + var parents = []; + var positions = {}; + var previousLevel = 0; + var self = this; + nodes.forEach(function (node, index) { + var level = Math.max(0, Math.min(2, parseInt(node.getAttribute('data-level'), 10) || 0)); + if (index === 0) { level = 0; } + if (level > previousLevel + 1) { level = previousLevel + 1; } + var parentId = level > 0 && parents[level - 1] ? parents[level - 1] : 0; + if (level > 0 && !parentId) { level = 0; } + parentId = level > 0 && parents[level - 1] ? parents[level - 1] : 0; + var id = parseInt(node.getAttribute('data-navigation-item-node'), 10) || 0; + var positionKey = String(parentId); + positions[positionKey] = (positions[positionKey] || 0) + 1; + parents[level] = id; + parents.length = level + 1; + previousLevel = level; + node.setAttribute('data-level', String(level)); + node.setAttribute('data-parent-id', String(parentId)); + node.setAttribute('data-position', String(positions[positionKey])); + var row = node.querySelector('[data-navigation-builder-item]'); + if (row) { + row.classList.remove('mb-level-0', 'mb-level-1', 'mb-level-2'); + row.classList.add('mb-level-' + level); + } + var pos = node.querySelector('.si-pos'); + if (pos) { pos.textContent = String(positions[positionKey]); } + var badge = node.querySelector('[data-navigation-level-badge]'); + if (badge) { badge.textContent = 'уровень ' + (level + 1); } + }); + nodes.forEach(function (node) { + var level = parseInt(node.getAttribute('data-level'), 10) || 0; + var decrease = node.querySelector('[data-navigation-item-indent="-1"]'); + var increase = node.querySelector('[data-navigation-item-indent="1"]'); + if (decrease) { decrease.disabled = level === 0; } + if (increase) { increase.disabled = !self.canIndentItem(node); } + }); + }, + + persistItemOrder: function () { + var order = []; + document.querySelectorAll('[data-navigation-items-tree] > ol > [data-navigation-item-node]').forEach(function (node) { + var parentId = parseInt(node.getAttribute('data-parent-id'), 10) || 0; + order.push({ + id: parseInt(node.getAttribute('data-navigation-item-node'), 10) || 0, + parent_id: parentId, + position: parseInt(node.getAttribute('data-position'), 10) || 1 + }); + }); + var data = new FormData(); + data.append('_csrf', this.csrf()); + data.append('order', JSON.stringify(order)); + var self = this; + var root = document.querySelector('[data-navigation-items-tree]'); + this.orderSaving = true; + if (root) { root.classList.add('is-order-saving'); } + this.post(this.base() + '/navigation/' + encodeURIComponent(this.currentNavigationId) + '/items/reorder', data) + .then(function (payload) { + Adminx.Toast.show(payload.message || 'Порядок сохранён', 'success'); + self.loadItems(); + }) + .catch(function (err) { + Adminx.Toast.show(err.message || 'Не удалось сохранить порядок', 'error'); + self.loadItems(); + }).finally(function () { + self.orderSaving = false; + var currentRoot = document.querySelector('[data-navigation-items-tree]'); + if (currentRoot) { currentRoot.classList.remove('is-order-saving'); } + }); + }, + + ajaxRefresh: function (message, keepOpen) { + if (message) { Adminx.Toast.show(message, 'success'); } + if (!keepOpen && Adminx.Drawer) { Adminx.Drawer.close(); } + this.applyFilterUrl(window.location.href, false); + if (keepOpen && window.Adminx.CodeEditor) { + setTimeout(function () { Adminx.CodeEditor.refreshAll(); }, 120); + } + }, + + clearErrors: function () { + if (!this.form) { return; } + this.form.querySelectorAll('[data-error]').forEach(function (el) { el.textContent = ''; }); + this.form.querySelectorAll('.is-invalid').forEach(function (el) { el.classList.remove('is-invalid'); }); + }, + + clearItemErrors: function () { + if (!this.itemForm) { return; } + this.itemForm.querySelectorAll('[data-error]').forEach(function (el) { el.textContent = ''; }); + this.itemForm.querySelectorAll('.is-invalid').forEach(function (el) { el.classList.remove('is-invalid'); }); + }, + + showErrors: function (err) { + if (err && err.errors) { + Object.keys(err.errors).forEach(function (key) { + var node = document.querySelector('[data-error="' + key + '"]'); + var field = document.querySelector('[name="' + key + '"]'); + if (node) { node.textContent = err.errors[key]; } + if (field) { field.classList.add('is-invalid'); } + }); + } + Adminx.Toast.show((err && err.message) || 'Проверьте поля формы', 'error'); + }, + + showItemErrors: function (err) { + if (err && err.errors) { + Object.keys(err.errors).forEach(function (key) { + var node = document.querySelector('[data-error="' + key + '"]'); + if (node) { node.textContent = err.errors[key]; } + }); + } + Adminx.Toast.show((err && err.message) || 'Проверьте поля пункта', 'error'); + }, + + esc: function (value) { + return String(value == null ? '' : value).replace(/[&<>"']/g, function (ch) { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]; + }); + }, + + csrf: function () { + var token = this.form ? this.form.querySelector('[name="_csrf"]') : document.querySelector('[name="_csrf"]'); + return token ? token.value : ''; + }, + + post: function (url, data) { + return fetch(url, { + method: 'POST', + body: data, + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }).then(this.json); + }, + + json: function (res) { + return res.json().then(function (payload) { + if (!res.ok || payload.success === false) { + var err = new Error(payload.message || ('HTTP ' + res.status)); + err.errors = payload.errors || {}; + throw err; + } + return payload; + }); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.Navigation.init(); }); + } else { + Adminx.Navigation.init(); + } +})(window, document); diff --git a/adminx/modules/Navigation/language/en/client.xml b/adminx/modules/Navigation/language/en/client.xml new file mode 100644 index 0000000..092abe2 --- /dev/null +++ b/adminx/modules/Navigation/language/en/client.xml @@ -0,0 +1,51 @@ + + + editing + Menu item + Menu structure + Drag item + Check item fields + Untitled + new + Menu root + Check the form fields + Alias saved + <button class="btn btn-ghost btn-icon btn-sm navigation-action-level" type="button" data-navigation-item-indent="1" data-tooltip="Increase level" aria-label="Increase level"><i class="ti ti-indent-increase"></i></button> + Failed to load item + (copy) + New menu item + Drag + <button class="btn btn-ghost btn-icon btn-sm navigation-action-level" type="button" data-navigation-item-indent="-1" data-tooltip="Decrease level" aria-label="Decrease level"><i class="ti ti-indent-decrease"></i></button> + Alias + groups + <div class="modal-header"><span class="dialog-icon info"><i class="ti ti-file-search"></i></span><div><h3 id="navigationDocumentPickerTitle">Select document</h3><p class="text-secondary">Title and link will be filled in automatically.</p></div><button class="modal-close" type="button" data-navigation-picker-close aria-label="Close"><i class="ti ti-x"></i></button></div> + Failed to load navigation + Not available to anyone + <option value="0">Menu root</option> + <div class="modal-body"><div class="input-wrap navigation-document-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="ID, title or alias" data-navigation-document-search></div><div class="navigation-picker-status" data-navigation-document-status>Loading...</div><div class="navigation-document-list" data-navigation-document-list></div></div> + Main navigation + Select document + " data-tooltip="Delete" aria-label="Delete"><i class="ti ti-trash"></i></button> + Item properties + Copy created + <div class="modal-footer"><div class="mf-left navigation-picker-count" data-navigation-document-count></div><button class="btn btn-ghost" type="button" data-navigation-picker-close>Close</button></div> + New navigation + All groups + from + Failed to apply filters + not selected + Select at least one group + " data-tooltip="On/Off" aria-label="On/Off"><i class="ti ti-power"></i></button> + " data-tooltip="Change" aria-label="Change"><i class="ti ti-pencil"></i></button> + Select image + <nav class="site-navigation" aria-label="Main navigation"> + Item + Navigation + (copy) + New navigation + Copy created + Check item fields + Navigation + <nav class="site-navigation" aria-label="Main navigation"> + Check the form fields + diff --git a/adminx/modules/Navigation/language/en/interface.xml b/adminx/modules/Navigation/language/en/interface.xml new file mode 100644 index 0000000..25af45b --- /dev/null +++ b/adminx/modules/Navigation/language/en/interface.xml @@ -0,0 +1,190 @@ + + + [tag:if_every:2]even[tag:if_else]odd[tag:/if] + , and normal and active items are elements + , start and end of level - list + . Levels 2–3 fields are only needed for submenus. + . The editors are arranged in order of HTML assembly. + 0 of + <nav class="site-navigation" aria-label="Main navigation"> + ID, name or alias + Active item + Alias + without alias + In a new window + in the current filter, + In this window + Outer wrapper of the entire menu + All levels + Total navigations + everything. + Insert tag + Second step of the nested menu + Select groups + Select menu item + Select all + Select document + Select image + Displayed once before the item tree. + off + The finished first level template has already been completed. + groups + Access groups + No public user groups have been created. + Tag groups + Menu item data + Tree of menu items and templates for their display. + Access to site navigation + There is a nested level + Closes the general wrapper after all levels. + Closes the second level list. + Closes the first level list. + Closes the third level list. + Image + Used for the current page and its active branch. + Every Nth point + End of navigation + End of level + Website menu builder + Menu root + Root menu items + Except every Nth + Navigation menu + Menus called by tags + Navigation + No navigations found. + Navigation #1 is protected from deletion; the tag can be copied from the list. + Start navigation + Start of level + Not selected + not selected + Not the first point + Not the last point + No nested level + New navigation + Regular item + Wrapper of a second level nested list. + Wrapper of a third level nested list. + Wrapper of the root list. The [tag:content] tag determines where the items are displayed. + Wrapper shapes + Wrappers + Base + Main navigation + Select who to show this navigation to. + First step of the nested menu + First point + Search tags + Order and nesting of items. + Last point + parity example + Public groups + Item + Menu item + points + Menu items + Menu items + Unfolding + Parent + With alias + With access groups + Reset changes + Item properties + Navigation Template Sections + Deselect + Contents of nested items + Create navigation + Save item + Link/alias + Link, parent and design. + Menu structure + Menu structure. + Current level + Active branch only + Remove document + Remove image + Level 1 + Level 2 + Level 3 + Condition with alternative + Terms + Second level active link template. + Third level active link template. + Second level inactive link template. + First level inactive link template. + Third level inactive link template. + Menu templates + Level templates support menu item tags. + " data-tooltip="On/Off" aria-label="On/Off"><i class="ti ti-power"></i></button> + " data-tooltip="Change" aria-label="Change"><i class="ti ti-pencil"></i></button> + " data-tooltip="Delete" aria-label="Delete"><i class="ti ti-trash"></i></button> + (copy) + <button class="btn btn-ghost btn-icon btn-sm navigation-action-level" type="button" data-navigation-item-indent="-1" data-tooltip="Decrease level" aria-label="Decrease level"><i class="ti ti-indent-decrease"></i></button> + <button class="btn btn-ghost btn-icon btn-sm navigation-action-level" type="button" data-navigation-item-indent="1" data-tooltip="Increase level" aria-label="Increase level"><i class="ti ti-indent-increase"></i></button> + <div class="modal-body"><div class="input-wrap navigation-document-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="ID, title or alias" data-navigation-document-search></div><div class="navigation-picker-status" data-navigation-document-status>Loading...</div><div class="navigation-document-list" data-navigation-document-list></div></div> + <div class="modal-footer"><div class="mf-left navigation-picker-count" data-navigation-document-count></div><button class="btn btn-ghost" type="button" data-navigation-picker-close>Close</button></div> + <div class="modal-header"><span class="dialog-icon info"><i class="ti ti-file-search"></i></span><div><h3 id="navigationDocumentPickerTitle">Select document</h3><p class="text-secondary">Title and link will be filled in automatically.</p></div><button class="modal-close" type="button" data-navigation-picker-close aria-label="Close"><i class="ti ti-x"></i></button></div> + <option value="0">Menu root</option> + Alias is free + Alias saved + Untitled + All groups + Select at least one group + Group + detail.textContent = + from + Navigation cache cleared + Content + Copy created + Navigation + Navigation + } + + $row = self::raw($id); + if (!$row) { + return false; + } + + self::clearCache($id, isset($row[ + Navigation not found + Navigation created + Navigation saved + Navigation removed + Navigation: view + Navigation: controls + Failed to load navigation + Failed to load item + Failed to apply filters + Insufficient rights + Incorrect order of items + Incorrect parent + Not available to anyone + new + New menu item + Incomplete order of items transmitted + Drag + Drag item + The order of the items is preserved + The order contains an unknown or duplicate item + Check item fields + Check the form fields + View templates and navigation items. + Item included + Item disabled + Navigation point not found + Navigation point created + Navigation point saved + Navigation item removed or disabled + editing + The parent of the item does not belong to this navigation + Category + + + Create, edit, copy, delete and import navigations. + This alias is already in use + A-Z only, 0-9, hyphen and underscore, up to 20 characters; not a number + Specify the active first level template + Specify an alias + Please enter a name + Enter the name of the item + Please provide a link or alias + Specify the first level link template + diff --git a/adminx/modules/Navigation/language/en/runtime.xml b/adminx/modules/Navigation/language/en/runtime.xml new file mode 100644 index 0000000..11dd3df --- /dev/null +++ b/adminx/modules/Navigation/language/en/runtime.xml @@ -0,0 +1,52 @@ + + + (copy) + <nav class="site-navigation" aria-label="Main navigation"> + Active branch + Alias is free + Cycle detected in menu structure + All levels + The selected parent does not belong to this navigation + Navigation cache cleared + Content + Copy created + The menu supports no more than three levels + Navigation + Navigation #1 is system navigation and cannot be deleted + Navigation not found + Navigation created + Navigation saved + Navigation removed + Navigation: view + Navigation: controls + Insufficient rights + Incorrect order of items + Incorrect parent + You can't move an item inside its own branch + New navigation + Incomplete order of items transmitted + The order of the items is preserved + The order contains an unknown or duplicate item + Check item fields + Check the form fields + View templates and navigation items. + Item included + Item disabled + Menu item not found + Navigation point not found + Navigation point created + Navigation point saved + Navigation item removed or disabled + Menu item parent not found + The parent of the item does not belong to this navigation + Create, edit, copy, delete and import navigations. + This alias is already in use + Current level + A-Z only, 0-9, hyphen and underscore, up to 20 characters; not a number + Specify the active first level template + Specify an alias + Please enter a name + Enter the name of the item + Please provide a link or alias + Specify the first level link template + diff --git a/adminx/modules/Navigation/language/ru/client.xml b/adminx/modules/Navigation/language/ru/client.xml new file mode 100644 index 0000000..27fd02f --- /dev/null +++ b/adminx/modules/Navigation/language/ru/client.xml @@ -0,0 +1,51 @@ + + + редактирование + Пункт меню + Структура меню + Перетащить пункт + Проверьте поля пункта + Без названия + новый + Корень меню + Проверьте поля формы + Алиас сохранён + <button class="btn btn-ghost btn-icon btn-sm navigation-action-level" type="button" data-navigation-item-indent="1" data-tooltip="Увеличить уровень" aria-label="Увеличить уровень"><i class="ti ti-indent-increase"></i></button> + Не удалось загрузить пункт + (копия) + Новый пункт меню + Перетащить + <button class="btn btn-ghost btn-icon btn-sm navigation-action-level" type="button" data-navigation-item-indent="-1" data-tooltip="Уменьшить уровень" aria-label="Уменьшить уровень"><i class="ti ti-indent-decrease"></i></button> + Алиас + группы + <div class="modal-header"><span class="dialog-icon info"><i class="ti ti-file-search"></i></span><div><h3 id="navigationDocumentPickerTitle">Выбрать документ</h3><p class="text-secondary">Название и ссылка заполнятся автоматически.</p></div><button class="modal-close" type="button" data-navigation-picker-close aria-label="Закрыть"><i class="ti ti-x"></i></button></div> + Не удалось загрузить навигацию + Никому не доступна + <option value="0">Корень меню</option> + <div class="modal-body"><div class="input-wrap navigation-document-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="ID, название или alias" data-navigation-document-search></div><div class="navigation-picker-status" data-navigation-document-status>Загрузка...</div><div class="navigation-document-list" data-navigation-document-list></div></div> + Основная навигация + Выбрать документ + " data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button> + Свойства пункта + Копия создана + <div class="modal-footer"><div class="mf-left navigation-picker-count" data-navigation-document-count></div><button class="btn btn-ghost" type="button" data-navigation-picker-close>Закрыть</button></div> + Новая навигация + Все группы + из + Не удалось применить фильтры + не выбран + Выберите хотя бы одну группу + " data-tooltip="Вкл/выкл" aria-label="Вкл/выкл"><i class="ti ti-power"></i></button> + " data-tooltip="Изменить" aria-label="Изменить"><i class="ti ti-pencil"></i></button> + Выбрать изображение + <nav class="site-navigation" aria-label="Основная навигация"> + Пункт + Навигация + (копия) + Новая навигация + Копия создана + Проверьте поля пункта + Навигация + <nav class="site-navigation" aria-label="Основная навигация"> + Проверьте поля формы + diff --git a/adminx/modules/Navigation/language/ru/interface.xml b/adminx/modules/Navigation/language/ru/interface.xml new file mode 100644 index 0000000..d8447f7 --- /dev/null +++ b/adminx/modules/Navigation/language/ru/interface.xml @@ -0,0 +1,190 @@ + + + [tag:if_every:2]чётный[tag:if_else]нечётный[tag:/if] + , а обычный и активный пункты — элементы + , начало и конец уровня — список + . Поля уровней 2–3 нужны только для вложенного меню. + . Редакторы расположены в порядке сборки HTML. + 0 из + <nav class="site-navigation" aria-label="Основная навигация"> + ID, название или алиас + Активный пункт + Алиас + без алиаса + В новом окне + в текущем фильтре, + В этом окне + Внешняя обёртка всего меню + Все уровни + Всего навигаций + всего. + Вставить тег + Вторая ступень вложенного меню + Выберите группы + Выберите пункт меню + Выбрать все + Выбрать документ + Выбрать изображение + Выводится один раз перед деревом пунктов. + выключена + Готовый шаблон первого уровня уже заполнен. + группы + Группы доступа + Группы публичных пользователей не созданы. + Группы тегов + Данные пункта меню + Дерево пунктов меню и шаблоны их вывода. + Доступ к навигации на сайте + Есть вложенный уровень + Закрывает общую обёртку после всех уровней. + Закрывает список второго уровня. + Закрывает список первого уровня. + Закрывает список третьего уровня. + Изображение + Используется для текущей страницы и её активной ветки. + Каждый N-й пункт + Конец навигации + Конец уровня + Конструктор меню сайта + Корень меню + Корневые пункты меню + Кроме каждого N-го + Меню навигации + Меню, вызываемые тегами + Навигации + Навигации не найдены. + Навигация #1 защищена от удаления; тег можно скопировать из списка. + Начало навигации + Начало уровня + Не выбран + не выбран + Не первый пункт + Не последний пункт + Нет вложенного уровня + Новая навигация + Обычный пункт + Обёртка вложенного списка второго уровня. + Обёртка вложенного списка третьего уровня. + Обёртка корневого списка. Тег [tag:content] определяет место вывода пунктов. + Обёртка формирует + Обёртки + Основа + Основная навигация + Отметьте, кому показывать эту навигацию. + Первая ступень вложенного меню + Первый пункт + Поиск тегов + Порядок и вложенность пунктов. + Последний пункт + пример чётности + Публичные группы + Пункт + Пункт меню + пунктов + Пунктов меню + Пункты меню + Разворачивание + Родитель + С алиасом + С группами доступа + Сбросить изменения + Свойства пункта + Секции шаблона навигации + Снять выбор + Содержимое вложенных пунктов + Создать навигацию + Сохранить пункт + Ссылка / alias + Ссылка, родитель и оформление. + Структура меню + Структура меню. + Текущий уровень + Только активная ветка + Убрать документ + Убрать изображение + Уровень 1 + Уровень 2 + Уровень 3 + Условие с альтернативой + Условия + Шаблон активной ссылки второго уровня. + Шаблон активной ссылки третьего уровня. + Шаблон неактивной ссылки второго уровня. + Шаблон неактивной ссылки первого уровня. + Шаблон неактивной ссылки третьего уровня. + Шаблоны меню + Шаблоны уровней поддерживают теги пунктов меню. + " data-tooltip="Вкл/выкл" aria-label="Вкл/выкл"><i class="ti ti-power"></i></button> + " data-tooltip="Изменить" aria-label="Изменить"><i class="ti ti-pencil"></i></button> + " data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button> + (копия) + <button class="btn btn-ghost btn-icon btn-sm navigation-action-level" type="button" data-navigation-item-indent="-1" data-tooltip="Уменьшить уровень" aria-label="Уменьшить уровень"><i class="ti ti-indent-decrease"></i></button> + <button class="btn btn-ghost btn-icon btn-sm navigation-action-level" type="button" data-navigation-item-indent="1" data-tooltip="Увеличить уровень" aria-label="Увеличить уровень"><i class="ti ti-indent-increase"></i></button> + <div class="modal-body"><div class="input-wrap navigation-document-search"><i class="ti ti-search"></i><input class="input" type="search" placeholder="ID, название или alias" data-navigation-document-search></div><div class="navigation-picker-status" data-navigation-document-status>Загрузка...</div><div class="navigation-document-list" data-navigation-document-list></div></div> + <div class="modal-footer"><div class="mf-left navigation-picker-count" data-navigation-document-count></div><button class="btn btn-ghost" type="button" data-navigation-picker-close>Закрыть</button></div> + <div class="modal-header"><span class="dialog-icon info"><i class="ti ti-file-search"></i></span><div><h3 id="navigationDocumentPickerTitle">Выбрать документ</h3><p class="text-secondary">Название и ссылка заполнятся автоматически.</p></div><button class="modal-close" type="button" data-navigation-picker-close aria-label="Закрыть"><i class="ti ti-x"></i></button></div> + <option value="0">Корень меню</option> + Алиас свободен + Алиас сохранён + Без названия + Все группы + Выберите хотя бы одну группу + Группа + detail.textContent = + из + Кеш навигации очищен + Контент + Копия создана + Навигация + Навигация + } + + $row = self::raw($id); + if (!$row) { + return false; + } + + self::clearCache($id, isset($row[ + Навигация не найдена + Навигация создана + Навигация сохранена + Навигация удалена + Навигация: просмотр + Навигация: управление + Не удалось загрузить навигацию + Не удалось загрузить пункт + Не удалось применить фильтры + Недостаточно прав + Некорректный порядок пунктов + Некорректный родитель + Никому не доступна + новый + Новый пункт меню + Передан неполный порядок пунктов + Перетащить + Перетащить пункт + Порядок пунктов сохранён + Порядок содержит неизвестный или повторяющийся пункт + Проверьте поля пункта + Проверьте поля формы + Просмотр шаблонов и пунктов навигации. + Пункт включён + Пункт выключен + Пункт навигации не найден + Пункт навигации создан + Пункт навигации сохранён + Пункт навигации удалён или выключен + редактирование + Родитель пункта не принадлежит этой навигации + Рубрика + + + Создание, изменение, копирование, удаление и импорт навигаций. + Такой алиас уже используется + Только A-Z, 0-9, дефис и подчёркивание, до 20 символов; не число + Укажите активный шаблон первого уровня + Укажите алиас + Укажите название + Укажите название пункта + Укажите ссылку или alias + Укажите шаблон ссылки первого уровня + diff --git a/adminx/modules/Navigation/language/ru/runtime.xml b/adminx/modules/Navigation/language/ru/runtime.xml new file mode 100644 index 0000000..e88d140 --- /dev/null +++ b/adminx/modules/Navigation/language/ru/runtime.xml @@ -0,0 +1,52 @@ + + + (копия) + <nav class="site-navigation" aria-label="Основная навигация"> + Активная ветка + Алиас свободен + В структуре меню обнаружен цикл + Все уровни + Выбранный родитель не принадлежит этой навигации + Кеш навигации очищен + Контент + Копия создана + Меню поддерживает не более трёх уровней + Навигация + Навигация #1 является системной и не может быть удалена + Навигация не найдена + Навигация создана + Навигация сохранена + Навигация удалена + Навигация: просмотр + Навигация: управление + Недостаточно прав + Некорректный порядок пунктов + Некорректный родитель + Нельзя переместить пункт внутрь собственной ветки + Новая навигация + Передан неполный порядок пунктов + Порядок пунктов сохранён + Порядок содержит неизвестный или повторяющийся пункт + Проверьте поля пункта + Проверьте поля формы + Просмотр шаблонов и пунктов навигации. + Пункт включён + Пункт выключен + Пункт меню не найден + Пункт навигации не найден + Пункт навигации создан + Пункт навигации сохранён + Пункт навигации удалён или выключен + Родитель пункта меню не найден + Родитель пункта не принадлежит этой навигации + Создание, изменение, копирование, удаление и импорт навигаций. + Такой алиас уже используется + Текущий уровень + Только A-Z, 0-9, дефис и подчёркивание, до 20 символов; не число + Укажите активный шаблон первого уровня + Укажите алиас + Укажите название + Укажите название пункта + Укажите ссылку или alias + Укажите шаблон ссылки первого уровня + diff --git a/adminx/modules/Navigation/migrations/001_create_navigation_tables.sql b/adminx/modules/Navigation/migrations/001_create_navigation_tables.sql new file mode 100644 index 0000000..6ac606f --- /dev/null +++ b/adminx/modules/Navigation/migrations/001_create_navigation_tables.sql @@ -0,0 +1,45 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_navigation` ( + `navigation_id` SMALLINT(3) UNSIGNED NOT NULL AUTO_INCREMENT, + `alias` VARCHAR(20) NOT NULL DEFAULT '', + `title` VARCHAR(255) NOT NULL DEFAULT '', + `level1` TEXT NOT NULL, + `level2` TEXT NOT NULL, + `level3` TEXT NOT NULL, + `level1_active` TEXT NOT NULL, + `level2_active` TEXT NOT NULL, + `level3_active` TEXT NOT NULL, + `level1_begin` TEXT NOT NULL, + `level1_end` TEXT NOT NULL, + `level2_begin` TEXT NOT NULL, + `level2_end` TEXT NOT NULL, + `level3_begin` TEXT NOT NULL, + `level3_end` TEXT NOT NULL, + `begin` TEXT NOT NULL, + `end` TEXT NOT NULL, + `user_group` TEXT NOT NULL, + `expand_ext` ENUM('0','1','2') DEFAULT '1', + PRIMARY KEY (`navigation_id`), + KEY `idx_alias` (`alias`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `{{prefix}}_navigation_items` ( + `navigation_item_id` MEDIUMINT(5) UNSIGNED NOT NULL AUTO_INCREMENT, + `navigation_id` SMALLINT(3) UNSIGNED NOT NULL DEFAULT 0, + `document_id` INT(11) DEFAULT NULL, + `alias` CHAR(255) NOT NULL DEFAULT '', + `title` CHAR(255) NOT NULL DEFAULT '', + `description` TEXT NOT NULL, + `target` ENUM('_blank','_self','_parent','_top') NOT NULL DEFAULT '_self', + `image` VARCHAR(255) NOT NULL DEFAULT '', + `css_style` VARCHAR(255) DEFAULT NULL, + `css_id` VARCHAR(50) DEFAULT NULL, + `css_class` VARCHAR(50) DEFAULT NULL, + `parent_id` MEDIUMINT(5) UNSIGNED NOT NULL DEFAULT 0, + `level` ENUM('1','2','3') NOT NULL DEFAULT '1', + `position` SMALLINT(3) UNSIGNED NOT NULL DEFAULT 1, + `status` ENUM('1','0') NOT NULL DEFAULT '1', + PRIMARY KEY (`navigation_item_id`), + KEY `idx_navigation_parent` (`navigation_id`, `parent_id`), + KEY `idx_navigation_position` (`navigation_id`, `position`), + KEY `idx_document` (`document_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/adminx/modules/Navigation/module.php b/adminx/modules/Navigation/module.php new file mode 100644 index 0000000..8568f24 --- /dev/null +++ b/adminx/modules/Navigation/module.php @@ -0,0 +1,85 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + return array( + 'code' => 'navigation', + 'name' => 'Навигация', + 'version' => '0.1.0', + + 'permissions' => array( + 'key' => 'navigation', + 'items' => array( + array( + 'code' => 'view_navigation', + 'group_code' => 'navigation', + 'name' => 'Навигация: просмотр', + 'description' => 'Просмотр шаблонов и пунктов навигации.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_navigation', + 'group_code' => 'content', + 'name' => 'Навигация: управление', + 'description' => 'Создание, изменение, копирование, удаление и импорт навигаций.', + 'sort_order' => 20, + ), + ), + 'icon' => 'ti ti-sitemap', + 'priority' => 35, + ), + + 'navigation' => array( + array( + 'code' => 'navigation', + 'label' => 'Навигация', + 'url' => '/navigation', + 'icon' => 'ti ti-sitemap', + 'permission' => 'view_navigation', + 'group' => 'Контент', + 'sort_order' => 25, + 'match' => array( + '/navigation', + ), + ), + ), + + 'migrations' => array( + array('id' => '001_create_navigation_tables', 'file' => 'migrations/001_create_navigation_tables.sql'), + ), + + 'routes' => array( + array('GET', '/navigation', array(\App\Adminx\Navigation\Controller::class, 'index')), + array('GET', '/navigation/alias-check', array(\App\Adminx\Navigation\Controller::class, 'aliasCheck')), + array('GET', '/navigation/documents/picker', array(\App\Adminx\Navigation\Controller::class, 'documentPicker')), + array('GET', '/navigation/{id}/items', array(\App\Adminx\Navigation\Controller::class, 'items')), + array('POST', '/navigation/{id}/items', array(\App\Adminx\Navigation\Controller::class, 'storeItem')), + array('POST', '/navigation/{id}/items/reorder', array(\App\Adminx\Navigation\Controller::class, 'reorderItems')), + array('GET', '/navigation/items/{item}', array(\App\Adminx\Navigation\Controller::class, 'item')), + array('POST', '/navigation/items/{item}', array(\App\Adminx\Navigation\Controller::class, 'updateItem')), + array('POST', '/navigation/items/{item}/toggle', array(\App\Adminx\Navigation\Controller::class, 'toggleItem')), + array('POST', '/navigation/items/{item}/delete', array(\App\Adminx\Navigation\Controller::class, 'destroyItem')), + array('GET', '/navigation/{id}', array(\App\Adminx\Navigation\Controller::class, 'show')), + array('POST', '/navigation', array(\App\Adminx\Navigation\Controller::class, 'store'), array('permission' => 'manage_navigation', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/navigation/{id}', array(\App\Adminx\Navigation\Controller::class, 'update'), array('permission' => 'manage_navigation', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/navigation/{id}/copy', array(\App\Adminx\Navigation\Controller::class, 'copy'), array('permission' => 'manage_navigation', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/navigation/{id}/clear-cache', array(\App\Adminx\Navigation\Controller::class, 'clearCache')), + array('POST', '/navigation/{id}/delete', array(\App\Adminx\Navigation\Controller::class, 'destroy')), + ), + + 'view_globals' => array( + 'module_code' => 'navigation', + ), + ); diff --git a/adminx/modules/Navigation/view/index.twig b/adminx/modules/Navigation/view/index.twig new file mode 100644 index 0000000..fd36aca --- /dev/null +++ b/adminx/modules/Navigation/view/index.twig @@ -0,0 +1,391 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Навигация{% endblock %} +{% block content %} + + + + + + + + + +{% if can_manage %} + + + +{% endif %} + +{% endblock %} diff --git a/adminx/modules/Registrations/Controller.php b/adminx/modules/Registrations/Controller.php new file mode 100644 index 0000000..fa47087 --- /dev/null +++ b/adminx/modules/Registrations/Controller.php @@ -0,0 +1,161 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Registrations; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Helpers\Request; + use App\Helpers\Response; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + if (!Permission::check('view_registrations')) { + Response::forbidden(); + return ''; + } + + $filters = array( + 'status' => Request::getStr('status', ''), + 'risk_class' => Request::getStr('risk_class', ''), + 'q' => Request::getStr('q', ''), + ); + + return $this->render('@registrations/index.twig', array( + 'items' => Model::all($filters), + 'summary' => Model::summary(), + 'statuses' => Model::statuses(), + 'risk_classes' => Model::riskClasses(), + 'filters' => $filters, + 'can_manage' => Permission::check('manage_registrations'), + )); + } + + public function create(array $params = array()) + { + if (!Permission::check('manage_registrations')) { + Response::forbidden(); + return ''; + } + + return $this->render('@registrations/edit.twig', array( + 'item' => null, + 'statuses' => Model::statuses(), + 'risk_classes' => Model::riskClasses(), + )); + } + + public function edit(array $params = array()) + { + if (!Permission::check('view_registrations')) { + Response::forbidden(); + return ''; + } + + $item = Model::one(isset($params['id']) ? (int) $params['id'] : 0); + if (!$item) { + Response::notFound(); + return ''; + } + + return $this->render('@registrations/edit.twig', array( + 'item' => $item, + 'statuses' => Model::statuses(), + 'risk_classes' => Model::riskClasses(), + 'can_manage' => Permission::check('manage_registrations'), + )); + } + + public function store(array $params = array()) + { + if (($error = $this->manageGuard()) !== null) { + return $error; + } + + try { + $item = Model::create($this->input()); + return $this->success('Удостоверение добавлено', array( + 'data' => array('id' => $item['id'], 'redirect' => $this->base() . '/registrations'), + )); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array('number' => $e->getMessage()), 422); + } + } + + public function update(array $params = array()) + { + if (($error = $this->manageGuard()) !== null) { + return $error; + } + + try { + $item = Model::update(isset($params['id']) ? (int) $params['id'] : 0, $this->input()); + if (!$item) { + return $this->error('Удостоверение не найдено', array(), 404); + } + + return $this->success('Сохранено', array('data' => array('id' => $item['id']))); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array('number' => $e->getMessage()), 422); + } + } + + public function delete(array $params = array()) + { + if (($error = $this->manageGuard()) !== null) { + return $error; + } + + $deleted = Model::delete(isset($params['id']) ? (int) $params['id'] : 0); + if (!$deleted) { + return $this->error('Удостоверение не найдено', array(), 404); + } + + return $this->success('Удостоверение удалено', array( + 'data' => array('summary' => Model::summary()), + )); + } + + protected function input() + { + return array( + 'number' => Request::postStr('number', ''), + 'holder' => Request::postStr('holder', ''), + 'product_name' => Request::postStr('product_name', ''), + 'risk_class' => Request::postStr('risk_class', ''), + 'status' => Request::postStr('status', 'active'), + 'document_id' => Request::postInt('document_id', 0), + 'scan_url' => Request::postStr('scan_url', ''), + 'issued_at' => Request::postStr('issued_at', ''), + 'valid_until' => Request::postStr('valid_until', ''), + 'perpetual' => Request::postInt('perpetual', 0), + 'note' => Request::postStr('note', ''), + ); + } + + protected function manageGuard() + { + if (($error = $this->csrfGuard()) !== null) { + return $error; + } + + return Permission::check('manage_registrations') + ? null + : $this->error('Недостаточно прав', array(), 403); + } + } diff --git a/adminx/modules/Registrations/Model.php b/adminx/modules/Registrations/Model.php new file mode 100644 index 0000000..e69e387 --- /dev/null +++ b/adminx/modules/Registrations/Model.php @@ -0,0 +1,269 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Registrations; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + + /** + * Реестр регистрационных удостоверений Росздравнадзора (РУ) на медизделия. + * + * Юридически обязателен для продажи медтехники. Мягкая связь с товаром + * (document_id nullable) — жёсткий FK не нужен, товар может быть удалён/заменён. + */ + class Model + { + /** Статусы РУ: code => [label, color]. */ + public static function statuses() + { + return array( + 'active' => array('label' => 'Действует', 'color' => 'green'), + 'suspended' => array('label' => 'Приостановлено', 'color' => 'amber'), + 'expired' => array('label' => 'Истекло', 'color' => 'gray'), + 'cancelled' => array('label' => 'Аннулировано', 'color' => 'red'), + ); + } + + /** Классы риска медизделий по номенклатуре Росздравнадзора. */ + public static function riskClasses() + { + return array('1', '2a', '2b', '3'); + } + + public static function normalizeStatus($status, $fallback = 'active') + { + $status = trim((string) $status); + return array_key_exists($status, self::statuses()) ? $status : $fallback; + } + + public static function normalizeRisk($risk) + { + $risk = trim((string) $risk); + return in_array($risk, self::riskClasses(), true) ? $risk : ''; + } + + public static function table() + { + return Schema::table('registration_certificates'); + } + + /** Список с фильтрами: status, risk_class, q (номер/владелец/изделие). */ + public static function all(array $filters = array()) + { + $sql = 'SELECT * FROM ' . self::table() . ' WHERE 1=1'; + $args = array(); + + $status = isset($filters['status']) ? self::normalizeStatusFilter($filters['status']) : ''; + if ($status !== '') { + $sql .= ' AND status = %s'; + $args[] = $status; + } + + $risk = isset($filters['risk_class']) ? self::normalizeRisk($filters['risk_class']) : ''; + if ($risk !== '') { + $sql .= ' AND risk_class = %s'; + $args[] = $risk; + } + + $q = isset($filters['q']) ? trim((string) $filters['q']) : ''; + if ($q !== '') { + $sql .= ' AND (number LIKE %ss OR holder LIKE %ss OR product_name LIKE %ss)'; + $args[] = $q; + $args[] = $q; + $args[] = $q; + } + + $sql .= ' ORDER BY updated_at DESC, id DESC'; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + + return array_map(array(self::class, 'normalize'), $rows ?: array()); + } + + public static function summary() + { + $row = DB::query( + 'SELECT COUNT(*) AS total,' + . ' SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS active,' + . ' SUM(CASE WHEN status <> %s AND status <> %s THEN 1 ELSE 0 END) AS inactive' + . ' FROM ' . self::table(), + 'active', + 'active', + 'expired' + )->getAssoc(); + + // Действующие РУ со сроком, истекающим в ближайшие 60 дней. + $expiring = (int) DB::query( + 'SELECT COUNT(*) FROM ' . self::table() + . ' WHERE status = %s AND perpetual = 0 AND valid_until > 0 AND valid_until < %i', + 'active', + strtotime('+60 days') + )->getValue(); + + return array( + 'total' => isset($row['total']) ? (int) $row['total'] : 0, + 'active' => isset($row['active']) ? (int) $row['active'] : 0, + 'inactive' => isset($row['inactive']) ? (int) $row['inactive'] : 0, + 'expiring' => $expiring, + ); + } + + public static function one($id) + { + $row = DB::query('SELECT * FROM ' . self::table() . ' WHERE id = %i LIMIT 1', (int) $id)->getAssoc(); + return $row ? self::normalize($row) : null; + } + + public static function create(array $data) + { + $fields = self::sanitize($data); + $now = time(); + $fields['created_at'] = $now; + $fields['updated_at'] = $now; + DB::Insert(self::table(), $fields); + return self::one((int) DB::insertId()); + } + + public static function update($id, array $data) + { + if (!self::one($id)) { + return null; + } + + $fields = self::sanitize($data); + $fields['updated_at'] = time(); + DB::Update(self::table(), $fields, 'id = %i', (int) $id); + return self::one($id); + } + + public static function delete($id) + { + if (!self::one($id)) { + return false; + } + + DB::Delete(self::table(), 'id = %i', (int) $id); + return true; + } + + protected static function sanitize(array $data) + { + $number = trim((string) (isset($data['number']) ? $data['number'] : '')); + if ($number === '') { + throw new \RuntimeException('Укажите номер РУ'); + } + + $product = trim((string) (isset($data['product_name']) ? $data['product_name'] : '')); + if ($product === '') { + throw new \RuntimeException('Укажите наименование медизделия'); + } + + $perpetual = !empty($data['perpetual']) ? 1 : 0; + $validUntil = $perpetual ? 0 : self::dateToTs(isset($data['valid_until']) ? $data['valid_until'] : ''); + + return array( + 'number' => mb_substr($number, 0, 120, 'UTF-8'), + 'holder' => mb_substr(trim((string) (isset($data['holder']) ? $data['holder'] : '')), 0, 255, 'UTF-8'), + 'product_name' => mb_substr($product, 0, 255, 'UTF-8'), + 'risk_class' => self::normalizeRisk(isset($data['risk_class']) ? $data['risk_class'] : ''), + 'status' => self::normalizeStatus(isset($data['status']) ? $data['status'] : 'active'), + 'document_id' => self::nullableId(isset($data['document_id']) ? $data['document_id'] : null), + 'scan_url' => self::url(isset($data['scan_url']) ? $data['scan_url'] : ''), + 'issued_at' => self::dateToTs(isset($data['issued_at']) ? $data['issued_at'] : ''), + 'valid_until' => $validUntil, + 'perpetual' => $perpetual, + 'note' => mb_substr(trim((string) (isset($data['note']) ? $data['note'] : '')), 0, 2000, 'UTF-8'), + ); + } + + public static function normalize($row) + { + $row = (array) $row; + $statuses = self::statuses(); + $status = self::normalizeStatus(isset($row['status']) ? $row['status'] : ''); + + $row['id'] = (int) $row['id']; + $row['number'] = (string) $row['number']; + $row['holder'] = (string) $row['holder']; + $row['product_name'] = (string) $row['product_name']; + $row['risk_class'] = (string) $row['risk_class']; + $row['status'] = $status; + $row['status_label'] = $statuses[$status]['label']; + $row['status_color'] = $statuses[$status]['color']; + $row['document_id'] = isset($row['document_id']) && $row['document_id'] !== null ? (int) $row['document_id'] : null; + $row['scan_url'] = (string) $row['scan_url']; + $row['issued_at'] = (int) $row['issued_at']; + $row['valid_until'] = (int) $row['valid_until']; + $row['perpetual'] = (int) $row['perpetual'] === 1; + $row['issued_label'] = self::tsLabel($row['issued_at']); + $row['issued_input'] = self::tsInput($row['issued_at']); + $row['valid_input'] = self::tsInput($row['valid_until']); + $row['valid_label'] = $row['perpetual'] ? 'бессрочно' : self::tsLabel($row['valid_until']); + $row['is_expiring'] = !$row['perpetual'] && $row['valid_until'] > 0 + && $row['valid_until'] < strtotime('+60 days') && $status === 'active'; + $row['note'] = (string) $row['note']; + return $row; + } + + protected static function normalizeStatusFilter($status) + { + $status = trim((string) $status); + return array_key_exists($status, self::statuses()) ? $status : ''; + } + + protected static function nullableId($value) + { + $value = (int) $value; + return $value > 0 ? $value : null; + } + + protected static function url($value) + { + $value = trim((string) $value); + if ($value === '') { + return ''; + } + + // Разрешаем только http(s) и локальные пути; прочее отбрасываем. + if (preg_match('#^https?://#i', $value) || strpos($value, '/') === 0) { + return mb_substr($value, 0, 500, 'UTF-8'); + } + + return ''; + } + + protected static function dateToTs($value) + { + $value = trim((string) $value); + if ($value === '') { + return 0; + } + + $ts = strtotime($value); + return $ts === false ? 0 : $ts; + } + + protected static function tsLabel($ts) + { + $ts = (int) $ts; + return $ts > 0 ? date('d.m.Y', $ts) : ''; + } + + protected static function tsInput($ts) + { + $ts = (int) $ts; + return $ts > 0 ? date('Y-m-d', $ts) : ''; + } + } diff --git a/adminx/modules/Registrations/Schema.php b/adminx/modules/Registrations/Schema.php new file mode 100644 index 0000000..80ffd7e --- /dev/null +++ b/adminx/modules/Registrations/Schema.php @@ -0,0 +1,42 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Registrations; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\PublicConfiguration; + + /** + * Резолвер таблиц модуля «Регистрационные удостоверения». + * + * Данные принадлежат удаляемому пакету → модульный префикс (тот же домен, что в + * миграции: {{module_prefix}}_registration_*). Живёт внутри /adminx. + */ + class Schema + { + protected static $allowed = array( + 'registration_certificates', + ); + + public static function table($suffix) + { + $suffix = (string) $suffix; + if (!in_array($suffix, self::$allowed, true)) { + throw new \InvalidArgumentException('Некорректная таблица регистрационных удостоверений'); + } + + return PublicConfiguration::prefix('module') . '_' . $suffix; + } + } diff --git a/adminx/modules/Registrations/assets/registrations.css b/adminx/modules/Registrations/assets/registrations.css new file mode 100644 index 0000000..08d7fc4 --- /dev/null +++ b/adminx/modules/Registrations/assets/registrations.css @@ -0,0 +1,80 @@ +/* Модуль «Регистрационные удостоверения»: реестр и форма (оформление AdminKit). */ +.registrations-summary { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; + margin-bottom: 18px; +} +@media (max-width: 1100px) { + .registrations-summary { + grid-template-columns: repeat(2, 1fr); + } +} +@media (max-width: 560px) { + .registrations-summary { + grid-template-columns: 1fr; + } +} +.registrations-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + flex-wrap: wrap; +} +.registrations-filters { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.registrations-filters .input-icon { + flex: 1 1 220px; + min-width: 180px; +} +.registrations-number { + display: flex; + flex-direction: column; + line-height: 1.35; +} +.registrations-number small { + color: var(--text-secondary, #64748b); +} +.registrations-table .ta-right { + text-align: right; +} +.registrations-table .mono { + font-variant-numeric: tabular-nums; +} +.registrations-form { + padding: 20px 22px 24px; +} +.registrations-form .form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px 18px; + margin-bottom: 8px; +} +.registrations-form .field-wide { + grid-column: 1 / -1; +} +.registrations-form .field-check { + display: flex; + align-items: center; + gap: 10px; + align-self: end; + padding-bottom: 6px; +} +@media (max-width: 720px) { + .registrations-form .form-grid { + grid-template-columns: 1fr; + } +} +.registrations-form-actions { + display: flex; + gap: 10px; + align-items: center; + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--border, #e2e8f0); +} diff --git a/adminx/modules/Registrations/assets/registrations.js b/adminx/modules/Registrations/assets/registrations.js new file mode 100644 index 0000000..a024ac8 --- /dev/null +++ b/adminx/modules/Registrations/assets/registrations.js @@ -0,0 +1,96 @@ +/* +|-------------------------------------------------------------------------------------- +| AVE.cms — модуль «Регистрационные удостоверения» (adminx) +|-------------------------------------------------------------------------------------- +| Adminx.Registrations: сабмит формы, «бессрочно», удаление — единый Ajax/Toast/Confirm. +*/ +(function () { + 'use strict'; + + if (typeof window.Adminx === 'undefined') { return; } + var Adminx = window.Adminx; + + Adminx.Registrations = { + init: function () { + this.bindPerpetual(); + this.bindForm(); + this.bindDelete(); + }, + + bindPerpetual: function () { + var toggle = document.querySelector('[data-reg-perpetual]'); + var valid = document.querySelector('[data-reg-valid]'); + if (!toggle || !valid) { return; } + var sync = function () { valid.disabled = toggle.checked; if (toggle.checked) { valid.value = ''; } }; + toggle.addEventListener('change', sync); + sync(); + }, + + bindForm: function () { + var form = document.querySelector('[data-reg-form]'); + if (!form) { return; } + + form.addEventListener('submit', function (event) { + event.preventDefault(); + var submit = form.querySelector('[type="submit"]'); + if (submit) { submit.disabled = true; } + + Adminx.Ajax.post(form.getAttribute('data-action'), new FormData(form)).then(function (res) { + var body = res.data || {}; + if (res.ok && body.success) { + Adminx.Toast.show(body.message || 'Сохранено', 'success'); + var redirect = form.getAttribute('data-redirect') || (body.data && body.data.redirect); + if (redirect) { + window.setTimeout(function () { window.location.href = redirect; }, 350); + } + return; + } + + Adminx.Toast.show(body.message || 'Не удалось сохранить', 'error'); + }).catch(function () { + Adminx.Toast.show('Сервер недоступен. Повторите попытку.', 'error'); + }).then(function () { + if (submit) { submit.disabled = false; } + }); + }); + }, + + bindDelete: function () { + document.addEventListener('click', function (event) { + var trigger = event.target.closest('[data-reg-delete]'); + if (!trigger) { return; } + event.preventDefault(); + + var id = trigger.getAttribute('data-reg-delete'); + Adminx.Confirm.open({ + kind: 'error', + title: 'Удалить удостоверение?', + message: 'Запись будет удалена из реестра без возможности восстановления.', + confirmLabel: 'Удалить', + confirmClass: 'btn-danger', + onConfirm: function () { + Adminx.Ajax.post(Adminx.base() + '/registrations/' + id + '/delete', new FormData()).then(function (res) { + var body = res.data || {}; + if (res.ok && body.success) { + var row = document.querySelector('tr[data-id="' + id + '"]'); + if (row) { row.remove(); } + Adminx.Toast.show(body.message || 'Удалено', 'success'); + return; + } + + Adminx.Toast.show(body.message || 'Не удалось удалить', 'error'); + }).catch(function () { + Adminx.Toast.show('Сервер недоступен. Повторите попытку.', 'error'); + }); + } + }); + }); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.Registrations.init(); }); + } else { + Adminx.Registrations.init(); + } +})(); diff --git a/adminx/modules/Registrations/language/en/client.xml b/adminx/modules/Registrations/language/en/client.xml new file mode 100644 index 0000000..effc451 --- /dev/null +++ b/adminx/modules/Registrations/language/en/client.xml @@ -0,0 +1,12 @@ + + + The entry will be deleted from the registry without the possibility of recovery. + Failed to delete + Delete + The server is unavailable. Please try again. + Failed to save + Deleted + Delete ID? + Saved + Saved + diff --git a/adminx/modules/Registrations/language/en/interface.xml b/adminx/modules/Registrations/language/en/interface.xml new file mode 100644 index 0000000..444e6ec --- /dev/null +++ b/adminx/modules/Registrations/language/en/interface.xml @@ -0,0 +1,71 @@ + + + https://… or /media/… + Product (document) ID + indefinitely + Owner + RU owner/manufacturer + Total RU + Issued + Registration date + Action + Valid until + Valid + Add RU + ≤60 days expire. + To the register + Risk class + Comment + Medical product + Name of medical product * + New + New ID + RU number + RU number * + Number, owner, product... + Open scan + Suspended/cancelled + Reg. certificates + Registration certificate of Roszdravnadzor for a medical device. + Registration certificates + Register of RU + Register of Roszdravnadzor for medical devices and their validity periods. + RZN 2020/12345 + RU + Communication and document + scan attached + Deadline + Deadlines + Link to scan of RU + Product and scan + Certificate + No identities have been added yet. + Filter by status, risk class and search by number, owner, product. + - not specified - + Canceled + Valid + The entry will be deleted from the registry without the possibility of recovery. + Expired + Catalog + Failed to save + Failed to delete + Insufficient rights + Incorrect registration certificate table + Suspended + View the register of registration certificates. + Reg. credentials: view + Reg. credentials: management + Register of registration certificates of Roszdravnadzor + Register of Roszdravnadzor for medical devices: risk class, validity period, connection with the product. + The server is unavailable. Please try again. + Creating, changing and deleting registration certificates. + Saved + Deleted + Delete + Delete ID? + Identity added + Identity not found + Identity deleted + Please indicate the name of the medical product + Specify RU number + diff --git a/adminx/modules/Registrations/language/en/runtime.xml b/adminx/modules/Registrations/language/en/runtime.xml new file mode 100644 index 0000000..6045999 --- /dev/null +++ b/adminx/modules/Registrations/language/en/runtime.xml @@ -0,0 +1,25 @@ + + + Canceled + Valid + Expired + Catalog + Insufficient rights + Incorrect registration certificate table + Suspended + View the register of registration certificates. + Reg. certificates + Reg. credentials: view + Reg. credentials: management + Registration certificates + Register of Roszdravnadzor for medical devices: risk class, validity period, connection with the product. + Register of registration certificates of Roszdravnadzor + Creating, changing and deleting registration certificates. + Saved + Identity added + Identity not found + Identity deleted + Please indicate the name of the medical product + Specify RU number + indefinitely + diff --git a/adminx/modules/Registrations/language/ru/client.xml b/adminx/modules/Registrations/language/ru/client.xml new file mode 100644 index 0000000..34bbd16 --- /dev/null +++ b/adminx/modules/Registrations/language/ru/client.xml @@ -0,0 +1,12 @@ + + + Запись будет удалена из реестра без возможности восстановления. + Не удалось удалить + Удалить + Сервер недоступен. Повторите попытку. + Не удалось сохранить + Удалено + Удалить удостоверение? + Сохранено + Сохранено + diff --git a/adminx/modules/Registrations/language/ru/interface.xml b/adminx/modules/Registrations/language/ru/interface.xml new file mode 100644 index 0000000..aa860cb --- /dev/null +++ b/adminx/modules/Registrations/language/ru/interface.xml @@ -0,0 +1,71 @@ + + + https://… или /media/… + ID товара (документа) + бессрочно + Владелец + Владелец РУ / производитель + Всего РУ + Выдано + Дата регистрации + Действие + Действует до + Действуют + Добавить РУ + Истекают ≤60 дн. + К реестру + Класс риска + Комментарий + Медизделие + Наименование медизделия * + Новое + Новое удостоверение + Номер РУ + Номер РУ * + Номер, владелец, изделие… + Открыть скан + Приостановлены/аннулированы + Рег. удостоверения + Регистрационное удостоверение Росздравнадзора на медицинское изделие. + Регистрационные удостоверения + Реестр РУ + Реестр РУ Росздравнадзора на медизделия и их сроки действия. + РЗН 2020/12345 + РУ + Связь и документ + скан прикреплён + Срок + Сроки + Ссылка на скан РУ + Товар и скан + Удостоверение + Удостоверения ещё не добавлены. + Фильтр по статусу, классу риска и поиск по номеру, владельцу, изделию. + — не указан — + Аннулировано + Действует + Запись будет удалена из реестра без возможности восстановления. + Истекло + Каталог + Не удалось сохранить + Не удалось удалить + Недостаточно прав + Некорректная таблица регистрационных удостоверений + Приостановлено + Просмотр реестра регистрационных удостоверений. + Рег. удостоверения: просмотр + Рег. удостоверения: управление + Реестр регистрационных удостоверений Росздравнадзора + Реестр РУ Росздравнадзора на медизделия: класс риска, срок действия, связь с товаром. + Сервер недоступен. Повторите попытку. + Создание, изменение и удаление регистрационных удостоверений. + Сохранено + Удалено + Удалить + Удалить удостоверение? + Удостоверение добавлено + Удостоверение не найдено + Удостоверение удалено + Укажите наименование медизделия + Укажите номер РУ + diff --git a/adminx/modules/Registrations/language/ru/runtime.xml b/adminx/modules/Registrations/language/ru/runtime.xml new file mode 100644 index 0000000..5ce5f84 --- /dev/null +++ b/adminx/modules/Registrations/language/ru/runtime.xml @@ -0,0 +1,25 @@ + + + Аннулировано + Действует + Истекло + Каталог + Недостаточно прав + Некорректная таблица регистрационных удостоверений + Приостановлено + Просмотр реестра регистрационных удостоверений. + Рег. удостоверения + Рег. удостоверения: просмотр + Рег. удостоверения: управление + Регистрационные удостоверения + Реестр РУ Росздравнадзора на медизделия: класс риска, срок действия, связь с товаром. + Реестр регистрационных удостоверений Росздравнадзора + Создание, изменение и удаление регистрационных удостоверений. + Сохранено + Удостоверение добавлено + Удостоверение не найдено + Удостоверение удалено + Укажите наименование медизделия + Укажите номер РУ + бессрочно + diff --git a/adminx/modules/Registrations/migrations/001_create_registration_certificates.sql b/adminx/modules/Registrations/migrations/001_create_registration_certificates.sql new file mode 100644 index 0000000..b102dfa --- /dev/null +++ b/adminx/modules/Registrations/migrations/001_create_registration_certificates.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS `{{module_prefix}}_registration_certificates` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `number` VARCHAR(120) NOT NULL DEFAULT '', + `holder` VARCHAR(255) NOT NULL DEFAULT '', + `product_name` VARCHAR(255) NOT NULL DEFAULT '', + `risk_class` VARCHAR(4) NOT NULL DEFAULT '', + `status` VARCHAR(20) NOT NULL DEFAULT 'active', + `document_id` INT UNSIGNED NULL DEFAULT NULL, + `scan_url` VARCHAR(500) NOT NULL DEFAULT '', + `issued_at` INT UNSIGNED NOT NULL DEFAULT 0, + `valid_until` INT UNSIGNED NOT NULL DEFAULT 0, + `perpetual` TINYINT(1) NOT NULL DEFAULT 0, + `note` TEXT NOT NULL, + `created_at` INT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_reg_status` (`status`, `updated_at`), + KEY `idx_reg_risk` (`risk_class`), + KEY `idx_reg_document` (`document_id`), + KEY `idx_reg_number` (`number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Registrations/migrations/002_register_registration_permissions.sql b/adminx/modules/Registrations/migrations/002_register_registration_permissions.sql new file mode 100644 index 0000000..a472ab7 --- /dev/null +++ b/adminx/modules/Registrations/migrations/002_register_registration_permissions.sql @@ -0,0 +1,8 @@ +INSERT INTO `{{prefix}}_permissions` + (`code`, `module`, `group_code`, `name`, `description`, `sort_order`) +VALUES + ('view_registrations', 'registrations', 'navigation', 'Рег. удостоверения: просмотр', 'Просмотр реестра регистрационных удостоверений.', 10), + ('manage_registrations', 'registrations', 'content', 'Рег. удостоверения: управление', 'Создание, изменение и удаление регистрационных удостоверений.', 20) +ON DUPLICATE KEY UPDATE + `module` = VALUES(`module`), `group_code` = VALUES(`group_code`), + `name` = VALUES(`name`), `description` = VALUES(`description`), `sort_order` = VALUES(`sort_order`); diff --git a/adminx/modules/Registrations/migrations/uninstall.sql b/adminx/modules/Registrations/migrations/uninstall.sql new file mode 100644 index 0000000..cec290e --- /dev/null +++ b/adminx/modules/Registrations/migrations/uninstall.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS `{{module_prefix}}_registration_certificates`; +DELETE FROM `{{prefix}}_permissions` WHERE `module` = 'registrations'; diff --git a/adminx/modules/Registrations/module.php b/adminx/modules/Registrations/module.php new file mode 100644 index 0000000..3da9899 --- /dev/null +++ b/adminx/modules/Registrations/module.php @@ -0,0 +1,88 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + return array( + 'code' => 'registrations', + 'name' => 'Регистрационные удостоверения', + 'version' => '1.0.0', + 'description' => 'Реестр РУ Росздравнадзора на медизделия: класс риска, срок действия, связь с товаром.', + 'author' => 'AVE.cms', + 'lifecycle' => array( + 'managed' => true, + 'uninstall' => array('migrations/uninstall.sql'), + ), + 'package' => array('removable' => true), + 'permissions' => array( + 'key' => 'registrations', + 'items' => array( + array( + 'code' => 'view_registrations', + 'group_code' => 'navigation', + 'name' => 'Рег. удостоверения: просмотр', + 'description' => 'Просмотр реестра регистрационных удостоверений.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_registrations', + 'group_code' => 'content', + 'name' => 'Рег. удостоверения: управление', + 'description' => 'Создание, изменение и удаление регистрационных удостоверений.', + 'sort_order' => 20, + ), + ), + 'icon' => 'ti ti-file-certificate', + 'priority' => 41, + ), + 'routes' => array( + array('GET', '/registrations', array(\App\Adminx\Registrations\Controller::class, 'index')), + array('GET', '/registrations/create', array(\App\Adminx\Registrations\Controller::class, 'create')), + array('POST', '/registrations', array(\App\Adminx\Registrations\Controller::class, 'store')), + array('GET', '/registrations/{id}/edit', array(\App\Adminx\Registrations\Controller::class, 'edit')), + array('POST', '/registrations/{id}/delete', array(\App\Adminx\Registrations\Controller::class, 'delete')), + array('POST', '/registrations/{id}', array(\App\Adminx\Registrations\Controller::class, 'update')), + ), + 'migrations' => array( + array('id' => '001_create_registration_certificates', 'file' => 'migrations/001_create_registration_certificates.sql'), + array('id' => '002_register_registration_permissions', 'file' => 'migrations/002_register_registration_permissions.sql'), + ), + 'assets' => array( + 'styles' => array( + array('url' => ADMINX_BASE . '/modules/Registrations/assets/registrations.css', 'priority' => 41), + ), + 'scripts' => array( + array('url' => ADMINX_BASE . '/modules/Registrations/assets/registrations.js', 'priority' => 41), + ), + ), + 'admin_extension' => array( + 'url' => '/registrations', + 'icon' => 'ti ti-file-certificate', + 'feature' => 'Реестр регистрационных удостоверений Росздравнадзора', + 'menu' => array( + array( + 'code' => 'registrations', + 'label' => 'Рег. удостоверения', + 'url' => '/registrations', + 'icon' => 'ti ti-file-certificate', + 'permission' => 'view_registrations', + 'group' => 'Каталог', + 'sort_order' => 40, + 'match' => array( + '/registrations', + ), + ), + ), + ), + ); diff --git a/adminx/modules/Registrations/view/edit.twig b/adminx/modules/Registrations/view/edit.twig new file mode 100644 index 0000000..0fd8634 --- /dev/null +++ b/adminx/modules/Registrations/view/edit.twig @@ -0,0 +1,60 @@ +{% extends '@adminx/main.twig' %} +{% block title %}{{ item ? 'РУ ' ~ item.number : 'Новое удостоверение' }}{% endblock %} +{% block content %} +{% set can_edit = item is null or can_manage %} + + + + +
    + + +
    Удостоверение

    Основные данные

    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    + +
    Сроки

    Действие

    +
    +
    +
    +
    + + Бессрочно +
    +
    + +
    Связь и документ

    Товар и скан

    +
    +
    +
    +
    +
    + + {% if can_edit %} +
    + + Отмена +
    + {% endif %} +
    +{% endblock %} diff --git a/adminx/modules/Registrations/view/index.twig b/adminx/modules/Registrations/view/index.twig new file mode 100644 index 0000000..ba514c4 --- /dev/null +++ b/adminx/modules/Registrations/view/index.twig @@ -0,0 +1,76 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Регистрационные удостоверения{% endblock %} +{% block content %} + + + + +
    +
    {{ summary.total }}
    Всего РУ
    +
    {{ summary.active }}
    Действуют
    +
    {{ summary.expiring }}
    Истекают ≤60 дн.
    +
    {{ summary.inactive }}
    Приостановлены/аннулированы
    +
    + +
    +
    +
    + +

    Реестр РУ

    Фильтр по статусу, классу риска и поиск по номеру, владельцу, изделию.

    +
    +
    + + +
    + + +
    + + {% if filters.status or filters.risk_class or filters.q %}Сбросить{% endif %} +
    +
    + +
    + + + + + + + {% for item in items %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
    Номер РУМедизделиеВладелецКлассВыданоСрокСтатус
    + + {{ item.number }} + {% if item.scan_url %} скан прикреплён{% endif %} + + {{ item.product_name }}{{ item.holder ?: '—' }}{% if item.risk_class %}{{ item.risk_class }}{% else %}{% endif %}{{ item.issued_label ?: '—' }}{% if item.perpetual %}бессрочно{% else %}{{ item.valid_label ?: '—' }}{% endif %}{{ item.status_label }} + {% if item.scan_url %}{% endif %} + + {% if can_manage %}{% endif %} +
    {% if filters.status or filters.risk_class or filters.q %}Ничего не найдено по фильтру.{% else %}Удостоверения ещё не добавлены.{% endif %}
    +
    +
    +{% endblock %} diff --git a/adminx/modules/Requests/Controller.php b/adminx/modules/Requests/Controller.php new file mode 100644 index 0000000..aaeba7f --- /dev/null +++ b/adminx/modules/Requests/Controller.php @@ -0,0 +1,682 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Requests; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Common\ErrorReport; + use App\Adminx\Support\CodeEditor; + use App\Content\Requests\RequestViewPreview; + use App\Content\Requests\RequestMatchExplainer; + use App\Content\Requests\RequestRendererRegistry; + use App\Content\Requests\RequestExecutorComparator; + use App\Helpers\Request; + + /** + * Раздел «Запросы»: список + редактор запроса (поля, шаблоны item/main в + * CodeMirror, условия). Управление данными; теги/PHP не исполняются. + */ + class Controller extends BaseController + { + /** GET /requests — список. */ + public function index(array $params = []) + { + AdminAssets::addStyle($this->base() . '/modules/Requests/assets/requests.css', 50); + AdminAssets::addScript($this->base() . '/modules/Requests/assets/requests.js', 50); + + return $this->render('@requests/index.twig', [ + 'requests' => Model::all(), + 'stats' => Model::stats(), + 'can_manage' => Permission::check('manage_requests'), + ]); + } + + /** GET /requests/native-audit — массовая безопасная сверка executors. */ + public function nativeAudit(array $params = array()) + { + AdminAssets::addStyle($this->base() . '/modules/Requests/assets/requests.css', 50); + AdminAssets::addScript($this->base() . '/modules/Requests/assets/requests.js', 50); + $rows = Model::nativeAuditRows(); + + return $this->render('@requests/native-audit.twig', array( + 'rows' => $rows, + 'stats' => Model::nativeAuditStats($rows), + 'can_manage' => Permission::check('manage_requests'), + )); + } + + /** POST /requests/native-audit/prepare — Shadow не меняет публичный результат. */ + public function nativeAuditPrepare(array $params = array()) + { + if (($resp = $this->guard()) !== null) { return $resp; } + $staged = Model::stageLegacyConditionValues(); + Model::setAllExecutorMode('shadow'); + $ids = array(); + foreach (Model::nativeAuditRows() as $row) { $ids[] = (int) $row['id']; } + $message = 'Все запросы переведены в безопасный режим Shadow'; + if (!empty($staged['conditions'])) { + $message .= '. Подготовлено декларативных условий: ' . (int) $staged['conditions']; + } + + return $this->success($message, array('data' => array('ids' => $ids, 'staged' => $staged))); + } + + /** POST /requests/{id}/native-audit — сверка и фиксация хеша плана. */ + public function nativeAuditOne(array $params = array()) + { + if (($resp = $this->guard()) !== null) { return $resp; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::find($id)) { return $this->error('Запрос не найден', array(), 404); } + + try { + $comparison = (new RequestExecutorComparator())->compare($id, 20); + if (Model::conditionValuesEquivalent($comparison)) { + $finalized = Model::finalizeStagedConditionValues( + $id, + isset($comparison['runtime_verification']) ? $comparison['runtime_verification'] : array() + ); + if (is_array($finalized)) { + $comparison = $finalized; + $comparison['condition_tags_finalized'] = true; + } + } + + Model::recordNativeVerification($id, $comparison); + } catch (\Throwable $e) { + return $this->error(ErrorReport::publicMessage( + 'Теневое сравнение не выполнено', + $e, + 'REQUEST_NATIVE_AUDIT', + array('request_id' => $id) + ), array(), 422); + } + + $message = !empty($comparison['condition_tags_finalized']) + ? 'Запрос проверен, PHP-условия заменены декларативными тегами' + : 'Запрос проверен'; + return $this->success($message, array('data' => array('comparison' => $comparison))); + } + + /** POST /requests/native-audit/activate — только подтверждённые планы. */ + public function nativeAuditActivate(array $params = array()) + { + if (($resp = $this->guard()) !== null) { return $resp; } + $count = Model::activateVerifiedNative(); + return $this->success('Native включён только для подтверждённых запросов', array('data' => array('count' => $count))); + } + + /** POST /requests/native-audit/rollback — мгновенный общий откат. */ + public function nativeAuditRollback(array $params = array()) + { + if (($resp = $this->guard()) !== null) { return $resp; } + $count = Model::setAllExecutorMode('legacy'); + return $this->success('Все запросы возвращены в Legacy', array('data' => array('count' => $count))); + } + + /** POST /requests/{id}/native-stabilize — safe ID tiebreaker with rollback. */ + public function nativeAuditStabilize(array $params = array()) + { + if (($resp = $this->guard()) !== null) { return $resp; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::find($id)) { return $this->error('Запрос не найден', array(), 404); } + + try { + $result = Model::stabilizeNativeOrder($id); + } catch (\Throwable $e) { + return $this->error(ErrorReport::publicMessage( + 'Не удалось безопасно закрепить порядок', + $e, + 'REQUEST_NATIVE_STABILIZE', + array('request_id' => $id) + ), array(), 422); + } + + return $this->success((string) $result['message'], array('data' => $result)); + } + + /** GET /requests/new — форма создания. */ + public function createForm(array $params = []) + { + return $this->editView(null); + } + + /** GET /requests/{id} — форма редактирования. */ + public function edit(array $params = []) + { + $request = Model::find($params['id'] ?? 0); + if (!$request) { + return $this->editNotFound(); + } + + return $this->editView($request); + } + + protected function editView($request) + { + CodeEditor::useCodeMirror('application/x-httpd-php'); + AdminAssets::addStyle($this->base() . '/modules/Requests/assets/requests.css', 50); + AdminAssets::addScript($this->base() . '/modules/Requests/assets/requests.js', 50); + + $id = $request ? (int) $request->Id : 0; + $rubricFields = $id ? Model::rubricFields((int) $request->rubric_id) : array(); + + //-- Палитра тегов из общего реестра модуля Templates (если он есть). + $tagGroups = array(); + if (class_exists('App\\Adminx\\Templates\\TagRegistry')) { + $tagGroups = \App\Adminx\Templates\TagRegistry::groups(); + } + + if ($rubricFields) { + array_unshift($tagGroups, $this->rubricFieldTagGroup($rubricFields)); + } + + $nativePlan = $id ? Model::nativePlan($id) : array( + 'eligible' => false, + 'reasons' => array('Сначала сохраните запрос.'), + ); + + $paginationOptions = Model::paginationOptions(); + return $this->render('@requests/edit.twig', [ + 'request' => $request, + 'condition_tree' => $id ? Model::conditionTree($id) : [], + 'compares' => Model::compareOptions(), + 'condition_value_modes' => Model::conditionValueModes(), + 'rubrics' => Model::rubricOptions(), + 'rubric_fields' => $rubricFields, + 'order_options' => \App\Frontend\RequestSort::storedOptions(), + 'sort_rules' => Model::sortRules($request), + 'pagination_options' => $paginationOptions, + 'pagination_ids' => array_map(function ($item) { return (int) $item['id']; }, $paginationOptions), + 'result_contract' => Model::resultContract($request), + 'result_system_options' => \App\Content\Requests\RequestResultContract::systemOptions(), + 'preview_renderer' => Model::previewRenderer($request), + 'preview_renderers' => RequestRendererRegistry::previewOptions(), + 'executor_mode' => Model::executorMode($request), + 'native_plan' => $nativePlan, + 'native_verified' => $request ? Model::nativeVerified($request, $nativePlan) : false, + 'flags' => Model::$flags, + 'tag_groups' => $tagGroups, + 'saved' => Request::getInt('saved') === 1, + 'can_manage' => Permission::check('manage_requests'), + ]); + } + + /** Tags available only while editing one request result item. */ + protected function rubricFieldTagGroup(array $fields) + { + $items = array(); + foreach ($fields as $field) { + $id = isset($field->Id) ? (int) $field->Id : 0; + $alias = isset($field->rubric_field_alias) ? trim((string) $field->rubric_field_alias) : ''; + $key = $alias !== '' ? $alias : (string) $id; + if ($key === '' || $key === '0') { continue; } + + $title = isset($field->rubric_field_title) ? trim((string) $field->rubric_field_title) : ''; + $type = isset($field->condition_type_label) + ? trim((string) $field->condition_type_label) + : trim(isset($field->rubric_field_type) ? (string) $field->rubric_field_type : ''); + $tag = '[tag:rfld:' . $key . '][0]'; + $description = $title !== '' ? $title : ('Поле #' . $id); + if ($type !== '') { $description .= ' · ' . $type; } + + $items[] = array( + 'value' => $tag, + 'label' => $tag, + 'description' => $description, + 'select' => '', + ); + } + + return array( + 'title' => 'Поля рубрики', + 'scope' => 'item', + 'items' => $items, + ); + } + + protected function editNotFound() + { + \App\Helpers\Response::setStatus(404); + return $this->render('@adminx/404.twig', []); + } + + /** POST /requests — создать. */ + public function store(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $data = $this->input(); + if ($errors = $this->validate($data, 0)) { + return $this->error('Проверьте поля', $errors); + } + + $id = Model::create($data); + return $this->success('Запрос создан', ['redirect' => $this->base() . '/requests/' . $id . '?saved=1']); + } + + /** POST /requests/{id} — сохранить. */ + public function update(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $id = (int) ($params['id'] ?? 0); + $request = Model::find($id); + if (!$request) { + return $this->error('Запрос не найден', [], 404); + } + + $data = $this->input(); + if ($errors = $this->validate($data, $id)) { + return $this->error('Проверьте поля', $errors); + } + + Model::update($id, $data); + if ((int) $request->rubric_id !== (int) $data['rubric_id']) { + return $this->success('Сохранено', array( + 'redirect' => $this->base() . '/requests/' . $id . '?saved=1', + )); + } + + return $this->success('Сохранено'); + } + + /** POST /requests/{id}/copy — копия. */ + public function copy(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $id = (int) ($params['id'] ?? 0); + if (!Model::find($id)) { + return $this->error('Запрос не найден', [], 404); + } + + $newId = Model::copy($id); + return $this->success('Создана копия', ['redirect' => $this->base() . '/requests/' . $newId]); + } + + /** POST /requests/{id}/delete — удалить. */ + public function destroy(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $id = (int) ($params['id'] ?? 0); + if (!Model::find($id)) { + return $this->error('Запрос не найден', [], 404); + } + + Model::delete($id); + return $this->success('Запрос удалён'); + } + + /** POST /requests/lint — проверка PHP-синтаксиса кода шаблона (ajax). */ + public function lint(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $code = (string) Request::post('code', ''); + if (!class_exists('App\\Adminx\\Templates\\Syntax')) { + return $this->error('Проверка недоступна', [], 501); + } + + $r = \App\Adminx\Templates\Syntax::check($code); + if (!empty($r['ok'])) { + return $this->success($r['message'], ['data' => ['checked' => !empty($r['checked'])]]); + } + + return $this->error($r['message'], ['code' => $r['output']]); + } + + /** POST /requests/{id}/conditions/reorder — порядок условий (drag-and-drop). */ + public function conditionReorder(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $id = (int) ($params['id'] ?? 0); + if (!Model::find($id)) { + return $this->error('Запрос не найден', [], 404); + } + + $order = Request::post('items', Request::post('order', [])); + Model::reorderConditions($id, is_array($order) ? $order : []); + return $this->success('Порядок сохранён'); + } + + /** GET /requests/alias — проверка алиаса (ajax). */ + public function aliasCheck(array $params = []) + { + $alias = strtolower(trim(Request::getStr('alias'))); + $id = Request::getInt('id'); + $valid = (bool) preg_match('/^[a-z0-9_-]{1,20}$/', $alias); + return $this->success('', ['data' => [ + 'available' => $valid && !Model::aliasExists($alias, $id), + 'valid' => $valid, + ]]); + } + + /** GET /requests/documents/picker — выбор документа для relation-условия. */ + public function documentPicker(array $params = array()) + { + return $this->success('', array('data' => array( + 'items' => Model::documentPicker( + Request::getStr('q', ''), + Request::getStr('rubric_id', ''), + Request::getInt('limit', 30) + ), + ))); + } + + /** POST /requests/{id}/conditions — сохранить условие. */ + public function conditionSave(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $id = (int) ($params['id'] ?? 0); + if (!Model::find($id)) { + return $this->error('Запрос не найден', [], 404); + } + + try { + $condId = Model::conditionSave($id, [ + 'id' => Request::postInt('cond_id'), + 'condition_field_id' => Request::postInt('condition_field_id'), + 'condition_compare' => Request::postStr('condition_compare'), + 'condition_value' => (string) Request::post('condition_value', ''), + 'condition_value_source' => Request::postStr('condition_value_source'), + 'condition_value_key' => Request::postStr('condition_value_key'), + 'condition_value_mode' => Request::postStr('condition_value_mode'), + 'condition_value_constant' => (string) Request::post('condition_value_constant', ''), + 'condition_join' => Request::postStr('condition_join'), + 'condition_status' => Request::postBool('condition_status', false), + 'condition_group_id' => Request::postInt('condition_group_id'), + ]); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Условие сохранено', ['data' => ['id' => $condId]]); + } + + /** POST /requests/conditions/{id}/delete — удалить условие. */ + public function conditionDelete(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + Model::conditionDelete((int) ($params['id'] ?? 0)); + return $this->success('Условие удалено'); + } + + public function groupSave(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $requestId = (int) ($params['id'] ?? 0); + if (!Model::find($requestId)) { + return $this->error('Запрос не найден', [], 404); + } + + $groupId = Model::groupSave($requestId, array( + 'id' => Request::postInt('group_id'), + 'parent_id' => Request::postInt('parent_id'), + 'group_title' => Request::postStr('group_title'), + 'group_operator' => Request::postStr('group_operator'), + )); + if ($groupId <= 0) { + return $this->error('Не удалось сохранить группу', [], 422); + } + + return $this->success('Группа сохранена', array('data' => array('id' => $groupId))); + } + + public function groupDelete(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $requestId = (int) ($params['id'] ?? 0); + $groupId = (int) ($params['groupId'] ?? 0); + if (!Model::groupDelete($requestId, $groupId)) { + return $this->error('Корневую или чужую группу удалить нельзя', [], 422); + } + + return $this->success('Группа удалена. Ее содержимое перенесено выше.'); + } + + /** POST /requests/{id}/preview — первые строки через текущий executor. */ + public function preview(array $params = []) + { + if (($resp = $this->guardPermission('view_requests')) !== null) { + return $resp; + } + + $request = Model::find(isset($params['id']) ? (int) $params['id'] : 0); + if (!$request) { + return $this->error('Запрос не найден', array(), 404); + } + + $contract = \App\Content\Requests\RequestResultContract::normalize(array( + 'system' => Request::post('result_system', array()), + 'fields' => Request::post('result_fields', array()), + ), array_map(function ($field) { return (int) $field->Id; }, Model::rubricFields((int) $request->rubric_id))); + + try { + $preview = (new RequestViewPreview())->run( + (int) $request->Id, + $contract, + Request::postInt('limit', 10), + RequestRendererRegistry::normalizePreviewCode(Request::postStr('renderer', Model::previewRenderer($request))) + ); + } catch (\Throwable $e) { + return $this->error(ErrorReport::publicMessage( + 'Предпросмотр запроса не выполнен', + $e, + 'REQUEST_PREVIEW', + array('request_id' => (int) $request->Id) + ), array(), 422); + } + + $preview['plan'] = Model::previewPlan((int) $request->Id); + try { + $preview['executor_comparison'] = (new RequestExecutorComparator())->compare( + (int) $request->Id, + Request::postInt('limit', 10) + ); + Model::recordNativeVerification((int) $request->Id, $preview['executor_comparison']); + } catch (\Throwable $e) { + $preview['executor_comparison'] = array( + 'status' => 'error', + 'matched' => false, + 'reasons' => array(ErrorReport::publicMessage( + 'Теневое сравнение не выполнено', + $e, + 'REQUEST_EXECUTOR_COMPARE', + array('request_id' => (int) $request->Id) + )), + ); + } + + $preview['contract'] = $contract; + $preview['system_options'] = \App\Content\Requests\RequestResultContract::systemOptions(); + $preview['field_options'] = array(); + foreach (Model::rubricFields((int) $request->rubric_id) as $field) { + $preview['field_options'][(string) $field->Id] = array( + 'title' => (string) $field->rubric_field_title, + 'alias' => (string) $field->rubric_field_alias, + 'type' => (string) $field->rubric_field_type, + ); + } + + if (!Permission::check('manage_requests')) { + unset($preview['sql']); + } + + return $this->success('Предпросмотр обновлён', array('data' => $preview)); + } + + public function explainDocuments(array $params = array()) + { + if (($resp = $this->guardPermission('view_requests')) !== null) { return $resp; } + $requestId = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::find($requestId)) { return $this->error('Запрос не найден', array(), 404); } + + return $this->success('', array('data' => array( + 'items' => Model::explainDocuments($requestId, Request::getStr('q', ''), Request::getInt('limit', 12)), + ))); + } + + public function explain(array $params = array()) + { + if (($resp = $this->guardPermission('view_requests')) !== null) { return $resp; } + $requestId = isset($params['id']) ? (int) $params['id'] : 0; + $documentId = Request::postInt('document_id'); + if ($requestId <= 0 || $documentId <= 0) { + return $this->error('Выберите документ для проверки', array('document_id' => 'Документ не выбран'), 422); + } + + try { + $result = (new RequestMatchExplainer())->explain($requestId, $documentId); + } catch (\Throwable $e) { + return $this->error(ErrorReport::publicMessage( + 'Не удалось объяснить результат запроса', + $e, + 'REQUEST_EXPLAIN', + array('request_id' => $requestId, 'document_id' => $documentId) + ), array(), 422); + } + + return $this->success($result['matched'] ? 'Документ входит в запрос' : 'Документ исключён запросом', array('data' => $result)); + } + + // ------------------------------------------------------------------ // + + protected function guard() + { + return $this->guardPermission('manage_requests'); + } + + protected function input() + { + $rubricId = Request::postInt('rubric_id'); + $fieldIds = array_map(function ($field) { return (int) $field->Id; }, Model::rubricFields($rubricId)); + $sortInput = Request::post('request_sort_rules', null); + if ($sortInput === null) { + $sortInput = array(); + $fieldOrder = Request::postInt('request_order_by_nat'); + $direction = Request::postStr('request_asc_desc') === 'DESC' ? 'DESC' : 'ASC'; + if ($fieldOrder > 0) { $sortInput[] = array('source' => 'field', 'key' => $fieldOrder, 'direction' => $direction); } + $order = trim((string) Request::post('request_order_by', '')); + if (strtoupper($order) === 'RAND()') { $sortInput = array(array('source' => 'random', 'key' => 'RAND()', 'direction' => 'ASC')); } + elseif ($order !== '') { $sortInput[] = array('source' => 'document', 'key' => $order, 'direction' => $direction); } + $tie = \App\Frontend\RequestSort::tieBreaker(Request::postStr('request_order_tiebreaker')); + if ($tie !== '') { $sortInput[] = array('source' => 'document', 'key' => 'Id', 'direction' => $tie); } + } + + $sortInspection = \App\Frontend\RequestSort::inspectRules($sortInput, $fieldIds); + $legacySort = \App\Frontend\RequestSort::legacyStorage($sortInspection['rules']); + $d = [ + 'request_alias' => strtolower(trim(Request::postStr('request_alias'))), + 'rubric_id' => $rubricId, + 'request_title' => trim((string) Request::post('request_title', '')), + 'request_description' => trim((string) Request::post('request_description', '')), + 'request_order_by' => $legacySort['request_order_by'], + 'request_order_by_nat' => $legacySort['request_order_by_nat'], + 'request_asc_desc' => $legacySort['request_asc_desc'], + 'request_order_tiebreaker' => $legacySort['request_order_tiebreaker'], + 'request_sort_rules' => \App\Frontend\RequestSort::encodeRules($sortInspection['rules']), + 'request_items_per_page' => Request::postInt('request_items_per_page'), + 'request_pagination' => Request::postInt('request_pagination'), + 'request_cache_lifetime' => Request::postInt('request_cache_lifetime'), + 'request_template_item' => (string) Request::post('request_template_item', ''), + 'request_template_main' => (string) Request::post('request_template_main', ''), + 'request_result_contract' => Model::encodeResultContract($rubricId, array( + 'system' => Request::post('result_system', array()), + 'fields' => Request::post('result_fields', array()), + )), + 'request_preview_renderer' => RequestRendererRegistry::normalizePreviewCode(Request::postStr('request_preview_renderer', 'data_cards')), + 'request_executor_mode' => in_array(Request::postStr('request_executor_mode', 'legacy'), array('legacy', 'shadow', 'native'), true) + ? Request::postStr('request_executor_mode', 'legacy') + : 'legacy', + ]; + $d['_sort_errors'] = $sortInspection['errors']; + foreach (Model::$flags as $f) { + $d[$f] = Request::postBool($f, false) ? 1 : 0; + } + + return $d; + } + + protected function validate(array $data, $id) + { + $errors = []; + if ($data['request_alias'] === '') { + $errors['request_alias'] = 'Укажите алиас.'; + } elseif (!preg_match('/^[a-z0-9_-]{1,20}$/', $data['request_alias'])) { + $errors['request_alias'] = 'Алиас: латиница/цифры/-/_, до 20 символов.'; + } elseif (Model::aliasExists($data['request_alias'], (int) $id)) { + $errors['request_alias'] = 'Такой алиас уже используется.'; + } + + if ($data['request_title'] === '') { + $errors['request_title'] = 'Укажите название.'; + } + + if (!empty($data['_sort_errors'])) { + $errors['request_sort_rules'] = implode(' ', $data['_sort_errors']); + } + + $paginationIds = array_map(function ($item) { return (int) $item['id']; }, Model::paginationOptions()); + if (!$paginationIds) { + $errors['request_pagination'] = 'Сначала создайте шаблон пагинации в настройках.'; + } elseif (!in_array((int) $data['request_pagination'], $paginationIds, true)) { + $errors['request_pagination'] = 'Выберите существующий шаблон пагинации.'; + } + + if ($data['request_executor_mode'] === 'native') { + if ((int) $id <= 0) { + $errors['request_executor_mode'] = 'Сначала сохраните запрос в режиме Legacy или Shadow.'; + } else { + $plan = \App\Content\Requests\NativeRequestPlanCompiler::compile((int) $id, $data); + if (empty($plan['eligible'])) { + $errors['request_executor_mode'] = implode(' ', $plan['reasons']); + } elseif (!Model::nativeVerified(Model::find((int) $id), $plan)) { + $errors['request_executor_mode'] = 'Сначала сохраните изменения и получите точное совпадение Legacy/Native в предпросмотре.'; + } + } + } + + return $errors; + } + } diff --git a/adminx/modules/Requests/Model.php b/adminx/modules/Requests/Model.php new file mode 100644 index 0000000..0ddb8ef --- /dev/null +++ b/adminx/modules/Requests/Model.php @@ -0,0 +1,1261 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Requests; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Common\Auth; + use App\Common\FileCacheInvalidator; + use App\Common\Lock; + use App\Common\SystemTables; + use App\Content\ContentTables; + use App\Content\Fields\FieldRegistry; + use App\Content\Fields\FieldSettings; + use App\Content\Requests\RequestConditionValue; + + /** + * Запросы (листинги) и их условия. Данные ведём как есть; шаблоны (item/main) и + * значения условий (в т.ч. legacy-PHP) сохраняются без изменений и не исполняются. + */ + class Model + { + protected static function req() { return ContentTables::table('request'); } + protected static function cond() { return ContentTables::table('request_conditions'); } + protected static function groups() { return ContentTables::table('request_condition_groups'); } + + /** Редактируемые скалярные поля запроса. */ + protected static $scalar = array( + 'request_alias', 'rubric_id', 'request_title', 'request_description', + 'request_order_by', 'request_asc_desc', 'request_items_per_page', + 'request_pagination', 'request_template_item', 'request_template_main', + 'request_cache_lifetime', 'request_result_contract', + 'request_preview_renderer', 'request_executor_mode', 'request_order_by_nat', + 'request_order_tiebreaker', 'request_sort_rules', + ); + + /** Поля-флаги enum('0','1'). */ + public static $flags = array( + 'request_show_pagination', 'request_count_items', 'request_hide_current', + 'request_only_owner', 'request_ajax', 'request_external', + 'request_cache_elements', 'request_show_statistic', + 'request_show_sql', 'request_use_query', + ); + + /** Операторы условий (легаси-коды). */ + public static function compareOptions() + { + return array( + '==' => 'Равно', + '!=' => 'Не равно', + '%%' => 'Содержит текст', + '%' => 'Начинается с', + '--' => 'Не содержит текст', + '!-' => 'Не начинается с', + '>' => 'Больше', + '<' => 'Меньше', + '>=' => 'Больше или равно', + '<=' => 'Меньше или равно', + 'N>' => 'Число больше', + 'N<' => 'Число меньше', + 'N>=' => 'Число больше или равно', + 'N<=' => 'Число меньше или равно', + 'SEGMENT' => 'Диапазон, включая границы', + 'INTERVAL' => 'Диапазон, не включая границы', + 'IN=' => 'Входит в список', + 'NOTIN=' => 'Не входит в список', + 'ANY' => 'Совпадает с любым', + 'FRE' => 'Legacy SQL (совместимость)', + ); + } + + public static function conditionValueModes() + { + return array( + 'string_scalar' => 'Взять текст как есть', + 'integer_scalar' => 'Взять целое число', + 'decimal_scalar' => 'Взять десятичное число', + 'string_list' => 'Взять список текстовых значений', + 'integer_list' => 'Взять список чисел', + 'integer_range' => 'Взять диапазон от/до', + 'decimal_range' => 'Взять десятичный диапазон', + 'integer_member' => 'Обернуть значение: |значение|', + 'integer_member_list' => 'Обернуть список: |значение|', + 'presence_constant' => 'Подставить константу, если заполнено', + ); + } + + /** Сводка для карточек списка. */ + public static function stats() + { + return array( + 'requests' => (int) DB::query('SELECT COUNT(*) FROM ' . self::req())->getValue(), + 'conditions' => (int) DB::query('SELECT COUNT(*) FROM ' . self::cond())->getValue(), + 'groups' => (int) DB::query('SELECT COUNT(*) FROM ' . self::groups())->getValue(), + 'rubrics' => (int) DB::query('SELECT COUNT(DISTINCT rubric_id) FROM ' . self::req())->getValue(), + ); + } + + public static function all() + { + return DB::query( + 'SELECT r.Id, r.request_alias, r.rubric_id, r.request_title, r.request_order_by, + r.request_asc_desc, r.request_items_per_page, r.request_changed, + r.request_executor_mode, r.request_native_verified_hash, + (SELECT COUNT(*) FROM ' . self::cond() . ' c WHERE c.request_id = r.Id) AS cond_count + FROM ' . self::req() . ' r + ORDER BY r.request_alias ASC' + )->getAll(); + } + + public static function nativeAuditRows() + { + $rows = array(); + foreach (DB::query( + 'SELECT Id,request_alias,request_title,rubric_id,request_executor_mode,request_native_verified_hash,request_native_plan,' + . 'request_native_audit_status,request_native_audit_details' + . ' FROM ' . self::req() . ' ORDER BY request_title ASC,Id ASC' + )->getAll() as $request) { + $request = (object) $request; + $plan = \App\Content\Requests\NativeRequestPlanCompiler::compile((int) $request->Id); + $details = isset($request->request_native_audit_details) + ? json_decode((string) $request->request_native_audit_details, true) + : array(); + $rows[] = array( + 'id' => (int) $request->Id, + 'alias' => (string) $request->request_alias, + 'title' => (string) $request->request_title, + 'rubric_id' => (int) $request->rubric_id, + 'mode' => self::executorMode($request), + 'eligible' => !empty($plan['eligible']), + 'verified' => self::nativeVerified($request, $plan), + 'reasons' => isset($plan['reasons']) ? array_values($plan['reasons']) : array(), + 'audit_status' => isset($request->request_native_audit_status) + ? (string) $request->request_native_audit_status + : '', + 'audit_details' => is_array($details) ? $details : array(), + ); + } + + return $rows; + } + + public static function nativeAuditStats(array $rows) + { + $stats = array( + 'total' => count($rows), + 'eligible' => 0, + 'verified' => 0, + 'legacy' => 0, + 'shadow' => 0, + 'native' => 0, + 'unstable' => 0, + 'order_diff' => 0, + ); + foreach ($rows as $row) { + if (!empty($row['eligible'])) { $stats['eligible']++; } + if (!empty($row['verified'])) { $stats['verified']++; } + $mode = isset($row['mode']) && isset($stats[$row['mode']]) ? $row['mode'] : 'legacy'; + $stats[$mode]++; + $status = isset($row['audit_status']) ? (string) $row['audit_status'] : ''; + if (isset($stats[$status])) { $stats[$status]++; } + } + + return $stats; + } + + public static function setAllExecutorMode($mode) + { + $mode = in_array($mode, array('legacy', 'shadow'), true) ? $mode : 'legacy'; + $requests = DB::query('SELECT Id,request_alias FROM ' . self::req())->getAll(); + DB::query('UPDATE ' . self::req() . ' SET request_executor_mode=%s', $mode); + $affected = (int) DB::affectedRows(); + \App\Frontend\RequestRepository::reset(); + foreach ($requests as $request) { + FileCacheInvalidator::request((int) $request['Id'], (string) $request['request_alias']); + } + + return $affected; + } + + /** Adds typed descriptors beside legacy PHP; public Legacy execution is unchanged. */ + public static function stageLegacyConditionValues() + { + $count = 0; + $requests = array(); + $rows = DB::query( + 'SELECT * FROM ' . self::cond() . " WHERE condition_value LIKE '%getAll(); + foreach ($rows as $row) { + if (isset($row['condition_value_source']) && $row['condition_value_source'] === 'input') { continue; } + $descriptor = RequestConditionValue::legacyDescriptor($row['condition_value'], $row['condition_compare']); + if ($descriptor === null) { continue; } + DB::Update(self::cond(), array( + 'condition_value_source' => 'input', + 'condition_value_key' => $descriptor['key'], + 'condition_value_config' => RequestConditionValue::encodeConfig($descriptor), + ), 'Id=%i', (int) $row['Id']); + $requests[(int) $row['request_id']] = true; + $count++; + } + + foreach (array_keys($requests) as $requestId) { + self::touch((int) $requestId); + } + + return array('conditions' => $count, 'requests' => count($requests)); + } + + public static function activateVerifiedNative() + { + $activated = 0; + $invalidated = array(); + foreach (DB::query('SELECT * FROM ' . self::req() . ' ORDER BY Id')->getAll() as $row) { + $request = (object) $row; + $plan = \App\Content\Requests\NativeRequestPlanCompiler::compile((int) $request->Id); + if (empty($plan['eligible']) || !self::nativeVerified($request, $plan)) { + continue; + } + + $auditDetails = isset($request->request_native_audit_details) + ? json_decode((string) $request->request_native_audit_details, true) + : array(); + $baseline = is_array($auditDetails) && isset($auditDetails['runtime_verification']) + ? (array) $auditDetails['runtime_verification'] + : array(); + $finalized = self::finalizeStagedConditionValues((int) $request->Id, $baseline); + if (!$finalized) { continue; } + if (is_array($finalized)) { self::recordNativeVerification((int) $request->Id, $finalized); } + DB::Update(self::req(), array('request_executor_mode' => 'native'), 'Id=%i', (int) $request->Id); + $invalidated[] = array((int) $request->Id, (string) $request->request_alias); + $activated++; + } + + \App\Frontend\RequestRepository::reset(); + foreach ($invalidated as $request) { + FileCacheInvalidator::request($request[0], $request[1]); + } + + return $activated; + } + + public static function conditionValuesEquivalent(array $comparison) + { + $verification = isset($comparison['runtime_verification']) ? $comparison['runtime_verification'] : array(); + if (!$verification) { return false; } + foreach ($verification as $item) { + if (!in_array(isset($item['status']) ? $item['status'] : '', array('matched', 'unstable', 'order_diff'), true)) { + return false; + } + + if ((int) $item['legacy_total'] !== (int) $item['native_total']) { return false; } + } + + return true; + } + + public static function finalizeStagedConditionValues($requestId, array $baseline = array()) + { + $rows = DB::query( + 'SELECT * FROM ' . self::cond() . " WHERE request_id=%i AND condition_value_source='input' AND condition_value LIKE '%getAll(); + if (!$rows) { return true; } + + $backup = array(); + foreach ($rows as $row) { + $descriptor = RequestConditionValue::descriptor($row); + if ($descriptor === null) { return false; } + $backup[(int) $row['Id']] = (string) $row['condition_value']; + DB::Update(self::cond(), array( + 'condition_value' => RequestConditionValue::tag($descriptor['key']), + ), 'Id=%i', (int) $row['Id']); + } + + \App\Content\Requests\NativeRequestPlanCompiler::persist((int) $requestId); + try { + $comparison = (new \App\Content\Requests\RequestExecutorComparator())->compare((int) $requestId, 20); + } catch (\Throwable $e) { + $comparison = array('matched' => false); + } + + if (!self::sameLegacyVerification($baseline, isset($comparison['runtime_verification']) ? $comparison['runtime_verification'] : array())) { + foreach ($backup as $conditionId => $value) { + DB::Update(self::cond(), array('condition_value' => $value), 'Id=%i', (int) $conditionId); + } + + \App\Content\Requests\NativeRequestPlanCompiler::persist((int) $requestId); + return false; + } + + return $comparison; + } + + protected static function sameLegacyVerification(array $before, array $after) + { + if (!$before || count($before) !== count($after)) { return false; } + foreach ($before as $index => $item) { + $current = isset($after[$index]) ? $after[$index] : array(); + if ((string) (isset($item['label']) ? $item['label'] : '') !== (string) (isset($current['label']) ? $current['label'] : '')) { + return false; + } + + if ((int) (isset($item['legacy_total']) ? $item['legacy_total'] : -1) !== (int) (isset($current['legacy_total']) ? $current['legacy_total'] : -2)) { + return false; + } + + if ((string) (isset($item['legacy_ids_hash']) ? $item['legacy_ids_hash'] : '') !== (string) (isset($current['legacy_ids_hash']) ? $current['legacy_ids_hash'] : '')) { + return false; + } + } + + return true; + } + + /** Add an ID tiebreaker only when it preserves the complete current Legacy order. */ + public static function stabilizeNativeOrder($requestId) + { + $requestId = (int) $requestId; + return Lock::run('request-native-order:' . $requestId, function () use ($requestId) { + $request = self::find($requestId); + if (!$request) { + return array('status' => 'missing', 'message' => 'Запрос не найден'); + } + + $sortRules = self::sortRules($request); + $currentTie = ''; + if ($sortRules) { + $lastRule = end($sortRules); + if ($lastRule['source'] === 'document' && (string) $lastRule['key'] === 'Id') { + $currentTie = (string) $lastRule['direction']; + } + } + + if ($currentTie !== '') { + return array('status' => 'already_set', 'message' => 'Финальный порядок уже настроен'); + } + + $recommendation = (new \App\Content\Requests\RequestTieBreakerAdvisor())->recommend($requestId); + if ($recommendation['status'] !== 'recommended') { + return array( + 'status' => $recommendation['status'], + 'message' => isset($recommendation['reasons'][0]) + ? (string) $recommendation['reasons'][0] + : 'Безопасный финальный порядок не найден', + ); + } + + $direction = (string) $recommendation['direction']; + $originalMode = self::executorMode($request); + $changed = false; + try { + $sortRules[] = array('source' => 'document', 'key' => 'Id', 'direction' => $direction); + $legacySort = \App\Frontend\RequestSort::legacyStorage($sortRules); + DB::Update(self::req(), array_merge($legacySort, array( + 'request_sort_rules' => \App\Frontend\RequestSort::encodeRules($sortRules), + 'request_executor_mode' => 'shadow', + )), 'Id=%i', $requestId); + $changed = true; + \App\Content\Requests\NativeRequestPlanCompiler::persist($requestId); + FileCacheInvalidator::request($requestId, (string) $request->request_alias); + + $comparison = (new \App\Content\Requests\RequestExecutorComparator())->compare($requestId); + $afterFingerprint = isset($comparison['legacy']) + ? \App\Content\Requests\RequestExecutorComparator::resultFingerprint($comparison['legacy']) + : ''; + if (empty($comparison['matched']) + || !hash_equals((string) $recommendation['legacy_fingerprint'], $afterFingerprint)) { + self::restoreTieBreaker($request, $originalMode); + return array( + 'status' => 'rolled_back', + 'message' => 'Порядок изменился при контрольной проверке. Настройка отменена.', + ); + } + + self::recordNativeVerification($requestId, $comparison); + DB::Update(self::req(), array('request_executor_mode' => 'native'), 'Id=%i', $requestId); + \App\Frontend\RequestRepository::reset(); + FileCacheInvalidator::request($requestId, (string) $request->request_alias); + + return array( + 'status' => 'applied', + 'message' => 'Порядок закреплён без изменения Legacy-результата', + 'direction' => $direction, + 'total' => isset($recommendation['total']) ? (int) $recommendation['total'] : 0, + 'comparison' => $comparison, + ); + } catch (\Throwable $e) { + if ($changed) { + self::restoreTieBreaker($request, $originalMode); + } + + throw $e; + } + }, 10.0, true); + } + + protected static function restoreTieBreaker($request, $mode) + { + DB::Update(self::req(), array( + 'request_order_by' => isset($request->request_order_by) ? (string) $request->request_order_by : '', + 'request_order_by_nat' => isset($request->request_order_by_nat) ? (int) $request->request_order_by_nat : 0, + 'request_asc_desc' => isset($request->request_asc_desc) ? (string) $request->request_asc_desc : 'ASC', + 'request_order_tiebreaker' => isset($request->request_order_tiebreaker) ? (string) $request->request_order_tiebreaker : '', + 'request_sort_rules' => isset($request->request_sort_rules) ? $request->request_sort_rules : null, + 'request_executor_mode' => (string) $mode, + ), 'Id=%i', (int) $request->Id); + \App\Content\Requests\NativeRequestPlanCompiler::persist((int) $request->Id); + \App\Frontend\RequestRepository::reset(); + FileCacheInvalidator::request((int) $request->Id, (string) $request->request_alias); + } + + public static function find($id) + { + return DB::query('SELECT * FROM ' . self::req() . ' WHERE Id = %i LIMIT 1', (int) $id)->getObject(); + } + + public static function aliasExists($alias, $exceptId = 0) + { + return (bool) DB::query( + 'SELECT Id FROM ' . self::req() . ' WHERE request_alias = %s AND Id != %i LIMIT 1', + (string) $alias, (int) $exceptId + )->getValue(); + } + + /** Собрать writable-набор полей из входных данных. */ + protected static function fields(array $data) + { + $fields = array(); + foreach (self::$scalar as $f) { + if (array_key_exists($f, $data)) { + $fields[$f] = $data[$f]; + } + } + + foreach (self::$flags as $f) { + if (array_key_exists($f, $data)) { + $fields[$f] = !empty($data[$f]) ? '1' : '0'; + } + } + + return $fields; + } + + public static function create(array $data) + { + $now = time(); + $fields = self::fields($data); + $fields += array( + 'request_author_id' => Auth::id(), + 'request_created' => $now, + 'request_changed' => $now, + 'request_changed_elements' => $now, + 'request_where_cond' => isset($data['request_where_cond']) ? (string) $data['request_where_cond'] : '', + ); + DB::Insert(self::req(), $fields); + $id = DB::insertId(); + self::createRootGroup($id); + \App\Content\Requests\NativeRequestPlanCompiler::persist($id); + return $id; + } + + public static function update($id, array $data) + { + $current = self::find($id); + $fields = self::fields($data); + $fields['request_changed'] = $fields['request_changed_elements'] = time(); + $fields['request_where_cond'] = ''; + DB::Update(self::req(), $fields, 'Id = %i', (int) $id); + \App\Content\Requests\NativeRequestPlanCompiler::persist($id, $fields); + FileCacheInvalidator::request($id, $current ? $current->request_alias : ''); + } + + public static function copy($id) + { + $row = self::find($id); + if (!$row) { + return 0; + } + + $data = (array) $row; + unset($data['Id']); + $data['request_alias'] = self::uniqueAlias((string) $row->request_alias . '_copy'); + $data['request_title'] = (string) $row->request_title . ' (копия)'; + $data['request_author_id'] = Auth::id(); + $data['request_created'] = time(); + $data['request_changed'] = time(); + $data['request_changed_elements'] = $data['request_changed']; + $data['request_where_cond'] = ''; + DB::Insert(self::req(), $data); + $newId = DB::insertId(); + + //-- Сначала копируем дерево групп, затем привязанные к нему условия. + $groupMap = array(); + foreach (self::groupsFlat($id) as $group) { + $group = (array) $group; + $oldId = (int) $group['Id']; + $parentId = (int) $group['parent_id']; + DB::Insert(self::groups(), array( + 'request_id' => $newId, + 'parent_id' => $parentId > 0 && isset($groupMap[$parentId]) ? $groupMap[$parentId] : 0, + 'group_title' => (string) $group['group_title'], + 'group_operator' => (string) $group['group_operator'], + 'group_position' => (int) $group['group_position'], + )); + $groupMap[$oldId] = DB::insertId(); + } + + if (!$groupMap) { + $groupMap[0] = self::createRootGroup($newId); + } + + foreach (self::conditions($id) as $condition) { + $copy = (array) $condition; + unset($copy['Id']); + $copy['request_id'] = $newId; + $oldGroup = isset($copy['condition_group_id']) ? (int) $copy['condition_group_id'] : 0; + $copy['condition_group_id'] = isset($groupMap[$oldGroup]) + ? $groupMap[$oldGroup] + : reset($groupMap); + DB::Insert(self::cond(), $copy); + } + + \App\Content\Requests\NativeRequestPlanCompiler::persist($newId); + + return $newId; + } + + protected static function uniqueAlias($base) + { + $base = substr(preg_replace('/[^a-z0-9_-]/', '', strtolower($base)), 0, 20) ?: 'request'; + $alias = $base; + $i = 1; + while (self::aliasExists($alias)) { + $suffix = (string) $i++; + $alias = substr($base, 0, 20 - strlen($suffix)) . $suffix; + } + + return $alias; + } + + public static function delete($id) + { + $current = self::find($id); + DB::Delete(self::cond(), 'request_id = %i', (int) $id); + DB::Delete(self::groups(), 'request_id = %i', (int) $id); + DB::Delete(self::req(), 'Id = %i', (int) $id); + FileCacheInvalidator::request($id, $current ? $current->request_alias : ''); + } + + // ---------------- Условия ---------------- + + public static function conditions($requestId) + { + return DB::query( + 'SELECT * FROM ' . self::cond() . ' WHERE request_id = %i ORDER BY condition_position ASC, Id ASC', + (int) $requestId + )->getAll(); + } + + public static function rubricOptions() + { + return DB::query( + 'SELECT Id, rubric_title FROM ' . ContentTables::table('rubrics') . ' ORDER BY rubric_title ASC, Id ASC' + )->getAll(); + } + + public static function rubricFields($rubricId) + { + $rows = DB::query( + 'SELECT Id, rubric_field_title, rubric_field_alias, rubric_field_type, rubric_field_numeric, + rubric_field_default, rubric_field_settings + FROM ' . ContentTables::table('rubric_fields') . ' + WHERE rubric_id = %i + ORDER BY rubric_field_position ASC, Id ASC', + (int) $rubricId + )->getAll(); + foreach ($rows as $index => $row) { + $rows[$index] = self::decorateConditionField($row); + } + + return $rows; + } + + protected static function decorateConditionField($row) + { + $row = is_object($row) ? $row : (object) $row; + $definition = (array) $row; + $typeCode = isset($definition['rubric_field_type']) ? (string) $definition['rubric_field_type'] : ''; + $type = FieldRegistry::get($typeCode); + $settings = FieldSettings::effective($definition); + $kind = self::conditionFieldKind($typeCode, $type, $definition); + $options = self::conditionFieldOptions(isset($settings['options']) ? $settings['options'] : array()); + $rubrics = array(); + foreach ((array) (isset($settings['rubric']) ? $settings['rubric'] : array()) as $rubricId) { + $rubricId = (int) $rubricId; + if ($rubricId > 0) { $rubrics[$rubricId] = $rubricId; } + } + + $row->condition_kind = $kind; + $row->condition_type_label = $type ? (string) $type->name() : $typeCode; + $row->condition_options = array_values($options); + $row->condition_rubrics = array_values($rubrics); + $row->condition_value_mode = self::conditionFieldMode($kind, $typeCode, $options); + $row->condition_operators = self::conditionFieldOperators($kind, $typeCode); + return $row; + } + + protected static function conditionFieldKind($typeCode, $type, array $definition) + { + if (in_array($typeCode, array('checkbox', 'boolean'), true)) { return 'boolean'; } + if ($typeCode === 'date') { return 'date'; } + if ($typeCode === 'date_time') { return 'datetime'; } + if (in_array($typeCode, array('doc_from_rub'), true)) { return 'relation'; } + if (in_array($typeCode, array('doc_from_rub_all', 'doc_from_rub_check', 'doc_from_rub_search', 'analoque', 'teasers'), true)) { return 'relation_list'; } + if ($type && $type->isChoice()) { return $type->isMultiple() ? 'choice_list' : 'choice'; } + if ($type && $type->isNumeric()) { return 'number'; } + if (!empty($definition['rubric_field_numeric'])) { return 'number'; } + + return 'text'; + } + + protected static function conditionFieldOptions($raw) + { + $options = array(); + foreach ((array) $raw as $key => $item) { + if (is_array($item)) { + $value = isset($item['value']) ? $item['value'] : (isset($item['key']) ? $item['key'] : $key); + $label = isset($item['label']) ? $item['label'] : (isset($item['title']) ? $item['title'] : $value); + } else { + $value = is_string($key) && !ctype_digit($key) ? $key : $item; + $label = $item; + } + + $value = trim((string) $value); + $label = trim((string) $label); + if ($value !== '') { $options[$value] = array('value' => $value, 'label' => $label !== '' ? $label : $value); } + } + + return $options; + } + + protected static function conditionFieldMode($kind, $typeCode, array $options) + { + if ($kind === 'relation' || $kind === 'boolean') { return 'integer_scalar'; } + if ($kind === 'number' || $kind === 'datetime') { return 'decimal_scalar'; } + if ($typeCode === 'catalog') { return 'integer_member'; } + if ($kind === 'relation_list') { return 'integer_list'; } + if ($kind === 'choice_list') { + return self::optionsAreInteger($options) ? 'integer_list' : 'string_list'; + } + + if ($kind === 'choice' && self::optionsAreInteger($options)) { return 'integer_scalar'; } + + return 'string_scalar'; + } + + protected static function optionsAreInteger(array $options) + { + if (!$options) { return false; } + foreach (array_keys($options) as $value) { + if (!preg_match('/^-?[0-9]+$/', (string) $value)) { return false; } + } + + return true; + } + + protected static function conditionFieldOperators($kind, $typeCode) + { + if ($kind === 'boolean') { return array('==', '!='); } + if (in_array($kind, array('number', 'date', 'datetime', 'relation'), true)) { + return array('==', '!=', 'N>', 'N<', 'N>=', 'N<=', 'SEGMENT', 'INTERVAL', 'IN=', 'NOTIN='); + } + + if (in_array($kind, array('choice', 'choice_list', 'relation_list'), true)) { + return array('==', '!=', '%%', '--', 'IN=', 'NOTIN='); + } + + if ($typeCode === 'catalog') { return array('==', '!=', '%%', '--', 'IN=', 'NOTIN='); } + + return array('==', '!=', '%%', '%', '--', '!-', 'IN=', 'NOTIN='); + } + + public static function documentPicker($query, $rubricIds = '', $limit = 30) + { + $limit = max(1, min(50, (int) $limit)); + $sql = 'SELECT d.Id,d.rubric_id,d.document_title,d.document_alias,r.rubric_title' + . ' FROM ' . ContentTables::table('documents') . ' d' + . ' LEFT JOIN ' . ContentTables::table('rubrics') . ' r ON r.Id=d.rubric_id' + . " WHERE d.document_deleted!='1'"; + $args = array(); + $query = trim((string) $query); + if ($query !== '') { + $sql .= ' AND (d.document_title LIKE %ss OR d.document_alias LIKE %ss OR d.Id=%i)'; + $args[] = $query; + $args[] = $query; + $args[] = (int) $query; + } + + $ids = array(); + foreach (explode(',', (string) $rubricIds) as $rubricId) { + $rubricId = (int) trim($rubricId); + if ($rubricId > 0) { $ids[$rubricId] = $rubricId; } + } + + if ($ids) { $sql .= ' AND d.rubric_id IN (' . implode(',', $ids) . ')'; } + $sql .= ' ORDER BY d.document_changed DESC,d.Id DESC LIMIT ' . $limit; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + $result = array(); + foreach ($rows as $row) { + $result[] = array( + 'id' => (int) $row['Id'], + 'title' => (string) $row['document_title'], + 'alias' => (string) $row['document_alias'], + 'rubric_id' => (int) $row['rubric_id'], + 'rubric_title' => (string) $row['rubric_title'], + ); + } + + return $result; + } + + public static function paginationOptions() + { + try { + return DB::query( + 'SELECT id,pagination_name FROM ' . SystemTables::table('paginations') . ' ORDER BY id ASC' + )->getAll(); + } catch (\Throwable $e) { + return array(); + } + } + + public static function resultContract($request) + { + $rubricId = is_object($request) && isset($request->rubric_id) ? (int) $request->rubric_id : 0; + $value = is_object($request) && isset($request->request_result_contract) + ? $request->request_result_contract + : null; + + return \App\Content\Requests\RequestResultContract::decode($value, self::rubricFieldIds($rubricId)); + } + + public static function encodeResultContract($rubricId, array $value) + { + return \App\Content\Requests\RequestResultContract::encode($value, self::rubricFieldIds($rubricId)); + } + + public static function previewRenderer($request) + { + $value = is_object($request) && isset($request->request_preview_renderer) + ? $request->request_preview_renderer + : 'data_cards'; + return \App\Content\Requests\RequestRendererRegistry::normalizePreviewCode($value); + } + + public static function sortRules($request) + { + return \App\Frontend\RequestSort::rulesForRequest($request); + } + + public static function previewPlan($requestId) + { + $request = self::find($requestId); + if (!$request) { return array(); } + + return array( + 'rubric_id' => (int) $request->rubric_id, + 'groups' => (int) DB::query( + 'SELECT COUNT(*) FROM ' . self::groups() . ' WHERE request_id=%i', + (int) $requestId + )->getValue(), + 'conditions' => (int) DB::query( + "SELECT COUNT(*) FROM " . self::cond() . " WHERE request_id=%i AND condition_status='1'", + (int) $requestId + )->getValue(), + 'order' => self::sortSummary($request), + 'publication' => 'Опубликован, не удалён и входит в срок публикации', + ); + } + + protected static function sortSummary($request) + { + $rules = self::sortRules($request); + if (!$rules) { return 'Без явной сортировки'; } + + $labels = array(); + $options = \App\Frontend\RequestSort::storedOptions(); + $fields = array(); + foreach (self::rubricFields((int) $request->rubric_id) as $field) { + $fields[(int) $field->Id] = (string) $field->rubric_field_title; + } + + foreach ($rules as $rule) { + if ($rule['source'] === 'field') { + $label = isset($fields[(int) $rule['key']]) ? $fields[(int) $rule['key']] : 'Поле #' . (int) $rule['key']; + } elseif ($rule['source'] === 'random') { + $labels[] = 'Случайный порядок'; + continue; + } else { + $label = isset($options[$rule['key']]) ? $options[$rule['key']] : (string) $rule['key']; + } + + $labels[] = $label . ($rule['direction'] === 'DESC' ? ' ↓' : ' ↑'); + } + + return implode(' → ', $labels); + } + + public static function nativePlan($requestId) + { + return \App\Content\Requests\NativeRequestPlanCompiler::compile((int) $requestId); + } + + public static function nativeVerified($request, array $plan) + { + $verified = is_object($request) && isset($request->request_native_verified_hash) + ? (string) $request->request_native_verified_hash + : ''; + return $verified !== '' && isset($plan['source_hash']) && hash_equals($verified, (string) $plan['source_hash']); + } + + public static function recordNativeVerification($requestId, array $comparison) + { + $hash = !empty($comparison['matched']) && isset($comparison['plan']['source_hash']) + ? (string) $comparison['plan']['source_hash'] + : null; + $details = array( + 'reasons' => isset($comparison['reasons']) ? array_values((array) $comparison['reasons']) : array(), + 'legacy_total' => isset($comparison['legacy']['total']) ? (int) $comparison['legacy']['total'] : null, + 'native_total' => isset($comparison['native']['total']) ? (int) $comparison['native']['total'] : null, + 'missing_ids' => isset($comparison['missing_ids']) ? array_slice(array_values((array) $comparison['missing_ids']), 0, 20) : array(), + 'extra_ids' => isset($comparison['extra_ids']) ? array_slice(array_values((array) $comparison['extra_ids']), 0, 20) : array(), + 'order_diagnostics' => isset($comparison['order_diagnostics']) && is_array($comparison['order_diagnostics']) + ? $comparison['order_diagnostics'] + : array(), + 'runtime_verification' => isset($comparison['runtime_verification']) && is_array($comparison['runtime_verification']) + ? $comparison['runtime_verification'] + : array(), + 'checked_at' => time(), + ); + $fields = array( + 'request_native_verified_hash' => $hash, + 'request_native_audit_status' => isset($comparison['status']) ? (string) $comparison['status'] : 'error', + 'request_native_audit_details' => json_encode($details, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ); + if (isset($comparison['plan']) && is_array($comparison['plan'])) { + $fields['request_native_plan'] = \App\Content\Requests\NativeRequestPlanCompiler::encode($comparison['plan']); + } + + DB::Update(self::req(), $fields, 'Id=%i', (int) $requestId); + \App\Frontend\RequestRepository::reset(); + } + + public static function executorMode($request) + { + $mode = is_object($request) && isset($request->request_executor_mode) + ? strtolower((string) $request->request_executor_mode) + : 'legacy'; + return in_array($mode, array('legacy', 'shadow', 'native'), true) ? $mode : 'legacy'; + } + + public static function explainDocuments($requestId, $query = '', $limit = 12) + { + $request = self::find($requestId); + if (!$request) { return array(); } + + $query = trim((string) $query); + $limit = max(1, min(20, (int) $limit)); + $sql = 'SELECT Id,document_title,document_alias,document_status,document_deleted' + . ' FROM ' . ContentTables::table('documents') . ' WHERE rubric_id=%i'; + $args = array((int) $request->rubric_id); + if ($query !== '') { + $sql .= ' AND (document_title LIKE %ss OR document_alias LIKE %ss OR Id=%i)'; + $args[] = $query; + $args[] = $query; + $args[] = (int) $query; + } + + $sql .= ' ORDER BY document_changed DESC,Id DESC LIMIT ' . $limit; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + $result = array(); + foreach ($rows ?: array() as $row) { + $result[] = array( + 'id' => (int) $row['Id'], + 'title' => htmlspecialchars_decode((string) $row['document_title'], ENT_QUOTES), + 'alias' => (string) $row['document_alias'], + 'status' => (int) $row['document_status'], + 'deleted' => (int) $row['document_deleted'], + ); + } + + return $result; + } + + protected static function rubricFieldIds($rubricId) + { + $result = array(); + foreach (self::rubricFields((int) $rubricId) as $field) { + $result[] = (int) $field->Id; + } + + return $result; + } + + public static function groupsFlat($requestId) + { + return DB::query( + 'SELECT * FROM ' . self::groups() . ' + WHERE request_id = %i ORDER BY parent_id ASC, group_position ASC, Id ASC', + (int) $requestId + )->getAll(); + } + + public static function conditionTree($requestId) + { + $groups = self::groupsFlat($requestId); + if (!$groups) { + self::createRootGroup($requestId); + $groups = self::groupsFlat($requestId); + } + + $nodes = array(); + foreach ($groups as $group) { + $item = (array) $group; + $item['conditions'] = array(); + $item['children'] = array(); + $item['depth'] = 0; + $nodes[(int) $group->Id] = $item; + } + + foreach (self::conditions($requestId) as $condition) { + $groupId = isset($condition->condition_group_id) ? (int) $condition->condition_group_id : 0; + if (!isset($nodes[$groupId])) { + $groupId = self::rootGroupId($requestId); + } + + if (isset($nodes[$groupId])) { + $nodes[$groupId]['conditions'][] = self::conditionPresentation((array) $condition); + } + } + + $roots = array(); + foreach (array_keys($nodes) as $groupId) { + $parentId = (int) $nodes[$groupId]['parent_id']; + if ($parentId > 0 && isset($nodes[$parentId])) { + $nodes[$groupId]['depth'] = min(4, (int) $nodes[$parentId]['depth'] + 1); + $nodes[$parentId]['children'][] =& $nodes[$groupId]; + } else { + $roots[] =& $nodes[$groupId]; + } + } + + return $roots ? $roots[0] : array(); + } + + public static function rootGroupId($requestId) + { + $id = (int) DB::query( + 'SELECT Id FROM ' . self::groups() . ' WHERE request_id = %i AND parent_id = 0 ORDER BY Id LIMIT 1', + (int) $requestId + )->getValue(); + return $id > 0 ? $id : self::createRootGroup($requestId); + } + + protected static function createRootGroup($requestId) + { + DB::Insert(self::groups(), array( + 'request_id' => (int) $requestId, + 'parent_id' => 0, + 'group_title' => '', + 'group_operator' => 'AND', + 'group_position' => 0, + )); + return DB::insertId(); + } + + public static function findGroup($id) + { + return DB::query('SELECT * FROM ' . self::groups() . ' WHERE Id = %i LIMIT 1', (int) $id)->getObject(); + } + + public static function groupSave($requestId, array $data) + { + $id = isset($data['id']) ? (int) $data['id'] : 0; + $parentId = isset($data['parent_id']) ? (int) $data['parent_id'] : 0; + $operator = isset($data['group_operator']) && $data['group_operator'] === 'OR' ? 'OR' : 'AND'; + $title = trim(isset($data['group_title']) ? (string) $data['group_title'] : ''); + $title = function_exists('mb_substr') ? mb_substr($title, 0, 100) : substr($title, 0, 100); + + if ($id > 0) { + $group = self::findGroup($id); + if (!$group || (int) $group->request_id !== (int) $requestId) { + return 0; + } + + DB::Update(self::groups(), array( + 'group_title' => $title, + 'group_operator' => $operator, + ), 'Id = %i', $id); + self::touch($requestId); + return $id; + } + + $parent = self::findGroup($parentId); + if (!$parent || (int) $parent->request_id !== (int) $requestId || self::groupDepth($parentId) >= 3) { + return 0; + } + + $position = (int) DB::query( + 'SELECT COALESCE(MAX(group_position), -1) + 1 FROM ' . self::groups() . ' + WHERE request_id = %i AND parent_id = %i', + (int) $requestId, $parentId + )->getValue(); + DB::Insert(self::groups(), array( + 'request_id' => (int) $requestId, + 'parent_id' => $parentId, + 'group_title' => $title, + 'group_operator' => $operator, + 'group_position' => $position, + )); + $newId = DB::insertId(); + self::touch($requestId); + return $newId; + } + + protected static function groupDepth($id) + { + $depth = 0; + $seen = array(); + while ($id > 0 && $depth < 10 && !isset($seen[$id])) { + $seen[$id] = true; + $group = self::findGroup($id); + if (!$group || (int) $group->parent_id === 0) { + break; + } + + $id = (int) $group->parent_id; + $depth++; + } + + return $depth; + } + + public static function groupDelete($requestId, $id) + { + $group = self::findGroup($id); + if (!$group || (int) $group->request_id !== (int) $requestId || (int) $group->parent_id === 0) { + return false; + } + + $parentId = (int) $group->parent_id; + DB::Update(self::cond(), array('condition_group_id' => $parentId), 'request_id = %i AND condition_group_id = %i', (int) $requestId, (int) $id); + DB::Update(self::groups(), array('parent_id' => $parentId), 'request_id = %i AND parent_id = %i', (int) $requestId, (int) $id); + DB::Delete(self::groups(), 'Id = %i', (int) $id); + self::touch($requestId); + return true; + } + + public static function findCondition($id) + { + return DB::query('SELECT * FROM ' . self::cond() . ' WHERE Id = %i LIMIT 1', (int) $id)->getObject(); + } + + public static function conditionSave($requestId, array $data) + { + $request = self::find($requestId); + if (!$request) { throw new \InvalidArgumentException('Запрос не найден.'); } + $fieldId = (int) (isset($data['condition_field_id']) ? $data['condition_field_id'] : 0); + $fieldIds = self::rubricFieldIds((int) $request->rubric_id); + if ($fieldId <= 0 || !in_array($fieldId, $fieldIds, true)) { + throw new \InvalidArgumentException('Выберите поле текущей рубрики.'); + } + + $compare = (string) (isset($data['condition_compare']) ? $data['condition_compare'] : '=='); + if (!array_key_exists($compare, self::compareOptions())) { + throw new \InvalidArgumentException('Выберите поддерживаемое сравнение.'); + } + + $valueFields = self::conditionValueFields($data); + //-- Позиция управляется drag-and-drop (reorder), в форме её нет. + $fields = array( + 'condition_field_id' => $fieldId, + 'condition_compare' => $compare, + 'condition_join' => (isset($data['condition_join']) && $data['condition_join'] === 'OR') ? 'OR' : 'AND', + 'condition_status' => !empty($data['condition_status']) ? '1' : '0', + ); + $fields += $valueFields; + + $groupId = isset($data['condition_group_id']) ? (int) $data['condition_group_id'] : 0; + $group = self::findGroup($groupId); + $fields['condition_group_id'] = $group && (int) $group->request_id === (int) $requestId + ? $groupId + : self::rootGroupId($requestId); + + $id = (int) (isset($data['id']) ? $data['id'] : 0); + if ($id > 0) { + $condition = self::findCondition($id); + if (!$condition || (int) $condition->request_id !== (int) $requestId) { + return 0; + } + + DB::Update(self::cond(), $fields, 'Id = %i AND request_id = %i', $id, (int) $requestId); + self::touch($requestId); + return $id; + } + + //-- новое условие — в конец + $fields['request_id'] = (int) $requestId; + $fields['condition_position'] = (int) DB::query( + 'SELECT COALESCE(MAX(condition_position), -1) + 1 FROM ' . self::cond() . ' WHERE request_id = %i AND condition_group_id = %i', + (int) $requestId, (int) $fields['condition_group_id'] + )->getValue(); + DB::Insert(self::cond(), $fields); + $newId = DB::insertId(); + self::touch($requestId); + return $newId; + } + + protected static function conditionValueFields(array $data) + { + $source = isset($data['condition_value_source']) ? strtolower(trim((string) $data['condition_value_source'])) : 'literal'; + if ($source !== 'input') { + return array( + 'condition_value' => (string) (isset($data['condition_value']) ? $data['condition_value'] : ''), + 'condition_value_source' => $source === 'legacy' ? 'legacy' : 'literal', + 'condition_value_key' => '', + 'condition_value_config' => null, + ); + } + + $key = isset($data['condition_value_key']) ? trim((string) $data['condition_value_key']) : ''; + if (RequestConditionValue::tag($key) === '') { + throw new \InvalidArgumentException('Alias публичного параметра должен начинаться с буквы или _.'); + } + + $mode = isset($data['condition_value_mode']) ? (string) $data['condition_value_mode'] : 'string_scalar'; + $constant = isset($data['condition_value_constant']) ? $data['condition_value_constant'] : ''; + $descriptor = self::descriptorForMode($mode, $key, $constant); + return array( + 'condition_value' => RequestConditionValue::tag($key), + 'condition_value_source' => 'input', + 'condition_value_key' => $key, + 'condition_value_config' => RequestConditionValue::encodeConfig($descriptor), + ); + } + + protected static function descriptorForMode($mode, $key, $constant) + { + $modes = array( + 'string_scalar' => array('cast' => 'string', 'shape' => 'scalar', 'transform' => 'plain'), + 'integer_scalar' => array('cast' => 'integer', 'shape' => 'scalar', 'transform' => 'plain'), + 'decimal_scalar' => array('cast' => 'decimal', 'shape' => 'scalar', 'transform' => 'plain'), + 'string_list' => array('cast' => 'string', 'shape' => 'list', 'transform' => 'plain'), + 'integer_list' => array('cast' => 'integer', 'shape' => 'list', 'transform' => 'plain'), + 'integer_range' => array('cast' => 'integer', 'shape' => 'range', 'transform' => 'plain'), + 'decimal_range' => array('cast' => 'decimal', 'shape' => 'range', 'transform' => 'plain'), + 'integer_member' => array('cast' => 'integer', 'shape' => 'scalar', 'transform' => 'pipe_member'), + 'integer_member_list' => array('cast' => 'integer', 'shape' => 'list', 'transform' => 'pipe_member'), + 'presence_constant' => array('cast' => 'string', 'shape' => 'presence', 'transform' => 'plain'), + ); + if (!isset($modes[$mode])) { throw new \InvalidArgumentException('Неизвестный способ обработки параметра.'); } + $modes[$mode]['source'] = 'input'; + $modes[$mode]['key'] = $key; + $modes[$mode]['constant'] = $mode === 'presence_constant' ? (string) $constant : ''; + if ($mode === 'presence_constant' && trim((string) $constant) === '') { + throw new \InvalidArgumentException('Укажите значение константы.'); + } + + return $modes[$mode]; + } + + protected static function conditionPresentation(array $condition) + { + $descriptor = RequestConditionValue::descriptor($condition); + if ($descriptor !== null) { + $condition['value_source_ui'] = 'input'; + $condition['value_key_ui'] = $descriptor['key']; + $condition['value_mode_ui'] = self::modeForDescriptor($descriptor); + $condition['value_constant_ui'] = $descriptor['constant']; + return $condition; + } + + $value = isset($condition['condition_value']) ? (string) $condition['condition_value'] : ''; + $condition['value_source_ui'] = preg_match('/<\?(?:php|=)?|\?>/i', $value) ? 'legacy' : 'literal'; + $condition['value_key_ui'] = ''; + $condition['value_mode_ui'] = 'string_scalar'; + $condition['value_constant_ui'] = ''; + return $condition; + } + + protected static function modeForDescriptor(array $descriptor) + { + if ($descriptor['shape'] === 'presence') { return 'presence_constant'; } + if ($descriptor['transform'] === 'pipe_member') { + return $descriptor['shape'] === 'list' ? 'integer_member_list' : 'integer_member'; + } + + return $descriptor['cast'] . '_' . $descriptor['shape']; + } + + public static function conditionDelete($id) + { + $condition = self::findCondition($id); + DB::Delete(self::cond(), 'Id = %i', (int) $id); + if ($condition) { self::touch((int) $condition->request_id); } + } + + /** Переупорядочить условия запроса по массиву Id (drag-and-drop). */ + public static function reorderConditions($requestId, array $items) + { + $positions = array(); + foreach ($items as $item) { + $id = is_array($item) && isset($item['id']) ? (int) $item['id'] : (int) $item; + $groupId = is_array($item) && isset($item['group_id']) ? (int) $item['group_id'] : self::rootGroupId($requestId); + if ($id <= 0) { + continue; + } + + $group = self::findGroup($groupId); + if (!$group || (int) $group->request_id !== (int) $requestId) { + continue; + } + + if (!isset($positions[$groupId])) { + $positions[$groupId] = 0; + } + + DB::Update(self::cond(), array( + 'condition_group_id' => $groupId, + 'condition_position' => $positions[$groupId]++, + ), 'Id = %i AND request_id = %i', $id, (int) $requestId); + } + + self::touch($requestId); + } + + protected static function touch($requestId) + { + $request = self::find($requestId); + $now = time(); + DB::Update(self::req(), array( + 'request_changed' => $now, + 'request_changed_elements' => $now, + 'request_where_cond' => '', + ), 'Id = %i', (int) $requestId); + \App\Content\Requests\NativeRequestPlanCompiler::persist($requestId); + FileCacheInvalidator::request($requestId, $request ? $request->request_alias : ''); + } + } diff --git a/adminx/modules/Requests/assets/requests.css b/adminx/modules/Requests/assets/requests.css new file mode 100644 index 0000000..b58fd6b --- /dev/null +++ b/adminx/modules/Requests/assets/requests.css @@ -0,0 +1,2556 @@ +/* Стили раздела «Запросы». */ +/* Список запросов: фильтр-карта + карта таблицы (как в Документах). */ +.requests-panel { + display: grid; + gap: 18px; +} +.requests-toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.requests-toolbar .requests-search { + flex: 1 1 300px; + min-width: 240px; + max-width: 520px; +} +.requests-toolbar [hidden] { + display: none; +} +.requests-panel .table-scroll { + max-height: none; + overflow-x: hidden; + overflow-y: hidden; +} +.requests-panel-header { + margin-top: 24px; +} +.requests-table { + width: 100%; + min-width: 0; + table-layout: fixed; +} +.requests-col-id { + width: 6ch; +} +.requests-col-name { + width: auto; +} +.requests-col-tag { + width: 250px; +} +.requests-col-rubric { + width: 96px; +} +.requests-col-conditions { + width: 92px; +} +.requests-col-date { + width: 142px; +} +.requests-col-actions { + width: 124px; +} +.requests-actions { + gap: 4px; + width: max-content; +} +@media (max-width: 980px) { + .requests-table { + min-width: 820px; + } +} +[data-request-advanced][hidden] { + display: none !important; +} +.request-editor-modebar { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) auto; + align-items: center; + gap: 11px; + min-height: 62px; + margin-bottom: 16px; + padding: 11px 14px; + border-radius: var(--radius-md); + background: var(--background-surface); + box-shadow: 0 0 0 1px var(--border-subtle); +} +.request-editor-mode-copy { + display: grid; + gap: 2px; + min-width: 0; +} +.request-editor-mode-copy b { + font-size: 12.5px; +} +.request-editor-mode-copy span { + color: var(--text-muted); + font-size: 10.5px; + line-height: 15px; + text-wrap: pretty; +} +.request-editor-mode-switch { + flex-wrap: nowrap; +} +.request-editor-mode-switch button { + gap: 6px; + min-height: 34px; + white-space: nowrap; +} +.request-editor-mode-switch button.is-active { + background: var(--color-primary); + color: #fff; +} +.request-advanced-flags { + display: grid; + gap: 8px; + margin-top: 4px; + padding-top: 12px; + border-top: 1px solid var(--border-subtle); +} +.request-advanced-flags-head { + display: grid; + gap: 2px; + margin-bottom: 2px; +} +.request-advanced-flags-head b { + font-size: 11.5px; +} +.request-advanced-flags-head span { + color: var(--text-muted); + font-size: 10px; + line-height: 14px; +} +.request-main-fields { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.request-sort-builder { + display: grid; + grid-column: 1 / -1; + gap: 10px; + min-width: 0; + padding: 12px; + border-radius: var(--radius-sm); + background: var(--background-muted); +} +.request-sort-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.request-sort-head > div:first-child { + display: grid; + gap: 2px; + min-width: 0; +} +.request-sort-head b { + font-size: 12px; + line-height: 17px; +} +.request-sort-head span { + color: var(--text-muted); + font-size: 10.5px; + line-height: 15px; + text-wrap: pretty; +} +.request-sort-head .cluster { + flex: 0 0 auto; + gap: 6px; +} +.request-sort-list { + display: grid; + gap: 7px; + min-width: 0; +} +.request-sort-rule { + display: grid; + grid-template-columns: 40px 24px minmax(220px, 1fr) minmax(270px, auto) 40px; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px; + border-radius: var(--radius-sm); + background: var(--background-surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.06); + transition-property: opacity, transform, box-shadow; + transition-duration: 140ms; +} +.request-sort-rule.is-dragging { + z-index: 2; + opacity: 0.58; + transform: scale(0.99); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12); +} +.request-sort-handle { + display: grid; + place-items: center; + width: 40px; + height: 40px; + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: grab; +} +.request-sort-handle:hover { + background: var(--background-muted); + color: var(--color-primary); +} +.request-sort-handle:active { + cursor: grabbing; + transform: scale(0.96); +} +.request-sort-position { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--blue-100); + color: var(--blue-700); + font-size: 10px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.request-sort-rule > label { + display: grid; + gap: 3px; + min-width: 0; +} +.request-sort-rule > label > span { + color: var(--text-muted); + font-size: 9.5px; + line-height: 13px; +} +.request-sort-rule .select { + width: 100%; + min-width: 0; +} +.request-sort-direction { + display: grid; + grid-template-columns: repeat(2, minmax(120px, 1fr)); + gap: 3px; + min-width: 0; + padding: 3px; + border-radius: var(--radius-sm); + background: var(--background-muted); + transition-property: opacity; + transition-duration: 140ms; +} +.request-sort-direction button { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + min-height: 34px; + padding: 5px 8px; + border: 0; + border-radius: calc(var(--radius-sm) - 2px); + background: transparent; + color: var(--text-secondary); + font-size: 10.5px; + white-space: nowrap; + cursor: pointer; +} +.request-sort-direction button:hover { + color: var(--color-primary); +} +.request-sort-direction button:active { + transform: scale(0.96); +} +.request-sort-direction button.is-active { + background: var(--background-surface); + color: var(--color-primary); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + font-weight: 600; +} +.request-sort-seed { + display: none; + grid-template-columns: minmax(120px, 160px) minmax(150px, 1fr); + align-items: end; + gap: 8px; + min-width: 0; +} +.request-sort-seed label { + display: grid; + gap: 3px; + min-width: 0; +} +.request-sort-seed label > span { + color: var(--text-muted); + font-size: 9.5px; + line-height: 13px; +} +.request-sort-seed small { + align-self: center; + color: var(--text-muted); + font-size: 9.5px; + line-height: 14px; + text-wrap: pretty; +} +.request-sort-rule.is-random .request-sort-direction { + display: none; +} +.request-sort-rule.is-random .request-sort-seed { + display: grid; +} +.request-sort-empty { + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + min-height: 68px; + padding: 10px; + border-radius: var(--radius-sm); + background: var(--background-surface); + color: var(--text-muted); + text-align: left; +} +.request-sort-empty[hidden] { + display: none; +} +.request-sort-empty > i { + font-size: 22px; +} +.request-sort-empty > span { + display: grid; + gap: 2px; +} +.request-sort-empty b { + color: var(--text-primary); + font-size: 11.5px; +} +.request-sort-empty small { + font-size: 10px; + line-height: 14px; + text-wrap: pretty; +} +.request-presentation-card .card-body { + display: grid; + gap: 14px; +} +.request-presentation-head { + display: flex; + align-items: center; + gap: 10px; +} +.request-presentation-head > div { + display: grid; + gap: 2px; + min-width: 0; +} +.request-presentation-head .req-card-title { + margin: 0; + text-wrap: balance; +} +.request-presentation-head p { + margin: 0; + color: var(--text-muted); + font-size: 11px; + line-height: 16px; + text-wrap: pretty; +} +.request-public-template-state { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 58px; + padding: 10px 12px; + border-radius: var(--radius-sm); + background: var(--background-muted); +} +.request-public-template-icon { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + background: var(--green-100); + color: var(--green-700); + font-size: 17px; +} +.request-public-template-state > div { + display: grid; + gap: 2px; + min-width: 0; +} +.request-public-template-state b { + font-size: 12px; + line-height: 17px; +} +.request-public-template-state div span { + color: var(--text-muted); + font-size: 10.5px; + line-height: 15px; + text-wrap: pretty; +} +.request-public-template-state > .badge { + gap: 5px; + white-space: nowrap; +} +.request-presentation-label { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} +.request-presentation-label b { + font-size: 11.5px; +} +.request-presentation-label span { + color: var(--text-muted); + font-size: 10.5px; +} +.request-presentation-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +.request-presentation-option { + position: relative; + display: grid; + grid-template-columns: 38px minmax(0, 1fr) 18px; + align-items: center; + gap: 9px; + min-height: 74px; + padding: 10px; + border: 0; + border-radius: var(--radius-sm); + background: var(--background-muted); + color: var(--text-primary); + text-align: left; + cursor: pointer; + box-shadow: inset 0 0 0 1px transparent; + transition-property: background-color, box-shadow, transform; + transition-duration: 140ms; +} +.request-presentation-option:hover { + background: var(--blue-50); + box-shadow: inset 0 0 0 1px var(--blue-200); +} +.request-presentation-option:active { + transform: scale(0.96); +} +.request-presentation-option.is-active { + background: var(--blue-50); + box-shadow: inset 0 0 0 2px var(--color-primary); +} +.request-presentation-option-icon { + display: grid; + place-items: center; + width: 38px; + height: 38px; + border-radius: var(--radius-sm); + background: var(--background-surface); + color: var(--color-primary); + font-size: 18px; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06); +} +.request-presentation-option > span:nth-child(2) { + display: grid; + gap: 2px; + min-width: 0; +} +.request-presentation-option b { + font-size: 11.5px; + line-height: 16px; +} +.request-presentation-option small { + color: var(--text-muted); + font-size: 10px; + line-height: 14px; + text-wrap: pretty; +} +.request-presentation-check { + color: transparent; + font-size: 17px; + transition-property: color, opacity, scale, filter; + transition-duration: 160ms; + opacity: 0; + scale: 0.25; + filter: blur(4px); +} +.request-presentation-option.is-active .request-presentation-check { + color: var(--color-primary); + opacity: 1; + scale: 1; + filter: blur(0); +} +.request-presentation-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.request-presentation-footer > span { + display: flex; + align-items: flex-start; + gap: 6px; + color: var(--text-muted); + font-size: 10.5px; + line-height: 15px; + text-wrap: pretty; +} +.request-presentation-footer > span i { + margin-top: 1px; + color: var(--cyan-600); + font-size: 14px; +} +.request-presentation-footer .btn { + flex: 0 0 auto; +} +@media (max-width: 680px) { + .request-editor-modebar { + grid-template-columns: 36px minmax(0, 1fr); + } + .request-editor-mode-switch { + grid-column: 1 / -1; + width: 100%; + } + .request-editor-mode-switch button { + flex: 1 1 50%; + justify-content: center; + } + .request-main-fields { + grid-template-columns: minmax(0, 1fr); + } + .request-sort-builder { + padding: 10px; + } + .request-sort-head { + align-items: stretch; + flex-direction: column; + } + .request-sort-head .cluster { + display: grid; + grid-template-columns: 1fr 1fr; + width: 100%; + } + .request-sort-head .btn { + min-width: 0; + justify-content: center; + white-space: normal; + } + .request-sort-rule { + grid-template-columns: 40px 24px minmax(0, 1fr) 40px; + align-items: center; + } + .request-sort-rule > label { + grid-column: 3; + } + .request-sort-rule > [data-request-sort-remove] { + grid-column: 4; + } + .request-sort-direction { + grid-column: 2 / 5; + grid-row: 2; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .request-sort-seed { + grid-column: 2 / 5; + grid-row: 2; + grid-template-columns: minmax(0, 1fr); + } + .request-sort-direction button { + min-width: 0; + } + .request-sort-direction button span { + overflow: hidden; + text-overflow: ellipsis; + } + .request-public-template-state { + grid-template-columns: 34px minmax(0, 1fr); + } + .request-public-template-state > .badge { + grid-column: 2; + justify-self: start; + } + .request-presentation-label { + align-items: flex-start; + flex-direction: column; + gap: 2px; + } + .request-presentation-options { + grid-template-columns: minmax(0, 1fr); + } + .request-presentation-footer { + align-items: stretch; + flex-direction: column; + } + .request-presentation-footer .btn { + width: 100%; + justify-content: center; + } +} +/* Редактор: основная колонка + сайдбар параметров */ +.requests-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 16px; + align-items: start; +} +@media (max-width: 1100px) { + .requests-grid { + grid-template-columns: 1fr; + } +} +/* Контракт структурированного результата */ +.request-contract { + margin-top: 18px; + padding: 18px; + border-radius: var(--radius-md); + background: var(--background-muted); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border-default) 72%, transparent); +} +.request-contract-head, +.request-contract-title, +.request-contract-group-head { + display: flex; + align-items: center; +} +.request-contract-head { + display: grid; + grid-template-columns: minmax(260px, 1fr) minmax(520px, auto); + align-items: start; + gap: 16px; + margin-bottom: 14px; +} +.request-contract-title { + gap: 11px; + min-width: 0; +} +.request-contract-title h2 { + margin: 0; + font-size: 15px; + line-height: 21px; + text-wrap: balance; +} +.request-contract-title p { + margin: 2px 0 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 18px; + text-wrap: pretty; +} +.request-renderer-control { + display: grid; + grid-template-columns: minmax(150px, auto) 170px 170px; + align-items: start; + gap: 10px; + min-width: 0; +} +.request-renderer-control > .badge { + align-self: start; + min-height: 36px; + margin-top: 17px; + justify-content: center; + white-space: nowrap; +} +.request-renderer-control label { + display: grid; + grid-template-rows: 14px 36px minmax(0, auto); + gap: 3px; + min-width: 0; + color: var(--text-muted); + font-size: 10px; +} +.request-renderer-control label > span:first-child { + display: flex; + align-items: center; + min-height: 14px; + line-height: 14px; +} +.request-renderer-control .select { + width: 100%; + min-height: 36px; + background: var(--background-surface); +} +.request-renderer-control .field-error { + max-width: 170px; + color: var(--color-danger); + font-size: 10px; + line-height: 14px; +} +.request-contract-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +@media (max-width: 1320px) { + .request-contract-head { + grid-template-columns: 1fr; + } + .request-renderer-control { + grid-template-columns: minmax(150px, auto) repeat(2, minmax(150px, 1fr)); + width: 100%; + } +} +@media (max-width: 680px) { + .request-renderer-control { + grid-template-columns: 1fr; + } + .request-renderer-control > .badge { + width: max-content; + margin-top: 0; + } +} +.request-contract-group { + min-width: 0; + padding: 14px; + border-radius: var(--radius-sm); + background: var(--background-surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.05); +} +.request-contract-group-head { + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} +.request-contract-group-head b { + font-size: 13px; +} +.request-contract-group-head span { + color: var(--text-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} +.request-contract-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} +.request-contract-option { + position: relative; + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; + min-height: 48px; + padding: 8px; + border-radius: var(--radius-sm); + background: var(--background-muted); + cursor: pointer; + transition-property: background-color, box-shadow, transform; + transition-duration: 140ms; +} +.request-contract-option:hover { + background: var(--color-primary-soft); +} +.request-contract-option:active { + transform: scale(0.96); +} +.request-contract-option:has(input:checked) { + background: var(--color-primary-soft); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 42%, transparent); +} +.request-contract-option input { + margin-top: 2px; + accent-color: var(--color-primary); +} +.request-contract-option > span { + display: flex; + flex-direction: column; + min-width: 0; +} +.request-contract-option b { + overflow: hidden; + color: var(--text-primary); + font-size: 12px; + line-height: 17px; + text-overflow: ellipsis; + white-space: nowrap; +} +.request-contract-option small { + overflow: hidden; + color: var(--text-muted); + font-size: 10.5px; + line-height: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} +.request-contract-empty { + display: flex; + align-items: center; + gap: 8px; + min-height: 48px; + color: var(--text-muted); + font-size: 12px; +} +.request-contract-empty i { + font-size: 20px; +} +@media (max-width: 900px) { + .request-contract-grid { + grid-template-columns: 1fr; + } +} +@media (max-width: 560px) { + .request-contract { + padding: 12px; + } + .request-contract-head { + align-items: stretch; + flex-direction: column; + } + .request-renderer-control { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; + width: 100%; + } + .request-renderer-control > .badge { + grid-column: 1 / -1; + justify-self: start; + } + .request-renderer-control label { + min-width: 0; + } + .request-renderer-control .select { + width: 100%; + } + .request-contract-options { + grid-template-columns: 1fr; + } +} +.req-card-title { + font-size: 14px; + font-weight: 700; + margin-bottom: 12px; +} +/* Кнопки-действия в таблицах: без подчёркивания и без переноса */ +.table td .btn { + text-decoration: none; + white-space: nowrap; +} +.table td .cluster { + flex-wrap: nowrap; +} +/* Тулбар редактора кода */ +.req-code-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} +.req-code-head .req-card-title { + margin-bottom: 0; +} +.req-code-tools { + display: flex; + gap: 4px; + flex-shrink: 0; +} +.req-lint-result.is-ok { + color: var(--color-success); +} +.req-lint-result.is-error { + color: var(--color-danger); + white-space: pre-wrap; +} +/* Разворот редактора на весь экран */ +body.req-code-fullscreen-open { + overflow: hidden; +} +.req-code-field.is-fullscreen { + position: fixed; + inset: 0; + z-index: 9000; + margin: 0; + border-radius: 0; + overflow: auto; +} +.req-code-field.is-fullscreen .CodeMirror { + height: calc(100vh - 110px) !important; +} +/* Палитра тегов */ +.req-tag-palette { + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); + margin-bottom: 10px; + overflow: hidden; +} +.req-tag-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border-bottom: 1px solid var(--border-default); +} +.req-tag-toolbar .input-wrap { + flex: 1; +} +.req-tag-nav { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 8px; + border-bottom: 1px solid var(--border-default); +} +.req-tag-nav button { + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid var(--border-default); + background: var(--background-muted); + border-radius: var(--radius-full); + padding: 3px 10px; + font-size: 12px; + cursor: pointer; + color: var(--text-secondary); +} +.req-tag-nav button b { + font-size: 10.5px; + color: var(--text-muted); +} +.req-tag-nav button.is-active { + background: var(--color-primary-soft); + border-color: var(--color-primary); + color: var(--color-primary); +} +.req-tag-nav button.is-active b { + color: var(--color-primary); +} +.req-tag-nav button[hidden], +.req-tag-group[hidden] { + display: none; +} +.req-tag-group { + display: none; + padding: 8px; + max-height: 260px; + overflow-y: auto; +} +.req-tag-group.is-active { + display: block; +} +.req-tag-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 6px; +} +.req-tag-list button { + text-align: left; + border: 1px solid var(--border-default); + background: var(--background-surface); + border-radius: var(--radius-sm); + padding: 6px 8px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.req-tag-list button:hover { + border-color: var(--color-primary); + background: var(--color-primary-soft); +} +.req-tag-list button code { + font-size: 12px; + color: var(--color-primary); +} +.req-tag-list button span { + font-size: 11px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.req-tag-empty { + padding: 14px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} +/* Конструктор логики условий */ +.req-condition-builder { + min-width: 0; +} +.req-condition-group { + border: 1px solid var(--border-default); + border-left: 3px solid var(--amber-500); + border-radius: var(--radius-md); + background: var(--background-surface); + overflow: hidden; + min-width: 0; +} +.req-condition-group.is-root { + border-left-color: var(--cyan-500); +} +.req-condition-group-head { + min-height: 58px; + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + background: var(--background-muted); + border-bottom: 1px solid var(--border-default); +} +.req-condition-group-title { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.req-condition-group-title > div { + display: flex; + flex-direction: column; + min-width: 0; +} +.req-condition-group-title strong { + font-size: 13px; + line-height: 18px; +} +.req-group-name { + width: min(280px, 40vw); + min-height: 24px; + padding: 1px 4px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-primary); + font: inherit; + font-size: 13px; + font-weight: 700; +} +.req-group-name:hover { + border-color: var(--border-default); + background: var(--background-surface); +} +.req-group-name:focus { + border-color: var(--color-primary); + background: var(--background-surface); + outline: 0; +} +.req-condition-group-title span:last-child { + color: var(--text-muted); + font-size: 11px; + line-height: 16px; +} +.req-condition-bracket { + width: 8px; + align-self: stretch; + border: solid var(--amber-500); + border-width: 2px 0 2px 2px; + border-radius: 3px 0 0 3px; + flex: 0 0 auto; +} +.is-root > .req-condition-group-head .req-condition-bracket { + border-color: var(--cyan-500); +} +.req-condition-group-tools { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} +.req-operator { + padding: 2px; +} +.req-operator button { + min-width: 42px; + height: 28px; + padding: 0 10px; + font-size: 11px; + font-weight: 700; +} +.req-condition-table-wrap { + border: 0; +} +.req-condition-table { + min-width: 980px; +} +.req-condition-table th { + white-space: nowrap; +} +.req-condition-cell-label { + display: none; +} +.req-condition-field-cell > .select, +.req-condition-compare-cell > .select { + display: block; +} +.cond-handle-col { + width: 38px; +} +.cond-field-col { + width: 260px; +} +.cond-compare-col { + width: 175px; +} +.cond-status-col { + width: 66px; +} +.cond-actions-col { + width: 82px; +} +.cond-actions { + gap: 4px; + flex-wrap: nowrap; +} +.req-condition-value { + display: grid; + gap: 7px; + min-width: 0; +} +.req-condition-value [hidden] { + display: none !important; +} +.req-condition-source-field, +.req-condition-literal { + display: grid; + gap: 3px; + min-width: 0; +} +.req-condition-source-field > span, +.req-condition-literal > span { + color: var(--text-muted); + font-size: 10px; + line-height: 14px; +} +.req-condition-source { + min-height: 36px; + height: 36px; + font-size: 12px; +} +.req-condition-input { + display: grid; + grid-template-columns: minmax(140px, 0.9fr) minmax(180px, 1.1fr); + gap: 6px; +} +.req-condition-input label { + display: grid; + gap: 3px; + min-width: 0; +} +.req-condition-input label > span { + color: var(--text-muted); + font-size: 10px; + line-height: 14px; +} +.req-condition-parameter { + grid-column: 1 / -1; +} +.req-condition-input [data-condition-constant] { + grid-column: 1 / -1; +} +.req-condition-literal > [data-condition-literal-storage][hidden] { + display: none !important; +} +.req-condition-smart-control { + display: block; + min-width: 0; +} +.req-condition-smart-control:empty { + display: none; +} +.req-condition-smart-control > .input, +.req-condition-smart-control > .select { + width: 100%; +} +.req-condition-choice[multiple] { + height: auto; + min-height: 76px; + padding-block: 5px; +} +.req-condition-choice[multiple] option { + padding: 5px 7px; + border-radius: 3px; +} +.req-condition-boolean { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; + padding: 3px; + border-radius: var(--radius-sm); + background: var(--background-muted); +} +.req-condition-boolean button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 34px; + border: 0; + border-radius: calc(var(--radius-sm) - 2px); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font: inherit; + font-size: 12px; + transition-property: background-color, color, box-shadow, transform; + transition-duration: 140ms; +} +.req-condition-boolean button:hover { + color: var(--text-primary); +} +.req-condition-boolean button:active { + transform: scale(0.96); +} +.req-condition-boolean button[aria-pressed="true"] { + background: var(--background-surface); + color: var(--blue-700); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); +} +.req-condition-document { + display: grid; + grid-template-columns: minmax(0, 1fr) 40px; + gap: 6px; +} +.req-condition-document .btn { + width: 40px; + min-width: 40px; + height: 40px; +} +.req-condition-preview { + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + align-items: start; + gap: 6px; + padding: 7px 8px; + border-radius: var(--radius-sm); + color: var(--blue-700); + background: var(--blue-50); + font-size: 11px; + line-height: 1.4; +} +.req-condition-preview i { + margin-top: 1px; + font-size: 14px; +} +.req-condition-technical { + color: var(--text-muted); + font-size: 10px; +} +.req-condition-technical summary { + width: max-content; + cursor: pointer; +} +.req-condition-technical code { + display: block; + width: max-content; + max-width: 100%; + margin-top: 4px; + overflow: hidden; + color: var(--color-primary); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} +.req-condition-children { + display: grid; + gap: 10px; + padding: 10px; + background: color-mix(in srgb, var(--background-muted) 55%, transparent); +} +.req-condition-children:empty { + display: none; +} +.req-condition-group .req-condition-group { + box-shadow: none; +} +.request-condition-picker { + width: min(66.666vw, 920px); + height: min(72vh, 720px); +} +.request-condition-picker .modal-body { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + min-height: 0; +} +.request-condition-picker-search { + max-width: 520px; +} +.request-condition-picker-status { + padding: 26px 10px; + color: var(--text-muted); + text-align: center; +} +.request-condition-picker-list { + display: grid; + align-content: start; + gap: 6px; + min-height: 0; + margin-top: 10px; + overflow-y: auto; +} +.request-condition-picker-item { + display: grid; + grid-template-columns: 64px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 54px; + padding: 7px 10px; + border: 0; + border-radius: var(--radius-sm); + background: var(--background-muted); + color: var(--text-primary); + cursor: pointer; + text-align: left; + transition-property: background-color, box-shadow, transform; + transition-duration: 140ms; +} +.request-condition-picker-item:hover { + background: var(--blue-50); + box-shadow: inset 0 0 0 1px var(--blue-200); +} +.request-condition-picker-item:active { + transform: scale(0.96); +} +.request-condition-picker-id { + color: var(--text-muted); + font-variant-numeric: tabular-nums; + font-size: 11px; +} +.request-condition-picker-item > span:nth-child(2) { + display: grid; + gap: 2px; + min-width: 0; +} +.request-condition-picker-item b, +.request-condition-picker-item small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.request-condition-picker-item b { + font-size: 12px; +} +.request-condition-picker-item small { + color: var(--text-muted); + font-size: 10px; +} +/* Drag-and-drop условий */ +.cond-drag-cell { + text-align: center; +} +.cond-drag { + cursor: grab; + color: var(--text-muted); + display: inline-flex; +} +.cond-drag:active { + cursor: grabbing; +} +tr.cond-dragging { + opacity: 0.5; + background: var(--color-primary-soft); +} +@media (max-width: 760px) { + .req-condition-group-head { + align-items: flex-start; + flex-direction: column; + } + .req-condition-group-tools { + width: 100%; + } + .request-condition-picker { + width: calc(100vw - 20px); + height: calc(100vh - 32px); + } + .request-condition-picker-item { + grid-template-columns: 52px minmax(0, 1fr); + } + .request-condition-picker-item .badge { + grid-column: 2; + width: max-content; + } +} +/* Предпросмотр представления без исполнения публичных шаблонов */ +.request-preview-section { + margin-top: 24px; +} +.request-preview-header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; +} +.request-preview-actions { + display: flex; + align-items: flex-end; + gap: 8px; +} +.request-preview-actions label { + display: grid; + gap: 3px; + color: var(--text-muted); + font-size: 10px; +} +.request-preview-actions .select { + width: 76px; + min-height: 40px; +} +.request-preview-actions .btn { + min-height: 40px; +} +.request-preview-actions .btn.is-loading i { + animation: request-preview-spin 0.7s linear infinite; +} +@keyframes request-preview-spin { + to { + transform: rotate(360deg); + } +} +.request-preview-card { + overflow: visible; +} +.request-explain-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 13px 16px; + border-bottom: 1px solid var(--border-default); + border-radius: var(--radius-md) var(--radius-md) 0 0; + background: var(--background-surface); +} +.request-explain-copy { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.request-explain-copy > div { + display: grid; + gap: 2px; + min-width: 0; +} +.request-explain-copy b { + font-size: 12.5px; + line-height: 18px; +} +.request-explain-copy span { + color: var(--text-muted); + font-size: 11px; + line-height: 16px; + text-wrap: pretty; +} +.request-explain-search { + position: relative; + display: flex; + align-items: center; + gap: 7px; + width: min(520px, 52%); +} +.request-explain-search .input-wrap { + flex: 1; + min-width: 180px; +} +.request-explain-search .btn { + min-height: 40px; +} +.request-explain-options { + position: absolute; + z-index: 30; + top: calc(100% + 6px); + right: 0; + width: 100%; + max-height: 320px; + padding: 6px; + overflow-y: auto; + border-radius: var(--radius-md); + background: var(--background-surface); + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.16), 0 0 0 1px rgba(0, 0, 0, 0.07); +} +.request-explain-options > button { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + min-height: 48px; + padding: 7px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-primary); + text-align: left; + cursor: pointer; + transition-property: background-color, transform; + transition-duration: 140ms; +} +.request-explain-options > button:hover { + background: var(--background-muted); +} +.request-explain-options > button:active { + transform: scale(0.96); +} +.request-explain-options .icon-tile { + --tile-bg: var(--blue-100); + --tile-fg: var(--blue-600); + width: 34px; + height: 34px; + flex: 0 0 auto; +} +.request-explain-options button > span:last-child { + display: grid; + gap: 1px; + min-width: 0; +} +.request-explain-options b, +.request-explain-options small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.request-explain-options b { + font-size: 12px; +} +.request-explain-options small { + color: var(--text-muted); + font-size: 10.5px; +} +.request-explain-options-state { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 64px; + color: var(--text-muted); + font-size: 12px; +} +.request-explain-options-state .ti-loader-2 { + animation: request-preview-spin 0.7s linear infinite; +} +.request-explain-options-state.is-error { + color: var(--color-danger); +} +.request-preview-empty, +.request-preview-no-items { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 130px; + padding: 24px; + text-align: left; +} +.request-preview-empty[hidden], +.request-preview-result[hidden] { + display: none; +} +.request-preview-empty > div, +.request-preview-no-items { + color: var(--text-secondary); +} +.request-preview-empty > div { + display: grid; + gap: 3px; +} +.request-preview-empty b, +.request-preview-no-items b { + color: var(--text-primary); + font-size: 13px; +} +.request-preview-empty span, +.request-preview-no-items span { + font-size: 12px; +} +.request-preview-result { + padding: 16px; +} +.request-preview-stats { + display: grid; + grid-template-columns: repeat(3, minmax(100px, 1fr)); + gap: 8px; + margin-bottom: 10px; +} +.request-preview-stats > div { + display: flex; + align-items: baseline; + gap: 7px; + padding: 9px 11px; + border-radius: var(--radius-sm); + background: var(--background-muted); +} +.request-preview-stats b { + font-size: 18px; + line-height: 22px; + font-variant-numeric: tabular-nums; +} +.request-preview-stats span { + color: var(--text-muted); + font-size: 10.5px; + text-transform: uppercase; +} +.request-preview-plan { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 14px; +} +.request-preview-plan span { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 28px; + padding: 4px 8px; + border-radius: var(--radius-full); + background: var(--background-muted); + color: var(--text-secondary); + font-size: 11px; +} +.request-preview-plan i { + color: var(--color-primary); + font-size: 14px; +} +.request-executor-comparison { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 10px; + margin-bottom: 12px; + padding: 11px 12px; + border-radius: var(--radius-sm); + background: var(--background-muted); +} +.request-executor-comparison[hidden] { + display: none; +} +.request-executor-comparison .icon-tile { + width: 34px; + height: 34px; +} +.request-executor-comparison > div { + display: grid; + gap: 2px; + min-width: 0; +} +.request-executor-comparison b { + font-size: 12.5px; + line-height: 18px; +} +.request-executor-comparison span { + color: var(--text-secondary); + font-size: 11px; + line-height: 16px; +} +.request-executor-comparison > small { + grid-column: 2; + color: var(--text-muted); + font-size: 10.5px; + line-height: 16px; + overflow-wrap: anywhere; +} +.request-executor-comparison.is-success .icon-tile { + --tile-bg: var(--green-100); + --tile-fg: var(--green-600); +} +.request-executor-comparison.is-warning .icon-tile { + --tile-bg: var(--amber-100); + --tile-fg: var(--amber-600); +} +.request-executor-comparison.is-danger .icon-tile { + --tile-bg: var(--red-100); + --tile-fg: var(--red-600); +} +.request-preview-items { + display: grid; + gap: 8px; +} +.request-preview-json { + max-height: 560px; + margin: 0; + padding: 14px; + overflow: auto; + border-radius: var(--radius-sm); + background: var(--background-muted); + color: var(--text-primary); + font-size: 11px; + line-height: 18px; + white-space: pre; + tab-size: 2; +} +.request-preview-list-item { + display: grid; + grid-template-columns: minmax(190px, 1.2fr) minmax(260px, 2fr) 40px; + align-items: center; + gap: 12px; + min-height: 62px; + padding: 8px 10px; + border-radius: var(--radius-sm); + background: var(--background-surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.06); +} +.request-preview-list-main { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} +.request-preview-list-main .icon-tile { + --tile-bg: var(--blue-100); + --tile-fg: var(--blue-600); + width: 34px; + height: 34px; + flex: 0 0 auto; +} +.request-preview-list-main > span:last-child { + display: grid; + min-width: 0; +} +.request-preview-list-main b { + overflow: hidden; + font-size: 12px; + line-height: 17px; + text-overflow: ellipsis; + white-space: nowrap; +} +.request-preview-list-main small { + color: var(--text-muted); + font-size: 10px; + font-variant-numeric: tabular-nums; +} +.request-preview-list-values { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; + min-width: 0; +} +.request-preview-list-values > span { + display: grid; + min-width: 0; +} +.request-preview-list-values small, +.request-preview-list-values b { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.request-preview-list-values small { + color: var(--text-muted); + font-size: 9.5px; +} +.request-preview-list-values b { + font-size: 10.5px; + font-weight: 500; +} +.request-preview-table-wrap { + width: 100%; + overflow-x: auto; + border-radius: var(--radius-sm); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06); +} +.request-preview-table { + min-width: 720px; + margin: 0; + background: var(--background-surface); +} +.request-preview-table th { + white-space: nowrap; +} +.request-preview-table td { + max-width: 280px; + overflow: hidden; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} +.request-preview-table-title { + min-width: 220px; +} +.request-preview-table-title b, +.request-preview-table-title small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.request-preview-table-title b { + font-size: 11.5px; +} +.request-preview-table-title small { + color: var(--text-muted); + font-size: 9.5px; + font-variant-numeric: tabular-nums; +} +.request-preview-table-action { + width: 52px; + min-width: 52px; + text-align: right; +} +.request-preview-item { + padding: 12px; + border-radius: var(--radius-sm); + background: var(--background-surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.06); +} +.request-preview-item header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.request-preview-item header > div { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} +.request-preview-item header .icon-tile { + --tile-bg: var(--blue-100); + --tile-fg: var(--blue-600); + width: 34px; + height: 34px; +} +.request-preview-item header div > span:last-child { + display: flex; + flex-direction: column; + min-width: 0; +} +.request-preview-item header b { + overflow: hidden; + font-size: 12.5px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} +.request-preview-item header small { + color: var(--text-muted); + font-size: 10px; + font-variant-numeric: tabular-nums; +} +.request-preview-values { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 7px; + margin: 10px 0 0; +} +.request-preview-values > div { + min-width: 0; + padding: 8px; + border-radius: var(--radius-sm); + background: var(--background-muted); +} +.request-preview-values dt { + display: flex; + justify-content: space-between; + gap: 8px; + color: var(--text-secondary); + font-size: 10.5px; +} +.request-preview-values dt small { + overflow: hidden; + color: var(--text-muted); + font-family: var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; +} +.request-preview-values dd { + overflow: hidden; + margin: 4px 0 0; + color: var(--text-primary); + font-size: 12px; + line-height: 17px; + text-overflow: ellipsis; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.request-preview-no-items { + flex-direction: column; + text-align: center; +} +.request-preview-no-items > i { + color: var(--text-muted); + font-size: 28px; +} +.request-preview-sql { + margin-top: 12px; + border-radius: var(--radius-sm); + background: var(--background-muted); +} +.request-preview-sql summary { + display: flex; + align-items: center; + gap: 7px; + min-height: 40px; + padding: 8px 11px; + cursor: pointer; + color: var(--text-secondary); + font-size: 12px; +} +.request-preview-sql pre { + max-height: 320px; + margin: 0; + padding: 12px; + overflow: auto; + border-top: 1px solid var(--border-default); + color: var(--text-primary); + font-size: 11px; + line-height: 17px; + white-space: pre-wrap; +} +@media (max-width: 760px) { + .request-preview-header { + grid-template-columns: auto minmax(0, 1fr); + } + .request-preview-actions { + grid-column: 1 / -1; + width: 100%; + } + .request-preview-actions .btn { + flex: 1; + } + .request-preview-stats { + grid-template-columns: 1fr; + } + .request-preview-result { + padding: 10px; + } + .request-preview-list-item { + grid-template-columns: minmax(0, 1fr) 40px; + } + .request-preview-list-values { + grid-column: 1 / -1; + grid-row: 2; + } + .request-explain-bar { + align-items: stretch; + flex-direction: column; + padding: 12px; + } + .request-explain-search { + width: 100%; + } + .request-explain-search .btn { + flex: 0 0 auto; + } +} +/* Explain: подробная трассировка одного документа */ +.request-explain-drawer { + width: min(66.666vw, 1120px); + max-width: none; +} +.request-explain-drawer-title { + display: flex; + align-items: center; + gap: 11px; + min-width: 0; +} +.request-explain-drawer-title > div { + min-width: 0; +} +.request-explain-drawer-title h3, +.request-explain-drawer-title p { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.request-explain-body { + background: var(--background-muted); +} +.request-explain-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + min-height: 240px; + color: var(--text-secondary); + font-size: 13px; +} +.request-explain-loading[hidden] { + display: none; +} +.request-explain-loading .ti-loader-2 { + animation: request-preview-spin 0.7s linear infinite; +} +.request-explain-loading .ti-alert-triangle { + color: var(--color-danger); +} +.request-explain-drawer .drawer-footer { + min-height: 64px; + align-items: center; +} +.request-explain-drawer .df-left { + display: flex; + align-items: center; + min-width: 0; + margin-right: auto; +} +.request-explain-drawer .drawer-footer > [data-close-drawer] { + margin-right: 0; + margin-left: 16px; +} +.request-explain-section { + margin-bottom: 14px; + padding: 16px; + border-radius: var(--radius-md); + background: var(--background-surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.05); +} +.request-explain-section-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} +.request-explain-section-head .icon-tile { + --tile-bg: var(--cyan-100); + --tile-fg: var(--cyan-600); +} +.request-explain-section:nth-child(2) .request-explain-section-head .icon-tile { + --tile-bg: var(--amber-100); + --tile-fg: var(--amber-600); +} +.request-explain-section-head h4 { + margin: 0; + font-size: 14px; + line-height: 20px; + text-wrap: balance; +} +.request-explain-section-head p { + margin: 1px 0 0; + color: var(--text-muted); + font-size: 11px; +} +.request-explain-checks { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} +.request-explain-check { + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; + padding: 10px; + border-radius: var(--radius-sm); + background: var(--background-muted); +} +.request-explain-check > i { + display: grid; + place-items: center; + width: 22px; + height: 22px; + flex: 0 0 auto; + border-radius: 50%; + font-size: 13px; +} +.request-explain-check.is-passed > i { + background: var(--green-100); + color: var(--green-700); +} +.request-explain-check.is-failed > i { + background: var(--red-100); + color: var(--red-700); +} +.request-explain-check > div { + display: grid; + gap: 2px; + min-width: 0; +} +.request-explain-check b { + font-size: 11.5px; + line-height: 16px; +} +.request-explain-check span { + color: var(--text-muted); + font-size: 10.5px; + line-height: 15px; + text-wrap: pretty; +} +.request-explain-tree { + display: grid; + gap: 9px; +} +.request-explain-group { + padding: 10px; + border-radius: var(--radius-sm); + background: var(--background-muted); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.045); +} +.request-explain-group > header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} +.request-explain-group > header > div { + display: grid; + min-width: 0; +} +.request-explain-group > header b { + font-size: 12px; + line-height: 17px; +} +.request-explain-group > header small { + color: var(--text-muted); + font-size: 10.5px; + line-height: 15px; +} +.request-explain-group-state, +.request-explain-condition-state { + display: grid; + place-items: center; + flex: 0 0 auto; + border-radius: 50%; +} +.request-explain-group-state { + width: 28px; + height: 28px; +} +.request-explain-condition-state { + width: 24px; + height: 24px; +} +.request-explain-group.is-passed > header .request-explain-group-state, +.request-explain-condition.is-passed .request-explain-condition-state { + background: var(--green-100); + color: var(--green-700); +} +.request-explain-group.is-failed > header .request-explain-group-state, +.request-explain-condition.is-failed .request-explain-condition-state { + background: var(--red-100); + color: var(--red-700); +} +.request-explain-group.is-unknown > header .request-explain-group-state, +.request-explain-condition.is-unknown .request-explain-condition-state { + background: var(--amber-100); + color: var(--amber-700); +} +.request-explain-group.is-empty > header .request-explain-group-state, +.request-explain-condition.is-skipped .request-explain-condition-state { + background: var(--gray-100); + color: var(--gray-600); +} +.request-explain-group-body { + display: grid; + gap: 7px; + margin-top: 9px; +} +.request-explain-group .request-explain-group { + background: var(--background-surface); +} +.request-explain-condition { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + padding: 9px; + border-radius: var(--radius-sm); + background: var(--background-surface); +} +.request-explain-condition-copy { + min-width: 0; +} +.request-explain-condition-copy > div:first-child { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 9px; +} +.request-explain-condition-copy b { + font-size: 11.5px; +} +.request-explain-condition-copy code { + overflow: hidden; + color: var(--text-muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} +.request-explain-condition dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin: 7px 0 0; +} +.request-explain-condition dl > div { + min-width: 0; + padding: 6px 7px; + border-radius: var(--radius-sm); + background: var(--background-muted); +} +.request-explain-condition dt { + color: var(--text-muted); + font-size: 9.5px; + text-transform: uppercase; +} +.request-explain-condition dd { + margin: 3px 0 0; + overflow-wrap: anywhere; + font-size: 11px; + line-height: 15px; + white-space: pre-wrap; +} +.request-explain-condition p { + margin: 7px 0 0; + color: var(--amber-700); + font-size: 10.5px; + line-height: 15px; +} +.request-explain-tree-empty { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 54px; + color: var(--text-muted); + font-size: 11px; + text-align: center; +} +.request-explain-notes { + display: grid; + gap: 5px; +} +.request-explain-notes span { + display: flex; + align-items: flex-start; + gap: 6px; + color: var(--text-muted); + font-size: 10.5px; + line-height: 15px; +} +.request-explain-notes i { + margin-top: 1px; + color: var(--color-primary); +} +.request-condition-guide { + margin: 0 0 14px; + overflow: hidden; + border-radius: var(--radius-md); + background: var(--background-surface); + box-shadow: 0 0 0 1px var(--border-subtle); +} +.request-condition-guide > summary { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 58px; + padding: 10px 14px; + cursor: pointer; + list-style: none; +} +.request-condition-guide > summary::-webkit-details-marker { + display: none; +} +.request-condition-guide > summary:hover { + background: var(--background-muted); +} +.request-condition-guide > summary > span:nth-child(2) { + display: grid; + gap: 2px; + min-width: 0; +} +.request-condition-guide > summary b { + font-size: 12.5px; +} +.request-condition-guide > summary small { + color: var(--text-muted); + font-size: 10.5px; + line-height: 15px; +} +.request-condition-guide-chevron { + color: var(--text-muted); + transition: transform 160ms ease; +} +.request-condition-guide[open] .request-condition-guide-chevron { + transform: rotate(90deg); +} +.request-condition-guide-body { + display: grid; + gap: 12px; + padding: 14px; + border-top: 1px solid var(--border-subtle); +} +.request-condition-guide-steps { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 9px; +} +.request-condition-guide-steps article { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + gap: 8px; + align-items: start; + min-width: 0; +} +.request-condition-guide-steps article > span { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--blue-100); + color: var(--blue-700); + font-size: 10.5px; + font-weight: 700; +} +.request-condition-guide-steps article div { + min-width: 0; +} +.request-condition-guide-steps b { + font-size: 11.5px; +} +.request-condition-guide-steps p { + margin: 2px 0 0; + color: var(--text-muted); + font-size: 10.5px; + line-height: 15px; + text-wrap: pretty; +} +.request-condition-guide-examples { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.request-condition-guide-examples > div { + display: grid; + gap: 4px; + min-width: 0; + padding: 9px 10px; + border-radius: var(--radius-sm); + background: var(--background-muted); +} +.request-condition-guide-examples b { + font-size: 10.5px; +} +.request-condition-guide-examples code { + overflow-wrap: anywhere; + color: var(--text-primary); + font-size: 10.5px; +} +.request-condition-guide-examples span { + color: var(--text-muted); + font-size: 10px; + line-height: 14px; +} +.request-condition-guide-note { + display: flex; + align-items: flex-start; + gap: 7px; + margin: 0; + color: var(--text-secondary); + font-size: 10.5px; + line-height: 16px; +} +.request-condition-guide-note i { + margin-top: 1px; + color: var(--cyan-600); + font-size: 14px; +} +@media (max-width: 900px) { + .request-condition-guide-steps { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .request-condition-guide-examples { + grid-template-columns: 1fr; + } +} +@media (max-width: 560px) { + .request-condition-guide-steps { + grid-template-columns: 1fr; + } + .request-condition-guide > summary { + padding-inline: 11px; + } +} +@media (max-width: 900px) { + .request-explain-drawer { + width: min(88vw, 760px); + } +} +@media (max-width: 620px) { + .request-explain-drawer { + width: 100vw; + } + .request-explain-checks, + .request-explain-condition dl { + grid-template-columns: 1fr; + } + .request-explain-section { + padding: 12px; + } +} +@media (prefers-reduced-motion: reduce) { + .request-contract-option { + transition-duration: 0ms; + } + .request-preview-actions .btn.is-loading i, + .request-explain-options-state .ti-loader-2, + .request-explain-loading .ti-loader-2 { + animation: none; + } +} +/* Массовая сверка Legacy и Native */ +.request-audit { + display: grid; + gap: 18px; +} +.request-audit-summary .stat-val, +.request-audit-progress span { + font-variant-numeric: tabular-nums; +} +.request-audit-note { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: start; + padding: 14px 16px; + border-radius: var(--radius-md); + background: var(--blue-50); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--blue-500) 18%, transparent); +} +.request-audit-note h2 { + margin: 0; + font-size: 13px; + line-height: 19px; + text-wrap: balance; +} +.request-audit-note p { + margin: 3px 0 0; + color: var(--text-secondary); + font-size: 11.5px; + line-height: 17px; + text-wrap: pretty; +} +.request-audit-progress { + display: grid; + gap: 8px; + padding: 13px 16px; + border-radius: var(--radius-md); + background: var(--background-surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.06); +} +.request-audit-progress[hidden] { + display: none; +} +.request-audit-progress > div:first-child { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 12px; +} +.request-audit-progress span { + color: var(--text-muted); +} +.request-audit-progress-track { + height: 6px; + overflow: hidden; + border-radius: var(--radius-full); + background: var(--background-muted); +} +.request-audit-progress-track span { + display: block; + width: 0; + height: 100%; + border-radius: inherit; + background: var(--color-primary); + transition-property: width; + transition-duration: 160ms; + transition-timing-function: cubic-bezier(0.2, 0, 0, 1); +} +.request-audit-header { + grid-template-columns: auto minmax(0, 1fr) auto; + margin-top: 0; +} +.request-audit-header-actions { + display: flex; + flex-wrap: nowrap; + justify-content: flex-end; + align-items: center; + gap: 8px; + width: max-content; + min-width: 0; +} +.request-audit-card { + overflow: hidden; +} +.request-audit-table { + width: 100%; + min-width: 820px; + table-layout: fixed; +} +.request-audit-id { + width: 58px; +} +.request-audit-table th:nth-child(3) { + width: 94px; +} +.request-audit-table th:nth-child(4) { + width: 104px; +} +.request-audit-table th:nth-child(5) { + width: 310px; +} +.request-audit-table th:last-child { + width: 62px; +} +.request-audit-table td { + vertical-align: middle; +} +.request-audit-alias { + display: block; + margin-top: 2px; + overflow: hidden; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10.5px; + text-overflow: ellipsis; + white-space: nowrap; +} +.request-audit-result { + display: grid; + gap: 2px; + min-width: 0; +} +.request-audit-result b { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11.5px; + line-height: 17px; +} +.request-audit-result small { + overflow: hidden; + color: var(--text-muted); + font-size: 10.5px; + line-height: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} +.request-audit-result .is-success { + color: var(--green-700); +} +.request-audit-result .is-warning { + color: var(--amber-700); +} +.request-audit-result .is-danger { + color: var(--red-700); +} +.request-audit-result .is-pending { + color: var(--text-secondary); +} +.request-audit-table .is-spinning { + animation: request-preview-spin 0.7s linear infinite; +} +.request-audit-table .btn:active { + transform: scale(0.96); +} +@media (max-width: 760px) { + .request-audit-header { + grid-template-columns: auto minmax(0, 1fr); + } + .request-audit-header-actions { + grid-column: 1 / -1; + flex-wrap: wrap; + width: 100%; + } + .request-audit-header-actions .btn { + flex: 1 1 220px; + } + .request-audit-card .table-scroll { + overflow-x: auto; + } +} +@media (prefers-reduced-motion: reduce) { + .request-audit-progress-track span { + transition-duration: 0ms; + } + .request-audit-table .is-spinning { + animation: none; + } +} diff --git a/adminx/modules/Requests/assets/requests.js b/adminx/modules/Requests/assets/requests.js new file mode 100644 index 0000000..660f09d --- /dev/null +++ b/adminx/modules/Requests/assets/requests.js @@ -0,0 +1,2017 @@ +/** + * JS раздела «Запросы». Список: копирование/удаление. Редактор: сохранение + * (ajax), палитра тегов + проверка PHP (lint) + разворот редактора, условия + * (CRUD + drag-and-drop сортировка). Всё через ajax, подтверждения — Adminx.Confirm. + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.Requests = { + activeField: null, + dragRow: null, + explainSearchTimer: null, + + init: function () { + this.form = document.getElementById('requestForm'); + this.condTable = document.getElementById('condTable'); + this.palette = document.querySelector('[data-req-tags-panel]'); + this.auditPage = document.querySelector('[data-native-audit-page]'); + this.auditRunning = false; + var self = this; + this.initEditorMode(); + this.initPresentation(); + this.initSortBuilder(); + + document.addEventListener('click', function (e) { self.onClick(e); }); + + // активный редактор — тот, в котором фокус + document.addEventListener('focusin', function (e) { + var f = e.target.closest('[data-req-code-field]'); + if (f) { self.activeField = f; } + }); + + // поиск по тегам + фильтр списка запросов + document.addEventListener('input', function (e) { + if (e.target.matches('[data-req-tags-search]')) { self.filterTags(e.target.value); } + if (e.target.matches('[data-requests-search]')) { self.filterList(e.target.value); } + if (e.target.matches('[data-request-explain-search]')) { self.explainSearchInput(e.target); } + if (e.target.matches('[data-request-sort-seed-input]')) { self.syncSortRules(); } + if (e.target.matches('[data-cf="condition_value_key"], [data-cf="condition_value_constant"], [data-cf="condition_value"]')) { self.conditionValueRefresh(e.target.closest('[data-condition-value]')); } + if (e.target.matches('[data-condition-smart-input]')) { self.conditionSmartValue(e.target); } + }); + document.addEventListener('change', function (e) { + if (e.target.matches('[data-group-title]')) { self.groupTitle(e.target); } + if (e.target.matches('[data-request-preview-renderer-advanced]')) { self.selectPresentation(e.target.value); } + if (e.target.matches('[data-request-sort-source]')) { self.sortSourceChanged(e.target); } + if (e.target.matches('[data-cf="condition_value_source"], [data-cf="condition_value_mode"], [data-cf="condition_field_id"], [data-cf="condition_compare"]')) { + var conditionRow = e.target.closest('tr'); + if (e.target.matches('[data-cf="condition_field_id"]')) { self.conditionFieldRefresh(conditionRow, true); } + else if (e.target.matches('[data-cf="condition_compare"], [data-cf="condition_value_source"]')) { self.conditionFieldRefresh(conditionRow, false); } + self.conditionValueRefresh(conditionRow.querySelector('[data-condition-value]'), e.target); + self.syncEditorAdvancedOptions(); + } + if (e.target.matches('[data-condition-smart-select]')) { self.conditionSmartValue(e.target); } + }); + + // сохранение запроса + if (this.form) { + this.form.addEventListener('submit', function (e) { e.preventDefault(); self.save(); }); + } + + // drag-and-drop условий + if (this.condTable) { + this.condTable.querySelectorAll('tr[data-cond-id]').forEach(function (row) { self.conditionFieldRefresh(row, false); }); + this.condTable.addEventListener('dragstart', function (e) { self.dragStart(e); }); + this.condTable.addEventListener('dragover', function (e) { self.dragOver(e); }); + this.condTable.addEventListener('drop', function (e) { self.drop(e); }); + this.condTable.addEventListener('dragend', function () { self.dragEnd(); }); + } + + // Esc закрывает fullscreen/палитру + document.addEventListener('keydown', function (e) { + if (e.key !== 'Escape') { return; } + var fs = document.querySelector('.req-code-field.is-fullscreen'); + if (fs) { self.fullscreen(fs, false); } + else if (self.palette && !self.palette.hidden) { self.closePalette(); } + }); + }, + + base: function () { return (this.form && this.form.getAttribute('data-base')) || Adminx.base(); }, + + initEditorMode: function () { + if (!document.querySelector('[data-request-editor-mode]')) { return; } + var mode = 'basic'; + try { mode = window.localStorage.getItem('adminx.requests.editorMode') === 'advanced' ? 'advanced' : 'basic'; } catch (e) {} + this.setEditorMode(mode, false); + }, + + setEditorMode: function (mode, remember) { + mode = mode === 'advanced' ? 'advanced' : 'basic'; + this.editorMode = mode; + document.querySelectorAll('[data-request-advanced]').forEach(function (node) { + node.hidden = mode !== 'advanced'; + }); + document.querySelectorAll('[data-request-editor-mode-value]').forEach(function (button) { + var active = button.getAttribute('data-request-editor-mode-value') === mode; + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + var description = document.querySelector('[data-request-editor-mode-description]'); + if (description) { + description.textContent = mode === 'advanced' + ? 'Кеш, интеграции, совместимость, контракт результата и Native executor.' + : 'Основные параметры, условия и оформление результата.'; + } + this.syncEditorAdvancedOptions(); + if (mode === 'advanced') { + window.requestAnimationFrame(function () { + document.querySelectorAll('[data-req-code-field]').forEach(function (field) { + var textarea = field.querySelector('textarea[data-code-editor]'); + if (textarea && textarea._adminxCodeMirror) { textarea._adminxCodeMirror.refresh(); } + }); + }); + } + if (remember !== false) { + try { window.localStorage.setItem('adminx.requests.editorMode', mode); } catch (e) {} + } + }, + + initPresentation: function () { + var select = document.querySelector('[data-request-preview-renderer]'); + if (!select) { return; } + this.selectPresentation(select.value, false); + }, + + selectPresentation: function (code) { + var select = document.querySelector('[data-request-preview-renderer]'); + if (!select) { return; } + var available = Array.prototype.some.call(select.options, function (option) { return option.value === code; }); + if (!available) { code = 'data_cards'; } + select.value = code; + var advanced = document.querySelector('[data-request-preview-renderer-advanced]'); + if (advanced) { advanced.value = code; } + document.querySelectorAll('[data-request-presentation-value]').forEach(function (button) { + var active = button.getAttribute('data-request-presentation-value') === code; + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + }, + + openCodeEditor: function () { + this.setEditorMode('advanced'); + var field = document.getElementById('requestSiteTemplates'); + if (!field) { return; } + window.requestAnimationFrame(function () { + field.scrollIntoView({behavior: 'smooth', block: 'start'}); + var textarea = field.querySelector('textarea[data-code-editor]'); + if (textarea && textarea._adminxCodeMirror) { textarea._adminxCodeMirror.focus(); } + else if (textarea) { textarea.focus(); } + }); + }, + + initSortBuilder: function () { + this.sortList = document.querySelector('[data-request-sort-list]'); + if (!this.sortList) { return; } + var self = this; + this.sortList.querySelectorAll('[data-request-sort-source]').forEach(function (select) { + select.setAttribute('data-sort-source-value', select.value); + }); + this.sortList.addEventListener('dragstart', function (event) { self.sortDragStart(event); }); + this.sortList.addEventListener('dragover', function (event) { self.sortDragOver(event); }); + this.sortList.addEventListener('drop', function (event) { self.sortDrop(event); }); + this.sortList.addEventListener('dragend', function () { self.sortDragEnd(); }); + this.syncSortRules(); + }, + + sortAdd: function (preferred) { + if (!this.sortList) { return; } + var rows = this.sortList.querySelectorAll('[data-request-sort-rule]'); + if (rows.length >= 8) { Adminx.Toast.show('Можно добавить не больше восьми уровней', 'warning'); return; } + var randomSelected = Array.from(this.sortList.querySelectorAll('[data-request-sort-source]')).some(function (select) { return select.value === 'random:RAND()'; }); + if (randomSelected) { Adminx.Toast.show('Случайный порядок используется без других уровней', 'warning'); return; } + + var template = document.querySelector('[data-request-sort-template]'); + if (!template) { return; } + var row = template.content.firstElementChild.cloneNode(true); + var select = row.querySelector('[data-request-sort-source]'); + var used = {}; + this.sortList.querySelectorAll('[data-request-sort-source]').forEach(function (current) { used[current.value] = true; }); + var target = preferred || ''; + if (!target || used[target]) { + var option = Array.from(select.options).find(function (candidate) { return candidate.value !== 'random:RAND()' && !used[candidate.value]; }); + target = option ? option.value : ''; + } + if (!target) { Adminx.Toast.show('Все доступные поля уже добавлены', 'warning'); return; } + select.value = target; + select.setAttribute('data-sort-source-value', target); + if (preferred === 'document:Id') { + row.querySelectorAll('[data-request-sort-direction]').forEach(function (button) { + var active = button.getAttribute('data-request-sort-direction') === 'ASC'; + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + } + this.sortList.appendChild(row); + this.syncSortRules(); + }, + + sortSourceChanged: function (select) { + var previous = select.getAttribute('data-sort-source-value') || ''; + var values = Array.from(this.sortList.querySelectorAll('[data-request-sort-source]')).filter(function (current) { return current !== select; }).map(function (current) { return current.value; }); + if (values.indexOf(select.value) !== -1) { + select.value = previous; + Adminx.Toast.show('Это поле уже участвует в сортировке', 'warning'); + return; + } + if ((select.value === 'random:RAND()' && values.length) || (select.value !== 'random:RAND()' && values.indexOf('random:RAND()') !== -1)) { + select.value = previous; + Adminx.Toast.show('Случайный порядок нельзя сочетать с другими уровнями', 'warning'); + return; + } + select.setAttribute('data-sort-source-value', select.value); + var row = select.closest('[data-request-sort-rule]'); + if (row) { row.classList.toggle('is-random', select.value === 'random:RAND()'); } + this.syncSortRules(); + if (row && select.value === 'random:RAND()') { + window.requestAnimationFrame(function () { + var seed = row.querySelector('[data-request-sort-seed-input]'); + if (seed) { seed.focus(); } + }); + } + }, + + sortDirection: function (button) { + var row = button.closest('[data-request-sort-rule]'); + if (!row || row.classList.contains('is-random')) { return; } + row.querySelectorAll('[data-request-sort-direction]').forEach(function (item) { + var active = item === button; + item.classList.toggle('is-active', active); + item.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + this.syncSortRules(); + }, + + sortRemove: function (button) { + var row = button.closest('[data-request-sort-rule]'); + if (row) { row.remove(); this.syncSortRules(); } + }, + + sortStable: function () { + if (!this.sortList) { return; } + var hasId = Array.from(this.sortList.querySelectorAll('[data-request-sort-source]')).some(function (select) { return select.value === 'document:Id'; }); + if (hasId) { Adminx.Toast.show('Финальный порядок по ID уже добавлен', 'info'); return; } + this.sortAdd('document:Id'); + }, + + syncSortRules: function () { + if (!this.sortList) { return; } + var rules = []; + this.sortList.querySelectorAll('[data-request-sort-rule]').forEach(function (row, index) { + var position = row.querySelector('[data-request-sort-position]'); + if (position) { position.textContent = String(index + 1); } + var select = row.querySelector('[data-request-sort-source]'); + if (!select) { return; } + var separator = select.value.indexOf(':'); + var source = separator >= 0 ? select.value.slice(0, separator) : ''; + var key = separator >= 0 ? select.value.slice(separator + 1) : ''; + var activeDirection = row.querySelector('[data-request-sort-direction].is-active'); + var rule = {source: source, key: source === 'field' ? parseInt(key, 10) : key, direction: source === 'random' ? 'ASC' : (activeDirection ? activeDirection.getAttribute('data-request-sort-direction') : 'ASC')}; + if (source === 'random') { + var seedInput = row.querySelector('[data-request-sort-seed-input]'); + var seed = seedInput ? seedInput.value.trim() : ''; + if (seed !== '') { rule.seed = seed; } + } + rules.push(rule); + }); + var input = document.querySelector('[data-request-sort-rules]'); + if (input) { input.value = JSON.stringify(rules); } + var empty = document.querySelector('[data-request-sort-empty]'); + if (empty) { empty.hidden = rules.length > 0; } + var stable = document.querySelector('[data-request-sort-stable]'); + if (stable) { + stable.disabled = rules.some(function (rule) { return rule.source === 'random' || (rule.source === 'document' && rule.key === 'Id'); }); + } + }, + + sortDragStart: function (event) { + var handle = event.target.closest('[data-request-sort-handle]'); + if (!handle) { event.preventDefault(); return; } + this.sortDragRow = handle.closest('[data-request-sort-rule]'); + if (!this.sortDragRow) { return; } + this.sortDragRow.classList.add('is-dragging'); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', 'request-sort'); + }, + + sortDragOver: function (event) { + if (!this.sortDragRow || !this.sortList) { return; } + event.preventDefault(); + var target = event.target.closest('[data-request-sort-rule]'); + if (!target || target === this.sortDragRow) { return; } + var rect = target.getBoundingClientRect(); + this.sortList.insertBefore(this.sortDragRow, event.clientY < rect.top + rect.height / 2 ? target : target.nextSibling); + }, + + sortDrop: function (event) { + if (!this.sortDragRow) { return; } + event.preventDefault(); + this.sortDragEnd(); + this.syncSortRules(); + }, + + sortDragEnd: function () { + if (this.sortDragRow) { this.sortDragRow.classList.remove('is-dragging'); } + this.sortDragRow = null; + }, + + syncEditorAdvancedOptions: function () { + var basic = this.editorMode !== 'advanced'; + document.querySelectorAll('[data-request-advanced-option]').forEach(function (option) { + option.hidden = basic && !option.selected; + }); + }, + + onClick: function (e) { + var self = this; + var editorMode = e.target.closest('[data-request-editor-mode-value]'); + if (editorMode) { return this.setEditorMode(editorMode.getAttribute('data-request-editor-mode-value')); } + var presentation = e.target.closest('[data-request-presentation-value]'); + if (presentation) { return this.selectPresentation(presentation.getAttribute('data-request-presentation-value')); } + if (e.target.closest('[data-request-open-code]')) { return this.openCodeEditor(); } + if (e.target.closest('[data-request-sort-add]')) { return this.sortAdd(); } + if (e.target.closest('[data-request-sort-stable]')) { return this.sortStable(); } + var sortDirection = e.target.closest('[data-request-sort-direction]'); + if (sortDirection) { return this.sortDirection(sortDirection); } + var sortRemove = e.target.closest('[data-request-sort-remove]'); + if (sortRemove) { return this.sortRemove(sortRemove); } + // список + var copy = e.target.closest('[data-request-copy]'); + if (copy) { return this.copy(copy.closest('tr')); } + var del = e.target.closest('[data-request-delete]'); + if (del) { return this.remove(del.closest('tr')); } + + // массовая сверка Legacy и Native + var auditOne = e.target.closest('[data-native-audit-one]'); + if (auditOne) { return this.auditOne(auditOne.closest('[data-native-audit-row]'), auditOne); } + if (e.target.closest('[data-native-audit-all]')) { return this.auditAll(e.target.closest('[data-native-audit-all]')); } + if (e.target.closest('[data-native-audit-stabilize]')) { return this.auditStabilize(); } + if (e.target.closest('[data-native-audit-activate]')) { return this.auditActivate(); } + if (e.target.closest('[data-native-audit-rollback]')) { return this.auditRollback(); } + + if (e.target.closest('[data-requests-reset]')) { + var input = document.querySelector('[data-requests-search]'); + if (input) { input.value = ''; } + return this.filterList(''); + } + + // редактор: тулбар + var tags = e.target.closest('[data-req-tags-toggle]'); + if (tags) { return this.openPalette(tags.closest('[data-req-code-field]')); } + if (e.target.closest('[data-req-tags-close]')) { return this.closePalette(); } + var lint = e.target.closest('[data-req-lint]'); + if (lint) { return this.lint(lint.closest('[data-req-code-field]')); } + var full = e.target.closest('[data-req-fullscreen]'); + if (full) { return this.fullscreen(full.closest('[data-req-code-field]')); } + var preview = e.target.closest('[data-request-preview]'); + if (preview) { return this.preview(preview); } + var explainOption = e.target.closest('[data-request-explain-option]'); + if (explainOption) { return this.selectExplainDocument(explainOption); } + var explainRun = e.target.closest('[data-request-explain-run]'); + if (explainRun) { return this.explainSelected(explainRun); } + var previewExplain = e.target.closest('[data-preview-explain]'); + if (previewExplain) { return this.explain(parseInt(previewExplain.getAttribute('data-preview-explain') || '0', 10)); } + + // палитра + var tab = e.target.closest('[data-req-tag-tab]'); + if (tab) { return this.activateGroup(tab.getAttribute('data-req-tag-tab')); } + var tag = e.target.closest('[data-req-tag]'); + if (tag) { return this.insertTag(tag); } + + // условия + var condAdd = e.target.closest('[data-cond-add]'); + if (condAdd) { return this.condAdd(condAdd.closest('[data-cond-group]')); } + var booleanChoice = e.target.closest('[data-condition-boolean]'); + if (booleanChoice) { return this.conditionBooleanValue(booleanChoice); } + var documentPick = e.target.closest('[data-condition-document-pick]'); + if (documentPick) { return this.conditionDocumentPicker(documentPick.closest('tr')); } + var save = e.target.closest('[data-cond-save]'); + if (save) { return this.condSave(save.closest('tr')); } + var cdel = e.target.closest('[data-cond-delete]'); + if (cdel) { return this.condDelete(cdel.closest('tr')); } + var groupOperator = e.target.closest('[data-group-operator]'); + if (groupOperator) { return this.groupOperator(groupOperator); } + var groupAdd = e.target.closest('[data-group-add]'); + if (groupAdd) { return this.groupAdd(groupAdd.closest('[data-cond-group]')); } + var groupDelete = e.target.closest('[data-group-delete]'); + if (groupDelete) { return this.groupDelete(groupDelete.closest('[data-cond-group]')); } + }, + + // ------------------------------------------------------------------ // + // Список + // ------------------------------------------------------------------ // + auditBase: function () { + return this.auditPage ? this.auditPage.getAttribute('data-base') : Adminx.base(); + }, + + auditRequest: function (url) { + return Adminx.Ajax.post(url, new FormData()).then(function (payload) { + var data = payload.data || {}; + if (!payload.ok || !data.success) { + throw new Error(data.message || 'Не удалось выполнить проверку'); + } + + return data; + }); + }, + + auditSetBusy: function (busy) { + this.auditRunning = busy; + if (!this.auditPage) { return; } + this.auditPage.querySelectorAll('[data-native-audit-one]').forEach(function (button) { + button.disabled = busy; + }); + document.querySelectorAll('[data-native-audit-all], [data-native-audit-stabilize], [data-native-audit-activate], [data-native-audit-rollback]').forEach(function (button) { + var unavailable = button.hasAttribute('data-native-audit-activate') + ? button.getAttribute('data-audit-enabled') !== '1' + : (button.hasAttribute('data-native-audit-stabilize') && button.getAttribute('data-audit-enabled') !== '1'); + button.disabled = busy || unavailable; + }); + }, + + auditProgress: function (done, total, title) { + var progress = this.auditPage && this.auditPage.querySelector('[data-native-audit-progress]'); + if (!progress) { return; } + progress.hidden = false; + var heading = progress.querySelector('[data-native-audit-progress-title]'); + var copy = progress.querySelector('[data-native-audit-progress-copy]'); + var bar = progress.querySelector('[data-native-audit-progress-bar]'); + if (heading) { heading.textContent = title || 'Проверка запросов'; } + if (copy) { copy.textContent = done + ' из ' + total; } + if (bar) { bar.style.width = (total ? Math.round(done / total * 100) : 0) + '%'; } + }, + + auditState: function (row, comparison) { + if (!row) { return; } + var result = row.querySelector('[data-audit-result]'); + var status = comparison && comparison.status ? comparison.status : 'error'; + var labels = { + matched: ['is-success', 'ti-circle-check', 'Совпадает'], + unstable: ['is-warning', 'ti-arrows-sort', 'Порядок не закреплён'], + unsupported: ['is-warning', 'ti-code', 'Остаётся Legacy'], + order_diff: ['is-warning', 'ti-arrows-sort', 'Отличается порядок'], + mismatch: ['is-danger', 'ti-alert-triangle', 'Результаты различаются'], + incomplete: ['is-warning', 'ti-hourglass', 'Нужна ручная проверка'], + error: ['is-danger', 'ti-alert-triangle', 'Ошибка проверки'] + }; + var label = labels[status] || labels.error; + var detail = ''; + if (comparison) { + if (comparison.reasons && comparison.reasons.length) { + detail = comparison.reasons.join(' '); + } else if (comparison.legacy && comparison.native) { + detail = 'Legacy: ' + comparison.legacy.total + ' · Native: ' + comparison.native.total; + if (comparison.runtime_verification && comparison.runtime_verification.length > 1) { + detail += ' · проверено состояний: ' + comparison.runtime_verification.length; + } + if (comparison.missing_ids && comparison.missing_ids.length) { detail += ' · нет ID: ' + comparison.missing_ids.slice(0, 8).join(', '); } + if (comparison.extra_ids && comparison.extra_ids.length) { detail += ' · лишние ID: ' + comparison.extra_ids.slice(0, 8).join(', '); } + } + var orderDetail = this.auditOrderDetail(comparison.order_diagnostics || {}); + if (orderDetail) { detail += (detail ? ' ' : '') + orderDetail; } + } + result.replaceChildren(); + var strong = document.createElement('b'); + strong.className = label[0]; + var icon = document.createElement('i'); + icon.className = 'ti ' + label[1]; + strong.appendChild(icon); + strong.appendChild(document.createTextNode(label[2])); + result.appendChild(strong); + if (detail) { + var small = document.createElement('small'); + small.textContent = detail; + small.setAttribute('data-tooltip', detail); + result.appendChild(small); + } + row.setAttribute('data-audit-verified', status === 'matched' ? '1' : '0'); + row.setAttribute('data-audit-status', status); + }, + + auditOrderDetail: function (diagnostics) { + if (!diagnostics || !diagnostics.position) { return ''; } + var detail = 'Позиция ' + diagnostics.position + ': Legacy #' + diagnostics.legacy_id + ', Native #' + diagnostics.native_id + '.'; + var criterion = diagnostics.criteria && diagnostics.criteria.length ? diagnostics.criteria[0] : null; + if (criterion) { + detail += ' ' + criterion.label + ': ' + criterion.legacy_value + ' / ' + criterion.native_value + '.'; + } + return detail; + }, + + auditOne: function (row, button, quiet) { + if (!row || this.auditRunning && !quiet) { return Promise.resolve(false); } + var self = this; + var id = row.getAttribute('data-native-audit-row'); + var icon = button && button.querySelector('i'); + if (button) { button.disabled = true; } + if (icon) { icon.classList.add('is-spinning'); } + return this.auditRequest(this.auditBase() + '/requests/' + encodeURIComponent(id) + '/native-audit').then(function (data) { + self.auditState(row, data.data && data.data.comparison ? data.data.comparison : {}); + self.auditRefreshStats(); + if (!quiet) { Adminx.Toast.show(data.message || 'Запрос проверен', 'success'); } + return true; + }).catch(function (error) { + self.auditState(row, { status: 'error', reasons: [error.message] }); + if (!quiet) { Adminx.Toast.show(error.message, 'error'); } + return false; + }).then(function (result) { + if (button) { button.disabled = false; } + if (icon) { icon.classList.remove('is-spinning'); } + return result; + }); + }, + + auditAll: function () { + if (!this.auditPage || this.auditRunning) { return; } + var self = this; + var rows = Array.prototype.slice.call(this.auditPage.querySelectorAll('[data-native-audit-row]')); + this.auditSetBusy(true); + this.auditProgress(0, rows.length, 'Подготовка Shadow'); + this.auditRequest(this.auditBase() + '/requests/native-audit/prepare').then(function () { + rows.forEach(function (row) { + var mode = row.querySelector('[data-audit-mode]'); + if (mode) { mode.className = 'badge badge-blue'; mode.textContent = 'Shadow'; } + }); + var done = 0; + var matched = 0; + var run = function () { + if (done >= rows.length) { + self.auditSetBusy(false); + self.auditRefreshStats(); + self.auditProgress(done, rows.length, 'Проверка завершена'); + Adminx.Toast.show('Проверка завершена: совпало ' + matched + ' из ' + rows.length, matched ? 'success' : 'warning'); + return; + } + self.auditProgress(done, rows.length, 'Проверяется запрос #' + rows[done].getAttribute('data-native-audit-row')); + var button = rows[done].querySelector('[data-native-audit-one]'); + self.auditOne(rows[done], button, true).then(function (ok) { + if (ok && rows[done].getAttribute('data-audit-verified') === '1') { matched++; } + done++; + self.auditProgress(done, rows.length, 'Проверка запросов'); + window.setTimeout(run, 30); + }); + }; + run(); + }).catch(function (error) { + self.auditSetBusy(false); + self.auditProgress(0, rows.length, 'Проверка остановлена'); + Adminx.Toast.show(error.message, 'error'); + }); + }, + + auditRefreshStats: function () { + if (!this.auditPage) { return; } + var rows = this.auditPage.querySelectorAll('[data-native-audit-row]'); + var verified = this.auditPage.querySelectorAll('[data-audit-verified="1"]').length; + var nativeCount = 0; + rows.forEach(function (row) { + var mode = row.querySelector('[data-audit-mode]'); + if (mode && mode.textContent.trim().toLowerCase() === 'native') { nativeCount++; } + }); + var verifiedStat = document.querySelector('[data-audit-stat="verified"]'); + var nativeStat = document.querySelector('[data-audit-stat="native"]'); + if (verifiedStat) { verifiedStat.textContent = String(verified); } + if (nativeStat) { nativeStat.textContent = String(nativeCount); } + var activate = document.querySelector('[data-native-audit-activate]'); + if (activate) { + activate.setAttribute('data-audit-enabled', verified ? '1' : '0'); + activate.disabled = this.auditRunning || !verified; + } + var unstable = this.auditPage.querySelectorAll('[data-audit-status="unstable"], [data-audit-status="order_diff"]').length; + var stabilize = document.querySelector('[data-native-audit-stabilize]'); + if (stabilize) { + stabilize.setAttribute('data-audit-enabled', unstable ? '1' : '0'); + stabilize.disabled = this.auditRunning || !unstable; + } + }, + + auditStabilize: function () { + if (!this.auditPage || this.auditRunning) { return; } + var self = this; + var rows = Array.prototype.slice.call(this.auditPage.querySelectorAll('[data-audit-status="unstable"], [data-audit-status="order_diff"]')); + if (!rows.length) { + Adminx.Toast.show('Запросов с незакреплённым порядком нет', 'info'); + return; + } + + Adminx.Confirm.open({ + kind: 'warning', + title: 'Закрепить порядок без изменения выдачи?', + message: 'Для каждого запроса полная Legacy-последовательность будет сверена с ID по возрастанию и убыванию. Native включится только при точном совпадении; любое расхождение автоматически отменит изменение.', + confirmLabel: 'Проверить и закрепить', + onConfirm: function () { + self.auditSetBusy(true); + var done = 0; + var applied = 0; + var skipped = 0; + var run = function () { + if (done >= rows.length) { + self.auditSetBusy(false); + self.auditRefreshStats(); + self.auditProgress(done, rows.length, 'Порядок проверен'); + var message = 'Порядок закреплён: ' + applied; + if (skipped) { message += ' · без изменений: ' + skipped; } + Adminx.Toast.show(message, applied ? 'success' : 'warning'); + window.setTimeout(function () { window.location.reload(); }, 650); + return; + } + + var row = rows[done]; + var id = row.getAttribute('data-native-audit-row'); + var originalStatus = row.getAttribute('data-audit-status') || 'unstable'; + self.auditProgress(done, rows.length, 'Проверяется порядок запроса #' + id); + self.auditRequest(self.auditBase() + '/requests/' + encodeURIComponent(id) + '/native-stabilize').then(function (data) { + var result = data.data || {}; + if (result.status === 'applied' && result.comparison) { + applied++; + self.auditState(row, result.comparison); + var mode = row.querySelector('[data-audit-mode]'); + if (mode) { mode.className = 'badge badge-green'; mode.textContent = 'Native'; } + var plan = row.querySelector('[data-audit-plan]'); + if (plan) { plan.className = 'badge badge-blue'; plan.textContent = 'Готов'; } + } else { + skipped++; + var reason = result.message || 'Безопасный порядок не найден'; + self.auditState(row, { status: originalStatus, reasons: [reason] }); + } + }).catch(function (error) { + skipped++; + self.auditState(row, { status: 'error', reasons: [error.message] }); + }).then(function () { + done++; + self.auditRefreshStats(); + self.auditProgress(done, rows.length, 'Закрепление порядка'); + window.setTimeout(run, 30); + }); + }; + + self.auditProgress(0, rows.length, 'Подготовка безопасной проверки'); + run(); + } + }); + }, + + auditActivate: function () { + if (this.auditRunning) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Включить Native executor?', + message: 'Новый путь включится только у запросов с точным совпадением текущего плана. Остальные продолжат работать через Legacy.', + confirmLabel: 'Включить подтверждённые', + onConfirm: function () { + self.auditSetBusy(true); + self.auditRequest(self.auditBase() + '/requests/native-audit/activate').then(function (data) { + Adminx.Toast.show((data.message || 'Native включён') + ': ' + ((data.data && data.data.count) || 0), 'success'); + window.location.reload(); + }).catch(function (error) { + self.auditSetBusy(false); + Adminx.Toast.show(error.message, 'error'); + }); + } + }); + }, + + auditRollback: function () { + if (this.auditRunning) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Вернуть все запросы в Legacy?', + message: 'Публичный сайт сразу вернётся к прежнему executor. Результаты проверки сохранятся.', + confirmLabel: 'Вернуть Legacy', + onConfirm: function () { + self.auditSetBusy(true); + self.auditRequest(self.auditBase() + '/requests/native-audit/rollback').then(function (data) { + Adminx.Toast.show(data.message || 'Legacy восстановлен', 'success'); + window.location.reload(); + }).catch(function (error) { + self.auditSetBusy(false); + Adminx.Toast.show(error.message, 'error'); + }); + } + }); + }, + + filterList: function (query) { + var root = document.querySelector('[data-requests-page]'); + if (!root) { return; } + var q = String(query || '').trim().toLowerCase(); + var rows = root.querySelectorAll('[data-requests-list] tr[data-request-row]'); + var visible = 0; + rows.forEach(function (row) { + var show = !q || (row.getAttribute('data-search') || '').indexOf(q) !== -1; + row.hidden = !show; + if (show) { visible++; } + }); + var count = root.querySelector('[data-requests-count]'); + if (count) { count.textContent = String(visible); } + var summary = root.querySelector('[data-requests-summary]'); + if (summary) { summary.textContent = q ? (visible + ' из ' + rows.length + ' по фильтру.') : (rows.length + ' запросов в списке.'); } + var empty = root.querySelector('[data-requests-empty]'); + if (empty) { empty.hidden = !(q && visible === 0); } + var reset = root.querySelector('[data-requests-reset]'); + if (reset) { reset.hidden = !q; } + }, + + copy: function (row) { + var base = Adminx.base(); + Adminx.Loader.show(); + Adminx.Ajax.post(base + '/requests/' + row.dataset.id + '/copy').then(function (p) { + Adminx.Loader.hide(); + var d = p.data || {}; + if (d.success && d.redirect) { window.location.href = d.redirect; } + else { Adminx.Toast.show(d.message || 'Не удалось', 'error'); } + }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); }); + }, + + remove: function (row) { + var base = Adminx.base(); + Adminx.Confirm.open({ + kind: 'error', title: 'Удалить запрос?', + message: 'Запрос «' + row.dataset.alias + '» и его условия будут удалены.', + confirmLabel: 'Удалить', confirmClass: 'btn-danger', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(base + '/requests/' + row.dataset.id + '/delete').then(function (p) { + Adminx.Loader.hide(); + var d = p.data || {}; + if (d.success) { row.remove(); Adminx.Toast.show(d.message, 'success'); } + else { Adminx.Toast.show(d.message || 'Ошибка', 'error'); } + }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); }); + } + }); + }, + + // ------------------------------------------------------------------ // + // Сохранение запроса + // ------------------------------------------------------------------ // + save: function () { + var self = this; + this.form.querySelectorAll('[data-error]').forEach(function (s) { s.textContent = ''; }); + var id = this.form.getAttribute('data-id') || '0'; + var url = this.base() + '/requests' + (id !== '0' ? '/' + id : ''); + Adminx.Loader.show(); + Adminx.Ajax.post(url, new FormData(this.form)).then(function (p) { + Adminx.Loader.hide(); + var d = p.data || {}; + if (d.success) { + if (d.redirect) { window.location.href = d.redirect; return; } + Adminx.Toast.show(d.message || 'Сохранено', 'success'); + return; + } + var errors = d.errors || {}; + Object.keys(errors).forEach(function (f) { + var span = self.form.querySelector('[data-error="' + f + '"]'); + if (span) { span.textContent = errors[f]; } + var inp = self.form.querySelector('[name="' + f + '"]'); + if (inp) { inp.classList.add('is-invalid'); } + }); + Adminx.Toast.show(d.message || 'Проверьте поля', 'error'); + }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); }); + }, + + preview: function (button) { + if (!this.form || !button || button.disabled) { return; } + var id = parseInt(this.form.getAttribute('data-id') || '0', 10); + if (id <= 0) { return; } + + var fd = new FormData(); + this.form.querySelectorAll('[name="result_system[]"]:checked').forEach(function (input) { + fd.append('result_system[]', input.value); + }); + this.form.querySelectorAll('[name="result_fields[]"]:checked').forEach(function (input) { + fd.append('result_fields[]', input.value); + }); + var limit = document.querySelector('[data-request-preview-limit]'); + fd.append('limit', limit ? limit.value : '10'); + var renderer = document.querySelector('[data-request-preview-renderer]'); + fd.append('renderer', renderer ? renderer.value : 'data_cards'); + + var self = this; + button.disabled = true; + button.classList.add('is-loading'); + Adminx.Ajax.post(this.base() + '/requests/' + id + '/preview', fd).then(function (payload) { + var response = payload.data || {}; + if (!response.success) { + Adminx.Toast.show(response.message || 'Предпросмотр не выполнен', 'error'); + return; + } + + self.renderPreview(response.data || {}); + Adminx.Toast.show(response.message || 'Предпросмотр обновлён', 'success'); + }).catch(function () { + Adminx.Toast.show('Ошибка сети при построении предпросмотра', 'error'); + }).then(function () { + button.disabled = false; + button.classList.remove('is-loading'); + }); + }, + + renderPreview: function (data) { + var section = document.querySelector('[data-request-preview-section]'); + if (!section) { return; } + var empty = section.querySelector('[data-request-preview-empty]'); + var result = section.querySelector('[data-request-preview-result]'); + if (empty) { empty.hidden = true; } + if (result) { result.hidden = false; } + + this.setText(section, '[data-preview-total]', String(data.total || 0)); + this.setText(section, '[data-preview-returned]', String((data.items || []).length)); + this.setText(section, '[data-preview-time]', this.previewMilliseconds(data.query_time)); + this.renderPreviewPlan(section.querySelector('[data-request-preview-plan]'), data.plan || {}); + this.renderExecutorComparison(section.querySelector('[data-request-executor-comparison]'), data.executor_comparison || {}); + this.renderPreviewItems(section.querySelector('[data-request-preview-items]'), data); + + var sql = section.querySelector('[data-request-preview-sql]'); + if (sql) { sql.textContent = data.sql || 'SQL доступен только пользователю с правом управления запросами.'; } + }, + + renderExecutorComparison: function (root, comparison) { + if (!root) { return; } + root.textContent = ''; + var status = comparison.status || ''; + if (!status) { root.hidden = true; return; } + root.hidden = false; + var states = { + matched: ['is-success', 'ti-circle-check', 'Legacy и Native совпали', 'Количество, состав и порядок документов одинаковы.'], + unstable: ['is-warning', 'ti-arrows-sort', 'Состав совпал, порядок не закреплён', 'Публичный вывод остаётся на Legacy, пока одинаковые значения сортировки не получат безопасный финальный порядок.'], + order_diff: ['is-warning', 'ti-arrows-sort', 'Состав совпал, порядок отличается', 'До переключения проверьте сортировку запроса.'], + mismatch: ['is-danger', 'ti-alert-triangle', 'Результаты различаются', 'Native включать рано: сравните отсутствующие и лишние ID.'], + incomplete: ['is-warning', 'ti-database-exclamation', 'Выборка слишком велика для автоподтверждения', 'Проверены первые 5000 документов. Оставьте Legacy или разбейте представление на более узкие запросы.'], + unsupported: ['is-warning', 'ti-code', 'Запрос пока требует Legacy', 'В плане есть PHP, свободный SQL или неподдерживаемая операция.'], + error: ['is-danger', 'ti-alert-circle', 'Сравнение не выполнено', 'Legacy продолжает работать; ошибка записана в системный журнал.'] + }; + var state = states[status] || states.error; + var nativeOption = document.querySelector('[data-native-executor-option]'); + var nativeState = document.querySelector('[data-native-plan-state]'); + if (nativeOption && !nativeOption.selected) { nativeOption.disabled = status !== 'matched'; } + if (nativeState) { + nativeState.className = 'badge ' + (status === 'matched' ? 'badge-green' : (status === 'unsupported' ? 'badge-amber' : 'badge-blue')); + nativeState.textContent = status === 'matched' + ? 'Native подтверждён' + : (status === 'unsupported' ? 'Требуется Legacy' : (status === 'unstable' ? 'Нужен стабильный порядок' : 'Нужно проверить')); + } + root.className = 'request-executor-comparison ' + state[0]; + var icon = document.createElement('span'); + icon.className = 'icon-tile'; + icon.innerHTML = ''; + var copy = document.createElement('div'); + var title = document.createElement('b'); + title.textContent = state[2]; + var description = document.createElement('span'); + description.textContent = state[3]; + copy.appendChild(title); + copy.appendChild(description); + root.appendChild(icon); + root.appendChild(copy); + + var details = []; + if (comparison.legacy && comparison.native) { + details.push('Legacy: ' + comparison.legacy.total); + details.push('Native: ' + comparison.native.total); + } + if (comparison.runtime_verification && comparison.runtime_verification.length > 1) { + details.push('Runtime-состояний: ' + comparison.runtime_verification.length); + } + if ((comparison.missing_ids || []).length) { details.push('Нет в Native: #' + comparison.missing_ids.join(', #')); } + if ((comparison.extra_ids || []).length) { details.push('Лишние в Native: #' + comparison.extra_ids.join(', #')); } + (comparison.reasons || []).forEach(function (reason) { details.push(reason); }); + if (details.length) { + var meta = document.createElement('small'); + meta.textContent = details.join(' · '); + root.appendChild(meta); + } + }, + + setText: function (root, selector, value) { + var node = root && root.querySelector(selector); + if (node) { node.textContent = value; } + }, + + previewMilliseconds: function (seconds) { + var value = Number(seconds || 0) * 1000; + if (!isFinite(value)) { value = 0; } + return (value < 10 ? value.toFixed(2) : value.toFixed(0)) + ' мс'; + }, + + renderPreviewPlan: function (root, plan) { + if (!root) { return; } + root.textContent = ''; + var items = [ + ['ti-category', 'Рубрика #' + (plan.rubric_id || 0)], + ['ti-brackets', (plan.groups || 0) + ' групп'], + ['ti-filter-check', (plan.conditions || 0) + ' активных условий'], + ['ti-sort-descending', plan.order || 'Без явной сортировки'], + ['ti-calendar-check', plan.publication || 'Проверка публикации'], + ]; + items.forEach(function (item) { + var chip = document.createElement('span'); + var icon = document.createElement('i'); + icon.className = 'ti ' + item[0]; + chip.appendChild(icon); + chip.appendChild(document.createTextNode(item[1])); + root.appendChild(chip); + }); + }, + + renderPreviewItems: function (root, data) { + if (!root) { return; } + root.textContent = ''; + var items = Array.isArray(data.items) ? data.items : []; + var renderer = data.renderer || {}; + if (renderer.presentation === 'json') { + var json = document.createElement('pre'); + json.className = 'request-preview-json'; + json.textContent = JSON.stringify({ + total: Number(data.total || 0), + limit: Number(data.limit || 0), + items: items + }, null, 2); + root.appendChild(json); + return; + } + if (!items.length) { + var empty = document.createElement('div'); + empty.className = 'request-preview-no-items'; + empty.innerHTML = 'Документы не найденыПроверьте условия, статус публикации и выбранную рубрику.'; + root.appendChild(empty); + return; + } + + var systemOptions = data.system_options || {}; + var fieldOptions = data.field_options || {}; + if (renderer.presentation === 'list') { + this.renderPreviewList(root, items, systemOptions, fieldOptions); + return; + } + if (renderer.presentation === 'table') { + this.renderPreviewTable(root, items, systemOptions, fieldOptions); + return; + } + items.forEach(function (item, index) { + var card = document.createElement('article'); + card.className = 'request-preview-item'; + card.style.setProperty('--preview-order', String(Math.min(index, 8))); + var head = document.createElement('header'); + var title = document.createElement('div'); + var titleText = item.system && item.system.title ? String(item.system.title) : 'Документ #' + item.document_id; + title.innerHTML = ''; + var copy = document.createElement('span'); + var strong = document.createElement('b'); + strong.textContent = titleText; + var small = document.createElement('small'); + small.textContent = '#' + item.document_id; + copy.appendChild(strong); + copy.appendChild(small); + title.appendChild(copy); + head.appendChild(title); + var explain = document.createElement('button'); + explain.className = 'btn btn-ghost btn-icon btn-sm ax-act ax-act-view'; + explain.type = 'button'; + explain.setAttribute('data-preview-explain', String(item.document_id)); + explain.setAttribute('data-tooltip', 'Объяснить результат'); + explain.setAttribute('aria-label', 'Объяснить результат'); + explain.innerHTML = ''; + head.appendChild(explain); + card.appendChild(head); + + var values = document.createElement('dl'); + values.className = 'request-preview-values'; + Object.keys(item.system || {}).forEach(function (key) { + if (key === 'title') { return; } + Adminx.Requests.appendPreviewValue(values, systemOptions[key] || key, item.system[key], key); + }); + Object.keys(item.fields || {}).forEach(function (key) { + var field = item.fields[key] || {}; + var option = fieldOptions[key] || {}; + var label = option.title || field.alias || ('Поле #' + key); + Adminx.Requests.appendPreviewValue(values, label, field.value, field.alias || key); + }); + card.appendChild(values); + root.appendChild(card); + }); + }, + + renderPreviewList: function (root, items, systemOptions, fieldOptions) { + items.forEach(function (item, index) { + var row = document.createElement('article'); + row.className = 'request-preview-list-item'; + row.style.setProperty('--preview-order', String(Math.min(index, 8))); + + var main = document.createElement('div'); + main.className = 'request-preview-list-main'; + main.innerHTML = ''; + var copy = document.createElement('span'); + var title = document.createElement('b'); + title.textContent = Adminx.Requests.previewTitle(item); + var id = document.createElement('small'); + id.textContent = '#' + item.document_id; + copy.appendChild(title); + copy.appendChild(id); + main.appendChild(copy); + row.appendChild(main); + + var values = document.createElement('div'); + values.className = 'request-preview-list-values'; + Adminx.Requests.previewEntries(item, systemOptions, fieldOptions).slice(0, 3).forEach(function (entry) { + var value = document.createElement('span'); + var label = document.createElement('small'); + label.textContent = entry.label; + var text = document.createElement('b'); + text.textContent = Adminx.Requests.previewValue(entry.value); + value.appendChild(label); + value.appendChild(text); + values.appendChild(value); + }); + row.appendChild(values); + row.appendChild(Adminx.Requests.previewExplainButton(item.document_id)); + root.appendChild(row); + }); + }, + + renderPreviewTable: function (root, items, systemOptions, fieldOptions) { + var columns = []; + var known = {}; + items.forEach(function (item) { + Adminx.Requests.previewEntries(item, systemOptions, fieldOptions).forEach(function (entry) { + if (!known[entry.key]) { known[entry.key] = true; columns.push({key: entry.key, label: entry.label}); } + }); + }); + + var wrap = document.createElement('div'); + wrap.className = 'request-preview-table-wrap'; + var table = document.createElement('table'); + table.className = 'table request-preview-table'; + var head = document.createElement('thead'); + var headRow = document.createElement('tr'); + var titleHead = document.createElement('th'); + titleHead.textContent = 'Материал'; + headRow.appendChild(titleHead); + columns.forEach(function (column) { + var th = document.createElement('th'); + th.textContent = column.label; + headRow.appendChild(th); + }); + var actionHead = document.createElement('th'); + actionHead.className = 'request-preview-table-action'; + headRow.appendChild(actionHead); + head.appendChild(headRow); + table.appendChild(head); + + var body = document.createElement('tbody'); + items.forEach(function (item) { + var values = {}; + Adminx.Requests.previewEntries(item, systemOptions, fieldOptions).forEach(function (entry) { values[entry.key] = entry.value; }); + var tr = document.createElement('tr'); + var titleCell = document.createElement('td'); + titleCell.className = 'request-preview-table-title'; + var title = document.createElement('b'); + title.textContent = Adminx.Requests.previewTitle(item); + var id = document.createElement('small'); + id.textContent = '#' + item.document_id; + titleCell.appendChild(title); + titleCell.appendChild(id); + tr.appendChild(titleCell); + columns.forEach(function (column) { + var td = document.createElement('td'); + td.textContent = Adminx.Requests.previewValue(Object.prototype.hasOwnProperty.call(values, column.key) ? values[column.key] : null); + tr.appendChild(td); + }); + var action = document.createElement('td'); + action.className = 'request-preview-table-action'; + action.appendChild(Adminx.Requests.previewExplainButton(item.document_id)); + tr.appendChild(action); + body.appendChild(tr); + }); + table.appendChild(body); + wrap.appendChild(table); + root.appendChild(wrap); + }, + + previewTitle: function (item) { + return item.system && item.system.title ? String(item.system.title) : 'Документ #' + item.document_id; + }, + + previewEntries: function (item, systemOptions, fieldOptions) { + var entries = []; + Object.keys(item.system || {}).forEach(function (key) { + if (key !== 'title') { entries.push({key: 'system.' + key, label: systemOptions[key] || key, value: item.system[key]}); } + }); + Object.keys(item.fields || {}).forEach(function (key) { + var field = item.fields[key] || {}; + var option = fieldOptions[key] || {}; + entries.push({key: 'field.' + key, label: option.title || field.alias || ('Поле #' + key), value: field.value}); + }); + return entries; + }, + + previewExplainButton: function (documentId) { + var button = document.createElement('button'); + button.className = 'btn btn-ghost btn-icon btn-sm ax-act ax-act-view'; + button.type = 'button'; + button.setAttribute('data-preview-explain', String(documentId)); + button.setAttribute('data-tooltip', 'Объяснить результат'); + button.setAttribute('aria-label', 'Объяснить результат'); + button.innerHTML = ''; + return button; + }, + + appendPreviewValue: function (root, label, value, code) { + var group = document.createElement('div'); + var term = document.createElement('dt'); + term.textContent = label; + var system = document.createElement('small'); + system.textContent = code; + term.appendChild(system); + var description = document.createElement('dd'); + description.textContent = this.previewValue(value); + group.appendChild(term); + group.appendChild(description); + root.appendChild(group); + }, + + previewValue: function (value) { + if (value === null || typeof value === 'undefined' || value === '') { return '—'; } + if (typeof value === 'boolean') { return value ? 'Да' : 'Нет'; } + if (typeof value === 'object') { + try { return JSON.stringify(value, null, 2); } catch (e) { return '[структурированные данные]'; } + } + return String(value); + }, + + explainSearchInput: function (input) { + input.removeAttribute('data-document-id'); + var button = document.querySelector('[data-request-explain-run]'); + var directId = /^\s*#?\d+\s*$/.test(input.value) ? parseInt(input.value.replace(/\D/g, ''), 10) : 0; + if (directId > 0) { + input.setAttribute('data-document-id', String(directId)); + if (button) { button.disabled = false; } + } else if (button) { + button.disabled = true; + } + + window.clearTimeout(this.explainSearchTimer); + var self = this; + this.explainSearchTimer = window.setTimeout(function () { self.searchExplainDocuments(input); }, 220); + }, + + searchExplainDocuments: function (input) { + if (!this.form || !input) { return; } + var id = parseInt(this.form.getAttribute('data-id') || '0', 10); + if (id <= 0) { return; } + var options = document.querySelector('[data-request-explain-options]'); + if (!options) { return; } + var query = String(input.value || '').trim(); + var url = this.base() + '/requests/' + id + '/explain-documents?q=' + encodeURIComponent(query) + '&limit=12'; + options.hidden = false; + options.innerHTML = '
    Поиск…
    '; + fetch(url, { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }).then(function (response) { + if (!response.ok) { throw new Error('HTTP ' + response.status); } + return response.json(); + }).then(function (payload) { + var response = payload || {}; + var items = response.data && response.data.items ? response.data.items : []; + Adminx.Requests.renderExplainOptions(options, items); + }).catch(function () { + options.innerHTML = '
    Поиск недоступен
    '; + }); + }, + + renderExplainOptions: function (root, items) { + root.textContent = ''; + if (!items.length) { + root.innerHTML = '
    Документы не найдены
    '; + return; + } + + items.forEach(function (item) { + var button = document.createElement('button'); + button.type = 'button'; + button.setAttribute('data-request-explain-option', String(item.id)); + button.setAttribute('data-title', item.title || ('Документ #' + item.id)); + var icon = document.createElement('span'); + icon.className = 'icon-tile'; + icon.innerHTML = ''; + var copy = document.createElement('span'); + var title = document.createElement('b'); + title.textContent = item.title || ('Документ #' + item.id); + var meta = document.createElement('small'); + meta.textContent = '#' + item.id + (item.alias ? ' · ' + item.alias : '') + (item.deleted ? ' · удалён' : (!item.status ? ' · снят с публикации' : '')); + copy.appendChild(title); + copy.appendChild(meta); + button.appendChild(icon); + button.appendChild(copy); + root.appendChild(button); + }); + }, + + selectExplainDocument: function (option) { + var input = document.querySelector('[data-request-explain-search]'); + var root = document.querySelector('[data-request-explain-options]'); + var id = parseInt(option.getAttribute('data-request-explain-option') || '0', 10); + if (!input || id <= 0) { return; } + input.value = '#' + id + ' · ' + (option.getAttribute('data-title') || 'Документ'); + input.setAttribute('data-document-id', String(id)); + if (root) { root.hidden = true; } + var run = document.querySelector('[data-request-explain-run]'); + if (run) { run.disabled = false; run.focus(); } + }, + + explainSelected: function (button) { + var input = document.querySelector('[data-request-explain-search]'); + var id = input ? parseInt(input.getAttribute('data-document-id') || '0', 10) : 0; + if (id > 0) { this.explain(id, button); } + }, + + explain: function (documentId, button) { + if (!this.form || documentId <= 0) { return; } + var requestId = parseInt(this.form.getAttribute('data-id') || '0', 10); + if (requestId <= 0) { return; } + var drawer = document.getElementById('requestExplainDrawer'); + if (!drawer) { return; } + this.resetExplainDrawer(drawer); + if (Adminx.Drawer) { Adminx.Drawer.open('requestExplainDrawer'); } + if (button) { button.disabled = true; } + var fd = new FormData(); + fd.append('document_id', String(documentId)); + Adminx.Ajax.post(this.base() + '/requests/' + requestId + '/explain', fd).then(function (payload) { + var response = payload.data || {}; + if (!response.success) { + Adminx.Requests.renderExplainError(drawer, response.message || 'Проверка не выполнена'); + return; + } + Adminx.Requests.renderExplain(drawer, response.data || {}); + }).catch(function () { + Adminx.Requests.renderExplainError(drawer, 'Ошибка сети при проверке документа'); + }).then(function () { + if (button) { button.disabled = false; } + }); + }, + + resetExplainDrawer: function (drawer) { + var loading = drawer.querySelector('[data-request-explain-loading]'); + var content = drawer.querySelector('[data-request-explain-content]'); + if (loading) { loading.hidden = false; loading.innerHTML = 'Проверяем запрос…'; } + if (content) { content.hidden = true; } + this.setText(drawer, '[data-request-explain-title]', 'Проверка документа'); + this.setText(drawer, '[data-request-explain-subtitle]', 'Условия запроса и фактический итог executor.'); + this.setText(drawer, '[data-request-explain-footer]', 'Выполняется'); + var footer = drawer.querySelector('[data-request-explain-footer]'); + if (footer) { footer.className = 'badge badge-gray'; } + var icon = drawer.querySelector('[data-request-explain-icon]'); + if (icon) { icon.className = 'dialog-icon info'; icon.innerHTML = ''; } + }, + + renderExplainError: function (drawer, message) { + var loading = drawer.querySelector('[data-request-explain-loading]'); + if (loading) { loading.hidden = false; loading.innerHTML = ''; loading.querySelector('span').textContent = message; } + this.setText(drawer, '[data-request-explain-footer]', 'Ошибка'); + var footer = drawer.querySelector('[data-request-explain-footer]'); + if (footer) { footer.className = 'badge badge-red'; } + var icon = drawer.querySelector('[data-request-explain-icon]'); + if (icon) { icon.className = 'dialog-icon danger'; icon.innerHTML = ''; } + }, + + renderExplain: function (drawer, data) { + var loading = drawer.querySelector('[data-request-explain-loading]'); + var content = drawer.querySelector('[data-request-explain-content]'); + if (loading) { loading.hidden = true; } + if (content) { content.hidden = false; } + var documentData = data.document || {}; + var matched = !!data.matched; + this.setText(drawer, '[data-request-explain-title]', documentData.title || ('Документ #' + documentData.id)); + this.setText(drawer, '[data-request-explain-subtitle]', '#' + documentData.id + (documentData.alias ? ' · ' + documentData.alias : '')); + this.setText(drawer, '[data-request-explain-footer]', matched ? 'Входит в запрос' : 'Исключён'); + var footer = drawer.querySelector('[data-request-explain-footer]'); + if (footer) { footer.className = 'badge ' + (matched ? 'badge-green' : 'badge-red'); } + var icon = drawer.querySelector('[data-request-explain-icon]'); + if (icon) { icon.className = 'dialog-icon ' + (matched ? 'success' : 'danger'); icon.innerHTML = ''; } + this.renderExplainChecks(drawer.querySelector('[data-request-explain-base]'), data.base || []); + this.renderExplainTree(drawer.querySelector('[data-request-explain-tree]'), data.groups || []); + this.renderExplainNotes(drawer.querySelector('[data-request-explain-notes]'), data.notes || []); + }, + + renderExplainChecks: function (root, checks) { + if (!root) { return; } + root.textContent = ''; + checks.forEach(function (check) { + var item = document.createElement('article'); + item.className = 'request-explain-check is-' + check.state; + item.innerHTML = '
    '; + item.querySelector('b').textContent = check.label || check.code; + item.querySelector('span').textContent = check.detail || ''; + root.appendChild(item); + }); + }, + + renderExplainTree: function (root, groups) { + if (!root) { return; } + root.textContent = ''; + if (!groups.length) { + root.innerHTML = '
    Активных условий нет. Результат определяется системными ограничениями.
    '; + return; + } + groups.forEach(function (group) { root.appendChild(Adminx.Requests.explainGroupNode(group)); }); + }, + + explainGroupNode: function (group) { + var section = document.createElement('section'); + section.className = 'request-explain-group is-' + (group.state || 'empty'); + var head = document.createElement('header'); + head.innerHTML = '
    '; + head.querySelector('b').textContent = group.title || ('Группа #' + group.id); + head.querySelector('small').textContent = group.operator === 'OR' ? 'Достаточно одного совпадения' : 'Должны совпасть все элементы'; + head.querySelector('.badge').textContent = group.operator === 'OR' ? 'ИЛИ' : 'И'; + head.querySelector('.badge').className = 'badge ' + (group.operator === 'OR' ? 'badge-amber' : 'badge-blue'); + head.querySelector('.ti').className = 'ti ti-' + this.explainStateIcon(group.state); + section.appendChild(head); + var body = document.createElement('div'); + body.className = 'request-explain-group-body'; + (group.conditions || []).forEach(function (condition) { body.appendChild(Adminx.Requests.explainConditionNode(condition)); }); + (group.children || []).forEach(function (child) { body.appendChild(Adminx.Requests.explainGroupNode(child)); }); + if (!body.children.length) { body.innerHTML = '
    В группе нет активных условий
    '; } + section.appendChild(body); + return section; + }, + + explainConditionNode: function (condition) { + var item = document.createElement('article'); + item.className = 'request-explain-condition is-' + (condition.state || 'unknown'); + item.innerHTML = '
    Фактически
    Условие
    '; + item.querySelector('.ti').className = 'ti ti-' + this.explainStateIcon(condition.state); + item.querySelector('b').textContent = condition.field || ('Поле #' + condition.field_id); + item.querySelector('code').textContent = (condition.alias || ('#' + condition.field_id)) + ' ' + condition.operator; + item.querySelector('[data-actual]').textContent = this.previewValue(condition.actual); + item.querySelector('[data-expected]').textContent = this.previewValue(condition.expected); + var note = item.querySelector('p'); + if (condition.note) { note.hidden = false; note.textContent = condition.note; } + return item; + }, + + explainStateIcon: function (state) { + if (state === 'passed') { return 'check'; } + if (state === 'failed') { return 'x'; } + if (state === 'unknown') { return 'help'; } + return 'minus'; + }, + + renderExplainNotes: function (root, notes) { + if (!root) { return; } + root.textContent = ''; + notes.forEach(function (note) { + var item = document.createElement('span'); + item.innerHTML = ''; + item.appendChild(document.createTextNode(note)); + root.appendChild(item); + }); + }, + + // ------------------------------------------------------------------ // + // Редактор кода: fullscreen / lint / палитра тегов + // ------------------------------------------------------------------ // + cmOf: function (field) { + var ta = field && field.querySelector('textarea[data-code-editor]'); + return ta && ta._adminxCodeMirror ? ta._adminxCodeMirror : null; + }, + + codeOf: function (field) { + var cm = this.cmOf(field); + if (cm) { cm.save(); return cm.getValue(); } + var ta = field && field.querySelector('textarea'); + return ta ? ta.value : ''; + }, + + fullscreen: function (field, force) { + if (!field) { return; } + var on = typeof force === 'boolean' ? force : !field.classList.contains('is-fullscreen'); + field.classList.toggle('is-fullscreen', on); + document.body.classList.toggle('req-code-fullscreen-open', on); + var cm = this.cmOf(field); + if (cm) { setTimeout(function () { cm.refresh(); cm.focus(); }, 30); } + }, + + lint: function (field) { + if (!field) { return; } + var out = field.querySelector('[data-req-lint-result]'); + if (out) { out.textContent = 'Проверка…'; out.classList.remove('is-ok', 'is-error'); } + var fd = new FormData(); + fd.append('code', this.codeOf(field)); + Adminx.Ajax.post(this.base() + '/requests/lint', fd).then(function (p) { + var d = p.data || {}; + if (d.success) { + if (out) { out.textContent = d.message; out.classList.add('is-ok'); } + Adminx.Toast.show(d.message || 'Синтаксис без ошибок', 'success'); + } else { + var detail = (d.errors && d.errors.code) ? d.errors.code : (d.message || 'Ошибка синтаксиса'); + if (out) { out.textContent = detail; out.classList.add('is-error'); } + Adminx.Toast.show(d.message || 'Ошибка синтаксиса', 'error'); + } + }).catch(function () { + if (out) { out.textContent = 'Не удалось выполнить проверку.'; out.classList.add('is-error'); } + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + openPalette: function (field) { + if (!this.palette || !field) { return; } + this.activeField = field; + var textarea = field.querySelector('textarea[data-req-editor]'); + this.paletteScope = textarea && textarea.name === 'request_template_item' ? 'item' : 'main'; + // переносим палитру под тулбар активного редактора + var ta = field.querySelector('textarea[data-code-editor]'); + var host = ta ? ta.parentNode : field.querySelector('.card-body'); + if (ta) { host.insertBefore(this.palette, ta); } else { host.appendChild(this.palette); } + this.palette.hidden = false; + this.applyPaletteScope(); + var search = this.palette.querySelector('[data-req-tags-search]'); + if (search) { search.value = ''; this.filterTags(''); search.focus(); } + }, + + closePalette: function () { if (this.palette) { this.palette.hidden = true; } }, + + applyPaletteScope: function () { + if (!this.palette) { return; } + var scope = this.paletteScope || 'main'; + this.palette.querySelectorAll('[data-req-tag-scope]').forEach(function (node) { + var target = node.getAttribute('data-req-tag-scope') || 'all'; + node.hidden = target !== 'all' && target !== scope; + if (node.hidden) { node.classList.remove('is-active'); } + }); + var first = this.palette.querySelector('[data-req-tag-tab]:not([hidden])'); + if (first) { this.activateGroup(first.getAttribute('data-req-tag-tab')); } + }, + + activateGroup: function (index) { + if (!this.palette) { return; } + this.palette.querySelectorAll('[data-req-tag-tab]').forEach(function (t) { + t.classList.toggle('is-active', !t.hidden && t.getAttribute('data-req-tag-tab') === String(index)); + }); + this.palette.querySelectorAll('[data-req-tag-panel]').forEach(function (g) { + g.classList.toggle('is-active', !g.hidden && g.getAttribute('data-req-tag-panel') === String(index)); + }); + }, + + filterTags: function (query) { + if (!this.palette) { return; } + query = (query || '').trim().toLowerCase(); + var shown = 0; + this.palette.querySelectorAll('[data-req-tag]').forEach(function (b) { + var group = b.closest('[data-req-tag-group]'); + var scope = group ? (group.getAttribute('data-req-tag-scope') || 'all') : ''; + var allowed = group && (scope === 'all' || scope === (this.paletteScope || 'main')); + var match = allowed && (query === '' || b.textContent.toLowerCase().indexOf(query) !== -1); + b.hidden = !match; + if (match) { shown++; } + }, this); + // При поиске показываем все подходящие группы активного редактора. + this.palette.querySelectorAll('[data-req-tag-group]').forEach(function (g) { + var scope = g.getAttribute('data-req-tag-scope') || 'all'; + var allowed = scope === 'all' || scope === (this.paletteScope || 'main'); + g.hidden = !allowed || (query !== '' && !g.querySelector('[data-req-tag]:not([hidden])')); + if (query !== '') { g.classList.toggle('is-active', !g.hidden); } + }, this); + this.palette.querySelectorAll('[data-req-tag-tab]').forEach(function (tab) { + var scope = tab.getAttribute('data-req-tag-scope') || 'all'; + var allowed = scope === 'all' || scope === (this.paletteScope || 'main'); + var panel = this.palette.querySelector('[data-req-tag-panel="' + tab.getAttribute('data-req-tag-tab') + '"]'); + tab.hidden = !allowed || (query !== '' && (!panel || panel.hidden)); + }, this); + if (query === '') { + var active = this.palette.querySelector('[data-req-tag-tab].is-active:not([hidden])'); + var first = active || this.palette.querySelector('[data-req-tag-tab]:not([hidden])'); + if (first) { this.activateGroup(first.getAttribute('data-req-tag-tab')); } + } + var empty = this.palette.querySelector('[data-req-tags-empty]'); + if (empty) { empty.hidden = shown !== 0; } + }, + + insertTag: function (button) { + var value = button.getAttribute('data-req-tag') || ''; + var select = button.getAttribute('data-req-tag-select') || ''; + var cm = this.cmOf(this.activeField); + if (!value || !cm) { this.closePalette(); return; } + var start = cm.indexFromPos(cm.getCursor()); + cm.replaceSelection(value, 'around'); + cm.focus(); + if (select && value.indexOf(select) !== -1) { + cm.setSelection(cm.posFromIndex(start + value.indexOf(select)), cm.posFromIndex(start + value.indexOf(select) + select.length)); + } else { + cm.setCursor(cm.posFromIndex(start + value.length)); + } + cm.save(); + this.closePalette(); + }, + + // ------------------------------------------------------------------ // + // Условия + // ------------------------------------------------------------------ // + condAdd: function (group) { + if (!this.condTable || !group) { return; } + var tpl = document.querySelector('[data-cond-tpl]'); + var body = group.querySelector(':scope > .req-condition-table-wrap [data-cond-body]'); + if (!tpl || !body) { return; } + var empty = body.querySelector('[data-cond-empty]'); + if (empty) { empty.remove(); } + var row = tpl.content.firstElementChild.cloneNode(true); + body.appendChild(row); + this.conditionFieldRefresh(row, true); + var f = row.querySelector('[data-cf="condition_field_id"]'); + if (f) { f.focus(); } + }, + + conditionFieldRefresh: function (row, changed) { + if (!row) { return; } + var field = row.querySelector('[data-cf="condition_field_id"]'); + var option = field && field.options[field.selectedIndex]; + if (!option) { return; } + var meta = row.querySelector('[data-condition-field-meta]'); + var typeLabel = option.getAttribute('data-cond-type-label') || option.getAttribute('data-cond-type') || 'Поле'; + var alias = option.getAttribute('data-cond-alias') || ('field_' + option.value); + if (meta) { meta.textContent = ''; meta.hidden = true; } + if (field) { field.title = 'Тип: ' + typeLabel + '. Системное имя: ' + alias; } + var mode = row.querySelector('[data-cf="condition_value_mode"]'); + if (changed && mode && option.getAttribute('data-cond-mode')) { mode.value = option.getAttribute('data-cond-mode'); } + this.conditionOperatorRefresh(row, option, changed); + this.conditionSmartControl(row, option); + this.conditionValueRefresh(row.querySelector('[data-condition-value]')); + }, + + conditionOperatorRefresh: function (row, fieldOption, changed) { + var compare = row.querySelector('[data-cf="condition_compare"]'); + var source = row.querySelector('[data-cf="condition_value_source"]'); + if (!compare) { return; } + var allowed = this.conditionJson(fieldOption.getAttribute('data-cond-operators'), []); + var legacy = source && source.value === 'legacy'; + Array.prototype.forEach.call(compare.options, function (option) { + var visible = legacy || allowed.indexOf(option.value) !== -1 || (!changed && option.selected); + option.hidden = !visible; + option.disabled = !visible; + }); + if (changed && compare.options[compare.selectedIndex] && compare.options[compare.selectedIndex].disabled) { + for (var i = 0; i < compare.options.length; i++) { + if (!compare.options[i].disabled) { compare.value = compare.options[i].value; break; } + } + } + }, + + conditionSmartControl: function (row, fieldOption) { + var editor = row.querySelector('[data-condition-value]'); + if (!editor) { return; } + var source = editor.querySelector('[data-cf="condition_value_source"]'); + var storage = editor.querySelector('[data-condition-literal-storage]'); + var root = editor.querySelector('[data-condition-smart-control]'); + if (!storage || !root) { return; } + root.innerHTML = ''; + storage.hidden = false; + storage.classList.remove('mono'); + if (!source || source.value !== 'literal') { return; } + var kind = fieldOption.getAttribute('data-cond-kind') || 'text'; + var compare = row.querySelector('[data-cf="condition_compare"]'); + var operator = compare ? compare.value : '=='; + var options = this.conditionJson(fieldOption.getAttribute('data-cond-options'), []); + var disabled = storage.disabled; + if (kind === 'text' && options.length === 0) { + storage.placeholder = operator === 'IN=' || operator === 'NOTIN=' ? 'Значения через запятую' : 'Введите значение'; + return; + } + if (kind === 'boolean') { + storage.hidden = true; + root.innerHTML = '' + + '' + + ''; + root.querySelectorAll('[data-condition-boolean]').forEach(function (button) { + button.disabled = disabled; + button.setAttribute('aria-pressed', button.getAttribute('data-condition-boolean') === storage.value ? 'true' : 'false'); + }); + return; + } + if ((kind === 'choice' || kind === 'choice_list') && options.length) { + storage.hidden = true; + var select = document.createElement('select'); + select.className = 'select req-condition-choice'; + select.setAttribute('data-condition-smart-select', ''); + select.disabled = disabled; + var multiple = kind === 'choice_list' || operator === 'IN=' || operator === 'NOTIN='; + select.multiple = multiple; + if (multiple) { select.size = Math.min(4, Math.max(2, options.length)); } + else { + var empty = document.createElement('option'); empty.value = ''; empty.textContent = '— выберите —'; select.appendChild(empty); + } + var selected = multiple ? this.conditionCsv(storage.value) : [String(storage.value || '')]; + options.forEach(function (item) { + var option = document.createElement('option'); + option.value = String(item.value == null ? '' : item.value); + option.textContent = String(item.label == null ? option.value : item.label); + option.selected = selected.indexOf(option.value) !== -1; + select.appendChild(option); + }); + root.appendChild(select); + return; + } + if (kind === 'relation') { + storage.hidden = true; + var relation = document.createElement('div'); + relation.className = 'req-condition-document'; + relation.innerHTML = '
    ' + + ''; + relation.querySelector('input').value = storage.value || ''; + relation.querySelector('input').disabled = disabled; + relation.querySelector('button').disabled = disabled; + root.appendChild(relation); + return; + } + if (kind === 'date' || kind === 'datetime') { + storage.hidden = true; + var date = document.createElement('input'); + date.className = 'input'; date.type = kind === 'date' ? 'date' : 'datetime-local'; + date.setAttribute('data-condition-smart-input', ''); date.setAttribute('data-condition-date-kind', kind); + var dateValue = this.conditionDateInput(storage.value, kind); + date.value = dateValue.value; date.setAttribute('data-condition-date-format', dateValue.format); date.disabled = disabled; + root.appendChild(date); + return; + } + storage.hidden = true; + var input = document.createElement('input'); + input.className = 'input'; input.type = 'text'; input.setAttribute('data-condition-smart-input', ''); input.value = storage.value || ''; input.disabled = disabled; + if (kind === 'number') { input.inputMode = 'decimal'; input.placeholder = operator === 'SEGMENT' || operator === 'INTERVAL' ? 'Например, 10, 50' : 'Введите число'; } + else if (kind === 'relation_list') { input.placeholder = 'ID документов через запятую'; } + else { input.placeholder = 'Введите значение'; } + root.appendChild(input); + }, + + conditionSmartValue: function (control) { + var editor = control.closest('[data-condition-value]'); + var storage = editor && editor.querySelector('[data-condition-literal-storage]'); + if (!storage) { return; } + if (control.matches('select[multiple]')) { + var values = Array.prototype.filter.call(control.options, function (item) { return item.selected; }).map(function (item) { return item.value; }); + storage.value = this.conditionCsvEncode(values); + } else if (control.hasAttribute('data-condition-date-kind')) { + storage.value = this.conditionDateStorage(control.value, control.getAttribute('data-condition-date-kind'), control.getAttribute('data-condition-date-format')); + } else { storage.value = control.value; } + storage.dispatchEvent(new Event('input', { bubbles: true })); + }, + + conditionBooleanValue: function (button) { + var editor = button.closest('[data-condition-value]'); + var storage = editor && editor.querySelector('[data-condition-literal-storage]'); + if (!storage) { return; } + storage.value = button.getAttribute('data-condition-boolean'); + button.parentNode.querySelectorAll('[data-condition-boolean]').forEach(function (item) { + item.setAttribute('aria-pressed', item === button ? 'true' : 'false'); + }); + storage.dispatchEvent(new Event('input', { bubbles: true })); + }, + + conditionJson: function (value, fallback) { + try { var parsed = JSON.parse(value || ''); return parsed || fallback; } catch (e) { return fallback; } + }, + + conditionCsv: function (value) { + var matches = String(value || '').match(/(?:^|,)\s*(?:"([^"]*(?:""[^"]*)*)"|([^,]*))/g) || []; + return matches.map(function (item) { return item.replace(/^\s*,?\s*/, '').replace(/^"|"$/g, '').replace(/""/g, '"').trim(); }).filter(Boolean); + }, + + conditionCsvEncode: function (values) { + return values.map(function (value) { + value = String(value); return /[,"]/.test(value) ? '"' + value.replace(/"/g, '""') + '"' : value; + }).join(','); + }, + + conditionDateInput: function (value, kind) { + value = String(value || '').trim(); + var format = kind === 'datetime' ? 'timestamp' : 'dmy'; + var date = null; + if (/^\d{9,10}$/.test(value)) { date = new Date(parseInt(value, 10) * 1000); format = 'timestamp'; } + else if (/^\d{2}\.\d{2}\.\d{4}$/.test(value)) { var p = value.split('.'); date = new Date(p[2] + '-' + p[1] + '-' + p[0] + 'T00:00:00'); format = 'dmy'; } + else if (/^\d{4}-\d{2}-\d{2}/.test(value)) { date = new Date(value.replace(' ', 'T')); format = 'iso'; } + if (!date || isNaN(date.getTime())) { return { value: '', format: format }; } + var pad = function (number) { return String(number).padStart(2, '0'); }; + var result = date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()); + if (kind === 'datetime') { result += 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()); } + return { value: result, format: format }; + }, + + conditionDateStorage: function (value, kind, format) { + if (!value) { return ''; } + if (format === 'timestamp' || kind === 'datetime') { return String(Math.floor(new Date(value).getTime() / 1000)); } + if (format === 'dmy') { var parts = value.split('-'); return parts[2] + '.' + parts[1] + '.' + parts[0]; } + return value; + }, + + conditionDocumentPicker: function (row) { + var self = this; + var field = row.querySelector('[data-cf="condition_field_id"]'); + var option = field && field.options[field.selectedIndex]; + var overlay = document.createElement('div'); + overlay.className = 'overlay request-condition-picker-overlay'; + overlay.innerHTML = ''; + document.body.appendChild(overlay); + requestAnimationFrame(function () { overlay.classList.add('show'); }); + var search = overlay.querySelector('[data-condition-picker-search]'); + var list = overlay.querySelector('[data-condition-picker-list]'); + var status = overlay.querySelector('[data-condition-picker-status]'); + var count = overlay.querySelector('[data-condition-picker-count]'); + var close = function () { overlay.classList.remove('show'); setTimeout(function () { overlay.remove(); }, 160); }; + var load = function () { + var params = new URLSearchParams(); params.set('q', search.value.trim()); params.set('limit', '30'); + if (option && option.getAttribute('data-cond-rubrics')) { params.set('rubric_id', option.getAttribute('data-cond-rubrics')); } + status.hidden = false; status.textContent = 'Загрузка...'; list.innerHTML = ''; + fetch(self.base() + '/requests/documents/picker?' + params.toString(), { credentials: 'same-origin', headers: { 'Accept': 'application/json' } }) + .then(function (response) { return response.json(); }) + .then(function (payload) { + var items = payload && payload.data && payload.data.items ? payload.data.items : []; + items.forEach(function (item) { + var button = document.createElement('button'); button.type = 'button'; button.className = 'request-condition-picker-item'; button.setAttribute('data-condition-picker-item', String(item.id)); + var main = document.createElement('span'); main.innerHTML = ''; main.querySelector('b').textContent = item.title || 'Без названия'; main.querySelector('small').textContent = '/' + String(item.alias || '').replace(/^\/+/, ''); + var id = document.createElement('span'); id.className = 'request-condition-picker-id'; id.textContent = '#' + item.id; + var badge = document.createElement('span'); badge.className = 'badge badge-blue'; badge.textContent = item.rubric_title || ('Рубрика #' + item.rubric_id); + button.appendChild(id); button.appendChild(main); button.appendChild(badge); list.appendChild(button); + }); + status.hidden = items.length > 0; status.textContent = search.value.trim() ? 'Ничего не найдено' : 'Документы не найдены'; count.textContent = items.length ? 'Найдено: ' + items.length : ''; + }).catch(function () { status.hidden = false; status.textContent = 'Не удалось загрузить документы'; }); + }; + var timer = null; + search.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(load, 220); }); + overlay.addEventListener('click', function (event) { + if (event.target === overlay || event.target.closest('[data-condition-picker-close]')) { close(); return; } + var item = event.target.closest('[data-condition-picker-item]'); + if (!item) { return; } + var storage = row.querySelector('[data-condition-literal-storage]'); if (storage) { storage.value = item.getAttribute('data-condition-picker-item'); } + self.conditionSmartControl(row, option); self.conditionValueRefresh(row.querySelector('[data-condition-value]')); close(); + }); + load(); search.focus(); + }, + + conditionValueRefresh: function (editor, trigger) { + if (!editor) { return; } + var row = editor.closest('tr'); + var source = editor.querySelector('[data-cf="condition_value_source"]'); + var literal = editor.querySelector('[data-condition-literal]'); + var input = editor.querySelector('[data-condition-input]'); + var key = editor.querySelector('[data-cf="condition_value_key"]'); + var mode = editor.querySelector('[data-cf="condition_value_mode"]'); + var constant = editor.querySelector('[data-condition-constant]'); + var tag = editor.querySelector('[data-condition-tag]'); + var technical = editor.querySelector('[data-condition-technical]'); + var literalLabel = editor.querySelector('[data-condition-literal-label]'); + var preview = editor.querySelector('[data-condition-preview-text]'); + var isInput = source && source.value === 'input'; + if (literal) { literal.hidden = isInput; } + if (input) { input.hidden = !isInput; } + if (constant) { constant.hidden = !mode || mode.value !== 'presence_constant'; } + if (technical) { technical.hidden = !isInput; } + if (literalLabel) { literalLabel.textContent = source && source.value === 'legacy' ? 'Сохранённый PHP-код' : 'Значение для сравнения'; } + if (isInput && key && !key.value && trigger && trigger.matches('[data-cf="condition_value_source"], [data-cf="condition_field_id"]')) { + var field = row && row.querySelector('[data-cf="condition_field_id"]'); + var option = field && field.options[field.selectedIndex]; + key.value = option ? (option.getAttribute('data-cond-alias') || ('field_' + option.value)) : ''; + } + if (tag) { tag.textContent = key && key.value ? '[tag:cond:' + key.value.trim() + ']' : '[tag:cond:alias]'; } + if (preview) { preview.textContent = this.conditionValuePreview(row, source, key, mode); } + }, + + conditionValuePreview: function (row, source, key, mode) { + var field = row && row.querySelector('[data-cf="condition_field_id"]'); + var compare = row && row.querySelector('[data-cf="condition_compare"]'); + var literal = row && row.querySelector('[data-cf="condition_value"]'); + var constant = row && row.querySelector('[data-cf="condition_value_constant"]'); + var fieldText = field && field.options[field.selectedIndex] ? field.options[field.selectedIndex].textContent.trim() : 'выбранное поле'; + fieldText = fieldText.replace(/^#\d+\s*·\s*/, '').replace(/\s*·\s*#\d+$/, ''); + var compareLabels = {'==':'равно','!=':'не равно','%%':'содержит','%':'начинается с','--':'не содержит','!-':'не начинается с','>':'больше','<':'меньше','>=':'больше или равно','<=':'меньше или равно','N>':'больше','N<':'меньше','N>=':'больше или равно','N<=':'меньше или равно','IN=':'входит в список','NOTIN=':'не входит в список','ANY':'совпадает с одним из значений','FRE':'проверяется свободным условием'}; + var relation = compare && compareLabels[compare.value] ? compareLabels[compare.value] : 'сравнивается со значением'; + var sourceValue = source ? source.value : 'literal'; + if (sourceValue === 'legacy') { + return 'Поле «' + fieldText + '» ' + relation + ' результатом сохранённого PHP-кода.'; + } + if (sourceValue !== 'input') { + var fixed = literal ? literal.value.trim() : ''; + return 'Поле «' + fieldText + '» ' + relation + ' фиксированное значение' + (fixed ? ' «' + fixed + '».' : '.'); + } + + var parameter = key && key.value.trim() ? key.value.trim() : 'параметр'; + var modeValue = mode ? mode.value : 'string_scalar'; + var valueDescription = 'значением параметра «' + parameter + '»'; + if (modeValue === 'integer_scalar') { valueDescription = 'целым числом из параметра «' + parameter + '»'; } + else if (modeValue === 'decimal_scalar') { valueDescription = 'десятичным числом из параметра «' + parameter + '»'; } + else if (modeValue === 'string_list' || modeValue === 'integer_list') { valueDescription = 'одним из значений параметра «' + parameter + '»'; } + else if (modeValue === 'integer_range' || modeValue === 'decimal_range') { valueDescription = 'диапазоном min/max из параметра «' + parameter + '»'; } + else if (modeValue === 'integer_member') { valueDescription = 'значение параметра «' + parameter + '» в формате |значение|'; } + else if (modeValue === 'integer_member_list') { valueDescription = 'одним из значений параметра «' + parameter + '» в формате |значение|'; } + else if (modeValue === 'presence_constant') { + var constantValue = constant ? constant.value.trim() : ''; + valueDescription = 'значением «' + (constantValue || 'укажите константу') + '», когда параметр «' + parameter + '» заполнен'; + } + + return 'Поле «' + fieldText + '» ' + relation + ' ' + valueDescription + '. Пустой параметр не участвует в фильтрации.'; + }, + + condSave: function (row) { + var reqId = this.condTable.getAttribute('data-req-id'); + var fd = new FormData(); + fd.append('cond_id', row.getAttribute('data-cond-id') || '0'); + fd.append('condition_group_id', row.closest('[data-cond-body]').getAttribute('data-group-id') || '0'); + row.querySelectorAll('[data-cf]').forEach(function (el) { + fd.append(el.getAttribute('data-cf'), el.type === 'checkbox' ? (el.checked ? '1' : '0') : el.value); + }); + Adminx.Loader.show(); + Adminx.Ajax.post(this.base() + '/requests/' + reqId + '/conditions', fd).then(function (p) { + Adminx.Loader.hide(); + var d = p.data || {}; + if (d.success) { + if (d.data && d.data.id) { row.setAttribute('data-cond-id', d.data.id); row.removeAttribute('data-cond-new'); } + Adminx.Toast.show(d.message || 'Сохранено', 'success'); + } else { Adminx.Toast.show(d.message || 'Ошибка', 'error'); } + }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); }); + }, + + condDelete: function (row) { + var self = this; + var condId = row.getAttribute('data-cond-id') || '0'; + if (condId === '0') { row.remove(); this.normalizeEmptyGroups(); return; } + Adminx.Confirm.open({ + kind: 'error', title: 'Удалить условие?', confirmLabel: 'Удалить', confirmClass: 'btn-danger', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(self.base() + '/requests/conditions/' + condId + '/delete').then(function (p) { + Adminx.Loader.hide(); + var d = p.data || {}; + if (d.success) { row.remove(); self.normalizeEmptyGroups(); Adminx.Toast.show(d.message, 'success'); } + else { Adminx.Toast.show(d.message || 'Ошибка', 'error'); } + }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); }); + } + }); + }, + + groupOperator: function (button) { + var group = button.closest('[data-cond-group]'); + var fd = new FormData(); + fd.append('group_id', group.getAttribute('data-group-id')); + fd.append('parent_id', group.getAttribute('data-parent-id') || '0'); + fd.append('group_operator', button.getAttribute('data-group-operator')); + var title = group.querySelector(':scope > .req-condition-group-head [data-group-title]'); + fd.append('group_title', title ? title.value : ''); + this.saveGroup(fd, function () { + group.querySelectorAll(':scope > .req-condition-group-head [data-group-operator]').forEach(function (item) { + item.classList.toggle('is-active', item === button); + }); + var hint = group.querySelector(':scope > .req-condition-group-head .req-condition-group-title span:last-child'); + if (hint) { hint.textContent = button.getAttribute('data-group-operator') === 'AND' ? 'Должны выполняться все элементы внутри' : 'Достаточно любого элемента внутри'; } + }); + }, + + groupAdd: function (parent) { + var self = this; + var fd = new FormData(); + fd.append('parent_id', parent.getAttribute('data-group-id')); + fd.append('group_operator', 'AND'); + fd.append('group_title', ''); + this.saveGroup(fd, function (data) { + var tpl = document.querySelector('[data-group-tpl]'); + if (!tpl || !data.id) { return; } + var group = tpl.content.firstElementChild.cloneNode(true); + var depth = parseInt(parent.style.getPropertyValue('--condition-depth') || '0', 10) + 1; + group.setAttribute('data-group-id', data.id); + group.setAttribute('data-parent-id', parent.getAttribute('data-group-id')); + self.setGroupDepth(group, depth); + group.querySelector('[data-cond-body]').setAttribute('data-group-id', data.id); + parent.querySelector(':scope > [data-group-children]').appendChild(group); + Adminx.Tooltip && Adminx.Tooltip.init && Adminx.Tooltip.init(group); + }); + }, + + groupTitle: function (input) { + var group = input.closest('[data-cond-group]'); + var active = group.querySelector(':scope > .req-condition-group-head [data-group-operator].is-active'); + var fd = new FormData(); + fd.append('group_id', group.getAttribute('data-group-id')); + fd.append('parent_id', group.getAttribute('data-parent-id') || '0'); + fd.append('group_operator', active ? active.getAttribute('data-group-operator') : 'AND'); + fd.append('group_title', input.value.trim()); + this.saveGroup(fd); + }, + + setGroupDepth: function (group, depth) { + group.style.setProperty('--condition-depth', depth); + var add = group.querySelector(':scope > .req-condition-group-head [data-group-add]'); + if (add && depth >= 3) { add.remove(); } + group.querySelectorAll(':scope > [data-group-children] > [data-cond-group]').forEach(function (child) { + Adminx.Requests.setGroupDepth(child, depth + 1); + }); + }, + + normalizeEmptyGroups: function () { + if (!this.condTable) { return; } + this.condTable.querySelectorAll('[data-cond-body]').forEach(function (body) { + var rows = body.querySelectorAll(':scope > tr[data-cond-id]'); + var empty = body.querySelector(':scope > [data-cond-empty]'); + if (rows.length && empty) { empty.remove(); } + if (!rows.length && !empty) { + var row = document.createElement('tr'); + row.setAttribute('data-cond-empty', ''); + row.innerHTML = 'В этой группе пока нет условий'; + body.appendChild(row); + } + }); + }, + + saveGroup: function (fd, done) { + var reqId = this.condTable.getAttribute('data-req-id'); + Adminx.Ajax.post(this.base() + '/requests/' + reqId + '/condition-groups', fd).then(function (p) { + var d = p.data || {}; + if (!d.success) { Adminx.Toast.show(d.message || 'Не удалось сохранить группу', 'error'); return; } + if (done) { done(d.data || {}); } + Adminx.Toast.show(d.message || 'Группа сохранена', 'success'); + }).catch(function () { Adminx.Toast.show('Ошибка сети', 'error'); }); + }, + + groupDelete: function (group) { + var self = this; + Adminx.Confirm.open({ + kind: 'error', title: 'Удалить группу?', + message: 'Условия и вложенные группы будут перенесены на уровень выше.', + confirmLabel: 'Удалить', confirmClass: 'btn-danger', + onConfirm: function () { + var reqId = self.condTable.getAttribute('data-req-id'); + var parent = group.parentNode.closest('[data-cond-group]'); + Adminx.Ajax.post(self.base() + '/requests/' + reqId + '/condition-groups/' + group.getAttribute('data-group-id') + '/delete').then(function (p) { + var d = p.data || {}; + if (!d.success) { Adminx.Toast.show(d.message || 'Не удалось удалить группу', 'error'); return; } + var parentBody = parent.querySelector(':scope > .req-condition-table-wrap [data-cond-body]'); + var parentEmpty = parentBody.querySelector(':scope > [data-cond-empty]'); + if (parentEmpty) { parentEmpty.remove(); } + group.querySelectorAll(':scope > .req-condition-table-wrap tbody > tr[data-cond-id]').forEach(function (row) { parentBody.appendChild(row); }); + var parentChildren = parent.querySelector(':scope > [data-group-children]'); + group.querySelectorAll(':scope > [data-group-children] > [data-cond-group]').forEach(function (child) { + child.setAttribute('data-parent-id', parent.getAttribute('data-group-id')); + self.setGroupDepth(child, parseInt(parent.style.getPropertyValue('--condition-depth') || '0', 10) + 1); + parentChildren.appendChild(child); + }); + group.remove(); + self.normalizeEmptyGroups(); + Adminx.Toast.show(d.message, 'success'); + }).catch(function () { Adminx.Toast.show('Ошибка сети', 'error'); }); + } + }); + }, + + // ---- drag-and-drop сортировка условий ---- + dragStart: function (e) { + var handle = e.target.closest('[data-cond-drag]'); + if (!handle) { return; } + this.dragRow = handle.closest('tr'); + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', ''); } catch (err) {} + var row = this.dragRow; + setTimeout(function () { row.classList.add('cond-dragging'); }, 0); + }, + + dragOver: function (e) { + if (!this.dragRow) { return; } + e.preventDefault(); + var row = e.target.closest('tr'); + var targetBody = e.target.closest('[data-cond-body]'); + if (!targetBody) { return; } + var empty = targetBody.querySelector(':scope > [data-cond-empty]'); + if (empty) { empty.remove(); } + if (!row || row === this.dragRow || !row.hasAttribute('data-cond-id')) { + targetBody.appendChild(this.dragRow); + return; + } + var rect = row.getBoundingClientRect(); + var body = row.parentNode; + if (e.clientY > rect.top + rect.height / 2) { body.insertBefore(this.dragRow, row.nextSibling); } + else { body.insertBefore(this.dragRow, row); } + }, + + drop: function (e) { if (this.dragRow) { e.preventDefault(); } }, + + dragEnd: function () { + if (!this.dragRow) { return; } + this.dragRow.classList.remove('cond-dragging'); + this.dragRow = null; + this.normalizeEmptyGroups(); + this.persistOrder(); + }, + + persistOrder: function () { + var reqId = this.condTable.getAttribute('data-req-id'); + var items = []; + this.condTable.querySelectorAll('[data-cond-body] tr[data-cond-id]').forEach(function (r) { + var id = r.getAttribute('data-cond-id'); + if (id && id !== '0') { items.push({ id: id, group_id: r.closest('[data-cond-body]').getAttribute('data-group-id') }); } + }); + if (!items.length) { return; } + var fd = new FormData(); + items.forEach(function (item, index) { + fd.append('items[' + index + '][id]', item.id); + fd.append('items[' + index + '][group_id]', item.group_id); + }); + Adminx.Ajax.post(this.base() + '/requests/' + reqId + '/conditions/reorder', fd).then(function (p) { + var d = p.data || {}; + Adminx.Toast.show(d.success ? 'Порядок сохранён' : (d.message || 'Ошибка'), d.success ? 'success' : 'error'); + }).catch(function () { Adminx.Toast.show('Ошибка сети', 'error'); }); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.Requests.init(); }); + } else { + Adminx.Requests.init(); + } +})(window, document); diff --git a/adminx/modules/Requests/language/en/client.xml b/adminx/modules/Requests/language/en/client.xml new file mode 100644 index 0000000..ab1d18b --- /dev/null +++ b/adminx/modules/Requests/language/en/client.xml @@ -0,0 +1,136 @@ + + + meaning " + found + For each request, the complete Legacy sequence will be checked against the ID in ascending and descending order. Native will only be enabled if there is an exact match; any discrepancy will automatically cancel the change. + specify a constant + Order verified + contains + Random order is used without other levels + Document + one of the parameter values " + Checking requests + <div class="request-explain-options-state"><i class="ti ti-loader-2"></i>Search...</div> + Comparison + Material + Field " + Nothing found + Delete condition? + <div class="request-explain-tree-empty">There are no active conditions in the group</div> + less + requests in the list. + And + greater than or equal to + Random order + Untitled + parameter value " + » filled + Document ID + starts with + <div class="request-explain-options-state"><i class="ti ti-file-off"></i>Documents not found</div> + · deleted + Close + . An empty parameter does not participate in filtering. + Boolean value + [structured data] + Cache, integrations, compatibility, result contract and Native executor. + Explain the result + The order is not fixed + » in the format |value| + Terms + Reinforce the order without changing the output? + Request + less than or equal to + active conditions + No documents found + Verification failed + included in the list + Reinforce order + Requests + checked by free condition + <span class="request-explain-condition-state"><i class="ti"></i></span><div class="request-explain-condition-copy"><div><b></b><code></code></div><dl><div><dt>Actually</dt><dd data-actual></dd></div><div><dt>Condition</dt><dd data-expected></dd></div></dl><p hidden></p></div> + matches one of the values + Remains Legacy + · extra IDs: + is compared with the value + Any element inside is enough + equals + Query conditions and actual result of executor. + Validation error + range min/max from the parameter " + Error + Basic parameters, conditions and presentation of the result. + not included in the list + groups + · withdrawn from publication + The order is different + Native confirmed + <i class="ti ti-loader-2"></i><span>Checking the request...</span> + One coincidence is enough + Delete + ", when the parameter " + Yes + · no ID: + Preparation + Shuffle cannot be combined with other levels + All available fields have already been added + Enable Confirmed + Ready + Post verification + fixed value + Test results + Saved PHP code + does not contain + the result of the saved PHP code. + parameter value " + Request verified + parameter + decimal number from parameter " + Requires Legacy + from + Network error + Position + no changes: + Scan stopped + Explain + Final order by ID has already been added + Results vary + doesn't start with + · checked states: + an integer from the parameter " + not equal + There are no queries with loose order + All elements must match + Found: + The order is fixed: + Failed to upload documents + No explicit sorting + This field is already being sorted + Validation failed + Find document + We are checking the request... + more + Matches + Document verification + Return Legacy + Network error when checking document + OR + Need manual verification + In progress + Saved + <div class="request-explain-options-state is-error"><i class="ti ti-alert-circle"></i>Search unavailable</div> + selected field + You can add up to eight levels + All elements inside must be executed + Check and secure + <i class="ti ti-filter-off"></i><b>Documents not found</b><span>Check the conditions, publication status and selected category.</span> + No + Comparison value + Check the fields + Saved + No explicit sorting + Preview updated + Field # + Random order + Request verified + diff --git a/adminx/modules/Requests/language/en/interface.xml b/adminx/modules/Requests/language/en/interface.xml new file mode 100644 index 0000000..f2bd12e --- /dev/null +++ b/adminx/modules/Requests/language/en/interface.xml @@ -0,0 +1,382 @@ + + + Special order + 0 of + 0 ms + alias not specified + ID, name or alias + ID, title, tag or category + Native confirmed + Native is not yet available + SQL in public debugging + SQL and diagnostics + {{ native_verified ? 'The current plan matches Legacy' : (native_plan.eligible ? 'Run a preview for shadow comparison' : (native_plan.reasons|first)|default('Native is not available yet')) }} + · category # + · states: + · number + Alias: + No changes + Untitled + Without rules, the order of materials can change if the values are the same. + In Native + There are no conditions for this group yet + Return Legacy + The top rule is applied first. Drag rows to change priority. + Preview type + Request preview type + On + Enable Confirmed + External call + Caching time, seconds + All request conditions + Select field + Select comparison + Choose a convenient view to check your request in the admin panel. + The selected view is applied by the “Update” button in the preview. + Conclusion + Ready + Ready to compare + Ready value + Condition groups + The group sets the logic for all the elements inside. Nested groups become SQL parentheses and allow AND/OR combinations to be assembled without ambiguity. + Tag groups + Condition tree + Add a nested group + Add level + Add condition + All elements inside must be executed + Any element inside is enough + Pin by ID + Reinforce order + Request + The request has been saved. + Request: + Requests + requests in the list. + No requests yet + Run preview for shadow comparison + Comparison value + And + Change priority + Changed + OR + Parameter name + Source: Ready Value. + Source: "URL or Form Parameter"; an empty parameter is not used. + For inquiries + How to process a parameter + How to show the result + How to collect a condition + What data does the API, preview and future renderers receive? Current public templates do not change. + What value to substitute + The catalog contains catalog + Order key + Compatibility code + The Check All command first turns on Shadow, then sequentially compares the count, ID, and order. Native is enabled with a separate button only for exact matches. + Result Contract + Cache items + Latin; used in the request insertion tag. + listings templates and conditions for selecting documents + Group logic + Materials per page + It only changes the display of the check below. + On the main page = Yes AND (News OR Article) + The site uses the current template + Click "Update" to check your selection and contract. + Group name + found + Find the material in this section and check each stage of the selection. + Sort direction + For example, 2026 + For example, catalog + For example: equals, contains, greater than, or is in a list. + Query setup + Not selected + Not supported + Don't show open document + Not verified + Nothing found for your request. + new + New request + Need manual verification + Needed for integrations and old templates + General list template + Explain + Regular + The same key maintains order between pages. Leave empty to mix again. + Waiting for verification + Basic parameters, conditions and presentation of the result. + Where to get the value + The order is different + URL or form parameter + The first documents are selected by the current public executor, and the display method is taken from the renderer registry. The item/main templates are not executed. + Plan + Ascending + Descending + Preparation + Count all results + Confirmed + Search tags + shown + Show pagination + Show statistics + Field, comparison, value source and group logic with simple examples + Category fields + Order of materials + No order specified + The order is not fixed + After saving, the real data of the selected category will appear. + The constant value is set manually, the dynamic value is taken from the URL or form. + Permanent filter + Why did the document hit or miss? + The preview has not started yet + Preview the result + Legacy reasons are stored next to the request and do not block other views. + Check all + Check request + Native check + Native Check Requests + Document verification + View Validation + We are checking the request... + Checked to field conditions. + The public site remains on Legacy during verification + Works + Expand + Advanced + Edit site template + Mode + Editor mode + Test results + Results vary + Rubrik + Reconciliation + Your request + System name (alias) + System data + System limitations + Parentheses and operators are shown in order of execution. + Service presentation + Service parameters + Service tags and Legacy PHP are only needed for compatibility. For new conditions, select values ​​in the form: the engine itself will check the input data and safely assemble the filter. + Random order + First create a view. + Save your request first + First, save the request and the selected category. + Gather groups + Compatibility + Compatible + Matches + Create a nested group and switch it to "OR". + Create your first selection of documents. + Create a new listing + Sort by + Save your request to add conditions + Saved selections of documents and tags for inserting them. + Saved list and material templates will continue to work without changes. + Saved PHP code + List of requests + Comparison + Comparing old and new selections without changing site templates + Rows + The current version of the plan is the same as Legacy + Material type = News + Only yours + requires all the elements + Requires Legacy + The selected category has no fields. + Remove level + Delete group + Removed template · # + Please indicate your source + Condition with brackets + Terms + Terms + Query conditions and actual result of executor. + Consider the language + Filter + Filter from address + Single material template + Pagination template + Templates not created + This is the document data that the request will check. + - at least one. A nested group works like parentheses. + (copy) + . Prepared declarative conditions: + . An empty parameter does not participate in filtering. + <div class="request-explain-options-state is-error"><i class="ti ti-alert-circle"></i>Search unavailable</div> + <div class="request-explain-options-state"><i class="ti ti-file-off"></i>Documents not found</div> + <div class="request-explain-options-state"><i class="ti ti-loader-2"></i>Search...</div> + <div class="request-explain-tree-empty">There are no active conditions in the group</div> + <i class="ti ti-filter-off"></i><b>Documents not found</b><span>Check the conditions, publication status and selected category.</span> + <i class="ti ti-loader-2"></i><span>Checking the request...</span> + <span class="request-explain-condition-state"><i class="ti"></i></span><div class="request-explain-condition-copy"><div><b></b><code></code></div><dl><div><dt>Actually</dt><dd data-actual></dd></div><div><dt>Condition</dt><dd data-expected></dd></div></dl><p hidden></p></div> + <td colspan="6" class="ax-empty">There are no conditions in this group yet</td> + [structured data] + Document ID + Legacy SQL (compatibility) + Native is enabled only for confirmed requests + no changes: + · extra IDs: + · no ID: + · checked states: + · withdrawn from publication + · deleted + » in the format |value| + » filled + ", when the parameter " + active conditions + No explicit sorting + Safe final order not found + More + more + Greater than or equal to + greater than or equal to + Take a decimal number + Take decimal range + Take range from/to + Get a list of text values + Get a list of numbers + Take the text as is + Take an integer + All available fields have already been added + All requests returned to Legacy + All requests are transferred to Shadow safe mode + Included in the list + included in the list + selected field + In progress + groups + Group saved + Yes + decimal number from parameter " + Range including boundaries + Range not including borders + range min/max from the parameter " + For each request, the complete Legacy sequence will be checked against the ID in ascending and descending order. Native will only be enabled if there is an exact match; any discrepancy will automatically cancel the change. + Document + Document + title.innerHTML = + Document + var icon = document.createElement( + Document + this.setText(drawer, + Document + }, + + previewEntries: function (item, systemOptions, fieldOptions) { + var entries = []; + Object.keys(item.system || {}).forEach(function (key) { + if (key !== + No documents found + All elements must match + One coincidence is enough + Reinforce the order without changing the output? + Close + Request not found + Request verified + Request verified, PHP conditions replaced with declarative tags + There are no queries with loose order + Requests + Queries: view + Queries: management + parameter value " + meaning " + parameter value " + from + Cache, integrations, compatibility, result contract and Native executor. + Content + Extra in Native: + (comparison.reasons || []).forEach(function (reason) { details.push(reason); }); + if (details.length) { + var meta = document.createElement( + Boolean value + Material + Less + less + Less than or equal to + less than or equal to + You can add up to eight levels + Found: + Find document + Starts with + starts with + Start typing your name + Not included in the list + not included in the list + Doesn't start with + doesn't start with + Not equal + not equal + does not contain + Does not contain text + Failed to secure order + Validation failed + Failed to upload documents + Failed to save group + Failed to delete group + No + Nothing found + Wrap value: |value| + Wrap list: |value| + Explain the result + one of the parameter values " + Published, not deleted and within the publication deadline + Remains Legacy + Error + Validation error + Network error + Network error when checking document + parameter + Substitute constant if filled + Position + Field + if ($type !== + Field + } elseif ($rule[ + Field + item.querySelector( + Field " + The order is fixed without changing the Legacy result + The order is fixed: + The order changed during the control check. The setting has been cancelled. + Order verified + Order preserved + Check and secure + Checking requests + Verification failed + Scan stopped + Post verification + The order of the request is checked + self.auditRequest(self.auditBase() + + checked by free condition + View the list of requests (listings). + Equals + equals + the result of the saved PHP code. + Random order is used without other levels + Shuffle cannot be combined with other levels + First save your request. + Matches any + matches one of the values + contains + Contains text + Creating/modifying queries, templates and conditions. + Saved + is compared with the value + Shadow comparison failed + Delete + Delete group? + Delete condition? + specify a constant + Conditions and nested groups will be moved to a higher level. + fixed value + Final order by ID has already been added + The final order is already set + an integer from the parameter " + The number is greater + Number greater than or equal to + The number is less + Number is less than or equal to + This field is already being sorted + diff --git a/adminx/modules/Requests/language/en/runtime.xml b/adminx/modules/Requests/language/en/runtime.xml new file mode 100644 index 0000000..b48e1ea --- /dev/null +++ b/adminx/modules/Requests/language/en/runtime.xml @@ -0,0 +1,93 @@ + + + (copy) + . Prepared declarative conditions: + The alias of a public parameter must begin with a letter or _. + Legacy SQL (compatibility) + Native is enabled only for confirmed requests + Alias: Latin/numbers/-/_, up to 20 characters. + No explicit sorting + Safe final order not found + More + Greater than or equal to + Take a decimal number + Take decimal range + Take range from/to + Get a list of text values + Get a list of numbers + Take the text as is + Take an integer + All requests returned to Legacy + All requests are transferred to Shadow safe mode + Included in the list + Select a document to check + Select a supported comparison. + Select the current category field. + Select an existing pagination template. + Group saved + The group has been deleted. Its contents have been moved above. + Range including boundaries + Range not including borders + The document is included in the request + Document excluded by request + No document selected + Request not found + Request not found. + Request verified + Request verified, PHP conditions replaced with declarative tags + Request created + Request deleted + Requests + Queries: view + Queries: management + Content + The root or foreign group cannot be deleted + Less + Less than or equal to + Starts with + Not included in the list + Doesn't start with + Not equal + Does not contain text + Failed to secure order + Failed to explain query result + Failed to save group + Unknown method for processing the parameter. + Wrap value: |value| + Wrap list: |value| + Published, not deleted and within the publication deadline + Substitute constant if filled + Field # + Category fields + The order is fixed without changing the Legacy result + The order changed during the control check. The setting has been cancelled. + Order preserved + Request preview failed + Preview updated + Verification not available + Check the fields + View the list of requests (listings). + Equals + Random order + First, create a pagination template in Settings. + First save the query in Legacy or Shadow mode. + First save your request. + First save your changes and get an exact match of Legacy/Native in the preview. + Matches any + Contains text + A copy has been created + Creating/modifying queries, templates and conditions. + Saved + This alias is already in use. + Shadow comparison failed + Specify an alias. + Specify the value of the constant. + Please provide a name. + Condition saved + Condition removed + The final order is already set + The number is greater + Number greater than or equal to + The number is less + Number is less than or equal to + diff --git a/adminx/modules/Requests/language/ru/client.xml b/adminx/modules/Requests/language/ru/client.xml new file mode 100644 index 0000000..265bf3c --- /dev/null +++ b/adminx/modules/Requests/language/ru/client.xml @@ -0,0 +1,136 @@ + + + значением « + найдено + Для каждого запроса полная Legacy-последовательность будет сверена с ID по возрастанию и убыванию. Native включится только при точном совпадении; любое расхождение автоматически отменит изменение. + укажите константу + Порядок проверен + содержит + Случайный порядок используется без других уровней + Документ + одним из значений параметра « + Проверка запросов + <div class="request-explain-options-state"><i class="ti ti-loader-2"></i>Поиск…</div> + Сравнение + Материал + Поле « + Ничего не найдено + Удалить условие? + <div class="request-explain-tree-empty">В группе нет активных условий</div> + меньше + запросов в списке. + И + больше или равно + Случайный порядок + Без названия + значением параметра « + » заполнен + ID документа + начинается с + <div class="request-explain-options-state"><i class="ti ti-file-off"></i>Документы не найдены</div> + · удалён + Закрыть + . Пустой параметр не участвует в фильтрации. + Логическое значение + [структурированные данные] + Кеш, интеграции, совместимость, контракт результата и Native executor. + Объяснить результат + Порядок не закреплён + » в формате |значение| + Условия + Закрепить порядок без изменения выдачи? + Запрос + меньше или равно + активных условий + Документы не найдены + Проверка не выполнена + входит в список + Закрепить порядок + Запросов + проверяется свободным условием + <span class="request-explain-condition-state"><i class="ti"></i></span><div class="request-explain-condition-copy"><div><b></b><code></code></div><dl><div><dt>Фактически</dt><dd data-actual></dd></div><div><dt>Условие</dt><dd data-expected></dd></div></dl><p hidden></p></div> + совпадает с одним из значений + Остаётся Legacy + · лишние ID: + сравнивается со значением + Достаточно любого элемента внутри + равно + Условия запроса и фактический итог executor. + Ошибка проверки + диапазоном min/max из параметра « + Ошибка + Основные параметры, условия и оформление результата. + не входит в список + групп + · снят с публикации + Отличается порядок + Native подтверждён + <i class="ti ti-loader-2"></i><span>Проверяем запрос…</span> + Достаточно одного совпадения + Удалить + », когда параметр « + Да + · нет ID: + Подготовка + Случайный порядок нельзя сочетать с другими уровнями + Все доступные поля уже добавлены + Включить подтверждённые + Готов + Проверка публикации + фиксированное значение + Результаты проверки + Сохранённый PHP-код + не содержит + результатом сохранённого PHP-кода. + значение параметра « + Запрос проверен + параметр + десятичным числом из параметра « + Требуется Legacy + из + Ошибка сети + Позиция + · без изменений: + Проверка остановлена + Объяснить + Финальный порядок по ID уже добавлен + Результаты различаются + не начинается с + · проверено состояний: + целым числом из параметра « + не равно + Запросов с незакреплённым порядком нет + Должны совпасть все элементы + Найдено: + Порядок закреплён: + Не удалось загрузить документы + Без явной сортировки + Это поле уже участвует в сортировке + Не удалось выполнить проверку + Найти документ + Проверяем запрос… + больше + Совпадает + Проверка документа + Вернуть Legacy + Ошибка сети при проверке документа + ИЛИ + Нужна ручная проверка + Выполняется + Сохранено + <div class="request-explain-options-state is-error"><i class="ti ti-alert-circle"></i>Поиск недоступен</div> + выбранное поле + Можно добавить не больше восьми уровней + Должны выполняться все элементы внутри + Проверить и закрепить + <i class="ti ti-filter-off"></i><b>Документы не найдены</b><span>Проверьте условия, статус публикации и выбранную рубрику.</span> + Нет + Значение для сравнения + Проверьте поля + Сохранено + Без явной сортировки + Предпросмотр обновлён + Поле # + Случайный порядок + Запрос проверен + diff --git a/adminx/modules/Requests/language/ru/interface.xml b/adminx/modules/Requests/language/ru/interface.xml new file mode 100644 index 0000000..3e649f3 --- /dev/null +++ b/adminx/modules/Requests/language/ru/interface.xml @@ -0,0 +1,382 @@ + + + Особый порядок + 0 из + 0 мс + alias не задан + ID, название или alias + ID, название, тег или рубрика + Native подтверждён + Native пока недоступен + SQL в публичной отладке + SQL и диагностика + {{ native_verified ? 'Текущая версия плана совпала с Legacy' : (native_plan.eligible ? 'Запустите предпросмотр для теневого сравнения' : (native_plan.reasons|first)|default('Native пока недоступен')) }} + · рубрика # + · состояний: + · число + Алиас: + Без изменений + Без названия + Без правил порядок материалов может меняться при одинаковых значениях. + В Native + В этой группе пока нет условий + Вернуть Legacy + Верхнее правило применяется первым. Перетащите строки, чтобы изменить приоритет. + Вид предпросмотра + Вид предпросмотра запроса + Вкл + Включить подтверждённые + Внешний вызов + Время кеширования, секунд + Все условия запроса + Выберите поле + Выберите сравнение + Выберите удобный вид для проверки запроса в админке. + Выбранный вид применяется кнопкой «Обновить» в предпросмотре. + Вывод + Готов + Готов к сравнению + Готовое значение + Групп условий + Группа задаёт логику для всех элементов внутри. Вложенные группы превращаются в скобки SQL и позволяют собрать сочетания И/ИЛИ без неоднозначности. + Группы тегов + Дерево условий + Добавить вложенную группу + Добавить уровень + Добавить условие + Должны выполняться все элементы внутри + Достаточно любого элемента внутри + Закрепить по ID + Закрепить порядок + Запрос + Запрос сохранён. + Запрос: + Запросов + запросов в списке. + Запросов пока нет + Запустите предпросмотр для теневого сравнения + Значение для сравнения + И + Изменить приоритет + Изменён + ИЛИ + Имя параметра + Источник: «Готовое значение». + Источник: «Параметр URL или формы»; пустой параметр не применяется. + К запросам + Как обработать параметр + Как показать результат + Как собрать условие + Какие данные получает API, предпросмотр и будущие renderer’ы. Текущие публичные шаблоны не меняются. + Какое значение подставить + Каталог содержит catalog + Ключ порядка + Код совместимости + Команда «Проверить все» сначала включает Shadow, затем последовательно сравнивает количество, ID и порядок. Native включается отдельной кнопкой только для точных совпадений. + Контракт результата + Кэшировать элементы + Латиницей; используется в теге вставки запроса. + листингов · шаблоны и условия выборки документов + Логика группы + Материалов на страницу + Меняет только отображение проверки ниже. + На главной = Да И (Новость ИЛИ Статья) + На сайте используется текущий шаблон + Нажмите «Обновить», чтобы проверить выборку и контракт. + Название группы + найдено + Найдите материал этой рубрики и проверьте каждый этап выборки. + Направление сортировки + Например, 2026 + Например, catalog + Например: равно, содержит, больше или входит в список. + Настройка запроса + Не выбрана + Не поддерживается + Не показывать открытый документ + Не проверен + Ничего не найдено по запросу. + новый + Новый запрос + Нужна ручная проверка + Нужны интеграциям и старым шаблонам + Общий шаблон списка + Объяснить + Обычный + Одинаковый ключ сохраняет порядок между страницами. Оставьте пустым, чтобы перемешивать заново. + Ожидание проверки + Основные параметры, условия и оформление результата. + Откуда взять значение + Отличается порядок + Параметр URL или формы + Первые документы выбираются текущим публичным executor, а способ показа берётся из реестра renderer’ов. Шаблоны item/main не выполняются. + План + По возрастанию + По убыванию + Подготовка + Подсчитывать все результаты + Подтверждены + Поиск тегов + показано + Показывать пагинацию + Показывать статистику + Поле, сравнение, источник значения и логика групп на простых примерах + Поля рубрики + Порядок материалов + Порядок не задан + Порядок не закреплён + После сохранения появятся реальные данные выбранной рубрики. + Постоянное значение задаётся вручную, динамическое берётся из URL или формы. + Постоянный фильтр + Почему документ попал или не попал? + Предпросмотр ещё не запускался + Предпросмотр результата + Причины Legacy сохраняются рядом с запросом и не блокируют остальные представления. + Проверить все + Проверить запрос + Проверка Native + Проверка Native · Запросы + Проверка документа + Проверка представления + Проверяем запрос… + Проверяются до условий полей. + Публичный сайт остаётся на Legacy во время проверки + Работает + Развернуть + Расширенный + Редактировать шаблон сайта + Режим + Режим редактора + Результаты проверки + Результаты различаются + Рубрик + Сверка + Свой запрос + Системное имя (alias) + Системные данные + Системные ограничения + Скобки и операторы показаны в порядке выполнения. + Служебное представление + Служебные параметры + Служебные теги и Legacy PHP нужны только для совместимости. Для новых условий выбирайте значения в форме: движок сам проверит входные данные и безопасно соберёт фильтр. + Случайный порядок + Сначала создайте представление. + Сначала сохраните запрос + Сначала сохраните запрос и выбранную рубрику. + Соберите группы + Совместимость + Совместимы + Совпадает + Создайте вложенную группу и переключите её на «ИЛИ». + Создайте первую выборку документов. + Создание нового листинга + Сортировать по + Сохраните запрос, чтобы добавить условия + Сохранённые выборки документов и теги для их вставки. + Сохранённые шаблоны списка и материала продолжат работать без изменений. + Сохранённый PHP-код + Список запросов + Сравнение + Сравнение старой и новой выборки без изменения шаблонов сайта + Строк + Текущая версия плана совпала с Legacy + Тип материала = Новость + Только свои + требует все элементы, + Требуется Legacy + У выбранной рубрики нет полей. + Убрать уровень + Удалить группу + Удалённый шаблон · # + Укажите источник + Условие со скобками + Условий + Условия + Условия запроса и фактический итог executor. + Учитывать язык + Фильтр + Фильтр из адреса + Шаблон одного материала + Шаблон пагинации + Шаблоны не созданы + Это данные документа, которые запрос будет проверять. + — хотя бы один. Вложенная группа работает как скобки. + (копия) + . Подготовлено декларативных условий: + . Пустой параметр не участвует в фильтрации. + <div class="request-explain-options-state is-error"><i class="ti ti-alert-circle"></i>Поиск недоступен</div> + <div class="request-explain-options-state"><i class="ti ti-file-off"></i>Документы не найдены</div> + <div class="request-explain-options-state"><i class="ti ti-loader-2"></i>Поиск…</div> + <div class="request-explain-tree-empty">В группе нет активных условий</div> + <i class="ti ti-filter-off"></i><b>Документы не найдены</b><span>Проверьте условия, статус публикации и выбранную рубрику.</span> + <i class="ti ti-loader-2"></i><span>Проверяем запрос…</span> + <span class="request-explain-condition-state"><i class="ti"></i></span><div class="request-explain-condition-copy"><div><b></b><code></code></div><dl><div><dt>Фактически</dt><dd data-actual></dd></div><div><dt>Условие</dt><dd data-expected></dd></div></dl><p hidden></p></div> + <td colspan="6" class="ax-empty">В этой группе пока нет условий</td> + [структурированные данные] + ID документа + Legacy SQL (совместимость) + Native включён только для подтверждённых запросов + · без изменений: + · лишние ID: + · нет ID: + · проверено состояний: + · снят с публикации + · удалён + » в формате |значение| + » заполнен + », когда параметр « + активных условий + Без явной сортировки + Безопасный финальный порядок не найден + Больше + больше + Больше или равно + больше или равно + Взять десятичное число + Взять десятичный диапазон + Взять диапазон от/до + Взять список текстовых значений + Взять список чисел + Взять текст как есть + Взять целое число + Все доступные поля уже добавлены + Все запросы возвращены в Legacy + Все запросы переведены в безопасный режим Shadow + Входит в список + входит в список + выбранное поле + Выполняется + групп + Группа сохранена + Да + десятичным числом из параметра « + Диапазон, включая границы + Диапазон, не включая границы + диапазоном min/max из параметра « + Для каждого запроса полная Legacy-последовательность будет сверена с ID по возрастанию и убыванию. Native включится только при точном совпадении; любое расхождение автоматически отменит изменение. + Документ + Документ + title.innerHTML = + Документ + var icon = document.createElement( + Документ + this.setText(drawer, + Документ + }, + + previewEntries: function (item, systemOptions, fieldOptions) { + var entries = []; + Object.keys(item.system || {}).forEach(function (key) { + if (key !== + Документы не найдены + Должны совпасть все элементы + Достаточно одного совпадения + Закрепить порядок без изменения выдачи? + Закрыть + Запрос не найден + Запрос проверен + Запрос проверен, PHP-условия заменены декларативными тегами + Запросов с незакреплённым порядком нет + Запросы + Запросы: просмотр + Запросы: управление + значение параметра « + значением « + значением параметра « + из + Кеш, интеграции, совместимость, контракт результата и Native executor. + Контент + Лишние в Native: + (comparison.reasons || []).forEach(function (reason) { details.push(reason); }); + if (details.length) { + var meta = document.createElement( + Логическое значение + Материал + Меньше + меньше + Меньше или равно + меньше или равно + Можно добавить не больше восьми уровней + Найдено: + Найти документ + Начинается с + начинается с + Начните вводить название + Не входит в список + не входит в список + Не начинается с + не начинается с + Не равно + не равно + не содержит + Не содержит текст + Не удалось безопасно закрепить порядок + Не удалось выполнить проверку + Не удалось загрузить документы + Не удалось сохранить группу + Не удалось удалить группу + Нет + Ничего не найдено + Обернуть значение: |значение| + Обернуть список: |значение| + Объяснить результат + одним из значений параметра « + Опубликован, не удалён и входит в срок публикации + Остаётся Legacy + Ошибка + Ошибка проверки + Ошибка сети + Ошибка сети при проверке документа + параметр + Подставить константу, если заполнено + Позиция + Поле + if ($type !== + Поле + } elseif ($rule[ + Поле + item.querySelector( + Поле « + Порядок закреплён без изменения Legacy-результата + Порядок закреплён: + Порядок изменился при контрольной проверке. Настройка отменена. + Порядок проверен + Порядок сохранён + Проверить и закрепить + Проверка запросов + Проверка не выполнена + Проверка остановлена + Проверка публикации + Проверяется порядок запроса + self.auditRequest(self.auditBase() + + проверяется свободным условием + Просмотр списка запросов (листингов). + Равно + равно + результатом сохранённого PHP-кода. + Случайный порядок используется без других уровней + Случайный порядок нельзя сочетать с другими уровнями + Сначала сохраните запрос. + Совпадает с любым + совпадает с одним из значений + содержит + Содержит текст + Создание/изменение запросов, шаблонов и условий. + Сохранено + сравнивается со значением + Теневое сравнение не выполнено + Удалить + Удалить группу? + Удалить условие? + укажите константу + Условия и вложенные группы будут перенесены на уровень выше. + фиксированное значение + Финальный порядок по ID уже добавлен + Финальный порядок уже настроен + целым числом из параметра « + Число больше + Число больше или равно + Число меньше + Число меньше или равно + Это поле уже участвует в сортировке + diff --git a/adminx/modules/Requests/language/ru/runtime.xml b/adminx/modules/Requests/language/ru/runtime.xml new file mode 100644 index 0000000..8cf95b1 --- /dev/null +++ b/adminx/modules/Requests/language/ru/runtime.xml @@ -0,0 +1,93 @@ + + + (копия) + . Подготовлено декларативных условий: + Alias публичного параметра должен начинаться с буквы или _. + Legacy SQL (совместимость) + Native включён только для подтверждённых запросов + Алиас: латиница/цифры/-/_, до 20 символов. + Без явной сортировки + Безопасный финальный порядок не найден + Больше + Больше или равно + Взять десятичное число + Взять десятичный диапазон + Взять диапазон от/до + Взять список текстовых значений + Взять список чисел + Взять текст как есть + Взять целое число + Все запросы возвращены в Legacy + Все запросы переведены в безопасный режим Shadow + Входит в список + Выберите документ для проверки + Выберите поддерживаемое сравнение. + Выберите поле текущей рубрики. + Выберите существующий шаблон пагинации. + Группа сохранена + Группа удалена. Ее содержимое перенесено выше. + Диапазон, включая границы + Диапазон, не включая границы + Документ входит в запрос + Документ исключён запросом + Документ не выбран + Запрос не найден + Запрос не найден. + Запрос проверен + Запрос проверен, PHP-условия заменены декларативными тегами + Запрос создан + Запрос удалён + Запросы + Запросы: просмотр + Запросы: управление + Контент + Корневую или чужую группу удалить нельзя + Меньше + Меньше или равно + Начинается с + Не входит в список + Не начинается с + Не равно + Не содержит текст + Не удалось безопасно закрепить порядок + Не удалось объяснить результат запроса + Не удалось сохранить группу + Неизвестный способ обработки параметра. + Обернуть значение: |значение| + Обернуть список: |значение| + Опубликован, не удалён и входит в срок публикации + Подставить константу, если заполнено + Поле # + Поля рубрики + Порядок закреплён без изменения Legacy-результата + Порядок изменился при контрольной проверке. Настройка отменена. + Порядок сохранён + Предпросмотр запроса не выполнен + Предпросмотр обновлён + Проверка недоступна + Проверьте поля + Просмотр списка запросов (листингов). + Равно + Случайный порядок + Сначала создайте шаблон пагинации в настройках. + Сначала сохраните запрос в режиме Legacy или Shadow. + Сначала сохраните запрос. + Сначала сохраните изменения и получите точное совпадение Legacy/Native в предпросмотре. + Совпадает с любым + Содержит текст + Создана копия + Создание/изменение запросов, шаблонов и условий. + Сохранено + Такой алиас уже используется. + Теневое сравнение не выполнено + Укажите алиас. + Укажите значение константы. + Укажите название. + Условие сохранено + Условие удалено + Финальный порядок уже настроен + Число больше + Число больше или равно + Число меньше + Число меньше или равно + diff --git a/adminx/modules/Requests/migrations/001_native_database_calls.sql b/adminx/modules/Requests/migrations/001_native_database_calls.sql new file mode 100644 index 0000000..b2fd09f --- /dev/null +++ b/adminx/modules/Requests/migrations/001_native_database_calls.sql @@ -0,0 +1,21 @@ +UPDATE `{{prefix}}_request_conditions` +SET `condition_value` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + `condition_value`, + '$AVE_DB->Query', 'DB::query'), + '$AVE_DB->EscStr', 'DB::escape'), + '->FetchAssocArray()', '->getAssoc()'), + '->FetchRow()', '->getObject()'), + '->GetCell()', '->getValue()'), + '->getCell()', '->getValue()') +WHERE `condition_value` LIKE '%$AVE_DB%'; + +UPDATE `{{prefix}}_request` +SET `request_where_cond` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + `request_where_cond`, + '$AVE_DB->Query', 'DB::query'), + '$AVE_DB->EscStr', 'DB::escape'), + '->FetchAssocArray()', '->getAssoc()'), + '->FetchRow()', '->getObject()'), + '->GetCell()', '->getValue()'), + '->getCell()', '->getValue()') +WHERE `request_where_cond` LIKE '%$AVE_DB%'; diff --git a/adminx/modules/Requests/migrations/002_correct_native_database_escape.sql b/adminx/modules/Requests/migrations/002_correct_native_database_escape.sql new file mode 100644 index 0000000..509a537 --- /dev/null +++ b/adminx/modules/Requests/migrations/002_correct_native_database_escape.sql @@ -0,0 +1,7 @@ +UPDATE `{{prefix}}_request_conditions` +SET `condition_value` = REPLACE(`condition_value`, 'DB::escape(', 'DB::safe(') +WHERE `condition_value` LIKE '%DB::escape(%'; + +UPDATE `{{prefix}}_request` +SET `request_where_cond` = REPLACE(`request_where_cond`, 'DB::escape(', 'DB::safe(') +WHERE `request_where_cond` LIKE '%DB::escape(%'; diff --git a/adminx/modules/Requests/migrations/003_condition_groups.sql b/adminx/modules/Requests/migrations/003_condition_groups.sql new file mode 100644 index 0000000..1593e35 --- /dev/null +++ b/adminx/modules/Requests/migrations/003_condition_groups.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_request_condition_groups` ( + `Id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `request_id` SMALLINT UNSIGNED NOT NULL, + `parent_id` INT UNSIGNED NOT NULL DEFAULT 0, + `group_title` VARCHAR(100) NOT NULL DEFAULT '', + `group_operator` ENUM('AND','OR') NOT NULL DEFAULT 'AND', + `group_position` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`Id`), + KEY `idx_request_parent` (`request_id`, `parent_id`, `group_position`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE `{{prefix}}_request_conditions` + ADD COLUMN IF NOT EXISTS `condition_group_id` INT UNSIGNED NOT NULL DEFAULT 0 AFTER `request_id`; + +INSERT INTO `{{prefix}}_request_condition_groups` + (`request_id`, `parent_id`, `group_title`, `group_operator`, `group_position`) +SELECT r.`Id`, 0, '', + COALESCE((SELECT c.`condition_join` + FROM `{{prefix}}_request_conditions` c + WHERE c.`request_id` = r.`Id` AND c.`condition_status` = '1' + ORDER BY c.`condition_position`, c.`Id` LIMIT 1), 'AND'), + 0 +FROM `{{prefix}}_request` r +WHERE NOT EXISTS ( + SELECT 1 FROM `{{prefix}}_request_condition_groups` g + WHERE g.`request_id` = r.`Id` AND g.`parent_id` = 0 +); + +UPDATE `{{prefix}}_request_conditions` c +JOIN `{{prefix}}_request_condition_groups` g + ON g.`request_id` = c.`request_id` AND g.`parent_id` = 0 +SET c.`condition_group_id` = g.`Id` +WHERE c.`condition_group_id` = 0; + +ALTER TABLE `{{prefix}}_request_conditions` + ADD KEY IF NOT EXISTS `idx_request_group_position` + (`request_id`, `condition_group_id`, `condition_position`); diff --git a/adminx/modules/Requests/migrations/004_recompile_condition_groups.sql b/adminx/modules/Requests/migrations/004_recompile_condition_groups.sql new file mode 100644 index 0000000..21e1829 --- /dev/null +++ b/adminx/modules/Requests/migrations/004_recompile_condition_groups.sql @@ -0,0 +1,3 @@ +UPDATE `{{prefix}}_request` +SET `request_where_cond` = '' +WHERE `request_where_cond` IS NOT NULL AND `request_where_cond` != ''; diff --git a/adminx/modules/Requests/migrations/005_rebuild_native_condition_cache.sql b/adminx/modules/Requests/migrations/005_rebuild_native_condition_cache.sql new file mode 100644 index 0000000..21e1829 --- /dev/null +++ b/adminx/modules/Requests/migrations/005_rebuild_native_condition_cache.sql @@ -0,0 +1,3 @@ +UPDATE `{{prefix}}_request` +SET `request_where_cond` = '' +WHERE `request_where_cond` IS NOT NULL AND `request_where_cond` != ''; diff --git a/adminx/modules/Requests/migrations/006_sargable_condition_cache.sql b/adminx/modules/Requests/migrations/006_sargable_condition_cache.sql new file mode 100644 index 0000000..21e1829 --- /dev/null +++ b/adminx/modules/Requests/migrations/006_sargable_condition_cache.sql @@ -0,0 +1,3 @@ +UPDATE `{{prefix}}_request` +SET `request_where_cond` = '' +WHERE `request_where_cond` IS NOT NULL AND `request_where_cond` != ''; diff --git a/adminx/modules/Requests/migrations/007_result_contract.php b/adminx/modules/Requests/migrations/007_result_contract.php new file mode 100644 index 0000000..70a4238 --- /dev/null +++ b/adminx/modules/Requests/migrations/007_result_contract.php @@ -0,0 +1,30 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\ContentTables; + + return function (array $context) { + $table = ContentTables::table('request'); + if (DatabaseSchema::columnExists($table, 'request_result_contract')) { return 0; } + + \DB::query( + 'ALTER TABLE %b ADD COLUMN `request_result_contract` MEDIUMTEXT NULL AFTER `request_description`', + $table + ); + + return 1; + }; diff --git a/adminx/modules/Requests/migrations/008_preview_renderer.php b/adminx/modules/Requests/migrations/008_preview_renderer.php new file mode 100644 index 0000000..43c5c83 --- /dev/null +++ b/adminx/modules/Requests/migrations/008_preview_renderer.php @@ -0,0 +1,30 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\ContentTables; + + return function (array $context) { + $table = ContentTables::table('request'); + if (DatabaseSchema::columnExists($table, 'request_preview_renderer')) { return 0; } + + \DB::query( + "ALTER TABLE %b ADD COLUMN `request_preview_renderer` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT 'data_cards' AFTER `request_result_contract`", + $table + ); + + return 1; + }; diff --git a/adminx/modules/Requests/migrations/009_native_executor.php b/adminx/modules/Requests/migrations/009_native_executor.php new file mode 100644 index 0000000..35a179f --- /dev/null +++ b/adminx/modules/Requests/migrations/009_native_executor.php @@ -0,0 +1,48 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\ContentTables; + + return function (array $context) { + $table = ContentTables::table('request'); + $changed = 0; + if (!DatabaseSchema::columnExists($table, 'request_executor_mode')) { + \DB::query( + "ALTER TABLE %b ADD COLUMN `request_executor_mode` VARCHAR(16) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT 'legacy' AFTER `request_preview_renderer`", + $table + ); + $changed++; + } + + if (!DatabaseSchema::columnExists($table, 'request_native_plan')) { + \DB::query( + 'ALTER TABLE %b ADD COLUMN `request_native_plan` MEDIUMTEXT NULL AFTER `request_executor_mode`', + $table + ); + $changed++; + } + + if (!DatabaseSchema::columnExists($table, 'request_native_verified_hash')) { + \DB::query( + 'ALTER TABLE %b ADD COLUMN `request_native_verified_hash` CHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NULL AFTER `request_native_plan`', + $table + ); + $changed++; + } + + return $changed; + }; diff --git a/adminx/modules/Requests/migrations/010_native_audit_result.php b/adminx/modules/Requests/migrations/010_native_audit_result.php new file mode 100644 index 0000000..d6b15f4 --- /dev/null +++ b/adminx/modules/Requests/migrations/010_native_audit_result.php @@ -0,0 +1,40 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\ContentTables; + + return function (array $context) { + $table = ContentTables::table('request'); + $changed = 0; + if (!DatabaseSchema::columnExists($table, 'request_native_audit_status')) { + \DB::query( + "ALTER TABLE %b ADD COLUMN `request_native_audit_status` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_general_ci NULL AFTER `request_native_verified_hash`", + $table + ); + $changed++; + } + + if (!DatabaseSchema::columnExists($table, 'request_native_audit_details')) { + \DB::query( + 'ALTER TABLE %b ADD COLUMN `request_native_audit_details` MEDIUMTEXT NULL AFTER `request_native_audit_status`', + $table + ); + $changed++; + } + + return $changed; + }; diff --git a/adminx/modules/Requests/migrations/011_order_tiebreaker.php b/adminx/modules/Requests/migrations/011_order_tiebreaker.php new file mode 100644 index 0000000..fb1d465 --- /dev/null +++ b/adminx/modules/Requests/migrations/011_order_tiebreaker.php @@ -0,0 +1,31 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\ContentTables; + + return function (array $context) { + $table = ContentTables::table('request'); + if (DatabaseSchema::columnExists($table, 'request_order_tiebreaker')) { + return 0; + } + + \DB::query( + "ALTER TABLE %b ADD COLUMN `request_order_tiebreaker` VARCHAR(4) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT '' AFTER `request_asc_desc`", + $table + ); + return 1; + }; diff --git a/adminx/modules/Requests/migrations/012_condition_value_sources.php b/adminx/modules/Requests/migrations/012_condition_value_sources.php new file mode 100644 index 0000000..92178d3 --- /dev/null +++ b/adminx/modules/Requests/migrations/012_condition_value_sources.php @@ -0,0 +1,36 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\ContentTables; + + return function (array $context) { + $table = ContentTables::table('request_conditions'); + $changes = 0; + $columns = array( + 'condition_value_source' => "VARCHAR(16) NOT NULL DEFAULT '' AFTER `condition_value`", + 'condition_value_key' => "VARCHAR(64) NOT NULL DEFAULT '' AFTER `condition_value_source`", + 'condition_value_config' => "TEXT NULL AFTER `condition_value_key`", + ); + + foreach ($columns as $column => $definition) { + if (DatabaseSchema::columnExists($table, $column)) { continue; } + \DB::query('ALTER TABLE %b ADD COLUMN `' . $column . '` ' . $definition, $table); + $changes++; + } + + return $changes; + }; diff --git a/adminx/modules/Requests/migrations/013_sort_rules.php b/adminx/modules/Requests/migrations/013_sort_rules.php new file mode 100644 index 0000000..61222f4 --- /dev/null +++ b/adminx/modules/Requests/migrations/013_sort_rules.php @@ -0,0 +1,31 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\ContentTables; + + return function (array $context) { + $table = ContentTables::table('request'); + if (DatabaseSchema::columnExists($table, 'request_sort_rules')) { + return 0; + } + + \DB::query( + 'ALTER TABLE %b ADD COLUMN `request_sort_rules` MEDIUMTEXT NULL AFTER `request_order_tiebreaker`', + $table + ); + return 1; + }; diff --git a/adminx/modules/Requests/module.php b/adminx/modules/Requests/module.php new file mode 100644 index 0000000..0e579c5 --- /dev/null +++ b/adminx/modules/Requests/module.php @@ -0,0 +1,110 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + /** + * Раздел «Запросы» (листинги AVE): управление запросами, их шаблонами + * (item/main) и условиями. Данные переносятся как есть; теги/PHP в шаблонах и + * условиях не исполняются здесь (движок тегов — отдельно). + */ + return [ + 'code' => 'requests', + 'name' => 'Запросы', + 'version' => '0.1.0', + + 'permissions' => [ + 'key' => 'requests', + 'items' => array( + array( + 'code' => 'view_requests', + 'group_code' => 'navigation', + 'name' => 'Запросы: просмотр', + 'description' => 'Просмотр списка запросов (листингов).', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_requests', + 'group_code' => 'admin', + 'name' => 'Запросы: управление', + 'description' => 'Создание/изменение запросов, шаблонов и условий.', + 'sort_order' => 20, + ), + ), + 'icon' => 'ti ti-list-search', + 'priority' => 26, + ], + + 'navigation' => array( + array( + 'code' => 'requests', + 'label' => 'Запросы', + 'url' => '/requests', + 'icon' => 'ti ti-list-search', + 'permission' => 'view_requests', + 'group' => 'Контент', + 'sort_order' => 26, + 'match' => array( + '/requests', + ), + ), + ), + + 'migrations' => [ + ['id' => '001_native_database_calls', 'file' => 'migrations/001_native_database_calls.sql'], + ['id' => '002_correct_native_database_escape', 'file' => 'migrations/002_correct_native_database_escape.sql'], + ['id' => '003_condition_groups', 'file' => 'migrations/003_condition_groups.sql'], + ['id' => '004_recompile_condition_groups', 'file' => 'migrations/004_recompile_condition_groups.sql'], + ['id' => '005_rebuild_native_condition_cache', 'file' => 'migrations/005_rebuild_native_condition_cache.sql'], + ['id' => '006_sargable_condition_cache', 'file' => 'migrations/006_sargable_condition_cache.sql'], + ['id' => '007_result_contract', 'file' => 'migrations/007_result_contract.php'], + ['id' => '008_preview_renderer', 'file' => 'migrations/008_preview_renderer.php'], + ['id' => '009_native_executor', 'file' => 'migrations/009_native_executor.php'], + ['id' => '010_native_audit_result', 'file' => 'migrations/010_native_audit_result.php'], + ['id' => '011_order_tiebreaker', 'file' => 'migrations/011_order_tiebreaker.php'], + ['id' => '012_condition_value_sources', 'file' => 'migrations/012_condition_value_sources.php'], + ['id' => '013_sort_rules', 'file' => 'migrations/013_sort_rules.php'], + ], + + 'routes' => array( + array('GET', '/requests', array(\App\Adminx\Requests\Controller::class, 'index')), + array('GET', '/requests/native-audit', array(\App\Adminx\Requests\Controller::class, 'nativeAudit'), array('permission' => 'view_requests')), + array('POST', '/requests/native-audit/prepare', array(\App\Adminx\Requests\Controller::class, 'nativeAuditPrepare'), array('permission' => 'manage_requests')), + array('POST', '/requests/native-audit/activate', array(\App\Adminx\Requests\Controller::class, 'nativeAuditActivate'), array('permission' => 'manage_requests')), + array('POST', '/requests/native-audit/rollback', array(\App\Adminx\Requests\Controller::class, 'nativeAuditRollback'), array('permission' => 'manage_requests')), + array('GET', '/requests/new', array(\App\Adminx\Requests\Controller::class, 'createForm')), + array('GET', '/requests/alias', array(\App\Adminx\Requests\Controller::class, 'aliasCheck')), + array('GET', '/requests/documents/picker', array(\App\Adminx\Requests\Controller::class, 'documentPicker'), array('permission' => 'manage_requests')), + array('POST', '/requests', array(\App\Adminx\Requests\Controller::class, 'store'), array('permission' => 'manage_requests', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/requests/lint', array(\App\Adminx\Requests\Controller::class, 'lint')), + array('POST', '/requests/conditions/{id}/delete', array(\App\Adminx\Requests\Controller::class, 'conditionDelete')), + array('POST', '/requests/{id}/conditions/reorder', array(\App\Adminx\Requests\Controller::class, 'conditionReorder')), + array('POST', '/requests/{id}/condition-groups', array(\App\Adminx\Requests\Controller::class, 'groupSave')), + array('POST', '/requests/{id}/condition-groups/{groupId}/delete', array(\App\Adminx\Requests\Controller::class, 'groupDelete')), + array('POST', '/requests/{id}/preview', array(\App\Adminx\Requests\Controller::class, 'preview'), array('permission' => 'view_requests')), + array('POST', '/requests/{id}/native-audit', array(\App\Adminx\Requests\Controller::class, 'nativeAuditOne'), array('permission' => 'manage_requests')), + array('POST', '/requests/{id}/native-stabilize', array(\App\Adminx\Requests\Controller::class, 'nativeAuditStabilize'), array('permission' => 'manage_requests')), + array('GET', '/requests/{id}/explain-documents', array(\App\Adminx\Requests\Controller::class, 'explainDocuments'), array('permission' => 'view_requests')), + array('POST', '/requests/{id}/explain', array(\App\Adminx\Requests\Controller::class, 'explain'), array('permission' => 'view_requests')), + array('GET', '/requests/{id}', array(\App\Adminx\Requests\Controller::class, 'edit')), + array('POST', '/requests/{id}', array(\App\Adminx\Requests\Controller::class, 'update'), array('permission' => 'manage_requests', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/requests/{id}/copy', array(\App\Adminx\Requests\Controller::class, 'copy'), array('permission' => 'manage_requests', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/requests/{id}/delete', array(\App\Adminx\Requests\Controller::class, 'destroy')), + array('POST', '/requests/{id}/conditions', array(\App\Adminx\Requests\Controller::class, 'conditionSave'), array('permission' => 'manage_requests', 'sensitive' => 'stored_php.write', 'reauth' => true)), + ), + + 'view_globals' => [ + 'module_code' => 'requests', + ], + ]; diff --git a/adminx/modules/Requests/view/edit.twig b/adminx/modules/Requests/view/edit.twig new file mode 100644 index 0000000..be8959c --- /dev/null +++ b/adminx/modules/Requests/view/edit.twig @@ -0,0 +1,485 @@ +{% extends '@adminx/main.twig' %} +{% block title %}{{ request ? 'Запрос: ' ~ request.request_alias : 'Новый запрос' }}{% endblock %} +{% set flag_labels = { + 'request_show_pagination': 'Показывать пагинацию', + 'request_count_items': 'Подсчитывать все результаты', + 'request_hide_current': 'Не показывать открытый документ', + 'request_only_owner': 'Только свои', + 'request_ajax': 'AJAX', + 'request_external': 'Внешний вызов', + 'request_lang': 'Учитывать язык', + 'request_cache_elements': 'Кэшировать элементы', + 'request_show_statistic': 'Показывать статистику', + 'request_show_sql': 'SQL в публичной отладке', + 'request_use_query': 'Свой запрос' +} %} +{% set basic_flags = ['request_show_pagination', 'request_count_items', 'request_hide_current'] %} +{% macro sort_rule(rule, order_options, rubric_fields) %} +
    + + 1 + +
    + + +
    +
    + + Одинаковый ключ сохраняет порядок между страницами. Оставьте пустым, чтобы перемешивать заново. +
    + +
    +{% endmacro %} +{% macro condition_field_option(f, selected) %} + +{% endmacro %} +{% macro condition_value(c, modes, can_manage) %} +
    + + +
    + + + +
    +
    +
    Служебное представление{{ c.value_key_ui ? '[tag:cond:' ~ c.value_key_ui ~ ']' : '[tag:cond:alias]' }}
    +
    +{% endmacro %} + +{% macro condition_row(c, compares, rubric_fields, modes, can_manage) %} + + {% if can_manage %}{% endif %} + Поле + + + Сравнение + {{ _self.condition_value(c, modes, can_manage) }} + Работает + {% if can_manage %}
    {% endif %} + +{% endmacro %} + +{% macro condition_group(group, compares, rubric_fields, modes, can_manage) %} +
    +
    +
    + +
    {% if group.parent_id == 0 %}Все условия запроса{% else %}{% endif %}{{ group.group_operator == 'AND' ? 'Должны выполняться все элементы внутри' : 'Достаточно любого элемента внутри' }}
    +
    +
    +
    + + +
    + {% if can_manage %}{% if group.depth|default(0) < 3 %}{% endif %}{% if group.parent_id != 0 %}{% endif %}{% endif %} +
    +
    +
    + + {% if can_manage %}{% endif %}{% if can_manage %}{% endif %} + + {% for c in group.conditions %}{{ _self.condition_row(c, compares, rubric_fields, modes, can_manage) }}{% else %}{% endfor %} + +
    ПолеСравнениеЗначениеВкл
    В этой группе пока нет условий
    +
    +
    {% for child in group.children %}{{ _self.condition_group(child, compares, rubric_fields, modes, can_manage) }}{% endfor %}
    +
    +{% endmacro %} +{% block content %} + + + + + +{% if saved %}
    Запрос сохранён.
    {% endif %} + +
    + +
    Настройка запросаОсновные параметры, условия и оформление результата.
    +
    + + +
    +
    + +
    +
    +
    + + {# --------- Основное --------- #} +
    +
    +

    Основное

    +
    + + + + +
    +
    +
    Порядок материаловВерхнее правило применяется первым. Перетащите строки, чтобы изменить приоритет.
    +
    + + +
    +
    +
    + {% for rule in sort_rules %}{{ _self.sort_rule(rule, order_options, rubric_fields) }}{% endfor %} +
    +
    Порядок не заданБез правил порядок материалов может меняться при одинаковых значениях.
    + + + +
    + + +
    + +
    +
    + + {# --------- Оформление результата --------- #} +
    +
    +
    + +
    +

    Как показать результат

    +

    Выберите удобный вид для проверки запроса в админке.

    +
    +
    + +
    + +
    На сайте используется текущий шаблонСохранённые шаблоны списка и материала продолжат работать без изменений.
    + Без изменений +
    + +
    Вид предпросмотраМеняет только отображение проверки ниже.
    +
    + {% for renderer in preview_renderers %} + + {% endfor %} +
    + + + + +
    +
    + + {# --------- Шаблоны --------- #} + + +
    + + {# --------- Флаги (сайдбар) --------- #} +
    +
    +
    +

    Вывод

    +
    + {% for code in basic_flags %} + {% set label = flag_labels[code] %} + + {% endfor %} + +
    +
    +
    +
    +
    + + +
    + +{% if can_manage and tag_groups %} + +{% endif %} + +{# --------- Условия --------- #} +
    +
    +
    +
    Фильтр
    +

    Условия

    +

    Группа задаёт логику для всех элементов внутри. Вложенные группы превращаются в скобки SQL и позволяют собрать сочетания И/ИЛИ без неоднозначности.

    +
    +
    + +
    + + + Как собрать условиеПоле, сравнение, источник значения и логика групп на простых примерах + + +
    +
    +
    1
    Выберите поле

    Это данные документа, которые запрос будет проверять.

    +
    2
    Выберите сравнение

    Например: равно, содержит, больше или входит в список.

    +
    3
    Укажите источник

    Постоянное значение задаётся вручную, динамическое берётся из URL или формы.

    +
    4
    Соберите группы

    И требует все элементы, ИЛИ — хотя бы один. Вложенная группа работает как скобки.

    +
    +
    +
    Постоянный фильтрТип материала = НовостьИсточник: «Готовое значение».
    +
    Фильтр из адресаКаталог содержит catalogИсточник: «Параметр URL или формы»; пустой параметр не применяется.
    +
    Условие со скобкамиНа главной = Да И (Новость ИЛИ Статья)Создайте вложенную группу и переключите её на «ИЛИ».
    +
    +

    Служебные теги и Legacy PHP нужны только для совместимости. Для новых условий выбирайте значения в форме: движок сам проверит входные данные и безопасно соберёт фильтр.

    +
    +
    + +
    + {% if not request %}
    Сохраните запрос, чтобы добавить условия
    {% else %}{{ _self.condition_group(condition_tree, compares, rubric_fields, condition_value_modes, can_manage) }}{% endif %} +
    + +{% if can_manage and request %} + + +{% endif %} + +
    +
    +
    +
    +
    Проверка представления
    +

    Предпросмотр результата

    +

    Первые документы выбираются текущим публичным executor, а способ показа берётся из реестра renderer’ов. Шаблоны item/main не выполняются.

    +
    +
    + + +
    +
    +
    +
    +
    Почему документ попал или не попал?Найдите материал этой рубрики и проверьте каждый этап выборки.
    + +
    +
    + +
    {{ request ? 'Предпросмотр ещё не запускался' : 'Сначала сохраните запрос' }}{{ request ? 'Нажмите «Обновить», чтобы проверить выборку и контракт.' : 'После сохранения появятся реальные данные выбранной рубрики.' }}
    +
    + +
    +
    + + + +{% endblock %} diff --git a/adminx/modules/Requests/view/index.twig b/adminx/modules/Requests/view/index.twig new file mode 100644 index 0000000..af899ef --- /dev/null +++ b/adminx/modules/Requests/view/index.twig @@ -0,0 +1,94 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Запросы{% endblock %} +{% block content %} + + + + + +
    +
    {{ stats.requests }}
    Запросов
    +
    {{ stats.conditions }}
    Условий
    +
    {{ stats.groups }}
    Групп условий
    +
    {{ stats.rubrics }}
    Рубрик
    +
    + +
    +
    +
    +

    Фильтры

    {{ requests|length }} запросов в списке.

    +
    +
    + + +
    +
    + +
    +

    Список запросов

    Сохранённые выборки документов и теги для их вставки.

    {{ requests|length }}
    +
    + + + + + + + + + {% if can_manage %}{% endif %} + + + + + + + + + + {% if can_manage %}{% endif %} + + + + {% for r in requests %} + {% set req_tag = '[tag:request:' ~ (r.request_alias ?: r.Id) ~ ']' %} + + + + + + + + {% if can_manage %} + + {% endif %} + + {% else %} + + {% endfor %} + +
    IDНазваниеТегРубрикаУсловийИзменёнДействия
    {{ r.Id }}{{ r.request_title }}{{ r.rubric_id }}{% if r.cond_count %}{{ r.cond_count }}{% else %}{% endif %}{{ r.request_changed ? r.request_changed|date('d.m.Y H:i') : '—' }} +
    + + + +
    +
    Запросов пока нетСоздайте первую выборку документов.
    +
    + +
    +
    + +{% endblock %} diff --git a/adminx/modules/Requests/view/native-audit.twig b/adminx/modules/Requests/view/native-audit.twig new file mode 100644 index 0000000..24108fc --- /dev/null +++ b/adminx/modules/Requests/view/native-audit.twig @@ -0,0 +1,26 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Проверка Native · Запросы{% endblock %} +{% block content %} + + + + + +
    +
    {{ stats.total }}
    Запросов
    +
    {{ stats.eligible }}
    Совместимы
    +
    {{ stats.verified }}
    Подтверждены
    +
    {{ stats.native }}
    В Native
    +
    + +
    +

    Публичный сайт остаётся на Legacy во время проверки

    Команда «Проверить все» сначала включает Shadow, затем последовательно сравнивает количество, ID и порядок. Native включается отдельной кнопкой только для точных совпадений.

    + {% if can_manage %}{% endif %} + +
    Совместимость

    Результаты проверки

    Причины Legacy сохраняются рядом с запросом и не блокируют остальные представления.

    {% if can_manage %}
    {% endif %}
    +
    {% if can_manage %}{% endif %} + {% for row in rows %}{% set order_diag = row.audit_details.order_diagnostics|default({}) %}{% set order_criterion = order_diag.criteria|default([])|first %}{% set runtime_checks = row.audit_details.runtime_verification|default([]) %}{% if can_manage %}{% endif %}{% else %}{% endfor %} +
    IDЗапросРежимПланСверкаДействия
    {{ row.id }}{{ row.title ?: 'Без названия' }}{{ row.alias ? '[tag:request:' ~ row.alias ~ ']' : 'alias не задан' }} · рубрика #{{ row.rubric_id }}{{ row.mode|capitalize }}{{ row.eligible ? 'Готов' : 'Legacy' }}
    {% if row.verified %}Совпадает{% if row.audit_details.legacy_total is not null %}Legacy: {{ row.audit_details.legacy_total }} · Native: {{ row.audit_details.native_total }}{% if runtime_checks|length > 1 %} · состояний: {{ runtime_checks|length }}{% endif %}{% endif %}{% elseif row.audit_status == 'unstable' %}Порядок не закреплён{{ row.audit_details.reasons|join(' ') }}{% elseif row.audit_status == 'order_diff' %}Отличается порядок{% if order_diag.position %}Позиция {{ order_diag.position }}: Legacy #{{ order_diag.legacy_id }}, Native #{{ order_diag.native_id }}{% if order_criterion %} · {{ order_criterion.label }}: {{ order_criterion.legacy_value }} / {{ order_criterion.native_value }}{% endif %}{% else %}Legacy: {{ row.audit_details.legacy_total }} · Native: {{ row.audit_details.native_total }}{% endif %}{% if runtime_checks|length > 1 %} · состояний: {{ runtime_checks|length }}{% endif %}{% elseif row.audit_status in ['mismatch', 'incomplete', 'error'] %}{{ row.audit_status == 'incomplete' ? 'Нужна ручная проверка' : 'Результаты различаются' }}{{ row.audit_details.reasons|join(' ') }}{% elseif not row.eligible %}Не поддерживается{{ row.reasons|join(' ') }}{% else %}Не проверен{% endif %}
    Запросов пока нетСначала создайте представление.
    +
    + +{% endblock %} diff --git a/adminx/modules/Rubrics/AdminView.php b/adminx/modules/Rubrics/AdminView.php new file mode 100644 index 0000000..cd8301d --- /dev/null +++ b/adminx/modules/Rubrics/AdminView.php @@ -0,0 +1,416 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AuditLog; + use App\Content\ContentTables; + use App\Content\Fields\FieldSettings; + use App\Helpers\Json; + use App\Helpers\Str; + use DB; + + /** Настраиваемое представление документов конкретной рубрики в Adminx. */ + class AdminView + { + const MAX_ITEMS = 10; + + public static function table() + { + return ContentTables::table('rubric_admin_views'); + } + + public static function defaults($rubricId) + { + return array( + 'rubric_id' => (int) $rubricId, + 'mode' => 'standard', + 'items' => array(), + 'template' => '', + 'updated_at' => '', + ); + } + + public static function get($rubricId) + { + $rubricId = (int) $rubricId; + if ($rubricId <= 0) { + return self::defaults(0); + } + + try { + $row = DB::query('SELECT * FROM ' . self::table() . ' WHERE rubric_id = %i LIMIT 1', $rubricId)->getAssoc(); + } catch (\Throwable $e) { + return self::defaults($rubricId); + } + + if (!$row) { + return self::defaults($rubricId); + } + + $config = Json::toArray((string) $row['config_json'], array()); + return array( + 'rubric_id' => $rubricId, + 'mode' => self::mode(isset($row['mode']) ? $row['mode'] : 'standard'), + 'items' => isset($config['items']) && is_array($config['items']) ? array_values($config['items']) : array(), + 'template' => isset($config['template']) ? (string) $config['template'] : '', + 'updated_at' => isset($row['updated_at']) ? (string) $row['updated_at'] : '', + ); + } + + public static function fields($rubricId) + { + $rows = DB::query( + 'SELECT Id, rubric_field_title, rubric_field_alias, rubric_field_type, rubric_field_settings' + . ' FROM ' . ContentTables::table('rubric_fields') + . ' WHERE rubric_id = %i ORDER BY rubric_field_position ASC, Id ASC', + (int) $rubricId + )->getAll() ?: array(); + $out = array(); + foreach ($rows as $row) { + $out[] = array( + 'id' => (int) $row['Id'], + 'source' => 'field:' . (int) $row['Id'], + 'title' => self::decode($row['rubric_field_title']), + 'alias' => (string) $row['rubric_field_alias'], + 'type' => (string) $row['rubric_field_type'], + 'format' => self::recommendedFormat((string) $row['rubric_field_type']), + ); + } + + return $out; + } + + public static function payload($rubricId) + { + $rubric = Model::one((int) $rubricId); + if (!$rubric) { + return null; + } + + $view = self::normalizeView(self::get((int) $rubricId), self::fields((int) $rubricId)); + $preview = DB::query( + 'SELECT Id, rubric_id, document_title, document_alias, document_status, document_deleted, document_changed' + . ' FROM ' . ContentTables::table('documents') + . ' WHERE rubric_id = %i ORDER BY document_changed DESC, Id DESC LIMIT 3', + (int) $rubricId + )->getAll() ?: array(); + foreach ($preview as &$document) { + $document['Id'] = (int) $document['Id']; + $document['document_title'] = self::decode($document['document_title']); + } + + unset($document); + $preview = self::previewDocuments(self::fields((int) $rubricId), $preview); + $preview = self::decorateDocuments($view, $preview); + return array( + 'rubric' => array('Id' => (int) $rubric['Id'], 'rubric_title' => (string) $rubric['rubric_title']), + 'view' => $view, + 'fields' => self::fields((int) $rubricId), + 'preview' => $preview, + ); + } + + public static function save($rubricId, array $input, $actorId) + { + $rubricId = (int) $rubricId; + $fields = self::fields($rubricId); + $items = isset($input['items']) ? $input['items'] : array(); + if (is_string($items)) { + $items = Json::toArray($items, array()); + } + + $view = self::normalizeView(array( + 'rubric_id' => $rubricId, + 'mode' => isset($input['mode']) ? $input['mode'] : 'standard', + 'items' => $items, + 'template' => isset($input['template']) ? $input['template'] : '', + ), $fields, true); + $config = Json::encode(array( + 'items' => $view['items'], + 'template' => $view['template'], + ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + DB::query( + 'INSERT INTO ' . self::table() . ' (rubric_id, mode, config_json, updated_by, updated_at)' + . ' VALUES (%i, %s, %s, %i, NOW()) ON DUPLICATE KEY UPDATE mode = VALUES(mode),' + . ' config_json = VALUES(config_json), updated_by = VALUES(updated_by), updated_at = VALUES(updated_at)', + $rubricId, + $view['mode'], + $config, + (int) $actorId + ); + AuditLog::record('rubric.admin_view_saved', array( + 'actor_id' => (int) $actorId > 0 ? (int) $actorId : null, + 'target_type' => 'rubric', + 'target_id' => $rubricId, + 'meta' => array('mode' => $view['mode'], 'items' => count($view['items'])), + )); + return $view; + } + + public static function forDocuments($rubricId, array $documents) + { + $rubricId = (int) $rubricId; + $view = self::normalizeView(self::get($rubricId), self::fields($rubricId)); + $view['active'] = $rubricId > 0 && $view['mode'] !== 'standard'; + $view['documents'] = $view['active'] ? self::decorateDocuments($view, $documents) : $documents; + return $view; + } + + protected static function normalizeView(array $view, array $fields, $strict = false) + { + foreach ($fields as $field) { + $fieldMap[$field['source']] = $field; + } + + $items = array(); + foreach (isset($view['items']) && is_array($view['items']) ? $view['items'] : array() as $item) { + if (!is_array($item) || empty($item['source']) || !isset($fieldMap[(string) $item['source']])) { + continue; + } + + $field = $fieldMap[(string) $item['source']]; + $format = isset($item['format']) ? (string) $item['format'] : $field['format']; + if (!in_array($format, array('text', 'badge', 'number', 'date', 'image'), true)) { + $format = $field['format']; + } + + $width = isset($item['width']) ? (string) $item['width'] : 'medium'; + if (!in_array($width, array('small', 'medium', 'wide'), true)) { + $width = 'medium'; + } + + $items[] = array( + 'source' => $field['source'], + 'field_id' => $field['id'], + 'label' => mb_substr(trim(isset($item['label']) ? (string) $item['label'] : $field['title']), 0, 80), + 'format' => $format, + 'width' => $width, + 'type' => $field['type'], + 'alias' => $field['alias'], + ); + if (count($items) >= self::MAX_ITEMS) { + break; + } + } + + $template = trim(isset($view['template']) ? (string) $view['template'] : ''); + if (strlen($template) > 20000) { + $template = substr($template, 0, 20000); + } + + if ($strict) { + self::assertSafeTemplate($template); + } + + return array( + 'rubric_id' => isset($view['rubric_id']) ? (int) $view['rubric_id'] : 0, + 'mode' => self::mode(isset($view['mode']) ? $view['mode'] : 'standard'), + 'items' => $items, + 'template' => $template, + 'updated_at' => isset($view['updated_at']) ? (string) $view['updated_at'] : '', + ); + } + + protected static function decorateDocuments(array $view, array $documents) + { + $docIds = array(); + $fieldIds = array(); + foreach ($documents as $document) { + $docIds[] = (int) $document['Id']; + } + + foreach ($view['items'] as $item) { + $fieldIds[] = (int) $item['field_id']; + } + + $values = array(); + if (!empty($docIds) && !empty($fieldIds)) { + $rows = DB::query( + 'SELECT df.document_id, df.rubric_field_id, df.field_value, dft.field_value AS field_value_more,' + . ' rf.rubric_field_type, rf.rubric_field_default, rf.rubric_field_settings' + . ' FROM ' . ContentTables::table('document_fields') . ' df' + . ' INNER JOIN ' . ContentTables::table('rubric_fields') . ' rf ON rf.Id = df.rubric_field_id' + . ' LEFT JOIN ' . ContentTables::table('document_fields_text') . ' dft' + . ' ON dft.document_id = df.document_id AND dft.rubric_field_id = df.rubric_field_id' + . ' WHERE df.document_id IN %li AND df.rubric_field_id IN %li', + array_values(array_unique($docIds)), + array_values(array_unique($fieldIds)) + )->getAll() ?: array(); + foreach ($rows as $row) { + $raw = (string) $row['field_value'] . (string) $row['field_value_more']; + $values[(int) $row['document_id']][(int) $row['rubric_field_id']] = self::displayValue($raw, $row); + } + } + + foreach ($documents as &$document) { + $document['admin_view_values'] = array(); + foreach ($view['items'] as $item) { + $document['admin_view_values'][] = array( + 'label' => $item['label'], + 'format' => $item['format'], + 'width' => $item['width'], + 'value' => isset($values[(int) $document['Id']][(int) $item['field_id']]) + ? $values[(int) $document['Id']][(int) $item['field_id']] + : array('text' => '', 'image' => ''), + ); + } + + $document['admin_view_html'] = $view['mode'] === 'custom' + ? self::renderTemplate($view['template'], $document, $view['items']) + : ''; + } + + unset($document); + return $documents; + } + + protected static function previewDocuments(array $fields, array $documents) + { + $docIds = array(); + $fieldIds = array(); + $fieldMap = array(); + foreach ($documents as $document) { $docIds[] = (int) $document['Id']; } + foreach ($fields as $field) { + $fieldIds[] = (int) $field['id']; + } + + $values = array(); + if (!empty($docIds) && !empty($fieldIds)) { + $rows = DB::query( + 'SELECT df.document_id, df.rubric_field_id, df.field_value, dft.field_value AS field_value_more,' + . ' rf.rubric_field_type, rf.rubric_field_default, rf.rubric_field_settings' + . ' FROM ' . ContentTables::table('document_fields') . ' df' + . ' INNER JOIN ' . ContentTables::table('rubric_fields') . ' rf ON rf.Id = df.rubric_field_id' + . ' LEFT JOIN ' . ContentTables::table('document_fields_text') . ' dft' + . ' ON dft.document_id = df.document_id AND dft.rubric_field_id = df.rubric_field_id' + . ' WHERE df.document_id IN %li AND df.rubric_field_id IN %li', + array_values(array_unique($docIds)), + array_values(array_unique($fieldIds)) + )->getAll() ?: array(); + foreach ($rows as $row) { + $fieldId = (int) $row['rubric_field_id']; + $raw = (string) $row['field_value'] . (string) $row['field_value_more']; + $values[(int) $row['document_id']]['field:' . $fieldId] = self::displayValue($raw, $row); + } + } + + foreach ($documents as &$document) { + $document['admin_field_values'] = isset($values[(int) $document['Id']]) + ? $values[(int) $document['Id']] + : array(); + } + + unset($document); + return $documents; + } + + protected static function displayValue($raw, array $field) + { + $type = (string) $field['rubric_field_type']; + $settings = FieldSettings::effective($field); + $text = trim((string) $raw); + if ($type === 'drop_down_key' && isset($settings['options']) && is_array($settings['options'])) { + foreach ($settings['options'] as $option) { + if (is_array($option) && isset($option['value']) && (string) $option['value'] === $text) { + $text = isset($option['label']) ? (string) $option['label'] : $text; + break; + } + } + } + + $decoded = ($text !== '' && ($text[0] === '[' || $text[0] === '{')) + ? Json::toArray($text, array()) + : array(); + if (!empty($decoded)) { + $flat = array(); + array_walk_recursive($decoded, function ($value) use (&$flat) { + if (is_scalar($value) && trim((string) $value) !== '') { $flat[] = trim((string) $value); } + }); + if (!empty($flat)) { $text = implode(', ', array_slice($flat, 0, 8)); } + } + + $image = self::imageFromValue($raw); + $text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags((string) $text), ENT_QUOTES, 'UTF-8'))); + if (mb_strlen($text) > 180) { $text = mb_substr($text, 0, 177) . '...'; } + return array('text' => $text, 'image' => $image); + } + + protected static function renderTemplate($template, array $document, array $items) + { + $template = trim((string) $template); + try { + self::assertSafeTemplate($template); + } catch (\RuntimeException $e) { + $template = ''; + } + + if ($template === '') { + $template = '[document:title]#[document:id] · [document:alias]'; + } + + $replace = array( + '[document:id]' => (string) (int) $document['Id'], + '[document:title]' => Str::escape(isset($document['document_title']) ? $document['document_title'] : ''), + '[document:alias]' => Str::escape(isset($document['document_alias']) ? $document['document_alias'] : ''), + ); + foreach ($items as $index => $item) { + $value = isset($document['admin_view_values'][$index]['value']['text']) ? $document['admin_view_values'][$index]['value']['text'] : ''; + $replace['[field:' . (int) $item['field_id'] . ']'] = Str::escape($value); + if ($item['alias'] !== '') { $replace['[field:' . $item['alias'] . ']'] = Str::escape($value); } + } + + return strtr($template, $replace); + } + + protected static function assertSafeTemplate($template) + { + if ($template === '') { return; } + if (preg_match('/<\?(?:php|=)?|<\s*(script|style|iframe|object|embed|form)\b|\son[a-z]+\s*=|javascript\s*:/i', $template)) { + throw new \RuntimeException('В административном шаблоне запрещены PHP, JavaScript и исполняемые HTML-элементы'); + } + } + + protected static function imageFromValue($raw) + { + if (preg_match('~(?:https?://[^\s"\']+|/[^\s"\']+)\.(?:jpe?g|png|gif|webp|avif)(?:\?[^\s"\']*)?~iu', (string) $raw, $match)) { + return $match[0]; + } + + return ''; + } + + protected static function recommendedFormat($type) + { + $type = (string) $type; + if (strpos($type, 'image') !== false || strpos($type, 'file') !== false) { return 'image'; } + if (strpos($type, 'date') !== false) { return 'date'; } + if (strpos($type, 'number') !== false || strpos($type, 'numeric') !== false) { return 'number'; } + if (strpos($type, 'checkbox') !== false || $type === 'boolean') { return 'badge'; } + return 'text'; + } + + protected static function mode($mode) + { + $mode = (string) $mode; + return in_array($mode, array('standard', 'table', 'cards', 'custom'), true) ? $mode : 'standard'; + } + + protected static function decode($value) + { + return htmlspecialchars_decode(stripcslashes((string) $value), ENT_QUOTES); + } + } diff --git a/adminx/modules/Rubrics/Controller.php b/adminx/modules/Rubrics/Controller.php new file mode 100644 index 0000000..45760e5 --- /dev/null +++ b/adminx/modules/Rubrics/Controller.php @@ -0,0 +1,1135 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\AuditLog; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Adminx\Support\CodeEditor; + use App\Adminx\Templates\Syntax; + use App\Adminx\Templates\TagRegistry; + use App\Helpers\Request; + use App\Content\Fields\FieldSettings; + use App\Content\Fields\FieldConditionEvaluator; + use App\Content\Fields\FieldSettingsForm; + use App\Content\Fields\FieldInstallationAudit; + use App\Content\Documents\DocumentAliasTemplate; + use App\Content\Rubrics\FieldSetRegistry; + use DB; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + AdminAssets::addStyle($this->base() . '/modules/Rubrics/assets/rubrics.css', 50); + AdminAssets::addScript($this->base() . '/modules/Rubrics/assets/rubrics.js', 50); + CodeEditor::useCodeMirror('application/x-httpd-php'); + + $q = Request::getStr('q', ''); + $state = Request::getStr('state', ''); + $typeUsage = Model::fieldTypeUsage(); + + return $this->render('@rubrics/index.twig', array( + 'rubrics' => Model::all($q, $state), + 'stats' => Model::stats(), + 'field_types' => FieldTypes::all(), + 'templates' => Model::templateOptions(), + 'field_type_usage' => $typeUsage, + 'field_type_summary' => FieldTypes::summary($typeUsage), + 'field_installation_audit' => FieldInstallationAudit::report(null, false, $typeUsage), + 'field_presets' => RubricFieldPresets::all(), + 'rubric_alias_tokens' => DocumentAliasTemplate::tokens(), + 'filters' => array('q' => $q, 'state' => $state), + 'can_manage' => Permission::check('manage_rubrics'), + 'can_view_documents' => Permission::check('view_documents'), + 'can_manage_documents' => Permission::check('manage_documents'), + )); + } + + public function templateTags(array $params = array()) + { + if (!Permission::check('view_rubrics')) { + return $this->error('Недостаточно прав', array(), 403); + } + + return $this->success('', array( + 'data' => array('groups' => class_exists(TagRegistry::class) ? TagRegistry::groups() : array()), + )); + } + + public function showRubric(array $params = array()) + { + $item = Model::one(isset($params['id']) ? (int) $params['id'] : 0); + if (!$item) { + return $this->error('Рубрика не найдена', array(), 404); + } + + return $this->success('', array('data' => $item)); + } + + public function fields(array $params = array()) + { + $id = isset($params['id']) ? (int) $params['id'] : 0; + $rubric = Model::one($id); + if (!$rubric) { + return $this->error('Рубрика не найдена', array(), 404); + } + + return $this->success('', array( + 'data' => array( + 'rubric' => $rubric, + 'groups' => Model::fieldGroups($id), + 'grouped_fields' => Model::groupedFields($id), + 'available_fields' => Model::availableFields($id), + 'field_types' => FieldTypes::all(), + 'field_set_links' => RubricFieldSetLinks::forRubric($id), + 'linked_field_sets_available' => RubricFieldSetLinks::available(), + ), + )); + } + + public function applyFieldSet(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + $code = isset($params['set']) ? trim((string) $params['set']) : ''; + if (!Model::one($rubricId)) { return $this->error('Рубрика не найдена', array(), 404); } + + DB::startTransaction(); + try { + $mode = Request::postStr('mode', 'copy') === 'linked' ? 'linked' : 'copy'; + if ($mode === 'linked' && RubricFieldSetLinks::hasLink($rubricId, $code)) { + throw new \RuntimeException('Этот набор уже связан с рубрикой. Используйте обновление в списке связанных наборов.'); + } + + $preview = RubricFieldPresets::preview($code, $rubricId); + $this->captureSchemaBefore($rubricId, 'Перед добавлением набора «' . $preview['title'] . '»'); + $result = RubricFieldPresets::apply($code, $rubricId, Auth::id()); + if ($mode === 'linked') { + $definition = RubricFieldPresets::definition($code); + RubricFieldSetLinks::attach($rubricId, $definition, $result['field_map'], Auth::id()); + } + + $this->captureSchemaAfter($rubricId, 'field_set', 'Добавлен набор «' . $preview['title'] . '»'); + DB::commit(); + } catch (\Throwable $e) { + DB::rollback(); + return $this->error($e->getMessage(), array(), 422); + } + + $message = $result['fields'] > 0 + ? 'Набор «' . $preview['title'] . '» добавлен' + : 'Все поля набора уже есть в рубрике'; + return $this->success($message, array('data' => array( + 'preview' => $preview, + 'created' => $result, + ))); + } + + public function previewFieldSet(array $params = array()) + { + if (!Permission::check('manage_rubrics')) { + return $this->error('Недостаточно прав', array(), 403); + } + + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + $code = isset($params['set']) ? trim((string) $params['set']) : ''; + if (!Model::one($rubricId)) { return $this->error('Рубрика не найдена', array(), 404); } + + try { + $preview = RubricFieldPresets::preview($code, $rubricId); + } catch (\RuntimeException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $preview['linked_available'] = RubricFieldSetLinks::available(); + return $this->success('', array('data' => $preview)); + } + + public function exportFieldSet(array $params = array()) + { + if (!Permission::check('manage_rubrics')) { + return $this->error('Недостаточно прав', array(), 403); + } + + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + try { + $descriptor = RubricFieldPresets::exportDescriptor($rubricId); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Набор полей подготовлен', array('data' => array( + 'descriptor' => $descriptor, + 'filename' => 'rubric-' . $rubricId . '-field-set.json', + ))); + } + + public function previewImportedFieldSet(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($rubricId)) { return $this->error('Рубрика не найдена', array(), 404); } + + try { + $descriptor = FieldSetRegistry::decodeDescriptor(Request::postStr('descriptor', '')); + $preview = RubricFieldPresets::previewDefinition($descriptor['field_set'], $rubricId); + $preview['linked_available'] = RubricFieldSetLinks::available(); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Набор проверен', array('data' => $preview)); + } + + public function applyImportedFieldSet(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($rubricId)) { return $this->error('Рубрика не найдена', array(), 404); } + + DB::startTransaction(); + try { + $descriptor = FieldSetRegistry::decodeDescriptor(Request::postStr('descriptor', '')); + $mode = Request::postStr('mode', 'copy') === 'linked' ? 'linked' : 'copy'; + $fingerprint = trim(Request::postStr('fingerprint', '')); + if (!preg_match('/^[a-f0-9]{64}$/', $fingerprint)) { + throw new \RuntimeException('Сначала выполните предварительный просмотр набора'); + } + + $preview = RubricFieldPresets::previewDefinition($descriptor['field_set'], $rubricId); + if (!hash_equals((string) $preview['fingerprint'], $fingerprint)) { + throw new \RuntimeException('Состав рубрики изменился после проверки. Выполните предварительный просмотр ещё раз.'); + } + + $this->captureSchemaBefore($rubricId, 'Перед импортом набора «' . $preview['title'] . '»'); + $syncingExisting = $mode === 'linked' + && RubricFieldSetLinks::hasLink($rubricId, $descriptor['field_set']['code']); + if ($syncingExisting) { + RubricFieldSetLinks::saveCustomDefinition($descriptor['field_set'], Auth::id()); + $syncPreview = RubricFieldSetLinks::preview($rubricId, $descriptor['field_set']['code']); + $result = RubricFieldSetLinks::sync( + $rubricId, + $descriptor['field_set']['code'], + $syncPreview['fingerprint'], + Auth::id() + ); + $this->captureSchemaAfter($rubricId, 'field_set_sync', 'Обновлён набор «' . $preview['title'] . '»'); + } else { + $result = RubricFieldPresets::applyDefinition( + $descriptor['field_set'], + $rubricId, + Auth::id(), + 'import:' . $descriptor['field_set']['code'], + $fingerprint + ); + if ($mode === 'linked') { + $definition = RubricFieldSetLinks::saveCustomDefinition($descriptor['field_set'], Auth::id()); + RubricFieldSetLinks::attach($rubricId, $definition, $result['field_map'], Auth::id()); + } + + $this->captureSchemaAfter($rubricId, 'field_set_import', 'Импортирован набор «' . $preview['title'] . '»'); + } + + DB::commit(); + } catch (\Throwable $e) { + DB::rollback(); + return $this->error($e->getMessage(), array(), 422); + } + + $message = !empty($syncingExisting) + ? 'Набор «' . $preview['title'] . '» обновлён и синхронизирован' + : (!empty($result['fields']) + ? 'Набор «' . $preview['title'] . '» импортирован' + : 'Все поля набора уже есть в рубрике'); + return $this->success($message, array('data' => array( + 'preview' => $preview, + 'created' => $result, + ))); + } + + public function previewFieldSetSync(array $params = array()) + { + if (!Permission::check('manage_rubrics')) { return $this->error('Недостаточно прав', array(), 403); } + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + $code = isset($params['set']) ? trim((string) $params['set']) : ''; + if (!Model::one($rubricId)) { return $this->error('Рубрика не найдена', array(), 404); } + + try { + $preview = RubricFieldSetLinks::preview($rubricId, $code); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('', array('data' => $preview)); + } + + public function syncFieldSet(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + $code = isset($params['set']) ? trim((string) $params['set']) : ''; + $fingerprint = trim(Request::postStr('fingerprint', '')); + if (!preg_match('/^[a-f0-9]{64}$/', $fingerprint)) { return $this->error('Сначала проверьте изменения набора', array(), 422); } + + DB::startTransaction(); + try { + $preview = RubricFieldSetLinks::preview($rubricId, $code); + $this->captureSchemaBefore($rubricId, 'Перед синхронизацией набора «' . $preview['title'] . '»'); + $result = RubricFieldSetLinks::sync($rubricId, $code, $fingerprint, Auth::id()); + $this->captureSchemaAfter($rubricId, 'field_set_sync', 'Синхронизирован набор «' . $preview['title'] . '»'); + DB::commit(); + } catch (\Throwable $e) { + DB::rollback(); + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Связанный набор синхронизирован', array('data' => $result)); + } + + public function detachFieldSet(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + $code = isset($params['set']) ? trim((string) $params['set']) : ''; + try { + RubricFieldSetLinks::detach($rubricId, $code, Auth::id()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Связь с набором удалена. Поля рубрики сохранены.'); + } + + public function showField(array $params = array()) + { + $field = Model::field(isset($params['field']) ? (int) $params['field'] : 0); + if (!$field) { + return $this->error('Поле не найдено', array(), 404); + } + + $settings = FieldSettings::effective($field); + $field['layout'] = array( + 'placed' => !array_key_exists('placed', $settings) || !empty($settings['placed']), + 'width' => isset($settings['width']) ? (string) $settings['width'] : '', + 'required' => !empty($settings['required']), + 'prefix' => isset($settings['prefix']) ? (string) $settings['prefix'] : '', + 'suffix' => isset($settings['suffix']) ? (string) $settings['suffix'] : '', + ); + return $this->success('', array('data' => $field)); + } + + public function showFieldType(array $params = array()) + { + $type = FieldTypes::one(isset($params['type']) ? (string) $params['type'] : ''); + if (!$type) { + return $this->error('Тип поля не найден', array(), 404); + } + + $type['settings_html'] = FieldSettingsForm::render((string) $type['id'], array()); + return $this->success('', array('data' => $type)); + } + + public function toggleFieldType(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $type = FieldTypes::setEnabled( + isset($params['type']) ? (string) $params['type'] : '', + Request::postBool('enabled', false), + Auth::id() + ); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success(!empty($type['enabled']) ? 'Тип поля включён' : 'Тип поля отключён', array( + 'data' => array( + 'type' => $type, + 'summary' => FieldTypes::summary(Model::fieldTypeUsage()), + ), + )); + } + + public function fieldPlugin(array $params = array()) + { + $field = Model::field(isset($params['field']) ? (int) $params['field'] : 0); + if (!$field) { + return $this->error('Поле не найдено', array(), 404); + } + + $type = FieldTypes::one((string) $field['rubric_field_type']); + if (!$type) { + return $this->success('', array( + 'data' => array( + 'field' => $field, + 'type' => null, + 'render' => array( + 'status' => 'error', + 'message' => 'Тип поля не зарегистрирован в системном FieldRegistry', + ), + ), + )); + } + + $type['settings_html'] = FieldSettingsForm::render( + (string) $field['rubric_field_type'], + FieldSettings::effective($field) + ); + return $this->success('', array( + 'data' => array( + 'field' => $field, + 'type' => $type, + 'render' => FieldAdapter::editPreview($field), + ), + )); + } + + public function templates(array $params = array()) + { + $id = isset($params['id']) ? (int) $params['id'] : 0; + $data = Model::templatesForRubric($id); + if (!$data) { + return $this->error('Рубрика не найдена', array(), 404); + } + + return $this->success('', array('data' => $data)); + } + + public function adminView(array $params = array()) + { + $data = AdminView::payload(isset($params['id']) ? (int) $params['id'] : 0); + if (!$data) { + return $this->error('Рубрика не найдена', array(), 404); + } + + return $this->success('', array('data' => $data)); + } + + public function updateAdminView(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($rubricId)) { + return $this->error('Рубрика не найдена', array(), 404); + } + + try { + $view = AdminView::save($rubricId, Request::postAll(), Auth::id()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Представление документов сохранено', array('data' => array('view' => $view))); + } + + public function storeRubric(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $errors = $this->validateRubric(Request::postAll(), 0); + if (!empty($errors)) { + return $this->error('Проверьте поля рубрики', $errors, 422); + } + + $preset = Request::postStr('rubric_preset', ''); + $presetError = RubricFieldPresets::availabilityError($preset); + if ($presetError !== '') { + return $this->error('Стартовый набор недоступен', array('rubric_preset' => $presetError), 422); + } + + DB::startTransaction(); + try { + $id = Model::saveRubric(0, Request::postAll(), Auth::id()); + $created = RubricFieldPresets::apply($preset, $id, Auth::id()); + $this->captureSchemaAfter($id, 'create', 'Создание рубрики и начальной схемы'); + DB::commit(); + } catch (\Throwable $e) { + DB::rollback(); + return $this->error($e->getMessage(), array(), 422); + } + + $message = $created['fields'] > 0 ? 'Рубрика и стартовый набор полей созданы' : 'Рубрика создана'; + return $this->success($message, array( + 'data' => array('id' => $id, 'preset' => $preset, 'created' => $created), + 'redirect' => $this->base() . '/rubrics', + )); + } + + public function updateRubric(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($id)) { + return $this->error('Рубрика не найдена', array(), 404); + } + + $errors = $this->validateRubric(Request::postAll(), $id); + if (!empty($errors)) { + return $this->error('Проверьте поля рубрики', $errors, 422); + } + + Model::saveRubric($id, Request::postAll(), Auth::id()); + return $this->success('Рубрика сохранена', array('redirect' => $this->base() . '/rubrics')); + } + + public function destroyRubric(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + Model::deleteRubric(isset($params['id']) ? (int) $params['id'] : 0); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Рубрика удалена'); + } + + public function storeField(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($rubricId)) { + return $this->error('Рубрика не найдена', array(), 404); + } + + $errors = $this->validateField(Request::postAll(), $rubricId, 0); + if (!empty($errors)) { + return $this->error('Проверьте поле', $errors, 422); + } + + try { + $this->captureSchemaBefore($rubricId, 'Перед созданием поля'); + $id = Model::saveField(0, $rubricId, Request::postAll()); + $this->captureSchemaAfter($rubricId, 'field_create', 'Создано поле #' . $id); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Поле создано', array('data' => array('id' => $id))); + } + + public function updateField(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $id = isset($params['field']) ? (int) $params['field'] : 0; + $field = Model::field($id); + if (!$field) { + return $this->error('Поле не найдено', array(), 404); + } + + $errors = $this->validateField(Request::postAll(), (int) $field['rubric_id'], $id); + if (!empty($errors)) { + return $this->error('Проверьте поле', $errors, 422); + } + + try { + $this->captureSchemaBefore((int) $field['rubric_id'], 'Перед изменением поля #' . $id); + Model::saveField($id, (int) $field['rubric_id'], Request::postAll()); + $this->captureSchemaAfter((int) $field['rubric_id'], 'field_update', 'Изменено поле #' . $id); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Поле сохранено'); + } + + public function destroyField(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $fieldId = isset($params['field']) ? (int) $params['field'] : 0; + $field = Model::field($fieldId); + try { + if ($field) { $this->captureSchemaBefore((int) $field['rubric_id'], 'Перед удалением поля #' . $fieldId); } + if (!Model::deleteField($fieldId)) { + return $this->error('Поле не найдено', array(), 404); + } + + $this->captureSchemaAfter((int) $field['rubric_id'], 'field_delete', 'Удалено поле #' . $fieldId); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Поле удалено'); + } + + public function updateMainTemplate(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($rubricId)) { + return $this->error('Рубрика не найдена', array(), 404); + } + + Model::saveMainTemplate($rubricId, Request::postAll()); + return $this->success('Шаблоны рубрики сохранены'); + } + + /** Проверка синтаксиса PHP в коде шаблона рубрики (mixed HTML/PHP). */ + public function lint(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $result = Syntax::check(Request::postStr('code', '')); + if (!$result['ok']) { + return $this->error($result['message'], array('code' => $result['output']), 422); + } + + return $this->success($result['message'], array('data' => $result)); + } + + public function showExtraTemplate(array $params = array()) + { + $item = Model::extraTemplate(isset($params['template']) ? (int) $params['template'] : 0); + if (!$item) { + return $this->error('Шаблон рубрики не найден', array(), 404); + } + + return $this->success('', array('data' => $item)); + } + + public function storeExtraTemplate(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($rubricId)) { + return $this->error('Рубрика не найдена', array(), 404); + } + + $errors = $this->validateExtraTemplate(Request::postAll()); + if (!empty($errors)) { + return $this->error('Проверьте шаблон', $errors, 422); + } + + $id = Model::saveExtraTemplate(0, $rubricId, Request::postAll(), Auth::id()); + return $this->success('Шаблон рубрики создан', array('data' => array('id' => $id))); + } + + public function updateExtraTemplate(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $id = isset($params['template']) ? (int) $params['template'] : 0; + if (!Model::extraTemplate($id)) { + return $this->error('Шаблон рубрики не найден', array(), 404); + } + + $errors = $this->validateExtraTemplate(Request::postAll()); + if (!empty($errors)) { + return $this->error('Проверьте шаблон', $errors, 422); + } + + Model::saveExtraTemplate($id, 0, Request::postAll(), Auth::id()); + return $this->success('Шаблон рубрики сохранён'); + } + + public function destroyExtraTemplate(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + if (!Model::deleteExtraTemplate(isset($params['template']) ? (int) $params['template'] : 0)) { + return $this->error('Шаблон рубрики не найден', array(), 404); + } + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Шаблон рубрики удалён'); + } + + public function storeGroup(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + $errors = $this->validateGroup(Request::postAll()); + if (!empty($errors)) { + return $this->error('Проверьте группу', $errors, 422); + } + + try { + $this->captureSchemaBefore($rubricId, 'Перед созданием группы'); + $id = Model::saveGroup(0, $rubricId, Request::postAll()); + $this->captureSchemaAfter($rubricId, 'group_create', 'Создана группа #' . $id); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Группа создана', array('data' => array('id' => $id))); + } + + public function updateGroup(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $errors = $this->validateGroup(Request::postAll()); + if (!empty($errors)) { + return $this->error('Проверьте группу', $errors, 422); + } + + $groupId = isset($params['group']) ? (int) $params['group'] : 0; + $group = Model::group($groupId); + if (!$group) { return $this->error('Группа не найдена', array(), 404); } + try { + $input = Request::postAll(); + $condition = array_key_exists('group_condition', $input) + ? Request::postJsonArray('group_condition') + : FieldConditionEvaluator::groupCondition($group); + if (!is_array($condition)) { return $this->error('Некорректное условие группы', array(), 422); } + $impact = FieldConditionImpact::previewGroup($groupId, $condition); + $fingerprint = trim(Request::postStr('impact_fingerprint')); + if (!empty($impact['requires_confirmation']) + && ($fingerprint === '' || !hash_equals((string) $impact['fingerprint'], $fingerprint))) { + return $this->error('Предпросмотр условия устарел. Проверьте изменение ещё раз.', array(), 409); + } + + $this->captureSchemaBefore((int) $group['rubric_id'], 'Перед изменением группы #' . $groupId); + Model::saveGroup($groupId, 0, Request::postAll()); + $this->captureSchemaAfter((int) $group['rubric_id'], 'group_update', 'Изменена группа #' . $groupId); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Группа сохранена', array('data' => array('impact' => $impact))); + } + + public function previewGroupCondition(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + $condition = Request::postJsonArray('group_condition'); + if (!is_array($condition)) { + return $this->error('Некорректное условие группы', array(), 422); + } + + try { + $impact = FieldConditionImpact::previewGroup( + isset($params['group']) ? (int) $params['group'] : 0, + $condition + ); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('', array('data' => array('impact' => $impact))); + } + + public function destroyGroup(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $groupId = isset($params['group']) ? (int) $params['group'] : 0; + $group = Model::group($groupId); + if ($group) { $this->captureSchemaBefore((int) $group['rubric_id'], 'Перед удалением группы #' . $groupId); } + if (!Model::deleteGroup($groupId)) { + return $this->error('Группа не найдена', array(), 404); + } + + $this->captureSchemaAfter((int) $group['rubric_id'], 'group_delete', 'Удалена группа #' . $groupId); + + return $this->success('Группа удалена'); + } + + public function reorderRubrics(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $order = Request::postJsonArray('order'); + if (!is_array($order)) { + return $this->error('Некорректный порядок рубрик', array(), 422); + } + + Model::reorderRubrics($order); + return $this->success('Порядок рубрик сохранён'); + } + + public function reorderFields(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $order = Request::postJsonArray('order'); + if (!is_array($order)) { + return $this->error('Некорректный порядок полей', array(), 422); + } + + try { + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + $this->captureSchemaBefore($rubricId, 'Перед изменением порядка полей'); + Model::reorderFields($rubricId, $order); + $this->captureSchemaAfter($rubricId, 'reorder', 'Изменён порядок полей'); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Порядок полей сохранён'); + } + + public function saveFieldBuilder(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $items = Request::postJsonArray('layout'); + $drafts = Request::postJsonArray('drafts'); + $conditionsEnabled = Request::postBool('conditions_enabled', false); + if (!is_array($items) || !is_array($drafts)) { + return $this->error('Некорректные данные конструктора', array(), 422); + } + + try { + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + $impact = FieldConditionImpact::preview($rubricId, $drafts, $conditionsEnabled); + $fingerprint = Request::postStr('impact_fingerprint', ''); + if (!empty($impact['requires_confirmation']) + && ($fingerprint === '' || !hash_equals((string) $impact['fingerprint'], $fingerprint))) { + return $this->error('Предпросмотр условий устарел. Проверьте изменения ещё раз.', array(), 409); + } + + $this->captureSchemaBefore($rubricId, 'Перед сохранением конструктора'); + Model::saveFieldBuilder($rubricId, $items, $drafts, $conditionsEnabled); + $this->captureSchemaAfter($rubricId, 'layout', 'Сохранён конструктор формы'); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Раскладка полей сохранена', array('data' => array('impact' => $impact))); + } + + public function previewFieldBuilder(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $drafts = Request::postJsonArray('drafts'); + if (!is_array($drafts)) { + return $this->error('Некорректные настройки полей', array(), 422); + } + + try { + $impact = FieldConditionImpact::preview( + isset($params['id']) ? (int) $params['id'] : 0, + $drafts, + Request::postBool('conditions_enabled', false) + ); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('', array('data' => array('impact' => $impact))); + } + + public function reorderGroups(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $order = Request::postJsonArray('order'); + if (!is_array($order)) { + return $this->error('Некорректный порядок групп', array(), 422); + } + + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + $this->captureSchemaBefore($rubricId, 'Перед изменением порядка групп'); + Model::reorderGroups($rubricId, $order); + $this->captureSchemaAfter($rubricId, 'reorder', 'Изменён порядок групп'); + return $this->success('Порядок групп сохранён'); + } + + public function schemaRevisions(array $params = array()) + { + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + $rubric = Model::one($rubricId); + if (!$rubric) { return $this->error('Рубрика не найдена', array(), 404); } + + return $this->success('', array('data' => array( + 'rubric' => array('id' => $rubricId, 'title' => $rubric['rubric_title']), + 'revisions' => RubricRevisions::listForRubric($rubricId), + ))); + } + + public function schemaRevision(array $params = array()) + { + $revisionId = isset($params['revision']) ? (int) $params['revision'] : 0; + $revision = RubricRevisions::one($revisionId); + if (!$revision) { return $this->error('Ревизия не найдена', array(), 404); } + + try { + $impact = RubricRevisions::previewRestore($revisionId); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + unset($revision['snapshot']); + return $this->success('', array('data' => array('revision' => $revision, 'impact' => $impact))); + } + + public function restoreSchemaRevision(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + try { + $result = RubricRevisions::restore( + isset($params['revision']) ? (int) $params['revision'] : 0, + Request::postStr('fingerprint', ''), + Auth::id() + ); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + AuditLog::record('rubric.schema_restored', array( + 'actor_id' => Auth::id(), + 'target_type' => 'rubric', + 'target_id' => isset($result['rubric_id']) ? (int) $result['rubric_id'] : null, + 'meta' => $result, + )); + return $this->success('Схема рубрики восстановлена', array('data' => $result)); + } + + public function deleteSchemaRevision(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + $rubricId = RubricRevisions::delete(isset($params['revision']) ? (int) $params['revision'] : 0); + if (!$rubricId) { return $this->error('Ревизия не найдена', array(), 404); } + + return $this->success('Ревизия удалена', array('data' => array('id' => $rubricId))); + } + + public function deleteSchemaRevisions(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + $rubricId = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($rubricId)) { return $this->error('Рубрика не найдена', array(), 404); } + $count = RubricRevisions::deleteForRubric($rubricId); + + return $this->success('Ревизии удалены', array('data' => array('id' => $rubricId, 'count' => $count))); + } + + protected function captureSchemaBefore($rubricId, $comment) + { + RubricRevisions::capture((int) $rubricId, 'baseline', Auth::id(), (string) $comment); + } + + protected function captureSchemaAfter($rubricId, $action, $comment) + { + RubricRevisions::capture((int) $rubricId, (string) $action, Auth::id(), (string) $comment); + } + + public function rubricAliasCheck(array $params = array()) + { + if (!Permission::check('manage_rubrics')) { + return $this->error('Недостаточно прав', array(), 403); + } + + $alias = trim(Request::getStr('alias', '')); + $id = Request::getInt('id', 0); + $result = $this->validateAlias($alias, $id, 'rubric'); + return $this->success($result['message'], array('data' => $result)); + } + + public function fieldAliasCheck(array $params = array()) + { + if (!Permission::check('manage_rubrics')) { + return $this->error('Недостаточно прав', array(), 403); + } + + $alias = trim(Request::getStr('alias', '')); + $rubricId = Request::getInt('rubric_id', 0); + $id = Request::getInt('id', 0); + $result = $this->validateFieldAlias($rubricId, $alias, $id); + return $this->success($result['message'], array('data' => $result)); + } + + public function documentPicker(array $params = array()) + { + if (!Permission::check('manage_rubrics')) { + return $this->error('Недостаточно прав', array(), 403); + } + + return $this->success('', array( + 'data' => array( + 'items' => Model::documentPicker( + Request::getStr('q', ''), + Request::getInt('rubric_id', 0), + Request::getInt('limit', 20) + ), + ), + )); + } + + protected function guard() + { + return $this->guardPermission('manage_rubrics'); + } + + protected function validateRubric(array $input, $id) + { + $errors = array(); + $title = trim(isset($input['rubric_title']) ? (string) $input['rubric_title'] : ''); + $alias = trim(isset($input['rubric_alias']) ? (string) $input['rubric_alias'] : ''); + if ($title === '') { + $errors['rubric_title'] = 'Укажите название'; + } + + $aliasState = $this->validateAlias($alias, (int) $id, 'rubric'); + if (!$aliasState['valid'] || !$aliasState['available']) { + $errors['rubric_alias'] = $aliasState['message']; + } + + foreach (array( + 'rubric_code_start' => 'Код до сохранения', + 'rubric_code_end' => 'Код после сохранения', + 'rubric_start_code' => 'Код публичного вывода', + ) as $key => $label) { + $code = isset($input[$key]) ? (string) $input[$key] : ''; + if (trim($code) === '') { + continue; + } + + $syntax = Syntax::check($code); + if (!$syntax['ok']) { + $errors[$key] = $label . ': ' . ($syntax['output'] !== '' ? $syntax['output'] : $syntax['message']); + } + } + + return $errors; + } + + protected function validateField(array $input, $rubricId, $id) + { + $errors = array(); + $title = trim(isset($input['rubric_field_title']) ? (string) $input['rubric_field_title'] : ''); + $alias = trim(isset($input['rubric_field_alias']) ? (string) $input['rubric_field_alias'] : ''); + $type = trim(isset($input['rubric_field_type']) ? (string) $input['rubric_field_type'] : ''); + if ($title === '') { + $errors['rubric_field_title'] = 'Укажите название поля'; + } + + if (!FieldTypes::exists($type)) { + $errors['rubric_field_type'] = 'Выберите тип поля'; + } elseif (!FieldTypes::isEnabled($type)) { + $existing = (int) $id > 0 ? Model::field((int) $id) : null; + if (!$existing || (string) $existing['rubric_field_type'] !== $type) { + $errors['rubric_field_type'] = 'Этот тип поля отключён в реестре'; + } + } + + $aliasState = $this->validateFieldAlias($rubricId, $alias, (int) $id); + if (!$aliasState['valid'] || !$aliasState['available']) { + $errors['rubric_field_alias'] = $aliasState['message']; + } + + return $errors; + } + + protected function validateGroup(array $input) + { + $title = trim(isset($input['group_title']) ? (string) $input['group_title'] : ''); + return $title === '' ? array('group_title' => 'Укажите название группы') : array(); + } + + protected function validateExtraTemplate(array $input) + { + $title = trim(isset($input['title']) ? (string) $input['title'] : ''); + return $title === '' ? array('title' => 'Укажите название шаблона') : array(); + } + + protected function validateAlias($alias, $id, $kind) + { + $result = array('alias' => $alias, 'valid' => true, 'available' => true, 'message' => 'Алиас свободен'); + if ($alias === '') { + $result['message'] = 'Без отдельного URL-алиаса'; + } elseif (($templateError = DocumentAliasTemplate::validationError($alias)) !== '') { + $result['valid'] = false; + $result['available'] = false; + $result['message'] = $templateError; + } elseif ($kind === 'rubric' && Model::rubricAliasExists($alias, (int) $id)) { + $result['available'] = false; + $result['message'] = 'Такой алиас уже используется'; + } + + return $result; + } + + protected function validateFieldAlias($rubricId, $alias, $id) + { + $result = array('alias' => $alias, 'valid' => true, 'available' => true, 'message' => 'Алиас свободен'); + if ($alias === '') { + $result['valid'] = false; + $result['available'] = false; + $result['message'] = 'Укажите алиас'; + } elseif (preg_match('/^[A-Za-z][A-Za-z0-9_]{0,19}$/', $alias) !== 1) { + $result['valid'] = false; + $result['available'] = false; + $result['message'] = 'Латинская буква первой, далее буквы, цифры или подчёркивание, до 20 символов'; + } elseif (Model::fieldAliasExists((int) $rubricId, $alias, (int) $id)) { + $result['available'] = false; + $result['message'] = 'Такой алиас уже есть в этой рубрике'; + } + + return $result; + } + } diff --git a/adminx/modules/Rubrics/FieldAdapter.php b/adminx/modules/Rubrics/FieldAdapter.php new file mode 100644 index 0000000..f7f0c18 --- /dev/null +++ b/adminx/modules/Rubrics/FieldAdapter.php @@ -0,0 +1,125 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Content\Fields\FieldContext; + use App\Content\Fields\FieldRegistry; + + class FieldAdapter + { + public static function editPreview(array $field) + { + $type = FieldTypes::one((string) $field['rubric_field_type']); + if (!$type) { + return self::result('error', 'Тип поля не зарегистрирован', '', array()); + } + + $plugin = FieldRegistry::get((string) $field['rubric_field_type']); + if (!$plugin) { + return self::result('error', 'Native-тип поля не зарегистрирован', '', array()); + } + + try { + $context = new FieldContext((string) $field['rubric_field_default'], $field, 'edit'); + $html = $plugin->renderEdit($context); + + $html = is_string($html) ? trim($html) : ''; + if ($html === '') { + return self::result('warning', 'Native-тип не вернул HTML для режима edit', '', array()); + } + + $normalized = self::normalizeHtml($html); + $html = $normalized['html']; + return self::result('ok', 'Native edit-render выполнен через FieldRegistry.', $html, array(), $normalized['notes']); + } catch (\Throwable $e) { + return self::result('error', $e->getMessage(), '', array()); + } + } + + protected static function normalizeHtml($html) + { + $html = (string) $html; + $notes = array(); + + $scriptCount = preg_match_all('#]*>.*?#is', $html, $m); + if ($scriptCount > 0) { + $html = preg_replace('#]*>.*?#is', '', $html); + $notes[] = 'Preview: legacy script-теги отключены (' . (int) $scriptCount . ').'; + } + + $handlerCount = preg_match_all('#\s+on[a-z]+\s*=\s*(".*?"|\'.*?\'|[^\s>]+)#is', $html, $m); + if ($handlerCount > 0) { + $html = preg_replace('#\s+on[a-z]+\s*=\s*(".*?"|\'.*?\'|[^\s>]+)#is', '', $html); + $notes[] = 'Preview: inline JS-обработчики удалены (' . (int) $handlerCount . ').'; + } + + $rewrites = 0; + $html = preg_replace_callback('/\b(href|src|action)\s*=\s*([\'"])(.*?)\2/i', function ($m) use (&$rewrites) { + $url = trim((string) $m[3]); + $normalized = self::normalizeUrl($url); + if ($normalized !== $url) { + $rewrites++; + } + + return $m[1] . '=' . $m[2] . htmlspecialchars($normalized, ENT_QUOTES, 'UTF-8') . $m[2]; + }, $html); + + $rootAssets = preg_match_all('#\b(?:href|src|action)\s*=\s*([\'"])/(admin|class|fields|images|lib|modules|templates|tmp|uploads)/#i', $html, $m); + if ($rewrites > 0) { + $notes[] = 'Preview: legacy asset-пути приведены к корню сайта (' . (int) $rewrites . ').'; + } elseif ($rootAssets > 0) { + $notes[] = 'Preview: legacy asset-пути используют корень сайта.'; + } + + return array('html' => $html, 'notes' => $notes); + } + + protected static function normalizeUrl($url) + { + $url = (string) $url; + $trimmed = trim($url); + if ($trimmed === '' || $trimmed[0] === '#' || $trimmed[0] === '/') { + return $url; + } + + if (preg_match('#^(https?:)?//#i', $trimmed) || preg_match('#^(mailto|tel|data):#i', $trimmed)) { + return $url; + } + + if (preg_match('#^javascript:#i', $trimmed)) { + return '#'; + } + + $path = preg_replace('#^\./#', '', $trimmed); + if (preg_match('#^(admin|class|fields|images|lib|modules|templates|tmp|uploads)/#i', $path)) { + return '/' . ltrim($path, '/'); + } + + return $url; + } + + protected static function result($status, $message, $html, array $assets, array $notes = array()) + { + return array( + 'status' => $status, + 'message' => $message, + 'html' => $html, + 'assets' => array_values($assets), + 'notes' => array_values(array_unique($notes)), + ); + } + } diff --git a/adminx/modules/Rubrics/FieldAdminEditors.php b/adminx/modules/Rubrics/FieldAdminEditors.php new file mode 100644 index 0000000..b6cd10f --- /dev/null +++ b/adminx/modules/Rubrics/FieldAdminEditors.php @@ -0,0 +1,169 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Adminx\Rubrics\FieldEditors\ChoiceFieldEditor; + use App\Adminx\Rubrics\FieldEditors\FieldEditorInterface; + use App\Adminx\Rubrics\FieldEditors\ListFieldEditor; + use App\Adminx\Rubrics\FieldEditors\MediaFieldEditor; + use App\Adminx\Rubrics\FieldEditors\RelationFieldEditor; + use App\Adminx\Rubrics\FieldEditors\SimpleFieldEditor; + use App\Content\Fields\FieldSettings; + + class FieldAdminEditors + { + protected static $editors = null; + + public static function describe($type) + { + $editor = self::editorFor($type); + if (!$editor) { + return self::unavailable($type); + } + + $description = $editor->describe($type); + if (FieldSettings::legacyDefaultIsConfiguration($type)) { + $description['controls'] = array_values(array_filter( + isset($description['controls']) ? $description['controls'] : array(), + function ($control) { + return !isset($control['name']) || $control['name'] !== 'rubric_field_default'; + } + )); + $description['default_is_configuration'] = true; + } + + return $description; + } + + public static function normalizeDefault($type, $value) + { + $editor = self::editorFor($type); + if (!$editor) { + throw new \RuntimeException('Для типа поля не зарегистрирован admin-редактор: ' . (string) $type); + } + + return $editor->normalizeDefault($type, $value); + } + + public static function parseValue($type, $value) + { + $editor = self::editorFor($type); + if (!$editor) { + throw new \RuntimeException('Для типа поля не зарегистрирован admin-редактор: ' . (string) $type); + } + + return $editor->parseValue($type, $value); + } + + public static function serializeValue($type, $value) + { + $editor = self::editorFor($type); + if (!$editor) { + throw new \RuntimeException('Для типа поля не зарегистрирован admin-редактор: ' . (string) $type); + } + + return $editor->serializeValue($type, $value); + } + + public static function renderEdit($type, array $field) + { + $editor = self::editorFor($type); + if (!$editor) { + throw new \RuntimeException('Для типа поля не зарегистрирован admin-редактор: ' . (string) $type); + } + + return $editor->renderEdit($field); + } + + public static function knownTypes() + { + $types = array(); + foreach (self::editors() as $editor) { + foreach (self::editorTypes($editor) as $type) { + $types[] = $type; + } + } + + sort($types, SORT_NATURAL | SORT_FLAG_CASE); + return array_values(array_unique($types)); + } + + protected static function editorFor($type) + { + foreach (self::editors() as $editor) { + if ($editor->supports($type)) { + return $editor; + } + } + + return null; + } + + protected static function editors() + { + if (self::$editors !== null) { + return self::$editors; + } + + self::loadEditorClasses(); + self::$editors = array( + new SimpleFieldEditor(), + new ChoiceFieldEditor(), + new ListFieldEditor(), + new MediaFieldEditor(), + new RelationFieldEditor(), + ); + return self::$editors; + } + + protected static function loadEditorClasses() + { + $files = array( + 'FieldEditorInterface.php', + 'AbstractFieldEditor.php', + 'SimpleFieldEditor.php', + 'ChoiceFieldEditor.php', + 'ListFieldEditor.php', + 'MediaFieldEditor.php', + 'RelationFieldEditor.php', + ); + foreach ($files as $file) { + require_once __DIR__ . '/FieldEditors/' . $file; + } + } + + protected static function editorTypes(FieldEditorInterface $editor) + { + $ref = new \ReflectionClass($editor); + $method = $ref->getMethod('definitions'); + $method->setAccessible(true); + $map = $method->invoke($editor); + return is_array($map) ? array_keys($map) : array(); + } + + protected static function unavailable($type) + { + return array( + 'title' => (string) $type, + 'icon' => 'plug', + 'status' => 'error', + 'summary' => 'Для типа не зарегистрирован native admin-редактор.', + 'type' => (string) $type, + 'controls' => array(), + ); + } + } diff --git a/adminx/modules/Rubrics/FieldConditionImpact.php b/adminx/modules/Rubrics/FieldConditionImpact.php new file mode 100644 index 0000000..7b704ab --- /dev/null +++ b/adminx/modules/Rubrics/FieldConditionImpact.php @@ -0,0 +1,444 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Content\Fields\FieldConditionEvaluator; + use App\Content\Fields\FieldSettings; + use App\Content\Fields\FieldSettingsForm; + use App\Helpers\Json; + use DB; + + /** Calculates document-form impact before conditional rules are persisted. */ + class FieldConditionImpact + { + const MAX_DOCUMENTS = 5000; + + public static function preview($rubricId, array $drafts, $enabled) + { + $rubricId = (int) $rubricId; + $rubric = Model::one($rubricId); + if (!$rubric) { + throw new \RuntimeException('Рубрика не найдена'); + } + + $enabled = (bool) $enabled; + $enabledBefore = !empty($rubric['form_conditions_enabled']); + $before = self::fieldMap(Model::fieldsForRubric($rubricId)); + $after = self::proposedFields($before, $drafts); + $targets = self::changedTargets($before, $after, $enabledBefore, $enabled); + return self::analyzeTransition($rubricId, $before, $after, $targets, $enabledBefore, $enabled); + } + + public static function previewGroup($groupId, $condition) + { + $groupId = (int) $groupId; + $group = Model::group($groupId); + if (!$group) { throw new \RuntimeException('Группа не найдена'); } + $rubricId = (int) $group['rubric_id']; + $rubric = Model::one($rubricId); + if (!$rubric) { throw new \RuntimeException('Рубрика не найдена'); } + + $enabled = !empty($rubric['form_conditions_enabled']); + $before = self::fieldMap(Model::fieldsForRubric($rubricId)); + $after = self::proposedGroupFields($before, $groupId, $condition); + $targets = array(); + if ($enabled && self::groupConditionKey($before, $groupId) !== self::groupConditionKey($after, $groupId)) { + foreach ($before as $fieldId => $field) { + if ((int) $field['rubric_field_group'] === $groupId) { $targets[] = (int) $fieldId; } + } + } + + $result = self::analyzeTransition($rubricId, $before, $after, $targets, $enabled, $enabled); + $result['scope'] = 'group'; + $result['group_id'] = $groupId; + $result['group_title'] = isset($group['group_title']) ? (string) $group['group_title'] : ''; + return $result; + } + + protected static function analyzeTransition($rubricId, array $before, array $after, array $targets, $enabledBefore, $enabledAfter) + { + $result = array( + 'enabled_before' => $enabledBefore, + 'enabled_after' => $enabledAfter, + 'conditions_before' => self::conditionCount($before), + 'conditions_after' => self::conditionCount($after), + 'changed_fields' => count($targets), + 'documents_total' => 0, + 'documents_analyzed' => 0, + 'documents_changed' => 0, + 'hidden_before' => 0, + 'hidden_after' => 0, + 'required_before' => 0, + 'required_after' => 0, + 'locked_before' => 0, + 'locked_after' => 0, + 'limited_before' => 0, + 'limited_after' => 0, + 'action_before' => 0, + 'action_after' => 0, + 'truncated' => false, + 'details' => array(), + 'requires_confirmation' => !empty($targets), + 'fingerprint' => self::fingerprintFromFields($rubricId, $before, $after, $enabledBefore, $enabledAfter), + ); + if (empty($targets)) { + return $result; + } + + $total = (int) DB::query( + "SELECT COUNT(*) FROM " . Model::docsTable() . " WHERE rubric_id=%i AND document_deleted!='1'", + $rubricId + )->getValue(); + $documents = DB::query( + "SELECT Id FROM " . Model::docsTable() . " WHERE rubric_id=%i AND document_deleted!='1' ORDER BY Id LIMIT " . self::MAX_DOCUMENTS, + $rubricId + )->getAll() ?: array(); + $result['documents_total'] = $total; + $result['documents_analyzed'] = count($documents); + $result['truncated'] = $total > count($documents); + $documentIds = array_map(function ($row) { return (int) $row['Id']; }, $documents); + $neededFields = self::neededFieldIds($targets, $before, $after); + $values = self::documentValues($rubricId, $documentIds, $neededFields); + $beforeEvaluation = self::evaluationFields($before, $neededFields); + $afterEvaluation = self::evaluationFields($after, $neededFields); + $details = array(); + foreach ($targets as $fieldId) { + $field = isset($after[$fieldId]) ? $after[$fieldId] : $before[$fieldId]; + $details[$fieldId] = array( + 'id' => $fieldId, + 'title' => (string) $field['rubric_field_title'], + 'alias' => (string) $field['rubric_field_alias'], + 'changed_documents' => 0, + 'hidden_before' => 0, + 'hidden_after' => 0, + 'required_before' => 0, + 'required_after' => 0, + 'locked_before' => 0, + 'locked_after' => 0, + 'limited_before' => 0, + 'limited_after' => 0, + 'action_before' => 0, + 'action_after' => 0, + ); + } + + $changedDocuments = array(); + foreach ($documentIds as $documentId) { + $documentValues = isset($values[$documentId]) ? $values[$documentId] : array(); + $beforeStates = $enabledBefore ? FieldConditionEvaluator::states($beforeEvaluation, $documentValues) : array(); + $afterStates = $enabledAfter ? FieldConditionEvaluator::states($afterEvaluation, $documentValues) : array(); + foreach ($targets as $fieldId) { + $beforeState = $enabledBefore && isset($beforeStates[$fieldId]) + ? $beforeStates[$fieldId] + : self::defaultState(isset($before[$fieldId]) ? $before[$fieldId] : array()); + $afterState = $enabledAfter && isset($afterStates[$fieldId]) + ? $afterStates[$fieldId] + : self::defaultState(isset($after[$fieldId]) ? $after[$fieldId] : array()); + $visibleBefore = !empty($beforeState['visible']); + $visibleAfter = !empty($afterState['visible']); + $requiredBefore = !empty($beforeState['required']); + $requiredAfter = !empty($afterState['required']); + $lockedBefore = !empty($beforeState['locked']); + $lockedAfter = !empty($afterState['locked']); + $allowedBefore = isset($beforeState['allowed_values']) && is_array($beforeState['allowed_values']) + ? array_values($beforeState['allowed_values']) : array(); + $allowedAfter = isset($afterState['allowed_values']) && is_array($afterState['allowed_values']) + ? array_values($afterState['allowed_values']) : array(); + $limitedBefore = !empty($allowedBefore); + $limitedAfter = !empty($allowedAfter); + $actionBefore = isset($beforeState['value_action']) ? (string) $beforeState['value_action'] : ''; + $actionAfter = isset($afterState['value_action']) ? (string) $afterState['value_action'] : ''; + $actionValueBefore = isset($beforeState['action_value']) ? (string) $beforeState['action_value'] : ''; + $actionValueAfter = isset($afterState['action_value']) ? (string) $afterState['action_value'] : ''; + if (!$visibleBefore) { $details[$fieldId]['hidden_before']++; $result['hidden_before']++; } + if (!$visibleAfter) { $details[$fieldId]['hidden_after']++; $result['hidden_after']++; } + if ($requiredBefore) { $details[$fieldId]['required_before']++; $result['required_before']++; } + if ($requiredAfter) { $details[$fieldId]['required_after']++; $result['required_after']++; } + if ($lockedBefore) { $details[$fieldId]['locked_before']++; $result['locked_before']++; } + if ($lockedAfter) { $details[$fieldId]['locked_after']++; $result['locked_after']++; } + if ($limitedBefore) { $details[$fieldId]['limited_before']++; $result['limited_before']++; } + if ($limitedAfter) { $details[$fieldId]['limited_after']++; $result['limited_after']++; } + if ($actionBefore !== '') { $details[$fieldId]['action_before']++; $result['action_before']++; } + if ($actionAfter !== '') { $details[$fieldId]['action_after']++; $result['action_after']++; } + if ($visibleBefore !== $visibleAfter || $requiredBefore !== $requiredAfter || $lockedBefore !== $lockedAfter + || $allowedBefore !== $allowedAfter || $actionBefore !== $actionAfter || $actionValueBefore !== $actionValueAfter) { + $details[$fieldId]['changed_documents']++; + $changedDocuments[$documentId] = true; + } + } + } + + $result['documents_changed'] = count($changedDocuments); + $result['details'] = array_values($details); + return $result; + } + + public static function fingerprint($rubricId, array $drafts, $enabled) + { + $rubric = Model::one((int) $rubricId); + if (!$rubric) { throw new \RuntimeException('Рубрика не найдена'); } + $before = self::fieldMap(Model::fieldsForRubric((int) $rubricId)); + $after = self::proposedFields($before, $drafts); + return self::fingerprintFromFields( + (int) $rubricId, + $before, + $after, + !empty($rubric['form_conditions_enabled']), + (bool) $enabled + ); + } + + protected static function proposedFields(array $fields, array $drafts) + { + foreach ($drafts as $draft) { + if (!is_array($draft) || empty($draft['id']) || !isset($draft['settings']) || !is_array($draft['settings'])) { continue; } + $fieldId = (int) $draft['id']; + if (!isset($fields[$fieldId])) { continue; } + $settings = FieldSettings::effective($fields[$fieldId]); + $type = (string) $fields[$fieldId]['rubric_field_type']; + foreach (FieldSettingsForm::keys($type) as $key) { unset($settings[$key]); } + $settings = array_replace($settings, FieldSettingsForm::collect($type, $draft['settings'])); + $fields[$fieldId]['rubric_field_settings'] = FieldSettings::encode($settings); + } + + foreach ($drafts as $draft) { + if (!is_array($draft) || empty($draft['id'])) { continue; } + $fieldId = (int) $draft['id']; + if (!isset($fields[$fieldId]) || !array_key_exists('condition', $draft)) { continue; } + $settings = FieldSettings::effective($fields[$fieldId]); + $condition = FieldConditionEvaluator::normalize($draft['condition'], array_values($fields), $fieldId); + if ($condition) { $settings['form_condition'] = $condition; } + else { unset($settings['form_condition']); } + $fields[$fieldId]['rubric_field_settings'] = FieldSettings::encode($settings); + } + + return $fields; + } + + protected static function proposedGroupFields(array $fields, $groupId, $condition) + { + $normalized = FieldConditionEvaluator::normalize($condition, array_values($fields)); + foreach ($fields as &$field) { + if ((int) $field['rubric_field_group'] !== (int) $groupId) { continue; } + $settings = Json::toArray(isset($field['group_settings']) ? $field['group_settings'] : ''); + if ($normalized) { $settings['form_condition'] = $normalized; } + else { unset($settings['form_condition']); } + $field['group_settings'] = Json::payload($settings); + } + + unset($field); + return $fields; + } + + protected static function changedTargets(array $before, array $after, $enabledBefore, $enabledAfter) + { + if (!$enabledBefore && !$enabledAfter) { + return array(); + } + + $targets = array(); + foreach ($before as $fieldId => $field) { + $old = FieldConditionEvaluator::condition($field); + $new = isset($after[$fieldId]) ? FieldConditionEvaluator::condition($after[$fieldId]) : array(); + if (self::conditionKey($old) !== self::conditionKey($new) + || ($enabledBefore !== $enabledAfter && (!empty($old) || !empty($new)))) { + $targets[] = (int) $fieldId; + } + } + + return $targets; + } + + protected static function neededFieldIds(array $targets, array $before, array $after) + { + $aliases = array(); + foreach ($before as $fieldId => $field) { + $alias = strtolower(trim((string) $field['rubric_field_alias'])); + if ($alias !== '') { $aliases[$alias] = (int) $fieldId; } + } + + $ids = array_fill_keys($targets, true); + foreach ($targets as $fieldId) { + foreach (array($before, $after) as $fields) { + $field = isset($fields[$fieldId]) ? $fields[$fieldId] : array(); + foreach (array(FieldConditionEvaluator::condition($field), FieldConditionEvaluator::groupCondition($field)) as $condition) { + self::collectReferences(isset($condition['tree']) ? $condition['tree'] : array(), $aliases, $ids); + } + } + } + + return array_map('intval', array_keys($ids)); + } + + protected static function collectReferences(array $node, array $aliases, array &$ids) + { + if (isset($node['items']) && is_array($node['items'])) { + foreach ($node['items'] as $item) { + if (is_array($item)) { self::collectReferences($item, $aliases, $ids); } + } + + return; + } + + $reference = isset($node['field']) ? strtolower((string) $node['field']) : ''; + if (strpos($reference, 'id:') === 0) { + $id = (int) substr($reference, 3); + if ($id > 0) { $ids[$id] = true; } + } elseif (strpos($reference, 'alias:') === 0) { + $alias = substr($reference, 6); + if (isset($aliases[$alias])) { $ids[$aliases[$alias]] = true; } + } + } + + protected static function documentValues($rubricId, array $documentIds, array $fieldIds) + { + $values = array(); + foreach ($documentIds as $documentId) { + $values[$documentId] = array_fill_keys($fieldIds, ''); + } + + if (empty($documentIds) || empty($fieldIds)) { return $values; } + + $rows = DB::query( + 'SELECT df.document_id,df.rubric_field_id,df.field_value,COALESCE(dft.field_value,\'\') more' + . ' FROM ' . Model::docFieldsTable() . ' df' + . ' INNER JOIN ' . Model::docsTable() . ' d ON d.Id=df.document_id' + . ' LEFT JOIN ' . Model::docFieldsTextTable() . ' dft ON dft.document_id=df.document_id AND dft.rubric_field_id=df.rubric_field_id' + . " WHERE d.rubric_id=%i AND d.document_deleted!='1' AND df.document_id IN %li AND df.rubric_field_id IN %li", + (int) $rubricId, + $documentIds, + $fieldIds + )->getAll() ?: array(); + foreach ($rows as $row) { + $values[(int) $row['document_id']][(int) $row['rubric_field_id']] = (string) $row['field_value'] . (string) $row['more']; + } + + return $values; + } + + protected static function evaluationFields(array $fields, array $ids) + { + $out = array(); + foreach ($ids as $id) { + if (isset($fields[$id])) { $out[] = $fields[$id]; } + } + + return $out; + } + + protected static function defaultState(array $field) + { + $settings = FieldSettings::effective($field); + return array( + 'visible' => true, + 'required' => !empty($settings['required']), + 'locked' => false, + 'allowed_values' => null, + 'value_action' => null, + 'action_value' => null, + ); + } + + protected static function conditionCount(array $fields) + { + $count = 0; + foreach ($fields as $field) { + if (FieldConditionEvaluator::condition($field)) { $count++; } + } + + return $count; + } + + protected static function fingerprintFromFields($rubricId, array $before, array $after, $enabledBefore, $enabledAfter) + { + $payload = array( + 'rubric_id' => (int) $rubricId, + 'enabled_before' => (bool) $enabledBefore, + 'enabled_after' => (bool) $enabledAfter, + 'before' => self::conditions($before), + 'after' => self::conditions($after), + 'before_groups' => self::groupConditions($before), + 'after_groups' => self::groupConditions($after), + 'documents' => self::documentSnapshot((int) $rubricId), + ); + return hash('sha256', Json::encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + protected static function documentSnapshot($rubricId) + { + $row = DB::query( + "SELECT COUNT(*) count,COALESCE(MAX(Id),0) max_id,COALESCE(MAX(document_changed),0) changed" + . " FROM " . Model::docsTable() . " WHERE rubric_id=%i AND document_deleted!='1'", + (int) $rubricId + )->getAssoc() ?: array(); + + return array( + 'count' => isset($row['count']) ? (int) $row['count'] : 0, + 'max_id' => isset($row['max_id']) ? (int) $row['max_id'] : 0, + 'changed' => isset($row['changed']) ? (int) $row['changed'] : 0, + ); + } + + protected static function conditions(array $fields) + { + $out = array(); + foreach ($fields as $fieldId => $field) { + $condition = FieldConditionEvaluator::condition($field); + if ($condition) { $out[(string) $fieldId] = $condition; } + } + + ksort($out, SORT_NUMERIC); + return $out; + } + + protected static function groupConditions(array $fields) + { + $out = array(); + foreach ($fields as $field) { + $groupId = isset($field['rubric_field_group']) ? (int) $field['rubric_field_group'] : 0; + if ($groupId <= 0 || isset($out[(string) $groupId])) { continue; } + $condition = FieldConditionEvaluator::groupCondition($field); + if ($condition) { $out[(string) $groupId] = $condition; } + } + + ksort($out, SORT_NUMERIC); + return $out; + } + + protected static function groupConditionKey(array $fields, $groupId) + { + foreach ($fields as $field) { + if ((int) $field['rubric_field_group'] === (int) $groupId) { + return self::conditionKey(FieldConditionEvaluator::groupCondition($field)); + } + } + + return self::conditionKey(array()); + } + + protected static function conditionKey(array $condition) + { + return Json::encode($condition, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + protected static function fieldMap(array $fields) + { + $out = array(); + foreach ($fields as $field) { $out[(int) $field['Id']] = $field; } + return $out; + } + } diff --git a/adminx/modules/Rubrics/FieldEditors/AbstractFieldEditor.php b/adminx/modules/Rubrics/FieldEditors/AbstractFieldEditor.php new file mode 100644 index 0000000..06a613f --- /dev/null +++ b/adminx/modules/Rubrics/FieldEditors/AbstractFieldEditor.php @@ -0,0 +1,185 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics\FieldEditors; + + use App\Content\Fields\FieldValueCodec; + use App\Content\Fields\FieldSettings; + use App\Helpers\Str; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + abstract class AbstractFieldEditor implements FieldEditorInterface + { + public function supports($type) + { + $map = $this->definitions(); + return isset($map[(string) $type]); + } + + public function describe($type) + { + $type = (string) $type; + $map = $this->definitions(); + if (!isset($map[$type])) { + throw new \RuntimeException('Для типа поля не зарегистрирован native admin-редактор: ' . $type); + } + + $editor = $map[$type]; + $editor['type'] = $type; + $editor['status'] = isset($editor['status']) ? $editor['status'] : 'native'; + $editor['controls'] = $this->controls($editor); + $editor['entity_fields'] = isset($editor['entity_fields']) && is_array($editor['entity_fields']) ? array_values($editor['entity_fields']) : array(); + $editor['default_is_configuration'] = isset($editor['default_is_configuration']) + ? (bool) $editor['default_is_configuration'] + : FieldSettings::legacyDefaultIsConfiguration($type); + $editor['capabilities'] = array( + 'parse_value' => true, + 'serialize_value' => true, + 'normalize_default' => true, + ); + return $editor; + } + + public function normalizeDefault($type, $value) + { + return (string) $value; + } + + public function parseValue($type, $value) + { + return array( + 'raw' => (string) $value, + 'value' => (string) $value, + ); + } + + public function serializeValue($type, $value) + { + if (is_array($value)) { + return isset($value['raw']) ? (string) $value['raw'] : ''; + } + + return (string) $value; + } + + public function renderEdit(array $field) + { + return $this->renderGeneric($field); + } + + // -------- Общие помощники отрисовки контролов документа -------- + + /** Экранирование значения атрибута/текста. */ + protected function e($value) + { + return Str::escape((string) $value); + } + + /** Имя поля формы: fields[ID], напр. fname($f,'[url]'). */ + protected function fname(array $field, $suffix = '[raw]') + { + return 'fields[' . (int) $field['Id'] . ']' . $suffix; + } + + /** Значение parsed поля (нормализованное во viewValue). */ + protected function value(array $field) + { + return isset($field['parsed']) ? $field['parsed'] : ''; + } + + protected function renderGeneric(array $field) + { + $parsed = $this->value($field); + $parsed = is_array($parsed) ? '' : (string) $parsed; + return ''; + } + + abstract protected function definitions(); + + protected function structuredValue($value) + { + return FieldValueCodec::decodeStructured($value, null); + } + + protected function structuredScalars($value) + { + $data = $this->structuredValue($value); + if (!is_array($data)) { + return null; + } + + $items = array(); + array_walk_recursive($data, function ($item) use (&$items) { + if (is_scalar($item) && trim((string) $item) !== '') { + $items[] = trim((string) $item); + } + }); + return array_values($items); + } + + protected function controls(array $editor) + { + $kind = isset($editor['default_kind']) ? (string) $editor['default_kind'] : 'textarea'; + return array( + array( + 'name' => 'rubric_field_default', + 'label' => isset($editor['default_label']) ? (string) $editor['default_label'] : 'Значение по умолчанию', + 'kind' => $kind, + 'hint' => isset($editor['default_hint']) ? (string) $editor['default_hint'] : '', + ), + array( + 'name' => 'rubric_field_template', + 'label' => 'Шаблон вывода', + 'kind' => 'code', + 'hint' => isset($editor['template_hint']) ? (string) $editor['template_hint'] : 'Публичный шаблон вывода поля.', + ), + array( + 'name' => 'rubric_field_template_request', + 'label' => 'Шаблон request', + 'kind' => 'code', + 'hint' => 'Шаблон обработки request-значения, если тип поля его использует.', + ), + ); + } + + protected function commaList($value) + { + $parts = preg_split('/[,\r\n]+/', (string) $value); + $out = array(); + foreach ($parts ?: array() as $part) { + $part = trim((string) $part); + if ($part !== '') { + $out[] = $part; + } + } + + return implode(',', $out); + } + + protected function pipeWrappedList($value) + { + $parts = preg_split('/[|,\r\n]+/', (string) $value); + $out = array(); + foreach ($parts ?: array() as $part) { + $part = trim((string) $part); + if ($part !== '') { + $out[] = $part; + } + } + + return !empty($out) ? '|' . implode('|', $out) . '|' : ''; + } + } diff --git a/adminx/modules/Rubrics/FieldEditors/ChoiceFieldEditor.php b/adminx/modules/Rubrics/FieldEditors/ChoiceFieldEditor.php new file mode 100644 index 0000000..cb85efa --- /dev/null +++ b/adminx/modules/Rubrics/FieldEditors/ChoiceFieldEditor.php @@ -0,0 +1,235 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics\FieldEditors; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Helpers\Json; + + class ChoiceFieldEditor extends AbstractFieldEditor + { + public function parseValue($type, $value) + { + $type = (string) $type; + $value = (string) $value; + + if ($type === 'choice') { + $items = $this->structuredScalars($value); + if ($items === null && strpos($value, ',') !== false) { + $items = explode(',', $value); + } + + return $items === null + ? array('raw' => $value, 'selected' => $value) + : array('raw' => $value, 'selected' => $this->cleanItems($items)); + } + + if (in_array($type, array('multi_select', 'checkbox_multi', 'multi_checkbox'), true)) { + $items = $this->structuredScalars($value); + if ($items === null) { + $items = $type === 'multi_select' + ? array() + : $this->pipeItems($value); + } + + return array('raw' => $value, 'selected' => $this->cleanItems($items)); + } + + return array('raw' => $value, 'selected' => $value); + } + + public function serializeValue($type, $value) + { + $type = (string) $type; + if (!is_array($value)) { + return (string) $value; + } + + if ($type === 'choice') { + $selected = isset($value['selected']) ? $value['selected'] : array(); + if (is_array($selected)) { + $selected = $this->cleanItems($selected); + return !empty($selected) ? Json::encode(array_values($selected), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : ''; + } + + return trim((string) $selected); + } + + if ($type === 'multi_select') { + $selected = isset($value['selected']) && is_array($value['selected']) ? $value['selected'] : array(); + $selected = $this->cleanItems($selected); + return !empty($selected) ? serialize($selected) : ''; + } + + if (in_array($type, array('checkbox_multi', 'multi_checkbox'), true)) { + $selected = isset($value['selected']) && is_array($value['selected']) ? $value['selected'] : array(); + $selected = $this->cleanItems($selected); + return !empty($selected) ? '|' . implode('|', $selected) . '|' : ''; + } + + return isset($value['selected']) ? (string) $value['selected'] : ''; + } + + public function normalizeDefault($type, $value) + { + if ((string) $type === 'choice') { + return trim((string) $value); + } + + return $this->commaList($value); + } + + public function renderEdit(array $field) + { + $kind = isset($field['editor_kind']) ? (string) $field['editor_kind'] : 'choice'; + $parsed = $this->value($field); + $options = isset($field['options']) && is_array($field['options']) ? $field['options'] : array(); + $settings = isset($field['settings']) && is_array($field['settings']) ? $field['settings'] : array(); + $presentation = isset($settings['presentation']) ? (string) $settings['presentation'] : 'select'; + + if ((string) $field['rubric_field_type'] === 'choice') { + $kind = isset($settings['mode']) && (string) $settings['mode'] === 'multiple' ? 'choice_multi' : 'choice'; + } + + if ($kind === 'choice_multi') { + $selected = is_array($parsed) ? array_map('strval', $parsed) : array(); + if ($presentation === 'select') { + $html = '' + . ''; + } + + $html = '' + . '
    '; + foreach ($options as $option) { + $ov = isset($option['value']) ? (string) $option['value'] : ''; + $ol = isset($option['label']) ? (string) $option['label'] : $ov; + $html .= ''; + } + + return $html . (empty($options) ? 'Варианты не настроены.' : '') . '
    '; + } + + $current = is_array($parsed) ? '' : (string) $parsed; + if ((string) $field['rubric_field_type'] === 'choice' && $presentation === 'checks') { + $html = '
    '; + foreach ($options as $option) { + $ov = isset($option['value']) ? (string) $option['value'] : ''; + $ol = isset($option['label']) ? (string) $option['label'] : $ov; + $html .= ''; + } + + return $html . (empty($options) ? 'Варианты не настроены.' : '') . '
    '; + } + + $html = ''; + } + + protected function definitions() + { + return array( + 'choice' => array( + 'title' => 'Выбор из списка', + 'icon' => 'list-check', + 'summary' => 'Одиночный или множественный выбор со стабильными ключами и JSON-настройками.', + 'default_label' => 'Выбранные ключи по умолчанию', + 'default_hint' => 'Для множественного режима перечислите ключи через запятую.', + 'default_kind' => 'text', + 'entity_fields' => array('selected[]'), + ), + 'drop_down' => array( + 'title' => 'Выпадающий список', + 'icon' => 'list', + 'summary' => 'Один вариант из списка. Варианты задаются строками.', + 'default_label' => 'Варианты', + 'default_hint' => 'Один вариант на строку.', + 'default_kind' => 'lines', + 'entity_fields' => array('selected'), + ), + 'drop_down_key' => array( + 'title' => 'Список ключ-значение', + 'icon' => 'list-details', + 'summary' => 'Список, где слева хранится ключ, справа показывается подпись.', + 'default_label' => 'Варианты', + 'default_hint' => 'Формат строки: key|Название. Один вариант на строку.', + 'default_kind' => 'key_value', + 'entity_fields' => array('selected'), + ), + 'multi_select' => array( + 'title' => 'Множественный выбор', + 'icon' => 'select', + 'summary' => 'Несколько вариантов из списка, варианты задаются в настройке поля.', + 'default_label' => 'Варианты', + 'default_hint' => 'Один вариант на строку.', + 'default_kind' => 'lines', + 'entity_fields' => array('selected[]'), + ), + 'checkbox_multi' => array( + 'title' => 'Несколько флажков', + 'icon' => 'checks', + 'summary' => 'Несколько значений из заданного набора.', + 'default_label' => 'Варианты', + 'default_hint' => 'Один вариант на строку. Для ключей используйте key|Название.', + 'default_kind' => 'key_value', + 'entity_fields' => array('selected[]'), + ), + 'multi_checkbox' => array( + 'title' => 'Мультивыбор', + 'icon' => 'checks', + 'summary' => 'Несколько отмеченных вариантов.', + 'default_label' => 'Варианты', + 'default_hint' => 'Один вариант на строку.', + 'default_kind' => 'lines', + 'entity_fields' => array('selected[]'), + ), + ); + } + + protected function pipeItems($value) + { + return $this->cleanItems(explode('|', (string) $value)); + } + + protected function cleanItems(array $items) + { + $out = array(); + foreach ($items as $item) { + $item = trim((string) $item); + if ($item !== '') { + $out[] = $item; + } + } + + return array_values($out); + } + } diff --git a/adminx/modules/Rubrics/FieldEditors/FieldEditorInterface.php b/adminx/modules/Rubrics/FieldEditors/FieldEditorInterface.php new file mode 100644 index 0000000..9b02e31 --- /dev/null +++ b/adminx/modules/Rubrics/FieldEditors/FieldEditorInterface.php @@ -0,0 +1,40 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics\FieldEditors; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + interface FieldEditorInterface + { + public function supports($type); + + public function describe($type); + + public function normalizeDefault($type, $value); + + public function parseValue($type, $value); + + public function serializeValue($type, $value); + + /** + * Отрисовать редактор значения поля документа. + * + * @param array $field Собранная строка поля (Model::fieldRow): Id, + * rubric_field_type, editor_kind, parsed, options, + * media_upload_dir и т.д. + * @return string HTML-разметка контрола (без обёртки .ax-document-field). + */ + public function renderEdit(array $field); + } diff --git a/adminx/modules/Rubrics/FieldEditors/ListFieldEditor.php b/adminx/modules/Rubrics/FieldEditors/ListFieldEditor.php new file mode 100644 index 0000000..52b9d1e --- /dev/null +++ b/adminx/modules/Rubrics/FieldEditors/ListFieldEditor.php @@ -0,0 +1,334 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics\FieldEditors; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Helpers\Json; + + class ListFieldEditor extends AbstractFieldEditor + { + public function parseValue($type, $value) + { + $type = (string) $type; + if ($type === 'packages') { + return array( + 'raw' => (string) $value, + 'items' => \App\Content\Fields\Types\PackagesValue::parseItems($value), + ); + } + + $items = $this->decodeListValue($value); + + $out = array(); + foreach ($items as $item) { + if (is_array($item)) { + if ($type === 'multi_list_single') { + $single = isset($item['value']) ? (string) $item['value'] : (isset($item['param']) ? (string) $item['param'] : ''); + if ($single !== '') { + $out[] = array('value' => $single); + } + + continue; + } + + if ($type === 'multi_list_triple') { + $param = isset($item['param']) ? (string) $item['param'] : (isset($item['title']) ? (string) $item['title'] : ''); + if ($param !== '') { + $out[] = array( + 'param' => $param, + 'value' => isset($item['value']) ? (string) $item['value'] : (isset($item['url']) ? (string) $item['url'] : ''), + 'value2' => isset($item['value2']) ? (string) $item['value2'] : (isset($item['description']) ? (string) $item['description'] : ''), + ); + } + + continue; + } + + $param = isset($item['param']) ? (string) $item['param'] : (isset($item['title']) ? (string) $item['title'] : ''); + if ($param !== '') { + $out[] = array( + 'param' => $param, + 'value' => isset($item['value']) ? (string) $item['value'] : (isset($item['url']) ? (string) $item['url'] : ''), + ); + } + + continue; + } + + $parts = explode('|', (string) $item); + if ($type === 'multi_list_single') { + $single = isset($parts[0]) ? trim((string) $parts[0]) : ''; + if ($single !== '') { + $out[] = array('value' => $single); + } + } elseif ($type === 'multi_list_triple') { + $param = isset($parts[0]) ? trim((string) $parts[0]) : ''; + if ($param !== '') { + $out[] = array( + 'param' => $param, + 'value' => isset($parts[1]) ? (string) $parts[1] : '', + 'value2' => isset($parts[2]) ? (string) $parts[2] : '', + ); + } + } else { + $param = isset($parts[0]) ? trim((string) $parts[0]) : ''; + if ($param !== '') { + $out[] = array( + 'param' => $param, + 'value' => isset($parts[1]) ? implode('|', array_slice($parts, 1)) : '', + ); + } + } + } + + return array('raw' => (string) $value, 'items' => $out); + } + + public function serializeValue($type, $value) + { + $type = (string) $type; + if (!is_array($value)) { + return (string) $value; + } + + $items = isset($value['items']) && is_array($value['items']) ? $value['items'] : array(); + if ($type === 'packages') { + return \App\Content\Fields\Types\PackagesValue::encodeItems(array('items' => $items)); + } + + $out = array(); + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + if ($type === 'multi_list_single') { + $single = trim(isset($item['value']) ? (string) $item['value'] : ''); + if ($single !== '') { + $out[] = $single; + } + } elseif ($type === 'multi_list_triple') { + $param = trim(isset($item['param']) ? (string) $item['param'] : ''); + $val1 = trim(isset($item['value']) ? (string) $item['value'] : ''); + $val2 = trim(isset($item['value2']) ? (string) $item['value2'] : ''); + if ($param !== '') { + $out[] = $param . '|' . $val1 . '|' . $val2; + } + } else { + $param = trim(isset($item['param']) ? (string) $item['param'] : ''); + $val = trim(isset($item['value']) ? (string) $item['value'] : ''); + if ($param !== '') { + $out[] = $param . ($val !== '' ? '|' . $val : ''); + } + } + } + + return !empty($out) ? serialize($out) : ''; + } + + protected function decodeListValue($value) + { + $value = trim((string) $value); + if ($value === '') { + return array(); + } + + $serialized = @unserialize($value, array('allowed_classes' => false)); + if (is_array($serialized)) { + return $serialized; + } + + if ($value[0] === '[' || $value[0] === '{') { + try { + $json = Json::decode($value); + } catch (\RuntimeException $e) { + $json = null; + } + + if (is_array($json)) { + if (isset($json['items']) && is_array($json['items'])) { + return $json['items']; + } + + if (isset($json[0])) { + return $json; + } + + return array($json); + } + } + + $lines = preg_split('/\r\n|\r|\n/', $value); + if (count($lines) <= 1 && strpos($value, ',') !== false) { + $lines = preg_split('/,/', $value); + } + + $out = array(); + foreach ($lines ?: array() as $line) { + $line = trim((string) $line); + if ($line !== '') { + $out[] = $line; + } + } + + return $out; + } + + public function normalizeDefault($type, $value) + { + if ((string) $type === 'packages') { + return \App\Content\Fields\Types\PackagesValue::encodeItems($value); + } + + return $this->commaList($value); + } + + public function renderEdit(array $field) + { + $kind = isset($field['editor_kind']) ? (string) $field['editor_kind'] : 'list_pair'; + $type = isset($field['rubric_field_type']) ? (string) $field['rubric_field_type'] : ''; + $settings = isset($field['settings']) && is_array($field['settings']) ? $field['settings'] : array(); + $parsed = $this->value($field); + $items = isset($parsed['items']) && is_array($parsed['items']) ? $parsed['items'] : array(); + + $rows = ''; + foreach ($items as $idx => $item) { + $item = is_array($item) ? $item : array(); + $rows .= '
    ' + . '
    ' + . '' + . '' + . '' + . '
    ' + . '
    ' . $this->rowFields($field, $kind, $type, (int) $idx, $item) . '
    ' + . '' + . '
    '; + } + + $dimensionUnit = isset($settings['dimension_unit']) ? (string) $settings['dimension_unit'] : 'cm'; + $weightUnit = isset($settings['weight_unit']) ? (string) $settings['weight_unit'] : 'kg'; + return '
    ' + . '' + . '
    ' + . '' + . '' + . '
    ' + . '
    ' . $rows . '
    ' + . '
    Нет добавленных строк.
    ' + . '
    '; + } + + protected function rowFields(array $field, $kind, $type, $idx, array $item) + { + $base = $this->fname($field, '[items][' . $idx . ']'); + $in = function ($sub, $key, $val, $ph) use ($base) { + return ''; + }; + + if ($kind === 'packages') { + $settings = isset($field['settings']) && is_array($field['settings']) ? $field['settings'] : array(); + $dimensionUnit = $this->dimensionUnitLabel(isset($settings['dimension_unit']) ? $settings['dimension_unit'] : 'cm'); + $weightUnit = $this->weightUnitLabel(isset($settings['weight_unit']) ? $settings['weight_unit'] : 'kg'); + return $in('[length]', 'length', isset($item['length']) ? $item['length'] : '', 'Длина, ' . $dimensionUnit) + . $in('[width]', 'width', isset($item['width']) ? $item['width'] : '', 'Ширина, ' . $dimensionUnit) + . $in('[height]', 'height', isset($item['height']) ? $item['height'] : '', 'Высота, ' . $dimensionUnit) + . $in('[weight]', 'weight', isset($item['weight']) ? $item['weight'] : '', 'Вес, ' . $weightUnit); + } + + if ($kind === 'list_single') { + return $in('[value]', 'value', isset($item['value']) ? $item['value'] : '', 'Значение'); + } + + if ($kind === 'list_triple') { + $isTriple = $type === 'multi_list_triple'; + return $in('[param]', 'param', isset($item['param']) ? $item['param'] : '', $isTriple ? 'Колонка 1' : 'Заголовок') + . $in('[value]', 'value', isset($item['value']) ? $item['value'] : '', $isTriple ? 'Колонка 2' : 'Значение') + . $in('[value2]', 'value2', isset($item['value2']) ? $item['value2'] : '', $isTriple ? 'Колонка 3' : 'Дополнительно'); + } + + $paramPh = $type === 'multi_links' ? 'Название' : ($type === 'teasers' ? 'Заголовок' : 'Параметр'); + $valuePh = $type === 'multi_links' ? 'URL' : ($type === 'teasers' ? 'ID тизера / ссылка' : 'Значение'); + return $in('[param]', 'param', isset($item['param']) ? $item['param'] : '', $paramPh) + . $in('[value]', 'value', isset($item['value']) ? $item['value'] : '', $valuePh); + } + + protected function dimensionUnitLabel($unit) + { + $units = array('mm' => 'мм', 'cm' => 'см', 'm' => 'м'); + $unit = (string) $unit; + return isset($units[$unit]) ? $units[$unit] : $units['cm']; + } + + protected function weightUnitLabel($unit) + { + return (string) $unit === 'g' ? 'г' : 'кг'; + } + + protected function definitions() + { + return array( + 'packages' => array( + 'title' => 'Упаковки товара', + 'icon' => 'packages', + 'summary' => 'Сортируемый список коробок с габаритами и весом для расчёта доставки.', + 'default_label' => 'Упаковки по умолчанию', + 'default_hint' => 'Одна упаковка на строку: длина|ширина|высота|вес.', + 'default_kind' => 'packages', + 'entity_fields' => array('items[].length', 'items[].width', 'items[].height', 'items[].weight'), + ), + 'multi_list' => array( + 'title' => 'Список записей', + 'icon' => 'list-details', + 'summary' => 'Повторяемый список записей, legacy хранит значения сериализованным массивом.', + 'default_label' => 'Строки по умолчанию', + 'default_hint' => 'Одна запись на строку. Для нескольких колонок используйте разделитель |.', + 'default_kind' => 'key_value', + 'entity_fields' => array('items[].param', 'items[].value'), + ), + 'multi_list_single' => array( + 'title' => 'Одноколоночный список', + 'icon' => 'list', + 'summary' => 'Повторяемый список из одного значения в строке.', + 'default_label' => 'Строки по умолчанию', + 'default_hint' => 'Одна запись на строку.', + 'default_kind' => 'lines', + 'entity_fields' => array('items[].value'), + ), + 'multi_list_triple' => array( + 'title' => 'Трёхколоночный список', + 'icon' => 'columns-3', + 'summary' => 'Повторяемый список из трёх частей, legacy использует разделитель |.', + 'default_label' => 'Строки по умолчанию', + 'default_hint' => 'Формат строки: колонка1|колонка2|колонка3.', + 'default_kind' => 'pipe_list', + 'entity_fields' => array('items[].param', 'items[].value', 'items[].value2'), + ), + 'multi_links' => array( + 'title' => 'Список ссылок', + 'icon' => 'link-plus', + 'summary' => 'Повторяемые ссылки с подписью.', + 'default_label' => 'Ссылки по умолчанию', + 'default_hint' => 'Формат строки: URL|Название.', + 'default_kind' => 'key_value', + 'entity_fields' => array('items[].param', 'items[].value'), + ), + ); + } + } diff --git a/adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php b/adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php new file mode 100644 index 0000000..24ff753 --- /dev/null +++ b/adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php @@ -0,0 +1,580 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics\FieldEditors; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Helpers\Json; + + class MediaFieldEditor extends AbstractFieldEditor + { + public function parseValue($type, $value) + { + $type = (string) $type; + $value = (string) $value; + + if (in_array($type, array('image_single', 'download'), true)) { + $json = $this->decodeJsonValue($value); + if (is_array($json)) { + return array( + 'raw' => $value, + 'url' => isset($json['url']) ? (string) $json['url'] : (isset($json['path']) ? (string) $json['path'] : ''), + 'description' => isset($json['description']) ? (string) $json['description'] : (isset($json['descr']) ? (string) $json['descr'] : ''), + ); + } + + $parts = explode('|', $value); + return array( + 'raw' => $value, + 'url' => isset($parts[0]) ? (string) $parts[0] : '', + 'description' => isset($parts[1]) ? implode('|', array_slice($parts, 1)) : '', + ); + } + + if ($type === 'youtube') { + $parts = explode('|', $value); + return array( + 'raw' => $value, + 'url' => isset($parts[0]) ? (string) $parts[0] : '', + 'width' => isset($parts[1]) ? (string) $parts[1] : '', + 'height' => isset($parts[2]) ? (string) $parts[2] : '', + 'fullscreen' => isset($parts[3]) ? (string) $parts[3] : '', + 'source' => isset($parts[4]) ? (string) $parts[4] : '', + ); + } + + if ($type === 'image_multi') { + $items = $this->decodeListValue($value); + $out = array(); + foreach ($items as $item) { + if (is_array($item)) { + $url = isset($item['url']) ? (string) $item['url'] : (isset($item['path']) ? (string) $item['path'] : ''); + if ($url === '') { + continue; + } + + $out[] = array( + 'url' => $url, + 'description' => isset($item['description']) ? (string) $item['description'] : (isset($item['descr']) ? (string) $item['descr'] : ''), + ); + continue; + } + + $parts = explode('|', (string) $item); + $url = isset($parts[0]) ? trim((string) $parts[0]) : ''; + if ($url !== '') { + $out[] = array( + 'url' => $url, + 'description' => isset($parts[1]) ? implode('|', array_slice($parts, 1)) : '', + ); + } + } + + return array('raw' => $value, 'items' => $out); + } + + if ($type === 'image_mega') { + $items = $this->decodeListValue($value); + $out = array(); + foreach ($items as $item) { + if (is_array($item)) { + $url = isset($item['url']) ? (string) $item['url'] : (isset($item['path']) ? (string) $item['path'] : ''); + if ($url === '') { + continue; + } + + $out[] = array( + 'url' => $url, + 'title' => isset($item['title']) ? (string) $item['title'] : (isset($item['name']) ? (string) $item['name'] : ''), + 'description' => isset($item['description']) ? (string) $item['description'] : (isset($item['descr']) ? (string) $item['descr'] : ''), + 'link' => isset($item['link']) ? (string) $item['link'] : '', + ); + continue; + } + + $parts = explode('|', (string) $item); + $url = isset($parts[0]) ? trim((string) $parts[0]) : ''; + if ($url !== '') { + $out[] = array( + 'url' => $url, + 'title' => isset($parts[1]) ? (string) $parts[1] : '', + 'description' => isset($parts[2]) ? (string) $parts[2] : '', + 'link' => isset($parts[3]) ? (string) $parts[3] : '', + ); + } + } + + return array('raw' => $value, 'items' => $out); + } + + if ($type === 'doc_files') { + $items = $this->decodeListValue($value); + $out = array(); + foreach ($items as $item) { + if (is_array($item)) { + $url = isset($item['url']) ? (string) $item['url'] : (isset($item['path']) ? (string) $item['path'] : ''); + if ($url === '') { + continue; + } + + $out[] = array( + 'name' => isset($item['name']) ? (string) $item['name'] : '', + 'description' => isset($item['descr']) ? (string) $item['descr'] : (isset($item['description']) ? (string) $item['description'] : ''), + 'url' => $url, + ); + continue; + } + + $parts = explode('|', (string) $item); + $url = count($parts) > 2 ? (string) $parts[2] : (string) $item; + $url = trim($url); + if ($url !== '') { + $out[] = array( + 'name' => isset($parts[0]) && count($parts) > 2 ? (string) $parts[0] : basename($url), + 'description' => isset($parts[1]) && count($parts) > 2 ? (string) $parts[1] : '', + 'url' => $url, + ); + } + } + + return array('raw' => $value, 'items' => $out); + } + + return parent::parseValue($type, $value); + } + + public function serializeValue($type, $value) + { + $type = (string) $type; + if (!is_array($value)) { + return (string) $value; + } + + if ($type === 'image_single') { + $url = trim(isset($value['url']) ? (string) $value['url'] : ''); + $description = trim(isset($value['description']) ? (string) $value['description'] : ''); + return $url === '' ? '' : htmlspecialchars($url . '|' . $description, ENT_QUOTES); + } + + if ($type === 'download') { + $url = trim(isset($value['url']) ? (string) $value['url'] : ''); + $description = trim(isset($value['description']) ? (string) $value['description'] : ''); + return $url === '' ? '' : htmlspecialchars($url . ($description !== '' ? '|' . $description : ''), ENT_QUOTES); + } + + if ($type === 'youtube') { + $url = trim(isset($value['url']) ? (string) $value['url'] : ''); + if ($url === '') { + return ''; + } + + return htmlspecialchars(implode('|', array( + $url, + isset($value['width']) ? (string) $value['width'] : '', + isset($value['height']) ? (string) $value['height'] : '', + isset($value['fullscreen']) ? (string) $value['fullscreen'] : '', + isset($value['source']) ? (string) $value['source'] : '', + )), ENT_QUOTES); + } + + if ($type === 'image_multi') { + $items = isset($value['items']) && is_array($value['items']) ? $value['items'] : array(); + $out = array(); + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $url = trim(isset($item['url']) ? (string) $item['url'] : ''); + $description = trim(isset($item['description']) ? (string) $item['description'] : ''); + if ($url !== '') { + $out[] = $url . ($description !== '' ? '|' . $description : ''); + } + } + + return !empty($out) ? serialize($out) : ''; + } + + if ($type === 'image_mega') { + $items = isset($value['items']) && is_array($value['items']) ? $value['items'] : array(); + $out = array(); + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $url = trim(isset($item['url']) ? (string) $item['url'] : ''); + if ($url === '') { + continue; + } + + $out[] = $url + . '|' . stripslashes(htmlspecialchars(isset($item['title']) ? (string) $item['title'] : '', ENT_QUOTES)) + . '|' . stripslashes(htmlspecialchars(isset($item['description']) ? (string) $item['description'] : '', ENT_QUOTES)) + . '|' . ltrim(isset($item['link']) ? (string) $item['link'] : '', '/'); + } + + return !empty($out) ? serialize($out) : ''; + } + + if ($type === 'doc_files') { + $items = isset($value['items']) && is_array($value['items']) ? $value['items'] : array(); + $out = array(); + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $url = trim(isset($item['url']) ? (string) $item['url'] : ''); + if ($url === '') { + continue; + } + + $out[] = array( + 'name' => isset($item['name']) ? (string) $item['name'] : '', + 'descr' => isset($item['description']) ? (string) $item['description'] : '', + 'url' => $url, + ); + } + + return !empty($out) ? serialize($out) : ''; + } + + return parent::serializeValue($type, $value); + } + + public function renderEdit(array $field) + { + $type = isset($field['rubric_field_type']) ? (string) $field['rubric_field_type'] : ''; + $kind = isset($field['editor_kind']) ? (string) $field['editor_kind'] : 'media'; + $parsed = $this->value($field); + $parsed = is_array($parsed) ? $parsed : array(); + + if ($type === 'youtube') { + return $this->renderYoutube($field, $parsed); + } + + if ($type === 'text_to_image') { + $value = isset($parsed['value']) ? (string) $parsed['value'] : (isset($parsed['raw']) ? (string) $parsed['raw'] : ''); + return ''; + } + + if ($kind === 'media_list') { + return $this->renderMediaList($field, $type, $parsed); + } + + return $this->renderSingleMedia($field, $type, $parsed); + } + + protected function renderYoutube(array $field, array $p) + { + $url = isset($p['url']) ? (string) $p['url'] : ''; + $width = isset($p['width']) ? (string) $p['width'] : ''; + $height = isset($p['height']) ? (string) $p['height'] : ''; + $fs = isset($p['fullscreen']) ? (string) $p['fullscreen'] : ''; + $src = isset($p['source']) ? (string) $p['source'] : ''; + + return '
    ' + . '' + . '
    ' + . '' + . '' + . '' + . '' + . '
    '; + } + + protected function renderSingleMedia(array $field, $type, array $p) + { + $accept = $type === 'download' ? 'all' : 'image'; + $isImage = $type !== 'download'; + $url = isset($p['url']) ? (string) $p['url'] : ''; + $description = isset($p['description']) ? (string) $p['description'] : ''; + $placeholder = $type === 'download' ? '/uploads/file.pdf' : '/uploads/file.jpg'; + $base = defined('ADMINX_BASE') ? ADMINX_BASE : ''; + $uploadDir = isset($field['media_upload_dir']) && $field['media_upload_dir'] !== '' ? (string) $field['media_upload_dir'] : '/uploads'; + $targetDir = isset($field['media_target_dir']) ? (string) $field['media_target_dir'] : '/uploads'; + $pickerDir = isset($field['media_picker_dir']) && $field['media_picker_dir'] !== '' ? (string) $field['media_picker_dir'] : '/uploads'; + $fileAccept = $accept === 'image' ? ' accept="image/*"' : ''; + + return '
    ' + . $this->mediaThumb($url, $isImage) + . '
    ' + . $this->mediaPath($url, $isImage) + . '
    ' + . '' + . '' + . '
    ' + . '
    ' + . '' + . '' + . '
    ' + . '' + . '
    '; + } + + /** Квадратное превью медиа (картинка или иконка файла). */ + protected function mediaThumb($url, $isImage) + { + $url = (string) $url; + if ($isImage) { + $inner = $url !== '' ? '' : ''; + } else { + $inner = ''; + } + + return ''; + } + + /** Подпись «куда сохранится» — путь к файлу. */ + protected function mediaPath($url, $isImage = true) + { + $url = (string) $url; + $body = $url !== '' + ? '' . $this->e($url) . '' + : '' . ($isImage ? 'Изображение не выбрано' : 'Файл не выбран') . ''; + return '
    ' . $body . '
    '; + } + + protected function renderMediaList(array $field, $type, array $parsed) + { + $items = isset($parsed['items']) && is_array($parsed['items']) ? $parsed['items'] : array(); + $accept = $type === 'doc_files' ? 'all' : 'image'; + $base = defined('ADMINX_BASE') ? ADMINX_BASE : ''; + $uploadDir = isset($field['media_upload_dir']) && $field['media_upload_dir'] !== '' ? (string) $field['media_upload_dir'] : '/uploads'; + $targetDir = isset($field['media_target_dir']) ? (string) $field['media_target_dir'] : '/uploads'; + $pickerDir = isset($field['media_picker_dir']) && $field['media_picker_dir'] !== '' ? (string) $field['media_picker_dir'] : '/uploads'; + + $isImage = $type !== 'doc_files'; + $rows = ''; + foreach ($items as $idx => $item) { + $item = is_array($item) ? $item : array(); + $url = isset($item['url']) ? (string) $item['url'] : ''; + $rows .= '
    ' + . $this->mediaThumb($url, $isImage) + . '
    ' + . $this->mediaPath($url, $isImage) + . '
    ' . $this->mediaRowFields($field, $type, (int) $idx, $item) . '
    ' + . '
    ' + . '' + . '' + . '' + . '' + . '
    ' + . '
    '; + } + + $fileAccept = $accept === 'image' ? ' accept="image/*"' : ''; + return '
    ' + . '' + . '
    ' + . '' + . '' + . '' + . '' + . '' + . '
    ' + . '
    Перетащите файлы сюда — после сохранения: ' . $this->e($targetDir) . '
    ' + . '
    ' . $rows . '
    ' + . '
    Нет добавленных элементов.
    ' + . '
    '; + } + + protected function mediaRowFields(array $field, $type, $idx, array $item) + { + $base = $this->fname($field, '[items][' . $idx . ']'); + $url = isset($item['url']) ? (string) $item['url'] : ''; + $description = isset($item['description']) ? (string) $item['description'] : ''; + + $pickerUrl = function ($sub, $val, $ph, $pickerType, $icon) use ($base) { + return '
    ' + . '
    '; + }; + $textInput = function ($sub, $val, $ph) use ($base) { + return ''; + }; + $textarea = function ($sub, $val, $ph) use ($base) { + return ''; + }; + // Поле «Ссылка»: файл или документ (две кнопки выбора). + $linkField = function ($sub, $val) use ($base) { + return ''; + }; + + if ($type === 'image_mega') { + $title = isset($item['title']) ? (string) $item['title'] : ''; + $link = isset($item['link']) ? (string) $item['link'] : ''; + return $pickerUrl('[url]', $url, '/uploads/image.jpg', 'image', 'ti-photo-plus') + . $textInput('[title]', $title, 'Заголовок изображения') + . $textarea('[description]', $description, 'Описание') + . $linkField('[link]', $link); + } + + if ($type === 'doc_files') { + $name = isset($item['name']) ? (string) $item['name'] : ''; + return $textInput('[name]', $name, 'Название файла') + . $textarea('[description]', $description, 'Описание') + . $pickerUrl('[url]', $url, '/uploads/file.pdf', 'all', 'ti-paperclip'); + } + + return $pickerUrl('[url]', $url, '/uploads/image.jpg', 'image', 'ti-photo-plus') + . $textarea('[description]', $description, 'Описание'); + } + + protected function decodeListValue($value) + { + $value = trim((string) $value); + if ($value === '') { + return array(); + } + + $serialized = @unserialize($value, array('allowed_classes' => false)); + if (is_array($serialized)) { + return $serialized; + } + + $json = $this->decodeJsonValue($value); + if (is_array($json)) { + if (isset($json['items']) && is_array($json['items'])) { + return $json['items']; + } + + if (isset($json[0])) { + return $json; + } + + if (isset($json['url']) || isset($json['path'])) { + return array($json); + } + } + + $lines = preg_split('/\r\n|\r|\n/', $value); + if (count($lines) <= 1 && strpos($value, ',') !== false) { + $lines = preg_split('/,/', $value); + } + + $out = array(); + foreach ($lines ?: array() as $line) { + $line = trim((string) $line); + if ($line !== '') { + $out[] = $line; + } + } + + return $out; + } + + protected function decodeJsonValue($value) + { + $value = trim((string) $value); + if ($value === '' || ($value[0] !== '{' && $value[0] !== '[')) { + return null; + } + + try { + $json = Json::decode($value); + return is_array($json) ? $json : null; + } catch (\RuntimeException $e) { + return null; + } + } + + protected function definitions() + { + return array( + 'image_single' => array( + 'title' => 'Изображение', + 'icon' => 'photo', + 'summary' => 'Одно изображение из медиабраузера.', + 'default_label' => 'Путь по умолчанию', + 'default_hint' => 'Обычно пусто. В документе будет использоваться медиабраузер.', + 'default_kind' => 'media', + 'entity_fields' => array('url', 'description'), + ), + 'image_multi' => array( + 'title' => 'Галерея', + 'icon' => 'photo-scan', + 'summary' => 'Набор изображений из медиабраузера.', + 'default_label' => 'Пути по умолчанию', + 'default_hint' => 'Один путь на строку, если нужно предзаполнить галерею.', + 'default_kind' => 'media_list', + 'entity_fields' => array('items[].url', 'items[].description'), + ), + 'image_mega' => array( + 'title' => 'Расширенная галерея', + 'icon' => 'photo-cog', + 'summary' => 'Сложное поле изображений. Adminx-редактор будет развиваться поверх медиабраузера.', + 'default_label' => 'Настройки по умолчанию', + 'default_kind' => 'media_list', + 'entity_fields' => array('items[].url', 'items[].title', 'items[].description', 'items[].link'), + ), + 'download' => array( + 'title' => 'Файл', + 'icon' => 'paperclip', + 'summary' => 'Файл для скачивания.', + 'default_label' => 'Путь к файлу по умолчанию', + 'default_kind' => 'media', + 'entity_fields' => array('url', 'description'), + ), + 'doc_files' => array( + 'title' => 'Файлы документа', + 'icon' => 'files', + 'summary' => 'Список файлов, привязанных к документу.', + 'default_label' => 'Файлы по умолчанию', + 'default_hint' => 'Один путь на строку.', + 'default_kind' => 'media_list', + 'entity_fields' => array('items[].name', 'items[].description', 'items[].url'), + ), + 'youtube' => array( + 'title' => 'YouTube', + 'icon' => 'brand-youtube', + 'summary' => 'Видео YouTube с дополнительными параметрами.', + 'default_label' => 'Видео по умолчанию', + 'default_hint' => 'URL или ID ролика. Дополнительные части legacy хранит через |.', + 'default_kind' => 'url', + 'entity_fields' => array('url', 'width', 'height', 'fullscreen', 'source'), + ), + 'text_to_image' => array( + 'title' => 'Текст в изображение', + 'icon' => 'photo-edit', + 'summary' => 'Текстовое значение, которое legacy превращает в изображение.', + 'default_label' => 'Текст по умолчанию', + 'default_hint' => 'Обычный текст; параметры вывода задаются шаблоном поля.', + 'default_kind' => 'text', + ), + ); + } + } diff --git a/adminx/modules/Rubrics/FieldEditors/RelationFieldEditor.php b/adminx/modules/Rubrics/FieldEditors/RelationFieldEditor.php new file mode 100644 index 0000000..637e439 --- /dev/null +++ b/adminx/modules/Rubrics/FieldEditors/RelationFieldEditor.php @@ -0,0 +1,378 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics\FieldEditors; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + class RelationFieldEditor extends AbstractFieldEditor + { + public function parseValue($type, $value) + { + $type = (string) $type; + $value = (string) $value; + + if ($type === 'tags') { + $items = $this->structuredScalars($value); + return array('raw' => $value, 'tags' => $items === null ? $this->pipeItems($value) : $this->cleanItems($items)); + } + + if (in_array($type, array('doc_from_rub_check', 'doc_from_rub_search'), true)) { + $items = $this->structuredScalars($value); + return array('raw' => $value, 'document_ids' => $items === null ? $this->pipeItems($value) : $this->documentIds($items)); + } + + if (in_array($type, array('analoque', 'teasers'), true)) { + return array('raw' => $value, 'document_ids' => $this->documentIds($value)); + } + + if ($type === 'doc_from_rub') { + return array('raw' => $value, 'document_id' => trim($value)); + } + + if ($type === 'catalog') { + $data = @unserialize($value, array('allowed_classes' => false)); + if (!is_array($data)) { + $data = array(); + } + + return array( + 'raw' => $value, + 'catalog_ids' => $this->csvItems(isset($data['catalogs']) ? $data['catalogs'] : ''), + 'document_ids' => $this->pipeItems(isset($data['documents']) ? $data['documents'] : ''), + 'names' => $this->csvItems(isset($data['names']) ? $data['names'] : ''), + ); + } + + return parent::parseValue($type, $value); + } + + public function serializeValue($type, $value) + { + $type = (string) $type; + if (!is_array($value)) { + return (string) $value; + } + + if ($type === 'tags') { + return $this->wrapPipeArray(isset($value['tags']) ? $value['tags'] : array()); + } + + if (in_array($type, array('doc_from_rub_check', 'doc_from_rub_search'), true)) { + return $this->wrapPipeArray(isset($value['document_ids']) ? $value['document_ids'] : array()); + } + + if (in_array($type, array('analoque', 'teasers'), true)) { + $ids = isset($value['document_ids']) && is_array($value['document_ids']) ? $value['document_ids'] : array(); + return implode(',', $this->cleanItems($ids)); + } + + if ($type === 'doc_from_rub') { + return isset($value['document_id']) ? (string) (int) $value['document_id'] : ''; + } + + if ($type === 'catalog') { + $catalogIds = isset($value['catalog_ids']) && is_array($value['catalog_ids']) ? $value['catalog_ids'] : array(); + $documentIds = isset($value['document_ids']) && is_array($value['document_ids']) ? $value['document_ids'] : array(); + $names = isset($value['names']) && is_array($value['names']) ? $value['names'] : array(); + $data = array( + 'catalogs' => implode(',', $this->cleanItems($catalogIds)), + 'documents' => $this->wrapPipeArray($documentIds), + ); + if (!empty($names)) { + $data['names'] = implode(',', $this->cleanItems($names)); + } + + return serialize($data); + } + + return parent::serializeValue($type, $value); + } + + public function normalizeDefault($type, $value) + { + $type = (string) $type; + if (in_array($type, array('tags', 'doc_from_rub_check', 'doc_from_rub_search'), true)) { + return $this->pipeWrappedList($value); + } + + if ($type === 'analoque') { + return $this->commaList($value); + } + + if ($type === 'catalog') { + return $this->commaList($value); + } + + if ($type === 'doc_from_rub') { + $value = trim((string) $value); + return $value === '' ? '' : (string) (0 + $value); + } + + return (string) $value; + } + + public function renderEdit(array $field) + { + $kind = isset($field['editor_kind']) ? (string) $field['editor_kind'] : 'textarea'; + $parsed = $this->value($field); + + if ($kind === 'relation') { + $type = isset($field['rubric_field_type']) ? (string) $field['rubric_field_type'] : ''; + $isDocFromRub = ($type === 'doc_from_rub'); + $rubricIds = isset($field['relation_rubric_ids']) && is_array($field['relation_rubric_ids']) + ? array_filter(array_map('intval', $field['relation_rubric_ids'])) + : array(); + $rubric = $isDocFromRub ? implode(',', $rubricIds) : ''; + if ($isDocFromRub) { + // Значение показываем только если у документа реально сохранён выбор, + // иначе parsed['document_id'] равен id рубрики из default. + $val = ((int) (isset($field['document_field_id']) ? $field['document_field_id'] : 0) > 0 && is_array($parsed)) + ? (string) (int) (isset($parsed['document_id']) ? $parsed['document_id'] : 0) + : ''; + } else { + $val = is_array($parsed) ? '' : (string) $parsed; + } + + $rubricAttr = $rubric !== '' ? ' data-document-picker-rubric="' . $this->e($rubric) . '"' : ''; + $hint = ($isDocFromRub && $rubric !== '') + ? 'Только документы из рубрики #' . $this->e($rubric) . '.' + : ''; + $hasValue = ($val !== '' && (int) $val > 0); + $selected = isset($field['relation_selected']) && is_array($field['relation_selected']) ? $field['relation_selected'] : array(); + $title = $hasValue + ? (isset($selected['title']) && $selected['title'] !== '' ? (string) $selected['title'] : ('Документ #' . (int) $val)) + : 'Документ не выбран'; + $idLabel = $hasValue ? '#' . (int) $val : 'нажмите, чтобы выбрать'; + return '
    ' + . '' + . '' + . '' + . '
    ' . $hint; + } + + if ($kind === 'relation_list') { + return $this->renderRelationList($field); + } + + if (in_array($kind, array('lines', 'key_value', 'pipe_list'), true)) { + $val = is_array($parsed) ? '' : (string) $parsed; + $hint = in_array($kind, array('key_value', 'pipe_list'), true) + ? 'Одна строка на запись, части разделяются символом |.' + : 'Одна строка на значение.'; + return '' + . '' . $hint . ''; + } + + return $this->renderGeneric($field); + } + + protected function renderRelationList(array $field) + { + $type = isset($field['rubric_field_type']) ? (string) $field['rubric_field_type'] : ''; + $rubricIds = isset($field['relation_rubric_ids']) && is_array($field['relation_rubric_ids']) + ? array_values(array_filter(array_map('intval', $field['relation_rubric_ids']))) + : array(); + $rubric = in_array($type, array('doc_from_rub_check', 'doc_from_rub_search'), true) + ? implode(',', $rubricIds) + : ''; + $items = isset($field['relation_items']) && is_array($field['relation_items']) ? $field['relation_items'] : array(); + $tokens = ''; + foreach ($items as $item) { + $id = isset($item['id']) ? (int) $item['id'] : 0; + if ($id <= 0) { continue; } + $title = isset($item['title']) ? (string) $item['title'] : ('Документ #' . $id); + $tokens .= '' + . '' . $this->e($title) . '#' . $id . '' + . '' + . '' + . ''; + } + + $rubricAttr = $rubric !== '' ? ' data-document-picker-rubric="' . $this->e($rubric) . '"' : ''; + return '
    ' + . '' + . '
    ' . $tokens . '
    ' + . '

    Документы не выбраны.

    ' + . '' + . '
    '; + } + + protected function definitions() + { + return array( + 'tags' => array( + 'title' => 'Теги', + 'icon' => 'tags', + 'summary' => 'Набор тегов документа.', + 'default_label' => 'Теги по умолчанию', + 'default_hint' => 'Один тег на строку.', + 'default_kind' => 'lines', + 'entity_fields' => array('tags[]'), + ), + 'doc_from_rub' => array( + 'title' => 'Документ из рубрики', + 'icon' => 'file-export', + 'summary' => 'Связь с одним документом из выбранной рубрики.', + 'default_label' => 'Документ по умолчанию', + 'default_hint' => 'ID документа или пусто.', + 'default_kind' => 'relation', + 'entity_fields' => array('document_id'), + ), + 'doc_from_rub_all' => array( + 'title' => 'Документы из рубрики', + 'icon' => 'files', + 'summary' => 'Вывод всех связанных документов из рубрики.', + 'default_label' => 'Значение по умолчанию', + 'default_hint' => 'Обычно пусто; выбор задаётся настройками legacy-поля.', + 'default_kind' => 'textarea', + 'entity_fields' => array('rubric_id', 'template'), + ), + 'doc_from_rub_check' => array( + 'title' => 'Документы из рубрики с выбором', + 'icon' => 'checks', + 'summary' => 'Несколько связанных документов, legacy хранит выбранные ID через |.', + 'default_label' => 'ID документов по умолчанию', + 'default_hint' => 'Один ID на строку.', + 'default_kind' => 'relation_list', + 'entity_fields' => array('document_ids[]'), + ), + 'doc_from_rub_search' => array( + 'title' => 'Поиск документов из рубрики', + 'icon' => 'file-search', + 'summary' => 'Связанные документы с поиском, legacy хранит выбранные ID через |.', + 'default_label' => 'ID документов по умолчанию', + 'default_hint' => 'Один ID на строку.', + 'default_kind' => 'relation_list', + 'entity_fields' => array('document_ids[]'), + ), + 'catalog' => array( + 'title' => 'Каталог', + 'icon' => 'building-store', + 'summary' => 'Связь с legacy-модулем catalog.', + 'default_label' => 'Каталоги по умолчанию', + 'default_hint' => 'Один ID каталога на строку. Legacy сохранение документов использует сериализованный массив.', + 'default_kind' => 'lines', + 'entity_fields' => array('catalog_ids[]', 'document_ids[]', 'names[]'), + ), + 'analoque' => array( + 'title' => 'Аналоги / вместе с товаром', + 'icon' => 'relation-one-to-many', + 'summary' => 'Список связанных документов. Legacy-данные встречаются как ID через запятую, JSON или serialize.', + 'default_label' => 'ID документов по умолчанию', + 'default_hint' => 'Один ID на строку или через запятую.', + 'default_kind' => 'relation_list', + 'entity_fields' => array('document_ids[]'), + ), + 'teasers' => array( + 'title' => 'Тизеры документов', + 'icon' => 'layout-list', + 'summary' => 'Упорядоченный список документов, которые выводятся как тизеры.', + 'default_label' => 'Документы по умолчанию', + 'default_hint' => 'Один ID документа на строку или через запятую.', + 'default_kind' => 'relation_list', + 'entity_fields' => array('document_ids[]'), + ), + ); + } + + protected function pipeItems($value) + { + return $this->cleanItems(explode('|', (string) $value)); + } + + protected function csvItems($value) + { + return $this->cleanItems(explode(',', (string) $value)); + } + + protected function wrapPipeArray(array $items) + { + $items = $this->cleanItems($items); + return !empty($items) ? '|' . implode('|', $items) . '|' : ''; + } + + protected function documentIds($value) + { + if (is_array($value)) { + $items = $this->flatten($value); + $value = implode(',', $items); + } else { + $value = trim((string) $value); + } + + if ($value === '') { + return array(); + } + + $items = array($value); + $decoded = $this->structuredValue($value); + if (is_array($decoded)) { + $items = $this->flatten($decoded); + } + + $ids = array(); + foreach ($items as $item) { + foreach (preg_split('/[,\s]+/', (string) $item) ?: array() as $part) { + $part = trim((string) $part); + if ($part === '') { + continue; + } + + if (strpos($part, '|') !== false) { + $bits = explode('|', $part); + $part = trim((string) end($bits)); + } + + if (ctype_digit($part) && (int) $part > 0) { + $ids[] = (string) (int) $part; + } + } + } + + return array_values(array_unique($ids)); + } + + protected function flatten($value) + { + $out = array(); + if (is_array($value)) { + foreach ($value as $item) { + $out = array_merge($out, $this->flatten($item)); + } + + return $out; + } + + return array((string) $value); + } + + protected function cleanItems(array $items) + { + $out = array(); + foreach ($items as $item) { + $item = trim((string) $item); + if ($item !== '') { + $out[] = $item; + } + } + + return array_values($out); + } + } diff --git a/adminx/modules/Rubrics/FieldEditors/SimpleFieldEditor.php b/adminx/modules/Rubrics/FieldEditors/SimpleFieldEditor.php new file mode 100644 index 0000000..7bbee64 --- /dev/null +++ b/adminx/modules/Rubrics/FieldEditors/SimpleFieldEditor.php @@ -0,0 +1,639 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics\FieldEditors; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Helpers\Json; + + class SimpleFieldEditor extends AbstractFieldEditor + { + public function parseValue($type, $value) + { + $type = (string) $type; + $value = (string) $value; + + if ($type === 'checkbox') { + return array('raw' => $value, 'checked' => (int) $value === 1); + } + + if ($type === 'date') { + return array( + 'raw' => $value, + 'timestamp' => preg_match('/^\d+$/', $value) ? (int) $value : 0, + 'date' => preg_match('/^\d+$/', $value) && (int) $value > 0 ? date('Y-m-d', (int) $value) : '', + ); + } + + if ($type === 'date_time') { + $timestamp = \App\Content\Fields\Types\DateTimeValue::normalize($value); + $timestamp = $timestamp === '' ? 0 : (int) $timestamp; + return array( + 'raw' => $value, + 'timestamp' => $timestamp, + 'date' => $timestamp > 0 ? date('Y-m-d', $timestamp) : '', + 'datetime' => $timestamp > 0 ? date('Y-m-d\TH:i', $timestamp) : '', + ); + } + + if ($type === 'period') { + $period = \App\Content\Fields\Types\PeriodValue::parseValue($value); + return array('raw' => $value, 'start' => $period['start'], 'end' => $period['end']); + } + + if ($type === 'range') { + $range = \App\Content\Fields\Types\RangeValue::parseValue($value); + return array('raw' => $value, 'min' => $range['min'], 'max' => $range['max']); + } + + if ($type === 'dimensions') { + $dimensions = \App\Content\Fields\Types\DimensionsValue::parseValue($value); + return array('raw' => $value) + $dimensions; + } + + if ($type === 'address') { + $address = \App\Content\Fields\Types\AddressValue::parseValue($value); + return array('raw' => $value) + $address; + } + + if (in_array($type, array('single_line_numeric_two', 'single_line_numeric_three'), true)) { + return array('raw' => $value, 'parts' => $this->cleanItems(explode('|', $value))); + } + + if ($type === 'single_line_numeric') { + return array('raw' => $value, 'number' => $value === '' ? '' : 0 + $value); + } + + if ($type === 'number') { + return array('raw' => $value, 'number' => $value); + } + + return parent::parseValue($type, $value); + } + + public function serializeValue($type, $value) + { + $type = (string) $type; + if (!is_array($value)) { + return (string) $value; + } + + if ($type === 'checkbox') { + return !empty($value['checked']) ? '1' : ''; + } + + if ($type === 'date') { + if (!empty($value['timestamp'])) { + return (string) (int) $value['timestamp']; + } + + if (!empty($value['date']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $value['date'])) { + $time = strtotime($value['date'] . ' 00:00:00'); + return $time !== false ? (string) $time : ''; + } + + return ''; + } + + if ($type === 'date_time') { + $input = isset($value['datetime']) ? (string) $value['datetime'] : (isset($value['date']) ? (string) $value['date'] : ''); + return \App\Content\Fields\Types\DateTimeValue::normalize($input); + } + + if ($type === 'period') { + return \App\Content\Fields\Types\PeriodValue::encodeValue($value); + } + + if ($type === 'range') { + $range = \App\Content\Fields\Types\RangeValue::parseValue($value); + return ($range['min'] === '' && $range['max'] === '') + ? '' + : Json::encode($range, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + if ($type === 'dimensions') { + $dimensions = \App\Content\Fields\Types\DimensionsValue::parseValue($value); + return count(array_filter($dimensions, 'strlen')) === 0 + ? '' + : Json::encode($dimensions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + if ($type === 'address') { + return \App\Content\Fields\Types\AddressValue::encodeValue($value); + } + + if (in_array($type, array('single_line_numeric_two', 'single_line_numeric_three'), true)) { + $parts = isset($value['parts']) && is_array($value['parts']) ? $value['parts'] : array(); + return implode('|', $this->cleanItems($parts)); + } + + if ($type === 'single_line_numeric') { + return isset($value['number']) && $value['number'] !== '' ? (string) (0 + $value['number']) : ''; + } + + if ($type === 'number') { + return isset($value['number']) ? trim((string) $value['number']) : ''; + } + + return parent::serializeValue($type, $value); + } + + public function normalizeDefault($type, $value) + { + $type = (string) $type; + $value = (string) $value; + + if ($type === 'checkbox') { + $normalized = trim(function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value)); + return in_array($normalized, array('1', 'true', 'yes', 'on', 'да', 'вкл', 'включено'), true) ? '1' : '0'; + } + + if ($type === 'number') { + return \App\Content\Fields\Types\NumberValue::normalize($value); + } + + if ($type === 'color') { + return \App\Content\Fields\Types\ColorValue::normalize($value); + } + + if ($type === 'range') { + $range = \App\Content\Fields\Types\RangeValue::parseValue($value); + return ($range['min'] === '' && $range['max'] === '') + ? '' + : Json::encode($range, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + if ($type === 'dimensions') { + $dimensions = \App\Content\Fields\Types\DimensionsValue::parseValue($value); + return count(array_filter($dimensions, 'strlen')) === 0 + ? '' + : Json::encode($dimensions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + if ($type === 'address') { + return \App\Content\Fields\Types\AddressValue::encodeValue($value); + } + + if (in_array($type, array('single_line_numeric', 'doc_from_rub'), true)) { + $value = trim($value); + return $value === '' ? '' : (string) (0 + $value); + } + + if (in_array($type, array('single_line_numeric_two', 'single_line_numeric_three'), true)) { + $parts = preg_split('/[|,\r\n]+/', $value); + $out = array(); + foreach ($parts ?: array() as $part) { + $part = trim((string) $part); + if ($part !== '') { + $out[] = (string) (0 + $part); + } + } + + return implode('|', $out); + } + + if ($type === 'date') { + $value = trim($value); + if ($value === '' || preg_match('/^\d+$/', $value)) { + return $value; + } + + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + $time = strtotime($value . ' 00:00:00'); + return $time !== false ? (string) $time : ''; + } + } + + if ($type === 'date_time') { + return \App\Content\Fields\Types\DateTimeValue::normalize($value); + } + + if ($type === 'period') { + return \App\Content\Fields\Types\PeriodValue::encodeValue($value); + } + + return $value; + } + + public function renderEdit(array $field) + { + $kind = isset($field['editor_kind']) ? (string) $field['editor_kind'] : 'text'; + $type = isset($field['rubric_field_type']) ? (string) $field['rubric_field_type'] : ''; + $parsed = $this->value($field); + $settings = isset($field['settings']) && is_array($field['settings']) ? $field['settings'] : array(); + $editorHeight = $this->editorHeight($settings, $kind === 'code' ? 280 : 300); + + if ($type === 'multi_line' && isset($settings['editor']) && (string) $settings['editor'] === 'plain') { + $kind = 'textarea'; + } + + if ($type === 'content') { + $mode = isset($settings['mode']) ? (string) $settings['mode'] : 'rich'; + $kind = $mode === 'code' ? 'code' : ($mode === 'plain' ? 'textarea' : 'rich'); + } + + if ($type === 'date_time') { + $mode = isset($settings['mode']) ? (string) $settings['mode'] : 'date'; + $timestamp = isset($field['field_value']) ? (int) $field['field_value'] : 0; + $isDate = $mode !== 'datetime'; + return ''; + } + + if ($type === 'period') { + $period = \App\Content\Fields\Types\PeriodValue::parseValue(isset($field['field_value']) ? $field['field_value'] : ''); + $dateOnly = !isset($settings['mode']) || (string) $settings['mode'] !== 'datetime'; + $inputType = $dateOnly ? 'date' : 'datetime-local'; + $inputFormat = $dateOnly ? 'Y-m-d' : 'Y-m-d\TH:i'; + $name = $this->e($this->fname($field, '')); + $html = '
    '; + foreach (array('start' => 'Начало', 'end' => 'Окончание') as $key => $label) { + $timestamp = isset($period[$key]) ? (int) $period[$key] : 0; + $html .= ''; + } + + return $html . '
    '; + } + + if ($type === 'contact') { + $mode = isset($settings['mode']) ? (string) $settings['mode'] : 'email'; + $inputType = $mode === 'phone' ? 'tel' : ($mode === 'url' ? 'url' : 'email'); + $placeholder = $mode === 'phone' ? '+7 999 000-00-00' : ($mode === 'url' ? 'https://example.com' : 'name@example.com'); + return ''; + } + + if ($type === 'color') { + $value = \App\Content\Fields\Types\ColorValue::normalize($parsed); + return '
    ' + . '' + . '
    '; + } + + if ($type === 'range') { + $range = \App\Content\Fields\Types\RangeValue::parseValue(isset($field['field_value']) ? $field['field_value'] : ''); + $unit = isset($settings['unit']) ? trim((string) $settings['unit']) : ''; + $minLabel = isset($settings['min_label']) && trim((string) $settings['min_label']) !== '' ? (string) $settings['min_label'] : 'От'; + $maxLabel = isset($settings['max_label']) && trim((string) $settings['max_label']) !== '' ? (string) $settings['max_label'] : 'До'; + $html = '
    '; + foreach (array('min' => $minLabel, 'max' => $maxLabel) as $key => $label) { + $html .= ''; + } + + return $html . '
    '; + } + + if ($type === 'dimensions') { + $dimensions = \App\Content\Fields\Types\DimensionsValue::parseValue(isset($field['field_value']) ? $field['field_value'] : ''); + $units = array('mm' => 'мм', 'cm' => 'см', 'm' => 'м'); + $unitKey = isset($settings['unit']) ? (string) $settings['unit'] : 'cm'; + $unit = isset($units[$unitKey]) ? $units[$unitKey] : $units['cm']; + $html = '
    '; + foreach (array('length' => 'Длина', 'width' => 'Ширина', 'height' => 'Высота') as $key => $label) { + $html .= ''; + } + + return $html . '
    '; + } + + if ($type === 'address') { + $address = \App\Content\Fields\Types\AddressValue::parseValue(isset($field['field_value']) ? $field['field_value'] : ''); + $name = $this->e($this->fname($field, '')); + $html = '
    '; + foreach (array( + 'postal_code' => 'Индекс', + 'region' => 'Регион', + 'city' => 'Город / населённый пункт', + 'street' => 'Улица', + 'building' => 'Дом / строение', + 'unit' => 'Квартира / офис', + ) as $key => $label) { + $html .= ''; + } + + if (!empty($settings['coordinates'])) { + foreach (array('latitude' => 'Широта', 'longitude' => 'Долгота') as $key => $label) { + $html .= ''; + } + } else { + $html .= '' + . ''; + } + + return $html . '
    '; + } + + if ($kind === 'boolean') { + $on = (string) $parsed === '1'; + $name = $this->e($this->fname($field, '[checked]')); + return '
    ' + . '' + . '' + . '
    '; + } + + if ($kind === 'date') { + return ''; + } + + if ($kind === 'number') { + return ''; + } + + if ($kind === 'numeric_parts') { + $count = $type === 'single_line_numeric_three' ? 3 : 2; + $parts = is_array($parsed) ? $parsed : array(); + $html = '
    '; + for ($i = 0; $i < $count; $i++) { + $val = isset($parts[$i]) ? $parts[$i] : ''; + $html .= ''; + } + + return $html . '
    '; + } + + if ($kind === 'url') { + return ''; + } + + if ($kind === 'code') { + $language = isset($settings['language']) ? (string) $settings['language'] : 'html'; + return ''; + } + + if ($kind === 'rich' || $kind === 'rich_simple' || $kind === 'rich_slim') { + $preset = $kind === 'rich' ? 'full' : ($kind === 'rich_simple' ? 'simple' : 'slim'); + return ''; + } + + if ($kind === 'textarea') { + return ''; + } + + if ($kind === 'text') { + $options = isset($field['options']) && is_array($field['options']) ? $field['options'] : array(); + if (!empty($options)) { + $html = ''; + } + + return ''; + } + + return $this->renderGeneric($field); + } + + protected function definitions() + { + return array( + 'single_line' => array( + 'title' => 'Строка', + 'icon' => 'forms', + 'summary' => 'Обычное текстовое поле для короткого значения.', + 'default_label' => 'Текст по умолчанию', + 'default_hint' => 'Можно оставить пустым. Значение будет подставлено при создании документа.', + 'default_kind' => 'text', + 'template_hint' => 'HTML/PHP-шаблон вывода значения поля на сайте.', + 'entity_fields' => array('value'), + ), + 'single_line_numeric' => array( + 'title' => 'Число', + 'icon' => 'number', + 'summary' => 'Одно числовое значение.', + 'default_label' => 'Число по умолчанию', + 'default_hint' => 'Можно оставить пустым или указать число.', + 'default_kind' => 'number', + 'entity_fields' => array('number'), + ), + 'number' => array( + 'title' => 'Числовое значение', + 'icon' => 'number', + 'summary' => 'Число с настраиваемым публичным форматом: сумма, процент, величина или рейтинг.', + 'default_label' => 'Число по умолчанию', + 'default_hint' => 'Можно использовать точку или запятую. В хранилище значение нормализуется автоматически.', + 'default_kind' => 'number', + 'entity_fields' => array('number'), + ), + 'date_time' => array( + 'title' => 'Дата и время', + 'icon' => 'calendar-time', + 'summary' => 'Дата или дата со временем в одном типе с настраиваемым публичным форматом.', + 'default_label' => 'Дата по умолчанию', + 'default_hint' => 'Хранится как Unix timestamp и редактируется нативным контролом браузера.', + 'default_kind' => 'datetime', + 'entity_fields' => array('timestamp'), + ), + 'period' => array( + 'title' => 'Период', + 'icon' => 'calendar-event', + 'summary' => 'Дата начала и окончания для событий, публикаций и временных блоков.', + 'default_label' => 'Период по умолчанию', + 'default_hint' => 'Формат: начало|окончание. Поддерживаются YYYY-MM-DD и дата со временем.', + 'default_kind' => 'period', + 'entity_fields' => array('start', 'end'), + ), + 'single_line_numeric_two' => array( + 'title' => 'Два числа', + 'icon' => 'numbers', + 'summary' => 'Две числовые части, которые legacy хранит через вертикальную черту.', + 'default_label' => 'Числа по умолчанию', + 'default_hint' => 'Формат: значение1|значение2.', + 'default_kind' => 'pipe', + 'entity_fields' => array('parts[]'), + ), + 'single_line_numeric_three' => array( + 'title' => 'Три числа', + 'icon' => 'numbers', + 'summary' => 'Три числовые части, которые legacy хранит через вертикальную черту.', + 'default_label' => 'Числа по умолчанию', + 'default_hint' => 'Формат: значение1|значение2|значение3.', + 'default_kind' => 'pipe', + 'entity_fields' => array('parts[]'), + ), + 'multi_line' => array( + 'title' => 'Многострочный текст', + 'icon' => 'align-left', + 'summary' => 'Полноценный визуальный HTML-редактор.', + 'default_label' => 'Текст по умолчанию', + 'default_hint' => 'Сохраняется как HTML, совместимый с legacy multi_line.', + 'default_kind' => 'rich', + 'entity_fields' => array('value'), + ), + 'content' => array( + 'title' => 'Текстовое содержимое', + 'icon' => 'file-text', + 'summary' => 'Единое поле с выбором обычного текста, Tiptap или CodeMirror.', + 'default_label' => 'Содержимое по умолчанию', + 'default_hint' => 'Режим редактора и публичного вывода задаётся в настройках типа.', + 'default_kind' => 'rich', + 'entity_fields' => array('value'), + ), + 'richtext' => array( + 'title' => 'Форматированный текст', + 'icon' => 'text-format', + 'summary' => 'Визуальный HTML-редактор с полным набором инструментов.', + 'default_label' => 'Текст по умолчанию', + 'default_hint' => 'Сохраняется как HTML и редактируется через общий rich-text редактор.', + 'default_kind' => 'rich', + 'entity_fields' => array('value'), + ), + 'multi_line_simple' => array( + 'title' => 'Простой многострочный текст', + 'icon' => 'align-left', + 'summary' => 'Визуальный HTML-редактор средней высоты.', + 'default_label' => 'Текст по умолчанию', + 'default_kind' => 'rich_simple', + 'entity_fields' => array('value'), + ), + 'multi_line_slim' => array( + 'title' => 'Компактный многострочный текст', + 'icon' => 'align-left', + 'summary' => 'Компактный визуальный HTML-редактор.', + 'default_label' => 'Текст по умолчанию', + 'default_kind' => 'rich_slim', + 'entity_fields' => array('value'), + ), + 'checkbox' => array( + 'title' => 'Флажок', + 'icon' => 'checkbox', + 'summary' => 'Булево значение: включено или выключено.', + 'default_label' => 'Значение по умолчанию', + 'default_hint' => 'Используйте 1 для включенного состояния или 0 для выключенного.', + 'default_kind' => 'boolean', + 'entity_fields' => array('checked'), + ), + 'date' => array( + 'title' => 'Дата', + 'icon' => 'calendar', + 'summary' => 'Дата или дата-время в документе.', + 'default_label' => 'Дата по умолчанию', + 'default_hint' => 'Поддерживаются YYYY-MM-DD, timestamp или пустое значение.', + 'default_kind' => 'date', + 'entity_fields' => array('timestamp', 'date'), + ), + 'link' => array( + 'title' => 'Ссылка', + 'icon' => 'link', + 'summary' => 'URL или относительная ссылка.', + 'default_label' => 'Ссылка по умолчанию', + 'default_hint' => 'Например /catalog или https://example.com.', + 'default_kind' => 'url', + 'entity_fields' => array('value'), + ), + 'contact' => array( + 'title' => 'Контакт', + 'icon' => 'address-book', + 'summary' => 'Email, телефон или URL с режимной валидацией и безопасной публичной ссылкой.', + 'default_label' => 'Значение по умолчанию', + 'default_hint' => 'Формат значения определяется настройкой типа контакта.', + 'default_kind' => 'text', + 'entity_fields' => array('value'), + ), + 'color' => array( + 'title' => 'Цвет', + 'icon' => 'palette', + 'summary' => 'HEX-цвет с нативным picker и синхронным ручным вводом.', + 'default_label' => 'Цвет по умолчанию', + 'default_hint' => 'Формат #rgb или #rrggbb.', + 'default_kind' => 'text', + 'entity_fields' => array('value'), + ), + 'range' => array( + 'title' => 'Числовой диапазон', + 'icon' => 'arrows-horizontal', + 'summary' => 'Нижняя и верхняя граница с единицей измерения и JSON-хранением.', + 'default_label' => 'Диапазон по умолчанию', + 'default_hint' => 'Формат: минимум|максимум. Одну границу можно оставить пустой.', + 'default_kind' => 'range', + 'entity_fields' => array('min', 'max'), + ), + 'dimensions' => array( + 'title' => 'Габариты', + 'icon' => 'box-model-2', + 'summary' => 'Длина, ширина и высота с единицей измерения и JSON-хранением.', + 'default_label' => 'Габариты по умолчанию', + 'default_hint' => 'Формат: длина|ширина|высота.', + 'default_kind' => 'dimensions', + 'entity_fields' => array('length', 'width', 'height'), + ), + 'address' => array( + 'title' => 'Адрес', + 'icon' => 'map-pin', + 'summary' => 'Структурированный почтовый адрес с необязательными координатами.', + 'default_label' => 'Адрес по умолчанию', + 'default_hint' => 'Формат: индекс|регион|город|улица|дом|помещение|широта|долгота.', + 'default_kind' => 'address', + 'entity_fields' => array('postal_code', 'region', 'city', 'street', 'building', 'unit', 'latitude', 'longitude'), + ), + 'code' => array( + 'title' => 'Код', + 'icon' => 'code-dots', + 'summary' => 'Редактор исходного кода.', + 'default_label' => 'Код по умолчанию', + 'default_kind' => 'code', + 'entity_fields' => array('value'), + ), + ); + } + + protected function cleanItems(array $items) + { + $out = array(); + foreach ($items as $item) { + $item = trim((string) $item); + if ($item !== '') { + $out[] = $item; + } + } + + return array_values($out); + } + + protected function editorHeight(array $settings, $default) + { + $height = isset($settings['height']) ? (int) $settings['height'] : (int) $default; + return max(140, min(900, $height)); + } + } diff --git a/adminx/modules/Rubrics/FieldTypes.php b/adminx/modules/Rubrics/FieldTypes.php new file mode 100644 index 0000000..668a795 --- /dev/null +++ b/adminx/modules/Rubrics/FieldTypes.php @@ -0,0 +1,223 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Content\Fields\FieldRegistry; + use App\Common\AuditLog; + use App\Common\ModuleSettings; + + class FieldTypes + { + protected static $cache = null; + protected static $enabledLoaded = false; + protected static $enabledIds = array(); + protected static $newTypeIds = array( + 'content', + 'number', + 'date_time', + 'choice', + 'contact', + 'color', + 'range', + 'dimensions', + 'packages', + 'address', + 'period', + ); + + public static function all() + { + if (self::$cache !== null) { + return self::$cache; + } + + $out = array(); + + foreach (FieldRegistry::all() as $id => $plugin) { + $out[$id] = self::nativeDescribe($id, $plugin); + } + + uasort($out, function ($a, $b) { + return strcasecmp($a['name'], $b['name']); + }); + + self::$cache = array_values($out); + return self::$cache; + } + + public static function names() + { + $names = array(); + foreach (self::all() as $type) { + $names[$type['id']] = $type['name']; + } + + return $names; + } + + /** Types available when creating a new rubric field. */ + public static function enabled() + { + return array_values(array_filter(self::all(), function ($type) { + return !empty($type['enabled']); + })); + } + + public static function isEnabled($id) + { + return in_array((string) $id, self::enabledIds(), true); + } + + public static function setEnabled($id, $enabled, $actorId = 0) + { + $id = (string) $id; + if (!FieldRegistry::has($id)) { + throw new \RuntimeException('Тип поля не найден'); + } + + $before = self::isEnabled($id); + $ids = self::enabledIds(); + if ($enabled && !in_array($id, $ids, true)) { + $ids[] = $id; + } elseif (!$enabled) { + $ids = array_values(array_diff($ids, array($id))); + } + + sort($ids, SORT_NATURAL | SORT_FLAG_CASE); + ModuleSettings::set('enabled_field_types', $ids, 'rubrics', 'json'); + self::reset(); + AuditLog::record($enabled ? 'rubric.field_type_enabled' : 'rubric.field_type_disabled', array( + 'actor_id' => (int) $actorId > 0 ? (int) $actorId : null, + 'target_type' => 'field_type', + 'meta' => array('code' => $id, 'before' => $before, 'after' => (bool) $enabled), + )); + return self::one($id); + } + + public static function one($id) + { + $id = (string) $id; + foreach (self::all() as $type) { + if ($type['id'] === $id) { + return $type; + } + } + + return null; + } + + public static function exists($id) + { + return self::one($id) !== null; + } + + public static function summary(array $usage = array()) + { + $types = self::all(); + $ok = 0; + $warning = 0; + $error = 0; + $assets = 0; + $templates = 0; + foreach ($types as $type) { + if ($type['status'] === 'ok') { + $ok++; + } elseif ($type['status'] === 'error') { + $error++; + } else { + $warning++; + } + + $assets += count($type['assets']); + $templates += count($type['templates']); + } + + return array( + 'types' => count($types), + 'new' => count(array_filter($types, function ($type) { return !empty($type['is_new']); })), + 'enabled' => count(array_filter($types, function ($type) { return !empty($type['enabled']); })), + 'disabled' => count(array_filter($types, function ($type) { return empty($type['enabled']); })), + 'ok' => $ok, + 'warning' => $warning, + 'error' => $error, + 'assets' => $assets, + 'templates' => $templates, + 'used' => array_sum($usage), + ); + } + + protected static function nativeDescribe($id, $plugin) + { + $editor = FieldAdminEditors::describe($id); + $registration = FieldRegistry::metadata($id); + $creatable = !is_array($registration) || !array_key_exists('creatable', $registration) || !empty($registration['creatable']); + return array( + 'id' => (string) $id, + 'name' => (string) $plugin->name(), + 'source' => is_array($registration) && isset($registration['source']) ? (string) $registration['source'] : 'core', + 'function' => '', + 'path' => '', + 'directory' => '', + 'templates' => array(), + 'assets' => array(), + 'lang' => array(), + 'capabilities' => array( + 'legacy_function' => false, + 'admin_template' => true, + 'document_template' => true, + 'request_template' => true, + 'options_template' => !empty($plugin->settingsSchema()), + 'assets' => false, + 'native' => true, + ), + 'description' => isset($editor['summary']) ? (string) $editor['summary'] : (string) $plugin->name(), + 'is_new' => in_array((string) $id, self::$newTypeIds, true), + 'enabled' => $creatable && self::isEnabled($id), + 'installed' => true, + 'creatable' => $creatable, + 'admin_editor' => $editor, + 'status' => 'ok', + 'issues' => array(), + ); + } + + protected static function enabledIds() + { + if (self::$enabledLoaded) { + return self::$enabledIds; + } + + $configured = ModuleSettings::get('enabled_field_types', null, 'rubrics'); + $available = array_keys(FieldRegistry::all()); + if (!is_array($configured)) { + self::$enabledIds = array_values($available); + } else { + self::$enabledIds = array_values(array_intersect(array_map('strval', $configured), $available)); + } + + self::$enabledLoaded = true; + return self::$enabledIds; + } + + protected static function reset() + { + self::$cache = null; + self::$enabledLoaded = false; + self::$enabledIds = array(); + } + + } diff --git a/adminx/modules/Rubrics/Model.php b/adminx/modules/Rubrics/Model.php new file mode 100644 index 0000000..b82522f --- /dev/null +++ b/adminx/modules/Rubrics/Model.php @@ -0,0 +1,1449 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Content\CatalogTables; + use App\Content\ContentTables; + use App\Content\PublicUserTables; + use App\Common\AuditLog; + use App\Common\FileCacheInvalidator; + use App\Content\Fields\FieldConditionEvaluator; + use App\Content\Fields\FieldSettings; + use App\Content\Fields\FieldSettingsForm; + use App\Helpers\Json; + + class Model + { + public static function rubricsTable() { return ContentTables::table('rubrics'); } + public static function fieldsTable() { return ContentTables::table('rubric_fields'); } + public static function groupsTable() { return ContentTables::table('rubric_fields_group'); } + public static function templatesTable() { return ContentTables::table('rubric_templates'); } + public static function docsTable() { return ContentTables::table('documents'); } + public static function docFieldsTable() { return ContentTables::table('document_fields'); } + public static function docFieldsTextTable() { return ContentTables::table('document_fields_text'); } + public static function permissionsTable() { return ContentTables::table('rubric_permissions'); } + public static function userGroupsTable() { return PublicUserTables::table('user_groups'); } + + /** Зарегистрированные шаблоны сайта (Id + название) для выпадающего выбора. */ + public static function templateOptions() + { + try { + return DB::query('SELECT Id, template_title FROM ' . ContentTables::table('templates') . ' ORDER BY template_title ASC')->getAll() ?: array(); + } catch (\Throwable $e) { + return array(); + } + } + + public static function all($q = '', $state = '') + { + $sql = 'SELECT r.*,' + . ' (SELECT COUNT(*) FROM ' . self::fieldsTable() . ' f WHERE f.rubric_id=r.Id) AS fields_count,' + . ' (SELECT COUNT(*) FROM ' . self::docsTable() . ' d WHERE d.rubric_id=r.Id) AS docs_count,' + . ' (SELECT COUNT(*) FROM ' . self::templatesTable() . ' t WHERE t.rubric_id=r.Id) AS templates_count,' + . ' (SELECT st.template_title FROM ' . ContentTables::table('templates') . ' st WHERE st.Id=r.rubric_template_id LIMIT 1) AS site_template_title' + . ' FROM ' . self::rubricsTable() . ' r WHERE 1=1'; + $args = array(); + $q = trim((string) $q); + if ($q !== '') { + $sql .= ' AND (r.rubric_title LIKE %ss OR r.rubric_alias LIKE %ss OR r.rubric_description LIKE %ss OR r.Id = %i)'; + $args[] = $q; + $args[] = $q; + $args[] = $q; + $args[] = (int) $q; + } + + if ($state === 'active') { + $sql .= ' AND r.rubric_docs_active = 1'; + } elseif ($state === 'inactive') { + $sql .= ' AND r.rubric_docs_active = 0'; + } elseif ($state === 'meta') { + $sql .= ' AND r.rubric_meta_gen = %s'; + $args[] = '1'; + } + + $sql .= ' ORDER BY r.rubric_position ASC, r.Id ASC'; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = self::rubricRow($row); + } + + return $out; + } + + public static function one($id) + { + $row = DB::query('SELECT * FROM ' . self::rubricsTable() . ' WHERE Id = %i LIMIT 1', (int) $id)->getAssoc(); + return $row ? self::rubricRow($row, true) : null; + } + + public static function stats() + { + return array( + 'rubrics' => (int) DB::query('SELECT COUNT(*) FROM ' . self::rubricsTable())->getValue(), + 'active' => (int) DB::query('SELECT COUNT(*) FROM ' . self::rubricsTable() . ' WHERE rubric_docs_active = 1')->getValue(), + 'fields' => (int) DB::query('SELECT COUNT(*) FROM ' . self::fieldsTable())->getValue(), + 'groups' => (int) DB::query('SELECT COUNT(*) FROM ' . self::groupsTable())->getValue(), + ); + } + + public static function fieldTypeUsage() + { + $rows = DB::query( + 'SELECT rubric_field_type, COUNT(*) AS cnt FROM ' . self::fieldsTable() . ' GROUP BY rubric_field_type' + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[(string) $row['rubric_field_type']] = (int) $row['cnt']; + } + + return $out; + } + + public static function saveRubric($id, array $input, $authorId) + { + $data = self::rubricInput($input); + $now = time(); + if ((int) $id > 0) { + $before = DB::query( + 'SELECT rubric_code_start, rubric_code_end, rubric_start_code FROM ' . self::rubricsTable() . ' WHERE Id = %i LIMIT 1', + (int) $id + )->getAssoc(); + $data['rubric_changed'] = $now; + $data['rubric_changed_fields'] = $now; + DB::Update(self::rubricsTable(), $data, 'Id = %i', (int) $id); + self::clearRubricCache((int) $id); + self::auditRubricSave('rubric.updated', (int) $id, (int) $authorId, $before, $data); + return (int) $id; + } + + $data['rubric_author_id'] = (int) $authorId > 0 ? (int) $authorId : 1; + $data['rubric_created'] = $now; + $data['rubric_changed'] = $now; + $data['rubric_changed_fields'] = $now; + $data['rubric_position'] = self::nextPosition(self::rubricsTable(), 'rubric_position'); + $data['rubric_template'] = '[tag:maincontent]'; + $data['rubric_teaser_template'] = ''; + $data['rubric_header_template'] = ''; + $data['rubric_og_template'] = ''; + $data['rubric_footer_template'] = ''; + $data['rubric_linked_rubric'] = '0'; + DB::Insert(self::rubricsTable(), $data); + $newId = (int) DB::insertId(); + self::seedPermissions($newId); + self::clearRubricCache($newId); + self::auditRubricSave('rubric.created', $newId, (int) $authorId, array(), $data); + return $newId; + } + + protected static function auditRubricSave($action, $rubricId, $authorId, array $before, array $after) + { + $changed = array(); + foreach (array('rubric_code_start', 'rubric_code_end', 'rubric_start_code') as $key) { + $changed[$key] = (string) (isset($before[$key]) ? $before[$key] : '') !== (string) (isset($after[$key]) ? $after[$key] : ''); + } + + AuditLog::record((string) $action, array( + 'actor_id' => (int) $authorId > 0 ? (int) $authorId : null, + 'target_type' => 'rubric', + 'target_id' => (int) $rubricId, + 'meta' => array('managed_code_changed' => $changed), + )); + } + + public static function deleteRubric($id) + { + $id = (int) $id; + if ($id <= 1) { + throw new \RuntimeException('Рубрика #1 является системной и не может быть удалена'); + } + + $docs = self::documentCount($id); + if ($docs > 0) { + throw new \RuntimeException('В рубрике есть документы, удаление недоступно'); + } + + $dependencies = self::rubricDependencies($id); + if (!empty($dependencies)) { + throw new \RuntimeException('Рубрика используется: ' . implode(', ', $dependencies) . '. Сначала уберите эти связи.'); + } + + $fields = DB::query('SELECT Id FROM ' . self::fieldsTable() . ' WHERE rubric_id = %i', $id)->getAll() ?: array(); + foreach ($fields as $field) { + $fieldDependencies = self::fieldDependencies((int) $field['Id']); + if (!empty($fieldDependencies)) { + throw new \RuntimeException( + 'Поле #' . (int) $field['Id'] . ' используется: ' . implode(', ', $fieldDependencies) + . '. Сначала уберите эти связи.' + ); + } + } + + DB::Delete(self::fieldsTable(), 'rubric_id = %i', $id); + DB::Delete(self::groupsTable(), 'rubric_id = %i', $id); + DB::Delete(self::templatesTable(), 'rubric_id = %i', $id); + DB::Delete(self::permissionsTable(), 'rubric_id = %i', $id); + DB::Delete(AdminView::table(), 'rubric_id = %i', $id); + RubricFieldSetLinks::deleteForRubric($id); + DB::Delete(self::rubricsTable(), 'Id = %i', $id); + RubricRevisions::deleteForRubric($id); + self::clearRubricCache($id); + return true; + } + + public static function fieldsForRubric($rubricId) + { + $groupSettings = self::hasGroupSettingsColumn() ? ', g.group_settings' : ', NULL AS group_settings'; + $rows = DB::query( + 'SELECT f.*, g.group_title, g.group_description, g.group_position' . $groupSettings + . ' FROM ' . self::fieldsTable() . ' f' + . ' LEFT JOIN ' . self::groupsTable() . ' g ON g.Id = f.rubric_field_group' + . ' WHERE f.rubric_id = %i' + . ' ORDER BY COALESCE(g.group_position, 999999) ASC, f.rubric_field_position ASC, f.Id ASC', + (int) $rubricId + )->getAll(); + $typeNames = FieldTypes::names(); + $out = array(); + foreach ($rows as $row) { + $out[] = self::fieldRow($row, $typeNames); + } + + return $out; + } + + public static function fieldGroups($rubricId) + { + $rows = DB::query( + 'SELECT g.*, (SELECT COUNT(*) FROM ' . self::fieldsTable() . ' f WHERE f.rubric_field_group = g.Id) AS fields_count' + . ' FROM ' . self::groupsTable() . ' g WHERE g.rubric_id = %i ORDER BY g.group_position ASC, g.Id ASC', + (int) $rubricId + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = self::groupRow($row); + } + + return $out; + } + + public static function group($id) + { + return DB::query('SELECT * FROM ' . self::groupsTable() . ' WHERE Id=%i LIMIT 1', (int) $id)->getAssoc() ?: null; + } + + public static function groupedFields($rubricId) + { + $groups = array(0 => array('id' => 0, 'title' => 'Без группы', 'description' => '', 'position' => 999999, 'items' => array())); + foreach (self::fieldGroups($rubricId) as $group) { + $group['items'] = array(); + $groups[$group['id']] = $group; + } + + foreach (self::fieldsForRubric($rubricId) as $field) { + if (isset($field['layout']['placed']) && !$field['layout']['placed']) { + continue; + } + + $groupId = (int) $field['rubric_field_group']; + if (!isset($groups[$groupId])) { + $groupId = 0; + } + + $groups[$groupId]['items'][] = $field; + } + + return array_values($groups); + } + + public static function availableFields($rubricId) + { + $available = array(); + foreach (self::fieldsForRubric($rubricId) as $field) { + if (isset($field['layout']['placed']) && !$field['layout']['placed']) { + $available[] = $field; + } + } + + return $available; + } + + /** Полный, но не содержащий значения документов снимок схемы формы рубрики. */ + public static function schemaSnapshot($rubricId) + { + $rubricId = (int) $rubricId; + $rubric = DB::query('SELECT * FROM ' . self::rubricsTable() . ' WHERE Id=%i LIMIT 1', $rubricId)->getAssoc(); + if (!$rubric) { return array(); } + $groupSettings = self::hasGroupSettingsColumn() ? ',group_settings' : ',NULL AS group_settings'; + $groups = DB::query( + 'SELECT Id,rubric_id,group_title,group_description,group_position' . $groupSettings . ' FROM ' . self::groupsTable() + . ' WHERE rubric_id=%i ORDER BY Id', + $rubricId + )->getAll() ?: array(); + $fields = DB::query('SELECT * FROM ' . self::fieldsTable() . ' WHERE rubric_id=%i ORDER BY Id', $rubricId)->getAll() ?: array(); + + return self::normalizeSchemaSnapshot(array( + 'version' => 1, + 'rubric' => array( + 'id' => $rubricId, + 'form_conditions_enabled' => !empty($rubric['rubric_form_conditions']), + ), + 'groups' => $groups, + 'fields' => $fields, + )); + } + + public static function normalizeSchemaSnapshot(array $snapshot) + { + $groupKeys = array('Id', 'rubric_id', 'group_title', 'group_description', 'group_position', 'group_settings'); + $fieldKeys = array( + 'Id', 'rubric_id', 'rubric_field_group', 'rubric_field_alias', 'rubric_field_title', + 'rubric_field_type', 'rubric_field_numeric', 'rubric_field_position', 'rubric_field_default', + 'rubric_field_settings', 'rubric_field_search', 'rubric_field_template', + 'rubric_field_template_request', 'rubric_field_description', + ); + $out = array( + 'version' => 1, + 'rubric' => array( + 'id' => isset($snapshot['rubric']['id']) ? (int) $snapshot['rubric']['id'] : 0, + 'form_conditions_enabled' => !empty($snapshot['rubric']['form_conditions_enabled']), + ), + 'groups' => array(), + 'fields' => array(), + ); + foreach (isset($snapshot['groups']) && is_array($snapshot['groups']) ? $snapshot['groups'] : array() as $group) { + if (!is_array($group) || empty($group['Id'])) { continue; } + $row = array(); + foreach ($groupKeys as $key) { $row[$key] = isset($group[$key]) ? $group[$key] : ''; } + $row['Id'] = (int) $row['Id']; + $row['rubric_id'] = (int) $row['rubric_id']; + $row['group_position'] = (int) $row['group_position']; + $out['groups'][] = $row; + } + + foreach (isset($snapshot['fields']) && is_array($snapshot['fields']) ? $snapshot['fields'] : array() as $field) { + if (!is_array($field) || empty($field['Id'])) { continue; } + $row = array(); + foreach ($fieldKeys as $key) { $row[$key] = isset($field[$key]) ? $field[$key] : ''; } + foreach (array('Id', 'rubric_id', 'rubric_field_group', 'rubric_field_position') as $key) { $row[$key] = (int) $row[$key]; } + $out['fields'][] = $row; + } + + usort($out['groups'], function ($a, $b) { return $a['Id'] <=> $b['Id']; }); + usort($out['fields'], function ($a, $b) { return $a['Id'] <=> $b['Id']; }); + + return $out; + } + + /** Восстанавливает схему без удаления полей, появившихся после снимка. */ + public static function applySchemaSnapshot($rubricId, array $snapshot) + { + $rubricId = (int) $rubricId; + $snapshot = self::normalizeSchemaSnapshot($snapshot); + if (!self::one($rubricId)) { throw new \RuntimeException('Рубрика не найдена'); } + + $currentGroups = DB::query('SELECT * FROM ' . self::groupsTable() . ' WHERE rubric_id=%i', $rubricId)->getAll() ?: array(); + $currentFields = DB::query('SELECT * FROM ' . self::fieldsTable() . ' WHERE rubric_id=%i', $rubricId)->getAll() ?: array(); + $groupMap = array(); + $currentGroupIds = array(); + foreach ($currentGroups as $group) { $currentGroupIds[(int) $group['Id']] = true; } + foreach ($snapshot['groups'] as $group) { + $sourceId = (int) $group['Id']; + $data = array( + 'rubric_id' => $rubricId, + 'group_title' => (string) $group['group_title'], + 'group_description' => (string) $group['group_description'], + 'group_position' => (int) $group['group_position'], + ); + if (self::hasGroupSettingsColumn()) { + $data['group_settings'] = trim((string) $group['group_settings']) !== '' ? (string) $group['group_settings'] : null; + } + + if (isset($currentGroupIds[$sourceId])) { + DB::Update(self::groupsTable(), $data, 'Id=%i AND rubric_id=%i', $sourceId, $rubricId); + $groupMap[$sourceId] = $sourceId; + continue; + } + + $owner = DB::query('SELECT rubric_id FROM ' . self::groupsTable() . ' WHERE Id=%i LIMIT 1', $sourceId)->getValue(); + if (!$owner) { $data['Id'] = $sourceId; } + DB::Insert(self::groupsTable(), $data); + $groupMap[$sourceId] = !$owner ? $sourceId : (int) DB::insertId(); + } + + $currentFieldIds = array(); + foreach ($currentFields as $field) { $currentFieldIds[(int) $field['Id']] = $field; } + $targetFieldIds = array(); + $restored = 0; + $updated = 0; + foreach ($snapshot['fields'] as $field) { + $sourceId = (int) $field['Id']; + $targetFieldIds[$sourceId] = true; + $data = self::schemaFieldData($field, $rubricId, $groupMap); + if (isset($currentFieldIds[$sourceId])) { + DB::Update(self::fieldsTable(), $data, 'Id=%i AND rubric_id=%i', $sourceId, $rubricId); + $updated++; + continue; + } + + $owner = DB::query('SELECT rubric_id FROM ' . self::fieldsTable() . ' WHERE Id=%i LIMIT 1', $sourceId)->getValue(); + if ($owner) { + throw new \RuntimeException('ID поля #' . $sourceId . ' уже занят другой рубрикой'); + } + + $data['Id'] = $sourceId; + DB::Insert(self::fieldsTable(), $data); + $newId = $sourceId; + self::seedDocumentFields($rubricId, $newId); + $restored++; + } + + $preserved = 0; + foreach ($currentFieldIds as $fieldId => $field) { + if (isset($targetFieldIds[$fieldId])) { continue; } + $settings = FieldSettings::effective($field); + $settings['placed'] = false; + DB::Update(self::fieldsTable(), array('rubric_field_settings' => FieldSettings::encode($settings)), 'Id=%i', $fieldId); + $preserved++; + } + + if (self::hasRubricFormConditionsColumn()) { + DB::Update(self::rubricsTable(), array( + 'rubric_form_conditions' => !empty($snapshot['rubric']['form_conditions_enabled']) ? 1 : 0, + ), 'Id=%i', $rubricId); + } + + self::touchFields($rubricId); + return array('rubric_id' => $rubricId, 'fields_updated' => $updated, 'fields_restored' => $restored, 'fields_preserved' => $preserved); + } + + protected static function schemaFieldData(array $field, $rubricId, array $groupMap) + { + $groupId = (int) $field['rubric_field_group']; + return array( + 'rubric_id' => (int) $rubricId, + 'rubric_field_group' => isset($groupMap[$groupId]) ? (int) $groupMap[$groupId] : 0, + 'rubric_field_alias' => (string) $field['rubric_field_alias'], + 'rubric_field_title' => (string) $field['rubric_field_title'], + 'rubric_field_type' => (string) $field['rubric_field_type'], + 'rubric_field_numeric' => (string) $field['rubric_field_numeric'] === '1' ? '1' : '0', + 'rubric_field_position' => max(1, (int) $field['rubric_field_position']), + 'rubric_field_default' => (string) $field['rubric_field_default'], + 'rubric_field_settings' => (string) $field['rubric_field_settings'], + 'rubric_field_search' => (string) $field['rubric_field_search'] === '0' ? '0' : '1', + 'rubric_field_template' => (string) $field['rubric_field_template'], + 'rubric_field_template_request' => (string) $field['rubric_field_template_request'], + 'rubric_field_description' => (string) $field['rubric_field_description'], + ); + } + + public static function field($id) + { + $row = DB::query( + 'SELECT f.*, g.group_title, g.group_description, g.group_position FROM ' . self::fieldsTable() . ' f' + . ' LEFT JOIN ' . self::groupsTable() . ' g ON g.Id = f.rubric_field_group' + . ' WHERE f.Id = %i LIMIT 1', + (int) $id + )->getAssoc(); + return $row ? self::fieldRow($row, FieldTypes::names(), true) : null; + } + + public static function saveField($id, $rubricId, array $input) + { + $rubricId = (int) $rubricId; + $data = self::fieldInput($input); + if ((int) $id > 0) { + $field = self::field($id); + if (!$field) { + throw new \RuntimeException('Поле не найдено'); + } + + if ((string) $field['rubric_field_type'] !== (string) $data['rubric_field_type'] && self::fieldHasValues((int) $id)) { + throw new \RuntimeException('Нельзя изменить тип заполненного поля. Сначала перенесите или очистите его значения.'); + } + + if (self::hasFieldSettingsColumn() && isset($data['rubric_field_settings'])) { + $oldSettings = FieldSettings::effective($field); + $newSettings = Json::toArray((string) $data['rubric_field_settings']); + $explicitLayout = isset($input['rubric_field_layout']) && is_array($input['rubric_field_layout']) + ? $input['rubric_field_layout'] + : array(); + if (array_key_exists('placed', $oldSettings) && !array_key_exists('placed', $explicitLayout)) { + $newSettings['placed'] = !empty($oldSettings['placed']); + } + + if (isset($oldSettings['form_condition']) && !isset($newSettings['form_condition'])) { + $newSettings['form_condition'] = $oldSettings['form_condition']; + } + + $data['rubric_field_settings'] = FieldSettings::encode($newSettings); + } + + self::assertGroupBelongsToRubric((int) $data['rubric_field_group'], (int) $field['rubric_id']); + DB::Update(self::fieldsTable(), $data, 'Id = %i', (int) $id); + self::touchFields((int) $field['rubric_id']); + return (int) $id; + } + + self::assertGroupBelongsToRubric((int) $data['rubric_field_group'], $rubricId); + $data['rubric_id'] = $rubricId; + $data['rubric_field_position'] = self::nextFieldPosition($rubricId, (int) $data['rubric_field_group']); + DB::Insert(self::fieldsTable(), $data); + $newId = (int) DB::insertId(); + self::seedDocumentFields($rubricId, $newId); + self::touchFields($rubricId); + return $newId; + } + + public static function deleteField($id) + { + $field = self::field($id); + if (!$field) { + return false; + } + + $dependencies = self::fieldDependencies((int) $id); + if (!empty($dependencies)) { + throw new \RuntimeException('Поле используется: ' . implode(', ', $dependencies) . '. Сначала уберите эти связи.'); + } + + DB::Delete(self::fieldsTable(), 'Id = %i', (int) $id); + DB::Delete(self::docFieldsTable(), 'rubric_field_id = %i', (int) $id); + if (self::tableExists(self::docFieldsTextTable())) { + DB::Delete(self::docFieldsTextTable(), 'rubric_field_id = %i', (int) $id); + } + + self::deleteDerivedFieldData((int) $id); + self::touchFields((int) $field['rubric_id']); + return true; + } + + public static function saveGroup($id, $rubricId, array $input) + { + $group = null; + if ((int) $id > 0) { + $group = DB::query('SELECT * FROM ' . self::groupsTable() . ' WHERE Id = %i LIMIT 1', (int) $id)->getAssoc(); + if (!$group) { throw new \RuntimeException('Группа не найдена'); } + $rubricId = (int) $group['rubric_id']; + } + + $data = array( + 'group_title' => trim(isset($input['group_title']) ? (string) $input['group_title'] : ''), + 'group_description' => isset($input['group_description']) ? (string) $input['group_description'] : '', + ); + if (self::hasGroupSettingsColumn()) { + $settings = $group ? Json::toArray(isset($group['group_settings']) ? $group['group_settings'] : '') : array(); + if (array_key_exists('group_condition', $input)) { + $rawCondition = Json::toArray($input['group_condition']); + $condition = FieldConditionEvaluator::normalize($rawCondition, self::fieldsForRubric((int) $rubricId)); + if ($condition) { $settings['form_condition'] = $condition; } + else { unset($settings['form_condition']); } + } + + $data['group_settings'] = Json::payload($settings); + } elseif (isset($input['group_condition']) && trim((string) $input['group_condition']) !== '' && trim((string) $input['group_condition']) !== '{}') { + throw new \RuntimeException('Сначала примените миграции рубрик для условий групп'); + } + + if ((int) $id > 0) { + DB::Update(self::groupsTable(), $data, 'Id = %i', (int) $id); + self::touchFields((int) $group['rubric_id']); + return (int) $id; + } + + if (!self::one((int) $rubricId)) { + throw new \RuntimeException('Рубрика не найдена'); + } + + $data['rubric_id'] = (int) $rubricId; + $data['group_position'] = self::nextGroupPosition((int) $rubricId); + DB::Insert(self::groupsTable(), $data); + $newId = (int) DB::insertId(); + self::touchFields((int) $rubricId); + return $newId; + } + + public static function deleteGroup($id) + { + $group = DB::query('SELECT * FROM ' . self::groupsTable() . ' WHERE Id = %i LIMIT 1', (int) $id)->getAssoc(); + if (!$group) { + return false; + } + + DB::Update(self::fieldsTable(), array('rubric_field_group' => 0), 'rubric_field_group = %i', (int) $id); + DB::Delete(self::groupsTable(), 'Id = %i', (int) $id); + self::touchFields((int) $group['rubric_id']); + return true; + } + + public static function reorderRubrics(array $ids) + { + foreach ($ids as $position => $id) { + DB::Update(self::rubricsTable(), array('rubric_position' => (int) $position + 1), 'Id = %i', (int) $id); + } + } + + public static function reorderFields($rubricId, array $items) + { + $validGroups = array(0 => true); + foreach (self::fieldGroups((int) $rubricId) as $group) { + $validGroups[(int) $group['id']] = true; + } + + foreach ($items as $position => $item) { + if (!is_array($item) || empty($item['id'])) { + continue; + } + + $groupId = isset($item['group_id']) ? (int) $item['group_id'] : 0; + if (!isset($validGroups[$groupId])) { + throw new \RuntimeException('Группа поля не принадлежит рубрике'); + } + + DB::Update(self::fieldsTable(), array( + 'rubric_field_position' => (int) $position + 1, + 'rubric_field_group' => $groupId, + ), 'Id = %i AND rubric_id = %i', (int) $item['id'], (int) $rubricId); + } + + self::touchFields((int) $rubricId); + } + + /** Атомарно сохраняет раскладку, быстрые настройки и состояние размещения полей. */ + public static function saveFieldBuilder($rubricId, array $items, array $drafts, $conditionsEnabled = false) + { + $rubricId = (int) $rubricId; + if (!self::one($rubricId)) { + throw new \RuntimeException('Рубрика не найдена'); + } + + $fields = self::fieldsForRubric($rubricId); + $fieldMap = array(); + foreach ($fields as $field) { + $fieldMap[(int) $field['Id']] = $field; + } + + $validGroups = array(0 => true); + foreach (self::fieldGroups($rubricId) as $group) { + $validGroups[(int) $group['id']] = true; + } + + $ordered = array(); + foreach ($items as $item) { + if (!is_array($item) || empty($item['id'])) { + throw new \RuntimeException('Некорректный элемент раскладки'); + } + + $fieldId = (int) $item['id']; + $groupId = isset($item['group_id']) ? (int) $item['group_id'] : 0; + if (!isset($fieldMap[$fieldId])) { + throw new \RuntimeException('Поле #' . $fieldId . ' не принадлежит рубрике'); + } + + if (isset($ordered[$fieldId])) { + throw new \RuntimeException('Поле #' . $fieldId . ' повторяется в раскладке'); + } + + if (!isset($validGroups[$groupId])) { + throw new \RuntimeException('Группа поля не принадлежит рубрике'); + } + + $item['group_id'] = $groupId; + $ordered[$fieldId] = $item; + } + + $draftMap = array(); + foreach ($drafts as $draft) { + if (!is_array($draft) || empty($draft['id'])) { + continue; + } + + $fieldId = (int) $draft['id']; + if (!isset($fieldMap[$fieldId])) { + throw new \RuntimeException('Настройки поля не принадлежат рубрике'); + } + + $draftMap[$fieldId] = $draft; + } + + DB::startTransaction(); + try { + if (self::hasRubricFormConditionsColumn()) { + DB::Update( + self::rubricsTable(), + array('rubric_form_conditions' => $conditionsEnabled ? 1 : 0), + 'Id = %i', + $rubricId + ); + } elseif ($conditionsEnabled) { + throw new \RuntimeException('Примените миграцию рубрик перед включением условий формы'); + } + + $position = 0; + $saveOrder = array_keys($ordered); + foreach ($fieldMap as $fieldId => $unused) { + if (!isset($ordered[$fieldId])) { + $saveOrder[] = $fieldId; + } + } + + foreach ($saveOrder as $fieldId) { + $field = $fieldMap[$fieldId]; + $isPlaced = isset($ordered[$fieldId]); + $item = $isPlaced ? $ordered[$fieldId] : array(); + $draft = isset($draftMap[$fieldId]) ? $draftMap[$fieldId] : array(); + $layout = array( + 'placed' => $isPlaced, + ); + $settings = FieldSettings::effective($field); + if ($isPlaced) { + $layout['width'] = isset($item['width']) ? (string) $item['width'] : ''; + $layout['prefix'] = isset($item['prefix']) ? (string) $item['prefix'] : ''; + $layout['suffix'] = isset($item['suffix']) ? (string) $item['suffix'] : ''; + } else { + $layout['width'] = isset($settings['width']) ? (string) $settings['width'] : ''; + $layout['prefix'] = isset($settings['prefix']) ? (string) $settings['prefix'] : ''; + $layout['suffix'] = isset($settings['suffix']) ? (string) $settings['suffix'] : ''; + } + + if (array_key_exists('settings', $draft) && is_array($draft['settings'])) { + foreach (FieldSettingsForm::keys((string) $field['rubric_field_type']) as $key) { + unset($settings[$key]); + } + + $settings = array_replace( + $settings, + FieldSettingsForm::collect((string) $field['rubric_field_type'], $draft['settings']) + ); + } + + $settings = self::mergeFieldLayout($settings, $layout); + if (array_key_exists('condition', $draft)) { + $conditionFields = $fieldMap; + $conditionFields[$fieldId]['rubric_field_settings'] = FieldSettings::encode($settings); + $condition = FieldConditionEvaluator::normalize($draft['condition'], array_values($conditionFields), $fieldId); + if ($condition) { $settings['form_condition'] = $condition; } + else { unset($settings['form_condition']); } + } + + $data = array( + 'rubric_field_position' => ++$position, + 'rubric_field_group' => $isPlaced ? (int) $item['group_id'] : (int) $field['rubric_field_group'], + ); + if (self::hasFieldSettingsColumn()) { + $data['rubric_field_settings'] = FieldSettings::encode($settings); + } + + if (!empty($draft)) { + if (array_key_exists('title', $draft)) { + $title = trim((string) $draft['title']); + if ($title === '') { + throw new \RuntimeException('Укажите наименование поля #' . $fieldId); + } + + $data['rubric_field_title'] = $title; + } + + $data['rubric_field_default'] = self::normalizeFieldDefault( + (string) $field['rubric_field_type'], + isset($draft['default']) ? (string) $draft['default'] : (string) $field['rubric_field_default'] + ); + $data['rubric_field_description'] = isset($draft['description']) + ? (string) $draft['description'] + : (string) $field['rubric_field_description']; + $data['rubric_field_search'] = !empty($draft['search']) ? '1' : '0'; + $nativeNumeric = in_array((string) $field['rubric_field_type'], array('number', 'date_time', 'period'), true); + $data['rubric_field_numeric'] = $nativeNumeric || !empty($draft['numeric']) ? '1' : '0'; + } + + DB::Update(self::fieldsTable(), $data, 'Id = %i AND rubric_id = %i', $fieldId, $rubricId); + } + + DB::commit(); + } catch (\Throwable $e) { + DB::rollback(); + throw $e; + } + + self::touchFields($rubricId); + } + + public static function reorderGroups($rubricId, array $ids) + { + foreach ($ids as $position => $id) { + DB::Update(self::groupsTable(), array('group_position' => (int) $position + 1), 'Id = %i AND rubric_id = %i', (int) $id, (int) $rubricId); + } + + self::touchFields((int) $rubricId); + } + + public static function rubricAliasExists($alias, $excludeId = 0) + { + return (bool) DB::query('SELECT Id FROM ' . self::rubricsTable() . ' WHERE rubric_alias = %s AND Id != %i LIMIT 1', (string) $alias, (int) $excludeId)->getValue(); + } + + public static function fieldAliasExists($rubricId, $alias, $excludeId = 0) + { + return (bool) DB::query( + 'SELECT Id FROM ' . self::fieldsTable() . ' WHERE rubric_id = %i AND rubric_field_alias = %s AND Id != %i LIMIT 1', + (int) $rubricId, + (string) $alias, + (int) $excludeId + )->getValue(); + } + + public static function documentCount($rubricId) + { + return (int) DB::query('SELECT COUNT(*) FROM ' . self::docsTable() . ' WHERE rubric_id = %i', (int) $rubricId)->getValue(); + } + + public static function templatesForRubric($rubricId) + { + $rubric = self::one($rubricId); + if (!$rubric) { + return null; + } + + $rows = DB::query( + 'SELECT t.*, (SELECT COUNT(*) FROM ' . self::docsTable() . ' d WHERE d.rubric_id = t.rubric_id AND d.rubric_tmpl_id = t.id) AS docs_count' + . ' FROM ' . self::templatesTable() . ' t WHERE t.rubric_id = %i ORDER BY t.id ASC', + (int) $rubricId + )->getAll(); + $templates = array(); + foreach ($rows as $row) { + $templates[] = self::extraTemplateRow($row); + } + + return array( + 'rubric' => array( + 'Id' => (int) $rubric['Id'], + 'rubric_title' => (string) $rubric['rubric_title'], + 'rubric_template' => (string) $rubric['rubric_template'], + 'rubric_header_template' => (string) $rubric['rubric_header_template'], + 'rubric_og_template' => isset($rubric['rubric_og_template']) ? (string) $rubric['rubric_og_template'] : '', + 'rubric_footer_template' => (string) $rubric['rubric_footer_template'], + 'rubric_teaser_template' => (string) $rubric['rubric_teaser_template'], + ), + 'fields' => self::templateTags($rubricId), + 'templates' => $templates, + ); + } + + public static function saveMainTemplate($rubricId, array $input) + { + $data = array( + 'rubric_template' => isset($input['rubric_template']) ? (string) $input['rubric_template'] : '', + 'rubric_header_template' => isset($input['rubric_header_template']) ? (string) $input['rubric_header_template'] : '', + 'rubric_og_template' => isset($input['rubric_og_template']) ? (string) $input['rubric_og_template'] : '', + 'rubric_footer_template' => isset($input['rubric_footer_template']) ? (string) $input['rubric_footer_template'] : '', + 'rubric_teaser_template' => isset($input['rubric_teaser_template']) ? (string) $input['rubric_teaser_template'] : '', + 'rubric_changed' => time(), + ); + DB::Update(self::rubricsTable(), $data, 'Id = %i', (int) $rubricId); + self::clearRubricCache((int) $rubricId); + return true; + } + + public static function extraTemplate($id) + { + $row = DB::query( + 'SELECT t.*, (SELECT COUNT(*) FROM ' . self::docsTable() . ' d WHERE d.rubric_id = t.rubric_id AND d.rubric_tmpl_id = t.id) AS docs_count' + . ' FROM ' . self::templatesTable() . ' t WHERE t.id = %i LIMIT 1', + (int) $id + )->getAssoc(); + return $row ? self::extraTemplateRow($row) : null; + } + + public static function saveExtraTemplate($id, $rubricId, array $input, $authorId) + { + $data = array( + 'title' => trim(isset($input['title']) ? (string) $input['title'] : ''), + 'template' => isset($input['template']) ? (string) $input['template'] : '', + ); + if ((int) $id > 0) { + $current = self::extraTemplate($id); + if (!$current) { + throw new \RuntimeException('Шаблон рубрики не найден'); + } + + DB::Update(self::templatesTable(), $data, 'id = %i', (int) $id); + self::touchTemplate((int) $current['rubric_id']); + return (int) $id; + } + + $data['rubric_id'] = (int) $rubricId; + $data['author_id'] = (int) $authorId > 0 ? (int) $authorId : 1; + $data['created'] = time(); + DB::Insert(self::templatesTable(), $data); + $newId = (int) DB::insertId(); + self::touchTemplate((int) $rubricId); + return $newId; + } + + public static function deleteExtraTemplate($id) + { + $template = self::extraTemplate($id); + if (!$template) { + return false; + } + + if ((int) $template['docs_count'] > 0) { + throw new \RuntimeException('Шаблон используется документами и не может быть удалён'); + } + + DB::Delete(self::templatesTable(), 'id = %i', (int) $id); + self::touchTemplate((int) $template['rubric_id']); + return true; + } + + public static function templateTags($rubricId) + { + $fields = self::fieldsForRubric($rubricId); + $out = array(); + foreach ($fields as $field) { + $out[] = array( + 'id' => (int) $field['Id'], + 'title' => (string) $field['rubric_field_title'], + 'alias' => (string) $field['rubric_field_alias'], + 'tag' => (string) $field['tag'], + 'type_label' => (string) $field['type_label'], + ); + } + + return $out; + } + + public static function documentPicker($q = '', $rubricId = 0, $limit = 20) + { + $limit = max(5, min(50, (int) $limit)); + $sql = 'SELECT d.Id, d.rubric_id, d.document_title, d.document_alias, d.document_status, d.document_deleted, r.rubric_title' + . ' FROM ' . self::docsTable() . ' d' + . ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id' + . ' WHERE d.document_deleted != 1'; + $args = array(); + if ((int) $rubricId > 0) { + $sql .= ' AND d.rubric_id = %i'; + $args[] = (int) $rubricId; + } + + $q = trim((string) $q); + if ($q !== '') { + $sql .= ' AND (d.document_title LIKE %ss OR d.document_alias LIKE %ss OR d.Id = %i)'; + $args[] = $q; + $args[] = $q; + $args[] = (int) $q; + } + + $sql .= ' ORDER BY d.Id DESC LIMIT ' . (int) $limit; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = array( + 'id' => (int) $row['Id'], + 'rubric_id' => (int) $row['rubric_id'], + 'title' => (string) $row['document_title'], + 'alias' => (string) $row['document_alias'], + 'status' => (int) $row['document_status'], + 'rubric_title' => isset($row['rubric_title']) ? (string) $row['rubric_title'] : '', + ); + } + + return $out; + } + + protected static function rubricInput(array $input) + { + return array( + 'rubric_title' => trim(isset($input['rubric_title']) ? (string) $input['rubric_title'] : ''), + 'rubric_alias' => trim(isset($input['rubric_alias']) ? (string) $input['rubric_alias'] : ''), + 'rubric_template_id' => max(1, (int) (isset($input['rubric_template_id']) ? $input['rubric_template_id'] : 1)), + 'rubric_docs_active' => !empty($input['rubric_docs_active']) ? 1 : 0, + 'rubric_meta_gen' => !empty($input['rubric_meta_gen']) ? '1' : '0', + 'rubric_alias_history' => !empty($input['rubric_alias_history']) ? '1' : '0', + 'rubric_description' => isset($input['rubric_description']) ? (string) $input['rubric_description'] : '', + 'rubric_code_start' => isset($input['rubric_code_start']) ? (string) $input['rubric_code_start'] : '', + 'rubric_code_end' => isset($input['rubric_code_end']) ? (string) $input['rubric_code_end'] : '', + 'rubric_start_code' => isset($input['rubric_start_code']) ? (string) $input['rubric_start_code'] : '', + ); + } + + protected static function fieldInput(array $input) + { + $type = trim(isset($input['rubric_field_type']) ? (string) $input['rubric_field_type'] : ''); + $normalizedDefault = self::normalizeFieldDefault($type, isset($input['rubric_field_default']) ? (string) $input['rubric_field_default'] : ''); + $data = array( + 'rubric_field_group' => isset($input['rubric_field_group']) ? (int) $input['rubric_field_group'] : 0, + 'rubric_field_title' => trim(isset($input['rubric_field_title']) ? (string) $input['rubric_field_title'] : ''), + 'rubric_field_alias' => trim(isset($input['rubric_field_alias']) ? (string) $input['rubric_field_alias'] : ''), + 'rubric_field_type' => $type, + 'rubric_field_numeric' => in_array($type, array('number', 'date_time', 'period'), true) || !empty($input['rubric_field_numeric']) ? '1' : '0', + 'rubric_field_search' => !empty($input['rubric_field_search']) ? '1' : '0', + 'rubric_field_default' => $normalizedDefault, + 'rubric_field_template' => isset($input['rubric_field_template']) ? (string) $input['rubric_field_template'] : '', + 'rubric_field_template_request' => isset($input['rubric_field_template_request']) ? (string) $input['rubric_field_template_request'] : '', + 'rubric_field_description' => isset($input['rubric_field_description']) ? (string) $input['rubric_field_description'] : '', + ); + // JSON-настройки типа (миграция 002). Пишем только если колонка есть — + // иначе сохранение поля продолжает работать на не-мигрированной БД. + if (self::hasFieldSettingsColumn()) { + if (array_key_exists('rubric_field_settings_canonical', $input) && is_array($input['rubric_field_settings_canonical'])) { + $settings = $input['rubric_field_settings_canonical']; + } elseif (array_key_exists('rubric_field_settings', $input)) { + $settings = FieldSettingsForm::collect($type, $input['rubric_field_settings']); + } else { + $settings = FieldSettings::fromLegacy($type, $normalizedDefault); + } + + $settings = self::mergeFieldLayout($settings, isset($input['rubric_field_layout']) && is_array($input['rubric_field_layout']) ? $input['rubric_field_layout'] : array()); + $data['rubric_field_settings'] = FieldSettings::encode($settings); + } + + return $data; + } + + protected static function normalizeFieldDefault($type, $value) + { + return FieldAdminEditors::normalizeDefault($type, $value); + } + + /** Persist type configuration that older AVE versions kept in default. */ + public static function migrateFieldSettings() + { + static $done = false; + if ($done || !self::hasFieldSettingsColumn()) { + return 0; + } + + $done = true; + $updated = 0; + $rows = DB::query( + 'SELECT Id, rubric_field_type, rubric_field_default, rubric_field_settings FROM ' . self::fieldsTable() + )->getAll() ?: array(); + foreach ($rows as $row) { + $current = Json::toArray((string) $row['rubric_field_settings']); + $effective = FieldSettings::effective($row); + if ($effective === $current) { + continue; + } + + DB::Update( + self::fieldsTable(), + array('rubric_field_settings' => FieldSettings::encode($effective)), + 'Id = %i', + (int) $row['Id'] + ); + $updated++; + } + + return $updated; + } + + protected static function assertGroupBelongsToRubric($groupId, $rubricId) + { + if ((int) $groupId === 0) { + return; + } + + $exists = DB::query( + 'SELECT Id FROM ' . self::groupsTable() . ' WHERE Id = %i AND rubric_id = %i LIMIT 1', + (int) $groupId, + (int) $rubricId + )->getValue(); + if (!$exists) { + throw new \RuntimeException('Выбранная группа не принадлежит рубрике'); + } + } + + protected static function fieldHasValues($fieldId) + { + $textTable = self::docFieldsTextTable(); + $textJoin = self::tableExists($textTable) + ? ' LEFT JOIN ' . $textTable . ' t ON t.document_id=d.document_id AND t.rubric_field_id=d.rubric_field_id' + : ''; + $textWhere = self::tableExists($textTable) ? " OR COALESCE(t.field_value, '') != ''" : ''; + return (bool) DB::query( + 'SELECT d.document_id FROM ' . self::docFieldsTable() . ' d' . $textJoin + . " WHERE d.rubric_field_id = %i AND (COALESCE(d.field_value, '') != '' OR COALESCE(d.field_number_value, 0) != 0" + . $textWhere . ') LIMIT 1', + (int) $fieldId + )->getValue(); + } + + public static function fieldHasStoredValues($fieldId) + { + return self::fieldHasValues((int) $fieldId); + } + + protected static function fieldDependencies($fieldId) + { + $checks = array( + array(ContentTables::table('request_conditions'), 'condition_field_id', 'условия запросов'), + array(CatalogTables::table('module_catalog_settings'), 'field_id', 'настройки каталога'), + array(CatalogTables::table('module_catalog_items'), 'field_id', 'разделы каталога'), + array(CatalogTables::table('catalog_variant_attributes'), 'field_id', 'варианты товаров'), + array(ContentTables::table('feed_definitions'), 'catalog_field_id', 'товарные фиды'), + ); + $out = array(); + foreach ($checks as $check) { + if (self::tableExists($check[0]) && DB::query( + 'SELECT 1 FROM ' . $check[0] . ' WHERE ' . $check[1] . ' = %i LIMIT 1', + (int) $fieldId + )->getValue()) { + $out[] = $check[2]; + } + } + + if (self::tableExists(CatalogTables::table('module_catalog_settings'))) { + $columns = array( + 'product_title_field_id', 'product_article_field_id', 'product_price_field_id', + 'product_old_price_field_id', 'product_stock_field_id', 'product_images_field_id' + ); + foreach ($columns as $column) { + if (DB::query('SELECT 1 FROM ' . CatalogTables::table('module_catalog_settings') . ' WHERE ' . $column . ' = %i LIMIT 1', (int) $fieldId)->getValue()) { + $out[] = 'товарное представление каталога'; + break; + } + } + } + + if (self::tableExists(CatalogTables::table('module_catalog_items')) && DB::query( + 'SELECT 1 FROM ' . CatalogTables::table('module_catalog_items') + . ' WHERE FIND_IN_SET(%s, REPLACE(fields_use, %s, %s)) > 0' + . ' OR FIND_IN_SET(%s, REPLACE(filters_use, %s, %s)) > 0 LIMIT 1', + (string) (int) $fieldId, + ' ', + '', + (string) (int) $fieldId, + ' ', + '' + )->getValue()) { + $out[] = 'поля или фильтры разделов каталога'; + } + + if (self::feedUsesField((int) $fieldId)) { + $out[] = 'параметры товарных фидов'; + } + + return array_values(array_unique($out)); + } + + protected static function rubricDependencies($rubricId) + { + $checks = array( + array(ContentTables::table('request'), 'rubric_id', 'запросы'), + array(CatalogTables::table('module_catalog_settings'), 'rubric_id', 'настройки каталога'), + array(CatalogTables::table('module_catalog_items'), 'rubric_id', 'разделы каталога'), + array(ContentTables::table('feed_definitions'), 'rubric_id', 'товарные фиды'), + ); + $out = array(); + foreach ($checks as $check) { + if (self::tableExists($check[0]) && DB::query( + 'SELECT 1 FROM ' . $check[0] . ' WHERE ' . $check[1] . ' = %i LIMIT 1', + (int) $rubricId + )->getValue()) { + $out[] = $check[2]; + } + } + + return array_values(array_unique($out)); + } + + protected static function feedUsesField($fieldId) + { + $table = ContentTables::table('feed_definitions'); + if (!self::tableExists($table)) { + return false; + } + + $rows = DB::query('SELECT mappings_json, params_json, conditions_json FROM ' . $table)->getAll() ?: array(); + foreach ($rows as $row) { + foreach (array('mappings_json', 'params_json', 'conditions_json') as $column) { + $data = Json::toArray((string) $row[$column]); + if (is_array($data) && self::structuredDataUsesField($data, (int) $fieldId)) { + return true; + } + } + } + + return false; + } + + protected static function structuredDataUsesField(array $data, $fieldId) + { + foreach ($data as $key => $value) { + if (is_array($value)) { + if (self::structuredDataUsesField($value, (int) $fieldId)) { + return true; + } + + continue; + } + + if ((in_array((string) $key, array('field_id', 'fieldId'), true) && (int) $value === (int) $fieldId) + || (string) $value === 'field:' . (int) $fieldId) { + return true; + } + } + + return false; + } + + protected static function deleteDerivedFieldData($fieldId) + { + foreach (array( + CatalogTables::table('catalog_filter_index'), + CatalogTables::table('catalog_variant_attributes') + ) as $table) { + if (self::tableExists($table)) { + DB::Delete($table, 'field_id = %i', (int) $fieldId); + } + } + } + + /** + * Домешивает оформление поля в документе (ширина/обязательность/аффиксы) + * в JSON-настройки. required хранится как флаг — его подхватывает + * App\Content\Fields\FieldValidator; width/prefix/suffix читает редактор + * документа. Пустые значения удаляются, чтобы не засорять настройки. + */ + protected static function mergeFieldLayout(array $settings, array $layout) + { + $settings['placed'] = !array_key_exists('placed', $layout) || !empty($layout['placed']); + + $width = isset($layout['width']) ? (string) $layout['width'] : ''; + if (in_array($width, array('full', 'half', 'third', 'quarter'), true)) { + $settings['width'] = $width; + } else { + unset($settings['width']); + } + + // required не трогаем: он приходит из валидации типа (FieldSettingsForm), + // здесь только раскладка. Иначе перетёрли бы флаг обязательности. + + foreach (array('prefix', 'suffix') as $key) { + $val = isset($layout[$key]) ? trim((string) $layout[$key]) : ''; + if ($val !== '') { + $settings[$key] = mb_substr($val, 0, 12); + } else { + unset($settings[$key]); + } + } + + return $settings; + } + + protected static function rubricRow(array $row) + { + $row['Id'] = (int) $row['Id']; + $row['id'] = (int) $row['Id']; + $row['fields_count'] = isset($row['fields_count']) ? (int) $row['fields_count'] : 0; + $row['docs_count'] = isset($row['docs_count']) ? (int) $row['docs_count'] : 0; + $row['templates_count'] = isset($row['templates_count']) ? (int) $row['templates_count'] : 0; + $row['site_template_title'] = isset($row['site_template_title']) ? trim((string) $row['site_template_title']) : ''; + $row['form_conditions_enabled'] = !empty($row['rubric_form_conditions']); + $row['can_delete'] = $row['Id'] > 1 && $row['docs_count'] === 0; + $row['status_label'] = (string) $row['rubric_docs_active'] === '1' ? 'активна' : 'выкл'; + return $row; + } + + protected static function fieldRow(array $row, array $typeNames) + { + $row['Id'] = (int) $row['Id']; + $row['id'] = (int) $row['Id']; + $row['rubric_id'] = (int) $row['rubric_id']; + $row['rubric_field_group'] = (int) $row['rubric_field_group']; + $row['type_label'] = isset($typeNames[$row['rubric_field_type']]) ? $typeNames[$row['rubric_field_type']] : $row['rubric_field_type']; + $row['tag'] = '[tag:fld:' . ($row['rubric_field_alias'] !== '' ? $row['rubric_field_alias'] : $row['Id']) . ']'; + $settings = FieldSettings::effective($row); + $row['layout'] = array( + 'placed' => !array_key_exists('placed', $settings) || !empty($settings['placed']), + 'width' => isset($settings['width']) ? (string) $settings['width'] : '', + 'required' => !empty($settings['required']), + 'prefix' => isset($settings['prefix']) ? (string) $settings['prefix'] : '', + 'suffix' => isset($settings['suffix']) ? (string) $settings['suffix'] : '', + ); + $row['condition'] = FieldConditionEvaluator::condition($row); + $row['condition_options'] = self::conditionOptions($settings); + $row['condition_value_action'] = FieldConditionEvaluator::supportsValueAction($row); + $row['condition_action_options'] = $row['condition_options']; + if ((string) $row['rubric_field_type'] === 'checkbox') { + $row['condition_action_options'] = array( + array('value' => '1', 'label' => 'Да / включено'), + array('value' => '0', 'label' => 'Нет / выключено'), + ); + } + + return $row; + } + + protected static function conditionOptions(array $settings) + { + $raw = isset($settings['options']) && is_array($settings['options']) ? $settings['options'] : array(); + $options = array(); + foreach ($raw as $key => $item) { + if (is_array($item)) { + $value = isset($item['value']) ? (string) $item['value'] : (isset($item['key']) ? (string) $item['key'] : (string) $key); + $label = isset($item['label']) ? (string) $item['label'] : (isset($item['title']) ? (string) $item['title'] : $value); + } else { + $label = trim((string) $item); + $value = is_string($key) ? (string) $key : $label; + } + + if ($value === '' || $label === '') { continue; } + $options[] = array('value' => $value, 'label' => $label); + } + + return $options; + } + + protected static function groupRow(array $row) + { + $condition = FieldConditionEvaluator::groupCondition($row); + return array( + 'id' => (int) $row['Id'], + 'rubric_id' => (int) $row['rubric_id'], + 'title' => (string) $row['group_title'], + 'description' => (string) $row['group_description'], + 'position' => (int) $row['group_position'], + 'fields_count' => isset($row['fields_count']) ? (int) $row['fields_count'] : 0, + 'condition' => $condition, + ); + } + + protected static function extraTemplateRow(array $row) + { + $row['id'] = (int) $row['id']; + $row['rubric_id'] = (int) $row['rubric_id']; + $row['author_id'] = (int) $row['author_id']; + $row['created'] = (int) $row['created']; + $row['docs_count'] = isset($row['docs_count']) ? (int) $row['docs_count'] : 0; + $row['created_label'] = $row['created'] > 0 ? date('d.m.Y H:i', $row['created']) : 'не задано'; + $row['size_label'] = self::formatBytes(strlen((string) $row['template'])); + $row['can_delete'] = $row['docs_count'] === 0; + return $row; + } + + protected static function seedPermissions($rubricId) + { + $groups = DB::query('SELECT user_group FROM ' . self::userGroupsTable())->getAll(); + foreach ($groups as $group) { + $groupId = (int) $group['user_group']; + DB::Insert(self::permissionsTable(), array( + 'rubric_id' => (int) $rubricId, + 'user_group_id' => $groupId, + 'rubric_permission' => $groupId === 1 ? 'alles|docread|new|newnow|editown|editall|delrev' : 'docread', + )); + } + } + + protected static function seedDocumentFields($rubricId, $fieldId) + { + $docs = DB::query('SELECT Id FROM ' . self::docsTable() . ' WHERE rubric_id = %i', (int) $rubricId)->getAll(); + foreach ($docs as $doc) { + DB::Insert(self::docFieldsTable(), array( + 'rubric_field_id' => (int) $fieldId, + 'document_id' => (int) $doc['Id'], + )); + } + } + + protected static function touchFields($rubricId) + { + DB::Update(self::rubricsTable(), array('rubric_changed' => time(), 'rubric_changed_fields' => time()), 'Id = %i', (int) $rubricId); + self::clearRubricCache((int) $rubricId); + } + + protected static function touchTemplate($rubricId) + { + DB::Update(self::rubricsTable(), array('rubric_changed' => time()), 'Id = %i', (int) $rubricId); + self::clearRubricCache((int) $rubricId); + } + + protected static function formatBytes($bytes) + { + $bytes = (int) $bytes; + if ($bytes >= 1048576) { + return round($bytes / 1048576, 1) . ' MB'; + } + + if ($bytes >= 1024) { + return round($bytes / 1024, 1) . ' KB'; + } + + return $bytes . ' B'; + } + + protected static function clearRubricCache($rubricId) + { + FileCacheInvalidator::rubric($rubricId); + } + + protected static function nextPosition($table, $field) + { + return (int) DB::query('SELECT COALESCE(MAX(' . $field . '), 0) + 1 FROM ' . $table)->getValue(); + } + + protected static function nextFieldPosition($rubricId, $groupId) + { + return (int) DB::query( + 'SELECT COALESCE(MAX(rubric_field_position), 0) + 1 FROM ' . self::fieldsTable() . ' WHERE rubric_id = %i AND rubric_field_group = %i', + (int) $rubricId, + (int) $groupId + )->getValue(); + } + + protected static function nextGroupPosition($rubricId) + { + return (int) DB::query('SELECT COALESCE(MAX(group_position), 0) + 1 FROM ' . self::groupsTable() . ' WHERE rubric_id = %i', (int) $rubricId)->getValue(); + } + + protected static function tableExists($table) + { + return (bool) DB::query('SHOW TABLES LIKE %s', (string) $table)->getValue(); + } + + /** Есть ли колонка rubric_field_settings (миграция 002). Кешируется на запрос. */ + protected static function hasFieldSettingsColumn() + { + static $has = null; + if ($has === null) { + $has = (bool) DB::query('SHOW COLUMNS FROM ' . self::fieldsTable() . ' LIKE %s', 'rubric_field_settings')->getValue(); + } + + return $has; + } + + protected static function hasRubricFormConditionsColumn() + { + static $has = null; + if ($has === null) { + $has = (bool) DB::query('SHOW COLUMNS FROM ' . self::rubricsTable() . ' LIKE %s', 'rubric_form_conditions')->getValue(); + } + + return $has; + } + + protected static function hasGroupSettingsColumn() + { + static $has = null; + if ($has === null) { + $has = (bool) DB::query('SHOW COLUMNS FROM ' . self::groupsTable() . ' LIKE %s', 'group_settings')->getValue(); + } + + return $has; + } + } diff --git a/adminx/modules/Rubrics/RubricFieldPresets.php b/adminx/modules/Rubrics/RubricFieldPresets.php new file mode 100644 index 0000000..4ec72d9 --- /dev/null +++ b/adminx/modules/Rubrics/RubricFieldPresets.php @@ -0,0 +1,527 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AuditLog; + use App\Content\Fields\FieldSettings; + use App\Content\Rubrics\FieldSetRegistry; + + /** Optional starter field sets for newly created content rubrics. */ + class RubricFieldPresets + { + public static function all() + { + $out = array(); + foreach (self::registeredDefinitions() as $code => $preset) { + $types = self::fieldTypes($preset); + $missing = array(); + foreach ($types as $type) { + if (!FieldTypes::isEnabled($type)) { + $missing[] = $type; + } + } + + $preset['code'] = $code; + $preset['field_count'] = self::fieldCount($preset); + $preset['available'] = !empty($preset['available']) && empty($missing); + $preset['missing_types'] = $missing; + $out[] = $preset; + } + + return $out; + } + + public static function exists($code) + { + $definitions = self::registeredDefinitions(); + return $code === '' || isset($definitions[(string) $code]); + } + + public static function definition($code) + { + $definitions = self::registeredDefinitions(); + $code = strtolower(trim((string) $code)); + return isset($definitions[$code]) ? $definitions[$code] : null; + } + + public static function availabilityError($code) + { + $code = trim((string) $code); + if ($code === '') { + return ''; + } + + $definitions = self::registeredDefinitions(); + if (!isset($definitions[$code])) { + return 'Неизвестный стартовый набор полей'; + } + + if (empty($definitions[$code]['available'])) { + return 'Набор полей принадлежит отключённому модулю'; + } + + $missing = array(); + foreach (self::fieldTypes($definitions[$code]) as $type) { + if (!FieldTypes::isEnabled($type)) { + $missing[] = $type; + } + } + + return empty($missing) + ? '' + : 'Для набора включите типы полей: ' . implode(', ', $missing); + } + + public static function apply($code, $rubricId, $actorId = 0) + { + $code = trim((string) $code); + if ($code === '') { + return array('groups' => 0, 'fields' => 0); + } + + $error = self::availabilityError($code); + if ($error !== '') { + throw new \RuntimeException($error); + } + + $definitions = self::registeredDefinitions(); + return self::applyDefinition($definitions[$code], $rubricId, $actorId, $code); + } + + /** Применить уже проверенное переносимое определение в режиме независимой копии. */ + public static function applyDefinition(array $preset, $rubricId, $actorId = 0, $source = 'import', $fingerprint = '') + { + $preset = FieldSetRegistry::normalizeDefinition(isset($preset['code']) ? $preset['code'] : '', $preset); + self::assertDefinitionAvailable($preset); + $preview = self::previewDefinition($preset, $rubricId); + if ($fingerprint !== '' && !hash_equals((string) $preview['fingerprint'], (string) $fingerprint)) { + throw new \RuntimeException('Состав рубрики изменился после проверки. Выполните предварительный просмотр ещё раз.'); + } + + if (!empty($preview['conflicts'])) { + throw new \RuntimeException('Набор конфликтует с полями рубрики: ' . implode(', ', $preview['conflicts'])); + } + + $groups = 0; + $fields = 0; + $skipped = 0; + $fieldMap = array(); + $existingFields = array(); + $existingById = array(); + $existingRows = Model::fieldsForRubric((int) $rubricId); + foreach ($existingRows as $field) { + $existingFields[strtolower((string) $field['rubric_field_alias'])] = $field; + $existingById[(int) $field['Id']] = $field; + } + + $sameSourceSchema = self::sourceSchemaMatches($preset, $existingRows); + + $existingGroups = array(); + foreach (Model::fieldGroups((int) $rubricId) as $group) { + $key = self::groupKey($group['title']); + if ($key !== '' && !isset($existingGroups[$key])) { $existingGroups[$key] = (int) $group['id']; } + } + + foreach ($preset['groups'] as $group) { + $pending = array(); + foreach ($group['fields'] as $field) { + $sourceId = isset($field['source_field_id']) ? (int) $field['source_field_id'] : 0; + if (!empty($field['generated_alias']) && $sameSourceSchema && isset($existingById[$sourceId])) { + $fieldMap[strtolower((string) $field['alias'])] = (int) $existingById[$sourceId]['Id']; + $skipped++; + continue; + } + + $key = strtolower((string) $field['alias']); + if (isset($existingFields[$key])) { + $fieldMap[$key] = (int) $existingFields[$key]['Id']; + $skipped++; + continue; + } + + $pending[] = $field; + } + + if (!$pending) { continue; } + $ungrouped = !empty($group['ungrouped']); + $groupKey = self::groupKey($group['title']); + $groupId = $ungrouped ? 0 : (isset($existingGroups[$groupKey]) ? (int) $existingGroups[$groupKey] : 0); + if (!$ungrouped && $groupId <= 0) { + $groupId = Model::saveGroup(0, (int) $rubricId, array( + 'group_title' => $group['title'], + 'group_description' => isset($group['description']) ? $group['description'] : '', + )); + $existingGroups[$groupKey] = $groupId; + $groups++; + } + + foreach ($pending as $field) { + $fieldId = Model::saveField(0, (int) $rubricId, array( + 'rubric_field_group' => $groupId, + 'rubric_field_title' => $field['title'], + 'rubric_field_alias' => $field['alias'], + 'rubric_field_type' => $field['type'], + 'rubric_field_search' => !empty($field['search']) ? '1' : '0', + 'rubric_field_numeric' => !empty($field['numeric']) ? '1' : '0', + 'rubric_field_default' => isset($field['default']) ? (string) $field['default'] : '', + 'rubric_field_description' => isset($field['description']) ? $field['description'] : '', + 'rubric_field_settings_canonical' => isset($field['settings']) ? $field['settings'] : array(), + 'rubric_field_layout' => array( + 'width' => isset($field['width']) ? $field['width'] : 'full', + 'placed' => !isset($field['settings']['placed']) || !empty($field['settings']['placed']), + 'prefix' => isset($field['settings']['prefix']) ? (string) $field['settings']['prefix'] : '', + 'suffix' => isset($field['settings']['suffix']) ? (string) $field['settings']['suffix'] : '', + ), + )); + $field['Id'] = $fieldId; + $existingFields[strtolower((string) $field['alias'])] = $field; + $fieldMap[strtolower((string) $field['alias'])] = $fieldId; + $fields++; + } + } + + AuditLog::record('rubric.field_set_applied', array( + 'actor_id' => (int) $actorId > 0 ? (int) $actorId : null, + 'target_type' => 'rubric', + 'target_id' => (int) $rubricId, + 'meta' => array('preset' => (string) $source, 'groups' => $groups, 'fields' => $fields, 'skipped' => $skipped), + )); + return array('groups' => $groups, 'fields' => $fields, 'skipped' => $skipped, 'field_map' => $fieldMap); + } + + public static function preview($code, $rubricId) + { + $code = trim((string) $code); + $error = self::availabilityError($code); + if ($error !== '') { throw new \RuntimeException($error); } + $definitions = self::registeredDefinitions(); + return self::previewDefinition($definitions[$code], $rubricId); + } + + public static function previewDefinition(array $preset, $rubricId) + { + $preset = FieldSetRegistry::normalizeDefinition(isset($preset['code']) ? $preset['code'] : '', $preset); + self::assertDefinitionAvailable($preset); + $existing = array(); + $existingById = array(); + $existingRows = Model::fieldsForRubric((int) $rubricId); + foreach ($existingRows as $field) { + $existing[strtolower((string) $field['rubric_field_alias'])] = $field; + $existingById[(int) $field['Id']] = $field; + } + + $sameSourceSchema = self::sourceSchemaMatches($preset, $existingRows); + + $create = 0; + $reuse = 0; + $conflicts = array(); + $fieldCount = 0; + $generatedAliases = 0; + foreach ($preset['groups'] as $group) { + foreach ($group['fields'] as $field) { + $fieldCount++; + if (!empty($field['generated_alias'])) { $generatedAliases++; } + $sourceId = isset($field['source_field_id']) ? (int) $field['source_field_id'] : 0; + if (!empty($field['generated_alias']) && $sameSourceSchema && isset($existingById[$sourceId])) { + if ((string) $existingById[$sourceId]['rubric_field_type'] === (string) $field['type']) { $reuse++; } + else { $conflicts[] = $field['alias'] . ' (исходное поле имеет тип ' . $existingById[$sourceId]['rubric_field_type'] . ')'; } + continue; + } + + $key = strtolower((string) $field['alias']); + if (!isset($existing[$key])) { $create++; continue; } + if ((string) $existing[$key]['rubric_field_type'] !== (string) $field['type']) { + $conflicts[] = $field['alias'] . ' (' . $existing[$key]['rubric_field_type'] . ' вместо ' . $field['type'] . ')'; + } else { + $reuse++; + } + } + } + + $existingGroups = array(); + foreach (Model::fieldGroups((int) $rubricId) as $group) { + $existingGroups[self::groupKey($group['title'])] = true; + } + + $groupsCreate = 0; + $groupsReuse = 0; + foreach ($preset['groups'] as $group) { + if (!empty($group['ungrouped'])) { continue; } + if (isset($existingGroups[self::groupKey($group['title'])])) { $groupsReuse++; } + else { $groupsCreate++; } + } + + $state = array(); + foreach ($existing as $alias => $field) { + $state[$alias] = (string) $field['rubric_field_type']; + } + + ksort($state); + $stateJson = json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $fingerprint = hash('sha256', FieldSetRegistry::fingerprint($preset) . '|' . (string) $stateJson); + + return array( + 'code' => $preset['code'], + 'title' => $preset['title'], + 'description' => $preset['description'], + 'field_count' => $fieldCount, + 'generated_aliases' => $generatedAliases, + 'source_schema_match' => $sameSourceSchema, + 'create' => $create, + 'reuse' => $reuse, + 'groups_create' => $groupsCreate, + 'groups_reuse' => $groupsReuse, + 'conflicts' => $conflicts, + 'fingerprint' => $fingerprint, + ); + } + + /** Сформировать descriptor текущей рубрики без документов, PHP и шаблонов. */ + public static function exportDescriptor($rubricId) + { + $rubricId = (int) $rubricId; + $rubric = Model::one($rubricId); + if (!$rubric) { throw new \RuntimeException('Рубрика не найдена'); } + + $groups = array(0 => array( + 'title' => 'Основные поля', + 'description' => 'Поля без отдельной группы.', + 'ungrouped' => true, + 'fields' => array(), + )); + $sourceState = array(); + foreach (Model::fieldGroups($rubricId) as $group) { + $groups[(int) $group['id']] = array( + 'title' => (string) $group['title'], + 'description' => (string) $group['description'], + 'fields' => array(), + ); + } + + foreach (Model::fieldsForRubric($rubricId) as $field) { + $sourceState[] = array( + 'id' => (int) $field['Id'], + 'alias' => (string) $field['rubric_field_alias'], + 'type' => (string) $field['rubric_field_type'], + ); + $groupId = isset($groups[(int) $field['rubric_field_group']]) ? (int) $field['rubric_field_group'] : 0; + $settings = FieldSettings::effective($field); + // Условия ссылаются на ID полей текущей рубрики и не переносимы между проектами. + unset($settings['form_condition']); + $type = (string) $field['rubric_field_type']; + $default = FieldSettings::legacyDefaultIsConfiguration($type) ? '' : (string) $field['rubric_field_default']; + $sourceAlias = trim((string) $field['rubric_field_alias']); + $generatedAlias = !preg_match('/^[A-Za-z][A-Za-z0-9_]{0,19}$/', $sourceAlias); + $portableAlias = $generatedAlias ? 'field_' . (int) $field['Id'] : $sourceAlias; + $groups[$groupId]['fields'][] = array( + 'title' => (string) $field['rubric_field_title'], + 'alias' => $portableAlias, + 'type' => $type, + 'source_field_id' => (int) $field['Id'], + 'generated_alias' => $generatedAlias, + 'search' => (string) $field['rubric_field_search'] !== '0', + 'numeric' => (string) $field['rubric_field_numeric'] === '1', + 'default' => $default, + 'width' => !empty($field['layout']['width']) ? (string) $field['layout']['width'] : 'full', + 'description' => (string) $field['rubric_field_description'], + 'settings' => $settings, + ); + } + + $groups = array_values(array_filter($groups, function ($group) { + return !empty($group['fields']); + })); + $definition = array( + 'title' => 'Поля: ' . (string) $rubric['rubric_title'], + 'description' => 'Переносимая схема формы рубрики «' . (string) $rubric['rubric_title'] . '».', + 'icon' => 'ti-forms', + 'source_schema_fingerprint' => self::sourceSchemaFingerprint($sourceState), + 'groups' => $groups, + ); + + return FieldSetRegistry::descriptor('rubric_' . $rubricId, $definition, array( + 'rubric_id' => $rubricId, + 'title' => (string) $rubric['rubric_title'], + 'alias' => isset($rubric['rubric_alias']) ? (string) $rubric['rubric_alias'] : '', + )); + } + + protected static function assertDefinitionAvailable(array $preset) + { + $missing = array(); + foreach (self::fieldTypes($preset) as $type) { + if (!FieldTypes::isEnabled($type)) { $missing[] = $type; } + } + + if ($missing) { + throw new \RuntimeException('Для набора включите типы полей: ' . implode(', ', $missing)); + } + } + + protected static function sourceSchemaMatches(array $preset, array $fields) + { + $expected = isset($preset['source_schema_fingerprint']) ? (string) $preset['source_schema_fingerprint'] : ''; + if (!preg_match('/^[a-f0-9]{64}$/', $expected)) { return false; } + $state = array(); + foreach ($fields as $field) { + $state[] = array( + 'id' => (int) $field['Id'], + 'alias' => (string) $field['rubric_field_alias'], + 'type' => (string) $field['rubric_field_type'], + ); + } + + return hash_equals($expected, self::sourceSchemaFingerprint($state)); + } + + protected static function sourceSchemaFingerprint(array $state) + { + usort($state, function ($left, $right) { + return (int) $left['id'] <=> (int) $right['id']; + }); + $json = json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return hash('sha256', (string) $json); + } + + protected static function fieldTypes(array $preset) + { + $types = array(); + foreach ($preset['groups'] as $group) { + foreach ($group['fields'] as $field) { + $types[] = (string) $field['type']; + } + } + + return array_values(array_unique($types)); + } + + protected static function fieldCount(array $preset) + { + $count = 0; + foreach ($preset['groups'] as $group) { + $count += count($group['fields']); + } + + return $count; + } + + public static function definitions() + { + $body = array( + 'title' => 'Основной текст', + 'alias' => 'body', + 'type' => 'content', + 'search' => true, + 'width' => 'full', + 'description' => 'Основное содержимое документа с визуальным редактором.', + 'settings' => array('mode' => 'rich', 'height' => 560, 'required' => true), + ); + $cover = array( + 'title' => 'Обложка', + 'alias' => 'cover', + 'type' => 'image_single', + 'width' => 'half', + 'description' => 'Основное изображение для страницы и карточки материала.', + ); + $gallery = array( + 'title' => 'Галерея', + 'alias' => 'gallery', + 'type' => 'image_multi', + 'width' => 'full', + 'description' => 'Дополнительные изображения материала.', + ); + + return array( + 'news' => array( + 'title' => 'Новость', + 'description' => 'Текст, обложка, галерея и ссылка на первоисточник.', + 'icon' => 'ti-news', + 'groups' => array( + array('title' => 'Содержание', 'description' => 'Основной материал новости.', 'fields' => array($body, $cover)), + array('title' => 'Медиа и источник', 'description' => 'Дополнительные материалы новости.', 'fields' => array( + $gallery, + array( + 'title' => 'Источник', + 'alias' => 'source', + 'type' => 'contact', + 'width' => 'half', + 'description' => 'Ссылка на первоисточник новости, если она требуется.', + 'settings' => array('mode' => 'url', 'clickable' => true, 'new_window' => true), + ), + )), + ), + ), + 'article' => array( + 'title' => 'Статья', + 'description' => 'Большой текст, обложка, галерея и прикреплённые файлы.', + 'icon' => 'ti-file-description', + 'groups' => array( + array('title' => 'Содержание', 'description' => 'Основной материал статьи.', 'fields' => array($body, $cover)), + array('title' => 'Материалы', 'description' => 'Иллюстрации и файлы статьи.', 'fields' => array( + $gallery, + array( + 'title' => 'Прикреплённые файлы', + 'alias' => 'attachments', + 'type' => 'doc_files', + 'width' => 'full', + 'description' => 'Документы и другие файлы для скачивания.', + ), + )), + ), + ), + 'blog' => array( + 'title' => 'Запись блога', + 'description' => 'Текст, обложка, галерея и видео.', + 'icon' => 'ti-pencil', + 'groups' => array( + array('title' => 'Публикация', 'description' => 'Основное содержимое записи.', 'fields' => array($body, $cover)), + array('title' => 'Медиа', 'description' => 'Иллюстрации и видео записи.', 'fields' => array( + $gallery, + array( + 'title' => 'Видео', + 'alias' => 'video', + 'type' => 'youtube', + 'width' => 'full', + 'description' => 'YouTube-видео с дополнительными параметрами.', + ), + )), + ), + ), + ); + } + + protected static function registeredDefinitions() + { + if (!FieldSetRegistry::get('news')) { + FieldSetRegistry::registerMany('module:rubrics', self::definitions(), true); + } + + $out = array(); + foreach (FieldSetRegistry::all(true) as $preset) { $out[(string) $preset['code']] = $preset; } + foreach (RubricFieldSetLinks::customDefinitions() as $code => $preset) { + if (!isset($out[$code])) { $out[$code] = $preset; } + } + + return $out; + } + + protected static function groupKey($title) + { + $title = trim((string) $title); + return function_exists('mb_strtolower') ? mb_strtolower($title, 'UTF-8') : strtolower($title); + } + } diff --git a/adminx/modules/Rubrics/RubricFieldSetLinks.php b/adminx/modules/Rubrics/RubricFieldSetLinks.php new file mode 100644 index 0000000..35b2c4d --- /dev/null +++ b/adminx/modules/Rubrics/RubricFieldSetLinks.php @@ -0,0 +1,491 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AuditLog; + use App\Common\DatabaseSchema; + use App\Content\ContentTables; + use App\Content\Fields\FieldSettings; + use App\Content\Rubrics\FieldSetMerge; + use App\Content\Rubrics\FieldSetRegistry; + use App\Helpers\Json; + use DB; + + /** Persistent definitions and safe links between reusable field sets and rubrics. */ + class RubricFieldSetLinks + { + public static function definitionsTable() { return ContentTables::table('rubric_field_sets'); } + public static function linksTable() { return ContentTables::table('rubric_field_set_links'); } + + public static function available() + { + return DatabaseSchema::tableExists(self::definitionsTable()) + && DatabaseSchema::tableExists(self::linksTable()); + } + + public static function customDefinitions() + { + if (!self::available()) { return array(); } + $rows = DB::query('SELECT definition_json FROM ' . self::definitionsTable() . ' WHERE active=1 ORDER BY title,code')->getAll() ?: array(); + $out = array(); + foreach ($rows as $row) { + $definition = Json::toArray((string) $row['definition_json']); + if (!$definition) { continue; } + try { + $definition = FieldSetRegistry::normalizeDefinition(isset($definition['code']) ? $definition['code'] : '', $definition); + $definition['source'] = 'database'; + $definition['available'] = true; + $out[(string) $definition['code']] = $definition; + } catch (\Throwable $e) { + continue; + } + } + + return $out; + } + + public static function saveCustomDefinition(array $definition, $actorId = 0) + { + self::assertAvailable(); + $definition = FieldSetRegistry::normalizeDefinition(isset($definition['code']) ? $definition['code'] : '', $definition); + $fingerprint = FieldSetRegistry::fingerprint($definition); + $existing = RubricFieldPresets::definition($definition['code']); + if ($existing && isset($existing['source']) && (string) $existing['source'] !== 'database' + && !hash_equals(FieldSetRegistry::fingerprint($existing), $fingerprint)) { + throw new \RuntimeException('Код набора уже занят ядром или установленным модулем: ' . $definition['code']); + } + + $data = array( + 'title' => $definition['title'], + 'description' => $definition['description'], + 'definition_json' => self::encode($definition), + 'fingerprint' => $fingerprint, + 'source' => 'import', + 'active' => 1, + 'updated_by' => (int) $actorId, + 'updated_at' => time(), + ); + $exists = DB::query('SELECT code FROM ' . self::definitionsTable() . ' WHERE code=%s LIMIT 1', $definition['code'])->getValue(); + if ($exists) { + DB::Update(self::definitionsTable(), $data, 'code=%s', $definition['code']); + } else { + $data['code'] = $definition['code']; + $data['created_at'] = time(); + DB::Insert(self::definitionsTable(), $data); + } + + return $definition; + } + + public static function attach($rubricId, array $definition, array $fieldMap, $actorId = 0) + { + self::assertAvailable(); + $definition = FieldSetRegistry::normalizeDefinition(isset($definition['code']) ? $definition['code'] : '', $definition); + $code = (string) $definition['code']; + $data = array( + 'applied_fingerprint' => FieldSetRegistry::fingerprint($definition), + 'applied_definition_json' => self::encode($definition), + 'field_map_json' => self::encode(self::cleanFieldMap($fieldMap)), + 'updated_by' => (int) $actorId, + 'updated_at' => time(), + ); + $id = (int) DB::query( + 'SELECT id FROM ' . self::linksTable() . ' WHERE rubric_id=%i AND set_code=%s LIMIT 1', + (int) $rubricId, + $code + )->getValue(); + if ($id > 0) { + DB::Update(self::linksTable(), $data, 'id=%i', $id); + } else { + $data['rubric_id'] = (int) $rubricId; + $data['set_code'] = $code; + $data['created_at'] = time(); + DB::Insert(self::linksTable(), $data); + } + + AuditLog::record('rubric.field_set_linked', array( + 'actor_id' => (int) $actorId > 0 ? (int) $actorId : null, + 'target_type' => 'rubric', + 'target_id' => (int) $rubricId, + 'meta' => array('set' => $code, 'fields' => count($fieldMap)), + )); + } + + public static function forRubric($rubricId) + { + if (!self::available()) { return array(); } + $rows = DB::query( + 'SELECT * FROM ' . self::linksTable() . ' WHERE rubric_id=%i ORDER BY set_code', + (int) $rubricId + )->getAll() ?: array(); + $out = array(); + foreach ($rows as $row) { + $definition = RubricFieldPresets::definition((string) $row['set_code']); + $currentFingerprint = $definition ? FieldSetRegistry::fingerprint($definition) : ''; + $out[] = array( + 'code' => (string) $row['set_code'], + 'title' => $definition ? (string) $definition['title'] : (string) $row['set_code'], + 'available' => (bool) $definition, + 'update_available' => $definition && !hash_equals((string) $row['applied_fingerprint'], $currentFingerprint), + 'applied_fingerprint' => (string) $row['applied_fingerprint'], + 'current_fingerprint' => $currentFingerprint, + 'updated_at' => (int) $row['updated_at'], + ); + } + + return $out; + } + + public static function hasLink($rubricId, $code) + { + if (!self::available()) { return false; } + + return (bool) DB::query( + 'SELECT id FROM ' . self::linksTable() . ' WHERE rubric_id=%i AND set_code=%s LIMIT 1', + (int) $rubricId, + strtolower(trim((string) $code)) + )->getValue(); + } + + public static function preview($rubricId, $code) + { + $state = self::syncState($rubricId, $code); + return $state['preview']; + } + + public static function sync($rubricId, $code, $fingerprint, $actorId = 0) + { + $state = self::syncState($rubricId, $code); + if (!hash_equals((string) $state['preview']['fingerprint'], (string) $fingerprint)) { + throw new \RuntimeException('Схема или поля рубрики изменились после проверки. Повторите предварительный просмотр.'); + } + + if (!empty($state['preview']['conflicts'])) { + throw new \RuntimeException('Синхронизация требует ручного решения: ' . implode(', ', $state['preview']['conflicts'])); + } + + $groupIds = self::groupIds((int) $rubricId); + $fieldMap = $state['field_map']; + $updated = 0; + $created = 0; + $detached = 0; + foreach ($state['plan'] as $item) { + if ($item['action'] === 'create') { + $groupId = self::ensureGroup($rubricId, $item['group'], $groupIds); + $fieldId = Model::saveField(0, $rubricId, self::fieldInput($item['next'], $groupId)); + $fieldMap[$item['alias']] = $fieldId; + $created++; + continue; + } + + $field = $item['current']; + if ($item['action'] === 'detach') { + $settings = Json::toArray((string) $field['rubric_field_settings']); + $settings['placed'] = false; + Model::saveField((int) $field['Id'], $rubricId, self::currentFieldInput($field, $settings)); + unset($fieldMap[$item['alias']]); + $detached++; + continue; + } + + $merged = $item['merged']; + $groupId = self::ensureGroup($rubricId, $merged['group'], $groupIds); + Model::saveField((int) $field['Id'], $rubricId, self::fieldInput($merged['field'], $groupId, $field)); + $fieldMap[$item['alias']] = (int) $field['Id']; + $updated++; + } + + self::attach($rubricId, $state['definition'], $fieldMap, $actorId); + AuditLog::record('rubric.field_set_synced', array( + 'actor_id' => (int) $actorId > 0 ? (int) $actorId : null, + 'target_type' => 'rubric', + 'target_id' => (int) $rubricId, + 'meta' => array('set' => (string) $code, 'created' => $created, 'updated' => $updated, 'detached' => $detached), + )); + + return array('created' => $created, 'updated' => $updated, 'detached' => $detached); + } + + public static function detach($rubricId, $code, $actorId = 0) + { + if (!self::available()) { return false; } + DB::Delete(self::linksTable(), 'rubric_id=%i AND set_code=%s', (int) $rubricId, (string) $code); + AuditLog::record('rubric.field_set_unlinked', array( + 'actor_id' => (int) $actorId > 0 ? (int) $actorId : null, + 'target_type' => 'rubric', + 'target_id' => (int) $rubricId, + 'meta' => array('set' => (string) $code), + )); + return true; + } + + public static function deleteForRubric($rubricId) + { + if (self::available()) { DB::Delete(self::linksTable(), 'rubric_id=%i', (int) $rubricId); } + } + + protected static function syncState($rubricId, $code) + { + self::assertAvailable(); + $link = DB::query( + 'SELECT * FROM ' . self::linksTable() . ' WHERE rubric_id=%i AND set_code=%s LIMIT 1', + (int) $rubricId, + (string) $code + )->getAssoc(); + if (!$link) { throw new \RuntimeException('Набор не связан с этой рубрикой'); } + $definition = RubricFieldPresets::definition((string) $code); + if (!$definition) { throw new \RuntimeException('Источник связанного набора недоступен'); } + + $previous = Json::toArray((string) $link['applied_definition_json']); + $previous = FieldSetRegistry::normalizeDefinition(isset($previous['code']) ? $previous['code'] : '', $previous); + $definition = FieldSetRegistry::normalizeDefinition(isset($definition['code']) ? $definition['code'] : '', $definition); + $fieldMap = self::cleanFieldMap(Json::toArray((string) $link['field_map_json'])); + $current = array(); + foreach (Model::fieldsForRubric((int) $rubricId) as $field) { $current[(int) $field['Id']] = $field; } + $oldFields = self::flatten($previous); + $newFields = self::flatten($definition); + $plan = array(); + $conflicts = array(); + $created = 0; + $updated = 0; + $detached = 0; + + foreach ($newFields as $alias => $next) { + $fieldId = isset($fieldMap[$alias]) ? (int) $fieldMap[$alias] : 0; + $field = isset($current[$fieldId]) ? $current[$fieldId] : null; + if (!$field) { + $field = self::findCurrentByAlias($current, $alias); + if ($field && (string) $field['rubric_field_type'] !== (string) $next['field']['type']) { + $conflicts[] = $alias . ': alias занят типом ' . $field['rubric_field_type']; + continue; + } + } + + if (!$field) { + $plan[] = array('action' => 'create', 'alias' => $alias, 'next' => $next['field'], 'group' => $next['group']); + $created++; + continue; + } + + $old = isset($oldFields[$alias]) ? $oldFields[$alias] : $next; + if ((string) $old['field']['type'] !== (string) $next['field']['type'] + && ((string) $field['rubric_field_type'] !== (string) $old['field']['type'] || self::fieldHasValues((int) $field['Id']))) { + $conflicts[] = $alias . ': нельзя автоматически изменить тип заполненного или локально изменённого поля'; + continue; + } + + $plan[] = array( + 'action' => 'update', + 'alias' => $alias, + 'current' => $field, + 'merged' => self::mergeField($field, $old, $next), + ); + $updated++; + } + + foreach ($oldFields as $alias => $old) { + if (isset($newFields[$alias]) || !isset($fieldMap[$alias]) || !isset($current[(int) $fieldMap[$alias]])) { continue; } + $plan[] = array('action' => 'detach', 'alias' => $alias, 'current' => $current[(int) $fieldMap[$alias]]); + $detached++; + } + + $state = array( + 'set' => FieldSetRegistry::fingerprint($definition), + 'fields' => self::currentFingerprintState($current), + 'map' => $fieldMap, + ); + return array( + 'definition' => $definition, + 'field_map' => $fieldMap, + 'plan' => $plan, + 'preview' => array( + 'code' => (string) $code, + 'title' => (string) $definition['title'], + 'created' => $created, + 'updated' => $updated, + 'detached' => $detached, + 'conflicts' => $conflicts, + 'fingerprint' => hash('sha256', self::encode($state)), + ), + ); + } + + protected static function mergeField(array $current, array $previous, array $next) + { + $old = $previous['field']; + $new = $next['field']; + $currentSettings = Json::toArray((string) $current['rubric_field_settings']); + $oldSettings = isset($old['settings']) && is_array($old['settings']) ? $old['settings'] : array(); + $newSettings = isset($new['settings']) && is_array($new['settings']) ? $new['settings'] : array(); + $currentWidth = isset($current['layout']['width']) && $current['layout']['width'] !== '' ? $current['layout']['width'] : 'full'; + $oldWidth = isset($old['width']) ? $old['width'] : 'full'; + $newWidth = isset($new['width']) ? $new['width'] : 'full'; + $merged = $new; + $merged['title'] = FieldSetMerge::value((string) $current['rubric_field_title'], (string) $old['title'], (string) $new['title']); + $merged['type'] = FieldSetMerge::value((string) $current['rubric_field_type'], (string) $old['type'], (string) $new['type']); + $merged['search'] = FieldSetMerge::value((string) $current['rubric_field_search'] !== '0', !empty($old['search']), !empty($new['search'])); + $merged['numeric'] = FieldSetMerge::value((string) $current['rubric_field_numeric'] === '1', !empty($old['numeric']), !empty($new['numeric'])); + $merged['default'] = FieldSetMerge::value((string) $current['rubric_field_default'], (string) $old['default'], (string) $new['default']); + $merged['description'] = FieldSetMerge::value((string) $current['rubric_field_description'], (string) $old['description'], (string) $new['description']); + $merged['width'] = FieldSetMerge::value((string) $currentWidth, (string) $oldWidth, (string) $newWidth); + $merged['settings'] = FieldSetMerge::settings($currentSettings, $oldSettings, $newSettings); + $currentGroup = isset($current['group_title']) ? (string) $current['group_title'] : ''; + $oldGroup = !empty($previous['group']['ungrouped']) ? '' : (string) $previous['group']['title']; + $newGroup = !empty($next['group']['ungrouped']) ? '' : (string) $next['group']['title']; + + return array( + 'field' => $merged, + 'group' => FieldSetMerge::value($currentGroup, $oldGroup, $newGroup) === $newGroup ? $next['group'] : array( + 'title' => $currentGroup, + 'description' => isset($current['group_description']) ? (string) $current['group_description'] : '', + 'ungrouped' => $currentGroup === '', + ), + ); + } + + protected static function flatten(array $definition) + { + $out = array(); + foreach ($definition['groups'] as $group) { + foreach ($group['fields'] as $field) { $out[strtolower((string) $field['alias'])] = array('field' => $field, 'group' => $group); } + } + + return $out; + } + + protected static function fieldInput(array $field, $groupId, array $current = array()) + { + $settings = isset($field['settings']) && is_array($field['settings']) ? $field['settings'] : array(); + $placed = !array_key_exists('placed', $settings) || !empty($settings['placed']); + return array( + 'rubric_field_group' => (int) $groupId, + 'rubric_field_title' => (string) $field['title'], + 'rubric_field_alias' => (string) $field['alias'], + 'rubric_field_type' => (string) $field['type'], + 'rubric_field_search' => !empty($field['search']) ? '1' : '0', + 'rubric_field_numeric' => !empty($field['numeric']) ? '1' : '0', + 'rubric_field_default' => isset($field['default']) ? (string) $field['default'] : '', + 'rubric_field_description' => isset($field['description']) ? (string) $field['description'] : '', + 'rubric_field_template' => isset($current['rubric_field_template']) ? (string) $current['rubric_field_template'] : '', + 'rubric_field_template_request' => isset($current['rubric_field_template_request']) ? (string) $current['rubric_field_template_request'] : '', + 'rubric_field_settings_canonical' => $settings, + 'rubric_field_layout' => array( + 'placed' => $placed, + 'width' => isset($field['width']) ? (string) $field['width'] : 'full', + 'prefix' => isset($settings['prefix']) ? (string) $settings['prefix'] : '', + 'suffix' => isset($settings['suffix']) ? (string) $settings['suffix'] : '', + ), + ); + } + + protected static function currentFieldInput(array $field, array $settings) + { + $definition = array( + 'title' => (string) $field['rubric_field_title'], + 'alias' => (string) $field['rubric_field_alias'], + 'type' => (string) $field['rubric_field_type'], + 'search' => (string) $field['rubric_field_search'] !== '0', + 'numeric' => (string) $field['rubric_field_numeric'] === '1', + 'default' => (string) $field['rubric_field_default'], + 'description' => (string) $field['rubric_field_description'], + 'width' => isset($field['layout']['width']) ? (string) $field['layout']['width'] : 'full', + 'settings' => $settings, + ); + return self::fieldInput($definition, (int) $field['rubric_field_group'], $field); + } + + protected static function groupIds($rubricId) + { + $out = array('' => 0); + foreach (Model::fieldGroups((int) $rubricId) as $group) { $out[self::groupKey($group['title'])] = (int) $group['id']; } + return $out; + } + + protected static function ensureGroup($rubricId, array $group, array &$groupIds) + { + if (!empty($group['ungrouped']) || trim((string) $group['title']) === '') { return 0; } + $key = self::groupKey($group['title']); + if (isset($groupIds[$key])) { return (int) $groupIds[$key]; } + $id = Model::saveGroup(0, (int) $rubricId, array( + 'group_title' => (string) $group['title'], + 'group_description' => isset($group['description']) ? (string) $group['description'] : '', + )); + $groupIds[$key] = $id; + return $id; + } + + protected static function findCurrentByAlias(array $fields, $alias) + { + foreach ($fields as $field) { + if (strcasecmp((string) $field['rubric_field_alias'], (string) $alias) === 0) { return $field; } + } + + return null; + } + + protected static function fieldHasValues($fieldId) + { + return Model::fieldHasStoredValues((int) $fieldId); + } + + protected static function currentFingerprintState(array $fields) + { + $out = array(); + foreach ($fields as $id => $field) { + $out[(int) $id] = array( + 'alias' => (string) $field['rubric_field_alias'], + 'type' => (string) $field['rubric_field_type'], + 'group' => (int) $field['rubric_field_group'], + 'title' => (string) $field['rubric_field_title'], + 'settings' => (string) $field['rubric_field_settings'], + ); + } + + ksort($out); + return $out; + } + + protected static function cleanFieldMap(array $fieldMap) + { + $out = array(); + foreach ($fieldMap as $alias => $id) { + $alias = strtolower(trim((string) $alias)); + if (preg_match('/^[a-z][a-z0-9_]{0,19}$/', $alias) && (int) $id > 0) { $out[$alias] = (int) $id; } + } + + ksort($out); + return $out; + } + + protected static function groupKey($title) + { + $title = trim((string) $title); + return function_exists('mb_strtolower') ? mb_strtolower($title, 'UTF-8') : strtolower($title); + } + + protected static function encode(array $value) + { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { throw new \RuntimeException('Не удалось сериализовать набор полей'); } + return $json; + } + + protected static function assertAvailable() + { + if (!self::available()) { throw new \RuntimeException('Примените миграцию связанных наборов полей'); } + } + } diff --git a/adminx/modules/Rubrics/RubricRevisions.php b/adminx/modules/Rubrics/RubricRevisions.php new file mode 100644 index 0000000..4a88e68 --- /dev/null +++ b/adminx/modules/Rubrics/RubricRevisions.php @@ -0,0 +1,721 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\SystemTables; + use App\Content\ContentTables; + use App\Content\Fields\FieldRegistry; + use App\Helpers\Json; + use DB; + + class RubricRevisions + { + public static function table() + { + return ContentTables::table('rubric_schema_revisions'); + } + + public static function available() + { + return (bool) DB::query('SHOW TABLES LIKE %s', self::table())->getValue(); + } + + public static function labels() + { + return array( + 'baseline' => array('label' => 'Исходная схема', 'badge' => 'badge-gray'), + 'create' => array('label' => 'Создание', 'badge' => 'badge-green'), + 'field_create' => array('label' => 'Новое поле', 'badge' => 'badge-green'), + 'field_update' => array('label' => 'Поле изменено', 'badge' => 'badge-blue'), + 'field_delete' => array('label' => 'Поле удалено', 'badge' => 'badge-red'), + 'group_create' => array('label' => 'Новая группа', 'badge' => 'badge-green'), + 'group_update' => array('label' => 'Группа изменена', 'badge' => 'badge-blue'), + 'group_delete' => array('label' => 'Группа удалена', 'badge' => 'badge-red'), + 'layout' => array('label' => 'Конструктор', 'badge' => 'badge-blue'), + 'reorder' => array('label' => 'Порядок', 'badge' => 'badge-cyan'), + 'field_set' => array('label' => 'Набор полей', 'badge' => 'badge-violet'), + 'restore' => array('label' => 'Восстановление', 'badge' => 'badge-cyan'), + 'restore_backup' => array('label' => 'Перед восстановлением', 'badge' => 'badge-amber'), + ); + } + + public static function listForRubric($rubricId, $limit = 80) + { + if (!self::available()) { return array(); } + $rows = DB::query( + 'SELECT * FROM ' . self::table() . ' WHERE rubric_id=%i ORDER BY created_at DESC,id DESC LIMIT %i', + (int) $rubricId, + max(1, min(200, (int) $limit)) + )->getAll() ?: array(); + $out = array(); + foreach ($rows as $row) { $out[] = self::format($row, false); } + + return $out; + } + + public static function one($id) + { + if (!self::available()) { return null; } + $row = DB::query('SELECT * FROM ' . self::table() . ' WHERE id=%i LIMIT 1', (int) $id)->getAssoc(); + return $row ? self::format($row, true) : null; + } + + public static function capture($rubricId, $action, $authorId = 0, $comment = '', array $snapshot = null, $sourceRevisionId = 0) + { + if (!self::available()) { return 0; } + $rubricId = (int) $rubricId; + $snapshot = $snapshot === null ? Model::schemaSnapshot($rubricId) : Model::normalizeSchemaSnapshot($snapshot); + if (!$snapshot) { return 0; } + + $json = Json::encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $hash = sha1((string) $json); + $last = DB::query( + 'SELECT snapshot_hash FROM ' . self::table() . ' WHERE rubric_id=%i ORDER BY created_at DESC,id DESC LIMIT 1', + $rubricId + )->getValue(); + if ($last && hash_equals((string) $last, $hash)) { return 0; } + + DB::Insert(self::table(), array( + 'rubric_id' => $rubricId, + 'action' => (string) $action, + 'snapshot_hash' => $hash, + 'snapshot_json' => $json, + 'comment' => mb_substr(trim((string) $comment), 0, 500, 'UTF-8'), + 'author_id' => (int) $authorId, + 'author_name' => self::authorName((int) $authorId), + 'source_revision_id' => (int) $sourceRevisionId, + 'created_at' => time(), + )); + + return (int) DB::insertId(); + } + + public static function previewRestore($revisionId) + { + $revision = self::one((int) $revisionId); + if (!$revision || empty($revision['snapshot'])) { + throw new \RuntimeException('Ревизия не найдена'); + } + + $current = Model::schemaSnapshot((int) $revision['rubric_id']); + if (!$current) { throw new \RuntimeException('Рубрика не найдена'); } + $impact = self::compareSnapshots($current, $revision['snapshot']); + $impact['documents_total'] = Model::documentCount((int) $revision['rubric_id']); + $impact['documents_with_values'] = self::documentsWithValues( + (int) $revision['rubric_id'], + $impact['affected_field_ids'] + ); + $impact['dependencies'] = RubricSchemaImpact::analyze( + (int) $revision['rubric_id'], + self::fieldReferences($current, $revision['snapshot'], $impact['affected_field_ids']) + ); + $impact['fingerprint'] = self::restoreFingerprint( + $revision, + $current, + (string) $impact['dependencies']['fingerprint'] + ); + unset($impact['affected_field_ids']); + + return $impact; + } + + public static function restore($revisionId, $fingerprint, $authorId = 0) + { + $revision = self::one((int) $revisionId); + if (!$revision || empty($revision['snapshot'])) { + throw new \RuntimeException('Ревизия не найдена'); + } + + $current = Model::schemaSnapshot((int) $revision['rubric_id']); + $impact = self::compareSnapshots($current, $revision['snapshot']); + $dependencies = RubricSchemaImpact::analyze( + (int) $revision['rubric_id'], + self::fieldReferences($current, $revision['snapshot'], $impact['affected_field_ids']) + ); + $expected = self::restoreFingerprint($revision, $current, (string) $dependencies['fingerprint']); + if ($fingerprint === '' || !hash_equals($expected, (string) $fingerprint)) { + throw new \RuntimeException('Схема изменилась после предпросмотра. Проверьте ревизию ещё раз.'); + } + + if (!empty($impact['blockers'])) { + throw new \RuntimeException('Восстановление заблокировано: ' . implode('; ', $impact['blockers'])); + } + + DB::startTransaction(); + try { + self::capture( + (int) $revision['rubric_id'], + 'restore_backup', + (int) $authorId, + 'Снимок перед восстановлением ревизии #' . (int) $revisionId, + $current, + (int) $revisionId + ); + $result = Model::applySchemaSnapshot((int) $revision['rubric_id'], $revision['snapshot']); + self::capture( + (int) $revision['rubric_id'], + 'restore', + (int) $authorId, + 'Восстановлено из ревизии #' . (int) $revisionId, + null, + (int) $revisionId + ); + DB::commit(); + } catch (\Throwable $e) { + DB::rollback(); + throw $e; + } + + return $result; + } + + public static function delete($revisionId) + { + $revision = self::one((int) $revisionId); + if (!$revision) { return 0; } + DB::Delete(self::table(), 'id=%i', (int) $revisionId); + + return (int) $revision['rubric_id']; + } + + public static function deleteForRubric($rubricId) + { + if (!self::available()) { return 0; } + $count = (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE rubric_id=%i', (int) $rubricId)->getValue(); + DB::Delete(self::table(), 'rubric_id=%i', (int) $rubricId); + + return $count; + } + + public static function compareSnapshots(array $current, array $target) + { + $current = Model::normalizeSchemaSnapshot($current); + $target = Model::normalizeSchemaSnapshot($target); + $currentGroups = self::mapById($current['groups']); + $targetGroups = self::mapById($target['groups']); + $currentFields = self::mapById($current['fields']); + $targetFields = self::mapById($target['fields']); + $groups = self::describeGroupDiff( + self::entityDiff($currentGroups, $targetGroups, 'group_title', 'Группа'), + $currentGroups, + $targetGroups + ); + $fields = self::describeFieldDiff( + self::entityDiff($currentFields, $targetFields, 'rubric_field_title', 'Поле'), + $currentFields, + $targetFields, + $currentGroups, + $targetGroups + ); + $blockers = self::restoreBlockers( + $currentFields, + $targetFields, + isset($current['rubric']['id']) ? (int) $current['rubric']['id'] : 0 + ); + $affected = array_values(array_unique(array_merge($fields['restore_ids'], $fields['changed_ids']))); + + return array( + 'conditions_before' => !empty($current['rubric']['form_conditions_enabled']), + 'conditions_after' => !empty($target['rubric']['form_conditions_enabled']), + 'groups' => $groups, + 'fields' => $fields, + 'summary' => array( + 'groups_restore' => count($groups['restore']), + 'groups_change' => count($groups['change']), + 'groups_preserve' => count($groups['preserve']), + 'fields_restore' => count($fields['restore']), + 'fields_change' => count($fields['change']), + 'fields_preserve' => count($fields['preserve']), + ), + 'blockers' => $blockers, + 'can_restore' => empty($blockers), + 'affected_field_ids' => $affected, + ); + } + + protected static function entityDiff(array $current, array $target, $titleKey, $fallback) + { + $out = array( + 'restore' => array(), 'change' => array(), 'preserve' => array(), + 'restore_ids' => array(), 'changed_ids' => array(), + ); + foreach ($target as $id => $item) { + $title = trim((string) (isset($item[$titleKey]) ? $item[$titleKey] : '')); + $entry = array('id' => (int) $id, 'title' => $title !== '' ? $title : $fallback . ' #' . (int) $id); + if (!isset($current[$id])) { + $out['restore'][] = $entry; + $out['restore_ids'][] = (int) $id; + continue; + } + + $changed = self::changedKeys($current[$id], $item); + if ($changed) { + $entry['changed_keys'] = $changed; + $out['change'][] = $entry; + $out['changed_ids'][] = (int) $id; + } + } + + foreach ($current as $id => $item) { + if (isset($target[$id])) { continue; } + $title = trim((string) (isset($item[$titleKey]) ? $item[$titleKey] : '')); + $out['preserve'][] = array( + 'id' => (int) $id, + 'title' => $title !== '' ? $title : $fallback . ' #' . (int) $id, + ); + } + + return $out; + } + + protected static function changedKeys(array $current, array $target) + { + $ignore = array('rubric_id'); + $keys = array_unique(array_merge(array_keys($current), array_keys($target))); + $out = array(); + foreach ($keys as $key) { + if (in_array($key, $ignore, true)) { continue; } + $before = isset($current[$key]) ? $current[$key] : null; + $after = isset($target[$key]) ? $target[$key] : null; + if (in_array($key, array('rubric_field_settings', 'group_settings'), true) + && self::sameValue(Json::toArray($before), Json::toArray($after))) { continue; } + if ((string) $before !== (string) $after) { $out[] = (string) $key; } + } + + return $out; + } + + protected static function describeGroupDiff(array $diff, array $current, array $target) + { + foreach ($diff['change'] as &$entry) { + $id = (int) $entry['id']; + $entry['changes'] = self::groupChanges($current[$id], $target[$id]); + } + + unset($entry); + return $diff; + } + + protected static function describeFieldDiff(array $diff, array $current, array $target, array $currentGroups, array $targetGroups) + { + foreach (array('restore', 'change', 'preserve') as $section) { + foreach ($diff[$section] as &$entry) { + $id = (int) $entry['id']; + $field = isset($target[$id]) ? $target[$id] : $current[$id]; + $entry['meta'] = self::fieldTypeLabel(isset($field['rubric_field_type']) ? $field['rubric_field_type'] : ''); + $alias = trim((string) (isset($field['rubric_field_alias']) ? $field['rubric_field_alias'] : '')); + if ($alias !== '') { $entry['meta'] .= ' · ' . $alias; } + if ($section === 'change') { + $entry['changes'] = self::fieldChanges($current[$id], $target[$id], $currentGroups, $targetGroups); + } + } + + unset($entry); + } + + return $diff; + } + + protected static function groupChanges(array $current, array $target) + { + $changes = array(); + self::addChange($changes, 'Название', $current['group_title'], $target['group_title']); + self::addChange($changes, 'Описание', $current['group_description'], $target['group_description']); + self::addChange($changes, 'Порядок', $current['group_position'], $target['group_position'], 'number'); + $before = Json::toArray(isset($current['group_settings']) ? $current['group_settings'] : ''); + $after = Json::toArray(isset($target['group_settings']) ? $target['group_settings'] : ''); + self::addSettingsChanges($changes, $before, $after, '', ''); + + return $changes; + } + + protected static function fieldChanges(array $current, array $target, array $currentGroups, array $targetGroups) + { + $changes = array(); + self::addChange($changes, 'Название', $current['rubric_field_title'], $target['rubric_field_title']); + self::addChange($changes, 'Системное имя', $current['rubric_field_alias'], $target['rubric_field_alias']); + self::addChange( + $changes, + 'Тип поля', + self::fieldTypeLabel($current['rubric_field_type']), + self::fieldTypeLabel($target['rubric_field_type']) + ); + self::addChange( + $changes, + 'Группа', + self::groupLabel((int) $current['rubric_field_group'], $currentGroups), + self::groupLabel((int) $target['rubric_field_group'], $targetGroups) + ); + self::addChange($changes, 'Порядок', $current['rubric_field_position'], $target['rubric_field_position'], 'number'); + self::addChange($changes, 'Значение по умолчанию', $current['rubric_field_default'], $target['rubric_field_default']); + self::addChange($changes, 'Числовое значение', $current['rubric_field_numeric'], $target['rubric_field_numeric'], 'bool'); + self::addChange($changes, 'Участвует в поиске', $current['rubric_field_search'], $target['rubric_field_search'], 'bool'); + self::addChange($changes, 'Подсказка редактору', $current['rubric_field_description'], $target['rubric_field_description']); + self::addChange($changes, 'Шаблон в документе', $current['rubric_field_template'], $target['rubric_field_template'], 'template'); + self::addChange($changes, 'Шаблон в запросе', $current['rubric_field_template_request'], $target['rubric_field_template_request'], 'template'); + self::addSettingsChanges( + $changes, + Json::toArray($current['rubric_field_settings']), + Json::toArray($target['rubric_field_settings']), + (string) $current['rubric_field_type'], + (string) $target['rubric_field_type'] + ); + + return $changes; + } + + protected static function addSettingsChanges(array &$changes, array $before, array $after, $beforeType, $afterType) + { + $descriptors = array_replace( + self::settingsDescriptors($beforeType), + self::settingsDescriptors($afterType), + array( + 'placed' => array('label' => 'Размещено на форме', 'type' => 'bool'), + 'width' => array('label' => 'Ширина поля', 'type' => 'width'), + 'prefix' => array('label' => 'Префикс'), + 'suffix' => array('label' => 'Суффикс'), + 'form_condition' => array('label' => 'Условие формы', 'type' => 'condition'), + ) + ); + $keys = array_unique(array_merge(array_keys($before), array_keys($after))); + foreach ($keys as $key) { + $old = array_key_exists($key, $before) ? $before[$key] : null; + $new = array_key_exists($key, $after) ? $after[$key] : null; + if (self::sameValue($old, $new)) { continue; } + $descriptor = isset($descriptors[$key]) ? $descriptors[$key] : array(); + $label = isset($descriptor['label']) ? (string) $descriptor['label'] : self::settingLabel($key); + $type = isset($descriptor['type']) ? (string) $descriptor['type'] : ''; + $options = isset($descriptor['options']) && is_array($descriptor['options']) ? $descriptor['options'] : array(); + $changes[] = array( + 'label' => $label, + 'before' => self::displayValue($old, $type, $options), + 'after' => self::displayValue($new, $type, $options), + ); + } + } + + protected static function settingsDescriptors($type) + { + $field = FieldRegistry::get((string) $type); + if (!$field) { return array(); } + $out = array(); + foreach (array_merge((array) $field->settingsSchema(), (array) $field->validationSchema()) as $descriptor) { + if (!is_array($descriptor) || empty($descriptor['key'])) { continue; } + $out[(string) $descriptor['key']] = $descriptor; + } + + return $out; + } + + protected static function addChange(array &$changes, $label, $before, $after, $type = '') + { + if (self::sameValue($before, $after)) { return; } + $changes[] = array( + 'label' => (string) $label, + 'before' => self::displayValue($before, $type), + 'after' => self::displayValue($after, $type), + ); + } + + protected static function sameValue($before, $after) + { + if (is_array($before) || is_array($after)) { + return Json::encode($before, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + === Json::encode($after, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + return (string) $before === (string) $after; + } + + protected static function displayValue($value, $type = '', array $options = array()) + { + if ($type === 'condition') { return self::conditionLabel(is_array($value) ? $value : array()); } + if ($type === 'bool') { return !empty($value) && (string) $value !== '0' ? 'Да' : 'Нет'; } + if ($type === 'width') { + $widths = array('full' => '12 колонок', 'half' => '6 колонок', 'third' => '4 колонки', 'quarter' => '3 колонки'); + return isset($widths[(string) $value]) ? $widths[(string) $value] : self::plainValue($value); + } + + if ($type === 'template') { + $value = trim((string) $value); + return $value === '' ? 'Не задан' : 'Задан · ' . mb_strlen($value, 'UTF-8') . ' симв.'; + } + + if ($type === 'select' && !is_array($value)) { + foreach ($options as $key => $label) { + $optionValue = is_int($key) ? $label : $key; + if ((string) $optionValue === (string) $value) { return (string) $label; } + } + } + + if (is_array($value)) { return self::listValue($value); } + + return self::plainValue($value); + } + + protected static function plainValue($value) + { + $value = trim((string) $value); + if ($value === '') { return 'Не задано'; } + $value = preg_replace('/\s+/u', ' ', $value); + return mb_strlen($value, 'UTF-8') > 90 ? mb_substr($value, 0, 87, 'UTF-8') . '...' : $value; + } + + protected static function listValue(array $value) + { + $labels = array(); + foreach ($value as $key => $item) { + if (is_array($item)) { + $label = isset($item['label']) ? $item['label'] : (isset($item['title']) ? $item['title'] : (isset($item['value']) ? $item['value'] : '')); + } else { + $label = is_int($key) ? $item : $item; + } + + $label = trim((string) $label); + if ($label !== '') { $labels[] = $label; } + } + + if (!$labels) { return 'Не задано'; } + $shown = array_slice($labels, 0, 3); + return count($labels) . ' знач.: ' . implode(', ', $shown) . (count($labels) > 3 ? '...' : ''); + } + + protected static function conditionLabel(array $condition) + { + if (!$condition) { return 'Нет'; } + $modes = array('show' => 'Показать', 'hide' => 'Скрыть', 'lock' => 'Запретить изменение'); + $mode = isset($condition['mode']) && isset($modes[$condition['mode']]) ? $modes[$condition['mode']] : 'Применить'; + $tree = isset($condition['tree']) ? $condition['tree'] : array(); + $ruleCount = self::conditionRuleCount($tree); + $parts = array($mode, $ruleCount . ' ' . ($ruleCount === 1 ? 'правило' : 'правил')); + $rules = array(); + self::conditionRules($tree, $rules); + if ($rules) { + $parts[] = implode('; ', array_slice($rules, 0, 2)) . (count($rules) > 2 ? '; ...' : ''); + } + + if (!empty($condition['required'])) { $parts[] = 'обязательное'; } + if (!empty($condition['allowed_values']) && is_array($condition['allowed_values'])) { + $parts[] = count($condition['allowed_values']) . ' доступных вариантов'; + } + + if (isset($condition['value_action']) && $condition['value_action'] === 'set') { $parts[] = 'установить значение'; } + if (isset($condition['value_action']) && $condition['value_action'] === 'clear') { $parts[] = 'очистить значение'; } + + return implode(' · ', $parts); + } + + protected static function conditionRuleCount(array $node) + { + if (isset($node['items']) && is_array($node['items'])) { + $count = 0; + foreach ($node['items'] as $item) { $count += is_array($item) ? self::conditionRuleCount($item) : 0; } + return $count; + } + + return isset($node['field']) ? 1 : 0; + } + + protected static function conditionRules(array $node, array &$out) + { + if (isset($node['items']) && is_array($node['items'])) { + foreach ($node['items'] as $item) { + if (is_array($item)) { self::conditionRules($item, $out); } + } + + return; + } + + if (empty($node['field'])) { return; } + $operators = array( + 'empty' => 'не заполнено', 'not_empty' => 'заполнено', 'equals' => '=', 'not_equals' => '≠', + 'contains' => 'содержит', 'not_contains' => 'не содержит', 'greater' => '>', + 'greater_or_equal' => '≥', 'less' => '<', 'less_or_equal' => '≤', + 'in' => 'одно из', 'not_in' => 'не входит в', + ); + $field = preg_replace('/^(?:id|alias):/', '', (string) $node['field']); + $operator = isset($node['operator']) && isset($operators[$node['operator']]) ? $operators[$node['operator']] : '='; + $value = isset($node['value']) ? self::plainValue(is_array($node['value']) ? implode(', ', $node['value']) : $node['value']) : ''; + $out[] = trim($field . ' ' . $operator . ' ' . ($value === 'Не задано' ? '' : $value)); + } + + protected static function fieldTypeLabel($type) + { + $field = FieldRegistry::get((string) $type); + return $field ? (string) $field->name() : ((string) $type !== '' ? (string) $type : 'Тип не задан'); + } + + protected static function groupLabel($id, array $groups) + { + if ($id <= 0) { return 'Без группы'; } + return isset($groups[$id]) && trim((string) $groups[$id]['group_title']) !== '' + ? (string) $groups[$id]['group_title'] + : 'Группа #' . $id; + } + + protected static function settingLabel($key) + { + $known = array( + 'upload_dir' => 'Папка загрузки', 'rubric' => 'Рубрика-источник', + 'options' => 'Список значений', 'height' => 'Высота редактора', + 'editor' => 'Режим редактора', 'language' => 'Язык кода', + ); + if (isset($known[$key])) { return $known[$key]; } + return mb_convert_case(str_replace('_', ' ', (string) $key), MB_CASE_TITLE, 'UTF-8'); + } + + protected static function restoreBlockers(array $current, array $target, $rubricId) + { + $blockers = array(); + $aliases = array(); + foreach ($target as $id => $field) { + $alias = strtolower(trim((string) $field['rubric_field_alias'])); + if ($alias !== '' && isset($aliases[$alias]) && $aliases[$alias] !== (int) $id) { + $blockers[] = 'В ревизии повторяется alias поля «' . $alias . '»'; + } + + if ($alias !== '') { $aliases[$alias] = (int) $id; } + $type = (string) $field['rubric_field_type']; + if (!FieldTypes::exists($type)) { $blockers[] = 'Тип поля «' . $type . '» не установлен'; } + } + + foreach ($current as $id => $field) { + if (isset($target[$id])) { continue; } + $alias = strtolower(trim((string) $field['rubric_field_alias'])); + if ($alias !== '' && isset($aliases[$alias])) { + $blockers[] = 'Сохранённое новое поле «' . $field['rubric_field_title'] . '» использует alias «' . $alias . '» из ревизии'; + } + } + + foreach ($target as $id => $field) { + if (isset($current[$id])) { continue; } + $owner = DB::query('SELECT rubric_id FROM ' . Model::fieldsTable() . ' WHERE Id=%i LIMIT 1', (int) $id)->getValue(); + if ($owner && (int) $owner !== (int) $rubricId) { + $blockers[] = 'ID поля #' . (int) $id . ' уже занят другой рубрикой'; + } + } + + return array_values(array_unique($blockers)); + } + + protected static function restoreFingerprint(array $revision, array $current, $dependencyFingerprint) + { + $state = DB::query( + "SELECT COUNT(*) count,COALESCE(MAX(Id),0) max_id,COALESCE(MAX(document_changed),0) changed" + . " FROM " . Model::docsTable() . " WHERE rubric_id=%i AND document_deleted!='1'", + (int) $revision['rubric_id'] + )->getAssoc() ?: array(); + $payload = array( + 'revision_id' => (int) $revision['id'], + 'target_hash' => (string) $revision['snapshot_hash'], + 'current_hash' => sha1(Json::encode(Model::normalizeSchemaSnapshot($current), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)), + 'documents' => $state, + 'dependencies' => (string) $dependencyFingerprint, + ); + + return hash('sha256', Json::encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + protected static function fieldReferences(array $current, array $target, array $fieldIds) + { + $wanted = array_fill_keys(array_map('intval', $fieldIds), true); + $references = array(); + foreach (array($current, $target) as $snapshot) { + foreach (isset($snapshot['fields']) && is_array($snapshot['fields']) ? $snapshot['fields'] : array() as $field) { + $id = isset($field['Id']) ? (int) $field['Id'] : 0; + if ($id <= 0 || !isset($wanted[$id])) { continue; } + if (!isset($references[$id])) { + $references[$id] = array('id' => $id, 'title' => '', 'aliases' => array()); + } + + $title = trim((string) (isset($field['rubric_field_title']) ? $field['rubric_field_title'] : '')); + $alias = strtolower(trim((string) (isset($field['rubric_field_alias']) ? $field['rubric_field_alias'] : ''))); + if ($title !== '') { $references[$id]['title'] = $title; } + if ($alias !== '' && !in_array($alias, $references[$id]['aliases'], true)) { + $references[$id]['aliases'][] = $alias; + } + } + } + + return $references; + } + + protected static function documentsWithValues($rubricId, array $fieldIds) + { + if (!$fieldIds) { return 0; } + return (int) DB::query( + 'SELECT COUNT(DISTINCT df.document_id) FROM ' . Model::docFieldsTable() . ' df' + . ' LEFT JOIN ' . Model::docFieldsTextTable() . ' dft ON dft.document_id=df.document_id AND dft.rubric_field_id=df.rubric_field_id' + . ' INNER JOIN ' . Model::docsTable() . ' d ON d.Id=df.document_id' + . " WHERE d.rubric_id=%i AND d.document_deleted!='1' AND df.rubric_field_id IN %li" + . " AND (df.field_value!='' OR COALESCE(dft.field_value,'')!='')", + (int) $rubricId, + $fieldIds + )->getValue(); + } + + protected static function mapById(array $items) + { + $out = array(); + foreach ($items as $item) { + if (is_array($item) && !empty($item['Id'])) { $out[(int) $item['Id']] = $item; } + } + + return $out; + } + + protected static function format(array $row, $withSnapshot) + { + $labels = self::labels(); + $action = (string) $row['action']; + $meta = isset($labels[$action]) ? $labels[$action] : array('label' => $action, 'badge' => 'badge-gray'); + $decoded = Json::toArray((string) $row['snapshot_json']); + $snapshot = $withSnapshot ? $decoded : null; + $created = (int) $row['created_at']; + $groups = isset($decoded['groups']) ? count($decoded['groups']) : 0; + $fields = isset($decoded['fields']) ? count($decoded['fields']) : 0; + + return array( + 'id' => (int) $row['id'], + 'rubric_id' => (int) $row['rubric_id'], + 'action' => $action, + 'action_label' => $meta['label'], + 'badge' => $meta['badge'], + 'comment' => (string) $row['comment'], + 'author_id' => (int) $row['author_id'], + 'author_name' => (string) $row['author_name'], + 'source_revision_id' => (int) $row['source_revision_id'], + 'created_at' => $created, + 'created_label' => $created ? date('d.m.Y H:i:s', $created) : '-', + 'snapshot_hash' => (string) $row['snapshot_hash'], + 'groups_count' => $groups, + 'fields_count' => $fields, + 'snapshot' => $snapshot, + ); + } + + protected static function authorName($id) + { + if ((int) $id <= 0) { return ''; } + $row = DB::query('SELECT name,login,email FROM ' . SystemTables::table('users') . ' WHERE id=%i LIMIT 1', (int) $id)->getAssoc(); + if (!$row) { return '#' . (int) $id; } + if (!empty($row['name'])) { return (string) $row['name']; } + if (!empty($row['login'])) { return '@' . (string) $row['login']; } + + return !empty($row['email']) ? (string) $row['email'] : '#' . (int) $id; + } + } diff --git a/adminx/modules/Rubrics/RubricSchemaImpact.php b/adminx/modules/Rubrics/RubricSchemaImpact.php new file mode 100644 index 0000000..e124f23 --- /dev/null +++ b/adminx/modules/Rubrics/RubricSchemaImpact.php @@ -0,0 +1,502 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Rubrics; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Content\CatalogTables; + use App\Content\ContentTables; + use App\Content\Requests\RequestResultContract; + use App\Helpers\Hooks; + use App\Helpers\Json; + use DB; + + /** Read-only dependency report shown before restoring a rubric schema revision. */ + class RubricSchemaImpact + { + public static function analyze($rubricId, array $fieldReferences) + { + $rubricId = (int) $rubricId; + $fields = self::normalizeFieldReferences($fieldReferences); + $report = array( + 'requests' => array(), + 'templates' => array(), + 'api' => array(), + 'modules' => array(), + 'warnings' => array(), + ); + + if ($rubricId <= 0 || !$fields) { + return self::finalize($report); + } + + try { + self::collectRequests($rubricId, $fields, $report); + } catch (\Throwable $e) { + $report['warnings'][] = 'Не удалось проверить запросы и API-контракты.'; + } + + try { + self::collectRubricTemplates($rubricId, $fields, $report); + } catch (\Throwable $e) { + $report['warnings'][] = 'Не удалось проверить шаблоны рубрики.'; + } + + self::collectDocumentApi($rubricId, $fields, $report); + self::collectCatalog($rubricId, $fields, $report); + self::collectFeeds($rubricId, $fields, $report); + self::collectModuleExtensions($rubricId, $fields, $report); + + return self::finalize($report); + } + + public static function normalizeFieldReferences(array $references) + { + $out = array(); + foreach ($references as $key => $reference) { + if (!is_array($reference)) { continue; } + $id = isset($reference['id']) ? (int) $reference['id'] : (int) $key; + if ($id <= 0) { continue; } + $aliases = isset($reference['aliases']) && is_array($reference['aliases']) + ? $reference['aliases'] + : array(isset($reference['alias']) ? $reference['alias'] : ''); + $cleanAliases = array(); + foreach ($aliases as $alias) { + $alias = strtolower(trim((string) $alias)); + if ($alias !== '' && !in_array($alias, $cleanAliases, true)) { $cleanAliases[] = $alias; } + } + + $out[$id] = array( + 'id' => $id, + 'title' => trim((string) (isset($reference['title']) ? $reference['title'] : '')), + 'aliases' => $cleanAliases, + ); + } + + ksort($out, SORT_NUMERIC); + return $out; + } + + /** Return affected field IDs referenced by fld/rfld/field tags in a template. */ + public static function templateReferences($template, array $fieldReferences) + { + $fields = self::normalizeFieldReferences($fieldReferences); + if (!$fields || trim((string) $template) === '') { return array(); } + preg_match_all('/\[tag:(?:r?fld|field):([a-zA-Z0-9_-]+)/i', (string) $template, $matches); + $tokens = isset($matches[1]) ? array_map('strtolower', $matches[1]) : array(); + $out = array(); + foreach ($fields as $fieldId => $field) { + if (in_array((string) $fieldId, $tokens, true) || array_intersect($field['aliases'], $tokens)) { + $out[] = (int) $fieldId; + } + } + + return $out; + } + + protected static function collectRequests($rubricId, array $fields, array &$report) + { + $requestTable = ContentTables::table('request'); + $conditionTable = ContentTables::table('request_conditions'); + if (!self::tableExists($requestTable)) { return; } + + $conditions = array(); + if (self::tableExists($conditionTable)) { + $rows = DB::query( + 'SELECT c.request_id,c.condition_field_id FROM ' . $conditionTable . ' c' + . ' INNER JOIN ' . $requestTable . ' r ON r.Id=c.request_id' + . ' WHERE r.rubric_id=%i AND c.condition_field_id IN %li', + (int) $rubricId, + array_keys($fields) + )->getAll() ?: array(); + foreach ($rows as $row) { + $conditions[(int) $row['request_id']][] = (int) $row['condition_field_id']; + } + } + + $requests = DB::query( + 'SELECT Id,request_title,request_alias,request_order_by_nat,request_sort_rules,request_template_item,request_template_main,request_result_contract' + . ' FROM ' . $requestTable . ' WHERE rubric_id=%i ORDER BY Id', + (int) $rubricId + )->getAll() ?: array(); + foreach ($requests as $request) { + $id = (int) $request['Id']; + $details = array('Источник данных: эта рубрика'); + if (!empty($conditions[$id])) { + $details[] = 'Условия: ' . self::fieldList($conditions[$id], $fields); + } + + $orderFieldIds = array(); + foreach (\App\Frontend\RequestSort::rulesForRequest($request) as $sortRule) { + if ($sortRule['source'] === 'field' && isset($fields[(int) $sortRule['key']])) { + $orderFieldIds[] = (int) $sortRule['key']; + } + } + + if ($orderFieldIds) { $details[] = 'Сортировка: ' . self::fieldList(array_unique($orderFieldIds), $fields); } + + $templateIds = array_values(array_unique(array_merge( + self::templateReferences((string) $request['request_template_item'], $fields), + self::templateReferences((string) $request['request_template_main'], $fields) + ))); + if ($templateIds) { + $details[] = 'Шаблон результата: ' . self::fieldList($templateIds, $fields); + $report['templates'][] = self::item( + 'request:' . $id, + 'Шаблоны запроса «' . self::requestTitle($request) . '»', + 'Запрос #' . $id, + array('Используются поля: ' . self::fieldList($templateIds, $fields)) + ); + } + + $contract = RequestResultContract::decode((string) $request['request_result_contract']); + $contractIds = array_values(array_intersect(array_keys($fields), $contract['fields'])); + if ($contractIds) { + $details[] = 'Контракт результата: ' . self::fieldList($contractIds, $fields); + $report['api'][] = self::item( + 'request:' . $id, + self::requestTitle($request), + 'Контракт запроса #' . $id, + array('API и структурированный preview получают: ' . self::fieldList($contractIds, $fields)) + ); + } + + $report['requests'][] = self::item( + 'id:' . $id, + self::requestTitle($request), + 'Запрос #' . $id . self::aliasMeta($request['request_alias']), + $details + ); + } + } + + protected static function collectRubricTemplates($rubricId, array $fields, array &$report) + { + $rubric = DB::query( + 'SELECT rubric_title,rubric_template,rubric_teaser_template,rubric_header_template,rubric_og_template,rubric_footer_template' + . ' FROM ' . Model::rubricsTable() . ' WHERE Id=%i LIMIT 1', + (int) $rubricId + )->getAssoc(); + if ($rubric) { + $parts = array( + 'rubric_template' => 'Документ', + 'rubric_teaser_template' => 'Тизер', + 'rubric_header_template' => 'Шапка', + 'rubric_og_template' => 'Open Graph', + 'rubric_footer_template' => 'Подвал', + ); + foreach ($parts as $column => $label) { + $ids = self::templateReferences(isset($rubric[$column]) ? $rubric[$column] : '', $fields); + if (!$ids) { continue; } + $report['templates'][] = self::item( + 'rubric:' . $column, + $label . ': «' . (string) $rubric['rubric_title'] . '»', + 'Основной шаблон рубрики', + array('Используются поля: ' . self::fieldList($ids, $fields)) + ); + } + } + + if (!self::tableExists(Model::templatesTable())) { return; } + $templates = DB::query( + 'SELECT id,title,template FROM ' . Model::templatesTable() . ' WHERE rubric_id=%i ORDER BY id', + (int) $rubricId + )->getAll() ?: array(); + foreach ($templates as $template) { + $ids = self::templateReferences((string) $template['template'], $fields); + if (!$ids) { continue; } + $report['templates'][] = self::item( + 'extra:' . (int) $template['id'], + trim((string) $template['title']) ?: 'Шаблон #' . (int) $template['id'], + 'Дополнительный шаблон #' . (int) $template['id'], + array('Используются поля: ' . self::fieldList($ids, $fields)) + ); + } + } + + protected static function collectDocumentApi($rubricId, array $fields, array &$report) + { + $report['api'][] = self::item( + 'document-api:rubric:' . (int) $rubricId, + 'JSON API документов', + '/api/v1/documents · рубрика #' . (int) $rubricId, + array( + 'Чтение и запись используют схему: ' . self::fieldList(array_keys($fields), $fields), + 'После восстановления проверьте клиентов, которые передают alias или ID этих полей.', + ) + ); + } + + protected static function collectCatalog($rubricId, array $fields, array &$report) + { + $settingsTable = CatalogTables::table('module_catalog_settings'); + $itemsTable = CatalogTables::table('module_catalog_items'); + $variantTable = CatalogTables::table('catalog_variant_attributes'); + try { + if (self::tableExists($settingsTable)) { + $rows = DB::query('SELECT * FROM ' . $settingsTable . ' WHERE rubric_id=%i ORDER BY id', (int) $rubricId)->getAll() ?: array(); + foreach ($rows as $row) { + $used = self::catalogSettingsFields($row, $fields); + $details = array('Каталог построен на этой рубрике'); + if ($used) { $details[] = 'Назначения товаров: ' . self::fieldList($used, $fields); } + $report['modules'][] = self::item('catalog:settings:' . (int) $row['id'], 'Настройки каталога', 'Модуль «Каталог»', $details); + } + } + + if (self::tableExists($itemsTable)) { + $rows = DB::query('SELECT id,name,field_id,fields_use,filters_use FROM ' . $itemsTable . ' WHERE rubric_id=%i ORDER BY id', (int) $rubricId)->getAll() ?: array(); + foreach ($rows as $row) { + $used = self::catalogItemFields($row, $fields); + $details = array('Раздел использует эту рубрику'); + if ($used) { $details[] = 'Поля и фильтры: ' . self::fieldList($used, $fields); } + $report['modules'][] = self::item( + 'catalog:item:' . (int) $row['id'], + trim((string) $row['name']) ?: 'Раздел #' . (int) $row['id'], + 'Раздел каталога #' . (int) $row['id'], + $details + ); + } + } + + if (self::tableExists($variantTable)) { + $rows = DB::query( + 'SELECT field_id,COUNT(*) count FROM ' . $variantTable . ' WHERE field_id IN %li GROUP BY field_id ORDER BY field_id', + array_keys($fields) + )->getAll() ?: array(); + foreach ($rows as $row) { + $fieldId = (int) $row['field_id']; + $report['modules'][] = self::item( + 'products:variants:' . $fieldId, + 'Варианты товаров', + 'Модуль «Товары»', + array(self::fieldLabel($fields[$fieldId]) . ': ' . (int) $row['count'] . ' значений вариантов') + ); + } + } + } catch (\Throwable $e) { + $report['warnings'][] = 'Не удалось полностью проверить зависимости каталога.'; + } + } + + protected static function collectFeeds($rubricId, array $fields, array &$report) + { + $table = ContentTables::table('feed_definitions'); + if (!self::tableExists($table)) { return; } + try { + $catalogColumn = self::columnExists($table, 'catalog_field_id') ? 'catalog_field_id' : '0 AS catalog_field_id'; + $rows = DB::query( + 'SELECT id,name,alias,' . $catalogColumn . ',mappings_json,params_json,conditions_json' + . ' FROM ' . $table . ' WHERE rubric_id=%i ORDER BY id', + (int) $rubricId + )->getAll() ?: array(); + foreach ($rows as $row) { + $used = array(); + $catalogFieldId = (int) $row['catalog_field_id']; + if ($catalogFieldId > 0 && isset($fields[$catalogFieldId])) { $used[$catalogFieldId] = $catalogFieldId; } + foreach (array('mappings_json', 'params_json', 'conditions_json') as $column) { + $data = Json::toArray((string) $row[$column]); + if (is_array($data)) { self::findStructuredFields($data, $fields, $used); } + } + + $details = array('Фид построен на этой рубрике'); + if ($used) { $details[] = 'Настройки полей: ' . self::fieldList(array_keys($used), $fields); } + $report['modules'][] = self::item( + 'feeds:' . (int) $row['id'], + trim((string) $row['name']) ?: 'Фид #' . (int) $row['id'], + 'Модуль «Товарные фиды»' . self::aliasMeta($row['alias']), + $details + ); + } + } catch (\Throwable $e) { + $report['warnings'][] = 'Не удалось проверить зависимости товарных фидов.'; + } + } + + protected static function collectModuleExtensions($rubricId, array $fields, array &$report) + { + if (!class_exists(Hooks::class) || !Hooks::exists('content.rubric.schema_impact')) { return; } + $payload = array( + 'rubric_id' => (int) $rubricId, + 'field_ids' => array_keys($fields), + 'fields' => array_values($fields), + 'items' => array(), + ); + try { + $payload = Hooks::filter('content.rubric.schema_impact', $payload); + $items = is_array($payload) && isset($payload['items']) && is_array($payload['items']) ? $payload['items'] : array(); + foreach ($items as $index => $item) { + if (!is_array($item)) { continue; } + $report['modules'][] = self::item( + isset($item['key']) ? $item['key'] : 'extension:' . $index, + isset($item['title']) ? $item['title'] : 'Зависимость модуля', + isset($item['meta']) ? $item['meta'] : 'Модуль', + isset($item['details']) && is_array($item['details']) ? $item['details'] : array() + ); + } + } catch (\Throwable $e) { + $report['warnings'][] = 'Один из модулей не завершил анализ зависимостей.'; + } + } + + protected static function catalogSettingsFields(array $row, array $fields) + { + $columns = array( + 'field_id', 'product_title_field_id', 'product_article_field_id', 'product_price_field_id', + 'product_old_price_field_id', 'product_stock_field_id', 'product_images_field_id', + ); + $used = array(); + foreach ($columns as $column) { + $id = isset($row[$column]) ? (int) $row[$column] : 0; + if ($id > 0 && isset($fields[$id])) { $used[$id] = $id; } + } + + foreach (array('fields_default', 'filters_default', 'filters_default_settings', 'product_card_settings') as $column) { + if (!isset($row[$column])) { continue; } + $data = Json::toArray((string) $row[$column]); + if (is_array($data)) { self::findStructuredFields($data, $fields, $used); } + } + + foreach (array('fields_default', 'filters_default') as $column) { + preg_match_all('/\d+/', isset($row[$column]) ? (string) $row[$column] : '', $matches); + foreach (isset($matches[0]) ? $matches[0] : array() as $value) { + $fieldId = (int) $value; + if (isset($fields[$fieldId])) { $used[$fieldId] = $fieldId; } + } + } + + return array_values($used); + } + + protected static function catalogItemFields(array $row, array $fields) + { + $used = array(); + $id = isset($row['field_id']) ? (int) $row['field_id'] : 0; + if ($id > 0 && isset($fields[$id])) { $used[$id] = $id; } + foreach (array('fields_use', 'filters_use') as $column) { + preg_match_all('/\d+/', isset($row[$column]) ? (string) $row[$column] : '', $matches); + foreach (isset($matches[0]) ? $matches[0] : array() as $value) { + $fieldId = (int) $value; + if (isset($fields[$fieldId])) { $used[$fieldId] = $fieldId; } + } + } + + return array_values($used); + } + + protected static function findStructuredFields(array $data, array $fields, array &$used) + { + foreach ($data as $key => $value) { + if (is_array($value)) { + self::findStructuredFields($value, $fields, $used); + continue; + } + + $fieldId = 0; + if (in_array((string) $key, array('field_id', 'fieldId'), true)) { $fieldId = (int) $value; } + if (is_string($value) && preg_match('/^field:(\d+)$/', $value, $match)) { $fieldId = (int) $match[1]; } + if ($fieldId > 0 && isset($fields[$fieldId])) { $used[$fieldId] = $fieldId; } + } + } + + protected static function finalize(array $report) + { + foreach (array('requests', 'templates', 'api', 'modules') as $section) { + $report[$section] = self::uniqueItems($report[$section]); + } + + $report['warnings'] = array_values(array_unique(array_filter(array_map('strval', $report['warnings'])))); + $report['summary'] = array( + 'requests' => count($report['requests']), + 'templates' => count($report['templates']), + 'api' => count($report['api']), + 'modules' => count($report['modules']), + ); + $report['fingerprint'] = hash( + 'sha256', + Json::encode($report, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + + return $report; + } + + protected static function uniqueItems(array $items) + { + $out = array(); + foreach ($items as $item) { + if (!is_array($item)) { continue; } + $key = (string) (isset($item['key']) ? $item['key'] : ''); + if ($key === '') { $key = sha1(Json::encode($item)); } + $out[$key] = self::item( + $key, + isset($item['title']) ? $item['title'] : 'Зависимость', + isset($item['meta']) ? $item['meta'] : '', + isset($item['details']) && is_array($item['details']) ? $item['details'] : array() + ); + } + + ksort($out, SORT_STRING); + return array_values($out); + } + + protected static function item($key, $title, $meta, array $details) + { + return array( + 'key' => trim((string) $key), + 'title' => trim((string) $title), + 'meta' => trim((string) $meta), + 'details' => array_values(array_unique(array_filter(array_map('strval', $details)))), + ); + } + + protected static function fieldList(array $ids, array $fields) + { + $labels = array(); + foreach (array_values(array_unique(array_map('intval', $ids))) as $id) { + if (isset($fields[$id])) { $labels[] = self::fieldLabel($fields[$id]); } + } + + return implode(', ', $labels); + } + + protected static function fieldLabel(array $field) + { + $title = trim((string) $field['title']); + $alias = !empty($field['aliases']) ? (string) $field['aliases'][0] : ''; + return ($title !== '' ? $title : 'Поле #' . (int) $field['id']) + . ($alias !== '' ? ' [' . $alias . ']' : ' [#' . (int) $field['id'] . ']'); + } + + protected static function requestTitle(array $request) + { + $title = trim((string) $request['request_title']); + return $title !== '' ? $title : 'Запрос #' . (int) $request['Id']; + } + + protected static function aliasMeta($alias) + { + $alias = trim((string) $alias); + return $alias !== '' ? ' · ' . $alias : ''; + } + + protected static function tableExists($table) + { + return (bool) DB::query('SHOW TABLES LIKE %s', (string) $table)->getValue(); + } + + protected static function columnExists($table, $column) + { + return (bool) DB::query('SHOW COLUMNS FROM ' . $table . ' LIKE %s', (string) $column)->getValue(); + } + } diff --git a/adminx/modules/Rubrics/assets/rubrics.css b/adminx/modules/Rubrics/assets/rubrics.css new file mode 100644 index 0000000..dd443cc --- /dev/null +++ b/adminx/modules/Rubrics/assets/rubrics.css @@ -0,0 +1,4772 @@ +.rubrics-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 14px; + margin-bottom: 16px; +} +.rubrics-panel a, +.rubrics-panel a:hover, +.rubrics-main-tabs .tab, +.rubrics-main-tabs .tab:hover { + text-decoration: none; +} +.rubrics-stat { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; +} +.rubrics-stat b { + display: block; + font-size: 20px; + line-height: 1.1; + font-weight: 800; + font-variant-numeric: tabular-nums; +} +.rubrics-stat span { + font-size: 13px; + color: var(--text-secondary); +} +.rubrics-import-card { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; + padding: 15px 16px; +} +.rubrics-import-title { + margin-bottom: 3px; + font-weight: 800; + color: var(--text-primary); +} +.rubrics-panel-header { + margin-bottom: 18px; +} +.rubrics-main-tabs { + margin-bottom: 16px; +} +.rubrics-panel-header h2 { + font-size: 22px; + line-height: 1.2; + font-weight: 800; + letter-spacing: 0; + text-wrap: balance; +} +.rubrics-card { + padding: 0; + overflow: hidden; +} +.rubrics-filter-card { + margin-bottom: 14px; +} +.rubrics-panel .table-scroll { + max-height: none; + overflow-x: hidden; + overflow-y: hidden; +} +.rubrics-panel table.table { + min-width: 0; +} +.rubrics-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid var(--border-default); +} +.rubrics-section-title { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} +.rubrics-section-title h2, +.rubrics-section-title h4 { + margin: 0 0 4px; + font-size: 16px; + line-height: 1.25; + font-weight: 700; + letter-spacing: 0; + text-wrap: balance; +} +.rubrics-section-title p { + margin: 0; + font-size: 13px; + line-height: 1.4; + text-wrap: pretty; +} +.rubrics-head-icon { + width: 34px; + height: 34px; + border-radius: var(--radius-md); + font-size: 17px; +} +.rubrics-toolbar, +.rubrics-filter { + display: flex; + align-items: center; + gap: 10px; +} +.rubrics-filter { + flex: 1 1 auto; + min-width: 0; +} +.rubrics-filter .input-wrap { + flex: 1 1 320px; + min-width: 240px; + max-width: 560px; +} +.rubrics-filter > .select { + flex: 0 0 190px; + width: 190px; +} +.rubrics-types-panel > .alert { + margin-bottom: 18px; +} +.rubrics-field-form [data-rubric-preset-wrap][hidden] { + display: none; +} +.rubrics-table { + width: 100%; + table-layout: fixed; +} +.rubrics-type-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 14px; + margin-bottom: 16px; +} +.rubrics-type-table { + width: 100%; + table-layout: fixed; +} +.rubrics-type-col-name { + width: 21%; +} +.rubrics-type-col-description { + width: 43%; +} +.rubrics-type-col-usage { + width: 13%; +} +.rubrics-type-col-status { + width: 13%; +} +.rubrics-type-col-enabled { + width: 10%; +} +.rubrics-type-name { + display: grid; + gap: 3px; + min-width: 0; +} +.rubrics-type-name b, +.rubrics-type-name small { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-type-name small { + color: var(--text-secondary); + font-size: 12px; +} +.rubrics-type-title { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} +.rubrics-type-title b { + min-width: 0; +} +.rubrics-type-title .badge { + flex: 0 0 auto; + padding: 2px 6px; + font-size: 10px; +} +.rubrics-type-description { + margin: 0; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.45; + overflow-wrap: anywhere; +} +.rubrics-type-tools { + display: grid; + grid-template-columns: minmax(260px, 1fr) minmax(180px, 240px); + gap: 12px; + padding: 0 16px 14px; +} +.rubrics-type-tools .input-wrap, +.rubrics-type-tools .select { + width: 100%; +} +.rubrics-type-table tbody tr.is-disabled td { + background: var(--gray-50); + color: var(--text-secondary); +} +.rubrics-type-table tbody tr.is-disabled:hover td { + background: var(--gray-100); +} +.rubrics-type-table tbody tr.is-disabled .rubrics-type-name b { + color: var(--text-secondary); +} +.rubrics-type-switch { + display: inline-flex; + vertical-align: middle; +} +.rubrics-type-issues { + display: grid; + gap: 2px; + margin-top: 6px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.35; +} +.rubrics-type-issues span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-col-handle { + width: 48px; +} +.rubrics-col-id { + width: 7ch; +} +.rubrics-col-title { + width: auto; +} +.rubrics-col-state { + width: 136px; +} +.rubrics-col-counts { + width: 132px; +} +.rubrics-col-documents { + width: 170px; +} +.rubrics-col-meta { + width: 160px; +} +.rubrics-col-actions { + width: 224px; +} +.rubrics-name, +.rubrics-meta { + display: grid; + gap: 3px; + min-width: 0; +} +.rubrics-name b, +.rubrics-name small, +.rubrics-meta span { + overflow-wrap: anywhere; +} +.rubrics-name small, +.rubrics-meta { + color: var(--text-secondary); + font-size: 12px; +} +.rubrics-materials { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; +} +.rubrics-materials-link, +.rubrics-materials-count { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + padding: 5px 7px; + border-radius: var(--radius-sm); + color: var(--text-primary); + text-decoration: none; +} +.rubrics-materials-link { + flex: 1 1 auto; + transition: background-color 150ms ease, color 150ms ease; +} +.rubrics-materials-link:hover { + background: var(--blue-100); + color: var(--blue-700); + text-decoration: none; +} +.rubrics-materials-link > span, +.rubrics-materials-count { + min-width: 0; +} +.rubrics-materials-link b, +.rubrics-materials-link small, +.rubrics-materials-count b, +.rubrics-materials-count small, +.rubrics-template-name b, +.rubrics-template-name small { + display: block; +} +.rubrics-materials-link b, +.rubrics-materials-count b { + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.rubrics-materials-link small, +.rubrics-materials-count small, +.rubrics-template-name small { + margin-top: 1px; + color: var(--text-muted); + font-size: 10.5px; +} +.rubrics-materials-link > i { + flex: 0 0 auto; + color: var(--text-muted); + font-size: 15px; +} +.rubrics-action-document-new { + flex: 0 0 auto; + color: var(--green-600); +} +.rubrics-action-document-new:hover { + background: var(--green-100); + color: var(--green-700); +} +.rubrics-template-name { + min-width: 0; +} +.rubrics-template-name b { + overflow: hidden; + font-size: 11.5px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} +.rubrics-template-name.is-missing b { + color: var(--amber-700); +} +.rubrics-badges { + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.rubrics-actions { + gap: 4px; + flex-wrap: nowrap; + justify-content: flex-end; +} +.rubrics-actions .btn-icon, +.rubrics-drag-handle, +.rubrics-group-drag, +.rubrics-field-drag { + width: 34px; + height: 34px; + min-width: 34px; +} +.rubrics-actions .btn, +.rubrics-drag-handle, +.rubrics-group-drag, +.rubrics-field-drag { + transition-property: background-color, color, transform; + transition-duration: 150ms; +} +.rubrics-actions .btn:active, +.rubrics-drag-handle:active, +.rubrics-group-drag:active, +.rubrics-field-drag:active { + transform: scale(0.96); +} +@media (max-width: 980px) { + .rubrics-table .rubrics-cell-handle, + .rubrics-table .rubrics-cell-id, + .rubrics-table .rubrics-cell-counts, + .rubrics-table .rubrics-cell-template { + display: none; + } + .rubrics-col-title { + width: auto; + } + .rubrics-col-state { + width: 128px; + } + .rubrics-col-documents { + width: 156px; + } + .rubrics-col-actions { + width: 224px; + } +} +@media (max-width: 680px) { + .rubrics-card > .table-scroll { + overflow-x: hidden; + } + .rubrics-table, + .rubrics-table tbody { + display: block; + width: 100%; + } + .rubrics-table colgroup, + .rubrics-table thead { + display: none; + } + .rubrics-table tbody tr { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 9px 12px; + padding: 12px; + border-bottom: 1px solid var(--border-subtle); + } + .rubrics-table tbody td { + display: block; + width: auto; + padding: 0; + border: 0; + } + .rubrics-table .rubrics-cell-title { + grid-column: 1; + grid-row: 1; + } + .rubrics-table .rubrics-cell-documents { + grid-column: 2; + grid-row: 1; + } + .rubrics-table .rubrics-cell-state { + grid-column: 1 / -1; + grid-row: 2; + } + .rubrics-table .rubrics-cell-actions { + grid-column: 1 / -1; + grid-row: 3; + } + .rubrics-actions { + flex-wrap: wrap; + justify-content: flex-start; + } + .rubrics-materials-link { + min-width: 94px; + } +} +.rubrics-action-fields { + color: var(--cyan-600); +} +.rubrics-action-templates { + color: var(--green-600); +} +.rubrics-action-revisions { + color: var(--violet-600); +} +.rubrics-action-edit { + color: var(--blue-600); +} +.rubrics-action-tag-copy { + color: var(--blue-600); +} +.rubrics-action-danger { + color: var(--red-600); +} +.rubrics-action-locked { + color: var(--text-tertiary); +} +.rubrics-revisions-panel { + width: min(92vw, 1180px); + max-width: min(92vw, 1180px); +} +.rubrics-revisions-panel .drawer-header [data-close-drawer] { + flex: 0 0 auto; + margin-left: auto; +} +.rubrics-revisions-body { + display: grid; + gap: 14px; + align-content: start; + min-height: 0; + background: var(--background-muted); +} +.rubrics-revisions-section { + min-width: 0; + overflow: hidden; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--surface-card); +} +.rubrics-revisions-head { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-default); +} +.rubrics-revisions-head h4, +.rubrics-revisions-head p { + margin: 0; + letter-spacing: 0; +} +.rubrics-revisions-head h4 { + margin-bottom: 3px; + font-size: 15px; + line-height: 1.25; + font-weight: 800; +} +.rubrics-revisions-head p { + font-size: 12px; + line-height: 1.4; +} +.rubrics-revisions-list { + max-height: 290px; + overflow-y: auto; +} +.rubrics-revision-row { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto 34px; + align-items: center; + gap: 11px; + min-height: 68px; + padding: 10px 14px; + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; + transition: background-color 150ms ease; +} +.rubrics-revision-row:last-child { + border-bottom: 0; +} +.rubrics-revision-row:hover { + background: var(--background-muted); +} +.rubrics-revision-row.is-active { + background: var(--blue-100); +} +.rubrics-revision-row-icon { + width: 34px; + height: 34px; + --tile-bg: var(--violet-100); + --tile-fg: var(--violet-600); +} +.rubrics-revision-row-main { + min-width: 0; +} +.rubrics-revision-row-main > div { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.rubrics-revision-row-main b { + font-size: 13px; + letter-spacing: 0; +} +.rubrics-revision-row-main small, +.rubrics-revision-row-main p { + display: block; + margin: 2px 0 0; + overflow: hidden; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} +.rubrics-revision-row-main p { + color: var(--text-muted); +} +.rubrics-revision-row-counts { + display: flex; + align-items: center; + gap: 6px; +} +.rubrics-revision-row-counts span { + padding: 5px 7px; + border-radius: var(--radius-md); + background: var(--background-muted); + color: var(--text-secondary); + font-size: 10.5px; + white-space: nowrap; +} +.rubrics-revision-preview-body { + padding: 16px; +} +.rubrics-revision-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 160px; + color: var(--text-muted); + text-align: center; +} +.rubrics-revision-empty i { + font-size: 28px; +} +.rubrics-revision-empty b { + color: var(--text-primary); + font-size: 13px; + letter-spacing: 0; +} +.rubrics-revision-empty span { + font-size: 11.5px; +} +.rubrics-revision-notice { + display: flex; + align-items: flex-start; + gap: 11px; + padding: 12px 14px; + border-radius: var(--radius-md); + background: var(--green-600); + color: #fff; +} +.rubrics-revision-notice.is-danger { + background: var(--red-600); +} +.rubrics-revision-notice > i { + flex: 0 0 auto; + margin-top: 1px; + font-size: 20px; +} +.rubrics-revision-notice b, +.rubrics-revision-notice span { + display: block; + letter-spacing: 0; +} +.rubrics-revision-notice b { + margin-bottom: 2px; + font-size: 13px; +} +.rubrics-revision-notice span { + color: rgba(255, 255, 255, 0.9); + font-size: 11.5px; + line-height: 1.45; +} +.rubrics-revision-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} +.rubrics-revision-metric { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 11px 12px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--background-muted); +} +.rubrics-revision-metric > i { + flex: 0 0 auto; + font-size: 20px; + color: var(--cyan-600); +} +.rubrics-revision-metric.is-blue > i { + color: var(--blue-600); +} +.rubrics-revision-metric.is-green > i { + color: var(--green-600); +} +.rubrics-revision-metric.is-amber > i { + color: var(--amber-600); +} +.rubrics-revision-metric b, +.rubrics-revision-metric span { + display: block; + letter-spacing: 0; +} +.rubrics-revision-metric b { + font-size: 18px; + line-height: 1; + font-variant-numeric: tabular-nums; +} +.rubrics-revision-metric span { + margin-top: 3px; + overflow: hidden; + color: var(--text-secondary); + font-size: 10.5px; + text-overflow: ellipsis; + white-space: nowrap; +} +.rubrics-revision-condition { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + margin-top: 12px; + padding: 10px 12px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 11.5px; +} +.rubrics-revision-condition i { + color: var(--blue-600); + font-size: 18px; +} +.rubrics-revision-condition small { + color: var(--text-muted); + white-space: nowrap; +} +.rubrics-revision-dependencies { + min-width: 0; + margin-top: 12px; + padding: 12px; + border-radius: var(--radius-md); + background: var(--background-muted); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-revision-dependencies-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.rubrics-revision-dependencies-head h5, +.rubrics-revision-dependencies-head p { + margin: 0; + letter-spacing: 0; +} +.rubrics-revision-dependencies-head h5 { + font-size: 13px; + line-height: 1.3; +} +.rubrics-revision-dependencies-head p { + margin-top: 3px; + color: var(--text-secondary); + font-size: 11.5px; + line-height: 1.4; +} +.rubrics-revision-dependencies-head > span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 30px; + height: 26px; + padding: 0 8px; + border-radius: var(--radius-md); + background: var(--surface-card); + color: var(--text-secondary); + font-size: 11px; + font-weight: 800; + font-variant-numeric: tabular-nums; + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-revision-dependency-summary { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} +.rubrics-revision-dependency-summary > span { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 28px; + padding: 5px 8px; + border-radius: var(--radius-md); + background: var(--surface-card); + color: var(--text-secondary); + font-size: 10.5px; + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-revision-dependency-summary i { + color: var(--blue-600); + font-size: 15px; +} +.rubrics-revision-dependency-summary b { + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} +.rubrics-revision-dependency-group { + margin-top: 8px; + overflow: hidden; + border-radius: var(--radius-md); + background: var(--surface-card); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-revision-dependency-group > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 40px; + padding: 8px 10px; + color: var(--text-primary); + cursor: pointer; + list-style: none; +} +.rubrics-revision-dependency-group > summary::-webkit-details-marker { + display: none; +} +.rubrics-revision-dependency-group > summary > span { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 11.5px; + font-weight: 750; +} +.rubrics-revision-dependency-group > summary i { + color: var(--cyan-600); + font-size: 16px; +} +.rubrics-revision-dependency-group > summary b { + min-width: 22px; + color: var(--text-secondary); + font-size: 10.5px; + text-align: right; + font-variant-numeric: tabular-nums; +} +.rubrics-revision-dependency-group[open] > summary { + box-shadow: inset 0 -1px 0 var(--border-subtle); +} +.rubrics-revision-dependency-list { + display: grid; +} +.rubrics-revision-dependency-list article { + display: grid; + grid-template-columns: minmax(180px, 0.38fr) minmax(0, 0.62fr); + gap: 12px; + min-width: 0; + padding: 10px; + box-shadow: inset 0 -1px 0 var(--border-subtle); +} +.rubrics-revision-dependency-list article:last-child { + box-shadow: none; +} +.rubrics-revision-dependency-list article > div { + min-width: 0; +} +.rubrics-revision-dependency-list article b, +.rubrics-revision-dependency-list article small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.rubrics-revision-dependency-list article b { + font-size: 11.5px; + line-height: 1.35; +} +.rubrics-revision-dependency-list article small { + margin-top: 2px; + color: var(--text-muted); + font-size: 10.5px; + line-height: 1.35; +} +.rubrics-revision-dependency-list ul { + margin: 0; + padding-left: 16px; + color: var(--text-secondary); + font-size: 10.5px; + line-height: 1.45; +} +.rubrics-revision-dependency-list li + li { + margin-top: 2px; +} +.rubrics-revision-dependency-warning { + display: flex; + align-items: flex-start; + gap: 7px; + margin-top: 8px; + padding: 8px 10px; + border-radius: var(--radius-md); + background: var(--amber-100); + color: var(--amber-700); + font-size: 10.5px; + line-height: 1.4; +} +.rubrics-revision-dependency-warning i { + flex: 0 0 auto; + margin-top: 1px; + font-size: 15px; +} +.rubrics-revision-changes { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + margin-top: 12px; +} +.rubrics-revision-changes > section { + min-width: 0; + padding: 12px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); +} +.rubrics-revision-changes h5 { + margin: 0 0 10px; + font-size: 13px; + letter-spacing: 0; +} +.rubrics-revision-changes p { + margin: 0; + font-size: 11.5px; +} +.rubrics-revision-change-set + .rubrics-revision-change-set { + margin-top: 10px; +} +.rubrics-revision-change-set > b { + display: block; + margin-bottom: 5px; + color: var(--cyan-700); + font-size: 10.5px; + letter-spacing: 0; +} +.rubrics-revision-change-set.is-blue > b { + color: var(--blue-700); +} +.rubrics-revision-change-set.is-green > b { + color: var(--green-700); +} +.rubrics-revision-change-set > div { + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.rubrics-revision-change-set > div > span { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + min-height: 34px; + padding: 5px 8px; + border-radius: var(--radius-md); + background: var(--background-muted); + color: var(--text-secondary); + font-size: 10.5px; +} +.rubrics-revision-change-set > div > span > span { + min-width: 0; +} +.rubrics-revision-change-set > div > span b, +.rubrics-revision-change-set > div > span small { + display: block; + overflow: hidden; + letter-spacing: 0; + text-overflow: ellipsis; + white-space: nowrap; +} +.rubrics-revision-change-set > div > span b { + color: var(--text-primary); + font-size: 11px; +} +.rubrics-revision-change-set > div > span small { + margin-top: 1px; + color: var(--text-muted); + font-size: 9.5px; +} +.rubrics-revision-change-set code { + color: var(--text-muted); + font-size: 10px; +} +.rubrics-revision-change-list + .rubrics-revision-change-set { + margin-top: 12px; +} +.rubrics-revision-change-list-title { + display: block; + margin-bottom: 5px; + color: var(--blue-700); + font-size: 10.5px; + letter-spacing: 0; +} +.rubrics-revision-change-item { + border-top: 1px solid var(--border-subtle); +} +.rubrics-revision-change-item:last-child { + border-bottom: 1px solid var(--border-subtle); +} +.rubrics-revision-change-item > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 48px; + padding: 8px 0; +} +.rubrics-revision-change-item > header > div { + min-width: 0; +} +.rubrics-revision-change-item > header b, +.rubrics-revision-change-item > header small { + display: block; + overflow: hidden; + letter-spacing: 0; + text-overflow: ellipsis; + white-space: nowrap; +} +.rubrics-revision-change-item > header b { + color: var(--text-primary); + font-size: 12px; +} +.rubrics-revision-change-item > header small { + margin-top: 2px; + color: var(--text-muted); + font-size: 10.5px; +} +.rubrics-revision-change-item > header > span { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 7px; + color: var(--text-secondary); + font-size: 10.5px; +} +.rubrics-revision-change-item > header code { + color: var(--text-muted); + font-size: 10px; +} +.rubrics-revision-change-details { + padding-bottom: 8px; +} +.rubrics-revision-change-detail { + display: grid; + grid-template-columns: minmax(120px, 0.8fr) minmax(0, 1fr) 18px minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 38px; + padding: 6px 8px; + border-radius: var(--radius-sm); +} +.rubrics-revision-change-detail:nth-child(odd) { + background: var(--background-muted); +} +.rubrics-revision-change-detail > b { + color: var(--text-secondary); + font-size: 10.5px; + letter-spacing: 0; +} +.rubrics-revision-change-detail > span { + overflow-wrap: anywhere; + color: var(--text-secondary); + font-size: 10.5px; + line-height: 1.4; +} +.rubrics-revision-change-detail > .is-before { + color: var(--text-muted); + text-decoration: line-through; +} +.rubrics-revision-change-detail > .is-after { + color: var(--text-primary); + font-weight: 650; +} +.rubrics-revision-change-detail > i { + color: var(--blue-600); + font-size: 14px; +} +@media (max-width: 820px) { + .rubrics-revisions-panel { + width: 100vw; + max-width: 100vw; + } + .rubrics-revision-row { + grid-template-columns: 34px minmax(0, 1fr) 34px; + } + .rubrics-revision-row-counts { + display: none; + } + .rubrics-revision-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .rubrics-revision-changes { + grid-template-columns: 1fr; + } + .rubrics-revision-condition { + grid-template-columns: auto minmax(0, 1fr); + } + .rubrics-revision-condition small { + grid-column: 2; + white-space: normal; + } + .rubrics-revision-dependency-list article { + grid-template-columns: 1fr; + gap: 6px; + } + .rubrics-revision-change-detail { + grid-template-columns: minmax(90px, 0.8fr) minmax(0, 1fr); + gap: 3px 8px; + padding: 8px; + } + .rubrics-revision-change-detail > i { + display: none; + } + .rubrics-revision-change-detail > .is-after { + grid-column: 2; + } + .rubrics-revision-change-detail > .is-before::before { + content: "Было: "; + color: var(--text-muted); + text-decoration: none; + } + .rubrics-revision-change-detail > .is-after::before { + content: "Будет: "; + color: var(--text-muted); + font-weight: 400; + } + .rubrics-revisions-panel .drawer-footer { + flex-wrap: wrap; + } + .rubrics-revisions-panel [data-schema-revision-delete] { + margin-left: 0 !important; + } +} +.rubrics-drawer-panel, +.rubrics-edit-panel, +.rubrics-group-panel { + width: min(66vw, 980px); +} +#rubricDrawer { + width: 66.6667vw; + max-width: 66.6667vw; + overflow-x: hidden; +} +.rubrics-fields-panel { + width: 100vw; + max-width: 100vw; + overflow-x: hidden; +} +#rubricFieldsDrawer { + right: 0; + left: auto; + width: 100vw; + max-width: 100vw; +} +.rubrics-templates-panel { + width: 100vw; + max-width: 100vw; + overflow-x: hidden; +} +.rubrics-extra-template-panel { + width: min(66vw, 980px); + overflow-x: hidden; +} +.rubrics-drawer { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} +.rubrics-drawer .drawer-body { + overflow-x: hidden; +} +/* Секции формы настроек поля: цветные плашки-заголовки и визуальное разделение */ +.rubrics-field-form { + display: flex; + flex-direction: column; + gap: 16px; +} +.rubrics-field-section { + flex: 0 0 auto; + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--surface-card); + overflow: hidden; +} +.rubrics-field-section-head { + display: flex; + align-items: center; + gap: 12px; + padding: 13px 16px; + border-bottom: 1px solid var(--border-subtle); + border-left: 0; + background: transparent; +} +.rubrics-field-section-head .icon-tile { + width: 36px; + height: 36px; + flex: 0 0 auto; +} +.rubrics-field-section-head h4 { + margin: 0; + font-size: 14px; +} +.rubrics-field-section-head p { + margin: 2px 0 0; + font-size: 12px; + color: var(--text-secondary); +} +.rubrics-field-section-body { + padding: 16px; +} +.rubrics-field-section-plugin .rubrics-field-section-body { + padding: 0; +} +.rubrics-field-section-plugin .rubrics-plugin-body { + padding: 16px; +} +.rubrics-plugin-panel { + border-radius: var(--radius-lg); + background: var(--surface-card); + box-shadow: inset 0 0 0 1px var(--border-subtle); + overflow: hidden; +} +.rubrics-plugin-body { + display: grid; + gap: 12px; + padding: 14px; +} +.rubrics-plugin-meta { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.rubrics-field-settings-block { + margin: 12px 0; + padding: 12px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-inset); +} +.rubrics-plugin-subhead { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 10px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + color: var(--text-secondary); +} +.ax-field-validation-head { + margin: 12px 0 8px; + padding-top: 10px; + border-top: 1px dashed var(--border-default); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + color: var(--text-tertiary); +} +.rubrics-plugin-meta b, +.rubrics-plugin-meta small { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-plugin-meta small { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12px; +} +.rubrics-plugin-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} +.rubrics-plugin-grid span { + display: grid; + gap: 4px; + min-width: 0; + padding: 10px; + border-radius: var(--radius-md); + background: var(--surface-muted); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-plugin-grid i { + color: var(--cyan-600); + font-size: 17px; +} +.rubrics-plugin-grid b { + font-size: 12px; + line-height: 1.2; +} +.rubrics-plugin-grid small { + overflow: hidden; + color: var(--text-secondary); + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-admin-editor { + display: grid; + gap: 10px; + min-width: 0; + padding: 12px; + border-radius: var(--radius-lg); + background: var(--surface-muted); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-admin-editor-head { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + gap: 10px; + align-items: start; + min-width: 0; +} +.rubrics-admin-editor-icon { + width: 34px; + height: 34px; + border-radius: var(--radius-md); + font-size: 17px; +} +.rubrics-admin-editor-head b, +.rubrics-admin-editor-head small { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} +.rubrics-admin-editor-head b { + font-size: 13px; + line-height: 1.25; +} +.rubrics-admin-editor-head small { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.35; + text-wrap: pretty; +} +.rubrics-admin-editor-controls { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.rubrics-admin-editor-controls span { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + gap: 3px 7px; + min-width: 0; + padding: 9px; + border-radius: var(--radius-md); + background: var(--surface-card); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-admin-editor-controls i { + grid-row: span 2; + color: var(--blue-600); + font-size: 16px; +} +.rubrics-admin-editor-controls b, +.rubrics-admin-editor-controls small { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-admin-editor-controls b { + font-size: 12px; + line-height: 1.2; +} +.rubrics-admin-editor-controls small { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.2; +} +.rubrics-admin-editor-entity { + display: grid; + gap: 7px; + min-width: 0; + padding-top: 2px; +} +.rubrics-admin-editor-entity b { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.2; +} +.rubrics-admin-editor-entity div { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; +} +.rubrics-admin-editor-entity code { + max-width: 100%; + overflow: hidden; + padding: 5px 7px; + border-radius: var(--radius-sm); + background: var(--surface-card); + color: var(--text-primary); + font-size: 11px; + line-height: 1.2; + white-space: nowrap; + text-overflow: ellipsis; + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-default-field { + min-width: 0; +} +.rubrics-default-editor { + min-width: 0; +} +.rubrics-default-input-wrap { + position: relative; + min-width: 0; +} +.rubrics-default-input-wrap i { + position: absolute; + top: 50%; + left: 12px; + color: var(--text-tertiary); + font-size: 17px; + transform: translateY(-50%); + pointer-events: none; +} +.rubrics-default-input-wrap .input { + padding-left: 38px; +} +.rubrics-default-input-wrap.has-action { + display: grid; + grid-template-columns: minmax(0, 1fr) 34px; + gap: 8px; +} +.rubrics-default-input-wrap.has-action i { + left: 12px; +} +.rubrics-default-media-btn, +.rubrics-default-relation-btn, +.rubrics-default-list-pick { + width: 34px; + height: 34px; + min-width: 34px; + color: var(--blue-600); +} +.rubrics-default-boolean { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + min-width: 0; +} +.rubrics-default-choice { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 0; + min-height: 40px; + padding: 9px 12px; + border: 0; + border-radius: var(--radius-md); + background: var(--surface-muted); + color: var(--text-secondary); + font: inherit; + font-size: 13px; + font-weight: 700; + line-height: 1.2; + cursor: pointer; + box-shadow: inset 0 0 0 1px var(--border-subtle); + transition-property: background-color, color, transform, box-shadow; + transition-duration: 150ms; +} +.rubrics-default-choice:hover { + background: var(--surface-hover); + color: var(--text-primary); +} +.rubrics-default-choice:active { + transform: scale(0.96); +} +.rubrics-default-choice.is-active { + background: var(--blue-100); + color: var(--blue-700); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--blue-500) 34%, transparent); +} +.rubrics-default-choice i { + flex: 0 0 auto; + font-size: 17px; +} +.rubrics-default-choice span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-default-list { + display: grid; + gap: 10px; + min-width: 0; +} +.rubrics-default-list-rows { + display: grid; + gap: 8px; + min-width: 0; +} +.rubrics-default-list-row { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 34px; + gap: 8px; + align-items: center; + min-width: 0; +} +.rubrics-default-list-row.is-key-value { + grid-template-columns: 34px minmax(96px, 0.42fr) minmax(0, 0.58fr) 34px; +} +.rubrics-default-list-row.is-media-list { + grid-template-columns: 34px minmax(0, 1fr) 34px 34px; +} +.rubrics-default-list-index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: var(--radius-md); + background: var(--surface-muted); + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + font-variant-numeric: tabular-nums; + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-default-list-row .input { + min-width: 0; +} +.rubrics-default-list-remove { + width: 34px; + height: 34px; + min-width: 34px; + color: var(--red-600); +} +.rubrics-default-list-add { + justify-self: start; + min-height: 36px; +} +.rubrics-media-picker { + width: min(66.666vw, 1200px); + height: min(66.666vh, 800px); + max-height: calc(100vh - 40px); +} +.rubrics-media-tools { + display: grid; + grid-template-columns: minmax(0, 1fr) 40px; + gap: 10px; + align-items: center; + margin-bottom: 10px; +} +.rubrics-media-crumbs { + display: flex; + flex-wrap: wrap; + gap: 5px; + min-width: 0; + margin-bottom: 10px; + color: var(--text-secondary); + font-size: 12px; +} +.rubrics-media-crumb { + border: 0; + padding: 4px 6px; + border-radius: var(--radius-sm); + background: var(--surface-muted); + color: var(--text-primary); + cursor: pointer; +} +.rubrics-media-status { + padding: 18px; + border-radius: var(--radius-md); + background: var(--surface-muted); + color: var(--text-secondary); + text-align: center; +} +.rubrics-media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); + gap: 10px; + flex: 1 1 auto; + align-content: start; + min-height: 0; + max-height: none; + overflow-y: auto; + overflow-x: hidden; + padding: 2px; +} +.rubrics-media-item { + display: grid; + gap: 7px; + min-width: 0; + padding: 8px; + border: 0; + border-radius: var(--radius-md); + background: var(--surface-muted); + color: var(--text-primary); + text-align: left; + cursor: pointer; + box-shadow: inset 0 0 0 1px var(--border-subtle); + transition-property: background-color, transform, box-shadow; + transition-duration: 150ms; +} +.rubrics-media-item:hover { + background: var(--surface-hover); +} +.rubrics-media-item:active { + transform: scale(0.96); +} +.rubrics-media-thumb { + display: grid; + place-items: center; + aspect-ratio: 1 / 1; + overflow: hidden; + border-radius: var(--radius-sm); + background: var(--surface-card); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); +} +.rubrics-media-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} +.rubrics-media-thumb i { + color: var(--amber-600); + font-size: 28px; +} +.rubrics-media-name, +.rubrics-media-meta { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-media-name { + font-size: 12px; + font-weight: 700; + line-height: 1.2; +} +.rubrics-media-meta { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.2; +} +.rubrics-relation-picker { + width: min(66.666vw, 1200px); + height: min(66.666vh, 800px); + max-height: calc(100vh - 40px); +} +.rubrics-media-picker .modal-body, +.rubrics-relation-picker .modal-body { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.rubrics-relation-tools { + margin-bottom: 10px; +} +.rubrics-relation-status { + padding: 18px; + border-radius: var(--radius-md); + background: var(--surface-muted); + color: var(--text-secondary); + text-align: center; +} +.rubrics-relation-list { + display: grid; + gap: 8px; + flex: 1 1 auto; + align-content: start; + min-height: 0; + max-height: none; + overflow-y: auto; + overflow-x: hidden; + padding: 2px; +} +.rubrics-relation-item { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-width: 0; + padding: 10px; + border: 0; + border-radius: var(--radius-md); + background: var(--surface-muted); + color: var(--text-primary); + text-align: left; + cursor: pointer; + box-shadow: inset 0 0 0 1px var(--border-subtle); + transition-property: background-color, transform, box-shadow; + transition-duration: 150ms; +} +.rubrics-relation-item:hover { + background: var(--surface-hover); +} +.rubrics-relation-item:active { + transform: scale(0.96); +} +.rubrics-relation-id { + color: var(--text-secondary); + font-size: 12px; + font-weight: 800; + font-variant-numeric: tabular-nums; +} +.rubrics-relation-main { + display: grid; + gap: 2px; + min-width: 0; +} +.rubrics-relation-main b, +.rubrics-relation-main small { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-relation-main b { + font-size: 13px; + line-height: 1.2; +} +.rubrics-relation-main small { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.2; +} +.rubrics-plugin-ok, +.rubrics-plugin-issues span, +.rubrics-plugin-note { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 9px 10px; + border-radius: var(--radius-md); + font-size: 12px; + line-height: 1.4; +} +.rubrics-plugin-ok { + background: var(--green-100); + color: var(--green-700); +} +.rubrics-plugin-issues { + display: grid; + gap: 6px; +} +.rubrics-plugin-notes { + display: grid; + gap: 6px; +} +.rubrics-plugin-notes span { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + border-radius: var(--radius-md); + background: var(--blue-100); + color: var(--blue-700); + font-size: 12px; + line-height: 1.4; +} +.rubrics-plugin-issues span { + background: var(--amber-100); + color: var(--amber-700); +} +.rubrics-plugin-note { + background: var(--surface-muted); + color: var(--text-secondary); +} +.rubrics-plugin-files { + display: grid; + gap: 6px; + min-width: 0; +} +.rubrics-plugin-files b { + font-size: 12px; + line-height: 1.2; +} +.rubrics-plugin-files code { + overflow: hidden; + min-width: 0; + padding: 5px 7px; + border-radius: var(--radius-sm); + background: var(--surface-muted); + color: var(--text-primary); + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-plugin-render { + display: grid; + gap: 10px; + min-width: 0; + padding: 12px; + border-radius: var(--radius-lg); + background: var(--surface-muted); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-plugin-render.is-ok { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--green-500) 32%, transparent); +} +.rubrics-plugin-render.is-warning { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--amber-500) 35%, transparent); +} +.rubrics-plugin-render.is-error { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--red-500) 32%, transparent); +} +.rubrics-plugin-render-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.rubrics-plugin-render-head b { + font-size: 13px; + line-height: 1.25; +} +.rubrics-plugin-render p { + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; +} +.rubrics-plugin-render-frame { + min-width: 0; + max-height: 360px; + overflow: auto; + padding: 12px; + border-radius: var(--radius-md); + background: var(--surface-card); + box-shadow: var(--shadow-sm); +} +.rubrics-plugin-render-frame table { + max-width: 100%; +} +.rubrics-plugin-render-frame input, +.rubrics-plugin-render-frame select, +.rubrics-plugin-render-frame textarea { + max-width: 100%; +} +.rubrics-plugin-render-frame .legacy-ckeditor-preview { + display: grid; + gap: 6px; +} +.rubrics-plugin-render-frame .legacy-ckeditor-preview::before { + content: "CKEditor legacy preview: " attr(data-toolbar); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.3; +} +.rubrics-plugin-render-frame .legacy-ckeditor-preview textarea { + display: block; + width: 100%; + padding: 10px 12px; + border: 0; + border-radius: var(--radius-md); + background: var(--surface-muted); + color: var(--text-primary); + font: inherit; + line-height: 1.45; + resize: vertical; + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-fields-body { + overflow-x: hidden; + background: var(--background-muted); +} +.rubrics-fields-panel { + box-shadow: none; +} +.rubrics-drawer-panel[hidden], +.rubrics-fields-panel[hidden], +.rubrics-templates-panel[hidden], +.rubrics-edit-panel[hidden], +.rubrics-extra-template-panel[hidden], +.rubrics-group-panel[hidden] { + box-shadow: none; +} +.rubrics-builder-header-actions { + flex-wrap: nowrap; +} +.rubrics-builder { + display: grid; + grid-template-columns: minmax(210px, 240px) minmax(520px, 1fr) minmax(290px, 330px); + gap: 14px; + align-items: start; + min-width: 0; +} +.rubrics-builder-panel { + min-width: 0; + border: 0; + background: transparent; + box-shadow: none; +} +.rubrics-builder-workspace { + --builder-workspace-bg: color-mix(in srgb, var(--blue-100) 22%, var(--background-muted)); + border: 1px solid color-mix(in srgb, var(--blue-500) 12%, var(--border-default)); + border-radius: var(--radius-lg); + background: var(--builder-workspace-bg); + box-shadow: none; + overflow: hidden; +} +.rubrics-builder-workspace > .rubrics-builder-panel-head { + background: var(--builder-workspace-bg); +} +.rubrics-builder-library, +.rubrics-builder-inspector { + position: sticky; + top: 0; + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--surface-card); + overflow: hidden; +} +.rubrics-builder-panel-head { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 64px; + padding: 12px 14px; + border-bottom: 1px solid var(--border-subtle); +} +.rubrics-builder-panel-head h2, +.rubrics-builder-panel-head p { + margin: 0; + letter-spacing: 0; +} +.rubrics-builder-panel-head h2 { + font-size: 14px; + line-height: 1.25; + font-weight: 700; + text-wrap: balance; +} +.rubrics-builder-panel-head p { + margin-top: 2px; + color: var(--text-secondary); + font-size: 11.5px; + line-height: 1.3; + text-wrap: pretty; +} +.rubrics-builder-search { + margin: 12px 12px 8px; +} +.rubrics-builder-field-set { + display: grid; + grid-template-columns: minmax(0, 1fr) 40px; + gap: 8px; + padding: 12px 12px 0; +} +.rubrics-builder-field-set .select { + min-width: 0; +} +.rubrics-builder-field-set-transfer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-column: 1 / -1; + gap: 8px; +} +.rubrics-builder-field-set-transfer .btn { + justify-content: center; + min-width: 0; +} +.rubrics-builder-field-set-mode, +.rubrics-field-set-import-mode { + display: grid; + grid-column: 1 / -1; + gap: 6px; +} +.rubrics-builder-field-set-mode > span, +.rubrics-field-set-import-mode > span { + color: var(--text-secondary); + font-size: 11.5px; + font-weight: 600; +} +.rubrics-builder-field-set-mode .segmented button, +.rubrics-field-set-import-mode .segmented button { + padding-inline: 9px; +} +.rubrics-builder-field-set-mode .segmented { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; +} +.rubrics-builder-field-set-mode .segmented button { + justify-content: center; + min-width: 0; +} +.rubrics-field-set-import-mode { + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 12px; +} +.rubrics-field-set-import-mode .segmented { + justify-self: end; +} +.rubrics-builder-field-set-links { + display: grid; + grid-column: 1 / -1; + gap: 6px; + margin-top: 2px; + padding-top: 10px; + border-top: 1px solid var(--border-subtle); +} +.rubrics-builder-field-set-links[hidden] { + display: none; +} +.rubrics-field-set-links-head { + display: flex; + align-items: center; + gap: 7px; + color: var(--text-secondary); + font-size: 11.5px; +} +.rubrics-field-set-links-head > i { + color: var(--blue-600); + font-size: 16px; +} +.rubrics-field-set-links-head .badge { + margin-left: auto; +} +.rubrics-builder-field-set-links article { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 7px; + min-width: 0; + padding: 8px; + border-radius: var(--radius-md); + background: var(--surface-card); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-builder-field-set-links article > span:first-child { + display: grid; + min-width: 0; + gap: 2px; +} +.rubrics-builder-field-set-links article b { + overflow: hidden; + font-size: 11.5px; + text-overflow: ellipsis; + white-space: nowrap; +} +.rubrics-builder-field-set-links article code { + color: var(--text-muted); + font-size: 10px; +} +.rubrics-field-set-import-mode { + margin: 14px 0 0; +} +.rubrics-builder-field-set > p { + grid-column: 1 / -1; + margin: 0; + color: var(--text-secondary); + font-size: 11.5px; + line-height: 1.4; +} +.rubrics-field-set-modal { + width: min(680px, calc(100vw - 32px)); + max-width: 680px; +} +.rubrics-field-set-dropzone { + display: flex; + align-items: center; + gap: 14px; + min-height: 106px; + padding: 18px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-subtle); + color: var(--text-primary); + cursor: pointer; + transition: border-color 0.16s ease, background-color 0.16s ease; +} +.rubrics-field-set-dropzone:hover, +.rubrics-field-set-dropzone.is-dragover { + border-color: var(--blue-500); + background: var(--blue-50); +} +.rubrics-field-set-dropzone > i { + flex: 0 0 auto; + color: var(--blue-600); + font-size: 32px; +} +.rubrics-field-set-dropzone span, +.rubrics-field-set-import-state span, +.rubrics-field-set-preview-head > span:last-child { + display: grid; + gap: 3px; + min-width: 0; +} +.rubrics-field-set-dropzone small, +.rubrics-field-set-import-state small, +.rubrics-field-set-preview-head small { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; +} +.rubrics-field-set-import-state { + display: flex; + align-items: center; + gap: 12px; + margin-top: 14px; + padding: 13px 14px; + border-radius: var(--radius-md); + background: var(--bg-subtle); +} +.rubrics-field-set-import-state[hidden] { + display: none; +} +.rubrics-field-set-import-state > i { + color: var(--blue-600); + font-size: 21px; +} +.rubrics-field-set-import-state.is-loading > i { + animation: spin 1s linear infinite; +} +.rubrics-field-set-preview-head { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; +} +.rubrics-field-set-preview-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin: 14px 0; +} +.rubrics-field-set-preview-grid > span { + display: grid; + gap: 3px; + min-width: 0; + padding: 12px; + border-radius: var(--radius-md); + background: var(--bg-subtle); +} +.rubrics-field-set-preview-grid b { + font-size: 20px; + font-variant-numeric: tabular-nums; +} +.rubrics-field-set-preview-grid small { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.3; +} +.rubrics-field-set-preview-grid .is-danger b { + color: var(--red-600); +} +@media (max-width: 640px) { + .rubrics-field-set-preview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +.rubrics-builder-search > i { + position: absolute; + top: 50%; + left: 11px; + z-index: 1; + color: var(--text-tertiary); + transform: translateY(-50%); + pointer-events: none; +} +.rubrics-builder-search .input { + padding-left: 34px; +} +.rubrics-builder-palette { + display: grid; + gap: 6px; + max-height: calc(100vh - 290px); + min-height: 150px; + overflow-y: auto; + padding: 4px 8px 10px; + scrollbar-width: thin; +} +.rubrics-builder-palette-empty { + display: grid; + justify-items: center; + gap: 5px; + padding: 28px 12px; + color: var(--text-secondary); + text-align: center; +} +.rubrics-builder-palette-empty i { + color: var(--green-600); + font-size: 24px; +} +.rubrics-builder-palette-empty b { + color: var(--text-primary); + font-size: 12.5px; +} +.rubrics-builder-palette-empty span { + font-size: 11.5px; + line-height: 1.4; + text-wrap: pretty; +} +.rubrics-builder-palette-item { + display: grid; + grid-template-columns: 32px minmax(0, 1fr) 18px; + gap: 9px; + align-items: center; + min-height: 48px; + width: 100%; + padding: 7px 8px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--surface-card); + color: var(--text-primary); + font: inherit; + text-align: left; + cursor: grab; + transition-property: background-color, box-shadow, transform; + transition-duration: 150ms; +} +.rubrics-builder-palette-item:hover { + border-color: var(--blue-200); + background: var(--blue-50); + box-shadow: var(--shadow-xs); +} +.rubrics-builder-palette-item.is-selected { + background: var(--cyan-100); + box-shadow: inset 3px 0 0 var(--cyan-500); +} +.rubrics-builder-palette-item:active { + cursor: grabbing; + transform: scale(0.96); +} +.rubrics-builder-palette-item b, +.rubrics-builder-palette-item small { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-builder-palette-item b { + font-size: 12.5px; + line-height: 1.25; +} +.rubrics-builder-palette-item small { + margin-top: 2px; + color: var(--text-secondary); + font-size: 11px; +} +.rubrics-builder-palette-icon { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: var(--radius-md); + background: var(--surface-muted); + color: var(--cyan-600); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-builder-palette-grip { + color: var(--text-tertiary); + font-size: 17px; +} +.rubrics-builder-help { + display: flex; + align-items: flex-start; + gap: 7px; + margin: 0; + padding: 10px 12px 12px; + border-top: 1px solid var(--border-subtle); + color: var(--text-secondary); + font-size: 11.5px; + line-height: 1.4; + text-wrap: pretty; +} +.rubrics-builder-help i { + margin-top: 1px; + color: var(--cyan-600); + font-size: 15px; +} +.rubrics-builder-canvas { + display: grid; + gap: 12px; + padding: 12px; + background: var(--builder-workspace-bg); +} +.rubrics-builder-group { + min-width: 0; + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--surface-card); + box-shadow: var(--shadow-sm); + overflow: hidden; +} +.rubrics-builder-group-head { + display: grid; + grid-template-columns: auto 34px minmax(0, 1fr) auto auto; + gap: 9px; + align-items: center; + min-height: 58px; + padding: 10px 12px; + border-bottom: 1px solid var(--border-subtle); +} +.rubrics-builder-group:not([data-group-row]) .rubrics-builder-group-head { + grid-template-columns: 34px minmax(0, 1fr) auto; +} +.rubrics-builder-group-head h3, +.rubrics-builder-group-head p { + margin: 0; +} +.rubrics-builder-group-head h3 { + font-size: 13.5px; + line-height: 1.25; + font-weight: 700; +} +.rubrics-builder-group-head p { + margin-top: 2px; + color: var(--text-secondary); + font-size: 11.5px; + line-height: 1.3; +} +.rubrics-builder-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 9px; + min-height: 74px; + padding: 10px; + background: var(--background-muted); +} +.rubrics-builder-field { + position: relative; + display: grid; + grid-template-rows: auto auto minmax(34px, auto) 30px; + gap: 8px; + min-width: 0; + min-height: 126px; + padding: 10px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); + box-shadow: var(--shadow-xs); + cursor: pointer; + transition-property: border-color, box-shadow, transform, opacity; + transition-duration: 150ms; +} +.rubrics-builder-field.is-full { + grid-column: span 12; +} +.rubrics-builder-field.is-half { + grid-column: span 6; +} +.rubrics-builder-field.is-third { + grid-column: span 4; +} +.rubrics-builder-field.is-quarter { + grid-column: span 3; +} +.rubrics-builder-field:hover { + border-color: var(--blue-200); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--blue-500) 7%, transparent), var(--shadow-sm); +} +.rubrics-builder-field.is-selected { + border-color: var(--blue-500); + box-shadow: inset 0 0 0 1px var(--blue-500), 0 0 0 3px color-mix(in srgb, var(--blue-500) 11%, transparent), var(--shadow-sm); +} +.rubrics-builder-field.is-dragging { + opacity: 0.5; + transform: scale(0.98); +} +.rubrics-builder-field-top, +.rubrics-builder-field-main, +.rubrics-builder-field-preview { + min-width: 0; +} +.rubrics-builder-field-top { + display: flex; + align-items: center; + gap: 6px; +} +.rubrics-builder-field-handle { + display: grid; + place-items: center; + width: 28px; + height: 28px; + margin: -5px 0 -5px -5px; + border: 0; + background: transparent; + color: var(--text-tertiary); + cursor: grab; +} +.rubrics-builder-field-type { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + overflow: hidden; + color: var(--text-secondary); + font-size: 10.5px; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-builder-field-type i { + color: var(--blue-600); + font-size: 14px; +} +.rubrics-builder-field-size { + margin-left: auto; + color: var(--text-tertiary); + font-size: 10.5px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.rubrics-builder-field-condition { + display: grid; + place-items: center; + flex: 0 0 22px; + width: 22px; + height: 22px; + border-radius: var(--radius-sm); + background: var(--blue-100); + color: var(--blue-700); + font-size: 13px; +} +.rubrics-builder-field-main { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} +.rubrics-builder-field-main b, +.rubrics-builder-field-main code { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-builder-field-main b { + font-size: 12.5px; + line-height: 1.25; +} +.rubrics-builder-field-main b em { + margin-left: 3px; + color: var(--red-500); + font-style: normal; +} +.rubrics-builder-field-main code { + color: var(--text-tertiary); + font-size: 10.5px; +} +.rubrics-builder-field-preview { + display: flex; + align-items: stretch; + min-height: 34px; + border-radius: var(--radius-sm); + background: var(--surface-muted); + box-shadow: inset 0 0 0 1px var(--border-subtle); + overflow: hidden; +} +.rubrics-builder-field-preview i, +.rubrics-builder-field-preview span { + display: flex; + align-items: center; + min-width: 0; + padding: 7px 9px; + color: var(--text-tertiary); + font-size: 11px; + font-style: normal; +} +.rubrics-builder-field-preview i { + flex: 1 1 auto; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-builder-field-preview span { + flex: 0 0 auto; + background: var(--background-inset); + color: var(--text-secondary); + font-weight: 600; +} +.rubrics-builder-field-actions { + display: flex; + justify-content: flex-end; + gap: 2px; + opacity: 0; + transform: translateY(3px); + transition-property: opacity, transform; + transition-duration: 150ms; +} +.rubrics-builder-field:hover .rubrics-builder-field-actions, +.rubrics-builder-field.is-selected .rubrics-builder-field-actions, +.rubrics-builder-field:focus-within .rubrics-builder-field-actions { + opacity: 1; + transform: translateY(0); +} +.rubrics-builder-field-actions .btn { + width: 30px; + height: 30px; + min-width: 30px; + background: var(--surface-card); + box-shadow: var(--shadow-sm); +} +.rubrics-builder-field-actions .rubrics-action-return, +.rubrics-builder-field-actions .rubrics-action-edit, +.rubrics-actions .rubrics-action-edit { + color: var(--blue-600); +} +.rubrics-builder-field-actions .rubrics-action-return:hover, +.rubrics-builder-field-actions .rubrics-action-edit:hover, +.rubrics-actions .rubrics-action-edit:hover { + background: var(--blue-100); + color: var(--blue-700); +} +.rubrics-builder-field-actions .rubrics-action-danger:hover, +.rubrics-actions .rubrics-action-danger:hover { + background: var(--red-100); + color: var(--red-700); +} +.rubrics-builder-drop-empty { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 62px; + border: 1px dashed var(--border-default); + border-radius: var(--radius-md); + color: var(--text-tertiary); + font-size: 12px; +} +.rubrics-builder-empty { + display: grid; + justify-items: center; + gap: 7px; + padding: 36px 20px; + text-align: center; +} +.rubrics-builder-empty[hidden], +.rubrics-builder-form[hidden] { + display: none; +} +.rubrics-builder-empty b { + margin-top: 4px; + font-size: 13px; +} +.rubrics-builder-empty p { + max-width: 230px; + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; + text-wrap: pretty; +} +.rubrics-builder-form { + display: grid; + gap: 14px; + max-height: calc(100vh - 230px); + overflow-y: auto; + padding: 12px; + scrollbar-width: thin; +} +.rubrics-builder-type-meta { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + border-radius: var(--radius-md); + background: var(--background-surface); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-builder-type-meta > span:last-child { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 8px; + min-width: 0; + flex: 1 1 auto; +} +.rubrics-builder-type-meta small { + grid-column: 1 / -1; + color: var(--text-muted); + font-size: 11px; +} +.rubrics-builder-type-meta b { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.rubrics-builder-type-meta code { + align-self: center; + color: var(--cyan-700); + font-size: 11px; +} +.rubrics-builder-fieldset { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.rubrics-builder-fieldset legend { + margin-bottom: 6px; + color: var(--text-primary); + font-size: 12px; + font-weight: 600; +} +.rubrics-builder-widths { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 5px; +} +.rubrics-builder-widths input { + position: absolute; + opacity: 0; + pointer-events: none; +} +.rubrics-builder-widths span { + display: grid; + justify-items: center; + gap: 4px; + min-height: 48px; + padding: 7px 3px; + border-radius: var(--radius-md); + background: var(--surface-muted); + color: var(--text-secondary); + font-size: 10.5px; + font-weight: 700; + font-variant-numeric: tabular-nums; + cursor: pointer; + box-shadow: inset 0 0 0 1px var(--border-subtle); + transition-property: background-color, color, box-shadow, transform; + transition-duration: 150ms; +} +.rubrics-builder-widths span:active { + transform: scale(0.96); +} +.rubrics-builder-widths input:checked + span { + background: var(--blue-100); + color: var(--blue-700); + box-shadow: inset 0 0 0 2px var(--blue-500); +} +.rubrics-builder-widths input:focus-visible + span { + outline: 2px solid var(--blue-500); + outline-offset: 2px; +} +.rubrics-builder-affixes { + gap: 8px; +} +.rubrics-builder-switches { + display: grid; + gap: 7px; +} +.rubrics-builder-switches > label { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 48px; + padding: 8px 10px; + border-radius: var(--radius-md); + background: var(--surface-muted); + cursor: pointer; + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-builder-switches b, +.rubrics-builder-switches small { + display: block; +} +.rubrics-builder-switches b { + font-size: 12px; +} +.rubrics-builder-switches small { + margin-top: 2px; + color: var(--text-secondary); + font-size: 10.5px; +} +.rubrics-builder-switches input { + position: absolute; + opacity: 0; +} +.rubrics-builder-switches .switch-track { + position: relative; + width: 36px; + height: 20px; + border-radius: 10px; + background: var(--gray-300); + transition-property: background-color; + transition-duration: 150ms; +} +.rubrics-builder-switches .switch-track::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + box-shadow: var(--shadow-xs); + transition-property: transform; + transition-duration: 150ms; +} +.rubrics-builder-switches input:checked + .switch-track { + background: var(--green-500); +} +.rubrics-builder-switches input:checked + .switch-track::after { + transform: translateX(16px); +} +.rubrics-builder-type-settings { + min-width: 0; + padding: 11px; + border-radius: var(--radius-md); + background: var(--background-inset); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-builder-settings-head, +.rubrics-builder-settings-loading { + display: flex; + align-items: center; + gap: 7px; + margin-bottom: 10px; + color: var(--text-secondary); + font-size: 11.5px; +} +.rubrics-builder-settings-head i { + color: var(--cyan-600); + font-size: 15px; +} +.rubrics-builder-settings-loading i { + animation: spin 0.7s linear infinite; +} +.rubrics-builder-type-settings .form-grid { + gap: 9px; +} +.rubrics-builder-type-settings .col-6, +.rubrics-builder-type-settings .col-12 { + grid-column: span 12; +} +.rubrics-builder-condition { + overflow: hidden; + border-radius: var(--radius-md); + background: var(--background-inset); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-builder-condition-head { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + gap: 9px; + align-items: center; + min-height: 56px; + padding: 9px 10px; +} +.rubrics-builder-condition-head > span:nth-child(2) { + min-width: 0; +} +.rubrics-builder-condition-head b, +.rubrics-builder-condition-head small, +.rubrics-builder-condition-required b, +.rubrics-builder-condition-required small, +.rubrics-builder-condition-values-head b, +.rubrics-builder-condition-values-head small { + display: block; +} +.rubrics-builder-condition-head b, +.rubrics-builder-condition-required b, +.rubrics-builder-condition-values-head b { + font-size: 12px; +} +.rubrics-builder-condition-head small, +.rubrics-builder-condition-required small, +.rubrics-builder-condition-values-head small { + margin-top: 2px; + color: var(--text-secondary); + font-size: 10.5px; +} +.rubrics-builder-condition-head .switch { + min-height: 40px; + padding: 10px 0 10px 10px; +} +.rubrics-builder-condition-body { + display: grid; + gap: 10px; + padding: 11px; + box-shadow: inset 0 1px 0 var(--border-subtle); +} +.rubrics-builder-condition-body[hidden] { + display: none; +} +.rubrics-builder-condition-body .form-grid { + gap: 8px; +} +.rubrics-builder-condition-required, +.rubrics-builder-condition-values-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 48px; + padding: 8px 10px; + border-radius: var(--radius-md); + background: var(--surface-card); + cursor: pointer; + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-builder-condition-required input, +.rubrics-builder-condition-values-head > input { + position: absolute; + opacity: 0; +} +.rubrics-builder-condition-required .switch-track, +.rubrics-builder-condition-values-head .switch-track { + position: relative; + width: 36px; + height: 20px; + border-radius: 10px; + background: var(--gray-300); + transition-property: background-color; + transition-duration: 150ms; +} +.rubrics-builder-condition-required .switch-track::after, +.rubrics-builder-condition-values-head .switch-track::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + box-shadow: var(--shadow-xs); + transition-property: transform; + transition-duration: 150ms; +} +.rubrics-builder-condition-required input:checked + .switch-track, +.rubrics-builder-condition-values-head input:checked + .switch-track { + background: var(--green-500); +} +.rubrics-builder-condition-required input:checked + .switch-track::after, +.rubrics-builder-condition-values-head input:checked + .switch-track::after { + transform: translateX(16px); +} +.rubrics-builder-condition-values { + display: grid; + gap: 7px; +} +.rubrics-builder-condition-action { + display: grid; + gap: 8px; + padding: 9px; + border-radius: var(--radius-md); + background: var(--surface-card); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-builder-condition-action[hidden], +.rubrics-builder-condition-action-value[hidden], +.rubrics-builder-condition-action-value [hidden] { + display: none; +} +.rubrics-builder-condition-action-value small { + color: var(--text-secondary); + font-size: 10.5px; + line-height: 1.4; +} +.rubrics-builder-condition-values[hidden], +.rubrics-builder-condition-options[hidden] { + display: none; +} +.rubrics-builder-condition-options { + display: grid; + gap: 5px; + padding: 7px; + border-radius: var(--radius-md); + background: var(--surface-card); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-builder-condition-options label { + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto; + gap: 7px; + align-items: center; + min-height: 32px; + padding: 4px 6px; + border-radius: var(--radius-sm); + color: var(--text-primary); + cursor: pointer; +} +.rubrics-builder-condition-options label:hover { + background: var(--background-muted); +} +.rubrics-builder-condition-options input { + margin: 0; + accent-color: var(--blue-600); +} +.rubrics-builder-condition-options span { + min-width: 0; + font-size: 12px; +} +.rubrics-builder-condition-options small { + color: var(--text-tertiary); + font-family: var(--font-mono); + font-size: 10px; +} +.rubrics-builder-condition-rows { + display: grid; + gap: 8px; +} +.rubrics-builder-condition-group { + display: grid; + min-width: 0; + overflow: hidden; + border-radius: var(--radius-md); + background: var(--background-muted); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-builder-condition-group:not(.is-root) { + margin-left: 8px; + background: var(--background-inset); +} +.rubrics-builder-condition-group > header { + display: grid; + grid-template-columns: 4px minmax(0, 1fr) auto; + gap: 7px; + align-items: center; + min-width: 0; + padding: 8px; +} +.rubrics-builder-condition-bracket { + width: 4px; + height: 30px; + border-radius: 3px; + background: var(--blue-500); +} +.rubrics-builder-condition-group:not(.is-root) .rubrics-builder-condition-bracket { + background: var(--cyan-500); +} +.rubrics-builder-condition-group-copy { + display: grid; + gap: 1px; + min-width: 0; +} +.rubrics-builder-condition-group-copy b { + overflow: hidden; + font-size: 10.5px; + text-overflow: ellipsis; + white-space: nowrap; +} +.rubrics-builder-condition-group-copy small { + overflow: hidden; + color: var(--text-muted); + font-size: 9.5px; + text-overflow: ellipsis; + white-space: nowrap; +} +.rubrics-builder-condition-group > header > .select { + grid-column: 2; + min-width: 0; + height: 34px; + padding-block: 5px; + font-size: 10.5px; +} +.rubrics-builder-condition-group-tools { + display: flex; + grid-column: 3; + grid-row: 1 / span 2; + gap: 3px; + justify-content: flex-end; + min-width: max-content; +} +.rubrics-builder-condition-group-tools .btn { + width: 32px; + height: 32px; +} +.rubrics-builder-condition-items { + display: grid; + gap: 8px; + min-width: 0; + padding: 0 8px 8px; +} +.rubrics-builder-condition-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 40px; + gap: 7px; + min-width: 0; + padding: 8px; + border-radius: var(--radius-md); + background: var(--surface-card); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-builder-condition-row [data-builder-condition-field] { + grid-column: 1; + grid-row: 1; +} +.rubrics-builder-condition-row [data-builder-condition-operator] { + grid-column: 1 / -1; + grid-row: 2; +} +.rubrics-builder-condition-row [data-builder-condition-value] { + grid-column: 1 / -1; + grid-row: 3; +} +.rubrics-builder-condition-row [data-builder-condition-remove] { + grid-column: 2; + grid-row: 1; + width: 40px; + height: 40px; +} +.rubrics-builder-condition-complex { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px; + border-radius: var(--radius-md); + background: var(--blue-100); + color: var(--blue-700); + font-size: 11px; + line-height: 1.45; +} +.rubrics-group-condition .rubrics-builder-condition-row { + grid-template-columns: minmax(170px, 1fr) minmax(145px, 0.7fr) minmax(170px, 1fr) 40px; +} +.rubrics-group-condition .rubrics-builder-condition-row [data-builder-condition-field], +.rubrics-group-condition .rubrics-builder-condition-row [data-builder-condition-operator], +.rubrics-group-condition .rubrics-builder-condition-row [data-builder-condition-value], +.rubrics-group-condition .rubrics-builder-condition-row [data-builder-condition-remove] { + grid-row: 1; +} +.rubrics-group-condition .rubrics-builder-condition-row [data-builder-condition-field] { + grid-column: 1; +} +.rubrics-group-condition .rubrics-builder-condition-row [data-builder-condition-operator] { + grid-column: 2; +} +.rubrics-group-condition .rubrics-builder-condition-row [data-builder-condition-value] { + grid-column: 3; +} +.rubrics-group-condition .rubrics-builder-condition-row [data-builder-condition-remove] { + grid-column: 4; +} +@media (max-width: 760px) { + .rubrics-group-condition .rubrics-builder-condition-row { + grid-template-columns: minmax(0, 1fr) 40px; + } + .rubrics-group-condition .rubrics-builder-condition-row [data-builder-condition-field] { + grid-column: 1; + grid-row: 1; + } + .rubrics-group-condition .rubrics-builder-condition-row [data-builder-condition-operator] { + grid-column: 1 / -1; + grid-row: 2; + } + .rubrics-group-condition .rubrics-builder-condition-row [data-builder-condition-value] { + grid-column: 1 / -1; + grid-row: 3; + } + .rubrics-group-condition .rubrics-builder-condition-row [data-builder-condition-remove] { + grid-column: 2; + grid-row: 1; + } +} +.rubrics-builder-form > [hidden], +.rubrics-builder-save-state[hidden], +.rubrics-default-field[hidden] { + display: none; +} +.ax-settings-map { + display: grid; + gap: 8px; + min-width: 0; +} +.ax-settings-map-head, +.ax-settings-map-row { + display: grid; + grid-template-columns: minmax(72px, 0.36fr) minmax(120px, 0.64fr) 34px; + gap: 8px; + align-items: center; + min-width: 0; +} +.ax-settings-map-head { + padding: 0 2px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 650; +} +.ax-settings-map-row .input { + min-width: 0; +} +.ax-settings-map-storage { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.ax-settings-map-remove { + width: 34px; + height: 34px; + min-width: 34px; + color: var(--red-600); +} +.ax-settings-map-add { + justify-self: start; +} +.rubrics-builder-save-actions { + margin-left: auto; + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-3); + min-width: 0; +} +.rubrics-builder-save-state { + flex: 0 1 auto; +} +.rubrics-builder-save-label-short { + display: none; +} +.rubrics-builder-runtime { + display: grid; + grid-template-columns: 40px minmax(0, 1fr) auto auto; + gap: 12px; + align-items: center; + margin-bottom: 14px; + padding: 12px 14px; + border-radius: var(--radius-md); + background: var(--surface-card); + box-shadow: var(--shadow-card); +} +.rubrics-builder-runtime.is-enabled { + box-shadow: inset 3px 0 0 var(--green-500), var(--shadow-card); +} +.rubrics-builder-runtime-copy { + min-width: 0; +} +.rubrics-builder-runtime-copy b, +.rubrics-builder-runtime-copy p { + display: block; + margin: 0; +} +.rubrics-builder-runtime-copy b { + font-size: 13.5px; +} +.rubrics-builder-runtime-copy p { + margin-top: 2px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; +} +.rubrics-builder-runtime .switch { + min-width: 44px; + min-height: 40px; + justify-content: flex-end; +} +.rubrics-builder-impact { + max-width: 760px; +} +.rubrics-impact-heading { + min-width: 0; + flex: 1; +} +.rubrics-impact-heading h3, +.rubrics-impact-heading p { + margin: 0; +} +.rubrics-impact-heading p { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12.5px; +} +.rubrics-impact-mode { + display: flex; + align-items: center; + gap: 11px; + margin-bottom: 14px; + padding: 11px 12px; + border-radius: var(--radius-md); + background: var(--green-100); + color: var(--green-700); +} +.rubrics-impact-mode.is-disabled { + background: var(--amber-100); + color: var(--amber-700); +} +.rubrics-impact-mode > i { + font-size: 21px; +} +.rubrics-impact-mode b, +.rubrics-impact-mode small { + display: block; +} +.rubrics-impact-mode small { + margin-top: 2px; + color: var(--text-secondary); +} +.rubrics-impact-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} +.rubrics-impact-summary > span { + min-width: 0; + padding: 12px; + border-radius: var(--radius-md); + background: var(--background-inset); +} +.rubrics-impact-summary b, +.rubrics-impact-summary small { + display: block; +} +.rubrics-impact-summary b { + font-size: 20px; + font-variant-numeric: tabular-nums; +} +.rubrics-impact-summary small { + margin-top: 2px; + color: var(--text-secondary); + font-size: 11.5px; +} +.rubrics-impact-notice { + display: flex; + gap: 8px; + margin: 0 0 12px; + padding: 10px 11px; + border-radius: var(--radius-sm); + background: var(--blue-100); + color: var(--blue-700); + font-size: 12px; + line-height: 1.45; +} +.rubrics-impact-list { + display: grid; + gap: 7px; +} +.rubrics-impact-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(86px, auto) minmax(96px, auto); + gap: 12px; + align-items: center; + padding: 10px 12px; + border-radius: var(--radius-sm); + background: var(--surface-card); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-impact-row > span { + min-width: 0; +} +.rubrics-impact-row b, +.rubrics-impact-row small { + display: block; +} +.rubrics-impact-row b { + overflow: hidden; + font-size: 12.5px; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-impact-row small { + margin-top: 2px; + color: var(--text-secondary); + font-size: 10.5px; +} +.rubrics-templates-body { + overflow-x: hidden; +} +.rubrics-fields-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 300px); + gap: 16px; + align-items: start; + max-width: 100%; +} +.rubrics-template-layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + align-items: start; + max-width: 100%; +} +.rubrics-template-main, +.rubrics-template-tags { + min-width: 0; +} +.rubrics-template-main { + border-radius: var(--radius-lg); + background: var(--surface-card); + box-shadow: var(--shadow-card); + overflow: hidden; +} +.rubrics-template-tabs { + padding: 0 14px; +} +.rubrics-template-form { + min-width: 0; +} +.rubrics-template-fields { + display: grid; + gap: 14px; + padding: 14px; +} +.rubrics-template-fields .field { + min-width: 0; +} +.rubrics-og-help { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 0; +} +.rubrics-og-help .icon-tile { + flex: 0 0 auto; +} +.rubrics-og-help b { + display: block; + margin-bottom: 3px; +} +.rubrics-og-help p { + margin: 0; + color: var(--text-secondary); + line-height: 1.5; + text-wrap: pretty; +} +.rubrics-og-help code, +.rubrics-og-tags code { + color: var(--blue-700); + font-size: 12px; +} +.rubrics-og-tags { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} +.rubrics-og-tags > span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; + padding: 10px 11px; + border-radius: var(--radius-sm); + background: var(--surface-muted); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-og-tags small { + color: var(--text-secondary); + font-size: 11.5px; +} +@media (max-width: 900px) { + .rubrics-og-tags { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +.rubrics-code-access-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-column: 1 / -1; + gap: 10px; + min-width: 0; +} +.rubrics-rubric-code-layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + min-width: 0; +} +.rubrics-rubric-code-layout > * { + min-width: 0; +} +.rubrics-code-access-card { + min-width: 0; + color: var(--text-primary); + background: color-mix(in srgb, var(--blue-500) 7%, var(--surface-card)); + border-color: color-mix(in srgb, var(--blue-500) 23%, transparent); +} +.rubrics-code-access-card .alert-ic, +.rubrics-public-code-info .alert-ic { + flex: 0 0 auto; + color: var(--blue-600); +} +.rubrics-code-access-card > div, +.rubrics-public-code-info > div { + min-width: 0; +} +.rubrics-code-access-list { + display: grid; + gap: 5px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; +} +.rubrics-code-access-list span { + min-width: 0; + text-wrap: pretty; +} +.rubrics-code-access-card code, +.rubrics-code-compat code, +.rubrics-code-phase-hint code, +.rubrics-public-code-info code { + padding: 1px 4px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--blue-500) 9%, var(--surface-card)); + color: var(--blue-700); + font-size: 11px; + overflow-wrap: anywhere; +} +.rubrics-code-compat { + display: flex; + grid-column: 1 / -1; + align-items: flex-start; + gap: 10px; + min-width: 0; + padding: 10px 12px; + border-radius: var(--radius-md); + background: var(--surface-muted); + color: var(--text-secondary); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-code-compat > i { + flex: 0 0 auto; + margin-top: 1px; + color: var(--amber-600); + font-size: 17px; +} +.rubrics-code-compat > div { + display: grid; + gap: 3px; + min-width: 0; +} +.rubrics-code-compat b { + color: var(--text-primary); + font-size: 12px; +} +.rubrics-code-compat span, +.rubrics-public-code-info span { + font-size: 12px; + line-height: 1.45; + text-wrap: pretty; +} +.rubrics-code-phase-hint { + display: flex; + grid-column: 1 / -1; + align-items: flex-start; + gap: 6px; + margin-top: -8px; + line-height: 1.45; +} +.rubrics-code-phase-hint i { + flex: 0 0 auto; + margin-top: 1px; + color: var(--blue-600); + font-size: 14px; +} +.rubrics-public-code-info { + grid-column: 1 / -1; + color: var(--text-primary); + background: color-mix(in srgb, var(--cyan-500) 7%, var(--surface-card)); + border-color: color-mix(in srgb, var(--cyan-500) 23%, transparent); +} +.rubrics-code-field { + display: flex; + flex-direction: column; + min-width: 0; +} +@media (max-width: 900px) { + .rubrics-code-access-grid { + grid-template-columns: 1fr; + } +} +.rubrics-code-label { + justify-content: space-between; + gap: 12px; +} +.rubrics-code-tools { + display: inline-flex; + align-items: center; + gap: 6px; +} +.rubrics-template-tags-button { + color: var(--cyan-600); +} +.rubrics-code-tools [data-tooltip]::after { + top: calc(100% + 7px); + bottom: auto; + z-index: 320; + transform: translateX(-50%) translateY(-3px); +} +.rubrics-code-tools [data-tooltip]:hover::after, +.rubrics-code-tools [data-tooltip]:focus-visible::after { + transform: translateX(-50%) translateY(0); +} +.rubrics-lint-button { + color: var(--amber-600); +} +.rubrics-code-fullscreen-button { + color: var(--blue-600); +} +.rubrics-code-lint-result:empty { + display: none; +} +.rubrics-code-lint-result.is-ok { + color: var(--green-600); +} +.rubrics-code-lint-result.is-error { + color: var(--red-600); +} +.rubrics-code-field.is-fullscreen { + position: fixed; + inset: 16px; + z-index: 260; + margin: 0; + padding: 16px; + border-radius: var(--radius-lg); + background: var(--background-surface); + box-shadow: var(--shadow-modal); +} +.rubrics-code-field.is-fullscreen .rubrics-code-label { + flex: 0 0 auto; + margin-bottom: 10px; +} +.rubrics-code-field.is-fullscreen .CodeMirror { + flex: 1 1 auto; + min-height: 0; + height: auto !important; +} +.rubrics-code-field.is-fullscreen::before { + content: ""; + position: fixed; + inset: -16px; + z-index: -1; + background: rgba(15, 23, 42, 0.48); +} +.rubrics-template-fields .CodeMirror, +.rubrics-extra-template-panel .CodeMirror { + border-radius: var(--radius-md); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-template-tag-palette { + margin: 8px 0 12px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); + overflow: hidden; +} +.rubrics-template-tag-palette[hidden] { + display: none !important; +} +.rubrics-template-tag-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border-bottom: 1px solid var(--border-default); +} +.rubrics-template-tag-toolbar .input-wrap { + flex: 1; +} +.rubrics-template-tag-nav { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 8px; + border-bottom: 1px solid var(--border-default); +} +.rubrics-template-tag-nav button { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 28px; + padding: 3px 10px; + border: 1px solid var(--border-default); + border-radius: var(--radius-full); + background: var(--background-muted); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition-property: background-color, border-color, color, transform; + transition-duration: 150ms; +} +.rubrics-template-tag-nav button:hover, +.rubrics-template-tag-nav button.is-active { + border-color: var(--color-primary); + color: var(--color-primary); +} +.rubrics-template-tag-nav button.is-active { + background: var(--color-primary-soft); +} +.rubrics-template-tag-nav button:active, +.rubrics-template-tag:active { + transform: scale(0.96); +} +.rubrics-template-tag-nav b { + color: var(--text-muted); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} +.rubrics-template-tag-nav button.is-active b { + color: var(--color-primary); +} +.rubrics-template-tag-group { + display: none; + max-height: 260px; + padding: 8px; + overflow-y: auto; +} +.rubrics-template-tag-group.is-active, +.rubrics-template-tag-palette.is-searching .rubrics-template-tag-group { + display: block; +} +.rubrics-template-tag-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 6px; +} +.rubrics-template-tag { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + padding: 6px 8px; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: var(--background-surface); + color: var(--text-primary); + text-align: left; + cursor: pointer; + transition-property: background-color, border-color, color, transform; + transition-duration: 150ms; +} +.rubrics-template-tag:hover { + border-color: var(--color-primary); + background: var(--color-primary-soft); +} +.rubrics-template-tag code, +.rubrics-template-tag span { + overflow: hidden; + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; +} +.rubrics-template-tag code { + color: var(--color-primary); + font-size: 12px; +} +.rubrics-template-tag span { + color: var(--text-muted); + font-size: 11px; +} +.rubrics-template-tag-empty { + padding: 14px; + color: var(--text-muted); + font-size: 13px; + text-align: center; +} +.rubrics-template-tag-empty[hidden] { + display: none !important; +} +.rubrics-extra-list { + display: grid; + gap: 10px; + padding: 14px; +} +.rubrics-extra-item { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(140px, 180px) auto; + gap: 12px; + align-items: center; + padding: 12px; + border-radius: var(--radius-md); + background: var(--surface-muted); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-extra-item b, +.rubrics-extra-item small { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-extra-item small { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12px; +} +.rubrics-extra-meta { + display: grid; + gap: 3px; + color: var(--text-secondary); + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.rubrics-fields-list, +.rubrics-fields-side { + min-width: 0; +} +.rubrics-fields-list, +.rubrics-side-card { + border-radius: var(--radius-lg); + background: var(--surface-card); + box-shadow: var(--shadow-card); + overflow: hidden; +} +.rubrics-inner-head { + padding: 14px 16px; +} +.rubrics-field-groups { + display: grid; + gap: 12px; + padding: 14px; +} +.rubrics-field-group { + border-radius: var(--radius-md); + background: var(--surface-muted); + box-shadow: inset 0 0 0 1px var(--border-subtle); + overflow: hidden; +} +.rubrics-field-group-head { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 12px; + border-bottom: 1px solid var(--border-subtle); +} +.rubrics-field-group-head h4 { + margin: 0 0 3px; + font-size: 14px; + line-height: 1.25; + font-weight: 700; +} +.rubrics-field-group-head p { + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.35; +} +.rubrics-field-list { + display: grid; + gap: 8px; + min-height: 54px; + padding: 10px; +} +.rubrics-field-item { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) minmax(140px, 200px) auto; + gap: 10px; + align-items: center; + padding: 9px; + border-radius: var(--radius-md); + background: var(--surface-card); + box-shadow: var(--shadow-sm); +} +.rubrics-field-item code { + overflow: hidden; + min-width: 0; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--text-primary); +} +.rubrics-field-main { + display: grid; + gap: 2px; + min-width: 0; +} +.rubrics-field-main b, +.rubrics-field-main small { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-field-main small { + color: var(--text-secondary); + font-size: 12px; +} +.rubrics-groups-list { + display: grid; + gap: 8px; + padding: 12px; +} +.rubrics-group-row { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 9px; + border-radius: var(--radius-md); + background: var(--surface-muted); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} +.rubrics-group-row b, +.rubrics-group-row small { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.rubrics-group-row small { + color: var(--text-secondary); + font-size: 12px; +} +.rubrics-drag-handle, +.rubrics-group-drag, +.rubrics-field-drag { + cursor: grab; + color: var(--text-tertiary); +} +.rubrics-drag-handle:active, +.rubrics-group-drag:active, +.rubrics-field-drag:active { + cursor: grabbing; +} +.rubrics-fields-panel [data-tooltip-left] { + position: relative; +} +.rubrics-fields-panel [data-tooltip-left]::after { + content: attr(data-tooltip-left); + position: absolute; + top: 50%; + right: calc(100% + 7px); + z-index: 140; + padding: 5px 9px; + border-radius: var(--radius-xs); + background: var(--gray-800); + color: #fff; + font-size: 11.5px; + font-weight: 500; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transform: translateX(3px) translateY(-50%); + transition-property: opacity, transform; + transition-duration: 120ms; +} +[data-theme="dark"] .rubrics-fields-panel [data-tooltip-left]::after { + background: var(--gray-100); + color: var(--gray-900); +} +.rubrics-fields-panel [data-tooltip-left]:hover::after, +.rubrics-fields-panel [data-tooltip-left]:focus-visible::after { + opacity: 1; + transform: translateX(0) translateY(-50%); +} +.rubrics-panel [data-tooltip]::after, +.rubrics-panel [data-tooltip-right]::after, +.rubrics-fields-panel [data-tooltip]::after, +.rubrics-fields-panel [data-tooltip-right]::after, +.rubrics-templates-panel [data-tooltip]::after, +.rubrics-templates-panel [data-tooltip-right]::after { + display: none; +} +.rubrics-panel [data-tooltip]:hover::after, +.rubrics-panel [data-tooltip]:focus-visible::after, +.rubrics-panel [data-tooltip-right]:hover::after, +.rubrics-panel [data-tooltip-right]:focus-visible::after, +.rubrics-fields-panel [data-tooltip]:hover::after, +.rubrics-fields-panel [data-tooltip]:focus-visible::after, +.rubrics-fields-panel [data-tooltip-right]:hover::after, +.rubrics-fields-panel [data-tooltip-right]:focus-visible::after, +.rubrics-templates-panel [data-tooltip]:hover::after, +.rubrics-templates-panel [data-tooltip]:focus-visible::after, +.rubrics-templates-panel [data-tooltip-right]:hover::after, +.rubrics-templates-panel [data-tooltip-right]:focus-visible::after { + display: block; +} +.field-hint.is-ok { + color: var(--green-600); +} +.field-hint.is-error { + color: var(--red-600); +} +@media (max-width: 1100px) { + .rubrics-builder { + grid-template-columns: minmax(210px, 240px) minmax(0, 1fr); + } + .rubrics-builder-inspector { + position: static; + grid-column: 1 / -1; + } + .rubrics-builder-form { + max-height: none; + } + .rubrics-fields-layout { + grid-template-columns: 1fr; + } + .rubrics-template-layout { + grid-template-columns: 1fr; + } + .rubrics-fields-panel, + .rubrics-templates-panel, + .rubrics-drawer-panel, + .rubrics-edit-panel, + .rubrics-extra-template-panel, + .rubrics-group-panel { + width: min(96vw, 980px); + } + #rubricDrawer { + width: 96vw; + max-width: 96vw; + } +} +@media (max-width: 760px) { + .rubrics-builder-runtime { + grid-template-columns: 40px minmax(0, 1fr) auto; + } + .rubrics-builder-runtime > .badge { + grid-column: 2; + justify-self: start; + } + .rubrics-builder-runtime > .switch { + grid-column: 3; + grid-row: 1 / span 2; + } + .rubrics-impact-summary { + grid-template-columns: 1fr; + } + .rubrics-impact-row { + grid-template-columns: minmax(0, 1fr) auto; + } + .rubrics-impact-row > span:last-child { + grid-column: 1 / -1; + } + .rubrics-fields-panel .drawer-footer > [data-close-drawer] { + display: none; + } + .rubrics-builder-save-actions { + width: 100%; + margin-left: 0; + justify-content: space-between; + gap: var(--space-2); + } + .rubrics-builder-save-label { + display: none; + } + .rubrics-builder-save-label-short { + display: inline; + } + .rubrics-builder { + grid-template-columns: minmax(0, 1fr); + } + .rubrics-builder-library, + .rubrics-builder-inspector { + position: static; + } + .rubrics-builder-inspector { + grid-column: auto; + } + .rubrics-builder-field.is-half, + .rubrics-builder-field.is-third, + .rubrics-builder-field.is-quarter { + grid-column: span 12; + } + .rubrics-builder-header-actions .btn:not(.btn-icon) { + width: 40px; + min-width: 40px; + padding: 0; + overflow: hidden; + color: transparent; + } + .rubrics-builder-header-actions .btn:not(.btn-icon) i { + color: var(--text-on-primary, #fff); + } + .rubrics-media-picker, + .rubrics-relation-picker { + width: 100%; + height: 94vh; + max-height: 94vh; + } + .rubrics-filter { + align-items: stretch; + flex-direction: column; + } + .rubrics-filter .input-wrap { + flex: 0 0 auto; + min-width: 0; + max-width: none; + width: 100%; + } + .rubrics-filter > .select { + flex: 0 0 auto; + width: 100%; + } + .rubrics-filter > .btn { + justify-content: center; + width: 100%; + } + .rubrics-field-item { + grid-template-columns: 34px minmax(0, 1fr) auto; + } + .rubrics-type-tools { + grid-template-columns: 1fr; + } + .rubrics-type-col-name { + width: 24%; + } + .rubrics-type-col-description { + width: 40%; + } + .rubrics-type-col-usage, + .rubrics-type-col-status, + .rubrics-type-col-enabled { + width: 12%; + } + .rubrics-plugin-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .rubrics-admin-editor-controls { + grid-template-columns: 1fr; + } + .rubrics-field-item code { + grid-column: 2 / -1; + } + .rubrics-extra-item { + grid-template-columns: minmax(0, 1fr) auto; + } + .rubrics-extra-meta { + grid-column: 1 / -1; + } +} +/* «Редактор типа»: заметка об отсутствии настроек */ +.rubrics-plugin-empty { + margin: 0; + color: var(--text-secondary); + font-size: 13px; +} +/* Легенда доступных тегов в шаблоне вывода поля */ +.rubrics-tag-legend { + margin-bottom: 14px; + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--background-muted); + overflow: hidden; +} +.rubrics-tag-legend-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 10px 14px; + border-bottom: 1px solid var(--border-subtle); +} +.rubrics-tag-legend-head i { + color: var(--cyan-600); + font-size: 16px; +} +.rubrics-tag-legend-head b { + font-size: 13px; +} +.rubrics-tag-legend-head small { + color: var(--text-muted); + font-size: 11.5px; +} +.rubrics-tag-legend-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 10px 14px; +} +.rubrics-tag-chip { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 100%; + padding: 5px 9px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--surface-card); + color: var(--text-primary); + cursor: pointer; + font-size: 12px; + transition: border-color 0.12s, background-color 0.12s; +} +.rubrics-tag-chip:hover { + border-color: var(--cyan-300); + background: color-mix(in srgb, var(--cyan-500) 10%, var(--surface-card)); +} +.rubrics-tag-chip code { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--cyan-700); + white-space: nowrap; +} +.rubrics-tag-chip span { + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; +} +/* Конструктор административного представления документов рубрики */ +.rubrics-action-admin-view { + color: var(--amber-600); +} +.rubrics-admin-view-panel { + width: 100vw; + max-width: 100vw; + overflow: hidden; + box-shadow: none; +} +.rubrics-admin-view-panel[hidden] { + box-shadow: none; +} +.rubrics-admin-view-body { + padding: 16px; + overflow-x: hidden; + background: var(--background-muted); +} +.rubrics-admin-view-modes { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + max-width: 1180px; + margin: 0 auto 14px; + padding: 5px; + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--surface-card); +} +.rubrics-admin-view-mode { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + min-height: 58px; + padding: 9px 12px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-secondary); + text-align: left; + cursor: pointer; + transition-property: background-color, color, box-shadow, transform; + transition-duration: 150ms; +} +.rubrics-admin-view-mode:hover { + background: var(--background-muted); + color: var(--text-primary); +} +.rubrics-admin-view-mode:active { + transform: scale(0.96); +} +.rubrics-admin-view-mode[aria-pressed="true"] { + background: color-mix(in srgb, var(--blue-500) 11%, var(--surface-card)); + color: var(--blue-700); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--blue-500) 30%, transparent); +} +.rubrics-admin-view-mode > i { + flex: 0 0 auto; + font-size: 21px; +} +.rubrics-admin-view-mode span { + min-width: 0; +} +.rubrics-admin-view-mode b, +.rubrics-admin-view-mode small { + display: block; + letter-spacing: 0; +} +.rubrics-admin-view-mode b { + font-size: 13px; + line-height: 1.25; +} +.rubrics-admin-view-mode small { + margin-top: 2px; + color: var(--text-muted); + font-size: 11px; + line-height: 1.25; +} +.rubrics-admin-view-builder { + display: grid; + grid-template-columns: minmax(220px, 260px) minmax(520px, 1fr) minmax(260px, 310px); + gap: 14px; + align-items: start; + min-width: 0; +} +.rubrics-admin-view-column { + min-width: 0; + overflow: hidden; + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--surface-card); +} +.rubrics-admin-view-library, +.rubrics-admin-view-inspector { + position: sticky; + top: 0; +} +.rubrics-admin-view-workspace { + background: color-mix(in srgb, var(--blue-100) 20%, var(--background-muted)); + border-color: color-mix(in srgb, var(--blue-500) 18%, var(--border-default)); +} +.rubrics-admin-view-head { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 64px; + padding: 12px 14px; + border-bottom: 1px solid var(--border-subtle); +} +.rubrics-admin-view-head .icon-tile { + width: 34px; + height: 34px; + font-size: 17px; +} +.rubrics-admin-view-head h2, +.rubrics-admin-view-head p { + margin: 0; + letter-spacing: 0; +} +.rubrics-admin-view-head h2 { + font-size: 14px; + line-height: 1.3; +} +.rubrics-admin-view-head p { + margin-top: 2px; + color: var(--text-muted); + font-size: 11.5px; + line-height: 1.3; +} +.rubrics-admin-view-search { + margin: 12px; +} +.rubrics-admin-view-field-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: calc(100vh - 260px); + padding: 0 12px 12px; + overflow-y: auto; +} +.rubrics-admin-view-field { + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + gap: 9px; + align-items: center; + width: 100%; + min-height: 50px; + padding: 8px 10px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--surface-card); + color: var(--text-primary); + text-align: left; + cursor: pointer; + transition-property: border-color, background-color, transform; + transition-duration: 140ms; +} +.rubrics-admin-view-field:hover:not(:disabled) { + border-color: var(--cyan-300); + background: color-mix(in srgb, var(--cyan-500) 7%, var(--surface-card)); +} +.rubrics-admin-view-field:active:not(:disabled) { + transform: scale(0.96); +} +.rubrics-admin-view-field.is-added { + opacity: 0.55; + cursor: default; +} +.rubrics-admin-view-field-icon { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: var(--radius-md); + background: var(--cyan-100); + color: var(--cyan-700); +} +.rubrics-admin-view-field.is-added .rubrics-admin-view-field-icon { + background: var(--green-100); + color: var(--green-700); +} +.rubrics-admin-view-field b, +.rubrics-admin-view-field small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + letter-spacing: 0; +} +.rubrics-admin-view-field b { + font-size: 12.5px; +} +.rubrics-admin-view-field small { + margin-top: 2px; + color: var(--text-muted); + font-size: 10.5px; +} +.rubrics-admin-view-items { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px; +} +.rubrics-admin-view-item { + position: relative; + display: grid; + grid-template-columns: 34px 28px minmax(0, 1fr) auto auto; + gap: 8px; + align-items: center; + min-height: 58px; + padding: 8px 10px 8px 6px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--surface-card); + cursor: pointer; + transition-property: border-color, box-shadow, opacity, transform; + transition-duration: 140ms; +} +.rubrics-admin-view-item:hover { + border-color: var(--blue-300); +} +.rubrics-admin-view-item.is-selected { + border-color: var(--blue-500); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--blue-500) 13%, transparent); +} +.rubrics-admin-view-item.is-dragging { + opacity: 0.45; +} +.rubrics-admin-view-item.is-drop-before::before, +.rubrics-admin-view-item.is-drop-after::after { + content: ""; + position: absolute; + right: 4px; + left: 4px; + height: 2px; + border-radius: 2px; + background: var(--blue-500); +} +.rubrics-admin-view-item.is-drop-before::before { + top: -5px; +} +.rubrics-admin-view-item.is-drop-after::after { + bottom: -5px; +} +.rubrics-admin-view-item-grip { + display: grid; + place-items: center; + width: 34px; + height: 40px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-muted); + cursor: grab; +} +.rubrics-admin-view-item-grip:active { + cursor: grabbing; + transform: scale(0.96); +} +.rubrics-admin-view-item-order { + color: var(--text-muted); + font: 12px var(--font-mono); + font-variant-numeric: tabular-nums; +} +.rubrics-admin-view-item-main { + min-width: 0; +} +.rubrics-admin-view-item-main b, +.rubrics-admin-view-item-main small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + letter-spacing: 0; +} +.rubrics-admin-view-item-main b { + font-size: 13px; +} +.rubrics-admin-view-item-main small { + margin-top: 2px; + color: var(--text-muted); + font-size: 10.5px; +} +.rubrics-admin-view-item-actions { + display: flex; + align-items: center; + gap: 2px; +} +.rubrics-admin-view-inspector-empty, +.rubrics-admin-view-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 150px; + padding: 24px; + color: var(--text-muted); + text-align: center; +} +.rubrics-admin-view-inspector-empty > i, +.rubrics-admin-view-empty > i { + font-size: 25px; +} +.rubrics-admin-view-inspector-empty b, +.rubrics-admin-view-empty b { + color: var(--text-primary); + font-size: 13px; + letter-spacing: 0; +} +.rubrics-admin-view-inspector-empty p, +.rubrics-admin-view-empty span { + max-width: 280px; + margin: 0; + font-size: 11.5px; + line-height: 1.45; +} +.rubrics-admin-view-empty-large { + min-height: 230px; +} +.rubrics-admin-view-inspector-form { + display: flex; + flex-direction: column; + gap: 14px; + padding: 14px; +} +.rubrics-admin-view-inspector-form[hidden], +.rubrics-admin-view-custom[hidden] { + display: none; +} +.rubrics-admin-view-widths { + margin: 0; + padding: 0; + border: 0; +} +.rubrics-admin-view-widths legend { + margin-bottom: 7px; + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; +} +.rubrics-admin-view-widths { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 5px; +} +.rubrics-admin-view-widths legend { + grid-column: 1 / -1; +} +.rubrics-admin-view-widths input { + position: absolute; + opacity: 0; + pointer-events: none; +} +.rubrics-admin-view-widths span { + display: grid; + place-items: center; + min-height: 38px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--surface-card); + font-size: 11px; + cursor: pointer; +} +.rubrics-admin-view-widths input:checked + span { + border-color: var(--blue-500); + background: var(--blue-100); + color: var(--blue-700); + font-weight: 700; +} +.rubrics-admin-view-field-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding-top: 4px; + border-top: 1px solid var(--border-subtle); +} +.rubrics-admin-view-field-meta span, +.rubrics-admin-view-field-meta code { + display: inline-flex; + align-items: center; + gap: 5px; + max-width: 100%; + padding: 5px 7px; + border-radius: var(--radius-md); + background: var(--background-muted); + color: var(--text-secondary); + font-size: 10.5px; + overflow-wrap: anywhere; +} +.rubrics-admin-view-custom { + margin: 0 14px 14px; + padding: 14px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--surface-card); +} +.rubrics-admin-view-custom-head, +.rubrics-admin-view-preview-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +.rubrics-admin-view-custom-head b, +.rubrics-admin-view-custom-head small, +.rubrics-admin-view-preview-head b, +.rubrics-admin-view-preview-head small { + display: block; + letter-spacing: 0; +} +.rubrics-admin-view-custom-head b, +.rubrics-admin-view-preview-head b { + font-size: 13px; +} +.rubrics-admin-view-custom-head small, +.rubrics-admin-view-preview-head small { + margin-top: 2px; + color: var(--text-muted); + font-size: 11px; +} +.rubrics-admin-view-custom .textarea { + width: 100%; + min-height: 150px; + resize: vertical; +} +.rubrics-admin-view-tokens { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 9px; +} +.rubrics-admin-view-tokens button { + display: inline-flex; + gap: 6px; + align-items: center; + max-width: 100%; + min-height: 32px; + padding: 5px 8px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--background-muted); + color: var(--text-secondary); + cursor: pointer; +} +.rubrics-admin-view-tokens button:hover { + border-color: var(--cyan-300); + color: var(--text-primary); +} +.rubrics-admin-view-tokens code { + color: var(--cyan-700); + font-size: 10.5px; + white-space: nowrap; +} +.rubrics-admin-view-preview-wrap { + margin: 0 14px 14px; + padding: 14px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--surface-card); +} +.rubrics-admin-view-preview-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px; +} +.rubrics-admin-view-preview-card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--surface-card); +} +.rubrics-admin-view-preview-card > header { + display: flex; + justify-content: space-between; + gap: 10px; + min-height: 58px; + padding: 10px 12px; + border-bottom: 1px solid var(--border-subtle); + background: var(--background-muted); +} +.rubrics-admin-view-preview-card > header b, +.rubrics-admin-view-preview-card > header small { + display: block; + letter-spacing: 0; +} +.rubrics-admin-view-preview-card > header b { + font-size: 12px; +} +.rubrics-admin-view-preview-card > header small { + margin-top: 3px; + color: var(--text-muted); + font-size: 10.5px; +} +.rubrics-admin-view-preview-content { + min-width: 0; + padding: 10px 12px; + color: var(--text-secondary); + font-size: 11.5px; + line-height: 1.45; +} +.rubrics-admin-view-preview-content .rubrics-admin-view-preview-fields { + padding: 0; +} +.rubrics-admin-view-preview-fields { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 8px; + padding: 10px 12px; +} +.rubrics-admin-view-preview-fields > div { + grid-column: span 3; + min-width: 0; +} +.rubrics-admin-view-preview-fields > .is-small { + grid-column: span 2; +} +.rubrics-admin-view-preview-fields > .is-wide { + grid-column: span 6; +} +.rubrics-admin-view-preview-fields small { + display: block; + margin-bottom: 3px; + color: var(--text-muted); + font-size: 9.5px; +} +.rubrics-admin-view-preview-fields span { + display: block; + overflow: hidden; + color: var(--text-secondary); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} +.rubrics-admin-view-preview-image { + display: block; + width: 42px; + height: 42px; + border-radius: var(--radius-md); + object-fit: cover; + outline: 1px solid rgba(0, 0, 0, 0.1); +} +[data-theme="dark"] .rubrics-admin-view-preview-image { + outline-color: rgba(255, 255, 255, 0.1); +} +.rubrics-admin-view-preview-table { + overflow-x: auto; +} +.rubrics-admin-view-preview-table table { + width: 100%; + border-collapse: collapse; + font-size: 11px; +} +.rubrics-admin-view-preview-table th, +.rubrics-admin-view-preview-table td { + padding: 8px 9px; + border-bottom: 1px solid var(--border-subtle); + text-align: left; + vertical-align: middle; +} +.rubrics-admin-view-preview-table th { + background: var(--background-muted); + color: var(--text-secondary); + font-size: 10px; +} +.rubrics-admin-view-preview-table td b, +.rubrics-admin-view-preview-table td small { + display: block; +} +.rubrics-admin-view-preview-table td small { + color: var(--text-muted); +} +.rubrics-admin-view-standard { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 150px; + color: var(--text-secondary); +} +.rubrics-admin-view-standard > i { + font-size: 26px; + color: var(--blue-600); +} +.rubrics-admin-view-standard b, +.rubrics-admin-view-standard span { + display: block; + letter-spacing: 0; +} +.rubrics-admin-view-standard b { + color: var(--text-primary); + font-size: 13px; +} +.rubrics-admin-view-standard span { + margin-top: 3px; + font-size: 11px; +} +@media (max-width: 1180px) { + .rubrics-admin-view-builder { + grid-template-columns: minmax(210px, 240px) minmax(0, 1fr); + } + .rubrics-admin-view-inspector { + position: static; + grid-column: 1 / -1; + } + .rubrics-admin-view-inspector-form { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +@media (max-width: 780px) { + .rubrics-admin-view-body { + padding: 10px; + } + .rubrics-admin-view-modes { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .rubrics-admin-view-builder { + grid-template-columns: 1fr; + } + .rubrics-admin-view-library, + .rubrics-admin-view-inspector { + position: static; + } + .rubrics-admin-view-preview-grid { + grid-template-columns: 1fr; + } + .rubrics-admin-view-item { + grid-template-columns: 30px 24px minmax(0, 1fr) auto; + } + .rubrics-admin-view-item > .badge { + display: none; + } + .rubrics-admin-view-item-actions .btn:not(.rubrics-action-danger) { + display: none; + } + .rubrics-admin-view-inspector-form { + display: flex; + } +} +.rubrics-alias-help-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; + gap: 12px; +} +.rubrics-alias-help { + align-items: flex-start; + height: 100%; + min-width: 0; + margin: 0; + padding: 13px 14px; + border-color: color-mix(in srgb, var(--blue-600) 22%, var(--border-subtle)); + background: color-mix(in srgb, var(--blue-600) 4%, var(--surface-card)); +} +.rubrics-alias-help > div { + min-width: 0; +} +.rubrics-alias-help .alert-title, +.rubrics-alias-help > div > span, +.rubrics-alias-help small { + display: block; + letter-spacing: 0; +} +.rubrics-alias-help > div > span { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; +} +.rubrics-alias-help small { + margin-top: 7px; + color: var(--text-muted); + font-size: 10.5px; + line-height: 1.4; +} +.rubrics-alias-token-list { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 4px 8px; + margin-top: 9px; +} +.rubrics-alias-token-list code { + color: var(--blue-700); + font-size: 11px; + font-weight: 700; +} +.rubrics-alias-token-list span { + color: var(--text-muted); + font-size: 10.5px; +} +.rubrics-alias-preview { + display: block; + max-width: 100%; + margin-top: 9px; + padding: 7px 9px; + overflow: hidden; + border-radius: var(--radius-md); + background: var(--surface-card); + color: var(--blue-700); + font-size: 11.5px; + text-overflow: ellipsis; + white-space: nowrap; +} +@media (max-width: 760px) { + #rubricDrawer { + width: 100vw; + max-width: 100vw; + } + .rubrics-alias-help-grid { + grid-template-columns: 1fr; + } +} diff --git a/adminx/modules/Rubrics/assets/rubrics.js b/adminx/modules/Rubrics/assets/rubrics.js new file mode 100644 index 0000000..a211fc9 --- /dev/null +++ b/adminx/modules/Rubrics/assets/rubrics.js @@ -0,0 +1,4030 @@ +/** + * JS раздела «Рубрики и поля»: AJAX-фильтры, drawer-CRUD и сортировка. + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + function esc(value) { + return String(value == null ? '' : value).replace(/[&<>"']/g, function (ch) { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]; + }); + } + + Adminx.Rubrics = { + form: null, + fieldForm: null, + groupForm: null, + templateForm: null, + extraTemplateForm: null, + builderForm: null, + currentRubricId: 0, + currentGroups: [], + currentFields: [], + currentFieldSetLinks: [], + fieldSetMode: 'copy', + currentTemplateFields: [], + activeTemplateTextarea: null, + filterTimer: null, + aliasTimer: null, + fieldAliasTimer: null, + dragRubricRow: null, + dragGroupNode: null, + dragFieldNode: null, + dragBuilderFieldId: 0, + builderSelectedId: 0, + builderDrafts: {}, + builderDirty: false, + builderHydrating: false, + builderSettingsReady: false, + adminViewForm: null, + adminViewState: null, + adminViewFields: [], + adminViewPreview: [], + adminViewSelected: '', + adminViewDragSource: '', + templateRegistryLoaded: false, + templateRegistryLoading: false, + currentSchemaRevisionRubricId: 0, + currentSchemaRevisionId: 0, + currentSchemaRevisionFingerprint: '', + + init: function () { + this.form = document.getElementById('rubricForm'); + this.fieldForm = document.getElementById('rubricFieldForm'); + this.groupForm = document.getElementById('rubricGroupForm'); + this.templateForm = document.getElementById('rubricTemplateForm'); + this.extraTemplateForm = document.getElementById('rubricExtraTemplateForm'); + this.builderForm = document.querySelector('[data-builder-inspector]'); + this.adminViewForm = document.getElementById('rubricAdminViewForm'); + this.syncFieldTypeOptions(''); + this.bind(); + }, + + base: function () { + return (this.form && this.form.getAttribute('data-base')) || Adminx.base(); + }, + + bind: function () { + var self = this; + + document.addEventListener('click', function (e) { + var cmWrap = e.target.closest('.CodeMirror'); + if (cmWrap) { self.captureCodeMirror(cmWrap); } + var mainTab = e.target.closest('[data-rubrics-main-tab]'); + if (mainTab) { self.activateMainTab(mainTab.getAttribute('data-rubrics-main-tab')); } + if (e.target.closest('[data-rubrics-filter-reset]')) { self.resetFilters(); } + if (e.target.closest('[data-rubric-new]')) { self.fillRubricNew(); } + var edit = e.target.closest('[data-rubric-edit]'); + if (edit) { self.fillRubricEdit(edit.closest('[data-rubric-row]')); } + var del = e.target.closest('[data-rubric-delete]'); + if (del) { self.deleteRubric(del.closest('[data-rubric-row]')); } + var fields = e.target.closest('[data-rubric-fields]'); + if (fields) { self.openFields(fields.closest('[data-rubric-row]')); } + var templates = e.target.closest('[data-rubric-templates]'); + if (templates) { self.openTemplates(templates.closest('[data-rubric-row]')); } + var adminView = e.target.closest('[data-rubric-admin-view]'); + if (adminView) { self.openAdminView(adminView.closest('[data-rubric-row]')); } + var schemaRevisions = e.target.closest('[data-rubric-schema-revisions]'); + if (schemaRevisions) { self.openSchemaRevisions(schemaRevisions.closest('[data-rubric-row]')); } + var schemaRevisionOpen = e.target.closest('[data-schema-revision-open]'); + if (schemaRevisionOpen && !e.target.closest('[data-schema-revision-row-delete]')) { + self.loadSchemaRevision(schemaRevisionOpen.getAttribute('data-schema-revision-open')); + } + var schemaRevisionRowDelete = e.target.closest('[data-schema-revision-row-delete]'); + if (schemaRevisionRowDelete) { self.deleteSchemaRevision(schemaRevisionRowDelete.getAttribute('data-schema-revision-row-delete')); } + if (e.target.closest('[data-schema-revision-delete]')) { self.deleteSchemaRevision(); } + if (e.target.closest('[data-schema-revisions-clear]')) { self.clearSchemaRevisions(); } + if (e.target.closest('[data-schema-revision-restore]')) { self.restoreSchemaRevision(); } + var adminViewMode = e.target.closest('[data-admin-view-mode]'); + if (adminViewMode) { self.setAdminViewMode(adminViewMode.getAttribute('data-admin-view-mode')); } + var adminViewAdd = e.target.closest('[data-admin-view-add]'); + if (adminViewAdd) { self.addAdminViewItem(adminViewAdd.getAttribute('data-admin-view-add')); } + var adminViewItem = e.target.closest('[data-admin-view-item]'); + if (adminViewItem && !e.target.closest('[data-admin-view-remove], [data-admin-view-move]')) { + self.selectAdminViewItem(adminViewItem.getAttribute('data-source')); + } + var adminViewRemove = e.target.closest('[data-admin-view-remove]'); + if (adminViewRemove) { self.removeAdminViewItem(adminViewRemove.getAttribute('data-admin-view-remove')); } + var adminViewMove = e.target.closest('[data-admin-view-move]'); + if (adminViewMove) { self.moveAdminViewItem(adminViewMove.getAttribute('data-source'), parseInt(adminViewMove.getAttribute('data-admin-view-move'), 10) || 0); } + var adminViewToken = e.target.closest('[data-admin-view-token]'); + if (adminViewToken) { self.insertAdminViewToken(adminViewToken.getAttribute('data-admin-view-token')); } + if (e.target.closest('[data-admin-view-template-default]')) { self.setAdminViewTemplateExample(); } + var templateTab = e.target.closest('[data-rubric-template-tab]'); + if (templateTab) { self.activateTemplateTab(templateTab.getAttribute('data-rubric-template-tab')); } + if (e.target.closest('[data-rubric-og-default]')) { self.setOpenGraphExample(); } + if (e.target.closest('[data-rubric-template-refresh]')) { self.loadTemplates(); } + var templateTagsToggle = e.target.closest('[data-rubric-template-tags-toggle]'); + if (templateTagsToggle) { self.openTemplateTagPalette(templateTagsToggle.closest('.rubrics-code-field')); } + if (e.target.closest('[data-rubric-template-tags-close]')) { self.closeTemplateTagPalette(); } + var templateTagTab = e.target.closest('[data-rubric-template-tag-tab]'); + if (templateTagTab) { self.activateTemplateTagGroup(templateTagTab.getAttribute('data-rubric-template-tag-tab')); } + var templateTag = e.target.closest('[data-rubric-template-tag]'); + if (templateTag) { self.insertTemplateTag(templateTag); } + var fieldTplTag = e.target.closest('[data-field-tpl-tag]'); + if (fieldTplTag) { e.preventDefault(); self.insertFieldTemplateTag(fieldTplTag.getAttribute('data-field-tpl-tag')); } + var codeLint = e.target.closest('[data-rubric-code-lint]'); + if (codeLint) { self.lintCodeField(codeLint.closest('.rubrics-code-field')); } + var codeFull = e.target.closest('[data-rubric-code-fullscreen]'); + if (codeFull) { self.toggleCodeFieldFullscreen(codeFull.closest('.rubrics-code-field')); } + if (e.target.closest('[data-extra-template-new]')) { self.fillExtraTemplateNew(); } + var extraEdit = e.target.closest('[data-extra-template-edit]'); + if (extraEdit) { self.fillExtraTemplateEdit(extraEdit.closest('[data-extra-template-row]')); } + var extraDelete = e.target.closest('[data-extra-template-delete]'); + if (extraDelete) { self.deleteExtraTemplate(extraDelete.closest('[data-extra-template-row]')); } + if (e.target.closest('[data-field-new]')) { self.fillFieldNew(); } + var builderReturn = e.target.closest('[data-builder-unplace]'); + if (builderReturn) { + e.preventDefault(); + self.unplaceBuilderField(builderReturn.closest('[data-field-row]')); + } + var builderSelect = e.target.closest('[data-builder-select]'); + if (builderSelect && !e.target.closest('[data-field-edit], [data-field-delete], [data-field-tag-copy], [data-builder-unplace]')) { + if (builderSelect.matches('[data-builder-palette-item]')) { + self.placeBuilderField(builderSelect.getAttribute('data-id'), 0); + } else { + self.selectBuilderField(builderSelect.getAttribute('data-id')); + } + } + if (e.target.closest('[data-builder-save]')) { self.saveBuilder(); } + if (e.target.closest('[data-builder-advanced]')) { self.openBuilderAdvanced(); } + if (e.target.closest('[data-builder-field-set-apply]')) { self.applyBuilderFieldSet(); } + if (e.target.closest('[data-builder-field-set-export]')) { self.exportBuilderFieldSet(); } + if (e.target.closest('[data-builder-field-set-import]')) { self.openBuilderFieldSetImport(); } + var fieldSetMode = e.target.closest('[data-field-set-mode]'); + if (fieldSetMode) { self.setBuilderFieldSetMode(fieldSetMode.getAttribute('data-field-set-mode')); } + var fieldSetSync = e.target.closest('[data-field-set-sync]'); + if (fieldSetSync) { self.previewLinkedFieldSet(fieldSetSync.getAttribute('data-field-set-sync')); } + var fieldSetDetach = e.target.closest('[data-field-set-detach]'); + if (fieldSetDetach) { self.detachLinkedFieldSet(fieldSetDetach.getAttribute('data-field-set-detach')); } + var fieldEdit = e.target.closest('[data-field-edit]'); + if (fieldEdit) { + if (self.builderDirty && fieldEdit.closest('[data-builder-canvas]')) { + Adminx.Toast.show('Сначала сохраните изменения конструктора', 'warning'); + return; + } + self.fillFieldEdit(fieldEdit.closest('[data-field-row]')); + if (Adminx.Drawer) { Adminx.Drawer.open('rubricFieldDrawer'); } + } + var fieldDelete = e.target.closest('[data-field-delete]'); + if (fieldDelete) { self.deleteField(fieldDelete.closest('[data-field-row]')); } + var fieldCopy = e.target.closest('[data-field-tag-copy]'); + if (fieldCopy) { self.copy(fieldCopy.getAttribute('data-field-tag-copy')); } + var defaultBool = e.target.closest('[data-default-boolean]'); + if (defaultBool) { + e.preventDefault(); + self.setDefaultBoolean(defaultBool.getAttribute('data-default-boolean')); + } + if (e.target.closest('[data-default-list-add]')) { + e.preventDefault(); + self.addDefaultListRow(); + } + var defaultListRemove = e.target.closest('[data-default-list-remove]'); + if (defaultListRemove) { + e.preventDefault(); + self.removeDefaultListRow(defaultListRemove.closest('[data-default-list-row]')); + } + var settingsMapAdd = e.target.closest('[data-settings-map-add]'); + if (settingsMapAdd) { + e.preventDefault(); + self.addSettingsMapRow(settingsMapAdd.closest('[data-settings-map]')); + } + var settingsMapRemove = e.target.closest('[data-settings-map-remove]'); + if (settingsMapRemove) { + e.preventDefault(); + self.removeSettingsMapRow(settingsMapRemove.closest('[data-settings-map-row]')); + } + if (e.target.closest('[data-builder-condition-add]')) { + e.preventDefault(); + self.addBuilderConditionRow(e.target.closest('[data-builder-condition-group]')); + } + if (e.target.closest('[data-builder-condition-group-add]')) { + e.preventDefault(); + self.addBuilderConditionGroup(e.target.closest('[data-builder-condition-group]')); + } + var conditionRemove = e.target.closest('[data-builder-condition-remove]'); + if (conditionRemove) { + e.preventDefault(); + self.removeBuilderConditionRow(conditionRemove.closest('[data-builder-condition-row]')); + } + var conditionGroupRemove = e.target.closest('[data-builder-condition-group-remove]'); + if (conditionGroupRemove) { + e.preventDefault(); + self.removeBuilderConditionGroup(conditionGroupRemove.closest('[data-builder-condition-group]')); + } + if (e.target.closest('[data-default-media-pick]')) { + e.preventDefault(); + self.openDefaultMediaPicker(null); + } + var mediaRowPick = e.target.closest('[data-default-media-row-pick]'); + if (mediaRowPick) { + e.preventDefault(); + self.openDefaultMediaPicker(mediaRowPick.closest('[data-default-list-row]')); + } + if (e.target.closest('[data-default-relation-pick]')) { + e.preventDefault(); + self.openDefaultRelationPicker(null); + } + var relationRowPick = e.target.closest('[data-default-relation-row-pick]'); + if (relationRowPick) { + e.preventDefault(); + self.openDefaultRelationPicker(relationRowPick.closest('[data-default-list-row]')); + } + if (e.target.closest('[data-group-new]')) { self.fillGroupNew(); } + var groupEdit = e.target.closest('[data-group-edit]'); + if (groupEdit) { + self.fillGroupEdit(groupEdit.closest('[data-group-row]')); + if (Adminx.Drawer) { Adminx.Drawer.open('rubricGroupDrawer'); } + } + var groupDelete = e.target.closest('[data-group-delete]'); + if (groupDelete) { self.deleteGroup(groupDelete.closest('[data-group-row]')); } + }); + + document.addEventListener('submit', function (e) { + var filter = e.target.closest('.rubrics-filter'); + if (filter) { + e.preventDefault(); + self.applyFilters(filter, true); + } + }); + + document.addEventListener('change', function (e) { + if (e.target.matches('[data-field-type-toggle]')) { + self.toggleFieldType(e.target); + return; + } + if (e.target.matches('[data-field-types-state]')) { + self.filterFieldTypes(); + } + if (e.target.matches('[data-rubric-preset]')) { + self.syncRubricPresetHint(); + } + if (e.target.matches('[data-builder-field-set]')) { self.syncBuilderFieldSet(); } + var filter = e.target.closest('.rubrics-filter'); + if (filter && e.target.matches('select')) { + self.applyFilters(filter, true); + } + if (self.fieldForm && e.target === self.fieldForm.elements.rubric_field_type) { + self.syncFieldTypeOptions(e.target.value); + self.syncNativeNumericType(e.target.value); + self.loadTypeInfo(e.target.value); + } + if (e.target.matches('[data-default-editor-input]')) { + self.syncDefaultEditorValue(e.target); + } + if (e.target.matches('[data-default-list-input], [data-default-list-key], [data-default-list-value]')) { + self.syncDefaultListValue(); + } + if (e.target.matches('[data-settings-map-key], [data-settings-map-label]')) { + self.syncSettingsMap(e.target.closest('[data-settings-map]')); + } + if (e.target.matches('[data-builder-conditions-enabled]')) { + self.syncBuilderRuntime(); + self.setBuilderDirty(true); + } + var changedCondition = e.target.closest('[data-builder-condition]'); + if (changedCondition && e.target.matches('[data-builder-condition-enabled], [data-builder-condition-mode], [data-builder-condition-operator], [data-builder-condition-join], [data-builder-condition-options-enabled], [data-builder-condition-value-action], [data-builder-condition-action-select]')) { + self.syncBuilderCondition(changedCondition); + } + if (self.builderForm && self.builderForm.contains(e.target)) { + self.captureBuilderInspector(); + if (e.target.matches('[data-builder-group]')) { self.moveSelectedBuilderField(e.target.value); } + if (e.target.matches('input[name="width"]')) { self.applyBuilderWidth(e.target.value); } + } + if (e.target.matches('[data-admin-view-format], input[name="admin_view_width"]')) { + self.captureAdminViewInspector(); + } + }); + + document.addEventListener('input', function (e) { + var filter = e.target.closest('.rubrics-filter'); + if (filter && e.target.matches('input[type="search"]')) { + clearTimeout(self.filterTimer); + self.filterTimer = setTimeout(function () { self.applyFilters(filter, true); }, 350); + } + if (self.form && e.target === self.form.elements.rubric_alias) { + self.updateRubricAliasPreview(); + clearTimeout(self.aliasTimer); + self.aliasTimer = setTimeout(function () { self.checkRubricAlias(false); }, 250); + } + if (self.fieldForm && e.target === self.fieldForm.elements.rubric_field_alias) { + clearTimeout(self.fieldAliasTimer); + self.fieldAliasTimer = setTimeout(function () { self.checkFieldAlias(false); }, 250); + } + if (e.target.matches('[data-default-editor-input]')) { + self.syncDefaultEditorValue(e.target); + } + if (e.target.matches('[data-default-list-input], [data-default-list-key], [data-default-list-value]')) { + self.syncDefaultListValue(); + } + if (e.target.matches('[data-settings-map-key], [data-settings-map-label]')) { + self.syncSettingsMap(e.target.closest('[data-settings-map]')); + } + if (e.target.matches('[data-builder-search]')) { self.filterBuilderPalette(e.target.value); } + if (e.target.matches('[data-rubric-template-tags-search]')) { self.filterTemplateTags(e.target.value); } + if (e.target.matches('[data-field-types-search]')) { self.filterFieldTypes(); } + if (self.builderForm && self.builderForm.contains(e.target)) { + self.captureBuilderInspector(); + if (e.target.matches('[name="rubric_field_settings[options]"]')) { self.syncBuilderConditionOptionSource(e.target.value); } + } + if (e.target.matches('[data-admin-view-search]')) { self.renderAdminViewFields(e.target.value); } + if (e.target.matches('[data-admin-view-label]')) { self.captureAdminViewInspector(); } + if (self.adminViewForm && e.target === self.adminViewForm.elements.template) { self.renderAdminViewPreview(); } + }); + + document.addEventListener('focusin', function (e) { + if (e.target && e.target.matches && e.target.matches('#rubricTemplateForm textarea, #rubricExtraTemplateForm textarea')) { + self.activeTemplateTextarea = e.target; + } + }); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + if (document.querySelector('.rubrics-code-field.is-fullscreen')) { self.closeCodeFullscreen(); } + else { self.closeTemplateTagPalette(); } + } + }); + + window.addEventListener('popstate', function () { + self.applyFilterUrl(window.location.href, false); + }); + + document.addEventListener('adminx:drawer:closed', function (e) { + var drawer = e.detail && e.detail.drawer ? e.detail.drawer : null; + if (drawer && drawer.id === 'rubricFieldDrawer') { self.resetFieldForm(); } + if (drawer && (drawer.id === 'rubricTemplatesDrawer' || drawer.id === 'rubricExtraTemplateDrawer')) { self.closeTemplateTagPalette(); } + }); + + document.addEventListener('dragstart', function (e) { + if (e.target.closest('[data-admin-view-item]')) { self.adminViewDragStart(e); return; } + self.dragStart(e); + }); + document.addEventListener('dragover', function (e) { + if (self.adminViewDragSource) { self.adminViewDragOver(e); return; } + self.dragOver(e); + }); + document.addEventListener('drop', function (e) { + if (self.adminViewDragSource) { self.adminViewDrop(e); return; } + self.drop(e); + }); + document.addEventListener('dragend', function () { self.adminViewDragEnd(); self.dragEnd(); }); + + if (this.form) { + this.form.addEventListener('submit', function (e) { + e.preventDefault(); + self.submitRubric(); + }); + } + if (this.fieldForm) { + this.fieldForm.addEventListener('submit', function (e) { + e.preventDefault(); + self.submitField(); + }); + } + if (this.groupForm) { + this.groupForm.addEventListener('submit', function (e) { + e.preventDefault(); + self.submitGroup(); + }); + } + if (this.templateForm) { + this.templateForm.addEventListener('submit', function (e) { + e.preventDefault(); + self.submitMainTemplate(); + }); + } + if (this.extraTemplateForm) { + this.extraTemplateForm.addEventListener('submit', function (e) { + e.preventDefault(); + self.submitExtraTemplate(); + }); + } + if (this.builderForm) { + this.builderForm.addEventListener('submit', function (e) { + e.preventDefault(); + self.saveBuilder(); + }); + } + if (this.adminViewForm) { + this.adminViewForm.addEventListener('submit', function (e) { + e.preventDefault(); + self.saveAdminView(); + }); + } + }, + + filterUrl: function (form) { + var params = new URLSearchParams(new FormData(form)); + Array.from(params.keys()).forEach(function (key) { + var value = String(params.get(key) || ''); + if (value === '') { params.delete(key); } + }); + var query = params.toString(); + return (form.getAttribute('action') || (this.base() + '/rubrics')) + (query ? '?' + query : ''); + }, + + applyFilters: function (form, push) { + this.applyFilterUrl(this.filterUrl(form), push); + }, + + applyFilterUrl: function (url, push) { + var self = this; + Adminx.Loader.show(); + fetch(url, { method: 'GET', headers: { 'Accept': 'text/html' }, credentials: 'same-origin' }) + .then(function (res) { + return res.text().then(function (html) { + if (!res.ok) { throw new Error('HTTP ' + res.status); } + self.replaceList(html, url, push); + }); + }) + .catch(function () { Adminx.Toast.show('Не удалось применить фильтры', 'error'); }) + .finally(function () { Adminx.Loader.hide(); }); + }, + + replaceList: function (html, url, push) { + var doc = new DOMParser().parseFromString(html, 'text/html'); + ['.rubrics-summary', '.rubrics-import-card', '.rubrics-panel'].forEach(function (selector) { + var next = doc.querySelector(selector); + var current = document.querySelector(selector); + if (next && current) { current.replaceWith(next); } + }); + if (push && window.history && window.history.pushState) { + window.history.pushState({ adminxRubricsFilters: true }, '', url); + } + }, + + activateMainTab: function (name) { + name = name || 'rubrics'; + document.querySelectorAll('[data-rubrics-main-tab]').forEach(function (tab) { + tab.setAttribute('aria-selected', tab.getAttribute('data-rubrics-main-tab') === name ? 'true' : 'false'); + }); + document.querySelectorAll('[data-rubrics-main-panel]').forEach(function (panel) { + panel.hidden = panel.getAttribute('data-rubrics-main-panel') !== name; + }); + }, + + resetFilters: function () { + var form = document.querySelector('.rubrics-filter'); + if (!form) { return; } + form.reset(); + Array.prototype.forEach.call(form.querySelectorAll('input, select'), function (el) { + if (el.tagName === 'SELECT') { el.selectedIndex = 0; } else { el.value = ''; } + }); + this.applyFilters(form, true); + }, + + toggleFieldType: function (input) { + if (!input || !this.form) { return; } + var self = this; + var type = input.getAttribute('data-field-type-toggle') || ''; + var enabled = input.checked; + var data = new FormData(); + data.append('_csrf', this.form.elements._csrf.value); + data.append('enabled', enabled ? '1' : '0'); + input.disabled = true; + this.ajax(this.base() + '/rubrics/field-types/' + encodeURIComponent(type) + '/toggle', data, function (json) { + var payload = json.data || {}; + var result = payload.type || {}; + var actual = !!result.enabled; + var row = input.closest('[data-field-type-row]'); + input.checked = actual; + input.disabled = false; + input.setAttribute('aria-label', (actual ? 'Отключить' : 'Включить') + ' тип ' + (result.name || type)); + var switchRoot = input.closest('.rubrics-type-switch'); + if (switchRoot) { + switchRoot.setAttribute('data-tooltip', actual ? 'Убрать из выбора новых полей' : 'Добавить в выбор новых полей'); + } + if (row) { + row.setAttribute('data-state', actual ? 'enabled' : 'disabled'); + row.classList.toggle('is-disabled', !actual); + } + var option = null; + if (self.fieldForm) { + Array.prototype.some.call(self.fieldForm.querySelectorAll('[data-field-type-option]'), function (candidate) { + if (candidate.value !== type) { return false; } + option = candidate; + return true; + }); + } + if (option) { + var label = option.getAttribute('data-field-type-label') || result.name || type; + option.setAttribute('data-enabled', actual ? '1' : '0'); + option.textContent = label + (actual ? '' : ' (отключён)'); + } + var summary = payload.summary || {}; + document.querySelectorAll('[data-field-types-enabled]').forEach(function (node) { + if (typeof summary.enabled !== 'undefined') { node.textContent = summary.enabled; } + }); + document.querySelectorAll('[data-field-types-disabled]').forEach(function (node) { + if (typeof summary.disabled !== 'undefined') { node.textContent = summary.disabled; } + }); + var currentType = self.fieldForm && self.fieldForm.elements.id.value + ? self.fieldForm.elements.rubric_field_type.value + : ''; + self.syncFieldTypeOptions(currentType); + self.filterFieldTypes(); + Adminx.Toast.show(json.message || (actual ? 'Тип поля включён' : 'Тип поля отключён'), 'success'); + }, function (json) { + input.checked = !enabled; + input.disabled = false; + Adminx.Toast.show(json.message || 'Не удалось изменить состояние типа поля', 'error'); + }); + }, + + filterFieldTypes: function () { + var search = document.querySelector('[data-field-types-search]'); + var state = document.querySelector('[data-field-types-state]'); + var needle = search ? String(search.value || '').trim().toLowerCase() : ''; + var selected = state ? String(state.value || '') : ''; + var visible = 0; + document.querySelectorAll('[data-field-type-row]').forEach(function (row) { + var matchesSearch = !needle || String(row.getAttribute('data-search') || '').indexOf(needle) !== -1; + var rowState = row.getAttribute('data-state') || ''; + var isNew = row.getAttribute('data-new') === '1'; + var usage = parseInt(row.getAttribute('data-usage') || '0', 10); + var matchesState = !selected + || (selected === 'used' ? usage > 0 : (selected === 'new' ? isNew : rowState === selected)); + row.hidden = !(matchesSearch && matchesState); + if (!row.hidden) { visible += 1; } + }); + var empty = document.querySelector('[data-field-types-empty]'); + if (empty) { empty.hidden = visible > 0; } + }, + + syncFieldTypeOptions: function (currentType) { + if (!this.fieldForm || !this.fieldForm.elements.rubric_field_type) { return; } + var select = this.fieldForm.elements.rubric_field_type; + currentType = String(currentType || ''); + Array.prototype.forEach.call(select.querySelectorAll('[data-field-type-option]'), function (option) { + option.disabled = option.getAttribute('data-enabled') !== '1' && option.value !== currentType; + }); + if (currentType) { + select.value = currentType; + return; + } + var selected = select.options[select.selectedIndex]; + if (!selected || selected.disabled) { + var first = select.querySelector('[data-field-type-option][data-enabled="1"]'); + select.value = first ? first.value : ''; + } + }, + + ajax: function (url, data, success, error) { + Adminx.Loader.show(); + fetch(url, { method: 'POST', body: data, credentials: 'same-origin', headers: { 'Accept': 'application/json' } }) + .then(function (res) { + return res.json().then(function (json) { + if (!res.ok || json.success === false) { + var err = new Error(json.message || 'Ошибка запроса'); + err.payload = json; + throw err; + } + return json; + }); + }) + .then(function (json) { + if (success) { success(json); } + }) + .catch(function (err) { + if (error) { error(err.payload || { message: err.message }); } + else { Adminx.Toast.show(err.message || 'Ошибка запроса', 'error'); } + }) + .finally(function () { Adminx.Loader.hide(); }); + }, + + fillRubricNew: function () { + this.clearErrors(this.form); + this.form.reset(); + this.form.elements.id.value = ''; + this.form.elements.rubric_template_id.value = '1'; + var presetWrap = this.form.querySelector('[data-rubric-preset-wrap]'); + if (presetWrap) { presetWrap.hidden = false; } + if (this.form.elements.rubric_preset) { this.form.elements.rubric_preset.value = ''; } + this.syncRubricPresetHint(); + this.setCodeValue(this.form.elements.rubric_code_start, ''); + this.setCodeValue(this.form.elements.rubric_code_end, ''); + this.setCodeValue(this.form.elements.rubric_start_code, ''); + if (this.form.elements.rubric_template_id.selectedIndex < 0) { this.form.elements.rubric_template_id.selectedIndex = 0; } + document.getElementById('rubricDrawerTitle').textContent = 'Новая рубрика'; + this.setHint('[data-rubric-alias-state]', ''); + this.updateRubricAliasPreview(); + this.refreshEditors(); + }, + + fillRubricEdit: function (row) { + if (!row) { return; } + var self = this; + this.clearErrors(this.form); + fetch(this.base() + '/rubrics/' + row.getAttribute('data-id'), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (res) { return res.json(); }) + .then(function (json) { + var item = json.data || {}; + self.form.elements.id.value = item.Id || ''; + self.form.elements.rubric_title.value = item.rubric_title || ''; + self.form.elements.rubric_alias.value = item.rubric_alias || ''; + var presetWrap = self.form.querySelector('[data-rubric-preset-wrap]'); + if (presetWrap) { presetWrap.hidden = true; } + self.form.elements.rubric_template_id.value = item.rubric_template_id || 1; + if (self.form.elements.rubric_template_id.selectedIndex < 0) { self.form.elements.rubric_template_id.selectedIndex = 0; } + self.form.elements.rubric_docs_active.value = String(item.rubric_docs_active || 0); + self.form.elements.rubric_meta_gen.value = String(item.rubric_meta_gen || 0); + self.form.elements.rubric_alias_history.value = String(item.rubric_alias_history || 0); + self.form.elements.rubric_description.value = item.rubric_description || ''; + self.setCodeValue(self.form.elements.rubric_code_start, item.rubric_code_start || ''); + self.setCodeValue(self.form.elements.rubric_code_end, item.rubric_code_end || ''); + self.setCodeValue(self.form.elements.rubric_start_code, item.rubric_start_code || ''); + document.getElementById('rubricDrawerTitle').textContent = 'Редактирование рубрики #' + item.Id; + self.setHint('[data-rubric-alias-state]', ''); + self.updateRubricAliasPreview(); + self.refreshEditors(); + }); + }, + + submitRubric: function () { + var self = this; + var id = this.form.elements.id.value; + this.saveEditors(); + this.clearErrors(this.form); + this.checkRubricAlias(true, function (ok) { + if (!ok) { return; } + self.ajax(self.base() + '/rubrics' + (id ? '/' + id : ''), new FormData(self.form), function (json) { + Adminx.Toast.show(json.message || 'Рубрика сохранена', 'success'); + if (Adminx.Drawer) { Adminx.Drawer.close(); } + self.applyFilterUrl(window.location.href, false); + }, function (json) { self.showErrors(self.form, json); }); + }); + }, + + syncRubricPresetHint: function () { + if (!this.form || !this.form.elements.rubric_preset) { return; } + var select = this.form.elements.rubric_preset; + var option = select.options[select.selectedIndex]; + var hint = this.form.querySelector('[data-rubric-preset-hint]'); + if (hint) { + hint.textContent = option ? option.getAttribute('data-description') || '' : ''; + } + }, + + updateRubricAliasPreview: function () { + if (!this.form) { return; } + var output = this.form.querySelector('[data-rubric-alias-preview]'); + if (!output) { return; } + var pattern = String(this.form.elements.rubric_alias.value || '').replace(/^\/+|\/+$/g, ''); + var now = new Date(); + var pad = function (value) { return String(value).padStart(2, '0'); }; + var replacements = { + '%d': pad(now.getDate()), '%m': pad(now.getMonth() + 1), '%Y': String(now.getFullYear()), + '%y': String(now.getFullYear()).slice(-2), '%H': pad(now.getHours()), '%M': pad(now.getMinutes()), '%S': pad(now.getSeconds()) + }; + Object.keys(replacements).forEach(function (token) { pattern = pattern.split(token).join(replacements[token]); }); + output.textContent = '/' + (pattern ? pattern + '/' : '') + 'document-alias'; + }, + + checkRubricAlias: function (required, done) { + var alias = this.form.elements.rubric_alias.value.trim(); + var id = this.form.elements.id.value || 0; + if (!alias && !required) { + this.setHint('[data-rubric-alias-state]', ''); + if (done) { done(false); } + return; + } + var self = this; + fetch(this.base() + '/rubrics/alias-check?alias=' + encodeURIComponent(alias) + '&id=' + encodeURIComponent(id), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (res) { return res.json(); }) + .then(function (json) { + var data = json.data || {}; + self.setHint('[data-rubric-alias-state]', data.message || '', data.valid && data.available); + if (done) { done(!!(data.valid && data.available)); } + }); + }, + + deleteRubric: function (row) { + if (!row || !window.confirm('Удалить рубрику «' + row.getAttribute('data-title') + '»?')) { return; } + var data = new FormData(); + data.append('_csrf', this.form.elements._csrf.value); + var self = this; + this.ajax(this.base() + '/rubrics/' + row.getAttribute('data-id') + '/delete', data, function () { + Adminx.Toast.show('Рубрика удалена', 'success'); + self.applyFilterUrl(window.location.href, false); + }); + }, + + openAdminView: function (row) { + if (!row) { return; } + this.currentRubricId = parseInt(row.getAttribute('data-id'), 10) || 0; + this.adminViewSelected = ''; + this.loadAdminView(); + }, + + loadAdminView: function () { + var self = this; + if (!this.currentRubricId || !this.adminViewForm) { return; } + Adminx.Loader.show(); + fetch(this.base() + '/rubrics/' + this.currentRubricId + '/admin-view', { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }).then(function (res) { + return res.json().then(function (json) { + if (!res.ok || json.success === false) { throw new Error(json.message || 'Не удалось загрузить представление'); } + return json.data || {}; + }); + }).then(function (data) { + var rubric = data.rubric || {}; + self.adminViewFields = data.fields || []; + self.adminViewPreview = data.preview || []; + self.adminViewState = data.view || { mode: 'standard', items: [], template: '' }; + self.adminViewState.items = Array.isArray(self.adminViewState.items) ? self.adminViewState.items : []; + self.adminViewState.template = self.adminViewState.template || ''; + self.adminViewForm.elements.rubric_id.value = self.currentRubricId; + self.adminViewForm.elements.template.value = self.adminViewState.template; + document.getElementById('rubricAdminViewTitle').textContent = 'Вид документов: ' + (rubric.rubric_title || ''); + var subtitle = document.querySelector('[data-admin-view-subtitle]'); + if (subtitle) { subtitle.textContent = 'Рубрика #' + self.currentRubricId + ' · настройка действует только в Adminx.'; } + var documentsLink = document.querySelector('[data-admin-view-documents]'); + if (documentsLink) { documentsLink.href = self.base() + '/documents?rubric_id=' + self.currentRubricId; } + self.renderAdminView(); + }).catch(function (error) { + Adminx.Toast.show(error.message || 'Не удалось загрузить представление', 'error'); + }).finally(function () { Adminx.Loader.hide(); }); + }, + + renderAdminView: function () { + if (!this.adminViewState) { return; } + var self = this; + document.querySelectorAll('[data-admin-view-mode]').forEach(function (button) { + button.setAttribute('aria-pressed', button.getAttribute('data-admin-view-mode') === self.adminViewState.mode ? 'true' : 'false'); + }); + var builder = document.querySelector('[data-admin-view-builder]'); + if (builder) { builder.classList.toggle('is-standard', this.adminViewState.mode === 'standard'); } + var custom = document.querySelector('[data-admin-view-custom]'); + if (custom) { custom.hidden = this.adminViewState.mode !== 'custom'; } + this.renderAdminViewFields(''); + this.renderAdminViewItems(); + this.renderAdminViewInspector(); + this.renderAdminViewTokens(); + this.renderAdminViewPreview(); + }, + + renderAdminViewFields: function (search) { + var root = document.querySelector('[data-admin-view-fields]'); + if (!root) { return; } + var needle = String(search || '').trim().toLowerCase(); + var selected = {}; + (this.adminViewState ? this.adminViewState.items : []).forEach(function (item) { selected[item.source] = true; }); + var fields = this.adminViewFields.filter(function (field) { + return !needle || (field.title + ' ' + field.alias + ' ' + field.type).toLowerCase().indexOf(needle) !== -1; + }); + root.innerHTML = fields.length ? fields.map(function (field) { + var used = !!selected[field.source]; + return ''; + }).join('') : '
    Поля не найдены
    '; + var count = document.querySelector('[data-admin-view-field-count]'); + if (count) { count.textContent = this.adminViewFields.length; } + }, + + renderAdminViewItems: function () { + var root = document.querySelector('[data-admin-view-items]'); + if (!root || !this.adminViewState) { return; } + var self = this; + var items = this.adminViewState.items || []; + root.innerHTML = items.length ? items.map(function (item, index) { + return '
    ' + + '' + + '' + (index + 1) + '' + + '' + esc(item.label) + '' + esc(item.type || '') + ' · ' + esc(item.format || 'text') + '' + + '' + esc(item.width || 'medium') + '' + + '' + + '' + + '' + + '' + + '
    '; + }).join('') : '
    Поля не добавленыВыберите их в левой колонке. Название, состояние и действия документа останутся системными.
    '; + var count = document.querySelector('[data-admin-view-item-count]'); + if (count) { count.textContent = items.length + ' / 10'; } + }, + + renderAdminViewInspector: function () { + var empty = document.querySelector('[data-admin-view-inspector-empty]'); + var form = document.querySelector('[data-admin-view-inspector]'); + var item = this.adminViewItem(this.adminViewSelected); + if (empty) { empty.hidden = !!item; } + if (form) { form.hidden = !item; } + if (!item || !form) { return; } + form.querySelector('[data-admin-view-label]').value = item.label || ''; + form.querySelector('[data-admin-view-format]').value = item.format || 'text'; + var width = form.querySelector('input[name="admin_view_width"][value="' + (item.width || 'medium') + '"]'); + if (width) { width.checked = true; } + var field = this.adminViewField(item.source); + var meta = form.querySelector('[data-admin-view-field-meta]'); + if (meta && field) { + meta.innerHTML = '' + esc(field.alias || 'без алиаса') + '' + esc(field.type || '') + '[field:' + esc(field.alias || field.id) + ']'; + } + }, + + renderAdminViewTokens: function () { + var root = document.querySelector('[data-admin-view-tokens]'); + if (!root || !this.adminViewState) { return; } + var tokens = [ + { token: '[document:title]', label: 'Название' }, + { token: '[document:id]', label: 'ID' }, + { token: '[document:alias]', label: 'Alias' } + ]; + this.adminViewState.items.forEach(function (item) { + tokens.push({ token: '[field:' + (item.alias || item.field_id) + ']', label: item.label }); + }); + root.innerHTML = tokens.map(function (item) { + return ''; + }).join(''); + }, + + renderAdminViewPreview: function () { + var root = document.querySelector('[data-admin-view-preview]'); + if (!root || !this.adminViewState) { return; } + var count = document.querySelector('[data-admin-view-preview-count]'); + if (count) { count.textContent = this.adminViewPreview.length; } + if (this.adminViewState.mode === 'standard') { + root.innerHTML = '
    Стандартный список документовID, название, рубрика, состояние, дата и системные действия.
    '; + return; + } + if (!this.adminViewPreview.length) { + root.innerHTML = '
    В рубрике пока нет документов
    '; + return; + } + var self = this; + if (this.adminViewState.mode === 'table') { + root.innerHTML = '
    ' + + this.adminViewState.items.map(function (item) { return ''; }).join('') + + '' + this.adminViewPreview.map(function (document) { + return '' + + self.adminViewState.items.map(function (item) { return ''; }).join('') + + ''; + }).join('') + '
    Документ' + esc(item.label) + '
    ' + esc(document.document_title) + '#' + esc(document.Id) + '' + self.adminViewValueHtml(document, item) + '
    '; + return; + } + root.innerHTML = '
    ' + this.adminViewPreview.map(function (document) { + var content = self.adminViewState.mode === 'custom' + ? self.adminViewCustomHtml(document) + : '
    ' + self.adminViewState.items.map(function (item) { + return '
    ' + esc(item.label) + '' + self.adminViewValueHtml(document, item) + '
    '; + }).join('') + '
    '; + return '
    ' + esc(document.document_title) + '#' + esc(document.Id) + ' · ' + esc(document.document_alias || 'без alias') + '
    ' + content + '
    '; + }).join('') + '
    '; + }, + + adminViewValueHtml: function (document, item) { + var values = document.admin_field_values || {}; + var value = values[item.source] || { text: '', image: '' }; + if (item.format === 'image') { + return value.image ? '' : ''; + } + if (item.format === 'badge') { return '' + esc(value.text || '—') + ''; } + return '' + esc(value.text || '—') + ''; + }, + + adminViewCustomHtml: function (document) { + var template = this.adminViewForm ? this.adminViewForm.elements.template.value : ''; + if (!template) { template = '[document:title]#[document:id] · [document:alias]'; } + var replace = { + '[document:id]': String(document.Id || ''), + '[document:title]': esc(document.document_title || ''), + '[document:alias]': esc(document.document_alias || '') + }; + var values = document.admin_field_values || {}; + (this.adminViewState.items || []).forEach(function (item) { + var value = values[item.source] || { text: '' }; + replace['[field:' + item.field_id + ']'] = esc(value.text || ''); + if (item.alias) { replace['[field:' + item.alias + ']'] = esc(value.text || ''); } + }); + Object.keys(replace).forEach(function (token) { template = template.split(token).join(replace[token]); }); + var parsed = new DOMParser().parseFromString('
    ' + template + '
    ', 'text/html'); + parsed.querySelectorAll('script,style,iframe,object,embed,form').forEach(function (node) { node.remove(); }); + parsed.querySelectorAll('*').forEach(function (node) { + Array.prototype.slice.call(node.attributes).forEach(function (attribute) { + if (/^on/i.test(attribute.name) || /javascript\s*:/i.test(attribute.value)) { node.removeAttribute(attribute.name); } + }); + }); + return parsed.body.firstElementChild ? parsed.body.firstElementChild.innerHTML : ''; + }, + + setAdminViewMode: function (mode) { + if (!this.adminViewState || ['standard', 'table', 'cards', 'custom'].indexOf(mode) === -1) { return; } + this.adminViewState.mode = mode; + this.renderAdminView(); + }, + + addAdminViewItem: function (source) { + if (!this.adminViewState || this.adminViewState.items.length >= 10 || this.adminViewItem(source)) { + if (this.adminViewState && this.adminViewState.items.length >= 10) { Adminx.Toast.show('Можно добавить не больше 10 полей', 'warning'); } + return; + } + var field = this.adminViewField(source); + if (!field) { return; } + this.adminViewState.items.push({ source: field.source, field_id: field.id, label: field.title, format: field.format || 'text', width: 'medium', type: field.type, alias: field.alias || '' }); + this.adminViewSelected = source; + if (this.adminViewState.mode === 'standard') { this.adminViewState.mode = 'table'; } + this.renderAdminView(); + }, + + removeAdminViewItem: function (source) { + if (!this.adminViewState) { return; } + this.adminViewState.items = this.adminViewState.items.filter(function (item) { return item.source !== source; }); + if (this.adminViewSelected === source) { this.adminViewSelected = ''; } + this.renderAdminView(); + }, + + selectAdminViewItem: function (source) { + this.adminViewSelected = source || ''; + this.renderAdminViewItems(); + this.renderAdminViewInspector(); + }, + + captureAdminViewInspector: function () { + var item = this.adminViewItem(this.adminViewSelected); + var form = document.querySelector('[data-admin-view-inspector]'); + if (!item || !form) { return; } + item.label = form.querySelector('[data-admin-view-label]').value.trim() || (this.adminViewField(item.source) || {}).title || item.label; + item.format = form.querySelector('[data-admin-view-format]').value || 'text'; + var width = form.querySelector('input[name="admin_view_width"]:checked'); + item.width = width ? width.value : 'medium'; + this.renderAdminViewItems(); + this.renderAdminViewTokens(); + this.renderAdminViewPreview(); + }, + + moveAdminViewItem: function (source, direction) { + if (!this.adminViewState || !direction) { return; } + var index = this.adminViewState.items.findIndex(function (item) { return item.source === source; }); + var target = index + direction; + if (index < 0 || target < 0 || target >= this.adminViewState.items.length) { return; } + var item = this.adminViewState.items.splice(index, 1)[0]; + this.adminViewState.items.splice(target, 0, item); + this.renderAdminView(); + }, + + adminViewDragStart: function (event) { + var row = event.target.closest('[data-admin-view-item]'); + if (!row) { return; } + this.adminViewDragSource = row.getAttribute('data-source') || ''; + row.classList.add('is-dragging'); + if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', this.adminViewDragSource); } + }, + + adminViewDragOver: function (event) { + var target = event.target.closest('[data-admin-view-item]'); + if (!target || target.getAttribute('data-source') === this.adminViewDragSource) { return; } + event.preventDefault(); + document.querySelectorAll('[data-admin-view-item]').forEach(function (item) { item.classList.remove('is-drop-before', 'is-drop-after'); }); + var rect = target.getBoundingClientRect(); + target.classList.add(event.clientY < rect.top + rect.height / 2 ? 'is-drop-before' : 'is-drop-after'); + }, + + adminViewDrop: function (event) { + var target = event.target.closest('[data-admin-view-item]'); + if (!target || !this.adminViewState) { this.adminViewDragEnd(); return; } + event.preventDefault(); + var sourceIndex = this.adminViewState.items.findIndex(function (item) { return item.source === this.adminViewDragSource; }, this); + var targetSource = target.getAttribute('data-source'); + var targetIndex = this.adminViewState.items.findIndex(function (item) { return item.source === targetSource; }); + if (sourceIndex >= 0 && targetIndex >= 0 && sourceIndex !== targetIndex) { + var moved = this.adminViewState.items.splice(sourceIndex, 1)[0]; + if (sourceIndex < targetIndex) { targetIndex -= 1; } + var rect = target.getBoundingClientRect(); + if (event.clientY >= rect.top + rect.height / 2) { targetIndex += 1; } + this.adminViewState.items.splice(targetIndex, 0, moved); + } + this.adminViewDragEnd(); + this.renderAdminView(); + }, + + adminViewDragEnd: function () { + this.adminViewDragSource = ''; + document.querySelectorAll('[data-admin-view-item]').forEach(function (item) { item.classList.remove('is-dragging', 'is-drop-before', 'is-drop-after'); }); + }, + + setAdminViewTemplateExample: function () { + if (!this.adminViewForm) { return; } + this.adminViewForm.elements.template.value = '
    \n [document:title]\n #[document:id] · [document:alias]\n
    '; + this.renderAdminViewPreview(); + }, + + insertAdminViewToken: function (token) { + if (!this.adminViewForm) { return; } + var textarea = this.adminViewForm.elements.template; + var start = textarea.selectionStart || 0; + var end = textarea.selectionEnd || 0; + textarea.value = textarea.value.slice(0, start) + token + textarea.value.slice(end); + textarea.focus(); + textarea.setSelectionRange(start + token.length, start + token.length); + this.renderAdminViewPreview(); + }, + + saveAdminView: function () { + if (!this.adminViewState || !this.adminViewForm) { return; } + var data = new FormData(this.adminViewForm); + data.append('mode', this.adminViewState.mode || 'standard'); + data.append('items', JSON.stringify(this.adminViewState.items || [])); + var self = this; + this.ajax(this.base() + '/rubrics/' + this.currentRubricId + '/admin-view', data, function (json) { + if (json.data && json.data.view) { self.adminViewState = json.data.view; } + Adminx.Toast.show(json.message || 'Представление документов сохранено', 'success'); + self.renderAdminView(); + }); + }, + + adminViewField: function (source) { + return this.adminViewFields.find(function (field) { return field.source === source; }) || null; + }, + + adminViewItem: function (source) { + if (!this.adminViewState || !source) { return null; } + return this.adminViewState.items.find(function (item) { return item.source === source; }) || null; + }, + + openTemplates: function (row) { + if (!row) { return; } + this.closeTemplateTagPalette(); + this.currentRubricId = parseInt(row.getAttribute('data-id'), 10) || 0; + this.activateTemplateTab('main'); + this.loadTemplates(); + }, + + loadTemplates: function () { + var self = this; + if (!this.currentRubricId) { return; } + fetch(this.base() + '/rubrics/' + this.currentRubricId + '/templates', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (res) { return res.json(); }) + .then(function (json) { + var data = json.data || {}; + var rubric = data.rubric || {}; + self.currentTemplateFields = data.fields || []; + document.getElementById('rubricTemplatesTitle').textContent = 'Шаблоны: ' + (rubric.rubric_title || ''); + var sub = document.querySelector('[data-rubric-templates-subtitle]'); + if (sub) { sub.textContent = 'Рубрика #' + self.currentRubricId + ', ' + (data.templates || []).length + ' дополнительных шаблонов.'; } + self.fillMainTemplateForm(rubric); + self.renderTemplateTags(data.fields || []); + self.renderExtraTemplates(data.templates || []); + self.refreshEditors(); + }) + .catch(function () { Adminx.Toast.show('Не удалось загрузить шаблоны рубрики', 'error'); }); + }, + + fillMainTemplateForm: function (rubric) { + if (!this.templateForm) { return; } + this.templateForm.elements.rubric_id.value = this.currentRubricId; + this.setCodeValue(this.templateForm.elements.rubric_header_template, rubric.rubric_header_template || ''); + this.setCodeValue(this.templateForm.elements.rubric_og_template, rubric.rubric_og_template || ''); + this.setCodeValue(this.templateForm.elements.rubric_template, rubric.rubric_template || ''); + this.setCodeValue(this.templateForm.elements.rubric_footer_template, rubric.rubric_footer_template || ''); + this.setCodeValue(this.templateForm.elements.rubric_teaser_template, rubric.rubric_teaser_template || ''); + this.activeTemplateTextarea = this.templateForm.elements.rubric_template; + }, + + setOpenGraphExample: function () { + var textarea = this.templateForm ? this.templateForm.elements.rubric_og_template : null; + var self = this; + var markup = '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + ''; + if (!textarea) { return; } + var current = textarea._adminxCodeMirror ? textarea._adminxCodeMirror.getValue() : textarea.value; + if (String(current || '').trim() === '') { + this.setCodeValue(textarea, markup); + this.activeTemplateTextarea = textarea; + return; + } + Adminx.Confirm.open({ + kind: 'warning', + title: 'Заменить OG-разметку?', + message: 'Текущий код в этой вкладке будет заменён базовой разметкой.', + confirmLabel: 'Заменить', + onConfirm: function () { self.setCodeValue(textarea, markup); self.activeTemplateTextarea = textarea; } + }); + }, + + submitMainTemplate: function () { + var self = this; + this.saveEditors(); + this.ajax(this.base() + '/rubrics/' + this.currentRubricId + '/templates/main', new FormData(this.templateForm), function () { + Adminx.Toast.show('Шаблоны рубрики сохранены', 'success'); + self.loadTemplates(); + }); + }, + + activateTemplateTab: function (name) { + name = name || 'main'; + this.closeTemplateTagPalette(); + document.querySelectorAll('[data-rubric-template-tab]').forEach(function (tab) { + tab.setAttribute('aria-selected', tab.getAttribute('data-rubric-template-tab') === name ? 'true' : 'false'); + }); + document.querySelectorAll('[data-rubric-template-panel]').forEach(function (panel) { + panel.classList.toggle('active', panel.getAttribute('data-rubric-template-panel') === name); + }); + this.refreshEditors(); + }, + + renderTemplateTags: function (fields) { + var root = document.querySelector('[data-rubric-template-tags]'); + var count = document.querySelector('[data-rubric-template-tags-count]'); + if (!root) { return; } + if (count) { count.textContent = String(fields.length); } + if (!fields.length) { + root.innerHTML = '
    Поля не найдены.
    '; + return; + } + root.innerHTML = fields.map(function (field) { + return ''; + }).join(''); + }, + + openTemplateTagPalette: function (field) { + var palette = document.querySelector('[data-rubric-template-tags-panel]'); + var textarea = field ? field.querySelector('textarea[data-code-editor]') : null; + var anchor = textarea && textarea._adminxCodeMirror ? textarea._adminxCodeMirror.getWrapperElement() : textarea; + var search; + if (!palette || !field || !textarea) { return; } + this.activeTemplateTextarea = textarea; + this.loadTemplateRegistryTags(); + field.insertBefore(palette, anchor || field.firstChild); + palette.hidden = false; + search = palette.querySelector('[data-rubric-template-tags-search]'); + if (search) { + search.value = ''; + this.filterTemplateTags(''); + search.focus(); + } + }, + + loadTemplateRegistryTags: function () { + var self = this; + if (this.templateRegistryLoaded || this.templateRegistryLoading) { return; } + this.templateRegistryLoading = true; + fetch(this.base() + '/rubrics/template-tags', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (res) { return res.json(); }) + .then(function (json) { + self.renderTemplateRegistryTags((json.data && json.data.groups) || []); + self.templateRegistryLoaded = true; + }) + .catch(function () { Adminx.Toast.show('Не удалось загрузить реестр тегов', 'error'); }) + .finally(function () { self.templateRegistryLoading = false; }); + }, + + renderTemplateRegistryTags: function (groups) { + var nav = document.querySelector('[data-rubric-template-tag-nav]'); + var root = document.querySelector('[data-rubric-template-tag-groups]'); + if (!nav || !root) { return; } + groups.forEach(function (group, index) { + var items = group && Array.isArray(group.items) ? group.items : []; + if (!items.length) { return; } + var name = 'registry-' + index; + nav.insertAdjacentHTML('beforeend', ''); + var buttons = items.map(function (tag) { + var select = tag.select ? ' data-rubric-template-tag-select="' + esc(tag.select) + '"' : ''; + return ''; + }).join(''); + root.querySelector('[data-rubric-template-tags-empty]').insertAdjacentHTML('beforebegin', + '
    ' + + '
    ' + buttons + '
    '); + }); + }, + + closeTemplateTagPalette: function () { + var palette = document.querySelector('[data-rubric-template-tags-panel]'); + if (palette) { palette.hidden = true; } + }, + + activateTemplateTagGroup: function (name) { + var palette = document.querySelector('[data-rubric-template-tags-panel]'); + if (!palette) { return; } + palette.querySelectorAll('[data-rubric-template-tag-tab]').forEach(function (tab) { + tab.classList.toggle('is-active', tab.getAttribute('data-rubric-template-tag-tab') === String(name)); + }); + palette.querySelectorAll('[data-rubric-template-tag-panel]').forEach(function (group) { + group.classList.toggle('is-active', group.getAttribute('data-rubric-template-tag-panel') === String(name)); + }); + }, + + filterTemplateTags: function (query) { + var palette = document.querySelector('[data-rubric-template-tags-panel]'); + var shown = 0; + if (!palette) { return; } + query = String(query || '').trim().toLowerCase(); + palette.classList.toggle('is-searching', query !== ''); + palette.querySelectorAll('[data-rubric-template-tag]').forEach(function (button) { + var visible = query === '' || button.textContent.toLowerCase().indexOf(query) !== -1; + button.hidden = !visible; + if (visible) { shown++; } + }); + palette.querySelectorAll('[data-rubric-template-tag-group]').forEach(function (group) { + group.hidden = query !== '' && !group.querySelector('[data-rubric-template-tag]:not([hidden])'); + }); + var empty = palette.querySelector('[data-rubric-template-tags-empty]'); + if (empty) { empty.hidden = shown > 0; } + }, + + renderExtraTemplates: function (templates) { + var root = document.querySelector('[data-extra-template-list]'); + var count = document.querySelector('[data-extra-template-count]'); + if (count) { count.textContent = String(templates.length); } + if (!root) { return; } + if (!templates.length) { + root.innerHTML = '
    Дополнительные шаблоны не созданы.
    '; + return; + } + root.innerHTML = templates.map(function (item) { + return '
    ' + + '
    ' + esc(item.title) + 'Шаблон #' + item.id + '
    ' + + '
    ' + esc(item.created_label) + '' + esc(item.size_label) + '' + item.docs_count + ' документов
    ' + + '
    ' + + '' + + (item.can_delete + ? '' + : '') + + '
    '; + }).join(''); + }, + + fillExtraTemplateNew: function () { + this.clearErrors(this.extraTemplateForm); + this.extraTemplateForm.reset(); + this.extraTemplateForm.elements.id.value = ''; + this.extraTemplateForm.elements.rubric_id.value = this.currentRubricId; + this.setCodeValue(this.extraTemplateForm.elements.template, ''); + document.getElementById('rubricExtraTemplateTitle').textContent = 'Новый дополнительный шаблон'; + this.activeTemplateTextarea = this.extraTemplateForm.elements.template; + if (Adminx.Drawer) { Adminx.Drawer.open('rubricExtraTemplateDrawer'); } + this.refreshEditors(); + }, + + fillExtraTemplateEdit: function (row) { + if (!row) { return; } + var self = this; + this.clearErrors(this.extraTemplateForm); + fetch(this.base() + '/rubrics/templates/' + row.getAttribute('data-id'), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (res) { return res.json(); }) + .then(function (json) { + var item = json.data || {}; + self.extraTemplateForm.elements.id.value = item.id || ''; + self.extraTemplateForm.elements.rubric_id.value = item.rubric_id || self.currentRubricId; + self.extraTemplateForm.elements.title.value = item.title || ''; + self.setCodeValue(self.extraTemplateForm.elements.template, item.template || ''); + document.getElementById('rubricExtraTemplateTitle').textContent = 'Редактирование шаблона #' + item.id; + self.activeTemplateTextarea = self.extraTemplateForm.elements.template; + self.refreshEditors(); + }); + }, + + submitExtraTemplate: function () { + var self = this; + var id = this.extraTemplateForm.elements.id.value; + var rubricId = this.extraTemplateForm.elements.rubric_id.value || this.currentRubricId; + this.clearErrors(this.extraTemplateForm); + this.saveEditors(); + this.ajax(this.base() + (id ? '/rubrics/templates/' + id : '/rubrics/' + rubricId + '/templates'), new FormData(this.extraTemplateForm), function () { + Adminx.Toast.show('Дополнительный шаблон сохранён', 'success'); + if (Adminx.Drawer) { Adminx.Drawer.close('rubricExtraTemplateDrawer'); } + self.loadTemplates(); + self.applyFilterUrl(window.location.href, false); + }, function (json) { self.showErrors(self.extraTemplateForm, json); }); + }, + + deleteExtraTemplate: function (row) { + if (!row || !window.confirm('Удалить дополнительный шаблон «' + row.getAttribute('data-title') + '»?')) { return; } + var data = new FormData(); + data.append('_csrf', this.form.elements._csrf.value); + var self = this; + this.ajax(this.base() + '/rubrics/templates/' + row.getAttribute('data-id') + '/delete', data, function () { + Adminx.Toast.show('Дополнительный шаблон удалён', 'success'); + self.loadTemplates(); + self.applyFilterUrl(window.location.href, false); + }); + }, + + insertTemplateTag: function (source) { + var textarea = this.visibleTemplateTextarea() || this.activeTemplateTextarea || (this.templateForm && this.templateForm.elements.rubric_template); + var tag = typeof source === 'string' ? source : source.getAttribute('data-rubric-template-tag'); + var select = typeof source === 'string' ? '' : (source.getAttribute('data-rubric-template-tag-select') || ''); + var cm; + if (!textarea || !tag) { return; } + cm = textarea._adminxCodeMirror; + if (cm) { + var start = cm.indexFromPos(cm.getCursor()); + cm.replaceSelection(tag, 'around'); + cm.focus(); + if (select && tag.indexOf(select) !== -1) { + cm.setSelection(cm.posFromIndex(start + tag.indexOf(select)), cm.posFromIndex(start + tag.indexOf(select) + select.length)); + } else { + cm.setCursor(cm.posFromIndex(start + tag.length)); + } + cm.save(); + this.closeTemplateTagPalette(); + return; + } + var start = textarea.selectionStart || 0; + var end = textarea.selectionEnd || 0; + textarea.value = textarea.value.slice(0, start) + tag + textarea.value.slice(end); + if (select && tag.indexOf(select) !== -1) { + textarea.selectionStart = start + tag.indexOf(select); + textarea.selectionEnd = textarea.selectionStart + select.length; + } else { + textarea.selectionStart = textarea.selectionEnd = start + tag.length; + } + textarea.focus(); + this.closeTemplateTagPalette(); + }, + + setCodeLintResult: function (el, message, kind) { + if (!el) { return; } + el.textContent = message || ''; + el.classList.remove('is-ok', 'is-error'); + if (kind) { el.classList.add(kind === 'error' ? 'is-error' : 'is-ok'); } + }, + + lintCodeField: function (field) { + if (!field) { return; } + var textarea = field.querySelector('textarea[data-code-editor]'); + if (!textarea) { return; } + if (textarea._adminxCodeMirror) { textarea._adminxCodeMirror.save(); } + var result = field.querySelector('[data-rubric-code-lint-result]'); + this.setCodeLintResult(result, 'Проверка...', ''); + var fd = new FormData(); + fd.set('_csrf', Adminx.csrf()); + fd.set('code', textarea.value || ''); + var self = this; + Adminx.Ajax.post(this.base() + '/rubrics/template/lint', fd).then(function (payload) { + var d = payload.data || {}; + if (d.success) { + var okMsg = (d.data && d.data.message) || d.message || 'Синтаксис PHP без ошибок.'; + self.setCodeLintResult(result, okMsg, 'ok'); + Adminx.Toast.show(okMsg, 'success'); + return; + } + var detail = d.errors && d.errors.code ? d.errors.code : (d.message || 'В PHP-коде есть ошибка.'); + self.setCodeLintResult(result, detail, 'error'); + Adminx.Toast.show(d.message || 'В PHP-коде есть ошибка.', 'error'); + }).catch(function () { + self.setCodeLintResult(result, 'Не удалось выполнить проверку.', 'error'); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + toggleCodeFieldFullscreen: function (field, force) { + if (!field) { return; } + var next = typeof force === 'boolean' ? force : !field.classList.contains('is-fullscreen'); + // Одновременно раскрыт только один редактор. + document.querySelectorAll('.rubrics-code-field.is-fullscreen').forEach(function (other) { + if (other !== field) { other.classList.remove('is-fullscreen'); } + }); + field.classList.toggle('is-fullscreen', next); + document.body.classList.toggle('rubrics-code-fullscreen-open', next); + var button = field.querySelector('[data-rubric-code-fullscreen]'); + if (button) { + button.setAttribute('data-tooltip', next ? 'Свернуть редактор' : 'Развернуть редактор'); + button.setAttribute('aria-label', next ? 'Свернуть редактор' : 'Развернуть редактор'); + button.innerHTML = next ? '' : ''; + } + var textarea = field.querySelector('textarea[data-code-editor]'); + var cm = textarea ? textarea._adminxCodeMirror : null; + if (cm) { + var height = parseInt(textarea.getAttribute('data-height'), 10) || 360; + setTimeout(function () { + cm.refresh(); + cm.setSize('100%', next ? '100%' : height); + }, 40); + } + }, + + closeCodeFullscreen: function () { + var field = document.querySelector('.rubrics-code-field.is-fullscreen'); + if (field) { this.toggleCodeFieldFullscreen(field, false); } + }, + + captureCodeMirror: function (wrapper) { + if (!window.Adminx || !Adminx.CodeEditor || !wrapper) { return; } + Adminx.CodeEditor.instances.forEach(function (editor) { + if (editor.getWrapperElement && editor.getWrapperElement() === wrapper) { + var textarea = editor.getTextArea(); + if (textarea && textarea.closest('#rubricTemplateForm, #rubricExtraTemplateForm')) { + Adminx.Rubrics.activeTemplateTextarea = textarea; + } + if (textarea && textarea.closest('#rubricFieldForm')) { + Adminx.Rubrics.activeFieldTemplateTextarea = textarea; + } + } + }); + }, + + visibleTemplateTextarea: function () { + if (this.activeTemplateTextarea && this.activeTemplateTextarea.name && this.isCodeTextareaVisible(this.activeTemplateTextarea)) { + return this.activeTemplateTextarea; + } + var panel = document.querySelector('[data-rubric-template-panel].active'); + var textarea; + if (panel) { + if (panel.getAttribute('data-rubric-template-panel') === 'main' && this.templateForm) { + textarea = this.templateForm.elements.rubric_template; + if (textarea) { return textarea; } + } + textarea = panel.querySelector('textarea[data-code-editor]'); + if (textarea) { return textarea; } + } + if (this.extraTemplateForm && !document.getElementById('rubricExtraTemplateDrawer').hidden) { + return this.extraTemplateForm.elements.template; + } + return null; + }, + + isCodeTextareaVisible: function (textarea) { + var wrapper; + if (!textarea) { return false; } + if (textarea._adminxCodeMirror) { + wrapper = textarea._adminxCodeMirror.getWrapperElement(); + return !!(wrapper && wrapper.offsetParent !== null); + } + return textarea.offsetParent !== null; + }, + + setCodeValue: function (textarea, value) { + if (!textarea) { return; } + textarea.value = value || ''; + if (textarea._adminxCodeMirror) { + textarea._adminxCodeMirror.setValue(value || ''); + textarea._adminxCodeMirror.save(); + } + }, + + saveEditors: function () { + if (!window.Adminx || !Adminx.CodeEditor) { return; } + Adminx.CodeEditor.instances.forEach(function (editor) { editor.save(); }); + }, + + refreshEditors: function () { + if (!window.Adminx || !Adminx.CodeEditor) { return; } + setTimeout(function () { Adminx.CodeEditor.refreshAll(); }, 80); + }, + + openFields: function (row) { + if (!row) { return; } + this.currentRubricId = parseInt(row.getAttribute('data-id'), 10) || 0; + this.loadFields(); + }, + + loadFields: function () { + var self = this; + fetch(this.base() + '/rubrics/' + this.currentRubricId + '/fields', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (res) { return res.json(); }) + .then(function (json) { + var data = json.data || {}; + self.currentGroups = data.groups || []; + self.currentFieldSetLinks = data.field_set_links || []; + self.linkedFieldSetsAvailable = !!data.linked_field_sets_available; + if (!self.linkedFieldSetsAvailable) { self.fieldSetMode = 'copy'; } + self.currentFields = []; + (data.grouped_fields || []).forEach(function (group) { + (group.items || []).forEach(function (field) { self.currentFields.push(field); }); + }); + (data.available_fields || []).forEach(function (field) { self.currentFields.push(field); }); + document.getElementById('rubricFieldsTitle').textContent = 'Поля: ' + ((data.rubric || {}).rubric_title || ''); + var sub = document.querySelector('[data-rubric-fields-subtitle]'); + if (sub) { sub.textContent = 'Рубрика #' + self.currentRubricId + ' · ' + self.currentFields.length + ' полей · форма редактора документа'; } + var conditionsToggle = document.querySelector('[data-builder-conditions-enabled]'); + if (conditionsToggle) { conditionsToggle.checked = !!((data.rubric || {}).form_conditions_enabled); } + self.syncBuilderRuntime(); + self.builderDrafts = {}; + self.builderSelectedId = 0; + self.setBuilderDirty(false); + self.renderGroups(data.grouped_fields || []); + self.syncBuilderFieldSet(); + self.renderLinkedFieldSets(); + self.fillGroupSelect(); + if (Adminx.Drawer) { Adminx.Drawer.open('rubricFieldsDrawer'); } + }); + }, + + renderGroups: function (groups) { + var canvas = ''; + var palette = ''; + var placed = {}; + var availableCount = 0; + var self = this; + var colors = [ + ['var(--blue-100)', 'var(--blue-600)'], + ['var(--green-100)', 'var(--green-600)'], + ['var(--cyan-100)', 'var(--cyan-600)'], + ['var(--amber-100)', 'var(--amber-600)'], + ['var(--blue-50)', 'var(--blue-700)'] + ]; + + groups.forEach(function (group) { + (group.items || []).forEach(function (field) { placed[parseInt(field.Id, 10)] = true; }); + }); + + this.currentFields.forEach(function (field) { + if (placed[parseInt(field.Id, 10)]) { return; } + availableCount++; + palette += self.builderPaletteMarkup(field); + }); + + groups.forEach(function (group, index) { + var color = colors[index % colors.length]; + var groupCondition = group.condition && typeof group.condition === 'object' ? group.condition : {}; + var groupAttrs = group.id ? ' draggable="true" data-group-row data-id="' + group.id + '" data-title="' + esc(group.title) + '" data-description="' + esc(group.description || '') + '" data-condition="' + esc(JSON.stringify(groupCondition)) + '"' : ''; + canvas += '
    ' + + '
    ' + + (group.id ? '' : '') + + '' + + '

    ' + esc(group.id ? group.title : 'Основные поля') + '

    ' + esc(group.description || (group.id ? 'Группа полей документа' : 'Поля без отдельной группы')) + '

    ' + + (groupCondition.tree ? 'условие' : '') + + '' + (group.items || []).length + '' + + (group.id ? '
    ' : '') + + '
    '; + (group.items || []).forEach(function (field) { + canvas += self.builderFieldMarkup(field, group.id); + }); + if (!(group.items || []).length) { + canvas += '
    Перетащите поле в эту группу
    '; + } + canvas += '
    '; + }); + var canvasRoot = document.querySelector('[data-builder-canvas]'); + var paletteRoot = document.querySelector('[data-builder-palette]'); + var count = document.querySelector('[data-builder-field-count]'); + if (canvasRoot) { canvasRoot.innerHTML = canvas || '
    Добавьте первое поле рубрики.
    '; } + if (paletteRoot) { paletteRoot.innerHTML = palette || '
    Все поля размещеныНовых полей для добавления нет.
    '; } + if (count) { count.textContent = availableCount; } + this.updateBuilderGroupCounts(); + }, + + syncBuilderFieldSet: function () { + var select = document.querySelector('[data-builder-field-set]'); + var button = document.querySelector('[data-builder-field-set-apply]'); + var exportButton = document.querySelector('[data-builder-field-set-export]'); + var importButton = document.querySelector('[data-builder-field-set-import]'); + var hint = document.querySelector('[data-builder-field-set-hint]'); + var option = select && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null; + var selectedCode = select ? String(select.value || '') : ''; + var alreadyLinked = this.fieldSetMode === 'linked' && (this.currentFieldSetLinks || []).some(function (link) { + return String(link.code || '') === selectedCode; + }); + if (button) { button.disabled = !select || !selectedCode || !this.currentRubricId || alreadyLinked || (this.fieldSetMode === 'linked' && !this.linkedFieldSetsAvailable); } + if (exportButton) { exportButton.disabled = !this.currentRubricId || !this.currentFields.length; } + if (importButton) { importButton.disabled = !this.currentRubricId; } + document.querySelectorAll('[data-field-set-mode="linked"]').forEach(function (modeButton) { + modeButton.disabled = !Adminx.Rubrics.linkedFieldSetsAvailable; + }); + if (hint) { + var description = option && select.value + ? (option.getAttribute('data-description') || 'Поля будут добавлены в текущую рубрику.') + : 'Готовая группа полей без изменения существующих данных.'; + if (!this.linkedFieldSetsAvailable) { + hint.textContent = 'Связанный режим станет доступен после применения миграций рубрик.'; + } else if (alreadyLinked) { + hint.textContent = 'Этот набор уже связан с рубрикой. Его обновления находятся ниже.'; + } else { + hint.textContent = this.fieldSetMode === 'linked' + ? description + ' Обновления набора можно будет применить после предварительной проверки.' + : description; + } + } + }, + + setBuilderFieldSetMode: function (mode) { + if (mode === 'linked' && !this.linkedFieldSetsAvailable) { + Adminx.Toast.show('Сначала примените миграции рубрик', 'warning'); + return; + } + this.fieldSetMode = mode === 'linked' ? 'linked' : 'copy'; + document.querySelectorAll('[data-field-set-mode]').forEach(function (button) { + button.setAttribute('aria-pressed', button.getAttribute('data-field-set-mode') === Adminx.Rubrics.fieldSetMode ? 'true' : 'false'); + }); + this.syncBuilderFieldSet(); + }, + + renderLinkedFieldSets: function () { + var root = document.querySelector('[data-builder-field-set-links]'); + if (!root) { return; } + var links = this.currentFieldSetLinks || []; + root.hidden = !links.length; + if (!links.length) { root.innerHTML = ''; return; } + root.innerHTML = '' + + links.map(function (link) { + var state = !link.available ? 'Источник недоступен' : (link.update_available ? 'Есть обновление' : 'Актуален'); + var badge = !link.available ? 'badge-red' : (link.update_available ? 'badge-amber' : 'badge-green'); + return '
    ' + esc(link.title || link.code) + '' + esc(link.code) + '' + + '' + state + '' + + '
    ' + (link.available && link.update_available ? '' : '') + + '
    '; + }).join(''); + }, + + applyBuilderFieldSet: function () { + var select = document.querySelector('[data-builder-field-set]'); + var button = document.querySelector('[data-builder-field-set-apply]'); + var code = select ? String(select.value || '') : ''; + if (!code || !this.currentRubricId) { return; } + if (this.builderDirty) { Adminx.Toast.show('Сначала сохраните изменения конструктора', 'warning'); return; } + var self = this; + if (button) { button.disabled = true; button.classList.add('is-loading'); } + fetch(this.base() + '/rubrics/' + this.currentRubricId + '/field-sets/' + encodeURIComponent(code) + '/preview', { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }).then(function (response) { + return response.json(); + }).then(function (json) { + var preview = json.data || {}; + if (!json.success) { throw new Error(json.message || 'Не удалось проверить набор'); } + if (preview.conflicts && preview.conflicts.length) { + Adminx.Toast.show('Конфликт полей: ' + preview.conflicts.join(', '), 'error'); + return; + } + + Adminx.Confirm.open({ + kind: 'warning', + title: 'Добавить набор «' + (preview.title || code) + '»?', + message: 'Будет создано полей: ' + (preview.create || 0) + '. Уже есть и останутся без изменений: ' + (preview.reuse || 0) + '.', + confirmLabel: 'Добавить набор', + onConfirm: function () { + var data = new FormData(); + data.append('_csrf', self.form.elements._csrf.value); + data.append('mode', self.fieldSetMode); + self.ajax(self.base() + '/rubrics/' + self.currentRubricId + '/field-sets/' + encodeURIComponent(code) + '/apply', data, function (result) { + Adminx.Toast.show(result.message || 'Набор полей добавлен', 'success'); + select.value = ''; + self.loadFields(); + self.applyFilterUrl(window.location.href, false); + }); + } + }); + }).catch(function (error) { + Adminx.Toast.show(error.message || 'Не удалось проверить набор', 'error'); + }).then(function () { + if (button) { button.classList.remove('is-loading'); } + self.syncBuilderFieldSet(); + }); + }, + + exportBuilderFieldSet: function () { + if (!this.currentRubricId || !this.currentFields.length) { return; } + if (this.builderDirty) { Adminx.Toast.show('Сначала сохраните изменения конструктора', 'warning'); return; } + var button = document.querySelector('[data-builder-field-set-export]'); + if (button) { button.disabled = true; button.classList.add('is-loading'); } + var self = this; + fetch(this.base() + '/rubrics/' + this.currentRubricId + '/field-sets/export', { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }).then(function (response) { + return response.json(); + }).then(function (json) { + if (!json.success) { throw new Error(json.message || 'Не удалось подготовить набор'); } + var data = json.data || {}; + var blob = new Blob([JSON.stringify(data.descriptor || {}, null, 2) + '\n'], { type: 'application/json;charset=utf-8' }); + var url = URL.createObjectURL(blob); + var link = document.createElement('a'); + link.href = url; + link.download = data.filename || ('rubric-' + self.currentRubricId + '-field-set.json'); + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(function () { URL.revokeObjectURL(url); }, 1000); + Adminx.Toast.show(json.message || 'Набор полей экспортирован', 'success'); + }).catch(function (error) { + Adminx.Toast.show(error.message || 'Не удалось экспортировать набор', 'error'); + }).then(function () { + if (button) { button.classList.remove('is-loading'); } + self.syncBuilderFieldSet(); + }); + }, + + openBuilderFieldSetImport: function () { + if (!this.currentRubricId) { return; } + if (this.builderDirty) { Adminx.Toast.show('Сначала сохраните изменения конструктора', 'warning'); return; } + var self = this; + var overlay = document.createElement('div'); + overlay.className = 'overlay rubrics-field-set-overlay'; + overlay.innerHTML = ''; + document.body.appendChild(overlay); + requestAnimationFrame(function () { overlay.classList.add('show'); }); + var fileInput = overlay.querySelector('[data-field-set-import-file]'); + var dropzone = overlay.querySelector('[data-field-set-dropzone]'); + var previewRoot = overlay.querySelector('[data-field-set-import-preview]'); + var stateRoot = overlay.querySelector('[data-field-set-import-state]'); + var applyButton = overlay.querySelector('[data-field-set-import-apply]'); + var nameRoot = overlay.querySelector('[data-field-set-import-name]'); + var descriptor = ''; + var fingerprint = ''; + var importMode = self.fieldSetMode; + var importHasConflicts = false; + var close = function () { + overlay.classList.remove('show'); + setTimeout(function () { overlay.remove(); }, 160); + }; + var showError = function (message) { + fingerprint = ''; + importHasConflicts = true; + applyButton.disabled = true; + previewRoot.hidden = false; + previewRoot.innerHTML = '
    Набор не готов к импорту

    ' + esc(message) + '

    '; + stateRoot.hidden = true; + }; + var preview = function (file) { + if (!file) { return; } + nameRoot.textContent = file.name || 'field-set.json'; + if (file.size > 262144) { showError('Файл превышает 256 КБ'); return; } + var reader = new FileReader(); + reader.onerror = function () { showError('Не удалось прочитать выбранный файл'); }; + reader.onload = function () { + descriptor = String(reader.result || ''); + stateRoot.hidden = false; + stateRoot.classList.add('is-loading'); + stateRoot.innerHTML = 'Проверяем наборСравниваем alias и типы полей.'; + previewRoot.hidden = true; + applyButton.disabled = true; + var data = new FormData(); + data.append('_csrf', self.form.elements._csrf.value); + data.append('descriptor', descriptor); + fetch(self.base() + '/rubrics/' + self.currentRubricId + '/field-sets/import/preview', { + method: 'POST', body: data, headers: { 'Accept': 'application/json' }, credentials: 'same-origin' + }).then(function (response) { return response.json(); }).then(function (json) { + if (!json.success) { throw new Error(json.message || 'Набор не прошёл проверку'); } + var result = json.data || {}; + fingerprint = result.fingerprint || ''; + var conflicts = result.conflicts || []; + importHasConflicts = !!conflicts.length; + stateRoot.hidden = true; + previewRoot.hidden = false; + previewRoot.innerHTML = '
    ' + esc(result.title || 'Набор полей') + '' + esc(result.description || 'Описание не задано') + '
    ' + + '
    ' + (result.field_count || 0) + 'всего полей' + (result.create || 0) + 'будет создано' + (result.reuse || 0) + 'уже существует' + conflicts.length + 'конфликтов
    ' + + '
    После импорта
    ' + + (conflicts.length ? '
    Конфликт типов

    ' + esc(conflicts.join(', ')) + '

    ' : '
    ' + (result.source_schema_match ? 'Исходная схема распознана' : 'Режим копии') + '

    ' + (result.source_schema_match ? 'Поля исходной рубрики будут переиспользованы без дублей.' : 'Созданные поля станут независимой частью текущей рубрики.' + (result.generated_aliases ? ' Для полей без системного имени подготовлено alias: ' + result.generated_aliases + '.' : '')) + '

    '); + applyButton.disabled = !!conflicts.length || !fingerprint || (!(parseInt(result.create, 10) > 0) && importMode !== 'linked'); + }).catch(function (error) { + showError(error.message || 'Набор не прошёл проверку'); + }).then(function () { + stateRoot.classList.remove('is-loading'); + }); + }; + reader.readAsText(file, 'UTF-8'); + }; + fileInput.addEventListener('change', function () { preview(fileInput.files && fileInput.files[0]); }); + dropzone.addEventListener('dragover', function (event) { event.preventDefault(); dropzone.classList.add('is-dragover'); }); + dropzone.addEventListener('dragleave', function () { dropzone.classList.remove('is-dragover'); }); + dropzone.addEventListener('drop', function (event) { + event.preventDefault(); + dropzone.classList.remove('is-dragover'); + preview(event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files[0] : null); + }); + overlay.addEventListener('click', function (event) { + var modeButton = event.target.closest('[data-import-field-set-mode]'); + if (modeButton && !modeButton.disabled) { + importMode = modeButton.getAttribute('data-import-field-set-mode') === 'linked' ? 'linked' : 'copy'; + overlay.querySelectorAll('[data-import-field-set-mode]').forEach(function (button) { button.setAttribute('aria-pressed', button === modeButton ? 'true' : 'false'); }); + applyButton.disabled = !fingerprint || importHasConflicts; + return; + } + if (event.target === overlay || event.target.closest('[data-field-set-import-close]')) { close(); return; } + if (!event.target.closest('[data-field-set-import-apply]') || applyButton.disabled) { return; } + applyButton.disabled = true; + applyButton.classList.add('is-loading'); + var data = new FormData(); + data.append('_csrf', self.form.elements._csrf.value); + data.append('descriptor', descriptor); + data.append('fingerprint', fingerprint); + data.append('mode', importMode); + fetch(self.base() + '/rubrics/' + self.currentRubricId + '/field-sets/import/apply', { + method: 'POST', body: data, headers: { 'Accept': 'application/json' }, credentials: 'same-origin' + }).then(function (response) { return response.json(); }).then(function (json) { + if (!json.success) { throw new Error(json.message || 'Не удалось импортировать набор'); } + Adminx.Toast.show(json.message || 'Набор полей импортирован', 'success'); + close(); + self.loadFields(); + self.applyFilterUrl(window.location.href, false); + }).catch(function (error) { + showError(error.message || 'Не удалось импортировать набор'); + }).then(function () { + applyButton.classList.remove('is-loading'); + }); + }); + }, + + previewLinkedFieldSet: function (code) { + if (!code || !this.currentRubricId) { return; } + var self = this; + fetch(this.base() + '/rubrics/' + this.currentRubricId + '/field-set-links/' + encodeURIComponent(code) + '/preview', { + headers: { 'Accept': 'application/json' }, credentials: 'same-origin' + }).then(function (response) { return response.json(); }).then(function (json) { + if (!json.success) { throw new Error(json.message || 'Не удалось проверить набор'); } + var data = json.data || {}; + if (data.conflicts && data.conflicts.length) { + Adminx.Toast.show('Нужна ручная проверка: ' + data.conflicts.join(', '), 'error'); + return; + } + Adminx.Confirm.open({ + kind: 'warning', + title: 'Синхронизировать «' + (data.title || code) + '»?', + message: 'Новых полей: ' + (data.created || 0) + '. Обновляемых: ' + (data.updated || 0) + '. Снятых с формы: ' + (data.detached || 0) + '. Локальные настройки будут сохранены.', + confirmLabel: 'Синхронизировать', + onConfirm: function () { + var form = new FormData(); + form.append('_csrf', self.form.elements._csrf.value); + form.append('fingerprint', data.fingerprint || ''); + self.ajax(self.base() + '/rubrics/' + self.currentRubricId + '/field-set-links/' + encodeURIComponent(code) + '/sync', form, function (result) { + Adminx.Toast.show(result.message || 'Набор синхронизирован', 'success'); + self.loadFields(); + }); + } + }); + }).catch(function (error) { Adminx.Toast.show(error.message || 'Не удалось проверить набор', 'error'); }); + }, + + detachLinkedFieldSet: function (code) { + if (!code || !this.currentRubricId) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить связь с набором?', + message: 'Поля и данные документов останутся в рубрике, но больше не будут получать обновления набора.', + confirmLabel: 'Удалить связь', + onConfirm: function () { + var form = new FormData(); + form.append('_csrf', self.form.elements._csrf.value); + self.ajax(self.base() + '/rubrics/' + self.currentRubricId + '/field-set-links/' + encodeURIComponent(code) + '/detach', form, function (result) { + Adminx.Toast.show(result.message || 'Связь удалена', 'success'); + self.loadFields(); + }); + } + }); + }, + + builderPaletteMarkup: function (field) { + return ''; + }, + + builderFieldMarkup: function (field, groupId) { + var layout = field.layout || {}; + var width = layout.width || this.builderAutoWidth(field); + var conditional = field.condition && field.condition.tree && Array.isArray(field.condition.tree.items) && field.condition.tree.items.length; + return '
    ' + + '
    ' + + '' + esc(field.type_label) + '' + + (conditional ? '' : '') + + '' + this.builderWidthLabel(width) + '
    ' + + '
    ' + esc(field.rubric_field_title) + (layout.required ? '*' : '') + '' + esc(field.rubric_field_alias || ('field_' + field.Id)) + '
    ' + + '
    ' + (layout.prefix ? '' + esc(layout.prefix) + '' : '') + '' + esc(this.builderPlaceholder(field)) + '' + (layout.suffix ? '' + esc(layout.suffix) + '' : '') + '
    ' + + '
    ' + + '
    '; + }, + + refreshBuilderPalette: function () { + var root = document.querySelector('[data-builder-palette]'); + var count = document.querySelector('[data-builder-field-count]'); + if (!root) { return; } + var items = root.querySelectorAll('[data-builder-palette-item]'); + if (!items.length) { + root.innerHTML = '
    Все поля размещеныНовых полей для добавления нет.
    '; + } + if (count) { count.textContent = items.length; } + var search = document.querySelector('[data-builder-search]'); + if (search && search.value) { this.filterBuilderPalette(search.value); } + }, + + resetBuilderInspector: function () { + this.builderSelectedId = 0; + document.querySelectorAll('[data-builder-select].is-selected').forEach(function (node) { node.classList.remove('is-selected'); }); + var empty = document.querySelector('[data-builder-inspector-empty]'); + if (empty) { empty.hidden = false; } + if (this.builderForm) { this.builderForm.hidden = true; } + }, + + unplaceBuilderField: function (row) { + if (!row) { return; } + var id = parseInt(row.getAttribute('data-id'), 10) || 0; + var field = this.builderField(id); + var palette = document.querySelector('[data-builder-palette]'); + if (!field || !palette) { return; } + if (this.builderSelectedId === id) { this.captureBuilderInspector(); } + if (!palette.querySelector('[data-builder-palette-item][data-id="' + id + '"]')) { + var empty = palette.querySelector('.rubrics-builder-palette-empty'); + if (empty) { empty.remove(); } + palette.insertAdjacentHTML('beforeend', this.builderPaletteMarkup(field)); + } + field.layout = field.layout || {}; + field.layout.placed = false; + row.remove(); + this.resetBuilderInspector(); + this.cleanupBuilderEmptyStates(); + this.updateBuilderGroupCounts(); + this.refreshBuilderPalette(); + this.setBuilderDirty(true); + }, + + placeBuilderField: function (id, groupId, beforeNode) { + id = parseInt(id, 10) || 0; + groupId = parseInt(groupId, 10) || 0; + var field = this.builderField(id); + var target = document.querySelector('[data-builder-canvas] [data-field-group-drop="' + groupId + '"] [data-field-sortable]'); + if (!field || !target) { return; } + var existing = document.querySelector('[data-builder-canvas] [data-field-row][data-id="' + id + '"]'); + if (existing) { this.selectBuilderField(id); return; } + var empty = target.querySelector('.rubrics-builder-drop-empty'); + if (empty) { empty.remove(); } + target.insertAdjacentHTML('beforeend', this.builderFieldMarkup(field, groupId)); + var row = target.querySelector('[data-field-row][data-id="' + id + '"]'); + if (row && beforeNode && beforeNode.parentNode === target) { target.insertBefore(row, beforeNode); } + var paletteItem = document.querySelector('[data-builder-palette-item][data-id="' + id + '"]'); + if (paletteItem) { paletteItem.remove(); } + field.layout = field.layout || {}; + field.layout.placed = true; + field.rubric_field_group = groupId; + this.refreshBuilderPalette(); + this.cleanupBuilderEmptyStates(); + this.updateBuilderGroupCounts(); + this.selectBuilderField(id); + this.setBuilderDirty(true); + }, + + builderField: function (id) { + id = parseInt(id, 10) || 0; + for (var i = 0; i < this.currentFields.length; i++) { + if (parseInt(this.currentFields[i].Id, 10) === id) { return this.currentFields[i]; } + } + return null; + }, + + builderTypeIcon: function (type) { + type = String(type || ''); + if (type.indexOf('image') !== -1 || type.indexOf('file') !== -1) { return 'ti-photo'; } + if (type.indexOf('date') !== -1) { return type === 'date_time' ? 'ti-calendar-time' : 'ti-calendar'; } + if (type.indexOf('checkbox') !== -1 || type === 'boolean') { return 'ti-checkbox'; } + if (type.indexOf('dropdown') !== -1 || type.indexOf('select') !== -1) { return 'ti-list'; } + if (type.indexOf('rub') !== -1 || type.indexOf('doc_') !== -1) { return 'ti-link'; } + if (type === 'contact') { return 'ti-address-book'; } + if (type === 'color') { return 'ti-palette'; } + if (type === 'range') { return 'ti-arrows-horizontal'; } + if (type === 'dimensions') { return 'ti-box-model-2'; } + if (type === 'packages') { return 'ti-packages'; } + if (type === 'address') { return 'ti-map-pin'; } + if (type === 'period') { return 'ti-calendar-event'; } + if (type.indexOf('numeric') !== -1 || type.indexOf('number') !== -1) { return 'ti-123'; } + if (type.indexOf('text') !== -1 || type.indexOf('editor') !== -1) { return 'ti-align-left'; } + return 'ti-cursor-text'; + }, + + builderAutoWidth: function (field) { + var type = String((field || {}).rubric_field_type || ''); + var narrow = ['boolean', 'date', 'date_time', 'single_line', 'single_line_numeric', 'number', 'range', 'dropdown', 'drop_down', 'drop_down_key', 'doc_from_rub']; + return narrow.indexOf(type) !== -1 ? 'half' : 'full'; + }, + + builderWidthLabel: function (width) { + return ({ full: '12/12', half: '6/12', third: '4/12', quarter: '3/12' })[width] || '12/12'; + }, + + builderPlaceholder: function (field) { + var type = String((field || {}).rubric_field_type || ''); + if (type.indexOf('image') !== -1 || type.indexOf('file') !== -1) { return 'Выбор файла'; } + if (type.indexOf('checkbox') !== -1 || type === 'boolean') { return 'Переключатель'; } + if (type.indexOf('dropdown') !== -1 || type.indexOf('select') !== -1) { return 'Выбор значения'; } + if (type === 'date_time') { return 'Дата и время'; } + if (type === 'contact') { return 'Email, телефон или ссылка'; } + if (type === 'color') { return '#rrggbb'; } + if (type === 'range') { return 'От — до'; } + if (type === 'dimensions') { return 'Д × Ш × В'; } + if (type === 'packages') { return 'Коробки: Д × Ш × В и вес'; } + if (type === 'address') { return 'Город, улица, дом'; } + if (type === 'period') { return 'Начало — окончание'; } + if (type.indexOf('date') !== -1) { return 'ДД.ММ.ГГГГ'; } + if (type.indexOf('text') !== -1 || type.indexOf('editor') !== -1) { return 'Текстовое содержимое'; } + return 'Значение поля'; + }, + + selectBuilderField: function (id) { + id = parseInt(id, 10) || 0; + var field = this.builderField(id); + var form = this.builderForm; + if (!field || !form) { return; } + if (this.builderSelectedId && this.builderSelectedId !== id) { this.captureBuilderInspector(false); } + this.builderSelectedId = id; + document.querySelectorAll('[data-builder-select]').forEach(function (node) { + node.classList.toggle('is-selected', parseInt(node.getAttribute('data-id'), 10) === id); + }); + + var draft = this.builderDrafts[id] || {}; + var layout = field.layout || (field.layout = {}); + var row = document.querySelector('[data-builder-canvas] [data-field-row][data-id="' + id + '"]'); + var width = row ? row.getAttribute('data-width') : (layout.width || this.builderAutoWidth(field)); + var empty = document.querySelector('[data-builder-inspector-empty]'); + if (empty) { empty.hidden = true; } + form.hidden = false; + this.builderHydrating = true; + var typeLabel = form.querySelector('[data-builder-type-label]'); + var typeCode = form.querySelector('[data-builder-type-code]'); + if (typeLabel) { typeLabel.textContent = field.type_label || field.rubric_field_type || 'Неизвестный тип'; } + if (typeCode) { typeCode.textContent = field.rubric_field_type || '—'; } + form.elements.title.value = Object.prototype.hasOwnProperty.call(draft, 'title') ? draft.title : (field.rubric_field_title || ('Поле #' + id)); + this.fillBuilderGroupSelect(row ? row.closest('[data-field-group-drop]').getAttribute('data-field-group-drop') : field.rubric_field_group); + var widthInput = form.querySelector('input[name="width"][value="' + width + '"]'); + if (widthInput) { widthInput.checked = true; } + form.elements.prefix.value = layout.prefix || ''; + form.elements.suffix.value = layout.suffix || ''; + form.elements.default.value = Object.prototype.hasOwnProperty.call(draft, 'default') ? draft.default : (field.rubric_field_default || ''); + form.elements.description.value = Object.prototype.hasOwnProperty.call(draft, 'description') ? draft.description : (field.rubric_field_description || ''); + form.elements.search.checked = Object.prototype.hasOwnProperty.call(draft, 'search') ? !!draft.search : String(field.rubric_field_search) === '1'; + form.elements.numeric.checked = Object.prototype.hasOwnProperty.call(draft, 'numeric') ? !!draft.numeric : String(field.rubric_field_numeric) === '1'; + var nativeNumeric = ['number', 'date_time', 'period'].indexOf(String(field.rubric_field_type || '')) !== -1; + if (nativeNumeric) { form.elements.numeric.checked = true; } + form.elements.numeric.disabled = nativeNumeric; + form.elements.numeric.title = nativeNumeric ? 'Этот тип всегда использует числовой индекс' : ''; + this.hydrateBuilderCondition(field, Object.prototype.hasOwnProperty.call(draft, 'condition') ? draft.condition : (field.condition || {})); + this.builderHydrating = false; + this.loadBuilderTypeSettings(field, draft.settings || null); + if (row && row.scrollIntoView) { row.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } + }, + + fillBuilderGroupSelect: function (selected) { + var select = this.builderForm && this.builderForm.querySelector('[data-builder-group]'); + if (!select) { return; } + var html = ''; + this.currentGroups.forEach(function (group) { + html += ''; + }); + select.innerHTML = html; + select.value = String(parseInt(selected, 10) || 0); + }, + + loadBuilderTypeSettings: function (field, draftSettings) { + var self = this; + var root = document.querySelector('[data-builder-type-settings]'); + if (!root || !field) { return; } + this.builderSettingsReady = false; + this.toggleBuilderDefaultField({}); + root.innerHTML = '
    Загрузка настроек типа...
    '; + fetch(this.base() + '/rubrics/fields/' + field.Id + '/plugin', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (res) { return res.json(); }) + .then(function (json) { + if (self.builderSelectedId !== parseInt(field.Id, 10)) { return; } + var data = json.data || {}; + var type = data.type || {}; + root.innerHTML = type.settings_html + ? '
    Настройки типа
    ' + type.settings_html + : '

    У этого типа нет дополнительных настроек.

    '; + if (draftSettings !== null) { self.applyBuilderSettingsDraft(root, draftSettings); } + self.hydrateSettingsMaps(root); + self.toggleBuilderDefaultField(type.admin_editor || {}); + self.builderSettingsReady = true; + }) + .catch(function () { root.innerHTML = '

    Не удалось загрузить настройки типа.

    '; }); + }, + + applyBuilderSettingsDraft: function (root, settings) { + Object.keys(settings || {}).forEach(function (key) { + var controls = root.querySelectorAll('[name="rubric_field_settings[' + key.replace(/"/g, '\\"') + ']"]'); + controls.forEach(function (control) { + if (control.type === 'checkbox') { control.checked = !!settings[key] && settings[key] !== '0'; } + else if (Array.isArray(settings[key])) { control.value = settings[key].map(function (item) { return typeof item === 'object' ? ((item.value || '') + '=' + (item.label || '')) : item; }).join('\n'); } + else { control.value = settings[key] == null ? '' : settings[key]; } + }); + }); + }, + + captureBuilderInspector: function (markDirty) { + if (this.builderHydrating || !this.builderSelectedId || !this.builderForm || this.builderForm.hidden) { return; } + var form = this.builderForm; + var field = this.builderField(this.builderSelectedId); + if (!field) { return; } + var settings = {}; + form.querySelectorAll('[name^="rubric_field_settings["]').forEach(function (control) { + var match = control.name.match(/^rubric_field_settings\[([^\]]+)\]$/); + if (!match) { return; } + if (control.type === 'checkbox') { + if (control.checked) { settings[match[1]] = control.value || '1'; } + else if (!Object.prototype.hasOwnProperty.call(settings, match[1])) { settings[match[1]] = '0'; } + } else { + settings[match[1]] = control.value; + } + }); + var layout = field.layout || (field.layout = {}); + layout.width = (form.querySelector('input[name="width"]:checked') || {}).value || 'full'; + layout.prefix = form.elements.prefix.value.trim(); + layout.suffix = form.elements.suffix.value.trim(); + var draft = { + id: this.builderSelectedId, + title: form.elements.title.value.trim(), + default: form.elements.default.value, + description: form.elements.description.value, + search: form.elements.search.checked ? 1 : 0, + numeric: form.elements.numeric.checked ? 1 : 0 + }; + draft.condition = this.captureBuilderCondition(); + if (this.builderSettingsReady) { + draft.settings = settings; + } else if (this.builderDrafts[this.builderSelectedId] && Object.prototype.hasOwnProperty.call(this.builderDrafts[this.builderSelectedId], 'settings')) { + draft.settings = this.builderDrafts[this.builderSelectedId].settings; + } + this.builderDrafts[this.builderSelectedId] = draft; + field.condition = draft.condition; + if (draft.title) { field.rubric_field_title = draft.title; } + this.updateBuilderFieldCard(field); + if (markDirty !== false) { this.setBuilderDirty(true); } + }, + + builderConditionOperators: function () { + return [ + ['equals', 'Равно'], ['not_equals', 'Не равно'], ['contains', 'Содержит'], ['not_contains', 'Не содержит'], + ['empty', 'Не заполнено'], ['not_empty', 'Заполнено'], ['greater', 'Больше'], ['greater_or_equal', 'Больше или равно'], + ['less', 'Меньше'], ['less_or_equal', 'Меньше или равно'], ['in', 'Одно из'], ['not_in', 'Не входит в'] + ]; + }, + + builderConditionFieldOptions: function (selected, current) { + current = parseInt(current, 10) || 0; + var html = ''; + this.currentFields.forEach(function (field) { + if (parseInt(field.Id, 10) === parseInt(current, 10)) { return; } + var alias = String(field.rubric_field_alias || '').trim().toLowerCase(); + var reference = 'id:' + parseInt(field.Id, 10); + var label = String(field.rubric_field_title || field.rubric_field_alias || ('Поле #' + field.Id)); + html += ''; + }); + return html; + }, + + builderConditionRowMarkup: function (rule, current) { + rule = rule || {}; + var operator = String(rule.operator || 'equals'); + var options = this.builderConditionOperators().map(function (item) { + return ''; + }).join(''); + var valueHidden = operator === 'empty' || operator === 'not_empty'; + return '
    ' + + '' + + '' + + '' + + '' + + '
    '; + }, + + builderConditionGroupMarkup: function (group, depth, isRoot, current) { + group = group && typeof group === 'object' ? group : {}; + depth = Math.max(1, parseInt(depth, 10) || 1); + var operator = group.operator === 'or' ? 'or' : 'and'; + var items = Array.isArray(group.items) ? group.items : []; + var self = this; + var body = items.map(function (item) { + return item && Array.isArray(item.items) + ? self.builderConditionGroupMarkup(item, depth + 1, false, current) + : self.builderConditionRowMarkup(item || {}, current); + }).join(''); + var title = isRoot ? 'Основная группа' : 'Вложенная группа'; + var hint = operator === 'or' ? 'Достаточно любого правила внутри' : 'Должны совпасть все правила внутри'; + var tools = ''; + if (depth < 4) { + tools += ''; + } + if (!isRoot) { + tools += ''; + } + + return '
    ' + + '
    ' + title + '' + hint + '
    ' + + '' + + '
    ' + tools + '
    ' + + '
    ' + body + '
    '; + }, + + hydrateBuilderConditionRoot: function (root, condition, current) { + if (!root) { return; } + condition = condition && typeof condition === 'object' ? condition : {}; + var tree = condition.tree && typeof condition.tree === 'object' ? condition.tree : {}; + var items = Array.isArray(tree.items) ? tree.items : []; + root._conditionOriginal = condition; + root.querySelector('[data-builder-condition-enabled]').checked = !!items.length; + root.querySelector('[data-builder-condition-mode]').value = ['show', 'hide', 'lock'].indexOf(condition.mode) !== -1 ? condition.mode : 'show'; + var required = root.querySelector('[data-builder-condition-required]'); + if (required) { required.checked = !!condition.required; } + var rows = root.querySelector('[data-builder-condition-rows]'); + if (rows) { + rows.innerHTML = this.builderConditionGroupMarkup({ operator: tree.operator, items: items }, 1, true, current); + } + this.syncBuilderCondition(root); + }, + + hydrateBuilderCondition: function (field, condition) { + var root = this.builderForm ? this.builderForm.querySelector('[data-builder-condition]') : null; + this.hydrateBuilderConditionRoot(root, condition, this.builderSelectedId); + this.hydrateBuilderConditionOptions(root, field, condition); + this.hydrateBuilderConditionAction(root, field, condition); + }, + + hydrateBuilderConditionOptions: function (root, field, condition) { + if (!root) { return; } + var section = root.querySelector('[data-builder-condition-values]'); + var list = root.querySelector('[data-builder-condition-options]'); + var toggle = root.querySelector('[data-builder-condition-options-enabled]'); + if (!section || !list || !toggle) { return; } + var options = field && Array.isArray(field.condition_options) ? field.condition_options : []; + var allowed = condition && Array.isArray(condition.allowed_values) ? condition.allowed_values.map(String) : []; + section.hidden = options.length === 0; + toggle.checked = allowed.length > 0; + list.hidden = !toggle.checked; + list.innerHTML = options.map(function (option) { + var value = String(option.value == null ? '' : option.value); + var label = String(option.label == null ? value : option.label); + return ''; + }).join(''); + }, + + hydrateBuilderConditionAction: function (root, field, condition) { + if (!root) { return; } + var section = root.querySelector('[data-builder-condition-action]'); + var action = root.querySelector('[data-builder-condition-value-action]'); + var row = root.querySelector('[data-builder-condition-action-value]'); + var select = root.querySelector('[data-builder-condition-action-select]'); + var input = root.querySelector('[data-builder-condition-action-input]'); + if (!section || !action || !row || !select || !input) { return; } + var supported = !!(field && field.condition_value_action); + var options = field && Array.isArray(field.condition_action_options) ? field.condition_action_options : []; + var value = condition && Object.prototype.hasOwnProperty.call(condition, 'action_value') ? String(condition.action_value) : ''; + var selectedAction = condition && ['set', 'clear'].indexOf(String(condition.value_action || '')) !== -1 ? String(condition.value_action) : ''; + section.hidden = !supported; + action.value = supported ? selectedAction : ''; + select.innerHTML = options.map(function (option) { + var optionValue = String(option.value == null ? '' : option.value); + var optionLabel = String(option.label == null ? optionValue : option.label); + return ''; + }).join(''); + select.hidden = options.length === 0; + input.hidden = options.length > 0; + if (options.length) { select.value = value; } + else { input.value = value; } + row.hidden = !supported || selectedAction !== 'set'; + }, + + syncBuilderConditionOptionSource: function (raw) { + var field = this.builderField(this.builderSelectedId); + var root = this.builderForm ? this.builderForm.querySelector('[data-builder-condition]') : null; + if (!field || !root) { return; } + var current = this.captureBuilderConditionRoot(root); + var source = String(raw || '').replace(/\r\n?/g, '\n').split('\n').map(function (line) { return line.trim(); }).filter(Boolean); + field.condition_options = source.map(function (line) { + var match = line.match(/^([^=:|]+)\s*(?:=>|=|:|\|)\s*(.+)$/); + return match + ? { value: match[1].trim(), label: match[2].trim() } + : { value: line, label: line }; + }); + field.condition_action_options = field.condition_options; + this.hydrateBuilderConditionOptions(root, field, current); + this.hydrateBuilderConditionAction(root, field, current); + this.syncBuilderCondition(root); + }, + + syncBuilderCondition: function (root) { + root = root || (this.builderForm ? this.builderForm.querySelector('[data-builder-condition]') : null); + if (!root) { return; } + var enabled = root.querySelector('[data-builder-condition-enabled]').checked; + var body = root.querySelector('[data-builder-condition-body]'); + if (body) { body.hidden = !enabled; } + var optionToggle = root.querySelector('[data-builder-condition-options-enabled]'); + var optionList = root.querySelector('[data-builder-condition-options]'); + if (optionToggle && optionList) { + optionList.hidden = !optionToggle.checked; + if (optionToggle.checked && !optionList.querySelector('[data-builder-condition-option]:checked')) { + optionList.querySelectorAll('[data-builder-condition-option]').forEach(function (control) { control.checked = true; }); + } + } + var action = root.querySelector('[data-builder-condition-value-action]'); + var actionValue = root.querySelector('[data-builder-condition-action-value]'); + if (action && actionValue) { actionValue.hidden = action.value !== 'set'; } + root.querySelectorAll('[data-builder-condition-row]').forEach(function (row) { + var operator = row.querySelector('[data-builder-condition-operator]'); + var value = row.querySelector('[data-builder-condition-value]'); + if (value) { value.hidden = operator && (operator.value === 'empty' || operator.value === 'not_empty'); } + }); + root.querySelectorAll('[data-builder-condition-group]').forEach(function (group) { + var join = group.querySelector(':scope > header [data-builder-condition-join]'); + var hint = group.querySelector(':scope > header [data-builder-condition-group-hint]'); + if (join && hint) { hint.textContent = join.value === 'or' ? 'Достаточно любого правила внутри' : 'Должны совпасть все правила внутри'; } + }); + if (enabled && !root.querySelector('[data-builder-condition-row]')) { + this.addBuilderConditionRow(root.querySelector('[data-builder-condition-group]'), false); + } + }, + + captureBuilderConditionGroup: function (group) { + if (!group) { return { operator: 'and', items: [] }; } + var join = group.querySelector(':scope > header [data-builder-condition-join]'); + var itemsRoot = group.querySelector(':scope > [data-builder-condition-items]'); + var items = []; + Array.prototype.forEach.call(itemsRoot ? itemsRoot.children : [], function (item) { + if (item.matches('[data-builder-condition-group]')) { + items.push(Adminx.Rubrics.captureBuilderConditionGroup(item)); + return; + } + if (!item.matches('[data-builder-condition-row]')) { return; } + var field = item.querySelector('[data-builder-condition-field]'); + var operator = item.querySelector('[data-builder-condition-operator]'); + var value = item.querySelector('[data-builder-condition-value]'); + if (!field || !field.value) { return; } + items.push({ + field: field.value, + operator: operator ? operator.value : 'equals', + value: value && !value.hidden ? value.value : '' + }); + }); + + return { operator: join && join.value === 'or' ? 'or' : 'and', items: items }; + }, + + captureBuilderConditionRoot: function (root) { + if (!root || !root.querySelector('[data-builder-condition-enabled]').checked) { return {}; } + var required = root.querySelector('[data-builder-condition-required]'); + var result = { + enabled: true, + mode: ['show', 'hide', 'lock'].indexOf(root.querySelector('[data-builder-condition-mode]').value) !== -1 + ? root.querySelector('[data-builder-condition-mode]').value + : 'show', + required: required ? required.checked : false, + tree: this.captureBuilderConditionGroup(root.querySelector('[data-builder-condition-group]')) + }; + var optionToggle = root.querySelector('[data-builder-condition-options-enabled]'); + if (optionToggle && optionToggle.checked) { + result.allowed_values = Array.prototype.map.call( + root.querySelectorAll('[data-builder-condition-option]:checked'), + function (control) { return control.value; } + ); + } + var action = root.querySelector('[data-builder-condition-value-action]'); + if (action && ['set', 'clear'].indexOf(action.value) !== -1) { + result.value_action = action.value; + if (action.value === 'set') { + var actionSelect = root.querySelector('[data-builder-condition-action-select]'); + var actionInput = root.querySelector('[data-builder-condition-action-input]'); + result.action_value = actionSelect && !actionSelect.hidden ? actionSelect.value : (actionInput ? actionInput.value : ''); + } + } + + return result; + }, + + captureBuilderCondition: function () { + return this.captureBuilderConditionRoot(this.builderForm ? this.builderForm.querySelector('[data-builder-condition]') : null); + }, + + addBuilderConditionRow: function (group, markDirty) { + var root = group ? group.closest('[data-builder-condition]') : null; + root = root || (this.builderForm ? this.builderForm.querySelector('[data-builder-condition]') : null); + group = group || (root ? root.querySelector('[data-builder-condition-group]') : null); + var rows = group ? group.querySelector(':scope > [data-builder-condition-items]') : null; + if (!rows) { return; } + if (root && root.querySelectorAll('[data-builder-condition-row]').length >= 32) { + Adminx.Toast.show('В одном условии допускается не более 32 правил', 'warning'); + return; + } + var current = root && root.getAttribute('data-condition-scope') === 'group' ? 0 : this.builderSelectedId; + rows.insertAdjacentHTML('beforeend', this.builderConditionRowMarkup({}, current)); + if (markDirty !== false && root && root.getAttribute('data-condition-scope') !== 'group') { this.captureBuilderInspector(); } + var added = rows.querySelectorAll(':scope > [data-builder-condition-row]'); + var select = added.length ? added[added.length - 1].querySelector('[data-builder-condition-field]') : null; + if (select) { select.focus(); } + }, + + addBuilderConditionGroup: function (group) { + var items = group ? group.querySelector(':scope > [data-builder-condition-items]') : null; + var depth = group ? parseInt(group.getAttribute('data-condition-depth'), 10) || 1 : 1; + if (!items || depth >= 4) { return; } + var root = group ? group.closest('[data-builder-condition]') : null; + root = root || (this.builderForm ? this.builderForm.querySelector('[data-builder-condition]') : null); + if (root && root.querySelectorAll('[data-builder-condition-row]').length >= 32) { + Adminx.Toast.show('В одном условии допускается не более 32 правил', 'warning'); + return; + } + var current = root && root.getAttribute('data-condition-scope') === 'group' ? 0 : this.builderSelectedId; + items.insertAdjacentHTML('beforeend', this.builderConditionGroupMarkup({ operator: 'and', items: [{}] }, depth + 1, false, current)); + if (!root || root.getAttribute('data-condition-scope') !== 'group') { this.captureBuilderInspector(); } + var groups = items.querySelectorAll(':scope > [data-builder-condition-group]'); + var select = groups.length ? groups[groups.length - 1].querySelector('[data-builder-condition-field]') : null; + if (select) { select.focus(); } + }, + + removeBuilderConditionRow: function (row) { + var root = row ? row.closest('[data-builder-condition]') : null; + if (row && row.parentNode) { row.parentNode.removeChild(row); } + if (!root || root.getAttribute('data-condition-scope') !== 'group') { this.captureBuilderInspector(); } + }, + + removeBuilderConditionGroup: function (group) { + if (!group || group.classList.contains('is-root')) { return; } + var root = group.closest('[data-builder-condition]'); + group.remove(); + if (!root || root.getAttribute('data-condition-scope') !== 'group') { this.captureBuilderInspector(); } + }, + + toggleBuilderDefaultField: function (editor) { + var field = this.builderForm ? this.builderForm.querySelector('[data-builder-default-field]') : null; + if (field) { field.hidden = !!editor.default_is_configuration; } + }, + + settingsMapRows: function (map) { + if (!map) { return []; } + return Array.prototype.map.call(map.querySelectorAll('[data-settings-map-row]'), function (row) { + var key = row.querySelector('[data-settings-map-key]'); + var label = row.querySelector('[data-settings-map-label]'); + return { + key: key ? key.value.trim() : '', + label: label ? label.value.trim() : '' + }; + }); + }, + + settingsMapRowMarkup: function (key, label) { + return '
    ' + + '' + + '' + + '' + + '
    '; + }, + + syncSettingsMap: function (map) { + var storage = map ? map.querySelector('[data-settings-map-storage]') : null; + if (!storage) { return; } + storage.value = this.settingsMapRows(map).filter(function (row) { + return row.key !== '' || row.label !== ''; + }).map(function (row) { + return row.key + '=' + row.label; + }).join('\n'); + }, + + hydrateSettingsMaps: function (root) { + var self = this; + (root || document).querySelectorAll('[data-settings-map]').forEach(function (map) { + var storage = map.querySelector('[data-settings-map-storage]'); + var rows = map.querySelector('[data-settings-map-rows]'); + if (!storage || !rows) { return; } + var values = String(storage.value || '').split(/\r?\n/).filter(function (line) { return line.trim() !== ''; }); + rows.innerHTML = values.map(function (line) { + var split = line.indexOf('='); + var key = split === -1 ? line : line.slice(0, split); + var label = split === -1 ? line : line.slice(split + 1); + return self.settingsMapRowMarkup(key.trim(), label.trim().replace(/\|\s*$/, '')); + }).join('') || self.settingsMapRowMarkup('', ''); + }); + }, + + addSettingsMapRow: function (map) { + var rows = map ? map.querySelector('[data-settings-map-rows]') : null; + if (!rows) { return; } + rows.insertAdjacentHTML('beforeend', this.settingsMapRowMarkup('', '')); + this.syncSettingsMap(map); + var inputs = rows.querySelectorAll('[data-settings-map-key]'); + if (inputs.length) { inputs[inputs.length - 1].focus(); } + if (this.builderForm && this.builderForm.contains(map)) { this.captureBuilderInspector(); } + }, + + removeSettingsMapRow: function (row) { + var map = row ? row.closest('[data-settings-map]') : null; + var rows = map ? map.querySelector('[data-settings-map-rows]') : null; + if (!map || !rows || !row) { return; } + row.remove(); + if (!rows.querySelector('[data-settings-map-row]')) { + rows.innerHTML = this.settingsMapRowMarkup('', ''); + } + this.syncSettingsMap(map); + if (this.builderForm && this.builderForm.contains(map)) { this.captureBuilderInspector(); } + }, + + applyBuilderWidth: function (width) { + var row = document.querySelector('[data-builder-canvas] [data-field-row][data-id="' + this.builderSelectedId + '"]'); + if (!row) { return; } + ['full', 'half', 'third', 'quarter'].forEach(function (name) { row.classList.toggle('is-' + name, name === width); }); + row.setAttribute('data-width', width); + var label = row.querySelector('.rubrics-builder-field-size'); + if (label) { label.textContent = this.builderWidthLabel(width); } + this.setBuilderDirty(true); + }, + + moveSelectedBuilderField: function (groupId) { + var row = document.querySelector('[data-builder-canvas] [data-field-row][data-id="' + this.builderSelectedId + '"]'); + var target = document.querySelector('[data-builder-canvas] [data-field-group-drop="' + (parseInt(groupId, 10) || 0) + '"] [data-field-sortable]'); + if (!row || !target || row.parentNode === target) { return; } + target.appendChild(row); + row.setAttribute('data-group', String(parseInt(groupId, 10) || 0)); + this.cleanupBuilderEmptyStates(); + this.updateBuilderGroupCounts(); + this.setBuilderDirty(true); + }, + + updateBuilderFieldCard: function (field) { + var row = document.querySelector('[data-builder-canvas] [data-field-row][data-id="' + field.Id + '"]'); + if (!row) { return; } + var layout = field.layout || {}; + var conditional = field.condition && field.condition.tree && Array.isArray(field.condition.tree.items) && field.condition.tree.items.length; + var top = row.querySelector('.rubrics-builder-field-top'); + var marker = row.querySelector('.rubrics-builder-field-condition'); + if (conditional && !marker && top) { + var size = top.querySelector('.rubrics-builder-field-size'); + var html = ''; + if (size) { size.insertAdjacentHTML('beforebegin', html); } else { top.insertAdjacentHTML('beforeend', html); } + } else if (!conditional && marker) { + marker.remove(); + } + var title = row.querySelector('.rubrics-builder-field-main > b'); + if (title) { title.innerHTML = esc(field.rubric_field_title || ('Поле #' + field.Id)) + (layout.required ? '*' : ''); } + var preview = row.querySelector('.rubrics-builder-field-preview'); + if (preview) { + preview.innerHTML = (layout.prefix ? '' + esc(layout.prefix) + '' : '') + + '' + esc(this.builderPlaceholder(field)) + '' + + (layout.suffix ? '' + esc(layout.suffix) + '' : ''); + } + }, + + filterBuilderPalette: function (query) { + query = String(query || '').trim().toLowerCase(); + document.querySelectorAll('[data-builder-palette-item]').forEach(function (item) { + item.hidden = query !== '' && String(item.getAttribute('data-builder-search-text') || '').toLowerCase().indexOf(query) === -1; + }); + }, + + updateBuilderGroupCounts: function () { + document.querySelectorAll('[data-builder-canvas] [data-field-group-drop]').forEach(function (group) { + var count = group.querySelector('[data-builder-group-count]'); + if (count) { count.textContent = group.querySelectorAll('[data-field-row]').length; } + }); + }, + + cleanupBuilderEmptyStates: function () { + document.querySelectorAll('[data-builder-canvas] [data-field-sortable]').forEach(function (list) { + var empty = list.querySelector('.rubrics-builder-drop-empty'); + var hasFields = !!list.querySelector('[data-field-row]'); + if (empty && hasFields) { empty.remove(); } + if (!empty && !hasFields) { list.innerHTML = '
    Перетащите поле в эту группу
    '; } + }); + }, + + setBuilderDirty: function (dirty) { + this.builderDirty = !!dirty; + var button = document.querySelector('[data-builder-save]'); + var state = document.querySelector('[data-builder-save-state]'); + if (button) { button.disabled = !this.builderDirty; } + if (state) { + state.hidden = !this.builderDirty; + } + }, + + syncBuilderRuntime: function () { + var root = document.querySelector('[data-builder-runtime]'); + var toggle = document.querySelector('[data-builder-conditions-enabled]'); + var state = document.querySelector('[data-builder-runtime-state]'); + if (!root || !toggle || !state) { return; } + var enabled = !!toggle.checked; + root.classList.toggle('is-enabled', enabled); + state.className = 'badge ' + (enabled ? 'badge-green' : 'badge-gray'); + state.textContent = enabled ? 'Включены' : 'Выключены'; + }, + + openBuilderAdvanced: function () { + var row = document.querySelector('[data-builder-canvas] [data-field-row][data-id="' + this.builderSelectedId + '"]'); + if (!row) { return; } + if (this.builderDirty) { + Adminx.Toast.show('Сначала сохраните изменения конструктора', 'warning'); + return; + } + this.fillFieldEdit(row); + if (Adminx.Drawer) { Adminx.Drawer.open('rubricFieldDrawer'); } + }, + + saveBuilder: function () { + var self = this; + if (!this.builderDirty) { return; } + this.captureBuilderInspector(); + var payload = this.builderSavePayload(); + var previewData = this.builderFormData(payload, ''); + this.ajax(this.base() + '/rubrics/' + this.currentRubricId + '/fields/builder/preview', previewData, function (json) { + var impact = (json.data || {}).impact || {}; + var persist = function () { self.persistBuilder(payload, impact.fingerprint || ''); }; + if (impact.requires_confirmation) { + self.showBuilderImpact(impact, persist); + return; + } + persist(); + }); + }, + + builderSavePayload: function () { + var self = this; + var layout = []; + document.querySelectorAll('[data-builder-canvas] [data-field-group-drop]').forEach(function (group) { + var groupId = parseInt(group.getAttribute('data-field-group-drop'), 10) || 0; + group.querySelectorAll('[data-field-row]').forEach(function (row) { + var field = self.builderField(row.getAttribute('data-id')); + var fieldLayout = field && field.layout ? field.layout : {}; + layout.push({ + id: parseInt(row.getAttribute('data-id'), 10), + group_id: groupId, + width: row.getAttribute('data-width') || 'full', + prefix: fieldLayout.prefix || '', + suffix: fieldLayout.suffix || '' + }); + }); + }); + var drafts = Object.keys(this.builderDrafts).map(function (id) { return self.builderDrafts[id]; }); + + return { + layout: layout, + drafts: drafts, + conditionsEnabled: !!(document.querySelector('[data-builder-conditions-enabled]') || {}).checked + }; + }, + + builderFormData: function (payload, fingerprint) { + var data = new FormData(); + data.append('_csrf', this.form.elements._csrf.value); + data.append('layout', JSON.stringify(payload.layout || [])); + data.append('drafts', JSON.stringify(payload.drafts || [])); + data.append('conditions_enabled', payload.conditionsEnabled ? '1' : '0'); + if (fingerprint) { data.append('impact_fingerprint', fingerprint); } + + return data; + }, + + persistBuilder: function (payload, fingerprint) { + var self = this; + var data = this.builderFormData(payload, fingerprint); + this.ajax(this.base() + '/rubrics/' + this.currentRubricId + '/fields/builder', data, function () { + self.setBuilderDirty(false); + self.builderDrafts = {}; + Adminx.Toast.show('Раскладка полей сохранена', 'success'); + self.loadFields(); + }); + }, + + showBuilderImpact: function (impact, onConfirm) { + var overlay = document.createElement('div'); + var enabled = !!impact.enabled_after; + var total = parseInt(impact.documents_total, 10) || 0; + var analyzed = parseInt(impact.documents_analyzed, 10) || 0; + var details = Array.isArray(impact.details) ? impact.details : []; + var detailsHtml = details.map(function (item) { + return '
    ' + + '' + esc(item.title || ('Поле #' + item.id)) + '' + esc(item.alias || ('field_' + item.id)) + '' + + '' + (parseInt(item.changed_documents, 10) || 0) + 'изменится' + + '' + (parseInt(item.hidden_before, 10) || 0) + ' → ' + (parseInt(item.hidden_after, 10) || 0) + 'скрыто · заблокировано ' + (parseInt(item.locked_before, 10) || 0) + ' → ' + (parseInt(item.locked_after, 10) || 0) + ' · ограничено ' + (parseInt(item.limited_before, 10) || 0) + ' → ' + (parseInt(item.limited_after, 10) || 0) + ' · автодействие ' + (parseInt(item.action_before, 10) || 0) + ' → ' + (parseInt(item.action_after, 10) || 0) + ' · обязательно ' + (parseInt(item.required_before, 10) || 0) + ' → ' + (parseInt(item.required_after, 10) || 0) + '' + + '
    '; + }).join(''); + if (!detailsHtml) { detailsHtml = '
    Правила полей не изменились.
    '; } + overlay.className = 'overlay rubrics-builder-impact-overlay'; + overlay.innerHTML = ''; + document.body.appendChild(overlay); + var close = function () { + overlay.classList.remove('show'); + setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 180); + }; + var onKey = function (e) { if (e.key === 'Escape') { close(); } }; + overlay.addEventListener('click', function (e) { + if (e.target === overlay || e.target.closest('[data-impact-cancel]')) { close(); return; } + if (e.target.closest('[data-impact-confirm]')) { + close(); + onConfirm(); + } + }); + document.addEventListener('keydown', onKey); + requestAnimationFrame(function () { + overlay.classList.add('show'); + var button = overlay.querySelector('[data-impact-confirm]'); + if (button) { button.focus(); } + }); + }, + + renderGroupList: function (groups) { + var html = ''; + groups.forEach(function (group) { + var condition = group.condition && typeof group.condition === 'object' ? group.condition : {}; + html += '
    ' + + '' + + '
    ' + esc(group.title) + '' + esc(group.fields_count) + ' полей' + (condition.tree ? ' · есть условие' : '') + '
    ' + + '
    ' + + '' + + '' + + '
    '; + }); + if (!html) { html = '
    Группы не созданы.
    '; } + var root = document.querySelector('[data-rubric-groups-list]'); + if (root) { root.innerHTML = html; } + }, + + fillFieldNew: function () { + this.clearErrors(this.fieldForm); + this.fieldForm.reset(); + this.fieldForm.elements.id.value = ''; + this.fieldForm.elements.rubric_id.value = this.currentRubricId; + this.syncFieldTypeOptions(''); + document.getElementById('rubricFieldTitle').textContent = 'Новое поле'; + this.fillGroupSelect(); + this.setHint('[data-field-alias-state]', ''); + this.syncFieldTemplateEditors(); + this.syncNativeNumericType(this.fieldForm.elements.rubric_field_type.value); + this.loadTypeInfo(this.fieldForm.elements.rubric_field_type.value); + if (Adminx.Drawer) { Adminx.Drawer.open('rubricFieldDrawer'); } + }, + + resetFieldForm: function () { + if (!this.fieldForm) { return; } + clearTimeout(this.fieldAliasTimer); + this.fieldAliasTimer = null; + this.activeFieldTemplateTextarea = null; + this.clearErrors(this.fieldForm); + this.fieldForm.reset(); + this.fieldForm.elements.id.value = ''; + this.fieldForm.elements.rubric_id.value = this.currentRubricId || ''; + this.setHint('[data-field-alias-state]', ''); + this.syncFieldTypeOptions(''); + ['rubric_field_template', 'rubric_field_template_request'].forEach(function (name) { + var textarea = this.fieldForm.elements[name]; + if (textarea && textarea._adminxCodeMirror) { + textarea._adminxCodeMirror.setValue(''); + textarea._adminxCodeMirror.save(); + } + }, this); + var plugin = document.querySelector('[data-field-plugin-body]'); + if (plugin) { plugin.innerHTML = '
    Выберите тип поля.
    '; } + var title = document.getElementById('rubricFieldTitle'); + if (title) { title.textContent = 'Новое поле'; } + }, + + /** Перенести значения textarea шаблонов поля в CodeMirror и обновить отрисовку. */ + syncFieldTemplateEditors: function () { + var form = this.fieldForm; + if (!form) { return; } + ['rubric_field_template', 'rubric_field_template_request'].forEach(function (name) { + var ta = form.elements[name]; + if (ta && ta._adminxCodeMirror) { ta._adminxCodeMirror.setValue(ta.value || ''); } + }); + this.refreshEditors(); + }, + + /** Вставить тег из легенды в активный (или первый) редактор шаблона поля. */ + insertFieldTemplateTag: function (tag) { + if (!tag || !this.fieldForm) { return; } + var ta = this.activeFieldTemplateTextarea; + if (!ta || !ta.closest || !ta.closest('#rubricFieldForm')) { + ta = this.fieldForm.elements.rubric_field_template; + } + if (!ta) { return; } + var cm = ta._adminxCodeMirror; + if (cm) { + cm.replaceSelection(tag); + cm.focus(); + cm.save(); + Adminx.Toast.show('Тег вставлен', 'success'); + return; + } + var start = ta.selectionStart || 0; + ta.value = ta.value.slice(0, start) + tag + ta.value.slice(ta.selectionEnd || start); + ta.focus(); + }, + + fillFieldEdit: function (row) { + if (!row) { return; } + var self = this; + this.clearErrors(this.fieldForm); + fetch(this.base() + '/rubrics/fields/' + row.getAttribute('data-id'), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (res) { return res.json(); }) + .then(function (json) { + var item = json.data || {}; + self.fillGroupSelect(); + self.fieldForm.elements.id.value = item.Id || ''; + self.fieldForm.elements.rubric_id.value = item.rubric_id || self.currentRubricId; + self.fieldForm.elements.rubric_field_title.value = item.rubric_field_title || ''; + self.fieldForm.elements.rubric_field_alias.value = item.rubric_field_alias || ''; + self.syncFieldTypeOptions(item.rubric_field_type || ''); + self.fieldForm.elements.rubric_field_type.value = item.rubric_field_type || ''; + self.fieldForm.elements.rubric_field_group.value = String(item.rubric_field_group || 0); + self.fieldForm.elements.rubric_field_search.value = String(item.rubric_field_search || 0); + self.fieldForm.elements.rubric_field_numeric.value = String(item.rubric_field_numeric || 0); + self.syncNativeNumericType(item.rubric_field_type || ''); + self.fieldForm.elements.rubric_field_default.value = item.rubric_field_default || ''; + self.fieldForm.elements.rubric_field_description.value = item.rubric_field_description || ''; + self.fieldForm.elements.rubric_field_template.value = item.rubric_field_template || ''; + self.fieldForm.elements.rubric_field_template_request.value = item.rubric_field_template_request || ''; + var L = item.layout || {}; + var el = self.fieldForm.elements; + if (el['rubric_field_layout[width]']) { el['rubric_field_layout[width]'].value = L.width || ''; } + if (el['rubric_field_layout[prefix]']) { el['rubric_field_layout[prefix]'].value = L.prefix || ''; } + if (el['rubric_field_layout[suffix]']) { el['rubric_field_layout[suffix]'].value = L.suffix || ''; } + document.getElementById('rubricFieldTitle').textContent = 'Редактирование поля #' + item.Id; + self.setHint('[data-field-alias-state]', ''); + self.syncFieldTemplateEditors(); + self.loadFieldPlugin(item.Id); + }); + }, + + loadTypeInfo: function (type) { + var root = document.querySelector('[data-field-plugin-body]'); + var self = this; + if (!root || !type) { return; } + var defaultWrap = document.querySelector('.rubrics-default-field'); + if (defaultWrap) { defaultWrap.hidden = false; } + root.innerHTML = '
    Загрузка типа поля...
    '; + fetch(this.base() + '/rubrics/field-types/' + encodeURIComponent(type), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (res) { return res.json(); }) + .then(function (json) { + self.renderPluginInfo({ type: json.data || null, field: null, render: null }); + }) + .catch(function () { + root.innerHTML = '
    Не удалось загрузить тип поля.
    '; + }); + }, + + syncNativeNumericType: function (type) { + if (!this.fieldForm || !this.fieldForm.elements.rubric_field_numeric) { return; } + var numeric = this.fieldForm.elements.rubric_field_numeric; + var inherent = ['number', 'date_time', 'period'].indexOf(String(type || '')) !== -1; + if (inherent) { numeric.value = '1'; } + numeric.disabled = inherent; + numeric.title = inherent ? 'Этот тип всегда использует числовой индекс' : ''; + }, + + loadFieldPlugin: function (id) { + var root = document.querySelector('[data-field-plugin-body]'); + var self = this; + if (!root || !id) { return; } + var defaultWrap = document.querySelector('.rubrics-default-field'); + if (defaultWrap) { defaultWrap.hidden = false; } + root.innerHTML = '
    Загрузка настроек типа...
    '; + fetch(this.base() + '/rubrics/fields/' + encodeURIComponent(id) + '/plugin', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (res) { return res.json(); }) + .then(function (json) { + self.renderPluginInfo(json.data || {}); + }) + .catch(function () { + root.innerHTML = '
    Не удалось загрузить настройки типа.
    '; + }); + }, + + renderFieldSettings: function (html) { + if (!html) { return ''; } + return '
    ' + + '
    Настройки поля
    ' + + html + '
    '; + }, + + renderPluginInfo: function (data) { + var root = document.querySelector('[data-field-plugin-body]'); + var type = data.type || null; + var render = data.render || null; + var statusClass; + var statusText; + if (!root) { return; } + if (!type) { + root.innerHTML = '
    Тип поля не зарегистрирован.
    '; + return; + } + this.applyFieldAdminEditor(type.admin_editor || {}); + statusClass = type.status === 'ok' ? 'badge-green' : (type.status === 'error' ? 'badge-red' : 'badge-amber'); + statusText = type.status === 'ok' ? 'готов' : (type.status === 'error' ? 'ошибка' : 'проверить'); + var settings = this.renderFieldSettings(type.settings_html || ''); + // Показываем только реальные настройки типа. Диагностика плагина + // (источник/функция/шаблоны/ассеты/preview) — не для контент-редактора; + // проблемы показываем лишь если они есть. + var issues = (type.issues && type.issues.length) ? this.renderPluginIssues(type.issues) : ''; + root.innerHTML = '
    ' + + '
    ' + esc(type.name) + '' + esc(type.id) + '
    ' + + '' + statusText + '' + + '
    ' + + (settings || '

    У этого типа нет дополнительных настроек.

    ') + + issues; + }, + + renderAdminEditor: function (editor) { + var controls = editor.controls || []; + var status = editor.status === 'native' ? 'adminx' : 'fallback'; + var badge = editor.status === 'native' ? 'badge-green' : 'badge-amber'; + if (!editor || !editor.title) { return ''; } + return '
    ' + + '
    ' + + '' + + '
    ' + esc(editor.title) + '' + esc(editor.summary || '') + '
    ' + + '' + status + '' + + '
    ' + + '
    ' + + controls.map(function (control) { + return '' + esc(control.label || control.name) + '' + esc(Adminx.Rubrics.kindLabel(control.kind || 'text')) + ''; + }).join('') + + '
    ' + + this.renderAdminEntityFields(editor.entity_fields || []) + + '
    '; + }, + + renderAdminEntityFields: function (fields) { + if (!fields.length) { return ''; } + return '
    ' + + 'Сущность поля' + + '
    ' + fields.map(function (field) { + return '' + esc(field) + ''; + }).join('') + '
    ' + + '
    '; + }, + + kindLabel: function (kind) { + var map = { + text: 'короткий текст', + textarea: 'многострочный текст', + lines: 'список строк', + key_value: 'ключ и значение', + boolean: 'да / нет', + date: 'дата', + datetime: 'дата и время', + number: 'число', + range: 'диапазон', + dimensions: 'три размера', + packages: 'список упаковок', + address: 'структурированный адрес', + period: 'период дат', + pipe: 'части через |', + pipe_list: 'строки с колонками', + media: 'медиафайл', + media_list: 'список файлов', + relation: 'документ', + relation_list: 'список документов', + url: 'ссылка', + code: 'код' + }; + return map[kind] || kind; + }, + + applyFieldAdminEditor: function (editor) { + var self = this; + var controls = editor.controls || []; + var byName = {}; + controls.forEach(function (control) { + if (control.name) { byName[control.name] = control; } + }); + var defaultWrap = document.querySelector('.rubrics-default-field'); + if (defaultWrap) { defaultWrap.hidden = !!editor.default_is_configuration; } + ['rubric_field_default', 'rubric_field_template', 'rubric_field_template_request'].forEach(function (name) { + var control = byName[name] || {}; + var label = document.querySelector('[data-field-control-label="' + name + '"]'); + var hint = document.querySelector('[data-field-control-hint="' + name + '"]'); + var input = self.fieldForm ? self.fieldForm.elements[name] : null; + if (label && control.label) { label.textContent = control.label; } + if (hint) { hint.textContent = control.hint || ''; } + if (input) { + input.setAttribute('data-admin-editor-kind', control.kind || 'text'); + input.placeholder = self.placeholderForControl(control.kind || 'text'); + if (name === 'rubric_field_default') { + input.rows = self.rowsForControl(control.kind || 'text'); + input.value = self.storageValueToEditor(input.value, control.kind || 'text'); + self.renderDefaultEditor(control.kind || 'text'); + } + } + }); + }, + + renderDefaultEditor: function (kind) { + var storage = this.defaultStorage(); + var root = document.querySelector('[data-field-default-editor]'); + var value; + if (!storage || !root) { return; } + value = this.storageValueToEditor(storage.value, kind); + storage.value = value; + storage.hidden = false; + root.hidden = true; + root.innerHTML = ''; + storage.classList.toggle('mono', kind === 'code'); + + if (['text', 'url', 'media', 'date', 'datetime', 'number', 'pipe', 'relation'].indexOf(kind) !== -1) { + var inputType = kind === 'date' ? 'date' : (kind === 'datetime' ? 'datetime-local' : (kind === 'url' ? 'url' : (kind === 'number' || kind === 'relation' ? 'number' : 'text'))); + var inputIcon = kind === 'date' ? 'calendar' : (kind === 'datetime' ? 'calendar-time' : (kind === 'url' ? 'link' : (kind === 'media' ? 'photo' : (kind === 'relation' ? 'file-search' : (kind === 'number' ? 'number' : 'forms'))))); + storage.hidden = true; + root.hidden = false; + root.innerHTML = '
    ' + + '' + + '' + + (kind === 'media' ? '' : '') + + (kind === 'relation' ? '' : '') + + '
    '; + return; + } + + if (kind === 'boolean') { + storage.hidden = true; + root.hidden = false; + root.innerHTML = '
    ' + + '' + + '' + + '
    '; + this.setDefaultBoolean(value === '1' ? '1' : '0'); + return; + } + + if (['lines', 'key_value', 'media_list', 'pipe_list', 'relation_list', 'packages'].indexOf(kind) !== -1) { + storage.hidden = true; + root.hidden = false; + root.setAttribute('data-default-list-kind', kind); + root.innerHTML = '
    ' + + '
    ' + + '' + + '
    '; + this.renderDefaultListRows(this.defaultListParts(value, kind), kind); + } + }, + + defaultStorage: function () { + return this.fieldForm ? this.fieldForm.querySelector('[data-field-default-storage]') : null; + }, + + syncDefaultEditorValue: function (input) { + var storage = this.defaultStorage(); + if (!storage || !input) { return; } + storage.value = input.value || ''; + }, + + setDefaultBoolean: function (value) { + var storage = this.defaultStorage(); + value = value === '1' ? '1' : '0'; + if (storage) { storage.value = value; } + document.querySelectorAll('[data-default-boolean]').forEach(function (btn) { + var active = btn.getAttribute('data-default-boolean') === value; + btn.classList.toggle('is-active', active); + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + }, + + defaultListParts: function (value, kind) { + value = String(value == null ? '' : value); + if (kind === 'media_list') { + return value.split(/[\r\n]+/).map(function (part) { return part.trim(); }).filter(Boolean); + } + if (kind === 'pipe_list') { + return value.split(/[,\r\n]+/).map(function (part) { return part.trim(); }).filter(Boolean); + } + if (kind === 'relation_list') { + return value.split(/[|,\r\n]+/).map(function (part) { return part.trim(); }).filter(Boolean); + } + if (kind === 'packages') { + return value.split(/[\r\n]+/).map(function (part) { return part.trim(); }).filter(Boolean); + } + return value.split(/[,\r\n]+/).map(function (part) { return part.trim(); }).filter(Boolean); + }, + + renderDefaultListRows: function (parts, kind) { + var root = document.querySelector('[data-default-list-rows]'); + if (!root) { return; } + if (!parts.length) { parts = ['']; } + root.classList.toggle('is-key-value', kind === 'key_value'); + root.innerHTML = parts.map(function (part, index) { + var placeholder = kind === 'key_value' ? 'key|Название' : (kind === 'media_list' ? '/uploads/file.webp' : (kind === 'relation_list' ? 'ID документа' : (kind === 'pipe_list' ? 'Колонка1|Колонка2|Колонка3' : (kind === 'packages' ? 'длина|ширина|высота|вес' : 'Вариант')))); + var keyValue; + if (kind === 'key_value') { + keyValue = part.split('|'); + return '
    ' + + '' + (index + 1) + '' + + '' + + '' + + '' + + '
    '; + } + return '
    ' + + '' + (index + 1) + '' + + '' + + (kind === 'media_list' ? '' : '') + + (kind === 'relation_list' ? '' : '') + + '' + + '
    '; + }).join(''); + this.syncDefaultListValue(); + }, + + addDefaultListRow: function () { + var root = document.querySelector('[data-default-list-rows]'); + var kindRoot = document.querySelector('[data-default-list-kind]'); + var kind = kindRoot ? kindRoot.getAttribute('data-default-list-kind') : 'lines'; + var parts = this.readDefaultListInputs(); + parts.push(''); + this.renderDefaultListRows(parts, kind); + var inputs = root ? root.querySelectorAll('[data-default-list-input], [data-default-list-key]') : []; + if (inputs.length) { inputs[inputs.length - 1].focus(); } + }, + + removeDefaultListRow: function (row) { + var kindRoot = document.querySelector('[data-default-list-kind]'); + var kind = kindRoot ? kindRoot.getAttribute('data-default-list-kind') : 'lines'; + var parts; + if (row) { row.remove(); } + parts = this.readDefaultListInputs(); + this.renderDefaultListRows(parts.length ? parts : [''], kind); + }, + + readDefaultListInputs: function () { + var keyRows = document.querySelectorAll('[data-default-list-row].is-key-value'); + if (keyRows.length) { + return Array.prototype.map.call(keyRows, function (row) { + var key = row.querySelector('[data-default-list-key]'); + var value = row.querySelector('[data-default-list-value]'); + key = key ? key.value.trim() : ''; + value = value ? value.value.trim() : ''; + return key || value ? key + '|' + value : ''; + }); + } + return Array.prototype.map.call(document.querySelectorAll('[data-default-list-input]'), function (input) { + return input.value || ''; + }); + }, + + syncDefaultListValue: function () { + var storage = this.defaultStorage(); + var kindRoot = document.querySelector('[data-default-list-kind]'); + var kind = kindRoot ? kindRoot.getAttribute('data-default-list-kind') : 'lines'; + var separator = kind === 'media_list' ? '\n' : '\n'; + if (!storage) { return; } + storage.value = this.readDefaultListInputs().map(function (part) { + return String(part || '').trim(); + }).filter(Boolean).join(separator); + }, + + openDefaultMediaPicker: function (row) { + var self = this; + Adminx.MediaPicker.open({ + type: 'image', + title: 'Выбрать файл', + description: 'Файл будет записан в значение по умолчанию поля.', + onPick: function (file) { if (file && file.url) { self.applyPickedMedia(file.url, row); } } + }); + }, + + applyPickedMedia: function (url, row) { + var input; + if (row) { + input = row.querySelector('[data-default-list-input]'); + if (input) { + input.value = url; + this.syncDefaultListValue(); + } + return; + } + input = document.querySelector('[data-default-editor-input]'); + if (input) { + input.value = url; + this.syncDefaultEditorValue(input); + } + }, + + openDefaultRelationPicker: function (row) { + var self = this; + var overlay = document.createElement('div'); + overlay.className = 'overlay rubrics-relation-picker-overlay'; + overlay.innerHTML = ''; + document.body.appendChild(overlay); + requestAnimationFrame(function () { overlay.classList.add('show'); }); + this.bindDefaultRelationPicker(overlay, row); + }, + + bindDefaultRelationPicker: function (overlay, targetRow) { + var self = this; + var list = overlay.querySelector('[data-relation-list]'); + var status = overlay.querySelector('[data-relation-status]'); + var search = overlay.querySelector('[data-relation-search]'); + var count = overlay.querySelector('[data-relation-count]'); + var close = function () { + overlay.classList.remove('show'); + setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 180); + }; + var render = function (items) { + list.innerHTML = ''; + (items || []).forEach(function (item) { + list.insertAdjacentHTML('beforeend', ''); + }); + status.hidden = items && items.length > 0; + status.textContent = search.value.trim() ? 'Ничего не найдено' : 'Начните вводить название или ID'; + count.textContent = items && items.length ? 'Документов: ' + items.length : ''; + }; + var load = function () { + status.textContent = 'Загрузка...'; + status.hidden = false; + var params = new URLSearchParams(); + params.set('q', search.value.trim()); + params.set('limit', 20); + fetch(self.base() + '/rubrics/documents/picker?' + params.toString(), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (res) { return res.json(); }) + .then(function (json) { + var data = json.data && json.data.success ? json.data.data : (json.data || {}); + render(data.items || []); + }) + .catch(function () { + list.innerHTML = ''; + status.textContent = 'Не удалось загрузить документы'; + status.hidden = false; + }); + }; + var apply = function (id) { + self.applyPickedRelation(id, targetRow); + close(); + }; + var timer = null; + var onKey = function (e) { if (e.key === 'Escape') { close(); } }; + overlay.addEventListener('click', function (e) { + if (e.target === overlay || e.target.closest('[data-relation-cancel]')) { close(); return; } + var item = e.target.closest('[data-relation-id]'); + if (item) { apply(item.getAttribute('data-relation-id')); } + }); + search.addEventListener('input', function () { + clearTimeout(timer); + timer = setTimeout(load, 220); + }); + document.addEventListener('keydown', onKey); + load(); + search.focus(); + }, + + applyPickedRelation: function (id, row) { + var input; + if (row) { + input = row.querySelector('[data-default-list-input]'); + if (input) { + input.value = id; + this.syncDefaultListValue(); + } + return; + } + input = document.querySelector('[data-default-editor-input]'); + if (input) { + input.value = id; + this.syncDefaultEditorValue(input); + } + }, + + storageValueToEditor: function (value, kind) { + value = String(value == null ? '' : value); + if (kind === 'packages' && value.trim().charAt(0) === '[') { + try { + var packages = JSON.parse(value); + if (Array.isArray(packages)) { + return packages.map(function (item) { + item = item || {}; + return [item.length || '', item.width || '', item.height || '', item.weight || ''].join('|'); + }).join('\n'); + } + } catch (ignore) {} + } + if (kind === 'address' && value.trim().charAt(0) === '{') { + try { + var address = JSON.parse(value) || {}; + return ['postal_code', 'region', 'city', 'street', 'building', 'unit', 'latitude', 'longitude'].map(function (key) { + return address[key] || ''; + }).join('|'); + } catch (ignoreAddress) {} + } + if (kind === 'period' && value.trim().charAt(0) === '{') { + try { + var period = JSON.parse(value) || {}; + var periodPart = function (timestamp) { + if (!/^\d+$/.test(String(timestamp || ''))) { return ''; } + var date = new Date(parseInt(timestamp, 10) * 1000); + var month = String(date.getMonth() + 1).padStart(2, '0'); + var day = String(date.getDate()).padStart(2, '0'); + var hours = String(date.getHours()).padStart(2, '0'); + var minutes = String(date.getMinutes()).padStart(2, '0'); + return date.getFullYear() + '-' + month + '-' + day + ' ' + hours + ':' + minutes; + }; + return periodPart(period.start) + '|' + periodPart(period.end); + } catch (ignorePeriod) {} + } + if ((kind === 'lines' || kind === 'key_value') && value.indexOf('\n') === -1) { + if (kind === 'lines' && value.indexOf('|') !== -1 && value.indexOf(',') === -1) { + return value.split('|').map(function (part) { return part.trim(); }).filter(Boolean).join('\n'); + } + return value.split(',').map(function (part) { return part.trim(); }).filter(Boolean).join('\n'); + } + if (kind === 'pipe_list' && value.indexOf('\n') === -1) { + return value.split(',').map(function (part) { return part.trim(); }).filter(Boolean).join('\n'); + } + if (kind === 'relation_list' && value.indexOf('\n') === -1) { + return value.split(/[|,]+/).map(function (part) { return part.trim(); }).filter(Boolean).join('\n'); + } + if (kind === 'boolean') { + value = value.trim().toLowerCase(); + return ['1', 'true', 'yes', 'on', 'да', 'вкл', 'включено'].indexOf(value) !== -1 ? '1' : '0'; + } + if (kind === 'date' && /^\d{9,10}$/.test(value)) { + var date = new Date(parseInt(value, 10) * 1000); + var month = String(date.getMonth() + 1).padStart(2, '0'); + var day = String(date.getDate()).padStart(2, '0'); + return date.getFullYear() + '-' + month + '-' + day; + } + if (kind === 'datetime' && /^\d{9,10}$/.test(value)) { + var dateTime = new Date(parseInt(value, 10) * 1000); + var dateTimeMonth = String(dateTime.getMonth() + 1).padStart(2, '0'); + var dateTimeDay = String(dateTime.getDate()).padStart(2, '0'); + var dateTimeHours = String(dateTime.getHours()).padStart(2, '0'); + var dateTimeMinutes = String(dateTime.getMinutes()).padStart(2, '0'); + return dateTime.getFullYear() + '-' + dateTimeMonth + '-' + dateTimeDay + 'T' + dateTimeHours + ':' + dateTimeMinutes; + } + return value; + }, + + editorValueToStorage: function (value, kind) { + value = String(value == null ? '' : value); + if (kind === 'lines' || kind === 'key_value' || kind === 'pipe_list' || kind === 'relation_list') { + return value.split(/[,\r\n]+/).map(function (part) { return part.trim(); }).filter(Boolean).join(','); + } + if (kind === 'boolean') { + value = value.trim().toLowerCase(); + return ['1', 'true', 'yes', 'on', 'да', 'вкл', 'включено'].indexOf(value) !== -1 ? '1' : '0'; + } + return value; + }, + + placeholderForControl: function (kind) { + var map = { + text: 'Текстовое значение', + textarea: 'Текст или параметры поля', + lines: 'Один вариант на строку', + key_value: 'key|Название', + boolean: '0 или 1', + date: 'YYYY-MM-DD', + datetime: 'YYYY-MM-DD HH:MM', + number: '0', + range: 'минимум|максимум', + dimensions: 'длина|ширина|высота', + packages: 'длина|ширина|высота|вес', + address: 'индекс|регион|город|улица|дом|помещение|широта|долгота', + period: 'YYYY-MM-DD|YYYY-MM-DD', + pipe: 'значение1|значение2', + pipe_list: 'Колонка1|Колонка2|Колонка3', + relation: 'ID документа', + relation_list: 'ID документа', + media: '/uploads/example.webp', + media_list: '/uploads/one.webp\n/uploads/two.webp', + url: '/catalog', + code: '' + }; + return map[kind] || ''; + }, + + rowsForControl: function (kind) { + if (kind === 'text' || kind === 'boolean' || kind === 'date' || kind === 'datetime' || kind === 'url' || kind === 'media' || kind === 'number' || kind === 'range' || kind === 'dimensions' || kind === 'period' || kind === 'pipe' || kind === 'relation') { return 2; } + if (kind === 'address') { return 3; } + if (kind === 'lines' || kind === 'key_value' || kind === 'media_list' || kind === 'pipe_list' || kind === 'relation_list' || kind === 'packages') { return 7; } + if (kind === 'code') { return 8; } + return 4; + }, + + renderPluginIssues: function (issues) { + if (!issues.length) { + return '
    Критичных проблем не найдено.
    '; + } + return '
    ' + issues.map(function (issue) { + return '' + esc(issue) + ''; + }).join('') + '
    '; + }, + + renderPluginFiles: function (title, files) { + if (!files.length) { return ''; } + return '
    ' + esc(title) + '' + files.slice(0, 6).map(function (file) { + return '' + esc(file) + ''; + }).join('') + (files.length > 6 ? '+' + (files.length - 6) + '' : '') + '
    '; + }, + + renderPluginPreview: function (render) { + var statusClass; + if (!render) { return ''; } + statusClass = render.status === 'ok' ? 'is-ok' : (render.status === 'error' ? 'is-error' : 'is-warning'); + return '
    ' + + '
    ' + + 'Preview edit-режима' + + '' + esc(render.status || 'info') + '' + + '
    ' + + '

    ' + esc(render.message || '') + '

    ' + + this.renderPluginNotes(render.notes || []) + + (render.html ? '
    ' + render.html + '
    ' : '') + + this.renderPluginFiles('Preview assets', render.assets || []) + + '
    '; + }, + + renderPluginNotes: function (notes) { + if (!notes.length) { return ''; } + return '
    ' + notes.map(function (note) { + return '' + esc(note) + ''; + }).join('') + '
    '; + }, + + submitField: function () { + var self = this; + var id = this.fieldForm.elements.id.value; + var rubricId = this.fieldForm.elements.rubric_id.value || this.currentRubricId; + if (window.Adminx && Adminx.CodeEditor && Adminx.CodeEditor.syncAll) { Adminx.CodeEditor.syncAll(this.fieldForm); } + this.clearErrors(this.fieldForm); + this.checkFieldAlias(true, function (ok) { + if (!ok) { return; } + self.ajax(self.base() + (id ? '/rubrics/fields/' + id : '/rubrics/' + rubricId + '/fields'), self.fieldFormData(), function () { + Adminx.Toast.show('Поле сохранено', 'success'); + if (Adminx.Drawer) { Adminx.Drawer.close('rubricFieldDrawer'); } + self.loadFields(); + self.applyFilterUrl(window.location.href, false); + }, function (json) { self.showErrors(self.fieldForm, json); }); + }); + }, + + fieldFormData: function () { + var data = new FormData(this.fieldForm); + var input = this.fieldForm.elements.rubric_field_default; + var kind = input ? input.getAttribute('data-admin-editor-kind') : ''; + if (input) { + data.set('rubric_field_default', this.editorValueToStorage(input.value, kind)); + } + return data; + }, + + checkFieldAlias: function (required, done) { + var alias = this.fieldForm.elements.rubric_field_alias.value.trim(); + var id = this.fieldForm.elements.id.value || 0; + var rubricId = this.fieldForm.elements.rubric_id.value || this.currentRubricId; + if (!alias && !required) { + this.setHint('[data-field-alias-state]', ''); + if (done) { done(false); } + return; + } + var self = this; + fetch(this.base() + '/rubrics/fields/alias-check?alias=' + encodeURIComponent(alias) + '&rubric_id=' + encodeURIComponent(rubricId) + '&id=' + encodeURIComponent(id), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (res) { return res.json(); }) + .then(function (json) { + var data = json.data || {}; + self.setHint('[data-field-alias-state]', data.message || '', data.valid && data.available); + if (done) { done(!!(data.valid && data.available)); } + }); + }, + + deleteField: function (row) { + if (!row || !window.confirm('Удалить поле? Значения документов для этого поля тоже будут удалены.')) { return; } + var data = new FormData(); + data.append('_csrf', this.form.elements._csrf.value); + var self = this; + this.ajax(this.base() + '/rubrics/fields/' + row.getAttribute('data-id') + '/delete', data, function () { + Adminx.Toast.show('Поле удалено', 'success'); + self.loadFields(); + self.applyFilterUrl(window.location.href, false); + }); + }, + + openSchemaRevisions: function (row) { + if (!row) { return; } + this.currentSchemaRevisionRubricId = parseInt(row.getAttribute('data-id'), 10) || 0; + this.currentSchemaRevisionId = 0; + this.currentSchemaRevisionFingerprint = ''; + var title = document.getElementById('rubricSchemaRevisionsTitle'); + var subtitle = document.querySelector('[data-schema-revisions-subtitle]'); + if (title) { title.textContent = 'Ревизии: ' + (row.getAttribute('data-title') || ('#' + this.currentSchemaRevisionRubricId)); } + if (subtitle) { subtitle.textContent = 'Поля, группы, раскладка и условия формы. Значения документов в снимок не входят.'; } + this.resetSchemaRevisionPreview(); + if (Adminx.Drawer) { Adminx.Drawer.open('rubricSchemaRevisionsDrawer'); } + this.refreshSchemaRevisions(); + }, + + refreshSchemaRevisions: function () { + if (!this.currentSchemaRevisionRubricId) { return; } + var self = this; + Adminx.Loader.show(); + Adminx.Ajax.request(this.base() + '/rubrics/' + this.currentSchemaRevisionRubricId + '/schema-revisions').then(function (payload) { + Adminx.Loader.hide(); + var response = payload.data || {}; + if (!response.success || !response.data) { + Adminx.Toast.show(response.message || 'Не удалось загрузить ревизии', 'error'); + return; + } + self.renderSchemaRevisions(response.data.revisions || []); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети при загрузке ревизий', 'error'); + }); + }, + + renderSchemaRevisions: function (items) { + var list = document.querySelector('[data-schema-revisions-list]'); + var count = document.querySelector('[data-schema-revisions-count]'); + var clear = document.querySelector('[data-schema-revisions-clear]'); + if (count) { count.textContent = items.length ? (items.length + ' снимков') : 'История пока пустая'; } + if (clear) { clear.disabled = !items.length; } + if (!list) { return; } + if (!items.length) { + list.innerHTML = '
    Ревизий пока нет. Первый снимок появится после изменения полей или конструктора.
    '; + this.resetSchemaRevisionPreview(); + return; + } + list.innerHTML = items.map(function (item) { + return '
    ' + + '' + + '
    Снимок #' + item.id + '' + esc(item.action_label || item.action) + '
    ' + + '' + esc(item.created_label || '-') + (item.author_name ? ' · ' + esc(item.author_name) : '') + '' + + '

    ' + esc(item.comment || 'Схема рубрики') + '

    ' + + '
    ' + item.fields_count + ' полей' + item.groups_count + ' групп
    ' + + '' + + '
    '; + }).join(''); + this.loadSchemaRevision(items[0].id); + }, + + loadSchemaRevision: function (id) { + id = parseInt(id, 10) || 0; + if (!id) { return; } + var self = this; + Adminx.Loader.show(); + Adminx.Ajax.request(this.base() + '/rubrics/schema-revisions/' + id).then(function (payload) { + Adminx.Loader.hide(); + var response = payload.data || {}; + if (!response.success || !response.data) { + Adminx.Toast.show(response.message || 'Не удалось открыть ревизию', 'error'); + return; + } + self.showSchemaRevision(response.data.revision || {}, response.data.impact || {}); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети при загрузке ревизии', 'error'); + }); + }, + + showSchemaRevision: function (revision, impact) { + this.currentSchemaRevisionId = parseInt(revision.id, 10) || 0; + this.currentSchemaRevisionFingerprint = impact.fingerprint || ''; + document.querySelectorAll('[data-schema-revision-open]').forEach(function (row) { + row.classList.toggle('is-active', row.getAttribute('data-schema-revision-open') === String(revision.id)); + }); + var title = document.querySelector('[data-schema-revision-title]'); + var meta = document.querySelector('[data-schema-revision-meta]'); + var preview = document.querySelector('[data-schema-revision-preview]'); + var remove = document.querySelector('[data-schema-revision-delete]'); + var restore = document.querySelector('[data-schema-revision-restore]'); + if (title) { title.textContent = 'Снимок #' + revision.id + ' · ' + (revision.action_label || revision.action || 'Схема'); } + if (meta) { meta.textContent = (revision.created_label || '-') + (revision.author_name ? ' · ' + revision.author_name : '') + (revision.comment ? ' · ' + revision.comment : ''); } + if (preview) { preview.innerHTML = this.renderSchemaImpact(impact); } + if (remove) { remove.disabled = !this.currentSchemaRevisionId; } + if (restore) { restore.disabled = !this.currentSchemaRevisionId || !impact.can_restore; } + }, + + renderSchemaImpact: function (impact) { + var summary = impact.summary || {}; + var fields = impact.fields || {}; + var groups = impact.groups || {}; + var blockers = impact.blockers || []; + var conditionText = impact.conditions_before === impact.conditions_after + ? (impact.conditions_after ? 'Условия формы останутся включены' : 'Условия формы останутся выключены') + : ('Условия формы: ' + (impact.conditions_before ? 'включены' : 'выключены') + ' → ' + (impact.conditions_after ? 'включены' : 'выключены')); + var html = '
    ' + + '
    ' + (blockers.length ? 'Восстановление заблокировано' : 'Данные документов останутся на месте') + '' + + '' + (blockers.length ? esc(blockers.join(' · ')) : 'Новые поля не удаляются: они перейдут в библиотеку конструктора. Значения документов не стираются.') + '
    ' + + '
    ' + + this.schemaMetric(summary.fields_restore || 0, 'Вернётся полей', 'cyan', 'ti-arrow-back-up') + + this.schemaMetric(summary.fields_change || 0, 'Изменится полей', 'blue', 'ti-pencil') + + this.schemaMetric(summary.fields_preserve || 0, 'Сохранится новых', 'green', 'ti-shield-check') + + this.schemaMetric(impact.documents_with_values || 0, 'С данными', 'amber', 'ti-file-database') + + '
    ' + + '
    ' + esc(conditionText) + 'Всего документов в рубрике: ' + (impact.documents_total || 0) + '
    ' + + this.renderSchemaDependencies(impact.dependencies || {}) + + '
    ' + + this.renderSchemaChangeGroup('Поля', fields) + + this.renderSchemaChangeGroup('Группы', groups) + + '
    '; + return html; + }, + + schemaMetric: function (value, label, color, icon) { + return '
    ' + value + '' + esc(label) + '
    '; + }, + + renderSchemaDependencies: function (dependencies) { + var summary = dependencies.summary || {}; + var warnings = dependencies.warnings || []; + var sections = [ + { key: 'requests', label: 'Запросы', icon: 'ti-filter-code' }, + { key: 'templates', label: 'Шаблоны', icon: 'ti-template' }, + { key: 'api', label: 'API-контракты', icon: 'ti-api' }, + { key: 'modules', label: 'Модули', icon: 'ti-plug-connected' } + ]; + var total = sections.reduce(function (count, section) { + return count + (dependencies[section.key] || []).length; + }, 0); + var body = sections.map(function (section) { + var items = dependencies[section.key] || []; + if (!items.length) { return ''; } + return '
    ' + + '' + esc(section.label) + '' + items.length + '' + + '
    ' + items.map(function (item) { + return '
    ' + esc(item.title || 'Зависимость') + '' + esc(item.meta || '') + '
    ' + + '
      ' + (item.details || []).map(function (detail) { return '
    • ' + esc(detail) + '
    • '; }).join('') + '
    '; + }).join('') + '
    '; + }).join(''); + var warningHtml = warnings.length + ? '
    ' + esc(warnings.join(' · ')) + '
    ' + : ''; + + return '
    ' + + '
    Зависимости схемы

    ' + + (total ? 'После восстановления проверьте перечисленные места.' : 'Прямых ссылок на изменяемые поля не найдено.') + + '

    ' + total + '
    ' + + '
    ' + + '' + (summary.requests || 0) + ' запросов' + + '' + (summary.templates || 0) + ' шаблонов' + + '' + (summary.api || 0) + ' API' + + '' + (summary.modules || 0) + ' модулей' + + '
    ' + warningHtml + body + '
    '; + }, + + renderSchemaChangeGroup: function (title, changes) { + var parts = [ + { key: 'restore', label: 'Будут возвращены', tone: 'cyan' }, + { key: 'preserve', label: 'Останутся без удаления', tone: 'green' } + ]; + var content = parts.map(function (part) { + var items = changes[part.key] || []; + if (!items.length) { return ''; } + return '
    ' + part.label + '
    ' + items.map(function (item) { + return '#' + item.id + '' + esc(item.title || '-') + '' + + (item.meta ? '' + esc(item.meta) + '' : '') + ''; + }).join('') + '
    '; + }).join(''); + var changed = (changes.change || []).map(function (item) { + var details = item.changes || []; + return '
    ' + + '
    ' + esc(item.title || '-') + '' + + (item.meta ? '' + esc(item.meta) + '' : '') + '
    ' + + '#' + item.id + '' + details.length + ' изм.
    ' + + '
    ' + details.map(function (detail) { + return '
    ' + esc(detail.label || 'Настройка') + '' + + '' + esc(detail.before || 'Не задано') + '' + + '' + + '' + esc(detail.after || 'Не задано') + '
    '; + }).join('') + '
    '; + }).join(''); + var changedBlock = changed + ? '
    Будут изменены' + changed + '
    ' + : ''; + return '
    ' + esc(title) + '
    ' + (changedBlock + content || '

    Изменений нет.

    ') + '
    '; + }, + + resetSchemaRevisionPreview: function () { + this.currentSchemaRevisionId = 0; + this.currentSchemaRevisionFingerprint = ''; + var preview = document.querySelector('[data-schema-revision-preview]'); + var title = document.querySelector('[data-schema-revision-title]'); + var meta = document.querySelector('[data-schema-revision-meta]'); + var remove = document.querySelector('[data-schema-revision-delete]'); + var restore = document.querySelector('[data-schema-revision-restore]'); + if (title) { title.textContent = 'Выберите ревизию'; } + if (meta) { meta.textContent = 'Здесь появится точный список изменений до восстановления.'; } + if (preview) { preview.innerHTML = '
    Снимок не выбранВыберите запись в истории выше.
    '; } + if (remove) { remove.disabled = true; } + if (restore) { restore.disabled = true; } + }, + + deleteSchemaRevision: function (id) { + id = parseInt(id || this.currentSchemaRevisionId, 10) || 0; + if (!id) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить снимок схемы?', + message: 'Эту точку восстановления нельзя будет вернуть.', + confirmLabel: 'Удалить', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(self.base() + '/rubrics/schema-revisions/' + id + '/delete').then(function (payload) { + Adminx.Loader.hide(); + var response = payload.data || {}; + if (!response.success) { Adminx.Toast.show(response.message || 'Не удалось удалить снимок', 'error'); return; } + Adminx.Toast.show(response.message || 'Снимок удалён', 'success'); + self.resetSchemaRevisionPreview(); + self.refreshSchemaRevisions(); + }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); }); + } + }); + }, + + clearSchemaRevisions: function () { + if (!this.currentSchemaRevisionRubricId) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить всю историю схемы?', + message: 'Все точки восстановления этой рубрики будут удалены.', + confirmLabel: 'Удалить все', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(self.base() + '/rubrics/' + self.currentSchemaRevisionRubricId + '/schema-revisions/delete').then(function (payload) { + Adminx.Loader.hide(); + var response = payload.data || {}; + if (!response.success) { Adminx.Toast.show(response.message || 'Не удалось очистить историю', 'error'); return; } + Adminx.Toast.show(response.message || 'История очищена', 'success'); + self.resetSchemaRevisionPreview(); + self.refreshSchemaRevisions(); + }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); }); + } + }); + }, + + restoreSchemaRevision: function () { + if (!this.currentSchemaRevisionId || !this.currentSchemaRevisionFingerprint) { return; } + var self = this; + var id = this.currentSchemaRevisionId; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Восстановить схему рубрики?', + message: 'Текущая схема сначала сохранится отдельным снимком. Новые поля и значения документов не удаляются.', + confirmLabel: 'Восстановить', + onConfirm: function () { + var data = new FormData(); + data.append('fingerprint', self.currentSchemaRevisionFingerprint); + Adminx.Loader.show(); + Adminx.Ajax.post(self.base() + '/rubrics/schema-revisions/' + id + '/restore', data).then(function (payload) { + Adminx.Loader.hide(); + var response = payload.data || {}; + if (!response.success) { Adminx.Toast.show(response.message || 'Не удалось восстановить схему', 'error'); return; } + Adminx.Toast.show(response.message || 'Схема восстановлена', 'success'); + self.resetSchemaRevisionPreview(); + self.refreshSchemaRevisions(); + if (self.currentRubricId === self.currentSchemaRevisionRubricId) { self.loadFields(); } + }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); }); + } + }); + }, + + fillGroupNew: function () { + this.clearErrors(this.groupForm); + this.groupForm.reset(); + this.groupForm.elements.id.value = ''; + this.groupForm.elements.rubric_id.value = this.currentRubricId; + this.groupForm.elements.group_condition.value = '{}'; + this.hydrateBuilderConditionRoot(this.groupForm.querySelector('[data-builder-condition]'), {}, 0); + document.getElementById('rubricGroupTitle').textContent = 'Новая группа'; + if (Adminx.Drawer) { Adminx.Drawer.open('rubricGroupDrawer'); } + }, + + fillGroupEdit: function (row) { + if (!row) { return; } + this.clearErrors(this.groupForm); + this.groupForm.elements.id.value = row.getAttribute('data-id'); + this.groupForm.elements.rubric_id.value = this.currentRubricId; + this.groupForm.elements.group_title.value = row.getAttribute('data-title') || ''; + this.groupForm.elements.group_description.value = row.getAttribute('data-description') || ''; + var condition = {}; + try { condition = JSON.parse(row.getAttribute('data-condition') || '{}'); } catch (e) {} + this.hydrateBuilderConditionRoot(this.groupForm.querySelector('[data-builder-condition]'), condition, 0); + document.getElementById('rubricGroupTitle').textContent = 'Редактирование группы #' + row.getAttribute('data-id'); + }, + + submitGroup: function (confirmed, fingerprint) { + var id = this.groupForm.elements.id.value; + var rubricId = this.groupForm.elements.rubric_id.value || this.currentRubricId; + var self = this; + this.clearErrors(this.groupForm); + var conditionRoot = this.groupForm.querySelector('[data-builder-condition]'); + var condition = this.captureBuilderConditionRoot(conditionRoot); + if (id && !confirmed && JSON.stringify(conditionRoot._conditionOriginal || {}) !== JSON.stringify(condition)) { + this.previewGroupCondition(id, condition); + return; + } + this.groupForm.elements.group_condition.value = JSON.stringify(condition); + var data = new FormData(this.groupForm); + if (fingerprint) { data.append('impact_fingerprint', fingerprint); } + this.ajax(this.base() + (id ? '/rubrics/groups/' + id : '/rubrics/' + rubricId + '/groups'), data, function () { + Adminx.Toast.show('Группа сохранена', 'success'); + if (Adminx.Drawer) { Adminx.Drawer.close('rubricGroupDrawer'); } + self.loadFields(); + }, function (json) { self.showErrors(self.groupForm, json); }); + }, + + previewGroupCondition: function (id, condition) { + var self = this; + var data = new FormData(); + data.append('_csrf', this.form.elements._csrf.value); + data.append('group_condition', JSON.stringify(condition || {})); + this.ajax(this.base() + '/rubrics/groups/' + id + '/condition/preview', data, function (json) { + var impact = (json.data || {}).impact || {}; + var persist = function () { self.submitGroup(true, impact.fingerprint || ''); }; + if (impact.requires_confirmation) { + self.showBuilderImpact(impact, persist); + return; + } + persist(); + }); + }, + + deleteGroup: function (row) { + if (!row || !window.confirm('Удалить группу? Поля будут перенесены в «Без группы».')) { return; } + var data = new FormData(); + data.append('_csrf', this.form.elements._csrf.value); + var self = this; + this.ajax(this.base() + '/rubrics/groups/' + row.getAttribute('data-id') + '/delete', data, function () { + Adminx.Toast.show('Группа удалена', 'success'); + self.loadFields(); + }); + }, + + fillGroupSelect: function () { + if (!this.fieldForm) { return; } + var select = this.fieldForm.querySelector('[data-field-groups-select]'); + if (!select) { return; } + var value = select.value; + select.innerHTML = '' + this.currentGroups.map(function (group) { + return ''; + }).join(''); + select.value = value || '0'; + }, + + dragStart: function (e) { + var rubricHandle = e.target.closest('[data-rubric-drag-handle]'); + var groupHandle = e.target.closest('[data-group-drag-handle]'); + var fieldHandle = e.target.closest('[data-field-drag-handle]'); + var paletteField = e.target.closest('[data-builder-palette-item]'); + if (rubricHandle) { + this.dragRubricRow = rubricHandle.closest('[data-rubric-row]'); + e.dataTransfer.effectAllowed = 'move'; + } else if (groupHandle) { + this.dragGroupNode = groupHandle.closest('[data-group-row]'); + e.dataTransfer.effectAllowed = 'move'; + } else if (fieldHandle) { + this.dragFieldNode = fieldHandle.closest('[data-field-row]'); + e.dataTransfer.effectAllowed = 'move'; + } else if (paletteField) { + this.dragBuilderFieldId = parseInt(paletteField.getAttribute('data-id'), 10) || 0; + this.dragFieldNode = null; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(this.dragBuilderFieldId)); + } + if (this.dragFieldNode) { this.dragFieldNode.classList.add('is-dragging'); } + }, + + dragOver: function (e) { + var row = e.target.closest('[data-rubric-row]'); + var group = e.target.closest('[data-group-row]'); + var field = e.target.closest('[data-field-row]'); + var fieldDrop = e.target.closest('[data-field-group-drop]'); + var palette = e.target.closest('[data-builder-palette]'); + if (this.dragRubricRow && row && row !== this.dragRubricRow) { + e.preventDefault(); + row.parentNode.insertBefore(this.dragRubricRow, this.before(e, row) ? row : row.nextSibling); + } else if (this.dragGroupNode && group && group !== this.dragGroupNode) { + e.preventDefault(); + group.parentNode.insertBefore(this.dragGroupNode, this.before(e, group) ? group : group.nextSibling); + } else if (this.dragFieldNode && (field || fieldDrop)) { + e.preventDefault(); + if (field && field !== this.dragFieldNode) { + field.parentNode.insertBefore(this.dragFieldNode, this.beforeBuilder(e, field) ? field : field.nextSibling); + } else if (fieldDrop) { + var list = fieldDrop.querySelector('[data-field-sortable]'); + if (list && !list.contains(this.dragFieldNode)) { list.appendChild(this.dragFieldNode); } + } + } else if (this.dragFieldNode && palette) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + } else if (this.dragBuilderFieldId && fieldDrop) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + } + }, + + drop: function (e) { + if (this.dragRubricRow) { + e.preventDefault(); + this.saveRubricOrder(); + } else if (this.dragGroupNode) { + e.preventDefault(); + this.saveGroupOrder(); + } else if (this.dragFieldNode && e.target.closest('[data-builder-palette]')) { + e.preventDefault(); + this.unplaceBuilderField(this.dragFieldNode); + } else if (this.dragBuilderFieldId) { + var fieldDrop = e.target.closest('[data-field-group-drop]'); + if (!fieldDrop) { return; } + e.preventDefault(); + var targetField = e.target.closest('[data-field-row]'); + this.placeBuilderField( + this.dragBuilderFieldId, + fieldDrop.getAttribute('data-field-group-drop'), + targetField && this.beforeBuilder(e, targetField) ? targetField : (targetField ? targetField.nextSibling : null) + ); + } else if (this.dragFieldNode) { + e.preventDefault(); + this.saveFieldOrder(); + } + }, + + dragEnd: function () { + if (this.dragFieldNode) { this.dragFieldNode.classList.remove('is-dragging'); } + this.dragRubricRow = null; + this.dragGroupNode = null; + this.dragFieldNode = null; + this.dragBuilderFieldId = 0; + }, + + before: function (event, target) { + var rect = target.getBoundingClientRect(); + return (event.clientY - rect.top) < rect.height / 2; + }, + + beforeBuilder: function (event, target) { + if (!target.closest('[data-builder-canvas]')) { return this.before(event, target); } + var rect = target.getBoundingClientRect(); + var y = event.clientY - rect.top; + if (y < rect.height * 0.3) { return true; } + if (y > rect.height * 0.7) { return false; } + return event.clientX < rect.left + rect.width / 2; + }, + + saveRubricOrder: function () { + var ids = Array.prototype.map.call(document.querySelectorAll('[data-rubric-row]'), function (row) { return row.getAttribute('data-id'); }); + var data = new FormData(); + data.append('_csrf', this.form.elements._csrf.value); + data.append('order', JSON.stringify(ids)); + this.ajax(this.base() + '/rubrics/reorder', data, function () { Adminx.Toast.show('Порядок рубрик сохранён', 'success'); }); + }, + + saveGroupOrder: function () { + var ids = Array.prototype.map.call(document.querySelectorAll('[data-group-row]'), function (row) { return row.getAttribute('data-id'); }); + var data = new FormData(); + data.append('_csrf', this.form.elements._csrf.value); + data.append('order', JSON.stringify(ids)); + this.ajax(this.base() + '/rubrics/' + this.currentRubricId + '/groups/reorder', data, function () { Adminx.Toast.show('Порядок групп сохранён', 'success'); }); + }, + + saveFieldOrder: function () { + var self = this; + document.querySelectorAll('[data-builder-canvas] [data-field-group-drop]').forEach(function (group) { + var groupId = parseInt(group.getAttribute('data-field-group-drop'), 10) || 0; + group.querySelectorAll('[data-field-row]').forEach(function (row) { + row.setAttribute('data-group', String(groupId)); + var field = self.builderField(row.getAttribute('data-id')); + if (field) { field.rubric_field_group = groupId; } + }); + }); + this.cleanupBuilderEmptyStates(); + this.updateBuilderGroupCounts(); + if (this.dragFieldNode) { this.selectBuilderField(this.dragFieldNode.getAttribute('data-id')); } + this.setBuilderDirty(true); + }, + + copy: function (text) { + if (navigator.clipboard) { + navigator.clipboard.writeText(text).then(function () { Adminx.Toast.show('Тег скопирован', 'success'); }); + } + }, + + setHint: function (selector, text, ok) { + var el = document.querySelector(selector); + if (!el) { return; } + el.textContent = text || ''; + el.classList.toggle('is-ok', !!ok); + el.classList.toggle('is-error', !!text && !ok); + }, + + clearErrors: function (form) { + if (!form) { return; } + Array.prototype.forEach.call(form.querySelectorAll('[data-error]'), function (el) { el.textContent = ''; }); + }, + + showErrors: function (form, json) { + Adminx.Toast.show((json && json.message) || 'Проверьте форму', 'error'); + var errors = (json && json.errors) || {}; + Object.keys(errors).forEach(function (name) { + var el = form.querySelector('[data-error="' + name + '"]'); + if (el) { el.textContent = errors[name]; } + }); + } + }; + + document.addEventListener('DOMContentLoaded', function () { + if (document.querySelector('.rubrics-panel')) { + Adminx.Rubrics.init(); + } + }); +})(window, document); diff --git a/adminx/modules/Rubrics/language/en/interface.xml b/adminx/modules/Rubrics/language/en/interface.xml new file mode 100644 index 0000000..f26460b --- /dev/null +++ b/adminx/modules/Rubrics/language/en/interface.xml @@ -0,0 +1,1144 @@ + + + Enable conditions for this rubric + [tag:img:/path] + [tag:c200x100:/path] + <img> from path ([tag:value] is supported) + from path ([tag:value] is supported) + $hook->fail('Reason') + (disabled) + , use + 12 columns + ; finished field output: + ; wet field: + <img> from the path (you can use [tag:value]) + [tag:c200x100:/path] + [tag:img:/path] + Header, main template and footer of the section. + HTML element template + HTML templates for displaying fields and requests. + ID is already available. Changed document properties are automatically written again without re-running hooks; field values ​​are saved after the handler. + ID, title, alias or description + Legacy fields cannot be deleted yet + OG markup + PHP and JavaScript are prohibited. Document and added field tags are available. + {{ type.enabled ? 'Disable' : 'Enable' }} type {{ type.name }} + {{ type.enabled ? 'Remove from new field choices' : 'Add to new field choices' }} + · turn on + Auto + Auto meta + Automatically + active + Active + Active + Alias + field alias + Announcement + Announcements + archive files and is not included in a clean installation. + Basic markup + Basic parameters of the category. + without alias + Without a group + Secure HTML Markup + The database uses + in the current filter, + Template option for category documents. + Type of documents + Type of documents in the category + Visibility, accessibility and commitment + Enabled + Enable + Enable group condition + Enable form conditions + Enable form conditions for a category + Enabled + Included + Manually + All types + everything. + Select the added field + Select a history entry above. + Select a field on the canvas + Select revision + Select a field type. + Choice + Selected fields as columns + off + Off + Off + Executed when a document page is generated. Does not apply to saving. + ready + A ready-made group of fields without changing existing data. + Group + A group of fields within a category. + Tag groups + Action on match + Before saving the document + Add new fields to selection + Add selected set + Add set + Add the required data to the view. + document + Document and fields via + document + documents + add. templates + Additional + Additional Templates + Additional template + Available + Available fields + Available tags + if the field is filled + if the field is empty + There are unsaved changes + Not yet posted on the form. + Deny change + Here you can change the caption, format and width. + The exact list of changes before the restore will appear here. + Default value + default value + entire field value + and + Changing the template does not rewrite the URLs of already created documents. + Changes will appear in the first entry. For a new document + Change value if matched + Image + The image is specified by the category field + or + Index value + Usage + Used + Used by documents through the `rubric_tmpl_id` field. + History of aliases + History of fields, groups, layouts and conditions of the form. + The document alias is added to the expanded template. An empty template leaves the document at the root. + How to make a template + How the address is formed + Canonical URL + Cards + kg + click - paste into active editor + Category code + Template code + Document form designer. + Context and system APIs + Public Output Context + Copy + The root legacy folder has been deleted; public and Adminx use only native handlers. + The root folder contains + Materials + Meta tags for social networks and instant messengers on the pages of this section. + Label + thumbnail: c-crop/r-resize/f-fit + You can hide a group or prevent changes to all its fields. + Field set + field name + Site name + Title, URL alias and description of the category. + Name, alias, type and group of the field. + Name, code or purpose + Name + Find field + Setting up a list in the documents section. + The settings will appear here and will be saved along with the layout. + Settings depending on the selected field type. + Set up a workspace for new rubrics. Disabling does not affect already created fields. + Set up the composition of the form, then immediately go to the materials of the selected category. + The native registry covers the working base + Not selected + Do not change + Don't index + Not saved + No handlers: + New group + New section + New meaning + New field + New types + New + Document save handlers and public output code. + Normal meaning + Regular parts of the path are written directly. Date markers are taken from the publication date of the document. + Required when showing + Mandatoryness is configured in the “Type Editor” → Validation. + Limit options + Description, use, and availability of each type when creating a field. + Main + Main conclusion + Basic template + Basic template and additional options. + Disabled + Disable + Disabled + Open the tag palette, insert a media field and add a format + Open documents + Open category materials + Formatting in the document + Design of the selected field. + Clear field + error + Registry errors + The first documents in the category, without saving settings. + Drag fields by the handle. + Drag the rows by the handle to change the order. + Field plugins + No types were found for the selected conditions. + Signature + Search tags + Search, value type and default value. + Show group + Show field + Show, hide, block and make fields mandatory depending on the entered values. + fields + fields + Fields in the database + Full + Half + Fields in a compact grid + Category fields + Once added, the field disappears from this list. + After saving the document + Consequences of recovery + Prefix + When publicly displaying a document + If there is a match, leave only the marked values + Example + Prefix + check + Also checked on the server + continue to work. + View the structure of categories. + Direct connections: + Publishing and output + Public teaser template for site lists and requests. + Empty category + equals + Sections of rubrics + Advanced settings + Real order, groups and width of fields. + Schema revisions + Rubric scheme revisions + Type editor + Presentation mode + Rubrik + The category will be created without additional fields. + Categories + No categories found. + Your own template + Properties of the selected field. + Related + System Document Table + Hide group + Hide field + Hidden + On the left, for example. No., $. + Snapshots of the circuit + No photo selected + Compatible with legacy code + Create material + Create material in the {{ rubric.rubric_title|e('html_attr') }} category + Create a category + Composition + Composition and order + Field Type Status + Save View + Save layout + List of categories + Add method + Method for adding a set + On the right, for example ₽, kg, %. + Will fire once when the condition is met. The value can be canceled before saving the document. + Average + Standard + Starter set of fields + Document structure + Suffix + Table + Current document: + Field type + The field type is stored in the category format, the admin editor builds adminx. + types; registered + Types of materials, their fields and design. + Field types + No field types found. + Third + Remove from selection of new fields + Delete photo + Narrow + Narrow margins fit into columns. + Condition for the whole group + Form conditions + Conditions of the document form + Set value + Participates in the search + Document form + Format + Store numeric index + part of the value №N (by “|”) + Quarter + Number + Numeric + Numeric value + What to do if there is a match + Document URL Template + Output template + Category template + Website template + Website template, document visibility and meta generation. + Output templates + Category templates + Width + Grid width and field prefix/suffix. + Width per line + Field width + Wide + — the entire payload of the document + — all transmitted field values + - change field + - setting + - stop saving + " data-document-color-picker aria-label="Pick a color"> + " placeholder="Value" data-builder-condition-value + " placeholder="Value name" data-settings-map-label> + " placeholder="For example, 0" data-settings-map-key> + "></i><span><b>The conditions will be + ' + (!result.linked_available ? ' disabled' : '') + '>Linked set</button></div></div>' + + (conflicts.length ? '<div class= + (the original field is of type + . Remove these connections first. + 0 or 1 + 3 columns + 4 columns + 6 columns + : alias is occupied by type + : You cannot automatically change the type of a populated or locally modified field + </b> requests</span> + </b> modules</span> + </b> templates</span> + </b><small>will change</small></span> + </b><small>fields with changes</small></span><span><b> + </b><small>hidden · blocked + </b><small>forms will change behavior</small></span><span><b> + </div><button class="btn btn-ghost" type="button" data-impact-cancel>Return</button><button class="btn btn-primary" type="button" data-impact-confirm><i class="ti ti-check"></i>Apply changes</button></div></div> + </span><small>Total documents in the category: + <b>Preview of edit mode</b> + <button class="btn btn-ghost btn-icon btn-sm ax-settings-map-remove" type="button" data-settings-map-remove data-tooltip-left="Delete" aria-label="Delete value"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm documents-drag-handle" type="button" data-doc-drag draggable="true" data-tooltip="Drag" aria-label="Drag"><i class="ti ti-grip-vertical"></i></button> + <button class="btn btn-ghost btn-icon btn-sm documents-value-remove" type="button" data-document-value-remove data-tooltip="Delete" aria-label="Delete"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm rubrics-action-danger" type="button" data-builder-condition-group-remove data-tooltip-left="Delete group" aria-label="Remove group"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm rubrics-action-danger" type="button" data-builder-condition-remove data-tooltip-left="Remove condition" aria-label="Remove condition"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm rubrics-action-danger" type="button" data-group-delete data-tooltip-left="Delete" aria-label="Delete"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm rubrics-action-edit" type="button" data-group-edit data-open-drawer="rubricGroupDrawer" data-tooltip-left="Edit" aria-label="Edit"><i class="ti ti-pencil"></i></button> + <button class="btn btn-ghost btn-icon btn-sm rubrics-group-drag" type="button" data-group-drag-handle data-tooltip-right="Drag" aria-label="Drag"><i class="ti ti-grip-vertical"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-builder-condition-add data-tooltip-left="Add condition" aria-label="Add condition"><i class="ti ti-plus"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-builder-condition-group-add data-tooltip-left="Add group" aria-label="Add nested group"><i class="ti ti-brackets"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-down data-tooltip="Below" aria-label="Below"><i class="ti ti-arrow-down"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-up data-tooltip="Above" aria-label="Above"><i class="ti ti-arrow-up"></i></button> + <button class="btn btn-ghost btn-sm" type="button" data-document-value-clear><i class="ti ti-trash"></i>Clear</button> + <button class="btn btn-secondary btn-sm" type="button" data-document-value-add><i class="ti ti-plus"></i>Add</button> + <button type="button" class="btn btn-ghost btn-icon btn-sm documents-relation-clear" data-document-relation-clear data-tooltip="Clear" aria-label="Clear" + <button type="button" class="documents-relation-choice" data-document-relation-pick data-tooltip="Select document"> + <div class="empty-state">Select a field type.</div> + <div class="empty-state">No groups created.</div> + <div class="empty-state">The field rules have not changed.</div> + <div class="empty-state">There are no revisions yet. The first snapshot will appear after changing the fields or constructor.</div> + <div class="modal-footer"><div class="mf-left text-secondary text-sm">Documents checked: + <div class="modal-header"><span class="dialog-icon warning"><i class="ti ti-git-compare"></i></span><div class="rubrics-impact-heading"><h3 id="rubricsBuilderImpactTitle">Check the impact of conditions</h3><p>Changes will affect the form of already created documents.</p></div><button class="modal-close" type="button" data-impact-cancel aria-label="Close"><i class="ti ti-x"></i></button></div> + <div class="rubrics-plugin-ok"><i class="ti ti-circle-check"></i>No critical problems found.</div> + <div class="rubrics-revision-change-list"><b class="rubrics-revision-change-list-title">Will be changed</b> + <div class="rubrics-revision-dependencies-head"><div><h5>Schema dependencies</h5><p> + <div class="rubrics-revision-empty"><i class="ti ti-history"></i><b>No photo selected</b><span>Select a history entry above.</span></div> + <div class="rubrics-revision-row-main"><div><b>Snapshot + + + <label class="field"><span class="field-label">Clip URL</span> + <label class="field"><span class="field-label">Height</span><input class="input" type="number" min="0" name=" + <label class="field"><span class="field-label">Value + <label class="field"><span class="field-label">Method</span><select class="select" name=" + <label class="field"><span class="field-label">Width</span><input class="input" type="number" min="0" name=" + <option value="">Not selected</option> + <p class="rubrics-impact-notice"><i class="ti ti-info-circle"></i>Verified + <p class="text-secondary">No changes.</p> + <select class="select" data-builder-condition-join aria-label="Group logic"><option value="and" + <span class="field-hint">Only documents from the category + : + <span class="rubrics-builder-field-condition" data-tooltip="Conditional field" aria-label="Conditional field"><i class="ti ti-git-branch"></i></span> + <span class="text-secondary text-sm">Options are not configured.</span> + >' + + '<div><b>' + esc(item.title) + '</b><small>Template + + '<div class= + >' + links.length + '</span></div>' + + links.map(function (link) { + var state = !link.available ? 'Source unavailable' : (link.update_available ? 'There is an update' : 'Relevant'); + var badge = !link.available ? 'badge-red' : (link.update_available ? 'badge-amber' : 'badge-green'); + return '<article><span><b>' + esc(link.title || link.code) + '</b><small><code>' + esc(link.code) + '</code></small></span>' + + '<span class= + ></i>' + esc(field.alias || 'without alias') + '</span><span><i class= + ></i>' + esc(field.type || '') + '</span><code>[field:' + esc(field.alias || field.id) + ']</code>'; + } + }, + + renderAdminViewTokens: function() { + var root = document.querySelector('[data-admin-view-tokens]'); + if (!root || !this.adminViewState) { return; } + var tokens = [ + { token: '[document:title]', label: 'Name' }, + { token: '[document:id]', label: 'ID' }, + { token: '[document:alias]', label: 'Alias' } + ]; + this.adminViewState.items.forEach(function (item) { + tokens.push({ token: '[field:' + (item.alias || item.field_id) + ']', label: item.label }); + }); + root.innerHTML = tokens.map(function (item) { + return '<button type= + ></i></span>' + + '<div><h3>' + esc(group.id ? group.title : 'Main fields') + '</h3><p>' + esc(group.description || (group.id ? 'Document field group' : 'Fields without separate group')) + '</p></div>' + + (groupCondition.tree ? '<span class= + ></i></span><span><b>' + esc(result.title || 'Field set') + '</b><small>' + esc(result.description || 'No description specified') + '</small></span></div>' + + '<div class= + ></i><b>All fields are placed</b><span>There are no new fields to add.</span></div>'; + } + if (count) { count.textContent = items.length; } + var search = document.querySelector('[data-builder-search]'); + if (search && search.value) { this.filterBuilderPalette(search.value); } + }, + + resetBuilderInspector: function() { + this.builderSelectedId = 0; + document.querySelectorAll('[data-builder-select].is-selected').forEach(function (node) { node.classList.remove('is-selected'); }); + var empty = document.querySelector('[data-builder-inspector-empty]'); + if (empty) { empty.hidden = false; } + if (this.builderForm) { this.builderForm.hidden = true; } + }, + + unplaceBuilderField: function (row) { + if (!row) { return; } + var id = parseInt(row.getAttribute('data-id'), 10) || 0; + var field = this.builderField(id); + var palette = document.querySelector('[data-builder-palette]'); + if (!field || !palette) { return; } + if (this.builderSelectedId === id) { this.captureBuilderInspector(); } + if (!palette.querySelector('[data-builder-palette-item][data-id= + ></i><b>Type settings</b></div>' + type.settings_html + : '<p class= + ></i><b>No fields added</b><span>Select them in the left column. The name, status and actions of the document will remain system.</span></div>'; + var count = document.querySelector('[data-admin-view-item-count]'); + if (count) { count.textContent = items.length + ' / 10'; } + }, + + renderAdminViewInspector: function() { + var empty = document.querySelector('[data-admin-view-inspector-empty]'); + var form = document.querySelector('[data-admin-view-inspector]'); + var item = this.adminViewItem(this.adminViewSelected); + if (empty) { empty.hidden = !!item; } + if (form) { form.hidden = !item; } + if (!item || !form) { return; } + form.querySelector('[data-admin-view-label]').value = item.label || ''; + form.querySelector('[data-admin-view-format]').value = item.format || 'text'; + var width = form.querySelector('input[name= + ></i><b>Linked sets</b><span class= + ></i><div><b>Type conflict</b><p>' + esc(conflicts.join(', ')) + '</p></div></div>' : '<div class= + ></i><div><b>The set is not ready for import</b><p>' + esc(message) + '</p></div></div>'; + stateRoot.hidden = true; + }; + var preview = function (file) { + if (!file) { return; } + nameRoot.textContent = file.name || 'field-set.json'; + if (file.size > 262144) { showError('File exceeds 256 KB'); return; } + var reader = new FileReader(); + reader.onerror = function () { showError('Could not read the selected file'); }; + reader.onload = function() { + descriptor = String(reader.result || ''); + stateRoot.hidden = false; + stateRoot.classList.add('is-loading'); + stateRoot.innerHTML = '<i class= + ></i><div><b>Standard list of documents</b><span>ID, title, category, status, date and system actions.</span></div></div>'; + return; + } + if (!this.adminViewPreview.length) { + root.innerHTML = '<div class= + ></i><span><b>Select JSON set</b><small>AVE.cms field-set, max 256 KB</small></span><input type= + ></i><span><b>Checking the set</b><small>Comparing alias and field types.</small></span>'; + previewRoot.hidden = true; + applyButton.disabled = true; + var data = new FormData(); + data.append('_csrf', self.form.elements._csrf.value); + data.append('descriptor', descriptor); + fetch(self.base() + '/rubrics/' + self.currentRubricId + '/field-sets/import/preview', { + method: 'POST', body: data, headers: { 'Accept': 'application/json' }, credentials: 'same-origin' + }).then(function (response) { return response.json(); }).then(function (json) { + if (!json.success) { throw new Error(json.message || 'The set failed the test'); } + var result = json.data || {}; + fingerprint = result.fingerprint || ''; + var conflicts = result.conflicts || []; + importHasConflicts = !!conflicts.length; + stateRoot.hidden = true; + previewRoot.hidden = false; + previewRoot.innerHTML = '<div class= + ></i><span><b>Check first</b><small>Documents and existing fields are not changed.</small></span></div><div data-field-set-import-preview hidden></div></div>' + + '<div class= + ></i><span>There are no documents in the category yet</span></div>'; + return; + } + var self = this; + if (this.adminViewState.mode === 'table') { + root.innerHTML = '<div class= + ></i><span>Drag the field to this group</span></div>'; + } + canvas += '</div></section>'; + }); + var canvasRoot = document.querySelector('[data-builder-canvas]'); + var paletteRoot = document.querySelector('[data-builder-palette]'); + var count = document.querySelector('[data-builder-field-count]'); + if (canvasRoot) { canvasRoot.innerHTML = canvas || '<div class= + ></i><span>Fields not found</span></div>'; + var count = document.querySelector('[data-admin-view-field-count]'); + if (count) { count.textContent = this.adminViewFields.length; } + }, + + renderAdminViewItems: function() { + var root = document.querySelector('[data-admin-view-items]'); + if (!root || !this.adminViewState) { return; } + var self = this; + var items = this.adminViewState.items || []; + root.innerHTML = items.length ? items.map(function (item, index) { + return '<article class= + ></i>Loading settings like...</div>'; + fetch(this.base() + '/rubrics/fields/' + field.Id + '/plugin', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (res) { return res.json(); }) + .then(function (json) { + if (self.builderSelectedId !== parseInt(field.Id, 10)) { return; } + var data = json.data || {}; + var type = data.type || {}; + root.innerHTML = type.settings_html + ? '<div class= + ></i>Import</button></div></div>'; + document.body.appendChild(overlay); + requestAnimationFrame(function () { overlay.classList.add('show'); }); + var fileInput = overlay.querySelector('[data-field-set-import-file]'); + var dropzone = overlay.querySelector('[data-field-set-dropzone]'); + var previewRoot = overlay.querySelector('[data-field-set-import-preview]'); + var stateRoot = overlay.querySelector('[data-field-set-import-state]'); + var applyButton = overlay.querySelector('[data-field-set-import-apply]'); + var nameRoot = overlay.querySelector('[data-field-set-import-name]'); + var descriptor = ''; + var fingerprint = ''; + var importMode = self.fieldSetMode; + var importHasConflicts = false; + var close = function() { + overlay.classList.remove('show'); + setTimeout(function () { overlay.remove(); }, 160); + }; + var showError = function (message) { + fingerprint = ''; + importHasConflicts = true; + applyButton.disabled = true; + previewRoot.hidden = false; + previewRoot.innerHTML = '<div class= + ></i>condition</span>' : '') + + '<span class= + ><b>' + conflicts.length + '</b><small>conflicts</small></span></div>' + + '<div class= + ><header><div><b>' + esc(document.document_title) + '</b><small>#' + esc(document.Id) + ' · ' + esc(document.document_alias || 'without alias') + '</small></div><i class= + ><span>' + + esc(group.title || 'Tags') + '</span><b>' + items.length + '</b></button>'); + var buttons = items.map(function (tag) { + var select = tag.select ? ' data-rubric-template-tag-select= + ><span>' + esc(item.created_label) + '</span><span>' + esc(item.size_label) + '</span><span>' + item.docs_count + ' documents</span></div>' + + '<div class= + ><span><b>' + (result.field_count || 0) + '</b><small>total fields</small></span><span><b>' + (result.create || 0) + '</b><small>will created</small></span><span><b>' + (result.reuse || 0) + '</b><small>already exists</small></span><span class= + ><span>After import</span><div class= + ><table><thead><tr><th>Document</th>' + + this.adminViewState.items.map(function (item) { return '<th>' + esc(item.label) + '</th>'; }).join('') + + '<th></th></tr></thead><tbody>' + this.adminViewPreview.map(function (document) { + return '<tr><td><b>' + esc(document.document_title) + '</b><small>#' + esc(document.Id) + '</small></td>' + + self.adminViewState.items.map(function (item) { return '<td>' + self.adminViewValueHtml(document, item) + '</td>'; }).join('') + + '<td><i class= + >All · AND</option><option value="or" + >Add the first category field.</div>'; } + if (paletteRoot) { paletteRoot.innerHTML = palette || '<div class= + >Additional templates have not been created.</div>'; + return; + } + root.innerHTML = templates.map(function (item) { + return '<article class= + >Forbidden</option></select></label> + >Import a set of fields</h3><p class= + >Any OR</option></select> + >Failed to load type settings.</p>'; }); + }, + + applyBuilderSettingsDraft: function (root, settings) { + Object.keys(settings || {}).forEach(function (key) { + var controls = root.querySelectorAll('[name= + >Independent copy</button><button type= + >Main fields</option>'; + this.currentGroups.forEach(function (group) { + html += '<option value= + >Fields not found.</div>'; + return; + } + root.innerHTML = fields.map(function (field) { + return '<button class= + >Checking the diagram before adding it to the current category.</p></div><button class= + >Allowed</option> + >This type has no additional settings.</p>'; + if (draftSettings !== null) { self.applyBuilderSettingsDraft(root, draftSettings); } + self.hydrateSettingsMaps(root); + self.toggleBuilderDefaultField(type.admin_editor || {}); + self.builderSettingsReady = true; + }) + .catch(function () { root.innerHTML = '<p class= + ]'); + var width = row ? row.getAttribute('data-width') : (layout.width || this.builderAutoWidth(field)); + var empty = document.querySelector('[data-builder-inspector-empty]'); + if (empty) { empty.hidden = true; } + form.hidden = false; + this.builderHydrating = true; + var typeLabel = form.querySelector('[data-builder-type-label]'); + var typeCode = form.querySelector('[data-builder-type-code]'); + if (typeLabel) { typeLabel.textContent = field.type_label || field.rubric_field_type || 'Unknown type'; } + if (typeCode) { typeCode.textContent = field.rubric_field_type || '—'; } + form.elements.title.value = Object.prototype.hasOwnProperty.call(draft, 'title') ? draft.title : (field.rubric_field_title || ('Field + this.fillBuilderGroupSelect(row ? row.closest('[data-field-group-drop]').getAttribute('data-field-group-drop') : field.rubric_field_group); + var widthInput = form.querySelector('input[name= + API and structured preview receive: + API contracts + data-document-value-empty>No rows added.</div> + data-field-set-import-close>Close</button><button class= + data-field-set-import-name>File not selected</span><button class= + Email, phone or URL with regime validation and secure public link. + HEX color with native picker and synchronous manual input. + HTML/PHP-template for displaying field values on the site. + Document ID + Field ID + } + + $data[ + Teaser ID/link + ID, name or alias + key|Name + Native edit-render is done via FieldRegistry. + Native type did not return HTML for edit mode + Native field type is not registered + Preview: inline JS handlers have been removed ( + Preview: legacy asset paths use the site root. + Preview: legacy asset paths are given to the site root ( + Preview: legacy script tags disabled ( + URL or relative link. + YouTube video with additional options. + · auto action + · auto actions + · there is a condition + · mandatory + · limited + » added + » imported + » updated and synchronized + Alias is free + Without a separate URL alias + Large text, cover, gallery and attached files. + Will be refunded + Boolean value: enabled or disabled. + PHP, JavaScript and executable HTML elements are not allowed in the administrative template + No more than 32 rules are allowed in one condition + Options + Product options + product options + The fields will return + Weight, + Video + Visual HTML editor with a full set of tools. + Medium height visual HTML editor. + on + Included + included + included + Nested group + instead of + Restore + Restore the rubric scheme? + Recovery + Recovery blocked + Recovery blocked: + All set fields are already in the category + All fields will again be available without conditional rules. + All restore points for this section will be deleted. + Select field type + Select from a list + The selected group does not belong to the category + Selected Default Keys + Select document + Select file + Off + turned off + Dropdown list + Height + Height, + Higher + g + Gallery + City/town + Group + Group + } + + protected static function settingLabel($key) + { + $known = array( + Group changed + Group not found + Group created + Group saved + Group deleted + Groups + Yes + yes + Yes / included + Document data will remain in place + Date + Date and time + Date or date and time in one type with a custom public format. + The date or datetime in the document. + Start and end dates for events, publications, and time blocks. + Default date + Two numbers + The two numeric parts that legacy stores via a vertical bar. + Length + Length + length|width|height + length|width|height|weight + For multiple mode, list the keys separated by commas. + For the set, include field types: + The native admin editor is not registered for the type. + The admin editor is not registered for the field type: + The native admin editor is not registered for the field type: + Before + Added set " + Document + $tokens .= + No document selected + documents. Total numbers for the entire category may be higher.</p> + Documents: + Documents and other files for downloading. + Longitude + All rules inside must match + House/building + Additionally + Additional images of the material. + Additional news materials. + Additional template + array( + Complements dependency analysis before restoring the rubric schema + Any rule inside is enough + available options + Single field with choice of plain text, Tiptap or CodeMirror. + Addiction + Heading + Loading... + Set · + Close + Blog Post + filled + Request + array( + Request + $details + ); + } + } + + protected static function collectRubricTemplates($rubricId, array $fields, array &$report) + { + $rubric = DB::query( + Request + } + + protected static function aliasMeta($alias) + { + $alias = trim((string) $alias); + return $alias !== + Requests + requests + meaning: + Meaning + value1|value2 + option values + from + Meas.</span></header> + Field changed + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success( + Fields will change + Changed group order + Changed field order + Illustrations and video recordings. + Illustrations and article files. + Imported set " + Index + index|region|city|street|house|room|latitude|longitude + Use 1 for enabled or 0 for disabled. + History cleared + The story is still empty + Source + Data source: this column + Linked set source unavailable + Original circuit + The catalog is built on this category + Apartment/office + Key + Code before saving + The dialing code is already occupied by the kernel or installed module: + Code after saving + Public output code + Column 1 + Column 2 + Column 3 + Column1|Column2|Column3 + Compact visual HTML editor. + Compact multiline text + Constructor + Contact + Content + Latin letter first, then letters, numbers or underscores, up to 20 characters + m + Media + Media and source + minimum|maximum + mm + Multiline text + Multiple Choice + Modules + Module "Catalog" + Module "Products" + You can use a period or a comma. In storage, the value is normalized automatically. + You can leave it blank or specify a number. + Can be left blank. The value will be substituted when creating the document. + Multi-select + Set " + The set conflicts with the rubric fields: + The set is not associated with this category + The field set is prepared + The field set belongs to a disabled module + Set checked + click to select + Title + Purpose of goods: + For example /catalog or https://example.com. + Settings + Directory settings + directory settings + Field settings: + Home + Start typing your name or ID + not included in + Not specified + Not specified + not specified + not filled in + does not contain + Failed to restore the schema + Failed to upload documents + Failed to load revisions + Failed to clear history + Failed to fully check directory dependencies. + Failed to validate requests and API contracts. + Failed to validate category templates. + Failed to serialize field set + Failed to delete photo + Insufficient rights + Unknown starting field set + Invalid group condition + Invalid constructor data + Incorrect field settings + Incorrect group order + Incorrect field order + Incorrect order of categories + Several options from the list, options are specified in the field settings. + Multiple values from a given set. + Several options noted. + Multiple checkboxes + No + No / disabled + Below + Nothing found + News + New fields are not deleted: they will go to the designer library. Document values ​​are not erased. + Cover + Updated set " + A regular text field for a short value. + mandatory + One option from the list. Options are specified by strings. + One option per line + One option per line. + One option per line. For keys, use key|Title. + Single or multiple selection with stable keys and JSON settings. + One entry per line. + One entry per line. For multiple columns, use the separator |. + One line per entry, parts separated by |. + One line per value. + One package per line: length|width|height|weight. + one of + One numeric value. + Single column list + End + Description + Main group + The main image for the page and material card. + The main content of a document with a visual editor. + The main content of the post. + Main news material. + Main material of the article. + Main text + Basic fields + Will remain without deletion + From + clear value + Network error + Network error when downloading revisions + Parameter + product feed parameters + Before recovery + Before adding the set " + Before changing group + Model::saveGroup($groupId, 0, Request::postAll()); + $this->captureSchemaAfter((int) $group[ + Before changing the group order + Before changing the order of fields + Before importing the set " + Before synchronizing the dial " + Before creating a field + Before saving the constructor + Before deleting a group + if (!Model::deleteGroup($groupId)) { + return $this->error( + Portable rubric form diagram " + Drag + Period + Default period + Repeatable links with signature. + A repeatable list of entries, legacy stores the values as a serialized array. + Iterable list of one value per line. + A repeatable three-part list, legacy uses the delimiter |. + YYYY-MM-DD, timestamp or empty value are supported. + Editor's tip + Show + Field + Field + } + + if (!isset($validGroups[$groupId])) { + throw new \RuntimeException( + Field + var preview = row.querySelector( + Field changed + Field not found + Field saved + Field removed + A complete visual HTML editor. + Fields + Fields without a separate group. + Fields and filters: + directory section fields or filters + Fields, groups, layout and conditions of the form. Document values ​​are not included in the snapshot. + Fields: + Order + The order of the groups is preserved + The order of the fields is preserved + The order of the categories has been preserved + After recovery, check the clients that are passing the alias or ID of these fields. + After recovery, check the listed locations. + rules + rule + Conditions preview is out of date. Check the changes again. + Condition preview is out of date. Check the change again. + Document view saved + Attached files + Apply migration of related field sets + Apply + Check out the group + Check the field + Check the category fields + View categories, groups and fields. + Simple multiline text + No direct links to mutable fields were found. + Publication + Public field output template. + Section + Section uses this rubric + catalog sections + Posted on the form + Field layout saved + Revisions deleted + Revisions: + Revision not found + Revision deleted + Region + Editing a group + }, + + submitGroup: function (confirmed, fingerprint) { + var id = this.groupForm.elements.id.value; + var rubricId = this.groupForm.elements.rubric_id.value || this.currentRubricId; + var self = this; + this.clearErrors(this.groupForm); + var conditionRoot = this.groupForm.querySelector( + Editing a field + self.setHint( + The editor and public output mode are set in the type settings. + Category + } + + $docs = self::documentCount($id); + if ($docs > 0) { + throw new \RuntimeException( + The category and starting set of fields have been created + Category not found + Rubric created + Category saved + Category deleted + Headings and fields + Categories: viewing + Category: management + With data + Linked set is synchronized + The connection to the set has been removed. Category fields are saved. + char. + Synchronization requires a manual solution: + Synchronized set " + System name + Hide + cm + Preview the set first + Check the set changes first + pictures + Snapshot before restoring a revision + $current + (int)$revisionId + ); + $result = Model::applySchemaSnapshot((int) $revision[ + Photo deleted + Contents + Default content + contains + Creation + Creating a Rubric and Initial Outline + Create, change, delete, sort and import categories and fields. + Field created + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success( + Sorting: + Sortable list of boxes with dimensions and weight for delivery calculations. + The composition of the section has changed after verification. Preview again. + New ones will be saved + It is saved as HTML and edited through a general rich-text editor. + Saved as legacy multi_line compatible HTML. + Saved form designer + List of entries + Key-value list + List of links + A list where the key is stored on the left and the signature is shown on the right. + Link + Link or document + Link to the original news source, if required. + Default link + Default links + Starter kit not available + Article + String + Default strings + The scheme has been restored + The layout changed after the preview. Check the revision again. + The category layout or fields have changed since review. Preview again. + The rubric scheme has been restored + Such an alias already exists in this section + This alias is already in use + Tag inserted + Text or field options + Default text + Text, cover, gallery and video. + Text, cover, gallery and link to the source. + Text value + Text content + The current layout will first be saved as a separate snapshot. New fields and document values ​​are not deleted. + Type not specified + Field type enabled + Field type not registered + The field type is not registered in the system FieldRegistry + Field type not found + Field type disabled + product catalog presentation + product feeds + Three numbers + The three numeric parts that legacy stores via a vertical bar. + Three column list + Remove + Remove document + Field removed + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success( + Delete + Delete everything + Delete all schema history? + Delete a field? The document values ​​for this field will also be deleted. + Delete the schematic snapshot? + Delete line + Specify an alias + Please enter a name + Enter the name of the group + Enter the field name + Specify the name of the template + street + Default packaging + Product packaging + Form condition + request conditions + Form conditions will remain enabled + Form conditions will remain disabled + Form conditions: + Conditions: + set value + Feed + The feed is based on this category + Checkbox + The form and server-side validation will begin to take into account the rules. + The value format is determined by the contact type setting. + String format: key|Name. One option per line. + String format: URL|Title. + Row format: column1|column2|column3. + Format: value1|value2. + Format: value1|value2|value3. + Format: start|end. YYYY-MM-DD and date with time are supported. + Rich text + Stored as a Unix timestamp and edited by the browser's native controls. + Color + Default color + Default numbers + Default number + A number with a customizable public format: amount, percentage, magnitude or rating. + Read and write use the scheme: + request template + Template in document + Template in request + The template is used by documents and cannot be deleted + Template for processing a request value, if the field type uses it. + Result template: + Category template not found + Templates + Request templates " + Width, + Latitude + This set is already associated with the category. Use the update in the list of related sets. + This field type is disabled in the registry + This restore point cannot be returned. + diff --git a/adminx/modules/Rubrics/language/en/runtime.xml b/adminx/modules/Rubrics/language/en/runtime.xml new file mode 100644 index 0000000..d428adb --- /dev/null +++ b/adminx/modules/Rubrics/language/en/runtime.xml @@ -0,0 +1,570 @@ + + + " data-document-color-picker aria-label="Pick a color"> + " placeholder="Link or document" data-document-media-url data-document-picker-type="all"> + (the original field is of type + . Remove these connections first. + 12 columns + 3 columns + 4 columns + 6 columns + : alias is occupied by type + : You cannot automatically change the type of a populated or locally modified field + <button class="btn btn-ghost btn-icon btn-sm documents-drag-handle" type="button" data-doc-drag draggable="true" data-tooltip="Drag" aria-label="Drag"><i class="ti ti-grip-vertical"></i></button> + <button class="btn btn-ghost btn-icon btn-sm documents-media-remove" type="button" data-document-media-remove data-tooltip="Delete" aria-label="Delete"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm documents-value-remove" type="button" data-document-value-remove data-tooltip="Delete" aria-label="Delete"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-down data-tooltip="Below" aria-label="Below"><i class="ti ti-arrow-down"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-up data-tooltip="Above" aria-label="Above"><i class="ti ti-arrow-up"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-relation-remove aria-label="Убрать документ" data-tooltip="Убрать"><i class="ti ti-x"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-down data-tooltip="Below" aria-label="Below"><i class="ti ti-arrow-down"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-up data-tooltip="Above" aria-label="Above"><i class="ti ti-arrow-up"></i></button> + <button class="btn btn-ghost btn-sm" type="button" data-document-media-clear><i class="ti ti-trash"></i>Clear</button> + <button class="btn btn-ghost btn-sm" type="button" data-document-media-import-folder><i class="ti ti-folder-plus"></i>From folder</button> + <button class="btn btn-ghost btn-sm" type="button" data-document-media-upload><i class="ti ti-upload"></i>Download</button> + <button class="btn btn-ghost btn-sm" type="button" data-document-value-clear><i class="ti ti-trash"></i>Clear</button> + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-doc-pick data-tooltip="Select document" aria-label="Select document"><i class="ti ti-file-search"></i></button></div> + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Выбрать файл" aria-label="Выбрать файл"><i class="ti + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Select file" aria-label="Select file"><i class="ti ti-paperclip"></i></button> + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Выбрать файл" aria-label="Выбрать файл"><i class="ti ti-photo-plus"></i></button> + <button class="btn btn-secondary btn-sm" type="button" data-document-media-add><i class="ti ti-plus"></i>Add</button> + <button class="btn btn-secondary btn-sm" type="button" data-document-relation-pick><i class="ti ti-file-plus"></i>Add document</button> + <button class="btn btn-secondary btn-sm" type="button" data-document-value-add><i class="ti ti-plus"></i>Add</button> + <button class="documents-media-thumb" type="button" data-document-media-pick aria-label="Выбрать файл"> + <button type="button" class="btn btn-ghost btn-icon btn-sm documents-relation-clear" data-document-relation-clear data-tooltip="Clear" aria-label="Clear" + <button type="button" class="documents-relation-choice" data-document-relation-pick data-tooltip="Select document"> + <label class="field"><span class="field-label">Clip URL</span> + <label class="field"><span class="field-label">Height</span><input class="input" type="number" min="0" name=" + <label class="field"><span class="field-label">Value + <label class="field"><span class="field-label">Width</span><input class="input" type="number" min="0" name=" + <option value="">Not selected</option> + <span class="field-hint">Only documents from the category # + <span class="text-secondary text-sm">Options are not configured.</span> + >Allowed</option> + API and structured preview receive: + Email, phone or URL with regime validation and secure public link. + HEX color with native picker and synchronous manual input. + HTML/PHP-template for displaying field values on the site. + Document ID or empty. + Default document IDs + Field ID # + Teaser ID/link + JSON API Documents + Native edit-render is done via FieldRegistry. + Native type did not return HTML for edit mode + Native field type is not registered + Preview: inline JS handlers have been removed ( + Preview: legacy asset paths use the site root. + Preview: legacy asset paths are given to the site root ( + Preview: legacy script tags disabled ( + URL or ID of the video. Additional legacy parts are stored via |. + URL or relative link. + YouTube video with additional options. + data-document-media-empty>No elements added.</div> + data-document-relation-empty>No documents selected.</p> + data-document-value-empty>No rows added.</div> + » added + » from revision + » imported + " uses alias " + » not installed + » updated and synchronized + Address + Default address + Alias is free + Analogues / together with the product + Without a group + Without a separate URL alias + Large text, cover, gallery and attached files. + Boolean value: enabled or disabled. + PHP, JavaScript and executable HTML elements are not allowed in the administrative template + The revision repeats the alias of the field “ + There are documents in the category, deletion is not available + Options + Product options + Weight, + Video + YouTube video with additional options. + Default video + Visual HTML editor with a full set of tools. + Medium height visual HTML editor. + Included + Recovery + Recovery blocked: + Restored from revision # + All set fields are already in the category + Select field type + Select from a list + The selected group does not belong to the category + Selected Default Keys + Displays all related documents from a category. + Off + Dropdown list + Height + Editor height + Height, + Dimensions + Default dimensions + Gallery + City/town + Group + Group # + Group changed + Group not found + Field group does not belong to category + Group created + Group saved + Group deleted + Yes + Yes / included + Date + Date and time + Date or date and time in one type with a custom public format. + The date or datetime in the document. + Start and end dates for events, publications, and time blocks. + Default date + Two numbers + The two numeric parts that legacy stores via a vertical bar. + Default range + Length + Length + Length, width and height with unit of measurement and JSON storage. + For multiple mode, list the keys separated by commas. + For the set, include field types: + The native admin editor is not registered for the type. + The admin editor is not registered for the field type: + The native admin editor is not registered for the field type: + Before + Added set " + Document + Document # + Document from the category + No document selected + Default document + Documents and other files for downloading. + Documents from the section + Documents from the category with a choice + Default Documents + Longitude + House/building + Additionally + Additional images of the material. + Additional news materials. + Additional template # + Complements dependency analysis before restoring the rubric schema + Single field with choice of plain text, Tiptap or CodeMirror. + Addiction + Module dependency + Heading + Heading within page content + Image title + Page title + Set · + Blog Post + Deny change + Request # + Meaning + Default value + Changed group # + Changed field # + Changed group order + Changed field order + Image + No image selected + Illustrations and video recordings. + Illustrations and article files. + Imported set " + Index + Use 1 for enabled or 0 for disabled. + Fields used: + Source + Data source: this column + Linked set source unavailable + Original circuit + Catalog + The catalog is built on this category + Default directories + Apartment/office + Code + Code before saving + The dialing code is already occupied by the kernel or installed module: + Default code + Code after saving + Public output code + Column 1 + Column 2 + Column 3 + Compact visual HTML editor. + Compact multiline text + Constructor + Contact + Content + Request contract # + Result Contract: + Latin letter first, then letters, numbers or underscores, up to 20 characters + Materials + Media + Media and source + Multiline text + Multiple Choice + Module + Module "Catalog" + Product Feeds module + Module "Products" + You can use a period or a comma. In storage, the value is normalized automatically. + You can leave it blank or specify a number. + Can be left blank. The value will be substituted when creating the document. + Multi-select + Set " + A set of images from the media browser. + The set conflicts with the rubric fields: + The set is not associated with this category + Field set + The field set is prepared + The field set belongs to a disabled module + Set checked + A set of document tags. + Title + File name or description + File name + Purpose of goods: + For example /catalog or https://example.com. + Directory settings + Default Settings + Field settings: + Field settings do not belong to the category + Home + Not specified + Not specified + Failed to fully check directory dependencies. + Failed to check product feed dependencies. + Failed to validate requests and API contracts. + Failed to validate category templates. + Failed to serialize field set + Insufficient rights + Unknown starting field set + Invalid group condition + Invalid constructor data + Incorrect field settings + Incorrect group order + Incorrect field order + Incorrect order of categories + Invalid layout element + You cannot change the type of field that is filled in. First move or clear its values. + Several options from the list, options are specified in the field settings. + Multiple values from a given set. + Several options noted. + Multiple linked documents, legacy stores selected IDs via |. + Multiple checkboxes + No + No / disabled + Lower and upper bound with unit of measurement and JSON storage. + New group + New field + News + Cover + Updated set " + Usually empty. The document will use the media browser. + Usually empty; the choice is determined by the settings of the legacy field. + A regular text field for a short value. + Plain text; Output parameters are specified by the field template. + One document ID per line or separated by commas. + One directory ID per line. Legacy document storage uses a serialized array. + One ID per line or separated by commas. + One ID per line. + One option from the list. Options are specified by strings. + One option per line. + One option per line. For keys, use key|Title. + One of the modules did not complete dependency analysis. + One path per line if you need to prepopulate the gallery. + One path per line. + One tag per line. + Single or multiple selection with stable keys and JSON settings. + One entry per line. + One entry per line. For multiple columns, use the separator |. + One line per entry, parts separated by |. + One line per value. + One package per line: length|width|height|weight. + One image from the media browser. + One numeric value. + Single column list + End + Description + Description of the image + The main image for the page and material card. + The main content of a document with a visual editor. + The main content of the post. + Main news material. + Main material of the article. + Main text + Main page text with visual editor + Main category template + Basic fields + From + Download folder + Parameter + Before recovery + Before adding the set " + Before changing group # + Before changing the field # + Before changing the group order + Before changing the order of fields + Before importing the set " + Before synchronizing the dial " + Before creating a group + Before creating a field + Before saving the constructor + Before deleting a group # + Before deleting the field # + Portable rubric form diagram " + Period + Default period + Repeatable links with signature. + A repeatable list of entries, legacy stores the values as a serialized array. + Iterable list of one value per line. + A repeatable three-part list, legacy uses the delimiter |. + Basement + YYYY-MM-DD, timestamp or empty value are supported. + Editor's tip + Search for documents from a category + Show + Field + Field # + Field changed + Field used: + Field not found + Field created + Field saved + Field removed + A complete visual HTML editor. + Fields without a separate group. + Fields and filters: + Fields: + Order + The order of the groups is preserved + The order of the fields is preserved + The order of the categories has been preserved + After recovery, check the clients that are passing the alias or ID of these fields. + Conditions preview is out of date. Check the changes again. + Condition preview is out of date. Check the change again. + Document view saved + Prefix + Attached files + Apply rubric migration before enabling form conditions + Apply migration of related field sets + Apply + Check out the group + Check the field + Check the category fields + Check the template + View categories, groups and fields. + Simple multiline text + Publication + Public field output template. + Default paths + Default file path + Default path + Section # + Section uses this rubric + Directory section # + Posted on the form + Field layout saved + Expanded gallery + Revisions deleted + Revision not found + Revision deleted + Region + Source code editor. + Editor mode + The editor and public output mode are set in the type settings. + Rubric #1 is systemic and cannot be deleted + The category and starting set of fields have been created + Rubric used: + Category not found + Rubric created + Category saved + Category deleted + Source category + Headings and fields + Categories: viewing + Category: management + Related documents with search, legacy stores the selected IDs via |. + Linked set is synchronized + Communication with the legacy catalog module. + The connection to the set has been removed. Category fields are saved. + Link to one document from the selected category. + Synchronization requires a manual solution: + Synchronized set " + System name + Hide + Complex field of images. The Adminx editor will be developed on top of the media browser. + Preview the set first + First apply rubric migrations to group conditions + Check the set changes first + Snapshot before restoring a revision # + Contents + Default content + Page content + Group created # + Creation + Creating a Rubric and Initial Outline + Create, change, delete, sort and import categories and fields. + Field created # + Sorting: + Sortable list of boxes with dimensions and weight for delivery calculations. + The composition of the section has changed after verification. Preview again. + It is saved as HTML and edited through a general rich-text editor. + Saved as legacy multi_line compatible HTML. + Saved form designer + Saved new field " + List of entries + List of values + Key-value list + List of related documents. Legacy data can be found as ID separated by commas, JSON or serialize. + List of links + List of files associated with the document. + A list where the key is stored on the left and the signature is shown on the right. + Link + Link to the original news source, if required. + Default link + Default links + Starter kit not available + Article + String + Default strings + Structured postal address with optional coordinates. + Suffix + The layout changed after the preview. Check the revision again. + The category layout or fields have changed since review. Preview again. + The rubric scheme has been restored + Such an alias already exists in this section + This alias is already in use + Tags + Default tags + Text to Image + Default text + Text, cover, gallery and video. + Text, cover, gallery and link to the source. + A text value that legacy turns into an image. + Text content + Teaser + Document teasers + Type not specified + Field type + Field type " + Field type enabled + Field type not registered + The field type is not registered in the system FieldRegistry + Field type not found + Field type disabled + Three numbers + The three numeric parts that legacy stores via a vertical bar. + Three column list + Group deleted # + Field # removed + Specify an alias + Please enter a name + Enter the name of the group + Enter the field name + Specify the name of the template + Specify the field name # + street + Default packaging + Product packaging + An ordered list of documents that are displayed as teasers. + Form condition + Conditions: + Participates in the search + File + File for download. + File not selected + Document files + Default files + Feed # + The feed is based on this category + Checkbox + Format #rgb or #rrggbb. + The value format is determined by the contact type setting. + String format: URL|Title. + String format: key|Name. One option per line. + Row format: column1|column2|column3. + Format: length|width|height. + Format: value1|value2. + Format: value1|value2|value3. + Format: index|region|city|street|house|room|latitude|longitude. + Format: minimum|maximum. One border can be left empty. + Format: start|end. YYYY-MM-DD and date with time are supported. + Rich text + Stored as a Unix timestamp and edited by the browser's native controls. + Color + Default color + Default numbers + Number + Default number + A number with a customizable public format: amount, percentage, magnitude or rating. + Numeric value + Numeric range + Read and write use the scheme: + Sample # + request template + Template in document + Template in request + Output template + The template is used by documents and cannot be deleted + Template for processing a request value, if the field type uses it. + Result template: + Category template not found + Category template created + Category template saved + Category template removed + Request templates " + Category templates saved + Cap + Width + Field width + Width, + Latitude + This set is already associated with the category. Use the update in the list of related sets. + This field type is disabled in the registry + Code language + active + product options + on + included + instead of + off + g + yes + available options + filled + requests + meaning: + option values + used: + kg + m + mm + click to select + directory settings + not included in + not specified + not filled in + does not belong to this category + does not contain + mandatory + one of + clear value + product feed parameters + repeated in layout + directory section fields or filters + rules + rule + catalog sections + char. + cm + contains + product catalog presentation + product feeds + already busy with another section + request conditions + set value + diff --git a/adminx/modules/Rubrics/language/ru/interface.xml b/adminx/modules/Rubrics/language/ru/interface.xml new file mode 100644 index 0000000..b75fb96 --- /dev/null +++ b/adminx/modules/Rubrics/language/ru/interface.xml @@ -0,0 +1,1144 @@ + + + Включить условия для этой рубрики + [tag:img:/путь] + [tag:c200x100:/путь] + <img> из пути (можно [tag:value]) + из пути (можно [tag:value]) + $hook->fail('Причина') + (отключён) + , используйте + 12 колонок + ; готовый вывод поля: + ; сырое поле: + <img> из пути (можно [tag:value]) + [tag:c200x100:/путь] + [tag:img:/путь] + Header, основной шаблон и footer рубрики. + HTML-шаблон элемента + HTML-шаблоны отображения поля и request. + ID уже доступен. Изменённые свойства документа автоматически записываются ещё раз без повторного запуска хуков; значения полей сохраняются после обработчика. + ID, название, алиас или описание + Legacy fields пока нельзя удалять + OG-разметка + PHP и JavaScript запрещены. Доступны теги документа и добавленных полей. + {{ type.enabled ? 'Отключить' : 'Включить' }} тип {{ type.name }} + {{ type.enabled ? 'Убрать из выбора новых полей' : 'Добавить в выбор новых полей' }} + · включите + Авто + Авто meta + Автоматически + активна + Активны + Активных + Алиас + алиас поля + Анонс + Анонсы + архивных файлов и не входит в чистую установку. + Базовая разметка + Базовые параметры рубрики. + без алиаса + Без группы + Безопасная HTML-разметка + В БД используется + в текущем фильтре, + Вариант шаблона для документов рубрики. + Вид документов + Вид документов рубрики + Видимость, доступность и обязательность + Включена + Включить + Включить условие группы + Включить условия формы + Включить условия формы для рубрики + Включён + Включённые + Вручную + Все типы + всего. + Выберите добавленное поле + Выберите запись в истории выше. + Выберите поле на холсте + Выберите ревизию + Выберите тип поля. + Выбор + Выбранные поля как колонки + выкл + Выключена + Выключены + Выполняется при формировании страницы документа. Не относится к сохранению. + готов + Готовая группа полей без изменения существующих данных. + Групп + Группа полей внутри рубрики. + Группы тегов + Действие при совпадении + До сохранения документа + Добавить в выбор новых полей + Добавить выбранный набор + Добавить набор + Добавьте нужные данные в представление. + документ + Документ и поля через + документа + документов + доп. шаблонов + Дополнительные + Дополнительные шаблоны + Дополнительный шаблон + Доступны + Доступные поля + Доступные теги + если поле заполнено + если поле пустое + Есть несохранённые изменения + Ещё не размещены на форме. + Запретить изменение + Здесь можно изменить подпись, формат и ширину. + Здесь появится точный список изменений до восстановления. + Значение по умолчанию + значение по умолчанию + значение поля целиком + и + Изменение шаблона не переписывает URL уже созданных документов. + Изменения попадут в первую запись. Для нового документа + Изменить значение при совпадении + Изображение + Изображение задаётся полем рубрики + или + Индексировать значение + Использование + Используемые + Используются документами через поле `rubric_tmpl_id`. + История алиасов + История полей, групп, раскладки и условий формы. + К развёрнутому шаблону добавляется alias документа. Пустой шаблон оставляет документ в корне. + Как составить шаблон + Как формируется адрес + Канонический URL + Карточки + кг + клик — вставить в активный редактор + Код рубрики + Код шаблона + Конструктор формы документа. + Контекст и системные API + Контекст публичного вывода + Копия + Корневая legacy-папка удалена; public и Adminx используют только нативные обработчики. + Корневая папка содержит + Материалы + Метатеги для соцсетей и мессенджеров на страницах этой рубрики. + Метка + миниатюра: c-обрезка/r-ресайз/f-вписать + Можно скрыть группу или запретить изменение всех её полей. + Набор полей + название поля + Название сайта + Название, URL-алиас и описание рубрики. + Название, алиас, тип и группа поля. + Название, код или назначение + Наименование + Найти поле + Настройка списка в разделе документов. + Настройки появятся здесь и сохранятся вместе с раскладкой. + Настройки, зависящие от выбранного типа поля. + Настройте рабочий набор для новых рубрик. Отключение не влияет на уже созданные поля. + Настройте состав формы, затем сразу переходите к материалам выбранной рубрики. + Нативный реестр покрывает рабочую базу + Не выбран + Не изменять + Не индексировать + Не сохранено + Нет обработчиков: + Новая группа + Новая рубрика + Новое значение + Новое поле + Новые типы + Новый + Обработчики сохранения документа и код публичного вывода. + Обычное значение + Обычные части пути пишутся напрямую. Маркеры даты берутся из даты публикации документа. + Обязательно при показе + Обязательность настраивается в «Редакторе типа» → Валидация. + Ограничить варианты + Описание, использование и доступность каждого типа при создании поля. + Основной + Основной вывод + Основной шаблон + Основной шаблон и дополнительные варианты. + Отключено + Отключить + Отключён + Откройте палитру тегов, вставьте медиаполе и добавьте формат + Открыть документы + Открыть материалы рубрики + Оформление в документе + Оформление выбранного поля. + Очистить поле + ошибка + Ошибок реестра + Первые документы рубрики, без сохранения настроек. + Перетаскивайте поля за маркер. + Перетащите строки за ручку, чтобы изменить порядок. + Плагины полей + По выбранным условиям типы не найдены. + Подпись + Поиск тегов + Поиск, тип значения и значение по умолчанию. + Показывать группу + Показывать поле + Показывают, скрывают, блокируют и делают поля обязательными в зависимости от введённых значений. + Полей + полей + Полей в БД + Полная + Половина + Поля в компактной сетке + Поля рубрики + После добавления поле исчезает из этого списка. + После сохранения документа + Последствия восстановления + Префикс + При публичном выводе документа + При совпадении оставить только отмеченные значения + Пример + Приставка + проверить + Проверяется также на сервере + продолжают работать. + Просмотр структуры рубрик. + Прямые подключения: + Публикация и вывод + Публичный teaser-шаблон для списков и запросов сайта. + Пустая рубрика + равен + Разделы рубрик + Расширенные настройки + Реальный порядок, группы и ширина полей. + Ревизии схемы + Ревизии схемы рубрики + Редактор типа + Режим представления + Рубрик + Рубрика будет создана без дополнительных полей. + Рубрики + Рубрики не найдены. + Свой шаблон + Свойства выбранного поля. + Связанный + Системная таблица документов + Скрывать группу + Скрывать поле + Скрыты + Слева, напр. №, $. + Снимки схемы + Снимок не выбран + Совместимость со старым кодом + Создать материал + Создать материал в рубрике {{ rubric.rubric_title|e('html_attr') }} + Создать рубрику + Состав + Состав и порядок + Состояние типов полей + Сохранить представление + Сохранить раскладку + Список рубрик + Способ добавления + Способ добавления набора + Справа, напр. ₽, кг, %. + Сработает один раз, когда условие станет выполненным. Значение можно отменить до сохранения документа. + Средняя + Стандартный + Стартовый набор полей + Структура документов + Суффикс + Таблица + Текущий документ: + Тип поля + Тип поля хранится в формате рубрик, админский редактор строит adminx. + типов; зарегистрировано + Типы материалов, их поля и оформление. + Типы полей + Типы полей не найдены. + Треть + Убрать из выбора новых полей + Удалить снимок + Узкая + Узкие поля встают в колонки. + Условие для всей группы + Условия формы + Условия формы документа + Установить значение + Участвует в поиске + Форма документа + Формат + Хранить числовой индекс + часть значения №N (по «|») + Четверть + Число + Числовое + Числовое значение + Что делать при совпадении + Шаблон URL документов + Шаблон вывода + Шаблон рубрики + Шаблон сайта + Шаблон сайта, видимость документов и генерация meta. + Шаблоны вывода + Шаблоны рубрики + Ширина + Ширина в сетке и приставка/суффикс поля. + Ширина в строке + Ширина поля + Широкая + — весь payload документа + — все переданные значения полей + — изменить поле + — настройка + — остановить сохранение + " data-document-color-picker aria-label="Выбрать цвет"> + " placeholder="Значение" data-builder-condition-value + " placeholder="Название значения" data-settings-map-label> + " placeholder="Например, 0" data-settings-map-key> + "></i><span><b>Условия будут + ' + (!result.linked_available ? ' disabled' : '') + '>Связанный набор</button></div></div>' + + (conflicts.length ? '<div class= + (исходное поле имеет тип + . Сначала уберите эти связи. + 0 или 1 + 3 колонки + 4 колонки + 6 колонок + : alias занят типом + : нельзя автоматически изменить тип заполненного или локально изменённого поля + </b> запросов</span> + </b> модулей</span> + </b> шаблонов</span> + </b><small>изменится</small></span> + </b><small>полей с изменениями</small></span><span><b> + </b><small>скрыто · заблокировано + </b><small>форм изменят поведение</small></span><span><b> + </div><button class="btn btn-ghost" type="button" data-impact-cancel>Вернуться</button><button class="btn btn-primary" type="button" data-impact-confirm><i class="ti ti-check"></i>Применить изменения</button></div></div> + </span><small>Всего документов в рубрике: + <b>Preview edit-режима</b> + <button class="btn btn-ghost btn-icon btn-sm ax-settings-map-remove" type="button" data-settings-map-remove data-tooltip-left="Удалить" aria-label="Удалить значение"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm documents-drag-handle" type="button" data-doc-drag draggable="true" data-tooltip="Перетащить" aria-label="Перетащить"><i class="ti ti-grip-vertical"></i></button> + <button class="btn btn-ghost btn-icon btn-sm documents-value-remove" type="button" data-document-value-remove data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm rubrics-action-danger" type="button" data-builder-condition-group-remove data-tooltip-left="Удалить группу" aria-label="Удалить группу"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm rubrics-action-danger" type="button" data-builder-condition-remove data-tooltip-left="Удалить условие" aria-label="Удалить условие"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm rubrics-action-danger" type="button" data-group-delete data-tooltip-left="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm rubrics-action-edit" type="button" data-group-edit data-open-drawer="rubricGroupDrawer" data-tooltip-left="Изменить" aria-label="Изменить"><i class="ti ti-pencil"></i></button> + <button class="btn btn-ghost btn-icon btn-sm rubrics-group-drag" type="button" data-group-drag-handle data-tooltip-right="Перетащить" aria-label="Перетащить"><i class="ti ti-grip-vertical"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-builder-condition-add data-tooltip-left="Добавить условие" aria-label="Добавить условие"><i class="ti ti-plus"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-builder-condition-group-add data-tooltip-left="Добавить группу" aria-label="Добавить вложенную группу"><i class="ti ti-brackets"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-down data-tooltip="Ниже" aria-label="Ниже"><i class="ti ti-arrow-down"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-up data-tooltip="Выше" aria-label="Выше"><i class="ti ti-arrow-up"></i></button> + <button class="btn btn-ghost btn-sm" type="button" data-document-value-clear><i class="ti ti-trash"></i>Очистить</button> + <button class="btn btn-secondary btn-sm" type="button" data-document-value-add><i class="ti ti-plus"></i>Добавить</button> + <button type="button" class="btn btn-ghost btn-icon btn-sm documents-relation-clear" data-document-relation-clear data-tooltip="Очистить" aria-label="Очистить" + <button type="button" class="documents-relation-choice" data-document-relation-pick data-tooltip="Выбрать документ"> + <div class="empty-state">Выберите тип поля.</div> + <div class="empty-state">Группы не созданы.</div> + <div class="empty-state">Правила полей не изменились.</div> + <div class="empty-state">Ревизий пока нет. Первый снимок появится после изменения полей или конструктора.</div> + <div class="modal-footer"><div class="mf-left text-secondary text-sm">Документов проверено: + <div class="modal-header"><span class="dialog-icon warning"><i class="ti ti-git-compare"></i></span><div class="rubrics-impact-heading"><h3 id="rubricsBuilderImpactTitle">Проверьте влияние условий</h3><p>Изменения затронут форму уже созданных документов.</p></div><button class="modal-close" type="button" data-impact-cancel aria-label="Закрыть"><i class="ti ti-x"></i></button></div> + <div class="rubrics-plugin-ok"><i class="ti ti-circle-check"></i>Критичных проблем не найдено.</div> + <div class="rubrics-revision-change-list"><b class="rubrics-revision-change-list-title">Будут изменены</b> + <div class="rubrics-revision-dependencies-head"><div><h5>Зависимости схемы</h5><p> + <div class="rubrics-revision-empty"><i class="ti ti-history"></i><b>Снимок не выбран</b><span>Выберите запись в истории выше.</span></div> + <div class="rubrics-revision-row-main"><div><b>Снимок + + + <label class="field"><span class="field-label">URL ролика</span> + <label class="field"><span class="field-label">Высота</span><input class="input" type="number" min="0" name=" + <label class="field"><span class="field-label">Значение + <label class="field"><span class="field-label">Метод</span><select class="select" name=" + <label class="field"><span class="field-label">Ширина</span><input class="input" type="number" min="0" name=" + <option value="">Не выбрано</option> + <p class="rubrics-impact-notice"><i class="ti ti-info-circle"></i>Проверены + <p class="text-secondary">Изменений нет.</p> + <select class="select" data-builder-condition-join aria-label="Логика группы"><option value="and" + <span class="field-hint">Только документы из рубрики + : + <span class="rubrics-builder-field-condition" data-tooltip="Условное поле" aria-label="Условное поле"><i class="ti ti-git-branch"></i></span> + <span class="text-secondary text-sm">Варианты не настроены.</span> + >' + + '<div><b>' + esc(item.title) + '</b><small>Шаблон + + '<div class= + >' + links.length + '</span></div>' + + links.map(function (link) { + var state = !link.available ? 'Источник недоступен' : (link.update_available ? 'Есть обновление' : 'Актуален'); + var badge = !link.available ? 'badge-red' : (link.update_available ? 'badge-amber' : 'badge-green'); + return '<article><span><b>' + esc(link.title || link.code) + '</b><small><code>' + esc(link.code) + '</code></small></span>' + + '<span class= + ></i>' + esc(field.alias || 'без алиаса') + '</span><span><i class= + ></i>' + esc(field.type || '') + '</span><code>[field:' + esc(field.alias || field.id) + ']</code>'; + } + }, + + renderAdminViewTokens: function () { + var root = document.querySelector('[data-admin-view-tokens]'); + if (!root || !this.adminViewState) { return; } + var tokens = [ + { token: '[document:title]', label: 'Название' }, + { token: '[document:id]', label: 'ID' }, + { token: '[document:alias]', label: 'Alias' } + ]; + this.adminViewState.items.forEach(function (item) { + tokens.push({ token: '[field:' + (item.alias || item.field_id) + ']', label: item.label }); + }); + root.innerHTML = tokens.map(function (item) { + return '<button type= + ></i></span>' + + '<div><h3>' + esc(group.id ? group.title : 'Основные поля') + '</h3><p>' + esc(group.description || (group.id ? 'Группа полей документа' : 'Поля без отдельной группы')) + '</p></div>' + + (groupCondition.tree ? '<span class= + ></i></span><span><b>' + esc(result.title || 'Набор полей') + '</b><small>' + esc(result.description || 'Описание не задано') + '</small></span></div>' + + '<div class= + ></i><b>Все поля размещены</b><span>Новых полей для добавления нет.</span></div>'; + } + if (count) { count.textContent = items.length; } + var search = document.querySelector('[data-builder-search]'); + if (search && search.value) { this.filterBuilderPalette(search.value); } + }, + + resetBuilderInspector: function () { + this.builderSelectedId = 0; + document.querySelectorAll('[data-builder-select].is-selected').forEach(function (node) { node.classList.remove('is-selected'); }); + var empty = document.querySelector('[data-builder-inspector-empty]'); + if (empty) { empty.hidden = false; } + if (this.builderForm) { this.builderForm.hidden = true; } + }, + + unplaceBuilderField: function (row) { + if (!row) { return; } + var id = parseInt(row.getAttribute('data-id'), 10) || 0; + var field = this.builderField(id); + var palette = document.querySelector('[data-builder-palette]'); + if (!field || !palette) { return; } + if (this.builderSelectedId === id) { this.captureBuilderInspector(); } + if (!palette.querySelector('[data-builder-palette-item][data-id= + ></i><b>Настройки типа</b></div>' + type.settings_html + : '<p class= + ></i><b>Поля не добавлены</b><span>Выберите их в левой колонке. Название, состояние и действия документа останутся системными.</span></div>'; + var count = document.querySelector('[data-admin-view-item-count]'); + if (count) { count.textContent = items.length + ' / 10'; } + }, + + renderAdminViewInspector: function () { + var empty = document.querySelector('[data-admin-view-inspector-empty]'); + var form = document.querySelector('[data-admin-view-inspector]'); + var item = this.adminViewItem(this.adminViewSelected); + if (empty) { empty.hidden = !!item; } + if (form) { form.hidden = !item; } + if (!item || !form) { return; } + form.querySelector('[data-admin-view-label]').value = item.label || ''; + form.querySelector('[data-admin-view-format]').value = item.format || 'text'; + var width = form.querySelector('input[name= + ></i><b>Связанные наборы</b><span class= + ></i><div><b>Конфликт типов</b><p>' + esc(conflicts.join(', ')) + '</p></div></div>' : '<div class= + ></i><div><b>Набор не готов к импорту</b><p>' + esc(message) + '</p></div></div>'; + stateRoot.hidden = true; + }; + var preview = function (file) { + if (!file) { return; } + nameRoot.textContent = file.name || 'field-set.json'; + if (file.size > 262144) { showError('Файл превышает 256 КБ'); return; } + var reader = new FileReader(); + reader.onerror = function () { showError('Не удалось прочитать выбранный файл'); }; + reader.onload = function () { + descriptor = String(reader.result || ''); + stateRoot.hidden = false; + stateRoot.classList.add('is-loading'); + stateRoot.innerHTML = '<i class= + ></i><div><b>Стандартный список документов</b><span>ID, название, рубрика, состояние, дата и системные действия.</span></div></div>'; + return; + } + if (!this.adminViewPreview.length) { + root.innerHTML = '<div class= + ></i><span><b>Выберите JSON набора</b><small>AVE.cms field-set, не более 256 КБ</small></span><input type= + ></i><span><b>Проверяем набор</b><small>Сравниваем alias и типы полей.</small></span>'; + previewRoot.hidden = true; + applyButton.disabled = true; + var data = new FormData(); + data.append('_csrf', self.form.elements._csrf.value); + data.append('descriptor', descriptor); + fetch(self.base() + '/rubrics/' + self.currentRubricId + '/field-sets/import/preview', { + method: 'POST', body: data, headers: { 'Accept': 'application/json' }, credentials: 'same-origin' + }).then(function (response) { return response.json(); }).then(function (json) { + if (!json.success) { throw new Error(json.message || 'Набор не прошёл проверку'); } + var result = json.data || {}; + fingerprint = result.fingerprint || ''; + var conflicts = result.conflicts || []; + importHasConflicts = !!conflicts.length; + stateRoot.hidden = true; + previewRoot.hidden = false; + previewRoot.innerHTML = '<div class= + ></i><span><b>Сначала выполняется проверка</b><small>Документы и существующие поля не меняются.</small></span></div><div data-field-set-import-preview hidden></div></div>' + + '<div class= + ></i><span>В рубрике пока нет документов</span></div>'; + return; + } + var self = this; + if (this.adminViewState.mode === 'table') { + root.innerHTML = '<div class= + ></i><span>Перетащите поле в эту группу</span></div>'; + } + canvas += '</div></section>'; + }); + var canvasRoot = document.querySelector('[data-builder-canvas]'); + var paletteRoot = document.querySelector('[data-builder-palette]'); + var count = document.querySelector('[data-builder-field-count]'); + if (canvasRoot) { canvasRoot.innerHTML = canvas || '<div class= + ></i><span>Поля не найдены</span></div>'; + var count = document.querySelector('[data-admin-view-field-count]'); + if (count) { count.textContent = this.adminViewFields.length; } + }, + + renderAdminViewItems: function () { + var root = document.querySelector('[data-admin-view-items]'); + if (!root || !this.adminViewState) { return; } + var self = this; + var items = this.adminViewState.items || []; + root.innerHTML = items.length ? items.map(function (item, index) { + return '<article class= + ></i>Загрузка настроек типа...</div>'; + fetch(this.base() + '/rubrics/fields/' + field.Id + '/plugin', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (res) { return res.json(); }) + .then(function (json) { + if (self.builderSelectedId !== parseInt(field.Id, 10)) { return; } + var data = json.data || {}; + var type = data.type || {}; + root.innerHTML = type.settings_html + ? '<div class= + ></i>Импортировать</button></div></div>'; + document.body.appendChild(overlay); + requestAnimationFrame(function () { overlay.classList.add('show'); }); + var fileInput = overlay.querySelector('[data-field-set-import-file]'); + var dropzone = overlay.querySelector('[data-field-set-dropzone]'); + var previewRoot = overlay.querySelector('[data-field-set-import-preview]'); + var stateRoot = overlay.querySelector('[data-field-set-import-state]'); + var applyButton = overlay.querySelector('[data-field-set-import-apply]'); + var nameRoot = overlay.querySelector('[data-field-set-import-name]'); + var descriptor = ''; + var fingerprint = ''; + var importMode = self.fieldSetMode; + var importHasConflicts = false; + var close = function () { + overlay.classList.remove('show'); + setTimeout(function () { overlay.remove(); }, 160); + }; + var showError = function (message) { + fingerprint = ''; + importHasConflicts = true; + applyButton.disabled = true; + previewRoot.hidden = false; + previewRoot.innerHTML = '<div class= + ></i>условие</span>' : '') + + '<span class= + ><b>' + conflicts.length + '</b><small>конфликтов</small></span></div>' + + '<div class= + ><header><div><b>' + esc(document.document_title) + '</b><small>#' + esc(document.Id) + ' · ' + esc(document.document_alias || 'без alias') + '</small></div><i class= + ><span>' + + esc(group.title || 'Теги') + '</span><b>' + items.length + '</b></button>'); + var buttons = items.map(function (tag) { + var select = tag.select ? ' data-rubric-template-tag-select= + ><span>' + esc(item.created_label) + '</span><span>' + esc(item.size_label) + '</span><span>' + item.docs_count + ' документов</span></div>' + + '<div class= + ><span><b>' + (result.field_count || 0) + '</b><small>всего полей</small></span><span><b>' + (result.create || 0) + '</b><small>будет создано</small></span><span><b>' + (result.reuse || 0) + '</b><small>уже существует</small></span><span class= + ><span>После импорта</span><div class= + ><table><thead><tr><th>Документ</th>' + + this.adminViewState.items.map(function (item) { return '<th>' + esc(item.label) + '</th>'; }).join('') + + '<th></th></tr></thead><tbody>' + this.adminViewPreview.map(function (document) { + return '<tr><td><b>' + esc(document.document_title) + '</b><small>#' + esc(document.Id) + '</small></td>' + + self.adminViewState.items.map(function (item) { return '<td>' + self.adminViewValueHtml(document, item) + '</td>'; }).join('') + + '<td><i class= + >Все · И</option><option value="or" + >Добавьте первое поле рубрики.</div>'; } + if (paletteRoot) { paletteRoot.innerHTML = palette || '<div class= + >Дополнительные шаблоны не созданы.</div>'; + return; + } + root.innerHTML = templates.map(function (item) { + return '<article class= + >Запрещён</option></select></label> + >Импорт набора полей</h3><p class= + >Любое · ИЛИ</option></select> + >Не удалось загрузить настройки типа.</p>'; }); + }, + + applyBuilderSettingsDraft: function (root, settings) { + Object.keys(settings || {}).forEach(function (key) { + var controls = root.querySelectorAll('[name= + >Независимая копия</button><button type= + >Основные поля</option>'; + this.currentGroups.forEach(function (group) { + html += '<option value= + >Поля не найдены.</div>'; + return; + } + root.innerHTML = fields.map(function (field) { + return '<button class= + >Проверка схемы перед добавлением в текущую рубрику.</p></div><button class= + >Разрешён</option> + >У этого типа нет дополнительных настроек.</p>'; + if (draftSettings !== null) { self.applyBuilderSettingsDraft(root, draftSettings); } + self.hydrateSettingsMaps(root); + self.toggleBuilderDefaultField(type.admin_editor || {}); + self.builderSettingsReady = true; + }) + .catch(function () { root.innerHTML = '<p class= + ]'); + var width = row ? row.getAttribute('data-width') : (layout.width || this.builderAutoWidth(field)); + var empty = document.querySelector('[data-builder-inspector-empty]'); + if (empty) { empty.hidden = true; } + form.hidden = false; + this.builderHydrating = true; + var typeLabel = form.querySelector('[data-builder-type-label]'); + var typeCode = form.querySelector('[data-builder-type-code]'); + if (typeLabel) { typeLabel.textContent = field.type_label || field.rubric_field_type || 'Неизвестный тип'; } + if (typeCode) { typeCode.textContent = field.rubric_field_type || '—'; } + form.elements.title.value = Object.prototype.hasOwnProperty.call(draft, 'title') ? draft.title : (field.rubric_field_title || ('Поле + this.fillBuilderGroupSelect(row ? row.closest('[data-field-group-drop]').getAttribute('data-field-group-drop') : field.rubric_field_group); + var widthInput = form.querySelector('input[name= + API и структурированный preview получают: + API-контракты + data-document-value-empty>Нет добавленных строк.</div> + data-field-set-import-close>Закрыть</button><button class= + data-field-set-import-name>Файл не выбран</span><button class= + Email, телефон или URL с режимной валидацией и безопасной публичной ссылкой. + HEX-цвет с нативным picker и синхронным ручным вводом. + HTML/PHP-шаблон вывода значения поля на сайте. + ID документа + ID поля + } + + $data[ + ID тизера / ссылка + ID, название или алиас + key|Название + Native edit-render выполнен через FieldRegistry. + Native-тип не вернул HTML для режима edit + Native-тип поля не зарегистрирован + Preview: inline JS-обработчики удалены ( + Preview: legacy asset-пути используют корень сайта. + Preview: legacy asset-пути приведены к корню сайта ( + Preview: legacy script-теги отключены ( + URL или относительная ссылка. + YouTube-видео с дополнительными параметрами. + · автодействие + · автодействий + · есть условие + · обязательно + · ограничено + » добавлен + » импортирован + » обновлён и синхронизирован + Алиас свободен + Без отдельного URL-алиаса + Большой текст, обложка, галерея и прикреплённые файлы. + Будут возвращены + Булево значение: включено или выключено. + В административном шаблоне запрещены PHP, JavaScript и исполняемые HTML-элементы + В одном условии допускается не более 32 правил + Варианты + Варианты товаров + варианты товаров + Вернётся полей + Вес, + Видео + Визуальный HTML-редактор с полным набором инструментов. + Визуальный HTML-редактор средней высоты. + вкл + Включено + включено + включены + Вложенная группа + вместо + Восстановить + Восстановить схему рубрики? + Восстановление + Восстановление заблокировано + Восстановление заблокировано: + Все поля набора уже есть в рубрике + Все поля снова будут доступны без условных правил. + Все точки восстановления этой рубрики будут удалены. + Выберите тип поля + Выбор из списка + Выбранная группа не принадлежит рубрике + Выбранные ключи по умолчанию + Выбрать документ + Выбрать файл + Выключено + выключены + Выпадающий список + Высота + Высота, + Выше + г + Галерея + Город / населённый пункт + Группа + Группа + } + + protected static function settingLabel($key) + { + $known = array( + Группа изменена + Группа не найдена + Группа создана + Группа сохранена + Группа удалена + Группы + Да + да + Да / включено + Данные документов останутся на месте + Дата + Дата и время + Дата или дата со временем в одном типе с настраиваемым публичным форматом. + Дата или дата-время в документе. + Дата начала и окончания для событий, публикаций и временных блоков. + Дата по умолчанию + Два числа + Две числовые части, которые legacy хранит через вертикальную черту. + Длина + Длина, + длина|ширина|высота + длина|ширина|высота|вес + Для множественного режима перечислите ключи через запятую. + Для набора включите типы полей: + Для типа не зарегистрирован native admin-редактор. + Для типа поля не зарегистрирован admin-редактор: + Для типа поля не зарегистрирован native admin-редактор: + До + Добавлен набор « + Документ + $tokens .= + Документ не выбран + документов. Итоговые числа для всей рубрики могут быть больше.</p> + Документов: + Документы и другие файлы для скачивания. + Долгота + Должны совпасть все правила внутри + Дом / строение + Дополнительно + Дополнительные изображения материала. + Дополнительные материалы новости. + Дополнительный шаблон + array( + Дополняет анализ зависимостей перед восстановлением схемы рубрики + Достаточно любого правила внутри + доступных вариантов + Единое поле с выбором обычного текста, Tiptap или CodeMirror. + Зависимость + Заголовок + Загрузка... + Задан · + Закрыть + Запись блога + заполнено + Запрос + array( + Запрос + $details + ); + } + } + + protected static function collectRubricTemplates($rubricId, array $fields, array &$report) + { + $rubric = DB::query( + Запрос + } + + protected static function aliasMeta($alias) + { + $alias = trim((string) $alias); + return $alias !== + Запросы + запросы + знач.: + Значение + значение1|значение2 + значений вариантов + из + изм.</span></header> + Изменено поле + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success( + Изменится полей + Изменён порядок групп + Изменён порядок полей + Иллюстрации и видео записи. + Иллюстрации и файлы статьи. + Импортирован набор « + Индекс + индекс|регион|город|улица|дом|помещение|широта|долгота + Используйте 1 для включенного состояния или 0 для выключенного. + История очищена + История пока пустая + Источник + Источник данных: эта рубрика + Источник связанного набора недоступен + Исходная схема + Каталог построен на этой рубрике + Квартира / офис + Ключ + Код до сохранения + Код набора уже занят ядром или установленным модулем: + Код после сохранения + Код публичного вывода + Колонка 1 + Колонка 2 + Колонка 3 + Колонка1|Колонка2|Колонка3 + Компактный визуальный HTML-редактор. + Компактный многострочный текст + Конструктор + Контакт + Контент + Латинская буква первой, далее буквы, цифры или подчёркивание, до 20 символов + м + Медиа + Медиа и источник + минимум|максимум + мм + Многострочный текст + Множественный выбор + Модули + Модуль «Каталог» + Модуль «Товары» + Можно использовать точку или запятую. В хранилище значение нормализуется автоматически. + Можно оставить пустым или указать число. + Можно оставить пустым. Значение будет подставлено при создании документа. + Мультивыбор + Набор « + Набор конфликтует с полями рубрики: + Набор не связан с этой рубрикой + Набор полей подготовлен + Набор полей принадлежит отключённому модулю + Набор проверен + нажмите, чтобы выбрать + Название + Назначения товаров: + Например /catalog или https://example.com. + Настройка + Настройки каталога + настройки каталога + Настройки полей: + Начало + Начните вводить название или ID + не входит в + Не задан + Не задано + не задано + не заполнено + не содержит + Не удалось восстановить схему + Не удалось загрузить документы + Не удалось загрузить ревизии + Не удалось очистить историю + Не удалось полностью проверить зависимости каталога. + Не удалось проверить запросы и API-контракты. + Не удалось проверить шаблоны рубрики. + Не удалось сериализовать набор полей + Не удалось удалить снимок + Недостаточно прав + Неизвестный стартовый набор полей + Некорректное условие группы + Некорректные данные конструктора + Некорректные настройки полей + Некорректный порядок групп + Некорректный порядок полей + Некорректный порядок рубрик + Несколько вариантов из списка, варианты задаются в настройке поля. + Несколько значений из заданного набора. + Несколько отмеченных вариантов. + Несколько флажков + Нет + Нет / выключено + Ниже + Ничего не найдено + Новость + Новые поля не удаляются: они перейдут в библиотеку конструктора. Значения документов не стираются. + Обложка + Обновлён набор « + Обычное текстовое поле для короткого значения. + обязательное + Один вариант из списка. Варианты задаются строками. + Один вариант на строку + Один вариант на строку. + Один вариант на строку. Для ключей используйте key|Название. + Одиночный или множественный выбор со стабильными ключами и JSON-настройками. + Одна запись на строку. + Одна запись на строку. Для нескольких колонок используйте разделитель |. + Одна строка на запись, части разделяются символом |. + Одна строка на значение. + Одна упаковка на строку: длина|ширина|высота|вес. + одно из + Одно числовое значение. + Одноколоночный список + Окончание + Описание + Основная группа + Основное изображение для страницы и карточки материала. + Основное содержимое документа с визуальным редактором. + Основное содержимое записи. + Основной материал новости. + Основной материал статьи. + Основной текст + Основные поля + Останутся без удаления + От + очистить значение + Ошибка сети + Ошибка сети при загрузке ревизий + Параметр + параметры товарных фидов + Перед восстановлением + Перед добавлением набора « + Перед изменением группы + Model::saveGroup($groupId, 0, Request::postAll()); + $this->captureSchemaAfter((int) $group[ + Перед изменением порядка групп + Перед изменением порядка полей + Перед импортом набора « + Перед синхронизацией набора « + Перед созданием поля + Перед сохранением конструктора + Перед удалением группы + if (!Model::deleteGroup($groupId)) { + return $this->error( + Переносимая схема формы рубрики « + Перетащить + Период + Период по умолчанию + Повторяемые ссылки с подписью. + Повторяемый список записей, legacy хранит значения сериализованным массивом. + Повторяемый список из одного значения в строке. + Повторяемый список из трёх частей, legacy использует разделитель |. + Поддерживаются YYYY-MM-DD, timestamp или пустое значение. + Подсказка редактору + Показать + Поле + Поле + } + + if (!isset($validGroups[$groupId])) { + throw new \RuntimeException( + Поле + var preview = row.querySelector( + Поле изменено + Поле не найдено + Поле сохранено + Поле удалено + Полноценный визуальный HTML-редактор. + Поля + Поля без отдельной группы. + Поля и фильтры: + поля или фильтры разделов каталога + Поля, группы, раскладка и условия формы. Значения документов в снимок не входят. + Поля: + Порядок + Порядок групп сохранён + Порядок полей сохранён + Порядок рубрик сохранён + После восстановления проверьте клиентов, которые передают alias или ID этих полей. + После восстановления проверьте перечисленные места. + правил + правило + Предпросмотр условий устарел. Проверьте изменения ещё раз. + Предпросмотр условия устарел. Проверьте изменение ещё раз. + Представление документов сохранено + Прикреплённые файлы + Примените миграцию связанных наборов полей + Применить + Проверьте группу + Проверьте поле + Проверьте поля рубрики + Просмотр рубрик, групп и полей. + Простой многострочный текст + Прямых ссылок на изменяемые поля не найдено. + Публикация + Публичный шаблон вывода поля. + Раздел + Раздел использует эту рубрику + разделы каталога + Размещено на форме + Раскладка полей сохранена + Ревизии удалены + Ревизии: + Ревизия не найдена + Ревизия удалена + Регион + Редактирование группы + }, + + submitGroup: function (confirmed, fingerprint) { + var id = this.groupForm.elements.id.value; + var rubricId = this.groupForm.elements.rubric_id.value || this.currentRubricId; + var self = this; + this.clearErrors(this.groupForm); + var conditionRoot = this.groupForm.querySelector( + Редактирование поля + self.setHint( + Режим редактора и публичного вывода задаётся в настройках типа. + Рубрика + } + + $docs = self::documentCount($id); + if ($docs > 0) { + throw new \RuntimeException( + Рубрика и стартовый набор полей созданы + Рубрика не найдена + Рубрика создана + Рубрика сохранена + Рубрика удалена + Рубрики и поля + Рубрики: просмотр + Рубрики: управление + С данными + Связанный набор синхронизирован + Связь с набором удалена. Поля рубрики сохранены. + симв. + Синхронизация требует ручного решения: + Синхронизирован набор « + Системное имя + Скрыть + см + Сначала выполните предварительный просмотр набора + Сначала проверьте изменения набора + снимков + Снимок перед восстановлением ревизии + $current, + (int) $revisionId + ); + $result = Model::applySchemaSnapshot((int) $revision[ + Снимок удалён + Содержание + Содержимое по умолчанию + содержит + Создание + Создание рубрики и начальной схемы + Создание, изменение, удаление, сортировка и импорт рубрик и полей. + Создано поле + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success( + Сортировка: + Сортируемый список коробок с габаритами и весом для расчёта доставки. + Состав рубрики изменился после проверки. Выполните предварительный просмотр ещё раз. + Сохранится новых + Сохраняется как HTML и редактируется через общий rich-text редактор. + Сохраняется как HTML, совместимый с legacy multi_line. + Сохранён конструктор формы + Список записей + Список ключ-значение + Список ссылок + Список, где слева хранится ключ, справа показывается подпись. + Ссылка + Ссылка или документ + Ссылка на первоисточник новости, если она требуется. + Ссылка по умолчанию + Ссылки по умолчанию + Стартовый набор недоступен + Статья + Строка + Строки по умолчанию + Схема восстановлена + Схема изменилась после предпросмотра. Проверьте ревизию ещё раз. + Схема или поля рубрики изменились после проверки. Повторите предварительный просмотр. + Схема рубрики восстановлена + Такой алиас уже есть в этой рубрике + Такой алиас уже используется + Тег вставлен + Текст или параметры поля + Текст по умолчанию + Текст, обложка, галерея и видео. + Текст, обложка, галерея и ссылка на первоисточник. + Текстовое значение + Текстовое содержимое + Текущая схема сначала сохранится отдельным снимком. Новые поля и значения документов не удаляются. + Тип не задан + Тип поля включён + Тип поля не зарегистрирован + Тип поля не зарегистрирован в системном FieldRegistry + Тип поля не найден + Тип поля отключён + товарное представление каталога + товарные фиды + Три числа + Три числовые части, которые legacy хранит через вертикальную черту. + Трёхколоночный список + Убрать + Убрать документ + Удалено поле + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success( + Удалить + Удалить все + Удалить всю историю схемы? + Удалить поле? Значения документов для этого поля тоже будут удалены. + Удалить снимок схемы? + Удалить строку + Укажите алиас + Укажите название + Укажите название группы + Укажите название поля + Укажите название шаблона + Улица + Упаковки по умолчанию + Упаковки товара + Условие формы + условия запросов + Условия формы останутся включены + Условия формы останутся выключены + Условия формы: + Условия: + установить значение + Фид + Фид построен на этой рубрике + Флажок + Форма и серверная проверка начнут учитывать правила. + Формат значения определяется настройкой типа контакта. + Формат строки: key|Название. Один вариант на строку. + Формат строки: URL|Название. + Формат строки: колонка1|колонка2|колонка3. + Формат: значение1|значение2. + Формат: значение1|значение2|значение3. + Формат: начало|окончание. Поддерживаются YYYY-MM-DD и дата со временем. + Форматированный текст + Хранится как Unix timestamp и редактируется нативным контролом браузера. + Цвет + Цвет по умолчанию + Числа по умолчанию + Число по умолчанию + Число с настраиваемым публичным форматом: сумма, процент, величина или рейтинг. + Чтение и запись используют схему: + Шаблон request + Шаблон в документе + Шаблон в запросе + Шаблон используется документами и не может быть удалён + Шаблон обработки request-значения, если тип поля его использует. + Шаблон результата: + Шаблон рубрики не найден + Шаблоны + Шаблоны запроса « + Ширина, + Широта + Этот набор уже связан с рубрикой. Используйте обновление в списке связанных наборов. + Этот тип поля отключён в реестре + Эту точку восстановления нельзя будет вернуть. + diff --git a/adminx/modules/Rubrics/language/ru/runtime.xml b/adminx/modules/Rubrics/language/ru/runtime.xml new file mode 100644 index 0000000..07e4978 --- /dev/null +++ b/adminx/modules/Rubrics/language/ru/runtime.xml @@ -0,0 +1,570 @@ + + + " data-document-color-picker aria-label="Выбрать цвет"> + " placeholder="Ссылка или документ" data-document-media-url data-document-picker-type="all"> + (исходное поле имеет тип + . Сначала уберите эти связи. + 12 колонок + 3 колонки + 4 колонки + 6 колонок + : alias занят типом + : нельзя автоматически изменить тип заполненного или локально изменённого поля + <button class="btn btn-ghost btn-icon btn-sm documents-drag-handle" type="button" data-doc-drag draggable="true" data-tooltip="Перетащить" aria-label="Перетащить"><i class="ti ti-grip-vertical"></i></button> + <button class="btn btn-ghost btn-icon btn-sm documents-media-remove" type="button" data-document-media-remove data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm documents-value-remove" type="button" data-document-value-remove data-tooltip="Удалить" aria-label="Удалить"><i class="ti ti-trash"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-down data-tooltip="Ниже" aria-label="Ниже"><i class="ti ti-arrow-down"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-media-up data-tooltip="Выше" aria-label="Выше"><i class="ti ti-arrow-up"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-relation-remove aria-label="Убрать документ" data-tooltip="Убрать"><i class="ti ti-x"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-down data-tooltip="Ниже" aria-label="Ниже"><i class="ti ti-arrow-down"></i></button> + <button class="btn btn-ghost btn-icon btn-sm" type="button" data-document-value-up data-tooltip="Выше" aria-label="Выше"><i class="ti ti-arrow-up"></i></button> + <button class="btn btn-ghost btn-sm" type="button" data-document-media-clear><i class="ti ti-trash"></i>Очистить</button> + <button class="btn btn-ghost btn-sm" type="button" data-document-media-import-folder><i class="ti ti-folder-plus"></i>Из папки</button> + <button class="btn btn-ghost btn-sm" type="button" data-document-media-upload><i class="ti ti-upload"></i>Загрузить</button> + <button class="btn btn-ghost btn-sm" type="button" data-document-value-clear><i class="ti ti-trash"></i>Очистить</button> + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-doc-pick data-tooltip="Выбрать документ" aria-label="Выбрать документ"><i class="ti ti-file-search"></i></button></div> + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Выбрать файл" aria-label="Выбрать файл"><i class="ti + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Выбрать файл" aria-label="Выбрать файл"><i class="ti ti-paperclip"></i></button> + <button class="btn btn-secondary btn-icon btn-sm" type="button" data-document-media-pick data-tooltip="Выбрать файл" aria-label="Выбрать файл"><i class="ti ti-photo-plus"></i></button> + <button class="btn btn-secondary btn-sm" type="button" data-document-media-add><i class="ti ti-plus"></i>Добавить</button> + <button class="btn btn-secondary btn-sm" type="button" data-document-relation-pick><i class="ti ti-file-plus"></i>Добавить документ</button> + <button class="btn btn-secondary btn-sm" type="button" data-document-value-add><i class="ti ti-plus"></i>Добавить</button> + <button class="documents-media-thumb" type="button" data-document-media-pick aria-label="Выбрать файл"> + <button type="button" class="btn btn-ghost btn-icon btn-sm documents-relation-clear" data-document-relation-clear data-tooltip="Очистить" aria-label="Очистить" + <button type="button" class="documents-relation-choice" data-document-relation-pick data-tooltip="Выбрать документ"> + <label class="field"><span class="field-label">URL ролика</span> + <label class="field"><span class="field-label">Высота</span><input class="input" type="number" min="0" name=" + <label class="field"><span class="field-label">Значение + <label class="field"><span class="field-label">Ширина</span><input class="input" type="number" min="0" name=" + <option value="">Не выбрано</option> + <span class="field-hint">Только документы из рубрики # + <span class="text-secondary text-sm">Варианты не настроены.</span> + >Разрешён</option> + API и структурированный preview получают: + Email, телефон или URL с режимной валидацией и безопасной публичной ссылкой. + HEX-цвет с нативным picker и синхронным ручным вводом. + HTML/PHP-шаблон вывода значения поля на сайте. + ID документа или пусто. + ID документов по умолчанию + ID поля # + ID тизера / ссылка + JSON API документов + Native edit-render выполнен через FieldRegistry. + Native-тип не вернул HTML для режима edit + Native-тип поля не зарегистрирован + Preview: inline JS-обработчики удалены ( + Preview: legacy asset-пути используют корень сайта. + Preview: legacy asset-пути приведены к корню сайта ( + Preview: legacy script-теги отключены ( + URL или ID ролика. Дополнительные части legacy хранит через |. + URL или относительная ссылка. + YouTube-видео с дополнительными параметрами. + data-document-media-empty>Нет добавленных элементов.</div> + data-document-relation-empty>Документы не выбраны.</p> + data-document-value-empty>Нет добавленных строк.</div> + » добавлен + » из ревизии + » импортирован + » использует alias « + » не установлен + » обновлён и синхронизирован + Адрес + Адрес по умолчанию + Алиас свободен + Аналоги / вместе с товаром + Без группы + Без отдельного URL-алиаса + Большой текст, обложка, галерея и прикреплённые файлы. + Булево значение: включено или выключено. + В административном шаблоне запрещены PHP, JavaScript и исполняемые HTML-элементы + В ревизии повторяется alias поля « + В рубрике есть документы, удаление недоступно + Варианты + Варианты товаров + Вес, + Видео + Видео YouTube с дополнительными параметрами. + Видео по умолчанию + Визуальный HTML-редактор с полным набором инструментов. + Визуальный HTML-редактор средней высоты. + Включено + Восстановление + Восстановление заблокировано: + Восстановлено из ревизии # + Все поля набора уже есть в рубрике + Выберите тип поля + Выбор из списка + Выбранная группа не принадлежит рубрике + Выбранные ключи по умолчанию + Вывод всех связанных документов из рубрики. + Выключено + Выпадающий список + Высота + Высота редактора + Высота, + Габариты + Габариты по умолчанию + Галерея + Город / населённый пункт + Группа + Группа # + Группа изменена + Группа не найдена + Группа поля не принадлежит рубрике + Группа создана + Группа сохранена + Группа удалена + Да + Да / включено + Дата + Дата и время + Дата или дата со временем в одном типе с настраиваемым публичным форматом. + Дата или дата-время в документе. + Дата начала и окончания для событий, публикаций и временных блоков. + Дата по умолчанию + Два числа + Две числовые части, которые legacy хранит через вертикальную черту. + Диапазон по умолчанию + Длина + Длина, + Длина, ширина и высота с единицей измерения и JSON-хранением. + Для множественного режима перечислите ключи через запятую. + Для набора включите типы полей: + Для типа не зарегистрирован native admin-редактор. + Для типа поля не зарегистрирован admin-редактор: + Для типа поля не зарегистрирован native admin-редактор: + До + Добавлен набор « + Документ + Документ # + Документ из рубрики + Документ не выбран + Документ по умолчанию + Документы и другие файлы для скачивания. + Документы из рубрики + Документы из рубрики с выбором + Документы по умолчанию + Долгота + Дом / строение + Дополнительно + Дополнительные изображения материала. + Дополнительные материалы новости. + Дополнительный шаблон # + Дополняет анализ зависимостей перед восстановлением схемы рубрики + Единое поле с выбором обычного текста, Tiptap или CodeMirror. + Зависимость + Зависимость модуля + Заголовок + Заголовок внутри содержимого страницы + Заголовок изображения + Заголовок страницы + Задан · + Запись блога + Запретить изменение + Запрос # + Значение + Значение по умолчанию + Изменена группа # + Изменено поле # + Изменён порядок групп + Изменён порядок полей + Изображение + Изображение не выбрано + Иллюстрации и видео записи. + Иллюстрации и файлы статьи. + Импортирован набор « + Индекс + Используйте 1 для включенного состояния или 0 для выключенного. + Используются поля: + Источник + Источник данных: эта рубрика + Источник связанного набора недоступен + Исходная схема + Каталог + Каталог построен на этой рубрике + Каталоги по умолчанию + Квартира / офис + Код + Код до сохранения + Код набора уже занят ядром или установленным модулем: + Код по умолчанию + Код после сохранения + Код публичного вывода + Колонка 1 + Колонка 2 + Колонка 3 + Компактный визуальный HTML-редактор. + Компактный многострочный текст + Конструктор + Контакт + Контент + Контракт запроса # + Контракт результата: + Латинская буква первой, далее буквы, цифры или подчёркивание, до 20 символов + Материалы + Медиа + Медиа и источник + Многострочный текст + Множественный выбор + Модуль + Модуль «Каталог» + Модуль «Товарные фиды» + Модуль «Товары» + Можно использовать точку или запятую. В хранилище значение нормализуется автоматически. + Можно оставить пустым или указать число. + Можно оставить пустым. Значение будет подставлено при создании документа. + Мультивыбор + Набор « + Набор изображений из медиабраузера. + Набор конфликтует с полями рубрики: + Набор не связан с этой рубрикой + Набор полей + Набор полей подготовлен + Набор полей принадлежит отключённому модулю + Набор проверен + Набор тегов документа. + Название + Название или описание файла + Название файла + Назначения товаров: + Например /catalog или https://example.com. + Настройки каталога + Настройки по умолчанию + Настройки полей: + Настройки поля не принадлежат рубрике + Начало + Не задан + Не задано + Не удалось полностью проверить зависимости каталога. + Не удалось проверить зависимости товарных фидов. + Не удалось проверить запросы и API-контракты. + Не удалось проверить шаблоны рубрики. + Не удалось сериализовать набор полей + Недостаточно прав + Неизвестный стартовый набор полей + Некорректное условие группы + Некорректные данные конструктора + Некорректные настройки полей + Некорректный порядок групп + Некорректный порядок полей + Некорректный порядок рубрик + Некорректный элемент раскладки + Нельзя изменить тип заполненного поля. Сначала перенесите или очистите его значения. + Несколько вариантов из списка, варианты задаются в настройке поля. + Несколько значений из заданного набора. + Несколько отмеченных вариантов. + Несколько связанных документов, legacy хранит выбранные ID через |. + Несколько флажков + Нет + Нет / выключено + Нижняя и верхняя граница с единицей измерения и JSON-хранением. + Новая группа + Новое поле + Новость + Обложка + Обновлён набор « + Обычно пусто. В документе будет использоваться медиабраузер. + Обычно пусто; выбор задаётся настройками legacy-поля. + Обычное текстовое поле для короткого значения. + Обычный текст; параметры вывода задаются шаблоном поля. + Один ID документа на строку или через запятую. + Один ID каталога на строку. Legacy сохранение документов использует сериализованный массив. + Один ID на строку или через запятую. + Один ID на строку. + Один вариант из списка. Варианты задаются строками. + Один вариант на строку. + Один вариант на строку. Для ключей используйте key|Название. + Один из модулей не завершил анализ зависимостей. + Один путь на строку, если нужно предзаполнить галерею. + Один путь на строку. + Один тег на строку. + Одиночный или множественный выбор со стабильными ключами и JSON-настройками. + Одна запись на строку. + Одна запись на строку. Для нескольких колонок используйте разделитель |. + Одна строка на запись, части разделяются символом |. + Одна строка на значение. + Одна упаковка на строку: длина|ширина|высота|вес. + Одно изображение из медиабраузера. + Одно числовое значение. + Одноколоночный список + Окончание + Описание + Описание изображения + Основное изображение для страницы и карточки материала. + Основное содержимое документа с визуальным редактором. + Основное содержимое записи. + Основной материал новости. + Основной материал статьи. + Основной текст + Основной текст страницы с визуальным редактором + Основной шаблон рубрики + Основные поля + От + Папка загрузки + Параметр + Перед восстановлением + Перед добавлением набора « + Перед изменением группы # + Перед изменением поля # + Перед изменением порядка групп + Перед изменением порядка полей + Перед импортом набора « + Перед синхронизацией набора « + Перед созданием группы + Перед созданием поля + Перед сохранением конструктора + Перед удалением группы # + Перед удалением поля # + Переносимая схема формы рубрики « + Период + Период по умолчанию + Повторяемые ссылки с подписью. + Повторяемый список записей, legacy хранит значения сериализованным массивом. + Повторяемый список из одного значения в строке. + Повторяемый список из трёх частей, legacy использует разделитель |. + Подвал + Поддерживаются YYYY-MM-DD, timestamp или пустое значение. + Подсказка редактору + Поиск документов из рубрики + Показать + Поле + Поле # + Поле изменено + Поле используется: + Поле не найдено + Поле создано + Поле сохранено + Поле удалено + Полноценный визуальный HTML-редактор. + Поля без отдельной группы. + Поля и фильтры: + Поля: + Порядок + Порядок групп сохранён + Порядок полей сохранён + Порядок рубрик сохранён + После восстановления проверьте клиентов, которые передают alias или ID этих полей. + Предпросмотр условий устарел. Проверьте изменения ещё раз. + Предпросмотр условия устарел. Проверьте изменение ещё раз. + Представление документов сохранено + Префикс + Прикреплённые файлы + Примените миграцию рубрик перед включением условий формы + Примените миграцию связанных наборов полей + Применить + Проверьте группу + Проверьте поле + Проверьте поля рубрики + Проверьте шаблон + Просмотр рубрик, групп и полей. + Простой многострочный текст + Публикация + Публичный шаблон вывода поля. + Пути по умолчанию + Путь к файлу по умолчанию + Путь по умолчанию + Раздел # + Раздел использует эту рубрику + Раздел каталога # + Размещено на форме + Раскладка полей сохранена + Расширенная галерея + Ревизии удалены + Ревизия не найдена + Ревизия удалена + Регион + Редактор исходного кода. + Режим редактора + Режим редактора и публичного вывода задаётся в настройках типа. + Рубрика #1 является системной и не может быть удалена + Рубрика и стартовый набор полей созданы + Рубрика используется: + Рубрика не найдена + Рубрика создана + Рубрика сохранена + Рубрика удалена + Рубрика-источник + Рубрики и поля + Рубрики: просмотр + Рубрики: управление + Связанные документы с поиском, legacy хранит выбранные ID через |. + Связанный набор синхронизирован + Связь с legacy-модулем catalog. + Связь с набором удалена. Поля рубрики сохранены. + Связь с одним документом из выбранной рубрики. + Синхронизация требует ручного решения: + Синхронизирован набор « + Системное имя + Скрыть + Сложное поле изображений. Adminx-редактор будет развиваться поверх медиабраузера. + Сначала выполните предварительный просмотр набора + Сначала примените миграции рубрик для условий групп + Сначала проверьте изменения набора + Снимок перед восстановлением ревизии # + Содержание + Содержимое по умолчанию + Содержимое страницы + Создана группа # + Создание + Создание рубрики и начальной схемы + Создание, изменение, удаление, сортировка и импорт рубрик и полей. + Создано поле # + Сортировка: + Сортируемый список коробок с габаритами и весом для расчёта доставки. + Состав рубрики изменился после проверки. Выполните предварительный просмотр ещё раз. + Сохраняется как HTML и редактируется через общий rich-text редактор. + Сохраняется как HTML, совместимый с legacy multi_line. + Сохранён конструктор формы + Сохранённое новое поле « + Список записей + Список значений + Список ключ-значение + Список связанных документов. Legacy-данные встречаются как ID через запятую, JSON или serialize. + Список ссылок + Список файлов, привязанных к документу. + Список, где слева хранится ключ, справа показывается подпись. + Ссылка + Ссылка на первоисточник новости, если она требуется. + Ссылка по умолчанию + Ссылки по умолчанию + Стартовый набор недоступен + Статья + Строка + Строки по умолчанию + Структурированный почтовый адрес с необязательными координатами. + Суффикс + Схема изменилась после предпросмотра. Проверьте ревизию ещё раз. + Схема или поля рубрики изменились после проверки. Повторите предварительный просмотр. + Схема рубрики восстановлена + Такой алиас уже есть в этой рубрике + Такой алиас уже используется + Теги + Теги по умолчанию + Текст в изображение + Текст по умолчанию + Текст, обложка, галерея и видео. + Текст, обложка, галерея и ссылка на первоисточник. + Текстовое значение, которое legacy превращает в изображение. + Текстовое содержимое + Тизер + Тизеры документов + Тип не задан + Тип поля + Тип поля « + Тип поля включён + Тип поля не зарегистрирован + Тип поля не зарегистрирован в системном FieldRegistry + Тип поля не найден + Тип поля отключён + Три числа + Три числовые части, которые legacy хранит через вертикальную черту. + Трёхколоночный список + Удалена группа # + Удалено поле # + Укажите алиас + Укажите название + Укажите название группы + Укажите название поля + Укажите название шаблона + Укажите наименование поля # + Улица + Упаковки по умолчанию + Упаковки товара + Упорядоченный список документов, которые выводятся как тизеры. + Условие формы + Условия: + Участвует в поиске + Файл + Файл для скачивания. + Файл не выбран + Файлы документа + Файлы по умолчанию + Фид # + Фид построен на этой рубрике + Флажок + Формат #rgb или #rrggbb. + Формат значения определяется настройкой типа контакта. + Формат строки: URL|Название. + Формат строки: key|Название. Один вариант на строку. + Формат строки: колонка1|колонка2|колонка3. + Формат: длина|ширина|высота. + Формат: значение1|значение2. + Формат: значение1|значение2|значение3. + Формат: индекс|регион|город|улица|дом|помещение|широта|долгота. + Формат: минимум|максимум. Одну границу можно оставить пустой. + Формат: начало|окончание. Поддерживаются YYYY-MM-DD и дата со временем. + Форматированный текст + Хранится как Unix timestamp и редактируется нативным контролом браузера. + Цвет + Цвет по умолчанию + Числа по умолчанию + Число + Число по умолчанию + Число с настраиваемым публичным форматом: сумма, процент, величина или рейтинг. + Числовое значение + Числовой диапазон + Чтение и запись используют схему: + Шаблон # + Шаблон request + Шаблон в документе + Шаблон в запросе + Шаблон вывода + Шаблон используется документами и не может быть удалён + Шаблон обработки request-значения, если тип поля его использует. + Шаблон результата: + Шаблон рубрики не найден + Шаблон рубрики создан + Шаблон рубрики сохранён + Шаблон рубрики удалён + Шаблоны запроса « + Шаблоны рубрики сохранены + Шапка + Ширина + Ширина поля + Ширина, + Широта + Этот набор уже связан с рубрикой. Используйте обновление в списке связанных наборов. + Этот тип поля отключён в реестре + Язык кода + активна + варианты товаров + вкл + включено + вместо + выкл + г + да + доступных вариантов + заполнено + запросы + знач.: + значений вариантов + используется: + кг + м + мм + нажмите, чтобы выбрать + настройки каталога + не входит в + не задано + не заполнено + не принадлежит рубрике + не содержит + обязательное + одно из + очистить значение + параметры товарных фидов + повторяется в раскладке + поля или фильтры разделов каталога + правил + правило + разделы каталога + симв. + см + содержит + товарное представление каталога + товарные фиды + уже занят другой рубрикой + условия запросов + установить значение + diff --git a/adminx/modules/Rubrics/migrations/001_create_rubrics_tables.sql b/adminx/modules/Rubrics/migrations/001_create_rubrics_tables.sql new file mode 100644 index 0000000..a51ff5f --- /dev/null +++ b/adminx/modules/Rubrics/migrations/001_create_rubrics_tables.sql @@ -0,0 +1,87 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_rubrics` ( + `Id` SMALLINT(3) UNSIGNED NOT NULL AUTO_INCREMENT, + `rubric_title` VARCHAR(255) NOT NULL DEFAULT '', + `rubric_alias` VARCHAR(255) NOT NULL DEFAULT '', + `rubric_alias_history` ENUM('0','1') NOT NULL DEFAULT '0', + `rubric_template` LONGTEXT NOT NULL, + `rubric_template_id` SMALLINT(3) UNSIGNED NOT NULL DEFAULT 1, + `rubric_author_id` INT(10) UNSIGNED NOT NULL DEFAULT 1, + `rubric_created` INT(10) UNSIGNED NOT NULL DEFAULT 0, + `rubric_docs_active` INT(1) UNSIGNED NOT NULL DEFAULT 1, + `rubric_start_code` TEXT NOT NULL, + `rubric_code_start` TEXT NOT NULL, + `rubric_code_end` TEXT NOT NULL, + `rubric_teaser_template` TEXT NOT NULL, + `rubric_header_template` TEXT NOT NULL, + `rubric_og_template` TEXT NOT NULL, + `rubric_footer_template` TEXT NOT NULL, + `rubric_linked_rubric` VARCHAR(255) NOT NULL DEFAULT '0', + `rubric_description` TEXT NOT NULL, + `rubric_meta_gen` ENUM('0','1') NOT NULL DEFAULT '0', + `rubric_position` INT(11) UNSIGNED NOT NULL DEFAULT 100, + `rubric_changed` INT(10) NOT NULL DEFAULT 0, + `rubric_changed_fields` INT(10) NOT NULL DEFAULT 0, + PRIMARY KEY (`Id`), + KEY `idx_rubric_position` (`rubric_position`), + KEY `idx_rubric_alias` (`rubric_alias`(191)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `{{content_prefix}}_rubric_admin_views` ( + `rubric_id` SMALLINT(3) UNSIGNED NOT NULL, + `mode` VARCHAR(20) NOT NULL DEFAULT 'standard', + `config_json` LONGTEXT NOT NULL, + `updated_by` INT(10) UNSIGNED DEFAULT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`rubric_id`), + KEY `idx_updated_at` (`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `{{prefix}}_rubric_fields` ( + `Id` MEDIUMINT(5) UNSIGNED NOT NULL AUTO_INCREMENT, + `rubric_id` SMALLINT(3) UNSIGNED NOT NULL DEFAULT 0, + `rubric_field_group` SMALLINT(3) DEFAULT NULL, + `rubric_field_alias` VARCHAR(20) NOT NULL DEFAULT '', + `rubric_field_title` VARCHAR(255) NOT NULL DEFAULT '', + `rubric_field_type` VARCHAR(75) NOT NULL DEFAULT '', + `rubric_field_numeric` ENUM('0','1') NOT NULL DEFAULT '0', + `rubric_field_position` SMALLINT(3) UNSIGNED NOT NULL DEFAULT 1, + `rubric_field_default` TEXT NOT NULL, + `rubric_field_search` ENUM('0','1') NOT NULL DEFAULT '1', + `rubric_field_template` TEXT NOT NULL, + `rubric_field_template_request` TEXT NOT NULL, + `rubric_field_description` TEXT NOT NULL, + PRIMARY KEY (`Id`), + KEY `idx_rubric` (`rubric_id`, `rubric_field_position`), + KEY `idx_group` (`rubric_field_group`), + KEY `idx_alias` (`rubric_id`, `rubric_field_alias`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `{{prefix}}_rubric_fields_group` ( + `Id` MEDIUMINT(5) UNSIGNED NOT NULL AUTO_INCREMENT, + `rubric_id` SMALLINT(3) UNSIGNED NOT NULL DEFAULT 0, + `group_title` VARCHAR(255) NOT NULL DEFAULT '', + `group_description` TEXT NOT NULL, + `group_position` INT(11) NOT NULL DEFAULT 100, + PRIMARY KEY (`Id`), + KEY `idx_rubric_position` (`rubric_id`, `group_position`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `{{prefix}}_rubric_templates` ( + `id` MEDIUMINT(5) UNSIGNED NOT NULL AUTO_INCREMENT, + `rubric_id` SMALLINT(3) UNSIGNED NOT NULL DEFAULT 0, + `title` VARCHAR(255) NOT NULL DEFAULT '', + `template` LONGTEXT NOT NULL, + `author_id` INT(10) UNSIGNED NOT NULL DEFAULT 1, + `created` INT(10) UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_rubric` (`rubric_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `{{prefix}}_rubric_permissions` ( + `Id` MEDIUMINT(5) UNSIGNED NOT NULL AUTO_INCREMENT, + `rubric_id` SMALLINT(3) UNSIGNED NOT NULL DEFAULT 0, + `user_group_id` SMALLINT(3) UNSIGNED NOT NULL DEFAULT 0, + `rubric_permission` CHAR(255) NOT NULL DEFAULT '', + PRIMARY KEY (`Id`), + KEY `idx_rubric_group` (`rubric_id`, `user_group_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/adminx/modules/Rubrics/migrations/002_rubric_field_settings.sql b/adminx/modules/Rubrics/migrations/002_rubric_field_settings.sql new file mode 100644 index 0000000..9470f41 --- /dev/null +++ b/adminx/modules/Rubrics/migrations/002_rubric_field_settings.sql @@ -0,0 +1,6 @@ +-- Фаза 2 контент-ядра полей (docs/development/fields-migration.md): +-- JSON-настройки типа поля как источник правды. Пусто => читается с fallback +-- из legacy rubric_field_default. Идемпотентность обеспечивает ModuleMigrator +-- (учёт применённых миграций по checksum); повторно не выполняется. +ALTER TABLE `{{prefix}}_rubric_fields` + ADD COLUMN `rubric_field_settings` LONGTEXT NULL DEFAULT NULL AFTER `rubric_field_default`; diff --git a/adminx/modules/Rubrics/migrations/003_native_public_document_context.sql b/adminx/modules/Rubrics/migrations/003_native_public_document_context.sql new file mode 100644 index 0000000..736f340 --- /dev/null +++ b/adminx/modules/Rubrics/migrations/003_native_public_document_context.sql @@ -0,0 +1,15 @@ +UPDATE `{{prefix}}_rubrics` +SET `rubric_template` = REPLACE( + `rubric_template`, + '$AVE_Core->curentdoc', + CONCAT(CHAR(92), 'App', CHAR(92), 'Frontend', CHAR(92), 'PublicPageContext::document()') +) +WHERE `rubric_template` LIKE '%$AVE_Core->curentdoc%'; + +UPDATE `{{prefix}}_rubric_templates` +SET `template` = REPLACE( + `template`, + '$AVE_Core->curentdoc', + CONCAT(CHAR(92), 'App', CHAR(92), 'Frontend', CHAR(92), 'PublicPageContext::document()') +) +WHERE `template` LIKE '%$AVE_Core->curentdoc%'; diff --git a/adminx/modules/Rubrics/migrations/004_native_database_calls.sql b/adminx/modules/Rubrics/migrations/004_native_database_calls.sql new file mode 100644 index 0000000..e0f5553 --- /dev/null +++ b/adminx/modules/Rubrics/migrations/004_native_database_calls.sql @@ -0,0 +1,21 @@ +UPDATE `{{prefix}}_rubrics` +SET `rubric_template` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + `rubric_template`, + '$AVE_DB->Query', 'DB::query'), + '$AVE_DB->EscStr', 'DB::escape'), + '->FetchAssocArray()', '->getAssoc()'), + '->FetchRow()', '->getObject()'), + '->GetCell()', '->getValue()'), + '->getCell()', '->getValue()') +WHERE `rubric_template` LIKE '%$AVE_DB%'; + +UPDATE `{{prefix}}_rubric_templates` +SET `template` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + `template`, + '$AVE_DB->Query', 'DB::query'), + '$AVE_DB->EscStr', 'DB::escape'), + '->FetchAssocArray()', '->getAssoc()'), + '->FetchRow()', '->getObject()'), + '->GetCell()', '->getValue()'), + '->getCell()', '->getValue()') +WHERE `template` LIKE '%$AVE_DB%'; diff --git a/adminx/modules/Rubrics/migrations/005_correct_native_database_escape.sql b/adminx/modules/Rubrics/migrations/005_correct_native_database_escape.sql new file mode 100644 index 0000000..d051dbb --- /dev/null +++ b/adminx/modules/Rubrics/migrations/005_correct_native_database_escape.sql @@ -0,0 +1,7 @@ +UPDATE `{{prefix}}_rubrics` +SET `rubric_template` = REPLACE(`rubric_template`, 'DB::escape(', 'DB::safe(') +WHERE `rubric_template` LIKE '%DB::escape(%'; + +UPDATE `{{prefix}}_rubric_templates` +SET `template` = REPLACE(`template`, 'DB::escape(', 'DB::safe(') +WHERE `template` LIKE '%DB::escape(%'; diff --git a/adminx/modules/Rubrics/migrations/006_admin_document_views.sql b/adminx/modules/Rubrics/migrations/006_admin_document_views.sql new file mode 100644 index 0000000..c30d192 --- /dev/null +++ b/adminx/modules/Rubrics/migrations/006_admin_document_views.sql @@ -0,0 +1,39 @@ +CREATE TABLE IF NOT EXISTS `{{content_prefix}}_rubric_admin_views` ( + `rubric_id` SMALLINT(3) UNSIGNED NOT NULL, + `mode` VARCHAR(20) NOT NULL DEFAULT 'standard', + `config_json` LONGTEXT NOT NULL, + `updated_by` INT(10) UNSIGNED DEFAULT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`rubric_id`), + KEY `idx_updated_at` (`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +SET @ave_admin_view_table = '{{content_prefix}}_rubrics'; +SET @ave_admin_view_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_admin_view_table + AND COLUMN_NAME = 'rubric_admin_teaser_template' + ), + CONCAT('ALTER TABLE `', @ave_admin_view_table, '` DROP COLUMN `rubric_admin_teaser_template`'), + 'SELECT 1' +); +PREPARE ave_admin_view_stmt FROM @ave_admin_view_sql; +EXECUTE ave_admin_view_stmt; +DEALLOCATE PREPARE ave_admin_view_stmt; + +SET @ave_admin_view_table = '{{database_prefix}}_rubrics'; +SET @ave_admin_view_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @ave_admin_view_table + AND COLUMN_NAME = 'rubric_admin_teaser_template' + ), + CONCAT('ALTER TABLE `', @ave_admin_view_table, '` DROP COLUMN `rubric_admin_teaser_template`'), + 'SELECT 1' +); +PREPARE ave_admin_view_stmt FROM @ave_admin_view_sql; +EXECUTE ave_admin_view_stmt; +DEALLOCATE PREPARE ave_admin_view_stmt; diff --git a/adminx/modules/Rubrics/migrations/007_rubric_open_graph.sql b/adminx/modules/Rubrics/migrations/007_rubric_open_graph.sql new file mode 100644 index 0000000..7d32d73 --- /dev/null +++ b/adminx/modules/Rubrics/migrations/007_rubric_open_graph.sql @@ -0,0 +1,2 @@ +ALTER TABLE `{{prefix}}_rubrics` + ADD COLUMN IF NOT EXISTS `rubric_og_template` TEXT NOT NULL AFTER `rubric_header_template`; diff --git a/adminx/modules/Rubrics/migrations/008_normalize_field_layout_width.sql b/adminx/modules/Rubrics/migrations/008_normalize_field_layout_width.sql new file mode 100644 index 0000000..7f275e5 --- /dev/null +++ b/adminx/modules/Rubrics/migrations/008_normalize_field_layout_width.sql @@ -0,0 +1,8 @@ +UPDATE `{{content_prefix}}_rubric_fields` +SET rubric_field_settings = REPLACE( + REPLACE(rubric_field_settings, '"width":12', '"width":"full"'), + '"width":"12"', + '"width":"full"' +) +WHERE rubric_field_settings LIKE '%"width":12%' + OR rubric_field_settings LIKE '%"width":"12"%'; diff --git a/adminx/modules/Rubrics/migrations/009_expand_clean_starter_rubric.php b/adminx/modules/Rubrics/migrations/009_expand_clean_starter_rubric.php new file mode 100644 index 0000000..b7d08e1 --- /dev/null +++ b/adminx/modules/Rubrics/migrations/009_expand_clean_starter_rubric.php @@ -0,0 +1,79 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Content\ContentTables; + + return function (array $context) { + $rubrics = ContentTables::table('rubrics'); + $fields = ContentTables::table('rubric_fields'); + $documents = ContentTables::table('documents'); + $values = ContentTables::table('document_fields'); + $textValues = ContentTables::table('document_fields_text'); + $starter = \DB::query('SELECT Id, document_title FROM ' . $documents . ' WHERE Id=1 AND rubric_id=1 LIMIT 1')->getAssoc(); + $fieldRows = \DB::query('SELECT * FROM ' . $fields . ' WHERE rubric_id=1 ORDER BY rubric_field_position, Id')->getAll(); + $queries = 2; + if (!is_array($starter) || strpos((string) $starter['document_title'], 'AVE.cms') !== 0 || count($fieldRows) !== 1) { + return $queries; + } + + $source = $fieldRows[0]; + $sourceId = (int) $source['Id']; + $layout = json_encode(array('width' => 'full'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $contentLayout = json_encode(array('width' => 'full', 'height' => 300), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + \DB::Update($fields, array( + 'rubric_field_alias' => 'title', 'rubric_field_title' => 'Заголовок страницы', + 'rubric_field_type' => 'single_line', 'rubric_field_position' => 1, + 'rubric_field_settings' => $layout, + 'rubric_field_description' => 'Заголовок внутри содержимого страницы', + ), 'Id=%i', $sourceId); + \DB::Insert($fields, array( + 'rubric_id' => 1, 'rubric_field_group' => null, 'rubric_field_alias' => 'content', + 'rubric_field_title' => 'Содержимое страницы', 'rubric_field_type' => 'richtext', + 'rubric_field_numeric' => 0, 'rubric_field_position' => 2, 'rubric_field_default' => '', + 'rubric_field_settings' => $contentLayout, 'rubric_field_search' => 1, + 'rubric_field_template' => '', 'rubric_field_template_request' => '', + 'rubric_field_description' => 'Основной текст страницы с визуальным редактором', + )); + $contentId = (int) \DB::insertId(); + $queries += 2; + + foreach (array(1, 2) as $documentId) { + $document = \DB::query('SELECT document_title, document_in_search FROM ' . $documents . ' WHERE Id=%i AND rubric_id=1 LIMIT 1', $documentId)->getAssoc(); + if (!$document) { continue; } + $oldText = \DB::query('SELECT field_value FROM ' . $textValues . ' WHERE document_id=%i AND rubric_field_id=%i LIMIT 1', $documentId, $sourceId)->getValue(); + \DB::Update($values, array( + 'field_value' => html_entity_decode((string) $document['document_title'], ENT_QUOTES, 'UTF-8'), + ), 'document_id=%i AND rubric_field_id=%i', $documentId, $sourceId); + \DB::Insert($values, array( + 'rubric_field_id' => $contentId, 'document_id' => $documentId, + 'field_number_value' => 0, 'field_value' => '', + 'document_in_search' => (int) $document['document_in_search'], + )); + $valueId = (int) \DB::insertId(); + \DB::Insert($textValues, array( + 'Id' => $valueId, 'rubric_field_id' => $contentId, 'document_id' => $documentId, + 'field_value' => (string) $oldText, + )); + \DB::Delete($textValues, 'document_id=%i AND rubric_field_id=%i', $documentId, $sourceId); + $queries += 5; + } + + \DB::Update($rubrics, array( + 'rubric_template' => '

    [tag:fld:title]

    [tag:fld:content]
    ', + 'rubric_changed' => time(), 'rubric_changed_fields' => time(), + ), 'Id=1'); + return $queries + 1; + }; diff --git a/adminx/modules/Rubrics/migrations/010_form_conditions.php b/adminx/modules/Rubrics/migrations/010_form_conditions.php new file mode 100644 index 0000000..a8fe6fe --- /dev/null +++ b/adminx/modules/Rubrics/migrations/010_form_conditions.php @@ -0,0 +1,31 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Content\ContentTables; + + return function (array $context) { + $table = ContentTables::table('rubrics'); + $exists = (bool) \DB::query('SHOW COLUMNS FROM ' . $table . ' LIKE %s', 'rubric_form_conditions')->getValue(); + if ($exists) { + return 1; + } + + \DB::query( + 'ALTER TABLE ' . $table + . ' ADD COLUMN rubric_form_conditions TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER rubric_meta_gen' + ); + return 2; + }; diff --git a/adminx/modules/Rubrics/migrations/011_schema_revisions.sql b/adminx/modules/Rubrics/migrations/011_schema_revisions.sql new file mode 100644 index 0000000..543cb11 --- /dev/null +++ b/adminx/modules/Rubrics/migrations/011_schema_revisions.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_rubric_schema_revisions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `rubric_id` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `action` VARCHAR(32) CHARACTER SET ascii NOT NULL DEFAULT 'update', + `snapshot_hash` CHAR(40) CHARACTER SET ascii NOT NULL DEFAULT '', + `snapshot_json` LONGTEXT NOT NULL, + `comment` VARCHAR(500) NOT NULL DEFAULT '', + `author_id` INT UNSIGNED NOT NULL DEFAULT 0, + `author_name` VARCHAR(255) NOT NULL DEFAULT '', + `source_revision_id` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `created_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_rubric_created` (`rubric_id`, `created_at`), + KEY `idx_action` (`action`), + KEY `idx_snapshot_hash` (`snapshot_hash`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Rubrics/migrations/012_linked_field_sets.sql b/adminx/modules/Rubrics/migrations/012_linked_field_sets.sql new file mode 100644 index 0000000..5e584ca --- /dev/null +++ b/adminx/modules/Rubrics/migrations/012_linked_field_sets.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_rubric_field_sets` ( + `code` VARCHAR(64) CHARACTER SET ascii NOT NULL, + `title` VARCHAR(255) NOT NULL DEFAULT '', + `description` TEXT NOT NULL, + `definition_json` LONGTEXT NOT NULL, + `fingerprint` CHAR(64) CHARACTER SET ascii NOT NULL DEFAULT '', + `source` VARCHAR(32) CHARACTER SET ascii NOT NULL DEFAULT 'import', + `active` TINYINT UNSIGNED NOT NULL DEFAULT 1, + `updated_by` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` INT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`code`), + KEY `idx_active_title` (`active`,`title`(120)) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `{{prefix}}_rubric_field_set_links` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `rubric_id` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `set_code` VARCHAR(64) CHARACTER SET ascii NOT NULL, + `applied_fingerprint` CHAR(64) CHARACTER SET ascii NOT NULL DEFAULT '', + `applied_definition_json` LONGTEXT NOT NULL, + `field_map_json` LONGTEXT NOT NULL, + `updated_by` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` INT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_rubric_set` (`rubric_id`,`set_code`), + KEY `idx_set_code` (`set_code`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Rubrics/migrations/013_group_form_conditions.php b/adminx/modules/Rubrics/migrations/013_group_form_conditions.php new file mode 100644 index 0000000..75150da --- /dev/null +++ b/adminx/modules/Rubrics/migrations/013_group_form_conditions.php @@ -0,0 +1,28 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\ContentTables; + + return function (array $context) { + $table = ContentTables::table('rubric_fields_group'); + if (DatabaseSchema::columnExists($table, 'group_settings')) { + return 1; + } + + \DB::query('ALTER TABLE ' . $table . ' ADD COLUMN group_settings MEDIUMTEXT NULL AFTER group_position'); + return 2; + }; diff --git a/adminx/modules/Rubrics/module.php b/adminx/modules/Rubrics/module.php new file mode 100644 index 0000000..040e90e --- /dev/null +++ b/adminx/modules/Rubrics/module.php @@ -0,0 +1,138 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + return array( + 'code' => 'rubrics', + 'name' => 'Рубрики и поля', + 'version' => '0.1.0', + 'field_sets' => \App\Adminx\Rubrics\RubricFieldPresets::definitions(), + 'hook_definitions' => array( + array( + 'name' => 'content.rubric.schema_impact', + 'description' => 'Дополняет анализ зависимостей перед восстановлением схемы рубрики', + 'kind' => 'filter', + 'context' => 'array{rubric_id,field_ids,fields,items}', + ), + ), + + 'permissions' => array( + 'key' => 'rubrics', + 'items' => array( + array( + 'code' => 'view_rubrics', + 'group_code' => 'content', + 'name' => 'Рубрики: просмотр', + 'description' => 'Просмотр рубрик, групп и полей.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_rubrics', + 'group_code' => 'content', + 'name' => 'Рубрики: управление', + 'description' => 'Создание, изменение, удаление, сортировка и импорт рубрик и полей.', + 'sort_order' => 20, + ), + ), + 'icon' => 'ti ti-forms', + 'priority' => 33, + ), + + 'navigation' => array( + array( + 'code' => 'rubrics', + 'label' => 'Рубрики и поля', + 'url' => '/rubrics', + 'icon' => 'ti ti-forms', + 'permission' => 'view_rubrics', + 'group' => 'Контент', + 'sort_order' => 23, + 'match' => array( + '/rubrics', + ), + ), + ), + + 'migrations' => array( + array('id' => '001_create_rubrics_tables', 'file' => 'migrations/001_create_rubrics_tables.sql', 'legacy_checksums' => array('a296e8ea4c3e853e10f71a74c1f0790a21590f3e')), + array('id' => '002_rubric_field_settings', 'file' => 'migrations/002_rubric_field_settings.sql'), + array('id' => '003_native_public_document_context', 'file' => 'migrations/003_native_public_document_context.sql'), + array('id' => '004_native_database_calls', 'file' => 'migrations/004_native_database_calls.sql'), + array('id' => '005_correct_native_database_escape', 'file' => 'migrations/005_correct_native_database_escape.sql'), + array('id' => '006_admin_document_views', 'file' => 'migrations/006_admin_document_views.sql'), + array('id' => '007_rubric_open_graph', 'file' => 'migrations/007_rubric_open_graph.sql'), + array('id' => '008_normalize_field_layout_width', 'file' => 'migrations/008_normalize_field_layout_width.sql'), + array('id' => '009_expand_clean_starter_rubric', 'file' => 'migrations/009_expand_clean_starter_rubric.php'), + array('id' => '010_form_conditions', 'file' => 'migrations/010_form_conditions.php'), + array('id' => '011_schema_revisions', 'file' => 'migrations/011_schema_revisions.sql'), + array('id' => '012_linked_field_sets', 'file' => 'migrations/012_linked_field_sets.sql'), + array('id' => '013_group_form_conditions', 'file' => 'migrations/013_group_form_conditions.php'), + ), + + 'routes' => array( + array('GET', '/rubrics', array(\App\Adminx\Rubrics\Controller::class, 'index')), + array('GET', '/rubrics/template-tags', array(\App\Adminx\Rubrics\Controller::class, 'templateTags')), + array('GET', '/rubrics/alias-check', array(\App\Adminx\Rubrics\Controller::class, 'rubricAliasCheck')), + array('GET', '/rubrics/fields/alias-check', array(\App\Adminx\Rubrics\Controller::class, 'fieldAliasCheck')), + array('GET', '/rubrics/documents/picker', array(\App\Adminx\Rubrics\Controller::class, 'documentPicker')), + array('GET', '/rubrics/field-types/{type}', array(\App\Adminx\Rubrics\Controller::class, 'showFieldType')), + array('POST', '/rubrics/field-types/{type}/toggle', array(\App\Adminx\Rubrics\Controller::class, 'toggleFieldType')), + array('POST', '/rubrics/reorder', array(\App\Adminx\Rubrics\Controller::class, 'reorderRubrics')), + array('POST', '/rubrics', array(\App\Adminx\Rubrics\Controller::class, 'storeRubric'), array('permission' => 'manage_rubrics', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('GET', '/rubrics/schema-revisions/{revision}', array(\App\Adminx\Rubrics\Controller::class, 'schemaRevision')), + array('POST', '/rubrics/schema-revisions/{revision}/restore', array(\App\Adminx\Rubrics\Controller::class, 'restoreSchemaRevision')), + array('POST', '/rubrics/schema-revisions/{revision}/delete', array(\App\Adminx\Rubrics\Controller::class, 'deleteSchemaRevision')), + array('GET', '/rubrics/{id}/schema-revisions', array(\App\Adminx\Rubrics\Controller::class, 'schemaRevisions')), + array('POST', '/rubrics/{id}/schema-revisions/delete', array(\App\Adminx\Rubrics\Controller::class, 'deleteSchemaRevisions')), + array('GET', '/rubrics/{id}', array(\App\Adminx\Rubrics\Controller::class, 'showRubric')), + array('POST', '/rubrics/{id}', array(\App\Adminx\Rubrics\Controller::class, 'updateRubric'), array('permission' => 'manage_rubrics', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/rubrics/{id}/delete', array(\App\Adminx\Rubrics\Controller::class, 'destroyRubric')), + array('GET', '/rubrics/{id}/fields', array(\App\Adminx\Rubrics\Controller::class, 'fields')), + array('GET', '/rubrics/{id}/templates', array(\App\Adminx\Rubrics\Controller::class, 'templates')), + array('GET', '/rubrics/{id}/admin-view', array(\App\Adminx\Rubrics\Controller::class, 'adminView')), + array('POST', '/rubrics/{id}/admin-view', array(\App\Adminx\Rubrics\Controller::class, 'updateAdminView')), + array('POST', '/rubrics/template/lint', array(\App\Adminx\Rubrics\Controller::class, 'lint')), + array('POST', '/rubrics/{id}/templates/main', array(\App\Adminx\Rubrics\Controller::class, 'updateMainTemplate'), array('permission' => 'manage_rubrics', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/rubrics/{id}/templates', array(\App\Adminx\Rubrics\Controller::class, 'storeExtraTemplate'), array('permission' => 'manage_rubrics', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/rubrics/{id}/fields', array(\App\Adminx\Rubrics\Controller::class, 'storeField')), + array('POST', '/rubrics/{id}/fields/builder/preview', array(\App\Adminx\Rubrics\Controller::class, 'previewFieldBuilder')), + array('POST', '/rubrics/{id}/fields/builder', array(\App\Adminx\Rubrics\Controller::class, 'saveFieldBuilder')), + array('GET', '/rubrics/{id}/field-sets/export', array(\App\Adminx\Rubrics\Controller::class, 'exportFieldSet')), + array('POST', '/rubrics/{id}/field-sets/import/preview', array(\App\Adminx\Rubrics\Controller::class, 'previewImportedFieldSet')), + array('POST', '/rubrics/{id}/field-sets/import/apply', array(\App\Adminx\Rubrics\Controller::class, 'applyImportedFieldSet')), + array('GET', '/rubrics/{id}/field-set-links/{set}/preview', array(\App\Adminx\Rubrics\Controller::class, 'previewFieldSetSync')), + array('POST', '/rubrics/{id}/field-set-links/{set}/sync', array(\App\Adminx\Rubrics\Controller::class, 'syncFieldSet')), + array('POST', '/rubrics/{id}/field-set-links/{set}/detach', array(\App\Adminx\Rubrics\Controller::class, 'detachFieldSet')), + array('GET', '/rubrics/{id}/field-sets/{set}/preview', array(\App\Adminx\Rubrics\Controller::class, 'previewFieldSet')), + array('POST', '/rubrics/{id}/field-sets/{set}/apply', array(\App\Adminx\Rubrics\Controller::class, 'applyFieldSet')), + array('POST', '/rubrics/{id}/fields/reorder', array(\App\Adminx\Rubrics\Controller::class, 'reorderFields')), + array('POST', '/rubrics/{id}/groups', array(\App\Adminx\Rubrics\Controller::class, 'storeGroup')), + array('POST', '/rubrics/{id}/groups/reorder', array(\App\Adminx\Rubrics\Controller::class, 'reorderGroups')), + array('GET', '/rubrics/templates/{template}', array(\App\Adminx\Rubrics\Controller::class, 'showExtraTemplate')), + array('POST', '/rubrics/templates/{template}', array(\App\Adminx\Rubrics\Controller::class, 'updateExtraTemplate'), array('permission' => 'manage_rubrics', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/rubrics/templates/{template}/delete', array(\App\Adminx\Rubrics\Controller::class, 'destroyExtraTemplate')), + array('GET', '/rubrics/fields/{field}', array(\App\Adminx\Rubrics\Controller::class, 'showField')), + array('GET', '/rubrics/fields/{field}/plugin', array(\App\Adminx\Rubrics\Controller::class, 'fieldPlugin')), + array('POST', '/rubrics/fields/{field}', array(\App\Adminx\Rubrics\Controller::class, 'updateField')), + array('POST', '/rubrics/fields/{field}/delete', array(\App\Adminx\Rubrics\Controller::class, 'destroyField')), + array('POST', '/rubrics/groups/{group}', array(\App\Adminx\Rubrics\Controller::class, 'updateGroup')), + array('POST', '/rubrics/groups/{group}/condition/preview', array(\App\Adminx\Rubrics\Controller::class, 'previewGroupCondition')), + array('POST', '/rubrics/groups/{group}/delete', array(\App\Adminx\Rubrics\Controller::class, 'destroyGroup')), + ), + + 'view_globals' => array( + 'module_code' => 'rubrics', + ), + ); diff --git a/adminx/modules/Rubrics/view/index.twig b/adminx/modules/Rubrics/view/index.twig new file mode 100644 index 0000000..5f385d1 --- /dev/null +++ b/adminx/modules/Rubrics/view/index.twig @@ -0,0 +1,992 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Рубрики и поля{% endblock %} + +{% macro code_field(label, name, height, extra, tags) %} +
    + + {{ label }} + + {% if tags %}{% endif %} + + + + + + +
    +{% endmacro %} + +{% block content %} +{% import _self as ui %} + + + + + +
    +
    + +
    {{ stats.rubrics }}Рубрик
    +
    +
    + +
    {{ stats.active }}Активных
    +
    +
    + +
    {{ stats.fields }}Полей
    +
    +
    + +
    {{ stats.groups }}Групп
    +
    +
    + +
    + + +
    + +
    +
    +
    +
    +
    Контент
    +

    Структура документов

    +

    Настройте состав формы, затем сразу переходите к материалам выбранной рубрики.

    +
    +
    + +
    +
    +
    + +
    +

    Рубрики

    +

    {{ rubrics|length }} в текущем фильтре, {{ stats.rubrics }} всего.

    +
    +
    + {{ filters.state ? filters.state : 'all' }} +
    +
    +
    +
    + + +
    + + + {% if filters.q or filters.state %} + + {% endif %} +
    +
    +
    + +
    +
    +
    + +
    +

    Список рубрик

    +

    {{ can_manage ? 'Перетащите строки за ручку, чтобы изменить порядок.' : 'Просмотр структуры рубрик.' }}

    +
    +
    + {{ rubrics|length }} +
    +
    + + + {% if can_manage %}{% endif %} + + + + + + + {% if can_manage %}{% endif %} + + + + {% if can_manage %}{% endif %} + + + + + + + {% if can_manage %}{% endif %} + + + + {% for rubric in rubrics %} + + {% if can_manage %} + + {% endif %} + + + + + + + {% if can_manage %} + + {% endif %} + + {% else %} + + {% endfor %} + +
    IDНазваниеСостояниеСоставМатериалыШаблон
    {{ rubric.Id }} +
    + {{ rubric.rubric_title }} + {{ rubric.rubric_alias ? rubric.rubric_alias : 'без алиаса' }} +
    +
    +
    + {% if rubric.rubric_docs_active == 1 %}активна{% else %}выкл{% endif %} + {% if rubric.rubric_meta_gen == '1' %}meta{% endif %} + {% if rubric.rubric_alias_history == '1' %}history{% endif %} + {% if rubric.Id == 1 %}system{% endif %} +
    +
    +
    + {{ rubric.fields_count }} полей + {{ rubric.templates_count }} доп. шаблонов +
    +
    +
    + {% if can_view_documents %} + + {{ rubric.docs_count }}{{ rubric.docs_count == 1 ? 'документ' : (rubric.docs_count in [2,3,4] ? 'документа' : 'документов') }} + + {% else %} + {{ rubric.docs_count }}документов + {% endif %} + {% if can_manage_documents %}{% endif %} +
    +
    +
    + {{ rubric.site_template_title ?: 'Не выбран' }} + {% if rubric.rubric_template_id %}#{{ rubric.rubric_template_id }}{% endif %} +
    +
    +
    + + + + + + {% if rubric.can_delete %} + + {% else %} + + {% endif %} +
    +
    Рубрики не найдены.
    +
    +
    +
    + + + +{% if can_manage %} + + + + + + + + + + + + + + + +{% endif %} + +{% endblock %} diff --git a/adminx/modules/Security/Controller.php b/adminx/modules/Security/Controller.php new file mode 100644 index 0000000..93f62a1 --- /dev/null +++ b/adminx/modules/Security/Controller.php @@ -0,0 +1,70 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Security; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\AuditLog; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\IpBlocker; + use App\Common\Permission; + use App\Helpers\Request; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + AdminAssets::addStyle($this->base() . '/modules/Security/assets/security.css', 50); + AdminAssets::addScript($this->base() . '/modules/Security/assets/security.js', 50); + return $this->render('@security/index.twig', array( + 'rows' => IpBlocker::all(Request::getStr('q', '')), + 'q' => Request::getStr('q', ''), + 'current_ip' => Request::ip(), + 'can_manage' => Permission::check('manage_ip_blocks'), + )); + } + + public function block(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + try { + $ip = Request::postStr('ip', ''); + $reason = Request::postStr('reason', ''); + $hours = Request::postInt('hours', 0); + $expires = $hours > 0 ? time() + min($hours, 87600) * 3600 : null; + $row = IpBlocker::block($ip, $reason, $expires, Auth::id()); + AuditLog::record('security.ip_blocked', array('actor_id' => Auth::id(), 'target_type' => 'ip', 'target_id' => $ip, 'meta' => array('reason' => $reason, 'expires_at' => $expires))); + return $this->success('IP-адрес заблокирован', array('redirect' => $this->base() . '/security/ip-blocks', 'data' => array('block' => $row))); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + } + + public function unblock(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + IpBlocker::unblock($id); + AuditLog::record('security.ip_unblocked', array('actor_id' => Auth::id(), 'target_type' => 'ip_block', 'target_id' => $id)); + return $this->success('IP-адрес разблокирован', array('redirect' => $this->base() . '/security/ip-blocks')); + } + + protected function guard() + { + return $this->guardPermission('manage_ip_blocks'); + } + } diff --git a/adminx/modules/Security/assets/security.css b/adminx/modules/Security/assets/security.css new file mode 100644 index 0000000..1d99fba --- /dev/null +++ b/adminx/modules/Security/assets/security.css @@ -0,0 +1,57 @@ +.security-layout { + display: grid; + grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); + gap: 18px; + align-items: start; +} +.security-form-card { + padding: 18px; +} +.security-form-card form { + display: grid; + gap: 14px; +} +.security-form-card label { + display: grid; + gap: 6px; + font-size: 13px; + font-weight: 600; +} +.security-filter { + display: flex; + gap: 8px; +} +.security-filter .input-wrap { + width: 260px; +} +.security-table-card { + overflow: hidden; +} +.security-table-card .table { + width: 100%; + table-layout: fixed; +} +.security-table-card th:nth-child(1) { + width: 150px; +} +.security-table-card th:nth-child(3) { + width: 130px; +} +.security-table-card th:nth-child(4) { + width: 175px; +} +.security-table-card th:nth-child(5) { + width: 90px; +} +.security-table-card td:nth-child(2) { + overflow-wrap: anywhere; +} +.security-table-card .table-actions { + padding-right: 16px; + text-align: right; +} +@media (max-width: 980px) { + .security-layout { + grid-template-columns: 1fr; + } +} diff --git a/adminx/modules/Security/assets/security.js b/adminx/modules/Security/assets/security.js new file mode 100644 index 0000000..8a591b0 --- /dev/null +++ b/adminx/modules/Security/assets/security.js @@ -0,0 +1,18 @@ +(function (window, document) { + 'use strict'; + var Adminx = window.Adminx; + function post(url, data) { + Adminx.Loader.show(); + return Adminx.Ajax.post(url, data).then(function (payload) { Adminx.Loader.hide(); Adminx.Ajax.handle(payload); }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); }); + } + document.addEventListener('submit', function (event) { + var form = event.target.closest('[data-ip-block-form]'); + if (!form) { return; } + event.preventDefault(); post(form.action, new FormData(form)); + }); + document.addEventListener('click', function (event) { + var button = event.target.closest('[data-ip-unblock]'); + if (!button) { return; } + Adminx.Confirm.open({ kind: 'error', title: 'Разблокировать IP?', message: 'Доступ с этого адреса будет восстановлен сразу.', confirmLabel: 'Разблокировать', onConfirm: function () { post(Adminx.base() + '/security/ip-blocks/' + button.dataset.ipUnblock + '/delete'); } }); + }); +})(window, document); diff --git a/adminx/modules/Security/language/en/client.xml b/adminx/modules/Security/language/en/client.xml new file mode 100644 index 0000000..15ee32e --- /dev/null +++ b/adminx/modules/Security/language/en/client.xml @@ -0,0 +1,7 @@ + + + Network error + Unblock + Access from this address will be restored immediately. + Unblock IP? + diff --git a/adminx/modules/Security/language/en/interface.xml b/adminx/modules/Security/language/en/interface.xml new file mode 100644 index 0000000..54929ff --- /dev/null +++ b/adminx/modules/Security/language/en/interface.xml @@ -0,0 +1,38 @@ + + + 1 hour + 24 hours + 30 days + 7 days + IP or reason + IP address + Addresses for which access to the site is restricted. + Active locks + No comment + up to + Blocked IPs + There are no blocked addresses + Block + Block address + Find + Permanent and time-limited blocking. + Reason + Reason for blocking + Unblock + Manual access locks and automatic request rate protection + Created + Deadline + Current address: + IP address blocked + IP address unblocked + IP blocking + IP blocking: view + IP blocking: management + Security + Blocking and unblocking IP addresses. + Access from this address will be restored immediately. + Network error + View blocked IP addresses. + Unblock IP? + System + diff --git a/adminx/modules/Security/language/en/runtime.xml b/adminx/modules/Security/language/en/runtime.xml new file mode 100644 index 0000000..5cccb65 --- /dev/null +++ b/adminx/modules/Security/language/en/runtime.xml @@ -0,0 +1,12 @@ + + + IP address blocked + IP address unblocked + IP blocking + IP blocking: view + IP blocking: management + Security + Blocking and unblocking IP addresses. + View blocked IP addresses. + System + diff --git a/adminx/modules/Security/language/ru/client.xml b/adminx/modules/Security/language/ru/client.xml new file mode 100644 index 0000000..19f3ce4 --- /dev/null +++ b/adminx/modules/Security/language/ru/client.xml @@ -0,0 +1,7 @@ + + + Ошибка сети + Разблокировать + Доступ с этого адреса будет восстановлен сразу. + Разблокировать IP? + diff --git a/adminx/modules/Security/language/ru/interface.xml b/adminx/modules/Security/language/ru/interface.xml new file mode 100644 index 0000000..7c8baa1 --- /dev/null +++ b/adminx/modules/Security/language/ru/interface.xml @@ -0,0 +1,38 @@ + + + 1 час + 24 часа + 30 дней + 7 дней + IP или причина + IP-адрес + Адреса, для которых ограничен доступ к сайту. + Активные блокировки + Без комментария + до + Заблокированные IP + Заблокированных адресов нет + Заблокировать + Заблокировать адрес + Найти + Постоянные и ограниченные по времени блокировки. + Причина + Причина блокировки + Разблокировать + Ручные блокировки доступа и автоматическая защита частоты запросов + Создано + Срок + Текущий адрес: + IP-адрес заблокирован + IP-адрес разблокирован + IP-блокировки + IP-блокировки: просмотр + IP-блокировки: управление + Безопасность + Блокировка и разблокировка IP-адресов. + Доступ с этого адреса будет восстановлен сразу. + Ошибка сети + Просмотр заблокированных IP-адресов. + Разблокировать IP? + Система + diff --git a/adminx/modules/Security/language/ru/runtime.xml b/adminx/modules/Security/language/ru/runtime.xml new file mode 100644 index 0000000..d2afb45 --- /dev/null +++ b/adminx/modules/Security/language/ru/runtime.xml @@ -0,0 +1,12 @@ + + + IP-адрес заблокирован + IP-адрес разблокирован + IP-блокировки + IP-блокировки: просмотр + IP-блокировки: управление + Безопасность + Блокировка и разблокировка IP-адресов. + Просмотр заблокированных IP-адресов. + Система + diff --git a/adminx/modules/Security/migrations/001_create_ip_blocks.sql b/adminx/modules/Security/migrations/001_create_ip_blocks.sql new file mode 100644 index 0000000..5edbf5a --- /dev/null +++ b/adminx/modules/Security/migrations/001_create_ip_blocks.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS `{{system_prefix}}_ip_blocks` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `ip` VARCHAR(45) NOT NULL, + `reason` VARCHAR(500) NOT NULL DEFAULT '', + `expires_at` INT UNSIGNED NULL, + `actor_id` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` INT UNSIGNED NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_ip` (`ip`), + KEY `idx_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Security/module.php b/adminx/modules/Security/module.php new file mode 100644 index 0000000..404f058 --- /dev/null +++ b/adminx/modules/Security/module.php @@ -0,0 +1,61 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + return array( + 'code' => 'security', + 'name' => 'Безопасность', + 'version' => '0.1.0', + 'permissions' => array('key' => 'security', 'items' => array( + array( + 'code' => 'view_ip_blocks', + 'group_code' => 'navigation', + 'name' => 'IP-блокировки: просмотр', + 'description' => 'Просмотр заблокированных IP-адресов.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_ip_blocks', + 'group_code' => 'admin', + 'name' => 'IP-блокировки: управление', + 'description' => 'Блокировка и разблокировка IP-адресов.', + 'sort_order' => 20, + ), + ), 'icon' => 'ti ti-shield-lock', 'priority' => 42), + 'navigation' => array( + array( + 'code' => 'security_ip_blocks', + 'label' => 'IP-блокировки', + 'url' => '/security/ip-blocks', + 'icon' => 'ti ti-shield-lock', + 'permission' => 'view_ip_blocks', + 'group' => 'Система', + 'sort_order' => 37, + 'parent' => 'settings', + 'match' => array( + '/security/ip-blocks', + ), + ), + ), + 'migrations' => array( + array('id' => '001_create_ip_blocks', 'file' => 'migrations/001_create_ip_blocks.sql'), + ), + 'routes' => array( + array('GET', '/security/ip-blocks', array(\App\Adminx\Security\Controller::class, 'index')), + array('POST', '/security/ip-blocks', array(\App\Adminx\Security\Controller::class, 'block')), + array('POST', '/security/ip-blocks/{id}/delete', array(\App\Adminx\Security\Controller::class, 'unblock')), + ), + 'view_globals' => array('module_code' => 'security'), + ); diff --git a/adminx/modules/Security/view/index.twig b/adminx/modules/Security/view/index.twig new file mode 100644 index 0000000..297dbdc --- /dev/null +++ b/adminx/modules/Security/view/index.twig @@ -0,0 +1,25 @@ +{% extends '@adminx/main.twig' %} +{% block title %}IP-блокировки{% endblock %} +{% block content %} + + +
    + {% if can_manage %}
    +

    Заблокировать адрес

    Текущий адрес: {{ current_ip }}

    +
    + + + + + +
    +
    {% endif %} +
    +
    Безопасность

    Заблокированные IP

    Постоянные и ограниченные по времени блокировки.

    +

    Активные блокировки

    Адреса, для которых ограничен доступ к сайту.

    {{ rows|length }}
    + {% for row in rows %} + {% else %}{% endfor %} +
    IP-адресПричинаСозданоСрокДействия
    {{ row.ip }}{{ row.reason ?: 'Без комментария' }}{{ row.created_at|date('d.m.Y H:i') }}{% if row.expires_at %}до {{ row.expires_at|date('d.m.Y H:i') }}{% else %}Бессрочно{% endif %}{% if can_manage %}{% endif %}

    Заблокированных адресов нет

    +
    +
    +{% endblock %} diff --git a/adminx/modules/Settings/Constants.php b/adminx/modules/Settings/Constants.php new file mode 100644 index 0000000..dca2f91 --- /dev/null +++ b/adminx/modules/Settings/Constants.php @@ -0,0 +1,314 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Settings; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Common\SystemTables; + use App\Frontend\Media\ThumbnailPresetPolicy; + use App\Helpers\Json; + + class Constants + { + public static function table() + { + return SystemTables::table('constants'); + } + + public static function groups() + { + return array( + '_CONST_URL' => 'Формирование URL', + '_CONST_THEMES' => 'Оформление', + '_CONST_FOLDERS' => 'Папки', + '_CONST_THUMBS' => 'Генерация миниатюр', + '_CONST_WATERMARKS' => 'Водяные знаки', + '_CONST_SESSIONS' => 'Session и Cookie', + '_CONST_DEV' => 'Разработка', + '_CONST_CACHE' => 'Кеширование', + '_CONST_COMPRESSION' => 'Компрессия', + '_CONST_MEMCACHED' => 'Memcached', + '_CONST_REQUEST' => 'Запросы', + '_CONST_OTHER' => 'Прочее', + 'custom' => 'Пользовательские', + ); + } + + public static function typeLabels() + { + return array( + 'string' => 'Строка', + 'bool' => 'Да/нет', + 'int' => 'Целое число', + 'select' => 'Список выбора', + 'path' => 'Путь', + 'tags' => 'Теги', + ); + } + + public static function all() + { + $rows = DB::query('SELECT * FROM ' . self::table() . ' ORDER BY group_code ASC, sort_order ASC, name ASC')->getAll(); + $typeLabels = self::typeLabels(); + foreach ($rows as $i => $row) { + $rows[$i]['options_array'] = self::decodeOptions($row['options']); + $rows[$i]['value_display'] = self::displayValue($row['value'], $row['type'], $row['name']); + $rows[$i]['type_label'] = isset($typeLabels[$row['type']]) ? $typeLabels[$row['type']] : $row['type']; + $rows[$i] = array_merge($rows[$i], self::auditStatus($row['name'])); + } + + $order = self::groupOrder(); + usort($rows, function ($a, $b) use ($order) { + $ag = isset($order[$a['group_code']]) ? $order[$a['group_code']] : 999; + $bg = isset($order[$b['group_code']]) ? $order[$b['group_code']] : 999; + if ($ag !== $bg) return $ag - $bg; + if ((int) $a['sort_order'] !== (int) $b['sort_order']) return (int) $a['sort_order'] - (int) $b['sort_order']; + return strcmp($a['name'], $b['name']); + }); + return $rows; + } + + public static function grouped() + { + $labels = self::groups(); + $out = array(); + foreach (self::all() as $row) { + $group = (string) $row['group_code']; + if (!isset($out[$group])) { + $out[$group] = array( + 'code' => $group, + 'label' => isset($labels[$group]) ? $labels[$group] : $group, + 'items' => array(), + ); + } + + $out[$group]['items'][] = $row; + } + + return $out; + } + + public static function one($name) + { + $row = DB::query('SELECT * FROM ' . self::table() . ' WHERE name = %s LIMIT 1', (string) $name)->getAssoc(); + if (!$row) { + return null; + } + + $typeLabels = self::typeLabels(); + $row['options_array'] = self::decodeOptions($row['options']); + $row['type_label'] = isset($typeLabels[$row['type']]) ? $typeLabels[$row['type']] : $row['type']; + return $row; + } + + public static function save(array $input) + { + $name = strtoupper(trim((string) (isset($input['name']) ? $input['name'] : ''))); + if (!preg_match('/^[A-Z][A-Z0-9_]*$/', $name)) { + throw new \InvalidArgumentException('Некорректное имя константы'); + } + + $type = self::normalizeType(isset($input['type']) ? $input['type'] : 'string'); + $rawValue = isset($input['value']) ? $input['value'] : ''; + if ($name === 'THUMBNAIL_SIZES') { + $type = 'tags'; + $invalid = ThumbnailPresetPolicy::invalidList($rawValue); + if ($invalid) { + throw new \InvalidArgumentException('Некорректные размеры: ' . implode(', ', $invalid)); + } + + $presets = ThumbnailPresetPolicy::normalizeList($rawValue); + if (!$presets) { + throw new \InvalidArgumentException('Добавьте хотя бы один разрешённый размер миниатюр'); + } + + $rawValue = implode(',', $presets); + } + + $value = self::normalizeValue($rawValue, $type); + $options = self::normalizeOptions(isset($input['options']) ? $input['options'] : ''); + + $data = array( + 'name' => $name, + 'value' => self::storeValue($value, $type), + 'type' => $type, + 'group_code' => trim((string) (isset($input['group_code']) ? $input['group_code'] : 'custom')) ?: 'custom', + 'label' => trim((string) (isset($input['label']) ? $input['label'] : $name)), + 'description' => trim((string) (isset($input['description']) ? $input['description'] : '')), + 'options' => Json::encode($options, JSON_UNESCAPED_UNICODE), + 'is_system' => !empty($input['is_system']) ? 1 : 0, + 'sort_order' => isset($input['sort_order']) ? (int) $input['sort_order'] : 100, + 'updated_at' => date('Y-m-d H:i:s'), + ); + + DB::query( + 'INSERT INTO ' . self::table() . ' (name, value, type, group_code, label, description, options, is_system, sort_order, updated_at) + VALUES (%s, %?, %s, %s, %s, %?, %?, %i, %i, %s) + ON DUPLICATE KEY UPDATE + value = VALUES(value), + type = VALUES(type), + group_code = VALUES(group_code), + label = VALUES(label), + description = VALUES(description), + options = VALUES(options), + is_system = VALUES(is_system), + sort_order = VALUES(sort_order), + updated_at = VALUES(updated_at)', + $data['name'], + $data['value'], + $data['type'], + $data['group_code'], + $data['label'], + $data['description'], + $data['options'], + $data['is_system'], + $data['sort_order'], + $data['updated_at'] + ); + + return $name; + } + + public static function delete($name) + { + $row = self::one($name); + if (!$row) { + return false; + } + + if ((int) $row['is_system'] === 1) { + throw new \RuntimeException('Системную константу нельзя удалить'); + } + + DB::Delete(self::table(), 'name = %s', (string) $name); + return true; + } + + protected static function normalizeType($type) + { + $type = strtolower((string) $type); + if ($type === 'integer') return 'int'; + if ($type === 'dropdown') return 'select'; + if ($type === 'folder') return 'path'; + if (!in_array($type, array('bool', 'int', 'string', 'select', 'path', 'tags'), true)) { + return 'string'; + } + + return $type; + } + + protected static function normalizeValue($value, $type) + { + if ($type === 'bool') { + return in_array((string) $value, array('1', 'true', 'on'), true); + } + + if ($type === 'int') { + return (int) $value; + } + + return (string) $value; + } + + protected static function storeValue($value, $type) + { + if ($type === 'bool') return $value ? '1' : '0'; + return (string) $value; + } + + protected static function decodeOptions($json) + { + return Json::toArray((string) $json); + } + + protected static function normalizeOptions($raw) + { + if (is_array($raw)) { + return array_values(array_map('strval', $raw)); + } + + $lines = preg_split('/[\\r\\n,]+/', (string) $raw); + $out = array(); + foreach ($lines as $line) { + $line = trim($line); + if ($line !== '') { + $out[] = $line; + } + } + + return $out; + } + + protected static function groupOrder() + { + $order = array(); + $i = 0; + foreach (array_keys(self::groups()) as $code) { + $order[$code] = $i++; + } + + return $order; + } + + protected static function auditStatus($name) + { + $compatibility = array( + 'CACHE_DOC_FILE', 'CACHE_DOC_FULL', 'CACHE_DOC_FULL_ADMIN', 'CACHE_DOC_TPL', + 'MEMORY_LIMIT_PANIC', 'PROFILING', 'REQUEST_BREAK_WORDS', 'REQUEST_ETC', + 'REQUEST_STRIP_TAGS', 'SEND_SQL_ERROR', 'SQL_QUERY_SANITIZE', + 'USE_ENCODE_SERIALIZE', 'USE_GET_FIELDS', 'USE_STATIC_DATA', + ); + if (in_array((string) $name, $compatibility, true)) { + return array('usage_status' => 'compatibility', 'usage_label' => 'Совместимость', 'usage_reason' => 'Используется только переходным публичным или legacy-слоем.'); + } + + return array('usage_status' => 'active', 'usage_label' => 'Используется', 'usage_reason' => 'Есть обращения из текущего framework, Adminx или публичного runtime.'); + } + + protected static function displayValue($value, $type, $name = '') + { + if ((string) $name === 'THUMBNAIL_SIZES') { + $count = count(ThumbnailPresetPolicy::normalizeList($value)); + return $count . ' ' . self::pluralSizes($count); + } + + if ($type === 'bool') { + return ((string) $value === '1') ? 'Да' : 'Нет'; + } + + return (string) $value; + } + + protected static function pluralSizes($count) + { + $mod100 = (int) $count % 100; + $mod10 = (int) $count % 10; + if ($mod100 >= 11 && $mod100 <= 14) { + return 'размеров'; + } + + if ($mod10 === 1) { + return 'размер'; + } + + if ($mod10 >= 2 && $mod10 <= 4) { + return 'размера'; + } + + return 'размеров'; + } + + } diff --git a/adminx/modules/Settings/Controller.php b/adminx/modules/Settings/Controller.php new file mode 100644 index 0000000..1e9d575 --- /dev/null +++ b/adminx/modules/Settings/Controller.php @@ -0,0 +1,315 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Settings; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Common\Navigation; + use App\Common\AuditLog; + use App\Common\Settings; + use App\Adminx\Support\CodeEditor; + use App\Adminx\Support\AdminLocale; + use App\Adminx\Support\InterfaceSettings; + use App\Adminx\Support\ModuleExtensions; + use App\Adminx\Support\ReAuth; + use App\Adminx\Dashboard\Widgets; + use App\Frontend\Media\ThumbnailPresetScanner; + use App\Helpers\Request; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + $this->redirect($this->base() . '/settings/main'); + } + + public function page(array $params = array()) + { + $path = trim((string) parse_url(isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '', PHP_URL_PATH), '/'); + $section = basename($path); + $titles = array('main'=>'Основные настройки','interface'=>'Интерфейс','security'=>'Безопасность','constants'=>'Константы','paginations'=>'Шаблоны пагинации','maintenance'=>'Обслуживание','diagnostics'=>'Диагностика','files'=>'Системные файлы'); + if (!isset($titles[$section])) { return $this->renderStatus('@adminx/404.twig', array('title'=>'Раздел настроек не найден'), 404); } + AdminAssets::addStyle($this->base() . '/modules/Settings/assets/settings.css', 50); + AdminAssets::addScript($this->base() . '/modules/Settings/assets/settings.js', 50); + if ($section === 'files') { CodeEditor::useCodeMirror('application/x-httpd-php'); } + + $data = array( + 'settings_page' => $section, + 'settings_title' => AdminLocale::translateMarkup($titles[$section]), + 'can_manage' => Permission::check('manage_settings'), + ); + if ($section === 'main') { + $data['groups'] = Schema::groupedFields(); + } elseif ($section === 'interface') { + $data['navigation_layout'] = InterfaceSettings::navigationCatalog(Navigation::all()); + $data['dashboard_layout'] = Widgets::catalog(ModuleExtensions::dashboardDefinitions()); + } elseif ($section === 'security') { + $data['reauth_enabled'] = (bool) Settings::get('adminx_reauth_enabled', false); + } elseif ($section === 'constants') { + $data['constant_groups'] = Constants::grouped(); + $data['constant_group_labels'] = Constants::groups(); + $data['constant_type_labels'] = Constants::typeLabels(); + } elseif ($section === 'paginations') { + $data['paginations'] = Model::paginations(); + } elseif ($section === 'maintenance') { + $data['cache_rows'] = Model::cacheRows(); + $data['maintenance_counts'] = Model::maintenanceCounts(); + } elseif ($section === 'diagnostics') { + $data['production_diagnostics'] = ProductionDiagnostics::run(); + } elseif ($section === 'files') { + $data['system_files'] = Model::systemFiles(); + } + + return $this->render('@settings/index.twig', $data); + } + + public function save(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $input = Request::postAll(); + $previousAccessMode = (string) Settings::get('site_access_mode', 'public'); + $result = Model::saveMany($input); + if (!$result['success']) { + return $this->error('Проверьте поля формы', $result['errors']); + } + + if (array_key_exists('site_access_mode', $input)) { + $currentAccessMode = (string) Settings::get('site_access_mode', 'public'); + if ($currentAccessMode !== $previousAccessMode) { + AuditLog::record('settings.site_access_changed', array( + 'actor_id' => Auth::id(), + 'target_type' => 'settings', + 'meta' => array('from' => $previousAccessMode, 'to' => $currentAccessMode), + )); + } + } + + return $this->success('Настройки сохранены', array( + 'data' => array('saved' => $result['saved']), + )); + } + + public function saveInterface(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $navigation = json_decode(Request::postStr('navigation', '[]'), true); + $dashboard = json_decode(Request::postStr('dashboard', '[]'), true); + if (!is_array($navigation) || !is_array($dashboard)) { + return $this->error('Некорректные данные интерфейса'); + } + + $navigationAvailable = InterfaceSettings::navigationCatalog(Navigation::all()); + $dashboardAvailable = Widgets::catalog(ModuleExtensions::dashboardDefinitions()); + InterfaceSettings::saveNavigation($navigation, $navigationAvailable); + InterfaceSettings::saveDashboard($dashboard, $dashboardAvailable); + AuditLog::record('settings.interface_updated', array( + 'actor_id' => Auth::id(), + 'target_type' => 'settings', + 'meta' => array('navigation' => count($navigation), 'dashboard' => count($dashboard)), + )); + + return $this->success('Интерфейс обновлен', array('reload' => true)); + } + + public function saveSecurity(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $enabled = Request::postStr('adminx_reauth_enabled', '0') === '1'; + Settings::set('adminx_reauth_enabled', $enabled, 'bool'); + if (!$enabled) { + ReAuth::forget(); + } + + AuditLog::record('settings.security_updated', array( + 'actor_id' => Auth::id(), + 'target_type' => 'settings', + 'meta' => array('adminx_reauth_enabled' => $enabled), + )); + + return $this->success('Настройки безопасности сохранены', array('reload' => true)); + } + + public function pagination(array $params = array()) + { + $item = Model::pagination(isset($params['id']) ? (int) $params['id'] : 0); + if (!$item) { + return $this->error('Шаблон пагинации не найден', array(), 404); + } + + return $this->success('', array('data' => $item)); + } + + public function savePagination(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $id = isset($params['id']) ? (int) $params['id'] : 0; + $savedId = Model::savePagination($id, Request::postAll()); + + return $this->success('Шаблон пагинации сохранён', array( + 'data' => array('id' => $savedId), + 'redirect' => $this->base() . '/settings/paginations', + )); + } + + public function deletePagination(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + if (!Model::deletePagination(isset($params['id']) ? (int) $params['id'] : 0)) { + return $this->error('Базовый шаблон удалить нельзя', array(), 422); + } + + return $this->success('Шаблон пагинации удалён'); + } + + public function clearCache(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $result = Model::clearCacheSource(isset($params['source']) ? $params['source'] : ''); + if ($result === false) { + return $this->error('Неизвестный источник кеша', array(), 404); + } + + return $this->success('Кеш очищен', array('data' => $result)); + } + + public function clearMaintenance(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $result = Model::clearMaintenance(isset($params['target']) ? $params['target'] : ''); + if ($result === false) { + return $this->error('Неизвестная операция обслуживания', array(), 404); + } + + return $this->success('Данные очищены', array('data' => $result)); + } + + public function systemFile(array $params = array()) + { + $file = Model::systemFile(isset($params['code']) ? $params['code'] : ''); + if (!$file) { + return $this->error('Системный файл не найден', array(), 404); + } + + return $this->success('', array('data' => $file)); + } + + public function saveSystemFile(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $file = Model::saveSystemFile( + isset($params['code']) ? $params['code'] : '', + Request::postStr('content', '') + ); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Системный файл сохранён', array('data' => $file)); + } + + public function constant(array $params = array()) + { + $item = Constants::one(isset($params['name']) ? $params['name'] : ''); + if (!$item) { + return $this->error('Константа не найдена', array(), 404); + } + + return $this->success('', array('data' => $item)); + } + + public function saveConstant(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $name = Constants::save(Request::postAll()); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + + return $this->success('Константа сохранена', array( + 'data' => array('name' => $name), + 'redirect' => $this->base() . '/settings/constants', + )); + } + + public function scanThumbnailSizes(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $result = ThumbnailPresetScanner::scan(); + } catch (\Throwable $e) { + return $this->error('Не удалось собрать размеры: ' . $e->getMessage(), array(), 422); + } + + return $this->success('Размеры собраны и подставлены в поле', array( + 'data' => $result, + )); + } + + public function deleteConstant(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + Constants::delete(isset($params['name']) ? $params['name'] : ''); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + + return $this->success('Константа удалена'); + } + + protected function guard() + { + return $this->guardPermission('manage_settings'); + } + } diff --git a/adminx/modules/Settings/Model.php b/adminx/modules/Settings/Model.php new file mode 100644 index 0000000..562f329 --- /dev/null +++ b/adminx/modules/Settings/Model.php @@ -0,0 +1,469 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Settings; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\Settings; + use App\Common\FileCacheInvalidator; + use App\Common\SystemTables; + use App\Content\ContentTables; + use App\Content\PublicShellTables; + use App\Frontend\PublicSettings; + use App\Frontend\ThemeSettings; + use DB; + + class Model + { + public static function countriesTable() + { + return SystemTables::table('countries'); + } + + public static function paginationsTable() + { + return SystemTables::table('paginations'); + } + + public static function value($key, $default = '') + { + return Settings::get($key, $default); + } + + public static function saveMany(array $input) + { + $defs = Schema::definitions(); + $errors = array(); + $values = array(); + + foreach ($defs as $key => $definition) { + if (!array_key_exists($key, $input)) { + continue; + } + + $raw = $input[$key]; + $result = self::validate($key, $definition, $raw); + if (!$result['success']) { + $errors[$key] = $result['message']; + continue; + } + + $values[$key] = $result; + } + + if (!empty($errors)) { + return array('success' => false, 'errors' => $errors, 'saved' => 0); + } + + $publicValues = array(); + foreach ($values as $key => $result) { + Settings::set($key, $result['value'], $result['store_type']); + $definition = isset($defs[$key]) ? $defs[$key] : array(); + if (!array_key_exists('public_shell', $definition) || $definition['public_shell'] !== false) { + $publicValues[$key] = $result['store_type'] === 'bool' + ? ($result['value'] ? '1' : '0') + : $result['value']; + } + } + + if ($publicValues) { + DB::Update(PublicShellTables::table('settings'), $publicValues, 'Id = %i', 1); + PublicSettings::reset(); + } + + DB::clearTags(array('settings')); + FileCacheInvalidator::publicPresentation(); + + return array('success' => true, 'errors' => array(), 'saved' => count($values)); + } + + public static function validate($key, array $definition, $raw) + { + $type = isset($definition['type']) ? $definition['type'] : 'string'; + if ($type === 'bool') { + $raw = $raw === null ? '0' : $raw; + } + + if (!empty($definition['required']) && trim((string) $raw) === '') { + return array('success' => false, 'message' => 'Заполните поле'); + } + + if ($type === 'int') { + $value = (int) $raw; + if (isset($definition['min']) && $value < (int) $definition['min']) { + return array('success' => false, 'message' => 'Минимум: ' . (int) $definition['min']); + } + + if (isset($definition['max']) && $value > (int) $definition['max']) { + return array('success' => false, 'message' => 'Максимум: ' . (int) $definition['max']); + } + + return array('success' => true, 'value' => $value, 'store_type' => 'int'); + } + + if ($type === 'bool') { + return array('success' => true, 'value' => in_array((string) $raw, array('1', 'true', 'on'), true), 'store_type' => 'bool'); + } + + if ($type === 'section_order') { + $decoded = is_array($raw) ? $raw : json_decode((string) $raw, true); + if (!is_array($decoded)) { + return array('success' => false, 'message' => 'Не удалось прочитать порядок секций'); + } + + $options = isset($definition['options']) && is_array($definition['options']) ? $definition['options'] : array(); + $value = ThemeSettings::sectionOrder($decoded, $options); + return array('success' => true, 'value' => array_map(function ($item) { + return array('code' => $item['code'], 'visible' => !empty($item['visible'])); + }, $value), 'store_type' => 'json'); + } + + $value = (string) $raw; + + if (($type === 'select' || !empty($definition['options'])) && !empty($definition['options'])) { + $allowed = array_keys($definition['options']); + if (!in_array($value, $allowed, true)) { + return array('success' => false, 'message' => 'Недопустимое значение'); + } + } + + if ($type === 'email' && $value !== '' && !preg_match('/^[^@\s]+@[^@\s]+$/', $value)) { + return array('success' => false, 'message' => 'Некорректный email'); + } + + if (isset($definition['max']) && function_exists('mb_strlen') && mb_strlen($value, 'UTF-8') > (int) $definition['max']) { + return array('success' => false, 'message' => 'Максимум символов: ' . (int) $definition['max']); + } + + $storeType = in_array($type, array('text', 'password', 'email', 'select'), true) ? 'string' : $type; + return array('success' => true, 'value' => $value, 'store_type' => $storeType); + } + + public static function countries() + { + try { + return DB::query( + 'SELECT Id, country_code, country_name, country_status, country_eu FROM ' . self::countriesTable() . ' ORDER BY country_status ASC, country_name ASC' + )->getAll(); + } catch (\Throwable $e) { + return array(); + } + } + + public static function saveCountries(array $input) + { + if (empty($input['country_name']) || !is_array($input['country_name'])) { + return 0; + } + + $saved = 0; + foreach ($input['country_name'] as $id => $name) { + $id = (int) $id; + if ($id <= 0) { + continue; + } + + $status = isset($input['country_status'][$id]) && (string) $input['country_status'][$id] === '1' ? '1' : '2'; + $eu = isset($input['country_eu'][$id]) && (string) $input['country_eu'][$id] === '1' ? '1' : '2'; + DB::Update(self::countriesTable(), array( + 'country_name' => trim((string) $name), + 'country_status' => $status, + 'country_eu' => $eu, + ), 'Id = %i', $id); + $saved++; + } + + DB::clearTags(array('settings', 'countries')); + return $saved; + } + + public static function paginations() + { + try { + return DB::query( + 'SELECT id, pagination_name FROM ' . self::paginationsTable() . ' ORDER BY id ASC' + )->getAll(); + } catch (\Throwable $e) { + return array(); + } + } + + public static function pagination($id) + { + return DB::query('SELECT * FROM ' . self::paginationsTable() . ' WHERE id = %i LIMIT 1', (int) $id)->getAssoc(); + } + + public static function savePagination($id, array $input) + { + $data = self::paginationInput($input); + if ((int) $id > 0) { + DB::Update(self::paginationsTable(), $data, 'id = %i', (int) $id); + DB::clearTags(array('settings', 'paginations')); + return (int) $id; + } + + DB::Insert(self::paginationsTable(), $data); + DB::clearTags(array('settings', 'paginations')); + return (int) DB::insertId(); + } + + public static function deletePagination($id) + { + $id = (int) $id; + if ($id <= 1) { + return false; + } + + DB::Delete(self::paginationsTable(), 'id = %i', $id); + DB::clearTags(array('settings', 'paginations')); + return true; + } + + public static function cacheSources() + { + return array( + 'twig' => array('label' => 'Twig', 'path' => BASEPATH . '/tmp/cache/twig'), + 'templates' => array('label' => 'Шаблоны страниц', 'path' => BASEPATH . '/tmp/cache/tpl'), + 'database' => array('label' => 'Данные и запросы', 'path' => BASEPATH . '/tmp/cache/sql'), + 'snapshots' => array('label' => 'JSON-снимки документов', 'path' => BASEPATH . '/tmp/cache/content-snapshots'), + 'modules' => array('label' => 'Публичные модули', 'path' => BASEPATH . '/tmp/cache/module'), + 'feeds' => array('label' => 'Товарные фиды', 'path' => BASEPATH . '/tmp/cache/feeds'), + ); + } + + public static function cacheRows() + { + $rows = array(); + foreach (self::cacheSources() as $code => $source) { + $bytes = self::dirSize($source['path']); + $rows[] = array( + 'code' => $code, + 'label' => $source['label'], + 'path' => str_replace(BASEPATH . '/', '', $source['path']), + 'bytes' => $bytes, + 'size' => self::formatBytes($bytes), + ); + } + + return $rows; + } + + public static function clearCacheSource($code) + { + $sources = self::cacheSources(); + $code = (string) $code; + if (!isset($sources[$code])) { + return false; + } + + self::removeDirContents($sources[$code]['path']); + return array( + 'code' => $code, + 'size' => self::formatBytes(self::dirSize($sources[$code]['path'])), + ); + } + + public static function maintenanceCounts() + { + return array( + 'revisions' => self::tableCount(ContentTables::table('document_rev')), + 'views' => self::tableCount(ContentTables::table('view_count')), + ); + } + + public static function clearMaintenance($target) + { + $tables = array( + 'revisions' => ContentTables::table('document_rev'), + 'views' => ContentTables::table('view_count'), + ); + $target = (string) $target; + if (!isset($tables[$target])) { + return false; + } + + $count = self::tableCount($tables[$target]); + DB::query('TRUNCATE TABLE ' . $tables[$target]); + return array('target' => $target, 'count' => $count); + } + + public static function systemFiles() + { + $out = array(); + foreach (self::systemFileDefinitions() as $code => $definition) { + $path = $definition['path']; + $content = is_file($path) ? (string) file_get_contents($path) : ''; + $out[$code] = array( + 'code' => $code, + 'label' => $definition['label'], + 'description' => $definition['description'], + 'path' => str_replace(BASEPATH . '/', '', $path), + 'mode' => $definition['mode'], + 'content' => $content, + 'bytes' => strlen($content), + 'writable' => (is_file($path) && is_writable($path)) || (!is_file($path) && is_writable(dirname($path))), + ); + } + + return $out; + } + + public static function systemFile($code) + { + $files = self::systemFiles(); + return isset($files[$code]) ? $files[$code] : null; + } + + public static function saveSystemFile($code, $content) + { + $definitions = self::systemFileDefinitions(); + $code = (string) $code; + if (!isset($definitions[$code])) { + throw new \RuntimeException('Неизвестный системный файл'); + } + + $content = str_replace(array("\r\n", "\r"), "\n", (string) $content); + if ($code === 'custom') { + if (!preg_match('/^\s*<\?php\b/', $content)) { + throw new \RuntimeException('Файл должен начинаться с getMessage()); + } + } + + $path = $definitions[$code]['path']; + $dir = dirname($path); + if (!is_dir($dir) || !is_writable($dir)) { + throw new \RuntimeException('Каталог системного файла недоступен для записи'); + } + + $tmp = tempnam($dir, '.adminx-file-'); + if ($tmp === false || file_put_contents($tmp, rtrim($content) . "\n", LOCK_EX) === false) { + if ($tmp) { @unlink($tmp); } + throw new \RuntimeException('Не удалось подготовить системный файл'); + } + + $mode = is_file($path) ? (fileperms($path) & 0777) : 0644; + @chmod($tmp, $mode); + if (!@rename($tmp, $path)) { + @unlink($tmp); + throw new \RuntimeException('Не удалось заменить системный файл'); + } + + return self::systemFile($code); + } + + protected static function systemFileDefinitions() + { + return array( + 'robots' => array('label' => 'robots.txt', 'description' => 'Правила индексации сайта поисковыми роботами.', 'path' => BASEPATH . '/robots.txt', 'mode' => 'text/plain'), + 'custom' => array('label' => 'custom.php', 'description' => 'Пользовательские функции фронта.', 'path' => BASEPATH . '/system/App/Functions/custom.php', 'mode' => 'application/x-httpd-php'), + ); + } + + protected static function tableCount($table) + { + try { + return (int) DB::query('SELECT COUNT(*) FROM ' . $table)->getValue(); + } catch (\Throwable $e) { + return 0; + } + } + + protected static function paginationInput(array $input) + { + $fields = array( + 'pagination_name', + 'pagination_box', + 'pagination_start_label', + 'pagination_end_label', + 'pagination_separator_box', + 'pagination_separator_label', + 'pagination_next_label', + 'pagination_prev_label', + 'pagination_link_box', + 'pagination_active_link_box', + 'pagination_link_template', + 'pagination_link_active_template', + ); + + $data = array(); + foreach ($fields as $field) { + $data[$field] = isset($input[$field]) ? trim((string) $input[$field]) : ''; + } + + if ($data['pagination_name'] === '') { + $data['pagination_name'] = 'Без названия'; + } + + return $data; + } + + protected static function dirSize($dir) + { + if (!is_dir($dir)) { + return 0; + } + + $size = 0; + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($it as $file) { + if ($file->isFile()) { + $size += (int) $file->getSize(); + } + } + + return $size; + } + + protected static function removeDirContents($dir) + { + if (!is_dir($dir)) { + return; + } + + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $file) { + if ($file->isDir()) { + @rmdir($file->getPathname()); + } else { + @unlink($file->getPathname()); + } + } + } + + protected static function formatBytes($bytes) + { + $bytes = (float) $bytes; + $units = array('B', 'KB', 'MB', 'GB'); + $i = 0; + while ($bytes >= 1024 && $i < count($units) - 1) { + $bytes = $bytes / 1024; + $i++; + } + + return ($i === 0 ? (string) (int) $bytes : number_format($bytes, 2, '.', '')) . ' ' . $units[$i]; + } + } diff --git a/adminx/modules/Settings/ProductionDiagnostics.php b/adminx/modules/Settings/ProductionDiagnostics.php new file mode 100644 index 0000000..7291a6c --- /dev/null +++ b/adminx/modules/Settings/ProductionDiagnostics.php @@ -0,0 +1,294 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Settings; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Common\ModuleManager; + use App\Common\SystemTables; + use App\Content\BasketTables; + use App\Content\CatalogTables; + use App\Content\ContactsTables; + use App\Content\ContentTables; + use App\Content\PublicShellTables; + use App\Content\PublicUserTables; + use App\Frontend\PublicSettings; + + /** Read-only production readiness checks. No mail, payment or data mutations. */ + class ProductionDiagnostics + { + public static function run() + { + $checks = array(); + self::checkRuntime($checks); + self::checkPaths($checks); + self::checkMail($checks); + self::checkContacts($checks); + self::checkBasket($checks); + self::checkPublicRuntime($checks); + self::checkNativeDataTables($checks); + self::checkStoredCodePrefixes($checks); + self::checkMedia($checks); + self::checkHosting($checks); + $summary = array('ok' => 0, 'warning' => 0, 'error' => 0); + foreach ($checks as $check) { + $state = isset($summary[$check['state']]) ? $check['state'] : 'warning'; + $summary[$state]++; + } + + return array('checks' => $checks, 'summary' => $summary, 'ready' => $summary['error'] === 0); + } + + protected static function checkRuntime(array &$checks) + { + self::add($checks, 'PHP', PHP_VERSION_ID >= 70300 && PHP_VERSION_ID < 80000 ? 'ok' : 'warning', PHP_VERSION); + foreach (array('mysqli', 'mbstring', 'json', 'gd', 'xmlwriter', 'curl', 'zip') as $extension) { + self::add($checks, 'Расширение ' . $extension, extension_loaded($extension) ? 'ok' : 'error', extension_loaded($extension) ? 'доступно' : 'не установлено'); + } + } + + protected static function checkPaths(array &$checks) + { + foreach (array('tmp/cache' => BASEPATH . '/tmp/cache', 'tmp/backup' => BASEPATH . '/tmp/backup', 'uploads' => BASEPATH . '/uploads') as $label => $path) { + $ok = is_dir($path) && is_writable($path); + self::add($checks, 'Запись ' . $label, $ok ? 'ok' : 'error', $ok ? 'доступна' : 'каталог отсутствует или недоступен'); + } + } + + protected static function checkMail(array &$checks) + { + $settings = PublicSettings::all(); + $type = isset($settings['mail_type']) ? strtolower(trim((string) $settings['mail_type'])) : ''; + self::add($checks, 'Почтовый транспорт', in_array($type, array('mail', 'smtp', 'sendmail'), true) ? 'ok' : 'error', $type !== '' ? $type : 'не задан'); + $from = isset($settings['mail_from']) ? trim((string) $settings['mail_from']) : ''; + self::add($checks, 'Email отправителя', filter_var($from, FILTER_VALIDATE_EMAIL) ? 'ok' : 'error', $from !== '' ? $from : 'не задан'); + if ($type === 'smtp') { + $host = isset($settings['mail_host']) ? trim((string) $settings['mail_host']) : ''; + $port = isset($settings['mail_port']) ? (int) $settings['mail_port'] : 0; + self::add($checks, 'SMTP endpoint', $host !== '' && $port > 0 ? 'ok' : 'error', $host . ($port ? ':' . $port : '')); + $login = isset($settings['mail_smtp_login']) ? trim((string) $settings['mail_smtp_login']) : ''; + self::add($checks, 'SMTP авторизация', $login !== '' ? 'ok' : 'warning', $login !== '' ? 'логин задан' : 'логин не задан'); + } + } + + protected static function checkContacts(array &$checks) + { + $module = ModuleManager::get('contacts'); + if (!$module || empty($module['enabled'])) { + return; + } + + $table = ContactsTables::table('module_contacts_forms'); + if (!self::tableExists($table)) { self::add($checks, 'Contacts', 'warning', 'таблица форм отсутствует'); return; } + $rows = DB::query('SELECT id, title, mail_set FROM `' . $table . '`')->getAll(); + $forms = 0; $recipients = 0; $invalid = 0; + foreach ($rows ?: array() as $row) { + $forms++; + $settings = self::decode((array) $row, 'mail_set'); + foreach ((array) (isset($settings['receivers']) ? $settings['receivers'] : array()) as $recipient) { + $email = is_array($recipient) && isset($recipient['email']) ? trim((string) $recipient['email']) : ''; + if (filter_var($email, FILTER_VALIDATE_EMAIL)) { $recipients++; } else { $invalid++; } + } + } + + self::add($checks, 'Contacts', $forms > 0 && $recipients > 0 && $invalid === 0 ? 'ok' : ($invalid ? 'error' : 'warning'), $forms . ' форм, ' . $recipients . ' получателей' . ($invalid ? ', некорректных: ' . $invalid : '')); + } + + protected static function checkBasket(array &$checks) + { + $module = ModuleManager::get('commerce'); + if (!$module || empty($module['enabled'])) { return; } + $table = BasketTables::table('module_basket_settings'); + if (!self::tableExists($table)) { self::add($checks, 'Уведомления заказов', 'error', 'настройки корзины отсутствуют'); return; } + $row = DB::query('SELECT receivers, from_email FROM `' . $table . '` ORDER BY id LIMIT 1')->getAssoc(); + $from = $row ? trim((string) $row['from_email']) : ''; + $valid = 0; $invalid = 0; + foreach (preg_split('/[;,\s]+/', $row ? (string) $row['receivers'] : '') ?: array() as $email) { + if ($email === '') { continue; } + if (filter_var($email, FILTER_VALIDATE_EMAIL)) { $valid++; } else { $invalid++; } + } + + self::add($checks, 'Уведомления заказов', $valid > 0 && !$invalid && filter_var($from, FILTER_VALIDATE_EMAIL) ? 'ok' : 'error', $valid . ' получателей, отправитель ' . ($from ?: 'не задан')); + } + + protected static function checkPublicRuntime(array &$checks) + { + $native = array(); + foreach (ModuleManager::all() as $module) { + $code = isset($module['code']) ? (string) $module['code'] : ''; + if ($code !== '' && !empty($module['enabled']) && is_file(BASEPATH . '/modules/' . $code . '/app/module.php')) { + $native[] = $code; + } + } + + sort($native, SORT_NATURAL | SORT_FLAG_CASE); + self::add($checks, 'Native-модули', $native ? 'ok' : 'warning', $native ? implode(', ', $native) : 'нет'); + self::add($checks, 'Публичный runtime', 'ok', 'native без fallback-переключателей'); + } + + protected static function checkNativeDataTables(array &$checks) + { + $tables = array(); + foreach (array( + 'documents', 'rubrics', 'rubric_fields', 'rubric_templates', 'templates', + 'request', 'request_conditions', 'navigation', 'navigation_items', + 'sysblocks', 'sysblocks_groups', 'view_count', 'document_relation_edges', + ) as $suffix) { + $tables[] = ContentTables::table($suffix); + } + + foreach (array('module_catalog_settings', 'module_catalog_items') as $suffix) { + $tables[] = CatalogTables::table($suffix); + } + + $contacts = ModuleManager::get('contacts'); + if ($contacts && !empty($contacts['enabled'])) { + foreach (array('module_contacts_fields', 'module_contacts_forms', 'module_contacts_history') as $suffix) { + $tables[] = ContactsTables::table($suffix); + } + } + + $commerce = ModuleManager::get('commerce'); + if ($commerce && !empty($commerce['enabled'])) { + foreach (array( + 'module_basket', 'module_basket_delivery', 'module_basket_history', + 'module_basket_payment', 'module_basket_settings', 'module_basket_status', + ) as $suffix) { + $tables[] = BasketTables::table($suffix); + } + } + + foreach (array('users', 'users_session', 'auth_tokens', 'auth_settings', 'user_profile_fields', 'user_profile_values', 'user_groups') as $suffix) { + $tables[] = PublicUserTables::table($suffix); + } + + foreach (array('settings', 'sessions') as $suffix) { + $tables[] = PublicShellTables::table($suffix); + } + + $tables[] = SystemTables::table('users'); + + $missing = array(); + foreach (array_unique($tables) as $table) { + if (!self::tableExists($table)) { + $missing[] = $table; + } + } + + self::add($checks, 'Target-таблицы native', $missing ? 'error' : 'ok', $missing ? 'отсутствуют: ' . implode(', ', $missing) : 'все критичные владельцы данных доступны'); + } + + protected static function checkHosting(array &$checks) + { + self::add($checks, 'Маршрутизация панели', is_file(ADMINX_PATH . '/.htaccess') ? 'ok' : 'error', is_file(ADMINX_PATH . '/.htaccess') ? '.htaccess присутствует' : '.htaccess отсутствует'); + $layout = @file_get_contents(ADMINX_PATH . '/view/main.twig'); + $cdn = $layout !== false && preg_match('#https?://#', $layout); + self::add($checks, 'UI-ассеты', $cdn ? 'warning' : 'ok', $cdn ? 'есть внешние CDN-зависимости' : 'локальные'); + } + + protected static function checkStoredCodePrefixes(array &$checks) + { + $prefix = ContentTables::prefix(); + + $targets = array( + 'templates' => array('template_text'), + 'rubrics' => array('rubric_template', 'rubric_header_template', 'rubric_footer_template', 'rubric_start_code', 'rubric_code_start', 'rubric_code_end', 'rubric_teaser_template'), + 'rubric_templates' => array('template'), + 'sysblocks' => array('sysblock_text'), + 'blocks' => array('block_text'), + 'request' => array('request_template_item', 'request_template_main'), + ); + $count = 0; + try { + foreach ($targets as $suffix => $columns) { + $table = $prefix . '_' . $suffix; + if (!self::tableExists($table)) { continue; } + foreach ($columns as $column) { + $count += (int) DB::query('SELECT COUNT(*) FROM `' . $table . '` WHERE `' . $column . '` LIKE %s', '%PREFIX%')->getValue(); + } + } + + self::add($checks, 'Префиксы DB-шаблонов', 'ok', $count . ' вхождений нормализуются при рендере шаблонов'); + } catch (\Throwable $e) { + self::add($checks, 'Префиксы DB-шаблонов', 'warning', 'аудит не выполнен: ' . $e->getMessage()); + } + } + + protected static function checkMedia(array &$checks) + { + $cache = BASEPATH . '/tmp/cache/adminx-media-integrity.json'; + $result = null; + if (is_file($cache) && filemtime($cache) > time() - 3600) { + $decoded = json_decode((string) file_get_contents($cache), true); + if (is_array($decoded)) { $result = $decoded; } + } + + if ($result === null) { + $paths = array(); + $prefix = ContentTables::prefix(); + $sources = array( + array($prefix . '_document_fields', 'field_value', 12000), + array($prefix . '_document_fields_text', 'field_value', 5000), + array($prefix . '_blocks', 'block_text', 1000), + array($prefix . '_sysblocks', 'sysblock_text', 1000), + array($prefix . '_templates', 'template_text', 100), + array($prefix . '_request', 'request_template_item', 1000), + array($prefix . '_request', 'request_template_main', 1000), + ); + foreach ($sources as $source) { + if (!self::tableExists($source[0])) { continue; } + $rows = DB::query('SELECT `' . $source[1] . '` AS content FROM `' . $source[0] . '` WHERE `' . $source[1] . '` LIKE %s LIMIT ' . (int) $source[2], '%/uploads/%')->getAll(); + foreach ($rows ?: array() as $row) { + if (!preg_match_all('#/?uploads/[^\s\"\'<>|\)]+#u', (string) $row['content'], $matches)) { continue; } + foreach ($matches[0] as $path) { + $path = '/' . ltrim(rawurldecode(strtok((string) $path, '?#')), '/'); + $paths[$path] = true; + } + } + } + + $missing = 0; $recoverable = 0; + foreach (array_keys($paths) as $path) { + if (is_file(BASEPATH . $path)) { continue; } + $original = preg_replace('#/th/([^/]+)-(?:r|c|f|t|s)\d+x\d+r?(\.[A-Za-z0-9]+)$#', '/$1$2', $path); + if ($original !== $path && is_file(BASEPATH . $original)) { $recoverable++; continue; } + $missing++; + } + + $result = array('references' => count($paths), 'missing' => $missing, 'recoverable' => $recoverable); + @file_put_contents($cache, json_encode($result)); + } + + $production = defined('ENV_CMS') && strtolower((string) ENV_CMS) === 'production'; + $state = $result['missing'] > 0 && $production ? 'warning' : 'ok'; + $detail = $result['references'] . ' ссылок, отсутствуют исходники: ' . $result['missing'] . ', превью создадутся: ' . $result['recoverable']; + if (!$production && $result['missing'] > 0) { $detail .= ' · локальная медиатека помечена как неполная'; } + self::add($checks, 'Медиа-ссылки', $state, $detail); + } + + protected static function decode(array $row, $key) + { + $value = isset($row[$key]) ? $row[$key] : ''; + if (is_array($value)) { return $value; } + $json = json_decode((string) $value, true); + if (is_array($json)) { return $json; } + $legacy = @unserialize((string) $value, array('allowed_classes' => false)); + return is_array($legacy) ? $legacy : array(); + } + + protected static function tableExists($table) { return (bool) DB::query('SHOW TABLES LIKE %s', $table)->getValue(); } + protected static function add(array &$checks, $label, $state, $detail) { $checks[] = array('label' => $label, 'state' => $state, 'detail' => trim((string) $detail)); } + } diff --git a/adminx/modules/Settings/Schema.php b/adminx/modules/Settings/Schema.php new file mode 100644 index 0000000..853e34c --- /dev/null +++ b/adminx/modules/Settings/Schema.php @@ -0,0 +1,471 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Settings; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Adminx\Support\AdminLocale; + use App\Common\SystemTables; + use App\Frontend\ThemeSettings; + + /** + * Описание системных настроек, переносимых из legacy source settings в + * key/value App\Common\Settings. Один источник для UI, validation и мигратора. + */ + class Schema + { + public static function groups() + { + $groups = array( + 'access' => array( + 'label' => 'Доступ к сайту', + 'icon' => 'ti ti-lock-access', + 'tile_bg' => 'var(--amber-100)', + 'tile_fg' => 'var(--amber-600)', + 'description' => 'Публичный доступ и временное закрытие сайта на время разработки.', + 'sort_order' => 5, + ), + 'site' => array( + 'label' => 'Сайт', + 'icon' => 'ti ti-world', + 'tile_bg' => 'var(--blue-100)', + 'tile_fg' => 'var(--blue-600)', + 'description' => 'Название, страна, дата и системные страницы.', + 'sort_order' => 10, + ), + 'mail' => array( + 'label' => 'Почта', + 'icon' => 'ti ti-mail', + 'tile_bg' => 'var(--green-100)', + 'tile_fg' => 'var(--green-600)', + 'description' => 'Отправитель, транспорт и шаблон письма.', + 'sort_order' => 20, + ), + 'pagination' => array( + 'label' => 'Пагинация', + 'icon' => 'ti ti-list-numbers', + 'tile_bg' => 'var(--violet-100)', + 'tile_fg' => 'var(--violet-600)', + 'description' => 'Шаблоны постраничной навигации по умолчанию.', + 'sort_order' => 30, + ), + 'breadcrumbs' => array( + 'label' => 'Хлебные крошки', + 'icon' => 'ti ti-route', + 'tile_bg' => 'var(--cyan-100)', + 'tile_fg' => 'var(--cyan-600)', + 'description' => 'Шаблоны цепочки навигации документа.', + 'sort_order' => 40, + ), + ); + + return $groups; + } + + public static function definitions() + { + $definitions = array( + 'site_access_mode' => array( + 'label' => 'Режим публичного доступа', + 'type' => 'select', + 'group' => 'access', + 'options' => array( + 'public' => 'Сайт открыт', + 'development' => 'Режим разработки', + ), + 'default' => 'public', + 'public_shell' => false, + 'sort_order' => 10, + ), + 'site_development_title' => array( + 'label' => 'Заголовок закрытой страницы', + 'type' => 'string', + 'group' => 'access', + 'default' => 'Сайт готовится к запуску', + 'max' => 160, + 'public_shell' => false, + 'depends' => array('site_access_mode' => 'development'), + 'sort_order' => 20, + ), + 'site_development_message' => array( + 'label' => 'Сообщение посетителям', + 'type' => 'text', + 'group' => 'access', + 'default' => 'Мы завершаем настройку сайта. Пожалуйста, зайдите немного позже.', + 'max' => 1000, + 'public_shell' => false, + 'depends' => array('site_access_mode' => 'development'), + 'sort_order' => 30, + ), + 'site_name' => array( + 'label' => 'Название сайта', + 'type' => 'string', + 'group' => 'site', + 'required' => true, + 'max' => 255, + 'sort_order' => 10, + ), + 'default_country' => array( + 'label' => 'Страна по умолчанию', + 'type' => 'select', + 'group' => 'site', + 'options' => self::countryOptions(), + 'default' => 'RU', + 'sort_order' => 20, + ), + 'date_format' => array( + 'label' => 'Формат даты', + 'type' => 'select', + 'group' => 'site', + 'options' => array( + '%d.%m.%Y' => '09.07.2026', + '%d %B %Y' => '09 июля 2026', + '%A, %d.%m.%Y' => 'Четверг, 09.07.2026', + '%A, %d %B %Y' => 'Четверг, 09 июля 2026', + ), + 'default' => '%d.%m.%Y', + 'sort_order' => 30, + ), + 'time_format' => array( + 'label' => 'Формат даты и времени', + 'type' => 'select', + 'group' => 'site', + 'options' => array( + '%d.%m.%Y, %H:%M' => '09.07.2026, 12:30', + '%d %B %Y, %H:%M' => '09 июля 2026, 12:30', + '%A, %d.%m.%Y (%H:%M)' => 'Четверг, 09.07.2026 (12:30)', + '%A, %d %B %Y (%H:%M)' => 'Четверг, 09 июля 2026 (12:30)', + ), + 'default' => '%d.%m.%Y, %H:%M', + 'sort_order' => 40, + ), + 'use_doctime' => array( + 'label' => 'Учитывать дату публикации документа', + 'type' => 'bool', + 'group' => 'site', + 'default' => true, + 'sort_order' => 50, + ), + 'page_not_found_id' => array( + 'label' => 'ID страницы 404', + 'type' => 'int', + 'group' => 'site', + 'default' => 2, + 'min' => 1, + 'sort_order' => 60, + ), + 'message_forbidden' => array( + 'label' => 'Текст запрета доступа', + 'type' => 'text', + 'group' => 'site', + 'sort_order' => 70, + ), + 'hidden_text' => array( + 'label' => 'Текст скрытого блока', + 'type' => 'text', + 'group' => 'site', + 'sort_order' => 80, + ), + + 'mail_from_name' => array( + 'label' => 'Имя отправителя', + 'type' => 'string', + 'group' => 'mail', + 'max' => 255, + 'sort_order' => 10, + ), + 'mail_from' => array( + 'label' => 'Email отправителя', + 'type' => 'email', + 'group' => 'mail', + 'max' => 255, + 'sort_order' => 20, + ), + 'mail_type' => array( + 'label' => 'Транспорт', + 'type' => 'select', + 'group' => 'mail', + 'options' => array( + 'mail' => 'PHP mail()', + 'smtp' => 'SMTP', + 'sendmail' => 'Sendmail', + ), + 'default' => 'mail', + 'sort_order' => 30, + ), + 'mail_host' => array( + 'label' => 'SMTP-сервер', + 'type' => 'string', + 'group' => 'mail', + 'max' => 255, + 'depends' => array('mail_type' => 'smtp'), + 'sort_order' => 40, + ), + 'mail_port' => array( + 'label' => 'SMTP-порт', + 'type' => 'int', + 'group' => 'mail', + 'default' => 25, + 'min' => 1, + 'max' => 65535, + 'depends' => array('mail_type' => 'smtp'), + 'sort_order' => 50, + ), + 'mail_smtp_encrypt' => array( + 'label' => 'SMTP-шифрование', + 'type' => 'select', + 'group' => 'mail', + 'options' => array('' => 'Без шифрования', 'ssl' => 'SSL', 'tls' => 'TLS'), + 'depends' => array('mail_type' => 'smtp'), + 'sort_order' => 60, + ), + 'mail_smtp_login' => array( + 'label' => 'SMTP-логин', + 'type' => 'string', + 'group' => 'mail', + 'max' => 255, + 'depends' => array('mail_type' => 'smtp'), + 'sort_order' => 70, + ), + 'mail_smtp_pass' => array( + 'label' => 'SMTP-пароль', + 'type' => 'password', + 'group' => 'mail', + 'max' => 255, + 'sensitive' => true, + 'depends' => array('mail_type' => 'smtp'), + 'sort_order' => 80, + ), + 'mail_sendmail_path' => array( + 'label' => 'Путь к sendmail', + 'type' => 'string', + 'group' => 'mail', + 'default' => '/usr/sbin/sendmail', + 'max' => 255, + 'depends' => array('mail_type' => 'sendmail'), + 'sort_order' => 90, + ), + 'mail_word_wrap' => array( + 'label' => 'Перенос строк', + 'type' => 'int', + 'group' => 'mail', + 'default' => 50, + 'min' => 0, + 'max' => 1000, + 'sort_order' => 100, + ), + 'mail_content_type' => array( + 'label' => 'Тип письма', + 'type' => 'select', + 'group' => 'mail', + 'options' => array('text/plain' => 'Text', 'text/html' => 'HTML'), + 'default' => 'text/plain', + 'sort_order' => 110, + ), + 'mail_new_user' => array( + 'label' => 'Письмо новому пользователю', + 'type' => 'text', + 'group' => 'mail', + 'default' => "Здравствуйте, %NAME%!\n\nВаш аккаунт на сайте %HOST% создан. Теперь вы можете войти, используя данные регистрации.", + 'sort_order' => 120, + ), + 'mail_signature' => array( + 'label' => 'Подпись письма', + 'type' => 'text', + 'group' => 'mail', + 'default' => "С уважением,\nкоманда сайта.", + 'sort_order' => 130, + ), + ) + self::navigationDefinitions(); + + return $definitions; + } + + public static function legacyKeys() + { + return array_keys(self::definitions()); + } + + public static function definition($key) + { + $defs = self::definitions(); + return isset($defs[$key]) ? $defs[$key] : null; + } + + public static function groupedFields() + { + $groups = self::groups(); + $defs = self::definitions(); + foreach ($groups as $code => $group) { + $groups[$code]['label'] = AdminLocale::translateMarkup($group['label']); + $groups[$code]['description'] = AdminLocale::translateMarkup($group['description']); + $groups[$code]['fields'] = array(); + } + + foreach ($defs as $key => $definition) { + $group = isset($definition['group']) ? $definition['group'] : 'site'; + if (!isset($groups[$group])) { + $groups[$group] = array( + 'label' => $group, + 'icon' => 'ti ti-settings', + 'description' => '', + 'sort_order' => 100, + 'fields' => array(), + ); + } + + $definition['key'] = $key; + $definition['value'] = Model::value($key, isset($definition['default']) ? $definition['default'] : ''); + $definition = self::localizeDefinition($definition); + if (isset($definition['type']) && $definition['type'] === 'section_order') { + $definition['order_items'] = ThemeSettings::sectionOrder( + $definition['value'], + isset($definition['options']) ? $definition['options'] : array() + ); + } + + $groups[$group]['fields'][] = $definition; + } + + uasort($groups, function ($a, $b) { + return (int) $a['sort_order'] < (int) $b['sort_order'] ? -1 : 1; + }); + + foreach ($groups as $code => $group) { + usort($groups[$code]['fields'], function ($a, $b) { + $sa = isset($a['sort_order']) ? (int) $a['sort_order'] : 100; + $sb = isset($b['sort_order']) ? (int) $b['sort_order'] : 100; + if ($sa === $sb) { + return strcmp($a['key'], $b['key']); + } + + return $sa < $sb ? -1 : 1; + }); + } + + return $groups; + } + + protected static function localizeDefinition(array $definition) + { + foreach (array('label', 'description', 'hint') as $key) { + if (isset($definition[$key]) && is_string($definition[$key])) { + $definition[$key] = AdminLocale::translateMarkup($definition[$key]); + } + } + + if (isset($definition['options']) && is_array($definition['options'])) { + foreach ($definition['options'] as $value => $label) { + if (is_string($label)) { + $definition['options'][$value] = AdminLocale::translateMarkup($label); + } + } + } + + return $definition; + } + + protected static function countryOptions() + { + $fallback = array('RU' => 'Россия'); + try { + $table = SystemTables::table('countries'); + $exists = \DB::query('SHOW TABLES LIKE %s', $table)->getValue(); + if (!$exists) { + return $fallback; + } + + $rows = \DB::query( + 'SELECT country_code, country_name FROM ' . $table . " WHERE country_status = '1' ORDER BY country_name ASC" + )->getAll(); + $out = array(); + foreach ((array) $rows as $row) { + $out[(string) $row['country_code']] = (string) $row['country_name']; + } + + return !empty($out) ? $out : $fallback; + } catch (\Throwable $e) { + return $fallback; + } + } + + protected static function navigationDefinitions() + { + $pagination = array( + 'navi_box' => array('Контейнер пагинации', '
      %s
    '), + 'start_label' => array('Ссылка «Первая»', 'Первая'), + 'end_label' => array('Ссылка «Последняя»', 'Последняя'), + 'separator_label' => array('Метка пропуска страниц', '…'), + 'next_label' => array('Ссылка «Следующая»', '»'), + 'prev_label' => array('Ссылка «Предыдущая»', '«'), + 'total_label' => array('Метка общего количества', 'Страница %d из %d'), + 'link_box' => array('Контейнер ссылки', '
  • %s
  • '), + 'total_box' => array('Контейнер итога', '%s'), + 'active_box' => array('Контейнер активной страницы', '
  • %s
  • '), + 'separator_box' => array('Контейнер разделителя', '
  • %s
  • '), + ); + + $breadcrumbs = array( + 'bread_box' => array('Контейнер хлебных крошек', ''), + 'bread_separator' => array('Разделитель хлебных крошек', ''), + 'bread_link_box' => array('Контейнер ссылки крошки', '
  • %s
  • '), + 'bread_link_template' => array('Шаблон ссылки крошки', '[name]'), + 'bread_self_box' => array('Контейнер текущей страницы', '
  • %s
  • '), + ); + + $defs = array(); + $sort = 10; + foreach ($pagination as $key => $definition) { + $defs[$key] = array( + 'label' => $definition[0], + 'type' => 'string', + 'group' => 'pagination', + 'default' => $definition[1], + 'sort_order' => $sort, + ); + $sort += 10; + } + + $sort = 10; + foreach ($breadcrumbs as $key => $definition) { + $defs[$key] = array( + 'label' => $definition[0], + 'type' => 'string', + 'group' => 'breadcrumbs', + 'default' => $definition[1], + 'sort_order' => $sort, + ); + $sort += 10; + } + + foreach (array( + 'bread_show_main' => 'Показывать главную', + 'bread_show_host' => 'Показывать хост', + 'bread_separator_use' => 'Использовать разделитель', + 'bread_link_box_last' => 'Последняя крошка как ссылка', + ) as $key => $label) { + $defs[$key] = array( + 'label' => $label, + 'type' => 'bool', + 'group' => 'breadcrumbs', + 'default' => true, + 'sort_order' => $sort, + ); + $sort += 10; + } + + return $defs; + } + } diff --git a/adminx/modules/Settings/assets/settings.css b/adminx/modules/Settings/assets/settings.css new file mode 100644 index 0000000..36c121a --- /dev/null +++ b/adminx/modules/Settings/assets/settings.css @@ -0,0 +1,591 @@ +.settings-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px; + margin-bottom: 16px; +} +.settings-status { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; +} +.settings-status-title { + font-weight: 700; + font-size: 14px; + margin-bottom: 3px; +} +.settings-tabs { + margin-bottom: 14px; +} +.settings-card { + padding: 0; +} +.settings-layout-card { + overflow: hidden; +} +.settings-layout-list { + display: grid; +} +.settings-layout-row { + display: grid; + grid-template-columns: 40px 38px minmax(0, 1fr) auto 44px; + align-items: center; + gap: 12px; + min-height: 66px; + padding: 10px 16px 10px 10px; + border-bottom: 1px solid var(--border-default); + background: var(--surface-card); + transition-property: background-color, opacity, transform; + transition-duration: 0.14s; +} +.settings-layout-row:last-child { + border-bottom: 0; +} +.settings-layout-row.is-disabled { + background: var(--surface-muted); + opacity: 0.66; +} +.settings-layout-row.is-dragging { + opacity: 0.45; + transform: scale(0.99); +} +.settings-layout-handle { + cursor: grab; +} +.settings-layout-handle:active { + cursor: grabbing; +} +.settings-layout-copy { + min-width: 0; +} +.settings-layout-copy b { + display: block; + font-size: 13.5px; +} +.settings-layout-copy small { + display: block; + margin-top: 3px; + overflow: hidden; + color: var(--text-secondary); + font-size: 11.5px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} +.settings-layout-move { + display: inline-flex; + gap: 2px; +} +@media (max-width: 680px) { + .settings-layout-row { + grid-template-columns: 36px 34px minmax(0, 1fr) 42px; + gap: 8px; + padding-right: 10px; + } + .settings-layout-move { + display: none; + } + .settings-layout-copy small { + white-space: normal; + } +} +.settings-panel-header { + margin-bottom: 18px; +} +.settings-panel-header h2 { + font-size: 22px; + line-height: 1.2; + font-weight: 800; + letter-spacing: 0; +} +.settings-panel-header .section-desc { + font-size: 13.5px; + line-height: 1.45; + text-wrap: pretty; +} +.settings-stack { + display: grid; + gap: 18px; +} +.settings-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid var(--border-default); +} +.settings-section-title { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} +.settings-head-icon { + width: 34px; + height: 34px; + border-radius: var(--radius-md); + font-size: 17px; +} +.settings-section-head h2 { + font-size: 16px; + line-height: 1.25; + font-weight: 700; + margin: 0 0 4px; + text-wrap: balance; +} +.settings-section-head p { + margin: 0; + font-size: 13px; + line-height: 1.4; + font-weight: 400; + text-wrap: pretty; +} +.settings-section-meta { + flex: 0 0 auto; + padding-top: 3px; +} +.settings-section-meta .badge, +.settings-section-head .badge { + font-size: 11px; + font-weight: 600; +} +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px 18px; + padding: 20px; +} +.settings-field-wide { + grid-column: 1 / -1; +} +.settings-grid-section { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 10px; + margin: 4px 0 -2px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} +.settings-grid-section::after { + content: ""; + height: 1px; + flex: 1; + background: var(--border-default); +} +.settings-section-order { + display: grid; + overflow: hidden; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--surface-muted); +} +.settings-section-order-row { + display: grid; + grid-template-columns: 38px 36px minmax(0, 1fr) auto 44px; + align-items: center; + gap: 10px; + min-height: 62px; + padding: 8px 12px 8px 8px; + border-bottom: 1px solid var(--border-default); + background: var(--surface-card); + transition: background-color 0.14s, opacity 0.14s, transform 0.14s; +} +.settings-section-order-row:last-child { + border-bottom: 0; +} +.settings-section-order-row.is-disabled { + opacity: 0.58; + background: var(--surface-muted); +} +.settings-section-order-row.is-dragging { + opacity: 0.42; + transform: scale(0.995); +} +.settings-section-order-row > span:nth-of-type(2) { + min-width: 0; +} +.settings-section-order-row b, +.settings-section-order-row small { + display: block; +} +.settings-section-order-row b { + font-size: 13.5px; +} +.settings-section-order-row small { + margin-top: 2px; + color: var(--text-secondary); + font: 11.5px/1.35 var(--font-mono); +} +.settings-section-order-row .icon-tile { + --tile-bg: var(--blue-100); + --tile-fg: var(--blue-600); + width: 34px; + height: 34px; +} +.settings-section-order-handle { + cursor: grab; +} +.settings-section-order-handle:active { + cursor: grabbing; +} +@media (max-width: 680px) { + .settings-section-order-row { + grid-template-columns: 34px 34px minmax(0, 1fr) 42px; + gap: 8px; + padding-right: 8px; + } + .settings-section-order-row .settings-layout-move { + display: none; + } +} +.settings-switch-line { + min-height: 68px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 11px 14px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + cursor: pointer; +} +.settings-switch-line > span:first-of-type { + display: flex; + flex-direction: column; + gap: 3px; +} +.settings-switch-line small { + color: var(--text-secondary); + font-size: 12px; + font-weight: 400; +} +.settings-field-bool > .field-label { + margin-bottom: 6px; +} +.settings-security-note { + margin-bottom: 18px; +} +.settings-security-note p { + margin: 3px 0 0; + line-height: 1.45; +} +.settings-security-control { + padding: 20px; +} +.settings-security-control .settings-switch-line { + min-height: 78px; +} +.settings-card > .card-actions { + margin: 0; +} +.settings-savebar { + position: sticky; + bottom: 16px; + z-index: 50; + margin: 18px auto 0; + max-width: 620px; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius-lg); + background: var(--background-surface); + box-shadow: var(--shadow-xl); + border: 1px solid var(--border-default); +} +.settings-savebar[hidden] { + display: none; +} +.settings-savebar .btn-primary { + margin-left: auto; +} +.settings-subsection { + margin-top: 28px; +} +.settings-subheader { + margin-bottom: 12px; +} +.settings-subheader h2 { + font-size: 22px; + margin: 0 0 4px; +} +.constants-table { + table-layout: fixed; + min-width: 1040px; +} +.constants-table th, +.constants-table td { + vertical-align: middle; + font-size: 13px; + line-height: 1.35; +} +.constants-table th { + font-size: 11.5px; + font-weight: 700; +} +.constants-table .badge { + font-size: 11px; + font-weight: 600; +} +.constants-col-name { + width: 230px; +} +.constants-col-status { + width: 138px; +} +.constants-col-value { + width: 260px; +} +.constants-col-type { + width: 138px; +} +.constants-col-actions { + width: 96px; +} +.constant-name { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; + max-width: 100%; + white-space: nowrap; +} +.constant-value { + overflow-wrap: anywhere; + word-break: break-word; +} +.constant-name b { + font-weight: 600; +} +.constant-editor-identity { + margin-bottom: 16px; +} +.constant-editor-tabs { + margin: 0 0 20px; +} +.constant-editor-panel { + min-height: 220px; +} +.constant-value-editor { + max-width: 620px; +} +.constant-thumbnail-tools { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: start; + gap: 12px; + margin-top: 16px; + padding: 14px; + border-radius: var(--radius-md); + background: var(--surface-muted); +} +.constant-thumbnail-tools[hidden] { + display: none; +} +.constant-thumbnail-tools .icon-tile { + width: 36px; + height: 36px; +} +.constant-thumbnail-tools-copy b { + display: block; + margin: 1px 0 4px; + font-size: 13.5px; +} +.constant-thumbnail-tools-copy p { + margin: 0; + color: var(--text-secondary); + font-size: 12.5px; + line-height: 1.45; + text-wrap: pretty; +} +.constant-thumbnail-result { + margin-top: 8px; + color: var(--blue-700); + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.constant-bool-control { + margin-top: 8px; +} +.constant-bool-control button { + min-width: 92px; + min-height: 40px; + justify-content: center; +} +.constant-value-hint { + margin: 12px 0 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} +.constant-value-empty { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + color: var(--amber-700); + font-size: 12px; +} +.constant-value-empty[hidden] { + display: none; +} +[data-constant-options-field][hidden] { + display: none; +} +.settings-country-scroll { + max-height: 520px; +} +.settings-country-table { + min-width: 760px; +} +.input-sm { + height: 30px; + padding-top: 5px; + padding-bottom: 5px; +} +.settings-pagination-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} +.settings-maintenance-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + padding: 16px; +} +.settings-maintenance-actions > div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 5px 12px; + padding: 14px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); +} +.settings-maintenance-actions > div span { + grid-column: 1; + font-size: 12px; +} +.settings-maintenance-actions > div .btn { + grid-column: 2; + grid-row: 1 / span 2; +} +.settings-production-summary { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} +.settings-production-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid var(--border-default); +} +.settings-production-check { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 3px 8px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-default); +} +.settings-production-check:nth-child(odd) { + border-right: 1px solid var(--border-default); +} +.settings-production-check i { + grid-row: 1 / span 2; + margin-top: 2px; +} +.settings-production-check i.is-ok { + color: var(--green-600); +} +.settings-production-check i.is-warning { + color: var(--amber-600); +} +.settings-production-check i.is-error { + color: var(--red-600); +} +.settings-production-check b { + font-size: 13px; +} +.settings-production-check span { + color: var(--text-secondary); + font-size: 12px; + overflow-wrap: anywhere; +} +.settings-file-meta { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-top: 1px solid var(--border-default); + color: var(--text-secondary); + font-size: 12px; +} +.settings-file-drawer { + width: min(980px, 66.666vw); +} +.settings-file-drawer > form { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; +} +.settings-file-editor { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + padding: 0; +} +.settings-file-editor > textarea { + height: 100%; + border: 0; + border-radius: 0; +} +.settings-file-editor .CodeMirror { + height: 100%; + border: 0; + border-radius: 0; +} +.settings-file-drawer .drawer-footer { + flex: 0 0 auto; +} +@media (max-width: 760px) { + .settings-grid { + grid-template-columns: 1fr; + } + .settings-pagination-grid { + grid-template-columns: 1fr; + } + .settings-maintenance-actions { + grid-template-columns: 1fr; + } + .settings-production-list { + grid-template-columns: 1fr; + } + .settings-production-check:nth-child(odd) { + border-right: 0; + } + .settings-file-drawer { + width: 100%; + } + .settings-savebar { + left: 12px; + right: 12px; + bottom: 12px; + } +} +@media (max-width: 680px) { + .constant-thumbnail-tools { + grid-template-columns: 36px minmax(0, 1fr); + } + .constant-thumbnail-tools .btn { + grid-column: 1 / -1; + width: 100%; + } +} diff --git a/adminx/modules/Settings/assets/settings.js b/adminx/modules/Settings/assets/settings.js new file mode 100644 index 0000000..c6b48d8 --- /dev/null +++ b/adminx/modules/Settings/assets/settings.js @@ -0,0 +1,698 @@ +/** + * JS раздела «Настройки»: tabs, conditional fields, ajax-save и запуск + * и операций обслуживания нативной схемы. + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.Settings = { + root: null, + form: null, + dirty: false, + layoutDrag: null, + sectionDrag: null, + + init: function () { + this.root = document.querySelector('[data-settings-page]'); + this.form = document.getElementById('settingsForm'); + if (!this.root) { return; } + + var self = this; + document.addEventListener('click', function (e) { + var tab = e.target.closest('[data-settings-tab]'); + if (tab) { self.openTab(tab.getAttribute('data-settings-tab')); } + + if (e.target.closest('[data-settings-reset]')) { window.location.reload(); } + if (e.target.closest('[data-pagination-new]')) { self.fillPaginationNew(); } + var paginationEdit = e.target.closest('[data-pagination-edit]'); + if (paginationEdit) { self.fillPaginationEdit(paginationEdit.closest('[data-pagination-row]')); } + var paginationDelete = e.target.closest('[data-pagination-delete]'); + if (paginationDelete) { self.deletePagination(paginationDelete.closest('[data-pagination-row]')); } + var cacheClear = e.target.closest('[data-cache-clear]'); + if (cacheClear) { self.clearCache(cacheClear.getAttribute('data-cache-clear')); } + var maintenanceClear = e.target.closest('[data-maintenance-clear]'); + if (maintenanceClear) { self.clearMaintenance(maintenanceClear.getAttribute('data-maintenance-clear')); } + var systemFile = e.target.closest('[data-system-file-edit]'); + if (systemFile) { self.openSystemFile(systemFile.getAttribute('data-system-file-edit')); } + if (e.target.closest('[data-constant-new]')) { self.fillConstantNew(); } + var constantEditorTab = e.target.closest('[data-constant-editor-tab]'); + if (constantEditorTab) { self.openConstantEditorTab(constantEditorTab.getAttribute('data-constant-editor-tab')); } + var constantBool = e.target.closest('[data-constant-bool]'); + if (constantBool) { self.setConstantBool(constantBool.getAttribute('data-constant-bool')); } + var constantEdit = e.target.closest('[data-constant-edit]'); + if (constantEdit) { self.fillConstantEdit(constantEdit.closest('[data-constant-row]')); } + var constantDelete = e.target.closest('[data-constant-delete]'); + if (constantDelete) { self.deleteConstant(constantDelete.closest('[data-constant-row]')); } + if (e.target.closest('[data-thumbnail-preset-scan]')) { self.scanThumbnailPresets(); } + var layoutMove = e.target.closest('[data-layout-move]'); + if (layoutMove) { self.moveLayout(layoutMove); } + var sectionMove = e.target.closest('[data-section-order-move]'); + if (sectionMove) { self.moveSection(sectionMove); } + }); + + if (this.form) { + this.form.addEventListener('input', function () { self.markDirty(); self.applyDependencies(); }); + this.form.addEventListener('change', function (e) { + self.markDirty(); self.applyDependencies(); self.updateSwitchLabels(); + if (e.target.matches('[data-section-order-visible]')) { + e.target.closest('[data-section-order-item]').classList.toggle('is-disabled', !e.target.checked); + self.syncSectionOrder(e.target.closest('[data-section-order]')); + } + }); + this.form.addEventListener('submit', function (e) { e.preventDefault(); self.save(); }); + this.form.addEventListener('dragstart', function (e) { self.sectionDragStart(e); }); + this.form.addEventListener('dragover', function (e) { self.sectionDragOver(e); }); + this.form.addEventListener('drop', function (e) { if (self.sectionDrag) { e.preventDefault(); } }); + this.form.addEventListener('dragend', function () { self.sectionDragEnd(); }); + } + + var paginationForm = document.getElementById('paginationForm'); + if (paginationForm) { + paginationForm.addEventListener('submit', function (e) { + e.preventDefault(); + self.savePagination(paginationForm); + }); + } + + var constantForm = document.getElementById('constantForm'); + if (constantForm) { + constantForm.addEventListener('submit', function (e) { + e.preventDefault(); + self.syncConstantValue(); + self.saveConstant(constantForm); + }); + constantForm.addEventListener('input', function (e) { + if (e.target.matches('[data-constant-input]')) { self.syncConstantValue(e.target); } + if (e.target.matches('[data-constant-options]')) { self.updateConstantEditor(); } + }); + constantForm.addEventListener('change', function (e) { + if (e.target.matches('[data-constant-type]')) { self.updateConstantEditor(); } + if (e.target.matches('[data-constant-input]')) { self.syncConstantValue(e.target); } + }); + } + + var systemFileForm = document.querySelector('[data-system-file-form]'); + if (systemFileForm) { + systemFileForm.addEventListener('submit', function (e) { + e.preventDefault(); + self.saveSystemFile(systemFileForm); + }); + } + + var interfaceForm = document.querySelector('[data-interface-layout-form]'); + if (interfaceForm) { + interfaceForm.addEventListener('submit', function (e) { e.preventDefault(); self.saveInterface(interfaceForm); }); + interfaceForm.addEventListener('change', function (e) { + if (e.target.matches('[data-layout-visible]')) { + e.target.closest('[data-layout-item]').classList.toggle('is-disabled', !e.target.checked); + } + }); + interfaceForm.addEventListener('dragstart', function (e) { self.layoutDragStart(e); }); + interfaceForm.addEventListener('dragover', function (e) { self.layoutDragOver(e); }); + interfaceForm.addEventListener('drop', function (e) { if (self.layoutDrag) { e.preventDefault(); } }); + interfaceForm.addEventListener('dragend', function () { self.layoutDragEnd(); }); + } + + if (this.form) { this.applyDependencies(); this.updateSwitchLabels(); } + }, + + base: function () { + return (this.root ? this.root.getAttribute('data-base') : '') || Adminx.base(); + }, + + layoutDragStart: function (event) { + var handle = event.target.closest('[data-layout-handle]'); + var row = handle ? handle.closest('[data-layout-item]') : null; + if (!row || handle.getAttribute('draggable') !== 'true') { event.preventDefault(); return; } + this.layoutDrag = row; + row.classList.add('is-dragging'); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', row.getAttribute('data-code') || ''); + } + }, + + layoutDragOver: function (event) { + if (!this.layoutDrag) { return; } + var target = event.target.closest('[data-layout-item]'); + var list = event.target.closest('[data-layout-list]'); + if (!target || !list || target === this.layoutDrag || this.layoutDrag.parentNode !== list) { return; } + event.preventDefault(); + var rect = target.getBoundingClientRect(); + list.insertBefore(this.layoutDrag, event.clientY < rect.top + rect.height / 2 ? target : target.nextSibling); + }, + + layoutDragEnd: function () { + if (this.layoutDrag) { this.layoutDrag.classList.remove('is-dragging'); } + this.layoutDrag = null; + }, + + moveLayout: function (button) { + var row = button.closest('[data-layout-item]'); + if (!row || button.disabled) { return; } + if (button.getAttribute('data-layout-move') === 'up' && row.previousElementSibling) { + row.parentNode.insertBefore(row, row.previousElementSibling); + } else if (button.getAttribute('data-layout-move') === 'down' && row.nextElementSibling) { + row.parentNode.insertBefore(row.nextElementSibling, row); + } + }, + + sectionDragStart: function (event) { + var handle = event.target.closest('[data-section-order-handle]'); + var row = handle ? handle.closest('[data-section-order-item]') : null; + if (!row || row.getAttribute('draggable') !== 'true') { event.preventDefault(); return; } + this.sectionDrag = row; + row.classList.add('is-dragging'); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', row.getAttribute('data-code') || ''); + } + }, + + sectionDragOver: function (event) { + if (!this.sectionDrag) { return; } + var target = event.target.closest('[data-section-order-item]'); + var list = event.target.closest('[data-section-order]'); + if (!target || !list || target === this.sectionDrag || this.sectionDrag.parentNode !== list) { return; } + event.preventDefault(); + var rect = target.getBoundingClientRect(); + list.insertBefore(this.sectionDrag, event.clientY < rect.top + rect.height / 2 ? target : target.nextSibling); + this.syncSectionOrder(list); + }, + + sectionDragEnd: function () { + if (this.sectionDrag) { + var list = this.sectionDrag.closest('[data-section-order]'); + this.sectionDrag.classList.remove('is-dragging'); + this.syncSectionOrder(list); + } + this.sectionDrag = null; + }, + + moveSection: function (button) { + var row = button.closest('[data-section-order-item]'); + if (!row || button.disabled) { return; } + if (button.getAttribute('data-section-order-move') === 'up' && row.previousElementSibling) { + row.parentNode.insertBefore(row, row.previousElementSibling); + } else if (button.getAttribute('data-section-order-move') === 'down' && row.nextElementSibling) { + row.parentNode.insertBefore(row.nextElementSibling, row); + } + this.syncSectionOrder(row.parentNode); + this.markDirty(); + }, + + syncSectionOrder: function (list) { + if (!list) { return; } + var input = list.parentNode.querySelector('[data-section-order-value]'); + if (!input) { return; } + input.value = JSON.stringify(Array.prototype.map.call(list.querySelectorAll('[data-section-order-item]'), function (row) { + var toggle = row.querySelector('[data-section-order-visible]'); + return { code: row.getAttribute('data-code') || '', visible: !!(toggle && toggle.checked) }; + })); + }, + + layoutValues: function (scope) { + var list = document.querySelector('[data-layout-list="' + scope + '"]'); + if (!list) { return []; } + return Array.prototype.map.call(list.querySelectorAll('[data-layout-item]'), function (row) { + var toggle = row.querySelector('[data-layout-visible]'); + return { code: row.getAttribute('data-code') || '', visible: !!(toggle && toggle.checked) }; + }); + }, + + saveInterface: function (form) { + var data = new FormData(form); + data.set('navigation', JSON.stringify(this.layoutValues('navigation'))); + data.set('dashboard', JSON.stringify(this.layoutValues('dashboard'))); + Adminx.Loader.show(); + Adminx.Ajax.post(this.base() + '/settings/interface', data).then(function (payload) { + Adminx.Loader.hide(); + var result = payload.data || {}; + if (!result.success) { + Adminx.Toast.show(result.message || 'Не удалось сохранить интерфейс', 'error'); + return; + } + Adminx.Toast.show(result.message || 'Интерфейс обновлен', 'success'); + window.setTimeout(function () { window.location.reload(); }, 250); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + openTab: function (code) { + document.querySelectorAll('[data-settings-tab]').forEach(function (tab) { + tab.setAttribute('aria-selected', tab.getAttribute('data-settings-tab') === code ? 'true' : 'false'); + }); + document.querySelectorAll('[data-settings-panel]').forEach(function (panel) { + panel.classList.toggle('active', panel.getAttribute('data-settings-panel') === code); + }); + }, + + openInitialTab: function () { + var hash = (window.location.hash || '').replace(/^#/, ''); + if (hash && document.querySelector('[data-settings-tab="' + hash + '"]')) { + this.openTab(hash); + } + }, + + markDirty: function () { + this.dirty = true; + var bar = document.getElementById('settingsSavebar'); + if (bar) { bar.hidden = false; } + }, + + clearErrors: function () { + this.form.querySelectorAll('[data-error]').forEach(function (s) { s.textContent = ''; }); + this.form.querySelectorAll('.input, .select, .textarea').forEach(function (el) { el.classList.remove('is-invalid'); }); + }, + + save: function () { + var self = this; + this.clearErrors(); + Adminx.Loader.show(); + Adminx.Ajax.post(this.base() + '/settings', new FormData(this.form)).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { + self.dirty = false; + var bar = document.getElementById('settingsSavebar'); + if (bar) { bar.hidden = true; } + Adminx.Toast.show(d.message || 'Настройки сохранены', 'success'); + return; + } + var errors = d.errors || {}; + Object.keys(errors).forEach(function (field) { + var span = self.form.querySelector('[data-error="' + field + '"]'); + if (span) { span.textContent = errors[field]; } + var input = self.form.querySelector('[name="' + field + '"]'); + if (input) { input.classList.add('is-invalid'); } + }); + Adminx.Toast.show(d.message || 'Не удалось сохранить', 'error'); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + paginationForm: function () { + return document.getElementById('paginationForm'); + }, + + fillPaginationNew: function () { + var form = this.paginationForm(); + if (!form) { return; } + form.reset(); + form.querySelector('[name="id"]').value = ''; + document.getElementById('paginationDrawerTitle').textContent = 'Новый шаблон пагинации'; + }, + + fillPaginationEdit: function (row) { + var form = this.paginationForm(); + if (!form || !row) { return; } + var id = row.getAttribute('data-id'); + this.fillPaginationNew(); + document.getElementById('paginationDrawerTitle').textContent = 'Изменение: ' + row.getAttribute('data-name'); + Adminx.Loader.show(); + Adminx.Ajax.request(this.base() + '/settings/paginations/' + id).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (!d.success) { + Adminx.Toast.show(d.message || 'Шаблон не найден', 'error'); + return; + } + var data = d.data || {}; + Object.keys(data).forEach(function (key) { + var el = form.querySelector('[name="' + key + '"]'); + if (el) { el.value = data[key] == null ? '' : data[key]; } + }); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + savePagination: function (form) { + var id = (form.querySelector('[name="id"]').value || '').trim(); + var url = this.base() + '/settings/paginations' + (id ? '/' + id : ''); + Adminx.Loader.show(); + Adminx.Ajax.post(url, new FormData(form)).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { + Adminx.Ajax.handle(payload); + if (!d.redirect) { window.location.reload(); } + } else { + Adminx.Toast.show(d.message || 'Не удалось сохранить шаблон', 'error'); + } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + deletePagination: function (row) { + if (!row) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'error', + title: 'Удалить шаблон пагинации?', + message: '«' + row.getAttribute('data-name') + '» будет удалён.', + confirmLabel: 'Удалить', + confirmClass: 'btn-danger', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(self.base() + '/settings/paginations/' + row.getAttribute('data-id') + '/delete').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { + row.remove(); + Adminx.Toast.show(d.message || 'Шаблон удалён', 'success'); + } else { + Adminx.Toast.show(d.message || 'Не удалось удалить', 'error'); + } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + }, + + clearCache: function (source) { + var self = this; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Очистить кеш?', + message: 'Будет очищен источник «' + source + '».', + confirmLabel: 'Очистить', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(self.base() + '/settings/cache/' + encodeURIComponent(source) + '/clear').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { + var row = document.querySelector('[data-cache-row="' + source + '"]'); + if (row && d.data && d.data.size) { + var cell = row.querySelector('[data-cache-size]'); + if (cell) { cell.textContent = d.data.size; } + } + Adminx.Toast.show(d.message || 'Кеш очищен', 'success'); + } else { + Adminx.Toast.show(d.message || 'Не удалось очистить кеш', 'error'); + } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + }, + + clearMaintenance: function (target) { + var self = this; + var label = target === 'revisions' ? 'все ревизии документов' : 'всю подневную статистику просмотров'; + Adminx.Confirm.open({ + kind: 'danger', + title: 'Очистить служебные данные?', + message: 'Будут удалены ' + label + '. Действие необратимо.', + confirmLabel: 'Очистить', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(self.base() + '/settings/maintenance/' + encodeURIComponent(target) + '/clear').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + Adminx.Toast.show(d.message || (d.success ? 'Данные очищены' : 'Не удалось очистить данные'), d.success ? 'success' : 'error'); + if (d.success) { window.location.reload(); } + }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); }); + } + }); + }, + + openSystemFile: function (code) { + var form = document.querySelector('[data-system-file-form]'); + if (!form) { return; } + Adminx.Loader.show(); + Adminx.Ajax.request(this.base() + '/settings/files/' + encodeURIComponent(code)).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (!d.success) { Adminx.Toast.show(d.message || 'Файл не найден', 'error'); return; } + var file = d.data || {}; + form.elements.code.value = file.code || code; + var textarea = form.elements.content; + textarea.value = file.content || ''; + textarea.setAttribute('data-code-mode', file.mode || 'text/plain'); + if (textarea._adminxCodeMirror) { + textarea._adminxCodeMirror.setOption('mode', file.mode || 'text/plain'); + textarea._adminxCodeMirror.setValue(file.content || ''); + textarea._adminxCodeMirror.setSize('100%', '100%'); + setTimeout(function () { textarea._adminxCodeMirror.refresh(); }, 80); + } + var title = document.getElementById('systemFileDrawerTitle'); + var path = document.querySelector('[data-system-file-path]'); + if (title) { title.textContent = file.label || 'Системный файл'; } + if (path) { path.textContent = file.path || ''; } + }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); }); + }, + + saveSystemFile: function (form) { + var code = form.elements.code.value || ''; + var textarea = form.elements.content; + if (textarea._adminxCodeMirror) { textarea._adminxCodeMirror.save(); } + Adminx.Loader.show(); + Adminx.Ajax.post(this.base() + '/settings/files/' + encodeURIComponent(code), new FormData(form)).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + Adminx.Toast.show(d.message || (d.success ? 'Файл сохранён' : 'Не удалось сохранить файл'), d.success ? 'success' : 'error'); + }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); }); + }, + + constantForm: function () { + return document.getElementById('constantForm'); + }, + + fillConstantNew: function () { + var form = this.constantForm(); + if (!form) { return; } + form.reset(); + form.querySelector('[name="name"]').readOnly = false; + form.querySelector('[name="is_system"]').value = '0'; + form.querySelector('[name="sort_order"]').value = '100'; + form.querySelector('[data-constant-value]').value = ''; + var presetResult = form.querySelector('[data-thumbnail-preset-result]'); + if (presetResult) { presetResult.hidden = true; presetResult.textContent = ''; } + this.openConstantEditorTab('value'); + this.updateConstantEditor(); + document.getElementById('constantDrawerTitle').textContent = 'Новая константа'; + }, + + fillConstantEdit: function (row) { + var form = this.constantForm(); + if (!form || !row) { return; } + var self = this; + this.fillConstantNew(); + var name = row.getAttribute('data-name'); + document.getElementById('constantDrawerTitle').textContent = 'Изменение: ' + name; + Adminx.Loader.show(); + Adminx.Ajax.request(this.base() + '/settings/constants/' + encodeURIComponent(name)).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (!d.success) { + Adminx.Toast.show(d.message || 'Константа не найдена', 'error'); + return; + } + var data = d.data || {}; + Object.keys(data).forEach(function (key) { + var el = form.querySelector('[name="' + key + '"]'); + if (el) { el.value = data[key] == null ? '' : data[key]; } + }); + form.querySelector('[name="name"]').readOnly = true; + if (Array.isArray(data.options_array)) { + form.querySelector('[name="options"]').value = data.options_array.join('\n'); + } + self.updateConstantEditor(); + self.openConstantEditorTab('value'); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + saveConstant: function (form) { + Adminx.Loader.show(); + Adminx.Ajax.post(this.base() + '/settings/constants', new FormData(form)).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { + Adminx.Ajax.handle(payload); + if (!d.redirect) { window.location.reload(); } + } else { + Adminx.Toast.show(d.message || 'Не удалось сохранить константу', 'error'); + } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + openConstantEditorTab: function (code) { + var form = this.constantForm(); if (!form) { return; } + form.querySelectorAll('[data-constant-editor-tab]').forEach(function (tab) { + var active = tab.getAttribute('data-constant-editor-tab') === code; + tab.classList.toggle('is-active', active); tab.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + form.querySelectorAll('[data-constant-editor-panel]').forEach(function (panel) { + panel.hidden = panel.getAttribute('data-constant-editor-panel') !== code; + }); + }, + + constantOptions: function () { + var form = this.constantForm(); if (!form) { return []; } + var raw = form.querySelector('[data-constant-options]').value || '', seen = {}; + return raw.split(/[\r\n,]+/).map(function (item) { return item.trim(); }).filter(function (item) { + if (!item || seen[item]) { return false; } seen[item] = true; return true; + }); + }, + + updateConstantEditor: function () { + var form = this.constantForm(); if (!form) { return; } + var type = form.querySelector('[data-constant-type]').value || 'string'; + var value = form.querySelector('[data-constant-value]').value || ''; + var name = (form.querySelector('[name="name"]').value || '').trim().toUpperCase(); + var thumbnailTools = form.querySelector('[data-thumbnail-preset-tools]'); + if (thumbnailTools) { thumbnailTools.hidden = name !== 'THUMBNAIL_SIZES'; } + form.querySelectorAll('[data-constant-control]').forEach(function (control) { control.hidden = control.getAttribute('data-constant-control') !== type; }); + var optionsField = form.querySelector('[data-constant-options-field]'); + optionsField.hidden = type !== 'select' && type !== 'tags'; + var hints = { string: 'Обычная текстовая строка.', path: 'Путь относительно корня проекта или абсолютный путь.', int: 'Только целое число.', bool: 'Выберите одно из двух состояний.', select: 'Выберите один из настроенных вариантов.', tags: 'Введите значения по одному на строку.' }; + form.querySelector('[data-constant-value-hint]').textContent = hints[type] || ''; + if (type === 'bool') { + this.setConstantBool(value === '1' || value === 'true' ? '1' : '0'); return; + } + if (type === 'select') { + var select = form.querySelector('[data-constant-input="select"]'), options = this.constantOptions(); + select.innerHTML = ''; + options.forEach(function (option) { var node = document.createElement('option'); node.value = option; node.textContent = option; select.appendChild(node); }); + if (options.indexOf(value) < 0 && value !== '') { var current = document.createElement('option'); current.value = value; current.textContent = value + ' (текущее)'; select.insertBefore(current, select.firstChild); } + select.value = value; select.disabled = options.length === 0 && value === ''; + form.querySelector('[data-constant-select-empty]').hidden = !select.disabled; + return; + } + var input = form.querySelector('[data-constant-input="' + type + '"]'); + if (input) { + input.value = type === 'tags' && name === 'THUMBNAIL_SIZES' + ? value.split(/[\s,;]+/).filter(Boolean).join('\n') + : value; + } + }, + + scanThumbnailPresets: function () { + var form = this.constantForm(); + if (!form) { return; } + var self = this; + var button = form.querySelector('[data-thumbnail-preset-scan]'); + if (button) { button.disabled = true; } + Adminx.Loader.show(); + Adminx.Ajax.post(this.base() + '/settings/constants/thumbnail-sizes/scan').then(function (payload) { + Adminx.Loader.hide(); + if (button) { button.disabled = false; } + var response = payload.data || {}; + if (!response.success) { + Adminx.Toast.show(response.message || 'Не удалось собрать размеры', 'error'); + return; + } + var data = response.data || {}; + var sizes = Array.isArray(data.sizes) ? data.sizes : []; + form.querySelector('[data-constant-type]').value = 'tags'; + form.querySelector('[data-constant-value]').value = sizes.join(','); + self.updateConstantEditor(); + var input = form.querySelector('[data-constant-input="tags"]'); + if (input) { input.value = sizes.join('\n'); } + self.syncConstantValue(input); + var result = form.querySelector('[data-thumbnail-preset-result]'); + if (result) { + result.hidden = false; + result.textContent = 'Найдено: ' + sizes.length + ' · файлов: ' + (data.files_scanned || 0) + + ' · таблиц БД: ' + (data.database_tables_scanned || 0) + + (Array.isArray(data.warnings) && data.warnings.length ? ' · предупреждений: ' + data.warnings.length : ''); + } + Adminx.Toast.show(response.message || 'Размеры собраны', 'success'); + }).catch(function () { + Adminx.Loader.hide(); + if (button) { button.disabled = false; } + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + setConstantBool: function (value, sync) { + var form = this.constantForm(); if (!form) { return; } + value = String(value) === '1' ? '1' : '0'; + form.querySelectorAll('[data-constant-bool]').forEach(function (button) { button.setAttribute('aria-pressed', button.getAttribute('data-constant-bool') === value ? 'true' : 'false'); }); + if (sync !== false) { form.querySelector('[data-constant-value]').value = value; } + }, + + syncConstantValue: function (control) { + var form = this.constantForm(); if (!form) { return; } + var type = form.querySelector('[data-constant-type]').value || 'string'; + if (type === 'bool') { return; } + control = control || form.querySelector('[data-constant-input="' + type + '"]'); + if (control) { form.querySelector('[data-constant-value]').value = control.value; } + }, + + deleteConstant: function (row) { + if (!row) { return; } + var self = this; + var name = row.getAttribute('data-name'); + Adminx.Confirm.open({ + kind: 'error', + title: 'Удалить константу?', + message: name + ' будет удалена из управляемой БД. PHP-файл изменится только после сборки.', + confirmLabel: 'Удалить', + confirmClass: 'btn-danger', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(self.base() + '/settings/constants/' + encodeURIComponent(name) + '/delete').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { + row.remove(); + Adminx.Toast.show(d.message || 'Константа удалена', 'success'); + } else { + Adminx.Toast.show(d.message || 'Не удалось удалить', 'error'); + } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + }, + + applyDependencies: function () { + var self = this; + this.form.querySelectorAll('[data-depends-key]').forEach(function (field) { + var key = field.getAttribute('data-depends-key'); + var value = field.getAttribute('data-depends-value'); + var control = self.form.querySelector('[name="' + key + '"]'); + var visible = control && control.value === value; + field.hidden = !visible; + }); + }, + + updateSwitchLabels: function () { + this.form.querySelectorAll('.settings-switch-line').forEach(function (line) { + var input = line.querySelector('input[type="checkbox"]'); + var label = line.querySelector('span b'); + if (input && label) { label.textContent = input.checked ? 'Включено' : 'Выключено'; } + }); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.Settings.init(); }); + } else { + Adminx.Settings.init(); + } +})(window, document); diff --git a/adminx/modules/Settings/language/en/client.xml b/adminx/modules/Settings/language/en/client.xml new file mode 100644 index 0000000..4cd9934 --- /dev/null +++ b/adminx/modules/Settings/language/en/client.xml @@ -0,0 +1,67 @@ + + + New pagination template + Settings saved + Template not found + Failed to save template + Constant + Path + New constant + (current) + Included + File not found + Data cleared + Failed to clear data + Constant removed + Enter values ​​one per line. + A regular text string. + System file + Delete pagination template? + Will be deleted + will be deleted from the managed database. The PHP file will only change after the build. + Interface updated + all daily viewing statistics + Failed to delete + Failed to clear cache + Delete constant? + Failed to collect dimensions + Integer only. + · files: + Settings + Failed to save interface + Delete + Interface + Clear + Choose one of two states. + Failed to save file + Failed to save + Failed to save constant + Clear cache? + · warnings: + Select one of the customized options. + The source will be cleared " + all document revisions + database tables: + Network error + Clear service data? + . The action is irreversible. + Dimensions collected + " will be deleted. + Off + Constant not found + Found: + Cache cleared + File saved + Template deleted + Change: + The path is relative to the project root or an absolute path. + Constant removed + Path + Interface + Cache cleared + Interface updated + Settings + Constant not found + Settings saved + Data cleared + diff --git a/adminx/modules/Settings/language/en/interface.xml b/adminx/modules/Settings/language/en/interface.xml new file mode 100644 index 0000000..8f4dbfa --- /dev/null +++ b/adminx/modules/Settings/language/en/interface.xml @@ -0,0 +1,328 @@ + + + , used by queries and documents. + B + Without a group + Blocks, templates, categories, queries, navigation and code operations. + Choices + Dashboard widgets + Invested in + attention + Selected value + Global cleaning of old revisions and daily counters. + Production readiness + ready + Yes + Add the first typed value. It will become available to the public runtime from the next request. + Additional check before changing executable code and installing modules. + available + Unified table of pagination templates. + There are unsaved changes + records + Request password + Constant value + Values + The change is applied after saving the settings. + Name + Cache sources + Cache and service data + Constant + Constants have not yet been created + Active Link Container + Navigation container + Separator container + Link container + Left menu + Label first + Latest label + Previous mark + Delimiter mark + Next Label + In production, it is better to leave protection enabled + No + One value per line + One successful confirmation is valid for 5 minutes. Disabling does not change role rights or CSRF protection. + Clear + Clearing current cache directories and service data. + errors + Password before operations that may change PHP/templates or add executables. + One option per line. + Re-entry + Daily views + Confirmation of actions + Administrator confirmation of critical actions + Show in menu + Show on main page + Show on dashboard + Order and visibility of control panel elements + The order of sections and sub-items of the panel. + Checks the theme, core, installed modules and text data files of the current database. The found presets will replace the contents of the field, but will be saved only after clicking “Save”. + Checking the environment and site readiness without changing data. + Checking the environment before production + Checks are completed without sending emails or creating payments. + Editor section + Placing elements + Document revisions + Editing permitted files without access to arbitrary paths. + Editable system PHP files + secret + System blocks and widgets of installed modules. + System Settings + Site and mail system parameters + System parameters of the site, mail, pagination and breadcrumbs. + System file + Hidden elements continue to operate on the direct address and still check access rights. + Document service data + First, add options in the Settings tab. + Collect + Collect the sizes used + Keep order + List of templates + Value type + Typed values of the public runtime, applied from the next request. + Typed system configuration values + Only directories that are used by the current runtime. + read only + Integer + Active link template + Pagination template + Link template + Templates have not yet been transferred + Templates from + Pagination templates + Page Navigation Templates + (current) + , incorrect: + , previews will be created: + . The action is irreversible. + .htaccess is missing + .htaccess is present + 09 July 2026 + 09 July 2026, 12:30 + Sender's email + Page ID 404 + JSON document snapshots + native without fallback switches + Native modules + Session and Cookie + SMTP authorization + SMTP login + SMTP password + SMTP port + SMTP server + SMTP encryption + Target tables native + UI assets + · local media library is marked as incomplete + · warnings: + database tables: + · files: + " will be deleted. + audit failed: + The base template cannot be deleted + Untitled + No encryption + Security + The source will be cleared " + will be deleted from the managed database. The PHP file will only change after the build. + Will be deleted + Enter values ​​one per line. + Included + Watermarks + all critical data owners are accessible + all document revisions + all daily viewing statistics + occurrences are normalized when rendering templates + Select one of the customized options. + Choose one of two states. + Off + Generation of thumbnails + Yes/no + Data and queries + Data cleared + Diagnostics + Add at least one allowed thumbnail size + Access to the site settings section. + Site access + available + available + there are external CDN dependencies + There are calls from the current framework, Adminx or public runtime. + Closed page title + Record + Fill in the field + Requests + Hello, %NAME%!\n\nYour account on the %HOST% website has been created. You can now log in using your registration details. + Changing settings, constants, paginations; cache maintenance. + Change: + Sender's name + Interface + Interface updated + Use separator + Used + Used only by the transitional public or legacy layer. + directory is missing or inaccessible + System file directory is not writable + Cache cleared + Caching + Compression + Constant not found + Constant saved + Constant removed + Constants + Active Page Container + Total container + Pagination container + crumb link container + Container of the current page + Container of bread crumbs + login is set + login not set + local + Maximum characters: + Maximum: + Panel routing + Media links + Total quantity label + Page Skip Mark + Minimum: + We are finishing up setting up the site. Please come back a little later. + Site name + Name, country, date and system pages. + Found: + Settings + Security settings saved + no cart settings + Settings saved + Settings: View + Settings: management + not specified + Failed to replace system file + Failed to clear data + Failed to clear cache + Failed to prepare system file + Could not read section order + Failed to collect dimensions + Failed to collect dimensions: + Failed to save + Failed to save interface + Failed to save constant + Failed to save file + Failed to save template + Failed to delete + not installed + Invalid value + Unknown maintenance operation + Unknown cache source + Unknown system file + Invalid constant name + Invalid interface data + Incorrect dimensions: + Incorrect email + no + New constant + New pagination template + Service + A regular text string. + Basic + Basic settings + Sender, transport and letter template. + missing: + Registration + Clear cache? + Clear service data? + PHP Error: + Network error + Pagination + Folders + First + Line wrapping + Letter to a new user + Signature of the letter + Show home + Show host + recipients + recipients, sender + Custom + Custom front functions. + Last + The last crumb is like a link + Mail + Postal transport + Rules for site indexing by search robots. + DB template prefixes + Check the form fields + Other + Public modules + Public runtime + Public access and temporary closure of the site during development. + Path + Path to sendmail + The path is relative to the project root or an absolute path. + Settings section not found + Bread crumb separator + size + size + sizes + Dimensions collected + Dimensions are collected and inserted into the field + Development + Expansion + Public access mode + Development mode + Russia + Best regards,\nsite team. + Website + The site is getting ready to launch + The site is open + System + A system constant cannot be deleted + System files + System file not found + System file saved + Compatibility + Message to visitors + Selection list + Link "First" + Link "Last" + Link "Previous" + "Next" link + links, sources missing: + Default country + Page %d from %d + String + no form table + Tags + Deny text + Hidden block text + Letter type + Product feeds + Integer only. + Transport + Order Notifications + Delete + Delete constant? + Delete pagination template? + Consider the publication date of the document + The file must start with <?php + File not found + File saved + forms, + Date Format + Date and time format + Formation of URL + bread crumbs + Thursday, July 09, 2026 + Thursday, July 09, 2026 (12:30) + Thursday, 07/09/2026 + Thursday, 07/09/2026 (12:30) + Template not found + Pagination template not found + Pagination template saved + Pagination template removed + Crumb link template + Template deleted + Default pagination templates. + Page Templates + Document breadcrumb templates. + diff --git a/adminx/modules/Settings/language/en/runtime.xml b/adminx/modules/Settings/language/en/runtime.xml new file mode 100644 index 0000000..3322c3d --- /dev/null +++ b/adminx/modules/Settings/language/en/runtime.xml @@ -0,0 +1,202 @@ + + + , incorrect: + , previews will be created: + .htaccess is missing + .htaccess is present + 09 July 2026 + 09 July 2026, 12:30 + Sender's email + Page ID 404 + JSON document snapshots + Native modules + SMTP authorization + SMTP login + SMTP password + SMTP port + SMTP server + SMTP encryption + Session and Cookie + Target tables native + UI assets + native without fallback switches + · local media library is marked as incomplete + The base template cannot be deleted + Untitled + No encryption + Security + Watermarks + Generation of thumbnails + Yes + Yes/no + Data and queries + Data cleared + Diagnostics + Add at least one allowed thumbnail size + Access to the site settings section. + Site access + There are calls from the current framework, Adminx or public runtime. + Closed page title + Record + Fill in the field + Requests + Hello, %NAME%! + +Your account on the website %HOST% has been created. You can now log in using your registration details. + Changing settings, constants, paginations; cache maintenance. + Sender's name + Interface + Interface updated + Use separator + Used + Used only by the transitional public or legacy layer. + System file directory is not writable + Cache cleared + Caching + Compression + Constant not found + Constant saved + Constant removed + Constants + Active Page Container + Total container + Pagination container + Separator container + Link container + crumb link container + Container of the current page + Container of bread crumbs + Maximum characters: + Maximum: + Panel routing + Media links + Total quantity label + Page Skip Mark + Minimum: + We are finishing up setting up the site. Please come back a little later. + Site name + Name, country, date and system pages. + Settings + Security settings saved + Settings saved + Settings: View + Settings: management + Failed to replace system file + Failed to prepare system file + Could not read section order + Failed to collect dimensions: + Invalid value + Unknown maintenance operation + Unknown cache source + Unknown system file + Invalid constant name + Invalid interface data + Incorrect dimensions: + Incorrect email + No + Service + Basic + Basic settings + Sender, transport and letter template. + Registration + PHP Error: + Pagination + Folders + First + Line wrapping + Letter to a new user + Signature of the letter + Show home + Show host + Custom + Custom front functions. + Last + The last crumb is like a link + Mail + Postal transport + Rules for site indexing by search robots. + DB template prefixes + Check the form fields + Other + Public modules + Public runtime + Public access and temporary closure of the site during development. + Path + Path to sendmail + Settings section not found + Bread crumb separator + Dimensions are collected and inserted into the field + Development + Expansion + Public access mode + Development mode + Russia + Best regards, +site team. + Website + The site is getting ready to launch + The site is open + System + A system constant cannot be deleted + System files + System file not found + System file saved + Compatibility + Message to visitors + Selection list + Link "First" + Link "Last" + Link "Previous" + "Next" link + Default country + Page %d from %d + String + Tags + Deny text + Hidden block text + Letter type + Product feeds + Transport + Order Notifications + Consider the publication date of the document + Date Format + Date and time format + Formation of URL + bread crumbs + Integer + Thursday, July 09, 2026 + Thursday, July 09, 2026 (12:30) + Thursday, 07/09/2026 + Thursday, 07/09/2026 (12:30) + Pagination template not found + Pagination template saved + Pagination template removed + Crumb link template + Pagination templates + Default pagination templates. + Page Templates + Document breadcrumb templates. + audit failed: + all critical data owners are accessible + occurrences are normalized when rendering templates + available + available + there are external CDN dependencies + directory is missing or inaccessible + login is set + login not set + local + no cart settings + not specified + not installed + no + missing: + recipients + recipients, sender + size + size + sizes + links, sources missing: + no form table + forms, + diff --git a/adminx/modules/Settings/language/ru/client.xml b/adminx/modules/Settings/language/ru/client.xml new file mode 100644 index 0000000..7dd5df9 --- /dev/null +++ b/adminx/modules/Settings/language/ru/client.xml @@ -0,0 +1,67 @@ + + + Новый шаблон пагинации + Настройки сохранены + Шаблон не найден + Не удалось сохранить шаблон + Константа + Путь + Новая константа + (текущее) + Включено + Файл не найден + Данные очищены + Не удалось очистить данные + Константа удалена + Введите значения по одному на строку. + Обычная текстовая строка. + Системный файл + Удалить шаблон пагинации? + Будут удалены + будет удалена из управляемой БД. PHP-файл изменится только после сборки. + Интерфейс обновлен + всю подневную статистику просмотров + Не удалось удалить + Не удалось очистить кеш + Удалить константу? + Не удалось собрать размеры + Только целое число. + · файлов: + Настройки + Не удалось сохранить интерфейс + Удалить + Интерфейс + Очистить + Выберите одно из двух состояний. + Не удалось сохранить файл + Не удалось сохранить + Не удалось сохранить константу + Очистить кеш? + · предупреждений: + Выберите один из настроенных вариантов. + Будет очищен источник « + все ревизии документов + · таблиц БД: + Ошибка сети + Очистить служебные данные? + . Действие необратимо. + Размеры собраны + » будет удалён. + Выключено + Константа не найдена + Найдено: + Кеш очищен + Файл сохранён + Шаблон удалён + Изменение: + Путь относительно корня проекта или абсолютный путь. + Константа удалена + Путь + Интерфейс + Кеш очищен + Интерфейс обновлен + Настройки + Константа не найдена + Настройки сохранены + Данные очищены + diff --git a/adminx/modules/Settings/language/ru/interface.xml b/adminx/modules/Settings/language/ru/interface.xml new file mode 100644 index 0000000..24ee88b --- /dev/null +++ b/adminx/modules/Settings/language/ru/interface.xml @@ -0,0 +1,328 @@ + + + , используемые запросами и документами. + Б + Без группы + Блоки, шаблоны, рубрики, запросы, навигация и операции с кодом. + Варианты выбора + Виджеты дашборда + Вложен в + внимания + Выбранное значение + Глобальная очистка старых ревизий и подневных счётчиков. + Готовность production + готовы + Да + Добавьте первое типизированное значение. Оно станет доступно публичному runtime со следующего запроса. + Дополнительная проверка перед изменением исполняемого кода и установкой модулей. + доступен + Единая таблица шаблонов пагинации. + Есть несохранённые изменения + записей + Запрашивать пароль + Значение константы + Значения + Изменение применяется после сохранения настроек. + Имя + Источники кеша + Кеш и служебные данные + Константа + Константы ещё не созданы + Контейнер активной ссылки + Контейнер навигации + Контейнер разделителя + Контейнер ссылки + Левое меню + Метка первой + Метка последней + Метка предыдущей + Метка разделителя + Метка следующей + На production защиту лучше оставить включённой + Нет + Одно значение на строку + Одно успешное подтверждение действует 5 минут. Отключение не меняет права ролей и CSRF-защиту. + Очистить + Очистка актуальных cache-директорий и служебных данных. + ошибок + Пароль перед операциями, которые могут изменить PHP/шаблоны или добавить исполняемые файлы. + По одному варианту на строку. + Повторный вход + Подневные просмотры + Подтверждение действий + Подтверждение критических действий администратором + Показывать в меню + Показывать на главной + Показывать на дашборде + Порядок и видимость элементов панели управления + Порядок разделов и вложенных пунктов панели. + Проверит файлы темы, ядра, установленных модулей и текстовые данные текущей БД. Найденные пресеты заменят содержимое поля, но сохранятся только после нажатия «Сохранить». + Проверка окружения и готовности сайта без изменения данных. + Проверка окружения перед production + Проверки выполняются без отправки писем и создания платежей. + Раздел редактора + Размещение элементов + Ревизии документов + Редактирование разрешённых файлов без доступа к произвольным путям. + Редактируемые системные PHP-файлы + секрет + Системные блоки и виджеты установленных модулей. + Системные настройки + Системные параметры сайта и почты + Системные параметры сайта, почты, пагинации и хлебных крошек. + Системный файл + Скрытые элементы продолжают работать по прямому адресу и по-прежнему проверяют права доступа. + Служебные данные документов + Сначала добавьте варианты во вкладке «Настройки». + Собрать + Собрать используемые размеры + Сохранить порядок + Список шаблонов + Тип значения + Типизированные значения публичного runtime, применяемые со следующего запроса. + Типизированные значения системной конфигурации + Только каталоги, которые используются текущим runtime. + только чтение + Целое число + Шаблон активной ссылки + Шаблон пагинации + Шаблон ссылки + Шаблоны ещё не перенесены + Шаблоны из + Шаблоны пагинации + Шаблоны постраничной навигации + (текущее) + , некорректных: + , превью создадутся: + . Действие необратимо. + .htaccess отсутствует + .htaccess присутствует + 09 июля 2026 + 09 июля 2026, 12:30 + Email отправителя + ID страницы 404 + JSON-снимки документов + native без fallback-переключателей + Native-модули + Session и Cookie + SMTP авторизация + SMTP-логин + SMTP-пароль + SMTP-порт + SMTP-сервер + SMTP-шифрование + Target-таблицы native + UI-ассеты + · локальная медиатека помечена как неполная + · предупреждений: + · таблиц БД: + · файлов: + » будет удалён. + аудит не выполнен: + Базовый шаблон удалить нельзя + Без названия + Без шифрования + Безопасность + Будет очищен источник « + будет удалена из управляемой БД. PHP-файл изменится только после сборки. + Будут удалены + Введите значения по одному на строку. + Включено + Водяные знаки + все критичные владельцы данных доступны + все ревизии документов + всю подневную статистику просмотров + вхождений нормализуются при рендере шаблонов + Выберите один из настроенных вариантов. + Выберите одно из двух состояний. + Выключено + Генерация миниатюр + Да/нет + Данные и запросы + Данные очищены + Диагностика + Добавьте хотя бы один разрешённый размер миниатюр + Доступ к разделу настроек сайта. + Доступ к сайту + доступна + доступно + есть внешние CDN-зависимости + Есть обращения из текущего framework, Adminx или публичного runtime. + Заголовок закрытой страницы + Запись + Заполните поле + Запросы + Здравствуйте, %NAME%!\n\nВаш аккаунт на сайте %HOST% создан. Теперь вы можете войти, используя данные регистрации. + Изменение настроек, констант, пагинаций; обслуживание кэша. + Изменение: + Имя отправителя + Интерфейс + Интерфейс обновлен + Использовать разделитель + Используется + Используется только переходным публичным или legacy-слоем. + каталог отсутствует или недоступен + Каталог системного файла недоступен для записи + Кеш очищен + Кеширование + Компрессия + Константа не найдена + Константа сохранена + Константа удалена + Константы + Контейнер активной страницы + Контейнер итога + Контейнер пагинации + Контейнер ссылки крошки + Контейнер текущей страницы + Контейнер хлебных крошек + логин задан + логин не задан + локальные + Максимум символов: + Максимум: + Маршрутизация панели + Медиа-ссылки + Метка общего количества + Метка пропуска страниц + Минимум: + Мы завершаем настройку сайта. Пожалуйста, зайдите немного позже. + Название сайта + Название, страна, дата и системные страницы. + Найдено: + Настройки + Настройки безопасности сохранены + настройки корзины отсутствуют + Настройки сохранены + Настройки: просмотр + Настройки: управление + не задан + Не удалось заменить системный файл + Не удалось очистить данные + Не удалось очистить кеш + Не удалось подготовить системный файл + Не удалось прочитать порядок секций + Не удалось собрать размеры + Не удалось собрать размеры: + Не удалось сохранить + Не удалось сохранить интерфейс + Не удалось сохранить константу + Не удалось сохранить файл + Не удалось сохранить шаблон + Не удалось удалить + не установлено + Недопустимое значение + Неизвестная операция обслуживания + Неизвестный источник кеша + Неизвестный системный файл + Некорректное имя константы + Некорректные данные интерфейса + Некорректные размеры: + Некорректный email + нет + Новая константа + Новый шаблон пагинации + Обслуживание + Обычная текстовая строка. + Основные + Основные настройки + Отправитель, транспорт и шаблон письма. + отсутствуют: + Оформление + Очистить кеш? + Очистить служебные данные? + Ошибка PHP: + Ошибка сети + Пагинация + Папки + Первая + Перенос строк + Письмо новому пользователю + Подпись письма + Показывать главную + Показывать хост + получателей + получателей, отправитель + Пользовательские + Пользовательские функции фронта. + Последняя + Последняя крошка как ссылка + Почта + Почтовый транспорт + Правила индексации сайта поисковыми роботами. + Префиксы DB-шаблонов + Проверьте поля формы + Прочее + Публичные модули + Публичный runtime + Публичный доступ и временное закрытие сайта на время разработки. + Путь + Путь к sendmail + Путь относительно корня проекта или абсолютный путь. + Раздел настроек не найден + Разделитель хлебных крошек + размер + размера + размеров + Размеры собраны + Размеры собраны и подставлены в поле + Разработка + Расширение + Режим публичного доступа + Режим разработки + Россия + С уважением,\nкоманда сайта. + Сайт + Сайт готовится к запуску + Сайт открыт + Система + Системную константу нельзя удалить + Системные файлы + Системный файл не найден + Системный файл сохранён + Совместимость + Сообщение посетителям + Список выбора + Ссылка «Первая» + Ссылка «Последняя» + Ссылка «Предыдущая» + Ссылка «Следующая» + ссылок, отсутствуют исходники: + Страна по умолчанию + Страница %d из %d + Строка + таблица форм отсутствует + Теги + Текст запрета доступа + Текст скрытого блока + Тип письма + Товарные фиды + Только целое число. + Транспорт + Уведомления заказов + Удалить + Удалить константу? + Удалить шаблон пагинации? + Учитывать дату публикации документа + Файл должен начинаться с <?php + Файл не найден + Файл сохранён + форм, + Формат даты + Формат даты и времени + Формирование URL + Хлебные крошки + Четверг, 09 июля 2026 + Четверг, 09 июля 2026 (12:30) + Четверг, 09.07.2026 + Четверг, 09.07.2026 (12:30) + Шаблон не найден + Шаблон пагинации не найден + Шаблон пагинации сохранён + Шаблон пагинации удалён + Шаблон ссылки крошки + Шаблон удалён + Шаблоны постраничной навигации по умолчанию. + Шаблоны страниц + Шаблоны цепочки навигации документа. + diff --git a/adminx/modules/Settings/language/ru/runtime.xml b/adminx/modules/Settings/language/ru/runtime.xml new file mode 100644 index 0000000..289a99a --- /dev/null +++ b/adminx/modules/Settings/language/ru/runtime.xml @@ -0,0 +1,202 @@ + + + , некорректных: + , превью создадутся: + .htaccess отсутствует + .htaccess присутствует + 09 июля 2026 + 09 июля 2026, 12:30 + Email отправителя + ID страницы 404 + JSON-снимки документов + Native-модули + SMTP авторизация + SMTP-логин + SMTP-пароль + SMTP-порт + SMTP-сервер + SMTP-шифрование + Session и Cookie + Target-таблицы native + UI-ассеты + native без fallback-переключателей + · локальная медиатека помечена как неполная + Базовый шаблон удалить нельзя + Без названия + Без шифрования + Безопасность + Водяные знаки + Генерация миниатюр + Да + Да/нет + Данные и запросы + Данные очищены + Диагностика + Добавьте хотя бы один разрешённый размер миниатюр + Доступ к разделу настроек сайта. + Доступ к сайту + Есть обращения из текущего framework, Adminx или публичного runtime. + Заголовок закрытой страницы + Запись + Заполните поле + Запросы + Здравствуйте, %NAME%! + +Ваш аккаунт на сайте %HOST% создан. Теперь вы можете войти, используя данные регистрации. + Изменение настроек, констант, пагинаций; обслуживание кэша. + Имя отправителя + Интерфейс + Интерфейс обновлен + Использовать разделитель + Используется + Используется только переходным публичным или legacy-слоем. + Каталог системного файла недоступен для записи + Кеш очищен + Кеширование + Компрессия + Константа не найдена + Константа сохранена + Константа удалена + Константы + Контейнер активной страницы + Контейнер итога + Контейнер пагинации + Контейнер разделителя + Контейнер ссылки + Контейнер ссылки крошки + Контейнер текущей страницы + Контейнер хлебных крошек + Максимум символов: + Максимум: + Маршрутизация панели + Медиа-ссылки + Метка общего количества + Метка пропуска страниц + Минимум: + Мы завершаем настройку сайта. Пожалуйста, зайдите немного позже. + Название сайта + Название, страна, дата и системные страницы. + Настройки + Настройки безопасности сохранены + Настройки сохранены + Настройки: просмотр + Настройки: управление + Не удалось заменить системный файл + Не удалось подготовить системный файл + Не удалось прочитать порядок секций + Не удалось собрать размеры: + Недопустимое значение + Неизвестная операция обслуживания + Неизвестный источник кеша + Неизвестный системный файл + Некорректное имя константы + Некорректные данные интерфейса + Некорректные размеры: + Некорректный email + Нет + Обслуживание + Основные + Основные настройки + Отправитель, транспорт и шаблон письма. + Оформление + Ошибка PHP: + Пагинация + Папки + Первая + Перенос строк + Письмо новому пользователю + Подпись письма + Показывать главную + Показывать хост + Пользовательские + Пользовательские функции фронта. + Последняя + Последняя крошка как ссылка + Почта + Почтовый транспорт + Правила индексации сайта поисковыми роботами. + Префиксы DB-шаблонов + Проверьте поля формы + Прочее + Публичные модули + Публичный runtime + Публичный доступ и временное закрытие сайта на время разработки. + Путь + Путь к sendmail + Раздел настроек не найден + Разделитель хлебных крошек + Размеры собраны и подставлены в поле + Разработка + Расширение + Режим публичного доступа + Режим разработки + Россия + С уважением, +команда сайта. + Сайт + Сайт готовится к запуску + Сайт открыт + Система + Системную константу нельзя удалить + Системные файлы + Системный файл не найден + Системный файл сохранён + Совместимость + Сообщение посетителям + Список выбора + Ссылка «Первая» + Ссылка «Последняя» + Ссылка «Предыдущая» + Ссылка «Следующая» + Страна по умолчанию + Страница %d из %d + Строка + Теги + Текст запрета доступа + Текст скрытого блока + Тип письма + Товарные фиды + Транспорт + Уведомления заказов + Учитывать дату публикации документа + Формат даты + Формат даты и времени + Формирование URL + Хлебные крошки + Целое число + Четверг, 09 июля 2026 + Четверг, 09 июля 2026 (12:30) + Четверг, 09.07.2026 + Четверг, 09.07.2026 (12:30) + Шаблон пагинации не найден + Шаблон пагинации сохранён + Шаблон пагинации удалён + Шаблон ссылки крошки + Шаблоны пагинации + Шаблоны постраничной навигации по умолчанию. + Шаблоны страниц + Шаблоны цепочки навигации документа. + аудит не выполнен: + все критичные владельцы данных доступны + вхождений нормализуются при рендере шаблонов + доступна + доступно + есть внешние CDN-зависимости + каталог отсутствует или недоступен + логин задан + логин не задан + локальные + настройки корзины отсутствуют + не задан + не установлено + нет + отсутствуют: + получателей + получателей, отправитель + размер + размера + размеров + ссылок, отсутствуют исходники: + таблица форм отсутствует + форм, + diff --git a/adminx/modules/Settings/migrations/001_create_settings_tables.sql b/adminx/modules/Settings/migrations/001_create_settings_tables.sql new file mode 100644 index 0000000..84b65df --- /dev/null +++ b/adminx/modules/Settings/migrations/001_create_settings_tables.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_countries` ( + `Id` MEDIUMINT(5) UNSIGNED NOT NULL AUTO_INCREMENT, + `country_code` CHAR(2) NOT NULL DEFAULT 'RU', + `country_name` CHAR(50) NOT NULL DEFAULT '', + `country_status` ENUM('1','2') NOT NULL DEFAULT '2', + `country_eu` ENUM('1','2') NOT NULL DEFAULT '2', + PRIMARY KEY (`Id`), + KEY `idx_country_code` (`country_code`), + KEY `idx_country_status` (`country_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `{{prefix}}_paginations` ( + `id` TINYINT(1) UNSIGNED NOT NULL AUTO_INCREMENT, + `pagination_name` TINYTEXT, + `pagination_box` VARCHAR(255) NOT NULL DEFAULT '', + `pagination_start_label` VARCHAR(255) NOT NULL DEFAULT '', + `pagination_end_label` VARCHAR(255) NOT NULL DEFAULT '', + `pagination_separator_box` VARCHAR(255) NOT NULL DEFAULT '', + `pagination_separator_label` VARCHAR(255) NOT NULL DEFAULT '', + `pagination_next_label` VARCHAR(255) NOT NULL DEFAULT '', + `pagination_prev_label` VARCHAR(255) NOT NULL DEFAULT '', + `pagination_link_box` VARCHAR(255) NOT NULL DEFAULT '', + `pagination_active_link_box` VARCHAR(255) NOT NULL DEFAULT '', + `pagination_link_template` VARCHAR(255) DEFAULT NULL, + `pagination_link_active_template` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `{{prefix}}_constants` ( + `name` VARCHAR(80) NOT NULL, + `value` MEDIUMTEXT, + `type` VARCHAR(20) NOT NULL DEFAULT 'string', + `group_code` VARCHAR(80) NOT NULL DEFAULT 'custom', + `label` VARCHAR(190) NOT NULL DEFAULT '', + `description` TEXT, + `options` TEXT, + `is_system` TINYINT(1) NOT NULL DEFAULT 0, + `sort_order` INT NOT NULL DEFAULT 100, + `updated_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`name`), + KEY `idx_group_code` (`group_code`), + KEY `idx_sort_order` (`sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Settings/migrations/002_normalize_breadcrumb_separator.sql b/adminx/modules/Settings/migrations/002_normalize_breadcrumb_separator.sql new file mode 100644 index 0000000..760a35e --- /dev/null +++ b/adminx/modules/Settings/migrations/002_normalize_breadcrumb_separator.sql @@ -0,0 +1,103 @@ +-- Normalize breadcrumb separator names across native and source settings. + +INSERT INTO `{{system_prefix}}_settings` (`param`, `value`, `type`) +SELECT 'bread_separator', `value`, `type` +FROM `{{system_prefix}}_settings` +WHERE `param` = 'bread_sepparator' +ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), `type` = VALUES(`type`); + +DELETE FROM `{{system_prefix}}_settings` +WHERE `param` = 'bread_sepparator'; + +INSERT INTO `{{system_prefix}}_settings` (`param`, `value`, `type`) +SELECT 'bread_separator_use', `value`, `type` +FROM `{{system_prefix}}_settings` +WHERE `param` = 'bread_sepparator_use' +ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), `type` = VALUES(`type`); + +DELETE FROM `{{system_prefix}}_settings` +WHERE `param` = 'bread_sepparator_use'; + +SET @ave_rename_table = '{{public_shell_prefix}}_settings'; +SET @ave_rename_sql = IF( + EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'bread_sepparator') + AND NOT EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'bread_separator'), + CONCAT('ALTER TABLE `', @ave_rename_table, '` CHANGE COLUMN `bread_sepparator` `bread_separator` VARCHAR(255) NOT NULL'), + 'SELECT 1' +); +PREPARE ave_rename_stmt FROM @ave_rename_sql; +EXECUTE ave_rename_stmt; +DEALLOCATE PREPARE ave_rename_stmt; + +SET @ave_rename_sql = IF( + EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'bread_sepparator_use') + AND NOT EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'bread_separator_use'), + CONCAT('ALTER TABLE `', @ave_rename_table, '` CHANGE COLUMN `bread_sepparator_use` `bread_separator_use` ENUM(\'1\',\'0\') NOT NULL'), + 'SELECT 1' +); +PREPARE ave_rename_stmt FROM @ave_rename_sql; +EXECUTE ave_rename_stmt; +DEALLOCATE PREPARE ave_rename_stmt; + +SET @ave_rename_table = '{{database_prefix}}_settings'; +SET @ave_rename_sql = IF( + EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'bread_sepparator') + AND NOT EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'bread_separator'), + CONCAT('ALTER TABLE `', @ave_rename_table, '` CHANGE COLUMN `bread_sepparator` `bread_separator` VARCHAR(255) NOT NULL'), + 'SELECT 1' +); +PREPARE ave_rename_stmt FROM @ave_rename_sql; +EXECUTE ave_rename_stmt; +DEALLOCATE PREPARE ave_rename_stmt; + +SET @ave_rename_sql = IF( + EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'bread_sepparator_use') + AND NOT EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'bread_separator_use'), + CONCAT('ALTER TABLE `', @ave_rename_table, '` CHANGE COLUMN `bread_sepparator_use` `bread_separator_use` ENUM(\'1\',\'0\') NOT NULL'), + 'SELECT 1' +); +PREPARE ave_rename_stmt FROM @ave_rename_sql; +EXECUTE ave_rename_stmt; +DEALLOCATE PREPARE ave_rename_stmt; + +SET @ave_rename_table = '{{content_prefix}}_rubric_breadcrumb'; +SET @ave_rename_sql = IF( + EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'sepparator') + AND NOT EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'separator'), + CONCAT('ALTER TABLE `', @ave_rename_table, '` CHANGE COLUMN `sepparator` `separator` VARCHAR(255) NOT NULL'), + 'SELECT 1' +); +PREPARE ave_rename_stmt FROM @ave_rename_sql; +EXECUTE ave_rename_stmt; +DEALLOCATE PREPARE ave_rename_stmt; + +SET @ave_rename_sql = IF( + EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'sepparator_use') + AND NOT EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'separator_use'), + CONCAT('ALTER TABLE `', @ave_rename_table, '` CHANGE COLUMN `sepparator_use` `separator_use` ENUM(\'1\',\'0\') NOT NULL'), + 'SELECT 1' +); +PREPARE ave_rename_stmt FROM @ave_rename_sql; +EXECUTE ave_rename_stmt; +DEALLOCATE PREPARE ave_rename_stmt; + +SET @ave_rename_table = '{{database_prefix}}_rubric_breadcrumb'; +SET @ave_rename_sql = IF( + EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'sepparator') + AND NOT EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'separator'), + CONCAT('ALTER TABLE `', @ave_rename_table, '` CHANGE COLUMN `sepparator` `separator` VARCHAR(255) NOT NULL'), + 'SELECT 1' +); +PREPARE ave_rename_stmt FROM @ave_rename_sql; +EXECUTE ave_rename_stmt; +DEALLOCATE PREPARE ave_rename_stmt; + +SET @ave_rename_sql = IF( + EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'sepparator_use') + AND NOT EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'separator_use'), + CONCAT('ALTER TABLE `', @ave_rename_table, '` CHANGE COLUMN `sepparator_use` `separator_use` ENUM(\'1\',\'0\') NOT NULL'), + 'SELECT 1' +); +PREPARE ave_rename_stmt FROM @ave_rename_sql; +EXECUTE ave_rename_stmt; +DEALLOCATE PREPARE ave_rename_stmt; diff --git a/adminx/modules/Settings/migrations/003_remove_unused_constants.sql b/adminx/modules/Settings/migrations/003_remove_unused_constants.sql new file mode 100644 index 0000000..6781c44 --- /dev/null +++ b/adminx/modules/Settings/migrations/003_remove_unused_constants.sql @@ -0,0 +1,9 @@ +DELETE FROM `{{prefix}}_constants` +WHERE `name` IN ( + 'ADMIN_CAPTCHA', 'ADMIN_EDITMENU', 'ADMIN_MENU', 'ADMIN_MENU_QUICK_ADD', + 'DB_EXPORT_GZ', 'DB_EXPORT_PREFIX', 'DB_EXPORT_TPL', 'DEFAULT_THEME_FOLDER_COLOR', + 'PROFILING_ADMIN', 'SITEMAP_CACHE_LIFETIME', 'SMARTY_COMPILE_CHECK', + 'SMARTY_DEBUGGING', 'SMARTY_USE_SUB_DIRS', 'UPLOAD_GALLERY_DIR', + 'USERS_TIME_SHOW', 'USER_IP', 'CHECK_VERSION', 'GOOGLE_MAP_API_KEY', + 'YANDEX_MAP_API_KEY' +); diff --git a/adminx/modules/Settings/migrations/004_seed_core_display_defaults.sql b/adminx/modules/Settings/migrations/004_seed_core_display_defaults.sql new file mode 100644 index 0000000..0599cb4 --- /dev/null +++ b/adminx/modules/Settings/migrations/004_seed_core_display_defaults.sql @@ -0,0 +1,22 @@ +INSERT INTO `{{prefix}}_settings` (`param`, `value`, `type`) VALUES +('mail_new_user', 'Здравствуйте, %NAME%!\n\nВаш аккаунт на сайте %HOST% создан. Теперь вы можете войти, используя данные регистрации.', 'string'), +('mail_signature', 'С уважением,\nкоманда сайта.', 'string'), +('navi_box', '
      %s
    ', 'string'), +('start_label', 'Первая', 'string'), +('end_label', 'Последняя', 'string'), +('separator_label', '…', 'string'), +('next_label', '»', 'string'), +('prev_label', '«', 'string'), +('total_label', 'Страница %d из %d', 'string'), +('link_box', '
  • %s
  • ', 'string'), +('total_box', '%s', 'string'), +('active_box', '
  • %s
  • ', 'string'), +('separator_box', '
  • %s
  • ', 'string'), +('bread_box', '', 'string'), +('bread_separator', '', 'string'), +('bread_link_box', '
  • %s
  • ', 'string'), +('bread_link_template', '[name]', 'string'), +('bread_self_box', '
  • %s
  • ', 'string') +ON DUPLICATE KEY UPDATE +`value` = IF(`value` IS NULL OR `value` = '', VALUES(`value`), `value`), +`type` = IF(`type` IS NULL OR `type` = '', VALUES(`type`), `type`); diff --git a/adminx/modules/Settings/migrations/005_order_system_navigation.php b/adminx/modules/Settings/migrations/005_order_system_navigation.php new file mode 100644 index 0000000..4b6dd8e --- /dev/null +++ b/adminx/modules/Settings/migrations/005_order_system_navigation.php @@ -0,0 +1,70 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\SystemTables; + use App\Helpers\Json; + + return function (array $context) { + $table = SystemTables::table('settings'); + $row = DB::query( + 'SELECT value FROM ' . $table . ' WHERE param = %s LIMIT 1', + 'adminx_interface.navigation' + )->getAssoc(); + if (!$row) { + return 0; + } + + $items = Json::toArray((string) $row['value']); + if (!is_array($items)) { + return 0; + } + + $preferred = array('roles', 'users', 'customers', 'settings', 'database', 'events'); + $selected = array(); + $remaining = array(); + $insertAt = null; + foreach ($items as $item) { + $code = is_array($item) && isset($item['code']) ? (string) $item['code'] : ''; + if (in_array($code, $preferred, true)) { + if ($insertAt === null) { + $insertAt = count($remaining); + } + + $selected[$code] = $item; + continue; + } + + $remaining[] = $item; + } + + $ordered = array(); + foreach ($preferred as $code) { + if (isset($selected[$code])) { + $ordered[] = $selected[$code]; + } + } + + if (!$ordered) { + return 0; + } + + array_splice($remaining, $insertAt === null ? count($remaining) : $insertAt, 0, $ordered); + DB::Update($table, array( + 'value' => Json::encode($remaining, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ), 'param = %s', 'adminx_interface.navigation'); + + return 1; + }; diff --git a/adminx/modules/Settings/migrations/006_create_admin_saved_views.sql b/adminx/modules/Settings/migrations/006_create_admin_saved_views.sql new file mode 100644 index 0000000..fd9cd64 --- /dev/null +++ b/adminx/modules/Settings/migrations/006_create_admin_saved_views.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS `{{system_prefix}}_admin_saved_views` ( + `id` CHAR(12) CHARACTER SET ascii NOT NULL, + `user_id` INT UNSIGNED NOT NULL, + `scope` VARCHAR(64) CHARACTER SET ascii NOT NULL, + `title` VARCHAR(60) NOT NULL, + `filters_json` TEXT NOT NULL, + `updated_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_user_scope_title` (`user_id`,`scope`,`title`), + KEY `idx_user_scope` (`user_id`,`scope`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Settings/module.php b/adminx/modules/Settings/module.php new file mode 100644 index 0000000..702e277 --- /dev/null +++ b/adminx/modules/Settings/module.php @@ -0,0 +1,204 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + return array( + 'code' => 'settings', + 'name' => 'Настройки', + 'version' => '0.1.1', + + 'permissions' => array( + 'key' => 'settings', + 'items' => array( + array( + 'code' => 'view_settings', + 'group_code' => 'navigation', + 'name' => 'Настройки: просмотр', + 'description' => 'Доступ к разделу настроек сайта.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_settings', + 'group_code' => 'admin', + 'name' => 'Настройки: управление', + 'description' => 'Изменение настроек, констант, пагинаций; обслуживание кэша.', + 'sort_order' => 20, + ), + ), + 'icon' => 'ti ti-settings', + 'priority' => 30, + ), + + 'navigation' => array( + array( + 'code' => 'settings', + 'label' => 'Настройки', + 'url' => '#', + 'icon' => 'ti ti-settings', + 'permission' => '', + 'group' => 'Система', + 'sort_order' => 33, + 'match' => array( + '/settings', + '/security/ip-blocks', + '/system/console', + ), + ), + array( + 'code' => 'settings_main', + 'label' => 'Основные', + 'url' => '/settings/main', + 'permission' => 'view_settings', + 'group' => 'Система', + 'parent' => 'settings', + 'sort_order' => 31, + 'match' => array( + '/settings/main', + ), + 'exact' => true, + ), + array( + 'code' => 'settings_interface', + 'label' => 'Интерфейс', + 'url' => '/settings/interface', + 'permission' => 'view_settings', + 'group' => 'Система', + 'parent' => 'settings', + 'sort_order' => 32, + 'match' => array( + '/settings/interface', + ), + 'exact' => true, + ), + array( + 'code' => 'settings_constants', + 'label' => 'Константы', + 'url' => '/settings/constants', + 'permission' => 'view_settings', + 'group' => 'Система', + 'parent' => 'settings', + 'sort_order' => 34, + 'match' => array( + '/settings/constants', + ), + 'exact' => true, + ), + array( + 'code' => 'settings_security', + 'label' => 'Безопасность', + 'url' => '/settings/security', + 'permission' => 'view_settings', + 'group' => 'Система', + 'parent' => 'settings', + 'sort_order' => 33, + 'match' => array( + '/settings/security', + ), + 'exact' => true, + ), + array( + 'code' => 'settings_paginations', + 'label' => 'Пагинация', + 'url' => '/settings/paginations', + 'permission' => 'view_settings', + 'group' => 'Система', + 'parent' => 'settings', + 'sort_order' => 35, + 'match' => array( + '/settings/paginations', + ), + 'exact' => true, + ), + array( + 'code' => 'settings_maintenance', + 'label' => 'Обслуживание', + 'url' => '/settings/maintenance', + 'permission' => 'view_settings', + 'group' => 'Система', + 'parent' => 'settings', + 'sort_order' => 36, + 'match' => array( + '/settings/maintenance', + ), + 'exact' => true, + ), + array( + 'code' => 'settings_diagnostics', + 'label' => 'Диагностика', + 'url' => '/settings/diagnostics', + 'permission' => 'view_settings', + 'group' => 'Система', + 'parent' => 'settings', + 'sort_order' => 37, + 'match' => array( + '/settings/diagnostics', + ), + 'exact' => true, + ), + array( + 'code' => 'settings_files', + 'label' => 'Системные файлы', + 'url' => '/settings/files', + 'permission' => 'view_settings', + 'group' => 'Система', + 'parent' => 'settings', + 'sort_order' => 38, + 'match' => array( + '/settings/files', + ), + 'exact' => true, + ), + ), + + 'migrations' => array( + array('id' => '001_create_settings_tables', 'file' => 'migrations/001_create_settings_tables.sql', 'legacy_checksums' => array('6346c2e9ed66ed6d253c51aa1addadb50b5feb3d')), + array('id' => '002_normalize_breadcrumb_separator', 'file' => 'migrations/002_normalize_breadcrumb_separator.sql'), + array('id' => '003_remove_unused_constants', 'file' => 'migrations/003_remove_unused_constants.sql'), + array('id' => '004_seed_core_display_defaults', 'file' => 'migrations/004_seed_core_display_defaults.sql'), + array('id' => '005_order_system_navigation', 'file' => 'migrations/005_order_system_navigation.php'), + array('id' => '006_create_admin_saved_views', 'file' => 'migrations/006_create_admin_saved_views.sql'), + ), + + 'routes' => array( + array('GET', '/settings', array(\App\Adminx\Settings\Controller::class, 'index')), + array('GET', '/settings/main', array(\App\Adminx\Settings\Controller::class, 'page')), + array('GET', '/settings/interface', array(\App\Adminx\Settings\Controller::class, 'page')), + array('GET', '/settings/security', array(\App\Adminx\Settings\Controller::class, 'page')), + array('GET', '/settings/constants', array(\App\Adminx\Settings\Controller::class, 'page')), + array('GET', '/settings/paginations', array(\App\Adminx\Settings\Controller::class, 'page')), + array('GET', '/settings/maintenance', array(\App\Adminx\Settings\Controller::class, 'page')), + array('GET', '/settings/diagnostics', array(\App\Adminx\Settings\Controller::class, 'page')), + array('GET', '/settings/files', array(\App\Adminx\Settings\Controller::class, 'page')), + array('POST', '/settings', array(\App\Adminx\Settings\Controller::class, 'save')), + array('POST', '/settings/interface', array(\App\Adminx\Settings\Controller::class, 'saveInterface')), + array('POST', '/settings/security', array(\App\Adminx\Settings\Controller::class, 'saveSecurity')), + array('GET', '/settings/paginations/{id}', array(\App\Adminx\Settings\Controller::class, 'pagination')), + array('POST', '/settings/paginations', array(\App\Adminx\Settings\Controller::class, 'savePagination')), + array('POST', '/settings/paginations/{id}', array(\App\Adminx\Settings\Controller::class, 'savePagination')), + array('POST', '/settings/paginations/{id}/delete', array(\App\Adminx\Settings\Controller::class, 'deletePagination')), + array('POST', '/settings/cache/{source}/clear', array(\App\Adminx\Settings\Controller::class, 'clearCache')), + array('POST', '/settings/maintenance/{target}/clear', array(\App\Adminx\Settings\Controller::class, 'clearMaintenance')), + array('GET', '/settings/files/{code}', array(\App\Adminx\Settings\Controller::class, 'systemFile')), + array('POST', '/settings/files/{code}', array(\App\Adminx\Settings\Controller::class, 'saveSystemFile')), + array('GET', '/settings/constants/{name}', array(\App\Adminx\Settings\Controller::class, 'constant')), + array('POST', '/settings/constants', array(\App\Adminx\Settings\Controller::class, 'saveConstant')), + array('POST', '/settings/constants/thumbnail-sizes/scan', array(\App\Adminx\Settings\Controller::class, 'scanThumbnailSizes')), + array('POST', '/settings/constants/{name}/delete', array(\App\Adminx\Settings\Controller::class, 'deleteConstant')), + ), + + 'view_globals' => array( + 'module_code' => 'settings', + ), + ); diff --git a/adminx/modules/Settings/view/index.twig b/adminx/modules/Settings/view/index.twig new file mode 100644 index 0000000..c5178d3 --- /dev/null +++ b/adminx/modules/Settings/view/index.twig @@ -0,0 +1,591 @@ +{% extends '@adminx/main.twig' %} +{% block title %}{{ settings_title }}{% endblock %} +{% block content %} +
    + + + + + +{% if settings_page == 'main' %} +
    +
    +
    +
    +
    Настройки
    +

    Основные

    +

    Системные параметры сайта, почты, пагинации и хлебных крошек.

    +
    +
    + +
    + {% for code, group in groups %} +
    {{ group.fields|length }}
    {{ group.label }}
    + {% endfor %} +
    + +
    + + +
    + {% for code, group in groups %} +
    +
    +
    + +
    +

    {{ group.label }}

    +

    {{ group.description }}

    +
    +
    + +
    + +
    + {% for field in group.fields %} + {% if field.section|default('') %}
    {{ field.section }}
    {% endif %} + {% set wide = field.type in ['text', 'section_order'] %} + {% set depends = field.depends is defined ? field.depends : null %} +
    + + + {% if field.type == 'section_order' %} + +
    + {% for item in field.order_items %} +
    + + + {{ item.label }}{{ item.code }} + + +
    + {% endfor %} +
    + {% elseif field.type == 'text' %} + + {% elseif field.type == 'select' %} + + {% elseif field.type == 'bool' %} + + {% else %} + + {% endif %} + + {% if field.hint|default('') %}{{ field.hint }}{% elseif field.theme_key is not defined %}{{ field.key }}{% endif %} + +
    + {% endfor %} +
    +
    + {% endfor %} +
    +
    +
    +{% endif %} + +{% if settings_page == 'security' %} +
    +
    +
    +
    Безопасность Adminx

    Подтверждение действий

    Дополнительная проверка перед изменением исполняемого кода и установкой модулей.

    +
    + +
    На production защиту лучше оставить включённой

    Одно успешное подтверждение действует 5 минут. Отключение не меняет права ролей и CSRF-защиту.

    + +
    + +
    +
    +

    Повторный вход

    Пароль перед операциями, которые могут изменить PHP/шаблоны или добавить исполняемые файлы.

    + {{ reauth_enabled ? 'Включено' : 'Выключено' }} +
    +
    + +
    + {% if can_manage %}
    {% endif %} +
    +
    +
    +{% endif %} + +{% if settings_page == 'interface' %} +
    +
    +
    +
    Интерфейс

    Размещение элементов

    Скрытые элементы продолжают работать по прямому адресу и по-прежнему проверяют права доступа.

    +
    + +
    + +
    +
    +
    +

    Левое меню

    Порядок разделов и вложенных пунктов панели.

    + +
    +
    + {% for item in navigation_layout %} +
    + + + {{ item.label }}{{ item.parent ? 'Вложен в ' ~ item.parent : (item.group ?: 'Без группы') }} · {{ item.layout_code }} + + +
    + {% endfor %} +
    +
    + +
    +
    +

    Виджеты дашборда

    Системные блоки и виджеты установленных модулей.

    + +
    +
    + {% for item in dashboard_layout %} +
    + + + {{ item.label }}{{ item.description ?: item.layout_code }}{% if item.description %} · {{ item.layout_code }}{% endif %} + + +
    + {% endfor %} +
    +
    +
    +
    +
    +{% endif %} + +{% if settings_page == 'constants' %} +
    +
    +
    +
    +
    Настройки
    +

    Константы

    +

    Типизированные значения публичного runtime, применяемые со следующего запроса.

    +
    + {% if can_manage %} +
    +
    + +
    +
    + {% endif %} +
    + +
    + {% for group in constant_groups %} +
    +
    +
    + +
    +

    {{ group.label }}

    +

    {{ group.code }}

    +
    +
    + +
    +
    + + + + + + + + {% if can_manage %}{% endif %} + + + + + + + + + {% if can_manage %}{% endif %} + + + + {% for c in group.items %} + + + + + + + {% if can_manage %} + + {% endif %} + + {% endfor %} + +
    ИмяСтатусЗначениеТипОписание
    {{ c.name }}{{ c.usage_label }}{{ c.value_display }}{{ c.type_label }}{{ c.description }} +
    + + {% if not c.is_system %} + + {% endif %} +
    +
    +
    +
    + {% else %} +
    + +
    +
    Константы ещё не созданы
    +
    Добавьте первое типизированное значение. Оно станет доступно публичному runtime со следующего запроса.
    +
    +
    + {% endfor %} +
    +
    +{% endif %} + +{% if settings_page == 'paginations' %} +
    +
    +
    +
    +
    Настройки
    +

    Шаблоны пагинации

    +

    Шаблоны из _paginations, используемые запросами и документами.

    +
    + {% if can_manage %} +
    + +
    + {% endif %} +
    + +
    +
    +
    + +
    +

    Список шаблонов

    +

    Единая таблица шаблонов пагинации.

    +
    +
    + +
    +
    + + + + + + {% if can_manage %}{% endif %} + + + + {% for p in paginations %} + + + + {% if can_manage %} + + {% endif %} + + {% else %} + + {% endfor %} + +
    IDНазвание
    {{ p.id }}{{ p.pagination_name }} +
    + + {% if p.id != 1 %} + + {% endif %} +
    +
    Шаблоны ещё не перенесены
    +
    +
    + +
    +{% endif %} + +{% if settings_page == 'files' %} +
    +
    Настройки

    Системные файлы

    Редактирование разрешённых файлов без доступа к произвольным путям.

    +
    + {% for code, file in system_files %} +
    +

    {{ file.label }}

    {{ file.description }}

    {{ file.writable ? 'доступен' : 'только чтение' }}{% if can_manage %}{% endif %}
    +
    {{ file.path }}{{ file.bytes }} Б
    +
    + {% endfor %} +
    +
    +{% endif %} + +{% if settings_page == 'maintenance' %} +
    +
    +
    +
    +
    Настройки
    +

    Обслуживание

    +

    Очистка актуальных cache-директорий и служебных данных.

    +
    +
    + +
    +
    +
    +
    + +
    +

    Источники кеша

    +

    Только каталоги, которые используются текущим runtime.

    +
    +
    + +
    +
    + + + + + + + {% if can_manage %}{% endif %} + + + + {% for row in cache_rows %} + + + + + {% if can_manage %} + + {% endif %} + + {% endfor %} + +
    ИсточникПутьРазмер
    {{ row.label }}
    {{ row.code }}
    {{ row.path }}{{ row.size }}
    +
    +
    + +
    +

    Служебные данные документов

    Глобальная очистка старых ревизий и подневных счётчиков.

    +
    +
    Ревизии документов{{ maintenance_counts.revisions }} записей{% if can_manage %}{% endif %}
    +
    Подневные просмотры{{ maintenance_counts.views }} записей{% if can_manage %}{% endif %}
    +
    +
    +
    +
    +{% endif %} + +{% if settings_page == 'diagnostics' %} +
    +
    Система

    Диагностика

    Проверка окружения и готовности сайта без изменения данных.

    +
    +
    +

    Готовность production

    Проверки выполняются без отправки писем и создания платежей.

    +
    {{ production_diagnostics.summary.ok }} готовы{% if production_diagnostics.summary.warning %}{{ production_diagnostics.summary.warning }} внимания{% endif %}{% if production_diagnostics.summary.error %}{{ production_diagnostics.summary.error }} ошибок{% endif %}
    +
    +
    + {% for check in production_diagnostics.checks %}
    {{ check.label }}{{ check.detail }}
    {% endfor %} +
    +
    +
    +{% endif %} + +{% if can_manage and settings_page == 'files' %} + +{% endif %} + +{% if can_manage and settings_page == 'constants' %} + +{% endif %} + +{% if can_manage and settings_page == 'paginations' %} + +{% endif %} + +{% if can_manage and settings_page == 'main' %} + +{% endif %} + +
    +{% endblock %} diff --git a/adminx/modules/Templates/Controller.php b/adminx/modules/Templates/Controller.php new file mode 100644 index 0000000..934e878 --- /dev/null +++ b/adminx/modules/Templates/Controller.php @@ -0,0 +1,250 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Templates; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Adminx\Support\CodeEditor; + use App\Common\AdminAssets; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Helpers\Request; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + AdminAssets::addStyle($this->base() . '/modules/Templates/assets/templates.css', 50); + AdminAssets::addScript($this->base() . '/modules/Templates/assets/templates.js', 50); + CodeEditor::useCodeMirror('application/x-httpd-php'); + + $q = Request::getStr('q', ''); + $items = Model::all($q); + + return $this->render('@templates/index.twig', array( + 'templates' => $items, + 'stats' => Model::stats(), + 'filters' => array('q' => $q), + 'tag_groups' => TagRegistry::groups(), + 'can_manage' => Permission::check('manage_templates'), + )); + } + + public function show(array $params = array()) + { + $item = Model::one(isset($params['id']) ? (int) $params['id'] : 0); + if (!$item) { + return $this->error('Шаблон не найден', array(), 404); + } + + return $this->success('', array('data' => $item)); + } + + public function store(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $errors = $this->validate(Request::postAll()); + if (!empty($errors)) { + return $this->error('Проверьте поля формы', $errors); + } + + $id = Model::save(0, Request::postAll(), Auth::id()); + return $this->success('Шаблон создан', array('data' => array('id' => $id), 'redirect' => $this->base() . '/templates')); + } + + public function update(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($id)) { + return $this->error('Шаблон не найден', array(), 404); + } + + $errors = $this->validate(Request::postAll()); + if (!empty($errors)) { + return $this->error('Проверьте поля формы', $errors); + } + + Model::save($id, Request::postAll(), Auth::id()); + return $this->success('Шаблон сохранён', array('redirect' => $this->base() . '/templates')); + } + + public function copy(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + Model::copy(isset($params['id']) ? (int) $params['id'] : 0, Request::postStr('title', ''), Auth::id()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Копия создана', array('redirect' => $this->base() . '/templates')); + } + + public function revisions(array $params = array()) + { + $id = isset($params['id']) ? (int) $params['id'] : 0; + $item = Model::one($id); + if (!$item) { + return $this->error('Шаблон не найден', array(), 404); + } + + return $this->success('', array( + 'data' => array( + 'template' => array('id' => $item['Id'], 'title' => $item['template_title']), + 'revisions' => Revisions::listForTemplate($id), + ), + )); + } + + public function revision(array $params = array()) + { + $revision = Revisions::one(isset($params['revision']) ? (int) $params['revision'] : 0); + if (!$revision) { + return $this->error('Ревизия не найдена', array(), 404); + } + + return $this->success('', array('data' => $revision)); + } + + public function restoreRevision(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $templateId = Revisions::restore(isset($params['revision']) ? (int) $params['revision'] : 0, Auth::id()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Шаблон восстановлен из ревизии', array('data' => array('id' => $templateId), 'redirect' => $this->base() . '/templates')); + } + + public function deleteRevision(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $templateId = Revisions::delete(isset($params['revision']) ? (int) $params['revision'] : 0); + if (!$templateId) { + return $this->error('Ревизия не найдена', array(), 404); + } + + return $this->success('Ревизия удалена', array('data' => array('id' => $templateId))); + } + + public function deleteRevisions(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::one($id)) { + return $this->error('Шаблон не найден', array(), 404); + } + + $count = Revisions::deleteForTemplate($id); + return $this->success('Ревизии удалены', array('data' => array('id' => $id, 'count' => $count))); + } + + public function lint(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $result = Syntax::check(Request::postStr('template_text', '')); + if (!$result['ok']) { + return $this->error($result['message'], array('template_text' => $result['output']), 422); + } + + return $this->success($result['message'], array('data' => $result)); + } + + public function clearCache(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + $item = Model::one(isset($params['id']) ? (int) $params['id'] : 0); + if (!$item) { + return $this->error('Шаблон не найден', array(), 404); + } + + Model::clearCache($item['Id']); + return $this->success('Файловый кеш шаблона очищен'); + } + + public function destroy(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + if (!Model::delete(isset($params['id']) ? (int) $params['id'] : 0, Auth::id())) { + return $this->error('Шаблон не найден', array(), 404); + } + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Шаблон удалён'); + } + + protected function guard() + { + return $this->guardPermission('manage_templates'); + } + + protected function validate(array $input) + { + $errors = array(); + $title = trim(isset($input['template_title']) ? (string) $input['template_title'] : ''); + $text = isset($input['template_text']) ? (string) $input['template_text'] : ''; + + if ($title === '') { + $errors['template_title'] = 'Укажите название'; + } + + if (trim($text) === '') { + $errors['template_text'] = 'Укажите код шаблона'; + } + + $syntax = Syntax::check($text); + if (!empty($syntax['checked']) && empty($syntax['ok'])) { + $errors['template_text'] = $syntax['line'] > 0 + ? 'PHP-синтаксис: ошибка на строке ' . (int) $syntax['line'] + : 'PHP-синтаксис: ошибка в коде шаблона'; + } + + return $errors; + } + } diff --git a/adminx/modules/Templates/Model.php b/adminx/modules/Templates/Model.php new file mode 100644 index 0000000..3f82694 --- /dev/null +++ b/adminx/modules/Templates/Model.php @@ -0,0 +1,335 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Templates; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Common\FileCacheInvalidator; + use App\Common\SystemTables; + use App\Content\ContentTables; + use App\Content\Templates\TemplateMutationService; + use App\Frontend\PageTemplateRepository; + use App\Helpers\File; + + class Model + { + public static function table() + { + return ContentTables::table('templates'); + } + + public static function rubricsTable() + { + return ContentTables::table('rubrics'); + } + + public static function all($q = '') + { + $sql = 'SELECT t.*, u.name AS author_name, u.login AS author_login, u.email AS author_email' + . ', ' . self::usedCountExpression() . ' AS used_count' + . ' FROM ' . self::table() . ' t' + . ' LEFT JOIN ' . SystemTables::table('users') . ' u ON u.id = t.template_author_id' + . ' WHERE 1=1'; + $args = array(); + $q = trim((string) $q); + if ($q !== '') { + $sql .= ' AND (t.template_title LIKE %ss OR t.template_text LIKE %ss OR t.Id = %i)'; + $args[] = $q; + $args[] = $q; + $args[] = (int) $q; + } + + $sql .= ' ORDER BY t.Id ASC'; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = self::row($row); + } + + return $out; + } + + public static function one($id) + { + $row = DB::query( + 'SELECT t.*, u.name AS author_name, u.login AS author_login, u.email AS author_email' + . ', ' . self::usedCountExpression() . ' AS used_count' + . ' FROM ' . self::table() . ' t' + . ' LEFT JOIN ' . SystemTables::table('users') . ' u ON u.id = t.template_author_id' + . ' WHERE t.Id = %i LIMIT 1', + (int) $id + )->getAssoc(); + return $row ? self::row($row, true) : null; + } + + public static function raw($id) + { + $row = DB::query('SELECT * FROM ' . self::table() . ' WHERE Id = %i LIMIT 1', (int) $id)->getAssoc(); + return $row ? $row : null; + } + + public static function revisionFields() + { + return array('Id', 'template_title', 'template_text', 'template_author_id', 'template_created'); + } + + public static function snapshot(array $row) + { + $out = array(); + foreach (self::revisionFields() as $field) { + $out[$field] = isset($row[$field]) ? $row[$field] : ''; + } + + $out['Id'] = (int) $out['Id']; + $out['template_author_id'] = (int) $out['template_author_id']; + $out['template_created'] = (int) $out['template_created']; + return $out; + } + + public static function applySnapshot($id, array $snapshot, $authorId) + { + $id = (int) $id; + $current = self::raw($id); + if (!$current) { + throw new \RuntimeException('Шаблон не найден'); + } + + $data = self::snapshot($snapshot); + unset($data['Id']); + $data['template_author_id'] = (int) $authorId > 0 ? (int) $authorId : (int) $data['template_author_id']; + DB::Update(self::table(), $data, 'Id = %i', $id); + self::writeCache($id, $data['template_text']); + return $id; + } + + public static function stats() + { + return array( + 'total' => (int) DB::query('SELECT COUNT(*) FROM ' . self::table())->getValue(), + 'used' => self::tableExists(self::rubricsTable()) ? (int) DB::query('SELECT COUNT(DISTINCT rubric_template_id) FROM ' . self::rubricsTable())->getValue() : 0, + 'php' => (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE template_text LIKE %s', '%getValue(), + 'bytes' => (int) DB::query('SELECT COALESCE(SUM(CHAR_LENGTH(template_text)), 0) FROM ' . self::table())->getValue(), + ); + } + + public static function rebuildCaches() + { + $rows = DB::query('SELECT Id, template_text FROM ' . self::table())->getAll(); + $count = 0; + foreach ($rows as $row) { + self::writeCache((int) $row['Id'], (string) $row['template_text']); + $count++; + } + + return $count; + } + + public static function save($id, array $input, $authorId) + { + $isUpdate = (int) $id > 0; + $templateId = (new TemplateMutationService())->save((int) $id, array( + 'template_title' => isset($input['template_title']) ? $input['template_title'] : '', + 'template_text' => isset($input['template_text']) ? $input['template_text'] : '', + ), (int) $authorId); + Revisions::capture( + $templateId, + $isUpdate ? 'update' : 'create', + (int) $authorId, + $isUpdate ? 'Сохранение шаблона' : 'Создание шаблона' + ); + return $templateId; + } + + public static function copy($id, $title, $authorId) + { + $row = self::one($id); + if (!$row) { + throw new \RuntimeException('Шаблон не найден'); + } + + $title = trim((string) $title); + if ($title === '') { + $title = $row['template_title'] . ' (копия)'; + } + + $data = array( + 'template_title' => self::uniqueTitle($title), + 'template_text' => self::encodeText((string) $row['template_text']), + 'template_author_id' => (int) $authorId > 0 ? (int) $authorId : 1, + 'template_created' => time(), + ); + DB::Insert(self::table(), $data); + $newId = (int) DB::insertId(); + self::writeCache($newId, $data['template_text']); + Revisions::capture($newId, 'copy', (int) $authorId, 'Копия шаблона #' . (int) $id); + return $newId; + } + + public static function delete($id, $authorId = 0) + { + $id = (int) $id; + if ($id === 1) { + throw new \RuntimeException('Шаблон #1 является системным и не может быть удалён'); + } + + if (self::usedCount($id) > 0) { + throw new \RuntimeException('Шаблон используется рубриками и не может быть удалён'); + } + + $raw = self::raw($id); + if (!$raw) { + return false; + } + + Revisions::capture($id, 'delete', (int) $authorId, 'Удаление шаблона', $raw); + return (new TemplateMutationService())->delete($id); + } + + public static function clearCache($id) + { + self::removeCache((int) $id); + } + + public static function usedCount($id) + { + if (!self::tableExists(self::rubricsTable())) { + return 0; + } + + return (int) DB::query('SELECT COUNT(*) FROM ' . self::rubricsTable() . ' WHERE rubric_template_id = %i', (int) $id)->getValue(); + } + + protected static function usedCountExpression() + { + if (!self::tableExists(self::rubricsTable())) { + return '0'; + } + + return '(SELECT COUNT(*) FROM ' . self::rubricsTable() . ' r WHERE r.rubric_template_id = t.Id)'; + } + + protected static function tableExists($table) + { + return (bool) DB::query('SHOW TABLES LIKE %s', (string) $table)->getValue(); + } + + protected static function input(array $input) + { + return array( + 'template_title' => trim(isset($input['template_title']) ? (string) $input['template_title'] : ''), + 'template_text' => self::encodeText(isset($input['template_text']) ? (string) $input['template_text'] : ''), + ); + } + + protected static function row(array $row, $withText = false) + { + $author = ''; + if (!empty($row['author_name'])) { + $author = (string) $row['author_name']; + } elseif (!empty($row['author_login'])) { + $author = '@' . (string) $row['author_login']; + } elseif (!empty($row['author_email'])) { + $author = (string) $row['author_email']; + } + + $cacheText = self::readCache((int) $row['Id']); + // Legacy templates are escaped in SQL. The editor must always return + // the database value decoded; an old cache may still contain slashes. + $text = self::decodeText((string) $row['template_text']); + $row['template_text'] = $text; + $row['Id'] = (int) $row['Id']; + $row['id'] = (int) $row['Id']; + $row['template_author_id'] = (int) $row['template_author_id']; + $row['template_created'] = (int) $row['template_created']; + $row['used_count'] = isset($row['used_count']) ? (int) $row['used_count'] : self::usedCount($row['Id']); + $row['author'] = $author !== '' ? $author : ('#' . $row['template_author_id']); + $row['created_label'] = $row['template_created'] > 0 ? date('d.m.Y H:i', $row['template_created']) : '-'; + $row['text_size'] = strlen($text); + $row['text_size_label'] = self::formatBytes($row['text_size']); + $row['has_php'] = Syntax::hasPhp($text); + $row['cache_exists'] = $cacheText !== null; + $row['can_delete'] = $row['Id'] !== 1 && $row['used_count'] <= 0; + return $row; + } + + protected static function uniqueTitle($base) + { + $base = trim((string) $base); + if ($base === '') { + $base = 'Новый шаблон'; + } + + $title = $base; + $i = 2; + while (DB::query('SELECT Id FROM ' . self::table() . ' WHERE template_title = %s LIMIT 1', $title)->getValue()) { + $title = $base . ' #' . $i; + $i++; + } + + return $title; + } + + protected static function cacheFile($id) + { + return PageTemplateRepository::cacheFilePath((int) $id); + } + + protected static function readCache($id) + { + $file = self::cacheFile((int) $id); + return is_file($file) ? File::getContent($file) : null; + } + + protected static function writeCache($id, $text) + { + $file = self::cacheFile((int) $id); + PageTemplateRepository::prepareCacheDirectory($file); + File::putAtomic($file, self::decodeText((string) $text)); + FileCacheInvalidator::pageTemplates(); + } + + public static function decodeText($text) + { + return stripslashes((string) $text); + } + + protected static function encodeText($text) + { + return addslashes((string) $text); + } + + protected static function formatBytes($bytes) + { + $bytes = (int) $bytes; + if ($bytes < 1024) { + return $bytes . ' Б'; + } + + if ($bytes < 1048576) { + return round($bytes / 1024, 1) . ' KB'; + } + + return round($bytes / 1048576, 1) . ' MB'; + } + + protected static function removeCache($id) + { + $file = self::cacheFile((int) $id); + if ($file !== null && is_file($file)) { + File::delete($file); + } + } + } diff --git a/adminx/modules/Templates/Revisions.php b/adminx/modules/Templates/Revisions.php new file mode 100644 index 0000000..61439b5 --- /dev/null +++ b/adminx/modules/Templates/Revisions.php @@ -0,0 +1,242 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Templates; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Common\SystemTables; + use App\Content\ContentTables; + use App\Helpers\Json; + + class Revisions + { + public static function table() + { + return ContentTables::table('template_revisions'); + } + + public static function labels() + { + return array( + 'create' => array('label' => 'Создание', 'badge' => 'badge-green'), + 'update' => array('label' => 'Сохранение', 'badge' => 'badge-blue'), + 'copy' => array('label' => 'Копия', 'badge' => 'badge-violet'), + 'import' => array('label' => 'Импорт', 'badge' => 'badge-amber'), + 'restore' => array('label' => 'Восстановление', 'badge' => 'badge-cyan'), + 'restore_backup' => array('label' => 'Перед восстановлением', 'badge' => 'badge-gray'), + 'delete' => array('label' => 'Удаление', 'badge' => 'badge-red'), + ); + } + + public static function listForTemplate($templateId, $limit = 50) + { + $rows = DB::query( + 'SELECT * FROM ' . self::table() . ' WHERE template_id = %i ORDER BY created_at DESC, id DESC LIMIT %i', + (int) $templateId, + max(1, min(200, (int) $limit)) + )->getAll(); + $out = array(); + foreach ($rows as $row) { + $out[] = self::format($row, false); + } + + return $out; + } + + public static function one($id) + { + $row = DB::query('SELECT * FROM ' . self::table() . ' WHERE id = %i LIMIT 1', (int) $id)->getAssoc(); + return $row ? self::format($row, true) : null; + } + + public static function capture($templateId, $action, $authorId = 0, $comment = '', array $snapshot = null, $sourceRevisionId = 0) + { + $templateId = (int) $templateId; + if ($snapshot === null) { + $snapshot = Model::raw($templateId); + } + + if (!$snapshot) { + return 0; + } + + $snapshot = Model::snapshot($snapshot); + $json = Json::encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + $json = '{}'; + } + + $snapshotHash = sha1($json); + $textHash = sha1((string) $snapshot['template_text']); + + if (in_array($action, array('update', 'import', 'restore'), true)) { + $last = DB::query( + 'SELECT snapshot_hash FROM ' . self::table() . ' WHERE template_id = %i ORDER BY created_at DESC, id DESC LIMIT 1', + $templateId + )->getValue(); + if ($last && (string) $last === $snapshotHash) { + return 0; + } + } + + DB::Insert(self::table(), array( + 'template_id' => $templateId, + 'action' => (string) $action, + 'snapshot_hash' => $snapshotHash, + 'text_hash' => $textHash, + 'snapshot_json' => $json, + 'comment' => trim((string) $comment), + 'author_id' => (int) $authorId, + 'author_name' => self::authorName((int) $authorId), + 'source_revision_id' => (int) $sourceRevisionId, + 'created_at' => time(), + )); + + return (int) DB::insertId(); + } + + public static function captureCurrent($action, $authorId = 0, $comment = '') + { + $rows = DB::query('SELECT * FROM ' . Model::table() . ' ORDER BY Id ASC')->getAll(); + $count = 0; + foreach ($rows as $row) { + if (self::capture((int) $row['Id'], $action, (int) $authorId, $comment, $row) > 0) { + $count++; + } + } + + return $count; + } + + public static function restore($revisionId, $authorId = 0) + { + $revision = self::one($revisionId); + if (!$revision || empty($revision['snapshot'])) { + throw new \RuntimeException('Ревизия не найдена'); + } + + $templateId = (int) $revision['template_id']; + $current = Model::raw($templateId); + if (!$current) { + throw new \RuntimeException('Шаблон не найден'); + } + + self::capture($templateId, 'restore_backup', (int) $authorId, 'Снимок перед восстановлением', $current, (int) $revisionId); + Model::applySnapshot($templateId, $revision['snapshot'], (int) $authorId); + self::capture($templateId, 'restore', (int) $authorId, 'Восстановлено из ревизии #' . (int) $revisionId, Model::raw($templateId), (int) $revisionId); + + return $templateId; + } + + public static function delete($revisionId) + { + $revision = self::one($revisionId); + if (!$revision) { + return false; + } + + DB::Delete(self::table(), 'id = %i', (int) $revisionId); + return (int) $revision['template_id']; + } + + public static function deleteForTemplate($templateId) + { + $templateId = (int) $templateId; + if ($templateId <= 0) { + return 0; + } + + $count = (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE template_id = %i', $templateId)->getValue(); + DB::Delete(self::table(), 'template_id = %i', $templateId); + return $count; + } + + protected static function format(array $row, $withSnapshot) + { + $labels = self::labels(); + $action = isset($row['action']) ? (string) $row['action'] : 'update'; + $meta = isset($labels[$action]) ? $labels[$action] : array('label' => $action, 'badge' => 'badge-gray'); + $snapshot = null; + if ($withSnapshot) { + $snapshot = Json::toArray((string) $row['snapshot_json']); + } + + $created = isset($row['created_at']) ? (int) $row['created_at'] : 0; + $text = $withSnapshot && is_array($snapshot) && isset($snapshot['template_text']) + ? Model::decodeText((string) $snapshot['template_text']) + : ''; + + return array( + 'id' => (int) $row['id'], + 'template_id' => (int) $row['template_id'], + 'action' => $action, + 'action_label' => $meta['label'], + 'badge' => $meta['badge'], + 'comment' => (string) $row['comment'], + 'author_id' => (int) $row['author_id'], + 'author_name' => (string) $row['author_name'], + 'created_at' => $created, + 'created_label' => $created > 0 ? date('d.m.Y H:i:s', $created) : '-', + 'source_revision_id' => (int) $row['source_revision_id'], + 'snapshot_hash' => (string) $row['snapshot_hash'], + 'text_hash' => (string) $row['text_hash'], + 'text_size' => strlen($text), + 'text_size_label' => $withSnapshot ? self::formatBytes(strlen($text)) : '', + 'snapshot' => $snapshot, + 'code' => $text, + ); + } + + protected static function authorName($id) + { + if ((int) $id <= 0) { + return ''; + } + + $row = DB::query('SELECT name, login, email FROM ' . SystemTables::table('users') . ' WHERE id = %i LIMIT 1', (int) $id)->getAssoc(); + if (!$row) { + return '#' . (int) $id; + } + + if (!empty($row['name'])) { + return (string) $row['name']; + } + + if (!empty($row['login'])) { + return '@' . (string) $row['login']; + } + + if (!empty($row['email'])) { + return (string) $row['email']; + } + + return '#' . (int) $id; + } + + protected static function formatBytes($bytes) + { + $bytes = (int) $bytes; + if ($bytes < 1024) { + return $bytes . ' Б'; + } + + if ($bytes < 1048576) { + return round($bytes / 1024, 1) . ' KB'; + } + + return round($bytes / 1048576, 1) . ' MB'; + } + } diff --git a/adminx/modules/Templates/Syntax.php b/adminx/modules/Templates/Syntax.php new file mode 100644 index 0000000..b4967b7 --- /dev/null +++ b/adminx/modules/Templates/Syntax.php @@ -0,0 +1,74 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Templates; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + class Syntax + { + public static function check($code) + { + $code = (string) $code; + if (!self::hasPhp($code)) { + return array('ok' => true, 'checked' => false, 'message' => 'PHP-код не найден, проверка не нужна.', 'line' => 0, 'output' => ''); + } + + $insidePhp = self::endsInsidePhp($code); + $wrapped = 'if (false) { ?>' . $code . ($insidePhp ? ' }' : ' true, + 'checked' => true, + 'message' => 'Синтаксис PHP без ошибок.', + 'line' => 0, + 'output' => '', + ); + } catch (\ParseError $e) { + $line = max(1, (int) $e->getLine() - 1); + $message = preg_replace('/\s+in .*$/', '', (string) $e->getMessage()); + return array( + 'ok' => false, + 'checked' => true, + 'message' => 'В PHP-коде есть синтаксическая ошибка.', + 'line' => $line, + 'output' => $message . ' (строка ' . $line . ')', + ); + } + } + + public static function hasPhp($code) + { + return preg_match('/<\\?(php|=|\\s)/i', (string) $code) === 1; + } + + protected static function endsInsidePhp($code) + { + $inside = false; + foreach (token_get_all((string) $code) as $token) { + if (!is_array($token)) { + continue; + } + + if ($token[0] === T_OPEN_TAG || $token[0] === T_OPEN_TAG_WITH_ECHO) { + $inside = true; + } elseif ($token[0] === T_CLOSE_TAG) { + $inside = false; + } + } + + return $inside; + } + } diff --git a/adminx/modules/Templates/TagRegistry.php b/adminx/modules/Templates/TagRegistry.php new file mode 100644 index 0000000..e34b228 --- /dev/null +++ b/adminx/modules/Templates/TagRegistry.php @@ -0,0 +1,169 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Templates; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Content\ContentTables; + + class TagRegistry + { + public static function groups() + { + return array( + self::group('Основные', array( + self::tag('[tag:theme:folder]', 'Папка темы', 'folder'), + self::tag('[tag:sitename]', 'Название сайта'), + self::tag('[tag:maincontent]', 'Основной контент'), + self::tag('[tag:title]', 'Заголовок'), + self::tag('[tag:excerpt]', 'Краткое описание'), + self::tag('[tag:alias]', 'Alias'), + self::tag('[tag:domain]', 'Домен'), + self::tag('[tag:home]', 'Главная'), + )), + self::group('SEO', array( + self::tag('[tag:keywords]', 'Keywords'), + self::tag('[tag:description]', 'Description'), + self::tag('[tag:robots]', 'Robots'), + self::tag('[tag:canonical]', 'Canonical'), + )), + self::group('Пути и файлы', array( + self::tag('[tag:path]', 'Корень'), + self::tag('[tag:mediapath]', 'Медиа-путь'), + self::tag('[tag:theme-styles]', 'CSS из реестра активной темы'), + self::tag('[tag:theme-scripts]', 'JavaScript из реестра активной темы'), + self::tag('[tag:asset:css/app.css]', 'Версионированный URL файла темы', 'css/app.css'), + )), + self::group('Композиция', array( + self::tag('[tag:rubheader]', 'Header рубрики'), + self::tag('[tag:rubfooter]', 'Footer рубрики'), + self::tag('[tag:breadcrumb]', 'Хлебные крошки'), + self::tag('[tag:printlink]', 'Печать'), + self::tag('[tag:version]', 'Версия'), + )), + self::group('Связанные данные', array( + self::tag('[tag:doc:XXX]', 'Поле документа', 'XXX'), + self::tag('[tag:sysblock:XXX]', 'Системный блок', 'XXX'), + self::tag('[tag:teaser:XXX]', 'Тизер', 'XXX'), + self::tag('[tag:navigation:XXX]', 'Навигация', 'XXX'), + )), + self::group('Системные блоки', self::sysblockTags()), + self::group('Навигации', self::navigationTags()), + self::group('Условия', array( + self::tag("[tag:if_print]\n\n[/tag:if_print]", 'Для печати', '', '[tag:if_print]'), + self::tag("[tag:if_notprint]\n\n[/tag:if_notprint]", 'Не печать', '', '[tag:if_notprint]'), + self::tag("[tag:if_payment_program:sfr]\n\n[tag:if:else]\n\n[tag:/if_payment_program]", 'Оплата через СФР', 'Условие доступности товара для программы СФР', '[tag:if_payment_program:sfr]'), + self::tag('[tag:payment_program_code:sfr]', 'Код СФР', 'Информационный код СФР текущего товара', '[tag:payment_program_code:sfr]'), + )), + self::group('HTML', array( + self::tag("
      \n\n
    ", 'Список', '', 'OL'), + self::tag("
      \n\n
    ", 'Список', '', 'UL'), + self::tag('
  • ', 'Пункт', '', 'LI'), + self::tag('

    ', 'Абзац', '', 'P'), + self::tag('', 'Жирный', '', 'B'), + self::tag('', 'Курсив', '', 'I'), + self::tag('

    ', 'Заголовок', '', 'H1'), + self::tag('

    ', 'Заголовок', '', 'H2'), + self::tag('

    ', 'Заголовок', '', 'H3'), + self::tag("
    \n\n
    ", 'Блок', '', 'DIV'), + self::tag('', 'Ссылка', '', 'A'), + self::tag('', 'Изображение', '', 'IMG'), + self::tag('', 'Span', '', 'SPAN'), + self::tag("
    \n\n
    ", 'Pre', '', 'PRE'), + self::tag('
    ', 'Перенос', '', 'BR'), + self::tag("\t", 'Табуляция', '', 'TAB'), + )), + ); + } + + protected static function sysblockTags() + { + $table = ContentTables::table('sysblocks'); + if (!self::tableExists($table)) { + return array(); + } + + $rows = DB::query( + 'SELECT id, sysblock_name, sysblock_alias, sysblock_active' + . ' FROM ' . $table + . ' ORDER BY sysblock_active DESC, sysblock_name ASC, id ASC' + . ' LIMIT 200' + )->getAll(); + + $items = array(); + foreach ($rows as $row) { + $id = (int) $row['id']; + $name = trim((string) $row['sysblock_name']); + $alias = trim((string) $row['sysblock_alias']); + $key = $alias !== '' ? $alias : (string) $id; + $description = $name !== '' ? $name : ('Системный блок #' . $id); + if ((string) $row['sysblock_active'] !== '1') { + $description .= ' · выключен'; + } + + $items[] = self::tag('[tag:sysblock:' . $key . ']', $description, '', '[tag:sysblock:' . $key . ']'); + } + + return $items; + } + + protected static function navigationTags() + { + $table = ContentTables::table('navigation'); + if (!self::tableExists($table)) { + return array(); + } + + $rows = DB::query( + 'SELECT navigation_id, alias, title' + . ' FROM ' . $table + . ' ORDER BY title ASC, navigation_id ASC' + . ' LIMIT 200' + )->getAll(); + + $items = array(); + foreach ($rows as $row) { + $id = (int) $row['navigation_id']; + $title = trim((string) $row['title']); + $alias = trim((string) $row['alias']); + $key = $alias !== '' ? $alias : (string) $id; + $description = $title !== '' ? $title : ('Навигация #' . $id); + $items[] = self::tag('[tag:navigation:' . $key . ']', $description, '', '[tag:navigation:' . $key . ']'); + } + + return $items; + } + + protected static function tableExists($table) + { + return (bool) DB::query('SHOW TABLES LIKE %s', (string) $table)->getValue(); + } + + protected static function group($title, array $items) + { + return array('title' => (string) $title, 'items' => $items); + } + + protected static function tag($value, $description, $select = '', $label = '') + { + return array( + 'value' => (string) $value, + 'description' => (string) $description, + 'select' => (string) $select, + 'label' => (string) ($label !== '' ? $label : $value), + ); + } + } diff --git a/adminx/modules/Templates/assets/templates.css b/adminx/modules/Templates/assets/templates.css new file mode 100644 index 0000000..aba1cd7 --- /dev/null +++ b/adminx/modules/Templates/assets/templates.css @@ -0,0 +1,674 @@ +.templates-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 14px; + margin-bottom: 16px; +} +.templates-panel a, +.templates-panel a:hover { + text-decoration: none; +} +.templates-stat { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; +} +.templates-stat b { + display: block; + font-size: 20px; + line-height: 1.1; + font-weight: 800; + font-variant-numeric: tabular-nums; +} +.templates-stat span { + font-size: 13px; + color: var(--text-secondary); +} +.templates-import-card { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; + padding: 15px 16px; +} +.templates-import-title { + margin-bottom: 3px; + font-weight: 800; + color: var(--text-primary); +} +.templates-panel-header { + margin-bottom: 18px; +} +.templates-panel-header h2 { + font-size: 22px; + line-height: 1.2; + font-weight: 800; + letter-spacing: 0; + text-wrap: balance; +} +.templates-card { + padding: 0; + overflow: hidden; +} +.templates-filter-card { + margin-bottom: 14px; +} +.templates-panel .table-scroll { + max-height: none; + overflow-x: hidden; + overflow-y: hidden; +} +.templates-panel table.table { + min-width: 0; +} +.templates-panel .table th { + white-space: normal; +} +.templates-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid var(--border-default); +} +.templates-section-title { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} +.templates-section-title h2 { + margin: 0 0 4px; + font-size: 16px; + line-height: 1.25; + font-weight: 700; + text-wrap: balance; +} +.templates-section-title p { + margin: 0; + font-size: 13px; + line-height: 1.4; + text-wrap: pretty; +} +.templates-head-icon { + width: 34px; + height: 34px; + border-radius: var(--radius-md); + font-size: 17px; +} +.templates-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.templates-filter { + display: flex; + align-items: center; + flex: 1 1 auto; + gap: 10px; + min-width: 0; +} +.templates-filter .input-wrap { + flex: 1 1 320px; + min-width: 240px; + max-width: 560px; +} +.templates-table { + width: 100%; + min-width: 0; + table-layout: fixed; +} +.templates-col-id { + width: 6ch; +} +.templates-col-title { + width: 42%; +} +.templates-col-state { + width: 24%; +} +.templates-col-meta { + width: 20%; +} +.templates-col-actions { + width: 246px; +} +.templates-name { + display: grid; + gap: 3px; + min-width: 0; +} +.templates-name b, +.templates-name small { + overflow-wrap: anywhere; + white-space: normal; +} +.templates-name small { + color: var(--text-secondary); + font-size: 12px; +} +.templates-badges { + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.templates-meta { + display: grid; + gap: 2px; + color: var(--text-secondary); + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.templates-actions { + gap: 4px; + flex-wrap: nowrap; + justify-content: flex-end; + width: max-content; + margin-left: auto; +} +.templates-actions .btn { + transition-property: background-color, color, transform; + transition-duration: 150ms; +} +.templates-actions .btn:active { + transform: scale(0.96); +} +.templates-action-edit { + color: var(--blue-600); +} +.templates-action-copy { + color: var(--violet-600); +} +.templates-action-revisions { + color: var(--cyan-600); +} +.templates-action-cache { + color: var(--green-600); +} +.templates-action-danger { + color: var(--red-600); +} +.templates-action-locked { + color: var(--text-muted); +} +.templates-drawer .drawer-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + padding-bottom: 0; + overflow-x: hidden; +} +.templates-drawer { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow-x: hidden; +} +#templateDrawer, +#templateRevisionsDrawer { + width: min(1040px, 94vw); + overflow-x: hidden; +} +#templateDrawer { + width: min(66vw, 1320px); + max-width: calc(100vw - 34px); +} +#templateRevisionsDrawer { + width: min(66vw, 1320px); + max-width: calc(100vw - 34px); +} +#templateDrawer[hidden], +#templateRevisionsDrawer[hidden] { + display: none !important; +} +#templateDrawer .drawer-header, +#templateRevisionsDrawer .drawer-header { + align-items: flex-start; +} +.templates-drawer-title { + flex: 1 1 auto; + min-width: 0; +} +#templateDrawer .drawer-header [data-close-drawer], +#templateRevisionsDrawer .drawer-header [data-close-drawer] { + flex: 0 0 auto; + margin-left: auto; +} +.templates-code-field { + position: relative; + display: flex; + flex: 1 1 auto; + flex-direction: column; + margin-top: 14px; + min-width: 0; + min-height: 0; + overflow: visible; +} +.templates-code-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.templates-code-tools { + display: inline-flex; + align-items: center; + gap: 6px; +} +.templates-code-tools [data-tooltip]::after { + top: calc(100% + 7px); + bottom: auto; + z-index: 320; + transform: translateX(-50%) translateY(-3px); +} +.templates-code-tools [data-tooltip]:hover::after, +.templates-code-tools [data-tooltip]:focus-visible::after { + transform: translateX(-50%) translateY(0); +} +.templates-tags-button { + color: var(--cyan-600); +} +.templates-tags-button.is-active { + background: var(--cyan-50); + color: var(--cyan-700); +} +.templates-lint-button { + color: var(--amber-600); +} +.templates-code-fullscreen-button { + color: var(--blue-600); +} +.templates-code-field [data-template-lint-result].is-ok { + color: var(--green-600); +} +.templates-code-field [data-template-lint-result].is-error { + color: var(--red-600); +} +.templates-tag-palette { + position: absolute; + top: 34px; + right: 0; + z-index: 40; + width: min(780px, 100%); + margin: 0; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); + box-shadow: var(--shadow-modal); + overflow: hidden; +} +.templates-tag-palette[hidden] { + display: none !important; +} +.templates-tag-toolbar { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px; + border-bottom: 1px solid var(--border-default); +} +.templates-tag-toolbar .input-wrap { + flex: 1; +} +.templates-tag-browser { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + min-height: 0; +} +.templates-tag-nav { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + padding: 8px; + border-right: 1px solid var(--border-default); + background: var(--background-muted); +} +.templates-tag-nav button { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 28px; + width: 100%; + padding: 6px 8px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + font-size: 12px; + text-align: left; + cursor: pointer; + transition-property: background-color, border-color, color, transform; + transition-duration: 150ms; +} +.templates-tag-nav button:hover { + border-color: var(--border-default); + background: var(--background-surface); + color: var(--color-primary); +} +.templates-tag-nav button:active { + transform: scale(0.96); +} +.templates-tag-nav button.is-active { + border-color: var(--border-default); + background: var(--color-primary-soft); + color: var(--color-primary); +} +.templates-tag-nav span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.templates-tag-nav b { + color: var(--text-muted); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} +.templates-tag-nav button.is-active b { + color: var(--color-primary); +} +.templates-tag-groups { + min-width: 0; +} +.templates-tag-group { + display: none; + min-width: 0; + max-height: 320px; + padding: 8px; + overflow-y: auto; +} +.templates-tag-group.is-active, +.templates-tag-palette.is-searching .templates-tag-group { + display: block; +} +.templates-tag-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 6px; +} +.templates-tag-list button { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + padding: 6px 8px; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: var(--background-surface); + color: var(--text-primary); + text-align: left; + cursor: pointer; + transition-property: background-color, border-color, color, transform; + transition-duration: 150ms; +} +.templates-tag-list button:hover { + border-color: var(--color-primary); + background: var(--color-primary-soft); +} +.templates-tag-list button:active { + transform: scale(0.96); +} +.templates-tag-list code { + min-width: 0; + overflow: hidden; + max-width: 100%; + color: var(--color-primary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} +.templates-tag-list span { + overflow: hidden; + color: var(--text-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} +.templates-tag-empty { + padding: 14px; + color: var(--text-muted); + font-size: 13px; + text-align: center; +} +.templates-tag-empty[hidden] { + display: none !important; +} +.templates-code-field .CodeMirror { + flex: 1 1 auto; + width: 100%; + height: auto !important; + max-width: 100%; + min-height: 0; +} +.templates-code-field .CodeMirror-scroll, +.templates-code-field .CodeMirror-sizer { + max-width: 100%; +} +.templates-code-field.is-fullscreen { + position: fixed; + inset: 16px; + z-index: 260; + display: flex; + flex-direction: column; + margin: 0; + padding: 16px; + border-radius: var(--radius-lg); + background: var(--background-surface); + box-shadow: var(--shadow-modal); +} +.templates-code-field.is-fullscreen .templates-code-label { + flex: 0 0 auto; + margin-bottom: 10px; +} +.templates-code-field.is-fullscreen .CodeMirror { + flex: 1 1 auto; + min-height: 0; + height: auto; +} +.templates-code-field.is-fullscreen .CodeMirror-wrap { + flex: 1 1 auto; +} +.templates-code-field.is-fullscreen::before { + content: ""; + position: fixed; + inset: -16px; + z-index: -1; + background: rgba(15, 23, 42, 0.48); +} +.templates-revisions-drawer { + display: flex; + flex-direction: column; + padding-bottom: 0; + overflow: hidden; +} +.templates-revisions-layout { + display: grid; + grid-template-columns: 1fr; + gap: 14px; + align-items: stretch; + min-height: 0; +} +.templates-revisions-list, +.templates-revision-preview { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); + overflow: hidden; +} +.templates-revisions-list .table-scroll, +.templates-revisions-list-rows { + flex: 1 1 auto; + min-height: 0; + max-height: 320px; + overflow-y: auto; +} +.templates-revisions-head { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-default); +} +.templates-revisions-head h4 { + margin: 0 0 3px; + font-size: 15px; + line-height: 1.25; + font-weight: 800; +} +.templates-revisions-head p { + margin: 0; + font-size: 12px; + line-height: 1.35; +} +.templates-revisions-table { + width: 100%; + min-width: 0; + table-layout: fixed; +} +.templates-revisions-table th:last-child, +.templates-revisions-table td:last-child { + width: 118px; +} +.templates-revision-row { + cursor: pointer; +} +.templates-revision-row.is-active { + background: var(--blue-50); +} +.templates-revision-icon { + width: 30px; + height: 30px; + border-radius: var(--radius-md); + font-size: 14px; +} +.templates-revision-row-main { + display: grid; + flex: 1 1 auto; + gap: 3px; + min-width: 0; +} +.templates-revision-row-main b { + display: inline; + margin-right: 6px; +} +.templates-revision-row-main .badge { + width: max-content; + font-size: 10px; +} +.templates-revision-delete { + color: var(--red-600); +} +.templates-revision-link { + display: grid; + gap: 4px; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.templates-revision-link b, +.templates-revision-link small { + overflow-wrap: anywhere; +} +.templates-revision-link b { + font-size: 12px; + line-height: 1.2; +} +.templates-revision-link small { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.3; +} +.templates-revision-fields { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 14px 16px 0; +} +.templates-revision-fields span { + display: grid; + gap: 3px; + min-width: 0; + padding: 10px 12px; + border-radius: var(--radius-md); + background: var(--background-muted); + box-shadow: inset 0 0 0 1px var(--border-default); +} +.templates-revision-fields b { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.2; + text-transform: uppercase; +} +.templates-revision-fields em { + color: var(--text-primary); + font-size: 12px; + font-style: normal; + line-height: 1.35; + overflow-wrap: anywhere; +} +.templates-revision-code { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + padding: 14px 16px 16px; +} +.templates-revision-code .CodeMirror { + flex: 1 1 auto; + min-height: 360px; +} +@media (max-width: 900px) { + .templates-tag-palette { + right: auto; + left: 0; + width: 100%; + } + .templates-filter { + flex-wrap: wrap; + } + .templates-filter .input-wrap { + flex: 1 1 100%; + max-width: none; + } + .templates-revision-fields { + grid-template-columns: 1fr; + } + .templates-tag-browser { + grid-template-columns: 1fr; + } + .templates-tag-nav { + display: flex; + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; + border-right: 0; + border-bottom: 1px solid var(--border-default); + } + .templates-tag-nav button { + flex: 0 0 auto; + width: auto; + } + .templates-tag-list button { + grid-template-columns: 1fr; + } + .templates-col-actions { + width: 154px; + } +} diff --git a/adminx/modules/Templates/assets/templates.js b/adminx/modules/Templates/assets/templates.js new file mode 100644 index 0000000..c9d35bc --- /dev/null +++ b/adminx/modules/Templates/assets/templates.js @@ -0,0 +1,709 @@ +/** + * JS раздела «Шаблоны»: drawer-CRUD, ревизии, lint и импорт legacy. + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + function esc(value) { + return String(value == null ? '' : value).replace(/[&<>"']/g, function (ch) { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]; + }); + } + + Adminx.Templates = { + form: null, + currentRevisionId: 0, + currentRevisionTemplateId: 0, + filterTimer: null, + + init: function () { + this.form = document.getElementById('templateForm'); + var self = this; + + document.addEventListener('click', function (e) { + if (e.target.closest('[data-template-new]')) { self.fillNew(); } + if (e.target.closest('[data-templates-filter-reset]')) { self.resetFilters(); } + if (e.target.closest('[data-template-lint]')) { self.lintCode(); } + if (e.target.closest('[data-template-code-fullscreen]')) { self.toggleCodeFullscreen(); } + if (e.target.closest('[data-template-submit-stay]')) { self.submit(true); } + if (e.target.closest('[data-template-tags-toggle]')) { self.toggleTagPalette(); } + if (e.target.closest('[data-template-tags-close]')) { self.toggleTagPalette(false); } + var tab = e.target.closest('[data-template-tag-tab]'); + if (tab) { self.activateTagGroup(tab.getAttribute('data-template-tag-tab')); } + var tag = e.target.closest('[data-template-tag]'); + if (tag) { self.insertTag(tag); } + + var edit = e.target.closest('[data-template-edit]'); + if (edit) { self.fillEdit(edit.closest('[data-template-row]')); } + var copy = e.target.closest('[data-template-copy]'); + if (copy) { self.copy(copy.closest('[data-template-row]')); } + var cache = e.target.closest('[data-template-cache]'); + if (cache) { self.clearCache(cache.closest('[data-template-row]')); } + var revisions = e.target.closest('[data-template-revisions]'); + if (revisions) { self.openRevisions(revisions.closest('[data-template-row]')); } + var del = e.target.closest('[data-template-delete]'); + if (del) { self.remove(del.closest('[data-template-row]')); } + + var revisionDelete = e.target.closest('[data-template-revision-delete]'); + if (revisionDelete) { self.deleteRevision(revisionDelete.getAttribute('data-template-revision-delete')); return; } + if (e.target.closest('[data-template-revisions-clear]')) { self.clearRevisions(); return; } + if (e.target.closest('[data-template-revision-restore]')) { self.restoreRevision(); } + var revisionOpen = e.target.closest('[data-template-revision-open]'); + if (revisionOpen) { self.loadRevision(revisionOpen.getAttribute('data-template-revision-open')); } + }); + + if (this.form) { + this.form.addEventListener('submit', function (e) { + e.preventDefault(); + self.submit(); + }); + } + + document.addEventListener('submit', function (e) { + var filter = e.target.closest('.templates-filter'); + if (!filter) { return; } + e.preventDefault(); + self.applyFilters(filter, true); + }); + + document.addEventListener('input', function (e) { + var filter = e.target.closest('.templates-filter'); + if (!filter || !e.target.matches('input[type="search"]')) { return; } + clearTimeout(self.filterTimer); + self.filterTimer = setTimeout(function () { self.applyFilters(filter, true); }, 350); + }); + document.addEventListener('input', function (e) { + if (!e.target.matches('[data-template-tags-search]')) { return; } + self.filterTagPalette(e.target.value || ''); + }); + + window.addEventListener('popstate', function () { + self.applyFilterUrl(window.location.href, false); + }); + + document.addEventListener('keydown', function (e) { + if (e.target && e.target.closest && e.target.closest('.CodeMirror')) { + return; + } + if ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 's' && self.isEditorDrawerOpen()) { + e.preventDefault(); + self.submit(true); + return; + } + if (e.key === 'Escape') { + if (document.querySelector('.templates-code-field.is-fullscreen')) { self.toggleCodeFullscreen(false); } + else { + var tagsPanel = document.querySelector('[data-template-tags-panel]'); + if (tagsPanel && !tagsPanel.hidden) { self.toggleTagPalette(false); } + } + } + }); + }, + + base: function () { return (this.form && this.form.getAttribute('data-base')) || Adminx.base(); }, + field: function (name) { return this.form.querySelector('[name="' + name + '"]'); }, + + filterUrl: function (form) { + var params = new URLSearchParams(new FormData(form)); + Array.from(params.keys()).forEach(function (key) { + if (String(params.get(key) || '') === '') { params.delete(key); } + }); + var query = params.toString(); + return (form.getAttribute('action') || (this.base() + '/templates')) + (query ? '?' + query : ''); + }, + + applyFilters: function (form, push) { + if (!form) { return; } + this.applyFilterUrl(this.filterUrl(form), push); + }, + + applyFilterUrl: function (url, push) { + var self = this; + Adminx.Loader.show(); + fetch(url, { method: 'GET', headers: { 'Accept': 'text/html' }, credentials: 'same-origin' }) + .then(function (res) { + return res.text().then(function (html) { + if (!res.ok) { throw new Error('HTTP ' + res.status); } + self.replaceList(html, url, push); + }); + }) + .catch(function () { Adminx.Toast.show('Не удалось применить фильтры', 'error'); }) + .finally(function () { Adminx.Loader.hide(); }); + }, + + replaceList: function (html, url, push) { + var doc = new DOMParser().parseFromString(html, 'text/html'); + ['.templates-summary', '.templates-import-card', '.templates-panel'].forEach(function (selector) { + var next = doc.querySelector(selector); + var current = document.querySelector(selector); + if (next && current) { current.replaceWith(next); } + }); + if (push && window.history && window.history.pushState) { + window.history.pushState({ adminxTemplatesFilters: true }, '', url); + } + }, + + isEditorDrawerOpen: function () { + var drawer = document.getElementById('templateDrawer'); + return !!(drawer && !drawer.hidden && this.form); + }, + + ajaxRefresh: function (message, keepOpen) { + if (message) { Adminx.Toast.show(message, 'success'); } + if (!keepOpen && Adminx.Drawer) { Adminx.Drawer.close(); } + this.applyFilterUrl(window.location.href, false); + if (keepOpen && window.Adminx.CodeEditor) { + setTimeout(function () { Adminx.CodeEditor.refreshAll(); }, 120); + } + }, + + resetFilters: function () { + var form = document.querySelector('.templates-filter'); + if (!form) { return; } + form.reset(); + Array.prototype.forEach.call(form.querySelectorAll('input[type="search"]'), function (input) { input.value = ''; }); + this.applyFilters(form, true); + }, + + clearErrors: function () { + if (!this.form) { return; } + this.form.querySelectorAll('[data-error]').forEach(function (el) { el.textContent = ''; }); + this.form.querySelectorAll('.is-invalid').forEach(function (el) { el.classList.remove('is-invalid'); }); + }, + + editor: function () { + var textarea = this.field('template_text'); + return textarea && textarea._adminxCodeMirror ? textarea._adminxCodeMirror : null; + }, + + setCode: function (value) { + var textarea = this.field('template_text'); + var editor = this.editor(); + if (textarea) { textarea.value = value || ''; } + if (editor) { editor.setValue(value || ''); setTimeout(function () { editor.refresh(); }, 40); } + }, + + saveCode: function () { + var editor = this.editor(); + if (editor) { editor.save(); } + }, + + toggleTagPalette: function (force) { + var panel = document.querySelector('[data-template-tags-panel]'); + var button = document.querySelector('[data-template-tags-toggle]'); + if (!panel) { return; } + var next = typeof force === 'boolean' ? force : panel.hidden; + panel.hidden = !next; + if (button) { + button.classList.toggle('is-active', next); + button.setAttribute('aria-expanded', next ? 'true' : 'false'); + } + if (next) { + var search = panel.querySelector('[data-template-tags-search]'); + if (search) { + search.value = ''; + this.filterTagPalette(''); + setTimeout(function () { search.focus(); }, 40); + } + } + }, + + activateTagGroup: function (index) { + var panel = document.querySelector('[data-template-tags-panel]'); + if (!panel) { return; } + var search = panel.querySelector('[data-template-tags-search]'); + if (search && search.value) { + search.value = ''; + this.filterTagPalette(''); + } + panel.querySelectorAll('[data-template-tag-tab]').forEach(function (tab) { + var active = tab.getAttribute('data-template-tag-tab') === String(index); + tab.classList.toggle('is-active', active); + tab.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + panel.querySelectorAll('[data-template-tag-panel]').forEach(function (group) { + group.classList.toggle('is-active', group.getAttribute('data-template-tag-panel') === String(index)); + }); + }, + + filterTagPalette: function (query) { + var panel = document.querySelector('[data-template-tags-panel]'); + if (!panel) { return; } + query = String(query || '').trim().toLowerCase(); + panel.classList.toggle('is-searching', query !== ''); + var visibleCount = 0; + panel.querySelectorAll('[data-template-tag]').forEach(function (button) { + var text = button.textContent.toLowerCase(); + var visible = query === '' || text.indexOf(query) !== -1; + button.hidden = !visible; + if (visible) { visibleCount++; } + }); + panel.querySelectorAll('[data-template-tag-group]').forEach(function (group) { + group.hidden = query !== '' && !group.querySelector('[data-template-tag]:not([hidden])'); + }); + var empty = panel.querySelector('[data-template-tags-empty]'); + if (empty) { empty.hidden = visibleCount > 0; } + }, + + insertTag: function (button) { + var value = button.getAttribute('data-template-tag') || ''; + var select = button.getAttribute('data-template-tag-select') || ''; + var editor = this.editor(); + if (!value) { return; } + + if (editor) { + var start = editor.getCursor(); + var baseIndex = editor.indexFromPos(start); + editor.replaceSelection(value, 'around'); + editor.focus(); + if (select && value.indexOf(select) !== -1) { + var from = editor.posFromIndex(baseIndex + value.indexOf(select)); + var to = editor.posFromIndex(baseIndex + value.indexOf(select) + select.length); + editor.setSelection(from, to); + } else { + editor.setCursor(editor.posFromIndex(baseIndex + value.length)); + } + editor.save(); + this.toggleTagPalette(false); + return; + } + + var textarea = this.field('template_text'); + if (!textarea) { return; } + var fromIndex = textarea.selectionStart || 0; + var toIndex = textarea.selectionEnd || fromIndex; + textarea.value = textarea.value.slice(0, fromIndex) + value + textarea.value.slice(toIndex); + textarea.focus(); + if (select && value.indexOf(select) !== -1) { + textarea.selectionStart = fromIndex + value.indexOf(select); + textarea.selectionEnd = textarea.selectionStart + select.length; + } else { + textarea.selectionStart = textarea.selectionEnd = fromIndex + value.length; + } + this.toggleTagPalette(false); + }, + + setLintResult: function (message, kind) { + var el = document.querySelector('[data-template-lint-result]'); + if (!el) { return; } + el.textContent = message || ''; + el.classList.remove('is-ok', 'is-error'); + if (kind) { el.classList.add(kind === 'error' ? 'is-error' : 'is-ok'); } + }, + + lintCode: function () { + if (!this.form) { return; } + this.saveCode(); + this.setLintResult('Проверка...', ''); + var fd = new FormData(); + fd.set('_csrf', Adminx.csrf()); + fd.set('template_text', this.field('template_text') ? this.field('template_text').value : ''); + var self = this; + Adminx.Ajax.post(this.base() + '/templates/lint', fd).then(function (payload) { + var d = payload.data || {}; + if (d.success) { + self.setLintResult(d.message || 'Синтаксис PHP без ошибок.', 'ok'); + Adminx.Toast.show(d.message || 'Синтаксис PHP без ошибок.', 'success'); + return; + } + var detail = d.errors && d.errors.template_text ? d.errors.template_text : (d.message || 'В PHP-коде есть ошибка.'); + self.setLintResult(detail, 'error'); + Adminx.Toast.show(d.message || 'В PHP-коде есть ошибка.', 'error'); + }).catch(function () { + self.setLintResult('Не удалось выполнить проверку.', 'error'); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + toggleCodeFullscreen: function (force) { + var field = document.querySelector('.templates-code-field'); + var button = document.querySelector('[data-template-code-fullscreen]'); + if (!field) { return; } + var next = typeof force === 'boolean' ? force : !field.classList.contains('is-fullscreen'); + field.classList.toggle('is-fullscreen', next); + document.body.classList.toggle('templates-code-fullscreen-open', next); + if (button) { + button.setAttribute('data-tooltip', next ? 'Свернуть редактор' : 'Развернуть редактор'); + button.setAttribute('aria-label', next ? 'Свернуть редактор' : 'Развернуть редактор'); + button.innerHTML = next ? '' : ''; + } + var editor = this.editor(); + if (editor) { + setTimeout(function () { + editor.refresh(); + editor.setSize('100%', next ? '100%' : 560); + }, 40); + } + }, + + revisionEditor: function () { + var textarea = document.querySelector('[data-template-revision-code]'); + return textarea && textarea._adminxCodeMirror ? textarea._adminxCodeMirror : null; + }, + + setRevisionCode: function (value) { + var textarea = document.querySelector('[data-template-revision-code]'); + var editor = this.revisionEditor(); + if (textarea) { textarea.value = value || ''; } + if (editor) { editor.setValue(value || ''); setTimeout(function () { editor.refresh(); }, 40); } + }, + + fillNew: function () { + if (!this.form) { return; } + this.clearErrors(); + this.form.reset(); + this.field('id').value = ''; + this.setCode(''); + this.setLintResult('', ''); + this.toggleCodeFullscreen(false); + document.getElementById('templateDrawerTitle').textContent = 'Новый шаблон'; + if (window.Adminx.CodeEditor) { setTimeout(function () { Adminx.CodeEditor.refreshAll(); }, 80); } + }, + + fillEdit: function (row) { + if (!row) { return; } + this.clearErrors(); + this.setLintResult('', ''); + this.toggleCodeFullscreen(false); + var self = this; + Adminx.Loader.show(); + Adminx.Ajax.request(this.base() + '/templates/' + row.dataset.id).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (!d.success || !d.data) { + Adminx.Toast.show(d.message || 'Не удалось загрузить шаблон', 'error'); + return; + } + var item = d.data; + self.field('id').value = item.Id || ''; + self.field('template_title').value = item.template_title || ''; + self.setCode(item.template_text || ''); + document.getElementById('templateDrawerTitle').textContent = 'Шаблон: ' + (item.template_title || ('#' + item.Id)); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + submit: function (stay) { + this.clearErrors(); + this.saveCode(); + var id = (this.field('id').value || '').trim(); + var url = this.base() + '/templates' + (id ? '/' + id : ''); + var fd = new FormData(this.form); + var self = this; + Adminx.Loader.show(); + Adminx.Ajax.post(url, fd).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { + if (stay && d.data && d.data.id) { + self.field('id').value = d.data.id; + document.getElementById('templateDrawerTitle').textContent = 'Шаблон: ' + (self.field('template_title').value || ('#' + d.data.id)); + } + self.ajaxRefresh(d.message || 'Сохранено', !!stay); + return; + } + Object.keys(d.errors || {}).forEach(function (field) { + var span = self.form.querySelector('[data-error="' + field + '"]'); + var input = self.field(field); + if (span) { span.textContent = d.errors[field]; } + if (input) { input.classList.add('is-invalid'); } + }); + Adminx.Toast.show(d.message || 'Не удалось сохранить', 'error'); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + copy: function (row) { + if (!row) { return; } + var base = this.base(); + Adminx.Confirm.open({ + kind: 'info', + title: 'Создать копию шаблона?', + message: 'Будет создана копия «' + row.dataset.title + '» с новым названием.', + confirmLabel: 'Создать копию', + onConfirm: function () { + var fd = new FormData(); + fd.set('_csrf', Adminx.csrf()); + fd.set('title', row.dataset.title + ' (копия)'); + Adminx.Loader.show(); + Adminx.Ajax.post(base + '/templates/' + row.dataset.id + '/copy', fd).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { Adminx.Templates.ajaxRefresh(d.message || 'Копия создана'); } + else { Adminx.Toast.show(d.message || 'Не удалось создать копию', 'error'); } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + }, + + clearCache: function (row) { + if (!row) { return; } + Adminx.Ajax.post(this.base() + '/templates/' + row.dataset.id + '/clear-cache').then(function (payload) { + var d = payload.data || {}; + Adminx.Toast.show(d.message || (d.success ? 'Кеш очищен' : 'Не удалось очистить кеш'), d.success ? 'success' : 'error'); + }).catch(function () { + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + remove: function (row) { + if (!row) { return; } + var base = this.base(); + Adminx.Confirm.open({ + kind: 'error', + title: 'Удалить шаблон?', + message: '«' + row.dataset.title + '» будет удалён из таблицы шаблонов. Если шаблон используется, сервер отменит удаление.', + confirmLabel: 'Удалить', + confirmClass: 'btn-danger', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(base + '/templates/' + row.dataset.id + '/delete').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { Adminx.Templates.ajaxRefresh(d.message || 'Шаблон удалён'); } + else { Adminx.Toast.show(d.message || 'Не удалось удалить', 'error'); } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + }, + + openRevisions: function (row) { + if (!row) { return; } + this.currentRevisionId = 0; + this.currentRevisionTemplateId = row.dataset.id || 0; + var title = document.getElementById('templateRevisionsDrawerTitle'); + var subtitle = document.getElementById('templateRevisionsDrawerSubtitle'); + var list = document.querySelector('[data-template-revisions-list]'); + var count = document.querySelector('[data-template-revisions-count]'); + var revisionTitle = document.querySelector('[data-template-revision-title]'); + var revisionMeta = document.querySelector('[data-template-revision-meta]'); + var fields = document.querySelector('[data-template-revision-fields]'); + var restore = document.querySelector('[data-template-revision-restore]'); + var remove = document.querySelector('[data-template-revision-delete]'); + var clear = document.querySelector('[data-template-revisions-clear]'); + + if (title) { title.textContent = 'Ревизии: ' + (row.dataset.title || ('#' + row.dataset.id)); } + if (subtitle) { subtitle.textContent = 'Шаблон #' + (row.dataset.id || ''); } + if (list) { list.innerHTML = '
    Загрузка...
    '; } + if (count) { count.textContent = 'Загрузка...'; } + if (revisionTitle) { revisionTitle.textContent = 'Выберите ревизию'; } + if (revisionMeta) { revisionMeta.textContent = 'Код и метаданные появятся после выбора снимка.'; } + if (fields) { fields.innerHTML = ''; } + if (restore) { restore.disabled = true; } + if (remove) { remove.disabled = true; remove.removeAttribute('data-template-revision-delete'); } + if (clear) { clear.disabled = true; } + this.setRevisionCode(''); + if (Adminx.Drawer) { Adminx.Drawer.open('templateRevisionsDrawer'); } + if (window.Adminx.CodeEditor) { setTimeout(function () { Adminx.CodeEditor.refreshAll(); }, 80); } + + this.refreshRevisions(); + }, + + refreshRevisions: function () { + if (!this.currentRevisionTemplateId) { return; } + var self = this; + Adminx.Loader.show(); + Adminx.Ajax.request(this.base() + '/templates/' + self.currentRevisionTemplateId + '/revisions').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (!d.success || !d.data) { + Adminx.Toast.show(d.message || 'Не удалось загрузить ревизии', 'error'); + return; + } + self.renderRevisions(d.data.revisions || []); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + renderRevisions: function (items) { + var list = document.querySelector('[data-template-revisions-list]'); + var count = document.querySelector('[data-template-revisions-count]'); + var clear = document.querySelector('[data-template-revisions-clear]'); + if (count) { count.textContent = items.length ? (items.length + ' снимков') : 'История пока пустая'; } + if (clear) { clear.disabled = !items.length; } + if (!list) { return; } + if (!items.length) { + list.innerHTML = '
    Ревизий пока нет. Первый снимок появится после сохранения шаблона.
    '; + this.currentRevisionId = 0; + this.showRevisionEmpty(); + return; + } + list.innerHTML = items.map(function (item) { + return '
    ' + + '' + + '
    ' + + 'Ревизия #' + item.id + '' + esc(item.action_label || item.action) + '' + + '
    ' + esc(item.created_label || '-') + (item.author_name ? ' · ' + esc(item.author_name) : '') + (item.comment ? ' · ' + esc(item.comment) : '') + '
    ' + + '
    ' + + '' + + '
    '; + }).join(''); + this.loadRevision(items[0].id); + }, + + showRevisionEmpty: function () { + var title = document.querySelector('[data-template-revision-title]'); + var meta = document.querySelector('[data-template-revision-meta]'); + var fields = document.querySelector('[data-template-revision-fields]'); + var restore = document.querySelector('[data-template-revision-restore]'); + var remove = document.querySelector('[data-template-revision-delete]'); + if (title) { title.textContent = 'Выберите ревизию'; } + if (meta) { meta.textContent = 'Код и метаданные появятся после выбора снимка.'; } + if (fields) { fields.innerHTML = ''; } + if (restore) { restore.disabled = true; } + if (remove) { remove.disabled = true; remove.removeAttribute('data-template-revision-delete'); } + this.setRevisionCode(''); + }, + + loadRevision: function (id) { + id = parseInt(id, 10) || 0; + if (!id) { return; } + var self = this; + Adminx.Loader.show(); + Adminx.Ajax.request(this.base() + '/templates/revisions/' + id).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (!d.success || !d.data) { + Adminx.Toast.show(d.message || 'Не удалось загрузить ревизию', 'error'); + return; + } + self.showRevision(d.data); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + showRevision: function (item) { + this.currentRevisionId = parseInt(item.id, 10) || 0; + document.querySelectorAll('[data-template-revision-open]').forEach(function (row) { + row.classList.toggle('is-active', row.getAttribute('data-template-revision-open') === String(item.id)); + }); + + var snapshot = item.snapshot || {}; + var title = document.querySelector('[data-template-revision-title]'); + var meta = document.querySelector('[data-template-revision-meta]'); + var fields = document.querySelector('[data-template-revision-fields]'); + var restore = document.querySelector('[data-template-revision-restore]'); + var remove = document.querySelector('[data-template-revision-delete]'); + + if (title) { title.textContent = '#' + item.id + ' · ' + (item.action_label || item.action || 'Ревизия'); } + if (meta) { + meta.textContent = (item.created_label || '-') + (item.author_name ? ' · ' + item.author_name : '') + (item.text_size_label ? ' · ' + item.text_size_label : ''); + } + if (fields) { + fields.innerHTML = + 'Название' + esc(snapshot.template_title || '-') + '' + + 'ID#' + esc(snapshot.Id || '-') + '' + + 'Размер' + esc(item.text_size_label || '-') + ''; + } + if (restore) { restore.disabled = !this.currentRevisionId; } + if (remove) { + remove.disabled = !this.currentRevisionId; + if (this.currentRevisionId) { remove.setAttribute('data-template-revision-delete', String(this.currentRevisionId)); } + } + this.setRevisionCode(item.code || ''); + }, + + deleteRevision: function (id) { + id = parseInt(id || this.currentRevisionId, 10) || 0; + if (!id) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить ревизию?', + message: 'Снимок будет удалён без восстановления.', + confirmLabel: 'Удалить', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(self.base() + '/templates/revisions/' + id + '/delete').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { + Adminx.Toast.show(d.message || 'Ревизия удалена', 'success'); + self.currentRevisionId = 0; + self.refreshRevisions(); + } else { + Adminx.Toast.show(d.message || 'Не удалось удалить ревизию', 'error'); + } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + }, + + clearRevisions: function () { + if (!this.currentRevisionTemplateId) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить все ревизии?', + message: 'Будет очищена вся история снимков этого шаблона.', + confirmLabel: 'Удалить все', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(self.base() + '/templates/' + self.currentRevisionTemplateId + '/revisions/delete').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { + Adminx.Toast.show(d.message || 'Ревизии удалены', 'success'); + self.currentRevisionId = 0; + self.refreshRevisions(); + } else { + Adminx.Toast.show(d.message || 'Не удалось удалить ревизии', 'error'); + } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + }, + + restoreRevision: function () { + if (!this.currentRevisionId) { return; } + var base = this.base(); + var id = this.currentRevisionId; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Восстановить ревизию?', + message: 'Текущее состояние шаблона будет сохранено отдельным снимком перед восстановлением.', + confirmLabel: 'Восстановить', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(base + '/templates/revisions/' + id + '/restore').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { Adminx.Templates.ajaxRefresh(d.message || 'Шаблон восстановлен'); } + else { Adminx.Toast.show(d.message || 'Не удалось восстановить ревизию', 'error'); } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.Templates.init(); }); + } else { + Adminx.Templates.init(); + } +})(window, document); diff --git a/adminx/modules/Templates/language/en/client.xml b/adminx/modules/Templates/language/en/client.xml new file mode 100644 index 0000000..f34a954 --- /dev/null +++ b/adminx/modules/Templates/language/en/client.xml @@ -0,0 +1,25 @@ + + + >There are no revisions yet. The first snapshot will appear after saving the template.</div>'; + this.currentRevisionId = 0; + this.showRevisionEmpty(); + return; + } + list.innerHTML = items.map(function (item) { + return '<div class= + Copy + The code and metadata will appear after selecting a photo. + New template + Select revision + Audits + (copy) + PHP syntax without errors. + Copy created + Template deleted + PHP syntax without errors. + (copy) + Template deleted + Copy + Copy created + New template + diff --git a/adminx/modules/Templates/language/en/interface.xml b/adminx/modules/Templates/language/en/interface.xml new file mode 100644 index 0000000..3081858 --- /dev/null +++ b/adminx/modules/Templates/language/en/interface.xml @@ -0,0 +1,149 @@ + + + ID, name or code + file cache + in the current filter, + Total templates + everything. + Select revision + Tag groups + Used by rubrics + Template snapshot history. + The code and metadata will appear after selecting a photo. + Template code + not attached + New template + Basic Templates + Search tags + When saving, the database and cache are updated + View revision + Audits + Template revisions + rubrics + Code characters + Pictures + Contains PHP + Create a template + retains and leaves the shape open. + List of revisions + List of templates + The #1 template and templates used by categories are protected from deletion. + Templates for displaying site documents. + No templates found. + (copy) + (string + >' + + '<b>Revision + '<div class= + >There are no revisions yet. The first snapshot will appear after saving the template.</div>'; + this.currentRevisionId = 0; + this.showRevisionEmpty(); + return; + } + list.innerHTML = items.map(function (item) { + return '<div class= + CSS from the active theme registry + Footer categories + Header categories + JavaScript from the active theme registry + PHP code not found, no verification needed. + PHP syntax: error in template code + PHP syntax: error on line + Paragraph + Block + There is a syntax error in the PHP code. + Versified theme file URL + Version + Recovery + Restored from revision + + return $templateId; + } + + public static function delete($revisionId) + { + $revision = self::one($revisionId); + if (!$revision) { + return false; + } + + DB::Delete(self::table(), + Home + Printable + Domain + Bold + Heading + Image + Import + SFR information code of the current product + SFR code + Composition + Content + Copy + Copy created + Template copy + return $newId; + } + + public static function delete($id, $authorId = 0) + { + $id = (int) $id; + if ($id === 1) { + throw new \RuntimeException( + Root + Brief description + Italic + Media path + Navigation + Navigation + Site name + Not a print + Payment via SFR + Main content + Basic + Theme folder + Before recovery + Transfer + Print + Document field + Check the form fields + View document templates. + Item + Paths and files + Revisions deleted + Revision not found + Revision deleted + Related Data + PHP syntax without errors. + System units + System unit + System unit + if ((string) $row[ + Snapshot before recovery + Creation + Create a template + Create, edit, copy, delete and restore templates. + Saving + Saving a template + List + Link + Tabulation + Teaser + Removal + Deleting a template + Specify the template code + Please enter a name + Condition for product availability for the SFR program + Terms + Template file cache cleared + bread crumbs + Template restored from revision + The template is used by categories and cannot be deleted + Template not found + Template created + Template saved + Template deleted + Templates + Templates: view + Templates: management + diff --git a/adminx/modules/Templates/language/en/runtime.xml b/adminx/modules/Templates/language/en/runtime.xml new file mode 100644 index 0000000..2207bc2 --- /dev/null +++ b/adminx/modules/Templates/language/en/runtime.xml @@ -0,0 +1,93 @@ + + + (copy) + (string + CSS from the active theme registry + Footer categories + Header categories + JavaScript from the active theme registry + PHP code not found, no verification needed. + PHP syntax: error in template code + PHP syntax: error on line + · off + Paragraph + B + Block + There is a syntax error in the PHP code. + Versified theme file URL + Version + Recovery + Restored from revision # + Home + Printable + Domain + Bold + Heading + Image + Import + SFR information code of the current product + SFR code + Composition + Content + Copy + Copy created + Template copy # + Root + Brief description + Italic + Media path + Navigation + Navigation + Navigation # + Site name + Not a print + New template + Payment via SFR + Main content + Basic + Theme folder + Before recovery + Transfer + Print + Document field + Check the form fields + View document templates. + Item + Paths and files + Revisions deleted + Revision not found + Revision deleted + Related Data + PHP syntax without errors. + System units + System unit + System unit # + Snapshot before recovery + Creation + Create a template + Create, edit, copy, delete and restore templates. + Saving + Saving a template + List + Link + Tabulation + Teaser + Removal + Deleting a template + Specify the template code + Please enter a name + Condition for product availability for the SFR program + Terms + Template file cache cleared + bread crumbs + Template #1 is system and cannot be deleted + Template restored from revision + The template is used by categories and cannot be deleted + Template not found + Template created + Template saved + Template deleted + Templates + Templates: view + Templates: management + diff --git a/adminx/modules/Templates/language/ru/client.xml b/adminx/modules/Templates/language/ru/client.xml new file mode 100644 index 0000000..8c66180 --- /dev/null +++ b/adminx/modules/Templates/language/ru/client.xml @@ -0,0 +1,25 @@ + + + >Ревизий пока нет. Первый снимок появится после сохранения шаблона.</div>'; + this.currentRevisionId = 0; + this.showRevisionEmpty(); + return; + } + list.innerHTML = items.map(function (item) { + return '<div class= + Копия + Код и метаданные появятся после выбора снимка. + Новый шаблон + Выберите ревизию + Ревизии + (копия) + Синтаксис PHP без ошибок. + Копия создана + Шаблон удалён + Синтаксис PHP без ошибок. + (копия) + Шаблон удалён + Копия + Копия создана + Новый шаблон + diff --git a/adminx/modules/Templates/language/ru/interface.xml b/adminx/modules/Templates/language/ru/interface.xml new file mode 100644 index 0000000..6fdffc6 --- /dev/null +++ b/adminx/modules/Templates/language/ru/interface.xml @@ -0,0 +1,149 @@ + + + ID, название или код + · файловый кеш + в текущем фильтре, + Всего шаблонов + всего. + Выберите ревизию + Группы тегов + Используются рубриками + История снимков шаблона. + Код и метаданные появятся после выбора снимка. + Код шаблона + не привязан + Новый шаблон + Основные шаблоны + Поиск тегов + При сохранении обновляется БД и кеш + Просмотр ревизии + Ревизии + Ревизии шаблона + рубрик + Символов кода + Снимки + Содержат PHP + Создать шаблон + сохраняет и оставляет форму открытой. + Список ревизий + Список шаблонов + Шаблон #1 и используемые рубриками шаблоны защищены от удаления. + Шаблоны вывода документов сайта. + Шаблоны не найдены. + (копия) + (строка + >' + + '<b>Ревизия + '<div class= + >Ревизий пока нет. Первый снимок появится после сохранения шаблона.</div>'; + this.currentRevisionId = 0; + this.showRevisionEmpty(); + return; + } + list.innerHTML = items.map(function (item) { + return '<div class= + CSS из реестра активной темы + Footer рубрики + Header рубрики + JavaScript из реестра активной темы + PHP-код не найден, проверка не нужна. + PHP-синтаксис: ошибка в коде шаблона + PHP-синтаксис: ошибка на строке + Абзац + Блок + В PHP-коде есть синтаксическая ошибка. + Версионированный URL файла темы + Версия + Восстановление + Восстановлено из ревизии + + return $templateId; + } + + public static function delete($revisionId) + { + $revision = self::one($revisionId); + if (!$revision) { + return false; + } + + DB::Delete(self::table(), + Главная + Для печати + Домен + Жирный + Заголовок + Изображение + Импорт + Информационный код СФР текущего товара + Код СФР + Композиция + Контент + Копия + Копия создана + Копия шаблона + return $newId; + } + + public static function delete($id, $authorId = 0) + { + $id = (int) $id; + if ($id === 1) { + throw new \RuntimeException( + Корень + Краткое описание + Курсив + Медиа-путь + Навигации + Навигация + Название сайта + Не печать + Оплата через СФР + Основной контент + Основные + Папка темы + Перед восстановлением + Перенос + Печать + Поле документа + Проверьте поля формы + Просмотр шаблонов документов. + Пункт + Пути и файлы + Ревизии удалены + Ревизия не найдена + Ревизия удалена + Связанные данные + Синтаксис PHP без ошибок. + Системные блоки + Системный блок + Системный блок + if ((string) $row[ + Снимок перед восстановлением + Создание + Создание шаблона + Создание, изменение, копирование, удаление и восстановление шаблонов. + Сохранение + Сохранение шаблона + Список + Ссылка + Табуляция + Тизер + Удаление + Удаление шаблона + Укажите код шаблона + Укажите название + Условие доступности товара для программы СФР + Условия + Файловый кеш шаблона очищен + Хлебные крошки + Шаблон восстановлен из ревизии + Шаблон используется рубриками и не может быть удалён + Шаблон не найден + Шаблон создан + Шаблон сохранён + Шаблон удалён + Шаблоны + Шаблоны: просмотр + Шаблоны: управление + diff --git a/adminx/modules/Templates/language/ru/runtime.xml b/adminx/modules/Templates/language/ru/runtime.xml new file mode 100644 index 0000000..b439afa --- /dev/null +++ b/adminx/modules/Templates/language/ru/runtime.xml @@ -0,0 +1,93 @@ + + + (копия) + (строка + CSS из реестра активной темы + Footer рубрики + Header рубрики + JavaScript из реестра активной темы + PHP-код не найден, проверка не нужна. + PHP-синтаксис: ошибка в коде шаблона + PHP-синтаксис: ошибка на строке + · выключен + Абзац + Б + Блок + В PHP-коде есть синтаксическая ошибка. + Версионированный URL файла темы + Версия + Восстановление + Восстановлено из ревизии # + Главная + Для печати + Домен + Жирный + Заголовок + Изображение + Импорт + Информационный код СФР текущего товара + Код СФР + Композиция + Контент + Копия + Копия создана + Копия шаблона # + Корень + Краткое описание + Курсив + Медиа-путь + Навигации + Навигация + Навигация # + Название сайта + Не печать + Новый шаблон + Оплата через СФР + Основной контент + Основные + Папка темы + Перед восстановлением + Перенос + Печать + Поле документа + Проверьте поля формы + Просмотр шаблонов документов. + Пункт + Пути и файлы + Ревизии удалены + Ревизия не найдена + Ревизия удалена + Связанные данные + Синтаксис PHP без ошибок. + Системные блоки + Системный блок + Системный блок # + Снимок перед восстановлением + Создание + Создание шаблона + Создание, изменение, копирование, удаление и восстановление шаблонов. + Сохранение + Сохранение шаблона + Список + Ссылка + Табуляция + Тизер + Удаление + Удаление шаблона + Укажите код шаблона + Укажите название + Условие доступности товара для программы СФР + Условия + Файловый кеш шаблона очищен + Хлебные крошки + Шаблон #1 является системным и не может быть удалён + Шаблон восстановлен из ревизии + Шаблон используется рубриками и не может быть удалён + Шаблон не найден + Шаблон создан + Шаблон сохранён + Шаблон удалён + Шаблоны + Шаблоны: просмотр + Шаблоны: управление + diff --git a/adminx/modules/Templates/migrations/001_create_templates_tables.sql b/adminx/modules/Templates/migrations/001_create_templates_tables.sql new file mode 100644 index 0000000..3e0a825 --- /dev/null +++ b/adminx/modules/Templates/migrations/001_create_templates_tables.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_templates` ( + `Id` SMALLINT(3) UNSIGNED NOT NULL AUTO_INCREMENT, + `template_title` VARCHAR(255) NOT NULL DEFAULT '', + `template_text` LONGTEXT NOT NULL, + `template_author_id` INT(10) UNSIGNED NOT NULL DEFAULT 1, + `template_created` INT(10) UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`Id`), + KEY `idx_template_title` (`template_title`), + KEY `idx_template_author` (`template_author_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/adminx/modules/Templates/migrations/002_create_template_revisions.sql b/adminx/modules/Templates/migrations/002_create_template_revisions.sql new file mode 100644 index 0000000..6756bc0 --- /dev/null +++ b/adminx/modules/Templates/migrations/002_create_template_revisions.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_template_revisions` ( + `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `template_id` SMALLINT(3) UNSIGNED NOT NULL DEFAULT 0, + `action` VARCHAR(32) NOT NULL DEFAULT 'update', + `snapshot_hash` CHAR(40) NOT NULL DEFAULT '', + `text_hash` CHAR(40) NOT NULL DEFAULT '', + `snapshot_json` LONGTEXT NOT NULL, + `comment` VARCHAR(255) NOT NULL DEFAULT '', + `author_id` INT(10) UNSIGNED NOT NULL DEFAULT 0, + `author_name` VARCHAR(255) NOT NULL DEFAULT '', + `source_revision_id` INT(10) UNSIGNED NOT NULL DEFAULT 0, + `created_at` INT(10) UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_template_created` (`template_id`, `created_at`), + KEY `idx_action` (`action`), + KEY `idx_snapshot_hash` (`snapshot_hash`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/adminx/modules/Templates/module.php b/adminx/modules/Templates/module.php new file mode 100644 index 0000000..afbf292 --- /dev/null +++ b/adminx/modules/Templates/module.php @@ -0,0 +1,83 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + return array( + 'code' => 'templates', + 'name' => 'Шаблоны', + 'version' => '0.1.0', + + 'permissions' => array( + 'key' => 'templates', + 'items' => array( + array( + 'code' => 'view_templates', + 'group_code' => 'navigation', + 'name' => 'Шаблоны: просмотр', + 'description' => 'Просмотр шаблонов документов.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_templates', + 'group_code' => 'content', + 'name' => 'Шаблоны: управление', + 'description' => 'Создание, изменение, копирование, удаление и восстановление шаблонов.', + 'sort_order' => 20, + ), + ), + 'icon' => 'ti ti-template', + 'priority' => 34, + ), + + 'navigation' => array( + array( + 'code' => 'templates', + 'label' => 'Шаблоны', + 'url' => '/templates', + 'icon' => 'ti ti-template', + 'permission' => 'view_templates', + 'group' => 'Контент', + 'sort_order' => 24, + 'match' => array( + '/templates', + ), + ), + ), + + 'migrations' => array( + array('id' => '001_create_templates_tables', 'file' => 'migrations/001_create_templates_tables.sql'), + array('id' => '002_create_template_revisions', 'file' => 'migrations/002_create_template_revisions.sql'), + ), + + 'routes' => array( + array('GET', '/templates', array(\App\Adminx\Templates\Controller::class, 'index')), + array('POST', '/templates/lint', array(\App\Adminx\Templates\Controller::class, 'lint')), + array('GET', '/templates/{id}/revisions', array(\App\Adminx\Templates\Controller::class, 'revisions')), + array('POST', '/templates/{id}/revisions/delete', array(\App\Adminx\Templates\Controller::class, 'deleteRevisions')), + array('GET', '/templates/revisions/{revision}', array(\App\Adminx\Templates\Controller::class, 'revision')), + array('POST', '/templates/revisions/{revision}/restore', array(\App\Adminx\Templates\Controller::class, 'restoreRevision'), array('permission' => 'manage_templates', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/templates/revisions/{revision}/delete', array(\App\Adminx\Templates\Controller::class, 'deleteRevision')), + array('GET', '/templates/{id}', array(\App\Adminx\Templates\Controller::class, 'show')), + array('POST', '/templates', array(\App\Adminx\Templates\Controller::class, 'store'), array('permission' => 'manage_templates', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/templates/{id}', array(\App\Adminx\Templates\Controller::class, 'update'), array('permission' => 'manage_templates', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/templates/{id}/copy', array(\App\Adminx\Templates\Controller::class, 'copy'), array('permission' => 'manage_templates', 'sensitive' => 'stored_php.write', 'reauth' => true)), + array('POST', '/templates/{id}/clear-cache', array(\App\Adminx\Templates\Controller::class, 'clearCache')), + array('POST', '/templates/{id}/delete', array(\App\Adminx\Templates\Controller::class, 'destroy')), + ), + + 'view_globals' => array( + 'module_code' => 'templates', + ), + ); diff --git a/adminx/modules/Templates/view/index.twig b/adminx/modules/Templates/view/index.twig new file mode 100644 index 0000000..200b9a2 --- /dev/null +++ b/adminx/modules/Templates/view/index.twig @@ -0,0 +1,277 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Шаблоны{% endblock %} +{% block content %} + + + + + +
    +
    + +
    {{ stats.total }}Всего шаблонов
    +
    +
    + +
    {{ stats.used }}Используются рубриками
    +
    +
    + +
    {{ stats.php }}Содержат PHP
    +
    +
    + +
    {{ stats.bytes }}Символов кода
    +
    +
    + +
    +
    +
    +
    +
    Контент
    +

    Список шаблонов

    +

    При сохранении обновляется БД и кеш templates/<theme>/templates/<id>.inc.

    +
    +
    + +
    +
    +
    + +
    +

    Шаблоны

    +

    {{ templates|length }} в текущем фильтре, {{ stats.total }} всего.

    +
    +
    + {{ filters.q ? 'filter' : 'all' }} +
    +
    +
    +
    + + +
    + + {% if filters.q %} + + {% endif %} +
    +
    +
    + +
    +
    +
    + +
    +

    Основные шаблоны

    +

    Шаблон #1 и используемые рубриками шаблоны защищены от удаления.

    +
    +
    + {{ templates|length }} +
    +
    + + + + + + + {% if can_manage %}{% endif %} + + + + + + + + {% if can_manage %}{% endif %} + + + + {% for item in templates %} + + + + + + {% if can_manage %} + + {% endif %} + + {% else %} + + {% endfor %} + +
    IDНазваниеСостояниеМетаданные
    {{ item.Id }} +
    + {{ item.template_title }} + {{ item.text_size_label }}{% if item.cache_exists %} · файловый кеш{% endif %} +
    +
    +
    + {% if item.used_count > 0 %}{{ item.used_count }} рубрик{% else %}не привязан{% endif %} + {% if item.has_php %}PHP{% endif %} + {% if item.Id == 1 %}system{% endif %} +
    +
    +
    + {{ item.author }} + {{ item.created_label }} +
    +
    +
    + + + + + {% if item.can_delete %} + + {% else %} + + {% endif %} +
    +
    Шаблоны не найдены.
    +
    +
    +
    + +{% if can_manage %} + + + +{% endif %} + +{% endblock %} diff --git a/adminx/modules/Themes/Controller.php b/adminx/modules/Themes/Controller.php new file mode 100644 index 0000000..d758068 --- /dev/null +++ b/adminx/modules/Themes/Controller.php @@ -0,0 +1,254 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Themes; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Adminx\Support\CodeEditor; + use App\Common\AuditLog; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Frontend\ThemeAssets; + use App\Frontend\ThemeSettings; + use App\Helpers\Request; + use App\Helpers\Response; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + CodeEditor::useCodeMirror('text/css'); + CodeEditor::useCodeMirror('text/javascript'); + CodeEditor::useCodeMirror('application/json'); + + $themes = Model::themes(); + $theme = Request::getStr('theme', ''); + if ($theme === '' && !empty($themes)) { $theme = $themes[0]['code']; } + $directory = Request::getStr('dir', ''); + $browser = $theme !== '' ? Model::directory($theme, $directory) : null; + $stats = array('themes' => count($themes), 'files' => 0, 'size' => '0 Б', 'connections' => 0); + if ($browser) { + $stats['files'] = $browser['theme']['files_count']; + $stats['size'] = $browser['theme']['size_label']; + $stats['connections'] = $browser['theme']['styles_count'] + $browser['theme']['scripts_count']; + } + + return $this->render('@themes/index.twig', array( + 'themes' => $themes, + 'selected_theme' => $theme, + 'browser' => $browser, + 'manifest' => $browser ? $browser['theme']['manifest'] : ThemeAssets::normalizeManifest(array(), ''), + 'theme_settings_group' => $browser ? ThemeSettings::group($theme) : array(), + 'theme_settings_fields' => $browser ? ThemeSettings::fields($theme) : array(), + 'available_assets' => $browser ? Model::availableAssets($theme) : array('styles' => array(), 'scripts' => array()), + 'revisions' => $browser ? Revisions::listing($theme) : array(), + 'stats' => $stats, + 'can_manage' => Permission::check('manage_themes'), + )); + } + + public function file(array $params = array()) + { + try { return $this->success('', array('data' => Model::file(Request::getStr('theme'), Request::getStr('path')))); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 404); } + } + + public function saveFile(array $params = array()) + { + return $this->mutate('theme.asset_saved', function () { + $item = Model::saveFile(Request::postStr('theme'), Request::postStr('path'), Request::postStr('content'), Auth::id()); + return array('message' => 'Файл сохранён', 'data' => $item, 'meta' => array('theme' => $item['theme'], 'path' => $item['path'])); + }); + } + + public function createFile(array $params = array()) + { + return $this->mutate('theme.asset_created', function () { + $item = Model::createFile(Request::postStr('theme'), Request::postStr('directory'), Request::postStr('name'), Auth::id()); + return array('message' => 'Файл создан', 'data' => $item, 'reload' => true, 'meta' => array('theme' => $item['theme'], 'path' => $item['path'])); + }); + } + + public function createFolder(array $params = array()) + { + return $this->mutate('theme.folder_created', function () { + $theme = Request::postStr('theme'); + $name = Request::postStr('name'); + Model::createDirectory($theme, Request::postStr('directory'), $name); + return array('message' => 'Каталог создан', 'reload' => true, 'meta' => array('theme' => $theme, 'name' => $name)); + }); + } + + public function upload(array $params = array()) + { + return $this->mutate('theme.assets_uploaded', function () { + $theme = Request::postStr('theme'); + $result = Model::upload($theme, Request::postStr('directory'), isset($_FILES['files']) ? $_FILES['files'] : array(), Auth::id()); + $message = count($result['uploaded']) . ' файл(ов) загружено'; + if ($result['errors']) { $message .= '. Ошибки: ' . implode('; ', $result['errors']); } + return array('message' => $message, 'data' => $result, 'reload' => true, 'meta' => array('theme' => $theme, 'files' => $result['uploaded'])); + }); + } + + public function deletePath(array $params = array()) + { + return $this->mutate('theme.asset_deleted', function () { + $theme = Request::postStr('theme'); + $path = Request::postStr('path'); + Model::deletePath($theme, $path, Auth::id()); + return array('message' => 'Файл или каталог удалён', 'reload' => true, 'meta' => array('theme' => $theme, 'path' => $path)); + }); + } + + public function saveManifest(array $params = array()) + { + return $this->mutate('theme.manifest_saved', function () { + $theme = Request::postStr('theme'); + $manifest = Model::saveManifest($theme, Request::postAll(), Auth::id()); + return array('message' => 'Подключения темы сохранены', 'data' => $manifest, 'reload' => true, 'meta' => array('theme' => $theme)); + }); + } + + public function saveSettings(array $params = array()) + { + return $this->mutate('theme.settings_saved', function () { + $theme = Request::postStr('theme'); + $result = Model::saveSettings($theme, Request::postAll(), Auth::id()); + return array( + 'message' => 'Настройки темы сохранены', + 'data' => $result, + 'reload' => true, + 'meta' => array( + 'theme' => $theme, + 'presentation_mode' => $result['manifest']['presentation_mode'], + 'saved' => $result['saved'], + ), + ); + }); + } + + public function createTheme(array $params = array()) + { + return $this->mutate('theme.created', function () { + $item = Model::createTheme(Request::postStr('code'), Request::postStr('name'), Auth::id()); + return array('message' => 'Тема создана', 'data' => $item, 'redirect' => $this->base() . '/themes?theme=' . rawurlencode($item['code']), 'meta' => array('theme' => $item['code'])); + }); + } + + public function activate(array $params = array()) + { + return $this->mutate('theme.activated', function () { + $item = Model::activate(Request::postStr('theme')); + return array('message' => 'Тема активирована', 'data' => $item, 'reload' => true, 'meta' => array('theme' => $item['code'])); + }); + } + + public function export(array $params = array()) + { + if (!Permission::check('view_themes')) { Response::forbidden(); return null; } + try { + $archive = Model::exportArchive(Request::getStr('theme')); + register_shutdown_function(function () use ($archive) { @unlink($archive['path']); }); + Response::download($archive['path'], $archive['name'], 'application/zip'); + return null; + } catch (\Throwable $e) { + return $this->renderStatus('@adminx/error.twig', array('message' => $e->getMessage()), 422); + } + } + + public function import(array $params = array()) + { + return $this->mutate('theme.imported', function () { + $item = Model::importArchive(isset($_FILES['archive']) ? $_FILES['archive'] : array(), Request::postStr('code'), Auth::id()); + return array('message' => 'Тема импортирована', 'data' => $item, 'redirect' => $this->base() . '/themes?theme=' . rawurlencode($item['code']), 'meta' => array('theme' => $item['code'])); + }); + } + + public function deleteTheme(array $params = array()) + { + return $this->mutate('theme.deleted', function () { + $theme = Request::postStr('theme'); + Model::deleteTheme($theme); + return array('message' => 'Тема удалена', 'redirect' => $this->base() . '/themes', 'meta' => array('theme' => $theme)); + }); + } + + public function revisions(array $params = array()) + { + try { return $this->success('', array('data' => Revisions::listing(Request::getStr('theme'), Request::getStr('path')))); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + } + + public function revision(array $params = array()) + { + $item = Revisions::one(isset($params['id']) ? (int) $params['id'] : 0); + return $item ? $this->success('', array('data' => $item)) : $this->error('Ревизия не найдена', array(), 404); + } + + public function restoreRevision(array $params = array()) + { + return $this->mutate('theme.revision_restored', function () use ($params) { + $id = isset($params['id']) ? (int) $params['id'] : 0; + $item = Revisions::restore($id, Auth::id()); + return array('message' => 'Ревизия восстановлена', 'data' => $item, 'reload' => true, 'meta' => array('revision_id' => $id, 'theme' => $item['theme'], 'path' => $item['path'])); + }); + } + + public function deleteRevision(array $params = array()) + { + return $this->mutate('theme.revision_deleted', function () use ($params) { + $id = isset($params['id']) ? (int) $params['id'] : 0; + if (!Revisions::delete($id)) { throw new \RuntimeException('Ревизия не найдена'); } + return array('message' => 'Ревизия удалена', 'reload' => true, 'meta' => array('revision_id' => $id)); + }); + } + + public function clearRevisions(array $params = array()) + { + return $this->mutate('theme.revisions_cleared', function () { + $theme = Request::postStr('theme'); + $path = Request::postStr('path'); + $count = $path !== '' ? Revisions::deleteForPath($theme, $path) : Revisions::deleteTheme($theme); + return array('message' => 'Удалено ревизий: ' . (int) $count, 'reload' => true, 'meta' => array('theme' => $theme, 'path' => $path, 'count' => (int) $count)); + }); + } + + protected function mutate($action, callable $callback) + { + if (($error = $this->guardPermission('manage_themes')) !== null) { return $error; } + try { + $result = $callback(); + $this->audit($action, isset($result['meta']) ? $result['meta'] : array()); + $extra = array('data' => isset($result['data']) ? $result['data'] : new \stdClass()); + if (!empty($result['redirect'])) { $extra['redirect'] = $result['redirect']; } + return $this->success(isset($result['message']) ? $result['message'] : 'Готово', $extra); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + } + + protected function audit($action, array $meta) + { + $user = Auth::user(); + AuditLog::record($action, array( + 'actor_id' => Auth::id(), + 'actor_name' => $user && isset($user['name']) ? $user['name'] : '', + 'target_type' => 'theme', + 'target_id' => null, + 'meta' => $meta, + )); + } + } diff --git a/adminx/modules/Themes/Model.php b/adminx/modules/Themes/Model.php new file mode 100644 index 0000000..b37cd1c --- /dev/null +++ b/adminx/modules/Themes/Model.php @@ -0,0 +1,826 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Themes; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\FileCacheInvalidator; + use App\Common\SystemTables; + use App\Frontend\ThemeAssets; + use App\Frontend\ThemeSettings; + use App\Helpers\File; + use App\Helpers\Json; + use DB; + + class Model + { + const MAX_TEXT_BYTES = 2097152; + const MAX_FILE_BYTES = 10485760; + const MAX_ARCHIVE_BYTES = 52428800; + const MAX_ARCHIVE_FILES = 2000; + const MAX_ARCHIVE_UNPACKED = 104857600; + + protected static $hiddenDirectories = array('include', 'templates', 'lang', '.tmb', '.quarantine'); + + public static function themes() + { + $items = array(); + $root = self::themesRoot(); + if (!is_dir($root)) { return $items; } + foreach (new \DirectoryIterator($root) as $entry) { + if ($entry->isDot() || !$entry->isDir() || $entry->isLink()) { continue; } + $code = $entry->getFilename(); + if (!ThemeAssets::validTheme($code)) { continue; } + $items[] = self::theme($code); + } + + usort($items, function ($left, $right) { + if ($left['active'] !== $right['active']) { return $left['active'] ? -1 : 1; } + return strnatcasecmp($left['name'], $right['name']); + }); + return $items; + } + + public static function theme($code) + { + $code = self::assertTheme($code); + $manifest = ThemeAssets::manifest($code); + $stats = self::themeStats($code); + return array( + 'code' => $code, + 'name' => $manifest['name'], + 'version' => $manifest['version'], + 'active' => $code === ThemeAssets::currentTheme(), + 'has_manifest' => is_file(self::themeRoot($code) . '/' . ThemeAssets::MANIFEST), + 'files_count' => $stats['files'], + 'size' => $stats['size'], + 'size_label' => self::formatBytes($stats['size']), + 'styles_count' => count($manifest['styles']), + 'scripts_count' => count($manifest['scripts']), + 'manifest' => $manifest, + ); + } + + public static function directory($theme, $directory = '') + { + $theme = self::assertTheme($theme); + $directory = self::normalizeDirectory($directory); + $absolute = self::existingPath($theme, $directory, true); + $manifest = ThemeAssets::manifest($theme); + $connected = array(); + foreach ($manifest['styles'] as $item) { $connected[$item['file']] = 'CSS'; } + foreach ($manifest['scripts'] as $item) { $connected[$item['file']] = 'JS'; } + $items = array(); + foreach (new \DirectoryIterator($absolute) as $entry) { + if ($entry->isDot() || $entry->isLink()) { continue; } + $name = $entry->getFilename(); + $path = ltrim($directory . '/' . $name, '/'); + if ($entry->isDir()) { + if (self::hiddenSegment($name)) { continue; } + $items[] = array( + 'type' => 'directory', 'name' => $name, 'path' => $path, 'extension' => '', + 'size' => 0, 'size_label' => '', 'modified' => $entry->getMTime(), + 'modified_label' => date('d.m.Y H:i', $entry->getMTime()), 'editable' => false, + 'connected' => '', 'url' => '', 'preview' => '', + ); + continue; + } + + if (!$entry->isFile() || ($name !== ThemeAssets::MANIFEST && !ThemeAssets::publicExtension($path) && !ThemeAssets::viewTemplate($path))) { continue; } + $extension = strtolower((string) pathinfo($name, PATHINFO_EXTENSION)); + $url = $name === ThemeAssets::MANIFEST || ThemeAssets::viewTemplate($path) ? '' : ThemeAssets::assetUrl($path, $theme); + $items[] = array( + 'type' => 'file', 'name' => $name, 'path' => $path, 'extension' => $extension, + 'size' => $entry->getSize(), 'size_label' => self::formatBytes($entry->getSize()), + 'modified' => $entry->getMTime(), 'modified_label' => date('d.m.Y H:i', $entry->getMTime()), + 'editable' => $name === ThemeAssets::MANIFEST || ThemeAssets::editableText($path), + 'connected' => isset($connected[$path]) ? $connected[$path] : '', + 'url' => $url, 'preview' => self::imageExtension($extension) ? $url : '', + ); + } + + usort($items, function ($left, $right) { + if ($left['type'] !== $right['type']) { return $left['type'] === 'directory' ? -1 : 1; } + return strnatcasecmp($left['name'], $right['name']); + }); + + return array( + 'theme' => self::theme($theme), 'directory' => $directory, 'items' => $items, + 'breadcrumbs' => self::breadcrumbs($directory), + ); + } + + public static function file($theme, $path) + { + $theme = self::assertTheme($theme); + $path = self::assertAssetPath($path, true); + $absolute = self::existingPath($theme, $path, false); + if (!is_file($absolute)) { throw new \RuntimeException('Файл темы не найден'); } + $editable = basename($path) === ThemeAssets::MANIFEST || ThemeAssets::editableText($path); + $size = filesize($absolute); + if ($editable && $size > self::MAX_TEXT_BYTES) { throw new \RuntimeException('Файл слишком большой для редактора'); } + $extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION)); + return array( + 'theme' => $theme, 'path' => $path, 'name' => basename($path), 'extension' => $extension, + 'editable' => $editable, 'content' => $editable ? (string) file_get_contents($absolute) : '', + 'mode' => self::editorMode($extension), 'size' => $size, 'size_label' => self::formatBytes($size), + 'modified' => filemtime($absolute), 'modified_label' => date('d.m.Y H:i:s', filemtime($absolute)), + 'url' => basename($path) === ThemeAssets::MANIFEST || ThemeAssets::viewTemplate($path) ? '' : ThemeAssets::assetUrl($path, $theme), + 'preview' => self::imageExtension($extension) ? ThemeAssets::assetUrl($path, $theme) : '', + ); + } + + public static function saveFile($theme, $path, $content, $authorId, $action = 'update', $sourceRevisionId = 0, $invalidate = true) + { + $theme = self::assertTheme($theme); + $path = self::assertAssetPath($path, true); + if (basename($path) !== ThemeAssets::MANIFEST && !ThemeAssets::editableText($path)) { + throw new \RuntimeException('Этот тип файла нельзя редактировать как текст'); + } + + $content = (string) $content; + if (strlen($content) > self::MAX_TEXT_BYTES) { throw new \RuntimeException('Текстовый файл не должен превышать 2 МБ'); } + $content = self::validateText($path, $content); + $absolute = self::targetPath($theme, $path); + $exists = is_file($absolute); + if ($exists && is_link($absolute)) { throw new \RuntimeException('Символические ссылки редактировать нельзя'); } + if ($exists && (string) file_get_contents($absolute) === $content) { return self::file($theme, $path); } + + if ($exists) { + Revisions::capture($theme, $path, 'backup', (string) file_get_contents($absolute), $authorId, 0); + } + + if (!File::putAtomic($absolute, $content)) { throw new \RuntimeException('Не удалось записать файл темы'); } + Revisions::capture($theme, $path, $exists ? $action : 'create', $content, $authorId, $sourceRevisionId); + if ($invalidate) { self::invalidate($theme); } + return self::file($theme, $path); + } + + public static function createFile($theme, $directory, $name, $authorId) + { + $directory = self::normalizeDirectory($directory); + $name = self::safeName($name); + $path = ltrim($directory . '/' . $name, '/'); + self::assertAssetPath($path, false); + if (!ThemeAssets::editableText($path)) { throw new \InvalidArgumentException('Создать можно CSS, JS, JSON, SVG, XML, Twig или текстовый файл'); } + if (file_exists(self::targetPath(self::assertTheme($theme), $path))) { throw new \RuntimeException('Файл с таким именем уже существует'); } + $content = self::initialContent($path); + return self::saveFile($theme, $path, $content, $authorId, 'create'); + } + + public static function createDirectory($theme, $directory, $name) + { + $theme = self::assertTheme($theme); + $directory = self::normalizeDirectory($directory); + $name = self::safeName($name, false); + if (self::hiddenSegment($name)) { throw new \InvalidArgumentException('Это имя каталога зарезервировано'); } + $path = ltrim($directory . '/' . $name, '/'); + $absolute = self::targetPath($theme, $path); + if (file_exists($absolute)) { throw new \RuntimeException('Каталог с таким именем уже существует'); } + if (!@mkdir($absolute, 0775, true)) { throw new \RuntimeException('Не удалось создать каталог'); } + return self::directory($theme, $directory); + } + + public static function upload($theme, $directory, array $files, $authorId) + { + $theme = self::assertTheme($theme); + $directory = self::normalizeDirectory($directory); + $names = isset($files['name']) ? (array) $files['name'] : array(); + $tmpNames = isset($files['tmp_name']) ? (array) $files['tmp_name'] : array(); + $sizes = isset($files['size']) ? (array) $files['size'] : array(); + $errors = isset($files['error']) ? (array) $files['error'] : array(); + $result = array('uploaded' => array(), 'errors' => array()); + foreach ($names as $index => $original) { + try { + if (!isset($errors[$index]) || (int) $errors[$index] !== UPLOAD_ERR_OK) { throw new \RuntimeException('Файл загрузился не полностью'); } + $tmp = isset($tmpNames[$index]) ? (string) $tmpNames[$index] : ''; + $size = isset($sizes[$index]) ? (int) $sizes[$index] : 0; + if ($tmp === '' || !is_uploaded_file($tmp) || $size < 1 || $size > self::MAX_FILE_BYTES) { throw new \RuntimeException('Размер файла должен быть от 1 байта до 10 МБ'); } + $name = self::safeName($original); + $path = ltrim($directory . '/' . $name, '/'); + self::assertAssetPath($path, false); + if (!ThemeAssets::publicExtension($path) && !ThemeAssets::viewTemplate($path)) { throw new \RuntimeException('Расширение файла не разрешено для темы'); } + $target = self::targetPath($theme, $path); + if (file_exists($target)) { throw new \RuntimeException('Файл уже существует'); } + $content = (string) file_get_contents($tmp); + self::validateUploadedContent($path, $tmp, $content); + if (ThemeAssets::editableText($path)) { + self::saveFile($theme, $path, self::validateText($path, $content), $authorId, 'upload', 0, false); + } elseif (!File::putAtomic($target, $content)) { + throw new \RuntimeException('Не удалось сохранить файл'); + } + + $result['uploaded'][] = $path; + } catch (\Throwable $e) { + $result['errors'][] = basename((string) $original) . ': ' . $e->getMessage(); + } + } + + self::invalidate($theme); + return $result; + } + + public static function deletePath($theme, $path, $authorId) + { + $theme = self::assertTheme($theme); + $path = self::assertAssetPath($path, false); + if (basename($path) === ThemeAssets::MANIFEST) { throw new \RuntimeException('Реестр темы удалить нельзя'); } + $absolute = self::existingPath($theme, $path, null); + if (is_file($absolute)) { + if (ThemeAssets::editableText($path)) { Revisions::capture($theme, $path, 'delete', (string) file_get_contents($absolute), $authorId, 0); } + if (!@unlink($absolute)) { throw new \RuntimeException('Не удалось удалить файл'); } + } elseif (is_dir($absolute)) { + self::removeAssetDirectory($absolute, $theme, $path, $authorId); + } else { + throw new \RuntimeException('Файл или каталог не найден'); + } + + self::removeFromManifest($theme, $path, $authorId); + self::invalidate($theme); + return true; + } + + public static function saveManifest($theme, array $input, $authorId) + { + $theme = self::assertTheme($theme); + $current = ThemeAssets::manifest($theme); + $styles = self::manifestRows(isset($input['styles']) ? $input['styles'] : array(), 'style', $theme); + $scripts = self::manifestRows(isset($input['scripts']) ? $input['scripts'] : array(), 'script', $theme); + $manifest = ThemeAssets::normalizeManifest(array( + 'name' => isset($input['name']) ? $input['name'] : '', + 'version' => isset($input['version']) ? $input['version'] : '', + 'styles' => $styles, 'scripts' => $scripts, + 'presentation_mode' => isset($current['presentation_mode']) ? $current['presentation_mode'] : 'native', + 'page_shell' => isset($current['page_shell']) ? $current['page_shell'] : '', + 'page_shell_mode' => isset($current['page_shell_mode']) ? $current['page_shell_mode'] : 'wrap', + 'inherit_rubric_templates' => !empty($current['inherit_rubric_templates']), + 'view_overrides' => isset($current['view_overrides']) ? $current['view_overrides'] : array(), + 'settings' => isset($current['settings']) ? $current['settings'] : array(), + ), $theme); + $content = Json::encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + self::saveFile($theme, ThemeAssets::MANIFEST, $content, $authorId, 'manifest'); + return $manifest; + } + + public static function saveSettings($theme, array $input, $authorId) + { + $theme = self::assertTheme($theme); + $manifest = ThemeAssets::manifest($theme); + $mode = isset($input['presentation_mode']) && (string) $input['presentation_mode'] === 'theme' + ? 'theme' + : 'native'; + if ($mode === 'theme' && empty($manifest['page_shell'])) { + throw new \RuntimeException('В теме не указан файл Twig-оболочки'); + } + + $result = ThemeSettings::save($theme, $input); + if (empty($result['success'])) { + $message = !empty($result['errors']) ? reset($result['errors']) : 'Проверьте значения настроек темы'; + throw new \InvalidArgumentException((string) $message); + } + + $manifest['presentation_mode'] = $mode; + $manifest = ThemeAssets::normalizeManifest($manifest, $theme); + $content = Json::encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + self::saveFile($theme, ThemeAssets::MANIFEST, $content, $authorId, 'settings'); + return array('manifest' => $manifest, 'saved' => (int) $result['saved']); + } + + public static function availableAssets($theme) + { + $theme = self::assertTheme($theme); + $out = array('styles' => array(), 'scripts' => array()); + $root = self::themeRoot($theme); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveCallbackFilterIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), function ($current) use ($root) { + if ($current->isLink()) { return false; } + if ($current->isDir() && self::hiddenSegment($current->getFilename())) { return false; } + return true; + }) + ); + foreach ($iterator as $file) { + if (!$file->isFile() || $file->isLink()) { continue; } + $path = ltrim(str_replace('\\', '/', substr($file->getPathname(), strlen($root))), '/'); + $extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION)); + if ($extension === 'css') { $out['styles'][] = $path; } + if (in_array($extension, array('js', 'mjs'), true)) { $out['scripts'][] = $path; } + } + + sort($out['styles'], SORT_NATURAL | SORT_FLAG_CASE); + sort($out['scripts'], SORT_NATURAL | SORT_FLAG_CASE); + return $out; + } + + public static function createTheme($code, $name, $authorId) + { + $code = self::validateThemeCode($code); + $root = self::themesRoot() . '/' . $code; + if (file_exists($root)) { throw new \RuntimeException('Тема с таким кодом уже существует'); } + if (!@mkdir($root . '/css', 0775, true) || !@mkdir($root . '/js', 0775, true) + || !@mkdir($root . '/images', 0775, true) || !@mkdir($root . '/fonts', 0775, true)) { + self::removeTree($root); + throw new \RuntimeException('Не удалось создать структуру темы'); + } + + File::putAtomic($root . '/index.php', self::guardFile()); + File::putAtomic($root . '/css/app.css', "/* Styles for theme " . $code . " */\n"); + File::putAtomic($root . '/js/app.js', "/* Scripts for theme " . $code . " */\n"); + self::saveManifest($code, array( + 'name' => trim((string) $name) !== '' ? $name : ucfirst($code), 'version' => '1.0.0', + 'styles' => array(array('file' => 'css/app.css', 'media' => '')), + 'scripts' => array(array('file' => 'js/app.js', 'defer' => 1)), + ), $authorId); + return self::theme($code); + } + + public static function activate($theme) + { + $theme = self::assertTheme($theme); + $table = SystemTables::table('constants'); + if (DB::query('SELECT name FROM ' . $table . ' WHERE name=%s LIMIT 1', 'DEFAULT_THEME_FOLDER')->getValue()) { + DB::Update($table, array('value' => $theme, 'type' => 'select', 'updated_at' => date('Y-m-d H:i:s')), 'name=%s', 'DEFAULT_THEME_FOLDER'); + } else { + DB::Insert($table, array( + 'name' => 'DEFAULT_THEME_FOLDER', 'value' => $theme, 'type' => 'select', 'group_code' => '_CONST_THEMES', + 'label' => 'Тема публичной части', 'description' => 'Активная тема публичной части.', 'options' => '[]', + 'is_system' => 1, 'sort_order' => 40, 'updated_at' => date('Y-m-d H:i:s'), + )); + } + + self::invalidate($theme, true); + $item = self::theme($theme); + $item['active'] = true; + return $item; + } + + public static function deleteTheme($theme) + { + $theme = self::assertTheme($theme); + if ($theme === ThemeAssets::currentTheme()) { throw new \RuntimeException('Активную тему удалить нельзя'); } + if (!self::removeTree(self::themeRoot($theme))) { throw new \RuntimeException('Не удалось удалить каталог темы'); } + Revisions::deleteTheme($theme); + self::invalidate($theme); + return true; + } + + public static function exportArchive($theme) + { + $theme = self::assertTheme($theme); + if (!class_exists('ZipArchive')) { throw new \RuntimeException('Для экспорта темы требуется PHP-расширение zip'); } + $file = tempnam(BASEPATH . '/tmp', 'ave-theme-'); + if ($file === false) { throw new \RuntimeException('Не удалось создать временный ZIP'); } + $archive = $file . '.zip'; + @rename($file, $archive); + $zip = new \ZipArchive(); + if ($zip->open($archive, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { @unlink($archive); throw new \RuntimeException('Не удалось создать ZIP темы'); } + $root = self::themeRoot($theme); + $files = self::exportFiles($theme); + foreach ($files as $path) { $zip->addFile($root . '/' . $path, $path); } + if (!in_array(ThemeAssets::MANIFEST, $files, true)) { + $zip->addFromString( + ThemeAssets::MANIFEST, + Json::encode(ThemeAssets::manifest($theme), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n" + ); + } + + $zip->close(); + return array('path' => $archive, 'name' => 'ave-theme-' . $theme . '-' . date('Ymd-His') . '.zip'); + } + + public static function importArchive(array $file, $theme, $authorId) + { + $theme = self::validateThemeCode($theme); + if (file_exists(self::themesRoot() . '/' . $theme)) { throw new \RuntimeException('Тема с таким кодом уже существует'); } + if (!class_exists('ZipArchive')) { throw new \RuntimeException('Для импорта темы требуется PHP-расширение zip'); } + self::validateArchiveUpload($file); + $zip = new \ZipArchive(); + if ($zip->open((string) $file['tmp_name']) !== true) { throw new \RuntimeException('Не удалось открыть ZIP-архив'); } + $entries = array(); + $total = 0; + try { + if ($zip->numFiles < 1 || $zip->numFiles > self::MAX_ARCHIVE_FILES) { throw new \RuntimeException('ZIP содержит недопустимое количество файлов'); } + $prefix = self::archivePrefix($zip); + for ($index = 0; $index < $zip->numFiles; $index++) { + $stat = $zip->statIndex($index); + if (!is_array($stat) || !isset($stat['name'])) { throw new \RuntimeException('Не удалось прочитать структуру ZIP'); } + self::assertArchiveNotSymlink($zip, $index); + $name = self::archiveEntry((string) $stat['name'], $prefix); + if ($name === null || substr($name, -1) === '/') { continue; } + $size = isset($stat['size']) ? (int) $stat['size'] : 0; + $total += $size; + if ($size > self::MAX_FILE_BYTES || $total > self::MAX_ARCHIVE_UNPACKED) { throw new \RuntimeException('Распакованная тема превышает безопасный лимит'); } + self::assertAssetPath($name, true); + if (basename($name) !== ThemeAssets::MANIFEST && !ThemeAssets::publicExtension($name) && !ThemeAssets::viewTemplate($name)) { throw new \RuntimeException('ZIP содержит запрещённый файл «' . $name . '»'); } + if (isset($entries[strtolower($name)])) { throw new \RuntimeException('ZIP содержит повторяющийся путь «' . $name . '»'); } + $entries[strtolower($name)] = array('index' => $index, 'path' => $name); + } + + if (!isset($entries[ThemeAssets::MANIFEST])) { throw new \RuntimeException('ZIP не содержит theme.json'); } + $manifestRaw = $zip->getFromIndex($entries[ThemeAssets::MANIFEST]['index']); + $manifest = Json::toArray((string) $manifestRaw); + if (!isset($manifest['format']) || $manifest['format'] !== 'ave-theme-v1') { throw new \RuntimeException('theme.json имеет неподдерживаемый формат'); } + $manifest = ThemeAssets::normalizeManifest($manifest, $theme); + foreach (array_merge($manifest['styles'], $manifest['scripts']) as $asset) { + if (!isset($entries[strtolower($asset['file'])])) { + throw new \RuntimeException('theme.json ссылается на отсутствующий файл «' . $asset['file'] . '»'); + } + } + + if ($manifest['page_shell'] !== '' && !isset($entries[strtolower($manifest['page_shell'])])) { + throw new \RuntimeException('theme.json ссылается на отсутствующую оболочку «' . $manifest['page_shell'] . '»'); + } + + $manifestContent = Json::encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + $root = self::themesRoot() . '/' . $theme; + if (!@mkdir($root, 0775, true)) { throw new \RuntimeException('Не удалось создать каталог темы'); } + try { + foreach ($entries as $entry) { + $content = $zip->getFromIndex($entry['index']); + if ($content === false) { throw new \RuntimeException('Не удалось извлечь «' . $entry['path'] . '»'); } + $content = basename($entry['path']) === ThemeAssets::MANIFEST + ? $manifestContent + : (ThemeAssets::editableText($entry['path']) ? self::validateText($entry['path'], (string) $content) : (string) $content); + self::validateUploadedContent($entry['path'], '', $content); + if (!File::putAtomic($root . '/' . $entry['path'], $content)) { throw new \RuntimeException('Не удалось записать «' . $entry['path'] . '»'); } + } + + File::putAtomic($root . '/index.php', self::guardFile()); + } catch (\Throwable $e) { + self::removeTree($root); + throw $e; + } + } finally { + $zip->close(); + } + + Revisions::capture($theme, ThemeAssets::MANIFEST, 'import', (string) file_get_contents(self::themeRoot($theme) . '/' . ThemeAssets::MANIFEST), $authorId, 0); + self::invalidate($theme); + return self::theme($theme); + } + + public static function invalidate($theme = '', $forcePublic = false) + { + ThemeAssets::clearRuntimeCache(); + if ($forcePublic || ((string) $theme !== '' && (string) $theme === ThemeAssets::currentTheme())) { + FileCacheInvalidator::publicPresentation(); + } + } + + public static function formatBytes($bytes) + { + $bytes = max(0, (int) $bytes); + if ($bytes < 1024) { return $bytes . ' Б'; } + if ($bytes < 1048576) { return round($bytes / 1024, 1) . ' KB'; } + return round($bytes / 1048576, 1) . ' MB'; + } + + protected static function themesRoot() + { + return rtrim(str_replace('\\', '/', BASEPATH), '/') . '/templates'; + } + + protected static function themeRoot($theme) + { + return self::themesRoot() . '/' . (string) $theme; + } + + protected static function assertTheme($theme) + { + $theme = self::validateThemeCode($theme); + $root = self::themeRoot($theme); + if (!is_dir($root) || is_link($root)) { throw new \InvalidArgumentException('Тема не найдена'); } + return $theme; + } + + protected static function validateThemeCode($theme) + { + $theme = strtolower(trim((string) $theme)); + if (!ThemeAssets::validTheme($theme)) { throw new \InvalidArgumentException('Код темы: латиница, цифры, _ и -, до 64 символов'); } + return $theme; + } + + protected static function normalizeDirectory($directory) + { + $directory = trim(str_replace('\\', '/', (string) $directory), '/'); + if ($directory === '') { return ''; } + return self::assertAssetPath($directory, false); + } + + protected static function assertAssetPath($path, $allowManifest) + { + $path = ThemeAssets::normalizePath($path); + if ($path === '') { throw new \InvalidArgumentException('Некорректный путь файла темы'); } + foreach (explode('/', $path) as $segment) { + if (self::hiddenSegment($segment)) { throw new \InvalidArgumentException('Служебный каталог темы недоступен'); } + if (preg_match('/(?:^|\.)(?:php\d*|phtml|phar|cgi|pl|py|sh|htaccess|user\.ini)(?:\.|$)/i', $segment)) { + throw new \InvalidArgumentException('Исполняемые и серверные файлы в теме запрещены'); + } + } + + if (basename($path) === ThemeAssets::MANIFEST && (!$allowManifest || $path !== ThemeAssets::MANIFEST)) { + throw new \InvalidArgumentException('Служебный файл темы доступен только в корне темы'); + } + + return $path; + } + + protected static function targetPath($theme, $path) + { + $root = realpath(self::themeRoot($theme)); + if ($root === false) { throw new \RuntimeException('Каталог темы не найден'); } + $parent = dirname(self::themeRoot($theme) . '/' . $path); + if (!is_dir($parent) && !@mkdir($parent, 0775, true)) { throw new \RuntimeException('Не удалось создать каталог ассета'); } + self::assertInsideRoot($parent, $root, true); + return rtrim(str_replace('\\', '/', $root), '/') . '/' . $path; + } + + protected static function existingPath($theme, $path, $directory) + { + $root = realpath(self::themeRoot($theme)); + $absolute = realpath(self::themeRoot($theme) . ($path !== '' ? '/' . $path : '')); + if ($root === false || $absolute === false) { throw new \RuntimeException('Путь темы не найден'); } + self::assertInsideRoot($absolute, $root, $path === ''); + if (is_link(self::themeRoot($theme) . ($path !== '' ? '/' . $path : ''))) { throw new \RuntimeException('Символические ссылки недоступны'); } + if ($directory === true && !is_dir($absolute)) { throw new \RuntimeException('Каталог темы не найден'); } + if ($directory === false && !is_file($absolute)) { throw new \RuntimeException('Файл темы не найден'); } + return str_replace('\\', '/', $absolute); + } + + protected static function assertInsideRoot($path, $root, $allowRoot = false) + { + $root = rtrim(str_replace('\\', '/', (string) realpath($root)), '/'); + $real = realpath($path); + $real = $real !== false ? rtrim(str_replace('\\', '/', $real), '/') : ''; + if ($root === '' || $real === '' || (!$allowRoot && strpos($real, $root . '/') !== 0) || ($allowRoot && $real !== $root && strpos($real, $root . '/') !== 0)) { + throw new \RuntimeException('Путь выходит за каталог темы'); + } + } + + protected static function safeName($name, $withExtension = true) + { + $name = trim(str_replace(array('/', '\\'), '', (string) $name)); + if ($name === '' || strlen($name) > 160 || $name[0] === '.' || preg_match('/[\x00-\x1F\x7F:*?"<>|]/u', $name) || preg_match('/[.\s]$/u', $name)) { + throw new \InvalidArgumentException('Некорректное имя файла или каталога'); + } + + if (!$withExtension && strpos($name, '.') !== false) { throw new \InvalidArgumentException('В имени каталога нельзя использовать точку'); } + return $name; + } + + protected static function hiddenSegment($segment) + { + return in_array(strtolower((string) $segment), self::$hiddenDirectories, true) || substr((string) $segment, 0, 1) === '.'; + } + + protected static function breadcrumbs($directory) + { + $out = array(array('name' => 'Корень темы', 'path' => '')); + $path = ''; + foreach ($directory === '' ? array() : explode('/', $directory) as $part) { + $path = ltrim($path . '/' . $part, '/'); + $out[] = array('name' => $part, 'path' => $path); + } + + return $out; + } + + protected static function themeStats($theme) + { + $stats = array('files' => 0, 'size' => 0); + $root = self::themeRoot($theme); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveCallbackFilterIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), function ($current) { + if ($current->isLink()) { return false; } + if ($current->isDir() && self::hiddenSegment($current->getFilename())) { return false; } + return true; + }) + ); + foreach ($iterator as $file) { + if ($file->isFile() && !$file->isLink()) { + $path = $file->getFilename(); + if ($path === ThemeAssets::MANIFEST || ThemeAssets::publicExtension($path)) { $stats['files']++; $stats['size'] += $file->getSize(); } + } + } + + return $stats; + } + + protected static function validateText($path, $content) + { + if (strpos((string) $content, "\0") !== false) { throw new \InvalidArgumentException('Текстовый файл содержит нулевой байт'); } + $extension = strtolower((string) pathinfo((string) $path, PATHINFO_EXTENSION)); + if ($extension === 'json' || $extension === 'map' || $extension === 'webmanifest') { + json_decode((string) $content, true); + if (json_last_error() !== JSON_ERROR_NONE) { throw new \InvalidArgumentException('JSON содержит синтаксическую ошибку: ' . json_last_error_msg()); } + if (basename((string) $path) === ThemeAssets::MANIFEST) { + $manifest = Json::toArray((string) $content); + if (!isset($manifest['format']) || $manifest['format'] !== 'ave-theme-v1') { + throw new \InvalidArgumentException('theme.json должен иметь формат ave-theme-v1'); + } + } + } + + if ($extension === 'svg') { return self::sanitizeSvg($content); } + return (string) $content; + } + + protected static function sanitizeSvg($content) + { + if (!class_exists('DOMDocument')) { throw new \RuntimeException('Для безопасной обработки SVG требуется DOM'); } + $previous = libxml_use_internal_errors(true); + $document = new \DOMDocument(); + $loaded = $document->loadXML((string) $content, LIBXML_NONET | LIBXML_NOBLANKS); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + if (!$loaded || !$document->documentElement || strtolower($document->documentElement->localName) !== 'svg') { + throw new \InvalidArgumentException('SVG содержит некорректный XML'); + } + + $blocked = array('script', 'foreignobject', 'iframe', 'object', 'embed', 'audio', 'video'); + $nodes = array(); + foreach ($document->getElementsByTagName('*') as $node) { $nodes[] = $node; } + foreach ($nodes as $node) { + if (in_array(strtolower($node->localName), $blocked, true)) { $node->parentNode->removeChild($node); continue; } + $attributes = array(); + foreach ($node->attributes ?: array() as $attribute) { $attributes[] = $attribute->name; } + foreach ($attributes as $name) { + $value = (string) $node->getAttribute($name); + if (stripos($name, 'on') === 0 || preg_match('/^\s*(?:javascript|data\s*:\s*text\/html)/i', $value)) { $node->removeAttribute($name); } + } + } + + return (string) $document->saveXML($document->documentElement); + } + + protected static function validateUploadedContent($path, $temporary, $content) + { + $extension = strtolower((string) pathinfo((string) $path, PATHINFO_EXTENSION)); + $images = array('gif' => IMAGETYPE_GIF, 'jpg' => IMAGETYPE_JPEG, 'jpeg' => IMAGETYPE_JPEG, 'png' => IMAGETYPE_PNG, 'bmp' => IMAGETYPE_BMP); + if (defined('IMAGETYPE_WEBP')) { $images['webp'] = IMAGETYPE_WEBP; } + if (isset($images[$extension])) { + $info = $temporary !== '' ? @getimagesize($temporary) : @getimagesizefromstring((string) $content); + if (!is_array($info) || !isset($info[2]) || (int) $info[2] !== (int) $images[$extension]) { throw new \InvalidArgumentException('Содержимое изображения не соответствует расширению'); } + } + + if ($extension === 'svg') { self::sanitizeSvg($content); } + if ((ThemeAssets::editableText($path) || in_array($extension, array('css', 'js', 'mjs', 'json', 'map', 'txt', 'xml', 'webmanifest'), true)) && strpos($content, "\0") !== false) { throw new \InvalidArgumentException('Текстовый ассет содержит бинарные данные'); } + } + + protected static function editorMode($extension) + { + if ($extension === 'css') { return 'text/css'; } + if (in_array($extension, array('js', 'mjs'), true)) { return 'text/javascript'; } + if (in_array($extension, array('json', 'map', 'webmanifest'), true)) { return 'application/json'; } + if (in_array($extension, array('svg', 'xml'), true)) { return 'xml'; } + return 'text/plain'; + } + + protected static function imageExtension($extension) + { + return in_array((string) $extension, array('png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg'), true); + } + + protected static function initialContent($path) + { + $extension = strtolower((string) pathinfo((string) $path, PATHINFO_EXTENSION)); + if ($extension === 'css') { return "/* Theme styles */\n"; } + if (in_array($extension, array('js', 'mjs'), true)) { return "'use strict';\n"; } + if (in_array($extension, array('json', 'map', 'webmanifest'), true)) { return "{}\n"; } + if ($extension === 'svg') { return '' . "\n"; } + if ($extension === 'twig') { return "{# Theme view override #}\n"; } + return ''; + } + + protected static function manifestRows($rows, $type, $theme) + { + $out = array(); + foreach (is_array($rows) ? $rows : array() as $row) { + if (!is_array($row)) { continue; } + $file = ThemeAssets::normalizePath(isset($row['file']) ? $row['file'] : ''); + if ($file === '') { continue; } + $extension = strtolower((string) pathinfo($file, PATHINFO_EXTENSION)); + if (($type === 'style' && $extension !== 'css') || ($type === 'script' && !in_array($extension, array('js', 'mjs'), true))) { continue; } + self::existingPath($theme, $file, false); + if ($type === 'style') { $out[] = array('file' => $file, 'media' => isset($row['media']) ? $row['media'] : ''); } + else { $out[] = array('file' => $file, 'defer' => !empty($row['defer']), 'async' => !empty($row['async']), 'module' => !empty($row['module'])); } + } + + return $out; + } + + protected static function removeFromManifest($theme, $path, $authorId) + { + $manifest = ThemeAssets::manifest($theme); + $prefix = rtrim((string) $path, '/') . '/'; + $manifest['styles'] = array_values(array_filter($manifest['styles'], function ($item) use ($path, $prefix) { return $item['file'] !== $path && strpos($item['file'], $prefix) !== 0; })); + $manifest['scripts'] = array_values(array_filter($manifest['scripts'], function ($item) use ($path, $prefix) { return $item['file'] !== $path && strpos($item['file'], $prefix) !== 0; })); + self::saveManifest($theme, $manifest, $authorId); + } + + protected static function removeAssetDirectory($absolute, $theme, $path, $authorId) + { + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($absolute, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item->isLink()) { throw new \RuntimeException('Каталог содержит символическую ссылку'); } + $relative = ltrim($path . '/' . substr(str_replace('\\', '/', $item->getPathname()), strlen(str_replace('\\', '/', $absolute)) + 1), '/'); + if ($item->isFile() && ThemeAssets::editableText($relative)) { Revisions::capture($theme, $relative, 'delete', (string) file_get_contents($item->getPathname()), $authorId, 0); } + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + + if (!@rmdir($absolute)) { throw new \RuntimeException('Не удалось удалить каталог'); } + } + + protected static function exportFiles($theme) + { + $out = array(); + $root = self::themeRoot($theme); + $iterator = new \RecursiveIteratorIterator(new \RecursiveCallbackFilterIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), function ($current) { + if ($current->isLink()) { return false; } + if ($current->isDir() && self::hiddenSegment($current->getFilename())) { return false; } + return true; + })); + foreach ($iterator as $file) { + if (!$file->isFile() || $file->isLink()) { continue; } + $path = ltrim(str_replace('\\', '/', substr($file->getPathname(), strlen($root))), '/'); + if (basename($path) === ThemeAssets::MANIFEST || ThemeAssets::publicExtension($path) || ThemeAssets::viewTemplate($path)) { $out[] = $path; } + } + + return $out; + } + + protected static function validateArchiveUpload(array $file) + { + if (!isset($file['error']) || (int) $file['error'] !== UPLOAD_ERR_OK || empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) { throw new \InvalidArgumentException('Выберите ZIP-архив темы'); } + $size = isset($file['size']) ? (int) $file['size'] : 0; + if ($size < 1 || $size > self::MAX_ARCHIVE_BYTES) { throw new \InvalidArgumentException('Размер ZIP должен быть не больше 50 МБ'); } + if (strtolower(pathinfo((string) $file['name'], PATHINFO_EXTENSION)) !== 'zip') { throw new \InvalidArgumentException('Поддерживается только ZIP-архив'); } + $handle = @fopen((string) $file['tmp_name'], 'rb'); + $head = $handle ? (string) fread($handle, 4) : ''; + if ($handle) { fclose($handle); } + if (!in_array($head, array("PK\x03\x04", "PK\x05\x06", "PK\x07\x08"), true)) { throw new \InvalidArgumentException('Файл не является ZIP-архивом'); } + } + + protected static function archivePrefix(\ZipArchive $zip) + { + $prefix = null; + for ($index = 0; $index < $zip->numFiles; $index++) { + $name = str_replace('\\', '/', (string) $zip->getNameIndex($index)); + $parts = explode('/', trim($name, '/')); + if (count($parts) < 2) { return ''; } + if ($prefix === null) { $prefix = $parts[0]; } + if ($parts[0] !== $prefix) { return ''; } + } + + return $prefix !== null ? $prefix . '/' : ''; + } + + protected static function archiveEntry($name, $prefix) + { + $name = str_replace('\\', '/', (string) $name); + if ($prefix !== '' && strpos($name, $prefix) === 0) { $name = substr($name, strlen($prefix)); } + $isDirectory = substr($name, -1) === '/'; + $name = trim($name, '/'); + if ($name === '') { return null; } + if ($name[0] === '/' || preg_match('/^[A-Za-z]:\//', $name) || strpos($name, "\0") !== false) { throw new \RuntimeException('ZIP содержит абсолютный путь'); } + foreach (explode('/', $name) as $segment) { + if ($segment === '' || $segment === '.' || $segment === '..' || self::hiddenSegment($segment)) { throw new \RuntimeException('ZIP содержит небезопасный путь'); } + } + + return $name . ($isDirectory ? '/' : ''); + } + + protected static function assertArchiveNotSymlink(\ZipArchive $zip, $index) + { + $opsys = 0; + $attributes = 0; + if ($zip->getExternalAttributesIndex($index, $opsys, $attributes) && (($attributes >> 16) & 0170000) === 0120000) { + throw new \RuntimeException('Символические ссылки в ZIP запрещены'); + } + } + + protected static function removeTree($path) + { + if (!file_exists($path)) { return true; } + if (is_link($path) || is_file($path)) { return @unlink($path); } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { $item->isDir() && !$item->isLink() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); } + return @rmdir($path); + } + + protected static function guardFile() + { + return " + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Themes; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\SystemTables; + use DB; + + class Revisions + { + public static function table() + { + return SystemTables::table('theme_asset_revisions'); + } + + public static function capture($theme, $path, $action, $content, $authorId, $sourceRevisionId) + { + $content = (string) $content; + $checksum = hash('sha256', $content); + $last = DB::query( + 'SELECT checksum,action FROM ' . self::table() . ' WHERE theme=%s AND asset_path=%s ORDER BY created_at DESC,id DESC LIMIT 1', + (string) $theme, (string) $path + )->getAssoc(); + if ($last && (string) $last['checksum'] === $checksum && (string) $last['action'] === (string) $action) { return 0; } + DB::Insert(self::table(), array( + 'theme' => (string) $theme, 'asset_path' => (string) $path, 'action' => (string) $action, + 'checksum' => $checksum, 'content' => $content, 'content_size' => strlen($content), + 'author_id' => (int) $authorId, 'author_name' => self::authorName($authorId), + 'source_revision_id' => (int) $sourceRevisionId, 'created_at' => time(), + )); + return (int) DB::insertId(); + } + + public static function listing($theme, $path = '', $limit = 200) + { + $sql = 'SELECT id,theme,asset_path,action,checksum,content_size,author_id,author_name,source_revision_id,created_at FROM ' . self::table() . ' WHERE theme=%s'; + $args = array((string) $theme); + if ((string) $path !== '') { $sql .= ' AND asset_path=%s'; $args[] = (string) $path; } + $sql .= ' ORDER BY created_at DESC,id DESC LIMIT %i'; + $args[] = max(1, min(500, (int) $limit)); + array_unshift($args, $sql); + $rows = call_user_func_array(array(DB::class, 'query'), $args)->getAll() ?: array(); + return array_map(array(__CLASS__, 'format'), $rows); + } + + public static function one($id) + { + $row = DB::query('SELECT * FROM ' . self::table() . ' WHERE id=%i LIMIT 1', (int) $id)->getAssoc(); + if (!$row) { return null; } + $row = self::format($row); + $row['content'] = (string) $row['raw']['content']; + unset($row['raw']); + return $row; + } + + public static function restore($id, $authorId) + { + $revision = self::one($id); + if (!$revision) { throw new \RuntimeException('Ревизия не найдена'); } + return Model::saveFile($revision['theme'], $revision['path'], $revision['content'], $authorId, 'restore', $revision['id']); + } + + public static function delete($id) + { + $exists = (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE id=%i', (int) $id)->getValue(); + if ($exists < 1) { return false; } + DB::Delete(self::table(), 'id=%i', (int) $id); + return true; + } + + public static function deleteForPath($theme, $path) + { + $count = (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE theme=%s AND asset_path=%s', (string) $theme, (string) $path)->getValue(); + DB::Delete(self::table(), 'theme=%s AND asset_path=%s', (string) $theme, (string) $path); + return $count; + } + + public static function deleteTheme($theme) + { + $count = (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE theme=%s', (string) $theme)->getValue(); + DB::Delete(self::table(), 'theme=%s', (string) $theme); + return $count; + } + + protected static function format(array $row) + { + $labels = array( + 'create' => array('Создание', 'badge-green'), 'upload' => array('Загрузка', 'badge-cyan'), + 'update' => array('Сохранение', 'badge-blue'), 'manifest' => array('Реестр', 'badge-violet'), + 'backup' => array('Предыдущая версия', 'badge-gray'), 'restore' => array('Восстановление', 'badge-amber'), + 'delete' => array('Удаление', 'badge-red'), 'import' => array('Импорт', 'badge-cyan'), + ); + $action = isset($row['action']) ? (string) $row['action'] : 'update'; + $label = isset($labels[$action]) ? $labels[$action] : array($action, 'badge-gray'); + $created = isset($row['created_at']) ? (int) $row['created_at'] : 0; + return array( + 'id' => (int) $row['id'], 'theme' => (string) $row['theme'], 'path' => (string) $row['asset_path'], + 'action' => $action, 'action_label' => $label[0], 'badge' => $label[1], + 'checksum' => (string) $row['checksum'], 'size' => (int) $row['content_size'], + 'size_label' => Model::formatBytes((int) $row['content_size']), 'author_id' => (int) $row['author_id'], + 'author_name' => (string) $row['author_name'], 'source_revision_id' => (int) $row['source_revision_id'], + 'created_at' => $created, 'created_label' => $created ? date('d.m.Y H:i:s', $created) : '-', 'raw' => $row, + ); + } + + protected static function authorName($id) + { + if ((int) $id <= 0) { return ''; } + $row = DB::query('SELECT name,login,email FROM ' . SystemTables::table('users') . ' WHERE id=%i LIMIT 1', (int) $id)->getAssoc(); + if (!$row) { return '#' . (int) $id; } + return !empty($row['name']) ? (string) $row['name'] : (!empty($row['login']) ? '@' . $row['login'] : (string) $row['email']); + } + } diff --git a/adminx/modules/Themes/assets/themes.css b/adminx/modules/Themes/assets/themes.css new file mode 100644 index 0000000..08b4936 --- /dev/null +++ b/adminx/modules/Themes/assets/themes.css @@ -0,0 +1,685 @@ +.themes-page-header .between { + align-items: flex-start; + gap: 18px; +} +.themes-page-header h1, +.themes-section-head h3, +.themes-panel-header h2 { + text-wrap: balance; +} +.themes-page-header p, +.themes-section-head p { + text-wrap: pretty; +} +.themes-header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; +} +.themes-picker { + width: min(310px, 32vw); +} +.themes-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 18px; +} +.themes-stat { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; + padding: 16px; +} +.themes-stat b { + display: block; + overflow: hidden; + font-size: 20px; + font-weight: 800; + line-height: 1.1; + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.themes-stat div > span { + color: var(--text-secondary); + font-size: 13px; +} +.themes-panel a, +.themes-panel a:hover { + text-decoration: none; +} +.themes-panel-header { + margin-bottom: 18px; +} +.themes-panel-header h2 { + font-size: 22px; + font-weight: 800; + line-height: 1.2; +} +.themes-tabs > .tabs-list { + margin-bottom: 18px; + overflow-x: auto; + scrollbar-width: thin; +} +.themes-tabs { + display: block; +} +.themes-tabs > .tabs-list .tab { + min-height: 40px; + white-space: nowrap; +} +.themes-tabs > [data-tab-panel] { + width: 100%; + min-width: 0; +} +.themes-card { + padding: 0; + overflow: hidden; +} +.themes-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid var(--border-default); +} +.themes-section-title { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} +.themes-section-title .icon-tile { + flex: 0 0 36px; + width: 36px; + height: 36px; +} +.themes-section-title h3 { + margin: 0 0 4px; + font-size: 16px; + font-weight: 700; + line-height: 1.25; +} +.themes-section-title p { + margin: 0; + font-size: 13px; + line-height: 1.45; +} +.themes-pathbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + min-height: 48px; + padding: 8px 20px; + background: var(--bg-subtle, rgba(15, 23, 42, 0.025)); + border-bottom: 1px solid var(--border-default); +} +.themes-pathbar a { + padding: 6px 8px; + border-radius: 6px; + color: var(--text-primary); + font-size: 13px; + font-weight: 600; + transition-property: background-color, color; + transition-duration: 150ms; +} +.themes-pathbar a:hover { + background: var(--blue-100); + color: var(--blue-700, var(--blue-600)); +} +.themes-pathbar i { + color: var(--text-muted); + font-size: 12px; +} +.themes-panel .table-scroll { + max-height: none; + overflow-y: visible; +} +.themes-files-table, +.themes-revisions-table { + width: 100%; + min-width: 0; + table-layout: fixed; +} +.themes-col-name { + width: auto; +} +.themes-col-type { + width: 120px; +} +.themes-col-size { + width: 100px; +} +.themes-col-date { + width: 150px; +} +.themes-col-actions { + width: 142px; +} +.themes-file-name { + display: flex; + align-items: center; + gap: 12px; + max-width: 100%; + min-width: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--text-primary); + text-align: left; + cursor: pointer; +} +.themes-file-name > span:last-child { + display: grid; + gap: 2px; + min-width: 0; +} +.themes-file-name b, +.themes-file-name small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.themes-file-name small { + color: var(--text-secondary); + font-size: 12px; +} +.themes-file-icon { + display: grid; + place-items: center; + flex: 0 0 40px; + width: 40px; + height: 40px; + overflow: hidden; + border-radius: 7px; + background: var(--blue-100); + color: var(--blue-600); + font-size: 21px; +} +.themes-file-icon.is-folder { + background: var(--amber-100); + color: var(--amber-600); +} +.themes-file-icon img { + width: 100%; + height: 100%; + object-fit: cover; + outline: 1px solid rgba(0, 0, 0, 0.1); + outline-offset: -1px; +} +[data-theme="dark"] .themes-file-icon img { + outline-color: rgba(255, 255, 255, 0.1); +} +.themes-row-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: nowrap; + gap: 4px; + width: max-content; + margin-left: auto; +} +.themes-row-actions .btn { + transition-property: background-color, color, transform; + transition-duration: 150ms; +} +.themes-row-actions .btn:active { + transform: scale(0.96); +} +.themes-action-edit, +.themes-action-revision { + color: var(--blue-600); +} +.themes-action-delete { + color: var(--red-600); +} +.themes-upload-button { + position: relative; + cursor: pointer; +} +.themes-form-body { + padding: 20px; +} +.themes-settings-form { + display: grid; + gap: 16px; +} +.themes-mode-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.themes-mode-card { + display: grid; + grid-template-columns: 42px minmax(0, 1fr) 22px; + align-items: center; + gap: 12px; + min-height: 96px; + padding: 14px; + border: 1px solid var(--border-default); + border-radius: 8px; + background: var(--surface-card); + cursor: pointer; + transition: border-color 150ms, background-color 150ms, box-shadow 150ms; +} +.themes-mode-card:hover { + border-color: var(--blue-400); + background: var(--blue-100); +} +.themes-mode-card.is-selected { + border-color: var(--blue-500); + box-shadow: inset 0 0 0 1px var(--blue-500); +} +.themes-mode-card.is-disabled { + opacity: 0.56; + cursor: not-allowed; +} +.themes-mode-card input { + position: absolute; + opacity: 0; + pointer-events: none; +} +.themes-mode-card > span:nth-of-type(2) { + min-width: 0; +} +.themes-mode-card b, +.themes-mode-card small { + display: block; +} +.themes-mode-card b { + margin-bottom: 4px; + color: var(--text-primary); + font-size: 14px; +} +.themes-mode-card small { + color: var(--text-secondary); + font-size: 12.5px; + line-height: 1.45; +} +.themes-mode-check { + color: var(--blue-600); + font-size: 21px; + opacity: 0; + transform: scale(0.8); + transition: opacity 150ms, transform 150ms; +} +.themes-mode-card.is-selected .themes-mode-check { + opacity: 1; + transform: scale(1); +} +.themes-mode-note { + margin: 16px 0 0; +} +.themes-settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px 20px; + padding: 20px; +} +.themes-settings-field { + min-width: 0; +} +.themes-settings-field.is-wide, +.themes-settings-section { + grid-column: 1 / -1; +} +.themes-settings-section { + display: flex; + align-items: center; + gap: 12px; + padding-top: 4px; + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} +.themes-settings-section::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border-default); +} +.themes-setting-switch { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 68px; + padding: 11px 14px; + border: 1px solid var(--border-default); + border-radius: 8px; + cursor: pointer; +} +.themes-setting-switch b, +.themes-setting-switch small { + display: block; +} +.themes-setting-switch small { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12px; +} +.themes-section-order { + display: grid; + overflow: hidden; + border: 1px solid var(--border-default); + border-radius: 8px; + background: var(--surface-muted); +} +.themes-section-order-row { + display: grid; + grid-template-columns: 38px 36px minmax(0, 1fr) auto 44px; + align-items: center; + gap: 10px; + min-height: 62px; + padding: 8px 12px 8px 8px; + border-bottom: 1px solid var(--border-default); + background: var(--surface-card); + transition: background-color 150ms, opacity 150ms, transform 150ms; +} +.themes-section-order-row:last-child { + border-bottom: 0; +} +.themes-section-order-row.is-disabled { + opacity: 0.58; + background: var(--surface-muted); +} +.themes-section-order-row.is-dragging { + opacity: 0.42; + transform: scale(0.995); +} +.themes-section-order-row > span:nth-of-type(2) { + min-width: 0; +} +.themes-section-order-row b, +.themes-section-order-row small { + display: block; +} +.themes-section-order-row b { + font-size: 13.5px; +} +.themes-section-order-row small { + margin-top: 2px; + color: var(--text-secondary); + font: 11.5px/1.35 var(--font-mono); +} +.themes-section-order-row .icon-tile { + width: 34px; + height: 34px; + background: var(--green-100); + color: var(--green-600); +} +.themes-section-order-handle { + cursor: grab; +} +.themes-section-order-handle:active { + cursor: grabbing; +} +.themes-connection-grid { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + margin-top: 16px; +} +.themes-manifest-list { + display: grid; + gap: 8px; + padding: 16px; +} +.themes-manifest-list:empty::before { + content: "Файлы пока не подключены"; + display: grid; + place-items: center; + min-height: 76px; + color: var(--text-secondary); + font-size: 13px; +} +.themes-manifest-row { + display: grid; + grid-template-columns: 40px minmax(220px, 1fr) minmax(180px, 0.55fr) 40px; + align-items: center; + gap: 8px; + padding: 8px; + border-radius: 8px; + background: var(--bg-subtle, rgba(15, 23, 42, 0.035)); + box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.06); + transition-property: opacity, box-shadow, transform; + transition-duration: 150ms; +} +.themes-manifest-row.is-script { + grid-template-columns: 40px minmax(220px, 1fr) auto auto auto 40px; +} +.themes-manifest-row.is-dragging { + opacity: 0.58; + transform: scale(0.99); + box-shadow: 0 8px 22px rgba(15, 23, 42, 0.12); +} +.themes-drag { + display: grid; + place-items: center; + width: 40px; + height: 40px; + padding: 0; + border: 0; + background: transparent; + color: var(--text-muted); + cursor: grab; +} +.themes-drag:active { + cursor: grabbing; +} +.themes-switch { + min-width: 76px; +} +.themes-switch b { + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; +} +.themes-sticky-actions { + justify-content: space-between; + gap: 16px; + margin-top: 16px; +} +.themes-sticky-actions .text-secondary { + flex: 1 1 320px; +} +.themes-revisions-table tr:not(.themes-empty-row) td:first-child { + display: grid; + gap: 3px; + min-width: 0; +} +.themes-revisions-table tr:not(.themes-empty-row) td:first-child b, +.themes-revisions-table tr:not(.themes-empty-row) td:first-child small { + overflow-wrap: anywhere; +} +.themes-revisions-table .themes-empty-row td { + display: table-cell; + width: auto; +} +#themeFileDrawer, +#themeRevisionDrawer { + width: min(66vw, 1320px); + max-width: calc(100vw - 34px); + overflow-x: hidden; +} +.themes-drawer-form { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow-x: hidden; +} +.themes-drawer-form .drawer-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 16px; + min-width: 0; + min-height: 0; + overflow-x: hidden; +} +.themes-drawer-form .CodeMirror { + flex: 1 1 auto; + min-width: 0; + min-height: 420px; +} +.themes-drawer-form .drawer-footer { + justify-content: space-between; + gap: 22px; +} +.themes-dropzone { + display: grid; + place-items: center; + gap: 7px; + min-height: 190px; + padding: 24px; + border: 1px dashed var(--border-strong, var(--border-default)); + border-radius: 8px; + background: var(--bg-subtle, rgba(15, 23, 42, 0.025)); + color: var(--text-secondary); + text-align: center; + cursor: pointer; + transition-property: background-color, border-color, color; + transition-duration: 150ms; +} +.themes-dropzone:hover { + border-color: var(--blue-500); + background: var(--blue-100); + color: var(--blue-700, var(--blue-600)); +} +.themes-dropzone i { + font-size: 34px; +} +.themes-dropzone b { + color: var(--text-primary); +} +.themes-dropzone input { + width: min(360px, 100%); +} +@media (max-width: 1080px) { + .themes-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .themes-page-header .between { + display: grid; + } + .themes-header-actions { + justify-content: flex-start; + } + .themes-picker { + width: min(360px, 100%); + } + .themes-manifest-row, + .themes-manifest-row.is-script { + grid-template-columns: 40px minmax(0, 1fr) 40px; + } + .themes-manifest-row > .input, + .themes-manifest-row > .themes-switch { + grid-column: 2; + } + .themes-manifest-row > [data-manifest-remove] { + grid-column: 3; + grid-row: 1; + } +} +@media (max-width: 720px) { + .themes-summary { + grid-template-columns: 1fr; + } + .themes-header-actions { + align-items: stretch; + } + .themes-header-actions .btn { + justify-content: center; + } + .themes-picker { + width: 100%; + } + .themes-section-head { + padding: 15px; + } + .themes-section-head > .themes-row-actions { + width: 100%; + } + .themes-pathbar { + padding-inline: 12px; + } + .themes-files-table, + .themes-files-table tbody, + .themes-files-table tr, + .themes-files-table td, + .themes-revisions-table, + .themes-revisions-table tbody, + .themes-revisions-table tr, + .themes-revisions-table td { + display: block; + width: 100%; + } + .themes-files-table, + .themes-revisions-table { + min-width: 0 !important; + } + .themes-files-table thead, + .themes-files-table colgroup, + .themes-revisions-table thead { + display: none; + } + .themes-files-table tr, + .themes-revisions-table tr { + padding: 14px 15px; + border-bottom: 1px solid var(--border-default); + } + .themes-files-table td, + .themes-revisions-table td { + padding: 5px 0; + border: 0; + } + .themes-files-table td:last-child, + .themes-revisions-table td:last-child { + margin-top: 6px; + } + .themes-row-actions { + margin-left: 0; + } + .themes-section-head > .themes-row-actions { + justify-content: flex-start; + flex-wrap: wrap; + } + #themeFileDrawer, + #themeRevisionDrawer { + width: calc(100vw - 12px); + max-width: none; + } + .themes-manifest-row, + .themes-manifest-row.is-script { + grid-template-columns: 32px minmax(0, 1fr) 40px; + padding: 7px; + } + .themes-drag { + width: 32px; + } + .themes-mode-grid, + .themes-settings-grid { + grid-template-columns: 1fr; + } + .themes-settings-field.is-wide, + .themes-settings-section { + grid-column: 1; + } + .themes-section-order-row { + grid-template-columns: 34px 34px minmax(0, 1fr) 42px; + gap: 8px; + padding-right: 8px; + } + .themes-section-order-move { + display: none; + } +} diff --git a/adminx/modules/Themes/assets/themes.js b/adminx/modules/Themes/assets/themes.js new file mode 100644 index 0000000..aba07dc --- /dev/null +++ b/adminx/modules/Themes/assets/themes.js @@ -0,0 +1,396 @@ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.Themes = { + root: null, + currentRevisionId: 0, + draggedRow: null, + settingsDrag: null, + + init: function () { + this.root = document.querySelector('[data-themes-root]'); + if (!this.root) { return; } + this.bindThemePicker(); + this.bindActions(); + this.bindForms(); + this.bindManifest(); + this.bindSettings(); + this.restoreTab(); + }, + + base: function () { return this.root ? this.root.getAttribute('data-base') : Adminx.base(); }, + theme: function () { return this.root ? this.root.getAttribute('data-theme') : ''; }, + + bindThemePicker: function () { + var select = document.querySelector('[data-theme-select]'); + if (!select) { return; } + select.addEventListener('change', function () { + window.location.href = Adminx.base() + '/themes?theme=' + encodeURIComponent(select.value); + }); + }, + + post: function (url, formData, options) { + options = options || {}; + Adminx.Loader.show(); + return Adminx.Ajax.post(url, formData).then(function (payload) { + Adminx.Loader.hide(); + var data = payload.data || {}; + if (!data.success) { + Adminx.Toast.show(data.message || 'Не удалось выполнить действие', 'error'); + return null; + } + if (data.message) { Adminx.Toast.show(data.message, 'success'); } + if (data.redirect) { window.location.href = data.redirect; return data; } + if (options.reload) { window.location.reload(); } + return data; + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Сервер недоступен', 'error'); + return null; + }); + }, + + formData: function (values) { + var data = new FormData(); + Object.keys(values || {}).forEach(function (key) { data.set(key, values[key]); }); + return data; + }, + + bindActions: function () { + var self = this; + document.addEventListener('click', function (event) { + var tab = event.target.closest('[data-tab-target]'); + if (tab && tab.closest('[data-themes-root]')) { + window.history.replaceState(null, '', '#' + tab.getAttribute('data-tab-target')); + } + + var edit = event.target.closest('[data-theme-file-edit]'); + if (edit) { self.openFile(edit.closest('[data-theme-path]')); return; } + + var remove = event.target.closest('[data-theme-path-delete]'); + if (remove) { self.deletePath(remove.closest('[data-theme-path]')); return; } + + var kind = event.target.closest('[data-create-kind]'); + if (kind) { self.configureCreateAsset(kind.getAttribute('data-create-kind')); return; } + + var activate = event.target.closest('[data-theme-activate]'); + if (activate) { self.activate(); return; } + + if (event.target.closest('[data-theme-delete]')) { self.deleteTheme(); return; } + + var revision = event.target.closest('[data-theme-revision-open]'); + if (revision) { self.openRevision(revision.closest('[data-revision-id]')); return; } + + var deleteRevision = event.target.closest('[data-theme-revision-delete]'); + if (deleteRevision) { self.deleteRevision(deleteRevision.closest('[data-revision-id]')); return; } + + if (event.target.closest('[data-theme-revisions-clear]')) { self.clearRevisions(); return; } + if (event.target.closest('[data-theme-revision-restore]')) { self.restoreRevision(); } + }); + + var upload = document.querySelector('[data-theme-upload]'); + if (upload) { + upload.addEventListener('change', function () { + if (!upload.files.length) { return; } + var data = new FormData(); + data.set('theme', self.theme()); + data.set('directory', self.root.getAttribute('data-directory') || ''); + Array.prototype.forEach.call(upload.files, function (file) { data.append('files[]', file); }); + self.post(self.base() + '/themes/upload', data, { reload: true }); + }); + } + }, + + restoreTab: function () { + var name = window.location.hash.replace(/^#/, ''); + if (!/^(files|connections|settings|revisions)$/.test(name)) { return; } + var tabs = this.root.querySelector('[data-tabs]'); + if (tabs && Adminx.Tabs) { Adminx.Tabs.activate(tabs, name); } + }, + + bindForms: function () { + var self = this; + var fileForm = document.querySelector('[data-theme-file-form]'); + if (fileForm) { + fileForm.addEventListener('submit', function (event) { + event.preventDefault(); + if (Adminx.CodeEditor) { Adminx.CodeEditor.syncAll(fileForm); } + self.post(self.base() + '/themes/file', new FormData(fileForm)).then(function (data) { + if (data) { fileForm.dataset.clean = fileForm.elements.content.value; } + }); + }); + } + + this.bindAjaxForm('[data-theme-create-asset]', function (form) { + return form.elements.kind.value === 'folder' ? '/themes/folders/create' : '/themes/files/create'; + }, true); + this.bindAjaxForm('[data-theme-create-form]', function () { return '/themes/create'; }); + this.bindAjaxForm('[data-theme-import-form]', function () { return '/themes/import'; }); + + var manifest = document.querySelector('[data-theme-manifest-form]'); + if (manifest) { + manifest.addEventListener('submit', function (event) { + event.preventDefault(); + self.indexManifest(manifest); + self.post(self.base() + '/themes/manifest', new FormData(manifest), { reload: true }); + }); + } + + var settings = document.querySelector('[data-theme-settings-form]'); + if (settings) { + settings.addEventListener('submit', function (event) { + event.preventDefault(); + settings.querySelectorAll('[data-theme-order]').forEach(function (list) { self.syncThemeOrder(list); }); + self.post(self.base() + '/themes/settings', new FormData(settings), { reload: true }); + }); + } + }, + + bindAjaxForm: function (selector, path, reload) { + var self = this; + var form = document.querySelector(selector); + if (!form) { return; } + form.addEventListener('submit', function (event) { + event.preventDefault(); + self.post(self.base() + path(form), new FormData(form), { reload: !!reload }); + }); + }, + + configureCreateAsset: function (kind) { + var form = document.querySelector('[data-theme-create-asset]'); + if (!form) { return; } + var folder = kind === 'folder'; + form.reset(); + form.elements.theme.value = this.theme(); + form.elements.directory.value = this.root.getAttribute('data-directory') || ''; + form.elements.kind.value = folder ? 'folder' : 'file'; + document.querySelector('[data-asset-create-title]').textContent = folder ? 'Создать папку' : 'Создать файл'; + document.querySelector('[data-asset-create-hint]').textContent = folder ? 'Имя без точки и служебных символов.' : 'Расширения: css, js, json, svg, xml, txt.'; + form.elements.name.placeholder = folder ? 'components' : 'styles.css'; + }, + + openFile: function (row) { + if (!row) { return; } + var self = this; + var url = this.base() + '/themes/file?theme=' + encodeURIComponent(this.theme()) + '&path=' + encodeURIComponent(row.dataset.themePath); + Adminx.Loader.show(); + Adminx.Ajax.request(url).then(function (payload) { + Adminx.Loader.hide(); + var response = payload.data || {}; + if (!response.success) { Adminx.Toast.show(response.message || 'Файл не найден', 'error'); return; } + var item = response.data || {}; + var form = document.querySelector('[data-theme-file-form]'); + form.elements.path.value = item.path || ''; + form.elements.content.value = item.content || ''; + form.dataset.clean = item.content || ''; + document.querySelector('[data-file-title]').textContent = item.name || 'Редактор файла'; + document.querySelector('[data-file-meta]').textContent = (item.path || '') + ' · ' + (item.size_label || '') + ' · ' + (item.modified_label || ''); + var textarea = form.elements.content; + textarea.setAttribute('data-mode', item.mode || 'text/plain'); + if (textarea._adminxCodeMirror) { + textarea._adminxCodeMirror.setOption('mode', item.mode || 'text/plain'); + textarea._adminxCodeMirror.setValue(item.content || ''); + } + Adminx.Drawer.open('themeFileDrawer'); + window.setTimeout(function () { if (Adminx.CodeEditor) { Adminx.CodeEditor.refreshAll(); } }, 80); + }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Сервер недоступен', 'error'); }); + }, + + deletePath: function (row) { + if (!row) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'error', title: row.dataset.themeType === 'directory' ? 'Удалить папку?' : 'Удалить файл?', + message: '«' + row.dataset.themeName + '» будет удалён из темы. Текстовая версия останется в ревизиях.', + confirmLabel: 'Удалить', confirmClass: 'btn-danger', + onConfirm: function () { + self.post(self.base() + '/themes/path/delete', self.formData({ theme: self.theme(), path: row.dataset.themePath }), { reload: true }); + } + }); + }, + + activate: function () { + var self = this; + Adminx.Confirm.open({ + kind: 'warning', title: 'Активировать тему?', + message: 'Публичный сайт начнёт использовать тему «' + this.theme() + '». Подключения применятся только там, где установлены новые теги темы.', + confirmLabel: 'Активировать', + onConfirm: function () { self.post(self.base() + '/themes/activate', self.formData({ theme: self.theme() }), { reload: true }); } + }); + }, + + deleteTheme: function () { + var self = this; + Adminx.Confirm.open({ + kind: 'error', title: 'Удалить тему?', + message: 'Каталог «' + this.theme() + '», его ассеты и ревизии будут удалены без возможности восстановления.', + confirmLabel: 'Удалить тему', confirmClass: 'btn-danger', + onConfirm: function () { self.post(self.base() + '/themes/delete', self.formData({ theme: self.theme() })); } + }); + }, + + bindManifest: function () { + var self = this; + document.addEventListener('click', function (event) { + var add = event.target.closest('[data-manifest-add]'); + if (add) { + var kind = add.getAttribute('data-manifest-add'); + var template = document.getElementById(kind === 'style' ? 'themeStyleRow' : 'themeScriptRow'); + var list = document.querySelector('[data-manifest-list="' + kind + '"]'); + if (template && list) { list.appendChild(template.content.cloneNode(true)); } + return; + } + var remove = event.target.closest('[data-manifest-remove]'); + if (remove) { remove.closest('[data-manifest-row]').remove(); } + }); + + document.querySelectorAll('[data-manifest-row]').forEach(function (row) { self.makeDraggable(row); }); + document.addEventListener('mouseover', function (event) { + var row = event.target.closest('[data-manifest-row]'); + if (row && !row.hasAttribute('draggable')) { self.makeDraggable(row); } + }); + }, + + bindSettings: function () { + var self = this; + var form = document.querySelector('[data-theme-settings-form]'); + if (!form) { return; } + + form.addEventListener('change', function (event) { + if (event.target.matches('input[name="presentation_mode"]')) { + form.querySelectorAll('.themes-mode-card').forEach(function (card) { + var radio = card.querySelector('input[name="presentation_mode"]'); + card.classList.toggle('is-selected', !!(radio && radio.checked)); + }); + } + + if (event.target.matches('[data-theme-order-visible]')) { + var row = event.target.closest('[data-theme-order-item]'); + row.classList.toggle('is-disabled', !event.target.checked); + self.syncThemeOrder(row.parentNode); + } + }); + + form.addEventListener('click', function (event) { + var button = event.target.closest('[data-theme-order-move]'); + if (!button || button.disabled) { return; } + var row = button.closest('[data-theme-order-item]'); + if (button.getAttribute('data-theme-order-move') === 'up' && row.previousElementSibling) { + row.parentNode.insertBefore(row, row.previousElementSibling); + } else if (button.getAttribute('data-theme-order-move') === 'down' && row.nextElementSibling) { + row.parentNode.insertBefore(row.nextElementSibling, row); + } + self.syncThemeOrder(row.parentNode); + }); + + form.addEventListener('dragstart', function (event) { + var handle = event.target.closest('[data-theme-order-handle]'); + var row = handle ? handle.closest('[data-theme-order-item]') : null; + if (!row || row.getAttribute('draggable') !== 'true') { event.preventDefault(); return; } + self.settingsDrag = row; + row.classList.add('is-dragging'); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', row.getAttribute('data-code') || ''); + } + }); + + form.addEventListener('dragover', function (event) { + if (!self.settingsDrag) { return; } + var target = event.target.closest('[data-theme-order-item]'); + var list = event.target.closest('[data-theme-order]'); + if (!target || !list || target === self.settingsDrag || self.settingsDrag.parentNode !== list) { return; } + event.preventDefault(); + var box = target.getBoundingClientRect(); + list.insertBefore(self.settingsDrag, event.clientY < box.top + box.height / 2 ? target : target.nextSibling); + self.syncThemeOrder(list); + }); + + form.addEventListener('dragend', function () { + if (!self.settingsDrag) { return; } + var list = self.settingsDrag.parentNode; + self.settingsDrag.classList.remove('is-dragging'); + self.settingsDrag = null; + self.syncThemeOrder(list); + }); + }, + + syncThemeOrder: function (list) { + if (!list) { return; } + var input = list.parentNode.querySelector('[data-theme-order-value]'); + if (!input) { return; } + input.value = JSON.stringify(Array.prototype.map.call(list.querySelectorAll('[data-theme-order-item]'), function (row) { + var toggle = row.querySelector('[data-theme-order-visible]'); + return { code: row.getAttribute('data-code') || '', visible: !!(toggle && toggle.checked) }; + })); + }, + + makeDraggable: function (row) { + var self = this; + row.setAttribute('draggable', 'true'); + row.addEventListener('dragstart', function () { self.draggedRow = row; row.classList.add('is-dragging'); }); + row.addEventListener('dragend', function () { row.classList.remove('is-dragging'); self.draggedRow = null; }); + row.addEventListener('dragover', function (event) { + if (!self.draggedRow || self.draggedRow.parentNode !== row.parentNode || self.draggedRow === row) { return; } + event.preventDefault(); + var box = row.getBoundingClientRect(); + row.parentNode.insertBefore(self.draggedRow, event.clientY < box.top + box.height / 2 ? row : row.nextSibling); + }); + }, + + indexManifest: function (form) { + form.querySelectorAll('[data-manifest-list]').forEach(function (list) { + var kind = list.getAttribute('data-manifest-list') === 'style' ? 'styles' : 'scripts'; + list.querySelectorAll('[data-manifest-row]').forEach(function (row, index) { + row.querySelectorAll('[data-field]').forEach(function (field) { + field.name = kind + '[' + index + '][' + field.getAttribute('data-field') + ']'; + }); + }); + }); + }, + + openRevision: function (row) { + if (!row) { return; } + var self = this; + Adminx.Ajax.request(this.base() + '/themes/revisions/' + row.dataset.revisionId).then(function (payload) { + var response = payload.data || {}; + if (!response.success) { Adminx.Toast.show(response.message || 'Ревизия не найдена', 'error'); return; } + var item = response.data || {}; + self.currentRevisionId = item.id || 0; + document.querySelector('[data-revision-title]').textContent = item.path || 'Ревизия'; + document.querySelector('[data-revision-meta]').textContent = (item.action_label || '') + ' · ' + (item.author_name || 'Система') + ' · ' + (item.created_label || ''); + var textarea = document.querySelector('[data-revision-content]'); + textarea.value = item.content || ''; + if (textarea._adminxCodeMirror) { textarea._adminxCodeMirror.setValue(item.content || ''); } + Adminx.Drawer.open('themeRevisionDrawer'); + window.setTimeout(function () { if (Adminx.CodeEditor) { Adminx.CodeEditor.refreshAll(); } }, 80); + }); + }, + + restoreRevision: function () { + if (!this.currentRevisionId) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'warning', title: 'Восстановить ревизию?', message: 'Текущее содержимое файла сначала будет сохранено как отдельная ревизия.', + confirmLabel: 'Восстановить', + onConfirm: function () { self.post(self.base() + '/themes/revisions/' + self.currentRevisionId + '/restore', new FormData(), { reload: true }); } + }); + }, + + deleteRevision: function (row) { + if (!row) { return; } + var self = this; + Adminx.Confirm.open({ kind: 'error', title: 'Удалить ревизию?', message: 'Версию файла нельзя будет восстановить.', confirmLabel: 'Удалить', confirmClass: 'btn-danger', onConfirm: function () { self.post(self.base() + '/themes/revisions/' + row.dataset.revisionId + '/delete', new FormData(), { reload: true }); } }); + }, + + clearRevisions: function () { + var self = this; + Adminx.Confirm.open({ kind: 'error', title: 'Удалить все ревизии темы?', message: 'История текстовых файлов будет очищена без возможности восстановления.', confirmLabel: 'Удалить все', confirmClass: 'btn-danger', onConfirm: function () { self.post(self.base() + '/themes/revisions/clear', self.formData({ theme: self.theme(), path: '' }), { reload: true }); } }); + } + }; + + if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { Adminx.Themes.init(); }); } + else { Adminx.Themes.init(); } +})(window, document); diff --git a/adminx/modules/Themes/language/en/client.xml b/adminx/modules/Themes/language/en/client.xml new file mode 100644 index 0000000..83ebbf7 --- /dev/null +++ b/adminx/modules/Themes/language/en/client.xml @@ -0,0 +1,39 @@ + + + Delete topic? + Catalog « + Extensions: css, js, json, svg, xml, txt. + ", its assets and revisions will be permanently deleted. + Server unavailable + Restore + The public site will start using the theme " + File not found + System + Activate + Restore the revision? + Delete the file? + The history of text files will be cleared without the possibility of recovery. + Delete all revisions of a topic? + Delete + Delete folder? + Activate theme? + Delete topic + Create a folder + File + and + Audit + The current contents of the file will first be saved as a separate revision. + " Connections will only be applied where new theme tags are installed. + Name + A name without a dot or service characters. + Connections + Delete everything + " will be removed from the topic. The text version will remain in revisions. + Create file + Action failed + Revision not found + Delete revision? + The file version cannot be restored. + File editor + Revision not found + diff --git a/adminx/modules/Themes/language/en/interface.xml b/adminx/modules/Themes/language/en/interface.xml new file mode 100644 index 0000000..d632aa9 --- /dev/null +++ b/adminx/modules/Themes/language/en/interface.xml @@ -0,0 +1,172 @@ + + + CSS, JavaScript, images, fonts and service JSON/XML-files. + media, for example print + Twig theme wrapper + ZIP up to 50 MB with required theme.json. + Theme ZIP package + · active + · version + Activate + active + Assets and theme overrides work in both modes. + A secure framework will be created with CSS, JS and theme.json. + B + In the catalog + The values declared by this theme. + and + The changes will be applied after saving the registry. + Changed + Import a theme + Import + Name + The directory is empty + Catalog code + Theme code + Click to select archive + Topic name + Native AVE templates + file not specified + New topic + They will appear after the first change to the text file. + Defines the CSS and JS order for tags + Theme Options + Move + Connections + Connections + Show + Connection order is from top to bottom. + After saving, the public cache will be cleared automatically. + View + Applies after saving. + Public registration + Asset size + Extensions: css, js, json, svg, xml, txt. + Audits + File revisions + No revisions yet + Audit + File editor + Connection registry + The download mode is set for each file. + Download ZIP themes + Scripts + Create a folder or upload theme files. + Create a blank topic or import a ZIP package. + Create a folder + Create file + Save connections + How to build a public page + Site assembly method + Styles + The page is assembled from a site template, categories, queries, blocks and navigation. + Text versions are saved before being modified, loaded, or deleted. + Them + No topics yet + Only the assembly of the general page. CSS, JavaScript, theme images and its module design will remain connected. + Delete topic + controls the overall composition of the page. + File + Theme files + Theme files + Files, CSS/JS connections and public skin versions. + What will the switch change? + ) !== false) { throw new \InvalidArgumentException('Text asset contains binary data'); } + } + + protected static function editorMode($extension) + { + if ($extension === 'css') { return 'text/css'; } + if (in_array($extension, array('js', 'mjs'), true)) { return 'text/javascript'; } + if (in_array($extension, array('json', 'map', 'webmanifest'), true)) { return 'application/json'; } + if (in_array($extension, array('svg', 'xml'), true)) { return 'xml'; } + return 'text/plain'; + } + + protected static function imageExtension($extension) + { + return in_array((string) $extension, array('png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg'), true); + } + + protected static function initialContent($path) + { + $extension = strtolower((string) pathinfo((string) $path, PATHINFO_EXTENSION)); + if ($extension === 'css') { return + . Errors: + 0 B + " will be removed from the topic. The text version will remain in revisions. + ", its assets and revisions will be permanently deleted. + " Connections will only be applied where new theme tags are installed. + Activate theme? + The theme does not include a Twig wrapper file + The file version cannot be restored. + Restore + Restore the revision? + Recovery + Done + Loading + Import + A name without a dot or service characters. + The history of text files will be cleared without the possibility of recovery. + Catalog « + A directory with the same name already exists + The directory contains a symbolic link + Catalog created + Content + Theme settings saved + Action failed + Failed to write theme file + Failed to create directory + Failed to save file + Failed to delete file + Theme connections saved + Previous version + Check your theme settings + View themes, files and the registry of connected assets. + The public site will start using the theme " + File size must be between 1 byte and 10 MB + File extension not allowed for theme + Revision restored + Revision not found + Revision deleted + Register + The theme registry cannot be deleted + Server unavailable + Symbolic links cannot be edited + System + Creation + Create, import, edit, activate and delete themes. + You can create a CSS, JS, JSON, SVG, XML, Twig or text file + Saving + The text file must not exceed 2 MB + The current contents of the file will first be saved as a separate revision. + Theme activated + Theme imported + Topic created + Topic deleted + Topics + Topics: viewing + Topics: management + Removal + Revisions removed: + Delete + Delete everything + Delete all revisions of a topic? + Delete folder? + Delete revision? + Delete topic? + Delete the file? + The file was not completely downloaded + File or directory not found + File or directory deleted + File not found + A file with the same name already exists + File too big for editor + File created + File saved + Theme file not found + The file already exists + file(s) uploaded + This directory name is reserved + This file type cannot be edited as text + diff --git a/adminx/modules/Themes/language/en/runtime.xml b/adminx/modules/Themes/language/en/runtime.xml new file mode 100644 index 0000000..66015e3 --- /dev/null +++ b/adminx/modules/Themes/language/en/runtime.xml @@ -0,0 +1,110 @@ + + + . Errors: + 0 B + JSON contains a syntax error: + SVG contains invalid XML + ZIP does not contain theme.json + ZIP contains absolute path + ZIP contains a prohibited file " + ZIP contains an unsafe path + ZIP contains an invalid number of files + ZIP contains duplicate path " + theme.json should be in the format ave-theme-v1 + theme.json is in an unsupported format + theme.json refers to a missing file " + theme.json refers to the missing skin " + Active topic of the public part. + An active topic cannot be deleted + B + You cannot use a dot in a directory name. + The theme does not include a Twig wrapper file + Recovery + Select theme zip archive + Done + SVG requires DOM to render safely + The PHP zip extension is required to import the theme + The PHP zip extension is required to export the theme + Loading + Import + Executable and server files are prohibited in the topic + A directory with the same name already exists + The directory contains a symbolic link + Catalog created + Theme directory not found + Subject code: Latin, numbers, _ and -, up to 64 characters + Content + Root of the topic + Theme settings saved + Failed to write " + Failed to write theme file + Failed to extract " + Failed to open ZIP archive + Failed to read ZIP structure + Failed to create ZIP themes + Failed to create temporary ZIP + Failed to create directory + Failed to create asset directory + Failed to create theme directory + Failed to create topic structure + Failed to save file + Failed to delete directory + Failed to delete theme directory + Failed to delete file + Invalid file or directory name + Incorrect theme file path + Only ZIP archive is supported + Theme connections saved + Previous version + Check your theme settings + View themes, files and the registry of connected assets. + The path is outside the theme directory + Theme path not found + ZIP size must be no more than 50 MB + File size must be between 1 byte and 10 MB + Unpacked topic exceeds safe limit + File extension not allowed for theme + Revision restored + Revision not found + Revision deleted + Register + The theme registry cannot be deleted + Symbolic links are not allowed in ZIP + Symbolic links are not available + Symbolic links cannot be edited + Theme service directory is unavailable + The theme service file is only available in the theme root + Image content does not match extension + Creation + Create, import, edit, activate and delete themes. + You can create a CSS, JS, JSON, SVG, XML, Twig or text file + Saving + A text asset contains binary data + The text file must not exceed 2 MB + Text file contains null byte + Theme activated + Theme imported + Topic not found + Theme of the public part + A topic with this code already exists + Topic created + Topic deleted + Topics + Topics: viewing + Topics: management + Removal + Revisions removed: + The file was not completely downloaded + File or directory not found + File or directory deleted + The file is not a ZIP archive + A file with the same name already exists + File too big for editor + File created + File saved + Theme file not found + The file already exists + This directory name is reserved + This file type cannot be edited as text + file(s) uploaded + diff --git a/adminx/modules/Themes/language/ru/client.xml b/adminx/modules/Themes/language/ru/client.xml new file mode 100644 index 0000000..1389bd1 --- /dev/null +++ b/adminx/modules/Themes/language/ru/client.xml @@ -0,0 +1,39 @@ + + + Удалить тему? + Каталог « + Расширения: css, js, json, svg, xml, txt. + », его ассеты и ревизии будут удалены без возможности восстановления. + Сервер недоступен + Восстановить + Публичный сайт начнёт использовать тему « + Файл не найден + Система + Активировать + Восстановить ревизию? + Удалить файл? + История текстовых файлов будет очищена без возможности восстановления. + Удалить все ревизии темы? + Удалить + Удалить папку? + Активировать тему? + Удалить тему + Создать папку + Файл + и + Ревизия + Текущее содержимое файла сначала будет сохранено как отдельная ревизия. + ». Подключения применятся только там, где установлены новые теги темы. + Имя + Имя без точки и служебных символов. + Подключения + Удалить все + » будет удалён из темы. Текстовая версия останется в ревизиях. + Создать файл + Не удалось выполнить действие + Ревизия не найдена + Удалить ревизию? + Версию файла нельзя будет восстановить. + Редактор файла + Ревизия не найдена + diff --git a/adminx/modules/Themes/language/ru/interface.xml b/adminx/modules/Themes/language/ru/interface.xml new file mode 100644 index 0000000..8de9640 --- /dev/null +++ b/adminx/modules/Themes/language/ru/interface.xml @@ -0,0 +1,172 @@ + + + CSS, JavaScript, изображения, шрифты и служебные JSON/XML-файлы. + media, например print + Twig-оболочка темы + ZIP до 50 МБ с обязательным theme.json. + ZIP-пакет темы + · активна + · версия + Активировать + активна + Ассеты и переопределения темы работают в обоих режимах. + Будет создан безопасный каркас с CSS, JS и theme.json. + В + В каталоге + Значения, объявленные этой темой. + и + Изменения применятся после сохранения реестра. + Изменён + Импорт темы + Импортировать + Имя + Каталог пуст + Код каталога + Код темы + Нажмите, чтобы выбрать архив + Название темы + Нативные шаблоны AVE + не указан файл + Новая тема + Они появятся после первого изменения текстового файла. + Определяет порядок CSS и JS для тегов + Параметры темы + Переместить + Подключений + Подключения + Показывать + Порядок подключения сверху вниз. + После сохранения публичный кеш будет очищен автоматически. + Посмотреть + Применится после сохранения. + Публичное оформление + Размер ассетов + Расширения: css, js, json, svg, xml, txt. + Ревизии + Ревизии файлов + Ревизий пока нет + Ревизия + Редактор файла + Реестр подключений + Режим загрузки задаётся для каждого файла. + Скачать ZIP темы + Скрипты + Создайте папку или загрузите файлы темы. + Создайте пустую тему или импортируйте ZIP-пакет. + Создать папку + Создать файл + Сохранить подключения + Способ сборки публичной страницы + Способ сборки сайта + Стили + Страница собирается из шаблона сайта, рубрик, запросов, блоков и навигации. + Текстовые версии сохраняются перед изменением, загрузкой и удалением. + Тем + Тем пока нет + Только сборку общей страницы. CSS, JavaScript, изображения темы и её оформления модулей останутся подключёнными. + Удалить тему + управляет общей композицией страницы. + Файл + Файлов темы + Файлы темы + Файлы, подключения CSS/JS и версии публичного оформления. + Что изменит переключатель + ) !== false) { throw new \InvalidArgumentException('Текстовый ассет содержит бинарные данные'); } + } + + protected static function editorMode($extension) + { + if ($extension === 'css') { return 'text/css'; } + if (in_array($extension, array('js', 'mjs'), true)) { return 'text/javascript'; } + if (in_array($extension, array('json', 'map', 'webmanifest'), true)) { return 'application/json'; } + if (in_array($extension, array('svg', 'xml'), true)) { return 'xml'; } + return 'text/plain'; + } + + protected static function imageExtension($extension) + { + return in_array((string) $extension, array('png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg'), true); + } + + protected static function initialContent($path) + { + $extension = strtolower((string) pathinfo((string) $path, PATHINFO_EXTENSION)); + if ($extension === 'css') { return + . Ошибки: + 0 Б + » будет удалён из темы. Текстовая версия останется в ревизиях. + », его ассеты и ревизии будут удалены без возможности восстановления. + ». Подключения применятся только там, где установлены новые теги темы. + Активировать тему? + В теме не указан файл Twig-оболочки + Версию файла нельзя будет восстановить. + Восстановить + Восстановить ревизию? + Восстановление + Готово + Загрузка + Импорт + Имя без точки и служебных символов. + История текстовых файлов будет очищена без возможности восстановления. + Каталог « + Каталог с таким именем уже существует + Каталог содержит символическую ссылку + Каталог создан + Контент + Настройки темы сохранены + Не удалось выполнить действие + Не удалось записать файл темы + Не удалось создать каталог + Не удалось сохранить файл + Не удалось удалить файл + Подключения темы сохранены + Предыдущая версия + Проверьте значения настроек темы + Просмотр тем, файлов и реестра подключаемых ассетов. + Публичный сайт начнёт использовать тему « + Размер файла должен быть от 1 байта до 10 МБ + Расширение файла не разрешено для темы + Ревизия восстановлена + Ревизия не найдена + Ревизия удалена + Реестр + Реестр темы удалить нельзя + Сервер недоступен + Символические ссылки редактировать нельзя + Система + Создание + Создание, импорт, редактирование, активация и удаление тем. + Создать можно CSS, JS, JSON, SVG, XML, Twig или текстовый файл + Сохранение + Текстовый файл не должен превышать 2 МБ + Текущее содержимое файла сначала будет сохранено как отдельная ревизия. + Тема активирована + Тема импортирована + Тема создана + Тема удалена + Темы + Темы: просмотр + Темы: управление + Удаление + Удалено ревизий: + Удалить + Удалить все + Удалить все ревизии темы? + Удалить папку? + Удалить ревизию? + Удалить тему? + Удалить файл? + Файл загрузился не полностью + Файл или каталог не найден + Файл или каталог удалён + Файл не найден + Файл с таким именем уже существует + Файл слишком большой для редактора + Файл создан + Файл сохранён + Файл темы не найден + Файл уже существует + файл(ов) загружено + Это имя каталога зарезервировано + Этот тип файла нельзя редактировать как текст + diff --git a/adminx/modules/Themes/language/ru/runtime.xml b/adminx/modules/Themes/language/ru/runtime.xml new file mode 100644 index 0000000..c3ffb4a --- /dev/null +++ b/adminx/modules/Themes/language/ru/runtime.xml @@ -0,0 +1,110 @@ + + + . Ошибки: + 0 Б + JSON содержит синтаксическую ошибку: + SVG содержит некорректный XML + ZIP не содержит theme.json + ZIP содержит абсолютный путь + ZIP содержит запрещённый файл « + ZIP содержит небезопасный путь + ZIP содержит недопустимое количество файлов + ZIP содержит повторяющийся путь « + theme.json должен иметь формат ave-theme-v1 + theme.json имеет неподдерживаемый формат + theme.json ссылается на отсутствующий файл « + theme.json ссылается на отсутствующую оболочку « + Активная тема публичной части. + Активную тему удалить нельзя + Б + В имени каталога нельзя использовать точку + В теме не указан файл Twig-оболочки + Восстановление + Выберите ZIP-архив темы + Готово + Для безопасной обработки SVG требуется DOM + Для импорта темы требуется PHP-расширение zip + Для экспорта темы требуется PHP-расширение zip + Загрузка + Импорт + Исполняемые и серверные файлы в теме запрещены + Каталог с таким именем уже существует + Каталог содержит символическую ссылку + Каталог создан + Каталог темы не найден + Код темы: латиница, цифры, _ и -, до 64 символов + Контент + Корень темы + Настройки темы сохранены + Не удалось записать « + Не удалось записать файл темы + Не удалось извлечь « + Не удалось открыть ZIP-архив + Не удалось прочитать структуру ZIP + Не удалось создать ZIP темы + Не удалось создать временный ZIP + Не удалось создать каталог + Не удалось создать каталог ассета + Не удалось создать каталог темы + Не удалось создать структуру темы + Не удалось сохранить файл + Не удалось удалить каталог + Не удалось удалить каталог темы + Не удалось удалить файл + Некорректное имя файла или каталога + Некорректный путь файла темы + Поддерживается только ZIP-архив + Подключения темы сохранены + Предыдущая версия + Проверьте значения настроек темы + Просмотр тем, файлов и реестра подключаемых ассетов. + Путь выходит за каталог темы + Путь темы не найден + Размер ZIP должен быть не больше 50 МБ + Размер файла должен быть от 1 байта до 10 МБ + Распакованная тема превышает безопасный лимит + Расширение файла не разрешено для темы + Ревизия восстановлена + Ревизия не найдена + Ревизия удалена + Реестр + Реестр темы удалить нельзя + Символические ссылки в ZIP запрещены + Символические ссылки недоступны + Символические ссылки редактировать нельзя + Служебный каталог темы недоступен + Служебный файл темы доступен только в корне темы + Содержимое изображения не соответствует расширению + Создание + Создание, импорт, редактирование, активация и удаление тем. + Создать можно CSS, JS, JSON, SVG, XML, Twig или текстовый файл + Сохранение + Текстовый ассет содержит бинарные данные + Текстовый файл не должен превышать 2 МБ + Текстовый файл содержит нулевой байт + Тема активирована + Тема импортирована + Тема не найдена + Тема публичной части + Тема с таким кодом уже существует + Тема создана + Тема удалена + Темы + Темы: просмотр + Темы: управление + Удаление + Удалено ревизий: + Файл загрузился не полностью + Файл или каталог не найден + Файл или каталог удалён + Файл не является ZIP-архивом + Файл с таким именем уже существует + Файл слишком большой для редактора + Файл создан + Файл сохранён + Файл темы не найден + Файл уже существует + Это имя каталога зарезервировано + Этот тип файла нельзя редактировать как текст + файл(ов) загружено + diff --git a/adminx/modules/Themes/migrations/001_create_theme_asset_revisions.sql b/adminx/modules/Themes/migrations/001_create_theme_asset_revisions.sql new file mode 100644 index 0000000..b7724c5 --- /dev/null +++ b/adminx/modules/Themes/migrations/001_create_theme_asset_revisions.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_theme_asset_revisions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `theme` VARCHAR(64) CHARACTER SET ascii NOT NULL, + `asset_path` VARCHAR(500) NOT NULL, + `action` VARCHAR(32) CHARACTER SET ascii NOT NULL DEFAULT 'update', + `checksum` CHAR(64) CHARACTER SET ascii NOT NULL, + `content` LONGTEXT NOT NULL, + `content_size` INT UNSIGNED NOT NULL DEFAULT 0, + `author_id` INT UNSIGNED NOT NULL DEFAULT 0, + `author_name` VARCHAR(255) NOT NULL DEFAULT '', + `source_revision_id` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `created_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_theme_created` (`theme`,`created_at`), + KEY `idx_theme_path` (`theme`,`asset_path`(120),`created_at`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Themes/migrations/002_place_theme_navigation.php b/adminx/modules/Themes/migrations/002_place_theme_navigation.php new file mode 100644 index 0000000..15b69c2 --- /dev/null +++ b/adminx/modules/Themes/migrations/002_place_theme_navigation.php @@ -0,0 +1,42 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\SystemTables; + use App\Helpers\Json; + + return function (array $context) { + $table = SystemTables::table('settings'); + $row = DB::query('SELECT value FROM ' . $table . ' WHERE param=%s LIMIT 1', 'adminx_interface.navigation')->getAssoc(); + if (!$row) { return 0; } + + $items = Json::toArray((string) $row['value']); + if (!is_array($items)) { return 0; } + foreach ($items as $item) { + if (is_array($item) && isset($item['code']) && (string) $item['code'] === 'themes') { return 0; } + } + + $insertAt = count($items); + foreach ($items as $index => $item) { + if (is_array($item) && isset($item['code']) && (string) $item['code'] === 'templates') { + $insertAt = $index; + break; + } + } + + array_splice($items, $insertAt, 0, array(array('code' => 'themes', 'visible' => true))); + DB::Update($table, array('value' => Json::encode($items, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)), 'param=%s', 'adminx_interface.navigation'); + return 1; + }; diff --git a/adminx/modules/Themes/module.php b/adminx/modules/Themes/module.php new file mode 100644 index 0000000..550a6a8 --- /dev/null +++ b/adminx/modules/Themes/module.php @@ -0,0 +1,94 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + return array( + 'code' => 'themes', + 'name' => 'Темы', + 'version' => '0.1.0', + + 'permissions' => array( + 'key' => 'themes', + 'items' => array( + array( + 'code' => 'view_themes', + 'group_code' => 'navigation', + 'name' => 'Темы: просмотр', + 'description' => 'Просмотр тем, файлов и реестра подключаемых ассетов.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_themes', + 'group_code' => 'content', + 'name' => 'Темы: управление', + 'description' => 'Создание, импорт, редактирование, активация и удаление тем.', + 'sort_order' => 20, + ), + ), + 'icon' => 'ti ti-palette', + 'priority' => 33, + ), + + 'navigation' => array( + array( + 'code' => 'themes', + 'label' => 'Темы', + 'url' => '/themes', + 'icon' => 'ti ti-palette', + 'permission' => 'view_themes', + 'group' => 'Контент', + 'sort_order' => 23, + 'match' => array('/themes'), + ), + ), + + 'migrations' => array( + array('id' => '001_create_theme_asset_revisions', 'file' => 'migrations/001_create_theme_asset_revisions.sql'), + array('id' => '002_place_theme_navigation', 'file' => 'migrations/002_place_theme_navigation.php'), + ), + + 'assets' => array( + 'styles' => array( + array('url' => ADMINX_BASE . '/modules/Themes/assets/themes.css', 'priority' => 50), + ), + 'scripts' => array( + array('url' => ADMINX_BASE . '/modules/Themes/assets/themes.js', 'priority' => 50), + ), + ), + + 'routes' => array( + array('GET', '/themes', array(\App\Adminx\Themes\Controller::class, 'index')), + array('GET', '/themes/file', array(\App\Adminx\Themes\Controller::class, 'file')), + array('POST', '/themes/file', array(\App\Adminx\Themes\Controller::class, 'saveFile'), array('permission' => 'manage_themes', 'sensitive' => 'theme_assets.write', 'reauth' => true)), + array('POST', '/themes/files/create', array(\App\Adminx\Themes\Controller::class, 'createFile'), array('permission' => 'manage_themes', 'sensitive' => 'theme_assets.write', 'reauth' => true)), + array('POST', '/themes/folders/create', array(\App\Adminx\Themes\Controller::class, 'createFolder'), array('permission' => 'manage_themes')), + array('POST', '/themes/upload', array(\App\Adminx\Themes\Controller::class, 'upload'), array('permission' => 'manage_themes', 'sensitive' => 'theme_assets.write', 'reauth' => true)), + array('POST', '/themes/path/delete', array(\App\Adminx\Themes\Controller::class, 'deletePath'), array('permission' => 'manage_themes', 'sensitive' => 'theme_assets.write', 'reauth' => true)), + array('POST', '/themes/manifest', array(\App\Adminx\Themes\Controller::class, 'saveManifest'), array('permission' => 'manage_themes', 'sensitive' => 'theme_assets.write', 'reauth' => true)), + array('POST', '/themes/settings', array(\App\Adminx\Themes\Controller::class, 'saveSettings'), array('permission' => 'manage_themes')), + array('POST', '/themes/create', array(\App\Adminx\Themes\Controller::class, 'createTheme'), array('permission' => 'manage_themes', 'sensitive' => 'theme_assets.write', 'reauth' => true)), + array('POST', '/themes/activate', array(\App\Adminx\Themes\Controller::class, 'activate'), array('permission' => 'manage_themes', 'sensitive' => 'theme_assets.write', 'reauth' => true)), + array('GET', '/themes/export', array(\App\Adminx\Themes\Controller::class, 'export')), + array('POST', '/themes/import', array(\App\Adminx\Themes\Controller::class, 'import'), array('permission' => 'manage_themes', 'sensitive' => 'theme_assets.write', 'reauth' => true)), + array('POST', '/themes/delete', array(\App\Adminx\Themes\Controller::class, 'deleteTheme'), array('permission' => 'manage_themes', 'sensitive' => 'theme_assets.write', 'reauth' => true)), + array('GET', '/themes/revisions', array(\App\Adminx\Themes\Controller::class, 'revisions')), + array('GET', '/themes/revisions/{id}', array(\App\Adminx\Themes\Controller::class, 'revision')), + array('POST', '/themes/revisions/{id}/restore', array(\App\Adminx\Themes\Controller::class, 'restoreRevision'), array('permission' => 'manage_themes', 'sensitive' => 'theme_assets.write', 'reauth' => true)), + array('POST', '/themes/revisions/{id}/delete', array(\App\Adminx\Themes\Controller::class, 'deleteRevision'), array('permission' => 'manage_themes')), + array('POST', '/themes/revisions/clear', array(\App\Adminx\Themes\Controller::class, 'clearRevisions'), array('permission' => 'manage_themes')), + ), + + 'view_globals' => array('module_code' => 'themes'), + ); diff --git a/adminx/modules/Themes/view/index.twig b/adminx/modules/Themes/view/index.twig new file mode 100644 index 0000000..6584e40 --- /dev/null +++ b/adminx/modules/Themes/view/index.twig @@ -0,0 +1,187 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Темы{% endblock %} +{% block content %} + + + + + +
    +
    {{ stats.themes }}Тем
    +
    {{ stats.files }}Файлов темы
    +
    {{ stats.size }}Размер ассетов
    +
    {{ stats.connections }}Подключений
    +
    + +{% if browser %} +
    +
    +
    +
    Публичное оформление

    {{ browser.theme.name }}

    {{ browser.theme.code }} · версия {{ browser.theme.version }}{% if browser.theme.active %} · активна{% endif %}

    +
    + +
    +
    + + + + +
    + +
    +
    +
    +

    Файлы темы

    CSS, JavaScript, изображения, шрифты и служебные JSON/XML-файлы.

    + {% if can_manage %}
    {% endif %} +
    +
    + {% for crumb in browser.breadcrumbs %}{{ crumb.name }}{% if not loop.last %}{% endif %}{% endfor %} +
    +
    + + + + + {% for item in browser.items %} + + + + + + + {% else %}{% endfor %} + +
    НазваниеТипРазмерИзменён
    {% if item.type == 'directory' %}{{ item.name }}Каталог{% else %}{% endif %}{% if item.type == 'directory' %}Папка{% else %}{{ item.connected ?: (item.extension|upper ?: 'FILE') }}{% endif %}{{ item.size_label ?: '—' }}{{ item.modified_label }}
    {% if item.type == 'file' and item.url %}{% endif %}{% if can_manage and item.editable %}{% endif %}{% if can_manage and item.name != 'theme.json' %}{% endif %}
    Каталог пустСоздайте папку или загрузите файлы темы.
    +
    +
    +
    + + + + + + +
    +
    +{% else %}
    Тем пока нетСоздайте пустую тему или импортируйте ZIP-пакет.
    {% endif %} + +{% if can_manage %} + + + + +{% endif %} + + + + + +{% endblock %} diff --git a/adminx/modules/Updates/Controller.php b/adminx/modules/Updates/Controller.php new file mode 100644 index 0000000..a1e40df --- /dev/null +++ b/adminx/modules/Updates/Controller.php @@ -0,0 +1,130 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Updates; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\AuditLog; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\CoreUpdate\PatchPackage; + use App\Common\CoreUpdate\ReleaseState; + use App\Common\CoreUpdate\UpdateJob; + use App\Common\CoreUpdate\UpdateRepository; + use App\Common\Permission; + use App\Common\UploadPolicy; + use App\Helpers\Request; + + class Controller extends BaseController + { + public function index(array $params = array()) + { + AdminAssets::addStyle($this->base() . '/modules/Updates/assets/updates.css', 50); + AdminAssets::addScript($this->base() . '/modules/Updates/assets/updates.js', 50); + $job = UpdateJob::latest(); + if ($job && in_array($job['status'], array('prepared', 'backed_up'), true)) { $job['preflight'] = UpdateJob::preflight($job); } + return $this->render('@updates/index.twig', array( + 'current' => ReleaseState::installed(), + 'source' => ReleaseState::source(), + 'repository' => UpdateRepository::catalog(false), + 'repository_settings' => UpdateRepository::settings(), + 'official_repository' => UpdateRepository::officialSettings(), + 'job' => $job, + 'can_manage' => Permission::check('manage_core_updates'), + 'can_install' => Permission::check('install_core_updates'), + )); + } + + public function settings(array $params = array()) + { + if (($guard = $this->guard('manage_core_updates', 'install_core_updates')) !== null) { return $guard; } + try { + $settings = UpdateRepository::saveSettings(array('enabled' => Request::postBool('enabled', false), 'url' => Request::postStr('url', ''), 'public_key' => Request::postStr('public_key', ''))); + $this->audit('core_update.repository_settings', array('enabled' => $settings['enabled'], 'url' => $settings['url'])); + return $this->success('Источник обновлений сохранён', array('data' => array('settings' => $settings))); + } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + } + + public function officialSettings(array $params = array()) + { + if (($guard = $this->guard('manage_core_updates', 'install_core_updates')) !== null) { return $guard; } + try { + $settings = UpdateRepository::restoreOfficial(); + $this->audit('core_update.repository_official_restored', array('url' => $settings['url'])); + return $this->success('Официальный источник обновлений восстановлен', array('data' => array('settings' => $settings))); + } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + } + + public function refresh(array $params = array()) + { + if (($guard = $this->guard('view_core_updates')) !== null) { return $guard; } + $catalog = UpdateRepository::catalog(true); + return empty($catalog['error']) ? $this->success('Каталог обновлений проверен', array('data' => array('catalog' => $catalog))) : $this->error($catalog['error'], array(), 422); + } + + public function upload(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + try { + $file = isset($_FILES['patch']) && is_array($_FILES['patch']) ? $_FILES['patch'] : array(); + if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name']) || (int) $file['error'] !== UPLOAD_ERR_OK) { throw new \RuntimeException('Архив патча не загружен'); } + UploadPolicy::assertAllowed($file['tmp_name'], isset($file['name']) ? $file['name'] : 'update.zip', isset($file['size']) ? $file['size'] : 0, '', 'core_update'); + if ((int) $file['size'] > PatchPackage::MAX_ARCHIVE_BYTES) { throw new \RuntimeException('Архив патча превышает 50 МБ'); } + $key = UpdateRepository::settings()['public_key']; + if ($key === '') { throw new \RuntimeException('Сначала сохраните публичный ключ обновлений'); } + $job = UpdateJob::create($file['tmp_name'], $key, Auth::id()); + $this->audit('core_update.prepared', array('job_id' => $job['id'], 'patch_id' => $job['manifest']['id'])); + return $this->success('Патч проверен', array('data' => array('job' => $job))); + } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + } + + public function download(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + try { + $job = UpdateRepository::download(isset($params['id']) ? $params['id'] : '', Auth::id()); + $this->audit('core_update.downloaded', array('job_id' => $job['id'], 'patch_id' => $job['manifest']['id'])); + return $this->success('Патч загружен и проверен', array('data' => array('job' => $job))); + } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + } + + public function step(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + try { + $job = UpdateJob::step(isset($params['id']) ? $params['id'] : ''); + if ($job['status'] === 'completed') { $this->audit('core_update.completed', array('job_id' => $job['id'], 'patch_id' => $job['manifest']['id'], 'to' => $job['manifest']['to'])); } + return $this->success($job['message'], array('data' => array('job' => $job))); + } catch (\Throwable $e) { $this->audit('core_update.failed', array('job_id' => isset($params['id']) ? $params['id'] : '', 'error' => $e->getMessage())); return $this->error($e->getMessage(), array(), 422); } + } + + public function rollback(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + try { $job = UpdateJob::rollback(isset($params['id']) ? $params['id'] : ''); $this->audit('core_update.rolled_back', array('job_id' => $job['id'])); return $this->success($job['message'], array('data' => array('job' => $job))); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + } + + public function discard(array $params = array()) + { + if (($guard = $this->guard()) !== null) { return $guard; } + try { $job = UpdateJob::discard(isset($params['id']) ? $params['id'] : ''); $this->audit('core_update.discarded', array('job_id' => $job['id'])); return $this->success($job['message'], array('data' => array('job' => $job))); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + } + + protected function guard($permission = 'install_core_updates', $additional = '') { return $this->guardPermission($permission, $additional); } + protected function audit($action, array $meta) { $user = Auth::user(); AuditLog::record($action, array('actor_id' => Auth::id(), 'actor_name' => $user && isset($user['name']) ? $user['name'] : '', 'target_type' => 'core_update', 'target_id' => null, 'meta' => $meta)); } + } diff --git a/adminx/modules/Updates/assets/updates.css b/adminx/modules/Updates/assets/updates.css new file mode 100644 index 0000000..3a18da9 --- /dev/null +++ b/adminx/modules/Updates/assets/updates.css @@ -0,0 +1,162 @@ +.updates-tabs { + margin-bottom: var(--space-5); +} +.updates-summary .stat-val { + font-variant-numeric: tabular-nums; +} +.updates-job, +.updates-settings { + padding: var(--space-5); +} +.updates-job-head, +.updates-job-actions, +.updates-job-meta { + display: flex; + align-items: center; + gap: var(--space-3); +} +.updates-job-head { + justify-content: space-between; +} +.updates-job-head h2 { + margin: var(--space-2) 0 0; + font-size: 18px; +} +.updates-progress { + margin: var(--space-5) 0 var(--space-3); +} +.updates-job-meta { + color: var(--text-muted); + margin-bottom: var(--space-5); +} +.updates-job-meta span { + padding-right: var(--space-4); + border-right: 1px solid var(--border-color); +} +.updates-job-meta span:last-child { + border-right: 0; +} +.updates-job-actions { + justify-content: flex-end; + border-top: 1px solid var(--border-color); + padding-top: var(--space-4); +} +.updates-job-empty { + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + align-items: center; + gap: 14px; + min-height: 112px; + padding: 22px 24px; +} +.updates-job-empty .icon-tile { + width: 48px; + height: 48px; + font-size: 22px; +} +.updates-job-empty h3, +.updates-job-empty p { + margin: 0; +} +.updates-job-empty h3 { + color: var(--text-primary); + font-size: 15px; +} +.updates-job-empty p { + margin-top: 4px; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.45; +} +.updates-settings { + display: grid; + gap: var(--space-4); +} +.updates-official-source { + display: grid; + grid-template-columns: 40px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 13px 14px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-muted); +} +.updates-official-copy { + min-width: 0; +} +.updates-official-copy b, +.updates-official-copy small { + display: block; +} +.updates-official-copy b { + color: var(--text-primary); + font-size: 13.5px; +} +.updates-official-copy small { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12px; + overflow-wrap: anywhere; +} +.updates-catalog-toggle { + display: grid; + grid-template-columns: 40px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + min-height: 66px; + padding: 12px 14px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); + cursor: pointer; +} +.updates-catalog-toggle-copy { + display: block; + min-width: 0; +} +.updates-catalog-toggle-copy b, +.updates-catalog-toggle-copy small { + display: block; +} +.updates-catalog-toggle-copy b { + color: var(--text-primary); + font-size: 13.5px; + line-height: 1.35; +} +.updates-catalog-toggle-copy small { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; +} +.updates-catalog-toggle > .switch { + flex: 0 0 auto; + margin: 0; +} +@media (max-width: 620px) { + .updates-official-source { + grid-template-columns: 36px minmax(0, 1fr); + } + .updates-official-source .btn { + grid-column: 1 / -1; + width: 100%; + } + .updates-catalog-toggle { + grid-template-columns: 36px minmax(0, 1fr) auto; + padding-inline: 11px; + } +} +@media (max-width: 720px) { + .updates-job-head { + align-items: flex-start; + flex-direction: column; + } + .updates-job-meta { + align-items: flex-start; + flex-direction: column; + } + .updates-job-meta span { + border: 0; + } +} diff --git a/adminx/modules/Updates/assets/updates.js b/adminx/modules/Updates/assets/updates.js new file mode 100644 index 0000000..2b1fd02 --- /dev/null +++ b/adminx/modules/Updates/assets/updates.js @@ -0,0 +1,72 @@ +/** Signed core update catalog and resumable installation flow. */ +(function (window, document) { + 'use strict'; + var Adminx = window.Adminx || (window.Adminx = {}); + var page = document.querySelector('[data-updates-page]'); + if (!page) { return; } + + function base() { return Adminx.base() + '/system/updates'; } + function post(url, data) { + Adminx.Loader.show(); + return Adminx.Ajax.post(url, data || new FormData()).then(function (payload) { + Adminx.Loader.hide(); + if (!payload.data || !payload.data.success) { throw new Error(payload.data && payload.data.message ? payload.data.message : 'Операция не выполнена'); } + return payload.data; + }).catch(function (error) { Adminx.Loader.hide(); Adminx.Toast.show(error.message || 'Ошибка обновления', 'error'); throw error; }); + } + function reload(tab) { window.setTimeout(function () { window.location.href = base() + (tab ? '?tab=' + tab : ''); }, 350); } + function rememberRecovery(token) { + if (!token) { return; } + try { + var adminBase = Adminx.base(); + var publicBase = adminBase.substring(0, adminBase.lastIndexOf('/')); + window.localStorage.setItem('ave-core-update-recovery', window.location.origin + publicBase + '/recovery.php?token=' + token); + } catch (error) {} + } + function renderJob(job) { + page.dataset.jobId = job.id; page.dataset.jobStatus = job.status; + var status = document.querySelector('[data-update-job-status]'); + var message = document.querySelector('[data-update-job-message]'); + var progress = document.querySelector('[data-update-progress]'); + if (status) { status.textContent = job.status; } + if (message) { message.textContent = job.message || ''; } + if (progress) { progress.style.width = Number(job.progress || 0) + '%'; } + if (job.status === 'completed' || job.status === 'rolled_back') { + try { window.localStorage.removeItem('ave-core-update-recovery'); } catch (error) {} + } + rememberRecovery(job.recovery_token); + } + function runStep() { + var id = page.dataset.jobId || ''; + if (!id) { return; } + post(base() + '/jobs/' + encodeURIComponent(id) + '/step', new FormData()).then(function (result) { + var job = result.data.job; renderJob(job); Adminx.Toast.show(job.message || 'Этап выполнен', 'success'); + if (job.status === 'backing_up' || job.status === 'backed_up' || job.status === 'applying' || job.status === 'applied') { window.setTimeout(runStep, 450); return; } + reload('job'); + }).catch(function () { reload('job'); }); + } + + document.addEventListener('click', function (event) { + var upload = event.target.closest('[data-update-upload-open]'); + if (upload) { document.querySelector('[data-update-file]').click(); return; } + var refresh = event.target.closest('[data-update-refresh]'); + if (refresh) { post(base() + '/refresh', new FormData()).then(function () { reload('updates'); }); return; } + var official = event.target.closest('[data-update-official]'); + if (official) { Adminx.Confirm.open({ kind: 'warning', title: 'Восстановить официальный источник?', message: 'Текущий URL и публичный ключ обновлений будут заменены данными из доверенного профиля сборки.', confirmLabel: 'Восстановить', confirmClass: 'btn-primary', onConfirm: function () { post(base() + '/settings/official', new FormData()).then(function () { Adminx.Toast.show('Официальный источник восстановлен', 'success'); reload('settings'); }); } }); return; } + var download = event.target.closest('[data-update-download]'); + if (download) { Adminx.Confirm.open({ kind: 'warning', title: 'Подготовить обновление?', message: 'Патч будет загружен и проверен. Файлы сайта пока не изменятся.', confirmLabel: 'Подготовить', confirmClass: 'btn-primary', onConfirm: function () { post(base() + '/download/' + encodeURIComponent(download.dataset.updateDownload), new FormData()).then(function () { reload('job'); }); } }); return; } + var start = event.target.closest('[data-update-continue]'); + if (start) { Adminx.Confirm.open({ kind: 'warning', title: 'Установить обновление ядра?', message: 'AVE.cms создаст резервную копию, временно закроет публичный сайт и заменит системные файлы.', confirmLabel: 'Установить', confirmClass: 'btn-primary', onConfirm: runStep }); return; } + var rollback = event.target.closest('[data-update-rollback]'); + if (rollback) { Adminx.Confirm.open({ kind: 'error', title: 'Откатить обновление?', message: 'Файлы и резервная копия БД будут восстановлены из journal этого задания.', confirmLabel: 'Откатить', confirmClass: 'btn-danger', onConfirm: function () { post(base() + '/jobs/' + encodeURIComponent(page.dataset.jobId) + '/rollback', new FormData()).then(function () { reload('job'); }); } }); } + var discard = event.target.closest('[data-update-discard]'); + if (discard) { Adminx.Confirm.open({ kind: 'warning', title: 'Отменить подготовку?', message: 'Загруженный ZIP и staging будут удалены. Файлы сайта ещё не изменялись.', confirmLabel: 'Отменить патч', confirmClass: 'btn-danger', onConfirm: function () { post(base() + '/jobs/' + encodeURIComponent(page.dataset.jobId) + '/discard', new FormData()).then(function () { reload('job'); }); } }); } + }); + var file = document.querySelector('[data-update-file]'); + if (file) { file.addEventListener('change', function () { if (!file.files.length) { return; } var data = new FormData(); data.append('patch', file.files[0]); post(base() + '/upload', data).then(function () { reload('job'); }); }); } + var settings = document.querySelector('[data-update-settings]'); + if (settings) { settings.addEventListener('submit', function (event) { event.preventDefault(); post(base() + '/settings', new FormData(settings)).then(function () { Adminx.Toast.show('Источник обновлений сохранён', 'success'); reload('settings'); }); }); } + var tab = new URLSearchParams(window.location.search).get('tab'); + rememberRecovery(page.dataset.recoveryToken); + if (tab && Adminx.Tabs) { Adminx.Tabs.activate(page, tab); } +})(window, document); diff --git a/adminx/modules/Updates/language/en/client.xml b/adminx/modules/Updates/language/en/client.xml new file mode 100644 index 0000000..7ff138d --- /dev/null +++ b/adminx/modules/Updates/language/en/client.xml @@ -0,0 +1,26 @@ + + + Prepare + The patch will be downloaded and tested. The site files will not change yet. + Restore + Operation failed + Cancel preparation? + Install a kernel update? + Official source restored + Cancel + The downloaded ZIP and staging will be deleted. The site files have not changed yet. + Prepare update + Cancel patch + Rollback + Update source saved + The files and database backup will be restored from the journal of this task. + Update error + AVE.cms will create a backup, temporarily close the public site and replace system files. + Prepare an update? + The current URL and public update key will be replaced with the data from the trusted build profile. + Install + Stage completed + Restore the official source? + Roll back the update? + Update source saved + diff --git a/adminx/modules/Updates/language/en/interface.xml b/adminx/modules/Updates/language/en/interface.xml new file mode 100644 index 0000000..146f43a --- /dev/null +++ b/adminx/modules/Updates/language/en/interface.xml @@ -0,0 +1,89 @@ + + + , and the completed release is + . Check the unfinished job or rollback. + {{ item.compatibility.reason|default('The patch does not match the installed version') }} + · updated + No active task + The files contain + All fixes + Select a patch from the catalog or download a signed ZIP. + Yes + There are no new patches for the current version + Available + Available patches + Download patch + or newer + The fixes of this patch are already included in the installed version + Each stage is saved on disk and can be continued after the request is terminated. + Channel + Update catalog + Catalog verified + KB + The kernel update key is stored separately from the module repository key. + The code and registry of the installed version diverge + The cumulative patch immediately installs all fixes after your build. + migrations + Start update + Doesn't fit + No + No right + No right to install updates + The update cannot be applied yet + Rollback + Public key: + Cancel + The patch does not match the installed version + Transition + Prepare update + Signed kernel patches with file verification, backup and rollback. + Signed source + Step by step installation + Check remote directory + Continue + Public RSA key + with + with build + Current task + Technical update of AVE.cms + Just look for available updates. There is no automatic installation. + deletions + Installed + files + Chain of trust + AVE.cms will create a backup, temporarily close the public site and replace system files. + Patch archive not loaded + The patch archive exceeds 50 MB + Restore + Restore the official source? + The downloaded ZIP and staging will be deleted. The site files have not changed yet. + Loading and running signed kernel executable code. + Update source saved + The update catalog has been checked + Configuring the official source of kernel updates. + The update will change the kernel executables and may perform database migrations. + Updates + Updates: Settings + Updates: view + Updates: installation + Operation failed + Roll back the update? + Cancel patch + Cancel preparation? + Official source restored + The official update source has been restored + Update error + The patch will be downloaded and tested. The site files will not change yet. + Patch downloaded and tested + Patch checked + Prepare + Prepare an update? + View version, signed catalog and update history. + System + First save the update public key + The current URL and public update key will be replaced with the data from the trusted build profile. + Install + Install a kernel update? + The files and database backup will be restored from the journal of this task. + Stage completed + diff --git a/adminx/modules/Updates/language/en/runtime.xml b/adminx/modules/Updates/language/en/runtime.xml new file mode 100644 index 0000000..b5cb742 --- /dev/null +++ b/adminx/modules/Updates/language/en/runtime.xml @@ -0,0 +1,20 @@ + + + Patch archive not loaded + The patch archive exceeds 50 MB + Loading and running signed kernel executable code. + Update source saved + The update catalog has been checked + Configuring the official source of kernel updates. + The update will change the kernel executables and may perform database migrations. + Updates + Updates: Settings + Updates: view + Updates: installation + The official update source has been restored + Patch downloaded and tested + Patch checked + View version, signed catalog and update history. + System + First save the update public key + diff --git a/adminx/modules/Updates/language/ru/client.xml b/adminx/modules/Updates/language/ru/client.xml new file mode 100644 index 0000000..6e435f8 --- /dev/null +++ b/adminx/modules/Updates/language/ru/client.xml @@ -0,0 +1,26 @@ + + + Подготовить + Патч будет загружен и проверен. Файлы сайта пока не изменятся. + Восстановить + Операция не выполнена + Отменить подготовку? + Установить обновление ядра? + Официальный источник восстановлен + Отменить + Загруженный ZIP и staging будут удалены. Файлы сайта ещё не изменялись. + Подготовить обновление + Отменить патч + Откатить + Источник обновлений сохранён + Файлы и резервная копия БД будут восстановлены из journal этого задания. + Ошибка обновления + AVE.cms создаст резервную копию, временно закроет публичный сайт и заменит системные файлы. + Подготовить обновление? + Текущий URL и публичный ключ обновлений будут заменены данными из доверенного профиля сборки. + Установить + Этап выполнен + Восстановить официальный источник? + Откатить обновление? + Источник обновлений сохранён + diff --git a/adminx/modules/Updates/language/ru/interface.xml b/adminx/modules/Updates/language/ru/interface.xml new file mode 100644 index 0000000..2d6723b --- /dev/null +++ b/adminx/modules/Updates/language/ru/interface.xml @@ -0,0 +1,89 @@ + + + , а завершённый релиз — + . Проверьте незавершённое задание или выполните откат. + {{ item.compatibility.reason|default('Патч не подходит к установленной версии') }} + · обновлён + Активного задания нет + В файлах находится + Все исправления + Выберите патч из каталога или загрузите подписанный ZIP. + Да + Для текущей версии новых патчей нет + Доступно + Доступные патчи + Загрузить патч + или новее + Исправления этого патча уже входят в установленную версию + Каждый этап сохраняется на диске и может быть продолжен после обрыва запроса. + Канал + Каталог обновлений + Каталог проверен + КБ + Ключ обновлений ядра хранится отдельно от ключа репозитория модулей. + Код и реестр установленной версии расходятся + Кумулятивный патч сразу устанавливает все исправления после вашей сборки. + миграций + Начать обновление + Не подходит + Нет + Нет права + Нет права на установку обновлений + Обновление пока нельзя применить + Откатить + Открытый ключ: + Отменить + Патч не подходит к установленной версии + Переход + Подготовить обновление + Подписанные патчи ядра с проверкой файлов, резервной копией и откатом. + Подписанный источник + Пошаговая установка + Проверять удалённый каталог + Продолжить + Публичный RSA-ключ + с + с build + Текущее задание + Техническое обновление AVE.cms + Только искать доступные обновления. Автоматическая установка не выполняется. + удалений + Установлено + файлов + Цепочка доверия + AVE.cms создаст резервную копию, временно закроет публичный сайт и заменит системные файлы. + Архив патча не загружен + Архив патча превышает 50 МБ + Восстановить + Восстановить официальный источник? + Загруженный ZIP и staging будут удалены. Файлы сайта ещё не изменялись. + Загрузка и применение подписанного исполняемого кода ядра. + Источник обновлений сохранён + Каталог обновлений проверен + Настройка официального источника обновлений ядра. + Обновление изменит исполняемые файлы ядра и может выполнить миграции БД. + Обновления + Обновления: настройки + Обновления: просмотр + Обновления: установка + Операция не выполнена + Откатить обновление? + Отменить патч + Отменить подготовку? + Официальный источник восстановлен + Официальный источник обновлений восстановлен + Ошибка обновления + Патч будет загружен и проверен. Файлы сайта пока не изменятся. + Патч загружен и проверен + Патч проверен + Подготовить + Подготовить обновление? + Просмотр версии, подписанного каталога и истории обновлений. + Система + Сначала сохраните публичный ключ обновлений + Текущий URL и публичный ключ обновлений будут заменены данными из доверенного профиля сборки. + Установить + Установить обновление ядра? + Файлы и резервная копия БД будут восстановлены из journal этого задания. + Этап выполнен + diff --git a/adminx/modules/Updates/language/ru/runtime.xml b/adminx/modules/Updates/language/ru/runtime.xml new file mode 100644 index 0000000..2eb785e --- /dev/null +++ b/adminx/modules/Updates/language/ru/runtime.xml @@ -0,0 +1,20 @@ + + + Архив патча не загружен + Архив патча превышает 50 МБ + Загрузка и применение подписанного исполняемого кода ядра. + Источник обновлений сохранён + Каталог обновлений проверен + Настройка официального источника обновлений ядра. + Обновление изменит исполняемые файлы ядра и может выполнить миграции БД. + Обновления + Обновления: настройки + Обновления: просмотр + Обновления: установка + Официальный источник обновлений восстановлен + Патч загружен и проверен + Патч проверен + Просмотр версии, подписанного каталога и истории обновлений. + Система + Сначала сохраните публичный ключ обновлений + diff --git a/adminx/modules/Updates/module.php b/adminx/modules/Updates/module.php new file mode 100644 index 0000000..be1cd98 --- /dev/null +++ b/adminx/modules/Updates/module.php @@ -0,0 +1,48 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + $updateRoute = array('permission' => 'install_core_updates', 'sensitive' => 'core.update', 'reauth' => array('reason' => 'Обновление изменит исполняемые файлы ядра и может выполнить миграции БД.')); + + return array( + 'code' => 'updates', + 'name' => 'Обновления', + 'version' => '0.1.0', + 'permissions' => array( + 'key' => 'updates', + 'items' => array( + array('code' => 'view_core_updates', 'group_code' => 'navigation', 'name' => 'Обновления: просмотр', 'description' => 'Просмотр версии, подписанного каталога и истории обновлений.', 'sort_order' => 10), + array('code' => 'manage_core_updates', 'group_code' => 'admin', 'name' => 'Обновления: настройки', 'description' => 'Настройка официального источника обновлений ядра.', 'sort_order' => 20), + array('code' => 'install_core_updates', 'group_code' => 'admin', 'name' => 'Обновления: установка', 'description' => 'Загрузка и применение подписанного исполняемого кода ядра.', 'sort_order' => 30), + ), + 'icon' => 'ti ti-refresh', + 'priority' => 42, + ), + 'navigation' => array( + array('code' => 'core_updates', 'label' => 'Обновления', 'url' => '/system/updates', 'icon' => 'ti ti-refresh', 'permission' => 'view_core_updates', 'group' => 'Система', 'sort_order' => 38, 'match' => array('/system/updates')), + ), + 'routes' => array( + array('GET', '/system/updates', array(\App\Adminx\Updates\Controller::class, 'index')), + array('POST', '/system/updates/settings', array(\App\Adminx\Updates\Controller::class, 'settings'), $updateRoute), + array('POST', '/system/updates/settings/official', array(\App\Adminx\Updates\Controller::class, 'officialSettings'), $updateRoute), + array('POST', '/system/updates/refresh', array(\App\Adminx\Updates\Controller::class, 'refresh')), + array('POST', '/system/updates/upload', array(\App\Adminx\Updates\Controller::class, 'upload'), $updateRoute), + array('POST', '/system/updates/download/{id}', array(\App\Adminx\Updates\Controller::class, 'download'), $updateRoute), + array('POST', '/system/updates/jobs/{id}/step', array(\App\Adminx\Updates\Controller::class, 'step'), $updateRoute), + array('POST', '/system/updates/jobs/{id}/rollback', array(\App\Adminx\Updates\Controller::class, 'rollback'), $updateRoute), + array('POST', '/system/updates/jobs/{id}/discard', array(\App\Adminx\Updates\Controller::class, 'discard'), array('permission' => 'install_core_updates')), + ), + 'view_globals' => array('module_code' => 'updates'), + ); diff --git a/adminx/modules/Updates/view/index.twig b/adminx/modules/Updates/view/index.twig new file mode 100644 index 0000000..0efee86 --- /dev/null +++ b/adminx/modules/Updates/view/index.twig @@ -0,0 +1,64 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Обновления{% endblock %} +{% block content %} +
    + + + + +
    +
    {{ current.version }}
    Версия
    +
    {{ current.build }}
    Build
    +
    {{ repository.verified ? 'Да' : 'Нет' }}
    Каталог проверен
    +
    {{ repository.available|default(0) }}
    Доступно
    +
    + + {% if source.version != current.version or source.build != current.build %} +
    Код и реестр установленной версии расходятся

    В файлах находится {{ source.version }} build {{ source.build }}, а завершённый релиз — {{ current.version }} build {{ current.build }}. Проверьте незавершённое задание или выполните откат.

    + {% endif %} + +
    + + + {% if can_manage %}{% endif %} +
    + +
    +
    Stable channel

    Доступные патчи

    Кумулятивный патч сразу устанавливает все исправления после вашей сборки.

    {% if repository.enabled %}
    {% endif %}
    +
    +

    {{ repository.name|default('Каталог обновлений') }}

    {{ repository.error ?: ('Канал ' ~ repository.channel ~ ' · обновлён ' ~ repository.generated_at) }}

    {{ repository.items|length }}
    +
    + {% for item in repository.items %}{% else %}{% endfor %} +
    ПереходОписаниеРазмерДействия
    {{ item.to.version }} build {{ item.to.build }}
    {% if item.cumulative %}с build {{ item.from.build }} или новее{% else %}с {{ item.from.version }} build {{ item.from.build }}{% endif %}
    {{ item.notes ?: 'Техническое обновление AVE.cms' }}{% if item.cumulative %}
    Все исправления
    {% endif %}
    {{ (item.size / 1024)|number_format(0, '.', ' ') }} КБ{% if item.installed %}Установлено{% elseif can_install and item.applicable %}{% elseif not can_install %}Нет права{% else %}Не подходит{% endif %}
    {{ repository.error ?: 'Для текущей версии новых патчей нет' }}
    +
    +
    + + + + {% if can_manage %}{% endif %} + + {% if can_install %}
    {% endif %} +
    +{% endblock %} diff --git a/adminx/modules/Users/Controller.php b/adminx/modules/Users/Controller.php new file mode 100644 index 0000000..30d4b66 --- /dev/null +++ b/adminx/modules/Users/Controller.php @@ -0,0 +1,216 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Users; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AdminAssets; + use App\Common\Auth; + use App\Common\Controller as BaseController; + use App\Common\Permission; + use App\Adminx\Support\Roles; + use App\Helpers\Request; + + /** + * CRUD пользователей. Список — server-render (таблица AdminKit), создание/правка — + * ajax из drawer-формы по единому контракту success/error (§3). + */ + class Controller extends BaseController + { + /** GET /users */ + public function index(array $params = []) + { + AdminAssets::addStyle($this->base() . '/modules/Users/assets/users.css', 50); + AdminAssets::addScript($this->base() . '/modules/Users/assets/users.js', 50); + + $search = Request::getStr('q'); + $role = Request::getStr('role'); + + return $this->render('@users/list.twig', [ + 'users' => Model::all($search, $role), + 'roles' => Roles::map(), + 'stats' => Model::stats(), + 'q' => $search, + 'role' => $role, + 'can_manage' => Permission::check('manage_users'), + 'current_id' => Auth::id(), + ]); + } + + /** GET /users/{id} — данные пользователя (JSON, для формы правки). */ + public function show(array $params = []) + { + $user = Model::find($params['id'] ?? 0); + if (!$user) { + return $this->error('Пользователь не найден', [], 404); + } + + return $this->success('', ['data' => [ + 'id' => (int) $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'login' => $user->login, + 'phone' => $user->phone, + 'role' => $user->role, + 'is_active' => (int) $user->is_active, + 'created_at' => $user->created_at, + 'updated_at' => $user->updated_at, + ]]); + } + + /** POST /users — создать. */ + public function store(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $data = $this->input(); + $errors = $this->validate($data, 0, true); + if ($errors) { + return $this->error('Проверьте поля формы', $errors); + } + + $id = Model::create($data); + return $this->success('Пользователь создан', ['redirect' => $this->base() . '/users']); + } + + /** POST /users/{id} — обновить. */ + public function update(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $id = (int) ($params['id'] ?? 0); + $user = Model::find($id); + if (!$user) { + return $this->error('Пользователь не найден', [], 404); + } + + $data = $this->input(); + if ($id === Auth::id()) { + $data['role'] = (string) $user->role; + $data['is_active'] = true; + } + + $errors = $this->validate($data, $id, false); + if ($errors) { + return $this->error('Проверьте поля формы', $errors); + } + + Model::update($id, $data); + return $this->success('Изменения сохранены', ['redirect' => $this->base() . '/users']); + } + + /** POST /users/{id}/toggle — вкл/выкл активность. */ + public function toggle(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $id = (int) ($params['id'] ?? 0); + if (!Model::find($id)) { + return $this->error('Пользователь не найден', [], 404); + } + + if ($id === Auth::id()) { + return $this->error('Нельзя отключить собственную учётную запись', [], 422); + } + + $active = Model::toggle($id); + return $this->success($active ? 'Пользователь включён' : 'Пользователь отключён', [ + 'data' => ['is_active' => $active], + ]); + } + + /** POST /users/{id}/delete — удалить. */ + public function destroy(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $id = (int) ($params['id'] ?? 0); + if (!Model::find($id)) { + return $this->error('Пользователь не найден', [], 404); + } + + if ($id === Auth::id()) { + return $this->error('Нельзя удалить самого себя', [], 422); + } + + Model::delete($id); + return $this->success('Пользователь удалён', ['redirect' => $this->base() . '/users']); + } + + // ------------------------------------------------------------------ // + + /** CSRF + право на изменение. */ + protected function guard() + { + return $this->guardPermission('manage_users'); + } + + protected function input() + { + return [ + 'name' => trim(Request::postStr('name')), + 'email' => trim(Request::postStr('email')), + 'login' => trim(Request::postStr('login')), + 'phone' => trim(Request::postStr('phone')), + 'role' => Request::postStr('role'), + 'password' => (string) Request::post('password', ''), + 'is_active' => Request::postBool('is_active', false), + ]; + } + + /** @return array field => message */ + protected function validate(array $data, $id, $isCreate) + { + $errors = []; + + if ($data['name'] === '') { + $errors['name'] = 'Укажите имя.'; + } + + if ($data['email'] === '') { + $errors['email'] = 'Укажите email.'; + } elseif (!preg_match('/^[^@\s]+@[^@\s]+$/', $data['email'])) { + //-- Допускаем локальные адреса вида user@local (демо/дев), которые + //-- FILTER_VALIDATE_EMAIL отвергает из-за отсутствия TLD. + $errors['email'] = 'Некорректный email.'; + } elseif (Model::emailExists($data['email'], (int) $id)) { + $errors['email'] = 'Этот email уже занят.'; + } + + if ($data['login'] !== '' && Model::loginExists($data['login'], (int) $id)) { + $errors['login'] = 'Этот логин уже занят.'; + } + + if (!in_array($data['role'], Roles::codes(), true)) { + $errors['role'] = 'Выберите роль.'; + } + + if ($isCreate && strlen($data['password']) < 6) { + $errors['password'] = 'Пароль не короче 6 символов.'; + } elseif (!$isCreate && $data['password'] !== '' && strlen($data['password']) < 6) { + $errors['password'] = 'Пароль не короче 6 символов.'; + } + + return $errors; + } + } diff --git a/adminx/modules/Users/Model.php b/adminx/modules/Users/Model.php new file mode 100644 index 0000000..8e5a3de --- /dev/null +++ b/adminx/modules/Users/Model.php @@ -0,0 +1,192 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Users; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use DB; + use App\Common\Auth\IdentityLinker; + use App\Common\SystemTables; + + /** + * Доступ к пользователям (таблица {prefix}_users, современная схема: + * id, name, email, password_hash, role, is_active, created_at, updated_at). + */ + class Model + { + /** Известные роли (пока без таблицы ролей — фиксированный список). */ + public static function roles() + { + return ['admin', 'manager', 'designer', 'user']; + } + + protected static function table() + { + return SystemTables::table('users'); + } + + /** Пустую строку сохраняем как NULL. */ + protected static function nullable(array $data, $key) + { + $v = isset($data[$key]) ? trim((string) $data[$key]) : ''; + return $v === '' ? null : $v; + } + + /** Список с фильтром по поиску (имя/email/логин) и роли. */ + public static function all($search = '', $role = '') + { + $sql = 'SELECT id, name, email, login, phone, role, is_active, created_at, updated_at FROM ' . self::table() . ' WHERE 1=1'; + $args = []; + + $search = trim((string) $search); + if ($search !== '') { + $sql .= ' AND (name LIKE %ss OR email LIKE %ss OR login LIKE %ss)'; + $args[] = $search; + $args[] = $search; + $args[] = $search; + } + + $role = trim((string) $role); + if ($role !== '') { + $sql .= ' AND role = %s'; + $args[] = $role; + } + + $sql .= ' ORDER BY id ASC'; + + return call_user_func_array([DB::class, 'query'], array_merge([$sql], $args))->getAll(); + } + + public static function find($id) + { + return DB::query('SELECT * FROM ' . self::table() . ' WHERE id = %i LIMIT 1', (int) $id)->getObject(); + } + + /** Занят ли email другим пользователем. */ + public static function emailExists($email, $exceptId = 0) + { + return (bool) DB::query( + 'SELECT id FROM ' . self::table() . ' WHERE email = %s AND id != %i LIMIT 1', + (string) $email, (int) $exceptId + )->getValue(); + } + + /** Занят ли логин другим пользователем (пустой логин не проверяем). */ + public static function loginExists($login, $exceptId = 0) + { + $login = trim((string) $login); + if ($login === '') { + return false; + } + + return (bool) DB::query( + 'SELECT id FROM ' . self::table() . ' WHERE login = %s AND id != %i LIMIT 1', + $login, (int) $exceptId + )->getValue(); + } + + /** Найти пользователя по email или логину (для входа). */ + public static function findByIdentifier($identifier) + { + return DB::query( + 'SELECT * FROM ' . self::table() . ' WHERE email = %s OR login = %s LIMIT 1', + (string) $identifier, (string) $identifier + )->getObject(); + } + + /** Проставить bcrypt-хеш и очистить legacy-пароль (после первого входа). */ + public static function setPassword($id, $hash) + { + DB::Update(self::table(), [ + 'password_hash' => (string) $hash, + 'legacy_password' => null, + 'legacy_salt' => null, + 'updated_at' => date('Y-m-d H:i:s'), + ], 'id = %i', (int) $id); + } + + public static function create(array $data) + { + $now = date('Y-m-d H:i:s'); + DB::Insert(self::table(), [ + 'name' => (string) $data['name'], + 'email' => (string) $data['email'], + 'login' => self::nullable($data, 'login'), + 'phone' => self::nullable($data, 'phone'), + 'password_hash' => password_hash((string) $data['password'], PASSWORD_BCRYPT, ['cost' => 12]), + 'role' => (string) $data['role'], + 'is_active' => !empty($data['is_active']) ? 1 : 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $id = (int) DB::insertId(); + IdentityLinker::ensurePublicForSystem($id, (string) $data['password']); + return $id; + } + + public static function update($id, array $data) + { + $fields = [ + 'name' => (string) $data['name'], + 'email' => (string) $data['email'], + 'login' => self::nullable($data, 'login'), + 'phone' => self::nullable($data, 'phone'), + 'role' => (string) $data['role'], + 'is_active' => !empty($data['is_active']) ? 1 : 0, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + //-- Пароль меняем только если задан новый. + if (!empty($data['password'])) { + $fields['password_hash'] = password_hash((string) $data['password'], PASSWORD_BCRYPT, ['cost' => 12]); + } + + DB::Update(self::table(), $fields, 'id = %i', (int) $id); + IdentityLinker::ensurePublicForSystem((int) $id, !empty($data['password']) ? (string) $data['password'] : ''); + } + + public static function toggle($id) + { + DB::query( + 'UPDATE ' . self::table() . ' SET is_active = 1 - is_active, updated_at = %s WHERE id = %i', + date('Y-m-d H:i:s'), (int) $id + ); + return (int) DB::query('SELECT is_active FROM ' . self::table() . ' WHERE id = %i', (int) $id)->getValue(); + } + + public static function delete($id) + { + DB::Delete(self::table(), 'id = %i', (int) $id); + } + + public static function count() + { + return (int) DB::query('SELECT COUNT(*) FROM ' . self::table())->getValue(); + } + + /** Сводка для карточек: всего / активных / отключённых / ролей. */ + public static function stats() + { + $total = self::count(); + $active = (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE is_active = 1')->getValue(); + $roles = (int) DB::query('SELECT COUNT(DISTINCT role) FROM ' . self::table() . ' WHERE role != ""')->getValue(); + + return [ + 'total' => $total, + 'active' => $active, + 'inactive' => $total - $active, + 'roles' => $roles, + ]; + } + } diff --git a/adminx/modules/Users/assets/users.css b/adminx/modules/Users/assets/users.css new file mode 100644 index 0000000..2ef14b8 --- /dev/null +++ b/adminx/modules/Users/assets/users.css @@ -0,0 +1,149 @@ +/* Стили раздела «Пользователи» (то, чего нет в общем UI-ките). */ +/* Отступы/размеры панельного заголовка — общие (.ax-panel-header). Здесь только акцент. */ +.users-panel-header { + --section-accent: var(--indigo-500); + --section-accent-soft: var(--indigo-100); +} +.users-filter { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.users-filter .input-wrap { + width: 220px; +} +.users-filter .select { + width: 150px; +} +.users-drawer { + width: min(66.666vw, 1120px); + max-width: none; +} +.users-drawer .drawer-body { + background: var(--background-inset); +} +.users-editor { + display: grid; + gap: 14px; +} +.users-field-input { + width: 100%; +} +.users-field-input .input { + width: 100%; +} +.users-editor [name="role"]:disabled, +.users-active-card:has([name="is_active"]:disabled) { + opacity: 0.72; +} +.users-editor-summary { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); +} +.users-editor-summary .avatar { + width: 42px; + height: 42px; + flex: 0 0 42px; +} +.users-editor-summary > div { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 3px; + min-width: 0; +} +.users-editor-summary > div span { + overflow: hidden; + color: var(--text-secondary); + font-size: 12.5px; + text-overflow: ellipsis; + white-space: nowrap; +} +.users-editor-summary > .badge { + flex: 0 0 auto; +} +.users-editor-section { + padding: 16px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); +} +.users-editor-section-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-default); +} +.users-editor-section-head > div { + display: flex; + flex-direction: column; + gap: 3px; +} +.users-editor-section-head > div span { + color: var(--text-secondary); + font-size: 12.5px; +} +.users-access-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(280px, 1fr); + gap: 12px; + align-items: end; +} +.users-active-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 68px; + padding: 11px 14px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + cursor: pointer; +} +.users-active-card > span:first-child { + display: flex; + flex-direction: column; + gap: 3px; +} +.users-active-card small { + color: var(--text-secondary); +} +.users-editor-dates { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + padding: 10px 14px; + border-radius: var(--radius-md); + background: var(--background-surface); + color: var(--text-secondary); + font-size: 12.5px; +} +.users-editor-dates span { + display: inline-flex; + align-items: center; + gap: 6px; +} +@media (max-width: 680px) { + .users-filter { + width: 100%; + } + .users-filter .input-wrap { + flex: 1; + width: auto; + } + .users-drawer { + width: 100%; + max-width: 100%; + } + .users-access-grid { + grid-template-columns: 1fr; + } +} diff --git a/adminx/modules/Users/assets/users.js b/adminx/modules/Users/assets/users.js new file mode 100644 index 0000000..ccf7e08 --- /dev/null +++ b/adminx/modules/Users/assets/users.js @@ -0,0 +1,340 @@ +/** + * JS раздела «Пользователи». Drawer-форма (создание/правка), toggle активности, + * удаление — через общий Adminx.Ajax по контракту success/error. Подключается + * точечно из Users\Controller (ТЗ §4). + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.Users = { + form: null, + + init: function () { + this.form = document.getElementById('userForm'); + if (!this.form) { return; } + var self = this; + + document.addEventListener('click', function (e) { + if (e.target.closest('[data-user-new]')) { self.fillNew(); } + var editBtn = e.target.closest('[data-user-edit]'); + if (editBtn) { self.fillEdit(editBtn.closest('tr')); } + var delBtn = e.target.closest('[data-user-delete]'); + if (delBtn) { self.remove(delBtn.closest('tr')); } + var pwBtn = e.target.closest('[data-pw-toggle]'); + if (pwBtn) { self.togglePw(pwBtn); } + if (e.target.closest('[data-pw-generate]')) { self.generatePassword(); } + if (e.target.closest('[data-pw-copy]')) { self.copyPassword(); } + }); + + document.addEventListener('change', function (e) { + var t = e.target.closest('[data-user-toggle]'); + if (t) { self.toggle(t); } + }); + + this.form.addEventListener('input', function (e) { + var pw = e.target.closest('[data-pw-input]'); + if (pw) { self.strength(pw.value || ''); } + if (e.target.matches('[name="name"], [name="email"]')) { self.updatePreview(); } + }); + + this.form.addEventListener('change', function (e) { + if (e.target.matches('[name="role"]')) { self.updatePreview(); } + }); + + this.form.addEventListener('submit', function (e) { + e.preventDefault(); + self.submit(); + }); + }, + + base: function () { return this.form.getAttribute('data-base') || Adminx.base(); }, + + clearErrors: function () { + this.form.querySelectorAll('[data-error]').forEach(function (s) { s.textContent = ''; }); + this.form.querySelectorAll('.input, .select').forEach(function (i) { i.classList.remove('is-invalid'); }); + }, + + setField: function (name, value) { + var el = this.form.querySelector('[name="' + name + '"]'); + if (el) { el.value = value; } + }, + + fillNew: function () { + this.clearErrors(); + this.form.reset(); + this.setField('id', ''); + this.form.querySelector('[name="is_active"]').checked = true; + this.setSelfLock(false); + this.resetPassword(); + document.getElementById('userDrawerTitle').textContent = 'Новый пользователь'; + var hint = this.form.querySelector('[data-pass-hint]'); + if (hint) { hint.textContent = 'Пароль обязателен для новой учётной записи.'; } + var dates = this.form.querySelector('[data-user-dates]'); + if (dates) { dates.hidden = true; } + this.updatePreview(); + }, + + fillEdit: function (row) { + if (!row) { return; } + this.clearErrors(); + this.setField('id', row.dataset.id); + this.setField('name', row.dataset.name); + this.setField('email', row.dataset.email); + this.setField('login', row.dataset.login || ''); + this.setField('phone', row.dataset.phone || ''); + this.setField('role', row.dataset.role); + this.setField('password', ''); + this.resetPassword(); + this.form.querySelector('[name="is_active"]').checked = row.dataset.active === '1'; + this.setSelfLock(row.hasAttribute('data-self')); + document.getElementById('userDrawerTitle').textContent = 'Изменение: ' + row.dataset.name; + var hint = this.form.querySelector('[data-pass-hint]'); + if (hint) { hint.textContent = 'Оставьте пароль пустым, если его не нужно менять.'; } + var dates = this.form.querySelector('[data-user-dates]'); + if (dates) { dates.hidden = false; } + var created = this.form.querySelector('[data-user-created]'); + var updated = this.form.querySelector('[data-user-updated]'); + if (created) { created.textContent = this.formatDate(row.dataset.created); } + if (updated) { updated.textContent = this.formatDate(row.dataset.updated); } + this.updatePreview(row.dataset.roleLabel || row.dataset.role); + }, + + setSelfLock: function (locked) { + var role = this.form.querySelector('[name="role"]'); + var active = this.form.querySelector('[name="is_active"]'); + var activeCard = active ? active.closest('.users-active-card') : null; + var roleHint = this.form.querySelector('[data-user-self-role-hint]'); + var activeHint = this.form.querySelector('[data-user-active-hint]'); + if (role) { role.disabled = !!locked; } + if (active) { active.disabled = !!locked; } + if (activeCard) { activeCard.classList.toggle('is-locked', !!locked); } + if (roleHint) { roleHint.hidden = !locked; } + if (activeHint) { activeHint.textContent = locked ? 'Собственную учётную запись отключить нельзя.' : 'Отключённый пользователь не сможет войти.'; } + }, + + formatDate: function (value) { + if (!value) { return '—'; } + return String(value).replace(/^([0-9]{4})-([0-9]{2})-([0-9]{2})/, '$3.$2.$1').slice(0, 16); + }, + + updatePreview: function (roleLabel) { + var name = this.form.querySelector('[name="name"]').value.trim() || 'Новый пользователь'; + var email = this.form.querySelector('[name="email"]').value.trim() || 'Учётная запись ещё не сохранена'; + var role = this.form.querySelector('[name="role"]'); + var roleText = roleLabel || (role && role.options[role.selectedIndex] ? role.options[role.selectedIndex].text : 'Пользователь'); + var avatar = this.form.querySelector('[data-user-preview-avatar]'); + var nameNode = this.form.querySelector('[data-user-preview-name]'); + var meta = this.form.querySelector('[data-user-preview-meta]'); + var roleNode = this.form.querySelector('[data-user-preview-role]'); + if (avatar) { avatar.textContent = name.slice(0, 2).toUpperCase(); } + if (nameNode) { nameNode.textContent = name; } + if (meta) { meta.textContent = email; } + if (roleNode) { roleNode.textContent = roleText; } + }, + + // --- Пароль: показ/скрытие + индикатор надёжности (компоненты AdminKit) --- + PW_HINT: 'Минимум 6 символов, желательно буквы разных регистров и цифры', + + togglePw: function (btn) { + var wrap = btn.closest('.input-wrap'); + var input = wrap ? wrap.querySelector('input') : null; + if (!input) { return; } + var show = input.type === 'password'; + input.type = show ? 'text' : 'password'; + var icon = btn.querySelector('i'); + if (icon) { icon.className = show ? 'ti ti-eye-off' : 'ti ti-eye'; } + }, + + strength: function (value) { + var meter = this.form.querySelector('.pw-strength'); + var hint = this.form.querySelector('[data-pw-hint]'); + if (!meter) { return; } + + if (value === '') { + meter.dataset.level = ''; + if (hint) { hint.textContent = this.PW_HINT; } + return; + } + + var s = 0; + if (value.length >= 6) { s++; } + if (value.length >= 10) { s++; } + if (/[a-z]/.test(value) && /[A-Z]/.test(value)) { s++; } + if (/\d/.test(value) && /[^A-Za-z0-9]/.test(value)) { s++; } + if (s < 1) { s = 1; } + if (s > 4) { s = 4; } + + meter.dataset.level = String(s); + var labels = { 1: 'слабый', 2: 'средний', 3: 'хороший', 4: 'отличный' }; + if (hint) { hint.textContent = 'Надёжность: ' + labels[s]; } + }, + + randomPassword: function (len) { + var lower = 'abcdefghijkmnpqrstuvwxyz'; + var upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; + var digit = '23456789'; + var sym = '!@#$%^&*-_=+'; + var all = lower + upper + digit + sym; + var rnd = function (max) { + var a = new Uint32Array(1); + (window.crypto || window.msCrypto).getRandomValues(a); + return a[0] % max; + }; + var pick = function (set) { return set.charAt(rnd(set.length)); }; + var out = [pick(lower), pick(upper), pick(digit), pick(sym)]; + for (var i = out.length; i < len; i++) { out.push(pick(all)); } + for (var j = out.length - 1; j > 0; j--) { // перемешать + var k = rnd(j + 1); + var t = out[j]; out[j] = out[k]; out[k] = t; + } + return out.join(''); + }, + + generatePassword: function () { + var input = this.form.querySelector('[data-pw-input]'); + if (!input) { return; } + input.value = this.randomPassword(14); + input.type = 'text'; // показываем, чтобы можно было скопировать + var icon = this.form.querySelector('[data-pw-toggle] i'); + if (icon) { icon.className = 'ti ti-eye-off'; } + this.strength(input.value); + Adminx.Toast.show('Пароль сгенерирован', 'success'); + }, + + copyPassword: function () { + var input = this.form.querySelector('[data-pw-input]'); + var val = input ? input.value : ''; + if (!val) { + Adminx.Toast.show('Сначала введите или сгенерируйте пароль', 'error'); + return; + } + var ok = function () { Adminx.Toast.show('Пароль скопирован в буфер', 'success'); }; + var fail = function () { Adminx.Toast.show('Не удалось скопировать', 'error'); }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(val).then(ok).catch(function () { + if (!Adminx.Users.legacyCopy(input)) { fail(); } else { ok(); } + }); + } else if (Adminx.Users.legacyCopy(input)) { + ok(); + } else { + fail(); + } + }, + + legacyCopy: function (input) { + try { + var wasPassword = input.type === 'password'; + if (wasPassword) { input.type = 'text'; } + input.focus(); + input.select(); + var done = document.execCommand('copy'); + if (wasPassword) { input.type = 'password'; } + return done; + } catch (e) { + return false; + } + }, + + resetPassword: function () { + var meter = this.form.querySelector('.pw-strength'); + if (meter) { meter.dataset.level = ''; } + var hint = this.form.querySelector('[data-pw-hint]'); + if (hint) { hint.textContent = this.PW_HINT; } + var input = this.form.querySelector('[data-pw-input]'); + if (input) { input.type = 'password'; } + var icon = this.form.querySelector('[data-pw-toggle] i'); + if (icon) { icon.className = 'ti ti-eye'; } + }, + + submit: function () { + this.clearErrors(); + var id = (this.form.querySelector('[name="id"]').value || '').trim(); + var url = this.base() + '/users' + (id ? '/' + id : ''); + var fd = new FormData(this.form); + var self = this; + + Adminx.Loader.show(); + Adminx.Ajax.post(url, fd).then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { + Adminx.Ajax.handle(payload); + if (!d.redirect) { window.location.reload(); } + return; + } + // ошибки валидации по полям + var errors = d.errors || {}; + Object.keys(errors).forEach(function (field) { + var span = self.form.querySelector('[data-error="' + field + '"]'); + if (span) { span.textContent = errors[field]; } + var input = self.form.querySelector('[name="' + field + '"]'); + if (input) { input.classList.add('is-invalid'); } + }); + if (d.message) { Adminx.Toast.show(d.message, 'error'); } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + toggle: function (checkbox) { + var row = checkbox.closest('tr'); + var id = row.dataset.id; + //-- Нельзя отключать собственную учётную запись (дублирует запрет в контроллере). + if (row.hasAttribute('data-self')) { + checkbox.checked = true; + Adminx.Toast.show('Нельзя отключить свою учётную запись', 'error'); + return; + } + Adminx.Ajax.post(this.base() + '/users/' + id + '/toggle').then(function (payload) { + var d = payload.data || {}; + if (d.success) { + row.dataset.active = String((d.data && d.data.is_active) || 0); + Adminx.Toast.show(d.message, 'success'); + } else { + checkbox.checked = !checkbox.checked; + Adminx.Toast.show(d.message || 'Не удалось', 'error'); + } + }).catch(function () { + checkbox.checked = !checkbox.checked; + Adminx.Toast.show('Ошибка сети', 'error'); + }); + }, + + remove: function (row) { + var id = row.dataset.id; + var base = this.base(); + Adminx.Confirm.open({ + kind: 'error', + title: 'Удалить пользователя?', + message: '«' + row.dataset.name + '» (' + row.dataset.email + ') будет удалён без возможности восстановления.', + confirmLabel: 'Удалить', + confirmClass: 'btn-danger', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(base + '/users/' + id + '/delete').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success) { + row.remove(); + Adminx.Toast.show(d.message, 'success'); + } else { + Adminx.Toast.show(d.message || 'Не удалось удалить', 'error'); + } + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.Users.init(); }); + } else { + Adminx.Users.init(); + } +})(window, document); diff --git a/adminx/modules/Users/language/en/client.xml b/adminx/modules/Users/language/en/client.xml new file mode 100644 index 0000000..b728385 --- /dev/null +++ b/adminx/modules/Users/language/en/client.xml @@ -0,0 +1,29 @@ + + + Failed to copy + Minimum 6 characters, preferably letters of different cases and numbers + Leave the password blank unless you need to change it. + Delete user? + Password copied to clipboard + User + ) will be deleted without the possibility of recovery. + Password generated + Failed to delete + Failed + Delete + First enter or generate a password + weak + Reliability: + New user + A password is required for a new account. + Account has not been saved yet + Network error + You can't deactivate your account + Minimum 6 characters + good + A disabled user will not be able to log in. + You cannot disable your own account. + average + excellent + Change: + diff --git a/adminx/modules/Users/language/en/interface.xml b/adminx/modules/Users/language/en/interface.xml new file mode 100644 index 0000000..b2b3603 --- /dev/null +++ b/adminx/modules/Users/language/en/interface.xml @@ -0,0 +1,85 @@ + + + (optional) + {{ u.is_active ? 'Disable' : 'Enable' }} + Administrators + Active + Enable + All roles + Data + Access + Access allowed + Changed: + Name + Employee name and contact information. + Login + Minimum 6 characters + Minimum 6 characters, preferably letters of different cases and numbers + Find + For example, Ivan Petrov + You can't deactivate your account + New password + New user + NP + Disable + Disabled + A disabled user will not be able to log in. + A password is required for a new account. + Search and filter by role, enable/disable and edit. + Search: name, email, login + Show/hide + Show password + User + Raleigh + The role determines the sections and actions available. + You cannot change your role. + Generate + Copy to clipboard + Created + Created by: + Account has not been saved yet + Employee account and access to Adminx + Accounts + Accounts, roles and access status to Adminx. + accounts · creation, roles and access + ) will be deleted without the possibility of recovery. + Select a role. + Access to the section and list of users. + Change: + Changes saved + Reliability: + Failed + Failed to copy + Failed to delete + Invalid email. + You cannot disable your own account + You can't delete yourself + Leave the password blank unless you need to change it. + excellent + Network error + The password must be at least 6 characters long. + Password generated + Password copied to clipboard + Users + User enabled + User not found + User disabled + User created + User deleted + Check the form fields + View users + System + weak + First enter or generate a password + You cannot disable your own account. + Creating, changing, deleting, turning on/off accounts. + average + Delete + Delete user? + Enter your email. + Please provide a name. + User management + good + This email is already taken. + This login is already taken. + diff --git a/adminx/modules/Users/language/en/runtime.xml b/adminx/modules/Users/language/en/runtime.xml new file mode 100644 index 0000000..5cc9ee9 --- /dev/null +++ b/adminx/modules/Users/language/en/runtime.xml @@ -0,0 +1,25 @@ + + + Select a role. + Access to the section and list of users. + Changes saved + Invalid email. + You cannot disable your own account + You can't delete yourself + The password must be at least 6 characters long. + Users + User enabled + User not found + User disabled + User created + User deleted + Check the form fields + View users + System + Creating, changing, deleting, turning on/off accounts. + Enter your email. + Please provide a name. + User management + This email is already taken. + This login is already taken. + diff --git a/adminx/modules/Users/language/ru/client.xml b/adminx/modules/Users/language/ru/client.xml new file mode 100644 index 0000000..d490b0a --- /dev/null +++ b/adminx/modules/Users/language/ru/client.xml @@ -0,0 +1,29 @@ + + + Не удалось скопировать + Минимум 6 символов, желательно буквы разных регистров и цифры + Оставьте пароль пустым, если его не нужно менять. + Удалить пользователя? + Пароль скопирован в буфер + Пользователь + ) будет удалён без возможности восстановления. + Пароль сгенерирован + Не удалось удалить + Не удалось + Удалить + Сначала введите или сгенерируйте пароль + слабый + Надёжность: + Новый пользователь + Пароль обязателен для новой учётной записи. + Учётная запись ещё не сохранена + Ошибка сети + Нельзя отключить свою учётную запись + Минимум 6 символов + хороший + Отключённый пользователь не сможет войти. + Собственную учётную запись отключить нельзя. + средний + отличный + Изменение: + diff --git a/adminx/modules/Users/language/ru/interface.xml b/adminx/modules/Users/language/ru/interface.xml new file mode 100644 index 0000000..c576b7c --- /dev/null +++ b/adminx/modules/Users/language/ru/interface.xml @@ -0,0 +1,85 @@ + + + (необязательно) + {{ u.is_active ? 'Отключить' : 'Включить' }} + Администраторы + Активен + Включить + Все роли + Данные + Доступ + Доступ разрешён + Изменён: + Имя + Имя и контакты сотрудника. + Логин + Минимум 6 символов + Минимум 6 символов, желательно буквы разных регистров и цифры + Найти + Например, Иван Петров + Нельзя отключить свою учётную запись + Новый пароль + Новый пользователь + НП + Отключить + Отключён + Отключённый пользователь не сможет войти. + Пароль обязателен для новой учётной записи. + Поиск и фильтр по роли, включение/выключение и редактирование. + Поиск: имя, email, логин + Показать / скрыть + Показать пароль + Пользователь + Ролей + Роль определяет доступные разделы и действия. + Свою роль изменить нельзя. + Сгенерировать + Скопировать в буфер + Создан + Создан: + Учётная запись ещё не сохранена + Учётная запись сотрудника и доступ к Adminx + Учётные записи + Учётные записи, роли и состояние доступа к Adminx. + учётных записей · создание, роли и доступ + ) будет удалён без возможности восстановления. + Выберите роль. + Доступ к разделу и списку пользователей. + Изменение: + Изменения сохранены + Надёжность: + Не удалось + Не удалось скопировать + Не удалось удалить + Некорректный email. + Нельзя отключить собственную учётную запись + Нельзя удалить самого себя + Оставьте пароль пустым, если его не нужно менять. + отличный + Ошибка сети + Пароль не короче 6 символов. + Пароль сгенерирован + Пароль скопирован в буфер + Пользователи + Пользователь включён + Пользователь не найден + Пользователь отключён + Пользователь создан + Пользователь удалён + Проверьте поля формы + Просмотр пользователей + Система + слабый + Сначала введите или сгенерируйте пароль + Собственную учётную запись отключить нельзя. + Создание, изменение, удаление, вкл/выкл учётных записей. + средний + Удалить + Удалить пользователя? + Укажите email. + Укажите имя. + Управление пользователями + хороший + Этот email уже занят. + Этот логин уже занят. + diff --git a/adminx/modules/Users/language/ru/runtime.xml b/adminx/modules/Users/language/ru/runtime.xml new file mode 100644 index 0000000..7ff5048 --- /dev/null +++ b/adminx/modules/Users/language/ru/runtime.xml @@ -0,0 +1,25 @@ + + + Выберите роль. + Доступ к разделу и списку пользователей. + Изменения сохранены + Некорректный email. + Нельзя отключить собственную учётную запись + Нельзя удалить самого себя + Пароль не короче 6 символов. + Пользователи + Пользователь включён + Пользователь не найден + Пользователь отключён + Пользователь создан + Пользователь удалён + Проверьте поля формы + Просмотр пользователей + Система + Создание, изменение, удаление, вкл/выкл учётных записей. + Укажите email. + Укажите имя. + Управление пользователями + Этот email уже занят. + Этот логин уже занят. + diff --git a/adminx/modules/Users/migrations/001_normalize_public_session_activity.sql b/adminx/modules/Users/migrations/001_normalize_public_session_activity.sql new file mode 100644 index 0000000..8812fae --- /dev/null +++ b/adminx/modules/Users/migrations/001_normalize_public_session_activity.sql @@ -0,0 +1,23 @@ +-- Correct last_activ in public remember-session tables without changing data. + +SET @ave_rename_table = '{{public_user_prefix}}_users_session'; +SET @ave_rename_sql = IF( + EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'last_activ') + AND NOT EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'last_active'), + CONCAT('ALTER TABLE `', @ave_rename_table, '` CHANGE COLUMN `last_activ` `last_active` INT(11) NOT NULL DEFAULT 0'), + 'SELECT 1' +); +PREPARE ave_rename_stmt FROM @ave_rename_sql; +EXECUTE ave_rename_stmt; +DEALLOCATE PREPARE ave_rename_stmt; + +SET @ave_rename_table = '{{database_prefix}}_users_session'; +SET @ave_rename_sql = IF( + EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'last_activ') + AND NOT EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @ave_rename_table AND COLUMN_NAME = 'last_active'), + CONCAT('ALTER TABLE `', @ave_rename_table, '` CHANGE COLUMN `last_activ` `last_active` INT(11) NOT NULL DEFAULT 0'), + 'SELECT 1' +); +PREPARE ave_rename_stmt FROM @ave_rename_sql; +EXECUTE ave_rename_stmt; +DEALLOCATE PREPARE ave_rename_stmt; diff --git a/adminx/modules/Users/migrations/002_harden_public_remember_tokens.php b/adminx/modules/Users/migrations/002_harden_public_remember_tokens.php new file mode 100644 index 0000000..d952369 --- /dev/null +++ b/adminx/modules/Users/migrations/002_harden_public_remember_tokens.php @@ -0,0 +1,76 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\PublicUserTables; + + return function (array $context = array()) { + $table = PublicUserTables::table('users_session'); + if (!DatabaseSchema::tableExists($table)) { + return 0; + } + + $operations = 0; + $columns = array( + 'token_version' => 'TINYINT UNSIGNED NOT NULL DEFAULT 1 AFTER `hash`', + 'created_at' => 'INT UNSIGNED NOT NULL DEFAULT 0 AFTER `last_active`', + 'expires_at' => 'INT UNSIGNED NOT NULL DEFAULT 0 AFTER `created_at`', + ); + foreach ($columns as $name => $definition) { + if (!DatabaseSchema::columnExists($table, $name)) { + DatabaseSchema::alter('ALTER TABLE `' . $table . '` ADD COLUMN `' . $name . '` ' . $definition); + $operations++; + } + } + + $now = time(); + $ttl = defined('COOKIE_LIFETIME') ? max(3600, (int) COOKIE_LIFETIME) : 1209600; + DB::query( + 'UPDATE `' . $table . '` SET' + . ' `hash`=SHA2(`hash`,256),`token_version`=2,' + . ' `created_at`=IF(`last_active`>0,`last_active`,%i),' + . ' `expires_at`=IF(`last_active`>0,`last_active`,%i)+%i' + . ' WHERE `token_version`<2', + $now, + $now, + $ttl + ); + if (DB::affectedRows() > 0) { $operations++; } + + DB::query( + 'DELETE old_token FROM `' . $table . '` old_token' + . ' INNER JOIN `' . $table . '` keep_token' + . ' ON keep_token.`hash`=old_token.`hash` AND keep_token.`id`>old_token.`id`' + ); + if (DB::affectedRows() > 0) { $operations++; } + + DatabaseSchema::alter( + 'ALTER TABLE `' . $table . '` MODIFY `hash` CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL' + ); + $operations++; + + if (!DatabaseSchema::indexExists($table, 'uniq_remember_hash')) { + DatabaseSchema::alter('ALTER TABLE `' . $table . '` ADD UNIQUE KEY `uniq_remember_hash` (`hash`)'); + $operations++; + } + + if (!DatabaseSchema::indexExists($table, 'idx_remember_expiry')) { + DatabaseSchema::alter('ALTER TABLE `' . $table . '` ADD KEY `idx_remember_expiry` (`expires_at`)'); + $operations++; + } + + return $operations; + }; diff --git a/adminx/modules/Users/module.php b/adminx/modules/Users/module.php new file mode 100644 index 0000000..8e0c5d7 --- /dev/null +++ b/adminx/modules/Users/module.php @@ -0,0 +1,80 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + /** + * Раздел «Пользователи» — CRUD над системной таблицей пользователей. + * Первый системный раздел после пилота (Фаза 1 плана миграции: идентичность). + */ + return [ + 'code' => 'users', + 'name' => 'Пользователи', + 'version' => '0.1.1', + + 'permissions' => [ + 'key' => 'users', + 'items' => array( + array( + 'code' => 'view_users', + 'group_code' => 'navigation', + 'name' => 'Просмотр пользователей', + 'description' => 'Доступ к разделу и списку пользователей.', + 'sort_order' => 10, + ), + array( + 'code' => 'manage_users', + 'group_code' => 'admin', + 'name' => 'Управление пользователями', + 'description' => 'Создание, изменение, удаление, вкл/выкл учётных записей.', + 'sort_order' => 20, + ), + ), + 'icon' => 'ti ti-users', + 'priority' => 20, + ], + + 'navigation' => array( + array( + 'code' => 'users', + 'label' => 'Пользователи', + 'url' => '/users', + 'icon' => 'ti ti-users', + 'permission' => 'view_users', + 'group' => 'Система', + 'sort_order' => 31, + 'match' => array( + '/users', + ), + ), + ), + + 'routes' => array( + array('GET', '/users', array(\App\Adminx\Users\Controller::class, 'index')), + array('GET', '/users/{id}', array(\App\Adminx\Users\Controller::class, 'show')), + array('POST', '/users', array(\App\Adminx\Users\Controller::class, 'store')), + array('POST', '/users/{id}', array(\App\Adminx\Users\Controller::class, 'update')), + array('POST', '/users/{id}/toggle', array(\App\Adminx\Users\Controller::class, 'toggle')), + array('POST', '/users/{id}/delete', array(\App\Adminx\Users\Controller::class, 'destroy')), + ), + + 'migrations' => [ + ['id' => '001_normalize_public_session_activity', 'file' => 'migrations/001_normalize_public_session_activity.sql'], + ['id' => '002_harden_public_remember_tokens', 'file' => 'migrations/002_harden_public_remember_tokens.php'], + ], + + 'view_globals' => [ + 'module_code' => 'users', + ], + ]; diff --git a/adminx/modules/Users/view/list.twig b/adminx/modules/Users/view/list.twig new file mode 100644 index 0000000..2578e4b --- /dev/null +++ b/adminx/modules/Users/view/list.twig @@ -0,0 +1,147 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Пользователи{% endblock %} +{% block content %} + + + + + +
    +
    {{ stats.total }}
    Всего
    +
    {{ stats.active }}
    Активные
    +
    {{ stats.inactive }}
    Отключённые
    +
    {{ stats.roles }}
    Ролей
    +
    + +
    +
    +
    +
    Данные
    +

    Учётные записи

    +

    Поиск и фильтр по роли, включение/выключение и редактирование.

    +
    +
    +
    +
    + +
    + + +
    +
    +
    + +
    +

    Администраторы

    Учётные записи, роли и состояние доступа к Adminx.

    {{ users|length }}
    +
    + + + + + + + + + + {% if can_manage %}{% endif %} + + + + {% for u in users %} + {% set is_self = u.id == current_id %} + + + + + + + + {% if can_manage %} + + {% endif %} + + {% else %} + + {% endfor %} + +
    IDИмяEmailРольСтатусСоздан
    {{ u.id }} + + {{ u.name|slice(0,2)|upper }} + + {{ u.name }} + {% if u.login %}@{{ u.login }}{% endif %} + + + {{ u.email }}{{ roles[u.role]|default(u.role) }} + {% if can_manage and not is_self %} + + {% elseif can_manage and is_self %} + + {% else %} + {{ u.is_active ? 'Активен' : 'Отключён' }} + {% endif %} + {{ u.created_at ? u.created_at|slice(0,10) : '—' }} +
    + + {% if not is_self %} + + {% endif %} +
    +
    Ничего не найдено
    +
    +
    + +{% if can_manage %} + +{% endif %} + +{% endblock %} diff --git a/adminx/view/404.twig b/adminx/view/404.twig new file mode 100644 index 0000000..97c3ff6 --- /dev/null +++ b/adminx/view/404.twig @@ -0,0 +1,10 @@ +{% extends '@adminx/main.twig' %} +{% block title %}404 — не найдено{% endblock %} +{% block content %} +
    +
    404
    +

    Страница не найдена

    +

    Раздел ещё не перенесён в новую админку или адрес указан неверно.

    + На дашборд +
    +{% endblock %} diff --git a/adminx/view/_pagination.twig b/adminx/view/_pagination.twig new file mode 100644 index 0000000..fc6e14b --- /dev/null +++ b/adminx/view/_pagination.twig @@ -0,0 +1,17 @@ +{# Усечённая пагинация. url — строка с плейсхолдером __P__ вместо номера страницы. #} +{% macro render(current, pages, url) %} +{%- set current = current|default(1) -%} +{%- if pages > 1 -%} + +{%- endif -%} +{% endmacro %} diff --git a/adminx/view/_saved_views.twig b/adminx/view/_saved_views.twig new file mode 100644 index 0000000..ad6ec13 --- /dev/null +++ b/adminx/view/_saved_views.twig @@ -0,0 +1,24 @@ +
    +
    + + + + +
    + +
    diff --git a/adminx/view/auth.twig b/adminx/view/auth.twig new file mode 100644 index 0000000..f7cd1f4 --- /dev/null +++ b/adminx/view/auth.twig @@ -0,0 +1,17 @@ + + + + + +{% block title %}{{ lang.btn_login|default('Вход') }}{% endblock %} · AVE.cms + + + +{% for style in admin_assets.styles %} + +{% endfor %} + + +{% block content %}{% endblock %} + + diff --git a/adminx/view/error.twig b/adminx/view/error.twig new file mode 100644 index 0000000..b0724c8 --- /dev/null +++ b/adminx/view/error.twig @@ -0,0 +1,10 @@ +{% extends '@adminx/main.twig' %} +{% block title %}{{ status }} — {{ title }}{% endblock %} +{% block content %} +
    +
    {{ status }}
    +

    {{ title }}

    +

    {{ message }}

    + На дашборд +
    +{% endblock %} diff --git a/adminx/view/main.twig b/adminx/view/main.twig new file mode 100644 index 0000000..c7aa640 --- /dev/null +++ b/adminx/view/main.twig @@ -0,0 +1,266 @@ + + + + + +{% block title %}{{ lang.app_admin_panel|default('Админпанель') }}{% endblock %} · AVE.cms + + + + +{% for style in admin_assets.styles %} + +{% endfor %} + + +{% if not popup_mode %} + + + + +
    + + + {% if site_development_mode %}{{ lang.development_mode|default('Режим разработки') }}{% endif %} +
    + + {% if module_header_actions %} +
    + {% for module_header in module_header_actions %} + {% if module_header.template %}{% include module_header.template with {module_data: module_header.data, module_header: module_header} %}{% endif %} + {% endfor %} +
    + {% endif %} + {% if can_clear_cache %} + + + {% endif %} + {% if section_help and section_help.doc and not popup_mode %}{% endif %} + + +
    +
    + + +{% endif %} + +
    +{% if not popup_mode %} + +
    {% endif %} + {% endif %} + {% endfor %} + + {% set role_titles = {'admin': lang.role_admin|default('Администратор'), 'editor': lang.role_editor|default('Редактор'), 'manager': lang.role_manager|default('Менеджер'), 'author': lang.role_author|default('Автор')} %} + + +{% endif %} + + +
    +{% if section_help and not popup_mode %} + +{% endif %} +{% block content %}{% endblock %} +
    + + +
    + +{% if not popup_mode %} + + +{% endif %} + +{% for module_header in module_header_actions %} + {% if module_header.modal_template %}{% include module_header.modal_template with {module_data: module_header.data, module_header: module_header} %}{% endif %} +{% endfor %} + + + +{% for script in admin_assets.scripts %} + +{% endfor %} +{% if can_clear_cache %} + +{% endif %} +{% block scripts %}{% endblock %} + + diff --git a/changelog.txt b/changelog.txt new file mode 100644 index 0000000..e69de29 diff --git a/configs/db.config.example.php b/configs/db.config.example.php new file mode 100644 index 0000000..f9635a4 --- /dev/null +++ b/configs/db.config.example.php @@ -0,0 +1,25 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + // Шаблон. Скопировать в db.config.php и заполнить своими данными. + $config = [ + 'dbhost' => "localhost", + 'dbuser' => "root", + 'dbpass' => "", + 'dbname' => '', + 'dbpref' => '', + 'dbchar' => "", + 'dbport' => null, + 'dbsock' => null + ]; diff --git a/configs/index.php b/configs/index.php new file mode 100644 index 0000000..928d9ef --- /dev/null +++ b/configs/index.php @@ -0,0 +1,20 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + /** + * Файл-заглушка, предназначенный для запрета показа списка файлов в текущей директории, + * если через адресную строку браузера было прямое общращение к данной директории. + */ + header('Location:/'); + exit; diff --git a/configs/public.config.php b/configs/public.config.php new file mode 100644 index 0000000..8524aab --- /dev/null +++ b/configs/public.config.php @@ -0,0 +1,43 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + return array( + // Имя каталога панели управления относительно корня сайта. При физическом + // переименовании каталога измените это значение на то же имя. + 'admin_directory' => 'adminx', + // Публичная часть одноязычная; значение нужно только старым locale/lang-файлам. + 'public_language' => 'ru', + // Начальные значения для чистого развёртывания. После первого запуска + // регистрацией управляет PublicAuthSettings через Adminx → Покупатели. + 'auth_registration' => array( + 'enabled' => false, + 'gate' => 'email', + 'default_group' => 4, + 'verification_ttl' => 86400, + 'reset_ttl' => 3600, + ), + // Миграционный SSO: только привилегированные legacy-группы могут связаться + // с активным системным пользователем по совпадающему email. + 'system_identity_public_groups' => array(1, 3), + // Forwarded-заголовки учитываются только от перечисленных proxy/CIDR. + // На сервере без reverse proxy оставьте список пустым. + 'trusted_proxies' => array(), + 'trusted_proxy_headers' => array('x-forwarded-for'), + 'debug' => array( + 'enabled' => true, + 'groups' => array(1), + ), + ); diff --git a/help/README.md b/help/README.md new file mode 100644 index 0000000..6a7ca75 --- /dev/null +++ b/help/README.md @@ -0,0 +1,38 @@ +# AVE.cms — документация + +Справочник по компонентам движка. Разделы раскладываются по папкам — по одному +компоненту (или группе) на описание. + +Название системы — **AVE.cms**. В документации слова «панель управления» и +«админка» обозначают административный интерфейс AVE.cms. `adminx/` — имя его +технического каталога по умолчанию и часть внутренних имён API, а не название +отдельного продукта. Каталог можно переименовать при установке или позднее. + +## Разделы + +| Раздел | Описание | +| --- | --- | +| [Установка](installation/README.md) | Чистая установка AVE.cms, требования, первый вход и защита от повторного запуска. | +| [Управление системой](administration/README.md) | Карта core-разделов панели: дашборд, пользователи, настройки, БД, события и эксплуатация. | +| [Как собирается сайт](content/README.md) | Шаблоны, рубрики, поля, документы, меню, запросы, блоки и универсальный каталог: связи и порядок публичного рендера. | +| [Ядро и жизненный цикл запроса](core/README.md) | Инициализация (`App::init`), маршрутизация, контроллеры, авторизация, сессии/CSRF, права. | +| [Модульная система](modules/README.md) | Модули панели управления и составные пакеты AVE.cms: manifest, установка, обновление, удаление, ZIP, миграции и вклады в UI. | +| [Поля документов](fields/README.md) | Архитектура полей, настройки, хранение, шаблоны и разработка собственного типа. | +| [Медиа и миниатюры](media/README.md) | Файловый браузер, генерация превью, режимы размеров, WebP и очистка производных файлов. | +| [Хуки и события](hooks/README.md) | Подписка на события ядра, контексты, каталог хуков, изменение данных и отладка. | +| [Публичная отладка](debug/README.md) | Защищённый Debug Toolbar на сайте, его вкладки, доступ и собственные вызовы `Debug::panel()`. | +| [HTTP API](api/README.md) | Токены, JSON API документов, форматы запросов и ответов, ошибки и безопасность. | +| [Работа с базой данных](database/README.md) | Фасад `DB`, плейсхолдеры запросов, чтение результатов, CRUD, транзакции, кеш запросов, резолверы имён таблиц. | +| [Безопасность файлов](security/README.md) | SHA-256 эталон, эвристический анализ, защита загрузок, карантин и автоматические проверки. | +| [Хелперы фреймворка](helpers/README.md) | 26 статических хелперов: строки, массивы, HTTP-запрос/ответ, валидация, файлы, безопасность, дата/число и т. д. | + +## Соглашения + +- Код совместим с **PHP 7.3** (без стрелочных функций, `??=`, типизации свойств и т. п.). +- Классы ядра и установленных модулей подключаются штатным загрузчиком; ручные `require` для классов не нужны. +- Почти все хелперы — **статические** (`Helper::method()`), состояние не хранят. +- Пути к файлам приложения стройте относительно `BASEPATH`, не привязываясь к + расположению проекта на конкретном сервере. + +Документация описывает поддерживаемые контракты движка. Непубличные детали +реализации могут меняться между версиями. diff --git a/help/administration/README.md b/help/administration/README.md new file mode 100644 index 0000000..9431e25 --- /dev/null +++ b/help/administration/README.md @@ -0,0 +1,46 @@ +# Управление системой + +Этот раздел описывает повседневную работу с чистой панелью AVE.cms. Контентные +сущности вынесены в [отдельную главу](../content/README.md), разработка модулей — +в [модульную систему](../modules/README.md). + +## Карта core-разделов + +| Раздел | Для чего нужен | Подробнее | +| --- | --- | --- | +| Дашборд | Состояние сайта, общие счётчики, недавние документы и виджеты модулей. | [Дашборд и интерфейс](dashboard.md) | +| Документы | Страницы и записи всех рубрик. | [Документы](../content/documents.md) | +| Рубрики и поля | Типы контента и формы документов. | [Рубрики](../content/rubrics.md) | +| Шаблоны | Общая оболочка публичных страниц. | [Шаблоны](../content/templates.md) | +| Блоки | Повторно используемые фрагменты и проектная логика. | [Блоки](../content/blocks.md) | +| Запросы | Списки документов с условиями и шаблонами. | [Запросы](../content/requests.md) | +| Навигация | Меню и деревья ссылок. | [Навигация](../content/navigation.md) | +| Каталог | Универсальные деревья разделов и фильтры. | [Каталог](../content/catalog.md) | +| Медиа | Файлы, изображения, WebP и миниатюры. | [Медиа](../media/README.md) | +| Роли и права | Разрешения сотрудников панели. | [Пользователи и доступ](users.md) | +| Пользователи | Сотрудники, которые могут работать в панели. | [Пользователи и доступ](users.md) | +| Пользователи сайта | Регистрация, личный кабинет и публичный профиль. | [Пользователи и доступ](users.md) | +| Настройки | Параметры сайта, интерфейс, безопасность, кеш и диагностика. | [Настройки](settings.md) | +| База данных | Схема, InnoDB, резервные копии и миграции. | [База данных и события](operations.md) | +| События | Аудит, runtime, 404, SQL-ошибки и переходы. | [База данных и события](operations.md) | +| Обновления | Подписанные патчи ядра, резервная копия и откат. | [Обновления AVE.cms](updates.md) | +| PHP-консоль | Ручная административная диагностика PHP-кода. | [PHP-консоль](console.md) | +| Модули | Установка, обновление, отключение и удаление расширений. | [Модули](../modules/README.md) | + +Дополнительные разделы появляются только после установки соответствующих +модулей. Наличие файлов пакета не означает, что он установлен или включён. + +## Рекомендуемый порядок настройки нового сайта + +1. Заполните основные настройки, почту и публичные адреса. +2. Создайте шаблон сайта и рубрики с полями. +3. Создайте документы, навигацию и необходимые блоки. +4. Настройте запросы и каталоги. +5. Создайте роли сотрудников и выдайте минимальные права. +6. Настройте регистрацию и публичные аккаунты, если они нужны проекту. +7. Проверьте диагностику, 404, SQL-журнал и резервное копирование. +8. Установите только необходимые модули. + +Перед запуском рабочего сайта полезен отдельный checklist готовности: адреса, +шаблоны, 404, превью, почта, права, cron-замены без CLI, кеш и журналы. Он может +стать следующим самостоятельным разделом справки. diff --git a/help/administration/console.md b/help/administration/console.md new file mode 100644 index 0000000..ed81597 --- /dev/null +++ b/help/administration/console.md @@ -0,0 +1,34 @@ +# PHP-консоль + +← [К управлению системой](README.md) + +PHP-консоль выполняет административный диагностический фрагмент в контексте +AVE.cms. Это не механизм фоновых заданий, миграций или публичной бизнес-логики. + +## Допустимые задачи + +- проверить значение настройки или работу хелпера; +- выполнить безопасный read-only запрос; +- исследовать структуру данных во время разработки; +- сохранить часто используемый диагностический сниппет. + +Для изменения схемы используйте миграцию модуля, для массового изменения +документов — специализированную операцию с предварительным просмотром, для +постоянной логики — service/hook. + +## Безопасность + +Выполнение требует отдельного права и может требовать повторного подтверждения +паролем. Код запускается на сервере с правами web-процесса, поэтому способен +изменить БД и файлы. + +Перед изменяющим сценарием: + +1. сделайте backup; +2. ограничьте выборку и сначала выведите предполагаемые изменения; +3. используйте транзакцию, если операция её допускает; +4. не выводите секреты, токены и пароли; +5. после выполнения проверьте аудит и удалите опасный временный сниппет. + +Production-сайт не должен зависеть от ручного запуска консоли или доступности +CLI. Все обязательные операции должны иметь штатный web/lifecycle-механизм. diff --git a/help/administration/dashboard.md b/help/administration/dashboard.md new file mode 100644 index 0000000..26ee5e7 --- /dev/null +++ b/help/administration/dashboard.md @@ -0,0 +1,44 @@ +# Дашборд и интерфейс + +← [К управлению системой](README.md) + +Дашборд — рабочая сводка, а не отдельный источник данных. Его карточки и таблицы +собираются из core-провайдеров и вкладов установленных модулей. + +## Что показывать + +Для обычного сайта полезны общие показатели: + +- активные документы и недавние изменения; +- активные публичные пользователи; +- ошибки 404 и SQL; +- состояние кеша и диагностики; +- уведомления, требующие действия. + +Товарные заказы, остатки и оплаты не должны появляться без установленных +commerce-модулей. Виджет исчезнувшего или выключенного модуля не должен оставлять +пустую область в сетке. + +## Настройка интерфейса + +В **Настройки → Интерфейс** можно менять видимость и порядок: + +- пунктов левого меню; +- виджетов дашборда. + +Настройка применяется поверх реестра модулей. Она не удаляет раздел и не меняет +права: скрытый пункт всё ещё должен быть защищён permission-проверкой на маршруте. + +Если модуль добавляет виджет, действие шапки или уведомление, вклад объявляется +в `module.php`, а пользовательская настройка только определяет его положение и +видимость. Подробнее: [Вклады модулей в UI](../modules/contributions.md). + +## Уведомления + +Колокольчик объединяет core-события и уведомления модулей. Элемент должен иметь +ссылку на действие, стабильный ID и право доступа. Ошибки БД, новые обращения и +новые заказы могут использовать один интерфейс, но поставляются разными +провайдерами. + +Большое вычисление нельзя выполнять при каждом открытии страницы панели. Для +счётчиков используйте быстрый агрегат, индекс или короткий кеш. diff --git a/help/administration/operations.md b/help/administration/operations.md new file mode 100644 index 0000000..73e5639 --- /dev/null +++ b/help/administration/operations.md @@ -0,0 +1,99 @@ +# База данных и системные события + +← [К управлению системой](README.md) + +Эти разделы отвечают за эксплуатацию и диагностику, а не за редактирование +контента. + +## База данных + +AVE.cms ориентируется на MySQL 5.7+ и InnoDB. Имя всех таблиц строится из +префикса `dbpref` в `configs/db.config.php`; не используйте жёсткий префикс в +SQL или модуле. + +Экран показывает только таблицы текущей схемы и префикса. Для каждой доступны +движок, размер и приблизительное количество строк. + +### Применить миграции ядра + +Кнопка показывается только тогда, когда в поставке есть ещё не выполненные +миграции встроенных разделов. Если ledger и файлы совпадают, вместо неё виден +статус **Схема актуальна**. При штатном обновлении эти миграции выполняются +автоматически внутри core-патча. Ручная операция нужна для восстановления или +development-сборки: она применяет ожидающие миграции ядра и +синхронизирует реестр прав. Она не читает резервную копию и не возвращает старые +данные. Миграции устанавливаемого модуля применяются его собственным lifecycle. +Ledger не позволяет повторно запускать успешно завершённую миграцию. Перед +изменением рабочей схемы создайте резервную копию. + +### InnoDB и обслуживание + +Чистая установка использует InnoDB. Перевод оставшихся legacy MyISAM-таблиц +выполняйте только после бэкапа: крупная таблица может временно блокировать запись. +`REPAIR` имеет смысл для MyISAM и не является штатной процедурой InnoDB. + +`OPTIMIZE` не нужно запускать после каждого изменения. Используйте его после +крупного удаления данных или по результату диагностики. + +### Резервные копии + +Панель создаёт gzip-дамп текущего префикса в `tmp/backup`, позволяет скачать, +загрузить обратно, восстановить и удалить его. Загруженный файл сначала +проверяется и не выполняется автоматически. Новые дампы используют переносимый +placeholder `{{prefix}}`: копию можно развернуть в другой установке с иным +`dbpref`. + +Перед восстановлением система проверяет формат, допустимые SQL-команды, таблицы +и неизменность файла. Затем автоматически создаёт страховочную копию текущей +базы и только после этого заменяет данные. Если выполнение дампа оборвётся, +система попробует сразу вернуть страховочную копию. Восстанавливаются все +таблицы текущей установки, поэтому выборочно вернуть одну запись этой кнопкой +нельзя. + +Во время операции открывается окно с живыми этапами: проверка файла, страховочная +копия, очистка текущей схемы, импорт таблиц и сброс runtime-кеша. Для импорта +показываются текущая таблица и общий прогресс. Страница может работать несколько +минут; не запускайте второе восстановление параллельно. Если MySQL отклонит DDL +или данные, в сообщении останутся имя таблицы и исходная ошибка сервера, а не +общая надпись «ничего не восстановлено». + +Дампы AVE.cms переносимы между одинаковыми версиями системы, другим именем базы +и другим префиксом. При создании копии MariaDB-описание таблиц нормализуется до +совместимого с MySQL 5.7 формата. Старое служебное пустое значение `ENUM`, +которое разрешал нестрогий режим MySQL, заменяется на штатное значение колонки +из `DEFAULT`. Это позволяет восстановить старую копию на сервере со строгим +SQL-режимом без отключения проверки остальных данных. Перед переносом между +разными версиями сначала обновите обе установки до одного build. + +Копия на том же сервере не защищает от потери диска: регулярно переносите +рабочие бэкапы во внешнее хранилище и проверяйте восстановление на отдельной +установке. + +## Системные события + +Раздел объединяет несколько независимых источников: + +| Источник | Содержимое | +| --- | --- | +| Аудит | Действия администраторов и lifecycle-операции. | +| Журнал приложения | Служебные сообщения публичного runtime и интеграций. | +| Ошибки 404 | Запросы к отсутствующим адресам. | +| SQL-ошибки | Ошибки запросов к БД. | +| Переходы | Внешние источники, UTM-метки и страницы входа. | + +Состояния окрашиваются по смыслу: успех, изменение, предупреждение, ошибка, +опасное действие и информация. Детали записи раскрываются отдельно, чтобы +таблица оставалась читаемой. + +Журнал можно фильтровать, экспортировать и очищать при наличии права управления. +Очистка аудита сама оставляет новую запись о факте очистки. Перед удалением +данных, нужных для расследования, сохраните CSV. + +## Что проверять регулярно + +1. Новые SQL-ошибки после релиза. +2. Повторяющиеся 404 по внутренним ссылкам и assets. +3. Неожиданные опасные действия в аудите. +4. Рост runtime-журнала и повторяющиеся предупреждения. +5. Необычные источники переходов и рекламные метки. +6. Наличие свежего проверенного бэкапа. diff --git a/help/administration/settings.md b/help/administration/settings.md new file mode 100644 index 0000000..a542cc4 --- /dev/null +++ b/help/administration/settings.md @@ -0,0 +1,100 @@ +# Настройки + +← [К управлению системой](README.md) + +Раздел настроек разбит на самостоятельные страницы. Core хранит только общие +параметры; платёжные ключи, OAuth и другие секреты принадлежат устанавливаемым +модулям. + +## Основные + +Здесь находятся название и адрес сайта, почта, параметры документов, хлебные +крошки и другие общие значения публичного runtime. После изменения проверьте +страницу сайта, отправку тестового письма и canonical URL. + +### Режим разработки + +В группе `Доступ к сайту` можно временно закрыть публичную часть. В этом режиме: + +- документы, публичные страницы модулей, sitemap и API возвращают HTTP 503; +- ответ содержит `Retry-After`, `X-Robots-Tag: noindex, nofollow, noarchive` + и не попадает в публичный кеш; +- аналитика посещений и фоновые web-задачи для закрытого запроса не запускаются; +- сотрудник с правом `Просмотр сайта в режиме разработки` видит настоящий сайт + и небольшой служебный индикатор; +- панель управления и её маршруты продолжают работать. + +Администратор с полным доступом получает право автоматически. Для редактора или +контент-менеджера назначьте его в разделе `Роли и права`. Проверяйте заглушку в +отдельном браузере без административной авторизации. После завершения работ +обязательно верните режим `Сайт открыт`. + +Прямые статические файлы, например CSS и изображения из `uploads`, этот режим не +скрывает: они нужны для предпросмотра сотрудника. Для закрытия файлов от прямого +доступа нужна отдельная политика веб-сервера или приватное хранилище. + +Письмо новому пользователю и подпись письма имеют безопасные значения по +умолчанию, но перед включением регистрации их нужно адаптировать к проекту. + +## Интерфейс + +Управляет порядком и видимостью левого меню и виджетов дашборда. Это персонализация +представления, а не система прав. Защита маршрутов остаётся в ролях и permissions. + +## Константы + +Константа имеет имя, тип, группу, описание, значение и допустимые варианты. +Редактор сначала показывает назначение и тип, затем отдельный контрол выбора +значения. + +- bool редактируется переключателем; +- число — числовым значением без случайного изменения колёсиком там, где это + критично; +- option — человекочитаемым списком; +- путь и строка — соответствующим текстовым контролом. + +Системные константы нельзя удалять как пользовательские. Новую константу +добавляйте только если это действительно runtime-настройка нескольких подсистем; +настройка одного модуля должна жить в `ModuleSettings`. + +## Безопасность + +Здесь находятся общие политики: rate limit, повторное подтверждение паролем для +опасных операций и связанные ограничения. IP-блокировки являются отдельным +экраном для ручной блокировки и разблокировки адресов. + +В чистой установке повторное подтверждение паролем выключено. Его можно включить +для production-проектов, где несколько сотрудников имеют доступ к PHP-коду, +шаблонам и установке модулей. Permission и CSRF-проверки работают в обоих +режимах. + +Администратора можно освободить от публичного rate limit в локальной работе, но +production endpoints всё равно должны иметь защиту от злоупотреблений. + +## Пагинация + +Пагинация хранит обёртку, обычную и активную ссылку, разделитель и подписи. После +изменения проверьте первую, среднюю и последнюю страницу, а также мобильную +ширину. Пагинация и хлебные крошки — разные сущности и не должны делить шаблон. + +## Обслуживание + +Очищает конкретные виды кеша и производных данных. Не используйте «очистить всё» +как штатный способ публикации: обычное сохранение сущности само инвалидирует +свои ключи. + +Перед очисткой превью учитывайте, что изображения будут создаваться повторно +только для разрешённых preset. См. [Медиа и миниатюры](../media/README.md). + +## Диагностика + +Проверяет PHP, расширения, пути записи, БД, почту, таблицы и публичный runtime. +Проверки устанавливаемого модуля должны появляться только вместе с ним; core не +должен ругаться на отсутствующую платёжную систему или форму. + +## Системные файлы + +Встроенный редактор предназначен для небольшого набора разрешённых файлов. Он не +является файловым менеджером. Перед изменением сделайте резервную копию и +проверьте синтаксис; ошибка в конфигурации или `.htaccess` может закрыть весь +сайт и панель. diff --git a/help/administration/updates.md b/help/administration/updates.md new file mode 100644 index 0000000..67eda7f --- /dev/null +++ b/help/administration/updates.md @@ -0,0 +1,67 @@ +# Обновления AVE.cms + +Раздел **Система → Обновления** устанавливает подписанные исправления ядра без +копирования полной сборки поверх рабочего сайта. + +## Подключение источника + +При чистой установке достаточно выбрать **Официальный канал AVE.cms**. Адрес и +открытый ключ уже находятся в сборке; искать и копировать их не требуется. +Система только проверяет наличие патчей и никогда не устанавливает их +автоматически. + +Если источник был отключён или изменён, откройте вкладку **Источник** и нажмите +**Восстановить официальный**. Текущий URL и ключ будут заменены доверенными +значениями из установленной сборки. + +Для стороннего источника его издатель должен предоставить единый JSON-профиль +либо отдельно HTTPS URL `index.json` и публичный RSA-ключ. Публичный ключ не +является паролем: он позволяет проверить подпись каталога. Закрытый ключ всегда +остаётся у издателя. + +1. Откройте вкладку **Источник**. +2. Включите проверку удалённого каталога. +3. Укажите HTTPS URL файла `index.json`. +4. Вставьте публичный RSA-ключ выбранного издателя. +5. Сохраните настройки и нажмите **Проверить**. + +Публичный ключ не является секретом. Закрытый ключ никогда не загружается на +сайт. Ключ обновлений ядра отделён от ключа каталога модулей. + +## Установка + +Выберите кумулятивный патч и нажмите кнопку подготовки. До подтверждения файлы +сайта не меняются. Один патч устанавливает все исправления от минимального +указанного build до текущего релиза; промежуточные обновления не нужны. +AVE.cms проверит подпись, диапазон build, требования сервера и права записи. + +Если выбран не тот патч, нажмите **Отменить** до запуска. Загруженный ZIP и +временные файлы удалятся; это действие не затрагивает сайт и базу данных. + +После запуска выполняются резервная копия, временное закрытие публичного сайта, +замена файлов, миграции и контрольная проверка. Этапы идут короткими AJAX- +запросами; вкладку можно открыть снова и продолжить незавершённое задание. + +Если патч содержит миграции, копия БД создаётся порциями. На больших сайтах это +может занять заметное время, но обрыв запроса не начинает процесс заново. + +## Какие файлы заменяются + +Файлы ядра, входящие в патч, заменяются последней опубликованной версией без +сверки со старой checksum на сервере. Их текущее состояние сначала сохраняется +в журнал отката. Подпись и checksum проверяют целостность нового архива, а номер +build определяет применимость обновления. + +Настройки БД, публичные шаблоны, медиа, runtime-данные и модули core-патчем не +перезаписываются. Новые миграции схемы выполняются внутри того же задания; после +обновления отдельно запускать миграции ядра не требуется. + +## Откат + +Кнопка **Откатить** возвращает файлы и, если миграции уже начались, базу данных. +Во время обновления браузер также сохраняет аварийную ссылку восстановления. +Она используется только тогда, когда панель после замены файлов не открывается, +и перестаёт работать после штатного завершения. + +Не удаляйте `storage/updates/` и не копируйте поверх сайта другую сборку, пока +задание не завершено или не откатилось. diff --git a/help/administration/users.md b/help/administration/users.md new file mode 100644 index 0000000..38bad8e --- /dev/null +++ b/help/administration/users.md @@ -0,0 +1,81 @@ +# Пользователи и доступ + +← [К управлению системой](README.md) + +В AVE.cms есть две связанные, но разные задачи: доступ сотрудников к панели и +публичные аккаунты посетителей. + +## Пользователи панели + +Раздел **Пользователи** хранит сотрудников. Пользователь имеет имя, логин, email, +телефон, активность и роль. Роль определяет доступные разделы и действия. + +- администратор не может отключить собственную активную учётную запись; +- администратор не должен лишить себя административной роли; +- отключение запрещает новый вход, но сохраняет автора документов и аудит; +- удаление сотрудника не должно уничтожать созданный им контент. + +Системный администратор использует общую авторизацию сайта и панели. После входа +в одном контексте он не должен повторно регистрироваться или вводить пароль в +другом; доступ к панели всё равно проверяется отдельным правом `admin_panel`. + +## Роли и права + +Права регистрируются ядром и установленными модулями, затем назначаются роли. +Выдавайте минимальный набор: + +- просмотр раздела; +- управление данными; +- отдельные опасные действия, если они предусмотрены модулем. + +Контент-менеджеру можно разрешить документы конкретных рубрик, не открывая БД, +системные файлы, PHP-код и установку модулей. Системная роль администратора +сохраняет полный доступ. + +## Пользователи сайта + +Раздел **Пользователи сайта** управляет регистрацией, личным кабинетом и +публичным профилем. Публичный пользователь может быть покупателем, автором, +участником сообщества или подписчиком — название раздела не привязывает систему +к магазину. + +Здесь настраиваются: + +- включение регистрации и группа новых аккаунтов; +- минимальная длина пароля и подтверждение email; +- сброс пароля; +- URL страниц входа, регистрации, восстановления и кабинета; +- HTML-шаблоны публичных форм; +- дополнительные поля профиля и их участие в регистрации/кабинете; +- установленные OAuth- и SMS-гейты. + +Назначенные URL резервируются в публичном реестре, поэтому документ с таким +alias создать нельзя. + +## Дополнительные поля профиля + +Поле имеет тип, подпись, обязательность, порядок и области показа. Не собирайте +данные «на будущее»: добавляйте только то, что реально используется в профиле, +заказах или интеграциях. + +Изменение типа рабочего поля требует миграции существующих значений. Сначала +создайте новое поле и перенесите данные, затем отключайте старое. + +## OAuth + +Вход через Яндекс и VK ID поставляется физически устанавливаемыми модулями. После +установки провайдер отображается в настройках публичной авторизации. OAuth не +создаёт отдельный тип пользователя: он связывает внешний аккаунт с той же +публичной учётной записью и сессией AVE.cms. + +## Вход по телефону + +Одноразовые коды и телефонная личность являются частью общей авторизации +AVE.cms, а конкретная отправка SMS подключается устанавливаемым модулем. После +установки `Вход по SMS (SMSC)` настройте API-ключ, отправьте тестовое сообщение +и только затем включайте публичную форму. + +Подтверждённый телефон уникален. Новый номер может создать обычного публичного +пользователя в группе, выбранной в настройках регистрации. Сотруднику не нужна +вторая учётная запись: если тот же телефон указан у его связанного публичного +профиля, SMS откроет существующую общую сессию. diff --git a/help/api/README.md b/help/api/README.md new file mode 100644 index 0000000..9c10759 --- /dev/null +++ b/help/api/README.md @@ -0,0 +1,43 @@ +# HTTP API + +Система предоставляет versioned JSON API документов для серверных интеграций. +Через него можно получить документ по ID или alias, создать новый и обновить +существующий. Запись проходит через те же поля, валидацию, код рубрики, hooks, +ревизии, terms и JSON snapshot, что и панель управления. + +## Маршруты v1 + +| Метод и URL | Scope | Назначение | +| --- | --- | --- | +| `GET /api/v1/documents/{id}` | `documents:read` | Получить документ по ID. | +| `GET /api/v1/documents/by-alias?alias=...` | `documents:read` | Получить по полному alias. | +| `POST /api/v1/documents` | `documents:write` | Создать документ. | +| `PUT /api/v1/documents/{id}` | `documents:write` | Обновить документ. | +| `PATCH /api/v1/documents/{id}` | `documents:write` | Частично обновить документ. | + +## API устанавливаемых модулей + +Управляемые модули могут добавлять собственные маршруты и правила доступа. После +установки модуля **Поиск по сайту** доступен публичный endpoint: + +| Метод и URL | Доступ | Назначение | +| --- | --- | --- | +| `GET /api/v1/search?q=...` | Публичный, rate limit | Поиск с настраиваемой проекцией полей и форматами `json`, `html`, `both`. | + +Состав ответа поиска задаётся в настройках модуля и не связан со scopes API- +токена документов. Полная настройка, параметры и примеры приведены в +[инструкции по поиску](../modules/search.md). + +В v1 нет листинга и удаления документов. Не перебирайте ID для выгрузки всего +контента: для такой интеграции нужен отдельный ограниченный endpoint модуля. + +## Карта раздела + +| Страница | Назначение | +| --- | --- | +| [Токены и безопасность](authentication.md) | Выпуск, scopes, Bearer, срок, отзыв, права и rate limit. | +| [Документы](documents.md) | Формат чтения, полный payload записи, поля и примеры curl. | +| [Ошибки](errors.md) | HTTP-статусы, JSON-контракт и стратегия повтора. | + +Все ответы имеют `Content-Type: application/json; charset=UTF-8`. Клиент должен +проверять и HTTP-статус, и `success`, а не искать текст сообщения. diff --git a/help/api/authentication.md b/help/api/authentication.md new file mode 100644 index 0000000..966195c --- /dev/null +++ b/help/api/authentication.md @@ -0,0 +1,63 @@ +# Токены и безопасность API + +← [К разделу «HTTP API»](README.md) + +## Создание токена + +1. В панели управления откройте **Документы → JSON API**. +2. Нажмите создание токена. +3. Укажите понятное имя интеграции. +4. Выберите минимально необходимые scopes. +5. При необходимости задайте срок действия. +6. Скопируйте секрет сразу: повторно он не показывается. + +Для управления токенами требуется право `manage_document_api`. + +## Передача Bearer + +```http +Authorization: Bearer ave_... +Accept: application/json +``` + +Не передавайте токен в query string, HTML, публичный JavaScript или журнал +запросов. Секрет предназначен для server-to-server интеграции и хранится в +секретах окружения внешнего сервиса. + +В БД система хранит только SHA-256 hash и короткий prefix. Восстановить +потерянный секрет нельзя: отзовите его и создайте новый. + +## Scopes и права пользователя + +| Scope | Разрешает | +| --- | --- | +| `documents:read` | GET по ID и alias. | +| `documents:write` | POST/PUT/PATCH. | +| `documents:*` | Чтение и запись. | + +Scope не заменяет роли. Владелец токена должен оставаться активным пользователем +и иметь `view_documents` для чтения или `manage_documents` для записи. Роль +`admin` и `all_permissions` проходят эту проверку. + +Если пользователя отключили, его роль потеряла право, токен истёк или отозван, +API отвечает `401 unauthorized`. + +## Срок и отзыв + +Токен может быть бессрочным или иметь будущую дату окончания. Панель показывает +последнее использование с обновлением не чаще одного раза в пять минут. При +компрометации сначала отзовите ключ, затем проверьте аудит действий его +владельца и создайте новый секрет. + +## Ограничение запросов + +Каждый токен может выполнить до 120 API-запросов за 60 секунд. При превышении: + +```http +HTTP/1.1 429 Too Many Requests +Retry-After: 17 +``` + +Клиент должен дождаться количества секунд из `Retry-After`, затем повторить +запрос с backoff. Не переключайтесь между несколькими токенами для обхода +лимита. diff --git a/help/api/documents.md b/help/api/documents.md new file mode 100644 index 0000000..8b6ff99 --- /dev/null +++ b/help/api/documents.md @@ -0,0 +1,161 @@ +# JSON API документов + +← [К разделу «HTTP API»](README.md) + +Во всех примерах замените домен и `$TOKEN` значениями своей установки. + +## Чтение + +```bash +curl -sS 'https://example.test/api/v1/documents/42' \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Accept: application/json' +``` + +По alias: + +```bash +curl -sS --get 'https://example.test/api/v1/documents/by-alias' \ + --data-urlencode 'alias=news/2026/example' \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Accept: application/json' +``` + +Успешный ответ: + +```json +{ + "success": true, + "data": { + "format": "ave.document-api", + "version": 1, + "document": { + "id": 42, + "rubric_id": 3, + "rubric_template_id": 1, + "title": "Пример", + "alias": "news/2026/example", + "excerpt": "Краткий анонс", + "status": 1, + "deleted": 0, + "published_at": 1784062800, + "expire_at": 0, + "changed_at": 1784062800, + "author_id": 1, + "meta": { + "title": "301", + "description": "Описание страницы", + "keywords": "пример, api", + "robots": "index,follow" + } + }, + "fields": { + "cover": { + "id": 17, + "type": "image_single", + "value": { + "url": "/uploads/example.jpg", + "description": "Обложка" + } + } + }, + "revision": 1784062800, + "generated_at": 1784062801 + } +} +``` + +Ключи `fields` — alias поля, а при его отсутствии строковый ID. Клиент должен +ориентироваться на `type` и `value`, не разбирать внутреннюю строку хранения. +`format` и `version` позволяют в будущем добавить новый формат без тихой поломки. + +## Создание + +Минимально нужны существующая `rubric_id` и `title`: + +```bash +curl -sS 'https://example.test/api/v1/documents' \ + -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + --data-binary @document.json +``` + +```json +{ + "rubric_id": 3, + "title": "Новая статья", + "alias": "new-article", + "excerpt": "Текст для карточки и метаописания по умолчанию", + "status": true, + "tags": ["API", "Интеграция"], + "meta_description": "Описание страницы", + "fields": { + "cover": { + "url": "/uploads/articles/cover.jpg", + "description": "Обложка статьи" + }, + "rating": 8 + } +} +``` + +Если `alias` задан только последним сегментом или пуст, система применяет +шаблон alias рубрики. Неизвестное поле или поле другой рубрики вызывает 422. +Не переданные поля нового документа получают значения по умолчанию рубрики. + +Успех возвращает `201 Created`, экспорт созданного документа и: + +```json +{ + "meta": { + "operation": "create", + "snapshot_warning": "" + } +} +``` + +## Обновление + +`PUT` и `PATCH` в текущей версии сохраняют не переданные свойства и поля: + +```bash +curl -sS 'https://example.test/api/v1/documents/42' \ + -X PATCH \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + --data '{"status":false,"fields":{"rating":9}}' +``` + +Успех возвращает `200 OK`, `operation: update` и новый snapshot. Рубрику +существующего документа изменить нельзя; создайте документ в другой рубрике. + +## Свойства документа + +| Ключ | Значение | +| --- | --- | +| `rubric_id` | Рубрика; обязательна при создании. | +| `template_id`, `parent_id` | Шаблон и родительский документ. | +| `title`, `breadcrumb_title`, `excerpt` | Основные тексты. | +| `alias`, `short_alias` | Публичные адреса. | +| `redirect_code` | `301`, `302`, `307` или `308`. | +| `alias_history` | Режим истории alias: `0..2`. | +| `published_at`, `expire_at` | Unix timestamp или строка даты, понятная PHP. | +| `author_id` | Автор; при создании фактически владельцем становится actor токена. | +| `status`, `in_search` | Boolean; также принимаются `0/1`, `true/false`, `active/published`. | +| `meta_keywords`, `meta_description`, `meta_robots` | SEO. Robots: `index,follow`, `index,nofollow`, `noindex,nofollow`. | +| `sitemap_frequency` | Числовое значение `0..6`. | +| `sitemap_priority` | Число `0..1`. | +| `navigation_id` | Связанный пункт навигации. | +| `tags` | Строка через запятую или массив. | +| `property`, `position`, `guid` | Дополнительные системные свойства. | +| `fields` | Объект значений полей по alias или числовому ID. | + +Значение поля можно передать напрямую или как `{"value": ...}`. Структурное +значение соответствует JSON-контракту типа поля; его описание находится в +[разделе о полях](../fields/README.md). + +Тело POST/PUT/PATCH должно быть валидным JSON до 2 МБ с +`Content-Type: application/json`. diff --git a/help/api/errors.md b/help/api/errors.md new file mode 100644 index 0000000..c86be80 --- /dev/null +++ b/help/api/errors.md @@ -0,0 +1,51 @@ +# Ошибки HTTP API + +← [К разделу «HTTP API»](README.md) + +Единый формат ошибки: + +```json +{ + "success": false, + "error": { + "code": "validation_failed", + "message": "Проверьте данные документа", + "fields": { + "title": "Укажите название документа" + } + } +} +``` + +`fields` присутствует у ошибок валидации и содержит машинный путь поля → текст. +Не привязывайте логику клиента к русскому `message`; используйте `code` и +HTTP-статус. + +| HTTP | `error.code` | Причина | +| --- | --- | --- | +| 400 | `invalid_json` | Тело не является корректным JSON-объектом. | +| 401 | `unauthorized` | Нет Bearer, токен/scope/роль недействительны. | +| 404 | `not_found` | Документ не найден или удалён. | +| 413 | `payload_too_large` | Тело больше 2 МБ или не прочитано. | +| 415 | `unsupported_media_type` | Для записи не указан `application/json`. | +| 422 | `validation_failed` | Ошибка документа, поля, hook или кода рубрики. | +| 429 | `rate_limited` | Превышено 120 запросов за 60 секунд. | +| 500 | `save_failed` | Непредвиденная ошибка сохранения. | + +## Повтор запроса + +- `400`, `401`, `404`, `413`, `415`, `422` не повторяйте без исправления запроса + или доступа. +- При `429` соблюдайте `Retry-After` и exponential backoff. +- При `500` допустим ограниченный повтор только для идемпотентной операции. + +Создание через POST не имеет внешнего idempotency-key в v1. После сетевого +обрыва сначала попробуйте найти документ по заранее заданному уникальному alias, +а не отправляйте POST вслепую второй раз. Для критичной массовой интеграции +используйте стабильный `guid` и собственный модуль синхронизации. + +## Журналирование клиента + +Записывайте метод, URL без токена, HTTP-статус, `error.code`, request ID внешней +системы и длительность. Не журналируйте заголовок `Authorization` и полное тело, +если оно содержит персональные данные. diff --git a/help/content/README.md b/help/content/README.md new file mode 100644 index 0000000..3c4d832 --- /dev/null +++ b/help/content/README.md @@ -0,0 +1,744 @@ +# Как AVE.cms собирает сайт + +Эта глава объясняет публичную модель AVE.cms: что такое шаблон сайта, рубрика, +поле, документ, навигация, запрос, блок и каталог, как они связаны и в каком +порядке превращаются в готовую HTML-страницу. + +## Общая схема + +```text +URL + -> документ + -> рубрика + -> поля документа + -> шаблон рубрики + -> [tag:maincontent] + -> шаблон сайта + -> блоки и модули + -> запросы + -> навигации + -> SEO и хлебные крошки + -> готовый HTML +``` + +У каждой сущности одна основная зона ответственности: + +| Сущность | Назначение | +| --- | --- | +| Шаблон сайта | Общая HTML-оболочка: `head`, шапка, подвал и места для компонентов. | +| Тема | Статические CSS/JS, изображения, шрифты и порядок их подключения. | +| Рубрика | Тип контента: набор полей, правила URL, права и способ вывода одного документа. | +| Поле | Контракт отдельного значения: редактор, валидация, хранение и публичный вывод. | +| Документ | Конкретная запись рубрики со своим URL, состоянием, SEO и значениями полей. | +| Навигация | Независимое дерево ссылок на документы и произвольные URL. | +| Запрос | Сохраненная выборка документов с условиями, сортировкой и шаблоном списка. | +| Блок | Переиспользуемый фрагмент разметки или логики. | +| Каталог | Иерархия разделов, назначение документов, характеристики, фильтры и листинги. | +| Модуль | Устанавливаемое расширение с маршрутами, тегами, таблицами, hooks и интерфейсом. | + +## Практические руководства + +Если вы впервые работаете с системой или хотите понять новый редактор целиком, +начните с подробной главы [Content Studio: от рубрики до публикации](content-studio.md). + +| Раздел панели | Руководство | +| --- | --- | +| Документы | [Создание, публикация, URL, ревизии и системные страницы](documents.md) | +| Рубрики и поля | [Проектирование типа контента, формы и публичного вывода](rubrics.md) | +| Шаблоны | [Внешняя оболочка сайта, теги, кеш и ревизии](templates.md) | +| Twig-компоненты | [Точечные модульные вставки и границы их применения](twig-components.md) | +| Темы | [CSS, JavaScript, изображения, шрифты и ZIP-пакеты темы](themes.md) | +| Блоки | [Переиспользуемые фрагменты, редакторы и параметры](blocks.md) | +| Навигация | [Шаблоны уровней, пункты и сборка HTML](navigation.md) | +| Запросы | [Выборки документов, группы условий и шаблоны списка](requests.md) | +| Каталог | [Универсальные деревья разделов, поля и фильтры](catalog.md) | + +Медиа, поля и модули вынесены в отдельные главы: [Медиа и +миниатюры](../media/README.md), [Поля документов](../fields/README.md) и +[Модульная система](../modules/README.md). + +Основной публичный сайт собирается встроенными сущностями AVE.cms. Twig не +заменяет шаблоны сайта, рубрик и запросов. Для сложных модульных компонентов он +подключается через обычный зарегистрированный тег; подробности приведены в +[отдельной главе](twig-components.md). + +## Три уровня шаблонов + +В AVE.cms слово «шаблон» используется для трех связанных, но разных уровней. + +### 1. Шаблон сайта + +Шаблон сайта содержит весь HTML-документ: + +```html + + + + + + [tag:title] + + + + [tag:rubheader] + + + + +
    + [tag:maincontent] +
    + +
    + [tag:sysblock:footer] +
    + [tag:rubfooter] + + +``` + +Главный контракт шаблона сайта — тег `[tag:maincontent]`. В него движок +подставляет результат шаблона рубрики текущего документа или HTML системной +страницы модуля. + +Шаблон сайта обычно содержит: + +- ``, `` и ``; +- SEO-теги текущего документа; +- общую шапку и подвал; +- теги подключения CSS и JavaScript активной темы; +- основную и вспомогательные навигации; +- общие блоки и модульные компоненты; +- `[tag:rubheader]` и `[tag:rubfooter]` для точечных вставок рубрики. + +Шаблон сайта не должен знать полный набор полей новости, товара или статьи. +Этим владеет рубрика. + +### 2. Основной шаблон рубрики + +Основной шаблон рубрики описывает содержимое одного документа: + +```html +
    +
    +

    [tag:doc:document_title]

    + +
    + +
    [tag:fld:lead]
    +
    [tag:fld:image]
    +
    [tag:fld:content]
    +
    +``` + +После обработки полей этот HTML становится `[tag:maincontent]` внешнего +шаблона сайта. + +### 3. Дополнительный шаблон рубрики + +Документ может выбрать альтернативный вариант вывода той же рубрики. Например, +у рубрики «Статьи» могут быть шаблоны: + +- обычная статья; +- лонгрид; +- фотогалерея; +- промостраница. + +Набор полей и правила рубрики остаются общими, меняется только композиция +конкретного документа. + +## Рубрика + +Рубрика — это тип контента, а не папка документов. Примеры рубрик: + +- Новости; +- Статьи; +- Страницы; +- Товары; +- Сотрудники; +- Вопросы и ответы. + +Рубрика определяет: + +- набор и группы полей; +- порядок и ширину полей в редакторе документа; +- значения полей по умолчанию; +- основной и дополнительные публичные шаблоны; +- шаблон тизера для элементов запросов; +- внешний шаблон сайта; +- шаблон формирования URL новых документов; +- права групп пользователей; +- Open Graph, вставки в header и footer; +- код и hooks до и после сохранения документа. + +Например, рубрика «Новости» может содержать поля `image`, `lead`, `content`, +`source` и `gallery`. Все документы этой рубрики используют один контракт +данных, поэтому запросы и шаблоны могут обращаться к полям по стабильным alias. + +Подробнее о типах, настройках и хранении значений: [Поля документов](../fields/README.md). + +## Документ + +Документ — конкретная запись выбранной рубрики. Он хранит: + +- ID и рубрику; +- название и заголовок хлебных крошек; +- alias и историю URL; +- публикацию, удаление и срок действия; +- автора и даты; +- анонс; +- SEO, robots, ключевые слова и теги; +- значения полей рубрики; +- выбранный дополнительный шаблон рубрики; +- связи с каталогами и модулями. + +Документ предоставляет данные, но не должен самостоятельно описывать всю +внешнюю оболочку сайта. + +### URL документа + +Рубрика может формировать alias по шаблону: + +```text +news/%year/%month/%alias +``` + +Для документа с коротким alias `company-opened-office` результатом будет: + +```text +/news/2026/07/company-opened-office +``` + +Меню не участвует в формировании URL. После изменения alias прежний адрес может +остаться в истории редиректов и вести на новый URL. + +## Навигация + +Навигация — независимое дерево ссылок. Она хранит: + +- пункты и уровни вложенности; +- связь с документом или произвольный URL; +- название, описание, target и изображение; +- CSS-класс и дополнительные атрибуты; +- доступность группам пользователей; +- шаблоны обычного и активного пункта каждого уровня. + +Навигация вставляется в страницу по alias: + +```text +[tag:navigation:main] +``` + +### Как собирается HTML навигации + +Шаблоны навигации применяются в строгом порядке: + +```text +Обёртка: начало + Уровень 1: начало + Обычный или активный пункт уровня 1 + ... + Уровень 1: конец +Обёртка: конец +``` + +Для вложенного меню шаблон пункта родительского уровня должен содержать место +вывода следующего уровня: + +```html + +
  • + [tag:linkname] + [tag:level:2] +
  • + + +
  • + [tag:linkname] + [tag:level:3] +
  • +``` + +`Уровень N: начало` задаёт обёртку списка. Тег `[tag:content]` внутри неё +заменяется готовыми пунктами уровня. Если тег не указан, пункты добавляются +после начальной разметки. `Уровень N: конец` всегда выводится после всех пунктов +уровня. Поля `Обёртка: начало` и `Обёртка: конец` выводятся один раз вокруг +всего дерева. + +Пример настроек первого уровня: + +```html + + +``` + +Результат будет иметь полную структуру +``. Если в навигации нет доступных пунктов, +внешние обёртки не выводятся. + +Удаление пункта меню не удаляет документ и не отключает его URL. Один документ +может находиться в нескольких меню или не входить ни в одно. + +## Запросы + +«Запрос» в AVE.cms — не HTTP-запрос, а сохраненная выборка документов. Он +отвечает на два вопроса: + +1. Какие документы выбрать? +2. Как вывести список и каждый его элемент? + +Запрос может задавать: + +- одну или несколько рубрик; +- простые и вложенные группы условий; +- сочетания `И` и `ИЛИ`; +- сортировку; +- лимит и пагинацию; +- шаблон элемента; +- обертку списка; +- настройки кеширования. + +Примеры запросов: + +- последние новости; +- популярные статьи; +- материалы текущего автора; +- связанные документы; +- акции; +- товары с заданными свойствами. + +Тег запроса использует ID или alias: + +```text +[tag:request:latest_news] +``` + +Условия с разной логикой должны быть сгруппированы явно: + +```text +Опубликован +И +( + Рубрика = Новости + ИЛИ + Рубрика = Статьи +) +И +( + Тег = Важное + ИЛИ + Просмотры > 1000 +) +``` + +Система сначала выбирает документы, затем применяет к каждому шаблон элемента и +объединяет элементы в обертку списка. + +## Блоки + +Блок — переиспользуемый фрагмент, который можно вставлять в шаблон сайта, +шаблон рубрики, запрос или другой блок. + +Примеры: + +- контакты в подвале; +- баннер; +- форма подписки; +- предупреждение; +- преимущества; +- сложный компонент с PHP-логикой. + +```text +[tag:sysblock:product_card] +[tag:sysblock:contacts] +``` + +Блоки могут содержать запросы, навигации, модульные теги и условия. Для новой +логики предпочтительнее передавать сложные операции в сервисы и модули, оставляя +в блоке композицию и небольшие проектные условия. + +## Каталог + +Каталог — отдельный слой над рубриками, документами и полями. Он не равен +магазину и не должен предполагать, что каждый проект содержит товары. + +Каталог можно использовать для: + +- товарного каталога; +- новостного портала; +- базы знаний; +- каталога услуг; +- библиотеки документов; +- базы специалистов или организаций; +- любого структурированного раздела с фильтрацией. + +### Место каталога в системе + +```text +Рубрики и поля + | + v +Документы + | + v +Каталог + |- разделы и вложенность + |- назначенные документы + |- наборы характеристик + |- фильтры и условия + |- сортировка + |- шаблон карточки и листинга + `- индекс для быстрого публичного поиска +``` + +Рубрика отвечает за структуру одного документа. Каталог отвечает за то, где и +как множество документов показывается пользователю. + +### Что хранит каталог + +- собственное название и назначение; +- корневые и вложенные разделы; +- документы каждого раздела; +- используемые поля и группы полей; +- поля, включенные по умолчанию для новых разделов; +- фильтры и их порядок; +- условия показа разделов и документов; +- настройки сортировки и пагинации; +- конфигурацию карточки и листинга; +- проекцию данных для быстрого публичного чтения. + +### Каталог и запросы + +Обычный запрос подходит для независимой подборки: последние новости, работы +автора, акции или связанные статьи. + +Каталог нужен, когда дополнительно требуются: + +- постоянная иерархия разделов; +- назначение документов разделам; +- фасетные фильтры; +- согласованные карточки; +- управляемая сортировка; +- быстрый индекс большого набора документов. + +Запросы и каталог дополняют друг друга. Например, главная страница может +показывать запрос «Новинки», а переход в раздел открывает полноценный каталог с +фильтрами. + +### Товарные возможности + +Товары являются расширением универсального каталога. Модуль товаров добавляет: + +- артикул, цену, старую цену и наличие; +- товарные характеристики; +- варианты одного товара; +- карточки товара; +- товарную индексацию. + +Корзина, заказы, оплаты и доставки принадлежат отдельным модулям. Их физическое +удаление не должно удалять документы, рубрики и универсальную структуру +каталога. + +### Как выводится раздел каталога + +1. По URL определяется каталог и его раздел. +2. Загружаются настройки раздела, набор полей и фильтров. +3. Входные значения фильтров нормализуются и проверяются. +4. Индекс выбирает подходящие документы. +5. Применяются сортировка и пагинация. +6. Для каждого документа собирается карточка. +7. Карточки объединяются в листинг. +8. Результат вставляется в публичную страницу каталога. + +Карточка является отдельным управляемым шаблоном. Она не должна копироваться в +несколько системных блоков, иначе главная, каталог и подборки начинают выглядеть +и работать по-разному. + +### Индексация и кеш каталога + +Публичный фильтр не должен каждый раз разбирать все JSON-значения полей. После +сохранения документа его данные проецируются в индекс каталога. Индекс содержит +только значения, нужные для листинга, сортировки и фильтрации. + +Перестроение требуется после изменения: + +- документа или его полей; +- назначения документа разделам; +- набора характеристик; +- настроек фильтров; +- конфигурации карточки; +- структуры вариантов товара. + +## Модули + +Модуль — физически отделяемое расширение AVE.cms. Он может добавить: + +- собственные публичные и административные маршруты; +- теги для шаблонов, рубрик и блоков; +- новые типы полей; +- hooks и события; +- таблицы и миграции; +- настройки и права; +- пункт в меню панели управления; +- действие в шапке, уведомления и Dashboard-виджет; +- фоновые web-задачи и интеграции с внешними сервисами. + +### Где модуль участвует в сборке + +До поиска документа `PublicKernel` загружает только включенные публичные части +установленных модулей. После этого возможны три основных сценария. + +1. **Собственный маршрут.** Модуль полностью отвечает на URL и завершает запрос + без документа. Так работают API, callbacks и файловые endpoints. +2. **Страница в оболочке сайта.** Модуль предоставляет `[tag:maincontent]`, но + использует выбранный шаблон сайта, SEO и общий публичный pipeline. +3. **Встроенный компонент.** Модуль регистрирует тег, который встречается в + шаблоне сайта, рубрики, запроса или блока. + +Например, форма обратной связи может зарегистрировать тег: + +```text +[tag:module:contacts:feedback] +``` + +Шаблон отвечает только за место компонента. Валидация, отправка и хранение +данных принадлежат модулю. + +### Зависимости и жизненный цикл + +```text +Файлы доступны + -> установка миграций и прав + -> модуль включен + -> маршруты, меню, hooks и теги работают +``` + +- **Отключение** прекращает загрузку runtime-вкладов, но сохраняет данные. +- **Деинсталляция** выполняет объявленный модулем сценарий и убирает права. +- **Физическое удаление** возможно только для отделяемого пакета после + деинсталляции. +- **Зависимости** не позволяют выключить модуль, пока от него зависит другой + включенный пакет. + +Ядро, рубрики и документы не должны напрямую подключать PHP-файлы модуля. +Взаимодействие строится через его публичные сервисы, теги, маршруты и hooks. + +Подробнее: [Модульная система](../modules/README.md). + +## Порядок сборки публичной страницы + +Фактический pipeline одного публичного запроса: + +1. `PublicKernel` запускает framework, сессию, авторизацию, модули, защиту и hooks. +2. Проверяются прямые публичные маршруты модулей. +3. URL разбирается на alias, пагинацию и дополнительные параметры. +4. По alias находится документ. +5. Если документа нет, проверяется история редиректов, затем загружается документ 404. +6. Вместе с документом загружаются рубрика, права и внешний шаблон сайта. +7. Проверяется полный кеш страницы. +8. Выполняется настроенный публичный код рубрики перед загрузкой документа. +9. Проверяются публикация документа и право текущей группы на чтение. +10. Основной или дополнительный шаблон рубрики заполняется полями документа. +11. Полученный HTML вставляется в `[tag:maincontent]` шаблона сайта. +12. Подставляются header, Open Graph и footer рубрики. +13. Обрабатываются поля запроса, блоки, системные блоки и тизеры. +14. Обрабатываются теги установленных модулей. +15. Выполняются сохраненные запросы. +16. Формируются навигации. +17. Подставляются SEO, данные документа, хлебные крошки и системные пути. +18. Выполняется разрешенный PHP из хранимых шаблонов. +19. Результат проходит через hooks ответа. +20. Для администратора добавляются быстрое редактирование и публичный debug. +21. Готовый HTML отправляется браузеру и при необходимости сохраняется в кеш. + +При попадании в полный кеш часть внутренних шагов не выполняется повторно, но +логическая структура результата остается той же. + +## Кеширование + +Публичный runtime использует несколько уровней: + +| Уровень | Что хранится | +| --- | --- | +| Snapshot документа | Нормализованные свойства документа и значения полей. | +| Шаблон документа | Результат заполнения шаблона рубрики. | +| Полная страница | Шаблон сайта вместе с содержимым и компонентами. | +| Запрос | Результат тяжелой выборки или листинга. | +| Каталог | Проекция для фильтрации, сортировки и карточек. | + +Ключи учитывают документ, рубрику, шаблон, группу пользователя, версии полей и +пагинацию. После сохранения документа, рубрики, поля, запроса, шаблона или +конфигурации каталога связанные записи должны инвалидироваться. + +Подробнее: [Кеш базы и контента](../database/cache.md). + +## Hooks публичного pipeline + +Hook — именованная точка вмешательства в работу системы. Модуль подписывает +обработчик на событие и получает структурированный контекст. Это позволяет +добавлять проектную логику без изменения ядра. + +### Основные публичные точки + +```text +frontend.request.received +frontend.route.resolved +content.document.loading +content.document.loaded +content.rubric.loading +content.rubric.loaded +content.query.loading +content.query.loaded +content.query.rendering +content.query.rendered +content.template.rendering +content.template.rendered +frontend.response.rendering +frontend.response.rendered +``` + +События `loading` позволяют подготовить или заменить источник до чтения. +События `loaded` получают уже загруженный объект и могут дополнить результат. +События `rendering` и `rendered` работают с содержимым до и после рендера. + +### Сохранение документов + +Для административного и API-сохранения предусмотрены отдельные стадии: + +```text +content.document.saving +content.document.saved +content.document.created +content.document.updated +content.document.published +content.document.unpublished +content.document.deleting +content.document.deleted +content.document.snapshot_built +``` + +Точные названия доступных событий и их контексты показываются в каталоге hooks. +Обработчик должен проверять рубрику, ID или состояние документа и быстро +завершаться, если событие ему не подходит. + +Примеры применения: + +- опубликовать новую статью в Telegram или Дзен; +- передать заказ во внешнюю CRM; +- проверить сертификат товара перед публикацией; +- дополнить документ данными внешнего API; +- поставить задачу на перестроение поискового индекса; +- очистить CDN или сторонний кеш после изменения страницы. + +### Hooks полей + +Тип поля сам отвечает за нормализацию и валидацию значения, но модуль может +подписаться на общий цикл поля: + +```text +content.field.normalizing +content.field.normalized +content.field.validating +content.field.validated +content.field.saving +content.field.saved +content.field.rendering +content.field.rendered +``` + +Обработчик обязательно ограничивается alias, ID или типом поля. Иначе он будет +запускаться для каждого поля каждого документа. + +### Каталог и hooks + +Индекс каталога обновляется вслед за сохранением документа и его snapshot, +поэтому интеграции могут использовать фактические события +`content.document.saved` и `content.document.snapshot_built`. Инвалидация +проходит через `cache.invalidating` и `cache.invalidated`. + +Устанавливаемый каталоговый или товарный модуль может объявить дополнительные +события через `hook_definitions`. Их имена и контекст становятся частью +контракта именно этого модуля и появляются в общем каталоге hooks. Использовать +необъявленное имя как системный контракт нельзя. + +### Изменение и отмена результата + +`LifecycleEvent` может вернуть измененный результат. Отмена поддерживается +только на стадиях, где это явно заявлено контрактом. При сохранении документа +для контролируемого отказа используется `DocumentSaveEvent::fail()`. Ошибка +должна быть понятной и не оставлять частично сохраненные данные. + +Для побочных действий после сохранения предпочтительна стадия `saved`. Для +валидации и запрета операции используется стадия до записи. Внешний HTTP-вызов +не следует выполнять внутри длинной транзакции, если его можно отложить до +успешного сохранения. + +Полный каталог, приоритеты, форматы контекста и отладка: +[Хуки и события](../hooks/README.md). + +## Практический порядок создания сайта + +1. Создать внешний шаблон сайта с `[tag:maincontent]`. +2. Создать рубрики для реальных типов контента. +3. Добавить и расположить поля каждой рубрики. +4. Собрать основной шаблон рубрики и необходимые альтернативы. +5. Создать документы и заполнить значения полей. +6. Настроить alias и проверить историю редиректов. +7. Создать навигации и привязать документы или URL. +8. Создать запросы для лент и связанных материалов. +9. Вынести повторяемые части в блоки. +10. При необходимости создать каталог, его разделы, поля и фильтры. +11. Установить только нужные проекту модули. +12. Проверить права, SEO, 404, пагинацию, кеш и мобильный вывод. + +## Типовые композиции + +### Новостной сайт + +```text +Рубрики: Новости, Статьи, Страницы +Запросы: Последние, Популярные, По теме +Каталог: необязателен; может использоваться как рубрикатор публикаций +Модули: RSS, формы, авторизация — по необходимости +``` + +### База знаний + +```text +Рубрики: Инструкции, FAQ, Справочные страницы +Каталог: разделы знаний и фильтры по продукту/версии +Запросы: Обновленные, Связанные, Популярные +Навигация: основное меню и дерево документации +``` + +### Интернет-магазин + +```text +Рубрики: Товары, Страницы, Новости +Каталог: товарные разделы, характеристики и фильтры +Модуль товаров: цены, наличие, варианты и карточки +Commerce: корзина, заказы, избранное и история просмотров +Gateways: оплаты, авторизация и доставки отдельными пакетами +``` + +## Частые ошибки + +- Размещать всю разметку документа во внешнем шаблоне сайта. +- Использовать меню для формирования URL документа. +- Копировать одну карточку в несколько системных блоков. +- Использовать длинную цепочку `И/ИЛИ` без групп условий. +- Делать каталог зависимым от товаров и корзины. +- Читать JSON всех полей при каждом публичном фильтре вместо индекса каталога. +- Помещать большую бизнес-логику в хранимый шаблон вместо сервиса или модуля. +- Забывать инвалидировать кеш после изменения шаблона, поля или каталога. diff --git a/help/content/blocks.md b/help/content/blocks.md new file mode 100644 index 0000000..3458be9 --- /dev/null +++ b/help/content/blocks.md @@ -0,0 +1,98 @@ +# Блоки + +← [К разделу «Как собирается сайт»](README.md) + +Блок — переиспользуемый фрагмент содержимого или проектной логики. Один блок +можно вставить в шаблон сайта, рубрику, запрос или другой блок. + +## Создание блока + +Для блока задаются название, уникальный alias, группа, описание, активность и +режим редактора. Группа нужна только для организации списка и не влияет на +публичный вызов. + +| Режим | Когда использовать | +| --- | --- | +| Обычный текст | Строки без HTML-редактирования. | +| Rich text | Контент, который меняет редактор без работы с разметкой. | +| HTML | Управляемая разметка, теги полей, запросов и других блоков. | +| PHP | Небольшая проектная логика, которой действительно нужен серверный код. | + +Переключение режима меняет редактор и способ обработки. PHP-код проходит +проверку синтаксиса перед сохранением. Для большой или повторно используемой +логики создавайте сервис и hook модуля. + +Обычное право `manage_blocks` управляет HTML, rich text и plain text блоками. +Исполняемый режим дополнительно требует `manage_php_blocks`: без него PHP-блок +нельзя открыть, изменить, скопировать или восстановить из ревизии. Изменения +фиксируются в общем журнале аудита без записи исходного кода в событие. + +## Вставка в шаблон + +Основной публичный тег использует alias: + +```text +[tag:sysblock:footer] +``` + +Старый `[tag:block:...]` поддерживается для совместимости, но новый код следует +писать через `sysblock`. + +Блок может содержать: + +```text +[tag:request:latest_news] +[tag:sysblock:contacts] +[tag:fld:cover] +[tag:breadcrumb] +``` + +Поля документа доступны только когда блок рендерится в контексте документа. + +## Параметры + +Параметры передаются JSON-объектом в теге: + +```text +[tag:sysblock:banner:{"theme":"dark","limit":4}] +``` + +Внутри блока значение читается так: + +```text +[sys:param:theme] +[sys:param:limit] +``` + +Не подставляйте параметр напрямую в SQL или исполняемый PHP без проверки типа и +допустимых значений. + +## Внешний и AJAX-вызов + +Флаг внешнего вызова разрешает получить блок отдельным публичным запросом. AJAX +может дополнительно ограничить такой вызов ожидаемым режимом. Не включайте эти +флаги для внутреннего блока без необходимости: внешний endpoint увеличивает +публичную поверхность системы. + +## Вложенность и рекурсия + +Блоки могут включать другие блоки, но не должны ссылаться сами на себя напрямую +или по цепочке. Runtime останавливает повторный вход и ограничивает максимальную +глубину, однако циклическая схема всё равно является ошибкой проекта. + +## Кеш и ревизии + +После сохранения, копирования, удаления или восстановления система очищает кеш +по ID и alias блока. Ручная очистка нужна после внешнего изменения БД или для +диагностики. + +Ревизии сохраняют содержимое и основные настройки. Перед восстановлением старой +версии создаётся снимок текущей. Удаление всех ревизий не удаляет сам блок. + +## Проверка блока + +1. Выполните проверку синтаксиса. +2. Сохраните и откройте страницу, где блок реально используется. +3. Проверьте гостя и авторизованного пользователя, если есть условия доступа. +4. Проверьте вложенные запросы и поля документа. +5. Для внешнего блока отдельно проверьте запрещённый и разрешённый вызов. diff --git a/help/content/catalog.md b/help/content/catalog.md new file mode 100644 index 0000000..6e545e7 --- /dev/null +++ b/help/content/catalog.md @@ -0,0 +1,460 @@ +# Каталог + +← [К разделу «Как собирается сайт»](README.md) + +Каталог — универсальная иерархия над документами и полями. Он подходит не только +товарам, но и новостям, базе знаний, услугам, специалистам и другим типам +контента. + +## Создание каталога + +При создании выбираются рубрика, название, alias поля и назначение. Система +создаёт или использует поле типа `catalog`, через которое документы связываются +с разделами дерева. + +- **Обычный каталог** доступен в core и не предполагает цены или корзину. +- **Товарный каталог** появляется только с установленным товарным модулем и + включает его проекцию, остатки, варианты и связанные инструменты. + +Если товарный модуль не установлен, интерфейс не должен предлагать товарные +сущности или пустой список «товарных каталогов». + +## Сохранённые фильтры товаров + +В списке товаров и в центре качества можно сохранить текущий набор фильтров. +Это удобно для постоянных очередей: «без фото», «не заполнена упаковка», +«выключенные товары» или «товары ТСР». + +Настройте фильтры, нажмите кнопку с закладкой и плюсом рядом с заголовком +блока, задайте название. Готовый набор появится в выпадающем списке и в +дальнейшем применится одним выбором. Повторное сохранение под тем же названием +обновляет набор, кнопка корзины удаляет его. + +Наборы принадлежат текущему сотруднику и не меняют каталог, документы или +публичный сайт. Список товаров и центр качества хранят свои представления +раздельно. + +## Разделы дерева + +Раздел хранит название, родителя, позицию, активность и связанные документы. +Порядок и уровень меняются перетаскиванием. Активность можно переключать прямо в +листинге. + +Связанный документ выбирается живым поиском. Используйте его, когда разделу +нужна собственная страница, SEO и содержимое. Выключенный раздел остаётся в +админке, но не должен участвовать в обычном публичном дереве. + +## Поля раздела + +Для каждого раздела можно включить поля документов, которые редактор должен +заполнять в этом контексте. Поля показываются по группам рубрики; выбранные +строки подсвечиваются. + +При создании нового раздела применяются поля по умолчанию из настроек каталога. +Наличие поля в рубрике и его включение в разделе — разные уровни: + +- рубрика определяет, что документ в принципе может хранить; +- раздел определяет, что важно для документов этой части каталога. + +Общее поле товарной рубрики не нужно включать во все разделы. Узкое свойство, +например «Тип насоса», назначайте только тем разделам, где редактор действительно +должен его заполнять. Мастер характеристик использует эти назначения и не +распространяет поле на остальные категории. Подготовка нового контура не удаляет +исходные поля и значения документов. + +Для первичной проверки откройте **Товары → Характеристики → Наведение порядка**. +Экран покажет поля, которые назначены слишком широко, не заполнены ни у одного +товара раздела или создают фильтр без полезного выбора. Это подсказки: система +ничего не снимает автоматически, пока администратор не проверит смысл поля. + +## Фильтры + +Фильтр строится по реальному полю рубрики или зарегистрированной характеристике. +Он имеет активность, порядок, способ сравнения и условия применения. + +Не создавайте дубли `weight_1`, `weight_2` только ради независимых фильтров. +Используйте единое семантическое поле и привязывайте его к нужным разделам и +фильтрам. Это упрощает импорт, API и перенос проекта. + +Условия фильтров должны учитывать тип данных. Число сравнивается как число, +список — по ключу значения, связь — по ID связанной сущности. После изменения +условий пересоберите индекс, если экран предлагает эту операцию. + +### Проверка перед Native + +В **Каталог → Характеристики → Режимы разделов** сначала назначьте набор и +оставьте оба режима в состоянии **Legacy**. Кнопка обновления строит теневой +индекс, а кнопка отчёта сравнивает старый и новый контуры без изменения сайта. + +Отчёт проверяет не только общее количество товаров. Для каждого значения он +сопоставляет старую подпись с постоянным ключом справочника, сравнивает число +товаров и, для числовых характеристик, минимальную и максимальную границы. +Строки с расхождениями показаны отдельно. Переключатель **Native** станет +доступен только после полной сверки выбранного раздела. Вернуть прежний вывод +можно в любой момент переключением этого раздела обратно в **Legacy**. + +## Значения в документе + +Поле `catalog` хранит выбранные пункты в нормализованном JSON-формате. При +открытии документа значения должны быть восстановлены в переключателях. Старые +строковые значения преобразуются слоем совместимости, но новый код должен +работать с JSON. + +## Публичный вывод + +Публичный каталог использует дерево разделов, запрос документов и активные +фильтры. Сама карточка содержимого настраивается отдельно от системных блоков, +потому что разные сайты могут иметь разные карточки и условия. + +Для новостного каталога не подключайте товарный индекс. Для товарного проекта +документ остаётся источником данных, а товарная таблица является быстрой +проекцией для цен, остатков, вариантов и фильтрации. + +Лимит изображений в настройках карточки относится только к компактным +листингам. Детальная страница получает полную галерею из медиаполя документа, +поэтому дополнительные фотографии не требуют повторного сохранения товара или +переиндексации каталога. + +### Подборки товаров + +В **Товары → Подборки** создаются управляемые товарные ленты для главной, +разделов и контентных блоков. Шаблон хранит только короткий тег, например: + +```text +[mod_product_list:new] +[mod_product_list:sale] +[mod_product_list:popular] +``` + +Название, подпись, ссылка «Показать все», количество карточек, раздел каталога, +диапазон цены и наличие меняются в настройках подборки. Править шаблон сайта +после этого не нужно. + +Есть два способа наполнения: + +- **Только выбранные вручную** — выводятся только назначенные товары; +- **Автоматический источник** — вручную назначенные товары идут первыми, а + свободные места заполняются новинками, акциями, популярными или всеми + подходящими товарами. + +Товар добавляется живым поиском по названию, артикулу или ID. Порядок меняется +перетаскиванием. Один товар нельзя добавить дважды. Выключенный, удалённый или +неподходящий фильтрам подборки товар на сайт не возвращается. + +Старый расширенный синтаксис оставлен для совместимости: + +```text +[mod_product_list:sale:8:125,42,310] +``` + +Он временно переопределяет количество и ручной приоритет прямо в теге. Для +новых страниц используйте короткий тег и экран подборок: так состав может +менять редактор без доступа к HTML. + +Акционным считается товар, у которого цена показывается, текущая цена больше +нуля, а старая цена действительно выше текущей. Старое служебное поле скидки +само по себе больше не делает товар акционным. + +Если старая цена не заполнена, обычная товарная карточка показывает расчётную +сравнительную цену на 25% выше текущей. Это только витринное представление: +расчётное значение не записывается в документ или индекс и не добавляет товар в +раздел акций. Акционный бейдж по-прежнему управляется отдельным признаком +скидки. Поведение включается и выключается в настройках товарного каталога: +**Каталог → нужный каталог → Карточка товара → Расчётная старая цена +25%**. + +Товарная проекция обновляется автоматически после общего события сохранения +документа. Поэтому одинаково работают редактор, JSON API, импорт документов и +CommerceML: интеграции не должны писать прямо в таблицу товарного индекса. +Окончательное удаление документа также удаляет его упаковки, платёжные признаки, +характеристики и связь с группой вариантов; оставшаяся группа автоматически +получает нового основного варианта. Перемещение в корзину эти данные не удаляет, +чтобы товар можно было восстановить. + +### Центр качества товаров + +Вкладка **Товары → Качество** собирает рабочие очереди по уже построенному +товарному индексу. Она помогает найти товары: + +- без цены, раздела, фотографии или артикула; +- без краткого анонса и SEO-описания; +- с неполной упаковкой при включённом расчёте доставки; +- с нулевым остатком; +- с устаревшей товарной проекцией. + +Нажмите нужную карточку очереди — таблица сразу отфильтруется по этой проблеме. +Поиск, состояние товара, число строк и пагинация работают без перезагрузки +страницы. Кнопка редактирования открывает исходный документ товара. + +Показатель **Готовность** учитывает только данные, без которых товар нельзя +нормально продавать или обрабатывать: цену, раздел, фотографию, артикул, +заполненную упаковку и актуальный индекс. Анонс и SEO показаны как рекомендации, +потому что на существующих проектах их роль может выполнять отдельное поле +рубрики. Нулевой остаток также является состоянием, а не ошибкой. + +Центр качества ничего не исправляет автоматически. После сохранения документа +его товарная проекция обновится обычным системным событием. Если менялась сама +схема полей или выполнялся внешний перенос, используйте точечное обновление +индекса либо общую перестройку на вкладке товаров. + +### Внешний вид фильтров + +В товарном модуле откройте **Каталог → Фильтры**, чтобы изменить Twig-разметку +и CSS фильтров. Черновик можно проверить на реальном разделе, не затрагивая +сайт. После публикации выберите представление в настройках нужного каталога. + +Режим **Текущий публичный вывод** сохраняет прежнюю разметку. Режим +**Предпросмотр** также ничего не меняет в паблике. Только режим +**Опубликованный Twig** включает новый шаблон, причем при ошибке система +возвращается к прежнему выводу. + +Количество товаров передается шаблону в `option.count`. Стандартный класс +счетчика — `.filter_checkbox-quantity`; его внешний вид можно менять в CSS +представления. Там же выбирается поведение вариантов с нулевым результатом: +показывать, отключать или скрывать. + +Технический контракт Twig описан в +`docs/development/catalog-filter-templates.md`. + +### Карточки в разных разделах сайта + +В **Каталог → Карточки** блок «Контексты использования» управляет карточками в +избранном, просмотренных товарах, результатах поиска и похожих материалах. +Сначала назначьте опубликованное представление и оставьте режим +**Подготовка без публикации**. После проверки переключите только нужный контекст +на **Центральная карточка**. Остальные места продолжат использовать прежнюю +разметку. + +В Twig доступно `context.code`: `catalog`, `favorites`, `viewed`, `search` или +`related`. Через него можно показывать разные команды без копирования всего +шаблона. Полный контракт описан в +`docs/development/catalog-card-templates.md`. + +### Цвета и варианты товара + +Модуль сразу добавляет две характеристики с областью **Вариант товара**: +`variant_color` («Цвет») и `variant_configuration` («Комплектация»). Они ничего +не добавляют существующим товарам автоматически. Затем откройте **Товары → +Группы вариантов**, соберите товары одной модели в группу и назначьте каждому +нужные значения. Цвет используйте только в группах тех разделов, где товары +реально различаются цветом; сама характеристика не становится обязательной для +всего каталога. + +При необходимости в **Каталог → Характеристики** можно создать другие оси, +например размер или материал. У типа +«Один вариант» или «Несколько вариантов» заранее добавьте общий список: подпись, +стабильный ключ и, при необходимости, HEX-цвет. Тогда во всех группах выбирается +одна и та же запись, а переименование не приходится повторять у каждого товара. + +Если список значений пуст, редактор оставляет ручной ввод подписи и цвета. Это +режим совместимости для существующих групп, а не рекомендуемый способ настройки +новых цветов и размеров. + +Тот же список используется у обычной характеристики товара. В карточке товара +выбирается подпись, но сохраняется постоянный ключ. Переименование подписи или +цвета не требует обходить все товары. Старые карточки, где была записана сама +подпись, продолжают открываться; известная подпись автоматически связывается со +справочником, а неизвестная помечается как прежнее значение. + +Ключ блокируется после первого сохранения. Это сделано намеренно: его могут +хранить товары, фильтры и внешние интеграции. Подпись и HEX-цвет при этом можно +исправлять в любое время. Удаление уже используемого значения также блокируется. + +Каждый вариант остаётся отдельным документом со своим URL, артикулом, ценой, +остатком и изображениями. В общем листинге группа показывается одной карточкой, +а на странице товара штатный переключатель использует нативные значения. Старые +группы с привязкой к полям рубрики продолжают работать; этот способ спрятан в +блоке совместимости. + +Внешний вид переключателя задаётся в **Товары → Группы вариантов**: + +- **Карточки** — прежний вид, где каждый вариант идёт одним пунктом; +- **По характеристикам** — отдельные ряды для цвета, размера, материала или комплектации. + +Там же можно включить показ цены, наличия и артикула. По умолчанию стоит режим **Карточки**, +поэтому обновление модуля не меняет вид сайта без действия администратора. + +Каждый пункт остаётся обычной ссылкой на отдельный товар. Поэтому URL, корзина, цена и артикул всегда +относятся к реально выбранному варианту. Если характеристики группы ещё не заполнены, движок покажет +прежние карточки вместо пустого блока. + +В матрице оси зависимы. Сначала выбирается цвет, после чего комплектации +проверяются именно для выбранного цвета. Если, например, существует синий товар +в стандартной комплектации, но нет синего товара с педалью, «С педалью» +останется видимой, но недоступной. Движок не подменяет её случайным товаром +другого цвета. + +Публичная модель `VariantRepository::forProduct()` отдаёт `items`, `attributes` и подготовленную `matrix`. +Шаблоны находятся в `modules/products/app/view/variants.twig` и +`modules/products/app/view/variant-matrix.twig`. Назначение характеристик само по себе не включает новый шаблон карточек каталога. + +### Как быстро заполнить варианты + +Откройте **Товары → Группы вариантов** и выберите группу. В таблице +**Матрица вариантов** каждая строка соответствует отдельному товару: + +1. Заполните цвет, комплектацию или другие отличия. +2. Проверьте артикул, цену, старую цену и наличие. +3. Нажмите состояние упаковки, чтобы добавить коробки, вес и габариты. +4. Для изменения названия, URL или всей галереи откройте товар кнопкой с + карандашом. + +Изменения в ячейках сохраняются автоматически. Отдельную кнопку сохранения всей +таблицы нажимать не нужно. Если значение не сохранилось, ячейка вернётся к +предыдущему состоянию и покажет причину. + +Для повторяющихся цветов и комплектаций сначала создайте общий список значений +в **Каталог → Характеристики**. Тогда в матрице будет понятный выбор, а не +ручной текст. Ручной режим оставлен для старых групп и постепенного перехода. + +Матрица не создаёт копию товара: обычный редактор, импорт и API работают с теми +же ценами, остатками и артикулами. Публичный вид также не меняется от появления +этой таблицы в Adminx. + +После осознанного переключения фильтров раздела в режим **Native** их варианты +также берутся из справочника. Пользовательский Twig-шаблон фильтра получает +`option.value`, `option.label`, `option.count`, `option.selected` и +`option.swatch`. Пустой `swatch` означает обычное текстовое значение. До +переключения публичный каталог и его старые фильтры не меняются. + +### Грузовые места + +Вес и размеры упаковки задаются в редакторе товара отдельным профилем доставки. +Можно добавить несколько коробок и количество одинаковых мест. Эти данные не +нужно дублировать характеристиками `weight`, `length`, `width`, `height`. + +Сервис `App\Content\ProductShipping::profile($productId)` возвращает `packages`, +сводку `summary` и признак `complete`. Для расчёта заказа используется +`packagesForQuantity($productId, $quantity)`: количество мест умножается на +количество товара. Публичный дизайн не выводит упаковку автоматически; сайт +показывает её только там, где шаблон или модуль явно использует этот сервис. + +### Связи и комплекты + +В **Товары → Связи и комплекты** можно связать два товара как совместимые, +обязательные, аксессуары, аналоги или сопутствующие покупки. Если связь работает +в обе стороны, включите соответствующий переключатель: вторую запись создавать +не нужно. + +Комплект объединяет минимум два товара и хранит количество каждой позиции. +Название, цена, остаток и изображения всегда читаются из самих товаров, поэтому +после изменения товара комплект не нужно пересохранять. + +Публичный сайт после создания связей не меняется. Вывод нужно явно вставить в +шаблон страницы товара: + +```text +[mod_product_relations] +[mod_product_relations:compatible] +[mod_product_relations:required] +[mod_product_relations:accessory] +[mod_product_relations:alternative] +[mod_product_relations:together] +[mod_product_bundles] +``` + +Тег без уточнения сначала выводит связи, которые администратор задал вручную. +Если у товара ещё нет ручных связей, модуль автоматически подбирает товары из +тех же разделов каталога. Поэтому блок «Похожие товары» можно сразу добавить в +общий шаблон товарной рубрики, а важные связи постепенно уточнять в панели. +Теги с конкретным типом выводят только ручные связи выбранного типа и не +подменяют их автоматической подборкой. + +Связанные товары используют те же центральные карточки, что каталог. Кнопка +комплекта добавляет каждую позицию в обычную корзину с текущей ценой. Если товар +выключен, удалён или у него не настроено поле цены, недоступный комплект не +выводится. + +Техническое описание и JSON API находятся в +`docs/development/product-relations.md`. + +### Сравнение товаров + +Откройте **Товары → Сравнение**, включите функцию и выберите: + +- адрес публичной страницы; +- общий шаблон сайта для этой страницы; +- сколько товаров можно сравнивать одновременно; +- нужно ли сначала скрывать одинаковые значения; +- сколько дней работает отправленная покупателю ссылка. + +Включение функции само по себе не добавляет кнопки в карточки. Это намеренно: +у разных сайтов разные дизайн и место команды. В Twig-шаблоне карточки доступны +`item.comparison.enabled`, `item.comparison.selected`, +`item.comparison.url` и `item.comparison.max`. + +Пример кнопки: + +```twig +{% if item.comparison.enabled %} + +{% endif %} +``` + +Движок сам добавляет и убирает товар по AJAX. Ссылку на страницу со счётчиком +можно разместить в общем шаблоне тегом `[mod_product_compare]`. + +В таблицу попадают артикул, цена, наличие и нативные характеристики товара. +Характеристики сопоставляются по системному коду: одинаковые поля разных +товаров окажутся в одной строке, даже если позже была исправлена подпись. +Выключенный или удалённый товар автоматически исчезает из сравнения. + +Кнопка **Скопировать ссылку** сохраняет набор товаров, но не копирует их цены и +описания. Поэтому открывший ссылку увидит актуальные данные. Срок ссылки задаёт +администратор. + +Разметку страницы можно переопределить в теме файлами +`views/products_public/comparison.twig` и +`views/products_public/comparison-mini.twig`. Полный API и контракт шаблона +описаны в `docs/development/product-comparison.md`. + +### Поступление, предзаказ и запрос цены + +Откройте **Товары → Обращения**. В одном разделе находятся: + +- очередь обращений покупателей; +- отправка уведомлений о поступлении; +- переключатели доступных сценариев; +- текст согласия; +- шаблон письма о поступлении; +- подключение к публичной странице и личному кабинету. + +Функция выключена по умолчанию. После включения она также ничего не вставляет в +карточку автоматически. Добавьте в шаблон товарной рубрики: + +```text +[mod_product_demand] +``` + +Форма сама определит текущий товар. Если покупатель вошёл, имя, email и телефон +будут взяты из профиля. Гость указывает email или телефон вручную. Повторное +обращение того же человека по тому же товару обновляет запись, поэтому очередь +не заполняется дублями. + +Для собственного Twig-шаблона карточки доступны: + +- `item.demand.restock` — товар отсутствует и можно подписаться; +- `item.demand.preorder` — разрешён предзаказ; +- `item.demand.price` — разрешён запрос цены; +- `item.demand.api_url` — адрес JSON API. + +При появлении положительного остатка подписки переходят в состояние +**Товар поступил**. Уведомления можно отправить одной кнопкой всей готовой +очереди или отдельно выбранному покупателю. Записи без email остаются в очереди +для ручного звонка. + +Вошедший покупатель видит страницу `/personal/requests` и может отменить +незавершённое обращение. Полный API и описание состояний находятся в +`docs/development/product-demands.md`. + +## Безопасное изменение + +1. Сначала меняйте структуру и поля в тестовом разделе. +2. Проверьте сохранение документа и повторное открытие редактора. +3. Пересоберите индекс и сравните товары, отдельные значения и диапазоны. +4. Проверьте выбор, сброс и повторный выбор каждого фильтра. +5. Проверьте страницу без результатов и пагинацию. +6. Только после этого переносите настройки на остальные разделы. diff --git a/help/content/content-studio.md b/help/content/content-studio.md new file mode 100644 index 0000000..02f464c --- /dev/null +++ b/help/content/content-studio.md @@ -0,0 +1,652 @@ +# Content Studio: от рубрики до публикации + +← [К разделу «Как собирается сайт»](README.md) + +Content Studio — это общий рабочий процесс AVE.cms для проектирования контента, +заполнения документов и подготовки публичных выборок. Это не отдельный модуль и +не новая база данных. Он объединяет уже знакомые сущности: рубрики, поля, +документы, запросы, шаблоны и модули. + +Эта глава рассчитана на человека, который впервые открыл панель и пока не знает, +с чего начать. Сначала прочитайте общую модель, затем выполните учебный сценарий +и только после этого переходите к расширенным настройкам. + +## 1. Простая модель + +Представьте обычный новостной сайт: + +1. **Рубрика** «Новости» определяет, какие данные есть у каждой новости. +2. **Поля** задают изображение, анонс, основной текст и источник. +3. **Документ** хранит конкретную новость и значения этих полей. +4. **Шаблон рубрики** превращает данные одной новости в HTML страницы. +5. **Запрос** получает несколько новостей для главной или архива. +6. **Шаблон запроса** превращает каждую найденную новость в карточку. +7. **Шаблон сайта** добавляет общую шапку, навигацию и подвал. + +Для статей, справочника сотрудников, каталога услуг или товаров принцип тот же. +Отличаются только поля, правила и публичная разметка. + +```text +Рубрика + -> определяет форму и правила + -> документ хранит данные + -> шаблон рубрики показывает одну страницу + -> запрос получает список страниц + -> шаблон запроса показывает карточки +``` + +## 2. Что где настраивать + +| Задача | Раздел | +| --- | --- | +| Добавить новый тип материалов | Рубрики и поля | +| Изменить состав или порядок полей | Конструктор формы рубрики | +| Создать новость, страницу или товар | Документы | +| Настроить URL всех материалов типа | Рубрика | +| Изменить HTML одной страницы | Шаблон рубрики | +| Получить список материалов | Запросы | +| Изменить карточку в списке | Шаблон запроса или renderer модуля | +| Добавить дерево разделов и фильтры | Каталог | +| Подключить внешнее поведение | Модуль и hooks | + +Не добавляйте HTML всей страницы в поле документа. Поле хранит содержимое, а +шаблон отвечает за разметку. Тогда дизайн можно заменить сразу для всех +документов, не переписывая каждую запись. + +В списке рубрик колонка **Материалы** связывает структуру с реальной работой: + +- нажмите количество документов, чтобы открыть список уже созданных материалов + только этой рубрики; +- нажмите кнопку с листом и плюсом, чтобы сразу создать материал выбранного + типа; +- в колонке **Шаблон** показывается его название, а внутренний номер оставлен + мелкой служебной подписью. + +Так не нужно запоминать ID рубрики или повторно выбирать её в разделе +документов. Кнопки учитывают права пользователя: создание не показывается тому, +кому разрешён только просмотр. + +## 3. Первый тип контента + +Допустим, нужно создать раздел статей. + +1. Откройте **Контент → Рубрики и поля**. +2. Создайте рубрику «Статьи». +3. Добавьте поля `lead`, `cover` и `content`. +4. Для `lead` выберите многострочный простой текст. +5. Для `cover` выберите одиночное изображение. +6. Для `content` выберите текстовый тип с rich-редактором. +7. Разместите поля в конструкторе формы. +8. Задайте ширину: изображение может занимать 4 колонки, а анонс — 8. +9. Настройте шаблон URL, например `articles/%Y/%m`. +10. Сохраните рубрику и проверьте её ревизию схемы. + +Системное название документа уже существует. Не создавайте второе поле `title`, +если вам не нужен отдельный заголовок с другим смыслом. + +### Переносимый набор полей + +Если похожая структура нужна в другом проекте или рубрике, не создавайте каждое +поле повторно. Откройте **Поля рубрики** и используйте команды **Экспорт** и +**Импорт** под списком готовых наборов. + +Экспорт создаёт небольшой JSON-файл. В него входят: + +- группы и их порядок; +- названия, системные alias и типы полей; +- ширина, размещение, префикс и суффикс; +- значение по умолчанию, поиск и числовой индекс; +- настройки и правила валидации конкретного типа поля. + +Если старое поле ещё не имеет системного alias, экспорт не меняет его в базе, а +создаёт для переносимого файла имя вида `field_`. Предпросмотр отдельно +показывает количество таких полей. В новой рубрике при необходимости замените +технические alias на понятные до начала заполнения документов. + +В файл намеренно не входят документы и их значения, PHP-код рубрики, публичные +шаблоны, права, URL и физические медиафайлы. Условия формы также не переносятся: +они ссылаются на внутренние ID полей исходной рубрики и должны быть собраны уже +после импорта по полям нового проекта. + +Перед применением выберите один из двух режимов: + +- **Копия** создаёт независимую схему. Последующие изменения исходного набора + на неё не влияют. Это лучший вариант для разовой заготовки или переноса между + проектами. +- **Связанный** запоминает версию набора и соответствие его alias реальным + полям рубрики. Такой вариант удобен, когда один стандарт полей используется + в нескольких рубриках. + +Обычное применение или импорт выполняются так: + +1. Выберите JSON или перетащите его в окно. +2. AVE.cms проверит формат, версию и доступность типов полей. +3. Предпросмотр покажет общее число полей, сколько будет создано, сколько уже + существует и есть ли конфликты. +4. Поле с тем же alias и тем же типом повторно не создаётся и не изменяется. +5. Одинаковый alias с другим типом считается конфликтом и блокирует импорт. +6. После подтверждения создаются только отсутствующие группы и поля, а схема + рубрики получает новую ревизию. + +У связанного набора в конструкторе показывается отдельная строка состояния. +Обновление не применяется автоматически: сначала нажмите кнопку проверки и +прочитайте сводку новых, изменяемых и снятых с формы полей. + +Чтобы выпустить новую версию импортированного набора, снова загрузите JSON с +тем же кодом и выберите режим **Связанный**. Текущая рубрика сразу проходит +безопасную синхронизацию, а в остальных связанных рубриках появляется состояние +**Есть обновление**. Повторный импорт не помечает изменения применёнными, пока +поля текущей рубрики действительно не обновлены. + +При синхронизации используется трёхстороннее сравнение: AVE.cms знает старую +версию набора, новую версию и текущее поле рубрики. Неизменённая настройка +получает новое значение набора, а локально исправленная подпись, ширина или +настройка сохраняется. Если набор удалил поле, значение документов не +удаляется: поле переходит в список доступных и перестаёт быть частью связи. +Изменение типа заполненного поля блокируется и требует ручного решения. + +Команда удаления связи также не удаляет поля и данные. Она лишь превращает их +в полностью независимые поля текущей рубрики. + +Между проверкой и подтверждением используется защитный fingerprint. Если другой +администратор успел поменять состав полей, импорт остановится и попросит снова +открыть предпросмотр. Существующие значения документов импорт никогда не +перезаписывает. + +JSON имеет версионированный формат `ave.cms/field-set`, поэтому обычный JSON из +другой программы нельзя случайно принять за схему рубрики. Максимальный размер +файла — 256 КБ, один набор может содержать до 50 групп и 500 полей. + +### Условия формы + +Условие формы управляет тем, когда конкретное поле или целая группа видны +редактору документа и можно ли их изменять. +Например, поле «Дата мероприятия» можно показывать только тогда, когда в поле +«Тип материала» выбрано значение «Событие». + +#### Если совсем просто + +Рубрика — это заготовка формы, а её поля — вопросы, на которые отвечает +редактор. Раньше форма всегда показывала все вопросы подряд. Теперь она может +реагировать на уже выбранные ответы: + +- для новости не показывать характеристики товара; +- для события открыть дату, место и форму регистрации; +- после выбора «Самовывоз» оставить только подходящие пункты выдачи; +- при переходе в статус «Готово» заполнить короткое служебное поле; +- показать важное поле и запретить его случайное изменение. + +Это сделано не ради эффектов в интерфейсе. Короткая и уместная форма быстрее +заполняется, а редактор реже сохраняет несовместимые данные. При этом AVE.cms не +переписывает старые документы только из-за открытия формы. Скрытое или +заблокированное значение остаётся в базе. Автоматически изменённое значение +получает заметную подпись и кнопку отмены, поэтому редактор видит действие и +может вернуть своё значение. + +Та же проверка выполняется на сервере. Нельзя обойти ограничение изменённой +формой или прямым запросом к JSON API. Публичная страница от этих правил не +меняется: условия управляют только заполнением документа в панели. + +Условие настраивается в правой колонке конструктора: + +1. Выберите поле на холсте. +2. Включите блок **Условия формы**. +3. Выберите действие при совпадении: показать, скрыть или запретить изменение + текущего поля. +4. При необходимости включите **Обязательно при показе**. +5. Для поля со списком можно включить **Ограничить варианты** и отметить значения, + которые останутся доступными при совпадении условия. Сначала сохраните сам + список в настройках типа поля, чтобы конструктор показал актуальные ключи. +6. Для простого поля можно выбрать **Изменить значение при совпадении**: + установить указанное значение или очистить поле. Для списка и переключателя + значение выбирается из готовых вариантов, для текста и числа вводится вручную. +7. В основной группе добавьте поле-источник, сравнение и ожидаемое значение. +8. Выберите **Все · И**, если должны совпасть все правила, или **Любое · ИЛИ**, + если достаточно одного. +9. Кнопка со скобками добавляет вложенную группу. Она вычисляется отдельно и + работает как скобки в обычном выражении. + +Для условия всей группы откройте редактирование группы кнопкой с карандашом и +включите блок **Условие для всей группы**. Дерево правил и действия те же, но +результат применяется сразу ко всем полям секции. Это удобно, например, когда +весь блок «Данные мероприятия» нужен только для материала типа «Событие». +Настройку не нужно повторять у каждого поля. + +Пример: + +```text +Тип материала = Событие +И +( + Формат = Онлайн + ИЛИ + Формат = Смешанный +) +``` + +Корневая группа использует **И**, вложенная — **ИЛИ**. Вложенность ограничена +четырьмя уровнями, а одно поле может содержать не более 32 правил. Эти лимиты +защищают форму от циклической и непонятной логики. + +Поле не может зависеть от самого себя. Если выбранное поле-источник удалено или +принадлежит другой рубрике, сервер остановит сохранение и покажет ошибку. То, +что правило выглядит корректно в браузере, не отменяет серверную проверку. + +Условия начинают влиять на редактор документов только после включения общего +переключателя **Условия формы документа** для рубрики. Перед применением AVE.cms +показывает, сколько существующих документов изменят видимость, доступность или +обязательность полей. Для условия группы предпросмотр считает каждое поле секции, +показывает документы, скрытые и заблокированные значения и только затем просит +подтверждение. Действие **Запретить изменение** оставляет +поле или группу видимыми и помечает их как доступные только для чтения. Сервер сохраняет прежнее значение, +даже если кто-то попробует подменить отправляемую форму или JSON API. Значения +скрытых полей и групп также не удаляются, а публичный шаблон и вывод документа не +меняются. + +Ограничение вариантов также повторяется на сервере. Например, поле «Способ +доставки» может показывать все варианты для обычного заказа, но при выборе +«Самовывоз» оставить только конкретные пункты выдачи. Недоступные пункты нельзя +подставить вручную через форму или API. Если документ уже содержит старое +значение, редактор пометит его и попросит выбрать допустимый вариант вместо +молчаливого удаления данных. + +Автоматическое значение срабатывает только в момент, когда условие стало +выполненным. Например, было «Статус = Черновик», редактор выбрал «Опубликован» — +поле «Готовность» получит заданное значение. Если документ уже открывается со +статусом «Опубликован», простое открытие или повторное сохранение ничего не +перезапишет. У изменённого поля появляется отметка **Установлено условием** или +**Очищено условием** и кнопка отмены. После ручной правки отметка исчезает. +Отмена и ручная правка передают серверу явный выбор редактора, поэтому значение +не будет снова заменено тем же условием во время сохранения. + +Автодействия доступны только для простых безопасных значений: текста в одну +строку, числа, даты, одиночного списка, одиночного выбора, переключателя и цвета. +Они намеренно недоступны для rich/code-контента, медиа, связей, каталога и +составных полей: такие данные нельзя надёжно заменить одним коротким значением. +Adminx и JSON API повторяют правило на сервере, поэтому отправка изменённой формы +вручную не обходит условие. Предпросмотр схемы показывает, в скольких формах +действие потенциально активно, но не меняет сохранённые документы. + +Предпросмотр связан с текущей схемой и документами защитным fingerprint. Если +между проверкой и подтверждением другой редактор изменил группу, поле или +документ, AVE.cms отменит сохранение и пересчитает влияние заново. + +### Ревизии схемы рубрики + +После изменения полей AVE.cms сохраняет снимок формы. Это история не текстов +документов, а самой структуры: какие поля и группы были, где они стояли, какую +ширину занимали, были ли обязательными и какие условия использовали. + +Перед восстановлением старого снимка панель сначала показывает сравнение: + +- какие поля вернутся; +- какие поля изменятся и что именно станет другим; +- какие более новые поля останутся на месте; +- включатся или выключатся условия формы; +- какие запросы, шаблоны, API-контракты и модули зависят от затронутых полей. + +Для изменённого поля значения показаны в виде **Было → Будет**. Например: +`Ширина поля: 12 колонок → 6 колонок` или +`Обязательное: Нет → Да`. Внутренний JSON для этого читать не нужно. + +Восстановление схемы не стирает значения документов. Поле, созданное после +снимка, не удаляется, а остаётся доступным в библиотеке конструктора. Если +схема изменилась после открытия предпросмотра, защита остановит операцию и +попросит проверить сравнение ещё раз. + +## 4. Создание документа и пресеты + +Перед открытием редактора AVE.cms предлагает выбрать **тип контента**. В +существующей базе типом остаётся рубрика: «Новости», «Статьи», «Услуги» и другие. +Система больше не предполагает, что новый документ обязательно является товаром. + +После выбора рубрики доступны два варианта: + +- **Пустой документ** — форма с обычными значениями по умолчанию из полей рубрики; +- **Пресет заполнения** — та же форма, заранее заполненная сохранённой заготовкой. + +Пресет удобен для повторяющихся материалов. Например, еженедельная новость может +сразу получить типовой анонс, источник, SEO-настройки и структуру основного текста. +Это не копия документа: у нового материала всегда будут собственные название, +URL, автор, даты и идентификаторы. + +Чтобы создать пресет: + +1. Откройте уже сохранённый документ нужной рубрики. +2. В расширенном режиме выберите **Инструменты → Пресет создания**. +3. Укажите понятное название и назначение заготовки. +4. Выберите, нужно ли переносить анонс, SEO, поиск и значения полей. +5. Включайте медиа, связи и каталог только осознанно: новый документ будет + ссылаться на те же файлы и связанные сущности. +6. Сохраните пресет. При следующем создании документа он появится у этой рубрики. + +Применение пресета только заполняет форму. База данных не меняется, пока редактор +не проверит значения и не нажмёт **Сохранить**. Пресет можно удалить прямо в +панели создания; уже созданные по нему документы при этом не изменятся. + +## 5. Редактор документа + +Редактор разделён на смысловые области: + +- **Основное** — название, рубрика, URL, хлебная крошка и анонс; +- **Поля документа** — содержимое, которое определила рубрика; +- **Публикация** — статус, автор, родитель, шаблон и позиция; +- **SEO и поиск** — keywords, description, robots, sitemap и теги; +- **Даты** — начало и окончание публикации; +- **Интеграция** — GUID и проектное свойство; +- **Инструменты** — заметки, редиректы, ревизии и JSON-снимок. + +Название, URL и поля рубрики являются данными одного документа. Блоки интерфейса +только помогают ориентироваться и не создают дополнительные сущности в БД. + +## 6. Три режима редактора + +В правой части заголовка находятся три режима. Выбор сохраняется в браузере +отдельно для пользователя и рубрики. + +### Быстро + +Показывает основное и поля документа. Подходит, когда редактор регулярно меняет +текст, изображение или характеристики и не должен отвлекаться на технические +параметры. + +Скрытые настройки не очищаются. Они остаются в документе и снова появятся при +переключении режима. + +### Обычно + +Дополнительно показывает публикацию: статус, автора, родителя и шаблон. Это +режим для повседневной работы контент-менеджера. + +### Расширенно + +Показывает все области, включая SEO, даты, интеграцию, сводку и инструменты. +Используйте его при первоначальной настройке, диагностике и сложном редактировании. + +Если ошибка находится в скрытом блоке, переход из сводки ошибок автоматически +включит необходимый режим. + +## 7. Сохранение + +Есть два обычных сценария: + +- **Сохранить** записывает документ и возвращает к списку; +- **Сохранить и остаться** записывает документ и оставляет редактор открытым. + +`Ctrl+S` и `Cmd+S` выполняют «Сохранить и остаться». Перед отправкой система +синхронизирует rich editor, CodeMirror, медиа-списки и составные поля. + +Сохранение выполняется транзакционно. Если код рубрики, валидация поля или БД +вернули ошибку, незавершённые изменения не должны оставаться частично записанными. + +## 8. Локальный черновик + +Пока форма изменена, AVE.cms сохраняет её состояние в хранилище текущего +браузера. Это не публикация и не скрытая запись в базу. + +Локальный черновик помогает, если: + +- вкладка была случайно закрыта; +- браузер перезагрузил страницу; +- сеть исчезла во время работы; +- сервер отклонил сохранение из-за более новой версии документа. + +При повторном открытии над формой появляется аккуратная панель. Она показывает +время черновика и предлагает два действия: + +- **Восстановить** — вернуть значения в форму для проверки; +- **Удалить** — оставить серверную версию и стереть локальную копию. + +После восстановления обязательно просмотрите документ и нажмите сохранение. +Локальный черновик сам по себе не меняет БД и не виден другим сотрудникам. + +После успешного сохранения черновик удаляется автоматически. Содержимое файловых +input не хранится из соображений безопасности браузера, но уже выбранные пути, +описания и значения медиа-полей сохраняются. + +## 9. Две вкладки и два редактора + +Каждый документ имеет внутренний номер версии. Он повышается при изменении через: + +- основной редактор документа или товара; +- JSON API; +- импорт документов; +- CommerceML; +- контент-пакет или демо-пакет; +- операции с документами; +- быстрое редактирование товара; +- служебные операции каталога и SEO. + +Пример конфликта: + +1. Анна и Борис открыли документ версии 7. +2. Анна сохранила изменения. Сервер создал версию 8. +3. Борис попытался сохранить старую форму версии 7. +4. Сервер вернул конфликт и не перезаписал работу Анны. +5. Браузер Бориса сохранил его форму локально. +6. После обновления Борис видит версию Анны и может аккуратно восстановить свой + черновик, сравнить данные и сохранить объединённый результат. + +Защита проверяется повторно внутри транзакции с блокировкой строки. Поэтому +одновременные запросы не проходят между предварительной проверкой и записью. + +## 10. Сводка ошибок + +Если сохранение невозможно, над формой появляется общий список проблем. Для +каждой ошибки показываются понятное название поля и сообщение сервера или +браузера. + +Нажмите строку ошибки, чтобы: + +1. открыть расширенный режим, если блок был скрыт; +2. переключиться на нужную группу полей; +3. прокрутить форму к проблемному элементу; +4. поставить курсор в доступный редактор. + +Красная сводка не означает, что данные потеряны. Исправьте значения и повторите +сохранение. Если ошибка относится к коду рубрики или БД, передайте администратору +текст сообщения и время возникновения. + +## 11. Полные ревизии документа + +Новая ревизия хранит два слоя: + +1. основные настройки документа; +2. все значения полей рубрики, включая медиа и связи в их штатном формате. + +В основные настройки входят название, URL, анонс, SEO, теги, публикация, даты, +автор, родитель, шаблон, навигация, позиция, GUID и проектное свойство. + +Ревизия намеренно не хранит: + +- ID и рубрику документа; +- счётчики просмотров и печати; +- физические файлы; +- строки внешних модульных таблиц; +- пароли, токены и системные секреты. + +Перед восстановлением текущая версия автоматически сохраняется отдельным +снимком. По умолчанию отмечены все основные настройки и поля, то есть кнопка +работает как прежнее полное восстановление. Снимите флажки с того, что менять не +нужно, или отключите сразу всю группу **Основные настройки** либо **Поля +рубрики**. Тогда AVE.cms восстановит только отмеченные элементы. ID документа и +его рубрика всегда остаются прежними. + +Старые ревизии не удаляются и отображаются с пометкой **Только поля**. Их можно +просматривать и восстанавливать: в таком случае основные настройки останутся +текущими. Новые записи помечаются как **Полный снимок**. Если поле после создания +ревизии было удалено из рубрики, оно показывается в истории, но не создаётся +заново при восстановлении. + +Кнопка **На сайте** открывает защищённый preview. Он не попадает в поисковую +индексацию и публичный кеш. Для полного снимка preview использует исторические +параметры документа и значения полей только в рамках текущего запроса. + +## 12. JSON без сохранения + +Кнопка **Данные JSON** показывает нормализованные данные текущей формы, не +записывая их в БД. Это полезно для проверки API, интеграций и сложных полей. + +Preview: + +- синхронизирует редакторы и динамические списки; +- выполняет штатную валидацию; +- показывает системные данные и поля; +- не запускает код рубрики до и после сохранения; +- не создаёт ревизию; +- не меняет кеш и публичный snapshot. + +## 13. Документы и товары + +Товар в AVE.cms остаётся документом товарной рубрики. Поэтому полный товарный +редактор получает те же режимы, черновики, ошибки, версии и ревизии. + +Товарный модуль добавляет только свою предметную область: артикул, цены, +характеристики, варианты, грузовые места, платёжные программы и карточки. +Публичный каталог от внедрения Content Studio автоматически не переключается и +не меняет HTML. + +Быстрый редактор товара повышает версию документа и сохраняет ревизию перед +изменением. Модульные значения из отдельных таблиц могут иметь собственную +историю; они не должны притворяться полями документа. + +## 14. Импорты, API и модули + +Правильный модуль создаёт и изменяет документы через +`App\Content\Documents\DocumentMutationService`. Этот сервис выполняет +валидацию, hooks, код рубрики, запись полей, термины, ревизию, snapshot и +инвалидацию кеша. + +Через общий сервис уже работают: + +- JSON API документов; +- импорт документов; +- CommerceML; +- контент-пакеты и демо-пакеты. + +Если модуль обходит сервис и напрямую обновляет таблицу документов, он обязан +как минимум повысить `document_version` и корректно инвалидировать производные +данные. Для нового кода прямой SQL не является рекомендуемым контрактом. + +Проектный импорт цен не относится к универсальному Content Studio и обслуживается +отдельно. Его нельзя использовать как образец нового импортера. + +### Представления и контракт результата + +Запрос остаётся совместимым представлением AVE.cms. Дерево условий определяет, +какие документы попадут в результат, а контракт перечисляет, какие свойства +этих документов разрешено вернуть в структурированном виде. + +В редакторе запроса можно выбрать системные данные и поля рубрики, затем +запустить AJAX-предпросмотр. Он не исполняет HTML/PHP шаблонов элементов и +сравнивает старую выборку с параметризованным Native-планом. + +Существующие `main/item` шаблоны остаются Legacy renderer. Для подтверждённого +запроса можно отдельно переключить только executor выборки; разметка публичного +сайта от этого не меняется. Изменение условий снимает подтверждение Native. + +### Связи между материалами + +Поля «Документ из рубрики», их множественные варианты, «Аналоги» и «Тизеры» +исторически сохранялись по-разному: одним ID, JSON-массивом или старой +serialize-строкой. Редактор продолжает читать все эти варианты и не требует +массового пересохранения документов. + +В JSON-снимке одна и та же связь теперь всегда описана одинаково: + +```json +{ + "format": "ave.document-relation", + "version": 1, + "multiple": true, + "items": [ + {"document_id": 42, "position": 0} + ] +} +``` + +Система также ведёт служебный индекс исходящих и входящих связей. Благодаря ему +модуль может быстро узнать не только «что выбрано в этом поле», но и «какие +материалы ссылаются на документ». Индекс является производным: источником +истины остаются значения полей документа. + +Реестр блоков подготавливает структурный контент для статей и страниц: текст, +форматированный текст, изображение, цитату и связанные материалы. Модуль может +добавить собственный тип блока декларативно. Само включение блокового редактора +в рубрику будет отдельным явным действием, поэтому существующие rich text поля +и их HTML не преобразуются автоматически. + +## 15. Что не изменилось в паблике + +Новые возможности относятся к управлению и сохранению. Они не меняют: + +- существующие URL документов; +- HTML шаблонов рубрик и запросов; +- теги полей; +- карточки каталога; +- алгоритмы фильтров; +- существующие значения БД; +- исходное `value` поля, которое используют существующие публичные шаблоны. + +Внутренний JSON-snapshot получил дополнительный объект `relation` и новую +версию схемы кеша. Старые snapshot автоматически перестраиваются при чтении; +это не меняет HTML сайта. + +Публичный результат меняется только после того, как редактор сам изменил данные +или шаблон и сохранил документ. + +## 16. Учебный сценарий + +1. Создайте тестовую рубрику с текстом и изображением. +2. Откройте новый документ этой рубрики. +3. В режиме **Быстро** заполните название и поля. +4. Переключитесь в **Обычно**, оставьте статус «Черновик». +5. Откройте **Расширенно**, заполните description и теги. +6. Нажмите **Данные JSON** и проверьте значения. +7. Сохраните и останьтесь. +8. Измените текст, подождите появления сообщения о локальном черновике и + обновите страницу. +9. Восстановите черновик и сохраните. +10. Измените документ ещё раз, затем откройте ревизии. +11. Просмотрите полный снимок и восстановите предыдущую версию. +12. Откройте документ на сайте и проверьте публичную страницу. + +## 17. Типичные ошибки + +### Черновик не появился + +Проверьте, разрешено ли локальное хранилище браузера и не открыт ли сайт в +режиме, который очищает данные после закрытия. Черновик создаётся только после +реального изменения формы. + +### После восстановления отсутствует выбранный файл + +Браузер запрещает программно восстанавливать локальный файл в ``. Выберите файл повторно. Уже загруженный путь в медиа-поле должен +восстановиться. + +### Система сообщает о более новой версии + +Не пытайтесь отправлять запрос повторно. Обновите страницу, восстановите +локальный черновик, сравните данные и сохраните объединённый вариант. + +### В ревизии нет основных настроек + +Это старая ревизия формата «Только поля». Она совместима, но была создана до +введения полного снимка. + +### Поле не видно + +Проверьте активную группу, условие видимости и режим редактора. Ошибка такого +поля автоматически откроет нужную область. + +## 18. Связанные руководства + +- [Документы](documents.md) +- [Рубрики и поля](rubrics.md) +- [Поля документов](../fields/README.md) +- [Запросы](requests.md) +- [Каталог](catalog.md) +- [Модульная система](../modules/README.md) +- [JSON API](../api/README.md) +- [Хуки документов](../hooks/documents.md) diff --git a/help/content/documents.md b/help/content/documents.md new file mode 100644 index 0000000..903497b --- /dev/null +++ b/help/content/documents.md @@ -0,0 +1,109 @@ +# Документы + +← [К разделу «Как собирается сайт»](README.md) + +Документ — конкретная страница, запись, статья или товар. Его структура задаётся +рубрикой, а внешний вид — шаблоном рубрики и шаблоном сайта. + +## Создание документа + +1. Откройте **Контент → Документы** и нажмите создание. +2. Сначала выберите рубрику. От неё зависят поля, группы, значения по умолчанию, + шаблон URL и права. +3. Заполните название, адрес, SEO и поля содержимого. +4. Проверьте JSON-предпросмотр данных без сохранения, если нужно увидеть полный + payload документа. +5. Сохраните черновик или опубликуйте документ. + +Рубрику существующего документа нельзя свободно поменять: у другой рубрики иной +набор полей и правила хранения. Такой перенос выполняется отдельной миграцией. + +## Состояния + +| Состояние | Что означает | +| --- | --- | +| Опубликован | Попадает в публичные листинги и доступен по прямому URL. | +| Выключен | Исключён из листингов, но доступен по известному прямому URL. | +| Вне срока | При включённой проверке дат ещё не опубликован либо уже истёк; гостю возвращается 404. | +| Удалён | Находится в корзине, гостю возвращается 404 и может быть восстановлен. | +| Окончательно удалён | Документ и его значения удаляются без восстановления через список. | + +Главная страница и назначенная страница 404 защищены от удаления. Для документа +№1 нельзя изменить корневой адрес `/`. + +Проверка дат управляется настройкой `use_doctime`. Когда она выключена, даты +начала и окончания остаются информационными. Когда включена, окно применяется и +к листингам, и к прямому URL. Авторизованный администратор может открыть удалённый +или находящийся вне срока документ на сайте, но увидит предупреждение; обычному +посетителю отдаётся штатная страница с HTTP 404. + +## Адрес и редиректы + +Полный URL собирается из шаблона рубрики и короткого alias документа. Например, +шаблон `news/%Y/%m` и alias `new-office` дают +`/news/2026/07/new-office`. + +- короткий alias должен быть уникален в своём поддерживаемом контракте; +- полный путь нельзя занять, если им уже владеет документ, редирект или публичный + маршрут модуля; +- при изменении адреса история alias может создать постоянный редирект; +- общая история доступна в **Документы → Редиректы**, история конкретной страницы + — из её редактора; +- 301 и 308 используются для постоянного переноса, 302 и 307 — для временного. + +Не создавайте новый документ на старом адресе, пока редирект нужен поисковым +системам и внешним ссылкам. + +## Поля и данные + +Редактор показывает только поля выбранной рубрики. Порядок, группы, ширина, +обязательность, значение по умолчанию, префикс и суффикс задаются в конструкторе +рубрики. Сам тип поля определяет редактор, валидацию, хранение и публичный вывод. + +Данные документа доступны: + +- в шаблоне рубрики через `[tag:fld:alias]`; +- в запросах и фильтрах по alias поля; +- через JSON API документа; +- в hooks до и после сохранения; +- в нативном snapshot, который объединяет документ и его поля. + +## SEO и анонс + +Название документа не заменяет все SEO-поля. Отдельно проверяйте title, +description, robots, canonical, Open Graph и заголовок хлебных крошек. + +Анонс — короткое самостоятельное представление документа для списков, карточек, +поиска и публикаций. Если проект строит анонс из полей или шаблона запроса, поле +можно оставить пустым; не копируйте туда полный основной текст. + +## Сохранение, ревизии и snapshot + +Обычное сохранение закрывает редактор, **Сохранить и остаться** оставляет его +открытым. `Ctrl+S` выполняет именно сохранение с продолжением работы. + +Новые ревизии сохраняют основные настройки документа и все значения полей. +Старые снимки формата «Только поля» остаются совместимыми. Перед восстановлением +создаётся резервная ревизия текущего состояния; ID и рубрика не меняются. +Snapshot публичных данных обновляется после сохранения и может быть пересобран +вручную из раздела документов. + +Форма также хранит локальный черновик в текущем браузере и защищена номером +версии. Если документ уже изменён в другой вкладке, старая форма не перезапишет +новые данные: её можно сохранить локально, обновить страницу и восстановить для +ручного объединения. Подробно: [Content Studio](content-studio.md). + +Кнопка **На сайте** у выбранной ревизии открывает её в реальном шаблоне рубрики. +Ссылка подписана текущей сессией на пять минут и работает только для сотрудника с +правом `manage_documents` и правом редактирования этой рубрики. Preview не читает +и не записывает compiled/full-page cache, не увеличивает просмотры, получает +`noindex` и `no-store`. Не формируйте параметры `revision_preview`, +`preview_token` или старый `revission` вручную: без подписи они возвращают 403. + +## Проверка после изменения + +1. Откройте публичный URL в новой вкладке. +2. Проверьте шаблон рубрики, поля, изображения и навигацию. +3. Проверьте title, description, canonical и Open Graph в исходном HTML. +4. При смене URL откройте старый адрес и убедитесь в редиректе. +5. Для документа в каталоге проверьте обновление его проекции и фильтров. diff --git a/help/content/navigation.md b/help/content/navigation.md new file mode 100644 index 0000000..ed2475d --- /dev/null +++ b/help/content/navigation.md @@ -0,0 +1,107 @@ +# Навигация + +← [К разделу «Как собирается сайт»](README.md) + +Навигация — независимое дерево ссылок на документы и произвольные URL. Она не +определяет адрес документа и не удаляет документ вместе с пунктом. + +## Создание меню + +1. Задайте название и короткий уникальный alias, например `main`. +2. Настройте внешнюю обёртку и шаблоны используемых уровней. +3. Выберите группы, которым доступна навигация. +4. Создайте пункты и расположите их перетаскиванием. +5. Вставьте тег `[tag:navigation:main]` в шаблон или блок. + +По умолчанию достаточно первого уровня. Второй и третий уровни настраиваются +только если дерево действительно их использует. + +## Пункты + +Пункт может ссылаться на документ или содержать произвольный URL. При выборе +документа название и ссылка заполняются автоматически, после чего их можно +переопределить для конкретного меню. + +Дополнительно доступны описание, target, изображение, CSS ID, класс и style. +Изображение выбирается через медиапикер. Для обычной ссылки используйте `_self`, +для осознанного открытия новой вкладки — `_blank`. + +Уровень определяется положением пункта в дереве. Перетаскивание меняет родителя, +уровень и порядок; отдельное числовое поле позиции для этого не требуется. + +## Шаблоны уровней + +Рендер выполняется в следующем порядке: + +```text +Обёртка: начало + Уровень 1: начало + обычный или активный пункт + следующий пункт + Уровень 1: конец +Обёртка: конец +``` + +`[tag:content]` в начале уровня заменяется готовыми пунктами. Без этого тега +пункты добавляются после начальной разметки. Конец уровня выводится всегда после +его пунктов. + +Минимальный первый уровень: + +```html + + +``` + +## Вложенные уровни + +Шаблон родительского пункта должен содержать тег следующего уровня: + +```html +
  • + [tag:linkname] + [tag:level:2] +
  • +``` + +Для второго уровня аналогично используется `[tag:level:3]`. Если тега нет, +дочерние пункты существуют в дереве, но не попадут в HTML. + +Условия `[tag:if_sub_level]`, `[tag:if_no_sub_level]`, `[tag:if_first]`, +`[tag:if_last]`, `[tag:if_every:N]` и парный `[tag:/if]` позволяют менять +разметку без PHP. + +## Активная ветка + +Система ищет пункт текущего документа или совпадающий alias, строит путь до +корня и применяет активный шаблон к соответствующим пунктам. Для корректного +`aria-current` назначайте его только ссылке текущего пункта, если дизайн +различает текущую страницу и активного родителя. + +## Кеш и диагностика + +Изменение шаблонов или структуры очищает кеш этой навигации. Ручная очистка +нужна после внешней правки БД. + +Если пункт не виден, проверьте: + +1. активность пункта и всех родителей; +2. доступ текущей группы к навигации; +3. наличие шаблона нужного уровня; +4. `[tag:level:2]` или `[tag:level:3]` у родителя; +5. внешнюю и уровневую закрывающую разметку. diff --git a/help/content/requests.md b/help/content/requests.md new file mode 100644 index 0000000..aa92c25 --- /dev/null +++ b/help/content/requests.md @@ -0,0 +1,514 @@ +# Запросы + +← [К разделу «Как собирается сайт»](README.md) + +Запрос — сохранённая выборка документов и правила её представления. Он подходит +для новостей, статей, карточек, связанных материалов и других списков. + +## Части запроса + +| Часть | Назначение | +| --- | --- | +| Рубрики и условия | Определяют документы результата. | +| Сортировка | Определяет порядок результата. | +| Количество и пагинация | Ограничивают выдачу и создают страницы списка. | +| Шаблон элемента | Рендерит один документ. | +| Основной шаблон | Оборачивает готовые элементы и пагинацию. | +| Контракт результата | Явно перечисляет системные свойства и поля, доступные структурированным потребителям. | +| Кеш | Сохраняет результат на заданное время. | + +Запрос вставляется по alias: + +```text +[tag:request:latest_news] +``` + +## Обычный и расширенный режимы + +Редактор открывается в **обычном режиме**. Для большинства списков этого +достаточно: здесь находятся название, рубрика, количество материалов, +сортировка, пагинация, шаблоны и условия. + +**Расширенный режим** нужен, когда вы настраиваете кеш, интеграцию, старый +PHP-шаблон, Native executor или точный контракт данных. Если смысл параметра +непонятен, оставьте его без изменения и вернитесь в обычный режим. + +Переключатель меняет только вид формы. Он не удаляет скрытые настройки и не +меняет публичную страницу. Выбранный режим запоминается в текущем браузере. + +Простой порядок работы: + +1. Выберите рубрику. +2. Укажите количество материалов и порядок сортировки. +3. Добавьте условия, если нужно отобрать не все документы рубрики. +4. Проверьте шаблон одного материала и общий шаблон списка. +5. Запустите предпросмотр и только затем сохраните запрос. + +## Как показать результат + +В обычном режиме есть отдельный блок **«Как показать результат»**. Он позволяет +посмотреть одну и ту же выборку в нескольких видах: + +| Вид | Для чего подходит | +| --- | --- | +| **Карточки данных** | Проверить все выбранные свойства каждого материала. | +| **Компактный список** | Быстро просмотреть названия и несколько основных значений. | +| **Таблица** | Сравнить одинаковые свойства нескольких материалов по колонкам. | +| **JSON** | Проверить структурированные данные для API и интеграций. | + +Выбор сохраняется вместе с запросом, но относится только к предпросмотру в +админке. Чтобы применить его, нажмите **«Обновить»** в блоке предпросмотра. + +Публичный сайт продолжает использовать сохранённые шаблоны `main` и `item`. +Переключение карточек, списка или таблицы не меняет HTML сайта. Для изменения +публичной разметки нажмите **«Редактировать шаблон сайта»**: редактор перейдёт +в расширенный режим и откроет текущие шаблоны. Это разделение позволяет +проверять данные, не рискуя случайно изменить работающую страницу. + +## Условия и группы + +Простой плоский список недостаточен, когда части фильтра должны работать с +разной логикой. Используйте группы и явно задавайте `И`/`ИЛИ`: + +```text +Опубликован +И +( + Рубрика = Новости + ИЛИ Рубрика = Статьи +) +И +( + Тег = Важное + ИЛИ Просмотры > 1000 +) +``` + +Сначала спроектируйте выражение, затем создавайте группы. Перемещение условия +между группами меняет смысл выборки даже при тех же значениях. + +Условие `И` должно сужать текущий набор, `ИЛИ` — добавлять альтернативу внутри +своей группы. После изменения обязательно проверьте реальный результат, а не +только сохранение формы. + +### Как читать одну строку условия + +Редактор читает каждую строку слева направо: + +```text +Поле документа → сравнение → откуда взять значение → значение +``` + +Например, строка `Цена → больше или равно → параметр min_price → число` +означает: посетитель может передать `min_price=1000`, после чего запрос оставит +документы с ценой от 1000. Если `min_price` не передан, эта необязательная +строка не участвует в фильтрации. + +1. **Поле** — данные документа, которые проверяются. +2. **Сравнение** — правило проверки: равно, содержит, больше, входит в список и + так далее. +3. **Источник** — готовое значение редактора либо параметр текущей страницы или + формы. +4. **Значение** — редактор сам предлагает подходящий элемент для типа поля: + переключатель, список, число, дату или выбор связанного документа. + +Коды старых операторов (`==`, `%%`, `FRE`) хранятся для совместимости, но в +редакторе показываются человекопонятные названия. Для новых условий свободный +SQL и PHP не требуются. + +### Как работают группы + +Переключатель в заголовке группы относится ко **всем непосредственным элементам +этой группы**: + +- **И** — должны совпасть все строки и вложенные группы; +- **ИЛИ** — достаточно одной совпавшей строки или вложенной группы; +- вложенная группа — это скобки, содержимое которых вычисляется отдельно. + +Например, выражение `Показывать на главной = Да И (Тип = Новость ИЛИ Тип = Статья)` +собирается из корневой группы **И** и вложенной группы **ИЛИ**. Если оставить +всё в одной группе, смысл будет другим. + +### Как выбрать сравнение + +| Сравнение | Практический смысл | +| --- | --- | +| **Равно / не равно** | Проверяет полное значение поля. | +| **Содержит / не содержит текст** | Ищет фрагмент внутри строки. Для точного элемента составного поля используйте обработку `\|значение\|`. | +| **Начинается / не начинается с** | Проверяет начало текстового значения. | +| **Больше / меньше** | Сравнивает обычное сохранённое значение. | +| **Число больше / меньше** | Использует числовой индекс поля и подходит для цены, веса, количества и других чисел. | +| **Входит / не входит в список** | Сравнивает значение с несколькими допустимыми вариантами. | +| **Диапазон** | Проверяет нижнюю и верхнюю границу. Для фильтра из формы выберите обработку диапазона. | +| **Legacy SQL** | Оставлен для старых проектов. Новые правила через него не создавайте. | + +Если результат не совпал с ожиданием, сначала проверьте не шаблон, а тип поля и +формат сохранённого значения. Число, текст и составное значение с разделителями +сравниваются по-разному. + +### Значение условия + +У значения есть три источника: + +| Источник | Когда использовать | +| --- | --- | +| **Готовое значение** | Запрос всегда сравнивает поле с выбранным или введённым значением. | +| **Параметр URL или формы** | Фильтр получает значение из адреса страницы или формы посетителя. | +| **Код совместимости** | Старое условие с PHP. Показывается в расширенном режиме и не нужно для новых запросов. | + +В режиме **Готовое значение** вид поля зависит от его типа: + +| Тип поля | Что показывает редактор | +| --- | --- | +| Флажок | Две понятные кнопки **Да** и **Нет**. | +| Список | Готовые варианты из настроек поля; для сравнения со списком можно выбрать несколько. | +| Число | Числовой ввод без стрелок увеличения и уменьшения. | +| Дата | Календарь; старый формат хранения сохраняется автоматически. | +| Связанный документ | Поиск по ID, названию и адресу с учётом рубрик-источников поля. | +| Обычный текст | Строка ввода; для оператора «входит в список» значения указываются через запятую. | + +После выбора поля список сравнений также сокращается. Например, у флажка +остаются только «равно» и «не равно», а у числа доступны числовые сравнения и +диапазоны. Существующий нестандартный оператор не удаляется при открытии формы: +его можно проверить и заменить вручную. + +Для публичного параметра укажите его имя. Под строкой +редактор сразу показывает итоговое правило обычной фразой: какое поле +сравнивается, откуда берётся значение и что с ним произойдёт. Например: + +> Поле «Каталог» содержит параметр «catalog», преобразованный в +> `|значение|`. Пустой параметр не участвует в фильтрации. + +Служебное представление можно раскрыть отдельно: + + [tag:cond:catalog] + +Это не обычный шаблонный тег и не строковая подстановка. Редактировать его +вручную не требуется. Он сообщает компилятору, что значение нужно безопасно +прочитать из параметра `catalog`. + +В обычном режиме редактор выбирает обработку по типу поля. Изменить её вручную +можно в **Расширенном** режиме. + +| Обработка значения | Что получает движок | +| --- | --- | +| Взять текст как есть | Одно текстовое значение. | +| Взять целое число | Одно число без дробной части. | +| Взять десятичное число | Одно число с дробной частью. | +| Взять список | Несколько значений, любое из которых может дать совпадение. | +| Взять диапазон от/до | Массив с ключами `min` и `max`; пустая граница не применяется. | +| Обернуть значение: `|значение|` | Точное значение внутри составного поля, например ID раздела `95` превращается в `|95|`. | +| Обернуть список: `|значение|` | Несколько точных значений внутри составного поля. | +| Подставить константу, если заполнено | Пока параметр пуст, условия нет; при заполнении сравнивается указанная константа. Только в этом режиме показывается поле константы. | + +Пустой необязательный параметр полностью исключает условие из дерева. Поэтому +не добавляйте разделители или SQL вокруг тега вручную: способ хранения задаётся +вариантом **Как читать**. Alias поля подставляется при выборе источника, но его +можно заменить, если публичный URL исторически использует другое имя, например +oldprice для поля old. + +Каталог создаёт такие же условия, а не отдельный фильтр в обход запросов. +Одиночные значения, флажки, множественный выбор и диапазоны остаются элементами +общего дерева И/ИЛИ и редактируются в запросе. + +### Пример: раздел каталога + +Условие запроса товаров обычно выглядит так: + +1. Поле — **Каталог**. +2. Сравнение — **содержит**. +3. Источник — **параметр URL или формы**. +4. Имя параметра — `catalog`. +5. Обработка — **обернуть значение: `|значение|`**. + +При значении `catalog=95` движок ищет в поле каталога точный маркер `|95|`. +Это не даёт разделу `9` случайно совпасть с разделом `95`. Если параметр +отсутствует или пуст, условие не возвращает ложный результат, а полностью +пропускается. Остальные активные условия запроса продолжают работать. + +## Шаблон элемента + +Шаблон получает текущий документ и его поля. Типичный элемент: + +```html + +``` + +Откройте кнопку **Теги** именно у редактора **Шаблон одного материала**. Первая +группа **Поля рубрики** строится по выбранной рубрике запроса. В ней рядом с +названием и типом поля показан готовый тег: + +```text +[tag:rfld:title][0] +[tag:rfld:image][0] +``` + +По возможности используется системный alias поля, а если его нет — ID. Суффикс +`[0]` включает обычный публичный шаблон поля для списка. Для изображения внутри +тега превью можно заменить его на `[img]`, например: + +```text +[tag:c760x460:[tag:rfld:image][img]] +``` + +Группа полей не показывается в палитре общего шаблона: там ещё нет текущего +документа, а готовые карточки вставляются тегом `[tag:content]`. Не выполняйте +отдельный SQL-запрос для каждого элемента списка. + +## Основной шаблон + +Основной шаблон получает уже собранные элементы и отвечает за контейнер, +заголовок и пагинацию. Не дублируйте в нём разметку одной карточки. + +Проверьте отдельно обычную и постраничную выдачу: у пагинации могут быть иной +URL и активное состояние. + +## Контракт результата + +Контракт отвечает не за поиск документов и не за HTML. Он перечисляет данные, +которые разрешено передать API, предпросмотру или renderer'у: + +- системные свойства документа: ID, название, alias, тизер, состояние и даты; +- выбранные поля рубрики; +- стабильные системные ключи и alias полей. + +Например, для списка новостей достаточно названия, alias, тизера, даты и поля с +обложкой. Цена и остаток в такой контракт не попадут, если они не выбраны. + +Пустой старый контракт автоматически получает безопасный базовый набор +системных свойств. Выбор полей сохраняется вместе с запросом и переносится +контент-пакетом. Удалённое или чужое поле при сохранении отбрасывается. + +Контракт первой итерации не меняет теги и HTML существующего сайта. Публичный +вывод по-прежнему выполняют шаблоны `main/item`, помеченные в редакторе как +**Legacy renderer**. + +Рядом можно выбрать renderer предпросмотра: + +- **Карточки данных** удобны для визуальной проверки выбранных свойств; +- **JSON** показывает тот же контракт в машинном формате. + +Выбор сохраняется у запроса, но не переключает публичную страницу. Поэтому +можно проверять будущий API-формат, не меняя карточки и листинги сайта. + +## Режим выполнения + +У запроса есть три режима executor. Они меняют только получение списка +документов. Шаблоны `main/item`, разметка карточек и публичные URL остаются +прежними. + +| Режим | Что происходит | +| --- | --- | +| **Legacy** | Работает прежняя выборка со всеми историческими PHP- и SQL-возможностями. Это безопасное состояние по умолчанию. | +| **Shadow** | Публичный сайт продолжает использовать Legacy. В Adminx тот же запрос дополнительно выполняется Native executor'ом и результаты сравниваются. | +| **Native** | Список выбирается по параметризованному плану без исполнения PHP из значений условий. Старые шаблоны продолжают рендерить результат. | + +Native нельзя выбрать сразу. Сначала сохраните запрос, включите **Shadow** и +запустите предпросмотр. Система сравнит: + +- общее количество документов; +- ID документов в проверяемом окне; +- порядок документов; +- текущую версию условий, групп, рубрики и сортировки. + +Если всё совпало, появится состояние **Native подтверждён** и режим станет +доступен. Подтверждение связано с хешем плана. Изменение условия, группы, +рубрики или сортировки автоматически снимает его; после сохранения нужно снова +запустить сравнение. + +Для автоматического подтверждения порядок должен заканчиваться уникальным +системным полем **ID**. Одинаковая дата публикации, позиция или цена может быть +у нескольких документов. Без финального ID MySQL вправе переставить такие +строки после очистки кеша, даже если состав списка не изменился. В этом случае +Adminx покажет **Порядок не закреплён** и сохранит Shadow/Legacy. Это защищает +пагинацию, меню и карточки от незаметной перестановки. + +Для текстовой сортировки равенство определяется той же collation базы, что и в +реальном `ORDER BY`. Например, `MODEL-X` и `Model-X` обычно считаются одним +значением. В диагностике они будут показаны как два исходных текста, но причина +останется той же: таким строкам нужен отдельный финальный порядок. + +В конструкторе порядка нажмите **«Закрепить по ID»**. Система добавит ID +последним уровнем по возрастанию. При необходимости направление можно поменять. +Это не заменяет предыдущие уровни: дата, цена или позиция остаётся главным +правилом, ID только стабилизирует строки с одинаковым значением. У случайного +порядка с заполненным ключом ID добавляется автоматически как незаметный +финальный признак; вручную добавлять его не нужно. + +Автоподтверждение сравнивает до 5000 документов целиком. Более крупная выборка +помечается как неполная и остаётся на Legacy, даже если её начало совпало. + +Native использует декларативные источники HTTP-параметров: необязательное +целое, десятичное число, строку, список, диапазон, составное значение и +константу по наличию параметра. PHP при этом не исполняется. Аудит проверяет +состояние без параметров, каждый параметр отдельно и их сочетание; для теста +берётся реальное значение соответствующего поля, когда оно доступно. + +Запрос останется на Legacy, если использует произвольный `FRE`, `ANY`, произвольный PHP, +`[field]`, случайную сортировку без постоянного ключа или переданные при вызове сырые `USER_WHERE`, +`USER_FROM`, `USER_JOIN`, `ORDER`, `SQL_QUERY`. Adminx показывает конкретные +причины. Эти возможности не интерпретируются приближённо. + +Декларативный диапазон и множественный фильтр могут хранить исторический код +оператора `FRE`, но внутри у них уже нет свободного SQL: Native строит +параметризованное сравнение по сохранённому типу. + +Даже в режиме Native действует защитный fallback: неподдерживаемый runtime- +параметр или неподтверждённая версия плана выполняются через Legacy. Для +быстрого ручного отката достаточно вернуть режим **Legacy**. + +### Массовая проверка + +На странице списка запросов откройте **Проверка Native**. Эта страница нужна +для безопасного перехода существующего сайта: + +1. **Проверить все** переводит запросы в Shadow. Распознанным старым условиям + записывается типизированное описание рядом с исходным PHP, но публичный HTML + продолжает собираться Legacy executor'ом. +2. Adminx последовательно сравнивает до 5000 документов каждого запроса, не + создавая одновременную нагрузку на БД. Если состав Legacy-выборки совпал во + всех состояниях параметров, PHP заменяется декларативным тегом и Legacy + проверяется ещё раз по хешу полного списка ID. При отличии значение + автоматически возвращается. +3. В таблице отдельно видны точное совпадение, несовпадение порядка, + незакреплённая сортировка и legacy-код. + Для несовпадения порядка показывается первая отличающаяся позиция, ID из + Legacy и Native и фактические значения ключа сортировки. Например, одинаковая + дата или цена сразу показывает, что фильтр совпал, а запросу не хватает + только финального ID. +4. **Закрепить порядок** обрабатывает только незакреплённые запросы. Для каждого + из них и запросов с отличием порядка система целиком сравнивает прежний + Legacy-порядок сначала с `Id ASC`, затем с `Id DESC`. Подходящее направление + сохраняется только при точном совпадении всех ID. После записи выполняется + повторная контрольная сверка; при любом отличии настройка и прежний режим + автоматически восстанавливаются. +5. **Включить подтверждённые** меняет executor только у запросов с актуальным + хешем, полным совпадением и уникальным финальным порядком. Условия запросов + без стабильной сортировки уже могут быть переведены на теги, но сам запрос + останется в Shadow/Legacy. +6. **Вернуть Legacy** одним действием откатывает все запросы. Хеши проверки + сохраняются, но после изменения запроса всё равно потребуют новой сверки. + +Если ни одно направление ID не воспроизводит существующую выдачу, запрос +остаётся в Shadow/Legacy. Это нормальный результат: система не пытается +«приблизительно» исправить порядок и не меняет публичную страницу. + +Смена режима очищает кеш настроек и элементов запросов. Поэтому контрольные +страницы после активации проверяйте заново, а не по старому закешированному +HTML. + +## Предпросмотр данных + +Кнопка **Обновить** выполняет сохранённую выборку, не запускает шаблоны +`main/item` и одновременно проводит теневое сравнение Legacy/Native. Поэтому +результат показывает данные и совместимость, а не дизайн сайта. + +В предпросмотре доступны: + +- общее количество найденных документов; +- первые 5, 10 или 20 результатов; +- время SQL; +- рубрика, число групп и активных условий, сортировка и публикационные + ограничения; +- только свойства, выбранные в контракте; +- SQL для пользователя с правом управления запросами. +- состояние Native-плана, причины несовместимости и различающиеся ID. + +Если вы изменили рубрику, сортировку или основные параметры запроса, сначала +сохраните форму. Выбор полей контракта передаётся в preview сразу и может быть +проверен до сохранения. + +Каждый показанный документ уже прошёл всё дерево условий. Чтобы проверить +исключённый материал, найдите его по ID, названию или alias в строке **Почему +документ попал или не попал?** и нажмите **Объяснить**. + +В правой панели отдельно показаны: + +- рубрика, состояние, удаление и срок публикации; +- результат каждой группы И/ИЛИ; +- фактическое значение поля и ожидаемое значение; +- окончательный ответ реального executor. + +Свободное условие `FRE` может содержать PHP, SQL или runtime-параметры. Инспектор +не исполняет такой код повторно и честно помечает условие как неопределённое, +но итог «входит / не входит» всё равно получает из настоящей выборки. + +## Сортировка + +Порядок собирается сверху вниз. Первое правило является главным, второе +применяется при совпадении первого, третье — при совпадении первых двух. + +Пример: + +```text +1. Дата публикации — по убыванию +2. Название документа — по возрастанию +3. ID документа — по возрастанию +``` + +Так новые материалы показываются первыми, материалы с одинаковой датой +упорядочиваются по названию, а ID делает итог стабильным для пагинации. + +Нажмите **«Добавить уровень»**, выберите системное свойство документа или поле +рубрики и задайте направление. Строки можно переставлять за левый маркер. +Одинаковое поле нельзя добавить дважды; доступно не больше восьми уровней. +Случайный порядок используется отдельно и не сочетается с другими правилами. + +После его выбора появляется **Ключ порядка**. Пустой ключ перемешивает +материалы заново, как прежний `RAND()`. Одинаковый числовой ключ всегда даёт +одинаковый порядок и подходит для пагинации: переход на следующую страницу не +перемешивает уже показанные материалы. + +Например, ключ `2026` можно оставить постоянным, а для ежедневной подборки +менять его планировщиком раз в день. SQL для этого писать не требуется. + +Для больших списков выбирайте поля с однозначным типом и числовым индексом. +SQL писать не требуется. Кнопка **«Закрепить по ID»** добавляет безопасный +последний уровень и предотвращает перестановку элементов между страницами. + +Старые запросы открываются в новом конструкторе без миграции вручную. Их +историческая последовательность «поле рубрики → системное поле → ID» +сохраняется. После сохранения полный порядок записывается в JSON, а старые +колонки продолжают заполняться для совместимости с прежними интеграциями. + +Поле **Шаблон пагинации** показывает реальные шаблоны из настроек по названию и +ID. Удалённый или несуществующий шаблон сохранить нельзя: сначала выберите +действующий либо создайте новый в настройках пагинации. + +## Кеш + +Время жизни `0` отключает долговременный кеш результата. Большой срок подходит +стабильным спискам, но задерживает появление новых документов. Сохранение +документа очищает связанные ключи там, где зависимость известна; проектные +внешние данные могут потребовать явной инвалидации. + +Перед выводом списка движок сам собирает ID документов и пакетно подготавливает +их JSON-снимки и поля. Поэтому шаблон элемента может продолжать использовать +обычные `[tag:doc:*]` и `[tag:rfld:*]`: переносить поля в SQL запроса ради +скорости не требуется. Если снимка ещё нет, первая страница создаст его; далее +используется готовый файл. Сохранение документа обновляет снимок автоматически. + +Не включайте общий кеш для результата, который зависит от пользователя, сессии +или непредставленных в ключе параметров. + +## Внешний и AJAX-вызов + +Эти флаги разрешают получить запрос вне обычной страницы. Включайте их только +для реально используемого endpoint и проверяйте права, входные параметры и +кеширование. Внутреннему тегу `[tag:request:alias]` они не нужны. + +## Проверка запроса + +1. Выберите минимальный контракт результата. +2. Запустите структурированный предпросмотр и сверьте первые документы. +3. Сверьте количество результатов без кеша. +4. Проверьте каждую группу условий отдельно. +5. Проверьте пустую выдачу и один элемент. +6. Проверьте первую, вторую и последнюю страницу пагинации. +7. Проверьте активность и дату публикации документов. +8. После включения кеша повторите изменение одного документа. +9. Оставьте режим Shadow на реальном трафике, затем включайте Native отдельно + для каждого подтверждённого запроса. diff --git a/help/content/rubrics.md b/help/content/rubrics.md new file mode 100644 index 0000000..67707a0 --- /dev/null +++ b/help/content/rubrics.md @@ -0,0 +1,155 @@ +# Рубрики и поля + +← [К разделу «Как собирается сайт»](README.md) + +Рубрика описывает тип контента. Она объединяет поля, форму редактора документа, +публичные шаблоны, URL, права и код жизненного цикла. Рубрика не является папкой +и сама не формирует дерево сайта. + +## С чего начать + +Перед созданием рубрики ответьте на четыре вопроса: + +1. Какие данные одинаковы для всех документов этого типа? +2. Какие поля нужны только редактору, а какие участвуют в публичном выводе, + поиске, запросах и фильтрах? +3. Как должен формироваться URL? +4. Как выглядит один документ и его краткое представление в списке? + +Пример для статьи: `lead`, `cover`, `content`, `author`, `published_source`. +Общие свойства не нужно дублировать несколькими полями ради разных каталогов: +для этого используются единый alias характеристики и настройки конкретного +каталога. + +## Конструктор формы + +Левая колонка содержит доступные, но ещё не размещённые поля. Центральная область +показывает форму документа, правая — настройки выбранного поля. + +- поле, добавленное в форму, исчезает из списка доступных; +- порядок меняется перетаскиванием; +- поле можно вернуть в библиотеку, не удаляя саму сущность; +- сетка состоит из 12 колонок, ширина определяет место поля в строке; +- группы визуально разделяют большие формы и не меняют публичные данные; +- переименование в конструкторе меняет подпись в этой рубрике, но не системный + alias типа поля; +- просмотр настроек без изменений не должен помечать форму как изменённую. + +После сохранения надпись о несохранённых изменениях должна исчезнуть. Если поле +не помещается или редактор растягивает строку, проверьте ширину и настройки типа. + +## Настройки поля в рубрике + +Настройки делятся на общие и принадлежащие типу. Одно значение не должно +редактироваться в двух местах. Например, для списка значений используется один +редактор вариантов, а для «Документ из рубрики» — один выбор рубрики-источника. + +Часто используемые параметры: + +| Параметр | Назначение | +| --- | --- | +| Название | Подпись поля в редакторе документа. | +| Ширина | Количество колонок формы из 12. | +| Значение по умолчанию | Начальное значение нового документа. | +| Обязательное | Запрещает сохранение без корректного значения. | +| Префикс/суффикс | Единица измерения или пояснение рядом с редактором. | +| Публичный шаблон | Разметка значения при выводе документа или запроса. | +| Путь загрузки | Каталог медиа с маркерами документа и поля. | + +Подробный контракт типов: [Поля документов](../fields/README.md). + +## Шаблоны рубрики + +Основной шаблон собирает `[tag:maincontent]` текущего документа. Дополнительные +шаблоны позволяют выбрать другой вид для отдельного документа без изменения его +полей. + +```html +
    +

    [tag:fld:title]

    +
    [tag:fld:content]
    +
    +``` + +Отдельно настраиваются: + +- основной и дополнительные шаблоны документа; +- Open Graph; +- вставка в `` через `[tag:rubheader]`; +- вставка перед закрытием страницы через `[tag:rubfooter]`; +- шаблон краткого представления там, где он поддерживается; +- код до и после сохранения. + +Не переносите общую оболочку ``, `` и `` в рубрику: ею владеет +шаблон сайта. + +## Шаблон URL + +Шаблон рубрики является префиксом адреса документа. Поддерживаются маркеры даты: + +| Маркер | Значение | +| --- | --- | +| `%d`, `%m` | День и месяц с ведущим нулём. | +| `%Y`, `%y` | Год из четырёх или двух цифр. | +| `%H`, `%M`, `%S` | Час, минута и секунда. | + +Пример: `news/%Y/%m`. В редакторе документа к результату добавляется короткий +alias. Полный путь, введённый вручную с `/`, система не переписывает шаблоном. + +## Права + +Права рубрики определяют, какие группы могут читать, создавать, редактировать и +удалять её документы. Они дополняют права раздела «Документы»: доступ к экрану +ещё не означает право менять любую рубрику. + +После создания рубрики проверьте минимум гостевую, пользовательскую, +модераторскую и административную группы. + +## Код до и после сохранения + +Сохранённый PHP пока поддерживается для проектной логики. Код получает контекст, +описанный в информационном блоке редактора, и выполняется через защищённый runtime. +Перед сохранением можно нормализовать или отклонить данные, после сохранения — +запустить интеграцию, не дублируя основной CRUD документа. + +Для переиспользуемой логики предпочтительнее hooks и сервис модуля. Так один +обработчик можно подключить к нескольким рубрикам, протестировать и отключить без +правки шаблонного кода. См. [Хуки документов](../hooks/documents.md). + +## Безопасное изменение рабочей рубрики + +1. Сделайте резервную копию БД. +2. Не удаляйте поле, пока его значения используются документами, запросами, + каталогами или шаблонами. +3. Сначала добавьте новое поле и перенесите данные. +4. Проверьте несколько реальных документов и публичных листингов. +5. Только после этого удаляйте старый alias и очищайте кеш. + +## Ревизии схемы + +Ревизия схемы — это точка восстановления состава полей и их расположения. Она +создаётся автоматически при изменении поля, группы, порядка или конструктора. +Откройте список рубрик и нажмите кнопку с иконкой истории в нужной строке. + +Перед восстановлением панель показывает три разных результата: + +- **вернётся** — объект был в старом снимке, но сейчас отсутствует; +- **изменится** — объект существует, но его настройки отличаются; +- **сохранится новым** — объект появился позже выбранного снимка. + +Последний вариант особенно важен: AVE.cms не удаляет более новые поля. Они +остаются в рубрике вместе со значениями документов, но возвращаются в библиотеку +конструктора как неразмещённые. Их можно снова поставить в форму вручную. + +Значения документов не входят в ревизию схемы и не перезаписываются. Перед +подтверждением система считает документы с данными затрагиваемых полей. Если +схема или документы изменились после открытия предпросмотра, восстановление +будет остановлено: откройте снимок повторно и проверьте новый расчёт. + +Нельзя восстановить снимок, если он ссылается на неустановленный тип поля, +содержит повторяющийся alias или конфликтует с полем другой рубрики. Исправьте +причину блокировки, а не удаляйте действующее поле ради обхода проверки. + +Перед каждым восстановлением текущая схема автоматически сохраняется отдельным +снимком. Поэтому сразу после операции можно выбрать запись «Перед +восстановлением» и безопасно вернуться назад. diff --git a/help/content/templates.md b/help/content/templates.md new file mode 100644 index 0000000..c124ed7 --- /dev/null +++ b/help/content/templates.md @@ -0,0 +1,113 @@ +# Шаблоны сайта + +← [К разделу «Как собирается сайт»](README.md) + +Шаблон сайта — внешняя оболочка готовой страницы. Он содержит HTML-документ, +метаданные, общую шапку, навигацию, место основного содержимого и подвал. + +## Минимальный контракт + +```html + + + + + + [tag:title] + + + + [tag:rubheader] + + + [tag:navigation:main] + [tag:maincontent] + [tag:sysblock:footer] + [tag:rubfooter] + + +``` + +Обязательный смысловой тег — `[tag:maincontent]`. Без него документ будет найден +и обработан, но содержимое рубрики не попадёт на страницу. + +## Что размещать в шаблоне + +- общий ``, CSS и JavaScript темы; +- шапку и подвал; +- общую навигацию; +- системные блоки, одинаковые для многих страниц; +- SEO-теги текущего документа; +- места для header/footer-вставок рубрики. + +Карточку конкретной статьи или товара размещайте в шаблоне рубрики. Списки +документов принадлежат запросам или каталогу. Это уменьшает количество условий в +глобальной оболочке. + +## Палитра тегов + +Палитра строится из реестра и показывает актуальные блоки, навигации, запросы и +системные значения. После выбора тег вставляется в текущую позицию редактора, а +панель закрывается. + +Предпочитайте alias вместо числового ID: + +```text +[tag:navigation:main] +[tag:request:latest_news] +[tag:sysblock:footer] +``` + +Так шаблон легче переносить между установками, если системные ID различаются. + +## Модульные Twig-компоненты + +Сложный модуль может вернуть HTML из внутреннего Twig-шаблона, но в шаблон сайта +он всё равно вставляется обычным зарегистрированным тегом: + +```text +[mod_search] +[mod_basket:mini] +``` + +Не указывайте путь к Twig-файлу непосредственно в шаблоне. Перед созданием +нового компонента проверьте, нельзя ли решить задачу рубрикой, запросом, блоком, +навигацией или существующим модульным тегом. Полный контракт описан в главе +[«Точечные Twig-компоненты»](twig-components.md). + +## PHP и проверка + +Шаблон может содержать проектный PHP, но ошибка нарушит все страницы, которые его +используют. Перед сохранением запускайте проверку синтаксиса. Общую бизнес-логику +лучше держать в сервисах и hooks, оставляя в шаблоне композицию и небольшие +условия. + +Не выполняйте запросы к БД в цикле карточек. Подготовьте данные запросом, +каталогом, блоком или сервисом до рендера. + +## Сохранение и кеш + +Источник шаблона хранится в БД. После сохранения система атомарно обновляет +файловый кеш публичного рендера. Путь кеша является внутренней деталью; не +редактируйте `.inc` вручную, иначе следующее сохранение перезапишет изменения. + +Кнопка пересборки кеша создаёт файлы заново из БД. Она нужна после переноса, +восстановления файлов или диагностики, а не после каждого обычного сохранения. + +## Ревизии + +При создании, сохранении, копировании, импорте и восстановлении создаются +ревизии. Восстановление сначала сохраняет текущее состояние как резервное, затем +применяет выбранный снимок и обновляет кеш. + +Шаблон №1 системный и не удаляется. Другой шаблон нельзя удалить, пока его +использует хотя бы одна рубрика. + +## Порядок изменения дизайна + +1. Создайте копию шаблона. +2. Назначьте её тестовой рубрике или документу. +3. Проверьте обычную страницу, 404, пагинацию, формы и авторизованное состояние. +4. Проверьте мобильную ширину и отсутствие горизонтальной прокрутки. +5. После проверки переключите рабочие рубрики. +6. Старый шаблон удаляйте только после периода наблюдения. diff --git a/help/content/themes.md b/help/content/themes.md new file mode 100644 index 0000000..13ae59c --- /dev/null +++ b/help/content/themes.md @@ -0,0 +1,178 @@ +# Темы публичного сайта + +Раздел **Темы** управляет файлами публичного оформления без загрузки через FTP: +CSS, JavaScript, изображениями, SVG, шрифтами и Twig-представлениями. Во +вкладке **Настройки** также выбирается, кто собирает общую страницу: нативные +шаблоны AVE.cms или файловая Twig-оболочка темы. + +## Где находятся темы + +Каждая тема имеет отдельный каталог: + +```text +templates/<код-темы>/ +├── theme.json +├── css/ +├── js/ +├── images/ +└── fonts/ +``` + +Активная тема выбирается в разделе **Темы**. Код каталога состоит из строчных +латинских букв, цифр, `_` и `-` и начинается с буквы. + +Служебные каталоги `include`, `templates`, `lang`, каталоги превью и скрытые +файлы не показываются в файловом менеджере. Это отделяет статические ассеты от +скомпилированных шаблонов и других внутренних файлов темы. + +## Подключение CSS и JavaScript + +Вкладка **Подключения** редактирует `theme.json`. В ней можно: + +- выбрать CSS-файлы и задать `media`; +- выбрать JS-файлы и включить `defer`, `async` или `type="module"`; +- изменить порядок файлов перетаскиванием; +- указать название и версию темы. + +В основном шаблоне сайта используются теги: + +```html + + [tag:theme-styles] + + + [tag:maincontent] + [tag:theme-scripts] + +``` + +Один отдельный файл можно получить независимо от реестра: + +```text +[tag:asset:images/logo.svg] +``` + +Движок проверяет существование файла и добавляет к URL короткую SHA-1-версию. +После сохранения файла браузер получает новый адрес вида `?v=...`, поэтому +старый CSS или JavaScript не остаётся в кеше. + +Новые теги являются явными. Создание или активация темы не добавляет их в +существующий шаблон и не меняет текущий публичный вид сайта. + +## Настройки витрины + +Тема может объявить в `theme.json` собственные редактируемые настройки. Для +каждой темы они автоматически появляются во вкладке **Темы -> Настройки**. + +Так редактируются контакты, подписи и лимиты подборок без изменения файлов. +Состав полей принадлежит теме: после переключения темы Adminx покажет настройки +нового оформления, а значения прежней темы сохранит под её собственными +ключами. + +Скалярное значение можно вывести и в нативном шаблоне, запросе или блоке: + +```html + + [tag:theme-setting:retail_phone] + +``` + +Тег читает только параметр, объявленный активной темой, и безопасно экранирует +его. Для стандартных полей `retail_phone` и `wholesale_phone` дополнительно +доступны ключи с суффиксом `_href`. + +Поле типа `section_order` управляет секциями только файловой Twig-оболочки. +Порядок нативной страницы меняют в шаблоне её рубрики: там явно видны блоки, +запросы и модульные теги, из которых собрана страница. Переключатель секции не +удаляет её документы или товары. + +## Нативный режим и оболочка темы + +Во вкладке **Настройки** доступны два способа сборки публичной страницы: + +- **Нативные шаблоны AVE** — общий шаблон сайта, рубрики, запросы, блоки и + навигация собирают страницу штатным способом. Это основной и рекомендуемый + режим; +- **Twig-оболочка темы** — файл из `page_shell` управляет общей структурой + страницы. Режим доступен только если такой файл объявлен в `theme.json`. + +Переключатель не отключает оформление. Подключённые CSS/JS, изображения и +переопределения представлений модулей работают в обоих режимах. Он меняет +только способ сборки общей страницы. + +```json +{ + "presentation_mode": "native", + "page_shell": "views/storefront/page.twig", + "page_shell_mode": "replace" +} +``` + +После сохранения публичный кеш очищается автоматически. Благодаря этому можно +подготовить Twig-оболочку заранее, проверить её, а затем вернуться к нативным +шаблонам одним переключателем. + +## Редактирование и ревизии + +CSS, JavaScript, JSON, XML, SVG и текстовые файлы открываются в редакторе кода. +Перед перезаписью сохраняется предыдущая версия. Вкладка **Ревизии** позволяет: + +- просмотреть содержимое и автора изменения; +- восстановить выбранную версию; +- удалить одну ревизию или всю историю темы. + +Бинарные изображения и шрифты не копируются в таблицу ревизий. Для их +резервного копирования используйте экспорт темы. + +## Создание и ZIP + +Кнопка **Создать** формирует каркас темы с `css/app.css`, `js/app.js` и +`theme.json`. **Экспорт** собирает только разрешённые публичные файлы темы. + +Импортируемый ZIP должен содержать `theme.json`: + +```json +{ + "format": "ave-theme-v1", + "name": "Название темы", + "version": "1.0.0", + "styles": [ + {"file": "css/app.css", "media": ""} + ], + "scripts": [ + {"file": "js/app.js", "defer": true, "async": false, "module": false} + ] +} +``` + +Архив может содержать один общий корневой каталог или файлы непосредственно в +корне. Символические ссылки, абсолютные пути, `..`, исполняемые PHP-файлы, +скрытые файлы и неизвестные расширения отклоняются до записи. SVG очищается от +скриптов и обработчиков событий. + +## Разделение ответственности + +| Задача | Где настраивать | +| --- | --- | +| Контакты и параметры оформления | **Темы -> Настройки** | +| Порядок секций нативной страницы | **Рубрики и поля -> шаблон рубрики** | +| Ссылки в меню и подвале | **Навигация** | +| HTML оболочки, `head`, шапки и подвала | **Шаблоны** | +| Товарная карточка и фильтр каталога | **Каталог -> Карточки / Шаблоны фильтров** | +| Корзина, оформление и кабинет покупателя | **Заказы -> Шаблоны** | +| CSS, JS, изображения, шрифты | **Темы** | +| HTML одного типа документа | **Рубрики и поля** | +| Переиспользуемый фрагмент | **Блоки** | +| Собственные CSS/JS устанавливаемого расширения | Внутри пакета **Модуля** | + +Модульные ассеты остаются у владельца-модуля. Не переносите их в тему, если +они обязательны для работы модуля. Тема может лишь переопределять их внешний +вид собственным CSS, подключённым позже. + +Файловая Twig-оболочка `page_shell` полезна для пакетных демо, готовых +файловых тем и специальных интеграций. Для обычного контентного сайта +предпочтительна нативная композиция из шаблона сайта, рубрик, запросов, блоков +и навигации. + +Точечное Twig-представление сложного модуля подключается только через его +зарегистрированный тег. Подробнее: [Точечные Twig-компоненты](twig-components.md). diff --git a/help/content/twig-components.md b/help/content/twig-components.md new file mode 100644 index 0000000..ab88bf5 --- /dev/null +++ b/help/content/twig-components.md @@ -0,0 +1,109 @@ +# Точечные Twig-компоненты + +← [К разделу «Как собирается сайт»](README.md) + +AVE.cms не использует Twig вместо шаблонов сайта, рубрик, запросов, блоков и +навигации. Эти сущности остаются основным способом сборки публичной страницы. +Twig применяется внутри сложного модуля, когда его вывод нельзя разумно собрать +обычными тегами. + +## Что выбрать + +| Задача | Штатный инструмент | +| --- | --- | +| Общая шапка, подвал и HTML-оболочка | Шаблон сайта | +| Страница новости, статьи или другого документа | Шаблон рубрики | +| Список документов и карточка списка | Шаблоны запроса | +| Повторяемая редактируемая вставка | Блок | +| Дерево ссылок | Навигация | +| Значение документа | Поле и его публичный шаблон | +| Каталог, корзина, поиск, авторизация или интерактивная форма | Модульный тег | + +Сначала используйте подходящий инструмент из таблицы. Новый Twig-компонент нужен +только тогда, когда компоненту необходимы подготовленные сервисом данные, +состояние пользователя, API или сложное интерактивное поведение. + +## Как вставляется компонент + +Twig-файл не указывается непосредственно в редакторе. Модуль регистрирует +обычный публичный тег: + +```text +[mod_search] +[mod_basket:mini] +[mod_gallery:12] +``` + +Такой тег можно поставить в шаблон сайта, рубрики, запроса или блока. Дальше +движок выполняет обычный конвейер: + +```text +тег + -> зарегистрированный обработчик модуля + -> получение и проверка данных + -> Twig-шаблон модуля + -> готовый HTML +``` + +Редактор страницы видит стабильный тег, а не путь к PHP или Twig-файлу. +Удаление либо отключение модуля не оставляет произвольного исполняемого шаблона. + +## Почему нет тега с путём к файлу + +Конструкции вида `[tag:twig:путь/к/file.twig]` в AVE.cms не используются. Такой +тег позволил бы шаблону из БД самостоятельно выбирать файлы, обходить контракт +модуля и передавать в представление неподготовленные данные. + +Допустимые Twig-шаблоны определяет сам модуль. Он же отвечает за: + +- набор доступных данных; +- проверку параметров тега; +- права и состояние пользователя; +- подключение CSS и JavaScript; +- кеширование; +- безопасное поведение при отсутствии данных. + +## Где редактировать внешний вид + +Управляемые настройки и шаблоны модуля редактируются в его разделе Adminx. Если +модулю разрешено переопределение активной темой, файл размещается в: + +```text +templates//views/_public/