mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
feat: keep deleted files in a Files trash with Restore, Delete permanently and Empty trash
This commit is contained in:
+20
-1
@@ -3917,7 +3917,26 @@
|
|||||||
"zoom_out": "تصغير",
|
"zoom_out": "تصغير",
|
||||||
"zoom_reset": "ملاءمة الشاشة",
|
"zoom_reset": "ملاءمة الشاشة",
|
||||||
"rotate": "تدوير",
|
"rotate": "تدوير",
|
||||||
"close_preview": "إغلاق"
|
"close_preview": "إغلاق",
|
||||||
|
"trash": "سلة المهملات",
|
||||||
|
"restore": "استعادة",
|
||||||
|
"restored": "{count, plural, one {تمت استعادة العنصر} other {تمت استعادة # عنصر}}",
|
||||||
|
"restore_error": "تعذّرت الاستعادة",
|
||||||
|
"moved_to_trash": "{count, plural, one {نُقل إلى سلة المهملات} other {نُقل # عنصر إلى سلة المهملات}}",
|
||||||
|
"delete_forever": "حذف نهائي",
|
||||||
|
"delete_forever_title": "{count, plural, one {حذف نهائي؟} other {حذف # عنصر نهائيًا؟}}",
|
||||||
|
"delete_forever_message": "سيُحذف \"{name}\". لا يمكنك التراجع عن ذلك.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {سيُحذف عنصر واحد} other {سيُحذف # عنصر}}. لا يمكنك التراجع عن ذلك.",
|
||||||
|
"deleted_forever": "{count, plural, one {حُذف نهائيًا} other {حُذف # عنصر نهائيًا}}",
|
||||||
|
"empty_trash": "إفراغ سلة المهملات",
|
||||||
|
"empty_trash_title": "إفراغ سلة المهملات؟",
|
||||||
|
"empty_trash_message": "{count, plural, one {سيُحذف العنصر الموجود في سلة المهملات} other {ستُحذف جميع العناصر (#) في سلة المهملات}} نهائيًا. لا يمكنك التراجع عن ذلك.",
|
||||||
|
"trash_emptied": "أُفرغت سلة المهملات",
|
||||||
|
"empty_trash_error": "تعذّر إفراغ سلة المهملات",
|
||||||
|
"trash_empty_title": "سلة المهملات فارغة",
|
||||||
|
"trash_empty_hint": "تبقى الملفات المحذوفة هنا حتى تُفرغ سلة المهملات.",
|
||||||
|
"deleted": "تاريخ الحذف",
|
||||||
|
"deleted_from": "حُذف من {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "شهاداتك",
|
"your_certificates": "شهاداتك",
|
||||||
|
|||||||
+20
-1
@@ -3917,7 +3917,26 @@
|
|||||||
"zoom_out": "Redueix",
|
"zoom_out": "Redueix",
|
||||||
"zoom_reset": "Ajusta a la pantalla",
|
"zoom_reset": "Ajusta a la pantalla",
|
||||||
"rotate": "Gira",
|
"rotate": "Gira",
|
||||||
"close_preview": "Tanca"
|
"close_preview": "Tanca",
|
||||||
|
"trash": "Paperera",
|
||||||
|
"restore": "Restaura",
|
||||||
|
"restored": "{count, plural, one {S'ha restaurat l'element} other {S'han restaurat # elements}}",
|
||||||
|
"restore_error": "No s'ha pogut restaurar",
|
||||||
|
"moved_to_trash": "{count, plural, one {S'ha mogut a la paperera} other {S'han mogut # elements a la paperera}}",
|
||||||
|
"delete_forever": "Suprimeix definitivament",
|
||||||
|
"delete_forever_title": "{count, plural, one {Voleu suprimir-lo definitivament?} other {Voleu suprimir # elements definitivament?}}",
|
||||||
|
"delete_forever_message": "Se suprimirà «{name}». No es pot desfer.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {Se suprimirà 1 element} other {Se suprimiran # elements}}. No es pot desfer.",
|
||||||
|
"deleted_forever": "{count, plural, one {S'ha suprimit definitivament} other {S'han suprimit # elements definitivament}}",
|
||||||
|
"empty_trash": "Buida la paperera",
|
||||||
|
"empty_trash_title": "Voleu buidar la paperera?",
|
||||||
|
"empty_trash_message": "{count, plural, one {L'element de la paperera se suprimirà} other {Els # elements de la paperera se suprimiran}} definitivament. No es pot desfer.",
|
||||||
|
"trash_emptied": "S'ha buidat la paperera",
|
||||||
|
"empty_trash_error": "No s'ha pogut buidar la paperera",
|
||||||
|
"trash_empty_title": "La paperera és buida",
|
||||||
|
"trash_empty_hint": "Els fitxers suprimits es queden aquí fins que buideu la paperera.",
|
||||||
|
"deleted": "Suprimit",
|
||||||
|
"deleted_from": "Suprimit de {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Els vostres certificats",
|
"your_certificates": "Els vostres certificats",
|
||||||
|
|||||||
+20
-1
@@ -3875,7 +3875,26 @@
|
|||||||
"zoom_out": "Oddálit",
|
"zoom_out": "Oddálit",
|
||||||
"zoom_reset": "Přizpůsobit obrazovce",
|
"zoom_reset": "Přizpůsobit obrazovce",
|
||||||
"rotate": "Otočit",
|
"rotate": "Otočit",
|
||||||
"close_preview": "Zavřít"
|
"close_preview": "Zavřít",
|
||||||
|
"trash": "Koš",
|
||||||
|
"restore": "Obnovit",
|
||||||
|
"restored": "{count, plural, one {Položka obnovena} few {Obnoveny # položky} other {Obnoveno # položek}}",
|
||||||
|
"restore_error": "Obnovení se nezdařilo",
|
||||||
|
"moved_to_trash": "{count, plural, one {Přesunuto do koše} few {# položky přesunuty do koše} other {# položek přesunuto do koše}}",
|
||||||
|
"delete_forever": "Trvale smazat",
|
||||||
|
"delete_forever_title": "{count, plural, one {Trvale smazat?} few {Trvale smazat # položky?} other {Trvale smazat # položek?}}",
|
||||||
|
"delete_forever_message": "Položka „{name}“ bude smazána. Tuto akci nelze vrátit.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {Bude smazána 1 položka} few {Budou smazány # položky} other {Bude smazáno # položek}}. Tuto akci nelze vrátit.",
|
||||||
|
"deleted_forever": "{count, plural, one {Trvale smazáno} few {Trvale smazány # položky} other {Trvale smazáno # položek}}",
|
||||||
|
"empty_trash": "Vysypat koš",
|
||||||
|
"empty_trash_title": "Vysypat koš?",
|
||||||
|
"empty_trash_message": "{count, plural, one {Položka v koši bude trvale smazána} few {Všechny # položky v koši budou trvale smazány} other {Všech # položek v koši bude trvale smazáno}}. Tuto akci nelze vrátit.",
|
||||||
|
"trash_emptied": "Koš byl vysypán",
|
||||||
|
"empty_trash_error": "Koš se nepodařilo vysypat",
|
||||||
|
"trash_empty_title": "Koš je prázdný",
|
||||||
|
"trash_empty_hint": "Smazané soubory zůstávají zde, dokud koš nevysypete.",
|
||||||
|
"deleted": "Smazáno",
|
||||||
|
"deleted_from": "Smazáno z {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Vaše certifikáty",
|
"your_certificates": "Vaše certifikáty",
|
||||||
|
|||||||
+20
-1
@@ -3917,7 +3917,26 @@
|
|||||||
"zoom_out": "Zoom ud",
|
"zoom_out": "Zoom ud",
|
||||||
"zoom_reset": "Tilpas til skærm",
|
"zoom_reset": "Tilpas til skærm",
|
||||||
"rotate": "Rotér",
|
"rotate": "Rotér",
|
||||||
"close_preview": "Luk"
|
"close_preview": "Luk",
|
||||||
|
"trash": "Papirkurv",
|
||||||
|
"restore": "Gendan",
|
||||||
|
"restored": "{count, plural, one {Elementet er gendannet} other {# elementer er gendannet}}",
|
||||||
|
"restore_error": "Kunne ikke gendanne",
|
||||||
|
"moved_to_trash": "{count, plural, one {Flyttet til papirkurven} other {# elementer flyttet til papirkurven}}",
|
||||||
|
"delete_forever": "Slet permanent",
|
||||||
|
"delete_forever_title": "{count, plural, one {Slet permanent?} other {Slet # elementer permanent?}}",
|
||||||
|
"delete_forever_message": "\"{name}\" bliver slettet. Det kan ikke fortrydes.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {1 element bliver slettet} other {# elementer bliver slettet}}. Det kan ikke fortrydes.",
|
||||||
|
"deleted_forever": "{count, plural, one {Slettet permanent} other {# elementer slettet permanent}}",
|
||||||
|
"empty_trash": "Tøm papirkurven",
|
||||||
|
"empty_trash_title": "Tøm papirkurven?",
|
||||||
|
"empty_trash_message": "{count, plural, one {Elementet i papirkurven} other {Alle # elementer i papirkurven}} bliver slettet permanent. Det kan ikke fortrydes.",
|
||||||
|
"trash_emptied": "Papirkurven er tømt",
|
||||||
|
"empty_trash_error": "Kunne ikke tømme papirkurven",
|
||||||
|
"trash_empty_title": "Papirkurven er tom",
|
||||||
|
"trash_empty_hint": "Slettede filer bliver her, indtil du tømmer papirkurven.",
|
||||||
|
"deleted": "Slettet",
|
||||||
|
"deleted_from": "Slettet fra {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Dine certifikater",
|
"your_certificates": "Dine certifikater",
|
||||||
|
|||||||
+20
-1
@@ -3875,7 +3875,26 @@
|
|||||||
"zoom_out": "Verkleinern",
|
"zoom_out": "Verkleinern",
|
||||||
"zoom_reset": "An Bildschirm anpassen",
|
"zoom_reset": "An Bildschirm anpassen",
|
||||||
"rotate": "Drehen",
|
"rotate": "Drehen",
|
||||||
"close_preview": "Schließen"
|
"close_preview": "Schließen",
|
||||||
|
"trash": "Papierkorb",
|
||||||
|
"restore": "Wiederherstellen",
|
||||||
|
"restored": "{count, plural, one {Element wiederhergestellt} other {# Elemente wiederhergestellt}}",
|
||||||
|
"restore_error": "Wiederherstellen fehlgeschlagen",
|
||||||
|
"moved_to_trash": "{count, plural, one {In den Papierkorb verschoben} other {# Elemente in den Papierkorb verschoben}}",
|
||||||
|
"delete_forever": "Endgültig löschen",
|
||||||
|
"delete_forever_title": "{count, plural, one {Endgültig löschen?} other {# Elemente endgültig löschen?}}",
|
||||||
|
"delete_forever_message": "„{name}“ wird gelöscht. Das lässt sich nicht rückgängig machen.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {1 Element wird gelöscht} other {# Elemente werden gelöscht}}. Das lässt sich nicht rückgängig machen.",
|
||||||
|
"deleted_forever": "{count, plural, one {Endgültig gelöscht} other {# Elemente endgültig gelöscht}}",
|
||||||
|
"empty_trash": "Papierkorb leeren",
|
||||||
|
"empty_trash_title": "Papierkorb leeren?",
|
||||||
|
"empty_trash_message": "{count, plural, one {Das Element im Papierkorb wird} other {Alle # Elemente im Papierkorb werden}} endgültig gelöscht. Das lässt sich nicht rückgängig machen.",
|
||||||
|
"trash_emptied": "Papierkorb geleert",
|
||||||
|
"empty_trash_error": "Papierkorb konnte nicht geleert werden",
|
||||||
|
"trash_empty_title": "Der Papierkorb ist leer",
|
||||||
|
"trash_empty_hint": "Gelöschte Dateien bleiben hier, bis Sie den Papierkorb leeren.",
|
||||||
|
"deleted": "Gelöscht",
|
||||||
|
"deleted_from": "Gelöscht aus {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Ihre Zertifikate",
|
"your_certificates": "Ihre Zertifikate",
|
||||||
|
|||||||
+20
-1
@@ -3917,7 +3917,26 @@
|
|||||||
"zoom_out": "Zoom out",
|
"zoom_out": "Zoom out",
|
||||||
"zoom_reset": "Fit to screen",
|
"zoom_reset": "Fit to screen",
|
||||||
"rotate": "Rotate",
|
"rotate": "Rotate",
|
||||||
"close_preview": "Close"
|
"close_preview": "Close",
|
||||||
|
"trash": "Trash",
|
||||||
|
"restore": "Restore",
|
||||||
|
"restored": "{count, plural, one {Item restored} other {# items restored}}",
|
||||||
|
"restore_error": "Couldn't restore",
|
||||||
|
"moved_to_trash": "{count, plural, one {Moved to trash} other {# items moved to trash}}",
|
||||||
|
"delete_forever": "Delete permanently",
|
||||||
|
"delete_forever_title": "{count, plural, one {Delete permanently?} other {Delete # items permanently?}}",
|
||||||
|
"delete_forever_message": "\"{name}\" will be deleted. You can't undo this.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {1 item} other {# items}} will be deleted. You can't undo this.",
|
||||||
|
"deleted_forever": "{count, plural, one {Deleted permanently} other {# items deleted permanently}}",
|
||||||
|
"empty_trash": "Empty trash",
|
||||||
|
"empty_trash_title": "Empty trash?",
|
||||||
|
"empty_trash_message": "{count, plural, one {The item in the trash} other {All # items in the trash}} will be deleted permanently. You can't undo this.",
|
||||||
|
"trash_emptied": "Trash emptied",
|
||||||
|
"empty_trash_error": "Couldn't empty the trash",
|
||||||
|
"trash_empty_title": "Trash is empty",
|
||||||
|
"trash_empty_hint": "Deleted files stay here until you empty the trash.",
|
||||||
|
"deleted": "Deleted",
|
||||||
|
"deleted_from": "Deleted from {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Your Certificates",
|
"your_certificates": "Your Certificates",
|
||||||
|
|||||||
+20
-1
@@ -3875,7 +3875,26 @@
|
|||||||
"zoom_out": "Alejar",
|
"zoom_out": "Alejar",
|
||||||
"zoom_reset": "Ajustar a la pantalla",
|
"zoom_reset": "Ajustar a la pantalla",
|
||||||
"rotate": "Girar",
|
"rotate": "Girar",
|
||||||
"close_preview": "Cerrar"
|
"close_preview": "Cerrar",
|
||||||
|
"trash": "Papelera",
|
||||||
|
"restore": "Restaurar",
|
||||||
|
"restored": "{count, plural, one {Elemento restaurado} other {# elementos restaurados}}",
|
||||||
|
"restore_error": "No se ha podido restaurar",
|
||||||
|
"moved_to_trash": "{count, plural, one {Movido a la papelera} other {# elementos movidos a la papelera}}",
|
||||||
|
"delete_forever": "Eliminar definitivamente",
|
||||||
|
"delete_forever_title": "{count, plural, one {¿Eliminar definitivamente?} other {¿Eliminar # elementos definitivamente?}}",
|
||||||
|
"delete_forever_message": "Se eliminará «{name}». No se puede deshacer.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {Se eliminará 1 elemento} other {Se eliminarán # elementos}}. No se puede deshacer.",
|
||||||
|
"deleted_forever": "{count, plural, one {Eliminado definitivamente} other {# elementos eliminados definitivamente}}",
|
||||||
|
"empty_trash": "Vaciar la papelera",
|
||||||
|
"empty_trash_title": "¿Vaciar la papelera?",
|
||||||
|
"empty_trash_message": "{count, plural, one {El elemento de la papelera se eliminará} other {Los # elementos de la papelera se eliminarán}} definitivamente. No se puede deshacer.",
|
||||||
|
"trash_emptied": "Papelera vaciada",
|
||||||
|
"empty_trash_error": "No se ha podido vaciar la papelera",
|
||||||
|
"trash_empty_title": "La papelera está vacía",
|
||||||
|
"trash_empty_hint": "Los archivos eliminados se quedan aquí hasta que vacíes la papelera.",
|
||||||
|
"deleted": "Eliminado",
|
||||||
|
"deleted_from": "Eliminado de {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Tus certificados",
|
"your_certificates": "Tus certificados",
|
||||||
|
|||||||
+20
-1
@@ -3917,7 +3917,26 @@
|
|||||||
"zoom_out": "Suumi välja",
|
"zoom_out": "Suumi välja",
|
||||||
"zoom_reset": "Sobita ekraanile",
|
"zoom_reset": "Sobita ekraanile",
|
||||||
"rotate": "Pööra",
|
"rotate": "Pööra",
|
||||||
"close_preview": "Sulge"
|
"close_preview": "Sulge",
|
||||||
|
"trash": "Prügikast",
|
||||||
|
"restore": "Taasta",
|
||||||
|
"restored": "{count, plural, one {Üksus taastati} other {# üksust taastati}}",
|
||||||
|
"restore_error": "Taastamine ebaõnnestus",
|
||||||
|
"moved_to_trash": "{count, plural, one {Teisaldati prügikasti} other {# üksust teisaldati prügikasti}}",
|
||||||
|
"delete_forever": "Kustuta jäädavalt",
|
||||||
|
"delete_forever_title": "{count, plural, one {Kas kustutada jäädavalt?} other {Kas kustutada # üksust jäädavalt?}}",
|
||||||
|
"delete_forever_message": "„{name}“ kustutatakse. Seda ei saa tagasi võtta.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {Kustutatakse 1 üksus} other {Kustutatakse # üksust}}. Seda ei saa tagasi võtta.",
|
||||||
|
"deleted_forever": "{count, plural, one {Kustutati jäädavalt} other {# üksust kustutati jäädavalt}}",
|
||||||
|
"empty_trash": "Tühjenda prügikast",
|
||||||
|
"empty_trash_title": "Kas tühjendada prügikast?",
|
||||||
|
"empty_trash_message": "{count, plural, one {Prügikastis olev üksus} other {Kõik # prügikastis olevat üksust}} kustutatakse jäädavalt. Seda ei saa tagasi võtta.",
|
||||||
|
"trash_emptied": "Prügikast tühjendati",
|
||||||
|
"empty_trash_error": "Prügikasti ei õnnestunud tühjendada",
|
||||||
|
"trash_empty_title": "Prügikast on tühi",
|
||||||
|
"trash_empty_hint": "Kustutatud failid jäävad siia, kuni prügikasti tühjendate.",
|
||||||
|
"deleted": "Kustutatud",
|
||||||
|
"deleted_from": "Kustutatud kaustast {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Sinu sertifikaadid",
|
"your_certificates": "Sinu sertifikaadid",
|
||||||
|
|||||||
+20
-1
@@ -3917,7 +3917,26 @@
|
|||||||
"zoom_out": "کوچکنمایی",
|
"zoom_out": "کوچکنمایی",
|
||||||
"zoom_reset": "متناسب با صفحه",
|
"zoom_reset": "متناسب با صفحه",
|
||||||
"rotate": "چرخش",
|
"rotate": "چرخش",
|
||||||
"close_preview": "بستن"
|
"close_preview": "بستن",
|
||||||
|
"trash": "سطل زباله",
|
||||||
|
"restore": "بازیابی",
|
||||||
|
"restored": "{count, plural, one {مورد بازیابی شد} other {# مورد بازیابی شد}}",
|
||||||
|
"restore_error": "بازیابی انجام نشد",
|
||||||
|
"moved_to_trash": "{count, plural, one {به سطل زباله منتقل شد} other {# مورد به سطل زباله منتقل شد}}",
|
||||||
|
"delete_forever": "حذف دائمی",
|
||||||
|
"delete_forever_title": "{count, plural, one {برای همیشه حذف شود؟} other {# مورد برای همیشه حذف شود؟}}",
|
||||||
|
"delete_forever_message": "«{name}» حذف میشود. این کار قابل بازگشت نیست.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {۱ مورد حذف میشود} other {# مورد حذف میشود}}. این کار قابل بازگشت نیست.",
|
||||||
|
"deleted_forever": "{count, plural, one {برای همیشه حذف شد} other {# مورد برای همیشه حذف شد}}",
|
||||||
|
"empty_trash": "خالی کردن سطل زباله",
|
||||||
|
"empty_trash_title": "سطل زباله خالی شود؟",
|
||||||
|
"empty_trash_message": "{count, plural, one {مورد داخل سطل زباله} other {همه # مورد داخل سطل زباله}} برای همیشه حذف میشود. این کار قابل بازگشت نیست.",
|
||||||
|
"trash_emptied": "سطل زباله خالی شد",
|
||||||
|
"empty_trash_error": "سطل زباله خالی نشد",
|
||||||
|
"trash_empty_title": "سطل زباله خالی است",
|
||||||
|
"trash_empty_hint": "فایلهای حذفشده تا زمانی که سطل زباله را خالی کنید اینجا میمانند.",
|
||||||
|
"deleted": "حذفشده",
|
||||||
|
"deleted_from": "حذفشده از {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "گواهیهای شما",
|
"your_certificates": "گواهیهای شما",
|
||||||
|
|||||||
+20
-1
@@ -3875,7 +3875,26 @@
|
|||||||
"zoom_out": "Zoom arrière",
|
"zoom_out": "Zoom arrière",
|
||||||
"zoom_reset": "Ajuster à l'écran",
|
"zoom_reset": "Ajuster à l'écran",
|
||||||
"rotate": "Pivoter",
|
"rotate": "Pivoter",
|
||||||
"close_preview": "Fermer"
|
"close_preview": "Fermer",
|
||||||
|
"trash": "Corbeille",
|
||||||
|
"restore": "Restaurer",
|
||||||
|
"restored": "{count, plural, one {Élément restauré} other {# éléments restaurés}}",
|
||||||
|
"restore_error": "Impossible de restaurer",
|
||||||
|
"moved_to_trash": "{count, plural, one {Placé dans la corbeille} other {# éléments placés dans la corbeille}}",
|
||||||
|
"delete_forever": "Supprimer définitivement",
|
||||||
|
"delete_forever_title": "{count, plural, one {Supprimer définitivement ?} other {Supprimer définitivement # éléments ?}}",
|
||||||
|
"delete_forever_message": "« {name} » sera supprimé. Cette action est irréversible.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {1 élément sera supprimé} other {# éléments seront supprimés}}. Cette action est irréversible.",
|
||||||
|
"deleted_forever": "{count, plural, one {Supprimé définitivement} other {# éléments supprimés définitivement}}",
|
||||||
|
"empty_trash": "Vider la corbeille",
|
||||||
|
"empty_trash_title": "Vider la corbeille ?",
|
||||||
|
"empty_trash_message": "{count, plural, one {L'élément de la corbeille sera supprimé} other {Les # éléments de la corbeille seront supprimés}} définitivement. Cette action est irréversible.",
|
||||||
|
"trash_emptied": "Corbeille vidée",
|
||||||
|
"empty_trash_error": "Impossible de vider la corbeille",
|
||||||
|
"trash_empty_title": "La corbeille est vide",
|
||||||
|
"trash_empty_hint": "Les fichiers supprimés restent ici jusqu'à ce que vous vidiez la corbeille.",
|
||||||
|
"deleted": "Supprimé le",
|
||||||
|
"deleted_from": "Supprimé de {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Vos certificats",
|
"your_certificates": "Vos certificats",
|
||||||
|
|||||||
+20
-1
@@ -3793,7 +3793,26 @@
|
|||||||
"zoom_out": "הקטן",
|
"zoom_out": "הקטן",
|
||||||
"zoom_reset": "התאם למסך",
|
"zoom_reset": "התאם למסך",
|
||||||
"rotate": "סובב",
|
"rotate": "סובב",
|
||||||
"close_preview": "סגור"
|
"close_preview": "סגור",
|
||||||
|
"trash": "אשפה",
|
||||||
|
"restore": "שחזר",
|
||||||
|
"restored": "{count, plural, one {הפריט שוחזר} other {# פריטים שוחזרו}}",
|
||||||
|
"restore_error": "לא ניתן היה לשחזר",
|
||||||
|
"moved_to_trash": "{count, plural, one {הועבר לאשפה} other {# פריטים הועברו לאשפה}}",
|
||||||
|
"delete_forever": "מחק לצמיתות",
|
||||||
|
"delete_forever_title": "{count, plural, one {למחוק לצמיתות?} other {למחוק # פריטים לצמיתות?}}",
|
||||||
|
"delete_forever_message": "\"{name}\" יימחק. לא ניתן לבטל פעולה זו.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {פריט אחד יימחק} other {# פריטים יימחקו}}. לא ניתן לבטל פעולה זו.",
|
||||||
|
"deleted_forever": "{count, plural, one {נמחק לצמיתות} other {# פריטים נמחקו לצמיתות}}",
|
||||||
|
"empty_trash": "רוקן את האשפה",
|
||||||
|
"empty_trash_title": "לרוקן את האשפה?",
|
||||||
|
"empty_trash_message": "{count, plural, one {הפריט באשפה יימחק} other {כל # הפריטים באשפה יימחקו}} לצמיתות. לא ניתן לבטל פעולה זו.",
|
||||||
|
"trash_emptied": "האשפה רוקנה",
|
||||||
|
"empty_trash_error": "לא ניתן היה לרוקן את האשפה",
|
||||||
|
"trash_empty_title": "האשפה ריקה",
|
||||||
|
"trash_empty_hint": "קבצים שנמחקו נשארים כאן עד שתרוקן את האשפה.",
|
||||||
|
"deleted": "נמחק",
|
||||||
|
"deleted_from": "נמחק מ-{folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "התעודות שלך",
|
"your_certificates": "התעודות שלך",
|
||||||
|
|||||||
+20
-1
@@ -3917,7 +3917,26 @@
|
|||||||
"zoom_out": "Kicsinyítés",
|
"zoom_out": "Kicsinyítés",
|
||||||
"zoom_reset": "Igazítás a képernyőhöz",
|
"zoom_reset": "Igazítás a képernyőhöz",
|
||||||
"rotate": "Forgatás",
|
"rotate": "Forgatás",
|
||||||
"close_preview": "Bezárás"
|
"close_preview": "Bezárás",
|
||||||
|
"trash": "Kuka",
|
||||||
|
"restore": "Visszaállítás",
|
||||||
|
"restored": "{count, plural, one {Elem visszaállítva} other {# elem visszaállítva}}",
|
||||||
|
"restore_error": "Nem sikerült visszaállítani",
|
||||||
|
"moved_to_trash": "{count, plural, one {Áthelyezve a kukába} other {# elem áthelyezve a kukába}}",
|
||||||
|
"delete_forever": "Végleges törlés",
|
||||||
|
"delete_forever_title": "{count, plural, one {Véglegesen törli?} other {Véglegesen törli a(z) # elemet?}}",
|
||||||
|
"delete_forever_message": "A(z) „{name}” törlődik. Ez nem vonható vissza.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {1 elem törlődik} other {# elem törlődik}}. Ez nem vonható vissza.",
|
||||||
|
"deleted_forever": "{count, plural, one {Véglegesen törölve} other {# elem véglegesen törölve}}",
|
||||||
|
"empty_trash": "Kuka ürítése",
|
||||||
|
"empty_trash_title": "Kiüríti a kukát?",
|
||||||
|
"empty_trash_message": "{count, plural, one {A kukában lévő elem} other {A kukában lévő mind a(z) # elem}} véglegesen törlődik. Ez nem vonható vissza.",
|
||||||
|
"trash_emptied": "Kuka kiürítve",
|
||||||
|
"empty_trash_error": "Nem sikerült kiüríteni a kukát",
|
||||||
|
"trash_empty_title": "A kuka üres",
|
||||||
|
"trash_empty_hint": "A törölt fájlok itt maradnak, amíg ki nem üríti a kukát.",
|
||||||
|
"deleted": "Törölve",
|
||||||
|
"deleted_from": "Törölve innen: {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Tanúsítványaid",
|
"your_certificates": "Tanúsítványaid",
|
||||||
|
|||||||
+20
-1
@@ -3875,7 +3875,26 @@
|
|||||||
"zoom_out": "Riduci",
|
"zoom_out": "Riduci",
|
||||||
"zoom_reset": "Adatta allo schermo",
|
"zoom_reset": "Adatta allo schermo",
|
||||||
"rotate": "Ruota",
|
"rotate": "Ruota",
|
||||||
"close_preview": "Chiudi"
|
"close_preview": "Chiudi",
|
||||||
|
"trash": "Cestino",
|
||||||
|
"restore": "Ripristina",
|
||||||
|
"restored": "{count, plural, one {Elemento ripristinato} other {# elementi ripristinati}}",
|
||||||
|
"restore_error": "Impossibile ripristinare",
|
||||||
|
"moved_to_trash": "{count, plural, one {Spostato nel cestino} other {# elementi spostati nel cestino}}",
|
||||||
|
"delete_forever": "Elimina definitivamente",
|
||||||
|
"delete_forever_title": "{count, plural, one {Eliminare definitivamente?} other {Eliminare definitivamente # elementi?}}",
|
||||||
|
"delete_forever_message": "«{name}» verrà eliminato. L'operazione non può essere annullata.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {Verrà eliminato 1 elemento} other {Verranno eliminati # elementi}}. L'operazione non può essere annullata.",
|
||||||
|
"deleted_forever": "{count, plural, one {Eliminato definitivamente} other {# elementi eliminati definitivamente}}",
|
||||||
|
"empty_trash": "Svuota il cestino",
|
||||||
|
"empty_trash_title": "Svuotare il cestino?",
|
||||||
|
"empty_trash_message": "{count, plural, one {L'elemento nel cestino verrà eliminato} other {Tutti i # elementi nel cestino verranno eliminati}} definitivamente. L'operazione non può essere annullata.",
|
||||||
|
"trash_emptied": "Cestino svuotato",
|
||||||
|
"empty_trash_error": "Impossibile svuotare il cestino",
|
||||||
|
"trash_empty_title": "Il cestino è vuoto",
|
||||||
|
"trash_empty_hint": "I file eliminati restano qui finché non svuoti il cestino.",
|
||||||
|
"deleted": "Eliminato",
|
||||||
|
"deleted_from": "Eliminato da {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "I tuoi certificati",
|
"your_certificates": "I tuoi certificati",
|
||||||
|
|||||||
+20
-1
@@ -3875,7 +3875,26 @@
|
|||||||
"zoom_out": "縮小",
|
"zoom_out": "縮小",
|
||||||
"zoom_reset": "画面に合わせる",
|
"zoom_reset": "画面に合わせる",
|
||||||
"rotate": "回転",
|
"rotate": "回転",
|
||||||
"close_preview": "閉じる"
|
"close_preview": "閉じる",
|
||||||
|
"trash": "ゴミ箱",
|
||||||
|
"restore": "復元",
|
||||||
|
"restored": "{count, plural, other {#件のアイテムを復元しました}}",
|
||||||
|
"restore_error": "復元できませんでした",
|
||||||
|
"moved_to_trash": "{count, plural, other {#件のアイテムをゴミ箱に移動しました}}",
|
||||||
|
"delete_forever": "完全に削除",
|
||||||
|
"delete_forever_title": "{count, plural, other {#件のアイテムを完全に削除しますか?}}",
|
||||||
|
"delete_forever_message": "「{name}」は削除されます。この操作は元に戻せません。",
|
||||||
|
"delete_forever_message_count": "{count, plural, other {#件のアイテムが削除されます}}。この操作は元に戻せません。",
|
||||||
|
"deleted_forever": "{count, plural, other {#件のアイテムを完全に削除しました}}",
|
||||||
|
"empty_trash": "ゴミ箱を空にする",
|
||||||
|
"empty_trash_title": "ゴミ箱を空にしますか?",
|
||||||
|
"empty_trash_message": "{count, plural, other {ゴミ箱内の#件のアイテムがすべて完全に削除されます}}。この操作は元に戻せません。",
|
||||||
|
"trash_emptied": "ゴミ箱を空にしました",
|
||||||
|
"empty_trash_error": "ゴミ箱を空にできませんでした",
|
||||||
|
"trash_empty_title": "ゴミ箱は空です",
|
||||||
|
"trash_empty_hint": "削除したファイルはゴミ箱を空にするまでここに残ります。",
|
||||||
|
"deleted": "削除日",
|
||||||
|
"deleted_from": "{folder} から削除"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "あなたの証明書",
|
"your_certificates": "あなたの証明書",
|
||||||
|
|||||||
+20
-1
@@ -3875,7 +3875,26 @@
|
|||||||
"zoom_out": "축소",
|
"zoom_out": "축소",
|
||||||
"zoom_reset": "화면에 맞추기",
|
"zoom_reset": "화면에 맞추기",
|
||||||
"rotate": "회전",
|
"rotate": "회전",
|
||||||
"close_preview": "닫기"
|
"close_preview": "닫기",
|
||||||
|
"trash": "휴지통",
|
||||||
|
"restore": "복원",
|
||||||
|
"restored": "{count, plural, other {항목 #개를 복원했습니다}}",
|
||||||
|
"restore_error": "복원하지 못했습니다",
|
||||||
|
"moved_to_trash": "{count, plural, other {항목 #개를 휴지통으로 이동했습니다}}",
|
||||||
|
"delete_forever": "영구 삭제",
|
||||||
|
"delete_forever_title": "{count, plural, other {항목 #개를 영구 삭제할까요?}}",
|
||||||
|
"delete_forever_message": "\"{name}\" 항목이 삭제됩니다. 되돌릴 수 없습니다.",
|
||||||
|
"delete_forever_message_count": "{count, plural, other {항목 #개가 삭제됩니다}}. 되돌릴 수 없습니다.",
|
||||||
|
"deleted_forever": "{count, plural, other {항목 #개를 영구 삭제했습니다}}",
|
||||||
|
"empty_trash": "휴지통 비우기",
|
||||||
|
"empty_trash_title": "휴지통을 비울까요?",
|
||||||
|
"empty_trash_message": "{count, plural, other {휴지통의 항목 #개가 모두 영구 삭제됩니다}}. 되돌릴 수 없습니다.",
|
||||||
|
"trash_emptied": "휴지통을 비웠습니다",
|
||||||
|
"empty_trash_error": "휴지통을 비우지 못했습니다",
|
||||||
|
"trash_empty_title": "휴지통이 비어 있습니다",
|
||||||
|
"trash_empty_hint": "삭제한 파일은 휴지통을 비울 때까지 여기에 남아 있습니다.",
|
||||||
|
"deleted": "삭제한 날짜",
|
||||||
|
"deleted_from": "{folder}에서 삭제됨"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "내 인증서",
|
"your_certificates": "내 인증서",
|
||||||
|
|||||||
+20
-1
@@ -3875,7 +3875,26 @@
|
|||||||
"zoom_out": "Tālināt",
|
"zoom_out": "Tālināt",
|
||||||
"zoom_reset": "Ietilpināt ekrānā",
|
"zoom_reset": "Ietilpināt ekrānā",
|
||||||
"rotate": "Pagriezt",
|
"rotate": "Pagriezt",
|
||||||
"close_preview": "Aizvērt"
|
"close_preview": "Aizvērt",
|
||||||
|
"trash": "Miskaste",
|
||||||
|
"restore": "Atjaunot",
|
||||||
|
"restored": "{count, plural, one {Vienums atjaunots} other {Atjaunoti # vienumi}}",
|
||||||
|
"restore_error": "Neizdevās atjaunot",
|
||||||
|
"moved_to_trash": "{count, plural, one {Pārvietots uz miskasti} other {# vienumi pārvietoti uz miskasti}}",
|
||||||
|
"delete_forever": "Dzēst neatgriezeniski",
|
||||||
|
"delete_forever_title": "{count, plural, one {Dzēst neatgriezeniski?} other {Dzēst # vienumus neatgriezeniski?}}",
|
||||||
|
"delete_forever_message": "“{name}” tiks dzēsts. To nevar atsaukt.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {Tiks dzēsts 1 vienums} other {Tiks dzēsti # vienumi}}. To nevar atsaukt.",
|
||||||
|
"deleted_forever": "{count, plural, one {Dzēsts neatgriezeniski} other {# vienumi dzēsti neatgriezeniski}}",
|
||||||
|
"empty_trash": "Iztukšot miskasti",
|
||||||
|
"empty_trash_title": "Iztukšot miskasti?",
|
||||||
|
"empty_trash_message": "{count, plural, one {Miskastē esošais vienums tiks dzēsts} other {Visi # miskastē esošie vienumi tiks dzēsti}} neatgriezeniski. To nevar atsaukt.",
|
||||||
|
"trash_emptied": "Miskaste iztukšota",
|
||||||
|
"empty_trash_error": "Neizdevās iztukšot miskasti",
|
||||||
|
"trash_empty_title": "Miskaste ir tukša",
|
||||||
|
"trash_empty_hint": "Dzēstie faili paliek šeit, līdz iztukšojat miskasti.",
|
||||||
|
"deleted": "Dzēsts",
|
||||||
|
"deleted_from": "Dzēsts no {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Jūsu sertifikāti",
|
"your_certificates": "Jūsu sertifikāti",
|
||||||
|
|||||||
+20
-1
@@ -3917,7 +3917,26 @@
|
|||||||
"zoom_out": "Жижигрүүлэх",
|
"zoom_out": "Жижигрүүлэх",
|
||||||
"zoom_reset": "Дэлгэцэнд тааруулах",
|
"zoom_reset": "Дэлгэцэнд тааруулах",
|
||||||
"rotate": "Эргүүлэх",
|
"rotate": "Эргүүлэх",
|
||||||
"close_preview": "Хаах"
|
"close_preview": "Хаах",
|
||||||
|
"trash": "Хогийн сав",
|
||||||
|
"restore": "Сэргээх",
|
||||||
|
"restored": "{count, plural, one {Зүйлийг сэргээлээ} other {# зүйлийг сэргээлээ}}",
|
||||||
|
"restore_error": "Сэргээж чадсангүй",
|
||||||
|
"moved_to_trash": "{count, plural, one {Хогийн сав руу зөөлөө} other {# зүйлийг хогийн сав руу зөөлөө}}",
|
||||||
|
"delete_forever": "Бүрмөсөн устгах",
|
||||||
|
"delete_forever_title": "{count, plural, one {Бүрмөсөн устгах уу?} other {# зүйлийг бүрмөсөн устгах уу?}}",
|
||||||
|
"delete_forever_message": "\"{name}\" устгагдана. Үүнийг буцаах боломжгүй.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {1 зүйл устгагдана} other {# зүйл устгагдана}}. Үүнийг буцаах боломжгүй.",
|
||||||
|
"deleted_forever": "{count, plural, one {Бүрмөсөн устгалаа} other {# зүйлийг бүрмөсөн устгалаа}}",
|
||||||
|
"empty_trash": "Хогийн савыг хоослох",
|
||||||
|
"empty_trash_title": "Хогийн савыг хоослох уу?",
|
||||||
|
"empty_trash_message": "{count, plural, one {Хогийн саван дахь зүйл} other {Хогийн саван дахь бүх # зүйл}} бүрмөсөн устгагдана. Үүнийг буцаах боломжгүй.",
|
||||||
|
"trash_emptied": "Хогийн савыг хоосоллоо",
|
||||||
|
"empty_trash_error": "Хогийн савыг хоосолж чадсангүй",
|
||||||
|
"trash_empty_title": "Хогийн сав хоосон байна",
|
||||||
|
"trash_empty_hint": "Устгасан файлууд хогийн савыг хоослох хүртэл энд хадгалагдана.",
|
||||||
|
"deleted": "Устгасан",
|
||||||
|
"deleted_from": "{folder}-с устгасан"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Таны сертификатууд",
|
"your_certificates": "Таны сертификатууд",
|
||||||
|
|||||||
+20
-1
@@ -3917,7 +3917,26 @@
|
|||||||
"zoom_out": "Zoom ut",
|
"zoom_out": "Zoom ut",
|
||||||
"zoom_reset": "Tilpass til skjermen",
|
"zoom_reset": "Tilpass til skjermen",
|
||||||
"rotate": "Roter",
|
"rotate": "Roter",
|
||||||
"close_preview": "Lukk"
|
"close_preview": "Lukk",
|
||||||
|
"trash": "Papirkurv",
|
||||||
|
"restore": "Gjenopprett",
|
||||||
|
"restored": "{count, plural, one {Elementet er gjenopprettet} other {# elementer er gjenopprettet}}",
|
||||||
|
"restore_error": "Kunne ikke gjenopprette",
|
||||||
|
"moved_to_trash": "{count, plural, one {Flyttet til papirkurven} other {# elementer flyttet til papirkurven}}",
|
||||||
|
"delete_forever": "Slett permanent",
|
||||||
|
"delete_forever_title": "{count, plural, one {Vil du slette permanent?} other {Vil du slette # elementer permanent?}}",
|
||||||
|
"delete_forever_message": "«{name}» blir slettet. Dette kan ikke angres.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {1 element blir slettet} other {# elementer blir slettet}}. Dette kan ikke angres.",
|
||||||
|
"deleted_forever": "{count, plural, one {Slettet permanent} other {# elementer slettet permanent}}",
|
||||||
|
"empty_trash": "Tøm papirkurven",
|
||||||
|
"empty_trash_title": "Vil du tømme papirkurven?",
|
||||||
|
"empty_trash_message": "{count, plural, one {Elementet i papirkurven} other {Alle # elementer i papirkurven}} blir slettet permanent. Dette kan ikke angres.",
|
||||||
|
"trash_emptied": "Papirkurven er tømt",
|
||||||
|
"empty_trash_error": "Kunne ikke tømme papirkurven",
|
||||||
|
"trash_empty_title": "Papirkurven er tom",
|
||||||
|
"trash_empty_hint": "Slettede filer blir liggende her til du tømmer papirkurven.",
|
||||||
|
"deleted": "Slettet",
|
||||||
|
"deleted_from": "Slettet fra {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Dine sertifikater",
|
"your_certificates": "Dine sertifikater",
|
||||||
|
|||||||
+20
-1
@@ -3875,7 +3875,26 @@
|
|||||||
"zoom_out": "Uitzoomen",
|
"zoom_out": "Uitzoomen",
|
||||||
"zoom_reset": "Aanpassen aan scherm",
|
"zoom_reset": "Aanpassen aan scherm",
|
||||||
"rotate": "Draaien",
|
"rotate": "Draaien",
|
||||||
"close_preview": "Sluiten"
|
"close_preview": "Sluiten",
|
||||||
|
"trash": "Prullenbak",
|
||||||
|
"restore": "Herstellen",
|
||||||
|
"restored": "{count, plural, one {Item hersteld} other {# items hersteld}}",
|
||||||
|
"restore_error": "Kan niet herstellen",
|
||||||
|
"moved_to_trash": "{count, plural, one {Naar de prullenbak verplaatst} other {# items naar de prullenbak verplaatst}}",
|
||||||
|
"delete_forever": "Definitief verwijderen",
|
||||||
|
"delete_forever_title": "{count, plural, one {Definitief verwijderen?} other {# items definitief verwijderen?}}",
|
||||||
|
"delete_forever_message": "'{name}' wordt verwijderd. Dit kan niet ongedaan worden gemaakt.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {1 item wordt verwijderd} other {# items worden verwijderd}}. Dit kan niet ongedaan worden gemaakt.",
|
||||||
|
"deleted_forever": "{count, plural, one {Definitief verwijderd} other {# items definitief verwijderd}}",
|
||||||
|
"empty_trash": "Prullenbak legen",
|
||||||
|
"empty_trash_title": "Prullenbak legen?",
|
||||||
|
"empty_trash_message": "{count, plural, one {Het item in de prullenbak wordt} other {Alle # items in de prullenbak worden}} definitief verwijderd. Dit kan niet ongedaan worden gemaakt.",
|
||||||
|
"trash_emptied": "Prullenbak geleegd",
|
||||||
|
"empty_trash_error": "Kan de prullenbak niet legen",
|
||||||
|
"trash_empty_title": "De prullenbak is leeg",
|
||||||
|
"trash_empty_hint": "Verwijderde bestanden blijven hier tot je de prullenbak leegt.",
|
||||||
|
"deleted": "Verwijderd",
|
||||||
|
"deleted_from": "Verwijderd uit {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Uw certificaten",
|
"your_certificates": "Uw certificaten",
|
||||||
|
|||||||
+20
-1
@@ -3875,7 +3875,26 @@
|
|||||||
"zoom_out": "Pomniejsz",
|
"zoom_out": "Pomniejsz",
|
||||||
"zoom_reset": "Dopasuj do ekranu",
|
"zoom_reset": "Dopasuj do ekranu",
|
||||||
"rotate": "Obróć",
|
"rotate": "Obróć",
|
||||||
"close_preview": "Zamknij"
|
"close_preview": "Zamknij",
|
||||||
|
"trash": "Kosz",
|
||||||
|
"restore": "Przywróć",
|
||||||
|
"restored": "{count, plural, one {Przywrócono element} few {Przywrócono # elementy} other {Przywrócono # elementów}}",
|
||||||
|
"restore_error": "Nie udało się przywrócić",
|
||||||
|
"moved_to_trash": "{count, plural, one {Przeniesiono do kosza} few {Przeniesiono # elementy do kosza} other {Przeniesiono # elementów do kosza}}",
|
||||||
|
"delete_forever": "Usuń trwale",
|
||||||
|
"delete_forever_title": "{count, plural, one {Usunąć trwale?} few {Usunąć trwale # elementy?} other {Usunąć trwale # elementów?}}",
|
||||||
|
"delete_forever_message": "„{name}” zostanie usunięty. Tej operacji nie można cofnąć.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {Zostanie usunięty 1 element} few {Zostaną usunięte # elementy} other {Zostanie usuniętych # elementów}}. Tej operacji nie można cofnąć.",
|
||||||
|
"deleted_forever": "{count, plural, one {Usunięto trwale} few {Usunięto trwale # elementy} other {Usunięto trwale # elementów}}",
|
||||||
|
"empty_trash": "Opróżnij kosz",
|
||||||
|
"empty_trash_title": "Opróżnić kosz?",
|
||||||
|
"empty_trash_message": "{count, plural, one {Element w koszu zostanie usunięty} few {Wszystkie # elementy w koszu zostaną usunięte} other {Wszystkie # elementów w koszu zostanie usuniętych}} trwale. Tej operacji nie można cofnąć.",
|
||||||
|
"trash_emptied": "Kosz opróżniony",
|
||||||
|
"empty_trash_error": "Nie udało się opróżnić kosza",
|
||||||
|
"trash_empty_title": "Kosz jest pusty",
|
||||||
|
"trash_empty_hint": "Usunięte pliki pozostają tutaj, dopóki nie opróżnisz kosza.",
|
||||||
|
"deleted": "Usunięto",
|
||||||
|
"deleted_from": "Usunięto z {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Twoje certyfikaty",
|
"your_certificates": "Twoje certyfikaty",
|
||||||
|
|||||||
+20
-1
@@ -3875,7 +3875,26 @@
|
|||||||
"zoom_out": "Diminuir zoom",
|
"zoom_out": "Diminuir zoom",
|
||||||
"zoom_reset": "Ajustar ao ecrã",
|
"zoom_reset": "Ajustar ao ecrã",
|
||||||
"rotate": "Rodar",
|
"rotate": "Rodar",
|
||||||
"close_preview": "Fechar"
|
"close_preview": "Fechar",
|
||||||
|
"trash": "Lixeira",
|
||||||
|
"restore": "Restaurar",
|
||||||
|
"restored": "{count, plural, one {Item restaurado} other {# itens restaurados}}",
|
||||||
|
"restore_error": "Não foi possível restaurar",
|
||||||
|
"moved_to_trash": "{count, plural, one {Movido para a lixeira} other {# itens movidos para a lixeira}}",
|
||||||
|
"delete_forever": "Excluir permanentemente",
|
||||||
|
"delete_forever_title": "{count, plural, one {Excluir permanentemente?} other {Excluir # itens permanentemente?}}",
|
||||||
|
"delete_forever_message": "\"{name}\" será excluído. Não é possível desfazer.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {1 item será excluído} other {# itens serão excluídos}}. Não é possível desfazer.",
|
||||||
|
"deleted_forever": "{count, plural, one {Excluído permanentemente} other {# itens excluídos permanentemente}}",
|
||||||
|
"empty_trash": "Esvaziar lixeira",
|
||||||
|
"empty_trash_title": "Esvaziar a lixeira?",
|
||||||
|
"empty_trash_message": "{count, plural, one {O item da lixeira será excluído} other {Todos os # itens da lixeira serão excluídos}} permanentemente. Não é possível desfazer.",
|
||||||
|
"trash_emptied": "Lixeira esvaziada",
|
||||||
|
"empty_trash_error": "Não foi possível esvaziar a lixeira",
|
||||||
|
"trash_empty_title": "A lixeira está vazia",
|
||||||
|
"trash_empty_hint": "Os arquivos excluídos ficam aqui até você esvaziar a lixeira.",
|
||||||
|
"deleted": "Excluído",
|
||||||
|
"deleted_from": "Excluído de {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Seus certificados",
|
"your_certificates": "Seus certificados",
|
||||||
|
|||||||
+20
-1
@@ -3917,7 +3917,26 @@
|
|||||||
"zoom_out": "Micșorează",
|
"zoom_out": "Micșorează",
|
||||||
"zoom_reset": "Potrivește pe ecran",
|
"zoom_reset": "Potrivește pe ecran",
|
||||||
"rotate": "Rotește",
|
"rotate": "Rotește",
|
||||||
"close_preview": "Închide"
|
"close_preview": "Închide",
|
||||||
|
"trash": "Coș de gunoi",
|
||||||
|
"restore": "Restaurați",
|
||||||
|
"restored": "{count, plural, one {Element restaurat} other {# elemente restaurate}}",
|
||||||
|
"restore_error": "Nu s-a putut restaura",
|
||||||
|
"moved_to_trash": "{count, plural, one {Mutat în coșul de gunoi} other {# elemente mutate în coșul de gunoi}}",
|
||||||
|
"delete_forever": "Ștergeți definitiv",
|
||||||
|
"delete_forever_title": "{count, plural, one {Ștergeți definitiv?} other {Ștergeți definitiv # elemente?}}",
|
||||||
|
"delete_forever_message": "„{name}” va fi șters. Acțiunea nu poate fi anulată.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {Va fi șters 1 element} other {Vor fi șterse # elemente}}. Acțiunea nu poate fi anulată.",
|
||||||
|
"deleted_forever": "{count, plural, one {Șters definitiv} other {# elemente șterse definitiv}}",
|
||||||
|
"empty_trash": "Goliți coșul de gunoi",
|
||||||
|
"empty_trash_title": "Goliți coșul de gunoi?",
|
||||||
|
"empty_trash_message": "{count, plural, one {Elementul din coșul de gunoi va fi șters} other {Toate cele # elemente din coșul de gunoi vor fi șterse}} definitiv. Acțiunea nu poate fi anulată.",
|
||||||
|
"trash_emptied": "Coșul de gunoi a fost golit",
|
||||||
|
"empty_trash_error": "Nu s-a putut goli coșul de gunoi",
|
||||||
|
"trash_empty_title": "Coșul de gunoi este gol",
|
||||||
|
"trash_empty_hint": "Fișierele șterse rămân aici până când goliți coșul de gunoi.",
|
||||||
|
"deleted": "Șters",
|
||||||
|
"deleted_from": "Șters din {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Certificatele dvs.",
|
"your_certificates": "Certificatele dvs.",
|
||||||
|
|||||||
+20
-1
@@ -3875,7 +3875,26 @@
|
|||||||
"zoom_out": "Уменьшить",
|
"zoom_out": "Уменьшить",
|
||||||
"zoom_reset": "По размеру экрана",
|
"zoom_reset": "По размеру экрана",
|
||||||
"rotate": "Повернуть",
|
"rotate": "Повернуть",
|
||||||
"close_preview": "Закрыть"
|
"close_preview": "Закрыть",
|
||||||
|
"trash": "Корзина",
|
||||||
|
"restore": "Восстановить",
|
||||||
|
"restored": "{count, plural, one {Элемент восстановлен} few {Восстановлено # элемента} other {Восстановлено # элементов}}",
|
||||||
|
"restore_error": "Не удалось восстановить",
|
||||||
|
"moved_to_trash": "{count, plural, one {Перемещено в корзину} few {# элемента перемещено в корзину} other {# элементов перемещено в корзину}}",
|
||||||
|
"delete_forever": "Удалить навсегда",
|
||||||
|
"delete_forever_title": "{count, plural, one {Удалить навсегда?} few {Удалить # элемента навсегда?} other {Удалить # элементов навсегда?}}",
|
||||||
|
"delete_forever_message": "«{name}» будет удалён. Это действие нельзя отменить.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {Будет удалён 1 элемент} few {Будут удалены # элемента} other {Будут удалены # элементов}}. Это действие нельзя отменить.",
|
||||||
|
"deleted_forever": "{count, plural, one {Удалено навсегда} few {# элемента удалено навсегда} other {# элементов удалено навсегда}}",
|
||||||
|
"empty_trash": "Очистить корзину",
|
||||||
|
"empty_trash_title": "Очистить корзину?",
|
||||||
|
"empty_trash_message": "{count, plural, one {Элемент в корзине будет удалён} few {Все # элемента в корзине будут удалены} other {Все # элементов в корзине будут удалены}} навсегда. Это действие нельзя отменить.",
|
||||||
|
"trash_emptied": "Корзина очищена",
|
||||||
|
"empty_trash_error": "Не удалось очистить корзину",
|
||||||
|
"trash_empty_title": "Корзина пуста",
|
||||||
|
"trash_empty_hint": "Удалённые файлы хранятся здесь, пока вы не очистите корзину.",
|
||||||
|
"deleted": "Удалено",
|
||||||
|
"deleted_from": "Удалено из {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Ваши сертификаты",
|
"your_certificates": "Ваши сертификаты",
|
||||||
|
|||||||
+20
-1
@@ -3917,7 +3917,26 @@
|
|||||||
"zoom_out": "Oddialiť",
|
"zoom_out": "Oddialiť",
|
||||||
"zoom_reset": "Prispôsobiť obrazovke",
|
"zoom_reset": "Prispôsobiť obrazovke",
|
||||||
"rotate": "Otočiť",
|
"rotate": "Otočiť",
|
||||||
"close_preview": "Zavrieť"
|
"close_preview": "Zavrieť",
|
||||||
|
"trash": "Kôš",
|
||||||
|
"restore": "Obnoviť",
|
||||||
|
"restored": "{count, plural, one {Položka obnovená} few {Obnovené # položky} other {Obnovených # položiek}}",
|
||||||
|
"restore_error": "Obnovenie sa nepodarilo",
|
||||||
|
"moved_to_trash": "{count, plural, one {Presunuté do koša} few {# položky presunuté do koša} other {# položiek presunutých do koša}}",
|
||||||
|
"delete_forever": "Natrvalo odstrániť",
|
||||||
|
"delete_forever_title": "{count, plural, one {Natrvalo odstrániť?} few {Natrvalo odstrániť # položky?} other {Natrvalo odstrániť # položiek?}}",
|
||||||
|
"delete_forever_message": "„{name}“ sa odstráni. Túto akciu nie je možné vrátiť.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {Odstráni sa 1 položka} few {Odstránia sa # položky} other {Odstráni sa # položiek}}. Túto akciu nie je možné vrátiť.",
|
||||||
|
"deleted_forever": "{count, plural, one {Natrvalo odstránené} few {Natrvalo odstránené # položky} other {Natrvalo odstránených # položiek}}",
|
||||||
|
"empty_trash": "Vyprázdniť kôš",
|
||||||
|
"empty_trash_title": "Vyprázdniť kôš?",
|
||||||
|
"empty_trash_message": "{count, plural, one {Položka v koši sa natrvalo odstráni} few {Všetky # položky v koši sa natrvalo odstránia} other {Všetkých # položiek v koši sa natrvalo odstráni}}. Túto akciu nie je možné vrátiť.",
|
||||||
|
"trash_emptied": "Kôš bol vyprázdnený",
|
||||||
|
"empty_trash_error": "Kôš sa nepodarilo vyprázdniť",
|
||||||
|
"trash_empty_title": "Kôš je prázdny",
|
||||||
|
"trash_empty_hint": "Odstránené súbory tu zostanú, kým kôš nevyprázdnite.",
|
||||||
|
"deleted": "Odstránené",
|
||||||
|
"deleted_from": "Odstránené z {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Vaše certifikáty",
|
"your_certificates": "Vaše certifikáty",
|
||||||
|
|||||||
+20
-1
@@ -3917,7 +3917,26 @@
|
|||||||
"zoom_out": "Uzaklaştır",
|
"zoom_out": "Uzaklaştır",
|
||||||
"zoom_reset": "Ekrana sığdır",
|
"zoom_reset": "Ekrana sığdır",
|
||||||
"rotate": "Döndür",
|
"rotate": "Döndür",
|
||||||
"close_preview": "Kapat"
|
"close_preview": "Kapat",
|
||||||
|
"trash": "Çöp kutusu",
|
||||||
|
"restore": "Geri yükle",
|
||||||
|
"restored": "{count, plural, one {Öğe geri yüklendi} other {# öğe geri yüklendi}}",
|
||||||
|
"restore_error": "Geri yüklenemedi",
|
||||||
|
"moved_to_trash": "{count, plural, one {Çöp kutusuna taşındı} other {# öğe çöp kutusuna taşındı}}",
|
||||||
|
"delete_forever": "Kalıcı olarak sil",
|
||||||
|
"delete_forever_title": "{count, plural, one {Kalıcı olarak silinsin mi?} other {# öğe kalıcı olarak silinsin mi?}}",
|
||||||
|
"delete_forever_message": "\"{name}\" silinecek. Bu işlem geri alınamaz.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {1 öğe silinecek} other {# öğe silinecek}}. Bu işlem geri alınamaz.",
|
||||||
|
"deleted_forever": "{count, plural, one {Kalıcı olarak silindi} other {# öğe kalıcı olarak silindi}}",
|
||||||
|
"empty_trash": "Çöp kutusunu boşalt",
|
||||||
|
"empty_trash_title": "Çöp kutusu boşaltılsın mı?",
|
||||||
|
"empty_trash_message": "{count, plural, one {Çöp kutusundaki öğe} other {Çöp kutusundaki # öğenin tümü}} kalıcı olarak silinecek. Bu işlem geri alınamaz.",
|
||||||
|
"trash_emptied": "Çöp kutusu boşaltıldı",
|
||||||
|
"empty_trash_error": "Çöp kutusu boşaltılamadı",
|
||||||
|
"trash_empty_title": "Çöp kutusu boş",
|
||||||
|
"trash_empty_hint": "Silinen dosyalar siz çöp kutusunu boşaltana kadar burada kalır.",
|
||||||
|
"deleted": "Silinme",
|
||||||
|
"deleted_from": "{folder} konumundan silindi"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Sertifikalarınız",
|
"your_certificates": "Sertifikalarınız",
|
||||||
|
|||||||
+20
-1
@@ -3875,7 +3875,26 @@
|
|||||||
"zoom_out": "Зменшити",
|
"zoom_out": "Зменшити",
|
||||||
"zoom_reset": "За розміром екрана",
|
"zoom_reset": "За розміром екрана",
|
||||||
"rotate": "Повернути",
|
"rotate": "Повернути",
|
||||||
"close_preview": "Закрити"
|
"close_preview": "Закрити",
|
||||||
|
"trash": "Кошик",
|
||||||
|
"restore": "Відновити",
|
||||||
|
"restored": "{count, plural, one {Елемент відновлено} few {Відновлено # елементи} other {Відновлено # елементів}}",
|
||||||
|
"restore_error": "Не вдалося відновити",
|
||||||
|
"moved_to_trash": "{count, plural, one {Переміщено в кошик} few {# елементи переміщено в кошик} other {# елементів переміщено в кошик}}",
|
||||||
|
"delete_forever": "Видалити назавжди",
|
||||||
|
"delete_forever_title": "{count, plural, one {Видалити назавжди?} few {Видалити # елементи назавжди?} other {Видалити # елементів назавжди?}}",
|
||||||
|
"delete_forever_message": "«{name}» буде видалено. Цю дію не можна скасувати.",
|
||||||
|
"delete_forever_message_count": "{count, plural, one {Буде видалено 1 елемент} few {Буде видалено # елементи} other {Буде видалено # елементів}}. Цю дію не можна скасувати.",
|
||||||
|
"deleted_forever": "{count, plural, one {Видалено назавжди} few {# елементи видалено назавжди} other {# елементів видалено назавжди}}",
|
||||||
|
"empty_trash": "Очистити кошик",
|
||||||
|
"empty_trash_title": "Очистити кошик?",
|
||||||
|
"empty_trash_message": "{count, plural, one {Елемент у кошику буде видалено} few {Усі # елементи в кошику буде видалено} other {Усі # елементів у кошику буде видалено}} назавжди. Цю дію не можна скасувати.",
|
||||||
|
"trash_emptied": "Кошик очищено",
|
||||||
|
"empty_trash_error": "Не вдалося очистити кошик",
|
||||||
|
"trash_empty_title": "Кошик порожній",
|
||||||
|
"trash_empty_hint": "Видалені файли зберігаються тут, доки ви не очистите кошик.",
|
||||||
|
"deleted": "Видалено",
|
||||||
|
"deleted_from": "Видалено з {folder}"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Ваші сертифікати",
|
"your_certificates": "Ваші сертифікати",
|
||||||
|
|||||||
@@ -3917,7 +3917,26 @@
|
|||||||
"zoom_out": "縮小",
|
"zoom_out": "縮小",
|
||||||
"zoom_reset": "符合螢幕大小",
|
"zoom_reset": "符合螢幕大小",
|
||||||
"rotate": "旋轉",
|
"rotate": "旋轉",
|
||||||
"close_preview": "關閉"
|
"close_preview": "關閉",
|
||||||
|
"trash": "垃圾桶",
|
||||||
|
"restore": "還原",
|
||||||
|
"restored": "{count, plural, other {已還原 # 個項目}}",
|
||||||
|
"restore_error": "無法還原",
|
||||||
|
"moved_to_trash": "{count, plural, other {已將 # 個項目移到垃圾桶}}",
|
||||||
|
"delete_forever": "永久刪除",
|
||||||
|
"delete_forever_title": "{count, plural, other {要永久刪除 # 個項目嗎?}}",
|
||||||
|
"delete_forever_message": "「{name}」將被刪除。此動作無法復原。",
|
||||||
|
"delete_forever_message_count": "{count, plural, other {將刪除 # 個項目}}。此動作無法復原。",
|
||||||
|
"deleted_forever": "{count, plural, other {已永久刪除 # 個項目}}",
|
||||||
|
"empty_trash": "清空垃圾桶",
|
||||||
|
"empty_trash_title": "要清空垃圾桶嗎?",
|
||||||
|
"empty_trash_message": "{count, plural, other {垃圾桶中的 # 個項目將被永久刪除}}。此動作無法復原。",
|
||||||
|
"trash_emptied": "已清空垃圾桶",
|
||||||
|
"empty_trash_error": "無法清空垃圾桶",
|
||||||
|
"trash_empty_title": "垃圾桶是空的",
|
||||||
|
"trash_empty_hint": "刪除的檔案會保留在這裡,直到您清空垃圾桶。",
|
||||||
|
"deleted": "刪除時間",
|
||||||
|
"deleted_from": "從 {folder} 刪除"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "您的憑證",
|
"your_certificates": "您的憑證",
|
||||||
|
|||||||
+20
-1
@@ -3875,7 +3875,26 @@
|
|||||||
"zoom_out": "缩小",
|
"zoom_out": "缩小",
|
||||||
"zoom_reset": "适应屏幕",
|
"zoom_reset": "适应屏幕",
|
||||||
"rotate": "旋转",
|
"rotate": "旋转",
|
||||||
"close_preview": "关闭"
|
"close_preview": "关闭",
|
||||||
|
"trash": "回收站",
|
||||||
|
"restore": "恢复",
|
||||||
|
"restored": "{count, plural, other {已恢复 # 个项目}}",
|
||||||
|
"restore_error": "无法恢复",
|
||||||
|
"moved_to_trash": "{count, plural, other {已将 # 个项目移到回收站}}",
|
||||||
|
"delete_forever": "永久删除",
|
||||||
|
"delete_forever_title": "{count, plural, other {永久删除 # 个项目?}}",
|
||||||
|
"delete_forever_message": "“{name}”将被删除。此操作无法撤销。",
|
||||||
|
"delete_forever_message_count": "{count, plural, other {将删除 # 个项目}}。此操作无法撤销。",
|
||||||
|
"deleted_forever": "{count, plural, other {已永久删除 # 个项目}}",
|
||||||
|
"empty_trash": "清空回收站",
|
||||||
|
"empty_trash_title": "清空回收站?",
|
||||||
|
"empty_trash_message": "{count, plural, other {回收站中的 # 个项目将被永久删除}}。此操作无法撤销。",
|
||||||
|
"trash_emptied": "已清空回收站",
|
||||||
|
"empty_trash_error": "无法清空回收站",
|
||||||
|
"trash_empty_title": "回收站为空",
|
||||||
|
"trash_empty_hint": "删除的文件会保留在这里,直到您清空回收站。",
|
||||||
|
"deleted": "删除时间",
|
||||||
|
"deleted_from": "从 {folder} 删除"
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "您的证书",
|
"your_certificates": "您的证书",
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../jmap-client', () => ({
|
||||||
|
jmapClient: {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
request: vi.fn(),
|
||||||
|
hasCapability: vi.fn(() => true),
|
||||||
|
hasAccountCapability: vi.fn(() => false),
|
||||||
|
getMaxObjectsInGet: vi.fn(() => 500),
|
||||||
|
getMaxObjectsInSet: vi.fn(() => 500),
|
||||||
|
getMaxCallsInRequest: vi.fn(() => 16),
|
||||||
|
getAccountCapability: vi.fn(),
|
||||||
|
currentSession: {
|
||||||
|
primaryAccounts: { 'urn:ietf:params:jmap:filenode': 'acc-1' },
|
||||||
|
accounts: { 'acc-1': { name: 'me', isPersonal: true }, 'acc-2': { name: 'them', isPersonal: false } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { jmapClient } from '../jmap-client';
|
||||||
|
import type { FileNode } from '../types';
|
||||||
|
import { emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash } from '../files-trash';
|
||||||
|
import { trashedItems } from '../../lib/files-trash';
|
||||||
|
|
||||||
|
// A Stalwart-like FileNode store for the user's own account: destroy without
|
||||||
|
// onDestroyRemoveChildren refuses a folder that holds anything.
|
||||||
|
type Node = { id: string; parentId: string | null; name: string; blobId: string | null; type?: string };
|
||||||
|
let store: Node[];
|
||||||
|
let nextId: number;
|
||||||
|
let calls: Array<Record<string, unknown>>;
|
||||||
|
let refuseUpdate: Set<string>;
|
||||||
|
|
||||||
|
function serve(method: string, args: Record<string, any>) {
|
||||||
|
if (method === 'FileNode/get') return { list: store.map((n) => ({ ...n })) };
|
||||||
|
calls.push(args);
|
||||||
|
const out: Record<string, any> = {};
|
||||||
|
for (const [key, props] of Object.entries((args.create ?? {}) as Record<string, any>)) {
|
||||||
|
const id = `n${nextId++}`;
|
||||||
|
store.push({ id, parentId: props.parentId ?? null, name: props.name, blobId: props.blobId ?? null });
|
||||||
|
(out.created ??= {})[key] = { id };
|
||||||
|
}
|
||||||
|
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, any>)) {
|
||||||
|
const node = store.find((n) => n.id === id);
|
||||||
|
if (!node || refuseUpdate.has(id)) {
|
||||||
|
(out.notUpdated ??= {})[id] = { type: 'forbidden', description: 'no' };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Object.assign(node, patch);
|
||||||
|
(out.updated ??= {})[id] = null;
|
||||||
|
}
|
||||||
|
for (const id of (args.destroy ?? []) as string[]) {
|
||||||
|
if (!store.some((n) => n.id === id)) {
|
||||||
|
(out.notDestroyed ??= {})[id] = { type: 'notFound' };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!args.onDestroyRemoveChildren && store.some((n) => n.parentId === id)) {
|
||||||
|
(out.notDestroyed ??= {})[id] = { type: 'nodeHasChildren' };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const gone = new Set([id]);
|
||||||
|
for (let grew = true; grew;) {
|
||||||
|
grew = false;
|
||||||
|
for (const n of store) if (n.parentId && gone.has(n.parentId) && !gone.has(n.id)) { gone.add(n.id); grew = true; }
|
||||||
|
}
|
||||||
|
store = store.filter((n) => !gone.has(n.id));
|
||||||
|
(out.destroyed ??= []).push(id);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listed = (): FileNode[] => store.map((n) => ({ ...n, type: n.type ?? '' }) as FileNode);
|
||||||
|
const byId = (id: string) => store.find((n) => n.id === id);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nextId = 1;
|
||||||
|
calls = [];
|
||||||
|
refuseUpdate = new Set();
|
||||||
|
store = [
|
||||||
|
{ id: 'docs', parentId: null, name: 'Docs', blobId: null },
|
||||||
|
{ id: 'a', parentId: 'docs', name: 'a.txt', blobId: 'ba' },
|
||||||
|
{ id: 'b', parentId: 'docs', name: 'b.txt', blobId: 'bb' },
|
||||||
|
{ id: 'top', parentId: null, name: 'top.txt', blobId: 'bt' },
|
||||||
|
];
|
||||||
|
(jmapClient.request as ReturnType<typeof vi.fn>).mockImplementation(async (methodCalls: any[]) => ({
|
||||||
|
methodResponses: methodCalls.map(([method, args, id]: [string, any, string]) => [method, serve(method, args), id]),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('moveToTrash', () => {
|
||||||
|
it('makes .Trash on the first delete and one trash folder per folder the items came from', async () => {
|
||||||
|
const nodes = listed();
|
||||||
|
const { done } = await moveToTrash(nodes, nodes.filter((n) => ['a', 'top'].includes(n.id)), 1730112000000);
|
||||||
|
|
||||||
|
const trash = store.find((n) => n.name === '.Trash')!;
|
||||||
|
expect(trash.parentId).toBeNull();
|
||||||
|
expect(trash.blobId).toBeNull();
|
||||||
|
const batchNames = store.filter((n) => n.parentId === trash.id).map((n) => n.name).sort();
|
||||||
|
expect(batchNames).toEqual(['1730112000000~', '1730112000000~docs']);
|
||||||
|
expect(byId('a')!.parentId).toBe(store.find((n) => n.name === '1730112000000~docs')!.id);
|
||||||
|
expect(byId('top')!.parentId).toBe(store.find((n) => n.name === '1730112000000~')!.id);
|
||||||
|
expect(done.map((i) => [i.node.id, i.fromParentId])).toEqual([['a', 'docs'], ['top', null]]);
|
||||||
|
// What the webmail reads back.
|
||||||
|
expect(trashedItems(listed()).map((i) => [i.node.id, i.fromParentId, i.deletedAt])).toEqual([
|
||||||
|
['a', 'docs', 1730112000000],
|
||||||
|
['top', null, 1730112000000],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the trash folder that is there', async () => {
|
||||||
|
store.push({ id: 'T', parentId: null, name: '.Trash', blobId: null });
|
||||||
|
const nodes = listed();
|
||||||
|
await moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000001);
|
||||||
|
expect(store.filter((n) => n.name === '.Trash')).toHaveLength(1);
|
||||||
|
expect(byId(byId('a')!.parentId!)!.parentId).toBe('T');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the trash again when the one on screen was emptied elsewhere', async () => {
|
||||||
|
store.push({ id: 'T', parentId: null, name: '.Trash', blobId: null });
|
||||||
|
const nodes = listed();
|
||||||
|
store = store.filter((n) => n.id !== 'T');
|
||||||
|
store.push({ id: 'T2', parentId: null, name: '.Trash', blobId: null });
|
||||||
|
// The fake creates under a missing parent, so make that fail like the server.
|
||||||
|
const real = (jmapClient.request as ReturnType<typeof vi.fn>).getMockImplementation() as (calls: any[]) => Promise<unknown>;
|
||||||
|
(jmapClient.request as ReturnType<typeof vi.fn>).mockImplementation(async (methodCalls: any[]) => {
|
||||||
|
const create = methodCalls[0][1].create as Record<string, any> | undefined;
|
||||||
|
const parent = create && Object.values(create)[0].parentId;
|
||||||
|
if (parent && !byId(parent)) {
|
||||||
|
return { methodResponses: [['FileNode/set', { notCreated: { 'new-dir': { type: 'invalidProperties' } } }, '0']] };
|
||||||
|
}
|
||||||
|
return real(methodCalls);
|
||||||
|
});
|
||||||
|
await moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000001);
|
||||||
|
expect(byId(byId('a')!.parentId!)!.parentId).toBe('T2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses nodes of a shared drive', async () => {
|
||||||
|
const shared = { id: 'acc-2:x', parentId: null, name: 'x', type: '', blobId: 'bx', isShared: true } as FileNode;
|
||||||
|
await expect(moveToTrash([shared], [shared])).rejects.toThrow();
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws the refusal and leaves no empty trash folder when nothing went', async () => {
|
||||||
|
refuseUpdate.add('a');
|
||||||
|
const nodes = listed();
|
||||||
|
await expect(moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000005)).rejects.toThrow('no');
|
||||||
|
expect(store.some((n) => n.name === '1730112000005~docs')).toBe(false);
|
||||||
|
expect(byId('a')!.parentId).toBe('docs');
|
||||||
|
const destroy = calls.find((c) => c.destroy);
|
||||||
|
expect(destroy?.onDestroyRemoveChildren).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a partial refusal and keeps what went', async () => {
|
||||||
|
refuseUpdate.add('b');
|
||||||
|
const nodes = listed();
|
||||||
|
const { done, refused } = await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000005);
|
||||||
|
expect(done.map((i) => i.node.id)).toEqual(['a']);
|
||||||
|
expect(refused).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restoreFromTrash', () => {
|
||||||
|
it('puts items back where they came from and removes their empty trash folder', async () => {
|
||||||
|
let nodes = listed();
|
||||||
|
await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007);
|
||||||
|
nodes = listed();
|
||||||
|
const batch = store.find((n) => n.name === '1730112000007~docs')!;
|
||||||
|
await restoreFromTrash(nodes, trashedItems(nodes));
|
||||||
|
expect(byId('a')).toMatchObject({ parentId: 'docs', name: 'a.txt' });
|
||||||
|
expect(byId('b')).toMatchObject({ parentId: 'docs', name: 'b.txt' });
|
||||||
|
expect(byId(batch.id)).toBeUndefined();
|
||||||
|
// No rename was sent along with the move.
|
||||||
|
const update = calls.filter((c) => c.update).pop()!.update as Record<string, Record<string, unknown>>;
|
||||||
|
expect(update.a).toEqual({ parentId: 'docs' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a trash folder that other items still hold', async () => {
|
||||||
|
let nodes = listed();
|
||||||
|
await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007);
|
||||||
|
nodes = listed();
|
||||||
|
const batch = store.find((n) => n.name === '1730112000007~docs')!;
|
||||||
|
await restoreFromTrash(nodes, trashedItems(nodes).filter((i) => i.node.id === 'a'));
|
||||||
|
expect(byId(batch.id)).toBeDefined();
|
||||||
|
expect(byId('b')!.parentId).toBe(batch.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a name taken by now a free one, and sends the root for a folder that is gone', async () => {
|
||||||
|
let nodes = listed();
|
||||||
|
await moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000007);
|
||||||
|
store.push({ id: 'a2', parentId: 'docs', name: 'a.txt', blobId: 'ba2' });
|
||||||
|
nodes = listed();
|
||||||
|
await moveToTrash(nodes, nodes.filter((n) => n.id === 'docs'), 1730112000008);
|
||||||
|
nodes = listed();
|
||||||
|
// Docs is in the trash itself now: a.txt goes to the root.
|
||||||
|
await restoreFromTrash(nodes, trashedItems(nodes).filter((i) => i.node.id === 'a'));
|
||||||
|
expect(byId('a')).toMatchObject({ parentId: null, name: 'a.txt' });
|
||||||
|
|
||||||
|
nodes = listed();
|
||||||
|
await restoreFromTrash(nodes, trashedItems(nodes).filter((i) => i.node.id === 'docs'));
|
||||||
|
await moveToTrash(listed(), listed().filter((n) => n.id === 'a2'), 1730112000009);
|
||||||
|
store.push({ id: 'a3', parentId: 'docs', name: 'a.txt', blobId: 'ba3' });
|
||||||
|
nodes = listed();
|
||||||
|
await restoreFromTrash(nodes, trashedItems(nodes).filter((i) => i.node.id === 'a2'));
|
||||||
|
expect(byId('a2')).toMatchObject({ parentId: 'docs', name: 'a (1).txt' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undoes a delete with the listing from before it, without renaming', async () => {
|
||||||
|
const before = listed();
|
||||||
|
const { done } = await moveToTrash(before, before.filter((n) => n.parentId === 'docs'), 1730112000007);
|
||||||
|
await restoreFromTrash(before, done);
|
||||||
|
expect(byId('a')).toMatchObject({ parentId: 'docs', name: 'a.txt' });
|
||||||
|
expect(byId('b')).toMatchObject({ parentId: 'docs', name: 'b.txt' });
|
||||||
|
expect(store.some((n) => n.name === '1730112000007~docs')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purgeFromTrash and emptyTrash', () => {
|
||||||
|
it('deletes items for good with what is inside, and their emptied trash folder', async () => {
|
||||||
|
let nodes = listed();
|
||||||
|
await moveToTrash(nodes, nodes.filter((n) => n.id === 'docs'), 1730112000007);
|
||||||
|
nodes = listed();
|
||||||
|
await purgeFromTrash(nodes, trashedItems(nodes));
|
||||||
|
expect(store.map((n) => n.id).sort()).toEqual(['n1', 'top']);
|
||||||
|
expect(byId('n1')!.name).toBe('.Trash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empties the trash, everything in it included', async () => {
|
||||||
|
let nodes = listed();
|
||||||
|
await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007);
|
||||||
|
nodes = listed();
|
||||||
|
await emptyTrash(nodes);
|
||||||
|
expect(store.map((n) => n.id).sort()).toEqual(['docs', 'top']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empties a trash made after the listing on screen', async () => {
|
||||||
|
const stale = listed();
|
||||||
|
await moveToTrash(stale, stale.filter((n) => n.id === 'top'), 1730112000007);
|
||||||
|
await emptyTrash(stale);
|
||||||
|
expect(store.map((n) => n.id).sort()).toEqual(['a', 'b', 'docs']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,8 +36,10 @@ import {
|
|||||||
copyFileNode,
|
copyFileNode,
|
||||||
createFolder,
|
createFolder,
|
||||||
deleteFileNodes,
|
deleteFileNodes,
|
||||||
|
destroyEmptyFolders,
|
||||||
fileNodeKind,
|
fileNodeKind,
|
||||||
filesAccountId,
|
filesAccountId,
|
||||||
|
getAllFileNodes,
|
||||||
getAllFileNodesAcrossAccounts,
|
getAllFileNodesAcrossAccounts,
|
||||||
getFileNodeDownloadUrl,
|
getFileNodeDownloadUrl,
|
||||||
listFolder,
|
listFolder,
|
||||||
@@ -45,6 +47,8 @@ import {
|
|||||||
renameFileNode,
|
renameFileNode,
|
||||||
uploadFileBatch,
|
uploadFileBatch,
|
||||||
} from '../files';
|
} from '../files';
|
||||||
|
import { emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash } from '../files-trash';
|
||||||
|
import { findTrashFolder, parseTrashBatchName, trashedItems, withoutTrash } from '../../lib/files-trash';
|
||||||
|
|
||||||
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
||||||
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
|
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
|
||||||
@@ -208,3 +212,175 @@ live('live Stalwart files', () => {
|
|||||||
await deleteFileNodes([bulk.id]);
|
await deleteFileNodes([bulk.id]);
|
||||||
}, 180_000);
|
}, 180_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dumps the drive for a check with the webmail's own code (BULWARK_LIVE_PROBE_DIR).
|
||||||
|
async function writeProbe(name: string, list: FileNode[]): Promise<void> {
|
||||||
|
const dir = process.env.BULWARK_LIVE_PROBE_DIR;
|
||||||
|
if (!dir) return;
|
||||||
|
const { writeFile } = await import('node:fs/promises');
|
||||||
|
await writeFile(`${dir}/${name}-${SERVER!.replace(/\D/g, '').slice(-4)}.json`, JSON.stringify(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The trash is the webmail's convention (lib/files-trash): /.Trash/<ms>~<parentId>/<item>.
|
||||||
|
live('live Stalwart files trash', () => {
|
||||||
|
let scratch = '';
|
||||||
|
// An account's own trash is left alone: only what these tests put there goes.
|
||||||
|
let trashBefore: string | undefined;
|
||||||
|
const batches = new Set<string>();
|
||||||
|
|
||||||
|
const own = () => getAllFileNodes();
|
||||||
|
const upload = async (name: string, parentId: string, text = name) =>
|
||||||
|
(await uploadFileBatch([pick(name, text)], parentId, [])).uploaded[0];
|
||||||
|
const trashed = async (id: string) => trashedItems(await own()).find((i) => i.node.id === id);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await connectSingleton();
|
||||||
|
trashBefore = findTrashFolder(await own())?.id;
|
||||||
|
scratch = (await createFolder(`live-trash-${Date.now().toString(36)}`, null)).id;
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const before = await own();
|
||||||
|
const trash = findTrashFolder(before);
|
||||||
|
const leftovers = [scratch, ...[...batches].filter((id) => before.some((n) => n.id === id))];
|
||||||
|
if (trash && !trashBefore) leftovers.push(trash.id);
|
||||||
|
await deleteFileNodes(leftovers.filter(Boolean)).catch(() => undefined);
|
||||||
|
const after = await own();
|
||||||
|
expect(after.filter((n) => leftovers.includes(n.id))).toEqual([]);
|
||||||
|
if (!trashBefore) expect(findTrashFolder(after)).toBeUndefined();
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it('moves a file and a folder into one trash folder that names when and where from, and hides them', async () => {
|
||||||
|
const file = await upload('trash-me.txt', scratch);
|
||||||
|
const folder = await createFolder('Trash me', scratch);
|
||||||
|
const inner = await upload('inner.txt', folder.id);
|
||||||
|
let list = await own();
|
||||||
|
const before = Date.now();
|
||||||
|
const { done, refused } = await moveToTrash(list, [
|
||||||
|
list.find((n) => n.id === file.id)!,
|
||||||
|
list.find((n) => n.id === folder.id)!,
|
||||||
|
]);
|
||||||
|
expect(refused).toEqual([]);
|
||||||
|
expect(done.map((i) => i.node.id).sort()).toEqual([file.id, folder.id].sort());
|
||||||
|
|
||||||
|
list = await own();
|
||||||
|
const trash = findTrashFolder(list)!;
|
||||||
|
expect(trash).toBeDefined();
|
||||||
|
expect(trash.parentId ?? null).toBeNull();
|
||||||
|
expect(trash.name).toBe('.Trash');
|
||||||
|
const batch = list.find((n) => n.id === done[0].batchId)!;
|
||||||
|
batches.add(batch.id);
|
||||||
|
expect(batch.parentId).toBe(trash.id);
|
||||||
|
expect(fileNodeKind(batch)).toBe('directory');
|
||||||
|
// What the webmail reads back to restore: the time and the folder.
|
||||||
|
expect(batch.name).toMatch(/^(\d{10,})~([A-Za-z0-9_-]*)$/);
|
||||||
|
expect(parseTrashBatchName(batch.name)?.parentId).toBe(scratch);
|
||||||
|
expect(parseTrashBatchName(batch.name)!.deletedAt).toBeGreaterThanOrEqual(before - 1000);
|
||||||
|
expect(list.find((n) => n.id === file.id)?.parentId).toBe(batch.id);
|
||||||
|
// The folder went whole.
|
||||||
|
expect(list.find((n) => n.id === inner.id)?.parentId).toBe(folder.id);
|
||||||
|
|
||||||
|
const items = trashedItems(list).filter((i) => i.batchId === batch.id);
|
||||||
|
expect(items.map((i) => i.node.name).sort()).toEqual(['Trash me', 'trash-me.txt']);
|
||||||
|
expect(items.every((i) => i.fromParentId === scratch)).toBe(true);
|
||||||
|
|
||||||
|
// Out of every normal listing, the root included.
|
||||||
|
const shown = withoutTrash(await nodes());
|
||||||
|
expect(shown.some((n) => [trash.id, batch.id, file.id, folder.id, inner.id].includes(n.id))).toBe(false);
|
||||||
|
expect(listFolder(shown, null).some((n) => n.id === trash.id)).toBe(false);
|
||||||
|
expect(listFolder(shown, scratch).map((n) => n.id)).toEqual([]);
|
||||||
|
|
||||||
|
await writeProbe('trash-after-delete', list);
|
||||||
|
}, 90_000);
|
||||||
|
|
||||||
|
it('restores items to the folder they came from and removes their empty trash folder', async () => {
|
||||||
|
const all = trashedItems(await own());
|
||||||
|
const item = all.find((i) => i.node.name === 'trash-me.txt' && i.fromParentId === scratch)!;
|
||||||
|
const sibling = all.find((i) => i.node.name === 'Trash me' && i.batchId === item.batchId)!;
|
||||||
|
let list = await own();
|
||||||
|
const { done } = await restoreFromTrash(list, [item]);
|
||||||
|
expect(done).toHaveLength(1);
|
||||||
|
list = await own();
|
||||||
|
expect(list.find((n) => n.id === item.node.id)).toMatchObject({ name: 'trash-me.txt', parentId: scratch });
|
||||||
|
// The other item still holds the trash folder.
|
||||||
|
expect(list.some((n) => n.id === item.batchId)).toBe(true);
|
||||||
|
|
||||||
|
await restoreFromTrash(list, [sibling]);
|
||||||
|
list = await own();
|
||||||
|
expect(list.find((n) => n.id === sibling.node.id)?.parentId).toBe(scratch);
|
||||||
|
expect(list.some((n) => n.id === item.batchId)).toBe(false);
|
||||||
|
expect(await trashed(item.node.id)).toBeUndefined();
|
||||||
|
}, 90_000);
|
||||||
|
|
||||||
|
it('restores under a free name when the old one is taken, and to the root when the folder is gone', async () => {
|
||||||
|
const sub = await createFolder('Gone soon', scratch);
|
||||||
|
const a = await upload('clash.txt', scratch, 'old');
|
||||||
|
const b = await upload('orphan.txt', sub.id);
|
||||||
|
let list = await own();
|
||||||
|
const first = await moveToTrash(list, [list.find((n) => n.id === a.id)!]);
|
||||||
|
const second = await moveToTrash(list, [list.find((n) => n.id === b.id)!]);
|
||||||
|
for (const i of [...first.done, ...second.done]) batches.add(i.batchId!);
|
||||||
|
await upload('clash.txt', scratch, 'new');
|
||||||
|
await deleteFileNodes([sub.id]);
|
||||||
|
|
||||||
|
list = await own();
|
||||||
|
await restoreFromTrash(list, [(await trashed(a.id))!, (await trashed(b.id))!]);
|
||||||
|
list = await own();
|
||||||
|
expect(list.find((n) => n.id === a.id)).toMatchObject({ name: 'clash (1).txt', parentId: scratch });
|
||||||
|
const orphan = list.find((n) => n.id === b.id)!;
|
||||||
|
try {
|
||||||
|
expect(orphan.name).toBe('orphan.txt');
|
||||||
|
expect(orphan.parentId ?? null).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await deleteFileNodes([b.id]);
|
||||||
|
}
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it('undoes a delete with the listing from before it', async () => {
|
||||||
|
const file = await upload('undo-me.txt', scratch);
|
||||||
|
const list = await own();
|
||||||
|
const { done } = await moveToTrash(list, [list.find((n) => n.id === file.id)!]);
|
||||||
|
batches.add(done[0].batchId!);
|
||||||
|
// The rows on screen still show it where it was.
|
||||||
|
await restoreFromTrash(list, done);
|
||||||
|
const after = await own();
|
||||||
|
expect(after.find((n) => n.id === file.id)).toMatchObject({ name: 'undo-me.txt', parentId: scratch });
|
||||||
|
expect(after.some((n) => n.id === done[0].batchId)).toBe(false);
|
||||||
|
}, 90_000);
|
||||||
|
|
||||||
|
it('deletes items of the trash for good, and leaves a trash folder that still holds anything', async () => {
|
||||||
|
const x = await upload('purge-x.txt', scratch);
|
||||||
|
const y = await upload('purge-y.txt', scratch);
|
||||||
|
let list = await own();
|
||||||
|
const picked = [x, y].map((u) => list.find((n) => n.id === u.id)!);
|
||||||
|
const { done } = await moveToTrash(list, picked);
|
||||||
|
const batchId = done[0].batchId!;
|
||||||
|
batches.add(batchId);
|
||||||
|
|
||||||
|
// Never with what is inside: a folder that holds anything stays.
|
||||||
|
expect(await destroyEmptyFolders([batchId])).toEqual([]);
|
||||||
|
list = await own();
|
||||||
|
expect(list.some((n) => n.id === batchId)).toBe(true);
|
||||||
|
|
||||||
|
await purgeFromTrash(list, trashedItems(list).filter((i) => i.node.id === x.id));
|
||||||
|
list = await own();
|
||||||
|
expect(list.some((n) => n.id === x.id)).toBe(false);
|
||||||
|
expect(list.some((n) => n.id === batchId)).toBe(true);
|
||||||
|
|
||||||
|
await purgeFromTrash(list, trashedItems(list).filter((i) => i.node.id === y.id));
|
||||||
|
list = await own();
|
||||||
|
expect(list.some((n) => [x.id, y.id, batchId].includes(n.id))).toBe(false);
|
||||||
|
}, 90_000);
|
||||||
|
|
||||||
|
it('empties the trash', async () => {
|
||||||
|
// Only on an account whose trash these tests made: a real one stays.
|
||||||
|
if (trashBefore) return;
|
||||||
|
const file = await upload('empty-me.txt', scratch);
|
||||||
|
const list = await own();
|
||||||
|
await moveToTrash(list, [list.find((n) => n.id === file.id)!]);
|
||||||
|
await emptyTrash(await own());
|
||||||
|
const after = await own();
|
||||||
|
expect(findTrashFolder(after)).toBeUndefined();
|
||||||
|
expect(after.some((n) => n.id === file.id)).toBe(false);
|
||||||
|
}, 90_000);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import type { FileNode } from './types';
|
||||||
|
import {
|
||||||
|
createFolder, deleteFileNodes, destroyEmptyFolders, getAllFileNodes, isCrossAccountId, updateFileNodes,
|
||||||
|
} from './files';
|
||||||
|
import { JMAPSetError } from './jmap-result';
|
||||||
|
import { getUniqueName } from '../lib/filenode-name';
|
||||||
|
import {
|
||||||
|
TRASH_FOLDER_NAME, findTrashFolder, restoreTarget, trashBatchName, trashSubtreeIds, type TrashedItem,
|
||||||
|
} from '../lib/files-trash';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The writes of the Files trash (lib/files-trash has the convention). All of
|
||||||
|
* them act on the user's own drive. `nodes` is the drive as last listed; the
|
||||||
|
* server has the last word wherever another client may have changed it since.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TrashWriteResult {
|
||||||
|
/** What went through: for a delete, the items as they now sit in the trash. */
|
||||||
|
done: TrashedItem[];
|
||||||
|
/** The server's refusals of the rest. */
|
||||||
|
refused: JMAPSetError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The trash folder, created on the first delete. */
|
||||||
|
async function ensureTrashFolder(nodes: FileNode[]): Promise<FileNode> {
|
||||||
|
const known = findTrashFolder(nodes);
|
||||||
|
if (known) return known;
|
||||||
|
try {
|
||||||
|
return await createFolder(TRASH_FOLDER_NAME, null);
|
||||||
|
} catch (err) {
|
||||||
|
// Another client may have made it since the drive was listed.
|
||||||
|
const fresh = findTrashFolder(await getAllFileNodes());
|
||||||
|
if (fresh) return fresh;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the trash folders of `batchIds` that hold nothing once `leaving` are out of them. */
|
||||||
|
async function removeEmptyBatches(nodes: FileNode[], batchIds: Iterable<string>, leaving: Set<string>): Promise<void> {
|
||||||
|
const empty = [...new Set(batchIds)].filter((id) => !nodes.some((n) => n.parentId === id && !leaving.has(n.id)));
|
||||||
|
if (empty.length === 0) return;
|
||||||
|
await destroyEmptyFolders(empty).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move nodes of the user's own drive into the trash: one trash folder per
|
||||||
|
* folder they came from, named after the time and that folder, like the
|
||||||
|
* webmail does. A node of a shared drive can't go there; delete it instead.
|
||||||
|
* Throws when nothing went.
|
||||||
|
*/
|
||||||
|
export async function moveToTrash(
|
||||||
|
nodes: FileNode[],
|
||||||
|
targets: FileNode[],
|
||||||
|
deletedAt: number = Date.now(),
|
||||||
|
): Promise<TrashWriteResult> {
|
||||||
|
const result: TrashWriteResult = { done: [], refused: [] };
|
||||||
|
if (targets.length === 0) return result;
|
||||||
|
if (targets.some((n) => n.isShared || isCrossAccountId(n.id))) {
|
||||||
|
throw new JMAPSetError('forbidden', 'Files of a shared drive cannot be moved to the trash');
|
||||||
|
}
|
||||||
|
const groups = new Map<string | null, FileNode[]>();
|
||||||
|
for (const node of targets) {
|
||||||
|
const parentId = node.parentId ?? null;
|
||||||
|
groups.set(parentId, [...(groups.get(parentId) ?? []), node]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let trash = await ensureTrashFolder(nodes);
|
||||||
|
const createBatch = async (parentId: string | null): Promise<FileNode> => {
|
||||||
|
const name = trashBatchName(parentId, deletedAt);
|
||||||
|
try {
|
||||||
|
return await createFolder(name, trash.id);
|
||||||
|
} catch {
|
||||||
|
// The trash on screen may be gone (emptied on another device): read
|
||||||
|
// the drive again and try once more.
|
||||||
|
trash = await ensureTrashFolder(await getAllFileNodes());
|
||||||
|
return createFolder(name, trash.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [parentId, group] of groups) {
|
||||||
|
const batch = await createBatch(parentId);
|
||||||
|
const update = await updateFileNodes(Object.fromEntries(group.map((n) => [n.id, { parentId: batch.id }])));
|
||||||
|
const moved = new Set(update.updated);
|
||||||
|
result.refused.push(...Object.values(update.notUpdated));
|
||||||
|
if (moved.size === 0) {
|
||||||
|
await destroyEmptyFolders([batch.id]).catch(() => undefined);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const node of group.filter((n) => moved.has(n.id))) {
|
||||||
|
result.done.push({ node: { ...node, parentId: batch.id }, batchId: batch.id, deletedAt, fromParentId: parentId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.done.length === 0) {
|
||||||
|
throw result.refused[0] ?? new JMAPSetError('setError', 'Failed to move to the trash');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put items of the trash back into the folder each came from, or the root
|
||||||
|
* when that folder is gone or in the trash itself. A name taken there by now
|
||||||
|
* gets " (1)" and so on. Their trash folders go once they are empty. Throws
|
||||||
|
* when nothing went.
|
||||||
|
*/
|
||||||
|
export async function restoreFromTrash(nodes: FileNode[], items: TrashedItem[]): Promise<TrashWriteResult> {
|
||||||
|
const result: TrashWriteResult = { done: [], refused: [] };
|
||||||
|
if (items.length === 0) return result;
|
||||||
|
const hidden = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id);
|
||||||
|
const restoring = new Set(items.map((item) => item.node.id));
|
||||||
|
// Names taken in each folder, so a restored item never clashes; the items
|
||||||
|
// themselves don't count (after an undo the listing may still show them
|
||||||
|
// where they were).
|
||||||
|
const taken = new Map<string | null, Set<string>>();
|
||||||
|
const namesIn = (parentId: string | null): Set<string> => {
|
||||||
|
let names = taken.get(parentId);
|
||||||
|
if (!names) {
|
||||||
|
names = new Set(nodes
|
||||||
|
.filter((n) => (n.parentId ?? null) === parentId && !n.isShared && !hidden.has(n.id) && !restoring.has(n.id))
|
||||||
|
.map((n) => n.name));
|
||||||
|
taken.set(parentId, names);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>> = {};
|
||||||
|
const targets = new Map<string, string | null>();
|
||||||
|
for (const item of items) {
|
||||||
|
const target = restoreTarget(item, nodes);
|
||||||
|
const used = namesIn(target);
|
||||||
|
const name = getUniqueName(item.node.name, used);
|
||||||
|
used.add(name);
|
||||||
|
// The name only goes along when it has to change: the listing shows a
|
||||||
|
// WebDAV name decoded, and sending that back would rename the file.
|
||||||
|
updates[item.node.id] = name === item.node.name ? { parentId: target } : { parentId: target, name };
|
||||||
|
targets.set(item.node.id, target);
|
||||||
|
}
|
||||||
|
const update = await updateFileNodes(updates);
|
||||||
|
result.refused.push(...Object.values(update.notUpdated));
|
||||||
|
const restored = new Set(update.updated);
|
||||||
|
if (restored.size === 0) {
|
||||||
|
throw result.refused[0] ?? new JMAPSetError('setError', 'Failed to restore');
|
||||||
|
}
|
||||||
|
for (const item of items.filter((i) => restored.has(i.node.id))) {
|
||||||
|
const name = updates[item.node.id].name ?? item.node.name;
|
||||||
|
result.done.push({ ...item, node: { ...item.node, name, parentId: targets.get(item.node.id) ?? null } });
|
||||||
|
}
|
||||||
|
await removeEmptyBatches(nodes, result.done.flatMap((i) => (i.batchId ? [i.batchId] : [])), restored);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete items of the trash for good, with what is inside a deleted folder. */
|
||||||
|
export async function purgeFromTrash(nodes: FileNode[], items: TrashedItem[]): Promise<void> {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const ids = new Set(items.map((item) => item.node.id));
|
||||||
|
try {
|
||||||
|
await deleteFileNodes([...ids]);
|
||||||
|
} finally {
|
||||||
|
// Also after a partial refusal: the server keeps a trash folder that still holds anything.
|
||||||
|
await removeEmptyBatches(nodes, items.flatMap((i) => (i.batchId ? [i.batchId] : [])), ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete everything in the trash for good. The next delete makes a new trash folder. */
|
||||||
|
export async function emptyTrash(nodes: FileNode[]): Promise<void> {
|
||||||
|
// Not in the listing on screen: another client may have made one since.
|
||||||
|
const trash = findTrashFolder(nodes) ?? findTrashFolder(await getAllFileNodes());
|
||||||
|
if (trash) await deleteFileNodes([trash.id]);
|
||||||
|
}
|
||||||
@@ -478,6 +478,80 @@ export async function updateFileNode(
|
|||||||
if (notUpdated) throw fileSetError(notUpdated, 'Update failed');
|
if (notUpdated) throw fileSetError(notUpdated, 'Update failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileNodeUpdateResult {
|
||||||
|
updated: string[];
|
||||||
|
notUpdated: Record<string, JMAPSetError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Several renames or moves in one account, in FileNode/set calls of at most
|
||||||
|
* `maxObjectsInSet` each. Every id must name a node of the same account (all
|
||||||
|
* the user's own, or all of one shared account), and so must new parents.
|
||||||
|
* Refusals come back per id instead of being thrown.
|
||||||
|
*/
|
||||||
|
export async function updateFileNodes(
|
||||||
|
updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>,
|
||||||
|
): Promise<FileNodeUpdateResult> {
|
||||||
|
const result: FileNodeUpdateResult = { updated: [], notUpdated: {} };
|
||||||
|
const ids = Object.keys(updates);
|
||||||
|
if (ids.length === 0) return result;
|
||||||
|
const accountId = resolveFileNodeId(ids[0]).accountId;
|
||||||
|
const listedIds = new Map<string, string>();
|
||||||
|
const patches: Record<string, Record<string, unknown>> = {};
|
||||||
|
for (const id of ids) {
|
||||||
|
const node = resolveFileNodeId(id);
|
||||||
|
if (node.accountId !== accountId) {
|
||||||
|
throw new JMAPSetError('invalidProperties', 'Files of different accounts cannot be changed together');
|
||||||
|
}
|
||||||
|
const patch: Record<string, unknown> = { ...updates[id] };
|
||||||
|
if ('parentId' in updates[id]) patch.parentId = parentInAccount(updates[id].parentId ?? null, accountId);
|
||||||
|
listedIds.set(node.id as string, id);
|
||||||
|
patches[node.id as string] = patch;
|
||||||
|
}
|
||||||
|
for (const slice of batched(Object.keys(patches), jmapClient.getMaxObjectsInSet())) {
|
||||||
|
const res = await jmapClient.request(
|
||||||
|
[['FileNode/set', { accountId, update: Object.fromEntries(slice.map((id) => [id, patches[id]])) }, '0']],
|
||||||
|
fileUsing(),
|
||||||
|
);
|
||||||
|
const set = requireMethodResult(res, '0', 'FileNode/set');
|
||||||
|
const refused = (set.notUpdated ?? {}) as Record<string, SetError>;
|
||||||
|
for (const id of slice) {
|
||||||
|
const listed = listedIds.get(id)!;
|
||||||
|
if (refused[id]) result.notUpdated[listed] = fileSetError(refused[id], 'Update failed');
|
||||||
|
else if (set.updated && id in set.updated) result.updated.push(listed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove folders that hold nothing, without onDestroyRemoveChildren: the
|
||||||
|
* server refuses one that still holds anything (`nodeHasChildren`), so what
|
||||||
|
* another client has just put there stays. Refusals are not errors here;
|
||||||
|
* returns the ids that went.
|
||||||
|
*/
|
||||||
|
export async function destroyEmptyFolders(ids: string[]): Promise<string[]> {
|
||||||
|
const gone: string[] = [];
|
||||||
|
const byAccount = new Map<string, Map<string, string>>();
|
||||||
|
for (const id of ids) {
|
||||||
|
const node = resolveFileNodeId(id);
|
||||||
|
const listed = byAccount.get(node.accountId) ?? new Map<string, string>();
|
||||||
|
listed.set(node.id as string, id);
|
||||||
|
byAccount.set(node.accountId, listed);
|
||||||
|
}
|
||||||
|
for (const [accountId, listed] of byAccount) {
|
||||||
|
for (const slice of batched([...listed.keys()], jmapClient.getMaxObjectsInSet())) {
|
||||||
|
const res = await jmapClient.request(
|
||||||
|
[['FileNode/set', { accountId, destroy: slice }, '0']],
|
||||||
|
fileUsing(),
|
||||||
|
);
|
||||||
|
const destroyed = (requireMethodResult(res, '0', 'FileNode/set').destroyed ?? []) as string[];
|
||||||
|
gone.push(...destroyed.map((id) => listed.get(id) ?? id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gone;
|
||||||
|
}
|
||||||
|
|
||||||
export async function renameFileNode(id: string, newName: string): Promise<void> {
|
export async function renameFileNode(id: string, newName: string): Promise<void> {
|
||||||
await updateFileNode(id, { name: newName });
|
await updateFileNode(id, { name: newName });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { FileNode } from '../../api/types';
|
||||||
|
import {
|
||||||
|
findTrashFolder, parseTrashBatchName, restoreTarget, trashBatchName, trashOriginPath, trashSubtreeIds,
|
||||||
|
trashedItems, withoutTrash,
|
||||||
|
} from '../files-trash';
|
||||||
|
|
||||||
|
// The webmail's trash convention (its lib/files-trash.ts), read the same way here.
|
||||||
|
|
||||||
|
const dir = (id: string, parentId: string | null, name = id, extra: Partial<FileNode> = {}): FileNode =>
|
||||||
|
({ id, parentId, name, type: '', blobId: null, ...extra });
|
||||||
|
const file = (id: string, parentId: string | null, name = id, extra: Partial<FileNode> = {}): FileNode =>
|
||||||
|
({ id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 1, ...extra });
|
||||||
|
|
||||||
|
const drive: FileNode[] = [
|
||||||
|
dir('docs', null, 'Documents'),
|
||||||
|
dir('tax', 'docs', 'Taxes'),
|
||||||
|
file('keep', 'docs', 'keep.txt'),
|
||||||
|
dir('t', null, '.Trash'),
|
||||||
|
dir('b1', 't', '1730112000000~tax'),
|
||||||
|
file('r1', 'b1', 'report.pdf'),
|
||||||
|
dir('old', 'b1', 'Old stuff'),
|
||||||
|
file('deep', 'old', 'deep.txt'),
|
||||||
|
dir('b2', 't', '1730115600000~'),
|
||||||
|
file('r2', 'b2', 'notes.txt'),
|
||||||
|
// Put into the trash by hand (WebDAV), not by a delete.
|
||||||
|
file('loose', 't', 'loose.txt'),
|
||||||
|
// A shared account's own trash is not the user's.
|
||||||
|
dir('acc-2:t', null, '.Trash', { isShared: true, accountId: 'acc-2' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('trash batch names', () => {
|
||||||
|
it('names a delete after its time and folder, the root as empty', () => {
|
||||||
|
expect(trashBatchName('tax', 1730112000000)).toBe('1730112000000~tax');
|
||||||
|
expect(trashBatchName(null, 1730112000000)).toBe('1730112000000~');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads both back, and nothing else', () => {
|
||||||
|
expect(parseTrashBatchName('1730112000000~tax')).toEqual({ deletedAt: 1730112000000, parentId: 'tax' });
|
||||||
|
expect(parseTrashBatchName('1730112000000~')).toEqual({ deletedAt: 1730112000000, parentId: null });
|
||||||
|
expect(parseTrashBatchName('Holiday photos')).toBeNull();
|
||||||
|
expect(parseTrashBatchName('12~tax')).toBeNull();
|
||||||
|
expect(parseTrashBatchName('1730112000000~acc-2:x')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findTrashFolder', () => {
|
||||||
|
it('finds the own drive\'s .Trash folder at the root only', () => {
|
||||||
|
expect(findTrashFolder(drive)?.id).toBe('t');
|
||||||
|
expect(findTrashFolder([dir('x', 'docs', '.Trash')])).toBeUndefined();
|
||||||
|
expect(findTrashFolder([file('x', null, '.Trash')])).toBeUndefined();
|
||||||
|
expect(findTrashFolder([dir('acc-2:t', null, '.Trash', { isShared: true })])).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('trashedItems', () => {
|
||||||
|
it('lists each deleted item once, with when and where it came from', () => {
|
||||||
|
const items = trashedItems(drive);
|
||||||
|
expect(items.map((i) => [i.node.id, i.batchId, i.deletedAt, i.fromParentId])).toEqual([
|
||||||
|
['r1', 'b1', 1730112000000, 'tax'],
|
||||||
|
['old', 'b1', 1730112000000, 'tax'],
|
||||||
|
['r2', 'b2', 1730115600000, null],
|
||||||
|
['loose', null, null, null],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is empty without a trash folder', () => {
|
||||||
|
expect(trashedItems(drive.filter((n) => n.id !== 't'))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('withoutTrash', () => {
|
||||||
|
it('leaves the trash and everything in it out', () => {
|
||||||
|
expect([...trashSubtreeIds(drive, 't')].sort()).toEqual(['b1', 'b2', 'deep', 'loose', 'old', 'r1', 'r2', 't']);
|
||||||
|
expect(withoutTrash(drive).map((n) => n.id)).toEqual(['docs', 'tax', 'keep', 'acc-2:t']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restoreTarget', () => {
|
||||||
|
it('puts an item back into its folder while that is still there', () => {
|
||||||
|
expect(restoreTarget({ fromParentId: 'tax' }, drive)).toBe('tax');
|
||||||
|
expect(restoreTarget({ fromParentId: null }, drive)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the root for a folder that is gone, in the trash or not a folder', () => {
|
||||||
|
expect(restoreTarget({ fromParentId: 'missing' }, drive)).toBeNull();
|
||||||
|
expect(restoreTarget({ fromParentId: 'old' }, drive)).toBeNull();
|
||||||
|
expect(restoreTarget({ fromParentId: 'keep' }, drive)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('trashOriginPath', () => {
|
||||||
|
it('names the folder an item came from by its path', () => {
|
||||||
|
expect(trashOriginPath({ fromParentId: 'tax' }, drive)).toBe('/Documents/Taxes');
|
||||||
|
expect(trashOriginPath({ fromParentId: null }, drive)).toBe('/');
|
||||||
|
expect(trashOriginPath({ fromParentId: 'old' }, drive)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { FileNode } from '../api/types';
|
||||||
|
import { isFolder } from '../api/files';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The Files trash, the webmail's convention (lib/files-trash.ts there), so
|
||||||
|
* both clients see and restore the same trash. Neither Stalwart 0.16 nor 1.0
|
||||||
|
* keeps deleted FileNodes: a destroy removes a node and everything below it
|
||||||
|
* for good. So the trash is a folder the clients manage: `.Trash` at the root
|
||||||
|
* of the user's own drive, hidden from every listing. Each delete puts its
|
||||||
|
* items into a folder of its own inside it, named after when it happened and
|
||||||
|
* which folder the items came from (empty for the root):
|
||||||
|
*
|
||||||
|
* /.Trash/1730112000000~<parentId>/report.pdf
|
||||||
|
*
|
||||||
|
* The name is the whole record; nothing else is stored. A drive someone
|
||||||
|
* shared has no trash of the user's: what is deleted there is gone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TRASH_FOLDER_NAME = '.Trash';
|
||||||
|
|
||||||
|
/** The trash folder of the user's own drive, if there is one yet. */
|
||||||
|
export function findTrashFolder(nodes: FileNode[]): FileNode | undefined {
|
||||||
|
return nodes.find((n) =>
|
||||||
|
(n.parentId ?? null) === null
|
||||||
|
&& n.name === TRASH_FOLDER_NAME
|
||||||
|
&& !n.isShared
|
||||||
|
&& !n.id.includes(':')
|
||||||
|
&& isFolder(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name of the folder one delete's items go into. */
|
||||||
|
export function trashBatchName(parentId: string | null, deletedAt: number = Date.now()): string {
|
||||||
|
return `${deletedAt}~${parentId ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When a batch was deleted and from which folder (null: the root); null for any other name. */
|
||||||
|
export function parseTrashBatchName(name: string): { deletedAt: number; parentId: string | null } | null {
|
||||||
|
const match = /^(\d{10,})~([A-Za-z0-9_-]*)$/.exec(name);
|
||||||
|
if (!match) return null;
|
||||||
|
return { deletedAt: Number(match[1]), parentId: match[2] || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The trash folder and everything in it. */
|
||||||
|
export function trashSubtreeIds(nodes: FileNode[], trashId: string | undefined): Set<string> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
if (!trashId) return ids;
|
||||||
|
ids.add(trashId);
|
||||||
|
let grew = true;
|
||||||
|
while (grew) {
|
||||||
|
grew = false;
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.parentId && ids.has(n.parentId) && !ids.has(n.id)) {
|
||||||
|
ids.add(n.id);
|
||||||
|
grew = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nodes without the trash and what is in it: what every listing but the trash's own shows. */
|
||||||
|
export function withoutTrash(nodes: FileNode[]): FileNode[] {
|
||||||
|
const hidden = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id);
|
||||||
|
return hidden.size === 0 ? nodes : nodes.filter((n) => !hidden.has(n.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrashedItem {
|
||||||
|
node: FileNode;
|
||||||
|
/** The delete's folder in the trash; null for something put into the trash directly. */
|
||||||
|
batchId: string | null;
|
||||||
|
/** When it was deleted (ms); null when unknown. */
|
||||||
|
deletedAt: number | null;
|
||||||
|
/** The folder it came from; null for the root or when unknown. */
|
||||||
|
fromParentId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the trash holds, one entry per deleted item. */
|
||||||
|
export function trashedItems(nodes: FileNode[], trash: FileNode | undefined = findTrashFolder(nodes)): TrashedItem[] {
|
||||||
|
if (!trash) return [];
|
||||||
|
const items: TrashedItem[] = [];
|
||||||
|
for (const child of nodes.filter((n) => n.parentId === trash.id)) {
|
||||||
|
const batch = isFolder(child) ? parseTrashBatchName(child.name) : null;
|
||||||
|
if (!batch) {
|
||||||
|
items.push({ node: child, batchId: null, deletedAt: null, fromParentId: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const node of nodes.filter((n) => n.parentId === child.id)) {
|
||||||
|
items.push({ node, batchId: child.id, deletedAt: batch.deletedAt, fromParentId: batch.parentId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a restored item goes: the folder it came from while that is still a
|
||||||
|
* folder of the user's own drive outside the trash, the root otherwise.
|
||||||
|
*/
|
||||||
|
export function restoreTarget(item: Pick<TrashedItem, 'fromParentId'>, nodes: FileNode[]): string | null {
|
||||||
|
const from = item.fromParentId;
|
||||||
|
if (from === null) return null;
|
||||||
|
const hidden = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id);
|
||||||
|
const folder = nodes.find((n) => n.id === from);
|
||||||
|
return folder && isFolder(folder) && !folder.isShared && !hidden.has(folder.id) ? from : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path of the folder a deleted item came from ("/Documents/Taxes", "/"
|
||||||
|
* for the root); null when that folder is gone.
|
||||||
|
*/
|
||||||
|
export function trashOriginPath(item: Pick<TrashedItem, 'fromParentId'>, nodes: FileNode[]): string | null {
|
||||||
|
if (item.fromParentId === null) return '/';
|
||||||
|
if (restoreTarget(item, nodes) === null) return null;
|
||||||
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||||
|
const names: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (let cur = byId.get(item.fromParentId); cur && !seen.has(cur.id); cur = cur.parentId ? byId.get(cur.parentId) : undefined) {
|
||||||
|
seen.add(cur.id);
|
||||||
|
names.unshift(cur.name);
|
||||||
|
}
|
||||||
|
return `/${names.join('/')}`;
|
||||||
|
}
|
||||||
+292
-46
@@ -20,7 +20,7 @@ import {
|
|||||||
ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo,
|
ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo,
|
||||||
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
|
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
|
||||||
MoreVertical, Pencil, Share2, Trash2, Upload, Users, X, Download, Search,
|
MoreVertical, Pencil, Share2, Trash2, Upload, Users, X, Download, Search,
|
||||||
ArrowUpDown, ArrowUp, ArrowDown, FolderInput, Copy, Check, Eye, ExternalLink, FileSymlink,
|
ArrowUpDown, ArrowUp, ArrowDown, FolderInput, Copy, Check, Eye, ExternalLink, FileSymlink, RotateCcw,
|
||||||
} from '../components/icons';
|
} from '../components/icons';
|
||||||
import { File, Paths } from 'expo-file-system';
|
import { File, Paths } from 'expo-file-system';
|
||||||
import * as Sharing from 'expo-sharing';
|
import * as Sharing from 'expo-sharing';
|
||||||
@@ -49,6 +49,9 @@ import {
|
|||||||
getFileNameRules, getFileNodeDownloadUrl, getMaxSizeUpload, isFolder,
|
getFileNameRules, getFileNodeDownloadUrl, getMaxSizeUpload, isFolder,
|
||||||
isSymlink, listFolder, moveFileNode, renameFileNode, supportsSharing, uploadFileBatch,
|
isSymlink, listFolder, moveFileNode, renameFileNode, supportsSharing, uploadFileBatch,
|
||||||
} from '../api/files';
|
} from '../api/files';
|
||||||
|
import { emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash } from '../api/files-trash';
|
||||||
|
import { trashOriginPath, trashedItems, withoutTrash, type TrashedItem } from '../lib/files-trash';
|
||||||
|
import { toast } from '../stores/toast-store';
|
||||||
import { jmapClient } from '../api/jmap-client';
|
import { jmapClient } from '../api/jmap-client';
|
||||||
import { downloadAttachment, shareAttachment } from '../lib/email-export';
|
import { downloadAttachment, shareAttachment } from '../lib/email-export';
|
||||||
import { secureFetch } from '../lib/client-cert';
|
import { secureFetch } from '../lib/client-cert';
|
||||||
@@ -79,6 +82,8 @@ import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
|||||||
// A file row carries the display name alongside the rest of the node.
|
// A file row carries the display name alongside the rest of the node.
|
||||||
interface FileRow extends FileNode {
|
interface FileRow extends FileNode {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
/** Set for a row of the trash: when it was deleted and where from. */
|
||||||
|
trash?: TrashedItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Translate = TranslateFn;
|
type Translate = TranslateFn;
|
||||||
@@ -146,6 +151,13 @@ function modifiedOf(node: FileNode): string | undefined {
|
|||||||
return node.modified || node.created;
|
return node.modified || node.created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The date a row shows and sorts by: in the trash, when it was deleted
|
||||||
|
// (like the webmail's trash).
|
||||||
|
function rowDateOf(row: FileRow): string | undefined {
|
||||||
|
if (row.trash) return row.trash.deletedAt != null ? new Date(row.trash.deletedAt).toISOString() : undefined;
|
||||||
|
return modifiedOf(row);
|
||||||
|
}
|
||||||
|
|
||||||
function fileNameProblemText(problem: FileNameProblem, t: Translate): string {
|
function fileNameProblemText(problem: FileNameProblem, t: Translate): string {
|
||||||
switch (problem.kind) {
|
switch (problem.kind) {
|
||||||
case 'chars':
|
case 'chars':
|
||||||
@@ -214,7 +226,10 @@ export default function FilesScreen() {
|
|||||||
const uploadAbortRef = useRef<AbortController | null>(null);
|
const uploadAbortRef = useRef<AbortController | null>(null);
|
||||||
const [batchBusy, setBatchBusy] = useState<string | null>(null);
|
const [batchBusy, setBatchBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// The trash (lib/files-trash) is a place of its own, entered from the root.
|
||||||
|
const [inTrash, setInTrash] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[] } | null>(null);
|
const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[] } | null>(null);
|
||||||
|
const [confirmEmptyTrash, setConfirmEmptyTrash] = useState(false);
|
||||||
const [newFolderOpen, setNewFolderOpen] = useState(false);
|
const [newFolderOpen, setNewFolderOpen] = useState(false);
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
|
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
|
||||||
@@ -249,11 +264,17 @@ export default function FilesScreen() {
|
|||||||
const uploading = uploadProgress != null;
|
const uploading = uploadProgress != null;
|
||||||
const selectionMode = selection.size > 0;
|
const selectionMode = selection.size > 0;
|
||||||
const currentParentId = path.length === 0 ? null : path[path.length - 1].id;
|
const currentParentId = path.length === 0 ? null : path[path.length - 1].id;
|
||||||
|
// The webmail's `/.Trash` and everything in it stay out of every listing
|
||||||
|
// but the trash's own, hidden files shown or not.
|
||||||
|
const driveNodes = useMemo(() => withoutTrash(allNodes), [allNodes]);
|
||||||
|
const trashItems = useMemo(() => trashedItems(allNodes), [allNodes]);
|
||||||
// Inside a folder another account shared with us, new folders and uploads
|
// Inside a folder another account shared with us, new folders and uploads
|
||||||
// go to that account, and only where its rights allow them.
|
// go to that account, and only where its rights allow them. Nothing is
|
||||||
const canCreateHere = mayCreateIn(currentParentId, allNodes);
|
// added to the trash other than by deleting.
|
||||||
|
const canCreateHere = !inTrash && mayCreateIn(currentParentId, driveNodes);
|
||||||
const rootLabel = t('files.title', 'Files');
|
const rootLabel = t('files.title', 'Files');
|
||||||
const headerName = path.length === 0 ? rootLabel : path[path.length - 1].name;
|
const trashLabel = t('files.trash', 'Trash');
|
||||||
|
const headerName = inTrash ? trashLabel : path.length === 0 ? rootLabel : path[path.length - 1].name;
|
||||||
const sharingEnabled = supportsSharing();
|
const sharingEnabled = supportsSharing();
|
||||||
|
|
||||||
const loadFiles = useCallback(
|
const loadFiles = useCallback(
|
||||||
@@ -291,6 +312,7 @@ export default function FilesScreen() {
|
|||||||
uploadAbortRef.current?.abort();
|
uploadAbortRef.current?.abort();
|
||||||
setAllNodes([]);
|
setAllNodes([]);
|
||||||
setPath([]);
|
setPath([]);
|
||||||
|
setInTrash(false);
|
||||||
setSelection(new Set());
|
setSelection(new Set());
|
||||||
setActionsTarget(null);
|
setActionsTarget(null);
|
||||||
setShareTarget(null);
|
setShareTarget(null);
|
||||||
@@ -303,12 +325,13 @@ export default function FilesScreen() {
|
|||||||
void loadFiles(action === 'reload' ? 'refresh' : 'initial');
|
void loadFiles(action === 'reload' ? 'refresh' : 'initial');
|
||||||
}, [activeAccountId, session, loadFiles]);
|
}, [activeAccountId, session, loadFiles]);
|
||||||
|
|
||||||
// If we navigated into a folder that no longer exists (deleted, or its
|
// If we navigated into a folder that no longer exists (deleted, moved to
|
||||||
// share was revoked), cut the stack back to the deepest ancestor that does.
|
// the trash on another device, or its share was revoked), cut the stack
|
||||||
|
// back to the deepest ancestor that does.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (allNodes.length === 0) return;
|
if (allNodes.length === 0) return;
|
||||||
setPath((prev) => {
|
setPath((prev) => {
|
||||||
const byId = new Map(allNodes.map((n) => [n.id, n]));
|
const byId = new Map(driveNodes.map((n) => [n.id, n]));
|
||||||
let cut = prev.length;
|
let cut = prev.length;
|
||||||
for (let i = 0; i < prev.length; i++) {
|
for (let i = 0; i < prev.length; i++) {
|
||||||
const node = byId.get(prev[i].id);
|
const node = byId.get(prev[i].id);
|
||||||
@@ -319,16 +342,18 @@ export default function FilesScreen() {
|
|||||||
}
|
}
|
||||||
return cut === prev.length ? prev : prev.slice(0, cut);
|
return cut === prev.length ? prev : prev.slice(0, cut);
|
||||||
});
|
});
|
||||||
}, [allNodes]);
|
}, [allNodes, driveNodes]);
|
||||||
|
|
||||||
// Root: our own top-level nodes, plus the roots of every shared-with-me
|
// Root: our own top-level nodes, plus the roots of every shared-with-me
|
||||||
// subtree; Stalwart 1.0's placeholder folders above them are left out.
|
// subtree; Stalwart 1.0's placeholder folders above them are left out.
|
||||||
const folderNodes = useMemo(() => listFolder(allNodes, currentParentId), [allNodes, currentParentId]);
|
const folderNodes = useMemo(() => listFolder(driveNodes, currentParentId), [driveNodes, currentParentId]);
|
||||||
|
|
||||||
const visibleFiles = useMemo<FileRow[]>(() => {
|
const visibleFiles = useMemo<FileRow[]>(() => {
|
||||||
const q = searchQuery.trim().toLowerCase();
|
const q = searchQuery.trim().toLowerCase();
|
||||||
const rows = folderNodes
|
const source: FileRow[] = inTrash
|
||||||
.map<FileRow>((n) => ({ ...n, displayName: n.name }))
|
? trashItems.map((item) => ({ ...item.node, displayName: item.node.name, trash: item }))
|
||||||
|
: folderNodes.map((n) => ({ ...n, displayName: n.name }));
|
||||||
|
const rows = source
|
||||||
.filter((r) => showHiddenFiles || !r.displayName.startsWith('.'))
|
.filter((r) => showHiddenFiles || !r.displayName.startsWith('.'))
|
||||||
.filter((r) => !q || r.displayName.toLowerCase().includes(q));
|
.filter((r) => !q || r.displayName.toLowerCase().includes(q));
|
||||||
const dir = sortDir === 'desc' ? -1 : 1;
|
const dir = sortDir === 'desc' ? -1 : 1;
|
||||||
@@ -344,8 +369,8 @@ export default function FilesScreen() {
|
|||||||
case 'size':
|
case 'size':
|
||||||
return ((a.size ?? 0) - (b.size ?? 0)) * dir;
|
return ((a.size ?? 0) - (b.size ?? 0)) * dir;
|
||||||
case 'modified': {
|
case 'modified': {
|
||||||
const am = modifiedOf(a) ? new Date(modifiedOf(a)!).getTime() : 0;
|
const am = rowDateOf(a) ? new Date(rowDateOf(a)!).getTime() : 0;
|
||||||
const bm = modifiedOf(b) ? new Date(modifiedOf(b)!).getTime() : 0;
|
const bm = rowDateOf(b) ? new Date(rowDateOf(b)!).getTime() : 0;
|
||||||
return (am - bm) * dir;
|
return (am - bm) * dir;
|
||||||
}
|
}
|
||||||
case 'name':
|
case 'name':
|
||||||
@@ -353,7 +378,7 @@ export default function FilesScreen() {
|
|||||||
return a.displayName.localeCompare(b.displayName) * dir;
|
return a.displayName.localeCompare(b.displayName) * dir;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [folderNodes, searchQuery, showHiddenFiles, sortKey, sortDir]);
|
}, [inTrash, trashItems, folderNodes, searchQuery, showHiddenFiles, sortKey, sortDir]);
|
||||||
|
|
||||||
const clearSelection = useCallback(() => setSelection(new Set()), []);
|
const clearSelection = useCallback(() => setSelection(new Set()), []);
|
||||||
|
|
||||||
@@ -468,6 +493,13 @@ export default function FilesScreen() {
|
|||||||
toggleSelect(row);
|
toggleSelect(row);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// In the trash a file can be looked at; a folder is restored before
|
||||||
|
// it is opened. Everything else is in the row's actions.
|
||||||
|
if (row.trash) {
|
||||||
|
if (!isFolder(row) && !isSymlink(row) && canPreviewInApp(row)) setPreviewTarget(row);
|
||||||
|
else setActionsTarget(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isFolder(row)) {
|
if (isFolder(row)) {
|
||||||
setPath((p) => [...p, { id: row.id, name: row.displayName }]);
|
setPath((p) => [...p, { id: row.id, name: row.displayName }]);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
@@ -486,17 +518,25 @@ export default function FilesScreen() {
|
|||||||
}, [toggleSelect]);
|
}, [toggleSelect]);
|
||||||
|
|
||||||
const goBack = useCallback(() => {
|
const goBack = useCallback(() => {
|
||||||
setPath((p) => (p.length > 0 ? p.slice(0, -1) : p));
|
if (inTrash) setInTrash(false);
|
||||||
|
else setPath((p) => (p.length > 0 ? p.slice(0, -1) : p));
|
||||||
|
setSearchQuery('');
|
||||||
|
clearSelection();
|
||||||
|
}, [inTrash, clearSelection]);
|
||||||
|
|
||||||
|
const openTrash = useCallback(() => {
|
||||||
|
setInTrash(true);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
clearSelection();
|
clearSelection();
|
||||||
}, [clearSelection]);
|
}, [clearSelection]);
|
||||||
|
|
||||||
// Android back closes the preview, then leaves selection mode, then goes
|
// Android back closes the preview, then leaves selection mode, then goes
|
||||||
// up one folder; only at the root does it switch to the Mail tab.
|
// up one folder (out of the trash, which opens from the root); only at the
|
||||||
|
// root does it switch to the Mail tab.
|
||||||
const backStep = filesBackStep({
|
const backStep = filesBackStep({
|
||||||
previewOpen: previewTarget != null,
|
previewOpen: previewTarget != null,
|
||||||
selecting: selectionMode,
|
selecting: selectionMode,
|
||||||
folderDepth: path.length,
|
folderDepth: inTrash ? 1 : path.length,
|
||||||
});
|
});
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
if (backStep === 'close-preview') setPreviewTarget(null);
|
if (backStep === 'close-preview') setPreviewTarget(null);
|
||||||
@@ -665,10 +705,45 @@ export default function FilesScreen() {
|
|||||||
}
|
}
|
||||||
}, [uploading, currentParentId, allNodes, loadFiles, t]);
|
}, [uploading, currentParentId, allNodes, loadFiles, t]);
|
||||||
|
|
||||||
|
// Undo of a delete: the items go back from the trash, the way Restore puts
|
||||||
|
// them back, judged against the drive as it was before the delete.
|
||||||
|
const undoTrash = useCallback(async (listing: FileNode[], items: TrashedItem[]) => {
|
||||||
|
try {
|
||||||
|
await restoreFromTrash(listing, items);
|
||||||
|
toast.success(t('files.undo_success', 'Action undone'));
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(t('files.undo_error', 'Failed to undo'), fileErrorMessage(e, t));
|
||||||
|
}
|
||||||
|
await loadFiles('refresh');
|
||||||
|
}, [loadFiles, t]);
|
||||||
|
|
||||||
|
// Delete in the user's own drive moves into the trash, with Undo and no
|
||||||
|
// question, like the webmail. A drive someone shared has no trash of the
|
||||||
|
// user's, and in the trash a delete is for good: those ask first.
|
||||||
|
const trashRows = useCallback(async (rows: FileRow[]) => {
|
||||||
|
clearSelection();
|
||||||
|
const listing = allNodes;
|
||||||
|
try {
|
||||||
|
const { done, refused } = await moveToTrash(listing, rows);
|
||||||
|
await loadFiles('refresh');
|
||||||
|
if (refused.length > 0) {
|
||||||
|
Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(refused[0], t));
|
||||||
|
}
|
||||||
|
toast.success(
|
||||||
|
t('files.moved_to_trash', '{count, plural, one {Moved to trash} other {# items moved to trash}}', { count: done.length }),
|
||||||
|
{ action: { label: t('files.undo', 'Undo'), onPress: () => void undoTrash(listing, done) } },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t));
|
||||||
|
await loadFiles('refresh');
|
||||||
|
}
|
||||||
|
}, [allNodes, clearSelection, loadFiles, undoTrash, t]);
|
||||||
|
|
||||||
const requestDelete = useCallback((rows: FileRow[]) => {
|
const requestDelete = useCallback((rows: FileRow[]) => {
|
||||||
if (rows.length === 0) return;
|
if (rows.length === 0) return;
|
||||||
setConfirmDelete({ rows });
|
if (inTrash || rows.some((r) => r.isShared)) setConfirmDelete({ rows });
|
||||||
}, []);
|
else void trashRows(rows);
|
||||||
|
}, [inTrash, trashRows]);
|
||||||
|
|
||||||
const performDelete = useCallback(async () => {
|
const performDelete = useCallback(async () => {
|
||||||
if (!confirmDelete) return;
|
if (!confirmDelete) return;
|
||||||
@@ -676,19 +751,52 @@ export default function FilesScreen() {
|
|||||||
setConfirmDelete(null);
|
setConfirmDelete(null);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
try {
|
try {
|
||||||
|
const fromTrash = targets.flatMap((r) => (r.trash ? [r.trash] : []));
|
||||||
// The server removes folder descendants (onDestroyRemoveChildren).
|
// The server removes folder descendants (onDestroyRemoveChildren).
|
||||||
await deleteFileNodes(targets.map((t) => t.id));
|
if (fromTrash.length > 0) await purgeFromTrash(allNodes, fromTrash);
|
||||||
await loadFiles('refresh');
|
else await deleteFileNodes(targets.map((r) => r.id));
|
||||||
|
toast.success(t('files.deleted_forever', '{count, plural, one {Deleted permanently} other {# items deleted permanently}}', {
|
||||||
|
count: targets.length,
|
||||||
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t));
|
Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t));
|
||||||
}
|
}
|
||||||
}, [confirmDelete, clearSelection, loadFiles, t]);
|
await loadFiles('refresh');
|
||||||
|
}, [confirmDelete, allNodes, clearSelection, loadFiles, t]);
|
||||||
|
|
||||||
|
const restoreRows = useCallback(async (rows: FileRow[]) => {
|
||||||
|
const items = rows.flatMap((r) => (r.trash ? [r.trash] : []));
|
||||||
|
if (items.length === 0) return;
|
||||||
|
clearSelection();
|
||||||
|
try {
|
||||||
|
const { done, refused } = await restoreFromTrash(allNodes, items);
|
||||||
|
if (refused.length > 0) {
|
||||||
|
Alert.alert(t('files.restore_error', "Couldn't restore"), fileErrorMessage(refused[0], t));
|
||||||
|
}
|
||||||
|
toast.success(t('files.restored', '{count, plural, one {Item restored} other {# items restored}}', { count: done.length }));
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert(t('files.restore_error', "Couldn't restore"), fileErrorMessage(e, t));
|
||||||
|
}
|
||||||
|
await loadFiles('refresh');
|
||||||
|
}, [allNodes, clearSelection, loadFiles, t]);
|
||||||
|
|
||||||
|
const performEmptyTrash = useCallback(async () => {
|
||||||
|
setConfirmEmptyTrash(false);
|
||||||
|
clearSelection();
|
||||||
|
try {
|
||||||
|
await emptyTrash(allNodes);
|
||||||
|
toast.success(t('files.trash_emptied', 'Trash emptied'));
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert(t('files.empty_trash_error', "Couldn't empty the trash"), fileErrorMessage(e, t));
|
||||||
|
}
|
||||||
|
await loadFiles('refresh');
|
||||||
|
}, [allNodes, clearSelection, loadFiles, t]);
|
||||||
|
|
||||||
const sortLabel = t(`files.${sortKey}`, sortKey);
|
const sortLabel = t(`files.${sortKey}`, sortKey);
|
||||||
const SortDirIcon = sortDir === 'desc' ? ArrowDown : ArrowUp;
|
const SortDirIcon = sortDir === 'desc' ? ArrowDown : ArrowUp;
|
||||||
|
|
||||||
const renderHeader = () => {
|
const renderHeader = () => {
|
||||||
const canBack = path.length > 0;
|
const canBack = path.length > 0 || inTrash;
|
||||||
const ToggleIcon = viewMode === 'list' ? LayoutGrid : ListIcon;
|
const ToggleIcon = viewMode === 'list' ? LayoutGrid : ListIcon;
|
||||||
if (selectionMode) {
|
if (selectionMode) {
|
||||||
const selectedRows = visibleFiles.filter((r) => selection.has(r.id));
|
const selectedRows = visibleFiles.filter((r) => selection.has(r.id));
|
||||||
@@ -708,24 +816,35 @@ export default function FilesScreen() {
|
|||||||
{t('files.selected_count', '{count, plural, one {# selected} other {# selected}}', { count: selection.size })}
|
{t('files.selected_count', '{count, plural, one {# selected} other {# selected}}', { count: selection.size })}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
<Pressable
|
{inTrash ? (
|
||||||
onPress={() => void downloadSelection(selectedRows)}
|
<Pressable
|
||||||
hitSlop={8}
|
onPress={() => void restoreRows(selectedRows)}
|
||||||
style={styles.headerBtn}
|
hitSlop={8}
|
||||||
accessibilityLabel={t('files.download', 'Download')}
|
style={styles.headerBtn}
|
||||||
disabled={batchBusy != null}
|
accessibilityLabel={t('files.restore', 'Restore')}
|
||||||
>
|
>
|
||||||
{batchBusy != null ? (
|
<RotateCcw size={20} color={c.text} />
|
||||||
<ActivityIndicator size="small" color={c.text} />
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
<Download size={20} color={c.text} />
|
<Pressable
|
||||||
)}
|
onPress={() => void downloadSelection(selectedRows)}
|
||||||
</Pressable>
|
hitSlop={8}
|
||||||
|
style={styles.headerBtn}
|
||||||
|
accessibilityLabel={t('files.download', 'Download')}
|
||||||
|
disabled={batchBusy != null}
|
||||||
|
>
|
||||||
|
{batchBusy != null ? (
|
||||||
|
<ActivityIndicator size="small" color={c.text} />
|
||||||
|
) : (
|
||||||
|
<Download size={20} color={c.text} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => requestDelete(selectedRows)}
|
onPress={() => requestDelete(selectedRows)}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={styles.headerBtn}
|
style={styles.headerBtn}
|
||||||
accessibilityLabel={t('files.delete', 'Delete')}
|
accessibilityLabel={inTrash ? t('files.delete_forever', 'Delete permanently') : t('files.delete', 'Delete')}
|
||||||
>
|
>
|
||||||
<Trash2 size={20} color={c.error} />
|
<Trash2 size={20} color={c.error} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -798,11 +917,22 @@ export default function FilesScreen() {
|
|||||||
>
|
>
|
||||||
<ToggleIcon size={20} color={c.text} />
|
<ToggleIcon size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
{path.length === 0 && !inTrash ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={openTrash}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.headerBtn}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={trashLabel}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} color={c.text} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{canBack ? (
|
{canBack ? (
|
||||||
<Text style={styles.breadcrumb} numberOfLines={1}>
|
<Text style={styles.breadcrumb} numberOfLines={1}>
|
||||||
{[rootLabel, ...path.map((p) => p.name)].join(' / ')}
|
{[rootLabel, ...(inTrash ? [trashLabel] : path.map((p) => p.name))].join(' / ')}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={styles.toolbarRow}>
|
<View style={styles.toolbarRow}>
|
||||||
@@ -819,6 +949,19 @@ export default function FilesScreen() {
|
|||||||
clearButtonMode="while-editing"
|
clearButtonMode="while-editing"
|
||||||
/>
|
/>
|
||||||
) : <View style={{ flex: 1 }} />}
|
) : <View style={{ flex: 1 }} />}
|
||||||
|
{inTrash ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setConfirmEmptyTrash(true)}
|
||||||
|
hitSlop={6}
|
||||||
|
style={[styles.sortChip, trashItems.length === 0 && { opacity: 0.5 }]}
|
||||||
|
disabled={trashItems.length === 0}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled: trashItems.length === 0 }}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} color={c.error} />
|
||||||
|
<Text style={[styles.sortChipText, { color: c.error }]}>{t('files.empty_trash', 'Empty trash')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setSortSheetOpen(true)}
|
onPress={() => setSortSheetOpen(true)}
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
@@ -854,6 +997,15 @@ export default function FilesScreen() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Where a deleted item came from, on its row in the trash.
|
||||||
|
const trashOrigin = (item: TrashedItem): string | null => {
|
||||||
|
const from = trashOriginPath(item, allNodes);
|
||||||
|
if (from === null) return null;
|
||||||
|
return t('files.deleted_from', 'Deleted from {folder}', {
|
||||||
|
folder: from === '/' ? t('files.my_files', 'My files') : from,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const renderIcon = (item: FileRow, size: number) => {
|
const renderIcon = (item: FileRow, size: number) => {
|
||||||
const isDir = isFolder(item);
|
const isDir = isFolder(item);
|
||||||
const thumb = !isDir && showThumbnails ? thumbnailSource(item) : null;
|
const thumb = !isDir && showThumbnails ? thumbnailSource(item) : null;
|
||||||
@@ -881,7 +1033,8 @@ export default function FilesScreen() {
|
|||||||
const selected = selection.has(item.id);
|
const selected = selection.has(item.id);
|
||||||
const busy = busyId === item.id;
|
const busy = busyId === item.id;
|
||||||
const sharedOut = !!item.shareWith && Object.keys(item.shareWith).length > 0;
|
const sharedOut = !!item.shareWith && Object.keys(item.shareWith).length > 0;
|
||||||
const modified = modifiedOf(item);
|
const modified = rowDateOf(item);
|
||||||
|
const origin = item.trash ? trashOrigin(item.trash) : null;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => handleRowPress(item)}
|
onPress={() => handleRowPress(item)}
|
||||||
@@ -906,6 +1059,9 @@ export default function FilesScreen() {
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.fileMetaRow}>
|
<View style={styles.fileMetaRow}>
|
||||||
|
{origin ? (
|
||||||
|
<Text style={[styles.fileMeta, styles.fileMetaShrink]} numberOfLines={1}>{origin}</Text>
|
||||||
|
) : null}
|
||||||
{item.isShared && item.accountName ? (
|
{item.isShared && item.accountName ? (
|
||||||
<Text style={styles.fileMeta} numberOfLines={1}>
|
<Text style={styles.fileMeta} numberOfLines={1}>
|
||||||
{t('files.shared_by', 'Shared by {name}', { name: item.accountName })}
|
{t('files.shared_by', 'Shared by {name}', { name: item.accountName })}
|
||||||
@@ -1004,6 +1160,16 @@ export default function FilesScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
} else if (visibleFiles.length === 0 && inTrash && !searchQuery.trim()) {
|
||||||
|
body = (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<Trash2 size={48} color={c.textMuted} />
|
||||||
|
<Text style={styles.emptyText}>{t('files.trash_empty_title', 'Trash is empty')}</Text>
|
||||||
|
<Text style={styles.emptyHint}>
|
||||||
|
{t('files.trash_empty_hint', 'Deleted files stay here until you empty the trash.')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
} else if (visibleFiles.length === 0) {
|
} else if (visibleFiles.length === 0) {
|
||||||
body = (
|
body = (
|
||||||
<View style={styles.center}>
|
<View style={styles.center}>
|
||||||
@@ -1133,11 +1299,15 @@ export default function FilesScreen() {
|
|||||||
setActionsTarget(null);
|
setActionsTarget(null);
|
||||||
requestDelete([r]);
|
requestDelete([r]);
|
||||||
}}
|
}}
|
||||||
|
onRestore={(r) => {
|
||||||
|
setActionsTarget(null);
|
||||||
|
void restoreRows([r]);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FolderPickerSheet
|
<FolderPickerSheet
|
||||||
target={moveTarget}
|
target={moveTarget}
|
||||||
allNodes={allNodes}
|
allNodes={driveNodes}
|
||||||
rootLabel={rootLabel}
|
rootLabel={rootLabel}
|
||||||
t={t}
|
t={t}
|
||||||
onClose={() => setMoveTarget(null)}
|
onClose={() => setMoveTarget(null)}
|
||||||
@@ -1164,18 +1334,37 @@ export default function FilesScreen() {
|
|||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
visible={confirmDelete != null}
|
visible={confirmDelete != null}
|
||||||
title={
|
title={t('files.delete_forever_title', '{count, plural, one {Delete permanently?} other {Delete # items permanently?}}', {
|
||||||
|
count: confirmDelete?.rows.length ?? 1,
|
||||||
|
})}
|
||||||
|
message={
|
||||||
confirmDelete && confirmDelete.rows.length > 1
|
confirmDelete && confirmDelete.rows.length > 1
|
||||||
? t('files.delete_items_title', '{count, plural, one {Delete # item?} other {Delete # items?}}', { count: confirmDelete.rows.length })
|
? t('files.delete_forever_message_count', "{count, plural, one {1 item} other {# items}} will be deleted. You can't undo this.", {
|
||||||
: t('files.delete_item_title', 'Delete this item?')
|
count: confirmDelete.rows.length,
|
||||||
|
})
|
||||||
|
: t('files.delete_forever_message', "\"{name}\" will be deleted. You can't undo this.", {
|
||||||
|
name: confirmDelete?.rows[0]?.displayName ?? '',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
message={t('files.delete_cascade_hint', "Folders are deleted along with everything inside them. This can't be undone.")}
|
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
confirmText={t('files.delete', 'Delete')}
|
confirmText={t('files.delete_forever', 'Delete permanently')}
|
||||||
cancelText={t('files.cancel', 'Cancel')}
|
cancelText={t('files.cancel', 'Cancel')}
|
||||||
onConfirm={() => void performDelete()}
|
onConfirm={() => void performDelete()}
|
||||||
onCancel={() => setConfirmDelete(null)}
|
onCancel={() => setConfirmDelete(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
visible={confirmEmptyTrash}
|
||||||
|
title={t('files.empty_trash_title', 'Empty trash?')}
|
||||||
|
message={t('files.empty_trash_message', "{count, plural, one {The item in the trash} other {All # items in the trash}} will be deleted permanently. You can't undo this.", {
|
||||||
|
count: trashItems.length,
|
||||||
|
})}
|
||||||
|
variant="destructive"
|
||||||
|
confirmText={t('files.empty_trash', 'Empty trash')}
|
||||||
|
cancelText={t('files.cancel', 'Cancel')}
|
||||||
|
onConfirm={() => void performEmptyTrash()}
|
||||||
|
onCancel={() => setConfirmEmptyTrash(false)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1252,10 +1441,13 @@ interface ActionsSheetProps {
|
|||||||
onDuplicate: (r: FileRow) => void;
|
onDuplicate: (r: FileRow) => void;
|
||||||
onRename: (r: FileRow) => void;
|
onRename: (r: FileRow) => void;
|
||||||
onDelete: (r: FileRow) => void;
|
onDelete: (r: FileRow) => void;
|
||||||
|
/** For a row of the trash: put it back where it came from. */
|
||||||
|
onRestore: (r: FileRow) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionsSheet({
|
function ActionsSheet({
|
||||||
target, access, sharingEnabled, t, onClose, onPreview, onOpenWith, onDownload, onShare, onMove, onDuplicate, onRename, onDelete,
|
target, access, sharingEnabled, t, onClose, onPreview, onOpenWith, onDownload, onShare, onMove, onDuplicate, onRename, onDelete,
|
||||||
|
onRestore,
|
||||||
}: ActionsSheetProps) {
|
}: ActionsSheetProps) {
|
||||||
// Centred on a wide window, clear of side insets in landscape.
|
// Centred on a wide window, clear of side insets in landscape.
|
||||||
const sheetInsets = useSheetInsets();
|
const sheetInsets = useSheetInsets();
|
||||||
@@ -1272,7 +1464,52 @@ function ActionsSheet({
|
|||||||
// nodes are shared on by their owner, and get the writes their rights
|
// nodes are shared on by their owner, and get the writes their rights
|
||||||
// allow (`access`).
|
// allow (`access`).
|
||||||
const canShare = sharingEnabled && !link && !target.isShared && (target.myRights?.mayShare ?? true);
|
const canShare = sharingEnabled && !link && !target.isShared && (target.myRights?.mayShare ?? true);
|
||||||
const modified = modifiedOf(target);
|
const modified = rowDateOf(target);
|
||||||
|
// A deleted item can be restored, looked at or deleted for good, nothing else.
|
||||||
|
if (target.trash) {
|
||||||
|
return (
|
||||||
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||||
|
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||||
|
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||||
|
<TouchableWithoutFeedback accessible={false}>
|
||||||
|
<View style={styles.sheet}>
|
||||||
|
<Text style={styles.sheetTitle} numberOfLines={1}>
|
||||||
|
{target.displayName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.sheetMeta} numberOfLines={1}>
|
||||||
|
{[
|
||||||
|
isDir ? t('files.folder', 'Folder') : link ? t('files.symlink', 'Symbolic link') : (target.type || t('files.file', 'File')),
|
||||||
|
isFile && target.size != null ? formatFileSize(target.size) : null,
|
||||||
|
modified ? `${t('files.deleted', 'Deleted')} ${formatModified(modified)}` : null,
|
||||||
|
].filter(Boolean).join(' · ')}
|
||||||
|
</Text>
|
||||||
|
<Pressable style={styles.action} onPress={() => onRestore(target)}>
|
||||||
|
<RotateCcw size={18} color={c.text} />
|
||||||
|
<Text style={styles.actionLabel}>{t('files.restore', 'Restore')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
{isFile && canPreviewInApp(target) ? (
|
||||||
|
<Pressable style={styles.action} onPress={() => onPreview(target)}>
|
||||||
|
<Eye size={18} color={c.text} />
|
||||||
|
<Text style={styles.actionLabel}>{t('files.preview', 'Preview')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
{isFile ? (
|
||||||
|
<Pressable style={styles.action} onPress={() => onDownload(target)}>
|
||||||
|
<Download size={18} color={c.text} />
|
||||||
|
<Text style={styles.actionLabel}>{t('files.download', 'Download')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
<Pressable style={styles.action} onPress={() => onDelete(target)}>
|
||||||
|
<Trash2 size={18} color={c.error} />
|
||||||
|
<Text style={[styles.actionLabel, { color: c.error }]}>{t('files.delete_forever', 'Delete permanently')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||||
@@ -1612,6 +1849,12 @@ function makeStyles(c: ThemePalette) {
|
|||||||
fontSize: typography.body.fontSize,
|
fontSize: typography.body.fontSize,
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
},
|
},
|
||||||
|
emptyHint: {
|
||||||
|
color: c.textMuted,
|
||||||
|
fontSize: typography.caption.fontSize,
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
color: c.error,
|
color: c.error,
|
||||||
fontSize: typography.body.fontSize,
|
fontSize: typography.body.fontSize,
|
||||||
@@ -1660,6 +1903,9 @@ function makeStyles(c: ThemePalette) {
|
|||||||
fontSize: typography.caption.fontSize,
|
fontSize: typography.caption.fontSize,
|
||||||
color: c.textMuted,
|
color: c.textMuted,
|
||||||
},
|
},
|
||||||
|
fileMetaShrink: {
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
rowMore: {
|
rowMore: {
|
||||||
width: componentSizes.buttonLg,
|
width: componentSizes.buttonLg,
|
||||||
height: componentSizes.buttonLg,
|
height: componentSizes.buttonLg,
|
||||||
|
|||||||
Reference in New Issue
Block a user