release: AVE.cms 3.3 build 0.42

This commit is contained in:
Codex
2026-10-13 18:10:02 +03:00
parent 5dad4a1cf1
commit 86a41e4adc
91 changed files with 2937 additions and 497 deletions
+1
View File
@@ -62,6 +62,7 @@ AddDefaultCharset UTF-8
RewriteRule ^setup/?$ setup/index.php [L,QSA]
# Internal implementation and runtime data must not be addressed directly.
RewriteRule ^system/App/Frontend/Auth/assets/(?:phone-mask|auth)\.js$ - [L]
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.
+75 -1
View File
@@ -11,7 +11,81 @@
## Не выпущено
Новые изменения будут добавлены после build 0.41.
- Исправлен единый результат оформления заказа: первый запрос и его повтор
получают одинаковый сохранённый снимок для оплаты и чека.
- В ИИ-помощнике исправлен стандартный профиль описания товара: разрешены
абзацы `<p>`, при этом запрет `h1`, стилей и атрибутов сохранён.
- Упрощена вкладка расхождений прайса: компактный выбор файлов, видимые
фильтры по разделу, виду сверки, полю и сопоставлению, сортировка,
таблица товаров и штатная номерная пагинация. Сверка отдельного товара
открывается в правой панели; фильтры сохраняются при повторном сравнении.
- Исправлена переоценка матрасов с условной ценой при покупке с кроватью:
обычная цена больше не заменяется комплектной. Добавлен отдельный режим
восстановления только цен таких матрасов без изменения остальных товаров
и правил акций; предпросмотры старой версии отклоняются.
- В импорте прайса добавлена вкладка «Расхождения»: сравнение нативных
характеристик, описаний, комплектации и отметок с текущими товарами,
необязательная сверка с предыдущим XLSX, поиск, фильтры и CSV. Изменения
цен отделены от изменений данных; сверка не изменяет товары. Исправлено
распознавание несъёмных и неповоротных подножек.
- В модуле импорта прайса появился режим «Только цены» по умолчанию: гарантия,
РУ, акции, новинки и характеристики не меняются при переоценке. Гарантия на
обивку больше не распознаётся как гарантия на весь товар; изменённый файл
или режим обновления требует нового предпросмотра.
- YooKassa формирует смешанный чек корректно: товары передаются без НДС,
а платная доставка — как услуга с НДС 22%. Локальный SDK принимает актуальный
код ставки `11`, действующий с 1 января 2026 года.
## 3.3 build 0.42 — 13 октября 2026
### Ядро и панель
- Исправлены проверки готовности и базовая конфигурация сайта: страница 404
берётся из настройки, навигация сохраняет явно пустые шаблоны, описание
запроса расширяется с TINYTEXT до TEXT. Главная в sitemap использует
собственные настройки и дату документа, учитывает запреты индексации;
искусственная текущая дата в индексе sitemap убрана.
- Core-обновления сохраняют проектные `robots.txt` и корневой `.htaccess`,
включая применение старых подписанных патчей и откат. В стандартном Apache
конфиге разрешены только публичные `phone-mask.js` и `auth.js` внутри
закрытого `/system/`, остальные служебные файлы остаются недоступными.
- Убрана зависимость от PHP CLI при проверке сниппетов и заданий документов,
а также при пересборке фильтров каталога. Нативные и старые фильтры
пересобираются штатными сервисами с откатом при ошибке, без изменения цен
и исходных характеристик. Консоль и сборщик используют общий поиск PHP CLI
с поддержкой Windows/OpenServer, проверкой SAPI и без запуска Apache/FPM;
поиск команд больше не требует `sh`.
- Проверка PHP в блоках больше не требует CLI и временных файлов: синтаксис
разбирается встроенным парсером без выполнения кода. Исправлен отказ
сохранения корректных блоков при PHP, работающем модулем Apache.
- Доработан richtext-редактор: вставка из Word с очисткой стилей и сохранением
структуры, полноэкранный режим, предпросмотр, таблицы, очистка форматирования
и локальное восстановление текста. Исправлены переключение заголовков
и создание новых документов с незаполненными полями.
- Добавлены массовая очистка корзины медиа, очистка inline-стилей только
в выбранных описаниях и заметные очереди внимания менеджеров.
### Каталог модулей
- Обновлены «Готовность сайта», «Контактные формы» и «Источники трафика».
У трафика добавлены отчёт по страницам, CSV и график за 14 дней;
уведомления форм учитывают все формы, не только первую.
### Выпуск
- Пересборка сохраняет опубликованные ZIP неизменённых модулей.
При отсутствии исходного core 0.20 строится прямой патч от предыдущего
оригинального core-архива, старые переходы остаются в каталоге.
## 3.3 build 0.41 — 7 сентября 2026
+118 -74
View File
@@ -2,29 +2,29 @@
"format": "ave-core-package-v2",
"product": "AVE.cms",
"version": "3.3",
"build": "0.41",
"build": "0.42",
"package": "core",
"built_at": "2026-09-07T04:07:54+00:00",
"built_at": "2026-10-13T15:01:23+00:00",
"installed_modules": 0,
"bundled_optional_modules": [],
"optional_after_install": [
"setup/"
],
"core_fingerprint": "7896669cffd40910cf19bca24b6784de5653e80ca4feee3c182c309971f9dcb8",
"core_fingerprint": "9717e3250c604abd868d4149bce909b3df72fcc5e72b840dd1fe6aa238d527ab",
"files": {
".gitignore": "20d4fb177d47da6944a7a1c2dbf5918fd62766881aa52b00901f9054b1db4b49",
".htaccess": "71c06b7bc41b09c347a562ca72768acaa0ee1ef8388c749fc67956c793d8a826",
"CHANGELOG.md": "ae00742770c10c9ed32f028b7df22df1ce55627635569b38de6f2043e3bcd093",
"README.md": "19b820f1b092780039aae011e133dc6a1e5f1cf213b435e0ff1962b592b9095b",
".htaccess": "8f891b9bf25d536848f169a539d0a42baf5e9cd9599321542598874476ca3ad3",
"CHANGELOG.md": "4595b4f99447da7ff5915807bc2aa6320edcf42594d195a2f0203cbfb2f224a9",
"README.md": "0ef45b0e6da8dc25c4d64bbaaa2163ee5078302888e4515b75e60f827902665a",
"adminx/.htaccess": "d03ea2d2972af8b7545a2d57c6253f303d15adeb30b775e161d887ca5ff73378",
"adminx/Support/AdminLocale.php": "a7fe84d86d65c9f021106172b1789c8bc6221c36b2fe39cf883ca9a505e3e8ad",
"adminx/Support/BulkActionExecutor.php": "82859d10eec39a53091ed46604d6819e7111b7d879e5f0f1595c26f8a7ff1a60",
"adminx/Support/CodeEditor.php": "51532980b1990e595b796d884c1a8bcc46f77f88344386e5153b4bef4cae973c",
"adminx/Support/GlobalSearchRegistry.php": "7f0cb38a76ecb4c71af2891176406b1d46004c7b778e44cd2fce8367407e9da1",
"adminx/Support/InterfaceSettings.php": "752bd345a6595604b3ef87bd93bd0259a76f144ed887c7ff443a4fe53651494d",
"adminx/Support/ModuleExtensions.php": "e7925a287351ef338a3733551e36646cb914c3ce069bed5f3a0c24aec7596b9a",
"adminx/Support/ModuleExtensions.php": "ade2a624386485d051016758de17dd0420642fa1f2d3b529e0a17e10a976c2b7",
"adminx/Support/Notifications.php": "ab08805d571cd3d8a4b2a09fdc6ce96fb4c7b5ec75691322cf8a4e1a142112a3",
"adminx/Support/PhpCode.php": "a9b53565414c9784919ca06ce6d3926f939ddbb42b097f62bb8111b8d235dd71",
"adminx/Support/PhpCode.php": "d5b37cc684e6780919bb6e11188fddf17f1b27f9382ecd41da24bfbccd04c3db",
"adminx/Support/ReAuth.php": "f8c2146fb4b8f03b7d0cd2f3576e3f39fe2749823c5d6f1dca494268dcaba303",
"adminx/Support/Roles.php": "6be335dd643317f77bd7c2db92f25895e601fe72818befd62f6aeb3ecac9fce7",
"adminx/Support/RouteGuard.php": "e9e20c097c0db354851546585be72bfc71a5d14652871f8bd1160e845d4dab1b",
@@ -39,7 +39,7 @@
"adminx/Support/Twig/AdminTextNode.php": "e51d8d3ee49411f42c8a874cd283f60c1fa0af6506b34568a119ebe2d18d7268",
"adminx/Support/ViewOverrideEditor.php": "9704c974f36b6f9e204b6c609582d61b0a0f65aa182a3f1381fe7a7d1c0b15e7",
"adminx/Support/WorkItemContext.php": "d4b87b78ff1f2bdd89d77bc0483ebb46e5c11616635211ead9a43b34568e3a59",
"adminx/assets/css/adminkit.css": "5cbcb6bf1e506d35afa6ffccbbc56385c8feb31a5eec52af5054c315335b71a2",
"adminx/assets/css/adminkit.css": "073a02fb05b0b1d2f73d968d2f3b72dcedaee869d0b2a94c0548a164fc9a03b7",
"adminx/assets/fonts/NunitoSans-Bold.woff2": "2d48580b7e1be60fa94b8092ebe86471fe0adfe5ca75242a7af2042ac9da825f",
"adminx/assets/fonts/NunitoSans-ExtraBold.woff2": "66612ee9d1cdc341c9eec8a43104bbf515e7cc52821558bfc7dfcf33c8cb0cc1",
"adminx/assets/fonts/NunitoSans-Regular.woff2": "878adc25278872933f004783f0ebbe092cebb3f9161aa30cffb2b9a8a07db040",
@@ -51,12 +51,16 @@
"adminx/assets/js/bulk-actions.js": "d7ee80678e6703aff2418a4679ba8e5027e7220cb089848bcced6d9f100b3aae",
"adminx/assets/js/command-palette.js": "f62f0bad5ddf2b6e813e7eda094d73df63dce09a6e581025971e8f10430e8d9f",
"adminx/assets/js/editor-codemirror.js": "90a04c219e84f5391a40b0d1fc807ecfe4ca582a198bc202bdc5aa6dccec51fa",
"adminx/assets/js/editor-tiptap.js": "d5c7596bb17de87cb061c18daf629f322d03e2c81be19dab78897e93691d52de",
"adminx/assets/js/editor-tiptap.js": "c3e79ed4fb4635b2bbdaebfa8ec8680c3fe1b64f3dee48354669d3baa5a29b07",
"adminx/assets/js/media-picker.js": "74481279a7d352c7c72893b4043e02454ff3cc28b6582c5463dfa712b17e23a2",
"adminx/assets/js/saved-views.js": "30c02121634f6f035dcff6a730e6fe300a1892fc9321971f142dcff600802f30",
"adminx/assets/src/_rich-extensions.js": "bf6cdd7537852f271aa79f7f56a733d1bbb18b7340c3a81bfdb101cd56fbdaa9",
"adminx/assets/src/_rich-recovery.js": "b9cae994ab21debae2d1194e1f48018aa1067401617aaac15800a58264a06bbb",
"adminx/assets/src/_rich-tools.js": "01674cafdd98ed8b718fd0719d2b5e5eb681da7dd9dc2d18b57ccb35948d5e5f",
"adminx/assets/src/_word-paste.js": "06671edab949ba84c8599cd25f4fc21923db3d2a1e98a02e6ed22d0b8610c816",
"adminx/assets/src/bulk-actions.js": "29bcc568366ce4a86a71b12a0f8be050bd71a9eabb0f65af6d9ca57281db9201",
"adminx/assets/src/command-palette.js": "ff86b0dc0f1ad59d551af772643dbe094cdfb2367d402eca49fd1a4c5b1bb9db",
"adminx/assets/src/editor-tiptap.js": "1cb23f5262b50aa8ac0fbf19d98b77ce063af966f47d9b2ab6b0e5e3fadf28ae",
"adminx/assets/src/editor-tiptap.js": "19a42915a34a5efcce867da59eeb442c15599713d1c1c4b15d85a64a21c4489f",
"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",
@@ -142,13 +146,15 @@
"adminx/assets/vendor/tabler/fonts/tabler-icons.woff2": "cce741fcb6c526dfea3c0d48ff486279ac723617cd5b4de2bd5d726e94cef477",
"adminx/assets/vendor/tabler/tabler-icons.min.css": "676509f34c06d34b75e4227e4c36105e5d220b458f609fffec247f20a8d72a8d",
"adminx/core-permissions.php": "084fbff2f7625f0e1b6a6a15d00fce536212adbdf40f6a7fa2eef34cab54394b",
"adminx/index.php": "267b4eb5ee65f64e1388acaf6e64ce27608b4b405744ff4fed6eda5f656bc7fb",
"adminx/language/en/client.xml": "7a6a03448ca15df594f38e3ccb63f11cabc9c3c5ce301022074a14490e3dbee5",
"adminx/index.php": "4d1796147aa9d0cd32604e85517e1c5937755fb0a979f4a346c4088d68e0a2ec",
"adminx/language/en/attention.xml": "93824acb0f746a10620a09cb44fbe7aec4e029cdc049afadf1a9c81633d575d8",
"adminx/language/en/client.xml": "3b7e8ded4253fd3e28199564563f8f61506e16a3d15373cb2989b4b2e4ab907a",
"adminx/language/en/common.xml": "e5f9972786f1d2a8e05f86913b07c361b82811f99cf51bee6d38e9ed275fc995",
"adminx/language/en/interface.xml": "80ccb298e1ae8ea31c533832072b99c1eb1369871f6d1b357635aa59622d9d52",
"adminx/language/en/runtime.xml": "68b00572d69484240e1716007ce64c2febe66cae4e021b09c428e758de990ae2",
"adminx/language/en/section-help.xml": "f82ba8d3b78d06d26b92afb81e036b241c8dd3ef9e4ded6158a0be10491e90b9",
"adminx/language/ru/client.xml": "606dac2a0fec6fdb5fbd4ca8201d1fef5f6607556a27d249688646c3dc595fe4",
"adminx/language/ru/attention.xml": "fc1bf362f801ddfa068ba853abf216fec5c35e457ec2170667999aa18e73d387",
"adminx/language/ru/client.xml": "3be6c0998f4ac1d624c7d8d5861fb7468affa6634f90b35096d4597d6d4de836",
"adminx/language/ru/common.xml": "d27d863bf82591dbe857317d97a888a23980c87529e2b4c1d4125a6c0ba20966",
"adminx/language/ru/interface.xml": "03fa6351745d2e47c2b3407da65c8b7b048b8e862409dc9bb934e939b9439c75",
"adminx/language/ru/runtime.xml": "3c411a18695af7a63dd0685a8351ac3a51695aea7d1fa2550fb24cc9e3e82be6",
@@ -164,13 +170,13 @@
"adminx/modules/Blocks/Controller.php": "5665327885171e07f87823b473f97e22f416ab896471a25ff7a2dff498a30d22",
"adminx/modules/Blocks/Model.php": "69dcdd7488f308349741bff0911066c5f1ceace23216ea408fbc79da4a73bc14",
"adminx/modules/Blocks/Revisions.php": "9c820024215c0760ba93fbed902b9cbbfdbe402fc9ac514e9908d06fcb794817",
"adminx/modules/Blocks/Syntax.php": "9b2eb8d40171633cdbef6cf21545958f801f4983d62176e9256bec8e4763a624",
"adminx/modules/Blocks/Syntax.php": "5e1d9a4f4eece94db48734ed6a3866729adf57100c4aa345cd70ac1a393741b0",
"adminx/modules/Blocks/assets/blocks.css": "13ec7cd2b5a9c2abda0ff2d1419fed50dc1e122edf8ec278e11cb12d75b8ff38",
"adminx/modules/Blocks/assets/blocks.js": "22d512d111a1f0dcae0a649bc1d49439c733151e87382f55dd5d5d17863fa61c",
"adminx/modules/Blocks/language/en/interface.xml": "6b358b561f81febe1a3284045ff20132737e624a04b2481b51681e258f748e22",
"adminx/modules/Blocks/language/en/runtime.xml": "cd76ddcfb5751496dc4cc160dc2c6f38ae5699a3a5c0041b5fb214fa434f04e1",
"adminx/modules/Blocks/language/en/runtime.xml": "021a5840f5d41b315663c1961c9df93c377ecb1dc9ea2bbac0586bd31991ea57",
"adminx/modules/Blocks/language/ru/interface.xml": "6048c75a95fb474287cf4903fe1b82f2608741b355134be885eadf511da5b656",
"adminx/modules/Blocks/language/ru/runtime.xml": "594a41e03eb86238ad18a45dc5c0f71b15e8f8eecc2a912e3f9fb5439bbedc4c",
"adminx/modules/Blocks/language/ru/runtime.xml": "918f9471b440c7b3eaee676ee0d59e93fd4e5c34928828f02d29d09234fd4245",
"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",
@@ -183,11 +189,11 @@
"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": "53eb434566d820814f5b25382200b75ed02b0067c066c8f8cb62bee13529b964",
"adminx/modules/Blocks/module.php": "d82b66828bbd549eb043d177b6f7673552942eb23220ca156d751d0074b251a1",
"adminx/modules/Blocks/view/index.twig": "39f9225b6a95c65549d0c861afb7db7879ca338c251290653dded6ab7cb715cc",
"adminx/modules/Catalog/Controller.php": "f9bf3bf72e15cf0b886157a9b421c4f6a2364b35a70464e7285259a151d51bbe",
"adminx/modules/Catalog/EditorContextLinks.php": "0319a6f237aa3aa627aba1f44a3efe4c6de007b26a9e75ba0df6b3b2ccc5478e",
"adminx/modules/Catalog/Model.php": "937cec97bcb67aad4ce4da40e9da2b1ad691de6969ae5b12bbec2118b997653d",
"adminx/modules/Catalog/Model.php": "cf86b67f956ae1c693e25133d2959a3bed0fdc6e254bde580ff6418f72641f44",
"adminx/modules/Catalog/assets/catalog-drawer.css": "27af3ab3f1b3cfe6425467528350489a870388b34427a79b0b2c4e94af9f1cd6",
"adminx/modules/Catalog/assets/catalog.css": "49e7a33fc444f8330806e1cc5cbb0d523b673c64faafd25dbf0fe33067643efd",
"adminx/modules/Catalog/assets/catalog.js": "fe5b3f7df7c8c70348badc433dab5b91268a69bf081a2bc87e52dd253923fad4",
@@ -206,7 +212,7 @@
"adminx/modules/Catalog/language/ru/runtime.xml": "fe59945f4ceb2bf2d85699623369e7e5889613b8cecd8551a390c78023531d14",
"adminx/modules/Catalog/migrations/005_add_catalog_purpose.sql": "006ff237e25042a4fa9b9f74d5079741eeee1aa52db0cd996ea4c8b817e6700f",
"adminx/modules/Catalog/migrations/006_add_item_sources.php": "97a023140d1380ef2ef6a2ab1dd1a86b0901689fa2f1ccdf0fc76608dd18e546",
"adminx/modules/Catalog/module.php": "f393b86b20c962f5262840381a397533e34e4c7f5b8bb786ae9aaa6de70f5d05",
"adminx/modules/Catalog/module.php": "a6e75ae55c4be87df1c752b55b31a9efa1553ec082305cefa536e52c0967655e",
"adminx/modules/Catalog/view/_context-links.twig": "fa079e6dbc48c87a901dcb9272b00728b0697884c6607ba93c36fc56bef2ea5f",
"adminx/modules/Catalog/view/_product-role-preview.twig": "9c3b5ce52dc23fe5af3acf682ee1470a07bef1bd8cee8cca946fb36745a3f026",
"adminx/modules/Catalog/view/_product-roles.twig": "905b2ed079e74e481e0ae7aa9782cc2d339dda75109be097b3994aef85a9571a",
@@ -215,7 +221,7 @@
"adminx/modules/Catalog/view/index.twig": "754c7c4dc81176e8c99b3d9e8c1fda2f62f03df341c3df1f7393f72d43c6f8e2",
"adminx/modules/Console/Controller.php": "af0309a50d7f4366279be8ea12959cf7718a3c68b895a65ec5846554fd6a4857",
"adminx/modules/Console/Model.php": "53aad480b8d4e5d68c4e9345d219ea5cbff6d04eee7ce4aa15deab6f00dac1cc",
"adminx/modules/Console/Runner.php": "6c8c216003d8228b091683acaae99791f612a93d4150542c2ff52c67dd029a33",
"adminx/modules/Console/Runner.php": "b6685e349cee269037b7fc8f366da4324c6bca6d37dab2dcb7f46a2726211e63",
"adminx/modules/Console/assets/console.css": "e2645bf876bfeafc15e6f4e5ad9889adb773aea35ddc42ff151d8ed1f8866eef",
"adminx/modules/Console/assets/console.js": "f0f2cec892c4b5cdeb11730bf42e9cf68ff779974338086cdf92618ebcacdb10",
"adminx/modules/Console/language/en/client.xml": "02bef78a30b83340eb8968297639b6283dc642236e853bef413e5b7d2ef70315",
@@ -225,7 +231,7 @@
"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": "c6c883cc51ee1a27460f71eca88edd44726f2be493934b84adeee1b7d8ccf8dc",
"adminx/modules/Console/module.php": "f9f3ea405583e293be5c96c33c11a0bc70da996e822dbdc7c803d656d762a144",
"adminx/modules/Console/view/index.twig": "54f877d5feea19bc4f4b618eea7cf7523a2266bb2e3909f3fb723edd16d4a326",
"adminx/modules/Customers/Controller.php": "0d9b8dfaf794c378ef8da5ddf2084e461b54ce0ed7eb490bda62c30574f05763",
"adminx/modules/Customers/CustomerCenter.php": "af34954e6330bc9036c04fb56b439b143f591e95b83124d95a034d87d6870bdf",
@@ -252,8 +258,11 @@
"adminx/modules/Customers/view/customer-center-detail.twig": "2239602955041c4eebe704c70e835fd45272f721abd28bf254904901bd77a4eb",
"adminx/modules/Customers/view/customer-center.twig": "223e657861275fbfe482ce624a6216a91e8df893cd1c5fe58a2999606468addf",
"adminx/modules/Customers/view/index.twig": "20868f4edf7f9ca7770a733d4593eb56ffb225199940212d60aa709f2c760070",
"adminx/modules/Dashboard/Controller.php": "7e6ad0610df7581bb010cee574d8d093c4737af439bd2b0763142f50ddee1c54",
"adminx/modules/Dashboard/Attention.php": "300eb3b32a92ce14e7f3260cdf82824949bfe4dec799f5dca63ea6e0293877ec",
"adminx/modules/Dashboard/Controller.php": "3f061b93f22ed95b2382e4ae749edab812c675dc979946a27011e91ad4bd28aa",
"adminx/modules/Dashboard/Widgets.php": "56fc7ba7c1f9e30d7eae8c61524b6441b1c706a2cd56b1db883bb8bff7186eed",
"adminx/modules/Dashboard/assets/attention.css": "da72506b21621dfbfa16b1acd46c2afd562a2b3b29c7be51999849fe74dc2066",
"adminx/modules/Dashboard/assets/attention.js": "f6625ed4585f00e698dc92e04c9dc5ba989a2feb5f1b590fc5e0d732c9654e6d",
"adminx/modules/Dashboard/assets/dashboard.css": "8e28b56cac2e55d9b57304ea6e265fd94a52400674c082fbdd78f1eed1872424",
"adminx/modules/Dashboard/assets/dashboard.js": "ac683db96673fb4f70b8cb2d23ab7eda2251f96cc2852223f5ee68bd11a911e7",
"adminx/modules/Dashboard/language/en/common.xml": "10445e46f7445366125f761c2d29567637d6d2c9ba1e9766136280ff28c3d465",
@@ -262,8 +271,8 @@
"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/module.php": "b46cd918395bc2cbef1cf6979f95e7b63c9f33ad9d45a4340b1cc727faf1db3f",
"adminx/modules/Dashboard/view/index.twig": "92ae9d59eee15ab437b2884d1c5cf7f5ee5734d8f6e4625ca8bcb17feb900af6",
"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",
@@ -296,22 +305,22 @@
"adminx/modules/Directories/migrations/001_create_directories.sql": "d1f84fccf75892fc1dcfe6b770112101e233d96d9f4866a08d1977646a690530",
"adminx/modules/Directories/module.php": "f6b7f0bfb96d1c2c7aa52c51e60abaa09cdba7a4309d8363394a3a02a8df96a0",
"adminx/modules/Directories/view/index.twig": "0bde23bc578b9ffad3c47534c304c0016b661a36588d6d3c9ffb92e2daa388a0",
"adminx/modules/Documents/BulkEditor.php": "24a2c33a50e5025e4f8a15426bf8d4f6ac21e6574ef636f30fb5c6fad0249133",
"adminx/modules/Documents/Controller.php": "283aec04707c7b7fdd01e2ba69c1961b925f259655e9a455889bdd5dfc66f4fe",
"adminx/modules/Documents/BulkEditor.php": "64c010ee7ce1b79d9c1af90d0b06d06a252a25764ddc1b61472f6b8bfcbee399",
"adminx/modules/Documents/Controller.php": "aebb52d4dfe9ae449c620e6f26f76ed64af6260c8d65d122fe40cc63b65ee50e",
"adminx/modules/Documents/DocumentHookRunner.php": "eb915de91391858987386925da37d943318fea1f46893de1b3fcc0957ce43e07",
"adminx/modules/Documents/DocumentMediaReplacement.php": "e97d960c852162e655a4389cc5f477f0178597da50e733b00bdf5bcab076c65d",
"adminx/modules/Documents/EditConflict.php": "ebc3ebb92c3fc3ccf3909d2ff517014fc888ee20576a520b4979f68bde632b97",
"adminx/modules/Documents/Model.php": "ff03bcfe54618d1b83a6780c57cb86816ea25879351f1beb4e847cbb1ae4c9a5",
"adminx/modules/Documents/Model.php": "5f1bc233f99e55a91f48a78c66a05e1df002f05d190cb6ceb15f00380e2c4a22",
"adminx/modules/Documents/Revisions.php": "f17f0f7ebb4fc3ccc44695e4a1ca7093dfa3f4fe3f9c895e17fa8631567769fe",
"adminx/modules/Documents/assets/documents.css": "88c0911854e90ed0d71b005ec95560520ba8db2f44c8e3a457c074a3b5aa323d",
"adminx/modules/Documents/assets/documents.js": "9d0abe758f84ed6fe4f262c335628ffc07678a012b579aacb5787a250cac8065",
"adminx/modules/Documents/assets/documents.css": "378ce4712ceeb9e9cc899d81c9fc13848b7b288ad68fbc1eb31e9a5bdc5dbad8",
"adminx/modules/Documents/assets/documents.js": "9d7bddf7832f3187a4b04c494c01fea983854089d91cb779453f7ef102c5e483",
"adminx/modules/Documents/assets/redirects.js": "95ca395c6b4fd3814da37450c27da351e75f509e26421c975dd8e0ee5f657efd",
"adminx/modules/Documents/language/en/bulk-editor.xml": "b7af92bd151c7899a56cc461f4a09edaa842213cb6e6b4b4363af31aee228e50",
"adminx/modules/Documents/language/en/bulk-editor.xml": "ddbb6c41d98ea5513c3359c4d8b818b00a15294100c68285e38c8c3954a04391",
"adminx/modules/Documents/language/en/client.xml": "a139276798beac506ad56539ca0ead06e7cd7dc69149c99e04d6cac12d72bde6",
"adminx/modules/Documents/language/en/interface.xml": "87b858717419a48ed12e1181183b8de0285483df4c9de37403730f8a7c034b52",
"adminx/modules/Documents/language/en/runtime.xml": "7009fa27d67ab168fecd9d9bf182216dfb0397d9c006eb6563ee3c464daa5b5e",
"adminx/modules/Documents/language/en/zz-interface.xml": "839ea37d585ff10d7b819a4fcd4bda876c94d51019489cc8d35af15fbc905f2b",
"adminx/modules/Documents/language/ru/bulk-editor.xml": "29500cbda63189ed9a0afdb57b20f998241594b254d0225a776a8be32d108834",
"adminx/modules/Documents/language/ru/bulk-editor.xml": "72cba2efceb35b0d22eb7bdcefee08c173fd6facc784abfc1a47273a41fa442a",
"adminx/modules/Documents/language/ru/client.xml": "e265a5a3c8f6bf973dbde8f3f8970ab9cc88557cd71df6449f3b4a93dbbaffec",
"adminx/modules/Documents/language/ru/interface.xml": "f8d60edba8ac5b6d0eb6deaa3b9b89733cd0b8d3eb951c105a9874ea6769c305",
"adminx/modules/Documents/language/ru/runtime.xml": "6c3e9a2dbf862bef5a76ff0bb600084fcd9d5d1a7f0cbfd02377242b74fcd759",
@@ -333,11 +342,12 @@
"adminx/modules/Documents/migrations/015_document_creation_presets.sql": "a123f8867ab6f6346002d08317acac37062c2792913ad14102c81812d45571bd",
"adminx/modules/Documents/migrations/016_reconcile_document_content_columns.php": "06671bb26bf29479dce24888fb3712c0d20e2aac5b3d70196604ce4097c875fc",
"adminx/modules/Documents/migrations/017_document_public_visibility.sql": "2a8298ade4af686a4aba41bcda0aaff5504c40f3140bf3ae710e8be727f38e18",
"adminx/modules/Documents/module.php": "81a2c35cb77fc142972a40d33e3fba3f712a6af886e3b4538246f1058f8ec38b",
"adminx/modules/Documents/view/_product_panels.twig": "d0e681a94f6e48c061dbc61cc47c384bd52c4eb66d648e36784b68d0c8ada603",
"adminx/modules/Documents/module.php": "d3876535fd0e7c08f124a20a1f00693955730e3908af98efdda1f0268cef0fd7",
"adminx/modules/Documents/view/_product_package_fields.twig": "04e592a9b54d8c1488ef3980574e812fc714fbd4814b8573a68513f34b6d001b",
"adminx/modules/Documents/view/_product_panels.twig": "3f047cbebbd81326290d47751454cd3e8e718c1246120338fbd399ec81b41ae8",
"adminx/modules/Documents/view/api.twig": "a035a2d566298587ce8530c98915e80e4e7f9b0f41a60e0e55d4547b996a6c40",
"adminx/modules/Documents/view/bulk-editor.twig": "270d6a2e52aa6aa8efed73b4b5c311e8e8f7d8e0cf82ef1e510e7e6fb4d3f029",
"adminx/modules/Documents/view/edit.twig": "cb4b983e868912bd3d072e3cbc6b57f21616e7d35cabacec7dca8b9e40ce09ef",
"adminx/modules/Documents/view/bulk-editor.twig": "17aa7b3383f4943f2304136a823f318d9bb02f44c1a6a749c141971f2e6c8296",
"adminx/modules/Documents/view/edit.twig": "41967969a80e1bc5e404886074b24b18470dbdad44fd0e735ba9cc79da17312e",
"adminx/modules/Documents/view/index.twig": "ccf2bd0d384091f8a98ead4e93a8ad389a515176d17e5b5ee661e7e3ea0908de",
"adminx/modules/Documents/view/redirects.twig": "4a4903edc81e4770220852d9be9f02a983ddde6f829a4360ea01d3b68456582b",
"adminx/modules/Documents/view/views.twig": "821f19085ba3997b6ef8f005e838f6fd449f0b32bce0d2804271311856cce8f0",
@@ -387,28 +397,30 @@
"adminx/modules/Media/ImagePresets.php": "8874cbf436a1a33f655c9a395b5f16e143c04c7df1d957c43e3d4ce3ae0645d5",
"adminx/modules/Media/MediaAudit.php": "2deeacd899b4410cc620b40b6e448c388f54eb553b5a57a23475ac0b5cae02f3",
"adminx/modules/Media/MediaSearchIndex.php": "7625627ecc00b74ea75aa1818dec55c468515c966dc00333fe1f2706eef154cc",
"adminx/modules/Media/MediaTrash.php": "3251e6cccd092d0c04c0cac70838c070210443c87dd0967c84a4e323bb15c5fb",
"adminx/modules/Media/MediaTrash.php": "308e94caf5fc8213860d56c235444421937a5d6a5a25cda55d9a490d0165d7e4",
"adminx/modules/Media/MediaUsageIndex.php": "d39d232bc56352c9cc2335a2e3bd2a7b7f415709c70ece4b38f1a462c2969415",
"adminx/modules/Media/Model.php": "1ac78ca828c6f492857e731a715d54939525dd29545bf81fb4065fcc02f0bc50",
"adminx/modules/Media/assets/media.css": "e7db9f602bc1a46644ccf172f977ebe0798c572c73d33f64584c9569d531611f",
"adminx/modules/Media/assets/media.js": "2fdd0c2541150e5c2bbb4ac3ef54f1f40edc029d48383297d432af0431607111",
"adminx/modules/Media/assets/media.css": "aaaad15f150c7b1388a6a3c5eb1f124d01d6d58517ad9d6cf0c92c54f9e817af",
"adminx/modules/Media/assets/media.js": "43687587d315612c6ae4e2910688b724b717cbc01ea191345fe94ad6289bc717",
"adminx/modules/Media/language/en/analyzer.xml": "5c6e8bec29fe73d0403cf041c7580800ecc2677d94f2148ffaf44c04d337c6d4",
"adminx/modules/Media/language/en/archive_processing.xml": "567d6e3571a36d9d75324b1b59cf7e966ea6eb00602eaf5cc57876cbc5536c21",
"adminx/modules/Media/language/en/client.xml": "06cecdb6cacdd0871334a3da0a3c7be27d7f835e2a048d37ea31ae4f37469270",
"adminx/modules/Media/language/en/improvements.xml": "5873d8eb9c43db0a584161ae3f086dfd101a8df25bbd4d518a2fd283f4f8cb3d",
"adminx/modules/Media/language/en/interface.xml": "2eab0a0d6acf094c43c690e4c09683378a26dfaec7e258c23f3f7bfb1fc9e9a7",
"adminx/modules/Media/language/en/runtime.xml": "8768433d736f0e8b3508b1a68de1281b2f841c2a9decbd8195603a31bff3a92f",
"adminx/modules/Media/language/ru/analyzer.xml": "c0102f4d337a3279b412e56e508a1d8d4b1196bdcebecd46bc3e95e56ba520ae",
"adminx/modules/Media/language/ru/archive_processing.xml": "1ce7412716cfceccfba36f346f972d0c3f7a936da13b5acd9bd5bccd5baf9497",
"adminx/modules/Media/language/ru/client.xml": "272d1eddafc77c79533be4fa89d5daa2d560c1860bc2bab4a1fbf6d609849768",
"adminx/modules/Media/language/ru/improvements.xml": "d3b92183909c2159debe29d4594544129f3981d253dded9f20704e0394b158a5",
"adminx/modules/Media/language/ru/interface.xml": "743b1b2e5f498ce05225aca69f866abe2f24765a260baa9d81c1947954029fa9",
"adminx/modules/Media/language/ru/runtime.xml": "538db360abf2d61e32d6476846173ea4f5115703021362314a9100df3854d6f7",
"adminx/modules/Media/migrations/001_image_presets.sql": "303dec5f331135ab3fda9d264b7a7b674b987cdefa0a7904c544a5d63554f15a",
"adminx/modules/Media/migrations/002_media_search_index.sql": "e84168429acdd900d494c91e398a8974ae97023d8bee3385321c2947dd627378",
"adminx/modules/Media/module.php": "45634e3fc5bd65c1bf545d1aa79912692a38297d390288d956af0f646dbc8c1c",
"adminx/modules/Media/module.php": "8e602a4b6e18d759683ec05122e7ecac41ec1d317d993f7246d42c3f3ef75230",
"adminx/modules/Media/view/audit.twig": "06936cd87c80b5c7f6ecec8729f227c2ab3fd492e61b11ae7040e6423939b518",
"adminx/modules/Media/view/file.twig": "b317dacbc61411831a9029e30fb1ff3457fa1d34164df3b891b6a9a2fdbe32ce",
"adminx/modules/Media/view/index.twig": "c3a4c42477df3a10fcc8ee558bb03fe25782a58f6f47734cb078686798a2d06a",
"adminx/modules/Media/view/trash.twig": "991f5db2deac791b9d378d2c06bc85d69d4afe3960202c57929e335ce4fd8a90",
"adminx/modules/Media/view/trash.twig": "1ec8e74a26ac229d0f0a17e77ac400bf0d5fa933848a7bee8e45ed38ee81721e",
"adminx/modules/Modules/Controller.php": "de3c800b32a05ab41dac31d4964242fb0635aef6ef32f69a332509159b8e755d",
"adminx/modules/Modules/ModuleArchiveInstaller.php": "22f015a6695de9f4fea12a580adfb76837e59ee4e9de92c153782c5a40a97560",
"adminx/modules/Modules/ModuleRepository.php": "29ced1e9d5c36dab4f0018c10fab1b80282b8d225805bf13f075e148016c45f9",
@@ -425,7 +437,7 @@
"adminx/modules/Modules/module.php": "710e4441a369d89c29e99a68f94b591dadb2b3df1d951ca6dcd16385b428941c",
"adminx/modules/Modules/view/index.twig": "2ca402c0886f611129e884129f6e0b0022e14a8c385d5d4ad55abe49ee39f5c2",
"adminx/modules/Navigation/Controller.php": "968da27af968f5f6e29b0e015f4c5df438c17735daf646a9d6343c055acfd48c",
"adminx/modules/Navigation/Model.php": "58d08733e246f92abc02d9ccfa74b88627970588fd2223688a59d03a4d6c4b87",
"adminx/modules/Navigation/Model.php": "1f181b9e57ffb34c65967678da115e0edac194558ce11e020e61b2b12673d69f",
"adminx/modules/Navigation/Revisions.php": "165a2e2d8ad218c1437fffbed9297b7be25d0ae6567f0d72aa78da872cefa1b8",
"adminx/modules/Navigation/assets/navigation.css": "e7ad029b253b19546ed2c0eb2a3527dbb3b03a56db13c6f3089c3da9c12933ca",
"adminx/modules/Navigation/assets/navigation.js": "72d68867419fab1b3e7e822a42a7fbcd866ccdbef8fa3051a245e23f23f5ec30",
@@ -437,7 +449,7 @@
"adminx/modules/Navigation/language/ru/runtime.xml": "45d994c39fa7fa092525ed4a61d6775008f3993f4cdcdd615d61e5f15a749cff",
"adminx/modules/Navigation/migrations/001_create_navigation_tables.sql": "43c937b8d7d7f5b70569c591db58ee8592386b988e8cf148fae723b1f68b3012",
"adminx/modules/Navigation/migrations/002_navigation_revisions.sql": "cf24c9e9cdb95c2ba4ccea63ec4375d62f29370d5ef909a82a0c990624b8ad02",
"adminx/modules/Navigation/module.php": "5eb07314b6c156893288f1616e248ac316ae3a3b561c996d63e772e4ea4e8b6f",
"adminx/modules/Navigation/module.php": "bbee9c86df3b2f00abb4f172ed4cc80bef406709066d1c95f1c6ed735387ecfa",
"adminx/modules/Navigation/view/index.twig": "b7dd1784238fc53456b1676a7bfd4d14c2d1efdd7f15cee34c2c4613bd0e7590",
"adminx/modules/PublicSite/Controller.php": "8c00240eca08149df4572732d1e8b03eeca5910d40f03c2742021035f16aa621",
"adminx/modules/PublicSite/Model.php": "912fcd71f2ba7215fc9a411882c1eb5e0953b6ebaedfbb92f01b7d3de477a015",
@@ -485,7 +497,8 @@
"adminx/modules/Requests/migrations/012_condition_value_sources.php": "c874ea024343e4d698cda7a138e13b23776a58f51706c2a16102ded1234b2aa2",
"adminx/modules/Requests/migrations/013_sort_rules.php": "4c58c56d9766c16517bc2ceee1d6313b1728e564a332fe9a5cdf69a04d7afe70",
"adminx/modules/Requests/migrations/014_request_revisions.sql": "1149785b788aeca0e6e074dbb0251f92e3813b4083170b7731348a7f8edf6bf5",
"adminx/modules/Requests/module.php": "d18ef457c854b5d223c731aeac386a806c14bc6fa0941d9f79911f2b15334060",
"adminx/modules/Requests/migrations/015_request_description_capacity.php": "c09090d46dffb50aa6020f9bd8b31656d64b04e053769068ba85539584d78764",
"adminx/modules/Requests/module.php": "e9c546b1ada48e01a518faa3bdbc001ad3d8d86a5cba893f04bc2544026e6e4e",
"adminx/modules/Requests/view/edit.twig": "b4ffac2dcbdc0895e00befe395c29e3c9d71bfe47852998e89f6ba8fe4e0d0a6",
"adminx/modules/Requests/view/index.twig": "164490c16e041b928e1ecf604915656399690055d1f7de4bc88caf5f5abd485d",
"adminx/modules/Requests/view/native-audit.twig": "42120fd41aa4afa39560fea3f2625a4e78a61cb4f9ccc0c0388ca319d4f39c06",
@@ -500,7 +513,7 @@
"adminx/modules/Rubrics/FieldEditors/ListFieldEditor.php": "bc1c7ead2ed6666f7861418ed2222166a44870edc5efb401226a5b9d80e549a2",
"adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php": "e63ba6ea4c569ac5237f057ee722e5a936a2235d670eb29fdcf171f61a95e3ad",
"adminx/modules/Rubrics/FieldEditors/RelationFieldEditor.php": "9504549a3b81893ec8a114a286a960450bbd302cb2f8f9af97660c8332f91925",
"adminx/modules/Rubrics/FieldEditors/SimpleFieldEditor.php": "e961e7ab2bf3a16d6d9131513f11d2d4b9520ecee5572ae7e1b7ae8475d1cacb",
"adminx/modules/Rubrics/FieldEditors/SimpleFieldEditor.php": "a40e6d4e0cc8c3b9b937328c12f3d4ec82feea217780dff3ef5c4df9469bf299",
"adminx/modules/Rubrics/FieldTypes.php": "c6cc64b0e1b1fb29b65275d337e5747b9f9f68ca46171e7e7f07129cd27b8409",
"adminx/modules/Rubrics/Model.php": "85aac47a064c1d2d95cc3e620e106c03989ebe9cf237cb4516253c338e2f49a9",
"adminx/modules/Rubrics/RubricFieldPresets.php": "80109bd9f066f077ad19e48d7c7599e4dbf7b1d0c06c54db9fc921992bca2e77",
@@ -533,7 +546,7 @@
"adminx/modules/Rubrics/migrations/015_merge_directory_permissions.php": "648b3e232e9426f84d69238295515342e42695e46288b901950cde2e8979b0e6",
"adminx/modules/Rubrics/migrations/016_rubric_trash.sql": "09385e8d14d5d4d977db59c60035338404019783cc44c6c784cc2172faab08f0",
"adminx/modules/Rubrics/migrations/017_rubric_public_visibility.sql": "2a6ef9686235b453807701c24d1cd8dc81ddfdbb80824a7ff10a70ca3f949827",
"adminx/modules/Rubrics/module.php": "a7ebe05a45eb4270f057f2d59f717b2f06df0350a0e86041f2319c70895b9a5b",
"adminx/modules/Rubrics/module.php": "361f4dc64b887dd12efb7901c4967b54b7c0aff13e52583199e5191298209e01",
"adminx/modules/Rubrics/view/_content_tabs.twig": "e4c0a9b899b5085aeaec2e1793b8eeac4a7619959fffc7ce39151af1250e0d9f",
"adminx/modules/Rubrics/view/field-sets.twig": "911dd7cc9546411e1b29724466328c7d2db8db630b2b52d33e8443a066b16810",
"adminx/modules/Rubrics/view/index.twig": "d25a68bbbde3bf2da618c465cc2ce6cdaa70b557388db6633c719e0d6deff163",
@@ -617,7 +630,7 @@
"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": "d3507184cdf713263cbeb20c6ac8761c941ac021853dbb40f27a8a851e65cd3a",
"adminx/modules/Updates/module.php": "6c377451763584d00f93eaa53505e85152da9549d8573242fbfcfa13a8d07c0e",
"adminx/modules/Updates/view/index.twig": "8d975051ca96fd99902714bd60b6a85131d6304578ea65a3dbf732523b638ece",
"adminx/modules/Users/Controller.php": "dd8e11ec359064b37437057f0e1295dd1f20239186d5726119ee0764bce2fd10",
"adminx/modules/Users/Model.php": "afd334340bbbeed9ac638421bc490262a21173007734740d2724d4ddc8cace63",
@@ -634,14 +647,15 @@
"adminx/modules/Users/migrations/002_harden_public_remember_tokens.php": "85c92acd3ed962dfc4bf85b78e07de87569b1f0aacb2c616287c95bb39fbee2a",
"adminx/modules/Users/migrations/003_system_user_security.php": "6e2cd00d63dd7c050c0a578fde2031764d52a697a70b711ddddad1e32dbad917",
"adminx/modules/Users/migrations/004_system_session_expiry.php": "00a1012683681fc5fbdbf3cc6258001ab2c6377f445f7b616fd1c513d2b30bcc",
"adminx/modules/Users/module.php": "c952fe3182dc176870aa155f5848089c21f4026146bab53efac0902d2de7730d",
"adminx/modules/Users/migrations/005_public_session_capacity.sql": "d7fed81d1f68319b8f47d645414e746dc9fda58b575539afa12371400e81014e",
"adminx/modules/Users/module.php": "f04a577e15b42ed2469e0402d553c2b316fea1fb6af1ad742377b8e4bded60da",
"adminx/modules/Users/view/list.twig": "845e0b0c8a04891096a2352b1a683fbd4614333017beea413434793173749efa",
"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": "6845837fe7c9adab56c611dcbdd713f910d65cbee0c7b62eff7ed7bcf571d6f5",
"adminx/view/main.twig": "b1ea3ab2356171f45e5174f44e271f3b07ab898f866400e9eab12f0eb341163f",
"configs/db.config.example.php": "e42bdb47aa7d1bc9cd85cb36e05acf940cafd951d6220664c485d1b971f18809",
"configs/index.php": "48e6b249e3d83530187c095ede0198af498c59f384bd458db74f9e0c2306eba9",
"configs/public.config.php": "882ea6a03ee8df191be8ea68ba78d39ae600d918a0880ebdff158fc38f534aa6",
@@ -663,16 +677,17 @@
"help/api/errors.md": "630270fb28d5d385e368f3b0cc1c5cc2acb3c56607bb8c9af62c2e62a6d09993",
"help/content/README.md": "273451d12b6360df56cc58323bfb8fd7521b4e3daf8f7ad93c874fe6877af048",
"help/content/blocks.md": "1d55be2a9f1a48df426e5856a54307d77b352d0037d68f08ed7e16a322d9ef8c",
"help/content/bulk-editor.md": "821107322b125c9344bd04346037fbd19f8ccd0ad6da822fe78af3299d01f746",
"help/content/bulk-editor.md": "5960ecea40856bf94c4c7ff2cc62f5a69b80e84ef25bb0df5d60773d3ec18a8d",
"help/content/catalog.md": "12434610186c9d797270ee0bd802abe79b67e976a88a94dbb10f2ee1fb4dbdbf",
"help/content/content-model-tools.md": "baaf738aec454b1855ea110b8f5e1ef3ceaaad51cb920d4551cd088daeab7e76",
"help/content/content-studio.md": "91ef485e614a9a71a07d4e6c143377f7052ddf3b00d3f2f0d0a32258bdb14dbe",
"help/content/directories.md": "ff6c5ea9bd7fe1fb9d0aa1875d03fc3bc00b2bd2b0ec83d6496cd55168db294c",
"help/content/documents.md": "273851452c0fb8fddd47019fea600da187025dcca271bdd91d59acae15d6b4c5",
"help/content/documents.md": "2b8a8ca4e323a869091bc61af821f7b58f8d5e276f3e7dee9f6eddb721610560",
"help/content/navigation.md": "50425db83ae70b8be72c69ecc6c54f77a15f029a244185021de1cb56efbb344f",
"help/content/presentations.md": "6e349bb4d1d1577617e6bf3d987b86ca6b255f64a47cd1ddb0e38c5242b788cb",
"help/content/public-site.md": "9b7cd51d6d36d21daa73b47cb243b84eeb79f0ff48e9eb61c5951e82c24f7c73",
"help/content/requests.md": "c008b1026520c85400fb9240cb117fcaf384531c06e859c1e36ccbb3a79a8590",
"help/content/rich-editor.md": "7816fce22a9187bd689a7a5e1fd8e9ffd66c7c321836e371d8fe010982804465",
"help/content/rubrics.md": "2c9842ce5647fc85663b02cef14f141c673694f0a20856c9262b6ccbea436c3c",
"help/content/templates.md": "c6fae2ef91c741479d1f34fe66d00e83f2d2ec1aba1a4099773a04a6e1b6552c",
"help/content/themes.md": "f522107e2ee27590a40d726f26538a3101925d0d0173fa1e58422089b5418388",
@@ -794,7 +809,7 @@
"help/en/modules/benchmark.md": "e7b9d233abd386db41413f45c79639a3bebfc37241bdbab461028e87a5615f87",
"help/en/modules/cache-warmup.md": "d92183948e03dd4f9a18308d36df7d5ba57e7e87f268b5f3f1de38ed99c518dc",
"help/en/modules/comments.md": "55f45f748f32fbd36233c43574e212f3fa3fdd1ce8eb819f9d1cafb53215ed97",
"help/en/modules/commerce.md": "e0a156c6ba634fe4c5beeae5eca0e0d592adddedbdab7e444da647609dbe01f7",
"help/en/modules/commerce.md": "48c45b60ce849f88cb965e455b5784ea4829b316a4a1f34e4c92fed14558492d",
"help/en/modules/commerceml.md": "610f6b2f9abbeeac604da0408192ae52dae7d6f3fb09e0d321e14d1cc190b808",
"help/en/modules/contacts.md": "81c818e9cbcd1d61f34d9199219ea160a04bf88af110b32c3527c65fba65d39e",
"help/en/modules/content-packages.md": "4b89df6d05fa75e484e38e404d53fa7ecc6b61aa6930fbf74cf9b6611bfbf532",
@@ -815,6 +830,7 @@
"help/en/modules/legacy-migration.md": "f9dabcd0ca6d69999ef989457a98b6a52ad29b42fc9958079e4b3f203b1bc15e",
"help/en/modules/localization.md": "f84bd43534cb3147f5675908deed2503271e9a96ef1cacca8b3fab17d8917f6c",
"help/en/modules/mcp.md": "98afdfe8128282672e6915159e39f6b1337b295022d9091c392c5a76d41ae239",
"help/en/modules/metrica_ecommerce.md": "5bb1b3966bacbf27349c191ea1c9c49c4f9e1dbfa85e80b30c64058d925fdef6",
"help/en/modules/migrations.md": "5a2d09f1252ad7c4670d06db1a1dd029593856fabd219005bfc0d9891bd6c2f2",
"help/en/modules/module-php.md": "d61b3a47097623c1d969b716d78d08939f446980648b0718c1dcef45b7af8f36",
"help/en/modules/not-found.md": "77ab48c7428a8f053938a780986d9c2d435be5c7aebc1d70093574776436c691",
@@ -893,7 +909,7 @@
"help/modules/benchmark.md": "6c31f16fc3f083f29c65398c38b78609a3d94da168d30a93825af8ee9650eb0d",
"help/modules/cache-warmup.md": "56c05ec252b44a1a0d1c4b09efa90cf9d450b0b138787903c898f56b377b8749",
"help/modules/comments.md": "04bcdea5aa7740e3749b51dcc9fc2954e98b22298d3e4fcdcc9865d94e1d0376",
"help/modules/commerce.md": "d398d0dfc1edca5010d81ae660d412b4b1ed8d4df448fc78f237ad0ee2cc731a",
"help/modules/commerce.md": "eeecc0171c134a995d22d3348bb237247e77b536367c27366f2e465f20c30d4f",
"help/modules/commerceml.md": "60a0a5c541b560b5a6a4ed8213d9145caace68c15be534af13f5f817eb5251ba",
"help/modules/contacts.md": "cb4284709f7b469164e3ef4c817893cdcaf4eba1b460164fb36f75a798bc457d",
"help/modules/content-packages.md": "8dde0ebc897b7891a6afadb6491f4797bef26622918addab67f0f4ebfd719678",
@@ -912,8 +928,10 @@
"help/modules/help-viewer.md": "175db88e150ee02dadd8b841ea74262490b8211c67bc42bf05cf1dbafba1a25f",
"help/modules/interactions.md": "6c62a4abb3422609265b1898cb448dd6bd840edeec98f30fb0062c6d18808e18",
"help/modules/legacy-migration.md": "773acd5026409522bdd3a0d3fbbfcd915ca54849d64c97dbc0cbfc2a79fce36e",
"help/modules/live_chat.md": "97cd5169676008546757509147f61f88ca64ae0e7c4a76a7d73be9ed9ff72769",
"help/modules/localization.md": "f6a58cba016a55c7a9a88c594ae25fc79828411dae13a70aa84a54860821ae43",
"help/modules/mcp.md": "a7b63a32773a1517de3c5bc328771bb3e2b9ff402f1004ededac2f94add25b02",
"help/modules/metrica_ecommerce.md": "3a9b1c9605d7a5f4a18a9a1d3b774bcff488309028679a5d547aff3c42e66ce9",
"help/modules/migrations.md": "e144ba009df225508d5f73e9ce71e17d4a379f9ddff49e640c2c1234e389588f",
"help/modules/module-php.md": "3594f19c10381b4d450b6ce5b9b1d83b2f413067d67f7cca334d3a14cdbf2a0e",
"help/modules/not-found.md": "50e45e048e07e27190b8583928c8fe20cd77c5e3401adfc47b9b2effa1ccd799",
@@ -937,7 +955,7 @@
"help/modules/search.md": "a39ffbd45e7d5786cd16d2678f5386c55c3081063177f6525b12849da1c251d3",
"help/modules/seo-audit.md": "b0fb78eda0e83c0711b7717fcc730e7bc0cb86c6d692f21aa749cc4975f0ee27",
"help/modules/site-readiness.md": "2c7f845ece693ef14f23ea2b3b226f38ebfed305fc67bf842fae971f0e12255a",
"help/modules/sliders.md": "f3022a36b5c483f49d63b86b4651989e98636018fc8a7449ca45b2875277a3fe",
"help/modules/sliders.md": "13c9d0f212bfa880fa965128b4b072a81f14eb4dba2e11c3889f0b6a57c1764f",
"help/modules/smsc-auth.md": "361b46fbc4812513f59e8ba15d095607851f835d7765ac396c3efd79ecde10e3",
"help/modules/system-health.md": "671d44d89f0d3ada4fcaa4ef3d8a7662a6c77dea6c0cd567d8f3e923d29e12a5",
"help/modules/traffic-analytics.md": "12f966acc6ffdf73e2d40682c6db203f8dc3918ba5461a78607c1d783ed54d02",
@@ -952,12 +970,12 @@
"setup/PackageExtractor.php": "57cb5473905992333936d7984221e5188cd72756d6e48206a42d96e8ab59442d",
"setup/assets/installer.css": "79e43dc7123f37206429e8535d92c5069c53231402c67b63ea9481bbea60ab42",
"setup/assets/installer.js": "892a982b872390fea21455562353408da906114b973ccbfc3a3cce06465ab1e8",
"setup/core-migrations.php": "c747a8a9289f27385310cd8189e7b618b7b78b0ddcc22c1d283425a5ee4a962b",
"setup/index.php": "cdbb99ab89035afb665d4a288c87633dc0749a75cf64a0bbf00e5ec8470fef36",
"setup/language/en.php": "963dd284ee244e6d006dede8522c1118dc0909974dcd24d477fd06b7e1965fa1",
"setup/core-migrations.php": "b1647f6f7d4dbe0e8dc69584b8f1373763b7626ccc108223c1a4cc39f0966303",
"setup/index.php": "daa384199bb69dc9aed3fd718c19d6d9bb100bb73e284651bcd16a48841b2036",
"setup/language/en.php": "a735bc01632ee20c900e619eebe7954a7b8851d05d4a78d70660851aaacdf34a",
"setup/language/ru.php": "8522b13c4f147804538eaa857585296db811c736e1361e6b403daecea05f34f8",
"setup/recovery.php": "359ae9b79508a382bdde175824b18a5bdb0fd69ddb7776c7d18ac9eb826d127a",
"setup/schema.sql": "4942144465f8854cce3c3e6f06bc9d2ebd11ad1239d4a086feda318a0ae70448",
"setup/schema.sql": "d5197503945c0bb9f2fc2a4a77bf667d032ca103c21215edd3d5d676246840d5",
"storage/.htaccess": "ca761a92f2b6eb47feffcf200a8fd846e7fdd8abe065008d29beff0319ca3496",
"system/App/Common/AdminAssets.php": "dcd58f19fda48263b2912a10dfdc24e3f12967683649c4f614a8d54c62d51b86",
"system/App/Common/AdminLocation.php": "d2729b0d627ddbdeb7f0c4b110664ed949f373946f80929c51688391c64352de",
@@ -967,14 +985,15 @@
"system/App/Common/Auth/IdentityLinker.php": "26c85deda5918b413f2ee4fc48ef00d5268aa6ef1081095b801b2428d61979f7",
"system/App/Common/Auth/PublicUserNames.php": "0345d1f452e0a9accaa9b98eea71f27a428692a5b4f2ad225a10ad8bcf777684",
"system/App/Common/Auth/PublicUserProvider.php": "5d3690dcbc0e9cace7e09350d283cba541f15cf8a346c9bedcbd9f6cec58d65f",
"system/App/Common/Cache.php": "bb322246cbfa12cbcdd67c16a24c55715ad67b31d356b4f18091c0aa1d00eb9a",
"system/App/Common/Cache.php": "887a9c4f1b7a2065707c22e3e42ff8c01f882d2c00e462438cd01caa308a5e76",
"system/App/Common/CacheKey.php": "47400cd90d1880d061995bfea585b56210e18a870607d9b1cf7f0eeaccf15032",
"system/App/Common/CliRuntime.php": "2f33eb99cc61917e8e1112c83e8ff24cb2463a8ea7024ba1d907b3a097166613",
"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/ReleaseState.php": "2c9e5955b3d4e57a9429cc15c566b95606265ba1440c7da488c6ff0f894ba671",
"system/App/Common/CoreUpdate/UpdateJob.php": "ae50e802ab0f55e043b0a389e15e73a8e874d7776f60ad523a0fb58cf97c1500",
"system/App/Common/CoreUpdate/UpdateRepository.php": "b8fbc3e0852b0d3d8be7b65a4513d12487dc3a42fd67c6f14330c7d5d85edc09",
"system/App/Common/CsvLogWriter.php": "9deaa3e2bd85eec300c65e89e22549f48aa9e2850ad50b2ee8826f8d4fa53aa1",
"system/App/Common/DatabaseConfiguration.php": "3eec09e9a0d9302761ca6b9ebfcb47b01aaded1bb7ed5834b1d55cac806918d3",
@@ -1019,7 +1038,7 @@
"system/App/Common/ModuleTagRegistry.php": "9e68b64303825a25ef4f71f31a7adedee9d2752e4d1d06162605da1dc510a6a5",
"system/App/Common/Navigation.php": "1a1b0a3552d941dc0fc59c334ab705e86a73ee96ee42871dc958012bec1d81f6",
"system/App/Common/NotFoundLog.php": "f5630e839aee069c1122452da42f42ecae5d2082fe582dda7be6760059f3eb71",
"system/App/Common/OutboundHttpClient.php": "dc03f3093e70438ba92cd8d15ae690141186b6cf7916a863bd6f7de4c15be0bd",
"system/App/Common/OutboundHttpClient.php": "56f6c08ecef0dddaaf662c84d05afb4f1e2ad27d72b7c51749eab07359bbe757",
"system/App/Common/PackageModuleRuntime.php": "5ad492b9053bcea4d3bd4398b11fb2d976272e2036d379bdc24c19dd5fe7486f",
"system/App/Common/Permission.php": "8e2241bca829de0104741dc2518860cd15489866dc310437e07aeab2bf0f0355",
"system/App/Common/Proxy.php": "707564208ec8c24ac2ffb5bf41cba34f1b39059ce6bda31a69aaf4b116d864e2",
@@ -1117,6 +1136,7 @@
"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/RichEditorSettings.php": "d583e8e5b52c278c06637315ea493dd9e97212a8303bcd69ae20684d6a848a7b",
"system/App/Content/Fields/SerialFieldType.php": "61685c9a19c0fe9aede73f16f7bdd730dc3562d6223e7b3d4eae5e3e303fce1f",
"system/App/Content/Fields/Types/AddressValue.php": "ae244e819453247a385f604b7ee4df3735bb6474346686bc29a0cc32c35133ec",
"system/App/Content/Fields/Types/Analoque.php": "d4169e7474f700831a5b572163a75185e410447c2f6c56050226fdbcc5a61e19",
@@ -1128,7 +1148,7 @@
"system/App/Content/Fields/Types/ColorValue.php": "8a68a4fdf628dc30922357bb0918235f373e76034d137afa270004edcd947740",
"system/App/Content/Fields/Types/Computed.php": "82ff8ff37fdb9802a6626edb7abfe97e0867901157b23d05fb75a84710aa851c",
"system/App/Content/Fields/Types/ContactValue.php": "b6f8533a8de393b4c760ea69423b8bd1c1fa0717bc5a608cca7710f58c751f11",
"system/App/Content/Fields/Types/Content.php": "e7e41bca3dc574e5735446593ba875d59ec1baf4769cb130ecdcecefda65709d",
"system/App/Content/Fields/Types/Content.php": "012dd0df95b21fccab274874ba127e3121512084015a699c6dd2c409223d182e",
"system/App/Content/Fields/Types/Date.php": "4952e8ff31abdc8d133e6f812e075847a49790c9fdd1865357cbb4080b0bb8f0",
"system/App/Content/Fields/Types/DateTimeValue.php": "592761636f12f9674d928d9ee7c21307b2c8402de6e9a2c2b537e5cfb51fe07d",
"system/App/Content/Fields/Types/DimensionsValue.php": "36a323ab98c2c011dee03eb30ee9ebd55f0450aa74645b0031f7202bcff77a7a",
@@ -1146,7 +1166,7 @@
"system/App/Content/Fields/Types/ImageSingle.php": "54c1253fcdf6c3726b2f6d4881d0bc7844fe3279e50f1d796d2bea14ed93dba1",
"system/App/Content/Fields/Types/Link.php": "6c3b7031d8a1b78371c041b142a90a373217afce12e637d29cc0a6b4ed126d7b",
"system/App/Content/Fields/Types/MultiCheckbox.php": "040dcf7824d15a5bb9cd032ce2de171a91c8f4379e3482a85a59d6c7b34fc2dd",
"system/App/Content/Fields/Types/MultiLine.php": "9cdba0fb14c1053a2c88c667f436eed2da243836d49ca6c3240612f32bdfab52",
"system/App/Content/Fields/Types/MultiLine.php": "eed21babbc30a6a253bc6d9cbeedd59d4439fd6361b52ead753db43f17aa9006",
"system/App/Content/Fields/Types/MultiLineSimple.php": "0286b13ad1a2e83b5fdc8b11f05bcb99b80b38a018650c25e58141785fda0fda",
"system/App/Content/Fields/Types/MultiLineSlim.php": "4c39e5810a9e2df82a7e03af7eab36b07c20c863fc31d3aabc2e172103b41d84",
"system/App/Content/Fields/Types/MultiLinks.php": "a4d1ae15f0c23934a5185fe7407605079873f7a018c6ddfae0998f2d1c3312f6",
@@ -1159,7 +1179,7 @@
"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/RichText.php": "757e235fd99958e436d72c37b5badcf07cd085bcb92c991694923e7c6ab8a2e6",
"system/App/Content/Fields/Types/SingleLine.php": "5d41fa26af3bb6dc0ceac95534ea228b3355df4ad2856277f62034232e31e41b",
"system/App/Content/Fields/Types/SingleLineNumeric.php": "a7cf2fec71fe4c0efa6a6d97dbee69510627068d1a5192661d3051ff660448ea",
"system/App/Content/Fields/Types/SingleLineNumericThree.php": "38f5c7a093933e879d6930323ace96ab8b3acdd482ef8231af9cb0a80df48153",
@@ -1234,7 +1254,7 @@
"system/App/Frontend/Auth/assets/auth.css": "4b9ecaf9050577c8c9d412f436a22e3f6aa89bc596fc0b1395dea06a6a48e33f",
"system/App/Frontend/Auth/assets/auth.js": "840c7322fc21f9a8dd61af8cd16e0ceed36d6beaeac45d948bedbcb87b6012c4",
"system/App/Frontend/Auth/assets/phone-mask.js": "b9001aeeeda7f2c290bcc24b3453ef828e43c54ec0db9c891b6ffc1a555d2878",
"system/App/Frontend/Auth/view/login.twig": "6d0435f45cdc75ca3c1454bd1c6f0286d6227ac4dd943715927906623e01efd8",
"system/App/Frontend/Auth/view/login.twig": "5205bc84c3cc1fdc8f95ee32437c977c881e74fc655f36deceb06809e59446cc",
"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",
@@ -1243,7 +1263,7 @@
"system/App/Frontend/Auth/view/password.twig": "099d7c1e48649faba57eb50df9bd9a091c79dad12e9c1fee9b38c3f2e025ed80",
"system/App/Frontend/Auth/view/phone.twig": "b0803ff6b00d4f63ab056a2129d2cf3a2a870bdada106e7d706ba5427cd9a6a8",
"system/App/Frontend/Auth/view/profile.twig": "a2523111d171324a2a116ab4200ade94299af08de45639a0a5d36c9df05eb5dd",
"system/App/Frontend/Auth/view/register.twig": "513dd690a902a51752b7239b113ff6b612d754c238718e6ccc1f5698dfa42c52",
"system/App/Frontend/Auth/view/register.twig": "c8ccff65a32ac068e6e04cbe50f532fe17bf95f4a19f960268db9fe5e5086d27",
"system/App/Frontend/Auth/view/remember.twig": "9872a6f4812aeb7d8f04870156e0427e9a73be4277c59b1480dfb81d9dc0dc5d",
"system/App/Frontend/Auth/view/reset.twig": "a8d13ea135ca16cc20af3d1940cd8ed5cb13143043992e2ec73b5c7d2c60c8cf",
"system/App/Frontend/BlockRenderer.php": "1d23d7ff85ba1052f896e204d1885f99ec2c0f6382e01d5d1903d69fe661305b",
@@ -1252,7 +1272,7 @@
"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": "337085d73f0ad609b5a37a4fa72537459f4ae2e5f503e39a8750be95127d89b5",
"system/App/Frontend/ContentCollectionTags.php": "36978ecfded9bfcf37e7fc9f44a976b5e440a653679c96e8162bdc60c07abe7b",
"system/App/Frontend/Debug/PublicDebugCollector.php": "6755ad710e9d045b2bfa74745354f717c101236b5d64ad5530fd779468969a00",
"system/App/Frontend/Debug/PublicDebugToolbar.php": "7a92630b59483e63d623fa769e13c246e6bd657c6d027a5d14ab8f95986da0de",
"system/App/Frontend/Debug/PublicLayoutInspector.php": "8a7f5ea4cff9834ad13cac7e0d2380d878978e406e71860e1ad9ca183e427dab",
@@ -1328,9 +1348,9 @@
"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/Controller.php": "314efe61465fcdb881a45028e741685f156d7940e4ea58b587feec1dfc536a8d",
"system/App/Frontend/Sitemap/Feature.php": "7ff7afcc57c8533acdd1050a40a0c4ca65815668f5e8c72d4b02bdd6c92ae8fd",
"system/App/Frontend/Sitemap/Repository.php": "2dff32e665493d6cd7e6946952541ad414d460d8c1b0d873161b134e24ed1ffe",
"system/App/Frontend/Sitemap/Repository.php": "4219a96faee7aec1aae5f8ca6ae054b7348e7c1e1d096991dc4eec699235ef49",
"system/App/Frontend/StoredTemplateApi.php": "042e21a93a6c914ac6cf53465b5292833a8240645870ac1bcf29e6fed0a2288f",
"system/App/Frontend/ThemeAssets.php": "5870bace8823561820b64752e5dba16e434449e12de317ad4085087b31232396",
"system/App/Frontend/ThemePageShellRenderer.php": "86638d6559c6b57f540ff2705ea3ba7607001ee46c94e9398ece474a34b05f52",
@@ -1347,13 +1367,14 @@
"system/App/Helpers/Dir.php": "0791ed320ab894e2c768d16768c227e9c68bbc627723eae289f587551037fb66",
"system/App/Helpers/File.php": "4a43dacb8964e023a4361f2a3057da33a2b044628a4af3c7bd7e36171c024291",
"system/App/Helpers/Hooks.php": "266e65cd317245297652756f2cbfb0ae4af44f7f1249d9009d1c955594994803",
"system/App/Helpers/Html.php": "4b4b6387bf40ef4f91fef02ef115ee4b7230694eed93633ec5caa6afb195e013",
"system/App/Helpers/Html.php": "e75a342898e8dc2598a15e855e80fbbe1a30ac0715d3fe67f427650c441ac1a0",
"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/PhpSyntax.php": "6c94f8e5aa16a174d554f499e3cd4f83f79df2df315a64c7e4bbf3bcf32674c0",
"system/App/Helpers/Request.php": "29cc2cf20e1e4acd6e2f23c3de9b9606aec3f44d0b3577613b4fde376f7ae858",
"system/App/Helpers/Response.php": "beea1596703526b7a2424459e392487e8bc8a9ce216f8d70bd2b09564709e0b9",
"system/App/Helpers/RussianStemmer.php": "60a896c8254290ae554273132c103c1c209a14944f52cfd631e7a3ae13145529",
@@ -1371,7 +1392,7 @@
"system/language/ru/lang.xml": "7d46cc9822f064a1af4d6528ccfe2dd4a9cc318912e327fd807befd2ca3dfd11",
"system/preload.php": "66bfb1aaf36ba85945b729b09a37e96ead0be1cc2749ebd9abd4b9da4f25ba0e",
"system/public.php": "3f72b5bf44bc0ba5ba6fb6b3adc2e2f9d5e9902d9cdfa3d7b748f4aebd179b87",
"system/release.php": "96de420b08cdefac694c48399015fdbccc9bc81c5992d1052254934c8e0d2f2e",
"system/release.php": "2b574ac66c0b98ee791f231d29b371e2936c202c11bd144912c85bad60b2321d",
"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",
@@ -1957,12 +1978,13 @@
"templates/public/theme.json": "4fb596d58dbda7f58c26c4f3640c56d6f6981f402ffbd368229adddc23683690",
"tmp/.htaccess": "ca761a92f2b6eb47feffcf200a8fd846e7fdd8abe065008d29beff0319ca3496",
"uploads/.htaccess": "90cd571b4c9f09a63732254fbcdca92cc7da8560a6e5b4d9ec7e58ac40e528bf",
"uploads/images/noimage.png": "bdb69cbc3e5df374b80ec1ee03c81c610554b4eca9fa6df7d5b55fd387d7afea",
"uploads/index.php": "e91434fe4986c22a2bba7ceda8e59d1aa2c9f943353035c83939cfd4f423fdd8",
"robots.txt": "d8119db904b34c2aab74cc2b6a09d13119f7c19add8b51ffbf4717eba65e8218"
},
"file_owners": {
".gitignore": "core",
".htaccess": "core",
".htaccess": "project",
"CHANGELOG.md": "core",
"README.md": "core",
"adminx/.htaccess": "core",
@@ -2003,6 +2025,10 @@
"adminx/assets/js/editor-tiptap.js": "core",
"adminx/assets/js/media-picker.js": "core",
"adminx/assets/js/saved-views.js": "core",
"adminx/assets/src/_rich-extensions.js": "core",
"adminx/assets/src/_rich-recovery.js": "core",
"adminx/assets/src/_rich-tools.js": "core",
"adminx/assets/src/_word-paste.js": "core",
"adminx/assets/src/bulk-actions.js": "core",
"adminx/assets/src/command-palette.js": "core",
"adminx/assets/src/editor-tiptap.js": "core",
@@ -2092,11 +2118,13 @@
"adminx/assets/vendor/tabler/tabler-icons.min.css": "core",
"adminx/core-permissions.php": "core",
"adminx/index.php": "core",
"adminx/language/en/attention.xml": "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/attention.xml": "core",
"adminx/language/ru/client.xml": "core",
"adminx/language/ru/common.xml": "core",
"adminx/language/ru/interface.xml": "core",
@@ -2201,8 +2229,11 @@
"adminx/modules/Customers/view/customer-center-detail.twig": "core",
"adminx/modules/Customers/view/customer-center.twig": "core",
"adminx/modules/Customers/view/index.twig": "core",
"adminx/modules/Dashboard/Attention.php": "core",
"adminx/modules/Dashboard/Controller.php": "core",
"adminx/modules/Dashboard/Widgets.php": "core",
"adminx/modules/Dashboard/assets/attention.css": "core",
"adminx/modules/Dashboard/assets/attention.js": "core",
"adminx/modules/Dashboard/assets/dashboard.css": "core",
"adminx/modules/Dashboard/assets/dashboard.js": "core",
"adminx/modules/Dashboard/language/en/common.xml": "core",
@@ -2283,6 +2314,7 @@
"adminx/modules/Documents/migrations/016_reconcile_document_content_columns.php": "core",
"adminx/modules/Documents/migrations/017_document_public_visibility.sql": "core",
"adminx/modules/Documents/module.php": "core",
"adminx/modules/Documents/view/_product_package_fields.twig": "core",
"adminx/modules/Documents/view/_product_panels.twig": "core",
"adminx/modules/Documents/view/api.twig": "core",
"adminx/modules/Documents/view/bulk-editor.twig": "core",
@@ -2342,11 +2374,13 @@
"adminx/modules/Media/assets/media.css": "core",
"adminx/modules/Media/assets/media.js": "core",
"adminx/modules/Media/language/en/analyzer.xml": "core",
"adminx/modules/Media/language/en/archive_processing.xml": "core",
"adminx/modules/Media/language/en/client.xml": "core",
"adminx/modules/Media/language/en/improvements.xml": "core",
"adminx/modules/Media/language/en/interface.xml": "core",
"adminx/modules/Media/language/en/runtime.xml": "core",
"adminx/modules/Media/language/ru/analyzer.xml": "core",
"adminx/modules/Media/language/ru/archive_processing.xml": "core",
"adminx/modules/Media/language/ru/client.xml": "core",
"adminx/modules/Media/language/ru/improvements.xml": "core",
"adminx/modules/Media/language/ru/interface.xml": "core",
@@ -2434,6 +2468,7 @@
"adminx/modules/Requests/migrations/012_condition_value_sources.php": "core",
"adminx/modules/Requests/migrations/013_sort_rules.php": "core",
"adminx/modules/Requests/migrations/014_request_revisions.sql": "core",
"adminx/modules/Requests/migrations/015_request_description_capacity.php": "core",
"adminx/modules/Requests/module.php": "core",
"adminx/modules/Requests/view/edit.twig": "core",
"adminx/modules/Requests/view/index.twig": "core",
@@ -2583,6 +2618,7 @@
"adminx/modules/Users/migrations/002_harden_public_remember_tokens.php": "core",
"adminx/modules/Users/migrations/003_system_user_security.php": "core",
"adminx/modules/Users/migrations/004_system_session_expiry.php": "core",
"adminx/modules/Users/migrations/005_public_session_capacity.sql": "core",
"adminx/modules/Users/module.php": "core",
"adminx/modules/Users/view/list.twig": "core",
"adminx/view/404.twig": "core",
@@ -2622,6 +2658,7 @@
"help/content/presentations.md": "core",
"help/content/public-site.md": "core",
"help/content/requests.md": "core",
"help/content/rich-editor.md": "core",
"help/content/rubrics.md": "core",
"help/content/templates.md": "core",
"help/content/themes.md": "core",
@@ -2764,6 +2801,7 @@
"help/en/modules/legacy-migration.md": "core",
"help/en/modules/localization.md": "core",
"help/en/modules/mcp.md": "core",
"help/en/modules/metrica_ecommerce.md": "core",
"help/en/modules/migrations.md": "core",
"help/en/modules/module-php.md": "core",
"help/en/modules/not-found.md": "core",
@@ -2861,8 +2899,10 @@
"help/modules/help-viewer.md": "core",
"help/modules/interactions.md": "core",
"help/modules/legacy-migration.md": "core",
"help/modules/live_chat.md": "core",
"help/modules/localization.md": "core",
"help/modules/mcp.md": "core",
"help/modules/metrica_ecommerce.md": "core",
"help/modules/migrations.md": "core",
"help/modules/module-php.md": "core",
"help/modules/not-found.md": "core",
@@ -2918,6 +2958,7 @@
"system/App/Common/Auth/PublicUserProvider.php": "core",
"system/App/Common/Cache.php": "core",
"system/App/Common/CacheKey.php": "core",
"system/App/Common/CliRuntime.php": "core",
"system/App/Common/CompiledModuleRegistry.php": "core",
"system/App/Common/Controller.php": "core",
"system/App/Common/CoreUpdate/DatabaseBackup.php": "core",
@@ -3066,6 +3107,7 @@
"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/RichEditorSettings.php": "core",
"system/App/Content/Fields/SerialFieldType.php": "core",
"system/App/Content/Fields/Types/AddressValue.php": "core",
"system/App/Content/Fields/Types/Analoque.php": "core",
@@ -3303,6 +3345,7 @@
"system/App/Helpers/MarketParser.php": "core",
"system/App/Helpers/Number.php": "core",
"system/App/Helpers/Phone.php": "core",
"system/App/Helpers/PhpSyntax.php": "core",
"system/App/Helpers/Request.php": "core",
"system/App/Helpers/Response.php": "core",
"system/App/Helpers/RussianStemmer.php": "core",
@@ -3906,7 +3949,8 @@
"templates/public/theme.json": "core",
"tmp/.htaccess": "core",
"uploads/.htaccess": "core",
"uploads/images/noimage.png": "core",
"uploads/index.php": "core",
"robots.txt": "core"
"robots.txt": "project"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# AVE.cms
**AVE.cms 3.3 build 0.37** — модульная система управления сайтами на PHP.
**AVE.cms 3.3 build 0.42** — модульная система управления сайтами на PHP.
Она подходит для проектов, в которых структуру содержимого нужно собирать из
собственных рубрик, полей, документов, запросов, блоков и шаблонов, не
привязываясь к заранее заданному типу сайта.
+6
View File
@@ -62,6 +62,12 @@
return self::contributions('header');
}
/** Small, permission-scoped work queues; providers must not mutate their records. */
public static function attentionQueues()
{
return self::contributions('attention');
}
public static function dashboardWidgets(array $visibleCodes = null)
{
return self::contributions('dashboard', $visibleCodes);
+11 -137
View File
@@ -16,158 +16,32 @@
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\CliRuntime;
use App\Common\PublicConfiguration;
use App\Helpers\PhpSyntax;
class PhpCode
{
const LINT_TIMEOUT = 3;
public static function source($code)
{
$code = trim((string) $code);
if (preg_match('/^<\?(?:php|=)?/i', $code)) {
return $code;
}
if (preg_match('/^<\?(?:php|=)?/i', $code)) { return $code; }
return "<?php\n" . $code . "\n";
}
public static function lint($code)
{
$directory = BASEPATH . '/tmp/console';
if (!is_dir($directory) && !@mkdir($directory, 0775, true)) {
return array('ok' => 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);
}
if (!function_exists('proc_open')) {
@unlink($file);
return array('ok' => false, 'message' => 'Проверка PHP недоступна на сервере.', 'output' => 'Функция proc_open отключена хостингом.', 'line' => 0);
}
$php = self::resolvePhpBinary();
if ($php === '') {
@unlink($file);
return array('ok' => false, 'message' => 'PHP CLI не найден.', 'output' => 'Укажите путь к PHP CLI в ADMINX_PHP_BINARY.', 'line' => 0);
}
$result = self::runProcess(
escapeshellarg($php) . ' -l ' . escapeshellarg($file),
self::LINT_TIMEOUT
);
@unlink($file);
if ($result['timed_out']) {
return array('ok' => false, 'message' => 'Проверка PHP превысила лимит времени.', 'output' => 'Проверьте ADMINX_PHP_BINARY: требуется консольный PHP, а не php-fpm.', 'line' => 0);
}
$text = trim($result['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' => $result['exit_code'] === 0 && $text !== '',
'message' => $result['exit_code'] === 0 ? 'Синтаксис PHP без ошибок.' : 'В PHP-коде есть синтаксическая ошибка.',
'output' => $text,
'line' => $line,
);
$code = trim((string) $code);
$offset = preg_match('/^<\?(?:php|=)?/i', $code) ? 0 : -1;
return PhpSyntax::check(self::source($code), $offset);
}
protected static function resolvePhpBinary()
public static function cliBinary()
{
$candidates = array();
$configured = trim((string) getenv('ADMINX_PHP_BINARY'));
if ($configured !== '') { $candidates[] = $configured; }
$config = PublicConfiguration::value('admin_console', array());
if (is_array($config) && !empty($config['php_binary'])) {
$candidates[] = trim((string) $config['php_binary']);
}
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';
foreach (array_unique($candidates) as $candidate) {
$result = self::runProcess(
escapeshellarg((string) $candidate) . ' -r ' . escapeshellarg('echo PHP_VERSION_ID;'),
1
);
if (!$result['timed_out'] && $result['exit_code'] === 0 && preg_match('/^\d{5,6}$/', trim($result['output']))) {
return (string) $candidate;
}
}
return '';
}
protected static function runProcess($command, $timeout)
{
$pipes = array();
try {
$process = @proc_open($command, array(
0 => array('pipe', 'r'),
1 => array('pipe', 'w'),
2 => array('pipe', 'w'),
), $pipes);
} catch (\Throwable $e) {
return array('exit_code' => 1, 'timed_out' => false, 'output' => $e->getMessage());
}
if (!is_resource($process)) {
return array('exit_code' => 1, 'timed_out' => false, 'output' => 'Не удалось запустить PHP CLI.');
}
fclose($pipes[0]);
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$started = microtime(true);
$output = '';
$error = '';
$timedOut = false;
$knownExitCode = null;
do {
$output .= (string) stream_get_contents($pipes[1]);
$error .= (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 >= max(1, (int) $timeout)) {
$timedOut = true;
proc_terminate($process, 9);
break;
}
usleep(20000);
} while (true);
$output .= (string) stream_get_contents($pipes[1]);
$error .= (string) stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$closedExitCode = proc_close($process);
$exitCode = $knownExitCode !== null && $knownExitCode >= 0 ? $knownExitCode : $closedExitCode;
return array(
'exit_code' => $exitCode,
'timed_out' => $timedOut,
'output' => $output . ($error !== '' ? ($output !== '' ? "\n" : '') . $error : ''),
);
$preferred = array(trim((string) getenv('ADMINX_PHP_BINARY')));
if (is_array($config) && !empty($config['php_binary'])) { $preferred[] = trim((string) $config['php_binary']); }
try { return CliRuntime::php($preferred); }
catch (\RuntimeException $e) { throw new \RuntimeException($e->getMessage() . ' Для панели также доступны ADMINX_PHP_BINARY и admin_console.php_binary.'); }
}
}
+206
View File
@@ -4563,6 +4563,212 @@ ul.sortable {
overflow: hidden;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
body.rich-editor-fullscreen-open {
overflow: hidden;
}
.rich-editor-status {
flex: 0 0 auto;
padding: 8px 12px;
border-top: 1px solid var(--border-default);
background: var(--background-muted);
color: var(--text-secondary);
font-size: 12px;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.rich-table-tools {
display: flex;
flex-wrap: wrap;
flex: 0 0 auto;
gap: 4px;
padding: 6px 12px;
border-bottom: 1px solid var(--border-default);
background: var(--background-surface);
}
.rich-table-tools[hidden],
.rich-recovery[hidden] {
display: none;
}
.rich-search-match {
background: #fff1ad;
color: #242a33;
}
.rich-search-match.is-current {
background: #ffcc66;
outline: 1px solid #926600;
}
.rich-tools-modal {
width: 640px;
max-width: calc(100vw - 24px);
}
.rich-tools-modal .modal-header {
gap: 12px;
}
.rich-tools-modal .modal-header h3 {
flex: 1;
}
.rich-tools-modal .modal-body {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
overflow: auto;
}
.rich-tools-modal .modal-footer {
flex-wrap: wrap;
}
.rich-tools-modal .modal-footer .btn {
white-space: normal;
}
.rich-tools-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.rich-symbols {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(48px, 1fr));
gap: 8px;
}
.rich-symbols .btn {
height: 48px;
font-size: 22px;
}
.rich-snippets {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.rich-snippets .btn {
justify-content: flex-start;
white-space: normal;
text-align: left;
}
.rich-review-row,
.rich-block-row {
display: flex;
gap: 10px;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--border-default);
}
.rich-review-row:last-child,
.rich-block-row:last-child {
border-bottom: 0;
}
.rich-review-row > span,
.rich-block-row > span {
flex: 1;
min-width: 0;
}
.rich-review-row b,
.rich-review-row small,
.rich-block-row b,
.rich-block-row small {
display: block;
overflow-wrap: anywhere;
}
.rich-review-row small,
.rich-block-row small {
color: var(--text-secondary);
margin-top: 4px;
}
.rich-block-row {
cursor: grab;
}
.rich-block-row small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rich-tools-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px 0;
color: var(--text-secondary);
}
.rich-recovery {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 10px 12px;
background: var(--color-primary-soft);
}
.rich-recovery > span {
flex: 1;
}
.rich-recovery-compare {
display: grid;
gap: 16px;
}
.rich-recovery-compare textarea {
min-height: 140px;
}
.rich-editor-prose .content-callout {
padding: 14px 18px;
border-left: 3px solid var(--color-primary);
background: var(--background-muted);
}
.rich-editor-prose .content-callout--warning {
border-left-color: #b38a20;
}
@media (max-width: 600px) {
.rich-tools-modal {
max-width: 100%;
}
.rich-snippets {
grid-template-columns: 1fr;
}
}
.rich-editor-fullscreen-parent {
transform: none !important;
filter: none !important;
backdrop-filter: none !important;
perspective: none !important;
contain: none !important;
will-change: auto !important;
overflow: visible !important;
position: relative !important;
z-index: 196 !important;
}
.rich-editor.rich-editor-fullscreen {
position: fixed;
inset: 0;
z-index: 197;
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
height: 100dvh;
max-width: none;
margin: 0;
border: 0;
border-radius: 0;
}
.rich-editor.rich-editor-fullscreen .rich-editor-toolbar {
flex: 0 0 auto;
max-height: 45%;
overflow: auto;
}
.rich-editor.rich-editor-fullscreen .rich-editor-surface,
.rich-editor.rich-editor-fullscreen .rich-editor-inline-source {
flex: 1 1 0;
height: auto;
min-height: 0;
overflow: auto;
}
.rich-editor.rich-editor-fullscreen .rich-editor-content,
.rich-editor.rich-editor-fullscreen .rich-editor-prose {
min-height: 100% !important;
}
.rich-editor.rich-editor-fullscreen .rich-editor-inline-source .CodeMirror {
height: 100% !important;
min-height: 0;
}
.rich-editor-toolbar {
display: flex;
align-items: center;
File diff suppressed because one or more lines are too long
+73
View File
@@ -0,0 +1,73 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { closeHistory } from '@tiptap/pm/history';
export const searchKey = new PluginKey('richSearch');
export function findMatches(doc, query, sensitive) {
if (!query) { return []; }
const matches = [];
const pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), sensitive ? 'g' : 'gi');
doc.descendants((node, pos) => {
if (!node.isTextblock) { return; }
// Leaf nodes occupy one ProseMirror position; never match across them.
const text = node.textBetween(0, node.content.size, '', '\ufffc');
let found;
while ((found = pattern.exec(text))) {
if (!found[0].includes('\ufffc')) { matches.push({ from: pos + 1 + found.index, to: pos + 1 + found.index + found[0].length }); }
}
return false;
});
return matches;
}
export const RichSearch = Extension.create({
name: 'richSearch',
addProseMirrorPlugins() {
return [new Plugin({
key: searchKey,
state: {
init: () => ({ query: '', sensitive: false, active: 0, matches: [] }),
apply(tr, previous) {
const state = { ...previous, ...(tr.getMeta(searchKey) || {}) };
if (tr.docChanged || tr.getMeta(searchKey)) { state.matches = findMatches(tr.doc, state.query, state.sensitive); }
state.active = Math.max(0, Math.min(state.active, state.matches.length - 1));
return state;
}
},
props: {
decorations(state) {
const search = searchKey.getState(state);
return DecorationSet.create(state.doc, search.matches.map((match, index) => Decoration.inline(match.from, match.to, { class: index === search.active ? 'rich-search-match is-current' : 'rich-search-match' })));
}
}
})];
}
});
export const ContentFragments = Extension.create({
name: 'contentFragments',
addGlobalAttributes() {
return [{ types: ['blockquote'], attributes: { class: {
default: null,
parseHTML: element => {
const name = element.getAttribute('class') || '';
return /\bcontent-callout(?:--(?:warning|info))?\b/.test(name) ? name : null;
}
} } }];
}
});
export function moveBlock(editor, fromIndex, toIndex) {
const blocks = [];
editor.state.doc.forEach((node, pos) => blocks.push({ node, pos }));
if (!blocks[fromIndex] || toIndex < 0 || toIndex > blocks.length || fromIndex === toIndex || fromIndex + 1 === toIndex) { return false; }
const source = blocks[fromIndex];
let target = toIndex === blocks.length ? editor.state.doc.content.size : blocks[toIndex].pos;
if (target > source.pos) { target -= source.node.nodeSize; }
const tr = closeHistory(editor.state.tr).delete(source.pos, source.pos + source.node.nodeSize).insert(target, source.node);
tr.setSelection(TextSelection.near(tr.doc.resolve(target))).scrollIntoView();
editor.view.dispatch(tr);
return true;
}
+54
View File
@@ -0,0 +1,54 @@
import { toolDialog } from './_rich-tools';
export function installRecovery(api, root, textarea, editor) {
const form = textarea.form;
// Documents own a full-form draft already, including rich editors and source mode.
if (form && form.querySelector('[data-document-draft-recovery]')) { return; }
const actor = (form && form.dataset.actorId) || document.querySelector('[data-attention-user]')?.dataset.attentionUser;
if (!actor || actor === '0' || !textarea.name) { return; }
const key = 'ave.rich-draft:' + actor + ':' + location.pathname + location.search + ':' + (form?.id || form?.getAttribute('action') || '') + ':' + textarea.name;
const baseline = textarea.value;
let candidate;
let timer;
let disposed = false;
const remove = () => { try { localStorage.removeItem(key); } catch (e) {} };
try {
candidate = JSON.parse(localStorage.getItem(key) || 'null');
if (candidate && (typeof candidate.html !== 'string' || Date.now() - candidate.saved > 7 * 86400000 || candidate.html === baseline)) { remove(); candidate = null; }
} catch (e) { candidate = null; }
const banner = document.createElement('div');
banner.className = 'rich-recovery';
banner.hidden = !candidate;
banner.innerHTML = '<span>Найден несохранённый текст</span><button type="button" class="btn btn-secondary btn-sm" data-recover>Посмотреть</button><button type="button" class="btn btn-ghost btn-icon btn-sm" aria-label="Удалить локальную копию" data-discard><i class="ti ti-trash"></i></button>';
root.querySelector('.rich-editor-toolbar').after(banner);
banner.querySelector('[data-discard]').addEventListener('click', () => { candidate = null; banner.hidden = true; remove(); });
banner.querySelector('[data-recover]').addEventListener('click', () => {
if (!candidate) { return; }
const dialog = toolDialog('Восстановить текст', '<div class="rich-recovery-compare"><label class="field"><span class="field-label">Сейчас на сервере</span><textarea class="textarea" readonly data-current></textarea></label><label class="field"><span class="field-label">Локальная копия</span><textarea class="textarea" readonly data-local></textarea></label></div>', editor);
dialog.body.querySelector('[data-current]').value = baseline;
dialog.body.querySelector('[data-local]').value = candidate.html;
const restore = document.createElement('button');
restore.type = 'button'; restore.className = 'btn btn-primary'; restore.textContent = 'Восстановить в редактор';
restore.addEventListener('click', () => { api.replaceHtml(textarea, candidate.html); candidate = null; banner.hidden = true; dialog.close(); });
dialog.footer.appendChild(restore);
});
const save = () => {
clearTimeout(timer);
if (disposed || candidate || editor.isDestroyed) { return; }
const html = api.syncTextarea(textarea, editor);
if (html === baseline) { remove(); return; }
if (html.length > 1000000) { return; }
try { localStorage.setItem(key, JSON.stringify({ html, saved: Date.now() })); } catch (e) {}
};
const schedule = () => { clearTimeout(timer); timer = setTimeout(save, 700); };
textarea.addEventListener('input', schedule);
window.addEventListener('pagehide', save);
const acknowledged = () => { clearTimeout(timer); remove(); };
if (form) { form.addEventListener('rich:saved', acknowledged); }
editor.on('destroy', () => {
disposed = true; clearTimeout(timer);
textarea.removeEventListener('input', schedule);
window.removeEventListener('pagehide', save);
if (form) { form.removeEventListener('rich:saved', acknowledged); }
});
}
+268
View File
@@ -0,0 +1,268 @@
import { searchKey, moveBlock } from './_rich-extensions';
import { closeHistory } from '@tiptap/pm/history';
const escape = value => String(value == null ? '' : value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
export const tr = value => window.Adminx.tr ? window.Adminx.tr(value) : value;
const button = (action, title, icon, text = '') => `<button type="button" class="btn btn-ghost btn-icon btn-sm" data-tool="${action}" aria-label="${escape(tr(title))}" data-tooltip="${escape(tr(title))}"><i class="ti ti-${icon}"></i>${escape(text)}</button>`;
export function readOptions(textarea) {
let options = {};
try { options = JSON.parse(textarea.getAttribute('data-rich-options') || '{}'); } catch (e) {}
const profile = options.profile || (textarea.getAttribute('data-rich-preset') === 'slim' ? 'compact' : 'full');
let headings = options.headings || 'profile';
if (headings === 'profile') { headings = ['product', 'news'].includes(profile) ? 'no_h1' : 'all'; }
return { ...options, profile, headings, product: !!textarea.closest('[data-catalog-mode="1"]'), snippets: options.snippets || 'all' };
}
// One reusable dialog for the editor tools; content is trusted markup, values are escaped.
export function toolDialog(title, content, editor, onClose) {
const overlay = document.createElement('div');
overlay.className = 'overlay rich-editor-tools-overlay show';
overlay.innerHTML = `<div class="modal rich-tools-modal" role="dialog" aria-modal="true" aria-label="${escape(tr(title))}"><div class="modal-header"><h3>${escape(tr(title))}</h3><button class="modal-close" type="button" data-tools-close aria-label="${escape(tr('Закрыть'))}"><i class="ti ti-x"></i></button></div><div class="modal-body">${content}</div><div class="modal-footer"><button class="btn btn-secondary" type="button" data-tools-close>${escape(tr('Закрыть'))}</button></div></div>`;
document.body.appendChild(overlay);
let closed = false;
const previous = document.activeElement;
const close = () => {
if (closed) { return; }
closed = true;
if (editor) { editor.off('destroy', close); }
document.removeEventListener('keydown', key, true);
overlay.remove();
if (onClose) { onClose(); }
if (previous && previous.isConnected) { previous.focus({ preventScroll: true }); }
};
const key = event => {
if (event.key === 'Escape') { event.preventDefault(); event.stopImmediatePropagation(); close(); }
if (event.key === 'Tab') {
const nodes = Array.from(overlay.querySelectorAll('button:not(:disabled),input,select,textarea,a[href]')).filter(node => !node.hidden && node.getClientRects().length);
if (!nodes.length) { return; }
const index = nodes.indexOf(document.activeElement);
event.preventDefault();
nodes[(index + (event.shiftKey ? nodes.length - 1 : 1)) % nodes.length].focus();
}
};
document.addEventListener('keydown', key, true);
if (editor) { editor.on('destroy', close); }
overlay.addEventListener('click', event => { if (event.target === overlay || event.target.closest('[data-tools-close]')) { close(); } });
const first = overlay.querySelector('.modal-body input, .modal-body select, .modal-body button, .modal-close');
if (first) { first.focus(); }
return { overlay, close, body: overlay.querySelector('.modal-body'), footer: overlay.querySelector('.modal-footer') };
}
export function openSearch(editor) {
const dialog = toolDialog('Найти и заменить', `<label class="field"><span class="field-label">${tr('Найти')}</span><input class="input" data-find autocomplete="off"></label><label class="field"><span class="field-label">${tr('Заменить на')}</span><input class="input" data-replace autocomplete="off"></label><label class="checkbox"><input type="checkbox" data-case> ${tr('Учитывать регистр')}</label><div class="rich-tools-actions">${button('prev', 'Предыдущее', 'arrow-up')}${button('next', 'Следующее', 'arrow-down')}<output data-find-count aria-live="polite"></output></div><div class="rich-tools-actions"><button type="button" class="btn btn-secondary" data-replace-one>${tr('Заменить')}</button><button type="button" class="btn btn-secondary" data-replace-all>${tr('Заменить все')}</button></div>`, editor, () => {
editor.off('transaction', updateCount);
if (!editor.isDestroyed) { editor.view.dispatch(editor.state.tr.setMeta(searchKey, { query: '' })); }
});
const query = dialog.body.querySelector('[data-find]');
const replacement = dialog.body.querySelector('[data-replace]');
const sensitive = dialog.body.querySelector('[data-case]');
function updateCount() {
const state = searchKey.getState(editor.state);
dialog.body.querySelector('[data-find-count]').textContent = state.matches.length ? `${state.active + 1} / ${state.matches.length}` : tr('Нет совпадений');
dialog.body.querySelectorAll('[data-replace-one],[data-replace-all],[data-tool]').forEach(node => { node.disabled = !state.matches.length; });
}
function search() { editor.view.dispatch(editor.state.tr.setMeta(searchKey, { query: query.value, sensitive: sensitive.checked, active: 0 })); }
function next(step) {
const state = searchKey.getState(editor.state);
if (!state.matches.length) { return; }
const active = (state.active + step + state.matches.length) % state.matches.length;
editor.view.dispatch(editor.state.tr.setMeta(searchKey, { active }));
const node = editor.view.dom.querySelector('.rich-search-match.is-current');
if (node) { node.scrollIntoView({ block: 'nearest' }); }
}
query.addEventListener('input', search);
sensitive.addEventListener('change', search);
query.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); next(event.shiftKey ? -1 : 1); } });
dialog.body.addEventListener('click', event => {
const control = event.target.closest('button');
if (!control || control.disabled) { return; }
if (control.dataset.tool) { next(control.dataset.tool === 'prev' ? -1 : 1); return; }
const state = searchKey.getState(editor.state);
const matches = control.hasAttribute('data-replace-all') ? state.matches : [state.matches[state.active]];
const transaction = closeHistory(editor.state.tr);
matches.slice().reverse().forEach(match => {
// Insert plain text with the first character's marks, never parse replacement as HTML.
if (replacement.value) { transaction.replaceWith(match.from, match.to, editor.schema.text(replacement.value, transaction.doc.nodeAt(match.from)?.marks || [])); }
else { transaction.delete(match.from, match.to); }
});
editor.view.dispatch(transaction);
editor.view.dispatch(closeHistory(editor.state.tr));
});
editor.on('transaction', updateCount);
const selected = editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, ' ');
query.value = selected;
search();
}
export function openSymbols(editor) {
const selection = { from: editor.state.selection.from, to: editor.state.selection.to };
const symbols = [['\u00a0', 'Неразрывный пробел'], ['°', 'Градус'], ['×', 'Умножение'], ['±', 'Плюс-минус'], ['®', 'Зарегистрированный знак'], ['©', 'Авторское право'], ['™', 'Товарный знак'], ['≤', 'Меньше или равно'], ['≥', 'Больше или равно'], ['µ', 'Микро'], ['²', 'Квадрат'], ['³', 'Куб'], ['«', 'Открывающая кавычка'], ['»', 'Закрывающая кавычка'], ['–', 'Короткое тире'], ['—', 'Длинное тире'], ['…', 'Многоточие']];
const dialog = toolDialog('Специальные символы', `<div class="rich-symbols">${symbols.map(([symbol, name], index) => `<button type="button" class="btn btn-secondary" data-symbol="${index}" aria-label="${escape(tr(name))}" data-tooltip="${escape(tr(name))}">${index ? escape(symbol) : '␣'}</button>`).join('')}</div>`, editor);
dialog.body.addEventListener('click', event => {
const item = event.target.closest('[data-symbol]');
if (!item) { return; }
editor.chain().focus().insertContentAt(selection, { type: 'text', text: symbols[Number(item.dataset.symbol)][0] }).run();
dialog.close();
});
}
const snippets = [
{ id: 'warning', title: 'Внимание', group: 'info', html: '<blockquote class="content-callout content-callout--warning"><p><strong>Внимание</strong></p><p>Текст предупреждения.</p></blockquote>' },
{ id: 'important', title: 'Важно', group: 'info', html: '<blockquote class="content-callout content-callout--info"><p><strong>Важно</strong></p><p>Дополнительная информация.</p></blockquote>' },
{ id: 'equipment', title: 'Комплектация', group: 'product', html: '<h2>Комплектация</h2><ul><li><p>Наименование позиции</p></li></ul>' },
{ id: 'specs', title: 'Таблица характеристик', group: 'product', html: '<h2>Характеристики</h2><table><tr><th><p>Параметр</p></th><th><p>Значение</p></th></tr><tr><td><p>Наименование</p></td><td><p>Значение</p></td></tr></table>' }
];
export function openSnippets(editor, options) {
const selection = { from: editor.state.selection.from, to: editor.state.selection.to };
const available = options.snippets === 'none' ? [] : snippets.filter(item => options.snippets === 'all' || item.group === options.snippets).filter(item => !options.hide_table || item.id !== 'specs');
const dialog = toolDialog('Готовые фрагменты', `<div class="rich-snippets">${available.map(item => `<button type="button" class="btn btn-secondary" data-snippet="${item.id}"><i class="ti ti-${item.id === 'specs' ? 'table' : 'template'}"></i>${escape(tr(item.title))}</button>`).join('')}</div>`, editor);
dialog.body.addEventListener('click', event => {
const control = event.target.closest('[data-snippet]');
const item = control && available.find(candidate => candidate.id === control.dataset.snippet);
if (!item) { return; }
editor.chain().focus().insertContentAt(selection, item.html).run();
dialog.close();
});
}
export function openBlocks(editor) {
const dialog = toolDialog('Порядок блоков', '<div class="rich-block-list" data-block-list></div>', editor, () => editor.off('update', render));
const list = dialog.body.firstElementChild;
let dragged = null;
function render() {
const rows = [];
editor.state.doc.forEach((node, pos, index) => {
const names = { paragraph: 'Абзац', heading: 'Заголовок', bulletList: 'Список', orderedList: 'Нумерованный список', blockquote: 'Цитата', image: 'Изображение', table: 'Таблица', horizontalRule: 'Разделитель', codeBlock: 'Код' };
const title = node.type.name === 'heading' ? `H${node.attrs.level}` : tr(names[node.type.name] || node.type.name);
rows.push(`<div class="rich-block-row" draggable="true" data-block-index="${index}"><i class="ti ti-grip-vertical" aria-hidden="true"></i><span><b>${escape(title)}</b><small>${escape((node.textContent || node.attrs.alt || node.attrs.src || '').slice(0, 120))}</small></span>${button('up', 'Переместить вверх', 'arrow-up')}${button('down', 'Переместить вниз', 'arrow-down')}</div>`);
});
list.innerHTML = rows.join('');
if (list.firstElementChild) { list.firstElementChild.querySelector('[data-tool=up]').disabled = true; list.lastElementChild.querySelector('[data-tool=down]').disabled = true; }
}
list.addEventListener('dragstart', event => {
const row = event.target.closest('[data-block-index]');
if (!row) { return; }
dragged = Number(row.dataset.blockIndex);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(dragged));
});
list.addEventListener('dragover', event => { if (dragged !== null) { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; } });
list.addEventListener('drop', event => {
if (dragged === null) { return; }
event.preventDefault();
const row = event.target.closest('[data-block-index]');
let target = editor.state.doc.childCount;
if (row) { target = Number(row.dataset.blockIndex) + (event.clientY > row.getBoundingClientRect().top + row.offsetHeight / 2 ? 1 : 0); }
moveBlock(editor, dragged, target);
dragged = null;
});
list.addEventListener('dragend', () => { dragged = null; });
list.addEventListener('click', event => {
const control = event.target.closest('[data-tool]');
if (!control || control.disabled) { return; }
const index = Number(control.closest('[data-block-index]').dataset.blockIndex);
moveBlock(editor, index, control.dataset.tool === 'up' ? index - 1 : index + 2);
});
editor.on('update', render);
render();
}
export function inspectHtml(html, options) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const issues = [];
doc.body.querySelectorAll('*').forEach(node => {
const tag = node.tagName;
const sample = node.textContent.trim().slice(0, 90);
if (tag === 'H1' && (options.product || options.headings !== 'all')) { issues.push({ node, kind: 'heading', title: 'H1 в тексте', sample }); }
else if (/^H[1-6]$/.test(tag) && !node.textContent.trim() && !node.querySelector('img')) { issues.push({ node, kind: 'empty', title: 'Пустой заголовок', sample }); }
if (tag === 'A' && (!(node.getAttribute('href') || '').trim() || (!node.textContent.trim() && !node.querySelector('img')))) { issues.push({ node, kind: 'link', title: 'Пустая ссылка', sample }); }
if (node.hasAttribute('style')) { issues.push({ node, kind: 'style', title: 'Встроенное оформление', sample: `${sample} (${node.getAttribute('style')})`.slice(0, 140) }); }
if (tag === 'P' && !node.textContent.trim() && !node.querySelector('img,table') && node.previousElementSibling && node.previousElementSibling.tagName === 'P' && !node.previousElementSibling.textContent.trim()) { issues.push({ node, kind: 'empty', title: 'Лишний пустой абзац', sample }); }
});
return { doc, issues };
}
export function openReview(editor, textarea, options) {
const api = window.Adminx.RichEditor;
const dialog = toolDialog('Проверка оформления', '<div data-review-list></div>', editor);
function render() {
const result = inspectHtml(api.syncTextarea(textarea, editor), options);
dialog.body.firstElementChild.innerHTML = result.issues.length ? result.issues.map((issue, index) => `<div class="rich-review-row"><span><b>${escape(tr(issue.title))}</b><small>${escape(issue.sample)}</small></span><button type="button" class="btn btn-secondary btn-sm" data-fix="${index}">${tr('Исправить')}</button></div>`).join('') : `<div class="rich-tools-empty"><i class="ti ti-circle-check"></i>${tr('Замечаний нет')}</div>`;
dialog.body.querySelectorAll('[data-fix]').forEach(control => control.addEventListener('click', () => {
const issue = result.issues[Number(control.dataset.fix)];
if (issue.kind === 'style') { issue.node.removeAttribute('style'); }
if (issue.kind === 'empty') { issue.node.remove(); }
if (issue.kind === 'link') { issue.node.replaceWith(...Array.from(issue.node.childNodes)); }
if (issue.kind === 'heading') {
const heading = result.doc.createElement('h2');
Array.from(issue.node.attributes).forEach(attr => heading.setAttribute(attr.name, attr.value));
while (issue.node.firstChild) { heading.appendChild(issue.node.firstChild); }
issue.node.replaceWith(heading);
}
api.replaceHtml(textarea, result.doc.body.innerHTML);
render();
}));
}
render();
}
export function reviewBeforeSave(form, done) {
const fields = Array.from(form.querySelectorAll('textarea[data-rich-editor]')).filter(node => node._adminxTiptap && !node._adminxTiptap.isDestroyed);
const issues = fields.map(field => ({ field, result: inspectHtml(window.Adminx.RichEditor.syncTextarea(field, field._adminxTiptap), readOptions(field)) })).filter(item => item.result.issues.length);
if (!issues.length) { return false; }
const dialog = toolDialog('Проверьте оформление', `<div class="rich-review-summary">${issues.map((item, index) => `<div class="rich-review-row"><span><b>${escape(item.field.closest('.ax-document-field')?.querySelector('.field-label')?.textContent || item.field.name || tr('Текст'))}</b><small>${escape(item.result.issues.map(issue => tr(issue.title)).filter((v, i, a) => a.indexOf(v) === i).join(', '))}</small></span><button type="button" class="btn btn-secondary btn-sm" data-check-field="${index}">${tr('Проверить')}</button></div>`).join('')}</div>`, null);
const save = document.createElement('button');
save.type = 'button'; save.className = 'btn btn-primary'; save.textContent = tr('Сохранить без исправлений');
save.addEventListener('click', () => { dialog.close(); done(); });
dialog.footer.appendChild(save);
dialog.body.addEventListener('click', event => {
const control = event.target.closest('[data-check-field]');
if (!control) { return; }
const field = issues[Number(control.dataset.checkField)].field;
dialog.close();
openReview(field._adminxTiptap, field, readOptions(field));
});
return true;
}
export function installTools(api, root, textarea, editor, options) {
const footer = document.createElement('div');
footer.className = 'rich-editor-status';
root.appendChild(footer);
const tablebar = document.createElement('div');
tablebar.className = 'rich-table-tools';
tablebar.hidden = true;
const commands = [
['addRowBefore', 'Строка выше', 'row-insert-top'], ['addRowAfter', 'Строка ниже', 'row-insert-bottom'], ['deleteRow', 'Удалить строку', 'row-remove'],
['addColumnBefore', 'Столбец слева', 'column-insert-left'], ['addColumnAfter', 'Столбец справа', 'column-insert-right'], ['deleteColumn', 'Удалить столбец', 'column-remove'],
['mergeCells', 'Объединить ячейки', 'table-alias'], ['splitCell', 'Разделить ячейку', 'table-options'], ['toggleHeaderRow', 'Строка заголовков', 'table-row'], ['toggleHeaderColumn', 'Столбец заголовков', 'table-column'], ['deleteTable', 'Удалить таблицу', 'table-minus']
];
tablebar.innerHTML = commands.map(([name, title, icon]) => button(name, title, icon)).join('');
root.querySelector('.rich-editor-toolbar').after(tablebar);
tablebar.addEventListener('mousedown', event => event.preventDefault());
tablebar.addEventListener('click', event => { const control = event.target.closest('[data-tool]'); if (control && !control.disabled) { editor.chain().focus()[control.dataset.tool]().run(); } });
let lastDoc;
let total = '';
const stats = value => `${tr('Слов')}: ${(value.trim().match(/\S+/gu) || []).length} · ${tr('Символов')}: ${Array.from(value).length}`;
function update() {
const source = root.classList.contains('rich-editor-source-open');
tablebar.hidden = source || !editor.isActive('table') || options.hide_table;
if (!tablebar.hidden) { commands.forEach(([name]) => { tablebar.querySelector(`[data-tool="${name}"]`).disabled = !editor.can()[name](); }); }
const selected = editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, ' ');
if (source && root._richSourceEditor) {
total = stats(new DOMParser().parseFromString(root._richSourceEditor.getValue(), 'text/html').body.textContent);
lastDoc = null;
} else if (lastDoc !== editor.state.doc) {
lastDoc = editor.state.doc;
total = stats(lastDoc.textBetween(0, lastDoc.content.size, ' '));
}
footer.textContent = total + (!source && selected ? ' | ' + tr('Выделено') + ': ' + stats(selected) : '');
}
editor.on('transaction', update);
root.addEventListener('rich:source-change', update);
root.addEventListener('rich:mode-change', update);
update();
editor.on('destroy', () => { editor.off('transaction', update); root.removeEventListener('rich:source-change', update); root.removeEventListener('rich:mode-change', update); });
}
+90
View File
@@ -0,0 +1,90 @@
// Clipboard HTML is parsed in an inert document, never inserted into the live DOM.
export function isWordHtml(html) {
return /\bmso-|class=["']?Mso|urn:schemas-microsoft-com:office|Microsoft Word/i.test(html);
}
export function cleanWordHtml(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll('script,style,meta,link,iframe,object,embed,svg,math,form,input,button').forEach(node => node.remove());
const comments = doc.createTreeWalker(doc.body, NodeFilter.SHOW_COMMENT);
const remove = [];
while (comments.nextNode()) { remove.push(comments.currentNode); }
remove.forEach(node => node.remove());
doc.querySelectorAll('p').forEach(node => {
const level = (node.className || '').match(/\bMsoHeading([1-6])\b/i) || (node.getAttribute('style') || '').match(/mso-outline-level:\s*([1-6])\b/i);
if (!level) { return; }
const heading = doc.createElement('h' + level[1]);
while (node.firstChild) { heading.appendChild(node.firstChild); }
node.replaceWith(heading);
});
// Desktop Word exports list items as paragraphs with mso-list and marker spans.
const lists = new Map();
doc.querySelectorAll('p').forEach(node => {
const style = node.getAttribute('style') || '';
const match = style.match(/mso-list:\s*(l\d+)\s+level(\d+)\s+(lfo\d+)/i);
if (!match) { return; }
const marker = Array.from(node.querySelectorAll('span')).find(span => /mso-list:\s*ignore/i.test(span.getAttribute('style') || ''));
const label = marker ? marker.textContent.trim() : '';
const ordered = /^(?:\d+|[a-z]+)[.)]/i.test(label);
lists.set(node, { key: match[1] + match[3], level: Number(match[2]), type: ordered ? 'ol' : 'ul', start: /^\d+/.test(label) ? parseInt(label, 10) : 1 });
if (marker) { marker.remove(); }
});
const parents = new Set(Array.from(lists.keys()).map(node => node.parentNode));
parents.forEach(parent => {
let stack = [];
let key = '';
Array.from(parent.childNodes).forEach(node => {
if (node.nodeType === 3 && !node.textContent.trim()) { return; }
const item = lists.get(node);
if (!item) { stack = []; key = ''; return; }
if (key !== item.key) { stack = []; key = item.key; }
while (stack.length && stack[stack.length - 1].level > item.level) { stack.pop(); }
if (stack.length && stack[stack.length - 1].level === item.level && stack[stack.length - 1].type !== item.type) { stack.pop(); }
if (!stack.length || stack[stack.length - 1].level < item.level) {
const list = doc.createElement(item.type);
if (item.type === 'ol' && item.start > 1) { list.setAttribute('start', item.start); }
if (stack.length) { stack[stack.length - 1].last.appendChild(list); }
else { parent.insertBefore(list, node); }
stack.push({ level: item.level, type: item.type, list: list, last: null });
}
const li = doc.createElement('li');
const entry = stack[stack.length - 1];
entry.list.appendChild(li);
li.appendChild(node);
entry.last = li;
});
});
const allowed = new Set('p div br h1 h2 h3 h4 h5 h6 strong b em i u s strike del blockquote pre code ul ol li a table thead tbody tfoot tr td th hr'.split(' '));
Array.from(doc.body.querySelectorAll('*')).reverse().forEach(node => {
if (!node.parentNode) { return; }
const tag = node.tagName.toLowerCase();
// Convert meaningful inline formatting before stripping presentation attributes.
const marks = [];
const weight = node.style.fontWeight;
if (weight === 'bold' || Number(weight) >= 600) { marks.push('strong'); }
if (node.style.fontStyle === 'italic') { marks.push('em'); }
const decoration = node.style.textDecoration;
if (/underline/.test(decoration)) { marks.push('u'); }
if (/line-through/.test(decoration)) { marks.push('s'); }
marks.forEach(mark => {
const wrapper = doc.createElement(mark);
while (node.firstChild) { wrapper.appendChild(node.firstChild); }
node.appendChild(wrapper);
});
Array.from(node.attributes).forEach(attr => {
const name = attr.name.toLowerCase();
const value = attr.value.trim();
const safeLink = tag === 'a' && name === 'href' && /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(value) && !/[\u0000-\u0020]/.test(value);
const span = /^(td|th)$/.test(tag) && /^(colspan|rowspan)$/.test(name) && /^[1-9]\d{0,2}$/.test(value);
const start = tag === 'ol' && name === 'start' && /^[1-9]\d{0,5}$/.test(value);
if (!safeLink && !span && !start) { node.removeAttribute(attr.name); }
});
if (!allowed.has(tag)) {
node.replaceWith(...Array.from(node.childNodes));
}
});
return doc.body.innerHTML;
}
+187 -6
View File
@@ -7,6 +7,10 @@ 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';
import { isWordHtml, cleanWordHtml } from './_word-paste';
import { RichSearch, ContentFragments } from './_rich-extensions';
import { tr, readOptions, openSearch, openSymbols, openSnippets, openBlocks, openReview, reviewBeforeSave, installTools } from './_rich-tools';
import { installRecovery } from './_rich-recovery';
var EnhancedImage = Image.extend({
addAttributes: function () {
@@ -90,6 +94,8 @@ var EnhancedImage = Image.extend({
extensions: function () {
return [
RichSearch,
ContentFragments,
StarterKit.configure({ link: false }),
Link.configure({
openOnClick: false,
@@ -107,6 +113,7 @@ var EnhancedImage = Image.extend({
},
create: function (textarea) {
var options = readOptions(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' });
@@ -131,6 +138,9 @@ var EnhancedImage = Image.extend({
extensions: this.extensions(),
content: textarea.value || '',
editorProps: {
transformPastedHTML: function (html) {
return isWordHtml(html) ? cleanWordHtml(html) : html;
},
attributes: {
class: 'rich-editor-prose',
spellcheck: textarea.getAttribute('spellcheck') || 'true'
@@ -155,15 +165,18 @@ var EnhancedImage = Image.extend({
}
});
this.buildToolbar(toolbar, editor);
this.buildToolbar(toolbar, editor, options, textarea);
textarea.setAttribute('data-rich-editor-ready', '1');
textarea._adminxTiptap = editor;
this.instances.push(editor);
this.bindForm(textarea, editor);
this.syncToolbar(root, editor);
installTools(this, root, textarea, editor, options);
installRecovery(this, root, textarea, editor);
},
button: function (name, tooltip, iconName, run) {
tooltip = tr(tooltip);
var btn = el('button', {
class: 'btn btn-ghost btn-icon btn-sm rich-editor-btn',
type: 'button',
@@ -175,7 +188,7 @@ var EnhancedImage = Image.extend({
return btn;
},
buildToolbar: function (toolbar, editor) {
buildToolbar: function (toolbar, editor, options, textarea) {
var group;
var self = this;
function addGroup() {
@@ -183,6 +196,11 @@ var EnhancedImage = Image.extend({
toolbar.appendChild(group);
}
function add(name, tooltip, iconName, run) {
if (options.hide_image && name === 'image') { return; }
if (options.hide_table && name === 'table') { return; }
if (options.hide_source && name === 'source') { return; }
if (options.hide_align && name.indexOf('align') === 0) { return; }
if (options.profile === 'compact' && ['image', 'table', 'code', 'alignCenter', 'alignRight', 'showBlocks'].indexOf(name) !== -1) { return; }
group.appendChild(self.button(name, tooltip, iconName, run));
}
function addBlockSelect() {
@@ -201,16 +219,20 @@ var EnhancedImage = Image.extend({
['heading-5', 'Заголовок H5'],
['heading-6', 'Заголовок H6']
].forEach(function (item) {
if (item[0].indexOf('heading-') === 0 && (options.headings === 'none' || (options.headings === 'no_h1' && item[0] === 'heading-1'))) { return; }
var option = el('option', { value: item[0] });
option.textContent = item[1];
select.appendChild(option);
});
select.addEventListener('change', function () {
if (select.value === 'paragraph') {
// Focusing in Safari/iOS synchronously refreshes the toolbar value.
var block = select.value;
var level = parseInt(block.replace('heading-', ''), 10);
if (block === 'paragraph') {
editor.chain().focus().setParagraph().run();
return;
}
editor.chain().focus().setHeading({ level: parseInt(select.value.replace('heading-', ''), 10) }).run();
editor.chain().focus().setHeading({ level: level }).run();
});
group.appendChild(select);
}
@@ -218,6 +240,7 @@ var EnhancedImage = Image.extend({
addGroup();
add('undo', 'Отменить', 'arrow-back-up', function () { editor.chain().focus().undo().run(); });
add('redo', 'Повторить', 'arrow-forward-up', function () { editor.chain().focus().redo().run(); });
add('pasteWord', 'Вставить из Word', 'clipboard-text', function () { self.openWordPaste(editor); });
addGroup();
add('bold', 'Жирный', 'bold', function () { editor.chain().focus().toggleBold().run(); });
@@ -238,6 +261,14 @@ var EnhancedImage = Image.extend({
addGroup();
add('link', 'Ссылка', 'link', function () { self.openLink(editor); });
add('documentLink', 'Ссылка на документ или товар', 'file-search', function () {
var selection = { from: editor.state.selection.from, to: editor.state.selection.to };
self.openDocumentLinkPicker(function (item) {
var href = item.url || '/' + String(item.alias || '').replace(/^\/+/, '');
if (selection.from !== selection.to) { editor.chain().focus().setTextSelection(selection).setLink({ href: href }).run(); }
else { editor.chain().focus().insertContentAt(selection, { type: 'text', text: item.title || href, marks: [{ type: 'link', attrs: { href: href } }] }).run(); }
});
});
add('image', 'Изображение', 'photo-plus', function () {
self.openMediaPicker(editor);
});
@@ -252,10 +283,140 @@ var EnhancedImage = Image.extend({
});
addGroup();
add('fullscreen', 'Развернуть редактор', 'arrows-maximize', function () { self.toggleFullscreen(editor); });
if (window.CodeMirror) {
add('source', 'Исходник', 'code-dots', function () { self.openSource(editor); });
}
add('clear', 'Очистить формат', 'eraser', function () { editor.chain().focus().unsetAllMarks().clearNodes().run(); });
addGroup();
add('find', 'Найти и заменить', 'replace', function () { openSearch(editor); });
add('symbols', 'Специальные символы', 'omega', function () { openSymbols(editor); });
if (options.snippets !== 'none') { add('snippets', 'Готовые фрагменты', 'template', function () { openSnippets(editor, options); }); }
if (!options.hide_blocks) { add('blocks', 'Порядок блоков', 'list-details', function () { openBlocks(editor); }); }
add('review', 'Проверка оформления', 'text-spellcheck', function () { openReview(editor, textarea, options); });
toolbar.querySelectorAll('.rich-editor-group').forEach(function (node) { if (!node.children.length) { node.remove(); } });
},
reviewBeforeSave: reviewBeforeSave,
replaceHtml: function (textarea, html) {
var editor = textarea._adminxTiptap;
if (!editor || editor.isDestroyed) { return; }
var root = textarea.closest('.rich-editor');
editor.commands.setContent(html || '<p></p>');
if (root && root._richSourceEditor) {
root._richSourceSyncing = true;
root._richSourceEditor.setValue(html);
root._richSourceSyncing = false;
}
// Preserve source attributes Tiptap does not model until the next visual edit.
textarea.value = html;
textarea.removeAttribute('data-rich-editor-dirty');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
},
toggleFullscreen: function (editor) {
if (this.fullscreen && this.fullscreen.editor === editor) { this.fullscreen.close(); return; }
if (this.fullscreen) { this.fullscreen.close(); }
var self = this;
var root = editor.view.dom.closest('.rich-editor');
var ancestors = [];
var scrollX = window.scrollX;
var scrollY = window.scrollY;
var parent = root.parentElement;
// Keep the editor inside its form; transformed drawers must not clip fixed positioning.
while (parent && parent !== document.body) {
parent.classList.add('rich-editor-fullscreen-parent');
ancestors.push(parent);
parent = parent.parentElement;
}
root.classList.add('rich-editor-fullscreen');
document.body.classList.add('rich-editor-fullscreen-open');
function refresh() {
if (root._richSourceEditor) { root._richSourceEditor.refresh(); }
}
function close() {
if (!self.fullscreen || self.fullscreen.editor !== editor) { return; }
root.classList.remove('rich-editor-fullscreen');
ancestors.forEach(function (node) { node.classList.remove('rich-editor-fullscreen-parent'); });
document.body.classList.remove('rich-editor-fullscreen-open');
document.removeEventListener('keydown', onKey, true);
window.removeEventListener('resize', refresh);
observer.disconnect();
editor.off('destroy', close);
self.fullscreen = null;
if (!editor.isDestroyed) { self.syncToolbar(root, editor); refresh(); }
window.scrollTo(scrollX, scrollY);
}
function onKey(event) {
if (event.key !== 'Escape') { return; }
var childDialog = Array.from(document.querySelectorAll('.overlay.show, .rich-editor-tools-overlay, .rich-editor-word-overlay, .rich-editor-link-overlay, .rich-editor-image-overlay, .rich-editor-document-picker-overlay, .media-picker-overlay.show')).some(function (node) { return !node.contains(root); });
if (childDialog) { return; }
event.preventDefault();
event.stopImmediatePropagation();
close();
}
var observer = new MutationObserver(function () { if (!root.isConnected || editor.isDestroyed) { close(); } });
observer.observe(document.body, { childList: true, subtree: true });
this.fullscreen = { editor: editor, close: close };
editor.on('destroy', close);
document.addEventListener('keydown', onKey, true);
window.addEventListener('resize', refresh);
this.syncToolbar(root, editor);
requestAnimationFrame(refresh);
},
openWordPaste: function (editor) {
var self = this;
var selection = { from: editor.state.selection.from, to: editor.state.selection.to };
var overlay = el('div', { class: 'overlay rich-editor-word-overlay' });
var modal = el('div', { class: 'modal rich-editor-prompt', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'richEditorWordTitle' });
modal.innerHTML = '<div class="modal-header"><span class="dialog-icon info"><i class="ti ti-clipboard-text"></i></span>' +
'<div style="flex:1"><h3 id="richEditorWordTitle">Вставить из Word</h3></div>' +
'<button class="modal-close" type="button" data-word-cancel aria-label="Закрыть"><i class="ti ti-x"></i></button></div>' +
'<div class="modal-body"><textarea class="input" rows="8" style="width:100%;resize:vertical;min-height:180px" aria-label="Текст из Word" placeholder="Вставьте скопированный текст" data-word-input></textarea></div>' +
'<div class="modal-footer"><button class="btn btn-ghost" type="button" data-word-cancel>Отмена</button><button class="btn btn-primary" type="button" data-word-apply disabled><i class="ti ti-check"></i>Вставить</button></div>';
overlay.appendChild(modal);
document.body.appendChild(overlay);
var input = overlay.querySelector('[data-word-input]');
var apply = overlay.querySelector('[data-word-apply]');
var html = null;
var closed = false;
function close() {
if (closed) { return; }
closed = true;
document.removeEventListener('keydown', onKey, true);
overlay.remove();
if (!editor.isDestroyed) { editor.commands.focus(); }
}
function onKey(event) {
if (event.key === 'Escape') { event.preventDefault(); event.stopImmediatePropagation(); close(); }
if (event.key === 'Tab') {
var controls = Array.from(modal.querySelectorAll('button:not(:disabled),textarea'));
var index = controls.indexOf(document.activeElement);
event.preventDefault();
controls[(index + (event.shiftKey ? controls.length - 1 : 1)) % controls.length].focus();
}
}
input.addEventListener('paste', function (event) {
var data = event.clipboardData;
if (!data) { return; }
event.preventDefault();
var source = data.getData('text/html');
html = source ? cleanWordHtml(source) : null;
input.value = data.getData('text/plain') || (html ? new DOMParser().parseFromString(html, 'text/html').body.textContent : '');
apply.disabled = !input.value.trim();
});
input.addEventListener('input', function () { html = null; apply.disabled = !input.value.trim(); });
overlay.addEventListener('click', function (event) {
if (event.target === overlay || event.target.closest('[data-word-cancel]')) { close(); return; }
if (!event.target.closest('[data-word-apply]') || apply.disabled || editor.isDestroyed) { return; }
var content = html !== null ? html : input.value.split(/\r?\n/).map(function (line) { return '<p>' + self.escape(line) + '</p>'; }).join('');
editor.chain().focus().insertContentAt(selection, content).run();
close();
});
document.addEventListener('keydown', onKey, true);
requestAnimationFrame(function () { if (!closed) { overlay.classList.add('show'); input.focus(); } });
},
syncToolbar: function (root, editor) {
@@ -278,12 +439,29 @@ var EnhancedImage = Image.extend({
var btn = root.querySelector('[data-rich-command="' + name + '"]');
if (btn) { btn.setAttribute('aria-pressed', states[name] ? 'true' : 'false'); }
});
var fullscreen = root.querySelector('[data-rich-command="fullscreen"]');
if (fullscreen) {
var expanded = root.classList.contains('rich-editor-fullscreen');
var label = tr(expanded ? 'Свернуть редактор' : 'Развернуть редактор');
fullscreen.setAttribute('aria-pressed', expanded ? 'true' : 'false');
fullscreen.setAttribute('aria-label', label);
fullscreen.setAttribute('data-tooltip', label);
fullscreen.innerHTML = icon(expanded ? 'arrows-minimize' : 'arrows-maximize');
}
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; }
}
var currentOnly = blockSelect.querySelector('[data-rich-current-block]');
if (currentOnly) { currentOnly.remove(); }
if (!Array.from(blockSelect.options).some(function (option) { return option.value === blockValue; })) {
var currentOption = el('option', { value: blockValue, 'data-rich-current-block': '' });
currentOption.textContent = 'H' + blockValue.replace('heading-', '') + ' (' + tr('в тексте') + ')';
currentOption.disabled = true;
blockSelect.appendChild(currentOption);
}
blockSelect.value = blockValue;
}
},
@@ -328,6 +506,7 @@ var EnhancedImage = Image.extend({
root.classList.remove('rich-editor-source-open');
this.setSourceControls(root, false);
this.syncToolbar(root, editor);
root.dispatchEvent(new Event('rich:mode-change'));
editor.commands.focus();
return;
}
@@ -352,23 +531,25 @@ var EnhancedImage = Image.extend({
storage.value = cm.getValue();
storage.setAttribute('data-rich-editor-dirty', '1');
storage.dispatchEvent(new Event('input', { bubbles: true }));
root.dispatchEvent(new Event('rich:source-change'));
});
}
var height = Math.max(260, Math.round(surface.getBoundingClientRect().height || 0));
root._richSourceSyncing = true;
root._richSourceEditor.setValue(editor.getHTML());
root._richSourceEditor.setValue(storage ? this.syncTextarea(storage, editor) : 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);
root.dispatchEvent(new Event('rich:mode-change'));
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; }
if (['source', 'fullscreen'].indexOf(control.getAttribute('data-rich-command')) !== -1) { return; }
control.disabled = !!sourceOpen;
});
},
+16 -3
View File
@@ -173,6 +173,7 @@
$authUser && Auth::ensureBrowserToken();
$canAdmin = $authUser !== null && Permission::checkAcp('admin_panel');
$isAttentionPoll = $requestPath === '/dashboard/attention';
$forcePasswordPath = in_array($requestPath, array('/account/password', '/logout', '/locale'), true);
if ($canAdmin && !empty($authUser['must_change_password']) && !$forcePasswordPath) {
if (Request::isAjax()) {
@@ -235,7 +236,7 @@
$canClearCache = Permission::check('manage_settings');
$cacheSizes = array();
if ($canClearCache) {
if ($canClearCache && !$isAttentionPoll) {
$cacheSizes = Cache::remember('adminx.cache.sizes.' . md5((string) ADMINX_BASE), 30, function () {
$out = array();
foreach (\App\Adminx\Settings\Model::cacheRows() as $row) {
@@ -252,7 +253,7 @@
'user_name' => $authUser['name'] ?? '',
'user_email' => $authUser['email'] ?? '',
'user_role' => $authUser['role'] ?? '',
'module_header_actions' => $canAdmin
'module_header_actions' => $canAdmin && !$isAttentionPoll
? \App\Adminx\Support\ModuleExtensions::headerActions()
: array(),
'can_clear_cache' => $canClearCache,
@@ -265,7 +266,7 @@
//-- Сводка уведомлений в шапке (новые заказы/письма) — только для админа.
$notifications = array('total' => 0, 'orders' => 0, 'messages' => 0, 'items' => array());
$notificationPreferences = array();
if ($canAdmin) {
if ($canAdmin && !$isAttentionPoll) {
$notificationPreferences = \App\Adminx\Support\ModuleExtensions::notificationPreferences();
$notificationKey = 'adminx.notifications.' . (int) Auth::id() . '.' . md5((string) ADMINX_BASE);
$notifications = Cache::remember($notificationKey, 15, function () {
@@ -280,6 +281,18 @@
'can_manage_notification_preferences' => Permission::check('manage_settings'),
));
$attention = array('items' => array());
$dashboardModule = \App\Common\ModuleManager::get('dashboard');
if ($canAdmin && !empty($dashboardModule['enabled'])) {
$attention = \App\Adminx\Dashboard\Attention::summary(ADMINX_BASE);
if ($attention['items'] && !$isAttentionPoll) {
AdminAssets::addStyle(ADMINX_BASE . '/modules/Dashboard/assets/attention.css', 55);
AdminAssets::addScript(ADMINX_BASE . '/modules/Dashboard/assets/attention.js', 55);
}
}
Twig::addGlobals(array('attention' => $attention, 'attention_user_id' => (int) Auth::id()));
if (!$isPublic && !$canAdmin) {
if (Request::isAjax()) {
Response::unauthorized();
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="attention_heading">Needs attention</phrase>
<phrase data="attention_empty">Nothing new</phrase>
<phrase data="attention_wait">Oldest waiting %s</phrase>
<phrase data="attention_waiting">Awaiting processing</phrase>
<phrase data="attention_minutes">min</phrase>
<phrase data="attention_hours">h</phrase>
<phrase data="attention_days">d</phrase>
<phrase data="attention_unavailable">Unable to refresh</phrase>
<phrase data="attention_stale">Updates temporarily unavailable</phrase>
<phrase data="attention_orders">New orders</phrase>
<phrase data="attention_messages">New messages</phrase>
<phrase data="attention_order_filter">New: awaiting processing</phrase>
</language>
+79
View File
@@ -1,5 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="rich_tool_001">Find and replace</phrase>
<phrase data="rich_tool_002">Find</phrase>
<phrase data="rich_tool_003">Replace with</phrase>
<phrase data="rich_tool_004">Replace</phrase>
<phrase data="rich_tool_005">Replace all</phrase>
<phrase data="rich_tool_006">Match case</phrase>
<phrase data="rich_tool_007">No matches</phrase>
<phrase data="rich_tool_008">Previous</phrase>
<phrase data="rich_tool_009">Next</phrase>
<phrase data="rich_tool_010">Special characters</phrase>
<phrase data="rich_tool_011">Non-breaking space</phrase>
<phrase data="rich_tool_012">Degree</phrase>
<phrase data="rich_tool_013">Multiplication</phrase>
<phrase data="rich_tool_014">Plus/minus</phrase>
<phrase data="rich_tool_015">Registered trademark</phrase>
<phrase data="rich_tool_016">Copyright</phrase>
<phrase data="rich_tool_017">Trademark</phrase>
<phrase data="rich_tool_018">Less than or equal</phrase>
<phrase data="rich_tool_019">Greater than or equal</phrase>
<phrase data="rich_tool_020">Micro</phrase>
<phrase data="rich_tool_021">Squared</phrase>
<phrase data="rich_tool_022">Cubed</phrase>
<phrase data="rich_tool_023">Opening quote</phrase>
<phrase data="rich_tool_024">Closing quote</phrase>
<phrase data="rich_tool_025">En dash</phrase>
<phrase data="rich_tool_026">Em dash</phrase>
<phrase data="rich_tool_027">Ellipsis</phrase>
<phrase data="rich_tool_028">Content fragments</phrase>
<phrase data="rich_tool_029">Warning</phrase>
<phrase data="rich_tool_030">Important</phrase>
<phrase data="rich_tool_031">Included items</phrase>
<phrase data="rich_tool_032">Specifications table</phrase>
<phrase data="rich_tool_033">Block order</phrase>
<phrase data="rich_tool_034">Move up</phrase>
<phrase data="rich_tool_035">Move down</phrase>
<phrase data="rich_tool_036">Formatting check</phrase>
<phrase data="rich_tool_037">Review formatting</phrase>
<phrase data="rich_tool_038">H1 in content</phrase>
<phrase data="rich_tool_039">Empty heading</phrase>
<phrase data="rich_tool_040">Empty link</phrase>
<phrase data="rich_tool_041">Inline formatting</phrase>
<phrase data="rich_tool_042">Extra empty paragraph</phrase>
<phrase data="rich_tool_043">Fix</phrase>
<phrase data="rich_tool_044">No issues found</phrase>
<phrase data="rich_tool_045">Save without changes</phrase>
<phrase data="rich_tool_046">Words</phrase>
<phrase data="rich_tool_047">Characters</phrase>
<phrase data="rich_tool_048">Selection</phrase>
<phrase data="rich_tool_049">in content</phrase>
<phrase data="rich_tool_050">Insert row above</phrase>
<phrase data="rich_tool_051">Insert row below</phrase>
<phrase data="rich_tool_052">Delete row</phrase>
<phrase data="rich_tool_053">Insert column left</phrase>
<phrase data="rich_tool_054">Insert column right</phrase>
<phrase data="rich_tool_055">Delete column</phrase>
<phrase data="rich_tool_056">Merge cells</phrase>
<phrase data="rich_tool_057">Split cell</phrase>
<phrase data="rich_tool_058">Header row</phrase>
<phrase data="rich_tool_059">Header column</phrase>
<phrase data="rich_tool_060">Delete table</phrase>
<phrase data="rich_tool_061">Link to document or product</phrase>
<phrase data="rich_tool_062">Expand editor</phrase>
<phrase data="rich_tool_063">Collapse editor</phrase>
<phrase data="rich_tool_064">Visual editor toolbar</phrase>
<phrase data="rich_tool_065">Full</phrase>
<phrase data="rich_tool_066">Compact</phrase>
<phrase data="rich_tool_067">Product description</phrase>
<phrase data="rich_tool_068">News / article</phrase>
<phrase data="rich_tool_069">Headings in toolbar</phrase>
<phrase data="rich_tool_070">Use profile</phrase>
<phrase data="rich_tool_071">No headings</phrase>
<phrase data="rich_tool_072">Informational</phrase>
<phrase data="rich_tool_073">Product</phrase>
<phrase data="rich_tool_074">Hide</phrase>
<phrase data="rich_tool_075">Hide images in toolbar</phrase>
<phrase data="rich_tool_076">Hide tables in toolbar</phrase>
<phrase data="rich_tool_077">Hide source code in toolbar</phrase>
<phrase data="rich_tool_078">Hide alignment in toolbar</phrase>
<phrase data="rich_tool_079">Hide block ordering in toolbar</phrase>
<phrase data="action_done">Done</phrase>
<phrase data="auto_1cc70f8e0fc03e82">Delete view</phrase>
<phrase data="auto_86433d57abc272e9">Saved Views</phrase>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="attention_heading">Требуют внимания</phrase>
<phrase data="attention_empty">Новых нет</phrase>
<phrase data="attention_wait">Самое старое ждёт %s</phrase>
<phrase data="attention_waiting">Ожидают обработки</phrase>
<phrase data="attention_minutes">мин</phrase>
<phrase data="attention_hours">ч</phrase>
<phrase data="attention_days">дн</phrase>
<phrase data="attention_unavailable">Не удалось обновить</phrase>
<phrase data="attention_stale">Данные временно не обновляются</phrase>
<phrase data="attention_orders">Новые заказы</phrase>
<phrase data="attention_messages">Новые обращения</phrase>
<phrase data="attention_order_filter">Новые: ожидают обработки</phrase>
</language>
+79
View File
@@ -1,5 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="rich_tool_001">Найти и заменить</phrase>
<phrase data="rich_tool_002">Найти</phrase>
<phrase data="rich_tool_003">Заменить на</phrase>
<phrase data="rich_tool_004">Заменить</phrase>
<phrase data="rich_tool_005">Заменить все</phrase>
<phrase data="rich_tool_006">Учитывать регистр</phrase>
<phrase data="rich_tool_007">Нет совпадений</phrase>
<phrase data="rich_tool_008">Предыдущее</phrase>
<phrase data="rich_tool_009">Следующее</phrase>
<phrase data="rich_tool_010">Специальные символы</phrase>
<phrase data="rich_tool_011">Неразрывный пробел</phrase>
<phrase data="rich_tool_012">Градус</phrase>
<phrase data="rich_tool_013">Умножение</phrase>
<phrase data="rich_tool_014">Плюс-минус</phrase>
<phrase data="rich_tool_015">Зарегистрированный знак</phrase>
<phrase data="rich_tool_016">Авторское право</phrase>
<phrase data="rich_tool_017">Товарный знак</phrase>
<phrase data="rich_tool_018">Меньше или равно</phrase>
<phrase data="rich_tool_019">Больше или равно</phrase>
<phrase data="rich_tool_020">Микро</phrase>
<phrase data="rich_tool_021">Квадрат</phrase>
<phrase data="rich_tool_022">Куб</phrase>
<phrase data="rich_tool_023">Открывающая кавычка</phrase>
<phrase data="rich_tool_024">Закрывающая кавычка</phrase>
<phrase data="rich_tool_025">Короткое тире</phrase>
<phrase data="rich_tool_026">Длинное тире</phrase>
<phrase data="rich_tool_027">Многоточие</phrase>
<phrase data="rich_tool_028">Готовые фрагменты</phrase>
<phrase data="rich_tool_029">Внимание</phrase>
<phrase data="rich_tool_030">Важно</phrase>
<phrase data="rich_tool_031">Комплектация</phrase>
<phrase data="rich_tool_032">Таблица характеристик</phrase>
<phrase data="rich_tool_033">Порядок блоков</phrase>
<phrase data="rich_tool_034">Переместить вверх</phrase>
<phrase data="rich_tool_035">Переместить вниз</phrase>
<phrase data="rich_tool_036">Проверка оформления</phrase>
<phrase data="rich_tool_037">Проверьте оформление</phrase>
<phrase data="rich_tool_038">H1 в тексте</phrase>
<phrase data="rich_tool_039">Пустой заголовок</phrase>
<phrase data="rich_tool_040">Пустая ссылка</phrase>
<phrase data="rich_tool_041">Встроенное оформление</phrase>
<phrase data="rich_tool_042">Лишний пустой абзац</phrase>
<phrase data="rich_tool_043">Исправить</phrase>
<phrase data="rich_tool_044">Замечаний нет</phrase>
<phrase data="rich_tool_045">Сохранить без исправлений</phrase>
<phrase data="rich_tool_046">Слов</phrase>
<phrase data="rich_tool_047">Символов</phrase>
<phrase data="rich_tool_048">Выделено</phrase>
<phrase data="rich_tool_049">в тексте</phrase>
<phrase data="rich_tool_050">Строка выше</phrase>
<phrase data="rich_tool_051">Строка ниже</phrase>
<phrase data="rich_tool_052">Удалить строку</phrase>
<phrase data="rich_tool_053">Столбец слева</phrase>
<phrase data="rich_tool_054">Столбец справа</phrase>
<phrase data="rich_tool_055">Удалить столбец</phrase>
<phrase data="rich_tool_056">Объединить ячейки</phrase>
<phrase data="rich_tool_057">Разделить ячейку</phrase>
<phrase data="rich_tool_058">Строка заголовков</phrase>
<phrase data="rich_tool_059">Столбец заголовков</phrase>
<phrase data="rich_tool_060">Удалить таблицу</phrase>
<phrase data="rich_tool_061">Ссылка на документ или товар</phrase>
<phrase data="rich_tool_062">Развернуть редактор</phrase>
<phrase data="rich_tool_063">Свернуть редактор</phrase>
<phrase data="rich_tool_064">Панель визуального редактора</phrase>
<phrase data="rich_tool_065">Полная</phrase>
<phrase data="rich_tool_066">Компактная</phrase>
<phrase data="rich_tool_067">Описание товара</phrase>
<phrase data="rich_tool_068">Новость / статья</phrase>
<phrase data="rich_tool_069">Заголовки в панели</phrase>
<phrase data="rich_tool_070">По профилю</phrase>
<phrase data="rich_tool_071">Без заголовков</phrase>
<phrase data="rich_tool_072">Информационные</phrase>
<phrase data="rich_tool_073">Товарные</phrase>
<phrase data="rich_tool_074">Не показывать</phrase>
<phrase data="rich_tool_075">Скрыть в панели: изображения</phrase>
<phrase data="rich_tool_076">Скрыть в панели: таблицы</phrase>
<phrase data="rich_tool_077">Скрыть в панели: исходный код</phrase>
<phrase data="rich_tool_078">Скрыть в панели: выравнивание</phrase>
<phrase data="rich_tool_079">Скрыть в панели: перемещение блоков</phrase>
<phrase data="action_done">Готово</phrase>
<phrase data="auto_1cc70f8e0fc03e82">Удалить представление</phrase>
<phrase data="auto_86433d57abc272e9">Сохранённые представления</phrase>
+4 -38
View File
@@ -16,6 +16,8 @@
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Helpers\PhpSyntax;
class Syntax
{
public static function check($code, $eval)
@@ -30,44 +32,8 @@
);
}
$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 . '<?php ');
if ($written === false) {
@unlink($file);
return self::unavailable('Не удалось записать временный файл для проверки.');
}
$php = defined('PHP_BINARY') && PHP_BINARY ? PHP_BINARY : 'php';
$cmd = escapeshellarg($php) . ' -l ' . escapeshellarg($file) . ' 2>&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,
);
$result = PhpSyntax::check('<?php ?>' . (string) $code);
return $result['checked'] ? $result : self::unavailable($result['message']);
}
protected static function unavailable($message)
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="runtime_c55667032216bcac">Built-in PHP syntax checking is not available on this server.</phrase>
<phrase data="runtime_287ef8ef854ce1aa">(copy)</phrase>
<phrase data="runtime_eb35949a5a0f8ff5">PHP CLI is not available for syntax checking.</phrase>
<phrase data="runtime_46c24d5ef1a06bf8">PHP syntax: error in block code</phrase>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="runtime_c55667032216bcac">Встроенная проверка PHP недоступна на сервере.</phrase>
<phrase data="runtime_287ef8ef854ce1aa">(копия)</phrase>
<phrase data="runtime_eb35949a5a0f8ff5">PHP CLI недоступен для проверки синтаксиса.</phrase>
<phrase data="runtime_46c24d5ef1a06bf8">PHP-синтаксис: ошибка в коде блока</phrase>
+1 -1
View File
@@ -17,7 +17,7 @@
return array(
'code' => 'blocks',
'name' => 'Блоки',
'version' => '0.1.4',
'version' => '0.1.6',
'permissions' => array(
'key' => 'blocks',
+43 -19
View File
@@ -35,6 +35,7 @@
use App\Adminx\Rubrics\Model as RubricsModel;
use App\Adminx\Documents\Model as DocumentsModel;
use App\Adminx\Packages\Products\ProductIndexer;
use App\Adminx\Packages\Products\Attributes;
class Model
{
@@ -1137,27 +1138,50 @@
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)));
}
$item = static::item((int) $itemId);
if (!$item || (int) $item['rubric_id'] !== (int) $rubricId) { throw new \RuntimeException('Раздел каталога не найден'); }
$settings = static::settings((int) $rubricId, (int) $item['field_id']);
$native = isset($item['filter_runtime']) && in_array($item['filter_runtime'], array('native', 'shadow'), true);
$products = static::productsAvailable() && isset($settings['purpose']) && $settings['purpose'] === 'commerce';
if ($native && !$products) { throw new \RuntimeException('Для нативных фильтров включите модуль «Товары»'); }
if (!$native && $products && !method_exists(ProductIndexer::class, 'rebuildFilterIndex')) { throw new \RuntimeException('Обновите модуль «Товары» для пересборки индекса фильтров'); }
$requestId = isset($settings['request_id']) ? (int) $settings['request_id'] : 0;
if (!$native && !$products && $requestId < 1) { throw new \RuntimeException('Сначала выберите запрос фильтра в настройках каталога'); }
$owns = !DB::$transaction_in_progress;
$previousMode = DB::$throw_exception_on_error;
$savepoint = 'catalog_filters_' . bin2hex(random_bytes(6));
$started = false;
DB::$throw_exception_on_error = true;
try {
if ($owns) { DB::startTransaction(); } else { DB::query('SAVEPOINT ' . $savepoint); }
$started = true;
$result = array('item_id' => (int) $itemId, 'runtime' => $native ? 'native' : 'legacy', 'request_id' => 0, 'products' => 0, 'filters' => 0);
if ($native) {
$report = Attributes::rebuildShadowIndex((int) $itemId);
$result['filters'] = (int) $report['indexed_values'];
$result['products'] = (int) DB::query('SELECT COUNT(DISTINCT product_id) FROM ' . Attributes::table('attribute_filter_index') . ' WHERE catalog_item_id=%i', (int) $itemId)->getValue();
} else {
if ($requestId > 0) {
$compiled = \App\Content\Requests\RequestConditionCompiler::compile($requestId, true);
if (!$compiled) { throw new \RuntimeException('Запрос фильтра не найден'); }
$result['request_id'] = $requestId;
}
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; }
}
if ($products) {
$ids = array_merge(array((int) $itemId), isset($item['source_item_ids']) ? (array) $item['source_item_ids'] : array());
$result = array_merge($result, ProductIndexer::rebuildFilterIndex($ids));
}
}
throw new \RuntimeException('CLI-интерпретатор PHP не найден');
if ($owns) { DB::commit(); } else { DB::query('RELEASE SAVEPOINT ' . $savepoint); }
} catch (\Throwable $e) {
if ($started && $owns) { DB::rollback(); }
elseif ($started) { DB::query('ROLLBACK TO SAVEPOINT ' . $savepoint); DB::query('RELEASE SAVEPOINT ' . $savepoint); }
throw $e;
} finally { DB::$throw_exception_on_error = $previousMode; }
DB::afterCommit(function () { static::clearCache(); });
$result['output'] = $native ? 'Нативный индекс фильтров пересобран.' : 'Условия запроса и индекс фильтров пересобраны.';
return $result;
}
protected static function rebuildRequest($requestId)
+1 -1
View File
@@ -14,7 +14,7 @@
defined('BASEPATH') || die('Direct access to this location is not allowed.');
return array(
'code' => 'catalog', 'name' => 'Каталог', 'version' => '0.3.9',
'code' => 'catalog', 'name' => 'Каталог', 'version' => '0.3.10',
'permissions' => array('key' => 'catalog', 'items' => array(
array(
'code' => 'view_catalog',
+2 -95
View File
@@ -51,6 +51,7 @@
throw new \RuntimeException($lint['message'] . ($lint['line'] ? ' Строка ' . $lint['line'] . '.' : ''));
}
$php = PhpCode::cliBinary();
$dir = TemporaryDirectory::create('console');
$codeFile = $dir . '/snippet.php';
@@ -70,7 +71,6 @@
@file_put_contents($codeFile, PhpCode::source($code));
@file_put_contents($runnerFile, $runner);
$php = self::resolvePhpBinary();
$command = escapeshellarg($php)
. ' -d display_errors=1 -d memory_limit=128M'
. ' -d disable_functions=exec,shell_exec,system,passthru,popen'
@@ -82,7 +82,7 @@
0 => array('pipe', 'r'),
1 => array('pipe', 'w'),
2 => array('pipe', 'w'),
), $pipes, $dir);
), $pipes, $dir, null, array('bypass_shell' => DIRECTORY_SEPARATOR === '\\'));
if (!is_resource($process)) {
self::cleanup($dir);
throw new \RuntimeException('Не удалось запустить PHP-процесс.');
@@ -137,99 +137,6 @@
);
}
/** 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;
}
$config = PublicConfiguration::value('admin_console', array());
if (is_array($config) && !empty($config['php_binary'])) {
$candidates[] = trim((string) $config['php_binary']);
}
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)
{
+2 -2
View File
@@ -20,7 +20,7 @@
return array(
'code' => 'console',
'name' => 'PHP-консоль',
'version' => '0.1.2',
'version' => '0.1.3',
'registry' => array('dynamic' => true),
);
}
@@ -28,7 +28,7 @@
return array(
'code' => 'console',
'name' => 'PHP-консоль',
'version' => '0.1.2',
'version' => '0.1.3',
'registry' => array('dynamic' => true),
'permissions' => array(
'key' => 'console',
+68
View File
@@ -0,0 +1,68 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Dashboard/Attention.php
| @author AVE.cms <support@ave-cms.ru>
| @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\AdminLocale;
use App\Adminx\Support\ModuleExtensions;
class Attention
{
protected static $summary;
public static function summary($base)
{
if (self::$summary !== null) { return self::$summary; }
$items = array();
foreach (ModuleExtensions::attentionQueues() as $queue) {
$data = isset($queue['data']) ? $queue['data'] : array();
$item = self::normalize($data, $queue['layout_code'], $base, time());
if ($item !== null) { $items[] = $item; }
}
return self::$summary = array('items' => $items, 'updated_at' => time());
}
public static function normalize(array $data, $key, $base, $now)
{
$url = isset($data['url']) ? (string) $data['url'] : '';
if (empty($data['title']) || !preg_match('~^/(?!/)[a-zA-Z0-9/_?=&.%-]+$~D', $url)) { return null; }
$available = !isset($data['available']) || $data['available'];
$count = max(0, (int) (isset($data['count']) ? $data['count'] : 0));
$oldest = max(0, (int) (isset($data['oldest_at']) ? $data['oldest_at'] : 0));
$wait = '';
if (!$available) {
$wait = AdminLocale::text('attention_unavailable', 'Не удалось обновить');
} elseif ($count === 0) {
$wait = AdminLocale::text('attention_empty', 'Новых нет');
} elseif ($oldest > 0) {
$minutes = max(1, (int) floor(($now - $oldest) / 60));
$wait = sprintf(AdminLocale::text('attention_wait', 'Самое старое ждёт %s'),
$minutes < 60 ? $minutes . ' ' . AdminLocale::text('attention_minutes', 'мин')
: ($minutes < 1440 ? (int) floor($minutes / 60) . ' ' . AdminLocale::text('attention_hours', 'ч')
: (int) floor($minutes / 1440) . ' ' . AdminLocale::text('attention_days', 'дн')));
} else {
$wait = AdminLocale::text('attention_waiting', 'Ожидают обработки');
}
return array('key' => (string) $key, 'title' => AdminLocale::translateMarkup($data['title']),
'icon' => isset($data['icon']) && preg_match('/^ti ti-[a-z0-9-]+$/D', $data['icon']) ? $data['icon'] : 'ti ti-bell',
'tone' => isset($data['tone']) && in_array($data['tone'], array('blue', 'green'), true) ? $data['tone'] : 'amber',
'count' => $count, 'available' => (bool) $available, 'oldest_at' => $oldest,
'latest_id' => max(0, (int) (isset($data['latest_id']) ? $data['latest_id'] : 0)),
'url' => rtrim((string) $base, '/') . $url, 'wait' => $wait);
}
}
+10
View File
@@ -31,6 +31,16 @@
*/
class Controller extends BaseController
{
public function attention(array $params = array())
{
if (!\App\Common\Auth::user() || !\App\Common\Permission::checkAcp('admin_panel')) {
return $this->error('Недостаточно прав', array(), 403);
}
\App\Helpers\Request::setHeader('Cache-Control: private, no-store', true);
return $this->success('', array('data' => Attention::summary($this->base())));
}
/** GET / и /dashboard */
public function index(array $params = [])
{
@@ -0,0 +1,252 @@
.attention-topbar {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.attention-topbar[hidden],
.attention-overview[hidden] {
display: none;
}
.attention-shortcut,
.attention-card {
--attention-color: var(--amber-700);
--attention-soft: var(--amber-100);
--attention-border: var(--amber-300);
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
text-decoration: none;
}
.attention-shortcut.is-blue,
.attention-card.is-blue {
--attention-color: var(--blue-700);
--attention-soft: var(--blue-100);
--attention-border: var(--blue-300);
}
.attention-shortcut.is-green,
.attention-card.is-green {
--attention-color: var(--green-700);
--attention-soft: var(--green-100);
--attention-border: var(--green-300);
}
.attention-shortcut.has-pending,
.attention-card.has-pending {
color: var(--attention-color);
background: var(--attention-soft);
border-color: var(--attention-border);
}
.attention-shortcut:hover,
.attention-card:hover {
color: var(--attention-color);
text-decoration: none;
}
.attention-shortcut:focus-visible,
.attention-card:focus-visible {
outline: 2px solid var(--attention-color);
outline-offset: 2px;
}
.attention-shortcut.is-arriving,
.attention-card.is-arriving {
animation: attention-arrival 0.7s ease-out 2;
}
.attention-shortcut[hidden],
.attention-card[hidden] {
display: none;
}
.attention-shortcut {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 36px;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--border-default);
border-radius: 6px;
background: var(--surface-card);
white-space: nowrap;
}
.attention-shortcut > .ti {
font-size: 18px;
}
.attention-shortcut b {
position: absolute;
top: -6px;
right: -5px;
display: flex;
align-items: center;
justify-content: center;
height: 18px;
min-width: 18px;
padding: 0 4px;
border-radius: 9px;
border: 2px solid var(--surface-card);
background: var(--attention-color);
color: #fff;
font-size: 10px;
line-height: 1;
text-align: center;
}
.attention-overview {
margin-bottom: 24px;
}
.attention-heading {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.attention-heading h2 {
margin: 0;
font-size: 16px;
line-height: 1.4;
}
.attention-heading > span {
color: var(--text-muted);
font-size: 12px;
}
.attention-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: 16px;
}
.attention-card {
display: flex;
align-items: flex-start;
gap: 16px;
min-width: 0;
min-height: 140px;
padding: 20px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-card);
}
.attention-card-icon {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 42px;
height: 42px;
border-radius: 8px;
background: var(--surface-card);
font-size: 24px;
}
.attention-card-body {
display: grid;
min-width: 0;
gap: 5px;
}
.attention-card-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 600;
}
.attention-card-body strong {
font-size: 30px;
line-height: 1.15;
overflow-wrap: anywhere;
}
.attention-wait {
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
}
.attention-card-arrow {
flex: 0 0 auto;
margin-left: auto;
font-size: 19px;
}
.attention-dot {
display: none;
flex: 0 0 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.has-pending .attention-dot {
display: inline-block;
animation: attention-pulse 2.2s ease-in-out infinite;
}
.attention-shortcut.is-stale {
opacity: 0.65;
}
@keyframes attention-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@keyframes attention-arrival {
0%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-3px);
}
}
@media (max-width: 680px) {
.attention-grid {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
.attention-card {
padding: 16px;
min-height: 125px;
gap: 12px;
}
.attention-topbar {
gap: 7px;
}
.topbar.has-attention {
padding-inline: 8px;
gap: 6px;
}
.topbar.has-attention .topbar-right {
gap: 4px;
}
.topbar.has-attention .topbar-site-link,
.topbar.has-attention .env-badge {
display: none;
}
.topbar.has-attention .logo-suffix {
display: none;
}
.topbar.has-attention .topbar-logo .logo-img {
width: 52px;
height: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.has-pending .attention-dot,
.attention-shortcut.is-arriving,
.attention-card.is-arriving {
animation: none;
}
}
@media (max-width: 380px) {
.topbar.has-attention .logo-suffix {
display: none;
}
.topbar.has-attention .topbar-logo .logo-img {
width: 52px;
height: auto;
}
.attention-shortcut {
flex-basis: 32px;
width: 32px;
height: 32px;
}
}
@media (max-width: 480px) {
.topbar.has-attention .topbar-logo {
display: none;
}
}
@@ -0,0 +1,91 @@
(function (window, document) {
'use strict';
var A = window.Adminx || (window.Adminx = {});
var root = document.querySelector('[data-attention-root]');
if (!root) return;
var endpoint = root.dataset.attentionUrl;
var storageKey = 'ave.attention.' + endpoint + '.' + root.dataset.attentionUser;
var timer = null, busy = false, stopped = false, lastRequest = 0, seen = {};
try { seen = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {}; } catch (e) {}
function saveSeen() { try { sessionStorage.setItem(storageKey, JSON.stringify(seen)); } catch (e) {} }
function update(payload) {
var present = {};
payload.items.forEach(function (item) {
present[item.key] = true;
var baseline = seen[item.key];
var arriving = item.available && baseline && (item.latest_id > baseline.latest_id || item.count > baseline.count);
if (item.available) {
seen[item.key] = { latest_id: Math.max(item.latest_id, baseline ? baseline.latest_id : 0), count: item.count };
}
document.querySelectorAll('[data-attention-key]').forEach(function (el) {
if (el.dataset.attentionKey !== item.key) return;
el.hidden = false;
el.href = item.url;
el.classList.toggle('has-pending', item.available && item.count > 0);
el.classList.toggle('is-stale', !item.available);
var count = el.querySelector('[data-attention-count]');
if (count) count.textContent = item.available ? (el.classList.contains('attention-shortcut') && item.count > 99 ? '99+' : item.count) : '\u2014';
var wait = el.querySelector('[data-attention-wait]');
if (wait) wait.textContent = item.wait;
if (el.classList.contains('attention-shortcut')) {
var label = item.title + ': ' + (item.available ? item.count : '\u2014') + ' \u00b7 ' + item.wait;
el.setAttribute('aria-label', label);
el.setAttribute('data-tooltip', label);
}
if (arriving) {
el.classList.remove('is-arriving');
void el.offsetWidth;
el.classList.add('is-arriving');
window.setTimeout(function () { el.classList.remove('is-arriving'); }, 1500);
}
});
});
document.querySelectorAll('[data-attention-key]').forEach(function (el) {
if (!present[el.dataset.attentionKey]) el.hidden = true;
});
var any = Object.keys(present).length > 0;
root.hidden = !any;
var overview = document.querySelector('[data-attention-overview]');
if (overview) overview.hidden = !any;
stale(false);
saveSeen();
}
function stale(value) {
root.classList.toggle('is-stale', value);
document.querySelectorAll('[data-attention-health]').forEach(function (el) { el.hidden = !value; });
if (value) root.querySelectorAll('.attention-shortcut').forEach(function (el) { el.classList.add('is-stale'); });
}
function schedule(delay) {
window.clearTimeout(timer);
if (!stopped && !document.hidden) timer = window.setTimeout(refresh, delay || 30000);
}
function refresh() {
if (stopped || busy || document.hidden) return;
if (Date.now() - lastRequest < 3000) { schedule(3000); return; }
busy = true; lastRequest = Date.now();
var controller = new AbortController();
var timeout = window.setTimeout(function () { controller.abort(); }, 10000);
fetch(endpoint, { credentials: 'same-origin', cache: 'no-store', signal: controller.signal,
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } })
.then(function (response) {
if (response.status === 401 || response.status === 403 || response.status === 428 || response.redirected) {
stopped = true;
throw Error('Session unavailable');
}
if (!response.ok) throw Error('Refresh failed');
return response.json();
}).then(function (result) {
if (!result.success || !result.data || !Array.isArray(result.data.items)) throw Error('Invalid summary');
update(result.data);
}).catch(function () { stale(true); })
.finally(function () { window.clearTimeout(timeout); busy = false; schedule(); });
}
try { update(JSON.parse(root.dataset.attentionInitial)); } catch (e) { stale(true); }
document.addEventListener('visibilitychange', function () { window.clearTimeout(timer); if (!document.hidden) refresh(); });
document.addEventListener('adminx:drawer:closed', function () { schedule(1000); });
document.addEventListener('adminx:form-saved', function () { schedule(1000); });
window.addEventListener('pageshow', function (event) { if (event.persisted) refresh(); });
A.Attention = { refresh: refresh };
schedule();
})(window, document);
+2 -1
View File
@@ -21,7 +21,7 @@
return [
'code' => 'dashboard',
'name' => 'Дашборд',
'version' => '0.1.0',
'version' => '0.2.3',
'navigation' => array(
array(
@@ -39,6 +39,7 @@
),
'routes' => array(
array('GET', '/dashboard/attention', array(\App\Adminx\Dashboard\Controller::class, 'attention')),
array('GET', '/', array(\App\Adminx\Dashboard\Controller::class, 'index')),
array('GET', '/dashboard', array(\App\Adminx\Dashboard\Controller::class, 'index')),
),
+15
View File
@@ -5,6 +5,21 @@
<div class="page-header dashboard-page-header"><div><h1>{{ greeting }}</h1><p class="text-secondary">{{ lang.dashboard_summary|default('Сводка системы и последние изменения') }}</p></div><div class="dashboard-date"><i class="ti ti-calendar"></i>{{ 'now'|date('d.m.Y') }}</div></div>
{% if attention.items %}
<section class="attention-overview" data-attention-overview aria-labelledby="attention-heading">
<div class="attention-heading"><h2 id="attention-heading">{{ lang.attention_heading|default('Требуют внимания') }}</h2><span data-attention-health hidden>{{ lang.attention_stale|default('Данные временно не обновляются') }}</span></div>
<div class="attention-grid">
{% for item in attention.items %}
<a class="attention-card is-{{ item.tone }}{{ item.available and item.count > 0 ? ' has-pending' : '' }}" href="{{ item.url }}" data-attention-key="{{ item.key }}">
<span class="attention-card-icon"><i class="{{ item.icon }}" aria-hidden="true"></i></span>
<span class="attention-card-body"><span class="attention-card-title">{{ item.title }}<i class="attention-dot" aria-hidden="true"></i></span><strong data-attention-count>{{ item.available ? item.count : '—' }}</strong><span class="attention-wait" data-attention-wait>{{ item.wait }}</span></span>
<i class="ti ti-arrow-up-right attention-card-arrow" aria-hidden="true"></i>
</a>
{% endfor %}
</div>
</section>
{% endif %}
{% if dashboard_widgets %}
<div class="dashboard-module-widgets">
{% for dashboard_widget in dashboard_widgets %}
+57 -6
View File
@@ -71,6 +71,7 @@
'rubrics' => Model::rubrics(),
'document_fields' => self::$documentFields,
'products_available' => self::productsAvailable(),
'product_descriptions_available' => self::productDescriptionsAvailable(),
);
}
@@ -78,6 +79,7 @@
{
$filters = self::filters($input);
$operation = self::operation($input, $filters);
if ($operation['type'] === 'strip_description_styles') { $filters['scope'] = 'products'; }
$matches = self::matches($filters);
if (!$matches['ids']) {
throw new \InvalidArgumentException('По выбранным условиям документы не найдены');
@@ -103,10 +105,10 @@
'title' => html_entity_decode((string) $row['document_title'], ENT_QUOTES, 'UTF-8'),
'rubric' => html_entity_decode((string) $row['rubric_title'], ENT_QUOTES, 'UTF-8'),
'state' => (int) $row['document_status'] === 1 ? 'Опубликован' : 'Черновик',
'before' => self::shortValue($change['before']),
'after' => self::shortValue($change['after']),
'before' => $operation['type'] === 'strip_description_styles' ? mb_substr($change['before'], 0, 1000, 'UTF-8') : self::shortValue($change['before']),
'after' => $operation['type'] === 'strip_description_styles' ? mb_substr($change['after'], 0, 1000, 'UTF-8') : self::shortValue($change['after']),
'changed' => $change['changed'],
'note' => $change['note'],
'note' => \App\Common\Language::translateSource($change['note']),
);
}
@@ -211,6 +213,17 @@
if (!$row) { return false; }
$type = $operation['type'];
if ($type === 'strip_description_styles') {
if (!self::productDescriptionsAvailable()) { throw new \InvalidArgumentException('Нет доступа к описаниям товаров'); }
$fieldId = self::productDescriptionField($row);
if (!$fieldId) { return false; }
$current = self::currentValue($row, array('target_type' => 'field', 'field_id' => $fieldId));
$next = self::changedValue($current, $operation);
if (!$next['changed']) { return false; }
$service->patch($documentId, array('fields' => array($fieldId => $next['value'])), $actorId, 'bulk_editor');
return true;
}
if (isset($operation['target_type']) && $operation['target_type'] === 'state') {
if ($type === 'technical' && Model::isProtectedDocument($documentId)) { return false; }
$current = (string) $row[$operation['target']];
@@ -272,7 +285,7 @@
{
$type = isset($input['operation']) ? (string) $input['operation'] : '';
$allowed = array_merge(
array('fill', 'set', 'clear', 'replace', 'move', 'publish', 'unpublish', 'recalculate'),
array('fill', 'set', 'clear', 'replace', 'move', 'publish', 'unpublish', 'recalculate', 'strip_description_styles'),
array_keys(self::$stateOperations)
);
if (!in_array($type, $allowed, true)) {
@@ -280,6 +293,11 @@
}
$operation = array('type' => $type, 'label' => self::operationLabel($type));
if ($type === 'strip_description_styles') {
if (!self::productDescriptionsAvailable()) { throw new \InvalidArgumentException('Нет доступа к описаниям товаров'); }
return array_merge($operation, array('target_type' => 'product_description', 'target_label' => 'Описание товара'));
}
if (isset(self::$stateOperations[$type])) {
$config = self::$stateOperations[$type];
$value = isset($config['value']) ? (string) $config['value'] : trim(isset($input[$config['value_input']]) ? (string) $input[$config['value_input']] : '');
@@ -400,6 +418,12 @@
foreach ($valueRows ?: array() as $valueRow) {
$currentValues[(int) $valueRow['Id']] = html_entity_decode((string) $valueRow['current_value'], ENT_QUOTES, 'UTF-8');
}
} elseif (isset($operation['target_type']) && $operation['target_type'] === 'product_description') {
foreach (array_chunk($ids, 500) as $chunk) {
$valueRows = DB::query('SELECT p.product_id,' . \App\Modules\Products\ProductRoles::valueSql('description')
. ' current_value FROM ' . CatalogTables::table('catalog_product_index') . ' p WHERE p.product_id IN %li AND p.is_deleted=0', $chunk)->getAll();
foreach ($valueRows ?: array() as $valueRow) { $currentValues[(int) $valueRow['product_id']] = (string) $valueRow['current_value']; }
}
} elseif (isset($operation['target_type']) && $operation['target_type'] === 'field') {
$valueRows = DB::query(
'SELECT df.document_id,CONCAT(COALESCE(df.field_value,\'\'),COALESCE(dft.field_value,\'\')) current_value'
@@ -444,7 +468,7 @@
}
if ($operation['type'] === 'move') {
return array('before' => (string) $row['rubric_title'], 'after' => (string) $operation['target_rubric_title'], 'changed' => (int) $row['rubric_id'] !== (int) $operation['target_rubric_id'], 'note' => 'Совпадающие поля переносятся по системному имени');
return array('before' => (string) $row['rubric_title'], 'after' => (string) $operation['target_rubric_title'], 'changed' => (int) $row['rubric_id'] !== (int) $operation['target_rubric_id'], 'note' => 'Переносятся только поля с одинаковыми системными именами и типами. Значения остальных полей не сохранятся в новой рубрике.');
}
if ($operation['target_type'] === 'state') {
@@ -460,7 +484,7 @@
$current = self::currentValue($row, $operation);
$next = self::changedValue($current, $operation);
return array('before' => $current, 'after' => $next['value'], 'changed' => $next['changed'], 'note' => $next['changed'] ? '' : 'Значение уже соответствует действию');
return array('before' => $current, 'after' => $next['value'], 'changed' => $next['changed'], 'note' => isset($next['removed']) ? 'Удалится атрибутов style: ' . $next['removed'] : ($next['changed'] ? '' : 'Значение уже соответствует действию'));
}
protected static function currentValue(array $row, array $operation)
@@ -469,6 +493,11 @@
return (string) $row['__bulk_current'];
}
if ($operation['target_type'] === 'product_description') {
$fieldId = self::productDescriptionField($row);
return $fieldId ? self::currentValue($row, array('target_type' => 'field', 'field_id' => $fieldId)) : '';
}
if ($operation['target_type'] === 'document' || $operation['target_type'] === 'state') {
if (!array_key_exists($operation['target'], $row)) {
$value = DB::query(
@@ -496,6 +525,11 @@
{
$current = (string) $current;
$type = $operation['type'];
if ($type === 'strip_description_styles') {
$result = \App\Helpers\Html::removeInlineStyles($current);
return array('value' => $result['html'], 'changed' => $result['removed'] > 0, 'removed' => $result['removed']);
}
if ($type === 'fill') {
$value = trim($current) === '' ? (string) $operation['value'] : $current;
} elseif ($type === 'clear') {
@@ -584,10 +618,27 @@
}
}
protected static function productDescriptionsAvailable()
{
$module = \App\Common\ModuleManager::get('products');
return $module && !empty($module['enabled']) && \App\Common\Permission::check('manage_products')
&& class_exists('App\\Modules\\Products\\ProductRoles') && self::productsAvailable();
}
protected static function productDescriptionField(array $row)
{
$product = DB::query('SELECT rubric_id FROM ' . CatalogTables::table('catalog_product_index')
. ' WHERE product_id=%i AND is_deleted=0', (int) $row['Id'])->getAssoc();
if (!$product || (int) $product['rubric_id'] !== (int) $row['rubric_id']) { return 0; }
$roles = \App\Modules\Products\ProductRoles::forRubric((int) $row['rubric_id']);
return isset($roles['description']) ? (int) $roles['description'] : 0;
}
protected static function operationLabel($type)
{
$labels = array(
'fill' => 'Заполнить пустые', 'set' => 'Установить значение', 'clear' => 'Очистить',
'strip_description_styles' => 'Удалить стили из описаний товаров',
'replace' => 'Найти и заменить', 'move' => 'Перенести в рубрику',
'publish' => 'Опубликовать', 'unpublish' => 'Снять с публикации',
'recalculate' => 'Пересчитать поля и индексы',
+7 -2
View File
@@ -442,6 +442,7 @@
AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50);
return $this->render('@documents/bulk-editor.twig', array(
'bulk_options' => BulkEditor::options(),
'bulk_clean_descriptions' => Request::getStr('operation', '') === 'strip_description_styles',
));
}
@@ -587,9 +588,11 @@
$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);
$editorState = Model::editorState($id);
return $this->success('Документ создан', array('data' => array(
'id' => $id,
'document_version' => Model::documentVersion($id),
'document_version' => $editorState ? $editorState['version'] : Model::documentVersion($id),
'editor_state' => $editorState,
'snapshot_warning' => $snapshotError,
'media_draft_token' => $nextMediaDraftToken,
), 'redirect' => $this->base() . '/documents/' . $id . '/edit'));
@@ -734,9 +737,11 @@
$snapshot = (new DocumentSnapshotRepository())->find($id);
DocumentSaveEvents::after('update', 'adminx', $id, (int) $doc['rubric_id'], Auth::id(), $input, $fieldValues, $doc, is_array($snapshot) ? $snapshot : array());
$mediaCleanup = DocumentMediaReplacement::cleanup($mediaReplacement, $fieldValues);
$editorState = Model::editorState($id);
return $this->success('Документ сохранён', array('data' => array(
'id' => $id,
'document_version' => Model::documentVersion($id),
'document_version' => $editorState ? $editorState['version'] : Model::documentVersion($id),
'editor_state' => $editorState,
'snapshot_warning' => $snapshotError,
'media_cleanup' => $mediaCleanup,
'media_draft_token' => DocumentMediaDraft::issue(Auth::id(), $id),
+20
View File
@@ -728,6 +728,23 @@
)->getValue());
}
public static function editorState($id)
{
$row = DB::query('SELECT Id,document_title,document_status,document_deleted,document_changed,document_version FROM '
. self::documentsTable() . ' WHERE Id=%i LIMIT 1', (int) $id)->getAssoc();
if (!$row) { return null; }
$label = $row['document_deleted'] ? 'Удалён' : ($row['document_status'] ? 'Опубликован' : 'Черновик');
$label = \App\Common\Language::translateSource($label);
return array(
'title' => self::decode($row['document_title']),
'version' => max(1, (int) $row['document_version']),
'state_label' => $label,
'state_badge' => $row['document_deleted'] ? 'gray' : ($row['document_status'] ? 'green' : 'amber'),
'meta' => 'ID ' . (int) $row['Id'] . ', ' . $label . \App\Common\Language::translateSource(', изменён:')
. ' ' . date('d.m.Y H:i', (int) $row['document_changed']),
);
}
public static function markChanged($id)
{
self::touchDocument((int) $id);
@@ -1744,6 +1761,9 @@
DB::Insert(self::docFieldsTable(), array(
'rubric_field_id' => (int) $field['Id'],
'document_id' => (int) $documentId,
'field_value' => '',
'field_number_value' => 0,
'document_in_search' => '1',
));
}
}
@@ -1243,6 +1243,7 @@
.documents-bulk-controls .field {
min-width: 0;
}
.documents-bulk-progress-actions [hidden],
.documents-bulk-controls .field[hidden],
[data-bulk-technical-help][hidden] {
display: none;
@@ -1337,6 +1338,15 @@
color: var(--text-muted);
font-size: 11px;
}
.documents-bulk-html-preview {
display: block;
max-height: 140px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 12px;
line-height: 1.5;
}
.documents-bulk-value-cell {
color: var(--text-secondary);
font-size: 12px;
+51 -12
View File
@@ -1289,7 +1289,7 @@
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'))];
var hide = limited && !allowed[String(field.getAttribute('data-rubric-field-id'))];
field.classList.toggle('is-catalog-hidden', hide);
});
var empty = catalog.querySelector('[data-document-catalog-empty]');
@@ -1377,9 +1377,11 @@
});
},
submit: function (stay) {
submit: function (stay, reviewed) {
if (!this.form || this.submitting) { return; }
var self = this;
if (!reviewed && Adminx.RichEditor && Adminx.RichEditor.reviewBeforeSave
&& Adminx.RichEditor.reviewBeforeSave(this.form, function () { self.submit(stay, true); })) { return; }
var id = parseInt(this.field('id').value, 10) || 0;
this.clearErrors();
this.syncEditors();
@@ -1426,6 +1428,7 @@
self.refreshMediaDraft(payload.data.media_draft_token, payload.data.id || id);
}
self.resetMediaReplacements();
self.refreshSavedHeading(payload.data && payload.data.editor_state);
if (self.form.getAttribute('data-quick-edit') === '1') {
self.refreshQuickEditOpener(!stay);
return;
@@ -1622,7 +1625,10 @@
}
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 || '<p></p>'); }
if (control._adminxTiptap) {
if (Adminx.RichEditor && Adminx.RichEditor.replaceHtml) { Adminx.RichEditor.replaceHtml(control, control.value); }
else { control._adminxTiptap.commands.setContent(control.value || '<p></p>'); }
}
});
});
this.form.querySelectorAll('[data-document-term-input]').forEach(function (field) {
@@ -1677,14 +1683,22 @@
if (note) { note.remove(); }
var factId = document.querySelector('[data-document-fact-id]');
if (factId) { factId.textContent = String(id); }
var titleField = this.field('document_title');
},
refreshSavedHeading: function (state) {
if (!state) { return; }
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'; }
if (heading) { heading.textContent = state.title; }
if (meta) { meta.textContent = state.meta; }
document.querySelectorAll('[data-document-state-badge]').forEach(function (badge) {
badge.classList.remove('badge-blue', 'badge-green', 'badge-amber', 'badge-gray');
if (['blue', 'green', 'amber', 'gray'].indexOf(state.state_badge) !== -1) {
badge.classList.add('badge-' + state.state_badge);
}
badge.textContent = state.state_label;
});
document.title = state.title + ' · AVE.cms';
},
setSaving: function (saving) {
@@ -3671,7 +3685,10 @@
var selected = target.options[target.selectedIndex];
var editsField = operation && ['fill', 'set', 'clear', 'replace'].indexOf(operation.value) !== -1;
var message = editsField && selected ? String(selected.getAttribute('data-help') || '') : '';
if (selected && selected.value === 'document_title' && scope && scope.value === 'products') {
if (operation && operation.value === 'strip_description_styles') {
message = Adminx.tr('Только поле с ролью «Описание товара». Будут удалены атрибуты style, включая отступы, цвета и размеры текста. Другие поля, классы, заголовки и блоки <style> не изменяются. HTML будет нормализован при разборе.');
}
if (editsField && selected && selected.value === 'document_title' && scope && scope.value === 'products') {
message = 'Это заголовок документа в панели. Название товара на сайте обычно берётся из поля рубрики, помеченного «витрина: название товара на сайте».';
}
var text = help.querySelector('span');
@@ -3702,6 +3719,12 @@
function updateOperation() {
var value = operation ? operation.value : '';
if (scope) {
Array.prototype.forEach.call(scope.options || [], function (option) {
option.disabled = value === 'strip_description_styles' && option.value !== 'products';
});
if (value === 'strip_description_styles') { scope.value = 'products'; }
}
var editsField = ['fill', 'set', 'clear', 'replace'].indexOf(value) !== -1;
setVisibility('[data-bulk-target-wrap]', editsField);
setVisibility('[data-bulk-search-wrap]', value === 'replace');
@@ -3757,6 +3780,17 @@
token = plan.token || '';
var rows = root.querySelector('[data-bulk-preview-rows]');
rows.replaceChildren();
var htmlPreview = plan.operation && plan.operation.type === 'strip_description_styles';
function valueCell(value) {
var td = cell(htmlPreview ? '' : value, 'documents-bulk-value-cell');
if (htmlPreview) {
var code = document.createElement('code');
code.className = 'documents-bulk-html-preview';
code.textContent = value || '';
td.appendChild(code);
}
return td;
}
(plan.sample || []).forEach(function (item) {
var tr = document.createElement('tr');
var identity = document.createElement('td');
@@ -3768,10 +3802,15 @@
identity.appendChild(meta);
tr.appendChild(identity);
tr.appendChild(cell(item.rubric));
tr.appendChild(cell(item.before, 'documents-bulk-value-cell'));
tr.appendChild(cell(item.after, 'documents-bulk-value-cell'));
tr.appendChild(valueCell(item.before));
tr.appendChild(valueCell(item.after));
var result = cell(item.changed ? 'Изменится' : (item.note || 'Без изменений'));
result.className = item.changed ? 'documents-bulk-result is-changed' : 'documents-bulk-result is-skipped';
if (item.changed && item.note) {
var note = document.createElement('small');
note.textContent = item.note;
result.appendChild(note);
}
tr.appendChild(result);
rows.appendChild(tr);
});
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="documents_bulk_strip_description_styles">Remove styles from product descriptions</phrase>
<phrase data="documents_bulk_description_styles_help">Only the field assigned to the product description role. Removes style attributes, including spacing, colors and font sizes. Other fields, classes, headings and &lt;style&gt; elements are unchanged. HTML is normalized during parsing.</phrase>
<phrase data="documents_bulk_move_warning">Only fields with matching aliases and types are transferred. Values of other fields will not be retained in the destination rubric.</phrase>
<phrase data="documents_bulk_title">Bulk editor</phrase>
<phrase data="documents_bulk_intro">Checks changes before starting and processes documents in small batches.</phrase>
<phrase data="documents_bulk_notice_title">Review first, then apply</phrase>
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="documents_bulk_strip_description_styles">Удалить стили из описаний товаров</phrase>
<phrase data="documents_bulk_description_styles_help">Только поле с ролью «Описание товара». Будут удалены атрибуты style, включая отступы, цвета и размеры текста. Другие поля, классы, заголовки и блоки &lt;style&gt; не изменяются. HTML будет нормализован при разборе.</phrase>
<phrase data="documents_bulk_move_warning">Переносятся только поля с одинаковыми системными именами и типами. Значения остальных полей не сохранятся в новой рубрике.</phrase>
<phrase data="documents_bulk_title">Массовый редактор</phrase>
<phrase data="documents_bulk_intro">Проверяет изменения до запуска и обрабатывает документы небольшими пакетами.</phrase>
<phrase data="documents_bulk_notice_title">Сначала проверка, затем применение</phrase>
+1 -1
View File
@@ -17,7 +17,7 @@
return array(
'code' => 'documents',
'name' => 'Документы',
'version' => '0.4.2',
'version' => '0.4.10',
'permissions' => array(
'key' => 'documents',
@@ -0,0 +1,6 @@
<label class="product-package-field product-package-field-title"><span>Грузовое место</span><input class="input" name="title" value="{{ package.title|default('')|e('html_attr') }}" placeholder="Коробка 1" aria-label="Название грузового места"></label>
<label class="product-package-field"><span>Количество</span><input class="input" type="number" name="quantity" min="1" max="999" step="1" value="{{ package.quantity }}" aria-label="Количество"></label>
<label class="product-package-field"><span>Вес, кг</span><input class="input" type="number" name="weight_kg" min="0" step="0.001" value="{{ package.weight_kg|default('') }}" placeholder="0" aria-label="Вес в килограммах"></label>
<label class="product-package-field"><span>Длина, см</span><input class="input" type="number" name="length_cm" min="0" step="0.1" value="{{ package.length_cm|default('') }}" placeholder="0" aria-label="Длина в сантиметрах"></label>
<label class="product-package-field"><span>Ширина, см</span><input class="input" type="number" name="width_cm" min="0" step="0.1" value="{{ package.width_cm|default('') }}" placeholder="0" aria-label="Ширина в сантиметрах"></label>
<label class="product-package-field"><span>Высота, см</span><input class="input" type="number" name="height_cm" min="0" step="0.1" value="{{ package.height_cm|default('') }}" placeholder="0" aria-label="Высота в сантиметрах"></label>
@@ -242,12 +242,7 @@
<div class="product-package-list" data-package-list>
{% for package in shipping_profile.packages %}
<div class="product-package-row" data-package-row>
<input class="input" name="title" value="{{ package.title|e('html_attr') }}" aria-label="Название грузового места">
<input class="input" type="number" name="quantity" min="1" max="999" step="1" value="{{ package.quantity }}" aria-label="Количество">
<input class="input" type="number" name="weight_kg" min="0" step="0.001" value="{{ package.weight_kg }}" aria-label="Вес в килограммах">
<input class="input" type="number" name="length_cm" min="0" step="0.1" value="{{ package.length_cm }}" aria-label="Длина в сантиметрах">
<input class="input" type="number" name="width_cm" min="0" step="0.1" value="{{ package.width_cm }}" aria-label="Ширина в сантиметрах">
<input class="input" type="number" name="height_cm" min="0" step="0.1" value="{{ package.height_cm }}" aria-label="Высота в сантиметрах">
{% include '@documents/_product_package_fields.twig' with {package: package} only %}
<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-package-remove data-tooltip="Удалить грузовое место" aria-label="Удалить грузовое место"><i class="ti ti-trash"></i></button>
</div>
{% endfor %}
@@ -259,6 +254,6 @@
</div>
</div>
</form>
<template data-package-template><div class="product-package-row" data-package-row><input class="input" name="title" value="" placeholder="Коробка 1" aria-label="Название грузового места"><input class="input" type="number" name="quantity" min="1" max="999" step="1" value="1" aria-label="Количество"><input class="input" type="number" name="weight_kg" min="0" step="0.001" value="" placeholder="0" aria-label="Вес в килограммах"><input class="input" type="number" name="length_cm" min="0" step="0.1" value="" placeholder="0" aria-label="Длина в сантиметрах"><input class="input" type="number" name="width_cm" min="0" step="0.1" value="" placeholder="0" aria-label="Ширина в сантиметрах"><input class="input" type="number" name="height_cm" min="0" step="0.1" value="" placeholder="0" aria-label="Высота в сантиметрах"><button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-package-remove data-tooltip="Удалить грузовое место" aria-label="Удалить грузовое место"><i class="ti ti-trash"></i></button></div></template>
<template data-package-template><div class="product-package-row" data-package-row>{% include '@documents/_product_package_fields.twig' with {package: {quantity: 1}} only %}<button class="btn btn-ghost btn-icon btn-sm ax-act ax-act-danger" type="button" data-package-remove data-tooltip="Удалить грузовое место" aria-label="Удалить грузовое место"><i class="ti ti-trash"></i></button></div></template>
</section>
{% endif %}
@@ -42,9 +42,9 @@
<div class="documents-bulk-controls">
{% if bulk_options.products_available %}
<label class="field"><span>Контур</span><select class="select" name="scope">
<option value="all">Документы и товары</option>
<option value="all"{{ not bulk_clean_descriptions ? ' selected' : '' }}>Документы и товары</option>
<option value="documents">Только обычные документы</option>
<option value="products">Только товары</option>
<option value="products"{{ bulk_clean_descriptions ? ' selected' : '' }}>Только товары</option>
</select></label>
{% else %}
<input type="hidden" name="scope" value="all">
@@ -57,7 +57,7 @@
</select></label>
<label class="field"><span>Состояние</span><select class="select" name="state">
<option value="">Опубликованные и черновики</option>
<option value="active">Только опубликованные</option>
<option value="active"{{ bulk_clean_descriptions ? ' selected' : '' }}>Только опубликованные</option>
<option value="draft">Только черновики</option>
</select></label>
<label class="field documents-bulk-search"><span>Поиск</span><span class="input-wrap"><i class="ti ti-search"></i><input class="input" type="search" name="q" placeholder="ID, название или alias"></span></label>
@@ -78,6 +78,7 @@
<option value="set">Установить значение</option>
<option value="clear">Очистить значение</option>
<option value="replace">Найти и заменить</option>
{% if bulk_options.product_descriptions_available %}<option value="strip_description_styles"{{ bulk_clean_descriptions ? ' selected' : '' }}>Удалить стили из описаний товаров</option>{% endif %}
<option value="move">Перенести в другую рубрику</option>
<option value="publish">Опубликовать</option>
<option value="unpublish">Снять с публикации</option>
+1 -1
View File
@@ -176,7 +176,7 @@
<span class="icon-tile documents-head-icon" style="--tile-bg:var(--sec-soft);--tile-fg:var(--sec)"><i class="ti ti-file-text"></i></span>
<h2>Основное</h2>
</div>
<span class="badge badge-{{ document.state_badge }}">{{ document.state_label }}</span>
<span class="badge badge-{{ document.state_badge }}" data-document-state-badge>{{ document.state_label }}</span>
</div>
<div class="documents-card-body">
<div class="form-grid">
+5
View File
@@ -129,7 +129,12 @@
{
return Lock::run('media-trash', function () use ($token) {
$entry = self::entry($token);
$payload = self::itemsRoot() . '/' . $entry['token'] . '/payload';
self::removeTree($payload);
clearstatcache(true, $payload);
if (file_exists($payload)) { throw new \RuntimeException('Не удалось удалить файлы корзины. Проверьте права доступа.'); }
self::removeTree(self::itemsRoot() . '/' . $entry['token']);
if (is_dir(self::itemsRoot() . '/' . $entry['token'])) { throw new \RuntimeException('Не удалось удалить запись корзины. Проверьте права доступа.'); }
AuditLog::record('media.purged', array('actor_id' => Auth::id(), 'target_type' => 'media', 'meta' => array('path' => $entry['path'], 'token' => $entry['token'])));
return $entry;
});
+8
View File
@@ -1036,3 +1036,11 @@ button.media-thumb {
color: var(--text-secondary, #667085);
font-size: 13px;
}
.media-trash-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--border-default);
}
+31
View File
@@ -59,10 +59,12 @@
var audit = e.target.closest('[data-media-audit-run]');
var trashRestore = e.target.closest('[data-media-trash-restore]');
var trashPurge = e.target.closest('[data-media-trash-purge]');
var trashClear = e.target.closest('[data-media-trash-clear]');
if (audit) { self.runAudit(audit); return; }
if (trashRestore) { self.restoreTrash(trashRestore); return; }
if (trashPurge) { self.purgeTrash(trashPurge); return; }
if (trashClear) { self.clearTrash(trashClear); return; }
if (folder) { self.createFolder(); return; }
if (rename) { self.rename(rename); return; }
if (del) { self.remove(del); return; }
@@ -401,6 +403,35 @@
});
},
clearTrash: function (button) {
var self = this;
if (self.clearingTrash) return;
var items = Array.from(document.querySelectorAll('[data-media-trash-purge]'));
if (!items.length) return;
Adminx.Confirm.open({kind: 'danger', title: 'Очистить корзину медиа?',
message: 'Все объекты, показанные в корзине, будут удалены окончательно. Восстановить их будет нельзя.',
confirmLabel: 'Удалить навсегда', confirmClass: 'btn-danger', onConfirm: function () {
self.clearingTrash = true;
var done = 0, progress = document.querySelector('[data-media-trash-progress]');
document.querySelectorAll('[data-media-trash-clear], [data-media-trash-purge], [data-media-trash-restore]').forEach(function (el) { el.disabled = true; });
function step() {
progress.textContent = done + ' / ' + items.length;
if (done === items.length) { window.location.reload(); return Promise.resolve(); }
var item = items[done];
return Adminx.Ajax.post(self.base() + '/media/trash/' + encodeURIComponent(item.getAttribute('data-media-trash-purge')) + '/purge', new FormData(document.getElementById('mediaActionForm'))).then(function (payload) {
var data = payload.data || {};
if (!data.success) throw new Error(data.message || Adminx.tr('Не удалось очистить корзину'));
item.closest('tr').remove(); done++; return step();
});
}
step().catch(function (error) {
self.clearingTrash = false;
document.querySelectorAll('[data-media-trash-clear], [data-media-trash-purge], [data-media-trash-restore]').forEach(function (el) { el.disabled = false; });
Adminx.Toast.show(error.message || 'Ошибка сети', 'error');
});
}});
},
clearThumbnails: function (button) {
var self = this;
var name = button.getAttribute('data-name') || button.getAttribute('data-path') || '';
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="media_trash_clear">Empty trash</phrase>
<phrase data="media_trash_clear_confirm">Empty media trash?</phrase>
<phrase data="media_trash_clear_warning">All items currently shown in the trash will be permanently deleted. This cannot be undone.</phrase>
<phrase data="media_trash_clear_error">Could not empty trash</phrase>
<phrase data="media_trash_files_error">Could not delete trashed files. Check filesystem permissions.</phrase>
<phrase data="media_trash_entry_error">Could not delete trash metadata. Check filesystem permissions.</phrase>
</language>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<language>
<phrase data="media_trash_clear">Очистить корзину</phrase>
<phrase data="media_trash_clear_confirm">Очистить корзину медиа?</phrase>
<phrase data="media_trash_clear_warning">Все объекты, показанные в корзине, будут удалены окончательно. Восстановить их будет нельзя.</phrase>
<phrase data="media_trash_clear_error">Не удалось очистить корзину</phrase>
<phrase data="media_trash_files_error">Не удалось удалить файлы корзины. Проверьте права доступа.</phrase>
<phrase data="media_trash_entry_error">Не удалось удалить запись корзины. Проверьте права доступа.</phrase>
</language>
+1 -1
View File
@@ -19,7 +19,7 @@
return array(
'code' => 'media',
'name' => 'Медиа',
'version' => '0.6.4',
'version' => '0.6.5',
'permissions' => array(
'key' => 'media',
+1
View File
@@ -10,6 +10,7 @@
</div>
<div class="section-header ax-panel-header"><div class="section-icon"><i class="ti ti-trash"></i></div><div><div class="section-eyebrow">Медиа</div><h2>Удалённые файлы и папки</h2><p class="section-desc">Восстановление возвращает объект по исходному пути. Окончательное удаление необратимо.</p></div></div>
<div class="card ax-list-card media-trash-card">
{% if items and can_manage %}<div class="media-trash-toolbar"><button class="btn btn-danger" type="button" data-media-trash-clear><i class="ti ti-trash"></i>Очистить корзину</button><span class="text-secondary" data-media-trash-progress role="status" aria-live="polite"></span></div>{% endif %}
<div class="table-scroll"><table class="table"><thead><tr><th>Объект</th><th>Тип</th><th>Удалён</th><th class="table-actions"></th></tr></thead><tbody>
{% for item in items %}<tr><td><b>{{ item.name }}</b><small class="table-subtitle mono">{{ item.path }}</small></td><td>{{ item.type == 'folder' ? 'Папка' : 'Файл' }}</td><td>{{ item.deleted_label }}</td><td class="table-actions"><div class="cluster">
<button class="btn btn-ghost btn-icon ax-act ax-act-restore" type="button" data-media-trash-restore="{{ item.token }}" data-name="{{ item.name }}" data-tooltip="Восстановить" aria-label="Восстановить"><i class="ti ti-restore"></i></button>
+1 -1
View File
@@ -441,7 +441,7 @@
}
foreach ($defaults as $key => $value) {
if (!isset($input[$key]) || trim((string) $input[$key]) === '') {
if (!array_key_exists($key, $input)) {
$input[$key] = $value;
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
return array(
'code' => 'navigation',
'name' => 'Навигация',
'version' => '0.2.2',
'version' => '0.2.3',
'hook_definitions' => array(
array(
@@ -0,0 +1,25 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Requests/migrations/015_request_description_capacity.php
| @author AVE.cms <support@ave-cms.ru>
| @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('request');
$column = \DB::query('SHOW COLUMNS FROM %b LIKE %s', $table, 'request_description')->getAssoc();
if (!$column || strtolower((string) $column['Type']) !== 'tinytext') { return 0; }
\DB::query('ALTER TABLE %b MODIFY COLUMN `request_description` TEXT NOT NULL', $table);
return 1;
};
+2 -1
View File
@@ -22,7 +22,7 @@
return [
'code' => 'requests',
'name' => 'Запросы',
'version' => '0.2.0',
'version' => '0.2.1',
'permissions' => [
'key' => 'requests',
@@ -76,6 +76,7 @@
['id' => '012_condition_value_sources', 'file' => 'migrations/012_condition_value_sources.php'],
['id' => '013_sort_rules', 'file' => 'migrations/013_sort_rules.php'],
['id' => '014_request_revisions', 'file' => 'migrations/014_request_revisions.sql'],
['id' => '015_request_description_capacity', 'file' => 'migrations/015_request_description_capacity.php'],
],
'routes' => array(
@@ -405,6 +405,7 @@
$preset = $kind === 'rich' ? 'full' : ($kind === 'rich_simple' ? 'simple' : 'slim');
return '<textarea class="textarea documents-rich-editor" name="' . $this->e($this->fname($field, '[raw]'))
. '" data-rich-editor data-rich-preset="' . $preset . '" data-editor-height="' . $editorHeight
. '" data-rich-options="' . $this->e(json_encode(\App\Content\Fields\RichEditorSettings::options($settings), JSON_UNESCAPED_UNICODE))
. '" spellcheck="true">' . $this->e($parsed) . '</textarea>';
}
+1 -1
View File
@@ -17,7 +17,7 @@
return array(
'code' => 'rubrics',
'name' => 'Рубрики и поля',
'version' => '0.3.10',
'version' => '0.3.11',
'field_sets' => \App\Adminx\Rubrics\RubricFieldPresets::definitions(),
'hook_definitions' => array(
array(
+1 -1
View File
@@ -19,7 +19,7 @@
return array(
'code' => 'updates',
'name' => 'Обновления',
'version' => '0.2.1',
'version' => '0.2.2',
'notifications' => array('provider' => array(\App\Adminx\Updates\NotificationProvider::class, 'items'), 'permission' => 'view_core_updates', 'sort_order' => 45),
'permissions' => array(
'key' => 'updates',
@@ -0,0 +1,3 @@
ALTER TABLE `{{public_shell_prefix}}_sessions`
MODIFY `sesskey` VARCHAR(256) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
MODIFY `value` MEDIUMTEXT NOT NULL;
+2 -1
View File
@@ -21,7 +21,7 @@
return [
'code' => 'users',
'name' => 'Пользователи',
'version' => '0.3.1',
'version' => '0.3.2',
'permissions' => [
'key' => 'users',
@@ -78,6 +78,7 @@
['id' => '002_harden_public_remember_tokens', 'file' => 'migrations/002_harden_public_remember_tokens.php'],
['id' => '003_system_user_security', 'file' => 'migrations/003_system_user_security.php'],
['id' => '004_system_session_expiry', 'file' => 'migrations/004_system_session_expiry.php'],
['id' => '005_public_session_capacity', 'file' => 'migrations/005_public_session_capacity.sql'],
],
'admin_extension' => array(
+10 -1
View File
@@ -18,11 +18,20 @@
<!-- ================= TOPBAR ================= -->
<style>.topbar-help .ti{color:var(--primary,#206bc4)}.topbar-help:hover .ti{color:var(--primary,#206bc4)}</style>
<header class="topbar">
<header class="topbar{{ attention.items ? ' has-attention' : '' }}">
<button class="btn btn-ghost btn-icon" id="sidebarToggle" aria-label="{{ lang.topbar_collapse_menu|default('Свернуть меню') }}" data-tooltip="{{ lang.topbar_menu|default('Меню') }}"><i class="ti ti-layout-sidebar-left-collapse"></i></button>
<a href="{{ ADMINX_BASE }}/" class="topbar-logo" style="color:var(--text-primary)"><img class="logo-img" src="{{ asset_ver(ADMINX_BASE ~ '/assets/img/logo.svg') }}" alt="AVE" width="66" height="24"><span class="logo-text logo-suffix">.cms</span></a>
{% if site_development_mode %}<a class="env-badge" href="{{ ADMINX_BASE }}/settings/main" data-tooltip="{{ lang.development_mode_hint|default('Сайт закрыт для посетителей') }}"><i class="ti ti-lock"></i>{{ lang.development_mode|default('Режим разработки') }}</a>{% endif %}
<div class="topbar-right">
{% if attention.items %}
<div class="attention-topbar" data-attention-root data-attention-user="{{ attention_user_id }}" data-attention-url="{{ ADMINX_BASE }}/dashboard/attention" data-attention-initial="{{ attention|json_encode|e('html_attr') }}" aria-label="{{ lang.attention_heading|default('Требуют внимания') }}">
{% for item in attention.items %}
<a class="attention-shortcut is-{{ item.tone }}{{ item.available and item.count > 0 ? ' has-pending' : '' }}" href="{{ item.url }}" data-attention-key="{{ item.key }}" data-tooltip="{{ item.title }}: {{ item.available ? item.count : '—' }} · {{ item.wait }}" aria-label="{{ item.title }}: {{ item.available ? item.count : '—' }} · {{ item.wait }}">
<i class="{{ item.icon }}" aria-hidden="true"></i><b data-attention-count>{{ item.available ? (item.count > 99 ? '99+' : item.count) : '—' }}</b>
</a>
{% endfor %}
</div>
{% endif %}
<a class="btn btn-ghost btn-icon topbar-site-link" href="{{ public_url }}" target="_blank" rel="noopener" aria-label="{{ lang.topbar_visit_site|default('Перейти на сайт') }}" data-tooltip="{{ lang.topbar_visit_site|default('Перейти на сайт') }}"><i class="ti ti-external-link"></i></a>
<button class="btn btn-ghost btn-icon topbar-command" type="button" data-command-palette-open aria-label="Быстрый переход" data-tooltip="Быстрый переход"><i class="ti ti-search"></i></button>
{% if module_header_actions %}
+20
View File
@@ -1,5 +1,25 @@
# Массовый редактор документов и товаров
## Очистка стилей описаний товаров
Из «Качества каталога» кнопка **Очистить стили описаний** открывает массовый
редактор с действием **Удалить стили из описаний товаров**. Можно также выбрать
это действие в самом редакторе. Контур автоматически ограничивается товарами.
Выберите состояние (опубликованные, черновики или оба), рубрику при необходимости,
нажмите **Проверить изменения**, просмотрите количество и фрагменты HTML до/после,
затем подтвердите запуск. Предпросмотр ничего в товарах не меняет.
Удаляются все атрибуты `style`, включая `margin-left: 40px`, цвета и размеры.
Поле определяется ролью «Описание товара», без фиксированного ID; поля других
ролей, SEO, краткое описание и характеристики не меняются. Классы, заголовки
H1–H6 и элементы `<style>` эта операция не удаляет. Разметка нормализуется
HTML-парсером. Описания без inline-стилей не пересохраняются. Поэтому очередь
«Разметка описания» может остаться непустой после очистки, если там остались H1
или элементы `<style>`.
Обработка пакетами по 20, с ревизиями и штатным сбросом кеша. Удалённые
документы исключены. Требуются права управления документами и товарами.
Раздел **Документы → Массовый редактор** изменяет большой набор записей через
тот же штатный сервис сохранения, который используют редактор документа, JSON
API, импорт и быстрое заполнение товаров.
+2
View File
@@ -5,6 +5,8 @@
Документ — конкретная страница, запись, статья или товар. Его структура задаётся
рубрикой, а внешний вид — шаблоном рубрики и шаблоном сайта.
Работа с текстом: [инструменты визуального редактора](rich-editor.md).
## Создание документа
1. Откройте **Контент → Документы** и нажмите создание.
+68
View File
@@ -0,0 +1,68 @@
# Визуальный редактор
## Инструменты
Наведите курсор на значок: подсказка показывает название действия.
- **Найти и заменить**: поиск по тексту, переключение регистра, переход между
совпадениями, замена одного или всех. Разметка не участвует в поиске.
Текст замены вставляется как текст, а не как HTML. «Заменить все» отменяется одним undo.
- **Специальные символы**: неразрывный пробел, градус, знак умножения, плюс-минус,
кавычки, степени и другие символы. Вставляются в позицию курсора вместо выделения.
- **Таблица**: после установки курсора в ячейку появляется дополнительная панель.
Можно добавлять/удалять строки и столбцы, выделить несколько ячеек и объединить их,
разделить ячейку, назначить заголовки или удалить таблицу. Недоступные действия отключены.
- **Ссылка на документ или товар**: выберите страницу в штатном поиске. Выделенный
текст станет ссылкой; без выделения вставится название выбранного документа.
- **Готовые фрагменты**: «Внимание», «Важно», «Комплектация», таблица характеристик.
После вставки замените заготовку своим текстом. Это обычная редактируемая HTML-разметка.
- **Порядок блоков**: отдельное окно со списком абзацев, заголовков, списков, изображений
и таблиц верхнего уровня. Перетащите строку или используйте стрелки вверх/вниз.
Вложенный список перемещается целиком со своим блоком. Изменения доступны в undo.
- **Проверка оформления**: H1 при ограниченном профиле или в редакторе товара,
пустые заголовки/ссылки, inline-стили, повторные пустые абзацы. Исправления выполняются
только по нажатию конкретной кнопки. Перед сохранением документа можно проверить
замечания или выбрать «Сохранить без исправлений».
- Под редактором показаны слова и символы всего текста, а при выделении — ещё и выделения.
- **Развернуть редактор**: полный экран; повторное нажатие или Escape возвращает обычный
размер. Если открыто вложенное окно, Escape сначала закрывает его.
- **Вставить из Word**: сохраняет структуру и выделения, удаляет оформление Word.
Обычная вставка автоматически очищается при распознавании Word. Изображения из Word
добавляйте через медиатеку отдельно.
## Настройки В Рубрике
Откройте **Рубрики и поля → нужная рубрика → текстовое поле → настройки типа**.
Настройки доступны у форматированного, многострочного и текстового содержимого в rich-режиме.
1. Выберите панель: полная, компактная, описание товара или новость/статья.
2. Выберите доступные заголовки: по профилю, H1–H6, H2–H6 или без заголовков.
3. Выберите набор фрагментов: все, информационные, товарные или отключённые.
4. При необходимости скройте изображения, таблицы, исходный код, выравнивание,
перемещение блоков.
5. Сохраните настройки поля и заново откройте документ.
Профили товара/новости по умолчанию убирают H1 из выбора. Уже сохранённый H1 не удаляется:
панель покажет «H1 (в тексте)». Настройки задаются каждому полю рубрики отдельно.
Это настройки удобства, а не запрет HTML на стороне сервера.
## Несохранённый Текст
В документах и товарах используется существующая локальная копия всей формы. Восстановление
теперь корректно обновляет и визуальный режим, и открытый HTML-исходник.
В остальных редакторах с именованным полем и известным пользователем хранится локальная
копия текста до 7 дней (не более 1 млн символов на поле). При повторном открытии можно
посмотреть текущий серверный текст и локальную копию, восстановить или удалить копию.
Автоматической перезаписи серверного документа нет. После восстановления нажмите сохранение
в основной форме. В браузерах с запрещённым localStorage восстановление недоступно.
Локальные копии привязаны к пользователю, странице и полю. На общем компьютере удаляйте
ненужные локальные копии и завершайте сеанс. Это не замена серверным ревизиям и резервным копиям.
## Оформление Фрагментов
Фрагменты не содержат inline-цветов и отступов. Информационные блоки используют стандартный
`blockquote` с классами `content-callout`, `content-callout--warning` или
`content-callout--info`. Их публичное оформление можно задать в CSS своей темы.
Без дополнительных стилей они остаются обычными цитатами, списками и таблицами.
+18
View File
@@ -3,6 +3,15 @@
The Commerce module manages the cart, checkout, delivery, payment,
coupons, favorites, viewed products and customer order history.
## Processing archived carts
In Orders → Abandoned carts, choose the contact or UTM archive. After handling
the customer, click the green Processed checkmark and confirm. The cart leaves
the pending archive queue and remains available under Processed archive.
Contacts, cart items and attribution are retained without automatic expiry.
The detail view shows when and by whom it was processed. Reopen returns it to
the queue. The mark is shared by all managers and requires order management permission.
## Order export
Set the search, order state, and payment filter, then click **CSV**. The current
@@ -472,3 +481,12 @@ The script is included in `System -> Site Users -> Registration`, in the block
**Account after order**. The consent text, policy address and Twig-
letter template. If public registration or this script is disabled,
checkout continues to work in guest mode.
# Payment after checkout
An online order is saved before starting payment. The confirmation page shows
its number and amount. The Pay button is immediately available; manager approval
is not required. Repeated clicks reuse the existing payment.
In **Shop settings → General → Payment notice**, edit the notice and toggle its
visibility. Hiding the notice does not hide the Pay button. Its presentation is
editable in the `payment_notice` shop template.
+70
View File
@@ -0,0 +1,70 @@
# Metrica: Ecommerce
This optional module connects storefront activity to Yandex Metrica without
replacing the theme or changing prices and orders. Products and Commerce must
be installed and enabled. Cart tracking requires Commerce 1.8.10 or newer.
## Connect
1. Open **Modules → Metrica: Ecommerce → Settings** and enter your numeric
counter ID. No account password or OAuth token is required.
2. Select **Already installed on the website** if a template, system block or
tag manager already loads Metrica. Enable ecommerce on that counter and use
the same data container, normally `dataLayer`.
3. Otherwise select **Load through this module**. The module loads and
initializes Metrica. Webvisor is optional.
4. Enable the integration and keep test mode on. Select the events and save.
5. Visit the storefront in another tab, view a product, add it to the cart,
change the quantity and remove it. Check the module's **Diagnostics** tab.
6. Disable test mode when ready. Verify actual receipt using Metrica tools.
An existing ecommerce sender must not send the same events as this module.
An existing counter alone is fine. The module never deletes template code.
## Events
- Impressions when at least half of a product card is visible; product clicks
and detail views.
- Add/remove events use the actual quantity difference after a successful
cart mutation. Failed or rolled-back changes are not purchases or additions.
- Purchase means a successfully created storefront order. Revenue is the
discounted merchandise total, excluding shipping. Gifts have zero price;
the order revenue includes any order-level coupon discount.
- Create the JavaScript goals `ave_begin_checkout` and
`ave_payment_confirmed` in Metrica. Confirmed payment is a separate goal,
never a second purchase.
Large orders are split to respect Metrica's 8192-character payload limit.
Parts use IDs such as `123-1` and `123-2`; revenue is divided, not duplicated.
Such an order counts as multiple purchases in Metrica reports.
## Diagnostics and limitations
Test mode does not push ecommerce into the live container or call Metrica
goals. An independently installed counter remains active. The browser-local
log retains the latest 50 events. Queued in JavaScript does not mean that
Yandex confirmed receipt. The log is not a delivery ledger.
Session queues, CSRF-protected acknowledgements and browser-side event IDs
reduce duplicates. Storage restrictions, ad blockers or closing a page can
still affect delivery; this is not an exactly-once server integration.
For external payment redirects, pending events are sent when the buyer comes
back in the same session. Events expire after 24 hours. Only this session's
new orders are watched for payment, for up to seven days and on subsequent
page visits or focus. No return means no browser payment goal. Guaranteed
server-side reporting requires a separate conversion import integration.
Manual and imported orders are not treated as storefront activity.
For custom cards use `data-product-card`, an inner `data-item` product ID,
`itemprop="name"` and `itemprop="price"` with numeric `content`. The standard
`.product-card` is also supported. Optional `data-metrica-list` labels a list.
AJAX cards are discovered automatically. The `commerce:cart-updated` event
and standard mini-cart replacement trigger a queue refresh.
Customer names, phones, emails, addresses, order access tokens and payment
credentials are not included. Configure your site's analytics consent policy
separately. Disabling this package does not remove a separately installed
counter. Uninstall uses the standard module lifecycle.
[Yandex payload reference](https://yandex.ru/support/metrica/en/ecommerce/data).
+26
View File
@@ -3,6 +3,16 @@
Модуль Commerce управляет корзиной, оформлением заказа, доставкой, оплатой,
купонами, избранным, просмотренными товарами и историей заказов покупателя.
## Обработка архивных корзин
В «Заказы → Брошенные корзины» выберите архив с контактами или UTM-метками.
После работы с покупателем нажмите зелёную галочку «Обработана» и подтвердите.
Корзина исчезнет из очереди архива, но останется в фильтре «Обработанные в архиве».
Контакты, состав и рекламные метки сохраняются без автоматического удаления по сроку.
В просмотре указаны дата обработки и менеджер. Кнопка «Вернуть в работу» снимает
отметку и возвращает корзину в очередь. Отметка общая для всех менеджеров;
для её изменения нужно право управления заказами.
## Выгрузка заказов
В списке заказов сначала задайте поиск, состояние и признак оплаты, затем
@@ -396,6 +406,12 @@ Commerce также дополняет общий view model товарной к
по себе не меняет итог заказа. Фиксированная стоимость способа доставки
продолжает работать независимо от API.
У каждого способа доставки можно задать «Бесплатно от, ₽». Ноль выключает
правило. Порог сравнивается с суммой товаров после акций и купона: при достижении
порога стоимость этого способа становится нулевой в оформлении, заказе и платеже.
Бесплатная доставка не добавляется платной услугой в чек. Правило действует и
для заказа, который создаёт менеджер; другие способы доставки не затрагиваются.
На странице оформления расчёт запускается автоматически после выбора способа
доставки и заполнения города или индекса. Под названием способа показываются
ориентировочная цена перевозчика и срок. Эта сумма информационная: в итог
@@ -505,3 +521,13 @@ Commerce использует общую систему публичных по
**Аккаунт после заказа**. Там же задаются текст согласия, адрес политики и Twig-
шаблон письма. Если публичная регистрация или этот сценарий выключены,
оформление заказа продолжает работать в гостевом режиме.
## Оплата после оформления
Онлайн-заказ сначала сохраняется и показывает номер и сумму. Переход к банку
происходит по отдельной кнопке «Оплатить», доступной сразу, без ожидания
подтверждения менеджера. Повторное нажатие не создаёт второй платёж.
В **Настройки магазина → Общие → Предупреждение перед оплатой** можно изменить
текст и выключить его показ. Кнопка оплаты остаётся доступной при скрытом
предупреждении. Оформление плашки редактируется в шаблоне `payment_notice`.
+138
View File
@@ -0,0 +1,138 @@
# Чат с посетителями
Модуль добавляет переписку с менеджером на публичные страницы сайта.
Регистрация посетителя не требуется. Если установлены товарный и торговый
модули, менеджер видит товар на открытой странице, корзину и заказы покупателя.
ИИ, файлы и внешние мессенджеры в эту версию не входят.
## Подключение
1. Откройте **Модули → Управление**, установите «Чат с посетителями».
2. Откройте **Чат с посетителями → Настройки** и включите чат.
3. Задайте дни, начало и окончание рабочего дня, часовой пояс.
По умолчанию: понедельник-пятница, 09:00-16:00, Europe/Moscow.
4. Настройте заголовок окна, текст вне сети и быстрые ответы (один на строку).
5. В ролях предоставьте менеджерам право **Чат: просмотр и ответы**.
Настройки и работа с чужими диалогами требуют дополнительного права
**Чат: настройки и управление чужими диалогами**.
Публичные шаблоны редактировать не нужно. Модуль подключает окно при штатном
рендеринге HTML-страниц. На служебных документах и страницах ошибок окна нет.
На автономных HTML-файлах вне движка автоматического подключения нет.
## Работа менеджера
- Откройте вкладку **Диалоги** и включите **Принимаю чаты**.
- Оставьте эту вкладку открытой. Присутствие подтверждается короткими запросами;
через 90 секунд без подтверждения менеджер перестаёт считаться доступным.
- Во **Входящих** выберите диалог и нажмите **Принять**. Он закрепится за вами
и появится в **Моих диалогах**. Два менеджера одновременно принять его не смогут.
- Напишите ответ или выберите быстрый ответ. Ссылка на опубликованный товар
текущего магазина дополнится карточкой товара; исходный текст сохранится.
Внешние ссылки остаются обычными ссылками. Товар можно также найти и отправить
кнопкой выбора товара в переписке.
- Режим **Внутренняя заметка** сохраняет текст только для сотрудников.
- **Ожидание** оставляет диалог за вами. Новое сообщение посетителя возвращает
его в работу. При завершении выберите результат; для «Связаться позже»
укажите дату и время. Завершённый диалог попадает в архив.
- Можно передать диалог другому менеджеру, который сейчас принимает чаты.
- Новый вопрос посетителя после завершения создаёт отдельный диалог во входящих.
Прежняя переписка остаётся в архиве и не смешивается с новой.
Фильтры: входящие, мои, в работе, ожидание, завершённые, все. По 40 диалогов
на страницу. Уведомление о новых диалогах/непрочитанных сообщениях появляется
в общем колокольчике. Звук включается отдельно во вкладке диалогов.
**Отправлено** означает запись в базу. **Прочитано** появляется после открытия
сообщений получателем в видимой активной вкладке. Загрузка списка диалогов
сама по себе не отмечает сообщения прочитанными.
## Для посетителя
Окно доступно с кнопки «Написать нам». В рабочее время при наличии принимающего
менеджера показывается статус «Менеджер на связи». В остальных случаях можно
оставить сообщение и контакт для последующего ответа: телефон **или** email.
Имя и контакт не обязательны для отправки; без контакта ответ можно прочитать
только при возвращении в тот же браузер. Автоматических SMS/писем модуль не шлёт.
Переписка сохраняется между переходами по сайту в том же браузере. Очистка cookie,
другой браузер или истечение cookie лишают посетителя доступа к прежней переписке.
Секрет посетителя хранится в HttpOnly cookie до 180 дней, в базе находится только
его SHA-256. В localStorage сохраняется лишь признак наличия чата, не текст и не
контакты. На общем компьютере нужно учитывать доступ следующего пользователя
того же браузера к переписке.
## Технические границы
- PHP 7.3, MySQL/MariaDB, обычные AJAX-запросы. WebSocket и внешний сервис не нужны.
- До первого открытия чата не создаются диалоги и не опрашивается очередь.
- После начала переписки открытое окно опрашивает новые сообщения раз в 4 секунды,
закрытое раз в 30 секунд; скрытая вкладка приостанавливает этот опрос.
- Административная очередь обновляется раз в 5 секунд в видимой вкладке.
- Текст до 4000 символов, без исполняемого HTML. Отправка защищена CSRF,
лимитами по IP и скрытым полем от простых автоматических отправок.
- Повтор запроса с тем же ключом не создаёт дубль сообщения.
- Общие изменения пишутся в аудит без содержимого сообщений и контактов.
- Отключение модуля сохраняет данные. Удаление модуля уничтожает его таблицы
диалогов, сообщений и присутствия. Перед удалением сделайте резервную копию.
- Автоматического удаления истории по сроку в этой версии нет.
При установке на рабочий сайт обновите информацию об обработке контактов
в соответствии с тем, как ваша организация использует чат.
# Дополнения версии 1.1.0
## Повторное посещение и контакты (1.2.1)
Переписка и ранее отправленные контакты восстанавливаются в том же браузере
по cookie чата, независимо от PHP-сессии. Срок привязки продлевается на 180 дней.
Очистка cookie или смена браузера/устройства не позволяет восстановить анонимную
историю автоматически. Сообщения хранятся в БД, а не в localStorage.
В настройках есть «Контактные поля раскрыты по умолчанию»: включено — имя и
контакт сразу видны, выключено — блок нужно раскрыть. Во время текущего
посещения ручное переключение блока и ввод посетителя не сбрасываются опросом.
## Архив 1.2.0
Завершённые чаты выводятся отдельным списком с фильтрами по датам завершения,
менеджеру, результату, ответам и контактам. Поиск: имя, контакт, источник, точный номер.
В каждой строке отдельно указаны сообщения посетителя, ответы менеджера,
внутренние заметки и служебные события. Сводка учитывает всю выборку.
Нажмите номер/имя для просмотра истории по дням. История не обрезается:
длинная переписка имеет страницы по 100 сообщений. «К списку» сохраняет фильтры.
Просмотр архива не меняет прочитанность и не включает режим приёма чатов.
В версии 1.1.2 добавлены хлебные крошки и предупреждение при уходе из окна чата.
Оно появляется, если включено «Принимаю чаты», идёт отправка или остался черновик.
Переход по ссылке можно отменить; при закрытии/обновлении вкладки браузер показывает
собственное предупреждение (его текст изменить нельзя). На мобильных устройствах
закрытие приложения браузера не всегда вызывает это событие.
Без других менеджеров онлайн чат переходит в «Ответим позже» после истечения
присутствия (до 90 секунд). Само окно остаётся доступным и сохраняет обращения.
Появились отдельные вкладки «Архив», «Статистика» и «Быстрые ответы».
В очереди видны новые чаты, ожидание ответа и назначенный менеджер.
Чтение сообщения не снимает ожидание ответа. Завершённые чаты не удаляются
автоматически; новое обращение того же посетителя создаётся отдельно.
Публичное имя отвечающего задаётся в настройках. Имена сотрудников видны только
в панели. Источники, UTM, реферер, IP и браузер доступны внутри переписки.
Архивная статистика отражает отдельные завершённые диалоги. Общая статистика группирует диалоги по дате
создания, а ответы менеджеров считает за выбранный период.
## Товары и уведомления (1.3.0)
Ссылки на опубликованные товары текущего сайта превращаются в мини-карточки
с изображением, названием, артикулом и ценой, если данные есть в каталоге.
Модуль не запрашивает OpenGraph у внешних сайтов. Если менеджер отправляет
ссылку на рабочий домен с локального или тестового сайта, добавьте этот домен
в настройку «Дополнительные домены магазина для превью товаров».
Очередь ставит новые обращения выше уже принятых; видно время ожидания и
недоступность назначенного менеджера. По желанию включите уведомления браузера
кнопкой во вкладке диалогов. Для браузерных уведомлений нужна открытая вкладка
панели и разрешение браузера. Обновление до версии 1.3.0 применяет миграцию 003.
Рядом с плашками заказов и форм отображается число новых/непросмотренных чатов.
+79
View File
@@ -0,0 +1,79 @@
# Метрика: электронная коммерция
Модуль связывает действия покупателя с Яндекс Метрикой. Он не меняет цены,
корзину, заказ и оформление страниц. Нужны установленные и включённые модули
«Товары» и «Магазин»; для изменений корзины нужен «Магазин» версии 1.8.10 или новее.
## Подключение
1. Откройте **Модули → Метрика: электронная коммерция → Настройки**.
2. Введите числовой номер счётчика из Яндекс Метрики. Пароль не нужен.
3. Если счётчик уже подключён общим шаблоном, системным блоком или менеджером
тегов, оставьте **Уже установлен на сайте**. Модуль не будет вставлять его
второй раз. В настройках существующего счётчика включите электронную
коммерцию, контейнер должен совпадать с настройкой модуля (`dataLayer`).
4. Если счётчика нет, выберите **Подключать модулем**. В этом режиме модуль
загружает код Яндекса и инициализирует ecommerce. Вебвизор необязателен.
5. Выберите события, включите интеграцию и пока оставьте тестовый режим.
6. Сохраните. В соседней вкладке откройте сайт, товар, добавьте его в корзину,
измените количество, удалите. Вернитесь во вкладку **Проверка**.
7. После проверки выключите тестовый режим. Для проверки реального приёма
используйте инструменты самой Метрики, а не только журнал модуля.
Нельзя одновременно оставлять старый собственный скрипт отправки ecommerce
и включать такие же события модуля: получится двойной учёт. Обычный счётчик
оставить можно. Модуль автоматически не удаляет код из ваших шаблонов.
## Что учитывается
- Показы карточек: когда не меньше половины карточки видно на экране.
- Переход из карточки: ссылка на основной товар, не переключение миниатюры.
- Просмотр товара: фактическая страница товара, включая повторное открытие.
- Добавление/удаление: только изменение количества после успешной операции.
Увеличили с одного до трёх — добавлено два. Ошибка и откат не считаются.
- Покупка: заказ действительно создан публичным оформлением. Берутся ID
товаров, состав, цены с акциями и итог товаров со скидками, без доставки.
Подарки имеют нулевую цену. Общая купонная скидка учтена в доходе заказа.
- Начало оформления: JS-цель `ave_begin_checkout`.
- Оплата подтверждена: JS-цель `ave_payment_confirmed`, не повторная покупка.
Обе JS-цели создайте в интерфейсе Метрики. ID товара остаётся ID документа,
а ID покупки — номер заказа. Это не идентификаторы покупателя.
Очень большой заказ делится на части, чтобы уложиться в ограничение Яндекса
8192 символа на объект. Части имеют номера вида `123-1`, `123-2`; доход
распределяется между ними, не дублируется. Число покупок в отчёте в этом случае
соответствует числу частей, а не числу исходных заказов.
## Проверка и ограничения
В тестовом режиме модуль ничего не помещает в рабочий dataLayer и не вызывает
цели Яндекса. Уже установленный отдельно счётчик при этом продолжает работать.
Журнал хранит последние 50 событий только в браузере; он не является журналом
доставки. «Передано в JS» означает постановку в очередь JavaScript, не ответ Яндекса.
Повторная загрузка страницы и повторное чтение очереди не должны повторять
покупку: используются ID события, подтверждение очереди с CSRF и память
браузера. При недоступности хранилища браузера защита слабее; абсолютную
доставку ровно один раз браузерная аналитика не гарантирует.
Если после заказа покупатель ушёл в банк, событие будет передано при возвращении
на сайт или следующем открытии страницы в той же сессии. Очередь живёт сутки.
Проверка оплаты отслеживает только заказы этой сессии, не старые заказы из БД;
наблюдение действует до семи дней. Без возвращения посетителя оплата не попадёт
в цель. Для гарантированного серверного учёта нужен отдельный механизм импорта
конверсий. Ручные и импортированные заказы не считаются посещениями сайта.
На новом шаблоне карточки используйте штатный `data-product-card`, внутри
`data-item` с ID товара и микроданные `itemprop="name"`, `itemprop="price"`
с числовым `content`. Дополнительно поддерживается обычный `.product-card`.
Необязательный `data-metrica-list` задаёт название списка. AJAX-карточки
подхватываются автоматически. Событие `commerce:cart-updated` обновляет очередь;
стандартная замена мини-корзины тоже поддерживается.
Модуль не передаёт имена, телефоны, email, адреса, токены заказа и платёжные
ключи. Настройку согласия на аналитику и политику сайта необходимо учитывать
отдельно. Выключение пакета прекращает его подключение; самостоятельно
встроенный счётчик останется в шаблоне.
Формат событий: [документация Яндекса](https://yandex.ru/support/metrica/ru/ecommerce/data).
+16 -1
View File
@@ -10,7 +10,7 @@
1. Откройте **Модули → Слайдеры** и нажмите **Создать слайдер**.
2. Укажите понятное название и постоянный системный код, например `main`.
3. Сохраните слайдер и добавьте изображения из общей медиатеки.
3. Сохраните слайдер. Откроется его отдельная страница со вкладками «Слайды» и «Настройки слайдера».
4. Для каждого слайда настройте текст, ссылки, кнопки, положение и затемнение.
5. Расставьте слайды перетаскиванием за маркер слева.
6. Скопируйте тег и вставьте его в шаблон, блок или содержимое документа:
@@ -22,6 +22,21 @@
Вместо кода разрешён числовой ID: `[mod_slider:3]`. Код удобнее, потому что
остаётся понятным после переноса данных между сайтами.
## Редактирование и сохранение
Откройте слайдер из списка. Во вкладке «Слайды» видны изображения, названия
и состояние каждого слайда. Карандаш открывает выбранный слайд в правой панели,
а «Добавить слайд» открывает пустую форму. Предпросмотр обновляется при вводе.
«Сохранить слайд» записывает изменения, обновляет список и закрывает панель.
«Сохранить и остаться» записывает изменения и оставляет этот слайд открытым.
Копирование создаёт выключенную копию и открывает её для редактирования.
Общие настройки сохраняются отдельно во вкладке «Настройки слайдера».
Кнопки внизу этой вкладки относятся только к общим настройкам.
После изменения слайдов, их порядка или настроек публичный кеш сбрасывается
автоматически. Переустанавливать модуль или заново вставлять тег не требуется.
## Что настраивается у слайдера
| Параметр | Что делает |
+4 -1
View File
@@ -105,10 +105,11 @@
'005_rebuild_native_condition_cache', '006_sargable_condition_cache',
'007_result_contract', '008_preview_renderer', '009_native_executor',
'010_native_audit_result', '011_order_tiebreaker', '012_condition_value_sources',
'013_sort_rules', '014_request_revisions',
'013_sort_rules', '014_request_revisions', '015_request_description_capacity',
), 'backup' => array(
'013_sort_rules' => array('{{content_prefix}}_request'),
'014_request_revisions' => array(),
'015_request_description_capacity' => array('{{content_prefix}}_request'),
)),
'rubrics' => array('directory' => 'Rubrics', 'ids' => array(
'001_create_rubrics_tables', '002_rubric_field_settings',
@@ -147,9 +148,11 @@
'users' => array('directory' => 'Users', 'ids' => array(
'001_normalize_public_session_activity', '002_harden_public_remember_tokens',
'003_system_user_security', '004_system_session_expiry',
'005_public_session_capacity',
), 'backup' => array(
'002_harden_public_remember_tokens' => array('{{public_user_prefix}}_users_session'),
'003_system_user_security' => array(),
'004_system_session_expiry' => array('{{prefix}}_users_session'),
'005_public_session_capacity' => array('{{public_shell_prefix}}_sessions'),
)),
);
+1 -1
View File
@@ -423,7 +423,7 @@
<label><span>Имя администратора</span><input name="admin_name" value="<?= setup_escape($defaults['admin_name']) ?>" required></label>
<label><span>Логин</span><input name="admin_login" value="<?= setup_escape($defaults['admin_login']) ?>" required></label>
<label><span>Email</span><input name="admin_email" type="email" value="<?= setup_escape($defaults['admin_email']) ?>" required></label>
<label><span>Пароль</span><input name="admin_password" type="password" minlength="<?= (int) $adminPasswordMinLength ?>" required><small>Не менее <?= (int) $adminPasswordMinLength ?> символов</small></label>
<label><span>Пароль</span><input name="admin_password" type="password" minlength="<?= (int) $adminPasswordMinLength ?>" required><small><?= setup_escape(sprintf(AveSetupLocale::translate('Не менее %d символов'), (int) $adminPasswordMinLength)) ?></small></label>
<label class="form-span-2"><span>Папка панели управления</span><input name="admin_directory" value="<?= setup_escape($defaults['admin_directory']) ?>" pattern="[A-Za-z][A-Za-z0-9_\-]{0,47}" maxlength="48" required><small>По умолчанию adminx. Можно изменить до установки; системные имена использовать нельзя.</small></label>
</div>
+5 -3
View File
@@ -212,7 +212,9 @@
'Конфигурация и кеш' => 'Configuration and cache',
'Распаковываем AVE.cms' => 'Unpacking AVE.cms',
'Система настроена' => 'The system is configured',
'Не менее символов' => 'At least characters',
'Не менее %d символов' => 'At least %d characters',
'Публичный адрес сайта' => 'Public website URL',
'Полный адрес с https://. Он используется для проверки домена и защищённых ссылок в письмах.' => 'Full URL starting with https://. Used to validate the domain and secure links in emails.',
'выбранный профиль' => 'selected profile',
'Новый сайт на AVE.cms' => 'New website on AVE.cms',
'Таблицы и индексы' => 'Tables and indexes',
@@ -222,7 +224,7 @@
'Проверяем данные' => 'Checking the data',
'Язык установщика' => 'Installer language',
'AVE.cms установлена' => 'AVE.cms installed',
'Core-схема найдена' => 'Core circuit found',
'Core-схема найдена' => 'Core schema found',
'Другой источник' => 'Other source',
'После установки' => 'After installation',
'Удалить объекты' => 'Delete objects',
@@ -243,7 +245,7 @@
'Ошибка записи:' => 'Write error:',
'Рекомендуется' => 'Recommended',
'Администратор' => 'Administrator',
'Схема ядра InnoDB' => 'InnoDB core diagram',
'Схема ядра InnoDB' => 'InnoDB core schema',
'Установка AVE.cms' => 'Installing AVE.cms',
'Не подключать' => 'Do not connect',
'PHP 7.3 или новее' => 'PHP 7.3 or later',
+3 -3
View File
@@ -354,9 +354,9 @@ CREATE TABLE IF NOT EXISTS `{{prefix}}_console_snippets` (
-- Public shell and accounts ----------------------------------------------------
CREATE TABLE IF NOT EXISTS `{{prefix}}_public_sessions` (
`sesskey` VARCHAR(32) NOT NULL,
`sesskey` VARCHAR(256) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`expiry` INT UNSIGNED NOT NULL DEFAULT 0,
`value` TEXT NOT NULL,
`value` MEDIUMTEXT NOT NULL,
`Ip` VARCHAR(45) NOT NULL DEFAULT '',
`expire_datum` VARCHAR(25) NOT NULL DEFAULT '',
PRIMARY KEY (`sesskey`),
@@ -992,7 +992,7 @@ CREATE TABLE IF NOT EXISTS `{{prefix}}_request` (
`request_order_by_nat` INT NOT NULL DEFAULT 0,
`request_author_id` INT UNSIGNED NOT NULL DEFAULT 1,
`request_created` INT UNSIGNED NOT NULL DEFAULT 0,
`request_description` TINYTEXT NOT NULL,
`request_description` TEXT NOT NULL,
`request_result_contract` MEDIUMTEXT DEFAULT NULL,
`request_preview_renderer` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT 'data_cards',
`request_executor_mode` VARCHAR(16) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT 'legacy',
+9 -2
View File
@@ -184,8 +184,10 @@
return $value;
}
$started = false;
try {
return Lock::run('cache-remember:' . (string) $key, function () use ($key, $ttl, $callback, $miss) {
return Lock::run('cache-remember:' . (string) $key, function () use ($key, $ttl, $callback, $miss, &$started) {
$started = true;
$value = self::get($key, $miss);
if ($value !== $miss) {
return $value;
@@ -196,6 +198,8 @@
return $value;
});
} catch (\RuntimeException $e) {
// Retry only lock acquisition, never a failing computation or cache write.
if ($started) { throw $e; }
$value = self::get($key, $miss);
if ($value !== $miss) {
return $value;
@@ -225,8 +229,10 @@
return $value;
}
$started = false;
try {
return Lock::run('cache-remember:' . (string) $key, function () use ($key, $ttl, $tags, $callback, $miss) {
return Lock::run('cache-remember:' . (string) $key, function () use ($key, $ttl, $tags, $callback, $miss, &$started) {
$started = true;
$value = self::get($key, $miss);
if ($value !== $miss) {
return $value;
@@ -237,6 +243,7 @@
return $value;
});
} catch (\RuntimeException $e) {
if ($started) { throw $e; }
$value = self::get($key, $miss);
if ($value !== $miss) {
return $value;
+142
View File
@@ -0,0 +1,142 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file system/App/Common/CliRuntime.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Common;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
class CliRuntime
{
public static function php(array $preferred = array(), $minimumVersion = 70300)
{
if (!function_exists('proc_open')) { throw new \RuntimeException('proc_open недоступен: запуск PHP CLI невозможен.'); }
$runtime = array(
'binary' => defined('PHP_BINARY') ? PHP_BINARY : '',
'bindir' => defined('PHP_BINDIR') ? PHP_BINDIR : '',
'ini' => php_ini_loaded_file() ?: '',
'path' => (string) getenv('PATH'),
'windows' => DIRECTORY_SEPARATOR === '\\',
'version' => PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION,
);
$preferred[] = trim((string) getenv('PHP_CLI_BINARY'));
foreach (self::phpCandidates($preferred, $runtime) as $candidate) {
if (!self::isPhpName($candidate) || !is_file($candidate) || (!$runtime['windows'] && !is_executable($candidate))) { continue; }
if (self::probe($candidate) >= (int) $minimumVersion) { return $candidate; }
}
$version = (int) floor((int) $minimumVersion / 10000) . '.' . (int) floor(((int) $minimumVersion % 10000) / 100);
throw new \RuntimeException('Консольный PHP ' . $version . ' или новее не найден. Укажите полный путь к php или php.exe в PHP_CLI_BINARY.');
}
public static function findCommand($name, array $preferred = array())
{
if (!preg_match('/^[A-Za-z0-9_.-]+$/', (string) $name)) { throw new \InvalidArgumentException('Invalid command name'); }
$windows = DIRECTORY_SEPARATOR === '\\';
$extensions = $windows ? explode(';', (string) getenv('PATHEXT')) : array();
$paths = explode($windows ? ';' : ':', (string) getenv('PATH'));
foreach (self::commandCandidates($name, $paths, $extensions, $windows, $preferred) as $candidate) {
if (is_file($candidate) && ($windows || is_executable($candidate))) { return $candidate; }
}
return '';
}
protected static function phpCandidates(array $preferred, array $runtime)
{
$windows = !empty($runtime['windows']);
$separator = $windows ? '\\' : '/';
$name = $windows ? 'php.exe' : 'php';
$out = $preferred;
if (self::isPhpName($runtime['binary'])) { $out[] = $runtime['binary']; }
foreach (array($runtime['bindir'], dirname(str_replace('\\', '/', (string) $runtime['ini'])), dirname(str_replace('\\', '/', (string) $runtime['binary']))) as $directory) {
if ($directory === '' || $directory === '.') { continue; }
$directory = $windows ? str_replace('/', '\\', $directory) : $directory;
$out[] = rtrim($directory, '/\\') . $separator . $name;
$out[] = rtrim($directory, '/\\') . $separator . 'bin' . $separator . $name;
}
$paths = explode($windows ? ';' : ':', (string) $runtime['path']);
$out = array_merge($out, self::commandCandidates($name, $paths, array(), $windows));
if ($windows && preg_match('~^(.*/modules)/~i', str_replace('\\', '/', (string) $runtime['binary']), $match)) {
$found = array();
foreach (array('/php/*/php.exe', '/php/*/bin/php.exe', '/PHP*/php.exe', '/PHP*/bin/php.exe') as $suffix) {
$found = array_merge($found, glob($match[1] . $suffix) ?: array());
}
$version = isset($runtime['version']) ? preg_quote($runtime['version'], '~') : '';
if ($version !== '') {
$version = str_replace('\\.', '[._-]', $version);
usort($found, function ($a, $b) use ($version) { return (int) preg_match('~' . $version . '~', $b) - (int) preg_match('~' . $version . '~', $a); });
}
$out = array_merge($out, $found);
}
return array_values(array_unique(array_filter($out, function ($candidate) { return trim((string) $candidate) !== ''; })));
}
protected static function commandCandidates($name, array $paths, array $extensions, $windows, array $preferred = array())
{
$suffixes = array('');
if ($windows && pathinfo((string) $name, PATHINFO_EXTENSION) === '') {
foreach (array_merge($extensions, array('.EXE', '.COM', '.BAT', '.CMD')) as $extension) {
$extension = trim((string) $extension);
if (preg_match('/^\.[A-Za-z0-9]+$/', $extension)) { $suffixes[] = strtolower($extension); }
}
}
$out = $preferred;
foreach ($paths as $directory) {
$directory = trim((string) $directory, " \t\n\r\0\x0B\"");
if ($directory === '') { continue; }
foreach (array_unique($suffixes) as $suffix) { $out[] = rtrim($directory, '/\\') . ($windows ? '\\' : '/') . $name . $suffix; }
}
return array_values(array_unique($out));
}
protected static function isPhpName($path)
{
return preg_match('/^php(?:[0-9]+(?:\.[0-9]+)*)?(?:\.exe)?$/i', basename(str_replace('\\', '/', (string) $path))) === 1;
}
protected static function probe($binary)
{
$command = escapeshellarg((string) $binary) . ' -n -r ' . escapeshellarg('echo PHP_SAPI,chr(124),PHP_VERSION_ID;');
$pipes = array();
try {
$process = @proc_open($command, array(0 => array('pipe', 'r'), 1 => array('pipe', 'w'), 2 => array('pipe', 'w')), $pipes, null, null, array('bypass_shell' => DIRECTORY_SEPARATOR === '\\'));
} catch (\Throwable $e) { return 0; }
if (!is_resource($process)) { return 0; }
fclose($pipes[0]);
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$start = microtime(true); $stdout = ''; $stderr = ''; $exitCode = null; $timedOut = false;
do {
$stdout .= (string) stream_get_contents($pipes[1]);
$stderr .= (string) stream_get_contents($pipes[2]);
$status = proc_get_status($process);
if (!$status['running']) { $exitCode = (int) $status['exitcode']; break; }
if (microtime(true) - $start >= 2 || strlen($stdout) + strlen($stderr) > 8192) { $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]);
$closed = proc_close($process);
$exitCode = $exitCode !== null && $exitCode >= 0 ? $exitCode : $closed;
if ($timedOut || $exitCode !== 0 || trim($stderr) !== '' || !preg_match('/^cli\|([0-9]{5,6})$/', trim($stdout), $match)) { return 0; }
return (int) $match[1];
}
}
@@ -216,6 +216,12 @@
return false;
}
/** Installation defaults become project-owned and must survive core updates. */
public static function preservedPath($logicalPath)
{
return in_array(strtolower(self::normalizeLogicalPath($logicalPath)), array('.htaccess', 'robots.txt'), true);
}
public static function lockFile()
{
return BASEPATH . '/storage/installed.lock';
+25 -9
View File
@@ -103,11 +103,11 @@
foreach (isset($requirements['extensions']) ? (array) $requirements['extensions'] : array() as $extension) { if (!extension_loaded((string) $extension)) { $errors[] = 'Отсутствует PHP-расширение ' . $extension; } }
$conflicts = array();
foreach ($manifest['files'] as $file) {
foreach (self::managedEntries($manifest['files']) as $file) {
if (!self::writableTarget(ReleaseState::resolve($file['path']))) { $errors[] = 'Нет прав записи: ' . $file['path']; }
}
foreach ($manifest['deletions'] as $file) {
foreach (self::managedEntries($manifest['deletions']) as $file) {
$target = ReleaseState::resolve($file['path']);
if (is_file($target) && !is_writable($target)) { $errors[] = 'Нет прав удаления: ' . $file['path']; }
}
@@ -118,7 +118,7 @@
$required = max(10485760, (int) filesize(self::directory($job['id']) . '/patch.zip') * 4 + (int) ceil($databaseBytes * 1.25));
$free = @disk_free_space(self::root());
if ($free !== false && $free < $required) { $errors[] = 'Недостаточно свободного места для staging и отката'; }
return array('ready' => !$errors && !$conflicts, 'errors' => $errors, 'conflicts' => $conflicts, 'files' => count($manifest['files']), 'deletions' => count($manifest['deletions']), 'migrations' => count($manifest['migrations']), 'required_bytes' => $required);
return array('ready' => !$errors && !$conflicts, 'errors' => $errors, 'conflicts' => $conflicts, 'files' => count(self::managedEntries($manifest['files'])), 'deletions' => count(self::managedEntries($manifest['deletions'])), 'preserved' => self::preservedEntries($manifest), 'migrations' => count($manifest['migrations']), 'required_bytes' => $required);
}
public static function rollback($id, $restoreDatabase = true)
@@ -162,7 +162,7 @@
@mkdir($directory, 0750, true);
$job['journal'] = array();
$paths = array();
foreach (array_merge($job['manifest']['files'], $job['manifest']['deletions']) as $file) { $paths[$file['path']] = $file['path']; }
foreach (self::managedEntries(array_merge($job['manifest']['files'], $job['manifest']['deletions'])) as $file) { $paths[$file['path']] = $file['path']; }
$paths['storage/installed.lock'] = 'storage/installed.lock';
foreach ($paths as $logical) {
$actual = $logical === 'storage/installed.lock' ? ReleaseState::lockFile() : ReleaseState::resolve($logical);
@@ -243,13 +243,13 @@
self::assertMaintenanceAvailable($job['id']);
self::writeMaintenance($job);
try {
foreach ($job['manifest']['files'] as $file) {
foreach (self::managedEntries($job['manifest']['files']) as $file) {
$source = self::directory($job['id']) . '/stage/payload/' . $file['path'];
$target = ReleaseState::resolve($file['path']);
self::replaceFile($source, $target, $file['sha256'], $file['mode']);
}
foreach ($job['manifest']['deletions'] as $file) { $target = ReleaseState::resolve($file['path']); if (is_file($target) && !@unlink($target)) { throw new \RuntimeException('Не удалось удалить устаревший файл: ' . $file['path']); } }
foreach (self::managedEntries($job['manifest']['deletions']) as $file) { $target = ReleaseState::resolve($file['path']); if (is_file($target) && !@unlink($target)) { throw new \RuntimeException('Не удалось удалить устаревший файл: ' . $file['path']); } }
$migrations = isset($job['pending_migrations']) && is_array($job['pending_migrations'])
? $job['pending_migrations']
: $job['manifest']['migrations'];
@@ -279,15 +279,31 @@
{
try {
if (!ReleaseState::matches($job['manifest']['to'])) { throw new \RuntimeException('Реестр версии не подтвердил обновление'); }
foreach ($job['manifest']['files'] as $file) { $target = ReleaseState::resolve($file['path']); if (!is_file($target) || !hash_equals($file['sha256'], hash_file('sha256', $target))) { throw new \RuntimeException('Контрольная проверка файла не пройдена: ' . $file['path']); } }
foreach (self::managedEntries($job['manifest']['files']) as $file) { $target = ReleaseState::resolve($file['path']); if (!is_file($target) || !hash_equals($file['sha256'], hash_file('sha256', $target))) { throw new \RuntimeException('Контрольная проверка файла не пройдена: ' . $file['path']); } }
self::verifyRuntime();
self::removeMaintenance(); $job['status'] = 'completed'; $job['progress'] = 100; $job['message'] = 'Обновление установлено'; $job['recovery_token'] = ''; $job['updated_at'] = date(DATE_ATOM); self::save($job); return $job;
$job['preserved_files'] = self::preservedEntries($job['manifest']);
self::removeMaintenance(); $job['status'] = 'completed'; $job['progress'] = 100; $job['message'] = 'Обновление установлено' . ($job['preserved_files'] ? '. Сохранены файлы проекта: ' . implode(', ', $job['preserved_files']) : ''); $job['recovery_token'] = ''; $job['updated_at'] = date(DATE_ATOM); self::save($job); return $job;
} catch (\Throwable $e) {
$job['status'] = 'verification_failed'; $job['error'] = $e->getMessage(); $job['message'] = 'Контрольная проверка не пройдена, требуется откат'; $job['updated_at'] = date(DATE_ATOM); self::save($job); throw $e;
}
}
protected static function withPreflight(array $job) { $job['preflight'] = self::preflight($job); return $job; }
protected static function managedEntries(array $entries)
{
return array_values(array_filter($entries, function ($file) { return !ReleaseState::preservedPath($file['path']); }));
}
protected static function preservedEntries(array $manifest)
{
$paths = array();
foreach (array_merge($manifest['files'], $manifest['deletions']) as $file) {
if (ReleaseState::preservedPath($file['path'])) { $paths[] = $file['path']; }
}
return array_values(array_unique($paths));
}
protected static function writableTarget($path) { $path = (string) $path; if (is_file($path)) { return is_writable($path) && is_writable(dirname($path)); } $directory = dirname($path); while (!is_dir($directory) && $directory !== dirname($directory)) { $directory = dirname($directory); } return is_dir($directory) && is_writable($directory); }
public static function databaseRequirementError($databaseVersion, array $requirements)
{
@@ -350,7 +366,7 @@
}
protected static function replaceFile($source, $target, $checksum, $mode) { if (!is_file($source) || !hash_equals($checksum, hash_file('sha256', $source))) { throw new \RuntimeException('Staging-файл повреждён'); } $directory = dirname($target); if (!is_dir($directory) && !@mkdir($directory, 0755, true) && !is_dir($directory)) { throw new \RuntimeException('Не удалось создать каталог обновления'); } $temporary = $target . '.update-' . bin2hex(random_bytes(4)); if (!copy($source, $temporary) || !@rename($temporary, $target)) { @unlink($temporary); throw new \RuntimeException('Не удалось заменить файл: ' . $target); } @chmod($target, $mode ?: 0644); if (function_exists('opcache_invalidate')) { @opcache_invalidate($target, true); } }
protected static function restoreFiles(array $job) { foreach (array_reverse(isset($job['journal']) ? $job['journal'] : array()) as $entry) { if (!empty($entry['existed'])) { self::replaceFile($entry['backup'], $entry['actual'], hash_file('sha256', $entry['backup']), fileperms($entry['backup']) & 0777); } elseif (is_file($entry['actual'])) { @unlink($entry['actual']); } } }
protected static function restoreFiles(array $job) { foreach (array_reverse(isset($job['journal']) ? $job['journal'] : array()) as $entry) { if (ReleaseState::preservedPath($entry['path'])) { continue; } if (!empty($entry['existed'])) { self::replaceFile($entry['backup'], $entry['actual'], hash_file('sha256', $entry['backup']), fileperms($entry['backup']) & 0777); } elseif (is_file($entry['actual'])) { @unlink($entry['actual']); } } }
protected static function clearRuntimeCaches()
{
Cache::flush();
+56 -4
View File
@@ -113,6 +113,7 @@
$currentMethod = $method;
$currentBody = $body;
$currentHeaders = $options['headers'];
$redirectCookies = array();
$started = microtime(true);
$requestId = substr(hash('sha256', $method . '|' . $currentUrl . '|' . microtime(true)), 0, 24);
self::observe('http.outbound.requesting', 'requesting', $requestId, array(
@@ -139,6 +140,7 @@
'redirects' => $redirect,
'duration_ms' => (int) round((microtime(true) - $started) * 1000),
), array('status' => (int) $response['status'], 'bytes' => (int) $response['bytes']), $options['source']);
unset($response['set_cookies']);
return $response;
}
@@ -148,12 +150,26 @@
$nextUrl = self::redirectUrl($target['url'], (string) $response['headers']['location']);
$nextTarget = self::resolve($nextUrl, $options);
if (self::origin($target) !== self::origin($nextTarget)) {
$sameOrigin = self::origin($target) === self::origin($nextTarget);
if (!$sameOrigin && $options['same_origin_redirects']) {
throw new \RuntimeException('Удалённый сервер перенаправил запрос на другой хост');
}
if (!$sameOrigin) {
$currentHeaders = self::withoutSensitiveHeaders($currentHeaders);
$redirectCookies = array();
} elseif ($options['redirect_cookies']) {
foreach (self::redirectCookiePairs($response['set_cookies']) as $name => $value) {
$redirectCookies[$name] = $value;
}
if ($redirectCookies) {
$currentHeaders = self::withCookieHeader($currentHeaders, $redirectCookies);
}
}
$currentUrl = $nextTarget['url'];
if ($response['status'] === 303 || (($response['status'] === 301 || $response['status'] === 302) && $currentMethod === 'POST')) {
if ($response['status'] === 303 || (($response['status'] === 301 || ($response['status'] === 302 && !$options['preserve_post_on_302'])) && $currentMethod === 'POST')) {
$currentMethod = 'GET';
$currentBody = null;
}
@@ -178,6 +194,7 @@
$bytes = 0;
$overflow = false;
$responseHeaders = array();
$setCookies = array();
$headerOverflow = false;
$file = null;
if ($options['download_path'] !== '' && $method !== 'HEAD') {
@@ -200,10 +217,11 @@
CURLOPT_USERAGENT => $options['user_agent'],
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RESOLVE => array($target['host'] . ':' . $target['port'] . ':' . $resolveIp),
CURLOPT_HEADERFUNCTION => function ($curl, $line) use (&$responseHeaders, &$headerOverflow, $options) {
CURLOPT_HEADERFUNCTION => function ($curl, $line) use (&$responseHeaders, &$setCookies, &$headerOverflow, $options) {
$length = strlen($line);
if (stripos($line, 'HTTP/') === 0) {
$responseHeaders = array();
$setCookies = array();
return $length;
}
@@ -214,6 +232,7 @@
$name = strtolower(trim(substr($line, 0, $position)));
$value = trim(substr($line, $position + 1));
if ($name === 'set-cookie') { $setCookies[] = $value; }
if ($name === 'content-length' && ctype_digit($value) && (int) $value > $options['max_bytes']) {
$headerOverflow = true;
return 0;
@@ -266,7 +285,7 @@
throw new \RuntimeException('Удалённый сервер изменил проверенный сетевой адрес');
}
return array('status' => $status, 'headers' => $responseHeaders, 'body' => $buffer, 'bytes' => $bytes);
return array('status' => $status, 'headers' => $responseHeaders, 'set_cookies' => $setCookies, 'body' => $buffer, 'bytes' => $bytes);
}
protected static function resolve($url, array $options)
@@ -443,6 +462,9 @@
'source' => 'runtime',
'download_path' => '',
'allow_http_errors' => false,
'preserve_post_on_302' => false,
'same_origin_redirects' => false,
'redirect_cookies' => false,
'allowed_private_hosts' => array(),
), $options);
$options['allowed_schemes'] = array_values(array_intersect(array('http', 'https'), array_map('strtolower', (array) $options['allowed_schemes'])));
@@ -453,6 +475,9 @@
$options['max_bytes'] = max(1, min(104857600, (int) $options['max_bytes']));
$options['download_path'] = isset($options['download_path']) ? (string) $options['download_path'] : '';
$options['allow_http_errors'] = !empty($options['allow_http_errors']);
$options['preserve_post_on_302'] = !empty($options['preserve_post_on_302']);
$options['same_origin_redirects'] = !empty($options['same_origin_redirects']);
$options['redirect_cookies'] = !empty($options['redirect_cookies']);
$options['allowed_private_hosts'] = array_values(array_unique(array_filter(array_map(function ($host) {
try { return self::asciiHost($host); } catch (\Throwable $e) { return ''; }
}, (array) $options['allowed_private_hosts']))));
@@ -527,6 +552,33 @@
}));
}
protected static function redirectCookiePairs(array $headers)
{
$pairs = array();
foreach ($headers as $header) {
$part = trim(strtok((string) $header, ';'));
$position = strpos($part, '=');
if ($position === false) { continue; }
$name = trim(substr($part, 0, $position));
$value = trim(substr($part, $position + 1));
if (!preg_match('/^[A-Za-z0-9!#$%&\x27*+.^_`|~-]+$/D', $name) || preg_match('/[\r\n;]/', $value)) { continue; }
$pairs[$name] = $value;
}
return $pairs;
}
protected static function withCookieHeader(array $headers, array $cookies)
{
$headers = array_values(array_filter($headers, function ($header) {
return !preg_match('/^cookie\s*:/i', (string) $header);
}));
$pairs = array();
foreach ($cookies as $name => $value) { $pairs[] = $name . '=' . $value; }
$headers[] = 'Cookie: ' . implode('; ', $pairs);
return $headers;
}
protected static function origin(array $target)
{
return $target['scheme'] . '://' . $target['host'] . ':' . $target['port'];
@@ -0,0 +1,50 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file system/App/Content/Fields/RichEditorSettings.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Content\Fields;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
class RichEditorSettings
{
public static function schema()
{
$schema = array(
array('key' => 'rich_profile', 'type' => 'select', 'label' => 'Панель визуального редактора',
'options' => array('full' => 'Полная', 'compact' => 'Компактная', 'product' => 'Описание товара', 'news' => 'Новость / статья'), 'default' => 'full'),
array('key' => 'rich_headings', 'type' => 'select', 'label' => 'Заголовки в панели',
'options' => array('profile' => 'По профилю', 'all' => 'H1–H6', 'no_h1' => 'H2–H6', 'none' => 'Без заголовков'), 'default' => 'profile'),
array('key' => 'rich_snippets', 'type' => 'select', 'label' => 'Готовые фрагменты',
'options' => array('all' => 'Все', 'info' => 'Информационные', 'product' => 'Товарные', 'none' => 'Не показывать'), 'default' => 'all'),
);
foreach (array('image' => 'изображения', 'table' => 'таблицы', 'source' => 'исходный код', 'align' => 'выравнивание', 'blocks' => 'перемещение блоков') as $key => $label) {
$schema[] = array('key' => 'rich_hide_' . $key, 'type' => 'bool', 'label' => 'Скрыть в панели: ' . $label, 'default' => false);
}
return $schema;
}
public static function options(array $settings)
{
$result = array();
foreach (self::schema() as $desc) {
$key = $desc['key'];
$value = isset($settings[$key]) ? $settings[$key] : $desc['default'];
if ($desc['type'] === 'select' && (!is_scalar($value) || !isset($desc['options'][$value]))) { $value = $desc['default']; }
$result[substr($key, 5)] = $desc['type'] === 'bool' ? (bool) $value : (string) $value;
}
return $result;
}
}
+2 -2
View File
@@ -43,7 +43,7 @@
public function settingsSchema()
{
return array(
return array_merge(array(
array(
'key' => 'mode',
'type' => 'select',
@@ -80,7 +80,7 @@
'label' => 'Высота редактора, px',
'default' => 300,
),
);
), \App\Content\Fields\RichEditorSettings::schema());
}
public function renderEdit(FieldContext $ctx)
@@ -44,11 +44,11 @@
public function settingsSchema()
{
return array(
return array_merge(array(
array('key' => 'editor', 'type' => 'select', 'label' => 'Редактор',
'options' => array('rich', 'plain'), 'default' => 'rich'),
array('key' => 'height', 'type' => 'int', 'label' => 'Высота редактора, px', 'default' => 300),
);
), \App\Content\Fields\RichEditorSettings::schema());
}
public function renderEdit(FieldContext $ctx)
+2 -2
View File
@@ -44,9 +44,9 @@
public function settingsSchema()
{
return array(
return array_merge(array(
array('key' => 'height', 'type' => 'int', 'label' => 'Высота редактора, px', 'default' => 300),
);
), \App\Content\Fields\RichEditorSettings::schema());
}
public function renderEdit(FieldContext $ctx)
+3
View File
@@ -16,5 +16,8 @@
{% endif %}
{% include '@system_auth/phone.twig' %}
{% include '@system_auth/oauth.twig' %}
{% if not password_login_enabled and not phone_providers and not oauth_providers %}
<p role="status" data-auth-unavailable>Вход временно недоступен. Попробуйте позже.</p>
{% endif %}
</div>
</div>
@@ -24,5 +24,8 @@
{% if phone_registration_enabled and registration_phone_providers %}
{% include '@system_auth/phone.twig' with {'phone_providers': registration_phone_providers, 'phone_auth_registration': true, 'return_url': auth_urls.profile} %}
{% endif %}
{% if not email_registration_enabled and not (phone_registration_enabled and registration_phone_providers) %}
<p role="status" data-auth-unavailable>Регистрация сейчас недоступна.</p>
{% endif %}
</div>
</div>
@@ -61,6 +61,7 @@
$params
))->getValue();
$pages = max(1, (int) ceil($total / $limit));
if ($paginate) { PublicPageContext::recordPagination(PublicPageContext::documentId(), 'page', $pages); }
$page = min($page, $pages);
$sql = 'SELECT d.Id,d.document_title,d.document_alias,d.document_excerpt,d.document_published,d.document_count_view'
. ' FROM %b d WHERE ' . $condition . ' ORDER BY d.document_published DESC,d.Id DESC'
+8 -6
View File
@@ -58,7 +58,7 @@
$xml = self::declaration() . "\n<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
for ($part = 1; $part <= $parts; $part++) {
$xml .= " <sitemap><loc>" . self::escape(rtrim(HOST, '/') . '/sitemap-' . $part . '.xml')
. '</loc><lastmod>' . date('c') . "</lastmod></sitemap>\n";
. "</loc></sitemap>\n";
}
return $xml . '</sitemapindex>';
@@ -69,7 +69,8 @@
$frequencies = array('always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never');
$xml = self::declaration() . "\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
if ($includeHome) {
$xml .= self::url(rtrim(HOST, '/') . '/', time(), 'weekly', '0.8');
$home = Repository::home();
if ($home) { array_unshift($rows, $home); }
}
foreach ($rows as $row) {
@@ -79,8 +80,8 @@
: 'weekly';
$changed = !empty($row['document_changed']) ? (int) $row['document_changed'] : (int) $row['document_published'];
$xml .= self::url(
rtrim(HOST, '/') . '/' . ltrim((string) $row['document_alias'], '/') . URL_SUFF,
$changed > 0 ? $changed : time(),
(int) $row['Id'] === 1 ? rtrim(HOST, '/') . '/' : rtrim(HOST, '/') . '/' . ltrim((string) $row['document_alias'], '/') . URL_SUFF,
$changed,
$frequency,
(string) $row['document_sitemap_pr']
);
@@ -91,8 +92,9 @@
protected static function url($location, $timestamp, $frequency, $priority)
{
return ' <url><loc>' . self::escape($location) . '</loc><lastmod>' . date('c', $timestamp)
. '</lastmod><changefreq>' . self::escape($frequency) . '</changefreq><priority>'
return ' <url><loc>' . self::escape($location) . '</loc>'
. ($timestamp > 0 ? '<lastmod>' . date('c', $timestamp) . '</lastmod>' : '')
. '<changefreq>' . self::escape($frequency) . '</changefreq><priority>'
. self::escape($priority) . "</priority></url>\n";
}
+12 -2
View File
@@ -40,7 +40,16 @@
)->getAll() ?: array();
}
protected static function baseSql($select)
public static function home()
{
return DB::query(self::baseSql(
'doc.Id, doc.document_alias, doc.document_published, doc.document_changed,'
. ' doc.document_sitemap_freq, doc.document_sitemap_pr',
true
) . ' LIMIT 1')->getAssoc() ?: null;
}
protected static function baseSql($select, $home = false)
{
$documents = ContentTables::table('documents');
$rubrics = ContentTables::table('rubrics');
@@ -57,7 +66,8 @@
. " WHERE doc.document_status = '1' AND doc.document_deleted = '0'"
. " AND doc.document_in_sitemap = 1"
. ' AND ' . DocumentVisibility::publicSql('doc', 'rub')
. " AND doc.Id != '1' AND doc.Id != '" . (int) PAGE_NOT_FOUND_ID . "'"
. ($home ? ' AND doc.Id = 1' : ' AND doc.Id != 1')
. ' AND doc.Id != ' . (defined('PAGE_NOT_FOUND_ID') ? (int) PAGE_NOT_FOUND_ID : (int) \App\Frontend\PublicSettings::get('page_not_found_id', 0))
. " AND doc.document_meta_robots NOT LIKE '%noindex%'"
. " AND (rub.rubric_template != '' OR EXISTS (SELECT 1 FROM `" . $templates . "` rt"
. " WHERE rt.rubric_id = rub.Id AND rt.template != ''))"
+27
View File
@@ -21,6 +21,33 @@
class Html
{
/** Remove inline CSS from an HTML fragment, leaving other attributes and elements intact. */
public static function removeInlineStyles($html)
{
$html = (string) $html;
if (stripos($html, 'style') === false) { return array('html' => $html, 'removed' => 0); }
$previous = libxml_use_internal_errors(true);
try {
$dom = new \DOMDocument('1.0', 'UTF-8');
if (!$dom->loadHTML('<?xml encoding="UTF-8"><html><body>' . $html . '</body></html>', LIBXML_NONET)) {
throw new \InvalidArgumentException('Не удалось разобрать HTML описания');
}
$nodes = (new \DOMXPath($dom))->query('//*[@style]');
$count = $nodes->length;
if (!$count) { return array('html' => $html, 'removed' => 0); }
foreach ($nodes as $node) { $node->removeAttribute('style'); }
$body = $dom->getElementsByTagName('body')->item(0);
if (!$body) { throw new \InvalidArgumentException('Не удалось разобрать HTML описания'); }
$result = '';
foreach ($body->childNodes as $node) { $result .= $dom->saveHTML($node); }
return array('html' => $result, 'removed' => $count);
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
}
protected function __construct()
{
//
+36
View File
@@ -0,0 +1,36 @@
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file system/App/Helpers/PhpSyntax.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Helpers;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
class PhpSyntax
{
public static function check($source, $lineOffset = 0)
{
if (!function_exists('token_get_all') || !defined('TOKEN_PARSE')) {
return array('ok' => false, 'checked' => false, 'message' => 'Встроенная проверка PHP недоступна на сервере.', 'output' => '', 'line' => 0);
}
try {
token_get_all((string) $source, TOKEN_PARSE);
} catch (\ParseError $e) {
$line = max(1, (int) $e->getLine() + (int) $lineOffset);
return array('ok' => false, 'checked' => true, 'message' => 'В PHP-коде есть синтаксическая ошибка.', 'output' => $e->getMessage() . ' (строка ' . $line . ')', 'line' => $line);
}
return array('ok' => true, 'checked' => true, 'message' => 'Синтаксис PHP без ошибок.', 'output' => '', 'line' => 0);
}
}
+1 -1
View File
@@ -17,5 +17,5 @@
return array(
'product' => 'AVE.cms',
'version' => '3.3',
'build' => '0.41',
'build' => '0.42',
);