Merge branch 'fx/files' into feat/stalwart-1.0

This commit is contained in:
Linus Rath
2026-10-29 03:26:03 +01:00
38 changed files with 2399 additions and 103 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",
+2
View File
@@ -483,6 +483,7 @@
"move_to": "Move to…",
"name_control_chars": "Names cannot contain control characters.",
"name_too_long": "This name is too long for the server.",
"name_trash": "\".Trash\" is the name of the trash folder at the top of your files. Choose another name.",
"open_error": "Could not open the file",
"open_with": "Open with…",
"preview_too_large": "This file is too large to preview.",
@@ -518,6 +519,7 @@
"shared_with": "Shared with",
"sharing_access": "Sharing & access",
"symlink": "Symbolic link",
"trash_unavailable_message": "{count, plural, one {This item} other {These # items}} can't be moved to the trash. Delete permanently instead? You can't undo this.",
"upload": "Upload",
"upload_failed": "{count, plural, one {1 file could not be uploaded} other {# files could not be uploaded}}",
"upload_unavailable": "The file picker is not installed in this build. Rebuild the app to enable uploads.",
+20 -1
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": "您的证书",
+460
View File
@@ -0,0 +1,460 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
serverUrl: 'https://mail.example.org',
username: 'me@example.org',
request: vi.fn(),
hasCapability: vi.fn(() => true),
hasAccountCapability: vi.fn(() => false),
getMaxObjectsInGet: vi.fn(() => 500),
getMaxObjectsInSet: vi.fn(() => 500),
getMaxCallsInRequest: vi.fn(() => 16),
getAccountCapability: vi.fn(),
currentSession: {
primaryAccounts: { 'urn:ietf:params:jmap:filenode': 'acc-1' },
accounts: { 'acc-1': { name: 'me', isPersonal: true }, 'acc-2': { name: 'them', isPersonal: false } },
},
},
}));
import { jmapClient } from '../jmap-client';
import type { FileNode } from '../types';
import {
TrashUnavailableError, emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash, undoMoveToTrash,
} from '../files-trash';
import { trashedItems } from '../../lib/files-trash';
// A Stalwart-like FileNode store for the user's own account: destroy without
// onDestroyRemoveChildren refuses a folder that holds anything.
type Node = { id: string; parentId: string | null; name: string; blobId: string | null; type?: string };
let store: Node[];
let nextId: number;
let calls: Array<Record<string, unknown>>;
let refuseUpdate: Set<string>;
function serve(method: string, args: Record<string, any>) {
if (method === 'FileNode/get') return { list: store.map((n) => ({ ...n })) };
calls.push(args);
const out: Record<string, any> = {};
// Like Stalwart, two nodes of one folder can't share a name.
const clashes = (parentId: string | null, name: string, self?: string) =>
store.some((n) => n.parentId === parentId && n.name === name && n.id !== self);
for (const [key, props] of Object.entries((args.create ?? {}) as Record<string, any>)) {
if (clashes(props.parentId ?? null, props.name)) {
(out.notCreated ??= {})[key] = { type: 'alreadyExists', description: 'taken' };
continue;
}
const id = `n${nextId++}`;
store.push({ id, parentId: props.parentId ?? null, name: props.name, blobId: props.blobId ?? null });
(out.created ??= {})[key] = { id };
}
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, any>)) {
const node = store.find((n) => n.id === id);
if (!node || refuseUpdate.has(id)) {
(out.notUpdated ??= {})[id] = { type: 'forbidden', description: 'no' };
continue;
}
const parentId = 'parentId' in patch ? patch.parentId : node.parentId;
if (clashes(parentId, patch.name ?? node.name, id)) {
(out.notUpdated ??= {})[id] = { type: 'alreadyExists', description: 'taken' };
continue;
}
Object.assign(node, patch);
(out.updated ??= {})[id] = null;
}
for (const id of (args.destroy ?? []) as string[]) {
if (!store.some((n) => n.id === id)) {
(out.notDestroyed ??= {})[id] = { type: 'notFound' };
continue;
}
if (!args.onDestroyRemoveChildren && store.some((n) => n.parentId === id)) {
(out.notDestroyed ??= {})[id] = { type: 'nodeHasChildren' };
continue;
}
const gone = new Set([id]);
for (let grew = true; grew;) {
grew = false;
for (const n of store) if (n.parentId && gone.has(n.parentId) && !gone.has(n.id)) { gone.add(n.id); grew = true; }
}
store = store.filter((n) => !gone.has(n.id));
(out.destroyed ??= []).push(id);
}
return out;
}
const listed = (): FileNode[] => store.map((n) => ({ ...n, type: n.type ?? '' }) as FileNode);
const byId = (id: string) => store.find((n) => n.id === id);
// Who is signed in: another login, or another files account of it.
const client = jmapClient as unknown as { username: string; currentSession: { primaryAccounts: Record<string, string> } };
function signIn(username: string, filesAccount = 'acc-1') {
client.username = username;
client.currentSession.primaryAccounts['urn:ietf:params:jmap:filenode'] = filesAccount;
}
beforeEach(() => {
signIn('me@example.org');
// No naming rules published unless a test says so.
(jmapClient.getAccountCapability as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
nextId = 1;
calls = [];
refuseUpdate = new Set();
store = [
{ id: 'docs', parentId: null, name: 'Docs', blobId: null },
{ id: 'a', parentId: 'docs', name: 'a.txt', blobId: 'ba' },
{ id: 'b', parentId: 'docs', name: 'b.txt', blobId: 'bb' },
{ id: 'top', parentId: null, name: 'top.txt', blobId: 'bt' },
];
(jmapClient.request as ReturnType<typeof vi.fn>).mockImplementation(async (methodCalls: any[]) => ({
methodResponses: methodCalls.map(([method, args, id]: [string, any, string]) => [method, serve(method, args), id]),
}));
});
describe('moveToTrash', () => {
it('makes .Trash on the first delete and one trash folder per folder the items came from', async () => {
const nodes = listed();
const { done } = await moveToTrash(nodes, nodes.filter((n) => ['a', 'top'].includes(n.id)), 1730112000000);
const trash = store.find((n) => n.name === '.Trash')!;
expect(trash.parentId).toBeNull();
expect(trash.blobId).toBeNull();
const batchNames = store.filter((n) => n.parentId === trash.id).map((n) => n.name).sort();
expect(batchNames).toEqual(['1730112000000~', '1730112000000~docs']);
expect(byId('a')!.parentId).toBe(store.find((n) => n.name === '1730112000000~docs')!.id);
expect(byId('top')!.parentId).toBe(store.find((n) => n.name === '1730112000000~')!.id);
expect(done.map((i) => [i.node.id, i.fromParentId])).toEqual([['a', 'docs'], ['top', null]]);
// What the webmail reads back.
expect(trashedItems(listed()).map((i) => [i.node.id, i.fromParentId, i.deletedAt])).toEqual([
['a', 'docs', 1730112000000],
['top', null, 1730112000000],
]);
});
it('reuses the trash folder that is there', async () => {
store.push({ id: 'T', parentId: null, name: '.Trash', blobId: null });
const nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000001);
expect(store.filter((n) => n.name === '.Trash')).toHaveLength(1);
expect(byId(byId('a')!.parentId!)!.parentId).toBe('T');
});
it('finds the trash again when the one on screen was emptied elsewhere', async () => {
store.push({ id: 'T', parentId: null, name: '.Trash', blobId: null });
const nodes = listed();
store = store.filter((n) => n.id !== 'T');
store.push({ id: 'T2', parentId: null, name: '.Trash', blobId: null });
// The fake creates under a missing parent, so make that fail like the server.
const real = (jmapClient.request as ReturnType<typeof vi.fn>).getMockImplementation() as (calls: any[]) => Promise<unknown>;
(jmapClient.request as ReturnType<typeof vi.fn>).mockImplementation(async (methodCalls: any[]) => {
const create = methodCalls[0][1].create as Record<string, any> | undefined;
const parent = create && Object.values(create)[0].parentId;
if (parent && !byId(parent)) {
return { methodResponses: [['FileNode/set', { notCreated: { 'new-dir': { type: 'invalidProperties' } } }, '0']] };
}
return real(methodCalls);
});
await moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000001);
expect(byId(byId('a')!.parentId!)!.parentId).toBe('T2');
});
it('refuses nodes of a shared drive', async () => {
const shared = { id: 'acc-2:x', parentId: null, name: 'x', type: '', blobId: 'bx', isShared: true } as FileNode;
await expect(moveToTrash([shared], [shared])).rejects.toThrow();
expect(calls).toEqual([]);
});
it('throws the refusal and leaves no empty trash folder when nothing went', async () => {
refuseUpdate.add('a');
const nodes = listed();
await expect(moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000005)).rejects.toThrow('no');
expect(store.some((n) => n.name === '1730112000005~docs')).toBe(false);
expect(byId('a')!.parentId).toBe('docs');
const destroy = calls.find((c) => c.destroy);
expect(destroy?.onDestroyRemoveChildren).toBeUndefined();
});
it('says the trash cannot be had when a file at the root is named .Trash', async () => {
store.push({ id: 'f', parentId: null, name: '.Trash', blobId: 'bf' });
const nodes = listed();
const err = await moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000005).catch((e) => e);
expect(err).toBeInstanceOf(TrashUnavailableError);
expect(calls.some((c) => c.update)).toBe(false);
expect(byId('a')!.parentId).toBe('docs');
});
it('says the trash cannot be had when no folder can be made in it (quota)', async () => {
store.push({ id: 'T', parentId: null, name: '.Trash', blobId: null });
const real = (jmapClient.request as ReturnType<typeof vi.fn>).getMockImplementation() as (c: any[]) => Promise<unknown>;
(jmapClient.request as ReturnType<typeof vi.fn>).mockImplementation(async (methodCalls: any[]) => {
if (methodCalls[0][1].create) {
return { methodResponses: [['FileNode/set', { notCreated: { 'new-dir': { type: 'overQuota' } } }, '0']] };
}
return real(methodCalls);
});
const nodes = listed();
await expect(moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000005)).rejects.toBeInstanceOf(TrashUnavailableError);
expect(byId('a')!.parentId).toBe('docs');
});
it('reports a partial refusal and keeps what went', async () => {
refuseUpdate.add('b');
const nodes = listed();
const { done, refused } = await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000005);
expect(done.map((i) => i.node.id)).toEqual(['a']);
expect(refused).toHaveLength(1);
});
});
describe('restoreFromTrash', () => {
it('puts items back where they came from and removes their empty trash folder', async () => {
let nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007);
nodes = listed();
const batch = store.find((n) => n.name === '1730112000007~docs')!;
await restoreFromTrash(trashedItems(nodes));
expect(byId('a')).toMatchObject({ parentId: 'docs', name: 'a.txt' });
expect(byId('b')).toMatchObject({ parentId: 'docs', name: 'b.txt' });
expect(byId(batch.id)).toBeUndefined();
// No rename was sent along with the move.
const update = calls.filter((c) => c.update).pop()!.update as Record<string, Record<string, unknown>>;
expect(update.a).toEqual({ parentId: 'docs' });
});
it('keeps a trash folder that other items still hold', async () => {
let nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007);
nodes = listed();
const batch = store.find((n) => n.name === '1730112000007~docs')!;
await restoreFromTrash(trashedItems(nodes).filter((i) => i.node.id === 'a'));
expect(byId(batch.id)).toBeDefined();
expect(byId('b')!.parentId).toBe(batch.id);
});
it('gives a name taken by now a free one, and sends the root for a folder that is gone', async () => {
let nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000007);
store.push({ id: 'a2', parentId: 'docs', name: 'a.txt', blobId: 'ba2' });
nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.id === 'docs'), 1730112000008);
nodes = listed();
// Docs is in the trash itself now: a.txt goes to the root.
await restoreFromTrash(trashedItems(nodes).filter((i) => i.node.id === 'a'));
expect(byId('a')).toMatchObject({ parentId: null, name: 'a.txt' });
nodes = listed();
await restoreFromTrash(trashedItems(nodes).filter((i) => i.node.id === 'docs'));
await moveToTrash(listed(), listed().filter((n) => n.id === 'a2'), 1730112000009);
store.push({ id: 'a3', parentId: 'docs', name: 'a.txt', blobId: 'ba3' });
nodes = listed();
await restoreFromTrash(trashedItems(nodes).filter((i) => i.node.id === 'a2'));
expect(byId('a2')).toMatchObject({ parentId: 'docs', name: 'a (1).txt' });
});
it('undoes a delete with the listing from before it, without renaming', async () => {
const before = listed();
const { done } = await moveToTrash(before, before.filter((n) => n.parentId === 'docs'), 1730112000007);
await restoreFromTrash(done);
expect(byId('a')).toMatchObject({ parentId: 'docs', name: 'a.txt' });
expect(byId('b')).toMatchObject({ parentId: 'docs', name: 'b.txt' });
expect(store.some((n) => n.name === '1730112000007~docs')).toBe(false);
});
it('undoes into the root when the folder went to the trash after the delete', async () => {
const before = listed();
const { done } = await moveToTrash(before, before.filter((n) => n.id === 'a'), 1730112000007);
await moveToTrash(listed(), listed().filter((n) => n.id === 'docs'), 1730112000008);
const { done: back } = await restoreFromTrash(done);
expect(back).toHaveLength(1);
expect(byId('a')).toMatchObject({ parentId: null, name: 'a.txt' });
});
it('undoes under a free name when the old one was taken after the delete', async () => {
const before = listed();
const { done } = await moveToTrash(before, before.filter((n) => n.id === 'a'), 1730112000007);
store.push({ id: 'a2', parentId: 'docs', name: 'a.txt', blobId: 'ba2' });
await restoreFromTrash(done);
expect(byId('a')).toMatchObject({ parentId: 'docs', name: 'a (1).txt' });
expect(byId('a2')).toMatchObject({ parentId: 'docs', name: 'a.txt' });
});
it('puts an item back into its folder when that folder is restored with it', async () => {
await moveToTrash(listed(), listed().filter((n) => n.id === 'a'), 1730112000007);
await moveToTrash(listed(), listed().filter((n) => n.id === 'docs'), 1730112000008);
const nodes = listed();
const { done } = await restoreFromTrash(trashedItems(nodes));
expect(done.map((i) => [i.node.id, i.node.parentId]).sort()).toEqual([['a', 'docs'], ['docs', null]]);
expect(byId('docs')!.parentId).toBeNull();
expect(byId('a')).toMatchObject({ parentId: 'docs', name: 'a.txt' });
expect(store.some((n) => /^\d+~/.test(n.name))).toBe(false);
});
it('puts an item back into a folder inside one restored with it, under a free name', async () => {
store.push({ id: 'sub', parentId: 'docs', name: 'Sub', blobId: null }, { id: 'c', parentId: 'sub', name: 'c.txt', blobId: 'bc' });
await moveToTrash(listed(), listed().filter((n) => n.id === 'c'), 1730112000007);
store.push({ id: 'c2', parentId: 'sub', name: 'c.txt', blobId: 'bc2' });
await moveToTrash(listed(), listed().filter((n) => n.id === 'docs'), 1730112000008);
await restoreFromTrash(trashedItems(listed()));
expect(byId('docs')!.parentId).toBeNull();
expect(byId('c')).toMatchObject({ parentId: 'sub', name: 'c (1).txt' });
expect(byId('c2')).toMatchObject({ parentId: 'sub', name: 'c.txt' });
});
it('puts the item at the root when its folder could not be restored with it', async () => {
await moveToTrash(listed(), listed().filter((n) => n.id === 'a'), 1730112000007);
await moveToTrash(listed(), listed().filter((n) => n.id === 'docs'), 1730112000008);
refuseUpdate.add('docs');
const { done, refused } = await restoreFromTrash(trashedItems(listed()));
expect(done.map((i) => i.node.id)).toEqual(['a']);
expect(refused).toHaveLength(1);
expect(byId('a')!.parentId).toBeNull();
});
it('gives a clashing WebDAV name a free one the server accepts', async () => {
// Stored by WebDAV with a ":" escaped, which JMAP names refuse.
store.push({ id: 'w', parentId: 'docs', name: 'a%3Ab.txt', blobId: 'bw' });
await moveToTrash(listed(), listed().filter((n) => n.id === 'w'), 1730112000007);
store.push({ id: 'w2', parentId: 'docs', name: 'a%3Ab.txt', blobId: 'bw2' });
(jmapClient.getAccountCapability as ReturnType<typeof vi.fn>).mockReturnValue({ forbiddenNameChars: ':*' });
await restoreFromTrash(trashedItems(listed()));
const update = calls.filter((c) => c.update).pop()!.update as Record<string, Record<string, unknown>>;
expect(update.w).toEqual({ parentId: 'docs', name: 'a_b (1).txt' });
expect(byId('w')).toMatchObject({ parentId: 'docs', name: 'a_b (1).txt' });
});
it('gives a folder named .Trash that goes back to the root another name', async () => {
store.push({ id: 'x', parentId: 'docs', name: '.Trash', blobId: null }, { id: 'xin', parentId: 'x', name: 'in.txt', blobId: 'bi' });
let nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.id === 'x'), 1730112000007);
// Docs goes too: the folder comes back to the root.
await moveToTrash(listed(), listed().filter((n) => n.id === 'docs'), 1730112000008);
nodes = listed();
await restoreFromTrash(trashedItems(nodes).filter((i) => i.node.id === 'x'));
expect(byId('x')).toMatchObject({ parentId: null, name: '.Trash (1)' });
expect(store.filter((n) => n.parentId === null && n.name === '.Trash')).toHaveLength(1);
});
it('leaves an item alone that left the trash since the rows were listed', async () => {
let nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000007);
nodes = listed();
const rows = trashedItems(nodes);
// Restored on another device and moved on from there.
byId('a')!.parentId = 'top-dir';
store.push({ id: 'top-dir', parentId: null, name: 'Elsewhere', blobId: null });
const sent = calls.length;
const { done } = await restoreFromTrash(rows);
expect(done).toEqual([]);
expect(calls.slice(sent).filter((c) => c.update)).toEqual([]);
expect(byId('a')!.parentId).toBe('top-dir');
});
});
describe('undoMoveToTrash', () => {
it('puts the items back while the same drive is signed in', async () => {
const before = listed();
const moved = await moveToTrash(before, before.filter((n) => n.id === 'a'), 1730112000007);
expect((await undoMoveToTrash(moved))?.done).toHaveLength(1);
expect(byId('a')).toMatchObject({ parentId: 'docs' });
});
it('sends nothing once another login is signed in, whose ids may be the same', async () => {
const before = listed();
const moved = await moveToTrash(before, before.filter((n) => n.id === 'a'), 1730112000007);
signIn('other@example.org');
const sent = calls.length;
const requests = (jmapClient.request as ReturnType<typeof vi.fn>).mock.calls.length;
expect(await undoMoveToTrash(moved)).toBeNull();
expect(calls.length).toBe(sent);
expect((jmapClient.request as ReturnType<typeof vi.fn>).mock.calls.length).toBe(requests);
expect(byId(byId('a')!.parentId!)!.name).toBe('1730112000007~docs');
});
it('sends nothing once the login reads another files account', async () => {
const before = listed();
const moved = await moveToTrash(before, before.filter((n) => n.id === 'a'), 1730112000007);
signIn('me@example.org', 'acc-3');
const sent = calls.length;
expect(await undoMoveToTrash(moved)).toBeNull();
expect(calls.length).toBe(sent);
});
it('stops before the restore when the account changes while the drive is read', async () => {
const before = listed();
const moved = await moveToTrash(before, before.filter((n) => n.id === 'a'), 1730112000007);
const real = (jmapClient.request as ReturnType<typeof vi.fn>).getMockImplementation() as (c: any[]) => Promise<unknown>;
(jmapClient.request as ReturnType<typeof vi.fn>).mockImplementation(async (methodCalls: any[]) => {
const out = await real(methodCalls);
if (methodCalls[0][0] === 'FileNode/get') signIn('other@example.org');
return out;
});
const sent = calls.length;
expect(await undoMoveToTrash(moved)).toBeNull();
expect(calls.length).toBe(sent);
});
it('stops a delete whose account changes before its items are moved', async () => {
const before = listed();
const real = (jmapClient.request as ReturnType<typeof vi.fn>).getMockImplementation() as (c: any[]) => Promise<unknown>;
(jmapClient.request as ReturnType<typeof vi.fn>).mockImplementation(async (methodCalls: any[]) => {
const out = await real(methodCalls);
if (methodCalls[0][1].create) signIn('other@example.org');
return out;
});
await expect(moveToTrash(before, before.filter((n) => n.id === 'a'), 1730112000007)).rejects.toThrow('Another account');
expect(calls.some((c) => c.update)).toBe(false);
expect(byId('a')!.parentId).toBe('docs');
});
});
describe('purgeFromTrash and emptyTrash', () => {
it('deletes items for good with what is inside, and their emptied trash folder', async () => {
let nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.id === 'docs'), 1730112000007);
nodes = listed();
expect(await purgeFromTrash(trashedItems(nodes))).toBe(1);
expect(store.map((n) => n.id).sort()).toEqual(['n1', 'top']);
expect(byId('n1')!.name).toBe('.Trash');
});
it('leaves a row on screen alone that was restored elsewhere meanwhile', async () => {
let nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.id === 'docs'), 1730112000007);
nodes = listed();
const rows = trashedItems(nodes);
// Another device puts Docs back before the Delete permanently here.
byId('docs')!.parentId = null;
expect(await purgeFromTrash(rows)).toBe(0);
expect(store.map((n) => n.id).sort()).toEqual(['a', 'b', 'docs', 'n1', 'n2', 'top']);
expect(calls.some((c) => (c.destroy as string[] | undefined)?.includes('docs'))).toBe(false);
});
it('deletes only the rows still in the trash, and their trash folder as it is now', async () => {
let nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007);
nodes = listed();
const rows = trashedItems(nodes);
byId('b')!.parentId = 'docs';
expect(await purgeFromTrash(rows)).toBe(1);
expect(byId('a')).toBeUndefined();
expect(byId('b')).toMatchObject({ parentId: 'docs' });
expect(store.some((n) => n.name === '1730112000007~docs')).toBe(false);
});
it('empties the trash, everything in it included', async () => {
const nodes = listed();
await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007);
await emptyTrash();
expect(store.map((n) => n.id).sort()).toEqual(['docs', 'top']);
});
it('empties the trash as it is now, not a folder that was the trash on screen', async () => {
store.push({ id: 'T', parentId: null, name: '.Trash', blobId: null }, { id: 'old', parentId: 'T', name: 'old.txt', blobId: 'bo' });
// Renamed elsewhere (WebDAV), and a new trash made since.
byId('T')!.name = 'Kept';
store.push({ id: 'T2', parentId: null, name: '.Trash', blobId: null }, { id: 'new', parentId: 'T2', name: 'new.txt', blobId: 'bn' });
await emptyTrash();
expect(store.map((n) => n.id).sort()).toEqual(['T', 'a', 'b', 'docs', 'old', 'top']);
});
});
@@ -36,8 +36,10 @@ import {
copyFileNode,
createFolder,
deleteFileNodes,
destroyEmptyFolders,
fileNodeKind,
filesAccountId,
getAllFileNodes,
getAllFileNodesAcrossAccounts,
getFileNodeDownloadUrl,
listFolder,
@@ -45,6 +47,10 @@ import {
renameFileNode,
uploadFileBatch,
} from '../files';
import {
TrashUnavailableError, emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash, undoMoveToTrash,
} from '../files-trash';
import { findTrashFolder, parseTrashBatchName, trashedItems, withoutTrash } from '../../lib/files-trash';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
@@ -208,3 +214,240 @@ live('live Stalwart files', () => {
await deleteFileNodes([bulk.id]);
}, 180_000);
});
// Dumps the drive for a check with the webmail's own code (BULWARK_LIVE_PROBE_DIR).
async function writeProbe(name: string, list: FileNode[]): Promise<void> {
const dir = process.env.BULWARK_LIVE_PROBE_DIR;
if (!dir) return;
const { writeFile } = await import('node:fs/promises');
await writeFile(`${dir}/${name}-${SERVER!.replace(/\D/g, '').slice(-4)}.json`, JSON.stringify(list));
}
// The trash is the webmail's convention (lib/files-trash): /.Trash/<ms>~<parentId>/<item>.
live('live Stalwart files trash', () => {
let scratch = '';
// An account's own trash is left alone: only what these tests put there goes.
let trashBefore: string | undefined;
const batches = new Set<string>();
const own = () => getAllFileNodes();
const upload = async (name: string, parentId: string, text = name) =>
(await uploadFileBatch([pick(name, text)], parentId, [])).uploaded[0];
const trashed = async (id: string) => trashedItems(await own()).find((i) => i.node.id === id);
beforeAll(async () => {
await connectSingleton();
trashBefore = findTrashFolder(await own())?.id;
scratch = (await createFolder(`live-trash-${Date.now().toString(36)}`, null)).id;
}, 60_000);
afterAll(async () => {
const before = await own();
const trash = findTrashFolder(before);
const leftovers = [scratch, ...[...batches].filter((id) => before.some((n) => n.id === id))];
if (trash && !trashBefore) leftovers.push(trash.id);
await deleteFileNodes(leftovers.filter(Boolean)).catch(() => undefined);
const after = await own();
expect(after.filter((n) => leftovers.includes(n.id))).toEqual([]);
if (!trashBefore) expect(findTrashFolder(after)).toBeUndefined();
}, 120_000);
it('says the trash cannot be had while a file at the root is named .Trash', async () => {
// Only on an account without a trash: a real one stays.
if (trashBefore) return;
const blocker = (await uploadFileBatch([pick('.Trash', 'not a folder')], null, [])).uploaded[0];
expect(blocker?.name).toBe('.Trash');
const file = await upload('blocked.txt', scratch);
try {
const list = await own();
const err = await moveToTrash(list, [list.find((n) => n.id === file.id)!]).catch((e) => e);
expect(err).toBeInstanceOf(TrashUnavailableError);
expect((await own()).find((n) => n.id === file.id)?.parentId).toBe(scratch);
} finally {
await deleteFileNodes([blocker.id, file.id]);
}
expect(findTrashFolder(await own())).toBeUndefined();
}, 90_000);
it('moves a file and a folder into one trash folder that names when and where from, and hides them', async () => {
const file = await upload('trash-me.txt', scratch);
const folder = await createFolder('Trash me', scratch);
const inner = await upload('inner.txt', folder.id);
let list = await own();
const before = Date.now();
const { done, refused } = await moveToTrash(list, [
list.find((n) => n.id === file.id)!,
list.find((n) => n.id === folder.id)!,
]);
expect(refused).toEqual([]);
expect(done.map((i) => i.node.id).sort()).toEqual([file.id, folder.id].sort());
list = await own();
const trash = findTrashFolder(list)!;
expect(trash).toBeDefined();
expect(trash.parentId ?? null).toBeNull();
expect(trash.name).toBe('.Trash');
const batch = list.find((n) => n.id === done[0].batchId)!;
batches.add(batch.id);
expect(batch.parentId).toBe(trash.id);
expect(fileNodeKind(batch)).toBe('directory');
// What the webmail reads back to restore: the time and the folder.
expect(batch.name).toMatch(/^(\d{10,})~([A-Za-z0-9_-]*)$/);
expect(parseTrashBatchName(batch.name)?.parentId).toBe(scratch);
expect(parseTrashBatchName(batch.name)!.deletedAt).toBeGreaterThanOrEqual(before - 1000);
expect(list.find((n) => n.id === file.id)?.parentId).toBe(batch.id);
// The folder went whole.
expect(list.find((n) => n.id === inner.id)?.parentId).toBe(folder.id);
const items = trashedItems(list).filter((i) => i.batchId === batch.id);
expect(items.map((i) => i.node.name).sort()).toEqual(['Trash me', 'trash-me.txt']);
expect(items.every((i) => i.fromParentId === scratch)).toBe(true);
// Out of every normal listing, the root included.
const shown = withoutTrash(await nodes());
expect(shown.some((n) => [trash.id, batch.id, file.id, folder.id, inner.id].includes(n.id))).toBe(false);
expect(listFolder(shown, null).some((n) => n.id === trash.id)).toBe(false);
expect(listFolder(shown, scratch).map((n) => n.id)).toEqual([]);
await writeProbe('trash-after-delete', list);
}, 90_000);
it('restores items to the folder they came from and removes their empty trash folder', async () => {
const all = trashedItems(await own());
const item = all.find((i) => i.node.name === 'trash-me.txt' && i.fromParentId === scratch)!;
const sibling = all.find((i) => i.node.name === 'Trash me' && i.batchId === item.batchId)!;
let list = await own();
const { done } = await restoreFromTrash([item]);
expect(done).toHaveLength(1);
list = await own();
expect(list.find((n) => n.id === item.node.id)).toMatchObject({ name: 'trash-me.txt', parentId: scratch });
// The other item still holds the trash folder.
expect(list.some((n) => n.id === item.batchId)).toBe(true);
await restoreFromTrash([sibling]);
list = await own();
expect(list.find((n) => n.id === sibling.node.id)?.parentId).toBe(scratch);
expect(list.some((n) => n.id === item.batchId)).toBe(false);
expect(await trashed(item.node.id)).toBeUndefined();
}, 90_000);
it('restores under a free name when the old one is taken, and to the root when the folder is gone', async () => {
const sub = await createFolder('Gone soon', scratch);
const a = await upload('clash.txt', scratch, 'old');
const b = await upload('orphan.txt', sub.id);
let list = await own();
const first = await moveToTrash(list, [list.find((n) => n.id === a.id)!]);
const second = await moveToTrash(list, [list.find((n) => n.id === b.id)!]);
for (const i of [...first.done, ...second.done]) batches.add(i.batchId!);
await upload('clash.txt', scratch, 'new');
await deleteFileNodes([sub.id]);
list = await own();
await restoreFromTrash([(await trashed(a.id))!, (await trashed(b.id))!]);
list = await own();
expect(list.find((n) => n.id === a.id)).toMatchObject({ name: 'clash (1).txt', parentId: scratch });
const orphan = list.find((n) => n.id === b.id)!;
try {
expect(orphan.name).toBe('orphan.txt');
expect(orphan.parentId ?? null).toBeNull();
} finally {
await deleteFileNodes([b.id]);
}
}, 120_000);
it('undoes a delete', async () => {
const file = await upload('undo-me.txt', scratch);
const list = await own();
const moved = await moveToTrash(list, [list.find((n) => n.id === file.id)!]);
batches.add(moved.done[0].batchId!);
expect((await undoMoveToTrash(moved))?.done).toHaveLength(1);
const after = await own();
expect(after.find((n) => n.id === file.id)).toMatchObject({ name: 'undo-me.txt', parentId: scratch });
expect(after.some((n) => n.id === moved.done[0].batchId)).toBe(false);
}, 90_000);
it('undoes a delete into the root when its folder went to the trash since', async () => {
const folder = await createFolder('Undo folder', scratch);
const file = await upload(`undo-root-${Date.now().toString(36)}.txt`, folder.id);
let list = await own();
const moved = await moveToTrash(list, [list.find((n) => n.id === file.id)!]);
list = await own();
const second = await moveToTrash(list, [list.find((n) => n.id === folder.id)!]);
for (const i of [...moved.done, ...second.done]) batches.add(i.batchId!);
try {
await undoMoveToTrash(moved);
const after = await own();
expect(after.find((n) => n.id === file.id)?.parentId ?? null).toBeNull();
expect(after.some((n) => n.id === moved.done[0].batchId)).toBe(false);
} finally {
await deleteFileNodes([file.id]).catch(() => undefined);
}
}, 90_000);
it('restores an item into its folder when the folder is restored with it', async () => {
const folder = await createFolder('Restore together', scratch);
const file = await upload('together.txt', folder.id);
let list = await own();
const first = await moveToTrash(list, [list.find((n) => n.id === file.id)!]);
list = await own();
const second = await moveToTrash(list, [list.find((n) => n.id === folder.id)!]);
for (const i of [...first.done, ...second.done]) batches.add(i.batchId!);
const items = trashedItems(await own()).filter((i) => [file.id, folder.id].includes(i.node.id));
expect(items).toHaveLength(2);
const { done, refused } = await restoreFromTrash(items);
expect(refused).toEqual([]);
expect(done).toHaveLength(2);
const after = await own();
expect(after.find((n) => n.id === folder.id)?.parentId).toBe(scratch);
expect(after.find((n) => n.id === file.id)).toMatchObject({ name: 'together.txt', parentId: folder.id });
}, 90_000);
it('leaves a row alone on Delete permanently that was restored since it was listed', async () => {
const file = await upload('stale-row.txt', scratch);
let list = await own();
const { done } = await moveToTrash(list, [list.find((n) => n.id === file.id)!]);
batches.add(done[0].batchId!);
list = await own();
const rows = trashedItems(list).filter((i) => i.node.id === file.id);
// Restored on another device meanwhile.
await restoreFromTrash(rows);
expect(await purgeFromTrash(rows)).toBe(0);
expect((await own()).find((n) => n.id === file.id)?.parentId).toBe(scratch);
}, 90_000);
it('deletes items of the trash for good, and leaves a trash folder that still holds anything', async () => {
const x = await upload('purge-x.txt', scratch);
const y = await upload('purge-y.txt', scratch);
let list = await own();
const picked = [x, y].map((u) => list.find((n) => n.id === u.id)!);
const { done } = await moveToTrash(list, picked);
const batchId = done[0].batchId!;
batches.add(batchId);
// Never with what is inside: a folder that holds anything stays.
expect(await destroyEmptyFolders([batchId])).toEqual([]);
list = await own();
expect(list.some((n) => n.id === batchId)).toBe(true);
await purgeFromTrash(trashedItems(list).filter((i) => i.node.id === x.id));
list = await own();
expect(list.some((n) => n.id === x.id)).toBe(false);
expect(list.some((n) => n.id === batchId)).toBe(true);
await purgeFromTrash(trashedItems(list).filter((i) => i.node.id === y.id));
list = await own();
expect(list.some((n) => [x.id, y.id, batchId].includes(n.id))).toBe(false);
}, 90_000);
it('empties the trash', async () => {
// Only on an account whose trash these tests made: a real one stays.
if (trashBefore) return;
const file = await upload('empty-me.txt', scratch);
const list = await own();
await moveToTrash(list, [list.find((n) => n.id === file.id)!]);
await emptyTrash();
const after = await own();
expect(findTrashFolder(after)).toBeUndefined();
expect(after.some((n) => n.id === file.id)).toBe(false);
}, 90_000);
});
+330
View File
@@ -0,0 +1,330 @@
import type { FileNode } from './types';
import { jmapClient } from './jmap-client';
import {
createFolder, deleteFileNodes, destroyEmptyFolders, filesAccountId, getAllFileNodes, getFileNameRules,
isCrossAccountId, isFolder, updateFileNodes,
} from './files';
import { JMAPSetError } from './jmap-result';
import { getUniqueName } from '../lib/filenode-name';
import { acceptedFileName } from '../lib/file-name-rules';
import {
TRASH_FOLDER_NAME, findTrashFolder, restoreTarget, trashBatchName, trashPlaces, trashSubtreeIds, trashedItems,
type TrashedItem,
} from '../lib/files-trash';
/*
* The writes of the Files trash (lib/files-trash has the convention). All of
* them act on the user's own drive. `nodes` is the drive as last listed; the
* server has the last word wherever another client may have changed it since.
*/
export interface TrashWriteResult {
/** What went through: for a delete, the items as they now sit in the trash. */
done: TrashedItem[];
/** The server's refusals of the rest. */
refused: JMAPSetError[];
}
/**
* The drive a write went to: the login (server and user) and its files
* account. Every write resolves the account when it is sent, and node ids
* repeat across accounts and servers, so a write prepared for one drive
* must not go out once another is signed in.
*/
export interface TrashDrive {
server: string | null;
username: string | null;
accountId: string;
}
export function currentTrashDrive(): TrashDrive {
return { server: jmapClient.serverUrl, username: jmapClient.username, accountId: filesAccountId() };
}
function isCurrentDrive(drive: TrashDrive): boolean {
const now = currentTrashDrive();
return now.server === drive.server && now.username === drive.username && now.accountId === drive.accountId;
}
/** Thrown before a write would go to another drive than the one it was prepared for. */
export class TrashDriveChangedError extends Error {
constructor() {
super('Another account is signed in');
this.name = 'TrashDriveChangedError';
}
}
function assertDrive(drive: TrashDrive | undefined): void {
if (drive && !isCurrentDrive(drive)) throw new TrashDriveChangedError();
}
/** A delete into the trash, with the drive it went to (for its undo). */
export interface TrashMoveResult extends TrashWriteResult {
drive: TrashDrive;
}
/**
* The trash, or a delete's folder in it, can't be made: a file is named
* `.Trash` at the root, the user may not create folders there, or the quota
* is used up. Nothing was moved; the items can only be deleted for good.
*/
export class TrashUnavailableError extends Error {
constructor(readonly reason: unknown) {
super(reason instanceof Error ? reason.message : 'The trash cannot be used');
this.name = 'TrashUnavailableError';
}
}
/** The trash folder, created on the first delete. */
async function ensureTrashFolder(nodes: FileNode[]): Promise<FileNode> {
const known = findTrashFolder(nodes);
if (known) return known;
try {
return await createFolder(TRASH_FOLDER_NAME, null);
} catch (err) {
// Another client may have made it since the drive was listed.
const fresh = findTrashFolder(await getAllFileNodes());
if (fresh) return fresh;
throw new TrashUnavailableError(err);
}
}
/** Remove the trash folders of `batchIds` that hold nothing once `leaving` are out of them. */
async function removeEmptyBatches(nodes: FileNode[], batchIds: Iterable<string>, leaving: Set<string>): Promise<void> {
const empty = [...new Set(batchIds)].filter((id) => !nodes.some((n) => n.parentId === id && !leaving.has(n.id)));
if (empty.length === 0) return;
await destroyEmptyFolders(empty).catch(() => undefined);
}
/**
* Move nodes of the user's own drive into the trash: one trash folder per
* folder they came from, named after the time and that folder, like the
* webmail does. A node of a shared drive can't go there; delete it instead.
* Stops once another account is signed in. Throws when nothing went.
*/
export async function moveToTrash(
nodes: FileNode[],
targets: FileNode[],
deletedAt: number = Date.now(),
): Promise<TrashMoveResult> {
const drive = currentTrashDrive();
const result: TrashMoveResult = { done: [], refused: [], drive };
if (targets.length === 0) return result;
if (targets.some((n) => n.isShared || isCrossAccountId(n.id))) {
throw new JMAPSetError('forbidden', 'Files of a shared drive cannot be moved to the trash');
}
const groups = new Map<string | null, FileNode[]>();
for (const node of targets) {
const parentId = node.parentId ?? null;
groups.set(parentId, [...(groups.get(parentId) ?? []), node]);
}
let trash = await ensureTrashFolder(nodes);
const createBatch = async (parentId: string | null): Promise<FileNode> => {
const name = trashBatchName(parentId, deletedAt);
try {
return await createFolder(name, trash.id);
} catch {
// The trash on screen may be gone (emptied on another device): read
// the drive again and try once more.
trash = await ensureTrashFolder(await getAllFileNodes());
assertDrive(drive);
try {
return await createFolder(name, trash.id);
} catch (err) {
throw new TrashUnavailableError(err);
}
}
};
for (const [parentId, group] of groups) {
if (!isCurrentDrive(drive)) break;
let batch: FileNode;
try {
batch = await createBatch(parentId);
} catch (err) {
// What went already stays in the trash; the rest is reported.
if (!(err instanceof TrashUnavailableError) || result.done.length === 0) throw err;
result.refused.push(new JMAPSetError('setError', err.message));
break;
}
if (!isCurrentDrive(drive)) break;
const update = await updateFileNodes(Object.fromEntries(group.map((n) => [n.id, { parentId: batch.id }])));
const moved = new Set(update.updated);
result.refused.push(...Object.values(update.notUpdated));
if (moved.size === 0) {
await destroyEmptyFolders([batch.id]).catch(() => undefined);
continue;
}
for (const node of group.filter((n) => moved.has(n.id))) {
result.done.push({ node: { ...node, parentId: batch.id }, batchId: batch.id, deletedAt, fromParentId: parentId });
}
}
if (result.done.length === 0) {
assertDrive(drive);
throw result.refused[0] ?? new JMAPSetError('setError', 'Failed to move to the trash');
}
return result;
}
/**
* Put items of the trash back into the folder each came from, or the root
* when that folder is gone or in the trash itself (unless it comes back in
* the same call). A name taken there by now gets " (1)" and so on. Their
* trash folders go once they are empty.
*
* The drive is read again first, and that is what counts: the listing on
* screen (for an undo, the one from before the delete) misses a folder
* trashed since and a name taken since. Items no longer in the trash are left
* where they are. Throws when nothing of what is still there went, and
* TrashDriveChangedError, with nothing sent, once `drive` is no longer the
* one signed in.
*/
export async function restoreFromTrash(items: TrashedItem[], drive?: TrashDrive): Promise<TrashWriteResult> {
const result: TrashWriteResult = { done: [], refused: [] };
if (items.length === 0) return result;
assertDrive(drive);
const nodes = await getAllFileNodes();
assertDrive(drive);
const trash = findTrashFolder(nodes);
const places = trashPlaces(nodes);
const { hidden } = places;
// The items as the trash holds them now: their name and trash folder may
// have changed on another device.
const current = new Map(trashedItems(nodes, trash).map((item) => [item.node.id, item]));
const pending = [...new Set(items.map((item) => item.node.id))].flatMap((id) => {
const item = current.get(id);
return item ? [item] : [];
});
if (pending.length === 0) return result;
// Names taken in each folder, so a restored item never clashes. Folders of
// the trash count too: one restored below may be the target.
const taken = new Map<string | null, Set<string>>();
const namesIn = (parentId: string | null): Set<string> => {
let names = taken.get(parentId);
if (!names) {
names = new Set(nodes
.filter((n) => (n.parentId ?? null) === parentId && !n.isShared)
.map((n) => n.name));
// Something named ".Trash" doesn't go back to the root under that name.
if (parentId === null) names.add(TRASH_FOLDER_NAME);
taken.set(parentId, names);
}
return names;
};
// An item may come from a folder that is among the items too, or inside
// one of them (deleted from it before the folder itself went). Once that
// item is back, so is the folder: the item waits for it instead of going
// to the root.
const restoring = new Set(pending.map((item) => item.node.id));
const holder = (folderId: string | null): string | null => {
const seen = new Set<string>();
for (let cur = folderId ? places.byId.get(folderId) : undefined; cur && hidden.has(cur.id) && !seen.has(cur.id);
cur = cur.parentId ? places.byId.get(cur.parentId) : undefined) {
seen.add(cur.id);
if (restoring.has(cur.id)) return cur.id;
}
return null;
};
const rules = getFileNameRules();
const restored = new Set<string>();
const tried = new Set<string>();
const updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>> = {};
const targets = new Map<string, string | null>();
for (let rest = pending; rest.length > 0;) {
const waits = (item: TrashedItem) => {
const h = holder(item.fromParentId);
return h !== null && h !== item.node.id && !tried.has(h);
};
let round = rest.filter((item) => !waits(item));
// Only a loop of folders (impossible in a tree) leaves none ready.
if (round.length === 0) round = rest;
rest = rest.filter((item) => !round.includes(item));
const roundUpdates: typeof updates = {};
for (const item of round) {
const from = item.fromParentId;
const h = holder(from);
const origin = from ? places.byId.get(from) : undefined;
const target = h !== null && restored.has(h) && origin && isFolder(origin) && !origin.isShared
? from
: restoreTarget(item, places);
const used = namesIn(target);
let name = getUniqueName(item.node.name, used);
// A new name goes to the server as it is, so it must pass its rules: a
// WebDAV name lists decoded ("a%3Ab.txt" as "a:b.txt"), and the ":"
// in "a:b (1).txt" would be refused.
if (name !== item.node.name) name = getUniqueName(acceptedFileName(name, rules), used);
used.add(name);
// The name only goes along when it has to change: the listing shows a
// WebDAV name decoded, and sending that back would rename the file.
roundUpdates[item.node.id] = name === item.node.name ? { parentId: target } : { parentId: target, name };
targets.set(item.node.id, target);
}
Object.assign(updates, roundUpdates);
assertDrive(drive);
const update = await updateFileNodes(roundUpdates);
for (const id of Object.keys(roundUpdates)) tried.add(id);
for (const id of update.updated) restored.add(id);
result.refused.push(...Object.values(update.notUpdated));
}
if (restored.size === 0) {
throw result.refused[0] ?? new JMAPSetError('setError', 'Failed to restore');
}
for (const item of pending.filter((i) => restored.has(i.node.id))) {
const name = updates[item.node.id].name ?? item.node.name;
result.done.push({ ...item, node: { ...item.node, name, parentId: targets.get(item.node.id) ?? null } });
}
if (!drive || isCurrentDrive(drive)) {
await removeEmptyBatches(nodes, result.done.flatMap((i) => (i.batchId ? [i.batchId] : [])), restored);
}
return result;
}
/**
* Undo of a delete into the trash (see restoreFromTrash). Null, and nothing
* sent, once another account or server is signed in than the one the delete
* went to: the same ids name other files there.
*/
export async function undoMoveToTrash(moved: Pick<TrashMoveResult, 'done' | 'drive'>): Promise<TrashWriteResult | null> {
try {
return await restoreFromTrash(moved.done, moved.drive);
} catch (err) {
if (err instanceof TrashDriveChangedError) return null;
throw err;
}
}
/**
* Delete items of the trash for good, with what is inside a deleted folder.
* The drive is read again first and only what is still in the trash goes: a
* row on screen may have been restored or moved out on another device since,
* and the destroy takes a folder's whole content wherever it sits. Returns
* how many items went.
*/
export async function purgeFromTrash(items: TrashedItem[]): Promise<number> {
if (items.length === 0) return 0;
const nodes = await getAllFileNodes();
const trash = findTrashFolder(nodes);
const inTrash = trashSubtreeIds(nodes, trash?.id);
const ids = new Set(items.map((item) => item.node.id).filter((id) => id !== trash?.id && inTrash.has(id)));
if (ids.size === 0) return 0;
// The trash folders the items sit in now, which need not be the ones on screen.
const batchIds = trashedItems(nodes, trash).flatMap((i) => (i.batchId && ids.has(i.node.id) ? [i.batchId] : []));
try {
await deleteFileNodes([...ids]);
} finally {
// Also after a partial refusal: the server keeps a trash folder that still holds anything.
await removeEmptyBatches(nodes, batchIds, ids);
}
return ids.size;
}
/** Delete everything in the trash for good. The next delete makes a new trash folder. */
export async function emptyTrash(): Promise<void> {
// Read again rather than trust the listing on screen: another client may
// have made the trash since, or put another folder in its place.
const trash = findTrashFolder(await getAllFileNodes());
if (trash) await deleteFileNodes([trash.id]);
}
+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 });
}
+129
View File
@@ -0,0 +1,129 @@
import { describe, it, expect } from 'vitest';
import type { FileNode } from '../../api/types';
import {
findTrashFolder, parseTrashBatchName, restoreTarget, takesTrashName, trashBatchName, trashOriginPath,
trashPlaces, trashSubtreeIds, trashedItems, withoutTrash,
} from '../files-trash';
// The webmail's trash convention (its lib/files-trash.ts), read the same way here.
const dir = (id: string, parentId: string | null, name = id, extra: Partial<FileNode> = {}): FileNode =>
({ id, parentId, name, type: '', blobId: null, ...extra });
const file = (id: string, parentId: string | null, name = id, extra: Partial<FileNode> = {}): FileNode =>
({ id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 1, ...extra });
const drive: FileNode[] = [
dir('docs', null, 'Documents'),
dir('tax', 'docs', 'Taxes'),
file('keep', 'docs', 'keep.txt'),
dir('t', null, '.Trash'),
dir('b1', 't', '1730112000000~tax'),
file('r1', 'b1', 'report.pdf'),
dir('old', 'b1', 'Old stuff'),
file('deep', 'old', 'deep.txt'),
dir('b2', 't', '1730115600000~'),
file('r2', 'b2', 'notes.txt'),
// Put into the trash by hand (WebDAV), not by a delete.
file('loose', 't', 'loose.txt'),
// A shared account's own trash is not the user's.
dir('acc-2:t', null, '.Trash', { isShared: true, accountId: 'acc-2' }),
];
describe('trash batch names', () => {
it('names a delete after its time and folder, the root as empty', () => {
expect(trashBatchName('tax', 1730112000000)).toBe('1730112000000~tax');
expect(trashBatchName(null, 1730112000000)).toBe('1730112000000~');
});
it('reads both back, and nothing else', () => {
expect(parseTrashBatchName('1730112000000~tax')).toEqual({ deletedAt: 1730112000000, parentId: 'tax' });
expect(parseTrashBatchName('1730112000000~')).toEqual({ deletedAt: 1730112000000, parentId: null });
expect(parseTrashBatchName('Holiday photos')).toBeNull();
expect(parseTrashBatchName('12~tax')).toBeNull();
expect(parseTrashBatchName('1730112000000~acc-2:x')).toBeNull();
});
});
describe('findTrashFolder', () => {
it('finds the own drive\'s .Trash folder at the root only', () => {
expect(findTrashFolder(drive)?.id).toBe('t');
expect(findTrashFolder([dir('x', 'docs', '.Trash')])).toBeUndefined();
expect(findTrashFolder([file('x', null, '.Trash')])).toBeUndefined();
expect(findTrashFolder([dir('acc-2:t', null, '.Trash', { isShared: true })])).toBeUndefined();
});
});
describe('trashedItems', () => {
it('lists each deleted item once, with when and where it came from', () => {
const items = trashedItems(drive);
expect(items.map((i) => [i.node.id, i.batchId, i.deletedAt, i.fromParentId])).toEqual([
['r1', 'b1', 1730112000000, 'tax'],
['old', 'b1', 1730112000000, 'tax'],
['r2', 'b2', 1730115600000, null],
['loose', null, null, null],
]);
});
it('is empty without a trash folder', () => {
expect(trashedItems(drive.filter((n) => n.id !== 't'))).toEqual([]);
});
});
describe('withoutTrash', () => {
it('leaves the trash and everything in it out', () => {
expect([...trashSubtreeIds(drive, 't')].sort()).toEqual(['b1', 'b2', 'deep', 'loose', 'old', 'r1', 'r2', 't']);
expect(withoutTrash(drive).map((n) => n.id)).toEqual(['docs', 'tax', 'keep', 'acc-2:t']);
});
});
describe('takesTrashName', () => {
it('refuses .Trash at the root of the own drive only', () => {
expect(takesTrashName('.Trash', null)).toBe(true);
expect(takesTrashName('.Trash', null, 'docs')).toBe(true);
expect(takesTrashName('.Trash', 'docs')).toBe(false);
expect(takesTrashName('.trash', null)).toBe(false);
expect(takesTrashName('.Trash (1)', null)).toBe(false);
// A node of a shared drive stays there.
expect(takesTrashName('.Trash', null, 'acc-2:x')).toBe(false);
});
});
describe('restoreTarget', () => {
it('puts an item back into its folder while that is still there', () => {
expect(restoreTarget({ fromParentId: 'tax' }, drive)).toBe('tax');
expect(restoreTarget({ fromParentId: null }, drive)).toBeNull();
});
it('falls back to the root for a folder that is gone, in the trash or not a folder', () => {
expect(restoreTarget({ fromParentId: 'missing' }, drive)).toBeNull();
expect(restoreTarget({ fromParentId: 'old' }, drive)).toBeNull();
expect(restoreTarget({ fromParentId: 'keep' }, drive)).toBeNull();
});
});
describe('trashOriginPath', () => {
it('names the folder an item came from by its path', () => {
expect(trashOriginPath({ fromParentId: 'tax' }, drive)).toBe('/Documents/Taxes');
expect(trashOriginPath({ fromParentId: null }, drive)).toBe('/');
expect(trashOriginPath({ fromParentId: 'old' }, drive)).toBeNull();
});
it('reads the listing once for all the rows of the trash, not once per row', () => {
// A listing that counts how often it is gone through.
let reads = 0;
const counted = new Proxy([...drive], {
get(target, key, receiver) {
if (key === Symbol.iterator || key === 'find' || key === 'filter' || key === 'map' || key === 'some') reads++;
return Reflect.get(target, key, receiver);
},
});
const places = trashPlaces(counted);
const before = reads;
const rows = trashedItems(drive);
const paths = rows.map((item) => trashOriginPath(item, places));
expect(paths).toEqual(['/Documents/Taxes', '/Documents/Taxes', '/', '/']);
expect(reads).toBe(before);
expect(rows.map((item) => restoreTarget(item, places))).toEqual(['tax', 'tax', null, null]);
expect(reads).toBe(before);
});
});
+160
View File
@@ -0,0 +1,160 @@
import type { FileNode } from '../api/types';
import { isFolder } from '../api/files';
/*
* The Files trash, the webmail's convention (lib/files-trash.ts there), so
* both clients see and restore the same trash. Neither Stalwart 0.16 nor 1.0
* keeps deleted FileNodes: a destroy removes a node and everything below it
* for good. So the trash is a folder the clients manage: `.Trash` at the root
* of the user's own drive, hidden from every listing. Each delete puts its
* items into a folder of its own inside it, named after when it happened and
* which folder the items came from (empty for the root):
*
* /.Trash/1730112000000~<parentId>/report.pdf
*
* The name is the whole record; nothing else is stored. A drive someone
* shared has no trash of the user's: what is deleted there is gone.
*/
export const TRASH_FOLDER_NAME = '.Trash';
/** The trash folder of the user's own drive, if there is one yet. */
export function findTrashFolder(nodes: FileNode[]): FileNode | undefined {
return nodes.find((n) =>
(n.parentId ?? null) === null
&& n.name === TRASH_FOLDER_NAME
&& !n.isShared
&& !n.id.includes(':')
&& isFolder(n));
}
/**
* Whether naming a node `name` in `parentId` (create, rename, move or upload)
* would put it in the trash's place, `.Trash` at the root of the user's own
* drive: a folder there becomes the trash, and a file there keeps the trash
* from being made. `nodeId` is the node renamed or moved, if any; one of a
* shared drive stays in that drive.
*/
export function takesTrashName(name: string, parentId: string | null, nodeId?: string): boolean {
return name === TRASH_FOLDER_NAME && parentId === null && !(nodeId?.includes(':'));
}
/** The name of the folder one delete's items go into. */
export function trashBatchName(parentId: string | null, deletedAt: number = Date.now()): string {
return `${deletedAt}~${parentId ?? ''}`;
}
/** When a batch was deleted and from which folder (null: the root); null for any other name. */
export function parseTrashBatchName(name: string): { deletedAt: number; parentId: string | null } | null {
const match = /^(\d{10,})~([A-Za-z0-9_-]*)$/.exec(name);
if (!match) return null;
return { deletedAt: Number(match[1]), parentId: match[2] || null };
}
/** The trash folder and everything in it. */
export function trashSubtreeIds(nodes: FileNode[], trashId: string | undefined): Set<string> {
const ids = new Set<string>();
if (!trashId) return ids;
// One pass over the drive, then down the tree: a pass per level was slow
// for a deep trash in a large drive.
const children = new Map<string, string[]>();
for (const n of nodes) {
if (!n.parentId) continue;
const list = children.get(n.parentId);
if (list) list.push(n.id);
else children.set(n.parentId, [n.id]);
}
const queue = [trashId];
ids.add(trashId);
for (let i = 0; i < queue.length; i++) {
for (const child of children.get(queue[i]) ?? []) {
if (!ids.has(child)) {
ids.add(child);
queue.push(child);
}
}
}
return ids;
}
/** The nodes without the trash and what is in it: what every listing but the trash's own shows. */
export function withoutTrash(nodes: FileNode[]): FileNode[] {
const hidden = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id);
return hidden.size === 0 ? nodes : nodes.filter((n) => !hidden.has(n.id));
}
export interface TrashedItem {
node: FileNode;
/** The delete's folder in the trash; null for something put into the trash directly. */
batchId: string | null;
/** When it was deleted (ms); null when unknown. */
deletedAt: number | null;
/** The folder it came from; null for the root or when unknown. */
fromParentId: string | null;
}
/** What the trash holds, one entry per deleted item. */
export function trashedItems(nodes: FileNode[], trash: FileNode | undefined = findTrashFolder(nodes)): TrashedItem[] {
if (!trash) return [];
const items: TrashedItem[] = [];
for (const child of nodes.filter((n) => n.parentId === trash.id)) {
const batch = isFolder(child) ? parseTrashBatchName(child.name) : null;
if (!batch) {
items.push({ node: child, batchId: null, deletedAt: null, fromParentId: null });
continue;
}
for (const node of nodes.filter((n) => n.parentId === child.id)) {
items.push({ node, batchId: child.id, deletedAt: batch.deletedAt, fromParentId: batch.parentId });
}
}
return items;
}
/**
* What restoreTarget and trashOriginPath look up in a listing, worked out
* once for it: every row of the trash asks, and each time anew it cost a
* walk of the whole drive per row.
*/
export interface TrashPlaces {
byId: Map<string, FileNode>;
/** The trash and everything in it. */
hidden: Set<string>;
}
export function trashPlaces(nodes: FileNode[]): TrashPlaces {
return { byId: new Map(nodes.map((n) => [n.id, n])), hidden: trashSubtreeIds(nodes, findTrashFolder(nodes)?.id) };
}
function placesOf(from: FileNode[] | TrashPlaces): TrashPlaces {
return Array.isArray(from) ? trashPlaces(from) : from;
}
/**
* Where a restored item goes: the folder it came from while that is still a
* folder of the user's own drive outside the trash, the root otherwise.
*/
export function restoreTarget(item: Pick<TrashedItem, 'fromParentId'>, from: FileNode[] | TrashPlaces): string | null {
const parentId = item.fromParentId;
if (parentId === null) return null;
const { byId, hidden } = placesOf(from);
const folder = byId.get(parentId);
return folder && isFolder(folder) && !folder.isShared && !hidden.has(folder.id) ? parentId : null;
}
/**
* The path of the folder a deleted item came from ("/Documents/Taxes", "/"
* for the root); null when that folder is gone.
*/
export function trashOriginPath(item: Pick<TrashedItem, 'fromParentId'>, from: FileNode[] | TrashPlaces): string | null {
if (item.fromParentId === null) return '/';
const places = placesOf(from);
if (restoreTarget(item, places) === null) return null;
const { byId } = places;
const names: string[] = [];
const seen = new Set<string>();
for (let cur = byId.get(item.fromParentId); cur && !seen.has(cur.id); cur = cur.parentId ? byId.get(cur.parentId) : undefined) {
seen.add(cur.id);
names.unshift(cur.name);
}
return `/${names.join('/')}`;
}
+389 -60
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
Alert,
AppState,
FlatList,
Image,
Modal,
@@ -20,8 +21,9 @@ import {
ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo,
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
MoreVertical, Pencil, Share2, Trash2, Upload, Users, X, Download, Search,
ArrowUpDown, ArrowUp, ArrowDown, FolderInput, Copy, Check, Eye, ExternalLink, FileSymlink,
ArrowUpDown, ArrowUp, ArrowDown, FolderInput, Copy, Check, Eye, ExternalLink, FileSymlink, RotateCcw,
} from '../components/icons';
import { useFocusEffect } from '@react-navigation/native';
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import JSZip from 'jszip';
@@ -49,6 +51,14 @@ import {
getFileNameRules, getFileNodeDownloadUrl, getMaxSizeUpload, isFolder,
isSymlink, listFolder, moveFileNode, renameFileNode, supportsSharing, uploadFileBatch,
} from '../api/files';
import {
TrashUnavailableError, emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash, undoMoveToTrash,
type TrashMoveResult,
} from '../api/files-trash';
import {
TRASH_FOLDER_NAME, takesTrashName, trashOriginPath, trashPlaces, trashedItems, withoutTrash, type TrashedItem,
} from '../lib/files-trash';
import { toast } from '../stores/toast-store';
import { jmapClient } from '../api/jmap-client';
import { downloadAttachment, shareAttachment } from '../lib/email-export';
import { secureFetch } from '../lib/client-cert';
@@ -79,6 +89,8 @@ import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
// A file row carries the display name alongside the rest of the node.
interface FileRow extends FileNode {
displayName: string;
/** Set for a row of the trash: when it was deleted and where from. */
trash?: TrashedItem;
}
type Translate = TranslateFn;
@@ -146,6 +158,13 @@ function modifiedOf(node: FileNode): string | undefined {
return node.modified || node.created;
}
// The date a row shows and sorts by: in the trash, when it was deleted
// (like the webmail's trash).
function rowDateOf(row: FileRow): string | undefined {
if (row.trash) return row.trash.deletedAt != null ? new Date(row.trash.deletedAt).toISOString() : undefined;
return modifiedOf(row);
}
function fileNameProblemText(problem: FileNameProblem, t: Translate): string {
switch (problem.kind) {
case 'chars':
@@ -161,6 +180,11 @@ function fileNameProblemText(problem: FileNameProblem, t: Translate): string {
}
}
// Why ".Trash" can't go to the root (see takesTrashName).
function trashNameText(t: Translate): string {
return t('files.name_trash', '".Trash" is the name of the trash folder at the top of your files. Choose another name.');
}
// The files an upload could not store, the first three by name, and what the
// server said about the first one.
function uploadFailureDetail(failed: Array<{ name: string; error: unknown }>, t: Translate): string {
@@ -214,7 +238,11 @@ export default function FilesScreen() {
const uploadAbortRef = useRef<AbortController | null>(null);
const [batchBusy, setBatchBusy] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[] } | null>(null);
// The trash (lib/files-trash) is a place of its own, entered from the root.
const [inTrash, setInTrash] = useState(false);
// `noTrash`: the rows were meant for the trash, which could not be made.
const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[]; noTrash?: boolean } | null>(null);
const [confirmEmptyTrash, setConfirmEmptyTrash] = useState(false);
const [newFolderOpen, setNewFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
@@ -249,32 +277,51 @@ export default function FilesScreen() {
const uploading = uploadProgress != null;
const selectionMode = selection.size > 0;
const currentParentId = path.length === 0 ? null : path[path.length - 1].id;
// The webmail's `/.Trash` and everything in it stay out of every listing
// but the trash's own, hidden files shown or not.
const driveNodes = useMemo(() => withoutTrash(allNodes), [allNodes]);
const trashItems = useMemo(() => trashedItems(allNodes), [allNodes]);
// Looked up by every row of the trash; worked out once per listing.
const trashPlacesNow = useMemo(() => trashPlaces(allNodes), [allNodes]);
// Inside a folder another account shared with us, new folders and uploads
// go to that account, and only where its rights allow them.
const canCreateHere = mayCreateIn(currentParentId, allNodes);
// go to that account, and only where its rights allow them. Nothing is
// added to the trash other than by deleting.
const canCreateHere = !inTrash && mayCreateIn(currentParentId, driveNodes);
const rootLabel = t('files.title', 'Files');
const headerName = path.length === 0 ? rootLabel : path[path.length - 1].name;
const trashLabel = t('files.trash', 'Trash');
const headerName = inTrash ? trashLabel : path.length === 0 ? rootLabel : path[path.length - 1].name;
const sharingEnabled = supportsSharing();
// Only the latest read is shown: one started before an account switch or a
// write may finish after the next one.
const loadSeqRef = useRef(0);
const loadFiles = useCallback(
async (mode: 'initial' | 'refresh' = 'initial') => {
async (mode: 'initial' | 'refresh' | 'quiet' = 'initial') => {
const seq = ++loadSeqRef.current;
if (mode === 'refresh') setRefreshing(true);
else setLoading(true);
setError(null);
else if (mode === 'initial') setLoading(true);
if (mode !== 'quiet') setError(null);
try {
// One fetch across all accessible accounts: own files plus nodes other
// principals shared with us (tagged isShared, ids namespaced).
const nodes = await getAllFileNodesAcrossAccounts();
setAllNodes(nodes);
if (seq === loadSeqRef.current) setAllNodes(nodes);
} catch (e) {
setError(e instanceof Error ? e.message : t('files.download_error', 'Failed to load files'));
// A quiet read keeps what is on screen.
if (mode !== 'quiet' && seq === loadSeqRef.current) {
setError(e instanceof Error ? e.message : t('files.download_error', 'Failed to load files'));
}
} finally {
setLoading(false);
setRefreshing(false);
if (seq === loadSeqRef.current) {
setLoading(false);
setRefreshing(false);
}
}
},
[t],
);
const loadFilesRef = useRef(loadFiles);
loadFilesRef.current = loadFiles;
// Load on mount and reset the whole drive whenever the active account, or
// the server behind it, changes: the node cache, folder stack and
@@ -283,14 +330,19 @@ export default function FilesScreen() {
// (another port, or Stalwart reinstalled there), which the account id does
// not tell. Every new session reads the drive again.
const driveRef = useRef<DriveScope | undefined>(undefined);
const undoToastRef = useRef<string | null>(null);
useEffect(() => {
const { action, scope } = driveChange(driveRef.current, activeAccountId, session);
driveRef.current = scope;
if (action === 'keep') return;
if (action === 'reset') {
uploadAbortRef.current?.abort();
// The last delete's Undo belongs to the previous drive.
if (undoToastRef.current) toast.dismiss(undoToastRef.current);
undoToastRef.current = null;
setAllNodes([]);
setPath([]);
setInTrash(false);
setSelection(new Set());
setActionsTarget(null);
setShareTarget(null);
@@ -303,12 +355,39 @@ export default function FilesScreen() {
void loadFiles(action === 'reload' ? 'refresh' : 'initial');
}, [activeAccountId, session, loadFiles]);
// If we navigated into a folder that no longer exists (deleted, or its
// share was revoked), cut the stack back to the deepest ancestor that does.
// Read the drive again when the tab comes back into view or the app to the
// foreground: another device may have restored, moved or emptied what the
// rows on screen show. The first focus is the mount, which reads already.
const focusedRef = useRef(false);
const seenFocusRef = useRef(false);
useFocusEffect(
useCallback(() => {
focusedRef.current = true;
if (seenFocusRef.current && driveRef.current?.account) void loadFilesRef.current('quiet');
seenFocusRef.current = true;
return () => {
focusedRef.current = false;
};
}, []),
);
useEffect(() => {
let last = AppState.currentState;
const subscription = AppState.addEventListener('change', (next) => {
if (next === 'active' && last !== 'active' && focusedRef.current && driveRef.current?.account) {
void loadFilesRef.current('quiet');
}
last = next;
});
return () => subscription.remove();
}, []);
// If we navigated into a folder that no longer exists (deleted, moved to
// the trash on another device, or its share was revoked), cut the stack
// back to the deepest ancestor that does.
useEffect(() => {
if (allNodes.length === 0) return;
setPath((prev) => {
const byId = new Map(allNodes.map((n) => [n.id, n]));
const byId = new Map(driveNodes.map((n) => [n.id, n]));
let cut = prev.length;
for (let i = 0; i < prev.length; i++) {
const node = byId.get(prev[i].id);
@@ -319,16 +398,18 @@ export default function FilesScreen() {
}
return cut === prev.length ? prev : prev.slice(0, cut);
});
}, [allNodes]);
}, [allNodes, driveNodes]);
// Root: our own top-level nodes, plus the roots of every shared-with-me
// subtree; Stalwart 1.0's placeholder folders above them are left out.
const folderNodes = useMemo(() => listFolder(allNodes, currentParentId), [allNodes, currentParentId]);
const folderNodes = useMemo(() => listFolder(driveNodes, currentParentId), [driveNodes, currentParentId]);
const visibleFiles = useMemo<FileRow[]>(() => {
const q = searchQuery.trim().toLowerCase();
const rows = folderNodes
.map<FileRow>((n) => ({ ...n, displayName: n.name }))
const source: FileRow[] = inTrash
? trashItems.map((item) => ({ ...item.node, displayName: item.node.name, trash: item }))
: folderNodes.map((n) => ({ ...n, displayName: n.name }));
const rows = source
.filter((r) => showHiddenFiles || !r.displayName.startsWith('.'))
.filter((r) => !q || r.displayName.toLowerCase().includes(q));
const dir = sortDir === 'desc' ? -1 : 1;
@@ -344,8 +425,8 @@ export default function FilesScreen() {
case 'size':
return ((a.size ?? 0) - (b.size ?? 0)) * dir;
case 'modified': {
const am = modifiedOf(a) ? new Date(modifiedOf(a)!).getTime() : 0;
const bm = modifiedOf(b) ? new Date(modifiedOf(b)!).getTime() : 0;
const am = rowDateOf(a) ? new Date(rowDateOf(a)!).getTime() : 0;
const bm = rowDateOf(b) ? new Date(rowDateOf(b)!).getTime() : 0;
return (am - bm) * dir;
}
case 'name':
@@ -353,7 +434,7 @@ export default function FilesScreen() {
return a.displayName.localeCompare(b.displayName) * dir;
}
});
}, [folderNodes, searchQuery, showHiddenFiles, sortKey, sortDir]);
}, [inTrash, trashItems, folderNodes, searchQuery, showHiddenFiles, sortKey, sortDir]);
const clearSelection = useCallback(() => setSelection(new Set()), []);
@@ -468,6 +549,13 @@ export default function FilesScreen() {
toggleSelect(row);
return;
}
// In the trash a file can be looked at; a folder is restored before
// it is opened. Everything else is in the row's actions.
if (row.trash) {
if (!isFolder(row) && !isSymlink(row) && canPreviewInApp(row)) setPreviewTarget(row);
else setActionsTarget(row);
return;
}
if (isFolder(row)) {
setPath((p) => [...p, { id: row.id, name: row.displayName }]);
setSearchQuery('');
@@ -486,17 +574,25 @@ export default function FilesScreen() {
}, [toggleSelect]);
const goBack = useCallback(() => {
setPath((p) => (p.length > 0 ? p.slice(0, -1) : p));
if (inTrash) setInTrash(false);
else setPath((p) => (p.length > 0 ? p.slice(0, -1) : p));
setSearchQuery('');
clearSelection();
}, [inTrash, clearSelection]);
const openTrash = useCallback(() => {
setInTrash(true);
setSearchQuery('');
clearSelection();
}, [clearSelection]);
// Android back closes the preview, then leaves selection mode, then goes
// up one folder; only at the root does it switch to the Mail tab.
// up one folder (out of the trash, which opens from the root); only at the
// root does it switch to the Mail tab.
const backStep = filesBackStep({
previewOpen: previewTarget != null,
selecting: selectionMode,
folderDepth: path.length,
folderDepth: inTrash ? 1 : path.length,
});
const handleBack = useCallback(() => {
if (backStep === 'close-preview') setPreviewTarget(null);
@@ -517,12 +613,16 @@ export default function FilesScreen() {
// Explains a name the server would refuse before anything is sent: the
// naming rules it publishes (Stalwart 0.16.6+) with 1.0's stricter ones on
// top, or at least the "/" every server refuses. True when the name can go.
const checkName = useCallback((name: string): boolean => {
// top, or at least the "/" every server refuses. `.Trash` is refused at the
// root, where it would become the trash (lib/files-trash). True when the
// name can go.
const checkName = useCallback((name: string, parentId: string | null, nodeId?: string): boolean => {
const problem = fileNameProblem(name, getFileNameRules());
const message = problem
? fileNameProblemText(problem, t)
: name.includes('/') ? t('files.invalid_name_slash', 'Names cannot contain "/".') : null;
: name.includes('/') ? t('files.invalid_name_slash', 'Names cannot contain "/".')
: takesTrashName(name, parentId, nodeId) ? trashNameText(t)
: null;
if (message) Alert.alert(t('files.invalid_name', 'Invalid name'), message);
return !message;
}, [t]);
@@ -530,7 +630,7 @@ export default function FilesScreen() {
const submitNewFolder = useCallback(async () => {
const name = newFolderName.trim();
if (!name) return;
if (!checkName(name)) return;
if (!checkName(name, currentParentId)) return;
setNewFolderOpen(false);
setNewFolderName('');
try {
@@ -548,7 +648,7 @@ export default function FilesScreen() {
setRenameTarget(null);
return;
}
if (!checkName(name)) return;
if (!checkName(name, renameTarget.parentId ?? null, renameTarget.id)) return;
const target = renameTarget;
setRenameTarget(null);
setRenameValue('');
@@ -579,6 +679,10 @@ export default function FilesScreen() {
const performMove = useCallback(async (row: FileRow, destinationId: string | null) => {
setMoveTarget(null);
if ((row.parentId ?? null) === destinationId) return;
if (takesTrashName(row.name, destinationId, row.id)) {
Alert.alert(t('files.move_error', 'Failed to move'), trashNameText(t));
return;
}
setBusyId(row.id);
try {
await moveFileNode(row.id, destinationId);
@@ -643,6 +747,8 @@ export default function FilesScreen() {
uploadAbortRef.current = controller;
const parentId = currentParentId;
const siblings = allNodes.filter((n) => (n.parentId ?? null) === parentId).map((n) => n.name);
// A file picked as ".Trash" gets a number at the root, where the name is the trash's.
if (takesTrashName(TRASH_FOLDER_NAME, parentId)) siblings.push(TRASH_FOLDER_NAME);
// A refused file no longer stops the rest; the ones that failed are
// reported once the batch is done.
const { failed } = await uploadFileBatch(accepted, parentId, siblings, {
@@ -665,10 +771,52 @@ export default function FilesScreen() {
}
}, [uploading, currentParentId, allNodes, loadFiles, t]);
// Undo of a delete: the items go back from the trash the way Restore puts
// them back, judged against the drive as it is by then. Nothing happens
// once another account is signed in (see undoMoveToTrash).
const undoTrash = useCallback(async (moved: TrashMoveResult) => {
undoToastRef.current = null;
try {
const result = await undoMoveToTrash(moved);
if (!result) return;
if (result.done.length > 0) toast.success(t('files.undo_success', 'Action undone'));
} catch (e) {
toast.error(t('files.undo_error', 'Failed to undo'), fileErrorMessage(e, t));
}
await loadFiles('refresh');
}, [loadFiles, t]);
// Delete in the user's own drive moves into the trash, with Undo and no
// question, like the webmail. A drive someone shared has no trash of the
// user's, and in the trash a delete is for good: those ask first.
const trashRows = useCallback(async (rows: FileRow[]) => {
clearSelection();
const listing = allNodes;
try {
const moved = await moveToTrash(listing, rows);
const { done, refused } = moved;
await loadFiles('refresh');
if (refused.length > 0) {
Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(refused[0], t));
}
undoToastRef.current = toast.success(
t('files.moved_to_trash', '{count, plural, one {Moved to trash} other {# items moved to trash}}', { count: done.length }),
{ action: { label: t('files.undo', 'Undo'), onPress: () => void undoTrash(moved) } },
);
} catch (e) {
// No trash to be had here (a file named .Trash at the root, no right
// to create folders there, a full quota): offer to delete for good.
if (e instanceof TrashUnavailableError) setConfirmDelete({ rows, noTrash: true });
else Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t));
await loadFiles('refresh');
}
}, [allNodes, clearSelection, loadFiles, undoTrash, t]);
const requestDelete = useCallback((rows: FileRow[]) => {
if (rows.length === 0) return;
setConfirmDelete({ rows });
}, []);
if (inTrash || rows.some((r) => r.isShared)) setConfirmDelete({ rows });
else void trashRows(rows);
}, [inTrash, trashRows]);
const performDelete = useCallback(async () => {
if (!confirmDelete) return;
@@ -676,19 +824,58 @@ export default function FilesScreen() {
setConfirmDelete(null);
clearSelection();
try {
// The server removes folder descendants (onDestroyRemoveChildren).
await deleteFileNodes(targets.map((t) => t.id));
await loadFiles('refresh');
const fromTrash = targets.flatMap((r) => (r.trash ? [r.trash] : []));
// The server removes folder descendants (onDestroyRemoveChildren). In
// the trash only what is still there goes (see purgeFromTrash).
let count = targets.length;
if (fromTrash.length > 0) count = await purgeFromTrash(fromTrash);
else await deleteFileNodes(targets.map((r) => r.id));
if (count > 0) {
toast.success(t('files.deleted_forever', '{count, plural, one {Deleted permanently} other {# items deleted permanently}}', {
count,
}));
}
} catch (e) {
Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t));
}
await loadFiles('refresh');
}, [confirmDelete, clearSelection, loadFiles, t]);
const restoreRows = useCallback(async (rows: FileRow[]) => {
const items = rows.flatMap((r) => (r.trash ? [r.trash] : []));
if (items.length === 0) return;
clearSelection();
try {
const { done, refused } = await restoreFromTrash(items);
if (refused.length > 0) {
Alert.alert(t('files.restore_error', "Couldn't restore"), fileErrorMessage(refused[0], t));
}
if (done.length > 0) {
toast.success(t('files.restored', '{count, plural, one {Item restored} other {# items restored}}', { count: done.length }));
}
} catch (e) {
Alert.alert(t('files.restore_error', "Couldn't restore"), fileErrorMessage(e, t));
}
await loadFiles('refresh');
}, [clearSelection, loadFiles, t]);
const performEmptyTrash = useCallback(async () => {
setConfirmEmptyTrash(false);
clearSelection();
try {
await emptyTrash();
toast.success(t('files.trash_emptied', 'Trash emptied'));
} catch (e) {
Alert.alert(t('files.empty_trash_error', "Couldn't empty the trash"), fileErrorMessage(e, t));
}
await loadFiles('refresh');
}, [clearSelection, loadFiles, t]);
const sortLabel = t(`files.${sortKey}`, sortKey);
const SortDirIcon = sortDir === 'desc' ? ArrowDown : ArrowUp;
const renderHeader = () => {
const canBack = path.length > 0;
const canBack = path.length > 0 || inTrash;
const ToggleIcon = viewMode === 'list' ? LayoutGrid : ListIcon;
if (selectionMode) {
const selectedRows = visibleFiles.filter((r) => selection.has(r.id));
@@ -708,24 +895,35 @@ export default function FilesScreen() {
{t('files.selected_count', '{count, plural, one {# selected} other {# selected}}', { count: selection.size })}
</Text>
<View style={styles.headerActions}>
<Pressable
onPress={() => void downloadSelection(selectedRows)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel={t('files.download', 'Download')}
disabled={batchBusy != null}
>
{batchBusy != null ? (
<ActivityIndicator size="small" color={c.text} />
) : (
<Download size={20} color={c.text} />
)}
</Pressable>
{inTrash ? (
<Pressable
onPress={() => void restoreRows(selectedRows)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel={t('files.restore', 'Restore')}
>
<RotateCcw size={20} color={c.text} />
</Pressable>
) : (
<Pressable
onPress={() => void downloadSelection(selectedRows)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel={t('files.download', 'Download')}
disabled={batchBusy != null}
>
{batchBusy != null ? (
<ActivityIndicator size="small" color={c.text} />
) : (
<Download size={20} color={c.text} />
)}
</Pressable>
)}
<Pressable
onPress={() => requestDelete(selectedRows)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel={t('files.delete', 'Delete')}
accessibilityLabel={inTrash ? t('files.delete_forever', 'Delete permanently') : t('files.delete', 'Delete')}
>
<Trash2 size={20} color={c.error} />
</Pressable>
@@ -798,11 +996,22 @@ export default function FilesScreen() {
>
<ToggleIcon size={20} color={c.text} />
</Pressable>
{path.length === 0 && !inTrash ? (
<Pressable
onPress={openTrash}
hitSlop={8}
style={styles.headerBtn}
accessibilityRole="button"
accessibilityLabel={trashLabel}
>
<Trash2 size={20} color={c.text} />
</Pressable>
) : null}
</View>
</View>
{canBack ? (
<Text style={styles.breadcrumb} numberOfLines={1}>
{[rootLabel, ...path.map((p) => p.name)].join(' / ')}
{[rootLabel, ...(inTrash ? [trashLabel] : path.map((p) => p.name))].join(' / ')}
</Text>
) : null}
<View style={styles.toolbarRow}>
@@ -819,6 +1028,19 @@ export default function FilesScreen() {
clearButtonMode="while-editing"
/>
) : <View style={{ flex: 1 }} />}
{inTrash ? (
<Pressable
onPress={() => setConfirmEmptyTrash(true)}
hitSlop={6}
style={[styles.sortChip, trashItems.length === 0 && { opacity: 0.5 }]}
disabled={trashItems.length === 0}
accessibilityRole="button"
accessibilityState={{ disabled: trashItems.length === 0 }}
>
<Trash2 size={13} color={c.error} />
<Text style={[styles.sortChipText, { color: c.error }]}>{t('files.empty_trash', 'Empty trash')}</Text>
</Pressable>
) : null}
<Pressable
onPress={() => setSortSheetOpen(true)}
hitSlop={6}
@@ -854,6 +1076,15 @@ export default function FilesScreen() {
);
};
// Where a deleted item came from, on its row in the trash.
const trashOrigin = (item: TrashedItem): string | null => {
const from = trashOriginPath(item, trashPlacesNow);
if (from === null) return null;
return t('files.deleted_from', 'Deleted from {folder}', {
folder: from === '/' ? t('files.my_files', 'My files') : from,
});
};
const renderIcon = (item: FileRow, size: number) => {
const isDir = isFolder(item);
const thumb = !isDir && showThumbnails ? thumbnailSource(item) : null;
@@ -881,7 +1112,8 @@ export default function FilesScreen() {
const selected = selection.has(item.id);
const busy = busyId === item.id;
const sharedOut = !!item.shareWith && Object.keys(item.shareWith).length > 0;
const modified = modifiedOf(item);
const modified = rowDateOf(item);
const origin = item.trash ? trashOrigin(item.trash) : null;
return (
<Pressable
onPress={() => handleRowPress(item)}
@@ -906,6 +1138,9 @@ export default function FilesScreen() {
) : null}
</View>
<View style={styles.fileMetaRow}>
{origin ? (
<Text style={[styles.fileMeta, styles.fileMetaShrink]} numberOfLines={1}>{origin}</Text>
) : null}
{item.isShared && item.accountName ? (
<Text style={styles.fileMeta} numberOfLines={1}>
{t('files.shared_by', 'Shared by {name}', { name: item.accountName })}
@@ -1004,6 +1239,16 @@ export default function FilesScreen() {
</TouchableOpacity>
</View>
);
} else if (visibleFiles.length === 0 && inTrash && !searchQuery.trim()) {
body = (
<View style={styles.center}>
<Trash2 size={48} color={c.textMuted} />
<Text style={styles.emptyText}>{t('files.trash_empty_title', 'Trash is empty')}</Text>
<Text style={styles.emptyHint}>
{t('files.trash_empty_hint', 'Deleted files stay here until you empty the trash.')}
</Text>
</View>
);
} else if (visibleFiles.length === 0) {
body = (
<View style={styles.center}>
@@ -1133,11 +1378,15 @@ export default function FilesScreen() {
setActionsTarget(null);
requestDelete([r]);
}}
onRestore={(r) => {
setActionsTarget(null);
void restoreRows([r]);
}}
/>
<FolderPickerSheet
target={moveTarget}
allNodes={allNodes}
allNodes={driveNodes}
rootLabel={rootLabel}
t={t}
onClose={() => setMoveTarget(null)}
@@ -1164,18 +1413,41 @@ export default function FilesScreen() {
<Dialog
visible={confirmDelete != null}
title={
confirmDelete && confirmDelete.rows.length > 1
? t('files.delete_items_title', '{count, plural, one {Delete # item?} other {Delete # items?}}', { count: confirmDelete.rows.length })
: t('files.delete_item_title', 'Delete this item?')
title={t('files.delete_forever_title', '{count, plural, one {Delete permanently?} other {Delete # items permanently?}}', {
count: confirmDelete?.rows.length ?? 1,
})}
message={
confirmDelete?.noTrash
? t('files.trash_unavailable_message', "{count, plural, one {This item} other {These # items}} can't be moved to the trash. Delete permanently instead? You can't undo this.", {
count: confirmDelete.rows.length,
})
: confirmDelete && confirmDelete.rows.length > 1
? t('files.delete_forever_message_count', "{count, plural, one {1 item} other {# items}} will be deleted. You can't undo this.", {
count: confirmDelete.rows.length,
})
: t('files.delete_forever_message', "\"{name}\" will be deleted. You can't undo this.", {
name: confirmDelete?.rows[0]?.displayName ?? '',
})
}
message={t('files.delete_cascade_hint', "Folders are deleted along with everything inside them. This can't be undone.")}
variant="destructive"
confirmText={t('files.delete', 'Delete')}
confirmText={t('files.delete_forever', 'Delete permanently')}
cancelText={t('files.cancel', 'Cancel')}
onConfirm={() => void performDelete()}
onCancel={() => setConfirmDelete(null)}
/>
<Dialog
visible={confirmEmptyTrash}
title={t('files.empty_trash_title', 'Empty trash?')}
message={t('files.empty_trash_message', "{count, plural, one {The item in the trash} other {All # items in the trash}} will be deleted permanently. You can't undo this.", {
count: trashItems.length,
})}
variant="destructive"
confirmText={t('files.empty_trash', 'Empty trash')}
cancelText={t('files.cancel', 'Cancel')}
onConfirm={() => void performEmptyTrash()}
onCancel={() => setConfirmEmptyTrash(false)}
/>
</View>
);
}
@@ -1252,10 +1524,13 @@ interface ActionsSheetProps {
onDuplicate: (r: FileRow) => void;
onRename: (r: FileRow) => void;
onDelete: (r: FileRow) => void;
/** For a row of the trash: put it back where it came from. */
onRestore: (r: FileRow) => void;
}
function ActionsSheet({
target, access, sharingEnabled, t, onClose, onPreview, onOpenWith, onDownload, onShare, onMove, onDuplicate, onRename, onDelete,
onRestore,
}: ActionsSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
@@ -1272,7 +1547,52 @@ function ActionsSheet({
// nodes are shared on by their owner, and get the writes their rights
// allow (`access`).
const canShare = sharingEnabled && !link && !target.isShared && (target.myRights?.mayShare ?? true);
const modified = modifiedOf(target);
const modified = rowDateOf(target);
// A deleted item can be restored, looked at or deleted for good, nothing else.
if (target.trash) {
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{target.displayName}
</Text>
<Text style={styles.sheetMeta} numberOfLines={1}>
{[
isDir ? t('files.folder', 'Folder') : link ? t('files.symlink', 'Symbolic link') : (target.type || t('files.file', 'File')),
isFile && target.size != null ? formatFileSize(target.size) : null,
modified ? `${t('files.deleted', 'Deleted')} ${formatModified(modified)}` : null,
].filter(Boolean).join(' · ')}
</Text>
<Pressable style={styles.action} onPress={() => onRestore(target)}>
<RotateCcw size={18} color={c.text} />
<Text style={styles.actionLabel}>{t('files.restore', 'Restore')}</Text>
</Pressable>
{isFile && canPreviewInApp(target) ? (
<Pressable style={styles.action} onPress={() => onPreview(target)}>
<Eye size={18} color={c.text} />
<Text style={styles.actionLabel}>{t('files.preview', 'Preview')}</Text>
</Pressable>
) : null}
{isFile ? (
<Pressable style={styles.action} onPress={() => onDownload(target)}>
<Download size={18} color={c.text} />
<Text style={styles.actionLabel}>{t('files.download', 'Download')}</Text>
</Pressable>
) : null}
<Pressable style={styles.action} onPress={() => onDelete(target)}>
<Trash2 size={18} color={c.error} />
<Text style={[styles.actionLabel, { color: c.error }]}>{t('files.delete_forever', 'Delete permanently')}</Text>
</Pressable>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
@@ -1612,6 +1932,12 @@ function makeStyles(c: ThemePalette) {
fontSize: typography.body.fontSize,
marginTop: spacing.sm,
},
emptyHint: {
color: c.textMuted,
fontSize: typography.caption.fontSize,
textAlign: 'center',
paddingHorizontal: spacing.lg,
},
errorText: {
color: c.error,
fontSize: typography.body.fontSize,
@@ -1660,6 +1986,9 @@ function makeStyles(c: ThemePalette) {
fontSize: typography.caption.fontSize,
color: c.textMuted,
},
fileMetaShrink: {
flexShrink: 1,
},
rowMore: {
width: componentSizes.buttonLg,
height: componentSizes.buttonLg,
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import type { FileNode } from '../../api/types';
import { isFolder } from '../../api/files';
import * as trash from '../../lib/files-trash';
import { recentFileItems } from '../build';
const dir = (id: string, parentId: string | null, name: string, modified: string): FileNode =>
({ id, parentId, name, type: '', blobId: null, modified });
const file = (id: string, parentId: string | null, name: string, modified: string): FileNode =>
({ id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 1, modified });
describe('recentFileItems', () => {
it('leaves the Files trash and everything in it out', () => {
const nodes = [
dir('docs', null, 'Documents', '2026-10-01T10:00:00Z'),
file('keep', 'docs', 'keep.txt', '2026-10-02T10:00:00Z'),
// The trash and a delete's folder are newer than anything else.
dir('t', null, '.Trash', '2026-10-28T10:00:00Z'),
dir('b1', 't', '1761700000000~docs', '2026-10-28T10:00:00Z'),
file('gone', 'b1', 'deleted.txt', '2026-10-27T10:00:00Z'),
];
expect(recentFileItems(nodes, { isFolder }, trash).map((i) => i.id)).toEqual(['keep', 'docs']);
});
});
+28 -15
View File
@@ -9,7 +9,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { jmapClient } from '../api/jmap-client';
import { CAPABILITIES, type CalendarEvent, type ContactCard } from '../api/types';
import { CAPABILITIES, type CalendarEvent, type ContactCard, type FileNode } from '../api/types';
import { generateAccountId } from '../lib/account-utils';
import { generateEmailAvatarColor, getEmailInitials } from '../lib/avatar-utils';
import { getEventEndDate, getEventStartDate } from '../lib/calendar-utils';
@@ -306,24 +306,37 @@ async function buildTasks(previous: WidgetSnapshot, accountId: string, reload: b
}
}
/**
* The Recent files widget's rows: the newest nodes of the user's own drive.
* The Files trash (lib/files-trash) and what is in it stay out, as they do
* in the Files tab. The helpers come in as arguments because they are loaded
* only once the widget needs them.
*/
export function recentFileItems(
nodes: FileNode[],
files: Pick<typeof import('../api/files'), 'isFolder'>,
trash: Pick<typeof import('../lib/files-trash'), 'withoutTrash'>,
): FileItem[] {
return trash.withoutTrash(nodes)
.filter((n) => !!n.name)
.map((n) => ({
id: n.id,
name: n.name,
isFolder: files.isFolder(n),
size: n.size ?? 0,
modified: Date.parse(n.modified || n.created || '') || 0,
type: n.type || '',
}))
.sort((a, b) => b.modified - a.modified)
.slice(0, 6);
}
async function buildFiles(previous: WidgetSnapshot): Promise<WidgetSnapshot['files']> {
const files = require('../api/files') as typeof import('../api/files');
if (!files.supportsFiles()) return { supported: false, items: [] };
try {
const nodes = await files.getAllFileNodes();
const items: FileItem[] = nodes
.filter((n) => !!n.name)
.map((n) => ({
id: n.id,
name: n.name,
isFolder: files.isFolder(n),
size: n.size ?? 0,
modified: Date.parse(n.modified || n.created || '') || 0,
type: n.type || '',
}))
.sort((a, b) => b.modified - a.modified)
.slice(0, 6);
return { supported: true, items };
const trash = require('../lib/files-trash') as typeof import('../lib/files-trash');
return { supported: true, items: recentFileItems(await files.getAllFileNodes(), files, trash) };
} catch {
return previous.files;
}