feat: keep deleted files in a Files trash with Restore, Delete permanently and Empty trash

This commit is contained in:
Linus Rath
2026-10-29 02:30:36 +01:00
parent 7c495d03ab
commit 7c91418336
35 changed files with 1729 additions and 74 deletions
+20 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+20 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "您的证书",
+240
View File
@@ -0,0 +1,240 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
hasCapability: vi.fn(() => true),
hasAccountCapability: vi.fn(() => false),
getMaxObjectsInGet: vi.fn(() => 500),
getMaxObjectsInSet: vi.fn(() => 500),
getMaxCallsInRequest: vi.fn(() => 16),
getAccountCapability: vi.fn(),
currentSession: {
primaryAccounts: { 'urn:ietf:params:jmap:filenode': 'acc-1' },
accounts: { 'acc-1': { name: 'me', isPersonal: true }, 'acc-2': { name: 'them', isPersonal: false } },
},
},
}));
import { jmapClient } from '../jmap-client';
import type { FileNode } from '../types';
import { emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash } from '../files-trash';
import { trashedItems } from '../../lib/files-trash';
// A Stalwart-like FileNode store for the user's own account: destroy without
// onDestroyRemoveChildren refuses a folder that holds anything.
type Node = { id: string; parentId: string | null; name: string; blobId: string | null; type?: string };
let store: Node[];
let nextId: number;
let calls: Array<Record<string, unknown>>;
let refuseUpdate: Set<string>;
function serve(method: string, args: Record<string, any>) {
if (method === 'FileNode/get') return { list: store.map((n) => ({ ...n })) };
calls.push(args);
const out: Record<string, any> = {};
for (const [key, props] of Object.entries((args.create ?? {}) as Record<string, any>)) {
const id = `n${nextId++}`;
store.push({ id, parentId: props.parentId ?? null, name: props.name, blobId: props.blobId ?? null });
(out.created ??= {})[key] = { id };
}
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, any>)) {
const node = store.find((n) => n.id === id);
if (!node || refuseUpdate.has(id)) {
(out.notUpdated ??= {})[id] = { type: 'forbidden', description: 'no' };
continue;
}
Object.assign(node, patch);
(out.updated ??= {})[id] = null;
}
for (const id of (args.destroy ?? []) as string[]) {
if (!store.some((n) => n.id === id)) {
(out.notDestroyed ??= {})[id] = { type: 'notFound' };
continue;
}
if (!args.onDestroyRemoveChildren && store.some((n) => n.parentId === id)) {
(out.notDestroyed ??= {})[id] = { type: 'nodeHasChildren' };
continue;
}
const gone = new Set([id]);
for (let grew = true; grew;) {
grew = false;
for (const n of store) if (n.parentId && gone.has(n.parentId) && !gone.has(n.id)) { gone.add(n.id); grew = true; }
}
store = store.filter((n) => !gone.has(n.id));
(out.destroyed ??= []).push(id);
}
return out;
}
const listed = (): FileNode[] => store.map((n) => ({ ...n, type: n.type ?? '' }) as FileNode);
const byId = (id: string) => store.find((n) => n.id === id);
beforeEach(() => {
nextId = 1;
calls = [];
refuseUpdate = new Set();
store = [
{ id: 'docs', parentId: null, name: 'Docs', blobId: null },
{ id: 'a', parentId: 'docs', name: 'a.txt', blobId: 'ba' },
{ id: 'b', parentId: 'docs', name: 'b.txt', blobId: 'bb' },
{ id: 'top', parentId: null, name: 'top.txt', blobId: 'bt' },
];
(jmapClient.request as ReturnType<typeof vi.fn>).mockImplementation(async (methodCalls: any[]) => ({
methodResponses: methodCalls.map(([method, args, id]: [string, any, string]) => [method, serve(method, args), id]),
}));
});
describe('moveToTrash', () => {
it('makes .Trash on the first delete and one trash folder per folder the items came from', async () => {
const nodes = listed();
const { done } = await moveToTrash(nodes, nodes.filter((n) => ['a', 'top'].includes(n.id)), 1730112000000);
const trash = store.find((n) => n.name === '.Trash')!;
expect(trash.parentId).toBeNull();
expect(trash.blobId).toBeNull();
const batchNames = store.filter((n) => n.parentId === trash.id).map((n) => n.name).sort();
expect(batchNames).toEqual(['1730112000000~', '1730112000000~docs']);
expect(byId('a')!.parentId).toBe(store.find((n) => n.name === '1730112000000~docs')!.id);
expect(byId('top')!.parentId).toBe(store.find((n) => n.name === '1730112000000~')!.id);
expect(done.map((i) => [i.node.id, i.fromParentId])).toEqual([['a', 'docs'], ['top', null]]);
// What the webmail reads back.
expect(trashedItems(listed()).map((i) => [i.node.id, i.fromParentId, i.deletedAt])).toEqual([
['a', 'docs', 1730112000000],
['top', null, 1730112000000],
]);
});
it('reuses the trash folder that is there', async () => {
store.push({ id: 'T', parentId: null, name: '.Trash', blobId: null });
const nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000001);
expect(store.filter((n) => n.name === '.Trash')).toHaveLength(1);
expect(byId(byId('a')!.parentId!)!.parentId).toBe('T');
});
it('finds the trash again when the one on screen was emptied elsewhere', async () => {
store.push({ id: 'T', parentId: null, name: '.Trash', blobId: null });
const nodes = listed();
store = store.filter((n) => n.id !== 'T');
store.push({ id: 'T2', parentId: null, name: '.Trash', blobId: null });
// The fake creates under a missing parent, so make that fail like the server.
const real = (jmapClient.request as ReturnType<typeof vi.fn>).getMockImplementation() as (calls: any[]) => Promise<unknown>;
(jmapClient.request as ReturnType<typeof vi.fn>).mockImplementation(async (methodCalls: any[]) => {
const create = methodCalls[0][1].create as Record<string, any> | undefined;
const parent = create && Object.values(create)[0].parentId;
if (parent && !byId(parent)) {
return { methodResponses: [['FileNode/set', { notCreated: { 'new-dir': { type: 'invalidProperties' } } }, '0']] };
}
return real(methodCalls);
});
await moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000001);
expect(byId(byId('a')!.parentId!)!.parentId).toBe('T2');
});
it('refuses nodes of a shared drive', async () => {
const shared = { id: 'acc-2:x', parentId: null, name: 'x', type: '', blobId: 'bx', isShared: true } as FileNode;
await expect(moveToTrash([shared], [shared])).rejects.toThrow();
expect(calls).toEqual([]);
});
it('throws the refusal and leaves no empty trash folder when nothing went', async () => {
refuseUpdate.add('a');
const nodes = listed();
await expect(moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000005)).rejects.toThrow('no');
expect(store.some((n) => n.name === '1730112000005~docs')).toBe(false);
expect(byId('a')!.parentId).toBe('docs');
const destroy = calls.find((c) => c.destroy);
expect(destroy?.onDestroyRemoveChildren).toBeUndefined();
});
it('reports a partial refusal and keeps what went', async () => {
refuseUpdate.add('b');
const nodes = listed();
const { done, refused } = await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000005);
expect(done.map((i) => i.node.id)).toEqual(['a']);
expect(refused).toHaveLength(1);
});
});
describe('restoreFromTrash', () => {
it('puts items back where they came from and removes their empty trash folder', async () => {
let nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007);
nodes = listed();
const batch = store.find((n) => n.name === '1730112000007~docs')!;
await restoreFromTrash(nodes, trashedItems(nodes));
expect(byId('a')).toMatchObject({ parentId: 'docs', name: 'a.txt' });
expect(byId('b')).toMatchObject({ parentId: 'docs', name: 'b.txt' });
expect(byId(batch.id)).toBeUndefined();
// No rename was sent along with the move.
const update = calls.filter((c) => c.update).pop()!.update as Record<string, Record<string, unknown>>;
expect(update.a).toEqual({ parentId: 'docs' });
});
it('keeps a trash folder that other items still hold', async () => {
let nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007);
nodes = listed();
const batch = store.find((n) => n.name === '1730112000007~docs')!;
await restoreFromTrash(nodes, trashedItems(nodes).filter((i) => i.node.id === 'a'));
expect(byId(batch.id)).toBeDefined();
expect(byId('b')!.parentId).toBe(batch.id);
});
it('gives a name taken by now a free one, and sends the root for a folder that is gone', async () => {
let nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000007);
store.push({ id: 'a2', parentId: 'docs', name: 'a.txt', blobId: 'ba2' });
nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.id === 'docs'), 1730112000008);
nodes = listed();
// Docs is in the trash itself now: a.txt goes to the root.
await restoreFromTrash(nodes, trashedItems(nodes).filter((i) => i.node.id === 'a'));
expect(byId('a')).toMatchObject({ parentId: null, name: 'a.txt' });
nodes = listed();
await restoreFromTrash(nodes, trashedItems(nodes).filter((i) => i.node.id === 'docs'));
await moveToTrash(listed(), listed().filter((n) => n.id === 'a2'), 1730112000009);
store.push({ id: 'a3', parentId: 'docs', name: 'a.txt', blobId: 'ba3' });
nodes = listed();
await restoreFromTrash(nodes, trashedItems(nodes).filter((i) => i.node.id === 'a2'));
expect(byId('a2')).toMatchObject({ parentId: 'docs', name: 'a (1).txt' });
});
it('undoes a delete with the listing from before it, without renaming', async () => {
const before = listed();
const { done } = await moveToTrash(before, before.filter((n) => n.parentId === 'docs'), 1730112000007);
await restoreFromTrash(before, done);
expect(byId('a')).toMatchObject({ parentId: 'docs', name: 'a.txt' });
expect(byId('b')).toMatchObject({ parentId: 'docs', name: 'b.txt' });
expect(store.some((n) => n.name === '1730112000007~docs')).toBe(false);
});
});
describe('purgeFromTrash and emptyTrash', () => {
it('deletes items for good with what is inside, and their emptied trash folder', async () => {
let nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.id === 'docs'), 1730112000007);
nodes = listed();
await purgeFromTrash(nodes, trashedItems(nodes));
expect(store.map((n) => n.id).sort()).toEqual(['n1', 'top']);
expect(byId('n1')!.name).toBe('.Trash');
});
it('empties the trash, everything in it included', async () => {
let nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007);
nodes = listed();
await emptyTrash(nodes);
expect(store.map((n) => n.id).sort()).toEqual(['docs', 'top']);
});
it('empties a trash made after the listing on screen', async () => {
const stale = listed();
await moveToTrash(stale, stale.filter((n) => n.id === 'top'), 1730112000007);
await emptyTrash(stale);
expect(store.map((n) => n.id).sort()).toEqual(['a', 'b', 'docs']);
});
});
@@ -36,8 +36,10 @@ import {
copyFileNode,
createFolder,
deleteFileNodes,
destroyEmptyFolders,
fileNodeKind,
filesAccountId,
getAllFileNodes,
getAllFileNodesAcrossAccounts,
getFileNodeDownloadUrl,
listFolder,
@@ -45,6 +47,8 @@ import {
renameFileNode,
uploadFileBatch,
} from '../files';
import { emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash } from '../files-trash';
import { findTrashFolder, parseTrashBatchName, trashedItems, withoutTrash } from '../../lib/files-trash';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
@@ -208,3 +212,175 @@ 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('moves a file and a folder into one trash folder that names when and where from, and hides them', async () => {
const file = await upload('trash-me.txt', scratch);
const folder = await createFolder('Trash me', scratch);
const inner = await upload('inner.txt', folder.id);
let list = await own();
const before = Date.now();
const { done, refused } = await moveToTrash(list, [
list.find((n) => n.id === file.id)!,
list.find((n) => n.id === folder.id)!,
]);
expect(refused).toEqual([]);
expect(done.map((i) => i.node.id).sort()).toEqual([file.id, folder.id].sort());
list = await own();
const trash = findTrashFolder(list)!;
expect(trash).toBeDefined();
expect(trash.parentId ?? null).toBeNull();
expect(trash.name).toBe('.Trash');
const batch = list.find((n) => n.id === done[0].batchId)!;
batches.add(batch.id);
expect(batch.parentId).toBe(trash.id);
expect(fileNodeKind(batch)).toBe('directory');
// What the webmail reads back to restore: the time and the folder.
expect(batch.name).toMatch(/^(\d{10,})~([A-Za-z0-9_-]*)$/);
expect(parseTrashBatchName(batch.name)?.parentId).toBe(scratch);
expect(parseTrashBatchName(batch.name)!.deletedAt).toBeGreaterThanOrEqual(before - 1000);
expect(list.find((n) => n.id === file.id)?.parentId).toBe(batch.id);
// The folder went whole.
expect(list.find((n) => n.id === inner.id)?.parentId).toBe(folder.id);
const items = trashedItems(list).filter((i) => i.batchId === batch.id);
expect(items.map((i) => i.node.name).sort()).toEqual(['Trash me', 'trash-me.txt']);
expect(items.every((i) => i.fromParentId === scratch)).toBe(true);
// Out of every normal listing, the root included.
const shown = withoutTrash(await nodes());
expect(shown.some((n) => [trash.id, batch.id, file.id, folder.id, inner.id].includes(n.id))).toBe(false);
expect(listFolder(shown, null).some((n) => n.id === trash.id)).toBe(false);
expect(listFolder(shown, scratch).map((n) => n.id)).toEqual([]);
await writeProbe('trash-after-delete', list);
}, 90_000);
it('restores items to the folder they came from and removes their empty trash folder', async () => {
const all = trashedItems(await own());
const item = all.find((i) => i.node.name === 'trash-me.txt' && i.fromParentId === scratch)!;
const sibling = all.find((i) => i.node.name === 'Trash me' && i.batchId === item.batchId)!;
let list = await own();
const { done } = await restoreFromTrash(list, [item]);
expect(done).toHaveLength(1);
list = await own();
expect(list.find((n) => n.id === item.node.id)).toMatchObject({ name: 'trash-me.txt', parentId: scratch });
// The other item still holds the trash folder.
expect(list.some((n) => n.id === item.batchId)).toBe(true);
await restoreFromTrash(list, [sibling]);
list = await own();
expect(list.find((n) => n.id === sibling.node.id)?.parentId).toBe(scratch);
expect(list.some((n) => n.id === item.batchId)).toBe(false);
expect(await trashed(item.node.id)).toBeUndefined();
}, 90_000);
it('restores under a free name when the old one is taken, and to the root when the folder is gone', async () => {
const sub = await createFolder('Gone soon', scratch);
const a = await upload('clash.txt', scratch, 'old');
const b = await upload('orphan.txt', sub.id);
let list = await own();
const first = await moveToTrash(list, [list.find((n) => n.id === a.id)!]);
const second = await moveToTrash(list, [list.find((n) => n.id === b.id)!]);
for (const i of [...first.done, ...second.done]) batches.add(i.batchId!);
await upload('clash.txt', scratch, 'new');
await deleteFileNodes([sub.id]);
list = await own();
await restoreFromTrash(list, [(await trashed(a.id))!, (await trashed(b.id))!]);
list = await own();
expect(list.find((n) => n.id === a.id)).toMatchObject({ name: 'clash (1).txt', parentId: scratch });
const orphan = list.find((n) => n.id === b.id)!;
try {
expect(orphan.name).toBe('orphan.txt');
expect(orphan.parentId ?? null).toBeNull();
} finally {
await deleteFileNodes([b.id]);
}
}, 120_000);
it('undoes a delete with the listing from before it', async () => {
const file = await upload('undo-me.txt', scratch);
const list = await own();
const { done } = await moveToTrash(list, [list.find((n) => n.id === file.id)!]);
batches.add(done[0].batchId!);
// The rows on screen still show it where it was.
await restoreFromTrash(list, done);
const after = await own();
expect(after.find((n) => n.id === file.id)).toMatchObject({ name: 'undo-me.txt', parentId: scratch });
expect(after.some((n) => n.id === done[0].batchId)).toBe(false);
}, 90_000);
it('deletes items of the trash for good, and leaves a trash folder that still holds anything', async () => {
const x = await upload('purge-x.txt', scratch);
const y = await upload('purge-y.txt', scratch);
let list = await own();
const picked = [x, y].map((u) => list.find((n) => n.id === u.id)!);
const { done } = await moveToTrash(list, picked);
const batchId = done[0].batchId!;
batches.add(batchId);
// Never with what is inside: a folder that holds anything stays.
expect(await destroyEmptyFolders([batchId])).toEqual([]);
list = await own();
expect(list.some((n) => n.id === batchId)).toBe(true);
await purgeFromTrash(list, trashedItems(list).filter((i) => i.node.id === x.id));
list = await own();
expect(list.some((n) => n.id === x.id)).toBe(false);
expect(list.some((n) => n.id === batchId)).toBe(true);
await purgeFromTrash(list, trashedItems(list).filter((i) => i.node.id === y.id));
list = await own();
expect(list.some((n) => [x.id, y.id, batchId].includes(n.id))).toBe(false);
}, 90_000);
it('empties the trash', async () => {
// Only on an account whose trash these tests made: a real one stays.
if (trashBefore) return;
const file = await upload('empty-me.txt', scratch);
const list = await own();
await moveToTrash(list, [list.find((n) => n.id === file.id)!]);
await emptyTrash(await own());
const after = await own();
expect(findTrashFolder(after)).toBeUndefined();
expect(after.some((n) => n.id === file.id)).toBe(false);
}, 90_000);
});
+168
View File
@@ -0,0 +1,168 @@
import type { FileNode } from './types';
import {
createFolder, deleteFileNodes, destroyEmptyFolders, getAllFileNodes, isCrossAccountId, updateFileNodes,
} from './files';
import { JMAPSetError } from './jmap-result';
import { getUniqueName } from '../lib/filenode-name';
import {
TRASH_FOLDER_NAME, findTrashFolder, restoreTarget, trashBatchName, trashSubtreeIds, type TrashedItem,
} from '../lib/files-trash';
/*
* The writes of the Files trash (lib/files-trash has the convention). All of
* them act on the user's own drive. `nodes` is the drive as last listed; the
* server has the last word wherever another client may have changed it since.
*/
export interface TrashWriteResult {
/** What went through: for a delete, the items as they now sit in the trash. */
done: TrashedItem[];
/** The server's refusals of the rest. */
refused: JMAPSetError[];
}
/** The trash folder, created on the first delete. */
async function ensureTrashFolder(nodes: FileNode[]): Promise<FileNode> {
const known = findTrashFolder(nodes);
if (known) return known;
try {
return await createFolder(TRASH_FOLDER_NAME, null);
} catch (err) {
// Another client may have made it since the drive was listed.
const fresh = findTrashFolder(await getAllFileNodes());
if (fresh) return fresh;
throw err;
}
}
/** Remove the trash folders of `batchIds` that hold nothing once `leaving` are out of them. */
async function removeEmptyBatches(nodes: FileNode[], batchIds: Iterable<string>, leaving: Set<string>): Promise<void> {
const empty = [...new Set(batchIds)].filter((id) => !nodes.some((n) => n.parentId === id && !leaving.has(n.id)));
if (empty.length === 0) return;
await destroyEmptyFolders(empty).catch(() => undefined);
}
/**
* Move nodes of the user's own drive into the trash: one trash folder per
* folder they came from, named after the time and that folder, like the
* webmail does. A node of a shared drive can't go there; delete it instead.
* Throws when nothing went.
*/
export async function moveToTrash(
nodes: FileNode[],
targets: FileNode[],
deletedAt: number = Date.now(),
): Promise<TrashWriteResult> {
const result: TrashWriteResult = { done: [], refused: [] };
if (targets.length === 0) return result;
if (targets.some((n) => n.isShared || isCrossAccountId(n.id))) {
throw new JMAPSetError('forbidden', 'Files of a shared drive cannot be moved to the trash');
}
const groups = new Map<string | null, FileNode[]>();
for (const node of targets) {
const parentId = node.parentId ?? null;
groups.set(parentId, [...(groups.get(parentId) ?? []), node]);
}
let trash = await ensureTrashFolder(nodes);
const createBatch = async (parentId: string | null): Promise<FileNode> => {
const name = trashBatchName(parentId, deletedAt);
try {
return await createFolder(name, trash.id);
} catch {
// The trash on screen may be gone (emptied on another device): read
// the drive again and try once more.
trash = await ensureTrashFolder(await getAllFileNodes());
return createFolder(name, trash.id);
}
};
for (const [parentId, group] of groups) {
const batch = await createBatch(parentId);
const update = await updateFileNodes(Object.fromEntries(group.map((n) => [n.id, { parentId: batch.id }])));
const moved = new Set(update.updated);
result.refused.push(...Object.values(update.notUpdated));
if (moved.size === 0) {
await destroyEmptyFolders([batch.id]).catch(() => undefined);
continue;
}
for (const node of group.filter((n) => moved.has(n.id))) {
result.done.push({ node: { ...node, parentId: batch.id }, batchId: batch.id, deletedAt, fromParentId: parentId });
}
}
if (result.done.length === 0) {
throw result.refused[0] ?? new JMAPSetError('setError', 'Failed to move to the trash');
}
return result;
}
/**
* Put items of the trash back into the folder each came from, or the root
* when that folder is gone or in the trash itself. A name taken there by now
* gets " (1)" and so on. Their trash folders go once they are empty. Throws
* when nothing went.
*/
export async function restoreFromTrash(nodes: FileNode[], items: TrashedItem[]): Promise<TrashWriteResult> {
const result: TrashWriteResult = { done: [], refused: [] };
if (items.length === 0) return result;
const hidden = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id);
const restoring = new Set(items.map((item) => item.node.id));
// Names taken in each folder, so a restored item never clashes; the items
// themselves don't count (after an undo the listing may still show them
// where they were).
const taken = new Map<string | null, Set<string>>();
const namesIn = (parentId: string | null): Set<string> => {
let names = taken.get(parentId);
if (!names) {
names = new Set(nodes
.filter((n) => (n.parentId ?? null) === parentId && !n.isShared && !hidden.has(n.id) && !restoring.has(n.id))
.map((n) => n.name));
taken.set(parentId, names);
}
return names;
};
const updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>> = {};
const targets = new Map<string, string | null>();
for (const item of items) {
const target = restoreTarget(item, nodes);
const used = namesIn(target);
const name = getUniqueName(item.node.name, used);
used.add(name);
// The name only goes along when it has to change: the listing shows a
// WebDAV name decoded, and sending that back would rename the file.
updates[item.node.id] = name === item.node.name ? { parentId: target } : { parentId: target, name };
targets.set(item.node.id, target);
}
const update = await updateFileNodes(updates);
result.refused.push(...Object.values(update.notUpdated));
const restored = new Set(update.updated);
if (restored.size === 0) {
throw result.refused[0] ?? new JMAPSetError('setError', 'Failed to restore');
}
for (const item of items.filter((i) => restored.has(i.node.id))) {
const name = updates[item.node.id].name ?? item.node.name;
result.done.push({ ...item, node: { ...item.node, name, parentId: targets.get(item.node.id) ?? null } });
}
await removeEmptyBatches(nodes, result.done.flatMap((i) => (i.batchId ? [i.batchId] : [])), restored);
return result;
}
/** Delete items of the trash for good, with what is inside a deleted folder. */
export async function purgeFromTrash(nodes: FileNode[], items: TrashedItem[]): Promise<void> {
if (items.length === 0) return;
const ids = new Set(items.map((item) => item.node.id));
try {
await deleteFileNodes([...ids]);
} finally {
// Also after a partial refusal: the server keeps a trash folder that still holds anything.
await removeEmptyBatches(nodes, items.flatMap((i) => (i.batchId ? [i.batchId] : [])), ids);
}
}
/** Delete everything in the trash for good. The next delete makes a new trash folder. */
export async function emptyTrash(nodes: FileNode[]): Promise<void> {
// Not in the listing on screen: another client may have made one since.
const trash = findTrashFolder(nodes) ?? findTrashFolder(await getAllFileNodes());
if (trash) await deleteFileNodes([trash.id]);
}
+74
View File
@@ -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 });
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest';
import type { FileNode } from '../../api/types';
import {
findTrashFolder, parseTrashBatchName, restoreTarget, trashBatchName, trashOriginPath, trashSubtreeIds,
trashedItems, withoutTrash,
} from '../files-trash';
// The webmail's trash convention (its lib/files-trash.ts), read the same way here.
const dir = (id: string, parentId: string | null, name = id, extra: Partial<FileNode> = {}): FileNode =>
({ id, parentId, name, type: '', blobId: null, ...extra });
const file = (id: string, parentId: string | null, name = id, extra: Partial<FileNode> = {}): FileNode =>
({ id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 1, ...extra });
const drive: FileNode[] = [
dir('docs', null, 'Documents'),
dir('tax', 'docs', 'Taxes'),
file('keep', 'docs', 'keep.txt'),
dir('t', null, '.Trash'),
dir('b1', 't', '1730112000000~tax'),
file('r1', 'b1', 'report.pdf'),
dir('old', 'b1', 'Old stuff'),
file('deep', 'old', 'deep.txt'),
dir('b2', 't', '1730115600000~'),
file('r2', 'b2', 'notes.txt'),
// Put into the trash by hand (WebDAV), not by a delete.
file('loose', 't', 'loose.txt'),
// A shared account's own trash is not the user's.
dir('acc-2:t', null, '.Trash', { isShared: true, accountId: 'acc-2' }),
];
describe('trash batch names', () => {
it('names a delete after its time and folder, the root as empty', () => {
expect(trashBatchName('tax', 1730112000000)).toBe('1730112000000~tax');
expect(trashBatchName(null, 1730112000000)).toBe('1730112000000~');
});
it('reads both back, and nothing else', () => {
expect(parseTrashBatchName('1730112000000~tax')).toEqual({ deletedAt: 1730112000000, parentId: 'tax' });
expect(parseTrashBatchName('1730112000000~')).toEqual({ deletedAt: 1730112000000, parentId: null });
expect(parseTrashBatchName('Holiday photos')).toBeNull();
expect(parseTrashBatchName('12~tax')).toBeNull();
expect(parseTrashBatchName('1730112000000~acc-2:x')).toBeNull();
});
});
describe('findTrashFolder', () => {
it('finds the own drive\'s .Trash folder at the root only', () => {
expect(findTrashFolder(drive)?.id).toBe('t');
expect(findTrashFolder([dir('x', 'docs', '.Trash')])).toBeUndefined();
expect(findTrashFolder([file('x', null, '.Trash')])).toBeUndefined();
expect(findTrashFolder([dir('acc-2:t', null, '.Trash', { isShared: true })])).toBeUndefined();
});
});
describe('trashedItems', () => {
it('lists each deleted item once, with when and where it came from', () => {
const items = trashedItems(drive);
expect(items.map((i) => [i.node.id, i.batchId, i.deletedAt, i.fromParentId])).toEqual([
['r1', 'b1', 1730112000000, 'tax'],
['old', 'b1', 1730112000000, 'tax'],
['r2', 'b2', 1730115600000, null],
['loose', null, null, null],
]);
});
it('is empty without a trash folder', () => {
expect(trashedItems(drive.filter((n) => n.id !== 't'))).toEqual([]);
});
});
describe('withoutTrash', () => {
it('leaves the trash and everything in it out', () => {
expect([...trashSubtreeIds(drive, 't')].sort()).toEqual(['b1', 'b2', 'deep', 'loose', 'old', 'r1', 'r2', 't']);
expect(withoutTrash(drive).map((n) => n.id)).toEqual(['docs', 'tax', 'keep', 'acc-2:t']);
});
});
describe('restoreTarget', () => {
it('puts an item back into its folder while that is still there', () => {
expect(restoreTarget({ fromParentId: 'tax' }, drive)).toBe('tax');
expect(restoreTarget({ fromParentId: null }, drive)).toBeNull();
});
it('falls back to the root for a folder that is gone, in the trash or not a folder', () => {
expect(restoreTarget({ fromParentId: 'missing' }, drive)).toBeNull();
expect(restoreTarget({ fromParentId: 'old' }, drive)).toBeNull();
expect(restoreTarget({ fromParentId: 'keep' }, drive)).toBeNull();
});
});
describe('trashOriginPath', () => {
it('names the folder an item came from by its path', () => {
expect(trashOriginPath({ fromParentId: 'tax' }, drive)).toBe('/Documents/Taxes');
expect(trashOriginPath({ fromParentId: null }, drive)).toBe('/');
expect(trashOriginPath({ fromParentId: 'old' }, drive)).toBeNull();
});
});
+121
View File
@@ -0,0 +1,121 @@
import type { FileNode } from '../api/types';
import { isFolder } from '../api/files';
/*
* The Files trash, the webmail's convention (lib/files-trash.ts there), so
* both clients see and restore the same trash. Neither Stalwart 0.16 nor 1.0
* keeps deleted FileNodes: a destroy removes a node and everything below it
* for good. So the trash is a folder the clients manage: `.Trash` at the root
* of the user's own drive, hidden from every listing. Each delete puts its
* items into a folder of its own inside it, named after when it happened and
* which folder the items came from (empty for the root):
*
* /.Trash/1730112000000~<parentId>/report.pdf
*
* The name is the whole record; nothing else is stored. A drive someone
* shared has no trash of the user's: what is deleted there is gone.
*/
export const TRASH_FOLDER_NAME = '.Trash';
/** The trash folder of the user's own drive, if there is one yet. */
export function findTrashFolder(nodes: FileNode[]): FileNode | undefined {
return nodes.find((n) =>
(n.parentId ?? null) === null
&& n.name === TRASH_FOLDER_NAME
&& !n.isShared
&& !n.id.includes(':')
&& isFolder(n));
}
/** The name of the folder one delete's items go into. */
export function trashBatchName(parentId: string | null, deletedAt: number = Date.now()): string {
return `${deletedAt}~${parentId ?? ''}`;
}
/** When a batch was deleted and from which folder (null: the root); null for any other name. */
export function parseTrashBatchName(name: string): { deletedAt: number; parentId: string | null } | null {
const match = /^(\d{10,})~([A-Za-z0-9_-]*)$/.exec(name);
if (!match) return null;
return { deletedAt: Number(match[1]), parentId: match[2] || null };
}
/** The trash folder and everything in it. */
export function trashSubtreeIds(nodes: FileNode[], trashId: string | undefined): Set<string> {
const ids = new Set<string>();
if (!trashId) return ids;
ids.add(trashId);
let grew = true;
while (grew) {
grew = false;
for (const n of nodes) {
if (n.parentId && ids.has(n.parentId) && !ids.has(n.id)) {
ids.add(n.id);
grew = true;
}
}
}
return ids;
}
/** The nodes without the trash and what is in it: what every listing but the trash's own shows. */
export function withoutTrash(nodes: FileNode[]): FileNode[] {
const hidden = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id);
return hidden.size === 0 ? nodes : nodes.filter((n) => !hidden.has(n.id));
}
export interface TrashedItem {
node: FileNode;
/** The delete's folder in the trash; null for something put into the trash directly. */
batchId: string | null;
/** When it was deleted (ms); null when unknown. */
deletedAt: number | null;
/** The folder it came from; null for the root or when unknown. */
fromParentId: string | null;
}
/** What the trash holds, one entry per deleted item. */
export function trashedItems(nodes: FileNode[], trash: FileNode | undefined = findTrashFolder(nodes)): TrashedItem[] {
if (!trash) return [];
const items: TrashedItem[] = [];
for (const child of nodes.filter((n) => n.parentId === trash.id)) {
const batch = isFolder(child) ? parseTrashBatchName(child.name) : null;
if (!batch) {
items.push({ node: child, batchId: null, deletedAt: null, fromParentId: null });
continue;
}
for (const node of nodes.filter((n) => n.parentId === child.id)) {
items.push({ node, batchId: child.id, deletedAt: batch.deletedAt, fromParentId: batch.parentId });
}
}
return items;
}
/**
* Where a restored item goes: the folder it came from while that is still a
* folder of the user's own drive outside the trash, the root otherwise.
*/
export function restoreTarget(item: Pick<TrashedItem, 'fromParentId'>, nodes: FileNode[]): string | null {
const from = item.fromParentId;
if (from === null) return null;
const hidden = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id);
const folder = nodes.find((n) => n.id === from);
return folder && isFolder(folder) && !folder.isShared && !hidden.has(folder.id) ? from : null;
}
/**
* The path of the folder a deleted item came from ("/Documents/Taxes", "/"
* for the root); null when that folder is gone.
*/
export function trashOriginPath(item: Pick<TrashedItem, 'fromParentId'>, nodes: FileNode[]): string | null {
if (item.fromParentId === null) return '/';
if (restoreTarget(item, nodes) === null) return null;
const byId = new Map(nodes.map((n) => [n.id, n]));
const names: string[] = [];
const seen = new Set<string>();
for (let cur = byId.get(item.fromParentId); cur && !seen.has(cur.id); cur = cur.parentId ? byId.get(cur.parentId) : undefined) {
seen.add(cur.id);
names.unshift(cur.name);
}
return `/${names.join('/')}`;
}
+292 -46
View File
@@ -20,7 +20,7 @@ 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 { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
@@ -49,6 +49,9 @@ import {
getFileNameRules, getFileNodeDownloadUrl, getMaxSizeUpload, isFolder,
isSymlink, listFolder, moveFileNode, renameFileNode, supportsSharing, uploadFileBatch,
} from '../api/files';
import { emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash } from '../api/files-trash';
import { trashOriginPath, trashedItems, withoutTrash, type TrashedItem } from '../lib/files-trash';
import { toast } from '../stores/toast-store';
import { jmapClient } from '../api/jmap-client';
import { downloadAttachment, shareAttachment } from '../lib/email-export';
import { secureFetch } from '../lib/client-cert';
@@ -79,6 +82,8 @@ import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
// A file row carries the display name alongside the rest of the node.
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 +151,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':
@@ -214,7 +226,10 @@ export default function FilesScreen() {
const uploadAbortRef = useRef<AbortController | null>(null);
const [batchBusy, setBatchBusy] = useState<string | null>(null);
// The trash (lib/files-trash) is a place of its own, entered from the root.
const [inTrash, setInTrash] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[] } | null>(null);
const [confirmEmptyTrash, setConfirmEmptyTrash] = useState(false);
const [newFolderOpen, setNewFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
@@ -249,11 +264,17 @@ 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]);
// 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();
const loadFiles = useCallback(
@@ -291,6 +312,7 @@ export default function FilesScreen() {
uploadAbortRef.current?.abort();
setAllNodes([]);
setPath([]);
setInTrash(false);
setSelection(new Set());
setActionsTarget(null);
setShareTarget(null);
@@ -303,12 +325,13 @@ 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.
// 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 +342,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 +369,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 +378,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 +493,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 +518,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);
@@ -665,10 +705,45 @@ 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 was before the delete.
const undoTrash = useCallback(async (listing: FileNode[], items: TrashedItem[]) => {
try {
await restoreFromTrash(listing, items);
toast.success(t('files.undo_success', 'Action undone'));
} catch (e) {
toast.error(t('files.undo_error', 'Failed to undo'), fileErrorMessage(e, t));
}
await loadFiles('refresh');
}, [loadFiles, t]);
// Delete in the user's own drive moves into the trash, with Undo and no
// question, like the webmail. A drive someone shared has no trash of the
// user's, and in the trash a delete is for good: those ask first.
const trashRows = useCallback(async (rows: FileRow[]) => {
clearSelection();
const listing = allNodes;
try {
const { done, refused } = await moveToTrash(listing, rows);
await loadFiles('refresh');
if (refused.length > 0) {
Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(refused[0], t));
}
toast.success(
t('files.moved_to_trash', '{count, plural, one {Moved to trash} other {# items moved to trash}}', { count: done.length }),
{ action: { label: t('files.undo', 'Undo'), onPress: () => void undoTrash(listing, done) } },
);
} catch (e) {
Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t));
await loadFiles('refresh');
}
}, [allNodes, clearSelection, loadFiles, undoTrash, t]);
const requestDelete = useCallback((rows: FileRow[]) => {
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 +751,52 @@ export default function FilesScreen() {
setConfirmDelete(null);
clearSelection();
try {
const fromTrash = targets.flatMap((r) => (r.trash ? [r.trash] : []));
// The server removes folder descendants (onDestroyRemoveChildren).
await deleteFileNodes(targets.map((t) => t.id));
await loadFiles('refresh');
if (fromTrash.length > 0) await purgeFromTrash(allNodes, fromTrash);
else await deleteFileNodes(targets.map((r) => r.id));
toast.success(t('files.deleted_forever', '{count, plural, one {Deleted permanently} other {# items deleted permanently}}', {
count: targets.length,
}));
} catch (e) {
Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t));
}
}, [confirmDelete, clearSelection, loadFiles, t]);
await loadFiles('refresh');
}, [confirmDelete, allNodes, clearSelection, loadFiles, t]);
const restoreRows = useCallback(async (rows: FileRow[]) => {
const items = rows.flatMap((r) => (r.trash ? [r.trash] : []));
if (items.length === 0) return;
clearSelection();
try {
const { done, refused } = await restoreFromTrash(allNodes, items);
if (refused.length > 0) {
Alert.alert(t('files.restore_error', "Couldn't restore"), fileErrorMessage(refused[0], t));
}
toast.success(t('files.restored', '{count, plural, one {Item restored} other {# items restored}}', { count: done.length }));
} catch (e) {
Alert.alert(t('files.restore_error', "Couldn't restore"), fileErrorMessage(e, t));
}
await loadFiles('refresh');
}, [allNodes, clearSelection, loadFiles, t]);
const performEmptyTrash = useCallback(async () => {
setConfirmEmptyTrash(false);
clearSelection();
try {
await emptyTrash(allNodes);
toast.success(t('files.trash_emptied', 'Trash emptied'));
} catch (e) {
Alert.alert(t('files.empty_trash_error', "Couldn't empty the trash"), fileErrorMessage(e, t));
}
await loadFiles('refresh');
}, [allNodes, clearSelection, loadFiles, t]);
const sortLabel = t(`files.${sortKey}`, sortKey);
const 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 +816,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 +917,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 +949,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 +997,15 @@ export default function FilesScreen() {
);
};
// Where a deleted item came from, on its row in the trash.
const trashOrigin = (item: TrashedItem): string | null => {
const from = trashOriginPath(item, allNodes);
if (from === null) return null;
return t('files.deleted_from', 'Deleted from {folder}', {
folder: from === '/' ? t('files.my_files', 'My files') : from,
});
};
const renderIcon = (item: FileRow, size: number) => {
const isDir = isFolder(item);
const thumb = !isDir && showThumbnails ? thumbnailSource(item) : null;
@@ -881,7 +1033,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 +1059,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 +1160,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 +1299,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 +1334,37 @@ export default function FilesScreen() {
<Dialog
visible={confirmDelete != null}
title={
title={t('files.delete_forever_title', '{count, plural, one {Delete permanently?} other {Delete # items permanently?}}', {
count: confirmDelete?.rows.length ?? 1,
})}
message={
confirmDelete && confirmDelete.rows.length > 1
? 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?')
? 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 +1441,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 +1464,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 +1849,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 +1903,9 @@ function makeStyles(c: ThemePalette) {
fontSize: typography.caption.fontSize,
color: c.textMuted,
},
fileMetaShrink: {
flexShrink: 1,
},
rowMore: {
width: componentSizes.buttonLg,
height: componentSizes.buttonLg,