'
. '
' . htmlspecialchars(updaterLang($_lang, 'updater_cli_intro', 'If you are updating manually, run this command in terminal:'), ENT_QUOTES, 'UTF-8') . '
'
@@ -1246,7 +1246,7 @@ function poll() {
'invalid_response' => updaterLang($_lang, 'updater_live_update_invalid_response', 'Manager returned an invalid update response.'),
]);
$managerUpdateButtonHtml = '
';
}
}
@@ -1256,7 +1256,7 @@ function poll() {
. '
'
. '
'
. '' . $safeCurrentWithDate . ''
- . ''
+ . ''
. '' . $safeLatestWithDate . ''
. '
'
. $releaseButtonsHtml
@@ -1265,14 +1265,14 @@ function poll() {
. '
'
. $branchModeNoticeHtml
- . '
'
+ . '
'
. htmlspecialchars($_lang['updater_notice_text_1'], ENT_QUOTES, 'UTF-8') . '
'
- . '
'
+ . '
'
. htmlspecialchars($_lang['updater_notice_text_2'], ENT_QUOTES, 'UTF-8')
. ''
. updaterBuildBackupWarningHtml($_lang)
. '
'
- . '
'
+ . '
'
. htmlspecialchars($_lang['updater_notice_text_3'], ENT_QUOTES, 'UTF-8') . '
'
. '
'
@@ -1293,7 +1293,7 @@ function poll() {
'menuindex' => '1',
'id' => 'updater',
'cols' => 'col-sm-12',
- 'icon' => 'fa-exclamation-triangle',
+ 'icon' => 'evo-icon-alert-triangle',
'title' => $_lang['updater_widget_title'],
'body' => $output
];
diff --git a/core/functions/actions/files.php b/core/functions/actions/files.php
index d68237c8a9..ffcc33bddf 100644
--- a/core/functions/actions/files.php
+++ b/core/functions/actions/files.php
@@ -1926,6 +1926,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
@@ -1963,10 +1984,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];
@@ -1989,11 +2016,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 . '
';
}
@@ -2002,7 +2029,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.
@@ -2011,14 +2038,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', [
@@ -2036,41 +2065,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 8de3c0d0f5..93f174219a 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 2ac2aa2046..8c7479d8c4 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 cf30f5bf05..4572913b1f 100644
--- a/core/lang/bg/global.php
+++ b/core/lang/bg/global.php
@@ -376,6 +376,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 b797c300e7..0e6902d2da 100644
--- a/core/lang/cs/global.php
+++ b/core/lang/cs/global.php
@@ -376,6 +376,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 9c24632a2d..3880b2d142 100644
--- a/core/lang/da/global.php
+++ b/core/lang/da/global.php
@@ -377,6 +377,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 2964d9c479..1679972ccd 100644
--- a/core/lang/de/global.php
+++ b/core/lang/de/global.php
@@ -394,6 +394,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 c4295e1281..8edd5933d5 100644
--- a/core/lang/en/global.php
+++ b/core/lang/en/global.php
@@ -413,6 +413,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 58210f0b5e..0149eea5a6 100644
--- a/core/lang/es/global.php
+++ b/core/lang/es/global.php
@@ -394,6 +394,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 4d62442115..53aeaf0953 100644
--- a/core/lang/et/global.php
+++ b/core/lang/et/global.php
@@ -413,6 +413,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:';
@@ -458,7 +465,7 @@
$_lang["htmlsnippet_name"] = 'HTML-ploki nimi';
$_lang["htmlsnippet_title"] = 'Loo/muuda HTML-plokki';
$_lang["icon"] = 'Ikoon';
-$_lang["icon_description"] = 'CSS-klassi väärtus, nt fa fa-star';
+$_lang["icon_description"] = 'CSS-klassi väärtus, nt evo-icon evo-icon-star';
$_lang["id"] = 'ID';
$_lang["illegal_parent_child"] = 'Ülemressursi määramine:\n\nRessurss on valitud ressursi alamressurss.';
$_lang["illegal_parent_self"] = 'Ülemressursi määramine:\n\nValitud ressurssi ei saa määrata iseenda ülemressursiks.';
@@ -563,7 +570,7 @@
$_lang["manager_theme_mode2"] = 'päis on tume';
$_lang["manager_theme_mode3"] = 'päis ja külgriba on tumedad';
$_lang["manager_theme_mode4"] = 'kõik on tume';
-$_lang['manager_theme_mode_message'] = 'Seda seadet kasutatakse vaikeväärtusena ja haldur saab selle muuta ressursipuus oleva kujunduse värvirežiimi lülitusnupuga:
';
+$_lang['manager_theme_mode_message'] = 'Seda seadet kasutatakse vaikeväärtusena ja haldur saab selle muuta ressursipuus oleva kujunduse värvirežiimi lülitusnupuga:
';
$_lang['manager_theme_mode_title'] = 'Kujunduse värvirežiimi lüliti';
$_lang["meta_keywords"] = 'META-märksõnad';
$_lang["metatag_intro"] = 'Sellel lehel saate META-silte kustutada, luua ja muuta. META-siltide sidumiseks ressurssidega avage ressursi muutmisel vahekaart
META-märksõnad ning valige soovitud META-sildid ja märksõnad. Uue sildi lisamiseks sisestage nimi ja väärtus ning klõpsake nuppu \'Lisa silt\'. Sildi muutmiseks klõpsake tabelis selle nime.';
diff --git a/core/lang/fa/global.php b/core/lang/fa/global.php
index 6df87e1347..332144dcbf 100644
--- a/core/lang/fa/global.php
+++ b/core/lang/fa/global.php
@@ -377,6 +377,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 746d819611..53155a561c 100644
--- a/core/lang/fi/global.php
+++ b/core/lang/fi/global.php
@@ -376,6 +376,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 c3b0954f70..44c31fa32f 100644
--- a/core/lang/fr/global.php
+++ b/core/lang/fr/global.php
@@ -379,6 +379,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 eb7effc4fe..b5a23d75c9 100644
--- a/core/lang/he/global.php
+++ b/core/lang/he/global.php
@@ -377,6 +377,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:';
@@ -740,7 +747,7 @@
$_lang["resource_opt_richtext_help"] = 'Leave this checked to use the rich text editor for editing documents. If your documents contain JavaScript and forms, uncheck this to edit in HTML mode and so that the editor doesn\'t screw your documents up.';
$_lang["resource_opt_show_menu"] = 'הצג בתפריט';
$_lang["resource_opt_show_menu_help"] = 'Select this option to show document inside a web menu. Please note that some Menu Builders might choose to ignore this option.';
-$_lang["resource_opt_trackvisit_help"] = 'Log each visitor\'s visit to this page';
+$_lang["resource_opt_trackvisit_help"] = 'תיעוד כל ביקור בדף זה';
$_lang["resource_overview"] = 'סקירת מסמך';
$_lang["resource_parent"] = 'אב המסמך';
$_lang["resource_parent_help"] = 'Click on the icon above to enable (or disable) selecting this document\'s parent. Next, click a document in the tree to set its new parent.';
diff --git a/core/lang/hy/global.php b/core/lang/hy/global.php
index 818b3c3dce..8f1fb9f7f8 100644
--- a/core/lang/hy/global.php
+++ b/core/lang/hy/global.php
@@ -413,6 +413,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"] = 'Ընտրեք բեռնվող ֆայլը՝';
@@ -458,7 +465,7 @@
$_lang["htmlsnippet_name"] = 'Չանքի անուն';
$_lang["htmlsnippet_title"] = 'Ստեղծել/խմբագրել չանք';
$_lang["icon"] = 'Պատկերակ';
-$_lang["icon_description"] = 'CSS դասի արժեքը, օրինակ՝ fa fa-star';
+$_lang["icon_description"] = 'CSS դասի արժեքը, օրինակ՝ evo-icon evo-icon-star';
$_lang["id"] = 'ID';
$_lang["illegal_parent_child"] = 'Ծնողի նշանակում՝\n\nՌեսուրսն ընտրված ռեսուրսի զավակն է։';
$_lang["illegal_parent_self"] = 'Ծնողի նշանակում՝\n\nԸնտրված ռեսուրսը չի կարող նշանակվել որպես իր իսկ ծնող։';
@@ -563,7 +570,7 @@
$_lang["manager_theme_mode2"] = 'վերնամասը մուգ է';
$_lang["manager_theme_mode3"] = 'վերնամասն ու կողային վահանակը մուգ են';
$_lang["manager_theme_mode4"] = 'ամեն ինչ մուգ է';
-$_lang['manager_theme_mode_message'] = 'Այս կարգավորումն օգտագործվում է որպես «լռելյայն» և կարող է փոխարինվել կառավարչի կողմից՝ ռեսուրսների ծառում թեմայի գունային ռեժիմի փոխարկման կոճակն օգտագործելիս․
';
+$_lang['manager_theme_mode_message'] = 'Այս կարգավորումն օգտագործվում է որպես «լռելյայն» և կարող է փոխարինվել կառավարչի կողմից՝ ռեսուրսների ծառում թեմայի գունային ռեժիմի փոխարկման կոճակն օգտագործելիս․
';
$_lang['manager_theme_mode_title'] = 'Թեմայի գունային ռեժիմի փոխարկիչ';
$_lang["meta_keywords"] = 'META հիմնաբառեր';
$_lang["metatag_intro"] = 'Այս էջում կարող եք ջնջել, ստեղծել կամ խմբագրել META պիտակները։ META պիտակները ռեսուրսներին կապելու համար ռեսուրսը խմբագրելիս սեղմեք
META հիմնաբառեր ներդիրը և ընտրեք ցանկալի META պիտակներն ու հիմնաբառերը։ Նոր պիտակ ավելացնելու համար մուտքագրեք անունն ու արժեքը և սեղմեք «Ավելացնել պիտակ» կոճակը։ Պիտակը խմբագրելու համար սեղմեք տվյալների աղյուսակում դրա անվանը։';
diff --git a/core/lang/it/global.php b/core/lang/it/global.php
index 4a0ddc44f4..c12a8cf392 100644
--- a/core/lang/it/global.php
+++ b/core/lang/it/global.php
@@ -394,6 +394,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 35ed3cc131..8d4633ab14 100644
--- a/core/lang/ja/global.php
+++ b/core/lang/ja/global.php
@@ -404,6 +404,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"] = 'アップロードするファイルを選択';
@@ -816,7 +823,7 @@
$_lang["resource_opt_richtext_help"] = '通常の記事を書く場合はここにチェックを入れたままとし、リッチテキストエディターでリソースを編集することをおすすめします。リソースがJavaScriptやフォームを含む場合は、チェックを外してプレーンテキストによる編集を行ってください。';
$_lang["resource_opt_show_menu"] = 'メニューに表示';
$_lang["resource_opt_show_menu_help"] = 'メニューにこのリソースを表示するにはこのオプションをチェックしてください。メニュー作成スニペットにはこのオプションを無視するものもありますのでご注意ください。';
-$_lang["resource_opt_trackvisit_help"] = 'ドキュメントツリーに子リソースを表示します。';
+$_lang["resource_opt_trackvisit_help"] = 'このページへの訪問を記録します。';
$_lang["resource_overview"] = '詳細情報を表示';
$_lang["resource_parent"] = '親リソース';
$_lang["resource_parent_help"] = 'コンテナアイコンをクリックすると、このリソースの親(コンテナ)を変更できる状態になります。アイコンが変化している状態で、親にしたいリソースをリソースツリー上でクリックしてください。もう一度クリックすると元に戻ります。';
diff --git a/core/lang/ka/global.php b/core/lang/ka/global.php
index 1e67f5eb1d..bcaa432175 100644
--- a/core/lang/ka/global.php
+++ b/core/lang/ka/global.php
@@ -413,6 +413,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 81489782b1..f49e93b680 100644
--- a/core/lang/kk/global.php
+++ b/core/lang/kk/global.php
@@ -434,6 +434,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 7529241c0c..e9df6d7080 100644
--- a/core/lang/ky/global.php
+++ b/core/lang/ky/global.php
@@ -413,6 +413,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 4ea5c411c8..e130719a5a 100644
--- a/core/lang/lt/global.php
+++ b/core/lang/lt/global.php
@@ -413,6 +413,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:';
@@ -458,7 +465,7 @@
$_lang["htmlsnippet_name"] = 'Fragmento pavadinimas';
$_lang["htmlsnippet_title"] = 'Kurti / redaguoti fragmentą';
$_lang["icon"] = 'Piktograma';
-$_lang["icon_description"] = 'CSS klasės reikšmė. Pvz., fa fa-star';
+$_lang["icon_description"] = 'CSS klasės reikšmė. Pvz., evo-icon evo-icon-star';
$_lang["id"] = 'ID';
$_lang["illegal_parent_child"] = 'Pirminio ištekliaus priskyrimas:\n\nIšteklius yra pasirinkto ištekliaus antrinis išteklius.';
$_lang["illegal_parent_self"] = 'Pirminio ištekliaus priskyrimas:\n\nPasirinkto ištekliaus negalima priskirti pačiam sau.';
@@ -563,7 +570,7 @@
$_lang["manager_theme_mode2"] = 'antraštė tamsi';
$_lang["manager_theme_mode3"] = 'antraštė ir šoninė juosta tamsios';
$_lang["manager_theme_mode4"] = 'viskas tamsu';
-$_lang['manager_theme_mode_message'] = 'Šis nustatymas naudojamas kaip "numatytasis" ir administratorius gali jį pakeisti išteklių medyje esančiu temos spalvų režimo perjungimo mygtuku:
';
+$_lang['manager_theme_mode_message'] = 'Šis nustatymas naudojamas kaip "numatytasis" ir administratorius gali jį pakeisti išteklių medyje esančiu temos spalvų režimo perjungimo mygtuku:
';
$_lang['manager_theme_mode_title'] = 'Temos spalvų režimo perjungimas';
$_lang["meta_keywords"] = 'META raktažodžiai';
$_lang["metatag_intro"] = 'Šiame puslapyje galite trinti, kurti arba redaguoti META žymas. Norėdami susieti META žymas su ištekliais, redaguodami išteklių spustelėkite skirtuką
META raktažodžiai ir pasirinkite norimas META žymas bei raktažodžius. Norėdami pridėti naują žymą, įveskite pavadinimą ir reikšmę ir spustelėkite mygtuką \'Pridėti žymą\'. Norėdami redaguoti žymą, spustelėkite jos pavadinimą lentelėje.';
diff --git a/core/lang/lv/global.php b/core/lang/lv/global.php
index 1c408f8ece..6c302c6392 100644
--- a/core/lang/lv/global.php
+++ b/core/lang/lv/global.php
@@ -413,6 +413,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:';
@@ -458,7 +465,7 @@
$_lang["htmlsnippet_name"] = 'Fragmenta nosaukums';
$_lang["htmlsnippet_title"] = 'Izveidot/rediģēt fragmentu';
$_lang["icon"] = 'Ikona';
-$_lang["icon_description"] = 'CSS klases vērtība. Piem., fa fa-star';
+$_lang["icon_description"] = 'CSS klases vērtība. Piem., evo-icon evo-icon-star';
$_lang["id"] = 'ID';
$_lang["illegal_parent_child"] = 'Vecākresursa piešķiršana:\n\nResurss ir atlasītā resursa bērnresurss.';
$_lang["illegal_parent_self"] = 'Vecākresursa piešķiršana:\n\nAtlasīto resursu nevar piešķirt pašu sev.';
@@ -563,7 +570,7 @@
$_lang["manager_theme_mode2"] = 'galvene ir tumša';
$_lang["manager_theme_mode3"] = 'galvene un sānjosla ir tumšas';
$_lang["manager_theme_mode4"] = 'viss ir tumšs';
-$_lang['manager_theme_mode_message'] = 'Šis iestatījums tiek izmantots kā "noklusējums", un administrators to var mainīt ar motīva krāsu režīma pārslēgšanas pogu resursu kokā:
';
+$_lang['manager_theme_mode_message'] = 'Šis iestatījums tiek izmantots kā "noklusējums", un administrators to var mainīt ar motīva krāsu režīma pārslēgšanas pogu resursu kokā:
';
$_lang['manager_theme_mode_title'] = 'Motīva krāsu režīma pārslēgšana';
$_lang["meta_keywords"] = 'META atslēgvārdi';
$_lang["metatag_intro"] = 'Šajā lapā varat dzēst, izveidot vai rediģēt META tagus. Lai saistītu META tagus ar resursiem, resursa rediģēšanas laikā noklikšķiniet uz cilnes
META atslēgvārdi un atlasiet vajadzīgos META tagus un atslēgvārdus. Lai pievienotu jaunu tagu, ievadiet nosaukumu un vērtību un noklikšķiniet uz pogas \'Pievienot tagu\'. Lai rediģētu tagu, noklikšķiniet uz tā nosaukuma tabulā.';
diff --git a/core/lang/nl/global.php b/core/lang/nl/global.php
index 64ff363d3a..2d1bb30bfb 100644
--- a/core/lang/nl/global.php
+++ b/core/lang/nl/global.php
@@ -394,6 +394,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 eddf84a93a..9c90df848f 100644
--- a/core/lang/nn/global.php
+++ b/core/lang/nn/global.php
@@ -376,6 +376,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 0b398ed9c4..68dea2b54e 100644
--- a/core/lang/pl/global.php
+++ b/core/lang/pl/global.php
@@ -395,6 +395,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 4b9b848b43..e35d96cef2 100644
--- a/core/lang/pt/global.php
+++ b/core/lang/pt/global.php
@@ -376,6 +376,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 621b061607..17236c77a8 100644
--- a/core/lang/ro/global.php
+++ b/core/lang/ro/global.php
@@ -413,6 +413,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:';
@@ -458,7 +465,7 @@
$_lang["htmlsnippet_name"] = 'Numele fragmentului';
$_lang["htmlsnippet_title"] = 'Creează/editează fragmentul';
$_lang["icon"] = 'Pictogramă';
-$_lang["icon_description"] = 'Valoarea clasei CSS. De exemplu, fa fa-star';
+$_lang["icon_description"] = 'Valoarea clasei CSS. De exemplu, evo-icon evo-icon-star';
$_lang["id"] = 'ID';
$_lang["illegal_parent_child"] = 'Atribuirea părintelui:\n\nResursa este un descendent al resursei selectate.';
$_lang["illegal_parent_self"] = 'Atribuirea părintelui:\n\nResursa selectată nu poate fi propriul său părinte.';
@@ -563,7 +570,7 @@
$_lang["manager_theme_mode2"] = 'antetul este întunecat';
$_lang["manager_theme_mode3"] = 'antetul și bara laterală sunt întunecate';
$_lang["manager_theme_mode4"] = 'totul este întunecat';
-$_lang['manager_theme_mode_message'] = 'Această setare este folosită ca valoare "implicită" și poate fi suprascrisă de administrator folosind butonul de schimbare a modului de culoare al temei din arborele de resurse:
';
+$_lang['manager_theme_mode_message'] = 'Această setare este folosită ca valoare "implicită" și poate fi suprascrisă de administrator folosind butonul de schimbare a modului de culoare al temei din arborele de resurse:
';
$_lang['manager_theme_mode_title'] = 'Comutare mod de culoare al temei';
$_lang["meta_keywords"] = 'Cuvinte-cheie META';
$_lang["metatag_intro"] = 'Pe această pagină puteți șterge, crea sau edita etichete META. Pentru a asocia etichete META cu resurse, faceți clic pe fila
Cuvinte-cheie META când editați resursa și selectați etichetele META și cuvintele-cheie dorite. Pentru a adăuga o etichetă nouă, introduceți numele și valoarea și faceți clic pe butonul \'Adaugă etichetă\'. Pentru a edita eticheta, faceți clic pe numele acesteia în tabel.';
diff --git a/core/lang/sk/global.php b/core/lang/sk/global.php
index 5390728154..859aab191b 100644
--- a/core/lang/sk/global.php
+++ b/core/lang/sk/global.php
@@ -402,6 +402,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:';
@@ -807,7 +814,7 @@
$_lang["resource_opt_richtext_help"] = 'Začiarknite, ak sa má na úpravu zdroja používať HTML editor. Ak zdroj obsahuje JavaScript alebo formuláre - zrušte začiarknutie, aby ste ho mohli upravovať v režime HTML kódu (aby HTML editor nevykonával žiadne zmeny v kóde).';
$_lang["resource_opt_show_menu"] = 'Zobrazovať v menu';
$_lang["resource_opt_show_menu_help"] = 'Zapnite tento parameter, ak sa má zdroj zobrazovať v niektorom menu webu. Majte na pamäti, že niektoré snippety môžu tento parameter ignorovať.';
-$_lang["resource_opt_trackvisit_help"] = 'Začiarknite, ak sa majú podradené zdroje zobrazovať v strome dokumentov';
+$_lang["resource_opt_trackvisit_help"] = 'Zaznamenať každú návštevu tejto stránky';
$_lang["resource_overview"] = 'Prehľad zdroja';
$_lang["resource_parent"] = 'Priečinok';
$_lang["resource_parent_help"] = 'Kliknite na ikonu kontajnera hore pre zapnutie (vypnutie) režimu výberu rodičovského zdroja, potom ho vyberte v strome webu vľavo.';
diff --git a/core/lang/sv/global.php b/core/lang/sv/global.php
index fc0f26719b..7dbcd9b6e2 100644
--- a/core/lang/sv/global.php
+++ b/core/lang/sv/global.php
@@ -377,6 +377,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 3b58836824..1a9eeacfd9 100644
--- a/core/lang/tg/global.php
+++ b/core/lang/tg/global.php
@@ -413,6 +413,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 531f867eca..526a1c7d6c 100644
--- a/core/lang/tk/global.php
+++ b/core/lang/tk/global.php
@@ -413,6 +413,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 e141b1eaf4..3063c3f087 100644
--- a/core/lang/uk/global.php
+++ b/core/lang/uk/global.php
@@ -414,6 +414,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"] = 'Оберіть файли для завантаження на сервер:';
@@ -821,7 +828,7 @@
$_lang["resource_opt_richtext_help"] = 'Відзначте, щоб для редагування ресурсу використовувався HTML-редактор. Якщо ресурс містить JavaScript або форми - зніміть галочку, щоб редагувати його в режимі HTML-коду (щоб HTML-редактор не вносив жодних змін в код).';
$_lang["resource_opt_show_menu"] = 'Показувати в меню';
$_lang["resource_opt_show_menu_help"] = 'Увімкніть цей параметр для відображення ресурсу в будь-якому меню сайту. Майте на увазі, деякі сніпети можуть ігнорувати цей параметр.';
-$_lang["resource_opt_trackvisit_help"] = 'Відзначте, щоб показувати дочірні ресурси в дереві документів';
+$_lang["resource_opt_trackvisit_help"] = 'Записувати кожне відвідування цієї сторінки';
$_lang["resource_overview"] = 'Огляд ресурсу';
$_lang["resource_parent"] = 'Папка';
$_lang["resource_parent_help"] = 'Клацніть мишею на значку контейнера вгорі, щоб включити (виключити) режим вибору батьківського ресурсу, потім виберіть його в дереві сайту зліва.';
diff --git a/core/lang/uz/global.php b/core/lang/uz/global.php
index 807080f594..6e36a53359 100644
--- a/core/lang/uz/global.php
+++ b/core/lang/uz/global.php
@@ -413,6 +413,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:';
@@ -458,7 +465,7 @@
$_lang["htmlsnippet_name"] = 'Chunk nomi';
$_lang["htmlsnippet_title"] = 'Chunk yaratish/tahrirlash';
$_lang["icon"] = 'Belgi';
-$_lang["icon_description"] = 'CSS klassi qiymati. Masalan, fa fa-star';
+$_lang["icon_description"] = 'CSS klassi qiymati. Masalan, evo-icon evo-icon-star';
$_lang["id"] = 'ID';
$_lang["illegal_parent_child"] = 'Ota resursni belgilash:\n\nResurs tanlangan resursning quyi resursidir.';
$_lang["illegal_parent_self"] = 'Ota resursni belgilash:\n\nTanlangan resurs o‘ziga ota resurs qilib belgilanmaydi.';
@@ -563,7 +570,7 @@
$_lang["manager_theme_mode2"] = 'sarlavha qismi to‘q rangda';
$_lang["manager_theme_mode3"] = 'sarlavha qismi va yon panel to‘q rangda';
$_lang["manager_theme_mode4"] = 'hammasi to‘q rangda';
-$_lang['manager_theme_mode_message'] = 'Bu sozlama "standart" qiymat sifatida ishlatiladi va administrator uni resurslar daraxtidagi mavzu rang rejimini almashtirish tugmasi orqali o‘zgartirishi mumkin:
';
+$_lang['manager_theme_mode_message'] = 'Bu sozlama "standart" qiymat sifatida ishlatiladi va administrator uni resurslar daraxtidagi mavzu rang rejimini almashtirish tugmasi orqali o‘zgartirishi mumkin:
';
$_lang['manager_theme_mode_title'] = 'Mavzu rang rejimini almashtirish';
$_lang["meta_keywords"] = 'META kalit so‘zlari';
$_lang["metatag_intro"] = 'Bu sahifada META teglarini o‘chirishingiz, yaratishingiz yoki tahrirlashingiz mumkin. META teglarini resurslarga bog‘lash uchun resursni tahrirlashda
META kalit so‘zlari ichki oynasini bosing va kerakli META teglari hamda kalit so‘zlarni tanlang. Yangi teg qo‘shish uchun nom va qiymatni kiriting va \'Teg qo‘shish\' tugmasini bosing. Tegni tahrirlash uchun jadvaldagi teg nomini bosing.';
diff --git a/core/lang/zh/global.php b/core/lang/zh/global.php
index d461aaa929..9a81529df2 100644
--- a/core/lang/zh/global.php
+++ b/core/lang/zh/global.php
@@ -377,6 +377,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"] = '选择一上传的文件:';
@@ -740,7 +747,7 @@
$_lang["resource_opt_richtext_help"] = '选择这个选项将让本文档能使用富文本编辑器,如果你的内容里有 JavaScript和表单,请取消这个选择而利用HTML格式来编辑,以免MODX为你自动剖析文本。';
$_lang["resource_opt_show_menu"] = '显示在菜单中';
$_lang["resource_opt_show_menu_help"] = 'Select this option to show Resource inside a web menu. Please note that some Menu Builders might choose to ignore this option.';
-$_lang["resource_opt_trackvisit_help"] = 'Log each visitor\'s visit to this page';
+$_lang["resource_opt_trackvisit_help"] = '记录每位访客对此页面的访问';
$_lang["resource_overview"] = 'Resource overview';
$_lang["resource_parent"] = '文档之父本。';
$_lang["resource_parent_help"] = '点上面文件夹的图标来激活或禁止父本的选择,当激活的时候,你可以从文档树中选择一文档作为本文档的父本。';
diff --git a/core/src/Controllers/Frame.php b/core/src/Controllers/Frame.php
index eb9c0d9d2f..5643db6b9b 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 (!\EvolutionCMS\Support\Formatter\CSSMinify::isCacheCurrent($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($themeDir . 'css/styles.min.css'));
-
+ file_put_contents($minCssPath, $minifier->minify($themeDir . 'css/styles.min.css'));
+ clearstatcache(true, $minCssPath);
}
}
- if (\EvolutionCMS\Support\Formatter\CSSMinify::isCacheCurrent($themeDir . 'css/styles.min.css')) {
- $this->parameters['css'] = $this->managerTheme->getThemeDir(false) . 'css/styles.min.css?v=' . evo()->getVersionData('version') . '.' . EVO_INSTALL_TIME;
+ if (\EvolutionCMS\Support\Formatter\CSSMinify::isCacheCurrent($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 884173167e..65e479b09e 100644
--- a/core/src/Core.php
+++ b/core/src/Core.php
@@ -3804,7 +3804,9 @@ function __alertQuit() {
el.remove();
" . $fnc . "
}
- window.setTimeout('__alertQuit();',100);
+ // after load: a manager tab or popup frame replaces alert() in its own onload; a timer
+ // started earlier fires first on a slow stylesheet, and the message shows twice
+ window.addEventListener('load', function () { window.setTimeout(__alertQuit, 100); });
@@ -3953,13 +3955,7 @@ public function buildLockedElementsCache()
public function cleanupExpiredLocks()
{
// Clean-up active_user_sessions first
- if ((int) $this->getConfig('session_timeout') < 2) {
- $timeout = 120;
- } else {
- $timeout = $this->getConfig('session_timeout') * 60;
- }
- // session.js pings every 10min, so 2min is minimum
- $validSessionTimeLimit = $this->time - $timeout;
+ $validSessionTimeLimit = ActiveUserSession::validSince((int) $this->time, (int) $this->getConfig('session_timeout'));
ActiveUserSession::where('lasthit', '<', (int) $validSessionTimeLimit)->delete();
// Clean-up active_user_locks
diff --git a/core/src/ManagerTheme.php b/core/src/ManagerTheme.php
index 46b9a81d66..953829bca5 100644
--- a/core/src/ManagerTheme.php
+++ b/core/src/ManagerTheme.php
@@ -913,8 +913,11 @@ public function css()
$minCssName = 'css/styles.min.css';
$minCssPath = $this->getThemeDir() . $minCssName;
- if (!\EvolutionCMS\Support\Formatter\CSSMinify::isCacheCurrent($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,
@@ -939,13 +942,16 @@ public function css()
$minifier = new \EvolutionCMS\Support\Formatter\CSSMinify($files);
$css = $minifier->minify($minCssPath);
file_put_contents($minCssPath, $css);
+ clearstatcache(true, $minCssPath);
}
}
+ $revision = '';
if (\EvolutionCMS\Support\Formatter\CSSMinify::isCacheCurrent($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/Models/ActiveUser.php b/core/src/Models/ActiveUser.php
index b03560f94d..757dddeb09 100644
--- a/core/src/Models/ActiveUser.php
+++ b/core/src/Models/ActiveUser.php
@@ -12,7 +12,7 @@
* @property string $action
* @property int $id
*
- * @method static \Illuminate\Database\Eloquent\Builder|\EvolutionCMS\Models\ActiveUser locked($action, $id = null, $userId = null)
+ * @method static \Illuminate\Database\Eloquent\Builder|\EvolutionCMS\Models\ActiveUser locked($action, $id = null, $userId = null, ?int $aliveSince = null)
*
* @mixin \Eloquent
*/
@@ -38,14 +38,20 @@ class ActiveUser extends Eloquent\Model
'id'
];
- public function scopeLocked(Eloquent\Builder $builder, $action, $id = null, $userId = null)
+ public function scopeLocked(Eloquent\Builder $builder, $action, $id = null, $userId = null, ?int $aliveSince = null)
{
if ($userId === null) {
$userId = evo()->getLoginUserID();
}
+ if ($aliveSince === null) {
+ $aliveSince = ActiveUserSession::validSince(time(), (int) evo()->getConfig('session_timeout'));
+ }
+ // A row stays here after its manager is gone (closed browser, expired session) until they open
+ // another page, so only a manager who is still signed in holds the page.
$builder = $builder->where('action', '=', (int)$action)
->where('internalKey', '!=', $userId)
+ ->whereIn('internalKey', ActiveUserSession::query()->select('internalKey')->where('lasthit', '>=', $aliveSince))
->orderBy('lasthit', 'DESC');
if ($id !== null) {
diff --git a/core/src/Models/ActiveUserSession.php b/core/src/Models/ActiveUserSession.php
index e34c208aef..26c0d3d833 100644
--- a/core/src/Models/ActiveUserSession.php
+++ b/core/src/Models/ActiveUserSession.php
@@ -31,4 +31,13 @@ class ActiveUserSession extends Eloquent\Model
'lasthit',
'ip'
];
+
+ /**
+ * Oldest lasthit of a session that still counts as signed in: session_timeout minutes, at least 2.
+ * An open manager pings every half timeout (evo.keepMeAlive), so it never falls out of this window.
+ */
+ public static function validSince(int $now, int $timeoutMinutes): int
+ {
+ return $now - max(2, $timeoutMinutes) * 60;
+ }
}
diff --git a/core/src/Support/Formatter/CSSMinify.php b/core/src/Support/Formatter/CSSMinify.php
index 74abeff16d..741a42d805 100644
--- a/core/src/Support/Formatter/CSSMinify.php
+++ b/core/src/Support/Formatter/CSSMinify.php
@@ -13,6 +13,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/ManagerIconAssetsTest.php b/core/tests/Unit/Manager/ManagerIconAssetsTest.php
index 0024bc60c0..91892d02fd 100644
--- a/core/tests/Unit/Manager/ManagerIconAssetsTest.php
+++ b/core/tests/Unit/Manager/ManagerIconAssetsTest.php
@@ -23,13 +23,37 @@
if ($file->getExtension() !== 'css' || $file->getFilename() === 'styles.min.css') {
continue;
}
- preg_match_all('/var\(--evo-icon-([a-z0-9-]+)\)/', file_get_contents($file->getPathname()), $matches);
+ $source = file_get_contents($file->getPathname());
+ // Font Awesome is gone, so its font name or a private-use glyph renders nothing (3.5.x merges bring them back)
+ expect(preg_match('/Font\s*Awesome/i', $source))->toBe(0, $file->getPathname() . ' still uses the Font Awesome font')
+ ->and(preg_match('/content\s*:\s*([\'"])\\\\f[0-9a-f]{3}\1/i', $source))->toBe(0, $file->getPathname() . ' still uses a Font Awesome glyph');
+ preg_match_all('/var\(--evo-icon-([a-z0-9-]+)\)/', $source, $matches);
foreach (array_unique($matches[1]) as $icon) {
expect($icons)->toContain($icon);
}
}
});
+test('bundled plugins render icons from the generated SVG set, not Font Awesome', function () {
+ $root = dirname(__DIR__, 4);
+ $icons = json_decode(file_get_contents($root . '/manager/media/style/common/icons/icons.json'), true, 512, JSON_THROW_ON_ERROR);
+ $utilities = ['fw', 'spin', 'spin-hover', 'pulse', 'lg', 'sm', 'xs'];
+
+ foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/assets/plugins', FilesystemIterator::SKIP_DOTS)) as $file) {
+ if ($file->getExtension() !== 'php') {
+ continue;
+ }
+ $source = file_get_contents($file->getPathname());
+ // markup classes and widget / popup 'icon' values
+ expect(preg_match('/class=\\\\?["\'][^"\']*\bfa\s+fa-|[\'"]icon[\'"]\s*=>\s*[\'"]fa-/', $source))
+ ->toBe(0, $file->getPathname() . ' still uses a Font Awesome icon');
+ preg_match_all('/\bevo-icon-([a-z0-9-]+)/', $source, $matches);
+ foreach (array_diff(array_unique($matches[1]), $utilities) as $icon) {
+ expect($icons)->toContain($icon);
+ }
+ }
+});
+
test('full and empty trash and open and closed folders retain distinct SVG shapes', function () {
$css = file_get_contents(dirname(__DIR__, 4) . '/manager/media/style/common/icons/manager-icons.css');
$shapes = [];
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/Manager/SettingsLockTest.php b/core/tests/Unit/Manager/SettingsLockTest.php
new file mode 100644
index 0000000000..88b93e8c61
--- /dev/null
+++ b/core/tests/Unit/Manager/SettingsLockTest.php
@@ -0,0 +1,82 @@
+addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']);
+ $capsule->setAsGlobal();
+ $capsule->bootEloquent();
+ Model::setConnectionResolver($capsule->getDatabaseManager());
+
+ $schema = $capsule->getConnection()->getSchemaBuilder();
+ $schema->create('active_user_sessions', function (Blueprint $table) {
+ $table->string('sid', 128)->primary();
+ $table->integer('internalKey')->default(0);
+ $table->integer('lasthit')->default(0);
+ $table->string('ip')->default('');
+ });
+ $schema->create('active_users', function (Blueprint $table) {
+ $table->string('sid', 128)->primary();
+ $table->integer('internalKey')->default(0);
+ $table->string('username', 50)->default('');
+ $table->integer('lasthit')->default(0);
+ $table->string('action', 10)->default('');
+ $table->integer('id')->nullable();
+ });
+});
+
+afterEach(function () {
+ Capsule::connection()->disconnect();
+});
+
+function onSettings(int $userId, string $username, string $sid, int $lasthit, ?int $sessionHit): void
+{
+ ActiveUser::query()->insert(['sid' => $sid, 'internalKey' => $userId, 'username' => $username, 'lasthit' => $lasthit, 'action' => '17', 'id' => null]);
+ if ($sessionHit !== null) {
+ ActiveUserSession::query()->insert(['sid' => $sid, 'internalKey' => $userId, 'lasthit' => $sessionHit, 'ip' => '127.0.0.1']);
+ }
+}
+
+test('a manager who left the settings open no longer holds them once the session is gone', function () {
+ $now = 1_800_000_000;
+ $since = ActiveUserSession::validSince($now, 20);
+ // left 2 h ago, session already cleaned up
+ onSettings(2, 'gone', 'sid-gone', $now - 7200, null);
+ // last hit long ago, session row not cleaned yet but past the timeout
+ onSettings(3, 'expired', 'sid-expired', $now - 7200, $now - 21 * 60);
+
+ expect(ActiveUser::locked(17, null, 1, $since)->first())->toBeNull();
+});
+
+test('a signed-in manager on the settings page still holds them', function () {
+ $now = 1_800_000_000;
+ // opened the settings 30 min ago and kept the manager open: keep-alive refreshed the session
+ onSettings(2, 'editor', 'sid-editor', $now - 1800, $now - 60);
+
+ expect(ActiveUser::locked(17, null, 1, ActiveUserSession::validSince($now, 20))->first()?->username)->toBe('editor')
+ ->and(ActiveUser::locked(17, null, 2, ActiveUserSession::validSince($now, 20))->first())->toBeNull();
+});
+
+test('the session window is session_timeout minutes and never below two', function () {
+ expect(ActiveUserSession::validSince(10_000, 20))->toBe(10_000 - 1200)
+ ->and(ActiveUserSession::validSince(10_000, 0))->toBe(10_000 - 120)
+ ->and(ActiveUserSession::validSince(10_000, 1))->toBe(10_000 - 120);
+});
+
+test('the alert page waits for load, so a manager frame replaces alert() before it fires', function () {
+ $source = file_get_contents(dirname(__DIR__, 3) . '/src/Core.php');
+
+ expect($source)->toContain("window.addEventListener('load', function () { window.setTimeout(__alertQuit, 100); });")
+ ->and($source)->not->toContain("window.setTimeout('__alertQuit();',100);");
+});
+
+test('the dashboard does not send a new install to settings another manager holds', function () {
+ $view = file_get_contents(dirname(__DIR__, 4) . '/manager/views/page/2.blade.php');
+
+ expect($view)->toMatch('/settings_version.*\n.*!\\\\EvolutionCMS\\\\Models\\\\ActiveUser::locked\(17\)->exists\(\)/');
+});
diff --git a/core/tests/Unit/Security/UploadLimitsTest.php b/core/tests/Unit/Security/UploadLimitsTest.php
index 619601bc9b..999b81baa6 100644
--- a/core/tests/Unit/Security/UploadLimitsTest.php
+++ b/core/tests/Unit/Security/UploadLimitsTest.php
@@ -106,14 +106,16 @@
->and($guard)->toBeLessThan(strpos($index, "header('Expires:"));
});
-it('stops an oversized selection in the file manager before it is sent', function () {
+it('sends a file manager selection in batches PHP takes whole, never in one plain form post', function () {
$source = (string) file_get_contents(dirname(__DIR__, 4) . '/manager/actions/files.dynamic.php');
- expect($source)->toContain('onsubmit="return fileManagerUploadFits(this);"')
- ->and($source)->toContain('onchange="if (fileManagerUploadFits(document.upload)) document.upload.submit();"')
- ->and($source)->toContain("js_json(\$_lang['upload_file_too_large'])")
- ->and($source)->toContain("js_json(\$_lang['upload_files_too_large'])")
- ->and($source)->not->toContain('onchange="document.upload.submit();"');
+ // PHP drops the files past max_file_uploads and a whole body past post_max_size without a word
+ expect($source)->toContain('onsubmit="fileManagerUpload(this); return false;"')
+ ->and($source)->toContain('onchange="fileManagerUpload(document.upload);"')
+ ->and($source)->toContain("maxFiles: = (int) ini_get('max_file_uploads') ?>")
+ ->and($source)->toContain('postLimit: = (int) $uploadPostLimit ?>')
+ ->and($source)->toContain('
" . $_lang['files_upload_inhibited_msg'] . "";
diff --git a/manager/media/browser/mcpuk/js/browser/files.js b/manager/media/browser/mcpuk/js/browser/files.js
index 231e15eccf..162ed39387 100755
--- a/manager/media/browser/mcpuk/js/browser/files.js
+++ b/manager/media/browser/mcpuk/js/browser/files.js
@@ -223,6 +223,7 @@ browser.deleteButtonEnabled = function(canDelete, files, canDeleteDirs) {
browser.clearSelection = function() {
$('.file').removeClass('selected');
this.lastSelectedFile = null;
+ this.treeSelection = null;
this.statusDir();
this.updateDeleteButton();
this.syncTreeSelection();
diff --git a/manager/media/browser/mcpuk/js/browser/folders.js b/manager/media/browser/mcpuk/js/browser/folders.js
index 707e397557..d8d3dd4321 100755
--- a/manager/media/browser/mcpuk/js/browser/folders.js
+++ b/manager/media/browser/mcpuk/js/browser/folders.js
@@ -15,13 +15,15 @@ browser.initFolders = function() {
browser.hideDialog();
});
$('div.folder > a').unbind();
- $('div.folder > a').bind('click', function() {
+ $('div.folder > a').bind('click', function(e) {
browser.hideDialog();
- browser.openTreeFolder($(this));
+ browser.handleTreeClick($(this), $(e.target).hasClass('brace'));
return false;
});
- $('div.folder > a').dblclick(function() {
- browser.toggleTreeFolder($(this));
+ $('div.folder > a').dblclick(function(e) {
+ // each single click on the arrow has toggled the branch already
+ if (!$(e.target).hasClass('brace'))
+ browser.toggleTreeFolder($(this));
return false;
});
$('div.folder > a > span.brace').unbind();
@@ -52,6 +54,27 @@ browser.initFolders = function() {
browser.openTreeFolder = function(dir) {
this.changeDir(dir);
+ this.selectTreeFolder(dir);
+};
+
+// a click in the tree selects that folder alone: nothing stays selected in the file list or elsewhere in the tree
+browser.selectTreeFolder = function(dir) {
+ $('.file').removeClass('selected');
+ this.lastSelectedFile = null;
+ this.treeSelection = dir.data('path');
+ this.statusDir();
+ this.updateDeleteButton();
+ this.syncTreeSelection();
+ this.updateMobileActions();
+};
+
+// like the resource tree: the arrow of a folder with subfolders expands or collapses it, the rest of the row opens it
+browser.handleTreeClick = function(dir, onArrow) {
+ var brace = dir.children('.brace');
+ if (onArrow && (brace.hasClass('opened') || brace.hasClass('closed')))
+ this.toggleTreeFolder(dir);
+ else
+ this.openTreeFolder(dir);
};
// a folder's node in the tree; empty while the branch holding it is not loaded
@@ -63,13 +86,6 @@ browser.subDirPath = function(name) {
return this.dir.length ? this.dir + '/' + name : name;
};
-// the subfolder of dir that leads to path: ("images", "images/team/2024") gives "team"
-browser.subDirOnPath = function(dir, path) {
- if ((typeof path != 'string') || (path.indexOf(dir + '/') !== 0))
- return undefined;
- return path.substr(dir.length + 1).split('/')[0];
-};
-
/**
* The tree node of a folder listed in the file list. A tree folder opened by a click is not expanded,
* so its branch is loaded here when it never was: every listed folder has a node to open or mark.
@@ -100,7 +116,15 @@ browser.revealTreeDir = function(dir) {
// the folders selected in the file list are marked in the tree too: both panes show one selection
browser.syncTreeSelection = function() {
$('div.folder > a > span.folder').removeClass('selected');
- $('#files .file.selected').each(function() {
+ var listed = $('#files .file.selected');
+ // a selection in the file list replaces the folder selected in the tree; without one, that folder stays
+ if (listed.length)
+ this.treeSelection = null;
+ else if (this.treeSelection) {
+ this.treeDir(this.treeSelection).children('span.folder').addClass('selected');
+ return;
+ }
+ listed.each(function() {
var file = $(this);
if (!file.data('isDir'))
return;
@@ -207,13 +231,10 @@ browser.updateDirSubtreePaths = function(dir, oldPath, newPath) {
}
};
+// expands or collapses the branch only; the folder shown in the file list stays
browser.expandDir = function(dir) {
- var path = dir.data('path');
if (dir.children('.brace').hasClass('opened')) {
- dir.parent().children('.folders').hide(500, function() {
- if (path == browser.dir.substr(0, path.length))
- browser.changeDir(dir);
- });
+ dir.parent().children('.folders').hide(500);
dir.children('.brace').removeClass('opened');
dir.children('.brace').addClass('closed');
} else {
@@ -281,7 +302,8 @@ browser.loadTreeBranch = function(dir, animate) {
browser.changeDir = function(dir) {
if (dir.children('span.folder').hasClass('regular')) {
- var previous = this.dir;
+ // a folder opened from anywhere starts with nothing selected; a tree click selects it afterwards
+ this.treeSelection = null;
this.clearMobileActions();
$('div.folder > a > span.folder').removeClass('current');
$('div.folder > a > span.folder').removeClass('selected');
@@ -303,10 +325,7 @@ browser.changeDir = function(dir) {
browser.files = data.files;
browser.links = data.links ? data.links : [];
browser.dir = dir.data('path');
- // up the tree, the folder it came from is selected in the list (and so in the tree)
- browser.orderFiles(function() {
- browser.scrollIntoPane('#files', $('#files .file.selected').get(0));
- }, browser.subDirOnPath(browser.dir, previous));
+ browser.orderFiles();
browser.dirWritable = data.dirWritable;
browser.updateWriteControls();
var title = "KCFinder: /" + browser.dir;
diff --git a/manager/media/browser/mcpuk/js/tests/folder-double-click.test.js b/manager/media/browser/mcpuk/js/tests/folder-double-click.test.js
index 70013c6053..28ddd840df 100644
--- a/manager/media/browser/mcpuk/js/tests/folder-double-click.test.js
+++ b/manager/media/browser/mcpuk/js/tests/folder-double-click.test.js
@@ -36,7 +36,7 @@ test('content and list folder clicks select; double-click opens the folder', ()
assert.equal(opened, 1);
});
-test('single-clicking a tree folder opens its contents without toggling the branch', () => {
+test('single-clicking a tree folder opens and selects it without toggling the branch', () => {
const browser = loadBrowser();
const actions = [];
const brace = {
@@ -47,10 +47,11 @@ test('single-clicking a tree folder opens its contents without toggling the bran
const folder = { children: () => brace };
browser.expandDir = () => actions.push('toggle');
browser.changeDir = () => actions.push('open');
+ browser.selectTreeFolder = () => actions.push('select');
browser.openTreeFolder(folder);
- assert.deepEqual(actions, ['open']);
+ assert.deepEqual(actions, ['open', 'select']);
});
test('double-clicking a tree folder toggles its branch without changing contents', () => {
@@ -64,6 +65,7 @@ test('double-clicking a tree folder toggles its branch without changing contents
const folder = { children: () => brace };
browser.expandDir = () => actions.push('toggle');
browser.changeDir = () => actions.push('open');
+ browser.selectTreeFolder = () => actions.push('select');
browser.toggleTreeFolder(folder);
@@ -80,3 +82,68 @@ test('double-clicking a leaf tree folder has no branch to toggle', () => {
assert.equal(toggled, false);
});
+
+function treeFolder(braceClass) {
+ return { children: () => ({ hasClass: (name) => name === braceClass }) };
+}
+
+test('a single click on the arrow of a tree folder only toggles its branch', () => {
+ for (const state of ['opened', 'closed']) {
+ const browser = loadBrowser();
+ const actions = [];
+ browser.expandDir = () => actions.push('toggle');
+ browser.changeDir = () => actions.push('open');
+ browser.selectTreeFolder = () => actions.push('select');
+ browser.selectTreeFolder = () => actions.push('select');
+
+ browser.handleTreeClick(treeFolder(state), true);
+
+ assert.deepEqual(actions, ['toggle'], state);
+ }
+});
+
+test('a single click on the name of a tree folder opens it', () => {
+ const browser = loadBrowser();
+ const actions = [];
+ browser.expandDir = () => actions.push('toggle');
+ browser.changeDir = () => actions.push('open');
+ browser.selectTreeFolder = () => actions.push('select');
+
+ browser.handleTreeClick(treeFolder('opened'), false);
+
+ assert.deepEqual(actions, ['open', 'select']);
+});
+
+test('the arrow area of a folder without subfolders opens it', () => {
+ const browser = loadBrowser();
+ const actions = [];
+ browser.expandDir = () => actions.push('toggle');
+ browser.changeDir = () => actions.push('open');
+ browser.selectTreeFolder = () => actions.push('select');
+
+ browser.handleTreeClick(treeFolder(''), true);
+
+ assert.deepEqual(actions, ['open', 'select']);
+});
+
+test('collapsing a branch does not change the folder shown in the file list', () => {
+ const browser = loadBrowser();
+ const calls = [];
+ const brace = {
+ hasClass: (name) => name === 'opened',
+ removeClass() { return brace; },
+ addClass() { return brace; },
+ };
+ const folders = { hide: (ms, done) => { calls.push('hide'); if (done) done(); } };
+ const dir = {
+ data: () => 'images',
+ children: () => brace,
+ parent: () => ({ children: () => folders }),
+ };
+ browser.dir = 'images/team';
+ browser.changeDir = () => calls.push('open');
+
+ browser.expandDir(dir);
+
+ assert.deepEqual(calls, ['hide']);
+});
diff --git a/manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js b/manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js
index 1c2979bb88..a40fe8de3f 100644
--- a/manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js
+++ b/manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js
@@ -133,17 +133,6 @@ function setup() {
return { browser, page };
}
-test('the subfolder of a folder that leads to a deeper path', () => {
- const { browser } = setup();
-
- assert.equal(browser.subDirOnPath('images', 'images/team/2024'), 'team');
- assert.equal(browser.subDirOnPath('images', 'images/team'), 'team');
- assert.equal(browser.subDirOnPath('images', 'images'), undefined);
- assert.equal(browser.subDirOnPath('images', 'images-old/team'), undefined);
- assert.equal(browser.subDirOnPath('images/team', 'images'), undefined);
- assert.equal(browser.subDirOnPath('images', undefined), undefined);
-});
-
test('a listed folder is found in the tree without loading anything when its branch is there', () => {
const { browser, page } = setup();
treeNode(subfolders(page.images), 'images/team');
@@ -292,16 +281,86 @@ function openWithChDir(browser, page, target) {
return ordered;
}
-test('going up the tree selects the folder it came from in the file list', () => {
+// images > team > 2024, with 2024 open in the file list
+function nested() {
const { browser, page } = setup();
page.images.a.kids[1].cls.delete('current');
page.images.a.kids[1].cls.add('regular');
+ const team = treeNode(subfolders(page.images), 'images/team', { brace: 'opened' });
+ const year = treeNode(subfolders(team), 'images/team/2024', { current: true });
browser.dir = 'images/team/2024';
+ return { browser, page, team, year };
+}
+
+function clickTreeFolder(browser, page, node) {
+ openWithChDir(browser, page, node);
+ browser.selectTreeFolder(wrap([node.a]));
+}
+
+test('clicking the root of three nested tree folders selects it alone, not the folder it came from', () => {
+ const { browser, page } = nested();
+ const ordered = [];
+ page.$.ajax = (options) => options.success({ files: [], links: [], dirWritable: true });
+ Object.assign(browser, {
+ loadingHtml: () => '', check4errors: () => false, updateWriteControls() {}, opener: {}, baseGetData: (act) => act,
+ orderFiles: (callBack, selected) => ordered.push(selected),
+ });
- const ordered = openWithChDir(browser, page, page.images);
+ browser.openTreeFolder(wrap([page.images.a]));
- assert.deepEqual(ordered, ['team']);
assert.equal(browser.dir, 'images');
+ assert.deepEqual(ordered, [undefined]);
+ assert.deepEqual(page.selectedInTree(), ['images']);
+});
+
+test('clicking the open tree folder deselects everything in the file list and selects it', () => {
+ const { browser, page, year } = nested();
+ page.card('photo.png', false, true);
+ page.card('old', true, true);
+
+ browser.openTreeFolder(wrap([year.a]));
+
+ assert.equal(page.$('#files .file.selected').length, 0);
+ assert.deepEqual(page.selectedInTree(), ['images/team/2024']);
+});
+
+test('the folder selected in the tree stays selected while nothing is picked in the file list', () => {
+ const { browser, page, team } = nested();
+ clickTreeFolder(browser, page, team);
+
+ browser.syncTreeSelection();
+
+ assert.deepEqual(page.selectedInTree(), ['images/team']);
+});
+
+test('selecting in the file list replaces the folder selected in the tree', () => {
+ const { browser, page, team } = nested();
+ clickTreeFolder(browser, page, team);
+ const card = page.card('photo.png', false);
+
+ browser.selectFolder(wrap([card]));
+ browser.clearSelection();
+
+ assert.deepEqual(page.selectedInTree(), []);
+ assert.equal(browser.treeSelection, null);
+});
+
+test('clicking empty space in the file list clears the folder selected in the tree', () => {
+ const { browser, page, team } = nested();
+ clickTreeFolder(browser, page, team);
+
+ browser.clearSelection();
+
+ assert.deepEqual(page.selectedInTree(), []);
+});
+
+test('a folder opened from the file list does not bring back an earlier tree selection', () => {
+ const { browser, page, team, year } = nested();
+ clickTreeFolder(browser, page, team);
+
+ openWithChDir(browser, page, year);
+
+ assert.deepEqual(page.selectedInTree(), []);
});
test('going down or sideways selects nothing in the file list', () => {
diff --git a/manager/media/browser/mcpuk/themes/evo/style.css b/manager/media/browser/mcpuk/themes/evo/style.css
index ebc40214ec..49f73493fd 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; content:"";display:inline-block;width:1em;height:1em;vertical-align:middle;background-color:currentColor;-webkit-mask:var(--evo-icon-folder-open) center/contain no-repeat;mask:var(--evo-icon-folder-open) center/contain no-repeat; 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/common/icons/icons.json b/manager/media/style/common/icons/icons.json
index 6d88e23b7e..4a3e98279f 100644
--- a/manager/media/style/common/icons/icons.json
+++ b/manager/media/style/common/icons/icons.json
@@ -10,6 +10,7 @@
"arrow-back-up-double",
"arrow-forward",
"arrow-left",
+ "arrow-right",
"arrows-exchange",
"arrows-maximize",
"arrows-move",
@@ -41,6 +42,7 @@
"chevron-up",
"circle",
"circle-arrow-down",
+ "circle-arrow-right",
"circle-arrow-up",
"circle-check",
"circle-plus",
@@ -79,6 +81,7 @@
"folder",
"folder-open",
"gavel",
+ "git-fork",
"hash",
"heading",
"help-circle",
diff --git a/manager/media/style/common/icons/manager-icons.css b/manager/media/style/common/icons/manager-icons.css
index da062bc5b7..bb2f6b54f9 100644
--- a/manager/media/style/common/icons/manager-icons.css
+++ b/manager/media/style/common/icons/manager-icons.css
@@ -11,6 +11,7 @@
--evo-icon-arrow-back-up-double:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-arrow-back-up-double%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M13%2014l-4%20-4l4%20-4%22%20%2F%3E%20%3Cpath%20d%3D%22M8%2014l-4%20-4l4%20-4%22%20%2F%3E%20%3Cpath%20d%3D%22M9%2010h7a4%204%200%201%201%200%208h-1%22%20%2F%3E%20%3C%2Fsvg%3E");
--evo-icon-arrow-forward:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-arrow-forward%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M15%2011l4%204l-4%204m4%20-4h-11a4%204%200%200%201%200%20-8h1%22%20%2F%3E%20%3C%2Fsvg%3E");
--evo-icon-arrow-left:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-arrow-left%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M5%2012l14%200%22%20%2F%3E%20%3Cpath%20d%3D%22M5%2012l6%206%22%20%2F%3E%20%3Cpath%20d%3D%22M5%2012l6%20-6%22%20%2F%3E%20%3C%2Fsvg%3E");
+--evo-icon-arrow-right:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-arrow-right%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M5%2012l14%200%22%20%2F%3E%20%3Cpath%20d%3D%22M13%2018l6%20-6%22%20%2F%3E%20%3Cpath%20d%3D%22M13%206l6%206%22%20%2F%3E%20%3C%2Fsvg%3E");
--evo-icon-arrows-exchange:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-arrows-exchange%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M7%2010h14l-4%20-4%22%20%2F%3E%20%3Cpath%20d%3D%22M17%2014h-14l4%204%22%20%2F%3E%20%3C%2Fsvg%3E");
--evo-icon-arrows-maximize:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-arrows-maximize%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M16%204l4%200l0%204%22%20%2F%3E%20%3Cpath%20d%3D%22M14%2010l6%20-6%22%20%2F%3E%20%3Cpath%20d%3D%22M8%2020l-4%200l0%20-4%22%20%2F%3E%20%3Cpath%20d%3D%22M4%2020l6%20-6%22%20%2F%3E%20%3Cpath%20d%3D%22M16%2020l4%200l0%20-4%22%20%2F%3E%20%3Cpath%20d%3D%22M14%2014l6%206%22%20%2F%3E%20%3Cpath%20d%3D%22M8%204l-4%200l0%204%22%20%2F%3E%20%3Cpath%20d%3D%22M4%204l6%206%22%20%2F%3E%20%3C%2Fsvg%3E");
--evo-icon-arrows-move:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-arrows-move%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M18%209l3%203l-3%203%22%20%2F%3E%20%3Cpath%20d%3D%22M15%2012h6%22%20%2F%3E%20%3Cpath%20d%3D%22M6%209l-3%203l3%203%22%20%2F%3E%20%3Cpath%20d%3D%22M3%2012h6%22%20%2F%3E%20%3Cpath%20d%3D%22M9%2018l3%203l3%20-3%22%20%2F%3E%20%3Cpath%20d%3D%22M12%2015v6%22%20%2F%3E%20%3Cpath%20d%3D%22M15%206l-3%20-3l-3%203%22%20%2F%3E%20%3Cpath%20d%3D%22M12%203v6%22%20%2F%3E%20%3C%2Fsvg%3E");
@@ -42,6 +43,7 @@
--evo-icon-chevron-up:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-chevron-up%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M6%2015l6%20-6l6%206%22%20%2F%3E%20%3C%2Fsvg%3E");
--evo-icon-circle:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-circle%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M3%2012a9%209%200%201%200%2018%200a9%209%200%201%200%20-18%200%22%20%2F%3E%20%3C%2Fsvg%3E");
--evo-icon-circle-arrow-down:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-circle-arrow-down%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M3%2012a9%209%200%201%200%2018%200a9%209%200%200%200%20-18%200%22%20%2F%3E%20%3Cpath%20d%3D%22M8%2012l4%204%22%20%2F%3E%20%3Cpath%20d%3D%22M12%208v8%22%20%2F%3E%20%3Cpath%20d%3D%22M16%2012l-4%204%22%20%2F%3E%20%3C%2Fsvg%3E");
+--evo-icon-circle-arrow-right:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-circle-arrow-right%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M12%203a9%209%200%201%200%200%2018a9%209%200%200%200%200%20-18%22%20%2F%3E%20%3Cpath%20d%3D%22M16%2012l-4%20-4%22%20%2F%3E%20%3Cpath%20d%3D%22M16%2012h-8%22%20%2F%3E%20%3Cpath%20d%3D%22M12%2016l4%20-4%22%20%2F%3E%20%3C%2Fsvg%3E");
--evo-icon-circle-arrow-up:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-circle-arrow-up%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M3%2012a9%209%200%201%200%2018%200a9%209%200%200%200%20-18%200%22%20%2F%3E%20%3Cpath%20d%3D%22M12%208l-4%204%22%20%2F%3E%20%3Cpath%20d%3D%22M12%208v8%22%20%2F%3E%20%3Cpath%20d%3D%22M16%2012l-4%20-4%22%20%2F%3E%20%3C%2Fsvg%3E");
--evo-icon-circle-check:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-circle-check%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M3%2012a9%209%200%201%200%2018%200a9%209%200%201%200%20-18%200%22%20%2F%3E%20%3Cpath%20d%3D%22M9%2012l2%202l4%20-4%22%20%2F%3E%20%3C%2Fsvg%3E");
--evo-icon-circle-plus:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-circle-plus%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M3%2012a9%209%200%201%200%2018%200a9%209%200%200%200%20-18%200%22%20%2F%3E%20%3Cpath%20d%3D%22M9%2012h6%22%20%2F%3E%20%3Cpath%20d%3D%22M12%209v6%22%20%2F%3E%20%3C%2Fsvg%3E");
@@ -80,6 +82,7 @@
--evo-icon-folder:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-folder%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M5%204h4l3%203h7a2%202%200%200%201%202%202v8a2%202%200%200%201%20-2%202h-14a2%202%200%200%201%20-2%20-2v-11a2%202%200%200%201%202%20-2%22%20%2F%3E%20%3C%2Fsvg%3E");
--evo-icon-folder-open:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-folder-open%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M5%2019l2.757%20-7.351a1%201%200%200%201%20.936%20-.649h12.307a1%201%200%200%201%20.986%201.164l-.996%205.211a2%202%200%200%201%20-1.964%201.625h-14.026a2%202%200%200%201%20-2%20-2v-11a2%202%200%200%201%202%20-2h4l3%203h7a2%202%200%200%201%202%202v2%22%20%2F%3E%20%3C%2Fsvg%3E");
--evo-icon-gavel:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-gavel%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M13%2010l7.383%207.418c.823%20.82%20.823%202.148%200%202.967a2.11%202.11%200%200%201%20-2.976%200l-7.407%20-7.385%22%20%2F%3E%20%3Cpath%20d%3D%22M6%209l4%204%22%20%2F%3E%20%3Cpath%20d%3D%22M13%2010l-4%20-4%22%20%2F%3E%20%3Cpath%20d%3D%22M3%2021h7%22%20%2F%3E%20%3Cpath%20d%3D%22M6.793%2015.793l-3.586%20-3.586a1%201%200%200%201%200%20-1.414l2.293%20-2.293l.5%20.5l3%20-3l-.5%20-.5l2.293%20-2.293a1%201%200%200%201%201.414%200l3.586%203.586a1%201%200%200%201%200%201.414l-2.293%202.293l-.5%20-.5l-3%203l.5%20.5l-2.293%202.293a1%201%200%200%201%20-1.414%200%22%20%2F%3E%20%3C%2Fsvg%3E");
+--evo-icon-git-fork:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-git-fork%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M10%2018a2%202%200%201%200%204%200a2%202%200%201%200%20-4%200%22%20%2F%3E%20%3Cpath%20d%3D%22M5%206a2%202%200%201%200%204%200a2%202%200%201%200%20-4%200%22%20%2F%3E%20%3Cpath%20d%3D%22M15%206a2%202%200%201%200%204%200a2%202%200%201%200%20-4%200%22%20%2F%3E%20%3Cpath%20d%3D%22M7%208v2a2%202%200%200%200%202%202h6a2%202%200%200%200%202%20-2v-2%22%20%2F%3E%20%3Cpath%20d%3D%22M12%2012l0%204%22%20%2F%3E%20%3C%2Fsvg%3E");
--evo-icon-hash:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-hash%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M5%209l14%200%22%20%2F%3E%20%3Cpath%20d%3D%22M5%2015l14%200%22%20%2F%3E%20%3Cpath%20d%3D%22M11%204l-4%2016%22%20%2F%3E%20%3Cpath%20d%3D%22M17%204l-4%2016%22%20%2F%3E%20%3C%2Fsvg%3E");
--evo-icon-heading:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-heading%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M7%2012h10%22%20%2F%3E%20%3Cpath%20d%3D%22M7%205v14%22%20%2F%3E%20%3Cpath%20d%3D%22M17%205v14%22%20%2F%3E%20%3Cpath%20d%3D%22M15%2019h4%22%20%2F%3E%20%3Cpath%20d%3D%22M15%205h4%22%20%2F%3E%20%3Cpath%20d%3D%22M5%2019h4%22%20%2F%3E%20%3Cpath%20d%3D%22M5%205h4%22%20%2F%3E%20%3C%2Fsvg%3E");
--evo-icon-help-circle:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22icon%20icon-tabler%20icons-tabler-outline%20icon-tabler-help-circle%22%20%3E%20%3Cpath%20stroke%3D%22none%22%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%20%2F%3E%20%3Cpath%20d%3D%22M3%2012a9%209%200%201%200%2018%200a9%209%200%200%200%20-18%200%22%20%2F%3E%20%3Cpath%20d%3D%22M12%2016v.01%22%20%2F%3E%20%3Cpath%20d%3D%22M12%2013a2%202%200%200%200%20.914%20-3.782a1.98%201.98%200%200%200%20-2.414%20.483%22%20%2F%3E%20%3C%2Fsvg%3E");
@@ -192,6 +195,7 @@
.evo-icon-arrow-back-up-double{--evo-icon:var(--evo-icon-arrow-back-up-double)}
.evo-icon-arrow-forward{--evo-icon:var(--evo-icon-arrow-forward)}
.evo-icon-arrow-left{--evo-icon:var(--evo-icon-arrow-left)}
+.evo-icon-arrow-right{--evo-icon:var(--evo-icon-arrow-right)}
.evo-icon-arrows-exchange{--evo-icon:var(--evo-icon-arrows-exchange)}
.evo-icon-arrows-maximize{--evo-icon:var(--evo-icon-arrows-maximize)}
.evo-icon-arrows-move{--evo-icon:var(--evo-icon-arrows-move)}
@@ -223,6 +227,7 @@
.evo-icon-chevron-up{--evo-icon:var(--evo-icon-chevron-up)}
.evo-icon-circle{--evo-icon:var(--evo-icon-circle)}
.evo-icon-circle-arrow-down{--evo-icon:var(--evo-icon-circle-arrow-down)}
+.evo-icon-circle-arrow-right{--evo-icon:var(--evo-icon-circle-arrow-right)}
.evo-icon-circle-arrow-up{--evo-icon:var(--evo-icon-circle-arrow-up)}
.evo-icon-circle-check{--evo-icon:var(--evo-icon-circle-check)}
.evo-icon-circle-plus{--evo-icon:var(--evo-icon-circle-plus)}
@@ -261,6 +266,7 @@
.evo-icon-folder{--evo-icon:var(--evo-icon-folder)}
.evo-icon-folder-open{--evo-icon:var(--evo-icon-folder-open)}
.evo-icon-gavel{--evo-icon:var(--evo-icon-gavel)}
+.evo-icon-git-fork{--evo-icon:var(--evo-icon-git-fork)}
.evo-icon-hash{--evo-icon:var(--evo-icon-hash)}
.evo-icon-heading{--evo-icon:var(--evo-icon-heading)}
.evo-icon-help-circle{--evo-icon:var(--evo-icon-help-circle)}
diff --git a/manager/media/style/default/css/tree.css b/manager/media/style/default/css/tree.css
index c9a09c314b..60478bf508 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:"";display:inline-block;width:1em;height:1em;vertical-align:middle;background-color:currentColor;-webkit-mask:var(--evo-icon-caret-right) center/contain no-repeat;mask:var(--evo-icon-caret-right) center/contain no-repeat; display: block; position: absolute; right: 0.1em; bottom: -0.5em; height: 1em; font: bold 1em/1em sans-serif; 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 > .evo-icon { 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:"";display:inline-block;width:1em;height:1em;vertical-align:middle;background-color:currentColor;-webkit-mask:var(--evo-icon-caret-right) center/contain no-repeat;mask:var(--evo-icon-caret-right) center/contain no-repeat; display: block; position: absolute; right: 0.1em; bottom: -0.5em; height: 1em; font: bold 1em/1em sans-serif; 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: ""; position: absolute; left: -1em; top: -0.5em; width: 1em; height: 1em; font-size: 0.875em; background-color: currentColor; -webkit-mask: var(--evo-icon-circle-arrow-right) center/contain no-repeat; mask: var(--evo-icon-circle-arrow-right) center/contain no-repeat; color: #22a447; }
+#treeDropMarker.line::before { margin-top: -1px; }
.treeRoot .icon { cursor: pointer; -webkit-transition-duration: 0.15s; transition-duration: 0.15s }
.treeRoot .icon:hover > .evo-icon { 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 }
@@ -122,6 +122,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 7e4a7d40c5..229a3726f0 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) {
@@ -1082,11 +1084,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) {
@@ -1109,6 +1108,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 () {
@@ -1118,10 +1118,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));
},
@@ -1129,148 +1134,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) {
@@ -1291,7 +1386,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)) {
@@ -1764,15 +1859,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();
});
@@ -2101,15 +2199,23 @@
history.go(1);
};
evo.alert(message);
- evo.getLockedElements(evo.getActionFromUrl(this.url), evo.main.getQueryVariable('id', this.url), function (data) {
- if (!!data || ~s.closeactions.indexOf(evo.getActionFromUrl(s.url))) {
- s.page.close();
- evo.tree.restoreTree();
- } else {
- w.main.location.href = evo.EVO_MANAGER_URL + s.url;
- w.history.replaceState(null, d.title, evo.tabsHistoryUrl(s.url));
- }
- });
+ var alertAction = evo.getActionFromUrl(this.url),
+ alertId = evo.main.getQueryVariable('id', this.url);
+ // only an element page has a lock to look up; any other page that alerts (System Settings
+ // held by another manager, no privileges) has nothing left to show, so its tab goes
+ if (!evo.typesactions[alertAction] || !alertId) {
+ s.page.close();
+ } else {
+ evo.getLockedElements(alertAction, alertId, function (data) {
+ if (!!data || ~s.closeactions.indexOf(evo.getActionFromUrl(s.url))) {
+ s.page.close();
+ evo.tree.restoreTree();
+ } else {
+ w.main.location.href = evo.EVO_MANAGER_URL + s.url;
+ w.history.replaceState(null, d.title, evo.tabsHistoryUrl(s.url));
+ }
+ });
+ }
} else {
if (evo.getActionFromUrl(this.url, 2) || (~this.saveAndCloseActions.indexOf(evo.getActionFromUrl(this.url)) && parseInt(evo.main.getQueryVariable('r', this.url)))) {
this.close(e);
diff --git a/manager/views/frame/1.blade.php b/manager/views/frame/1.blade.php
index 1b7c2bb7dc..088b545023 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;
+
diff --git a/manager/views/page/2.blade.php b/manager/views/page/2.blade.php
index c522bf2791..19f4bad214 100644
--- a/manager/views/page/2.blade.php
+++ b/manager/views/page/2.blade.php
@@ -21,7 +21,9 @@
get('ManagerTheme')->getFileProcessor("actions/welcome.static.php");*/
unset($_SESSION['itemname'], $_SESSION['itemaction']); // clear this, because it's only set for logging purposes
- if (evo()->hasPermission('settings') && evo()->getConfig('settings_version') !== evo()->getVersionData('version')) {
+ // not while another manager holds the settings: their page would alert and go back here, and loop
+ if (evo()->hasPermission('settings') && evo()->getConfig('settings_version') !== evo()->getVersionData('version')
+ && !\EvolutionCMS\Models\ActiveUser::locked(17)->exists()) {
// seems to be a new install - send the user to the configuration page
exit('');
}