mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
Merge branch 'fx/files' into feat/stalwart-1.0
This commit is contained in:
+20
-1
@@ -3917,7 +3917,26 @@
|
||||
"zoom_out": "تصغير",
|
||||
"zoom_reset": "ملاءمة الشاشة",
|
||||
"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": {
|
||||
"your_certificates": "شهاداتك",
|
||||
|
||||
+20
-1
@@ -3917,7 +3917,26 @@
|
||||
"zoom_out": "Redueix",
|
||||
"zoom_reset": "Ajusta a la pantalla",
|
||||
"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": {
|
||||
"your_certificates": "Els vostres certificats",
|
||||
|
||||
+20
-1
@@ -3875,7 +3875,26 @@
|
||||
"zoom_out": "Oddálit",
|
||||
"zoom_reset": "Přizpůsobit obrazovce",
|
||||
"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": {
|
||||
"your_certificates": "Vaše certifikáty",
|
||||
|
||||
+20
-1
@@ -3917,7 +3917,26 @@
|
||||
"zoom_out": "Zoom ud",
|
||||
"zoom_reset": "Tilpas til skærm",
|
||||
"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": {
|
||||
"your_certificates": "Dine certifikater",
|
||||
|
||||
+20
-1
@@ -3875,7 +3875,26 @@
|
||||
"zoom_out": "Verkleinern",
|
||||
"zoom_reset": "An Bildschirm anpassen",
|
||||
"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": {
|
||||
"your_certificates": "Ihre Zertifikate",
|
||||
|
||||
+20
-1
@@ -3917,7 +3917,26 @@
|
||||
"zoom_out": "Zoom out",
|
||||
"zoom_reset": "Fit to screen",
|
||||
"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": {
|
||||
"your_certificates": "Your Certificates",
|
||||
|
||||
+20
-1
@@ -3875,7 +3875,26 @@
|
||||
"zoom_out": "Alejar",
|
||||
"zoom_reset": "Ajustar a la pantalla",
|
||||
"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": {
|
||||
"your_certificates": "Tus certificados",
|
||||
|
||||
+20
-1
@@ -3917,7 +3917,26 @@
|
||||
"zoom_out": "Suumi välja",
|
||||
"zoom_reset": "Sobita ekraanile",
|
||||
"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": {
|
||||
"your_certificates": "Sinu sertifikaadid",
|
||||
|
||||
+20
-1
@@ -3917,7 +3917,26 @@
|
||||
"zoom_out": "کوچکنمایی",
|
||||
"zoom_reset": "متناسب با صفحه",
|
||||
"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": {
|
||||
"your_certificates": "گواهیهای شما",
|
||||
|
||||
+20
-1
@@ -3875,7 +3875,26 @@
|
||||
"zoom_out": "Zoom arrière",
|
||||
"zoom_reset": "Ajuster à l'écran",
|
||||
"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": {
|
||||
"your_certificates": "Vos certificats",
|
||||
|
||||
+20
-1
@@ -3793,7 +3793,26 @@
|
||||
"zoom_out": "הקטן",
|
||||
"zoom_reset": "התאם למסך",
|
||||
"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": {
|
||||
"your_certificates": "התעודות שלך",
|
||||
|
||||
+20
-1
@@ -3917,7 +3917,26 @@
|
||||
"zoom_out": "Kicsinyítés",
|
||||
"zoom_reset": "Igazítás a képernyőhöz",
|
||||
"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": {
|
||||
"your_certificates": "Tanúsítványaid",
|
||||
|
||||
+20
-1
@@ -3875,7 +3875,26 @@
|
||||
"zoom_out": "Riduci",
|
||||
"zoom_reset": "Adatta allo schermo",
|
||||
"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": {
|
||||
"your_certificates": "I tuoi certificati",
|
||||
|
||||
+20
-1
@@ -3875,7 +3875,26 @@
|
||||
"zoom_out": "縮小",
|
||||
"zoom_reset": "画面に合わせる",
|
||||
"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": {
|
||||
"your_certificates": "あなたの証明書",
|
||||
|
||||
+20
-1
@@ -3875,7 +3875,26 @@
|
||||
"zoom_out": "축소",
|
||||
"zoom_reset": "화면에 맞추기",
|
||||
"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": {
|
||||
"your_certificates": "내 인증서",
|
||||
|
||||
+20
-1
@@ -3875,7 +3875,26 @@
|
||||
"zoom_out": "Tālināt",
|
||||
"zoom_reset": "Ietilpināt ekrānā",
|
||||
"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": {
|
||||
"your_certificates": "Jūsu sertifikāti",
|
||||
|
||||
+20
-1
@@ -3917,7 +3917,26 @@
|
||||
"zoom_out": "Жижигрүүлэх",
|
||||
"zoom_reset": "Дэлгэцэнд тааруулах",
|
||||
"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": {
|
||||
"your_certificates": "Таны сертификатууд",
|
||||
|
||||
+20
-1
@@ -3917,7 +3917,26 @@
|
||||
"zoom_out": "Zoom ut",
|
||||
"zoom_reset": "Tilpass til skjermen",
|
||||
"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": {
|
||||
"your_certificates": "Dine sertifikater",
|
||||
|
||||
+20
-1
@@ -3875,7 +3875,26 @@
|
||||
"zoom_out": "Uitzoomen",
|
||||
"zoom_reset": "Aanpassen aan scherm",
|
||||
"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": {
|
||||
"your_certificates": "Uw certificaten",
|
||||
|
||||
+20
-1
@@ -3875,7 +3875,26 @@
|
||||
"zoom_out": "Pomniejsz",
|
||||
"zoom_reset": "Dopasuj do ekranu",
|
||||
"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": {
|
||||
"your_certificates": "Twoje certyfikaty",
|
||||
|
||||
+20
-1
@@ -3875,7 +3875,26 @@
|
||||
"zoom_out": "Diminuir zoom",
|
||||
"zoom_reset": "Ajustar ao ecrã",
|
||||
"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": {
|
||||
"your_certificates": "Seus certificados",
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"move_to": "Move to…",
|
||||
"name_control_chars": "Names cannot contain control characters.",
|
||||
"name_too_long": "This name is too long for the server.",
|
||||
"name_trash": "\".Trash\" is the name of the trash folder at the top of your files. Choose another name.",
|
||||
"open_error": "Could not open the file",
|
||||
"open_with": "Open with…",
|
||||
"preview_too_large": "This file is too large to preview.",
|
||||
@@ -518,6 +519,7 @@
|
||||
"shared_with": "Shared with",
|
||||
"sharing_access": "Sharing & access",
|
||||
"symlink": "Symbolic link",
|
||||
"trash_unavailable_message": "{count, plural, one {This item} other {These # items}} can't be moved to the trash. Delete permanently instead? You can't undo this.",
|
||||
"upload": "Upload",
|
||||
"upload_failed": "{count, plural, one {1 file could not be uploaded} other {# files could not be uploaded}}",
|
||||
"upload_unavailable": "The file picker is not installed in this build. Rebuild the app to enable uploads.",
|
||||
|
||||
+20
-1
@@ -3917,7 +3917,26 @@
|
||||
"zoom_out": "Micșorează",
|
||||
"zoom_reset": "Potrivește pe ecran",
|
||||
"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": {
|
||||
"your_certificates": "Certificatele dvs.",
|
||||
|
||||
+20
-1
@@ -3875,7 +3875,26 @@
|
||||
"zoom_out": "Уменьшить",
|
||||
"zoom_reset": "По размеру экрана",
|
||||
"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": {
|
||||
"your_certificates": "Ваши сертификаты",
|
||||
|
||||
+20
-1
@@ -3917,7 +3917,26 @@
|
||||
"zoom_out": "Oddialiť",
|
||||
"zoom_reset": "Prispôsobiť obrazovke",
|
||||
"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": {
|
||||
"your_certificates": "Vaše certifikáty",
|
||||
|
||||
+20
-1
@@ -3917,7 +3917,26 @@
|
||||
"zoom_out": "Uzaklaştır",
|
||||
"zoom_reset": "Ekrana sığdı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": {
|
||||
"your_certificates": "Sertifikalarınız",
|
||||
|
||||
+20
-1
@@ -3875,7 +3875,26 @@
|
||||
"zoom_out": "Зменшити",
|
||||
"zoom_reset": "За розміром екрана",
|
||||
"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": {
|
||||
"your_certificates": "Ваші сертифікати",
|
||||
|
||||
@@ -3917,7 +3917,26 @@
|
||||
"zoom_out": "縮小",
|
||||
"zoom_reset": "符合螢幕大小",
|
||||
"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": {
|
||||
"your_certificates": "您的憑證",
|
||||
|
||||
+20
-1
@@ -3875,7 +3875,26 @@
|
||||
"zoom_out": "缩小",
|
||||
"zoom_reset": "适应屏幕",
|
||||
"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": {
|
||||
"your_certificates": "您的证书",
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
serverUrl: 'https://mail.example.org',
|
||||
username: 'me@example.org',
|
||||
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 {
|
||||
TrashUnavailableError, emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash, undoMoveToTrash,
|
||||
} 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> = {};
|
||||
// Like Stalwart, two nodes of one folder can't share a name.
|
||||
const clashes = (parentId: string | null, name: string, self?: string) =>
|
||||
store.some((n) => n.parentId === parentId && n.name === name && n.id !== self);
|
||||
for (const [key, props] of Object.entries((args.create ?? {}) as Record<string, any>)) {
|
||||
if (clashes(props.parentId ?? null, props.name)) {
|
||||
(out.notCreated ??= {})[key] = { type: 'alreadyExists', description: 'taken' };
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
const parentId = 'parentId' in patch ? patch.parentId : node.parentId;
|
||||
if (clashes(parentId, patch.name ?? node.name, id)) {
|
||||
(out.notUpdated ??= {})[id] = { type: 'alreadyExists', description: 'taken' };
|
||||
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);
|
||||
|
||||
// Who is signed in: another login, or another files account of it.
|
||||
const client = jmapClient as unknown as { username: string; currentSession: { primaryAccounts: Record<string, string> } };
|
||||
function signIn(username: string, filesAccount = 'acc-1') {
|
||||
client.username = username;
|
||||
client.currentSession.primaryAccounts['urn:ietf:params:jmap:filenode'] = filesAccount;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
signIn('me@example.org');
|
||||
// No naming rules published unless a test says so.
|
||||
(jmapClient.getAccountCapability as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
||||
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('says the trash cannot be had when a file at the root is named .Trash', async () => {
|
||||
store.push({ id: 'f', parentId: null, name: '.Trash', blobId: 'bf' });
|
||||
const nodes = listed();
|
||||
const err = await moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000005).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(TrashUnavailableError);
|
||||
expect(calls.some((c) => c.update)).toBe(false);
|
||||
expect(byId('a')!.parentId).toBe('docs');
|
||||
});
|
||||
|
||||
it('says the trash cannot be had when no folder can be made in it (quota)', async () => {
|
||||
store.push({ id: 'T', parentId: null, name: '.Trash', blobId: null });
|
||||
const real = (jmapClient.request as ReturnType<typeof vi.fn>).getMockImplementation() as (c: any[]) => Promise<unknown>;
|
||||
(jmapClient.request as ReturnType<typeof vi.fn>).mockImplementation(async (methodCalls: any[]) => {
|
||||
if (methodCalls[0][1].create) {
|
||||
return { methodResponses: [['FileNode/set', { notCreated: { 'new-dir': { type: 'overQuota' } } }, '0']] };
|
||||
}
|
||||
return real(methodCalls);
|
||||
});
|
||||
const nodes = listed();
|
||||
await expect(moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000005)).rejects.toBeInstanceOf(TrashUnavailableError);
|
||||
expect(byId('a')!.parentId).toBe('docs');
|
||||
});
|
||||
|
||||
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(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(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(trashedItems(nodes).filter((i) => i.node.id === 'a'));
|
||||
expect(byId('a')).toMatchObject({ parentId: null, name: 'a.txt' });
|
||||
|
||||
nodes = listed();
|
||||
await restoreFromTrash(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(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(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);
|
||||
});
|
||||
|
||||
it('undoes into the root when the folder went to the trash after the delete', async () => {
|
||||
const before = listed();
|
||||
const { done } = await moveToTrash(before, before.filter((n) => n.id === 'a'), 1730112000007);
|
||||
await moveToTrash(listed(), listed().filter((n) => n.id === 'docs'), 1730112000008);
|
||||
const { done: back } = await restoreFromTrash(done);
|
||||
expect(back).toHaveLength(1);
|
||||
expect(byId('a')).toMatchObject({ parentId: null, name: 'a.txt' });
|
||||
});
|
||||
|
||||
it('undoes under a free name when the old one was taken after the delete', async () => {
|
||||
const before = listed();
|
||||
const { done } = await moveToTrash(before, before.filter((n) => n.id === 'a'), 1730112000007);
|
||||
store.push({ id: 'a2', parentId: 'docs', name: 'a.txt', blobId: 'ba2' });
|
||||
await restoreFromTrash(done);
|
||||
expect(byId('a')).toMatchObject({ parentId: 'docs', name: 'a (1).txt' });
|
||||
expect(byId('a2')).toMatchObject({ parentId: 'docs', name: 'a.txt' });
|
||||
});
|
||||
|
||||
it('puts an item back into its folder when that folder is restored with it', async () => {
|
||||
await moveToTrash(listed(), listed().filter((n) => n.id === 'a'), 1730112000007);
|
||||
await moveToTrash(listed(), listed().filter((n) => n.id === 'docs'), 1730112000008);
|
||||
const nodes = listed();
|
||||
const { done } = await restoreFromTrash(trashedItems(nodes));
|
||||
expect(done.map((i) => [i.node.id, i.node.parentId]).sort()).toEqual([['a', 'docs'], ['docs', null]]);
|
||||
expect(byId('docs')!.parentId).toBeNull();
|
||||
expect(byId('a')).toMatchObject({ parentId: 'docs', name: 'a.txt' });
|
||||
expect(store.some((n) => /^\d+~/.test(n.name))).toBe(false);
|
||||
});
|
||||
|
||||
it('puts an item back into a folder inside one restored with it, under a free name', async () => {
|
||||
store.push({ id: 'sub', parentId: 'docs', name: 'Sub', blobId: null }, { id: 'c', parentId: 'sub', name: 'c.txt', blobId: 'bc' });
|
||||
await moveToTrash(listed(), listed().filter((n) => n.id === 'c'), 1730112000007);
|
||||
store.push({ id: 'c2', parentId: 'sub', name: 'c.txt', blobId: 'bc2' });
|
||||
await moveToTrash(listed(), listed().filter((n) => n.id === 'docs'), 1730112000008);
|
||||
await restoreFromTrash(trashedItems(listed()));
|
||||
expect(byId('docs')!.parentId).toBeNull();
|
||||
expect(byId('c')).toMatchObject({ parentId: 'sub', name: 'c (1).txt' });
|
||||
expect(byId('c2')).toMatchObject({ parentId: 'sub', name: 'c.txt' });
|
||||
});
|
||||
|
||||
it('puts the item at the root when its folder could not be restored with it', async () => {
|
||||
await moveToTrash(listed(), listed().filter((n) => n.id === 'a'), 1730112000007);
|
||||
await moveToTrash(listed(), listed().filter((n) => n.id === 'docs'), 1730112000008);
|
||||
refuseUpdate.add('docs');
|
||||
const { done, refused } = await restoreFromTrash(trashedItems(listed()));
|
||||
expect(done.map((i) => i.node.id)).toEqual(['a']);
|
||||
expect(refused).toHaveLength(1);
|
||||
expect(byId('a')!.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('gives a clashing WebDAV name a free one the server accepts', async () => {
|
||||
// Stored by WebDAV with a ":" escaped, which JMAP names refuse.
|
||||
store.push({ id: 'w', parentId: 'docs', name: 'a%3Ab.txt', blobId: 'bw' });
|
||||
await moveToTrash(listed(), listed().filter((n) => n.id === 'w'), 1730112000007);
|
||||
store.push({ id: 'w2', parentId: 'docs', name: 'a%3Ab.txt', blobId: 'bw2' });
|
||||
(jmapClient.getAccountCapability as ReturnType<typeof vi.fn>).mockReturnValue({ forbiddenNameChars: ':*' });
|
||||
await restoreFromTrash(trashedItems(listed()));
|
||||
const update = calls.filter((c) => c.update).pop()!.update as Record<string, Record<string, unknown>>;
|
||||
expect(update.w).toEqual({ parentId: 'docs', name: 'a_b (1).txt' });
|
||||
expect(byId('w')).toMatchObject({ parentId: 'docs', name: 'a_b (1).txt' });
|
||||
});
|
||||
|
||||
it('gives a folder named .Trash that goes back to the root another name', async () => {
|
||||
store.push({ id: 'x', parentId: 'docs', name: '.Trash', blobId: null }, { id: 'xin', parentId: 'x', name: 'in.txt', blobId: 'bi' });
|
||||
let nodes = listed();
|
||||
await moveToTrash(nodes, nodes.filter((n) => n.id === 'x'), 1730112000007);
|
||||
// Docs goes too: the folder comes back to the root.
|
||||
await moveToTrash(listed(), listed().filter((n) => n.id === 'docs'), 1730112000008);
|
||||
nodes = listed();
|
||||
await restoreFromTrash(trashedItems(nodes).filter((i) => i.node.id === 'x'));
|
||||
expect(byId('x')).toMatchObject({ parentId: null, name: '.Trash (1)' });
|
||||
expect(store.filter((n) => n.parentId === null && n.name === '.Trash')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves an item alone that left the trash since the rows were listed', async () => {
|
||||
let nodes = listed();
|
||||
await moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000007);
|
||||
nodes = listed();
|
||||
const rows = trashedItems(nodes);
|
||||
// Restored on another device and moved on from there.
|
||||
byId('a')!.parentId = 'top-dir';
|
||||
store.push({ id: 'top-dir', parentId: null, name: 'Elsewhere', blobId: null });
|
||||
const sent = calls.length;
|
||||
const { done } = await restoreFromTrash(rows);
|
||||
expect(done).toEqual([]);
|
||||
expect(calls.slice(sent).filter((c) => c.update)).toEqual([]);
|
||||
expect(byId('a')!.parentId).toBe('top-dir');
|
||||
});
|
||||
});
|
||||
|
||||
describe('undoMoveToTrash', () => {
|
||||
it('puts the items back while the same drive is signed in', async () => {
|
||||
const before = listed();
|
||||
const moved = await moveToTrash(before, before.filter((n) => n.id === 'a'), 1730112000007);
|
||||
expect((await undoMoveToTrash(moved))?.done).toHaveLength(1);
|
||||
expect(byId('a')).toMatchObject({ parentId: 'docs' });
|
||||
});
|
||||
|
||||
it('sends nothing once another login is signed in, whose ids may be the same', async () => {
|
||||
const before = listed();
|
||||
const moved = await moveToTrash(before, before.filter((n) => n.id === 'a'), 1730112000007);
|
||||
signIn('other@example.org');
|
||||
const sent = calls.length;
|
||||
const requests = (jmapClient.request as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
expect(await undoMoveToTrash(moved)).toBeNull();
|
||||
expect(calls.length).toBe(sent);
|
||||
expect((jmapClient.request as ReturnType<typeof vi.fn>).mock.calls.length).toBe(requests);
|
||||
expect(byId(byId('a')!.parentId!)!.name).toBe('1730112000007~docs');
|
||||
});
|
||||
|
||||
it('sends nothing once the login reads another files account', async () => {
|
||||
const before = listed();
|
||||
const moved = await moveToTrash(before, before.filter((n) => n.id === 'a'), 1730112000007);
|
||||
signIn('me@example.org', 'acc-3');
|
||||
const sent = calls.length;
|
||||
expect(await undoMoveToTrash(moved)).toBeNull();
|
||||
expect(calls.length).toBe(sent);
|
||||
});
|
||||
|
||||
it('stops before the restore when the account changes while the drive is read', async () => {
|
||||
const before = listed();
|
||||
const moved = await moveToTrash(before, before.filter((n) => n.id === 'a'), 1730112000007);
|
||||
const real = (jmapClient.request as ReturnType<typeof vi.fn>).getMockImplementation() as (c: any[]) => Promise<unknown>;
|
||||
(jmapClient.request as ReturnType<typeof vi.fn>).mockImplementation(async (methodCalls: any[]) => {
|
||||
const out = await real(methodCalls);
|
||||
if (methodCalls[0][0] === 'FileNode/get') signIn('other@example.org');
|
||||
return out;
|
||||
});
|
||||
const sent = calls.length;
|
||||
expect(await undoMoveToTrash(moved)).toBeNull();
|
||||
expect(calls.length).toBe(sent);
|
||||
});
|
||||
|
||||
it('stops a delete whose account changes before its items are moved', async () => {
|
||||
const before = listed();
|
||||
const real = (jmapClient.request as ReturnType<typeof vi.fn>).getMockImplementation() as (c: any[]) => Promise<unknown>;
|
||||
(jmapClient.request as ReturnType<typeof vi.fn>).mockImplementation(async (methodCalls: any[]) => {
|
||||
const out = await real(methodCalls);
|
||||
if (methodCalls[0][1].create) signIn('other@example.org');
|
||||
return out;
|
||||
});
|
||||
await expect(moveToTrash(before, before.filter((n) => n.id === 'a'), 1730112000007)).rejects.toThrow('Another account');
|
||||
expect(calls.some((c) => c.update)).toBe(false);
|
||||
expect(byId('a')!.parentId).toBe('docs');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
expect(await purgeFromTrash(trashedItems(nodes))).toBe(1);
|
||||
expect(store.map((n) => n.id).sort()).toEqual(['n1', 'top']);
|
||||
expect(byId('n1')!.name).toBe('.Trash');
|
||||
});
|
||||
|
||||
it('leaves a row on screen alone that was restored elsewhere meanwhile', async () => {
|
||||
let nodes = listed();
|
||||
await moveToTrash(nodes, nodes.filter((n) => n.id === 'docs'), 1730112000007);
|
||||
nodes = listed();
|
||||
const rows = trashedItems(nodes);
|
||||
// Another device puts Docs back before the Delete permanently here.
|
||||
byId('docs')!.parentId = null;
|
||||
expect(await purgeFromTrash(rows)).toBe(0);
|
||||
expect(store.map((n) => n.id).sort()).toEqual(['a', 'b', 'docs', 'n1', 'n2', 'top']);
|
||||
expect(calls.some((c) => (c.destroy as string[] | undefined)?.includes('docs'))).toBe(false);
|
||||
});
|
||||
|
||||
it('deletes only the rows still in the trash, and their trash folder as it is now', async () => {
|
||||
let nodes = listed();
|
||||
await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007);
|
||||
nodes = listed();
|
||||
const rows = trashedItems(nodes);
|
||||
byId('b')!.parentId = 'docs';
|
||||
expect(await purgeFromTrash(rows)).toBe(1);
|
||||
expect(byId('a')).toBeUndefined();
|
||||
expect(byId('b')).toMatchObject({ parentId: 'docs' });
|
||||
expect(store.some((n) => n.name === '1730112000007~docs')).toBe(false);
|
||||
});
|
||||
|
||||
it('empties the trash, everything in it included', async () => {
|
||||
const nodes = listed();
|
||||
await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007);
|
||||
await emptyTrash();
|
||||
expect(store.map((n) => n.id).sort()).toEqual(['docs', 'top']);
|
||||
});
|
||||
|
||||
it('empties the trash as it is now, not a folder that was the trash on screen', async () => {
|
||||
store.push({ id: 'T', parentId: null, name: '.Trash', blobId: null }, { id: 'old', parentId: 'T', name: 'old.txt', blobId: 'bo' });
|
||||
// Renamed elsewhere (WebDAV), and a new trash made since.
|
||||
byId('T')!.name = 'Kept';
|
||||
store.push({ id: 'T2', parentId: null, name: '.Trash', blobId: null }, { id: 'new', parentId: 'T2', name: 'new.txt', blobId: 'bn' });
|
||||
await emptyTrash();
|
||||
expect(store.map((n) => n.id).sort()).toEqual(['T', 'a', 'b', 'docs', 'old', 'top']);
|
||||
});
|
||||
});
|
||||
@@ -36,8 +36,10 @@ import {
|
||||
copyFileNode,
|
||||
createFolder,
|
||||
deleteFileNodes,
|
||||
destroyEmptyFolders,
|
||||
fileNodeKind,
|
||||
filesAccountId,
|
||||
getAllFileNodes,
|
||||
getAllFileNodesAcrossAccounts,
|
||||
getFileNodeDownloadUrl,
|
||||
listFolder,
|
||||
@@ -45,6 +47,10 @@ import {
|
||||
renameFileNode,
|
||||
uploadFileBatch,
|
||||
} from '../files';
|
||||
import {
|
||||
TrashUnavailableError, emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash, undoMoveToTrash,
|
||||
} from '../files-trash';
|
||||
import { findTrashFolder, parseTrashBatchName, trashedItems, withoutTrash } from '../../lib/files-trash';
|
||||
|
||||
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
||||
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
|
||||
@@ -208,3 +214,240 @@ live('live Stalwart files', () => {
|
||||
await deleteFileNodes([bulk.id]);
|
||||
}, 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('says the trash cannot be had while a file at the root is named .Trash', async () => {
|
||||
// Only on an account without a trash: a real one stays.
|
||||
if (trashBefore) return;
|
||||
const blocker = (await uploadFileBatch([pick('.Trash', 'not a folder')], null, [])).uploaded[0];
|
||||
expect(blocker?.name).toBe('.Trash');
|
||||
const file = await upload('blocked.txt', scratch);
|
||||
try {
|
||||
const list = await own();
|
||||
const err = await moveToTrash(list, [list.find((n) => n.id === file.id)!]).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(TrashUnavailableError);
|
||||
expect((await own()).find((n) => n.id === file.id)?.parentId).toBe(scratch);
|
||||
} finally {
|
||||
await deleteFileNodes([blocker.id, file.id]);
|
||||
}
|
||||
expect(findTrashFolder(await own())).toBeUndefined();
|
||||
}, 90_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([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([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([(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', async () => {
|
||||
const file = await upload('undo-me.txt', scratch);
|
||||
const list = await own();
|
||||
const moved = await moveToTrash(list, [list.find((n) => n.id === file.id)!]);
|
||||
batches.add(moved.done[0].batchId!);
|
||||
expect((await undoMoveToTrash(moved))?.done).toHaveLength(1);
|
||||
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 === moved.done[0].batchId)).toBe(false);
|
||||
}, 90_000);
|
||||
|
||||
it('undoes a delete into the root when its folder went to the trash since', async () => {
|
||||
const folder = await createFolder('Undo folder', scratch);
|
||||
const file = await upload(`undo-root-${Date.now().toString(36)}.txt`, folder.id);
|
||||
let list = await own();
|
||||
const moved = await moveToTrash(list, [list.find((n) => n.id === file.id)!]);
|
||||
list = await own();
|
||||
const second = await moveToTrash(list, [list.find((n) => n.id === folder.id)!]);
|
||||
for (const i of [...moved.done, ...second.done]) batches.add(i.batchId!);
|
||||
try {
|
||||
await undoMoveToTrash(moved);
|
||||
const after = await own();
|
||||
expect(after.find((n) => n.id === file.id)?.parentId ?? null).toBeNull();
|
||||
expect(after.some((n) => n.id === moved.done[0].batchId)).toBe(false);
|
||||
} finally {
|
||||
await deleteFileNodes([file.id]).catch(() => undefined);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
it('restores an item into its folder when the folder is restored with it', async () => {
|
||||
const folder = await createFolder('Restore together', scratch);
|
||||
const file = await upload('together.txt', folder.id);
|
||||
let list = await own();
|
||||
const first = await moveToTrash(list, [list.find((n) => n.id === file.id)!]);
|
||||
list = await own();
|
||||
const second = await moveToTrash(list, [list.find((n) => n.id === folder.id)!]);
|
||||
for (const i of [...first.done, ...second.done]) batches.add(i.batchId!);
|
||||
const items = trashedItems(await own()).filter((i) => [file.id, folder.id].includes(i.node.id));
|
||||
expect(items).toHaveLength(2);
|
||||
const { done, refused } = await restoreFromTrash(items);
|
||||
expect(refused).toEqual([]);
|
||||
expect(done).toHaveLength(2);
|
||||
const after = await own();
|
||||
expect(after.find((n) => n.id === folder.id)?.parentId).toBe(scratch);
|
||||
expect(after.find((n) => n.id === file.id)).toMatchObject({ name: 'together.txt', parentId: folder.id });
|
||||
}, 90_000);
|
||||
|
||||
it('leaves a row alone on Delete permanently that was restored since it was listed', async () => {
|
||||
const file = await upload('stale-row.txt', scratch);
|
||||
let list = await own();
|
||||
const { done } = await moveToTrash(list, [list.find((n) => n.id === file.id)!]);
|
||||
batches.add(done[0].batchId!);
|
||||
list = await own();
|
||||
const rows = trashedItems(list).filter((i) => i.node.id === file.id);
|
||||
// Restored on another device meanwhile.
|
||||
await restoreFromTrash(rows);
|
||||
expect(await purgeFromTrash(rows)).toBe(0);
|
||||
expect((await own()).find((n) => n.id === file.id)?.parentId).toBe(scratch);
|
||||
}, 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(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(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();
|
||||
const after = await own();
|
||||
expect(findTrashFolder(after)).toBeUndefined();
|
||||
expect(after.some((n) => n.id === file.id)).toBe(false);
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { FileNode } from './types';
|
||||
import { jmapClient } from './jmap-client';
|
||||
import {
|
||||
createFolder, deleteFileNodes, destroyEmptyFolders, filesAccountId, getAllFileNodes, getFileNameRules,
|
||||
isCrossAccountId, isFolder, updateFileNodes,
|
||||
} from './files';
|
||||
import { JMAPSetError } from './jmap-result';
|
||||
import { getUniqueName } from '../lib/filenode-name';
|
||||
import { acceptedFileName } from '../lib/file-name-rules';
|
||||
import {
|
||||
TRASH_FOLDER_NAME, findTrashFolder, restoreTarget, trashBatchName, trashPlaces, trashSubtreeIds, trashedItems,
|
||||
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 drive a write went to: the login (server and user) and its files
|
||||
* account. Every write resolves the account when it is sent, and node ids
|
||||
* repeat across accounts and servers, so a write prepared for one drive
|
||||
* must not go out once another is signed in.
|
||||
*/
|
||||
export interface TrashDrive {
|
||||
server: string | null;
|
||||
username: string | null;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
export function currentTrashDrive(): TrashDrive {
|
||||
return { server: jmapClient.serverUrl, username: jmapClient.username, accountId: filesAccountId() };
|
||||
}
|
||||
|
||||
function isCurrentDrive(drive: TrashDrive): boolean {
|
||||
const now = currentTrashDrive();
|
||||
return now.server === drive.server && now.username === drive.username && now.accountId === drive.accountId;
|
||||
}
|
||||
|
||||
/** Thrown before a write would go to another drive than the one it was prepared for. */
|
||||
export class TrashDriveChangedError extends Error {
|
||||
constructor() {
|
||||
super('Another account is signed in');
|
||||
this.name = 'TrashDriveChangedError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertDrive(drive: TrashDrive | undefined): void {
|
||||
if (drive && !isCurrentDrive(drive)) throw new TrashDriveChangedError();
|
||||
}
|
||||
|
||||
/** A delete into the trash, with the drive it went to (for its undo). */
|
||||
export interface TrashMoveResult extends TrashWriteResult {
|
||||
drive: TrashDrive;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trash, or a delete's folder in it, can't be made: a file is named
|
||||
* `.Trash` at the root, the user may not create folders there, or the quota
|
||||
* is used up. Nothing was moved; the items can only be deleted for good.
|
||||
*/
|
||||
export class TrashUnavailableError extends Error {
|
||||
constructor(readonly reason: unknown) {
|
||||
super(reason instanceof Error ? reason.message : 'The trash cannot be used');
|
||||
this.name = 'TrashUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 new TrashUnavailableError(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.
|
||||
* Stops once another account is signed in. Throws when nothing went.
|
||||
*/
|
||||
export async function moveToTrash(
|
||||
nodes: FileNode[],
|
||||
targets: FileNode[],
|
||||
deletedAt: number = Date.now(),
|
||||
): Promise<TrashMoveResult> {
|
||||
const drive = currentTrashDrive();
|
||||
const result: TrashMoveResult = { done: [], refused: [], drive };
|
||||
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());
|
||||
assertDrive(drive);
|
||||
try {
|
||||
return await createFolder(name, trash.id);
|
||||
} catch (err) {
|
||||
throw new TrashUnavailableError(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const [parentId, group] of groups) {
|
||||
if (!isCurrentDrive(drive)) break;
|
||||
let batch: FileNode;
|
||||
try {
|
||||
batch = await createBatch(parentId);
|
||||
} catch (err) {
|
||||
// What went already stays in the trash; the rest is reported.
|
||||
if (!(err instanceof TrashUnavailableError) || result.done.length === 0) throw err;
|
||||
result.refused.push(new JMAPSetError('setError', err.message));
|
||||
break;
|
||||
}
|
||||
if (!isCurrentDrive(drive)) break;
|
||||
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) {
|
||||
assertDrive(drive);
|
||||
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 (unless it comes back in
|
||||
* the same call). A name taken there by now gets " (1)" and so on. Their
|
||||
* trash folders go once they are empty.
|
||||
*
|
||||
* The drive is read again first, and that is what counts: the listing on
|
||||
* screen (for an undo, the one from before the delete) misses a folder
|
||||
* trashed since and a name taken since. Items no longer in the trash are left
|
||||
* where they are. Throws when nothing of what is still there went, and
|
||||
* TrashDriveChangedError, with nothing sent, once `drive` is no longer the
|
||||
* one signed in.
|
||||
*/
|
||||
export async function restoreFromTrash(items: TrashedItem[], drive?: TrashDrive): Promise<TrashWriteResult> {
|
||||
const result: TrashWriteResult = { done: [], refused: [] };
|
||||
if (items.length === 0) return result;
|
||||
assertDrive(drive);
|
||||
const nodes = await getAllFileNodes();
|
||||
assertDrive(drive);
|
||||
const trash = findTrashFolder(nodes);
|
||||
const places = trashPlaces(nodes);
|
||||
const { hidden } = places;
|
||||
// The items as the trash holds them now: their name and trash folder may
|
||||
// have changed on another device.
|
||||
const current = new Map(trashedItems(nodes, trash).map((item) => [item.node.id, item]));
|
||||
const pending = [...new Set(items.map((item) => item.node.id))].flatMap((id) => {
|
||||
const item = current.get(id);
|
||||
return item ? [item] : [];
|
||||
});
|
||||
if (pending.length === 0) return result;
|
||||
// Names taken in each folder, so a restored item never clashes. Folders of
|
||||
// the trash count too: one restored below may be the target.
|
||||
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)
|
||||
.map((n) => n.name));
|
||||
// Something named ".Trash" doesn't go back to the root under that name.
|
||||
if (parentId === null) names.add(TRASH_FOLDER_NAME);
|
||||
taken.set(parentId, names);
|
||||
}
|
||||
return names;
|
||||
};
|
||||
|
||||
// An item may come from a folder that is among the items too, or inside
|
||||
// one of them (deleted from it before the folder itself went). Once that
|
||||
// item is back, so is the folder: the item waits for it instead of going
|
||||
// to the root.
|
||||
const restoring = new Set(pending.map((item) => item.node.id));
|
||||
const holder = (folderId: string | null): string | null => {
|
||||
const seen = new Set<string>();
|
||||
for (let cur = folderId ? places.byId.get(folderId) : undefined; cur && hidden.has(cur.id) && !seen.has(cur.id);
|
||||
cur = cur.parentId ? places.byId.get(cur.parentId) : undefined) {
|
||||
seen.add(cur.id);
|
||||
if (restoring.has(cur.id)) return cur.id;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const rules = getFileNameRules();
|
||||
const restored = new Set<string>();
|
||||
const tried = new Set<string>();
|
||||
const updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>> = {};
|
||||
const targets = new Map<string, string | null>();
|
||||
for (let rest = pending; rest.length > 0;) {
|
||||
const waits = (item: TrashedItem) => {
|
||||
const h = holder(item.fromParentId);
|
||||
return h !== null && h !== item.node.id && !tried.has(h);
|
||||
};
|
||||
let round = rest.filter((item) => !waits(item));
|
||||
// Only a loop of folders (impossible in a tree) leaves none ready.
|
||||
if (round.length === 0) round = rest;
|
||||
rest = rest.filter((item) => !round.includes(item));
|
||||
|
||||
const roundUpdates: typeof updates = {};
|
||||
for (const item of round) {
|
||||
const from = item.fromParentId;
|
||||
const h = holder(from);
|
||||
const origin = from ? places.byId.get(from) : undefined;
|
||||
const target = h !== null && restored.has(h) && origin && isFolder(origin) && !origin.isShared
|
||||
? from
|
||||
: restoreTarget(item, places);
|
||||
const used = namesIn(target);
|
||||
let name = getUniqueName(item.node.name, used);
|
||||
// A new name goes to the server as it is, so it must pass its rules: a
|
||||
// WebDAV name lists decoded ("a%3Ab.txt" as "a:b.txt"), and the ":"
|
||||
// in "a:b (1).txt" would be refused.
|
||||
if (name !== item.node.name) name = getUniqueName(acceptedFileName(name, rules), 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.
|
||||
roundUpdates[item.node.id] = name === item.node.name ? { parentId: target } : { parentId: target, name };
|
||||
targets.set(item.node.id, target);
|
||||
}
|
||||
Object.assign(updates, roundUpdates);
|
||||
assertDrive(drive);
|
||||
const update = await updateFileNodes(roundUpdates);
|
||||
for (const id of Object.keys(roundUpdates)) tried.add(id);
|
||||
for (const id of update.updated) restored.add(id);
|
||||
result.refused.push(...Object.values(update.notUpdated));
|
||||
}
|
||||
if (restored.size === 0) {
|
||||
throw result.refused[0] ?? new JMAPSetError('setError', 'Failed to restore');
|
||||
}
|
||||
for (const item of pending.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 } });
|
||||
}
|
||||
if (!drive || isCurrentDrive(drive)) {
|
||||
await removeEmptyBatches(nodes, result.done.flatMap((i) => (i.batchId ? [i.batchId] : [])), restored);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo of a delete into the trash (see restoreFromTrash). Null, and nothing
|
||||
* sent, once another account or server is signed in than the one the delete
|
||||
* went to: the same ids name other files there.
|
||||
*/
|
||||
export async function undoMoveToTrash(moved: Pick<TrashMoveResult, 'done' | 'drive'>): Promise<TrashWriteResult | null> {
|
||||
try {
|
||||
return await restoreFromTrash(moved.done, moved.drive);
|
||||
} catch (err) {
|
||||
if (err instanceof TrashDriveChangedError) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete items of the trash for good, with what is inside a deleted folder.
|
||||
* The drive is read again first and only what is still in the trash goes: a
|
||||
* row on screen may have been restored or moved out on another device since,
|
||||
* and the destroy takes a folder's whole content wherever it sits. Returns
|
||||
* how many items went.
|
||||
*/
|
||||
export async function purgeFromTrash(items: TrashedItem[]): Promise<number> {
|
||||
if (items.length === 0) return 0;
|
||||
const nodes = await getAllFileNodes();
|
||||
const trash = findTrashFolder(nodes);
|
||||
const inTrash = trashSubtreeIds(nodes, trash?.id);
|
||||
const ids = new Set(items.map((item) => item.node.id).filter((id) => id !== trash?.id && inTrash.has(id)));
|
||||
if (ids.size === 0) return 0;
|
||||
// The trash folders the items sit in now, which need not be the ones on screen.
|
||||
const batchIds = trashedItems(nodes, trash).flatMap((i) => (i.batchId && ids.has(i.node.id) ? [i.batchId] : []));
|
||||
try {
|
||||
await deleteFileNodes([...ids]);
|
||||
} finally {
|
||||
// Also after a partial refusal: the server keeps a trash folder that still holds anything.
|
||||
await removeEmptyBatches(nodes, batchIds, ids);
|
||||
}
|
||||
return ids.size;
|
||||
}
|
||||
|
||||
/** Delete everything in the trash for good. The next delete makes a new trash folder. */
|
||||
export async function emptyTrash(): Promise<void> {
|
||||
// Read again rather than trust the listing on screen: another client may
|
||||
// have made the trash since, or put another folder in its place.
|
||||
const trash = findTrashFolder(await getAllFileNodes());
|
||||
if (trash) await deleteFileNodes([trash.id]);
|
||||
}
|
||||
@@ -478,6 +478,80 @@ export async function updateFileNode(
|
||||
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> {
|
||||
await updateFileNode(id, { name: newName });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { FileNode } from '../../api/types';
|
||||
import {
|
||||
findTrashFolder, parseTrashBatchName, restoreTarget, takesTrashName, trashBatchName, trashOriginPath,
|
||||
trashPlaces, 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('takesTrashName', () => {
|
||||
it('refuses .Trash at the root of the own drive only', () => {
|
||||
expect(takesTrashName('.Trash', null)).toBe(true);
|
||||
expect(takesTrashName('.Trash', null, 'docs')).toBe(true);
|
||||
expect(takesTrashName('.Trash', 'docs')).toBe(false);
|
||||
expect(takesTrashName('.trash', null)).toBe(false);
|
||||
expect(takesTrashName('.Trash (1)', null)).toBe(false);
|
||||
// A node of a shared drive stays there.
|
||||
expect(takesTrashName('.Trash', null, 'acc-2:x')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('reads the listing once for all the rows of the trash, not once per row', () => {
|
||||
// A listing that counts how often it is gone through.
|
||||
let reads = 0;
|
||||
const counted = new Proxy([...drive], {
|
||||
get(target, key, receiver) {
|
||||
if (key === Symbol.iterator || key === 'find' || key === 'filter' || key === 'map' || key === 'some') reads++;
|
||||
return Reflect.get(target, key, receiver);
|
||||
},
|
||||
});
|
||||
const places = trashPlaces(counted);
|
||||
const before = reads;
|
||||
const rows = trashedItems(drive);
|
||||
const paths = rows.map((item) => trashOriginPath(item, places));
|
||||
expect(paths).toEqual(['/Documents/Taxes', '/Documents/Taxes', '/', '/']);
|
||||
expect(reads).toBe(before);
|
||||
expect(rows.map((item) => restoreTarget(item, places))).toEqual(['tax', 'tax', null, null]);
|
||||
expect(reads).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether naming a node `name` in `parentId` (create, rename, move or upload)
|
||||
* would put it in the trash's place, `.Trash` at the root of the user's own
|
||||
* drive: a folder there becomes the trash, and a file there keeps the trash
|
||||
* from being made. `nodeId` is the node renamed or moved, if any; one of a
|
||||
* shared drive stays in that drive.
|
||||
*/
|
||||
export function takesTrashName(name: string, parentId: string | null, nodeId?: string): boolean {
|
||||
return name === TRASH_FOLDER_NAME && parentId === null && !(nodeId?.includes(':'));
|
||||
}
|
||||
|
||||
/** 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;
|
||||
// One pass over the drive, then down the tree: a pass per level was slow
|
||||
// for a deep trash in a large drive.
|
||||
const children = new Map<string, string[]>();
|
||||
for (const n of nodes) {
|
||||
if (!n.parentId) continue;
|
||||
const list = children.get(n.parentId);
|
||||
if (list) list.push(n.id);
|
||||
else children.set(n.parentId, [n.id]);
|
||||
}
|
||||
const queue = [trashId];
|
||||
ids.add(trashId);
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
for (const child of children.get(queue[i]) ?? []) {
|
||||
if (!ids.has(child)) {
|
||||
ids.add(child);
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* What restoreTarget and trashOriginPath look up in a listing, worked out
|
||||
* once for it: every row of the trash asks, and each time anew it cost a
|
||||
* walk of the whole drive per row.
|
||||
*/
|
||||
export interface TrashPlaces {
|
||||
byId: Map<string, FileNode>;
|
||||
/** The trash and everything in it. */
|
||||
hidden: Set<string>;
|
||||
}
|
||||
|
||||
export function trashPlaces(nodes: FileNode[]): TrashPlaces {
|
||||
return { byId: new Map(nodes.map((n) => [n.id, n])), hidden: trashSubtreeIds(nodes, findTrashFolder(nodes)?.id) };
|
||||
}
|
||||
|
||||
function placesOf(from: FileNode[] | TrashPlaces): TrashPlaces {
|
||||
return Array.isArray(from) ? trashPlaces(from) : from;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'>, from: FileNode[] | TrashPlaces): string | null {
|
||||
const parentId = item.fromParentId;
|
||||
if (parentId === null) return null;
|
||||
const { byId, hidden } = placesOf(from);
|
||||
const folder = byId.get(parentId);
|
||||
return folder && isFolder(folder) && !folder.isShared && !hidden.has(folder.id) ? parentId : 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'>, from: FileNode[] | TrashPlaces): string | null {
|
||||
if (item.fromParentId === null) return '/';
|
||||
const places = placesOf(from);
|
||||
if (restoreTarget(item, places) === null) return null;
|
||||
const { byId } = places;
|
||||
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('/')}`;
|
||||
}
|
||||
+389
-60
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
AppState,
|
||||
FlatList,
|
||||
Image,
|
||||
Modal,
|
||||
@@ -20,8 +21,9 @@ import {
|
||||
ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo,
|
||||
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
|
||||
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';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import JSZip from 'jszip';
|
||||
@@ -49,6 +51,14 @@ import {
|
||||
getFileNameRules, getFileNodeDownloadUrl, getMaxSizeUpload, isFolder,
|
||||
isSymlink, listFolder, moveFileNode, renameFileNode, supportsSharing, uploadFileBatch,
|
||||
} from '../api/files';
|
||||
import {
|
||||
TrashUnavailableError, emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash, undoMoveToTrash,
|
||||
type TrashMoveResult,
|
||||
} from '../api/files-trash';
|
||||
import {
|
||||
TRASH_FOLDER_NAME, takesTrashName, trashOriginPath, trashPlaces, trashedItems, withoutTrash, type TrashedItem,
|
||||
} from '../lib/files-trash';
|
||||
import { toast } from '../stores/toast-store';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { downloadAttachment, shareAttachment } from '../lib/email-export';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
@@ -79,6 +89,8 @@ import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
// A file row carries the display name alongside the rest of the node.
|
||||
interface FileRow extends FileNode {
|
||||
displayName: string;
|
||||
/** Set for a row of the trash: when it was deleted and where from. */
|
||||
trash?: TrashedItem;
|
||||
}
|
||||
|
||||
type Translate = TranslateFn;
|
||||
@@ -146,6 +158,13 @@ function modifiedOf(node: FileNode): string | undefined {
|
||||
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 {
|
||||
switch (problem.kind) {
|
||||
case 'chars':
|
||||
@@ -161,6 +180,11 @@ function fileNameProblemText(problem: FileNameProblem, t: Translate): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Why ".Trash" can't go to the root (see takesTrashName).
|
||||
function trashNameText(t: Translate): string {
|
||||
return t('files.name_trash', '".Trash" is the name of the trash folder at the top of your files. Choose another name.');
|
||||
}
|
||||
|
||||
// The files an upload could not store, the first three by name, and what the
|
||||
// server said about the first one.
|
||||
function uploadFailureDetail(failed: Array<{ name: string; error: unknown }>, t: Translate): string {
|
||||
@@ -214,7 +238,11 @@ export default function FilesScreen() {
|
||||
const uploadAbortRef = useRef<AbortController | null>(null);
|
||||
const [batchBusy, setBatchBusy] = useState<string | null>(null);
|
||||
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[] } | null>(null);
|
||||
// The trash (lib/files-trash) is a place of its own, entered from the root.
|
||||
const [inTrash, setInTrash] = useState(false);
|
||||
// `noTrash`: the rows were meant for the trash, which could not be made.
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[]; noTrash?: boolean } | null>(null);
|
||||
const [confirmEmptyTrash, setConfirmEmptyTrash] = useState(false);
|
||||
const [newFolderOpen, setNewFolderOpen] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
|
||||
@@ -249,32 +277,51 @@ export default function FilesScreen() {
|
||||
const uploading = uploadProgress != null;
|
||||
const selectionMode = selection.size > 0;
|
||||
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]);
|
||||
// Looked up by every row of the trash; worked out once per listing.
|
||||
const trashPlacesNow = useMemo(() => trashPlaces(allNodes), [allNodes]);
|
||||
// Inside a folder another account shared with us, new folders and uploads
|
||||
// go to that account, and only where its rights allow them.
|
||||
const canCreateHere = mayCreateIn(currentParentId, allNodes);
|
||||
// go to that account, and only where its rights allow them. Nothing is
|
||||
// added to the trash other than by deleting.
|
||||
const canCreateHere = !inTrash && mayCreateIn(currentParentId, driveNodes);
|
||||
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();
|
||||
|
||||
// Only the latest read is shown: one started before an account switch or a
|
||||
// write may finish after the next one.
|
||||
const loadSeqRef = useRef(0);
|
||||
const loadFiles = useCallback(
|
||||
async (mode: 'initial' | 'refresh' = 'initial') => {
|
||||
async (mode: 'initial' | 'refresh' | 'quiet' = 'initial') => {
|
||||
const seq = ++loadSeqRef.current;
|
||||
if (mode === 'refresh') setRefreshing(true);
|
||||
else setLoading(true);
|
||||
setError(null);
|
||||
else if (mode === 'initial') setLoading(true);
|
||||
if (mode !== 'quiet') setError(null);
|
||||
try {
|
||||
// One fetch across all accessible accounts: own files plus nodes other
|
||||
// principals shared with us (tagged isShared, ids namespaced).
|
||||
const nodes = await getAllFileNodesAcrossAccounts();
|
||||
setAllNodes(nodes);
|
||||
if (seq === loadSeqRef.current) setAllNodes(nodes);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('files.download_error', 'Failed to load files'));
|
||||
// A quiet read keeps what is on screen.
|
||||
if (mode !== 'quiet' && seq === loadSeqRef.current) {
|
||||
setError(e instanceof Error ? e.message : t('files.download_error', 'Failed to load files'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
if (seq === loadSeqRef.current) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
const loadFilesRef = useRef(loadFiles);
|
||||
loadFilesRef.current = loadFiles;
|
||||
|
||||
// Load on mount and reset the whole drive whenever the active account, or
|
||||
// the server behind it, changes: the node cache, folder stack and
|
||||
@@ -283,14 +330,19 @@ export default function FilesScreen() {
|
||||
// (another port, or Stalwart reinstalled there), which the account id does
|
||||
// not tell. Every new session reads the drive again.
|
||||
const driveRef = useRef<DriveScope | undefined>(undefined);
|
||||
const undoToastRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const { action, scope } = driveChange(driveRef.current, activeAccountId, session);
|
||||
driveRef.current = scope;
|
||||
if (action === 'keep') return;
|
||||
if (action === 'reset') {
|
||||
uploadAbortRef.current?.abort();
|
||||
// The last delete's Undo belongs to the previous drive.
|
||||
if (undoToastRef.current) toast.dismiss(undoToastRef.current);
|
||||
undoToastRef.current = null;
|
||||
setAllNodes([]);
|
||||
setPath([]);
|
||||
setInTrash(false);
|
||||
setSelection(new Set());
|
||||
setActionsTarget(null);
|
||||
setShareTarget(null);
|
||||
@@ -303,12 +355,39 @@ export default function FilesScreen() {
|
||||
void loadFiles(action === 'reload' ? 'refresh' : 'initial');
|
||||
}, [activeAccountId, session, loadFiles]);
|
||||
|
||||
// If we navigated into a folder that no longer exists (deleted, or its
|
||||
// share was revoked), cut the stack back to the deepest ancestor that does.
|
||||
// Read the drive again when the tab comes back into view or the app to the
|
||||
// foreground: another device may have restored, moved or emptied what the
|
||||
// rows on screen show. The first focus is the mount, which reads already.
|
||||
const focusedRef = useRef(false);
|
||||
const seenFocusRef = useRef(false);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
focusedRef.current = true;
|
||||
if (seenFocusRef.current && driveRef.current?.account) void loadFilesRef.current('quiet');
|
||||
seenFocusRef.current = true;
|
||||
return () => {
|
||||
focusedRef.current = false;
|
||||
};
|
||||
}, []),
|
||||
);
|
||||
useEffect(() => {
|
||||
let last = AppState.currentState;
|
||||
const subscription = AppState.addEventListener('change', (next) => {
|
||||
if (next === 'active' && last !== 'active' && focusedRef.current && driveRef.current?.account) {
|
||||
void loadFilesRef.current('quiet');
|
||||
}
|
||||
last = next;
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
// If we navigated into a folder that no longer exists (deleted, moved to
|
||||
// the trash on another device, or its share was revoked), cut the stack
|
||||
// back to the deepest ancestor that does.
|
||||
useEffect(() => {
|
||||
if (allNodes.length === 0) return;
|
||||
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;
|
||||
for (let i = 0; i < prev.length; i++) {
|
||||
const node = byId.get(prev[i].id);
|
||||
@@ -319,16 +398,18 @@ export default function FilesScreen() {
|
||||
}
|
||||
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
|
||||
// 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 q = searchQuery.trim().toLowerCase();
|
||||
const rows = folderNodes
|
||||
.map<FileRow>((n) => ({ ...n, displayName: n.name }))
|
||||
const source: FileRow[] = inTrash
|
||||
? 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) => !q || r.displayName.toLowerCase().includes(q));
|
||||
const dir = sortDir === 'desc' ? -1 : 1;
|
||||
@@ -344,8 +425,8 @@ export default function FilesScreen() {
|
||||
case 'size':
|
||||
return ((a.size ?? 0) - (b.size ?? 0)) * dir;
|
||||
case 'modified': {
|
||||
const am = modifiedOf(a) ? new Date(modifiedOf(a)!).getTime() : 0;
|
||||
const bm = modifiedOf(b) ? new Date(modifiedOf(b)!).getTime() : 0;
|
||||
const am = rowDateOf(a) ? new Date(rowDateOf(a)!).getTime() : 0;
|
||||
const bm = rowDateOf(b) ? new Date(rowDateOf(b)!).getTime() : 0;
|
||||
return (am - bm) * dir;
|
||||
}
|
||||
case 'name':
|
||||
@@ -353,7 +434,7 @@ export default function FilesScreen() {
|
||||
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()), []);
|
||||
|
||||
@@ -468,6 +549,13 @@ export default function FilesScreen() {
|
||||
toggleSelect(row);
|
||||
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)) {
|
||||
setPath((p) => [...p, { id: row.id, name: row.displayName }]);
|
||||
setSearchQuery('');
|
||||
@@ -486,17 +574,25 @@ export default function FilesScreen() {
|
||||
}, [toggleSelect]);
|
||||
|
||||
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('');
|
||||
clearSelection();
|
||||
}, [clearSelection]);
|
||||
|
||||
// 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({
|
||||
previewOpen: previewTarget != null,
|
||||
selecting: selectionMode,
|
||||
folderDepth: path.length,
|
||||
folderDepth: inTrash ? 1 : path.length,
|
||||
});
|
||||
const handleBack = useCallback(() => {
|
||||
if (backStep === 'close-preview') setPreviewTarget(null);
|
||||
@@ -517,12 +613,16 @@ export default function FilesScreen() {
|
||||
|
||||
// Explains a name the server would refuse before anything is sent: the
|
||||
// naming rules it publishes (Stalwart 0.16.6+) with 1.0's stricter ones on
|
||||
// top, or at least the "/" every server refuses. True when the name can go.
|
||||
const checkName = useCallback((name: string): boolean => {
|
||||
// top, or at least the "/" every server refuses. `.Trash` is refused at the
|
||||
// root, where it would become the trash (lib/files-trash). True when the
|
||||
// name can go.
|
||||
const checkName = useCallback((name: string, parentId: string | null, nodeId?: string): boolean => {
|
||||
const problem = fileNameProblem(name, getFileNameRules());
|
||||
const message = problem
|
||||
? fileNameProblemText(problem, t)
|
||||
: name.includes('/') ? t('files.invalid_name_slash', 'Names cannot contain "/".') : null;
|
||||
: name.includes('/') ? t('files.invalid_name_slash', 'Names cannot contain "/".')
|
||||
: takesTrashName(name, parentId, nodeId) ? trashNameText(t)
|
||||
: null;
|
||||
if (message) Alert.alert(t('files.invalid_name', 'Invalid name'), message);
|
||||
return !message;
|
||||
}, [t]);
|
||||
@@ -530,7 +630,7 @@ export default function FilesScreen() {
|
||||
const submitNewFolder = useCallback(async () => {
|
||||
const name = newFolderName.trim();
|
||||
if (!name) return;
|
||||
if (!checkName(name)) return;
|
||||
if (!checkName(name, currentParentId)) return;
|
||||
setNewFolderOpen(false);
|
||||
setNewFolderName('');
|
||||
try {
|
||||
@@ -548,7 +648,7 @@ export default function FilesScreen() {
|
||||
setRenameTarget(null);
|
||||
return;
|
||||
}
|
||||
if (!checkName(name)) return;
|
||||
if (!checkName(name, renameTarget.parentId ?? null, renameTarget.id)) return;
|
||||
const target = renameTarget;
|
||||
setRenameTarget(null);
|
||||
setRenameValue('');
|
||||
@@ -579,6 +679,10 @@ export default function FilesScreen() {
|
||||
const performMove = useCallback(async (row: FileRow, destinationId: string | null) => {
|
||||
setMoveTarget(null);
|
||||
if ((row.parentId ?? null) === destinationId) return;
|
||||
if (takesTrashName(row.name, destinationId, row.id)) {
|
||||
Alert.alert(t('files.move_error', 'Failed to move'), trashNameText(t));
|
||||
return;
|
||||
}
|
||||
setBusyId(row.id);
|
||||
try {
|
||||
await moveFileNode(row.id, destinationId);
|
||||
@@ -643,6 +747,8 @@ export default function FilesScreen() {
|
||||
uploadAbortRef.current = controller;
|
||||
const parentId = currentParentId;
|
||||
const siblings = allNodes.filter((n) => (n.parentId ?? null) === parentId).map((n) => n.name);
|
||||
// A file picked as ".Trash" gets a number at the root, where the name is the trash's.
|
||||
if (takesTrashName(TRASH_FOLDER_NAME, parentId)) siblings.push(TRASH_FOLDER_NAME);
|
||||
// A refused file no longer stops the rest; the ones that failed are
|
||||
// reported once the batch is done.
|
||||
const { failed } = await uploadFileBatch(accepted, parentId, siblings, {
|
||||
@@ -665,10 +771,52 @@ export default function FilesScreen() {
|
||||
}
|
||||
}, [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 is by then. Nothing happens
|
||||
// once another account is signed in (see undoMoveToTrash).
|
||||
const undoTrash = useCallback(async (moved: TrashMoveResult) => {
|
||||
undoToastRef.current = null;
|
||||
try {
|
||||
const result = await undoMoveToTrash(moved);
|
||||
if (!result) return;
|
||||
if (result.done.length > 0) 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 moved = await moveToTrash(listing, rows);
|
||||
const { done, refused } = moved;
|
||||
await loadFiles('refresh');
|
||||
if (refused.length > 0) {
|
||||
Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(refused[0], t));
|
||||
}
|
||||
undoToastRef.current = 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(moved) } },
|
||||
);
|
||||
} catch (e) {
|
||||
// No trash to be had here (a file named .Trash at the root, no right
|
||||
// to create folders there, a full quota): offer to delete for good.
|
||||
if (e instanceof TrashUnavailableError) setConfirmDelete({ rows, noTrash: true });
|
||||
else 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[]) => {
|
||||
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 () => {
|
||||
if (!confirmDelete) return;
|
||||
@@ -676,19 +824,58 @@ export default function FilesScreen() {
|
||||
setConfirmDelete(null);
|
||||
clearSelection();
|
||||
try {
|
||||
// The server removes folder descendants (onDestroyRemoveChildren).
|
||||
await deleteFileNodes(targets.map((t) => t.id));
|
||||
await loadFiles('refresh');
|
||||
const fromTrash = targets.flatMap((r) => (r.trash ? [r.trash] : []));
|
||||
// The server removes folder descendants (onDestroyRemoveChildren). In
|
||||
// the trash only what is still there goes (see purgeFromTrash).
|
||||
let count = targets.length;
|
||||
if (fromTrash.length > 0) count = await purgeFromTrash(fromTrash);
|
||||
else await deleteFileNodes(targets.map((r) => r.id));
|
||||
if (count > 0) {
|
||||
toast.success(t('files.deleted_forever', '{count, plural, one {Deleted permanently} other {# items deleted permanently}}', {
|
||||
count,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t));
|
||||
}
|
||||
await loadFiles('refresh');
|
||||
}, [confirmDelete, 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(items);
|
||||
if (refused.length > 0) {
|
||||
Alert.alert(t('files.restore_error', "Couldn't restore"), fileErrorMessage(refused[0], t));
|
||||
}
|
||||
if (done.length > 0) {
|
||||
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');
|
||||
}, [clearSelection, loadFiles, t]);
|
||||
|
||||
const performEmptyTrash = useCallback(async () => {
|
||||
setConfirmEmptyTrash(false);
|
||||
clearSelection();
|
||||
try {
|
||||
await emptyTrash();
|
||||
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');
|
||||
}, [clearSelection, loadFiles, t]);
|
||||
|
||||
const sortLabel = t(`files.${sortKey}`, sortKey);
|
||||
const SortDirIcon = sortDir === 'desc' ? ArrowDown : ArrowUp;
|
||||
|
||||
const renderHeader = () => {
|
||||
const canBack = path.length > 0;
|
||||
const canBack = path.length > 0 || inTrash;
|
||||
const ToggleIcon = viewMode === 'list' ? LayoutGrid : ListIcon;
|
||||
if (selectionMode) {
|
||||
const selectedRows = visibleFiles.filter((r) => selection.has(r.id));
|
||||
@@ -708,24 +895,35 @@ export default function FilesScreen() {
|
||||
{t('files.selected_count', '{count, plural, one {# selected} other {# selected}}', { count: selection.size })}
|
||||
</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={() => void downloadSelection(selectedRows)}
|
||||
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>
|
||||
{inTrash ? (
|
||||
<Pressable
|
||||
onPress={() => void restoreRows(selectedRows)}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
accessibilityLabel={t('files.restore', 'Restore')}
|
||||
>
|
||||
<RotateCcw size={20} color={c.text} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => void downloadSelection(selectedRows)}
|
||||
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
|
||||
onPress={() => requestDelete(selectedRows)}
|
||||
hitSlop={8}
|
||||
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} />
|
||||
</Pressable>
|
||||
@@ -798,11 +996,22 @@ export default function FilesScreen() {
|
||||
>
|
||||
<ToggleIcon size={20} color={c.text} />
|
||||
</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>
|
||||
{canBack ? (
|
||||
<Text style={styles.breadcrumb} numberOfLines={1}>
|
||||
{[rootLabel, ...path.map((p) => p.name)].join(' / ')}
|
||||
{[rootLabel, ...(inTrash ? [trashLabel] : path.map((p) => p.name))].join(' / ')}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.toolbarRow}>
|
||||
@@ -819,6 +1028,19 @@ export default function FilesScreen() {
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
) : <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
|
||||
onPress={() => setSortSheetOpen(true)}
|
||||
hitSlop={6}
|
||||
@@ -854,6 +1076,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, trashPlacesNow);
|
||||
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 isDir = isFolder(item);
|
||||
const thumb = !isDir && showThumbnails ? thumbnailSource(item) : null;
|
||||
@@ -881,7 +1112,8 @@ export default function FilesScreen() {
|
||||
const selected = selection.has(item.id);
|
||||
const busy = busyId === item.id;
|
||||
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 (
|
||||
<Pressable
|
||||
onPress={() => handleRowPress(item)}
|
||||
@@ -906,6 +1138,9 @@ export default function FilesScreen() {
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.fileMetaRow}>
|
||||
{origin ? (
|
||||
<Text style={[styles.fileMeta, styles.fileMetaShrink]} numberOfLines={1}>{origin}</Text>
|
||||
) : null}
|
||||
{item.isShared && item.accountName ? (
|
||||
<Text style={styles.fileMeta} numberOfLines={1}>
|
||||
{t('files.shared_by', 'Shared by {name}', { name: item.accountName })}
|
||||
@@ -1004,6 +1239,16 @@ export default function FilesScreen() {
|
||||
</TouchableOpacity>
|
||||
</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) {
|
||||
body = (
|
||||
<View style={styles.center}>
|
||||
@@ -1133,11 +1378,15 @@ export default function FilesScreen() {
|
||||
setActionsTarget(null);
|
||||
requestDelete([r]);
|
||||
}}
|
||||
onRestore={(r) => {
|
||||
setActionsTarget(null);
|
||||
void restoreRows([r]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FolderPickerSheet
|
||||
target={moveTarget}
|
||||
allNodes={allNodes}
|
||||
allNodes={driveNodes}
|
||||
rootLabel={rootLabel}
|
||||
t={t}
|
||||
onClose={() => setMoveTarget(null)}
|
||||
@@ -1164,18 +1413,41 @@ export default function FilesScreen() {
|
||||
|
||||
<Dialog
|
||||
visible={confirmDelete != null}
|
||||
title={
|
||||
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_item_title', 'Delete this item?')
|
||||
title={t('files.delete_forever_title', '{count, plural, one {Delete permanently?} other {Delete # items permanently?}}', {
|
||||
count: confirmDelete?.rows.length ?? 1,
|
||||
})}
|
||||
message={
|
||||
confirmDelete?.noTrash
|
||||
? t('files.trash_unavailable_message', "{count, plural, one {This item} other {These # items}} can't be moved to the trash. Delete permanently instead? You can't undo this.", {
|
||||
count: confirmDelete.rows.length,
|
||||
})
|
||||
: confirmDelete && confirmDelete.rows.length > 1
|
||||
? t('files.delete_forever_message_count', "{count, plural, one {1 item} other {# items}} will be deleted. You can't undo this.", {
|
||||
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"
|
||||
confirmText={t('files.delete', 'Delete')}
|
||||
confirmText={t('files.delete_forever', 'Delete permanently')}
|
||||
cancelText={t('files.cancel', 'Cancel')}
|
||||
onConfirm={() => void performDelete()}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1252,10 +1524,13 @@ interface ActionsSheetProps {
|
||||
onDuplicate: (r: FileRow) => void;
|
||||
onRename: (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({
|
||||
target, access, sharingEnabled, t, onClose, onPreview, onOpenWith, onDownload, onShare, onMove, onDuplicate, onRename, onDelete,
|
||||
onRestore,
|
||||
}: ActionsSheetProps) {
|
||||
// Centred on a wide window, clear of side insets in landscape.
|
||||
const sheetInsets = useSheetInsets();
|
||||
@@ -1272,7 +1547,52 @@ function ActionsSheet({
|
||||
// nodes are shared on by their owner, and get the writes their rights
|
||||
// allow (`access`).
|
||||
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 (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
@@ -1612,6 +1932,12 @@ function makeStyles(c: ThemePalette) {
|
||||
fontSize: typography.body.fontSize,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
emptyHint: {
|
||||
color: c.textMuted,
|
||||
fontSize: typography.caption.fontSize,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
errorText: {
|
||||
color: c.error,
|
||||
fontSize: typography.body.fontSize,
|
||||
@@ -1660,6 +1986,9 @@ function makeStyles(c: ThemePalette) {
|
||||
fontSize: typography.caption.fontSize,
|
||||
color: c.textMuted,
|
||||
},
|
||||
fileMetaShrink: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
rowMore: {
|
||||
width: componentSizes.buttonLg,
|
||||
height: componentSizes.buttonLg,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { FileNode } from '../../api/types';
|
||||
import { isFolder } from '../../api/files';
|
||||
import * as trash from '../../lib/files-trash';
|
||||
import { recentFileItems } from '../build';
|
||||
|
||||
const dir = (id: string, parentId: string | null, name: string, modified: string): FileNode =>
|
||||
({ id, parentId, name, type: '', blobId: null, modified });
|
||||
const file = (id: string, parentId: string | null, name: string, modified: string): FileNode =>
|
||||
({ id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 1, modified });
|
||||
|
||||
describe('recentFileItems', () => {
|
||||
it('leaves the Files trash and everything in it out', () => {
|
||||
const nodes = [
|
||||
dir('docs', null, 'Documents', '2026-10-01T10:00:00Z'),
|
||||
file('keep', 'docs', 'keep.txt', '2026-10-02T10:00:00Z'),
|
||||
// The trash and a delete's folder are newer than anything else.
|
||||
dir('t', null, '.Trash', '2026-10-28T10:00:00Z'),
|
||||
dir('b1', 't', '1761700000000~docs', '2026-10-28T10:00:00Z'),
|
||||
file('gone', 'b1', 'deleted.txt', '2026-10-27T10:00:00Z'),
|
||||
];
|
||||
expect(recentFileItems(nodes, { isFolder }, trash).map((i) => i.id)).toEqual(['keep', 'docs']);
|
||||
});
|
||||
});
|
||||
+28
-15
@@ -9,7 +9,7 @@
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { CAPABILITIES, type CalendarEvent, type ContactCard } from '../api/types';
|
||||
import { CAPABILITIES, type CalendarEvent, type ContactCard, type FileNode } from '../api/types';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { generateEmailAvatarColor, getEmailInitials } from '../lib/avatar-utils';
|
||||
import { getEventEndDate, getEventStartDate } from '../lib/calendar-utils';
|
||||
@@ -306,24 +306,37 @@ async function buildTasks(previous: WidgetSnapshot, accountId: string, reload: b
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Recent files widget's rows: the newest nodes of the user's own drive.
|
||||
* The Files trash (lib/files-trash) and what is in it stay out, as they do
|
||||
* in the Files tab. The helpers come in as arguments because they are loaded
|
||||
* only once the widget needs them.
|
||||
*/
|
||||
export function recentFileItems(
|
||||
nodes: FileNode[],
|
||||
files: Pick<typeof import('../api/files'), 'isFolder'>,
|
||||
trash: Pick<typeof import('../lib/files-trash'), 'withoutTrash'>,
|
||||
): FileItem[] {
|
||||
return trash.withoutTrash(nodes)
|
||||
.filter((n) => !!n.name)
|
||||
.map((n) => ({
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
isFolder: files.isFolder(n),
|
||||
size: n.size ?? 0,
|
||||
modified: Date.parse(n.modified || n.created || '') || 0,
|
||||
type: n.type || '',
|
||||
}))
|
||||
.sort((a, b) => b.modified - a.modified)
|
||||
.slice(0, 6);
|
||||
}
|
||||
|
||||
async function buildFiles(previous: WidgetSnapshot): Promise<WidgetSnapshot['files']> {
|
||||
const files = require('../api/files') as typeof import('../api/files');
|
||||
if (!files.supportsFiles()) return { supported: false, items: [] };
|
||||
try {
|
||||
const nodes = await files.getAllFileNodes();
|
||||
const items: FileItem[] = nodes
|
||||
.filter((n) => !!n.name)
|
||||
.map((n) => ({
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
isFolder: files.isFolder(n),
|
||||
size: n.size ?? 0,
|
||||
modified: Date.parse(n.modified || n.created || '') || 0,
|
||||
type: n.type || '',
|
||||
}))
|
||||
.sort((a, b) => b.modified - a.modified)
|
||||
.slice(0, 6);
|
||||
return { supported: true, items };
|
||||
const trash = require('../lib/files-trash') as typeof import('../lib/files-trash');
|
||||
return { supported: true, items: recentFileItems(await files.getAllFileNodes(), files, trash) };
|
||||
} catch {
|
||||
return previous.files;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user