diff --git a/core/functions/actions/files.php b/core/functions/actions/files.php index 1e01f1da11..dd46c7985e 100644 --- a/core/functions/actions/files.php +++ b/core/functions/actions/files.php @@ -1908,6 +1908,27 @@ function rrmdir($dir) } } +if (!function_exists('fileManagerUploadSummary')) { + /** + * The head of an upload report: how many of the files the user picked were stored, and how many never + * reached PHP, which drops every file past max_file_uploads without a trace. + */ + function fileManagerUploadSummary(array $lang, int $selected, int $received, int $uploaded, int $maxFileUploads): string + { + $total = max($selected, $received); + $text = static fn (string $key, array $values): string => htmlspecialchars(vsprintf($lang[$key] ?? $key, $values), ENT_QUOTES, 'UTF-8'); + $html = '

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

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

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

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

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

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

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

' . $_lang['files_filetype_notok'] . '

'; + $fileMsg .= '

' . $_lang['files_filetype_notok'] . '

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

' . $_lang['files_access_denied'] . '

'; + $fileMsg .= '

' . $_lang['files_access_denied'] . '

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

' . $_lang['files_upload_ok'] . '


'; + $fileMsg .= '

' . $_lang['files_upload_ok'] . '


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

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

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

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

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

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

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

' + batch.join() + '

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

0,1

', '

2

']); + assert.deepEqual(result.notSent, []); +}); + +test('a failed request stops the queue and names every file not sent', async () => { + let calls = 0; + const send = (batch, token) => { + calls++; + if (calls === 2) return Promise.reject(new Error('HTTP 500')); + return Promise.resolve({ report: 'ok', uploaded: batch.length, token: 'next' }); + }; + + const result = await queue.run([[0, 1], [2, 3], [4]], 't', send); + + assert.equal(calls, 2); + assert.equal(result.uploaded, 2); + assert.deepEqual(result.notSent, [2, 3, 4]); + assert.equal(result.reason, 'HTTP 500'); +}); + +test('an answer without a token for the next request stops the queue after it', async () => { + const send = (batch) => Promise.resolve({ report: 'ok', uploaded: batch.length, token: '' }); + + const result = await queue.run([[0], [1]], 't', send); + + assert.equal(result.uploaded, 1); + assert.deepEqual(result.notSent, [1]); +}); + +test('messages are formatted and file names escaped', () => { + assert.equal(queue.format('Uploaded %1$s of %2$s files.', [20, 30]), 'Uploaded 20 of 30 files.'); + assert.equal(queue.escapeHtml('&\''), '<img src=x onerror="a">&''); +}); diff --git a/manager/media/script/tree-drop-helper.js b/manager/media/script/tree-drop-helper.js index 94cfef4ed1..f359bd4dbe 100644 --- a/manager/media/script/tree-drop-helper.js +++ b/manager/media/script/tree-drop-helper.js @@ -9,6 +9,31 @@ }(typeof globalThis !== 'undefined' ? globalThis : this, function () { 'use strict'; + // One effect for every allowed position; a drop moves the resource, so no "+" copy cursor. + var DROP_EFFECT = 'move'; + + // A pointer exactly on a boundary takes the lower zone. Without "into" (a deleted target) + // the row splits into halves. + function getDropZone(offsetY, height, canDropInto) { + if (canDropInto === false) { + return offsetY < height / 2 ? 'before' : 'after'; + } + + if (offsetY >= height / 1.51) { + return 'after'; + } + + if (offsetY < height / 3) { + return 'before'; + } + + return 'into'; + } + + function isTreeNode(element) { + return !!element && element.nodeType === 1 && /^node\d+$/.test(element.id || ''); + } + function getFolderDropContainer(targetAnchor) { if (!targetAnchor) { return null; @@ -23,6 +48,33 @@ return container; } + // first child node of an expanded folder; null when collapsed or empty + function getFirstChildNode(targetAnchor) { + var container = getFolderDropContainer(targetAnchor); + var children = container ? container.children || [] : []; + + for (var i = 0; i < children.length; i++) { + if (isTreeNode(children[i])) { + return children[i]; + } + } + + return null; + } + + // resource ids in tree order; skips placeholders such as the "empty folder" row + function collectMenuIndex(children) { + var ids = []; + + for (var i = 0; i < (children ? children.length : 0); i++) { + if (isTreeNode(children[i])) { + ids.push(children[i].id.substr(4)); + } + } + + return ids; + } + function moveNodeIntoFolder(targetAnchor, draggedNode) { var container = getFolderDropContainer(targetAnchor); @@ -46,7 +98,12 @@ } return { + DROP_EFFECT: DROP_EFFECT, + getDropZone: getDropZone, + isTreeNode: isTreeNode, getFolderDropContainer: getFolderDropContainer, + getFirstChildNode: getFirstChildNode, + collectMenuIndex: collectMenuIndex, moveNodeIntoFolder: moveNodeIntoFolder }; })); diff --git a/manager/media/script/upload-queue.js b/manager/media/script/upload-queue.js new file mode 100644 index 0000000000..ec7ffaa95d --- /dev/null +++ b/manager/media/script/upload-queue.js @@ -0,0 +1,111 @@ +(function (root, factory) { + var exported = factory(); + + if (typeof module === 'object' && module.exports) { + module.exports = exported; + } + + root.evoUploadQueue = exported; +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + // room in a request for the other form fields and the multipart framing + var REQUEST_OVERHEAD = 16384; + var PART_OVERHEAD = 512; + + /** + * Splits the chosen files into requests PHP takes whole: PHP drops every file past max_file_uploads + * and the whole request past post_max_size, both without telling anyone. sizes are in bytes, in the + * order chosen; a limit of 0 means none. A file over fileLimit, or too large for a request of its own, + * goes to skipped. Returns indexes into sizes. + */ + function plan(sizes, maxFiles, postLimit, fileLimit) { + var budget = postLimit > 0 ? postLimit - REQUEST_OVERHEAD : 0; + var batches = []; + var skipped = []; + var current = []; + var bytes = 0; + + for (var i = 0; i < sizes.length; i++) { + var cost = sizes[i] + PART_OVERHEAD; + if ((fileLimit > 0 && sizes[i] > fileLimit) || (budget > 0 && cost > budget)) { + skipped.push(i); + continue; + } + if (current.length && ((maxFiles > 0 && current.length >= maxFiles) || (budget > 0 && bytes + cost > budget))) { + batches.push(current); + current = []; + bytes = 0; + } + current.push(i); + bytes += cost; + } + if (current.length) { + batches.push(current); + } + + return { batches: batches, skipped: skipped }; + } + + /** + * Sends the batches one after another; each answer carries the single-use token of the next request. + * send(batch, token) resolves to { report, uploaded, total, token } and rejects with a reason. + * After a failed request the token chain is broken, so that batch and all later ones are not sent. + */ + function run(batches, token, send, onProgress) { + var result = { reports: [], uploaded: 0, notSent: [], reason: '' }; + var index = 0; + + function next() { + if (index >= batches.length) { + return Promise.resolve(result); + } + var batch = batches[index]; + if (onProgress) { + onProgress(index, batches.length); + } + return Promise.resolve() + .then(function () { + return send(batch, token); + }) + .then(function (answer) { + result.reports.push(answer.report); + result.uploaded += answer.uploaded; + token = answer.token; + index++; + if (!token && index < batches.length) { + throw new Error('no token for the next request'); + } + return next(); + }) + .catch(function (error) { + result.reason = error && error.message ? error.message : String(error); + for (var i = index; i < batches.length; i++) { + result.notSent = result.notSent.concat(batches[i]); + } + return result; + }); + } + + return next(); + } + + function format(text, values) { + return String(text).replace(/%(\d)\$s/g, function (match, n) { + return values[n - 1]; + }); + } + + function escapeHtml(text) { + return String(text).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + return { + plan: plan, + run: run, + format: format, + escapeHtml: escapeHtml + }; +})); diff --git a/manager/media/style/default/css/tree.css b/manager/media/style/default/css/tree.css index 3aceb5223e..9cb0a7258b 100755 --- a/manager/media/style/default/css/tree.css +++ b/manager/media/style/default/css/tree.css @@ -31,9 +31,6 @@ #treeRoot a .editResource { margin-left: 0.2em } #treeRoot .indent { position: relative; z-index: -1; float: left; height: 1.5em; } #treeRoot .indent i { float: left; height: 1.2em; width: 1.5em } -#treeRoot .dragafter > a .indent, #treeRoot .dragbefore > a .indent { z-index: 1; } -#treeRoot .dragafter > a .indent::after, #treeRoot .dragbefore > a .indent::before { content: "\f0da"; display: block; position: absolute; right: 0.1em; bottom: -0.5em; height: 1em; font: bold 1em/1em 'Font Awesome 5 Free'; color: #f04 } -#treeRoot .dragbefore > a .indent::before { bottom: auto; top: -0.5em; } #treeRoot .empty { color: #aaa; cursor: default; } #treeRoot .icon { cursor: pointer; -webkit-transition-duration: 0.15s; transition-duration: 0.15s } #treeRoot .icon:hover > .fa { color: #000 } @@ -61,10 +58,13 @@ .treeRoot a .editResource { margin-left: 0.2em } .treeRoot .indent { position: relative; z-index: -1; float: left; height: 1.5em; } .treeRoot .indent i { float: left; height: 1.2em; width: 1.5em } -.treeRoot .dragafter > a .indent, .treeRoot .dragbefore > a .indent { z-index: 1; } -.treeRoot .dragafter > a .indent::after, .treeRoot .dragbefore > a .indent::before { content: "\f0da"; display: block; position: absolute; right: 0.1em; bottom: -0.5em; height: 1em; font: bold 1em/1em 'Font Awesome 5 Free'; color: #f04 } -.treeRoot .dragbefore > a .indent::before { bottom: auto; top: -0.5em; } .treeRoot .empty { color: #aaa; cursor: default; } +/* drop marker: line on the edge the resource goes to, arrow centred on it (on the row middle for "into") */ +#treeDropMarker { display: none; position: absolute; z-index: 2; right: 0; height: 0; margin-left: -1.55em; pointer-events: none; } +#treeDropMarker.visible { display: block; } +#treeDropMarker.line { margin-top: -1px; border-top: 2px solid #22a447; } +#treeDropMarker::before { content: "\f0a9"; position: absolute; left: -1em; top: -0.5em; width: 1em; height: 1em; font: 900 0.875em/1em 'Font Awesome 5 Free'; text-align: center; color: #22a447; } +#treeDropMarker.line::before { margin-top: -1px; } .treeRoot .icon { cursor: pointer; -webkit-transition-duration: 0.15s; transition-duration: 0.15s } .treeRoot .icon:hover > .fa { color: #000 } .treeRoot .toggle { float: left; margin: 1px 0.2em 1px -1.5em; width: 1.3em; height: 1.3em; line-height: 1.4em; text-align: center; vertical-align: top; cursor: pointer; border-radius: 50%; -webkit-transition-duration: 0.15s; transition-duration: 0.15s } @@ -113,6 +113,8 @@ .dark .treeRoot a:hover:not(.empty), .dark .treeRoot a.current, .darkness .treeRoot a:hover:not(.empty), .darkness .treeRoot a.current { background-color: rgba(255, 255, 255, 0.15); } .dark .treeRoot a.selected, .dark .treeRoot .dragenter > a, .darkness .treeRoot a.selected, .darkness.treeRoot .dragenter > a { background-color: rgba(0, 142, 255, 0.19); } .dark .treeRoot .dragafter > a::after, .dark .treeRoot .dragbefore > a::before, .darkness .treeRoot .dragafter > a::after, .darkness .treeRoot .dragbefore > a::before { background-color: rgba(255, 255, 255, 0.25); } +.dark #treeDropMarker, .darkness #treeDropMarker { border-color: #4cd07d; } +.dark #treeDropMarker::before, .darkness #treeDropMarker::before { color: #4cd07d; } .dark .treeRoot a .title, .darkness .treeRoot a .title { color: #7cb2dc; } .dark .treeRoot a.hidemenu .title, .darkness .treeRoot a.hidemenu .title { color: #c5c5c5; } .dark .treeRoot a.deleted .title, .darkness .treeRoot a.deleted .title { color: #a52a2a; } diff --git a/manager/media/style/default/js/evo.js b/manager/media/style/default/js/evo.js index d2d8909fb4..01d9e29539 100644 --- a/manager/media/style/default/js/evo.js +++ b/manager/media/style/default/js/evo.js @@ -175,7 +175,9 @@ this.moduleViewport.init(); this.search.init(); if (this.config.session_timeout > 0) { - w.setInterval(this.keepMeAlive, 1000 * 60 * this.config.session_timeout); + // twice per timeout: a session (and its resource locks) expires once its last hit is a full timeout old, + // and browsers run timers of background tabs late + w.setInterval(this.keepMeAlive, 1000 * 60 * this.config.session_timeout / 2); } d.addEventListener('click', this.hideDropDown, false); if (evo.config.global_tabs) { @@ -1075,11 +1077,8 @@ var el = els[i]; el.onmousedown = this.onmousedown; el.ondragstart = this.ondragstart; - el.ondragenter = this.ondragenter; - el.ondragover = this.ondragover; - el.ondragleave = this.ondragleave; - el.ondrop = this.ondrop; } + this.initDropTargets(); } }, onmousedown: function (e) { @@ -1102,6 +1101,7 @@ if (draggable) { this.parentNode.draggable = true; this.parentNode.ondragstart = evo.tree.ondragstart; + this.parentNode.ondragend = evo.tree.ondragend; } else { this.parentNode.draggable = false; this.parentNode.ondragstart = function () { @@ -1111,10 +1111,15 @@ } }, ondragstart: function (e) { - e.dataTransfer.effectAllowed = 'all'; - e.dataTransfer.dropEffect = 'all'; + // set only while a tree node is dragged, so files dragged in from outside move nothing + evo.tree.dragId = this.id.substr(4); + e.dataTransfer.effectAllowed = modxTreeDropHelper.DROP_EFFECT; e.dataTransfer.setData('text', this.id.substr(4)); }, + ondragend: function () { + evo.tree.dragId = null; + evo.tree.clearDropTarget(); + }, isBlockedDropTarget: function (target) { return !!(w.modxTreeDropGuardHelper && !w.modxTreeDropGuardHelper.canDropIntoTarget(target)); }, @@ -1122,148 +1127,238 @@ isBlockedParentTarget: function (node) { return !!(w.modxTreeParentGuardHelper && w.modxTreeParentGuardHelper.isBlockedParentTarget(node)); }, - ondragenter: function (e) { - if ( - d.getElementById('node' + evo.tree.itemToChange) === (this.parentNode.closest('#node' + evo.tree.itemToChange) || this.parentNode) - || evo.tree.isBlockedDropTarget(this) - ) { - this.parentNode.className = ''; - e.dataTransfer.effectAllowed = 'none'; - e.dataTransfer.dropEffect = 'none'; - evo.tree.drag = false; - } else { - this.parentNode.className = 'dragenter'; - e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.dropEffect = 'copy'; - evo.tree.drag = true; + setNodeLevel: function (node, level) { + node.firstChild.querySelector('.indent').innerHTML = ''.repeat(Math.max(0, level)); + }, + // parent id of the folder (or tree root) that holds the node of this anchor + parentIdOf: function (anchor) { + var holder = anchor.parentNode.parentNode; + if (/node/.test(holder.parentNode.id)) { + return parseInt(holder.parentNode.id.substr(4)); } - e.preventDefault(); + return /treeRoot/.test(holder.id) ? parseInt(holder.id.substr(8)) : 0; }, - ondragover: function (e) { - if (evo.tree.isBlockedDropTarget(this)) { - this.parentNode.className = ''; - e.dataTransfer.effectAllowed = 'none'; - e.dataTransfer.dropEffect = 'none'; - evo.tree.drag = false; - } else if (evo.tree.drag) { - var a = e.clientY; - var b = parseInt(this.getBoundingClientRect().top); - var c = a - b; - if (c > this.offsetHeight / 1.51) { - //this.parentNode.className = 'dragafter'; - this.parentNode.classList.add('dragafter'); - this.parentNode.classList.remove('dragbefore'); - this.parentNode.classList.remove('dragenter'); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.dropEffect = 'move'; - } else if (c < this.offsetHeight / 3) { - //this.parentNode.className = 'dragbefore'; - this.parentNode.classList.add('dragbefore'); - this.parentNode.classList.remove('dragafter'); - this.parentNode.classList.remove('dragenter'); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.dropEffect = 'move'; - } else { - //this.parentNode.className = 'dragenter'; - this.parentNode.classList.add('dragenter'); - this.parentNode.classList.remove('dragafter'); - this.parentNode.classList.remove('dragbefore'); - e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.dropEffect = 'copy'; + // One dragover / drop handler for the whole tree. Every place inside it maps to a position the + // resource can move to, so a drag never shows "no drop" over the tree: + // before / into / after a row (after an expanded folder's row = its first child) + // aftertree after a row and everything below it + // end the end of the tree root (node null) + initDropTargets: function () { + var holder = d.getElementById('treeHolder'); + if (!holder || holder.dataset.dropTargets) { + return; + } + holder.dataset.dropTargets = '1'; + var ondrag = function (e) { + var dragged = evo.tree.dragId && d.getElementById('node' + evo.tree.dragId); + if (!dragged) { + return; } + e.preventDefault(); + evo.tree.cancelDropTargetClear(); + var target = evo.tree.resolveDropTarget(e, dragged); + if (target) { + evo.tree.setDropTarget(target.node, target.zone); + } + // elsewhere in the tree (plugin output, the loader) the last position stays + e.dataTransfer.dropEffect = evo.tree.dropZone ? modxTreeDropHelper.DROP_EFFECT : 'none'; + }; + holder.addEventListener('dragenter', ondrag); + holder.addEventListener('dragover', ondrag); + holder.addEventListener('dragleave', function (e) { + if (e.relatedTarget && holder.contains(e.relatedTarget)) { + return; + } + // relatedTarget is null when leaving for the main frame, and in some browsers on every move, + // so the marker goes only if no dragover inside the tree follows + evo.tree.scheduleDropTargetClear(e.relatedTarget ? 0 : 400); + }); + holder.addEventListener('drop', function (e) { + if (evo.tree.dragId) { + e.preventDefault(); + evo.tree.dropOnTarget(); + } + }); + }, + resolveDropTarget: function (e, dragged) { + var holder = d.getElementById('treeHolder'); + var root = d.getElementById('treeRoot0'); + var element = e.target.nodeType === 1 ? e.target : e.target.parentNode; + var firstRootNode = root ? root.querySelector(':scope > [id^="node"]') : null; + if (element === holder) { + return firstRootNode ? { node: null, zone: 'end' } : null; + } + if (element.closest('#node0')) { + // the site root row: first place at the root + return firstRootNode ? evo.tree.positionOn(firstRootNode.firstChild, 0, dragged) : null; + } + var anchor = element.closest('.treeRoot a'); + if (!anchor) { + return null; + } + if (anchor.classList.contains('empty')) { + // the "empty folder" row: into that folder + var folder = anchor.parentNode.parentNode.previousElementSibling; + return folder ? evo.tree.positionOn(folder, null, dragged) : null; + } + return evo.tree.positionOn(anchor, e.clientY - anchor.getBoundingClientRect().top, dragged); + }, + // offset null = the middle of the row + positionOn: function (anchor, offset, dragged) { + var node = anchor.parentNode; + var height = anchor.getBoundingClientRect().height; + if (offset === null) { + offset = height / 2; + } + if (dragged.contains(node)) { + // its own row or one below it: before or after itself, where it already is + return { node: dragged, zone: node === dragged && offset < height / 2 ? 'before' : 'aftertree' }; + } + // nothing goes into a deleted resource, but next to it is fine + var canDropInto = !evo.tree.isBlockedDropTarget(anchor); + var zone = modxTreeDropHelper.getDropZone(offset, height, canDropInto); + return { node: node, zone: canDropInto || zone === 'before' ? zone : 'aftertree' }; + }, + setDropTarget: function (node, zone) { + var prev = evo.tree.dropNode; + var root = d.getElementById('treeRoot0'); + var cls = { before: 'dragbefore', into: 'dragenter', after: 'dragafter', aftertree: 'dragafter' }[zone]; + if (prev && prev !== node) { + prev.classList.remove('dragbefore', 'dragenter', 'dragafter'); + } + evo.tree.dropNode = node; + evo.tree.dropZone = zone; + if (node && !node.classList.contains(cls)) { + node.classList.remove('dragbefore', 'dragenter', 'dragafter'); + node.classList.add(cls); + } + if (root) { + root.classList.toggle('dragend', !node); + } + + var anchor = node ? node.firstChild : root.querySelector(':scope > [id^="node"] > a'); + var rect = anchor.getBoundingClientRect(); + var indent = anchor; + var y = rect.bottom; + if (zone === 'end') { + y = root.getBoundingClientRect().bottom; + } else if (zone === 'before') { + y = rect.top; + } else if (zone === 'into') { + y = rect.top + rect.height / 2; + } else if (zone === 'aftertree') { + y = node.getBoundingClientRect().bottom; } else { - e.dataTransfer.effectAllowed = 'none'; - e.dataTransfer.dropEffect = 'none'; - evo.tree.drag = false; + // the first child's level, which is where the resource goes + indent = modxTreeDropHelper.getFirstChildNode(anchor) || node; + indent = indent.firstChild || indent; } - e.preventDefault(); + evo.tree.showDropMarker(indent.querySelector('.indent'), y, zone !== 'into'); }, - ondragleave: function (e) { - this.parentNode.className = ''; - this.parentNode.removeAttribute('draggable'); - e.preventDefault(); + clearDropTarget: function () { + var root = d.getElementById('treeRoot0'); + var marker = d.getElementById('treeDropMarker'); + evo.tree.cancelDropTargetClear(); + if (evo.tree.dropNode) { + evo.tree.dropNode.classList.remove('dragbefore', 'dragenter', 'dragafter'); + } + evo.tree.dropNode = null; + evo.tree.dropZone = null; + if (root) { + root.classList.remove('dragend'); + } + if (marker) { + marker.className = ''; + } }, - ondrop: function (e) { - if (evo.tree.isBlockedDropTarget(this)) { - this.parentNode.removeAttribute('class'); - this.parentNode.removeAttribute('draggable'); - evo.alert(evo.lang.error_parent_deleted); - evo.tree.restoreTree(); - e.preventDefault(); + scheduleDropTargetClear: function (delay) { + evo.tree.cancelDropTargetClear(); + evo.tree.dropClearTimer = setTimeout(evo.tree.clearDropTarget, delay); + }, + cancelDropTargetClear: function () { + clearTimeout(evo.tree.dropClearTimer); + }, + // one marker over the tree: a row clips its own overflow, so a marker drawn inside it cannot sit on its edge + showDropMarker: function (indent, y, line) { + var holder = d.getElementById('treeHolder'); + var marker = d.getElementById('treeDropMarker'); + if (!holder || !indent) { return; } - - let el = d.getElementById('node' + evo.tree.itemToChange); - let els = null; - let id = evo.tree.itemToChange; - let parent = 0; - let menuindex = []; - let level = 0; - let indent = el.firstChild.querySelector('.indent'); - let i = 0; - indent.innerHTML = ''; - el.removeAttribute('draggable'); - if (this.parentNode.classList.contains('dragenter')) { - parent = parseInt(this.parentNode.id.substr(4)); - level = parseInt(this.dataset.level) + 1; - for (i = 0; i < level; i++) { - indent.innerHTML += ''; - } - if (this.nextSibling) { - const dropResult = modxTreeDropHelper.moveNodeIntoFolder(this, el); - els = dropResult.children; - for (i = 0; i < els.length; i++) { - menuindex[i] = els[i].id.substr(4); - } - } else { - el.parentNode.removeChild(el); - d.querySelector('#node' + parent + ' .icon').innerHTML = parseInt(this.dataset.private) ? evo.style.tree_folder_secure : evo.style.tree_folder; - } - evo.tree.ondragupdate(this, id, parent, menuindex); + if (!marker) { + marker = d.createElement('div'); + marker.id = 'treeDropMarker'; + holder.appendChild(marker); } - if (this.parentNode.classList.contains('dragafter')) { - parent = /node/.test(this.parentNode.parentNode.parentNode.id) ? parseInt(this.parentNode.parentNode.parentNode.id.substr(4)) : 0; - if (parent == 0) { - parent = /treeRoot/.test(this.parentNode.parentNode.id) ? parseInt(this.parentNode.parentNode.id.substr(8)) : 0; - } - level = parseInt(this.dataset.level); - for (i = 0; i < level; i++) { - indent.innerHTML += ''; - } - this.parentNode.parentNode.insertBefore(el, this.parentNode.nextSibling); - els = this.parentNode.parentNode.children; - for (i = 0; i < els.length; i++) { - menuindex[i] = els[i].id.substr(4); - } - evo.tree.ondragupdate(this, id, parent, menuindex); + var box = holder.getBoundingClientRect(); + marker.style.top = Math.round(y - box.top + holder.scrollTop) + 'px'; + marker.style.left = Math.round(indent.getBoundingClientRect().right - box.left + holder.scrollLeft) + 'px'; + marker.className = line ? 'visible line' : 'visible'; + }, + dropOnTarget: function () { + var node = evo.tree.dropNode; + var zone = evo.tree.dropZone; + var id = evo.tree.dragId; + var el = id && d.getElementById('node' + id); + var root = d.getElementById('treeRoot0'); + evo.tree.clearDropTarget(); + evo.tree.dragId = null; + // before or after itself leaves it where it is + if (!el || !zone || node === el) { + return; } - if (this.parentNode.classList.contains('dragbefore')) { - parent = /node/.test(this.parentNode.parentNode.parentNode.id) ? parseInt(this.parentNode.parentNode.parentNode.id.substr(4)) : 0; - if (parent == 0) { - parent = /treeRoot/.test(this.parentNode.parentNode.id) ? parseInt(this.parentNode.parentNode.id.substr(8)) : 0; - } - level = parseInt(this.dataset.level); - for (i = 0; i < level; i++) { - indent.innerHTML += ''; - } - this.parentNode.parentNode.insertBefore(el, this.parentNode); - els = this.parentNode.parentNode.children; - for (i = 0; i < els.length; i++) { - menuindex[i] = els[i].id.substr(4); - } - evo.tree.ondragupdate(this, id, parent, menuindex); + // checked before the tree changes: a drop into a document takes the row out of the page + if (el.firstChild.dataset.nomove == 1) { + alert(evo.lang.error_no_privileges); + return; } - this.parentNode.removeAttribute('class'); - this.parentNode.removeAttribute('draggable'); - e.preventDefault(); + el.removeAttribute('draggable'); + + if (zone === 'end') { + evo.tree.setNodeLevel(el, 0); + root.appendChild(el); + evo.tree.ondragupdate(null, id, 0, modxTreeDropHelper.collectMenuIndex(root.children)); + return; + } + + var anchor = node.firstChild; + var parent = evo.tree.parentIdOf(anchor); + var level = parseInt(anchor.dataset.level); + var siblings = node.parentNode; + var firstChild = zone === 'after' ? modxTreeDropHelper.getFirstChildNode(anchor) : null; + if (zone === 'into' || firstChild) { + parent = parseInt(node.id.substr(4)); + level++; + // the tree reloads after the move: show the target opened, with the resource in it + evo.openedArray[parent] = 1; + } + evo.tree.setNodeLevel(el, level); + + if (firstChild) { + siblings = firstChild.parentNode; + siblings.insertBefore(el, firstChild); + } else if (zone === 'after' || zone === 'aftertree') { + siblings.insertBefore(el, node.nextSibling); + } else if (zone === 'before') { + siblings.insertBefore(el, node); + } else if (anchor.nextSibling) { + siblings = modxTreeDropHelper.moveNodeIntoFolder(anchor, el).container; + } else { + siblings = null; + el.parentNode.removeChild(el); + node.querySelector('.icon').innerHTML = parseInt(anchor.dataset.private) ? evo.style.tree_folder_secure : evo.style.tree_folder; + } + evo.tree.ondragupdate(anchor, id, parent, siblings ? modxTreeDropHelper.collectMenuIndex(siblings.children) : []); }, ondragupdate: function (a, id, parent, menuindex) { - if (d.querySelector('#node' + id + ' > a').dataset.nomove == 1) { + var moved = d.querySelector('#node' + id + ' > a'); + if (moved && moved.dataset.nomove == 1) { alert(evo.lang.error_no_privileges); evo.tree.restoreTree(); return; } - let roles = a.dataset.roles + (!a.parentNode.parentNode.classList.contains('treeRoot') ? a.parentNode.parentNode.previousSibling.dataset.roles : ''); + // a is null for a drop on the tree root, which carries no roles + let roles = a ? a.dataset.roles + (!a.parentNode.parentNode.classList.contains('treeRoot') ? a.parentNode.parentNode.previousSibling.dataset.roles : '') : ''; let checked_group = false; if (roles != '') { checked_group = roles.split(',').map(Number).filter(function (role) { @@ -1284,7 +1379,7 @@ menuindex: menuindex }, function (r) { if (r && r.errors) evo.alert(r.errors); - evo.tree.restoreTree(); + evo.tree.restoreTree(true); }, 'json'); var b = evo.normalizeUrl(w.main.frameElement.contentWindow.location.href); if (evo.getActionFromUrl(b, 27) && parseInt(evo.main.getQueryVariable('id', b)) === parseInt(id)) { @@ -1758,15 +1853,18 @@ } this.setSelected(this.itemToChange); }, - restoreTree: function () { + // withFolderState: the reload also stores the folders opened here, in the same request (a separate + // saveFolderState() request can finish after it and leave the session with the old state) + restoreTree: function (withFolderState) { //console.log('evo.tree.restoreTree()'); let _this = this; + let folderState = withFolderState === true ? this.getFolderState() : ''; d.getElementById('treeloader').classList.add('visible'); d.querySelectorAll('.treeRoot').forEach(function (element) { _this.setItemToChange(); let rpcNode = d.getElementById(element.id); let parent = element.id.replace('treeRoot', ''); - evo.post(evo.EVO_MANAGER_URL + 'media/style/' + evo.config.theme + '/ajax.php', 'a=1&f=nodes&indent=1&parent=' + parent + '&expandAll=2&id=' + _this.itemToChange, function (r) { + evo.post(evo.EVO_MANAGER_URL + 'media/style/' + evo.config.theme + '/ajax.php', 'a=1&f=nodes&indent=1&parent=' + parent + '&expandAll=2&id=' + _this.itemToChange + folderState, function (r) { evo.tree.rpcLoadData(r, rpcNode); evo.tree.draggable(); }); diff --git a/manager/views/frame/1.blade.php b/manager/views/frame/1.blade.php index f6a28bfffd..4eab82a9b7 100644 --- a/manager/views/frame/1.blade.php +++ b/manager/views/frame/1.blade.php @@ -218,6 +218,7 @@ function jsIconMarkup($icon) { window.modx = evo; window.tree = evo.tree; +