From 7c9141833606b61d6922c3b28764aa37a047fe33 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:30:36 +0100 Subject: [PATCH 01/11] feat: keep deleted files in a Files trash with Restore, Delete permanently and Empty trash --- locales/ar/common.json | 21 +- locales/ca/common.json | 21 +- locales/cs/common.json | 21 +- locales/da/common.json | 21 +- locales/de/common.json | 21 +- locales/en/common.json | 21 +- locales/es/common.json | 21 +- locales/et/common.json | 21 +- locales/fa/common.json | 21 +- locales/fr/common.json | 21 +- locales/he/common.json | 21 +- locales/hu/common.json | 21 +- locales/it/common.json | 21 +- locales/ja/common.json | 21 +- locales/ko/common.json | 21 +- locales/lv/common.json | 21 +- locales/mn/common.json | 21 +- locales/nb/common.json | 21 +- locales/nl/common.json | 21 +- locales/pl/common.json | 21 +- locales/pt/common.json | 21 +- locales/ro/common.json | 21 +- locales/ru/common.json | 21 +- locales/sk/common.json | 21 +- locales/tr/common.json | 21 +- locales/uk/common.json | 21 +- locales/zh-TW/common.json | 21 +- locales/zh/common.json | 21 +- src/api/__tests__/files-trash.test.ts | 240 +++++++++++++ src/api/__tests__/live-stalwart-files.test.ts | 176 +++++++++ src/api/files-trash.ts | 168 +++++++++ src/api/files.ts | 74 ++++ src/lib/__tests__/files-trash.test.ts | 98 +++++ src/lib/files-trash.ts | 121 +++++++ src/screens/FilesScreen.tsx | 338 +++++++++++++++--- 35 files changed, 1729 insertions(+), 74 deletions(-) create mode 100644 src/api/__tests__/files-trash.test.ts create mode 100644 src/api/files-trash.ts create mode 100644 src/lib/__tests__/files-trash.test.ts create mode 100644 src/lib/files-trash.ts diff --git a/locales/ar/common.json b/locales/ar/common.json index d300aa76..449709e5 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -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": "شهاداتك", diff --git a/locales/ca/common.json b/locales/ca/common.json index c20ba9e6..792ada0f 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index 4dfa844f..aa55b93f 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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", diff --git a/locales/da/common.json b/locales/da/common.json index 395e8d5c..97712d01 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index 7341f54c..94196fa4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index f7f38617..f88fc58d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index e609f900..2a8185ef 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/et/common.json b/locales/et/common.json index 527d286f..5f0cfe91 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index 189db08c..0fc8112c 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -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": "گواهی‌های شما", diff --git a/locales/fr/common.json b/locales/fr/common.json index b672cd3a..79a998cd 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 96a7e3a2..1e9d9b5f 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "התעודות שלך", diff --git a/locales/hu/common.json b/locales/hu/common.json index 29fd5076..a8bd838b 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index 487e54f6..891eefa6 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7a4f2a30..b8ce6443 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "あなたの証明書", diff --git a/locales/ko/common.json b/locales/ko/common.json index b5db2cbb..6f9db746 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "내 인증서", diff --git a/locales/lv/common.json b/locales/lv/common.json index 82e05f54..0e45ff75 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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", diff --git a/locales/mn/common.json b/locales/mn/common.json index 7bf1f9cb..0f4be07f 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -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": "Таны сертификатууд", diff --git a/locales/nb/common.json b/locales/nb/common.json index 309a8485..d2dc9f9a 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index b2c6c434..e8bd2e45 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index dcca6570..3726837d 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 0289a36d..004060fc 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index 3fed4915..b88c7fb1 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 60e741ca..ea8d49ab 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "Ваши сертификаты", diff --git a/locales/sk/common.json b/locales/sk/common.json index 0de128b4..beb001f5 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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", diff --git a/locales/tr/common.json b/locales/tr/common.json index 48de33e0..c22ba818 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index 2eef54e2..6501e429 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -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": "Ваші сертифікати", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 9b283a04..d562c927 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -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": "您的憑證", diff --git a/locales/zh/common.json b/locales/zh/common.json index a462f90e..cb49c06b 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "您的证书", diff --git a/src/api/__tests__/files-trash.test.ts b/src/api/__tests__/files-trash.test.ts new file mode 100644 index 00000000..6cdda50f --- /dev/null +++ b/src/api/__tests__/files-trash.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + request: vi.fn(), + hasCapability: vi.fn(() => true), + hasAccountCapability: vi.fn(() => false), + getMaxObjectsInGet: vi.fn(() => 500), + getMaxObjectsInSet: vi.fn(() => 500), + getMaxCallsInRequest: vi.fn(() => 16), + getAccountCapability: vi.fn(), + currentSession: { + primaryAccounts: { 'urn:ietf:params:jmap:filenode': 'acc-1' }, + accounts: { 'acc-1': { name: 'me', isPersonal: true }, 'acc-2': { name: 'them', isPersonal: false } }, + }, + }, +})); + +import { jmapClient } from '../jmap-client'; +import type { FileNode } from '../types'; +import { emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash } from '../files-trash'; +import { trashedItems } from '../../lib/files-trash'; + +// A Stalwart-like FileNode store for the user's own account: destroy without +// onDestroyRemoveChildren refuses a folder that holds anything. +type Node = { id: string; parentId: string | null; name: string; blobId: string | null; type?: string }; +let store: Node[]; +let nextId: number; +let calls: Array>; +let refuseUpdate: Set; + +function serve(method: string, args: Record) { + if (method === 'FileNode/get') return { list: store.map((n) => ({ ...n })) }; + calls.push(args); + const out: Record = {}; + for (const [key, props] of Object.entries((args.create ?? {}) as Record)) { + 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)) { + const node = store.find((n) => n.id === id); + if (!node || refuseUpdate.has(id)) { + (out.notUpdated ??= {})[id] = { type: 'forbidden', description: 'no' }; + continue; + } + Object.assign(node, patch); + (out.updated ??= {})[id] = null; + } + for (const id of (args.destroy ?? []) as string[]) { + if (!store.some((n) => n.id === id)) { + (out.notDestroyed ??= {})[id] = { type: 'notFound' }; + continue; + } + if (!args.onDestroyRemoveChildren && store.some((n) => n.parentId === id)) { + (out.notDestroyed ??= {})[id] = { type: 'nodeHasChildren' }; + continue; + } + const gone = new Set([id]); + for (let grew = true; grew;) { + grew = false; + for (const n of store) if (n.parentId && gone.has(n.parentId) && !gone.has(n.id)) { gone.add(n.id); grew = true; } + } + store = store.filter((n) => !gone.has(n.id)); + (out.destroyed ??= []).push(id); + } + return out; +} + +const listed = (): FileNode[] => store.map((n) => ({ ...n, type: n.type ?? '' }) as FileNode); +const byId = (id: string) => store.find((n) => n.id === id); + +beforeEach(() => { + nextId = 1; + calls = []; + refuseUpdate = new Set(); + store = [ + { id: 'docs', parentId: null, name: 'Docs', blobId: null }, + { id: 'a', parentId: 'docs', name: 'a.txt', blobId: 'ba' }, + { id: 'b', parentId: 'docs', name: 'b.txt', blobId: 'bb' }, + { id: 'top', parentId: null, name: 'top.txt', blobId: 'bt' }, + ]; + (jmapClient.request as ReturnType).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).getMockImplementation() as (calls: any[]) => Promise; + (jmapClient.request as ReturnType).mockImplementation(async (methodCalls: any[]) => { + const create = methodCalls[0][1].create as Record | undefined; + const parent = create && Object.values(create)[0].parentId; + if (parent && !byId(parent)) { + return { methodResponses: [['FileNode/set', { notCreated: { 'new-dir': { type: 'invalidProperties' } } }, '0']] }; + } + return real(methodCalls); + }); + await moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000001); + expect(byId(byId('a')!.parentId!)!.parentId).toBe('T2'); + }); + + it('refuses nodes of a shared drive', async () => { + const shared = { id: 'acc-2:x', parentId: null, name: 'x', type: '', blobId: 'bx', isShared: true } as FileNode; + await expect(moveToTrash([shared], [shared])).rejects.toThrow(); + expect(calls).toEqual([]); + }); + + it('throws the refusal and leaves no empty trash folder when nothing went', async () => { + refuseUpdate.add('a'); + const nodes = listed(); + await expect(moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000005)).rejects.toThrow('no'); + expect(store.some((n) => n.name === '1730112000005~docs')).toBe(false); + expect(byId('a')!.parentId).toBe('docs'); + const destroy = calls.find((c) => c.destroy); + expect(destroy?.onDestroyRemoveChildren).toBeUndefined(); + }); + + it('reports a partial refusal and keeps what went', async () => { + refuseUpdate.add('b'); + const nodes = listed(); + const { done, refused } = await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000005); + expect(done.map((i) => i.node.id)).toEqual(['a']); + expect(refused).toHaveLength(1); + }); +}); + +describe('restoreFromTrash', () => { + it('puts items back where they came from and removes their empty trash folder', async () => { + let nodes = listed(); + await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007); + nodes = listed(); + const batch = store.find((n) => n.name === '1730112000007~docs')!; + await restoreFromTrash(nodes, trashedItems(nodes)); + expect(byId('a')).toMatchObject({ parentId: 'docs', name: 'a.txt' }); + expect(byId('b')).toMatchObject({ parentId: 'docs', name: 'b.txt' }); + expect(byId(batch.id)).toBeUndefined(); + // No rename was sent along with the move. + const update = calls.filter((c) => c.update).pop()!.update as Record>; + expect(update.a).toEqual({ parentId: 'docs' }); + }); + + it('keeps a trash folder that other items still hold', async () => { + let nodes = listed(); + await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007); + nodes = listed(); + const batch = store.find((n) => n.name === '1730112000007~docs')!; + await restoreFromTrash(nodes, trashedItems(nodes).filter((i) => i.node.id === 'a')); + expect(byId(batch.id)).toBeDefined(); + expect(byId('b')!.parentId).toBe(batch.id); + }); + + it('gives a name taken by now a free one, and sends the root for a folder that is gone', async () => { + let nodes = listed(); + await moveToTrash(nodes, nodes.filter((n) => n.id === 'a'), 1730112000007); + store.push({ id: 'a2', parentId: 'docs', name: 'a.txt', blobId: 'ba2' }); + nodes = listed(); + await moveToTrash(nodes, nodes.filter((n) => n.id === 'docs'), 1730112000008); + nodes = listed(); + // Docs is in the trash itself now: a.txt goes to the root. + await restoreFromTrash(nodes, trashedItems(nodes).filter((i) => i.node.id === 'a')); + expect(byId('a')).toMatchObject({ parentId: null, name: 'a.txt' }); + + nodes = listed(); + await restoreFromTrash(nodes, trashedItems(nodes).filter((i) => i.node.id === 'docs')); + await moveToTrash(listed(), listed().filter((n) => n.id === 'a2'), 1730112000009); + store.push({ id: 'a3', parentId: 'docs', name: 'a.txt', blobId: 'ba3' }); + nodes = listed(); + await restoreFromTrash(nodes, trashedItems(nodes).filter((i) => i.node.id === 'a2')); + expect(byId('a2')).toMatchObject({ parentId: 'docs', name: 'a (1).txt' }); + }); + + it('undoes a delete with the listing from before it, without renaming', async () => { + const before = listed(); + const { done } = await moveToTrash(before, before.filter((n) => n.parentId === 'docs'), 1730112000007); + await restoreFromTrash(before, done); + expect(byId('a')).toMatchObject({ parentId: 'docs', name: 'a.txt' }); + expect(byId('b')).toMatchObject({ parentId: 'docs', name: 'b.txt' }); + expect(store.some((n) => n.name === '1730112000007~docs')).toBe(false); + }); +}); + +describe('purgeFromTrash and emptyTrash', () => { + it('deletes items for good with what is inside, and their emptied trash folder', async () => { + let nodes = listed(); + await moveToTrash(nodes, nodes.filter((n) => n.id === 'docs'), 1730112000007); + nodes = listed(); + await purgeFromTrash(nodes, trashedItems(nodes)); + expect(store.map((n) => n.id).sort()).toEqual(['n1', 'top']); + expect(byId('n1')!.name).toBe('.Trash'); + }); + + it('empties the trash, everything in it included', async () => { + let nodes = listed(); + await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007); + nodes = listed(); + await emptyTrash(nodes); + expect(store.map((n) => n.id).sort()).toEqual(['docs', 'top']); + }); + + it('empties a trash made after the listing on screen', async () => { + const stale = listed(); + await moveToTrash(stale, stale.filter((n) => n.id === 'top'), 1730112000007); + await emptyTrash(stale); + expect(store.map((n) => n.id).sort()).toEqual(['a', 'b', 'docs']); + }); +}); diff --git a/src/api/__tests__/live-stalwart-files.test.ts b/src/api/__tests__/live-stalwart-files.test.ts index 8a879c50..6af6e804 100644 --- a/src/api/__tests__/live-stalwart-files.test.ts +++ b/src/api/__tests__/live-stalwart-files.test.ts @@ -36,8 +36,10 @@ import { copyFileNode, createFolder, deleteFileNodes, + destroyEmptyFolders, fileNodeKind, filesAccountId, + getAllFileNodes, getAllFileNodesAcrossAccounts, getFileNodeDownloadUrl, listFolder, @@ -45,6 +47,8 @@ import { renameFileNode, uploadFileBatch, } from '../files'; +import { emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash } from '../files-trash'; +import { findTrashFolder, parseTrashBatchName, trashedItems, withoutTrash } from '../../lib/files-trash'; const SERVER = process.env.BULWARK_LIVE_JMAP; const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; @@ -208,3 +212,175 @@ live('live Stalwart files', () => { await deleteFileNodes([bulk.id]); }, 180_000); }); + +// Dumps the drive for a check with the webmail's own code (BULWARK_LIVE_PROBE_DIR). +async function writeProbe(name: string, list: FileNode[]): Promise { + 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/~/. +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(); + + const own = () => getAllFileNodes(); + const upload = async (name: string, parentId: string, text = name) => + (await uploadFileBatch([pick(name, text)], parentId, [])).uploaded[0]; + const trashed = async (id: string) => trashedItems(await own()).find((i) => i.node.id === id); + + beforeAll(async () => { + await connectSingleton(); + trashBefore = findTrashFolder(await own())?.id; + scratch = (await createFolder(`live-trash-${Date.now().toString(36)}`, null)).id; + }, 60_000); + + afterAll(async () => { + const before = await own(); + const trash = findTrashFolder(before); + const leftovers = [scratch, ...[...batches].filter((id) => before.some((n) => n.id === id))]; + if (trash && !trashBefore) leftovers.push(trash.id); + await deleteFileNodes(leftovers.filter(Boolean)).catch(() => undefined); + const after = await own(); + expect(after.filter((n) => leftovers.includes(n.id))).toEqual([]); + if (!trashBefore) expect(findTrashFolder(after)).toBeUndefined(); + }, 120_000); + + it('moves a file and a folder into one trash folder that names when and where from, and hides them', async () => { + const file = await upload('trash-me.txt', scratch); + const folder = await createFolder('Trash me', scratch); + const inner = await upload('inner.txt', folder.id); + let list = await own(); + const before = Date.now(); + const { done, refused } = await moveToTrash(list, [ + list.find((n) => n.id === file.id)!, + list.find((n) => n.id === folder.id)!, + ]); + expect(refused).toEqual([]); + expect(done.map((i) => i.node.id).sort()).toEqual([file.id, folder.id].sort()); + + list = await own(); + const trash = findTrashFolder(list)!; + expect(trash).toBeDefined(); + expect(trash.parentId ?? null).toBeNull(); + expect(trash.name).toBe('.Trash'); + const batch = list.find((n) => n.id === done[0].batchId)!; + batches.add(batch.id); + expect(batch.parentId).toBe(trash.id); + expect(fileNodeKind(batch)).toBe('directory'); + // What the webmail reads back to restore: the time and the folder. + expect(batch.name).toMatch(/^(\d{10,})~([A-Za-z0-9_-]*)$/); + expect(parseTrashBatchName(batch.name)?.parentId).toBe(scratch); + expect(parseTrashBatchName(batch.name)!.deletedAt).toBeGreaterThanOrEqual(before - 1000); + expect(list.find((n) => n.id === file.id)?.parentId).toBe(batch.id); + // The folder went whole. + expect(list.find((n) => n.id === inner.id)?.parentId).toBe(folder.id); + + const items = trashedItems(list).filter((i) => i.batchId === batch.id); + expect(items.map((i) => i.node.name).sort()).toEqual(['Trash me', 'trash-me.txt']); + expect(items.every((i) => i.fromParentId === scratch)).toBe(true); + + // Out of every normal listing, the root included. + const shown = withoutTrash(await nodes()); + expect(shown.some((n) => [trash.id, batch.id, file.id, folder.id, inner.id].includes(n.id))).toBe(false); + expect(listFolder(shown, null).some((n) => n.id === trash.id)).toBe(false); + expect(listFolder(shown, scratch).map((n) => n.id)).toEqual([]); + + await writeProbe('trash-after-delete', list); + }, 90_000); + + it('restores items to the folder they came from and removes their empty trash folder', async () => { + const all = trashedItems(await own()); + const item = all.find((i) => i.node.name === 'trash-me.txt' && i.fromParentId === scratch)!; + const sibling = all.find((i) => i.node.name === 'Trash me' && i.batchId === item.batchId)!; + let list = await own(); + const { done } = await restoreFromTrash(list, [item]); + expect(done).toHaveLength(1); + list = await own(); + expect(list.find((n) => n.id === item.node.id)).toMatchObject({ name: 'trash-me.txt', parentId: scratch }); + // The other item still holds the trash folder. + expect(list.some((n) => n.id === item.batchId)).toBe(true); + + await restoreFromTrash(list, [sibling]); + list = await own(); + expect(list.find((n) => n.id === sibling.node.id)?.parentId).toBe(scratch); + expect(list.some((n) => n.id === item.batchId)).toBe(false); + expect(await trashed(item.node.id)).toBeUndefined(); + }, 90_000); + + it('restores under a free name when the old one is taken, and to the root when the folder is gone', async () => { + const sub = await createFolder('Gone soon', scratch); + const a = await upload('clash.txt', scratch, 'old'); + const b = await upload('orphan.txt', sub.id); + let list = await own(); + const first = await moveToTrash(list, [list.find((n) => n.id === a.id)!]); + const second = await moveToTrash(list, [list.find((n) => n.id === b.id)!]); + for (const i of [...first.done, ...second.done]) batches.add(i.batchId!); + await upload('clash.txt', scratch, 'new'); + await deleteFileNodes([sub.id]); + + list = await own(); + await restoreFromTrash(list, [(await trashed(a.id))!, (await trashed(b.id))!]); + list = await own(); + expect(list.find((n) => n.id === a.id)).toMatchObject({ name: 'clash (1).txt', parentId: scratch }); + const orphan = list.find((n) => n.id === b.id)!; + try { + expect(orphan.name).toBe('orphan.txt'); + expect(orphan.parentId ?? null).toBeNull(); + } finally { + await deleteFileNodes([b.id]); + } + }, 120_000); + + it('undoes a delete with the listing from before it', async () => { + const file = await upload('undo-me.txt', scratch); + const list = await own(); + const { done } = await moveToTrash(list, [list.find((n) => n.id === file.id)!]); + batches.add(done[0].batchId!); + // The rows on screen still show it where it was. + await restoreFromTrash(list, done); + const after = await own(); + expect(after.find((n) => n.id === file.id)).toMatchObject({ name: 'undo-me.txt', parentId: scratch }); + expect(after.some((n) => n.id === done[0].batchId)).toBe(false); + }, 90_000); + + it('deletes items of the trash for good, and leaves a trash folder that still holds anything', async () => { + const x = await upload('purge-x.txt', scratch); + const y = await upload('purge-y.txt', scratch); + let list = await own(); + const picked = [x, y].map((u) => list.find((n) => n.id === u.id)!); + const { done } = await moveToTrash(list, picked); + const batchId = done[0].batchId!; + batches.add(batchId); + + // Never with what is inside: a folder that holds anything stays. + expect(await destroyEmptyFolders([batchId])).toEqual([]); + list = await own(); + expect(list.some((n) => n.id === batchId)).toBe(true); + + await purgeFromTrash(list, trashedItems(list).filter((i) => i.node.id === x.id)); + list = await own(); + expect(list.some((n) => n.id === x.id)).toBe(false); + expect(list.some((n) => n.id === batchId)).toBe(true); + + await purgeFromTrash(list, trashedItems(list).filter((i) => i.node.id === y.id)); + list = await own(); + expect(list.some((n) => [x.id, y.id, batchId].includes(n.id))).toBe(false); + }, 90_000); + + it('empties the trash', async () => { + // Only on an account whose trash these tests made: a real one stays. + if (trashBefore) return; + const file = await upload('empty-me.txt', scratch); + const list = await own(); + await moveToTrash(list, [list.find((n) => n.id === file.id)!]); + await emptyTrash(await own()); + const after = await own(); + expect(findTrashFolder(after)).toBeUndefined(); + expect(after.some((n) => n.id === file.id)).toBe(false); + }, 90_000); +}); diff --git a/src/api/files-trash.ts b/src/api/files-trash.ts new file mode 100644 index 00000000..320fad7f --- /dev/null +++ b/src/api/files-trash.ts @@ -0,0 +1,168 @@ +import type { FileNode } from './types'; +import { + createFolder, deleteFileNodes, destroyEmptyFolders, getAllFileNodes, isCrossAccountId, updateFileNodes, +} from './files'; +import { JMAPSetError } from './jmap-result'; +import { getUniqueName } from '../lib/filenode-name'; +import { + TRASH_FOLDER_NAME, findTrashFolder, restoreTarget, trashBatchName, trashSubtreeIds, type TrashedItem, +} from '../lib/files-trash'; + +/* + * The writes of the Files trash (lib/files-trash has the convention). All of + * them act on the user's own drive. `nodes` is the drive as last listed; the + * server has the last word wherever another client may have changed it since. + */ + +export interface TrashWriteResult { + /** What went through: for a delete, the items as they now sit in the trash. */ + done: TrashedItem[]; + /** The server's refusals of the rest. */ + refused: JMAPSetError[]; +} + +/** The trash folder, created on the first delete. */ +async function ensureTrashFolder(nodes: FileNode[]): Promise { + const known = findTrashFolder(nodes); + if (known) return known; + try { + return await createFolder(TRASH_FOLDER_NAME, null); + } catch (err) { + // Another client may have made it since the drive was listed. + const fresh = findTrashFolder(await getAllFileNodes()); + if (fresh) return fresh; + throw err; + } +} + +/** Remove the trash folders of `batchIds` that hold nothing once `leaving` are out of them. */ +async function removeEmptyBatches(nodes: FileNode[], batchIds: Iterable, leaving: Set): Promise { + const empty = [...new Set(batchIds)].filter((id) => !nodes.some((n) => n.parentId === id && !leaving.has(n.id))); + if (empty.length === 0) return; + await destroyEmptyFolders(empty).catch(() => undefined); +} + +/** + * Move nodes of the user's own drive into the trash: one trash folder per + * folder they came from, named after the time and that folder, like the + * webmail does. A node of a shared drive can't go there; delete it instead. + * Throws when nothing went. + */ +export async function moveToTrash( + nodes: FileNode[], + targets: FileNode[], + deletedAt: number = Date.now(), +): Promise { + const result: TrashWriteResult = { done: [], refused: [] }; + if (targets.length === 0) return result; + if (targets.some((n) => n.isShared || isCrossAccountId(n.id))) { + throw new JMAPSetError('forbidden', 'Files of a shared drive cannot be moved to the trash'); + } + const groups = new Map(); + 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 => { + const name = trashBatchName(parentId, deletedAt); + try { + return await createFolder(name, trash.id); + } catch { + // The trash on screen may be gone (emptied on another device): read + // the drive again and try once more. + trash = await ensureTrashFolder(await getAllFileNodes()); + return createFolder(name, trash.id); + } + }; + + for (const [parentId, group] of groups) { + const batch = await createBatch(parentId); + const update = await updateFileNodes(Object.fromEntries(group.map((n) => [n.id, { parentId: batch.id }]))); + const moved = new Set(update.updated); + result.refused.push(...Object.values(update.notUpdated)); + if (moved.size === 0) { + await destroyEmptyFolders([batch.id]).catch(() => undefined); + continue; + } + for (const node of group.filter((n) => moved.has(n.id))) { + result.done.push({ node: { ...node, parentId: batch.id }, batchId: batch.id, deletedAt, fromParentId: parentId }); + } + } + if (result.done.length === 0) { + throw result.refused[0] ?? new JMAPSetError('setError', 'Failed to move to the trash'); + } + return result; +} + +/** + * Put items of the trash back into the folder each came from, or the root + * when that folder is gone or in the trash itself. A name taken there by now + * gets " (1)" and so on. Their trash folders go once they are empty. Throws + * when nothing went. + */ +export async function restoreFromTrash(nodes: FileNode[], items: TrashedItem[]): Promise { + const result: TrashWriteResult = { done: [], refused: [] }; + if (items.length === 0) return result; + const hidden = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id); + const restoring = new Set(items.map((item) => item.node.id)); + // Names taken in each folder, so a restored item never clashes; the items + // themselves don't count (after an undo the listing may still show them + // where they were). + const taken = new Map>(); + const namesIn = (parentId: string | null): Set => { + let names = taken.get(parentId); + if (!names) { + names = new Set(nodes + .filter((n) => (n.parentId ?? null) === parentId && !n.isShared && !hidden.has(n.id) && !restoring.has(n.id)) + .map((n) => n.name)); + taken.set(parentId, names); + } + return names; + }; + + const updates: Record>> = {}; + const targets = new Map(); + for (const item of items) { + const target = restoreTarget(item, nodes); + const used = namesIn(target); + const name = getUniqueName(item.node.name, used); + used.add(name); + // The name only goes along when it has to change: the listing shows a + // WebDAV name decoded, and sending that back would rename the file. + updates[item.node.id] = name === item.node.name ? { parentId: target } : { parentId: target, name }; + targets.set(item.node.id, target); + } + const update = await updateFileNodes(updates); + result.refused.push(...Object.values(update.notUpdated)); + const restored = new Set(update.updated); + if (restored.size === 0) { + throw result.refused[0] ?? new JMAPSetError('setError', 'Failed to restore'); + } + for (const item of items.filter((i) => restored.has(i.node.id))) { + const name = updates[item.node.id].name ?? item.node.name; + result.done.push({ ...item, node: { ...item.node, name, parentId: targets.get(item.node.id) ?? null } }); + } + await removeEmptyBatches(nodes, result.done.flatMap((i) => (i.batchId ? [i.batchId] : [])), restored); + return result; +} + +/** Delete items of the trash for good, with what is inside a deleted folder. */ +export async function purgeFromTrash(nodes: FileNode[], items: TrashedItem[]): Promise { + if (items.length === 0) return; + const ids = new Set(items.map((item) => item.node.id)); + try { + await deleteFileNodes([...ids]); + } finally { + // Also after a partial refusal: the server keeps a trash folder that still holds anything. + await removeEmptyBatches(nodes, items.flatMap((i) => (i.batchId ? [i.batchId] : [])), ids); + } +} + +/** Delete everything in the trash for good. The next delete makes a new trash folder. */ +export async function emptyTrash(nodes: FileNode[]): Promise { + // Not in the listing on screen: another client may have made one since. + const trash = findTrashFolder(nodes) ?? findTrashFolder(await getAllFileNodes()); + if (trash) await deleteFileNodes([trash.id]); +} diff --git a/src/api/files.ts b/src/api/files.ts index 86193963..d618f601 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -478,6 +478,80 @@ export async function updateFileNode( if (notUpdated) throw fileSetError(notUpdated, 'Update failed'); } +export interface FileNodeUpdateResult { + updated: string[]; + notUpdated: Record; +} + +/** + * 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>>, +): Promise { + 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(); + const patches: Record> = {}; + 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 = { ...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; + 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 { + const gone: string[] = []; + const byAccount = new Map>(); + for (const id of ids) { + const node = resolveFileNodeId(id); + const listed = byAccount.get(node.accountId) ?? new Map(); + 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 { await updateFileNode(id, { name: newName }); } diff --git a/src/lib/__tests__/files-trash.test.ts b/src/lib/__tests__/files-trash.test.ts new file mode 100644 index 00000000..5caa6351 --- /dev/null +++ b/src/lib/__tests__/files-trash.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import type { FileNode } from '../../api/types'; +import { + findTrashFolder, parseTrashBatchName, restoreTarget, trashBatchName, trashOriginPath, trashSubtreeIds, + trashedItems, withoutTrash, +} from '../files-trash'; + +// The webmail's trash convention (its lib/files-trash.ts), read the same way here. + +const dir = (id: string, parentId: string | null, name = id, extra: Partial = {}): FileNode => + ({ id, parentId, name, type: '', blobId: null, ...extra }); +const file = (id: string, parentId: string | null, name = id, extra: Partial = {}): FileNode => + ({ id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 1, ...extra }); + +const drive: FileNode[] = [ + dir('docs', null, 'Documents'), + dir('tax', 'docs', 'Taxes'), + file('keep', 'docs', 'keep.txt'), + dir('t', null, '.Trash'), + dir('b1', 't', '1730112000000~tax'), + file('r1', 'b1', 'report.pdf'), + dir('old', 'b1', 'Old stuff'), + file('deep', 'old', 'deep.txt'), + dir('b2', 't', '1730115600000~'), + file('r2', 'b2', 'notes.txt'), + // Put into the trash by hand (WebDAV), not by a delete. + file('loose', 't', 'loose.txt'), + // A shared account's own trash is not the user's. + dir('acc-2:t', null, '.Trash', { isShared: true, accountId: 'acc-2' }), +]; + +describe('trash batch names', () => { + it('names a delete after its time and folder, the root as empty', () => { + expect(trashBatchName('tax', 1730112000000)).toBe('1730112000000~tax'); + expect(trashBatchName(null, 1730112000000)).toBe('1730112000000~'); + }); + + it('reads both back, and nothing else', () => { + expect(parseTrashBatchName('1730112000000~tax')).toEqual({ deletedAt: 1730112000000, parentId: 'tax' }); + expect(parseTrashBatchName('1730112000000~')).toEqual({ deletedAt: 1730112000000, parentId: null }); + expect(parseTrashBatchName('Holiday photos')).toBeNull(); + expect(parseTrashBatchName('12~tax')).toBeNull(); + expect(parseTrashBatchName('1730112000000~acc-2:x')).toBeNull(); + }); +}); + +describe('findTrashFolder', () => { + it('finds the own drive\'s .Trash folder at the root only', () => { + expect(findTrashFolder(drive)?.id).toBe('t'); + expect(findTrashFolder([dir('x', 'docs', '.Trash')])).toBeUndefined(); + expect(findTrashFolder([file('x', null, '.Trash')])).toBeUndefined(); + expect(findTrashFolder([dir('acc-2:t', null, '.Trash', { isShared: true })])).toBeUndefined(); + }); +}); + +describe('trashedItems', () => { + it('lists each deleted item once, with when and where it came from', () => { + const items = trashedItems(drive); + expect(items.map((i) => [i.node.id, i.batchId, i.deletedAt, i.fromParentId])).toEqual([ + ['r1', 'b1', 1730112000000, 'tax'], + ['old', 'b1', 1730112000000, 'tax'], + ['r2', 'b2', 1730115600000, null], + ['loose', null, null, null], + ]); + }); + + it('is empty without a trash folder', () => { + expect(trashedItems(drive.filter((n) => n.id !== 't'))).toEqual([]); + }); +}); + +describe('withoutTrash', () => { + it('leaves the trash and everything in it out', () => { + expect([...trashSubtreeIds(drive, 't')].sort()).toEqual(['b1', 'b2', 'deep', 'loose', 'old', 'r1', 'r2', 't']); + expect(withoutTrash(drive).map((n) => n.id)).toEqual(['docs', 'tax', 'keep', 'acc-2:t']); + }); +}); + +describe('restoreTarget', () => { + it('puts an item back into its folder while that is still there', () => { + expect(restoreTarget({ fromParentId: 'tax' }, drive)).toBe('tax'); + expect(restoreTarget({ fromParentId: null }, drive)).toBeNull(); + }); + + it('falls back to the root for a folder that is gone, in the trash or not a folder', () => { + expect(restoreTarget({ fromParentId: 'missing' }, drive)).toBeNull(); + expect(restoreTarget({ fromParentId: 'old' }, drive)).toBeNull(); + expect(restoreTarget({ fromParentId: 'keep' }, drive)).toBeNull(); + }); +}); + +describe('trashOriginPath', () => { + it('names the folder an item came from by its path', () => { + expect(trashOriginPath({ fromParentId: 'tax' }, drive)).toBe('/Documents/Taxes'); + expect(trashOriginPath({ fromParentId: null }, drive)).toBe('/'); + expect(trashOriginPath({ fromParentId: 'old' }, drive)).toBeNull(); + }); +}); diff --git a/src/lib/files-trash.ts b/src/lib/files-trash.ts new file mode 100644 index 00000000..a7e0e23e --- /dev/null +++ b/src/lib/files-trash.ts @@ -0,0 +1,121 @@ +import type { FileNode } from '../api/types'; +import { isFolder } from '../api/files'; + +/* + * The Files trash, the webmail's convention (lib/files-trash.ts there), so + * both clients see and restore the same trash. Neither Stalwart 0.16 nor 1.0 + * keeps deleted FileNodes: a destroy removes a node and everything below it + * for good. So the trash is a folder the clients manage: `.Trash` at the root + * of the user's own drive, hidden from every listing. Each delete puts its + * items into a folder of its own inside it, named after when it happened and + * which folder the items came from (empty for the root): + * + * /.Trash/1730112000000~/report.pdf + * + * The name is the whole record; nothing else is stored. A drive someone + * shared has no trash of the user's: what is deleted there is gone. + */ + +export const TRASH_FOLDER_NAME = '.Trash'; + +/** The trash folder of the user's own drive, if there is one yet. */ +export function findTrashFolder(nodes: FileNode[]): FileNode | undefined { + return nodes.find((n) => + (n.parentId ?? null) === null + && n.name === TRASH_FOLDER_NAME + && !n.isShared + && !n.id.includes(':') + && isFolder(n)); +} + +/** The name of the folder one delete's items go into. */ +export function trashBatchName(parentId: string | null, deletedAt: number = Date.now()): string { + return `${deletedAt}~${parentId ?? ''}`; +} + +/** When a batch was deleted and from which folder (null: the root); null for any other name. */ +export function parseTrashBatchName(name: string): { deletedAt: number; parentId: string | null } | null { + const match = /^(\d{10,})~([A-Za-z0-9_-]*)$/.exec(name); + if (!match) return null; + return { deletedAt: Number(match[1]), parentId: match[2] || null }; +} + +/** The trash folder and everything in it. */ +export function trashSubtreeIds(nodes: FileNode[], trashId: string | undefined): Set { + const ids = new Set(); + if (!trashId) return ids; + ids.add(trashId); + let grew = true; + while (grew) { + grew = false; + for (const n of nodes) { + if (n.parentId && ids.has(n.parentId) && !ids.has(n.id)) { + ids.add(n.id); + grew = true; + } + } + } + return ids; +} + +/** The nodes without the trash and what is in it: what every listing but the trash's own shows. */ +export function withoutTrash(nodes: FileNode[]): FileNode[] { + const hidden = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id); + return hidden.size === 0 ? nodes : nodes.filter((n) => !hidden.has(n.id)); +} + +export interface TrashedItem { + node: FileNode; + /** The delete's folder in the trash; null for something put into the trash directly. */ + batchId: string | null; + /** When it was deleted (ms); null when unknown. */ + deletedAt: number | null; + /** The folder it came from; null for the root or when unknown. */ + fromParentId: string | null; +} + +/** What the trash holds, one entry per deleted item. */ +export function trashedItems(nodes: FileNode[], trash: FileNode | undefined = findTrashFolder(nodes)): TrashedItem[] { + if (!trash) return []; + const items: TrashedItem[] = []; + for (const child of nodes.filter((n) => n.parentId === trash.id)) { + const batch = isFolder(child) ? parseTrashBatchName(child.name) : null; + if (!batch) { + items.push({ node: child, batchId: null, deletedAt: null, fromParentId: null }); + continue; + } + for (const node of nodes.filter((n) => n.parentId === child.id)) { + items.push({ node, batchId: child.id, deletedAt: batch.deletedAt, fromParentId: batch.parentId }); + } + } + return items; +} + +/** + * Where a restored item goes: the folder it came from while that is still a + * folder of the user's own drive outside the trash, the root otherwise. + */ +export function restoreTarget(item: Pick, nodes: FileNode[]): string | null { + const from = item.fromParentId; + if (from === null) return null; + const hidden = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id); + const folder = nodes.find((n) => n.id === from); + return folder && isFolder(folder) && !folder.isShared && !hidden.has(folder.id) ? from : null; +} + +/** + * The path of the folder a deleted item came from ("/Documents/Taxes", "/" + * for the root); null when that folder is gone. + */ +export function trashOriginPath(item: Pick, nodes: FileNode[]): string | null { + if (item.fromParentId === null) return '/'; + if (restoreTarget(item, nodes) === null) return null; + const byId = new Map(nodes.map((n) => [n.id, n])); + const names: string[] = []; + const seen = new Set(); + 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('/')}`; +} diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index fb31a433..1b738ce9 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -20,7 +20,7 @@ import { ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo, FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon, MoreVertical, Pencil, Share2, Trash2, Upload, Users, X, Download, Search, - ArrowUpDown, ArrowUp, ArrowDown, FolderInput, Copy, Check, Eye, ExternalLink, FileSymlink, + ArrowUpDown, ArrowUp, ArrowDown, FolderInput, Copy, Check, Eye, ExternalLink, FileSymlink, RotateCcw, } from '../components/icons'; import { File, Paths } from 'expo-file-system'; import * as Sharing from 'expo-sharing'; @@ -49,6 +49,9 @@ import { getFileNameRules, getFileNodeDownloadUrl, getMaxSizeUpload, isFolder, isSymlink, listFolder, moveFileNode, renameFileNode, supportsSharing, uploadFileBatch, } from '../api/files'; +import { emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash } from '../api/files-trash'; +import { trashOriginPath, trashedItems, withoutTrash, type TrashedItem } from '../lib/files-trash'; +import { toast } from '../stores/toast-store'; import { jmapClient } from '../api/jmap-client'; import { downloadAttachment, shareAttachment } from '../lib/email-export'; import { secureFetch } from '../lib/client-cert'; @@ -79,6 +82,8 @@ import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; // A file row carries the display name alongside the rest of the node. interface FileRow extends FileNode { displayName: string; + /** Set for a row of the trash: when it was deleted and where from. */ + trash?: TrashedItem; } type Translate = TranslateFn; @@ -146,6 +151,13 @@ function modifiedOf(node: FileNode): string | undefined { return node.modified || node.created; } +// The date a row shows and sorts by: in the trash, when it was deleted +// (like the webmail's trash). +function rowDateOf(row: FileRow): string | undefined { + if (row.trash) return row.trash.deletedAt != null ? new Date(row.trash.deletedAt).toISOString() : undefined; + return modifiedOf(row); +} + function fileNameProblemText(problem: FileNameProblem, t: Translate): string { switch (problem.kind) { case 'chars': @@ -214,7 +226,10 @@ export default function FilesScreen() { const uploadAbortRef = useRef(null); const [batchBusy, setBatchBusy] = useState(null); + // The trash (lib/files-trash) is a place of its own, entered from the root. + const [inTrash, setInTrash] = useState(false); const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[] } | null>(null); + const [confirmEmptyTrash, setConfirmEmptyTrash] = useState(false); const [newFolderOpen, setNewFolderOpen] = useState(false); const [newFolderName, setNewFolderName] = useState(''); const [renameTarget, setRenameTarget] = useState(null); @@ -249,11 +264,17 @@ export default function FilesScreen() { const uploading = uploadProgress != null; const selectionMode = selection.size > 0; const currentParentId = path.length === 0 ? null : path[path.length - 1].id; + // The webmail's `/.Trash` and everything in it stay out of every listing + // but the trash's own, hidden files shown or not. + const driveNodes = useMemo(() => withoutTrash(allNodes), [allNodes]); + const trashItems = useMemo(() => trashedItems(allNodes), [allNodes]); // Inside a folder another account shared with us, new folders and uploads - // go to that account, and only where its rights allow them. - const canCreateHere = mayCreateIn(currentParentId, allNodes); + // go to that account, and only where its rights allow them. Nothing is + // added to the trash other than by deleting. + const canCreateHere = !inTrash && mayCreateIn(currentParentId, driveNodes); const rootLabel = t('files.title', 'Files'); - const headerName = path.length === 0 ? rootLabel : path[path.length - 1].name; + const trashLabel = t('files.trash', 'Trash'); + const headerName = inTrash ? trashLabel : path.length === 0 ? rootLabel : path[path.length - 1].name; const sharingEnabled = supportsSharing(); const loadFiles = useCallback( @@ -291,6 +312,7 @@ export default function FilesScreen() { uploadAbortRef.current?.abort(); setAllNodes([]); setPath([]); + setInTrash(false); setSelection(new Set()); setActionsTarget(null); setShareTarget(null); @@ -303,12 +325,13 @@ export default function FilesScreen() { void loadFiles(action === 'reload' ? 'refresh' : 'initial'); }, [activeAccountId, session, loadFiles]); - // If we navigated into a folder that no longer exists (deleted, or its - // share was revoked), cut the stack back to the deepest ancestor that does. + // If we navigated into a folder that no longer exists (deleted, moved to + // the trash on another device, or its share was revoked), cut the stack + // back to the deepest ancestor that does. useEffect(() => { if (allNodes.length === 0) return; setPath((prev) => { - const byId = new Map(allNodes.map((n) => [n.id, n])); + const byId = new Map(driveNodes.map((n) => [n.id, n])); let cut = prev.length; for (let i = 0; i < prev.length; i++) { const node = byId.get(prev[i].id); @@ -319,16 +342,18 @@ export default function FilesScreen() { } return cut === prev.length ? prev : prev.slice(0, cut); }); - }, [allNodes]); + }, [allNodes, driveNodes]); // Root: our own top-level nodes, plus the roots of every shared-with-me // subtree; Stalwart 1.0's placeholder folders above them are left out. - const folderNodes = useMemo(() => listFolder(allNodes, currentParentId), [allNodes, currentParentId]); + const folderNodes = useMemo(() => listFolder(driveNodes, currentParentId), [driveNodes, currentParentId]); const visibleFiles = useMemo(() => { const q = searchQuery.trim().toLowerCase(); - const rows = folderNodes - .map((n) => ({ ...n, displayName: n.name })) + const source: FileRow[] = inTrash + ? trashItems.map((item) => ({ ...item.node, displayName: item.node.name, trash: item })) + : folderNodes.map((n) => ({ ...n, displayName: n.name })); + const rows = source .filter((r) => showHiddenFiles || !r.displayName.startsWith('.')) .filter((r) => !q || r.displayName.toLowerCase().includes(q)); const dir = sortDir === 'desc' ? -1 : 1; @@ -344,8 +369,8 @@ export default function FilesScreen() { case 'size': return ((a.size ?? 0) - (b.size ?? 0)) * dir; case 'modified': { - const am = modifiedOf(a) ? new Date(modifiedOf(a)!).getTime() : 0; - const bm = modifiedOf(b) ? new Date(modifiedOf(b)!).getTime() : 0; + const am = rowDateOf(a) ? new Date(rowDateOf(a)!).getTime() : 0; + const bm = rowDateOf(b) ? new Date(rowDateOf(b)!).getTime() : 0; return (am - bm) * dir; } case 'name': @@ -353,7 +378,7 @@ export default function FilesScreen() { return a.displayName.localeCompare(b.displayName) * dir; } }); - }, [folderNodes, searchQuery, showHiddenFiles, sortKey, sortDir]); + }, [inTrash, trashItems, folderNodes, searchQuery, showHiddenFiles, sortKey, sortDir]); const clearSelection = useCallback(() => setSelection(new Set()), []); @@ -468,6 +493,13 @@ export default function FilesScreen() { toggleSelect(row); return; } + // In the trash a file can be looked at; a folder is restored before + // it is opened. Everything else is in the row's actions. + if (row.trash) { + if (!isFolder(row) && !isSymlink(row) && canPreviewInApp(row)) setPreviewTarget(row); + else setActionsTarget(row); + return; + } if (isFolder(row)) { setPath((p) => [...p, { id: row.id, name: row.displayName }]); setSearchQuery(''); @@ -486,17 +518,25 @@ export default function FilesScreen() { }, [toggleSelect]); const goBack = useCallback(() => { - setPath((p) => (p.length > 0 ? p.slice(0, -1) : p)); + if (inTrash) setInTrash(false); + else setPath((p) => (p.length > 0 ? p.slice(0, -1) : p)); + setSearchQuery(''); + clearSelection(); + }, [inTrash, clearSelection]); + + const openTrash = useCallback(() => { + setInTrash(true); setSearchQuery(''); clearSelection(); }, [clearSelection]); // Android back closes the preview, then leaves selection mode, then goes - // up one folder; only at the root does it switch to the Mail tab. + // up one folder (out of the trash, which opens from the root); only at the + // root does it switch to the Mail tab. const backStep = filesBackStep({ previewOpen: previewTarget != null, selecting: selectionMode, - folderDepth: path.length, + folderDepth: inTrash ? 1 : path.length, }); const handleBack = useCallback(() => { if (backStep === 'close-preview') setPreviewTarget(null); @@ -665,10 +705,45 @@ export default function FilesScreen() { } }, [uploading, currentParentId, allNodes, loadFiles, t]); + // Undo of a delete: the items go back from the trash, the way Restore puts + // them back, judged against the drive as it was before the delete. + const undoTrash = useCallback(async (listing: FileNode[], items: TrashedItem[]) => { + try { + await restoreFromTrash(listing, items); + toast.success(t('files.undo_success', 'Action undone')); + } catch (e) { + toast.error(t('files.undo_error', 'Failed to undo'), fileErrorMessage(e, t)); + } + await loadFiles('refresh'); + }, [loadFiles, t]); + + // Delete in the user's own drive moves into the trash, with Undo and no + // question, like the webmail. A drive someone shared has no trash of the + // user's, and in the trash a delete is for good: those ask first. + const trashRows = useCallback(async (rows: FileRow[]) => { + clearSelection(); + const listing = allNodes; + try { + const { done, refused } = await moveToTrash(listing, rows); + await loadFiles('refresh'); + if (refused.length > 0) { + Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(refused[0], t)); + } + toast.success( + t('files.moved_to_trash', '{count, plural, one {Moved to trash} other {# items moved to trash}}', { count: done.length }), + { action: { label: t('files.undo', 'Undo'), onPress: () => void undoTrash(listing, done) } }, + ); + } catch (e) { + Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t)); + await loadFiles('refresh'); + } + }, [allNodes, clearSelection, loadFiles, undoTrash, t]); + const requestDelete = useCallback((rows: FileRow[]) => { if (rows.length === 0) return; - setConfirmDelete({ rows }); - }, []); + if (inTrash || rows.some((r) => r.isShared)) setConfirmDelete({ rows }); + else void trashRows(rows); + }, [inTrash, trashRows]); const performDelete = useCallback(async () => { if (!confirmDelete) return; @@ -676,19 +751,52 @@ export default function FilesScreen() { setConfirmDelete(null); clearSelection(); try { + const fromTrash = targets.flatMap((r) => (r.trash ? [r.trash] : [])); // The server removes folder descendants (onDestroyRemoveChildren). - await deleteFileNodes(targets.map((t) => t.id)); - await loadFiles('refresh'); + if (fromTrash.length > 0) await purgeFromTrash(allNodes, fromTrash); + else await deleteFileNodes(targets.map((r) => r.id)); + toast.success(t('files.deleted_forever', '{count, plural, one {Deleted permanently} other {# items deleted permanently}}', { + count: targets.length, + })); } catch (e) { Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t)); } - }, [confirmDelete, clearSelection, loadFiles, t]); + await loadFiles('refresh'); + }, [confirmDelete, allNodes, clearSelection, loadFiles, t]); + + const restoreRows = useCallback(async (rows: FileRow[]) => { + const items = rows.flatMap((r) => (r.trash ? [r.trash] : [])); + if (items.length === 0) return; + clearSelection(); + try { + const { done, refused } = await restoreFromTrash(allNodes, items); + if (refused.length > 0) { + Alert.alert(t('files.restore_error', "Couldn't restore"), fileErrorMessage(refused[0], t)); + } + toast.success(t('files.restored', '{count, plural, one {Item restored} other {# items restored}}', { count: done.length })); + } catch (e) { + Alert.alert(t('files.restore_error', "Couldn't restore"), fileErrorMessage(e, t)); + } + await loadFiles('refresh'); + }, [allNodes, clearSelection, loadFiles, t]); + + const performEmptyTrash = useCallback(async () => { + setConfirmEmptyTrash(false); + clearSelection(); + try { + await emptyTrash(allNodes); + toast.success(t('files.trash_emptied', 'Trash emptied')); + } catch (e) { + Alert.alert(t('files.empty_trash_error', "Couldn't empty the trash"), fileErrorMessage(e, t)); + } + await loadFiles('refresh'); + }, [allNodes, clearSelection, loadFiles, t]); const sortLabel = t(`files.${sortKey}`, sortKey); const SortDirIcon = sortDir === 'desc' ? ArrowDown : ArrowUp; const renderHeader = () => { - const canBack = path.length > 0; + const canBack = path.length > 0 || inTrash; const ToggleIcon = viewMode === 'list' ? LayoutGrid : ListIcon; if (selectionMode) { const selectedRows = visibleFiles.filter((r) => selection.has(r.id)); @@ -708,24 +816,35 @@ export default function FilesScreen() { {t('files.selected_count', '{count, plural, one {# selected} other {# selected}}', { count: selection.size })} - void downloadSelection(selectedRows)} - hitSlop={8} - style={styles.headerBtn} - accessibilityLabel={t('files.download', 'Download')} - disabled={batchBusy != null} - > - {batchBusy != null ? ( - - ) : ( - - )} - + {inTrash ? ( + void restoreRows(selectedRows)} + hitSlop={8} + style={styles.headerBtn} + accessibilityLabel={t('files.restore', 'Restore')} + > + + + ) : ( + void downloadSelection(selectedRows)} + hitSlop={8} + style={styles.headerBtn} + accessibilityLabel={t('files.download', 'Download')} + disabled={batchBusy != null} + > + {batchBusy != null ? ( + + ) : ( + + )} + + )} requestDelete(selectedRows)} hitSlop={8} style={styles.headerBtn} - accessibilityLabel={t('files.delete', 'Delete')} + accessibilityLabel={inTrash ? t('files.delete_forever', 'Delete permanently') : t('files.delete', 'Delete')} > @@ -798,11 +917,22 @@ export default function FilesScreen() { > + {path.length === 0 && !inTrash ? ( + + + + ) : null} {canBack ? ( - {[rootLabel, ...path.map((p) => p.name)].join(' / ')} + {[rootLabel, ...(inTrash ? [trashLabel] : path.map((p) => p.name))].join(' / ')} ) : null} @@ -819,6 +949,19 @@ export default function FilesScreen() { clearButtonMode="while-editing" /> ) : } + {inTrash ? ( + setConfirmEmptyTrash(true)} + hitSlop={6} + style={[styles.sortChip, trashItems.length === 0 && { opacity: 0.5 }]} + disabled={trashItems.length === 0} + accessibilityRole="button" + accessibilityState={{ disabled: trashItems.length === 0 }} + > + + {t('files.empty_trash', 'Empty trash')} + + ) : null} setSortSheetOpen(true)} hitSlop={6} @@ -854,6 +997,15 @@ export default function FilesScreen() { ); }; + // Where a deleted item came from, on its row in the trash. + const trashOrigin = (item: TrashedItem): string | null => { + const from = trashOriginPath(item, allNodes); + if (from === null) return null; + return t('files.deleted_from', 'Deleted from {folder}', { + folder: from === '/' ? t('files.my_files', 'My files') : from, + }); + }; + const renderIcon = (item: FileRow, size: number) => { const isDir = isFolder(item); const thumb = !isDir && showThumbnails ? thumbnailSource(item) : null; @@ -881,7 +1033,8 @@ export default function FilesScreen() { const selected = selection.has(item.id); const busy = busyId === item.id; const sharedOut = !!item.shareWith && Object.keys(item.shareWith).length > 0; - const modified = modifiedOf(item); + const modified = rowDateOf(item); + const origin = item.trash ? trashOrigin(item.trash) : null; return ( handleRowPress(item)} @@ -906,6 +1059,9 @@ export default function FilesScreen() { ) : null} + {origin ? ( + {origin} + ) : null} {item.isShared && item.accountName ? ( {t('files.shared_by', 'Shared by {name}', { name: item.accountName })} @@ -1004,6 +1160,16 @@ export default function FilesScreen() { ); + } else if (visibleFiles.length === 0 && inTrash && !searchQuery.trim()) { + body = ( + + + {t('files.trash_empty_title', 'Trash is empty')} + + {t('files.trash_empty_hint', 'Deleted files stay here until you empty the trash.')} + + + ); } else if (visibleFiles.length === 0) { body = ( @@ -1133,11 +1299,15 @@ export default function FilesScreen() { setActionsTarget(null); requestDelete([r]); }} + onRestore={(r) => { + setActionsTarget(null); + void restoreRows([r]); + }} /> setMoveTarget(null)} @@ -1164,18 +1334,37 @@ export default function FilesScreen() { 1 - ? t('files.delete_items_title', '{count, plural, one {Delete # item?} other {Delete # items?}}', { count: confirmDelete.rows.length }) - : t('files.delete_item_title', 'Delete this item?') + ? t('files.delete_forever_message_count', "{count, plural, one {1 item} other {# items}} will be deleted. You can't undo this.", { + count: confirmDelete.rows.length, + }) + : t('files.delete_forever_message', "\"{name}\" will be deleted. You can't undo this.", { + name: confirmDelete?.rows[0]?.displayName ?? '', + }) } - message={t('files.delete_cascade_hint', "Folders are deleted along with everything inside them. This can't be undone.")} variant="destructive" - confirmText={t('files.delete', 'Delete')} + confirmText={t('files.delete_forever', 'Delete permanently')} cancelText={t('files.cancel', 'Cancel')} onConfirm={() => void performDelete()} onCancel={() => setConfirmDelete(null)} /> + + void performEmptyTrash()} + onCancel={() => setConfirmEmptyTrash(false)} + /> ); } @@ -1252,10 +1441,13 @@ interface ActionsSheetProps { onDuplicate: (r: FileRow) => void; onRename: (r: FileRow) => void; onDelete: (r: FileRow) => void; + /** For a row of the trash: put it back where it came from. */ + onRestore: (r: FileRow) => void; } function ActionsSheet({ target, access, sharingEnabled, t, onClose, onPreview, onOpenWith, onDownload, onShare, onMove, onDuplicate, onRename, onDelete, + onRestore, }: ActionsSheetProps) { // Centred on a wide window, clear of side insets in landscape. const sheetInsets = useSheetInsets(); @@ -1272,7 +1464,52 @@ function ActionsSheet({ // nodes are shared on by their owner, and get the writes their rights // allow (`access`). const canShare = sharingEnabled && !link && !target.isShared && (target.myRights?.mayShare ?? true); - const modified = modifiedOf(target); + const modified = rowDateOf(target); + // A deleted item can be restored, looked at or deleted for good, nothing else. + if (target.trash) { + return ( + + + + + + + {target.displayName} + + + {[ + 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(' · ')} + + onRestore(target)}> + + {t('files.restore', 'Restore')} + + {isFile && canPreviewInApp(target) ? ( + onPreview(target)}> + + {t('files.preview', 'Preview')} + + ) : null} + {isFile ? ( + onDownload(target)}> + + {t('files.download', 'Download')} + + ) : null} + onDelete(target)}> + + {t('files.delete_forever', 'Delete permanently')} + + + + + + + ); + } return ( @@ -1612,6 +1849,12 @@ function makeStyles(c: ThemePalette) { fontSize: typography.body.fontSize, marginTop: spacing.sm, }, + emptyHint: { + color: c.textMuted, + fontSize: typography.caption.fontSize, + textAlign: 'center', + paddingHorizontal: spacing.lg, + }, errorText: { color: c.error, fontSize: typography.body.fontSize, @@ -1660,6 +1903,9 @@ function makeStyles(c: ThemePalette) { fontSize: typography.caption.fontSize, color: c.textMuted, }, + fileMetaShrink: { + flexShrink: 1, + }, rowMore: { width: componentSizes.buttonLg, height: componentSizes.buttonLg, From be04fcb1795dbca284a5b93469bf2d52f386513f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:53:53 +0100 Subject: [PATCH 02/11] fix: delete permanently only what is still in the trash, and read the drive again on returning to Files --- src/api/__tests__/files-trash.test.ts | 41 +++++++++-- src/api/__tests__/live-stalwart-files.test.ts | 6 +- src/api/files-trash.ts | 33 ++++++--- src/screens/FilesScreen.tsx | 73 +++++++++++++++---- 4 files changed, 118 insertions(+), 35 deletions(-) diff --git a/src/api/__tests__/files-trash.test.ts b/src/api/__tests__/files-trash.test.ts index 6cdda50f..9b6ff70f 100644 --- a/src/api/__tests__/files-trash.test.ts +++ b/src/api/__tests__/files-trash.test.ts @@ -218,23 +218,48 @@ describe('purgeFromTrash and emptyTrash', () => { let nodes = listed(); await moveToTrash(nodes, nodes.filter((n) => n.id === 'docs'), 1730112000007); nodes = listed(); - await purgeFromTrash(nodes, trashedItems(nodes)); + expect(await purgeFromTrash(trashedItems(nodes))).toBe(1); expect(store.map((n) => n.id).sort()).toEqual(['n1', 'top']); expect(byId('n1')!.name).toBe('.Trash'); }); - it('empties the trash, everything in it included', async () => { + 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(); - await emptyTrash(nodes); + 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 a trash made after the listing on screen', async () => { - const stale = listed(); - await moveToTrash(stale, stale.filter((n) => n.id === 'top'), 1730112000007); - await emptyTrash(stale); - expect(store.map((n) => n.id).sort()).toEqual(['a', 'b', 'docs']); + 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']); }); }); diff --git a/src/api/__tests__/live-stalwart-files.test.ts b/src/api/__tests__/live-stalwart-files.test.ts index 6af6e804..85dd47b9 100644 --- a/src/api/__tests__/live-stalwart-files.test.ts +++ b/src/api/__tests__/live-stalwart-files.test.ts @@ -362,12 +362,12 @@ live('live Stalwart files trash', () => { list = await own(); expect(list.some((n) => n.id === batchId)).toBe(true); - await purgeFromTrash(list, trashedItems(list).filter((i) => i.node.id === x.id)); + 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(list, trashedItems(list).filter((i) => i.node.id === y.id)); + 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); @@ -378,7 +378,7 @@ live('live Stalwart files trash', () => { const file = await upload('empty-me.txt', scratch); const list = await own(); await moveToTrash(list, [list.find((n) => n.id === file.id)!]); - await emptyTrash(await own()); + await emptyTrash(); const after = await own(); expect(findTrashFolder(after)).toBeUndefined(); expect(after.some((n) => n.id === file.id)).toBe(false); diff --git a/src/api/files-trash.ts b/src/api/files-trash.ts index 320fad7f..f7db1464 100644 --- a/src/api/files-trash.ts +++ b/src/api/files-trash.ts @@ -5,7 +5,8 @@ import { import { JMAPSetError } from './jmap-result'; import { getUniqueName } from '../lib/filenode-name'; import { - TRASH_FOLDER_NAME, findTrashFolder, restoreTarget, trashBatchName, trashSubtreeIds, type TrashedItem, + TRASH_FOLDER_NAME, findTrashFolder, restoreTarget, trashBatchName, trashSubtreeIds, trashedItems, + type TrashedItem, } from '../lib/files-trash'; /* @@ -148,21 +149,35 @@ export async function restoreFromTrash(nodes: FileNode[], items: TrashedItem[]): return result; } -/** Delete items of the trash for good, with what is inside a deleted folder. */ -export async function purgeFromTrash(nodes: FileNode[], items: TrashedItem[]): Promise { - if (items.length === 0) return; - const ids = new Set(items.map((item) => item.node.id)); +/** + * 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 { + 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, items.flatMap((i) => (i.batchId ? [i.batchId] : [])), ids); + 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(nodes: FileNode[]): Promise { - // Not in the listing on screen: another client may have made one since. - const trash = findTrashFolder(nodes) ?? findTrashFolder(await getAllFileNodes()); +export async function emptyTrash(): Promise { + // 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]); } diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index 1b738ce9..e671c927 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, Alert, + AppState, FlatList, Image, Modal, @@ -22,6 +23,7 @@ import { MoreVertical, Pencil, Share2, Trash2, Upload, Users, X, Download, Search, 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'; @@ -277,25 +279,36 @@ export default function FilesScreen() { 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 @@ -325,6 +338,32 @@ export default function FilesScreen() { void loadFiles(action === 'reload' ? 'refresh' : 'initial'); }, [activeAccountId, session, loadFiles]); + // 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. @@ -752,17 +791,21 @@ export default function FilesScreen() { clearSelection(); try { const fromTrash = targets.flatMap((r) => (r.trash ? [r.trash] : [])); - // The server removes folder descendants (onDestroyRemoveChildren). - if (fromTrash.length > 0) await purgeFromTrash(allNodes, fromTrash); + // 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)); - toast.success(t('files.deleted_forever', '{count, plural, one {Deleted permanently} other {# items deleted permanently}}', { - count: targets.length, - })); + 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, allNodes, clearSelection, loadFiles, t]); + }, [confirmDelete, clearSelection, loadFiles, t]); const restoreRows = useCallback(async (rows: FileRow[]) => { const items = rows.flatMap((r) => (r.trash ? [r.trash] : [])); @@ -784,13 +827,13 @@ export default function FilesScreen() { setConfirmEmptyTrash(false); clearSelection(); try { - await emptyTrash(allNodes); + 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'); - }, [allNodes, clearSelection, loadFiles, t]); + }, [clearSelection, loadFiles, t]); const sortLabel = t(`files.${sortKey}`, sortKey); const SortDirIcon = sortDir === 'desc' ? ArrowDown : ArrowUp; From 323fea7451b875f83d574d3500785de9ce78e827 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:57:10 +0100 Subject: [PATCH 03/11] fix: undo and restore against the drive as it is now, not the listing from before the delete --- src/api/__tests__/files-trash.test.ts | 57 +++++++++++++++++-- src/api/__tests__/live-stalwart-files.test.ts | 8 +-- src/api/files-trash.ts | 33 +++++++---- src/screens/FilesScreen.tsx | 20 ++++--- 4 files changed, 88 insertions(+), 30 deletions(-) diff --git a/src/api/__tests__/files-trash.test.ts b/src/api/__tests__/files-trash.test.ts index 9b6ff70f..39056804 100644 --- a/src/api/__tests__/files-trash.test.ts +++ b/src/api/__tests__/files-trash.test.ts @@ -34,7 +34,14 @@ function serve(method: string, args: Record) { if (method === 'FileNode/get') return { list: store.map((n) => ({ ...n })) }; calls.push(args); const out: Record = {}; + // 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)) { + 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 }; @@ -45,6 +52,11 @@ function serve(method: string, args: Record) { (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; } @@ -164,7 +176,7 @@ describe('restoreFromTrash', () => { await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007); nodes = listed(); const batch = store.find((n) => n.name === '1730112000007~docs')!; - await restoreFromTrash(nodes, trashedItems(nodes)); + 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(); @@ -178,7 +190,7 @@ describe('restoreFromTrash', () => { await moveToTrash(nodes, nodes.filter((n) => n.parentId === 'docs'), 1730112000007); nodes = listed(); const batch = store.find((n) => n.name === '1730112000007~docs')!; - await restoreFromTrash(nodes, trashedItems(nodes).filter((i) => i.node.id === 'a')); + await restoreFromTrash(trashedItems(nodes).filter((i) => i.node.id === 'a')); expect(byId(batch.id)).toBeDefined(); expect(byId('b')!.parentId).toBe(batch.id); }); @@ -191,26 +203,59 @@ describe('restoreFromTrash', () => { await moveToTrash(nodes, nodes.filter((n) => n.id === 'docs'), 1730112000008); nodes = listed(); // Docs is in the trash itself now: a.txt goes to the root. - await restoreFromTrash(nodes, trashedItems(nodes).filter((i) => i.node.id === 'a')); + await restoreFromTrash(trashedItems(nodes).filter((i) => i.node.id === 'a')); expect(byId('a')).toMatchObject({ parentId: null, name: 'a.txt' }); nodes = listed(); - await restoreFromTrash(nodes, trashedItems(nodes).filter((i) => i.node.id === 'docs')); + await 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(nodes, trashedItems(nodes).filter((i) => i.node.id === 'a2')); + 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(before, done); + 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('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('purgeFromTrash and emptyTrash', () => { diff --git a/src/api/__tests__/live-stalwart-files.test.ts b/src/api/__tests__/live-stalwart-files.test.ts index 85dd47b9..7600ac61 100644 --- a/src/api/__tests__/live-stalwart-files.test.ts +++ b/src/api/__tests__/live-stalwart-files.test.ts @@ -298,14 +298,14 @@ live('live Stalwart files trash', () => { const item = all.find((i) => i.node.name === 'trash-me.txt' && i.fromParentId === scratch)!; const sibling = all.find((i) => i.node.name === 'Trash me' && i.batchId === item.batchId)!; let list = await own(); - const { done } = await restoreFromTrash(list, [item]); + 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(list, [sibling]); + 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); @@ -324,7 +324,7 @@ live('live Stalwart files trash', () => { await deleteFileNodes([sub.id]); list = await own(); - await restoreFromTrash(list, [(await trashed(a.id))!, (await trashed(b.id))!]); + 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)!; @@ -342,7 +342,7 @@ live('live Stalwart files trash', () => { const { done } = await moveToTrash(list, [list.find((n) => n.id === file.id)!]); batches.add(done[0].batchId!); // The rows on screen still show it where it was. - await restoreFromTrash(list, done); + await restoreFromTrash(done); const after = await own(); expect(after.find((n) => n.id === file.id)).toMatchObject({ name: 'undo-me.txt', parentId: scratch }); expect(after.some((n) => n.id === done[0].batchId)).toBe(false); diff --git a/src/api/files-trash.ts b/src/api/files-trash.ts index f7db1464..a51af3da 100644 --- a/src/api/files-trash.ts +++ b/src/api/files-trash.ts @@ -100,23 +100,34 @@ export async function moveToTrash( /** * Put items of the trash back into the folder each came from, or the root * when that folder is gone or in the trash itself. A name taken there by now - * gets " (1)" and so on. Their trash folders go once they are empty. Throws - * when nothing went. + * 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. */ -export async function restoreFromTrash(nodes: FileNode[], items: TrashedItem[]): Promise { +export async function restoreFromTrash(items: TrashedItem[]): Promise { const result: TrashWriteResult = { done: [], refused: [] }; if (items.length === 0) return result; - const hidden = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id); - const restoring = new Set(items.map((item) => item.node.id)); - // Names taken in each folder, so a restored item never clashes; the items - // themselves don't count (after an undo the listing may still show them - // where they were). + const nodes = await getAllFileNodes(); + const trash = findTrashFolder(nodes); + const hidden = trashSubtreeIds(nodes, trash?.id); + // 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. const taken = new Map>(); const namesIn = (parentId: string | null): Set => { let names = taken.get(parentId); if (!names) { names = new Set(nodes - .filter((n) => (n.parentId ?? null) === parentId && !n.isShared && !hidden.has(n.id) && !restoring.has(n.id)) + .filter((n) => (n.parentId ?? null) === parentId && !n.isShared && !hidden.has(n.id)) .map((n) => n.name)); taken.set(parentId, names); } @@ -125,7 +136,7 @@ export async function restoreFromTrash(nodes: FileNode[], items: TrashedItem[]): const updates: Record>> = {}; const targets = new Map(); - for (const item of items) { + for (const item of pending) { const target = restoreTarget(item, nodes); const used = namesIn(target); const name = getUniqueName(item.node.name, used); @@ -141,7 +152,7 @@ export async function restoreFromTrash(nodes: FileNode[], items: TrashedItem[]): if (restored.size === 0) { throw result.refused[0] ?? new JMAPSetError('setError', 'Failed to restore'); } - for (const item of items.filter((i) => restored.has(i.node.id))) { + 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 } }); } diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index e671c927..8edef477 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -744,12 +744,12 @@ export default function FilesScreen() { } }, [uploading, currentParentId, allNodes, loadFiles, t]); - // Undo of a delete: the items go back from the trash, the way Restore puts - // them back, judged against the drive as it was before the delete. - const undoTrash = useCallback(async (listing: FileNode[], items: TrashedItem[]) => { + // 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. + const undoTrash = useCallback(async (items: TrashedItem[]) => { try { - await restoreFromTrash(listing, items); - toast.success(t('files.undo_success', 'Action undone')); + const { done } = await restoreFromTrash(items); + if (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)); } @@ -770,7 +770,7 @@ export default function FilesScreen() { } toast.success( t('files.moved_to_trash', '{count, plural, one {Moved to trash} other {# items moved to trash}}', { count: done.length }), - { action: { label: t('files.undo', 'Undo'), onPress: () => void undoTrash(listing, done) } }, + { action: { label: t('files.undo', 'Undo'), onPress: () => void undoTrash(done) } }, ); } catch (e) { Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t)); @@ -812,16 +812,18 @@ export default function FilesScreen() { if (items.length === 0) return; clearSelection(); try { - const { done, refused } = await restoreFromTrash(allNodes, items); + const { done, refused } = await restoreFromTrash(items); if (refused.length > 0) { Alert.alert(t('files.restore_error', "Couldn't restore"), fileErrorMessage(refused[0], t)); } - toast.success(t('files.restored', '{count, plural, one {Item restored} other {# items restored}}', { count: done.length })); + 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'); - }, [allNodes, clearSelection, loadFiles, t]); + }, [clearSelection, loadFiles, t]); const performEmptyTrash = useCallback(async () => { setConfirmEmptyTrash(false); From caeec2bdab8a56885d7d1b56f03204ad189260a6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:00:35 +0100 Subject: [PATCH 04/11] fix: let Undo of a Files delete do nothing once another account is signed in --- src/api/__tests__/files-trash.test.ts | 69 ++++++++++++++++++++++- src/api/files-trash.ts | 80 ++++++++++++++++++++++++--- src/screens/FilesScreen.tsx | 26 ++++++--- 3 files changed, 159 insertions(+), 16 deletions(-) diff --git a/src/api/__tests__/files-trash.test.ts b/src/api/__tests__/files-trash.test.ts index 39056804..c3830df4 100644 --- a/src/api/__tests__/files-trash.test.ts +++ b/src/api/__tests__/files-trash.test.ts @@ -3,6 +3,8 @@ 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), @@ -19,7 +21,7 @@ vi.mock('../jmap-client', () => ({ import { jmapClient } from '../jmap-client'; import type { FileNode } from '../types'; -import { emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash } from '../files-trash'; +import { 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 @@ -83,7 +85,15 @@ function serve(method: string, args: Record) { 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 } }; +function signIn(username: string, filesAccount = 'acc-1') { + client.username = username; + client.currentSession.primaryAccounts['urn:ietf:params:jmap:filenode'] = filesAccount; +} + beforeEach(() => { + signIn('me@example.org'); nextId = 1; calls = []; refuseUpdate = new Set(); @@ -258,6 +268,63 @@ describe('restoreFromTrash', () => { }); }); +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).mock.calls.length; + expect(await undoMoveToTrash(moved)).toBeNull(); + expect(calls.length).toBe(sent); + expect((jmapClient.request as ReturnType).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).getMockImplementation() as (c: any[]) => Promise; + (jmapClient.request as ReturnType).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).getMockImplementation() as (c: any[]) => Promise; + (jmapClient.request as ReturnType).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(); diff --git a/src/api/files-trash.ts b/src/api/files-trash.ts index a51af3da..5c651c6f 100644 --- a/src/api/files-trash.ts +++ b/src/api/files-trash.ts @@ -1,6 +1,8 @@ import type { FileNode } from './types'; +import { jmapClient } from './jmap-client'; import { - createFolder, deleteFileNodes, destroyEmptyFolders, getAllFileNodes, isCrossAccountId, updateFileNodes, + createFolder, deleteFileNodes, destroyEmptyFolders, filesAccountId, getAllFileNodes, isCrossAccountId, + updateFileNodes, } from './files'; import { JMAPSetError } from './jmap-result'; import { getUniqueName } from '../lib/filenode-name'; @@ -22,6 +24,44 @@ export interface TrashWriteResult { 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 folder, created on the first delete. */ async function ensureTrashFolder(nodes: FileNode[]): Promise { const known = findTrashFolder(nodes); @@ -47,14 +87,15 @@ async function removeEmptyBatches(nodes: FileNode[], batchIds: Iterable, * Move nodes of the user's own drive into the trash: one trash folder per * folder they came from, named after the time and that folder, like the * webmail does. A node of a shared drive can't go there; delete it instead. - * Throws when nothing went. + * Stops once another account is signed in. Throws when nothing went. */ export async function moveToTrash( nodes: FileNode[], targets: FileNode[], deletedAt: number = Date.now(), -): Promise { - const result: TrashWriteResult = { done: [], refused: [] }; +): Promise { + 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'); @@ -74,12 +115,15 @@ export async function moveToTrash( // 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); return createFolder(name, trash.id); } }; for (const [parentId, group] of groups) { + if (!isCurrentDrive(drive)) break; const batch = await createBatch(parentId); + 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)); @@ -92,6 +136,7 @@ export async function moveToTrash( } } if (result.done.length === 0) { + assertDrive(drive); throw result.refused[0] ?? new JMAPSetError('setError', 'Failed to move to the trash'); } return result; @@ -105,12 +150,16 @@ export async function moveToTrash( * 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. + * 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[]): Promise { +export async function restoreFromTrash(items: TrashedItem[], drive?: TrashDrive): Promise { const result: TrashWriteResult = { done: [], refused: [] }; if (items.length === 0) return result; + assertDrive(drive); const nodes = await getAllFileNodes(); + assertDrive(drive); const trash = findTrashFolder(nodes); const hidden = trashSubtreeIds(nodes, trash?.id); // The items as the trash holds them now: their name and trash folder may @@ -146,6 +195,7 @@ export async function restoreFromTrash(items: TrashedItem[]): Promise (i.batchId ? [i.batchId] : [])), restored); + 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): Promise { + 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 diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index 8edef477..de35b30b 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -51,7 +51,9 @@ import { getFileNameRules, getFileNodeDownloadUrl, getMaxSizeUpload, isFolder, isSymlink, listFolder, moveFileNode, renameFileNode, supportsSharing, uploadFileBatch, } from '../api/files'; -import { emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash } from '../api/files-trash'; +import { + emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash, undoMoveToTrash, type TrashMoveResult, +} from '../api/files-trash'; import { trashOriginPath, trashedItems, withoutTrash, type TrashedItem } from '../lib/files-trash'; import { toast } from '../stores/toast-store'; import { jmapClient } from '../api/jmap-client'; @@ -317,12 +319,16 @@ 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(undefined); + const undoToastRef = useRef(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); @@ -745,11 +751,14 @@ 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. - const undoTrash = useCallback(async (items: TrashedItem[]) => { + // 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 { done } = await restoreFromTrash(items); - if (done.length > 0) toast.success(t('files.undo_success', 'Action undone')); + 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)); } @@ -763,14 +772,15 @@ export default function FilesScreen() { clearSelection(); const listing = allNodes; try { - const { done, refused } = await moveToTrash(listing, rows); + 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)); } - toast.success( + 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(done) } }, + { action: { label: t('files.undo', 'Undo'), onPress: () => void undoTrash(moved) } }, ); } catch (e) { Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t)); From 8be9743b80cff104757e33cad7f974bdc28f8270 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:03:57 +0100 Subject: [PATCH 05/11] fix: keep the Files trash out of the Recent files widget --- src/widgets/__tests__/recent-files.test.ts | 24 ++++++++++++ src/widgets/build.ts | 43 ++++++++++++++-------- 2 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 src/widgets/__tests__/recent-files.test.ts diff --git a/src/widgets/__tests__/recent-files.test.ts b/src/widgets/__tests__/recent-files.test.ts new file mode 100644 index 00000000..5548e53e --- /dev/null +++ b/src/widgets/__tests__/recent-files.test.ts @@ -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']); + }); +}); diff --git a/src/widgets/build.ts b/src/widgets/build.ts index fba4516f..ed5478f5 100644 --- a/src/widgets/build.ts +++ b/src/widgets/build.ts @@ -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, + trash: Pick, +): 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 { 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; } From e1e6af3cca3b8aa1a2785db112894499fc48a2c7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:07:40 +0100 Subject: [PATCH 06/11] fix: offer to delete permanently when a Files delete can't go to the trash --- locales/rn/en.json | 1 + src/api/__tests__/files-trash.test.ts | 27 +++++++++++++++++++++++- src/api/files-trash.ts | 30 ++++++++++++++++++++++++--- src/screens/FilesScreen.tsx | 17 +++++++++++---- 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/locales/rn/en.json b/locales/rn/en.json index f81aa537..0075aa38 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -515,6 +515,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.", diff --git a/src/api/__tests__/files-trash.test.ts b/src/api/__tests__/files-trash.test.ts index c3830df4..8229d90f 100644 --- a/src/api/__tests__/files-trash.test.ts +++ b/src/api/__tests__/files-trash.test.ts @@ -21,7 +21,9 @@ vi.mock('../jmap-client', () => ({ import { jmapClient } from '../jmap-client'; import type { FileNode } from '../types'; -import { emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash, undoMoveToTrash } from '../files-trash'; +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 @@ -171,6 +173,29 @@ describe('moveToTrash', () => { 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).getMockImplementation() as (c: any[]) => Promise; + (jmapClient.request as ReturnType).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(); diff --git a/src/api/files-trash.ts b/src/api/files-trash.ts index 5c651c6f..6d6d3114 100644 --- a/src/api/files-trash.ts +++ b/src/api/files-trash.ts @@ -62,6 +62,18 @@ 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 { const known = findTrashFolder(nodes); @@ -72,7 +84,7 @@ async function ensureTrashFolder(nodes: FileNode[]): Promise { // Another client may have made it since the drive was listed. const fresh = findTrashFolder(await getAllFileNodes()); if (fresh) return fresh; - throw err; + throw new TrashUnavailableError(err); } } @@ -116,13 +128,25 @@ export async function moveToTrash( // the drive again and try once more. trash = await ensureTrashFolder(await getAllFileNodes()); assertDrive(drive); - return createFolder(name, trash.id); + try { + return await createFolder(name, trash.id); + } catch (err) { + throw new TrashUnavailableError(err); + } } }; for (const [parentId, group] of groups) { if (!isCurrentDrive(drive)) break; - const batch = await createBatch(parentId); + 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); diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index de35b30b..bda92a6d 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -52,7 +52,8 @@ import { isSymlink, listFolder, moveFileNode, renameFileNode, supportsSharing, uploadFileBatch, } from '../api/files'; import { - emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash, undoMoveToTrash, type TrashMoveResult, + TrashUnavailableError, emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash, undoMoveToTrash, + type TrashMoveResult, } from '../api/files-trash'; import { trashOriginPath, trashedItems, withoutTrash, type TrashedItem } from '../lib/files-trash'; import { toast } from '../stores/toast-store'; @@ -232,7 +233,8 @@ export default function FilesScreen() { // The trash (lib/files-trash) is a place of its own, entered from the root. const [inTrash, setInTrash] = useState(false); - const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[] } | null>(null); + // `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(''); @@ -783,7 +785,10 @@ export default function FilesScreen() { { action: { label: t('files.undo', 'Undo'), onPress: () => void undoTrash(moved) } }, ); } catch (e) { - Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t)); + // 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]); @@ -1393,7 +1398,11 @@ export default function FilesScreen() { count: confirmDelete?.rows.length ?? 1, })} message={ - confirmDelete && confirmDelete.rows.length > 1 + 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, }) From d908f95fd7edff585db9013180b1bad99d38f00c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:10:35 +0100 Subject: [PATCH 07/11] fix: refuse .Trash as a name at the top of Files, where it would become the trash --- locales/rn/en.json | 1 + src/api/__tests__/files-trash.test.ts | 12 +++++++++++ src/api/files-trash.ts | 2 ++ src/lib/__tests__/files-trash.test.ts | 16 +++++++++++++-- src/lib/files-trash.ts | 11 ++++++++++ src/screens/FilesScreen.tsx | 29 +++++++++++++++++++++------ 6 files changed, 63 insertions(+), 8 deletions(-) diff --git a/locales/rn/en.json b/locales/rn/en.json index 0075aa38..6e824fe1 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -480,6 +480,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.", diff --git a/src/api/__tests__/files-trash.test.ts b/src/api/__tests__/files-trash.test.ts index 8229d90f..5dbaa249 100644 --- a/src/api/__tests__/files-trash.test.ts +++ b/src/api/__tests__/files-trash.test.ts @@ -277,6 +277,18 @@ describe('restoreFromTrash', () => { expect(byId('a2')).toMatchObject({ parentId: 'docs', name: 'a.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); diff --git a/src/api/files-trash.ts b/src/api/files-trash.ts index 6d6d3114..1c03f28b 100644 --- a/src/api/files-trash.ts +++ b/src/api/files-trash.ts @@ -202,6 +202,8 @@ export async function restoreFromTrash(items: TrashedItem[], drive?: TrashDrive) names = new Set(nodes .filter((n) => (n.parentId ?? null) === parentId && !n.isShared && !hidden.has(n.id)) .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; diff --git a/src/lib/__tests__/files-trash.test.ts b/src/lib/__tests__/files-trash.test.ts index 5caa6351..2afbf50e 100644 --- a/src/lib/__tests__/files-trash.test.ts +++ b/src/lib/__tests__/files-trash.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from 'vitest'; import type { FileNode } from '../../api/types'; import { - findTrashFolder, parseTrashBatchName, restoreTarget, trashBatchName, trashOriginPath, trashSubtreeIds, - trashedItems, withoutTrash, + findTrashFolder, parseTrashBatchName, restoreTarget, takesTrashName, trashBatchName, trashOriginPath, + trashSubtreeIds, trashedItems, withoutTrash, } from '../files-trash'; // The webmail's trash convention (its lib/files-trash.ts), read the same way here. @@ -76,6 +76,18 @@ describe('withoutTrash', () => { }); }); +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'); diff --git a/src/lib/files-trash.ts b/src/lib/files-trash.ts index a7e0e23e..43046bbc 100644 --- a/src/lib/files-trash.ts +++ b/src/lib/files-trash.ts @@ -28,6 +28,17 @@ export function findTrashFolder(nodes: FileNode[]): FileNode | undefined { && 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 ?? ''}`; diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index bda92a6d..68df05bb 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -55,7 +55,9 @@ import { TrashUnavailableError, emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash, undoMoveToTrash, type TrashMoveResult, } from '../api/files-trash'; -import { trashOriginPath, trashedItems, withoutTrash, type TrashedItem } from '../lib/files-trash'; +import { + TRASH_FOLDER_NAME, takesTrashName, trashOriginPath, trashedItems, withoutTrash, type TrashedItem, +} from '../lib/files-trash'; import { toast } from '../stores/toast-store'; import { jmapClient } from '../api/jmap-client'; import { downloadAttachment, shareAttachment } from '../lib/email-export'; @@ -178,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 { @@ -604,12 +611,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]); @@ -617,7 +628,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 { @@ -635,7 +646,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(''); @@ -666,6 +677,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); @@ -730,6 +745,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, { From 4a56e825e4dc359f876700571018192e7e467203 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:13:31 +0100 Subject: [PATCH 08/11] fix: work out where trashed items came from once per listing instead of once per row --- src/api/files-trash.ts | 7 ++-- src/lib/__tests__/files-trash.test.ts | 21 +++++++++- src/lib/files-trash.ts | 60 ++++++++++++++++++++------- src/screens/FilesScreen.tsx | 6 ++- 4 files changed, 72 insertions(+), 22 deletions(-) diff --git a/src/api/files-trash.ts b/src/api/files-trash.ts index 1c03f28b..9a4028ae 100644 --- a/src/api/files-trash.ts +++ b/src/api/files-trash.ts @@ -7,7 +7,7 @@ import { import { JMAPSetError } from './jmap-result'; import { getUniqueName } from '../lib/filenode-name'; import { - TRASH_FOLDER_NAME, findTrashFolder, restoreTarget, trashBatchName, trashSubtreeIds, trashedItems, + TRASH_FOLDER_NAME, findTrashFolder, restoreTarget, trashBatchName, trashPlaces, trashSubtreeIds, trashedItems, type TrashedItem, } from '../lib/files-trash'; @@ -185,7 +185,8 @@ export async function restoreFromTrash(items: TrashedItem[], drive?: TrashDrive) const nodes = await getAllFileNodes(); assertDrive(drive); const trash = findTrashFolder(nodes); - const hidden = trashSubtreeIds(nodes, trash?.id); + 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])); @@ -212,7 +213,7 @@ export async function restoreFromTrash(items: TrashedItem[], drive?: TrashDrive) const updates: Record>> = {}; const targets = new Map(); for (const item of pending) { - const target = restoreTarget(item, nodes); + const target = restoreTarget(item, places); const used = namesIn(target); const name = getUniqueName(item.node.name, used); used.add(name); diff --git a/src/lib/__tests__/files-trash.test.ts b/src/lib/__tests__/files-trash.test.ts index 2afbf50e..b49c86ba 100644 --- a/src/lib/__tests__/files-trash.test.ts +++ b/src/lib/__tests__/files-trash.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import type { FileNode } from '../../api/types'; import { findTrashFolder, parseTrashBatchName, restoreTarget, takesTrashName, trashBatchName, trashOriginPath, - trashSubtreeIds, trashedItems, withoutTrash, + trashPlaces, trashSubtreeIds, trashedItems, withoutTrash, } from '../files-trash'; // The webmail's trash convention (its lib/files-trash.ts), read the same way here. @@ -107,4 +107,23 @@ describe('trashOriginPath', () => { 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); + }); }); diff --git a/src/lib/files-trash.ts b/src/lib/files-trash.ts index 43046bbc..dad0ee40 100644 --- a/src/lib/files-trash.ts +++ b/src/lib/files-trash.ts @@ -55,14 +55,22 @@ export function parseTrashBatchName(name: string): { deletedAt: number; parentId export function trashSubtreeIds(nodes: FileNode[], trashId: string | undefined): Set { const ids = new Set(); 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(); + 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); - let grew = true; - while (grew) { - grew = false; - for (const n of nodes) { - if (n.parentId && ids.has(n.parentId) && !ids.has(n.id)) { - ids.add(n.id); - grew = true; + 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); } } } @@ -102,26 +110,46 @@ export function trashedItems(nodes: FileNode[], trash: FileNode | undefined = fi 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; + /** The trash and everything in it. */ + hidden: Set; +} + +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, nodes: FileNode[]): string | null { - const from = item.fromParentId; - if (from === null) return null; - const hidden = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id); - const folder = nodes.find((n) => n.id === from); - return folder && isFolder(folder) && !folder.isShared && !hidden.has(folder.id) ? from : null; +export function restoreTarget(item: Pick, 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, nodes: FileNode[]): string | null { +export function trashOriginPath(item: Pick, from: FileNode[] | TrashPlaces): string | null { if (item.fromParentId === null) return '/'; - if (restoreTarget(item, nodes) === null) return null; - const byId = new Map(nodes.map((n) => [n.id, n])); + const places = placesOf(from); + if (restoreTarget(item, places) === null) return null; + const { byId } = places; const names: string[] = []; const seen = new Set(); for (let cur = byId.get(item.fromParentId); cur && !seen.has(cur.id); cur = cur.parentId ? byId.get(cur.parentId) : undefined) { diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index 68df05bb..f3e764cd 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -56,7 +56,7 @@ import { type TrashMoveResult, } from '../api/files-trash'; import { - TRASH_FOLDER_NAME, takesTrashName, trashOriginPath, trashedItems, withoutTrash, type TrashedItem, + 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'; @@ -281,6 +281,8 @@ export default function FilesScreen() { // 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. Nothing is // added to the trash other than by deleting. @@ -1076,7 +1078,7 @@ export default function FilesScreen() { // Where a deleted item came from, on its row in the trash. const trashOrigin = (item: TrashedItem): string | null => { - const from = trashOriginPath(item, allNodes); + 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, From 5ea19a85814e117a04b28f401307b90c1978ecde Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:17:48 +0100 Subject: [PATCH 09/11] fix: restore a trashed item into its folder when that folder is restored with it --- src/api/__tests__/files-trash.test.ts | 32 ++++++++++++ src/api/files-trash.ts | 73 ++++++++++++++++++++------- 2 files changed, 87 insertions(+), 18 deletions(-) diff --git a/src/api/__tests__/files-trash.test.ts b/src/api/__tests__/files-trash.test.ts index 5dbaa249..dcb29ea6 100644 --- a/src/api/__tests__/files-trash.test.ts +++ b/src/api/__tests__/files-trash.test.ts @@ -277,6 +277,38 @@ describe('restoreFromTrash', () => { 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 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(); diff --git a/src/api/files-trash.ts b/src/api/files-trash.ts index 9a4028ae..3bb15c15 100644 --- a/src/api/files-trash.ts +++ b/src/api/files-trash.ts @@ -2,7 +2,7 @@ import type { FileNode } from './types'; import { jmapClient } from './jmap-client'; import { createFolder, deleteFileNodes, destroyEmptyFolders, filesAccountId, getAllFileNodes, isCrossAccountId, - updateFileNodes, + isFolder, updateFileNodes, } from './files'; import { JMAPSetError } from './jmap-result'; import { getUniqueName } from '../lib/filenode-name'; @@ -168,8 +168,9 @@ export async function moveToTrash( /** * Put items of the trash back into the folder each came from, or the root - * when that folder is gone or in the trash itself. A name taken there by now - * gets " (1)" and so on. Their trash folders go once they are empty. + * 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 @@ -195,13 +196,14 @@ export async function restoreFromTrash(items: TrashedItem[], drive?: TrashDrive) return item ? [item] : []; }); if (pending.length === 0) return result; - // Names taken in each folder, so a restored item never clashes. + // 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>(); const namesIn = (parentId: string | null): Set => { let names = taken.get(parentId); if (!names) { names = new Set(nodes - .filter((n) => (n.parentId ?? null) === parentId && !n.isShared && !hidden.has(n.id)) + .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); @@ -210,22 +212,57 @@ export async function restoreFromTrash(items: TrashedItem[], drive?: TrashDrive) 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(); + 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 restored = new Set(); + const tried = new Set(); const updates: Record>> = {}; const targets = new Map(); - for (const item of pending) { - const target = restoreTarget(item, places); - const used = namesIn(target); - const name = getUniqueName(item.node.name, used); - used.add(name); - // The name only goes along when it has to change: the listing shows a - // WebDAV name decoded, and sending that back would rename the file. - updates[item.node.id] = name === item.node.name ? { parentId: target } : { parentId: target, name }; - targets.set(item.node.id, target); + 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); + const name = getUniqueName(item.node.name, used); + used.add(name); + // The name only goes along when it has to change: the listing shows a + // WebDAV name decoded, and sending that back would rename the file. + 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)); } - assertDrive(drive); - const update = await updateFileNodes(updates); - result.refused.push(...Object.values(update.notUpdated)); - const restored = new Set(update.updated); if (restored.size === 0) { throw result.refused[0] ?? new JMAPSetError('setError', 'Failed to restore'); } From 3bfdf8cef91a4778ce6d0ea38f613e0d1c52808f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:19:43 +0100 Subject: [PATCH 10/11] fix: give a restored file with a clashing WebDAV name a free name the server accepts --- src/api/__tests__/files-trash.test.ts | 14 ++++++++++++++ src/api/files-trash.ts | 12 +++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/api/__tests__/files-trash.test.ts b/src/api/__tests__/files-trash.test.ts index dcb29ea6..a927f340 100644 --- a/src/api/__tests__/files-trash.test.ts +++ b/src/api/__tests__/files-trash.test.ts @@ -96,6 +96,8 @@ function signIn(username: string, filesAccount = 'acc-1') { beforeEach(() => { signIn('me@example.org'); + // No naming rules published unless a test says so. + (jmapClient.getAccountCapability as ReturnType).mockReturnValue(undefined); nextId = 1; calls = []; refuseUpdate = new Set(); @@ -309,6 +311,18 @@ describe('restoreFromTrash', () => { 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).mockReturnValue({ forbiddenNameChars: ':*' }); + await restoreFromTrash(trashedItems(listed())); + const update = calls.filter((c) => c.update).pop()!.update as Record>; + 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(); diff --git a/src/api/files-trash.ts b/src/api/files-trash.ts index 3bb15c15..23e32bef 100644 --- a/src/api/files-trash.ts +++ b/src/api/files-trash.ts @@ -1,11 +1,12 @@ import type { FileNode } from './types'; import { jmapClient } from './jmap-client'; import { - createFolder, deleteFileNodes, destroyEmptyFolders, filesAccountId, getAllFileNodes, isCrossAccountId, - isFolder, updateFileNodes, + 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, @@ -226,6 +227,7 @@ export async function restoreFromTrash(items: TrashedItem[], drive?: TrashDrive) } return null; }; + const rules = getFileNameRules(); const restored = new Set(); const tried = new Set(); const updates: Record>> = {}; @@ -249,7 +251,11 @@ export async function restoreFromTrash(items: TrashedItem[], drive?: TrashDrive) ? from : restoreTarget(item, places); const used = namesIn(target); - const name = getUniqueName(item.node.name, used); + 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. From 946304ab762b946efcc8a9f365b08ce81a33a156 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:24:43 +0100 Subject: [PATCH 11/11] test: check the Files trash fixes live on Stalwart 0.16 and 1.0 --- src/api/__tests__/live-stalwart-files.test.ts | 81 +++++++++++++++++-- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/src/api/__tests__/live-stalwart-files.test.ts b/src/api/__tests__/live-stalwart-files.test.ts index 7600ac61..7c4d7d4e 100644 --- a/src/api/__tests__/live-stalwart-files.test.ts +++ b/src/api/__tests__/live-stalwart-files.test.ts @@ -47,7 +47,9 @@ import { renameFileNode, uploadFileBatch, } from '../files'; -import { emptyTrash, moveToTrash, purgeFromTrash, restoreFromTrash } from '../files-trash'; +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; @@ -250,6 +252,23 @@ live('live Stalwart files trash', () => { 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); @@ -336,16 +355,64 @@ live('live Stalwart files trash', () => { } }, 120_000); - it('undoes a delete with the listing from before it', async () => { + it('undoes a delete', async () => { const file = await upload('undo-me.txt', scratch); const list = await own(); - const { done } = await moveToTrash(list, [list.find((n) => n.id === file.id)!]); - batches.add(done[0].batchId!); - // The rows on screen still show it where it was. - await restoreFromTrash(done); + 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 === done[0].batchId)).toBe(false); + 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 () => {