forked from 1b-agent/opencadstudio-upstream
web: autosave snapshots with crash-recovery restore, persistent storage
The web build had no autosave: on_autosave was a no-op and the subscription was native-only. Both gates are gone; every savetime tick now serializes each dirty tab (same pre-serialize sync as the web save path, no thumbnail, serialized serially off the critical paint path) into a dedicated opencadstudio-autosave OPFS directory, keyed by drawing name, without touching the dirty flag, downloads, or recents. On boot the web app scans that directory and offers any snapshot newer than the last saved copy in a localized Recover Drawing modal (Restore opens it as "name (recovered).dwg" through the standard open pipeline; Discard removes it; Escape discards). Saving or closing a drawing clears its snapshot; the boot scan also garbage- collects stale entries. run_web now requests navigator.storage.persist so settings and OPFS data survive browser eviction.
This commit is contained in:
1 parent
ad60be11ed
commit
101d167ebc
31 files changed
+746
-11
No files matched your search
@@ -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 <key> <command>)
|
||||
.alignment-indented-label-title-case = المحاذاة:
|
||||
|
||||
@@ -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 = Подравняване:
|
||||
|
||||
@@ -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 <key> <command>)
|
||||
.alignment-indented-label-title-case = Zarovnání:
|
||||
|
||||
@@ -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 <key> <command>)
|
||||
.alignment-indented-label-title-case = Ausrichtung:
|
||||
|
||||
@@ -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 <πλήκτρο> <εντολή>)
|
||||
|
||||
@@ -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 <key> <command>)
|
||||
.alignment-indented-label-title-case = Alignment:
|
||||
.background-fill-enabled = Background fill enabled
|
||||
|
||||
@@ -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 <tecla> <comando>)
|
||||
.alignment-indented-label-title-case = Alineación:
|
||||
|
||||
@@ -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 <key> <command>)
|
||||
.alignment-indented-label-title-case = Tasaus:
|
||||
|
||||
@@ -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 <key> <command>)
|
||||
.alignment-indented-label-title-case = Alignement :
|
||||
|
||||
@@ -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 <key> <command>)
|
||||
.alignment-indented-label-title-case = संरेखण:
|
||||
|
||||
@@ -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 <key> <command>)
|
||||
.alignment-indented-label-title-case = Igazítás:
|
||||
|
||||
@@ -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 <key> <command>)
|
||||
.alignment-indented-label-title-case = Allineamento:
|
||||
|
||||
@@ -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 <key> <command> を使用)
|
||||
.alignment-indented-label-title-case = 配置:
|
||||
|
||||
@@ -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 <key> <command>)
|
||||
.alignment-indented-label-title-case =정렬:
|
||||
|
||||
@@ -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 <toets> <opdracht>)
|
||||
.alignment-indented-label-title-case = Uitlijning:
|
||||
|
||||
@@ -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 <key> <command>)
|
||||
.alignment-indented-label-title-case = Wyrównanie:
|
||||
|
||||
@@ -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 <tecla> <comando>)
|
||||
.alignment-indented-label-title-case = Alinhamento:
|
||||
|
||||
@@ -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 <key> <command>)
|
||||
.alignment-indented-label-title-case = Выравнивание:
|
||||
|
||||
@@ -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 <key> <command>)
|
||||
.alignment-indented-label-title-case = Adapt:
|
||||
.background-fill-enabled = Arka plan doldurma etkinleştir etkinleştirildi
|
||||
|
||||
@@ -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 <key> <command>)
|
||||
.alignment-indented-label-title-case = 对齐:
|
||||
|
||||
@@ -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 <key> <command>)
|
||||
.alignment-indented-label-title-case = 對齊:
|
||||
|
||||
+65
-1
@@ -724,6 +724,18 @@ pub(super) struct OpenCADStudio {
|
||||
pending_native_thumbnail_save: Option<PendingNativeThumbnailSave>,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pending_web_thumbnail_save: Option<PendingWebThumbnailSave>,
|
||||
/// 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<crate::io::web_autosave::WebAutosaveEntry>,
|
||||
// ── 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<PathBuf, String>),
|
||||
/// Web: the boot scan finished — these autosave snapshots are newer than
|
||||
/// the drawings' last saved copies (newest first).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
WebAutosavesScanned(Result<Vec<crate::io::web_autosave::WebAutosaveEntry>, 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<Vec<u8>, 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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Message> {
|
||||
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<Message> {
|
||||
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<Vec<crate::io::web_autosave::WebAutosaveEntry>, String>,
|
||||
) -> Task<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Vec<u8>, String>,
|
||||
) -> Task<Message> {
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-5
@@ -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"))]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<f64>) -> 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<Vec<WebAutosaveEntry>, 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<Vec<u8>, 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<Vec<WebAutosaveEntry>, 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<String> {
|
||||
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<web_sys::FileSystemDirectoryHandle, String> {
|
||||
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::<web_sys::FileSystemDirectoryHandle>()
|
||||
.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::<web_sys::FileSystemDirectoryHandle>()
|
||||
.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");
|
||||
}
|
||||
}
|
||||
+28
-5
@@ -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<Vec<u8>, 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<Vec<u8>, 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<web_sys::File, String> {
|
||||
@@ -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<u8>) -> Option<Thumbnail> {
|
||||
})
|
||||
}
|
||||
|
||||
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<f64> {
|
||||
let directory = recent_directory(false).await.ok()?;
|
||||
let handle = JsFuture::from(directory.get_file_handle(&cache_key(name)))
|
||||
.await
|
||||
.ok()?
|
||||
.dyn_into::<web_sys::FileSystemFileHandle>()
|
||||
.ok()?;
|
||||
let file = JsFuture::from(handle.get_file())
|
||||
.await
|
||||
.ok()?
|
||||
.dyn_into::<web_sys::File>()
|
||||
.ok()?;
|
||||
Some(file.last_modified())
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user