diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index ae2f9dcbb..cf7029c7a 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -273,6 +273,12 @@ command-copy = } | مصفوفة | Enter=إنهاء | الأساس { $x },{ $y }]: common = + .recover-drawing = استرداد الرسم + .restore-it-as-a-recovered-tab-or-discard-the-copy = استعده كعلامة تبويب مستردة أو تجاهل النسخة. + .name-has-an-autosave-copy-newer-than-the-last-save = لدى "__ocs_fmt_0__" نسخة حفظ تلقائي أحدث من آخر حفظ. + .autosave-copy-could-not-be-stored = تعذر تخزين نسخة الحفظ التلقائي: __ocs_fmt_0__ + .restored-from-the-autosave-copy = تم استرداد "__ocs_fmt_0__" من نسخة الحفظ التلقائي + .autosave-copy-could-not-be-read = تعذر قراءة نسخة الحفظ التلقائي: __ocs_fmt_0__ .jogged-radius = نصف قطر منكسر .none-use-shortcuts-set-key-command = (لا شيء — استخدم: SHORTCUTS SET ) .alignment-indented-label-title-case = المحاذاة: diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 44b46ade8..0b05cd024 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -260,6 +260,12 @@ command-copy = *[other] { $count } копия досега } | Масив | Enter=готово | база { $x },{ $y }]: common = + .recover-drawing = Възстановяване на чертеж + .restore-it-as-a-recovered-tab-or-discard-the-copy = Възстановете го като възстановен раздел или отхвърлете копието. + .name-has-an-autosave-copy-newer-than-the-last-save = "__ocs_fmt_0__" има копие от автоматично записване, по-ново от последния запис. + .autosave-copy-could-not-be-stored = Копието от автоматичното записване не можа да бъде съхранено: __ocs_fmt_0__ + .restored-from-the-autosave-copy = "__ocs_fmt_0__" беше възстановен от копието от автоматичното записване + .autosave-copy-could-not-be-read = Копието от автоматичното записване не можа да бъде прочетено: __ocs_fmt_0__ .jogged-radius = Радиус с чупка .none-use-shortcuts-set-key-command = (няма — използвай: SHORTCUTS SET <клавиш> <команда>) .alignment-indented-label-title-case = Подравняване: diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index ff24f0b1f..9a71e4089 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -260,6 +260,12 @@ command-copy = } | Pole | Enter=hotovo | základna { $x },{ $y }]: common = + .recover-drawing = Obnovit výkres + .restore-it-as-a-recovered-tab-or-discard-the-copy = Obnovte jej jako obnovenou kartu nebo kopii zahoďte. + .name-has-an-autosave-copy-newer-than-the-last-save = "__ocs_fmt_0__" má kopii automatického uložení novější než poslední uložení. + .autosave-copy-could-not-be-stored = Kopii automatického uložení nelze uložit: __ocs_fmt_0__ + .restored-from-the-autosave-copy = "__ocs_fmt_0__" byl obnoven z kopie automatického uložení + .autosave-copy-could-not-be-read = Kopii automatického uložení nelze přečíst: __ocs_fmt_0__ .jogged-radius = Zalomený poloměr .none-use-shortcuts-set-key-command =(žádné — použijte: SHORTCUTS SET ) .alignment-indented-label-title-case = Zarovnání: diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index bad405a19..583328919 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -261,6 +261,12 @@ command-copy = } | Array | Eingabe=fertig | Basis { $x },{ $y }]: common = + .recover-drawing = Zeichnung wiederherstellen + .restore-it-as-a-recovered-tab-or-discard-the-copy = Als wiederhergestellte Registerkarte öffnen oder die Kopie verwerfen. + .name-has-an-autosave-copy-newer-than-the-last-save = Für „__ocs_fmt_0__“ existiert eine automatische Sicherungskopie, die neuer ist als die letzte gespeicherte Version. + .autosave-copy-could-not-be-stored = Automatische Sicherungskopie konnte nicht gespeichert werden: __ocs_fmt_0__ + .restored-from-the-autosave-copy = „__ocs_fmt_0__“ wurde aus der automatischen Sicherungskopie wiederhergestellt + .autosave-copy-could-not-be-read = Automatische Sicherungskopie konnte nicht gelesen werden: __ocs_fmt_0__ .jogged-radius = Geknickter Radius .none-use-shortcuts-set-key-command = (keine — verwenden: SHORTCUTS SET ) .alignment-indented-label-title-case = Ausrichtung: diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 652a44b80..eca71506c 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -260,6 +260,12 @@ command-copy = } | Διάταξη | Enter=τέλος | βάση { $x },{ $y }]: common = + .recover-drawing = Ανάκτηση σχεδίου + .restore-it-as-a-recovered-tab-or-discard-the-copy = Επαναφέρετέ το ως ανακτημένη καρτέλα ή απορρίψτε το αντίγραφο. + .name-has-an-autosave-copy-newer-than-the-last-save = Το "__ocs_fmt_0__" έχει αντίγραφο αυτόματης αποθήκευσης νεότερο της τελευταίας αποθήκευσης. + .autosave-copy-could-not-be-stored = Δεν ήταν δυνατή η αποθήκευση του αντιγράφου αυτόματης αποθήκευσης: __ocs_fmt_0__ + .restored-from-the-autosave-copy = Το "__ocs_fmt_0__" επαναφέρθηκε από το αντίγραφο αυτόματης αποθήκευσης + .autosave-copy-could-not-be-read = Δεν ήταν δυνατή η ανάγνωση του αντιγράφου αυτόματης αποθήκευσης: __ocs_fmt_0__ .revolve-created-objects-failed-sources = REVOLVE: δημιουργήθηκαν __ocs_arg_created__ αντικείμενα· __ocs_arg_failed__ αρχικά αντικείμενα δεν μπόρεσαν να περιστραφούν. .revolve-no-selected-object-could-be-revolved = REVOLVE: κανένα επιλεγμένο αντικείμενο δεν μπόρεσε να περιστραφεί με τις ζητούμενες επιλογές. .none-use-shortcuts-set-key-command = (κανένα — χρησιμοποιήστε: SHORTCUTS SET <πλήκτρο> <εντολή>) diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index a0d1f0b71..c05a98e77 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -260,6 +260,12 @@ command-copy = } | Array | Enter=done | base { $x },{ $y }]: common = + .recover-drawing = Recover Drawing + .restore-it-as-a-recovered-tab-or-discard-the-copy = Restore it as a recovered tab, or discard the copy. + .name-has-an-autosave-copy-newer-than-the-last-save = "__ocs_fmt_0__" has an autosave copy newer than the last save. + .autosave-copy-could-not-be-stored = Autosave copy could not be stored: __ocs_fmt_0__ + .restored-from-the-autosave-copy = Restored "__ocs_fmt_0__" from the autosave copy + .autosave-copy-could-not-be-read = Autosave copy could not be read: __ocs_fmt_0__ .none-use-shortcuts-set-key-command = (none — use: SHORTCUTS SET ) .alignment-indented-label-title-case = Alignment: .background-fill-enabled = Background fill enabled diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 72ddf64f4..3b595a361 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -261,6 +261,12 @@ command-copy = } | Matriz | Enter=terminar | base { $x },{ $y }]: common = + .recover-drawing = Recuperar dibujo + .restore-it-as-a-recovered-tab-or-discard-the-copy = Restáurelo como una pestaña recuperada o descarte la copia. + .name-has-an-autosave-copy-newer-than-the-last-save = "__ocs_fmt_0__" tiene una copia de autoguardado más reciente que la última copia guardada. + .autosave-copy-could-not-be-stored = No se pudo guardar la copia de autoguardado: __ocs_fmt_0__ + .restored-from-the-autosave-copy = "__ocs_fmt_0__" restaurado desde la copia de autoguardado + .autosave-copy-could-not-be-read = No se pudo leer la copia de autoguardado: __ocs_fmt_0__ .jogged-radius = Radio con recodo .none-use-shortcuts-set-key-command = (ninguno — use: SHORTCUTS SET ) .alignment-indented-label-title-case = Alineación: diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index f8ece8573..6712eacb6 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -260,6 +260,12 @@ command-copy = } | taulukko | Enter=valmis | pohja { $x },{ $y }]: common = + .recover-drawing = Palauta piirustus + .restore-it-as-a-recovered-tab-or-discard-the-copy = Palauta se palautettuna välilehtenä tai hylkää kopio. + .name-has-an-autosave-copy-newer-than-the-last-save = Kohteella "__ocs_fmt_0__" on automaattisen tallennuksen kopio, joka on uudempi kuin viimeisin tallennus. + .autosave-copy-could-not-be-stored = Automaattisen tallennuksen kopiota ei voitu tallentaa: __ocs_fmt_0__ + .restored-from-the-autosave-copy = "__ocs_fmt_0__" palautettiin automaattisen tallennuksen kopiosta + .autosave-copy-could-not-be-read = Automaattisen tallennuksen kopiota ei voitu lukea: __ocs_fmt_0__ .jogged-radius = Katkaistu säde .none-use-shortcuts-set-key-command =(ei mitään — käytä: SHORTCUTS SET ) .alignment-indented-label-title-case = Tasaus: diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 56c3ec309..c3027d400 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -261,6 +261,12 @@ command-copy = } | Réseau | Entrée=terminer | base { $x },{ $y }] : common = + .recover-drawing = Récupérer le dessin + .restore-it-as-a-recovered-tab-or-discard-the-copy = Restaurez-le dans un onglet récupéré ou supprimez la copie. + .name-has-an-autosave-copy-newer-than-the-last-save = «__ocs_fmt_0__» dispose d’une copie de sauvegarde automatique plus récente que le dernier enregistrement. + .autosave-copy-could-not-be-stored = Impossible de stocker la copie de sauvegarde automatique : __ocs_fmt_0__ + .restored-from-the-autosave-copy = «__ocs_fmt_0__» restauré depuis la copie de sauvegarde automatique + .autosave-copy-could-not-be-read = Impossible de lire la copie de sauvegarde automatique : __ocs_fmt_0__ .jogged-radius = Rayon raccourci .none-use-shortcuts-set-key-command = (aucune utilisation: SHORTCUTS SET ) .alignment-indented-label-title-case = Alignement : diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index a237913db..c26a31978 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -249,6 +249,12 @@ command-copy = .target = COPY गंतव्य निर्दिष्ट करें [अब तक { $count } प्रतियाँ | ऐरे | Enter=पूर्ण | आधार { $x },{ $y }]: common = + .recover-drawing = चित्र पुनर्प्राप्त करें + .restore-it-as-a-recovered-tab-or-discard-the-copy = इसे पुनर्प्राप्त टैब के रूप में पुनर्स्थापित करें, या प्रतिलिपि त्याग दें। + .name-has-an-autosave-copy-newer-than-the-last-save = "__ocs_fmt_0__" के पास अंतिम सहेजने से नई ऑटोसेव प्रतिलिपि है। + .autosave-copy-could-not-be-stored = ऑटोसेव प्रतिलिपि संग्रहित नहीं की जा सकी: __ocs_fmt_0__ + .restored-from-the-autosave-copy = "__ocs_fmt_0__" को ऑटोसेव प्रतिलिपि से पुनर्स्थापित किया गया + .autosave-copy-could-not-be-read = ऑटोसेव प्रतिलिपि पढ़ी नहीं जा सकी: __ocs_fmt_0__ .jogged-radius = मोड़ वाला त्रिज्या माप .none-use-shortcuts-set-key-command = (कोई नहीं — उपयोग: SHORTCUTS SET ) .alignment-indented-label-title-case = संरेखण: diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 83e0202fa..3e4cd8dde 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -260,6 +260,12 @@ command-copy = } | tömb | Enter=kész | alap { $x },{ $y }]: common = + .recover-drawing = Rajz helyreállítása + .restore-it-as-a-recovered-tab-or-discard-the-copy = Állítsa helyre egy helyreállított lapon, vagy vesse el a másolatot. + .name-has-an-autosave-copy-newer-than-the-last-save = A(z) "__ocs_fmt_0__" újabb automatikus mentési másolattal rendelkezik, mint az utolsó mentés. + .autosave-copy-could-not-be-stored = Az automatikus mentési másolatot nem sikerült tárolni: __ocs_fmt_0__ + .restored-from-the-autosave-copy = A(z) "__ocs_fmt_0__" helyreállítva az automatikus mentési másolatból + .autosave-copy-could-not-be-read = Az automatikus mentési másolatot nem sikerült elolvasni: __ocs_fmt_0__ .jogged-radius = Tört sugarú méret .none-use-shortcuts-set-key-command =(nincs – használat: SHORTCUTS SET ) .alignment-indented-label-title-case = Igazítás: diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 2e1a28c2c..fb71c8e2f 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -260,6 +260,12 @@ command-copy = } | Serie | Enter=completato | base { $x },{ $y }]: common = + .recover-drawing = Recupera disegno + .restore-it-as-a-recovered-tab-or-discard-the-copy = Ripristinalo in una scheda recuperata oppure elimina la copia. + .name-has-an-autosave-copy-newer-than-the-last-save = "__ocs_fmt_0__" ha una copia di salvataggio automatico più recente dell’ultimo salvataggio. + .autosave-copy-could-not-be-stored = Impossibile salvare la copia di salvataggio automatico: __ocs_fmt_0__ + .restored-from-the-autosave-copy = "__ocs_fmt_0__" ripristinato dalla copia di salvataggio automatico + .autosave-copy-could-not-be-read = Impossibile leggere la copia di salvataggio automatico: __ocs_fmt_0__ .jogged-radius = Raggio spezzato .none-use-shortcuts-set-key-command =(nessuno — utilizzare: SHORTCUTS SET ) .alignment-indented-label-title-case = Allineamento: diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index e14ae20c2..8b959ad50 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -251,6 +251,12 @@ command-copy = .target = COPY 移動先を指定 [これまでに { $count } 個複写 | 配列 | Enter=完了 | 基点 { $x },{ $y }]: common = + .recover-drawing = 図面の復元 + .restore-it-as-a-recovered-tab-or-discard-the-copy = 復元タブとして開くか、コピーを破棄してください。 + .name-has-an-autosave-copy-newer-than-the-last-save = "__ocs_fmt_0__" には最終保存より新しい自動保存コピーがあります。 + .autosave-copy-could-not-be-stored = 自動保存コピーを保存できませんでした: __ocs_fmt_0__ + .restored-from-the-autosave-copy = "__ocs_fmt_0__" を自動保存コピーから復元しました + .autosave-copy-could-not-be-read = 自動保存コピーを読み込めませんでした: __ocs_fmt_0__ .jogged-radius = 折れ曲がり半径 .none-use-shortcuts-set-key-command = (なし — SHORTCUTS SET を使用) .alignment-indented-label-title-case = 配置: diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 05151a434..ba320253b 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -260,6 +260,12 @@ command-copy = } | 배열 | Enter=완료 | 베이스 { $x },{ $y }]: common = + .recover-drawing = 도면 복구 + .restore-it-as-a-recovered-tab-or-discard-the-copy = 복구된 탭으로 열거나 사본을 삭제하세요. + .name-has-an-autosave-copy-newer-than-the-last-save = "__ocs_fmt_0__"에 마지막 저장보다 새로운 자동 저장 사본이 있습니다. + .autosave-copy-could-not-be-stored = 자동 저장 사본을 저장할 수 없습니다: __ocs_fmt_0__ + .restored-from-the-autosave-copy = "__ocs_fmt_0__"을(를) 자동 저장 사본에서 복원했습니다 + .autosave-copy-could-not-be-read = 자동 저장 사본을 읽을 수 없습니다: __ocs_fmt_0__ .jogged-radius = 꺾인 반지름 .none-use-shortcuts-set-key-command = (없음 — 사용: SHORTCUTS SET ) .alignment-indented-label-title-case =정렬: diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 600feb8a4..a62f2992a 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -261,6 +261,12 @@ command-copy = } | Reeks | Enter=gereed | basis { $x },{ $y }]: common = + .recover-drawing = Tekening herstellen + .restore-it-as-a-recovered-tab-or-discard-the-copy = Herstel het als een hersteld tabblad of verwijder de kopie. + .name-has-an-autosave-copy-newer-than-the-last-save = "__ocs_fmt_0__" heeft een automatisch opgeslagen kopie die nieuwer is dan de laatste opslag. + .autosave-copy-could-not-be-stored = Automatische opslagkopie kon niet worden bewaard: __ocs_fmt_0__ + .restored-from-the-autosave-copy = "__ocs_fmt_0__" is hersteld uit de automatische opslagkopie + .autosave-copy-could-not-be-read = Automatische opslagkopie kon niet worden gelezen: __ocs_fmt_0__ .jogged-radius = Geknikte straal .none-use-shortcuts-set-key-command = (geen gebruik: SHORTCUTS SET ) .alignment-indented-label-title-case = Uitlijning: diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index b8d7b9e80..baa5b629c 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -260,6 +260,12 @@ command-copy = } | Tablica | Enter=gotowe | baza { $x },{ $y }]: common = + .recover-drawing = Odzyskaj rysunek + .restore-it-as-a-recovered-tab-or-discard-the-copy = Przywróć go jako odzyskaną kartę lub odrzuć kopię. + .name-has-an-autosave-copy-newer-than-the-last-save = "__ocs_fmt_0__" ma kopię autozapisu nowszą niż ostatni zapis. + .autosave-copy-could-not-be-stored = Nie można zapisać kopii autozapisu: __ocs_fmt_0__ + .restored-from-the-autosave-copy = Przywrócono "__ocs_fmt_0__" z kopii autozapisu + .autosave-copy-could-not-be-read = Nie można odczytać kopii autozapisu: __ocs_fmt_0__ .jogged-radius = Promień łamany .none-use-shortcuts-set-key-command =(brak — użyj: SHORTCUTS SET ) .alignment-indented-label-title-case = Wyrównanie: diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index f5a0507f4..cecec8c54 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -261,6 +261,12 @@ command-copy = } | Matriz | Enter=concluir | base { $x },{ $y }]: common = + .recover-drawing = Recuperar desenho + .restore-it-as-a-recovered-tab-or-discard-the-copy = Restaure-o como uma guia recuperada ou descarte a cópia. + .name-has-an-autosave-copy-newer-than-the-last-save = "__ocs_fmt_0__" tem uma cópia de salvamento automático mais recente que o último salvamento. + .autosave-copy-could-not-be-stored = Não foi possível armazenar a cópia de salvamento automático: __ocs_fmt_0__ + .restored-from-the-autosave-copy = "__ocs_fmt_0__" restaurado da cópia de salvamento automático + .autosave-copy-could-not-be-read = Não foi possível ler a cópia de salvamento automático: __ocs_fmt_0__ .jogged-radius = Raio com desvio .none-use-shortcuts-set-key-command = (nenhum — use: SHORTCUTS SET ) .alignment-indented-label-title-case = Alinhamento: diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 6dfa348e3..03a19b8a7 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -264,6 +264,12 @@ command-copy = } | Массив | Enter=готово | база { $x },{ $y }]: common = + .recover-drawing = Восстановление чертежа + .restore-it-as-a-recovered-tab-or-discard-the-copy = Восстановите его на отдельной вкладке или удалите копию. + .name-has-an-autosave-copy-newer-than-the-last-save = У «__ocs_fmt_0__» есть копия автосохранения новее последнего сохранения. + .autosave-copy-could-not-be-stored = Не удалось сохранить копию автосохранения: __ocs_fmt_0__ + .restored-from-the-autosave-copy = «__ocs_fmt_0__» восстановлен из копии автосохранения + .autosave-copy-could-not-be-read = Не удалось прочитать копию автосохранения: __ocs_fmt_0__ .jogged-radius = Радиус с изломом .none-use-shortcuts-set-key-command = (нет — используйте: SHORTCUTS SET ) .alignment-indented-label-title-case = Выравнивание: diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index f0538ada9..1142082cb 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -246,6 +246,12 @@ command-copy = .target = COPY Hedef noktayı belirtin [şimdiye kadar { $count } kopya | Dizi | Enter=tamam | temel { $x },{ $y }]: common = + .recover-drawing = Çizimi Kurtar + .restore-it-as-a-recovered-tab-or-discard-the-copy = Onu kurtarılan bir sekme olarak geri yükleyin veya kopyayı atın. + .name-has-an-autosave-copy-newer-than-the-last-save = "__ocs_fmt_0__" son kayıttan daha yeni bir otomatik kayıt kopyasına sahip. + .autosave-copy-could-not-be-stored = Otomatik kayıt kopyası depolanamadı: __ocs_fmt_0__ + .restored-from-the-autosave-copy = "__ocs_fmt_0__" otomatik kayıt kopyasından geri yüklendi + .autosave-copy-could-not-be-read = Otomatik kayıt kopyası okunamadı: __ocs_fmt_0__ .none-use-shortcuts-set-key-command = (none – use: SHORTCUTS SET ) .alignment-indented-label-title-case = Adapt: .background-fill-enabled = Arka plan doldurma etkinleştir etkinleştirildi diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index ba629a629..9da568111 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -248,6 +248,12 @@ command-copy = .target = COPY 指定目标点 [已复制 { $count } 个 | 阵列 | 回车=完成 | 基点 { $x },{ $y }]: common = + .recover-drawing = 恢复图形 + .restore-it-as-a-recovered-tab-or-discard-the-copy = 将其恢复为已恢复的标签页,或放弃该副本。 + .name-has-an-autosave-copy-newer-than-the-last-save = "__ocs_fmt_0__" 有一份比上次保存更新的自动保存副本。 + .autosave-copy-could-not-be-stored = 无法存储自动保存副本: __ocs_fmt_0__ + .restored-from-the-autosave-copy = 已从自动保存副本恢复 "__ocs_fmt_0__" + .autosave-copy-could-not-be-read = 无法读取自动保存副本: __ocs_fmt_0__ .jogged-radius = 折弯半径 .none-use-shortcuts-set-key-command = (无 — 使用:SHORTCUTS SET ) .alignment-indented-label-title-case = 对齐: diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 86825c1c0..49ad5652f 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -260,6 +260,12 @@ command-copy = } | 陣列 | Enter=完成 | 基準點 { $x },{ $y }]: common = + .recover-drawing = 恢復圖面 + .restore-it-as-a-recovered-tab-or-discard-the-copy = 將其恢復為已恢復的分頁,或捨棄該副本。 + .name-has-an-autosave-copy-newer-than-the-last-save = "__ocs_fmt_0__" 有一份比上次儲存更新的自動儲存副本。 + .autosave-copy-could-not-be-stored = 無法儲存自動儲存副本: __ocs_fmt_0__ + .restored-from-the-autosave-copy = 已從自動儲存副本恢復 "__ocs_fmt_0__" + .autosave-copy-could-not-be-read = 無法讀取自動儲存副本: __ocs_fmt_0__ .jogged-radius = 折彎半徑 .none-use-shortcuts-set-key-command = (無 — 使用:SHORTCUTS SET ) .alignment-indented-label-title-case = 對齊: diff --git a/src/app/mod.rs b/src/app/mod.rs index 7c188eb0b..01068c88f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -724,6 +724,18 @@ pub(super) struct OpenCADStudio { pending_native_thumbnail_save: Option, #[cfg(target_arch = "wasm32")] pending_web_thumbnail_save: Option, + /// One web autosave round is running; ticks that fire while it lasts are + /// dropped instead of queued (the next interval retries). + #[cfg(target_arch = "wasm32")] + web_autosave_in_flight: bool, + /// Web autosave storage already reported a failure — stay quiet until a + /// round succeeds again, so a full quota does not spam every tick. + #[cfg(target_arch = "wasm32")] + web_autosave_warned: bool, + /// Crash-recovery snapshots found at boot (newest first) that the restore + /// prompt still has to offer, one at a time. + #[cfg(target_arch = "wasm32")] + web_autosave_pending: Vec, // ── Floating panel windows ──────────────────────────────────────────── /// Active `iced_aw` colour picker: destination plus its initial colour. color_pick_target: Option<(ColorPickTarget, AcadColor)>, @@ -1770,6 +1782,11 @@ pub enum ModalKind { /// means and what usually fixes it. Queued once per verdict; the status /// bar's ⚠ pill reopens it. GpuWarning, + /// Web: a crash-recovery autosave snapshot newer than the drawing's last + /// save awaits a restore/discard decision. Distinct from the corrupt-file + /// `RecoveryPrompt` — nothing is wrong with the drawing itself. + #[cfg(target_arch = "wasm32")] + AutosaveRestore, } /// A property group controlled by a layer state's restore mask. @@ -1994,6 +2011,23 @@ pub enum Message { /// Web: an asynchronous OPFS copy written after Save is ready for recents. #[cfg(target_arch = "wasm32")] WebRecentStored(Result), + /// Web: the boot scan finished — these autosave snapshots are newer than + /// the drawings' last saved copies (newest first). + #[cfg(target_arch = "wasm32")] + WebAutosavesScanned(Result, String>), + /// Web: restore the offered autosave snapshot into a recovered tab. + #[cfg(target_arch = "wasm32")] + WebAutosaveRestore, + /// Web: delete the offered autosave snapshot (and offer the next, if any). + #[cfg(target_arch = "wasm32")] + WebAutosaveDiscard, + /// Web: the offered snapshot's bytes are in (and the OPFS copy consumed); + /// open them as `name`. + #[cfg(target_arch = "wasm32")] + WebAutosaveBytes(String, Result, String>), + /// Web: one autosave round finished for all dirty tabs (serially). + #[cfg(target_arch = "wasm32")] + WebAutosaveSnapshotFinished(Result<(), String>), SaveFile, SaveAs, // ── Custom Save-As dialog ───────────────────────────────────────────── @@ -3729,6 +3763,12 @@ impl OpenCADStudio { pending_native_thumbnail_save: None, #[cfg(target_arch = "wasm32")] pending_web_thumbnail_save: None, + #[cfg(target_arch = "wasm32")] + web_autosave_in_flight: false, + #[cfg(target_arch = "wasm32")] + web_autosave_warned: false, + #[cfg(target_arch = "wasm32")] + web_autosave_pending: Vec::new(), color_pick_target: None, color_picker_tab: ColorPickerTab::Index, recent_colors: Vec::new(), @@ -4258,9 +4298,23 @@ impl OpenCADStudio { Message::DiscussionsFetched, ); let thumbs_fetch = s.refresh_recent_thumbs(); + // Crash-recovery scan: OPFS snapshots newer than a drawing's last + // saved copy come back as a restore prompt (stale ones are deleted). + let autosave_scan = Task::perform( + crate::io::web_autosave::scan(), + Message::WebAutosavesScanned, + ); ( s, - Task::batch([focus, fonts, patrons, videos, discussions, thumbs_fetch]), + Task::batch([ + focus, + fonts, + patrons, + videos, + discussions, + thumbs_fetch, + autosave_scan, + ]), ) } } @@ -4320,6 +4374,16 @@ pub fn run_web() -> iced::Result { // winit never emits DroppedFile on the web, so the document drag & drop // listeners are installed here and feed the control poll (#344). crate::io::web_drop::install(); + // Best-effort, fire-and-forget: ask the browser not to evict the origin's + // storage under pressure — settings (localStorage) and the OPFS recent + // copies / autosave snapshots live there. A denial changes nothing. + wasm_bindgen_futures::spawn_local(async { + if let Some(window) = web_sys::window() { + if let Ok(promise) = window.navigator().storage().persist() { + let _ = wasm_bindgen_futures::JsFuture::from(promise).await; + } + } + }); iced::application( OpenCADStudio::boot_web, OpenCADStudio::update, diff --git a/src/app/startup.rs b/src/app/startup.rs index 6530a6744..0d375d623 100644 --- a/src/app/startup.rs +++ b/src/app/startup.rs @@ -24,6 +24,20 @@ impl OpenCADStudio { } } + /// Queue the web crash-recovery prompt behind whatever startup modal is + /// already showing (donation, GPU warning…); the after-message hook in + /// `update` surfaces it as soon as the slot is free. + #[cfg(target_arch = "wasm32")] + pub(super) fn queue_autosave_restore_prompt(&mut self) { + if !self + .pending_startup_modals + .contains(&ModalKind::AutosaveRestore) + { + self.pending_startup_modals.push_back(ModalKind::AutosaveRestore); + } + self.show_next_startup_modal(); + } + /// Notice when the scene is drawn by a software rasterizer, or not at /// all, and say so everywhere the user might look: the command line, /// the log, a one-time popup, and a status-bar pill that stays. diff --git a/src/app/update/file.rs b/src/app/update/file.rs index f57c3f07d..35b5c3bfd 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1906,6 +1906,14 @@ impl OpenCADStudio { } let mut recent_task = Task::none(); + // The name the autosave snapshot for this tab is keyed by — capture it + // before the save re-points `current_path` at the download name. + let autosave_key = self.tabs[i] + .current_path + .as_ref() + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| self.tabs[i].tab_display_name()); let saved = match crate::io::save_to_bytes(&self.tabs[i].scene.document, &ext, version) { Ok(bytes) => { crate::sys::download_bytes(&filename, &bytes); @@ -1926,6 +1934,24 @@ impl OpenCADStudio { }, Message::WebRecentStored, ); + // A clean save supersedes any crash-recovery snapshot for this + // drawing — drop the old key and the new one so stale copies + // don't resurface in the next boot scan. + let new_key = self.tabs[i] + .current_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(); + recent_task = Task::batch([ + recent_task, + Task::perform( + async move { + let _ = crate::io::web_autosave::remove(&autosave_key).await; + let _ = crate::io::web_autosave::remove(&new_key).await; + }, + |_| Message::Noop, + ), + ]); self.command_line .push_output(crate::tf!("Saved: {filename}").as_ref()); true @@ -2871,11 +2897,185 @@ impl OpenCADStudio { Task::batch(tasks) } + /// Periodic autosave on the web (SAVETIME): serialize every dirty tab to + /// an OPFS snapshot (`io::web_autosave`), the browser counterpart of the + /// native `.sv$` copy — best-effort and non-destructive: no download, no + /// recents update, dirty flag untouched. Snapshots run serially in one + /// round; while it lasts (or a web save's thumbnail capture is pending) + /// further ticks are dropped, not queued. #[cfg(target_arch = "wasm32")] pub(super) fn on_autosave(&mut self) -> Task { + if self.web_autosave_in_flight || self.pending_web_thumbnail_save.is_some() { + return Task::none(); + } + let mut pending = Vec::new(); + for i in 0..self.tabs.len() { + if !self.tabs[i].dirty { + continue; + } + // Same pre-serialize state sync the web Save flow performs + // (on_save_dialog_confirm): stamp the live camera, vport and + // sysvars into the document so the snapshot round-trips. + self.sync_view_state_for_save(i); + sync_annotation_scale_header(&mut self.tabs[i].scene); + self.stamp_header_sysvars(i); + self.sync_solid_models_for_save(i); + self.tabs[i].scene.sync_native_parametric_graph(); + let tab = &self.tabs[i]; + // Key by the drawing's display name (the recent copy's name), so + // the boot scan can compare the two; untitled tabs use the tab + // title, which is what Save would download as. + let name = tab + .current_path + .as_ref() + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| tab.tab_display_name()); + let is_dxf = crate::io::source_is_dxf(tab.current_path.as_deref(), &tab.scene.document); + let ext = if is_dxf { "dxf" } else { "dwg" }.to_string(); + let version = tab.scene.document.version; + pending.push((name, ext, version, tab.scene.document.clone())); + } + if pending.is_empty() { + return Task::none(); + } + self.web_autosave_in_flight = true; + Task::perform( + async move { + let mut failure = None; + for (name, ext, version, doc) in pending { + // Autosave exists to recover geometry, not pretty recents: + // no thumbnail capture, just bytes into OPFS. + match crate::io::save_to_bytes(&doc, &ext, version) { + Ok(bytes) => { + if let Err(error) = + crate::io::web_autosave::snapshot(&name, &bytes, None).await + { + failure = Some(error); + } + } + Err(error) => failure = Some(error), + } + } + failure.map_or(Ok(()), Err) + }, + Message::WebAutosaveSnapshotFinished, + ) + } + + /// One web autosave round finished. Silent on success; on failure one + /// command-line warning until a round succeeds again. + #[cfg(target_arch = "wasm32")] + pub(super) fn on_web_autosave_finished(&mut self, result: Result<(), String>) -> Task { + self.web_autosave_in_flight = false; + match result { + Ok(()) => self.web_autosave_warned = false, + Err(error) => { + if !self.web_autosave_warned { + self.web_autosave_warned = true; + self.command_line.push_warning( + crate::tf!("Autosave copy could not be stored: {error}").as_ref(), + ); + } + } + } Task::none() } + /// The boot scan decided which snapshots are recoverable; queue the + /// restore prompt (behind any already-queued startup modal). + #[cfg(target_arch = "wasm32")] + pub(super) fn on_web_autosaves_scanned( + &mut self, + result: Result, String>, + ) -> Task { + let Ok(mut entries) = result else { + // Best-effort feature: no storage access, no prompt. + return Task::none(); + }; + entries.sort_by(|a, b| b.modified_ms.total_cmp(&a.modified_ms)); + self.web_autosave_pending = entries; + self.queue_autosave_restore_prompt(); + Task::none() + } + + /// Restore the offered snapshot: read (and consume) the OPFS copy, then + /// open it as a recovered tab through the standard web-bytes open flow. + #[cfg(target_arch = "wasm32")] + pub(super) fn on_web_autosave_restore(&mut self) -> Task { + let Some(entry) = self.web_autosave_pending.first().cloned() else { + self.close_active_modal(); + return Task::none(); + }; + self.web_autosave_pending.remove(0); + self.close_active_modal(); + let open_name = crate::io::web_autosave::recovered_name(&entry.name); + let key = entry.name; + Task::perform( + async move { + let bytes = crate::io::web_autosave::read(&key).await?; + // The snapshot is spent: the recovered tab (and its recent + // copy) replaces it from here on. + let _ = crate::io::web_autosave::remove(&key).await; + Ok(bytes) + }, + move |result| Message::WebAutosaveBytes(open_name, result), + ) + } + + /// Discard the offered snapshot; the prompt stays up for the next one. + #[cfg(target_arch = "wasm32")] + pub(super) fn on_web_autosave_discard(&mut self) -> Task { + if let Some(entry) = self.web_autosave_pending.first().cloned() { + self.web_autosave_pending.remove(0); + wasm_bindgen_futures::spawn_local(async move { + let _ = crate::io::web_autosave::remove(&entry.name).await; + }); + } + if self.web_autosave_pending.is_empty() { + self.close_active_modal(); + } + Task::none() + } + + /// The offered snapshot's bytes arrived (or did not): open the recovered + /// tab, or warn and move on to the next snapshot. + #[cfg(target_arch = "wasm32")] + pub(super) fn on_web_autosave_bytes( + &mut self, + open_name: String, + result: Result, String>, + ) -> Task { + match result { + Ok(bytes) => { + self.command_line.push_output( + crate::tf!("Restored \"{}\" from the autosave copy", open_name).as_ref(), + ); + let task = self.update(Message::WebFileDropped(open_name, bytes)); + // Further snapshots wait behind this one; queue the prompt + // again now that an open is in flight, so it reappears when + // the recovered drawing finishes opening. + if !self.web_autosave_pending.is_empty() + && !self + .pending_startup_modals + .contains(&crate::app::ModalKind::AutosaveRestore) + { + self.pending_startup_modals + .push_back(crate::app::ModalKind::AutosaveRestore); + } + task + } + Err(error) => { + self.command_line + .push_warning(crate::tf!("Autosave copy could not be read: {error}").as_ref()); + if !self.web_autosave_pending.is_empty() { + self.queue_autosave_restore_prompt(); + } + Task::none() + } + } + } + /// Delete the `.sv$` autosave recovery files for all open drawings. They /// exist only to survive a crash, so a clean save or exit removes them. pub(in crate::app) fn cleanup_autosaves(&self) { @@ -2883,6 +3083,21 @@ impl OpenCADStudio { for i in 0..self.tabs.len() { let _ = std::fs::remove_file(self.autosave_target(i)); } + // Web counterpart: drop each open tab's OPFS snapshot. A tab close is + // not an exit, so this only runs on the explicit exit path; anything + // the teardown race leaves behind is caught by the next boot scan. + #[cfg(target_arch = "wasm32")] + for tab in &self.tabs { + let name = tab + .current_path + .as_ref() + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| tab.tab_display_name()); + wasm_bindgen_futures::spawn_local(async move { + let _ = crate::io::web_autosave::remove(&name).await; + }); + } } /// Remove the autosave recovery files, then quit the application. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index c7e28007f..e32786300 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -959,6 +959,17 @@ impl OpenCADStudio { } }, + #[cfg(target_arch = "wasm32")] + Message::WebAutosavesScanned(result) => self.on_web_autosaves_scanned(result), + #[cfg(target_arch = "wasm32")] + Message::WebAutosaveRestore => self.on_web_autosave_restore(), + #[cfg(target_arch = "wasm32")] + Message::WebAutosaveDiscard => self.on_web_autosave_discard(), + #[cfg(target_arch = "wasm32")] + Message::WebAutosaveBytes(name, result) => self.on_web_autosave_bytes(name, result), + #[cfg(target_arch = "wasm32")] + Message::WebAutosaveSnapshotFinished(result) => self.on_web_autosave_finished(result), + Message::ImagePick => { Task::perform(crate::io::pick_image_file(), Message::ImagePickResult) } @@ -7054,6 +7065,12 @@ impl OpenCADStudio { if self.active_modal == Some(super::ModalKind::RecoveryPrompt) { return self.update(Message::RecoveryDecline); } + // Closing the crash-recovery prompt (✕ or Escape) discards + // the offered snapshot — otherwise it would requeue itself. + #[cfg(target_arch = "wasm32")] + if self.active_modal == Some(super::ModalKind::AutosaveRestore) { + return self.update(Message::WebAutosaveDiscard); + } // Closing the shortcut editor with un-applied rows needs an // explicit discard; a second close attempt (or the overlay's // Discard button) goes through. diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 0e3c7fdae..5d5adce2b 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2476,9 +2476,9 @@ impl OpenCADStudio { iced::time::every(std::time::Duration::from_millis(300)).map(|_| Message::PollWebFonts); #[cfg(not(target_arch = "wasm32"))] let web_fonts = Subscription::none(); - // Periodic autosave to a `.sv$` recovery file (native only). SAVETIME is - // the interval in minutes; 0 disables it. - #[cfg(not(target_arch = "wasm32"))] + // Periodic autosave (SAVETIME): a `.sv$` recovery copy beside the file + // on native, an OPFS snapshot on web. SAVETIME is the interval in + // minutes; 0 disables it. let autosave = if self.savetime_min > 0 { iced::time::every(std::time::Duration::from_secs( self.savetime_min as u64 * 60, @@ -2487,8 +2487,6 @@ impl OpenCADStudio { } else { Subscription::none() }; - #[cfg(target_arch = "wasm32")] - let autosave = Subscription::none(); // Drawings handed over by a second launch (single instance). Inert in a // process that lost the port election, so it costs nothing there. #[cfg(not(target_arch = "wasm32"))] diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 8df1e0324..0e7ce7c7b 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -54,6 +54,8 @@ impl OpenCADStudio { Some(K::SaveDialog) => crate::tr!("modal", "save-drawing-as"), Some(K::Recovery) => crate::tr!("modal", "recovery-report"), Some(K::RecoveryPrompt) => crate::tr!("modal", "recovery-prompt"), + #[cfg(target_arch = "wasm32")] + Some(K::AutosaveRestore) => crate::t!("Recover Drawing").into_owned(), Some(K::GpuWarning) => crate::tr!("gpu", "title"), None => String::new(), } @@ -1589,6 +1591,17 @@ impl OpenCADStudio { ) }) } + #[cfg(target_arch = "wasm32")] + super::super::ModalKind::AutosaveRestore => { + let name = self + .web_autosave_pending + .first() + .map(|entry| entry.name.clone()) + .unwrap_or_default(); + automatic_flow(ex, |flow| { + autosave_restore_dialog_window(&name, flow) + }) + } }) } } @@ -1761,6 +1774,40 @@ fn unsaved_changes_dialog_window( .into() } +/// Web crash-recovery prompt: an autosave snapshot of `name` is newer than +/// the drawing's last save. Restore opens it as a recovered tab; Discard +/// deletes the snapshot. Mirrors the unsaved-changes dialog's layout. +#[cfg(target_arch = "wasm32")] +fn autosave_restore_dialog_window( + name: &str, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'static, Message> { + container( + column![ + text(crate::tf!( + "\"{name}\" has an autosave copy newer than the last save." + )) + .size(14), + iced::widget::Space::new().height(4), + text(t!("Restore it as a recovered tab, or discard the copy.")) + .size(12) + .style(dialog_muted_text_style), + iced::widget::Space::new().height(20), + row![ + dialog_button(t!("Restore"), Message::WebAutosaveRestore, button::primary), + iced::widget::Space::new().width(8), + dialog_button(t!("Discard"), Message::WebAutosaveDiscard, button::danger), + ], + ] + .spacing(0), + ) + .style(dialog_body_style) + .center_x(sizing.width) + .center_y(sizing.height) + .padding([24, 28]) + .into() +} + #[cfg(not(target_arch = "wasm32"))] fn file_in_use_dialog_window( path: &str, diff --git a/src/io/mod.rs b/src/io/mod.rs index a5ea8cb12..152226277 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -25,6 +25,9 @@ pub mod thumbnail; mod web_worker; #[cfg(target_arch = "wasm32")] pub(crate) mod web_recent; +// Registered on every target so the pure helpers (and their tests) compile on +// the native host; the OPFS half inside is wasm-only. +pub(crate) mod web_autosave; #[cfg(target_arch = "wasm32")] pub(crate) mod web_drop; diff --git a/src/io/web_autosave.rs b/src/io/web_autosave.rs new file mode 100644 index 000000000..a5ed5deec --- /dev/null +++ b/src/io/web_autosave.rs @@ -0,0 +1,222 @@ +//! Crash-recovery autosave snapshots for the web build. +//! +//! The native editor writes a `.sv$` copy beside each dirty drawing; the +//! browser has no writable paths, so the same SAVETIME tick instead serializes +//! every dirty tab into a dedicated OPFS directory (`opencadstudio-autosave`, +//! separate from the recents cache). Snapshots are keyed by the drawing's +//! display name — one snapshot per name, overwritten each round — and are +//! offered for restore at boot when newer than the drawing's last explicit +//! save (the OPFS recent copy of the same name). +//! +//! OPFS entry names carry only the FNV hash of the drawing name (like +//! `web_recent`), so a small JSON index maps hashes back to names. The index +//! is written before its snapshot: a crash between the two writes leaves a +//! name without bytes, which `list` filters out, rather than an orphan +//! snapshot nobody can name. + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsCast; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_futures::JsFuture; + +#[cfg(target_arch = "wasm32")] +const AUTOSAVE_DIRECTORY: &str = "opencadstudio-autosave"; +#[cfg(target_arch = "wasm32")] +const INDEX_KEY: &str = "index.json"; + +/// One snapshot as `list`/`scan` report it. `modified_ms` is the OPFS file's +/// `lastModified` (milliseconds since the Unix epoch), used to decide whether +/// the snapshot outranks the drawing's last explicit save. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +#[derive(Debug, Clone)] +pub struct WebAutosaveEntry { + pub name: String, + pub size: u64, + pub modified_ms: f64, +} + +/// A snapshot is worth offering when it is strictly newer than the last +/// explicit save, or when no saved copy exists at all (the drawing was never +/// saved — or its recent copy was evicted — so the snapshot is all there is). +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +pub(crate) fn newer_than_saved(autosave_ms: f64, saved_ms: Option) -> bool { + match saved_ms { + Some(saved_ms) => autosave_ms > saved_ms, + None => true, + } +} + +/// The name a restored drawing carries as a tab and download: the drawing's +/// stem plus " (recovered)" and its own extension (DWG when the stored name +/// has none, matching how autosave serializes untitled tabs). The extension +/// must survive intact because the web parser picks the format from it. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +pub(crate) fn recovered_name(drawing: &str) -> String { + let path = std::path::Path::new(drawing); + let stem = path + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .filter(|stem| !stem.is_empty()) + .unwrap_or_else(|| drawing.to_string()); + let ext = path + .extension() + .map(|ext| ext.to_string_lossy().into_owned()) + .filter(|ext| !ext.is_empty()) + .unwrap_or_else(|| "dwg".to_string()); + format!("{stem} (recovered).{ext}") +} + +/// Overwrite (or create) the snapshot for `name`. The optional thumbnail is +/// written alongside as `.thumb`; passing `None` removes any stale one. +#[cfg(target_arch = "wasm32")] +pub async fn snapshot(name: &str, bytes: &[u8], thumbnail: Option<&[u8]>) -> Result<(), String> { + let directory = autosave_directory(true).await?; + let mut names = read_index(&directory).await; + if !names.iter().any(|stored| stored == name) { + names.push(name.to_string()); + write_index(&directory, &names).await?; + } + super::web_recent::write_entry(&directory, &snapshot_key(name), bytes).await?; + match thumbnail { + Some(thumbnail) => { + super::web_recent::write_entry(&directory, &thumbnail_key(name), thumbnail).await?; + } + None => { + let _ = JsFuture::from(directory.remove_entry(&thumbnail_key(name))).await; + } + } + Ok(()) +} + +/// All live snapshots. Index rows whose file is gone (crash between writes, +/// partial eviction) are skipped rather than reported. +#[cfg(target_arch = "wasm32")] +pub async fn list() -> Result, String> { + let directory = autosave_directory(false).await?; + let mut entries = Vec::new(); + for name in read_index(&directory).await { + let Ok(file) = super::web_recent::get_file(&directory, &snapshot_key(&name)).await else { + continue; + }; + entries.push(WebAutosaveEntry { + name, + size: file.size() as u64, + modified_ms: file.last_modified(), + }); + } + Ok(entries) +} + +#[cfg(target_arch = "wasm32")] +pub async fn read(name: &str) -> Result, String> { + let directory = autosave_directory(false).await?; + super::web_recent::read_entry(&directory, &snapshot_key(name)).await +} + +#[cfg(target_arch = "wasm32")] +pub async fn remove(name: &str) -> Result<(), String> { + let directory = autosave_directory(false).await?; + let drawing = JsFuture::from(directory.remove_entry(&snapshot_key(name))) + .await + .map_err(super::web_recent::js_error); + let _ = JsFuture::from(directory.remove_entry(&thumbnail_key(name))).await; + let mut names = read_index(&directory).await; + let before = names.len(); + names.retain(|stored| stored != name); + if names.len() != before { + // Best-effort: a stale row only costs a skipped stat in `list`. + let _ = write_index(&directory, &names).await; + } + drawing.map(|_| ()) +} + +/// Boot scan: keep the snapshots worth offering (newer than the drawing's +/// last explicit save, newest first) and silently delete the stale ones — a +/// snapshot older than the saved copy recovers nothing and would otherwise +/// pile up across sessions. +#[cfg(target_arch = "wasm32")] +pub async fn scan() -> Result, String> { + let mut recoverable = Vec::new(); + for entry in list().await? { + let saved_ms = super::web_recent::modified_ms(&entry.name).await; + if newer_than_saved(entry.modified_ms, saved_ms) { + recoverable.push(entry); + } else { + let _ = remove(&entry.name).await; + } + } + recoverable.sort_by(|a, b| b.modified_ms.total_cmp(&a.modified_ms)); + Ok(recoverable) +} + +#[cfg(target_arch = "wasm32")] +fn snapshot_key(name: &str) -> String { + format!("{}.sv$", super::web_recent::name_hash(name)) +} + +#[cfg(target_arch = "wasm32")] +fn thumbnail_key(name: &str) -> String { + format!("{}.thumb", super::web_recent::name_hash(name)) +} + +#[cfg(target_arch = "wasm32")] +async fn read_index(directory: &web_sys::FileSystemDirectoryHandle) -> Vec { + super::web_recent::read_entry(directory, INDEX_KEY) + .await + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default() +} + +#[cfg(target_arch = "wasm32")] +async fn write_index( + directory: &web_sys::FileSystemDirectoryHandle, + names: &[String], +) -> Result<(), String> { + let bytes = serde_json::to_vec(names).map_err(|error| error.to_string())?; + super::web_recent::write_entry(directory, INDEX_KEY, &bytes).await +} + +#[cfg(target_arch = "wasm32")] +async fn autosave_directory(create: bool) -> Result { + let window = web_sys::window().ok_or_else(|| "browser window unavailable".to_string())?; + let root = JsFuture::from(window.navigator().storage().get_directory()) + .await + .map_err(super::web_recent::js_error)? + .dyn_into::() + .map_err(super::web_recent::js_error)?; + let options = web_sys::FileSystemGetDirectoryOptions::new(); + options.set_create(create); + JsFuture::from(root.get_directory_handle_with_options(AUTOSAVE_DIRECTORY, &options)) + .await + .map_err(super::web_recent::js_error)? + .dyn_into::() + .map_err(super::web_recent::js_error) +} + +/// Pure logic only — the OPFS side needs a browser and is exercised by hand +/// (and by the boot scan on every web start). +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_snapshot_must_outrank_the_last_save_to_be_offered() { + assert!(newer_than_saved(2000.0, Some(1000.0))); + // Equal timestamps are the same save round — not newer. + assert!(!newer_than_saved(1000.0, Some(1000.0))); + assert!(!newer_than_saved(500.0, Some(1000.0))); + // No saved copy at all: the snapshot is the only survivor. + assert!(newer_than_saved(1.0, None)); + } + + #[test] + fn recovered_names_keep_a_parseable_extension() { + assert_eq!(recovered_name("plan.dwg"), "plan (recovered).dwg"); + assert_eq!(recovered_name("sketch.dxf"), "sketch (recovered).dxf"); + assert_eq!(recovered_name("Drawing1"), "Drawing1 (recovered).dwg"); + // Multi-dot names keep everything but the final extension in the stem. + assert_eq!(recovered_name("a.b.dwg"), "a.b (recovered).dwg"); + assert_eq!(recovered_name(""), " (recovered).dwg"); + } +} diff --git a/src/io/web_recent.rs b/src/io/web_recent.rs index a91722f2f..185322ccd 100644 --- a/src/io/web_recent.rs +++ b/src/io/web_recent.rs @@ -111,7 +111,9 @@ pub async fn store_open( } } -async fn write_entry( +/// Shared with the autosave snapshot store (`web_autosave`), which keeps its +/// own OPFS directory but the same entry format. +pub(crate) async fn write_entry( directory: &web_sys::FileSystemDirectoryHandle, key: &str, bytes: &[u8], @@ -139,7 +141,7 @@ pub async fn read(name: &str) -> Result, String> { read_entry(&directory, &cache_key(name)).await } -async fn read_entry( +pub(crate) async fn read_entry( directory: &web_sys::FileSystemDirectoryHandle, key: &str, ) -> Result, String> { @@ -147,7 +149,7 @@ async fn read_entry( read_blob(file.as_ref()).await } -async fn get_file( +pub(crate) async fn get_file( directory: &web_sys::FileSystemDirectoryHandle, key: &str, ) -> Result { @@ -213,7 +215,9 @@ fn thumbnail_key(name: &str) -> String { format!("{}.thumb", name_hash(name)) } -fn name_hash(name: &str) -> String { +/// Shared with the autosave snapshot store so both caches derive identical +/// keys for the same drawing name. +pub(crate) fn name_hash(name: &str) -> String { let mut hash = 0xcbf29ce484222325_u64; for byte in name.as_bytes() { hash ^= u64::from(*byte); @@ -256,8 +260,27 @@ fn decode_thumbnail(bytes: Vec) -> Option { }) } -fn js_error(value: wasm_bindgen::JsValue) -> String { +/// Shared with the autosave snapshot store (`web_autosave`). +pub(crate) fn js_error(value: wasm_bindgen::JsValue) -> String { value .as_string() .unwrap_or_else(|| format!("browser storage error: {value:?}")) } + +/// Last-modified time (ms since the Unix epoch) of a drawing's cached copy, +/// or `None` when no copy exists. The autosave boot scan compares it against +/// the crash-recovery snapshot of the same name. +pub(crate) async fn modified_ms(name: &str) -> Option { + let directory = recent_directory(false).await.ok()?; + let handle = JsFuture::from(directory.get_file_handle(&cache_key(name))) + .await + .ok()? + .dyn_into::() + .ok()?; + let file = JsFuture::from(handle.get_file()) + .await + .ok()? + .dyn_into::() + .ok()?; + Some(file.last_modified()) +} diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index fa1dd3a69..417b39367 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -4602,6 +4602,12 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Enter a GitHub URL or repository in owner/repo format." => Some(("common", "enter-a-github-url-or-repository-in-owner-repo-format")), "DWGUNITS Convert drawing to:" => Some(("common", "dwgunits-convert-drawing-to")), "Opened plot preview." => Some(("common", "opened-plot-preview")), + "Recover Drawing" => Some(("common", "recover-drawing")), + "Restore it as a recovered tab, or discard the copy." => Some(("common", "restore-it-as-a-recovered-tab-or-discard-the-copy")), + "\"{name}\" has an autosave copy newer than the last save." => Some(("common", "name-has-an-autosave-copy-newer-than-the-last-save")), + "Autosave copy could not be stored: {error}" => Some(("common", "autosave-copy-could-not-be-stored")), + "Restored \"{}\" from the autosave copy" => Some(("common", "restored-from-the-autosave-copy")), + "Autosave copy could not be read: {error}" => Some(("common", "autosave-copy-could-not-be-read")), _ => None, } }