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: = (int) ini_get('max_file_uploads') ?>")
+ ->and($source)->toContain('postLimit: = (int) $uploadPostLimit ?>')
+ ->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;
+