From 27e23c30eca1996caf0552db2c314d743c3e0684 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sat, 31 Oct 2026 19:57:04 +0100 Subject: [PATCH 1/7] fix(i18n): translation drift and @composer --- core/composer.json | 10 ++++++++-- core/lang/he/global.php | 2 +- core/lang/ja/global.php | 2 +- core/lang/sk/global.php | 2 +- core/lang/uk/global.php | 2 +- core/lang/zh/global.php | 2 +- 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/core/composer.json b/core/composer.json index 353da92301..432e3ec921 100644 --- a/core/composer.json +++ b/core/composer.json @@ -159,8 +159,14 @@ "scripts": { "sync-replace": "@php functions/sync-replace-version.php", "test": "vendor/bin/pest", - "optimize": "@composer install --no-dev && @composer dump-autoload -o --no-dev --classmap-authoritative", - "optimize-dev": "@composer install && @composer dump-autoload -o --classmap-authoritative", + "optimize": [ + "@composer install --no-dev", + "@composer dump-autoload -o --no-dev --classmap-authoritative" + ], + "optimize-dev": [ + "@composer install", + "@composer dump-autoload -o --classmap-authoritative" + ], "upd": [ "@sync-replace", "@composer update --lock --ansi" diff --git a/core/lang/he/global.php b/core/lang/he/global.php index ddfc0f3d2e..4f2f63cbd2 100644 --- a/core/lang/he/global.php +++ b/core/lang/he/global.php @@ -732,7 +732,7 @@ $_lang["resource_opt_richtext_help"] = 'Leave this checked to use the rich text editor for editing documents. If your documents contain JavaScript and forms, uncheck this to edit in HTML mode and so that the editor doesn\'t screw your documents up.'; $_lang["resource_opt_show_menu"] = 'הצג בתפריט'; $_lang["resource_opt_show_menu_help"] = 'Select this option to show document inside a web menu. Please note that some Menu Builders might choose to ignore this option.'; -$_lang["resource_opt_trackvisit_help"] = 'Log each visitor\'s visit to this page'; +$_lang["resource_opt_trackvisit_help"] = 'תיעוד כל ביקור בדף זה'; $_lang["resource_overview"] = 'סקירת מסמך'; $_lang["resource_parent"] = 'אב המסמך'; $_lang["resource_parent_help"] = 'Click on the icon above to enable (or disable) selecting this document\'s parent. Next, click a document in the tree to set its new parent.'; diff --git a/core/lang/ja/global.php b/core/lang/ja/global.php index a8630dda2e..3efc1f92df 100644 --- a/core/lang/ja/global.php +++ b/core/lang/ja/global.php @@ -808,7 +808,7 @@ $_lang["resource_opt_richtext_help"] = '通常の記事を書く場合はここにチェックを入れたままとし、リッチテキストエディターでリソースを編集することをおすすめします。リソースがJavaScriptやフォームを含む場合は、チェックを外してプレーンテキストによる編集を行ってください。'; $_lang["resource_opt_show_menu"] = 'メニューに表示'; $_lang["resource_opt_show_menu_help"] = 'メニューにこのリソースを表示するにはこのオプションをチェックしてください。メニュー作成スニペットにはこのオプションを無視するものもありますのでご注意ください。'; -$_lang["resource_opt_trackvisit_help"] = 'ドキュメントツリーに子リソースを表示します。'; +$_lang["resource_opt_trackvisit_help"] = 'このページへの訪問を記録します。'; $_lang["resource_overview"] = '詳細情報を表示'; $_lang["resource_parent"] = '親リソース'; $_lang["resource_parent_help"] = 'コンテナアイコンをクリックすると、このリソースの親(コンテナ)を変更できる状態になります。アイコンが変化している状態で、親にしたいリソースをリソースツリー上でクリックしてください。もう一度クリックすると元に戻ります。'; diff --git a/core/lang/sk/global.php b/core/lang/sk/global.php index 9655125587..c396ba6e9f 100644 --- a/core/lang/sk/global.php +++ b/core/lang/sk/global.php @@ -799,7 +799,7 @@ $_lang["resource_opt_richtext_help"] = 'Začiarknite, ak sa má na úpravu zdroja používať HTML editor. Ak zdroj obsahuje JavaScript alebo formuláre - zrušte začiarknutie, aby ste ho mohli upravovať v režime HTML kódu (aby HTML editor nevykonával žiadne zmeny v kóde).'; $_lang["resource_opt_show_menu"] = 'Zobrazovať v menu'; $_lang["resource_opt_show_menu_help"] = 'Zapnite tento parameter, ak sa má zdroj zobrazovať v niektorom menu webu. Majte na pamäti, že niektoré snippety môžu tento parameter ignorovať.'; -$_lang["resource_opt_trackvisit_help"] = 'Začiarknite, ak sa majú podradené zdroje zobrazovať v strome dokumentov'; +$_lang["resource_opt_trackvisit_help"] = 'Zaznamenať každú návštevu tejto stránky'; $_lang["resource_overview"] = 'Prehľad zdroja'; $_lang["resource_parent"] = 'Priečinok'; $_lang["resource_parent_help"] = 'Kliknite na ikonu kontajnera hore pre zapnutie (vypnutie) režimu výberu rodičovského zdroja, potom ho vyberte v strome webu vľavo.'; diff --git a/core/lang/uk/global.php b/core/lang/uk/global.php index 700f147f6c..afeb215f65 100644 --- a/core/lang/uk/global.php +++ b/core/lang/uk/global.php @@ -806,7 +806,7 @@ $_lang["resource_opt_richtext_help"] = 'Відзначте, щоб для редагування ресурсу використовувався HTML-редактор. Якщо ресурс містить JavaScript або форми - зніміть галочку, щоб редагувати його в режимі HTML-коду (щоб HTML-редактор не вносив жодних змін в код).'; $_lang["resource_opt_show_menu"] = 'Показувати в меню'; $_lang["resource_opt_show_menu_help"] = 'Увімкніть цей параметр для відображення ресурсу в будь-якому меню сайту. Майте на увазі, деякі сніпети можуть ігнорувати цей параметр.'; -$_lang["resource_opt_trackvisit_help"] = 'Відзначте, щоб показувати дочірні ресурси в дереві документів'; +$_lang["resource_opt_trackvisit_help"] = 'Записувати кожне відвідування цієї сторінки'; $_lang["resource_overview"] = 'Огляд ресурсу'; $_lang["resource_parent"] = 'Папка'; $_lang["resource_parent_help"] = 'Клацніть мишею на значку контейнера вгорі, щоб включити (виключити) режим вибору батьківського ресурсу, потім виберіть його в дереві сайту зліва.'; diff --git a/core/lang/zh/global.php b/core/lang/zh/global.php index 14e9d97c3f..5e307b97c5 100644 --- a/core/lang/zh/global.php +++ b/core/lang/zh/global.php @@ -732,7 +732,7 @@ $_lang["resource_opt_richtext_help"] = '选择这个选项将让本文档能使用富文本编辑器,如果你的内容里有 JavaScript和表单,请取消这个选择而利用HTML格式来编辑,以免MODX为你自动剖析文本。'; $_lang["resource_opt_show_menu"] = '显示在菜单中'; $_lang["resource_opt_show_menu_help"] = 'Select this option to show Resource inside a web menu. Please note that some Menu Builders might choose to ignore this option.'; -$_lang["resource_opt_trackvisit_help"] = 'Log each visitor\'s visit to this page'; +$_lang["resource_opt_trackvisit_help"] = '记录每位访客对此页面的访问'; $_lang["resource_overview"] = 'Resource overview'; $_lang["resource_parent"] = '文档之父本。'; $_lang["resource_parent_help"] = '点上面文件夹的图标来激活或禁止父本的选择,当激活的时候,你可以从文档树中选择一文档作为本文档的父本。'; From 92edc82d79e43f3ab1bfd59d27e8dc9dffbe4930 Mon Sep 17 00:00:00 2001 From: Shin777 Date: Thu, 22 Oct 2026 23:13:40 +0300 Subject: [PATCH 2/7] Allow access to image files in .htaccess I updated .htaccess to allow common image formats (JPG, PNG, GIF, WebP, AVIF, BMP, and ICO) while keeping other cache files blocked. --- assets/cache/.htaccess | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/assets/cache/.htaccess b/assets/cache/.htaccess index fb1de45bdb..cec631e861 100644 --- a/assets/cache/.htaccess +++ b/assets/cache/.htaccess @@ -1,7 +1,14 @@ Require all denied + + Require all granted + Order deny,allow Deny from all + + Order deny,allow + Allow from all + From 98fd0b873cb8f3615f1b51dfef1dff65dc26697a Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 2 Nov 2026 04:05:28 +0100 Subject: [PATCH 3/7] fix(manager): fix tree drag-n-drop to make resource a folder, inserting near first and last element, move symbol rendered as copy symbol, multiple files upload over PHP limits handling, tree selection in file browser --- core/functions/actions/files.php | 68 +++- core/lang/az/global.php | 7 + core/lang/be/global.php | 7 + core/lang/bg/global.php | 7 + core/lang/cs/global.php | 7 + core/lang/da/global.php | 7 + core/lang/de/global.php | 7 + core/lang/en/global.php | 7 + core/lang/es/global.php | 7 + core/lang/et/global.php | 7 + core/lang/fa/global.php | 7 + core/lang/fi/global.php | 7 + core/lang/fr/global.php | 7 + core/lang/he/global.php | 7 + core/lang/hy/global.php | 7 + core/lang/it/global.php | 7 + core/lang/ja/global.php | 7 + core/lang/ka/global.php | 7 + core/lang/kk/global.php | 7 + core/lang/ky/global.php | 7 + core/lang/lt/global.php | 7 + core/lang/lv/global.php | 7 + core/lang/nl/global.php | 7 + core/lang/nn/global.php | 7 + core/lang/pl/global.php | 7 + core/lang/pt/global.php | 7 + core/lang/ro/global.php | 7 + core/lang/sk/global.php | 7 + core/lang/sv/global.php | 7 + core/lang/tg/global.php | 7 + core/lang/tk/global.php | 7 + core/lang/uk/global.php | 7 + core/lang/uz/global.php | 7 + core/lang/zh/global.php | 7 + core/src/Controllers/Frame.php | 16 +- core/src/Core.php | 4 + core/src/ManagerTheme.php | 12 +- core/src/Support/Formatter/CSSMinify.php | 20 + .../Manager/FileManagerUploadReportTest.php | 136 +++++++ .../FrameMainTargetHelperIncludeTest.php | 12 + .../Manager/SessionKeepAliveIntervalTest.php | 8 + core/tests/Unit/Security/UploadLimitsTest.php | 14 +- .../tests/Unit/Support/CSSMinifyStaleTest.php | 46 +++ core/vendor/composer/LICENSE | 2 + manager/actions/files.dynamic.php | 151 ++++++-- .../media/browser/mcpuk/js/browser/files.js | 1 + .../media/browser/mcpuk/js/browser/folders.js | 63 +-- .../js/tests/folder-double-click.test.js | 71 +++- .../js/tests/tree-selection-sync.test.js | 87 ++++- .../media/browser/mcpuk/themes/evo/style.css | 2 + .../script/tests/tree-drop-helper.test.js | 56 +++ .../media/script/tests/upload-queue.test.js | 82 ++++ manager/media/script/tree-drop-helper.js | 57 +++ manager/media/script/upload-queue.js | 111 ++++++ manager/media/style/default/css/tree.css | 14 +- manager/media/style/default/js/evo.js | 366 +++++++++++------- manager/views/frame/1.blade.php | 1 + 57 files changed, 1390 insertions(+), 241 deletions(-) create mode 100644 core/tests/Unit/Manager/FileManagerUploadReportTest.php create mode 100644 core/tests/Unit/Manager/SessionKeepAliveIntervalTest.php create mode 100644 core/tests/Unit/Support/CSSMinifyStaleTest.php create mode 100644 manager/media/script/tests/upload-queue.test.js create mode 100644 manager/media/script/upload-queue.js diff --git a/core/functions/actions/files.php b/core/functions/actions/files.php index 1e01f1da11..dd46c7985e 100644 --- a/core/functions/actions/files.php +++ b/core/functions/actions/files.php @@ -1908,6 +1908,27 @@ function rrmdir($dir) } } +if (!function_exists('fileManagerUploadSummary')) { + /** + * The head of an upload report: how many of the files the user picked were stored, and how many never + * reached PHP, which drops every file past max_file_uploads without a trace. + */ + function fileManagerUploadSummary(array $lang, int $selected, int $received, int $uploaded, int $maxFileUploads): string + { + $total = max($selected, $received); + $text = static fn (string $key, array $values): string => htmlspecialchars(vsprintf($lang[$key] ?? $key, $values), ENT_QUOTES, 'UTF-8'); + $html = '

' + . $text('files_upload_summary', [$uploaded, $total]) . '

'; + if ($selected > $received) { + $html .= '

' + . $text('files_upload_not_received', [$selected - $received, $maxFileUploads]) . ' ' + . $text('files_upload_zip_hint', []) . '

'; + } + + return $html; + } +} + if(!function_exists('fileupload')) { /** * @return string @@ -1945,10 +1966,16 @@ function fileupload() $dirGroupIds = fileManagerEffectiveGroupIds($dirRel); } $inheritInserts = []; + $received = 0; + $uploaded = 0; foreach ($_FILES['userfile']['name'] as $i => $name) { - if (empty($_FILES['userfile']['tmp_name'][$i])) { + // only an empty file field; a file PHP refused (too large, partial) has no tmp_name either and is reported + if ((int) ($_FILES['userfile']['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { continue; } + $received++; + $fileMsg = ''; + $fileUploaded = false; $userfile = []; $userfile['tmp_name'] = $_FILES['userfile']['tmp_name'][$i]; @@ -1971,11 +1998,11 @@ function fileupload() // this seems to be an upload action. $rel_path = ltrim(substr($startpath, strlen($filemanager_path)), '/'); $path = EVO_SITE_URL . ($rel_path ? $rel_path . '/' : '') . $userfile['name']; - $msg .= htmlspecialchars($path, ENT_QUOTES, 'UTF-8'); + $fileMsg .= htmlspecialchars($path, ENT_QUOTES, 'UTF-8'); if ($userfile['error'] == 0) { $img = (strpos($userfile['type'],'image') !== false) ? '
' : ''; - $msg .= "

" . $_lang['files_file_type'] . htmlspecialchars($userfile['type'], ENT_QUOTES, + $fileMsg .= "

" . $_lang['files_file_type'] . htmlspecialchars($userfile['type'], ENT_QUOTES, 'UTF-8') . ", " . niceSize(filesize($userfile['tmp_name'])) . $img . '

'; } @@ -1984,7 +2011,7 @@ function fileupload() if (is_uploaded_file($userfilename)) { // file is uploaded file, process it! if (!checkExtension($userfile['name'])) { - $msg .= '

' . $_lang['files_filetype_notok'] . '

'; + $fileMsg .= '

' . $_lang['files_filetype_notok'] . '

'; } else { $targetFile = $startpath . '/' . $userfile['name']; // a replaced file is a modified file: its own restrictions apply, not just the folder's. @@ -1993,14 +2020,16 @@ function fileupload() $replacing = file_exists($targetFile); if ($replacing && !fileManagerCanModifyExistingPath($existingRel)) { fileManagerLogDenied('upload over existing file', $existingRel, 'file group'); - $msg .= '

' . $_lang['files_access_denied'] . '

'; + $fileMsg .= '

' . $_lang['files_access_denied'] . '

'; } elseif (@move_uploaded_file($userfile['tmp_name'], $targetFile)) { // Ryan: Repair broken permissions issue with file manager if (strtoupper(substr(PHP_OS, 0, 3)) != 'WIN') { @chmod($targetFile, $new_file_permissions); } // Ryan: End - $msg .= '

' . $_lang['files_upload_ok'] . '


'; + $fileMsg .= '

' . $_lang['files_upload_ok'] . '


'; + $uploaded++; + $fileUploaded = true; // invoke OnFileManagerUpload event $modx->invokeEvent('OnFileManagerUpload', [ @@ -2018,41 +2047,48 @@ function fileupload() } } } else { - $msg .= '

' . $_lang['files_upload_copyfailed'] . ' ' + $fileMsg .= '

' . $_lang['files_upload_copyfailed'] . ' ' . $_lang["files_upload_permissions_error"] . '

'; } } } else { - $msg .= '
' . $_lang['files_upload_error'] . ':'; + $fileMsg .= '
' . $_lang['files_upload_error'] . ':'; switch ($userfile['error']) { case 0: //no error; possible file attack! - $msg .= $_lang['files_upload_error0']; + $fileMsg .= $_lang['files_upload_error0']; break; case 1: //uploaded file exceeds the upload_max_filesize directive in php.ini - $msg .= $_lang['files_upload_error1']; + $fileMsg .= $_lang['files_upload_error1']; break; case 2: //uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the html form - $msg .= $_lang['files_upload_error2']; + $fileMsg .= $_lang['files_upload_error2']; break; case 3: //uploaded file was only partially uploaded - $msg .= $_lang['files_upload_error3']; + $fileMsg .= $_lang['files_upload_error3']; break; case 4: //no file was uploaded - $msg .= $_lang['files_upload_error4']; + $fileMsg .= $_lang['files_upload_error4']; break; default: //a default error, just in case! :) - $msg .= $_lang['files_upload_error5']; + $fileMsg .= $_lang['files_upload_error5']; break; } - $msg .= '
'; + $fileMsg .= '

'; } + // one block per file, so a page can show the failed ones first + $msg .= '
' . $fileMsg . '
'; } if (!empty($inheritInserts)) { \EvolutionCMS\Models\FileGroup::query()->insertOrIgnore($inheritInserts); } - return $msg . '
'; + // the number of files the browser sent in this request, to tell the ones PHP dropped + $selected = max(0, (int) ($_POST['upload_selected'] ?? 0)); + $summary = fileManagerUploadSummary($_lang, $selected, $received, $uploaded, (int) ini_get('max_file_uploads')); + + return '
' + . $summary . $msg . '

'; } } diff --git a/core/lang/az/global.php b/core/lang/az/global.php index ef9799fb7b..d9d077964a 100644 --- a/core/lang/az/global.php +++ b/core/lang/az/global.php @@ -216,6 +216,13 @@ $_lang["files_upload_error5"] = 'Yükləmə zamanı problem yarandı.'; $_lang["files_upload_inhibited_msg"] = 'Yükləmə funksiyası məhdudlaşdırılıb - yükləmələrin dəstəkləndiyinə və qovluğun PHP üçün yazıla bilən olduğuna əmin olun.'; $_lang["files_upload_ok"] = 'Fayl uğurla yükləndi!'; +$_lang["files_upload_summary"] = '%2$s fayldan %1$s fayl yükləndi.'; +$_lang["files_upload_not_received"] = '%1$s fayl serverə çatmadı: server bir yükləmədə ən çox %2$s fayl qəbul edir.'; +$_lang["files_upload_zip_hint"] = 'Onları yenidən yükləyin və ya bir ZIP faylına yığın, onu yükləyin və burada açın.'; +$_lang["files_upload_progress"] = 'Fayllar yüklənir: %1$s-%2$s / %3$s...'; +$_lang["files_upload_details"] = 'Yüklənmiş fayllar'; +$_lang["files_upload_file_skipped"] = '"%1$s" faylının ölçüsü %2$s, lakin faylın ölçüsü ən çox %3$s ola bilər: fayl yüklənmədi.'; +$_lang["files_upload_stopped"] = 'Yükləmə dayandı (%1$s). Bu %2$s fayl yüklənmədi: %3$s'; $_lang["files_upload_permissions_error"] = 'İcazə problemi ola bilər - yükləmək istədiyiniz qovluq veb-serveriniz tərəfindən yazıla bilən olmalıdır.'; $_lang["files_uploadfile"] = 'Fayl yüklə'; $_lang["files_uploadfile_msg"] = 'Yükləmək üçün fayl seçin:'; diff --git a/core/lang/be/global.php b/core/lang/be/global.php index 22b8f43629..4c9e3a9d58 100644 --- a/core/lang/be/global.php +++ b/core/lang/be/global.php @@ -204,6 +204,13 @@ $_lang["files_upload_error5"] = 'Узнікла памылка падчас загрузкі.'; $_lang["files_upload_inhibited_msg"] = 'Функцыя загрузкі забароненая - пераканайцеся, што загрузкі дазволены і каталог даступны для запісу для PHP.'; $_lang["files_upload_ok"] = 'Файл паспяхова загружаны!'; +$_lang["files_upload_summary"] = 'Загружана %1$s з %2$s файлаў.'; +$_lang["files_upload_not_received"] = '%1$s файлаў не дайшлі да сервера: ён прымае не больш за %2$s файлаў за адну загрузку.'; +$_lang["files_upload_zip_hint"] = 'Загрузіце іх яшчэ раз або спакуйце ў адзін ZIP-файл, загрузіце яго і распакуйце тут.'; +$_lang["files_upload_progress"] = 'Загрузка файлаў %1$s-%2$s з %3$s...'; +$_lang["files_upload_details"] = 'Загружаныя файлы'; +$_lang["files_upload_file_skipped"] = 'Памер «%1$s» — %2$s, але файл можа быць не больш за %3$s: ён не загружаны.'; +$_lang["files_upload_stopped"] = 'Загрузка спынілася (%1$s). Гэтыя файлы (%2$s) не загружаны: %3$s'; $_lang["files_upload_permissions_error"] = 'Магчымыя праблемы з дазволамі - каталог, у які вы хочаце загрузіць файл, павінен быць даступным для запісу вашым вэб-серверам.'; $_lang["files_uploadfile"] = 'Загрузіць файл'; $_lang["files_uploadfile_msg"] = 'Выберыце файл для загрузкі:'; diff --git a/core/lang/bg/global.php b/core/lang/bg/global.php index 4d526f5427..2aa17e07a9 100644 --- a/core/lang/bg/global.php +++ b/core/lang/bg/global.php @@ -368,6 +368,13 @@ $_lang["files_upload_error5"] = 'Възникна проблем при ъплоудването.'; $_lang["files_upload_inhibited_msg"] = 'Опцията за ъплоудване е забранена - уверете се, че се поддържа ъплоудване и директорията е достъпна за запис за PHP.
'; $_lang["files_upload_ok"] = 'Файлът е ъплоуднат успешно!'; +$_lang["files_upload_summary"] = 'Ъплоуднати са %1$s от %2$s файла.'; +$_lang["files_upload_not_received"] = '%1$s файла не достигнаха сървъра: той приема най-много %2$s файла при едно ъплоудване.'; +$_lang["files_upload_zip_hint"] = 'Ъплоуднете ги отново или ги пакетирайте в един ZIP файл, ъплоуднете го и го разархивирайте тук.'; +$_lang["files_upload_progress"] = 'Ъплоудване на файлове %1$s-%2$s от %3$s...'; +$_lang["files_upload_details"] = 'Ъплоуднати файлове'; +$_lang["files_upload_file_skipped"] = '„%1$s“ е %2$s, но файлът може да бъде най-много %3$s: не е ъплоуднат.'; +$_lang["files_upload_stopped"] = 'Ъплоудването спря (%1$s). Тези %2$s файла не са ъплоуднати: %3$s'; $_lang["files_upload_permissions_error"] = 'Possible permission problems - the directory you want to upload to needs to be writable by your webserver.'; $_lang["files_uploadfile"] = 'Ъплоудване на файл'; $_lang["files_uploadfile_msg"] = 'Избор на файл за ъплоудване:'; diff --git a/core/lang/cs/global.php b/core/lang/cs/global.php index 976b7055b5..5d092ad100 100644 --- a/core/lang/cs/global.php +++ b/core/lang/cs/global.php @@ -368,6 +368,13 @@ $_lang["files_upload_error5"] = 'Vyskytnul se problém při nahrávání souboru.'; $_lang["files_upload_inhibited_msg"] = 'Do tohoto adresáře se nedají nahrávat soubory - presvěčte se, zda jsou do tohoto adresáře nastavená práva pro zápis.
'; $_lang["files_upload_ok"] = 'Soubor byl úspěšně nahrán!'; +$_lang["files_upload_summary"] = 'Nahráno %1$s z %2$s souborů.'; +$_lang["files_upload_not_received"] = 'Na server nedorazilo %1$s souborů: v jednom nahrávání přijme nejvýše %2$s souborů.'; +$_lang["files_upload_zip_hint"] = 'Nahrajte je znovu, nebo je zabalte do jednoho souboru ZIP, nahrajte ho a rozbalte zde.'; +$_lang["files_upload_progress"] = 'Nahrávají se soubory %1$s-%2$s z %3$s...'; +$_lang["files_upload_details"] = 'Nahrané soubory'; +$_lang["files_upload_file_skipped"] = 'Soubor „%1$s“ má %2$s, ale soubor smí mít nejvýše %3$s: nebyl nahrán.'; +$_lang["files_upload_stopped"] = 'Nahrávání se zastavilo (%1$s). Tyto soubory (%2$s) nebyly nahrány: %3$s'; $_lang["files_upload_permissions_error"] = 'Možný problém s oprávněním - adresář, do kterého chcete nahrávat soubory musí mít povoleno zapisování servrem.'; $_lang["files_uploadfile"] = 'Nahrát soubor'; $_lang["files_uploadfile_msg"] = 'Vybrat soubor pro nahrání:'; diff --git a/core/lang/da/global.php b/core/lang/da/global.php index 0ebf91ba09..a932623805 100644 --- a/core/lang/da/global.php +++ b/core/lang/da/global.php @@ -369,6 +369,13 @@ $_lang["files_upload_error5"] = 'Der opstod et problem under upload.'; $_lang["files_upload_inhibited_msg"] = 'Upload funktionen er deaktiveret - kontroller at upload er understøttet, samt at mappen er skrivbar for PHP.'; $_lang["files_upload_ok"] = 'Upload blev gennemført!'; +$_lang["files_upload_summary"] = 'Uploadede %1$s af %2$s filer.'; +$_lang["files_upload_not_received"] = '%1$s filer nåede ikke frem til serveren: den modtager højst %2$s filer i én upload.'; +$_lang["files_upload_zip_hint"] = 'Upload dem igen, eller pak dem i én ZIP-fil, upload den og pak den ud her.'; +$_lang["files_upload_progress"] = 'Uploader filer %1$s-%2$s af %3$s...'; +$_lang["files_upload_details"] = 'Uploadede filer'; +$_lang["files_upload_file_skipped"] = '"%1$s" er %2$s, men en fil må højst være %3$s: den blev ikke uploadet.'; +$_lang["files_upload_stopped"] = 'Uploaden stoppede (%1$s). Disse %2$s filer blev ikke uploadet: %3$s'; $_lang["files_upload_permissions_error"] = 'Problem med rettigheder. Mappen, som der uploades til, skal være skrivbar af webserveren.'; $_lang["files_uploadfile"] = 'Upload fil'; $_lang["files_uploadfile_msg"] = 'Vælg en fil til upload:'; diff --git a/core/lang/de/global.php b/core/lang/de/global.php index 186b9ddec6..2f29b90289 100644 --- a/core/lang/de/global.php +++ b/core/lang/de/global.php @@ -386,6 +386,13 @@ $_lang["files_upload_error5"] = 'Beim Upload ist ein Problem aufgetreten.'; $_lang["files_upload_inhibited_msg"] = 'Datei-Upload fehlgeschlagen. Stellen Sie sicher, dass der Upload unterstützt wird und PHP in dieses Verzeichnis schreiben darf.'; $_lang["files_upload_ok"] = 'Upload erfolgreich!'; +$_lang["files_upload_summary"] = '%1$s von %2$s Dateien hochgeladen.'; +$_lang["files_upload_not_received"] = '%1$s Dateien haben den Server nicht erreicht: Er nimmt höchstens %2$s Dateien pro Upload an.'; +$_lang["files_upload_zip_hint"] = 'Laden Sie sie erneut hoch oder packen Sie sie in eine ZIP-Datei, laden Sie diese hoch und entpacken Sie sie hier.'; +$_lang["files_upload_progress"] = 'Dateien %1$s-%2$s von %3$s werden hochgeladen...'; +$_lang["files_upload_details"] = 'Hochgeladene Dateien'; +$_lang["files_upload_file_skipped"] = '„%1$s“ ist %2$s groß, eine Datei darf aber höchstens %3$s groß sein: Sie wurde nicht hochgeladen.'; +$_lang["files_upload_stopped"] = 'Der Upload wurde abgebrochen (%1$s). Diese %2$s Dateien wurden nicht hochgeladen: %3$s'; $_lang["files_upload_permissions_error"] = 'Mögliche Probleme mit den Zugriffsrechten: das Upload-Verzeichnis muss auf dem Web-Server beschreibbar sein.'; $_lang["files_uploadfile"] = 'Datei hochladen'; $_lang["files_uploadfile_msg"] = 'Datei zum Hochladen wählen:'; diff --git a/core/lang/en/global.php b/core/lang/en/global.php index 0f8fb195c6..debacda57d 100644 --- a/core/lang/en/global.php +++ b/core/lang/en/global.php @@ -398,6 +398,13 @@ $_lang["files_upload_error5"] = 'There was a problem with your upload.'; $_lang["files_upload_inhibited_msg"] = 'Upload feature inhibited - make sure uploads are supported and the directory is writable for PHP.'; $_lang["files_upload_ok"] = 'File uploaded successfully!'; +$_lang["files_upload_summary"] = 'Uploaded %1$s of %2$s files.'; +$_lang["files_upload_not_received"] = '%1$s files did not reach the server: it accepts at most %2$s files in one upload.'; +$_lang["files_upload_zip_hint"] = 'Upload them again, or pack them into one ZIP file, upload it and unpack it here.'; +$_lang["files_upload_progress"] = 'Uploading files %1$s-%2$s of %3$s...'; +$_lang["files_upload_details"] = 'Uploaded files'; +$_lang["files_upload_file_skipped"] = '"%1$s" is %2$s, but a file can be at most %3$s: it was not uploaded.'; +$_lang["files_upload_stopped"] = 'The upload stopped (%1$s). These %2$s files were not uploaded: %3$s'; $_lang["files_upload_permissions_error"] = 'Possible permission problems - the directory you want to upload to needs to be writable by your webserver.'; $_lang["files_uploadfile"] = 'Upload file'; $_lang["files_uploadfile_msg"] = 'Select a file to upload:'; diff --git a/core/lang/es/global.php b/core/lang/es/global.php index 38ededb5b9..02106f1896 100644 --- a/core/lang/es/global.php +++ b/core/lang/es/global.php @@ -386,6 +386,13 @@ $_lang["files_upload_error5"] = 'Hubo un problema con tu subida.'; $_lang["files_upload_inhibited_msg"] = 'Característica de subida deshabilitada - asegúrate de que las subidas están soportadas y que el directorio es escribible por PHP.
'; $_lang["files_upload_ok"] = '¡Archivo subido con éxito!'; +$_lang["files_upload_summary"] = 'Se subieron %1$s de %2$s archivos.'; +$_lang["files_upload_not_received"] = '%1$s archivos no llegaron al servidor: acepta como máximo %2$s archivos por subida.'; +$_lang["files_upload_zip_hint"] = 'Vuelve a subirlos, o empaquétalos en un solo archivo ZIP, súbelo y descomprímelo aquí.'; +$_lang["files_upload_progress"] = 'Subiendo archivos %1$s-%2$s de %3$s...'; +$_lang["files_upload_details"] = 'Archivos subidos'; +$_lang["files_upload_file_skipped"] = '"%1$s" ocupa %2$s, pero un archivo puede ocupar como máximo %3$s: no se ha subido.'; +$_lang["files_upload_stopped"] = 'La subida se detuvo (%1$s). Estos %2$s archivos no se subieron: %3$s'; $_lang["files_upload_permissions_error"] = 'Posibles problemas de permiso - el directorio al que quieres subir necesita ser escribible por tu servidor web.'; $_lang["files_uploadfile"] = 'Subir archivo'; $_lang["files_uploadfile_msg"] = 'Selecciona archivo a subir:'; diff --git a/core/lang/et/global.php b/core/lang/et/global.php index 597cc2692b..e8fbfd3804 100644 --- a/core/lang/et/global.php +++ b/core/lang/et/global.php @@ -398,6 +398,13 @@ $_lang["files_upload_error5"] = 'Üleslaadimisel tekkis probleem.'; $_lang["files_upload_inhibited_msg"] = 'Üleslaadimine on keelatud – veenduge, et üleslaadimine on toetatud ja PHP saab kausta kirjutada.'; $_lang["files_upload_ok"] = 'Fail laaditi edukalt üles!'; +$_lang["files_upload_summary"] = '%2$s failist laaditi üles %1$s.'; +$_lang["files_upload_not_received"] = '%1$s faili ei jõudnud serverisse: see võtab ühe üleslaadimisega vastu kuni %2$s faili.'; +$_lang["files_upload_zip_hint"] = 'Laadige need uuesti üles või pakkige need ühte ZIP-faili, laadige see üles ja pakkige see siin lahti.'; +$_lang["files_upload_progress"] = 'Laaditakse üles faile %1$s-%2$s (kokku %3$s)...'; +$_lang["files_upload_details"] = 'Üleslaaditud failid'; +$_lang["files_upload_file_skipped"] = 'Fail „%1$s“ on %2$s, kuid fail võib olla kuni %3$s: seda ei laaditud üles.'; +$_lang["files_upload_stopped"] = 'Üleslaadimine peatus (%1$s). Neid faile (%2$s) ei laaditud üles: %3$s'; $_lang["files_upload_permissions_error"] = 'Võimalik õiguste probleem – kataloog, kuhu soovite faile üles laadida, peab olema veebiserverile kirjutatav.'; $_lang["files_uploadfile"] = 'Laadi fail üles'; $_lang["files_uploadfile_msg"] = 'Valige üleslaaditav fail:'; diff --git a/core/lang/fa/global.php b/core/lang/fa/global.php index dadca76c12..653a2b224a 100644 --- a/core/lang/fa/global.php +++ b/core/lang/fa/global.php @@ -369,6 +369,13 @@ $_lang["files_upload_error5"] = 'مشکلی در عملیات آپلود فایل شما ایجاد شده است.'; $_lang["files_upload_inhibited_msg"] = 'قابلیت آپلود سلب شده است - از این موضوع اطمینان حاصل کنید که قابلیت آپلود فراهم است و دایره یا دایرکتوری آن قابل نگارش است.
'; $_lang["files_upload_ok"] = 'آپلود فایل موفقیت آمیز بود!'; +$_lang["files_upload_summary"] = '%1$s از %2$s فایل آپلود شد.'; +$_lang["files_upload_not_received"] = '%1$s فایل به سرور نرسید: سرور در هر آپلود حداکثر %2$s فایل می‌پذیرد.'; +$_lang["files_upload_zip_hint"] = 'آنها را دوباره آپلود کنید، یا همه را در یک فایل ZIP فشرده کنید، آن را آپلود کنید و اینجا از حالت فشرده خارج کنید.'; +$_lang["files_upload_progress"] = 'در حال آپلود فایل‌های %1$s-%2$s از %3$s...'; +$_lang["files_upload_details"] = 'فایل‌های آپلودشده'; +$_lang["files_upload_file_skipped"] = 'حجم «%1$s» برابر %2$s است، اما حجم هر فایل حداکثر می‌تواند %3$s باشد: این فایل آپلود نشد.'; +$_lang["files_upload_stopped"] = 'آپلود متوقف شد (%1$s). این %2$s فایل آپلود نشدند: %3$s'; $_lang["files_upload_permissions_error"] = 'Possible permission problems - the directory you want to upload to needs to be writable by your webserver.'; $_lang["files_uploadfile"] = 'آپلود فایل'; $_lang["files_uploadfile_msg"] = 'انتخاب فایل برای آپلود :'; diff --git a/core/lang/fi/global.php b/core/lang/fi/global.php index 3e22f21b43..1bec1845f6 100644 --- a/core/lang/fi/global.php +++ b/core/lang/fi/global.php @@ -368,6 +368,13 @@ $_lang["files_upload_error5"] = 'Ongelma tiedoston siirrossa.'; $_lang["files_upload_inhibited_msg"] = 'Siirto-oikeuksia rajoitettu. Varmista tiedoston siirtojen tuki ja tarkista kansion kirjoitusoikeudet.'; $_lang["files_upload_ok"] = 'Tiedoston siirto onnistui!'; +$_lang["files_upload_summary"] = 'Siirretty %1$s/%2$s tiedostoa.'; +$_lang["files_upload_not_received"] = '%1$s tiedostoa ei saapunut palvelimelle: se vastaanottaa enintään %2$s tiedostoa yhdellä siirrolla.'; +$_lang["files_upload_zip_hint"] = 'Siirrä ne uudelleen tai pakkaa ne yhteen ZIP-tiedostoon, siirrä se ja pura se täällä.'; +$_lang["files_upload_progress"] = 'Siirretään tiedostoja %1$s-%2$s/%3$s...'; +$_lang["files_upload_details"] = 'Siirretyt tiedostot'; +$_lang["files_upload_file_skipped"] = 'Tiedoston "%1$s" koko on %2$s, mutta tiedosto voi olla enintään %3$s: sitä ei siirretty.'; +$_lang["files_upload_stopped"] = 'Siirto keskeytyi (%1$s). Näitä tiedostoja (%2$s) ei siirretty: %3$s'; $_lang["files_upload_permissions_error"] = 'Mahdollinen käyttöoikeusongelma - palvelimella on oltava kirjoitusoikeudet kansioon, johon haluat ladata tiedostoja.'; $_lang["files_uploadfile"] = 'Lähetä tiedosto'; $_lang["files_uploadfile_msg"] = 'Valitse lähetettävä tiedosto:'; diff --git a/core/lang/fr/global.php b/core/lang/fr/global.php index 20e6b30996..e585c14930 100644 --- a/core/lang/fr/global.php +++ b/core/lang/fr/global.php @@ -371,6 +371,13 @@ $_lang["files_upload_error5"] = 'Un problème est survenu lors de votre téléchargement.'; $_lang["files_upload_inhibited_msg"] = 'Le téléchargement est désactivé - assurez-vous que votre serveur supporte le téléchargement et que PHP possède l\'autorisation d\'écrire dans le répertoire.
'; $_lang["files_upload_ok"] = 'Fichier téléchargé avec succès!'; +$_lang["files_upload_summary"] = '%1$s fichiers sur %2$s téléversés.'; +$_lang["files_upload_not_received"] = '%1$s fichiers ne sont pas parvenus au serveur : il accepte au maximum %2$s fichiers par téléversement.'; +$_lang["files_upload_zip_hint"] = 'Téléversez-les à nouveau, ou regroupez-les dans un seul fichier ZIP, téléversez-le et décompressez-le ici.'; +$_lang["files_upload_progress"] = 'Téléversement des fichiers %1$s-%2$s sur %3$s...'; +$_lang["files_upload_details"] = 'Fichiers téléversés'; +$_lang["files_upload_file_skipped"] = '« %1$s » pèse %2$s, mais un fichier ne peut pas dépasser %3$s : il n\'a pas été téléversé.'; +$_lang["files_upload_stopped"] = 'Le téléversement s\'est interrompu (%1$s). Ces %2$s fichiers n\'ont pas été téléversés : %3$s'; $_lang["files_upload_permissions_error"] = 'Il y a probablement un problème de permissions - le répertoire où vous souhaitez envoyer un fichier doit être accessible en écriture par votre serveur.'; $_lang["files_uploadfile"] = 'Téléverser un fichier'; $_lang["files_uploadfile_msg"] = 'Sélectionnez un fichier à téléverser:'; diff --git a/core/lang/he/global.php b/core/lang/he/global.php index 4f2f63cbd2..8f081485d5 100644 --- a/core/lang/he/global.php +++ b/core/lang/he/global.php @@ -369,6 +369,13 @@ $_lang["files_upload_error5"] = 'There was a problem with your upload.'; $_lang["files_upload_inhibited_msg"] = 'Upload feature inhibited - make sure uploads are supported and the directory is writable for PHP.
'; $_lang["files_upload_ok"] = 'File uploaded successfully!'; +$_lang["files_upload_summary"] = 'הועלו %1$s מתוך %2$s קבצים.'; +$_lang["files_upload_not_received"] = '%1$s קבצים לא הגיעו לשרת: הוא מקבל לכל היותר %2$s קבצים בהעלאה אחת.'; +$_lang["files_upload_zip_hint"] = 'העלו אותם שוב, או ארזו אותם בקובץ ZIP אחד, העלו אותו וחלצו אותו כאן.'; +$_lang["files_upload_progress"] = 'מעלה קבצים %1$s-%2$s מתוך %3$s...'; +$_lang["files_upload_details"] = 'קבצים שהועלו'; +$_lang["files_upload_file_skipped"] = 'גודל "%1$s" הוא %2$s, אך גודל קובץ יכול להיות לכל היותר %3$s: הקובץ לא הועלה.'; +$_lang["files_upload_stopped"] = 'ההעלאה נעצרה (%1$s). %2$s הקבצים האלה לא הועלו: %3$s'; $_lang["files_upload_permissions_error"] = 'Possible permission problems - the directory you want to upload to needs to be writable by your webserver.'; $_lang["files_uploadfile"] = 'טען קובץ'; $_lang["files_uploadfile_msg"] = 'Select a file to upload:'; diff --git a/core/lang/hy/global.php b/core/lang/hy/global.php index 809a0e58ec..88ab4ca972 100644 --- a/core/lang/hy/global.php +++ b/core/lang/hy/global.php @@ -398,6 +398,13 @@ $_lang["files_upload_error5"] = 'Վերբեռնման ընթացքում խնդիր է առաջացել։'; $_lang["files_upload_inhibited_msg"] = 'Վերբեռնման գործառույթն անջատված է - համոզվեք, որ վերբեռնումներն աջակցվում են, և PHP-ն կարող է գրել պանակում։'; $_lang["files_upload_ok"] = 'Ֆայլը հաջողությամբ վերբեռնվել է։'; +$_lang["files_upload_summary"] = 'Վերբեռնվել է %1$s ֆայլ %2$s-ից։'; +$_lang["files_upload_not_received"] = '%1$s ֆայլ չի հասել սերվերին. այն մեկ վերբեռնման ընթացքում ընդունում է առավելագույնը %2$s ֆայլ։'; +$_lang["files_upload_zip_hint"] = 'Վերբեռնեք դրանք կրկին կամ փաթեթավորեք մեկ ZIP ֆայլի մեջ, վերբեռնեք այն և ապափաթեթավորեք այստեղ։'; +$_lang["files_upload_progress"] = 'Վերբեռնվում են ֆայլեր %1$s-%2$s / %3$s...'; +$_lang["files_upload_details"] = 'Վերբեռնված ֆայլեր'; +$_lang["files_upload_file_skipped"] = '«%1$s» ֆայլի չափը %2$s է, բայց ֆայլը կարող է լինել առավելագույնը %3$s. այն չի վերբեռնվել։'; +$_lang["files_upload_stopped"] = 'Վերբեռնումը դադարեց (%1$s)։ Այս %2$s ֆայլերը չեն վերբեռնվել. %3$s'; $_lang["files_upload_permissions_error"] = 'Հնարավոր են թույլտվությունների խնդիրներ. բեռնման նպատակային պանակը պետք է գրելի լինի ձեր վեբ սերվերի համար։'; $_lang["files_uploadfile"] = 'Բեռնել ֆայլ'; $_lang["files_uploadfile_msg"] = 'Ընտրեք բեռնվող ֆայլը՝'; diff --git a/core/lang/it/global.php b/core/lang/it/global.php index c8149e28c9..9ce58b0f6e 100644 --- a/core/lang/it/global.php +++ b/core/lang/it/global.php @@ -386,6 +386,13 @@ $_lang["files_upload_error5"] = 'Si è verificato un problema durante il caricamento.'; $_lang["files_upload_inhibited_msg"] = 'Il caricamento file è inibito - assicuratevi che il caricamento sia supportato e che la cartella sia scrivibile da PHP.
'; $_lang["files_upload_ok"] = 'File caricato con successo!'; +$_lang["files_upload_summary"] = 'Caricati %1$s file su %2$s.'; +$_lang["files_upload_not_received"] = '%1$s file non sono arrivati al server: accetta al massimo %2$s file per caricamento.'; +$_lang["files_upload_zip_hint"] = 'Caricateli di nuovo, oppure raccoglieteli in un unico file ZIP, caricatelo ed estraetelo qui.'; +$_lang["files_upload_progress"] = 'Caricamento dei file %1$s-%2$s di %3$s...'; +$_lang["files_upload_details"] = 'File caricati'; +$_lang["files_upload_file_skipped"] = '"%1$s" occupa %2$s, ma un file può occupare al massimo %3$s: non è stato caricato.'; +$_lang["files_upload_stopped"] = 'Il caricamento si è interrotto (%1$s). Questi %2$s file non sono stati caricati: %3$s'; $_lang["files_upload_permissions_error"] = 'Possibile errore nei permessi di scrittura - la directory deve poter essere scrivibile dal webserver.'; $_lang["files_uploadfile"] = 'Carica file'; $_lang["files_uploadfile_msg"] = 'Selezionate un file da caricare:'; diff --git a/core/lang/ja/global.php b/core/lang/ja/global.php index 3efc1f92df..c3665db4cf 100644 --- a/core/lang/ja/global.php +++ b/core/lang/ja/global.php @@ -396,6 +396,13 @@ $_lang["files_upload_error5"] = 'アップロード中に問題が発生しました。'; $_lang["files_upload_inhibited_msg"] = 'アップロード機能が使用できません。 - アップロードがサポートされているか、PHPがディレクトリに書込パーミッションを持っているかを確認してください。
'; $_lang["files_upload_ok"] = 'ファイルのアップロードが完了しました。'; +$_lang["files_upload_summary"] = '%2$s 件中 %1$s 件のファイルをアップロードしました。'; +$_lang["files_upload_not_received"] = '%1$s 件のファイルがサーバーに届きませんでした。サーバーが一度に受け付けるファイルは最大 %2$s 件です。'; +$_lang["files_upload_zip_hint"] = 'もう一度アップロードするか、1つのZIPファイルにまとめてアップロードし、ここで解凍してください。'; +$_lang["files_upload_progress"] = 'ファイル %1$s-%2$s / %3$s をアップロード中...'; +$_lang["files_upload_details"] = 'アップロードしたファイル'; +$_lang["files_upload_file_skipped"] = '「%1$s」のサイズは %2$s ですが、ファイルの最大サイズは %3$s です。アップロードされませんでした。'; +$_lang["files_upload_stopped"] = 'アップロードが中断しました(%1$s)。次の %2$s 件のファイルはアップロードされませんでした: %3$s'; $_lang["files_upload_permissions_error"] = 'パーミッションエラー - ファイルをアップロードするディレクトリを書き込み可能にしてください。'; $_lang["files_uploadfile"] = 'アップロードを実行'; $_lang["files_uploadfile_msg"] = 'アップロードするファイルを選択'; diff --git a/core/lang/ka/global.php b/core/lang/ka/global.php index 6a2a9ed864..0ca4d49d87 100644 --- a/core/lang/ka/global.php +++ b/core/lang/ka/global.php @@ -398,6 +398,13 @@ $_lang["files_upload_error5"] = 'ატვირთვისას პრობლემა წარმოიშვა.'; $_lang["files_upload_inhibited_msg"] = 'ატვირთვის ფუნქცია შეზღუდულია - დარწმუნდით, რომ ატვირთვა მხარდაჭერილია და PHP-ს საქაღალდეში ჩაწერის უფლება აქვს.'; $_lang["files_upload_ok"] = 'ფაილი წარმატებით აიტვირთა!'; +$_lang["files_upload_summary"] = 'აიტვირთა %1$s ფაილი %2$s-დან.'; +$_lang["files_upload_not_received"] = '%1$s ფაილმა ვერ მიაღწია სერვერს: ის ერთ ატვირთვაზე მაქსიმუმ %2$s ფაილს იღებს.'; +$_lang["files_upload_zip_hint"] = 'ატვირთეთ ისინი ხელახლა, ან შეფუთეთ ერთ ZIP ფაილში, ატვირთეთ და აქ გაშალეთ.'; +$_lang["files_upload_progress"] = 'იტვირთება ფაილები %1$s-%2$s / %3$s...'; +$_lang["files_upload_details"] = 'ატვირთული ფაილები'; +$_lang["files_upload_file_skipped"] = '„%1$s“ არის %2$s, მაგრამ ფაილი შეიძლება იყოს მაქსიმუმ %3$s: ის არ აიტვირთა.'; +$_lang["files_upload_stopped"] = 'ატვირთვა შეწყდა (%1$s). ეს %2$s ფაილი არ აიტვირთა: %3$s'; $_lang["files_upload_permissions_error"] = 'შესაძლოა, უფლებების პრობლემაა - ვებსერვერს სამიზნე საქაღალდეში ჩაწერის უფლება უნდა ჰქონდეს.'; $_lang["files_uploadfile"] = 'ფაილის ატვირთვა'; $_lang["files_uploadfile_msg"] = 'აირჩიეთ ასატვირთი ფაილი:'; diff --git a/core/lang/kk/global.php b/core/lang/kk/global.php index 0384c5b08f..41e24f1465 100644 --- a/core/lang/kk/global.php +++ b/core/lang/kk/global.php @@ -419,6 +419,13 @@ $_lang["files_upload_error5"] = 'Жүктеу кезінде мәселе пайда болды.'; $_lang["files_upload_inhibited_msg"] = 'Жүктеу мүмкіндігі бұғатталған — жүктеуге қолдау барын және PHP каталогқа жаза алатынын тексеріңіз.'; $_lang["files_upload_ok"] = 'Файл сәтті жүктелді!'; +$_lang["files_upload_summary"] = '%2$s файлдың %1$s файлы жүктелді.'; +$_lang["files_upload_not_received"] = '%1$s файл серверге жетпеді: ол бір жүктеуде ең көбі %2$s файл қабылдайды.'; +$_lang["files_upload_zip_hint"] = 'Оларды қайта жүктеңіз немесе бір ZIP файлына буып, оны жүктеп, осы жерде ашыңыз.'; +$_lang["files_upload_progress"] = '%3$s файлдың %1$s-%2$s файлдары жүктелуде...'; +$_lang["files_upload_details"] = 'Жүктелген файлдар'; +$_lang["files_upload_file_skipped"] = '«%1$s» көлемі %2$s, бірақ файл ең көбі %3$s болуы мүмкін: ол жүктелмеді.'; +$_lang["files_upload_stopped"] = 'Жүктеу тоқтады (%1$s). Мына %2$s файл жүктелмеді: %3$s'; $_lang["files_upload_permissions_error"] = 'Рұқсат мәселесі болуы мүмкін — веб-сервер жүктеу каталогына жаза алуы керек.'; $_lang["files_uploadfile"] = 'Файлды жүктеу'; $_lang["files_uploadfile_msg"] = 'Жүктелетін файлды таңдаңыз:'; diff --git a/core/lang/ky/global.php b/core/lang/ky/global.php index 6301090f64..9034476155 100644 --- a/core/lang/ky/global.php +++ b/core/lang/ky/global.php @@ -398,6 +398,13 @@ $_lang["files_upload_error5"] = 'Жүктөөдө көйгөй чыкты.'; $_lang["files_upload_inhibited_msg"] = 'Жүктөө мүмкүнчүлүгү өчүрүлгөн — жүктөөгө колдоо бар экенин жана PHP папкага жаза аларын текшериңиз.'; $_lang["files_upload_ok"] = 'Файл ийгиликтүү жүктөлдү!'; +$_lang["files_upload_summary"] = '%2$s файлдын %1$s файлы жүктөлдү.'; +$_lang["files_upload_not_received"] = '%1$s файл серверге жеткен жок: ал бир жүктөөдө эң көп дегенде %2$s файл кабыл алат.'; +$_lang["files_upload_zip_hint"] = 'Аларды кайра жүктөңүз же бир ZIP файлга таңгактап, аны жүктөп, ушул жерде ачыңыз.'; +$_lang["files_upload_progress"] = '%3$s файлдын %1$s-%2$s файлдары жүктөлүүдө...'; +$_lang["files_upload_details"] = 'Жүктөлгөн файлдар'; +$_lang["files_upload_file_skipped"] = '«%1$s» көлөмү %2$s, бирок файл эң көп дегенде %3$s болушу мүмкүн: ал жүктөлгөн жок.'; +$_lang["files_upload_stopped"] = 'Жүктөө токтоду (%1$s). Бул %2$s файл жүктөлгөн жок: %3$s'; $_lang["files_upload_permissions_error"] = 'Укуктарда көйгөй болушу мүмкүн — веб-сервер максаттуу папкага жаза алышы керек.'; $_lang["files_uploadfile"] = 'Файл жүктөө'; $_lang["files_uploadfile_msg"] = 'Жүктөлүүчү файлды тандаңыз:'; diff --git a/core/lang/lt/global.php b/core/lang/lt/global.php index b12c2797fd..244477d9b9 100644 --- a/core/lang/lt/global.php +++ b/core/lang/lt/global.php @@ -398,6 +398,13 @@ $_lang["files_upload_error5"] = 'Įkeliant kilo problema.'; $_lang["files_upload_inhibited_msg"] = 'Įkėlimo funkcija išjungta – įsitikinkite, kad įkėlimas palaikomas ir PHP gali rašyti į katalogą.'; $_lang["files_upload_ok"] = 'Failas sėkmingai įkeltas!'; +$_lang["files_upload_summary"] = 'Įkelta %1$s iš %2$s failų.'; +$_lang["files_upload_not_received"] = '%1$s failų nepasiekė serverio: jis vienu įkėlimu priima ne daugiau kaip %2$s failų.'; +$_lang["files_upload_zip_hint"] = 'Įkelkite juos dar kartą arba supakuokite į vieną ZIP failą, įkelkite jį ir išpakuokite čia.'; +$_lang["files_upload_progress"] = 'Įkeliami failai %1$s-%2$s iš %3$s...'; +$_lang["files_upload_details"] = 'Įkelti failai'; +$_lang["files_upload_file_skipped"] = '„%1$s“ dydis yra %2$s, bet failas gali būti ne didesnis nei %3$s: jis neįkeltas.'; +$_lang["files_upload_stopped"] = 'Įkėlimas sustojo (%1$s). Šie failai (%2$s) neįkelti: %3$s'; $_lang["files_upload_permissions_error"] = 'Galimos prieigos teisių problemos - katalogas, į kurį norite įkelti, turi būti prieinamas žiniatinklio serveriui rašyti.'; $_lang["files_uploadfile"] = 'Įkelti failą'; $_lang["files_uploadfile_msg"] = 'Pasirinkite failą įkelti:'; diff --git a/core/lang/lv/global.php b/core/lang/lv/global.php index 4b3414e513..477f47dbb8 100644 --- a/core/lang/lv/global.php +++ b/core/lang/lv/global.php @@ -398,6 +398,13 @@ $_lang["files_upload_error5"] = 'Augšupielādes laikā radās problēma.'; $_lang["files_upload_inhibited_msg"] = 'Augšupielādes funkcija ir atspējota — pārliecinieties, ka augšupielāde tiek atbalstīta un PHP ir atļauts rakstīt direktorijā.'; $_lang["files_upload_ok"] = 'Fails sekmīgi augšupielādēts!'; +$_lang["files_upload_summary"] = 'Augšupielādēti %1$s no %2$s failiem.'; +$_lang["files_upload_not_received"] = '%1$s faili nenonāca līdz serverim: tas vienā augšupielādē pieņem ne vairāk kā %2$s failus.'; +$_lang["files_upload_zip_hint"] = 'Augšupielādējiet tos vēlreiz vai sapakojiet vienā ZIP failā, augšupielādējiet to un izpakojiet šeit.'; +$_lang["files_upload_progress"] = 'Augšupielādē failus %1$s-%2$s no %3$s...'; +$_lang["files_upload_details"] = 'Augšupielādētie faili'; +$_lang["files_upload_file_skipped"] = '„%1$s“ izmērs ir %2$s, bet faila izmērs nedrīkst pārsniegt %3$s: tas netika augšupielādēts.'; +$_lang["files_upload_stopped"] = 'Augšupielāde apstājās (%1$s). Šie faili (%2$s) netika augšupielādēti: %3$s'; $_lang["files_upload_permissions_error"] = 'Iespējamas piekļuves tiesību problēmas - direktorijam, kurā vēlaties augšupielādēt, jābūt pieejamam tīmekļa serverim rakstīšanai.'; $_lang["files_uploadfile"] = 'Augšupielādēt failu'; $_lang["files_uploadfile_msg"] = 'Atlasiet augšupielādējamo failu:'; diff --git a/core/lang/nl/global.php b/core/lang/nl/global.php index e18363725b..b942b3de7a 100644 --- a/core/lang/nl/global.php +++ b/core/lang/nl/global.php @@ -386,6 +386,13 @@ $_lang["files_upload_error5"] = 'Er was een probleem bij het uploaden.'; $_lang["files_upload_inhibited_msg"] = 'Upload optie niet toegelaten - controleer of uploaden ondersteund wordt en of de Map voor PHP schrijfbaar is.'; $_lang["files_upload_ok"] = 'Het uploaden van het bestand is succesvol verlopen!'; +$_lang["files_upload_summary"] = '%1$s van %2$s bestanden geüpload.'; +$_lang["files_upload_not_received"] = '%1$s bestanden hebben de server niet bereikt: deze accepteert maximaal %2$s bestanden per upload.'; +$_lang["files_upload_zip_hint"] = 'Upload ze opnieuw, of pak ze in één ZIP-bestand in, upload dat en pak het hier uit.'; +$_lang["files_upload_progress"] = 'Bestanden %1$s-%2$s van %3$s worden geüpload...'; +$_lang["files_upload_details"] = 'Geüploade bestanden'; +$_lang["files_upload_file_skipped"] = '"%1$s" is %2$s, maar een bestand mag maximaal %3$s zijn: het is niet geüpload.'; +$_lang["files_upload_stopped"] = 'Het uploaden is gestopt (%1$s). Deze %2$s bestanden zijn niet geüpload: %3$s'; $_lang["files_upload_permissions_error"] = 'Mogelijk rechten probleem - de Map waar u naartoe wilt uploaden heeft schrijfrechten nodig van uw webserver.'; $_lang["files_uploadfile"] = 'Bestand uploaden'; $_lang["files_uploadfile_msg"] = 'Selecteer een bestand om te uploaden:'; diff --git a/core/lang/nn/global.php b/core/lang/nn/global.php index 9053e916b7..6ed226ce61 100644 --- a/core/lang/nn/global.php +++ b/core/lang/nn/global.php @@ -368,6 +368,13 @@ $_lang["files_upload_error5"] = 'En feil oppstod med din opplasting.'; $_lang["files_upload_inhibited_msg"] = 'Opplasting nektet - særg for at opplastinger støttes og at PHP har skriverettigheter til den aktuelle mappen'; $_lang["files_upload_ok"] = 'Filen ble laster opp korrekt!'; +$_lang["files_upload_summary"] = 'Lastet opp %1$s av %2$s filer.'; +$_lang["files_upload_not_received"] = '%1$s filer nådde ikke serveren: den tar imot høyst %2$s filer i én opplasting.'; +$_lang["files_upload_zip_hint"] = 'Last dem opp på nytt, eller pakk dem i én ZIP-fil, last den opp og pakk den ut her.'; +$_lang["files_upload_progress"] = 'Laster opp filer %1$s-%2$s av %3$s...'; +$_lang["files_upload_details"] = 'Opplastede filer'; +$_lang["files_upload_file_skipped"] = '"%1$s" er %2$s, men en fil kan være høyst %3$s: den ble ikke lastet opp.'; +$_lang["files_upload_stopped"] = 'Opplastingen stoppet (%1$s). Disse %2$s filene ble ikke lastet opp: %3$s'; $_lang["files_upload_permissions_error"] = 'Possible permission problems - the directory you want to upload to needs to be writable by your webserver.'; $_lang["files_uploadfile"] = 'Last opp fil'; $_lang["files_uploadfile_msg"] = 'Velg en fil å laste opp:'; diff --git a/core/lang/pl/global.php b/core/lang/pl/global.php index ca554bb906..a5d6ccaf64 100644 --- a/core/lang/pl/global.php +++ b/core/lang/pl/global.php @@ -387,6 +387,13 @@ $_lang["files_upload_error5"] = 'Wystąpił problem w trakcie wgrywania pliku.'; $_lang["files_upload_inhibited_msg"] = 'Funkcja wgrywania wstrzymana - upewnij się, że wgrywanie plików jest obsługiwane i katalog jest zapisywalny dla PHP.
'; $_lang["files_upload_ok"] = 'Plik wgrany pomyślnie!'; +$_lang["files_upload_summary"] = 'Wgrano %1$s z %2$s plików.'; +$_lang["files_upload_not_received"] = '%1$s plików nie dotarło na serwer: przyjmuje on maksymalnie %2$s plików w jednym wgrywaniu.'; +$_lang["files_upload_zip_hint"] = 'Wgraj je ponownie albo spakuj je do jednego pliku ZIP, wgraj go i rozpakuj tutaj.'; +$_lang["files_upload_progress"] = 'Wgrywanie plików %1$s-%2$s z %3$s...'; +$_lang["files_upload_details"] = 'Wgrane pliki'; +$_lang["files_upload_file_skipped"] = '„%1$s” ma %2$s, a plik może mieć najwyżej %3$s: nie został wgrany.'; +$_lang["files_upload_stopped"] = 'Wgrywanie zostało przerwane (%1$s). Te pliki (%2$s) nie zostały wgrane: %3$s'; $_lang["files_upload_permissions_error"] = 'Prawdopodobnie wystapiły problemy związane z prawami dostępu - folder do którego chcesz wgrać pliki musi być dostępny dla zapisu przez serwer.'; $_lang["files_uploadfile"] = 'Wgraj plik'; $_lang["files_uploadfile_msg"] = 'Wybierz plik, który chcesz wgrać:'; diff --git a/core/lang/pt/global.php b/core/lang/pt/global.php index d9f1c4d908..ae1ce8f983 100644 --- a/core/lang/pt/global.php +++ b/core/lang/pt/global.php @@ -368,6 +368,13 @@ $_lang["files_upload_error5"] = 'Ocorreu um problema com o envio (upload).'; $_lang["files_upload_inhibited_msg"] = 'Proibido enviar (upload) ficheiros - Certifique-se de que os uploads são suportados e de que o directório tem permissão de escrita por PHP.
'; $_lang["files_upload_ok"] = 'Ficheiro enviado com sucesso!'; +$_lang["files_upload_summary"] = 'Enviados %1$s de %2$s ficheiros.'; +$_lang["files_upload_not_received"] = '%1$s ficheiros não chegaram ao servidor: este aceita no máximo %2$s ficheiros por envio.'; +$_lang["files_upload_zip_hint"] = 'Envie-os novamente, ou junte-os num único ficheiro ZIP, envie-o e descompacte-o aqui.'; +$_lang["files_upload_progress"] = 'A enviar ficheiros %1$s-%2$s de %3$s...'; +$_lang["files_upload_details"] = 'Ficheiros enviados'; +$_lang["files_upload_file_skipped"] = '"%1$s" tem %2$s, mas um ficheiro pode ter no máximo %3$s: não foi enviado.'; +$_lang["files_upload_stopped"] = 'O envio parou (%1$s). Estes %2$s ficheiros não foram enviados: %3$s'; $_lang["files_upload_permissions_error"] = 'Possible permission problems - the directory you want to upload to needs to be writable by your webserver.'; $_lang["files_uploadfile"] = 'Enviar ficheiro (upload)'; $_lang["files_uploadfile_msg"] = 'Seleccione um ficheiro a enviar (upload):'; diff --git a/core/lang/ro/global.php b/core/lang/ro/global.php index 80db14e09f..b56e35a7f8 100644 --- a/core/lang/ro/global.php +++ b/core/lang/ro/global.php @@ -398,6 +398,13 @@ $_lang["files_upload_error5"] = 'A apărut o problemă la încărcare.'; $_lang["files_upload_inhibited_msg"] = 'Funcția de încărcare este dezactivată - asigurați-vă că încărcările sunt acceptate și că PHP are drept de scriere în director.'; $_lang["files_upload_ok"] = 'Fișierul a fost încărcat cu succes!'; +$_lang["files_upload_summary"] = 'Au fost încărcate %1$s din %2$s fișiere.'; +$_lang["files_upload_not_received"] = '%1$s fișiere nu au ajuns la server: acesta acceptă cel mult %2$s fișiere la o încărcare.'; +$_lang["files_upload_zip_hint"] = 'Încărcați-le din nou sau arhivați-le într-un singur fișier ZIP, încărcați-l și dezarhivați-l aici.'; +$_lang["files_upload_progress"] = 'Se încarcă fișierele %1$s-%2$s din %3$s...'; +$_lang["files_upload_details"] = 'Fișiere încărcate'; +$_lang["files_upload_file_skipped"] = '„%1$s” are %2$s, dar un fișier poate avea cel mult %3$s: nu a fost încărcat.'; +$_lang["files_upload_stopped"] = 'Încărcarea s-a oprit (%1$s). Aceste %2$s fișiere nu au fost încărcate: %3$s'; $_lang["files_upload_permissions_error"] = 'Posibile probleme de permisiuni - directorul în care doriți să încărcați trebuie să permită scrierea de către serverul web.'; $_lang["files_uploadfile"] = 'Încarcă fișier'; $_lang["files_uploadfile_msg"] = 'Selectați un fișier de încărcat:'; diff --git a/core/lang/sk/global.php b/core/lang/sk/global.php index c396ba6e9f..1f37abd13d 100644 --- a/core/lang/sk/global.php +++ b/core/lang/sk/global.php @@ -394,6 +394,13 @@ $_lang["files_upload_error5"] = 'Vyskytli sa problémy s nahrávaním.'; $_lang["files_upload_inhibited_msg"] = 'Funkcia nahrávania súborov na server nie je dostupná - uistite sa, že do priečinka môže PHP zapisovať.'; $_lang["files_upload_ok"] = 'Súbor bol úspešne nahraný'; +$_lang["files_upload_summary"] = 'Nahraných %1$s z %2$s súborov.'; +$_lang["files_upload_not_received"] = 'Na server nedorazilo %1$s súborov: pri jednom nahrávaní prijme najviac %2$s súborov.'; +$_lang["files_upload_zip_hint"] = 'Nahrajte ich znova alebo ich zabaľte do jedného súboru ZIP, nahrajte ho a rozbaľte tu.'; +$_lang["files_upload_progress"] = 'Nahrávajú sa súbory %1$s-%2$s z %3$s...'; +$_lang["files_upload_details"] = 'Nahrané súbory'; +$_lang["files_upload_file_skipped"] = 'Súbor „%1$s“ má %2$s, ale súbor môže mať najviac %3$s: nebol nahraný.'; +$_lang["files_upload_stopped"] = 'Nahrávanie sa zastavilo (%1$s). Tieto súbory (%2$s) neboli nahrané: %3$s'; $_lang["files_upload_permissions_error"] = 'Pravdepodobne chyba prístupových práv - priečinok, do ktorého chcete nahrávať, nie je na vašom serveri zapisovateľný.'; $_lang["files_uploadfile"] = 'Nahrať súbor na server'; $_lang["files_uploadfile_msg"] = 'Vyberte súbory na nahranie na server:'; diff --git a/core/lang/sv/global.php b/core/lang/sv/global.php index 731639f975..1679cb88d2 100644 --- a/core/lang/sv/global.php +++ b/core/lang/sv/global.php @@ -369,6 +369,13 @@ $_lang["files_upload_error5"] = 'Ett fel inträffade vid din uppladdning.'; $_lang["files_upload_inhibited_msg"] = 'Uppladdning nekad - se till att uppladdningar stöds och att PHP har skrivrättigheter i den aktuella katalogen.'; $_lang["files_upload_ok"] = 'Filen laddades upp!'; +$_lang["files_upload_summary"] = 'Laddade upp %1$s av %2$s filer.'; +$_lang["files_upload_not_received"] = '%1$s filer nådde inte servern: den tar emot högst %2$s filer per uppladdning.'; +$_lang["files_upload_zip_hint"] = 'Ladda upp dem igen, eller packa dem i en ZIP-fil, ladda upp den och packa upp den här.'; +$_lang["files_upload_progress"] = 'Laddar upp filerna %1$s-%2$s av %3$s...'; +$_lang["files_upload_details"] = 'Uppladdade filer'; +$_lang["files_upload_file_skipped"] = '"%1$s" är %2$s, men en fil får vara högst %3$s: den laddades inte upp.'; +$_lang["files_upload_stopped"] = 'Uppladdningen avbröts (%1$s). Dessa %2$s filer laddades inte upp: %3$s'; $_lang["files_upload_permissions_error"] = 'Möjligt behörighetsproblem - den katalog som du vill ladda upp till behöver vara skrivbar för din webbserver.'; $_lang["files_uploadfile"] = 'Ladda upp fil'; $_lang["files_uploadfile_msg"] = 'Välj en fil att ladda upp:'; diff --git a/core/lang/tg/global.php b/core/lang/tg/global.php index 13538ea700..c8515fb69b 100644 --- a/core/lang/tg/global.php +++ b/core/lang/tg/global.php @@ -398,6 +398,13 @@ $_lang["files_upload_error5"] = 'Ҳангоми боргузорӣ мушкил рух дод.'; $_lang["files_upload_inhibited_msg"] = 'Боргузорӣ маҳдуд шудааст — дастгирии боргузорӣ ва иҷозати навиштани PHP ба ҷузвдонро санҷед.'; $_lang["files_upload_ok"] = 'Файл муваффақ боргузорӣ шуд!'; +$_lang["files_upload_summary"] = 'Аз %2$s файл %1$s боргузорӣ шуд.'; +$_lang["files_upload_not_received"] = '%1$s файл ба сервер нарасид: сервер дар як боргузорӣ на бештар аз %2$s файл қабул мекунад.'; +$_lang["files_upload_zip_hint"] = 'Онҳоро аз нав боргузорӣ кунед ё ба як файли ZIP ҷамъ карда, онро боргузорӣ кунед ва дар ин ҷо кушоед.'; +$_lang["files_upload_progress"] = 'Боргузории файлҳои %1$s-%2$s аз %3$s...'; +$_lang["files_upload_details"] = 'Файлҳои боргузоришуда'; +$_lang["files_upload_file_skipped"] = 'Ҳаҷми «%1$s» %2$s аст, аммо ҳаҷми файл набояд аз %3$s зиёд бошад: он боргузорӣ нашуд.'; +$_lang["files_upload_stopped"] = 'Боргузорӣ қатъ шуд (%1$s). Ин %2$s файл боргузорӣ нашуданд: %3$s'; $_lang["files_upload_permissions_error"] = 'Эҳтимол мушкили иҷозатҳо вуҷуд дорад — вебсервер бояд ба ҷузвдони боргузорӣ навишта тавонад.'; $_lang["files_uploadfile"] = 'Боргузории файл'; $_lang["files_uploadfile_msg"] = 'Файлеро барои боргузорӣ интихоб кунед:'; diff --git a/core/lang/tk/global.php b/core/lang/tk/global.php index f5215e1ee0..2d8d8f020e 100644 --- a/core/lang/tk/global.php +++ b/core/lang/tk/global.php @@ -398,6 +398,13 @@ $_lang["files_upload_error5"] = 'Ýüklemegiňizde näsazlyk ýüze çykdy.'; $_lang["files_upload_inhibited_msg"] = 'Ýükleme funksiýasy öçürilen - ýüklemeleriň goldanylýandygyna we PHP üçin bukja ýazyp bolýandygyna göz ýetiriň.'; $_lang["files_upload_ok"] = 'Faýl üstünlikli ýüklendi!'; +$_lang["files_upload_summary"] = '%2$s faýlyň %1$s sanysy ýüklendi.'; +$_lang["files_upload_not_received"] = '%1$s faýl serwere ýetmedi: ol bir ýüklemede iň köp %2$s faýl kabul edýär.'; +$_lang["files_upload_zip_hint"] = 'Olary täzeden ýükläň ýa-da bir ZIP faýlyna gaplaň, ony ýükläň we şu ýerde açyň.'; +$_lang["files_upload_progress"] = '%3$s faýlyň %1$s-%2$s aralygy ýüklenýär...'; +$_lang["files_upload_details"] = 'Ýüklenen faýllar'; +$_lang["files_upload_file_skipped"] = '"%1$s" faýlynyň ölçegi %2$s, emma faýl iň köp %3$s bolup biler: ol ýüklenmedi.'; +$_lang["files_upload_stopped"] = 'Ýükleme togtady (%1$s). Bu %2$s faýl ýüklenmedi: %3$s'; $_lang["files_upload_permissions_error"] = 'Rugsat bilen baglanyşykly näsazlyk bolup biler - ýüklemek isleýän bukjaňyza web serweriňiz ýazyp bilmeli.'; $_lang["files_uploadfile"] = 'Faýl ýükle'; $_lang["files_uploadfile_msg"] = 'Ýüklemek üçin faýl saýlaň:'; diff --git a/core/lang/uk/global.php b/core/lang/uk/global.php index afeb215f65..44a327419c 100644 --- a/core/lang/uk/global.php +++ b/core/lang/uk/global.php @@ -399,6 +399,13 @@ $_lang["files_upload_error5"] = 'Виникли проблеми із завантаженням.'; $_lang["files_upload_inhibited_msg"] = 'Функція завантаження файлів на сервер файлів недоступна - переконайтеся, що папка доступна PHP для запису.'; $_lang["files_upload_ok"] = 'Файл успішно завантажений'; +$_lang["files_upload_summary"] = 'Завантажено %1$s з %2$s файлів.'; +$_lang["files_upload_not_received"] = '%1$s файлів не дійшли до сервера: він приймає не більше %2$s файлів за одне завантаження.'; +$_lang["files_upload_zip_hint"] = 'Завантажте їх ще раз або запакуйте в один ZIP-файл, завантажте його та розпакуйте тут.'; +$_lang["files_upload_progress"] = 'Завантаження файлів %1$s-%2$s з %3$s...'; +$_lang["files_upload_details"] = 'Завантажені файли'; +$_lang["files_upload_file_skipped"] = '«%1$s» має розмір %2$s, але файл може бути не більше %3$s: його не завантажено.'; +$_lang["files_upload_stopped"] = 'Завантаження зупинилося (%1$s). Ці файли (%2$s) не завантажено: %3$s'; $_lang["files_upload_permissions_error"] = 'Можливо помилка прав доступу - папка, в яку ви хочете завантажити дані, недоступна для запису на вашому сервері.'; $_lang["files_uploadfile"] = 'Завантажити файл на сервер'; $_lang["files_uploadfile_msg"] = 'Оберіть файли для завантаження на сервер:'; diff --git a/core/lang/uz/global.php b/core/lang/uz/global.php index d30d27d2ef..5ac0de8639 100644 --- a/core/lang/uz/global.php +++ b/core/lang/uz/global.php @@ -398,6 +398,13 @@ $_lang["files_upload_error5"] = 'Yuklashda muammo yuz berdi.'; $_lang["files_upload_inhibited_msg"] = 'Yuklash funksiyasi o‘chirilgan - yuklash qo‘llab-quvvatlanishini va PHP katalogga yoza olishini tekshiring.'; $_lang["files_upload_ok"] = 'Fayl muvaffaqiyatli yuklandi!'; +$_lang["files_upload_summary"] = '%2$s ta fayldan %1$s tasi yuklandi.'; +$_lang["files_upload_not_received"] = '%1$s ta fayl serverga yetib bormadi: server bir yuklashda ko‘pi bilan %2$s ta fayl qabul qiladi.'; +$_lang["files_upload_zip_hint"] = 'Ularni qayta yuklang yoki bitta ZIP faylga joylab, uni yuklang va shu yerda oching.'; +$_lang["files_upload_progress"] = '%3$s ta fayldan %1$s-%2$s yuklanmoqda...'; +$_lang["files_upload_details"] = 'Yuklangan fayllar'; +$_lang["files_upload_file_skipped"] = '"%1$s" hajmi %2$s, lekin fayl hajmi ko‘pi bilan %3$s bo‘lishi mumkin: u yuklanmadi.'; +$_lang["files_upload_stopped"] = 'Yuklash to‘xtadi (%1$s). Ushbu %2$s ta fayl yuklanmadi: %3$s'; $_lang["files_upload_permissions_error"] = 'Ruxsatlar bilan bog‘liq muammo bo‘lishi mumkin - fayl yuklanadigan katalog veb-serveringiz uchun yozishga ochiq bo‘lishi kerak.'; $_lang["files_uploadfile"] = 'Fayl yuklash'; $_lang["files_uploadfile_msg"] = 'Yuklash uchun faylni tanlang:'; diff --git a/core/lang/zh/global.php b/core/lang/zh/global.php index 5e307b97c5..5dd257d13d 100644 --- a/core/lang/zh/global.php +++ b/core/lang/zh/global.php @@ -369,6 +369,13 @@ $_lang["files_upload_error5"] = '上传发生了问题!'; $_lang["files_upload_inhibited_msg"] = '传输性能被评比 - 请确定服务器是否可以上传或文件目录是否可写
'; $_lang["files_upload_ok"] = '文件上传成功!'; +$_lang["files_upload_summary"] = '已上传 %1$s / %2$s 个文件。'; +$_lang["files_upload_not_received"] = '有 %1$s 个文件未到达服务器:服务器每次上传最多接受 %2$s 个文件。'; +$_lang["files_upload_zip_hint"] = '请重新上传,或将它们打包成一个 ZIP 文件,上传后在此处解压。'; +$_lang["files_upload_progress"] = '正在上传第 %1$s-%2$s 个文件,共 %3$s 个...'; +$_lang["files_upload_details"] = '已上传的文件'; +$_lang["files_upload_file_skipped"] = '“%1$s”大小为 %2$s,但单个文件最大为 %3$s:未上传。'; +$_lang["files_upload_stopped"] = '上传已中止(%1$s)。以下 %2$s 个文件未上传:%3$s'; $_lang["files_upload_permissions_error"] = 'Possible permission problems - the directory you want to upload to needs to be writable by your webserver.'; $_lang["files_uploadfile"] = '上传文件'; $_lang["files_uploadfile_msg"] = '选择一上传的文件:'; diff --git a/core/src/Controllers/Frame.php b/core/src/Controllers/Frame.php index 73f7d0eaed..2243ca2c03 100644 --- a/core/src/Controllers/Frame.php +++ b/core/src/Controllers/Frame.php @@ -197,20 +197,22 @@ protected function registerCss(): string $themeDir = $this->managerTheme->getThemeDir(); if (file_exists($themeDir . 'CSSMinify.php')) { - if (!file_exists($themeDir . 'css/styles.min.css') && is_writable($themeDir . 'css')) { - $cssFiles = include_once $themeDir . 'CSSMinify.php'; - if (is_array($cssFiles) && count($cssFiles)) { + $minCssPath = $themeDir . 'css/styles.min.css'; + $cssFiles = include $themeDir . 'CSSMinify.php'; + $cssFiles = is_array($cssFiles) ? $cssFiles : []; + if (\EvolutionCMS\Support\Formatter\CSSMinify::isStale($minCssPath, $cssFiles) && is_writable($themeDir . 'css')) { + if (count($cssFiles)) { $minifier = new \EvolutionCMS\Support\Formatter\CSSMinify(); foreach ($cssFiles as $item) { $minifier->addFile($item); } - file_put_contents($themeDir . 'css/styles.min.css', $minifier->minify()); - + file_put_contents($minCssPath, $minifier->minify()); + clearstatcache(true, $minCssPath); } } - if (file_exists($themeDir . 'css/styles.min.css')) { - $this->parameters['css'] = $this->managerTheme->getThemeDir(false) . 'css/styles.min.css?v=' . evo()->getVersionData('version') . '.' . EVO_INSTALL_TIME; + if (file_exists($minCssPath)) { + $this->parameters['css'] = $this->managerTheme->getThemeDir(false) . 'css/styles.min.css?v=' . evo()->getVersionData('version') . '.' . EVO_INSTALL_TIME . '.' . filemtime($minCssPath); } } diff --git a/core/src/Core.php b/core/src/Core.php index 5067fe1475..f32768a5ca 100644 --- a/core/src/Core.php +++ b/core/src/Core.php @@ -2270,6 +2270,8 @@ public function evalPlugin($pluginCode, $params) if (is_array($params)) { extract($params, EXTR_SKIP); } + // error_get_last() below must only see what the plugin raised, not an earlier (even @-suppressed) warning. + error_clear_last(); ob_start(); try { eval($pluginCode); @@ -2344,6 +2346,8 @@ public function evalSnippet($phpcode, $params) if (is_array($params)) { extract($params, EXTR_SKIP); } + // error_get_last() below must only see what the snippet raised, not an earlier (even @-suppressed) warning. + error_clear_last(); ob_start(); if (is_scalar($phpcode) && Str::contains($phpcode, ';')) { if (substr($phpcode, 0, 5) === 'getThemeDir() . $minCssName; - if (!file_exists($minCssPath) && is_writable($this->getThemeDir() . 'css')) { - $files = $this->getCssFiles(); + $sources = $this->getCssFiles(); + if (\EvolutionCMS\Support\Formatter\CSSMinify::isStale($minCssPath, $sources) + && is_writable($this->getThemeDir() . 'css') + ) { + $files = $sources; if (!empty($files)) { $evtOut = $this->getCore()->invokeEvent('OnBeforeMinifyCss', [ 'files' => $files, @@ -929,13 +932,16 @@ public function css() $minifier = new \EvolutionCMS\Support\Formatter\CSSMinify($files); $css = $minifier->minify(); file_put_contents($minCssPath, $css); + clearstatcache(true, $minCssPath); } } + $revision = ''; if (file_exists($minCssPath)) { $css = $this->getThemeUrl() . $minCssName; + $revision = '.' . filemtime($minCssPath); } - return $css . '?v=' . $this->getCore()->getVersionData('version'); + return $css . '?v=' . $this->getCore()->getVersionData('version') . $revision; } public function getMainFrameHeaderHTMLBlock(): string diff --git a/core/src/Support/Formatter/CSSMinify.php b/core/src/Support/Formatter/CSSMinify.php index 973c8ce452..3a311326a6 100644 --- a/core/src/Support/Formatter/CSSMinify.php +++ b/core/src/Support/Formatter/CSSMinify.php @@ -11,6 +11,26 @@ public function __construct($cssFilesPath = []) } } + /** + * True when the bundle is missing or older than one of its sources, so a changed + * theme stylesheet reaches sites whose bundle was built before an update. + */ + public static function isStale(string $target, array $files): bool + { + if (!is_file($target)) { + return true; + } + + $built = filemtime($target); + foreach ($files as $file) { + if (is_string($file) && is_file($file) && filemtime($file) > $built) { + return true; + } + } + + return false; + } + public function addFile($cssFilePath) { $this->cssPath[] = $cssFilePath; diff --git a/core/tests/Unit/Manager/FileManagerUploadReportTest.php b/core/tests/Unit/Manager/FileManagerUploadReportTest.php new file mode 100644 index 0000000000..9d75f4ce9a --- /dev/null +++ b/core/tests/Unit/Manager/FileManagerUploadReportTest.php @@ -0,0 +1,136 @@ + EVO_BASE_PATH, "use_udperms" => 0, "upload_files" => "txt", "upload_images" => "png", "upload_media" => ""][$key] ?? $default;' + . ' }' + . ' public function hasPermission($permission) { return $permission === "file_manager"; }' + . ' public function invokeEvent(...$args) { return []; }' + . ' public function logEvent($id, $type, $message, $source) {}' + . ' }' + . ' $GLOBALS["fmStub"] = new FmUploadStubEvo();' + . ' function evolutionCMS() { return $GLOBALS["fmStub"]; }' + . ' function logFileChange($type, $filename) {}' + . ' function fileManagerAclKey($relativePath) { return null; }' + . ' $_lang = ["files_access_denied" => "ACCESS_DENIED", "files_file_type" => "Type: ", "files_upload_ok" => "UPLOADED",' + . ' "files_upload_error" => "ERROR", "files_upload_error0" => "ATTACK?", "files_upload_error1" => "TOO_BIG", "files_upload_error3" => "PARTIAL",' + . ' "files_upload_summary" => "Uploaded %1\$s of %2\$s files.", "files_upload_not_received" => "%1\$s files did not reach the server (at most %2\$s).",' + . ' "files_upload_zip_hint" => "Use a ZIP."];' + . ' $_POST = ' . var_export(['path' => 'assets/files'] + $post, true) . ';' + . ' $_REQUEST = $_POST;' + . ' $_FILES = ["userfile" => ' . var_export($files, true) . '];' + . ' require ' . var_export($core . '/functions/actions/files.php', true) . ';' + . ' echo fileupload();'; + + $tmp = tempnam(sys_get_temp_dir(), 'evo-fm-upload-'); + file_put_contents($tmp, $script); + try { + return evoRunPhp($tmp); + } finally { + unlink($tmp); + } +} + +function uploadEntries(array $entries): array +{ + $files = ['name' => [], 'type' => [], 'tmp_name' => [], 'error' => [], 'size' => []]; + foreach ($entries as [$name, $error]) { + $files['name'][] = $name; + $files['type'][] = $error === UPLOAD_ERR_OK ? 'text/plain' : ''; + $files['tmp_name'][] = ''; + $files['error'][] = $error; + $files['size'][] = 0; + } + + return $files; +} + +beforeEach(function () { + $base = str_replace('\\', '/', sys_get_temp_dir()) . '/evo-fm-upload-' . bin2hex(random_bytes(6)); + mkdir($base . '/site/assets/files', 0777, true); + $this->base = str_replace('\\', '/', realpath($base)); + $this->site = $this->base . '/site'; +}); + +afterEach(function () { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->base, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($this->base); +}); + +it('reports a file PHP refused instead of skipping it', function () { + $output = runFileManagerUpload($this->site, uploadEntries([['big.txt', UPLOAD_ERR_INI_SIZE], ['half.txt', UPLOAD_ERR_PARTIAL]])); + + expect($output)->toContain('big.txt')->toContain('TOO_BIG') + ->toContain('half.txt')->toContain('PARTIAL') + ->toContain('Uploaded 0 of 2 files.') + ->toContain('data-uploaded="0" data-total="2"') + // one block per file, marked, so the page can put the failed ones first + ->and(substr_count($output, 'class="upload-file upload-failed"'))->toBe(2); +}); + +it('ignores an empty file field', function () { + $output = runFileManagerUpload($this->site, uploadEntries([['', UPLOAD_ERR_NO_FILE], ['big.txt', UPLOAD_ERR_INI_SIZE]])); + + expect($output)->toContain('Uploaded 0 of 1 files.')->not->toContain('You must select'); +}); + +it('says how many of the files sent never reached PHP, and what to do about it', function () { + $output = runFileManagerUpload( + $this->site, + uploadEntries([['a.txt', UPLOAD_ERR_INI_SIZE], ['b.txt', UPLOAD_ERR_INI_SIZE]]), + ['upload_selected' => '30'] + ); + + expect($output)->toContain('Uploaded 0 of 30 files.') + ->toContain('28 files did not reach the server (at most ' . (int) ini_get('max_file_uploads') . ').') + ->toContain('Use a ZIP.') + ->toContain('class="upload-dropped"'); +}); + +it('escapes the lexicon text of the summary', function () { + $summary = (function () { + $core = dirname(__DIR__, 3); + $script = ' \'%1$s/%2$s\'], 3, 3, 3, 20);'; + $tmp = tempnam(sys_get_temp_dir(), 'evo-fm-summary-'); + file_put_contents($tmp, $script); + try { + return evoRunPhp($tmp); + } finally { + unlink($tmp); + } + })(); + + expect($summary)->toContain('<b>3/3</b>')->toContain('class="success"'); +}); + +it('defines the upload report messages in the English lexicon', function () { + $_lang = []; + include dirname(__DIR__, 3) . '/lang/en/global.php'; + + foreach (['files_upload_summary', 'files_upload_not_received', 'files_upload_zip_hint', 'files_upload_progress', + 'files_upload_file_skipped', 'files_upload_stopped'] as $key) { + expect($_lang)->toHaveKey($key); + } +}); diff --git a/core/tests/Unit/Manager/FrameMainTargetHelperIncludeTest.php b/core/tests/Unit/Manager/FrameMainTargetHelperIncludeTest.php index f7b69381c4..955d4f9b45 100644 --- a/core/tests/Unit/Manager/FrameMainTargetHelperIncludeTest.php +++ b/core/tests/Unit/Manager/FrameMainTargetHelperIncludeTest.php @@ -14,3 +14,15 @@ expect(strpos($template, $mainTargetHelper))->toBeLessThan(strpos($template, $tabHelper)); expect(strpos($template, $tabHelper))->toBeLessThan(strpos($template, $evoScript)); }); + +test('manager frame loads the tree drop helper that evo js calls on drop', function () { + $template = file_get_contents(dirname(__DIR__, 4) . '/manager/views/frame/1.blade.php'); + $evoJs = file_get_contents(dirname(__DIR__, 4) . '/manager/media/style/default/js/evo.js'); + + $dropHelper = "media/script/tree-drop-helper.js?v={{evo()->getVersionData('version')}}"; + $evoScript = "@revision(MGR_DIR . '/' . ManagerTheme::getThemeDir(false) . 'js/evo.js')"; + + expect($evoJs)->toContain('modxTreeDropHelper.'); + expect($template)->toContain($dropHelper); + expect(strpos($template, $dropHelper))->toBeLessThan(strpos($template, $evoScript)); +}); diff --git a/core/tests/Unit/Manager/SessionKeepAliveIntervalTest.php b/core/tests/Unit/Manager/SessionKeepAliveIntervalTest.php new file mode 100644 index 0000000000..961ccc41e6 --- /dev/null +++ b/core/tests/Unit/Manager/SessionKeepAliveIntervalTest.php @@ -0,0 +1,8 @@ +toContain('w.setInterval(this.keepMeAlive, 1000 * 60 * this.config.session_timeout / 2);'); +}); diff --git a/core/tests/Unit/Security/UploadLimitsTest.php b/core/tests/Unit/Security/UploadLimitsTest.php index 619601bc9b..999b81baa6 100644 --- a/core/tests/Unit/Security/UploadLimitsTest.php +++ b/core/tests/Unit/Security/UploadLimitsTest.php @@ -106,14 +106,16 @@ ->and($guard)->toBeLessThan(strpos($index, "header('Expires:")); }); -it('stops an oversized selection in the file manager before it is sent', function () { +it('sends a file manager selection in batches PHP takes whole, never in one plain form post', function () { $source = (string) file_get_contents(dirname(__DIR__, 4) . '/manager/actions/files.dynamic.php'); - expect($source)->toContain('onsubmit="return fileManagerUploadFits(this);"') - ->and($source)->toContain('onchange="if (fileManagerUploadFits(document.upload)) document.upload.submit();"') - ->and($source)->toContain("js_json(\$_lang['upload_file_too_large'])") - ->and($source)->toContain("js_json(\$_lang['upload_files_too_large'])") - ->and($source)->not->toContain('onchange="document.upload.submit();"'); + // PHP drops the files past max_file_uploads and a whole body past post_max_size without a word + expect($source)->toContain('onsubmit="fileManagerUpload(this); return false;"') + ->and($source)->toContain('onchange="fileManagerUpload(document.upload);"') + ->and($source)->toContain("maxFiles: ") + ->and($source)->toContain('postLimit: ') + ->and($source)->toContain(' " . $_lang['files_upload_inhibited_msg'] . "

"; diff --git a/manager/media/browser/mcpuk/js/browser/files.js b/manager/media/browser/mcpuk/js/browser/files.js index 231e15eccf..162ed39387 100755 --- a/manager/media/browser/mcpuk/js/browser/files.js +++ b/manager/media/browser/mcpuk/js/browser/files.js @@ -223,6 +223,7 @@ browser.deleteButtonEnabled = function(canDelete, files, canDeleteDirs) { browser.clearSelection = function() { $('.file').removeClass('selected'); this.lastSelectedFile = null; + this.treeSelection = null; this.statusDir(); this.updateDeleteButton(); this.syncTreeSelection(); diff --git a/manager/media/browser/mcpuk/js/browser/folders.js b/manager/media/browser/mcpuk/js/browser/folders.js index 707e397557..d8d3dd4321 100755 --- a/manager/media/browser/mcpuk/js/browser/folders.js +++ b/manager/media/browser/mcpuk/js/browser/folders.js @@ -15,13 +15,15 @@ browser.initFolders = function() { browser.hideDialog(); }); $('div.folder > a').unbind(); - $('div.folder > a').bind('click', function() { + $('div.folder > a').bind('click', function(e) { browser.hideDialog(); - browser.openTreeFolder($(this)); + browser.handleTreeClick($(this), $(e.target).hasClass('brace')); return false; }); - $('div.folder > a').dblclick(function() { - browser.toggleTreeFolder($(this)); + $('div.folder > a').dblclick(function(e) { + // each single click on the arrow has toggled the branch already + if (!$(e.target).hasClass('brace')) + browser.toggleTreeFolder($(this)); return false; }); $('div.folder > a > span.brace').unbind(); @@ -52,6 +54,27 @@ browser.initFolders = function() { browser.openTreeFolder = function(dir) { this.changeDir(dir); + this.selectTreeFolder(dir); +}; + +// a click in the tree selects that folder alone: nothing stays selected in the file list or elsewhere in the tree +browser.selectTreeFolder = function(dir) { + $('.file').removeClass('selected'); + this.lastSelectedFile = null; + this.treeSelection = dir.data('path'); + this.statusDir(); + this.updateDeleteButton(); + this.syncTreeSelection(); + this.updateMobileActions(); +}; + +// like the resource tree: the arrow of a folder with subfolders expands or collapses it, the rest of the row opens it +browser.handleTreeClick = function(dir, onArrow) { + var brace = dir.children('.brace'); + if (onArrow && (brace.hasClass('opened') || brace.hasClass('closed'))) + this.toggleTreeFolder(dir); + else + this.openTreeFolder(dir); }; // a folder's node in the tree; empty while the branch holding it is not loaded @@ -63,13 +86,6 @@ browser.subDirPath = function(name) { return this.dir.length ? this.dir + '/' + name : name; }; -// the subfolder of dir that leads to path: ("images", "images/team/2024") gives "team" -browser.subDirOnPath = function(dir, path) { - if ((typeof path != 'string') || (path.indexOf(dir + '/') !== 0)) - return undefined; - return path.substr(dir.length + 1).split('/')[0]; -}; - /** * The tree node of a folder listed in the file list. A tree folder opened by a click is not expanded, * so its branch is loaded here when it never was: every listed folder has a node to open or mark. @@ -100,7 +116,15 @@ browser.revealTreeDir = function(dir) { // the folders selected in the file list are marked in the tree too: both panes show one selection browser.syncTreeSelection = function() { $('div.folder > a > span.folder').removeClass('selected'); - $('#files .file.selected').each(function() { + var listed = $('#files .file.selected'); + // a selection in the file list replaces the folder selected in the tree; without one, that folder stays + if (listed.length) + this.treeSelection = null; + else if (this.treeSelection) { + this.treeDir(this.treeSelection).children('span.folder').addClass('selected'); + return; + } + listed.each(function() { var file = $(this); if (!file.data('isDir')) return; @@ -207,13 +231,10 @@ browser.updateDirSubtreePaths = function(dir, oldPath, newPath) { } }; +// expands or collapses the branch only; the folder shown in the file list stays browser.expandDir = function(dir) { - var path = dir.data('path'); if (dir.children('.brace').hasClass('opened')) { - dir.parent().children('.folders').hide(500, function() { - if (path == browser.dir.substr(0, path.length)) - browser.changeDir(dir); - }); + dir.parent().children('.folders').hide(500); dir.children('.brace').removeClass('opened'); dir.children('.brace').addClass('closed'); } else { @@ -281,7 +302,8 @@ browser.loadTreeBranch = function(dir, animate) { browser.changeDir = function(dir) { if (dir.children('span.folder').hasClass('regular')) { - var previous = this.dir; + // a folder opened from anywhere starts with nothing selected; a tree click selects it afterwards + this.treeSelection = null; this.clearMobileActions(); $('div.folder > a > span.folder').removeClass('current'); $('div.folder > a > span.folder').removeClass('selected'); @@ -303,10 +325,7 @@ browser.changeDir = function(dir) { browser.files = data.files; browser.links = data.links ? data.links : []; browser.dir = dir.data('path'); - // up the tree, the folder it came from is selected in the list (and so in the tree) - browser.orderFiles(function() { - browser.scrollIntoPane('#files', $('#files .file.selected').get(0)); - }, browser.subDirOnPath(browser.dir, previous)); + browser.orderFiles(); browser.dirWritable = data.dirWritable; browser.updateWriteControls(); var title = "KCFinder: /" + browser.dir; diff --git a/manager/media/browser/mcpuk/js/tests/folder-double-click.test.js b/manager/media/browser/mcpuk/js/tests/folder-double-click.test.js index 70013c6053..28ddd840df 100644 --- a/manager/media/browser/mcpuk/js/tests/folder-double-click.test.js +++ b/manager/media/browser/mcpuk/js/tests/folder-double-click.test.js @@ -36,7 +36,7 @@ test('content and list folder clicks select; double-click opens the folder', () assert.equal(opened, 1); }); -test('single-clicking a tree folder opens its contents without toggling the branch', () => { +test('single-clicking a tree folder opens and selects it without toggling the branch', () => { const browser = loadBrowser(); const actions = []; const brace = { @@ -47,10 +47,11 @@ test('single-clicking a tree folder opens its contents without toggling the bran const folder = { children: () => brace }; browser.expandDir = () => actions.push('toggle'); browser.changeDir = () => actions.push('open'); + browser.selectTreeFolder = () => actions.push('select'); browser.openTreeFolder(folder); - assert.deepEqual(actions, ['open']); + assert.deepEqual(actions, ['open', 'select']); }); test('double-clicking a tree folder toggles its branch without changing contents', () => { @@ -64,6 +65,7 @@ test('double-clicking a tree folder toggles its branch without changing contents const folder = { children: () => brace }; browser.expandDir = () => actions.push('toggle'); browser.changeDir = () => actions.push('open'); + browser.selectTreeFolder = () => actions.push('select'); browser.toggleTreeFolder(folder); @@ -80,3 +82,68 @@ test('double-clicking a leaf tree folder has no branch to toggle', () => { assert.equal(toggled, false); }); + +function treeFolder(braceClass) { + return { children: () => ({ hasClass: (name) => name === braceClass }) }; +} + +test('a single click on the arrow of a tree folder only toggles its branch', () => { + for (const state of ['opened', 'closed']) { + const browser = loadBrowser(); + const actions = []; + browser.expandDir = () => actions.push('toggle'); + browser.changeDir = () => actions.push('open'); + browser.selectTreeFolder = () => actions.push('select'); + browser.selectTreeFolder = () => actions.push('select'); + + browser.handleTreeClick(treeFolder(state), true); + + assert.deepEqual(actions, ['toggle'], state); + } +}); + +test('a single click on the name of a tree folder opens it', () => { + const browser = loadBrowser(); + const actions = []; + browser.expandDir = () => actions.push('toggle'); + browser.changeDir = () => actions.push('open'); + browser.selectTreeFolder = () => actions.push('select'); + + browser.handleTreeClick(treeFolder('opened'), false); + + assert.deepEqual(actions, ['open', 'select']); +}); + +test('the arrow area of a folder without subfolders opens it', () => { + const browser = loadBrowser(); + const actions = []; + browser.expandDir = () => actions.push('toggle'); + browser.changeDir = () => actions.push('open'); + browser.selectTreeFolder = () => actions.push('select'); + + browser.handleTreeClick(treeFolder(''), true); + + assert.deepEqual(actions, ['open', 'select']); +}); + +test('collapsing a branch does not change the folder shown in the file list', () => { + const browser = loadBrowser(); + const calls = []; + const brace = { + hasClass: (name) => name === 'opened', + removeClass() { return brace; }, + addClass() { return brace; }, + }; + const folders = { hide: (ms, done) => { calls.push('hide'); if (done) done(); } }; + const dir = { + data: () => 'images', + children: () => brace, + parent: () => ({ children: () => folders }), + }; + browser.dir = 'images/team'; + browser.changeDir = () => calls.push('open'); + + browser.expandDir(dir); + + assert.deepEqual(calls, ['hide']); +}); diff --git a/manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js b/manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js index 1c2979bb88..a40fe8de3f 100644 --- a/manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js +++ b/manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js @@ -133,17 +133,6 @@ function setup() { return { browser, page }; } -test('the subfolder of a folder that leads to a deeper path', () => { - const { browser } = setup(); - - assert.equal(browser.subDirOnPath('images', 'images/team/2024'), 'team'); - assert.equal(browser.subDirOnPath('images', 'images/team'), 'team'); - assert.equal(browser.subDirOnPath('images', 'images'), undefined); - assert.equal(browser.subDirOnPath('images', 'images-old/team'), undefined); - assert.equal(browser.subDirOnPath('images/team', 'images'), undefined); - assert.equal(browser.subDirOnPath('images', undefined), undefined); -}); - test('a listed folder is found in the tree without loading anything when its branch is there', () => { const { browser, page } = setup(); treeNode(subfolders(page.images), 'images/team'); @@ -292,16 +281,86 @@ function openWithChDir(browser, page, target) { return ordered; } -test('going up the tree selects the folder it came from in the file list', () => { +// images > team > 2024, with 2024 open in the file list +function nested() { const { browser, page } = setup(); page.images.a.kids[1].cls.delete('current'); page.images.a.kids[1].cls.add('regular'); + const team = treeNode(subfolders(page.images), 'images/team', { brace: 'opened' }); + const year = treeNode(subfolders(team), 'images/team/2024', { current: true }); browser.dir = 'images/team/2024'; + return { browser, page, team, year }; +} + +function clickTreeFolder(browser, page, node) { + openWithChDir(browser, page, node); + browser.selectTreeFolder(wrap([node.a])); +} + +test('clicking the root of three nested tree folders selects it alone, not the folder it came from', () => { + const { browser, page } = nested(); + const ordered = []; + page.$.ajax = (options) => options.success({ files: [], links: [], dirWritable: true }); + Object.assign(browser, { + loadingHtml: () => '', check4errors: () => false, updateWriteControls() {}, opener: {}, baseGetData: (act) => act, + orderFiles: (callBack, selected) => ordered.push(selected), + }); - const ordered = openWithChDir(browser, page, page.images); + browser.openTreeFolder(wrap([page.images.a])); - assert.deepEqual(ordered, ['team']); assert.equal(browser.dir, 'images'); + assert.deepEqual(ordered, [undefined]); + assert.deepEqual(page.selectedInTree(), ['images']); +}); + +test('clicking the open tree folder deselects everything in the file list and selects it', () => { + const { browser, page, year } = nested(); + page.card('photo.png', false, true); + page.card('old', true, true); + + browser.openTreeFolder(wrap([year.a])); + + assert.equal(page.$('#files .file.selected').length, 0); + assert.deepEqual(page.selectedInTree(), ['images/team/2024']); +}); + +test('the folder selected in the tree stays selected while nothing is picked in the file list', () => { + const { browser, page, team } = nested(); + clickTreeFolder(browser, page, team); + + browser.syncTreeSelection(); + + assert.deepEqual(page.selectedInTree(), ['images/team']); +}); + +test('selecting in the file list replaces the folder selected in the tree', () => { + const { browser, page, team } = nested(); + clickTreeFolder(browser, page, team); + const card = page.card('photo.png', false); + + browser.selectFolder(wrap([card])); + browser.clearSelection(); + + assert.deepEqual(page.selectedInTree(), []); + assert.equal(browser.treeSelection, null); +}); + +test('clicking empty space in the file list clears the folder selected in the tree', () => { + const { browser, page, team } = nested(); + clickTreeFolder(browser, page, team); + + browser.clearSelection(); + + assert.deepEqual(page.selectedInTree(), []); +}); + +test('a folder opened from the file list does not bring back an earlier tree selection', () => { + const { browser, page, team, year } = nested(); + clickTreeFolder(browser, page, team); + + openWithChDir(browser, page, year); + + assert.deepEqual(page.selectedInTree(), []); }); test('going down or sideways selects nothing in the file list', () => { diff --git a/manager/media/browser/mcpuk/themes/evo/style.css b/manager/media/browser/mcpuk/themes/evo/style.css index 41fffe62b4..c22ba8681f 100644 --- a/manager/media/browser/mcpuk/themes/evo/style.css +++ b/manager/media/browser/mcpuk/themes/evo/style.css @@ -21,6 +21,8 @@ div.folder { white-space: nowrap; } div.folder a { display: inline-block; text-decoration: none; cursor: default; outline: none; color: #000; } span.folder { display: inline-block; font-size: 1.1rem; line-height: 1.2rem; padding: 2px 3px 2px 3px; outline: none; background: no-repeat 3px center; cursor: pointer; border: 1px solid transparent; } span.brace { float: left; width: 1.6rem; height: 1.6rem; padding-left: 0; line-height: 1.9rem; font-size: 0; text-align: center; outline: none; } +span.brace.opened, span.brace.closed { cursor: pointer; border-radius: 50%; } +span.brace.opened:hover, span.brace.closed:hover { background-color: rgba(0, 0, 0, 0.07); } span.current { background-color: #e4f0fa; border-color: #e4f0fa; color: #0e80cb; } span.current:before { display: inline-block; font-family: 'Font Awesome 5 Free'; content: "\f07c"; width: 1.3em; font-size: 1.3rem; line-height: .8em; vertical-align: middle; color: #565656; font-weight:900} span.regular { background-color: #f8f7f6; } diff --git a/manager/media/script/tests/tree-drop-helper.test.js b/manager/media/script/tests/tree-drop-helper.test.js index 1c0bda16a1..2dc32e1639 100644 --- a/manager/media/script/tests/tree-drop-helper.test.js +++ b/manager/media/script/tests/tree-drop-helper.test.js @@ -62,3 +62,59 @@ test('moveNodeIntoFolder falls back to removing the dragged node when no folder assert.equal(draggedNode.parentNode, null); assert.deepEqual(sourceContainer.children, []); }); + +test('every drop position uses the same move drop effect', () => { + assert.equal(helper.DROP_EFFECT, 'move'); +}); + +test('getDropZone splits a row into before, into and after', () => { + assert.equal(helper.getDropZone(0, 21), 'before'); + assert.equal(helper.getDropZone(6, 21), 'before'); + assert.equal(helper.getDropZone(7, 21), 'into'); + assert.equal(helper.getDropZone(10.5, 21), 'into'); + assert.equal(helper.getDropZone(14, 21), 'after'); + assert.equal(helper.getDropZone(21, 21), 'after'); +}); + +test('getDropZone gives a pointer on a boundary to the lower zone', () => { + assert.equal(helper.getDropZone(10, 30), 'into'); + assert.equal(helper.getDropZone(15.1, 15.1 * 1.51), 'after'); +}); + +test('getDropZone splits a row into halves when it takes no drop into it', () => { + assert.equal(helper.getDropZone(0, 20, false), 'before'); + assert.equal(helper.getDropZone(9.9, 20, false), 'before'); + assert.equal(helper.getDropZone(10, 20, false), 'after'); + assert.equal(helper.getDropZone(20, 20, false), 'after'); +}); + +test('getFirstChildNode returns the first resource of an expanded folder', () => { + const placeholder = { nodeType: 1, id: '' }; + const first = { nodeType: 1, id: 'node5' }; + const second = { nodeType: 1, id: 'node6' }; + const anchor = { nextElementSibling: createContainer([placeholder, first, second]) }; + + assert.equal(helper.getFirstChildNode(anchor), first); +}); + +test('getFirstChildNode is null for collapsed, empty and non-folder rows', () => { + const emptyFolder = { nextElementSibling: createContainer([{ nodeType: 1, id: '' }]) }; + + assert.equal(helper.getFirstChildNode({ nextElementSibling: createContainer() }), null); + assert.equal(helper.getFirstChildNode(emptyFolder), null); + assert.equal(helper.getFirstChildNode({ nextElementSibling: null, nextSibling: null }), null); + assert.equal(helper.getFirstChildNode(null), null); +}); + +test('collectMenuIndex lists resource ids in order and skips placeholders', () => { + const children = [ + { nodeType: 1, id: '' }, + { nodeType: 1, id: 'node12' }, + { nodeType: 3, id: 'node99' }, + { nodeType: 1, id: 'node3' }, + { nodeType: 1, id: 'nodeX' } + ]; + + assert.deepEqual(helper.collectMenuIndex(children), ['12', '3']); + assert.deepEqual(helper.collectMenuIndex(null), []); +}); diff --git a/manager/media/script/tests/upload-queue.test.js b/manager/media/script/tests/upload-queue.test.js new file mode 100644 index 0000000000..7063d551a3 --- /dev/null +++ b/manager/media/script/tests/upload-queue.test.js @@ -0,0 +1,82 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const queue = require('../upload-queue'); + +const KB = 1024; +const MB = 1024 * KB; + +test('30 files go up in batches of at most max_file_uploads, in the order chosen', () => { + const sizes = Array.from({ length: 30 }, () => 10 * KB); + + const { batches, skipped } = queue.plan(sizes, 20, 8 * MB, 2 * MB); + + assert.deepEqual(batches.map((b) => b.length), [20, 10]); + assert.deepEqual(batches.flat(), sizes.map((s, i) => i)); + assert.deepEqual(skipped, []); +}); + +test('a batch also stays under post_max_size with room for the form', () => { + const sizes = [3 * MB, 3 * MB, 3 * MB, 1 * MB]; + + const { batches } = queue.plan(sizes, 20, 8 * MB, 0); + + assert.deepEqual(batches, [[0, 1], [2, 3]]); +}); + +test('a file over the file limit, or too large for any request, is left out and named', () => { + const sizes = [1 * MB, 3 * MB, 1 * MB, 9 * MB]; + + assert.deepEqual(queue.plan(sizes, 20, 8 * MB, 2 * MB), { batches: [[0, 2]], skipped: [1, 3] }); + assert.deepEqual(queue.plan(sizes, 20, 8 * MB, 0), { batches: [[0, 1, 2]], skipped: [3] }); +}); + +test('no limits: one batch', () => { + assert.deepEqual(queue.plan([1, 2, 3], 0, 0, 0), { batches: [[0, 1, 2]], skipped: [] }); +}); + +test('each request uses the token the previous answer brought', async () => { + const seen = []; + const send = (batch, token) => { + seen.push([batch, token]); + return Promise.resolve({ report: '

' + batch.join() + '

', uploaded: batch.length, token: token + '+' }); + }; + const progress = []; + + const result = await queue.run([[0, 1], [2]], 't', send, (i, n) => progress.push(i + '/' + n)); + + assert.deepEqual(seen, [[[0, 1], 't'], [[2], 't+']]); + assert.deepEqual(progress, ['0/2', '1/2']); + assert.equal(result.uploaded, 3); + assert.deepEqual(result.reports, ['

0,1

', '

2

']); + assert.deepEqual(result.notSent, []); +}); + +test('a failed request stops the queue and names every file not sent', async () => { + let calls = 0; + const send = (batch, token) => { + calls++; + if (calls === 2) return Promise.reject(new Error('HTTP 500')); + return Promise.resolve({ report: 'ok', uploaded: batch.length, token: 'next' }); + }; + + const result = await queue.run([[0, 1], [2, 3], [4]], 't', send); + + assert.equal(calls, 2); + assert.equal(result.uploaded, 2); + assert.deepEqual(result.notSent, [2, 3, 4]); + assert.equal(result.reason, 'HTTP 500'); +}); + +test('an answer without a token for the next request stops the queue after it', async () => { + const send = (batch) => Promise.resolve({ report: 'ok', uploaded: batch.length, token: '' }); + + const result = await queue.run([[0], [1]], 't', send); + + assert.equal(result.uploaded, 1); + assert.deepEqual(result.notSent, [1]); +}); + +test('messages are formatted and file names escaped', () => { + assert.equal(queue.format('Uploaded %1$s of %2$s files.', [20, 30]), 'Uploaded 20 of 30 files.'); + assert.equal(queue.escapeHtml('&\''), '<img src=x onerror="a">&''); +}); diff --git a/manager/media/script/tree-drop-helper.js b/manager/media/script/tree-drop-helper.js index 94cfef4ed1..f359bd4dbe 100644 --- a/manager/media/script/tree-drop-helper.js +++ b/manager/media/script/tree-drop-helper.js @@ -9,6 +9,31 @@ }(typeof globalThis !== 'undefined' ? globalThis : this, function () { 'use strict'; + // One effect for every allowed position; a drop moves the resource, so no "+" copy cursor. + var DROP_EFFECT = 'move'; + + // A pointer exactly on a boundary takes the lower zone. Without "into" (a deleted target) + // the row splits into halves. + function getDropZone(offsetY, height, canDropInto) { + if (canDropInto === false) { + return offsetY < height / 2 ? 'before' : 'after'; + } + + if (offsetY >= height / 1.51) { + return 'after'; + } + + if (offsetY < height / 3) { + return 'before'; + } + + return 'into'; + } + + function isTreeNode(element) { + return !!element && element.nodeType === 1 && /^node\d+$/.test(element.id || ''); + } + function getFolderDropContainer(targetAnchor) { if (!targetAnchor) { return null; @@ -23,6 +48,33 @@ return container; } + // first child node of an expanded folder; null when collapsed or empty + function getFirstChildNode(targetAnchor) { + var container = getFolderDropContainer(targetAnchor); + var children = container ? container.children || [] : []; + + for (var i = 0; i < children.length; i++) { + if (isTreeNode(children[i])) { + return children[i]; + } + } + + return null; + } + + // resource ids in tree order; skips placeholders such as the "empty folder" row + function collectMenuIndex(children) { + var ids = []; + + for (var i = 0; i < (children ? children.length : 0); i++) { + if (isTreeNode(children[i])) { + ids.push(children[i].id.substr(4)); + } + } + + return ids; + } + function moveNodeIntoFolder(targetAnchor, draggedNode) { var container = getFolderDropContainer(targetAnchor); @@ -46,7 +98,12 @@ } return { + DROP_EFFECT: DROP_EFFECT, + getDropZone: getDropZone, + isTreeNode: isTreeNode, getFolderDropContainer: getFolderDropContainer, + getFirstChildNode: getFirstChildNode, + collectMenuIndex: collectMenuIndex, moveNodeIntoFolder: moveNodeIntoFolder }; })); diff --git a/manager/media/script/upload-queue.js b/manager/media/script/upload-queue.js new file mode 100644 index 0000000000..ec7ffaa95d --- /dev/null +++ b/manager/media/script/upload-queue.js @@ -0,0 +1,111 @@ +(function (root, factory) { + var exported = factory(); + + if (typeof module === 'object' && module.exports) { + module.exports = exported; + } + + root.evoUploadQueue = exported; +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + // room in a request for the other form fields and the multipart framing + var REQUEST_OVERHEAD = 16384; + var PART_OVERHEAD = 512; + + /** + * Splits the chosen files into requests PHP takes whole: PHP drops every file past max_file_uploads + * and the whole request past post_max_size, both without telling anyone. sizes are in bytes, in the + * order chosen; a limit of 0 means none. A file over fileLimit, or too large for a request of its own, + * goes to skipped. Returns indexes into sizes. + */ + function plan(sizes, maxFiles, postLimit, fileLimit) { + var budget = postLimit > 0 ? postLimit - REQUEST_OVERHEAD : 0; + var batches = []; + var skipped = []; + var current = []; + var bytes = 0; + + for (var i = 0; i < sizes.length; i++) { + var cost = sizes[i] + PART_OVERHEAD; + if ((fileLimit > 0 && sizes[i] > fileLimit) || (budget > 0 && cost > budget)) { + skipped.push(i); + continue; + } + if (current.length && ((maxFiles > 0 && current.length >= maxFiles) || (budget > 0 && bytes + cost > budget))) { + batches.push(current); + current = []; + bytes = 0; + } + current.push(i); + bytes += cost; + } + if (current.length) { + batches.push(current); + } + + return { batches: batches, skipped: skipped }; + } + + /** + * Sends the batches one after another; each answer carries the single-use token of the next request. + * send(batch, token) resolves to { report, uploaded, total, token } and rejects with a reason. + * After a failed request the token chain is broken, so that batch and all later ones are not sent. + */ + function run(batches, token, send, onProgress) { + var result = { reports: [], uploaded: 0, notSent: [], reason: '' }; + var index = 0; + + function next() { + if (index >= batches.length) { + return Promise.resolve(result); + } + var batch = batches[index]; + if (onProgress) { + onProgress(index, batches.length); + } + return Promise.resolve() + .then(function () { + return send(batch, token); + }) + .then(function (answer) { + result.reports.push(answer.report); + result.uploaded += answer.uploaded; + token = answer.token; + index++; + if (!token && index < batches.length) { + throw new Error('no token for the next request'); + } + return next(); + }) + .catch(function (error) { + result.reason = error && error.message ? error.message : String(error); + for (var i = index; i < batches.length; i++) { + result.notSent = result.notSent.concat(batches[i]); + } + return result; + }); + } + + return next(); + } + + function format(text, values) { + return String(text).replace(/%(\d)\$s/g, function (match, n) { + return values[n - 1]; + }); + } + + function escapeHtml(text) { + return String(text).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + return { + plan: plan, + run: run, + format: format, + escapeHtml: escapeHtml + }; +})); diff --git a/manager/media/style/default/css/tree.css b/manager/media/style/default/css/tree.css index 3aceb5223e..9cb0a7258b 100755 --- a/manager/media/style/default/css/tree.css +++ b/manager/media/style/default/css/tree.css @@ -31,9 +31,6 @@ #treeRoot a .editResource { margin-left: 0.2em } #treeRoot .indent { position: relative; z-index: -1; float: left; height: 1.5em; } #treeRoot .indent i { float: left; height: 1.2em; width: 1.5em } -#treeRoot .dragafter > a .indent, #treeRoot .dragbefore > a .indent { z-index: 1; } -#treeRoot .dragafter > a .indent::after, #treeRoot .dragbefore > a .indent::before { content: "\f0da"; display: block; position: absolute; right: 0.1em; bottom: -0.5em; height: 1em; font: bold 1em/1em 'Font Awesome 5 Free'; color: #f04 } -#treeRoot .dragbefore > a .indent::before { bottom: auto; top: -0.5em; } #treeRoot .empty { color: #aaa; cursor: default; } #treeRoot .icon { cursor: pointer; -webkit-transition-duration: 0.15s; transition-duration: 0.15s } #treeRoot .icon:hover > .fa { color: #000 } @@ -61,10 +58,13 @@ .treeRoot a .editResource { margin-left: 0.2em } .treeRoot .indent { position: relative; z-index: -1; float: left; height: 1.5em; } .treeRoot .indent i { float: left; height: 1.2em; width: 1.5em } -.treeRoot .dragafter > a .indent, .treeRoot .dragbefore > a .indent { z-index: 1; } -.treeRoot .dragafter > a .indent::after, .treeRoot .dragbefore > a .indent::before { content: "\f0da"; display: block; position: absolute; right: 0.1em; bottom: -0.5em; height: 1em; font: bold 1em/1em 'Font Awesome 5 Free'; color: #f04 } -.treeRoot .dragbefore > a .indent::before { bottom: auto; top: -0.5em; } .treeRoot .empty { color: #aaa; cursor: default; } +/* drop marker: line on the edge the resource goes to, arrow centred on it (on the row middle for "into") */ +#treeDropMarker { display: none; position: absolute; z-index: 2; right: 0; height: 0; margin-left: -1.55em; pointer-events: none; } +#treeDropMarker.visible { display: block; } +#treeDropMarker.line { margin-top: -1px; border-top: 2px solid #22a447; } +#treeDropMarker::before { content: "\f0a9"; position: absolute; left: -1em; top: -0.5em; width: 1em; height: 1em; font: 900 0.875em/1em 'Font Awesome 5 Free'; text-align: center; color: #22a447; } +#treeDropMarker.line::before { margin-top: -1px; } .treeRoot .icon { cursor: pointer; -webkit-transition-duration: 0.15s; transition-duration: 0.15s } .treeRoot .icon:hover > .fa { color: #000 } .treeRoot .toggle { float: left; margin: 1px 0.2em 1px -1.5em; width: 1.3em; height: 1.3em; line-height: 1.4em; text-align: center; vertical-align: top; cursor: pointer; border-radius: 50%; -webkit-transition-duration: 0.15s; transition-duration: 0.15s } @@ -113,6 +113,8 @@ .dark .treeRoot a:hover:not(.empty), .dark .treeRoot a.current, .darkness .treeRoot a:hover:not(.empty), .darkness .treeRoot a.current { background-color: rgba(255, 255, 255, 0.15); } .dark .treeRoot a.selected, .dark .treeRoot .dragenter > a, .darkness .treeRoot a.selected, .darkness.treeRoot .dragenter > a { background-color: rgba(0, 142, 255, 0.19); } .dark .treeRoot .dragafter > a::after, .dark .treeRoot .dragbefore > a::before, .darkness .treeRoot .dragafter > a::after, .darkness .treeRoot .dragbefore > a::before { background-color: rgba(255, 255, 255, 0.25); } +.dark #treeDropMarker, .darkness #treeDropMarker { border-color: #4cd07d; } +.dark #treeDropMarker::before, .darkness #treeDropMarker::before { color: #4cd07d; } .dark .treeRoot a .title, .darkness .treeRoot a .title { color: #7cb2dc; } .dark .treeRoot a.hidemenu .title, .darkness .treeRoot a.hidemenu .title { color: #c5c5c5; } .dark .treeRoot a.deleted .title, .darkness .treeRoot a.deleted .title { color: #a52a2a; } diff --git a/manager/media/style/default/js/evo.js b/manager/media/style/default/js/evo.js index d2d8909fb4..01d9e29539 100644 --- a/manager/media/style/default/js/evo.js +++ b/manager/media/style/default/js/evo.js @@ -175,7 +175,9 @@ this.moduleViewport.init(); this.search.init(); if (this.config.session_timeout > 0) { - w.setInterval(this.keepMeAlive, 1000 * 60 * this.config.session_timeout); + // twice per timeout: a session (and its resource locks) expires once its last hit is a full timeout old, + // and browsers run timers of background tabs late + w.setInterval(this.keepMeAlive, 1000 * 60 * this.config.session_timeout / 2); } d.addEventListener('click', this.hideDropDown, false); if (evo.config.global_tabs) { @@ -1075,11 +1077,8 @@ var el = els[i]; el.onmousedown = this.onmousedown; el.ondragstart = this.ondragstart; - el.ondragenter = this.ondragenter; - el.ondragover = this.ondragover; - el.ondragleave = this.ondragleave; - el.ondrop = this.ondrop; } + this.initDropTargets(); } }, onmousedown: function (e) { @@ -1102,6 +1101,7 @@ if (draggable) { this.parentNode.draggable = true; this.parentNode.ondragstart = evo.tree.ondragstart; + this.parentNode.ondragend = evo.tree.ondragend; } else { this.parentNode.draggable = false; this.parentNode.ondragstart = function () { @@ -1111,10 +1111,15 @@ } }, ondragstart: function (e) { - e.dataTransfer.effectAllowed = 'all'; - e.dataTransfer.dropEffect = 'all'; + // set only while a tree node is dragged, so files dragged in from outside move nothing + evo.tree.dragId = this.id.substr(4); + e.dataTransfer.effectAllowed = modxTreeDropHelper.DROP_EFFECT; e.dataTransfer.setData('text', this.id.substr(4)); }, + ondragend: function () { + evo.tree.dragId = null; + evo.tree.clearDropTarget(); + }, isBlockedDropTarget: function (target) { return !!(w.modxTreeDropGuardHelper && !w.modxTreeDropGuardHelper.canDropIntoTarget(target)); }, @@ -1122,148 +1127,238 @@ isBlockedParentTarget: function (node) { return !!(w.modxTreeParentGuardHelper && w.modxTreeParentGuardHelper.isBlockedParentTarget(node)); }, - ondragenter: function (e) { - if ( - d.getElementById('node' + evo.tree.itemToChange) === (this.parentNode.closest('#node' + evo.tree.itemToChange) || this.parentNode) - || evo.tree.isBlockedDropTarget(this) - ) { - this.parentNode.className = ''; - e.dataTransfer.effectAllowed = 'none'; - e.dataTransfer.dropEffect = 'none'; - evo.tree.drag = false; - } else { - this.parentNode.className = 'dragenter'; - e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.dropEffect = 'copy'; - evo.tree.drag = true; + setNodeLevel: function (node, level) { + node.firstChild.querySelector('.indent').innerHTML = ''.repeat(Math.max(0, level)); + }, + // parent id of the folder (or tree root) that holds the node of this anchor + parentIdOf: function (anchor) { + var holder = anchor.parentNode.parentNode; + if (/node/.test(holder.parentNode.id)) { + return parseInt(holder.parentNode.id.substr(4)); } - e.preventDefault(); + return /treeRoot/.test(holder.id) ? parseInt(holder.id.substr(8)) : 0; }, - ondragover: function (e) { - if (evo.tree.isBlockedDropTarget(this)) { - this.parentNode.className = ''; - e.dataTransfer.effectAllowed = 'none'; - e.dataTransfer.dropEffect = 'none'; - evo.tree.drag = false; - } else if (evo.tree.drag) { - var a = e.clientY; - var b = parseInt(this.getBoundingClientRect().top); - var c = a - b; - if (c > this.offsetHeight / 1.51) { - //this.parentNode.className = 'dragafter'; - this.parentNode.classList.add('dragafter'); - this.parentNode.classList.remove('dragbefore'); - this.parentNode.classList.remove('dragenter'); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.dropEffect = 'move'; - } else if (c < this.offsetHeight / 3) { - //this.parentNode.className = 'dragbefore'; - this.parentNode.classList.add('dragbefore'); - this.parentNode.classList.remove('dragafter'); - this.parentNode.classList.remove('dragenter'); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.dropEffect = 'move'; - } else { - //this.parentNode.className = 'dragenter'; - this.parentNode.classList.add('dragenter'); - this.parentNode.classList.remove('dragafter'); - this.parentNode.classList.remove('dragbefore'); - e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.dropEffect = 'copy'; + // One dragover / drop handler for the whole tree. Every place inside it maps to a position the + // resource can move to, so a drag never shows "no drop" over the tree: + // before / into / after a row (after an expanded folder's row = its first child) + // aftertree after a row and everything below it + // end the end of the tree root (node null) + initDropTargets: function () { + var holder = d.getElementById('treeHolder'); + if (!holder || holder.dataset.dropTargets) { + return; + } + holder.dataset.dropTargets = '1'; + var ondrag = function (e) { + var dragged = evo.tree.dragId && d.getElementById('node' + evo.tree.dragId); + if (!dragged) { + return; } + e.preventDefault(); + evo.tree.cancelDropTargetClear(); + var target = evo.tree.resolveDropTarget(e, dragged); + if (target) { + evo.tree.setDropTarget(target.node, target.zone); + } + // elsewhere in the tree (plugin output, the loader) the last position stays + e.dataTransfer.dropEffect = evo.tree.dropZone ? modxTreeDropHelper.DROP_EFFECT : 'none'; + }; + holder.addEventListener('dragenter', ondrag); + holder.addEventListener('dragover', ondrag); + holder.addEventListener('dragleave', function (e) { + if (e.relatedTarget && holder.contains(e.relatedTarget)) { + return; + } + // relatedTarget is null when leaving for the main frame, and in some browsers on every move, + // so the marker goes only if no dragover inside the tree follows + evo.tree.scheduleDropTargetClear(e.relatedTarget ? 0 : 400); + }); + holder.addEventListener('drop', function (e) { + if (evo.tree.dragId) { + e.preventDefault(); + evo.tree.dropOnTarget(); + } + }); + }, + resolveDropTarget: function (e, dragged) { + var holder = d.getElementById('treeHolder'); + var root = d.getElementById('treeRoot0'); + var element = e.target.nodeType === 1 ? e.target : e.target.parentNode; + var firstRootNode = root ? root.querySelector(':scope > [id^="node"]') : null; + if (element === holder) { + return firstRootNode ? { node: null, zone: 'end' } : null; + } + if (element.closest('#node0')) { + // the site root row: first place at the root + return firstRootNode ? evo.tree.positionOn(firstRootNode.firstChild, 0, dragged) : null; + } + var anchor = element.closest('.treeRoot a'); + if (!anchor) { + return null; + } + if (anchor.classList.contains('empty')) { + // the "empty folder" row: into that folder + var folder = anchor.parentNode.parentNode.previousElementSibling; + return folder ? evo.tree.positionOn(folder, null, dragged) : null; + } + return evo.tree.positionOn(anchor, e.clientY - anchor.getBoundingClientRect().top, dragged); + }, + // offset null = the middle of the row + positionOn: function (anchor, offset, dragged) { + var node = anchor.parentNode; + var height = anchor.getBoundingClientRect().height; + if (offset === null) { + offset = height / 2; + } + if (dragged.contains(node)) { + // its own row or one below it: before or after itself, where it already is + return { node: dragged, zone: node === dragged && offset < height / 2 ? 'before' : 'aftertree' }; + } + // nothing goes into a deleted resource, but next to it is fine + var canDropInto = !evo.tree.isBlockedDropTarget(anchor); + var zone = modxTreeDropHelper.getDropZone(offset, height, canDropInto); + return { node: node, zone: canDropInto || zone === 'before' ? zone : 'aftertree' }; + }, + setDropTarget: function (node, zone) { + var prev = evo.tree.dropNode; + var root = d.getElementById('treeRoot0'); + var cls = { before: 'dragbefore', into: 'dragenter', after: 'dragafter', aftertree: 'dragafter' }[zone]; + if (prev && prev !== node) { + prev.classList.remove('dragbefore', 'dragenter', 'dragafter'); + } + evo.tree.dropNode = node; + evo.tree.dropZone = zone; + if (node && !node.classList.contains(cls)) { + node.classList.remove('dragbefore', 'dragenter', 'dragafter'); + node.classList.add(cls); + } + if (root) { + root.classList.toggle('dragend', !node); + } + + var anchor = node ? node.firstChild : root.querySelector(':scope > [id^="node"] > a'); + var rect = anchor.getBoundingClientRect(); + var indent = anchor; + var y = rect.bottom; + if (zone === 'end') { + y = root.getBoundingClientRect().bottom; + } else if (zone === 'before') { + y = rect.top; + } else if (zone === 'into') { + y = rect.top + rect.height / 2; + } else if (zone === 'aftertree') { + y = node.getBoundingClientRect().bottom; } else { - e.dataTransfer.effectAllowed = 'none'; - e.dataTransfer.dropEffect = 'none'; - evo.tree.drag = false; + // the first child's level, which is where the resource goes + indent = modxTreeDropHelper.getFirstChildNode(anchor) || node; + indent = indent.firstChild || indent; } - e.preventDefault(); + evo.tree.showDropMarker(indent.querySelector('.indent'), y, zone !== 'into'); }, - ondragleave: function (e) { - this.parentNode.className = ''; - this.parentNode.removeAttribute('draggable'); - e.preventDefault(); + clearDropTarget: function () { + var root = d.getElementById('treeRoot0'); + var marker = d.getElementById('treeDropMarker'); + evo.tree.cancelDropTargetClear(); + if (evo.tree.dropNode) { + evo.tree.dropNode.classList.remove('dragbefore', 'dragenter', 'dragafter'); + } + evo.tree.dropNode = null; + evo.tree.dropZone = null; + if (root) { + root.classList.remove('dragend'); + } + if (marker) { + marker.className = ''; + } }, - ondrop: function (e) { - if (evo.tree.isBlockedDropTarget(this)) { - this.parentNode.removeAttribute('class'); - this.parentNode.removeAttribute('draggable'); - evo.alert(evo.lang.error_parent_deleted); - evo.tree.restoreTree(); - e.preventDefault(); + scheduleDropTargetClear: function (delay) { + evo.tree.cancelDropTargetClear(); + evo.tree.dropClearTimer = setTimeout(evo.tree.clearDropTarget, delay); + }, + cancelDropTargetClear: function () { + clearTimeout(evo.tree.dropClearTimer); + }, + // one marker over the tree: a row clips its own overflow, so a marker drawn inside it cannot sit on its edge + showDropMarker: function (indent, y, line) { + var holder = d.getElementById('treeHolder'); + var marker = d.getElementById('treeDropMarker'); + if (!holder || !indent) { return; } - - let el = d.getElementById('node' + evo.tree.itemToChange); - let els = null; - let id = evo.tree.itemToChange; - let parent = 0; - let menuindex = []; - let level = 0; - let indent = el.firstChild.querySelector('.indent'); - let i = 0; - indent.innerHTML = ''; - el.removeAttribute('draggable'); - if (this.parentNode.classList.contains('dragenter')) { - parent = parseInt(this.parentNode.id.substr(4)); - level = parseInt(this.dataset.level) + 1; - for (i = 0; i < level; i++) { - indent.innerHTML += ''; - } - if (this.nextSibling) { - const dropResult = modxTreeDropHelper.moveNodeIntoFolder(this, el); - els = dropResult.children; - for (i = 0; i < els.length; i++) { - menuindex[i] = els[i].id.substr(4); - } - } else { - el.parentNode.removeChild(el); - d.querySelector('#node' + parent + ' .icon').innerHTML = parseInt(this.dataset.private) ? evo.style.tree_folder_secure : evo.style.tree_folder; - } - evo.tree.ondragupdate(this, id, parent, menuindex); + if (!marker) { + marker = d.createElement('div'); + marker.id = 'treeDropMarker'; + holder.appendChild(marker); } - if (this.parentNode.classList.contains('dragafter')) { - parent = /node/.test(this.parentNode.parentNode.parentNode.id) ? parseInt(this.parentNode.parentNode.parentNode.id.substr(4)) : 0; - if (parent == 0) { - parent = /treeRoot/.test(this.parentNode.parentNode.id) ? parseInt(this.parentNode.parentNode.id.substr(8)) : 0; - } - level = parseInt(this.dataset.level); - for (i = 0; i < level; i++) { - indent.innerHTML += ''; - } - this.parentNode.parentNode.insertBefore(el, this.parentNode.nextSibling); - els = this.parentNode.parentNode.children; - for (i = 0; i < els.length; i++) { - menuindex[i] = els[i].id.substr(4); - } - evo.tree.ondragupdate(this, id, parent, menuindex); + var box = holder.getBoundingClientRect(); + marker.style.top = Math.round(y - box.top + holder.scrollTop) + 'px'; + marker.style.left = Math.round(indent.getBoundingClientRect().right - box.left + holder.scrollLeft) + 'px'; + marker.className = line ? 'visible line' : 'visible'; + }, + dropOnTarget: function () { + var node = evo.tree.dropNode; + var zone = evo.tree.dropZone; + var id = evo.tree.dragId; + var el = id && d.getElementById('node' + id); + var root = d.getElementById('treeRoot0'); + evo.tree.clearDropTarget(); + evo.tree.dragId = null; + // before or after itself leaves it where it is + if (!el || !zone || node === el) { + return; } - if (this.parentNode.classList.contains('dragbefore')) { - parent = /node/.test(this.parentNode.parentNode.parentNode.id) ? parseInt(this.parentNode.parentNode.parentNode.id.substr(4)) : 0; - if (parent == 0) { - parent = /treeRoot/.test(this.parentNode.parentNode.id) ? parseInt(this.parentNode.parentNode.id.substr(8)) : 0; - } - level = parseInt(this.dataset.level); - for (i = 0; i < level; i++) { - indent.innerHTML += ''; - } - this.parentNode.parentNode.insertBefore(el, this.parentNode); - els = this.parentNode.parentNode.children; - for (i = 0; i < els.length; i++) { - menuindex[i] = els[i].id.substr(4); - } - evo.tree.ondragupdate(this, id, parent, menuindex); + // checked before the tree changes: a drop into a document takes the row out of the page + if (el.firstChild.dataset.nomove == 1) { + alert(evo.lang.error_no_privileges); + return; } - this.parentNode.removeAttribute('class'); - this.parentNode.removeAttribute('draggable'); - e.preventDefault(); + el.removeAttribute('draggable'); + + if (zone === 'end') { + evo.tree.setNodeLevel(el, 0); + root.appendChild(el); + evo.tree.ondragupdate(null, id, 0, modxTreeDropHelper.collectMenuIndex(root.children)); + return; + } + + var anchor = node.firstChild; + var parent = evo.tree.parentIdOf(anchor); + var level = parseInt(anchor.dataset.level); + var siblings = node.parentNode; + var firstChild = zone === 'after' ? modxTreeDropHelper.getFirstChildNode(anchor) : null; + if (zone === 'into' || firstChild) { + parent = parseInt(node.id.substr(4)); + level++; + // the tree reloads after the move: show the target opened, with the resource in it + evo.openedArray[parent] = 1; + } + evo.tree.setNodeLevel(el, level); + + if (firstChild) { + siblings = firstChild.parentNode; + siblings.insertBefore(el, firstChild); + } else if (zone === 'after' || zone === 'aftertree') { + siblings.insertBefore(el, node.nextSibling); + } else if (zone === 'before') { + siblings.insertBefore(el, node); + } else if (anchor.nextSibling) { + siblings = modxTreeDropHelper.moveNodeIntoFolder(anchor, el).container; + } else { + siblings = null; + el.parentNode.removeChild(el); + node.querySelector('.icon').innerHTML = parseInt(anchor.dataset.private) ? evo.style.tree_folder_secure : evo.style.tree_folder; + } + evo.tree.ondragupdate(anchor, id, parent, siblings ? modxTreeDropHelper.collectMenuIndex(siblings.children) : []); }, ondragupdate: function (a, id, parent, menuindex) { - if (d.querySelector('#node' + id + ' > a').dataset.nomove == 1) { + var moved = d.querySelector('#node' + id + ' > a'); + if (moved && moved.dataset.nomove == 1) { alert(evo.lang.error_no_privileges); evo.tree.restoreTree(); return; } - let roles = a.dataset.roles + (!a.parentNode.parentNode.classList.contains('treeRoot') ? a.parentNode.parentNode.previousSibling.dataset.roles : ''); + // a is null for a drop on the tree root, which carries no roles + let roles = a ? a.dataset.roles + (!a.parentNode.parentNode.classList.contains('treeRoot') ? a.parentNode.parentNode.previousSibling.dataset.roles : '') : ''; let checked_group = false; if (roles != '') { checked_group = roles.split(',').map(Number).filter(function (role) { @@ -1284,7 +1379,7 @@ menuindex: menuindex }, function (r) { if (r && r.errors) evo.alert(r.errors); - evo.tree.restoreTree(); + evo.tree.restoreTree(true); }, 'json'); var b = evo.normalizeUrl(w.main.frameElement.contentWindow.location.href); if (evo.getActionFromUrl(b, 27) && parseInt(evo.main.getQueryVariable('id', b)) === parseInt(id)) { @@ -1758,15 +1853,18 @@ } this.setSelected(this.itemToChange); }, - restoreTree: function () { + // withFolderState: the reload also stores the folders opened here, in the same request (a separate + // saveFolderState() request can finish after it and leave the session with the old state) + restoreTree: function (withFolderState) { //console.log('evo.tree.restoreTree()'); let _this = this; + let folderState = withFolderState === true ? this.getFolderState() : ''; d.getElementById('treeloader').classList.add('visible'); d.querySelectorAll('.treeRoot').forEach(function (element) { _this.setItemToChange(); let rpcNode = d.getElementById(element.id); let parent = element.id.replace('treeRoot', ''); - evo.post(evo.EVO_MANAGER_URL + 'media/style/' + evo.config.theme + '/ajax.php', 'a=1&f=nodes&indent=1&parent=' + parent + '&expandAll=2&id=' + _this.itemToChange, function (r) { + evo.post(evo.EVO_MANAGER_URL + 'media/style/' + evo.config.theme + '/ajax.php', 'a=1&f=nodes&indent=1&parent=' + parent + '&expandAll=2&id=' + _this.itemToChange + folderState, function (r) { evo.tree.rpcLoadData(r, rpcNode); evo.tree.draggable(); }); diff --git a/manager/views/frame/1.blade.php b/manager/views/frame/1.blade.php index f6a28bfffd..4eab82a9b7 100644 --- a/manager/views/frame/1.blade.php +++ b/manager/views/frame/1.blade.php @@ -218,6 +218,7 @@ function jsIconMarkup($icon) { window.modx = evo; window.tree = evo.tree; + From 3c3e4d11d7e88a7d952fdd01d7775611919b4c11 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 2 Nov 2026 04:17:59 +0100 Subject: [PATCH 4/7] fix(vendor): drop stray blank lines from composer LICENSE Co-Authored-By: Claude Opus 5.5 --- core/vendor/composer/LICENSE | 2 -- 1 file changed, 2 deletions(-) diff --git a/core/vendor/composer/LICENSE b/core/vendor/composer/LICENSE index f27399a042..62ecfd8d00 100644 --- a/core/vendor/composer/LICENSE +++ b/core/vendor/composer/LICENSE @@ -1,4 +1,3 @@ - Copyright (c) Nils Adermann, Jordi Boggiano Permission is hereby granted, free of charge, to any person obtaining a copy @@ -18,4 +17,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - From 502b4ad25ce657ef3dd14d22c2f62ab0cf506579 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 2 Nov 2026 04:17:59 +0100 Subject: [PATCH 5/7] fix(tests): align thumbnail cache test with image allowlist in assets/cache (#2538) Co-Authored-By: Claude Opus 5.5 --- core/tests/Unit/PhpThumbCacheImagesTest.php | 26 ++++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/core/tests/Unit/PhpThumbCacheImagesTest.php b/core/tests/Unit/PhpThumbCacheImagesTest.php index b2160d3669..eae138289e 100644 --- a/core/tests/Unit/PhpThumbCacheImagesTest.php +++ b/core/tests/Unit/PhpThumbCacheImagesTest.php @@ -76,12 +76,26 @@ function phpThumbRepoFile(string $relative): string expect($passes)->toBe($served); })->with('phpthumb cache paths'); -it('keeps the rest of assets/cache closed in the per-directory file', function () { - expect(phpThumbRepoFile('assets/cache/.htaccess')) - ->toContain('Require all denied') - ->not->toContain('FilesMatch') - ->not->toContain('granted'); -}); +it('keeps non-image cache files closed in both Apache authorization dialects', function (string $name, bool $served) { + $htaccess = phpThumbRepoFile('assets/cache/.htaccess'); + expect($htaccess)->toContain('Require all denied')->toContain('Deny from all'); + preg_match_all('/\s*(.*?)\s*<\/FilesMatch>/s', $htaccess, $rules, PREG_SET_ORDER); + + expect($rules)->toHaveCount(2); + foreach ($rules as $rule) { + expect(preg_match('~' . $rule[1] . '~', $name))->toBe($served ? 1 : 0); + } +})->with([ + 'JPEG image' => ['thumbnail.jpg', true], + 'PNG image' => ['thumbnail.PNG', true], + 'WebP image' => ['thumbnail.jpg.webp', true], + 'site cache' => ['siteCache.idx.php', false], + 'PHP with image prefix' => ['thumbnail.jpg.php', false], + 'JSON cache' => ['settings.json', false], + 'HTML' => ['page.html', false], + 'SVG' => ['image.svg', false], + 'server configuration' => ['.htaccess', false], +]); it('writes the same two-dialect .htaccess into the thumbnail folder as the one shipped there', function () { expect(HelperProcessor::CACHE_FOLDER_HTACCESS) From 0be7db271a4852ebf7f8eeef17ba68b43698f119 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 2 Nov 2026 12:14:24 +0100 Subject: [PATCH 6/7] fix(manager): stop stale page locks, alert loops and double alerts - A manager whose last page was System Settings (or another lockable page) kept it locked for everyone after closing the browser: the lock now needs a signed-in session (session_timeout, as the session cleanup uses). - A new install's dashboard redirected to settings held by another manager; the locked page went back to the dashboard and the two looped. - The alert page started its timer before load, so on a slow stylesheet the native alert() fired before the manager frame replaced it: two popups. - A tab whose page alerted without an element id (settings, no privileges) was left blank and open; it is closed now. Co-Authored-By: Claude Opus 5.5 --- core/src/Core.php | 12 +-- core/src/Models/ActiveUser.php | 10 ++- core/src/Models/ActiveUserSession.php | 9 +++ core/tests/Unit/Manager/SettingsLockTest.php | 82 ++++++++++++++++++++ manager/media/style/default/js/evo.js | 26 ++++--- manager/views/page/2.blade.php | 4 +- 6 files changed, 123 insertions(+), 20 deletions(-) create mode 100644 core/tests/Unit/Manager/SettingsLockTest.php diff --git a/core/src/Core.php b/core/src/Core.php index f32768a5ca..8fd6669bce 100644 --- a/core/src/Core.php +++ b/core/src/Core.php @@ -3786,7 +3786,9 @@ function __alertQuit() { el.remove(); " . $fnc . " } - window.setTimeout('__alertQuit();',100); + // after load: a manager tab or popup frame replaces alert() in its own onload; a timer + // started earlier fires first on a slow stylesheet, and the message shows twice + window.addEventListener('load', function () { window.setTimeout(__alertQuit, 100); }); @@ -3935,13 +3937,7 @@ public function buildLockedElementsCache() public function cleanupExpiredLocks() { // Clean-up active_user_sessions first - if ((int) $this->getConfig('session_timeout') < 2) { - $timeout = 120; - } else { - $timeout = $this->getConfig('session_timeout') * 60; - } - // session.js pings every 10min, so 2min is minimum - $validSessionTimeLimit = $this->time - $timeout; + $validSessionTimeLimit = ActiveUserSession::validSince((int) $this->time, (int) $this->getConfig('session_timeout')); ActiveUserSession::where('lasthit', '<', (int) $validSessionTimeLimit)->delete(); // Clean-up active_user_locks diff --git a/core/src/Models/ActiveUser.php b/core/src/Models/ActiveUser.php index b03560f94d..757dddeb09 100644 --- a/core/src/Models/ActiveUser.php +++ b/core/src/Models/ActiveUser.php @@ -12,7 +12,7 @@ * @property string $action * @property int $id * - * @method static \Illuminate\Database\Eloquent\Builder|\EvolutionCMS\Models\ActiveUser locked($action, $id = null, $userId = null) + * @method static \Illuminate\Database\Eloquent\Builder|\EvolutionCMS\Models\ActiveUser locked($action, $id = null, $userId = null, ?int $aliveSince = null) * * @mixin \Eloquent */ @@ -38,14 +38,20 @@ class ActiveUser extends Eloquent\Model 'id' ]; - public function scopeLocked(Eloquent\Builder $builder, $action, $id = null, $userId = null) + public function scopeLocked(Eloquent\Builder $builder, $action, $id = null, $userId = null, ?int $aliveSince = null) { if ($userId === null) { $userId = evo()->getLoginUserID(); } + if ($aliveSince === null) { + $aliveSince = ActiveUserSession::validSince(time(), (int) evo()->getConfig('session_timeout')); + } + // A row stays here after its manager is gone (closed browser, expired session) until they open + // another page, so only a manager who is still signed in holds the page. $builder = $builder->where('action', '=', (int)$action) ->where('internalKey', '!=', $userId) + ->whereIn('internalKey', ActiveUserSession::query()->select('internalKey')->where('lasthit', '>=', $aliveSince)) ->orderBy('lasthit', 'DESC'); if ($id !== null) { diff --git a/core/src/Models/ActiveUserSession.php b/core/src/Models/ActiveUserSession.php index e34c208aef..26c0d3d833 100644 --- a/core/src/Models/ActiveUserSession.php +++ b/core/src/Models/ActiveUserSession.php @@ -31,4 +31,13 @@ class ActiveUserSession extends Eloquent\Model 'lasthit', 'ip' ]; + + /** + * Oldest lasthit of a session that still counts as signed in: session_timeout minutes, at least 2. + * An open manager pings every half timeout (evo.keepMeAlive), so it never falls out of this window. + */ + public static function validSince(int $now, int $timeoutMinutes): int + { + return $now - max(2, $timeoutMinutes) * 60; + } } diff --git a/core/tests/Unit/Manager/SettingsLockTest.php b/core/tests/Unit/Manager/SettingsLockTest.php new file mode 100644 index 0000000000..88b93e8c61 --- /dev/null +++ b/core/tests/Unit/Manager/SettingsLockTest.php @@ -0,0 +1,82 @@ +addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + Model::setConnectionResolver($capsule->getDatabaseManager()); + + $schema = $capsule->getConnection()->getSchemaBuilder(); + $schema->create('active_user_sessions', function (Blueprint $table) { + $table->string('sid', 128)->primary(); + $table->integer('internalKey')->default(0); + $table->integer('lasthit')->default(0); + $table->string('ip')->default(''); + }); + $schema->create('active_users', function (Blueprint $table) { + $table->string('sid', 128)->primary(); + $table->integer('internalKey')->default(0); + $table->string('username', 50)->default(''); + $table->integer('lasthit')->default(0); + $table->string('action', 10)->default(''); + $table->integer('id')->nullable(); + }); +}); + +afterEach(function () { + Capsule::connection()->disconnect(); +}); + +function onSettings(int $userId, string $username, string $sid, int $lasthit, ?int $sessionHit): void +{ + ActiveUser::query()->insert(['sid' => $sid, 'internalKey' => $userId, 'username' => $username, 'lasthit' => $lasthit, 'action' => '17', 'id' => null]); + if ($sessionHit !== null) { + ActiveUserSession::query()->insert(['sid' => $sid, 'internalKey' => $userId, 'lasthit' => $sessionHit, 'ip' => '127.0.0.1']); + } +} + +test('a manager who left the settings open no longer holds them once the session is gone', function () { + $now = 1_800_000_000; + $since = ActiveUserSession::validSince($now, 20); + // left 2 h ago, session already cleaned up + onSettings(2, 'gone', 'sid-gone', $now - 7200, null); + // last hit long ago, session row not cleaned yet but past the timeout + onSettings(3, 'expired', 'sid-expired', $now - 7200, $now - 21 * 60); + + expect(ActiveUser::locked(17, null, 1, $since)->first())->toBeNull(); +}); + +test('a signed-in manager on the settings page still holds them', function () { + $now = 1_800_000_000; + // opened the settings 30 min ago and kept the manager open: keep-alive refreshed the session + onSettings(2, 'editor', 'sid-editor', $now - 1800, $now - 60); + + expect(ActiveUser::locked(17, null, 1, ActiveUserSession::validSince($now, 20))->first()?->username)->toBe('editor') + ->and(ActiveUser::locked(17, null, 2, ActiveUserSession::validSince($now, 20))->first())->toBeNull(); +}); + +test('the session window is session_timeout minutes and never below two', function () { + expect(ActiveUserSession::validSince(10_000, 20))->toBe(10_000 - 1200) + ->and(ActiveUserSession::validSince(10_000, 0))->toBe(10_000 - 120) + ->and(ActiveUserSession::validSince(10_000, 1))->toBe(10_000 - 120); +}); + +test('the alert page waits for load, so a manager frame replaces alert() before it fires', function () { + $source = file_get_contents(dirname(__DIR__, 3) . '/src/Core.php'); + + expect($source)->toContain("window.addEventListener('load', function () { window.setTimeout(__alertQuit, 100); });") + ->and($source)->not->toContain("window.setTimeout('__alertQuit();',100);"); +}); + +test('the dashboard does not send a new install to settings another manager holds', function () { + $view = file_get_contents(dirname(__DIR__, 4) . '/manager/views/page/2.blade.php'); + + expect($view)->toMatch('/settings_version.*\n.*!\\\\EvolutionCMS\\\\Models\\\\ActiveUser::locked\(17\)->exists\(\)/'); +}); diff --git a/manager/media/style/default/js/evo.js b/manager/media/style/default/js/evo.js index 01d9e29539..a8d48c1921 100644 --- a/manager/media/style/default/js/evo.js +++ b/manager/media/style/default/js/evo.js @@ -2185,15 +2185,23 @@ history.go(1); }; evo.alert(message); - evo.getLockedElements(evo.getActionFromUrl(this.url), evo.main.getQueryVariable('id', this.url), function (data) { - if (!!data || ~s.closeactions.indexOf(evo.getActionFromUrl(s.url))) { - s.page.close(); - evo.tree.restoreTree(); - } else { - w.main.location.href = evo.EVO_MANAGER_URL + s.url; - w.history.replaceState(null, d.title, evo.tabsHistoryUrl(s.url)); - } - }); + var alertAction = evo.getActionFromUrl(this.url), + alertId = evo.main.getQueryVariable('id', this.url); + // only an element page has a lock to look up; any other page that alerts (System Settings + // held by another manager, no privileges) has nothing left to show, so its tab goes + if (!evo.typesactions[alertAction] || !alertId) { + s.page.close(); + } else { + evo.getLockedElements(alertAction, alertId, function (data) { + if (!!data || ~s.closeactions.indexOf(evo.getActionFromUrl(s.url))) { + s.page.close(); + evo.tree.restoreTree(); + } else { + w.main.location.href = evo.EVO_MANAGER_URL + s.url; + w.history.replaceState(null, d.title, evo.tabsHistoryUrl(s.url)); + } + }); + } } else { if (evo.getActionFromUrl(this.url, 2) || (~this.saveAndCloseActions.indexOf(evo.getActionFromUrl(this.url)) && parseInt(evo.main.getQueryVariable('r', this.url)))) { this.close(e); diff --git a/manager/views/page/2.blade.php b/manager/views/page/2.blade.php index 03dbef6c9c..e572ce5cac 100644 --- a/manager/views/page/2.blade.php +++ b/manager/views/page/2.blade.php @@ -21,7 +21,9 @@ get('ManagerTheme')->getFileProcessor("actions/welcome.static.php");*/ unset($_SESSION['itemname'], $_SESSION['itemaction']); // clear this, because it's only set for logging purposes - if (evo()->hasPermission('settings') && evo()->getConfig('settings_version') !== evo()->getVersionData('version')) { + // not while another manager holds the settings: their page would alert and go back here, and loop + if (evo()->hasPermission('settings') && evo()->getConfig('settings_version') !== evo()->getVersionData('version') + && !\EvolutionCMS\Models\ActiveUser::locked(17)->exists()) { // seems to be a new install - send the user to the configuration page exit(''); } From ad363bd94beda9b089e04592396a76e81813fe07 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 2 Nov 2026 12:14:55 +0100 Subject: [PATCH 7/7] fix(manager): replace Font Awesome leftovers in tree drop marker, plugins and locales 3.6 ships no Font Awesome, so these rendered nothing: - the tree drop marker arrow merged in from 3.5.x (move icon while dragging) - Updater and OutdatedExtrasCheck widget, popup and button icons - the theme mode icon and icon class example of the et, hy, lt, lv, ro and uz lexicons Adds circle-arrow-right, arrow-right and git-fork to the generated SVG icons. The icon asset test now fails on Font Awesome fonts or glyphs in manager CSS and on Font Awesome classes in bundled plugins. Co-Authored-By: Claude Opus 5.5 --- .../extrascheck/CheckOutdated.class.php | 16 +++++------ .../OutdatedExtrasCheck.plugin.php | 2 +- assets/plugins/updater/plugin.updater.php | 28 +++++++++---------- core/lang/et/global.php | 4 +-- core/lang/hy/global.php | 4 +-- core/lang/lt/global.php | 4 +-- core/lang/lv/global.php | 4 +-- core/lang/ro/global.php | 4 +-- core/lang/uz/global.php | 4 +-- .../Unit/Manager/ManagerIconAssetsTest.php | 26 ++++++++++++++++- manager/media/style/common/icons/icons.json | 3 ++ .../style/common/icons/manager-icons.css | 6 ++++ manager/media/style/default/css/tree.css | 2 +- 13 files changed, 70 insertions(+), 37 deletions(-) diff --git a/assets/plugins/extrascheck/CheckOutdated.class.php b/assets/plugins/extrascheck/CheckOutdated.class.php index 038f54931a..e7166e23b3 100644 --- a/assets/plugins/extrascheck/CheckOutdated.class.php +++ b/assets/plugins/extrascheck/CheckOutdated.class.php @@ -73,7 +73,7 @@ public function snippet($name, $options) if (!is_null($snippet)) { $tpl = isset($options['template']) ? $options['template'] : '@CODE:
' . - ' ' . + ' ' . '[+snippet+] [+name+] (version [+currentVersion+]) ' . ' [+isSecurity:empty:then=`[+isoutdated+] Evolution [+evo_cms_version+]`:else=`[+security_reason+].`+]' . '
[+please_update+] [+name+] [+to_latest+]' . @@ -124,7 +124,7 @@ public function plugin($name, $options) if (!is_null($plugin)) { $tpl = isset($options['template']) ? $options['template'] : '@CODE:
' . - ' ' . + ' ' . '[+plugin+] [+name+] (version [+currentVersion+]) ' . ' [+isSecurity:empty:then=`[+isoutdated+] Evolution [+evo_cms_version+]`:else=`[+security_reason+].`+]' . '
[+please_update+] [+name+] [+to_latest+]' . @@ -174,7 +174,7 @@ public function module($name, $options) if (!is_null($module)) { $tpl = isset($options['template']) ? $options['template'] : '@CODE:
' . - ' ' . + ' ' . '[+lock_element_type_6+] [+name+] (version [+currentVersion+]) ' . ' [+isSecurity:empty:then=`[+isoutdated+] Evolution [+evo_cms_version+]`:else=`[+security_reason+].`+]' . '
[+please_update+] [+name+] [+to_latest+]' . @@ -224,7 +224,7 @@ public function file($path, $options = []) $tpl = isset($options['template']) ? $options['template'] : '@CODE:
' . - ' ' . + ' ' . '[+path+] ' . ' [+isSecurity:empty:then=`[+isoutdated+] Evolution [+evo_cms_version+]`:else=`[+security_reason+].`+]' . '
[+please_delete+]. [+if_dont_use+]' . @@ -249,7 +249,7 @@ public function theme($theme, $options = []) $tpl = isset($options['template']) ? $options['template'] : '@CODE:
' . - ' ' . + ' ' . '[+manager_theme+] [+theme+] ' . '[+isSecurity:empty:then=`[+isoutdated+] Evolution [+evo_cms_version+]`:else=`- [+security_reason+].`+]' . '
[+please_delete+] [+from_folder+] [+path+]. [+if_dont_use+]' . @@ -279,7 +279,7 @@ public function rss($key, $options) if (isset($options['old']) && $this->modx->getConfig($key) === $options['old']) { $tpl = isset($options['template']) ? $options['template'] : '@CODE:
' . - ' ' . + ' ' . '[+settings_config+] > [+' . $key . '_title+] ' . '([+oldUrl+]) [+outdated+]. ' . '[+please_download_and_install+] UpdateEvosRss [+from+]' . @@ -364,7 +364,7 @@ public function makeConfigButton($title) $popup = [ 'url' => EVO_MANAGER_URL . '?a=102&id=' . $this->getPluginId() . '&tab=1', 'title1' => $title, - 'icon' => 'fa-cog', + 'icon' => 'evo-icon-settings', 'iframe' => 'iframe', 'selector2' => '#tabConfig', 'position' => 'center', @@ -385,7 +385,7 @@ public function makeConfigButton($title) str_replace('"', "'", stripslashes(json_encode($popup))) . ')" ' . '>' . - ' ' . + ' ' . ''; } diff --git a/assets/plugins/extrascheck/OutdatedExtrasCheck.plugin.php b/assets/plugins/extrascheck/OutdatedExtrasCheck.plugin.php index 7fb3bbd2fa..b8ec329677 100644 --- a/assets/plugins/extrascheck/OutdatedExtrasCheck.plugin.php +++ b/assets/plugins/extrascheck/OutdatedExtrasCheck.plugin.php @@ -53,7 +53,7 @@ 'cols' => 'col-lg-12', 'headAttr' => 'style="color:#E84B39;"', 'bodyAttr' => '', - 'icon' => 'fa-warning', + 'icon' => 'evo-icon-alert-triangle', 'title' => '' . $wdgTitle . ' ' . $button . '', 'body' => '
' . $out . '
', 'hide' => '0' diff --git a/assets/plugins/updater/plugin.updater.php b/assets/plugins/updater/plugin.updater.php index f7064422c5..8c878e4ef3 100644 --- a/assets/plugins/updater/plugin.updater.php +++ b/assets/plugins/updater/plugin.updater.php @@ -891,7 +891,7 @@ function poll() { 'menuindex' => '1', 'id' => 'updater', 'cols' => 'col-sm-12', - 'icon' => 'fa-exclamation-triangle', + 'icon' => 'evo-icon-alert-triangle', 'title' => $_lang['system_update'], 'body' => $output ]; @@ -952,7 +952,7 @@ function poll() { 'menuindex' => '1000', 'id' => 'updater', 'cols' => 'col-sm-12', - 'icon' => 'fa-exclamation-triangle', + 'icon' => 'evo-icon-alert-triangle', 'title' => $_lang['system_update'], 'body' => $output ]; @@ -1160,14 +1160,14 @@ function poll() { . '
'; $supportButtonHtml = '' - . ' ' . htmlspecialchars($_lang['updater_action_support'], ENT_QUOTES, 'UTF-8') . ''; + . ' ' . htmlspecialchars($_lang['updater_action_support'], ENT_QUOTES, 'UTF-8') . ''; $releaseButtonsHtml = ''; @@ -1180,14 +1180,14 @@ function poll() { $safeCliCommand = htmlspecialchars($cliCommand, ENT_QUOTES, 'UTF-8'); $branchModeNoticeHtml = ''; if ($isLineMode) { - $branchModeNoticeHtml = '

' + $branchModeNoticeHtml = '

' . htmlspecialchars(sprintf( updaterLang($_lang, 'updater_line_mode_notice', 'This site follows %s, which has new commits. To return to a release, run php artisan make:site update in the core folder.'), $version === 'evolution-cms/evolution' ? $latestVersionRaw : $version . ' ' . $latestVersionRaw ), ENT_QUOTES, 'UTF-8') . '

'; } elseif ($isBranchMode) { - $branchModeNoticeHtml = '

' + $branchModeNoticeHtml = '

' . htmlspecialchars(updaterLang($_lang, 'updater_branch_mode_notice', 'Development branch/ref update mode is enabled. Use it only for testing or controlled maintenance.'), ENT_QUOTES, 'UTF-8') . '

'; } @@ -1199,7 +1199,7 @@ function poll() { if ($canShowUpdateActions) { $primaryUpdateButtonHtml = '' - . ' ' . htmlspecialchars(updaterLang($_lang, 'updater_cli_summary', 'Manual update (CLI)'), ENT_QUOTES, 'UTF-8') + . ' ' . htmlspecialchars(updaterLang($_lang, 'updater_cli_summary', 'Manual update (CLI)'), ENT_QUOTES, 'UTF-8') . ''; $cliPanelHtml = '